@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,694 @@
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 { serverLog } from './utils/log.js'
15
+ import { electricUrlWithPath } from './utils/electric-url.js'
16
+ import { DEFAULT_TENANT_ID } from './tenant.js'
17
+ import type { EntityBridgeRow, PostgresRegistry } from './entity-registry.js'
18
+ import type { StreamClient } from './stream-client.js'
19
+ import type {
20
+ ChangeMessage,
21
+ Offset,
22
+ Row,
23
+ ShapeStreamInterface,
24
+ } from '@electric-sql/client'
25
+ import type {
26
+ EntityMembershipRow,
27
+ EntityTags,
28
+ } from '@electric-ax/agents-runtime'
29
+
30
+ export interface EntityBridgeCoordinator {
31
+ start(): Promise<void>
32
+ stop(): Promise<void>
33
+ register(tagsInput: unknown): Promise<{
34
+ sourceRef: string
35
+ streamUrl: string
36
+ }>
37
+ onEntityChanged(entityUrl: string): Promise<void>
38
+ touchByStreamPath(streamPath: string): Promise<void>
39
+ beginClientRead(streamPath: string): Promise<(() => Promise<void>) | null>
40
+ }
41
+
42
+ interface EntityShapeRow extends Row<unknown> {
43
+ tenant_id: string
44
+ url: string
45
+ type: string
46
+ status: `spawning` | `running` | `idle` | `stopped`
47
+ tags: EntityTags
48
+ spawn_args?: Record<string, unknown> | null
49
+ parent?: string | null
50
+ type_revision?: number | null
51
+ inbox_schemas?: Record<string, Record<string, unknown>> | null
52
+ state_schemas?: Record<string, Record<string, unknown>> | null
53
+ created_at: number
54
+ updated_at: number
55
+ }
56
+
57
+ const ENTITY_SHAPE_COLUMNS = [
58
+ `tenant_id`,
59
+ `url`,
60
+ `type`,
61
+ `status`,
62
+ `tags`,
63
+ `spawn_args`,
64
+ `parent`,
65
+ `type_revision`,
66
+ `inbox_schemas`,
67
+ `state_schemas`,
68
+ `created_at`,
69
+ `updated_at`,
70
+ ] as const
71
+
72
+ function parseElectricOffset(offset: string): Offset | null {
73
+ if (offset === `-1` || offset === `now`) {
74
+ return offset
75
+ }
76
+ return /^\d+_\d+$/.test(offset) ? (offset as Offset) : null
77
+ }
78
+
79
+ function sameMember(
80
+ left: EntityMembershipRow | undefined,
81
+ right: EntityMembershipRow
82
+ ): boolean {
83
+ return JSON.stringify(left) === JSON.stringify(right)
84
+ }
85
+
86
+ function toMemberRow(entity: EntityShapeRow): EntityMembershipRow {
87
+ return {
88
+ url: entity.url,
89
+ type: entity.type,
90
+ status: entity.status,
91
+ tags: entity.tags,
92
+ spawn_args: entity.spawn_args ?? {},
93
+ parent: entity.parent ?? null,
94
+ type_revision: entity.type_revision ?? null,
95
+ inbox_schemas: entity.inbox_schemas ?? null,
96
+ state_schemas: entity.state_schemas ?? null,
97
+ created_at: entity.created_at,
98
+ updated_at: entity.updated_at,
99
+ }
100
+ }
101
+
102
+ function buildTagsWhereClause(tags: EntityTags): string {
103
+ const encoded = buildTagsIndex(tags).map(
104
+ (entry) => `'${entry.replace(/'/g, `''`)}'`
105
+ )
106
+ if (encoded.length === 0) {
107
+ return `TRUE`
108
+ }
109
+ return `tags_index @> ARRAY[${encoded.join(`, `)}]::text[]`
110
+ }
111
+
112
+ function sqlStringLiteral(value: string): string {
113
+ return `'${value.replace(/'/g, `''`)}'`
114
+ }
115
+
116
+ function buildTenantTagsWhereClause(
117
+ tenantId: string,
118
+ tags: EntityTags
119
+ ): string {
120
+ return `tenant_id = ${sqlStringLiteral(tenantId)} AND (${buildTagsWhereClause(tags)})`
121
+ }
122
+
123
+ function shapeEntityKey(message: ChangeMessage<EntityShapeRow>): string {
124
+ return message.value.url
125
+ }
126
+
127
+ class EntityBridge {
128
+ readonly sourceRef: string
129
+ readonly tags: EntityTags
130
+ readonly streamUrl: string
131
+
132
+ private currentMembers = new Map<string, EntityMembershipRow>()
133
+ private producer: IdempotentProducer | null = null
134
+ private liveAbortController: AbortController | null = null
135
+ private liveUnsubscribe: (() => void) | null = null
136
+ private stopped = false
137
+ private resyncPromise: Promise<void> | null = null
138
+ private bootstrapState: {
139
+ staleMembers: Map<string, EntityMembershipRow>
140
+ resolve: (result: `up-to-date` | `must-refetch`) => void
141
+ reject: (error: Error) => void
142
+ } | null = null
143
+
144
+ constructor(
145
+ row: EntityBridgeRow,
146
+ private registry: PostgresRegistry,
147
+ private streamClient: StreamClient,
148
+ private electricUrl: string,
149
+ private tenantId: string,
150
+ private electricSecret?: string
151
+ ) {
152
+ this.sourceRef = row.sourceRef
153
+ this.tags = normalizeTags(row.tags)
154
+ this.streamUrl = row.streamUrl
155
+ this.initialShapeHandle = row.shapeHandle
156
+ this.initialShapeOffset = row.shapeOffset
157
+ }
158
+
159
+ private initialShapeHandle?: string
160
+ private initialShapeOffset?: string
161
+
162
+ async start(): Promise<void> {
163
+ await this.ensureStream()
164
+ this.producer = new IdempotentProducer(
165
+ new DurableStream({
166
+ url: `${this.streamClient.baseUrl}${this.streamUrl}`,
167
+ contentType: `application/json`,
168
+ }),
169
+ `entity-bridge-${this.sourceRef}`,
170
+ {
171
+ autoClaim: true,
172
+ onError: (error) => {
173
+ serverLog.warn(
174
+ `[entity-bridge] producer write failed for ${this.sourceRef}:`,
175
+ error
176
+ )
177
+ },
178
+ }
179
+ )
180
+ await this.loadCurrentMembers()
181
+ if (this.initialShapeHandle && this.initialShapeOffset) {
182
+ const initialOffset = parseElectricOffset(this.initialShapeOffset)
183
+ if (initialOffset) {
184
+ this.startLiveStream(initialOffset, this.initialShapeHandle)
185
+ return
186
+ }
187
+ }
188
+ await this.resync(`startup`)
189
+ }
190
+
191
+ async stop(): Promise<void> {
192
+ this.stopped = true
193
+ this.stopLiveStream()
194
+ this.clearBootstrapState()?.resolve(`up-to-date`)
195
+ if (this.producer) {
196
+ try {
197
+ await this.producer.flush()
198
+ } catch {
199
+ // Reconcile repairs missed writes on next startup.
200
+ }
201
+ await this.producer.detach()
202
+ this.producer = null
203
+ }
204
+ }
205
+
206
+ async requestResync(reason: string): Promise<void> {
207
+ if (this.stopped) return
208
+ if (this.resyncPromise) {
209
+ await this.resyncPromise
210
+ return
211
+ }
212
+
213
+ this.resyncPromise = this.resync(reason).finally(() => {
214
+ this.resyncPromise = null
215
+ })
216
+ await this.resyncPromise
217
+ }
218
+
219
+ private async resync(reason: string): Promise<void> {
220
+ if (this.stopped) return
221
+
222
+ serverLog.info(
223
+ `[entity-bridge] resyncing ${this.sourceRef} from shape log bootstrap (${reason})`
224
+ )
225
+
226
+ this.stopLiveStream()
227
+
228
+ if (this.producer) {
229
+ try {
230
+ await this.producer.flush()
231
+ } catch {
232
+ // A later reconcile will repair any dropped writes.
233
+ }
234
+ }
235
+
236
+ for (;;) {
237
+ await this.loadCurrentMembers()
238
+ const result = await this.startBootstrapStream()
239
+ if (result === `up-to-date`) {
240
+ return
241
+ }
242
+ }
243
+ }
244
+
245
+ private async ensureStream(): Promise<void> {
246
+ if (!(await this.streamClient.exists(this.streamUrl))) {
247
+ await this.streamClient.create(this.streamUrl, {
248
+ contentType: `application/json`,
249
+ })
250
+ }
251
+ }
252
+
253
+ private startBootstrapStream(): Promise<`up-to-date` | `must-refetch`> {
254
+ return new Promise((resolve, reject) => {
255
+ this.bootstrapState = {
256
+ staleMembers: new Map(this.currentMembers),
257
+ resolve,
258
+ reject,
259
+ }
260
+ this.startLiveStream(`-1`)
261
+ })
262
+ }
263
+
264
+ private finalizeBootstrap(): void {
265
+ if (!this.bootstrapState) {
266
+ return
267
+ }
268
+
269
+ for (const [url, existing] of this.bootstrapState.staleMembers) {
270
+ this.append(`delete`, existing)
271
+ this.currentMembers.delete(url)
272
+ }
273
+ }
274
+
275
+ private clearBootstrapState(): {
276
+ staleMembers: Map<string, EntityMembershipRow>
277
+ resolve: (result: `up-to-date` | `must-refetch`) => void
278
+ reject: (error: Error) => void
279
+ } | null {
280
+ const state = this.bootstrapState
281
+ this.bootstrapState = null
282
+ return state
283
+ }
284
+
285
+ private async loadCurrentMembers(): Promise<void> {
286
+ this.currentMembers.clear()
287
+ const events = await this.streamClient.readJson<Record<string, unknown>>(
288
+ this.streamUrl
289
+ )
290
+ for (const event of events) {
291
+ if (event.type !== `members` || typeof event.key !== `string`) {
292
+ continue
293
+ }
294
+ const headers =
295
+ typeof event.headers === `object` && event.headers !== null
296
+ ? (event.headers as Record<string, unknown>)
297
+ : undefined
298
+ const operation = headers?.operation
299
+ if (operation === `delete`) {
300
+ this.currentMembers.delete(event.key)
301
+ continue
302
+ }
303
+ const value = event.value as EntityMembershipRow | undefined
304
+ if (value) {
305
+ this.currentMembers.set(event.key, value)
306
+ }
307
+ }
308
+ }
309
+
310
+ private createShapeStream(opts?: {
311
+ offset?: Offset
312
+ handle?: string
313
+ signal?: AbortSignal
314
+ }): ShapeStreamInterface<EntityShapeRow> {
315
+ return new ShapeStream<EntityShapeRow>({
316
+ url: electricUrlWithPath(this.electricUrl, `/v1/shape`).toString(),
317
+ params: {
318
+ table: `entities`,
319
+ where: buildTenantTagsWhereClause(this.tenantId, this.tags),
320
+ ...(this.electricSecret ? { secret: this.electricSecret } : {}),
321
+ columns: [...ENTITY_SHAPE_COLUMNS],
322
+ replica: `full`,
323
+ },
324
+ parser: {
325
+ int8: (value: string) => Number.parseInt(value, 10),
326
+ },
327
+ ...(opts?.offset ? { offset: opts.offset } : {}),
328
+ ...(opts?.handle ? { handle: opts.handle } : {}),
329
+ ...(opts?.signal ? { signal: opts.signal } : {}),
330
+ onError: (error) => {
331
+ if (opts?.signal?.aborted) {
332
+ return {}
333
+ }
334
+ serverLog.warn(
335
+ `[entity-bridge] live shape error for ${this.sourceRef}:`,
336
+ error
337
+ )
338
+ return {}
339
+ },
340
+ })
341
+ }
342
+
343
+ private startLiveStream(offset: Offset, handle?: string): void {
344
+ if (this.stopped) return
345
+
346
+ const abortController = new AbortController()
347
+ const stream = this.createShapeStream({
348
+ offset,
349
+ handle,
350
+ signal: abortController.signal,
351
+ })
352
+
353
+ this.liveAbortController = abortController
354
+ this.liveUnsubscribe = stream.subscribe(
355
+ async (messages) => {
356
+ let shouldPersistCursor = false
357
+ let bootstrapResult: `up-to-date` | `must-refetch` | null = null
358
+ for (const message of messages) {
359
+ if (isControlMessage(message)) {
360
+ if (message.headers.control === `must-refetch`) {
361
+ await this.registry.clearEntityBridgeCursor(this.sourceRef)
362
+ const bootstrapState = this.clearBootstrapState()
363
+ if (bootstrapState) {
364
+ this.stopLiveStream()
365
+ bootstrapResult = `must-refetch`
366
+ bootstrapState.resolve(`must-refetch`)
367
+ return
368
+ }
369
+ await this.requestResync(`shape-reset`)
370
+ return
371
+ }
372
+ if (
373
+ message.headers.control === `up-to-date` &&
374
+ this.bootstrapState
375
+ ) {
376
+ this.finalizeBootstrap()
377
+ bootstrapResult = `up-to-date`
378
+ }
379
+ shouldPersistCursor = true
380
+ continue
381
+ }
382
+
383
+ if (!isChangeMessage(message)) {
384
+ continue
385
+ }
386
+
387
+ this.bootstrapState?.staleMembers.delete(shapeEntityKey(message))
388
+ this.applyChange(message)
389
+ shouldPersistCursor = true
390
+ }
391
+
392
+ if (shouldPersistCursor) {
393
+ await this.persistCursor(stream)
394
+ }
395
+
396
+ if (bootstrapResult === `up-to-date`) {
397
+ const bootstrapState = this.clearBootstrapState()
398
+ bootstrapState?.resolve(`up-to-date`)
399
+ }
400
+ },
401
+ (error) => {
402
+ const bootstrapState = this.clearBootstrapState()
403
+ if (bootstrapState) {
404
+ bootstrapState.reject(
405
+ error instanceof Error ? error : new Error(String(error))
406
+ )
407
+ }
408
+ if (abortController.signal.aborted) {
409
+ return
410
+ }
411
+ serverLog.warn(
412
+ `[entity-bridge] live subscription failed for ${this.sourceRef}:`,
413
+ error
414
+ )
415
+ void this.requestResync(`subscription-error`)
416
+ }
417
+ )
418
+ }
419
+
420
+ private async persistCursor(
421
+ stream: ShapeStreamInterface<EntityShapeRow>
422
+ ): Promise<void> {
423
+ const shapeHandle = stream.shapeHandle
424
+ const shapeOffset = stream.lastOffset
425
+ if (!shapeHandle || shapeOffset === `-1`) {
426
+ return
427
+ }
428
+ await this.registry.updateEntityBridgeCursor(
429
+ this.sourceRef,
430
+ shapeHandle,
431
+ shapeOffset
432
+ )
433
+ }
434
+
435
+ private stopLiveStream(): void {
436
+ this.liveUnsubscribe?.()
437
+ this.liveUnsubscribe = null
438
+ this.liveAbortController?.abort()
439
+ this.liveAbortController = null
440
+ }
441
+
442
+ private applyChange(message: ChangeMessage<EntityShapeRow>): void {
443
+ const next = toMemberRow(message.value)
444
+ const key = shapeEntityKey(message)
445
+ const existing = this.currentMembers.get(key)
446
+ const operation = message.headers.operation
447
+
448
+ if (operation === `delete`) {
449
+ if (!existing) return
450
+ this.append(`delete`, existing)
451
+ this.currentMembers.delete(key)
452
+ return
453
+ }
454
+
455
+ if (!existing) {
456
+ this.append(`insert`, next)
457
+ this.currentMembers.set(key, next)
458
+ return
459
+ }
460
+
461
+ if (!sameMember(existing, next)) {
462
+ this.append(`update`, next)
463
+ this.currentMembers.set(key, next)
464
+ }
465
+ }
466
+
467
+ private append(
468
+ operation: `insert` | `update` | `delete`,
469
+ row: EntityMembershipRow
470
+ ): void {
471
+ if (!this.producer) {
472
+ throw new Error(
473
+ `[entity-bridge] producer is not initialized for ${this.sourceRef}`
474
+ )
475
+ }
476
+
477
+ const event =
478
+ operation === `delete`
479
+ ? {
480
+ type: `members`,
481
+ key: row.url,
482
+ old_value: row,
483
+ headers: {
484
+ operation,
485
+ timestamp: new Date().toISOString(),
486
+ },
487
+ }
488
+ : {
489
+ type: `members`,
490
+ key: row.url,
491
+ value: row,
492
+ headers: {
493
+ operation,
494
+ timestamp: new Date().toISOString(),
495
+ },
496
+ }
497
+
498
+ this.producer.append(JSON.stringify(event))
499
+ }
500
+ }
501
+
502
+ export class EntityBridgeManager implements EntityBridgeCoordinator {
503
+ private bridges = new Map<string, EntityBridge>()
504
+ private startingBridges = new Map<string, Promise<void>>()
505
+ private activeReaders = new Map<string, number>()
506
+ private gcTimer: NodeJS.Timeout | null = null
507
+
508
+ constructor(
509
+ private registry: PostgresRegistry,
510
+ private streamClient: StreamClient,
511
+ private electricUrl?: string,
512
+ private electricSecret?: string,
513
+ private tenantId: string = DEFAULT_TENANT_ID
514
+ ) {}
515
+
516
+ async start(): Promise<void> {
517
+ if (
518
+ !this.electricUrl ||
519
+ typeof this.registry.listEntityBridges !== `function`
520
+ ) {
521
+ return
522
+ }
523
+
524
+ const rows = await this.registry.listEntityBridges()
525
+ await Promise.all(
526
+ rows.map(async (row) => {
527
+ try {
528
+ await this.ensureBridge(row)
529
+ } catch (error) {
530
+ serverLog.warn(
531
+ `[entity-bridge] failed to start ${row.sourceRef}:`,
532
+ error
533
+ )
534
+ }
535
+ })
536
+ )
537
+
538
+ // 5-minute sweep / 15-minute idle TTL (see sweepIdleBridges). The idle
539
+ // grace absorbs client flapping (reloads, brief disconnects) without
540
+ // triggering a full reconcile on each reconnect; the sweep cadence is
541
+ // fast enough to release bridges soon after observers go away.
542
+ this.gcTimer = setInterval(() => {
543
+ void this.sweepIdleBridges().catch((error) => {
544
+ serverLog.warn(`[entity-bridge] idle sweep failed:`, error)
545
+ })
546
+ }, 5 * 60_000)
547
+ }
548
+
549
+ async stop(): Promise<void> {
550
+ if (this.gcTimer) {
551
+ clearInterval(this.gcTimer)
552
+ this.gcTimer = null
553
+ }
554
+
555
+ const bridges = [...this.bridges.values()]
556
+ this.bridges.clear()
557
+ this.startingBridges.clear()
558
+ this.activeReaders.clear()
559
+
560
+ await Promise.all(
561
+ bridges.map(async (bridge) => {
562
+ await bridge.stop()
563
+ })
564
+ )
565
+ }
566
+
567
+ async register(tagsInput: unknown): Promise<{
568
+ sourceRef: string
569
+ streamUrl: string
570
+ }> {
571
+ if (!this.electricUrl) {
572
+ throw new Error(`[entity-bridge] Electric URL is required for entities()`)
573
+ }
574
+
575
+ const tags = normalizeTags(assertTags(tagsInput))
576
+ const sourceRef = sourceRefForTags(tags)
577
+ const streamUrl = getEntitiesStreamPath(sourceRef)
578
+
579
+ const row = await this.registry.upsertEntityBridge({
580
+ sourceRef,
581
+ tags,
582
+ streamUrl,
583
+ })
584
+ await this.registry.touchEntityBridge(sourceRef)
585
+ await this.ensureBridge(row)
586
+
587
+ return { sourceRef, streamUrl }
588
+ }
589
+
590
+ async onEntityChanged(_entityUrl: string): Promise<void> {
591
+ // Membership updates come from the Electric shape. This hook remains only
592
+ // to preserve existing call sites until they are cleaned up.
593
+ }
594
+
595
+ async touchByStreamPath(streamPath: string): Promise<void> {
596
+ const sourceRef = this.sourceRefFromStreamPath(streamPath)
597
+ if (!sourceRef) return
598
+ await this.touchSourceRef(sourceRef, `head`)
599
+ }
600
+
601
+ async beginClientRead(
602
+ streamPath: string
603
+ ): Promise<(() => Promise<void>) | null> {
604
+ const sourceRef = this.sourceRefFromStreamPath(streamPath)
605
+ if (!sourceRef) return null
606
+
607
+ const current = this.activeReaders.get(sourceRef) ?? 0
608
+ this.activeReaders.set(sourceRef, current + 1)
609
+ await this.touchSourceRef(sourceRef, `read-open`)
610
+
611
+ return async () => {
612
+ const remaining = (this.activeReaders.get(sourceRef) ?? 1) - 1
613
+ if (remaining <= 0) {
614
+ this.activeReaders.delete(sourceRef)
615
+ } else {
616
+ this.activeReaders.set(sourceRef, remaining)
617
+ }
618
+ await this.touchSourceRef(sourceRef, `read-close`)
619
+ }
620
+ }
621
+
622
+ private async ensureBridge(row: EntityBridgeRow): Promise<void> {
623
+ if (this.bridges.has(row.sourceRef)) return
624
+ const starting = this.startingBridges.get(row.sourceRef)
625
+ if (starting) {
626
+ await starting
627
+ return
628
+ }
629
+ if (!this.electricUrl) {
630
+ throw new Error(`[entity-bridge] Electric URL is required for entities()`)
631
+ }
632
+
633
+ const startPromise = (async () => {
634
+ const bridge = new EntityBridge(
635
+ row,
636
+ this.registry,
637
+ this.streamClient,
638
+ this.electricUrl!,
639
+ this.tenantId,
640
+ this.electricSecret
641
+ )
642
+ await bridge.start()
643
+ this.bridges.set(row.sourceRef, bridge)
644
+ })().finally(() => {
645
+ this.startingBridges.delete(row.sourceRef)
646
+ })
647
+
648
+ this.startingBridges.set(row.sourceRef, startPromise)
649
+ await startPromise
650
+ }
651
+
652
+ private async sweepIdleBridges(): Promise<void> {
653
+ const activeSourceRefs = await this.collectReferencedSourceRefs()
654
+ for (const sourceRef of activeSourceRefs) {
655
+ await this.registry.touchEntityBridge(sourceRef)
656
+ }
657
+
658
+ const stale = await this.registry.listStaleEntityBridges(
659
+ new Date(Date.now() - 15 * 60_000)
660
+ )
661
+
662
+ for (const row of stale) {
663
+ if (activeSourceRefs.has(row.sourceRef)) continue
664
+ if ((this.activeReaders.get(row.sourceRef) ?? 0) > 0) continue
665
+ const bridge = this.bridges.get(row.sourceRef)
666
+ this.bridges.delete(row.sourceRef)
667
+ await bridge?.stop()
668
+ await this.registry.deleteEntityBridge(row.sourceRef)
669
+ }
670
+ }
671
+
672
+ private async collectReferencedSourceRefs(): Promise<Set<string>> {
673
+ return new Set(await this.registry.listReferencedEntitySourceRefs())
674
+ }
675
+
676
+ private sourceRefFromStreamPath(streamPath: string): string | null {
677
+ const match = streamPath.match(/^\/_entities\/([^/]+)$/)
678
+ return match?.[1] ?? null
679
+ }
680
+
681
+ private async touchSourceRef(
682
+ sourceRef: string,
683
+ reason: string
684
+ ): Promise<void> {
685
+ try {
686
+ await this.registry.touchEntityBridge(sourceRef)
687
+ } catch (error) {
688
+ serverLog.warn(
689
+ `[entity-bridge] failed to touch ${sourceRef} during ${reason}:`,
690
+ error
691
+ )
692
+ }
693
+ }
694
+ }