@electric-ax/agents-server 0.4.19 → 0.4.20
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 +586 -39
- package/dist/index.cjs +572 -35
- package/dist/index.d.cts +290 -40
- package/dist/index.d.ts +290 -40
- package/dist/index.js +573 -36
- package/drizzle/0015_pg_sync_bridges.sql +14 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/db/schema.ts +28 -0
- package/src/entity-manager.ts +34 -29
- package/src/entity-registry.ts +144 -3
- package/src/manifest-side-effects.ts +10 -0
- package/src/pg-sync-bridge-manager.ts +552 -0
- package/src/routing/context.ts +2 -0
- package/src/routing/entities-router.ts +28 -16
- package/src/routing/global-router.ts +3 -0
- package/src/routing/hooks.ts +7 -0
- package/src/routing/internal-router.ts +2 -0
- package/src/routing/pg-sync-router.ts +113 -0
- package/src/runtime.ts +20 -1
- package/src/scheduler.ts +26 -0
- package/src/server.ts +4 -0
- package/src/standalone-runtime.ts +16 -0
- package/src/utils/server-utils.ts +97 -1
- package/src/wake-registry.ts +27 -2
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import postgres from "postgres";
|
|
|
8
8
|
import { and, desc, eq, inArray, lt, ne, sql } from "drizzle-orm";
|
|
9
9
|
import { bigint, bigserial, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
|
|
10
10
|
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
|
|
11
|
-
import { COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
11
|
+
import { COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, canonicalPgSyncOptions, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
12
12
|
import { DurableStream, DurableStreamError, FetchError, IdempotentProducer } from "@durable-streams/client";
|
|
13
13
|
import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
|
|
14
14
|
import pino from "pino";
|
|
@@ -32,6 +32,7 @@ __export(schema_exports, {
|
|
|
32
32
|
entityPermissionGrants: () => entityPermissionGrants,
|
|
33
33
|
entityTypePermissionGrants: () => entityTypePermissionGrants,
|
|
34
34
|
entityTypes: () => entityTypes,
|
|
35
|
+
pgSyncBridges: () => pgSyncBridges,
|
|
35
36
|
runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
|
|
36
37
|
runners: () => runners,
|
|
37
38
|
scheduledTasks: () => scheduledTasks,
|
|
@@ -353,6 +354,18 @@ const scheduledTasks = pgTable(`scheduled_tasks`, {
|
|
|
353
354
|
index(`idx_scheduled_tasks_manifest_pending`).on(table.tenantId, table.ownerEntityUrl, table.manifestKey).where(sql`${table.kind} = 'delayed_send' AND ${table.completedAt} IS NULL AND ${table.manifestKey} IS NOT NULL`),
|
|
354
355
|
index(`idx_scheduled_tasks_stale_claims`).on(table.tenantId, table.claimedAt).where(sql`${table.completedAt} IS NULL AND ${table.claimedAt} IS NOT NULL`)
|
|
355
356
|
]);
|
|
357
|
+
const pgSyncBridges = pgTable(`pg_sync_bridges`, {
|
|
358
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
359
|
+
sourceRef: text(`source_ref`).notNull(),
|
|
360
|
+
options: jsonb(`options`).notNull(),
|
|
361
|
+
streamUrl: text(`stream_url`).notNull(),
|
|
362
|
+
shapeHandle: text(`shape_handle`),
|
|
363
|
+
shapeOffset: text(`shape_offset`),
|
|
364
|
+
initialSnapshotComplete: boolean(`initial_snapshot_complete`).notNull().default(false),
|
|
365
|
+
lastTouchedAt: timestamp(`last_touched_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
366
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
367
|
+
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
368
|
+
}, (table) => [primaryKey({ columns: [table.tenantId, table.sourceRef] }), unique(`uq_pg_sync_bridges_stream_url`).on(table.tenantId, table.streamUrl)]);
|
|
356
369
|
const entityBridges = pgTable(`entity_bridges`, {
|
|
357
370
|
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
358
371
|
sourceRef: text(`source_ref`).notNull(),
|
|
@@ -813,6 +826,9 @@ var PostgresRegistry = class {
|
|
|
813
826
|
entityBridgeWhere(sourceRef) {
|
|
814
827
|
return and(eq(entityBridges.tenantId, this.tenantId), eq(entityBridges.sourceRef, sourceRef));
|
|
815
828
|
}
|
|
829
|
+
pgSyncBridgeWhere(sourceRef) {
|
|
830
|
+
return and(eq(pgSyncBridges.tenantId, this.tenantId), eq(pgSyncBridges.sourceRef, sourceRef));
|
|
831
|
+
}
|
|
816
832
|
async createEntityType(et) {
|
|
817
833
|
await this.db.insert(entityTypes).values({
|
|
818
834
|
tenantId: this.tenantId,
|
|
@@ -1282,11 +1298,12 @@ var PostgresRegistry = class {
|
|
|
1282
1298
|
};
|
|
1283
1299
|
const nextTags = normalizeTags(mutation.nextTags);
|
|
1284
1300
|
const updatedAt = Date.now();
|
|
1285
|
-
await tx.update(entities).set({
|
|
1301
|
+
const [updateResult] = await tx.update(entities).set({
|
|
1286
1302
|
tags: nextTags,
|
|
1287
1303
|
tagsIndex: buildTagsIndex(nextTags),
|
|
1288
1304
|
updatedAt
|
|
1289
|
-
}).where(this.entityWhere(url));
|
|
1305
|
+
}).where(this.entityWhere(url)).returning({ txid: sql`pg_current_xact_id()::xid::text` });
|
|
1306
|
+
const txid = updateResult ? parseInt(updateResult.txid) : void 0;
|
|
1290
1307
|
await tx.insert(tagStreamOutbox).values({
|
|
1291
1308
|
tenantId: this.tenantId,
|
|
1292
1309
|
entityUrl: url,
|
|
@@ -1304,10 +1321,63 @@ var PostgresRegistry = class {
|
|
|
1304
1321
|
return {
|
|
1305
1322
|
entity,
|
|
1306
1323
|
changed: true,
|
|
1307
|
-
...op === `insert` || op === `update` ? { op } : {}
|
|
1324
|
+
...op === `insert` || op === `update` ? { op } : {},
|
|
1325
|
+
...txid !== void 0 ? { txid } : {}
|
|
1308
1326
|
};
|
|
1309
1327
|
});
|
|
1310
1328
|
}
|
|
1329
|
+
async upsertPgSyncBridge(row) {
|
|
1330
|
+
await this.db.insert(pgSyncBridges).values({
|
|
1331
|
+
tenantId: this.tenantId,
|
|
1332
|
+
sourceRef: row.sourceRef,
|
|
1333
|
+
options: row.options,
|
|
1334
|
+
streamUrl: row.streamUrl,
|
|
1335
|
+
lastTouchedAt: new Date(),
|
|
1336
|
+
updatedAt: new Date()
|
|
1337
|
+
}).onConflictDoUpdate({
|
|
1338
|
+
target: [pgSyncBridges.tenantId, pgSyncBridges.sourceRef],
|
|
1339
|
+
set: {
|
|
1340
|
+
options: row.options,
|
|
1341
|
+
streamUrl: row.streamUrl,
|
|
1342
|
+
initialSnapshotComplete: false,
|
|
1343
|
+
lastTouchedAt: new Date(),
|
|
1344
|
+
updatedAt: new Date()
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
const existing = await this.getPgSyncBridge(row.sourceRef);
|
|
1348
|
+
if (!existing) throw new Error(`Failed to load pgSync bridge ${row.sourceRef}`);
|
|
1349
|
+
return existing;
|
|
1350
|
+
}
|
|
1351
|
+
async getPgSyncBridge(sourceRef) {
|
|
1352
|
+
const rows = await this.db.select().from(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef)).limit(1);
|
|
1353
|
+
return rows[0] ? this.rowToPgSyncBridge(rows[0]) : null;
|
|
1354
|
+
}
|
|
1355
|
+
async listPgSyncBridges(tenantId = this.tenantId) {
|
|
1356
|
+
const rows = tenantId === null ? await this.db.select().from(pgSyncBridges) : await this.db.select().from(pgSyncBridges).where(eq(pgSyncBridges.tenantId, tenantId));
|
|
1357
|
+
return rows.map((row) => this.rowToPgSyncBridge(row));
|
|
1358
|
+
}
|
|
1359
|
+
async touchPgSyncBridge(sourceRef) {
|
|
1360
|
+
await this.db.update(pgSyncBridges).set({
|
|
1361
|
+
lastTouchedAt: new Date(),
|
|
1362
|
+
updatedAt: new Date()
|
|
1363
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
1364
|
+
}
|
|
1365
|
+
async updatePgSyncBridgeCursor(sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete) {
|
|
1366
|
+
await this.db.update(pgSyncBridges).set({
|
|
1367
|
+
shapeHandle,
|
|
1368
|
+
shapeOffset,
|
|
1369
|
+
...initialSnapshotComplete !== void 0 ? { initialSnapshotComplete } : {},
|
|
1370
|
+
updatedAt: new Date()
|
|
1371
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
1372
|
+
}
|
|
1373
|
+
async clearPgSyncBridgeCursor(sourceRef) {
|
|
1374
|
+
await this.db.update(pgSyncBridges).set({
|
|
1375
|
+
shapeHandle: null,
|
|
1376
|
+
shapeOffset: null,
|
|
1377
|
+
initialSnapshotComplete: false,
|
|
1378
|
+
updatedAt: new Date()
|
|
1379
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
1380
|
+
}
|
|
1311
1381
|
async upsertEntityBridge(row) {
|
|
1312
1382
|
await this.db.insert(entityBridges).values({
|
|
1313
1383
|
tenantId: this.tenantId,
|
|
@@ -1527,6 +1597,20 @@ var PostgresRegistry = class {
|
|
|
1527
1597
|
updated_at: row.updatedAt
|
|
1528
1598
|
};
|
|
1529
1599
|
}
|
|
1600
|
+
rowToPgSyncBridge(row) {
|
|
1601
|
+
return {
|
|
1602
|
+
tenantId: row.tenantId,
|
|
1603
|
+
sourceRef: row.sourceRef,
|
|
1604
|
+
options: row.options,
|
|
1605
|
+
streamUrl: row.streamUrl,
|
|
1606
|
+
shapeHandle: row.shapeHandle ?? void 0,
|
|
1607
|
+
shapeOffset: row.shapeOffset ?? void 0,
|
|
1608
|
+
initialSnapshotComplete: row.initialSnapshotComplete,
|
|
1609
|
+
lastTouchedAt: row.lastTouchedAt,
|
|
1610
|
+
createdAt: row.createdAt,
|
|
1611
|
+
updatedAt: row.updatedAt
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1530
1614
|
rowToEntityBridge(row) {
|
|
1531
1615
|
return {
|
|
1532
1616
|
tenantId: row.tenantId,
|
|
@@ -3174,6 +3258,9 @@ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
|
|
|
3174
3258
|
function isRecord$1(value) {
|
|
3175
3259
|
return typeof value === `object` && value !== null && !Array.isArray(value);
|
|
3176
3260
|
}
|
|
3261
|
+
function getPgSyncManifestStreamPath(sourceRef) {
|
|
3262
|
+
return `/_electric/pg-sync/${sourceRef}`;
|
|
3263
|
+
}
|
|
3177
3264
|
function extractManifestSourceUrl(manifest) {
|
|
3178
3265
|
if (!manifest) return void 0;
|
|
3179
3266
|
if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
|
|
@@ -3186,6 +3273,7 @@ function extractManifestSourceUrl(manifest) {
|
|
|
3186
3273
|
}
|
|
3187
3274
|
if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
|
|
3188
3275
|
if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
|
|
3276
|
+
if (manifest.sourceType === `pgSync`) return typeof manifest.sourceRef === `string` ? getPgSyncManifestStreamPath(manifest.sourceRef) : void 0;
|
|
3189
3277
|
if (manifest.sourceType === `webhook`) {
|
|
3190
3278
|
if (typeof config?.streamUrl === `string`) return config.streamUrl;
|
|
3191
3279
|
if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
|
|
@@ -3300,6 +3388,13 @@ function isRecord(value) {
|
|
|
3300
3388
|
function cloneRecord(value) {
|
|
3301
3389
|
return JSON.parse(JSON.stringify(value));
|
|
3302
3390
|
}
|
|
3391
|
+
function withOptionalTxid(entity, txid) {
|
|
3392
|
+
if (txid === void 0) return entity;
|
|
3393
|
+
return {
|
|
3394
|
+
...entity,
|
|
3395
|
+
txid
|
|
3396
|
+
};
|
|
3397
|
+
}
|
|
3303
3398
|
/**
|
|
3304
3399
|
* Orchestrates the Electric Agents entity lifecycle: register types, spawn, send, kill.
|
|
3305
3400
|
*
|
|
@@ -4441,15 +4536,17 @@ var EntityManager = class {
|
|
|
4441
4536
|
await this.registry.updateStatus(entityUrl, `idle`);
|
|
4442
4537
|
await this.entityBridgeManager?.onEntityChanged(entityUrl);
|
|
4443
4538
|
}
|
|
4539
|
+
const txid = crypto.randomUUID();
|
|
4444
4540
|
const envelope = entityStateSchema.inbox.insert({
|
|
4445
4541
|
key,
|
|
4446
|
-
value
|
|
4542
|
+
value,
|
|
4543
|
+
headers: { txid }
|
|
4447
4544
|
});
|
|
4448
4545
|
const encoded = this.encodeChangeEvent(envelope);
|
|
4449
4546
|
try {
|
|
4450
4547
|
if (opts?.producerId) {
|
|
4451
4548
|
await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
|
|
4452
|
-
return;
|
|
4549
|
+
return { txid };
|
|
4453
4550
|
}
|
|
4454
4551
|
await this.streamClient.append(entity.streams.main, encoded);
|
|
4455
4552
|
if (entity.type === `principal` && req.type === `update_identity`) {
|
|
@@ -4457,9 +4554,11 @@ var EntityManager = class {
|
|
|
4457
4554
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent({
|
|
4458
4555
|
type: `identity`,
|
|
4459
4556
|
key: `self`,
|
|
4460
|
-
value: identity
|
|
4557
|
+
value: identity,
|
|
4558
|
+
headers: { txid }
|
|
4461
4559
|
}));
|
|
4462
4560
|
}
|
|
4561
|
+
return { txid };
|
|
4463
4562
|
} catch (err) {
|
|
4464
4563
|
if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
|
|
4465
4564
|
throw err;
|
|
@@ -4480,18 +4579,26 @@ var EntityManager = class {
|
|
|
4480
4579
|
if (req.status === `cancelled`) value.cancelled_at = now;
|
|
4481
4580
|
}
|
|
4482
4581
|
if (Object.keys(value).length === 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `No inbox fields to update`, 400);
|
|
4582
|
+
const txid = crypto.randomUUID();
|
|
4483
4583
|
const envelope = entityStateSchema.inbox.update({
|
|
4484
4584
|
key,
|
|
4485
|
-
value
|
|
4585
|
+
value,
|
|
4586
|
+
headers: { txid }
|
|
4486
4587
|
});
|
|
4487
4588
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
4589
|
+
return { txid };
|
|
4488
4590
|
}
|
|
4489
4591
|
async deleteInboxMessage(entityUrl, key) {
|
|
4490
4592
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4491
4593
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4492
4594
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4493
|
-
const
|
|
4595
|
+
const txid = crypto.randomUUID();
|
|
4596
|
+
const envelope = entityStateSchema.inbox.delete({
|
|
4597
|
+
key,
|
|
4598
|
+
headers: { txid }
|
|
4599
|
+
});
|
|
4494
4600
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
4601
|
+
return { txid };
|
|
4495
4602
|
}
|
|
4496
4603
|
isAttachmentStreamPath(path$1) {
|
|
4497
4604
|
return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$1);
|
|
@@ -4580,28 +4687,26 @@ var EntityManager = class {
|
|
|
4580
4687
|
await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
|
|
4581
4688
|
return { txid };
|
|
4582
4689
|
}
|
|
4583
|
-
async setTag(entityUrl, key, req
|
|
4690
|
+
async setTag(entityUrl, key, req) {
|
|
4584
4691
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4585
4692
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4586
|
-
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
4587
4693
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4588
4694
|
if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
|
|
4589
4695
|
const result = await this.registry.setEntityTag(entityUrl, key, req.value);
|
|
4590
4696
|
const updated = result.entity;
|
|
4591
4697
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag write`, 500);
|
|
4592
4698
|
if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4593
|
-
return updated;
|
|
4699
|
+
return withOptionalTxid(updated, result.txid);
|
|
4594
4700
|
}
|
|
4595
|
-
async deleteTag(entityUrl, key
|
|
4701
|
+
async deleteTag(entityUrl, key) {
|
|
4596
4702
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4597
4703
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4598
|
-
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
4599
4704
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4600
4705
|
const result = await this.registry.removeEntityTag(entityUrl, key);
|
|
4601
4706
|
const updated = result.entity;
|
|
4602
4707
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
|
|
4603
4708
|
if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4604
|
-
return updated;
|
|
4709
|
+
return withOptionalTxid(updated, result.txid);
|
|
4605
4710
|
}
|
|
4606
4711
|
async ensureEntitiesMembershipStream(tags, principal) {
|
|
4607
4712
|
if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
|
|
@@ -5397,6 +5502,9 @@ function isPermanentElectricAgentsError(err) {
|
|
|
5397
5502
|
const name = typeof err === `object` && err !== null && `name` in err ? err.name : void 0;
|
|
5398
5503
|
return name === `ElectricAgentsError` && typeof status$1 === `number` && status$1 >= 400 && status$1 < 500;
|
|
5399
5504
|
}
|
|
5505
|
+
function cronTaskStreamPath(payload) {
|
|
5506
|
+
return typeof payload.streamPath === `string` ? payload.streamPath : null;
|
|
5507
|
+
}
|
|
5400
5508
|
function normalizeTask(row) {
|
|
5401
5509
|
return {
|
|
5402
5510
|
id: Number(row.id),
|
|
@@ -5739,6 +5847,15 @@ var Scheduler = class {
|
|
|
5739
5847
|
`;
|
|
5740
5848
|
if (completed.length === 0) return;
|
|
5741
5849
|
const nextFireAt = getNextCronFireAt(task.cronExpression, task.cronTimezone, task.fireAt);
|
|
5850
|
+
const streamPath = cronTaskStreamPath(task.payload);
|
|
5851
|
+
const subscriberRows = streamPath ? await sql$1`
|
|
5852
|
+
select 1 as exists
|
|
5853
|
+
from wake_registrations
|
|
5854
|
+
where tenant_id = ${tenantId}
|
|
5855
|
+
and source_url = ${streamPath}
|
|
5856
|
+
limit 1
|
|
5857
|
+
` : [];
|
|
5858
|
+
if (subscriberRows.length === 0) return;
|
|
5742
5859
|
await sql$1`
|
|
5743
5860
|
insert into scheduled_tasks (
|
|
5744
5861
|
tenant_id,
|
|
@@ -5838,6 +5955,308 @@ var Scheduler = class {
|
|
|
5838
5955
|
}
|
|
5839
5956
|
};
|
|
5840
5957
|
|
|
5958
|
+
//#endregion
|
|
5959
|
+
//#region src/pg-sync-bridge-manager.ts
|
|
5960
|
+
const PG_SYNC_ELECTRIC_SHAPE_URL = process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ?? `http://localhost:3000/v1/shape`;
|
|
5961
|
+
const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
|
|
5962
|
+
const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
|
|
5963
|
+
function buildElectricShapeParams(options) {
|
|
5964
|
+
return {
|
|
5965
|
+
table: options.table,
|
|
5966
|
+
...options.columns !== void 0 ? { columns: [...options.columns] } : {},
|
|
5967
|
+
...options.where !== void 0 ? { where: options.where } : {},
|
|
5968
|
+
...options.params !== void 0 ? { params: Array.isArray(options.params) ? [...options.params] : { ...options.params } } : {},
|
|
5969
|
+
...options.replica !== void 0 ? { replica: options.replica } : {},
|
|
5970
|
+
...options.metadata?.tenantId ? { electric_agents_tenant_id: options.metadata.tenantId } : {},
|
|
5971
|
+
...options.metadata?.principalKind ? { electric_agents_principal_kind: options.metadata.principalKind } : {},
|
|
5972
|
+
...options.metadata?.principalId ? { electric_agents_principal_id: options.metadata.principalId } : {},
|
|
5973
|
+
...options.metadata?.principalKey ? { electric_agents_principal_key: options.metadata.principalKey } : {},
|
|
5974
|
+
...options.metadata?.principalUrl ? { electric_agents_principal_url: options.metadata.principalUrl } : {},
|
|
5975
|
+
...options.metadata?.entityUrl ? { electric_agents_entity_url: options.metadata.entityUrl } : {},
|
|
5976
|
+
...options.metadata?.entityType ? { electric_agents_entity_type: options.metadata.entityType } : {},
|
|
5977
|
+
...options.metadata?.streamPath ? { electric_agents_stream_path: options.metadata.streamPath } : {},
|
|
5978
|
+
...options.metadata?.runtimeConsumerId ? { electric_agents_runtime_consumer_id: options.metadata.runtimeConsumerId } : {},
|
|
5979
|
+
...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
|
|
5980
|
+
};
|
|
5981
|
+
}
|
|
5982
|
+
function jsonSafe(value) {
|
|
5983
|
+
if (typeof value === `bigint`) return value.toString();
|
|
5984
|
+
if (value === null || typeof value !== `object`) return value;
|
|
5985
|
+
if (Array.isArray(value)) return value.map(jsonSafe);
|
|
5986
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
|
|
5987
|
+
}
|
|
5988
|
+
function stableJson(value) {
|
|
5989
|
+
if (typeof value === `bigint`) return JSON.stringify(value.toString());
|
|
5990
|
+
if (value === null || typeof value !== `object`) return JSON.stringify(value);
|
|
5991
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`;
|
|
5992
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(`,`)}}`;
|
|
5993
|
+
}
|
|
5994
|
+
function parseElectricOffset(offset) {
|
|
5995
|
+
if (offset === `-1`) return offset;
|
|
5996
|
+
return /^\d+_\d+$/.test(offset) ? offset : null;
|
|
5997
|
+
}
|
|
5998
|
+
function rowKeyForMessage(message) {
|
|
5999
|
+
const headers = message.headers;
|
|
6000
|
+
const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
|
|
6001
|
+
return candidate === void 0 ? void 0 : stableJson(candidate);
|
|
6002
|
+
}
|
|
6003
|
+
function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
|
|
6004
|
+
const operation = message.headers.operation;
|
|
6005
|
+
if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
|
|
6006
|
+
const sourceRef = typeof optionsOrSourceRef === `string` ? optionsOrSourceRef : sourceRefForPgSync(optionsOrSourceRef);
|
|
6007
|
+
const rowKey = rowKeyForMessage(message);
|
|
6008
|
+
const offset = message.headers.offset;
|
|
6009
|
+
if (typeof offset !== `string` || offset.length === 0) return null;
|
|
6010
|
+
const messageKeyPart = offset;
|
|
6011
|
+
const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
|
|
6012
|
+
const timestamp$1 = new Date().toISOString();
|
|
6013
|
+
const oldValue = message.old_value;
|
|
6014
|
+
const safeValue = jsonSafe(message.value);
|
|
6015
|
+
const safeOldValue = jsonSafe(oldValue);
|
|
6016
|
+
const safeHeaders = jsonSafe(message.headers);
|
|
6017
|
+
return {
|
|
6018
|
+
type: `pg_sync_change`,
|
|
6019
|
+
key: messageKey,
|
|
6020
|
+
value: {
|
|
6021
|
+
key: messageKey,
|
|
6022
|
+
table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
|
|
6023
|
+
operation,
|
|
6024
|
+
...rowKey !== void 0 ? { rowKey } : {},
|
|
6025
|
+
...message.value !== void 0 ? { value: safeValue } : {},
|
|
6026
|
+
...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
|
|
6027
|
+
headers: safeHeaders,
|
|
6028
|
+
...typeof offset === `string` ? { offset } : {},
|
|
6029
|
+
receivedAt: timestamp$1
|
|
6030
|
+
},
|
|
6031
|
+
headers: {
|
|
6032
|
+
operation,
|
|
6033
|
+
timestamp: timestamp$1
|
|
6034
|
+
}
|
|
6035
|
+
};
|
|
6036
|
+
}
|
|
6037
|
+
function cursorFromRow(row) {
|
|
6038
|
+
return row?.shapeHandle && row.shapeOffset ? {
|
|
6039
|
+
handle: row.shapeHandle,
|
|
6040
|
+
offset: row.shapeOffset,
|
|
6041
|
+
initialSnapshotComplete: row.initialSnapshotComplete
|
|
6042
|
+
} : void 0;
|
|
6043
|
+
}
|
|
6044
|
+
var PgSyncBridge = class {
|
|
6045
|
+
producer = null;
|
|
6046
|
+
unsubscribe = null;
|
|
6047
|
+
abortController = null;
|
|
6048
|
+
skipChangesUntilUpToDate = false;
|
|
6049
|
+
recovering = false;
|
|
6050
|
+
committedCursor;
|
|
6051
|
+
retryAttempt = 0;
|
|
6052
|
+
constructor(sourceRef, streamUrl, options, resolvedSource, retry, streamClient, registry, evaluateWakes, initialCursor) {
|
|
6053
|
+
this.sourceRef = sourceRef;
|
|
6054
|
+
this.streamUrl = streamUrl;
|
|
6055
|
+
this.options = options;
|
|
6056
|
+
this.resolvedSource = resolvedSource;
|
|
6057
|
+
this.retry = retry;
|
|
6058
|
+
this.streamClient = streamClient;
|
|
6059
|
+
this.registry = registry;
|
|
6060
|
+
this.evaluateWakes = evaluateWakes;
|
|
6061
|
+
this.initialCursor = initialCursor;
|
|
6062
|
+
this.committedCursor = initialCursor;
|
|
6063
|
+
}
|
|
6064
|
+
async start() {
|
|
6065
|
+
if (!this.producer) this.producer = new IdempotentProducer(new DurableStream({
|
|
6066
|
+
url: `${this.streamClient.baseUrl}${this.streamUrl}`,
|
|
6067
|
+
contentType: `application/json`
|
|
6068
|
+
}), `pg-sync-bridge-${this.sourceRef}`);
|
|
6069
|
+
if (this.initialCursor) {
|
|
6070
|
+
const offset = parseElectricOffset(this.initialCursor.offset);
|
|
6071
|
+
if (offset) {
|
|
6072
|
+
this.startStream(offset, this.initialCursor.handle, !this.initialCursor.initialSnapshotComplete);
|
|
6073
|
+
return;
|
|
6074
|
+
}
|
|
6075
|
+
}
|
|
6076
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
6077
|
+
this.startStream(`now`, void 0, true);
|
|
6078
|
+
}
|
|
6079
|
+
async stop() {
|
|
6080
|
+
this.unsubscribe?.();
|
|
6081
|
+
this.abortController?.abort();
|
|
6082
|
+
this.unsubscribe = null;
|
|
6083
|
+
this.abortController = null;
|
|
6084
|
+
try {
|
|
6085
|
+
await this.producer?.flush();
|
|
6086
|
+
} finally {
|
|
6087
|
+
await this.producer?.detach();
|
|
6088
|
+
this.producer = null;
|
|
6089
|
+
}
|
|
6090
|
+
}
|
|
6091
|
+
startStream(offset, handle, skipChangesUntilUpToDate = false, log = offset === `now` ? `changes_only` : `full`) {
|
|
6092
|
+
this.unsubscribe?.();
|
|
6093
|
+
this.abortController?.abort();
|
|
6094
|
+
this.skipChangesUntilUpToDate = skipChangesUntilUpToDate;
|
|
6095
|
+
this.abortController = new AbortController();
|
|
6096
|
+
const stream = new ShapeStream({
|
|
6097
|
+
url: this.resolvedSource.url,
|
|
6098
|
+
params: buildElectricShapeParams(this.options),
|
|
6099
|
+
offset,
|
|
6100
|
+
log,
|
|
6101
|
+
...handle ? { handle } : {},
|
|
6102
|
+
signal: this.abortController.signal
|
|
6103
|
+
});
|
|
6104
|
+
this.unsubscribe = stream.subscribe(async (messages) => {
|
|
6105
|
+
try {
|
|
6106
|
+
for (const message of messages) {
|
|
6107
|
+
if (isControlMessage(message)) {
|
|
6108
|
+
if (message.headers.control === `must-refetch`) {
|
|
6109
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
6110
|
+
this.startStream(`now`, void 0, true);
|
|
6111
|
+
return;
|
|
6112
|
+
}
|
|
6113
|
+
if (message.headers.control === `up-to-date`) {
|
|
6114
|
+
this.skipChangesUntilUpToDate = false;
|
|
6115
|
+
await this.persistCursor(stream, true);
|
|
6116
|
+
continue;
|
|
6117
|
+
}
|
|
6118
|
+
await this.persistCursor(stream);
|
|
6119
|
+
continue;
|
|
6120
|
+
}
|
|
6121
|
+
if (!isChangeMessage(message)) continue;
|
|
6122
|
+
if (!this.skipChangesUntilUpToDate) {
|
|
6123
|
+
const event = pgSyncMessageToDurableEvent(message, this.options);
|
|
6124
|
+
if (event) {
|
|
6125
|
+
if (!this.producer) throw new Error(`pg-sync producer is not started`);
|
|
6126
|
+
await this.producer.append(JSON.stringify(event));
|
|
6127
|
+
await this.producer.flush?.();
|
|
6128
|
+
await this.evaluateWakes?.(this.streamUrl, event);
|
|
6129
|
+
}
|
|
6130
|
+
}
|
|
6131
|
+
await this.persistCursor(stream);
|
|
6132
|
+
this.retryAttempt = 0;
|
|
6133
|
+
}
|
|
6134
|
+
} catch (error) {
|
|
6135
|
+
serverLog.warn(`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`, error);
|
|
6136
|
+
await this.recoverStream();
|
|
6137
|
+
}
|
|
6138
|
+
}, (error) => {
|
|
6139
|
+
if (this.abortController?.signal.aborted) return;
|
|
6140
|
+
serverLog.warn(`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`, error);
|
|
6141
|
+
this.recoverStream();
|
|
6142
|
+
});
|
|
6143
|
+
}
|
|
6144
|
+
async recoverStream() {
|
|
6145
|
+
if (this.recovering) return;
|
|
6146
|
+
this.recovering = true;
|
|
6147
|
+
try {
|
|
6148
|
+
const attempt = this.retryAttempt++;
|
|
6149
|
+
const baseDelay = Math.min(this.retry.initialDelayMs * 2 ** attempt, this.retry.maxDelayMs);
|
|
6150
|
+
const jitter = Math.floor(baseDelay * .2 * this.retry.random());
|
|
6151
|
+
const delay = baseDelay + jitter;
|
|
6152
|
+
if (delay > 0) await this.retry.sleep(delay);
|
|
6153
|
+
const offset = this.committedCursor ? parseElectricOffset(this.committedCursor.offset) : null;
|
|
6154
|
+
if (offset && this.committedCursor) this.startStream(offset, this.committedCursor.handle, !this.committedCursor.initialSnapshotComplete);
|
|
6155
|
+
else {
|
|
6156
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
6157
|
+
this.startStream(`now`, void 0, true);
|
|
6158
|
+
}
|
|
6159
|
+
} finally {
|
|
6160
|
+
this.recovering = false;
|
|
6161
|
+
}
|
|
6162
|
+
}
|
|
6163
|
+
async persistCursor(stream, initialSnapshotComplete = !this.skipChangesUntilUpToDate) {
|
|
6164
|
+
const shapeHandle = stream.shapeHandle;
|
|
6165
|
+
const shapeOffset = stream.lastOffset;
|
|
6166
|
+
if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return;
|
|
6167
|
+
await this.registry?.updatePgSyncBridgeCursor(this.sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete);
|
|
6168
|
+
this.committedCursor = {
|
|
6169
|
+
handle: shapeHandle,
|
|
6170
|
+
offset: shapeOffset,
|
|
6171
|
+
initialSnapshotComplete
|
|
6172
|
+
};
|
|
6173
|
+
}
|
|
6174
|
+
};
|
|
6175
|
+
var PgSyncBridgeManager = class {
|
|
6176
|
+
bridges = new Map();
|
|
6177
|
+
starting = new Map();
|
|
6178
|
+
url;
|
|
6179
|
+
retry;
|
|
6180
|
+
constructor(streamClient, evaluateWakes, registry, options = {}) {
|
|
6181
|
+
this.streamClient = streamClient;
|
|
6182
|
+
this.evaluateWakes = evaluateWakes;
|
|
6183
|
+
this.registry = registry;
|
|
6184
|
+
this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
|
|
6185
|
+
this.retry = {
|
|
6186
|
+
initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
|
|
6187
|
+
maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
|
|
6188
|
+
random: options.retry?.random ?? Math.random,
|
|
6189
|
+
sleep: options.retry?.sleep ?? ((ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)))
|
|
6190
|
+
};
|
|
6191
|
+
}
|
|
6192
|
+
async start() {
|
|
6193
|
+
const rows = await this.registry?.listPgSyncBridges?.();
|
|
6194
|
+
if (!rows) return;
|
|
6195
|
+
await Promise.all(rows.map((row) => this.ensureBridge(row).catch((error) => {
|
|
6196
|
+
serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
|
|
6197
|
+
})));
|
|
6198
|
+
}
|
|
6199
|
+
async register(options, metadata) {
|
|
6200
|
+
const mergedMetadata = {
|
|
6201
|
+
...options.metadata,
|
|
6202
|
+
...metadata
|
|
6203
|
+
};
|
|
6204
|
+
const canonicalOptions = {
|
|
6205
|
+
...canonicalPgSyncOptions(options),
|
|
6206
|
+
...Object.keys(mergedMetadata).length > 0 ? { metadata: mergedMetadata } : {}
|
|
6207
|
+
};
|
|
6208
|
+
const resolvedSource = this.resolveSource(canonicalOptions);
|
|
6209
|
+
const sourceRef = sourceRefForPgSync(canonicalOptions);
|
|
6210
|
+
const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId);
|
|
6211
|
+
const row = await this.registry?.upsertPgSyncBridge({
|
|
6212
|
+
sourceRef,
|
|
6213
|
+
options: canonicalOptions,
|
|
6214
|
+
streamUrl
|
|
6215
|
+
});
|
|
6216
|
+
await this.streamClient.ensure(streamUrl, { contentType: `application/json` });
|
|
6217
|
+
if (!this.bridges.has(sourceRef)) {
|
|
6218
|
+
let start = this.starting.get(sourceRef);
|
|
6219
|
+
if (!start) {
|
|
6220
|
+
start = (async () => {
|
|
6221
|
+
const bridge = new PgSyncBridge(sourceRef, streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
|
|
6222
|
+
await bridge.start();
|
|
6223
|
+
this.bridges.set(sourceRef, bridge);
|
|
6224
|
+
})().finally(() => this.starting.delete(sourceRef));
|
|
6225
|
+
this.starting.set(sourceRef, start);
|
|
6226
|
+
}
|
|
6227
|
+
await start;
|
|
6228
|
+
}
|
|
6229
|
+
return {
|
|
6230
|
+
sourceRef,
|
|
6231
|
+
streamUrl
|
|
6232
|
+
};
|
|
6233
|
+
}
|
|
6234
|
+
async ensureBridge(row) {
|
|
6235
|
+
if (this.bridges.has(row.sourceRef)) return;
|
|
6236
|
+
let start = this.starting.get(row.sourceRef);
|
|
6237
|
+
if (!start) {
|
|
6238
|
+
start = (async () => {
|
|
6239
|
+
await this.streamClient.ensure(row.streamUrl, { contentType: `application/json` });
|
|
6240
|
+
const canonicalOptions = canonicalPgSyncOptions(row.options);
|
|
6241
|
+
const resolvedSource = this.resolveSource(canonicalOptions);
|
|
6242
|
+
const bridge = new PgSyncBridge(row.sourceRef, row.streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
|
|
6243
|
+
await bridge.start();
|
|
6244
|
+
this.bridges.set(row.sourceRef, bridge);
|
|
6245
|
+
})().finally(() => this.starting.delete(row.sourceRef));
|
|
6246
|
+
this.starting.set(row.sourceRef, start);
|
|
6247
|
+
}
|
|
6248
|
+
await start;
|
|
6249
|
+
}
|
|
6250
|
+
resolveSource(options) {
|
|
6251
|
+
return { url: options.url ?? this.url };
|
|
6252
|
+
}
|
|
6253
|
+
async stop() {
|
|
6254
|
+
await Promise.allSettled(this.starting.values());
|
|
6255
|
+
await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()));
|
|
6256
|
+
this.bridges.clear();
|
|
6257
|
+
}
|
|
6258
|
+
};
|
|
6259
|
+
|
|
5841
6260
|
//#endregion
|
|
5842
6261
|
//#region src/runtime.ts
|
|
5843
6262
|
function omitUndefined(value) {
|
|
@@ -5852,6 +6271,7 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
5852
6271
|
wakeRegistry;
|
|
5853
6272
|
scheduler;
|
|
5854
6273
|
entityBridgeManager;
|
|
6274
|
+
pgSyncBridgeManager;
|
|
5855
6275
|
claimWriteTokens;
|
|
5856
6276
|
manager;
|
|
5857
6277
|
constructor(options) {
|
|
@@ -5876,9 +6296,10 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
5876
6296
|
writeTokenValidator: (entity, token) => this.claimWriteTokens.isValid(this.serviceId, entity.streams.main, token),
|
|
5877
6297
|
stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false
|
|
5878
6298
|
});
|
|
6299
|
+
this.pgSyncBridgeManager = options.pgSyncBridgeManager ?? new PgSyncBridgeManager(this.streamClient, (sourceUrl, event) => this.manager.evaluateWakes(sourceUrl, event), this.registry, options.pgSync);
|
|
5879
6300
|
}
|
|
5880
6301
|
async stop() {
|
|
5881
|
-
await this.manager.shutdown();
|
|
6302
|
+
await Promise.all([this.manager.shutdown(), this.pgSyncBridgeManager.stop()]);
|
|
5882
6303
|
}
|
|
5883
6304
|
async rehydrateCronSchedules() {
|
|
5884
6305
|
const rows = await this.db.select({ sourceUrl: wakeRegistrations.sourceUrl }).from(wakeRegistrations).where(eq(wakeRegistrations.tenantId, this.serviceId));
|
|
@@ -6642,7 +7063,10 @@ var WakeRegistry = class {
|
|
|
6642
7063
|
}
|
|
6643
7064
|
if (!isChangeMessage(message)) return;
|
|
6644
7065
|
if (message.headers.operation === `delete`) {
|
|
6645
|
-
|
|
7066
|
+
const oldValue = message.old_value;
|
|
7067
|
+
const oldId = Number(oldValue?.id);
|
|
7068
|
+
if (Number.isFinite(oldId)) this.removeCachedRegistrationByDbId(oldId);
|
|
7069
|
+
else this.resetCachedRegistrations();
|
|
6646
7070
|
return;
|
|
6647
7071
|
}
|
|
6648
7072
|
this.upsertCachedRegistration(this.normalizeShapeRow(message.value));
|
|
@@ -6753,9 +7177,9 @@ var WakeRegistry = class {
|
|
|
6753
7177
|
matchCondition(reg, event) {
|
|
6754
7178
|
if (reg.condition === `runFinished`) {
|
|
6755
7179
|
if (event.type !== `run`) return null;
|
|
6756
|
-
const value = event.value;
|
|
7180
|
+
const value$1 = event.value;
|
|
6757
7181
|
const headers$1 = event.headers;
|
|
6758
|
-
const status$1 = value?.status;
|
|
7182
|
+
const status$1 = value$1?.status;
|
|
6759
7183
|
const operation$1 = headers$1?.operation;
|
|
6760
7184
|
if (operation$1 !== `update`) return null;
|
|
6761
7185
|
if (status$1 !== `completed` && status$1 !== `failed`) return null;
|
|
@@ -6778,13 +7202,15 @@ var WakeRegistry = class {
|
|
|
6778
7202
|
}
|
|
6779
7203
|
const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
|
|
6780
7204
|
if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
|
|
7205
|
+
const value = event.value;
|
|
6781
7206
|
const change = {
|
|
6782
7207
|
collection: eventType,
|
|
6783
7208
|
kind,
|
|
6784
7209
|
key: event.key || ``
|
|
6785
7210
|
};
|
|
7211
|
+
if (value && `value` in value) change.value = value.value;
|
|
7212
|
+
if (value && `oldValue` in value) change.oldValue = value.oldValue;
|
|
6786
7213
|
if (eventType === `inbox`) {
|
|
6787
|
-
const value = event.value;
|
|
6788
7214
|
if (typeof value?.from === `string`) change.from = value.from;
|
|
6789
7215
|
if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
|
|
6790
7216
|
if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
|
|
@@ -7188,6 +7614,22 @@ function resolveDurableStreamsRoutingAdapter(adapter, _durableStreamsUrl) {
|
|
|
7188
7614
|
|
|
7189
7615
|
//#endregion
|
|
7190
7616
|
//#region src/utils/server-utils.ts
|
|
7617
|
+
/**
|
|
7618
|
+
* Raised when an Electric shape proxy request must be rejected for security
|
|
7619
|
+
* reasons (an un-scoped table, or a client `where` clause that could escape the
|
|
7620
|
+
* enforced per-tenant/per-principal scoping). The global `errorMapper` hook
|
|
7621
|
+
* maps this to an HTTP error response. Defined here (rather than reusing
|
|
7622
|
+
* `ElectricAgentsError`) to keep this module free of the heavy entity-manager
|
|
7623
|
+
* import graph.
|
|
7624
|
+
*/
|
|
7625
|
+
var ElectricProxyError = class extends Error {
|
|
7626
|
+
constructor(code, message, status$1) {
|
|
7627
|
+
super(message);
|
|
7628
|
+
this.code = code;
|
|
7629
|
+
this.status = status$1;
|
|
7630
|
+
this.name = `ElectricProxyError`;
|
|
7631
|
+
}
|
|
7632
|
+
};
|
|
7191
7633
|
function buildElectricProxyTarget(options) {
|
|
7192
7634
|
const targetPath = options.incomingUrl.pathname.replace(`/_electric/electric`, ``);
|
|
7193
7635
|
const target = electricUrlWithPath(options.electricUrl, targetPath);
|
|
@@ -7197,7 +7639,12 @@ function buildElectricProxyTarget(options) {
|
|
|
7197
7639
|
applyElectricUrlQueryParams(target, options.electricUrl);
|
|
7198
7640
|
if (targetPath !== `/v1/shape`) return target;
|
|
7199
7641
|
if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
|
|
7200
|
-
const
|
|
7642
|
+
const clientWhere = options.incomingUrl.searchParams.get(`where`);
|
|
7643
|
+
if (clientWhere !== null && !isSelfContainedWhereClause(clientWhere)) throw new ElectricProxyError(`INVALID_WHERE`, `Invalid where clause`, 400);
|
|
7644
|
+
const tableParams = options.incomingUrl.searchParams.getAll(`table`);
|
|
7645
|
+
if (tableParams.length !== 1) throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
|
|
7646
|
+
const table = tableParams[0];
|
|
7647
|
+
target.searchParams.set(`table`, table);
|
|
7201
7648
|
if (table === `entities`) {
|
|
7202
7649
|
target.searchParams.set(`columns`, `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","sandbox","parent","created_by","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`);
|
|
7203
7650
|
applyShapeWhere(target, buildReadableEntitiesWhere({
|
|
@@ -7255,9 +7702,39 @@ function buildElectricProxyTarget(options) {
|
|
|
7255
7702
|
principalKind: options.principalKind ?? ``,
|
|
7256
7703
|
permissionBypass: options.permissionBypass
|
|
7257
7704
|
}));
|
|
7258
|
-
}
|
|
7705
|
+
} else throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
|
|
7259
7706
|
return target;
|
|
7260
7707
|
}
|
|
7708
|
+
/**
|
|
7709
|
+
* Returns true when a client-supplied Electric `where` clause is self-contained:
|
|
7710
|
+
* its parentheses are balanced, never close below the top level, all string
|
|
7711
|
+
* (`'`) and identifier (`"`) literals are terminated, and it contains no SQL
|
|
7712
|
+
* comment markers. Such a clause cannot break out of the `(...)` group it is
|
|
7713
|
+
* wrapped in when AND-combined with the enforced scoping predicate, nor comment
|
|
7714
|
+
* out the trailing paren the proxy appends. Characters inside string/identifier
|
|
7715
|
+
* literals are ignored. Comment markers are rejected unconditionally (even where
|
|
7716
|
+
* harmless) as a conservative defensive measure; dollar-quoted and `E''` strings
|
|
7717
|
+
* are not modeled and only ever cause fail-safe over-rejection, never a bypass.
|
|
7718
|
+
*/
|
|
7719
|
+
function isSelfContainedWhereClause(where) {
|
|
7720
|
+
let depth = 0;
|
|
7721
|
+
let quote = null;
|
|
7722
|
+
for (let i = 0; i < where.length; i++) {
|
|
7723
|
+
const ch = where[i];
|
|
7724
|
+
if (quote !== null) {
|
|
7725
|
+
if (ch === quote) if (where[i + 1] === quote) i++;
|
|
7726
|
+
else quote = null;
|
|
7727
|
+
continue;
|
|
7728
|
+
}
|
|
7729
|
+
if (ch === `'` || ch === `"`) quote = ch;
|
|
7730
|
+
else if (ch === `(`) depth++;
|
|
7731
|
+
else if (ch === `)`) {
|
|
7732
|
+
depth--;
|
|
7733
|
+
if (depth < 0) return false;
|
|
7734
|
+
} else if (ch === `-` && where[i + 1] === `-` || ch === `/` && where[i + 1] === `*`) return false;
|
|
7735
|
+
}
|
|
7736
|
+
return depth === 0 && quote === null;
|
|
7737
|
+
}
|
|
7261
7738
|
function buildReadableEntitiesWhere(options) {
|
|
7262
7739
|
const tenant = sqlStringLiteral(options.tenantId);
|
|
7263
7740
|
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
@@ -8265,7 +8742,7 @@ async function parseAttachmentForm(request) {
|
|
|
8265
8742
|
};
|
|
8266
8743
|
}
|
|
8267
8744
|
function contentDisposition(filename) {
|
|
8268
|
-
const fallback = filename.replace(/["
|
|
8745
|
+
const fallback = filename.replace(/[^\x20-\x7e]|["\\]/g, `_`);
|
|
8269
8746
|
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
8270
8747
|
}
|
|
8271
8748
|
function rejectPrincipalEntityMutation(request, action) {
|
|
@@ -8463,22 +8940,28 @@ async function deleteEventSourceSubscription(request, ctx) {
|
|
|
8463
8940
|
const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
|
|
8464
8941
|
return json(result);
|
|
8465
8942
|
}
|
|
8943
|
+
function tagResponseBody(entity) {
|
|
8944
|
+
const publicEntity = toPublicEntity(entity);
|
|
8945
|
+
if (entity.txid !== void 0) return {
|
|
8946
|
+
...publicEntity,
|
|
8947
|
+
txid: entity.txid
|
|
8948
|
+
};
|
|
8949
|
+
return publicEntity;
|
|
8950
|
+
}
|
|
8466
8951
|
async function setTag(request, ctx) {
|
|
8467
8952
|
const principalMutationError = rejectPrincipalEntityMutation(request, `tag updated`);
|
|
8468
8953
|
if (principalMutationError) return principalMutationError;
|
|
8469
8954
|
const parsed = routeBody(request);
|
|
8470
8955
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8471
|
-
const
|
|
8472
|
-
|
|
8473
|
-
return json(toPublicEntity(updated));
|
|
8956
|
+
const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value });
|
|
8957
|
+
return json(tagResponseBody(updated));
|
|
8474
8958
|
}
|
|
8475
8959
|
async function deleteTag(request, ctx) {
|
|
8476
8960
|
const principalMutationError = rejectPrincipalEntityMutation(request, `tag deleted`);
|
|
8477
8961
|
if (principalMutationError) return principalMutationError;
|
|
8478
8962
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8479
|
-
const
|
|
8480
|
-
|
|
8481
|
-
return json(toPublicEntity(updated));
|
|
8963
|
+
const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey));
|
|
8964
|
+
return json(tagResponseBody(updated));
|
|
8482
8965
|
}
|
|
8483
8966
|
async function forkEntity(request, ctx) {
|
|
8484
8967
|
const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
|
|
@@ -8545,9 +9028,12 @@ async function sendEntity(request, ctx) {
|
|
|
8545
9028
|
mode: parsed.mode,
|
|
8546
9029
|
position: parsed.position
|
|
8547
9030
|
};
|
|
8548
|
-
if (parsed.afterMs && parsed.afterMs > 0)
|
|
8549
|
-
|
|
8550
|
-
|
|
9031
|
+
if (parsed.afterMs && parsed.afterMs > 0) {
|
|
9032
|
+
await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
|
|
9033
|
+
return status(204);
|
|
9034
|
+
}
|
|
9035
|
+
const result = await ctx.entityManager.send(entityUrl, sendReq);
|
|
9036
|
+
return json(result);
|
|
8551
9037
|
}
|
|
8552
9038
|
async function createAttachment(request, ctx) {
|
|
8553
9039
|
const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
|
|
@@ -8590,13 +9076,13 @@ async function deleteAttachment(request, ctx) {
|
|
|
8590
9076
|
async function updateInboxMessage(request, ctx) {
|
|
8591
9077
|
const parsed = routeBody(request);
|
|
8592
9078
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8593
|
-
await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
|
|
8594
|
-
return
|
|
9079
|
+
const result = await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
|
|
9080
|
+
return json(result);
|
|
8595
9081
|
}
|
|
8596
9082
|
async function deleteInboxMessage(request, ctx) {
|
|
8597
9083
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8598
|
-
await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
|
|
8599
|
-
return
|
|
9084
|
+
const result = await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
|
|
9085
|
+
return json(result);
|
|
8600
9086
|
}
|
|
8601
9087
|
async function spawnEntity(request, ctx) {
|
|
8602
9088
|
const parsed = routeBody(request);
|
|
@@ -8867,6 +9353,49 @@ function toPublicEntityType(entityType) {
|
|
|
8867
9353
|
};
|
|
8868
9354
|
}
|
|
8869
9355
|
|
|
9356
|
+
//#endregion
|
|
9357
|
+
//#region src/routing/pg-sync-router.ts
|
|
9358
|
+
const pgSyncOptionsSchema = Type.Object({
|
|
9359
|
+
url: Type.Optional(Type.String()),
|
|
9360
|
+
table: Type.String(),
|
|
9361
|
+
columns: Type.Optional(Type.Array(Type.String())),
|
|
9362
|
+
where: Type.Optional(Type.String()),
|
|
9363
|
+
params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
|
|
9364
|
+
replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)]))
|
|
9365
|
+
});
|
|
9366
|
+
const pgSyncRequestMetadataSchema = Type.Object({
|
|
9367
|
+
entityUrl: Type.Optional(Type.String()),
|
|
9368
|
+
entityType: Type.Optional(Type.String()),
|
|
9369
|
+
streamPath: Type.Optional(Type.String()),
|
|
9370
|
+
runtimeConsumerId: Type.Optional(Type.String()),
|
|
9371
|
+
wakeId: Type.Optional(Type.String())
|
|
9372
|
+
});
|
|
9373
|
+
const pgSyncRegisterBodySchema = Type.Object({
|
|
9374
|
+
options: pgSyncOptionsSchema,
|
|
9375
|
+
metadata: Type.Optional(pgSyncRequestMetadataSchema)
|
|
9376
|
+
});
|
|
9377
|
+
const pgSyncRouter = Router({ base: `/_electric/pg-sync` });
|
|
9378
|
+
pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
|
|
9379
|
+
async function registerPgSync(request, ctx) {
|
|
9380
|
+
const { options, metadata } = routeBody(request);
|
|
9381
|
+
if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
|
|
9382
|
+
if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
|
|
9383
|
+
try {
|
|
9384
|
+
const requestMetadata$1 = {
|
|
9385
|
+
tenantId: ctx.service,
|
|
9386
|
+
principalKind: ctx.principal.kind,
|
|
9387
|
+
principalId: ctx.principal.id,
|
|
9388
|
+
principalKey: ctx.principal.key,
|
|
9389
|
+
principalUrl: ctx.principal.url,
|
|
9390
|
+
...metadata ?? {}
|
|
9391
|
+
};
|
|
9392
|
+
const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
|
|
9393
|
+
return json(result);
|
|
9394
|
+
} catch (error) {
|
|
9395
|
+
return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
9396
|
+
}
|
|
9397
|
+
}
|
|
9398
|
+
|
|
8870
9399
|
//#endregion
|
|
8871
9400
|
//#region src/routing/hooks.ts
|
|
8872
9401
|
const SPAN_KEY = Symbol(`agents-server.otel-span`);
|
|
@@ -8941,6 +9470,10 @@ function errorMapper(err, req) {
|
|
|
8941
9470
|
});
|
|
8942
9471
|
}
|
|
8943
9472
|
if (err instanceof ElectricAgentsError) return apiError(err.status, err.code, err.message, err.details);
|
|
9473
|
+
if (err instanceof ElectricProxyError) {
|
|
9474
|
+
serverLog.warn(`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`);
|
|
9475
|
+
return apiError(err.status, err.code, err.message);
|
|
9476
|
+
}
|
|
8944
9477
|
serverLog.error(`[agent-server] Unhandled error:`, err);
|
|
8945
9478
|
return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`);
|
|
8946
9479
|
}
|
|
@@ -9391,6 +9924,7 @@ internalRouter.all(`/runners`, runnersRouter.fetch);
|
|
|
9391
9924
|
internalRouter.all(`/runners/*`, runnersRouter.fetch);
|
|
9392
9925
|
internalRouter.all(`/entities/*`, entitiesRouter.fetch);
|
|
9393
9926
|
internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch);
|
|
9927
|
+
internalRouter.all(`/pg-sync/*`, pgSyncRouter.fetch);
|
|
9394
9928
|
internalRouter.all(`/observations/*`, observationsRouter.fetch);
|
|
9395
9929
|
internalRouter.get(`/electric/*`, electricProxyRouter.fetch);
|
|
9396
9930
|
internalRouter.all(`*`, () => status(404));
|
|
@@ -9774,6 +10308,9 @@ const globalRouter = AutoRouter({
|
|
|
9774
10308
|
finally: [otelEndSpan, applyCors]
|
|
9775
10309
|
});
|
|
9776
10310
|
globalRouter.all(`/_electric/shared-state/*`, durableStreamsRouter.fetch);
|
|
10311
|
+
globalRouter.all(`/_electric/pg-sync/register`, internalRouter.fetch);
|
|
10312
|
+
globalRouter.get(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
|
|
10313
|
+
globalRouter.head(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
|
|
9777
10314
|
globalRouter.all(`/_electric/*`, internalRouter.fetch);
|
|
9778
10315
|
globalRouter.all(`*`, durableStreamsRouter.fetch);
|
|
9779
10316
|
|