@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.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, buildTagsIndex, buildWebhookSourceManifestEntry, canonicalPgSyncOptions, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveWebhookSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature, webhookSourceSubscriptionManifestKey } 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,
@@ -1339,7 +1344,6 @@ var PostgresRegistry = class {
1339
1344
  set: {
1340
1345
  options: row.options,
1341
1346
  streamUrl: row.streamUrl,
1342
- initialSnapshotComplete: false,
1343
1347
  lastTouchedAt: new Date(),
1344
1348
  updatedAt: new Date()
1345
1349
  }
@@ -1378,6 +1382,9 @@ var PostgresRegistry = class {
1378
1382
  updatedAt: new Date()
1379
1383
  }).where(this.pgSyncBridgeWhere(sourceRef));
1380
1384
  }
1385
+ async deletePgSyncBridge(sourceRef) {
1386
+ await this.db.delete(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef));
1387
+ }
1381
1388
  async upsertEntityBridge(row) {
1382
1389
  await this.db.insert(entityBridges).values({
1383
1390
  tenantId: this.tenantId,
@@ -1540,6 +1547,7 @@ var PostgresRegistry = class {
1540
1547
  creation_schema: row.creationSchema,
1541
1548
  inbox_schemas: row.inboxSchemas,
1542
1549
  state_schemas: row.stateSchemas,
1550
+ externally_writable_collections: row.externallyWritableCollections ?? void 0,
1543
1551
  slash_commands: row.slashCommands ?? void 0,
1544
1552
  serve_endpoint: row.serveEndpoint ?? void 0,
1545
1553
  default_dispatch_policy: row.defaultDispatchPolicy ?? void 0,
@@ -3258,9 +3266,6 @@ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
3258
3266
  function isRecord$1(value) {
3259
3267
  return typeof value === `object` && value !== null && !Array.isArray(value);
3260
3268
  }
3261
- function getPgSyncManifestStreamPath(sourceRef) {
3262
- return `/_electric/pg-sync/${sourceRef}`;
3263
- }
3264
3269
  function extractManifestSourceUrl(manifest) {
3265
3270
  if (!manifest) return void 0;
3266
3271
  if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
@@ -3273,7 +3278,7 @@ function extractManifestSourceUrl(manifest) {
3273
3278
  }
3274
3279
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
3275
3280
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
3276
- if (manifest.sourceType === `pgSync`) return typeof manifest.sourceRef === `string` ? getPgSyncManifestStreamPath(manifest.sourceRef) : void 0;
3281
+ if (manifest.sourceType === `pgSync`) return typeof config?.streamUrl === `string` ? config.streamUrl : void 0;
3277
3282
  if (manifest.sourceType === `webhook`) {
3278
3283
  if (typeof config?.streamUrl === `string`) return config.streamUrl;
3279
3284
  if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
@@ -3469,6 +3474,7 @@ var EntityManager = class {
3469
3474
  creation_schema: req.creation_schema,
3470
3475
  inbox_schemas: req.inbox_schemas,
3471
3476
  state_schemas: req.state_schemas,
3477
+ externally_writable_collections: req.externally_writable_collections,
3472
3478
  slash_commands: req.slash_commands,
3473
3479
  serve_endpoint: req.serve_endpoint,
3474
3480
  default_dispatch_policy: defaultDispatchPolicy,
@@ -4564,6 +4570,40 @@ var EntityManager = class {
4564
4570
  throw err;
4565
4571
  }
4566
4572
  }
4573
+ async writeCollection(entityUrl, collection, req) {
4574
+ const entity = await this.registry.getEntity(entityUrl);
4575
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4576
+ const { externallyWritableCollections } = await this.getEffectiveSchemas(entity);
4577
+ const config = externallyWritableCollections?.[collection];
4578
+ if (!config) throw new ElectricAgentsError(ErrCodeUnauthorized, `Collection "${collection}" is not writable`, 403);
4579
+ const allowedOperations = config.operations ?? [`insert`];
4580
+ if (!allowedOperations.includes(req.operation)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Operation "${req.operation}" is not allowed on collection "${collection}"`, 403);
4581
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4582
+ if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
4583
+ if (req.operation !== `delete` && (req.value === void 0 || req.value === null)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `value is required for ${req.operation}`, 400);
4584
+ if (req.operation !== `insert` && !req.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `key is required for ${req.operation}`, 400);
4585
+ const key = req.key ?? `${collection}-${randomUUID()}`;
4586
+ const event = {
4587
+ type: config.type,
4588
+ key,
4589
+ headers: {
4590
+ operation: req.operation,
4591
+ timestamp: new Date().toISOString(),
4592
+ principal: req.principal
4593
+ }
4594
+ };
4595
+ if (req.operation !== `delete`) event.value = req.value;
4596
+ const validationError = await this.validateWriteEvent(entity, event);
4597
+ if (validationError) throw new ElectricAgentsError(validationError.code, validationError.message, validationError.status);
4598
+ const encoded = this.encodeChangeEvent(event);
4599
+ try {
4600
+ await this.streamClient.append(entity.streams.main, encoded);
4601
+ } catch (err) {
4602
+ if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4603
+ throw err;
4604
+ }
4605
+ return { key };
4606
+ }
4567
4607
  async updateInboxMessage(entityUrl, key, req) {
4568
4608
  const entity = await this.registry.getEntity(entityUrl);
4569
4609
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
@@ -4830,7 +4870,7 @@ var EntityManager = class {
4830
4870
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
4831
4871
  return { txid };
4832
4872
  }
4833
- async upsertEventSourceSubscription(entityUrl, req) {
4873
+ async upsertWebhookSourceSubscription(entityUrl, req) {
4834
4874
  const manifestKey = req.subscription.manifestKey;
4835
4875
  const txid = randomUUID();
4836
4876
  await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
@@ -4852,8 +4892,20 @@ var EntityManager = class {
4852
4892
  subscription: req.subscription
4853
4893
  };
4854
4894
  }
4855
- async deleteEventSourceSubscription(entityUrl, req) {
4856
- const manifestKey = eventSourceSubscriptionManifestKey(req.id);
4895
+ async deleteWebhookSourceSubscription(entityUrl, req) {
4896
+ const manifestKey = webhookSourceSubscriptionManifestKey(req.id);
4897
+ const txid = randomUUID();
4898
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
4899
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
4900
+ return { txid };
4901
+ }
4902
+ /**
4903
+ * Stop this entity observing a pg-sync source: drop its manifest entry and
4904
+ * the wake it anchors. The shared pg-sync bridge (keyed by sourceRef, not by
4905
+ * subscriber) is intentionally left running for any other observers.
4906
+ */
4907
+ async deletePgSyncObservation(entityUrl, req) {
4908
+ const manifestKey = `source:pgSync:${req.sourceRef}`;
4857
4909
  const txid = randomUUID();
4858
4910
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
4859
4911
  await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
@@ -5256,7 +5308,8 @@ var EntityManager = class {
5256
5308
  async getEffectiveSchemas(entity) {
5257
5309
  if (!entity.type) return {
5258
5310
  inboxSchemas: entity.inbox_schemas,
5259
- stateSchemas: entity.state_schemas
5311
+ stateSchemas: entity.state_schemas,
5312
+ externallyWritableCollections: void 0
5260
5313
  };
5261
5314
  const latestType = await this.registry.getEntityType(entity.type);
5262
5315
  return {
@@ -5267,7 +5320,8 @@ var EntityManager = class {
5267
5320
  stateSchemas: latestType?.state_schemas ? {
5268
5321
  ...entity.state_schemas ?? {},
5269
5322
  ...latestType.state_schemas
5270
- } : entity.state_schemas
5323
+ } : entity.state_schemas,
5324
+ externallyWritableCollections: latestType?.externally_writable_collections
5271
5325
  };
5272
5326
  }
5273
5327
  isClosedStreamError(err) {
@@ -5957,9 +6011,13 @@ var Scheduler = class {
5957
6011
 
5958
6012
  //#endregion
5959
6013
  //#region src/pg-sync-bridge-manager.ts
5960
- const PG_SYNC_ELECTRIC_SHAPE_URL = process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ?? `http://localhost:3000/v1/shape`;
6014
+ /** Registration was rejected because the source itself is invalid — map to a 4xx. */
6015
+ var PgSyncSourceValidationError = class extends Error {
6016
+ name = `PgSyncSourceValidationError`;
6017
+ };
5961
6018
  const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
5962
6019
  const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
6020
+ const DEFAULT_PROBE_TIMEOUT_MS = 1e4;
5963
6021
  function buildElectricShapeParams(options) {
5964
6022
  return {
5965
6023
  table: options.table,
@@ -5979,6 +6037,31 @@ function buildElectricShapeParams(options) {
5979
6037
  ...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
5980
6038
  };
5981
6039
  }
6040
+ /**
6041
+ * Build the one-shot URL used to validate a shape source at registration
6042
+ * time. Approximates the query-param encoding of the Electric TS client
6043
+ * (arrays comma-joined, where-clause params as `params[n]`) — unlike the
6044
+ * client it does not quote column identifiers, so probe and stream encoding
6045
+ * can diverge for exotic column names.
6046
+ */
6047
+ function buildShapeProbeUrl(sourceUrl, options) {
6048
+ let url;
6049
+ try {
6050
+ url = new URL(sourceUrl);
6051
+ } catch {
6052
+ throw new PgSyncSourceValidationError(`pgSync url "${sourceUrl}" is not a valid URL`);
6053
+ }
6054
+ 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`);
6055
+ for (const [key, value] of Object.entries(buildElectricShapeParams(options))) {
6056
+ if (value === void 0 || value === null) continue;
6057
+ if (Array.isArray(value)) if (key === `params`) value.forEach((item, index$1) => url.searchParams.set(`params[${index$1 + 1}]`, String(item)));
6058
+ else url.searchParams.set(key, value.join(`,`));
6059
+ else if (typeof value === `object`) for (const [k, v] of Object.entries(value)) url.searchParams.set(`${key}[${k}]`, String(v));
6060
+ else url.searchParams.set(key, String(value));
6061
+ }
6062
+ url.searchParams.set(`offset`, `now`);
6063
+ return url;
6064
+ }
5982
6065
  function jsonSafe(value) {
5983
6066
  if (typeof value === `bigint`) return value.toString();
5984
6067
  if (value === null || typeof value !== `object`) return value;
@@ -6000,37 +6083,19 @@ function rowKeyForMessage(message) {
6000
6083
  const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
6001
6084
  return candidate === void 0 ? void 0 : stableJson(candidate);
6002
6085
  }
6003
- function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
6086
+ function pgSyncMessageToDurableEvent(message) {
6004
6087
  const operation = message.headers.operation;
6005
6088
  if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
6006
- const sourceRef = typeof optionsOrSourceRef === `string` ? optionsOrSourceRef : sourceRefForPgSync(optionsOrSourceRef);
6007
- const rowKey = rowKeyForMessage(message);
6008
- const offset = message.headers.offset;
6009
- if (typeof offset !== `string` || offset.length === 0) return null;
6010
- const messageKeyPart = offset;
6011
- const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
6012
- const timestamp$1 = new Date().toISOString();
6013
- const oldValue = message.old_value;
6014
- const safeValue = jsonSafe(message.value);
6015
- const safeOldValue = jsonSafe(oldValue);
6016
- const safeHeaders = jsonSafe(message.headers);
6089
+ const key = message.key ?? (typeof message.headers.key === `string` ? message.headers.key : void 0) ?? rowKeyForMessage(message);
6090
+ if (!key) return null;
6091
+ const safeMessage = jsonSafe(message);
6017
6092
  return {
6018
6093
  type: `pg_sync_change`,
6019
- key: messageKey,
6020
- value: {
6021
- key: messageKey,
6022
- table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
6023
- operation,
6024
- ...rowKey !== void 0 ? { rowKey } : {},
6025
- ...message.value !== void 0 ? { value: safeValue } : {},
6026
- ...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
6027
- headers: safeHeaders,
6028
- ...typeof offset === `string` ? { offset } : {},
6029
- receivedAt: timestamp$1
6030
- },
6094
+ key,
6095
+ value: safeMessage,
6031
6096
  headers: {
6032
- operation,
6033
- timestamp: timestamp$1
6097
+ ...jsonSafe(message.headers),
6098
+ operation
6034
6099
  }
6035
6100
  };
6036
6101
  }
@@ -6120,13 +6185,13 @@ var PgSyncBridge = class {
6120
6185
  }
6121
6186
  if (!isChangeMessage(message)) continue;
6122
6187
  if (!this.skipChangesUntilUpToDate) {
6123
- const event = pgSyncMessageToDurableEvent(message, this.options);
6188
+ const event = pgSyncMessageToDurableEvent(message);
6124
6189
  if (event) {
6125
6190
  if (!this.producer) throw new Error(`pg-sync producer is not started`);
6126
6191
  await this.producer.append(JSON.stringify(event));
6127
6192
  await this.producer.flush?.();
6128
6193
  await this.evaluateWakes?.(this.streamUrl, event);
6129
- }
6194
+ } else serverLog.warn(`[pg-sync-bridge] dropped change message for ${this.sourceRef} (unknown operation or missing row key):`, message.headers);
6130
6195
  }
6131
6196
  await this.persistCursor(stream);
6132
6197
  this.retryAttempt = 0;
@@ -6175,13 +6240,15 @@ var PgSyncBridge = class {
6175
6240
  var PgSyncBridgeManager = class {
6176
6241
  bridges = new Map();
6177
6242
  starting = new Map();
6178
- url;
6179
6243
  retry;
6244
+ fetchFn;
6245
+ probeTimeoutMs;
6180
6246
  constructor(streamClient, evaluateWakes, registry, options = {}) {
6181
6247
  this.streamClient = streamClient;
6182
6248
  this.evaluateWakes = evaluateWakes;
6183
6249
  this.registry = registry;
6184
- this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
6250
+ this.fetchFn = options.fetchFn;
6251
+ this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
6185
6252
  this.retry = {
6186
6253
  initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
6187
6254
  maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
@@ -6192,9 +6259,16 @@ var PgSyncBridgeManager = class {
6192
6259
  async start() {
6193
6260
  const rows = await this.registry?.listPgSyncBridges?.();
6194
6261
  if (!rows) return;
6195
- await Promise.all(rows.map((row) => this.ensureBridge(row).catch((error) => {
6196
- serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
6197
- })));
6262
+ await Promise.all(rows.map(async (row) => {
6263
+ if (!row.options.url) {
6264
+ serverLog.warn(`[pg-sync-bridge] deleting registration ${row.sourceRef}: it predates required source URLs; re-register the observation with an explicit Electric shape URL`);
6265
+ await this.registry?.deletePgSyncBridge?.(row.sourceRef);
6266
+ return;
6267
+ }
6268
+ await this.ensureBridge(row).catch((error) => {
6269
+ serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
6270
+ });
6271
+ }));
6198
6272
  }
6199
6273
  async register(options, metadata) {
6200
6274
  const mergedMetadata = {
@@ -6208,6 +6282,7 @@ var PgSyncBridgeManager = class {
6208
6282
  const resolvedSource = this.resolveSource(canonicalOptions);
6209
6283
  const sourceRef = sourceRefForPgSync(canonicalOptions);
6210
6284
  const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId);
6285
+ if (!this.bridges.has(sourceRef) && !this.starting.has(sourceRef)) await this.probeSource(resolvedSource, canonicalOptions);
6211
6286
  const row = await this.registry?.upsertPgSyncBridge({
6212
6287
  sourceRef,
6213
6288
  options: canonicalOptions,
@@ -6248,7 +6323,32 @@ var PgSyncBridgeManager = class {
6248
6323
  await start;
6249
6324
  }
6250
6325
  resolveSource(options) {
6251
- return { url: options.url ?? this.url };
6326
+ if (!options.url) throw new PgSyncSourceValidationError(`pgSync source url is required; no server default is configured`);
6327
+ return { url: options.url };
6328
+ }
6329
+ /**
6330
+ * One-shot fetch of the shape log before a bridge is created, so a bad
6331
+ * URL or rejected shape fails the registration instead of dying silently
6332
+ * in the bridge's retry loop.
6333
+ */
6334
+ async probeSource(source, options) {
6335
+ const probeUrl = buildShapeProbeUrl(source.url, options);
6336
+ const fetchFn = this.fetchFn ?? globalThis.fetch;
6337
+ let response;
6338
+ try {
6339
+ response = await fetchFn(probeUrl, { signal: AbortSignal.timeout(this.probeTimeoutMs) });
6340
+ } catch (error) {
6341
+ throw new PgSyncSourceValidationError(`pgSync source at ${source.url} is unreachable: ${error instanceof Error ? error.message : String(error)}`);
6342
+ }
6343
+ if (!response.ok) {
6344
+ const body = (await response.text().catch(() => `<failed to read body>`)).slice(0, 500);
6345
+ throw new PgSyncSourceValidationError(`pgSync source at ${source.url} rejected the shape request (${response.status})${body ? `: ${body}` : ``}`);
6346
+ }
6347
+ if (!response.headers.get(`electric-handle`)) {
6348
+ const suggestion = new URL(source.url);
6349
+ suggestion.pathname = `/v1/shape`;
6350
+ 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`);
6351
+ }
6252
6352
  }
6253
6353
  async stop() {
6254
6354
  await Promise.allSettled(this.starting.values());
@@ -7210,6 +7310,7 @@ var WakeRegistry = class {
7210
7310
  };
7211
7311
  if (value && `value` in value) change.value = value.value;
7212
7312
  if (value && `oldValue` in value) change.oldValue = value.oldValue;
7313
+ else if (value && `old_value` in value) change.oldValue = value.old_value;
7213
7314
  if (eventType === `inbox`) {
7214
7315
  if (typeof value?.from === `string`) change.from = value.from;
7215
7316
  if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
@@ -8505,6 +8606,15 @@ const spawnBodySchema = Type.Object({
8505
8606
  manifestKey: Type.Optional(Type.String())
8506
8607
  }))
8507
8608
  });
8609
+ const writeCollectionBodySchema = Type.Object({
8610
+ operation: Type.Union([
8611
+ Type.Literal(`insert`),
8612
+ Type.Literal(`update`),
8613
+ Type.Literal(`delete`)
8614
+ ]),
8615
+ key: Type.Optional(Type.String()),
8616
+ value: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
8617
+ }, { additionalProperties: false });
8508
8618
  const sendBodySchema = Type.Object({
8509
8619
  payload: Type.Optional(Type.Unknown()),
8510
8620
  key: Type.Optional(Type.String()),
@@ -8614,8 +8724,8 @@ const subscriptionLifetimeSchema = Type.Union([
8614
8724
  }),
8615
8725
  Type.Object({ kind: Type.Literal(`manual`) })
8616
8726
  ]);
8617
- const eventSourceSubscriptionBodySchema = Type.Object({
8618
- sourceKey: Type.String(),
8727
+ const webhookSourceSubscriptionBodySchema = Type.Object({
8728
+ webhookKey: Type.String(),
8619
8729
  bucketKey: Type.Optional(Type.String()),
8620
8730
  params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
8621
8731
  filterKey: Type.Optional(Type.String()),
@@ -8637,6 +8747,7 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermissi
8637
8747
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
8638
8748
  entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
8639
8749
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
8750
+ entitiesRouter.post(`/:type/:instanceId/collections/:collection`, withExistingEntity, withSchema(writeCollectionBodySchema), withEntityPermission(`write`), writeCollection);
8640
8751
  entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
8641
8752
  entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
8642
8753
  entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
@@ -8647,8 +8758,9 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
8647
8758
  entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withEntityPermission(`write`), deleteTag);
8648
8759
  entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), withEntityPermission(`schedule`), upsertSchedule);
8649
8760
  entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withEntityPermission(`schedule`), deleteSchedule);
8650
- entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertEventSourceSubscription);
8651
- entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withEntityPermission(`write`), deleteEventSourceSubscription);
8761
+ entitiesRouter.put(`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(webhookSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertWebhookSourceSubscription);
8762
+ entitiesRouter.delete(`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`, withExistingEntity, withEntityPermission(`write`), deleteWebhookSourceSubscription);
8763
+ entitiesRouter.delete(`/:type/:instanceId/pg-sync-observations/:sourceRef`, withExistingEntity, withEntityPermission(`write`), deletePgSyncObservation);
8652
8764
  entitiesRouter.get(`/:type/:instanceId/grants`, withExistingEntity, withEntityPermission(`manage`), listEntityPermissionGrants);
8653
8765
  entitiesRouter.post(`/:type/:instanceId/grants`, withExistingEntity, withSchema(entityPermissionGrantInputSchema), withEntityPermission(`manage`), createEntityPermissionGrant);
8654
8766
  entitiesRouter.delete(`/:type/:instanceId/grants/:grantId`, withExistingEntity, withEntityPermission(`manage`), deleteEntityPermissionGrant);
@@ -8899,22 +9011,22 @@ async function deleteSchedule(request, ctx) {
8899
9011
  const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
8900
9012
  return json(result);
8901
9013
  }
8902
- async function upsertEventSourceSubscription(request, ctx) {
8903
- const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
9014
+ async function upsertWebhookSourceSubscription(request, ctx) {
9015
+ const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to webhook sources`);
8904
9016
  if (principalMutationError) return principalMutationError;
8905
- const catalog = ctx.eventSources;
8906
- if (!catalog) return apiError(404, ErrCodeNotFound, `No event source catalog is configured`);
9017
+ const catalog = ctx.webhookSources;
9018
+ if (!catalog) return apiError(404, ErrCodeNotFound, `No webhook source catalog is configured`);
8907
9019
  const { entityUrl } = requireExistingEntityRoute(request);
8908
9020
  const parsed = routeBody(request);
8909
- const source = await catalog.getEventSource(parsed.sourceKey);
8910
- if (!source) return apiError(404, ErrCodeNotFound, `Event source "${parsed.sourceKey}" not found`);
9021
+ const source = await catalog.getWebhookSource(parsed.webhookKey);
9022
+ if (!source) return apiError(404, ErrCodeNotFound, `Webhook source "${parsed.webhookKey}" not found`);
8911
9023
  if (parsed.lifetime?.kind === `expires_at`) {
8912
9024
  const expiresAt = new Date(parsed.lifetime.at);
8913
9025
  if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
8914
9026
  }
8915
9027
  let resolved;
8916
9028
  try {
8917
- resolved = resolveEventSourceSubscription({
9029
+ resolved = resolveWebhookSourceSubscription({
8918
9030
  contract: source,
8919
9031
  entityUrl,
8920
9032
  request: {
@@ -8926,18 +9038,25 @@ async function upsertEventSourceSubscription(request, ctx) {
8926
9038
  } catch (error) {
8927
9039
  return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
8928
9040
  }
8929
- await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
8930
- const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
9041
+ await ctx.ensureWebhookSourceWakeSource?.(resolved.subscription.sourceUrl);
9042
+ const result = await ctx.entityManager.upsertWebhookSourceSubscription(entityUrl, {
8931
9043
  subscription: resolved.subscription,
8932
- manifest: buildEventSourceManifestEntry(resolved)
9044
+ manifest: buildWebhookSourceManifestEntry(resolved)
8933
9045
  });
8934
9046
  return json(result);
8935
9047
  }
8936
- async function deleteEventSourceSubscription(request, ctx) {
8937
- const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from event sources`);
9048
+ async function deleteWebhookSourceSubscription(request, ctx) {
9049
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from webhook sources`);
9050
+ if (principalMutationError) return principalMutationError;
9051
+ const { entityUrl } = requireExistingEntityRoute(request);
9052
+ const result = await ctx.entityManager.deleteWebhookSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
9053
+ return json(result);
9054
+ }
9055
+ async function deletePgSyncObservation(request, ctx) {
9056
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unobserved a pg-sync source`);
8938
9057
  if (principalMutationError) return principalMutationError;
8939
9058
  const { entityUrl } = requireExistingEntityRoute(request);
8940
- const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
9059
+ const result = await ctx.entityManager.deletePgSyncObservation(entityUrl, { sourceRef: decodeURIComponent(request.params.sourceRef) });
8941
9060
  return json(result);
8942
9061
  }
8943
9062
  function tagResponseBody(entity) {
@@ -9035,6 +9154,23 @@ async function sendEntity(request, ctx) {
9035
9154
  const result = await ctx.entityManager.send(entityUrl, sendReq);
9036
9155
  return json(result);
9037
9156
  }
9157
+ async function writeCollection(request, ctx) {
9158
+ const parsed = routeBody(request);
9159
+ await ctx.entityManager.ensurePrincipal(ctx.principal);
9160
+ const { entityUrl } = requireExistingEntityRoute(request);
9161
+ const collection = request.params.collection;
9162
+ const result = await ctx.entityManager.writeCollection(entityUrl, collection, {
9163
+ operation: parsed.operation,
9164
+ key: parsed.key,
9165
+ value: parsed.value,
9166
+ principal: {
9167
+ url: ctx.principal.url,
9168
+ kind: ctx.principal.kind,
9169
+ id: ctx.principal.id
9170
+ }
9171
+ });
9172
+ return json(result, { status: parsed.operation === `insert` ? 201 : 200 });
9173
+ }
9038
9174
  async function createAttachment(request, ctx) {
9039
9175
  const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
9040
9176
  if (principalMutationError) return principalMutationError;
@@ -9132,8 +9268,13 @@ async function spawnEntity(request, ctx) {
9132
9268
  headers: { "x-write-token": entity.write_token }
9133
9269
  });
9134
9270
  }
9135
- function getEntity(request) {
9136
- return json(toPublicEntity(requireExistingEntityRoute(request).entity));
9271
+ async function getEntity(request, ctx) {
9272
+ const { entity } = requireExistingEntityRoute(request);
9273
+ const entityType = entity.type ? await ctx.entityManager.registry.getEntityType(entity.type) : null;
9274
+ return json({
9275
+ ...toPublicEntity(entity),
9276
+ ...entityType?.externally_writable_collections && { externally_writable_collections: entityType.externally_writable_collections }
9277
+ });
9137
9278
  }
9138
9279
  function headEntity() {
9139
9280
  return status(200);
@@ -9168,6 +9309,16 @@ async function signalEntity(request, ctx) {
9168
9309
  //#region src/routing/entity-types-router.ts
9169
9310
  const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
9170
9311
  const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
9312
+ const externallyWritableCollectionsSchema = Type.Record(Type.String(), Type.Object({
9313
+ type: Type.String(),
9314
+ contract: Type.Optional(Type.String()),
9315
+ operations: Type.Optional(Type.Array(Type.Union([
9316
+ Type.Literal(`insert`),
9317
+ Type.Literal(`update`),
9318
+ Type.Literal(`delete`)
9319
+ ]))),
9320
+ principalColumn: Type.Optional(Type.String())
9321
+ }, { additionalProperties: false }));
9171
9322
  const slashCommandArgumentSchema = Type.Object({
9172
9323
  name: Type.String(),
9173
9324
  type: Type.Union([
@@ -9198,7 +9349,8 @@ const registerEntityTypeBodySchema = Type.Object({
9198
9349
  slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
9199
9350
  serve_endpoint: Type.Optional(Type.String()),
9200
9351
  default_dispatch_policy: Type.Optional(dispatchPolicySchema),
9201
- permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
9352
+ permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema)),
9353
+ externally_writable_collections: Type.Optional(externallyWritableCollectionsSchema)
9202
9354
  }, { additionalProperties: false });
9203
9355
  const amendEntityTypeSchemasBodySchema = Type.Object({
9204
9356
  inbox_schemas: Type.Optional(schemaMapSchema),
@@ -9329,7 +9481,20 @@ function parseExpiresAt(value) {
9329
9481
  if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
9330
9482
  return expiresAt;
9331
9483
  }
9484
+ /**
9485
+ * The `comments` collection name is reserved for the canonical comments
9486
+ * contract: the UI keys its comment affordances on it, so a divergent
9487
+ * collection registered under that name (or the contract mounted under
9488
+ * another name) would break that assumption silently.
9489
+ */
9490
+ function validateExternallyWritableCollections(collections) {
9491
+ for (const [name, config] of Object.entries(collections ?? {})) {
9492
+ 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);
9493
+ if (config.contract === COMMENTS_CONTRACT && name !== `comments`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The "${COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`, 400);
9494
+ }
9495
+ }
9332
9496
  function normalizeEntityTypeRequest(parsed) {
9497
+ validateExternallyWritableCollections(parsed.externally_writable_collections);
9333
9498
  const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
9334
9499
  return {
9335
9500
  name: parsed.name ?? ``,
@@ -9343,7 +9508,8 @@ function normalizeEntityTypeRequest(parsed) {
9343
9508
  type: `webhook`,
9344
9509
  url: serveEndpoint
9345
9510
  }] } : void 0),
9346
- permission_grants: parsed.permission_grants
9511
+ permission_grants: parsed.permission_grants,
9512
+ externally_writable_collections: parsed.externally_writable_collections
9347
9513
  };
9348
9514
  }
9349
9515
  function toPublicEntityType(entityType) {
@@ -9356,7 +9522,7 @@ function toPublicEntityType(entityType) {
9356
9522
  //#endregion
9357
9523
  //#region src/routing/pg-sync-router.ts
9358
9524
  const pgSyncOptionsSchema = Type.Object({
9359
- url: Type.Optional(Type.String()),
9525
+ url: Type.String(),
9360
9526
  table: Type.String(),
9361
9527
  columns: Type.Optional(Type.Array(Type.String())),
9362
9528
  where: Type.Optional(Type.String()),
@@ -9378,6 +9544,7 @@ const pgSyncRouter = Router({ base: `/_electric/pg-sync` });
9378
9544
  pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
9379
9545
  async function registerPgSync(request, ctx) {
9380
9546
  const { options, metadata } = routeBody(request);
9547
+ if (options.url.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync url must be non-empty`);
9381
9548
  if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
9382
9549
  if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
9383
9550
  try {
@@ -9392,6 +9559,8 @@ async function registerPgSync(request, ctx) {
9392
9559
  const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
9393
9560
  return json(result);
9394
9561
  } catch (error) {
9562
+ if (error instanceof PgSyncSourceValidationError) return apiError(400, ErrCodeInvalidRequest, error.message);
9563
+ serverLog.error(`[pg-sync] registration failed for table "${options.table}":`, error);
9395
9564
  return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
9396
9565
  }
9397
9566
  }
@@ -9916,7 +10085,7 @@ const wakeCallbackBodySchema = Type.Object({
9916
10085
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
9917
10086
  const internalRouter = Router({ base: `/_electric` });
9918
10087
  internalRouter.get(`/health`, () => json({ status: `ok` }));
9919
- internalRouter.get(`/event-sources`, listEventSources);
10088
+ internalRouter.get(`/webhook-sources`, listWebhookSources);
9920
10089
  internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
9921
10090
  internalRouter.post(`/subscription-webhooks/:subscriptionId`, subscriptionWebhook);
9922
10091
  internalRouter.post(`/wake-callbacks/:consumerId`, wakeCallback);
@@ -10038,11 +10207,11 @@ async function registerWake(request, ctx) {
10038
10207
  await ctx.entityManager.registerWake(opts);
10039
10208
  return status(204);
10040
10209
  }
10041
- async function listEventSources(_request, ctx) {
10042
- const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
10043
- return json({ eventSources: eventSources.filter(isAgentVisibleEventSource) });
10210
+ async function listWebhookSources(_request, ctx) {
10211
+ const webhookSources = ctx.webhookSources ? await ctx.webhookSources.listWebhookSources() : [];
10212
+ return json({ webhookSources: webhookSources.filter(isAgentVisibleWebhookSource) });
10044
10213
  }
10045
- function isAgentVisibleEventSource(source) {
10214
+ function isAgentVisibleWebhookSource(source) {
10046
10215
  return source.agentVisible === true && source.status === `active`;
10047
10216
  }
10048
10217
  async function subscriptionWebhook(request, ctx) {
@@ -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
  }