@highstate/backend 0.19.1 → 0.21.1

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 (134) hide show
  1. package/dist/chunk-b05q6fm2.js +37 -0
  2. package/dist/{chunk-V2NILDHS.js → chunk-gxjwa93h.js} +704 -604
  3. package/dist/{chunk-X2WG3WGL.js → chunk-vzdz6chj.js} +18 -15
  4. package/dist/highstate.manifest.json +4 -4
  5. package/dist/index.js +7350 -3514
  6. package/dist/library/package-resolution-worker.js +121 -10
  7. package/dist/library/worker/main.js +31 -17
  8. package/dist/shared/index.js +254 -4
  9. package/package.json +19 -20
  10. package/prisma/backend/_schema/object.prisma +12 -0
  11. package/prisma/backend/sqlite/migrations/20260222113554_add_object_tracking/migration.sql +7 -0
  12. package/prisma/project/artifact.prisma +3 -0
  13. package/prisma/project/entity.prisma +125 -0
  14. package/prisma/project/instance.prisma +6 -0
  15. package/prisma/project/migrations/20260301210131_add_entity_tracking/migration.sql +70 -0
  16. package/prisma/project/migrations/20260302212734_add_resource_hooks_flag/migration.sql +1 -0
  17. package/prisma/project/operation.prisma +3 -0
  18. package/src/artifact/factory.ts +3 -2
  19. package/src/business/artifact.test.ts +22 -2
  20. package/src/business/artifact.ts +7 -1
  21. package/src/business/entity-snapshot.test.ts +684 -0
  22. package/src/business/entity-snapshot.ts +904 -0
  23. package/src/business/evaluation.test.ts +56 -0
  24. package/src/business/evaluation.ts +102 -22
  25. package/src/business/global-search.test.ts +344 -0
  26. package/src/business/global-search.ts +902 -0
  27. package/src/business/index.ts +4 -0
  28. package/src/business/instance-lock.ts +58 -74
  29. package/src/business/instance-state.test.ts +15 -1
  30. package/src/business/instance-state.ts +37 -14
  31. package/src/business/object-ref-index.test.ts +140 -0
  32. package/src/business/object-ref-index.ts +193 -0
  33. package/src/business/operation.test.ts +15 -1
  34. package/src/business/operation.ts +4 -0
  35. package/src/business/project-model.ts +154 -13
  36. package/src/business/project-unlock.ts +25 -2
  37. package/src/business/project.ts +9 -0
  38. package/src/business/secret.test.ts +35 -2
  39. package/src/business/secret.ts +32 -9
  40. package/src/business/settings.ts +761 -0
  41. package/src/business/unit-output.test.ts +477 -0
  42. package/src/business/unit-output.ts +461 -0
  43. package/src/business/worker.ts +55 -4
  44. package/src/database/_generated/backend/postgresql/browser.ts +6 -0
  45. package/src/database/_generated/backend/postgresql/client.ts +6 -0
  46. package/src/database/_generated/backend/postgresql/internal/class.ts +23 -5
  47. package/src/database/_generated/backend/postgresql/internal/prismaNamespace.ts +89 -5
  48. package/src/database/_generated/backend/postgresql/internal/prismaNamespaceBrowser.ts +9 -0
  49. package/src/database/_generated/backend/postgresql/models/Object.ts +1076 -0
  50. package/src/database/_generated/backend/postgresql/models.ts +1 -0
  51. package/src/database/_generated/backend/sqlite/browser.ts +6 -0
  52. package/src/database/_generated/backend/sqlite/client.ts +6 -0
  53. package/src/database/_generated/backend/sqlite/internal/class.ts +23 -5
  54. package/src/database/_generated/backend/sqlite/internal/prismaNamespace.ts +89 -5
  55. package/src/database/_generated/backend/sqlite/internal/prismaNamespaceBrowser.ts +9 -0
  56. package/src/database/_generated/backend/sqlite/models/Object.ts +1074 -0
  57. package/src/database/_generated/backend/sqlite/models.ts +1 -0
  58. package/src/database/_generated/project/browser.ts +23 -0
  59. package/src/database/_generated/project/client.ts +23 -0
  60. package/src/database/_generated/project/commonInputTypes.ts +87 -53
  61. package/src/database/_generated/project/enums.ts +8 -0
  62. package/src/database/_generated/project/internal/class.ts +53 -5
  63. package/src/database/_generated/project/internal/prismaNamespace.ts +367 -13
  64. package/src/database/_generated/project/internal/prismaNamespaceBrowser.ts +48 -1
  65. package/src/database/_generated/project/models/Artifact.ts +199 -11
  66. package/src/database/_generated/project/models/Entity.ts +1274 -0
  67. package/src/database/_generated/project/models/EntitySnapshot.ts +2389 -0
  68. package/src/database/_generated/project/models/EntitySnapshotContent.ts +1260 -0
  69. package/src/database/_generated/project/models/EntitySnapshotReference.ts +1449 -0
  70. package/src/database/_generated/project/models/InstanceState.ts +361 -1
  71. package/src/database/_generated/project/models/Operation.ts +148 -3
  72. package/src/database/_generated/project/models/OperationLog.ts +0 -4
  73. package/src/database/_generated/project/models.ts +4 -0
  74. package/src/database/migration.ts +3 -0
  75. package/src/library/find-package-json.test.ts +77 -0
  76. package/src/library/find-package-json.ts +149 -0
  77. package/src/library/package-resolution-worker.ts +7 -3
  78. package/src/library/worker/evaluator.ts +7 -1
  79. package/src/orchestrator/manager.ts +7 -0
  80. package/src/orchestrator/operation-context.captured-outputs.test.ts +118 -0
  81. package/src/orchestrator/operation-context.ts +154 -16
  82. package/src/orchestrator/operation-plan.destroy.test.md +33 -12
  83. package/src/orchestrator/operation-plan.destroy.test.ts +140 -2
  84. package/src/orchestrator/operation-plan.fixtures.ts +2 -0
  85. package/src/orchestrator/operation-plan.md +4 -1
  86. package/src/orchestrator/operation-plan.ts +286 -92
  87. package/src/orchestrator/operation-plan.update.test.md +286 -11
  88. package/src/orchestrator/operation-plan.update.test.ts +656 -5
  89. package/src/orchestrator/operation-workset.ts +72 -22
  90. package/src/orchestrator/operation.cancel.test.ts +4 -0
  91. package/src/orchestrator/operation.composite.test.ts +341 -0
  92. package/src/orchestrator/operation.destroy.test.ts +4 -0
  93. package/src/orchestrator/operation.output-validation.failure.test.ts +124 -0
  94. package/src/orchestrator/operation.preview.test.ts +4 -0
  95. package/src/orchestrator/operation.refresh.test.ts +4 -0
  96. package/src/orchestrator/operation.test-utils.ts +52 -13
  97. package/src/orchestrator/operation.ts +230 -68
  98. package/src/orchestrator/operation.update.failure.test.ts +4 -0
  99. package/src/orchestrator/operation.update.skip.test.ts +196 -0
  100. package/src/orchestrator/operation.update.test.ts +4 -0
  101. package/src/orchestrator/plan-test-builder.ts +1 -0
  102. package/src/orchestrator/unit-input-values.test.ts +450 -0
  103. package/src/orchestrator/unit-input-values.ts +281 -0
  104. package/src/pubsub/manager.ts +3 -0
  105. package/src/runner/abstractions.ts +23 -54
  106. package/src/runner/factory.ts +3 -3
  107. package/src/runner/force-abort.ts +7 -2
  108. package/src/runner/local.ts +116 -87
  109. package/src/runner/pulumi.ts +3 -5
  110. package/src/services.ts +53 -2
  111. package/src/shared/models/prisma.ts +1 -0
  112. package/src/shared/models/project/entity.ts +121 -0
  113. package/src/shared/models/project/index.ts +1 -0
  114. package/src/shared/models/project/operation.ts +61 -3
  115. package/src/shared/models/project/state.ts +10 -0
  116. package/src/shared/models/project/worker.ts +7 -0
  117. package/src/shared/resolvers/effective-output-type.test.ts +494 -0
  118. package/src/shared/resolvers/effective-output-type.ts +162 -0
  119. package/src/shared/resolvers/index.ts +1 -0
  120. package/src/shared/resolvers/input.ts +59 -9
  121. package/src/shared/utils/index.ts +1 -0
  122. package/src/shared/utils/stable-json.ts +41 -0
  123. package/src/terminal/manager.ts +6 -0
  124. package/src/terminal/run.sh.ts +9 -4
  125. package/src/worker/manager.ts +97 -1
  126. package/LICENSE +0 -21
  127. package/dist/chunk-I7BWSAN6.js +0 -49
  128. package/dist/chunk-I7BWSAN6.js.map +0 -1
  129. package/dist/chunk-V2NILDHS.js.map +0 -1
  130. package/dist/chunk-X2WG3WGL.js.map +0 -1
  131. package/dist/index.js.map +0 -1
  132. package/dist/library/package-resolution-worker.js.map +0 -1
  133. package/dist/library/worker/main.js.map +0 -1
  134. package/dist/shared/index.js.map +0 -1
