@electric-ax/agents-server 0.4.20 → 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.
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import postgres from "postgres";
8
8
  import { and, desc, eq, inArray, lt, ne, sql } from "drizzle-orm";
9
9
  import { bigint, bigserial, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
10
10
  import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
11
- import { COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, canonicalPgSyncOptions, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
11
+ import { COMMENTS_CONTRACT, COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, canonicalPgSyncOptions, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
12
12
  import { DurableStream, DurableStreamError, FetchError, IdempotentProducer } from "@durable-streams/client";
13
13
  import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
14
14
  import pino from "pino";
@@ -50,6 +50,7 @@ const entityTypes = pgTable(`entity_types`, {
50
50
  creationSchema: jsonb(`creation_schema`),
51
51
  inboxSchemas: jsonb(`inbox_schemas`),
52
52
  stateSchemas: jsonb(`state_schemas`),
53
+ externallyWritableCollections: jsonb(`externally_writable_collections`).$type(),
53
54
  slashCommands: jsonb(`slash_commands`),
54
55
  serveEndpoint: text(`serve_endpoint`),
55
56
  defaultDispatchPolicy: jsonb(`default_dispatch_policy`),
@@ -837,6 +838,7 @@ var PostgresRegistry = class {
837
838
  creationSchema: et.creation_schema ?? null,
838
839
  inboxSchemas: et.inbox_schemas ?? null,
839
840
  stateSchemas: et.state_schemas ?? null,
841
+ externallyWritableCollections: et.externally_writable_collections ?? null,
840
842
  slashCommands: et.slash_commands ?? null,
841
843
  serveEndpoint: et.serve_endpoint ?? null,
842
844
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -850,6 +852,7 @@ var PostgresRegistry = class {
850
852
  creationSchema: et.creation_schema ?? null,
851
853
  inboxSchemas: et.inbox_schemas ?? null,
852
854
  stateSchemas: et.state_schemas ?? null,
855
+ externallyWritableCollections: et.externally_writable_collections ?? null,
853
856
  slashCommands: et.slash_commands ?? null,
854
857
  serveEndpoint: et.serve_endpoint ?? null,
855
858
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -868,6 +871,7 @@ var PostgresRegistry = class {
868
871
  creationSchema: et.creation_schema ?? null,
869
872
  inboxSchemas: et.inbox_schemas ?? null,
870
873
  stateSchemas: et.state_schemas ?? null,
874
+ externallyWritableCollections: et.externally_writable_collections ?? null,
871
875
  slashCommands: et.slash_commands ?? null,
872
876
  serveEndpoint: et.serve_endpoint ?? null,
873
877
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -895,6 +899,7 @@ var PostgresRegistry = class {
895
899
  creationSchema: et.creation_schema ?? null,
896
900
  inboxSchemas: et.inbox_schemas ?? null,
897
901
  stateSchemas: et.state_schemas ?? null,
902
+ externallyWritableCollections: et.externally_writable_collections ?? null,
898
903
  slashCommands: et.slash_commands ?? null,
899
904
  serveEndpoint: et.serve_endpoint ?? null,
900
905
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -1540,6 +1545,7 @@ var PostgresRegistry = class {
1540
1545
  creation_schema: row.creationSchema,
1541
1546
  inbox_schemas: row.inboxSchemas,
1542
1547
  state_schemas: row.stateSchemas,
1548
+ externally_writable_collections: row.externallyWritableCollections ?? void 0,
1543
1549
  slash_commands: row.slashCommands ?? void 0,
1544
1550
  serve_endpoint: row.serveEndpoint ?? void 0,
1545
1551
  default_dispatch_policy: row.defaultDispatchPolicy ?? void 0,
@@ -3469,6 +3475,7 @@ var EntityManager = class {
3469
3475
  creation_schema: req.creation_schema,
3470
3476
  inbox_schemas: req.inbox_schemas,
3471
3477
  state_schemas: req.state_schemas,
3478
+ externally_writable_collections: req.externally_writable_collections,
3472
3479
  slash_commands: req.slash_commands,
3473
3480
  serve_endpoint: req.serve_endpoint,
3474
3481
  default_dispatch_policy: defaultDispatchPolicy,
@@ -4564,6 +4571,40 @@ var EntityManager = class {
4564
4571
  throw err;
4565
4572
  }
4566
4573
  }
4574
+ async writeCollection(entityUrl, collection, req) {
4575
+ const entity = await this.registry.getEntity(entityUrl);
4576
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4577
+ const { externallyWritableCollections } = await this.getEffectiveSchemas(entity);
4578
+ const config = externallyWritableCollections?.[collection];
4579
+ if (!config) throw new ElectricAgentsError(ErrCodeUnauthorized, `Collection "${collection}" is not writable`, 403);
4580
+ const allowedOperations = config.operations ?? [`insert`];
4581
+ if (!allowedOperations.includes(req.operation)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Operation "${req.operation}" is not allowed on collection "${collection}"`, 403);
4582
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4583
+ if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
4584
+ if (req.operation !== `delete` && (req.value === void 0 || req.value === null)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `value is required for ${req.operation}`, 400);
4585
+ if (req.operation !== `insert` && !req.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `key is required for ${req.operation}`, 400);
4586
+ const key = req.key ?? `${collection}-${randomUUID()}`;
4587
+ const event = {
4588
+ type: config.type,
4589
+ key,
4590
+ headers: {
4591
+ operation: req.operation,
4592
+ timestamp: new Date().toISOString(),
4593
+ principal: req.principal
4594
+ }
4595
+ };
4596
+ if (req.operation !== `delete`) event.value = req.value;
4597
+ const validationError = await this.validateWriteEvent(entity, event);
4598
+ if (validationError) throw new ElectricAgentsError(validationError.code, validationError.message, validationError.status);
4599
+ const encoded = this.encodeChangeEvent(event);
4600
+ try {
4601
+ await this.streamClient.append(entity.streams.main, encoded);
4602
+ } catch (err) {
4603
+ if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4604
+ throw err;
4605
+ }
4606
+ return { key };
4607
+ }
4567
4608
  async updateInboxMessage(entityUrl, key, req) {
4568
4609
  const entity = await this.registry.getEntity(entityUrl);
4569
4610
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
@@ -5256,7 +5297,8 @@ var EntityManager = class {
5256
5297
  async getEffectiveSchemas(entity) {
5257
5298
  if (!entity.type) return {
5258
5299
  inboxSchemas: entity.inbox_schemas,
5259
- stateSchemas: entity.state_schemas
5300
+ stateSchemas: entity.state_schemas,
5301
+ externallyWritableCollections: void 0
5260
5302
  };
5261
5303
  const latestType = await this.registry.getEntityType(entity.type);
5262
5304
  return {
@@ -5267,7 +5309,8 @@ var EntityManager = class {
5267
5309
  stateSchemas: latestType?.state_schemas ? {
5268
5310
  ...entity.state_schemas ?? {},
5269
5311
  ...latestType.state_schemas
5270
- } : entity.state_schemas
5312
+ } : entity.state_schemas,
5313
+ externallyWritableCollections: latestType?.externally_writable_collections
5271
5314
  };
5272
5315
  }
5273
5316
  isClosedStreamError(err) {
@@ -8505,6 +8548,15 @@ const spawnBodySchema = Type.Object({
8505
8548
  manifestKey: Type.Optional(Type.String())
8506
8549
  }))
8507
8550
  });
8551
+ const writeCollectionBodySchema = Type.Object({
8552
+ operation: Type.Union([
8553
+ Type.Literal(`insert`),
8554
+ Type.Literal(`update`),
8555
+ Type.Literal(`delete`)
8556
+ ]),
8557
+ key: Type.Optional(Type.String()),
8558
+ value: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
8559
+ }, { additionalProperties: false });
8508
8560
  const sendBodySchema = Type.Object({
8509
8561
  payload: Type.Optional(Type.Unknown()),
8510
8562
  key: Type.Optional(Type.String()),
@@ -8637,6 +8689,7 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermissi
8637
8689
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
8638
8690
  entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
8639
8691
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
8692
+ entitiesRouter.post(`/:type/:instanceId/collections/:collection`, withExistingEntity, withSchema(writeCollectionBodySchema), withEntityPermission(`write`), writeCollection);
8640
8693
  entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
8641
8694
  entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
8642
8695
  entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
@@ -9035,6 +9088,23 @@ async function sendEntity(request, ctx) {
9035
9088
  const result = await ctx.entityManager.send(entityUrl, sendReq);
9036
9089
  return json(result);
9037
9090
  }
9091
+ async function writeCollection(request, ctx) {
9092
+ const parsed = routeBody(request);
9093
+ await ctx.entityManager.ensurePrincipal(ctx.principal);
9094
+ const { entityUrl } = requireExistingEntityRoute(request);
9095
+ const collection = request.params.collection;
9096
+ const result = await ctx.entityManager.writeCollection(entityUrl, collection, {
9097
+ operation: parsed.operation,
9098
+ key: parsed.key,
9099
+ value: parsed.value,
9100
+ principal: {
9101
+ url: ctx.principal.url,
9102
+ kind: ctx.principal.kind,
9103
+ id: ctx.principal.id
9104
+ }
9105
+ });
9106
+ return json(result, { status: parsed.operation === `insert` ? 201 : 200 });
9107
+ }
9038
9108
  async function createAttachment(request, ctx) {
9039
9109
  const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
9040
9110
  if (principalMutationError) return principalMutationError;
@@ -9132,8 +9202,13 @@ async function spawnEntity(request, ctx) {
9132
9202
  headers: { "x-write-token": entity.write_token }
9133
9203
  });
9134
9204
  }
9135
- function getEntity(request) {
9136
- return json(toPublicEntity(requireExistingEntityRoute(request).entity));
9205
+ async function getEntity(request, ctx) {
9206
+ const { entity } = requireExistingEntityRoute(request);
9207
+ const entityType = entity.type ? await ctx.entityManager.registry.getEntityType(entity.type) : null;
9208
+ return json({
9209
+ ...toPublicEntity(entity),
9210
+ ...entityType?.externally_writable_collections && { externally_writable_collections: entityType.externally_writable_collections }
9211
+ });
9137
9212
  }
9138
9213
  function headEntity() {
9139
9214
  return status(200);
@@ -9168,6 +9243,16 @@ async function signalEntity(request, ctx) {
9168
9243
  //#region src/routing/entity-types-router.ts
9169
9244
  const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
9170
9245
  const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
9246
+ const externallyWritableCollectionsSchema = Type.Record(Type.String(), Type.Object({
9247
+ type: Type.String(),
9248
+ contract: Type.Optional(Type.String()),
9249
+ operations: Type.Optional(Type.Array(Type.Union([
9250
+ Type.Literal(`insert`),
9251
+ Type.Literal(`update`),
9252
+ Type.Literal(`delete`)
9253
+ ]))),
9254
+ principalColumn: Type.Optional(Type.String())
9255
+ }, { additionalProperties: false }));
9171
9256
  const slashCommandArgumentSchema = Type.Object({
9172
9257
  name: Type.String(),
9173
9258
  type: Type.Union([
@@ -9198,7 +9283,8 @@ const registerEntityTypeBodySchema = Type.Object({
9198
9283
  slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
9199
9284
  serve_endpoint: Type.Optional(Type.String()),
9200
9285
  default_dispatch_policy: Type.Optional(dispatchPolicySchema),
9201
- permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
9286
+ permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema)),
9287
+ externally_writable_collections: Type.Optional(externallyWritableCollectionsSchema)
9202
9288
  }, { additionalProperties: false });
9203
9289
  const amendEntityTypeSchemasBodySchema = Type.Object({
9204
9290
  inbox_schemas: Type.Optional(schemaMapSchema),
@@ -9329,7 +9415,20 @@ function parseExpiresAt(value) {
9329
9415
  if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
9330
9416
  return expiresAt;
9331
9417
  }
9418
+ /**
9419
+ * The `comments` collection name is reserved for the canonical comments
9420
+ * contract: the UI keys its comment affordances on it, so a divergent
9421
+ * collection registered under that name (or the contract mounted under
9422
+ * another name) would break that assumption silently.
9423
+ */
9424
+ function validateExternallyWritableCollections(collections) {
9425
+ for (const [name, config] of Object.entries(collections ?? {})) {
9426
+ if (name === `comments` && config.contract !== COMMENTS_CONTRACT) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The externally-writable collection name "comments" is reserved for the "${COMMENTS_CONTRACT}" contract`, 400);
9427
+ if (config.contract === COMMENTS_CONTRACT && name !== `comments`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The "${COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`, 400);
9428
+ }
9429
+ }
9332
9430
  function normalizeEntityTypeRequest(parsed) {
9431
+ validateExternallyWritableCollections(parsed.externally_writable_collections);
9333
9432
  const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
9334
9433
  return {
9335
9434
  name: parsed.name ?? ``,
@@ -9343,7 +9442,8 @@ function normalizeEntityTypeRequest(parsed) {
9343
9442
  type: `webhook`,
9344
9443
  url: serveEndpoint
9345
9444
  }] } : void 0),
9346
- permission_grants: parsed.permission_grants
9445
+ permission_grants: parsed.permission_grants,
9446
+ externally_writable_collections: parsed.externally_writable_collections
9347
9447
  };
9348
9448
  }
9349
9449
  function toPublicEntityType(entityType) {
@@ -0,0 +1 @@
1
+ ALTER TABLE "entity_types" ADD COLUMN "externally_writable_collections" jsonb;
@@ -113,6 +113,13 @@
113
113
  "when": 1779728400000,
114
114
  "tag": "0015_pg_sync_bridges",
115
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
116
123
  }
117
124
  ]
118
125
  }
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.0",
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.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.17",
68
+ "@electric-ax/agents": "0.4.18",
69
69
  "@electric-ax/agents-server-conformance-tests": "0.1.12",
70
- "@electric-ax/agents-server-ui": "0.4.20"
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`),
@@ -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
@@ -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,
@@ -3876,11 +3995,16 @@ export class EntityManager {
3876
3995
  private async getEffectiveSchemas(entity: ElectricAgentsEntity): Promise<{
3877
3996
  inboxSchemas?: Record<string, Record<string, unknown>>
3878
3997
  stateSchemas?: Record<string, Record<string, unknown>>
3998
+ externallyWritableCollections?: Record<
3999
+ string,
4000
+ ExternallyWritableCollectionConfig
4001
+ >
3879
4002
  }> {
3880
4003
  if (!entity.type) {
3881
4004
  return {
3882
4005
  inboxSchemas: entity.inbox_schemas,
3883
4006
  stateSchemas: entity.state_schemas,
4007
+ externallyWritableCollections: undefined,
3884
4008
  }
3885
4009
  }
3886
4010
 
@@ -3893,6 +4017,8 @@ export class EntityManager {
3893
4017
  stateSchemas: latestType?.state_schemas
3894
4018
  ? { ...(entity.state_schemas ?? {}), ...latestType.state_schemas }
3895
4019
  : entity.state_schemas,
4020
+ externallyWritableCollections:
4021
+ latestType?.externally_writable_collections,
3896
4022
  }
3897
4023
  }
3898
4024
 
@@ -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,
@@ -1957,6 +1966,11 @@ export class PostgresRegistry {
1957
1966
  state_schemas: row.stateSchemas as
1958
1967
  | Record<string, Record<string, unknown>>
1959
1968
  | undefined,
1969
+ externally_writable_collections:
1970
+ (row.externallyWritableCollections as Record<
1971
+ string,
1972
+ ExternallyWritableCollectionConfig
1973
+ > | null) ?? undefined,
1960
1974
  slash_commands:
1961
1975
  (row.slashCommands as ElectricAgentsEntityType[`slash_commands`]) ??
1962
1976
  undefined,
@@ -149,6 +149,19 @@ const spawnBodySchema = Type.Object({
149
149
  ),
150
150
  })
151
151
 
152
+ const writeCollectionBodySchema = Type.Object(
153
+ {
154
+ operation: Type.Union([
155
+ Type.Literal(`insert`),
156
+ Type.Literal(`update`),
157
+ Type.Literal(`delete`),
158
+ ]),
159
+ key: Type.Optional(Type.String()),
160
+ value: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
161
+ },
162
+ { additionalProperties: false }
163
+ )
164
+
152
165
  const sendBodySchema = Type.Object({
153
166
  payload: Type.Optional(Type.Unknown()),
154
167
  key: Type.Optional(Type.String()),
@@ -328,6 +341,7 @@ const eventSourceSubscriptionBodySchema = Type.Object({
328
341
  })
329
342
 
330
343
  type SpawnBody = Static<typeof spawnBodySchema>
344
+ type WriteCollectionBody = Static<typeof writeCollectionBodySchema>
331
345
  type SendBody = Static<typeof sendBodySchema>
332
346
  type InboxMessageBody = Static<typeof inboxMessageBodySchema>
333
347
  type ForkBody = Static<typeof forkBodySchema>
@@ -408,6 +422,13 @@ entitiesRouter.post(
408
422
  withEntityPermission(`write`),
409
423
  sendEntity
410
424
  )
425
+ entitiesRouter.post(
426
+ `/:type/:instanceId/collections/:collection`,
427
+ withExistingEntity,
428
+ withSchema(writeCollectionBodySchema),
429
+ withEntityPermission(`write`),
430
+ writeCollection
431
+ )
411
432
  entitiesRouter.post(
412
433
  `/:type/:instanceId/attachments`,
413
434
  withExistingEntity,
@@ -1308,6 +1329,31 @@ async function sendEntity(
1308
1329
  return json(result)
1309
1330
  }
1310
1331
 
1332
+ async function writeCollection(
1333
+ request: AgentsRouteRequest,
1334
+ ctx: TenantContext
1335
+ ): Promise<Response> {
1336
+ const parsed = routeBody<WriteCollectionBody>(request)
1337
+ await ctx.entityManager.ensurePrincipal(ctx.principal)
1338
+ const { entityUrl } = requireExistingEntityRoute(request)
1339
+ const collection = request.params.collection
1340
+ const result = await ctx.entityManager.writeCollection(
1341
+ entityUrl,
1342
+ collection,
1343
+ {
1344
+ operation: parsed.operation,
1345
+ key: parsed.key,
1346
+ value: parsed.value,
1347
+ principal: {
1348
+ url: ctx.principal.url,
1349
+ kind: ctx.principal.kind,
1350
+ id: ctx.principal.id,
1351
+ },
1352
+ }
1353
+ )
1354
+ return json(result, { status: parsed.operation === `insert` ? 201 : 200 })
1355
+ }
1356
+
1311
1357
  async function createAttachment(
1312
1358
  request: AgentsRouteRequest,
1313
1359
  ctx: TenantContext
@@ -1473,8 +1519,21 @@ async function spawnEntity(
1473
1519
  )
1474
1520
  }
1475
1521
 
1476
- function getEntity(request: AgentsRouteRequest): Response {
1477
- return json(toPublicEntity(requireExistingEntityRoute(request).entity))
1522
+ async function getEntity(
1523
+ request: AgentsRouteRequest,
1524
+ ctx: TenantContext
1525
+ ): Promise<Response> {
1526
+ const { entity } = requireExistingEntityRoute(request)
1527
+ const entityType = entity.type
1528
+ ? await ctx.entityManager.registry.getEntityType(entity.type)
1529
+ : null
1530
+ return json({
1531
+ ...toPublicEntity(entity),
1532
+ ...(entityType?.externally_writable_collections && {
1533
+ externally_writable_collections:
1534
+ entityType.externally_writable_collections,
1535
+ }),
1536
+ })
1478
1537
  }
1479
1538
 
1480
1539
  function headEntity(): Response {