@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/dist/index.cjs CHANGED
@@ -79,6 +79,7 @@ const entityTypes = (0, drizzle_orm_pg_core.pgTable)(`entity_types`, {
79
79
  creationSchema: (0, drizzle_orm_pg_core.jsonb)(`creation_schema`),
80
80
  inboxSchemas: (0, drizzle_orm_pg_core.jsonb)(`inbox_schemas`),
81
81
  stateSchemas: (0, drizzle_orm_pg_core.jsonb)(`state_schemas`),
82
+ externallyWritableCollections: (0, drizzle_orm_pg_core.jsonb)(`externally_writable_collections`).$type(),
82
83
  slashCommands: (0, drizzle_orm_pg_core.jsonb)(`slash_commands`),
83
84
  serveEndpoint: (0, drizzle_orm_pg_core.text)(`serve_endpoint`),
84
85
  defaultDispatchPolicy: (0, drizzle_orm_pg_core.jsonb)(`default_dispatch_policy`),
@@ -866,6 +867,7 @@ var PostgresRegistry = class {
866
867
  creationSchema: et.creation_schema ?? null,
867
868
  inboxSchemas: et.inbox_schemas ?? null,
868
869
  stateSchemas: et.state_schemas ?? null,
870
+ externallyWritableCollections: et.externally_writable_collections ?? null,
869
871
  slashCommands: et.slash_commands ?? null,
870
872
  serveEndpoint: et.serve_endpoint ?? null,
871
873
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -879,6 +881,7 @@ var PostgresRegistry = class {
879
881
  creationSchema: et.creation_schema ?? null,
880
882
  inboxSchemas: et.inbox_schemas ?? null,
881
883
  stateSchemas: et.state_schemas ?? null,
884
+ externallyWritableCollections: et.externally_writable_collections ?? null,
882
885
  slashCommands: et.slash_commands ?? null,
883
886
  serveEndpoint: et.serve_endpoint ?? null,
884
887
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -897,6 +900,7 @@ var PostgresRegistry = class {
897
900
  creationSchema: et.creation_schema ?? null,
898
901
  inboxSchemas: et.inbox_schemas ?? null,
899
902
  stateSchemas: et.state_schemas ?? null,
903
+ externallyWritableCollections: et.externally_writable_collections ?? null,
900
904
  slashCommands: et.slash_commands ?? null,
901
905
  serveEndpoint: et.serve_endpoint ?? null,
902
906
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -924,6 +928,7 @@ var PostgresRegistry = class {
924
928
  creationSchema: et.creation_schema ?? null,
925
929
  inboxSchemas: et.inbox_schemas ?? null,
926
930
  stateSchemas: et.state_schemas ?? null,
931
+ externallyWritableCollections: et.externally_writable_collections ?? null,
927
932
  slashCommands: et.slash_commands ?? null,
928
933
  serveEndpoint: et.serve_endpoint ?? null,
929
934
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -1368,7 +1373,6 @@ var PostgresRegistry = class {
1368
1373
  set: {
1369
1374
  options: row.options,
1370
1375
  streamUrl: row.streamUrl,
1371
- initialSnapshotComplete: false,
1372
1376
  lastTouchedAt: new Date(),
1373
1377
  updatedAt: new Date()
1374
1378
  }
@@ -1407,6 +1411,9 @@ var PostgresRegistry = class {
1407
1411
  updatedAt: new Date()
1408
1412
  }).where(this.pgSyncBridgeWhere(sourceRef));
1409
1413
  }
1414
+ async deletePgSyncBridge(sourceRef) {
1415
+ await this.db.delete(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef));
1416
+ }
1410
1417
  async upsertEntityBridge(row) {
1411
1418
  await this.db.insert(entityBridges).values({
1412
1419
  tenantId: this.tenantId,
@@ -1569,6 +1576,7 @@ var PostgresRegistry = class {
1569
1576
  creation_schema: row.creationSchema,
1570
1577
  inbox_schemas: row.inboxSchemas,
1571
1578
  state_schemas: row.stateSchemas,
1579
+ externally_writable_collections: row.externallyWritableCollections ?? void 0,
1572
1580
  slash_commands: row.slashCommands ?? void 0,
1573
1581
  serve_endpoint: row.serveEndpoint ?? void 0,
1574
1582
  default_dispatch_policy: row.defaultDispatchPolicy ?? void 0,
@@ -3287,9 +3295,6 @@ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
3287
3295
  function isRecord$1(value) {
3288
3296
  return typeof value === `object` && value !== null && !Array.isArray(value);
3289
3297
  }
3290
- function getPgSyncManifestStreamPath(sourceRef) {
3291
- return `/_electric/pg-sync/${sourceRef}`;
3292
- }
3293
3298
  function extractManifestSourceUrl(manifest) {
3294
3299
  if (!manifest) return void 0;
3295
3300
  if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
@@ -3302,7 +3307,7 @@ function extractManifestSourceUrl(manifest) {
3302
3307
  }
3303
3308
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
3304
3309
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(manifest.sourceRef) : void 0;
3305
- if (manifest.sourceType === `pgSync`) return typeof manifest.sourceRef === `string` ? getPgSyncManifestStreamPath(manifest.sourceRef) : void 0;
3310
+ if (manifest.sourceType === `pgSync`) return typeof config?.streamUrl === `string` ? config.streamUrl : void 0;
3306
3311
  if (manifest.sourceType === `webhook`) {
3307
3312
  if (typeof config?.streamUrl === `string`) return config.streamUrl;
3308
3313
  if (typeof config?.endpointKey === `string`) return (0, __electric_ax_agents_runtime.getWebhookStreamPath)(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
@@ -3498,6 +3503,7 @@ var EntityManager = class {
3498
3503
  creation_schema: req.creation_schema,
3499
3504
  inbox_schemas: req.inbox_schemas,
3500
3505
  state_schemas: req.state_schemas,
3506
+ externally_writable_collections: req.externally_writable_collections,
3501
3507
  slash_commands: req.slash_commands,
3502
3508
  serve_endpoint: req.serve_endpoint,
3503
3509
  default_dispatch_policy: defaultDispatchPolicy,
@@ -4593,6 +4599,40 @@ var EntityManager = class {
4593
4599
  throw err;
4594
4600
  }
4595
4601
  }
4602
+ async writeCollection(entityUrl, collection, req) {
4603
+ const entity = await this.registry.getEntity(entityUrl);
4604
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4605
+ const { externallyWritableCollections } = await this.getEffectiveSchemas(entity);
4606
+ const config = externallyWritableCollections?.[collection];
4607
+ if (!config) throw new ElectricAgentsError(ErrCodeUnauthorized, `Collection "${collection}" is not writable`, 403);
4608
+ const allowedOperations = config.operations ?? [`insert`];
4609
+ if (!allowedOperations.includes(req.operation)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Operation "${req.operation}" is not allowed on collection "${collection}"`, 403);
4610
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4611
+ if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
4612
+ if (req.operation !== `delete` && (req.value === void 0 || req.value === null)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `value is required for ${req.operation}`, 400);
4613
+ if (req.operation !== `insert` && !req.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `key is required for ${req.operation}`, 400);
4614
+ const key = req.key ?? `${collection}-${(0, node_crypto.randomUUID)()}`;
4615
+ const event = {
4616
+ type: config.type,
4617
+ key,
4618
+ headers: {
4619
+ operation: req.operation,
4620
+ timestamp: new Date().toISOString(),
4621
+ principal: req.principal
4622
+ }
4623
+ };
4624
+ if (req.operation !== `delete`) event.value = req.value;
4625
+ const validationError = await this.validateWriteEvent(entity, event);
4626
+ if (validationError) throw new ElectricAgentsError(validationError.code, validationError.message, validationError.status);
4627
+ const encoded = this.encodeChangeEvent(event);
4628
+ try {
4629
+ await this.streamClient.append(entity.streams.main, encoded);
4630
+ } catch (err) {
4631
+ if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4632
+ throw err;
4633
+ }
4634
+ return { key };
4635
+ }
4596
4636
  async updateInboxMessage(entityUrl, key, req) {
4597
4637
  const entity = await this.registry.getEntity(entityUrl);
4598
4638
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
@@ -4859,7 +4899,7 @@ var EntityManager = class {
4859
4899
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
4860
4900
  return { txid };
4861
4901
  }
4862
- async upsertEventSourceSubscription(entityUrl, req) {
4902
+ async upsertWebhookSourceSubscription(entityUrl, req) {
4863
4903
  const manifestKey = req.subscription.manifestKey;
4864
4904
  const txid = (0, node_crypto.randomUUID)();
4865
4905
  await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
@@ -4881,8 +4921,20 @@ var EntityManager = class {
4881
4921
  subscription: req.subscription
4882
4922
  };
4883
4923
  }
4884
- async deleteEventSourceSubscription(entityUrl, req) {
4885
- const manifestKey = (0, __electric_ax_agents_runtime.eventSourceSubscriptionManifestKey)(req.id);
4924
+ async deleteWebhookSourceSubscription(entityUrl, req) {
4925
+ const manifestKey = (0, __electric_ax_agents_runtime.webhookSourceSubscriptionManifestKey)(req.id);
4926
+ const txid = (0, node_crypto.randomUUID)();
4927
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
4928
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
4929
+ return { txid };
4930
+ }
4931
+ /**
4932
+ * Stop this entity observing a pg-sync source: drop its manifest entry and
4933
+ * the wake it anchors. The shared pg-sync bridge (keyed by sourceRef, not by
4934
+ * subscriber) is intentionally left running for any other observers.
4935
+ */
4936
+ async deletePgSyncObservation(entityUrl, req) {
4937
+ const manifestKey = `source:pgSync:${req.sourceRef}`;
4886
4938
  const txid = (0, node_crypto.randomUUID)();
4887
4939
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
4888
4940
  await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
@@ -5285,7 +5337,8 @@ var EntityManager = class {
5285
5337
  async getEffectiveSchemas(entity) {
5286
5338
  if (!entity.type) return {
5287
5339
  inboxSchemas: entity.inbox_schemas,
5288
- stateSchemas: entity.state_schemas
5340
+ stateSchemas: entity.state_schemas,
5341
+ externallyWritableCollections: void 0
5289
5342
  };
5290
5343
  const latestType = await this.registry.getEntityType(entity.type);
5291
5344
  return {
@@ -5296,7 +5349,8 @@ var EntityManager = class {
5296
5349
  stateSchemas: latestType?.state_schemas ? {
5297
5350
  ...entity.state_schemas ?? {},
5298
5351
  ...latestType.state_schemas
5299
- } : entity.state_schemas
5352
+ } : entity.state_schemas,
5353
+ externallyWritableCollections: latestType?.externally_writable_collections
5300
5354
  };
5301
5355
  }
5302
5356
  isClosedStreamError(err) {
@@ -5986,9 +6040,13 @@ var Scheduler = class {
5986
6040
 
5987
6041
  //#endregion
5988
6042
  //#region src/pg-sync-bridge-manager.ts
5989
- const PG_SYNC_ELECTRIC_SHAPE_URL = process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ?? `http://localhost:3000/v1/shape`;
6043
+ /** Registration was rejected because the source itself is invalid — map to a 4xx. */
6044
+ var PgSyncSourceValidationError = class extends Error {
6045
+ name = `PgSyncSourceValidationError`;
6046
+ };
5990
6047
  const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
5991
6048
  const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
6049
+ const DEFAULT_PROBE_TIMEOUT_MS = 1e4;
5992
6050
  function buildElectricShapeParams(options) {
5993
6051
  return {
5994
6052
  table: options.table,
@@ -6008,6 +6066,31 @@ function buildElectricShapeParams(options) {
6008
6066
  ...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
6009
6067
  };
6010
6068
  }
6069
+ /**
6070
+ * Build the one-shot URL used to validate a shape source at registration
6071
+ * time. Approximates the query-param encoding of the Electric TS client
6072
+ * (arrays comma-joined, where-clause params as `params[n]`) — unlike the
6073
+ * client it does not quote column identifiers, so probe and stream encoding
6074
+ * can diverge for exotic column names.
6075
+ */
6076
+ function buildShapeProbeUrl(sourceUrl, options) {
6077
+ let url;
6078
+ try {
6079
+ url = new URL(sourceUrl);
6080
+ } catch {
6081
+ throw new PgSyncSourceValidationError(`pgSync url "${sourceUrl}" is not a valid URL`);
6082
+ }
6083
+ if (url.protocol !== `http:` && url.protocol !== `https:`) throw new PgSyncSourceValidationError(`pgSync url "${sourceUrl}" must be an HTTP(S) Electric shape endpoint, not a database connection string`);
6084
+ for (const [key, value] of Object.entries(buildElectricShapeParams(options))) {
6085
+ if (value === void 0 || value === null) continue;
6086
+ if (Array.isArray(value)) if (key === `params`) value.forEach((item, index$1) => url.searchParams.set(`params[${index$1 + 1}]`, String(item)));
6087
+ else url.searchParams.set(key, value.join(`,`));
6088
+ else if (typeof value === `object`) for (const [k, v] of Object.entries(value)) url.searchParams.set(`${key}[${k}]`, String(v));
6089
+ else url.searchParams.set(key, String(value));
6090
+ }
6091
+ url.searchParams.set(`offset`, `now`);
6092
+ return url;
6093
+ }
6011
6094
  function jsonSafe(value) {
6012
6095
  if (typeof value === `bigint`) return value.toString();
6013
6096
  if (value === null || typeof value !== `object`) return value;
@@ -6029,37 +6112,19 @@ function rowKeyForMessage(message) {
6029
6112
  const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
6030
6113
  return candidate === void 0 ? void 0 : stableJson(candidate);
6031
6114
  }
6032
- function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
6115
+ function pgSyncMessageToDurableEvent(message) {
6033
6116
  const operation = message.headers.operation;
6034
6117
  if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
6035
- const sourceRef = typeof optionsOrSourceRef === `string` ? optionsOrSourceRef : (0, __electric_ax_agents_runtime.sourceRefForPgSync)(optionsOrSourceRef);
6036
- const rowKey = rowKeyForMessage(message);
6037
- const offset = message.headers.offset;
6038
- if (typeof offset !== `string` || offset.length === 0) return null;
6039
- const messageKeyPart = offset;
6040
- const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
6041
- const timestamp$1 = new Date().toISOString();
6042
- const oldValue = message.old_value;
6043
- const safeValue = jsonSafe(message.value);
6044
- const safeOldValue = jsonSafe(oldValue);
6045
- const safeHeaders = jsonSafe(message.headers);
6118
+ const key = message.key ?? (typeof message.headers.key === `string` ? message.headers.key : void 0) ?? rowKeyForMessage(message);
6119
+ if (!key) return null;
6120
+ const safeMessage = jsonSafe(message);
6046
6121
  return {
6047
6122
  type: `pg_sync_change`,
6048
- key: messageKey,
6049
- value: {
6050
- key: messageKey,
6051
- table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
6052
- operation,
6053
- ...rowKey !== void 0 ? { rowKey } : {},
6054
- ...message.value !== void 0 ? { value: safeValue } : {},
6055
- ...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
6056
- headers: safeHeaders,
6057
- ...typeof offset === `string` ? { offset } : {},
6058
- receivedAt: timestamp$1
6059
- },
6123
+ key,
6124
+ value: safeMessage,
6060
6125
  headers: {
6061
- operation,
6062
- timestamp: timestamp$1
6126
+ ...jsonSafe(message.headers),
6127
+ operation
6063
6128
  }
6064
6129
  };
6065
6130
  }
@@ -6149,13 +6214,13 @@ var PgSyncBridge = class {
6149
6214
  }
6150
6215
  if (!(0, __electric_sql_client.isChangeMessage)(message)) continue;
6151
6216
  if (!this.skipChangesUntilUpToDate) {
6152
- const event = pgSyncMessageToDurableEvent(message, this.options);
6217
+ const event = pgSyncMessageToDurableEvent(message);
6153
6218
  if (event) {
6154
6219
  if (!this.producer) throw new Error(`pg-sync producer is not started`);
6155
6220
  await this.producer.append(JSON.stringify(event));
6156
6221
  await this.producer.flush?.();
6157
6222
  await this.evaluateWakes?.(this.streamUrl, event);
6158
- }
6223
+ } else serverLog.warn(`[pg-sync-bridge] dropped change message for ${this.sourceRef} (unknown operation or missing row key):`, message.headers);
6159
6224
  }
6160
6225
  await this.persistCursor(stream);
6161
6226
  this.retryAttempt = 0;
@@ -6204,13 +6269,15 @@ var PgSyncBridge = class {
6204
6269
  var PgSyncBridgeManager = class {
6205
6270
  bridges = new Map();
6206
6271
  starting = new Map();
6207
- url;
6208
6272
  retry;
6273
+ fetchFn;
6274
+ probeTimeoutMs;
6209
6275
  constructor(streamClient, evaluateWakes, registry, options = {}) {
6210
6276
  this.streamClient = streamClient;
6211
6277
  this.evaluateWakes = evaluateWakes;
6212
6278
  this.registry = registry;
6213
- this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
6279
+ this.fetchFn = options.fetchFn;
6280
+ this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
6214
6281
  this.retry = {
6215
6282
  initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
6216
6283
  maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
@@ -6221,9 +6288,16 @@ var PgSyncBridgeManager = class {
6221
6288
  async start() {
6222
6289
  const rows = await this.registry?.listPgSyncBridges?.();
6223
6290
  if (!rows) return;
6224
- await Promise.all(rows.map((row) => this.ensureBridge(row).catch((error) => {
6225
- serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
6226
- })));
6291
+ await Promise.all(rows.map(async (row) => {
6292
+ if (!row.options.url) {
6293
+ serverLog.warn(`[pg-sync-bridge] deleting registration ${row.sourceRef}: it predates required source URLs; re-register the observation with an explicit Electric shape URL`);
6294
+ await this.registry?.deletePgSyncBridge?.(row.sourceRef);
6295
+ return;
6296
+ }
6297
+ await this.ensureBridge(row).catch((error) => {
6298
+ serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
6299
+ });
6300
+ }));
6227
6301
  }
6228
6302
  async register(options, metadata) {
6229
6303
  const mergedMetadata = {
@@ -6237,6 +6311,7 @@ var PgSyncBridgeManager = class {
6237
6311
  const resolvedSource = this.resolveSource(canonicalOptions);
6238
6312
  const sourceRef = (0, __electric_ax_agents_runtime.sourceRefForPgSync)(canonicalOptions);
6239
6313
  const streamUrl = (0, __electric_ax_agents_runtime.getPgSyncStreamPath)(sourceRef, this.registry?.tenantId);
6314
+ if (!this.bridges.has(sourceRef) && !this.starting.has(sourceRef)) await this.probeSource(resolvedSource, canonicalOptions);
6240
6315
  const row = await this.registry?.upsertPgSyncBridge({
6241
6316
  sourceRef,
6242
6317
  options: canonicalOptions,
@@ -6277,7 +6352,32 @@ var PgSyncBridgeManager = class {
6277
6352
  await start;
6278
6353
  }
6279
6354
  resolveSource(options) {
6280
- return { url: options.url ?? this.url };
6355
+ if (!options.url) throw new PgSyncSourceValidationError(`pgSync source url is required; no server default is configured`);
6356
+ return { url: options.url };
6357
+ }
6358
+ /**
6359
+ * One-shot fetch of the shape log before a bridge is created, so a bad
6360
+ * URL or rejected shape fails the registration instead of dying silently
6361
+ * in the bridge's retry loop.
6362
+ */
6363
+ async probeSource(source, options) {
6364
+ const probeUrl = buildShapeProbeUrl(source.url, options);
6365
+ const fetchFn = this.fetchFn ?? globalThis.fetch;
6366
+ let response;
6367
+ try {
6368
+ response = await fetchFn(probeUrl, { signal: AbortSignal.timeout(this.probeTimeoutMs) });
6369
+ } catch (error) {
6370
+ throw new PgSyncSourceValidationError(`pgSync source at ${source.url} is unreachable: ${error instanceof Error ? error.message : String(error)}`);
6371
+ }
6372
+ if (!response.ok) {
6373
+ const body = (await response.text().catch(() => `<failed to read body>`)).slice(0, 500);
6374
+ throw new PgSyncSourceValidationError(`pgSync source at ${source.url} rejected the shape request (${response.status})${body ? `: ${body}` : ``}`);
6375
+ }
6376
+ if (!response.headers.get(`electric-handle`)) {
6377
+ const suggestion = new URL(source.url);
6378
+ suggestion.pathname = `/v1/shape`;
6379
+ throw new PgSyncSourceValidationError(`pgSync source at ${source.url} responded but is not a shape log (missing electric-handle header) — the Electric shape API is usually served at ${suggestion.origin}/v1/shape`);
6380
+ }
6281
6381
  }
6282
6382
  async stop() {
6283
6383
  await Promise.allSettled(this.starting.values());
@@ -7239,6 +7339,7 @@ var WakeRegistry = class {
7239
7339
  };
7240
7340
  if (value && `value` in value) change.value = value.value;
7241
7341
  if (value && `oldValue` in value) change.oldValue = value.oldValue;
7342
+ else if (value && `old_value` in value) change.oldValue = value.old_value;
7242
7343
  if (eventType === `inbox`) {
7243
7344
  if (typeof value?.from === `string`) change.from = value.from;
7244
7345
  if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
@@ -8534,6 +8635,15 @@ const spawnBodySchema = __sinclair_typebox.Type.Object({
8534
8635
  manifestKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
8535
8636
  }))
8536
8637
  });
8638
+ const writeCollectionBodySchema = __sinclair_typebox.Type.Object({
8639
+ operation: __sinclair_typebox.Type.Union([
8640
+ __sinclair_typebox.Type.Literal(`insert`),
8641
+ __sinclair_typebox.Type.Literal(`update`),
8642
+ __sinclair_typebox.Type.Literal(`delete`)
8643
+ ]),
8644
+ key: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
8645
+ value: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Unknown()))
8646
+ }, { additionalProperties: false });
8537
8647
  const sendBodySchema = __sinclair_typebox.Type.Object({
8538
8648
  payload: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown()),
8539
8649
  key: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
@@ -8643,8 +8753,8 @@ const subscriptionLifetimeSchema = __sinclair_typebox.Type.Union([
8643
8753
  }),
8644
8754
  __sinclair_typebox.Type.Object({ kind: __sinclair_typebox.Type.Literal(`manual`) })
8645
8755
  ]);
8646
- const eventSourceSubscriptionBodySchema = __sinclair_typebox.Type.Object({
8647
- sourceKey: __sinclair_typebox.Type.String(),
8756
+ const webhookSourceSubscriptionBodySchema = __sinclair_typebox.Type.Object({
8757
+ webhookKey: __sinclair_typebox.Type.String(),
8648
8758
  bucketKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
8649
8759
  params: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Unknown())),
8650
8760
  filterKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
@@ -8666,6 +8776,7 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermissi
8666
8776
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
8667
8777
  entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
8668
8778
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
8779
+ entitiesRouter.post(`/:type/:instanceId/collections/:collection`, withExistingEntity, withSchema(writeCollectionBodySchema), withEntityPermission(`write`), writeCollection);
8669
8780
  entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
8670
8781
  entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
8671
8782
  entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
@@ -8676,8 +8787,9 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
8676
8787
  entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withEntityPermission(`write`), deleteTag);
8677
8788
  entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), withEntityPermission(`schedule`), upsertSchedule);
8678
8789
  entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withEntityPermission(`schedule`), deleteSchedule);
8679
- entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertEventSourceSubscription);
8680
- entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withEntityPermission(`write`), deleteEventSourceSubscription);
8790
+ entitiesRouter.put(`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(webhookSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertWebhookSourceSubscription);
8791
+ entitiesRouter.delete(`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`, withExistingEntity, withEntityPermission(`write`), deleteWebhookSourceSubscription);
8792
+ entitiesRouter.delete(`/:type/:instanceId/pg-sync-observations/:sourceRef`, withExistingEntity, withEntityPermission(`write`), deletePgSyncObservation);
8681
8793
  entitiesRouter.get(`/:type/:instanceId/grants`, withExistingEntity, withEntityPermission(`manage`), listEntityPermissionGrants);
8682
8794
  entitiesRouter.post(`/:type/:instanceId/grants`, withExistingEntity, withSchema(entityPermissionGrantInputSchema), withEntityPermission(`manage`), createEntityPermissionGrant);
8683
8795
  entitiesRouter.delete(`/:type/:instanceId/grants/:grantId`, withExistingEntity, withEntityPermission(`manage`), deleteEntityPermissionGrant);
@@ -8928,22 +9040,22 @@ async function deleteSchedule(request, ctx) {
8928
9040
  const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
8929
9041
  return (0, itty_router.json)(result);
8930
9042
  }
8931
- async function upsertEventSourceSubscription(request, ctx) {
8932
- const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
9043
+ async function upsertWebhookSourceSubscription(request, ctx) {
9044
+ const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to webhook sources`);
8933
9045
  if (principalMutationError) return principalMutationError;
8934
- const catalog = ctx.eventSources;
8935
- if (!catalog) return apiError(404, ErrCodeNotFound, `No event source catalog is configured`);
9046
+ const catalog = ctx.webhookSources;
9047
+ if (!catalog) return apiError(404, ErrCodeNotFound, `No webhook source catalog is configured`);
8936
9048
  const { entityUrl } = requireExistingEntityRoute(request);
8937
9049
  const parsed = routeBody(request);
8938
- const source = await catalog.getEventSource(parsed.sourceKey);
8939
- if (!source) return apiError(404, ErrCodeNotFound, `Event source "${parsed.sourceKey}" not found`);
9050
+ const source = await catalog.getWebhookSource(parsed.webhookKey);
9051
+ if (!source) return apiError(404, ErrCodeNotFound, `Webhook source "${parsed.webhookKey}" not found`);
8940
9052
  if (parsed.lifetime?.kind === `expires_at`) {
8941
9053
  const expiresAt = new Date(parsed.lifetime.at);
8942
9054
  if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
8943
9055
  }
8944
9056
  let resolved;
8945
9057
  try {
8946
- resolved = (0, __electric_ax_agents_runtime.resolveEventSourceSubscription)({
9058
+ resolved = (0, __electric_ax_agents_runtime.resolveWebhookSourceSubscription)({
8947
9059
  contract: source,
8948
9060
  entityUrl,
8949
9061
  request: {
@@ -8955,18 +9067,25 @@ async function upsertEventSourceSubscription(request, ctx) {
8955
9067
  } catch (error) {
8956
9068
  return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
8957
9069
  }
8958
- await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
8959
- const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
9070
+ await ctx.ensureWebhookSourceWakeSource?.(resolved.subscription.sourceUrl);
9071
+ const result = await ctx.entityManager.upsertWebhookSourceSubscription(entityUrl, {
8960
9072
  subscription: resolved.subscription,
8961
- manifest: (0, __electric_ax_agents_runtime.buildEventSourceManifestEntry)(resolved)
9073
+ manifest: (0, __electric_ax_agents_runtime.buildWebhookSourceManifestEntry)(resolved)
8962
9074
  });
8963
9075
  return (0, itty_router.json)(result);
8964
9076
  }
8965
- async function deleteEventSourceSubscription(request, ctx) {
8966
- const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from event sources`);
9077
+ async function deleteWebhookSourceSubscription(request, ctx) {
9078
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from webhook sources`);
9079
+ if (principalMutationError) return principalMutationError;
9080
+ const { entityUrl } = requireExistingEntityRoute(request);
9081
+ const result = await ctx.entityManager.deleteWebhookSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
9082
+ return (0, itty_router.json)(result);
9083
+ }
9084
+ async function deletePgSyncObservation(request, ctx) {
9085
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unobserved a pg-sync source`);
8967
9086
  if (principalMutationError) return principalMutationError;
8968
9087
  const { entityUrl } = requireExistingEntityRoute(request);
8969
- const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
9088
+ const result = await ctx.entityManager.deletePgSyncObservation(entityUrl, { sourceRef: decodeURIComponent(request.params.sourceRef) });
8970
9089
  return (0, itty_router.json)(result);
8971
9090
  }
8972
9091
  function tagResponseBody(entity) {
@@ -9064,6 +9183,23 @@ async function sendEntity(request, ctx) {
9064
9183
  const result = await ctx.entityManager.send(entityUrl, sendReq);
9065
9184
  return (0, itty_router.json)(result);
9066
9185
  }
9186
+ async function writeCollection(request, ctx) {
9187
+ const parsed = routeBody(request);
9188
+ await ctx.entityManager.ensurePrincipal(ctx.principal);
9189
+ const { entityUrl } = requireExistingEntityRoute(request);
9190
+ const collection = request.params.collection;
9191
+ const result = await ctx.entityManager.writeCollection(entityUrl, collection, {
9192
+ operation: parsed.operation,
9193
+ key: parsed.key,
9194
+ value: parsed.value,
9195
+ principal: {
9196
+ url: ctx.principal.url,
9197
+ kind: ctx.principal.kind,
9198
+ id: ctx.principal.id
9199
+ }
9200
+ });
9201
+ return (0, itty_router.json)(result, { status: parsed.operation === `insert` ? 201 : 200 });
9202
+ }
9067
9203
  async function createAttachment(request, ctx) {
9068
9204
  const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
9069
9205
  if (principalMutationError) return principalMutationError;
@@ -9161,8 +9297,13 @@ async function spawnEntity(request, ctx) {
9161
9297
  headers: { "x-write-token": entity.write_token }
9162
9298
  });
9163
9299
  }
9164
- function getEntity(request) {
9165
- return (0, itty_router.json)(toPublicEntity(requireExistingEntityRoute(request).entity));
9300
+ async function getEntity(request, ctx) {
9301
+ const { entity } = requireExistingEntityRoute(request);
9302
+ const entityType = entity.type ? await ctx.entityManager.registry.getEntityType(entity.type) : null;
9303
+ return (0, itty_router.json)({
9304
+ ...toPublicEntity(entity),
9305
+ ...entityType?.externally_writable_collections && { externally_writable_collections: entityType.externally_writable_collections }
9306
+ });
9166
9307
  }
9167
9308
  function headEntity() {
9168
9309
  return (0, itty_router.status)(200);
@@ -9197,6 +9338,16 @@ async function signalEntity(request, ctx) {
9197
9338
  //#region src/routing/entity-types-router.ts
9198
9339
  const jsonObjectSchema = __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Unknown());
9199
9340
  const schemaMapSchema = __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), jsonObjectSchema);
9341
+ const externallyWritableCollectionsSchema = __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Object({
9342
+ type: __sinclair_typebox.Type.String(),
9343
+ contract: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
9344
+ operations: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(__sinclair_typebox.Type.Union([
9345
+ __sinclair_typebox.Type.Literal(`insert`),
9346
+ __sinclair_typebox.Type.Literal(`update`),
9347
+ __sinclair_typebox.Type.Literal(`delete`)
9348
+ ]))),
9349
+ principalColumn: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
9350
+ }, { additionalProperties: false }));
9200
9351
  const slashCommandArgumentSchema = __sinclair_typebox.Type.Object({
9201
9352
  name: __sinclair_typebox.Type.String(),
9202
9353
  type: __sinclair_typebox.Type.Union([
@@ -9227,7 +9378,8 @@ const registerEntityTypeBodySchema = __sinclair_typebox.Type.Object({
9227
9378
  slash_commands: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(slashCommandSchema)),
9228
9379
  serve_endpoint: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
9229
9380
  default_dispatch_policy: __sinclair_typebox.Type.Optional(dispatchPolicySchema),
9230
- permission_grants: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(typePermissionGrantInputSchema))
9381
+ permission_grants: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(typePermissionGrantInputSchema)),
9382
+ externally_writable_collections: __sinclair_typebox.Type.Optional(externallyWritableCollectionsSchema)
9231
9383
  }, { additionalProperties: false });
9232
9384
  const amendEntityTypeSchemasBodySchema = __sinclair_typebox.Type.Object({
9233
9385
  inbox_schemas: __sinclair_typebox.Type.Optional(schemaMapSchema),
@@ -9358,7 +9510,20 @@ function parseExpiresAt(value) {
9358
9510
  if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
9359
9511
  return expiresAt;
9360
9512
  }
9513
+ /**
9514
+ * The `comments` collection name is reserved for the canonical comments
9515
+ * contract: the UI keys its comment affordances on it, so a divergent
9516
+ * collection registered under that name (or the contract mounted under
9517
+ * another name) would break that assumption silently.
9518
+ */
9519
+ function validateExternallyWritableCollections(collections) {
9520
+ for (const [name, config] of Object.entries(collections ?? {})) {
9521
+ if (name === `comments` && config.contract !== __electric_ax_agents_runtime.COMMENTS_CONTRACT) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The externally-writable collection name "comments" is reserved for the "${__electric_ax_agents_runtime.COMMENTS_CONTRACT}" contract`, 400);
9522
+ if (config.contract === __electric_ax_agents_runtime.COMMENTS_CONTRACT && name !== `comments`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The "${__electric_ax_agents_runtime.COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`, 400);
9523
+ }
9524
+ }
9361
9525
  function normalizeEntityTypeRequest(parsed) {
9526
+ validateExternallyWritableCollections(parsed.externally_writable_collections);
9362
9527
  const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
9363
9528
  return {
9364
9529
  name: parsed.name ?? ``,
@@ -9372,7 +9537,8 @@ function normalizeEntityTypeRequest(parsed) {
9372
9537
  type: `webhook`,
9373
9538
  url: serveEndpoint
9374
9539
  }] } : void 0),
9375
- permission_grants: parsed.permission_grants
9540
+ permission_grants: parsed.permission_grants,
9541
+ externally_writable_collections: parsed.externally_writable_collections
9376
9542
  };
9377
9543
  }
9378
9544
  function toPublicEntityType(entityType) {
@@ -9385,7 +9551,7 @@ function toPublicEntityType(entityType) {
9385
9551
  //#endregion
9386
9552
  //#region src/routing/pg-sync-router.ts
9387
9553
  const pgSyncOptionsSchema = __sinclair_typebox.Type.Object({
9388
- url: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
9554
+ url: __sinclair_typebox.Type.String(),
9389
9555
  table: __sinclair_typebox.Type.String(),
9390
9556
  columns: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(__sinclair_typebox.Type.String())),
9391
9557
  where: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
@@ -9407,6 +9573,7 @@ const pgSyncRouter = (0, itty_router.Router)({ base: `/_electric/pg-sync` });
9407
9573
  pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
9408
9574
  async function registerPgSync(request, ctx) {
9409
9575
  const { options, metadata } = routeBody(request);
9576
+ if (options.url.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync url must be non-empty`);
9410
9577
  if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
9411
9578
  if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
9412
9579
  try {
@@ -9421,6 +9588,8 @@ async function registerPgSync(request, ctx) {
9421
9588
  const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
9422
9589
  return (0, itty_router.json)(result);
9423
9590
  } catch (error) {
9591
+ if (error instanceof PgSyncSourceValidationError) return apiError(400, ErrCodeInvalidRequest, error.message);
9592
+ serverLog.error(`[pg-sync] registration failed for table "${options.table}":`, error);
9424
9593
  return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
9425
9594
  }
9426
9595
  }
@@ -9945,7 +10114,7 @@ const wakeCallbackBodySchema = __sinclair_typebox.Type.Object({
9945
10114
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
9946
10115
  const internalRouter = (0, itty_router.Router)({ base: `/_electric` });
9947
10116
  internalRouter.get(`/health`, () => (0, itty_router.json)({ status: `ok` }));
9948
- internalRouter.get(`/event-sources`, listEventSources);
10117
+ internalRouter.get(`/webhook-sources`, listWebhookSources);
9949
10118
  internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
9950
10119
  internalRouter.post(`/subscription-webhooks/:subscriptionId`, subscriptionWebhook);
9951
10120
  internalRouter.post(`/wake-callbacks/:consumerId`, wakeCallback);
@@ -10067,11 +10236,11 @@ async function registerWake(request, ctx) {
10067
10236
  await ctx.entityManager.registerWake(opts);
10068
10237
  return (0, itty_router.status)(204);
10069
10238
  }
10070
- async function listEventSources(_request, ctx) {
10071
- const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
10072
- return (0, itty_router.json)({ eventSources: eventSources.filter(isAgentVisibleEventSource) });
10239
+ async function listWebhookSources(_request, ctx) {
10240
+ const webhookSources = ctx.webhookSources ? await ctx.webhookSources.listWebhookSources() : [];
10241
+ return (0, itty_router.json)({ webhookSources: webhookSources.filter(isAgentVisibleWebhookSource) });
10073
10242
  }
10074
- function isAgentVisibleEventSource(source) {
10243
+ function isAgentVisibleWebhookSource(source) {
10075
10244
  return source.agentVisible === true && source.status === `active`;
10076
10245
  }
10077
10246
  async function subscriptionWebhook(request, ctx) {