@forgeax/engine-pack 0.1.21 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +167 -48
  2. package/dist/__tests__/inventory-schema.test.d.ts +2 -0
  3. package/dist/__tests__/inventory-schema.test.d.ts.map +1 -0
  4. package/dist/__tests__/material-cook-receipt-layer-plan.unit.test.d.ts +2 -0
  5. package/dist/__tests__/material-cook-receipt-layer-plan.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/pack-authoring-gateway.unit.test.d.ts +2 -0
  7. package/dist/__tests__/pack-authoring-gateway.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/pack-authoring.unit.test.d.ts +2 -0
  9. package/dist/__tests__/pack-authoring.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/pack-scanner.unit.test.d.ts +2 -0
  11. package/dist/__tests__/pack-scanner.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/scanner-blacklist.test.d.ts +2 -0
  13. package/dist/__tests__/scanner-blacklist.test.d.ts.map +1 -0
  14. package/dist/build.d.ts +9 -4
  15. package/dist/build.d.ts.map +1 -1
  16. package/dist/build.mjs +3449 -897
  17. package/dist/build.mjs.map +1 -1
  18. package/dist/builtin.mjs +37 -4
  19. package/dist/builtin.mjs.map +1 -1
  20. package/dist/catalog-builder.d.ts.map +1 -1
  21. package/dist/cli-asset.d.ts.map +1 -1
  22. package/dist/cli-asset.mjs +1361 -845
  23. package/dist/cli-asset.mjs.map +1 -1
  24. package/dist/deriveAssetName.d.ts +11 -7
  25. package/dist/deriveAssetName.d.ts.map +1 -1
  26. package/dist/evidence/material-cook.d.ts +11 -0
  27. package/dist/evidence/material-cook.d.ts.map +1 -1
  28. package/dist/evidence/source-inventory.d.ts.map +1 -1
  29. package/dist/guid.d.ts +15 -0
  30. package/dist/guid.d.ts.map +1 -1
  31. package/dist/guid.mjs +53 -5
  32. package/dist/guid.mjs.map +1 -1
  33. package/dist/index.d.ts +4 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.mjs +1297 -562
  36. package/dist/index.mjs.map +1 -1
  37. package/dist/inventory/binding.d.ts +6 -0
  38. package/dist/inventory/binding.d.ts.map +1 -0
  39. package/dist/inventory/declaration.d.ts +24 -0
  40. package/dist/inventory/declaration.d.ts.map +1 -0
  41. package/dist/inventory/index.d.ts +3 -0
  42. package/dist/inventory/index.d.ts.map +1 -0
  43. package/dist/inventory/sync.d.ts +5 -0
  44. package/dist/inventory/sync.d.ts.map +1 -0
  45. package/dist/material-cook.d.ts +1 -1
  46. package/dist/material-cook.d.ts.map +1 -1
  47. package/dist/material-cook.mjs +50 -3
  48. package/dist/material-cook.mjs.map +1 -1
  49. package/dist/name.mjs +4 -7
  50. package/dist/name.mjs.map +1 -1
  51. package/dist/pack-authoring-node.d.ts +26 -0
  52. package/dist/pack-authoring-node.d.ts.map +1 -0
  53. package/dist/pack-authoring-node.mjs +11089 -0
  54. package/dist/pack-authoring-node.mjs.map +1 -0
  55. package/dist/pack-authoring.d.ts +230 -0
  56. package/dist/pack-authoring.d.ts.map +1 -0
  57. package/dist/pack-authoring.mjs +1137 -0
  58. package/dist/pack-authoring.mjs.map +1 -0
  59. package/dist/package-finalizer.d.ts +2 -20
  60. package/dist/package-finalizer.d.ts.map +1 -1
  61. package/dist/resolve-asset-source.d.ts +1 -3
  62. package/dist/resolve-asset-source.d.ts.map +1 -1
  63. package/dist/resolve-asset-source.mjs +6 -81
  64. package/dist/resolve-asset-source.mjs.map +1 -1
  65. package/dist/runtime.mjs +77 -7
  66. package/dist/runtime.mjs.map +1 -1
  67. package/dist/scanner.d.ts +18 -7
  68. package/dist/scanner.d.ts.map +1 -1
  69. package/dist/scanner.mjs +1164 -687
  70. package/dist/scanner.mjs.map +1 -1
  71. package/dist/schema-compiled.d.ts.map +1 -1
  72. package/dist/schema.mjs +79 -7
  73. package/dist/schema.mjs.map +1 -1
  74. package/dist/scriptable-pack-node.d.ts +7 -31
  75. package/dist/scriptable-pack-node.d.ts.map +1 -1
  76. package/dist/scriptable-pack-node.mjs +572 -1019
  77. package/dist/scriptable-pack-node.mjs.map +1 -1
  78. package/dist/scriptable-pack-worker.mjs +29 -11
  79. package/dist/scriptable-pack-worker.mjs.map +1 -1
  80. package/dist/scriptable-pack.d.ts +20 -532
  81. package/dist/scriptable-pack.d.ts.map +1 -1
  82. package/dist/scriptable-pack.mjs +1038 -527
  83. package/dist/scriptable-pack.mjs.map +1 -1
  84. package/package.json +2 -7
  85. package/schema/meta.schema.json +1 -1
  86. package/schema/pack.schema.json +76 -6
  87. package/src/__tests__/guid-collision.unit.test.ts +18 -9
  88. package/src/__tests__/inventory-schema.test.ts +109 -0
  89. package/src/__tests__/material-cook-receipt-layer-plan.unit.test.ts +16 -0
  90. package/src/__tests__/material-cook-schema.unit.test.ts +62 -0
  91. package/src/__tests__/pack-authoring-gateway.unit.test.ts +249 -0
  92. package/src/__tests__/pack-authoring.unit.test.ts +266 -0
  93. package/src/__tests__/pack-scanner.unit.test.ts +123 -0
  94. package/src/__tests__/pack.unit.test.ts +1 -238
  95. package/src/__tests__/package-finalizer.contract.test.ts +55 -0
  96. package/src/__tests__/resolve-asset-source.test.ts +15 -98
  97. package/src/__tests__/scanner-blacklist.test.ts +55 -0
  98. package/src/__tests__/scanner-inventory.contract.test.ts +11 -14
  99. package/src/__tests__/scanner-inventory.test.ts +23 -10
  100. package/src/__tests__/scriptable-pack-cli.integration.test.ts +4 -9
  101. package/src/__tests__/topology.unit.test.ts +23 -0
  102. package/src/build.ts +28 -18
  103. package/src/catalog-builder.ts +60 -33
  104. package/src/cli-asset.ts +68 -83
  105. package/src/deriveAssetName.ts +14 -13
  106. package/src/evidence/material-cook.ts +79 -3
  107. package/src/evidence/source-inventory.ts +34 -21
  108. package/src/guid.ts +65 -4
  109. package/src/index.ts +15 -11
  110. package/src/inventory/binding.ts +25 -0
  111. package/src/inventory/declaration.ts +168 -0
  112. package/src/inventory/index.ts +18 -0
  113. package/src/inventory/sync.ts +22 -0
  114. package/src/material-cook.ts +1 -0
  115. package/src/pack-authoring-node.ts +1966 -0
  116. package/src/pack-authoring.ts +1478 -0
  117. package/src/package-finalizer.ts +2 -42
  118. package/src/resolve-asset-source.ts +4 -76
  119. package/src/scanner.ts +236 -56
  120. package/src/schema/material-cook.schema.json +4 -1
  121. package/src/schema-compiled.ts +2 -0
  122. package/src/scriptable-pack-node.ts +108 -719
  123. package/src/scriptable-pack-worker.ts +48 -16
  124. package/src/scriptable-pack.ts +28 -939
  125. package/dist/.tsbuildinfo +0 -1
  126. package/dist/__tests__/load-asset-config.test.d.ts +0 -2
  127. package/dist/__tests__/load-asset-config.test.d.ts.map +0 -1
  128. package/dist/__tests__/scriptable-pack.test-d.d.ts +0 -2
  129. package/dist/__tests__/scriptable-pack.test-d.d.ts.map +0 -1
  130. package/dist/__tests__/scriptable-pack.unit.test.d.ts +0 -2
  131. package/dist/__tests__/scriptable-pack.unit.test.d.ts.map +0 -1
  132. package/dist/config.d.ts +0 -6
  133. package/dist/config.d.ts.map +0 -1
  134. package/dist/config.mjs +0 -29
  135. package/dist/config.mjs.map +0 -1
  136. package/src/__tests__/load-asset-config.test.ts +0 -121
  137. package/src/__tests__/scriptable-pack.test-d.ts +0 -131
  138. package/src/__tests__/scriptable-pack.unit.test.ts +0 -789
  139. package/src/config.ts +0 -36
