@@ -19,7 +19,13 @@ import {
1919} from "./projectContract.js" ;
2020
2121export const TOOL_STATE_CONTRACT_ID = "gamefoundrystudio.tool-state.lifecycle" ;
22- export const TOOL_STATE_CONTRACT_VERSION = "1.0.0" ;
22+ export const TOOL_STATE_CONTRACT_VERSION = "1.1.0" ;
23+
24+ export const TOOL_STATE_SLOTS = Object . freeze ( {
25+ CURRENT_SAVED_STATE : "current-saved-state" ,
26+ RECOVERY_STATE : "recovery-state" ,
27+ VERSION_HISTORY : "version-history" ,
28+ } ) ;
2329
2430export const TOOL_STATE_FIELDS = Object . freeze ( {
2531 TOOL_STATE_ID : "toolStateId" ,
@@ -71,6 +77,40 @@ export const TOOL_STATE_RELATIONSHIP_LIST = Object.freeze([
7177 TOOL_STATE_RELATIONSHIPS . MAY_BE_EXPORTED ,
7278] ) ;
7379
80+ export const TOOL_STATE_RECOVERY_ACTIONS = Object . freeze ( {
81+ RESUME : "resume" ,
82+ OPEN : "open" ,
83+ DISCARD : "discard" ,
84+ PROMOTE : "promote" ,
85+ } ) ;
86+
87+ export const TOOL_STATE_STARTUP_ACTION_LIST = Object . freeze ( [
88+ TOOL_STATE_RECOVERY_ACTIONS . RESUME ,
89+ TOOL_STATE_RECOVERY_ACTIONS . OPEN ,
90+ ] ) ;
91+
92+ export const TOOL_STATE_RECOVERY_AGE = Object . freeze ( {
93+ NEWER_THAN_SAVED : "newer-than-saved" ,
94+ OLDER_THAN_SAVED : "older-than-saved" ,
95+ SAME_AS_SAVED : "same-as-saved" ,
96+ WITHOUT_SAVED_STATE : "without-saved-state" ,
97+ } ) ;
98+
99+ export const TOOL_STATE_RECOVERY_RULES = Object . freeze ( {
100+ SEPARATE_FROM_SAVED_STATE : true ,
101+ TEMPORARY : true ,
102+ NEVER_OVERWRITES_SAVED_STATE_AUTOMATICALLY : true ,
103+ USER_SELECTED : true ,
104+ MAY_BE_AUTOSAVED : true ,
105+ MAY_BE_DISCARDED : true ,
106+ MAY_BE_PROMOTED_TO_SAVED_STATE : true ,
107+ NEVER_AUTO_LOADED : true ,
108+ NEVER_AUTO_APPLIED : true ,
109+ REMAINS_AVAILABLE_UNTIL_SAVED_OR_DISCARDED : true ,
110+ REQUIRES_TIMESTAMP : true ,
111+ IDENTIFIES_TOOL_AND_PROJECT : true ,
112+ } ) ;
113+
74114export const TOOL_STATE_PORTABLE_EXPORT_FIELDS = Object . freeze ( [
75115 TOOL_STATE_FIELDS . TOOL_STATE_ID ,
76116 TOOL_STATE_FIELDS . TOOL_TYPE ,
@@ -95,6 +135,12 @@ export const TOOL_STATE_CONTRACT_ERRORS = Object.freeze({
95135 RELATIONSHIP_INVALID : "TOOL_STATE_RELATIONSHIP_INVALID" ,
96136 ASSET_REF_INVALID : "TOOL_STATE_ASSET_REF_INVALID" ,
97137 PORTABLE_EXPORT_INVALID : "TOOL_STATE_PORTABLE_EXPORT_INVALID" ,
138+ RECOVERY_OWNER_REQUIRED : "TOOL_STATE_RECOVERY_OWNER_REQUIRED" ,
139+ RECOVERY_PROJECT_REQUIRED : "TOOL_STATE_RECOVERY_PROJECT_REQUIRED" ,
140+ RECOVERY_TOOL_TYPE_REQUIRED : "TOOL_STATE_RECOVERY_TOOL_TYPE_REQUIRED" ,
141+ RECOVERY_TIMESTAMP_REQUIRED : "TOOL_STATE_RECOVERY_TIMESTAMP_REQUIRED" ,
142+ RECOVERY_TIMESTAMP_INVALID : "TOOL_STATE_RECOVERY_TIMESTAMP_INVALID" ,
143+ RECOVERY_PROJECT_MISMATCH : "TOOL_STATE_RECOVERY_PROJECT_MISMATCH" ,
98144} ) ;
99145
100146export function isToolStateStatus ( value ) {
@@ -369,10 +415,194 @@ export function validatePortableToolStateExport(portableExport) {
369415 } ) ;
370416}
371417
418+ export function validateToolStateRecoveryContract ( {
419+ savedState = null ,
420+ recoveryState = null ,
421+ } = { } ) {
422+ const errors = [ ] ;
423+
424+ if ( ! recoveryState ) {
425+ return Object . freeze ( {
426+ valid : true ,
427+ errors : Object . freeze ( errors ) ,
428+ } ) ;
429+ }
430+
431+ if ( ! hasNonEmptyString ( recoveryState . ownerId ) ) {
432+ errors . push ( createContractError (
433+ TOOL_STATE_CONTRACT_ERRORS . RECOVERY_OWNER_REQUIRED ,
434+ "Recovery state requires ownerId." ,
435+ "recoveryState.ownerId"
436+ ) ) ;
437+ }
438+
439+ if ( ! hasNonEmptyString ( recoveryState . projectId ) ) {
440+ errors . push ( createContractError (
441+ TOOL_STATE_CONTRACT_ERRORS . RECOVERY_PROJECT_REQUIRED ,
442+ "Recovery state requires projectId." ,
443+ "recoveryState.projectId"
444+ ) ) ;
445+ }
446+
447+ if ( ! hasNonEmptyString ( recoveryState . toolType ) ) {
448+ errors . push ( createContractError (
449+ TOOL_STATE_CONTRACT_ERRORS . RECOVERY_TOOL_TYPE_REQUIRED ,
450+ "Recovery state requires toolType." ,
451+ "recoveryState.toolType"
452+ ) ) ;
453+ }
454+
455+ if ( ! hasNonEmptyString ( recoveryState . timestamp ) ) {
456+ errors . push ( createContractError (
457+ TOOL_STATE_CONTRACT_ERRORS . RECOVERY_TIMESTAMP_REQUIRED ,
458+ "Recovery state requires timestamp." ,
459+ "recoveryState.timestamp"
460+ ) ) ;
461+ } else if ( ! isTimestamp ( recoveryState . timestamp ) ) {
462+ errors . push ( createContractError (
463+ TOOL_STATE_CONTRACT_ERRORS . RECOVERY_TIMESTAMP_INVALID ,
464+ "Recovery state timestamp must be a valid timestamp." ,
465+ "recoveryState.timestamp"
466+ ) ) ;
467+ }
468+
469+ if ( savedState && hasNonEmptyString ( savedState . projectId ) && recoveryState . projectId !== savedState . projectId ) {
470+ errors . push ( createContractError (
471+ TOOL_STATE_CONTRACT_ERRORS . RECOVERY_PROJECT_MISMATCH ,
472+ "Recovery state projectId must match saved state projectId." ,
473+ "recoveryState.projectId"
474+ ) ) ;
475+ }
476+
477+ return Object . freeze ( {
478+ valid : errors . length === 0 ,
479+ errors : Object . freeze ( errors ) ,
480+ } ) ;
481+ }
482+
483+ export function getToolStateRecoveryAge ( {
484+ savedState = null ,
485+ recoveryState = null ,
486+ } = { } ) {
487+ if ( ! savedState ) {
488+ return TOOL_STATE_RECOVERY_AGE . WITHOUT_SAVED_STATE ;
489+ }
490+
491+ const recoveryTime = Date . parse ( recoveryState ?. timestamp || "" ) ;
492+ const savedTime = Date . parse ( savedState . savedAt || savedState . timestamp || "" ) ;
493+
494+ if ( ! Number . isFinite ( recoveryTime ) || ! Number . isFinite ( savedTime ) ) {
495+ return TOOL_STATE_RECOVERY_AGE . WITHOUT_SAVED_STATE ;
496+ }
497+
498+ if ( recoveryTime > savedTime ) {
499+ return TOOL_STATE_RECOVERY_AGE . NEWER_THAN_SAVED ;
500+ }
501+
502+ if ( recoveryTime < savedTime ) {
503+ return TOOL_STATE_RECOVERY_AGE . OLDER_THAN_SAVED ;
504+ }
505+
506+ return TOOL_STATE_RECOVERY_AGE . SAME_AS_SAVED ;
507+ }
508+
509+ export function getToolStateStartupChoices ( {
510+ savedState = null ,
511+ recoveryState = null ,
512+ } = { } ) {
513+ if ( recoveryState ) {
514+ return Object . freeze ( [
515+ Object . freeze ( {
516+ action : TOOL_STATE_RECOVERY_ACTIONS . RESUME ,
517+ label : "Resume" ,
518+ stateSlot : TOOL_STATE_SLOTS . RECOVERY_STATE ,
519+ enabled : true ,
520+ } ) ,
521+ Object . freeze ( {
522+ action : TOOL_STATE_RECOVERY_ACTIONS . OPEN ,
523+ label : "Open" ,
524+ stateSlot : TOOL_STATE_SLOTS . CURRENT_SAVED_STATE ,
525+ enabled : Boolean ( savedState ) ,
526+ } ) ,
527+ ] ) ;
528+ }
529+
530+ return Object . freeze ( [
531+ Object . freeze ( {
532+ action : TOOL_STATE_RECOVERY_ACTIONS . OPEN ,
533+ label : "Open" ,
534+ stateSlot : TOOL_STATE_SLOTS . CURRENT_SAVED_STATE ,
535+ enabled : Boolean ( savedState ) ,
536+ } ) ,
537+ ] ) ;
538+ }
539+
540+ export function resolveToolStateStartupState ( context = { } ) {
541+ return Object . freeze ( {
542+ action : null ,
543+ stateSlot : null ,
544+ loadedState : null ,
545+ recoveryAvailable : Boolean ( context . recoveryState ) ,
546+ choices : getToolStateStartupChoices ( context ) ,
547+ } ) ;
548+ }
549+
550+ export function selectToolStateStartupAction ( context = { } , action ) {
551+ if ( action === TOOL_STATE_RECOVERY_ACTIONS . RESUME ) {
552+ return Object . freeze ( {
553+ action,
554+ stateSlot : TOOL_STATE_SLOTS . RECOVERY_STATE ,
555+ loadedState : cloneJsonData ( context . recoveryState ) ,
556+ savedState : cloneJsonData ( context . savedState ) ,
557+ recoveryState : cloneJsonData ( context . recoveryState ) ,
558+ } ) ;
559+ }
560+
561+ if ( action === TOOL_STATE_RECOVERY_ACTIONS . OPEN ) {
562+ return Object . freeze ( {
563+ action,
564+ stateSlot : TOOL_STATE_SLOTS . CURRENT_SAVED_STATE ,
565+ loadedState : cloneJsonData ( context . savedState ) ,
566+ savedState : cloneJsonData ( context . savedState ) ,
567+ recoveryState : cloneJsonData ( context . recoveryState ) ,
568+ } ) ;
569+ }
570+
571+ return Object . freeze ( {
572+ action : null ,
573+ stateSlot : null ,
574+ loadedState : null ,
575+ savedState : cloneJsonData ( context . savedState ) ,
576+ recoveryState : cloneJsonData ( context . recoveryState ) ,
577+ } ) ;
578+ }
579+
580+ export function discardToolStateRecovery ( context = { } ) {
581+ return Object . freeze ( {
582+ savedState : cloneJsonData ( context . savedState ) ,
583+ recoveryState : null ,
584+ versionHistory : cloneJsonData ( context . versionHistory ?? [ ] ) ,
585+ } ) ;
586+ }
587+
588+ export function promoteToolStateRecovery ( context = { } ) {
589+ const promotedSavedState = recoveryStateAsSavedState ( context . recoveryState ) ;
590+
591+ return Object . freeze ( {
592+ savedState : promotedSavedState ,
593+ recoveryState : null ,
594+ versionHistory : cloneJsonData ( context . versionHistory ?? [ ] ) ,
595+ } ) ;
596+ }
597+
372598function hasNonEmptyString ( value ) {
373599 return typeof value === "string" && value . trim ( ) . length > 0 ;
374600}
375601
602+ function isTimestamp ( value ) {
603+ return hasNonEmptyString ( value ) && Number . isFinite ( Date . parse ( value ) ) ;
604+ }
605+
376606function normalizeStringArray ( values ) {
377607 if ( ! Array . isArray ( values ) ) {
378608 return [ ] ;
@@ -385,6 +615,30 @@ function clonePortablePayload(payload) {
385615 return Object . freeze ( JSON . parse ( JSON . stringify ( payload ) ) ) ;
386616}
387617
618+ function cloneJsonData ( value ) {
619+ if ( value === undefined || value === null ) {
620+ return null ;
621+ }
622+
623+ return JSON . parse ( JSON . stringify ( value ) ) ;
624+ }
625+
626+ function recoveryStateAsSavedState ( recoveryState ) {
627+ if ( ! recoveryState ) {
628+ return null ;
629+ }
630+
631+ const savedState = cloneJsonData ( recoveryState ) ;
632+ delete savedState . autosaved ;
633+ delete savedState . temporary ;
634+ delete savedState . timestamp ;
635+
636+ return Object . freeze ( {
637+ ...savedState ,
638+ savedAt : recoveryState . timestamp ,
639+ } ) ;
640+ }
641+
388642function createContractError ( code , message , path ) {
389643 return Object . freeze ( { code, message, path } ) ;
390644}
0 commit comments