@electric-ax/agents-server 0.4.18 → 0.4.20

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, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
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";
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";
@@ -32,6 +32,7 @@ __export(schema_exports, {
32
32
  entityPermissionGrants: () => entityPermissionGrants,
33
33
  entityTypePermissionGrants: () => entityTypePermissionGrants,
34
34
  entityTypes: () => entityTypes,
35
+ pgSyncBridges: () => pgSyncBridges,
35
36
  runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
36
37
  runners: () => runners,
37
38
  scheduledTasks: () => scheduledTasks,
@@ -353,6 +354,18 @@ const scheduledTasks = pgTable(`scheduled_tasks`, {
353
354
  index(`idx_scheduled_tasks_manifest_pending`).on(table.tenantId, table.ownerEntityUrl, table.manifestKey).where(sql`${table.kind} = 'delayed_send' AND ${table.completedAt} IS NULL AND ${table.manifestKey} IS NOT NULL`),
354
355
  index(`idx_scheduled_tasks_stale_claims`).on(table.tenantId, table.claimedAt).where(sql`${table.completedAt} IS NULL AND ${table.claimedAt} IS NOT NULL`)
355
356
  ]);
357
+ const pgSyncBridges = pgTable(`pg_sync_bridges`, {
358
+ tenantId: text(`tenant_id`).notNull().default(`default`),
359
+ sourceRef: text(`source_ref`).notNull(),
360
+ options: jsonb(`options`).notNull(),
361
+ streamUrl: text(`stream_url`).notNull(),
362
+ shapeHandle: text(`shape_handle`),
363
+ shapeOffset: text(`shape_offset`),
364
+ initialSnapshotComplete: boolean(`initial_snapshot_complete`).notNull().default(false),
365
+ lastTouchedAt: timestamp(`last_touched_at`, { withTimezone: true }).notNull().defaultNow(),
366
+ createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
367
+ updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
368
+ }, (table) => [primaryKey({ columns: [table.tenantId, table.sourceRef] }), unique(`uq_pg_sync_bridges_stream_url`).on(table.tenantId, table.streamUrl)]);
356
369
  const entityBridges = pgTable(`entity_bridges`, {
357
370
  tenantId: text(`tenant_id`).notNull().default(`default`),
358
371
  sourceRef: text(`source_ref`).notNull(),
@@ -813,6 +826,9 @@ var PostgresRegistry = class {
813
826
  entityBridgeWhere(sourceRef) {
814
827
  return and(eq(entityBridges.tenantId, this.tenantId), eq(entityBridges.sourceRef, sourceRef));
815
828
  }
829
+ pgSyncBridgeWhere(sourceRef) {
830
+ return and(eq(pgSyncBridges.tenantId, this.tenantId), eq(pgSyncBridges.sourceRef, sourceRef));
831
+ }
816
832
  async createEntityType(et) {
817
833
  await this.db.insert(entityTypes).values({
818
834
  tenantId: this.tenantId,
@@ -1282,11 +1298,12 @@ var PostgresRegistry = class {
1282
1298
  };
1283
1299
  const nextTags = normalizeTags(mutation.nextTags);
1284
1300
  const updatedAt = Date.now();
1285
- await tx.update(entities).set({
1301
+ const [updateResult] = await tx.update(entities).set({
1286
1302
  tags: nextTags,
1287
1303
  tagsIndex: buildTagsIndex(nextTags),
1288
1304
  updatedAt
1289
- }).where(this.entityWhere(url));
1305
+ }).where(this.entityWhere(url)).returning({ txid: sql`pg_current_xact_id()::xid::text` });
1306
+ const txid = updateResult ? parseInt(updateResult.txid) : void 0;
1290
1307
  await tx.insert(tagStreamOutbox).values({
1291
1308
  tenantId: this.tenantId,
1292
1309
  entityUrl: url,
@@ -1304,10 +1321,63 @@ var PostgresRegistry = class {
1304
1321
  return {
1305
1322
  entity,
1306
1323
  changed: true,
1307
- ...op === `insert` || op === `update` ? { op } : {}
1324
+ ...op === `insert` || op === `update` ? { op } : {},
1325
+ ...txid !== void 0 ? { txid } : {}
1308
1326
  };
1309
1327
  });
1310
1328
  }
1329
+ async upsertPgSyncBridge(row) {
1330
+ await this.db.insert(pgSyncBridges).values({
1331
+ tenantId: this.tenantId,
1332
+ sourceRef: row.sourceRef,
1333
+ options: row.options,
1334
+ streamUrl: row.streamUrl,
1335
+ lastTouchedAt: new Date(),
1336
+ updatedAt: new Date()
1337
+ }).onConflictDoUpdate({
1338
+ target: [pgSyncBridges.tenantId, pgSyncBridges.sourceRef],
1339
+ set: {
1340
+ options: row.options,
1341
+ streamUrl: row.streamUrl,
1342
+ initialSnapshotComplete: false,
1343
+ lastTouchedAt: new Date(),
1344
+ updatedAt: new Date()
1345
+ }
1346
+ });
1347
+ const existing = await this.getPgSyncBridge(row.sourceRef);
1348
+ if (!existing) throw new Error(`Failed to load pgSync bridge ${row.sourceRef}`);
1349
+ return existing;
1350
+ }
1351
+ async getPgSyncBridge(sourceRef) {
1352
+ const rows = await this.db.select().from(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef)).limit(1);
1353
+ return rows[0] ? this.rowToPgSyncBridge(rows[0]) : null;
1354
+ }
1355
+ async listPgSyncBridges(tenantId = this.tenantId) {
1356
+ const rows = tenantId === null ? await this.db.select().from(pgSyncBridges) : await this.db.select().from(pgSyncBridges).where(eq(pgSyncBridges.tenantId, tenantId));
1357
+ return rows.map((row) => this.rowToPgSyncBridge(row));
1358
+ }
1359
+ async touchPgSyncBridge(sourceRef) {
1360
+ await this.db.update(pgSyncBridges).set({
1361
+ lastTouchedAt: new Date(),
1362
+ updatedAt: new Date()
1363
+ }).where(this.pgSyncBridgeWhere(sourceRef));
1364
+ }
1365
+ async updatePgSyncBridgeCursor(sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete) {
1366
+ await this.db.update(pgSyncBridges).set({
1367
+ shapeHandle,
1368
+ shapeOffset,
1369
+ ...initialSnapshotComplete !== void 0 ? { initialSnapshotComplete } : {},
1370
+ updatedAt: new Date()
1371
+ }).where(this.pgSyncBridgeWhere(sourceRef));
1372
+ }
1373
+ async clearPgSyncBridgeCursor(sourceRef) {
1374
+ await this.db.update(pgSyncBridges).set({
1375
+ shapeHandle: null,
1376
+ shapeOffset: null,
1377
+ initialSnapshotComplete: false,
1378
+ updatedAt: new Date()
1379
+ }).where(this.pgSyncBridgeWhere(sourceRef));
1380
+ }
1311
1381
  async upsertEntityBridge(row) {
1312
1382
  await this.db.insert(entityBridges).values({
1313
1383
  tenantId: this.tenantId,
@@ -1527,6 +1597,20 @@ var PostgresRegistry = class {
1527
1597
  updated_at: row.updatedAt
1528
1598
  };
1529
1599
  }
1600
+ rowToPgSyncBridge(row) {
1601
+ return {
1602
+ tenantId: row.tenantId,
1603
+ sourceRef: row.sourceRef,
1604
+ options: row.options,
1605
+ streamUrl: row.streamUrl,
1606
+ shapeHandle: row.shapeHandle ?? void 0,
1607
+ shapeOffset: row.shapeOffset ?? void 0,
1608
+ initialSnapshotComplete: row.initialSnapshotComplete,
1609
+ lastTouchedAt: row.lastTouchedAt,
1610
+ createdAt: row.createdAt,
1611
+ updatedAt: row.updatedAt
1612
+ };
1613
+ }
1530
1614
  rowToEntityBridge(row) {
1531
1615
  return {
1532
1616
  tenantId: row.tenantId,
@@ -3174,6 +3258,9 @@ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
3174
3258
  function isRecord$1(value) {
3175
3259
  return typeof value === `object` && value !== null && !Array.isArray(value);
3176
3260
  }
3261
+ function getPgSyncManifestStreamPath(sourceRef) {
3262
+ return `/_electric/pg-sync/${sourceRef}`;
3263
+ }
3177
3264
  function extractManifestSourceUrl(manifest) {
3178
3265
  if (!manifest) return void 0;
3179
3266
  if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
@@ -3186,6 +3273,7 @@ function extractManifestSourceUrl(manifest) {
3186
3273
  }
3187
3274
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
3188
3275
  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;
3189
3277
  if (manifest.sourceType === `webhook`) {
3190
3278
  if (typeof config?.streamUrl === `string`) return config.streamUrl;
3191
3279
  if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
@@ -3300,6 +3388,13 @@ function isRecord(value) {
3300
3388
  function cloneRecord(value) {
3301
3389
  return JSON.parse(JSON.stringify(value));
3302
3390
  }
3391
+ function withOptionalTxid(entity, txid) {
3392
+ if (txid === void 0) return entity;
3393
+ return {
3394
+ ...entity,
3395
+ txid
3396
+ };
3397
+ }
3303
3398
  /**
3304
3399
  * Orchestrates the Electric Agents entity lifecycle: register types, spawn, send, kill.
3305
3400
  *
@@ -4441,15 +4536,17 @@ var EntityManager = class {
4441
4536
  await this.registry.updateStatus(entityUrl, `idle`);
4442
4537
  await this.entityBridgeManager?.onEntityChanged(entityUrl);
4443
4538
  }
4539
+ const txid = crypto.randomUUID();
4444
4540
  const envelope = entityStateSchema.inbox.insert({
4445
4541
  key,
4446
- value
4542
+ value,
4543
+ headers: { txid }
4447
4544
  });
4448
4545
  const encoded = this.encodeChangeEvent(envelope);
4449
4546
  try {
4450
4547
  if (opts?.producerId) {
4451
4548
  await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
4452
- return;
4549
+ return { txid };
4453
4550
  }
4454
4551
  await this.streamClient.append(entity.streams.main, encoded);
4455
4552
  if (entity.type === `principal` && req.type === `update_identity`) {
@@ -4457,9 +4554,11 @@ var EntityManager = class {
4457
4554
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent({
4458
4555
  type: `identity`,
4459
4556
  key: `self`,
4460
- value: identity
4557
+ value: identity,
4558
+ headers: { txid }
4461
4559
  }));
4462
4560
  }
4561
+ return { txid };
4463
4562
  } catch (err) {
4464
4563
  if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4465
4564
  throw err;
@@ -4480,18 +4579,26 @@ var EntityManager = class {
4480
4579
  if (req.status === `cancelled`) value.cancelled_at = now;
4481
4580
  }
4482
4581
  if (Object.keys(value).length === 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `No inbox fields to update`, 400);
4582
+ const txid = crypto.randomUUID();
4483
4583
  const envelope = entityStateSchema.inbox.update({
4484
4584
  key,
4485
- value
4585
+ value,
4586
+ headers: { txid }
4486
4587
  });
4487
4588
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
4589
+ return { txid };
4488
4590
  }
4489
4591
  async deleteInboxMessage(entityUrl, key) {
4490
4592
  const entity = await this.registry.getEntity(entityUrl);
4491
4593
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4492
4594
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4493
- const envelope = entityStateSchema.inbox.delete({ key });
4595
+ const txid = crypto.randomUUID();
4596
+ const envelope = entityStateSchema.inbox.delete({
4597
+ key,
4598
+ headers: { txid }
4599
+ });
4494
4600
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
4601
+ return { txid };
4495
4602
  }
4496
4603
  isAttachmentStreamPath(path$1) {
4497
4604
  return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$1);
@@ -4580,28 +4687,26 @@ var EntityManager = class {
4580
4687
  await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
4581
4688
  return { txid };
4582
4689
  }
4583
- async setTag(entityUrl, key, req, token) {
4690
+ async setTag(entityUrl, key, req) {
4584
4691
  const entity = await this.registry.getEntity(entityUrl);
4585
4692
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4586
- if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
4587
4693
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4588
4694
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
4589
4695
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
4590
4696
  const updated = result.entity;
4591
4697
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag write`, 500);
4592
4698
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4593
- return updated;
4699
+ return withOptionalTxid(updated, result.txid);
4594
4700
  }
4595
- async deleteTag(entityUrl, key, token) {
4701
+ async deleteTag(entityUrl, key) {
4596
4702
  const entity = await this.registry.getEntity(entityUrl);
4597
4703
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4598
- if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
4599
4704
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4600
4705
  const result = await this.registry.removeEntityTag(entityUrl, key);
4601
4706
  const updated = result.entity;
4602
4707
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
4603
4708
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4604
- return updated;
4709
+ return withOptionalTxid(updated, result.txid);
4605
4710
  }
4606
4711
  async ensureEntitiesMembershipStream(tags, principal) {
4607
4712
  if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
@@ -5397,6 +5502,9 @@ function isPermanentElectricAgentsError(err) {
5397
5502
  const name = typeof err === `object` && err !== null && `name` in err ? err.name : void 0;
5398
5503
  return name === `ElectricAgentsError` && typeof status$1 === `number` && status$1 >= 400 && status$1 < 500;
5399
5504
  }
5505
+ function cronTaskStreamPath(payload) {
5506
+ return typeof payload.streamPath === `string` ? payload.streamPath : null;
5507
+ }
5400
5508
  function normalizeTask(row) {
5401
5509
  return {
5402
5510
  id: Number(row.id),
@@ -5739,6 +5847,15 @@ var Scheduler = class {
5739
5847
  `;
5740
5848
  if (completed.length === 0) return;
5741
5849
  const nextFireAt = getNextCronFireAt(task.cronExpression, task.cronTimezone, task.fireAt);
5850
+ const streamPath = cronTaskStreamPath(task.payload);
5851
+ const subscriberRows = streamPath ? await sql$1`
5852
+ select 1 as exists
5853
+ from wake_registrations
5854
+ where tenant_id = ${tenantId}
5855
+ and source_url = ${streamPath}
5856
+ limit 1
5857
+ ` : [];
5858
+ if (subscriberRows.length === 0) return;
5742
5859
  await sql$1`
5743
5860
  insert into scheduled_tasks (
5744
5861
  tenant_id,
@@ -5838,6 +5955,308 @@ var Scheduler = class {
5838
5955
  }
5839
5956
  };
5840
5957
 
5958
+ //#endregion
5959
+ //#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`;
5961
+ const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
5962
+ const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
5963
+ function buildElectricShapeParams(options) {
5964
+ return {
5965
+ table: options.table,
5966
+ ...options.columns !== void 0 ? { columns: [...options.columns] } : {},
5967
+ ...options.where !== void 0 ? { where: options.where } : {},
5968
+ ...options.params !== void 0 ? { params: Array.isArray(options.params) ? [...options.params] : { ...options.params } } : {},
5969
+ ...options.replica !== void 0 ? { replica: options.replica } : {},
5970
+ ...options.metadata?.tenantId ? { electric_agents_tenant_id: options.metadata.tenantId } : {},
5971
+ ...options.metadata?.principalKind ? { electric_agents_principal_kind: options.metadata.principalKind } : {},
5972
+ ...options.metadata?.principalId ? { electric_agents_principal_id: options.metadata.principalId } : {},
5973
+ ...options.metadata?.principalKey ? { electric_agents_principal_key: options.metadata.principalKey } : {},
5974
+ ...options.metadata?.principalUrl ? { electric_agents_principal_url: options.metadata.principalUrl } : {},
5975
+ ...options.metadata?.entityUrl ? { electric_agents_entity_url: options.metadata.entityUrl } : {},
5976
+ ...options.metadata?.entityType ? { electric_agents_entity_type: options.metadata.entityType } : {},
5977
+ ...options.metadata?.streamPath ? { electric_agents_stream_path: options.metadata.streamPath } : {},
5978
+ ...options.metadata?.runtimeConsumerId ? { electric_agents_runtime_consumer_id: options.metadata.runtimeConsumerId } : {},
5979
+ ...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
5980
+ };
5981
+ }
5982
+ function jsonSafe(value) {
5983
+ if (typeof value === `bigint`) return value.toString();
5984
+ if (value === null || typeof value !== `object`) return value;
5985
+ if (Array.isArray(value)) return value.map(jsonSafe);
5986
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
5987
+ }
5988
+ function stableJson(value) {
5989
+ if (typeof value === `bigint`) return JSON.stringify(value.toString());
5990
+ if (value === null || typeof value !== `object`) return JSON.stringify(value);
5991
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`;
5992
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(`,`)}}`;
5993
+ }
5994
+ function parseElectricOffset(offset) {
5995
+ if (offset === `-1`) return offset;
5996
+ return /^\d+_\d+$/.test(offset) ? offset : null;
5997
+ }
5998
+ function rowKeyForMessage(message) {
5999
+ const headers = message.headers;
6000
+ const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
6001
+ return candidate === void 0 ? void 0 : stableJson(candidate);
6002
+ }
6003
+ function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
6004
+ const operation = message.headers.operation;
6005
+ 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);
6017
+ return {
6018
+ 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
+ },
6031
+ headers: {
6032
+ operation,
6033
+ timestamp: timestamp$1
6034
+ }
6035
+ };
6036
+ }
6037
+ function cursorFromRow(row) {
6038
+ return row?.shapeHandle && row.shapeOffset ? {
6039
+ handle: row.shapeHandle,
6040
+ offset: row.shapeOffset,
6041
+ initialSnapshotComplete: row.initialSnapshotComplete
6042
+ } : void 0;
6043
+ }
6044
+ var PgSyncBridge = class {
6045
+ producer = null;
6046
+ unsubscribe = null;
6047
+ abortController = null;
6048
+ skipChangesUntilUpToDate = false;
6049
+ recovering = false;
6050
+ committedCursor;
6051
+ retryAttempt = 0;
6052
+ constructor(sourceRef, streamUrl, options, resolvedSource, retry, streamClient, registry, evaluateWakes, initialCursor) {
6053
+ this.sourceRef = sourceRef;
6054
+ this.streamUrl = streamUrl;
6055
+ this.options = options;
6056
+ this.resolvedSource = resolvedSource;
6057
+ this.retry = retry;
6058
+ this.streamClient = streamClient;
6059
+ this.registry = registry;
6060
+ this.evaluateWakes = evaluateWakes;
6061
+ this.initialCursor = initialCursor;
6062
+ this.committedCursor = initialCursor;
6063
+ }
6064
+ async start() {
6065
+ if (!this.producer) this.producer = new IdempotentProducer(new DurableStream({
6066
+ url: `${this.streamClient.baseUrl}${this.streamUrl}`,
6067
+ contentType: `application/json`
6068
+ }), `pg-sync-bridge-${this.sourceRef}`);
6069
+ if (this.initialCursor) {
6070
+ const offset = parseElectricOffset(this.initialCursor.offset);
6071
+ if (offset) {
6072
+ this.startStream(offset, this.initialCursor.handle, !this.initialCursor.initialSnapshotComplete);
6073
+ return;
6074
+ }
6075
+ }
6076
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
6077
+ this.startStream(`now`, void 0, true);
6078
+ }
6079
+ async stop() {
6080
+ this.unsubscribe?.();
6081
+ this.abortController?.abort();
6082
+ this.unsubscribe = null;
6083
+ this.abortController = null;
6084
+ try {
6085
+ await this.producer?.flush();
6086
+ } finally {
6087
+ await this.producer?.detach();
6088
+ this.producer = null;
6089
+ }
6090
+ }
6091
+ startStream(offset, handle, skipChangesUntilUpToDate = false, log = offset === `now` ? `changes_only` : `full`) {
6092
+ this.unsubscribe?.();
6093
+ this.abortController?.abort();
6094
+ this.skipChangesUntilUpToDate = skipChangesUntilUpToDate;
6095
+ this.abortController = new AbortController();
6096
+ const stream = new ShapeStream({
6097
+ url: this.resolvedSource.url,
6098
+ params: buildElectricShapeParams(this.options),
6099
+ offset,
6100
+ log,
6101
+ ...handle ? { handle } : {},
6102
+ signal: this.abortController.signal
6103
+ });
6104
+ this.unsubscribe = stream.subscribe(async (messages) => {
6105
+ try {
6106
+ for (const message of messages) {
6107
+ if (isControlMessage(message)) {
6108
+ if (message.headers.control === `must-refetch`) {
6109
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
6110
+ this.startStream(`now`, void 0, true);
6111
+ return;
6112
+ }
6113
+ if (message.headers.control === `up-to-date`) {
6114
+ this.skipChangesUntilUpToDate = false;
6115
+ await this.persistCursor(stream, true);
6116
+ continue;
6117
+ }
6118
+ await this.persistCursor(stream);
6119
+ continue;
6120
+ }
6121
+ if (!isChangeMessage(message)) continue;
6122
+ if (!this.skipChangesUntilUpToDate) {
6123
+ const event = pgSyncMessageToDurableEvent(message, this.options);
6124
+ if (event) {
6125
+ if (!this.producer) throw new Error(`pg-sync producer is not started`);
6126
+ await this.producer.append(JSON.stringify(event));
6127
+ await this.producer.flush?.();
6128
+ await this.evaluateWakes?.(this.streamUrl, event);
6129
+ }
6130
+ }
6131
+ await this.persistCursor(stream);
6132
+ this.retryAttempt = 0;
6133
+ }
6134
+ } catch (error) {
6135
+ serverLog.warn(`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`, error);
6136
+ await this.recoverStream();
6137
+ }
6138
+ }, (error) => {
6139
+ if (this.abortController?.signal.aborted) return;
6140
+ serverLog.warn(`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`, error);
6141
+ this.recoverStream();
6142
+ });
6143
+ }
6144
+ async recoverStream() {
6145
+ if (this.recovering) return;
6146
+ this.recovering = true;
6147
+ try {
6148
+ const attempt = this.retryAttempt++;
6149
+ const baseDelay = Math.min(this.retry.initialDelayMs * 2 ** attempt, this.retry.maxDelayMs);
6150
+ const jitter = Math.floor(baseDelay * .2 * this.retry.random());
6151
+ const delay = baseDelay + jitter;
6152
+ if (delay > 0) await this.retry.sleep(delay);
6153
+ const offset = this.committedCursor ? parseElectricOffset(this.committedCursor.offset) : null;
6154
+ if (offset && this.committedCursor) this.startStream(offset, this.committedCursor.handle, !this.committedCursor.initialSnapshotComplete);
6155
+ else {
6156
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
6157
+ this.startStream(`now`, void 0, true);
6158
+ }
6159
+ } finally {
6160
+ this.recovering = false;
6161
+ }
6162
+ }
6163
+ async persistCursor(stream, initialSnapshotComplete = !this.skipChangesUntilUpToDate) {
6164
+ const shapeHandle = stream.shapeHandle;
6165
+ const shapeOffset = stream.lastOffset;
6166
+ if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return;
6167
+ await this.registry?.updatePgSyncBridgeCursor(this.sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete);
6168
+ this.committedCursor = {
6169
+ handle: shapeHandle,
6170
+ offset: shapeOffset,
6171
+ initialSnapshotComplete
6172
+ };
6173
+ }
6174
+ };
6175
+ var PgSyncBridgeManager = class {
6176
+ bridges = new Map();
6177
+ starting = new Map();
6178
+ url;
6179
+ retry;
6180
+ constructor(streamClient, evaluateWakes, registry, options = {}) {
6181
+ this.streamClient = streamClient;
6182
+ this.evaluateWakes = evaluateWakes;
6183
+ this.registry = registry;
6184
+ this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
6185
+ this.retry = {
6186
+ initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
6187
+ maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
6188
+ random: options.retry?.random ?? Math.random,
6189
+ sleep: options.retry?.sleep ?? ((ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)))
6190
+ };
6191
+ }
6192
+ async start() {
6193
+ const rows = await this.registry?.listPgSyncBridges?.();
6194
+ 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
+ })));
6198
+ }
6199
+ async register(options, metadata) {
6200
+ const mergedMetadata = {
6201
+ ...options.metadata,
6202
+ ...metadata
6203
+ };
6204
+ const canonicalOptions = {
6205
+ ...canonicalPgSyncOptions(options),
6206
+ ...Object.keys(mergedMetadata).length > 0 ? { metadata: mergedMetadata } : {}
6207
+ };
6208
+ const resolvedSource = this.resolveSource(canonicalOptions);
6209
+ const sourceRef = sourceRefForPgSync(canonicalOptions);
6210
+ const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId);
6211
+ const row = await this.registry?.upsertPgSyncBridge({
6212
+ sourceRef,
6213
+ options: canonicalOptions,
6214
+ streamUrl
6215
+ });
6216
+ await this.streamClient.ensure(streamUrl, { contentType: `application/json` });
6217
+ if (!this.bridges.has(sourceRef)) {
6218
+ let start = this.starting.get(sourceRef);
6219
+ if (!start) {
6220
+ start = (async () => {
6221
+ const bridge = new PgSyncBridge(sourceRef, streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
6222
+ await bridge.start();
6223
+ this.bridges.set(sourceRef, bridge);
6224
+ })().finally(() => this.starting.delete(sourceRef));
6225
+ this.starting.set(sourceRef, start);
6226
+ }
6227
+ await start;
6228
+ }
6229
+ return {
6230
+ sourceRef,
6231
+ streamUrl
6232
+ };
6233
+ }
6234
+ async ensureBridge(row) {
6235
+ if (this.bridges.has(row.sourceRef)) return;
6236
+ let start = this.starting.get(row.sourceRef);
6237
+ if (!start) {
6238
+ start = (async () => {
6239
+ await this.streamClient.ensure(row.streamUrl, { contentType: `application/json` });
6240
+ const canonicalOptions = canonicalPgSyncOptions(row.options);
6241
+ const resolvedSource = this.resolveSource(canonicalOptions);
6242
+ const bridge = new PgSyncBridge(row.sourceRef, row.streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
6243
+ await bridge.start();
6244
+ this.bridges.set(row.sourceRef, bridge);
6245
+ })().finally(() => this.starting.delete(row.sourceRef));
6246
+ this.starting.set(row.sourceRef, start);
6247
+ }
6248
+ await start;
6249
+ }
6250
+ resolveSource(options) {
6251
+ return { url: options.url ?? this.url };
6252
+ }
6253
+ async stop() {
6254
+ await Promise.allSettled(this.starting.values());
6255
+ await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()));
6256
+ this.bridges.clear();
6257
+ }
6258
+ };
6259
+
5841
6260
  //#endregion
5842
6261
  //#region src/runtime.ts
5843
6262
  function omitUndefined(value) {
@@ -5852,6 +6271,7 @@ var ElectricAgentsTenantRuntime = class {
5852
6271
  wakeRegistry;
5853
6272
  scheduler;
5854
6273
  entityBridgeManager;
6274
+ pgSyncBridgeManager;
5855
6275
  claimWriteTokens;
5856
6276
  manager;
5857
6277
  constructor(options) {
@@ -5876,9 +6296,10 @@ var ElectricAgentsTenantRuntime = class {
5876
6296
  writeTokenValidator: (entity, token) => this.claimWriteTokens.isValid(this.serviceId, entity.streams.main, token),
5877
6297
  stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false
5878
6298
  });
6299
+ this.pgSyncBridgeManager = options.pgSyncBridgeManager ?? new PgSyncBridgeManager(this.streamClient, (sourceUrl, event) => this.manager.evaluateWakes(sourceUrl, event), this.registry, options.pgSync);
5879
6300
  }
5880
6301
  async stop() {
5881
- await this.manager.shutdown();
6302
+ await Promise.all([this.manager.shutdown(), this.pgSyncBridgeManager.stop()]);
5882
6303
  }
5883
6304
  async rehydrateCronSchedules() {
5884
6305
  const rows = await this.db.select({ sourceUrl: wakeRegistrations.sourceUrl }).from(wakeRegistrations).where(eq(wakeRegistrations.tenantId, this.serviceId));
@@ -6642,7 +7063,10 @@ var WakeRegistry = class {
6642
7063
  }
6643
7064
  if (!isChangeMessage(message)) return;
6644
7065
  if (message.headers.operation === `delete`) {
6645
- this.removeCachedRegistrationByDbId(Number(message.key));
7066
+ const oldValue = message.old_value;
7067
+ const oldId = Number(oldValue?.id);
7068
+ if (Number.isFinite(oldId)) this.removeCachedRegistrationByDbId(oldId);
7069
+ else this.resetCachedRegistrations();
6646
7070
  return;
6647
7071
  }
6648
7072
  this.upsertCachedRegistration(this.normalizeShapeRow(message.value));
@@ -6753,9 +7177,9 @@ var WakeRegistry = class {
6753
7177
  matchCondition(reg, event) {
6754
7178
  if (reg.condition === `runFinished`) {
6755
7179
  if (event.type !== `run`) return null;
6756
- const value = event.value;
7180
+ const value$1 = event.value;
6757
7181
  const headers$1 = event.headers;
6758
- const status$1 = value?.status;
7182
+ const status$1 = value$1?.status;
6759
7183
  const operation$1 = headers$1?.operation;
6760
7184
  if (operation$1 !== `update`) return null;
6761
7185
  if (status$1 !== `completed` && status$1 !== `failed`) return null;
@@ -6778,13 +7202,15 @@ var WakeRegistry = class {
6778
7202
  }
6779
7203
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
6780
7204
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
7205
+ const value = event.value;
6781
7206
  const change = {
6782
7207
  collection: eventType,
6783
7208
  kind,
6784
7209
  key: event.key || ``
6785
7210
  };
7211
+ if (value && `value` in value) change.value = value.value;
7212
+ if (value && `oldValue` in value) change.oldValue = value.oldValue;
6786
7213
  if (eventType === `inbox`) {
6787
- const value = event.value;
6788
7214
  if (typeof value?.from === `string`) change.from = value.from;
6789
7215
  if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
6790
7216
  if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
@@ -7188,6 +7614,22 @@ function resolveDurableStreamsRoutingAdapter(adapter, _durableStreamsUrl) {
7188
7614
 
7189
7615
  //#endregion
7190
7616
  //#region src/utils/server-utils.ts
7617
+ /**
7618
+ * Raised when an Electric shape proxy request must be rejected for security
7619
+ * reasons (an un-scoped table, or a client `where` clause that could escape the
7620
+ * enforced per-tenant/per-principal scoping). The global `errorMapper` hook
7621
+ * maps this to an HTTP error response. Defined here (rather than reusing
7622
+ * `ElectricAgentsError`) to keep this module free of the heavy entity-manager
7623
+ * import graph.
7624
+ */
7625
+ var ElectricProxyError = class extends Error {
7626
+ constructor(code, message, status$1) {
7627
+ super(message);
7628
+ this.code = code;
7629
+ this.status = status$1;
7630
+ this.name = `ElectricProxyError`;
7631
+ }
7632
+ };
7191
7633
  function buildElectricProxyTarget(options) {
7192
7634
  const targetPath = options.incomingUrl.pathname.replace(`/_electric/electric`, ``);
7193
7635
  const target = electricUrlWithPath(options.electricUrl, targetPath);
@@ -7197,7 +7639,12 @@ function buildElectricProxyTarget(options) {
7197
7639
  applyElectricUrlQueryParams(target, options.electricUrl);
7198
7640
  if (targetPath !== `/v1/shape`) return target;
7199
7641
  if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
7200
- const table = options.incomingUrl.searchParams.get(`table`);
7642
+ const clientWhere = options.incomingUrl.searchParams.get(`where`);
7643
+ if (clientWhere !== null && !isSelfContainedWhereClause(clientWhere)) throw new ElectricProxyError(`INVALID_WHERE`, `Invalid where clause`, 400);
7644
+ const tableParams = options.incomingUrl.searchParams.getAll(`table`);
7645
+ if (tableParams.length !== 1) throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
7646
+ const table = tableParams[0];
7647
+ target.searchParams.set(`table`, table);
7201
7648
  if (table === `entities`) {
7202
7649
  target.searchParams.set(`columns`, `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","sandbox","parent","created_by","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`);
7203
7650
  applyShapeWhere(target, buildReadableEntitiesWhere({
@@ -7255,9 +7702,39 @@ function buildElectricProxyTarget(options) {
7255
7702
  principalKind: options.principalKind ?? ``,
7256
7703
  permissionBypass: options.permissionBypass
7257
7704
  }));
7258
- }
7705
+ } else throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
7259
7706
  return target;
7260
7707
  }
7708
+ /**
7709
+ * Returns true when a client-supplied Electric `where` clause is self-contained:
7710
+ * its parentheses are balanced, never close below the top level, all string
7711
+ * (`'`) and identifier (`"`) literals are terminated, and it contains no SQL
7712
+ * comment markers. Such a clause cannot break out of the `(...)` group it is
7713
+ * wrapped in when AND-combined with the enforced scoping predicate, nor comment
7714
+ * out the trailing paren the proxy appends. Characters inside string/identifier
7715
+ * literals are ignored. Comment markers are rejected unconditionally (even where
7716
+ * harmless) as a conservative defensive measure; dollar-quoted and `E''` strings
7717
+ * are not modeled and only ever cause fail-safe over-rejection, never a bypass.
7718
+ */
7719
+ function isSelfContainedWhereClause(where) {
7720
+ let depth = 0;
7721
+ let quote = null;
7722
+ for (let i = 0; i < where.length; i++) {
7723
+ const ch = where[i];
7724
+ if (quote !== null) {
7725
+ if (ch === quote) if (where[i + 1] === quote) i++;
7726
+ else quote = null;
7727
+ continue;
7728
+ }
7729
+ if (ch === `'` || ch === `"`) quote = ch;
7730
+ else if (ch === `(`) depth++;
7731
+ else if (ch === `)`) {
7732
+ depth--;
7733
+ if (depth < 0) return false;
7734
+ } else if (ch === `-` && where[i + 1] === `-` || ch === `/` && where[i + 1] === `*`) return false;
7735
+ }
7736
+ return depth === 0 && quote === null;
7737
+ }
7261
7738
  function buildReadableEntitiesWhere(options) {
7262
7739
  const tenant = sqlStringLiteral(options.tenantId);
7263
7740
  if (options.permissionBypass) return `tenant_id = ${tenant}`;
@@ -7893,7 +8370,10 @@ async function authorizeDurableStreamAccess(request, ctx) {
7893
8370
  }
7894
8371
  if (method === `PUT` || method === `POST`) {
7895
8372
  const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
7896
- if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) return void 0;
8373
+ if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) {
8374
+ if (ownerEntityUrl) await ctx.entityManager.registry.replaceSharedStateLink(ownerEntityUrl, `shared-state:${sharedStateId}`, sharedStateId);
8375
+ return void 0;
8376
+ }
7897
8377
  return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
7898
8378
  }
7899
8379
  return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
@@ -8262,7 +8742,7 @@ async function parseAttachmentForm(request) {
8262
8742
  };
8263
8743
  }
8264
8744
  function contentDisposition(filename) {
8265
- const fallback = filename.replace(/["\\\r\n]/g, `_`);
8745
+ const fallback = filename.replace(/[^\x20-\x7e]|["\\]/g, `_`);
8266
8746
  return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
8267
8747
  }
8268
8748
  function rejectPrincipalEntityMutation(request, action) {
@@ -8460,22 +8940,28 @@ async function deleteEventSourceSubscription(request, ctx) {
8460
8940
  const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
8461
8941
  return json(result);
8462
8942
  }
8943
+ function tagResponseBody(entity) {
8944
+ const publicEntity = toPublicEntity(entity);
8945
+ if (entity.txid !== void 0) return {
8946
+ ...publicEntity,
8947
+ txid: entity.txid
8948
+ };
8949
+ return publicEntity;
8950
+ }
8463
8951
  async function setTag(request, ctx) {
8464
8952
  const principalMutationError = rejectPrincipalEntityMutation(request, `tag updated`);
8465
8953
  if (principalMutationError) return principalMutationError;
8466
8954
  const parsed = routeBody(request);
8467
8955
  const { entityUrl } = requireExistingEntityRoute(request);
8468
- const token = writeTokenFromRequest(request);
8469
- const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value }, token);
8470
- return json(toPublicEntity(updated));
8956
+ const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value });
8957
+ return json(tagResponseBody(updated));
8471
8958
  }
8472
8959
  async function deleteTag(request, ctx) {
8473
8960
  const principalMutationError = rejectPrincipalEntityMutation(request, `tag deleted`);
8474
8961
  if (principalMutationError) return principalMutationError;
8475
8962
  const { entityUrl } = requireExistingEntityRoute(request);
8476
- const token = writeTokenFromRequest(request);
8477
- const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey), token);
8478
- return json(toPublicEntity(updated));
8963
+ const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey));
8964
+ return json(tagResponseBody(updated));
8479
8965
  }
8480
8966
  async function forkEntity(request, ctx) {
8481
8967
  const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
@@ -8542,9 +9028,12 @@ async function sendEntity(request, ctx) {
8542
9028
  mode: parsed.mode,
8543
9029
  position: parsed.position
8544
9030
  };
8545
- if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
8546
- else await ctx.entityManager.send(entityUrl, sendReq);
8547
- return status(204);
9031
+ if (parsed.afterMs && parsed.afterMs > 0) {
9032
+ await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
9033
+ return status(204);
9034
+ }
9035
+ const result = await ctx.entityManager.send(entityUrl, sendReq);
9036
+ return json(result);
8548
9037
  }
8549
9038
  async function createAttachment(request, ctx) {
8550
9039
  const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
@@ -8587,13 +9076,13 @@ async function deleteAttachment(request, ctx) {
8587
9076
  async function updateInboxMessage(request, ctx) {
8588
9077
  const parsed = routeBody(request);
8589
9078
  const { entityUrl } = requireExistingEntityRoute(request);
8590
- await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
8591
- return status(204);
9079
+ const result = await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
9080
+ return json(result);
8592
9081
  }
8593
9082
  async function deleteInboxMessage(request, ctx) {
8594
9083
  const { entityUrl } = requireExistingEntityRoute(request);
8595
- await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
8596
- return status(204);
9084
+ const result = await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
9085
+ return json(result);
8597
9086
  }
8598
9087
  async function spawnEntity(request, ctx) {
8599
9088
  const parsed = routeBody(request);
@@ -8864,6 +9353,49 @@ function toPublicEntityType(entityType) {
8864
9353
  };
8865
9354
  }
8866
9355
 
9356
+ //#endregion
9357
+ //#region src/routing/pg-sync-router.ts
9358
+ const pgSyncOptionsSchema = Type.Object({
9359
+ url: Type.Optional(Type.String()),
9360
+ table: Type.String(),
9361
+ columns: Type.Optional(Type.Array(Type.String())),
9362
+ where: Type.Optional(Type.String()),
9363
+ params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
9364
+ replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)]))
9365
+ });
9366
+ const pgSyncRequestMetadataSchema = Type.Object({
9367
+ entityUrl: Type.Optional(Type.String()),
9368
+ entityType: Type.Optional(Type.String()),
9369
+ streamPath: Type.Optional(Type.String()),
9370
+ runtimeConsumerId: Type.Optional(Type.String()),
9371
+ wakeId: Type.Optional(Type.String())
9372
+ });
9373
+ const pgSyncRegisterBodySchema = Type.Object({
9374
+ options: pgSyncOptionsSchema,
9375
+ metadata: Type.Optional(pgSyncRequestMetadataSchema)
9376
+ });
9377
+ const pgSyncRouter = Router({ base: `/_electric/pg-sync` });
9378
+ pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
9379
+ async function registerPgSync(request, ctx) {
9380
+ const { options, metadata } = routeBody(request);
9381
+ if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
9382
+ if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
9383
+ try {
9384
+ const requestMetadata$1 = {
9385
+ tenantId: ctx.service,
9386
+ principalKind: ctx.principal.kind,
9387
+ principalId: ctx.principal.id,
9388
+ principalKey: ctx.principal.key,
9389
+ principalUrl: ctx.principal.url,
9390
+ ...metadata ?? {}
9391
+ };
9392
+ const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
9393
+ return json(result);
9394
+ } catch (error) {
9395
+ return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
9396
+ }
9397
+ }
9398
+
8867
9399
  //#endregion
8868
9400
  //#region src/routing/hooks.ts
8869
9401
  const SPAN_KEY = Symbol(`agents-server.otel-span`);
@@ -8938,6 +9470,10 @@ function errorMapper(err, req) {
8938
9470
  });
8939
9471
  }
8940
9472
  if (err instanceof ElectricAgentsError) return apiError(err.status, err.code, err.message, err.details);
9473
+ if (err instanceof ElectricProxyError) {
9474
+ serverLog.warn(`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`);
9475
+ return apiError(err.status, err.code, err.message);
9476
+ }
8941
9477
  serverLog.error(`[agent-server] Unhandled error:`, err);
8942
9478
  return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`);
8943
9479
  }
@@ -9388,6 +9924,7 @@ internalRouter.all(`/runners`, runnersRouter.fetch);
9388
9924
  internalRouter.all(`/runners/*`, runnersRouter.fetch);
9389
9925
  internalRouter.all(`/entities/*`, entitiesRouter.fetch);
9390
9926
  internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch);
9927
+ internalRouter.all(`/pg-sync/*`, pgSyncRouter.fetch);
9391
9928
  internalRouter.all(`/observations/*`, observationsRouter.fetch);
9392
9929
  internalRouter.get(`/electric/*`, electricProxyRouter.fetch);
9393
9930
  internalRouter.all(`*`, () => status(404));
@@ -9771,6 +10308,9 @@ const globalRouter = AutoRouter({
9771
10308
  finally: [otelEndSpan, applyCors]
9772
10309
  });
9773
10310
  globalRouter.all(`/_electric/shared-state/*`, durableStreamsRouter.fetch);
10311
+ globalRouter.all(`/_electric/pg-sync/register`, internalRouter.fetch);
10312
+ globalRouter.get(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
10313
+ globalRouter.head(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
9774
10314
  globalRouter.all(`/_electric/*`, internalRouter.fetch);
9775
10315
  globalRouter.all(`*`, durableStreamsRouter.fetch);
9776
10316