@electric-ax/agents-server 0.4.20 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, 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";
7
+ import { COMMENTS_CONTRACT, COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildTagsIndex, buildWebhookSourceManifestEntry, canonicalPgSyncOptions, createEntityRegistry, createRuntimeHandler, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveWebhookSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature, webhookSourceSubscriptionManifestKey } from "@electric-ax/agents-runtime";
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";
@@ -65,6 +65,7 @@ const entityTypes = pgTable(`entity_types`, {
65
65
  creationSchema: jsonb(`creation_schema`),
66
66
  inboxSchemas: jsonb(`inbox_schemas`),
67
67
  stateSchemas: jsonb(`state_schemas`),
68
+ externallyWritableCollections: jsonb(`externally_writable_collections`).$type(),
68
69
  slashCommands: jsonb(`slash_commands`),
69
70
  serveEndpoint: text(`serve_endpoint`),
70
71
  defaultDispatchPolicy: jsonb(`default_dispatch_policy`),
@@ -2796,6 +2797,7 @@ var PostgresRegistry = class {
2796
2797
  creationSchema: et.creation_schema ?? null,
2797
2798
  inboxSchemas: et.inbox_schemas ?? null,
2798
2799
  stateSchemas: et.state_schemas ?? null,
2800
+ externallyWritableCollections: et.externally_writable_collections ?? null,
2799
2801
  slashCommands: et.slash_commands ?? null,
2800
2802
  serveEndpoint: et.serve_endpoint ?? null,
2801
2803
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -2809,6 +2811,7 @@ var PostgresRegistry = class {
2809
2811
  creationSchema: et.creation_schema ?? null,
2810
2812
  inboxSchemas: et.inbox_schemas ?? null,
2811
2813
  stateSchemas: et.state_schemas ?? null,
2814
+ externallyWritableCollections: et.externally_writable_collections ?? null,
2812
2815
  slashCommands: et.slash_commands ?? null,
2813
2816
  serveEndpoint: et.serve_endpoint ?? null,
2814
2817
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -2827,6 +2830,7 @@ var PostgresRegistry = class {
2827
2830
  creationSchema: et.creation_schema ?? null,
2828
2831
  inboxSchemas: et.inbox_schemas ?? null,
2829
2832
  stateSchemas: et.state_schemas ?? null,
2833
+ externallyWritableCollections: et.externally_writable_collections ?? null,
2830
2834
  slashCommands: et.slash_commands ?? null,
2831
2835
  serveEndpoint: et.serve_endpoint ?? null,
2832
2836
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -2854,6 +2858,7 @@ var PostgresRegistry = class {
2854
2858
  creationSchema: et.creation_schema ?? null,
2855
2859
  inboxSchemas: et.inbox_schemas ?? null,
2856
2860
  stateSchemas: et.state_schemas ?? null,
2861
+ externallyWritableCollections: et.externally_writable_collections ?? null,
2857
2862
  slashCommands: et.slash_commands ?? null,
2858
2863
  serveEndpoint: et.serve_endpoint ?? null,
2859
2864
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
@@ -3298,7 +3303,6 @@ var PostgresRegistry = class {
3298
3303
  set: {
3299
3304
  options: row.options,
3300
3305
  streamUrl: row.streamUrl,
3301
- initialSnapshotComplete: false,
3302
3306
  lastTouchedAt: new Date(),
3303
3307
  updatedAt: new Date()
3304
3308
  }
@@ -3337,6 +3341,9 @@ var PostgresRegistry = class {
3337
3341
  updatedAt: new Date()
3338
3342
  }).where(this.pgSyncBridgeWhere(sourceRef));
3339
3343
  }
3344
+ async deletePgSyncBridge(sourceRef) {
3345
+ await this.db.delete(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef));
3346
+ }
3340
3347
  async upsertEntityBridge(row) {
3341
3348
  await this.db.insert(entityBridges).values({
3342
3349
  tenantId: this.tenantId,
@@ -3499,6 +3506,7 @@ var PostgresRegistry = class {
3499
3506
  creation_schema: row.creationSchema,
3500
3507
  inbox_schemas: row.inboxSchemas,
3501
3508
  state_schemas: row.stateSchemas,
3509
+ externally_writable_collections: row.externallyWritableCollections ?? void 0,
3502
3510
  slash_commands: row.slashCommands ?? void 0,
3503
3511
  serve_endpoint: row.serveEndpoint ?? void 0,
3504
3512
  default_dispatch_policy: row.defaultDispatchPolicy ?? void 0,
@@ -3650,9 +3658,6 @@ var PostgresRegistry = class {
3650
3658
  function isRecord$1(value) {
3651
3659
  return typeof value === `object` && value !== null && !Array.isArray(value);
3652
3660
  }
3653
- function getPgSyncManifestStreamPath(sourceRef) {
3654
- return `/_electric/pg-sync/${sourceRef}`;
3655
- }
3656
3661
  function extractManifestSourceUrl(manifest) {
3657
3662
  if (!manifest) return void 0;
3658
3663
  if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
@@ -3665,7 +3670,7 @@ function extractManifestSourceUrl(manifest) {
3665
3670
  }
3666
3671
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
3667
3672
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
3668
- if (manifest.sourceType === `pgSync`) return typeof manifest.sourceRef === `string` ? getPgSyncManifestStreamPath(manifest.sourceRef) : void 0;
3673
+ if (manifest.sourceType === `pgSync`) return typeof config?.streamUrl === `string` ? config.streamUrl : void 0;
3669
3674
  if (manifest.sourceType === `webhook`) {
3670
3675
  if (typeof config?.streamUrl === `string`) return config.streamUrl;
3671
3676
  if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
@@ -3861,6 +3866,7 @@ var EntityManager = class {
3861
3866
  creation_schema: req.creation_schema,
3862
3867
  inbox_schemas: req.inbox_schemas,
3863
3868
  state_schemas: req.state_schemas,
3869
+ externally_writable_collections: req.externally_writable_collections,
3864
3870
  slash_commands: req.slash_commands,
3865
3871
  serve_endpoint: req.serve_endpoint,
3866
3872
  default_dispatch_policy: defaultDispatchPolicy,
@@ -4956,6 +4962,40 @@ var EntityManager = class {
4956
4962
  throw err;
4957
4963
  }
4958
4964
  }
4965
+ async writeCollection(entityUrl, collection, req) {
4966
+ const entity = await this.registry.getEntity(entityUrl);
4967
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
4968
+ const { externallyWritableCollections } = await this.getEffectiveSchemas(entity);
4969
+ const config = externallyWritableCollections?.[collection];
4970
+ if (!config) throw new ElectricAgentsError(ErrCodeUnauthorized, `Collection "${collection}" is not writable`, 403);
4971
+ const allowedOperations = config.operations ?? [`insert`];
4972
+ if (!allowedOperations.includes(req.operation)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Operation "${req.operation}" is not allowed on collection "${collection}"`, 403);
4973
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
4974
+ if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
4975
+ if (req.operation !== `delete` && (req.value === void 0 || req.value === null)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `value is required for ${req.operation}`, 400);
4976
+ if (req.operation !== `insert` && !req.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `key is required for ${req.operation}`, 400);
4977
+ const key = req.key ?? `${collection}-${randomUUID()}`;
4978
+ const event = {
4979
+ type: config.type,
4980
+ key,
4981
+ headers: {
4982
+ operation: req.operation,
4983
+ timestamp: new Date().toISOString(),
4984
+ principal: req.principal
4985
+ }
4986
+ };
4987
+ if (req.operation !== `delete`) event.value = req.value;
4988
+ const validationError = await this.validateWriteEvent(entity, event);
4989
+ if (validationError) throw new ElectricAgentsError(validationError.code, validationError.message, validationError.status);
4990
+ const encoded = this.encodeChangeEvent(event);
4991
+ try {
4992
+ await this.streamClient.append(entity.streams.main, encoded);
4993
+ } catch (err) {
4994
+ if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
4995
+ throw err;
4996
+ }
4997
+ return { key };
4998
+ }
4959
4999
  async updateInboxMessage(entityUrl, key, req) {
4960
5000
  const entity = await this.registry.getEntity(entityUrl);
4961
5001
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
@@ -5222,7 +5262,7 @@ var EntityManager = class {
5222
5262
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
5223
5263
  return { txid };
5224
5264
  }
5225
- async upsertEventSourceSubscription(entityUrl, req) {
5265
+ async upsertWebhookSourceSubscription(entityUrl, req) {
5226
5266
  const manifestKey = req.subscription.manifestKey;
5227
5267
  const txid = randomUUID();
5228
5268
  await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
@@ -5244,8 +5284,20 @@ var EntityManager = class {
5244
5284
  subscription: req.subscription
5245
5285
  };
5246
5286
  }
5247
- async deleteEventSourceSubscription(entityUrl, req) {
5248
- const manifestKey = eventSourceSubscriptionManifestKey(req.id);
5287
+ async deleteWebhookSourceSubscription(entityUrl, req) {
5288
+ const manifestKey = webhookSourceSubscriptionManifestKey(req.id);
5289
+ const txid = randomUUID();
5290
+ await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
5291
+ await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
5292
+ return { txid };
5293
+ }
5294
+ /**
5295
+ * Stop this entity observing a pg-sync source: drop its manifest entry and
5296
+ * the wake it anchors. The shared pg-sync bridge (keyed by sourceRef, not by
5297
+ * subscriber) is intentionally left running for any other observers.
5298
+ */
5299
+ async deletePgSyncObservation(entityUrl, req) {
5300
+ const manifestKey = `source:pgSync:${req.sourceRef}`;
5249
5301
  const txid = randomUUID();
5250
5302
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
5251
5303
  await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
@@ -5648,7 +5700,8 @@ var EntityManager = class {
5648
5700
  async getEffectiveSchemas(entity) {
5649
5701
  if (!entity.type) return {
5650
5702
  inboxSchemas: entity.inbox_schemas,
5651
- stateSchemas: entity.state_schemas
5703
+ stateSchemas: entity.state_schemas,
5704
+ externallyWritableCollections: void 0
5652
5705
  };
5653
5706
  const latestType = await this.registry.getEntityType(entity.type);
5654
5707
  return {
@@ -5659,7 +5712,8 @@ var EntityManager = class {
5659
5712
  stateSchemas: latestType?.state_schemas ? {
5660
5713
  ...entity.state_schemas ?? {},
5661
5714
  ...latestType.state_schemas
5662
- } : entity.state_schemas
5715
+ } : entity.state_schemas,
5716
+ externallyWritableCollections: latestType?.externally_writable_collections
5663
5717
  };
5664
5718
  }
5665
5719
  isClosedStreamError(err) {
@@ -5922,6 +5976,15 @@ const spawnBodySchema = Type.Object({
5922
5976
  manifestKey: Type.Optional(Type.String())
5923
5977
  }))
5924
5978
  });
5979
+ const writeCollectionBodySchema = Type.Object({
5980
+ operation: Type.Union([
5981
+ Type.Literal(`insert`),
5982
+ Type.Literal(`update`),
5983
+ Type.Literal(`delete`)
5984
+ ]),
5985
+ key: Type.Optional(Type.String()),
5986
+ value: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
5987
+ }, { additionalProperties: false });
5925
5988
  const sendBodySchema = Type.Object({
5926
5989
  payload: Type.Optional(Type.Unknown()),
5927
5990
  key: Type.Optional(Type.String()),
@@ -6031,8 +6094,8 @@ const subscriptionLifetimeSchema = Type.Union([
6031
6094
  }),
6032
6095
  Type.Object({ kind: Type.Literal(`manual`) })
6033
6096
  ]);
6034
- const eventSourceSubscriptionBodySchema = Type.Object({
6035
- sourceKey: Type.String(),
6097
+ const webhookSourceSubscriptionBodySchema = Type.Object({
6098
+ webhookKey: Type.String(),
6036
6099
  bucketKey: Type.Optional(Type.String()),
6037
6100
  params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
6038
6101
  filterKey: Type.Optional(Type.String()),
@@ -6054,6 +6117,7 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermissi
6054
6117
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
6055
6118
  entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
6056
6119
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
6120
+ entitiesRouter.post(`/:type/:instanceId/collections/:collection`, withExistingEntity, withSchema(writeCollectionBodySchema), withEntityPermission(`write`), writeCollection);
6057
6121
  entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
6058
6122
  entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
6059
6123
  entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
@@ -6064,8 +6128,9 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
6064
6128
  entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withEntityPermission(`write`), deleteTag);
6065
6129
  entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), withEntityPermission(`schedule`), upsertSchedule);
6066
6130
  entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withEntityPermission(`schedule`), deleteSchedule);
6067
- entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertEventSourceSubscription);
6068
- entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withEntityPermission(`write`), deleteEventSourceSubscription);
6131
+ entitiesRouter.put(`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(webhookSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertWebhookSourceSubscription);
6132
+ entitiesRouter.delete(`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`, withExistingEntity, withEntityPermission(`write`), deleteWebhookSourceSubscription);
6133
+ entitiesRouter.delete(`/:type/:instanceId/pg-sync-observations/:sourceRef`, withExistingEntity, withEntityPermission(`write`), deletePgSyncObservation);
6069
6134
  entitiesRouter.get(`/:type/:instanceId/grants`, withExistingEntity, withEntityPermission(`manage`), listEntityPermissionGrants);
6070
6135
  entitiesRouter.post(`/:type/:instanceId/grants`, withExistingEntity, withSchema(entityPermissionGrantInputSchema), withEntityPermission(`manage`), createEntityPermissionGrant);
6071
6136
  entitiesRouter.delete(`/:type/:instanceId/grants/:grantId`, withExistingEntity, withEntityPermission(`manage`), deleteEntityPermissionGrant);
@@ -6316,22 +6381,22 @@ async function deleteSchedule(request, ctx) {
6316
6381
  const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
6317
6382
  return json(result);
6318
6383
  }
6319
- async function upsertEventSourceSubscription(request, ctx) {
6320
- const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
6384
+ async function upsertWebhookSourceSubscription(request, ctx) {
6385
+ const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to webhook sources`);
6321
6386
  if (principalMutationError) return principalMutationError;
6322
- const catalog = ctx.eventSources;
6323
- if (!catalog) return apiError(404, ErrCodeNotFound, `No event source catalog is configured`);
6387
+ const catalog = ctx.webhookSources;
6388
+ if (!catalog) return apiError(404, ErrCodeNotFound, `No webhook source catalog is configured`);
6324
6389
  const { entityUrl } = requireExistingEntityRoute(request);
6325
6390
  const parsed = routeBody(request);
6326
- const source = await catalog.getEventSource(parsed.sourceKey);
6327
- if (!source) return apiError(404, ErrCodeNotFound, `Event source "${parsed.sourceKey}" not found`);
6391
+ const source = await catalog.getWebhookSource(parsed.webhookKey);
6392
+ if (!source) return apiError(404, ErrCodeNotFound, `Webhook source "${parsed.webhookKey}" not found`);
6328
6393
  if (parsed.lifetime?.kind === `expires_at`) {
6329
6394
  const expiresAt = new Date(parsed.lifetime.at);
6330
6395
  if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
6331
6396
  }
6332
6397
  let resolved;
6333
6398
  try {
6334
- resolved = resolveEventSourceSubscription({
6399
+ resolved = resolveWebhookSourceSubscription({
6335
6400
  contract: source,
6336
6401
  entityUrl,
6337
6402
  request: {
@@ -6343,18 +6408,25 @@ async function upsertEventSourceSubscription(request, ctx) {
6343
6408
  } catch (error) {
6344
6409
  return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
6345
6410
  }
6346
- await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
6347
- const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
6411
+ await ctx.ensureWebhookSourceWakeSource?.(resolved.subscription.sourceUrl);
6412
+ const result = await ctx.entityManager.upsertWebhookSourceSubscription(entityUrl, {
6348
6413
  subscription: resolved.subscription,
6349
- manifest: buildEventSourceManifestEntry(resolved)
6414
+ manifest: buildWebhookSourceManifestEntry(resolved)
6350
6415
  });
6351
6416
  return json(result);
6352
6417
  }
6353
- async function deleteEventSourceSubscription(request, ctx) {
6354
- const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from event sources`);
6418
+ async function deleteWebhookSourceSubscription(request, ctx) {
6419
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from webhook sources`);
6420
+ if (principalMutationError) return principalMutationError;
6421
+ const { entityUrl } = requireExistingEntityRoute(request);
6422
+ const result = await ctx.entityManager.deleteWebhookSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
6423
+ return json(result);
6424
+ }
6425
+ async function deletePgSyncObservation(request, ctx) {
6426
+ const principalMutationError = rejectPrincipalEntityMutation(request, `unobserved a pg-sync source`);
6355
6427
  if (principalMutationError) return principalMutationError;
6356
6428
  const { entityUrl } = requireExistingEntityRoute(request);
6357
- const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
6429
+ const result = await ctx.entityManager.deletePgSyncObservation(entityUrl, { sourceRef: decodeURIComponent(request.params.sourceRef) });
6358
6430
  return json(result);
6359
6431
  }
6360
6432
  function tagResponseBody(entity) {
@@ -6452,6 +6524,23 @@ async function sendEntity(request, ctx) {
6452
6524
  const result = await ctx.entityManager.send(entityUrl, sendReq);
6453
6525
  return json(result);
6454
6526
  }
6527
+ async function writeCollection(request, ctx) {
6528
+ const parsed = routeBody(request);
6529
+ await ctx.entityManager.ensurePrincipal(ctx.principal);
6530
+ const { entityUrl } = requireExistingEntityRoute(request);
6531
+ const collection = request.params.collection;
6532
+ const result = await ctx.entityManager.writeCollection(entityUrl, collection, {
6533
+ operation: parsed.operation,
6534
+ key: parsed.key,
6535
+ value: parsed.value,
6536
+ principal: {
6537
+ url: ctx.principal.url,
6538
+ kind: ctx.principal.kind,
6539
+ id: ctx.principal.id
6540
+ }
6541
+ });
6542
+ return json(result, { status: parsed.operation === `insert` ? 201 : 200 });
6543
+ }
6455
6544
  async function createAttachment(request, ctx) {
6456
6545
  const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
6457
6546
  if (principalMutationError) return principalMutationError;
@@ -6549,8 +6638,13 @@ async function spawnEntity(request, ctx) {
6549
6638
  headers: { "x-write-token": entity.write_token }
6550
6639
  });
6551
6640
  }
6552
- function getEntity(request) {
6553
- return json(toPublicEntity(requireExistingEntityRoute(request).entity));
6641
+ async function getEntity(request, ctx) {
6642
+ const { entity } = requireExistingEntityRoute(request);
6643
+ const entityType = entity.type ? await ctx.entityManager.registry.getEntityType(entity.type) : null;
6644
+ return json({
6645
+ ...toPublicEntity(entity),
6646
+ ...entityType?.externally_writable_collections && { externally_writable_collections: entityType.externally_writable_collections }
6647
+ });
6554
6648
  }
6555
6649
  function headEntity() {
6556
6650
  return status(200);
@@ -6585,6 +6679,16 @@ async function signalEntity(request, ctx) {
6585
6679
  //#region src/routing/entity-types-router.ts
6586
6680
  const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
6587
6681
  const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
6682
+ const externallyWritableCollectionsSchema = Type.Record(Type.String(), Type.Object({
6683
+ type: Type.String(),
6684
+ contract: Type.Optional(Type.String()),
6685
+ operations: Type.Optional(Type.Array(Type.Union([
6686
+ Type.Literal(`insert`),
6687
+ Type.Literal(`update`),
6688
+ Type.Literal(`delete`)
6689
+ ]))),
6690
+ principalColumn: Type.Optional(Type.String())
6691
+ }, { additionalProperties: false }));
6588
6692
  const slashCommandArgumentSchema = Type.Object({
6589
6693
  name: Type.String(),
6590
6694
  type: Type.Union([
@@ -6615,7 +6719,8 @@ const registerEntityTypeBodySchema = Type.Object({
6615
6719
  slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
6616
6720
  serve_endpoint: Type.Optional(Type.String()),
6617
6721
  default_dispatch_policy: Type.Optional(dispatchPolicySchema),
6618
- permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
6722
+ permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema)),
6723
+ externally_writable_collections: Type.Optional(externallyWritableCollectionsSchema)
6619
6724
  }, { additionalProperties: false });
6620
6725
  const amendEntityTypeSchemasBodySchema = Type.Object({
6621
6726
  inbox_schemas: Type.Optional(schemaMapSchema),
@@ -6746,7 +6851,20 @@ function parseExpiresAt(value) {
6746
6851
  if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
6747
6852
  return expiresAt;
6748
6853
  }
6854
+ /**
6855
+ * The `comments` collection name is reserved for the canonical comments
6856
+ * contract: the UI keys its comment affordances on it, so a divergent
6857
+ * collection registered under that name (or the contract mounted under
6858
+ * another name) would break that assumption silently.
6859
+ */
6860
+ function validateExternallyWritableCollections(collections) {
6861
+ for (const [name, config] of Object.entries(collections ?? {})) {
6862
+ 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);
6863
+ if (config.contract === COMMENTS_CONTRACT && name !== `comments`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The "${COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`, 400);
6864
+ }
6865
+ }
6749
6866
  function normalizeEntityTypeRequest(parsed) {
6867
+ validateExternallyWritableCollections(parsed.externally_writable_collections);
6750
6868
  const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
6751
6869
  return {
6752
6870
  name: parsed.name ?? ``,
@@ -6760,7 +6878,8 @@ function normalizeEntityTypeRequest(parsed) {
6760
6878
  type: `webhook`,
6761
6879
  url: serveEndpoint
6762
6880
  }] } : void 0),
6763
- permission_grants: parsed.permission_grants
6881
+ permission_grants: parsed.permission_grants,
6882
+ externally_writable_collections: parsed.externally_writable_collections
6764
6883
  };
6765
6884
  }
6766
6885
  function toPublicEntityType(entityType) {
@@ -6771,130 +6890,481 @@ function toPublicEntityType(entityType) {
6771
6890
  }
6772
6891
 
6773
6892
  //#endregion
6774
- //#region src/routing/pg-sync-router.ts
6775
- const pgSyncOptionsSchema = Type.Object({
6776
- url: Type.Optional(Type.String()),
6777
- table: Type.String(),
6778
- columns: Type.Optional(Type.Array(Type.String())),
6779
- where: Type.Optional(Type.String()),
6780
- params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
6781
- replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)]))
6782
- });
6783
- const pgSyncRequestMetadataSchema = Type.Object({
6784
- entityUrl: Type.Optional(Type.String()),
6785
- entityType: Type.Optional(Type.String()),
6786
- streamPath: Type.Optional(Type.String()),
6787
- runtimeConsumerId: Type.Optional(Type.String()),
6788
- wakeId: Type.Optional(Type.String())
6789
- });
6790
- const pgSyncRegisterBodySchema = Type.Object({
6791
- options: pgSyncOptionsSchema,
6792
- metadata: Type.Optional(pgSyncRequestMetadataSchema)
6793
- });
6794
- const pgSyncRouter = Router({ base: `/_electric/pg-sync` });
6795
- pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
6796
- async function registerPgSync(request, ctx) {
6797
- const { options, metadata } = routeBody(request);
6798
- if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
6799
- if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
6893
+ //#region src/pg-sync-bridge-manager.ts
6894
+ /** Registration was rejected because the source itself is invalid — map to a 4xx. */
6895
+ var PgSyncSourceValidationError = class extends Error {
6896
+ name = `PgSyncSourceValidationError`;
6897
+ };
6898
+ const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
6899
+ const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
6900
+ const DEFAULT_PROBE_TIMEOUT_MS = 1e4;
6901
+ function buildElectricShapeParams(options) {
6902
+ return {
6903
+ table: options.table,
6904
+ ...options.columns !== void 0 ? { columns: [...options.columns] } : {},
6905
+ ...options.where !== void 0 ? { where: options.where } : {},
6906
+ ...options.params !== void 0 ? { params: Array.isArray(options.params) ? [...options.params] : { ...options.params } } : {},
6907
+ ...options.replica !== void 0 ? { replica: options.replica } : {},
6908
+ ...options.metadata?.tenantId ? { electric_agents_tenant_id: options.metadata.tenantId } : {},
6909
+ ...options.metadata?.principalKind ? { electric_agents_principal_kind: options.metadata.principalKind } : {},
6910
+ ...options.metadata?.principalId ? { electric_agents_principal_id: options.metadata.principalId } : {},
6911
+ ...options.metadata?.principalKey ? { electric_agents_principal_key: options.metadata.principalKey } : {},
6912
+ ...options.metadata?.principalUrl ? { electric_agents_principal_url: options.metadata.principalUrl } : {},
6913
+ ...options.metadata?.entityUrl ? { electric_agents_entity_url: options.metadata.entityUrl } : {},
6914
+ ...options.metadata?.entityType ? { electric_agents_entity_type: options.metadata.entityType } : {},
6915
+ ...options.metadata?.streamPath ? { electric_agents_stream_path: options.metadata.streamPath } : {},
6916
+ ...options.metadata?.runtimeConsumerId ? { electric_agents_runtime_consumer_id: options.metadata.runtimeConsumerId } : {},
6917
+ ...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
6918
+ };
6919
+ }
6920
+ /**
6921
+ * Build the one-shot URL used to validate a shape source at registration
6922
+ * time. Approximates the query-param encoding of the Electric TS client
6923
+ * (arrays comma-joined, where-clause params as `params[n]`) — unlike the
6924
+ * client it does not quote column identifiers, so probe and stream encoding
6925
+ * can diverge for exotic column names.
6926
+ */
6927
+ function buildShapeProbeUrl(sourceUrl, options) {
6928
+ let url;
6800
6929
  try {
6801
- const requestMetadata$1 = {
6802
- tenantId: ctx.service,
6803
- principalKind: ctx.principal.kind,
6804
- principalId: ctx.principal.id,
6805
- principalKey: ctx.principal.key,
6806
- principalUrl: ctx.principal.url,
6807
- ...metadata ?? {}
6808
- };
6809
- const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
6810
- return json(result);
6811
- } catch (error) {
6812
- return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
6930
+ url = new URL(sourceUrl);
6931
+ } catch {
6932
+ throw new PgSyncSourceValidationError(`pgSync url "${sourceUrl}" is not a valid URL`);
6813
6933
  }
6934
+ if (url.protocol !== `http:` && url.protocol !== `https:`) throw new PgSyncSourceValidationError(`pgSync url "${sourceUrl}" must be an HTTP(S) Electric shape endpoint, not a database connection string`);
6935
+ for (const [key, value] of Object.entries(buildElectricShapeParams(options))) {
6936
+ if (value === void 0 || value === null) continue;
6937
+ if (Array.isArray(value)) if (key === `params`) value.forEach((item, index$1) => url.searchParams.set(`params[${index$1 + 1}]`, String(item)));
6938
+ else url.searchParams.set(key, value.join(`,`));
6939
+ else if (typeof value === `object`) for (const [k, v] of Object.entries(value)) url.searchParams.set(`${key}[${k}]`, String(v));
6940
+ else url.searchParams.set(key, String(value));
6941
+ }
6942
+ url.searchParams.set(`offset`, `now`);
6943
+ return url;
6814
6944
  }
6815
-
6816
- //#endregion
6817
- //#region src/routing/hooks.ts
6818
- const SPAN_KEY = Symbol(`agents-server.otel-span`);
6819
- function headersRecord(headers) {
6820
- const out = {};
6821
- headers.forEach((value, key) => {
6822
- out[key] = value;
6823
- });
6824
- return out;
6825
- }
6826
- function carrier(req) {
6827
- return req;
6945
+ function jsonSafe(value) {
6946
+ if (typeof value === `bigint`) return value.toString();
6947
+ if (value === null || typeof value !== `object`) return value;
6948
+ if (Array.isArray(value)) return value.map(jsonSafe);
6949
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
6828
6950
  }
6829
- function startRequestSpan(req, ctx) {
6830
- const existing = carrier(req)[SPAN_KEY];
6831
- if (existing) return existing;
6832
- const url = new URL(req.url);
6833
- const parentCtx = extractTraceContext(headersRecord(req.headers));
6834
- const span = tracer.startSpan(`HTTP ${req.method}`, {
6835
- kind: SpanKind.SERVER,
6836
- attributes: {
6837
- [ATTR.HTTP_METHOD]: req.method,
6838
- [ATTR.HTTP_ROUTE]: url.pathname,
6839
- "electric_agents.tenant_id": ctx.service
6840
- }
6841
- }, parentCtx);
6842
- carrier(req)[SPAN_KEY] = span;
6843
- return span;
6951
+ function stableJson(value) {
6952
+ if (typeof value === `bigint`) return JSON.stringify(value.toString());
6953
+ if (value === null || typeof value !== `object`) return JSON.stringify(value);
6954
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`;
6955
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(`,`)}}`;
6844
6956
  }
6845
- function otelStartSpan(req, ctx) {
6846
- startRequestSpan(req, ctx);
6847
- return void 0;
6957
+ function parseElectricOffset$1(offset) {
6958
+ if (offset === `-1`) return offset;
6959
+ return /^\d+_\d+$/.test(offset) ? offset : null;
6848
6960
  }
6849
- function otelEndSpan(response, req) {
6850
- const span = carrier(req)[SPAN_KEY];
6851
- if (!span) return;
6852
- if (response) span.setAttribute(ATTR.HTTP_STATUS, response.status);
6853
- span.end();
6854
- carrier(req)[SPAN_KEY] = void 0;
6961
+ function rowKeyForMessage(message) {
6962
+ const headers = message.headers;
6963
+ const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
6964
+ return candidate === void 0 ? void 0 : stableJson(candidate);
6855
6965
  }
6856
- function applyCors(response) {
6857
- if (!response) return response;
6858
- const headers = new Headers(response.headers);
6859
- headers.set(`access-control-allow-origin`, `*`);
6860
- headers.set(`access-control-allow-methods`, `GET, POST, PUT, PATCH, DELETE, OPTIONS`);
6861
- headers.set(`access-control-allow-headers`, [
6862
- `content-type`,
6863
- `authorization`,
6864
- `electric-claim-token`,
6865
- `electric-owner-entity`,
6866
- ELECTRIC_PRINCIPAL_HEADER,
6867
- `ngrok-skip-browser-warning`
6868
- ].join(`, `));
6869
- headers.set(`access-control-expose-headers`, `*`);
6870
- return new Response(response.body, {
6871
- status: response.status,
6872
- statusText: response.statusText,
6873
- headers
6874
- });
6966
+ function pgSyncMessageToDurableEvent(message) {
6967
+ const operation = message.headers.operation;
6968
+ if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
6969
+ const key = message.key ?? (typeof message.headers.key === `string` ? message.headers.key : void 0) ?? rowKeyForMessage(message);
6970
+ if (!key) return null;
6971
+ const safeMessage = jsonSafe(message);
6972
+ return {
6973
+ type: `pg_sync_change`,
6974
+ key,
6975
+ value: safeMessage,
6976
+ headers: {
6977
+ ...jsonSafe(message.headers),
6978
+ operation
6979
+ }
6980
+ };
6875
6981
  }
6876
- function preflightCors(req) {
6877
- if (req.method !== `OPTIONS`) return void 0;
6878
- return new Response(null, { status: 204 });
6982
+ function cursorFromRow(row) {
6983
+ return row?.shapeHandle && row.shapeOffset ? {
6984
+ handle: row.shapeHandle,
6985
+ offset: row.shapeOffset,
6986
+ initialSnapshotComplete: row.initialSnapshotComplete
6987
+ } : void 0;
6879
6988
  }
6880
- function errorMapper(err, req) {
6881
- const span = carrier(req)[SPAN_KEY];
6882
- if (err instanceof Error) {
6883
- span?.recordException(err);
6884
- span?.setStatus({
6885
- code: SpanStatusCode.ERROR,
6886
- message: err.message
6887
- });
6989
+ var PgSyncBridge = class {
6990
+ producer = null;
6991
+ unsubscribe = null;
6992
+ abortController = null;
6993
+ skipChangesUntilUpToDate = false;
6994
+ recovering = false;
6995
+ committedCursor;
6996
+ retryAttempt = 0;
6997
+ constructor(sourceRef, streamUrl, options, resolvedSource, retry, streamClient, registry, evaluateWakes, initialCursor) {
6998
+ this.sourceRef = sourceRef;
6999
+ this.streamUrl = streamUrl;
7000
+ this.options = options;
7001
+ this.resolvedSource = resolvedSource;
7002
+ this.retry = retry;
7003
+ this.streamClient = streamClient;
7004
+ this.registry = registry;
7005
+ this.evaluateWakes = evaluateWakes;
7006
+ this.initialCursor = initialCursor;
7007
+ this.committedCursor = initialCursor;
6888
7008
  }
6889
- if (err instanceof ElectricAgentsError) return apiError(err.status, err.code, err.message, err.details);
6890
- if (err instanceof ElectricProxyError) {
6891
- serverLog.warn(`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`);
6892
- return apiError(err.status, err.code, err.message);
7009
+ async start() {
7010
+ if (!this.producer) this.producer = new IdempotentProducer(new DurableStream({
7011
+ url: `${this.streamClient.baseUrl}${this.streamUrl}`,
7012
+ contentType: `application/json`
7013
+ }), `pg-sync-bridge-${this.sourceRef}`);
7014
+ if (this.initialCursor) {
7015
+ const offset = parseElectricOffset$1(this.initialCursor.offset);
7016
+ if (offset) {
7017
+ this.startStream(offset, this.initialCursor.handle, !this.initialCursor.initialSnapshotComplete);
7018
+ return;
7019
+ }
7020
+ }
7021
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
7022
+ this.startStream(`now`, void 0, true);
6893
7023
  }
6894
- serverLog.error(`[agent-server] Unhandled error:`, err);
6895
- return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`);
6896
- }
6897
- function rejectIfShuttingDown(req, ctx) {
7024
+ async stop() {
7025
+ this.unsubscribe?.();
7026
+ this.abortController?.abort();
7027
+ this.unsubscribe = null;
7028
+ this.abortController = null;
7029
+ try {
7030
+ await this.producer?.flush();
7031
+ } finally {
7032
+ await this.producer?.detach();
7033
+ this.producer = null;
7034
+ }
7035
+ }
7036
+ startStream(offset, handle, skipChangesUntilUpToDate = false, log = offset === `now` ? `changes_only` : `full`) {
7037
+ this.unsubscribe?.();
7038
+ this.abortController?.abort();
7039
+ this.skipChangesUntilUpToDate = skipChangesUntilUpToDate;
7040
+ this.abortController = new AbortController();
7041
+ const stream = new ShapeStream({
7042
+ url: this.resolvedSource.url,
7043
+ params: buildElectricShapeParams(this.options),
7044
+ offset,
7045
+ log,
7046
+ ...handle ? { handle } : {},
7047
+ signal: this.abortController.signal
7048
+ });
7049
+ this.unsubscribe = stream.subscribe(async (messages) => {
7050
+ try {
7051
+ for (const message of messages) {
7052
+ if (isControlMessage(message)) {
7053
+ if (message.headers.control === `must-refetch`) {
7054
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
7055
+ this.startStream(`now`, void 0, true);
7056
+ return;
7057
+ }
7058
+ if (message.headers.control === `up-to-date`) {
7059
+ this.skipChangesUntilUpToDate = false;
7060
+ await this.persistCursor(stream, true);
7061
+ continue;
7062
+ }
7063
+ await this.persistCursor(stream);
7064
+ continue;
7065
+ }
7066
+ if (!isChangeMessage(message)) continue;
7067
+ if (!this.skipChangesUntilUpToDate) {
7068
+ const event = pgSyncMessageToDurableEvent(message);
7069
+ if (event) {
7070
+ if (!this.producer) throw new Error(`pg-sync producer is not started`);
7071
+ await this.producer.append(JSON.stringify(event));
7072
+ await this.producer.flush?.();
7073
+ await this.evaluateWakes?.(this.streamUrl, event);
7074
+ } else serverLog.warn(`[pg-sync-bridge] dropped change message for ${this.sourceRef} (unknown operation or missing row key):`, message.headers);
7075
+ }
7076
+ await this.persistCursor(stream);
7077
+ this.retryAttempt = 0;
7078
+ }
7079
+ } catch (error) {
7080
+ serverLog.warn(`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`, error);
7081
+ await this.recoverStream();
7082
+ }
7083
+ }, (error) => {
7084
+ if (this.abortController?.signal.aborted) return;
7085
+ serverLog.warn(`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`, error);
7086
+ this.recoverStream();
7087
+ });
7088
+ }
7089
+ async recoverStream() {
7090
+ if (this.recovering) return;
7091
+ this.recovering = true;
7092
+ try {
7093
+ const attempt = this.retryAttempt++;
7094
+ const baseDelay = Math.min(this.retry.initialDelayMs * 2 ** attempt, this.retry.maxDelayMs);
7095
+ const jitter = Math.floor(baseDelay * .2 * this.retry.random());
7096
+ const delay = baseDelay + jitter;
7097
+ if (delay > 0) await this.retry.sleep(delay);
7098
+ const offset = this.committedCursor ? parseElectricOffset$1(this.committedCursor.offset) : null;
7099
+ if (offset && this.committedCursor) this.startStream(offset, this.committedCursor.handle, !this.committedCursor.initialSnapshotComplete);
7100
+ else {
7101
+ await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
7102
+ this.startStream(`now`, void 0, true);
7103
+ }
7104
+ } finally {
7105
+ this.recovering = false;
7106
+ }
7107
+ }
7108
+ async persistCursor(stream, initialSnapshotComplete = !this.skipChangesUntilUpToDate) {
7109
+ const shapeHandle = stream.shapeHandle;
7110
+ const shapeOffset = stream.lastOffset;
7111
+ if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return;
7112
+ await this.registry?.updatePgSyncBridgeCursor(this.sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete);
7113
+ this.committedCursor = {
7114
+ handle: shapeHandle,
7115
+ offset: shapeOffset,
7116
+ initialSnapshotComplete
7117
+ };
7118
+ }
7119
+ };
7120
+ var PgSyncBridgeManager = class {
7121
+ bridges = new Map();
7122
+ starting = new Map();
7123
+ retry;
7124
+ fetchFn;
7125
+ probeTimeoutMs;
7126
+ constructor(streamClient, evaluateWakes, registry, options = {}) {
7127
+ this.streamClient = streamClient;
7128
+ this.evaluateWakes = evaluateWakes;
7129
+ this.registry = registry;
7130
+ this.fetchFn = options.fetchFn;
7131
+ this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
7132
+ this.retry = {
7133
+ initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
7134
+ maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
7135
+ random: options.retry?.random ?? Math.random,
7136
+ sleep: options.retry?.sleep ?? ((ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)))
7137
+ };
7138
+ }
7139
+ async start() {
7140
+ const rows = await this.registry?.listPgSyncBridges?.();
7141
+ if (!rows) return;
7142
+ await Promise.all(rows.map(async (row) => {
7143
+ if (!row.options.url) {
7144
+ serverLog.warn(`[pg-sync-bridge] deleting registration ${row.sourceRef}: it predates required source URLs; re-register the observation with an explicit Electric shape URL`);
7145
+ await this.registry?.deletePgSyncBridge?.(row.sourceRef);
7146
+ return;
7147
+ }
7148
+ await this.ensureBridge(row).catch((error) => {
7149
+ serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
7150
+ });
7151
+ }));
7152
+ }
7153
+ async register(options, metadata) {
7154
+ const mergedMetadata = {
7155
+ ...options.metadata,
7156
+ ...metadata
7157
+ };
7158
+ const canonicalOptions = {
7159
+ ...canonicalPgSyncOptions(options),
7160
+ ...Object.keys(mergedMetadata).length > 0 ? { metadata: mergedMetadata } : {}
7161
+ };
7162
+ const resolvedSource = this.resolveSource(canonicalOptions);
7163
+ const sourceRef = sourceRefForPgSync(canonicalOptions);
7164
+ const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId);
7165
+ if (!this.bridges.has(sourceRef) && !this.starting.has(sourceRef)) await this.probeSource(resolvedSource, canonicalOptions);
7166
+ const row = await this.registry?.upsertPgSyncBridge({
7167
+ sourceRef,
7168
+ options: canonicalOptions,
7169
+ streamUrl
7170
+ });
7171
+ await this.streamClient.ensure(streamUrl, { contentType: `application/json` });
7172
+ if (!this.bridges.has(sourceRef)) {
7173
+ let start = this.starting.get(sourceRef);
7174
+ if (!start) {
7175
+ start = (async () => {
7176
+ const bridge = new PgSyncBridge(sourceRef, streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
7177
+ await bridge.start();
7178
+ this.bridges.set(sourceRef, bridge);
7179
+ })().finally(() => this.starting.delete(sourceRef));
7180
+ this.starting.set(sourceRef, start);
7181
+ }
7182
+ await start;
7183
+ }
7184
+ return {
7185
+ sourceRef,
7186
+ streamUrl
7187
+ };
7188
+ }
7189
+ async ensureBridge(row) {
7190
+ if (this.bridges.has(row.sourceRef)) return;
7191
+ let start = this.starting.get(row.sourceRef);
7192
+ if (!start) {
7193
+ start = (async () => {
7194
+ await this.streamClient.ensure(row.streamUrl, { contentType: `application/json` });
7195
+ const canonicalOptions = canonicalPgSyncOptions(row.options);
7196
+ const resolvedSource = this.resolveSource(canonicalOptions);
7197
+ const bridge = new PgSyncBridge(row.sourceRef, row.streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
7198
+ await bridge.start();
7199
+ this.bridges.set(row.sourceRef, bridge);
7200
+ })().finally(() => this.starting.delete(row.sourceRef));
7201
+ this.starting.set(row.sourceRef, start);
7202
+ }
7203
+ await start;
7204
+ }
7205
+ resolveSource(options) {
7206
+ if (!options.url) throw new PgSyncSourceValidationError(`pgSync source url is required; no server default is configured`);
7207
+ return { url: options.url };
7208
+ }
7209
+ /**
7210
+ * One-shot fetch of the shape log before a bridge is created, so a bad
7211
+ * URL or rejected shape fails the registration instead of dying silently
7212
+ * in the bridge's retry loop.
7213
+ */
7214
+ async probeSource(source, options) {
7215
+ const probeUrl = buildShapeProbeUrl(source.url, options);
7216
+ const fetchFn = this.fetchFn ?? globalThis.fetch;
7217
+ let response;
7218
+ try {
7219
+ response = await fetchFn(probeUrl, { signal: AbortSignal.timeout(this.probeTimeoutMs) });
7220
+ } catch (error) {
7221
+ throw new PgSyncSourceValidationError(`pgSync source at ${source.url} is unreachable: ${error instanceof Error ? error.message : String(error)}`);
7222
+ }
7223
+ if (!response.ok) {
7224
+ const body = (await response.text().catch(() => `<failed to read body>`)).slice(0, 500);
7225
+ throw new PgSyncSourceValidationError(`pgSync source at ${source.url} rejected the shape request (${response.status})${body ? `: ${body}` : ``}`);
7226
+ }
7227
+ if (!response.headers.get(`electric-handle`)) {
7228
+ const suggestion = new URL(source.url);
7229
+ suggestion.pathname = `/v1/shape`;
7230
+ throw new PgSyncSourceValidationError(`pgSync source at ${source.url} responded but is not a shape log (missing electric-handle header) — the Electric shape API is usually served at ${suggestion.origin}/v1/shape`);
7231
+ }
7232
+ }
7233
+ async stop() {
7234
+ await Promise.allSettled(this.starting.values());
7235
+ await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()));
7236
+ this.bridges.clear();
7237
+ }
7238
+ };
7239
+
7240
+ //#endregion
7241
+ //#region src/routing/pg-sync-router.ts
7242
+ const pgSyncOptionsSchema = Type.Object({
7243
+ url: Type.String(),
7244
+ table: Type.String(),
7245
+ columns: Type.Optional(Type.Array(Type.String())),
7246
+ where: Type.Optional(Type.String()),
7247
+ params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
7248
+ replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)]))
7249
+ });
7250
+ const pgSyncRequestMetadataSchema = Type.Object({
7251
+ entityUrl: Type.Optional(Type.String()),
7252
+ entityType: Type.Optional(Type.String()),
7253
+ streamPath: Type.Optional(Type.String()),
7254
+ runtimeConsumerId: Type.Optional(Type.String()),
7255
+ wakeId: Type.Optional(Type.String())
7256
+ });
7257
+ const pgSyncRegisterBodySchema = Type.Object({
7258
+ options: pgSyncOptionsSchema,
7259
+ metadata: Type.Optional(pgSyncRequestMetadataSchema)
7260
+ });
7261
+ const pgSyncRouter = Router({ base: `/_electric/pg-sync` });
7262
+ pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
7263
+ async function registerPgSync(request, ctx) {
7264
+ const { options, metadata } = routeBody(request);
7265
+ if (options.url.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync url must be non-empty`);
7266
+ if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
7267
+ if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
7268
+ try {
7269
+ const requestMetadata$1 = {
7270
+ tenantId: ctx.service,
7271
+ principalKind: ctx.principal.kind,
7272
+ principalId: ctx.principal.id,
7273
+ principalKey: ctx.principal.key,
7274
+ principalUrl: ctx.principal.url,
7275
+ ...metadata ?? {}
7276
+ };
7277
+ const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
7278
+ return json(result);
7279
+ } catch (error) {
7280
+ if (error instanceof PgSyncSourceValidationError) return apiError(400, ErrCodeInvalidRequest, error.message);
7281
+ serverLog.error(`[pg-sync] registration failed for table "${options.table}":`, error);
7282
+ return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
7283
+ }
7284
+ }
7285
+
7286
+ //#endregion
7287
+ //#region src/routing/hooks.ts
7288
+ const SPAN_KEY = Symbol(`agents-server.otel-span`);
7289
+ function headersRecord(headers) {
7290
+ const out = {};
7291
+ headers.forEach((value, key) => {
7292
+ out[key] = value;
7293
+ });
7294
+ return out;
7295
+ }
7296
+ function carrier(req) {
7297
+ return req;
7298
+ }
7299
+ function startRequestSpan(req, ctx) {
7300
+ const existing = carrier(req)[SPAN_KEY];
7301
+ if (existing) return existing;
7302
+ const url = new URL(req.url);
7303
+ const parentCtx = extractTraceContext(headersRecord(req.headers));
7304
+ const span = tracer.startSpan(`HTTP ${req.method}`, {
7305
+ kind: SpanKind.SERVER,
7306
+ attributes: {
7307
+ [ATTR.HTTP_METHOD]: req.method,
7308
+ [ATTR.HTTP_ROUTE]: url.pathname,
7309
+ "electric_agents.tenant_id": ctx.service
7310
+ }
7311
+ }, parentCtx);
7312
+ carrier(req)[SPAN_KEY] = span;
7313
+ return span;
7314
+ }
7315
+ function otelStartSpan(req, ctx) {
7316
+ startRequestSpan(req, ctx);
7317
+ return void 0;
7318
+ }
7319
+ function otelEndSpan(response, req) {
7320
+ const span = carrier(req)[SPAN_KEY];
7321
+ if (!span) return;
7322
+ if (response) span.setAttribute(ATTR.HTTP_STATUS, response.status);
7323
+ span.end();
7324
+ carrier(req)[SPAN_KEY] = void 0;
7325
+ }
7326
+ function applyCors(response) {
7327
+ if (!response) return response;
7328
+ const headers = new Headers(response.headers);
7329
+ headers.set(`access-control-allow-origin`, `*`);
7330
+ headers.set(`access-control-allow-methods`, `GET, POST, PUT, PATCH, DELETE, OPTIONS`);
7331
+ headers.set(`access-control-allow-headers`, [
7332
+ `content-type`,
7333
+ `authorization`,
7334
+ `electric-claim-token`,
7335
+ `electric-owner-entity`,
7336
+ ELECTRIC_PRINCIPAL_HEADER,
7337
+ `ngrok-skip-browser-warning`
7338
+ ].join(`, `));
7339
+ headers.set(`access-control-expose-headers`, `*`);
7340
+ return new Response(response.body, {
7341
+ status: response.status,
7342
+ statusText: response.statusText,
7343
+ headers
7344
+ });
7345
+ }
7346
+ function preflightCors(req) {
7347
+ if (req.method !== `OPTIONS`) return void 0;
7348
+ return new Response(null, { status: 204 });
7349
+ }
7350
+ function errorMapper(err, req) {
7351
+ const span = carrier(req)[SPAN_KEY];
7352
+ if (err instanceof Error) {
7353
+ span?.recordException(err);
7354
+ span?.setStatus({
7355
+ code: SpanStatusCode.ERROR,
7356
+ message: err.message
7357
+ });
7358
+ }
7359
+ if (err instanceof ElectricAgentsError) return apiError(err.status, err.code, err.message, err.details);
7360
+ if (err instanceof ElectricProxyError) {
7361
+ serverLog.warn(`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`);
7362
+ return apiError(err.status, err.code, err.message);
7363
+ }
7364
+ serverLog.error(`[agent-server] Unhandled error:`, err);
7365
+ return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`);
7366
+ }
7367
+ function rejectIfShuttingDown(req, ctx) {
6898
7368
  if (!ctx.isShuttingDown()) return void 0;
6899
7369
  const path$1 = new URL(req.url).pathname;
6900
7370
  if (!path$1.startsWith(`/_electric/subscription-webhooks/`)) return void 0;
@@ -7333,7 +7803,7 @@ const wakeCallbackBodySchema = Type.Object({
7333
7803
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
7334
7804
  const internalRouter = Router({ base: `/_electric` });
7335
7805
  internalRouter.get(`/health`, () => json({ status: `ok` }));
7336
- internalRouter.get(`/event-sources`, listEventSources);
7806
+ internalRouter.get(`/webhook-sources`, listWebhookSources);
7337
7807
  internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
7338
7808
  internalRouter.post(`/subscription-webhooks/:subscriptionId`, subscriptionWebhook);
7339
7809
  internalRouter.post(`/wake-callbacks/:consumerId`, wakeCallback);
@@ -7455,11 +7925,11 @@ async function registerWake(request, ctx) {
7455
7925
  await ctx.entityManager.registerWake(opts);
7456
7926
  return status(204);
7457
7927
  }
7458
- async function listEventSources(_request, ctx) {
7459
- const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
7460
- return json({ eventSources: eventSources.filter(isAgentVisibleEventSource) });
7928
+ async function listWebhookSources(_request, ctx) {
7929
+ const webhookSources = ctx.webhookSources ? await ctx.webhookSources.listWebhookSources() : [];
7930
+ return json({ webhookSources: webhookSources.filter(isAgentVisibleWebhookSource) });
7461
7931
  }
7462
- function isAgentVisibleEventSource(source) {
7932
+ function isAgentVisibleWebhookSource(source) {
7463
7933
  return source.agentVisible === true && source.status === `active`;
7464
7934
  }
7465
7935
  async function subscriptionWebhook(request, ctx) {
@@ -7770,7 +8240,7 @@ const ENTITY_SHAPE_COLUMNS = [
7770
8240
  `created_at`,
7771
8241
  `updated_at`
7772
8242
  ];
7773
- function parseElectricOffset$1(offset) {
8243
+ function parseElectricOffset(offset) {
7774
8244
  if (offset === `-1`) return offset;
7775
8245
  return /^\d+_\d+$/.test(offset) ? offset : null;
7776
8246
  }
@@ -7862,7 +8332,7 @@ var EntityBridge = class {
7862
8332
  });
7863
8333
  await this.loadCurrentMembers();
7864
8334
  if (this.initialShapeHandle && this.initialShapeOffset) {
7865
- const initialOffset = parseElectricOffset$1(this.initialShapeOffset);
8335
+ const initialOffset = parseElectricOffset(this.initialShapeOffset);
7866
8336
  if (initialOffset) {
7867
8337
  this.startLiveStream(initialOffset, this.initialShapeHandle);
7868
8338
  return;
@@ -8367,739 +8837,437 @@ function normalizeTask(row) {
8367
8837
  }
8368
8838
  var Scheduler = class {
8369
8839
  claimExpiryMs;
8370
- safetyPollMs;
8371
- listenEnabled;
8372
- pgClient;
8373
- instanceId;
8374
- tenantId;
8375
- tenantIds;
8376
- running = false;
8377
- loopPromise = null;
8378
- currentSleepResolve = null;
8379
- currentSleepTimer = null;
8380
- listenerMeta = null;
8381
- constructor(options) {
8382
- this.options = options;
8383
- this.pgClient = options.pgClient;
8384
- this.instanceId = options.instanceId;
8385
- this.tenantId = options.tenantId === void 0 ? DEFAULT_TENANT_ID : options.tenantId;
8386
- this.tenantIds = options.tenantIds;
8387
- this.claimExpiryMs = options.claimExpiryMs ?? 3e4;
8388
- this.safetyPollMs = options.safetyPollMs ?? 1e4;
8389
- this.listenEnabled = options.listen !== false;
8390
- }
8391
- resolveTenantId(tenantId) {
8392
- if (tenantId) return tenantId;
8393
- if (this.tenantId) return this.tenantId;
8394
- throw new Error(`Scheduler tenantId is required in shared mode`);
8395
- }
8396
- async start() {
8397
- if (this.running) return;
8398
- this.running = true;
8399
- if (this.listenEnabled) this.listenerMeta = await this.pgClient.listen(`scheduled_tasks_wake`, () => {
8400
- this.wakeEarly();
8401
- });
8402
- this.loopPromise = this.runLoop().catch((err) => {
8403
- console.error(`[agent-server] scheduler loop failed:`, err);
8404
- this.running = false;
8405
- this.wakeEarly();
8406
- });
8407
- }
8408
- async stop() {
8409
- this.running = false;
8410
- this.wakeEarly();
8411
- if (this.loopPromise) {
8412
- await this.loopPromise;
8413
- this.loopPromise = null;
8414
- }
8415
- if (this.listenerMeta) {
8416
- await this.listenerMeta.unlisten();
8417
- this.listenerMeta = null;
8418
- }
8419
- }
8420
- wake() {
8421
- this.wakeEarly();
8422
- }
8423
- async enqueueDelayedSend(payload, fireAt, opts) {
8424
- const tenantId = this.resolveTenantId();
8425
- await this.pgClient`
8426
- insert into scheduled_tasks (
8427
- tenant_id,
8428
- kind,
8429
- payload,
8430
- fire_at,
8431
- owner_entity_url,
8432
- manifest_key
8433
- )
8434
- values (
8435
- ${tenantId},
8436
- 'delayed_send',
8437
- ${JSON.stringify(payload)}::jsonb,
8438
- ${fireAt.toISOString()}::timestamptz,
8439
- ${opts?.ownerEntityUrl ?? null},
8440
- ${opts?.manifestKey ?? null}
8441
- )
8442
- `;
8443
- this.wakeEarly();
8444
- }
8445
- async syncManifestDelayedSend(ownerEntityUrl, manifestKey, payload, fireAt) {
8446
- const tenantId = this.resolveTenantId();
8447
- await this.pgClient.begin(async (sql$1) => {
8448
- await sql$1`
8449
- update scheduled_tasks
8450
- set completed_at = now(), claimed_at = null, claimed_by = null
8451
- where tenant_id = ${tenantId}
8452
- and kind = 'delayed_send'
8453
- and owner_entity_url = ${ownerEntityUrl}
8454
- and manifest_key = ${manifestKey}
8455
- and completed_at is null
8456
- `;
8457
- await sql$1`
8458
- insert into scheduled_tasks (
8459
- tenant_id,
8460
- kind,
8461
- payload,
8462
- fire_at,
8463
- owner_entity_url,
8464
- manifest_key
8465
- )
8466
- values (
8467
- ${tenantId},
8468
- 'delayed_send',
8469
- ${JSON.stringify(payload)}::jsonb,
8470
- ${fireAt.toISOString()}::timestamptz,
8471
- ${ownerEntityUrl},
8472
- ${manifestKey}
8473
- )
8474
- `;
8475
- });
8476
- this.wakeEarly();
8477
- }
8478
- async cancelManifestDelayedSend(ownerEntityUrl, manifestKey) {
8479
- const tenantId = this.resolveTenantId();
8480
- await this.pgClient`
8481
- update scheduled_tasks
8482
- set completed_at = now(), claimed_at = null, claimed_by = null
8483
- where tenant_id = ${tenantId}
8484
- and kind = 'delayed_send'
8485
- and owner_entity_url = ${ownerEntityUrl}
8486
- and manifest_key = ${manifestKey}
8487
- and completed_at is null
8488
- `;
8489
- this.wakeEarly();
8490
- }
8491
- async enqueueCronTick(expression, timezone, tickNumber, streamPath, fireAt) {
8492
- const tenantId = this.resolveTenantId();
8493
- await this.pgClient`
8494
- insert into scheduled_tasks (
8495
- tenant_id,
8496
- kind,
8497
- payload,
8498
- fire_at,
8499
- cron_expression,
8500
- cron_timezone,
8501
- cron_tick_number
8502
- )
8503
- values (
8504
- ${tenantId},
8505
- 'cron_tick',
8506
- ${JSON.stringify({ streamPath })}::jsonb,
8507
- ${fireAt.toISOString()}::timestamptz,
8508
- ${expression},
8509
- ${timezone},
8510
- ${tickNumber}
8511
- )
8512
- on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
8513
- `;
8514
- this.wakeEarly();
8515
- }
8516
- async runLoop() {
8517
- while (this.running) try {
8518
- await this.reclaimStaleClaims();
8519
- await this.fireReadyTasks();
8520
- const nextFireAt = await this.getNextFireAt();
8521
- const sleepTargetMs = nextFireAt ? Math.max(0, nextFireAt.getTime() - Date.now()) : this.safetyPollMs;
8522
- await this.sleepOrWake(Math.min(sleepTargetMs, this.safetyPollMs));
8523
- } catch (err) {
8524
- console.error(`[agent-server] scheduler iteration failed:`, err);
8525
- await this.sleepOrWake(this.safetyPollMs);
8526
- }
8527
- }
8528
- async reclaimStaleClaims() {
8529
- if (this.tenantId === null) {
8530
- const tenantIds = this.sharedTenantIds();
8531
- if (tenantIds && tenantIds.length === 0) return;
8532
- if (tenantIds) {
8533
- await this.pgClient`
8534
- update scheduled_tasks
8535
- set claimed_by = null, claimed_at = null
8536
- where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
8537
- and completed_at is null
8538
- and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
8539
- `;
8540
- return;
8541
- }
8542
- await this.pgClient`
8543
- update scheduled_tasks
8544
- set claimed_by = null, claimed_at = null
8545
- where completed_at is null
8546
- and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
8547
- `;
8548
- return;
8549
- }
8550
- await this.pgClient`
8551
- update scheduled_tasks
8552
- set claimed_by = null, claimed_at = null
8553
- where tenant_id = ${this.tenantId}
8554
- and completed_at is null
8555
- and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
8556
- `;
8557
- }
8558
- async fireReadyTasks() {
8559
- while (this.running) {
8560
- const tasks = await this.claimReadyTasks();
8561
- if (tasks.length === 0) return;
8562
- for (const task of tasks) await this.executeTask(task);
8563
- }
8564
- }
8565
- async claimReadyTasks() {
8566
- if (this.tenantId === null) {
8567
- const tenantIds = this.sharedTenantIds();
8568
- if (tenantIds && tenantIds.length === 0) return [];
8569
- if (tenantIds) {
8570
- const rows$2 = await this.pgClient`
8571
- update scheduled_tasks
8572
- set claimed_by = ${this.instanceId}, claimed_at = now()
8573
- where id in (
8574
- select id
8575
- from scheduled_tasks
8576
- where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
8577
- and completed_at is null
8578
- and claimed_at is null
8579
- and fire_at <= now()
8580
- order by fire_at, id
8581
- for update skip locked
8582
- limit 50
8583
- )
8584
- returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
8585
- , owner_entity_url, manifest_key
8586
- `;
8587
- return rows$2.map(normalizeTask);
8588
- }
8589
- const rows$1 = await this.pgClient`
8590
- update scheduled_tasks
8591
- set claimed_by = ${this.instanceId}, claimed_at = now()
8592
- where id in (
8593
- select id
8594
- from scheduled_tasks
8595
- where completed_at is null
8596
- and claimed_at is null
8597
- and fire_at <= now()
8598
- order by fire_at, id
8599
- for update skip locked
8600
- limit 50
8601
- )
8602
- returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
8603
- , owner_entity_url, manifest_key
8604
- `;
8605
- return rows$1.map(normalizeTask);
8606
- }
8607
- const rows = await this.pgClient`
8608
- update scheduled_tasks
8609
- set claimed_by = ${this.instanceId}, claimed_at = now()
8610
- where tenant_id = ${this.tenantId}
8611
- and id in (
8612
- select id
8613
- from scheduled_tasks
8614
- where tenant_id = ${this.tenantId}
8615
- and completed_at is null
8616
- and claimed_at is null
8617
- and fire_at <= now()
8618
- order by fire_at, id
8619
- for update skip locked
8620
- limit 50
8621
- )
8622
- returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
8623
- , owner_entity_url, manifest_key
8624
- `;
8625
- return rows.map(normalizeTask);
8840
+ safetyPollMs;
8841
+ listenEnabled;
8842
+ pgClient;
8843
+ instanceId;
8844
+ tenantId;
8845
+ tenantIds;
8846
+ running = false;
8847
+ loopPromise = null;
8848
+ currentSleepResolve = null;
8849
+ currentSleepTimer = null;
8850
+ listenerMeta = null;
8851
+ constructor(options) {
8852
+ this.options = options;
8853
+ this.pgClient = options.pgClient;
8854
+ this.instanceId = options.instanceId;
8855
+ this.tenantId = options.tenantId === void 0 ? DEFAULT_TENANT_ID : options.tenantId;
8856
+ this.tenantIds = options.tenantIds;
8857
+ this.claimExpiryMs = options.claimExpiryMs ?? 3e4;
8858
+ this.safetyPollMs = options.safetyPollMs ?? 1e4;
8859
+ this.listenEnabled = options.listen !== false;
8626
8860
  }
8627
- async executeTask(task) {
8628
- try {
8629
- if (task.kind === `delayed_send`) {
8630
- await this.options.executors.delayed_send(task.payload, task.id, task.tenantId);
8631
- await this.markTaskComplete(task.id, task.tenantId);
8632
- return;
8633
- }
8634
- const tickNumber = task.cronTickNumber;
8635
- if (tickNumber == null || !task.cronExpression || !task.cronTimezone) throw new Error(`cron task ${task.id} is missing cron metadata`);
8636
- await this.options.executors.cron_tick(task.payload, tickNumber, task.id, task.tenantId);
8637
- await this.completeAndRescheduleCron(task);
8638
- } catch (err) {
8639
- const message = err instanceof Error ? err.message : String(err);
8640
- if (isUnregisteredTenantError(err)) {
8641
- await this.releaseClaim(task.id, message, task.tenantId);
8642
- serverLog.warn(`[scheduler] skipped ${task.kind} task ${task.id} for unregistered tenant "${task.tenantId}": ${message}`);
8643
- return;
8644
- }
8645
- if (isPermanentElectricAgentsError(err)) {
8646
- await this.markTaskPermanentFailure(task.id, message, task.tenantId);
8647
- return;
8648
- }
8649
- await this.releaseClaim(task.id, message, task.tenantId);
8650
- }
8861
+ resolveTenantId(tenantId) {
8862
+ if (tenantId) return tenantId;
8863
+ if (this.tenantId) return this.tenantId;
8864
+ throw new Error(`Scheduler tenantId is required in shared mode`);
8651
8865
  }
8652
- async markTaskComplete(taskId, tenantId = this.resolveTenantId()) {
8653
- await this.pgClient`
8654
- update scheduled_tasks
8655
- set completed_at = now(), last_error = null
8656
- where tenant_id = ${tenantId}
8657
- and id = ${taskId}
8658
- and claimed_by = ${this.instanceId}
8659
- and completed_at is null
8660
- `;
8866
+ async start() {
8867
+ if (this.running) return;
8868
+ this.running = true;
8869
+ if (this.listenEnabled) this.listenerMeta = await this.pgClient.listen(`scheduled_tasks_wake`, () => {
8870
+ this.wakeEarly();
8871
+ });
8872
+ this.loopPromise = this.runLoop().catch((err) => {
8873
+ console.error(`[agent-server] scheduler loop failed:`, err);
8874
+ this.running = false;
8875
+ this.wakeEarly();
8876
+ });
8661
8877
  }
8662
- async markTaskPermanentFailure(taskId, message, tenantId = this.resolveTenantId()) {
8663
- await this.pgClient`
8664
- update scheduled_tasks
8665
- set completed_at = now(), last_error = ${message}
8666
- where tenant_id = ${tenantId}
8667
- and id = ${taskId}
8668
- and claimed_by = ${this.instanceId}
8669
- and completed_at is null
8670
- `;
8878
+ async stop() {
8879
+ this.running = false;
8880
+ this.wakeEarly();
8881
+ if (this.loopPromise) {
8882
+ await this.loopPromise;
8883
+ this.loopPromise = null;
8884
+ }
8885
+ if (this.listenerMeta) {
8886
+ await this.listenerMeta.unlisten();
8887
+ this.listenerMeta = null;
8888
+ }
8671
8889
  }
8672
- async releaseClaim(taskId, message, tenantId = this.resolveTenantId()) {
8890
+ wake() {
8891
+ this.wakeEarly();
8892
+ }
8893
+ async enqueueDelayedSend(payload, fireAt, opts) {
8894
+ const tenantId = this.resolveTenantId();
8673
8895
  await this.pgClient`
8674
- update scheduled_tasks
8675
- set claimed_at = null, claimed_by = null, last_error = ${message}
8676
- where tenant_id = ${tenantId}
8677
- and id = ${taskId}
8678
- and claimed_by = ${this.instanceId}
8679
- and completed_at is null
8896
+ insert into scheduled_tasks (
8897
+ tenant_id,
8898
+ kind,
8899
+ payload,
8900
+ fire_at,
8901
+ owner_entity_url,
8902
+ manifest_key
8903
+ )
8904
+ values (
8905
+ ${tenantId},
8906
+ 'delayed_send',
8907
+ ${JSON.stringify(payload)}::jsonb,
8908
+ ${fireAt.toISOString()}::timestamptz,
8909
+ ${opts?.ownerEntityUrl ?? null},
8910
+ ${opts?.manifestKey ?? null}
8911
+ )
8680
8912
  `;
8913
+ this.wakeEarly();
8681
8914
  }
8682
- async completeAndRescheduleCron(task) {
8683
- const tenantId = task.tenantId ?? this.resolveTenantId();
8915
+ async syncManifestDelayedSend(ownerEntityUrl, manifestKey, payload, fireAt) {
8916
+ const tenantId = this.resolveTenantId();
8684
8917
  await this.pgClient.begin(async (sql$1) => {
8685
- const completed = await sql$1`
8918
+ await sql$1`
8686
8919
  update scheduled_tasks
8687
- set completed_at = now(), last_error = null
8920
+ set completed_at = now(), claimed_at = null, claimed_by = null
8688
8921
  where tenant_id = ${tenantId}
8689
- and id = ${task.id}
8690
- and claimed_by = ${this.instanceId}
8922
+ and kind = 'delayed_send'
8923
+ and owner_entity_url = ${ownerEntityUrl}
8924
+ and manifest_key = ${manifestKey}
8691
8925
  and completed_at is null
8692
- returning id
8693
8926
  `;
8694
- if (completed.length === 0) return;
8695
- const nextFireAt = getNextCronFireAt(task.cronExpression, task.cronTimezone, task.fireAt);
8696
- const streamPath = cronTaskStreamPath(task.payload);
8697
- const subscriberRows = streamPath ? await sql$1`
8698
- select 1 as exists
8699
- from wake_registrations
8700
- where tenant_id = ${tenantId}
8701
- and source_url = ${streamPath}
8702
- limit 1
8703
- ` : [];
8704
- if (subscriberRows.length === 0) return;
8705
8927
  await sql$1`
8706
8928
  insert into scheduled_tasks (
8707
8929
  tenant_id,
8708
8930
  kind,
8709
8931
  payload,
8710
8932
  fire_at,
8711
- cron_expression,
8712
- cron_timezone,
8713
- cron_tick_number
8933
+ owner_entity_url,
8934
+ manifest_key
8714
8935
  )
8715
8936
  values (
8716
8937
  ${tenantId},
8717
- 'cron_tick',
8718
- ${JSON.stringify(task.payload)}::jsonb,
8719
- ${nextFireAt.toISOString()}::timestamptz,
8720
- ${task.cronExpression},
8721
- ${task.cronTimezone},
8722
- ${task.cronTickNumber + 1}
8938
+ 'delayed_send',
8939
+ ${JSON.stringify(payload)}::jsonb,
8940
+ ${fireAt.toISOString()}::timestamptz,
8941
+ ${ownerEntityUrl},
8942
+ ${manifestKey}
8723
8943
  )
8724
- on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
8725
8944
  `;
8726
8945
  });
8946
+ this.wakeEarly();
8727
8947
  }
8728
- async getNextFireAt() {
8729
- if (this.tenantId === null) {
8730
- const tenantIds = this.sharedTenantIds();
8731
- if (tenantIds && tenantIds.length === 0) return null;
8732
- if (tenantIds) {
8733
- const rows$2 = await this.pgClient`
8734
- select fire_at
8735
- from scheduled_tasks
8736
- where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
8737
- and completed_at is null
8738
- and claimed_at is null
8739
- order by fire_at, id
8740
- limit 1
8741
- `;
8742
- if (rows$2.length === 0) return null;
8743
- const fireAt$2 = rows$2[0].fire_at;
8744
- return fireAt$2 instanceof Date ? fireAt$2 : new Date(fireAt$2);
8745
- }
8746
- const rows$1 = await this.pgClient`
8747
- select fire_at
8748
- from scheduled_tasks
8749
- where completed_at is null
8750
- and claimed_at is null
8751
- order by fire_at, id
8752
- limit 1
8753
- `;
8754
- if (rows$1.length === 0) return null;
8755
- const fireAt$1 = rows$1[0].fire_at;
8756
- return fireAt$1 instanceof Date ? fireAt$1 : new Date(fireAt$1);
8757
- }
8758
- const rows = await this.pgClient`
8759
- select fire_at
8760
- from scheduled_tasks
8761
- where tenant_id = ${this.tenantId}
8948
+ async cancelManifestDelayedSend(ownerEntityUrl, manifestKey) {
8949
+ const tenantId = this.resolveTenantId();
8950
+ await this.pgClient`
8951
+ update scheduled_tasks
8952
+ set completed_at = now(), claimed_at = null, claimed_by = null
8953
+ where tenant_id = ${tenantId}
8954
+ and kind = 'delayed_send'
8955
+ and owner_entity_url = ${ownerEntityUrl}
8956
+ and manifest_key = ${manifestKey}
8762
8957
  and completed_at is null
8763
- and claimed_at is null
8764
- order by fire_at, id
8765
- limit 1
8766
8958
  `;
8767
- if (rows.length === 0) return null;
8768
- const fireAt = rows[0].fire_at;
8769
- return fireAt instanceof Date ? fireAt : new Date(fireAt);
8770
- }
8771
- async sleepOrWake(durationMs) {
8772
- if (!this.running) return;
8773
- await new Promise((resolve$1) => {
8774
- const finish = () => {
8775
- if (this.currentSleepTimer) {
8776
- clearTimeout(this.currentSleepTimer);
8777
- this.currentSleepTimer = null;
8778
- }
8779
- this.currentSleepResolve = null;
8780
- resolve$1();
8781
- };
8782
- this.currentSleepResolve = finish;
8783
- this.currentSleepTimer = setTimeout(finish, Math.max(durationMs, 0));
8784
- });
8785
- }
8786
- wakeEarly() {
8787
- const resolve$1 = this.currentSleepResolve;
8788
- this.currentSleepResolve = null;
8789
- if (this.currentSleepTimer) {
8790
- clearTimeout(this.currentSleepTimer);
8791
- this.currentSleepTimer = null;
8792
- }
8793
- resolve$1?.();
8794
- }
8795
- sharedTenantIds() {
8796
- if (this.tenantId !== null || !this.tenantIds) return null;
8797
- return [...new Set(this.tenantIds())];
8959
+ this.wakeEarly();
8798
8960
  }
8799
- sharedTenantIdsParameter(tenantIds) {
8800
- return this.pgClient.array(tenantIds, POSTGRES_TEXT_OID);
8961
+ async enqueueCronTick(expression, timezone, tickNumber, streamPath, fireAt) {
8962
+ const tenantId = this.resolveTenantId();
8963
+ await this.pgClient`
8964
+ insert into scheduled_tasks (
8965
+ tenant_id,
8966
+ kind,
8967
+ payload,
8968
+ fire_at,
8969
+ cron_expression,
8970
+ cron_timezone,
8971
+ cron_tick_number
8972
+ )
8973
+ values (
8974
+ ${tenantId},
8975
+ 'cron_tick',
8976
+ ${JSON.stringify({ streamPath })}::jsonb,
8977
+ ${fireAt.toISOString()}::timestamptz,
8978
+ ${expression},
8979
+ ${timezone},
8980
+ ${tickNumber}
8981
+ )
8982
+ on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
8983
+ `;
8984
+ this.wakeEarly();
8801
8985
  }
8802
- };
8803
-
8804
- //#endregion
8805
- //#region src/pg-sync-bridge-manager.ts
8806
- const PG_SYNC_ELECTRIC_SHAPE_URL = process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ?? `http://localhost:3000/v1/shape`;
8807
- const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
8808
- const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
8809
- function buildElectricShapeParams(options) {
8810
- return {
8811
- table: options.table,
8812
- ...options.columns !== void 0 ? { columns: [...options.columns] } : {},
8813
- ...options.where !== void 0 ? { where: options.where } : {},
8814
- ...options.params !== void 0 ? { params: Array.isArray(options.params) ? [...options.params] : { ...options.params } } : {},
8815
- ...options.replica !== void 0 ? { replica: options.replica } : {},
8816
- ...options.metadata?.tenantId ? { electric_agents_tenant_id: options.metadata.tenantId } : {},
8817
- ...options.metadata?.principalKind ? { electric_agents_principal_kind: options.metadata.principalKind } : {},
8818
- ...options.metadata?.principalId ? { electric_agents_principal_id: options.metadata.principalId } : {},
8819
- ...options.metadata?.principalKey ? { electric_agents_principal_key: options.metadata.principalKey } : {},
8820
- ...options.metadata?.principalUrl ? { electric_agents_principal_url: options.metadata.principalUrl } : {},
8821
- ...options.metadata?.entityUrl ? { electric_agents_entity_url: options.metadata.entityUrl } : {},
8822
- ...options.metadata?.entityType ? { electric_agents_entity_type: options.metadata.entityType } : {},
8823
- ...options.metadata?.streamPath ? { electric_agents_stream_path: options.metadata.streamPath } : {},
8824
- ...options.metadata?.runtimeConsumerId ? { electric_agents_runtime_consumer_id: options.metadata.runtimeConsumerId } : {},
8825
- ...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
8826
- };
8827
- }
8828
- function jsonSafe(value) {
8829
- if (typeof value === `bigint`) return value.toString();
8830
- if (value === null || typeof value !== `object`) return value;
8831
- if (Array.isArray(value)) return value.map(jsonSafe);
8832
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
8833
- }
8834
- function stableJson(value) {
8835
- if (typeof value === `bigint`) return JSON.stringify(value.toString());
8836
- if (value === null || typeof value !== `object`) return JSON.stringify(value);
8837
- if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`;
8838
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(`,`)}}`;
8839
- }
8840
- function parseElectricOffset(offset) {
8841
- if (offset === `-1`) return offset;
8842
- return /^\d+_\d+$/.test(offset) ? offset : null;
8843
- }
8844
- function rowKeyForMessage(message) {
8845
- const headers = message.headers;
8846
- const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
8847
- return candidate === void 0 ? void 0 : stableJson(candidate);
8848
- }
8849
- function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
8850
- const operation = message.headers.operation;
8851
- if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
8852
- const sourceRef = typeof optionsOrSourceRef === `string` ? optionsOrSourceRef : sourceRefForPgSync(optionsOrSourceRef);
8853
- const rowKey = rowKeyForMessage(message);
8854
- const offset = message.headers.offset;
8855
- if (typeof offset !== `string` || offset.length === 0) return null;
8856
- const messageKeyPart = offset;
8857
- const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
8858
- const timestamp$1 = new Date().toISOString();
8859
- const oldValue = message.old_value;
8860
- const safeValue = jsonSafe(message.value);
8861
- const safeOldValue = jsonSafe(oldValue);
8862
- const safeHeaders = jsonSafe(message.headers);
8863
- return {
8864
- type: `pg_sync_change`,
8865
- key: messageKey,
8866
- value: {
8867
- key: messageKey,
8868
- table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
8869
- operation,
8870
- ...rowKey !== void 0 ? { rowKey } : {},
8871
- ...message.value !== void 0 ? { value: safeValue } : {},
8872
- ...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
8873
- headers: safeHeaders,
8874
- ...typeof offset === `string` ? { offset } : {},
8875
- receivedAt: timestamp$1
8876
- },
8877
- headers: {
8878
- operation,
8879
- timestamp: timestamp$1
8986
+ async runLoop() {
8987
+ while (this.running) try {
8988
+ await this.reclaimStaleClaims();
8989
+ await this.fireReadyTasks();
8990
+ const nextFireAt = await this.getNextFireAt();
8991
+ const sleepTargetMs = nextFireAt ? Math.max(0, nextFireAt.getTime() - Date.now()) : this.safetyPollMs;
8992
+ await this.sleepOrWake(Math.min(sleepTargetMs, this.safetyPollMs));
8993
+ } catch (err) {
8994
+ console.error(`[agent-server] scheduler iteration failed:`, err);
8995
+ await this.sleepOrWake(this.safetyPollMs);
8880
8996
  }
8881
- };
8882
- }
8883
- function cursorFromRow(row) {
8884
- return row?.shapeHandle && row.shapeOffset ? {
8885
- handle: row.shapeHandle,
8886
- offset: row.shapeOffset,
8887
- initialSnapshotComplete: row.initialSnapshotComplete
8888
- } : void 0;
8889
- }
8890
- var PgSyncBridge = class {
8891
- producer = null;
8892
- unsubscribe = null;
8893
- abortController = null;
8894
- skipChangesUntilUpToDate = false;
8895
- recovering = false;
8896
- committedCursor;
8897
- retryAttempt = 0;
8898
- constructor(sourceRef, streamUrl, options, resolvedSource, retry, streamClient, registry, evaluateWakes, initialCursor) {
8899
- this.sourceRef = sourceRef;
8900
- this.streamUrl = streamUrl;
8901
- this.options = options;
8902
- this.resolvedSource = resolvedSource;
8903
- this.retry = retry;
8904
- this.streamClient = streamClient;
8905
- this.registry = registry;
8906
- this.evaluateWakes = evaluateWakes;
8907
- this.initialCursor = initialCursor;
8908
- this.committedCursor = initialCursor;
8909
8997
  }
8910
- async start() {
8911
- if (!this.producer) this.producer = new IdempotentProducer(new DurableStream({
8912
- url: `${this.streamClient.baseUrl}${this.streamUrl}`,
8913
- contentType: `application/json`
8914
- }), `pg-sync-bridge-${this.sourceRef}`);
8915
- if (this.initialCursor) {
8916
- const offset = parseElectricOffset(this.initialCursor.offset);
8917
- if (offset) {
8918
- this.startStream(offset, this.initialCursor.handle, !this.initialCursor.initialSnapshotComplete);
8998
+ async reclaimStaleClaims() {
8999
+ if (this.tenantId === null) {
9000
+ const tenantIds = this.sharedTenantIds();
9001
+ if (tenantIds && tenantIds.length === 0) return;
9002
+ if (tenantIds) {
9003
+ await this.pgClient`
9004
+ update scheduled_tasks
9005
+ set claimed_by = null, claimed_at = null
9006
+ where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
9007
+ and completed_at is null
9008
+ and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
9009
+ `;
8919
9010
  return;
8920
9011
  }
9012
+ await this.pgClient`
9013
+ update scheduled_tasks
9014
+ set claimed_by = null, claimed_at = null
9015
+ where completed_at is null
9016
+ and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
9017
+ `;
9018
+ return;
8921
9019
  }
8922
- await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
8923
- this.startStream(`now`, void 0, true);
9020
+ await this.pgClient`
9021
+ update scheduled_tasks
9022
+ set claimed_by = null, claimed_at = null
9023
+ where tenant_id = ${this.tenantId}
9024
+ and completed_at is null
9025
+ and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
9026
+ `;
8924
9027
  }
8925
- async stop() {
8926
- this.unsubscribe?.();
8927
- this.abortController?.abort();
8928
- this.unsubscribe = null;
8929
- this.abortController = null;
8930
- try {
8931
- await this.producer?.flush();
8932
- } finally {
8933
- await this.producer?.detach();
8934
- this.producer = null;
9028
+ async fireReadyTasks() {
9029
+ while (this.running) {
9030
+ const tasks = await this.claimReadyTasks();
9031
+ if (tasks.length === 0) return;
9032
+ for (const task of tasks) await this.executeTask(task);
8935
9033
  }
8936
9034
  }
8937
- startStream(offset, handle, skipChangesUntilUpToDate = false, log = offset === `now` ? `changes_only` : `full`) {
8938
- this.unsubscribe?.();
8939
- this.abortController?.abort();
8940
- this.skipChangesUntilUpToDate = skipChangesUntilUpToDate;
8941
- this.abortController = new AbortController();
8942
- const stream = new ShapeStream({
8943
- url: this.resolvedSource.url,
8944
- params: buildElectricShapeParams(this.options),
8945
- offset,
8946
- log,
8947
- ...handle ? { handle } : {},
8948
- signal: this.abortController.signal
8949
- });
8950
- this.unsubscribe = stream.subscribe(async (messages) => {
8951
- try {
8952
- for (const message of messages) {
8953
- if (isControlMessage(message)) {
8954
- if (message.headers.control === `must-refetch`) {
8955
- await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
8956
- this.startStream(`now`, void 0, true);
8957
- return;
8958
- }
8959
- if (message.headers.control === `up-to-date`) {
8960
- this.skipChangesUntilUpToDate = false;
8961
- await this.persistCursor(stream, true);
8962
- continue;
8963
- }
8964
- await this.persistCursor(stream);
8965
- continue;
8966
- }
8967
- if (!isChangeMessage(message)) continue;
8968
- if (!this.skipChangesUntilUpToDate) {
8969
- const event = pgSyncMessageToDurableEvent(message, this.options);
8970
- if (event) {
8971
- if (!this.producer) throw new Error(`pg-sync producer is not started`);
8972
- await this.producer.append(JSON.stringify(event));
8973
- await this.producer.flush?.();
8974
- await this.evaluateWakes?.(this.streamUrl, event);
8975
- }
8976
- }
8977
- await this.persistCursor(stream);
8978
- this.retryAttempt = 0;
8979
- }
8980
- } catch (error) {
8981
- serverLog.warn(`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`, error);
8982
- await this.recoverStream();
9035
+ async claimReadyTasks() {
9036
+ if (this.tenantId === null) {
9037
+ const tenantIds = this.sharedTenantIds();
9038
+ if (tenantIds && tenantIds.length === 0) return [];
9039
+ if (tenantIds) {
9040
+ const rows$2 = await this.pgClient`
9041
+ update scheduled_tasks
9042
+ set claimed_by = ${this.instanceId}, claimed_at = now()
9043
+ where id in (
9044
+ select id
9045
+ from scheduled_tasks
9046
+ where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
9047
+ and completed_at is null
9048
+ and claimed_at is null
9049
+ and fire_at <= now()
9050
+ order by fire_at, id
9051
+ for update skip locked
9052
+ limit 50
9053
+ )
9054
+ returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
9055
+ , owner_entity_url, manifest_key
9056
+ `;
9057
+ return rows$2.map(normalizeTask);
8983
9058
  }
8984
- }, (error) => {
8985
- if (this.abortController?.signal.aborted) return;
8986
- serverLog.warn(`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`, error);
8987
- this.recoverStream();
8988
- });
9059
+ const rows$1 = await this.pgClient`
9060
+ update scheduled_tasks
9061
+ set claimed_by = ${this.instanceId}, claimed_at = now()
9062
+ where id in (
9063
+ select id
9064
+ from scheduled_tasks
9065
+ where completed_at is null
9066
+ and claimed_at is null
9067
+ and fire_at <= now()
9068
+ order by fire_at, id
9069
+ for update skip locked
9070
+ limit 50
9071
+ )
9072
+ returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
9073
+ , owner_entity_url, manifest_key
9074
+ `;
9075
+ return rows$1.map(normalizeTask);
9076
+ }
9077
+ const rows = await this.pgClient`
9078
+ update scheduled_tasks
9079
+ set claimed_by = ${this.instanceId}, claimed_at = now()
9080
+ where tenant_id = ${this.tenantId}
9081
+ and id in (
9082
+ select id
9083
+ from scheduled_tasks
9084
+ where tenant_id = ${this.tenantId}
9085
+ and completed_at is null
9086
+ and claimed_at is null
9087
+ and fire_at <= now()
9088
+ order by fire_at, id
9089
+ for update skip locked
9090
+ limit 50
9091
+ )
9092
+ returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
9093
+ , owner_entity_url, manifest_key
9094
+ `;
9095
+ return rows.map(normalizeTask);
8989
9096
  }
8990
- async recoverStream() {
8991
- if (this.recovering) return;
8992
- this.recovering = true;
9097
+ async executeTask(task) {
8993
9098
  try {
8994
- const attempt = this.retryAttempt++;
8995
- const baseDelay = Math.min(this.retry.initialDelayMs * 2 ** attempt, this.retry.maxDelayMs);
8996
- const jitter = Math.floor(baseDelay * .2 * this.retry.random());
8997
- const delay = baseDelay + jitter;
8998
- if (delay > 0) await this.retry.sleep(delay);
8999
- const offset = this.committedCursor ? parseElectricOffset(this.committedCursor.offset) : null;
9000
- if (offset && this.committedCursor) this.startStream(offset, this.committedCursor.handle, !this.committedCursor.initialSnapshotComplete);
9001
- else {
9002
- await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
9003
- this.startStream(`now`, void 0, true);
9099
+ if (task.kind === `delayed_send`) {
9100
+ await this.options.executors.delayed_send(task.payload, task.id, task.tenantId);
9101
+ await this.markTaskComplete(task.id, task.tenantId);
9102
+ return;
9004
9103
  }
9005
- } finally {
9006
- this.recovering = false;
9104
+ const tickNumber = task.cronTickNumber;
9105
+ if (tickNumber == null || !task.cronExpression || !task.cronTimezone) throw new Error(`cron task ${task.id} is missing cron metadata`);
9106
+ await this.options.executors.cron_tick(task.payload, tickNumber, task.id, task.tenantId);
9107
+ await this.completeAndRescheduleCron(task);
9108
+ } catch (err) {
9109
+ const message = err instanceof Error ? err.message : String(err);
9110
+ if (isUnregisteredTenantError(err)) {
9111
+ await this.releaseClaim(task.id, message, task.tenantId);
9112
+ serverLog.warn(`[scheduler] skipped ${task.kind} task ${task.id} for unregistered tenant "${task.tenantId}": ${message}`);
9113
+ return;
9114
+ }
9115
+ if (isPermanentElectricAgentsError(err)) {
9116
+ await this.markTaskPermanentFailure(task.id, message, task.tenantId);
9117
+ return;
9118
+ }
9119
+ await this.releaseClaim(task.id, message, task.tenantId);
9007
9120
  }
9008
9121
  }
9009
- async persistCursor(stream, initialSnapshotComplete = !this.skipChangesUntilUpToDate) {
9010
- const shapeHandle = stream.shapeHandle;
9011
- const shapeOffset = stream.lastOffset;
9012
- if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return;
9013
- await this.registry?.updatePgSyncBridgeCursor(this.sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete);
9014
- this.committedCursor = {
9015
- handle: shapeHandle,
9016
- offset: shapeOffset,
9017
- initialSnapshotComplete
9018
- };
9122
+ async markTaskComplete(taskId, tenantId = this.resolveTenantId()) {
9123
+ await this.pgClient`
9124
+ update scheduled_tasks
9125
+ set completed_at = now(), last_error = null
9126
+ where tenant_id = ${tenantId}
9127
+ and id = ${taskId}
9128
+ and claimed_by = ${this.instanceId}
9129
+ and completed_at is null
9130
+ `;
9019
9131
  }
9020
- };
9021
- var PgSyncBridgeManager = class {
9022
- bridges = new Map();
9023
- starting = new Map();
9024
- url;
9025
- retry;
9026
- constructor(streamClient, evaluateWakes, registry, options = {}) {
9027
- this.streamClient = streamClient;
9028
- this.evaluateWakes = evaluateWakes;
9029
- this.registry = registry;
9030
- this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
9031
- this.retry = {
9032
- initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
9033
- maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
9034
- random: options.retry?.random ?? Math.random,
9035
- sleep: options.retry?.sleep ?? ((ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)))
9036
- };
9132
+ async markTaskPermanentFailure(taskId, message, tenantId = this.resolveTenantId()) {
9133
+ await this.pgClient`
9134
+ update scheduled_tasks
9135
+ set completed_at = now(), last_error = ${message}
9136
+ where tenant_id = ${tenantId}
9137
+ and id = ${taskId}
9138
+ and claimed_by = ${this.instanceId}
9139
+ and completed_at is null
9140
+ `;
9037
9141
  }
9038
- async start() {
9039
- const rows = await this.registry?.listPgSyncBridges?.();
9040
- if (!rows) return;
9041
- await Promise.all(rows.map((row) => this.ensureBridge(row).catch((error) => {
9042
- serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
9043
- })));
9142
+ async releaseClaim(taskId, message, tenantId = this.resolveTenantId()) {
9143
+ await this.pgClient`
9144
+ update scheduled_tasks
9145
+ set claimed_at = null, claimed_by = null, last_error = ${message}
9146
+ where tenant_id = ${tenantId}
9147
+ and id = ${taskId}
9148
+ and claimed_by = ${this.instanceId}
9149
+ and completed_at is null
9150
+ `;
9044
9151
  }
9045
- async register(options, metadata) {
9046
- const mergedMetadata = {
9047
- ...options.metadata,
9048
- ...metadata
9049
- };
9050
- const canonicalOptions = {
9051
- ...canonicalPgSyncOptions(options),
9052
- ...Object.keys(mergedMetadata).length > 0 ? { metadata: mergedMetadata } : {}
9053
- };
9054
- const resolvedSource = this.resolveSource(canonicalOptions);
9055
- const sourceRef = sourceRefForPgSync(canonicalOptions);
9056
- const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId);
9057
- const row = await this.registry?.upsertPgSyncBridge({
9058
- sourceRef,
9059
- options: canonicalOptions,
9060
- streamUrl
9152
+ async completeAndRescheduleCron(task) {
9153
+ const tenantId = task.tenantId ?? this.resolveTenantId();
9154
+ await this.pgClient.begin(async (sql$1) => {
9155
+ const completed = await sql$1`
9156
+ update scheduled_tasks
9157
+ set completed_at = now(), last_error = null
9158
+ where tenant_id = ${tenantId}
9159
+ and id = ${task.id}
9160
+ and claimed_by = ${this.instanceId}
9161
+ and completed_at is null
9162
+ returning id
9163
+ `;
9164
+ if (completed.length === 0) return;
9165
+ const nextFireAt = getNextCronFireAt(task.cronExpression, task.cronTimezone, task.fireAt);
9166
+ const streamPath = cronTaskStreamPath(task.payload);
9167
+ const subscriberRows = streamPath ? await sql$1`
9168
+ select 1 as exists
9169
+ from wake_registrations
9170
+ where tenant_id = ${tenantId}
9171
+ and source_url = ${streamPath}
9172
+ limit 1
9173
+ ` : [];
9174
+ if (subscriberRows.length === 0) return;
9175
+ await sql$1`
9176
+ insert into scheduled_tasks (
9177
+ tenant_id,
9178
+ kind,
9179
+ payload,
9180
+ fire_at,
9181
+ cron_expression,
9182
+ cron_timezone,
9183
+ cron_tick_number
9184
+ )
9185
+ values (
9186
+ ${tenantId},
9187
+ 'cron_tick',
9188
+ ${JSON.stringify(task.payload)}::jsonb,
9189
+ ${nextFireAt.toISOString()}::timestamptz,
9190
+ ${task.cronExpression},
9191
+ ${task.cronTimezone},
9192
+ ${task.cronTickNumber + 1}
9193
+ )
9194
+ on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
9195
+ `;
9061
9196
  });
9062
- await this.streamClient.ensure(streamUrl, { contentType: `application/json` });
9063
- if (!this.bridges.has(sourceRef)) {
9064
- let start = this.starting.get(sourceRef);
9065
- if (!start) {
9066
- start = (async () => {
9067
- const bridge = new PgSyncBridge(sourceRef, streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
9068
- await bridge.start();
9069
- this.bridges.set(sourceRef, bridge);
9070
- })().finally(() => this.starting.delete(sourceRef));
9071
- this.starting.set(sourceRef, start);
9197
+ }
9198
+ async getNextFireAt() {
9199
+ if (this.tenantId === null) {
9200
+ const tenantIds = this.sharedTenantIds();
9201
+ if (tenantIds && tenantIds.length === 0) return null;
9202
+ if (tenantIds) {
9203
+ const rows$2 = await this.pgClient`
9204
+ select fire_at
9205
+ from scheduled_tasks
9206
+ where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
9207
+ and completed_at is null
9208
+ and claimed_at is null
9209
+ order by fire_at, id
9210
+ limit 1
9211
+ `;
9212
+ if (rows$2.length === 0) return null;
9213
+ const fireAt$2 = rows$2[0].fire_at;
9214
+ return fireAt$2 instanceof Date ? fireAt$2 : new Date(fireAt$2);
9072
9215
  }
9073
- await start;
9216
+ const rows$1 = await this.pgClient`
9217
+ select fire_at
9218
+ from scheduled_tasks
9219
+ where completed_at is null
9220
+ and claimed_at is null
9221
+ order by fire_at, id
9222
+ limit 1
9223
+ `;
9224
+ if (rows$1.length === 0) return null;
9225
+ const fireAt$1 = rows$1[0].fire_at;
9226
+ return fireAt$1 instanceof Date ? fireAt$1 : new Date(fireAt$1);
9074
9227
  }
9075
- return {
9076
- sourceRef,
9077
- streamUrl
9078
- };
9228
+ const rows = await this.pgClient`
9229
+ select fire_at
9230
+ from scheduled_tasks
9231
+ where tenant_id = ${this.tenantId}
9232
+ and completed_at is null
9233
+ and claimed_at is null
9234
+ order by fire_at, id
9235
+ limit 1
9236
+ `;
9237
+ if (rows.length === 0) return null;
9238
+ const fireAt = rows[0].fire_at;
9239
+ return fireAt instanceof Date ? fireAt : new Date(fireAt);
9079
9240
  }
9080
- async ensureBridge(row) {
9081
- if (this.bridges.has(row.sourceRef)) return;
9082
- let start = this.starting.get(row.sourceRef);
9083
- if (!start) {
9084
- start = (async () => {
9085
- await this.streamClient.ensure(row.streamUrl, { contentType: `application/json` });
9086
- const canonicalOptions = canonicalPgSyncOptions(row.options);
9087
- const resolvedSource = this.resolveSource(canonicalOptions);
9088
- const bridge = new PgSyncBridge(row.sourceRef, row.streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
9089
- await bridge.start();
9090
- this.bridges.set(row.sourceRef, bridge);
9091
- })().finally(() => this.starting.delete(row.sourceRef));
9092
- this.starting.set(row.sourceRef, start);
9241
+ async sleepOrWake(durationMs) {
9242
+ if (!this.running) return;
9243
+ await new Promise((resolve$1) => {
9244
+ const finish = () => {
9245
+ if (this.currentSleepTimer) {
9246
+ clearTimeout(this.currentSleepTimer);
9247
+ this.currentSleepTimer = null;
9248
+ }
9249
+ this.currentSleepResolve = null;
9250
+ resolve$1();
9251
+ };
9252
+ this.currentSleepResolve = finish;
9253
+ this.currentSleepTimer = setTimeout(finish, Math.max(durationMs, 0));
9254
+ });
9255
+ }
9256
+ wakeEarly() {
9257
+ const resolve$1 = this.currentSleepResolve;
9258
+ this.currentSleepResolve = null;
9259
+ if (this.currentSleepTimer) {
9260
+ clearTimeout(this.currentSleepTimer);
9261
+ this.currentSleepTimer = null;
9093
9262
  }
9094
- await start;
9263
+ resolve$1?.();
9095
9264
  }
9096
- resolveSource(options) {
9097
- return { url: options.url ?? this.url };
9265
+ sharedTenantIds() {
9266
+ if (this.tenantId !== null || !this.tenantIds) return null;
9267
+ return [...new Set(this.tenantIds())];
9098
9268
  }
9099
- async stop() {
9100
- await Promise.allSettled(this.starting.values());
9101
- await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()));
9102
- this.bridges.clear();
9269
+ sharedTenantIdsParameter(tenantIds) {
9270
+ return this.pgClient.array(tenantIds, POSTGRES_TEXT_OID);
9103
9271
  }
9104
9272
  };
9105
9273
 
@@ -10056,6 +10224,7 @@ var WakeRegistry = class {
10056
10224
  };
10057
10225
  if (value && `value` in value) change.value = value.value;
10058
10226
  if (value && `oldValue` in value) change.oldValue = value.oldValue;
10227
+ else if (value && `old_value` in value) change.oldValue = value.old_value;
10059
10228
  if (eventType === `inbox`) {
10060
10229
  if (typeof value?.from === `string`) change.from = value.from;
10061
10230
  if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
@@ -10418,8 +10587,8 @@ var ElectricAgentsServer = class {
10418
10587
  runtime: this.standaloneRuntime.runtime,
10419
10588
  entityBridgeManager: this.entityBridgeManager,
10420
10589
  pgSyncBridgeManager: this.standaloneRuntime.runtime.pgSyncBridgeManager,
10421
- ...this.options.eventSources ? { eventSources: this.options.eventSources } : {},
10422
- ...this.options.ensureEventSourceWakeSource ? { ensureEventSourceWakeSource: this.options.ensureEventSourceWakeSource } : {},
10590
+ ...this.options.webhookSources ? { webhookSources: this.options.webhookSources } : {},
10591
+ ...this.options.ensureWebhookSourceWakeSource ? { ensureWebhookSourceWakeSource: this.options.ensureWebhookSourceWakeSource } : {},
10423
10592
  ...this.options.authorizeRequest ? { authorizeRequest: this.options.authorizeRequest } : {},
10424
10593
  isShuttingDown: () => this.shuttingDown,
10425
10594
  mockAgent: this.mockAgentBootstrap ? { runtime: this.mockAgentBootstrap.runtime } : void 0