@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.
@@ -4,7 +4,7 @@ import { DurableStreamTestServer } from "@durable-streams/server";
4
4
  import { createServer } from "node:http";
5
5
  import { createServerAdapter } from "@whatwg-node/server";
6
6
  import { Agent } from "undici";
7
- import { COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, createEntityRegistry, createRuntimeHandler, 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";
7
+ import { COMMENTS_CONTRACT, COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, canonicalPgSyncOptions, createEntityRegistry, createRuntimeHandler, 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";
8
8
  import fs, { existsSync } from "node:fs";
9
9
  import path, { dirname, resolve } from "node:path";
10
10
  import { drizzle } from "drizzle-orm/postgres-js";
@@ -47,6 +47,7 @@ __export(schema_exports, {
47
47
  entityPermissionGrants: () => entityPermissionGrants,
48
48
  entityTypePermissionGrants: () => entityTypePermissionGrants,
49
49
  entityTypes: () => entityTypes,
50
+ pgSyncBridges: () => pgSyncBridges,
50
51
  runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
51
52
  runners: () => runners,
52
53
  scheduledTasks: () => scheduledTasks,
@@ -64,6 +65,7 @@ const entityTypes = pgTable(`entity_types`, {
64
65
  creationSchema: jsonb(`creation_schema`),
65
66
  inboxSchemas: jsonb(`inbox_schemas`),
66
67
  stateSchemas: jsonb(`state_schemas`),
68
+ externallyWritableCollections: jsonb(`externally_writable_collections`).$type(),
67
69
  slashCommands: jsonb(`slash_commands`),
68
70
  serveEndpoint: text(`serve_endpoint`),
69
71
  defaultDispatchPolicy: jsonb(`default_dispatch_policy`),
@@ -368,6 +370,18 @@ const scheduledTasks = pgTable(`scheduled_tasks`, {
368
370
  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`),
369
371
  index(`idx_scheduled_tasks_stale_claims`).on(table.tenantId, table.claimedAt).where(sql`${table.completedAt} IS NULL AND ${table.claimedAt} IS NOT NULL`)
370
372
  ]);
373
+ const pgSyncBridges = pgTable(`pg_sync_bridges`, {
374
+ tenantId: text(`tenant_id`).notNull().default(`default`),
375
+ sourceRef: text(`source_ref`).notNull(),
376
+ options: jsonb(`options`).notNull(),
377
+ streamUrl: text(`stream_url`).notNull(),
378
+ shapeHandle: text(`shape_handle`),
379
+ shapeOffset: text(`shape_offset`),
380
+ initialSnapshotComplete: boolean(`initial_snapshot_complete`).notNull().default(false),
381
+ lastTouchedAt: timestamp(`last_touched_at`, { withTimezone: true }).notNull().defaultNow(),
382
+ createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
383
+ updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
384
+ }, (table) => [primaryKey({ columns: [table.tenantId, table.sourceRef] }), unique(`uq_pg_sync_bridges_stream_url`).on(table.tenantId, table.streamUrl)]);
371
385
  const entityBridges = pgTable(`entity_bridges`, {
372
386
  tenantId: text(`tenant_id`).notNull().default(`default`),
373
387
  sourceRef: text(`source_ref`).notNull(),
@@ -1134,6 +1148,22 @@ async function fileExists(filePath) {
1134
1148
  return false;
1135
1149
  }
1136
1150
  }
1151
+ /**
1152
+ * Raised when an Electric shape proxy request must be rejected for security
1153
+ * reasons (an un-scoped table, or a client `where` clause that could escape the
1154
+ * enforced per-tenant/per-principal scoping). The global `errorMapper` hook
1155
+ * maps this to an HTTP error response. Defined here (rather than reusing
1156
+ * `ElectricAgentsError`) to keep this module free of the heavy entity-manager
1157
+ * import graph.
1158
+ */
1159
+ var ElectricProxyError = class extends Error {
1160
+ constructor(code, message, status$1) {
1161
+ super(message);
1162
+ this.code = code;
1163
+ this.status = status$1;
1164
+ this.name = `ElectricProxyError`;
1165
+ }
1166
+ };
1137
1167
  function buildElectricProxyTarget(options) {
1138
1168
  const targetPath = options.incomingUrl.pathname.replace(`/_electric/electric`, ``);
1139
1169
  const target = electricUrlWithPath(options.electricUrl, targetPath);
@@ -1143,7 +1173,12 @@ function buildElectricProxyTarget(options) {
1143
1173
  applyElectricUrlQueryParams(target, options.electricUrl);
1144
1174
  if (targetPath !== `/v1/shape`) return target;
1145
1175
  if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
1146
- const table = options.incomingUrl.searchParams.get(`table`);
1176
+ const clientWhere = options.incomingUrl.searchParams.get(`where`);
1177
+ if (clientWhere !== null && !isSelfContainedWhereClause(clientWhere)) throw new ElectricProxyError(`INVALID_WHERE`, `Invalid where clause`, 400);
1178
+ const tableParams = options.incomingUrl.searchParams.getAll(`table`);
1179
+ if (tableParams.length !== 1) throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
1180
+ const table = tableParams[0];
1181
+ target.searchParams.set(`table`, table);
1147
1182
  if (table === `entities`) {
1148
1183
  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"`);
1149
1184
  applyShapeWhere(target, buildReadableEntitiesWhere({
@@ -1201,9 +1236,39 @@ function buildElectricProxyTarget(options) {
1201
1236
  principalKind: options.principalKind ?? ``,
1202
1237
  permissionBypass: options.permissionBypass
1203
1238
  }));
1204
- }
1239
+ } else throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
1205
1240
  return target;
1206
1241
  }
1242
+ /**
1243
+ * Returns true when a client-supplied Electric `where` clause is self-contained:
1244
+ * its parentheses are balanced, never close below the top level, all string
1245
+ * (`'`) and identifier (`"`) literals are terminated, and it contains no SQL
1246
+ * comment markers. Such a clause cannot break out of the `(...)` group it is
1247
+ * wrapped in when AND-combined with the enforced scoping predicate, nor comment
1248
+ * out the trailing paren the proxy appends. Characters inside string/identifier
1249
+ * literals are ignored. Comment markers are rejected unconditionally (even where
1250
+ * harmless) as a conservative defensive measure; dollar-quoted and `E''` strings
1251
+ * are not modeled and only ever cause fail-safe over-rejection, never a bypass.
1252
+ */
1253
+ function isSelfContainedWhereClause(where) {
1254
+ let depth = 0;
1255
+ let quote = null;
1256
+ for (let i = 0; i < where.length; i++) {
1257
+ const ch = where[i];
1258
+ if (quote !== null) {
1259
+ if (ch === quote) if (where[i + 1] === quote) i++;
1260
+ else quote = null;
1261
+ continue;
1262
+ }
1263
+ if (ch === `'` || ch === `"`) quote = ch;
1264
+ else if (ch === `(`) depth++;
1265
+ else if (ch === `)`) {
1266
+ depth--;
1267
+ if (depth < 0) return false;
1268
+ } else if (ch === `-` && where[i + 1] === `-` || ch === `/` && where[i + 1] === `*`) return false;
1269
+ }
1270
+ return depth === 0 && quote === null;
1271
+ }
1207
1272
  function buildReadableEntitiesWhere(options) {
1208
1273
  const tenant = sqlStringLiteral$2(options.tenantId);
1209
1274
  if (options.permissionBypass) return `tenant_id = ${tenant}`;
@@ -2721,6 +2786,9 @@ var PostgresRegistry = class {
2721
2786
  entityBridgeWhere(sourceRef) {
2722
2787
  return and(eq(entityBridges.tenantId, this.tenantId), eq(entityBridges.sourceRef, sourceRef));
2723
2788
  }
2789
+ pgSyncBridgeWhere(sourceRef) {
2790
+ return and(eq(pgSyncBridges.tenantId, this.tenantId), eq(pgSyncBridges.sourceRef, sourceRef));
2791
+ }
2724
2792
  async createEntityType(et) {
2725
2793
  await this.db.insert(entityTypes).values({
2726
2794
  tenantId: this.tenantId,
@@ -2729,6 +2797,7 @@ var PostgresRegistry = class {
2729
2797
  creationSchema: et.creation_schema ?? null,
2730
2798
  inboxSchemas: et.inbox_schemas ?? null,
2731
2799
  stateSchemas: et.state_schemas ?? null,
2800
+ externallyWritableCollections: et.externally_writable_collections ?? null,
2732
2801
  slashCommands: et.slash_commands ?? null,
2733
2802
  serveEndpoint: et.serve_endpoint ?? null,
2734
2803
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -2742,6 +2811,7 @@ var PostgresRegistry = class {
2742
2811
  creationSchema: et.creation_schema ?? null,
2743
2812
  inboxSchemas: et.inbox_schemas ?? null,
2744
2813
  stateSchemas: et.state_schemas ?? null,
2814
+ externallyWritableCollections: et.externally_writable_collections ?? null,
2745
2815
  slashCommands: et.slash_commands ?? null,
2746
2816
  serveEndpoint: et.serve_endpoint ?? null,
2747
2817
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -2760,6 +2830,7 @@ var PostgresRegistry = class {
2760
2830
  creationSchema: et.creation_schema ?? null,
2761
2831
  inboxSchemas: et.inbox_schemas ?? null,
2762
2832
  stateSchemas: et.state_schemas ?? null,
2833
+ externallyWritableCollections: et.externally_writable_collections ?? null,
2763
2834
  slashCommands: et.slash_commands ?? null,
2764
2835
  serveEndpoint: et.serve_endpoint ?? null,
2765
2836
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -2787,6 +2858,7 @@ var PostgresRegistry = class {
2787
2858
  creationSchema: et.creation_schema ?? null,
2788
2859
  inboxSchemas: et.inbox_schemas ?? null,
2789
2860
  stateSchemas: et.state_schemas ?? null,
2861
+ externallyWritableCollections: et.externally_writable_collections ?? null,
2790
2862
  slashCommands: et.slash_commands ?? null,
2791
2863
  serveEndpoint: et.serve_endpoint ?? null,
2792
2864
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -3190,11 +3262,12 @@ var PostgresRegistry = class {
3190
3262
  };
3191
3263
  const nextTags = normalizeTags(mutation.nextTags);
3192
3264
  const updatedAt = Date.now();
3193
- await tx.update(entities).set({
3265
+ const [updateResult] = await tx.update(entities).set({
3194
3266
  tags: nextTags,
3195
3267
  tagsIndex: buildTagsIndex(nextTags),
3196
3268
  updatedAt
3197
- }).where(this.entityWhere(url));
3269
+ }).where(this.entityWhere(url)).returning({ txid: sql`pg_current_xact_id()::xid::text` });
3270
+ const txid = updateResult ? parseInt(updateResult.txid) : void 0;
3198
3271
  await tx.insert(tagStreamOutbox).values({
3199
3272
  tenantId: this.tenantId,
3200
3273
  entityUrl: url,
@@ -3212,10 +3285,63 @@ var PostgresRegistry = class {
3212
3285
  return {
3213
3286
  entity,
3214
3287
  changed: true,
3215
- ...op === `insert` || op === `update` ? { op } : {}
3288
+ ...op === `insert` || op === `update` ? { op } : {},
3289
+ ...txid !== void 0 ? { txid } : {}
3216
3290
  };
3217
3291
  });
3218
3292
  }
3293
+ async upsertPgSyncBridge(row) {
3294
+ await this.db.insert(pgSyncBridges).values({
3295
+ tenantId: this.tenantId,
3296
+ sourceRef: row.sourceRef,
3297
+ options: row.options,
3298
+ streamUrl: row.streamUrl,
3299
+ lastTouchedAt: new Date(),
3300
+ updatedAt: new Date()
3301
+ }).onConflictDoUpdate({
3302
+ target: [pgSyncBridges.tenantId, pgSyncBridges.sourceRef],
3303
+ set: {
3304
+ options: row.options,
3305
+ streamUrl: row.streamUrl,
3306
+ initialSnapshotComplete: false,
3307
+ lastTouchedAt: new Date(),
3308
+ updatedAt: new Date()
3309
+ }
3310
+ });
3311
+ const existing = await this.getPgSyncBridge(row.sourceRef);
3312
+ if (!existing) throw new Error(`Failed to load pgSync bridge ${row.sourceRef}`);
3313
+ return existing;
3314
+ }
3315
+ async getPgSyncBridge(sourceRef) {
3316
+ const rows = await this.db.select().from(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef)).limit(1);
3317
+ return rows[0] ? this.rowToPgSyncBridge(rows[0]) : null;
3318
+ }
3319
+ async listPgSyncBridges(tenantId = this.tenantId) {
3320
+ const rows = tenantId === null ? await this.db.select().from(pgSyncBridges) : await this.db.select().from(pgSyncBridges).where(eq(pgSyncBridges.tenantId, tenantId));
3321
+ return rows.map((row) => this.rowToPgSyncBridge(row));
3322
+ }
3323
+ async touchPgSyncBridge(sourceRef) {
3324
+ await this.db.update(pgSyncBridges).set({
3325
+ lastTouchedAt: new Date(),
3326
+ updatedAt: new Date()
3327
+ }).where(this.pgSyncBridgeWhere(sourceRef));
3328
+ }
3329
+ async updatePgSyncBridgeCursor(sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete) {
3330
+ await this.db.update(pgSyncBridges).set({
3331
+ shapeHandle,
3332
+ shapeOffset,
3333
+ ...initialSnapshotComplete !== void 0 ? { initialSnapshotComplete } : {},
3334
+ updatedAt: new Date()
3335
+ }).where(this.pgSyncBridgeWhere(sourceRef));
3336
+ }
3337
+ async clearPgSyncBridgeCursor(sourceRef) {
3338
+ await this.db.update(pgSyncBridges).set({
3339
+ shapeHandle: null,
3340
+ shapeOffset: null,
3341
+ initialSnapshotComplete: false,
3342
+ updatedAt: new Date()
3343
+ }).where(this.pgSyncBridgeWhere(sourceRef));
3344
+ }
3219
3345
  async upsertEntityBridge(row) {
3220
3346
  await this.db.insert(entityBridges).values({
3221
3347
  tenantId: this.tenantId,
@@ -3378,6 +3504,7 @@ var PostgresRegistry = class {
3378
3504
  creation_schema: row.creationSchema,
3379
3505
  inbox_schemas: row.inboxSchemas,
3380
3506
  state_schemas: row.stateSchemas,
3507
+ externally_writable_collections: row.externallyWritableCollections ?? void 0,
3381
3508
  slash_commands: row.slashCommands ?? void 0,
3382
3509
  serve_endpoint: row.serveEndpoint ?? void 0,
3383
3510
  default_dispatch_policy: row.defaultDispatchPolicy ?? void 0,
@@ -3435,6 +3562,20 @@ var PostgresRegistry = class {
3435
3562
  updated_at: row.updatedAt
3436
3563
  };
3437
3564
  }
3565
+ rowToPgSyncBridge(row) {
3566
+ return {
3567
+ tenantId: row.tenantId,
3568
+ sourceRef: row.sourceRef,
3569
+ options: row.options,
3570
+ streamUrl: row.streamUrl,
3571
+ shapeHandle: row.shapeHandle ?? void 0,
3572
+ shapeOffset: row.shapeOffset ?? void 0,
3573
+ initialSnapshotComplete: row.initialSnapshotComplete,
3574
+ lastTouchedAt: row.lastTouchedAt,
3575
+ createdAt: row.createdAt,
3576
+ updatedAt: row.updatedAt
3577
+ };
3578
+ }
3438
3579
  rowToEntityBridge(row) {
3439
3580
  return {
3440
3581
  tenantId: row.tenantId,
@@ -3515,6 +3656,9 @@ var PostgresRegistry = class {
3515
3656
  function isRecord$1(value) {
3516
3657
  return typeof value === `object` && value !== null && !Array.isArray(value);
3517
3658
  }
3659
+ function getPgSyncManifestStreamPath(sourceRef) {
3660
+ return `/_electric/pg-sync/${sourceRef}`;
3661
+ }
3518
3662
  function extractManifestSourceUrl(manifest) {
3519
3663
  if (!manifest) return void 0;
3520
3664
  if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
@@ -3527,6 +3671,7 @@ function extractManifestSourceUrl(manifest) {
3527
3671
  }
3528
3672
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
3529
3673
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
3674
+ if (manifest.sourceType === `pgSync`) return typeof manifest.sourceRef === `string` ? getPgSyncManifestStreamPath(manifest.sourceRef) : void 0;
3530
3675
  if (manifest.sourceType === `webhook`) {
3531
3676
  if (typeof config?.streamUrl === `string`) return config.streamUrl;
3532
3677
  if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
@@ -3641,6 +3786,13 @@ function isRecord(value) {
3641
3786
  function cloneRecord(value) {
3642
3787
  return JSON.parse(JSON.stringify(value));
3643
3788
  }
3789
+ function withOptionalTxid(entity, txid) {
3790
+ if (txid === void 0) return entity;
3791
+ return {
3792
+ ...entity,
3793
+ txid
3794
+ };
3795
+ }
3644
3796
  /**
3645
3797
  * Orchestrates the Electric Agents entity lifecycle: register types, spawn, send, kill.
3646
3798
  *
@@ -3715,6 +3867,7 @@ var EntityManager = class {
3715
3867
  creation_schema: req.creation_schema,
3716
3868
  inbox_schemas: req.inbox_schemas,
3717
3869
  state_schemas: req.state_schemas,
3870
+ externally_writable_collections: req.externally_writable_collections,
3718
3871
  slash_commands: req.slash_commands,
3719
3872
  serve_endpoint: req.serve_endpoint,
3720
3873
  default_dispatch_policy: defaultDispatchPolicy,
@@ -4782,15 +4935,17 @@ var EntityManager = class {
4782
4935
  await this.registry.updateStatus(entityUrl, `idle`);
4783
4936
  await this.entityBridgeManager?.onEntityChanged(entityUrl);
4784
4937
  }
4938
+ const txid = crypto.randomUUID();
4785
4939
  const envelope = entityStateSchema.inbox.insert({
4786
4940
  key,
4787
- value
4941
+ value,
4942
+ headers: { txid }
4788
4943
  });
4789
4944
  const encoded = this.encodeChangeEvent(envelope);
4790
4945
  try {
4791
4946
  if (opts?.producerId) {
4792
4947
  await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
4793
- return;
4948
+ return { txid };
4794
4949
  }
4795
4950
  await this.streamClient.append(entity.streams.main, encoded);
4796
4951
  if (entity.type === `principal` && req.type === `update_identity`) {
@@ -4798,13 +4953,49 @@ var EntityManager = class {
4798
4953
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent({
4799
4954
  type: `identity`,
4800
4955
  key: `self`,
4801
- value: identity
4956
+ value: identity,
4957
+ headers: { txid }
4802
4958
  }));
4803
4959
  }
4960
+ return { txid };
4961
+ } catch (err) {
4962
+ if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4963
+ throw err;
4964
+ }
4965
+ }
4966
+ async writeCollection(entityUrl, collection, req) {
4967
+ const entity = await this.registry.getEntity(entityUrl);
4968
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4969
+ const { externallyWritableCollections } = await this.getEffectiveSchemas(entity);
4970
+ const config = externallyWritableCollections?.[collection];
4971
+ if (!config) throw new ElectricAgentsError(ErrCodeUnauthorized, `Collection "${collection}" is not writable`, 403);
4972
+ const allowedOperations = config.operations ?? [`insert`];
4973
+ if (!allowedOperations.includes(req.operation)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Operation "${req.operation}" is not allowed on collection "${collection}"`, 403);
4974
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4975
+ if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
4976
+ if (req.operation !== `delete` && (req.value === void 0 || req.value === null)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `value is required for ${req.operation}`, 400);
4977
+ if (req.operation !== `insert` && !req.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `key is required for ${req.operation}`, 400);
4978
+ const key = req.key ?? `${collection}-${randomUUID()}`;
4979
+ const event = {
4980
+ type: config.type,
4981
+ key,
4982
+ headers: {
4983
+ operation: req.operation,
4984
+ timestamp: new Date().toISOString(),
4985
+ principal: req.principal
4986
+ }
4987
+ };
4988
+ if (req.operation !== `delete`) event.value = req.value;
4989
+ const validationError = await this.validateWriteEvent(entity, event);
4990
+ if (validationError) throw new ElectricAgentsError(validationError.code, validationError.message, validationError.status);
4991
+ const encoded = this.encodeChangeEvent(event);
4992
+ try {
4993
+ await this.streamClient.append(entity.streams.main, encoded);
4804
4994
  } catch (err) {
4805
4995
  if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4806
4996
  throw err;
4807
4997
  }
4998
+ return { key };
4808
4999
  }
4809
5000
  async updateInboxMessage(entityUrl, key, req) {
4810
5001
  const entity = await this.registry.getEntity(entityUrl);
@@ -4821,18 +5012,26 @@ var EntityManager = class {
4821
5012
  if (req.status === `cancelled`) value.cancelled_at = now;
4822
5013
  }
4823
5014
  if (Object.keys(value).length === 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `No inbox fields to update`, 400);
5015
+ const txid = crypto.randomUUID();
4824
5016
  const envelope = entityStateSchema.inbox.update({
4825
5017
  key,
4826
- value
5018
+ value,
5019
+ headers: { txid }
4827
5020
  });
4828
5021
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
5022
+ return { txid };
4829
5023
  }
4830
5024
  async deleteInboxMessage(entityUrl, key) {
4831
5025
  const entity = await this.registry.getEntity(entityUrl);
4832
5026
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4833
5027
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4834
- const envelope = entityStateSchema.inbox.delete({ key });
5028
+ const txid = crypto.randomUUID();
5029
+ const envelope = entityStateSchema.inbox.delete({
5030
+ key,
5031
+ headers: { txid }
5032
+ });
4835
5033
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
5034
+ return { txid };
4836
5035
  }
4837
5036
  isAttachmentStreamPath(path$1) {
4838
5037
  return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$1);
@@ -4921,28 +5120,26 @@ var EntityManager = class {
4921
5120
  await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
4922
5121
  return { txid };
4923
5122
  }
4924
- async setTag(entityUrl, key, req, token) {
5123
+ async setTag(entityUrl, key, req) {
4925
5124
  const entity = await this.registry.getEntity(entityUrl);
4926
5125
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4927
- if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
4928
5126
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4929
5127
  if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
4930
5128
  const result = await this.registry.setEntityTag(entityUrl, key, req.value);
4931
5129
  const updated = result.entity;
4932
5130
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag write`, 500);
4933
5131
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4934
- return updated;
5132
+ return withOptionalTxid(updated, result.txid);
4935
5133
  }
4936
- async deleteTag(entityUrl, key, token) {
5134
+ async deleteTag(entityUrl, key) {
4937
5135
  const entity = await this.registry.getEntity(entityUrl);
4938
5136
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4939
- if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
4940
5137
  if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4941
5138
  const result = await this.registry.removeEntityTag(entityUrl, key);
4942
5139
  const updated = result.entity;
4943
5140
  if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
4944
5141
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
4945
- return updated;
5142
+ return withOptionalTxid(updated, result.txid);
4946
5143
  }
4947
5144
  async ensureEntitiesMembershipStream(tags, principal) {
4948
5145
  if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
@@ -5492,7 +5689,8 @@ var EntityManager = class {
5492
5689
  async getEffectiveSchemas(entity) {
5493
5690
  if (!entity.type) return {
5494
5691
  inboxSchemas: entity.inbox_schemas,
5495
- stateSchemas: entity.state_schemas
5692
+ stateSchemas: entity.state_schemas,
5693
+ externallyWritableCollections: void 0
5496
5694
  };
5497
5695
  const latestType = await this.registry.getEntityType(entity.type);
5498
5696
  return {
@@ -5503,7 +5701,8 @@ var EntityManager = class {
5503
5701
  stateSchemas: latestType?.state_schemas ? {
5504
5702
  ...entity.state_schemas ?? {},
5505
5703
  ...latestType.state_schemas
5506
- } : entity.state_schemas
5704
+ } : entity.state_schemas,
5705
+ externallyWritableCollections: latestType?.externally_writable_collections
5507
5706
  };
5508
5707
  }
5509
5708
  isClosedStreamError(err) {
@@ -5766,6 +5965,15 @@ const spawnBodySchema = Type.Object({
5766
5965
  manifestKey: Type.Optional(Type.String())
5767
5966
  }))
5768
5967
  });
5968
+ const writeCollectionBodySchema = Type.Object({
5969
+ operation: Type.Union([
5970
+ Type.Literal(`insert`),
5971
+ Type.Literal(`update`),
5972
+ Type.Literal(`delete`)
5973
+ ]),
5974
+ key: Type.Optional(Type.String()),
5975
+ value: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
5976
+ }, { additionalProperties: false });
5769
5977
  const sendBodySchema = Type.Object({
5770
5978
  payload: Type.Optional(Type.Unknown()),
5771
5979
  key: Type.Optional(Type.String()),
@@ -5898,6 +6106,7 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermissi
5898
6106
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
5899
6107
  entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
5900
6108
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
6109
+ entitiesRouter.post(`/:type/:instanceId/collections/:collection`, withExistingEntity, withSchema(writeCollectionBodySchema), withEntityPermission(`write`), writeCollection);
5901
6110
  entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
5902
6111
  entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
5903
6112
  entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
@@ -6003,7 +6212,7 @@ async function parseAttachmentForm(request) {
6003
6212
  };
6004
6213
  }
6005
6214
  function contentDisposition(filename) {
6006
- const fallback = filename.replace(/["\\\r\n]/g, `_`);
6215
+ const fallback = filename.replace(/[^\x20-\x7e]|["\\]/g, `_`);
6007
6216
  return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
6008
6217
  }
6009
6218
  function rejectPrincipalEntityMutation(request, action) {
@@ -6201,22 +6410,28 @@ async function deleteEventSourceSubscription(request, ctx) {
6201
6410
  const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
6202
6411
  return json(result);
6203
6412
  }
6413
+ function tagResponseBody(entity) {
6414
+ const publicEntity = toPublicEntity(entity);
6415
+ if (entity.txid !== void 0) return {
6416
+ ...publicEntity,
6417
+ txid: entity.txid
6418
+ };
6419
+ return publicEntity;
6420
+ }
6204
6421
  async function setTag(request, ctx) {
6205
6422
  const principalMutationError = rejectPrincipalEntityMutation(request, `tag updated`);
6206
6423
  if (principalMutationError) return principalMutationError;
6207
6424
  const parsed = routeBody(request);
6208
6425
  const { entityUrl } = requireExistingEntityRoute(request);
6209
- const token = writeTokenFromRequest(request);
6210
- const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value }, token);
6211
- return json(toPublicEntity(updated));
6426
+ const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value });
6427
+ return json(tagResponseBody(updated));
6212
6428
  }
6213
6429
  async function deleteTag(request, ctx) {
6214
6430
  const principalMutationError = rejectPrincipalEntityMutation(request, `tag deleted`);
6215
6431
  if (principalMutationError) return principalMutationError;
6216
6432
  const { entityUrl } = requireExistingEntityRoute(request);
6217
- const token = writeTokenFromRequest(request);
6218
- const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey), token);
6219
- return json(toPublicEntity(updated));
6433
+ const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey));
6434
+ return json(tagResponseBody(updated));
6220
6435
  }
6221
6436
  async function forkEntity(request, ctx) {
6222
6437
  const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
@@ -6283,9 +6498,29 @@ async function sendEntity(request, ctx) {
6283
6498
  mode: parsed.mode,
6284
6499
  position: parsed.position
6285
6500
  };
6286
- if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
6287
- else await ctx.entityManager.send(entityUrl, sendReq);
6288
- return status(204);
6501
+ if (parsed.afterMs && parsed.afterMs > 0) {
6502
+ await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
6503
+ return status(204);
6504
+ }
6505
+ const result = await ctx.entityManager.send(entityUrl, sendReq);
6506
+ return json(result);
6507
+ }
6508
+ async function writeCollection(request, ctx) {
6509
+ const parsed = routeBody(request);
6510
+ await ctx.entityManager.ensurePrincipal(ctx.principal);
6511
+ const { entityUrl } = requireExistingEntityRoute(request);
6512
+ const collection = request.params.collection;
6513
+ const result = await ctx.entityManager.writeCollection(entityUrl, collection, {
6514
+ operation: parsed.operation,
6515
+ key: parsed.key,
6516
+ value: parsed.value,
6517
+ principal: {
6518
+ url: ctx.principal.url,
6519
+ kind: ctx.principal.kind,
6520
+ id: ctx.principal.id
6521
+ }
6522
+ });
6523
+ return json(result, { status: parsed.operation === `insert` ? 201 : 200 });
6289
6524
  }
6290
6525
  async function createAttachment(request, ctx) {
6291
6526
  const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
@@ -6328,13 +6563,13 @@ async function deleteAttachment(request, ctx) {
6328
6563
  async function updateInboxMessage(request, ctx) {
6329
6564
  const parsed = routeBody(request);
6330
6565
  const { entityUrl } = requireExistingEntityRoute(request);
6331
- await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
6332
- return status(204);
6566
+ const result = await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
6567
+ return json(result);
6333
6568
  }
6334
6569
  async function deleteInboxMessage(request, ctx) {
6335
6570
  const { entityUrl } = requireExistingEntityRoute(request);
6336
- await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
6337
- return status(204);
6571
+ const result = await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
6572
+ return json(result);
6338
6573
  }
6339
6574
  async function spawnEntity(request, ctx) {
6340
6575
  const parsed = routeBody(request);
@@ -6384,8 +6619,13 @@ async function spawnEntity(request, ctx) {
6384
6619
  headers: { "x-write-token": entity.write_token }
6385
6620
  });
6386
6621
  }
6387
- function getEntity(request) {
6388
- return json(toPublicEntity(requireExistingEntityRoute(request).entity));
6622
+ async function getEntity(request, ctx) {
6623
+ const { entity } = requireExistingEntityRoute(request);
6624
+ const entityType = entity.type ? await ctx.entityManager.registry.getEntityType(entity.type) : null;
6625
+ return json({
6626
+ ...toPublicEntity(entity),
6627
+ ...entityType?.externally_writable_collections && { externally_writable_collections: entityType.externally_writable_collections }
6628
+ });
6389
6629
  }
6390
6630
  function headEntity() {
6391
6631
  return status(200);
@@ -6420,6 +6660,16 @@ async function signalEntity(request, ctx) {
6420
6660
  //#region src/routing/entity-types-router.ts
6421
6661
  const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
6422
6662
  const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
6663
+ const externallyWritableCollectionsSchema = Type.Record(Type.String(), Type.Object({
6664
+ type: Type.String(),
6665
+ contract: Type.Optional(Type.String()),
6666
+ operations: Type.Optional(Type.Array(Type.Union([
6667
+ Type.Literal(`insert`),
6668
+ Type.Literal(`update`),
6669
+ Type.Literal(`delete`)
6670
+ ]))),
6671
+ principalColumn: Type.Optional(Type.String())
6672
+ }, { additionalProperties: false }));
6423
6673
  const slashCommandArgumentSchema = Type.Object({
6424
6674
  name: Type.String(),
6425
6675
  type: Type.Union([
@@ -6450,7 +6700,8 @@ const registerEntityTypeBodySchema = Type.Object({
6450
6700
  slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
6451
6701
  serve_endpoint: Type.Optional(Type.String()),
6452
6702
  default_dispatch_policy: Type.Optional(dispatchPolicySchema),
6453
- permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
6703
+ permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema)),
6704
+ externally_writable_collections: Type.Optional(externallyWritableCollectionsSchema)
6454
6705
  }, { additionalProperties: false });
6455
6706
  const amendEntityTypeSchemasBodySchema = Type.Object({
6456
6707
  inbox_schemas: Type.Optional(schemaMapSchema),
@@ -6581,7 +6832,20 @@ function parseExpiresAt(value) {
6581
6832
  if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
6582
6833
  return expiresAt;
6583
6834
  }
6835
+ /**
6836
+ * The `comments` collection name is reserved for the canonical comments
6837
+ * contract: the UI keys its comment affordances on it, so a divergent
6838
+ * collection registered under that name (or the contract mounted under
6839
+ * another name) would break that assumption silently.
6840
+ */
6841
+ function validateExternallyWritableCollections(collections) {
6842
+ for (const [name, config] of Object.entries(collections ?? {})) {
6843
+ 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);
6844
+ if (config.contract === COMMENTS_CONTRACT && name !== `comments`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The "${COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`, 400);
6845
+ }
6846
+ }
6584
6847
  function normalizeEntityTypeRequest(parsed) {
6848
+ validateExternallyWritableCollections(parsed.externally_writable_collections);
6585
6849
  const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
6586
6850
  return {
6587
6851
  name: parsed.name ?? ``,
@@ -6595,7 +6859,8 @@ function normalizeEntityTypeRequest(parsed) {
6595
6859
  type: `webhook`,
6596
6860
  url: serveEndpoint
6597
6861
  }] } : void 0),
6598
- permission_grants: parsed.permission_grants
6862
+ permission_grants: parsed.permission_grants,
6863
+ externally_writable_collections: parsed.externally_writable_collections
6599
6864
  };
6600
6865
  }
6601
6866
  function toPublicEntityType(entityType) {
@@ -6605,6 +6870,49 @@ function toPublicEntityType(entityType) {
6605
6870
  };
6606
6871
  }
6607
6872
 
6873
+ //#endregion
6874
+ //#region src/routing/pg-sync-router.ts
6875
+ const pgSyncOptionsSchema = Type.Object({
6876
+ url: Type.Optional(Type.String()),
6877
+ table: Type.String(),
6878
+ columns: Type.Optional(Type.Array(Type.String())),
6879
+ where: Type.Optional(Type.String()),
6880
+ params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
6881
+ replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)]))
6882
+ });
6883
+ const pgSyncRequestMetadataSchema = Type.Object({
6884
+ entityUrl: Type.Optional(Type.String()),
6885
+ entityType: Type.Optional(Type.String()),
6886
+ streamPath: Type.Optional(Type.String()),
6887
+ runtimeConsumerId: Type.Optional(Type.String()),
6888
+ wakeId: Type.Optional(Type.String())
6889
+ });
6890
+ const pgSyncRegisterBodySchema = Type.Object({
6891
+ options: pgSyncOptionsSchema,
6892
+ metadata: Type.Optional(pgSyncRequestMetadataSchema)
6893
+ });
6894
+ const pgSyncRouter = Router({ base: `/_electric/pg-sync` });
6895
+ pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
6896
+ async function registerPgSync(request, ctx) {
6897
+ const { options, metadata } = routeBody(request);
6898
+ if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
6899
+ if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
6900
+ try {
6901
+ const requestMetadata$1 = {
6902
+ tenantId: ctx.service,
6903
+ principalKind: ctx.principal.kind,
6904
+ principalId: ctx.principal.id,
6905
+ principalKey: ctx.principal.key,
6906
+ principalUrl: ctx.principal.url,
6907
+ ...metadata ?? {}
6908
+ };
6909
+ const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
6910
+ return json(result);
6911
+ } catch (error) {
6912
+ return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
6913
+ }
6914
+ }
6915
+
6608
6916
  //#endregion
6609
6917
  //#region src/routing/hooks.ts
6610
6918
  const SPAN_KEY = Symbol(`agents-server.otel-span`);
@@ -6679,6 +6987,10 @@ function errorMapper(err, req) {
6679
6987
  });
6680
6988
  }
6681
6989
  if (err instanceof ElectricAgentsError) return apiError(err.status, err.code, err.message, err.details);
6990
+ if (err instanceof ElectricProxyError) {
6991
+ serverLog.warn(`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`);
6992
+ return apiError(err.status, err.code, err.message);
6993
+ }
6682
6994
  serverLog.error(`[agent-server] Unhandled error:`, err);
6683
6995
  return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`);
6684
6996
  }
@@ -7129,6 +7441,7 @@ internalRouter.all(`/runners`, runnersRouter.fetch);
7129
7441
  internalRouter.all(`/runners/*`, runnersRouter.fetch);
7130
7442
  internalRouter.all(`/entities/*`, entitiesRouter.fetch);
7131
7443
  internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch);
7444
+ internalRouter.all(`/pg-sync/*`, pgSyncRouter.fetch);
7132
7445
  internalRouter.all(`/observations/*`, observationsRouter.fetch);
7133
7446
  internalRouter.get(`/electric/*`, electricProxyRouter.fetch);
7134
7447
  internalRouter.all(`*`, () => status(404));
@@ -7512,6 +7825,9 @@ const globalRouter = AutoRouter({
7512
7825
  finally: [otelEndSpan, applyCors]
7513
7826
  });
7514
7827
  globalRouter.all(`/_electric/shared-state/*`, durableStreamsRouter.fetch);
7828
+ globalRouter.all(`/_electric/pg-sync/register`, internalRouter.fetch);
7829
+ globalRouter.get(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
7830
+ globalRouter.head(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
7515
7831
  globalRouter.all(`/_electric/*`, internalRouter.fetch);
7516
7832
  globalRouter.all(`*`, durableStreamsRouter.fetch);
7517
7833
 
@@ -7554,7 +7870,7 @@ const ENTITY_SHAPE_COLUMNS = [
7554
7870
  `created_at`,
7555
7871
  `updated_at`
7556
7872
  ];
7557
- function parseElectricOffset(offset) {
7873
+ function parseElectricOffset$1(offset) {
7558
7874
  if (offset === `-1`) return offset;
7559
7875
  return /^\d+_\d+$/.test(offset) ? offset : null;
7560
7876
  }
@@ -7646,7 +7962,7 @@ var EntityBridge = class {
7646
7962
  });
7647
7963
  await this.loadCurrentMembers();
7648
7964
  if (this.initialShapeHandle && this.initialShapeOffset) {
7649
- const initialOffset = parseElectricOffset(this.initialShapeOffset);
7965
+ const initialOffset = parseElectricOffset$1(this.initialShapeOffset);
7650
7966
  if (initialOffset) {
7651
7967
  this.startLiveStream(initialOffset, this.initialShapeHandle);
7652
7968
  return;
@@ -8132,6 +8448,9 @@ function isPermanentElectricAgentsError(err) {
8132
8448
  const name = typeof err === `object` && err !== null && `name` in err ? err.name : void 0;
8133
8449
  return name === `ElectricAgentsError` && typeof status$1 === `number` && status$1 >= 400 && status$1 < 500;
8134
8450
  }
8451
+ function cronTaskStreamPath(payload) {
8452
+ return typeof payload.streamPath === `string` ? payload.streamPath : null;
8453
+ }
8135
8454
  function normalizeTask(row) {
8136
8455
  return {
8137
8456
  id: Number(row.id),
@@ -8474,6 +8793,15 @@ var Scheduler = class {
8474
8793
  `;
8475
8794
  if (completed.length === 0) return;
8476
8795
  const nextFireAt = getNextCronFireAt(task.cronExpression, task.cronTimezone, task.fireAt);
8796
+ const streamPath = cronTaskStreamPath(task.payload);
8797
+ const subscriberRows = streamPath ? await sql$1`
8798
+ select 1 as exists
8799
+ from wake_registrations
8800
+ where tenant_id = ${tenantId}
8801
+ and source_url = ${streamPath}
8802
+ limit 1
8803
+ ` : [];
8804
+ if (subscriberRows.length === 0) return;
8477
8805
  await sql$1`
8478
8806
  insert into scheduled_tasks (
8479
8807
  tenant_id,
@@ -8573,6 +8901,308 @@ var Scheduler = class {
8573
8901
  }
8574
8902
  };
8575
8903
 
8904
+ //#endregion
8905
+ //#region src/pg-sync-bridge-manager.ts
8906
+ const PG_SYNC_ELECTRIC_SHAPE_URL = process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ?? `http://localhost:3000/v1/shape`;
8907
+ const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
8908
+ const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
8909
+ function buildElectricShapeParams(options) {
8910
+ return {
8911
+ table: options.table,
8912
+ ...options.columns !== void 0 ? { columns: [...options.columns] } : {},
8913
+ ...options.where !== void 0 ? { where: options.where } : {},
8914
+ ...options.params !== void 0 ? { params: Array.isArray(options.params) ? [...options.params] : { ...options.params } } : {},
8915
+ ...options.replica !== void 0 ? { replica: options.replica } : {},
8916
+ ...options.metadata?.tenantId ? { electric_agents_tenant_id: options.metadata.tenantId } : {},
8917
+ ...options.metadata?.principalKind ? { electric_agents_principal_kind: options.metadata.principalKind } : {},
8918
+ ...options.metadata?.principalId ? { electric_agents_principal_id: options.metadata.principalId } : {},
8919
+ ...options.metadata?.principalKey ? { electric_agents_principal_key: options.metadata.principalKey } : {},
8920
+ ...options.metadata?.principalUrl ? { electric_agents_principal_url: options.metadata.principalUrl } : {},
8921
+ ...options.metadata?.entityUrl ? { electric_agents_entity_url: options.metadata.entityUrl } : {},
8922
+ ...options.metadata?.entityType ? { electric_agents_entity_type: options.metadata.entityType } : {},
8923
+ ...options.metadata?.streamPath ? { electric_agents_stream_path: options.metadata.streamPath } : {},
8924
+ ...options.metadata?.runtimeConsumerId ? { electric_agents_runtime_consumer_id: options.metadata.runtimeConsumerId } : {},
8925
+ ...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
8926
+ };
8927
+ }
8928
+ function jsonSafe(value) {
8929
+ if (typeof value === `bigint`) return value.toString();
8930
+ if (value === null || typeof value !== `object`) return value;
8931
+ if (Array.isArray(value)) return value.map(jsonSafe);
8932
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
8933
+ }
8934
+ function stableJson(value) {
8935
+ if (typeof value === `bigint`) return JSON.stringify(value.toString());
8936
+ if (value === null || typeof value !== `object`) return JSON.stringify(value);
8937
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`;
8938
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(`,`)}}`;
8939
+ }
8940
+ function parseElectricOffset(offset) {
8941
+ if (offset === `-1`) return offset;
8942
+ return /^\d+_\d+$/.test(offset) ? offset : null;
8943
+ }
8944
+ function rowKeyForMessage(message) {
8945
+ const headers = message.headers;
8946
+ const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
8947
+ return candidate === void 0 ? void 0 : stableJson(candidate);
8948
+ }
8949
+ function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
8950
+ const operation = message.headers.operation;
8951
+ if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
8952
+ const sourceRef = typeof optionsOrSourceRef === `string` ? optionsOrSourceRef : sourceRefForPgSync(optionsOrSourceRef);
8953
+ const rowKey = rowKeyForMessage(message);
8954
+ const offset = message.headers.offset;
8955
+ if (typeof offset !== `string` || offset.length === 0) return null;
8956
+ const messageKeyPart = offset;
8957
+ const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
8958
+ const timestamp$1 = new Date().toISOString();
8959
+ const oldValue = message.old_value;
8960
+ const safeValue = jsonSafe(message.value);
8961
+ const safeOldValue = jsonSafe(oldValue);
8962
+ const safeHeaders = jsonSafe(message.headers);
8963
+ return {
8964
+ type: `pg_sync_change`,
8965
+ key: messageKey,
8966
+ value: {
8967
+ key: messageKey,
8968
+ table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
8969
+ operation,
8970
+ ...rowKey !== void 0 ? { rowKey } : {},
8971
+ ...message.value !== void 0 ? { value: safeValue } : {},
8972
+ ...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
8973
+ headers: safeHeaders,
8974
+ ...typeof offset === `string` ? { offset } : {},
8975
+ receivedAt: timestamp$1
8976
+ },
8977
+ headers: {
8978
+ operation,
8979
+ timestamp: timestamp$1
8980
+ }
8981
+ };
8982
+ }
8983
+ function cursorFromRow(row) {
8984
+ return row?.shapeHandle && row.shapeOffset ? {
8985
+ handle: row.shapeHandle,
8986
+ offset: row.shapeOffset,
8987
+ initialSnapshotComplete: row.initialSnapshotComplete
8988
+ } : void 0;
8989
+ }
8990
+ var PgSyncBridge = class {
8991
+ producer = null;
8992
+ unsubscribe = null;
8993
+ abortController = null;
8994
+ skipChangesUntilUpToDate = false;
8995
+ recovering = false;
8996
+ committedCursor;
8997
+ retryAttempt = 0;
8998
+ constructor(sourceRef, streamUrl, options, resolvedSource, retry, streamClient, registry, evaluateWakes, initialCursor) {
8999
+ this.sourceRef = sourceRef;
9000
+ this.streamUrl = streamUrl;
9001
+ this.options = options;
9002
+ this.resolvedSource = resolvedSource;
9003
+ this.retry = retry;
9004
+ this.streamClient = streamClient;
9005
+ this.registry = registry;
9006
+ this.evaluateWakes = evaluateWakes;
9007
+ this.initialCursor = initialCursor;
9008
+ this.committedCursor = initialCursor;
9009
+ }
9010
+ async start() {
9011
+ if (!this.producer) this.producer = new IdempotentProducer(new DurableStream({
9012
+ url: `${this.streamClient.baseUrl}${this.streamUrl}`,
9013
+ contentType: `application/json`
9014
+ }), `pg-sync-bridge-${this.sourceRef}`);
9015
+ if (this.initialCursor) {
9016
+ const offset = parseElectricOffset(this.initialCursor.offset);
9017
+ if (offset) {
9018
+ this.startStream(offset, this.initialCursor.handle, !this.initialCursor.initialSnapshotComplete);
9019
+ return;
9020
+ }
9021
+ }
9022
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
9023
+ this.startStream(`now`, void 0, true);
9024
+ }
9025
+ async stop() {
9026
+ this.unsubscribe?.();
9027
+ this.abortController?.abort();
9028
+ this.unsubscribe = null;
9029
+ this.abortController = null;
9030
+ try {
9031
+ await this.producer?.flush();
9032
+ } finally {
9033
+ await this.producer?.detach();
9034
+ this.producer = null;
9035
+ }
9036
+ }
9037
+ startStream(offset, handle, skipChangesUntilUpToDate = false, log = offset === `now` ? `changes_only` : `full`) {
9038
+ this.unsubscribe?.();
9039
+ this.abortController?.abort();
9040
+ this.skipChangesUntilUpToDate = skipChangesUntilUpToDate;
9041
+ this.abortController = new AbortController();
9042
+ const stream = new ShapeStream({
9043
+ url: this.resolvedSource.url,
9044
+ params: buildElectricShapeParams(this.options),
9045
+ offset,
9046
+ log,
9047
+ ...handle ? { handle } : {},
9048
+ signal: this.abortController.signal
9049
+ });
9050
+ this.unsubscribe = stream.subscribe(async (messages) => {
9051
+ try {
9052
+ for (const message of messages) {
9053
+ if (isControlMessage(message)) {
9054
+ if (message.headers.control === `must-refetch`) {
9055
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
9056
+ this.startStream(`now`, void 0, true);
9057
+ return;
9058
+ }
9059
+ if (message.headers.control === `up-to-date`) {
9060
+ this.skipChangesUntilUpToDate = false;
9061
+ await this.persistCursor(stream, true);
9062
+ continue;
9063
+ }
9064
+ await this.persistCursor(stream);
9065
+ continue;
9066
+ }
9067
+ if (!isChangeMessage(message)) continue;
9068
+ if (!this.skipChangesUntilUpToDate) {
9069
+ const event = pgSyncMessageToDurableEvent(message, this.options);
9070
+ if (event) {
9071
+ if (!this.producer) throw new Error(`pg-sync producer is not started`);
9072
+ await this.producer.append(JSON.stringify(event));
9073
+ await this.producer.flush?.();
9074
+ await this.evaluateWakes?.(this.streamUrl, event);
9075
+ }
9076
+ }
9077
+ await this.persistCursor(stream);
9078
+ this.retryAttempt = 0;
9079
+ }
9080
+ } catch (error) {
9081
+ serverLog.warn(`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`, error);
9082
+ await this.recoverStream();
9083
+ }
9084
+ }, (error) => {
9085
+ if (this.abortController?.signal.aborted) return;
9086
+ serverLog.warn(`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`, error);
9087
+ this.recoverStream();
9088
+ });
9089
+ }
9090
+ async recoverStream() {
9091
+ if (this.recovering) return;
9092
+ this.recovering = true;
9093
+ try {
9094
+ const attempt = this.retryAttempt++;
9095
+ const baseDelay = Math.min(this.retry.initialDelayMs * 2 ** attempt, this.retry.maxDelayMs);
9096
+ const jitter = Math.floor(baseDelay * .2 * this.retry.random());
9097
+ const delay = baseDelay + jitter;
9098
+ if (delay > 0) await this.retry.sleep(delay);
9099
+ const offset = this.committedCursor ? parseElectricOffset(this.committedCursor.offset) : null;
9100
+ if (offset && this.committedCursor) this.startStream(offset, this.committedCursor.handle, !this.committedCursor.initialSnapshotComplete);
9101
+ else {
9102
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
9103
+ this.startStream(`now`, void 0, true);
9104
+ }
9105
+ } finally {
9106
+ this.recovering = false;
9107
+ }
9108
+ }
9109
+ async persistCursor(stream, initialSnapshotComplete = !this.skipChangesUntilUpToDate) {
9110
+ const shapeHandle = stream.shapeHandle;
9111
+ const shapeOffset = stream.lastOffset;
9112
+ if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return;
9113
+ await this.registry?.updatePgSyncBridgeCursor(this.sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete);
9114
+ this.committedCursor = {
9115
+ handle: shapeHandle,
9116
+ offset: shapeOffset,
9117
+ initialSnapshotComplete
9118
+ };
9119
+ }
9120
+ };
9121
+ var PgSyncBridgeManager = class {
9122
+ bridges = new Map();
9123
+ starting = new Map();
9124
+ url;
9125
+ retry;
9126
+ constructor(streamClient, evaluateWakes, registry, options = {}) {
9127
+ this.streamClient = streamClient;
9128
+ this.evaluateWakes = evaluateWakes;
9129
+ this.registry = registry;
9130
+ this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
9131
+ this.retry = {
9132
+ initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
9133
+ maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
9134
+ random: options.retry?.random ?? Math.random,
9135
+ sleep: options.retry?.sleep ?? ((ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)))
9136
+ };
9137
+ }
9138
+ async start() {
9139
+ const rows = await this.registry?.listPgSyncBridges?.();
9140
+ if (!rows) return;
9141
+ await Promise.all(rows.map((row) => this.ensureBridge(row).catch((error) => {
9142
+ serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
9143
+ })));
9144
+ }
9145
+ async register(options, metadata) {
9146
+ const mergedMetadata = {
9147
+ ...options.metadata,
9148
+ ...metadata
9149
+ };
9150
+ const canonicalOptions = {
9151
+ ...canonicalPgSyncOptions(options),
9152
+ ...Object.keys(mergedMetadata).length > 0 ? { metadata: mergedMetadata } : {}
9153
+ };
9154
+ const resolvedSource = this.resolveSource(canonicalOptions);
9155
+ const sourceRef = sourceRefForPgSync(canonicalOptions);
9156
+ const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId);
9157
+ const row = await this.registry?.upsertPgSyncBridge({
9158
+ sourceRef,
9159
+ options: canonicalOptions,
9160
+ streamUrl
9161
+ });
9162
+ await this.streamClient.ensure(streamUrl, { contentType: `application/json` });
9163
+ if (!this.bridges.has(sourceRef)) {
9164
+ let start = this.starting.get(sourceRef);
9165
+ if (!start) {
9166
+ start = (async () => {
9167
+ const bridge = new PgSyncBridge(sourceRef, streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
9168
+ await bridge.start();
9169
+ this.bridges.set(sourceRef, bridge);
9170
+ })().finally(() => this.starting.delete(sourceRef));
9171
+ this.starting.set(sourceRef, start);
9172
+ }
9173
+ await start;
9174
+ }
9175
+ return {
9176
+ sourceRef,
9177
+ streamUrl
9178
+ };
9179
+ }
9180
+ async ensureBridge(row) {
9181
+ if (this.bridges.has(row.sourceRef)) return;
9182
+ let start = this.starting.get(row.sourceRef);
9183
+ if (!start) {
9184
+ start = (async () => {
9185
+ await this.streamClient.ensure(row.streamUrl, { contentType: `application/json` });
9186
+ const canonicalOptions = canonicalPgSyncOptions(row.options);
9187
+ const resolvedSource = this.resolveSource(canonicalOptions);
9188
+ const bridge = new PgSyncBridge(row.sourceRef, row.streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
9189
+ await bridge.start();
9190
+ this.bridges.set(row.sourceRef, bridge);
9191
+ })().finally(() => this.starting.delete(row.sourceRef));
9192
+ this.starting.set(row.sourceRef, start);
9193
+ }
9194
+ await start;
9195
+ }
9196
+ resolveSource(options) {
9197
+ return { url: options.url ?? this.url };
9198
+ }
9199
+ async stop() {
9200
+ await Promise.allSettled(this.starting.values());
9201
+ await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()));
9202
+ this.bridges.clear();
9203
+ }
9204
+ };
9205
+
8576
9206
  //#endregion
8577
9207
  //#region src/runtime.ts
8578
9208
  function omitUndefined(value) {
@@ -8587,6 +9217,7 @@ var ElectricAgentsTenantRuntime = class {
8587
9217
  wakeRegistry;
8588
9218
  scheduler;
8589
9219
  entityBridgeManager;
9220
+ pgSyncBridgeManager;
8590
9221
  claimWriteTokens;
8591
9222
  manager;
8592
9223
  constructor(options) {
@@ -8611,9 +9242,10 @@ var ElectricAgentsTenantRuntime = class {
8611
9242
  writeTokenValidator: (entity, token) => this.claimWriteTokens.isValid(this.serviceId, entity.streams.main, token),
8612
9243
  stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false
8613
9244
  });
9245
+ this.pgSyncBridgeManager = options.pgSyncBridgeManager ?? new PgSyncBridgeManager(this.streamClient, (sourceUrl, event) => this.manager.evaluateWakes(sourceUrl, event), this.registry, options.pgSync);
8614
9246
  }
8615
9247
  async stop() {
8616
- await this.manager.shutdown();
9248
+ await Promise.all([this.manager.shutdown(), this.pgSyncBridgeManager.stop()]);
8617
9249
  }
8618
9250
  async rehydrateCronSchedules() {
8619
9251
  const rows = await this.db.select({ sourceUrl: wakeRegistrations.sourceUrl }).from(wakeRegistrations).where(eq(wakeRegistrations.tenantId, this.serviceId));
@@ -9377,7 +10009,10 @@ var WakeRegistry = class {
9377
10009
  }
9378
10010
  if (!isChangeMessage(message)) return;
9379
10011
  if (message.headers.operation === `delete`) {
9380
- this.removeCachedRegistrationByDbId(Number(message.key));
10012
+ const oldValue = message.old_value;
10013
+ const oldId = Number(oldValue?.id);
10014
+ if (Number.isFinite(oldId)) this.removeCachedRegistrationByDbId(oldId);
10015
+ else this.resetCachedRegistrations();
9381
10016
  return;
9382
10017
  }
9383
10018
  this.upsertCachedRegistration(this.normalizeShapeRow(message.value));
@@ -9488,9 +10123,9 @@ var WakeRegistry = class {
9488
10123
  matchCondition(reg, event) {
9489
10124
  if (reg.condition === `runFinished`) {
9490
10125
  if (event.type !== `run`) return null;
9491
- const value = event.value;
10126
+ const value$1 = event.value;
9492
10127
  const headers$1 = event.headers;
9493
- const status$1 = value?.status;
10128
+ const status$1 = value$1?.status;
9494
10129
  const operation$1 = headers$1?.operation;
9495
10130
  if (operation$1 !== `update`) return null;
9496
10131
  if (status$1 !== `completed` && status$1 !== `failed`) return null;
@@ -9513,13 +10148,15 @@ var WakeRegistry = class {
9513
10148
  }
9514
10149
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
9515
10150
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
10151
+ const value = event.value;
9516
10152
  const change = {
9517
10153
  collection: eventType,
9518
10154
  kind,
9519
10155
  key: event.key || ``
9520
10156
  };
10157
+ if (value && `value` in value) change.value = value.value;
10158
+ if (value && `oldValue` in value) change.oldValue = value.oldValue;
9521
10159
  if (eventType === `inbox`) {
9522
- const value = event.value;
9523
10160
  if (typeof value?.from === `string`) change.from = value.from;
9524
10161
  if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
9525
10162
  if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
@@ -9563,12 +10200,15 @@ async function startStandaloneAgentsRuntime(options) {
9563
10200
  wakeRegistry,
9564
10201
  scheduler,
9565
10202
  entityBridgeManager,
10203
+ pgSyncBridgeManager: options.pgSyncBridgeManager,
10204
+ pgSync: options.pgSync,
9566
10205
  stopWakeRegistryOnShutdown: options.wakeRegistry ? false : true
9567
10206
  });
9568
10207
  const startWakeRegistry = options.startWakeRegistry ?? true;
9569
10208
  const startScheduler = options.startScheduler ?? true;
9570
10209
  const startTagStreamOutboxDrainer = options.startTagStreamOutboxDrainer ?? true;
9571
10210
  const startEntityBridgeManager = options.startEntityBridgeManager ?? true;
10211
+ const startPgSyncBridgeManager = options.startPgSyncBridgeManager ?? true;
9572
10212
  const rehydrateOnStart = options.rehydrateOnStart ?? true;
9573
10213
  let entityBridgeManagerStarted = false;
9574
10214
  let tagStreamOutboxDrainerStarted = false;
@@ -9605,6 +10245,10 @@ async function startStandaloneAgentsRuntime(options) {
9605
10245
  await entityBridgeManager.start();
9606
10246
  entityBridgeManagerStarted = true;
9607
10247
  }
10248
+ if (startPgSyncBridgeManager) {
10249
+ serverLog.info(`[agent-server] starting pg-sync bridge manager...`);
10250
+ await runtime.pgSyncBridgeManager.start?.();
10251
+ }
9608
10252
  if (startTagStreamOutboxDrainer) {
9609
10253
  serverLog.info(`[agent-server] starting tag stream outbox drainer...`);
9610
10254
  tagStreamOutboxDrainer.start();
@@ -9632,6 +10276,7 @@ async function startStandaloneAgentsRuntime(options) {
9632
10276
  manager: runtime.manager,
9633
10277
  scheduler,
9634
10278
  entityBridgeManager,
10279
+ pgSyncBridgeManager: runtime.pgSyncBridgeManager,
9635
10280
  tagStreamOutboxDrainer,
9636
10281
  stop
9637
10282
  };
@@ -9749,7 +10394,8 @@ var ElectricAgentsServer = class {
9749
10394
  pgClient: client,
9750
10395
  streamClient: this.streamClient,
9751
10396
  electricUrl: this.options.electricUrl,
9752
- electricSecret: this.options.electricSecret
10397
+ electricSecret: this.options.electricSecret,
10398
+ pgSync: this.options.pgSync
9753
10399
  });
9754
10400
  this.electricAgentsManager = this.standaloneRuntime.manager;
9755
10401
  this.entityBridgeManager = this.standaloneRuntime.entityBridgeManager;
@@ -9871,6 +10517,7 @@ var ElectricAgentsServer = class {
9871
10517
  streamClient: this.streamClient,
9872
10518
  runtime: this.standaloneRuntime.runtime,
9873
10519
  entityBridgeManager: this.entityBridgeManager,
10520
+ pgSyncBridgeManager: this.standaloneRuntime.runtime.pgSyncBridgeManager,
9874
10521
  ...this.options.eventSources ? { eventSources: this.options.eventSources } : {},
9875
10522
  ...this.options.ensureEventSourceWakeSource ? { ensureEventSourceWakeSource: this.options.ensureEventSourceWakeSource } : {},
9876
10523
  ...this.options.authorizeRequest ? { authorizeRequest: this.options.authorizeRequest } : {},