@electric-ax/agents-server 0.4.19 → 0.4.20

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.
@@ -0,0 +1,14 @@
1
+ CREATE TABLE "pg_sync_bridges" (
2
+ "tenant_id" text DEFAULT 'default' NOT NULL,
3
+ "source_ref" text NOT NULL,
4
+ "options" jsonb NOT NULL,
5
+ "stream_url" text NOT NULL,
6
+ "shape_handle" text,
7
+ "shape_offset" text,
8
+ "initial_snapshot_complete" boolean DEFAULT false NOT NULL,
9
+ "last_touched_at" timestamp with time zone DEFAULT now() NOT NULL,
10
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
11
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
12
+ CONSTRAINT "pg_sync_bridges_tenant_id_source_ref_pk" PRIMARY KEY("tenant_id","source_ref"),
13
+ CONSTRAINT "uq_pg_sync_bridges_stream_url" UNIQUE("tenant_id","stream_url")
14
+ );
@@ -106,6 +106,13 @@
106
106
  "when": 1780600000000,
107
107
  "tag": "0014_entity_type_slash_commands",
108
108
  "breakpoints": true
109
+ },
110
+ {
111
+ "idx": 15,
112
+ "version": "7",
113
+ "when": 1779728400000,
114
+ "tag": "0015_pg_sync_bridges",
115
+ "breakpoints": true
109
116
  }
110
117
  ]
111
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents-server",
3
- "version": "0.4.19",
3
+ "version": "0.4.20",
4
4
  "description": "Electric Agents entity runtime server",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "@durable-streams/client": "^0.2.6",
40
40
  "@durable-streams/server": "^0.3.7",
41
41
  "@durable-streams/state": "^0.3.1",
42
- "@electric-sql/client": "^1.5.20",
42
+ "@electric-sql/client": "^1.5.21",
43
43
  "@mariozechner/pi-agent-core": "^0.70.2",
44
44
  "@opentelemetry/api": "^1.9.1",
45
45
  "@sinclair/typebox": "^0.34.48",
@@ -54,7 +54,7 @@
54
54
  "pino-pretty": "^13.0.0",
55
55
  "postgres": "^3.4.0",
56
56
  "undici": "^7.24.7",
57
- "@electric-ax/agents-runtime": "0.3.12"
57
+ "@electric-ax/agents-runtime": "0.3.13"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.19.15",
@@ -65,9 +65,9 @@
65
65
  "tsx": "^4.19.0",
66
66
  "typescript": "^5.0.0",
67
67
  "vitest": "^4.1.0",
68
- "@electric-ax/agents": "0.4.16",
69
- "@electric-ax/agents-server-conformance-tests": "0.1.11",
70
- "@electric-ax/agents-server-ui": "0.4.19"
68
+ "@electric-ax/agents": "0.4.17",
69
+ "@electric-ax/agents-server-conformance-tests": "0.1.12",
70
+ "@electric-ax/agents-server-ui": "0.4.20"
71
71
  },
72
72
  "files": [
73
73
  "dist",
package/src/db/schema.ts CHANGED
@@ -621,6 +621,34 @@ export const scheduledTasks = pgTable(
621
621
  ]
622
622
  )
623
623
 
