@highstate/backend 0.18.0 → 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-JT4KWE3B.js → chunk-52MY2TCE.js} +348 -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 +61 -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-JT4KWE3B.js.map +0 -1
@@ -0,0 +1,902 @@
1
+ import type { Logger } from "pino"
2
+ import type { DatabaseManager } from "../database"
3
+ import type { ProjectDatabase } from "../database/prisma"
4
+ import type { ProjectUnlockBackend } from "../unlock"
5
+ import { type CommonObjectMeta, commonObjectMetaSchema } from "@highstate/contract"
6
+
7
+ export type GlobalSearchObjectKind =
8
+ | "apiKey"
9
+ | "artifact"
10
+ | "entity"
11
+ | "entitySnapshot"
12
+ | "instanceState"
13
+ | "operation"
14
+ | "page"
15
+ | "secret"
16
+ | "serviceAccount"
17
+ | "terminal"
18
+ | "terminalSession"
19
+ | "trigger"
20
+ | "unlockMethod"
21
+ | "worker"
22
+ | "workerVersion"
23
+
24
+ export type GlobalSearchHit = {
25
+ kind: GlobalSearchObjectKind
26
+ id: string
27
+ meta: CommonObjectMeta
28
+ }
29
+
30
+ export type GlobalSearchTextProjectResult = {
31
+ projectId: string
32
+ hits: GlobalSearchHit[]
33
+ }
34
+
35
+ export type GlobalSearchTextResult = {
36
+ text: string
37
+ projects: GlobalSearchTextProjectResult[]
38
+ }
39
+
40
+ function normalizeSearchText(text: string): string {
41
+ return text.trim().toLowerCase()
42
+ }
43
+
44
+ function stringIncludesQuery(value: string | undefined, query: string): boolean {
45
+ if (!value) {
46
+ return false
47
+ }
48
+
49
+ return value.toLowerCase().includes(query)
50
+ }
51
+
52
+ function matchesCommonObjectMeta(meta: unknown, query: string): boolean {
53
+ const parsed = commonObjectMetaSchema.safeParse(meta)
54
+
55
+ if (parsed.success) {
56
+ return (
57
+ stringIncludesQuery(parsed.data.title, query) ||
58
+ stringIncludesQuery(parsed.data.description, query)
59
+ )
60
+ }
61
+
62
+ if (meta && typeof meta === "object") {
63
+ const title = "title" in meta && typeof meta.title === "string" ? meta.title : undefined
64
+ const description =
65
+ "description" in meta && typeof meta.description === "string" ? meta.description : undefined
66
+
67
+ return stringIncludesQuery(title, query) || stringIncludesQuery(description, query)
68
+ }
69
+
70
+ return false
71
+ }
72
+
73
+ function createSearchHits<TItem extends { id: string }>(
74
+ kind: GlobalSearchObjectKind,
75
+ items: TItem[],
76
+ metaExtractor: (item: TItem) => unknown,
77
+ logger: Logger,
78
+ ): GlobalSearchHit[] {
79
+ return items.map(item => {
80
+ const metaResult = commonObjectMetaSchema.safeParse(metaExtractor(item))
81
+
82
+ if (!metaResult.success) {
83
+ logger.warn(
84
+ { itemId: item.id, kind, error: metaResult.error },
85
+ "failed to parse meta for search hit, using fallback",
86
+ )
87
+
88
+ return {
89
+ kind,
90
+ id: item.id,
91
+ meta: {
92
+ title: item.id,
93
+ },
94
+ }
95
+ }
96
+
97
+ return {
98
+ kind,
99
+ id: item.id,
100
+ meta: metaResult.data,
101
+ }
102
+ })
103
+ }
104
+
105
+ export type GlobalSearchProjectResult =
106
+ | {
107
+ projectId: string
108
+ unlockState: "locked"
109
+ }
110
+ | {
111
+ projectId: string
112
+ unlockState: "unlocked"
113
+ hits: GlobalSearchHit[]
114
+ }
115
+
116
+ export type GlobalSearchResult = {
117
+ id: string
118
+ projects: GlobalSearchProjectResult[]
119
+ }
120
+
121
+ type ProjectSearchResolver = (
122
+ database: ProjectDatabase,
123
+ ids: string[],
124
+ logger: Logger,
125
+ ) => Promise<GlobalSearchHit[]>
126
+
127
+ const projectSearchResolvers: ProjectSearchResolver[] = [
128
+ async (database, ids, logger) => {
129
+ const operations = await database.operation.findMany({
130
+ where: {
131
+ id: {
132
+ in: ids,
133
+ },
134
+ },
135
+ select: {
136
+ id: true,
137
+ meta: true,
138
+ },
139
+ })
140
+
141
+ return createSearchHits("operation", operations, operation => operation.meta, logger)
142
+ },
143
+ async (database, ids, logger) => {
144
+ const states = await database.instanceState.findMany({
145
+ where: {
146
+ id: {
147
+ in: ids,
148
+ },
149
+ },
150
+ select: {
151
+ id: true,
152
+ instanceId: true,
153
+ status: true,
154
+ },
155
+ })
156
+
157
+ return createSearchHits(
158
+ "instanceState",
159
+ states,
160
+ state => ({
161
+ title: state.instanceId,
162
+ description: `${state.status}`,
163
+ }),
164
+ logger,
165
+ )
166
+ },
167
+ async (database, ids, logger) => {
168
+ const artifacts = await database.artifact.findMany({
169
+ where: {
170
+ id: {
171
+ in: ids,
172
+ },
173
+ },
174
+ select: {
175
+ id: true,
176
+ meta: true,
177
+ },
178
+ })
179
+
180
+ return createSearchHits("artifact", artifacts, artifact => artifact.meta, logger)
181
+ },
182
+ async (database, ids, logger) => {
183
+ const pages = await database.page.findMany({
184
+ where: {
185
+ id: {
186
+ in: ids,
187
+ },
188
+ },
189
+ select: {
190
+ id: true,
191
+ meta: true,
192
+ },
193
+ })
194
+
195
+ return createSearchHits("page", pages, page => page.meta, logger)
196
+ },
197
+ async (database, ids, logger) => {
198
+ const terminals = await database.terminal.findMany({
199
+ where: {
200
+ id: {
201
+ in: ids,
202
+ },
203
+ },
204
+ select: {
205
+ id: true,
206
+ meta: true,
207
+ },
208
+ })
209
+
210
+ return createSearchHits("terminal", terminals, terminal => terminal.meta, logger)
211
+ },
212
+ async (database, ids, logger) => {
213
+ const sessions = await database.terminalSession.findMany({
214
+ where: {
215
+ id: {
216
+ in: ids,
217
+ },
218
+ },
219
+ select: {
220
+ id: true,
221
+ terminal: {
222
+ select: {
223
+ meta: true,
224
+ },
225
+ },
226
+ },
227
+ })
228
+
229
+ return createSearchHits("terminalSession", sessions, session => session.terminal.meta, logger)
230
+ },
231
+ async (database, ids, logger) => {
232
+ const secrets = await database.secret.findMany({
233
+ where: {
234
+ id: {
235
+ in: ids,
236
+ },
237
+ },
238
+ select: {
239
+ id: true,
240
+ meta: true,
241
+ },
242
+ })
243
+
244
+ return createSearchHits("secret", secrets, secret => secret.meta, logger)
245
+ },
246
+ async (database, ids, logger) => {
247
+ const accounts = await database.serviceAccount.findMany({
248
+ where: {
249
+ id: {
250
+ in: ids,
251
+ },
252
+ },
253
+ select: {
254
+ id: true,
255
+ meta: true,
256
+ },
257
+ })
258
+
259
+ return createSearchHits("serviceAccount", accounts, account => account.meta, logger)
260
+ },
261
+ async (database, ids, logger) => {
262
+ const apiKeys = await database.apiKey.findMany({
263
+ where: {
264
+ id: {
265
+ in: ids,
266
+ },
267
+ },
268
+ select: {
269
+ id: true,
270
+ meta: true,
271
+ },
272
+ })
273
+
274
+ return createSearchHits("apiKey", apiKeys, apiKey => apiKey.meta, logger)
275
+ },
276
+ async (database, ids, logger) => {
277
+ const triggers = await database.trigger.findMany({
278
+ where: {
279
+ id: {
280
+ in: ids,
281
+ },
282
+ },
283
+ select: {
284
+ id: true,
285
+ meta: true,
286
+ },
287
+ })
288
+
289
+ return createSearchHits("trigger", triggers, trigger => trigger.meta, logger)
290
+ },
291
+ async (database, ids, logger) => {
292
+ const unlockMethods = await database.unlockMethod.findMany({
293
+ where: {
294
+ id: {
295
+ in: ids,
296
+ },
297
+ },
298
+ select: {
299
+ id: true,
300
+ meta: true,
301
+ },
302
+ })
303
+
304
+ return createSearchHits(
305
+ "unlockMethod",
306
+ unlockMethods,
307
+ unlockMethod => unlockMethod.meta,
308
+ logger,
309
+ )
310
+ },
311
+ async (database, ids, logger) => {
312
+ const workers = await database.worker.findMany({
313
+ where: {
314
+ id: {
315
+ in: ids,
316
+ },
317
+ },
318
+ select: {
319
+ id: true,
320
+ identity: true,
321
+ versions: {
322
+ orderBy: {
323
+ createdAt: "desc",
324
+ },
325
+ take: 1,
326
+ select: {
327
+ meta: true,
328
+ },
329
+ },
330
+ },
331
+ })
332
+
333
+ return createSearchHits("worker", workers, worker => worker.versions[0]?.meta, logger)
334
+ },
335
+ async (database, ids, logger) => {
336
+ const versions = await database.workerVersion.findMany({
337
+ where: {
338
+ id: {
339
+ in: ids,
340
+ },
341
+ },
342
+ select: {
343
+ id: true,
344
+ meta: true,
345
+ },
346
+ })
347
+
348
+ return createSearchHits("workerVersion", versions, version => version.meta, logger)
349
+ },
350
+ async (database, ids, logger) => {
351
+ const snapshots = await database.entitySnapshot.findMany({
352
+ where: {
353
+ id: {
354
+ in: ids,
355
+ },
356
+ },
357
+ select: {
358
+ id: true,
359
+ content: {
360
+ select: {
361
+ meta: true,
362
+ },
363
+ },
364
+ },
365
+ })
366
+
367
+ return createSearchHits("entitySnapshot", snapshots, snapshot => snapshot.content.meta, logger)
368
+ },
369
+ async (database, ids, logger) => {
370
+ const entities = await database.entity.findMany({
371
+ where: {
372
+ id: {
373
+ in: ids,
374
+ },
375
+ },
376
+ select: {
377
+ id: true,
378
+ type: true,
379
+ identity: true,
380
+ snapshots: {
381
+ orderBy: {
382
+ createdAt: "desc",
383
+ },
384
+ take: 1,
385
+ select: {
386
+ content: {
387
+ select: {
388
+ meta: true,
389
+ },
390
+ },
391
+ },
392
+ },
393
+ },
394
+ })
395
+
396
+ return createSearchHits("entity", entities, entity => entity.snapshots[0]?.content.meta, logger)
397
+ },
398
+ ]
399
+
400
+ type ProjectTextSearchResolver = (
401
+ database: ProjectDatabase,
402
+ text: string,
403
+ logger: Logger,
404
+ ) => Promise<GlobalSearchHit[]>
405
+
406
+ const projectTextSearchResolvers: ProjectTextSearchResolver[] = [
407
+ async (database, text, logger) => {
408
+ const operations = await database.operation.findMany({
409
+ select: {
410
+ id: true,
411
+ meta: true,
412
+ },
413
+ })
414
+
415
+ const matches = operations.filter(
416
+ operation =>
417
+ stringIncludesQuery(operation.id, text) || matchesCommonObjectMeta(operation.meta, text),
418
+ )
419
+
420
+ return createSearchHits("operation", matches, operation => operation.meta, logger)
421
+ },
422
+ async (database, text, logger) => {
423
+ const states = await database.instanceState.findMany({
424
+ select: {
425
+ id: true,
426
+ instanceId: true,
427
+ status: true,
428
+ },
429
+ })
430
+
431
+ const matches = states.filter(
432
+ state =>
433
+ stringIncludesQuery(state.id, text) ||
434
+ stringIncludesQuery(state.instanceId, text) ||
435
+ stringIncludesQuery(`${state.status}`, text),
436
+ )
437
+
438
+ return createSearchHits(
439
+ "instanceState",
440
+ matches,
441
+ state => ({
442
+ title: state.instanceId,
443
+ description: `${state.status}`,
444
+ }),
445
+ logger,
446
+ )
447
+ },
448
+ async (database, text, logger) => {
449
+ const artifacts = await database.artifact.findMany({
450
+ select: {
451
+ id: true,
452
+ hash: true,
453
+ meta: true,
454
+ },
455
+ })
456
+
457
+ const matches = artifacts.filter(
458
+ artifact =>
459
+ stringIncludesQuery(artifact.id, text) ||
460
+ stringIncludesQuery(artifact.hash, text) ||
461
+ matchesCommonObjectMeta(artifact.meta, text),
462
+ )
463
+
464
+ return createSearchHits("artifact", matches, artifact => artifact.meta, logger)
465
+ },
466
+ async (database, text, logger) => {
467
+ const pages = await database.page.findMany({
468
+ select: {
469
+ id: true,
470
+ name: true,
471
+ meta: true,
472
+ },
473
+ })
474
+
475
+ const matches = pages.filter(
476
+ page =>
477
+ stringIncludesQuery(page.id, text) ||
478
+ stringIncludesQuery(page.name ?? undefined, text) ||
479
+ matchesCommonObjectMeta(page.meta, text),
480
+ )
481
+
482
+ return createSearchHits("page", matches, page => page.meta, logger)
483
+ },
484
+ async (database, text, logger) => {
485
+ const terminals = await database.terminal.findMany({
486
+ select: {
487
+ id: true,
488
+ name: true,
489
+ meta: true,
490
+ },
491
+ })
492
+
493
+ const matches = terminals.filter(
494
+ terminal =>
495
+ stringIncludesQuery(terminal.id, text) ||
496
+ stringIncludesQuery(terminal.name ?? undefined, text) ||
497
+ matchesCommonObjectMeta(terminal.meta, text),
498
+ )
499
+
500
+ return createSearchHits("terminal", matches, terminal => terminal.meta, logger)
501
+ },
502
+ async (database, text, logger) => {
503
+ const sessions = await database.terminalSession.findMany({
504
+ select: {
505
+ id: true,
506
+ terminal: {
507
+ select: {
508
+ name: true,
509
+ meta: true,
510
+ },
511
+ },
512
+ },
513
+ })
514
+
515
+ const matches = sessions.filter(
516
+ session =>
517
+ stringIncludesQuery(session.id, text) ||
518
+ stringIncludesQuery(session.terminal.name ?? undefined, text) ||
519
+ matchesCommonObjectMeta(session.terminal.meta, text),
520
+ )
521
+
522
+ return createSearchHits("terminalSession", matches, session => session.terminal.meta, logger)
523
+ },
524
+ async (database, text, logger) => {
525
+ const secrets = await database.secret.findMany({
526
+ select: {
527
+ id: true,
528
+ name: true,
529
+ systemName: true,
530
+ meta: true,
531
+ },
532
+ })
533
+
534
+ const matches = secrets.filter(
535
+ secret =>
536
+ stringIncludesQuery(secret.id, text) ||
537
+ stringIncludesQuery(secret.name ?? undefined, text) ||
538
+ stringIncludesQuery(secret.systemName ?? undefined, text) ||
539
+ matchesCommonObjectMeta(secret.meta, text),
540
+ )
541
+
542
+ return createSearchHits("secret", matches, secret => secret.meta, logger)
543
+ },
544
+ async (database, text, logger) => {
545
+ const accounts = await database.serviceAccount.findMany({
546
+ select: {
547
+ id: true,
548
+ meta: true,
549
+ },
550
+ })
551
+
552
+ const matches = accounts.filter(
553
+ account =>
554
+ stringIncludesQuery(account.id, text) || matchesCommonObjectMeta(account.meta, text),
555
+ )
556
+
557
+ return createSearchHits("serviceAccount", matches, account => account.meta, logger)
558
+ },
559
+ async (database, text, logger) => {
560
+ const apiKeys = await database.apiKey.findMany({
561
+ select: {
562
+ id: true,
563
+ meta: true,
564
+ },
565
+ })
566
+
567
+ const matches = apiKeys.filter(
568
+ apiKey => stringIncludesQuery(apiKey.id, text) || matchesCommonObjectMeta(apiKey.meta, text),
569
+ )
570
+
571
+ return createSearchHits("apiKey", matches, apiKey => apiKey.meta, logger)
572
+ },
573
+ async (database, text, logger) => {
574
+ const triggers = await database.trigger.findMany({
575
+ select: {
576
+ id: true,
577
+ name: true,
578
+ meta: true,
579
+ },
580
+ })
581
+
582
+ const matches = triggers.filter(
583
+ trigger =>
584
+ stringIncludesQuery(trigger.id, text) ||
585
+ stringIncludesQuery(trigger.name, text) ||
586
+ matchesCommonObjectMeta(trigger.meta, text),
587
+ )
588
+
589
+ return createSearchHits("trigger", matches, trigger => trigger.meta, logger)
590
+ },
591
+ async (database, text, logger) => {
592
+ const unlockMethods = await database.unlockMethod.findMany({
593
+ select: {
594
+ id: true,
595
+ type: true,
596
+ recipient: true,
597
+ meta: true,
598
+ },
599
+ })
600
+
601
+ const matches = unlockMethods.filter(
602
+ unlockMethod =>
603
+ stringIncludesQuery(unlockMethod.id, text) ||
604
+ stringIncludesQuery(`${unlockMethod.type}`, text) ||
605
+ stringIncludesQuery(unlockMethod.recipient, text) ||
606
+ matchesCommonObjectMeta(unlockMethod.meta, text),
607
+ )
608
+
609
+ return createSearchHits("unlockMethod", matches, unlockMethod => unlockMethod.meta, logger)
610
+ },
611
+ async (database, text, logger) => {
612
+ const workers = await database.worker.findMany({
613
+ select: {
614
+ id: true,
615
+ identity: true,
616
+ versions: {
617
+ orderBy: {
618
+ createdAt: "desc",
619
+ },
620
+ take: 1,
621
+ select: {
622
+ meta: true,
623
+ },
624
+ },
625
+ },
626
+ })
627
+
628
+ const matches = workers.filter(
629
+ worker =>
630
+ stringIncludesQuery(worker.id, text) ||
631
+ stringIncludesQuery(worker.identity, text) ||
632
+ matchesCommonObjectMeta(worker.versions[0]?.meta, text),
633
+ )
634
+
635
+ return createSearchHits("worker", matches, worker => worker.versions[0]?.meta, logger)
636
+ },
637
+ async (database, text, logger) => {
638
+ const versions = await database.workerVersion.findMany({
639
+ select: {
640
+ id: true,
641
+ digest: true,
642
+ meta: true,
643
+ },
644
+ })
645
+
646
+ const matches = versions.filter(
647
+ version =>
648
+ stringIncludesQuery(version.id, text) ||
649
+ stringIncludesQuery(version.digest, text) ||
650
+ matchesCommonObjectMeta(version.meta, text),
651
+ )
652
+
653
+ return createSearchHits("workerVersion", matches, version => version.meta, logger)
654
+ },
655
+ async (database, text, logger) => {
656
+ const snapshots = await database.entitySnapshot.findMany({
657
+ select: {
658
+ id: true,
659
+ entityId: true,
660
+ content: {
661
+ select: {
662
+ meta: true,
663
+ },
664
+ },
665
+ },
666
+ })
667
+
668
+ const matches = snapshots.filter(
669
+ snapshot =>
670
+ stringIncludesQuery(snapshot.id, text) ||
671
+ stringIncludesQuery(snapshot.entityId, text) ||
672
+ matchesCommonObjectMeta(snapshot.content.meta, text),
673
+ )
674
+
675
+ return createSearchHits("entitySnapshot", matches, snapshot => snapshot.content.meta, logger)
676
+ },
677
+ async (database, text, logger) => {
678
+ const entities = await database.entity.findMany({
679
+ select: {
680
+ id: true,
681
+ type: true,
682
+ identity: true,
683
+ snapshots: {
684
+ orderBy: {
685
+ createdAt: "desc",
686
+ },
687
+ take: 1,
688
+ select: {
689
+ content: {
690
+ select: {
691
+ meta: true,
692
+ },
693
+ },
694
+ },
695
+ },
696
+ },
697
+ })
698
+
699
+ const matches = entities.filter(
700
+ entity =>
701
+ stringIncludesQuery(entity.id, text) ||
702
+ stringIncludesQuery(entity.type, text) ||
703
+ stringIncludesQuery(entity.identity, text) ||
704
+ matchesCommonObjectMeta(entity.snapshots[0]?.content.meta, text),
705
+ )
706
+
707
+ return createSearchHits("entity", matches, entity => entity.snapshots[0]?.content.meta, logger)
708
+ },
709
+ ]
710
+
711
+ export class GlobalSearchService {
712
+ constructor(
713
+ private readonly database: DatabaseManager,
714
+ private readonly projectUnlockBackend: ProjectUnlockBackend,
715
+ private readonly logger: Logger,
716
+ ) {}
717
+
718
+ /**
719
+ * Searches for objects by their IDs across all projects that reference them.
720
+ *
721
+ * For locked projects, it only returns that the project has a match.
722
+ * For unlocked projects, it queries a curated set of collections to find matching objects.
723
+ *
724
+ * @param ids The list of object IDs to search for.
725
+ */
726
+ async searchByIds(ids: string[]): Promise<GlobalSearchResult[]> {
727
+ const uniqueIds = Array.from(new Set(ids.map(id => id.trim()).filter(Boolean)))
728
+
729
+ if (uniqueIds.length === 0) {
730
+ return []
731
+ }
732
+
733
+ const indexed = await this.database.backend.object.findMany({
734
+ where: {
735
+ id: {
736
+ in: uniqueIds,
737
+ },
738
+ },
739
+ select: {
740
+ id: true,
741
+ projectId: true,
742
+ },
743
+ })
744
+
745
+ const idsByProjectId = new Map<string, Set<string>>()
746
+
747
+ for (const row of indexed) {
748
+ const existingIds = idsByProjectId.get(row.projectId) ?? new Set<string>()
749
+ existingIds.add(row.id)
750
+ idsByProjectId.set(row.projectId, existingIds)
751
+ }
752
+
753
+ const projectsById = new Map<string, GlobalSearchProjectResult[]>()
754
+
755
+ for (const projectId of idsByProjectId.keys()) {
756
+ const idsInProject = Array.from(idsByProjectId.get(projectId) ?? [])
757
+
758
+ if (idsInProject.length === 0) {
759
+ continue
760
+ }
761
+
762
+ const isUnlocked = await this.projectUnlockBackend.checkProjectUnlocked(projectId)
763
+
764
+ if (!isUnlocked) {
765
+ for (const id of idsInProject) {
766
+ const existing = projectsById.get(id) ?? []
767
+ existing.push({ projectId, unlockState: "locked" })
768
+ projectsById.set(id, existing)
769
+ }
770
+ continue
771
+ }
772
+
773
+ try {
774
+ const projectDatabase = await this.database.forProject(projectId)
775
+ const hitsById = await this.searchInUnlockedProject(projectDatabase, idsInProject)
776
+
777
+ for (const id of idsInProject) {
778
+ const hits = hitsById.get(id) ?? []
779
+
780
+ const existing = projectsById.get(id) ?? []
781
+ existing.push({ projectId, unlockState: "unlocked", hits })
782
+ projectsById.set(id, existing)
783
+ }
784
+ } catch (error) {
785
+ this.logger.error(
786
+ { error, projectId },
787
+ 'failed to search in unlocked project "%s"',
788
+ projectId,
789
+ )
790
+
791
+ for (const id of idsInProject) {
792
+ const existing = projectsById.get(id) ?? []
793
+ existing.push({ projectId, unlockState: "unlocked", hits: [] })
794
+ projectsById.set(id, existing)
795
+ }
796
+ }
797
+ }
798
+
799
+ return uniqueIds.map(id => ({ id, projects: projectsById.get(id) ?? [] }))
800
+ }
801
+
802
+ /**
803
+ * Searches for objects across all unlocked projects by a free-form text.
804
+ *
805
+ * It queries a curated set of collections within each unlocked project.
806
+ * Locked projects are skipped because their databases cannot be queried.
807
+ *
808
+ * @param text The text query to search for.
809
+ */
810
+ async searchByText(text: string): Promise<GlobalSearchTextResult> {
811
+ const normalizedText = normalizeSearchText(text)
812
+
813
+ if (normalizedText.length === 0) {
814
+ return { text, projects: [] }
815
+ }
816
+
817
+ const projects = await this.database.backend.project.findMany({
818
+ select: {
819
+ id: true,
820
+ },
821
+ })
822
+
823
+ const results: GlobalSearchTextProjectResult[] = []
824
+
825
+ for (const project of projects) {
826
+ const isUnlocked = await this.projectUnlockBackend.checkProjectUnlocked(project.id)
827
+
828
+ if (!isUnlocked) {
829
+ continue
830
+ }
831
+
832
+ try {
833
+ const projectDatabase = await this.database.forProject(project.id)
834
+ const hits = await this.searchInUnlockedProjectByText(projectDatabase, normalizedText)
835
+
836
+ if (hits.length === 0) {
837
+ continue
838
+ }
839
+
840
+ results.push({ projectId: project.id, hits })
841
+ } catch (error) {
842
+ this.logger.error(
843
+ { error, projectId: project.id },
844
+ 'failed to search by text in unlocked project "%s"',
845
+ project.id,
846
+ )
847
+ }
848
+ }
849
+
850
+ return { text, projects: results }
851
+ }
852
+
853
+ private async searchInUnlockedProject(
854
+ database: ProjectDatabase,
855
+ ids: string[],
856
+ ): Promise<Map<string, GlobalSearchHit[]>> {
857
+ const settled = await Promise.allSettled(
858
+ projectSearchResolvers.map(async r => await r(database, ids, this.logger)),
859
+ )
860
+
861
+ const hitsById = new Map<string, GlobalSearchHit[]>()
862
+
863
+ for (const result of settled) {
864
+ if (result.status === "rejected") {
865
+ this.logger.debug({ error: result.reason, ids }, "project search resolver failed")
866
+ continue
867
+ }
868
+
869
+ for (const hit of result.value) {
870
+ const existing = hitsById.get(hit.id) ?? []
871
+ existing.push(hit)
872
+ hitsById.set(hit.id, existing)
873
+ }
874
+ }
875
+
876
+ return hitsById
877
+ }
878
+
879
+ private async searchInUnlockedProjectByText(
880
+ database: ProjectDatabase,
881
+ text: string,
882
+ ): Promise<GlobalSearchHit[]> {
883
+ const settled = await Promise.allSettled(
884
+ projectTextSearchResolvers.map(async r => await r(database, text, this.logger)),
885
+ )
886
+
887
+ const hitsByKey = new Map<string, GlobalSearchHit>()
888
+
889
+ for (const result of settled) {
890
+ if (result.status === "rejected") {
891
+ this.logger.debug({ error: result.reason, text }, "project text search resolver failed")
892
+ continue
893
+ }
894
+
895
+ for (const hit of result.value) {
896
+ hitsByKey.set(`${hit.kind}:${hit.id}`, hit)
897
+ }
898
+ }
899
+
900
+ return Array.from(hitsByKey.values())
901
+ }
902
+ }