@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.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 { COMMENTS_CONTRACT, COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, canonicalPgSyncOptions, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
12
12
  import { DurableStream, DurableStreamError, FetchError, IdempotentProducer } from "@durable-streams/client";
13
13
  import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
14
14
  import pino from "pino";
@@ -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,
@@ -49,6 +50,7 @@ const entityTypes = pgTable(`entity_types`, {
49
50
  creationSchema: jsonb(`creation_schema`),
50
51
  inboxSchemas: jsonb(`inbox_schemas`),
51
52
  stateSchemas: jsonb(`state_schemas`),
53
+ externallyWritableCollections: jsonb(`externally_writable_collections`).$type(),
52
54
  slashCommands: jsonb(`slash_commands`),
53
55
  serveEndpoint: text(`serve_endpoint`),
54
56
  defaultDispatchPolicy: jsonb(`default_dispatch_policy`),
@@ -353,6 +355,18 @@ const scheduledTasks = pgTable(`scheduled_tasks`, {
353
355
  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
356
  index(`idx_scheduled_tasks_stale_claims`).on(table.tenantId, table.claimedAt).where(sql`${table.completedAt} IS NULL AND ${table.claimedAt} IS NOT NULL`)
355
357
  ]);
358
+ const pgSyncBridges = pgTable(`pg_sync_bridges`, {
359
+ tenantId: text(`tenant_id`).notNull().default(`default`),
360
+ sourceRef: text(`source_ref`).notNull(),
361
+ options: jsonb(`options`).notNull(),
362
+ streamUrl: text(`stream_url`).notNull(),
363
+ shapeHandle: text(`shape_handle`),
364
+ shapeOffset: text(`shape_offset`),
365
+ initialSnapshotComplete: boolean(`initial_snapshot_complete`).notNull().default(false),
366
+ lastTouchedAt: timestamp(`last_touched_at`, { withTimezone: true }).notNull().defaultNow(),
367
+ createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
368
+ updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
369
+ }, (table) => [primaryKey({ columns: [table.tenantId, table.sourceRef] }), unique(`uq_pg_sync_bridges_stream_url`).on(table.tenantId, table.streamUrl)]);
356
370
  const entityBridges = pgTable(`entity_bridges`, {
357
371
  tenantId: text(`tenant_id`).notNull().default(`default`),
358
372
  sourceRef: text(`source_ref`).notNull(),
@@ -813,6 +827,9 @@ var PostgresRegistry = class {
813
827
  entityBridgeWhere(sourceRef) {
814
828
  return and(eq(entityBridges.tenantId, this.tenantId), eq(entityBridges.sourceRef, sourceRef));
815
829
  }
830
+ pgSyncBridgeWhere(sourceRef) {
831
+ return and(eq(pgSyncBridges.tenantId, this.tenantId), eq(pgSyncBridges.sourceRef, sourceRef));
832
+ }
816
833
  async createEntityType(et) {
817
834
  await this.db.insert(entityTypes).values({
818
835
  tenantId: this.tenantId,
@@ -821,6 +838,7 @@ var PostgresRegistry = class {
821
838
  creationSchema: et.creation_schema ?? null,
822
839
  inboxSchemas: et.inbox_schemas ?? null,
823
840
  stateSchemas: et.state_schemas ?? null,
841
+ externallyWritableCollections: et.externally_writable_collections ?? null,
824
842
  slashCommands: et.slash_commands ?? null,
825
843
  serveEndpoint: et.serve_endpoint ?? null,
826
844
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -834,6 +852,7 @@ var PostgresRegistry = class {
834
852
  creationSchema: et.creation_schema ?? null,
835
853
  inboxSchemas: et.inbox_schemas ?? null,
836
854
  stateSchemas: et.state_schemas ?? null,
855
+ externallyWritableCollections: et.externally_writable_collections ?? null,
837
856
  slashCommands: et.slash_commands ?? null,
838
857
  serveEndpoint: et.serve_endpoint ?? null,
839
858
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -852,6 +871,7 @@ var PostgresRegistry = class {
852
871
  creationSchema: et.creation_schema ?? null,
853
872
  inboxSchemas: et.inbox_schemas ?? null,
854
873
  stateSchemas: et.state_schemas ?? null,
874
+ externallyWritableCollections: et.externally_writable_collections ?? null,
855
875
  slashCommands: et.slash_commands ?? null,
856
876
  serveEndpoint: et.serve_endpoint ?? null,
857
877
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -879,6 +899,7 @@ var PostgresRegistry = class {
879
899
  creationSchema: et.creation_schema ?? null,
880
900
  inboxSchemas: et.inbox_schemas ?? null,
881
901
  stateSchemas: et.state_schemas ?? null,
902
+ externallyWritableCollections: et.externally_writable_collections ?? null,
882
903
  slashCommands: et.slash_commands ?? null,
883
904
  serveEndpoint: et.serve_endpoint ?? null,
884
905
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -1282,11 +1303,12 @@ var PostgresRegistry = class {
1282
1303
  };
1283
1304
  const nextTags = normalizeTags(mutation.nextTags);
1284
1305
  const updatedAt = Date.now();
1285
- await tx.update(entities).set({
1306
+ const [updateResult] = await tx.update(entities).set({
1286
1307
  tags: nextTags,
1287
1308
  tagsIndex: buildTagsIndex(nextTags),
1288
1309
  updatedAt
1289
- }).where(this.entityWhere(url));
1310
+ }).where(this.entityWhere(url)).returning({ txid: sql`pg_current_xact_id()::xid::text` });
1311
+ const txid = updateResult ? parseInt(updateResult.txid) : void 0;
1290
1312
  await tx.insert(tagStreamOutbox).values({
1291
1313
  tenantId: this.tenantId,
1292
1314
  entityUrl: url,
@@ -1304,10 +1326,63 @@ var PostgresRegistry = class {
1304
1326
  return {
1305
1327
  entity,
1306
1328
  changed: true,
1307
- ...op === `insert` || op === `update` ? { op } : {}
1329
+ ...op === `insert` || op === `update` ? { op } : {},
1330
+ ...txid !== void 0 ? { txid } : {}
1308
1331
  };
1309
1332
  });
1310
1333
  }
1334
+ async upsertPgSyncBridge(row) {
1335
+ await this.db.insert(pgSyncBridges).values({
1336
+ tenantId: this.tenantId,
1337
+ sourceRef: row.sourceRef,
1338
+ options: row.options,
1339
+ streamUrl: row.streamUrl,
1340
+ lastTouchedAt: new Date(),
1341
+ updatedAt: new Date()
1342
+ }).onConflictDoUpdate({
1343
+ target: [pgSyncBridges.tenantId, pgSyncBridges.sourceRef],
1344
+ set: {
1345
+ options: row.options,
1346
+ streamUrl: row.streamUrl,
1347
+ initialSnapshotComplete: false,
1348
+ lastTouchedAt: new Date(),
1349
+ updatedAt: new Date()
1350
+ }
1351
+ });
1352
+ const existing = await this.getPgSyncBridge(row.sourceRef);
1353
+ if (!existing) throw new Error(`Failed to load pgSync bridge ${row.sourceRef}`);
1354
+ return existing;
1355
+ }
1356
+ async getPgSyncBridge(sourceRef) {
1357
+ const rows = await this.db.select().from(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef)).limit(1);
1358
+ return rows[0] ? this.rowToPgSyncBridge(rows[0]) : null;
1359
+ }
1360
+ async listPgSyncBridges(tenantId = this.tenantId) {
1361
+ const rows = tenantId === null ? await this.db.select().from(pgSyncBridges) : await this.db.select().from(pgSyncBridges).where(eq(pgSyncBridges.tenantId, tenantId));
1362
+ return rows.map((row) => this.rowToPgSyncBridge(row));
1363
+ }
1364
+ async touchPgSyncBridge(sourceRef) {
1365
+ await this.db.update(pgSyncBridges).set({
1366
+ lastTouchedAt: new Date(),
1367
+ updatedAt: new Date()
1368
+ }).where(this.pgSyncBridgeWhere(sourceRef));
1369
+ }
1370
+ async updatePgSyncBridgeCursor(sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete) {
1371
+ await this.db.update(pgSyncBridges).set({
1372
+ shapeHandle,
1373
+ shapeOffset,
1374
+ ...initialSnapshotComplete !== void 0 ? { initialSnapshotComplete } : {},
1375
+ updatedAt: new Date()
1376
+ }).where(this.pgSyncBridgeWhere(sourceRef));
1377
+ }
1378
+ async clearPgSyncBridgeCursor(sourceRef) {
1379
+ await this.db.update(pgSyncBridges).set({
1380
+ shapeHandle: null,
1381
+ shapeOffset: null,
1382
+ initialSnapshotComplete: false,
1383
+ updatedAt: new Date()
1384
+ }).where(this.pgSyncBridgeWhere(sourceRef));
1385
+ }
1311
1386
  async upsertEntityBridge(row) {
1312
1387
  await this.db.insert(entityBridges).values({
1313
1388
  tenantId: this.tenantId,
@@ -1470,6 +1545,7 @@ var PostgresRegistry = class {
1470
1545
  creation_schema: row.creationSchema,
1471
1546
  inbox_schemas: row.inboxSchemas,
1472
1547
  state_schemas: row.stateSchemas,
1548
+ externally_writable_collections: row.externallyWritableCollections ?? void 0,
1473
1549
  slash_commands: row.slashCommands ?? void 0,
1474
1550
  serve_endpoint: row.serveEndpoint ?? void 0,
1475
1551
  default_dispatch_policy: row.defaultDispatchPolicy ?? void 0,
@@ -1527,6 +1603,20 @@ var PostgresRegistry = class {
1527
1603
  updated_at: row.updatedAt
1528
1604
  };
1529
1605
  }
1606
+ rowToPgSyncBridge(row) {
1607
+ return {
1608
+ tenantId: row.tenantId,
1609
+ sourceRef: row.sourceRef,
1610
+ options: row.options,
1611
+ streamUrl: row.streamUrl,
1612
+ shapeHandle: row.shapeHandle ?? void 0,
1613
+ shapeOffset: row.shapeOffset ?? void 0,
1614
+ initialSnapshotComplete: row.initialSnapshotComplete,
1615
+ lastTouchedAt: row.lastTouchedAt,
1616
+ createdAt: row.createdAt,
1617
+ updatedAt: row.updatedAt
1618
+ };
1619
+ }
1530
1620
  rowToEntityBridge(row) {
1531
1621
  return {
1532
1622
  tenantId: row.tenantId,
@@ -3174,6 +3264,9 @@ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
3174
3264
  function isRecord$1(value) {
3175
3265
  return typeof value === `object` && value !== null && !Array.isArray(value);
3176
3266
  }
3267
+ function getPgSyncManifestStreamPath(sourceRef) {
3268
+ return `/_electric/pg-sync/${sourceRef}`;
3269
+ }
3177
3270
  function extractManifestSourceUrl(manifest) {
3178
3271
  if (!manifest) return void 0;
3179
3272
  if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
@@ -3186,6 +3279,7 @@ function extractManifestSourceUrl(manifest) {
3186
3279
  }
3187
3280
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
3188
3281
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
3282
+ if (manifest.sourceType === `pgSync`) return typeof manifest.sourceRef === `string` ? getPgSyncManifestStreamPath(manifest.sourceRef) : void 0;
3189
3283
  if (manifest.sourceType === `webhook`) {
3190
3284
  if (typeof config?.streamUrl === `string`) return config.streamUrl;
3191
3285
  if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
@@ -3300,6 +3394,13 @@ function isRecord(value) {
3300
3394
  function cloneRecord(value) {
3301
3395
  return JSON.parse(JSON.stringify(value));
3302
3396
  }
3397
+ function withOptionalTxid(entity, txid) {
3398
+ if (txid === void 0) return entity;
3399
+ return {
3400
+ ...entity,
3401
+ txid
3402
+ };
3403
+ }
3303
3404
  /**
3304
3405
  * Orchestrates the Electric Agents entity lifecycle: register types, spawn, send, kill.
3305
3406
  *
@@ -3374,6 +3475,7 @@ var EntityManager = class {
3374
3475
  creation_schema: req.creation_schema,
3375
3476
  inbox_schemas: req.inbox_schemas,
3376
3477
  state_schemas: req.state_schemas,
3478
+ externally_writable_collections: req.externally_writable_collections,
3377
3479
  slash_commands: req.slash_commands,
3378
3480
  serve_endpoint: req.serve_endpoint,
3379
3481
  default_dispatch_policy: defaultDispatchPolicy,
@@ -4441,15 +4543,17 @@ var EntityManager = class {
4441
4543
  await this.registry.updateStatus(entityUrl, `idle`);
4442
4544
  await this.entityBridgeManager?.onEntityChanged(entityUrl);
4443
4545
  }
4546
+ const txid = crypto.randomUUID();
4444
4547
  const envelope = entityStateSchema.inbox.insert({
4445
4548
  key,
4446
- value
4549
+ value,
4550
+ headers: { txid }
4447
4551
  });
4448
4552
  const encoded = this.encodeChangeEvent(envelope);
4449
4553
  try {
4450
4554
  if (opts?.producerId) {
4451
4555
  await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
4452
- return;
4556
+ return { txid };
4453
4557
  }
4454
4558
  await this.streamClient.append(entity.streams.main, encoded);
4455
4559
  if (entity.type === `principal` && req.type === `update_identity`) {
@@ -4457,14 +4561,50 @@ var EntityManager = class {
4457
4561
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent({
4458
4562
  type: `identity`,
4459
4563
  key: `self`,
4460
- value: identity
4564
+ value: identity,
4565
+ headers: { txid }
4461
4566
  }));
4462
4567
  }
4568
+ return { txid };
4463
4569
  } catch (err) {
4464
4570
  if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4465
4571
  throw err;
4466
4572
  }
4467
4573
  }
4574
+ async writeCollection(entityUrl, collection, req) {
4575
+ const entity = await this.registry.getEntity(entityUrl);
4576
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4577
+ const { externallyWritableCollections } = await this.getEffectiveSchemas(entity);
4578
+ const config = externallyWritableCollections?.[collection];
4579
+ if (!config) throw new ElectricAgentsError(ErrCodeUnauthorized, `Collection "${collection}" is not writable`, 403);
4580
+ const allowedOperations = config.operations ?? [`insert`];
4581
+ if (!allowedOperations.includes(req.operation)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Operation "${req.operation}" is not allowed on collection "${collection}"`, 403);
4582
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4583
+ if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
4584
+ if (req.operation !== `delete` && (req.value === void 0 || req.value === null)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `value is required for ${req.operation}`, 400);
4585
+ if (req.operation !== `insert` && !req.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `key is required for ${req.operation}`, 400);
4586
+ const key = req.key ?? `${collection}-${randomUUID()}`;
4587
+ const event = {
4588
+ type: config.type,
4589
+ key,
4590
+ headers: {
4591
+ operation: req.operation,
4592
+ timestamp: new Date().toISOString(),
4593
+ principal: req.principal
4594
+ }
4595
+ };
4596
+ if (req.operation !== `delete`) event.value = req.value;
4597
+ const validationError = await this.validateWriteEvent(entity, event);
4598
+ if (validationError) throw new ElectricAgentsError(validationError.code, validationError.message, validationError.status);
4599
+ const encoded = this.encodeChangeEvent(event);
4600
+ try {
4601
+ await this.streamClient.append(entity.streams.main, encoded);
4602
+ } catch (err) {
4603
+ if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4604
+ throw err;
4605
+ }
4606
+ return { key };
4607
+ }
4468
4608
  async updateInboxMessage(entityUrl, key, req) {
4469
4609
  const entity = await this.registry.getEntity(entityUrl);
4470
4610
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
@@ -4480,18 +4620,26 @@ var EntityManager = class {
4480
4620
  if (req.status === `cancelled`) value.cancelled_at = now;
4481
4621
  }
4482
4622
  if (Object.keys(value).length === 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `No inbox fields to update`, 400);
4623
+ const txid = crypto.randomUUID();
4483
4624
  const envelope = entityStateSchema.inbox.update({
4484
4625
  key,
4485
- value
4626
+ value,
4627
+ headers: { txid }
4486
4628
  });
4487
4629
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
4630
+ return { txid };
4488
4631
  }
4489
4632
  async deleteInboxMessage(entityUrl, key) {
4490
4633
  const entity = await this.registry.getEntity(entityUrl);
4491
4634
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4492
4635
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4493
- const envelope = entityStateSchema.inbox.delete({ key });
4636
+ const txid = crypto.randomUUID();
4637
+ const envelope = entityStateSchema.inbox.delete({
4638
+ key,
4639
+ headers: { txid }
4640
+ });
4494
4641
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
4642
+ return { txid };
4495
4643
  }
4496
4644
  isAttachmentStreamPath(path$1) {
4497
4645
  return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$1);
@@ -4580,28 +4728,26 @@ var EntityManager = class {
4580
4728
  await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
4581
4729
  return { txid };
4582
4730
  }
4583
- async setTag(entityUrl, key, req, token) {
4731
+ async setTag(entityUrl, key, req) {
4584
4732
  const entity = await this.registry.getEntity(entityUrl);
4585
4733
  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
4734
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4588
4735
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
4589
4736
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
4590
4737
  const updated = result.entity;
4591
4738
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag write`, 500);
4592
4739
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4593
- return updated;
4740
+ return withOptionalTxid(updated, result.txid);
4594
4741
  }
4595
- async deleteTag(entityUrl, key, token) {
4742
+ async deleteTag(entityUrl, key) {
4596
4743
  const entity = await this.registry.getEntity(entityUrl);
4597
4744
  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
4745
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4600
4746
  const result = await this.registry.removeEntityTag(entityUrl, key);
4601
4747
  const updated = result.entity;
4602
4748
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
4603
4749
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4604
- return updated;
4750
+ return withOptionalTxid(updated, result.txid);
4605
4751
  }
4606
4752
  async ensureEntitiesMembershipStream(tags, principal) {
4607
4753
  if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
@@ -5151,7 +5297,8 @@ var EntityManager = class {
5151
5297
  async getEffectiveSchemas(entity) {
5152
5298
  if (!entity.type) return {
5153
5299
  inboxSchemas: entity.inbox_schemas,
5154
- stateSchemas: entity.state_schemas
5300
+ stateSchemas: entity.state_schemas,
5301
+ externallyWritableCollections: void 0
5155
5302
  };
5156
5303
  const latestType = await this.registry.getEntityType(entity.type);
5157
5304
  return {
@@ -5162,7 +5309,8 @@ var EntityManager = class {
5162
5309
  stateSchemas: latestType?.state_schemas ? {
5163
5310
  ...entity.state_schemas ?? {},
5164
5311
  ...latestType.state_schemas
5165
- } : entity.state_schemas
5312
+ } : entity.state_schemas,
5313
+ externallyWritableCollections: latestType?.externally_writable_collections
5166
5314
  };
5167
5315
  }
5168
5316
  isClosedStreamError(err) {
@@ -5397,6 +5545,9 @@ function isPermanentElectricAgentsError(err) {
5397
5545
  const name = typeof err === `object` && err !== null && `name` in err ? err.name : void 0;
5398
5546
  return name === `ElectricAgentsError` && typeof status$1 === `number` && status$1 >= 400 && status$1 < 500;
5399
5547
  }
5548
+ function cronTaskStreamPath(payload) {
5549
+ return typeof payload.streamPath === `string` ? payload.streamPath : null;
5550
+ }
5400
5551
  function normalizeTask(row) {
5401
5552
  return {
5402
5553
  id: Number(row.id),
@@ -5739,6 +5890,15 @@ var Scheduler = class {
5739
5890
  `;
5740
5891
  if (completed.length === 0) return;
5741
5892
  const nextFireAt = getNextCronFireAt(task.cronExpression, task.cronTimezone, task.fireAt);
5893
+ const streamPath = cronTaskStreamPath(task.payload);
5894
+ const subscriberRows = streamPath ? await sql$1`
5895
+ select 1 as exists
5896
+ from wake_registrations
5897
+ where tenant_id = ${tenantId}
5898
+ and source_url = ${streamPath}
5899
+ limit 1
5900
+ ` : [];
5901
+ if (subscriberRows.length === 0) return;
5742
5902
  await sql$1`
5743
5903
  insert into scheduled_tasks (
5744
5904
  tenant_id,
@@ -5838,6 +5998,308 @@ var Scheduler = class {
5838
5998
  }
5839
5999
  };
5840
6000
 
6001
+ //#endregion
6002
+ //#region src/pg-sync-bridge-manager.ts
6003
+ const PG_SYNC_ELECTRIC_SHAPE_URL = process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ?? `http://localhost:3000/v1/shape`;
6004
+ const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
6005
+ const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
6006
+ function buildElectricShapeParams(options) {
6007
+ return {
6008
+ table: options.table,
6009
+ ...options.columns !== void 0 ? { columns: [...options.columns] } : {},
6010
+ ...options.where !== void 0 ? { where: options.where } : {},
6011
+ ...options.params !== void 0 ? { params: Array.isArray(options.params) ? [...options.params] : { ...options.params } } : {},
6012
+ ...options.replica !== void 0 ? { replica: options.replica } : {},
6013
+ ...options.metadata?.tenantId ? { electric_agents_tenant_id: options.metadata.tenantId } : {},
6014
+ ...options.metadata?.principalKind ? { electric_agents_principal_kind: options.metadata.principalKind } : {},
6015
+ ...options.metadata?.principalId ? { electric_agents_principal_id: options.metadata.principalId } : {},
6016
+ ...options.metadata?.principalKey ? { electric_agents_principal_key: options.metadata.principalKey } : {},
6017
+ ...options.metadata?.principalUrl ? { electric_agents_principal_url: options.metadata.principalUrl } : {},
6018
+ ...options.metadata?.entityUrl ? { electric_agents_entity_url: options.metadata.entityUrl } : {},
6019
+ ...options.metadata?.entityType ? { electric_agents_entity_type: options.metadata.entityType } : {},
6020
+ ...options.metadata?.streamPath ? { electric_agents_stream_path: options.metadata.streamPath } : {},
6021
+ ...options.metadata?.runtimeConsumerId ? { electric_agents_runtime_consumer_id: options.metadata.runtimeConsumerId } : {},
6022
+ ...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
6023
+ };
6024
+ }
6025
+ function jsonSafe(value) {
6026
+ if (typeof value === `bigint`) return value.toString();
6027
+ if (value === null || typeof value !== `object`) return value;
6028
+ if (Array.isArray(value)) return value.map(jsonSafe);
6029
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
6030
+ }
6031
+ function stableJson(value) {
6032
+ if (typeof value === `bigint`) return JSON.stringify(value.toString());
6033
+ if (value === null || typeof value !== `object`) return JSON.stringify(value);
6034
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`;
6035
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(`,`)}}`;
6036
+ }
6037
+ function parseElectricOffset(offset) {
6038
+ if (offset === `-1`) return offset;
6039
+ return /^\d+_\d+$/.test(offset) ? offset : null;
6040
+ }
6041
+ function rowKeyForMessage(message) {
6042
+ const headers = message.headers;
6043
+ const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
6044
+ return candidate === void 0 ? void 0 : stableJson(candidate);
6045
+ }
6046
+ function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
6047
+ const operation = message.headers.operation;
6048
+ if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
6049
+ const sourceRef = typeof optionsOrSourceRef === `string` ? optionsOrSourceRef : sourceRefForPgSync(optionsOrSourceRef);
6050
+ const rowKey = rowKeyForMessage(message);
6051
+ const offset = message.headers.offset;
6052
+ if (typeof offset !== `string` || offset.length === 0) return null;
6053
+ const messageKeyPart = offset;
6054
+ const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
6055
+ const timestamp$1 = new Date().toISOString();
6056
+ const oldValue = message.old_value;
6057
+ const safeValue = jsonSafe(message.value);
6058
+ const safeOldValue = jsonSafe(oldValue);
6059
+ const safeHeaders = jsonSafe(message.headers);
6060
+ return {
6061
+ type: `pg_sync_change`,
6062
+ key: messageKey,
6063
+ value: {
6064
+ key: messageKey,
6065
+ table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
6066
+ operation,
6067
+ ...rowKey !== void 0 ? { rowKey } : {},
6068
+ ...message.value !== void 0 ? { value: safeValue } : {},
6069
+ ...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
6070
+ headers: safeHeaders,
6071
+ ...typeof offset === `string` ? { offset } : {},
6072
+ receivedAt: timestamp$1
6073
+ },
6074
+ headers: {
6075
+ operation,
6076
+ timestamp: timestamp$1
6077
+ }
6078
+ };
6079
+ }
6080
+ function cursorFromRow(row) {
6081
+ return row?.shapeHandle && row.shapeOffset ? {
6082
+ handle: row.shapeHandle,
6083
+ offset: row.shapeOffset,
6084
+ initialSnapshotComplete: row.initialSnapshotComplete
6085
+ } : void 0;
6086
+ }
6087
+ var PgSyncBridge = class {
6088
+ producer = null;
6089
+ unsubscribe = null;
6090
+ abortController = null;
6091
+ skipChangesUntilUpToDate = false;
6092
+ recovering = false;
6093
+ committedCursor;
6094
+ retryAttempt = 0;
6095
+ constructor(sourceRef, streamUrl, options, resolvedSource, retry, streamClient, registry, evaluateWakes, initialCursor) {
6096
+ this.sourceRef = sourceRef;
6097
+ this.streamUrl = streamUrl;
6098
+ this.options = options;
6099
+ this.resolvedSource = resolvedSource;
6100
+ this.retry = retry;
6101
+ this.streamClient = streamClient;
6102
+ this.registry = registry;
6103
+ this.evaluateWakes = evaluateWakes;
6104
+ this.initialCursor = initialCursor;
6105
+ this.committedCursor = initialCursor;
6106
+ }
6107
+ async start() {
6108
+ if (!this.producer) this.producer = new IdempotentProducer(new DurableStream({
6109
+ url: `${this.streamClient.baseUrl}${this.streamUrl}`,
6110
+ contentType: `application/json`
6111
+ }), `pg-sync-bridge-${this.sourceRef}`);
6112
+ if (this.initialCursor) {
6113
+ const offset = parseElectricOffset(this.initialCursor.offset);
6114
+ if (offset) {
6115
+ this.startStream(offset, this.initialCursor.handle, !this.initialCursor.initialSnapshotComplete);
6116
+ return;
6117
+ }
6118
+ }
6119
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
6120
+ this.startStream(`now`, void 0, true);
6121
+ }
6122
+ async stop() {
6123
+ this.unsubscribe?.();
6124
+ this.abortController?.abort();
6125
+ this.unsubscribe = null;
6126
+ this.abortController = null;
6127
+ try {
6128
+ await this.producer?.flush();
6129
+ } finally {
6130
+ await this.producer?.detach();
6131
+ this.producer = null;
6132
+ }
6133
+ }
6134
+ startStream(offset, handle, skipChangesUntilUpToDate = false, log = offset === `now` ? `changes_only` : `full`) {
6135
+ this.unsubscribe?.();
6136
+ this.abortController?.abort();
6137
+ this.skipChangesUntilUpToDate = skipChangesUntilUpToDate;
6138
+ this.abortController = new AbortController();
6139
+ const stream = new ShapeStream({
6140
+ url: this.resolvedSource.url,
6141
+ params: buildElectricShapeParams(this.options),
6142
+ offset,
6143
+ log,
6144
+ ...handle ? { handle } : {},
6145
+ signal: this.abortController.signal
6146
+ });
6147
+ this.unsubscribe = stream.subscribe(async (messages) => {
6148
+ try {
6149
+ for (const message of messages) {
6150
+ if (isControlMessage(message)) {
6151
+ if (message.headers.control === `must-refetch`) {
6152
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
6153
+ this.startStream(`now`, void 0, true);
6154
+ return;
6155
+ }
6156
+ if (message.headers.control === `up-to-date`) {
6157
+ this.skipChangesUntilUpToDate = false;
6158
+ await this.persistCursor(stream, true);
6159
+ continue;
6160
+ }
6161
+ await this.persistCursor(stream);
6162
+ continue;
6163
+ }
6164
+ if (!isChangeMessage(message)) continue;
6165
+ if (!this.skipChangesUntilUpToDate) {
6166
+ const event = pgSyncMessageToDurableEvent(message, this.options);
6167
+ if (event) {
6168
+ if (!this.producer) throw new Error(`pg-sync producer is not started`);
6169
+ await this.producer.append(JSON.stringify(event));
6170
+ await this.producer.flush?.();
6171
+ await this.evaluateWakes?.(this.streamUrl, event);
6172
+ }
6173
+ }
6174
+ await this.persistCursor(stream);
6175
+ this.retryAttempt = 0;
6176
+ }
6177
+ } catch (error) {
6178
+ serverLog.warn(`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`, error);
6179
+ await this.recoverStream();
6180
+ }
6181
+ }, (error) => {
6182
+ if (this.abortController?.signal.aborted) return;
6183
+ serverLog.warn(`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`, error);
6184
+ this.recoverStream();
6185
+ });
6186
+ }
6187
+ async recoverStream() {
6188
+ if (this.recovering) return;
6189
+ this.recovering = true;
6190
+ try {
6191
+ const attempt = this.retryAttempt++;
6192
+ const baseDelay = Math.min(this.retry.initialDelayMs * 2 ** attempt, this.retry.maxDelayMs);
6193
+ const jitter = Math.floor(baseDelay * .2 * this.retry.random());
6194
+ const delay = baseDelay + jitter;
6195
+ if (delay > 0) await this.retry.sleep(delay);
6196
+ const offset = this.committedCursor ? parseElectricOffset(this.committedCursor.offset) : null;
6197
+ if (offset && this.committedCursor) this.startStream(offset, this.committedCursor.handle, !this.committedCursor.initialSnapshotComplete);
6198
+ else {
6199
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
6200
+ this.startStream(`now`, void 0, true);
6201
+ }
6202
+ } finally {
6203
+ this.recovering = false;
6204
+ }
6205
+ }
6206
+ async persistCursor(stream, initialSnapshotComplete = !this.skipChangesUntilUpToDate) {
6207
+ const shapeHandle = stream.shapeHandle;
6208
+ const shapeOffset = stream.lastOffset;
6209
+ if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return;
6210
+ await this.registry?.updatePgSyncBridgeCursor(this.sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete);
6211
+ this.committedCursor = {
6212
+ handle: shapeHandle,
6213
+ offset: shapeOffset,
6214
+ initialSnapshotComplete
6215
+ };
6216
+ }
6217
+ };
6218
+ var PgSyncBridgeManager = class {
6219
+ bridges = new Map();
6220
+ starting = new Map();
6221
+ url;
6222
+ retry;
6223
+ constructor(streamClient, evaluateWakes, registry, options = {}) {
6224
+ this.streamClient = streamClient;
6225
+ this.evaluateWakes = evaluateWakes;
6226
+ this.registry = registry;
6227
+ this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
6228
+ this.retry = {
6229
+ initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
6230
+ maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
6231
+ random: options.retry?.random ?? Math.random,
6232
+ sleep: options.retry?.sleep ?? ((ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)))
6233
+ };
6234
+ }
6235
+ async start() {
6236
+ const rows = await this.registry?.listPgSyncBridges?.();
6237
+ if (!rows) return;
6238
+ await Promise.all(rows.map((row) => this.ensureBridge(row).catch((error) => {
6239
+ serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
6240
+ })));
6241
+ }
6242
+ async register(options, metadata) {
6243
+ const mergedMetadata = {
6244
+ ...options.metadata,
6245
+ ...metadata
6246
+ };
6247
+ const canonicalOptions = {
6248
+ ...canonicalPgSyncOptions(options),
6249
+ ...Object.keys(mergedMetadata).length > 0 ? { metadata: mergedMetadata } : {}
6250
+ };
6251
+ const resolvedSource = this.resolveSource(canonicalOptions);
6252
+ const sourceRef = sourceRefForPgSync(canonicalOptions);
6253
+ const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId);
6254
+ const row = await this.registry?.upsertPgSyncBridge({
6255
+ sourceRef,
6256
+ options: canonicalOptions,
6257
+ streamUrl
6258
+ });
6259
+ await this.streamClient.ensure(streamUrl, { contentType: `application/json` });
6260
+ if (!this.bridges.has(sourceRef)) {
6261
+ let start = this.starting.get(sourceRef);
6262
+ if (!start) {
6263
+ start = (async () => {
6264
+ const bridge = new PgSyncBridge(sourceRef, streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
6265
+ await bridge.start();
6266
+ this.bridges.set(sourceRef, bridge);
6267
+ })().finally(() => this.starting.delete(sourceRef));
6268
+ this.starting.set(sourceRef, start);
6269
+ }
6270
+ await start;
6271
+ }
6272
+ return {
6273
+ sourceRef,
6274
+ streamUrl
6275
+ };
6276
+ }
6277
+ async ensureBridge(row) {
6278
+ if (this.bridges.has(row.sourceRef)) return;
6279
+ let start = this.starting.get(row.sourceRef);
6280
+ if (!start) {
6281
+ start = (async () => {
6282
+ await this.streamClient.ensure(row.streamUrl, { contentType: `application/json` });
6283
+ const canonicalOptions = canonicalPgSyncOptions(row.options);
6284
+ const resolvedSource = this.resolveSource(canonicalOptions);
6285
+ const bridge = new PgSyncBridge(row.sourceRef, row.streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
6286
+ await bridge.start();
6287
+ this.bridges.set(row.sourceRef, bridge);
6288
+ })().finally(() => this.starting.delete(row.sourceRef));
6289
+ this.starting.set(row.sourceRef, start);
6290
+ }
6291
+ await start;
6292
+ }
6293
+ resolveSource(options) {
6294
+ return { url: options.url ?? this.url };
6295
+ }
6296
+ async stop() {
6297
+ await Promise.allSettled(this.starting.values());
6298
+ await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()));
6299
+ this.bridges.clear();
6300
+ }
6301
+ };
6302
+
5841
6303
  //#endregion
5842
6304
  //#region src/runtime.ts
5843
6305
  function omitUndefined(value) {
@@ -5852,6 +6314,7 @@ var ElectricAgentsTenantRuntime = class {
5852
6314
  wakeRegistry;
5853
6315
  scheduler;
5854
6316
  entityBridgeManager;
6317
+ pgSyncBridgeManager;
5855
6318
  claimWriteTokens;
5856
6319
  manager;
5857
6320
  constructor(options) {
@@ -5876,9 +6339,10 @@ var ElectricAgentsTenantRuntime = class {
5876
6339
  writeTokenValidator: (entity, token) => this.claimWriteTokens.isValid(this.serviceId, entity.streams.main, token),
5877
6340
  stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false
5878
6341
  });
6342
+ this.pgSyncBridgeManager = options.pgSyncBridgeManager ?? new PgSyncBridgeManager(this.streamClient, (sourceUrl, event) => this.manager.evaluateWakes(sourceUrl, event), this.registry, options.pgSync);
5879
6343
  }
5880
6344
  async stop() {
5881
- await this.manager.shutdown();
6345
+ await Promise.all([this.manager.shutdown(), this.pgSyncBridgeManager.stop()]);
5882
6346
  }
5883
6347
  async rehydrateCronSchedules() {
5884
6348
  const rows = await this.db.select({ sourceUrl: wakeRegistrations.sourceUrl }).from(wakeRegistrations).where(eq(wakeRegistrations.tenantId, this.serviceId));
@@ -6642,7 +7106,10 @@ var WakeRegistry = class {
6642
7106
  }
6643
7107
  if (!isChangeMessage(message)) return;
6644
7108
  if (message.headers.operation === `delete`) {
6645
- this.removeCachedRegistrationByDbId(Number(message.key));
7109
+ const oldValue = message.old_value;
7110
+ const oldId = Number(oldValue?.id);
7111
+ if (Number.isFinite(oldId)) this.removeCachedRegistrationByDbId(oldId);
7112
+ else this.resetCachedRegistrations();
6646
7113
  return;
6647
7114
  }
6648
7115
  this.upsertCachedRegistration(this.normalizeShapeRow(message.value));
@@ -6753,9 +7220,9 @@ var WakeRegistry = class {
6753
7220
  matchCondition(reg, event) {
6754
7221
  if (reg.condition === `runFinished`) {
6755
7222
  if (event.type !== `run`) return null;
6756
- const value = event.value;
7223
+ const value$1 = event.value;
6757
7224
  const headers$1 = event.headers;
6758
- const status$1 = value?.status;
7225
+ const status$1 = value$1?.status;
6759
7226
  const operation$1 = headers$1?.operation;
6760
7227
  if (operation$1 !== `update`) return null;
6761
7228
  if (status$1 !== `completed` && status$1 !== `failed`) return null;
@@ -6778,13 +7245,15 @@ var WakeRegistry = class {
6778
7245
  }
6779
7246
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
6780
7247
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
7248
+ const value = event.value;
6781
7249
  const change = {
6782
7250
  collection: eventType,
6783
7251
  kind,
6784
7252
  key: event.key || ``
6785
7253
  };
7254
+ if (value && `value` in value) change.value = value.value;
7255
+ if (value && `oldValue` in value) change.oldValue = value.oldValue;
6786
7256
  if (eventType === `inbox`) {
6787
- const value = event.value;
6788
7257
  if (typeof value?.from === `string`) change.from = value.from;
6789
7258
  if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
6790
7259
  if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
@@ -7188,6 +7657,22 @@ function resolveDurableStreamsRoutingAdapter(adapter, _durableStreamsUrl) {
7188
7657
 
7189
7658
  //#endregion
7190
7659
  //#region src/utils/server-utils.ts
7660
+ /**
7661
+ * Raised when an Electric shape proxy request must be rejected for security
7662
+ * reasons (an un-scoped table, or a client `where` clause that could escape the
7663
+ * enforced per-tenant/per-principal scoping). The global `errorMapper` hook
7664
+ * maps this to an HTTP error response. Defined here (rather than reusing
7665
+ * `ElectricAgentsError`) to keep this module free of the heavy entity-manager
7666
+ * import graph.
7667
+ */
7668
+ var ElectricProxyError = class extends Error {
7669
+ constructor(code, message, status$1) {
7670
+ super(message);
7671
+ this.code = code;
7672
+ this.status = status$1;
7673
+ this.name = `ElectricProxyError`;
7674
+ }
7675
+ };
7191
7676
  function buildElectricProxyTarget(options) {
7192
7677
  const targetPath = options.incomingUrl.pathname.replace(`/_electric/electric`, ``);
7193
7678
  const target = electricUrlWithPath(options.electricUrl, targetPath);
@@ -7197,7 +7682,12 @@ function buildElectricProxyTarget(options) {
7197
7682
  applyElectricUrlQueryParams(target, options.electricUrl);
7198
7683
  if (targetPath !== `/v1/shape`) return target;
7199
7684
  if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
7200
- const table = options.incomingUrl.searchParams.get(`table`);
7685
+ const clientWhere = options.incomingUrl.searchParams.get(`where`);
7686
+ if (clientWhere !== null && !isSelfContainedWhereClause(clientWhere)) throw new ElectricProxyError(`INVALID_WHERE`, `Invalid where clause`, 400);
7687
+ const tableParams = options.incomingUrl.searchParams.getAll(`table`);
7688
+ if (tableParams.length !== 1) throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
7689
+ const table = tableParams[0];
7690
+ target.searchParams.set(`table`, table);
7201
7691
  if (table === `entities`) {
7202
7692
  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
7693
  applyShapeWhere(target, buildReadableEntitiesWhere({
@@ -7255,9 +7745,39 @@ function buildElectricProxyTarget(options) {
7255
7745
  principalKind: options.principalKind ?? ``,
7256
7746
  permissionBypass: options.permissionBypass
7257
7747
  }));
7258
- }
7748
+ } else throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
7259
7749
  return target;
7260
7750
  }
7751
+ /**
7752
+ * Returns true when a client-supplied Electric `where` clause is self-contained:
7753
+ * its parentheses are balanced, never close below the top level, all string
7754
+ * (`'`) and identifier (`"`) literals are terminated, and it contains no SQL
7755
+ * comment markers. Such a clause cannot break out of the `(...)` group it is
7756
+ * wrapped in when AND-combined with the enforced scoping predicate, nor comment
7757
+ * out the trailing paren the proxy appends. Characters inside string/identifier
7758
+ * literals are ignored. Comment markers are rejected unconditionally (even where
7759
+ * harmless) as a conservative defensive measure; dollar-quoted and `E''` strings
7760
+ * are not modeled and only ever cause fail-safe over-rejection, never a bypass.
7761
+ */
7762
+ function isSelfContainedWhereClause(where) {
7763
+ let depth = 0;
7764
+ let quote = null;
7765
+ for (let i = 0; i < where.length; i++) {
7766
+ const ch = where[i];
7767
+ if (quote !== null) {
7768
+ if (ch === quote) if (where[i + 1] === quote) i++;
7769
+ else quote = null;
7770
+ continue;
7771
+ }
7772
+ if (ch === `'` || ch === `"`) quote = ch;
7773
+ else if (ch === `(`) depth++;
7774
+ else if (ch === `)`) {
7775
+ depth--;
7776
+ if (depth < 0) return false;
7777
+ } else if (ch === `-` && where[i + 1] === `-` || ch === `/` && where[i + 1] === `*`) return false;
7778
+ }
7779
+ return depth === 0 && quote === null;
7780
+ }
7261
7781
  function buildReadableEntitiesWhere(options) {
7262
7782
  const tenant = sqlStringLiteral(options.tenantId);
7263
7783
  if (options.permissionBypass) return `tenant_id = ${tenant}`;
@@ -8028,6 +8548,15 @@ const spawnBodySchema = Type.Object({
8028
8548
  manifestKey: Type.Optional(Type.String())
8029
8549
  }))
8030
8550
  });
8551
+ const writeCollectionBodySchema = Type.Object({
8552
+ operation: Type.Union([
8553
+ Type.Literal(`insert`),
8554
+ Type.Literal(`update`),
8555
+ Type.Literal(`delete`)
8556
+ ]),
8557
+ key: Type.Optional(Type.String()),
8558
+ value: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
8559
+ }, { additionalProperties: false });
8031
8560
  const sendBodySchema = Type.Object({
8032
8561
  payload: Type.Optional(Type.Unknown()),
8033
8562
  key: Type.Optional(Type.String()),
@@ -8160,6 +8689,7 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermissi
8160
8689
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
8161
8690
  entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
8162
8691
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
8692
+ entitiesRouter.post(`/:type/:instanceId/collections/:collection`, withExistingEntity, withSchema(writeCollectionBodySchema), withEntityPermission(`write`), writeCollection);
8163
8693
  entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
8164
8694
  entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
8165
8695
  entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
@@ -8265,7 +8795,7 @@ async function parseAttachmentForm(request) {
8265
8795
  };
8266
8796
  }
8267
8797
  function contentDisposition(filename) {
8268
- const fallback = filename.replace(/["\\\r\n]/g, `_`);
8798
+ const fallback = filename.replace(/[^\x20-\x7e]|["\\]/g, `_`);
8269
8799
  return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
8270
8800
  }
8271
8801
  function rejectPrincipalEntityMutation(request, action) {
@@ -8463,22 +8993,28 @@ async function deleteEventSourceSubscription(request, ctx) {
8463
8993
  const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
8464
8994
  return json(result);
8465
8995
  }
8996
+ function tagResponseBody(entity) {
8997
+ const publicEntity = toPublicEntity(entity);
8998
+ if (entity.txid !== void 0) return {
8999
+ ...publicEntity,
9000
+ txid: entity.txid
9001
+ };
9002
+ return publicEntity;
9003
+ }
8466
9004
  async function setTag(request, ctx) {
8467
9005
  const principalMutationError = rejectPrincipalEntityMutation(request, `tag updated`);
8468
9006
  if (principalMutationError) return principalMutationError;
8469
9007
  const parsed = routeBody(request);
8470
9008
  const { entityUrl } = requireExistingEntityRoute(request);
8471
- const token = writeTokenFromRequest(request);
8472
- const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value }, token);
8473
- return json(toPublicEntity(updated));
9009
+ const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value });
9010
+ return json(tagResponseBody(updated));
8474
9011
  }
8475
9012
  async function deleteTag(request, ctx) {
8476
9013
  const principalMutationError = rejectPrincipalEntityMutation(request, `tag deleted`);
8477
9014
  if (principalMutationError) return principalMutationError;
8478
9015
  const { entityUrl } = requireExistingEntityRoute(request);
8479
- const token = writeTokenFromRequest(request);
8480
- const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey), token);
8481
- return json(toPublicEntity(updated));
9016
+ const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey));
9017
+ return json(tagResponseBody(updated));
8482
9018
  }
8483
9019
  async function forkEntity(request, ctx) {
8484
9020
  const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
@@ -8545,9 +9081,29 @@ async function sendEntity(request, ctx) {
8545
9081
  mode: parsed.mode,
8546
9082
  position: parsed.position
8547
9083
  };
8548
- if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
8549
- else await ctx.entityManager.send(entityUrl, sendReq);
8550
- return status(204);
9084
+ if (parsed.afterMs && parsed.afterMs > 0) {
9085
+ await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
9086
+ return status(204);
9087
+ }
9088
+ const result = await ctx.entityManager.send(entityUrl, sendReq);
9089
+ return json(result);
9090
+ }
9091
+ async function writeCollection(request, ctx) {
9092
+ const parsed = routeBody(request);
9093
+ await ctx.entityManager.ensurePrincipal(ctx.principal);
9094
+ const { entityUrl } = requireExistingEntityRoute(request);
9095
+ const collection = request.params.collection;
9096
+ const result = await ctx.entityManager.writeCollection(entityUrl, collection, {
9097
+ operation: parsed.operation,
9098
+ key: parsed.key,
9099
+ value: parsed.value,
9100
+ principal: {
9101
+ url: ctx.principal.url,
9102
+ kind: ctx.principal.kind,
9103
+ id: ctx.principal.id
9104
+ }
9105
+ });
9106
+ return json(result, { status: parsed.operation === `insert` ? 201 : 200 });
8551
9107
  }
8552
9108
  async function createAttachment(request, ctx) {
8553
9109
  const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
@@ -8590,13 +9146,13 @@ async function deleteAttachment(request, ctx) {
8590
9146
  async function updateInboxMessage(request, ctx) {
8591
9147
  const parsed = routeBody(request);
8592
9148
  const { entityUrl } = requireExistingEntityRoute(request);
8593
- await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
8594
- return status(204);
9149
+ const result = await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
9150
+ return json(result);
8595
9151
  }
8596
9152
  async function deleteInboxMessage(request, ctx) {
8597
9153
  const { entityUrl } = requireExistingEntityRoute(request);
8598
- await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
8599
- return status(204);
9154
+ const result = await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
9155
+ return json(result);
8600
9156
  }
8601
9157
  async function spawnEntity(request, ctx) {
8602
9158
  const parsed = routeBody(request);
@@ -8646,8 +9202,13 @@ async function spawnEntity(request, ctx) {
8646
9202
  headers: { "x-write-token": entity.write_token }
8647
9203
  });
8648
9204
  }
8649
- function getEntity(request) {
8650
- return json(toPublicEntity(requireExistingEntityRoute(request).entity));
9205
+ async function getEntity(request, ctx) {
9206
+ const { entity } = requireExistingEntityRoute(request);
9207
+ const entityType = entity.type ? await ctx.entityManager.registry.getEntityType(entity.type) : null;
9208
+ return json({
9209
+ ...toPublicEntity(entity),
9210
+ ...entityType?.externally_writable_collections && { externally_writable_collections: entityType.externally_writable_collections }
9211
+ });
8651
9212
  }
8652
9213
  function headEntity() {
8653
9214
  return status(200);
@@ -8682,6 +9243,16 @@ async function signalEntity(request, ctx) {
8682
9243
  //#region src/routing/entity-types-router.ts
8683
9244
  const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
8684
9245
  const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
9246
+ const externallyWritableCollectionsSchema = Type.Record(Type.String(), Type.Object({
9247
+ type: Type.String(),
9248
+ contract: Type.Optional(Type.String()),
9249
+ operations: Type.Optional(Type.Array(Type.Union([
9250
+ Type.Literal(`insert`),
9251
+ Type.Literal(`update`),
9252
+ Type.Literal(`delete`)
9253
+ ]))),
9254
+ principalColumn: Type.Optional(Type.String())
9255
+ }, { additionalProperties: false }));
8685
9256
  const slashCommandArgumentSchema = Type.Object({
8686
9257
  name: Type.String(),
8687
9258
  type: Type.Union([
@@ -8712,7 +9283,8 @@ const registerEntityTypeBodySchema = Type.Object({
8712
9283
  slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
8713
9284
  serve_endpoint: Type.Optional(Type.String()),
8714
9285
  default_dispatch_policy: Type.Optional(dispatchPolicySchema),
8715
- permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
9286
+ permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema)),
9287
+ externally_writable_collections: Type.Optional(externallyWritableCollectionsSchema)
8716
9288
  }, { additionalProperties: false });
8717
9289
  const amendEntityTypeSchemasBodySchema = Type.Object({
8718
9290
  inbox_schemas: Type.Optional(schemaMapSchema),
@@ -8843,7 +9415,20 @@ function parseExpiresAt(value) {
8843
9415
  if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
8844
9416
  return expiresAt;
8845
9417
  }
9418
+ /**
9419
+ * The `comments` collection name is reserved for the canonical comments
9420
+ * contract: the UI keys its comment affordances on it, so a divergent
9421
+ * collection registered under that name (or the contract mounted under
9422
+ * another name) would break that assumption silently.
9423
+ */
9424
+ function validateExternallyWritableCollections(collections) {
9425
+ for (const [name, config] of Object.entries(collections ?? {})) {
9426
+ if (name === `comments` && config.contract !== COMMENTS_CONTRACT) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The externally-writable collection name "comments" is reserved for the "${COMMENTS_CONTRACT}" contract`, 400);
9427
+ if (config.contract === COMMENTS_CONTRACT && name !== `comments`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The "${COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`, 400);
9428
+ }
9429
+ }
8846
9430
  function normalizeEntityTypeRequest(parsed) {
9431
+ validateExternallyWritableCollections(parsed.externally_writable_collections);
8847
9432
  const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
8848
9433
  return {
8849
9434
  name: parsed.name ?? ``,
@@ -8857,7 +9442,8 @@ function normalizeEntityTypeRequest(parsed) {
8857
9442
  type: `webhook`,
8858
9443
  url: serveEndpoint
8859
9444
  }] } : void 0),
8860
- permission_grants: parsed.permission_grants
9445
+ permission_grants: parsed.permission_grants,
9446
+ externally_writable_collections: parsed.externally_writable_collections
8861
9447
  };
8862
9448
  }
8863
9449
  function toPublicEntityType(entityType) {
@@ -8867,6 +9453,49 @@ function toPublicEntityType(entityType) {
8867
9453
  };
8868
9454
  }
8869
9455
 
9456
+ //#endregion
9457
+ //#region src/routing/pg-sync-router.ts
9458
+ const pgSyncOptionsSchema = Type.Object({
9459
+ url: Type.Optional(Type.String()),
9460
+ table: Type.String(),
9461
+ columns: Type.Optional(Type.Array(Type.String())),
9462
+ where: Type.Optional(Type.String()),
9463
+ params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
9464
+ replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)]))
9465
+ });
9466
+ const pgSyncRequestMetadataSchema = Type.Object({
9467
+ entityUrl: Type.Optional(Type.String()),
9468
+ entityType: Type.Optional(Type.String()),
9469
+ streamPath: Type.Optional(Type.String()),
9470
+ runtimeConsumerId: Type.Optional(Type.String()),
9471
+ wakeId: Type.Optional(Type.String())
9472
+ });
9473
+ const pgSyncRegisterBodySchema = Type.Object({
9474
+ options: pgSyncOptionsSchema,
9475
+ metadata: Type.Optional(pgSyncRequestMetadataSchema)
9476
+ });
9477
+ const pgSyncRouter = Router({ base: `/_electric/pg-sync` });
9478
+ pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
9479
+ async function registerPgSync(request, ctx) {
9480
+ const { options, metadata } = routeBody(request);
9481
+ if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
9482
+ if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
9483
+ try {
9484
+ const requestMetadata$1 = {
9485
+ tenantId: ctx.service,
9486
+ principalKind: ctx.principal.kind,
9487
+ principalId: ctx.principal.id,
9488
+ principalKey: ctx.principal.key,
9489
+ principalUrl: ctx.principal.url,
9490
+ ...metadata ?? {}
9491
+ };
9492
+ const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
9493
+ return json(result);
9494
+ } catch (error) {
9495
+ return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
9496
+ }
9497
+ }
9498
+
8870
9499
  //#endregion
8871
9500
  //#region src/routing/hooks.ts
8872
9501
  const SPAN_KEY = Symbol(`agents-server.otel-span`);
@@ -8941,6 +9570,10 @@ function errorMapper(err, req) {
8941
9570
  });
8942
9571
  }
8943
9572
  if (err instanceof ElectricAgentsError) return apiError(err.status, err.code, err.message, err.details);
9573
+ if (err instanceof ElectricProxyError) {
9574
+ serverLog.warn(`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`);
9575
+ return apiError(err.status, err.code, err.message);
9576
+ }
8944
9577
  serverLog.error(`[agent-server] Unhandled error:`, err);
8945
9578
  return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`);
8946
9579
  }
@@ -9391,6 +10024,7 @@ internalRouter.all(`/runners`, runnersRouter.fetch);
9391
10024
  internalRouter.all(`/runners/*`, runnersRouter.fetch);
9392
10025
  internalRouter.all(`/entities/*`, entitiesRouter.fetch);
9393
10026
  internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch);
10027
+ internalRouter.all(`/pg-sync/*`, pgSyncRouter.fetch);
9394
10028
  internalRouter.all(`/observations/*`, observationsRouter.fetch);
9395
10029
  internalRouter.get(`/electric/*`, electricProxyRouter.fetch);
9396
10030
  internalRouter.all(`*`, () => status(404));
@@ -9774,6 +10408,9 @@ const globalRouter = AutoRouter({
9774
10408
  finally: [otelEndSpan, applyCors]
9775
10409
  });
9776
10410
  globalRouter.all(`/_electric/shared-state/*`, durableStreamsRouter.fetch);
10411
+ globalRouter.all(`/_electric/pg-sync/register`, internalRouter.fetch);
10412
+ globalRouter.get(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
10413
+ globalRouter.head(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
9777
10414
  globalRouter.all(`/_electric/*`, internalRouter.fetch);
9778
10415
  globalRouter.all(`*`, durableStreamsRouter.fetch);
9779
10416