@electric-ax/agents-server 0.3.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 (68) hide show
  1. package/LICENSE +177 -0
  2. package/dist/chunk-Cl8Af3a2.js +11 -0
  3. package/dist/entrypoint.js +7319 -0
  4. package/dist/index.cjs +7090 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4263 -0
  7. package/dist/index.js +7053 -0
  8. package/drizzle/0000_baseline.sql +97 -0
  9. package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
  10. package/drizzle/0002_tag_outbox_hardening.sql +14 -0
  11. package/drizzle/0003_entity_manifest_sources.sql +11 -0
  12. package/drizzle/0004_tenant_scoping.sql +139 -0
  13. package/drizzle/0005_pull_wake_control_plane.sql +156 -0
  14. package/drizzle/meta/0000_snapshot.json +593 -0
  15. package/drizzle/meta/_journal.json +48 -0
  16. package/package.json +89 -0
  17. package/src/authenticated-user-format.ts +17 -0
  18. package/src/claim-write-token-store.ts +74 -0
  19. package/src/db/index.ts +53 -0
  20. package/src/db/schema.ts +490 -0
  21. package/src/dev-asserted-auth.ts +46 -0
  22. package/src/dispatch-policy-schema.ts +52 -0
  23. package/src/electric-agents/adapter-types.ts +70 -0
  24. package/src/electric-agents/default-entity-schemas.ts +1 -0
  25. package/src/electric-agents/schema-validator.ts +143 -0
  26. package/src/electric-agents-http.ts +46 -0
  27. package/src/electric-agents-types.ts +335 -0
  28. package/src/entity-bridge-manager.ts +694 -0
  29. package/src/entity-manager.ts +2601 -0
  30. package/src/entity-projector.ts +765 -0
  31. package/src/entity-registry.ts +1162 -0
  32. package/src/entrypoint-lib.ts +295 -0
  33. package/src/entrypoint.ts +11 -0
  34. package/src/host.ts +323 -0
  35. package/src/index.ts +49 -0
  36. package/src/manifest-side-effects.ts +183 -0
  37. package/src/routing/agent-ui-router.ts +81 -0
  38. package/src/routing/context.ts +35 -0
  39. package/src/routing/cron-router.ts +45 -0
  40. package/src/routing/dispatch-policy.ts +248 -0
  41. package/src/routing/durable-streams-router.ts +407 -0
  42. package/src/routing/durable-streams-routing-adapter.ts +96 -0
  43. package/src/routing/electric-proxy-router.ts +61 -0
  44. package/src/routing/entities-router.ts +484 -0
  45. package/src/routing/entity-types-router.ts +229 -0
  46. package/src/routing/global-router.ts +33 -0
  47. package/src/routing/hooks.ts +123 -0
  48. package/src/routing/internal-router.ts +741 -0
  49. package/src/routing/oss-server-router.ts +56 -0
  50. package/src/routing/runners-router.ts +416 -0
  51. package/src/routing/schema.ts +141 -0
  52. package/src/routing/stream-append.ts +196 -0
  53. package/src/routing/tenant-stream-paths.ts +26 -0
  54. package/src/runtime-registry.ts +49 -0
  55. package/src/runtime.ts +537 -0
  56. package/src/scheduler.ts +788 -0
  57. package/src/schema-validation.ts +15 -0
  58. package/src/server.ts +374 -0
  59. package/src/standalone-runtime.ts +188 -0
  60. package/src/stream-client.ts +842 -0
  61. package/src/tag-stream-outbox-drainer.ts +188 -0
  62. package/src/tenant.ts +25 -0
  63. package/src/tracing.ts +57 -0
  64. package/src/utils/electric-url.ts +15 -0
  65. package/src/utils/log.ts +95 -0
  66. package/src/utils/server-utils.ts +245 -0
  67. package/src/utils/webhook-url.ts +33 -0
  68. package/src/wake-registry.ts +946 -0
