@electric-ax/agents-server 0.5.0 → 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.
- package/dist/entrypoint.js +938 -869
- package/dist/index.cjs +135 -66
- package/dist/index.d.cts +26 -12
- package/dist/index.d.ts +26 -12
- package/dist/index.js +136 -67
- package/package.json +5 -5
- package/src/entity-manager.ts +31 -7
- package/src/entity-registry.ts +11 -1
- package/src/index.ts +6 -6
- package/src/manifest-side-effects.ts +2 -6
- package/src/pg-sync-bridge-manager.ts +147 -47
- package/src/routing/context.ts +11 -11
- package/src/routing/entities-router.ts +51 -28
- package/src/routing/internal-router.ts +9 -7
- package/src/routing/pg-sync-router.ts +14 -1
- package/src/server.ts +8 -8
- package/src/wake-registry.ts +2 -0
package/dist/entrypoint.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
5289
|
-
const manifestKey =
|
|
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
|
|
6087
|
-
|
|
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/
|
|
6121
|
-
entitiesRouter.delete(`/:type/:instanceId/
|
|
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
|
|
6373
|
-
const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to
|
|
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.
|
|
6376
|
-
if (!catalog) return apiError(404, ErrCodeNotFound, `No
|
|
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.
|
|
6380
|
-
if (!source) return apiError(404, ErrCodeNotFound, `
|
|
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 =
|
|
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.
|
|
6400
|
-
const result = await ctx.entityManager.
|
|
6411
|
+
await ctx.ensureWebhookSourceWakeSource?.(resolved.subscription.sourceUrl);
|
|
6412
|
+
const result = await ctx.entityManager.upsertWebhookSourceSubscription(entityUrl, {
|
|
6401
6413
|
subscription: resolved.subscription,
|
|
6402
|
-
manifest:
|
|
6414
|
+
manifest: buildWebhookSourceManifestEntry(resolved)
|
|
6403
6415
|
});
|
|
6404
6416
|
return json(result);
|
|
6405
6417
|
}
|
|
6406
|
-
async function
|
|
6407
|
-
const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from
|
|
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.
|
|
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/
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
}
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
}
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
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
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
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
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
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
|
|
6930
|
-
|
|
6931
|
-
if (
|
|
6932
|
-
|
|
6933
|
-
|
|
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
|
|
6946
|
-
|
|
6947
|
-
return
|
|
6957
|
+
function parseElectricOffset$1(offset) {
|
|
6958
|
+
if (offset === `-1`) return offset;
|
|
6959
|
+
return /^\d+_\d+$/.test(offset) ? offset : null;
|
|
6948
6960
|
}
|
|
6949
|
-
function
|
|
6950
|
-
const
|
|
6951
|
-
|
|
6952
|
-
|
|
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
|
|
6957
|
-
|
|
6958
|
-
|
|
6959
|
-
headers.
|
|
6960
|
-
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
`
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
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
|
|
6977
|
-
|
|
6978
|
-
|
|
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
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
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
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
|
|
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
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
}
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
const
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
7015
|
-
|
|
7016
|
-
|
|
7017
|
-
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
}
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
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(`/
|
|
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
|
|
7559
|
-
const
|
|
7560
|
-
return json({
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
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
|
|
8753
|
-
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
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
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
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
|
-
|
|
8890
|
+
wake() {
|
|
8891
|
+
this.wakeEarly();
|
|
8892
|
+
}
|
|
8893
|
+
async enqueueDelayedSend(payload, fireAt, opts) {
|
|
8894
|
+
const tenantId = this.resolveTenantId();
|
|
8773
8895
|
await this.pgClient`
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
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
|
|
8783
|
-
const tenantId =
|
|
8915
|
+
async syncManifestDelayedSend(ownerEntityUrl, manifestKey, payload, fireAt) {
|
|
8916
|
+
const tenantId = this.resolveTenantId();
|
|
8784
8917
|
await this.pgClient.begin(async (sql$1) => {
|
|
8785
|
-
|
|
8918
|
+
await sql$1`
|
|
8786
8919
|
update scheduled_tasks
|
|
8787
|
-
set completed_at = now(),
|
|
8920
|
+
set completed_at = now(), claimed_at = null, claimed_by = null
|
|
8788
8921
|
where tenant_id = ${tenantId}
|
|
8789
|
-
and
|
|
8790
|
-
and
|
|
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
|
-
|
|
8812
|
-
|
|
8813
|
-
cron_tick_number
|
|
8933
|
+
owner_entity_url,
|
|
8934
|
+
manifest_key
|
|
8814
8935
|
)
|
|
8815
8936
|
values (
|
|
8816
8937
|
${tenantId},
|
|
8817
|
-
'
|
|
8818
|
-
${JSON.stringify(
|
|
8819
|
-
${
|
|
8820
|
-
${
|
|
8821
|
-
${
|
|
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
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8836
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8900
|
-
|
|
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
|
-
|
|
8905
|
-
|
|
8906
|
-
const
|
|
8907
|
-
const
|
|
8908
|
-
|
|
8909
|
-
|
|
8910
|
-
|
|
8911
|
-
|
|
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
|
|
9011
|
-
if (
|
|
9012
|
-
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9016
|
-
|
|
9017
|
-
|
|
9018
|
-
|
|
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.
|
|
9023
|
-
|
|
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
|
|
9026
|
-
this.
|
|
9027
|
-
|
|
9028
|
-
|
|
9029
|
-
|
|
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
|
-
|
|
9038
|
-
this.
|
|
9039
|
-
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
9044
|
-
|
|
9045
|
-
|
|
9046
|
-
|
|
9047
|
-
|
|
9048
|
-
|
|
9049
|
-
|
|
9050
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
|
|
9055
|
-
|
|
9056
|
-
|
|
9057
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
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
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9087
|
-
|
|
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
|
|
9091
|
-
if (this.recovering) return;
|
|
9092
|
-
this.recovering = true;
|
|
9097
|
+
async executeTask(task) {
|
|
9093
9098
|
try {
|
|
9094
|
-
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
|
|
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
|
-
|
|
9106
|
-
|
|
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
|
|
9110
|
-
|
|
9111
|
-
|
|
9112
|
-
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9117
|
-
|
|
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
|
-
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
|
|
9126
|
-
|
|
9127
|
-
|
|
9128
|
-
|
|
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
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
|
|
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
|
|
9146
|
-
const
|
|
9147
|
-
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
|
|
9151
|
-
|
|
9152
|
-
|
|
9153
|
-
|
|
9154
|
-
|
|
9155
|
-
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
|
|
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
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
|
|
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
|
|
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
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
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
|
|
9181
|
-
if (this.
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
this.
|
|
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
|
-
|
|
9263
|
+
resolve$1?.();
|
|
9195
9264
|
}
|
|
9196
|
-
|
|
9197
|
-
|
|
9265
|
+
sharedTenantIds() {
|
|
9266
|
+
if (this.tenantId !== null || !this.tenantIds) return null;
|
|
9267
|
+
return [...new Set(this.tenantIds())];
|
|
9198
9268
|
}
|
|
9199
|
-
|
|
9200
|
-
|
|
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.
|
|
10522
|
-
...this.options.
|
|
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
|