@@ -0,0 +1,1966 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import type { AssetGuid as EngineAssetGuid, Result } from '@forgeax/engine-types';
5
+ import { err, ok } from '@forgeax/engine-types';
6
+ import { AssetGuid, isValidAssetGuidString, isValidPackSourceKey, PackageId } from './guid.js';
7
+ import {
8
+ type AnyScriptablePackDefinition,
9
+ createPackAuthoringGateway,
10
+ type DirectPackJsonAsset,
11
+ type PackAuthoringError,
12
+ type PackAuthoringErrorCode,
13
+ type PackAuthoringGatewayPort,
14
+ type PackAuthoringOperation,
15
+ type PackAuthoringOperationResult,
16
+ type PackAuthoringResolutionStatus,
17
+ type PackParameterDefinition,
18
+ type PackParameterInheritanceSubject,
19
+ parsePackSourceJson,
20
+ projectDirectPackJson,
21
+ type ResolvedPackParameterInheritance,
22
+ resolvePackParameterInheritance,
23
+ validatePackDefinition,
24
+ } from './pack-authoring.js';
25
+ import { type ScanInventory, scanInventory } from './scanner.js';
26
+
27
+ export interface PackAuthoringMaterializedAsset {
28
+ readonly packageId: string;
29
+ readonly sourceKey: string;
30
+ readonly guid: string;
31
+ readonly kind: string;
32
+ readonly sourcePath?: string;
33
+ readonly refs?: readonly string[];
34
+ readonly ready?: boolean;
35
+ readonly artifactsReady?: boolean;
36
+ }
37
+
38
+ export interface FileSystemPackAuthoringOptions {
39
+ readonly gameRoot: string;
40
+ readonly scanRoots?: readonly string[];
41
+ /** Current-generation rows only; this callback is never called by mutations. */
42
+ readonly materialized?: () =>
43
+ | readonly PackAuthoringMaterializedAsset[]
44
+ | Promise<readonly PackAuthoringMaterializedAsset[]>;
45
+ /** Build owner callback; the gateway itself does not own DDC or Catalog state. */
46
+ readonly rebuild?: (
47
+ sourcePath: string,
48
+ mode: 'rebuild' | 'cold-cook',
49
+ ) => Promise<Result<unknown, PackAuthoringError>>;
50
+ /** Injection point for hosts that already own a validated Source Index. */
51
+ readonly inventory?: () => Promise<Result<ScanInventory, unknown>>;
52
+ }
53
+
54
+ interface ConfinedPath {
55
+ readonly absolute: string;
56
+ readonly relative: string;
57
+ }
58
+
59
+ interface ProjectedDirectAsset extends DirectPackJsonAsset {
60
+ readonly guid: string;
61
+ readonly sourceKey: string;
62
+ }
63
+
64
+ interface SubjectBase {
65
+ readonly packageId: PackageId;
66
+ readonly sourcePath: string;
67
+ readonly relativePath: string;
68
+ }
69
+
70
+ interface SourceSubject extends SubjectBase {
71
+ readonly format: 'source';
72
+ readonly definition: AnyScriptablePackDefinition;
73
+ }
74
+
75
+ interface DirectSubject extends SubjectBase {
76
+ readonly format: 'direct';
77
+ readonly assets: readonly ProjectedDirectAsset[];
78
+ }
79
+
80
+ interface InstanceSubject extends SubjectBase {
81
+ readonly format: 'instance';
82
+ readonly parent: PackageId;
83
+ readonly values: Readonly<Record<string, unknown>>;
84
+ }
85
+
86
+ type Subject = SourceSubject | DirectSubject | InstanceSubject;
87
+
88
+ interface MaterializedAsset {
89
+ readonly packageId: string;
90
+ readonly sourceKey: string;
91
+ readonly guid: string;
92
+ readonly kind: string;
93
+ readonly sourcePath?: string;
94
+ readonly name?: string;
95
+ readonly refs?: readonly string[];
96
+ readonly ready: boolean;
97
+ }
98
+
99
+ type DirectAuthorAsset = Omit<MaterializedAsset, 'ready'>;
100
+
101
+ interface GatewaySnapshot {
102
+ readonly inventory: ScanInventory;
103
+ readonly subjects: ReadonlyMap<string, Subject>;
104
+ readonly directAssets: readonly DirectAuthorAsset[];
105
+ readonly materialized: readonly MaterializedAsset[];
106
+ readonly knownGuids: ReadonlySet<string>;
107
+ }
108
+
109
+ function isRecord(value: unknown): value is Record<string, unknown> {
110
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
111
+ }
112
+
113
+ function jsonValue(value: unknown): unknown {
114
+ if (value instanceof Uint8Array) return AssetGuid.format(value as EngineAssetGuid);
115
+ if (Array.isArray(value)) return value.map((item) => jsonValue(item));
116
+ if (isRecord(value)) {
117
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonValue(item)]));
118
+ }
119
+ return value;
120
+ }
121
+
122
+ function revisionOf(source: string): string {
123
+ return `sha256:${createHash('sha256').update(source).digest('hex')}`;
124
+ }
125
+
126
+ function makeError(
127
+ code: PackAuthoringErrorCode,
128
+ expected: string,
129
+ hint: string,
130
+ detail: Readonly<Record<string, unknown>> = {},
131
+ actual?: string,
132
+ ): PackAuthoringError {
133
+ return {
134
+ code,
135
+ expected,
136
+ hint,
137
+ ...(actual === undefined ? {} : { actual }),
138
+ detail,
139
+ };
140
+ }
141
+
142
+ function pathError(
143
+ code:
144
+ | 'pack-source-path-invalid'
145
+ | 'pack-source-not-found'
146
+ | 'pack-source-write-failed'
147
+ | 'pack-source-revision-conflict',
148
+ operation: PackAuthoringOperation,
149
+ expected: string,
150
+ hint: string,
151
+ detail: Readonly<Record<string, unknown>> = {},
152
+ actual?: string,
153
+ ): PackAuthoringError {
154
+ return makeError(code, expected, hint, { requestId: operation.requestId, ...detail }, actual);
155
+ }
156
+
157
+ function confinedPath(
158
+ gameRoot: string,
159
+ candidate: unknown,
160
+ operation: PackAuthoringOperation,
161
+ ): Result<ConfinedPath, PackAuthoringError> {
162
+ if (typeof candidate !== 'string' || candidate.length === 0 || isAbsolute(candidate)) {
163
+ return err(
164
+ pathError(
165
+ 'pack-source-path-invalid',
166
+ operation,
167
+ 'a non-empty game-root-relative .pack.ts or .pack.json path',
168
+ 'pass a relative source locator inside the selected game root',
169
+ { sourcePath: candidate },
170
+ typeof candidate === 'string' ? candidate : undefined,
171
+ ),
172
+ );
173
+ }
174
+ const root = resolve(gameRoot);
175
+ const absolute = resolve(root, candidate);
176
+ const relativePath = relative(root, absolute).split(sep).join('/');
177
+ if (
178
+ relativePath.length === 0 ||
179
+ relativePath === '..' ||
180
+ relativePath.startsWith('../') ||
181
+ (!relativePath.endsWith('.pack.ts') && !relativePath.endsWith('.pack.json'))
182
+ ) {
183
+ return err(
184
+ pathError(
185
+ 'pack-source-path-invalid',
186
+ operation,
187
+ 'a game-root-relative .pack.ts or .pack.json path',
188
+ 'choose a Pack source path inside the selected game root',
189
+ { sourcePath: candidate },
190
+ candidate,
191
+ ),
192
+ );
193
+ }
194
+ return ok({ absolute, relative: relativePath });
195
+ }
196
+
197
+ async function atomicWrite(path: string, source: string): Promise<void> {
198
+ await mkdir(dirname(path), { recursive: true });
199
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
200
+ try {
201
+ await writeFile(temporary, source, { encoding: 'utf8', flag: 'wx' });
202
+ await rename(temporary, path);
203
+ } catch (cause) {
204
+ await rm(temporary, { force: true });
205
+ throw cause;
206
+ }
207
+ }
208
+
209
+ function packageKey(packageId: PackageId): string {
210
+ return PackageId.format(packageId).toLowerCase();
211
+ }
212
+
213
+ function parsePackageId(value: unknown): PackageId | undefined {
214
+ if (typeof value !== 'string') return undefined;
215
+ const parsed = PackageId.parse(value);
216
+ return parsed.ok ? parsed.value : undefined;
217
+ }
218
+
219
+ function serializedParameters(
220
+ definition: AnyScriptablePackDefinition,
221
+ ): readonly Readonly<Record<string, unknown>>[] | undefined {
222
+ if (!('parameters' in definition)) return undefined;
223
+ return definition.parameters.map((parameter) => ({
224
+ name: parameter.name,
225
+ type: parameter.type,
226
+ default: jsonValue(parameter.default),
227
+ ...(parameter.minimum === undefined ? {} : { minimum: parameter.minimum }),
228
+ ...(parameter.maximum === undefined ? {} : { maximum: parameter.maximum }),
229
+ ...(parameter.values === undefined ? {} : { values: parameter.values }),
230
+ ...(parameter.kind === undefined ? {} : { kind: parameter.kind }),
231
+ }));
232
+ }
233
+
234
+ function sourceResult(
235
+ subject: Subject,
236
+ operation: PackAuthoringOperation,
237
+ ): PackAuthoringOperationResult {
238
+ const base = {
239
+ operation: operation.operation,
240
+ requestId: operation.requestId,
241
+ sourcePath: subject.relativePath,
242
+ packageId: PackageId.format(subject.packageId),
243
+ format: subject.format === 'source' ? 'pack.ts' : subject.format,
244
+ } as const;
245
+ if (subject.format === 'source') {
246
+ const parameters = serializedParameters(subject.definition);
247
+ return {
248
+ ...base,
249
+ ...(parameters === undefined ? {} : { parameters }),
250
+ ...(parameters === undefined
251
+ ? {}
252
+ : {
253
+ capabilities: [
254
+ 'asset-source.create-instance',
255
+ 'asset-source.rebuild',
256
+ 'asset-source.cold-cook',
257
+ ],
258
+ }),
259
+ };
260
+ }
261
+ if (subject.format === 'instance') {
262
+ return {
263
+ ...base,
264
+ parentPackageId: PackageId.format(subject.parent),
265
+ values: jsonValue(subject.values) as Readonly<Record<string, unknown>>,
266
+ };
267
+ }
268
+ return {
269
+ ...base,
270
+ assets: subject.assets.map((asset) => ({
271
+ sourceKey: asset.sourceKey,
272
+ guid: asset.guid,
273
+ kind: asset.kind,
274
+ ...(asset.name === undefined ? {} : { name: asset.name }),
275
+ refs: asset.refs,
276
+ })),
277
+ };
278
+ }
279
+
280
+ function scanFailure(operation: PackAuthoringOperation, cause: unknown): PackAuthoringError {
281
+ const value =
282
+ cause !== null && typeof cause === 'object' ? (cause as Record<string, unknown>) : {};
283
+ const detail = value.detail !== null && typeof value.detail === 'object' ? value.detail : {};
284
+ const reason = isRecord(detail) && typeof detail.reason === 'string' ? detail.reason : undefined;
285
+ const code: PackAuthoringErrorCode =
286
+ reason === 'pack-parent-not-found' ||
287
+ reason === 'pack-parent-cycle' ||
288
+ reason === 'pack-parent-has-no-parameters'
289
+ ? reason
290
+ : value.code === 'pack-guid-collision'
291
+ ? 'pack-guid-collision'
292
+ : 'pack-parameter-invalid';
293
+ return makeError(
294
+ code,
295
+ typeof value.expected === 'string' ? value.expected : 'a valid Pack Source Index',
296
+ typeof value.hint === 'string'
297
+ ? value.hint
298
+ : 'repair the first source or package diagnostic, then retry',
299
+ { requestId: operation.requestId, cause: value.detail ?? String(cause) },
300
+ );
301
+ }
302
+
303
+ function scanRoots(options: FileSystemPackAuthoringOptions): readonly string[] {
304
+ return options.scanRoots === undefined || options.scanRoots.length === 0
305
+ ? [resolve(options.gameRoot)]
306
+ : options.scanRoots.map((root) => resolve(options.gameRoot, root));
307
+ }
308
+
309
+ async function readMaterialized(
310
+ options: FileSystemPackAuthoringOptions,
311
+ operation: PackAuthoringOperation,
312
+ ): Promise<Result<readonly MaterializedAsset[], PackAuthoringError>> {
313
+ let rawRows: unknown;
314
+ try {
315
+ rawRows = (await options.materialized?.()) ?? [];
316
+ } catch (cause) {
317
+ return err(
318
+ makeError(
319
+ 'pack-parameter-invalid',
320
+ 'the materialized projection callback to return current-generation rows',
321
+ 'repair the build-owner projection callback, then retry the Pack operation',
322
+ {
323
+ requestId: operation.requestId,
324
+ cause: cause instanceof Error ? cause.message : String(cause),
325
+ },
326
+ ),
327
+ );
328
+ }
329
+ if (!Array.isArray(rawRows)) {
330
+ return err(
331
+ makeError(
332
+ 'pack-parameter-invalid',
333
+ 'the materialized projection callback to return an array',
334
+ 'repair the build-owner projection callback, then retry the Pack operation',
335
+ { requestId: operation.requestId, actual: typeof rawRows },
336
+ ),
337
+ );
338
+ }
339
+ const materialized: MaterializedAsset[] = [];
340
+ const pairOwners = new Map<string, string>();
341
+ const guidOwners = new Map<string, string>();
342
+ for (const rawRow of rawRows) {
343
+ if (!isRecord(rawRow)) {
344
+ return err(
345
+ makeError(
346
+ 'pack-parameter-invalid',
347
+ 'materialized rows to be objects',
348
+ 'repair the current generation projection before querying Pack authoring',
349
+ { requestId: operation.requestId, actual: typeof rawRow },
350
+ ),
351
+ );
352
+ }
353
+ const row = rawRow as unknown as PackAuthoringMaterializedAsset;
354
+ const parsedPackageId = parsePackageId(row.packageId);
355
+ if (parsedPackageId === undefined) {
356
+ return err(
357
+ makeError(
358
+ 'pack-package-id-invalid',
359
+ 'materialized rows to contain a valid UUID packageId',
360
+ 'repair the current generation projection before querying Pack authoring',
361
+ { requestId: operation.requestId, packageId: row.packageId, sourceKey: row.sourceKey },
362
+ ),
363
+ );
364
+ }
365
+ if (!isValidPackSourceKey(row.sourceKey)) {
366
+ return err(
367
+ makeError(
368
+ 'pack-source-key-invalid',
369
+ 'materialized rows to contain a valid sourceKey',
370
+ 'repair the current generation projection before querying Pack authoring',
371
+ { requestId: operation.requestId, packageId: row.packageId, sourceKey: row.sourceKey },
372
+ ),
373
+ );
374
+ }
375
+ if (
376
+ typeof row.guid !== 'string' ||
377
+ !isValidAssetGuidString(row.guid) ||
378
+ typeof row.kind !== 'string' ||
379
+ row.kind.length === 0
380
+ ) {
381
+ return err(
382
+ makeError(
383
+ 'pack-parameter-invalid',
384
+ 'materialized rows to contain a valid GUID and non-empty kind',
385
+ 'repair the current generation projection before querying Pack authoring',
386
+ { requestId: operation.requestId, packageId: row.packageId, sourceKey: row.sourceKey },
387
+ ),
388
+ );
389
+ }
390
+ if (
391
+ row.refs !== undefined &&
392
+ (!Array.isArray(row.refs) ||
393
+ row.refs.some((ref) => typeof ref !== 'string' || !isValidAssetGuidString(ref)))
394
+ ) {
395
+ return err(
396
+ makeError(
397
+ 'pack-parameter-invalid',
398
+ 'materialized refs to contain UUID AssetGuid strings when present',
399
+ 'repair the current generation projection before querying Pack authoring',
400
+ { requestId: operation.requestId, packageId: row.packageId, sourceKey: row.sourceKey },
401
+ ),
402
+ );
403
+ }
404
+ if (row.sourcePath !== undefined && typeof row.sourcePath !== 'string') {
405
+ return err(
406
+ makeError(
407
+ 'pack-parameter-invalid',
408
+ 'materialized sourcePath to be a string when present',
409
+ 'repair the current generation projection before querying Pack authoring',
410
+ { requestId: operation.requestId, sourcePath: row.sourcePath },
411
+ ),
412
+ );
413
+ }
414
+ const packageId = PackageId.format(parsedPackageId).toLowerCase();
415
+ const expectedGuid = AssetGuid.format(
416
+ AssetGuid.derive(parsedPackageId, row.sourceKey),
417
+ ).toLowerCase();
418
+ const guid = row.guid.toLowerCase();
419
+ if (guid !== expectedGuid) {
420
+ return err(
421
+ makeError(
422
+ 'pack-guid-collision',
423
+ 'materialized GUID to equal UUIDv5(packageId, sourceKey)',
424
+ 'rebuild the current generation from the stable packageId and sourceKey pair',
425
+ {
426
+ requestId: operation.requestId,
427
+ packageId,
428
+ sourceKey: row.sourceKey,
429
+ expectedGuid,
430
+ observedGuid: row.guid,
431
+ },
432
+ ),
433
+ );
434
+ }
435
+ const pair = `${packageId}/${row.sourceKey}`;
436
+ const pairOwner = pairOwners.get(pair);
437
+ if (pairOwner !== undefined) {
438
+ return err(
439
+ makeError(
440
+ 'pack-guid-collision',
441
+ 'one materialized row per packageId/sourceKey pair',
442
+ 'remove the stale duplicate row from the current generation projection',
443
+ { requestId: operation.requestId, pair, paths: [pairOwner, row.sourcePath] },
444
+ ),
445
+ );
446
+ }
447
+ pairOwners.set(pair, row.sourcePath ?? pair);
448
+ const guidOwner = guidOwners.get(guid);
449
+ if (guidOwner !== undefined) {
450
+ return err(
451
+ makeError(
452
+ 'pack-guid-collision',
453
+ 'one materialized owner per derived AssetGuid',
454
+ 'repair the current generation projection so each derived GUID has one owner',
455
+ { requestId: operation.requestId, guid, paths: [guidOwner, row.sourcePath] },
456
+ ),
457
+ );
458
+ }
459
+ guidOwners.set(guid, row.sourcePath ?? pair);
460
+ materialized.push({
461
+ packageId,
462
+ sourceKey: row.sourceKey,
463
+ guid,
464
+ kind: row.kind,
465
+ ...(row.sourcePath === undefined ? {} : { sourcePath: row.sourcePath }),
466
+ ...(row.refs === undefined ? {} : { refs: Object.freeze([...row.refs]) }),
467
+ ready: row.ready === true && row.artifactsReady !== false,
468
+ });
469
+ }
470
+ materialized.sort((left, right) =>
471
+ `${left.packageId}/${left.sourceKey}`.localeCompare(`${right.packageId}/${right.sourceKey}`),
472
+ );
473
+ return ok(materialized);
474
+ }
475
+
476
+ async function createSnapshot(
477
+ options: FileSystemPackAuthoringOptions,
478
+ operation: PackAuthoringOperation,
479
+ ): Promise<Result<GatewaySnapshot, PackAuthoringError>> {
480
+ const scanned =
481
+ options.inventory === undefined
482
+ ? await scanInventory(scanRoots(options))
483
+ : await options.inventory();
484
+ if (!scanned.ok) return err(scanFailure(operation, scanned.error));
485
+ const subjects = new Map<string, Subject>();
486
+ const directAssets: DirectAuthorAsset[] = [];
487
+ for (const [sourcePath, declaration] of scanned.value.declarations) {
488
+ const relativePath = relative(resolve(options.gameRoot), sourcePath).split(sep).join('/');
489
+ if (declaration.format === 'pack.ts') {
490
+ subjects.set(packageKey(declaration.definition.packageId), {
491
+ format: 'source',
492
+ packageId: declaration.definition.packageId,
493
+ sourcePath,
494
+ relativePath,
495
+ definition: declaration.definition,
496
+ });
497
+ continue;
498
+ }
499
+ if (declaration.format !== 'pack.json' || declaration.value.schemaVersion !== '3.0.0') continue;
500
+ const parsed = parsePackSourceJson(declaration.value);
501
+ if (!parsed.ok) return err(parsed.error);
502
+ if (parsed.value.format === 'direct') {
503
+ const projected = projectDirectPackJson(parsed.value);
504
+ if (!projected.ok) return err(projected.error);
505
+ const subject: DirectSubject = {
506
+ format: 'direct',
507
+ packageId: parsed.value.packageId,
508
+ sourcePath,
509
+ relativePath,
510
+ assets: projected.value.assets,
511
+ };
512
+ subjects.set(packageKey(subject.packageId), subject);
513
+ directAssets.push(
514
+ ...projected.value.assets.map((asset) => ({
515
+ packageId: projected.value.packageId.toLowerCase(),
516
+ sourceKey: asset.sourceKey,
517
+ guid: asset.guid.toLowerCase(),
518
+ kind: asset.kind,
519
+ sourcePath: relativePath,
520
+ ...(asset.name === undefined ? {} : { name: asset.name }),
521
+ refs: asset.refs,
522
+ })),
523
+ );
524
+ } else {
525
+ subjects.set(packageKey(parsed.value.packageId), {
526
+ format: 'instance',
527
+ packageId: parsed.value.packageId,
528
+ parent: parsed.value.parent,
529
+ values: parsed.value.values,
530
+ sourcePath,
531
+ relativePath,
532
+ });
533
+ }
534
+ }
535
+ const materializedResult = await readMaterialized(options, operation);
536
+ if (!materializedResult.ok) return materializedResult;
537
+ const materialized = materializedResult.value;
538
+ const guidOwners = new Map<string, string>();
539
+ const directPairs = new Set<string>();
540
+ for (const entry of scanned.value.inventory) {
541
+ const declaration = scanned.value.declarations.get(entry.sourcePath);
542
+ // scanInventory already projects v3 direct entries below; keep that
543
+ // source-local projection from colliding with its gateway subject copy.
544
+ if (declaration?.format === 'pack.json' && declaration.value.schemaVersion === '3.0.0') {
545
+ continue;
546
+ }
547
+ guidOwners.set(entry.guid.toLowerCase(), entry.sourcePath);
548
+ }
549
+ for (const asset of directAssets) {
550
+ directPairs.add(`${asset.packageId}/${asset.sourceKey}`);
551
+ const existing = guidOwners.get(asset.guid);
552
+ if (existing !== undefined) {
553
+ return err(
554
+ makeError(
555
+ 'pack-guid-collision',
556
+ 'one current-generation owner per AssetGuid',
557
+ 'repair the direct Pack or external source projection before querying Pack authoring',
558
+ {
559
+ requestId: operation.requestId,
560
+ guid: asset.guid,
561
+ paths: [existing, asset.sourcePath],
562
+ },
563
+ ),
564
+ );
565
+ }
566
+ guidOwners.set(asset.guid, asset.sourcePath ?? asset.packageId);
567
+ }
568
+ for (const asset of materialized) {
569
+ const existing = guidOwners.get(asset.guid);
570
+ const pair = `${asset.packageId}/${asset.sourceKey}`;
571
+ // A published row for a direct author asset is the expected current
572
+ // generation. It shares the source identity; only a different owner is a
573
+ // collision.
574
+ if (existing !== undefined && !directPairs.has(pair)) {
575
+ return err(
576
+ makeError(
577
+ 'pack-guid-collision',
578
+ 'one current-generation owner per AssetGuid',
579
+ 'remove the stale materialized row or repair the direct/source owner before querying Pack authoring',
580
+ {
581
+ requestId: operation.requestId,
582
+ guid: asset.guid,
583
+ paths: [existing, asset.sourcePath],
584
+ },
585
+ ),
586
+ );
587
+ }
588
+ guidOwners.set(asset.guid, asset.sourcePath ?? asset.packageId);
589
+ }
590
+ const knownGuids = new Set<string>([
591
+ ...scanned.value.inventory.map((entry) => entry.guid.toLowerCase()),
592
+ ...directAssets.map((asset) => asset.guid),
593
+ ...materialized.map((asset) => asset.guid),
594
+ ]);
595
+ return ok({ inventory: scanned.value, subjects, directAssets, materialized, knownGuids });
596
+ }
597
+
598
+ function parentSubject(
599
+ snapshot: GatewaySnapshot,
600
+ packageId: PackageId,
601
+ ): PackParameterInheritanceSubject | undefined {
602
+ const subject = snapshot.subjects.get(packageKey(packageId));
603
+ if (subject === undefined) return undefined;
604
+ if (subject.format === 'source') {
605
+ return {
606
+ format: 'source',
607
+ packageId: subject.packageId,
608
+ parameters: 'parameters' in subject.definition ? subject.definition.parameters : [],
609
+ };
610
+ }
611
+ if (subject.format === 'instance') {
612
+ return {
613
+ format: 'instance',
614
+ packageId: subject.packageId,
615
+ parent: subject.parent,
616
+ values: subject.values,
617
+ };
618
+ }
619
+ return { format: 'direct', packageId: subject.packageId };
620
+ }
621
+
622
+ async function resolveInstance(
623
+ snapshot: GatewaySnapshot,
624
+ subject: InstanceSubject,
625
+ ): Promise<Result<ResolvedPackParameterInheritance, PackAuthoringError>> {
626
+ return resolvePackParameterInheritance(
627
+ {
628
+ format: 'instance',
629
+ packageId: subject.packageId,
630
+ parent: subject.parent,
631
+ values: subject.values,
632
+ },
633
+ async (packageId) => parentSubject(snapshot, packageId),
634
+ );
635
+ }
636
+
637
+ function subjectFor(
638
+ gameRoot: string,
639
+ snapshot: GatewaySnapshot,
640
+ operation: PackAuthoringOperation,
641
+ ): Result<Subject, PackAuthoringError> {
642
+ if (operation.sourcePath !== undefined) {
643
+ const confined = confinedPath(gameRoot, operation.sourcePath, operation);
644
+ if (!confined.ok) return confined;
645
+ const expected = confined.value.relative;
646
+ const source = [...snapshot.subjects.values()].find(
647
+ (candidate) => candidate.relativePath === expected,
648
+ );
649
+ if (source !== undefined) {
650
+ const requestedPackageId =
651
+ operation.packageId === undefined ? undefined : PackageId.parse(operation.packageId);
652
+ if (requestedPackageId?.ok === false) {
653
+ return err(
654
+ makeError(
655
+ 'pack-package-id-invalid',
656
+ 'a UUID packageId',
657
+ 'repair packageId before querying the Pack subject',
658
+ { requestId: operation.requestId, packageId: operation.packageId },
659
+ operation.packageId,
660
+ ),
661
+ );
662
+ }
663
+ if (
664
+ requestedPackageId?.ok === true &&
665
+ packageKey(requestedPackageId.value) !== packageKey(source.packageId)
666
+ ) {
667
+ return err(
668
+ makeError(
669
+ 'pack-parameter-invalid',
670
+ 'sourcePath and packageId to identify the same Pack subject',
671
+ 'remove the conflicting locator or use the packageId returned by asset.list',
672
+ {
673
+ requestId: operation.requestId,
674
+ sourcePath: expected,
675
+ packageId: operation.packageId,
676
+ observedPackageId: PackageId.format(source.packageId),
677
+ },
678
+ ),
679
+ );
680
+ }
681
+ return ok(source);
682
+ }
683
+ return err(
684
+ pathError(
685
+ 'pack-source-not-found',
686
+ operation,
687
+ 'sourcePath to identify a current Pack subject',
688
+ 'inspect the current Source Index and choose an existing source path',
689
+ { sourcePath: expected },
690
+ expected,
691
+ ),
692
+ );
693
+ }
694
+ if (operation.packageId !== undefined) {
695
+ const parsed = PackageId.parse(operation.packageId);
696
+ if (!parsed.ok) {
697
+ return err(
698
+ makeError(
699
+ 'pack-package-id-invalid',
700
+ 'a UUID packageId',
701
+ 'repair packageId before querying the Pack',
702
+ { requestId: operation.requestId, packageId: operation.packageId },
703
+ operation.packageId,
704
+ ),
705
+ );
706
+ }
707
+ const subject = snapshot.subjects.get(packageKey(parsed.value));
708
+ if (subject !== undefined) return ok(subject);
709
+ return err(
710
+ makeError(
711
+ 'pack-source-not-found',
712
+ 'packageId to exist in the current Source Index',
713
+ 'inspect the current source index and choose an existing packageId',
714
+ { requestId: operation.requestId, packageId: operation.packageId },
715
+ ),
716
+ );
717
+ }
718
+ if (operation.subject !== undefined && !isValidAssetGuidString(operation.subject)) {
719
+ const confined = confinedPath(gameRoot, operation.subject, operation);
720
+ if (!confined.ok) return confined;
721
+ const source = [...snapshot.subjects.values()].find(
722
+ (candidate) => candidate.relativePath === confined.value.relative,
723
+ );
724
+ if (source !== undefined) return ok(source);
725
+ }
726
+ if (operation.subject !== undefined && isValidAssetGuidString(operation.subject)) {
727
+ const guid = operation.subject.toLowerCase();
728
+ const packageSubject = snapshot.subjects.get(guid);
729
+ if (packageSubject !== undefined) return ok(packageSubject);
730
+ const asset = [...snapshot.directAssets, ...snapshot.materialized].find(
731
+ (candidate) => candidate.guid === guid,
732
+ );
733
+ if (asset !== undefined) {
734
+ const subject = snapshot.subjects.get(asset.packageId);
735
+ if (subject !== undefined) return ok(subject);
736
+ }
737
+ }
738
+ return err(
739
+ makeError(
740
+ 'pack-source-not-found',
741
+ 'a packageId, source path, or known AssetGuid subject',
742
+ 'run asset.list or asset.inspect to obtain a current subject locator',
743
+ { requestId: operation.requestId, subject: operation.subject },
744
+ ),
745
+ );
746
+ }
747
+
748
+ async function inspectAsset(
749
+ gameRoot: string,
750
+ snapshot: GatewaySnapshot,
751
+ operation: PackAuthoringOperation,
752
+ ): Promise<Result<PackAuthoringOperationResult, PackAuthoringError>> {
753
+ const selected = subjectFor(gameRoot, snapshot, operation);
754
+ if (!selected.ok) return selected;
755
+ const subject = selected.value;
756
+ if (operation.sourceKey !== undefined) {
757
+ if (!isValidPackSourceKey(operation.sourceKey)) {
758
+ return err(
759
+ makeError(
760
+ 'pack-source-key-invalid',
761
+ 'a stable lower-case sourceKey',
762
+ 'repair sourceKey before inspecting the output identity',
763
+ { requestId: operation.requestId, sourceKey: operation.sourceKey },
764
+ operation.sourceKey,
765
+ ),
766
+ );
767
+ }
768
+ if (subject.format === 'direct') {
769
+ const asset = subject.assets.find((candidate) => candidate.sourceKey === operation.sourceKey);
770
+ if (asset === undefined) {
771
+ return err(
772
+ makeError(
773
+ 'pack-output-not-materialized',
774
+ 'sourceKey to exist in the direct Pack',
775
+ 'inspect the current source and choose an existing sourceKey',
776
+ {
777
+ requestId: operation.requestId,
778
+ sourceKey: operation.sourceKey,
779
+ packageId: PackageId.format(subject.packageId),
780
+ },
781
+ ),
782
+ );
783
+ }
784
+ const materialized = snapshot.materialized.find(
785
+ (candidate) =>
786
+ candidate.packageId === packageKey(subject.packageId) &&
787
+ candidate.sourceKey === operation.sourceKey,
788
+ );
789
+ return ok({
790
+ ...sourceResult(subject, operation),
791
+ sourceKey: asset.sourceKey,
792
+ guid: asset.guid,
793
+ kind: asset.kind,
794
+ status:
795
+ materialized === undefined
796
+ ? ('identity' as const)
797
+ : materialized.ready
798
+ ? 'ready'
799
+ : 'present',
800
+ });
801
+ }
802
+ try {
803
+ const guid = AssetGuid.format(AssetGuid.derive(subject.packageId, operation.sourceKey));
804
+ const materialized = [...snapshot.materialized].find(
805
+ (candidate) =>
806
+ candidate.packageId === packageKey(subject.packageId) &&
807
+ candidate.sourceKey === operation.sourceKey,
808
+ );
809
+ return ok({
810
+ ...sourceResult(subject, operation),
811
+ sourceKey: operation.sourceKey,
812
+ guid,
813
+ ...(materialized === undefined
814
+ ? { status: 'identity' as const }
815
+ : {
816
+ status: materialized.ready ? ('ready' as const) : ('present' as const),
817
+ kind: materialized.kind,
818
+ }),
819
+ });
820
+ } catch (cause) {
821
+ return err(
822
+ makeError(
823
+ 'pack-source-key-invalid',
824
+ 'a valid lower-case sourceKey',
825
+ 'repair sourceKey before inspecting the output identity',
826
+ { requestId: operation.requestId, sourceKey: operation.sourceKey, cause: String(cause) },
827
+ operation.sourceKey,
828
+ ),
829
+ );
830
+ }
831
+ }
832
+ if (subject.format === 'instance') {
833
+ const resolved = await resolveInstance(snapshot, subject);
834
+ if (!resolved.ok) return resolved;
835
+ return ok({
836
+ ...sourceResult(subject, operation),
837
+ parameters: resolved.value.parameters.map((parameter) => ({
838
+ name: parameter.name,
839
+ type: parameter.type,
840
+ default: jsonValue(parameter.default),
841
+ ...(parameter.minimum === undefined ? {} : { minimum: parameter.minimum }),
842
+ ...(parameter.maximum === undefined ? {} : { maximum: parameter.maximum }),
843
+ ...(parameter.values === undefined ? {} : { values: parameter.values }),
844
+ ...(parameter.kind === undefined ? {} : { kind: parameter.kind }),
845
+ })),
846
+ effectiveValues: jsonValue(resolved.value.values) as Readonly<Record<string, unknown>>,
847
+ parentChain: resolved.value.parentChain,
848
+ });
849
+ }
850
+ return ok(sourceResult(subject, operation));
851
+ }
852
+
853
+ function listAssets(
854
+ snapshot: GatewaySnapshot,
855
+ operation: PackAuthoringOperation,
856
+ ): PackAuthoringOperationResult {
857
+ const assets = snapshotAssets(snapshot)
858
+ .map((asset) => ({
859
+ packageId: asset.packageId,
860
+ sourceKey: asset.sourceKey,
861
+ guid: asset.guid,
862
+ kind: asset.kind,
863
+ ...(asset.name === undefined ? {} : { name: asset.name }),
864
+ ...(asset.sourcePath === undefined ? {} : { sourcePath: asset.sourcePath }),
865
+ status: !('ready' in asset)
866
+ ? ('identity' as const)
867
+ : asset.ready
868
+ ? ('ready' as const)
869
+ : ('present' as const),
870
+ }))
871
+ .sort((left, right) =>
872
+ `${left.packageId}/${left.sourceKey}`.localeCompare(`${right.packageId}/${right.sourceKey}`),
873
+ );
874
+ const sources = [...snapshot.subjects.values()]
875
+ .map((subject) => {
876
+ if (subject.format === 'source') {
877
+ const parameters = serializedParameters(subject.definition);
878
+ return {
879
+ packageId: PackageId.format(subject.packageId),
880
+ sourcePath: subject.relativePath,
881
+ format: 'pack.ts' as const,
882
+ ...(parameters === undefined ? {} : { parameters }),
883
+ };
884
+ }
885
+ if (subject.format === 'instance') {
886
+ return {
887
+ packageId: PackageId.format(subject.packageId),
888
+ sourcePath: subject.relativePath,
889
+ format: 'instance' as const,
890
+ parentPackageId: PackageId.format(subject.parent),
891
+ values: jsonValue(subject.values) as Readonly<Record<string, unknown>>,
892
+ };
893
+ }
894
+ return {
895
+ packageId: PackageId.format(subject.packageId),
896
+ sourcePath: subject.relativePath,
897
+ format: 'direct' as const,
898
+ };
899
+ })
900
+ .sort((left, right) =>
901
+ `${left.packageId}/${left.sourcePath}`.localeCompare(
902
+ `${right.packageId}/${right.sourcePath}`,
903
+ ),
904
+ );
905
+ return {
906
+ operation: operation.operation,
907
+ requestId: operation.requestId,
908
+ assets,
909
+ sources,
910
+ snapshot: { sourceCount: snapshot.subjects.size, assetCount: assets.length },
911
+ };
912
+ }
913
+
914
+ function snapshotAssets(
915
+ snapshot: GatewaySnapshot,
916
+ ): readonly (DirectAuthorAsset | MaterializedAsset)[] {
917
+ const assets = new Map<string, DirectAuthorAsset | MaterializedAsset>();
918
+ for (const asset of snapshot.directAssets) {
919
+ assets.set(`${asset.packageId}/${asset.sourceKey}`, asset);
920
+ }
921
+ for (const asset of snapshot.materialized) {
922
+ assets.set(`${asset.packageId}/${asset.sourceKey}`, asset);
923
+ }
924
+ return [...assets.values()];
925
+ }
926
+
927
+ function assetForPair(
928
+ snapshot: GatewaySnapshot,
929
+ packageId: PackageId,
930
+ sourceKey: string,
931
+ ): DirectAuthorAsset | MaterializedAsset | undefined {
932
+ const key = `${packageKey(packageId)}/${sourceKey}`;
933
+ return snapshotAssets(snapshot).find((asset) => `${asset.packageId}/${asset.sourceKey}` === key);
934
+ }
935
+
936
+ async function resolveAsset(
937
+ gameRoot: string,
938
+ snapshot: GatewaySnapshot,
939
+ operation: PackAuthoringOperation,
940
+ ): Promise<Result<PackAuthoringOperationResult, PackAuthoringError>> {
941
+ if (
942
+ operation.require !== undefined &&
943
+ operation.require !== 'identity' &&
944
+ operation.require !== 'present' &&
945
+ operation.require !== 'ready'
946
+ ) {
947
+ return err(
948
+ makeError(
949
+ 'pack-parameter-invalid',
950
+ "require to be 'identity', 'present', or 'ready'",
951
+ 'choose one of the three Pack identity proof levels',
952
+ { requestId: operation.requestId, require: operation.require },
953
+ ),
954
+ );
955
+ }
956
+ let packageId: PackageId | undefined;
957
+ let sourceKey = operation.sourceKey;
958
+ let resolvedOutputGuid: string | undefined;
959
+ if (operation.packageId !== undefined) {
960
+ const parsed = PackageId.parse(operation.packageId);
961
+ if (!parsed.ok) {
962
+ return err(
963
+ makeError(
964
+ 'pack-package-id-invalid',
965
+ 'a UUID packageId',
966
+ 'repair packageId before resolving a sourceKey',
967
+ { requestId: operation.requestId },
968
+ operation.packageId,
969
+ ),
970
+ );
971
+ }
972
+ packageId = parsed.value;
973
+ }
974
+ if (operation.subject !== undefined && isValidAssetGuidString(operation.subject)) {
975
+ const found = [...snapshot.directAssets, ...snapshot.materialized].find(
976
+ (asset) => asset.guid === operation.subject?.toLowerCase(),
977
+ );
978
+ if (found !== undefined) {
979
+ const parsed = PackageId.parse(found.packageId);
980
+ if (parsed.ok) {
981
+ if (packageId !== undefined && packageKey(packageId) !== packageKey(parsed.value)) {
982
+ return err(
983
+ makeError(
984
+ 'pack-parameter-invalid',
985
+ 'packageId and AssetGuid to identify the same Pack subject',
986
+ 'remove the conflicting locator or resolve the pair from one source',
987
+ {
988
+ requestId: operation.requestId,
989
+ packageId: PackageId.format(packageId),
990
+ observedPackageId: found.packageId,
991
+ guid: operation.subject,
992
+ },
993
+ ),
994
+ );
995
+ }
996
+ if (sourceKey !== undefined && sourceKey !== found.sourceKey) {
997
+ return err(
998
+ makeError(
999
+ 'pack-parameter-invalid',
1000
+ 'sourceKey and AssetGuid to identify the same Pack output',
1001
+ 'remove the conflicting locator or use the sourceKey returned by asset.list',
1002
+ {
1003
+ requestId: operation.requestId,
1004
+ sourceKey,
1005
+ observedSourceKey: found.sourceKey,
1006
+ guid: operation.subject,
1007
+ },
1008
+ ),
1009
+ );
1010
+ }
1011
+ packageId = parsed.value;
1012
+ sourceKey ??= found.sourceKey;
1013
+ resolvedOutputGuid = found.guid;
1014
+ }
1015
+ }
1016
+ }
1017
+ if (
1018
+ packageId === undefined &&
1019
+ operation.subject !== undefined &&
1020
+ isValidAssetGuidString(operation.subject)
1021
+ ) {
1022
+ const parsed = PackageId.parse(operation.subject);
1023
+ if (parsed.ok) packageId = parsed.value;
1024
+ }
1025
+ const sourceLocatorCandidate =
1026
+ operation.sourcePath ??
1027
+ (operation.subject !== undefined && !isValidAssetGuidString(operation.subject)
1028
+ ? operation.subject
1029
+ : undefined);
1030
+ let sourceLocator: string | undefined;
1031
+ if (sourceLocatorCandidate !== undefined) {
1032
+ const confined = confinedPath(gameRoot, sourceLocatorCandidate, operation);
1033
+ if (!confined.ok) return confined;
1034
+ sourceLocator = confined.value.relative;
1035
+ }
1036
+ if (sourceLocator !== undefined) {
1037
+ const source = [...snapshot.subjects.values()].find(
1038
+ (candidate) => candidate.relativePath === sourceLocator,
1039
+ );
1040
+ if (source === undefined) {
1041
+ return err(
1042
+ pathError(
1043
+ 'pack-source-not-found',
1044
+ operation,
1045
+ 'sourcePath to identify a current Pack subject',
1046
+ 'inspect the current Source Index and choose an existing source path',
1047
+ { sourcePath: sourceLocator },
1048
+ sourceLocator,
1049
+ ),
1050
+ );
1051
+ }
1052
+ if (packageId !== undefined && packageKey(packageId) !== packageKey(source.packageId)) {
1053
+ return err(
1054
+ makeError(
1055
+ 'pack-parameter-invalid',
1056
+ 'sourcePath and packageId to identify the same Pack subject',
1057
+ 'remove the conflicting locator or use the packageId returned by asset.list',
1058
+ {
1059
+ requestId: operation.requestId,
1060
+ sourcePath: sourceLocator,
1061
+ packageId: PackageId.format(packageId),
1062
+ observedPackageId: PackageId.format(source.packageId),
1063
+ },
1064
+ ),
1065
+ );
1066
+ }
1067
+ packageId ??= source.packageId;
1068
+ }
1069
+ if (packageId === undefined || sourceKey === undefined) {
1070
+ return err(
1071
+ makeError(
1072
+ 'pack-parameter-invalid',
1073
+ 'packageId and sourceKey (or a known output GUID)',
1074
+ 'pass packageId + sourceKey, a known AssetGuid, or a source path plus sourceKey',
1075
+ { requestId: operation.requestId, packageId: operation.packageId, sourceKey },
1076
+ ),
1077
+ );
1078
+ }
1079
+ if (!isValidPackSourceKey(sourceKey)) {
1080
+ return err(
1081
+ makeError(
1082
+ 'pack-source-key-invalid',
1083
+ 'a stable lower-case sourceKey',
1084
+ 'repair sourceKey before deriving its AssetGuid',
1085
+ { requestId: operation.requestId, sourceKey },
1086
+ sourceKey,
1087
+ ),
1088
+ );
1089
+ }
1090
+ let guid: EngineAssetGuid;
1091
+ try {
1092
+ guid = AssetGuid.derive(packageId, sourceKey);
1093
+ } catch (cause) {
1094
+ return err(
1095
+ makeError(
1096
+ 'pack-source-key-invalid',
1097
+ 'a valid packageId and sourceKey pair',
1098
+ 'repair the identity pair before resolving',
1099
+ {
1100
+ requestId: operation.requestId,
1101
+ cause: cause instanceof Error ? cause.message : String(cause),
1102
+ },
1103
+ ),
1104
+ );
1105
+ }
1106
+ const guidString = AssetGuid.format(guid);
1107
+ if (resolvedOutputGuid !== undefined && guidString.toLowerCase() !== resolvedOutputGuid) {
1108
+ return err(
1109
+ makeError(
1110
+ 'pack-guid-collision',
1111
+ 'the known output GUID to equal UUIDv5(packageId, sourceKey)',
1112
+ 'rebuild the current generation from the stable identity pair',
1113
+ {
1114
+ requestId: operation.requestId,
1115
+ packageId: PackageId.format(packageId),
1116
+ sourceKey,
1117
+ expectedGuid: guidString,
1118
+ observedGuid: resolvedOutputGuid,
1119
+ },
1120
+ ),
1121
+ );
1122
+ }
1123
+ const asset = assetForPair(snapshot, packageId, sourceKey);
1124
+ const materialized = snapshot.materialized.find(
1125
+ (candidate) =>
1126
+ candidate.packageId === packageKey(packageId) && candidate.sourceKey === sourceKey,
1127
+ );
1128
+ const requirement: PackAuthoringResolutionStatus = operation.require ?? 'identity';
1129
+ if (requirement === 'present' && materialized === undefined) {
1130
+ return err(
1131
+ makeError(
1132
+ 'pack-output-not-materialized',
1133
+ 'the current build topology to contain this sourceKey',
1134
+ 'run rebuild/cold-cook and retry after the current generation publishes the output',
1135
+ {
1136
+ requestId: operation.requestId,
1137
+ packageId: PackageId.format(packageId),
1138
+ sourceKey,
1139
+ guid: guidString,
1140
+ },
1141
+ ),
1142
+ );
1143
+ }
1144
+ if (requirement === 'ready' && (materialized === undefined || !materialized.ready)) {
1145
+ return err(
1146
+ makeError(
1147
+ 'asset-not-ready',
1148
+ 'current Pack evidence and artifacts for this derived GUID',
1149
+ 'inspect producer evidence, then rebuild or cold-cook the same subject',
1150
+ {
1151
+ requestId: operation.requestId,
1152
+ packageId: PackageId.format(packageId),
1153
+ sourceKey,
1154
+ guid: guidString,
1155
+ },
1156
+ ),
1157
+ );
1158
+ }
1159
+ return ok({
1160
+ operation: operation.operation,
1161
+ requestId: operation.requestId,
1162
+ packageId: PackageId.format(packageId),
1163
+ sourceKey,
1164
+ guid: guidString,
1165
+ status: materialized === undefined ? 'identity' : materialized.ready ? 'ready' : 'present',
1166
+ ...(asset === undefined
1167
+ ? {}
1168
+ : {
1169
+ ...(asset.sourcePath === undefined ? {} : { sourcePath: asset.sourcePath }),
1170
+ kind: asset.kind,
1171
+ }),
1172
+ });
1173
+ }
1174
+
1175
+ async function verify(
1176
+ snapshot: GatewaySnapshot,
1177
+ operation: PackAuthoringOperation,
1178
+ ): Promise<Result<PackAuthoringOperationResult, PackAuthoringError>> {
1179
+ for (const subject of snapshot.subjects.values()) {
1180
+ if (subject.format === 'instance') {
1181
+ const resolved = await resolveInstance(snapshot, subject);
1182
+ if (!resolved.ok) return err(resolved.error);
1183
+ }
1184
+ if (subject.format === 'direct') {
1185
+ for (const asset of subject.assets) {
1186
+ const missing = asset.refs.find((ref) => !snapshot.knownGuids.has(ref.toLowerCase()));
1187
+ if (missing !== undefined) {
1188
+ return err(
1189
+ makeError(
1190
+ 'pack-output-reference-missing',
1191
+ 'every direct Pack ref to resolve to a known AssetGuid',
1192
+ 'repair the payload ref or add the owning external asset before verifying',
1193
+ {
1194
+ requestId: operation.requestId,
1195
+ packageId: PackageId.format(subject.packageId),
1196
+ sourceKey: asset.sourceKey,
1197
+ guid: missing,
1198
+ },
1199
+ ),
1200
+ );
1201
+ }
1202
+ }
1203
+ }
1204
+ }
1205
+ for (const asset of snapshotAssets(snapshot)) {
1206
+ const missing = asset.refs?.find((ref) => !snapshot.knownGuids.has(ref.toLowerCase()));
1207
+ if (missing !== undefined) {
1208
+ return err(
1209
+ makeError(
1210
+ 'pack-output-reference-missing',
1211
+ 'every materialized Pack ref to resolve to a known AssetGuid',
1212
+ 'repair the published output reference or publish its owning asset before verifying',
1213
+ {
1214
+ requestId: operation.requestId,
1215
+ packageId: asset.packageId,
1216
+ sourceKey: asset.sourceKey,
1217
+ guid: missing,
1218
+ },
1219
+ ),
1220
+ );
1221
+ }
1222
+ }
1223
+ return ok({
1224
+ operation: operation.operation,
1225
+ requestId: operation.requestId,
1226
+ snapshot: {
1227
+ sourceCount: snapshot.subjects.size,
1228
+ assetCount: snapshotAssets(snapshot).length,
1229
+ },
1230
+ });
1231
+ }
1232
+
1233
+ function packageIdFromOperation(
1234
+ snapshot: GatewaySnapshot,
1235
+ operation: PackAuthoringOperation,
1236
+ ): Result<PackageId, PackAuthoringError> {
1237
+ const value =
1238
+ operation.packageId === undefined
1239
+ ? ok(PackageId.random())
1240
+ : PackageId.parse(operation.packageId);
1241
+ if (!value.ok) {
1242
+ return err(
1243
+ makeError(
1244
+ 'pack-package-id-invalid',
1245
+ 'a UUID packageId',
1246
+ 'repair packageId or omit it so the gateway can mint one',
1247
+ { requestId: operation.requestId },
1248
+ operation.packageId,
1249
+ ),
1250
+ );
1251
+ }
1252
+ const normalized = packageKey(value.value);
1253
+ const alreadyMaterialized = snapshot.materialized.some((asset) => asset.packageId === normalized);
1254
+ return snapshot.subjects.has(normalized) || alreadyMaterialized
1255
+ ? err(
1256
+ makeError(
1257
+ 'pack-package-id-collision',
1258
+ 'a packageId unused by the current Source Index',
1259
+ 'choose a new packageId or use clone without an explicit identity',
1260
+ { requestId: operation.requestId, packageId: PackageId.format(value.value) },
1261
+ ),
1262
+ )
1263
+ : ok(value.value);
1264
+ }
1265
+
1266
+ async function readConfined(
1267
+ options: FileSystemPackAuthoringOptions,
1268
+ operation: PackAuthoringOperation,
1269
+ sourcePath: unknown,
1270
+ ): Promise<
1271
+ Result<ConfinedPath & { readonly source: string; readonly revision: string }, PackAuthoringError>
1272
+ > {
1273
+ const path = confinedPath(options.gameRoot, sourcePath, operation);
1274
+ if (!path.ok) return path;
1275
+ try {
1276
+ const source = await readFile(path.value.absolute, 'utf8');
1277
+ return ok({ ...path.value, source, revision: revisionOf(source) });
1278
+ } catch (cause) {
1279
+ const code: 'pack-source-not-found' | 'pack-source-write-failed' =
1280
+ (cause as NodeJS.ErrnoException).code === 'ENOENT'
1281
+ ? 'pack-source-not-found'
1282
+ : 'pack-source-write-failed';
1283
+ return err(
1284
+ pathError(
1285
+ code,
1286
+ operation,
1287
+ 'the requested Pack source to be readable',
1288
+ 'restore the source or choose a current locator',
1289
+ {
1290
+ sourcePath: path.value.relative,
1291
+ cause: cause instanceof Error ? cause.message : String(cause),
1292
+ },
1293
+ path.value.relative,
1294
+ ),
1295
+ );
1296
+ }
1297
+ }
1298
+
1299
+ function revisionConflict(
1300
+ operation: PackAuthoringOperation,
1301
+ path: string,
1302
+ actual: string,
1303
+ ): PackAuthoringError {
1304
+ return makeError(
1305
+ 'pack-source-revision-conflict',
1306
+ 'expectedRevision to match the current source bytes',
1307
+ 'inspect the current source, reconcile the edit, and retry with a new requestId',
1308
+ {
1309
+ requestId: operation.requestId,
1310
+ sourcePath: path,
1311
+ expectedRevision: operation.expectedRevision,
1312
+ actualRevision: actual,
1313
+ },
1314
+ actual,
1315
+ );
1316
+ }
1317
+
1318
+ function directJson(
1319
+ packageId: PackageId,
1320
+ assets: Readonly<Record<string, DirectPackJsonAsset>>,
1321
+ ): Record<string, unknown> {
1322
+ return { schemaVersion: '3.0.0', packageId: PackageId.format(packageId), assets };
1323
+ }
1324
+
1325
+ function packSourceScaffold(
1326
+ packageId: PackageId,
1327
+ parameters: readonly PackParameterDefinition[] | undefined,
1328
+ ): string {
1329
+ const parameterSource =
1330
+ parameters === undefined
1331
+ ? ''
1332
+ : '\n parameters: ' +
1333
+ JSON.stringify(
1334
+ parameters.map((parameter) => jsonValue(parameter)),
1335
+ null,
1336
+ 2,
1337
+ ).replaceAll('\n', '\n ') +
1338
+ ',';
1339
+ return (
1340
+ "import { definePack, definePackageId } from '@forgeax/engine-pack/source';\n" +
1341
+ "import { ok } from '@forgeax/engine-types';\n\n" +
1342
+ 'const packageId = definePackageId(' +
1343
+ JSON.stringify(PackageId.format(packageId)) +
1344
+ ');\n\n' +
1345
+ 'export default definePack({\n' +
1346
+ " schemaVersion: '2.0.0',\n" +
1347
+ ' packageId,' +
1348
+ parameterSource +
1349
+ '\n build: () => ok({}),\n' +
1350
+ '});\n'
1351
+ );
1352
+ }
1353
+
1354
+ function replacePackageIdLiteral(source: string, packageId: string): string | undefined {
1355
+ const pattern =
1356
+ /(definePackageId\s*\(\s*)(['"])([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(\2\s*\))/;
1357
+ if (!pattern.test(source)) return undefined;
1358
+ return source.replace(pattern, `$1${JSON.stringify(packageId)}$4`);
1359
+ }
1360
+
1361
+ async function inspectAfterWrite(
1362
+ operation: PackAuthoringOperation,
1363
+ path: ConfinedPath,
1364
+ source: string,
1365
+ packageId: PackageId,
1366
+ extra: Partial<PackAuthoringOperationResult> = {},
1367
+ ): Promise<Result<PackAuthoringOperationResult, PackAuthoringError>> {
1368
+ return ok({
1369
+ operation: operation.operation,
1370
+ requestId: operation.requestId,
1371
+ sourcePath: path.relative,
1372
+ targetPath: path.relative,
1373
+ packageId: PackageId.format(packageId),
1374
+ revision: revisionOf(source),
1375
+ ...extra,
1376
+ });
1377
+ }
1378
+
1379
+ async function executeRaw(
1380
+ options: FileSystemPackAuthoringOptions,
1381
+ operation: PackAuthoringOperation,
1382
+ ): Promise<Result<PackAuthoringOperationResult, PackAuthoringError>> {
1383
+ if (operation.operation === 'asset.list' || operation.operation === 'asset.inspect') {
1384
+ const snapshot = await createSnapshot(options, operation);
1385
+ if (!snapshot.ok) return snapshot;
1386
+ return operation.operation === 'asset.list'
1387
+ ? ok(listAssets(snapshot.value, operation))
1388
+ : await inspectAsset(options.gameRoot, snapshot.value, operation);
1389
+ }
1390
+ const snapshot = await createSnapshot(options, operation);
1391
+ if (!snapshot.ok) return snapshot;
1392
+ if (operation.operation === 'asset.resolve')
1393
+ return resolveAsset(options.gameRoot, snapshot.value, operation);
1394
+ if (operation.operation === 'asset.verify') return verify(snapshot.value, operation);
1395
+
1396
+ if (operation.operation === 'asset-source.create') {
1397
+ const path = confinedPath(
1398
+ options.gameRoot,
1399
+ operation.targetPath ?? operation.sourcePath,
1400
+ operation,
1401
+ );
1402
+ if (!path.ok) return path;
1403
+ try {
1404
+ await stat(path.value.absolute);
1405
+ return err(
1406
+ pathError(
1407
+ 'pack-source-revision-conflict',
1408
+ operation,
1409
+ 'an unused target source path',
1410
+ 'inspect the existing source or choose another target path',
1411
+ { targetPath: path.value.relative },
1412
+ path.value.relative,
1413
+ ),
1414
+ );
1415
+ } catch (cause) {
1416
+ if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') {
1417
+ return err(
1418
+ pathError(
1419
+ 'pack-source-write-failed',
1420
+ operation,
1421
+ 'the target path to be available for atomic creation',
1422
+ 'repair the filesystem and retry',
1423
+ { targetPath: path.value.relative, cause: String(cause) },
1424
+ ),
1425
+ );
1426
+ }
1427
+ }
1428
+ const identity = packageIdFromOperation(snapshot.value, operation);
1429
+ if (!identity.ok) return identity;
1430
+ const format =
1431
+ operation.format ?? (path.value.relative.endsWith('.pack.json') ? 'pack.json' : 'pack.ts');
1432
+ if (
1433
+ (format === 'pack.json' && !path.value.relative.endsWith('.pack.json')) ||
1434
+ (format === 'pack.ts' && !path.value.relative.endsWith('.pack.ts'))
1435
+ ) {
1436
+ return err(
1437
+ makeError(
1438
+ 'pack-source-path-invalid',
1439
+ 'format and target extension to agree',
1440
+ 'use a .pack.ts target for source creation or a .pack.json target for direct data',
1441
+ { requestId: operation.requestId, format, targetPath: path.value.relative },
1442
+ ),
1443
+ );
1444
+ }
1445
+ let source: string;
1446
+ let extra: Partial<PackAuthoringOperationResult> = { format };
1447
+ if (format === 'pack.json') {
1448
+ if (
1449
+ operation.parameters !== undefined ||
1450
+ operation.parent !== undefined ||
1451
+ operation.parentPackageId !== undefined ||
1452
+ operation.values !== undefined
1453
+ ) {
1454
+ return err(
1455
+ makeError(
1456
+ 'pack-parameter-invalid',
1457
+ 'direct create to contain assets only',
1458
+ 'use pack.ts for a ScriptablePack source or create-instance for parent+values',
1459
+ { requestId: operation.requestId },
1460
+ ),
1461
+ );
1462
+ }
1463
+ const assets = operation.initialAssets ?? {};
1464
+ const parsed = parsePackSourceJson(directJson(identity.value, assets));
1465
+ if (!parsed.ok) return err(parsed.error);
1466
+ if (parsed.value.format !== 'direct') {
1467
+ return err(
1468
+ makeError(
1469
+ 'pack-parameter-invalid',
1470
+ 'direct create to produce a direct v3 Pack document',
1471
+ 'use create-instance for parent + values instead of direct assets',
1472
+ { requestId: operation.requestId },
1473
+ ),
1474
+ );
1475
+ }
1476
+ source = `${JSON.stringify(directJson(identity.value, parsed.value.assets), null, 2)}\n`;
1477
+ } else {
1478
+ if (
1479
+ operation.initialAssets !== undefined &&
1480
+ Object.keys(operation.initialAssets).length > 0
1481
+ ) {
1482
+ return err(
1483
+ makeError(
1484
+ 'pack-parameter-invalid',
1485
+ 'a pack.ts source to define its outputs in build()',
1486
+ 'use pack.json for direct assets or omit initialAssets when creating a pack.ts source',
1487
+ { requestId: operation.requestId },
1488
+ ),
1489
+ );
1490
+ }
1491
+ if (
1492
+ operation.parent !== undefined ||
1493
+ operation.parentPackageId !== undefined ||
1494
+ operation.values !== undefined
1495
+ ) {
1496
+ return err(
1497
+ makeError(
1498
+ 'pack-parameter-invalid',
1499
+ 'a new pack.ts source without instance-only parent/value fields',
1500
+ 'use asset-source.create-instance for parent + values',
1501
+ { requestId: operation.requestId },
1502
+ ),
1503
+ );
1504
+ }
1505
+ let parameters: readonly PackParameterDefinition[] | undefined;
1506
+ if (operation.parameters !== undefined) {
1507
+ const validated = validatePackDefinition({
1508
+ schemaVersion: '2.0.0',
1509
+ packageId: identity.value,
1510
+ parameters: operation.parameters,
1511
+ build: () => ok({}),
1512
+ });
1513
+ if (!validated.ok) return err(validated.error);
1514
+ if (!('parameters' in validated.value)) {
1515
+ return err(
1516
+ makeError(
1517
+ 'pack-parameter-invalid',
1518
+ 'parameters to be non-empty when supplied',
1519
+ 'omit parameters for a zero-parameter Pack',
1520
+ { requestId: operation.requestId },
1521
+ ),
1522
+ );
1523
+ }
1524
+ parameters = validated.value.parameters;
1525
+ }
1526
+ source = packSourceScaffold(identity.value, parameters);
1527
+ const serialized =
1528
+ parameters === undefined
1529
+ ? undefined
1530
+ : serializedParameters({
1531
+ schemaVersion: '2.0.0',
1532
+ packageId: identity.value,
1533
+ parameters,
1534
+ build: () => ok({}),
1535
+ } as AnyScriptablePackDefinition);
1536
+ extra = {
1537
+ format,
1538
+ ...(serialized === undefined ? {} : { parameters: serialized }),
1539
+ };
1540
+ }
1541
+ try {
1542
+ await atomicWrite(path.value.absolute, source);
1543
+ } catch (cause) {
1544
+ return err(
1545
+ pathError(
1546
+ 'pack-source-write-failed',
1547
+ operation,
1548
+ 'the new Pack source to be written atomically',
1549
+ 'repair filesystem permissions or disk capacity, then retry with a new requestId',
1550
+ {
1551
+ targetPath: path.value.relative,
1552
+ cause: cause instanceof Error ? cause.message : String(cause),
1553
+ },
1554
+ ),
1555
+ );
1556
+ }
1557
+ return inspectAfterWrite(operation, path.value, source, identity.value, extra);
1558
+ }
1559
+
1560
+ if (operation.operation === 'asset-source.clone') {
1561
+ const read = await readConfined(options, operation, operation.sourcePath);
1562
+ if (!read.ok) return read;
1563
+ if (
1564
+ operation.expectedRevision !== undefined &&
1565
+ operation.expectedRevision !== read.value.revision
1566
+ ) {
1567
+ return err(revisionConflict(operation, read.value.relative, read.value.revision));
1568
+ }
1569
+ const target = confinedPath(options.gameRoot, operation.targetPath, operation);
1570
+ if (!target.ok) return target;
1571
+ const sourceIsTypeScript = read.value.relative.endsWith('.pack.ts');
1572
+ const targetIsTypeScript = target.value.relative.endsWith('.pack.ts');
1573
+ if (sourceIsTypeScript !== targetIsTypeScript) {
1574
+ return err(
1575
+ makeError(
1576
+ 'pack-source-path-invalid',
1577
+ 'clone source and target to use the same .pack.ts or .pack.json format',
1578
+ 'preserve the source extension when cloning a Pack subject',
1579
+ {
1580
+ requestId: operation.requestId,
1581
+ sourcePath: read.value.relative,
1582
+ targetPath: target.value.relative,
1583
+ },
1584
+ ),
1585
+ );
1586
+ }
1587
+ try {
1588
+ await stat(target.value.absolute);
1589
+ return err(
1590
+ pathError(
1591
+ 'pack-source-revision-conflict',
1592
+ operation,
1593
+ 'an unused clone target path',
1594
+ 'inspect the target or choose another path',
1595
+ { targetPath: target.value.relative },
1596
+ target.value.relative,
1597
+ ),
1598
+ );
1599
+ } catch (cause) {
1600
+ if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') {
1601
+ return err(
1602
+ pathError(
1603
+ 'pack-source-write-failed',
1604
+ operation,
1605
+ 'the clone target to be writable',
1606
+ 'repair filesystem permissions and retry',
1607
+ { targetPath: target.value.relative, cause: String(cause) },
1608
+ ),
1609
+ );
1610
+ }
1611
+ }
1612
+ const identity = packageIdFromOperation(snapshot.value, operation);
1613
+ if (!identity.ok) return identity;
1614
+ let source = read.value.source;
1615
+ if (read.value.relative.endsWith('.pack.json')) {
1616
+ let parsedValue: unknown;
1617
+ try {
1618
+ parsedValue = JSON.parse(source);
1619
+ } catch {
1620
+ return err(
1621
+ makeError(
1622
+ 'pack-parameter-invalid',
1623
+ 'a valid v3 pack.json document',
1624
+ 'repair the source before cloning it',
1625
+ { requestId: operation.requestId, sourcePath: read.value.relative },
1626
+ ),
1627
+ );
1628
+ }
1629
+ const parsed = parsePackSourceJson(parsedValue);
1630
+ if (!parsed.ok) return err(parsed.error);
1631
+ source = `${JSON.stringify(
1632
+ parsed.value.format === 'direct'
1633
+ ? directJson(identity.value, parsed.value.assets)
1634
+ : {
1635
+ schemaVersion: '3.0.0',
1636
+ packageId: PackageId.format(identity.value),
1637
+ parent: PackageId.format(parsed.value.parent),
1638
+ values: parsed.value.values,
1639
+ },
1640
+ null,
1641
+ 2,
1642
+ )}\n`;
1643
+ } else {
1644
+ const replaced = replacePackageIdLiteral(source, PackageId.format(identity.value));
1645
+ if (replaced === undefined) {
1646
+ return err(
1647
+ makeError(
1648
+ 'pack-source-mutation-unsupported',
1649
+ 'a constrained definePackageId(UUID) literal in the source',
1650
+ 'clone a generated Pack source or create a new Pack; arbitrary TypeScript AST rewriting is unsupported',
1651
+ { requestId: operation.requestId, sourcePath: read.value.relative },
1652
+ ),
1653
+ );
1654
+ }
1655
+ source = replaced;
1656
+ }
1657
+ try {
1658
+ await atomicWrite(target.value.absolute, source);
1659
+ } catch (cause) {
1660
+ return err(
1661
+ pathError(
1662
+ 'pack-source-write-failed',
1663
+ operation,
1664
+ 'the cloned Pack source to be written atomically',
1665
+ 'repair filesystem permissions or disk capacity, then retry',
1666
+ {
1667
+ targetPath: target.value.relative,
1668
+ cause: cause instanceof Error ? cause.message : String(cause),
1669
+ },
1670
+ ),
1671
+ );
1672
+ }
1673
+ return inspectAfterWrite(operation, target.value, source, identity.value, {
1674
+ format: target.value.relative.endsWith('.pack.json') ? 'pack.json' : 'pack.ts',
1675
+ });
1676
+ }
1677
+
1678
+ if (operation.operation === 'asset-source.create-instance') {
1679
+ const target = confinedPath(
1680
+ options.gameRoot,
1681
+ operation.targetPath ?? operation.sourcePath,
1682
+ operation,
1683
+ );
1684
+ if (!target.ok) return target;
1685
+ const parentText = operation.parentPackageId ?? operation.parent;
1686
+ if (parentText === undefined) {
1687
+ return err(
1688
+ makeError(
1689
+ 'pack-parent-not-found',
1690
+ 'a parent packageId for the new instance',
1691
+ 'pass parentPackageId or parent and point it at a ScriptablePack source with parameters',
1692
+ { requestId: operation.requestId },
1693
+ ),
1694
+ );
1695
+ }
1696
+ const parentId = PackageId.parse(parentText);
1697
+ if (!parentId.ok) {
1698
+ return err(
1699
+ makeError(
1700
+ 'pack-package-id-invalid',
1701
+ 'a UUID parent packageId',
1702
+ 'repair the parent package identity',
1703
+ { requestId: operation.requestId },
1704
+ parentText,
1705
+ ),
1706
+ );
1707
+ }
1708
+ const parent = snapshot.value.subjects.get(packageKey(parentId.value));
1709
+ if (parent === undefined) {
1710
+ return err(
1711
+ makeError(
1712
+ 'pack-parent-not-found',
1713
+ 'the parent packageId to exist in the current Source Index',
1714
+ 'inspect the Source Index and choose a ScriptablePack parent with parameters',
1715
+ { requestId: operation.requestId, parent: parentText },
1716
+ ),
1717
+ );
1718
+ }
1719
+ if (parent.format === 'source' && !('parameters' in parent.definition)) {
1720
+ return err(
1721
+ makeError(
1722
+ 'pack-parent-has-no-parameters',
1723
+ 'a ScriptablePack source with a non-empty parameter list as the parent',
1724
+ 'use asset-source.clone for a zero-parameter Pack',
1725
+ { requestId: operation.requestId, parent: parentText },
1726
+ ),
1727
+ );
1728
+ }
1729
+ if (parent.format === 'direct') {
1730
+ return err(
1731
+ makeError(
1732
+ 'pack-parent-has-no-parameters',
1733
+ 'a ScriptablePack source or instance with parameters as the parent',
1734
+ 'direct Packs cannot be instance parents',
1735
+ { requestId: operation.requestId, parent: parentText },
1736
+ ),
1737
+ );
1738
+ }
1739
+ try {
1740
+ await stat(target.value.absolute);
1741
+ return err(
1742
+ pathError(
1743
+ 'pack-source-revision-conflict',
1744
+ operation,
1745
+ 'an unused instance target path',
1746
+ 'inspect the existing instance or choose another path',
1747
+ { targetPath: target.value.relative },
1748
+ target.value.relative,
1749
+ ),
1750
+ );
1751
+ } catch (cause) {
1752
+ if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') {
1753
+ return err(
1754
+ pathError(
1755
+ 'pack-source-write-failed',
1756
+ operation,
1757
+ 'the instance target to be writable',
1758
+ 'repair filesystem permissions and retry',
1759
+ { targetPath: target.value.relative, cause: String(cause) },
1760
+ ),
1761
+ );
1762
+ }
1763
+ }
1764
+ const identity = packageIdFromOperation(snapshot.value, operation);
1765
+ if (!identity.ok) return identity;
1766
+ const values = operation.values ?? {};
1767
+ const serializedValues = jsonValue(values) as Readonly<Record<string, unknown>>;
1768
+ const candidate: InstanceSubject = {
1769
+ format: 'instance',
1770
+ packageId: identity.value,
1771
+ parent: parentId.value,
1772
+ values,
1773
+ sourcePath: target.value.absolute,
1774
+ relativePath: target.value.relative,
1775
+ };
1776
+ const withCandidate: GatewaySnapshot = {
1777
+ ...snapshot.value,
1778
+ subjects: new Map([...snapshot.value.subjects, [packageKey(identity.value), candidate]]),
1779
+ };
1780
+ const resolved = await resolveInstance(withCandidate, candidate);
1781
+ if (!resolved.ok) return err(resolved.error);
1782
+ const source = `${JSON.stringify(
1783
+ {
1784
+ schemaVersion: '3.0.0',
1785
+ packageId: PackageId.format(identity.value),
1786
+ parent: PackageId.format(parentId.value),
1787
+ values: serializedValues,
1788
+ },
1789
+ null,
1790
+ 2,
1791
+ )}\n`;
1792
+ try {
1793
+ await atomicWrite(target.value.absolute, source);
1794
+ } catch (cause) {
1795
+ return err(
1796
+ pathError(
1797
+ 'pack-source-write-failed',
1798
+ operation,
1799
+ 'the new instance to be written atomically',
1800
+ 'repair filesystem permissions or disk capacity, then retry',
1801
+ {
1802
+ targetPath: target.value.relative,
1803
+ cause: cause instanceof Error ? cause.message : String(cause),
1804
+ },
1805
+ ),
1806
+ );
1807
+ }
1808
+ return inspectAfterWrite(operation, target.value, source, identity.value, {
1809
+ format: 'instance',
1810
+ parentPackageId: PackageId.format(parentId.value),
1811
+ values: serializedValues,
1812
+ effectiveValues: jsonValue(resolved.value.values) as Readonly<Record<string, unknown>>,
1813
+ parentChain: resolved.value.parentChain,
1814
+ });
1815
+ }
1816
+
1817
+ if (operation.operation === 'asset-source.apply-values') {
1818
+ const read = await readConfined(options, operation, operation.sourcePath);
1819
+ if (!read.ok) return read;
1820
+ if (
1821
+ operation.expectedRevision === undefined ||
1822
+ operation.expectedRevision !== read.value.revision
1823
+ ) {
1824
+ return err(revisionConflict(operation, read.value.relative, read.value.revision));
1825
+ }
1826
+ let json: unknown;
1827
+ try {
1828
+ json = JSON.parse(read.value.source);
1829
+ } catch {
1830
+ return err(
1831
+ makeError(
1832
+ 'pack-parameter-invalid',
1833
+ 'a valid v3 instance JSON document',
1834
+ 'repair the source before applying values',
1835
+ { requestId: operation.requestId, sourcePath: read.value.relative },
1836
+ ),
1837
+ );
1838
+ }
1839
+ const parsed = parsePackSourceJson(json);
1840
+ if (!parsed.ok) return err(parsed.error);
1841
+ if (parsed.value.format !== 'instance') {
1842
+ return err(
1843
+ makeError(
1844
+ 'pack-parameter-invalid',
1845
+ 'an instance pack.json subject',
1846
+ 'apply-values only edits parent+values instances',
1847
+ { requestId: operation.requestId, sourcePath: read.value.relative },
1848
+ ),
1849
+ );
1850
+ }
1851
+ const values = operation.values ?? {};
1852
+ const serializedValues = jsonValue(values) as Readonly<Record<string, unknown>>;
1853
+ const candidate: InstanceSubject = {
1854
+ format: 'instance',
1855
+ packageId: parsed.value.packageId,
1856
+ parent: parsed.value.parent,
1857
+ values,
1858
+ sourcePath: read.value.absolute,
1859
+ relativePath: read.value.relative,
1860
+ };
1861
+ const withCandidate: GatewaySnapshot = {
1862
+ ...snapshot.value,
1863
+ subjects: new Map([...snapshot.value.subjects, [packageKey(candidate.packageId), candidate]]),
1864
+ };
1865
+ const resolved = await resolveInstance(withCandidate, candidate);
1866
+ if (!resolved.ok) return err(resolved.error);
1867
+ const source = `${JSON.stringify(
1868
+ {
1869
+ schemaVersion: '3.0.0',
1870
+ packageId: PackageId.format(parsed.value.packageId),
1871
+ parent: PackageId.format(parsed.value.parent),
1872
+ values: serializedValues,
1873
+ },
1874
+ null,
1875
+ 2,
1876
+ )}\n`;
1877
+ try {
1878
+ await atomicWrite(read.value.absolute, source);
1879
+ } catch (cause) {
1880
+ return err(
1881
+ pathError(
1882
+ 'pack-source-write-failed',
1883
+ operation,
1884
+ 'the updated instance to be written atomically',
1885
+ 'repair filesystem permissions or disk capacity, then retry with a new requestId',
1886
+ {
1887
+ sourcePath: read.value.relative,
1888
+ cause: cause instanceof Error ? cause.message : String(cause),
1889
+ },
1890
+ ),
1891
+ );
1892
+ }
1893
+ return inspectAfterWrite(operation, read.value, source, parsed.value.packageId, {
1894
+ format: 'instance',
1895
+ parentPackageId: PackageId.format(parsed.value.parent),
1896
+ values: serializedValues,
1897
+ effectiveValues: jsonValue(resolved.value.values) as Readonly<Record<string, unknown>>,
1898
+ parentChain: resolved.value.parentChain,
1899
+ });
1900
+ }
1901
+
1902
+ if (
1903
+ operation.operation === 'asset-source.rebuild' ||
1904
+ operation.operation === 'asset-source.cold-cook'
1905
+ ) {
1906
+ const read = await readConfined(options, operation, operation.sourcePath);
1907
+ if (!read.ok) return read;
1908
+ if (
1909
+ operation.expectedRevision === undefined ||
1910
+ operation.expectedRevision !== read.value.revision
1911
+ ) {
1912
+ return err(revisionConflict(operation, read.value.relative, read.value.revision));
1913
+ }
1914
+ const rebuilt = await options.rebuild?.(
1915
+ read.value.relative,
1916
+ operation.operation === 'asset-source.rebuild' ? 'rebuild' : 'cold-cook',
1917
+ );
1918
+ if (rebuilt === undefined) {
1919
+ return err(
1920
+ makeError(
1921
+ 'pack-source-mutation-unsupported',
1922
+ 'a build-owner callback for rebuild or cold-cook',
1923
+ 'connect the gateway to the project build owner before requesting a mutation',
1924
+ { requestId: operation.requestId, sourcePath: read.value.relative },
1925
+ ),
1926
+ );
1927
+ }
1928
+ if (!rebuilt.ok) return err(rebuilt.error);
1929
+ const refreshed = await readConfined(options, operation, operation.sourcePath);
1930
+ if (!refreshed.ok) return refreshed;
1931
+ const refreshedSnapshot = await createSnapshot(options, operation);
1932
+ if (!refreshedSnapshot.ok) return refreshedSnapshot;
1933
+ const selected = subjectFor(options.gameRoot, refreshedSnapshot.value, operation);
1934
+ if (!selected.ok) return selected;
1935
+ return inspectAfterWrite(
1936
+ operation,
1937
+ refreshed.value,
1938
+ refreshed.value.source,
1939
+ selected.value.packageId,
1940
+ {
1941
+ format: refreshed.value.relative.endsWith('.pack.json') ? 'pack.json' : 'pack.ts',
1942
+ },
1943
+ );
1944
+ }
1945
+
1946
+ return err(
1947
+ makeError(
1948
+ 'pack-parameter-invalid',
1949
+ 'a supported Pack authoring operation',
1950
+ 'use one of PACK_AUTHORING_OPERATION_IDS',
1951
+ { requestId: operation.requestId, operation: operation.operation },
1952
+ ),
1953
+ );
1954
+ }
1955
+
1956
+ export function createFileSystemPackAuthoringPort(
1957
+ options: FileSystemPackAuthoringOptions,
1958
+ ): PackAuthoringGatewayPort<PackAuthoringOperationResult> {
1959
+ return { execute: (operation) => executeRaw(options, operation) };
1960
+ }
1961
+
1962
+ export function createFileSystemPackAuthoringGateway(
1963
+ options: FileSystemPackAuthoringOptions,
1964
+ ): PackAuthoringGatewayPort<PackAuthoringOperationResult> {
1965
+ return createPackAuthoringGateway(createFileSystemPackAuthoringPort(options));
1966
+ }