@electric-ax/agents-server 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ import { DurableStreamTestServer } from "@durable-streams/server";
4
4
  import { createServer } from "node:http";
5
5
  import { createServerAdapter } from "@whatwg-node/server";
6
6
  import { Agent } from "undici";
7
- import { COMMENTS_CONTRACT, COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, canonicalPgSyncOptions, createEntityRegistry, createRuntimeHandler, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
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";
@@ -3303,7 +3303,6 @@ var PostgresRegistry = class {
3303
3303
  set: {
3304
3304
  options: row.options,
3305
3305
  streamUrl: row.streamUrl,
3306
- initialSnapshotComplete: false,
3307
3306
  lastTouchedAt: new Date(),
3308
3307
  updatedAt: new Date()
3309
3308
  }
@@ -3342,6 +3341,9 @@ var PostgresRegistry = class {
3342
3341
  updatedAt: new Date()
3343
3342
  }).where(this.pgSyncBridgeWhere(sourceRef));
3344
3343
  }
3344
+ async deletePgSyncBridge(sourceRef) {
3345
+ await this.db.delete(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef));
3346
+ }
3345
3347
  async upsertEntityBridge(row) {
3346
3348
  await this.db.insert(entityBridges).values({
3347
3349
  tenantId: this.tenantId,
@@ -3656,9 +3658,6 @@ var PostgresRegistry = class {
3656
3658
  function isRecord$1(value) {
3657
3659
  return typeof value === `object` && value !== null && !Array.isArray(value);
3658
3660
  }
3659
- function getPgSyncManifestStreamPath(sourceRef) {
3660
- return `/_electric/pg-sync/${sourceRef}`;
3661
- }
3662
3661
  function extractManifestSourceUrl(manifest) {
3663
3662
  if (!manifest) return void 0;
3664
3663
  if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
@@ -3671,7 +3670,7 @@ function extractManifestSourceUrl(manifest) {
3671
3670
  }
3672
3671
  if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
3673
3672
  if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
3674
- if (manifest.sourceType === `pgSync`) return typeof manifest.sourceRef === `string` ? getPgSyncManifestStreamPath(manifest.sourceRef) : void 0;
3673
+ if (manifest.sourceType === `pgSync`) return typeof config?.streamUrl === `string` ? config.streamUrl : void 0;
3675
3674
  if (manifest.sourceType === `webhook`) {
3676
3675
  if (typeof config?.streamUrl === `string`) return config.streamUrl;
3677
3676
  if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
@@ -5263,7 +5262,7 @@ var EntityManager = class {
5263
5262
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
5264
5263
  return { txid };
5265
5264
  }
5266
- async upsertEventSourceSubscription(entityUrl, req) {
5265
+ async upsertWebhookSourceSubscription(entityUrl, req) {
5267
5266
  const manifestKey = req.subscription.manifestKey;
5268
5267
  const txid = randomUUID();
5269
5268
  await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
@@ -5285,8 +5284,20 @@ var EntityManager = class {
5285
5284
  subscription: req.subscription
5286
5285
  };
5287
5286
  }
5288
- async deleteEventSourceSubscription(entityUrl, req) {
5289
- 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}`;
5290
5301
  const txid = randomUUID();
5291
5302
  await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
5292
5303
  await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
@@ -6083,8 +6094,8 @@ const subscriptionLifetimeSchema = Type.Union([
6083
6094
  }),
6084
6095
  Type.Object({ kind: Type.Literal(`manual`) })
6085
6096
  ]);
6086
- const eventSourceSubscriptionBodySchema = Type.Object({
6087
- sourceKey: Type.String(),
6097
+ const webhookSourceSubscriptionBodySchema = Type.Object({
6098
+ webhookKey: Type.String(),
6088
6099
  bucketKey: Type.Optional(Type.String()),
6089
6100
  params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
6090
6101
  filterKey: Type.Optional(Type.String()),
@@ -6117,8 +6128,9 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
6117
6128
  entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withEntityPermission(`write`), deleteTag);
6118
6129
  entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), withEntityPermission(`schedule`), upsertSchedule);
6119
6130
  entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withEntityPermission(`schedule`), deleteSchedule);
6120
- entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertEventSourceSubscription);
6121
- 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);
6122
6134
  entitiesRouter.get(`/:type/:instanceId/grants`, withExistingEntity, withEntityPermission(`manage`), listEntityPermissionGrants);
6123
6135
  entitiesRouter.post(`/:type/:instanceId/grants`, withExistingEntity, withSchema(entityPermissionGrantInputSchema), withEntityPermission(`manage`), createEntityPermissionGrant);
6124
6136
  entitiesRouter.delete(`/:type/:instanceId/grants/:grantId`, withExistingEntity, withEntityPermission(`manage`), deleteEntityPermissionGrant);
@@ -6369,22 +6381,22 @@ async function deleteSchedule(request, ctx) {
6369
6381
  const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
6370
6382
  return json(result);
6371
6383
  }
6372
- async function upsertEventSourceSubscription(request, ctx) {
6373
- const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
6384
+ async function upsertWebhookSourceSubscription(request, ctx) {
6385
+ const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to webhook sources`);
6374
6386
  if (principalMutationError) return principalMutationError;
6375
- const catalog = ctx.eventSources;
6376
- 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`);
6377
6389
  const { entityUrl } = requireExistingEntityRoute(request);
6378
6390
  const parsed = routeBody(request);
6379
- const source = await catalog.getEventSource(parsed.sourceKey);
6380
- 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`);
6381
6393
  if (parsed.lifetime?.kind === `expires_at`) {
6382
6394
  const expiresAt = new Date(parsed.lifetime.at);
6383
6395
  if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
6384
6396
  }
6385
6397
  let resolved;
6386
6398
  try {
6387
- resolved = resolveEventSourceSubscription({
6399
+ resolved = resolveWebhookSourceSubscription({
6388
6400
  contract: source,
6389
6401
  entityUrl,
6390
6402
  request: {
@@ -6396,18 +6408,25 @@ async function upsertEventSourceSubscription(request, ctx) {
6396
6408
  } catch (error) {
6397
6409
  return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
6398
6410
  }
6399
- await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
6400
- const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
6411
+ await ctx.ensureWebhookSourceWakeSource?.(resolved.subscription.sourceUrl);
6412
+ const result = await ctx.entityManager.upsertWebhookSourceSubscription(entityUrl, {
6401
6413
  subscription: resolved.subscription,
6402
- manifest: buildEventSourceManifestEntry(resolved)
6414
+ manifest: buildWebhookSourceManifestEntry(resolved)
6403
6415
  });
6404
6416
  return json(result);
6405
6417
  }
6406
- async function deleteEventSourceSubscription(request, ctx) {
6407
- 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`);
6408
6427
  if (principalMutationError) return principalMutationError;
6409
6428
  const { entityUrl } = requireExistingEntityRoute(request);
6410
- 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) });
6411
6430
  return json(result);
6412
6431
  }
6413
6432
  function tagResponseBody(entity) {
@@ -6871,171 +6890,522 @@ function toPublicEntityType(entityType) {
6871
6890
  }
6872
6891
 
6873
6892
  //#endregion
6874
- //#region src/routing/pg-sync-router.ts
6875
- const pgSyncOptionsSchema = Type.Object({
6876
- url: Type.Optional(Type.String()),
6877
- table: Type.String(),
6878
- columns: Type.Optional(Type.Array(Type.String())),
6879
- where: Type.Optional(Type.String()),
6880
- params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
6881
- replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)]))
6882
- });
6883
- const pgSyncRequestMetadataSchema = Type.Object({
6884
- entityUrl: Type.Optional(Type.String()),
6885
- entityType: Type.Optional(Type.String()),
6886
- streamPath: Type.Optional(Type.String()),
6887
- runtimeConsumerId: Type.Optional(Type.String()),
6888
- wakeId: Type.Optional(Type.String())
6889
- });
6890
- const pgSyncRegisterBodySchema = Type.Object({
6891
- options: pgSyncOptionsSchema,
6892
- metadata: Type.Optional(pgSyncRequestMetadataSchema)
6893
- });
6894
- const pgSyncRouter = Router({ base: `/_electric/pg-sync` });
6895
- pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
6896
- async function registerPgSync(request, ctx) {
6897
- const { options, metadata } = routeBody(request);
6898
- if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
6899
- if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
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;
6900
6929
  try {
6901
- const requestMetadata$1 = {
6902
- tenantId: ctx.service,
6903
- principalKind: ctx.principal.kind,
6904
- principalId: ctx.principal.id,
6905
- principalKey: ctx.principal.key,
6906
- principalUrl: ctx.principal.url,
6907
- ...metadata ?? {}
6908
- };
6909
- const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
6910
- return json(result);
6911
- } catch (error) {
6912
- return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
6930
+ url = new URL(sourceUrl);
6931
+ } catch {
6932
+ throw new PgSyncSourceValidationError(`pgSync url "${sourceUrl}" is not a valid URL`);
6913
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;
6914
6944
  }
6915
-
6916
- //#endregion
6917
- //#region src/routing/hooks.ts
6918
- const SPAN_KEY = Symbol(`agents-server.otel-span`);
6919
- function headersRecord(headers) {
6920
- const out = {};
6921
- headers.forEach((value, key) => {
6922
- out[key] = value;
6923
- });
6924
- return out;
6925
- }
6926
- function carrier(req) {
6927
- 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)]));
6928
6950
  }
6929
- function startRequestSpan(req, ctx) {
6930
- const existing = carrier(req)[SPAN_KEY];
6931
- if (existing) return existing;
6932
- const url = new URL(req.url);
6933
- const parentCtx = extractTraceContext(headersRecord(req.headers));
6934
- const span = tracer.startSpan(`HTTP ${req.method}`, {
6935
- kind: SpanKind.SERVER,
6936
- attributes: {
6937
- [ATTR.HTTP_METHOD]: req.method,
6938
- [ATTR.HTTP_ROUTE]: url.pathname,
6939
- "electric_agents.tenant_id": ctx.service
6940
- }
6941
- }, parentCtx);
6942
- carrier(req)[SPAN_KEY] = span;
6943
- 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(`,`)}}`;
6944
6956
  }
6945
- function otelStartSpan(req, ctx) {
6946
- startRequestSpan(req, ctx);
6947
- return void 0;
6957
+ function parseElectricOffset$1(offset) {
6958
+ if (offset === `-1`) return offset;
6959
+ return /^\d+_\d+$/.test(offset) ? offset : null;
6948
6960
  }
6949
- function otelEndSpan(response, req) {
6950
- const span = carrier(req)[SPAN_KEY];
6951
- if (!span) return;
6952
- if (response) span.setAttribute(ATTR.HTTP_STATUS, response.status);
6953
- span.end();
6954
- 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);
6955
6965
  }
6956
- function applyCors(response) {
6957
- if (!response) return response;
6958
- const headers = new Headers(response.headers);
6959
- headers.set(`access-control-allow-origin`, `*`);
6960
- headers.set(`access-control-allow-methods`, `GET, POST, PUT, PATCH, DELETE, OPTIONS`);
6961
- headers.set(`access-control-allow-headers`, [
6962
- `content-type`,
6963
- `authorization`,
6964
- `electric-claim-token`,
6965
- `electric-owner-entity`,
6966
- ELECTRIC_PRINCIPAL_HEADER,
6967
- `ngrok-skip-browser-warning`
6968
- ].join(`, `));
6969
- headers.set(`access-control-expose-headers`, `*`);
6970
- return new Response(response.body, {
6971
- status: response.status,
6972
- statusText: response.statusText,
6973
- headers
6974
- });
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
+ };
6975
6981
  }
6976
- function preflightCors(req) {
6977
- if (req.method !== `OPTIONS`) return void 0;
6978
- 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;
6979
6988
  }
6980
- function errorMapper(err, req) {
6981
- const span = carrier(req)[SPAN_KEY];
6982
- if (err instanceof Error) {
6983
- span?.recordException(err);
6984
- span?.setStatus({
6985
- code: SpanStatusCode.ERROR,
6986
- message: err.message
6987
- });
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;
6988
7008
  }
6989
- if (err instanceof ElectricAgentsError) return apiError(err.status, err.code, err.message, err.details);
6990
- if (err instanceof ElectricProxyError) {
6991
- serverLog.warn(`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`);
6992
- return apiError(err.status, err.code, err.message);
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);
6993
7023
  }
6994
- serverLog.error(`[agent-server] Unhandled error:`, err);
6995
- return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`);
6996
- }
6997
- function rejectIfShuttingDown(req, ctx) {
6998
- if (!ctx.isShuttingDown()) return void 0;
6999
- const path$1 = new URL(req.url).pathname;
7000
- if (!path$1.startsWith(`/_electric/subscription-webhooks/`)) return void 0;
7001
- return apiError(503, `SERVER_STOPPING`, `Server is shutting down`);
7002
- }
7003
- function getRequestSpan(req) {
7004
- return carrier(req)[SPAN_KEY];
7005
- }
7006
-
7007
- //#endregion
7008
- //#region src/routing/observations-router.ts
7009
- const stringRecordSchema = Type.Record(Type.String(), Type.String());
7010
- const ensureEntitiesMembershipStreamBodySchema = Type.Object({ tags: Type.Optional(stringRecordSchema) });
7011
- const ensureCronStreamBodySchema = Type.Object({
7012
- expression: Type.String(),
7013
- timezone: Type.Optional(Type.String())
7014
- });
7015
- const observationsRouter = Router({ base: `/_electric/observations` });
7016
- observationsRouter.post(`/entities/ensure-stream`, withSchema(ensureEntitiesMembershipStreamBodySchema), ensureEntitiesMembershipStream);
7017
- observationsRouter.post(`/cron/ensure-stream`, withSchema(ensureCronStreamBodySchema), ensureCronStream);
7018
- async function ensureEntitiesMembershipStream(request, ctx) {
7019
- const parsed = routeBody(request);
7020
- const result = await ctx.entityManager.ensureEntitiesMembershipStream(parsed.tags ?? {}, ctx.principal);
7021
- return json(result);
7022
- }
7023
- async function ensureCronStream(request, ctx) {
7024
- const parsed = routeBody(request);
7025
- const streamPath = await ctx.entityManager.getOrCreateCronStream(parsed.expression, parsed.timezone);
7026
- return json({ streamUrl: streamPath });
7027
- }
7028
-
7029
- //#endregion
7030
- //#region src/routing/tenant-stream-paths.ts
7031
- function withLeadingSlash(path$1) {
7032
- return path$1.startsWith(`/`) ? path$1 : `/${path$1}`;
7033
- }
7034
-
7035
- //#endregion
7036
- //#region src/routing/runners-router.ts
7037
- const sandboxProfileBodySchema = Type.Object({
7038
- name: Type.String(),
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) {
7368
+ if (!ctx.isShuttingDown()) return void 0;
7369
+ const path$1 = new URL(req.url).pathname;
7370
+ if (!path$1.startsWith(`/_electric/subscription-webhooks/`)) return void 0;
7371
+ return apiError(503, `SERVER_STOPPING`, `Server is shutting down`);
7372
+ }
7373
+ function getRequestSpan(req) {
7374
+ return carrier(req)[SPAN_KEY];
7375
+ }
7376
+
7377
+ //#endregion
7378
+ //#region src/routing/observations-router.ts
7379
+ const stringRecordSchema = Type.Record(Type.String(), Type.String());
7380
+ const ensureEntitiesMembershipStreamBodySchema = Type.Object({ tags: Type.Optional(stringRecordSchema) });
7381
+ const ensureCronStreamBodySchema = Type.Object({
7382
+ expression: Type.String(),
7383
+ timezone: Type.Optional(Type.String())
7384
+ });
7385
+ const observationsRouter = Router({ base: `/_electric/observations` });
7386
+ observationsRouter.post(`/entities/ensure-stream`, withSchema(ensureEntitiesMembershipStreamBodySchema), ensureEntitiesMembershipStream);
7387
+ observationsRouter.post(`/cron/ensure-stream`, withSchema(ensureCronStreamBodySchema), ensureCronStream);
7388
+ async function ensureEntitiesMembershipStream(request, ctx) {
7389
+ const parsed = routeBody(request);
7390
+ const result = await ctx.entityManager.ensureEntitiesMembershipStream(parsed.tags ?? {}, ctx.principal);
7391
+ return json(result);
7392
+ }
7393
+ async function ensureCronStream(request, ctx) {
7394
+ const parsed = routeBody(request);
7395
+ const streamPath = await ctx.entityManager.getOrCreateCronStream(parsed.expression, parsed.timezone);
7396
+ return json({ streamUrl: streamPath });
7397
+ }
7398
+
7399
+ //#endregion
7400
+ //#region src/routing/tenant-stream-paths.ts
7401
+ function withLeadingSlash(path$1) {
7402
+ return path$1.startsWith(`/`) ? path$1 : `/${path$1}`;
7403
+ }
7404
+
7405
+ //#endregion
7406
+ //#region src/routing/runners-router.ts
7407
+ const sandboxProfileBodySchema = Type.Object({
7408
+ name: Type.String(),
7039
7409
  label: Type.String(),
7040
7410
  description: Type.Optional(Type.String()),
7041
7411
  remote: Type.Optional(Type.Boolean())
@@ -7433,7 +7803,7 @@ const wakeCallbackBodySchema = Type.Object({
7433
7803
  const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
7434
7804
  const internalRouter = Router({ base: `/_electric` });
7435
7805
  internalRouter.get(`/health`, () => json({ status: `ok` }));
7436
- internalRouter.get(`/event-sources`, listEventSources);
7806
+ internalRouter.get(`/webhook-sources`, listWebhookSources);
7437
7807
  internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
7438
7808
  internalRouter.post(`/subscription-webhooks/:subscriptionId`, subscriptionWebhook);
7439
7809
  internalRouter.post(`/wake-callbacks/:consumerId`, wakeCallback);
@@ -7555,11 +7925,11 @@ async function registerWake(request, ctx) {
7555
7925
  await ctx.entityManager.registerWake(opts);
7556
7926
  return status(204);
7557
7927
  }
7558
- async function listEventSources(_request, ctx) {
7559
- const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
7560
- 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) });
7561
7931
  }
7562
- function isAgentVisibleEventSource(source) {
7932
+ function isAgentVisibleWebhookSource(source) {
7563
7933
  return source.agentVisible === true && source.status === `active`;
7564
7934
  }
7565
7935
  async function subscriptionWebhook(request, ctx) {
@@ -7870,7 +8240,7 @@ const ENTITY_SHAPE_COLUMNS = [
7870
8240
  `created_at`,
7871
8241
  `updated_at`
7872
8242
  ];
7873
- function parseElectricOffset$1(offset) {
8243
+ function parseElectricOffset(offset) {
7874
8244
  if (offset === `-1`) return offset;
7875
8245
  return /^\d+_\d+$/.test(offset) ? offset : null;
7876
8246
  }
@@ -7962,7 +8332,7 @@ var EntityBridge = class {
7962
8332
  });
7963
8333
  await this.loadCurrentMembers();
7964
8334
  if (this.initialShapeHandle && this.initialShapeOffset) {
7965
- const initialOffset = parseElectricOffset$1(this.initialShapeOffset);
8335
+ const initialOffset = parseElectricOffset(this.initialShapeOffset);
7966
8336
  if (initialOffset) {
7967
8337
  this.startLiveStream(initialOffset, this.initialShapeHandle);
7968
8338
  return;
@@ -8467,739 +8837,437 @@ function normalizeTask(row) {
8467
8837
  }
8468
8838
  var Scheduler = class {
8469
8839
  claimExpiryMs;
8470
- safetyPollMs;
8471
- listenEnabled;
8472
- pgClient;
8473
- instanceId;
8474
- tenantId;
8475
- tenantIds;
8476
- running = false;
8477
- loopPromise = null;
8478
- currentSleepResolve = null;
8479
- currentSleepTimer = null;
8480
- listenerMeta = null;
8481
- constructor(options) {
8482
- this.options = options;
8483
- this.pgClient = options.pgClient;
8484
- this.instanceId = options.instanceId;
8485
- this.tenantId = options.tenantId === void 0 ? DEFAULT_TENANT_ID : options.tenantId;
8486
- this.tenantIds = options.tenantIds;
8487
- this.claimExpiryMs = options.claimExpiryMs ?? 3e4;
8488
- this.safetyPollMs = options.safetyPollMs ?? 1e4;
8489
- this.listenEnabled = options.listen !== false;
8490
- }
8491
- resolveTenantId(tenantId) {
8492
- if (tenantId) return tenantId;
8493
- if (this.tenantId) return this.tenantId;
8494
- throw new Error(`Scheduler tenantId is required in shared mode`);
8495
- }
8496
- async start() {
8497
- if (this.running) return;
8498
- this.running = true;
8499
- if (this.listenEnabled) this.listenerMeta = await this.pgClient.listen(`scheduled_tasks_wake`, () => {
8500
- this.wakeEarly();
8501
- });
8502
- this.loopPromise = this.runLoop().catch((err) => {
8503
- console.error(`[agent-server] scheduler loop failed:`, err);
8504
- this.running = false;
8505
- this.wakeEarly();
8506
- });
8507
- }
8508
- async stop() {
8509
- this.running = false;
8510
- this.wakeEarly();
8511
- if (this.loopPromise) {
8512
- await this.loopPromise;
8513
- this.loopPromise = null;
8514
- }
8515
- if (this.listenerMeta) {
8516
- await this.listenerMeta.unlisten();
8517
- this.listenerMeta = null;
8518
- }
8519
- }
8520
- wake() {
8521
- this.wakeEarly();
8522
- }
8523
- async enqueueDelayedSend(payload, fireAt, opts) {
8524
- const tenantId = this.resolveTenantId();
8525
- await this.pgClient`
8526
- insert into scheduled_tasks (
8527
- tenant_id,
8528
- kind,
8529
- payload,
8530
- fire_at,
8531
- owner_entity_url,
8532
- manifest_key
8533
- )
8534
- values (
8535
- ${tenantId},
8536
- 'delayed_send',
8537
- ${JSON.stringify(payload)}::jsonb,
8538
- ${fireAt.toISOString()}::timestamptz,
8539
- ${opts?.ownerEntityUrl ?? null},
8540
- ${opts?.manifestKey ?? null}
8541
- )
8542
- `;
8543
- this.wakeEarly();
8544
- }
8545
- async syncManifestDelayedSend(ownerEntityUrl, manifestKey, payload, fireAt) {
8546
- const tenantId = this.resolveTenantId();
8547
- await this.pgClient.begin(async (sql$1) => {
8548
- await sql$1`
8549
- update scheduled_tasks
8550
- set completed_at = now(), claimed_at = null, claimed_by = null
8551
- where tenant_id = ${tenantId}
8552
- and kind = 'delayed_send'
8553
- and owner_entity_url = ${ownerEntityUrl}
8554
- and manifest_key = ${manifestKey}
8555
- and completed_at is null
8556
- `;
8557
- await sql$1`
8558
- insert into scheduled_tasks (
8559
- tenant_id,
8560
- kind,
8561
- payload,
8562
- fire_at,
8563
- owner_entity_url,
8564
- manifest_key
8565
- )
8566
- values (
8567
- ${tenantId},
8568
- 'delayed_send',
8569
- ${JSON.stringify(payload)}::jsonb,
8570
- ${fireAt.toISOString()}::timestamptz,
8571
- ${ownerEntityUrl},
8572
- ${manifestKey}
8573
- )
8574
- `;
8575
- });
8576
- this.wakeEarly();
8577
- }
8578
- async cancelManifestDelayedSend(ownerEntityUrl, manifestKey) {
8579
- const tenantId = this.resolveTenantId();
8580
- await this.pgClient`
8581
- update scheduled_tasks
8582
- set completed_at = now(), claimed_at = null, claimed_by = null
8583
- where tenant_id = ${tenantId}
8584
- and kind = 'delayed_send'
8585
- and owner_entity_url = ${ownerEntityUrl}
8586
- and manifest_key = ${manifestKey}
8587
- and completed_at is null
8588
- `;
8589
- this.wakeEarly();
8590
- }
8591
- async enqueueCronTick(expression, timezone, tickNumber, streamPath, fireAt) {
8592
- const tenantId = this.resolveTenantId();
8593
- await this.pgClient`
8594
- insert into scheduled_tasks (
8595
- tenant_id,
8596
- kind,
8597
- payload,
8598
- fire_at,
8599
- cron_expression,
8600
- cron_timezone,
8601
- cron_tick_number
8602
- )
8603
- values (
8604
- ${tenantId},
8605
- 'cron_tick',
8606
- ${JSON.stringify({ streamPath })}::jsonb,
8607
- ${fireAt.toISOString()}::timestamptz,
8608
- ${expression},
8609
- ${timezone},
8610
- ${tickNumber}
8611
- )
8612
- on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
8613
- `;
8614
- this.wakeEarly();
8615
- }
8616
- async runLoop() {
8617
- while (this.running) try {
8618
- await this.reclaimStaleClaims();
8619
- await this.fireReadyTasks();
8620
- const nextFireAt = await this.getNextFireAt();
8621
- const sleepTargetMs = nextFireAt ? Math.max(0, nextFireAt.getTime() - Date.now()) : this.safetyPollMs;
8622
- await this.sleepOrWake(Math.min(sleepTargetMs, this.safetyPollMs));
8623
- } catch (err) {
8624
- console.error(`[agent-server] scheduler iteration failed:`, err);
8625
- await this.sleepOrWake(this.safetyPollMs);
8626
- }
8627
- }
8628
- async reclaimStaleClaims() {
8629
- if (this.tenantId === null) {
8630
- const tenantIds = this.sharedTenantIds();
8631
- if (tenantIds && tenantIds.length === 0) return;
8632
- if (tenantIds) {
8633
- await this.pgClient`
8634
- update scheduled_tasks
8635
- set claimed_by = null, claimed_at = null
8636
- where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
8637
- and completed_at is null
8638
- and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
8639
- `;
8640
- return;
8641
- }
8642
- await this.pgClient`
8643
- update scheduled_tasks
8644
- set claimed_by = null, claimed_at = null
8645
- where completed_at is null
8646
- and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
8647
- `;
8648
- return;
8649
- }
8650
- await this.pgClient`
8651
- update scheduled_tasks
8652
- set claimed_by = null, claimed_at = null
8653
- where tenant_id = ${this.tenantId}
8654
- and completed_at is null
8655
- and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
8656
- `;
8657
- }
8658
- async fireReadyTasks() {
8659
- while (this.running) {
8660
- const tasks = await this.claimReadyTasks();
8661
- if (tasks.length === 0) return;
8662
- for (const task of tasks) await this.executeTask(task);
8663
- }
8664
- }
8665
- async claimReadyTasks() {
8666
- if (this.tenantId === null) {
8667
- const tenantIds = this.sharedTenantIds();
8668
- if (tenantIds && tenantIds.length === 0) return [];
8669
- if (tenantIds) {
8670
- const rows$2 = await this.pgClient`
8671
- update scheduled_tasks
8672
- set claimed_by = ${this.instanceId}, claimed_at = now()
8673
- where id in (
8674
- select id
8675
- from scheduled_tasks
8676
- where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
8677
- and completed_at is null
8678
- and claimed_at is null
8679
- and fire_at <= now()
8680
- order by fire_at, id
8681
- for update skip locked
8682
- limit 50
8683
- )
8684
- returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
8685
- , owner_entity_url, manifest_key
8686
- `;
8687
- return rows$2.map(normalizeTask);
8688
- }
8689
- const rows$1 = await this.pgClient`
8690
- update scheduled_tasks
8691
- set claimed_by = ${this.instanceId}, claimed_at = now()
8692
- where id in (
8693
- select id
8694
- from scheduled_tasks
8695
- where completed_at is null
8696
- and claimed_at is null
8697
- and fire_at <= now()
8698
- order by fire_at, id
8699
- for update skip locked
8700
- limit 50
8701
- )
8702
- returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
8703
- , owner_entity_url, manifest_key
8704
- `;
8705
- return rows$1.map(normalizeTask);
8706
- }
8707
- const rows = await this.pgClient`
8708
- update scheduled_tasks
8709
- set claimed_by = ${this.instanceId}, claimed_at = now()
8710
- where tenant_id = ${this.tenantId}
8711
- and id in (
8712
- select id
8713
- from scheduled_tasks
8714
- where tenant_id = ${this.tenantId}
8715
- and completed_at is null
8716
- and claimed_at is null
8717
- and fire_at <= now()
8718
- order by fire_at, id
8719
- for update skip locked
8720
- limit 50
8721
- )
8722
- returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
8723
- , owner_entity_url, manifest_key
8724
- `;
8725
- 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;
8726
8860
  }
8727
- async executeTask(task) {
8728
- try {
8729
- if (task.kind === `delayed_send`) {
8730
- await this.options.executors.delayed_send(task.payload, task.id, task.tenantId);
8731
- await this.markTaskComplete(task.id, task.tenantId);
8732
- return;
8733
- }
8734
- const tickNumber = task.cronTickNumber;
8735
- if (tickNumber == null || !task.cronExpression || !task.cronTimezone) throw new Error(`cron task ${task.id} is missing cron metadata`);
8736
- await this.options.executors.cron_tick(task.payload, tickNumber, task.id, task.tenantId);
8737
- await this.completeAndRescheduleCron(task);
8738
- } catch (err) {
8739
- const message = err instanceof Error ? err.message : String(err);
8740
- if (isUnregisteredTenantError(err)) {
8741
- await this.releaseClaim(task.id, message, task.tenantId);
8742
- serverLog.warn(`[scheduler] skipped ${task.kind} task ${task.id} for unregistered tenant "${task.tenantId}": ${message}`);
8743
- return;
8744
- }
8745
- if (isPermanentElectricAgentsError(err)) {
8746
- await this.markTaskPermanentFailure(task.id, message, task.tenantId);
8747
- return;
8748
- }
8749
- await this.releaseClaim(task.id, message, task.tenantId);
8750
- }
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`);
8751
8865
  }
8752
- async markTaskComplete(taskId, tenantId = this.resolveTenantId()) {
8753
- await this.pgClient`
8754
- update scheduled_tasks
8755
- set completed_at = now(), last_error = null
8756
- where tenant_id = ${tenantId}
8757
- and id = ${taskId}
8758
- and claimed_by = ${this.instanceId}
8759
- and completed_at is null
8760
- `;
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
+ });
8761
8877
  }
8762
- async markTaskPermanentFailure(taskId, message, tenantId = this.resolveTenantId()) {
8763
- await this.pgClient`
8764
- update scheduled_tasks
8765
- set completed_at = now(), last_error = ${message}
8766
- where tenant_id = ${tenantId}
8767
- and id = ${taskId}
8768
- and claimed_by = ${this.instanceId}
8769
- and completed_at is null
8770
- `;
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
+ }
8771
8889
  }
8772
- async releaseClaim(taskId, message, tenantId = this.resolveTenantId()) {
8890
+ wake() {
8891
+ this.wakeEarly();
8892
+ }
8893
+ async enqueueDelayedSend(payload, fireAt, opts) {
8894
+ const tenantId = this.resolveTenantId();
8773
8895
  await this.pgClient`
8774
- update scheduled_tasks
8775
- set claimed_at = null, claimed_by = null, last_error = ${message}
8776
- where tenant_id = ${tenantId}
8777
- and id = ${taskId}
8778
- and claimed_by = ${this.instanceId}
8779
- 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
+ )
8780
8912
  `;
8913
+ this.wakeEarly();
8781
8914
  }
8782
- async completeAndRescheduleCron(task) {
8783
- const tenantId = task.tenantId ?? this.resolveTenantId();
8915
+ async syncManifestDelayedSend(ownerEntityUrl, manifestKey, payload, fireAt) {
8916
+ const tenantId = this.resolveTenantId();
8784
8917
  await this.pgClient.begin(async (sql$1) => {
8785
- const completed = await sql$1`
8918
+ await sql$1`
8786
8919
  update scheduled_tasks
8787
- set completed_at = now(), last_error = null
8920
+ set completed_at = now(), claimed_at = null, claimed_by = null
8788
8921
  where tenant_id = ${tenantId}
8789
- and id = ${task.id}
8790
- and claimed_by = ${this.instanceId}
8922
+ and kind = 'delayed_send'
8923
+ and owner_entity_url = ${ownerEntityUrl}
8924
+ and manifest_key = ${manifestKey}
8791
8925
  and completed_at is null
8792
- returning id
8793
8926
  `;
8794
- if (completed.length === 0) return;
8795
- const nextFireAt = getNextCronFireAt(task.cronExpression, task.cronTimezone, task.fireAt);
8796
- const streamPath = cronTaskStreamPath(task.payload);
8797
- const subscriberRows = streamPath ? await sql$1`
8798
- select 1 as exists
8799
- from wake_registrations
8800
- where tenant_id = ${tenantId}
8801
- and source_url = ${streamPath}
8802
- limit 1
8803
- ` : [];
8804
- if (subscriberRows.length === 0) return;
8805
8927
  await sql$1`
8806
8928
  insert into scheduled_tasks (
8807
8929
  tenant_id,
8808
8930
  kind,
8809
8931
  payload,
8810
8932
  fire_at,
8811
- cron_expression,
8812
- cron_timezone,
8813
- cron_tick_number
8933
+ owner_entity_url,
8934
+ manifest_key
8814
8935
  )
8815
8936
  values (
8816
8937
  ${tenantId},
8817
- 'cron_tick',
8818
- ${JSON.stringify(task.payload)}::jsonb,
8819
- ${nextFireAt.toISOString()}::timestamptz,
8820
- ${task.cronExpression},
8821
- ${task.cronTimezone},
8822
- ${task.cronTickNumber + 1}
8938
+ 'delayed_send',
8939
+ ${JSON.stringify(payload)}::jsonb,
8940
+ ${fireAt.toISOString()}::timestamptz,
8941
+ ${ownerEntityUrl},
8942
+ ${manifestKey}
8823
8943
  )
8824
- on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
8825
8944
  `;
8826
8945
  });
8946
+ this.wakeEarly();
8827
8947
  }
8828
- async getNextFireAt() {
8829
- if (this.tenantId === null) {
8830
- const tenantIds = this.sharedTenantIds();
8831
- if (tenantIds && tenantIds.length === 0) return null;
8832
- if (tenantIds) {
8833
- const rows$2 = await this.pgClient`
8834
- select fire_at
8835
- from scheduled_tasks
8836
- where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
8837
- and completed_at is null
8838
- and claimed_at is null
8839
- order by fire_at, id
8840
- limit 1
8841
- `;
8842
- if (rows$2.length === 0) return null;
8843
- const fireAt$2 = rows$2[0].fire_at;
8844
- return fireAt$2 instanceof Date ? fireAt$2 : new Date(fireAt$2);
8845
- }
8846
- const rows$1 = await this.pgClient`
8847
- select fire_at
8848
- from scheduled_tasks
8849
- where completed_at is null
8850
- and claimed_at is null
8851
- order by fire_at, id
8852
- limit 1
8853
- `;
8854
- if (rows$1.length === 0) return null;
8855
- const fireAt$1 = rows$1[0].fire_at;
8856
- return fireAt$1 instanceof Date ? fireAt$1 : new Date(fireAt$1);
8857
- }
8858
- const rows = await this.pgClient`
8859
- select fire_at
8860
- from scheduled_tasks
8861
- 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}
8862
8957
  and completed_at is null
8863
- and claimed_at is null
8864
- order by fire_at, id
8865
- limit 1
8866
8958
  `;
8867
- if (rows.length === 0) return null;
8868
- const fireAt = rows[0].fire_at;
8869
- return fireAt instanceof Date ? fireAt : new Date(fireAt);
8870
- }
8871
- async sleepOrWake(durationMs) {
8872
- if (!this.running) return;
8873
- await new Promise((resolve$1) => {
8874
- const finish = () => {
8875
- if (this.currentSleepTimer) {
8876
- clearTimeout(this.currentSleepTimer);
8877
- this.currentSleepTimer = null;
8878
- }
8879
- this.currentSleepResolve = null;
8880
- resolve$1();
8881
- };
8882
- this.currentSleepResolve = finish;
8883
- this.currentSleepTimer = setTimeout(finish, Math.max(durationMs, 0));
8884
- });
8885
- }
8886
- wakeEarly() {
8887
- const resolve$1 = this.currentSleepResolve;
8888
- this.currentSleepResolve = null;
8889
- if (this.currentSleepTimer) {
8890
- clearTimeout(this.currentSleepTimer);
8891
- this.currentSleepTimer = null;
8892
- }
8893
- resolve$1?.();
8894
- }
8895
- sharedTenantIds() {
8896
- if (this.tenantId !== null || !this.tenantIds) return null;
8897
- return [...new Set(this.tenantIds())];
8959
+ this.wakeEarly();
8898
8960
  }
8899
- sharedTenantIdsParameter(tenantIds) {
8900
- 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();
8901
8985
  }
8902
- };
8903
-
8904
- //#endregion
8905
- //#region src/pg-sync-bridge-manager.ts
8906
- const PG_SYNC_ELECTRIC_SHAPE_URL = process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ?? `http://localhost:3000/v1/shape`;
8907
- const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
8908
- const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
8909
- function buildElectricShapeParams(options) {
8910
- return {
8911
- table: options.table,
8912
- ...options.columns !== void 0 ? { columns: [...options.columns] } : {},
8913
- ...options.where !== void 0 ? { where: options.where } : {},
8914
- ...options.params !== void 0 ? { params: Array.isArray(options.params) ? [...options.params] : { ...options.params } } : {},
8915
- ...options.replica !== void 0 ? { replica: options.replica } : {},
8916
- ...options.metadata?.tenantId ? { electric_agents_tenant_id: options.metadata.tenantId } : {},
8917
- ...options.metadata?.principalKind ? { electric_agents_principal_kind: options.metadata.principalKind } : {},
8918
- ...options.metadata?.principalId ? { electric_agents_principal_id: options.metadata.principalId } : {},
8919
- ...options.metadata?.principalKey ? { electric_agents_principal_key: options.metadata.principalKey } : {},
8920
- ...options.metadata?.principalUrl ? { electric_agents_principal_url: options.metadata.principalUrl } : {},
8921
- ...options.metadata?.entityUrl ? { electric_agents_entity_url: options.metadata.entityUrl } : {},
8922
- ...options.metadata?.entityType ? { electric_agents_entity_type: options.metadata.entityType } : {},
8923
- ...options.metadata?.streamPath ? { electric_agents_stream_path: options.metadata.streamPath } : {},
8924
- ...options.metadata?.runtimeConsumerId ? { electric_agents_runtime_consumer_id: options.metadata.runtimeConsumerId } : {},
8925
- ...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
8926
- };
8927
- }
8928
- function jsonSafe(value) {
8929
- if (typeof value === `bigint`) return value.toString();
8930
- if (value === null || typeof value !== `object`) return value;
8931
- if (Array.isArray(value)) return value.map(jsonSafe);
8932
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
8933
- }
8934
- function stableJson(value) {
8935
- if (typeof value === `bigint`) return JSON.stringify(value.toString());
8936
- if (value === null || typeof value !== `object`) return JSON.stringify(value);
8937
- if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`;
8938
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(`,`)}}`;
8939
- }
8940
- function parseElectricOffset(offset) {
8941
- if (offset === `-1`) return offset;
8942
- return /^\d+_\d+$/.test(offset) ? offset : null;
8943
- }
8944
- function rowKeyForMessage(message) {
8945
- const headers = message.headers;
8946
- const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
8947
- return candidate === void 0 ? void 0 : stableJson(candidate);
8948
- }
8949
- function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
8950
- const operation = message.headers.operation;
8951
- if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
8952
- const sourceRef = typeof optionsOrSourceRef === `string` ? optionsOrSourceRef : sourceRefForPgSync(optionsOrSourceRef);
8953
- const rowKey = rowKeyForMessage(message);
8954
- const offset = message.headers.offset;
8955
- if (typeof offset !== `string` || offset.length === 0) return null;
8956
- const messageKeyPart = offset;
8957
- const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
8958
- const timestamp$1 = new Date().toISOString();
8959
- const oldValue = message.old_value;
8960
- const safeValue = jsonSafe(message.value);
8961
- const safeOldValue = jsonSafe(oldValue);
8962
- const safeHeaders = jsonSafe(message.headers);
8963
- return {
8964
- type: `pg_sync_change`,
8965
- key: messageKey,
8966
- value: {
8967
- key: messageKey,
8968
- table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
8969
- operation,
8970
- ...rowKey !== void 0 ? { rowKey } : {},
8971
- ...message.value !== void 0 ? { value: safeValue } : {},
8972
- ...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
8973
- headers: safeHeaders,
8974
- ...typeof offset === `string` ? { offset } : {},
8975
- receivedAt: timestamp$1
8976
- },
8977
- headers: {
8978
- operation,
8979
- timestamp: timestamp$1
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);
8980
8996
  }
8981
- };
8982
- }
8983
- function cursorFromRow(row) {
8984
- return row?.shapeHandle && row.shapeOffset ? {
8985
- handle: row.shapeHandle,
8986
- offset: row.shapeOffset,
8987
- initialSnapshotComplete: row.initialSnapshotComplete
8988
- } : void 0;
8989
- }
8990
- var PgSyncBridge = class {
8991
- producer = null;
8992
- unsubscribe = null;
8993
- abortController = null;
8994
- skipChangesUntilUpToDate = false;
8995
- recovering = false;
8996
- committedCursor;
8997
- retryAttempt = 0;
8998
- constructor(sourceRef, streamUrl, options, resolvedSource, retry, streamClient, registry, evaluateWakes, initialCursor) {
8999
- this.sourceRef = sourceRef;
9000
- this.streamUrl = streamUrl;
9001
- this.options = options;
9002
- this.resolvedSource = resolvedSource;
9003
- this.retry = retry;
9004
- this.streamClient = streamClient;
9005
- this.registry = registry;
9006
- this.evaluateWakes = evaluateWakes;
9007
- this.initialCursor = initialCursor;
9008
- this.committedCursor = initialCursor;
9009
8997
  }
9010
- async start() {
9011
- if (!this.producer) this.producer = new IdempotentProducer(new DurableStream({
9012
- url: `${this.streamClient.baseUrl}${this.streamUrl}`,
9013
- contentType: `application/json`
9014
- }), `pg-sync-bridge-${this.sourceRef}`);
9015
- if (this.initialCursor) {
9016
- const offset = parseElectricOffset(this.initialCursor.offset);
9017
- if (offset) {
9018
- this.startStream(offset, this.initialCursor.handle, !this.initialCursor.initialSnapshotComplete);
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
+ `;
9019
9010
  return;
9020
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;
9021
9019
  }
9022
- await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
9023
- 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
+ `;
9024
9027
  }
9025
- async stop() {
9026
- this.unsubscribe?.();
9027
- this.abortController?.abort();
9028
- this.unsubscribe = null;
9029
- this.abortController = null;
9030
- try {
9031
- await this.producer?.flush();
9032
- } finally {
9033
- await this.producer?.detach();
9034
- this.producer = null;
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);
9035
9033
  }
9036
9034
  }
9037
- startStream(offset, handle, skipChangesUntilUpToDate = false, log = offset === `now` ? `changes_only` : `full`) {
9038
- this.unsubscribe?.();
9039
- this.abortController?.abort();
9040
- this.skipChangesUntilUpToDate = skipChangesUntilUpToDate;
9041
- this.abortController = new AbortController();
9042
- const stream = new ShapeStream({
9043
- url: this.resolvedSource.url,
9044
- params: buildElectricShapeParams(this.options),
9045
- offset,
9046
- log,
9047
- ...handle ? { handle } : {},
9048
- signal: this.abortController.signal
9049
- });
9050
- this.unsubscribe = stream.subscribe(async (messages) => {
9051
- try {
9052
- for (const message of messages) {
9053
- if (isControlMessage(message)) {
9054
- if (message.headers.control === `must-refetch`) {
9055
- await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
9056
- this.startStream(`now`, void 0, true);
9057
- return;
9058
- }
9059
- if (message.headers.control === `up-to-date`) {
9060
- this.skipChangesUntilUpToDate = false;
9061
- await this.persistCursor(stream, true);
9062
- continue;
9063
- }
9064
- await this.persistCursor(stream);
9065
- continue;
9066
- }
9067
- if (!isChangeMessage(message)) continue;
9068
- if (!this.skipChangesUntilUpToDate) {
9069
- const event = pgSyncMessageToDurableEvent(message, this.options);
9070
- if (event) {
9071
- if (!this.producer) throw new Error(`pg-sync producer is not started`);
9072
- await this.producer.append(JSON.stringify(event));
9073
- await this.producer.flush?.();
9074
- await this.evaluateWakes?.(this.streamUrl, event);
9075
- }
9076
- }
9077
- await this.persistCursor(stream);
9078
- this.retryAttempt = 0;
9079
- }
9080
- } catch (error) {
9081
- serverLog.warn(`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`, error);
9082
- await this.recoverStream();
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);
9083
9058
  }
9084
- }, (error) => {
9085
- if (this.abortController?.signal.aborted) return;
9086
- serverLog.warn(`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`, error);
9087
- this.recoverStream();
9088
- });
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);
9089
9096
  }
9090
- async recoverStream() {
9091
- if (this.recovering) return;
9092
- this.recovering = true;
9097
+ async executeTask(task) {
9093
9098
  try {
9094
- const attempt = this.retryAttempt++;
9095
- const baseDelay = Math.min(this.retry.initialDelayMs * 2 ** attempt, this.retry.maxDelayMs);
9096
- const jitter = Math.floor(baseDelay * .2 * this.retry.random());
9097
- const delay = baseDelay + jitter;
9098
- if (delay > 0) await this.retry.sleep(delay);
9099
- const offset = this.committedCursor ? parseElectricOffset(this.committedCursor.offset) : null;
9100
- if (offset && this.committedCursor) this.startStream(offset, this.committedCursor.handle, !this.committedCursor.initialSnapshotComplete);
9101
- else {
9102
- await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
9103
- this.startStream(`now`, void 0, true);
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;
9104
9103
  }
9105
- } finally {
9106
- 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);
9107
9120
  }
9108
9121
  }
9109
- async persistCursor(stream, initialSnapshotComplete = !this.skipChangesUntilUpToDate) {
9110
- const shapeHandle = stream.shapeHandle;
9111
- const shapeOffset = stream.lastOffset;
9112
- if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return;
9113
- await this.registry?.updatePgSyncBridgeCursor(this.sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete);
9114
- this.committedCursor = {
9115
- handle: shapeHandle,
9116
- offset: shapeOffset,
9117
- initialSnapshotComplete
9118
- };
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
+ `;
9119
9131
  }
9120
- };
9121
- var PgSyncBridgeManager = class {
9122
- bridges = new Map();
9123
- starting = new Map();
9124
- url;
9125
- retry;
9126
- constructor(streamClient, evaluateWakes, registry, options = {}) {
9127
- this.streamClient = streamClient;
9128
- this.evaluateWakes = evaluateWakes;
9129
- this.registry = registry;
9130
- this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
9131
- this.retry = {
9132
- initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
9133
- maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
9134
- random: options.retry?.random ?? Math.random,
9135
- sleep: options.retry?.sleep ?? ((ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)))
9136
- };
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
+ `;
9137
9141
  }
9138
- async start() {
9139
- const rows = await this.registry?.listPgSyncBridges?.();
9140
- if (!rows) return;
9141
- await Promise.all(rows.map((row) => this.ensureBridge(row).catch((error) => {
9142
- serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
9143
- })));
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
+ `;
9144
9151
  }
9145
- async register(options, metadata) {
9146
- const mergedMetadata = {
9147
- ...options.metadata,
9148
- ...metadata
9149
- };
9150
- const canonicalOptions = {
9151
- ...canonicalPgSyncOptions(options),
9152
- ...Object.keys(mergedMetadata).length > 0 ? { metadata: mergedMetadata } : {}
9153
- };
9154
- const resolvedSource = this.resolveSource(canonicalOptions);
9155
- const sourceRef = sourceRefForPgSync(canonicalOptions);
9156
- const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId);
9157
- const row = await this.registry?.upsertPgSyncBridge({
9158
- sourceRef,
9159
- options: canonicalOptions,
9160
- streamUrl
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
+ `;
9161
9196
  });
9162
- await this.streamClient.ensure(streamUrl, { contentType: `application/json` });
9163
- if (!this.bridges.has(sourceRef)) {
9164
- let start = this.starting.get(sourceRef);
9165
- if (!start) {
9166
- start = (async () => {
9167
- const bridge = new PgSyncBridge(sourceRef, streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
9168
- await bridge.start();
9169
- this.bridges.set(sourceRef, bridge);
9170
- })().finally(() => this.starting.delete(sourceRef));
9171
- this.starting.set(sourceRef, start);
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);
9172
9215
  }
9173
- 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);
9174
9227
  }
9175
- return {
9176
- sourceRef,
9177
- streamUrl
9178
- };
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);
9179
9240
  }
9180
- async ensureBridge(row) {
9181
- if (this.bridges.has(row.sourceRef)) return;
9182
- let start = this.starting.get(row.sourceRef);
9183
- if (!start) {
9184
- start = (async () => {
9185
- await this.streamClient.ensure(row.streamUrl, { contentType: `application/json` });
9186
- const canonicalOptions = canonicalPgSyncOptions(row.options);
9187
- const resolvedSource = this.resolveSource(canonicalOptions);
9188
- const bridge = new PgSyncBridge(row.sourceRef, row.streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
9189
- await bridge.start();
9190
- this.bridges.set(row.sourceRef, bridge);
9191
- })().finally(() => this.starting.delete(row.sourceRef));
9192
- this.starting.set(row.sourceRef, start);
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;
9193
9262
  }
9194
- await start;
9263
+ resolve$1?.();
9195
9264
  }
9196
- resolveSource(options) {
9197
- return { url: options.url ?? this.url };
9265
+ sharedTenantIds() {
9266
+ if (this.tenantId !== null || !this.tenantIds) return null;
9267
+ return [...new Set(this.tenantIds())];
9198
9268
  }
9199
- async stop() {
9200
- await Promise.allSettled(this.starting.values());
9201
- await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()));
9202
- this.bridges.clear();
9269
+ sharedTenantIdsParameter(tenantIds) {
9270
+ return this.pgClient.array(tenantIds, POSTGRES_TEXT_OID);
9203
9271
  }
9204
9272
  };
9205
9273
 
@@ -10156,6 +10224,7 @@ var WakeRegistry = class {
10156
10224
  };
10157
10225
  if (value && `value` in value) change.value = value.value;
10158
10226
  if (value && `oldValue` in value) change.oldValue = value.oldValue;
10227
+ else if (value && `old_value` in value) change.oldValue = value.old_value;
10159
10228
  if (eventType === `inbox`) {
10160
10229
  if (typeof value?.from === `string`) change.from = value.from;
10161
10230
  if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
@@ -10518,8 +10587,8 @@ var ElectricAgentsServer = class {
10518
10587
  runtime: this.standaloneRuntime.runtime,
10519
10588
  entityBridgeManager: this.entityBridgeManager,
10520
10589
  pgSyncBridgeManager: this.standaloneRuntime.runtime.pgSyncBridgeManager,
10521
- ...this.options.eventSources ? { eventSources: this.options.eventSources } : {},
10522
- ...this.options.ensureEventSourceWakeSource ? { ensureEventSourceWakeSource: this.options.ensureEventSourceWakeSource } : {},
10590
+ ...this.options.webhookSources ? { webhookSources: this.options.webhookSources } : {},
10591
+ ...this.options.ensureWebhookSourceWakeSource ? { ensureWebhookSourceWakeSource: this.options.ensureWebhookSourceWakeSource } : {},
10523
10592
  ...this.options.authorizeRequest ? { authorizeRequest: this.options.authorizeRequest } : {},
10524
10593
  isShuttingDown: () => this.shuttingDown,
10525
10594
  mockAgent: this.mockAgentBootstrap ? { runtime: this.mockAgentBootstrap.runtime } : void 0