@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.
Files changed (120) hide show
  1. package/dist/{chunk-V2NILDHS.js → chunk-52MY2TCE.js} +347 -19
  2. package/dist/chunk-52MY2TCE.js.map +1 -0
  3. package/dist/{chunk-I7BWSAN6.js → chunk-UAWBPTDW.js} +3 -3
  4. package/dist/{chunk-I7BWSAN6.js.map → chunk-UAWBPTDW.js.map} +1 -1
  5. package/dist/highstate.manifest.json +4 -4
  6. package/dist/index.js +4159 -785
  7. package/dist/index.js.map +1 -1
  8. package/dist/library/worker/main.js +5 -2
  9. package/dist/library/worker/main.js.map +1 -1
  10. package/dist/shared/index.js +2 -2
  11. package/package.json +7 -7
  12. package/prisma/backend/_schema/object.prisma +12 -0
  13. package/prisma/backend/sqlite/migrations/20260222113554_add_object_tracking/migration.sql +7 -0
  14. package/prisma/project/artifact.prisma +3 -0
  15. package/prisma/project/entity.prisma +125 -0
  16. package/prisma/project/instance.prisma +6 -0
  17. package/prisma/project/migrations/20260301210131_add_entity_tracking/migration.sql +70 -0
  18. package/prisma/project/migrations/20260302212734_add_resource_hooks_flag/migration.sql +1 -0
  19. package/prisma/project/operation.prisma +3 -0
  20. package/src/business/artifact.test.ts +22 -2
  21. package/src/business/artifact.ts +7 -1
  22. package/src/business/entity-snapshot.test.ts +684 -0
  23. package/src/business/entity-snapshot.ts +904 -0
  24. package/src/business/evaluation.test.ts +56 -0
  25. package/src/business/evaluation.ts +102 -22
  26. package/src/business/global-search.test.ts +344 -0
  27. package/src/business/global-search.ts +902 -0
  28. package/src/business/index.ts +4 -0
  29. package/src/business/instance-lock.ts +58 -74
  30. package/src/business/instance-state.test.ts +15 -1
  31. package/src/business/instance-state.ts +37 -14
  32. package/src/business/object-ref-index.test.ts +140 -0
  33. package/src/business/object-ref-index.ts +193 -0
  34. package/src/business/operation.test.ts +15 -1
  35. package/src/business/operation.ts +4 -0
  36. package/src/business/project-model.ts +154 -13
  37. package/src/business/project-unlock.ts +25 -2
  38. package/src/business/project.ts +9 -0
  39. package/src/business/secret.test.ts +35 -2
  40. package/src/business/secret.ts +32 -9
  41. package/src/business/settings.ts +761 -0
  42. package/src/business/unit-output.test.ts +477 -0
  43. package/src/business/unit-output.ts +461 -0
  44. package/src/business/worker.ts +55 -4
  45. package/src/database/_generated/backend/postgresql/browser.ts +6 -0
  46. package/src/database/_generated/backend/postgresql/client.ts +6 -0
  47. package/src/database/_generated/backend/postgresql/internal/class.ts +23 -5
  48. package/src/database/_generated/backend/postgresql/internal/prismaNamespace.ts +89 -5
  49. package/src/database/_generated/backend/postgresql/internal/prismaNamespaceBrowser.ts +9 -0
  50. package/src/database/_generated/backend/postgresql/models/Object.ts +1076 -0
  51. package/src/database/_generated/backend/postgresql/models.ts +1 -0
  52. package/src/database/_generated/backend/sqlite/browser.ts +6 -0
  53. package/src/database/_generated/backend/sqlite/client.ts +6 -0
  54. package/src/database/_generated/backend/sqlite/internal/class.ts +23 -5
  55. package/src/database/_generated/backend/sqlite/internal/prismaNamespace.ts +89 -5
  56. package/src/database/_generated/backend/sqlite/internal/prismaNamespaceBrowser.ts +9 -0
  57. package/src/database/_generated/backend/sqlite/models/Object.ts +1074 -0
  58. package/src/database/_generated/backend/sqlite/models.ts +1 -0
  59. package/src/database/_generated/project/browser.ts +23 -0
  60. package/src/database/_generated/project/client.ts +23 -0
  61. package/src/database/_generated/project/commonInputTypes.ts +87 -53
  62. package/src/database/_generated/project/enums.ts +8 -0
  63. package/src/database/_generated/project/internal/class.ts +53 -5
  64. package/src/database/_generated/project/internal/prismaNamespace.ts +367 -13
  65. package/src/database/_generated/project/internal/prismaNamespaceBrowser.ts +48 -1
  66. package/src/database/_generated/project/models/Artifact.ts +199 -11
  67. package/src/database/_generated/project/models/Entity.ts +1274 -0
  68. package/src/database/_generated/project/models/EntitySnapshot.ts +2389 -0
  69. package/src/database/_generated/project/models/EntitySnapshotContent.ts +1260 -0
  70. package/src/database/_generated/project/models/EntitySnapshotReference.ts +1449 -0
  71. package/src/database/_generated/project/models/InstanceState.ts +361 -1
  72. package/src/database/_generated/project/models/Operation.ts +148 -3
  73. package/src/database/_generated/project/models/OperationLog.ts +0 -4
  74. package/src/database/_generated/project/models.ts +4 -0
  75. package/src/database/migration.ts +3 -0
  76. package/src/library/worker/evaluator.ts +7 -1
  77. package/src/orchestrator/manager.ts +7 -0
  78. package/src/orchestrator/operation-context.captured-outputs.test.ts +118 -0
  79. package/src/orchestrator/operation-context.ts +154 -16
  80. package/src/orchestrator/operation-plan.destroy.test.md +33 -12
  81. package/src/orchestrator/operation-plan.destroy.test.ts +140 -2
  82. package/src/orchestrator/operation-plan.fixtures.ts +2 -0
  83. package/src/orchestrator/operation-plan.md +4 -1
  84. package/src/orchestrator/operation-plan.ts +286 -92
  85. package/src/orchestrator/operation-plan.update.test.md +286 -11
  86. package/src/orchestrator/operation-plan.update.test.ts +656 -5
  87. package/src/orchestrator/operation-workset.ts +72 -22
  88. package/src/orchestrator/operation.cancel.test.ts +4 -0
  89. package/src/orchestrator/operation.composite.test.ts +341 -0
  90. package/src/orchestrator/operation.destroy.test.ts +4 -0
  91. package/src/orchestrator/operation.output-validation.failure.test.ts +124 -0
  92. package/src/orchestrator/operation.preview.test.ts +4 -0
  93. package/src/orchestrator/operation.refresh.test.ts +4 -0
  94. package/src/orchestrator/operation.test-utils.ts +52 -13
  95. package/src/orchestrator/operation.ts +228 -68
  96. package/src/orchestrator/operation.update.failure.test.ts +4 -0
  97. package/src/orchestrator/operation.update.skip.test.ts +110 -0
  98. package/src/orchestrator/operation.update.test.ts +4 -0
  99. package/src/orchestrator/plan-test-builder.ts +1 -0
  100. package/src/orchestrator/unit-input-values.test.ts +450 -0
  101. package/src/orchestrator/unit-input-values.ts +281 -0
  102. package/src/pubsub/manager.ts +3 -0
  103. package/src/runner/abstractions.ts +23 -54
  104. package/src/runner/local.ts +109 -85
  105. package/src/services.ts +52 -1
  106. package/src/shared/models/prisma.ts +1 -0
  107. package/src/shared/models/project/entity.ts +121 -0
  108. package/src/shared/models/project/index.ts +1 -0
  109. package/src/shared/models/project/operation.ts +61 -3
  110. package/src/shared/models/project/state.ts +10 -0
  111. package/src/shared/models/project/worker.ts +7 -0
  112. package/src/shared/resolvers/effective-output-type.test.ts +494 -0
  113. package/src/shared/resolvers/effective-output-type.ts +162 -0
  114. package/src/shared/resolvers/index.ts +1 -0
  115. package/src/shared/resolvers/input.ts +59 -9
  116. package/src/shared/utils/index.ts +1 -0
  117. package/src/shared/utils/stable-json.ts +41 -0
  118. package/src/terminal/manager.ts +6 -0
  119. package/src/worker/manager.ts +97 -1
  120. package/dist/chunk-V2NILDHS.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import type { LibraryBackend, ProjectEvaluationResult } from "../library"
