@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,1162 @@
1
+ import { and, desc, eq, lt, ne, sql } from 'drizzle-orm'
2
+ import { buildTagsIndex, normalizeTags } from '@electric-ax/agents-runtime'
3
+ import {
4
+ consumerClaims,
5
+ entities,
6
+ entityBridges,
7
+ entityDispatchState,
8
+ entityManifestSources,
9
+ entityTypes,
10
+ runners,
11
+ tagStreamOutbox,
12
+ } from './db/schema.js'
13
+ import {
14
+ assertEntityStatus,
15
+ assertRunnerAdminStatus,
16
+ assertRunnerKind,
17
+ } from './electric-agents-types.js'
18
+ import { DEFAULT_TENANT_ID } from './tenant.js'
19
+ import type { DrizzleDB } from './db/index.js'
20
+ import type {
21
+ ElectricAgentsEntity,
22
+ ElectricAgentsEntityType,
23
+ ElectricAgentsRunner,
24
+ EntityStatus,
25
+ RunnerAdminStatus,
26
+ RunnerKind,
27
+ SourceStreamOffset,
28
+ ConsumerClaim,
29
+ DispatchPolicy,
30
+ } from './electric-agents-types.js'
31
+ import type { EntityTags } from '@electric-ax/agents-runtime'
32
+
33
+ export class EntityAlreadyExistsError extends Error {
34
+ constructor(public readonly url: string) {
35
+ super(`Entity already exists at URL "${url}"`)
36
+ this.name = `EntityAlreadyExistsError`
37
+ }
38
+ }
39
+
40
+ function isDuplicateUrlError(err: unknown): boolean {
41
+ if (!err || typeof err !== `object`) return false
42
+ const e = err as { code?: string; constraint_name?: string }
43
+ return e.code === `23505`
44
+ }
45
+
46
+ export interface EntityBridgeRow {
47
+ tenantId: string
48
+ sourceRef: string
49
+ tags: EntityTags
50
+ streamUrl: string
51
+ shapeHandle?: string
52
+ shapeOffset?: string
53
+ lastObserverActivityAt: Date
54
+ createdAt: Date
55
+ updatedAt: Date
56
+ }
57
+
58
+ export interface TagStreamOutboxRow {
59
+ id: number
60
+ tenantId: string
61
+ entityUrl: string
62
+ collection: string
63
+ op: `insert` | `update` | `delete`
64
+ key: string
65
+ rowData?: { key: string; value: string }
66
+ attemptCount: number
67
+ lastError?: string
68
+ claimedBy?: string
69
+ claimedAt?: Date
70
+ deadLetteredAt?: Date
71
+ createdAt: Date
72
+ }
73
+
74
+ export interface RegisterRunnerInput {
75
+ id: string
76
+ ownerUserId: string
77
+ label: string
78
+ kind?: RunnerKind
79
+ adminStatus?: RunnerAdminStatus
80
+ wakeStream?: string
81
+ }
82
+
83
+ export interface HeartbeatRunnerInput {
84
+ runnerId: string
85
+ heartbeatAt?: Date
86
+ livenessLeaseExpiresAt?: Date
87
+ leaseMs?: number
88
+ wakeStreamOffset?: string
89
+ }
90
+
91
+ export interface MaterializeActiveClaimInput {
92
+ consumerId: string
93
+ epoch: number
94
+ entityUrl: string
95
+ streamPath: string
96
+ wakeId?: string
97
+ runnerId?: string
98
+ claimedAt?: Date
99
+ leaseExpiresAt?: Date
100
+ }
101
+
102
+ export interface MaterializeHeartbeatClaimInput {
103
+ consumerId: string
104
+ epoch: number
105
+ heartbeatAt?: Date
106
+ leaseExpiresAt?: Date
107
+ }
108
+
109
+ export interface MaterializeReleasedClaimInput {
110
+ consumerId: string
111
+ epoch: number
112
+ ackedStreams?: Array<SourceStreamOffset>
113
+ releasedAt?: Date
114
+ }
115
+
116
+ const DEFAULT_RUNNER_LEASE_MS = 30_000
117
+
118
+ export function runnerWakeStream(runnerId: string): string {
119
+ return `/runners/${runnerId}/wake`
120
+ }
121
+
122
+ export class PostgresRegistry {
123
+ constructor(
124
+ private db: DrizzleDB,
125
+ readonly tenantId: string = DEFAULT_TENANT_ID
126
+ ) {}
127
+
128
+ async initialize(): Promise<void> {}
129
+
130
+ close(): void {}
131
+
132
+ async createRunner(
133
+ input: RegisterRunnerInput
134
+ ): Promise<ElectricAgentsRunner> {
135
+ const now = new Date()
136
+ const wakeStream = input.wakeStream ?? runnerWakeStream(input.id)
137
+
138
+ await this.db
139
+ .insert(runners)
140
+ .values({
141
+ tenantId: this.tenantId,
142
+ id: input.id,
143
+ ownerUserId: input.ownerUserId,
144
+ label: input.label,
145
+ kind: input.kind ?? `local`,
146
+ adminStatus: input.adminStatus ?? `enabled`,
147
+ wakeStream,
148
+ updatedAt: now,
149
+ })
150
+ .onConflictDoUpdate({
151
+ target: [runners.tenantId, runners.id],
152
+ set: {
153
+ ownerUserId: input.ownerUserId,
154
+ label: input.label,
155
+ kind: input.kind ?? `local`,
156
+ adminStatus: input.adminStatus ?? `enabled`,
157
+ wakeStream,
158
+ updatedAt: now,
159
+ },
160
+ })
161
+
162
+ const runner = await this.getRunner(input.id)
163
+ if (!runner) {
164
+ throw new Error(`Failed to read back runner "${input.id}"`)
165
+ }
166
+ return runner
167
+ }
168
+
169
+ async getRunner(id: string): Promise<ElectricAgentsRunner | null> {
170
+ const rows = await this.db
171
+ .select()
172
+ .from(runners)
173
+ .where(and(eq(runners.tenantId, this.tenantId), eq(runners.id, id)))
174
+ .limit(1)
175
+ return rows[0] ? this.rowToRunner(rows[0]) : null
176
+ }
177
+
178
+ async listRunners(filter?: {
179
+ ownerUserId?: string
180
+ }): Promise<Array<ElectricAgentsRunner>> {
181
+ const conditions = [eq(runners.tenantId, this.tenantId)]
182
+ if (filter?.ownerUserId) {
183
+ conditions.push(eq(runners.ownerUserId, filter.ownerUserId))
184
+ }
185
+ const rows = await this.db
186
+ .select()
187
+ .from(runners)
188
+ .where(and(...conditions))
189
+ .orderBy(desc(runners.createdAt))
190
+ return rows.map((row) => this.rowToRunner(row))
191
+ }
192
+
193
+ async heartbeatRunner(
194
+ input: HeartbeatRunnerInput
195
+ ): Promise<ElectricAgentsRunner | null> {
196
+ const now = input.heartbeatAt ?? new Date()
197
+ const leaseExpiresAt =
198
+ input.livenessLeaseExpiresAt ??
199
+ new Date(now.getTime() + (input.leaseMs ?? DEFAULT_RUNNER_LEASE_MS))
200
+
201
+ const rows = await this.db
202
+ .update(runners)
203
+ .set({
204
+ lastSeenAt: now,
205
+ livenessLeaseExpiresAt: leaseExpiresAt,
206
+ ...(input.wakeStreamOffset !== undefined
207
+ ? { wakeStreamOffset: input.wakeStreamOffset }
208
+ : {}),
209
+ updatedAt: now,
210
+ })
211
+ .where(
212
+ and(eq(runners.tenantId, this.tenantId), eq(runners.id, input.runnerId))
213
+ )
214
+ .returning()
215
+
216
+ return rows[0] ? this.rowToRunner(rows[0]) : null
217
+ }
218
+
219
+ async setRunnerAdminStatus(
220
+ runnerId: string,
221
+ adminStatus: RunnerAdminStatus
222
+ ): Promise<ElectricAgentsRunner | null> {
223
+ const rows = await this.db
224
+ .update(runners)
225
+ .set({
226
+ adminStatus,
227
+ updatedAt: new Date(),
228
+ })
229
+ .where(and(eq(runners.tenantId, this.tenantId), eq(runners.id, runnerId)))
230
+ .returning()
231
+
232
+ return rows[0] ? this.rowToRunner(rows[0]) : null
233
+ }
234
+
235
+ async materializeActiveClaim(
236
+ input: MaterializeActiveClaimInput
237
+ ): Promise<void> {
238
+ const claimedAt = input.claimedAt ?? new Date()
239
+ await this.db.transaction(async (tx) => {
240
+ await tx
241
+ .insert(consumerClaims)
242
+ .values({
243
+ tenantId: this.tenantId,
244
+ consumerId: input.consumerId,
245
+ epoch: input.epoch,
246
+ wakeId: input.wakeId ?? null,
247
+ entityUrl: input.entityUrl,
248
+ streamPath: input.streamPath,
249
+ runnerId: input.runnerId ?? null,
250
+ status: `active`,
251
+ claimedAt,
252
+ leaseExpiresAt: input.leaseExpiresAt ?? null,
253
+ updatedAt: claimedAt,
254
+ })
255
+ .onConflictDoUpdate({
256
+ target: [
257
+ consumerClaims.tenantId,
258
+ consumerClaims.consumerId,
259
+ consumerClaims.epoch,
260
+ ],
261
+ set: {
262
+ wakeId: input.wakeId ?? null,
263
+ entityUrl: input.entityUrl,
264
+ streamPath: input.streamPath,
265
+ runnerId: input.runnerId ?? null,
266
+ status: `active`,
267
+ claimedAt,
268
+ leaseExpiresAt: input.leaseExpiresAt ?? null,
269
+ releasedAt: null,
270
+ updatedAt: claimedAt,
271
+ },
272
+ })
273
+
274
+ await tx
275
+ .insert(entityDispatchState)
276
+ .values({
277
+ tenantId: this.tenantId,
278
+ entityUrl: input.entityUrl,
279
+ activeConsumerId: input.consumerId,
280
+ activeRunnerId: input.runnerId ?? null,
281
+ activeEpoch: input.epoch,
282
+ activeClaimedAt: claimedAt,
283
+ activeLeaseExpiresAt: input.leaseExpiresAt ?? null,
284
+ lastClaimedAt: claimedAt,
285
+ updatedAt: claimedAt,
286
+ })
287
+ .onConflictDoUpdate({
288
+ target: [entityDispatchState.tenantId, entityDispatchState.entityUrl],
289
+ set: {
290
+ activeConsumerId: input.consumerId,
291
+ activeRunnerId: input.runnerId ?? null,
292
+ activeEpoch: input.epoch,
293
+ activeClaimedAt: claimedAt,
294
+ activeLeaseExpiresAt: input.leaseExpiresAt ?? null,
295
+ lastClaimedAt: claimedAt,
296
+ updatedAt: claimedAt,
297
+ },
298
+ })
299
+ })
300
+ }
301
+
302
+ async materializeHeartbeatClaim(
303
+ input: MaterializeHeartbeatClaimInput
304
+ ): Promise<void> {
305
+ const heartbeatAt = input.heartbeatAt ?? new Date()
306
+ await this.db
307
+ .update(consumerClaims)
308
+ .set({
309
+ lastHeartbeatAt: heartbeatAt,
310
+ leaseExpiresAt: input.leaseExpiresAt ?? null,
311
+ updatedAt: heartbeatAt,
312
+ })
313
+ .where(
314
+ and(
315
+ eq(consumerClaims.tenantId, this.tenantId),
316
+ eq(consumerClaims.consumerId, input.consumerId),
317
+ eq(consumerClaims.epoch, input.epoch)
318
+ )
319
+ )
320
+ }
321
+
322
+ async materializeReleasedClaim(
323
+ input: MaterializeReleasedClaimInput
324
+ ): Promise<ConsumerClaim | null> {
325
+ const releasedAt = input.releasedAt ?? new Date()
326
+ const rows = await this.db
327
+ .update(consumerClaims)
328
+ .set({
329
+ status: `released`,
330
+ releasedAt,
331
+ ackedStreams: input.ackedStreams ?? null,
332
+ updatedAt: releasedAt,
333
+ })
334
+ .where(
335
+ and(
336
+ eq(consumerClaims.tenantId, this.tenantId),
337
+ eq(consumerClaims.consumerId, input.consumerId),
338
+ eq(consumerClaims.epoch, input.epoch)
339
+ )
340
+ )
341
+ .returning()
342
+
343
+ const claim = rows[0] ? this.rowToConsumerClaim(rows[0]) : null
344
+ if (claim) {
345
+ await this.db
346
+ .update(entityDispatchState)
347
+ .set({
348
+ activeConsumerId: null,
349
+ activeRunnerId: null,
350
+ activeEpoch: null,
351
+ activeClaimedAt: null,
352
+ activeLeaseExpiresAt: null,
353
+ lastReleasedAt: releasedAt,
354
+ lastCompletedAt: releasedAt,
355
+ updatedAt: releasedAt,
356
+ })
357
+ .where(
358
+ and(
359
+ eq(entityDispatchState.tenantId, this.tenantId),
360
+ eq(entityDispatchState.entityUrl, claim.entity_url),
361
+ eq(entityDispatchState.activeConsumerId, input.consumerId),
362
+ eq(entityDispatchState.activeEpoch, input.epoch)
363
+ )
364
+ )
365
+ }
366
+ return claim
367
+ }
368
+
369
+ private entityTypeWhere(name: string) {
370
+ return and(
371
+ eq(entityTypes.tenantId, this.tenantId),
372
+ eq(entityTypes.name, name)
373
+ )
374
+ }
375
+
376
+ private entityWhere(url: string) {
377
+ return and(eq(entities.tenantId, this.tenantId), eq(entities.url, url))
378
+ }
379
+
380
+ private entityBridgeWhere(sourceRef: string) {
381
+ return and(
382
+ eq(entityBridges.tenantId, this.tenantId),
383
+ eq(entityBridges.sourceRef, sourceRef)
384
+ )
385
+ }
386
+
387
+ async createEntityType(et: ElectricAgentsEntityType): Promise<void> {
388
+ await this.db
389
+ .insert(entityTypes)
390
+ .values({
391
+ tenantId: this.tenantId,
392
+ name: et.name,
393
+ description: et.description,
394
+ creationSchema: et.creation_schema ?? null,
395
+ inboxSchemas: et.inbox_schemas ?? null,
396
+ stateSchemas: et.state_schemas ?? null,
397
+ serveEndpoint: et.serve_endpoint ?? null,
398
+ defaultDispatchPolicy: et.default_dispatch_policy ?? null,
399
+ revision: et.revision,
400
+ createdAt: et.created_at,
401
+ updatedAt: et.updated_at,
402
+ })
403
+ .onConflictDoUpdate({
404
+ target: [entityTypes.tenantId, entityTypes.name],
405
+ set: {
406
+ description: et.description,
407
+ creationSchema: et.creation_schema ?? null,
408
+ inboxSchemas: et.inbox_schemas ?? null,
409
+ stateSchemas: et.state_schemas ?? null,
410
+ serveEndpoint: et.serve_endpoint ?? null,
411
+ defaultDispatchPolicy: et.default_dispatch_policy ?? null,
412
+ revision: et.revision,
413
+ updatedAt: et.updated_at,
414
+ },
415
+ })
416
+ }
417
+
418
+ async getEntityType(name: string): Promise<ElectricAgentsEntityType | null> {
419
+ const rows = await this.db
420
+ .select()
421
+ .from(entityTypes)
422
+ .where(this.entityTypeWhere(name))
423
+ .limit(1)
424
+ if (rows.length === 0) return null
425
+ return this.rowToEntityType(rows[0]!)
426
+ }
427
+
428
+ async listEntityTypes(): Promise<Array<ElectricAgentsEntityType>> {
429
+ const rows = await this.db
430
+ .select()
431
+ .from(entityTypes)
432
+ .where(eq(entityTypes.tenantId, this.tenantId))
433
+ .orderBy(entityTypes.name)
434
+ return rows.map((row) => this.rowToEntityType(row))
435
+ }
436
+
437
+ async deleteEntityType(name: string): Promise<void> {
438
+ await this.db.delete(entityTypes).where(this.entityTypeWhere(name))
439
+ }
440
+
441
+ async updateEntityTypeInPlace(et: ElectricAgentsEntityType): Promise<void> {
442
+ await this.db
443
+ .update(entityTypes)
444
+ .set({
445
+ description: et.description,
446
+ creationSchema: et.creation_schema ?? null,
447
+ inboxSchemas: et.inbox_schemas ?? null,
448
+ stateSchemas: et.state_schemas ?? null,
449
+ serveEndpoint: et.serve_endpoint ?? null,
450
+ defaultDispatchPolicy: et.default_dispatch_policy ?? null,
451
+ revision: et.revision,
452
+ updatedAt: et.updated_at,
453
+ })
454
+ .where(this.entityTypeWhere(et.name))
455
+ }
456
+
457
+ async createEntity(entity: ElectricAgentsEntity): Promise<number> {
458
+ try {
459
+ return await this.db.transaction(async (tx) => {
460
+ const result = await tx
461
+ .insert(entities)
462
+ .values({
463
+ tenantId: this.tenantId,
464
+ url: entity.url,
465
+ type: entity.type,
466
+ status: entity.status,
467
+ subscriptionId: entity.subscription_id,
468
+ dispatchPolicy: entity.dispatch_policy ?? null,
469
+ writeToken: entity.write_token,
470
+ tags: normalizeTags(entity.tags),
471
+ tagsIndex: buildTagsIndex(entity.tags),
472
+ spawnArgs: entity.spawn_args ?? {},
473
+ parent: entity.parent ?? null,
474
+ typeRevision: entity.type_revision ?? null,
475
+ inboxSchemas: entity.inbox_schemas ?? null,
476
+ stateSchemas: entity.state_schemas ?? null,
477
+ createdAt: entity.created_at,
478
+ updatedAt: entity.updated_at,
479
+ })
480
+ .returning({
481
+ txid: sql<string>`pg_current_xact_id()::xid::text`,
482
+ })
483
+
484
+ await tx
485
+ .insert(entityDispatchState)
486
+ .values({
487
+ tenantId: this.tenantId,
488
+ entityUrl: entity.url,
489
+ pendingSourceStreams: [],
490
+ updatedAt: new Date(),
491
+ })
492
+ .onConflictDoNothing()
493
+
494
+ return parseInt(result[0]!.txid)
495
+ })
496
+ } catch (err) {
497
+ if (isDuplicateUrlError(err)) {
498
+ throw new EntityAlreadyExistsError(entity.url)
499
+ }
500
+ throw err
501
+ }
502
+ }
503
+
504
+ async getEntity(url: string): Promise<ElectricAgentsEntity | null> {
505
+ const rows = await this.db
506
+ .select()
507
+ .from(entities)
508
+ .where(this.entityWhere(url))
509
+ .limit(1)
510
+ if (rows.length === 0) return null
511
+ return this.rowToEntity(rows[0]!)
512
+ }
513
+
514
+ async updateEntityDispatchPolicy(
515
+ url: string,
516
+ dispatchPolicy: DispatchPolicy
517
+ ): Promise<ElectricAgentsEntity | null> {
518
+ const [row] = await this.db
519
+ .update(entities)
520
+ .set({ dispatchPolicy, updatedAt: Date.now() })
521
+ .where(this.entityWhere(url))
522
+ .returning()
523
+ return row ? this.rowToEntity(row) : null
524
+ }
525
+
526
+ async getEntityByStream(
527
+ streamPath: string
528
+ ): Promise<ElectricAgentsEntity | null> {
529
+ const mainSuffix = `/main`
530
+ const errorSuffix = `/error`
531
+ let entityUrl: string | null = null
532
+ if (streamPath.endsWith(mainSuffix)) {
533
+ entityUrl = streamPath.slice(0, -mainSuffix.length)
534
+ } else if (streamPath.endsWith(errorSuffix)) {
535
+ entityUrl = streamPath.slice(0, -errorSuffix.length)
536
+ }
537
+ if (!entityUrl) return null
538
+ return this.getEntity(entityUrl)
539
+ }
540
+
541
+ async listEntities(filter?: {
542
+ type?: string
543
+ status?: string
544
+ parent?: string
545
+ limit?: number
546
+ offset?: number
547
+ }): Promise<{ entities: Array<ElectricAgentsEntity>; total: number }> {
548
+ const conditions = [eq(entities.tenantId, this.tenantId)]
549
+ if (filter?.type) conditions.push(eq(entities.type, filter.type))
550
+ if (filter?.status) conditions.push(eq(entities.status, filter.status))
551
+ if (filter?.parent) conditions.push(eq(entities.parent, filter.parent))
552
+
553
+ const whereClause = and(...conditions)
554
+
555
+ const countResult = await this.db
556
+ .select({ count: sql<number>`count(*)` })
557
+ .from(entities)
558
+ .where(whereClause)
559
+ const total = Number(countResult[0]!.count)
560
+
561
+ let query = this.db
562
+ .select()
563
+ .from(entities)
564
+ .where(whereClause)
565
+ .orderBy(desc(entities.createdAt))
566
+ .$dynamic()
567
+
568
+ if (filter?.limit !== undefined) {
569
+ query = query.limit(filter.limit)
570
+ }
571
+ if (filter?.offset !== undefined) {
572
+ query = query.offset(filter.offset)
573
+ }
574
+
575
+ const rows = await query
576
+ return {
577
+ entities: rows.map((row) => this.rowToEntity(row)),
578
+ total,
579
+ }
580
+ }
581
+
582
+ async updateStatus(entityUrl: string, status: EntityStatus): Promise<void> {
583
+ const whereClause =
584
+ status === `stopped`
585
+ ? this.entityWhere(entityUrl)
586
+ : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`))
587
+
588
+ await this.db
589
+ .update(entities)
590
+ .set({ status, updatedAt: Date.now() })
591
+ .where(whereClause)
592
+ }
593
+
594
+ async updateStatusWithTxid(
595
+ entityUrl: string,
596
+ status: EntityStatus
597
+ ): Promise<number> {
598
+ return await this.db.transaction(async (tx) => {
599
+ const whereClause =
600
+ status === `stopped`
601
+ ? this.entityWhere(entityUrl)
602
+ : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`))
603
+
604
+ await tx
605
+ .update(entities)
606
+ .set({ status, updatedAt: Date.now() })
607
+ .where(whereClause)
608
+ const result = await tx.execute(
609
+ sql`SELECT pg_current_xact_id()::xid::text AS txid`
610
+ )
611
+ return parseInt((result[0] as { txid: string }).txid)
612
+ })
613
+ }
614
+
615
+ async setEntityTag(
616
+ url: string,
617
+ key: string,
618
+ value: string
619
+ ): Promise<{
620
+ entity: ElectricAgentsEntity | null
621
+ changed: boolean
622
+ op?: `insert` | `update` | `delete`
623
+ }> {
624
+ return this.mutateEntityTags(url, (oldTags) => {
625
+ const previous = oldTags[key]
626
+ if (previous === value) return null
627
+ return {
628
+ nextTags: { ...oldTags, [key]: value },
629
+ outbox: {
630
+ op: previous === undefined ? `insert` : `update`,
631
+ key,
632
+ rowData: { key, value },
633
+ },
634
+ }
635
+ })
636
+ }
637
+
638
+ async removeEntityTag(
639
+ url: string,
640
+ key: string
641
+ ): Promise<{ entity: ElectricAgentsEntity | null; changed: boolean }> {
642
+ return this.mutateEntityTags(url, (oldTags) => {
643
+ if (!(key in oldTags)) return null
644
+ const { [key]: _removed, ...remaining } = oldTags
645
+ return {
646
+ nextTags: remaining,
647
+ outbox: { op: `delete`, key },
648
+ }
649
+ })
650
+ }
651
+
652
+ private async mutateEntityTags(
653
+ url: string,
654
+ compute: (oldTags: EntityTags) => {
655
+ nextTags: EntityTags
656
+ outbox: {
657
+ op: `insert` | `update` | `delete`
658
+ key: string
659
+ rowData?: { key: string; value: string }
660
+ }
661
+ } | null
662
+ ): Promise<{
663
+ entity: ElectricAgentsEntity | null
664
+ changed: boolean
665
+ op?: `insert` | `update`
666
+ }> {
667
+ return await this.db.transaction(async (tx) => {
668
+ const [row] = await tx
669
+ .select()
670
+ .from(entities)
671
+ .where(this.entityWhere(url))
672
+ .limit(1)
673
+ .for(`update`)
674
+ if (!row) {
675
+ return { entity: null, changed: false }
676
+ }
677
+
678
+ const oldTags = (row.tags as EntityTags | null | undefined) ?? {}
679
+ const mutation = compute(oldTags)
680
+ if (!mutation) {
681
+ return { entity: this.rowToEntity(row), changed: false }
682
+ }
683
+
684
+ const nextTags = normalizeTags(mutation.nextTags)
685
+ const updatedAt = Date.now()
686
+ await tx
687
+ .update(entities)
688
+ .set({
689
+ tags: nextTags,
690
+ tagsIndex: buildTagsIndex(nextTags),
691
+ updatedAt,
692
+ })
693
+ .where(this.entityWhere(url))
694
+
695
+ await tx.insert(tagStreamOutbox).values({
696
+ tenantId: this.tenantId,
697
+ entityUrl: url,
698
+ collection: `tags`,
699
+ op: mutation.outbox.op,
700
+ key: mutation.outbox.key,
701
+ rowData: mutation.outbox.rowData,
702
+ })
703
+
704
+ const entity = this.rowToEntity({
705
+ ...row,
706
+ tags: nextTags,
707
+ updatedAt,
708
+ })
709
+ const op = mutation.outbox.op
710
+ return {
711
+ entity,
712
+ changed: true,
713
+ ...(op === `insert` || op === `update` ? { op } : {}),
714
+ }
715
+ })
716
+ }
717
+
718
+ async upsertEntityBridge(row: {
719
+ sourceRef: string
720
+ tags: EntityTags
721
+ streamUrl: string
722
+ }): Promise<EntityBridgeRow> {
723
+ await this.db
724
+ .insert(entityBridges)
725
+ .values({
726
+ tenantId: this.tenantId,
727
+ sourceRef: row.sourceRef,
728
+ tags: normalizeTags(row.tags),
729
+ streamUrl: row.streamUrl,
730
+ })
731
+ .onConflictDoNothing()
732
+
733
+ const existing = await this.getEntityBridge(row.sourceRef)
734
+ if (!existing) {
735
+ throw new Error(`Failed to load entity bridge ${row.sourceRef}`)
736
+ }
737
+ return existing
738
+ }
739
+
740
+ async getEntityBridge(sourceRef: string): Promise<EntityBridgeRow | null> {
741
+ const rows = await this.db
742
+ .select()
743
+ .from(entityBridges)
744
+ .where(this.entityBridgeWhere(sourceRef))
745
+ .limit(1)
746
+ return rows[0] ? this.rowToEntityBridge(rows[0]) : null
747
+ }
748
+
749
+ async listEntityBridges(
750
+ tenantId: string | null = this.tenantId
751
+ ): Promise<Array<EntityBridgeRow>> {
752
+ const rows =
753
+ tenantId === null
754
+ ? await this.db.select().from(entityBridges)
755
+ : await this.db
756
+ .select()
757
+ .from(entityBridges)
758
+ .where(eq(entityBridges.tenantId, tenantId))
759
+ return rows.map((row) => this.rowToEntityBridge(row))
760
+ }
761
+
762
+ async listStaleEntityBridges(before: Date): Promise<Array<EntityBridgeRow>> {
763
+ const rows = await this.db
764
+ .select()
765
+ .from(entityBridges)
766
+ .where(
767
+ and(
768
+ eq(entityBridges.tenantId, this.tenantId),
769
+ lt(entityBridges.lastObserverActivityAt, before)
770
+ )
771
+ )
772
+ return rows.map((row) => this.rowToEntityBridge(row))
773
+ }
774
+
775
+ async replaceEntityManifestSource(
776
+ ownerEntityUrl: string,
777
+ manifestKey: string,
778
+ sourceRef?: string
779
+ ): Promise<void> {
780
+ await this.db
781
+ .delete(entityManifestSources)
782
+ .where(
783
+ and(
784
+ eq(entityManifestSources.tenantId, this.tenantId),
785
+ eq(entityManifestSources.ownerEntityUrl, ownerEntityUrl),
786
+ eq(entityManifestSources.manifestKey, manifestKey)
787
+ )
788
+ )
789
+
790
+ if (!sourceRef) {
791
+ return
792
+ }
793
+
794
+ await this.db
795
+ .insert(entityManifestSources)
796
+ .values({
797
+ tenantId: this.tenantId,
798
+ ownerEntityUrl,
799
+ manifestKey,
800
+ sourceRef,
801
+ })
802
+ .onConflictDoUpdate({
803
+ target: [
804
+ entityManifestSources.tenantId,
805
+ entityManifestSources.ownerEntityUrl,
806
+ entityManifestSources.manifestKey,
807
+ ],
808
+ set: {
809
+ sourceRef,
810
+ updatedAt: new Date(),
811
+ },
812
+ })
813
+ }
814
+
815
+ async clearEntityManifestSources(): Promise<void> {
816
+ await this.db
817
+ .delete(entityManifestSources)
818
+ .where(eq(entityManifestSources.tenantId, this.tenantId))
819
+ }
820
+
821
+ async listReferencedEntitySourceRefs(): Promise<Array<string>> {
822
+ const rows = await this.db
823
+ .selectDistinct({ sourceRef: entityManifestSources.sourceRef })
824
+ .from(entityManifestSources)
825
+ .where(eq(entityManifestSources.tenantId, this.tenantId))
826
+ .orderBy(entityManifestSources.sourceRef)
827
+ return rows.map((row) => row.sourceRef)
828
+ }
829
+
830
+ async touchEntityBridge(sourceRef: string): Promise<void> {
831
+ await this.db
832
+ .update(entityBridges)
833
+ .set({
834
+ lastObserverActivityAt: new Date(),
835
+ updatedAt: new Date(),
836
+ })
837
+ .where(this.entityBridgeWhere(sourceRef))
838
+ }
839
+
840
+ async updateEntityBridgeCursor(
841
+ sourceRef: string,
842
+ shapeHandle: string,
843
+ shapeOffset: string
844
+ ): Promise<void> {
845
+ await this.db
846
+ .update(entityBridges)
847
+ .set({
848
+ shapeHandle,
849
+ shapeOffset,
850
+ updatedAt: new Date(),
851
+ })
852
+ .where(this.entityBridgeWhere(sourceRef))
853
+ }
854
+
855
+ async clearEntityBridgeCursor(sourceRef: string): Promise<void> {
856
+ await this.db
857
+ .update(entityBridges)
858
+ .set({
859
+ shapeHandle: null,
860
+ shapeOffset: null,
861
+ updatedAt: new Date(),
862
+ })
863
+ .where(this.entityBridgeWhere(sourceRef))
864
+ }
865
+
866
+ async deleteEntityBridge(sourceRef: string): Promise<void> {
867
+ await this.db.delete(entityBridges).where(this.entityBridgeWhere(sourceRef))
868
+ }
869
+
870
+ // The 30-second window is the claim lease TTL: if a worker crashes mid-
871
+ // publish, its claim is reclaimable by another worker after 30s. Pairs
872
+ // with DRAIN_INTERVAL_MS=500 — short enough that recovery is fast, long
873
+ // enough that a healthy in-flight publish won't be stolen.
874
+ async claimTagOutboxRows(
875
+ workerId: string,
876
+ limit = 25,
877
+ tenantId: string | null = this.tenantId
878
+ ): Promise<Array<TagStreamOutboxRow>> {
879
+ const tenantFilter =
880
+ tenantId === null
881
+ ? sql``
882
+ : sql`AND ${tagStreamOutbox.tenantId} = ${tenantId}`
883
+ const claimed = await this.db.execute(sql`
884
+ WITH candidates AS (
885
+ SELECT id
886
+ FROM ${tagStreamOutbox}
887
+ WHERE ${tagStreamOutbox.deadLetteredAt} IS NULL
888
+ ${tenantFilter}
889
+ AND (
890
+ ${tagStreamOutbox.claimedAt} IS NULL
891
+ OR ${tagStreamOutbox.claimedAt} < now() - interval '30 seconds'
892
+ )
893
+ ORDER BY ${tagStreamOutbox.createdAt}
894
+ LIMIT ${limit}
895
+ FOR UPDATE SKIP LOCKED
896
+ )
897
+ UPDATE ${tagStreamOutbox}
898
+ SET claimed_by = ${workerId},
899
+ claimed_at = now()
900
+ WHERE ${tagStreamOutbox.id} IN (SELECT id FROM candidates)
901
+ RETURNING
902
+ id,
903
+ tenant_id AS "tenantId",
904
+ entity_url AS "entityUrl",
905
+ collection,
906
+ op,
907
+ key,
908
+ row_data AS "rowData",
909
+ attempt_count AS "attemptCount",
910
+ last_error AS "lastError",
911
+ claimed_by AS "claimedBy",
912
+ claimed_at AS "claimedAt",
913
+ dead_lettered_at AS "deadLetteredAt",
914
+ created_at AS "createdAt"
915
+ `)
916
+
917
+ return (
918
+ claimed as unknown as Array<{
919
+ id: number
920
+ tenantId: string
921
+ entityUrl: string
922
+ collection: string
923
+ op: `insert` | `update` | `delete`
924
+ key: string
925
+ rowData?: { key: string; value: string } | null
926
+ attemptCount: number
927
+ lastError?: string | null
928
+ claimedBy?: string | null
929
+ claimedAt?: Date | null
930
+ deadLetteredAt?: Date | null
931
+ createdAt: Date
932
+ }>
933
+ ).map((row) => this.rowToTagStreamOutbox(row))
934
+ }
935
+
936
+ async failTagOutboxRow(
937
+ id: number,
938
+ workerId: string,
939
+ errorMessage: string,
940
+ maxAttempts: number,
941
+ tenantId: string | null = this.tenantId
942
+ ): Promise<{ attemptCount: number; deadLettered: boolean }> {
943
+ const tenantFilter =
944
+ tenantId === null
945
+ ? sql``
946
+ : sql`AND ${tagStreamOutbox.tenantId} = ${tenantId}`
947
+ const [row] = await this.db.execute(sql`
948
+ UPDATE ${tagStreamOutbox}
949
+ SET attempt_count = ${tagStreamOutbox.attemptCount} + 1,
950
+ last_error = ${errorMessage},
951
+ claimed_by = null,
952
+ claimed_at = null,
953
+ dead_lettered_at = CASE
954
+ WHEN ${tagStreamOutbox.attemptCount} + 1 >= ${maxAttempts}
955
+ THEN now()
956
+ ELSE ${tagStreamOutbox.deadLetteredAt}
957
+ END
958
+ WHERE ${tagStreamOutbox.id} = ${id}
959
+ ${tenantFilter}
960
+ AND ${tagStreamOutbox.claimedBy} = ${workerId}
961
+ RETURNING
962
+ attempt_count AS "attemptCount",
963
+ dead_lettered_at AS "deadLetteredAt"
964
+ `)
965
+
966
+ if (!row) {
967
+ throw new Error(`Failed to mark tag outbox row ${id} as failed`)
968
+ }
969
+
970
+ const typedRow = row as {
971
+ attemptCount: number
972
+ deadLetteredAt?: Date | null
973
+ }
974
+ return {
975
+ attemptCount: typedRow.attemptCount,
976
+ deadLettered: typedRow.deadLetteredAt != null,
977
+ }
978
+ }
979
+
980
+ async deleteTagOutboxRow(
981
+ id: number,
982
+ tenantId: string | null = this.tenantId
983
+ ): Promise<void> {
984
+ const conditions = [eq(tagStreamOutbox.id, id)]
985
+ if (tenantId !== null) {
986
+ conditions.unshift(eq(tagStreamOutbox.tenantId, tenantId))
987
+ }
988
+ await this.db.delete(tagStreamOutbox).where(and(...conditions))
989
+ }
990
+
991
+ async releaseTagOutboxClaims(
992
+ workerId: string,
993
+ tenantId: string | null = this.tenantId
994
+ ): Promise<void> {
995
+ const conditions = [
996
+ eq(tagStreamOutbox.claimedBy, workerId),
997
+ sql`${tagStreamOutbox.deadLetteredAt} IS NULL`,
998
+ ]
999
+ if (tenantId !== null) {
1000
+ conditions.unshift(eq(tagStreamOutbox.tenantId, tenantId))
1001
+ }
1002
+ await this.db
1003
+ .update(tagStreamOutbox)
1004
+ .set({
1005
+ claimedBy: null,
1006
+ claimedAt: null,
1007
+ })
1008
+ .where(and(...conditions))
1009
+ }
1010
+
1011
+ async deleteEntity(url: string): Promise<void> {
1012
+ await this.db.delete(entities).where(this.entityWhere(url))
1013
+ }
1014
+
1015
+ private rowToEntityType(
1016
+ row: typeof entityTypes.$inferSelect
1017
+ ): ElectricAgentsEntityType {
1018
+ return {
1019
+ name: row.name,
1020
+ description: row.description,
1021
+ creation_schema: row.creationSchema as
1022
+ | Record<string, unknown>
1023
+ | undefined,
1024
+ inbox_schemas: row.inboxSchemas as
1025
+ | Record<string, Record<string, unknown>>
1026
+ | undefined,
1027
+ state_schemas: row.stateSchemas as
1028
+ | Record<string, Record<string, unknown>>
1029
+ | undefined,
1030
+ serve_endpoint: row.serveEndpoint ?? undefined,
1031
+ default_dispatch_policy:
1032
+ (row.defaultDispatchPolicy as ElectricAgentsEntityType[`default_dispatch_policy`]) ??
1033
+ undefined,
1034
+ revision: row.revision,
1035
+ created_at: row.createdAt,
1036
+ updated_at: row.updatedAt,
1037
+ }
1038
+ }
1039
+
1040
+ private rowToEntity(row: typeof entities.$inferSelect): ElectricAgentsEntity {
1041
+ return {
1042
+ url: row.url,
1043
+ type: row.type,
1044
+ status: assertEntityStatus(row.status),
1045
+ streams: {
1046
+ main: `${row.url}/main`,
1047
+ error: `${row.url}/error`,
1048
+ },
1049
+ subscription_id: row.subscriptionId,
1050
+ dispatch_policy:
1051
+ (row.dispatchPolicy as ElectricAgentsEntity[`dispatch_policy`]) ??
1052
+ undefined,
1053
+ write_token: row.writeToken,
1054
+ tags: (row.tags as EntityTags | null | undefined) ?? {},
1055
+ spawn_args: row.spawnArgs as Record<string, unknown> | undefined,
1056
+ parent: row.parent ?? undefined,
1057
+ type_revision: row.typeRevision ?? undefined,
1058
+ inbox_schemas: row.inboxSchemas as
1059
+ | Record<string, Record<string, unknown>>
1060
+ | undefined,
1061
+ state_schemas: row.stateSchemas as
1062
+ | Record<string, Record<string, unknown>>
1063
+ | undefined,
1064
+ created_at: row.createdAt,
1065
+ updated_at: row.updatedAt,
1066
+ }
1067
+ }
1068
+
1069
+ private rowToEntityBridge(
1070
+ row: typeof entityBridges.$inferSelect
1071
+ ): EntityBridgeRow {
1072
+ return {
1073
+ tenantId: row.tenantId,
1074
+ sourceRef: row.sourceRef,
1075
+ tags: (row.tags as EntityTags | null | undefined) ?? {},
1076
+ streamUrl: row.streamUrl,
1077
+ shapeHandle: row.shapeHandle ?? undefined,
1078
+ shapeOffset: row.shapeOffset ?? undefined,
1079
+ lastObserverActivityAt: row.lastObserverActivityAt,
1080
+ createdAt: row.createdAt,
1081
+ updatedAt: row.updatedAt,
1082
+ }
1083
+ }
1084
+
1085
+ private rowToTagStreamOutbox(row: {
1086
+ id: number
1087
+ tenantId: string
1088
+ entityUrl: string
1089
+ collection: string
1090
+ op: string
1091
+ key: string
1092
+ rowData?: { key: string; value: string } | null
1093
+ attemptCount: number
1094
+ lastError?: string | null
1095
+ claimedBy?: string | null
1096
+ claimedAt?: Date | null
1097
+ deadLetteredAt?: Date | null
1098
+ createdAt: Date
1099
+ }): TagStreamOutboxRow {
1100
+ return {
1101
+ id: row.id,
1102
+ tenantId: row.tenantId,
1103
+ entityUrl: row.entityUrl,
1104
+ collection: row.collection,
1105
+ op: row.op as `insert` | `update` | `delete`,
1106
+ key: row.key,
1107
+ rowData:
1108
+ (row.rowData as { key: string; value: string } | null | undefined) ??
1109
+ undefined,
1110
+ attemptCount: row.attemptCount,
1111
+ lastError: row.lastError ?? undefined,
1112
+ claimedBy: row.claimedBy ?? undefined,
1113
+ claimedAt: row.claimedAt ?? undefined,
1114
+ deadLetteredAt: row.deadLetteredAt ?? undefined,
1115
+ createdAt: row.createdAt,
1116
+ }
1117
+ }
1118
+
1119
+ private rowToRunner(row: typeof runners.$inferSelect): ElectricAgentsRunner {
1120
+ const now = Date.now()
1121
+ const livenessExpiry = row.livenessLeaseExpiresAt?.getTime()
1122
+ return {
1123
+ id: row.id,
1124
+ owner_user_id: row.ownerUserId,
1125
+ label: row.label,
1126
+ kind: assertRunnerKind(row.kind),
1127
+ admin_status: assertRunnerAdminStatus(row.adminStatus),
1128
+ liveness:
1129
+ livenessExpiry !== undefined && livenessExpiry > now
1130
+ ? `online`
1131
+ : `offline`,
1132
+ last_seen_at: row.lastSeenAt?.toISOString(),
1133
+ liveness_lease_expires_at: row.livenessLeaseExpiresAt?.toISOString(),
1134
+ wake_stream: row.wakeStream,
1135
+ wake_stream_offset: row.wakeStreamOffset ?? undefined,
1136
+ created_at: row.createdAt.toISOString(),
1137
+ updated_at: row.updatedAt.toISOString(),
1138
+ }
1139
+ }
1140
+
1141
+ private rowToConsumerClaim(
1142
+ row: typeof consumerClaims.$inferSelect
1143
+ ): ConsumerClaim {
1144
+ return {
1145
+ consumer_id: row.consumerId,
1146
+ epoch: row.epoch,
1147
+ wake_id: row.wakeId ?? undefined,
1148
+ entity_url: row.entityUrl,
1149
+ stream_path: row.streamPath,
1150
+ runner_id: row.runnerId ?? undefined,
1151
+ status: row.status as ConsumerClaim[`status`],
1152
+ claimed_at: row.claimedAt.toISOString(),
1153
+ last_heartbeat_at: row.lastHeartbeatAt?.toISOString(),
1154
+ lease_expires_at: row.leaseExpiresAt?.toISOString(),
1155
+ released_at: row.releasedAt?.toISOString(),
1156
+ acked_streams:
1157
+ (row.ackedStreams as Array<SourceStreamOffset> | null | undefined) ??
1158
+ undefined,
1159
+ updated_at: row.updatedAt.toISOString(),
1160
+ }
1161
+ }
1162
+ }