@electric-ax/agents-server 0.4.20 → 0.5.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents-server",
3
- "version": "0.4.20",
3
+ "version": "0.5.1",
4
4
  "description": "Electric Agents entity runtime server",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
@@ -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.13"
57
+ "@electric-ax/agents-runtime": "0.4.1"
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.17",
69
- "@electric-ax/agents-server-conformance-tests": "0.1.12",
70
- "@electric-ax/agents-server-ui": "0.4.20"
68
+ "@electric-ax/agents-server-ui": "0.5.1",
69
+ "@electric-ax/agents": "0.4.19",
70
+ "@electric-ax/agents-server-conformance-tests": "0.1.12"
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`),
@@ -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
@@ -7,7 +7,7 @@ import {
7
7
  getCronStreamPath,
8
8
  getSharedStateStreamPath,
9
9
  getNextCronFireAt,
10
- eventSourceSubscriptionManifestKey,
10
+ webhookSourceSubscriptionManifestKey,
11
11
  manifestChildKey,
12
12
  manifestSharedStateKey,
13
13
  manifestSourceKey,
@@ -56,7 +56,7 @@ import type { queueAsPromised } from 'fastq'
56
56
  import type { SchedulerClient } from './scheduler.js'
57
57
  import type { WakeEvalResult, WakeRegistry } from './wake-registry.js'
58
58
  import type { WakeMessage } from '@electric-ax/agents-runtime'
59
- import type { EventSourceSubscription } from '@electric-ax/agents-runtime'
59
+ import type { WebhookSourceSubscription } from '@electric-ax/agents-runtime'
60
60
  import type { PostgresRegistry } from './entity-registry.js'
61
61
  import type { SchemaValidator } from './electric-agents/schema-validator.js'
62
62
  import type { StreamClient } from './stream-client.js'
@@ -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
@@ -488,6 +506,7 @@ export class EntityManager {
488
506
  creation_schema: req.creation_schema,
489
507
  inbox_schemas: req.inbox_schemas,
490
508
  state_schemas: req.state_schemas,
509
+ externally_writable_collections: req.externally_writable_collections,
491
510
  slash_commands: req.slash_commands,
492
511
  serve_endpoint: req.serve_endpoint,
493
512
  default_dispatch_policy: defaultDispatchPolicy,
@@ -2434,6 +2453,106 @@ export class EntityManager {
2434
2453
  }
2435
2454
  }
2436
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
+
2437
2556
  async updateInboxMessage(
2438
2557
  entityUrl: string,
2439
2558
  key: string,
@@ -3023,13 +3142,13 @@ export class EntityManager {
3023
3142
  return { txid }
3024
3143
  }
3025
3144
 
3026
- async upsertEventSourceSubscription(
3145
+ async upsertWebhookSourceSubscription(
3027
3146
  entityUrl: string,
3028
3147
  req: {
3029
- subscription: EventSourceSubscription
3148
+ subscription: WebhookSourceSubscription
3030
3149
  manifest: Record<string, unknown>
3031
3150
  }
3032
- ): Promise<{ txid: string; subscription: EventSourceSubscription }> {
3151
+ ): Promise<{ txid: string; subscription: WebhookSourceSubscription }> {
3033
3152
  const manifestKey = req.subscription.manifestKey
3034
3153
  const txid = randomUUID()
3035
3154
  await this.writeManifestEntry(
@@ -3065,11 +3184,35 @@ export class EntityManager {
3065
3184
  return { txid, subscription: req.subscription }
3066
3185
  }
3067
3186
 
3068
- async deleteEventSourceSubscription(
3187
+ async deleteWebhookSourceSubscription(
3069
3188
  entityUrl: string,
3070
3189
  req: { id: string }
3071
3190
  ): Promise<{ txid: string }> {
3072
- const manifestKey = eventSourceSubscriptionManifestKey(req.id)
3191
+ const manifestKey = webhookSourceSubscriptionManifestKey(req.id)
3192
+ const txid = randomUUID()
3193
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, undefined, {
3194
+ txid,
3195
+ })
3196
+
3197
+ await this.wakeRegistry.unregisterByManifestKey(
3198
+ entityUrl,
3199
+ manifestKey,
3200
+ this.tenantId
3201
+ )
3202
+
3203
+ return { txid }
3204
+ }
3205
+
3206
+ /**
3207
+ * Stop this entity observing a pg-sync source: drop its manifest entry and
3208
+ * the wake it anchors. The shared pg-sync bridge (keyed by sourceRef, not by
3209
+ * subscriber) is intentionally left running for any other observers.
3210
+ */
3211
+ async deletePgSyncObservation(
3212
+ entityUrl: string,
3213
+ req: { sourceRef: string }
3214
+ ): Promise<{ txid: string }> {
3215
+ const manifestKey = `source:pgSync:${req.sourceRef}`
3073
3216
  const txid = randomUUID()
3074
3217
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, undefined, {
3075
3218
  txid,
@@ -3876,11 +4019,16 @@ export class EntityManager {
3876
4019
  private async getEffectiveSchemas(entity: ElectricAgentsEntity): Promise<{
3877
4020
  inboxSchemas?: Record<string, Record<string, unknown>>
3878
4021
  stateSchemas?: Record<string, Record<string, unknown>>
4022
+ externallyWritableCollections?: Record<
4023
+ string,
4024
+ ExternallyWritableCollectionConfig
4025
+ >
3879
4026
  }> {
3880
4027
  if (!entity.type) {
3881
4028
  return {
3882
4029
  inboxSchemas: entity.inbox_schemas,
3883
4030
  stateSchemas: entity.state_schemas,
4031
+ externallyWritableCollections: undefined,
3884
4032
  }
3885
4033
  }
3886
4034
 
@@ -3893,6 +4041,8 @@ export class EntityManager {
3893
4041
  stateSchemas: latestType?.state_schemas
3894
4042
  ? { ...(entity.state_schemas ?? {}), ...latestType.state_schemas }
3895
4043
  : entity.state_schemas,
4044
+ externallyWritableCollections:
4045
+ latestType?.externally_writable_collections,
3896
4046
  }
3897
4047
  }
3898
4048
 
@@ -43,6 +43,7 @@ import type {
43
43
  EntityTypePermission,
44
44
  EntityTypePermissionGrant,
45
45
  PermissionSubjectKind,
46
+ ExternallyWritableCollectionConfig,
46
47
  } from './electric-agents-types.js'
47
48
  import type { EntityTags, PgSyncOptions } from '@electric-ax/agents-runtime'
48
49
  import type { Principal } from './principal.js'
@@ -654,6 +655,8 @@ export class PostgresRegistry {
654
655
  creationSchema: et.creation_schema ?? null,
655
656
  inboxSchemas: et.inbox_schemas ?? null,
656
657
  stateSchemas: et.state_schemas ?? null,
658
+ externallyWritableCollections:
659
+ et.externally_writable_collections ?? null,
657
660
  slashCommands: et.slash_commands ?? null,
658
661
  serveEndpoint: et.serve_endpoint ?? null,
659
662
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -668,6 +671,8 @@ export class PostgresRegistry {
668
671
  creationSchema: et.creation_schema ?? null,
669
672
  inboxSchemas: et.inbox_schemas ?? null,
670
673
  stateSchemas: et.state_schemas ?? null,
674
+ externallyWritableCollections:
675
+ et.externally_writable_collections ?? null,
671
676
  slashCommands: et.slash_commands ?? null,
672
677
  serveEndpoint: et.serve_endpoint ?? null,
673
678
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -691,6 +696,8 @@ export class PostgresRegistry {
691
696
  creationSchema: et.creation_schema ?? null,
692
697
  inboxSchemas: et.inbox_schemas ?? null,
693
698
  stateSchemas: et.state_schemas ?? null,
699
+ externallyWritableCollections:
700
+ et.externally_writable_collections ?? null,
694
701
  slashCommands: et.slash_commands ?? null,
695
702
  serveEndpoint: et.serve_endpoint ?? null,
696
703
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -733,6 +740,8 @@ export class PostgresRegistry {
733
740
  creationSchema: et.creation_schema ?? null,
734
741
  inboxSchemas: et.inbox_schemas ?? null,
735
742
  stateSchemas: et.state_schemas ?? null,
743
+ externallyWritableCollections:
744
+ et.externally_writable_collections ?? null,
736
745
  slashCommands: et.slash_commands ?? null,
737
746
  serveEndpoint: et.serve_endpoint ?? null,
738
747
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -1566,10 +1575,16 @@ export class PostgresRegistry {
1566
1575
  })
1567
1576
  .onConflictDoUpdate({
1568
1577
  target: [pgSyncBridges.tenantId, pgSyncBridges.sourceRef],
1578
+ // A conflict means the sourceRef matched, i.e. the shape-identity
1579
+ // options are identical, so the persisted cursor and bootstrap state
1580
+ // are still valid. Re-registration is the common path now that the
1581
+ // sourceRef ignores per-request metadata; resetting
1582
+ // initialSnapshotComplete here would make a later restart resume from
1583
+ // the saved cursor while re-skipping changes until up-to-date, dropping
1584
+ // real changes that arrived during downtime.
1569
1585
  set: {
1570
1586
  options: row.options,
1571
1587
  streamUrl: row.streamUrl,
1572
- initialSnapshotComplete: false,
1573
1588
  lastTouchedAt: new Date(),
1574
1589
  updatedAt: new Date(),
1575
1590
  },
@@ -1641,6 +1656,10 @@ export class PostgresRegistry {
1641
1656
  .where(this.pgSyncBridgeWhere(sourceRef))
1642
1657
  }
1643
1658
 
1659
+ async deletePgSyncBridge(sourceRef: string): Promise<void> {
1660
+ await this.db.delete(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef))
1661
+ }
1662
+
1644
1663
  async upsertEntityBridge(row: {
1645
1664
  sourceRef: string
1646
1665
  tags: EntityTags
@@ -1957,6 +1976,11 @@ export class PostgresRegistry {
1957
1976
  state_schemas: row.stateSchemas as
1958
1977
  | Record<string, Record<string, unknown>>
1959
1978
  | undefined,
1979
+ externally_writable_collections:
1980
+ (row.externallyWritableCollections as Record<
1981
+ string,
1982
+ ExternallyWritableCollectionConfig
1983
+ > | null) ?? undefined,
1960
1984
  slash_commands:
1961
1985
  (row.slashCommands as ElectricAgentsEntityType[`slash_commands`]) ??
1962
1986
  undefined,
package/src/index.ts CHANGED
@@ -65,17 +65,17 @@ export type {
65
65
  AuthorizeRequest,
66
66
  } from './electric-agents-types.js'
67
67
  export type {
68
- EventSourceBucket,
69
- EventSourceContract,
70
- EventSourceFilter,
71
- EventSourceSubscription,
72
- EventSourceSubscriptionInput,
68
+ WebhookSourceBucket,
69
+ WebhookSourceContract,
70
+ WebhookSourceFilter,
71
+ WebhookSourceSubscription,
72
+ WebhookSourceSubscriptionInput,
73
73
  SubscriptionLifetime,
74
74
  } from '@electric-ax/agents-runtime'
75
75
  export type { Principal, PrincipalKind } from './principal.js'
76
76
  export { globalRouter } from './routing/global-router.js'
77
77
  export type { GlobalRoutes } from './routing/global-router.js'
78
- export type { EventSourceCatalog, TenantContext } from './routing/context.js'
78
+ export type { WebhookSourceCatalog, TenantContext } from './routing/context.js'
79
79
  export {
80
80
  streamRootDurableStreamsRoutingAdapter,
81
81
  pathPrefixedSingleTenantDurableStreamsRoutingAdapter,
@@ -10,10 +10,6 @@ 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
-
17
13
  export function extractManifestSourceUrl(
18
14
  manifest: Record<string, unknown> | undefined
19
15
  ): string | undefined {
@@ -62,8 +58,8 @@ export function extractManifestSourceUrl(
62
58
  }
63
59
 
64
60
  if (manifest.sourceType === `pgSync`) {
65
- return typeof manifest.sourceRef === `string`
66
- ? getPgSyncManifestStreamPath(manifest.sourceRef)
61
+ return typeof config?.streamUrl === `string`
62
+ ? config.streamUrl
67
63
  : undefined
68
64
  }
69
65