@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,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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
223
|
-
{
|
|
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({
|
|
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({
|
|
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
|
|
package/src/business/project.ts
CHANGED
|
@@ -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
|
-
|
|
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 }) => {
|