624
+ export const pgSyncBridges = pgTable(
625
+ `pg_sync_bridges`,
626
+ {
627
+ tenantId: text(`tenant_id`).notNull().default(`default`),
628
+ sourceRef: text(`source_ref`).notNull(),
629
+ options: jsonb(`options`).notNull(),
630
+ streamUrl: text(`stream_url`).notNull(),
631
+ shapeHandle: text(`shape_handle`),
632
+ shapeOffset: text(`shape_offset`),
633
+ initialSnapshotComplete: boolean(`initial_snapshot_complete`)
634
+ .notNull()
635
+ .default(false),
636
+ lastTouchedAt: timestamp(`last_touched_at`, { withTimezone: true })
637
+ .notNull()
638
+ .defaultNow(),
639
+ createdAt: timestamp(`created_at`, { withTimezone: true })
640
+ .notNull()
641
+ .defaultNow(),
642
+ updatedAt: timestamp(`updated_at`, { withTimezone: true })
643
+ .notNull()
644
+ .defaultNow(),
645
+ },
646
+ (table) => [
647
+ primaryKey({ columns: [table.tenantId, table.sourceRef] }),
648
+ unique(`uq_pg_sync_bridges_stream_url`).on(table.tenantId, table.streamUrl),
649
+ ]
650
+ )
651
+
624
652
  export const entityBridges = pgTable(
625
653
  `entity_bridges`,
626
654
  {
@@ -336,6 +336,14 @@ function cloneRecord<T extends Record<string, unknown>>(value: T): T {
336
336
  return JSON.parse(JSON.stringify(value)) as T
337
337
  }
338
338
 
339
+ function withOptionalTxid<T>(
340
+ entity: T,
341
+ txid: number | undefined
342
+ ): T & { txid?: number } {
343
+ if (txid === undefined) return entity as T & { txid?: number }
344
+ return { ...entity, txid }
345
+ }
346
+
339
347
  /**
340
348
  * Orchestrates the Electric Agents entity lifecycle: register types, spawn, send, kill.
341
349
  *
@@ -2336,7 +2344,7 @@ export class EntityManager {
2336
2344
  entityUrl: string,
2337
2345
  req: SendRequest,
2338
2346
  opts?: { producerId?: string }
2339
- ): Promise<void> {
2347
+ ): Promise<{ txid: string }> {
2340
2348
  const entity = await this.validateSendRequest(entityUrl, req)
2341
2349
  if (
2342
2350
  this.isForkWorkLockedEntity(entityUrl) &&
@@ -2384,9 +2392,11 @@ export class EntityManager {
2384
2392
  await this.entityBridgeManager?.onEntityChanged(entityUrl)
2385
2393
  }
2386
2394
 
2395
+ const txid = crypto.randomUUID()
2387
2396
  const envelope = entityStateSchema.inbox.insert({
2388
2397
  key,
2389
2398
  value,
2399
+ headers: { txid },
2390
2400
  } as any)
2391
2401
 
2392
2402
  const encoded = this.encodeChangeEvent(envelope as Record<string, unknown>)
@@ -2395,7 +2405,7 @@ export class EntityManager {
2395
2405
  await this.streamClient.appendIdempotent(entity.streams.main, encoded, {
2396
2406
  producerId: opts.producerId,
2397
2407
  })
2398
- return
2408
+ return { txid }
2399
2409
  }
2400
2410
 
2401
2411
  await this.streamClient.append(entity.streams.main, encoded)
@@ -2407,9 +2417,11 @@ export class EntityManager {
2407
2417
  type: `identity`,
2408
2418
  key: `self`,
2409
2419
  value: identity,
2420
+ headers: { txid },
2410
2421
  })
2411
2422
  )
2412
2423
  }
2424
+ return { txid }
2413
2425
  } catch (err) {
2414
2426
  if (this.isClosedStreamError(err)) {
2415
2427
  throw new ElectricAgentsError(
@@ -2431,7 +2443,7 @@ export class EntityManager {
2431
2443
  mode?: `immediate` | `queued` | `paused` | `steer`
2432
2444
  status?: `pending` | `processed` | `cancelled`
2433
2445
  }
2434
- ): Promise<void> {
2446
+ ): Promise<{ txid: string }> {
2435
2447
  const entity = await this.registry.getEntity(entityUrl)
2436
2448
  if (!entity) {
2437
2449
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
@@ -2463,17 +2475,23 @@ export class EntityManager {
2463
2475
  )
2464
2476
  }
2465
2477
 
2478
+ const txid = crypto.randomUUID()
2466
2479
  const envelope = entityStateSchema.inbox.update({
2467
2480
  key,
2468
2481
  value,
2482
+ headers: { txid },
2469
2483
  } as any)
2470
2484
  await this.streamClient.append(
2471
2485
  entity.streams.main,
2472
2486
  this.encodeChangeEvent(envelope as Record<string, unknown>)
2473
2487
  )
2488
+ return { txid }
2474
2489
  }
2475
2490
 
2476
- async deleteInboxMessage(entityUrl: string, key: string): Promise<void> {
2491
+ async deleteInboxMessage(
2492
+ entityUrl: string,
2493
+ key: string
2494
+ ): Promise<{ txid: string }> {
2477
2495
  const entity = await this.registry.getEntity(entityUrl)
2478
2496
  if (!entity) {
2479
2497
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
@@ -2486,11 +2504,16 @@ export class EntityManager {
2486
2504
  )
2487
2505
  }
2488
2506
 
2489
- const envelope = entityStateSchema.inbox.delete({ key } as any)
2507
+ const txid = crypto.randomUUID()
2508
+ const envelope = entityStateSchema.inbox.delete({
2509
+ key,
2510
+ headers: { txid },
2511
+ } as any)
2490
2512
  await this.streamClient.append(
2491
2513
  entity.streams.main,
2492
2514
  this.encodeChangeEvent(envelope as Record<string, unknown>)
2493
2515
  )
2516
+ return { txid }
2494
2517
  }
2495
2518
 
2496
2519
  // ==========================================================================
@@ -2686,21 +2709,12 @@ export class EntityManager {
2686
2709
  async setTag(
2687
2710
  entityUrl: string,
2688
2711
  key: string,
2689
- req: SetTagRequest,
2690
- token: string
2691
- ): Promise<ElectricAgentsEntity> {
2712
+ req: SetTagRequest
2713
+ ): Promise<ElectricAgentsEntity & { txid?: number }> {
2692
2714
  const entity = await this.registry.getEntity(entityUrl)
2693
2715
  if (!entity) {
2694
2716
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
2695
2717
  }
2696
-
2697
- if (!this.isValidWriteToken(entity, token)) {
2698
- throw new ElectricAgentsError(
2699
- ErrCodeUnauthorized,
2700
- `Invalid write token`,
2701
- 401
2702
- )
2703
- }
2704
2718
  if (rejectsNormalWrites(entity.status)) {
2705
2719
  throw new ElectricAgentsError(
2706
2720
  ErrCodeNotRunning,
@@ -2731,26 +2745,17 @@ export class EntityManager {
2731
2745
  await this.entityBridgeManager.onEntityChanged(entityUrl)
2732
2746
  }
2733
2747
 
2734
- return updated
2748
+ return withOptionalTxid(updated, result.txid)
2735
2749
  }
2736
2750
 
2737
2751
  async deleteTag(
2738
2752
  entityUrl: string,
2739
- key: string,
2740
- token: string
2741
- ): Promise<ElectricAgentsEntity> {
2753
+ key: string
2754
+ ): Promise<ElectricAgentsEntity & { txid?: number }> {
2742
2755
  const entity = await this.registry.getEntity(entityUrl)
2743
2756
  if (!entity) {
2744
2757
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
2745
2758
  }
2746
-
2747
- if (!this.isValidWriteToken(entity, token)) {
2748
- throw new ElectricAgentsError(
2749
- ErrCodeUnauthorized,
2750
- `Invalid write token`,
2751
- 401
2752
- )
2753
- }
2754
2759
  if (rejectsNormalWrites(entity.status)) {
2755
2760
  throw new ElectricAgentsError(
2756
2761
  ErrCodeNotRunning,
@@ -2773,7 +2778,7 @@ export class EntityManager {
2773
2778
  await this.entityBridgeManager.onEntityChanged(entityUrl)
2774
2779
  }
2775
2780
 
2776
- return updated
2781
+ return withOptionalTxid(updated, result.txid)
2777
2782
  }
2778
2783
 
2779
2784
  async ensureEntitiesMembershipStream(
@@ -10,6 +10,7 @@ import {
10
10
  entityLineage,
11
11
  entityPermissionGrants,
12
12
  entityTypes,
13
+ pgSyncBridges,
13
14
  entityTypePermissionGrants,
14
15
  runnerRuntimeDiagnostics,
15
16
  runners,
@@ -43,7 +44,7 @@ import type {
43
44
  EntityTypePermissionGrant,
44
45
  PermissionSubjectKind,
45
46
  } from './electric-agents-types.js'
46
- import type { EntityTags } from '@electric-ax/agents-runtime'
47
+ import type { EntityTags, PgSyncOptions } from '@electric-ax/agents-runtime'
47
48
  import type { Principal } from './principal.js'
48
49
 
49
50
  export class EntityAlreadyExistsError extends Error {
@@ -73,6 +74,19 @@ export interface EntityBridgeRow {
73
74
  updatedAt: Date
74
75
  }
75
76
 
77
+ export interface PgSyncBridgeRow {
78
+ tenantId: string
79
+ sourceRef: string
80
+ options: PgSyncOptions
81
+ streamUrl: string
82
+ shapeHandle?: string
83
+ shapeOffset?: string
84
+ initialSnapshotComplete: boolean
85
+ lastTouchedAt: Date
86
+ createdAt: Date
87
+ updatedAt: Date
88
+ }
89
+
76
90
  export interface TagStreamOutboxRow {
77
91
  id: number
78
92
  tenantId: string
@@ -623,6 +637,13 @@ export class PostgresRegistry {
623
637
  )
624
638
  }
625
639
 
640
+ private pgSyncBridgeWhere(sourceRef: string) {
641
+ return and(
642
+ eq(pgSyncBridges.tenantId, this.tenantId),
643
+ eq(pgSyncBridges.sourceRef, sourceRef)
644
+ )
645
+ }
646
+
626
647
  async createEntityType(et: ElectricAgentsEntityType): Promise<void> {
627
648
  await this.db
628
649
  .insert(entityTypes)
@@ -1422,6 +1443,7 @@ export class PostgresRegistry {
1422
1443
  entity: ElectricAgentsEntity | null
1423
1444
  changed: boolean
1424
1445
  op?: `insert` | `update` | `delete`
1446
+ txid?: number
1425
1447
  }> {
1426
1448
  return this.mutateEntityTags(url, (oldTags) => {
1427
1449
  const previous = oldTags[key]
@@ -1440,7 +1462,11 @@ export class PostgresRegistry {
1440
1462
  async removeEntityTag(
1441
1463
  url: string,
1442
1464
  key: string
1443
- ): Promise<{ entity: ElectricAgentsEntity | null; changed: boolean }> {
1465
+ ): Promise<{
1466
+ entity: ElectricAgentsEntity | null
1467
+ changed: boolean
1468
+ txid?: number
1469
+ }> {
1444
1470
  return this.mutateEntityTags(url, (oldTags) => {
1445
1471
  if (!(key in oldTags)) return null
1446
1472
  const { [key]: _removed, ...remaining } = oldTags
@@ -1465,6 +1491,7 @@ export class PostgresRegistry {
1465
1491
  entity: ElectricAgentsEntity | null
1466
1492
  changed: boolean
1467
1493
  op?: `insert` | `update`
1494
+ txid?: number
1468
1495
  }> {
1469
1496
  return await this.db.transaction(async (tx) => {
1470
1497
  const [row] = await tx
@@ -1485,7 +1512,7 @@ export class PostgresRegistry {
1485
1512
 
1486
1513
  const nextTags = normalizeTags(mutation.nextTags)
1487
1514
  const updatedAt = Date.now()
1488
- await tx
1515
+ const [updateResult] = await tx
1489
1516
  .update(entities)
1490
1517
  .set({
1491
1518
  tags: nextTags,
@@ -1493,6 +1520,10 @@ export class PostgresRegistry {
1493
1520
  updatedAt,
1494
1521
  })
1495
1522
  .where(this.entityWhere(url))
1523
+ .returning({
1524
+ txid: sql<string>`pg_current_xact_id()::xid::text`,
1525
+ })
1526
+ const txid = updateResult ? parseInt(updateResult.txid) : undefined
1496
1527
 
1497
1528
  await tx.insert(tagStreamOutbox).values({
1498
1529
  tenantId: this.tenantId,
@@ -1513,10 +1544,103 @@ export class PostgresRegistry {
1513
1544
  entity,
1514
1545
  changed: true,
1515
1546
  ...(op === `insert` || op === `update` ? { op } : {}),
1547
+ ...(txid !== undefined ? { txid } : {}),
1516
1548
  }
1517
1549
  })
1518
1550
  }
1519
1551
 
1552
+ async upsertPgSyncBridge(row: {
1553
+ sourceRef: string
1554
+ options: PgSyncOptions
1555
+ streamUrl: string
1556
+ }): Promise<PgSyncBridgeRow> {
1557
+ await this.db
1558
+ .insert(pgSyncBridges)
1559
+ .values({
1560
+ tenantId: this.tenantId,
1561
+ sourceRef: row.sourceRef,
1562
+ options: row.options,
1563
+ streamUrl: row.streamUrl,
1564
+ lastTouchedAt: new Date(),
1565
+ updatedAt: new Date(),
1566
+ })
1567
+ .onConflictDoUpdate({
1568
+ target: [pgSyncBridges.tenantId, pgSyncBridges.sourceRef],
1569
+ set: {
1570
+ options: row.options,
1571
+ streamUrl: row.streamUrl,
1572
+ initialSnapshotComplete: false,
1573
+ lastTouchedAt: new Date(),
1574
+ updatedAt: new Date(),
1575
+ },
1576
+ })
1577
+
1578
+ const existing = await this.getPgSyncBridge(row.sourceRef)
1579
+ if (!existing)
1580
+ throw new Error(`Failed to load pgSync bridge ${row.sourceRef}`)
1581
+ return existing
1582
+ }
1583
+
1584
+ async getPgSyncBridge(sourceRef: string): Promise<PgSyncBridgeRow | null> {
1585
+ const rows = await this.db
1586
+ .select()
1587
+ .from(pgSyncBridges)
1588
+ .where(this.pgSyncBridgeWhere(sourceRef))
1589
+ .limit(1)
1590
+ return rows[0] ? this.rowToPgSyncBridge(rows[0]) : null
1591
+ }
1592
+
1593
+ async listPgSyncBridges(
1594
+ tenantId: string | null = this.tenantId
1595
+ ): Promise<Array<PgSyncBridgeRow>> {
1596
+ const rows =
1597
+ tenantId === null
1598
+ ? await this.db.select().from(pgSyncBridges)
1599
+ : await this.db
1600
+ .select()
1601
+ .from(pgSyncBridges)
1602
+ .where(eq(pgSyncBridges.tenantId, tenantId))
1603
+ return rows.map((row) => this.rowToPgSyncBridge(row))
1604
+ }
1605
+
1606
+ async touchPgSyncBridge(sourceRef: string): Promise<void> {
1607
+ await this.db
1608
+ .update(pgSyncBridges)
1609
+ .set({ lastTouchedAt: new Date(), updatedAt: new Date() })
1610
+ .where(this.pgSyncBridgeWhere(sourceRef))
1611
+ }
1612
+
1613
+ async updatePgSyncBridgeCursor(
1614
+ sourceRef: string,
1615
+ shapeHandle: string,
1616
+ shapeOffset: string,
1617
+ initialSnapshotComplete?: boolean
1618
+ ): Promise<void> {
1619
+ await this.db
1620
+ .update(pgSyncBridges)
1621
+ .set({
1622
+ shapeHandle,
1623
+ shapeOffset,
1624
+ ...(initialSnapshotComplete !== undefined
1625
+ ? { initialSnapshotComplete }
1626
+ : {}),
1627
+ updatedAt: new Date(),
1628
+ })
1629
+ .where(this.pgSyncBridgeWhere(sourceRef))
1630
+ }
1631
+
1632
+ async clearPgSyncBridgeCursor(sourceRef: string): Promise<void> {
1633
+ await this.db
1634
+ .update(pgSyncBridges)
1635
+ .set({
1636
+ shapeHandle: null,
1637
+ shapeOffset: null,
1638
+ initialSnapshotComplete: false,
1639
+ updatedAt: new Date(),
1640
+ })
1641
+ .where(this.pgSyncBridgeWhere(sourceRef))
1642
+ }
1643
+
1520
1644
  async upsertEntityBridge(row: {
1521
1645
  sourceRef: string
1522
1646
  tags: EntityTags
@@ -1911,6 +2035,23 @@ export class PostgresRegistry {
1911
2035
  }
1912
2036
  }
1913
2037
 
2038
+ private rowToPgSyncBridge(
2039
+ row: typeof pgSyncBridges.$inferSelect
2040
+ ): PgSyncBridgeRow {
2041
+ return {
2042
+ tenantId: row.tenantId,
2043
+ sourceRef: row.sourceRef,
2044
+ options: row.options as PgSyncOptions,
2045
+ streamUrl: row.streamUrl,
2046
+ shapeHandle: row.shapeHandle ?? undefined,
2047
+ shapeOffset: row.shapeOffset ?? undefined,
2048
+ initialSnapshotComplete: row.initialSnapshotComplete,
2049
+ lastTouchedAt: row.lastTouchedAt,
2050
+ createdAt: row.createdAt,
2051
+ updatedAt: row.updatedAt,
2052
+ }
2053
+ }
2054
+
1914
2055
  private rowToEntityBridge(
1915
2056
  row: typeof entityBridges.$inferSelect
1916
2057
  ): EntityBridgeRow {
@@ -10,6 +10,10 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
10
10
  return typeof value === `object` && value !== null && !Array.isArray(value)
11
11
  }
12
12
 
13
+ function getPgSyncManifestStreamPath(sourceRef: string): string {
14
+ return `/_electric/pg-sync/${sourceRef}`
15
+ }
16
+
13
17
  export function extractManifestSourceUrl(
14
18
  manifest: Record<string, unknown> | undefined
15
19
  ): string | undefined {
@@ -57,6 +61,12 @@ export function extractManifestSourceUrl(
57
61
  : undefined
58
62
  }
59
63
 
64
+ if (manifest.sourceType === `pgSync`) {
65
+ return typeof manifest.sourceRef === `string`
66
+ ? getPgSyncManifestStreamPath(manifest.sourceRef)
67
+ : undefined
68
+ }
69
+
60
70
  if (manifest.sourceType === `webhook`) {
61
71
  if (typeof config?.streamUrl === `string`) return config.streamUrl
62
72
  if (typeof config?.endpointKey === `string`) {