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