|
| 1 | +/* |
| 2 | +Toolbox Aid |
| 3 | +David Quesenberry |
| 4 | +06/02/2026 |
| 5 | +workspaceRuntimeContract.js |
| 6 | +*/ |
| 7 | + |
| 8 | +export const WORKSPACE_RUNTIME_CONTRACT_ID = "gamefoundrystudio.workspace.runtime-only"; |
| 9 | +export const WORKSPACE_RUNTIME_CONTRACT_VERSION = "1.0.0"; |
| 10 | + |
| 11 | +export const WORKSPACE_RUNTIME_FIELDS = Object.freeze({ |
| 12 | + ACTIVE_PROJECT_ID: "activeProjectId", |
| 13 | + ACTIVE_TOOL_ID: "activeToolId", |
| 14 | + ACTIVE_TOOL_STATE_ID: "activeToolStateId", |
| 15 | + DIRTY: "dirty", |
| 16 | + RECOVERY_AVAILABLE: "recoveryAvailable", |
| 17 | + RECOVERY_TOOL_STATE_ID: "recoveryToolStateId", |
| 18 | + ACTIVE_PALETTE_CONTEXT: "activePaletteContext", |
| 19 | + FLOW_STATE: "flowState", |
| 20 | +}); |
| 21 | + |
| 22 | +export const WORKSPACE_RUNTIME_FIELD_LIST = Object.freeze([ |
| 23 | + WORKSPACE_RUNTIME_FIELDS.ACTIVE_PROJECT_ID, |
| 24 | + WORKSPACE_RUNTIME_FIELDS.ACTIVE_TOOL_ID, |
| 25 | + WORKSPACE_RUNTIME_FIELDS.ACTIVE_TOOL_STATE_ID, |
| 26 | + WORKSPACE_RUNTIME_FIELDS.DIRTY, |
| 27 | + WORKSPACE_RUNTIME_FIELDS.RECOVERY_AVAILABLE, |
| 28 | + WORKSPACE_RUNTIME_FIELDS.RECOVERY_TOOL_STATE_ID, |
| 29 | + WORKSPACE_RUNTIME_FIELDS.ACTIVE_PALETTE_CONTEXT, |
| 30 | + WORKSPACE_RUNTIME_FIELDS.FLOW_STATE, |
| 31 | +]); |
| 32 | + |
| 33 | +export const WORKSPACE_RUNTIME_FLOW_STATES = Object.freeze({ |
| 34 | + IDLE: "idle", |
| 35 | + OPENING: "opening", |
| 36 | + OPEN: "open", |
| 37 | + CLOSING: "closing", |
| 38 | + SAVING: "saving", |
| 39 | +}); |
| 40 | + |
| 41 | +export const WORKSPACE_RUNTIME_FLOW_STATE_LIST = Object.freeze([ |
| 42 | + WORKSPACE_RUNTIME_FLOW_STATES.IDLE, |
| 43 | + WORKSPACE_RUNTIME_FLOW_STATES.OPENING, |
| 44 | + WORKSPACE_RUNTIME_FLOW_STATES.OPEN, |
| 45 | + WORKSPACE_RUNTIME_FLOW_STATES.CLOSING, |
| 46 | + WORKSPACE_RUNTIME_FLOW_STATES.SAVING, |
| 47 | +]); |
| 48 | + |
| 49 | +export const WORKSPACE_RUNTIME_RULES = Object.freeze({ |
| 50 | + RUNTIME_ONLY: true, |
| 51 | + DOES_NOT_PERSIST_TOOL_PAYLOADS: true, |
| 52 | + DOES_NOT_OWN_SAVED_TOOL_STATE: true, |
| 53 | + DOES_NOT_DUPLICATE_PROJECT_STORAGE: true, |
| 54 | + DOES_NOT_DUPLICATE_TOOL_STATE_STORAGE: true, |
| 55 | + RECOVERY_POINTS_TO_TOOL_STATE: true, |
| 56 | +}); |
| 57 | + |
| 58 | +export const WORKSPACE_DATA_OWNERSHIP = Object.freeze({ |
| 59 | + PROJECT: "persisted-db-container", |
| 60 | + TOOL_STATE: "persisted-db-record-for-one-tool", |
| 61 | + WORKSPACE: "runtime-session-ui-context", |
| 62 | + MANIFEST: "portable-export-import-format", |
| 63 | +}); |
| 64 | + |
| 65 | +export const WORKSPACE_SAVED_EDITING_SOURCE = "tool-state"; |
| 66 | + |
| 67 | +export const WORKSPACE_RUNTIME_FORBIDDEN_FIELDS = Object.freeze([ |
| 68 | + "payload", |
| 69 | + "payloadJson", |
| 70 | + "toolPayload", |
| 71 | + "toolPayloads", |
| 72 | + "savedToolState", |
| 73 | + "savedToolStates", |
| 74 | + "toolState", |
| 75 | + "toolStates", |
| 76 | + "toolStateRecords", |
| 77 | + "project", |
| 78 | + "projects", |
| 79 | + "projectRecords", |
| 80 | + "manifest", |
| 81 | + "databaseId", |
| 82 | + "recoveryState", |
| 83 | + "savedState", |
| 84 | + "swatches", |
| 85 | + "colors", |
| 86 | + "palettePayload", |
| 87 | +]); |
| 88 | + |
| 89 | +export const WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS = Object.freeze({ |
| 90 | + PALETTE_ID: "paletteId", |
| 91 | + SOURCE_TOOL_ID: "sourceToolId", |
| 92 | + SOURCE_TOOL_STATE_ID: "sourceToolStateId", |
| 93 | + VERSION: "version", |
| 94 | +}); |
| 95 | + |
| 96 | +export const WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELD_LIST = Object.freeze([ |
| 97 | + WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.PALETTE_ID, |
| 98 | + WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.SOURCE_TOOL_ID, |
| 99 | + WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.SOURCE_TOOL_STATE_ID, |
| 100 | + WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.VERSION, |
| 101 | +]); |
| 102 | + |
| 103 | +export const WORKSPACE_RUNTIME_ERRORS = Object.freeze({ |
| 104 | + CONTEXT_INVALID: "WORKSPACE_RUNTIME_CONTEXT_INVALID", |
| 105 | + FIELD_NOT_ALLOWED: "WORKSPACE_RUNTIME_FIELD_NOT_ALLOWED", |
| 106 | + REFERENCE_INVALID: "WORKSPACE_RUNTIME_REFERENCE_INVALID", |
| 107 | + DIRTY_INVALID: "WORKSPACE_RUNTIME_DIRTY_INVALID", |
| 108 | + RECOVERY_AVAILABLE_INVALID: "WORKSPACE_RUNTIME_RECOVERY_AVAILABLE_INVALID", |
| 109 | + RECOVERY_TOOL_STATE_REQUIRED: "WORKSPACE_RUNTIME_RECOVERY_TOOL_STATE_REQUIRED", |
| 110 | + FLOW_STATE_INVALID: "WORKSPACE_RUNTIME_FLOW_STATE_INVALID", |
| 111 | + ACTIVE_PALETTE_CONTEXT_INVALID: "WORKSPACE_RUNTIME_ACTIVE_PALETTE_CONTEXT_INVALID", |
| 112 | +}); |
| 113 | + |
| 114 | +export function isWorkspaceRuntimeFlowState(value) { |
| 115 | + return WORKSPACE_RUNTIME_FLOW_STATE_LIST.includes(value); |
| 116 | +} |
| 117 | + |
| 118 | +export function isWorkspaceRuntimeReference(value) { |
| 119 | + return value === undefined || value === null || hasNonEmptyString(value); |
| 120 | +} |
| 121 | + |
| 122 | +export function canWorkspacePersistToolPayloadData() { |
| 123 | + return false; |
| 124 | +} |
| 125 | + |
| 126 | +export function canWorkspaceCreateSavedToolStateRecord() { |
| 127 | + return false; |
| 128 | +} |
| 129 | + |
| 130 | +export function isToolStateSavedEditingSource(value) { |
| 131 | + return value === WORKSPACE_SAVED_EDITING_SOURCE; |
| 132 | +} |
| 133 | + |
| 134 | +export function workspaceRecoveryTargetsToolState(workspaceContext) { |
| 135 | + if (!workspaceContext?.recoveryAvailable) { |
| 136 | + return true; |
| 137 | + } |
| 138 | + |
| 139 | + return hasNonEmptyString(workspaceContext.recoveryToolStateId) |
| 140 | + && !hasForbiddenWorkspaceField(workspaceContext); |
| 141 | +} |
| 142 | + |
| 143 | +export function validateWorkspaceRuntimeContract(workspaceContext = {}) { |
| 144 | + const errors = []; |
| 145 | + |
| 146 | + if (!workspaceContext || typeof workspaceContext !== "object" || Array.isArray(workspaceContext)) { |
| 147 | + errors.push(createContractError( |
| 148 | + WORKSPACE_RUNTIME_ERRORS.CONTEXT_INVALID, |
| 149 | + "Workspace runtime context must be an object.", |
| 150 | + "workspace" |
| 151 | + )); |
| 152 | + |
| 153 | + return Object.freeze({ |
| 154 | + valid: false, |
| 155 | + errors: Object.freeze(errors), |
| 156 | + }); |
| 157 | + } |
| 158 | + |
| 159 | + collectForbiddenFieldErrors(workspaceContext, errors); |
| 160 | + validateRuntimeReference(workspaceContext, WORKSPACE_RUNTIME_FIELDS.ACTIVE_PROJECT_ID, errors); |
| 161 | + validateRuntimeReference(workspaceContext, WORKSPACE_RUNTIME_FIELDS.ACTIVE_TOOL_ID, errors); |
| 162 | + validateRuntimeReference(workspaceContext, WORKSPACE_RUNTIME_FIELDS.ACTIVE_TOOL_STATE_ID, errors); |
| 163 | + validateRuntimeReference(workspaceContext, WORKSPACE_RUNTIME_FIELDS.RECOVERY_TOOL_STATE_ID, errors); |
| 164 | + |
| 165 | + if (workspaceContext.dirty !== undefined && typeof workspaceContext.dirty !== "boolean") { |
| 166 | + errors.push(createContractError( |
| 167 | + WORKSPACE_RUNTIME_ERRORS.DIRTY_INVALID, |
| 168 | + "Workspace dirty status must be a runtime boolean when provided.", |
| 169 | + WORKSPACE_RUNTIME_FIELDS.DIRTY |
| 170 | + )); |
| 171 | + } |
| 172 | + |
| 173 | + if (workspaceContext.recoveryAvailable !== undefined && typeof workspaceContext.recoveryAvailable !== "boolean") { |
| 174 | + errors.push(createContractError( |
| 175 | + WORKSPACE_RUNTIME_ERRORS.RECOVERY_AVAILABLE_INVALID, |
| 176 | + "Workspace recovery availability must be a runtime boolean when provided.", |
| 177 | + WORKSPACE_RUNTIME_FIELDS.RECOVERY_AVAILABLE |
| 178 | + )); |
| 179 | + } |
| 180 | + |
| 181 | + if (workspaceContext.recoveryAvailable === true && !hasNonEmptyString(workspaceContext.recoveryToolStateId)) { |
| 182 | + errors.push(createContractError( |
| 183 | + WORKSPACE_RUNTIME_ERRORS.RECOVERY_TOOL_STATE_REQUIRED, |
| 184 | + "Workspace recovery must point to a tool state recovery reference.", |
| 185 | + WORKSPACE_RUNTIME_FIELDS.RECOVERY_TOOL_STATE_ID |
| 186 | + )); |
| 187 | + } |
| 188 | + |
| 189 | + if (workspaceContext.flowState !== undefined && !isWorkspaceRuntimeFlowState(workspaceContext.flowState)) { |
| 190 | + errors.push(createContractError( |
| 191 | + WORKSPACE_RUNTIME_ERRORS.FLOW_STATE_INVALID, |
| 192 | + "Workspace flow state must be an allowed runtime flow state.", |
| 193 | + WORKSPACE_RUNTIME_FIELDS.FLOW_STATE |
| 194 | + )); |
| 195 | + } |
| 196 | + |
| 197 | + validateActivePaletteContext(workspaceContext.activePaletteContext, errors); |
| 198 | + |
| 199 | + return Object.freeze({ |
| 200 | + valid: errors.length === 0, |
| 201 | + errors: Object.freeze(errors), |
| 202 | + }); |
| 203 | +} |
| 204 | + |
| 205 | +export function getWorkspaceRuntimeReferences(workspaceContext = {}) { |
| 206 | + return Object.freeze({ |
| 207 | + activeProjectId: normalizeReference(workspaceContext.activeProjectId), |
| 208 | + activeToolId: normalizeReference(workspaceContext.activeToolId), |
| 209 | + activeToolStateId: normalizeReference(workspaceContext.activeToolStateId), |
| 210 | + recoveryToolStateId: normalizeReference(workspaceContext.recoveryToolStateId), |
| 211 | + }); |
| 212 | +} |
| 213 | + |
| 214 | +function validateRuntimeReference(workspaceContext, fieldName, errors) { |
| 215 | + if (!isWorkspaceRuntimeReference(workspaceContext[fieldName])) { |
| 216 | + errors.push(createContractError( |
| 217 | + WORKSPACE_RUNTIME_ERRORS.REFERENCE_INVALID, |
| 218 | + "Workspace runtime references must be non-empty strings when provided.", |
| 219 | + fieldName |
| 220 | + )); |
| 221 | + } |
| 222 | +} |
| 223 | + |
| 224 | +function validateActivePaletteContext(activePaletteContext, errors) { |
| 225 | + if (activePaletteContext === undefined || activePaletteContext === null) { |
| 226 | + return; |
| 227 | + } |
| 228 | + |
| 229 | + if (typeof activePaletteContext !== "object" || Array.isArray(activePaletteContext)) { |
| 230 | + errors.push(createContractError( |
| 231 | + WORKSPACE_RUNTIME_ERRORS.ACTIVE_PALETTE_CONTEXT_INVALID, |
| 232 | + "Workspace active palette context must be an object when provided.", |
| 233 | + WORKSPACE_RUNTIME_FIELDS.ACTIVE_PALETTE_CONTEXT |
| 234 | + )); |
| 235 | + return; |
| 236 | + } |
| 237 | + |
| 238 | + collectForbiddenFieldErrors( |
| 239 | + activePaletteContext, |
| 240 | + errors, |
| 241 | + WORKSPACE_RUNTIME_FIELDS.ACTIVE_PALETTE_CONTEXT |
| 242 | + ); |
| 243 | + |
| 244 | + for (const fieldName of [ |
| 245 | + WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.PALETTE_ID, |
| 246 | + WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.SOURCE_TOOL_ID, |
| 247 | + WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.SOURCE_TOOL_STATE_ID, |
| 248 | + ]) { |
| 249 | + if (!isWorkspaceRuntimeReference(activePaletteContext[fieldName])) { |
| 250 | + errors.push(createContractError( |
| 251 | + WORKSPACE_RUNTIME_ERRORS.ACTIVE_PALETTE_CONTEXT_INVALID, |
| 252 | + "Workspace active palette context references must be non-empty strings when provided.", |
| 253 | + `${WORKSPACE_RUNTIME_FIELDS.ACTIVE_PALETTE_CONTEXT}.${fieldName}` |
| 254 | + )); |
| 255 | + } |
| 256 | + } |
| 257 | + |
| 258 | + if (activePaletteContext.version !== undefined && !isPositiveInteger(activePaletteContext.version)) { |
| 259 | + errors.push(createContractError( |
| 260 | + WORKSPACE_RUNTIME_ERRORS.ACTIVE_PALETTE_CONTEXT_INVALID, |
| 261 | + "Workspace active palette context version must be a positive integer when provided.", |
| 262 | + `${WORKSPACE_RUNTIME_FIELDS.ACTIVE_PALETTE_CONTEXT}.${WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.VERSION}` |
| 263 | + )); |
| 264 | + } |
| 265 | +} |
| 266 | + |
| 267 | +function collectForbiddenFieldErrors(record, errors, pathPrefix = "") { |
| 268 | + for (const fieldName of WORKSPACE_RUNTIME_FORBIDDEN_FIELDS) { |
| 269 | + if (Object.hasOwn(record, fieldName)) { |
| 270 | + errors.push(createContractError( |
| 271 | + WORKSPACE_RUNTIME_ERRORS.FIELD_NOT_ALLOWED, |
| 272 | + "Workspace runtime context must not persist project, tool state, manifest, or tool payload data.", |
| 273 | + pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName |
| 274 | + )); |
| 275 | + } |
| 276 | + } |
| 277 | +} |
| 278 | + |
| 279 | +function hasForbiddenWorkspaceField(record) { |
| 280 | + if (!record || typeof record !== "object" || Array.isArray(record)) { |
| 281 | + return false; |
| 282 | + } |
| 283 | + |
| 284 | + return WORKSPACE_RUNTIME_FORBIDDEN_FIELDS.some((fieldName) => Object.hasOwn(record, fieldName)); |
| 285 | +} |
| 286 | + |
| 287 | +function hasNonEmptyString(value) { |
| 288 | + return typeof value === "string" && value.trim().length > 0; |
| 289 | +} |
| 290 | + |
| 291 | +function isPositiveInteger(value) { |
| 292 | + return Number.isInteger(value) && value >= 1; |
| 293 | +} |
| 294 | + |
| 295 | +function normalizeReference(value) { |
| 296 | + return hasNonEmptyString(value) ? value : null; |
| 297 | +} |
| 298 | + |
| 299 | +function createContractError(code, message, path) { |
| 300 | + return Object.freeze({ code, message, path }); |
| 301 | +} |
0 commit comments