@highstate/backend 0.19.1 → 0.20.0
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.
- package/dist/{chunk-V2NILDHS.js → chunk-52MY2TCE.js} +347 -19
- package/dist/chunk-52MY2TCE.js.map +1 -0
- package/dist/{chunk-I7BWSAN6.js → chunk-UAWBPTDW.js} +3 -3
- package/dist/{chunk-I7BWSAN6.js.map → chunk-UAWBPTDW.js.map} +1 -1
- package/dist/highstate.manifest.json +4 -4
- package/dist/index.js +4159 -785
- package/dist/index.js.map +1 -1
- package/dist/library/worker/main.js +5 -2
- package/dist/library/worker/main.js.map +1 -1
- package/dist/shared/index.js +2 -2
- package/package.json +7 -7
- package/prisma/backend/_schema/object.prisma +12 -0
- package/prisma/backend/sqlite/migrations/20260222113554_add_object_tracking/migration.sql +7 -0
- package/prisma/project/artifact.prisma +3 -0
- package/prisma/project/entity.prisma +125 -0
- package/prisma/project/instance.prisma +6 -0
- package/prisma/project/migrations/20260301210131_add_entity_tracking/migration.sql +70 -0
- package/prisma/project/migrations/20260302212734_add_resource_hooks_flag/migration.sql +1 -0
- package/prisma/project/operation.prisma +3 -0
- package/src/business/artifact.test.ts +22 -2
- package/src/business/artifact.ts +7 -1
- package/src/business/entity-snapshot.test.ts +684 -0
- package/src/business/entity-snapshot.ts +904 -0
- package/src/business/evaluation.test.ts +56 -0
- package/src/business/evaluation.ts +102 -22
- package/src/business/global-search.test.ts +344 -0
- package/src/business/global-search.ts +902 -0
- package/src/business/index.ts +4 -0
- package/src/business/instance-lock.ts +58 -74
- package/src/business/instance-state.test.ts +15 -1
- package/src/business/instance-state.ts +37 -14
- package/src/business/object-ref-index.test.ts +140 -0
- package/src/business/object-ref-index.ts +193 -0
- package/src/business/operation.test.ts +15 -1
- package/src/business/operation.ts +4 -0
- package/src/business/project-model.ts +154 -13
- package/src/business/project-unlock.ts +25 -2
- package/src/business/project.ts +9 -0
- package/src/business/secret.test.ts +35 -2
- package/src/business/secret.ts +32 -9
- package/src/business/settings.ts +761 -0
- package/src/business/unit-output.test.ts +477 -0
- package/src/business/unit-output.ts +461 -0
- package/src/business/worker.ts +55 -4
- package/src/database/_generated/backend/postgresql/browser.ts +6 -0
- package/src/database/_generated/backend/postgresql/client.ts +6 -0
- package/src/database/_generated/backend/postgresql/internal/class.ts +23 -5
- package/src/database/_generated/backend/postgresql/internal/prismaNamespace.ts +89 -5
- package/src/database/_generated/backend/postgresql/internal/prismaNamespaceBrowser.ts +9 -0
- package/src/database/_generated/backend/postgresql/models/Object.ts +1076 -0
- package/src/database/_generated/backend/postgresql/models.ts +1 -0
- package/src/database/_generated/backend/sqlite/browser.ts +6 -0
- package/src/database/_generated/backend/sqlite/client.ts +6 -0
- package/src/database/_generated/backend/sqlite/internal/class.ts +23 -5
- package/src/database/_generated/backend/sqlite/internal/prismaNamespace.ts +89 -5
- package/src/database/_generated/backend/sqlite/internal/prismaNamespaceBrowser.ts +9 -0
- package/src/database/_generated/backend/sqlite/models/Object.ts +1074 -0
- package/src/database/_generated/backend/sqlite/models.ts +1 -0
- package/src/database/_generated/project/browser.ts +23 -0
- package/src/database/_generated/project/client.ts +23 -0
- package/src/database/_generated/project/commonInputTypes.ts +87 -53
- package/src/database/_generated/project/enums.ts +8 -0
- package/src/database/_generated/project/internal/class.ts +53 -5
- package/src/database/_generated/project/internal/prismaNamespace.ts +367 -13
- package/src/database/_generated/project/internal/prismaNamespaceBrowser.ts +48 -1
- package/src/database/_generated/project/models/Artifact.ts +199 -11
- package/src/database/_generated/project/models/Entity.ts +1274 -0
- package/src/database/_generated/project/models/EntitySnapshot.ts +2389 -0
- package/src/database/_generated/project/models/EntitySnapshotContent.ts +1260 -0
- package/src/database/_generated/project/models/EntitySnapshotReference.ts +1449 -0
- package/src/database/_generated/project/models/InstanceState.ts +361 -1
- package/src/database/_generated/project/models/Operation.ts +148 -3
- package/src/database/_generated/project/models/OperationLog.ts +0 -4
- package/src/database/_generated/project/models.ts +4 -0
- package/src/database/migration.ts +3 -0
- package/src/library/worker/evaluator.ts +7 -1
- package/src/orchestrator/manager.ts +7 -0
- package/src/orchestrator/operation-context.captured-outputs.test.ts +118 -0
- package/src/orchestrator/operation-context.ts +154 -16
- package/src/orchestrator/operation-plan.destroy.test.md +33 -12
- package/src/orchestrator/operation-plan.destroy.test.ts +140 -2
- package/src/orchestrator/operation-plan.fixtures.ts +2 -0
- package/src/orchestrator/operation-plan.md +4 -1
- package/src/orchestrator/operation-plan.ts +286 -92
- package/src/orchestrator/operation-plan.update.test.md +286 -11
- package/src/orchestrator/operation-plan.update.test.ts +656 -5
- package/src/orchestrator/operation-workset.ts +72 -22
- package/src/orchestrator/operation.cancel.test.ts +4 -0
- package/src/orchestrator/operation.composite.test.ts +341 -0
- package/src/orchestrator/operation.destroy.test.ts +4 -0
- package/src/orchestrator/operation.output-validation.failure.test.ts +124 -0
- package/src/orchestrator/operation.preview.test.ts +4 -0
- package/src/orchestrator/operation.refresh.test.ts +4 -0
- package/src/orchestrator/operation.test-utils.ts +52 -13
- package/src/orchestrator/operation.ts +228 -68
- package/src/orchestrator/operation.update.failure.test.ts +4 -0
- package/src/orchestrator/operation.update.skip.test.ts +110 -0
- package/src/orchestrator/operation.update.test.ts +4 -0
- package/src/orchestrator/plan-test-builder.ts +1 -0
- package/src/orchestrator/unit-input-values.test.ts +450 -0
- package/src/orchestrator/unit-input-values.ts +281 -0
- package/src/pubsub/manager.ts +3 -0
- package/src/runner/abstractions.ts +23 -54
- package/src/runner/local.ts +109 -85
- package/src/services.ts +52 -1
- package/src/shared/models/prisma.ts +1 -0
- package/src/shared/models/project/entity.ts +121 -0
- package/src/shared/models/project/index.ts +1 -0
- package/src/shared/models/project/operation.ts +61 -3
- package/src/shared/models/project/state.ts +10 -0
- package/src/shared/models/project/worker.ts +7 -0
- package/src/shared/resolvers/effective-output-type.test.ts +494 -0
- package/src/shared/resolvers/effective-output-type.ts +162 -0
- package/src/shared/resolvers/index.ts +1 -0
- package/src/shared/resolvers/input.ts +59 -9
- package/src/shared/utils/index.ts +1 -0
- package/src/shared/utils/stable-json.ts +41 -0
- package/src/terminal/manager.ts +6 -0
- package/src/worker/manager.ts +97 -1
- package/dist/chunk-V2NILDHS.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
|
+
}
|