@@ -0,0 +1,765 @@
1
+ import { DurableStream, IdempotentProducer } from '@durable-streams/client'
2
+ import {
3
+ assertTags,
4
+ buildTagsIndex,
5
+ getEntitiesStreamPath,
6
+ normalizeTags,
7
+ sourceRefForTags,
8
+ } from '@electric-ax/agents-runtime'
9
+ import {
10
+ ShapeStream,
11
+ isChangeMessage,
12
+ isControlMessage,
13
+ } from '@electric-sql/client'
14
+ import { PostgresRegistry } from './entity-registry.js'
15
+ import { electricUrlWithPath } from './utils/electric-url.js'
16
+ import { serverLog } from './utils/log.js'
17
+ import { isUnregisteredTenantError } from './tenant.js'
18
+ import type { DrizzleDB } from './db/index.js'
19
+ import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
20
+ import type { EntityBridgeRow } from './entity-registry.js'
21
+ import type { StreamClient } from './stream-client.js'
22
+ import type {
23
+ ChangeMessage,
24
+ Message,
25
+ Offset,
26
+ Row,
27
+ ShapeStreamInterface,
28
+ } from '@electric-sql/client'
29
+ import type {
30
+ EntityMembershipRow,
31
+ EntityTags,
32
+ } from '@electric-ax/agents-runtime'
33
+
34
+ interface EntityShapeRow extends Row<unknown> {
35
+ tenant_id: string
36
+ url: string
37
+ type: string
38
+ status: `spawning` | `running` | `idle` | `stopped`
39
+ tags: EntityTags
40
+ spawn_args?: Record<string, unknown> | null
41
+ parent?: string | null
42
+ type_revision?: number | null
43
+ inbox_schemas?: Record<string, Record<string, unknown>> | null
44
+ state_schemas?: Record<string, Record<string, unknown>> | null
45
+ created_at: number
46
+ updated_at: number
47
+ }
48
+
49
+ const ENTITY_SHAPE_COLUMNS = [
50
+ `tenant_id`,
51
+ `url`,
52
+ `type`,
53
+ `status`,
54
+ `tags`,
55
+ `spawn_args`,
56
+ `parent`,
57
+ `type_revision`,
58
+ `inbox_schemas`,
59
+ `state_schemas`,
60
+ `created_at`,
61
+ `updated_at`,
62
+ ] as const
63
+
64
+ type StreamClientResolver = (
65
+ tenantId: string
66
+ ) => StreamClient | Promise<StreamClient>
67
+ type TenantIdsProvider = () => Iterable<string>
68
+
69
+ export interface EntityProjectorOptions {
70
+ db: DrizzleDB
71
+ electricUrl?: string
72
+ electricSecret?: string
73
+ streamClientForTenant: StreamClientResolver
74
+ tenantIds?: TenantIdsProvider
75
+ }
76
+
77
+ function entityKey(tenantId: string, url: string): string {
78
+ return `${tenantId}:${url}`
79
+ }
80
+
81
+ function projectionKey(tenantId: string, sourceRef: string): string {
82
+ return `${tenantId}:${sourceRef}`
83
+ }
84
+
85
+ function sourceRefFromStreamPath(streamPath: string): string | null {
86
+ const match = streamPath.match(/^\/_entities\/([^/]+)$/)
87
+ return match?.[1] ?? null
88
+ }
89
+
90
+ function sameMember(
91
+ left: EntityMembershipRow | undefined,
92
+ right: EntityMembershipRow
93
+ ): boolean {
94
+ return JSON.stringify(left) === JSON.stringify(right)
95
+ }
96
+
97
+ function entityMatchesTags(entity: EntityShapeRow, tags: EntityTags): boolean {
98
+ const required = buildTagsIndex(tags)
99
+ if (required.length === 0) return true
100
+ const entityTags = new Set(buildTagsIndex(entity.tags))
101
+ return required.every((tag) => entityTags.has(tag))
102
+ }
103
+
104
+ function toMemberRow(entity: EntityShapeRow): EntityMembershipRow {
105
+ return {
106
+ url: entity.url,
107
+ type: entity.type,
108
+ status: entity.status,
109
+ tags: entity.tags,
110
+ spawn_args: entity.spawn_args ?? {},
111
+ parent: entity.parent ?? null,
112
+ type_revision: entity.type_revision ?? null,
113
+ inbox_schemas: entity.inbox_schemas ?? null,
114
+ state_schemas: entity.state_schemas ?? null,
115
+ created_at: entity.created_at,
116
+ updated_at: entity.updated_at,
117
+ }
118
+ }
119
+
120
+ class ProjectedEntityBridge {
121
+ readonly tenantId: string
122
+ readonly sourceRef: string
123
+ readonly tags: EntityTags
124
+ readonly streamUrl: string
125
+
126
+ private currentMembers = new Map<string, EntityMembershipRow>()
127
+ private producer: IdempotentProducer | null = null
128
+ private stopped = false
129
+
130
+ constructor(
131
+ row: EntityBridgeRow,
132
+ private streamClient: StreamClient
133
+ ) {
134
+ this.tenantId = row.tenantId
135
+ this.sourceRef = row.sourceRef
136
+ this.tags = normalizeTags(row.tags)
137
+ this.streamUrl = row.streamUrl
138
+ }
139
+
140
+ async start(initialEntities: Iterable<EntityShapeRow>): Promise<void> {
141
+ await this.ensureStream()
142
+ this.producer = new IdempotentProducer(
143
+ new DurableStream({
144
+ url: `${this.streamClient.baseUrl}${this.streamUrl}`,
145
+ contentType: `application/json`,
146
+ }),
147
+ `entity-bridge-${this.sourceRef}`,
148
+ {
149
+ autoClaim: true,
150
+ onError: (error) => {
151
+ serverLog.warn(
152
+ `[entity-projector] producer write failed for ${this.tenantId}/${this.sourceRef}:`,
153
+ error
154
+ )
155
+ },
156
+ }
157
+ )
158
+ await this.loadCurrentMembers()
159
+ this.reconcile(initialEntities)
160
+ }
161
+
162
+ async stop(): Promise<void> {
163
+ this.stopped = true
164
+ if (this.producer) {
165
+ try {
166
+ await this.producer.flush()
167
+ } catch {
168
+ // Reconcile repairs missed writes on next startup.
169
+ }
170
+ await this.producer.detach()
171
+ this.producer = null
172
+ }
173
+ }
174
+
175
+ reconcile(entities: Iterable<EntityShapeRow>): void {
176
+ if (this.stopped) return
177
+
178
+ const staleMembers = new Map(this.currentMembers)
179
+ for (const entity of entities) {
180
+ if (entity.tenant_id !== this.tenantId) continue
181
+ if (!entityMatchesTags(entity, this.tags)) continue
182
+ staleMembers.delete(entity.url)
183
+ this.upsertEntity(entity)
184
+ }
185
+
186
+ for (const [url, row] of staleMembers) {
187
+ this.append(`delete`, row)
188
+ this.currentMembers.delete(url)
189
+ }
190
+ }
191
+
192
+ applyEntity(entity: EntityShapeRow): void {
193
+ if (this.stopped) return
194
+ if (entity.tenant_id !== this.tenantId) return
195
+
196
+ if (!entityMatchesTags(entity, this.tags)) {
197
+ const existing = this.currentMembers.get(entity.url)
198
+ if (!existing) return
199
+ this.append(`delete`, existing)
200
+ this.currentMembers.delete(entity.url)
201
+ return
202
+ }
203
+
204
+ this.upsertEntity(entity)
205
+ }
206
+
207
+ deleteEntity(entity: EntityShapeRow): void {
208
+ if (this.stopped) return
209
+ const existing = this.currentMembers.get(entity.url)
210
+ if (!existing) return
211
+ this.append(`delete`, existing)
212
+ this.currentMembers.delete(entity.url)
213
+ }
214
+
215
+ private upsertEntity(entity: EntityShapeRow): void {
216
+ const next = toMemberRow(entity)
217
+ const existing = this.currentMembers.get(entity.url)
218
+
219
+ if (!existing) {
220
+ this.append(`insert`, next)
221
+ this.currentMembers.set(entity.url, next)
222
+ return
223
+ }
224
+
225
+ if (!sameMember(existing, next)) {
226
+ this.append(`update`, next)
227
+ this.currentMembers.set(entity.url, next)
228
+ }
229
+ }
230
+
231
+ private async ensureStream(): Promise<void> {
232
+ if (!(await this.streamClient.exists(this.streamUrl))) {
233
+ await this.streamClient.create(this.streamUrl, {
234
+ contentType: `application/json`,
235
+ })
236
+ }
237
+ }
238
+
239
+ private async loadCurrentMembers(): Promise<void> {
240
+ this.currentMembers.clear()
241
+ const events = await this.streamClient.readJson<Record<string, unknown>>(
242
+ this.streamUrl
243
+ )
244
+ for (const event of events) {
245
+ if (event.type !== `members` || typeof event.key !== `string`) {
246
+ continue
247
+ }
248
+ const headers =
249
+ typeof event.headers === `object` && event.headers !== null
250
+ ? (event.headers as Record<string, unknown>)
251
+ : undefined
252
+ const operation = headers?.operation
253
+ if (operation === `delete`) {
254
+ this.currentMembers.delete(event.key)
255
+ continue
256
+ }
257
+ const value = event.value as EntityMembershipRow | undefined
258
+ if (value) {
259
+ this.currentMembers.set(event.key, value)
260
+ }
261
+ }
262
+ }
263
+
264
+ private append(
265
+ operation: `insert` | `update` | `delete`,
266
+ row: EntityMembershipRow
267
+ ): void {
268
+ if (!this.producer) {
269
+ throw new Error(
270
+ `[entity-projector] producer is not initialized for ${this.tenantId}/${this.sourceRef}`
271
+ )
272
+ }
273
+
274
+ const event =
275
+ operation === `delete`
276
+ ? {
277
+ type: `members`,
278
+ key: row.url,
279
+ old_value: row,
280
+ headers: {
281
+ operation,
282
+ timestamp: new Date().toISOString(),
283
+ },
284
+ }
285
+ : {
286
+ type: `members`,
287
+ key: row.url,
288
+ value: row,
289
+ headers: {
290
+ operation,
291
+ timestamp: new Date().toISOString(),
292
+ },
293
+ }
294
+
295
+ this.producer.append(JSON.stringify(event))
296
+ }
297
+ }
298
+
299
+ export class EntityProjector {
300
+ private readonly db: DrizzleDB
301
+ private readonly electricUrl?: string
302
+ private readonly electricSecret?: string
303
+ private readonly streamClientForTenant: StreamClientResolver
304
+ private readonly tenantIds?: TenantIdsProvider
305
+ private readonly projections = new Map<string, ProjectedEntityBridge>()
306
+ private readonly startingProjections = new Map<string, Promise<void>>()
307
+ private readonly registries = new Map<string, PostgresRegistry>()
308
+ private readonly activeReaders = new Map<string, number>()
309
+ private readonly entities = new Map<string, EntityShapeRow>()
310
+ private abortController: AbortController | null = null
311
+ private unsubscribe: (() => void) | null = null
312
+ private gcTimer: NodeJS.Timeout | null = null
313
+ private started = false
314
+ private upToDate = false
315
+ private readyPromise: Promise<void> = Promise.resolve()
316
+ private readyResolve: (() => void) | null = null
317
+ private readyReject: ((error: Error) => void) | null = null
318
+
319
+ constructor(options: EntityProjectorOptions) {
320
+ this.db = options.db
321
+ this.electricUrl = options.electricUrl
322
+ this.electricSecret = options.electricSecret
323
+ this.streamClientForTenant = options.streamClientForTenant
324
+ this.tenantIds = options.tenantIds
325
+ }
326
+
327
+ forTenant(
328
+ tenantId: string,
329
+ registry = new PostgresRegistry(this.db, tenantId)
330
+ ): EntityProjectorTenantFacade {
331
+ this.registries.set(tenantId, registry)
332
+ return new EntityProjectorTenantFacade(this, tenantId, registry)
333
+ }
334
+
335
+ async start(): Promise<void> {
336
+ if (!this.electricUrl) return
337
+ if (this.started) {
338
+ await this.waitUntilReady()
339
+ return
340
+ }
341
+
342
+ this.started = true
343
+ this.resetReady()
344
+ this.startShapeStream(`-1`)
345
+ await this.waitUntilReady()
346
+ await this.loadPersistedBridges()
347
+
348
+ this.gcTimer = setInterval(() => {
349
+ void this.sweepIdleBridges().catch((error) => {
350
+ serverLog.warn(`[entity-projector] idle sweep failed:`, error)
351
+ })
352
+ }, 5 * 60_000)
353
+ }
354
+
355
+ async stop(): Promise<void> {
356
+ this.started = false
357
+ this.upToDate = false
358
+ this.unsubscribe?.()
359
+ this.unsubscribe = null
360
+ this.abortController?.abort()
361
+ this.abortController = null
362
+ if (this.gcTimer) {
363
+ clearInterval(this.gcTimer)
364
+ this.gcTimer = null
365
+ }
366
+
367
+ const projections = [...this.projections.values()]
368
+ this.projections.clear()
369
+ this.startingProjections.clear()
370
+ this.activeReaders.clear()
371
+ await Promise.all(projections.map((projection) => projection.stop()))
372
+ }
373
+
374
+ async register(
375
+ tenantId: string,
376
+ registry: PostgresRegistry,
377
+ tagsInput: unknown
378
+ ): Promise<{ sourceRef: string; streamUrl: string }> {
379
+ if (!this.electricUrl) {
380
+ throw new Error(
381
+ `[entity-projector] Electric URL is required for entities()`
382
+ )
383
+ }
384
+
385
+ await this.start()
386
+ this.registries.set(tenantId, registry)
387
+ const tags = normalizeTags(assertTags(tagsInput))
388
+ const sourceRef = sourceRefForTags(tags)
389
+ const streamUrl = getEntitiesStreamPath(sourceRef)
390
+ const row = await registry.upsertEntityBridge({
391
+ sourceRef,
392
+ tags,
393
+ streamUrl,
394
+ })
395
+ await registry.touchEntityBridge(sourceRef)
396
+ await this.ensureProjection(row)
397
+
398
+ return { sourceRef, streamUrl }
399
+ }
400
+
401
+ async touchByStreamPath(
402
+ tenantId: string,
403
+ registry: PostgresRegistry,
404
+ streamPath: string
405
+ ): Promise<void> {
406
+ const sourceRef = sourceRefFromStreamPath(streamPath)
407
+ if (!sourceRef) return
408
+ await this.touchSourceRef(tenantId, registry, sourceRef, `head`)
409
+ await this.ensureProjectionForSourceRef(tenantId, registry, sourceRef)
410
+ }
411
+
412
+ async beginClientRead(
413
+ tenantId: string,
414
+ registry: PostgresRegistry,
415
+ streamPath: string
416
+ ): Promise<(() => Promise<void>) | null> {
417
+ const sourceRef = sourceRefFromStreamPath(streamPath)
418
+ if (!sourceRef) return null
419
+
420
+ const key = projectionKey(tenantId, sourceRef)
421
+ this.activeReaders.set(key, (this.activeReaders.get(key) ?? 0) + 1)
422
+ await this.touchSourceRef(tenantId, registry, sourceRef, `read-open`)
423
+ await this.ensureProjectionForSourceRef(tenantId, registry, sourceRef)
424
+
425
+ return async () => {
426
+ const remaining = (this.activeReaders.get(key) ?? 1) - 1
427
+ if (remaining <= 0) {
428
+ this.activeReaders.delete(key)
429
+ } else {
430
+ this.activeReaders.set(key, remaining)
431
+ }
432
+ await this.touchSourceRef(tenantId, registry, sourceRef, `read-close`)
433
+ }
434
+ }
435
+
436
+ async onEntityChanged(_tenantId: string, _entityUrl: string): Promise<void> {
437
+ // Membership updates come from the shared Electric entities shape.
438
+ }
439
+
440
+ async loadTenantBridges(
441
+ tenantId: string,
442
+ registry = this.registryForTenant(tenantId)
443
+ ): Promise<void> {
444
+ if (!this.started || !this.electricUrl) return
445
+ await this.loadPersistedBridgesForTenant(tenantId, registry)
446
+ }
447
+
448
+ private resetReady(): void {
449
+ this.upToDate = false
450
+ this.readyPromise = new Promise<void>((resolve, reject) => {
451
+ this.readyResolve = resolve
452
+ this.readyReject = reject
453
+ })
454
+ }
455
+
456
+ private async waitUntilReady(): Promise<void> {
457
+ await this.readyPromise
458
+ }
459
+
460
+ private createShapeStream(
461
+ offset: Offset,
462
+ signal: AbortSignal
463
+ ): ShapeStreamInterface<EntityShapeRow> {
464
+ return new ShapeStream<EntityShapeRow>({
465
+ url: electricUrlWithPath(this.electricUrl!, `/v1/shape`).toString(),
466
+ params: {
467
+ table: `entities`,
468
+ ...(this.electricSecret ? { secret: this.electricSecret } : {}),
469
+ columns: [...ENTITY_SHAPE_COLUMNS],
470
+ replica: `full`,
471
+ },
472
+ parser: {
473
+ int8: (value: string) => Number.parseInt(value, 10),
474
+ },
475
+ offset,
476
+ signal,
477
+ onError: (error) => {
478
+ if (signal.aborted) {
479
+ return {}
480
+ }
481
+ serverLog.warn(`[entity-projector] shared shape error:`, error)
482
+ return {}
483
+ },
484
+ })
485
+ }
486
+
487
+ private startShapeStream(offset: Offset): void {
488
+ if (!this.electricUrl) return
489
+
490
+ this.unsubscribe?.()
491
+ this.abortController?.abort()
492
+ const abortController = new AbortController()
493
+ const stream = this.createShapeStream(offset, abortController.signal)
494
+ this.abortController = abortController
495
+ this.unsubscribe = stream.subscribe(
496
+ async (messages) => {
497
+ await this.applyShapeMessages(messages)
498
+ },
499
+ (error) => {
500
+ if (abortController.signal.aborted) {
501
+ return
502
+ }
503
+ const err = error instanceof Error ? error : new Error(String(error))
504
+ this.readyReject?.(err)
505
+ serverLog.warn(`[entity-projector] shared subscription failed:`, error)
506
+ }
507
+ )
508
+ }
509
+
510
+ private async applyShapeMessages(
511
+ messages: Array<Message<EntityShapeRow>>
512
+ ): Promise<void> {
513
+ for (const message of messages) {
514
+ if (isControlMessage(message)) {
515
+ if (message.headers.control === `must-refetch`) {
516
+ this.entities.clear()
517
+ this.resetReady()
518
+ this.startShapeStream(`-1`)
519
+ return
520
+ }
521
+ if (message.headers.control === `up-to-date`) {
522
+ this.upToDate = true
523
+ this.reconcileAll()
524
+ this.readyResolve?.()
525
+ }
526
+ continue
527
+ }
528
+
529
+ if (!isChangeMessage(message)) continue
530
+ this.applyChangeMessage(message)
531
+ }
532
+ }
533
+
534
+ private applyChangeMessage(message: ChangeMessage<EntityShapeRow>): void {
535
+ const entity = message.value
536
+ const key = entityKey(entity.tenant_id, entity.url)
537
+ if (message.headers.operation === `delete`) {
538
+ this.entities.delete(key)
539
+ if (this.upToDate) {
540
+ for (const projection of this.projectionsForTenant(entity.tenant_id)) {
541
+ projection.deleteEntity(entity)
542
+ }
543
+ }
544
+ return
545
+ }
546
+
547
+ this.entities.set(key, entity)
548
+ if (this.upToDate) {
549
+ for (const projection of this.projectionsForTenant(entity.tenant_id)) {
550
+ projection.applyEntity(entity)
551
+ }
552
+ }
553
+ }
554
+
555
+ private async loadPersistedBridges(): Promise<void> {
556
+ const registry = new PostgresRegistry(this.db)
557
+ const rows = await registry.listEntityBridges(null)
558
+ const tenantIds = this.sharedTenantIds()
559
+ const filteredRows = tenantIds
560
+ ? rows.filter((row) => tenantIds.has(row.tenantId))
561
+ : rows
562
+ await Promise.all(
563
+ filteredRows.map(async (row) => {
564
+ try {
565
+ this.registryForTenant(row.tenantId)
566
+ await this.ensureProjection(row)
567
+ } catch (error) {
568
+ serverLog.warn(
569
+ `[entity-projector] failed to start ${row.tenantId}/${row.sourceRef}:`,
570
+ error
571
+ )
572
+ }
573
+ })
574
+ )
575
+ }
576
+
577
+ private async loadPersistedBridgesForTenant(
578
+ tenantId: string,
579
+ registry: PostgresRegistry
580
+ ): Promise<void> {
581
+ await this.waitUntilReady()
582
+ this.registries.set(tenantId, registry)
583
+ const rows = await registry.listEntityBridges(tenantId)
584
+ await Promise.all(
585
+ rows.map(async (row) => {
586
+ try {
587
+ await this.ensureProjection(row)
588
+ } catch (error) {
589
+ serverLog.warn(
590
+ `[entity-projector] failed to start ${row.tenantId}/${row.sourceRef}:`,
591
+ error
592
+ )
593
+ }
594
+ })
595
+ )
596
+ }
597
+
598
+ private registryForTenant(tenantId: string): PostgresRegistry {
599
+ const existing = this.registries.get(tenantId)
600
+ if (existing) return existing
601
+ const registry = new PostgresRegistry(this.db, tenantId)
602
+ this.registries.set(tenantId, registry)
603
+ return registry
604
+ }
605
+
606
+ private async ensureProjectionForSourceRef(
607
+ tenantId: string,
608
+ registry: PostgresRegistry,
609
+ sourceRef: string
610
+ ): Promise<void> {
611
+ await this.start()
612
+ const row = await registry.getEntityBridge(sourceRef)
613
+ if (!row) return
614
+ if (row.tenantId !== tenantId) return
615
+ await this.ensureProjection(row)
616
+ }
617
+
618
+ private async ensureProjection(row: EntityBridgeRow): Promise<void> {
619
+ await this.waitUntilReady()
620
+ const key = projectionKey(row.tenantId, row.sourceRef)
621
+ if (this.projections.has(key)) return
622
+ const starting = this.startingProjections.get(key)
623
+ if (starting) {
624
+ await starting
625
+ return
626
+ }
627
+
628
+ const startPromise = (async () => {
629
+ let streamClient: StreamClient
630
+ try {
631
+ streamClient = await this.streamClientForTenant(row.tenantId)
632
+ } catch (error) {
633
+ if (isUnregisteredTenantError(error)) {
634
+ const message = error instanceof Error ? error.message : String(error)
635
+ serverLog.warn(
636
+ `[entity-projector] skipped ${row.tenantId}/${row.sourceRef} for unregistered tenant: ${message}`
637
+ )
638
+ return
639
+ }
640
+ throw error
641
+ }
642
+ const projection = new ProjectedEntityBridge(row, streamClient)
643
+ await projection.start(this.entitiesForTenant(row.tenantId))
644
+ this.projections.set(key, projection)
645
+ })().finally(() => {
646
+ this.startingProjections.delete(key)
647
+ })
648
+
649
+ this.startingProjections.set(key, startPromise)
650
+ await startPromise
651
+ }
652
+
653
+ private entitiesForTenant(tenantId: string): Iterable<EntityShapeRow> {
654
+ return [...this.entities.values()].filter(
655
+ (entity) => entity.tenant_id === tenantId
656
+ )
657
+ }
658
+
659
+ private projectionsForTenant(tenantId: string): Array<ProjectedEntityBridge> {
660
+ return [...this.projections.values()].filter(
661
+ (projection) => projection.tenantId === tenantId
662
+ )
663
+ }
664
+
665
+ private reconcileAll(): void {
666
+ for (const projection of this.projections.values()) {
667
+ projection.reconcile(this.entitiesForTenant(projection.tenantId))
668
+ }
669
+ }
670
+
671
+ private async touchSourceRef(
672
+ tenantId: string,
673
+ registry: PostgresRegistry,
674
+ sourceRef: string,
675
+ reason: string
676
+ ): Promise<void> {
677
+ try {
678
+ await registry.touchEntityBridge(sourceRef)
679
+ } catch (error) {
680
+ serverLog.warn(
681
+ `[entity-projector] failed to touch ${tenantId}/${sourceRef} during ${reason}:`,
682
+ error
683
+ )
684
+ }
685
+ }
686
+
687
+ private async sweepIdleBridges(): Promise<void> {
688
+ const tenantIds = this.sharedTenantIds()
689
+ for (const [tenantId, registry] of this.registries.entries()) {
690
+ if (tenantIds && !tenantIds.has(tenantId)) continue
691
+ const activeSourceRefs = new Set(
692
+ await registry.listReferencedEntitySourceRefs()
693
+ )
694
+ for (const sourceRef of activeSourceRefs) {
695
+ await registry.touchEntityBridge(sourceRef)
696
+ }
697
+
698
+ const stale = await registry.listStaleEntityBridges(
699
+ new Date(Date.now() - 15 * 60_000)
700
+ )
701
+
702
+ for (const row of stale) {
703
+ const key = projectionKey(tenantId, row.sourceRef)
704
+ if (activeSourceRefs.has(row.sourceRef)) continue
705
+ if ((this.activeReaders.get(key) ?? 0) > 0) continue
706
+ const projection = this.projections.get(key)
707
+ this.projections.delete(key)
708
+ await projection?.stop()
709
+ await registry.deleteEntityBridge(row.sourceRef)
710
+ }
711
+ }
712
+ }
713
+
714
+ private sharedTenantIds(): Set<string> | null {
715
+ if (!this.tenantIds) return null
716
+ return new Set(this.tenantIds())
717
+ }
718
+ }
719
+
720
+ export class EntityProjectorTenantFacade implements EntityBridgeCoordinator {
721
+ constructor(
722
+ private readonly projector: EntityProjector,
723
+ private readonly tenantId: string,
724
+ private readonly registry: PostgresRegistry
725
+ ) {}
726
+
727
+ async start(): Promise<void> {
728
+ await this.projector.start()
729
+ }
730
+
731
+ async stop(): Promise<void> {}
732
+
733
+ async register(tagsInput: unknown): Promise<{
734
+ sourceRef: string
735
+ streamUrl: string
736
+ }> {
737
+ return await this.projector.register(
738
+ this.tenantId,
739
+ this.registry,
740
+ tagsInput
741
+ )
742
+ }
743
+
744
+ async onEntityChanged(entityUrl: string): Promise<void> {
745
+ await this.projector.onEntityChanged(this.tenantId, entityUrl)
746
+ }
747
+
748
+ async touchByStreamPath(streamPath: string): Promise<void> {
749
+ await this.projector.touchByStreamPath(
750
+ this.tenantId,
751
+ this.registry,
752
+ streamPath
753
+ )
754
+ }
755
+
756
+ async beginClientRead(
757
+ streamPath: string
758
+ ): Promise<(() => Promise<void>) | null> {
759
+ return await this.projector.beginClientRead(
760
+ this.tenantId,
761
+ this.registry,
762
+ streamPath
763
+ )
764
+ }
765
+ }