2
2
  import type { PubSubManager } from "../pubsub"
3
3
  import type { LibraryModel, ProjectModel } from "../shared"
4
+ import type { ObjectRefIndexService } from "./object-ref-index"
4
5
  import type { ProjectModelService } from "./project-model"
5
6
  import type { ProjectUnlockService } from "./project-unlock"
6
7
  import { defineComponent, defineUnit, getInstanceId, type InstanceModel } from "@highstate/contract"
@@ -141,6 +142,9 @@ describe("ProjectEvaluationSubsystem", () => {
141
142
  projectModelService,
142
143
  pubsubManager,
143
144
  projectUnlockService,
145
+ vi.mockObject({
146
+ track: vi.fn().mockResolvedValue(undefined),
147
+ } as unknown as ObjectRefIndexService),
144
148
  logger,
145
149
  )
146
150
 
@@ -317,4 +321,56 @@ describe("ProjectEvaluationSubsystem", () => {
317
321
  expect(payload?.deletedGhostInstanceIds).toEqual([virtualInstance.id])
318
322
  },
319
323
  )
324
+
325
+ evaluationTest(
326
+ "deduplicates evaluation states and prioritizes errors",
327
+ async ({
328
+ subsystem,
329
+ project,
330
+ projectDatabase,
331
+ projectModelService,
332
+ libraryBackend,
333
+ expect,
334
+ }) => {
335
+ // arrange
336
+ const virtualInstance = createVirtualInstance("overlap")
337
+
338
+ const evaluationResult: ProjectEvaluationResult = {
339
+ success: true,
340
+ virtualInstances: [virtualInstance],
341
+ topLevelErrors: {
342
+ [virtualInstance.id]: "failed to evaluate subtree",
343
+ },
344
+ }
345
+
346
+ projectModelService.resolveProject.mockResolvedValue({
347
+ project,
348
+ library: clone(libraryModel),
349
+ instances: [
350
+ {
351
+ ...virtualInstance,
352
+ kind: "composite",
353
+ type: "composite.v1",
354
+ },
355
+ ],
356
+ stateMap: new Map(),
357
+ resolvedInputs: {},
358
+ })
359
+
360
+ libraryBackend.evaluateCompositeInstances.mockResolvedValue(evaluationResult)
361
+
362
+ // act
363
+ await subsystem.evaluateProject(project.id)
364
+
365
+ // assert
366
+ const state = await projectDatabase.instanceState.findUniqueOrThrow({
367
+ where: { instanceId: virtualInstance.id },
368
+ include: { evaluationState: true },
369
+ })
370
+
371
+ expect(state.evaluationState).toBeDefined()
372
+ expect(state.evaluationState?.status).toBe("error")
373
+ expect(state.evaluationState?.message).toBe("failed to evaluate subtree")
374
+ },
375
+ )
320
376
  })
