@electric-ax/agents-server 0.4.19 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -61,6 +61,7 @@ __export(schema_exports, {
61
61
  entityPermissionGrants: () => entityPermissionGrants,
62
62
  entityTypePermissionGrants: () => entityTypePermissionGrants,
63
63
  entityTypes: () => entityTypes,
64
+ pgSyncBridges: () => pgSyncBridges,
64
65
  runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
65
66
  runners: () => runners,
66
67
  scheduledTasks: () => scheduledTasks,
@@ -78,6 +79,7 @@ const entityTypes = (0, drizzle_orm_pg_core.pgTable)(`entity_types`, {
78
79
  creationSchema: (0, drizzle_orm_pg_core.jsonb)(`creation_schema`),
79
80
  inboxSchemas: (0, drizzle_orm_pg_core.jsonb)(`inbox_schemas`),
80
81
  stateSchemas: (0, drizzle_orm_pg_core.jsonb)(`state_schemas`),
82
+ externallyWritableCollections: (0, drizzle_orm_pg_core.jsonb)(`externally_writable_collections`).$type(),
81
83
  slashCommands: (0, drizzle_orm_pg_core.jsonb)(`slash_commands`),
82
84
  serveEndpoint: (0, drizzle_orm_pg_core.text)(`serve_endpoint`),
83
85
  defaultDispatchPolicy: (0, drizzle_orm_pg_core.jsonb)(`default_dispatch_policy`),
@@ -382,6 +384,18 @@ const scheduledTasks = (0, drizzle_orm_pg_core.pgTable)(`scheduled_tasks`, {
382
384
  (0, drizzle_orm_pg_core.index)(`idx_scheduled_tasks_manifest_pending`).on(table.tenantId, table.ownerEntityUrl, table.manifestKey).where(drizzle_orm.sql`${table.kind} = 'delayed_send' AND ${table.completedAt} IS NULL AND ${table.manifestKey} IS NOT NULL`),
383
385
  (0, drizzle_orm_pg_core.index)(`idx_scheduled_tasks_stale_claims`).on(table.tenantId, table.claimedAt).where(drizzle_orm.sql`${table.completedAt} IS NULL AND ${table.claimedAt} IS NOT NULL`)
384
386
  ]);
387
+ const pgSyncBridges = (0, drizzle_orm_pg_core.pgTable)(`pg_sync_bridges`, {
388
+ tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
389
+ sourceRef: (0, drizzle_orm_pg_core.text)(`source_ref`).notNull(),
390
+ options: (0, drizzle_orm_pg_core.jsonb)(`options`).notNull(),
391
+ streamUrl: (0, drizzle_orm_pg_core.text)(`stream_url`).notNull(),
392
+ shapeHandle: (0, drizzle_orm_pg_core.text)(`shape_handle`),
393
+ shapeOffset: (0, drizzle_orm_pg_core.text)(`shape_offset`),
394
+ initialSnapshotComplete: (0, drizzle_orm_pg_core.boolean)(`initial_snapshot_complete`).notNull().default(false),
395
+ lastTouchedAt: (0, drizzle_orm_pg_core.timestamp)(`last_touched_at`, { withTimezone: true }).notNull().defaultNow(),
396
+ createdAt: (0, drizzle_orm_pg_core.timestamp)(`created_at`, { withTimezone: true }).notNull().defaultNow(),
397
+ updatedAt: (0, drizzle_orm_pg_core.timestamp)(`updated_at`, { withTimezone: true }).notNull().defaultNow()
398
+ }, (table) => [(0, drizzle_orm_pg_core.primaryKey)({ columns: [table.tenantId, table.sourceRef] }), (0, drizzle_orm_pg_core.unique)(`uq_pg_sync_bridges_stream_url`).on(table.tenantId, table.streamUrl)]);
385
399
  const entityBridges = (0, drizzle_orm_pg_core.pgTable)(`entity_bridges`, {
386
400
  tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
387
401
  sourceRef: (0, drizzle_orm_pg_core.text)(`source_ref`).notNull(),
@@ -842,6 +856,9 @@ var PostgresRegistry = class {
842
856
  entityBridgeWhere(sourceRef) {
843
857
  return (0, drizzle_orm.and)((0, drizzle_orm.eq)(entityBridges.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityBridges.sourceRef, sourceRef));
844
858
  }
859
+ pgSyncBridgeWhere(sourceRef) {
860
+ return (0, drizzle_orm.and)((0, drizzle_orm.eq)(pgSyncBridges.tenantId, this.tenantId), (0, drizzle_orm.eq)(pgSyncBridges.sourceRef, sourceRef));
861
+ }
845
862
  async createEntityType(et) {
846
863
  await this.db.insert(entityTypes).values({
847
864
  tenantId: this.tenantId,
@@ -850,6 +867,7 @@ var PostgresRegistry = class {
850
867
  creationSchema: et.creation_schema ?? null,
851
868
  inboxSchemas: et.inbox_schemas ?? null,
852
869
  stateSchemas: et.state_schemas ?? null,
870
+ externallyWritableCollections: et.externally_writable_collections ?? null,
853
871
  slashCommands: et.slash_commands ?? null,
854
872
  serveEndpoint: et.serve_endpoint ?? null,
855
873
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -863,6 +881,7 @@ var PostgresRegistry = class {
863
881
  creationSchema: et.creation_schema ?? null,
864
882
  inboxSchemas: et.inbox_schemas ?? null,
865
883
  stateSchemas: et.state_schemas ?? null,
884
+ externallyWritableCollections: et.externally_writable_collections ?? null,
866
885
  slashCommands: et.slash_commands ?? null,
867
886
  serveEndpoint: et.serve_endpoint ?? null,
868
887
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -881,6 +900,7 @@ var PostgresRegistry = class {
881
900
  creationSchema: et.creation_schema ?? null,
882
901
  inboxSchemas: et.inbox_schemas ?? null,
883
902
  stateSchemas: et.state_schemas ?? null,
903
+ externallyWritableCollections: et.externally_writable_collections ?? null,
884
904
  slashCommands: et.slash_commands ?? null,
885
905
  serveEndpoint: et.serve_endpoint ?? null,
886
906
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -908,6 +928,7 @@ var PostgresRegistry = class {
908
928
  creationSchema: et.creation_schema ?? null,
909
929
  inboxSchemas: et.inbox_schemas ?? null,
910
930
  stateSchemas: et.state_schemas ?? null,
931
+ externallyWritableCollections: et.externally_writable_collections ?? null,
911
932
  slashCommands: et.slash_commands ?? null,
912
933
  serveEndpoint: et.serve_endpoint ?? null,
913
934
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -1311,11 +1332,12 @@ var PostgresRegistry = class {
1311
1332
  };
1312
1333
  const nextTags = (0, __electric_ax_agents_runtime.normalizeTags)(mutation.nextTags);
1313
1334
  const updatedAt = Date.now();
1314
- await tx.update(entities).set({
1335
+ const [updateResult] = await tx.update(entities).set({
1315
1336
  tags: nextTags,
1316
1337
  tagsIndex: (0, __electric_ax_agents_runtime.buildTagsIndex)(nextTags),
1317
1338
  updatedAt
1318
- }).where(this.entityWhere(url));
1339
+ }).where(this.entityWhere(url)).returning({ txid: drizzle_orm.sql`pg_current_xact_id()::xid::text` });
1340
+ const txid = updateResult ? parseInt(updateResult.txid) : void 0;
1319
1341
  await tx.insert(tagStreamOutbox).values({
1320
1342
  tenantId: this.tenantId,
1321
1343
  entityUrl: url,
@@ -1333,10 +1355,63 @@ var PostgresRegistry = class {
1333
1355
  return {
1334
1356
  entity,
1335
1357
  changed: true,
1336
- ...op === `insert` || op === `update` ? { op } : {}
1358
+ ...op === `insert` || op === `update` ? { op } : {},
1359
+ ...txid !== void 0 ? { txid } : {}
1337
1360
  };
1338
1361
  });
1339
1362
  }
1363
+ async upsertPgSyncBridge(row) {
1364
+ await this.db.insert(pgSyncBridges).values({
1365
+ tenantId: this.tenantId,
1366
+ sourceRef: row.sourceRef,
1367
+ options: row.options,
1368
+ streamUrl: row.streamUrl,
1369
+ lastTouchedAt: new Date(),
1370
+ updatedAt: new Date()
1371
+ }).onConflictDoUpdate({
1372
+ target: [pgSyncBridges.tenantId, pgSyncBridges.sourceRef],
1373
+ set: {
1374
+ options: row.options,
1375
+ streamUrl: row.streamUrl,
1376
+ initialSnapshotComplete: false,
1377
+ lastTouchedAt: new Date(),
1378
+ updatedAt: new Date()
1379
+ }
1380
+ });
1381
+ const existing = await this.getPgSyncBridge(row.sourceRef);
1382
+ if (!existing) throw new Error(`Failed to load pgSync bridge ${row.sourceRef}`);
1383
+ return existing;
1384
+ }
1385
+ async getPgSyncBridge(sourceRef) {
1386
+ const rows = await this.db.select().from(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef)).limit(1);
1387
+ return rows[0] ? this.rowToPgSyncBridge(rows[0]) : null;
1388
+ }
1389
+ async listPgSyncBridges(tenantId = this.tenantId) {
1390
+ const rows = tenantId === null ? await this.db.select().from(pgSyncBridges) : await this.db.select().from(pgSyncBridges).where((0, drizzle_orm.eq)(pgSyncBridges.tenantId, tenantId));
1391
+ return rows.map((row) => this.rowToPgSyncBridge(row));
1392
+ }
1393
+ async touchPgSyncBridge(sourceRef) {
1394
+ await this.db.update(pgSyncBridges).set({
1395
+ lastTouchedAt: new Date(),
1396
+ updatedAt: new Date()
1397
+ }).where(this.pgSyncBridgeWhere(sourceRef));
1398
+ }
1399
+ async updatePgSyncBridgeCursor(sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete) {
1400
+ await this.db.update(pgSyncBridges).set({
1401
+ shapeHandle,
1402
+ shapeOffset,
1403
+ ...initialSnapshotComplete !== void 0 ? { initialSnapshotComplete } : {},
1404
+ updatedAt: new Date()
1405
+ }).where(this.pgSyncBridgeWhere(sourceRef));
1406
+ }
1407
+ async clearPgSyncBridgeCursor(sourceRef) {
1408
+ await this.db.update(pgSyncBridges).set({
1409
+ shapeHandle: null,
1410
+ shapeOffset: null,
1411
+ initialSnapshotComplete: false,
1412
+ updatedAt: new Date()
1413
+ }).where(this.pgSyncBridgeWhere(sourceRef));
1414
+ }
1340
1415
  async upsertEntityBridge(row) {
1341
1416
  await this.db.insert(entityBridges).values({
1342
1417
  tenantId: this.tenantId,
@@ -1499,6 +1574,7 @@ var PostgresRegistry = class {
1499
1574
  creation_schema: row.creationSchema,
1500
1575
  inbox_schemas: row.inboxSchemas,
1501
1576
  state_schemas: row.stateSchemas,
1577
+ externally_writable_collections: row.externallyWritableCollections ?? void 0,
1502
1578
  slash_commands: row.slashCommands ?? void 0,
1503
1579
  serve_endpoint: row.serveEndpoint ?? void 0,
1504
1580
  default_dispatch_policy: row.defaultDispatchPolicy ?? void 0,
@@ -1556,6 +1632,20 @@ var PostgresRegistry = class {
1556
1632
  updated_at: row.updatedAt
1557
1633
  };
1558
1634
  }
1635
+ rowToPgSyncBridge(row) {
1636
+ return {
1637
+ tenantId: row.tenantId,
1638
+ sourceRef: row.sourceRef,
1639
+ options: row.options,
1640
+ streamUrl: row.streamUrl,
1641
+ shapeHandle: row.shapeHandle ?? void 0,
1642
+ shapeOffset: row.shapeOffset ?? void 0,
1643
+ initialSnapshotComplete: row.initialSnapshotComplete,
1644
+ lastTouchedAt: row.lastTouchedAt,
1645
+ createdAt: row.createdAt,
1646
+ updatedAt: row.updatedAt
1647
+ };
1648
+ }
1559
1649
  rowToEntityBridge(row) {
1560
1650
  return {
1561
1651
  tenantId: row.tenantId,
@@ -3203,6 +3293,9 @@ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
3203
3293
  function isRecord$1(value) {
3204
3294
  return typeof value === `object` && value !== null && !Array.isArray(value);
3205
3295
  }
3296
+ function getPgSyncManifestStreamPath(sourceRef) {
3297
+ return `/_electric/pg-sync/${sourceRef}`;
3298
+ }
3206
3299
  function extractManifestSourceUrl(manifest) {
3207
3300
  if (!manifest) return void 0;
3208
3301
  if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
@@ -3215,6 +3308,7 @@ function extractManifestSourceUrl(manifest) {
3215
3308
  }
3216
3309
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
3217
3310
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(manifest.sourceRef) : void 0;
3311
+ if (manifest.sourceType === `pgSync`) return typeof manifest.sourceRef === `string` ? getPgSyncManifestStreamPath(manifest.sourceRef) : void 0;
3218
3312
  if (manifest.sourceType === `webhook`) {
3219
3313
  if (typeof config?.streamUrl === `string`) return config.streamUrl;
3220
3314
  if (typeof config?.endpointKey === `string`) return (0, __electric_ax_agents_runtime.getWebhookStreamPath)(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
@@ -3329,6 +3423,13 @@ function isRecord(value) {
3329
3423
  function cloneRecord(value) {
3330
3424
  return JSON.parse(JSON.stringify(value));
3331
3425
  }
3426
+ function withOptionalTxid(entity, txid) {
3427
+ if (txid === void 0) return entity;
3428
+ return {
3429
+ ...entity,
3430
+ txid
3431
+ };
3432
+ }
3332
3433
  /**
3333
3434
  * Orchestrates the Electric Agents entity lifecycle: register types, spawn, send, kill.
3334
3435
  *
@@ -3403,6 +3504,7 @@ var EntityManager = class {
3403
3504
  creation_schema: req.creation_schema,
3404
3505
  inbox_schemas: req.inbox_schemas,
3405
3506
  state_schemas: req.state_schemas,
3507
+ externally_writable_collections: req.externally_writable_collections,
3406
3508
  slash_commands: req.slash_commands,
3407
3509
  serve_endpoint: req.serve_endpoint,
3408
3510
  default_dispatch_policy: defaultDispatchPolicy,
@@ -4470,15 +4572,17 @@ var EntityManager = class {
4470
4572
  await this.registry.updateStatus(entityUrl, `idle`);
4471
4573
  await this.entityBridgeManager?.onEntityChanged(entityUrl);
4472
4574
  }
4575
+ const txid = crypto.randomUUID();
4473
4576
  const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.insert({
4474
4577
  key,
4475
- value
4578
+ value,
4579
+ headers: { txid }
4476
4580
  });
4477
4581
  const encoded = this.encodeChangeEvent(envelope);
4478
4582
  try {
4479
4583
  if (opts?.producerId) {
4480
4584
  await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
4481
- return;
4585
+ return { txid };
4482
4586
  }
4483
4587
  await this.streamClient.append(entity.streams.main, encoded);
4484
4588
  if (entity.type === `principal` && req.type === `update_identity`) {
@@ -4486,14 +4590,50 @@ var EntityManager = class {
4486
4590
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent({
4487
4591
  type: `identity`,
4488
4592
  key: `self`,
4489
- value: identity
4593
+ value: identity,
4594
+ headers: { txid }
4490
4595
  }));
4491
4596
  }
4597
+ return { txid };
4492
4598
  } catch (err) {
4493
4599
  if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4494
4600
  throw err;
4495
4601
  }
4496
4602
  }
4603
+ async writeCollection(entityUrl, collection, req) {
4604
+ const entity = await this.registry.getEntity(entityUrl);
4605
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4606
+ const { externallyWritableCollections } = await this.getEffectiveSchemas(entity);
4607
+ const config = externallyWritableCollections?.[collection];
4608
+ if (!config) throw new ElectricAgentsError(ErrCodeUnauthorized, `Collection "${collection}" is not writable`, 403);
4609
+ const allowedOperations = config.operations ?? [`insert`];
4610
+ if (!allowedOperations.includes(req.operation)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Operation "${req.operation}" is not allowed on collection "${collection}"`, 403);
4611
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4612
+ if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
4613
+ if (req.operation !== `delete` && (req.value === void 0 || req.value === null)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `value is required for ${req.operation}`, 400);
4614
+ if (req.operation !== `insert` && !req.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `key is required for ${req.operation}`, 400);
4615
+ const key = req.key ?? `${collection}-${(0, node_crypto.randomUUID)()}`;
4616
+ const event = {
4617
+ type: config.type,
4618
+ key,
4619
+ headers: {
4620
+ operation: req.operation,
4621
+ timestamp: new Date().toISOString(),
4622
+ principal: req.principal
4623
+ }
4624
+ };
4625
+ if (req.operation !== `delete`) event.value = req.value;
4626
+ const validationError = await this.validateWriteEvent(entity, event);
4627
+ if (validationError) throw new ElectricAgentsError(validationError.code, validationError.message, validationError.status);
4628
+ const encoded = this.encodeChangeEvent(event);
4629
+ try {
4630
+ await this.streamClient.append(entity.streams.main, encoded);
4631
+ } catch (err) {
4632
+ if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4633
+ throw err;
4634
+ }
4635
+ return { key };
4636
+ }
4497
4637
  async updateInboxMessage(entityUrl, key, req) {
4498
4638
  const entity = await this.registry.getEntity(entityUrl);
4499
4639
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
@@ -4509,18 +4649,26 @@ var EntityManager = class {
4509
4649
  if (req.status === `cancelled`) value.cancelled_at = now;
4510
4650
  }
4511
4651
  if (Object.keys(value).length === 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `No inbox fields to update`, 400);
4652
+ const txid = crypto.randomUUID();
4512
4653
  const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.update({
4513
4654
  key,
4514
- value
4655
+ value,
4656
+ headers: { txid }
4515
4657
  });
4516
4658
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
4659
+ return { txid };
4517
4660
  }
4518
4661
  async deleteInboxMessage(entityUrl, key) {
4519
4662
  const entity = await this.registry.getEntity(entityUrl);
4520
4663
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4521
4664
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4522
- const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.delete({ key });
4665
+ const txid = crypto.randomUUID();
4666
+ const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.delete({
4667
+ key,
4668
+ headers: { txid }
4669
+ });
4523
4670
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
4671
+ return { txid };
4524
4672
  }
4525
4673
  isAttachmentStreamPath(path$2) {
4526
4674
  return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$2);
@@ -4609,28 +4757,26 @@ var EntityManager = class {
4609
4757
  await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
4610
4758
  return { txid };
4611
4759
  }
4612
- async setTag(entityUrl, key, req, token) {
4760
+ async setTag(entityUrl, key, req) {
4613
4761
  const entity = await this.registry.getEntity(entityUrl);
4614
4762
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4615
- if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
4616
4763
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4617
4764
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
4618
4765
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
4619
4766
  const updated = result.entity;
4620
4767
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag write`, 500);
4621
4768
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4622
- return updated;
4769
+ return withOptionalTxid(updated, result.txid);
4623
4770
  }
4624
- async deleteTag(entityUrl, key, token) {
4771
+ async deleteTag(entityUrl, key) {
4625
4772
  const entity = await this.registry.getEntity(entityUrl);
4626
4773
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4627
- if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
4628
4774
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4629
4775
  const result = await this.registry.removeEntityTag(entityUrl, key);
4630
4776
  const updated = result.entity;
4631
4777
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
4632
4778
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4633
- return updated;
4779
+ return withOptionalTxid(updated, result.txid);
4634
4780
  }
4635
4781
  async ensureEntitiesMembershipStream(tags, principal) {
4636
4782
  if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
@@ -5180,7 +5326,8 @@ var EntityManager = class {
5180
5326
  async getEffectiveSchemas(entity) {
5181
5327
  if (!entity.type) return {
5182
5328
  inboxSchemas: entity.inbox_schemas,
5183
- stateSchemas: entity.state_schemas
5329
+ stateSchemas: entity.state_schemas,
5330
+ externallyWritableCollections: void 0
5184
5331
  };
5185
5332
  const latestType = await this.registry.getEntityType(entity.type);
5186
5333
  return {
@@ -5191,7 +5338,8 @@ var EntityManager = class {
5191
5338
  stateSchemas: latestType?.state_schemas ? {
5192
5339
  ...entity.state_schemas ?? {},
5193
5340
  ...latestType.state_schemas
5194
- } : entity.state_schemas
5341
+ } : entity.state_schemas,
5342
+ externallyWritableCollections: latestType?.externally_writable_collections
5195
5343
  };
5196
5344
  }
5197
5345
  isClosedStreamError(err) {
@@ -5426,6 +5574,9 @@ function isPermanentElectricAgentsError(err) {
5426
5574
  const name = typeof err === `object` && err !== null && `name` in err ? err.name : void 0;
5427
5575
  return name === `ElectricAgentsError` && typeof status$4 === `number` && status$4 >= 400 && status$4 < 500;
5428
5576
  }
5577
+ function cronTaskStreamPath(payload) {
5578
+ return typeof payload.streamPath === `string` ? payload.streamPath : null;
5579
+ }
5429
5580
  function normalizeTask(row) {
5430
5581
  return {
5431
5582
  id: Number(row.id),
@@ -5768,6 +5919,15 @@ var Scheduler = class {
5768
5919
  `;
5769
5920
  if (completed.length === 0) return;
5770
5921
  const nextFireAt = (0, __electric_ax_agents_runtime.getNextCronFireAt)(task.cronExpression, task.cronTimezone, task.fireAt);
5922
+ const streamPath = cronTaskStreamPath(task.payload);
5923
+ const subscriberRows = streamPath ? await sql$2`
5924
+ select 1 as exists
5925
+ from wake_registrations
5926
+ where tenant_id = ${tenantId}
5927
+ and source_url = ${streamPath}
5928
+ limit 1
5929
+ ` : [];
5930
+ if (subscriberRows.length === 0) return;
5771
5931
  await sql$2`
5772
5932
  insert into scheduled_tasks (
5773
5933
  tenant_id,
@@ -5867,6 +6027,308 @@ var Scheduler = class {
5867
6027
  }
5868
6028
  };
5869
6029
 
6030
+ //#endregion
6031
+ //#region src/pg-sync-bridge-manager.ts
6032
+ const PG_SYNC_ELECTRIC_SHAPE_URL = process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ?? `http://localhost:3000/v1/shape`;
6033
+ const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
6034
+ const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
6035
+ function buildElectricShapeParams(options) {
6036
+ return {
6037
+ table: options.table,
6038
+ ...options.columns !== void 0 ? { columns: [...options.columns] } : {},
6039
+ ...options.where !== void 0 ? { where: options.where } : {},
6040
+ ...options.params !== void 0 ? { params: Array.isArray(options.params) ? [...options.params] : { ...options.params } } : {},
6041
+ ...options.replica !== void 0 ? { replica: options.replica } : {},
6042
+ ...options.metadata?.tenantId ? { electric_agents_tenant_id: options.metadata.tenantId } : {},
6043
+ ...options.metadata?.principalKind ? { electric_agents_principal_kind: options.metadata.principalKind } : {},
6044
+ ...options.metadata?.principalId ? { electric_agents_principal_id: options.metadata.principalId } : {},
6045
+ ...options.metadata?.principalKey ? { electric_agents_principal_key: options.metadata.principalKey } : {},
6046
+ ...options.metadata?.principalUrl ? { electric_agents_principal_url: options.metadata.principalUrl } : {},
6047
+ ...options.metadata?.entityUrl ? { electric_agents_entity_url: options.metadata.entityUrl } : {},
6048
+ ...options.metadata?.entityType ? { electric_agents_entity_type: options.metadata.entityType } : {},
6049
+ ...options.metadata?.streamPath ? { electric_agents_stream_path: options.metadata.streamPath } : {},
6050
+ ...options.metadata?.runtimeConsumerId ? { electric_agents_runtime_consumer_id: options.metadata.runtimeConsumerId } : {},
6051
+ ...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
6052
+ };
6053
+ }
6054
+ function jsonSafe(value) {
6055
+ if (typeof value === `bigint`) return value.toString();
6056
+ if (value === null || typeof value !== `object`) return value;
6057
+ if (Array.isArray(value)) return value.map(jsonSafe);
6058
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
6059
+ }
6060
+ function stableJson(value) {
6061
+ if (typeof value === `bigint`) return JSON.stringify(value.toString());
6062
+ if (value === null || typeof value !== `object`) return JSON.stringify(value);
6063
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`;
6064
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(`,`)}}`;
6065
+ }
6066
+ function parseElectricOffset(offset) {
6067
+ if (offset === `-1`) return offset;
6068
+ return /^\d+_\d+$/.test(offset) ? offset : null;
6069
+ }
6070
+ function rowKeyForMessage(message) {
6071
+ const headers = message.headers;
6072
+ const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
6073
+ return candidate === void 0 ? void 0 : stableJson(candidate);
6074
+ }
6075
+ function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
6076
+ const operation = message.headers.operation;
6077
+ if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
6078
+ const sourceRef = typeof optionsOrSourceRef === `string` ? optionsOrSourceRef : (0, __electric_ax_agents_runtime.sourceRefForPgSync)(optionsOrSourceRef);
6079
+ const rowKey = rowKeyForMessage(message);
6080
+ const offset = message.headers.offset;
6081
+ if (typeof offset !== `string` || offset.length === 0) return null;
6082
+ const messageKeyPart = offset;
6083
+ const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
6084
+ const timestamp$1 = new Date().toISOString();
6085
+ const oldValue = message.old_value;
6086
+ const safeValue = jsonSafe(message.value);
6087
+ const safeOldValue = jsonSafe(oldValue);
6088
+ const safeHeaders = jsonSafe(message.headers);
6089
+ return {
6090
+ type: `pg_sync_change`,
6091
+ key: messageKey,
6092
+ value: {
6093
+ key: messageKey,
6094
+ table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
6095
+ operation,
6096
+ ...rowKey !== void 0 ? { rowKey } : {},
6097
+ ...message.value !== void 0 ? { value: safeValue } : {},
6098
+ ...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
6099
+ headers: safeHeaders,
6100
+ ...typeof offset === `string` ? { offset } : {},
6101
+ receivedAt: timestamp$1
6102
+ },
6103
+ headers: {
6104
+ operation,
6105
+ timestamp: timestamp$1
6106
+ }
6107
+ };
6108
+ }
6109
+ function cursorFromRow(row) {
6110
+ return row?.shapeHandle && row.shapeOffset ? {
6111
+ handle: row.shapeHandle,
6112
+ offset: row.shapeOffset,
6113
+ initialSnapshotComplete: row.initialSnapshotComplete
6114
+ } : void 0;
6115
+ }
6116
+ var PgSyncBridge = class {
6117
+ producer = null;
6118
+ unsubscribe = null;
6119
+ abortController = null;
6120
+ skipChangesUntilUpToDate = false;
6121
+ recovering = false;
6122
+ committedCursor;
6123
+ retryAttempt = 0;
6124
+ constructor(sourceRef, streamUrl, options, resolvedSource, retry, streamClient, registry, evaluateWakes, initialCursor) {
6125
+ this.sourceRef = sourceRef;
6126
+ this.streamUrl = streamUrl;
6127
+ this.options = options;
6128
+ this.resolvedSource = resolvedSource;
6129
+ this.retry = retry;
6130
+ this.streamClient = streamClient;
6131
+ this.registry = registry;
6132
+ this.evaluateWakes = evaluateWakes;
6133
+ this.initialCursor = initialCursor;
6134
+ this.committedCursor = initialCursor;
6135
+ }
6136
+ async start() {
6137
+ if (!this.producer) this.producer = new __durable_streams_client.IdempotentProducer(new __durable_streams_client.DurableStream({
6138
+ url: `${this.streamClient.baseUrl}${this.streamUrl}`,
6139
+ contentType: `application/json`
6140
+ }), `pg-sync-bridge-${this.sourceRef}`);
6141
+ if (this.initialCursor) {
6142
+ const offset = parseElectricOffset(this.initialCursor.offset);
6143
+ if (offset) {
6144
+ this.startStream(offset, this.initialCursor.handle, !this.initialCursor.initialSnapshotComplete);
6145
+ return;
6146
+ }
6147
+ }
6148
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
6149
+ this.startStream(`now`, void 0, true);
6150
+ }
6151
+ async stop() {
6152
+ this.unsubscribe?.();
6153
+ this.abortController?.abort();
6154
+ this.unsubscribe = null;
6155
+ this.abortController = null;
6156
+ try {
6157
+ await this.producer?.flush();
6158
+ } finally {
6159
+ await this.producer?.detach();
6160
+ this.producer = null;
6161
+ }
6162
+ }
6163
+ startStream(offset, handle, skipChangesUntilUpToDate = false, log = offset === `now` ? `changes_only` : `full`) {
6164
+ this.unsubscribe?.();
6165
+ this.abortController?.abort();
6166
+ this.skipChangesUntilUpToDate = skipChangesUntilUpToDate;
6167
+ this.abortController = new AbortController();
6168
+ const stream = new __electric_sql_client.ShapeStream({
6169
+ url: this.resolvedSource.url,
6170
+ params: buildElectricShapeParams(this.options),
6171
+ offset,
6172
+ log,
6173
+ ...handle ? { handle } : {},
6174
+ signal: this.abortController.signal
6175
+ });
6176
+ this.unsubscribe = stream.subscribe(async (messages) => {
6177
+ try {
6178
+ for (const message of messages) {
6179
+ if ((0, __electric_sql_client.isControlMessage)(message)) {
6180
+ if (message.headers.control === `must-refetch`) {
6181
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
6182
+ this.startStream(`now`, void 0, true);
6183
+ return;
6184
+ }
6185
+ if (message.headers.control === `up-to-date`) {
6186
+ this.skipChangesUntilUpToDate = false;
6187
+ await this.persistCursor(stream, true);
6188
+ continue;
6189
+ }
6190
+ await this.persistCursor(stream);
6191
+ continue;
6192
+ }
6193
+ if (!(0, __electric_sql_client.isChangeMessage)(message)) continue;
6194
+ if (!this.skipChangesUntilUpToDate) {
6195
+ const event = pgSyncMessageToDurableEvent(message, this.options);
6196
+ if (event) {
6197
+ if (!this.producer) throw new Error(`pg-sync producer is not started`);
6198
+ await this.producer.append(JSON.stringify(event));
6199
+ await this.producer.flush?.();
6200
+ await this.evaluateWakes?.(this.streamUrl, event);
6201
+ }
6202
+ }
6203
+ await this.persistCursor(stream);
6204
+ this.retryAttempt = 0;
6205
+ }
6206
+ } catch (error) {
6207
+ serverLog.warn(`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`, error);
6208
+ await this.recoverStream();
6209
+ }
6210
+ }, (error) => {
6211
+ if (this.abortController?.signal.aborted) return;
6212
+ serverLog.warn(`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`, error);
6213
+ this.recoverStream();
6214
+ });
6215
+ }
6216
+ async recoverStream() {
6217
+ if (this.recovering) return;
6218
+ this.recovering = true;
6219
+ try {
6220
+ const attempt = this.retryAttempt++;
6221
+ const baseDelay = Math.min(this.retry.initialDelayMs * 2 ** attempt, this.retry.maxDelayMs);
6222
+ const jitter = Math.floor(baseDelay * .2 * this.retry.random());
6223
+ const delay = baseDelay + jitter;
6224
+ if (delay > 0) await this.retry.sleep(delay);
6225
+ const offset = this.committedCursor ? parseElectricOffset(this.committedCursor.offset) : null;
6226
+ if (offset && this.committedCursor) this.startStream(offset, this.committedCursor.handle, !this.committedCursor.initialSnapshotComplete);
6227
+ else {
6228
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
6229
+ this.startStream(`now`, void 0, true);
6230
+ }
6231
+ } finally {
6232
+ this.recovering = false;
6233
+ }
6234
+ }
6235
+ async persistCursor(stream, initialSnapshotComplete = !this.skipChangesUntilUpToDate) {
6236
+ const shapeHandle = stream.shapeHandle;
6237
+ const shapeOffset = stream.lastOffset;
6238
+ if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return;
6239
+ await this.registry?.updatePgSyncBridgeCursor(this.sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete);
6240
+ this.committedCursor = {
6241
+ handle: shapeHandle,
6242
+ offset: shapeOffset,
6243
+ initialSnapshotComplete
6244
+ };
6245
+ }
6246
+ };
6247
+ var PgSyncBridgeManager = class {
6248
+ bridges = new Map();
6249
+ starting = new Map();
6250
+ url;
6251
+ retry;
6252
+ constructor(streamClient, evaluateWakes, registry, options = {}) {
6253
+ this.streamClient = streamClient;
6254
+ this.evaluateWakes = evaluateWakes;
6255
+ this.registry = registry;
6256
+ this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
6257
+ this.retry = {
6258
+ initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
6259
+ maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
6260
+ random: options.retry?.random ?? Math.random,
6261
+ sleep: options.retry?.sleep ?? ((ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)))
6262
+ };
6263
+ }
6264
+ async start() {
6265
+ const rows = await this.registry?.listPgSyncBridges?.();
6266
+ if (!rows) return;
6267
+ await Promise.all(rows.map((row) => this.ensureBridge(row).catch((error) => {
6268
+ serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
6269
+ })));
6270
+ }
6271
+ async register(options, metadata) {
6272
+ const mergedMetadata = {
6273
+ ...options.metadata,
6274
+ ...metadata
6275
+ };
6276
+ const canonicalOptions = {
6277
+ ...(0, __electric_ax_agents_runtime.canonicalPgSyncOptions)(options),
6278
+ ...Object.keys(mergedMetadata).length > 0 ? { metadata: mergedMetadata } : {}
6279
+ };
6280
+ const resolvedSource = this.resolveSource(canonicalOptions);
6281
+ const sourceRef = (0, __electric_ax_agents_runtime.sourceRefForPgSync)(canonicalOptions);
6282
+ const streamUrl = (0, __electric_ax_agents_runtime.getPgSyncStreamPath)(sourceRef, this.registry?.tenantId);
6283
+ const row = await this.registry?.upsertPgSyncBridge({
6284
+ sourceRef,
6285
+ options: canonicalOptions,
6286
+ streamUrl
6287
+ });
6288
+ await this.streamClient.ensure(streamUrl, { contentType: `application/json` });
6289
+ if (!this.bridges.has(sourceRef)) {
6290
+ let start = this.starting.get(sourceRef);
6291
+ if (!start) {
6292
+ start = (async () => {
6293
+ const bridge = new PgSyncBridge(sourceRef, streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
6294
+ await bridge.start();
6295
+ this.bridges.set(sourceRef, bridge);
6296
+ })().finally(() => this.starting.delete(sourceRef));
6297
+ this.starting.set(sourceRef, start);
6298
+ }
6299
+ await start;
6300
+ }
6301
+ return {
6302
+ sourceRef,
6303
+ streamUrl
6304
+ };
6305
+ }
6306
+ async ensureBridge(row) {
6307
+ if (this.bridges.has(row.sourceRef)) return;
6308
+ let start = this.starting.get(row.sourceRef);
6309
+ if (!start) {
6310
+ start = (async () => {
6311
+ await this.streamClient.ensure(row.streamUrl, { contentType: `application/json` });
6312
+ const canonicalOptions = (0, __electric_ax_agents_runtime.canonicalPgSyncOptions)(row.options);
6313
+ const resolvedSource = this.resolveSource(canonicalOptions);
6314
+ const bridge = new PgSyncBridge(row.sourceRef, row.streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
6315
+ await bridge.start();
6316
+ this.bridges.set(row.sourceRef, bridge);
6317
+ })().finally(() => this.starting.delete(row.sourceRef));
6318
+ this.starting.set(row.sourceRef, start);
6319
+ }
6320
+ await start;
6321
+ }
6322
+ resolveSource(options) {
6323
+ return { url: options.url ?? this.url };
6324
+ }
6325
+ async stop() {
6326
+ await Promise.allSettled(this.starting.values());
6327
+ await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()));
6328
+ this.bridges.clear();
6329
+ }
6330
+ };
6331
+
5870
6332
  //#endregion
5871
6333
  //#region src/runtime.ts
5872
6334
  function omitUndefined(value) {
@@ -5881,6 +6343,7 @@ var ElectricAgentsTenantRuntime = class {
5881
6343
  wakeRegistry;
5882
6344
  scheduler;
5883
6345
  entityBridgeManager;
6346
+ pgSyncBridgeManager;
5884
6347
  claimWriteTokens;
5885
6348
  manager;
5886
6349
  constructor(options) {
@@ -5905,9 +6368,10 @@ var ElectricAgentsTenantRuntime = class {
5905
6368
  writeTokenValidator: (entity, token) => this.claimWriteTokens.isValid(this.serviceId, entity.streams.main, token),
5906
6369
  stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false
5907
6370
  });
6371
+ this.pgSyncBridgeManager = options.pgSyncBridgeManager ?? new PgSyncBridgeManager(this.streamClient, (sourceUrl, event) => this.manager.evaluateWakes(sourceUrl, event), this.registry, options.pgSync);
5908
6372
  }
5909
6373
  async stop() {
5910
- await this.manager.shutdown();
6374
+ await Promise.all([this.manager.shutdown(), this.pgSyncBridgeManager.stop()]);
5911
6375
  }
5912
6376
  async rehydrateCronSchedules() {
5913
6377
  const rows = await this.db.select({ sourceUrl: wakeRegistrations.sourceUrl }).from(wakeRegistrations).where((0, drizzle_orm.eq)(wakeRegistrations.tenantId, this.serviceId));
@@ -6671,7 +7135,10 @@ var WakeRegistry = class {
6671
7135
  }
6672
7136
  if (!(0, __electric_sql_client.isChangeMessage)(message)) return;
6673
7137
  if (message.headers.operation === `delete`) {
6674
- this.removeCachedRegistrationByDbId(Number(message.key));
7138
+ const oldValue = message.old_value;
7139
+ const oldId = Number(oldValue?.id);
7140
+ if (Number.isFinite(oldId)) this.removeCachedRegistrationByDbId(oldId);
7141
+ else this.resetCachedRegistrations();
6675
7142
  return;
6676
7143
  }
6677
7144
  this.upsertCachedRegistration(this.normalizeShapeRow(message.value));
@@ -6782,9 +7249,9 @@ var WakeRegistry = class {
6782
7249
  matchCondition(reg, event) {
6783
7250
  if (reg.condition === `runFinished`) {
6784
7251
  if (event.type !== `run`) return null;
6785
- const value = event.value;
7252
+ const value$1 = event.value;
6786
7253
  const headers$1 = event.headers;
6787
- const status$4 = value?.status;
7254
+ const status$4 = value$1?.status;
6788
7255
  const operation$1 = headers$1?.operation;
6789
7256
  if (operation$1 !== `update`) return null;
6790
7257
  if (status$4 !== `completed` && status$4 !== `failed`) return null;
@@ -6807,13 +7274,15 @@ var WakeRegistry = class {
6807
7274
  }
6808
7275
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
6809
7276
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
7277
+ const value = event.value;
6810
7278
  const change = {
6811
7279
  collection: eventType,
6812
7280
  kind,
6813
7281
  key: event.key || ``
6814
7282
  };
7283
+ if (value && `value` in value) change.value = value.value;
7284
+ if (value && `oldValue` in value) change.oldValue = value.oldValue;
6815
7285
  if (eventType === `inbox`) {
6816
- const value = event.value;
6817
7286
  if (typeof value?.from === `string`) change.from = value.from;
6818
7287
  if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
6819
7288
  if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
@@ -7217,6 +7686,22 @@ function resolveDurableStreamsRoutingAdapter(adapter, _durableStreamsUrl) {
7217
7686
 
7218
7687
  //#endregion
7219
7688
  //#region src/utils/server-utils.ts
7689
+ /**
7690
+ * Raised when an Electric shape proxy request must be rejected for security
7691
+ * reasons (an un-scoped table, or a client `where` clause that could escape the
7692
+ * enforced per-tenant/per-principal scoping). The global `errorMapper` hook
7693
+ * maps this to an HTTP error response. Defined here (rather than reusing
7694
+ * `ElectricAgentsError`) to keep this module free of the heavy entity-manager
7695
+ * import graph.
7696
+ */
7697
+ var ElectricProxyError = class extends Error {
7698
+ constructor(code, message, status$4) {
7699
+ super(message);
7700
+ this.code = code;
7701
+ this.status = status$4;
7702
+ this.name = `ElectricProxyError`;
7703
+ }
7704
+ };
7220
7705
  function buildElectricProxyTarget(options) {
7221
7706
  const targetPath = options.incomingUrl.pathname.replace(`/_electric/electric`, ``);
7222
7707
  const target = electricUrlWithPath(options.electricUrl, targetPath);
@@ -7226,7 +7711,12 @@ function buildElectricProxyTarget(options) {
7226
7711
  applyElectricUrlQueryParams(target, options.electricUrl);
7227
7712
  if (targetPath !== `/v1/shape`) return target;
7228
7713
  if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
7229
- const table = options.incomingUrl.searchParams.get(`table`);
7714
+ const clientWhere = options.incomingUrl.searchParams.get(`where`);
7715
+ if (clientWhere !== null && !isSelfContainedWhereClause(clientWhere)) throw new ElectricProxyError(`INVALID_WHERE`, `Invalid where clause`, 400);
7716
+ const tableParams = options.incomingUrl.searchParams.getAll(`table`);
7717
+ if (tableParams.length !== 1) throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
7718
+ const table = tableParams[0];
7719
+ target.searchParams.set(`table`, table);
7230
7720
  if (table === `entities`) {
7231
7721
  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"`);
7232
7722
  applyShapeWhere(target, buildReadableEntitiesWhere({
@@ -7284,9 +7774,39 @@ function buildElectricProxyTarget(options) {
7284
7774
  principalKind: options.principalKind ?? ``,
7285
7775
  permissionBypass: options.permissionBypass
7286
7776
  }));
7287
- }
7777
+ } else throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
7288
7778
  return target;
7289
7779
  }
7780
+ /**
7781
+ * Returns true when a client-supplied Electric `where` clause is self-contained:
7782
+ * its parentheses are balanced, never close below the top level, all string
7783
+ * (`'`) and identifier (`"`) literals are terminated, and it contains no SQL
7784
+ * comment markers. Such a clause cannot break out of the `(...)` group it is
7785
+ * wrapped in when AND-combined with the enforced scoping predicate, nor comment
7786
+ * out the trailing paren the proxy appends. Characters inside string/identifier
7787
+ * literals are ignored. Comment markers are rejected unconditionally (even where
7788
+ * harmless) as a conservative defensive measure; dollar-quoted and `E''` strings
7789
+ * are not modeled and only ever cause fail-safe over-rejection, never a bypass.
7790
+ */
7791
+ function isSelfContainedWhereClause(where) {
7792
+ let depth = 0;
7793
+ let quote = null;
7794
+ for (let i = 0; i < where.length; i++) {
7795
+ const ch = where[i];
7796
+ if (quote !== null) {
7797
+ if (ch === quote) if (where[i + 1] === quote) i++;
7798
+ else quote = null;
7799
+ continue;
7800
+ }
7801
+ if (ch === `'` || ch === `"`) quote = ch;
7802
+ else if (ch === `(`) depth++;
7803
+ else if (ch === `)`) {
7804
+ depth--;
7805
+ if (depth < 0) return false;
7806
+ } else if (ch === `-` && where[i + 1] === `-` || ch === `/` && where[i + 1] === `*`) return false;
7807
+ }
7808
+ return depth === 0 && quote === null;
7809
+ }
7290
7810
  function buildReadableEntitiesWhere(options) {
7291
7811
  const tenant = sqlStringLiteral(options.tenantId);
7292
7812
  if (options.permissionBypass) return `tenant_id = ${tenant}`;
@@ -8057,6 +8577,15 @@ const spawnBodySchema = __sinclair_typebox.Type.Object({
8057
8577
  manifestKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
8058
8578
  }))
8059
8579
  });
8580
+ const writeCollectionBodySchema = __sinclair_typebox.Type.Object({
8581
+ operation: __sinclair_typebox.Type.Union([
8582
+ __sinclair_typebox.Type.Literal(`insert`),
8583
+ __sinclair_typebox.Type.Literal(`update`),
8584
+ __sinclair_typebox.Type.Literal(`delete`)
8585
+ ]),
8586
+ key: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
8587
+ value: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Unknown()))
8588
+ }, { additionalProperties: false });
8060
8589
  const sendBodySchema = __sinclair_typebox.Type.Object({
8061
8590
  payload: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown()),
8062
8591
  key: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
@@ -8189,6 +8718,7 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermissi
8189
8718
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
8190
8719
  entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
8191
8720
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
8721
+ entitiesRouter.post(`/:type/:instanceId/collections/:collection`, withExistingEntity, withSchema(writeCollectionBodySchema), withEntityPermission(`write`), writeCollection);
8192
8722
  entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
8193
8723
  entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
8194
8724
  entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
@@ -8294,7 +8824,7 @@ async function parseAttachmentForm(request) {
8294
8824
  };
8295
8825
  }
8296
8826
  function contentDisposition(filename) {
8297
- const fallback = filename.replace(/["\\\r\n]/g, `_`);
8827
+ const fallback = filename.replace(/[^\x20-\x7e]|["\\]/g, `_`);
8298
8828
  return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
8299
8829
  }
8300
8830
  function rejectPrincipalEntityMutation(request, action) {
@@ -8492,22 +9022,28 @@ async function deleteEventSourceSubscription(request, ctx) {
8492
9022
  const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
8493
9023
  return (0, itty_router.json)(result);
8494
9024
  }
9025
+ function tagResponseBody(entity) {
9026
+ const publicEntity = toPublicEntity(entity);
9027
+ if (entity.txid !== void 0) return {
9028
+ ...publicEntity,
9029
+ txid: entity.txid
9030
+ };
9031
+ return publicEntity;
9032
+ }
8495
9033
  async function setTag(request, ctx) {
8496
9034
  const principalMutationError = rejectPrincipalEntityMutation(request, `tag updated`);
8497
9035
  if (principalMutationError) return principalMutationError;
8498
9036
  const parsed = routeBody(request);
8499
9037
  const { entityUrl } = requireExistingEntityRoute(request);
8500
- const token = writeTokenFromRequest(request);
8501
- const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value }, token);
8502
- return (0, itty_router.json)(toPublicEntity(updated));
9038
+ const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value });
9039
+ return (0, itty_router.json)(tagResponseBody(updated));
8503
9040
  }
8504
9041
  async function deleteTag(request, ctx) {
8505
9042
  const principalMutationError = rejectPrincipalEntityMutation(request, `tag deleted`);
8506
9043
  if (principalMutationError) return principalMutationError;
8507
9044
  const { entityUrl } = requireExistingEntityRoute(request);
8508
- const token = writeTokenFromRequest(request);
8509
- const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey), token);
8510
- return (0, itty_router.json)(toPublicEntity(updated));
9045
+ const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey));
9046
+ return (0, itty_router.json)(tagResponseBody(updated));
8511
9047
  }
8512
9048
  async function forkEntity(request, ctx) {
8513
9049
  const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
@@ -8574,9 +9110,29 @@ async function sendEntity(request, ctx) {
8574
9110
  mode: parsed.mode,
8575
9111
  position: parsed.position
8576
9112
  };
8577
- if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
8578
- else await ctx.entityManager.send(entityUrl, sendReq);
8579
- return (0, itty_router.status)(204);
9113
+ if (parsed.afterMs && parsed.afterMs > 0) {
9114
+ await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
9115
+ return (0, itty_router.status)(204);
9116
+ }
9117
+ const result = await ctx.entityManager.send(entityUrl, sendReq);
9118
+ return (0, itty_router.json)(result);
9119
+ }
9120
+ async function writeCollection(request, ctx) {
9121
+ const parsed = routeBody(request);
9122
+ await ctx.entityManager.ensurePrincipal(ctx.principal);
9123
+ const { entityUrl } = requireExistingEntityRoute(request);
9124
+ const collection = request.params.collection;
9125
+ const result = await ctx.entityManager.writeCollection(entityUrl, collection, {
9126
+ operation: parsed.operation,
9127
+ key: parsed.key,
9128
+ value: parsed.value,
9129
+ principal: {
9130
+ url: ctx.principal.url,
9131
+ kind: ctx.principal.kind,
9132
+ id: ctx.principal.id
9133
+ }
9134
+ });
9135
+ return (0, itty_router.json)(result, { status: parsed.operation === `insert` ? 201 : 200 });
8580
9136
  }
8581
9137
  async function createAttachment(request, ctx) {
8582
9138
  const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
@@ -8619,13 +9175,13 @@ async function deleteAttachment(request, ctx) {
8619
9175
  async function updateInboxMessage(request, ctx) {
8620
9176
  const parsed = routeBody(request);
8621
9177
  const { entityUrl } = requireExistingEntityRoute(request);
8622
- await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
8623
- return (0, itty_router.status)(204);
9178
+ const result = await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
9179
+ return (0, itty_router.json)(result);
8624
9180
  }
8625
9181
  async function deleteInboxMessage(request, ctx) {
8626
9182
  const { entityUrl } = requireExistingEntityRoute(request);
8627
- await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
8628
- return (0, itty_router.status)(204);
9183
+ const result = await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
9184
+ return (0, itty_router.json)(result);
8629
9185
  }
8630
9186
  async function spawnEntity(request, ctx) {
8631
9187
  const parsed = routeBody(request);
@@ -8675,8 +9231,13 @@ async function spawnEntity(request, ctx) {
8675
9231
  headers: { "x-write-token": entity.write_token }
8676
9232
  });
8677
9233
  }
8678
- function getEntity(request) {
8679
- return (0, itty_router.json)(toPublicEntity(requireExistingEntityRoute(request).entity));
9234
+ async function getEntity(request, ctx) {
9235
+ const { entity } = requireExistingEntityRoute(request);
9236
+ const entityType = entity.type ? await ctx.entityManager.registry.getEntityType(entity.type) : null;
9237
+ return (0, itty_router.json)({
9238
+ ...toPublicEntity(entity),
9239
+ ...entityType?.externally_writable_collections && { externally_writable_collections: entityType.externally_writable_collections }
9240
+ });
8680
9241
  }
8681
9242
  function headEntity() {
8682
9243
  return (0, itty_router.status)(200);
@@ -8711,6 +9272,16 @@ async function signalEntity(request, ctx) {
8711
9272
  //#region src/routing/entity-types-router.ts
8712
9273
  const jsonObjectSchema = __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Unknown());
8713
9274
  const schemaMapSchema = __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), jsonObjectSchema);
9275
+ const externallyWritableCollectionsSchema = __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Object({
9276
+ type: __sinclair_typebox.Type.String(),
9277
+ contract: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
9278
+ operations: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(__sinclair_typebox.Type.Union([
9279
+ __sinclair_typebox.Type.Literal(`insert`),
9280
+ __sinclair_typebox.Type.Literal(`update`),
9281
+ __sinclair_typebox.Type.Literal(`delete`)
9282
+ ]))),
9283
+ principalColumn: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
9284
+ }, { additionalProperties: false }));
8714
9285
  const slashCommandArgumentSchema = __sinclair_typebox.Type.Object({
8715
9286
  name: __sinclair_typebox.Type.String(),
8716
9287
  type: __sinclair_typebox.Type.Union([
@@ -8741,7 +9312,8 @@ const registerEntityTypeBodySchema = __sinclair_typebox.Type.Object({
8741
9312
  slash_commands: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(slashCommandSchema)),
8742
9313
  serve_endpoint: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
8743
9314
  default_dispatch_policy: __sinclair_typebox.Type.Optional(dispatchPolicySchema),
8744
- permission_grants: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(typePermissionGrantInputSchema))
9315
+ permission_grants: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(typePermissionGrantInputSchema)),
9316
+ externally_writable_collections: __sinclair_typebox.Type.Optional(externallyWritableCollectionsSchema)
8745
9317
  }, { additionalProperties: false });
8746
9318
  const amendEntityTypeSchemasBodySchema = __sinclair_typebox.Type.Object({
8747
9319
  inbox_schemas: __sinclair_typebox.Type.Optional(schemaMapSchema),
@@ -8872,7 +9444,20 @@ function parseExpiresAt(value) {
8872
9444
  if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
8873
9445
  return expiresAt;
8874
9446
  }
9447
+ /**
9448
+ * The `comments` collection name is reserved for the canonical comments
9449
+ * contract: the UI keys its comment affordances on it, so a divergent
9450
+ * collection registered under that name (or the contract mounted under
9451
+ * another name) would break that assumption silently.
9452
+ */
9453
+ function validateExternallyWritableCollections(collections) {
9454
+ for (const [name, config] of Object.entries(collections ?? {})) {
9455
+ 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);
9456
+ 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);
9457
+ }
9458
+ }
8875
9459
  function normalizeEntityTypeRequest(parsed) {
9460
+ validateExternallyWritableCollections(parsed.externally_writable_collections);
8876
9461
  const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
8877
9462
  return {
8878
9463
  name: parsed.name ?? ``,
@@ -8886,7 +9471,8 @@ function normalizeEntityTypeRequest(parsed) {
8886
9471
  type: `webhook`,
8887
9472
  url: serveEndpoint
8888
9473
  }] } : void 0),
8889
- permission_grants: parsed.permission_grants
9474
+ permission_grants: parsed.permission_grants,
9475
+ externally_writable_collections: parsed.externally_writable_collections
8890
9476
  };
8891
9477
  }
8892
9478
  function toPublicEntityType(entityType) {
@@ -8896,6 +9482,49 @@ function toPublicEntityType(entityType) {
8896
9482
  };
8897
9483
  }
8898
9484
 
9485
+ //#endregion
9486
+ //#region src/routing/pg-sync-router.ts
9487
+ const pgSyncOptionsSchema = __sinclair_typebox.Type.Object({
9488
+ url: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
9489
+ table: __sinclair_typebox.Type.String(),
9490
+ columns: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(__sinclair_typebox.Type.String())),
9491
+ where: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
9492
+ params: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Array(__sinclair_typebox.Type.String()), __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.String())])),
9493
+ replica: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`default`), __sinclair_typebox.Type.Literal(`full`)]))
9494
+ });
9495
+ const pgSyncRequestMetadataSchema = __sinclair_typebox.Type.Object({
9496
+ entityUrl: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
9497
+ entityType: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
9498
+ streamPath: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
9499
+ runtimeConsumerId: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
9500
+ wakeId: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
9501
+ });
9502
+ const pgSyncRegisterBodySchema = __sinclair_typebox.Type.Object({
9503
+ options: pgSyncOptionsSchema,
9504
+ metadata: __sinclair_typebox.Type.Optional(pgSyncRequestMetadataSchema)
9505
+ });
9506
+ const pgSyncRouter = (0, itty_router.Router)({ base: `/_electric/pg-sync` });
9507
+ pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
9508
+ async function registerPgSync(request, ctx) {
9509
+ const { options, metadata } = routeBody(request);
9510
+ if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
9511
+ if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
9512
+ try {
9513
+ const requestMetadata$1 = {
9514
+ tenantId: ctx.service,
9515
+ principalKind: ctx.principal.kind,
9516
+ principalId: ctx.principal.id,
9517
+ principalKey: ctx.principal.key,
9518
+ principalUrl: ctx.principal.url,
9519
+ ...metadata ?? {}
9520
+ };
9521
+ const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
9522
+ return (0, itty_router.json)(result);
9523
+ } catch (error) {
9524
+ return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
9525
+ }
9526
+ }
9527
+
8899
9528
  //#endregion
8900
9529
  //#region src/routing/hooks.ts
8901
9530
  const SPAN_KEY = Symbol(`agents-server.otel-span`);
@@ -8970,6 +9599,10 @@ function errorMapper(err, req) {
8970
9599
  });
8971
9600
  }
8972
9601
  if (err instanceof ElectricAgentsError) return apiError(err.status, err.code, err.message, err.details);
9602
+ if (err instanceof ElectricProxyError) {
9603
+ serverLog.warn(`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`);
9604
+ return apiError(err.status, err.code, err.message);
9605
+ }
8973
9606
  serverLog.error(`[agent-server] Unhandled error:`, err);
8974
9607
  return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`);
8975
9608
  }
@@ -9420,6 +10053,7 @@ internalRouter.all(`/runners`, runnersRouter.fetch);
9420
10053
  internalRouter.all(`/runners/*`, runnersRouter.fetch);
9421
10054
  internalRouter.all(`/entities/*`, entitiesRouter.fetch);
9422
10055
  internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch);
10056
+ internalRouter.all(`/pg-sync/*`, pgSyncRouter.fetch);
9423
10057
  internalRouter.all(`/observations/*`, observationsRouter.fetch);
9424
10058
  internalRouter.get(`/electric/*`, electricProxyRouter.fetch);
9425
10059
  internalRouter.all(`*`, () => (0, itty_router.status)(404));
@@ -9803,6 +10437,9 @@ const globalRouter = (0, itty_router.AutoRouter)({
9803
10437
  finally: [otelEndSpan, applyCors]
9804
10438
  });
9805
10439
  globalRouter.all(`/_electric/shared-state/*`, durableStreamsRouter.fetch);
10440
+ globalRouter.all(`/_electric/pg-sync/register`, internalRouter.fetch);
10441
+ globalRouter.get(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
10442
+ globalRouter.head(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
9806
10443
  globalRouter.all(`/_electric/*`, internalRouter.fetch);
9807
10444
  globalRouter.all(`*`, durableStreamsRouter.fetch);
9808
10445