@electric-ax/agents-server 0.4.19 → 0.5.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.
@@ -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
+ );
@@ -0,0 +1 @@
1
+ ALTER TABLE "entity_types" ADD COLUMN "externally_writable_collections" jsonb;
@@ -106,6 +106,20 @@
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
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "7",
120
+ "when": 1781200000000,
121
+ "tag": "0016_entity_type_externally_writable_collections",
122
+ "breakpoints": true
109
123
  }
110
124
  ]
111
125
  }
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.5.0",
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.4.0"
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.18",
69
+ "@electric-ax/agents-server-conformance-tests": "0.1.12",
70
+ "@electric-ax/agents-server-ui": "0.5.0"
71
71
  },
72
72
  "files": [
73
73
  "dist",
package/src/db/schema.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  timestamp,
15
15
  unique,
16
16
  } from 'drizzle-orm/pg-core'
17
+ import type { ExternallyWritableCollectionConfig } from '../electric-agents-types.js'
17
18
 
18
19
  export const entityTypes = pgTable(
19
20
  `entity_types`,
@@ -24,6 +25,9 @@ export const entityTypes = pgTable(
24
25
  creationSchema: jsonb(`creation_schema`),
25
26
  inboxSchemas: jsonb(`inbox_schemas`),
26
27
  stateSchemas: jsonb(`state_schemas`),
28
+ externallyWritableCollections: jsonb(
29
+ `externally_writable_collections`
30
+ ).$type<Record<string, ExternallyWritableCollectionConfig>>(),
27
31
  slashCommands: jsonb(`slash_commands`),
28
32
  serveEndpoint: text(`serve_endpoint`),
29
33
  defaultDispatchPolicy: jsonb(`default_dispatch_policy`),
@@ -621,6 +625,34 @@ export const scheduledTasks = pgTable(
621
625
  ]
622
626
  )
623
627
 
628
+ export const pgSyncBridges = pgTable(
629
+ `pg_sync_bridges`,
630
+ {
631
+ tenantId: text(`tenant_id`).notNull().default(`default`),
632
+ sourceRef: text(`source_ref`).notNull(),
633
+ options: jsonb(`options`).notNull(),
634
+ streamUrl: text(`stream_url`).notNull(),
635
+ shapeHandle: text(`shape_handle`),
636
+ shapeOffset: text(`shape_offset`),
637
+ initialSnapshotComplete: boolean(`initial_snapshot_complete`)
638
+ .notNull()
639
+ .default(false),
640
+ lastTouchedAt: timestamp(`last_touched_at`, { withTimezone: true })
641
+ .notNull()
642
+ .defaultNow(),
643
+ createdAt: timestamp(`created_at`, { withTimezone: true })
644
+ .notNull()
645
+ .defaultNow(),
646
+ updatedAt: timestamp(`updated_at`, { withTimezone: true })
647
+ .notNull()
648
+ .defaultNow(),
649
+ },
650
+ (table) => [
651
+ primaryKey({ columns: [table.tenantId, table.sourceRef] }),
652
+ unique(`uq_pg_sync_bridges_stream_url`).on(table.tenantId, table.streamUrl),
653
+ ]
654
+ )
655
+
624
656
  export const entityBridges = pgTable(
625
657
  `entity_bridges`,
626
658
  {
@@ -494,12 +494,31 @@ export function toPublicEntity(
494
494
  }
495
495
  }
496
496
 
497
+ /** Per-collection config making an entity-state collection externally writable via the router. */
498
+ export interface ExternallyWritableCollectionConfig {
499
+ /** Durable-stream event type for this collection, e.g. `state:comments`. */
500
+ type: string
501
+ /** Well-known contract this collection implements, e.g. `comments/v1`. */
502
+ contract?: string
503
+ /**
504
+ * Allowlist of external write operations. When set, the router rejects any
505
+ * operation not listed (403). When unset, only `insert` is permitted — the
506
+ * safe default, since open update/delete lets a client overwrite or remove
507
+ * another principal's rows by key.
508
+ */
509
+ operations?: Array<`insert` | `update` | `delete`>
510
+ }
511
+
497
512
  export interface ElectricAgentsEntityType {
498
513
  name: string
499
514
  description: string
500
515
  creation_schema?: Record<string, unknown>
501
516
  inbox_schemas?: Record<string, Record<string, unknown>>
502
517
  state_schemas?: Record<string, Record<string, unknown>>
518
+ externally_writable_collections?: Record<
519
+ string,
520
+ ExternallyWritableCollectionConfig
521
+ >
503
522
  slash_commands?: Array<SlashCommandDefinition>
504
523
  serve_endpoint?: string
505
524
  default_dispatch_policy?: DispatchPolicy
@@ -514,6 +533,10 @@ export interface RegisterEntityTypeRequest {
514
533
  creation_schema?: Record<string, unknown>
515
534
  inbox_schemas?: Record<string, Record<string, unknown>>
516
535
  state_schemas?: Record<string, Record<string, unknown>>
536
+ externally_writable_collections?: Record<
537
+ string,
538
+ ExternallyWritableCollectionConfig
539
+ >
517
540
  slash_commands?: Array<SlashCommandDefinition>
518
541
  serve_endpoint?: string
519
542
  default_dispatch_policy?: DispatchPolicy
@@ -71,6 +71,7 @@ import type {
71
71
  SignalRequest,
72
72
  SignalResponse,
73
73
  TypedSpawnRequest,
74
+ ExternallyWritableCollectionConfig,
74
75
  } from './electric-agents-types.js'
75
76
  import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
76
77
  import type { Principal } from './principal.js'
@@ -131,6 +132,23 @@ export interface CreateAttachmentRequest {
131
132
  meta?: Record<string, unknown>
132
133
  }
133
134
 
135
+ export interface WriteCollectionPrincipal {
136
+ url: string
137
+ kind: string
138
+ id: string
139
+ }
140
+
141
+ export interface WriteCollectionRequest {
142
+ operation: `insert` | `update` | `delete`
143
+ key?: string
144
+ value?: Record<string, unknown>
145
+ principal: WriteCollectionPrincipal
146
+ }
147
+
148
+ export interface WriteCollectionResult {
149
+ key: string
150
+ }
151
+
134
152
  export interface ReadAttachmentResult {
135
153
  attachment: ManifestAttachmentEntry
136
154
  bytes: Uint8Array
@@ -336,6 +354,14 @@ function cloneRecord<T extends Record<string, unknown>>(value: T): T {
336
354
  return JSON.parse(JSON.stringify(value)) as T
337
355
  }
338
356
 
357
+ function withOptionalTxid<T>(
358
+ entity: T,
359
+ txid: number | undefined
360
+ ): T & { txid?: number } {
361
+ if (txid === undefined) return entity as T & { txid?: number }
362
+ return { ...entity, txid }
363
+ }
364
+
339
365
  /**
340
366
  * Orchestrates the Electric Agents entity lifecycle: register types, spawn, send, kill.
341
367
  *
@@ -480,6 +506,7 @@ export class EntityManager {
480
506
  creation_schema: req.creation_schema,
481
507
  inbox_schemas: req.inbox_schemas,
482
508
  state_schemas: req.state_schemas,
509
+ externally_writable_collections: req.externally_writable_collections,
483
510
  slash_commands: req.slash_commands,
484
511
  serve_endpoint: req.serve_endpoint,
485
512
  default_dispatch_policy: defaultDispatchPolicy,
@@ -2336,7 +2363,7 @@ export class EntityManager {
2336
2363
  entityUrl: string,
2337
2364
  req: SendRequest,
2338
2365
  opts?: { producerId?: string }
2339
- ): Promise<void> {
2366
+ ): Promise<{ txid: string }> {
2340
2367
  const entity = await this.validateSendRequest(entityUrl, req)
2341
2368
  if (
2342
2369
  this.isForkWorkLockedEntity(entityUrl) &&
@@ -2384,9 +2411,11 @@ export class EntityManager {
2384
2411
  await this.entityBridgeManager?.onEntityChanged(entityUrl)
2385
2412
  }
2386
2413
 
2414
+ const txid = crypto.randomUUID()
2387
2415
  const envelope = entityStateSchema.inbox.insert({
2388
2416
  key,
2389
2417
  value,
2418
+ headers: { txid },
2390
2419
  } as any)
2391
2420
 
2392
2421
  const encoded = this.encodeChangeEvent(envelope as Record<string, unknown>)
@@ -2395,7 +2424,7 @@ export class EntityManager {
2395
2424
  await this.streamClient.appendIdempotent(entity.streams.main, encoded, {
2396
2425
  producerId: opts.producerId,
2397
2426
  })
2398
- return
2427
+ return { txid }
2399
2428
  }
2400
2429
 
2401
2430
  await this.streamClient.append(entity.streams.main, encoded)
@@ -2407,9 +2436,11 @@ export class EntityManager {
2407
2436
  type: `identity`,
2408
2437
  key: `self`,
2409
2438
  value: identity,
2439
+ headers: { txid },
2410
2440
  })
2411
2441
  )
2412
2442
  }
2443
+ return { txid }
2413
2444
  } catch (err) {
2414
2445
  if (this.isClosedStreamError(err)) {
2415
2446
  throw new ElectricAgentsError(
@@ -2422,6 +2453,106 @@ export class EntityManager {
2422
2453
  }
2423
2454
  }
2424
2455
 
2456
+ async writeCollection(
2457
+ entityUrl: string,
2458
+ collection: string,
2459
+ req: WriteCollectionRequest
2460
+ ): Promise<WriteCollectionResult> {
2461
+ const entity = await this.registry.getEntity(entityUrl)
2462
+ if (!entity) {
2463
+ throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
2464
+ }
2465
+
2466
+ const { externallyWritableCollections } =
2467
+ await this.getEffectiveSchemas(entity)
2468
+ const config = externallyWritableCollections?.[collection]
2469
+ if (!config) {
2470
+ throw new ElectricAgentsError(
2471
+ ErrCodeUnauthorized,
2472
+ `Collection "${collection}" is not writable`,
2473
+ 403
2474
+ )
2475
+ }
2476
+
2477
+ const allowedOperations = config.operations ?? [`insert`]
2478
+ if (!allowedOperations.includes(req.operation)) {
2479
+ throw new ElectricAgentsError(
2480
+ ErrCodeUnauthorized,
2481
+ `Operation "${req.operation}" is not allowed on collection "${collection}"`,
2482
+ 403
2483
+ )
2484
+ }
2485
+
2486
+ if (rejectsNormalWrites(entity.status)) {
2487
+ throw new ElectricAgentsError(
2488
+ ErrCodeNotRunning,
2489
+ `Entity is not accepting writes`,
2490
+ 409
2491
+ )
2492
+ }
2493
+ if (this.isForkWorkLockedEntity(entityUrl)) {
2494
+ this.assertEntityNotForkWorkLocked(entityUrl)
2495
+ }
2496
+
2497
+ if (
2498
+ req.operation !== `delete` &&
2499
+ (req.value === undefined || req.value === null)
2500
+ ) {
2501
+ throw new ElectricAgentsError(
2502
+ ErrCodeInvalidRequest,
2503
+ `value is required for ${req.operation}`,
2504
+ 400
2505
+ )
2506
+ }
2507
+ if (req.operation !== `insert` && !req.key) {
2508
+ throw new ElectricAgentsError(
2509
+ ErrCodeInvalidRequest,
2510
+ `key is required for ${req.operation}`,
2511
+ 400
2512
+ )
2513
+ }
2514
+
2515
+ const key = req.key ?? `${collection}-${randomUUID()}`
2516
+
2517
+ const event: Record<string, unknown> = {
2518
+ type: config.type,
2519
+ key,
2520
+ headers: {
2521
+ operation: req.operation,
2522
+ timestamp: new Date().toISOString(),
2523
+ principal: req.principal,
2524
+ },
2525
+ }
2526
+ if (req.operation !== `delete`) {
2527
+ event.value = req.value
2528
+ }
2529
+
2530
+ const validationError = await this.validateWriteEvent(entity, event)
2531
+ if (validationError) {
2532
+ throw new ElectricAgentsError(
2533
+ validationError.code,
2534
+ validationError.message,
2535
+ validationError.status
2536
+ )
2537
+ }
2538
+
2539
+ const encoded = this.encodeChangeEvent(event)
2540
+ try {
2541
+ await this.streamClient.append(entity.streams.main, encoded)
2542
+ } catch (err) {
2543
+ if (this.isClosedStreamError(err)) {
2544
+ throw new ElectricAgentsError(
2545
+ ErrCodeNotRunning,
2546
+ `Entity is stopped`,
2547
+ 409
2548
+ )
2549
+ }
2550
+ throw err
2551
+ }
2552
+
2553
+ return { key }
2554
+ }
2555
+
2425
2556
  async updateInboxMessage(
2426
2557
  entityUrl: string,
2427
2558
  key: string,
@@ -2431,7 +2562,7 @@ export class EntityManager {
2431
2562
  mode?: `immediate` | `queued` | `paused` | `steer`
2432
2563
  status?: `pending` | `processed` | `cancelled`
2433
2564
  }
2434
- ): Promise<void> {
2565
+ ): Promise<{ txid: string }> {
2435
2566
  const entity = await this.registry.getEntity(entityUrl)
2436
2567
  if (!entity) {
2437
2568
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
@@ -2463,17 +2594,23 @@ export class EntityManager {
2463
2594
  )
2464
2595
  }
2465
2596
 
2597
+ const txid = crypto.randomUUID()
2466
2598
  const envelope = entityStateSchema.inbox.update({
2467
2599
  key,
2468
2600
  value,
2601
+ headers: { txid },
2469
2602
  } as any)
2470
2603
  await this.streamClient.append(
2471
2604
  entity.streams.main,
2472
2605
  this.encodeChangeEvent(envelope as Record<string, unknown>)
2473
2606
  )
2607
+ return { txid }
2474
2608
  }
2475
2609
 
2476
- async deleteInboxMessage(entityUrl: string, key: string): Promise<void> {
2610
+ async deleteInboxMessage(
2611
+ entityUrl: string,
2612
+ key: string
2613
+ ): Promise<{ txid: string }> {
2477
2614
  const entity = await this.registry.getEntity(entityUrl)
2478
2615
  if (!entity) {
2479
2616
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
@@ -2486,11 +2623,16 @@ export class EntityManager {
2486
2623
  )
2487
2624
  }
2488
2625
 
2489
- const envelope = entityStateSchema.inbox.delete({ key } as any)
2626
+ const txid = crypto.randomUUID()
2627
+ const envelope = entityStateSchema.inbox.delete({
2628
+ key,
2629
+ headers: { txid },
2630
+ } as any)
2490
2631
  await this.streamClient.append(
2491
2632
  entity.streams.main,
2492
2633
  this.encodeChangeEvent(envelope as Record<string, unknown>)
2493
2634
  )
2635
+ return { txid }
2494
2636
  }
2495
2637
 
2496
2638
  // ==========================================================================
@@ -2686,21 +2828,12 @@ export class EntityManager {
2686
2828
  async setTag(
2687
2829
  entityUrl: string,
2688
2830
  key: string,
2689
- req: SetTagRequest,
2690
- token: string
2691
- ): Promise<ElectricAgentsEntity> {
2831
+ req: SetTagRequest
2832
+ ): Promise<ElectricAgentsEntity & { txid?: number }> {
2692
2833
  const entity = await this.registry.getEntity(entityUrl)
2693
2834
  if (!entity) {
2694
2835
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
2695
2836
  }
2696
-
2697
- if (!this.isValidWriteToken(entity, token)) {
2698
- throw new ElectricAgentsError(
2699
- ErrCodeUnauthorized,
2700
- `Invalid write token`,
2701
- 401
2702
- )
2703
- }
2704
2837
  if (rejectsNormalWrites(entity.status)) {
2705
2838
  throw new ElectricAgentsError(
2706
2839
  ErrCodeNotRunning,
@@ -2731,26 +2864,17 @@ export class EntityManager {
2731
2864
  await this.entityBridgeManager.onEntityChanged(entityUrl)
2732
2865
  }
2733
2866
 
2734
- return updated
2867
+ return withOptionalTxid(updated, result.txid)
2735
2868
  }
2736
2869
 
2737
2870
  async deleteTag(
2738
2871
  entityUrl: string,
2739
- key: string,
2740
- token: string
2741
- ): Promise<ElectricAgentsEntity> {
2872
+ key: string
2873
+ ): Promise<ElectricAgentsEntity & { txid?: number }> {
2742
2874
  const entity = await this.registry.getEntity(entityUrl)
2743
2875
  if (!entity) {
2744
2876
  throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
2745
2877
  }
2746
-
2747
- if (!this.isValidWriteToken(entity, token)) {
2748
- throw new ElectricAgentsError(
2749
- ErrCodeUnauthorized,
2750
- `Invalid write token`,
2751
- 401
2752
- )
2753
- }
2754
2878
  if (rejectsNormalWrites(entity.status)) {
2755
2879
  throw new ElectricAgentsError(
2756
2880
  ErrCodeNotRunning,
@@ -2773,7 +2897,7 @@ export class EntityManager {
2773
2897
  await this.entityBridgeManager.onEntityChanged(entityUrl)
2774
2898
  }
2775
2899
 
2776
- return updated
2900
+ return withOptionalTxid(updated, result.txid)
2777
2901
  }
2778
2902
 
2779
2903
  async ensureEntitiesMembershipStream(
@@ -3871,11 +3995,16 @@ export class EntityManager {
3871
3995
  private async getEffectiveSchemas(entity: ElectricAgentsEntity): Promise<{
3872
3996
  inboxSchemas?: Record<string, Record<string, unknown>>
3873
3997
  stateSchemas?: Record<string, Record<string, unknown>>
3998
+ externallyWritableCollections?: Record<
3999
+ string,
4000
+ ExternallyWritableCollectionConfig
4001
+ >
3874
4002
  }> {
3875
4003
  if (!entity.type) {
3876
4004
  return {
3877
4005
  inboxSchemas: entity.inbox_schemas,
3878
4006
  stateSchemas: entity.state_schemas,
4007
+ externallyWritableCollections: undefined,
3879
4008
  }
3880
4009
  }
3881
4010
 
@@ -3888,6 +4017,8 @@ export class EntityManager {
3888
4017
  stateSchemas: latestType?.state_schemas
3889
4018
  ? { ...(entity.state_schemas ?? {}), ...latestType.state_schemas }
3890
4019
  : entity.state_schemas,
4020
+ externallyWritableCollections:
4021
+ latestType?.externally_writable_collections,
3891
4022
  }
3892
4023
  }
3893
4024