@@ -2,6 +2,7 @@ import type { InstanceId, InstanceModel } from "@highstate/contract"
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 { isNonNullish } from "remeda"
@@ -13,6 +14,7 @@ import {
13
14
  type InstanceEvaluationStateUpdateInput,
14
15
  type InstanceStatus,
15
16
  } from "../database"
17
+ import { stableJsonStringify } from "../shared"
16
18
 
17
19
  type EvaluatedInstance = {
18
20
  instanceId: InstanceId
@@ -23,6 +25,8 @@ type EvaluatedInstance = {
23
25
 
24
26
  export class ProjectEvaluationSubsystem {
25
27
  private readonly projectWatchers = new Map<string, AbortController>()
28
+ private readonly evaluationPromises = new Map<string, Promise<void>>()
29
+ private readonly evaluationRequested = new Set<string>()
26
30
 
27
31
  constructor(
28
32
  private readonly database: DatabaseManager,
@@ -30,6 +34,7 @@ export class ProjectEvaluationSubsystem {
30
34
  private readonly projectModelService: ProjectModelService,
31
35
  private readonly pubsubManager: PubSubManager,
32
36
  private readonly projectUnlockService: ProjectUnlockService,
37
+ private readonly objectRefIndexService: ObjectRefIndexService,
33
38
  private readonly logger: Logger,
34
39
  ) {
35
40
  this.projectUnlockService.registerUnlockTask(
@@ -46,6 +51,30 @@ export class ProjectEvaluationSubsystem {
46
51
  }
47
52
 
48
53
  async evaluateProject(projectId: string): Promise<void> {
54
+ const existingPromise = this.evaluationPromises.get(projectId)
55
+ if (existingPromise) {
56
+ this.evaluationRequested.add(projectId)
57
+ await existingPromise
58
+ return
59
+ }
60
+
61
+ const promise = this.evaluateProjectLoop(projectId).finally(() => {
62
+ this.evaluationPromises.delete(projectId)
63
+ this.evaluationRequested.delete(projectId)
64
+ })
65
+
66
+ this.evaluationPromises.set(projectId, promise)
67
+ await promise
68
+ }
69
+
70
+ private async evaluateProjectLoop(projectId: string): Promise<void> {
71
+ do {
72
+ this.evaluationRequested.delete(projectId)
73
+ await this.evaluateProjectNow(projectId)
74
+ } while (this.evaluationRequested.has(projectId))
75
+ }
76
+
77
+ private async evaluateProjectNow(projectId: string): Promise<void> {
49
78
  const { project, instances, resolvedInputs } =
50
79
  await this.projectModelService.resolveProject(projectId)
51
80
 
@@ -88,10 +117,13 @@ export class ProjectEvaluationSubsystem {
88
117
  model: null,
89
118
  }))
90
119
 
91
- await this.setInstanceEvaluationStates(project.id, [
92
- ...evaluatedInstances,
93
- ...errorEvaluatedInstances,
94
- ])
120
+ await this.setInstanceEvaluationStates(
121
+ project.id,
122
+ ProjectEvaluationSubsystem.mergeEvaluatedInstances(
123
+ evaluatedInstances,
124
+ errorEvaluatedInstances,
125
+ ),
126
+ )
95
127
  } else {
96
128
  // set all composite instances to error state in evaluation
97
129
  const errorEvaluationStates: EvaluatedInstance[] = compositeInstanceIds.map(instanceId => ({
@@ -135,7 +167,7 @@ export class ProjectEvaluationSubsystem {
135
167
  ): Promise<void> {
136
168
  const database = await this.database.forProject(projectId)
137
169
 
138
- await database.$transaction(async tx => {
170
+ const { stateIds } = await database.$transaction(async tx => {
139
171
  const previousVirtualStates = await tx.instanceState.findMany({
140
172
  where: { source: "virtual" },
141
173
  select: {
@@ -186,6 +218,9 @@ export class ProjectEvaluationSubsystem {
186
218
  const existingStates = await tx.instanceEvaluationState.findMany({
187
219
  select: {
188
220
  stateId: true,
221
+ status: true,
222
+ message: true,
223
+ model: true,
189
224
  state: {
190
225
  select: {
191
226
  instanceId: true,
@@ -197,9 +232,21 @@ export class ProjectEvaluationSubsystem {
197
232
 
198
233
  const existingStateIds = new Set(existingStates.map(state => state.stateId))
199
234
  const actualStateIds = new Set(states.map(state => state.stateId))
235
+ const existingStateById = new Map(existingStates.map(state => [state.stateId, state]))
200
236
 
201
237
  const newStates = states.filter(state => !existingStateIds.has(state.stateId))
202
- const statesToUpdate = states.filter(state => existingStateIds.has(state.stateId))
238
+ const statesToUpdate = states.filter(state => {
239
+ const existingState = existingStateById.get(state.stateId)
240
+ if (!existingState) {
241
+ return false
242
+ }
243
+
244
+ return (
245
+ existingState.status !== state.status ||
246
+ (existingState.message ?? null) !== (state.message ?? null) ||
247
+ stableJsonStringify(existingState.model) !== stableJsonStringify(state.model)
248
+ )
249
+ })
203
250
  const statesToDelete = existingStates.filter(state => !actualStateIds.has(state.stateId))
204
251
 
205
252
  // create new states
@@ -225,13 +272,15 @@ export class ProjectEvaluationSubsystem {
225
272
  })
226
273
 
227
274
  // 5. publish evaluation state updates
228
- for (const state of updatedStates) {
275
+ if (updatedStates.length > 0) {
229
276
  void this.pubsubManager.publish(["instance-state", projectId], {
230
- type: "patched",
231
- stateId: state.stateId,
232
- patch: {
233
- evaluationState: state,
234
- },
277
+ type: "patched-batch",
278
+ patches: updatedStates.map(state => ({
279
+ stateId: state.stateId,
280
+ patch: {
281
+ evaluationState: state,
282
+ },
283
+ })),
235
284
  })
236
285
  }
237
286
 
@@ -276,19 +325,33 @@ export class ProjectEvaluationSubsystem {
276
325
  }
277
326
  }
278
327
 
279
- // 6. publish project model update
280
- void this.pubsubManager.publish(["project-model", projectId], {
281
- updatedVirtualInstances: [...newStates, ...updatedStates]
282
- .map(state => state.model as InstanceModel)
283
- .filter(isNonNullish),
284
-
285
- deletedVirtualInstanceIds: statesToDelete.map(state => state.state.instanceId),
328
+ const updatedVirtualInstances = [...newStates, ...updatedStates]
329
+ .map(state => state.model as InstanceModel)
330
+ .filter(isNonNullish)
331
+ const deletedVirtualInstanceIds = statesToDelete.map(state => state.state.instanceId)
332
+ const updatedGhostInstances = newGhostInstances.filter(isNonNullish)
286
333
 
287
- updatedGhostInstances: newGhostInstances.filter(isNonNullish),
334
+ // 6. publish project model update
335
+ if (
336
+ updatedVirtualInstances.length > 0 ||
337
+ deletedVirtualInstanceIds.length > 0 ||
338
+ updatedGhostInstances.length > 0 ||
339
+ resolvedGhostInstanceIds.length > 0
340
+ ) {
341
+ void this.pubsubManager.publish(["project-model", projectId], {
342
+ updatedVirtualInstances,
343
+ deletedVirtualInstanceIds,
344
+ updatedGhostInstances,
345
+ deletedGhostInstanceIds: resolvedGhostInstanceIds,
346
+ })
347
+ }
288
348
 
289
- deletedGhostInstanceIds: resolvedGhostInstanceIds,
290
- })
349
+ return { stateIds: Array.from(instanceIdToStateMap.values()).map(state => state.id) }
291
350
  })
351
+
352
+ if (stateIds.length > 0) {
353
+ await this.objectRefIndexService.track(projectId, stateIds)
354
+ }
292
355
  }
293
356
 
294
357
  private async trackUnlockedProject(projectId: string): Promise<void> {
@@ -384,4 +447,21 @@ export class ProjectEvaluationSubsystem {
384
447
 
385
448
  return `Composite instance evaluation completed successfully.\n\nInstance Tree:\n${tree}`
386
449
  }
450
+
451
+ private static mergeEvaluatedInstances(
452
+ evaluatedInstances: EvaluatedInstance[],
453
+ errorEvaluatedInstances: EvaluatedInstance[],
454
+ ): EvaluatedInstance[] {
455
+ const merged = new Map<InstanceId, EvaluatedInstance>()
456
+
457
+ for (const state of evaluatedInstances) {
458
+ merged.set(state.instanceId, state)
459
+ }
460
+
461
+ for (const state of errorEvaluatedInstances) {
462
+ merged.set(state.instanceId, state)
463
+ }
464
+
465
+ return Array.from(merged.values())
466
+ }
387
467
  }
@@ -0,0 +1,344 @@
1
+ import type { ProjectUnlockBackend } from "../unlock"
2
+ import { createId } from "@paralleldrive/cuid2"
3
+ import { describe, type MockedObject, vi } from "vitest"
4
+ import { test } from "../test-utils"
5
+ import { GlobalSearchService } from "./global-search"
6
+
7
+ const globalSearchTest = test.extend<{
8
+ projectUnlockBackend: MockedObject<ProjectUnlockBackend>
9
+ globalSearchService: GlobalSearchService
10
+ }>({
11
+ projectUnlockBackend: async ({}, use) => {
12
+ const projectUnlockBackend = vi.mockObject({
13
+ checkProjectUnlocked: vi.fn().mockResolvedValue(false),
14
+ getProjectMasterKey: vi.fn().mockResolvedValue(null),
15
+ unlockProject: vi.fn().mockResolvedValue(undefined),
16
+ lockProject: vi.fn().mockResolvedValue(undefined),
17
+ } as unknown as ProjectUnlockBackend)
18
+
19
+ await use(projectUnlockBackend)
20
+ },
21
+
22
+ globalSearchService: async ({ database, projectUnlockBackend, logger }, use) => {
23
+ const service = new GlobalSearchService(
24
+ database,
25
+ projectUnlockBackend,
26
+ logger.child({ service: "GlobalSearchService" }),
27
+ )
28
+
29
+ await use(service)
30
+ },
31
+ })
32
+
33
+ describe("searchByText", () => {
34
+ globalSearchTest(
35
+ "returns hits only from unlocked projects",
36
+ async ({
37
+ database,
38
+ createProject,
39
+ project,
40
+ projectUnlockBackend,
41
+ globalSearchService,
42
+ expect,
43
+ }) => {
44
+ // arrange
45
+ const lockedProject = await createProject("locked")
46
+
47
+ projectUnlockBackend.checkProjectUnlocked.mockImplementation(async projectId => {
48
+ return projectId === project.id
49
+ })
50
+
51
+ const unlockedDb = await database.forProject(project.id)
52
+ const lockedDb = await database.forProject(lockedProject.id)
53
+
54
+ const unlockedOperationId = createId()
55
+ await unlockedDb.operation.create({
56
+ data: {
57
+ id: unlockedOperationId,
58
+ meta: { title: "Deploy application" },
59
+ type: "update",
60
+ options: {},
61
+ requestedInstanceIds: [],
62
+ },
63
+ })
64
+
65
+ const lockedOperationId = createId()
66
+ await lockedDb.operation.create({
67
+ data: {
68
+ id: lockedOperationId,
69
+ meta: { title: "Deploy locked project" },
70
+ type: "update",
71
+ options: {},
72
+ requestedInstanceIds: [],
73
+ },
74
+ })
75
+
76
+ // act
77
+ const result = await globalSearchService.searchByText("deploy")
78
+
79
+ // assert
80
+ expect(result.projects).toHaveLength(1)
81
+ expect(result.projects[0].projectId).toBe(project.id)
82
+
83
+ const operationHits = result.projects[0].hits.filter(h => h.kind === "operation")
84
+ expect(operationHits.map(h => h.id)).toEqual([unlockedOperationId])
85
+ },
86
+ )
87
+
88
+ globalSearchTest(
89
+ "matches instance states by instanceId",
90
+ async ({ database, project, projectUnlockBackend, globalSearchService, expect }) => {
91
+ // arrange
92
+ projectUnlockBackend.checkProjectUnlocked.mockResolvedValue(true)
93
+
94
+ const db = await database.forProject(project.id)
95
+
96
+ const stateId = createId()
97
+ const instanceId = "component.v1:my-instance-123"
98
+
99
+ await db.instanceState.create({
100
+ data: {
101
+ id: stateId,
102
+ instanceId,
103
+ status: "undeployed",
104
+ source: "resident",
105
+ kind: "unit",
106
+ },
107
+ })
108
+
109
+ // act
110
+ const result = await globalSearchService.searchByText("my-instance")
111
+
112
+ // assert
113
+ expect(result.projects).toHaveLength(1)
114
+ expect(result.projects[0].projectId).toBe(project.id)
115
+
116
+ const hits = result.projects[0].hits.filter(h => h.kind === "instanceState")
117
+ expect(hits).toHaveLength(1)
118
+ expect(hits[0].id).toBe(stateId)
119
+ expect(hits[0].meta.title).toBe(instanceId)
120
+ expect(hits[0].meta.description).toBe("undeployed")
121
+ },
122
+ )
123
+
124
+ globalSearchTest(
125
+ "returns empty result for empty query",
126
+ async ({ projectUnlockBackend, globalSearchService, expect }) => {
127
+ // arrange
128
+ projectUnlockBackend.checkProjectUnlocked.mockResolvedValue(true)
129
+
130
+ // act
131
+ const result = await globalSearchService.searchByText(" ")
132
+
133
+ // assert
134
+ expect(result.projects).toEqual([])
135
+ },
136
+ )
137
+ })
138
+
139
+ describe("searchByIds", () => {
140
+ globalSearchTest(
141
+ "returns empty result for empty ids",
142
+ async ({ globalSearchService, expect }) => {
143
+ // act
144
+ const result = await globalSearchService.searchByIds([])
145
+
146
+ // assert
147
+ expect(result).toEqual([])
148
+ },
149
+ )
150
+
151
+ globalSearchTest(
152
+ "marks projects as locked when the project is locked",
153
+ async ({ database, project, projectUnlockBackend, globalSearchService, expect }) => {
154
+ // arrange
155
+ projectUnlockBackend.checkProjectUnlocked.mockResolvedValue(false)
156
+
157
+ const objectId = createId()
158
+ await database.backend.object.create({
159
+ data: {
160
+ id: objectId,
161
+ projectId: project.id,
162
+ },
163
+ })
164
+
165
+ // act
166
+ const result = await globalSearchService.searchByIds([objectId])
167
+
168
+ // assert
169
+ expect(result).toHaveLength(1)
170
+ expect(result[0].id).toBe(objectId)
171
+ expect(result[0].projects).toEqual([{ projectId: project.id, unlockState: "locked" }])
172
+ },
173
+ )
174
+
175
+ globalSearchTest(
176
+ "returns hits for unlocked projects and does not leak details for locked ones",
177
+ async ({
178
+ database,
179
+ createProject,
180
+ project,
181
+ projectUnlockBackend,
182
+ globalSearchService,
183
+ expect,
184
+ }) => {
185
+ // arrange
186
+ const lockedProject = await createProject(`locked-ids-${createId()}`)
187
+
188
+ projectUnlockBackend.checkProjectUnlocked.mockImplementation(async projectId => {
189
+ return projectId === project.id
190
+ })
191
+
192
+ const objectId = createId()
193
+ await database.backend.object.createMany({
194
+ data: [
195
+ { id: objectId, projectId: project.id },
196
+ { id: objectId, projectId: lockedProject.id },
197
+ ],
198
+ })
199
+
200
+ const unlockedDb = await database.forProject(project.id)
201
+ await unlockedDb.operation.create({
202
+ data: {
203
+ id: objectId,
204
+ meta: { title: "Deploy application" },
205
+ type: "update",
206
+ options: {},
207
+ requestedInstanceIds: [],
208
+ },
209
+ })
210
+
211
+ // act
212
+ const result = await globalSearchService.searchByIds([objectId])
213
+
214
+ // assert
215
+ expect(result).toHaveLength(1)
216
+
217
+ const projectResults = result[0].projects
218
+ expect(projectResults).toHaveLength(2)
219
+
220
+ const unlocked = projectResults.find(p => p.projectId === project.id)
221
+ expect(unlocked).toEqual({
222
+ projectId: project.id,
223
+ unlockState: "unlocked",
224
+ hits: [
225
+ {
226
+ kind: "operation",
227
+ id: objectId,
228
+ meta: { title: "Deploy application" },
229
+ },
230
+ ],
231
+ })
232
+
233
+ const locked = projectResults.find(p => p.projectId === lockedProject.id)
234
+ expect(locked).toEqual({ projectId: lockedProject.id, unlockState: "locked" })
235
+ },
236
+ )
237
+
238
+ globalSearchTest(
239
+ "returns unlocked projects with empty hits when the indexed object is not found in curated collections",
240
+ async ({ database, project, projectUnlockBackend, globalSearchService, expect }) => {
241
+ // arrange
242
+ projectUnlockBackend.checkProjectUnlocked.mockResolvedValue(true)
243
+
244
+ const objectId = createId()
245
+ await database.backend.object.create({
246
+ data: {
247
+ id: objectId,
248
+ projectId: project.id,
249
+ },
250
+ })
251
+
252
+ // act
253
+ const result = await globalSearchService.searchByIds([objectId])
254
+
255
+ // assert
256
+ expect(result).toHaveLength(1)
257
+ expect(result[0].projects).toEqual([
258
+ {
259
+ projectId: project.id,
260
+ unlockState: "unlocked",
261
+ hits: [],
262
+ },
263
+ ])
264
+ },
265
+ )
266
+
267
+ globalSearchTest(
268
+ "returns unlocked projects with empty hits when project database access fails",
269
+ async ({ database, createProject, projectUnlockBackend, logger, expect }) => {
270
+ // arrange
271
+ const projectA = await createProject(`aa-${createId()}`)
272
+ const projectB = await createProject(`bb-${createId()}`)
273
+
274
+ projectUnlockBackend.checkProjectUnlocked.mockResolvedValue(true)
275
+
276
+ const objectId = createId()
277
+ await database.backend.object.createMany({
278
+ data: [
279
+ { id: objectId, projectId: projectA.id },
280
+ { id: objectId, projectId: projectB.id },
281
+ ],
282
+ })
283
+
284
+ const unlockedDb = await database.forProject(projectA.id)
285
+ await unlockedDb.operation.create({
286
+ data: {
287
+ id: objectId,
288
+ meta: { title: "Deploy application" },
289
+ type: "update",
290
+ options: {},
291
+ requestedInstanceIds: [],
292
+ },
293
+ })
294
+
295
+ const failingDatabase = {
296
+ backend: database.backend,
297
+ isEncryptionEnabled: database.isEncryptionEnabled,
298
+ updateBackendUnlockRecipients: async () => {},
299
+ getProjectMasterKey: async () => undefined,
300
+ setupDatabase: async () => {
301
+ throw new Error("not implemented")
302
+ },
303
+ forProject: async (projectId: string) => {
304
+ if (projectId === projectB.id) {
305
+ throw new Error("boom")
306
+ }
307
+
308
+ return await database.forProject(projectId)
309
+ },
310
+ }
311
+
312
+ const service = new GlobalSearchService(
313
+ failingDatabase as never,
314
+ projectUnlockBackend,
315
+ logger.child({ service: "GlobalSearchService" }),
316
+ )
317
+
318
+ // act
319
+ const result = await service.searchByIds([objectId])
320
+
321
+ // assert
322
+ expect(result).toHaveLength(1)
323
+
324
+ const byProjectId = new Map(result[0].projects.map(p => [p.projectId, p]))
325
+ expect(byProjectId.get(projectA.id)).toEqual({
326
+ projectId: projectA.id,
327
+ unlockState: "unlocked",
328
+ hits: [
329
+ {
330
+ kind: "operation",
331
+ id: objectId,
332
+ meta: { title: "Deploy application" },
333
+ },
334
+ ],
335
+ })
336
+
337
+ expect(byProjectId.get(projectB.id)).toEqual({
338
+ projectId: projectB.id,
339
+ unlockState: "unlocked",
340
+ hits: [],
341
+ })
342
+ },
343
+ )
344
+ })