@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,193 @@
1
+ import type { Logger } from "pino"
2
+ import type { DatabaseManager } from "../database"
3
+ import type { ProjectDatabase } from "../database/prisma"
4
+
5
+ const objectRefBatchSize = 500
6
+
7
+ type IdRow = { id: string }
8
+
9
+ type CuratedIdResolver = (database: ProjectDatabase) => Promise<IdRow[]>
10
+
11
+ const curatedIdResolvers: CuratedIdResolver[] = [
12
+ async database => await database.operation.findMany({ select: { id: true } }),
13
+ async database => await database.instanceState.findMany({ select: { id: true } }),
14
+ async database => await database.artifact.findMany({ select: { id: true } }),
15
+ async database => await database.page.findMany({ select: { id: true } }),
16
+ async database => await database.terminal.findMany({ select: { id: true } }),
17
+ async database => await database.terminalSession.findMany({ select: { id: true } }),
18
+ async database => await database.secret.findMany({ select: { id: true } }),
19
+ async database => await database.serviceAccount.findMany({ select: { id: true } }),
20
+ async database => await database.apiKey.findMany({ select: { id: true } }),
21
+ async database => await database.trigger.findMany({ select: { id: true } }),
22
+ async database => await database.unlockMethod.findMany({ select: { id: true } }),
23
+ async database => await database.worker.findMany({ select: { id: true } }),
24
+ async database => await database.workerVersion.findMany({ select: { id: true } }),
25
+ async database => await database.entitySnapshot.findMany({ select: { id: true } }),
26
+ async database => await database.entity.findMany({ select: { id: true } }),
27
+ ]
28
+
29
+ function chunk<T>(items: T[], size: number): T[][] {
30
+ if (size <= 0) {
31
+ throw new Error("chunk size must be positive")
32
+ }
33
+
34
+ const result: T[][] = []
35
+
36
+ for (let i = 0; i < items.length; i += size) {
37
+ result.push(items.slice(i, i + size))
38
+ }
39
+
40
+ return result
41
+ }
42
+
43
+ function normalizeIds(ids: string[]): string[] {
44
+ return Array.from(new Set(ids.map(id => id.trim()).filter(Boolean)))
45
+ }
46
+
47
+ /**
48
+ * Maintains the global backend object reference index.
49
+ *
50
+ * The index is stored in the backend database as `(id, projectId)` pairs.
51
+ * It is used by `GlobalSearchService.searchByIds()` to quickly find which projects
52
+ * may contain a given object ID.
53
+ *
54
+ * This service only indexes the curated list of project collections used by global object search.
55
+ */
56
+ export class ObjectRefIndexService {
57
+ constructor(
58
+ private readonly database: DatabaseManager,
59
+ private readonly logger: Logger,
60
+ ) {}
61
+
62
+ /**
63
+ * Tracks the provided object IDs for the given project.
64
+ *
65
+ * This is a best-effort helper for incremental updates.
66
+ * It never deletes references.
67
+ *
68
+ * @param projectId The ID of the project that knows the objects.
69
+ * @param ids The list of object IDs.
70
+ */
71
+ async track(projectId: string, ids: string[]): Promise<void> {
72
+ const uniqueIds = normalizeIds(ids)
73
+
74
+ if (uniqueIds.length === 0) {
75
+ return
76
+ }
77
+
78
+ try {
79
+ for (const batch of chunk(uniqueIds, objectRefBatchSize)) {
80
+ const existing = await this.database.backend.object.findMany({
81
+ where: {
82
+ projectId,
83
+ id: {
84
+ in: batch,
85
+ },
86
+ },
87
+ select: {
88
+ id: true,
89
+ },
90
+ })
91
+
92
+ const existingIds = new Set(existing.map(r => r.id))
93
+ const toCreate = batch.filter(id => !existingIds.has(id))
94
+ if (toCreate.length === 0) {
95
+ continue
96
+ }
97
+
98
+ await this.database.backend.object.createMany({
99
+ data: toCreate.map(id => ({ id, projectId })),
100
+ })
101
+ }
102
+ } catch (error) {
103
+ this.logger.warn(
104
+ { error, projectId },
105
+ 'failed to track object refs for project "%s"',
106
+ projectId,
107
+ )
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Reconciles backend object references for the given project.
113
+ *
114
+ * It queries the curated set of project collections and makes backend.object match.
115
+ * This method requires the project database to be accessible (project must be unlocked).
116
+ *
117
+ * @param projectId The ID of the project to sync.
118
+ */
119
+ async syncProject(projectId: string): Promise<void> {
120
+ let projectDatabase: ProjectDatabase
121
+
122
+ try {
123
+ projectDatabase = await this.database.forProject(projectId)
124
+ } catch (error) {
125
+ this.logger.debug({ error, projectId }, "failed to open project database for object ref sync")
126
+ return
127
+ }
128
+
129
+ const [expectedIds, existingRows] = await Promise.all([
130
+ this.collectCuratedProjectObjectIds(projectDatabase, projectId),
131
+ this.database.backend.object.findMany({
132
+ where: { projectId },
133
+ select: { id: true },
134
+ }),
135
+ ])
136
+
137
+ const existingIds = new Set(existingRows.map(r => r.id))
138
+
139
+ const toCreate: string[] = []
140
+ for (const id of expectedIds) {
141
+ if (!existingIds.has(id)) {
142
+ toCreate.push(id)
143
+ }
144
+ }
145
+
146
+ const expectedSet = new Set(expectedIds)
147
+ const toDelete: string[] = []
148
+ for (const id of existingIds) {
149
+ if (!expectedSet.has(id)) {
150
+ toDelete.push(id)
151
+ }
152
+ }
153
+
154
+ for (const batch of chunk(toCreate, objectRefBatchSize)) {
155
+ await this.database.backend.object.createMany({
156
+ data: batch.map(id => ({ id, projectId })),
157
+ })
158
+ }
159
+
160
+ for (const batch of chunk(toDelete, objectRefBatchSize)) {
161
+ await this.database.backend.object.deleteMany({
162
+ where: {
163
+ projectId,
164
+ id: {
165
+ in: batch,
166
+ },
167
+ },
168
+ })
169
+ }
170
+ }
171
+
172
+ private async collectCuratedProjectObjectIds(
173
+ database: ProjectDatabase,
174
+ projectId: string,
175
+ ): Promise<string[]> {
176
+ const settled = await Promise.allSettled(curatedIdResolvers.map(async r => await r(database)))
177
+
178
+ const ids = new Set<string>()
179
+
180
+ for (const result of settled) {
181
+ if (result.status === "rejected") {
182
+ this.logger.debug({ error: result.reason, projectId }, "curated object id resolver failed")
183
+ continue
184
+ }
185
+
186
+ for (const row of result.value) {
187
+ ids.add(row.id)
188
+ }
189
+ }
190
+
191
+ return Array.from(ids)
192
+ }
193
+ }
@@ -1,6 +1,7 @@
1
1
  import type { InstanceId } from "@highstate/contract"
2
2
  import type { PubSubManager } from "../pubsub"
3
3
  import type { OperationOptions } from "../shared"
4
+ import type { ObjectRefIndexService } from "./object-ref-index"
4
5
  import { createId } from "@paralleldrive/cuid2"
5
6
  import { describe, type MockedObject, vi } from "vitest"
6
7
  import { test } from "../test-utils"
@@ -8,6 +9,7 @@ import { OperationService } from "./operation"
8
9
 
9
10
  const operationTest = test.extend<{
10
11
  pubsubManager: MockedObject<PubSubManager>
12
+ objectRefIndexService: MockedObject<ObjectRefIndexService>
11
13
  operationService: OperationService
12
14
  }>({
13
15
  pubsubManager: async ({}, use) => {
@@ -20,10 +22,19 @@ const operationTest = test.extend<{
20
22
  await use(pubsubManager)
21
23
  },
22
24
 
23
- operationService: async ({ database, pubsubManager, logger }, use) => {
25
+ objectRefIndexService: async ({}, use) => {
26
+ const objectRefIndexService = vi.mockObject({
27
+ track: vi.fn().mockResolvedValue(undefined),
28
+ } as unknown as ObjectRefIndexService)
29
+
30
+ await use(objectRefIndexService)
31
+ },
32
+
33
+ operationService: async ({ database, pubsubManager, objectRefIndexService, logger }, use) => {
24
34
  const service = new OperationService(
25
35
  database,
26
36
  pubsubManager,
37
+ objectRefIndexService,
27
38
  logger.child({ service: "OperationService" }),
28
39
  )
29
40
 
@@ -39,6 +50,7 @@ describe("createOperation", () => {
39
50
  projectDatabase,
40
51
  project,
41
52
  pubsubManager,
53
+ objectRefIndexService,
42
54
  createInstanceState,
43
55
  expect,
44
56
  }) => {
@@ -79,6 +91,8 @@ describe("createOperation", () => {
79
91
  type: "updated",
80
92
  operation,
81
93
  })
94
+
95
+ expect(objectRefIndexService.track).toHaveBeenCalledWith(project.id, [operation.id])
82
96
  },
83
97
  )
84
98
  })
@@ -2,6 +2,7 @@ import type { InstanceId } from "@highstate/contract"
2
2
  import type { Logger } from "pino"
3
3
  import type { DatabaseManager, Operation, OperationStatus, OperationUpdateInput } from "../database"
4
4
  import type { PubSubManager } from "../pubsub"
5
+ import type { ObjectRefIndexService } from "./object-ref-index"
5
6
  import { ulid } from "ulid"
6
7
  import {
7
8
  type OperationMeta,
@@ -14,6 +15,7 @@ export class OperationService {
14
15
  constructor(
15
16
  private readonly database: DatabaseManager,
16
17
  private readonly pubsubManager: PubSubManager,
18
+ private readonly objectRefIndexService: ObjectRefIndexService,
17
19
  private readonly logger: Logger,
18
20
  ) {}
19
21
 
@@ -46,6 +48,8 @@ export class OperationService {
46
48
  },
47
49
  })
48
50
 
51
+ await this.objectRefIndexService.track(projectId, [operation.id])
52
+
49
53
  await this.pubsubManager.publish(["operation", projectId], {
50
54
  type: "updated",
51
55
  operation,
@@ -1,4 +1,4 @@
1
- import type { InstanceModel } from "@highstate/contract"
1
+ import type { InstanceInput, InstanceModel } from "@highstate/contract"
2
2
  import type { Logger } from "pino"
3
3
  import type { DatabaseManager } from "../database"
4
4
  import type { LibraryBackend } from "../library"
@@ -42,6 +42,12 @@ export class ProjectModelService {
42
42
  private readonly projectUnlockService: ProjectUnlockService,
43
43
  private readonly logger: Logger,
44
44
  ) {
45
+ this.projectUnlockService.registerUnlockTask(
46
+ //
47
+ "repair-instance-inputs",
48
+ projectId => this.repairInstanceInputs(projectId),
49
+ )
50
+
45
51
  this.projectUnlockService.registerUnlockTask(
46
52
  //
47
53
  "sync-instance-states",
@@ -61,18 +67,28 @@ export class ProjectModelService {
61
67
  ): Promise<[projectModel: FullProjectModel, project: ProjectOutput]> {
62
68
  const { project, backend, spec } = await this.getProjectWithBackend(projectId)
63
69
 
64
- // get base model from storage backend
65
- const { instances, hubs } = await backend.getProjectModel(project, spec)
70
+ const [{ instances, hubs }, library] = await Promise.all([
71
+ backend.getProjectModel(project, spec),
72
+ this.libraryBackend.loadLibrary(project.libraryId),
73
+ ])
74
+
75
+ const filteredInstances = this.filterInstancesWithKnownComponents(
76
+ project.id,
77
+ project.libraryId,
78
+ instances,
79
+ library.components,
80
+ "project-model",
81
+ )
66
82
 
67
83
  return [
68
84
  {
69
- instances,
85
+ instances: filteredInstances,
70
86
  hubs,
71
87
  virtualInstances: includeVirtualInstances ? await this.getVirtualInstances(projectId) : [],
72
88
  ghostInstances: includeGhostInstances
73
89
  ? await this.getGhostInstances(
74
90
  projectId,
75
- instances.map(instance => instance.id),
91
+ filteredInstances.map(instance => instance.id),
76
92
  )
77
93
  : [],
78
94
  },
@@ -94,21 +110,21 @@ export class ProjectModelService {
94
110
 
95
111
  const library = await this.libraryBackend.loadLibrary(project.libraryId)
96
112
 
97
- const filteredInstances = instances.filter(instance => instance.type in library.components)
98
113
  const stateMap = new Map(states.map(state => [state.id, state]))
99
114
 
100
115
  const inputResolverNodes = new Map<string, InputResolverNode>()
101
116
 
102
- for (const instance of filteredInstances) {
117
+ for (const instance of instances) {
103
118
  inputResolverNodes.set(`instance:${instance.id}`, {
104
119
  kind: "instance",
105
120
  instance,
106
- component: library.components[instance.type],
121
+ component: library.components[instance.type]!,
122
+ entities: library.entities,
107
123
  })
108
124
  }
109
125
 
110
126
  for (const hub of hubs) {
111
- inputResolverNodes.set(`hub:${hub.id}`, { kind: "hub", hub })
127
+ inputResolverNodes.set(`hub:${hub.id}`, { kind: "hub", hub, entities: library.entities })
112
128
  }
113
129
 
114
130
  const inputResolver = new InputResolver(inputResolverNodes, this.logger)
@@ -118,7 +134,7 @@ export class ProjectModelService {
118
134
 
119
135
  await inputResolver.process()
120
136
 
121
- for (const instance of filteredInstances) {
137
+ for (const instance of instances) {
122
138
  const output = inputResolver.requireOutput(`instance:${instance.id}`)
123
139
  if (output.kind !== "instance") {
124
140
  throw new Error("Expected instance node")
@@ -130,12 +146,42 @@ export class ProjectModelService {
130
146
  return {
131
147
  project,
132
148
  library,
133
- instances: filteredInstances,
149
+ instances,
134
150
  stateMap,
135
151
  resolvedInputs,
136
152
  }
137
153
  }
138
154
 
155
+ private filterInstancesWithKnownComponents(
156
+ projectId: string,
157
+ libraryId: string,
158
+ instances: InstanceModel[],
159
+ components: Record<string, unknown>,
160
+ source: string,
161
+ ): InstanceModel[] {
162
+ const filteredInstances: InstanceModel[] = []
163
+
164
+ for (const instance of instances) {
165
+ if (instance.type in components) {
166
+ filteredInstances.push(instance)
167
+ continue
168
+ }
169
+
170
+ this.logger.warn(
171
+ {
172
+ projectId,
173
+ libraryId,
174
+ instanceId: instance.id,
175
+ instanceType: instance.type,
176
+ source,
177
+ },
178
+ "ignoring instance because its component is not defined in the library",
179
+ )
180
+ }
181
+
182
+ return filteredInstances
183
+ }
184
+
139
185
  /**
140
186
  /**
141
187
  * Get the appropriate project model backend for the given project.
@@ -219,8 +265,12 @@ export class ProjectModelService {
219
265
  // the resident instance is ghost if it is not in the model
220
266
  { source: "resident", instanceId: { notIn: residentInstanceIds } },
221
267
 
222
- // the virtual instance is ghost if it is has no evaluation state
223
- { source: "virtual", evaluationState: null },
268
+ // the virtual instance is ghost if it has no evaluation state and is not represented in the model
269
+ {
270
+ source: "virtual",
271
+ evaluationState: null,
272
+ instanceId: { notIn: residentInstanceIds },
273
+ },
224
274
  ],
225
275
  },
226
276
  select: { model: true },
@@ -255,4 +305,95 @@ export class ProjectModelService {
255
305
  this.logger.info({ projectId }, "created missing %s instance states", missingInstances.length)
256
306
  })
257
307
  }
308
+
309
+ private async repairInstanceInputs(projectId: string): Promise<void> {
310
+ const { project, backend, spec } = await this.getProjectWithBackend(projectId)
311
+
312
+ const [{ instances, hubs }, library] = await Promise.all([
313
+ backend.getProjectModel(project, spec),
314
+ this.libraryBackend.loadLibrary(project.libraryId),
315
+ ])
316
+
317
+ const instanceById = new Map(instances.map(instance => [instance.id, instance]))
318
+
319
+ const isValidInstanceInput = (input: InstanceInput): boolean => {
320
+ const target = instanceById.get(input.instanceId)
321
+ if (!target) {
322
+ return false
323
+ }
324
+
325
+ const targetComponent = library.components[target.type]
326
+ if (!targetComponent) {
327
+ return false
328
+ }
329
+
330
+ return Boolean(targetComponent.outputs?.[input.output])
331
+ }
332
+
333
+ let repairedInstances = 0
334
+ let removedInputs = 0
335
+ let repairedHubs = 0
336
+ let removedHubInputs = 0
337
+
338
+ for (const instance of instances) {
339
+ const component = library.components[instance.type]
340
+ if (!component || !instance.inputs) {
341
+ continue
342
+ }
343
+
344
+ const componentInputNames = new Set(Object.keys(component.inputs ?? {}))
345
+ const nextInputs: Record<string, InstanceInput[]> = Object.fromEntries(
346
+ Object.entries(instance.inputs)
347
+ .filter(([inputName]) => componentInputNames.has(inputName))
348
+ .map(([inputName, inputValues]) => [inputName, inputValues.filter(isValidInstanceInput)])
349
+ .filter(([, inputValues]) => inputValues.length > 0),
350
+ )
351
+
352
+ const removed =
353
+ Object.values(instance.inputs).reduce((sum, values) => sum + values.length, 0) -
354
+ Object.values(nextInputs).reduce((sum, values) => sum + values.length, 0)
355
+
356
+ if (removed === 0) {
357
+ continue
358
+ }
359
+
360
+ await backend.updateInstance(project, spec, instance.id, {
361
+ inputs: nextInputs,
362
+ })
363
+
364
+ repairedInstances += 1
365
+ removedInputs += removed
366
+ }
367
+
368
+ for (const hub of hubs) {
369
+ const inputs = hub.inputs ?? []
370
+ if (inputs.length === 0) {
371
+ continue
372
+ }
373
+
374
+ const nextInputs = inputs.filter(isValidInstanceInput)
375
+ const removed = inputs.length - nextInputs.length
376
+ if (removed === 0) {
377
+ continue
378
+ }
379
+
380
+ await backend.updateHub(project, spec, hub.id, {
381
+ inputs: nextInputs,
382
+ })
383
+
384
+ repairedHubs += 1
385
+ removedHubInputs += removed
386
+ }
387
+
388
+ if (repairedInstances > 0 || repairedHubs > 0) {
389
+ this.logger.warn(
390
+ { projectId },
391
+ "repaired %s instances and %s hubs, removed %s instance inputs and %s hub inputs during unlock",
392
+ repairedInstances,
393
+ repairedHubs,
394
+ removedInputs,
395
+ removedHubInputs,
396
+ )
397
+ }
398
+ }
258
399
  }
@@ -2,6 +2,7 @@ import type { Logger } from "pino"
2
2
  import type { DatabaseManager } from "../database"
3
3
  import type { PubSubManager } from "../pubsub"
4
4
  import type { ProjectUnlockBackend } from "../unlock"
5
+ import type { ObjectRefIndexService } from "./object-ref-index"
5
6
  import { randomBytes } from "node:crypto"
6
7
  import { armor, Decrypter, Encrypter } from "age-encryption"
7
8
  import { z } from "zod"
@@ -32,6 +33,7 @@ export class ProjectUnlockService {
32
33
  private readonly database: DatabaseManager,
33
34
  private readonly pubsubManager: PubSubManager,
34
35
  private readonly projectUnlockBackend: ProjectUnlockBackend,
36
+ private readonly objectRefIndexService: ObjectRefIndexService,
35
37
  private readonly config: z.infer<typeof projectUnlockServiceConfig>,
36
38
  private readonly logger: Logger,
37
39
  ) {}
@@ -92,7 +94,12 @@ export class ProjectUnlockService {
92
94
  const database = await this.database.setupDatabase(projectId)
93
95
 
94
96
  // persist unlock method (now we can do it since the database is set up and unlocked)
95
- await database.unlockMethod.create({ data: unlockMethodInput })
97
+ const unlockMethod = await database.unlockMethod.create({
98
+ data: unlockMethodInput,
99
+ select: { id: true },
100
+ })
101
+
102
+ await this.objectRefIndexService.track(projectId, [unlockMethod.id])
96
103
 
97
104
  const unlockSuite: ProjectUnlockSuite = {
98
105
  encryptedIdentities: [unlockMethodInput.encryptedIdentity],
@@ -175,6 +182,8 @@ export class ProjectUnlockService {
175
182
  ): Promise<void> {
176
183
  const database = await this.database.forProject(projectId)
177
184
 
185
+ let createdUnlockMethodId: string | null = null
186
+
178
187
  await database.$transaction(async tx => {
179
188
  // 1. fetch all unlock method recipients for the project
180
189
  const unlockMethods = await tx.unlockMethod.findMany({
@@ -187,7 +196,12 @@ export class ProjectUnlockService {
187
196
  const encryptedMasterKey = await this.encryptProjectMasterKey(projectId, allUnlockMethods)
188
197
 
189
198
  // 3. persist the new unlock method
190
- await tx.unlockMethod.create({ data: inputUnlockMethod })
199
+ const created = await tx.unlockMethod.create({
200
+ data: inputUnlockMethod,
201
+ select: { id: true },
202
+ })
203
+
204
+ createdUnlockMethodId = created.id
191
205
 
192
206
  // 4. update the project with the new master key and unlock suite
193
207
  await this.database.backend.project.update({
@@ -198,6 +212,10 @@ export class ProjectUnlockService {
198
212
  },
199
213
  })
200
214
  })
215
+
216
+ if (createdUnlockMethodId) {
217
+ await this.objectRefIndexService.track(projectId, [createdUnlockMethodId])
218
+ }
201
219
  }
202
220
 
203
221
  /**
@@ -246,6 +264,11 @@ export class ProjectUnlockService {
246
264
  * @param handler The handler function for the unlock task. It receives the project ID as an argument.
247
265
  */
248
266
  registerUnlockTask(name: string, handler: (projectId: string) => Promise<void> | void): void {
267
+ const existingIndex = this.unlockTasks.findIndex(task => task.name === name)
268
+ if (existingIndex !== -1) {
269
+ this.unlockTasks.splice(existingIndex, 1)
270
+ }
271
+
249
272
  this.unlockTasks.push({ name, handler })
250
273
  }
251
274
 
@@ -2,6 +2,7 @@ import type { InputJsonValue } from "@prisma/client/runtime/client"
2
2
  import type { Logger } from "pino"
3
3
  import type { LibraryBackend } from "../library"
4
4
  import type { PubSubManager } from "../pubsub"
5
+ import type { ObjectRefIndexService } from "./object-ref-index"
5
6
  import type { ProjectModelService } from "./project-model"
6
7
  import type { ProjectUnlockService } from "./project-unlock"
7
8
  import {
@@ -45,6 +46,7 @@ export class ProjectService {
45
46
  private readonly projectModelBackends: Record<string, ProjectModelBackend>,
46
47
  private readonly libraryBackend: LibraryBackend,
47
48
  private readonly pubsubManager: PubSubManager,
49
+ private readonly objectRefIndexService: ObjectRefIndexService,
48
50
  private readonly logger: Logger,
49
51
  ) {}
50
52
 
@@ -452,6 +454,13 @@ export class ProjectService {
452
454
  )
453
455
  })
454
456
 
457
+ if (states.length > 0) {
458
+ await this.objectRefIndexService.track(
459
+ projectId,
460
+ states.map(state => state.id),
461
+ )
462
+ }
463
+
455
464
  void this.pubsubManager.publish(["project-model", projectId], {
456
465
  updatedHubs: hubs,
457
466
  updatedInstances: instances,
@@ -1,8 +1,9 @@
1
1
  import type { LibraryBackend } from "../library"
2
2
  import type { PubSubManager } from "../pubsub"
3
+ import type { ObjectRefIndexService } from "./object-ref-index"
3
4
  import { defineEntity, defineUnit, z } from "@highstate/contract"
4
5
  import { createId } from "@paralleldrive/cuid2"
5
- import { describe, vi } from "vitest"
6
+ import { describe, type MockedObject, vi } from "vitest"
6
7
  import { InstanceStateNotFoundError, InvalidInstanceKindError, SystemSecretNames } from "../shared"
7
8
  import { test } from "../test-utils"
8
9
  import { SecretService } from "./secret"
@@ -10,6 +11,7 @@ import { SecretService } from "./secret"
10
11
  const secretTest = test.extend<{
11
12
  pubsubManager: PubSubManager
12
13
  libraryBackend: LibraryBackend
14
+ objectRefIndexService: MockedObject<ObjectRefIndexService>
13
15
  secretService: SecretService
14
16
  }>({
15
17
  pubsubManager: async ({}, use) => {
@@ -94,11 +96,23 @@ const secretTest = test.extend<{
94
96
  await use(libraryBackend)
95
97
  },
96
98
 
97
- secretService: async ({ database, pubsubManager, libraryBackend, logger }, use) => {
99
+ objectRefIndexService: async ({}, use) => {
100
+ const objectRefIndexService = vi.mockObject({
101
+ track: vi.fn().mockResolvedValue(undefined),
102
+ } as unknown as ObjectRefIndexService)
103
+
104
+ await use(objectRefIndexService)
105
+ },
106
+
107
+ secretService: async (
108
+ { database, pubsubManager, libraryBackend, objectRefIndexService, logger },
109
+ use,
110
+ ) => {
98
111
  const service = new SecretService(
99
112
  database,
100
113
  pubsubManager,
101
114
  libraryBackend,
115
+ objectRefIndexService,
102
116
  logger.child({ service: "SecretService" }),
103
117
  )
104
118
 
@@ -452,6 +466,25 @@ describe("getInstanceSecretValues", () => {
452
466
  })
453
467
 
454
468
  describe("getPulumiPassword", () => {
469
+ secretTest(
470
+ "tracks created pulumi password secret",
471
+ async ({ secretService, database, createProject, objectRefIndexService, expect }) => {
472
+ const otherProject = await createProject(`aa-${createId()}`)
473
+
474
+ const password = await secretService.getPulumiPassword(otherProject.id)
475
+ expect(password).toBeTypeOf("string")
476
+
477
+ const otherProjectDatabase = await database.forProject(otherProject.id)
478
+ const secret = await otherProjectDatabase.secret.findUnique({
479
+ where: { systemName: SystemSecretNames.PulumiPassword },
480
+ select: { id: true },
481
+ })
482
+
483
+ expect(secret).toBeDefined()
484
+ expect(objectRefIndexService.track).toHaveBeenCalledWith(otherProject.id, [secret!.id])
485
+ },
486
+ )
487
+
455
488
  secretTest(
456
489
  "returns existing Pulumi password",
457
490
  async ({ secretService, projectDatabase, project, expect }) => {