@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,461 @@
1
+ import type { Logger } from "pino"
2
+ import type { LibraryBackend } from "../library"
3
+ import { crc32 } from "node:zlib"
4
+ import {
5
+ type EntityWithMeta,
6
+ getEntityId,
7
+ type InstanceStatusField,
8
+ instanceStatusFieldSchema,
9
+ isAssignableTo,
10
+ type UnitPage,
11
+ type UnitTerminal,
12
+ type UnitTrigger,
13
+ type UnitWorker,
14
+ unitArtifactId,
15
+ unitArtifactSchema,
16
+ unitPageSchema,
17
+ unitTerminalSchema,
18
+ unitTriggerSchema,
19
+ unitWorkerSchema,
20
+ type VersionedName,
21
+ } from "@highstate/contract"
22
+ import { encode } from "@msgpack/msgpack"
23
+ import { sha256 } from "@noble/hashes/sha2"
24
+ import { mapValues, omitBy } from "remeda"
25
+ import { z } from "zod"
26
+
27
+ export type RawPulumiOutputValue = {
28
+ value: unknown
29
+ secret?: boolean
30
+ }
31
+
32
+ export type RawPulumiOutputs = Record<string, RawPulumiOutputValue>
33
+
34
+ export type UnitEntitySnapshotPayload = {
35
+ nodes: UnitEntitySnapshotNode[]
36
+ implicitReferences: UnitEntitySnapshotReference[]
37
+ explicitReferences: UnitEntitySnapshotReference[]
38
+ }
39
+
40
+ export type UnitEntitySnapshotNode = {
41
+ entityId: string
42
+ entityType: VersionedName
43
+ identity: string
44
+ meta: {
45
+ title?: string
46
+ description?: string
47
+ icon?: string
48
+ iconColor?: string
49
+ } | null
50
+ content: Record<string, unknown>
51
+ referencedOutputs: string[]
52
+ exportedOutputs: string[]
53
+ }
54
+
55
+ export type UnitEntitySnapshotReference = {
56
+ fromEntityId: string
57
+ toEntityId: string
58
+ group: string
59
+ }
60
+
61
+ export type ParsedUnitOutputs = {
62
+ outputHash: number | null
63
+ statusFields: InstanceStatusField[] | null
64
+ terminals: UnitTerminal[] | null
65
+ pages: UnitPage[] | null
66
+ triggers: UnitTrigger[] | null
67
+ secrets: Record<string, unknown> | null
68
+ workers: UnitWorker[] | null
69
+ exportedArtifactIds: Record<string, string[]> | null
70
+ hasResourceHooks: boolean
71
+ entitySnapshotError: string | null
72
+ entitySnapshotPayload: UnitEntitySnapshotPayload | null
73
+ }
74
+
75
+ export class UnitOutputService {
76
+ constructor(
77
+ private readonly libraryBackend: LibraryBackend,
78
+ readonly _logger: Logger,
79
+ ) {}
80
+
81
+ /**
82
+ * Parses raw Pulumi outputs returned by the runner.
83
+ *
84
+ * It extracts Highstate-specific "$..." outputs, computes output hash,
85
+ * and builds entity snapshot payload based on static component/entity models.
86
+ *
87
+ * @param options The raw parsing inputs.
88
+ */
89
+ async parseUnitOutputs(options: {
90
+ libraryId: string
91
+ instanceType: VersionedName
92
+ outputs: RawPulumiOutputs
93
+ signal?: AbortSignal
94
+ }): Promise<ParsedUnitOutputs> {
95
+ const unitOutputs = omitBy(options.outputs, (_value, key) => key.startsWith("$"))
96
+ const outputNames = Object.keys(unitOutputs)
97
+
98
+ const outputHash = outputNames.length > 0 ? crc32(sha256(encode(unitOutputs))) : null
99
+
100
+ const statusFields = options.outputs.$statusFields
101
+ ? z.array(instanceStatusFieldSchema).parse(options.outputs.$statusFields.value)
102
+ : null
103
+
104
+ const terminals = options.outputs.$terminals
105
+ ? z.array(unitTerminalSchema).parse(options.outputs.$terminals.value)
106
+ : null
107
+
108
+ const pages = options.outputs.$pages
109
+ ? z.array(unitPageSchema).parse(options.outputs.$pages.value)
110
+ : null
111
+
112
+ const triggers = options.outputs.$triggers
113
+ ? z.array(unitTriggerSchema).parse(options.outputs.$triggers.value)
114
+ : null
115
+
116
+ const workers = options.outputs.$workers
117
+ ? z.array(unitWorkerSchema).parse(options.outputs.$workers.value)
118
+ : null
119
+
120
+ const secrets = options.outputs.$secrets
121
+ ? z.record(z.string(), z.unknown()).parse(options.outputs.$secrets.value)
122
+ : null
123
+
124
+ const exportedArtifactIds = this.parseExportedArtifactIds(options.outputs)
125
+
126
+ const hasResourceHooks = options.outputs.$hasResourceHooks
127
+ ? z.boolean().parse(options.outputs.$hasResourceHooks.value)
128
+ : false
129
+
130
+ let entitySnapshotPayload: UnitEntitySnapshotPayload | null = null
131
+ let entitySnapshotError: string | null = null
132
+
133
+ try {
134
+ entitySnapshotPayload = await this.parseEntitySnapshotPayload({
135
+ libraryId: options.libraryId,
136
+ instanceType: options.instanceType,
137
+ unitOutputs,
138
+ signal: options.signal,
139
+ })
140
+ } catch (error) {
141
+ entitySnapshotError = error instanceof Error ? error.message : String(error)
142
+ entitySnapshotPayload = null
143
+ }
144
+
145
+ return {
146
+ outputHash,
147
+ statusFields,
148
+ terminals,
149
+ pages,
150
+ triggers,
151
+ secrets,
152
+ workers,
153
+ exportedArtifactIds,
154
+ hasResourceHooks,
155
+ entitySnapshotError,
156
+ entitySnapshotPayload,
157
+ }
158
+ }
159
+
160
+ private parseExportedArtifactIds(outputs: RawPulumiOutputs): Record<string, string[]> | null {
161
+ const rawArtifacts = outputs.$artifacts
162
+ if (!rawArtifacts) {
163
+ return null
164
+ }
165
+
166
+ const rawArtifactsByOutput = z
167
+ .record(z.string(), z.array(z.unknown()))
168
+ .parse(rawArtifacts.value)
169
+
170
+ return mapValues(rawArtifactsByOutput, artifacts => {
171
+ return artifacts.map(rawArtifact => {
172
+ if (typeof rawArtifact !== "object" || rawArtifact === null) {
173
+ throw new Error("Invalid artifact value")
174
+ }
175
+
176
+ const validated = unitArtifactSchema.parse(rawArtifact)
177
+ const id = (rawArtifact as Record<string | symbol, unknown>)[unitArtifactId]
178
+ if (typeof id === "string" && id.length > 0) {
179
+ return id
180
+ }
181
+
182
+ throw new Error(`Failed to determine artifact ID for artifact with hash ${validated.hash}`)
183
+ })
184
+ })
185
+ }
186
+
187
+ private async parseEntitySnapshotPayload(options: {
188
+ libraryId: string
189
+ instanceType: VersionedName
190
+ unitOutputs: RawPulumiOutputs
191
+ signal?: AbortSignal
192
+ }): Promise<UnitEntitySnapshotPayload | null> {
193
+ const unitOutputNames = Object.keys(options.unitOutputs)
194
+ if (unitOutputNames.length === 0) {
195
+ return null
196
+ }
197
+
198
+ const library = await this.libraryBackend.loadLibrary(options.libraryId, options.signal)
199
+ const component = library.components[options.instanceType]
200
+
201
+ if (!component) {
202
+ throw new Error(`Component "${options.instanceType}" is not defined in the library`)
203
+ }
204
+
205
+ const nodeByEntityId = new Map<
206
+ string,
207
+ {
208
+ node: Omit<UnitEntitySnapshotNode, "exportedOutputs" | "referencedOutputs"> & {
209
+ exportedOutputs: Set<string>
210
+ referencedOutputs: Set<string>
211
+ }
212
+ entityModelInclusions: Array<{
213
+ type: VersionedName
214
+ required: boolean
215
+ multiple: boolean
216
+ field: string
217
+ }>
218
+ }
219
+ >()
220
+
221
+ const implicitReferencesByKey = new Map<string, UnitEntitySnapshotReference>()
222
+ const explicitReferencesByKey = new Map<string, UnitEntitySnapshotReference>()
223
+
224
+ const isRecord = (value: unknown): value is Record<string, unknown> => {
225
+ return typeof value === "object" && value !== null && !Array.isArray(value)
226
+ }
227
+
228
+ function assertEntityWithMeta(
229
+ expectedType: VersionedName,
230
+ output: string,
231
+ value: unknown,
232
+ ): asserts value is EntityWithMeta {
233
+ if (!isRecord(value)) {
234
+ throw new Error(`Output "${output}" must be an object`)
235
+ }
236
+
237
+ const meta = (value as Record<string, unknown>).$meta
238
+ if (!isRecord(meta)) {
239
+ throw new Error(`Output "${output}" must include a "$meta" object`)
240
+ }
241
+
242
+ const metaType = meta.type
243
+ if (typeof metaType !== "string") {
244
+ throw new Error(
245
+ `Output "${output}" has invalid "$meta.type": expected "${expectedType}", got "${String(metaType)}"`,
246
+ )
247
+ }
248
+
249
+ const entityModel = library.entities[metaType as VersionedName]
250
+ if (!entityModel) {
251
+ throw new Error(`Entity type "${metaType}" is not defined in the library`)
252
+ }
253
+
254
+ if (!isAssignableTo(entityModel, expectedType)) {
255
+ throw new Error(
256
+ `Output "${output}" has invalid "$meta.type": expected assignable to "${expectedType}", got "${String(metaType)}"`,
257
+ )
258
+ }
259
+
260
+ const metaIdentity = meta.identity
261
+ if (typeof metaIdentity !== "string" || metaIdentity.length === 0) {
262
+ throw new Error(
263
+ `Output "${output}" has invalid "$meta.identity": expected a non-empty string`,
264
+ )
265
+ }
266
+ }
267
+
268
+ const normalizeSnapshotContent = (
269
+ entityType: VersionedName,
270
+ record: Record<string, unknown>,
271
+ ): Record<string, unknown> => {
272
+ const entityModel = library.entities[entityType]
273
+ if (!entityModel) {
274
+ throw new Error(`Entity type "${entityType}" is not defined in the library`)
275
+ }
276
+
277
+ const { $meta: _ignoredMeta, ...content } = record
278
+ for (const inclusion of entityModel.inclusions ?? []) {
279
+ delete (content as Record<string, unknown>)[inclusion.field]
280
+ }
281
+
282
+ return content
283
+ }
284
+
285
+ const collectEntityValue = async (options: {
286
+ expectedType: VersionedName
287
+ output: string
288
+ value: unknown
289
+ relation: "exported" | "referenced"
290
+ stack: string[]
291
+ }): Promise<string> => {
292
+ assertEntityWithMeta(options.expectedType, options.output, options.value)
293
+
294
+ const record = options.value as unknown as Record<string, unknown>
295
+
296
+ const entityWithMeta = options.value as EntityWithMeta
297
+ const { identity } = entityWithMeta.$meta
298
+ const meta = entityWithMeta.$meta
299
+ const entityType = entityWithMeta.$meta.type
300
+
301
+ const entityId = getEntityId(entityWithMeta)
302
+ if (options.stack.includes(entityId)) {
303
+ throw new Error(
304
+ `Detected entity inclusion cycle while collecting output "${options.output}": "${entityId}"`,
305
+ )
306
+ }
307
+
308
+ const existing = nodeByEntityId.get(entityId)
309
+ if (!existing) {
310
+ const entityModel = library.entities[entityType]
311
+ if (!entityModel) {
312
+ throw new Error(`Entity type "${entityType}" is not defined in the library`)
313
+ }
314
+
315
+ const normalizedContent = normalizeSnapshotContent(entityType, record)
316
+
317
+ const snapshotMeta = {
318
+ ...(typeof meta.title === "string" ? { title: meta.title } : {}),
319
+ ...(typeof meta.description === "string" ? { description: meta.description } : {}),
320
+ ...(typeof meta.icon === "string" ? { icon: meta.icon } : {}),
321
+ ...(typeof meta.iconColor === "string" ? { iconColor: meta.iconColor } : {}),
322
+ }
323
+
324
+ nodeByEntityId.set(entityId, {
325
+ node: {
326
+ entityId,
327
+ entityType,
328
+ identity,
329
+ meta: Object.keys(snapshotMeta).length > 0 ? snapshotMeta : null,
330
+ content: normalizedContent,
331
+ referencedOutputs: new Set<string>(),
332
+ exportedOutputs: new Set<string>(),
333
+ },
334
+ entityModelInclusions: (entityModel.inclusions ?? []).map(i => ({
335
+ type: i.type,
336
+ required: i.required,
337
+ multiple: i.multiple,
338
+ field: i.field,
339
+ })),
340
+ })
341
+ }
342
+
343
+ const current = nodeByEntityId.get(entityId)!
344
+ if (options.relation === "exported") {
345
+ current.node.exportedOutputs.add(options.output)
346
+ } else {
347
+ current.node.referencedOutputs.add(options.output)
348
+ }
349
+
350
+ const rawReferences = meta.references
351
+ if (rawReferences) {
352
+ for (const [group, ids] of Object.entries(rawReferences)) {
353
+ for (const id of ids) {
354
+ explicitReferencesByKey.set(`${entityId}:${id}:${group}`, {
355
+ fromEntityId: entityId,
356
+ toEntityId: id,
357
+ group,
358
+ })
359
+ }
360
+ }
361
+ }
362
+
363
+ for (const inclusion of current.entityModelInclusions) {
364
+ const rawIncluded = record[inclusion.field]
365
+ if (rawIncluded === undefined || rawIncluded === null) {
366
+ continue
367
+ }
368
+
369
+ if (inclusion.multiple) {
370
+ for (const item of rawIncluded as unknown[]) {
371
+ const childEntityId = await collectEntityValue({
372
+ expectedType: inclusion.type,
373
+ output: options.output,
374
+ value: item,
375
+ relation: "referenced",
376
+ stack: [...options.stack, entityId],
377
+ })
378
+
379
+ implicitReferencesByKey.set(`${entityId}:${childEntityId}:${inclusion.field}`, {
380
+ fromEntityId: entityId,
381
+ toEntityId: childEntityId,
382
+ group: inclusion.field,
383
+ })
384
+ }
385
+ } else {
386
+ const childEntityId = await collectEntityValue({
387
+ expectedType: inclusion.type,
388
+ output: options.output,
389
+ value: rawIncluded,
390
+ relation: "referenced",
391
+ stack: [...options.stack, entityId],
392
+ })
393
+
394
+ implicitReferencesByKey.set(`${entityId}:${childEntityId}:${inclusion.field}`, {
395
+ fromEntityId: entityId,
396
+ toEntityId: childEntityId,
397
+ group: inclusion.field,
398
+ })
399
+ }
400
+ }
401
+
402
+ return entityId
403
+ }
404
+
405
+ for (const outputName of unitOutputNames) {
406
+ const outputSpec = component.outputs[outputName]
407
+ if (!outputSpec) {
408
+ throw new Error(`Output "${outputName}" is not defined on component "${component.type}"`)
409
+ }
410
+
411
+ const outputValue = options.unitOutputs[outputName]?.value
412
+ if (outputValue === undefined || outputValue === null) {
413
+ continue
414
+ }
415
+
416
+ if (outputSpec.multiple) {
417
+ if (!Array.isArray(outputValue)) {
418
+ throw new Error(`Output "${outputName}" must be an array`)
419
+ }
420
+
421
+ for (const item of outputValue) {
422
+ await collectEntityValue({
423
+ expectedType: outputSpec.type,
424
+ output: outputName,
425
+ value: item,
426
+ relation: "exported",
427
+ stack: [],
428
+ })
429
+ }
430
+ } else {
431
+ await collectEntityValue({
432
+ expectedType: outputSpec.type,
433
+ output: outputName,
434
+ value: outputValue,
435
+ relation: "exported",
436
+ stack: [],
437
+ })
438
+ }
439
+ }
440
+
441
+ if (nodeByEntityId.size === 0) {
442
+ return null
443
+ }
444
+
445
+ const nodes = Array.from(nodeByEntityId.values()).map(({ node }) => ({
446
+ entityId: node.entityId,
447
+ entityType: node.entityType,
448
+ identity: node.identity,
449
+ meta: node.meta,
450
+ content: node.content,
451
+ referencedOutputs: Array.from(node.referencedOutputs),
452
+ exportedOutputs: Array.from(node.exportedOutputs),
453
+ }))
454
+
455
+ return {
456
+ nodes,
457
+ implicitReferences: Array.from(implicitReferencesByKey.values()),
458
+ explicitReferences: Array.from(explicitReferencesByKey.values()),
459
+ }
460
+ }
461
+ }
@@ -1,4 +1,4 @@
1
- import type { CommonObjectMeta, UnitWorker } from "@highstate/contract"
1
+ import type { CommonObjectMeta, ServiceAccountMeta, UnitWorker } from "@highstate/contract"
2
2
  import type { Logger } from "pino"