@@ -0,0 +1,904 @@
1
+ import type { Logger } from "pino"
2
+ import type { ObjectRefIndexService } from "./object-ref-index"
3
+ import type { UnitEntitySnapshotPayload } from "./unit-output"
4
+ import { createHash } from "node:crypto"
5
+ import { createId } from "@paralleldrive/cuid2"
6
+ import { PrismaClientKnownRequestError } from "@prisma/client/runtime/client"
7
+ import { type DatabaseManager, DbNull, type ProjectTransaction } from "../database"
8
+ import { type LibraryModel, stableJsonStringify } from "../shared"
9
+
10
+ function sha256String(value: string): string {
11
+ return createHash("sha256").update(value).digest("hex")
12
+ }
13
+
14
+ export type CapturedEntitySnapshotValue =
15
+ | {
16
+ ok: true
17
+ value: Record<string, unknown>
18
+ }
19
+ | {
20
+ ok: false
21
+ error: {
22
+ message: string
23
+ snapshotId: string
24
+ entityType?: string
25
+ entityIdentity?: string
26
+ }
27
+ }
28
+
29
+ export type OutputReferencedEntitySnapshot = {
30
+ snapshotId: string
31
+ entityId: string
32
+ entityType: string
33
+ entityIdentity: string
34
+ content: unknown
35
+ }
36
+
37
+ export class EntitySnapshotService {
38
+ constructor(
39
+ private readonly database: DatabaseManager,
40
+ private readonly objectRefIndexService: ObjectRefIndexService,
41
+ private readonly logger: Logger,
42
+ ) {}
43
+
44
+ /**
45
+ * Reconstructs entity values exported by outputs from persisted normalized snapshots.
46
+ *
47
+ * The reconstruction is driven by the entity model inclusions.
48
+ * Reference groups that do not match inclusion field names are ignored.
49
+ * Reconstruction issues (for example, missing required inclusions) are captured as `ok: false`.
50
+ *
51
+ * The result is grouped by `${stateId}:${output}`.
52
+ */
53
+ async reconstructLatestExportedOutputValues(
54
+ projectId: string,
55
+ keys: { stateId: string; output: string; operationId?: string }[],
56
+ library: LibraryModel,
57
+ ): Promise<Map<string, CapturedEntitySnapshotValue[]>> {
58
+ if (keys.length === 0) {
59
+ return new Map()
60
+ }
61
+
62
+ const uniqueKeys = new Map<string, { stateId: string; output: string; operationId?: string }>()
63
+ for (const key of keys) {
64
+ uniqueKeys.set(`${key.stateId}:${key.output}`, key)
65
+ }
66
+
67
+ const projectDatabase = await this.database.forProject(projectId)
68
+
69
+ const result = new Map<string, CapturedEntitySnapshotValue[]>()
70
+
71
+ for (const key of uniqueKeys.values()) {
72
+ const resolvedOperationId =
73
+ key.operationId ??
74
+ (await this.findLatestOperationIdForExportedOutput(
75
+ projectDatabase,
76
+ key.stateId,
77
+ key.output,
78
+ ))
79
+
80
+ if (!resolvedOperationId) {
81
+ result.set(`${key.stateId}:${key.output}`, [])
82
+ continue
83
+ }
84
+
85
+ const snapshotsInOperationRaw = await projectDatabase.entitySnapshot.findMany({
86
+ where: { stateId: key.stateId, operationId: resolvedOperationId },
87
+ orderBy: { createdAt: "asc" },
88
+ select: {
89
+ id: true,
90
+ entityId: true,
91
+ content: {
92
+ select: {
93
+ meta: true,
94
+ content: true,
95
+ },
96
+ },
97
+ exportedInOutputs: true,
98
+ referencedInOutputs: true,
99
+ entity: {
100
+ select: {
101
+ type: true,
102
+ identity: true,
103
+ },
104
+ },
105
+ },
106
+ })
107
+
108
+ const snapshotsInOperation = snapshotsInOperationRaw.map(snapshot => ({
109
+ ...snapshot,
110
+ meta: snapshot.content.meta,
111
+ content: snapshot.content.content,
112
+ }))
113
+
114
+ const rootSnapshotIds = snapshotsInOperation
115
+ .filter(s => this.jsonStringArrayIncludes(s.exportedInOutputs, key.output))
116
+ .map(s => s.id)
117
+
118
+ if (rootSnapshotIds.length === 0) {
119
+ result.set(`${key.stateId}:${key.output}`, [])
120
+ continue
121
+ }
122
+
123
+ let snapshotById: Map<
124
+ string,
125
+ {
126
+ id: string
127
+ entityId: string
128
+ content: unknown
129
+ meta: unknown
130
+ entity: { type: string; identity: string }
131
+ }
132
+ >
133
+ let referencesByFromId: Map<string, Map<string, string[]>>
134
+
135
+ try {
136
+ ;({ snapshotById, referencesByFromId } = await this.loadSnapshotGraph(
137
+ projectDatabase,
138
+ snapshotsInOperation,
139
+ ))
140
+ } catch (error) {
141
+ const message = error instanceof Error ? error.message : String(error)
142
+
143
+ this.logger.warn(
144
+ {
145
+ projectId,
146
+ stateId: key.stateId,
147
+ output: key.output,
148
+ error: message,
149
+ },
150
+ "failed to load entity snapshot graph for captured output",
151
+ )
152
+
153
+ const snapshotInfoById = new Map(snapshotsInOperation.map(s => [s.id, s] as const))
154
+
155
+ result.set(
156
+ `${key.stateId}:${key.output}`,
157
+ rootSnapshotIds.map(rootId => {
158
+ const snapshot = snapshotInfoById.get(rootId)
159
+ return {
160
+ ok: false,
161
+ error: {
162
+ message,
163
+ snapshotId: rootId,
164
+ entityType: snapshot?.entity.type,
165
+ entityIdentity: snapshot?.entity.identity,
166
+ },
167
+ }
168
+ }),
169
+ )
170
+
171
+ continue
172
+ }
173
+
174
+ const reconstructed: CapturedEntitySnapshotValue[] = []
175
+
176
+ for (const rootId of rootSnapshotIds) {
177
+ try {
178
+ reconstructed.push({
179
+ ok: true,
180
+ value: this.reconstructSnapshotValue({
181
+ snapshotId: rootId,
182
+ snapshotById,
183
+ referencesByFromId,
184
+ library,
185
+ includeSnapshotIdInMeta: false,
186
+ stack: [],
187
+ }),
188
+ })
189
+ } catch (error) {
190
+ const message = error instanceof Error ? error.message : String(error)
191
+ const snapshot = snapshotById.get(rootId)
192
+
193
+ this.logger.warn(
194
+ {
195
+ projectId,
196
+ stateId: key.stateId,
197
+ output: key.output,
198
+ snapshotId: rootId,
199
+ entityType: snapshot?.entity.type,
200
+ entityIdentity: snapshot?.entity.identity,
201
+ error: message,
202
+ },
203
+ "failed to reconstruct captured entity snapshot",
204
+ )
205
+
206
+ reconstructed.push({
207
+ ok: false,
208
+ error: {
209
+ message,
210
+ snapshotId: rootId,
211
+ entityType: snapshot?.entity.type,
212
+ entityIdentity: snapshot?.entity.identity,
213
+ },
214
+ })
215
+ }
216
+ }
217
+
218
+ result.set(`${key.stateId}:${key.output}`, reconstructed)
219
+ }
220
+
221
+ return result
222
+ }
223
+
224
+ /**
225
+ * Lists all persisted entity snapshots that belong to the given output in the latest operation.
226
+ *
227
+ * The result includes both the root exported entity snapshot(s) and all snapshots that were
228
+ * referenced from that output (directly or indirectly) during the same operation.
229
+ *
230
+ * This is intended for UI panels that need to inspect referenced entities without reconstructing
231
+ * full exported output values.
232
+ *
233
+ * @param projectId The ID of the project.
234
+ * @param stateId The instance state ID.
235
+ * @param output The output name.
236
+ * @returns A list of snapshots associated with the output.
237
+ */
238
+ async listReferencedEntitySnapshotsForOutput(
239
+ projectId: string,
240
+ stateId: string,
241
+ output: string,
242
+ library?: LibraryModel,
243
+ ): Promise<OutputReferencedEntitySnapshot[]> {
244
+ const projectDatabase = await this.database.forProject(projectId)
245
+
246
+ const operationId = await this.findLatestOperationIdForOutput(projectDatabase, stateId, output)
247
+
248
+ if (!operationId) {
249
+ return []
250
+ }
251
+
252
+ const snapshotsInOperationRaw = await projectDatabase.entitySnapshot.findMany({
253
+ where: { stateId, operationId },
254
+ orderBy: { createdAt: "asc" },
255
+ select: {
256
+ id: true,
257
+ entityId: true,
258
+ content: {
259
+ select: {
260
+ meta: true,
261
+ content: true,
262
+ },
263
+ },
264
+ exportedInOutputs: true,
265
+ referencedInOutputs: true,
266
+ entity: {
267
+ select: {
268
+ type: true,
269
+ identity: true,
270
+ },
271
+ },
272
+ },
273
+ })
274
+
275
+ const snapshotsInOperation = snapshotsInOperationRaw.map(snapshot => ({
276
+ ...snapshot,
277
+ meta: snapshot.content.meta,
278
+ content: snapshot.content.content,
279
+ }))
280
+
281
+ const matching = snapshotsInOperation.filter(
282
+ snapshot =>
283
+ this.jsonStringArrayIncludes(snapshot.exportedInOutputs, output) ||
284
+ this.jsonStringArrayIncludes(snapshot.referencedInOutputs, output),
285
+ )
286
+
287
+ if (!library) {
288
+ return matching.map(snapshot => ({
289
+ snapshotId: snapshot.id,
290
+ entityId: snapshot.entityId,
291
+ entityType: snapshot.entity.type,
292
+ entityIdentity: snapshot.entity.identity,
293
+ content: snapshot.content,
294
+ }))
295
+ }
296
+
297
+ const { snapshotById, referencesByFromId } = await this.loadSnapshotGraph(
298
+ projectDatabase,
299
+ snapshotsInOperation,
300
+ )
301
+
302
+ return matching.map(snapshot => ({
303
+ snapshotId: snapshot.id,
304
+ entityId: snapshot.entityId,
305
+ entityType: snapshot.entity.type,
306
+ entityIdentity: snapshot.entity.identity,
307
+ content: this.reconstructSnapshotValue({
308
+ snapshotId: snapshot.id,
309
+ snapshotById,
310
+ referencesByFromId,
311
+ library,
312
+ includeSnapshotIdInMeta: true,
313
+ stack: [],
314
+ }),
315
+ }))
316
+ }
317
+
318
+ /**
319
+ * Reconstructs a persisted entity snapshot content by following inclusions within the operation.
320
+ *
321
+ * This powers UI views that want to show the same reconstructed shape as exported output popups.
322
+ *
323
+ * @param projectId The ID of the project.
324
+ * @param snapshotId The ID of the snapshot to reconstruct.
325
+ * @param library The loaded library model.
326
+ * @returns The reconstructed snapshot content.
327
+ */
328
+ async reconstructSnapshotContent(
329
+ projectId: string,
330
+ snapshotId: string,
331
+ library: LibraryModel,
332
+ ): Promise<unknown | null> {
333
+ const projectDatabase = await this.database.forProject(projectId)
334
+
335
+ const snapshot = await projectDatabase.entitySnapshot.findUnique({
336
+ where: { id: snapshotId },
337
+ select: {
338
+ id: true,
339
+ stateId: true,
340
+ operationId: true,
341
+ },
342
+ })
343
+
344
+ if (!snapshot) {
345
+ return null
346
+ }
347
+
348
+ const snapshotsInOperationRaw = await projectDatabase.entitySnapshot.findMany({
349
+ where: { stateId: snapshot.stateId, operationId: snapshot.operationId },
350
+ orderBy: { createdAt: "asc" },
351
+ select: {
352
+ id: true,
353
+ entityId: true,
354
+ content: {
355
+ select: {
356
+ meta: true,
357
+ content: true,
358
+ },
359
+ },
360
+ exportedInOutputs: true,
361
+ referencedInOutputs: true,
362
+ entity: {
363
+ select: {
364
+ type: true,
365
+ identity: true,
366
+ },
367
+ },
368
+ },
369
+ })
370
+
371
+ const snapshotsInOperation = snapshotsInOperationRaw.map(snapshot => ({
372
+ ...snapshot,
373
+ meta: snapshot.content.meta,
374
+ content: snapshot.content.content,
375
+ }))
376
+
377
+ const { snapshotById, referencesByFromId } = await this.loadSnapshotGraph(
378
+ projectDatabase,
379
+ snapshotsInOperation,
380
+ )
381
+
382
+ return this.reconstructSnapshotValue({
383
+ snapshotId: snapshot.id,
384
+ snapshotById,
385
+ referencesByFromId,
386
+ library,
387
+ includeSnapshotIdInMeta: true,
388
+ stack: [],
389
+ })
390
+ }
391
+
392
+ private async findLatestOperationIdForExportedOutput(
393
+ projectDatabase: Awaited<ReturnType<DatabaseManager["forProject"]>>,
394
+ stateId: string,
395
+ output: string,
396
+ ): Promise<string | undefined> {
397
+ const candidates = await projectDatabase.entitySnapshot.findMany({
398
+ where: { stateId },
399
+ orderBy: { createdAt: "desc" },
400
+ take: 500,
401
+ select: {
402
+ operationId: true,
403
+ exportedInOutputs: true,
404
+ },
405
+ })
406
+
407
+ const match = candidates.find(s => this.jsonStringArrayIncludes(s.exportedInOutputs, output))
408
+ return match?.operationId
409
+ }
410
+
411
+ private async findLatestOperationIdForOutput(
412
+ projectDatabase: Awaited<ReturnType<DatabaseManager["forProject"]>>,
413
+ stateId: string,
414
+ output: string,
415
+ ): Promise<string | undefined> {
416
+ const candidates = await projectDatabase.entitySnapshot.findMany({
417
+ where: { stateId },
418
+ orderBy: { createdAt: "desc" },
419
+ take: 500,
420
+ select: {
421
+ operationId: true,
422
+ exportedInOutputs: true,
423
+ referencedInOutputs: true,
424
+ },
425
+ })
426
+
427
+ const match = candidates.find(
428
+ s =>
429
+ this.jsonStringArrayIncludes(s.exportedInOutputs, output) ||
430
+ this.jsonStringArrayIncludes(s.referencedInOutputs, output),
431
+ )
432
+
433
+ return match?.operationId
434
+ }
435
+
436
+ private jsonStringArrayIncludes(value: unknown, item: string): boolean {
437
+ if (!Array.isArray(value)) {
438
+ return false
439
+ }
440
+
441
+ return value.some(x => x === item)
442
+ }
443
+
444
+ private async loadSnapshotGraph(
445
+ projectDatabase: Awaited<ReturnType<DatabaseManager["forProject"]>>,
446
+ initialSnapshots: Array<{
447
+ id: string
448
+ entityId: string
449
+ content: unknown
450
+ meta: unknown
451
+ exportedInOutputs: unknown
452
+ referencedInOutputs: unknown
453
+ entity: { type: string; identity: string }
454
+ }>,
455
+ ): Promise<{
456
+ snapshotById: Map<
457
+ string,
458
+ {
459
+ id: string
460
+ entityId: string
461
+ content: unknown
462
+ meta: unknown
463
+ entity: { type: string; identity: string }
464
+ }
465
+ >
466
+ referencesByFromId: Map<string, Map<string, string[]>>
467
+ }> {
468
+ const snapshotById = new Map<
469
+ string,
470
+ {
471
+ id: string
472
+ entityId: string
473
+ content: unknown
474
+ meta: unknown
475
+ entity: { type: string; identity: string }
476
+ }
477
+ >()
478
+
479
+ for (const snapshot of initialSnapshots) {
480
+ snapshotById.set(snapshot.id, {
481
+ id: snapshot.id,
482
+ entityId: snapshot.entityId,
483
+ content: snapshot.content,
484
+ meta: snapshot.meta,
485
+ entity: snapshot.entity,
486
+ })
487
+ }
488
+
489
+ const referencesByFromId = new Map<string, Map<string, string[]>>()
490
+
491
+ let frontier = Array.from(snapshotById.keys())
492
+ const seen = new Set(frontier)
493
+
494
+ const maxSnapshots = 5000
495
+
496
+ while (frontier.length > 0) {
497
+ const refs = await projectDatabase.entitySnapshotReference.findMany({
498
+ where: { fromId: { in: frontier }, kind: "inclusion" },
499
+ select: { fromId: true, toId: true, group: true },
500
+ })
501
+
502
+ const nextToIds: string[] = []
503
+
504
+ for (const ref of refs) {
505
+ const groupMap = referencesByFromId.get(ref.fromId) ?? new Map<string, string[]>()
506
+ const list = groupMap.get(ref.group) ?? []
507
+ list.push(ref.toId)
508
+ groupMap.set(ref.group, list)
509
+ referencesByFromId.set(ref.fromId, groupMap)
510
+
511
+ if (!seen.has(ref.toId)) {
512
+ nextToIds.push(ref.toId)
513
+ }
514
+ }
515
+
516
+ const uniqueNextToIds = Array.from(new Set(nextToIds))
517
+ if (uniqueNextToIds.length === 0) {
518
+ break
519
+ }
520
+
521
+ const missing = uniqueNextToIds.filter(id => !snapshotById.has(id))
522
+ if (missing.length > 0) {
523
+ const loaded = await projectDatabase.entitySnapshot.findMany({
524
+ where: { id: { in: missing } },
525
+ select: {
526
+ id: true,
527
+ entityId: true,
528
+ content: {
529
+ select: {
530
+ meta: true,
531
+ content: true,
532
+ },
533
+ },
534
+ entity: { select: { type: true, identity: true } },
535
+ },
536
+ })
537
+
538
+ for (const snapshot of loaded) {
539
+ snapshotById.set(snapshot.id, {
540
+ id: snapshot.id,
541
+ entityId: snapshot.entityId,
542
+ content: snapshot.content.content,
543
+ meta: snapshot.content.meta,
544
+ entity: snapshot.entity,
545
+ })
546
+ seen.add(snapshot.id)
547
+ }
548
+
549
+ if (snapshotById.size > maxSnapshots) {
550
+ throw new Error("Entity snapshot graph is too large to reconstruct")
551
+ }
552
+ }
553
+
554
+ frontier = uniqueNextToIds
555
+ }
556
+
557
+ return { snapshotById, referencesByFromId }
558
+ }
559
+
560
+ private reconstructSnapshotValue(options: {
561
+ snapshotId: string
562
+ snapshotById: Map<
563
+ string,
564
+ {
565
+ id: string
566
+ entityId: string
567
+ content: unknown
568
+ meta: unknown
569
+ entity: { type: string; identity: string }
570
+ }
571
+ >
572
+ referencesByFromId: Map<string, Map<string, string[]>>
573
+ library: LibraryModel
574
+ includeSnapshotIdInMeta: boolean
575
+ stack: string[]
576
+ }): Record<string, unknown> {
577
+ if (options.stack.includes(options.snapshotId)) {
578
+ throw new Error(
579
+ `Detected entity snapshot cycle during reconstruction: "${options.snapshotId}"`,
580
+ )
581
+ }
582
+
583
+ const snapshot = options.snapshotById.get(options.snapshotId)
584
+ if (!snapshot) {
585
+ throw new Error(`Snapshot "${options.snapshotId}" not found during reconstruction`)
586
+ }
587
+
588
+ const content = snapshot.content
589
+ if (typeof content !== "object" || content === null || Array.isArray(content)) {
590
+ throw new Error(`Entity snapshot content is not an object for snapshot "${snapshot.id}"`)
591
+ }
592
+
593
+ const meta: Record<string, unknown> = {
594
+ type: snapshot.entity.type,
595
+ identity: snapshot.entity.identity,
596
+ ...(this.normalizeSnapshotMeta(snapshot.meta) ?? {}),
597
+ }
598
+
599
+ if (options.includeSnapshotIdInMeta) {
600
+ meta.snapshotId = snapshot.id
601
+ meta.entityId = snapshot.entityId
602
+ }
603
+
604
+ const value: Record<string, unknown> = {
605
+ $meta: meta,
606
+ ...(content as Record<string, unknown>),
607
+ }
608
+
609
+ const entityModel = options.library.entities[snapshot.entity.type]
610
+ if (!entityModel) {
611
+ throw new Error(`Entity type "${snapshot.entity.type}" is not defined in the library`)
612
+ }
613
+
614
+ const refsByGroup = options.referencesByFromId.get(snapshot.id) ?? new Map<string, string[]>()
615
+
616
+ for (const inclusion of entityModel.inclusions ?? []) {
617
+ const toIds = refsByGroup.get(inclusion.field) ?? []
618
+
619
+ if (inclusion.multiple) {
620
+ if (toIds.length === 0) {
621
+ if (inclusion.required) {
622
+ throw new Error(
623
+ `Missing required inclusion "${inclusion.field}" on entity "${snapshot.entity.type}"`,
624
+ )
625
+ }
626
+
627
+ value[inclusion.field] = []
628
+ continue
629
+ }
630
+
631
+ value[inclusion.field] = toIds.map(toId =>
632
+ this.reconstructSnapshotValue({
633
+ snapshotId: toId,
634
+ snapshotById: options.snapshotById,
635
+ referencesByFromId: options.referencesByFromId,
636
+ library: options.library,
637
+ includeSnapshotIdInMeta: options.includeSnapshotIdInMeta,
638
+ stack: [...options.stack, options.snapshotId],
639
+ }),
640
+ )
641
+ continue
642
+ }
643
+
644
+ if (toIds.length === 0) {
645
+ if (inclusion.required) {
646
+ throw new Error(
647
+ `Missing required inclusion "${inclusion.field}" on entity "${snapshot.entity.type}"`,
648
+ )
649
+ }
650
+
651
+ continue
652
+ }
653
+
654
+ if (toIds.length > 1) {
655
+ throw new Error(
656
+ `Multiple references found for single inclusion "${inclusion.field}" on entity "${snapshot.entity.type}"`,
657
+ )
658
+ }
659
+
660
+ value[inclusion.field] = this.reconstructSnapshotValue({
661
+ snapshotId: toIds[0]!,
662
+ snapshotById: options.snapshotById,
663
+ referencesByFromId: options.referencesByFromId,
664
+ library: options.library,
665
+ includeSnapshotIdInMeta: options.includeSnapshotIdInMeta,
666
+ stack: [...options.stack, options.snapshotId],
667
+ })
668
+ }
669
+
670
+ return value
671
+ }
672
+
673
+ private normalizeSnapshotMeta(meta: unknown): Record<string, unknown> | null {
674
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
675
+ return null
676
+ }
677
+
678
+ const record = meta as Record<string, unknown>
679
+ const normalized: Record<string, unknown> = {}
680
+
681
+ if (typeof record.title === "string" && record.title.length > 0) {
682
+ normalized.title = record.title
683
+ }
684
+ if (typeof record.description === "string" && record.description.length > 0) {
685
+ normalized.description = record.description
686
+ }
687
+ if (typeof record.icon === "string" && record.icon.length > 0) {
688
+ normalized.icon = record.icon
689
+ }
690
+ if (typeof record.iconColor === "string" && record.iconColor.length > 0) {
691
+ normalized.iconColor = record.iconColor
692
+ }
693
+
694
+ return Object.keys(normalized).length > 0 ? normalized : null
695
+ }
696
+
697
+ /**
698
+ * Persists all entity snapshots produced by a unit run.
699
+ *
700
+ * It creates or updates the corresponding entity rows,
701
+ * stores immutable snapshots with operation + state provenance,
702
+ * and materializes both implicit and explicit snapshot references.
703
+ *
704
+ * @param options The persistence parameters for a single unit completion.
705
+ */
706
+ async persistUnitEntitySnapshots(options: {
707
+ projectId: string
708
+ operationId: string
709
+ stateId: string
710
+ payload: UnitEntitySnapshotPayload
711
+ }): Promise<void> {
712
+ const projectDatabase = await this.database.forProject(options.projectId)
713
+
714
+ const { entityIds, snapshotIds } = await projectDatabase.$transaction(async tx => {
715
+ return await this.persistUnitEntitySnapshotsInTransaction(tx, options)
716
+ })
717
+
718
+ const idsToTrack = [...entityIds, ...snapshotIds]
719
+ if (idsToTrack.length > 0) {
720
+ await this.objectRefIndexService.track(options.projectId, idsToTrack)
721
+ }
722
+ }
723
+
724
+ private async persistUnitEntitySnapshotsInTransaction(
725
+ tx: ProjectTransaction,
726
+ options: {
727
+ projectId: string
728
+ operationId: string
729
+ stateId: string
730
+ payload: UnitEntitySnapshotPayload
731
+ },
732
+ ): Promise<{ entityIds: string[]; snapshotIds: string[] }> {
733
+ const snapshotIdByEntityId = new Map<string, string>()
734
+ const entityIds = new Set<string>()
735
+ const snapshotIds = new Set<string>()
736
+
737
+ const contentByHash = new Map<
738
+ string,
739
+ {
740
+ meta: Record<string, unknown> | null
741
+ content: unknown
742
+ }
743
+ >()
744
+ const preparedNodes: Array<{
745
+ snapshotId: string
746
+ contentHash: string
747
+ node: UnitEntitySnapshotPayload["nodes"][number]
748
+ }> = []
749
+
750
+ for (const node of options.payload.nodes) {
751
+ const snapshotId = createId()
752
+ const entityId = node.entityId
753
+ snapshotIdByEntityId.set(entityId, snapshotId)
754
+ entityIds.add(entityId)
755
+ snapshotIds.add(snapshotId)
756
+
757
+ const meta = this.normalizeSnapshotMeta(node.meta)
758
+ const contentStable = stableJsonStringify(node.content)
759
+ const contentHash = sha256String(
760
+ meta ? `${stableJsonStringify(meta)}\n${contentStable}` : contentStable,
761
+ )
762
+ if (!contentByHash.has(contentHash)) {
763
+ contentByHash.set(contentHash, {
764
+ meta,
765
+ content: node.content,
766
+ })
767
+ }
768
+
769
+ preparedNodes.push({ snapshotId, contentHash, node })
770
+
771
+ await tx.entity.upsert({
772
+ where: { id: entityId },
773
+ create: {
774
+ id: entityId,
775
+ type: node.entityType,
776
+ identity: node.identity,
777
+ },
778
+ update: {
779
+ type: node.entityType,
780
+ identity: node.identity,
781
+ },
782
+ })
783
+ }
784
+
785
+ if (contentByHash.size > 0) {
786
+ for (const [hash, entry] of contentByHash.entries()) {
787
+ try {
788
+ await tx.entitySnapshotContent.create({
789
+ data: {
790
+ hash,
791
+ meta: entry.meta ?? DbNull,
792
+ content: entry.content,
793
+ },
794
+ })
795
+ } catch (error) {
796
+ if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
797
+ continue
798
+ }
799
+
800
+ throw error
801
+ }
802
+ }
803
+ }
804
+
805
+ for (const prepared of preparedNodes) {
806
+ const node = prepared.node
807
+
808
+ await tx.entitySnapshot.create({
809
+ data: {
810
+ id: prepared.snapshotId,
811
+ entityId: node.entityId,
812
+ operationId: options.operationId,
813
+ stateId: options.stateId,
814
+ referencedInOutputs: node.referencedOutputs,
815
+ exportedInOutputs: node.exportedOutputs,
816
+ contentHash: prepared.contentHash,
817
+ },
818
+ })
819
+ }
820
+
821
+ const uniqueEdges = new Map<
822
+ string,
823
+ { fromId: string; toId: string; kind: "explicit" | "inclusion"; group: string }
824
+ >()
825
+
826
+ for (const ref of options.payload.implicitReferences) {
827
+ const fromId = snapshotIdByEntityId.get(ref.fromEntityId)
828
+ const toId = snapshotIdByEntityId.get(ref.toEntityId)
829
+ if (!fromId || !toId) {
830
+ throw new Error("Failed to resolve implicit entity snapshot reference")
831
+ }
832
+
833
+ if (fromId === toId) {
834
+ continue
835
+ }
836
+
837
+ uniqueEdges.set(`${fromId}:${toId}:inclusion:${ref.group}`, {
838
+ fromId,
839
+ toId,
840
+ kind: "inclusion",
841
+ group: ref.group,
842
+ })
843
+ }
844
+
845
+ for (const ref of options.payload.explicitReferences) {
846
+ const fromId = snapshotIdByEntityId.get(ref.fromEntityId)
847
+ if (!fromId) {
848
+ throw new Error("Failed to resolve explicit entity snapshot reference source")
849
+ }
850
+
851
+ if (ref.fromEntityId === ref.toEntityId) {
852
+ continue
853
+ }
854
+
855
+ const toSnapshotInPayload = snapshotIdByEntityId.get(ref.toEntityId)
856
+ if (toSnapshotInPayload) {
857
+ if (fromId !== toSnapshotInPayload) {
858
+ uniqueEdges.set(`${fromId}:${toSnapshotInPayload}:explicit:${ref.group}`, {
859
+ fromId,
860
+ toId: toSnapshotInPayload,
861
+ kind: "explicit",
862
+ group: ref.group,
863
+ })
864
+ }
865
+ continue
866
+ }
867
+
868
+ const snapshot = await tx.entitySnapshot.findFirst({
869
+ where: { entityId: ref.toEntityId },
870
+ orderBy: { createdAt: "desc" },
871
+ select: { id: true },
872
+ })
873
+
874
+ if (!snapshot) {
875
+ this.logger.error(
876
+ {
877
+ projectId: options.projectId,
878
+ operationId: options.operationId,
879
+ entityId: ref.toEntityId,
880
+ },
881
+ "referenced entity not found",
882
+ )
883
+ throw new Error(`Referenced entity "${ref.toEntityId}" does not exist`)
884
+ }
885
+
886
+ if (fromId !== snapshot.id) {
887
+ uniqueEdges.set(`${fromId}:${snapshot.id}:explicit:${ref.group}`, {
888
+ fromId,
889
+ toId: snapshot.id,
890
+ kind: "explicit",
891
+ group: ref.group,
892
+ })
893
+ }
894
+ }
895
+
896
+ if (uniqueEdges.size > 0) {
897
+ await tx.entitySnapshotReference.createMany({
898
+ data: Array.from(uniqueEdges.values()),
899
+ })
900
+ }
901
+
902
+ return { entityIds: Array.from(entityIds), snapshotIds: Array.from(snapshotIds) }
903
+ }
904
+ }