3
3
  import type {
4
4
  DatabaseManager,
@@ -13,6 +13,7 @@ import { randomBytes } from "node:crypto"
13
13
  import { PrismaClientKnownRequestError } from "@prisma/client/runtime/client"
14
14
  import { createProjectLogger } from "../common"
15
15
  import {
16
+ type ApiKeyMeta,
16
17
  extractDigestFromImage,
17
18
  getWorkerIdentity,
18
19
  type WorkerUnitRegistrationEvent,
@@ -41,7 +42,9 @@ export class WorkerService {
41
42
  projectId: string,
42
43
  stateId: string,
43
44
  unitWorkers: UnitWorker[],
44
- ): Promise<void> {
45
+ ): Promise<string[]> {
46
+ const objectIds = new Set<string>()
47
+
45
48
  // parse images first
46
49
  const parsedWorkers = unitWorkers.map(w => {
47
50
  const digest = extractDigestFromImage(w.image)
@@ -67,6 +70,13 @@ export class WorkerService {
67
70
  const workerRecord = await this.ensureWorker(tx, worker.identity)
68
71
  const workerVersionRecord = await this.ensureWorkerVersion(tx, workerRecord, worker.digest)
69
72
 
73
+ objectIds.add(workerRecord.id)
74
+ objectIds.add(workerRecord.serviceAccountId)
75
+ objectIds.add(workerVersionRecord.id)
76
+ if (workerVersionRecord.apiKeyId) {
77
+ objectIds.add(workerVersionRecord.apiKeyId)
78
+ }
79
+
70
80
  const existing = existingRegistrations.find(r => r.name === worker.name)
71
81
  const stringifiedParams = JSON.stringify(worker.params)
72
82
 
@@ -145,6 +155,8 @@ export class WorkerService {
145
155
  void this.workerManager.syncWorkers(projectId)
146
156
 
147
157
  logger.info(`updated worker registrations for instance state "%s"`, stateId)
158
+
159
+ return Array.from(objectIds)
148
160
  }
149
161
 
150
162
  private async ensureWorker(tx: ProjectTransaction, identity: string): Promise<Worker> {
@@ -230,7 +242,7 @@ export class WorkerService {
230
242
  where: {
231
243
  unitRegistrations: { none: {} },
232
244
  },
233
- select: { id: true },
245
+ select: { id: true, apiKeyId: true },
234
246
  })
235
247
 
236
248
  if (unused.length === 0) {
@@ -240,6 +252,22 @@ export class WorkerService {
240
252
  await tx.workerVersion.deleteMany({
241
253
  where: { id: { in: unused.map(u => u.id) } },
242
254
  })
255
+
256
+ await tx.apiKey.deleteMany({
257
+ where: { id: { in: unused.map(u => u.apiKeyId) } },
258
+ })
259
+ }
260
+
261
+ private createWorkerApiKeyMeta(
262
+ workerIdentity: string,
263
+ serviceAccountMeta: Pick<ServiceAccountMeta, "title" | "description">,
264
+ ): ApiKeyMeta {
265
+ return {
266
+ title: `${serviceAccountMeta.title} API Key`,
267
+ description:
268
+ serviceAccountMeta.description ??
269
+ `Automatically managed API key for worker "${workerIdentity}" service account.`,
270
+ }
243
271
  }
244
272
 
245
273
  /**
@@ -253,15 +281,38 @@ export class WorkerService {
253
281
  projectId: string,
254
282
  workerVersionId: string,
255
283
  meta: CommonObjectMeta,
284
+ serviceAccountMeta?: ServiceAccountMeta,
256
285
  ): Promise<void> {
257
286
  const database = await this.database.forProject(projectId)
258
287
  const logger = createProjectLogger(this.logger, projectId)
259
288
 
260
289
  try {
261
- await database.workerVersion.update({
290
+ const workerVersion = await database.workerVersion.update({
262
291
  where: { id: workerVersionId },
292
+ select: {
293
+ worker: {
294
+ select: {
295
+ identity: true,
296
+ serviceAccountId: true,
297
+ },
298
+ },
299
+ },
263
300
  data: { meta },
264
301
  })
302
+
303
+ if (serviceAccountMeta) {
304
+ await database.serviceAccount.update({
305
+ where: { id: workerVersion.worker.serviceAccountId },
306
+ data: { meta: serviceAccountMeta },
307
+ })
308
+
309
+ await database.apiKey.updateMany({
310
+ where: { serviceAccountId: workerVersion.worker.serviceAccountId },
311
+ data: {
312
+ meta: this.createWorkerApiKeyMeta(workerVersion.worker.identity, serviceAccountMeta),
313
+ },
314
+ })
315
+ }
265
316
  } catch (error) {
266
317
  if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
267
318
  throw new WorkerVersionNotFoundError(projectId, workerVersionId)
@@ -27,6 +27,12 @@ export type UserWorkspaceLayout = Prisma.UserWorkspaceLayoutModel
27
27
  *
28
28
  */
29
29
  export type Library = Prisma.LibraryModel
30
+ /**
31
+ * Model Object
32
+ * The object allows to track arbitrary object across multiple projects and search them globally by their IDs.
33
+ * This also allow to correlate different entities across different projects.
34
+ */
35
+ export type Object = Prisma.ObjectModel
30
36
  /**
31
37
  * Model Project
32
38
  *
@@ -49,6 +49,12 @@ export type UserWorkspaceLayout = Prisma.UserWorkspaceLayoutModel
49
49
  *
50
50
  */
51
51
  export type Library = Prisma.LibraryModel
52
+ /**
53
+ * Model Object
54
+ * The object allows to track arbitrary object across multiple projects and search them globally by their IDs.
55
+ * This also allow to correlate different entities across different projects.
56
+ */
57
+ export type Object = Prisma.ObjectModel
52
58
  /**
53
59
  * Model Project
54
60
  *