@electric-ax/agents-server 0.4.18 → 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 +590 -40
- package/dist/index.cjs +576 -36
- package/dist/index.d.cts +290 -40
- package/dist/index.d.ts +290 -40
- package/dist/index.js +577 -37
- 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/durable-streams-router.ts +13 -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.cjs
CHANGED
|
@@ -61,6 +61,7 @@ __export(schema_exports, {
|
|
|
61
61
|
entityPermissionGrants: () => entityPermissionGrants,
|
|
62
62
|
entityTypePermissionGrants: () => entityTypePermissionGrants,
|
|
63
63
|
entityTypes: () => entityTypes,
|
|
64
|
+
pgSyncBridges: () => pgSyncBridges,
|
|
64
65
|
runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
|
|
65
66
|
runners: () => runners,
|
|
66
67
|
scheduledTasks: () => scheduledTasks,
|
|
@@ -382,6 +383,18 @@ const scheduledTasks = (0, drizzle_orm_pg_core.pgTable)(`scheduled_tasks`, {
|
|
|
382
383
|
(0, drizzle_orm_pg_core.index)(`idx_scheduled_tasks_manifest_pending`).on(table.tenantId, table.ownerEntityUrl, table.manifestKey).where(drizzle_orm.sql`${table.kind} = 'delayed_send' AND ${table.completedAt} IS NULL AND ${table.manifestKey} IS NOT NULL`),
|
|
383
384
|
(0, drizzle_orm_pg_core.index)(`idx_scheduled_tasks_stale_claims`).on(table.tenantId, table.claimedAt).where(drizzle_orm.sql`${table.completedAt} IS NULL AND ${table.claimedAt} IS NOT NULL`)
|
|
384
385
|
]);
|
|
386
|
+
const pgSyncBridges = (0, drizzle_orm_pg_core.pgTable)(`pg_sync_bridges`, {
|
|
387
|
+
tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
|
|
388
|
+
sourceRef: (0, drizzle_orm_pg_core.text)(`source_ref`).notNull(),
|
|
389
|
+
options: (0, drizzle_orm_pg_core.jsonb)(`options`).notNull(),
|
|
390
|
+
streamUrl: (0, drizzle_orm_pg_core.text)(`stream_url`).notNull(),
|
|
391
|
+
shapeHandle: (0, drizzle_orm_pg_core.text)(`shape_handle`),
|
|
392
|
+
shapeOffset: (0, drizzle_orm_pg_core.text)(`shape_offset`),
|
|
393
|
+
initialSnapshotComplete: (0, drizzle_orm_pg_core.boolean)(`initial_snapshot_complete`).notNull().default(false),
|
|
394
|
+
lastTouchedAt: (0, drizzle_orm_pg_core.timestamp)(`last_touched_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
395
|
+
createdAt: (0, drizzle_orm_pg_core.timestamp)(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
396
|
+
updatedAt: (0, drizzle_orm_pg_core.timestamp)(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
397
|
+
}, (table) => [(0, drizzle_orm_pg_core.primaryKey)({ columns: [table.tenantId, table.sourceRef] }), (0, drizzle_orm_pg_core.unique)(`uq_pg_sync_bridges_stream_url`).on(table.tenantId, table.streamUrl)]);
|
|
385
398
|
const entityBridges = (0, drizzle_orm_pg_core.pgTable)(`entity_bridges`, {
|
|
386
399
|
tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
|
|
387
400
|
sourceRef: (0, drizzle_orm_pg_core.text)(`source_ref`).notNull(),
|
|
@@ -842,6 +855,9 @@ var PostgresRegistry = class {
|
|
|
842
855
|
entityBridgeWhere(sourceRef) {
|
|
843
856
|
return (0, drizzle_orm.and)((0, drizzle_orm.eq)(entityBridges.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityBridges.sourceRef, sourceRef));
|
|
844
857
|
}
|
|
858
|
+
pgSyncBridgeWhere(sourceRef) {
|
|
859
|
+
return (0, drizzle_orm.and)((0, drizzle_orm.eq)(pgSyncBridges.tenantId, this.tenantId), (0, drizzle_orm.eq)(pgSyncBridges.sourceRef, sourceRef));
|
|
860
|
+
}
|
|
845
861
|
async createEntityType(et) {
|
|
846
862
|
await this.db.insert(entityTypes).values({
|
|
847
863
|
tenantId: this.tenantId,
|
|
@@ -1311,11 +1327,12 @@ var PostgresRegistry = class {
|
|
|
1311
1327
|
};
|
|
1312
1328
|
const nextTags = (0, __electric_ax_agents_runtime.normalizeTags)(mutation.nextTags);
|
|
1313
1329
|
const updatedAt = Date.now();
|
|
1314
|
-
await tx.update(entities).set({
|
|
1330
|
+
const [updateResult] = await tx.update(entities).set({
|
|
1315
1331
|
tags: nextTags,
|
|
1316
1332
|
tagsIndex: (0, __electric_ax_agents_runtime.buildTagsIndex)(nextTags),
|
|
1317
1333
|
updatedAt
|
|
1318
|
-
}).where(this.entityWhere(url));
|
|
1334
|
+
}).where(this.entityWhere(url)).returning({ txid: drizzle_orm.sql`pg_current_xact_id()::xid::text` });
|
|
1335
|
+
const txid = updateResult ? parseInt(updateResult.txid) : void 0;
|
|
1319
1336
|
await tx.insert(tagStreamOutbox).values({
|
|
1320
1337
|
tenantId: this.tenantId,
|
|
1321
1338
|
entityUrl: url,
|
|
@@ -1333,10 +1350,63 @@ var PostgresRegistry = class {
|
|
|
1333
1350
|
return {
|
|
1334
1351
|
entity,
|
|
1335
1352
|
changed: true,
|
|
1336
|
-
...op === `insert` || op === `update` ? { op } : {}
|
|
1353
|
+
...op === `insert` || op === `update` ? { op } : {},
|
|
1354
|
+
...txid !== void 0 ? { txid } : {}
|
|
1337
1355
|
};
|
|
1338
1356
|
});
|
|
1339
1357
|
}
|
|
1358
|
+
async upsertPgSyncBridge(row) {
|
|
1359
|
+
await this.db.insert(pgSyncBridges).values({
|
|
1360
|
+
tenantId: this.tenantId,
|
|
1361
|
+
sourceRef: row.sourceRef,
|
|
1362
|
+
options: row.options,
|
|
1363
|
+
streamUrl: row.streamUrl,
|
|
1364
|
+
lastTouchedAt: new Date(),
|
|
1365
|
+
updatedAt: new Date()
|
|
1366
|
+
}).onConflictDoUpdate({
|
|
1367
|
+
target: [pgSyncBridges.tenantId, pgSyncBridges.sourceRef],
|
|
1368
|
+
set: {
|
|
1369
|
+
options: row.options,
|
|
1370
|
+
streamUrl: row.streamUrl,
|
|
1371
|
+
initialSnapshotComplete: false,
|
|
1372
|
+
lastTouchedAt: new Date(),
|
|
1373
|
+
updatedAt: new Date()
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
const existing = await this.getPgSyncBridge(row.sourceRef);
|
|
1377
|
+
if (!existing) throw new Error(`Failed to load pgSync bridge ${row.sourceRef}`);
|
|
1378
|
+
return existing;
|
|
1379
|
+
}
|
|
1380
|
+
async getPgSyncBridge(sourceRef) {
|
|
1381
|
+
const rows = await this.db.select().from(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef)).limit(1);
|
|
1382
|
+
return rows[0] ? this.rowToPgSyncBridge(rows[0]) : null;
|
|
1383
|
+
}
|
|
1384
|
+
async listPgSyncBridges(tenantId = this.tenantId) {
|
|
1385
|
+
const rows = tenantId === null ? await this.db.select().from(pgSyncBridges) : await this.db.select().from(pgSyncBridges).where((0, drizzle_orm.eq)(pgSyncBridges.tenantId, tenantId));
|
|
1386
|
+
return rows.map((row) => this.rowToPgSyncBridge(row));
|
|
1387
|
+
}
|
|
1388
|
+
async touchPgSyncBridge(sourceRef) {
|
|
1389
|
+
await this.db.update(pgSyncBridges).set({
|
|
1390
|
+
lastTouchedAt: new Date(),
|
|
1391
|
+
updatedAt: new Date()
|
|
1392
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
1393
|
+
}
|
|
1394
|
+
async updatePgSyncBridgeCursor(sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete) {
|
|
1395
|
+
await this.db.update(pgSyncBridges).set({
|
|
1396
|
+
shapeHandle,
|
|
1397
|
+
shapeOffset,
|
|
1398
|
+
...initialSnapshotComplete !== void 0 ? { initialSnapshotComplete } : {},
|
|
1399
|
+
updatedAt: new Date()
|
|
1400
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
1401
|
+
}
|
|
1402
|
+
async clearPgSyncBridgeCursor(sourceRef) {
|
|
1403
|
+
await this.db.update(pgSyncBridges).set({
|
|
1404
|
+
shapeHandle: null,
|
|
1405
|
+
shapeOffset: null,
|
|
1406
|
+
initialSnapshotComplete: false,
|
|
1407
|
+
updatedAt: new Date()
|
|
1408
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
1409
|
+
}
|
|
1340
1410
|
async upsertEntityBridge(row) {
|
|
1341
1411
|
await this.db.insert(entityBridges).values({
|
|
1342
1412
|
tenantId: this.tenantId,
|
|
@@ -1556,6 +1626,20 @@ var PostgresRegistry = class {
|
|
|
1556
1626
|
updated_at: row.updatedAt
|
|
1557
1627
|
};
|
|
1558
1628
|
}
|
|
1629
|
+
rowToPgSyncBridge(row) {
|
|
1630
|
+
return {
|
|
1631
|
+
tenantId: row.tenantId,
|
|
1632
|
+
sourceRef: row.sourceRef,
|
|
1633
|
+
options: row.options,
|
|
1634
|
+
streamUrl: row.streamUrl,
|
|
1635
|
+
shapeHandle: row.shapeHandle ?? void 0,
|
|
1636
|
+
shapeOffset: row.shapeOffset ?? void 0,
|
|
1637
|
+
initialSnapshotComplete: row.initialSnapshotComplete,
|
|
1638
|
+
lastTouchedAt: row.lastTouchedAt,
|
|
1639
|
+
createdAt: row.createdAt,
|
|
1640
|
+
updatedAt: row.updatedAt
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1559
1643
|
rowToEntityBridge(row) {
|
|
1560
1644
|
return {
|
|
1561
1645
|
tenantId: row.tenantId,
|
|
@@ -3203,6 +3287,9 @@ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
|
|
|
3203
3287
|
function isRecord$1(value) {
|
|
3204
3288
|
return typeof value === `object` && value !== null && !Array.isArray(value);
|
|
3205
3289
|
}
|
|
3290
|
+
function getPgSyncManifestStreamPath(sourceRef) {
|
|
3291
|
+
return `/_electric/pg-sync/${sourceRef}`;
|
|
3292
|
+
}
|
|
3206
3293
|
function extractManifestSourceUrl(manifest) {
|
|
3207
3294
|
if (!manifest) return void 0;
|
|
3208
3295
|
if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
|
|
@@ -3215,6 +3302,7 @@ function extractManifestSourceUrl(manifest) {
|
|
|
3215
3302
|
}
|
|
3216
3303
|
if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
|
|
3217
3304
|
if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(manifest.sourceRef) : void 0;
|
|
3305
|
+
if (manifest.sourceType === `pgSync`) return typeof manifest.sourceRef === `string` ? getPgSyncManifestStreamPath(manifest.sourceRef) : void 0;
|
|
3218
3306
|
if (manifest.sourceType === `webhook`) {
|
|
3219
3307
|
if (typeof config?.streamUrl === `string`) return config.streamUrl;
|
|
3220
3308
|
if (typeof config?.endpointKey === `string`) return (0, __electric_ax_agents_runtime.getWebhookStreamPath)(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
|
|
@@ -3329,6 +3417,13 @@ function isRecord(value) {
|
|
|
3329
3417
|
function cloneRecord(value) {
|
|
3330
3418
|
return JSON.parse(JSON.stringify(value));
|
|
3331
3419
|
}
|
|
3420
|
+
function withOptionalTxid(entity, txid) {
|
|
3421
|
+
if (txid === void 0) return entity;
|
|
3422
|
+
return {
|
|
3423
|
+
...entity,
|
|
3424
|
+
txid
|
|
3425
|
+
};
|
|
3426
|
+
}
|
|
3332
3427
|
/**
|
|
3333
3428
|
* Orchestrates the Electric Agents entity lifecycle: register types, spawn, send, kill.
|
|
3334
3429
|
*
|
|
@@ -4470,15 +4565,17 @@ var EntityManager = class {
|
|
|
4470
4565
|
await this.registry.updateStatus(entityUrl, `idle`);
|
|
4471
4566
|
await this.entityBridgeManager?.onEntityChanged(entityUrl);
|
|
4472
4567
|
}
|
|
4568
|
+
const txid = crypto.randomUUID();
|
|
4473
4569
|
const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.insert({
|
|
4474
4570
|
key,
|
|
4475
|
-
value
|
|
4571
|
+
value,
|
|
4572
|
+
headers: { txid }
|
|
4476
4573
|
});
|
|
4477
4574
|
const encoded = this.encodeChangeEvent(envelope);
|
|
4478
4575
|
try {
|
|
4479
4576
|
if (opts?.producerId) {
|
|
4480
4577
|
await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
|
|
4481
|
-
return;
|
|
4578
|
+
return { txid };
|
|
4482
4579
|
}
|
|
4483
4580
|
await this.streamClient.append(entity.streams.main, encoded);
|
|
4484
4581
|
if (entity.type === `principal` && req.type === `update_identity`) {
|
|
@@ -4486,9 +4583,11 @@ var EntityManager = class {
|
|
|
4486
4583
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent({
|
|
4487
4584
|
type: `identity`,
|
|
4488
4585
|
key: `self`,
|
|
4489
|
-
value: identity
|
|
4586
|
+
value: identity,
|
|
4587
|
+
headers: { txid }
|
|
4490
4588
|
}));
|
|
4491
4589
|
}
|
|
4590
|
+
return { txid };
|
|
4492
4591
|
} catch (err) {
|
|
4493
4592
|
if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
|
|
4494
4593
|
throw err;
|
|
@@ -4509,18 +4608,26 @@ var EntityManager = class {
|
|
|
4509
4608
|
if (req.status === `cancelled`) value.cancelled_at = now;
|
|
4510
4609
|
}
|
|
4511
4610
|
if (Object.keys(value).length === 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `No inbox fields to update`, 400);
|
|
4611
|
+
const txid = crypto.randomUUID();
|
|
4512
4612
|
const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.update({
|
|
4513
4613
|
key,
|
|
4514
|
-
value
|
|
4614
|
+
value,
|
|
4615
|
+
headers: { txid }
|
|
4515
4616
|
});
|
|
4516
4617
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
4618
|
+
return { txid };
|
|
4517
4619
|
}
|
|
4518
4620
|
async deleteInboxMessage(entityUrl, key) {
|
|
4519
4621
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4520
4622
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4521
4623
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4522
|
-
const
|
|
4624
|
+
const txid = crypto.randomUUID();
|
|
4625
|
+
const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.delete({
|
|
4626
|
+
key,
|
|
4627
|
+
headers: { txid }
|
|
4628
|
+
});
|
|
4523
4629
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
4630
|
+
return { txid };
|
|
4524
4631
|
}
|
|
4525
4632
|
isAttachmentStreamPath(path$2) {
|
|
4526
4633
|
return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$2);
|
|
@@ -4609,28 +4716,26 @@ var EntityManager = class {
|
|
|
4609
4716
|
await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
|
|
4610
4717
|
return { txid };
|
|
4611
4718
|
}
|
|
4612
|
-
async setTag(entityUrl, key, req
|
|
4719
|
+
async setTag(entityUrl, key, req) {
|
|
4613
4720
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4614
4721
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4615
|
-
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
4616
4722
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4617
4723
|
if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
|
|
4618
4724
|
const result = await this.registry.setEntityTag(entityUrl, key, req.value);
|
|
4619
4725
|
const updated = result.entity;
|
|
4620
4726
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag write`, 500);
|
|
4621
4727
|
if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4622
|
-
return updated;
|
|
4728
|
+
return withOptionalTxid(updated, result.txid);
|
|
4623
4729
|
}
|
|
4624
|
-
async deleteTag(entityUrl, key
|
|
4730
|
+
async deleteTag(entityUrl, key) {
|
|
4625
4731
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4626
4732
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4627
|
-
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
4628
4733
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4629
4734
|
const result = await this.registry.removeEntityTag(entityUrl, key);
|
|
4630
4735
|
const updated = result.entity;
|
|
4631
4736
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
|
|
4632
4737
|
if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4633
|
-
return updated;
|
|
4738
|
+
return withOptionalTxid(updated, result.txid);
|
|
4634
4739
|
}
|
|
4635
4740
|
async ensureEntitiesMembershipStream(tags, principal) {
|
|
4636
4741
|
if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
|
|
@@ -5426,6 +5531,9 @@ function isPermanentElectricAgentsError(err) {
|
|
|
5426
5531
|
const name = typeof err === `object` && err !== null && `name` in err ? err.name : void 0;
|
|
5427
5532
|
return name === `ElectricAgentsError` && typeof status$4 === `number` && status$4 >= 400 && status$4 < 500;
|
|
5428
5533
|
}
|
|
5534
|
+
function cronTaskStreamPath(payload) {
|
|
5535
|
+
return typeof payload.streamPath === `string` ? payload.streamPath : null;
|
|
5536
|
+
}
|
|
5429
5537
|
function normalizeTask(row) {
|
|
5430
5538
|
return {
|
|
5431
5539
|
id: Number(row.id),
|
|
@@ -5768,6 +5876,15 @@ var Scheduler = class {
|
|
|
5768
5876
|
`;
|
|
5769
5877
|
if (completed.length === 0) return;
|
|
5770
5878
|
const nextFireAt = (0, __electric_ax_agents_runtime.getNextCronFireAt)(task.cronExpression, task.cronTimezone, task.fireAt);
|
|
5879
|
+
const streamPath = cronTaskStreamPath(task.payload);
|
|
5880
|
+
const subscriberRows = streamPath ? await sql$2`
|
|
5881
|
+
select 1 as exists
|
|
5882
|
+
from wake_registrations
|
|
5883
|
+
where tenant_id = ${tenantId}
|
|
5884
|
+
and source_url = ${streamPath}
|
|
5885
|
+
limit 1
|
|
5886
|
+
` : [];
|
|
5887
|
+
if (subscriberRows.length === 0) return;
|
|
5771
5888
|
await sql$2`
|
|
5772
5889
|
insert into scheduled_tasks (
|
|
5773
5890
|
tenant_id,
|
|
@@ -5867,6 +5984,308 @@ var Scheduler = class {
|
|
|
5867
5984
|
}
|
|
5868
5985
|
};
|
|
5869
5986
|
|
|
5987
|
+
//#endregion
|
|
5988
|
+
//#region src/pg-sync-bridge-manager.ts
|
|
5989
|
+
const PG_SYNC_ELECTRIC_SHAPE_URL = process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ?? `http://localhost:3000/v1/shape`;
|
|
5990
|
+
const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
|
|
5991
|
+
const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
|
|
5992
|
+
function buildElectricShapeParams(options) {
|
|
5993
|
+
return {
|
|
5994
|
+
table: options.table,
|
|
5995
|
+
...options.columns !== void 0 ? { columns: [...options.columns] } : {},
|
|
5996
|
+
...options.where !== void 0 ? { where: options.where } : {},
|
|
5997
|
+
...options.params !== void 0 ? { params: Array.isArray(options.params) ? [...options.params] : { ...options.params } } : {},
|
|
5998
|
+
...options.replica !== void 0 ? { replica: options.replica } : {},
|
|
5999
|
+
...options.metadata?.tenantId ? { electric_agents_tenant_id: options.metadata.tenantId } : {},
|
|
6000
|
+
...options.metadata?.principalKind ? { electric_agents_principal_kind: options.metadata.principalKind } : {},
|
|
6001
|
+
...options.metadata?.principalId ? { electric_agents_principal_id: options.metadata.principalId } : {},
|
|
6002
|
+
...options.metadata?.principalKey ? { electric_agents_principal_key: options.metadata.principalKey } : {},
|
|
6003
|
+
...options.metadata?.principalUrl ? { electric_agents_principal_url: options.metadata.principalUrl } : {},
|
|
6004
|
+
...options.metadata?.entityUrl ? { electric_agents_entity_url: options.metadata.entityUrl } : {},
|
|
6005
|
+
...options.metadata?.entityType ? { electric_agents_entity_type: options.metadata.entityType } : {},
|
|
6006
|
+
...options.metadata?.streamPath ? { electric_agents_stream_path: options.metadata.streamPath } : {},
|
|
6007
|
+
...options.metadata?.runtimeConsumerId ? { electric_agents_runtime_consumer_id: options.metadata.runtimeConsumerId } : {},
|
|
6008
|
+
...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
|
|
6009
|
+
};
|
|
6010
|
+
}
|
|
6011
|
+
function jsonSafe(value) {
|
|
6012
|
+
if (typeof value === `bigint`) return value.toString();
|
|
6013
|
+
if (value === null || typeof value !== `object`) return value;
|
|
6014
|
+
if (Array.isArray(value)) return value.map(jsonSafe);
|
|
6015
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
|
|
6016
|
+
}
|
|
6017
|
+
function stableJson(value) {
|
|
6018
|
+
if (typeof value === `bigint`) return JSON.stringify(value.toString());
|
|
6019
|
+
if (value === null || typeof value !== `object`) return JSON.stringify(value);
|
|
6020
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`;
|
|
6021
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(`,`)}}`;
|
|
6022
|
+
}
|
|
6023
|
+
function parseElectricOffset(offset) {
|
|
6024
|
+
if (offset === `-1`) return offset;
|
|
6025
|
+
return /^\d+_\d+$/.test(offset) ? offset : null;
|
|
6026
|
+
}
|
|
6027
|
+
function rowKeyForMessage(message) {
|
|
6028
|
+
const headers = message.headers;
|
|
6029
|
+
const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
|
|
6030
|
+
return candidate === void 0 ? void 0 : stableJson(candidate);
|
|
6031
|
+
}
|
|
6032
|
+
function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
|
|
6033
|
+
const operation = message.headers.operation;
|
|
6034
|
+
if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
|
|
6035
|
+
const sourceRef = typeof optionsOrSourceRef === `string` ? optionsOrSourceRef : (0, __electric_ax_agents_runtime.sourceRefForPgSync)(optionsOrSourceRef);
|
|
6036
|
+
const rowKey = rowKeyForMessage(message);
|
|
6037
|
+
const offset = message.headers.offset;
|
|
6038
|
+
if (typeof offset !== `string` || offset.length === 0) return null;
|
|
6039
|
+
const messageKeyPart = offset;
|
|
6040
|
+
const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
|
|
6041
|
+
const timestamp$1 = new Date().toISOString();
|
|
6042
|
+
const oldValue = message.old_value;
|
|
6043
|
+
const safeValue = jsonSafe(message.value);
|
|
6044
|
+
const safeOldValue = jsonSafe(oldValue);
|
|
6045
|
+
const safeHeaders = jsonSafe(message.headers);
|
|
6046
|
+
return {
|
|
6047
|
+
type: `pg_sync_change`,
|
|
6048
|
+
key: messageKey,
|
|
6049
|
+
value: {
|
|
6050
|
+
key: messageKey,
|
|
6051
|
+
table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
|
|
6052
|
+
operation,
|
|
6053
|
+
...rowKey !== void 0 ? { rowKey } : {},
|
|
6054
|
+
...message.value !== void 0 ? { value: safeValue } : {},
|
|
6055
|
+
...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
|
|
6056
|
+
headers: safeHeaders,
|
|
6057
|
+
...typeof offset === `string` ? { offset } : {},
|
|
6058
|
+
receivedAt: timestamp$1
|
|
6059
|
+
},
|
|
6060
|
+
headers: {
|
|
6061
|
+
operation,
|
|
6062
|
+
timestamp: timestamp$1
|
|
6063
|
+
}
|
|
6064
|
+
};
|
|
6065
|
+
}
|
|
6066
|
+
function cursorFromRow(row) {
|
|
6067
|
+
return row?.shapeHandle && row.shapeOffset ? {
|
|
6068
|
+
handle: row.shapeHandle,
|
|
6069
|
+
offset: row.shapeOffset,
|
|
6070
|
+
initialSnapshotComplete: row.initialSnapshotComplete
|
|
6071
|
+
} : void 0;
|
|
6072
|
+
}
|
|
6073
|
+
var PgSyncBridge = class {
|
|
6074
|
+
producer = null;
|
|
6075
|
+
unsubscribe = null;
|
|
6076
|
+
abortController = null;
|
|
6077
|
+
skipChangesUntilUpToDate = false;
|
|
6078
|
+
recovering = false;
|
|
6079
|
+
committedCursor;
|
|
6080
|
+
retryAttempt = 0;
|
|
6081
|
+
constructor(sourceRef, streamUrl, options, resolvedSource, retry, streamClient, registry, evaluateWakes, initialCursor) {
|
|
6082
|
+
this.sourceRef = sourceRef;
|
|
6083
|
+
this.streamUrl = streamUrl;
|
|
6084
|
+
this.options = options;
|
|
6085
|
+
this.resolvedSource = resolvedSource;
|
|
6086
|
+
this.retry = retry;
|
|
6087
|
+
this.streamClient = streamClient;
|
|
6088
|
+
this.registry = registry;
|
|
6089
|
+
this.evaluateWakes = evaluateWakes;
|
|
6090
|
+
this.initialCursor = initialCursor;
|
|
6091
|
+
this.committedCursor = initialCursor;
|
|
6092
|
+
}
|
|
6093
|
+
async start() {
|
|
6094
|
+
if (!this.producer) this.producer = new __durable_streams_client.IdempotentProducer(new __durable_streams_client.DurableStream({
|
|
6095
|
+
url: `${this.streamClient.baseUrl}${this.streamUrl}`,
|
|
6096
|
+
contentType: `application/json`
|
|
6097
|
+
}), `pg-sync-bridge-${this.sourceRef}`);
|
|
6098
|
+
if (this.initialCursor) {
|
|
6099
|
+
const offset = parseElectricOffset(this.initialCursor.offset);
|
|
6100
|
+
if (offset) {
|
|
6101
|
+
this.startStream(offset, this.initialCursor.handle, !this.initialCursor.initialSnapshotComplete);
|
|
6102
|
+
return;
|
|
6103
|
+
}
|
|
6104
|
+
}
|
|
6105
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
6106
|
+
this.startStream(`now`, void 0, true);
|
|
6107
|
+
}
|
|
6108
|
+
async stop() {
|
|
6109
|
+
this.unsubscribe?.();
|
|
6110
|
+
this.abortController?.abort();
|
|
6111
|
+
this.unsubscribe = null;
|
|
6112
|
+
this.abortController = null;
|
|
6113
|
+
try {
|
|
6114
|
+
await this.producer?.flush();
|
|
6115
|
+
} finally {
|
|
6116
|
+
await this.producer?.detach();
|
|
6117
|
+
this.producer = null;
|
|
6118
|
+
}
|
|
6119
|
+
}
|
|
6120
|
+
startStream(offset, handle, skipChangesUntilUpToDate = false, log = offset === `now` ? `changes_only` : `full`) {
|
|
6121
|
+
this.unsubscribe?.();
|
|
6122
|
+
this.abortController?.abort();
|
|
6123
|
+
this.skipChangesUntilUpToDate = skipChangesUntilUpToDate;
|
|
6124
|
+
this.abortController = new AbortController();
|
|
6125
|
+
const stream = new __electric_sql_client.ShapeStream({
|
|
6126
|
+
url: this.resolvedSource.url,
|
|
6127
|
+
params: buildElectricShapeParams(this.options),
|
|
6128
|
+
offset,
|
|
6129
|
+
log,
|
|
6130
|
+
...handle ? { handle } : {},
|
|
6131
|
+
signal: this.abortController.signal
|
|
6132
|
+
});
|
|
6133
|
+
this.unsubscribe = stream.subscribe(async (messages) => {
|
|
6134
|
+
try {
|
|
6135
|
+
for (const message of messages) {
|
|
6136
|
+
if ((0, __electric_sql_client.isControlMessage)(message)) {
|
|
6137
|
+
if (message.headers.control === `must-refetch`) {
|
|
6138
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
6139
|
+
this.startStream(`now`, void 0, true);
|
|
6140
|
+
return;
|
|
6141
|
+
}
|
|
6142
|
+
if (message.headers.control === `up-to-date`) {
|
|
6143
|
+
this.skipChangesUntilUpToDate = false;
|
|
6144
|
+
await this.persistCursor(stream, true);
|
|
6145
|
+
continue;
|
|
6146
|
+
}
|
|
6147
|
+
await this.persistCursor(stream);
|
|
6148
|
+
continue;
|
|
6149
|
+
}
|
|
6150
|
+
if (!(0, __electric_sql_client.isChangeMessage)(message)) continue;
|
|
6151
|
+
if (!this.skipChangesUntilUpToDate) {
|
|
6152
|
+
const event = pgSyncMessageToDurableEvent(message, this.options);
|
|
6153
|
+
if (event) {
|
|
6154
|
+
if (!this.producer) throw new Error(`pg-sync producer is not started`);
|
|
6155
|
+
await this.producer.append(JSON.stringify(event));
|
|
6156
|
+
await this.producer.flush?.();
|
|
6157
|
+
await this.evaluateWakes?.(this.streamUrl, event);
|
|
6158
|
+
}
|
|
6159
|
+
}
|
|
6160
|
+
await this.persistCursor(stream);
|
|
6161
|
+
this.retryAttempt = 0;
|
|
6162
|
+
}
|
|
6163
|
+
} catch (error) {
|
|
6164
|
+
serverLog.warn(`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`, error);
|
|
6165
|
+
await this.recoverStream();
|
|
6166
|
+
}
|
|
6167
|
+
}, (error) => {
|
|
6168
|
+
if (this.abortController?.signal.aborted) return;
|
|
6169
|
+
serverLog.warn(`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`, error);
|
|
6170
|
+
this.recoverStream();
|
|
6171
|
+
});
|
|
6172
|
+
}
|
|
6173
|
+
async recoverStream() {
|
|
6174
|
+
if (this.recovering) return;
|
|
6175
|
+
this.recovering = true;
|
|
6176
|
+
try {
|
|
6177
|
+
const attempt = this.retryAttempt++;
|
|
6178
|
+
const baseDelay = Math.min(this.retry.initialDelayMs * 2 ** attempt, this.retry.maxDelayMs);
|
|
6179
|
+
const jitter = Math.floor(baseDelay * .2 * this.retry.random());
|
|
6180
|
+
const delay = baseDelay + jitter;
|
|
6181
|
+
if (delay > 0) await this.retry.sleep(delay);
|
|
6182
|
+
const offset = this.committedCursor ? parseElectricOffset(this.committedCursor.offset) : null;
|
|
6183
|
+
if (offset && this.committedCursor) this.startStream(offset, this.committedCursor.handle, !this.committedCursor.initialSnapshotComplete);
|
|
6184
|
+
else {
|
|
6185
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
6186
|
+
this.startStream(`now`, void 0, true);
|
|
6187
|
+
}
|
|
6188
|
+
} finally {
|
|
6189
|
+
this.recovering = false;
|
|
6190
|
+
}
|
|
6191
|
+
}
|
|
6192
|
+
async persistCursor(stream, initialSnapshotComplete = !this.skipChangesUntilUpToDate) {
|
|
6193
|
+
const shapeHandle = stream.shapeHandle;
|
|
6194
|
+
const shapeOffset = stream.lastOffset;
|
|
6195
|
+
if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return;
|
|
6196
|
+
await this.registry?.updatePgSyncBridgeCursor(this.sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete);
|
|
6197
|
+
this.committedCursor = {
|
|
6198
|
+
handle: shapeHandle,
|
|
6199
|
+
offset: shapeOffset,
|
|
6200
|
+
initialSnapshotComplete
|
|
6201
|
+
};
|
|
6202
|
+
}
|
|
6203
|
+
};
|
|
6204
|
+
var PgSyncBridgeManager = class {
|
|
6205
|
+
bridges = new Map();
|
|
6206
|
+
starting = new Map();
|
|
6207
|
+
url;
|
|
6208
|
+
retry;
|
|
6209
|
+
constructor(streamClient, evaluateWakes, registry, options = {}) {
|
|
6210
|
+
this.streamClient = streamClient;
|
|
6211
|
+
this.evaluateWakes = evaluateWakes;
|
|
6212
|
+
this.registry = registry;
|
|
6213
|
+
this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
|
|
6214
|
+
this.retry = {
|
|
6215
|
+
initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
|
|
6216
|
+
maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
|
|
6217
|
+
random: options.retry?.random ?? Math.random,
|
|
6218
|
+
sleep: options.retry?.sleep ?? ((ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)))
|
|
6219
|
+
};
|
|
6220
|
+
}
|
|
6221
|
+
async start() {
|
|
6222
|
+
const rows = await this.registry?.listPgSyncBridges?.();
|
|
6223
|
+
if (!rows) return;
|
|
6224
|
+
await Promise.all(rows.map((row) => this.ensureBridge(row).catch((error) => {
|
|
6225
|
+
serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
|
|
6226
|
+
})));
|
|
6227
|
+
}
|
|
6228
|
+
async register(options, metadata) {
|
|
6229
|
+
const mergedMetadata = {
|
|
6230
|
+
...options.metadata,
|
|
6231
|
+
...metadata
|
|
6232
|
+
};
|
|
6233
|
+
const canonicalOptions = {
|
|
6234
|
+
...(0, __electric_ax_agents_runtime.canonicalPgSyncOptions)(options),
|
|
6235
|
+
...Object.keys(mergedMetadata).length > 0 ? { metadata: mergedMetadata } : {}
|
|
6236
|
+
};
|
|
6237
|
+
const resolvedSource = this.resolveSource(canonicalOptions);
|
|
6238
|
+
const sourceRef = (0, __electric_ax_agents_runtime.sourceRefForPgSync)(canonicalOptions);
|
|
6239
|
+
const streamUrl = (0, __electric_ax_agents_runtime.getPgSyncStreamPath)(sourceRef, this.registry?.tenantId);
|
|
6240
|
+
const row = await this.registry?.upsertPgSyncBridge({
|
|
6241
|
+
sourceRef,
|
|
6242
|
+
options: canonicalOptions,
|
|
6243
|
+
streamUrl
|
|
6244
|
+
});
|
|
6245
|
+
await this.streamClient.ensure(streamUrl, { contentType: `application/json` });
|
|
6246
|
+
if (!this.bridges.has(sourceRef)) {
|
|
6247
|
+
let start = this.starting.get(sourceRef);
|
|
6248
|
+
if (!start) {
|
|
6249
|
+
start = (async () => {
|
|
6250
|
+
const bridge = new PgSyncBridge(sourceRef, streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
|
|
6251
|
+
await bridge.start();
|
|
6252
|
+
this.bridges.set(sourceRef, bridge);
|
|
6253
|
+
})().finally(() => this.starting.delete(sourceRef));
|
|
6254
|
+
this.starting.set(sourceRef, start);
|
|
6255
|
+
}
|
|
6256
|
+
await start;
|
|
6257
|
+
}
|
|
6258
|
+
return {
|
|
6259
|
+
sourceRef,
|
|
6260
|
+
streamUrl
|
|
6261
|
+
};
|
|
6262
|
+
}
|
|
6263
|
+
async ensureBridge(row) {
|
|
6264
|
+
if (this.bridges.has(row.sourceRef)) return;
|
|
6265
|
+
let start = this.starting.get(row.sourceRef);
|
|
6266
|
+
if (!start) {
|
|
6267
|
+
start = (async () => {
|
|
6268
|
+
await this.streamClient.ensure(row.streamUrl, { contentType: `application/json` });
|
|
6269
|
+
const canonicalOptions = (0, __electric_ax_agents_runtime.canonicalPgSyncOptions)(row.options);
|
|
6270
|
+
const resolvedSource = this.resolveSource(canonicalOptions);
|
|
6271
|
+
const bridge = new PgSyncBridge(row.sourceRef, row.streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
|
|
6272
|
+
await bridge.start();
|
|
6273
|
+
this.bridges.set(row.sourceRef, bridge);
|
|
6274
|
+
})().finally(() => this.starting.delete(row.sourceRef));
|
|
6275
|
+
this.starting.set(row.sourceRef, start);
|
|
6276
|
+
}
|
|
6277
|
+
await start;
|
|
6278
|
+
}
|
|
6279
|
+
resolveSource(options) {
|
|
6280
|
+
return { url: options.url ?? this.url };
|
|
6281
|
+
}
|
|
6282
|
+
async stop() {
|
|
6283
|
+
await Promise.allSettled(this.starting.values());
|
|
6284
|
+
await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()));
|
|
6285
|
+
this.bridges.clear();
|
|
6286
|
+
}
|
|
6287
|
+
};
|
|
6288
|
+
|
|
5870
6289
|
//#endregion
|
|
5871
6290
|
//#region src/runtime.ts
|
|
5872
6291
|
function omitUndefined(value) {
|
|
@@ -5881,6 +6300,7 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
5881
6300
|
wakeRegistry;
|
|
5882
6301
|
scheduler;
|
|
5883
6302
|
entityBridgeManager;
|
|
6303
|
+
pgSyncBridgeManager;
|
|
5884
6304
|
claimWriteTokens;
|
|
5885
6305
|
manager;
|
|
5886
6306
|
constructor(options) {
|
|
@@ -5905,9 +6325,10 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
5905
6325
|
writeTokenValidator: (entity, token) => this.claimWriteTokens.isValid(this.serviceId, entity.streams.main, token),
|
|
5906
6326
|
stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false
|
|
5907
6327
|
});
|
|
6328
|
+
this.pgSyncBridgeManager = options.pgSyncBridgeManager ?? new PgSyncBridgeManager(this.streamClient, (sourceUrl, event) => this.manager.evaluateWakes(sourceUrl, event), this.registry, options.pgSync);
|
|
5908
6329
|
}
|
|
5909
6330
|
async stop() {
|
|
5910
|
-
await this.manager.shutdown();
|
|
6331
|
+
await Promise.all([this.manager.shutdown(), this.pgSyncBridgeManager.stop()]);
|
|
5911
6332
|
}
|
|
5912
6333
|
async rehydrateCronSchedules() {
|
|
5913
6334
|
const rows = await this.db.select({ sourceUrl: wakeRegistrations.sourceUrl }).from(wakeRegistrations).where((0, drizzle_orm.eq)(wakeRegistrations.tenantId, this.serviceId));
|
|
@@ -6671,7 +7092,10 @@ var WakeRegistry = class {
|
|
|
6671
7092
|
}
|
|
6672
7093
|
if (!(0, __electric_sql_client.isChangeMessage)(message)) return;
|
|
6673
7094
|
if (message.headers.operation === `delete`) {
|
|
6674
|
-
|
|
7095
|
+
const oldValue = message.old_value;
|
|
7096
|
+
const oldId = Number(oldValue?.id);
|
|
7097
|
+
if (Number.isFinite(oldId)) this.removeCachedRegistrationByDbId(oldId);
|
|
7098
|
+
else this.resetCachedRegistrations();
|
|
6675
7099
|
return;
|
|
6676
7100
|
}
|
|
6677
7101
|
this.upsertCachedRegistration(this.normalizeShapeRow(message.value));
|
|
@@ -6782,9 +7206,9 @@ var WakeRegistry = class {
|
|
|
6782
7206
|
matchCondition(reg, event) {
|
|
6783
7207
|
if (reg.condition === `runFinished`) {
|
|
6784
7208
|
if (event.type !== `run`) return null;
|
|
6785
|
-
const value = event.value;
|
|
7209
|
+
const value$1 = event.value;
|
|
6786
7210
|
const headers$1 = event.headers;
|
|
6787
|
-
const status$4 = value?.status;
|
|
7211
|
+
const status$4 = value$1?.status;
|
|
6788
7212
|
const operation$1 = headers$1?.operation;
|
|
6789
7213
|
if (operation$1 !== `update`) return null;
|
|
6790
7214
|
if (status$4 !== `completed` && status$4 !== `failed`) return null;
|
|
@@ -6807,13 +7231,15 @@ var WakeRegistry = class {
|
|
|
6807
7231
|
}
|
|
6808
7232
|
const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
|
|
6809
7233
|
if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
|
|
7234
|
+
const value = event.value;
|
|
6810
7235
|
const change = {
|
|
6811
7236
|
collection: eventType,
|
|
6812
7237
|
kind,
|
|
6813
7238
|
key: event.key || ``
|
|
6814
7239
|
};
|
|
7240
|
+
if (value && `value` in value) change.value = value.value;
|
|
7241
|
+
if (value && `oldValue` in value) change.oldValue = value.oldValue;
|
|
6815
7242
|
if (eventType === `inbox`) {
|
|
6816
|
-
const value = event.value;
|
|
6817
7243
|
if (typeof value?.from === `string`) change.from = value.from;
|
|
6818
7244
|
if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
|
|
6819
7245
|
if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
|
|
@@ -7217,6 +7643,22 @@ function resolveDurableStreamsRoutingAdapter(adapter, _durableStreamsUrl) {
|
|
|
7217
7643
|
|
|
7218
7644
|
//#endregion
|
|
7219
7645
|
//#region src/utils/server-utils.ts
|
|
7646
|
+
/**
|
|
7647
|
+
* Raised when an Electric shape proxy request must be rejected for security
|
|
7648
|
+
* reasons (an un-scoped table, or a client `where` clause that could escape the
|
|
7649
|
+
* enforced per-tenant/per-principal scoping). The global `errorMapper` hook
|
|
7650
|
+
* maps this to an HTTP error response. Defined here (rather than reusing
|
|
7651
|
+
* `ElectricAgentsError`) to keep this module free of the heavy entity-manager
|
|
7652
|
+
* import graph.
|
|
7653
|
+
*/
|
|
7654
|
+
var ElectricProxyError = class extends Error {
|
|
7655
|
+
constructor(code, message, status$4) {
|
|
7656
|
+
super(message);
|
|
7657
|
+
this.code = code;
|
|
7658
|
+
this.status = status$4;
|
|
7659
|
+
this.name = `ElectricProxyError`;
|
|
7660
|
+
}
|
|
7661
|
+
};
|
|
7220
7662
|
function buildElectricProxyTarget(options) {
|
|
7221
7663
|
const targetPath = options.incomingUrl.pathname.replace(`/_electric/electric`, ``);
|
|
7222
7664
|
const target = electricUrlWithPath(options.electricUrl, targetPath);
|
|
@@ -7226,7 +7668,12 @@ function buildElectricProxyTarget(options) {
|
|
|
7226
7668
|
applyElectricUrlQueryParams(target, options.electricUrl);
|
|
7227
7669
|
if (targetPath !== `/v1/shape`) return target;
|
|
7228
7670
|
if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
|
|
7229
|
-
const
|
|
7671
|
+
const clientWhere = options.incomingUrl.searchParams.get(`where`);
|
|
7672
|
+
if (clientWhere !== null && !isSelfContainedWhereClause(clientWhere)) throw new ElectricProxyError(`INVALID_WHERE`, `Invalid where clause`, 400);
|
|
7673
|
+
const tableParams = options.incomingUrl.searchParams.getAll(`table`);
|
|
7674
|
+
if (tableParams.length !== 1) throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
|
|
7675
|
+
const table = tableParams[0];
|
|
7676
|
+
target.searchParams.set(`table`, table);
|
|
7230
7677
|
if (table === `entities`) {
|
|
7231
7678
|
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"`);
|
|
7232
7679
|
applyShapeWhere(target, buildReadableEntitiesWhere({
|
|
@@ -7284,9 +7731,39 @@ function buildElectricProxyTarget(options) {
|
|
|
7284
7731
|
principalKind: options.principalKind ?? ``,
|
|
7285
7732
|
permissionBypass: options.permissionBypass
|
|
7286
7733
|
}));
|
|
7287
|
-
}
|
|
7734
|
+
} else throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
|
|
7288
7735
|
return target;
|
|
7289
7736
|
}
|
|
7737
|
+
/**
|
|
7738
|
+
* Returns true when a client-supplied Electric `where` clause is self-contained:
|
|
7739
|
+
* its parentheses are balanced, never close below the top level, all string
|
|
7740
|
+
* (`'`) and identifier (`"`) literals are terminated, and it contains no SQL
|
|
7741
|
+
* comment markers. Such a clause cannot break out of the `(...)` group it is
|
|
7742
|
+
* wrapped in when AND-combined with the enforced scoping predicate, nor comment
|
|
7743
|
+
* out the trailing paren the proxy appends. Characters inside string/identifier
|
|
7744
|
+
* literals are ignored. Comment markers are rejected unconditionally (even where
|
|
7745
|
+
* harmless) as a conservative defensive measure; dollar-quoted and `E''` strings
|
|
7746
|
+
* are not modeled and only ever cause fail-safe over-rejection, never a bypass.
|
|
7747
|
+
*/
|
|
7748
|
+
function isSelfContainedWhereClause(where) {
|
|
7749
|
+
let depth = 0;
|
|
7750
|
+
let quote = null;
|
|
7751
|
+
for (let i = 0; i < where.length; i++) {
|
|
7752
|
+
const ch = where[i];
|
|
7753
|
+
if (quote !== null) {
|
|
7754
|
+
if (ch === quote) if (where[i + 1] === quote) i++;
|
|
7755
|
+
else quote = null;
|
|
7756
|
+
continue;
|
|
7757
|
+
}
|
|
7758
|
+
if (ch === `'` || ch === `"`) quote = ch;
|
|
7759
|
+
else if (ch === `(`) depth++;
|
|
7760
|
+
else if (ch === `)`) {
|
|
7761
|
+
depth--;
|
|
7762
|
+
if (depth < 0) return false;
|
|
7763
|
+
} else if (ch === `-` && where[i + 1] === `-` || ch === `/` && where[i + 1] === `*`) return false;
|
|
7764
|
+
}
|
|
7765
|
+
return depth === 0 && quote === null;
|
|
7766
|
+
}
|
|
7290
7767
|
function buildReadableEntitiesWhere(options) {
|
|
7291
7768
|
const tenant = sqlStringLiteral(options.tenantId);
|
|
7292
7769
|
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
@@ -7922,7 +8399,10 @@ async function authorizeDurableStreamAccess(request, ctx) {
|
|
|
7922
8399
|
}
|
|
7923
8400
|
if (method === `PUT` || method === `POST`) {
|
|
7924
8401
|
const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
|
|
7925
|
-
if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl))
|
|
8402
|
+
if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) {
|
|
8403
|
+
if (ownerEntityUrl) await ctx.entityManager.registry.replaceSharedStateLink(ownerEntityUrl, `shared-state:${sharedStateId}`, sharedStateId);
|
|
8404
|
+
return void 0;
|
|
8405
|
+
}
|
|
7926
8406
|
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
|
|
7927
8407
|
}
|
|
7928
8408
|
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
|
|
@@ -8291,7 +8771,7 @@ async function parseAttachmentForm(request) {
|
|
|
8291
8771
|
};
|
|
8292
8772
|
}
|
|
8293
8773
|
function contentDisposition(filename) {
|
|
8294
|
-
const fallback = filename.replace(/["
|
|
8774
|
+
const fallback = filename.replace(/[^\x20-\x7e]|["\\]/g, `_`);
|
|
8295
8775
|
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
8296
8776
|
}
|
|
8297
8777
|
function rejectPrincipalEntityMutation(request, action) {
|
|
@@ -8489,22 +8969,28 @@ async function deleteEventSourceSubscription(request, ctx) {
|
|
|
8489
8969
|
const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
|
|
8490
8970
|
return (0, itty_router.json)(result);
|
|
8491
8971
|
}
|
|
8972
|
+
function tagResponseBody(entity) {
|
|
8973
|
+
const publicEntity = toPublicEntity(entity);
|
|
8974
|
+
if (entity.txid !== void 0) return {
|
|
8975
|
+
...publicEntity,
|
|
8976
|
+
txid: entity.txid
|
|
8977
|
+
};
|
|
8978
|
+
return publicEntity;
|
|
8979
|
+
}
|
|
8492
8980
|
async function setTag(request, ctx) {
|
|
8493
8981
|
const principalMutationError = rejectPrincipalEntityMutation(request, `tag updated`);
|
|
8494
8982
|
if (principalMutationError) return principalMutationError;
|
|
8495
8983
|
const parsed = routeBody(request);
|
|
8496
8984
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8497
|
-
const
|
|
8498
|
-
|
|
8499
|
-
return (0, itty_router.json)(toPublicEntity(updated));
|
|
8985
|
+
const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value });
|
|
8986
|
+
return (0, itty_router.json)(tagResponseBody(updated));
|
|
8500
8987
|
}
|
|
8501
8988
|
async function deleteTag(request, ctx) {
|
|
8502
8989
|
const principalMutationError = rejectPrincipalEntityMutation(request, `tag deleted`);
|
|
8503
8990
|
if (principalMutationError) return principalMutationError;
|
|
8504
8991
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8505
|
-
const
|
|
8506
|
-
|
|
8507
|
-
return (0, itty_router.json)(toPublicEntity(updated));
|
|
8992
|
+
const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey));
|
|
8993
|
+
return (0, itty_router.json)(tagResponseBody(updated));
|
|
8508
8994
|
}
|
|
8509
8995
|
async function forkEntity(request, ctx) {
|
|
8510
8996
|
const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
|
|
@@ -8571,9 +9057,12 @@ async function sendEntity(request, ctx) {
|
|
|
8571
9057
|
mode: parsed.mode,
|
|
8572
9058
|
position: parsed.position
|
|
8573
9059
|
};
|
|
8574
|
-
if (parsed.afterMs && parsed.afterMs > 0)
|
|
8575
|
-
|
|
8576
|
-
|
|
9060
|
+
if (parsed.afterMs && parsed.afterMs > 0) {
|
|
9061
|
+
await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
|
|
9062
|
+
return (0, itty_router.status)(204);
|
|
9063
|
+
}
|
|
9064
|
+
const result = await ctx.entityManager.send(entityUrl, sendReq);
|
|
9065
|
+
return (0, itty_router.json)(result);
|
|
8577
9066
|
}
|
|
8578
9067
|
async function createAttachment(request, ctx) {
|
|
8579
9068
|
const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
|
|
@@ -8616,13 +9105,13 @@ async function deleteAttachment(request, ctx) {
|
|
|
8616
9105
|
async function updateInboxMessage(request, ctx) {
|
|
8617
9106
|
const parsed = routeBody(request);
|
|
8618
9107
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8619
|
-
await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
|
|
8620
|
-
return (0, itty_router.
|
|
9108
|
+
const result = await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
|
|
9109
|
+
return (0, itty_router.json)(result);
|
|
8621
9110
|
}
|
|
8622
9111
|
async function deleteInboxMessage(request, ctx) {
|
|
8623
9112
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8624
|
-
await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
|
|
8625
|
-
return (0, itty_router.
|
|
9113
|
+
const result = await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
|
|
9114
|
+
return (0, itty_router.json)(result);
|
|
8626
9115
|
}
|
|
8627
9116
|
async function spawnEntity(request, ctx) {
|
|
8628
9117
|
const parsed = routeBody(request);
|
|
@@ -8893,6 +9382,49 @@ function toPublicEntityType(entityType) {
|
|
|
8893
9382
|
};
|
|
8894
9383
|
}
|
|
8895
9384
|
|
|
9385
|
+
//#endregion
|
|
9386
|
+
//#region src/routing/pg-sync-router.ts
|
|
9387
|
+
const pgSyncOptionsSchema = __sinclair_typebox.Type.Object({
|
|
9388
|
+
url: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
|
|
9389
|
+
table: __sinclair_typebox.Type.String(),
|
|
9390
|
+
columns: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(__sinclair_typebox.Type.String())),
|
|
9391
|
+
where: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
|
|
9392
|
+
params: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Array(__sinclair_typebox.Type.String()), __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.String())])),
|
|
9393
|
+
replica: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`default`), __sinclair_typebox.Type.Literal(`full`)]))
|
|
9394
|
+
});
|
|
9395
|
+
const pgSyncRequestMetadataSchema = __sinclair_typebox.Type.Object({
|
|
9396
|
+
entityUrl: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
|
|
9397
|
+
entityType: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
|
|
9398
|
+
streamPath: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
|
|
9399
|
+
runtimeConsumerId: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
|
|
9400
|
+
wakeId: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
|
|
9401
|
+
});
|
|
9402
|
+
const pgSyncRegisterBodySchema = __sinclair_typebox.Type.Object({
|
|
9403
|
+
options: pgSyncOptionsSchema,
|
|
9404
|
+
metadata: __sinclair_typebox.Type.Optional(pgSyncRequestMetadataSchema)
|
|
9405
|
+
});
|
|
9406
|
+
const pgSyncRouter = (0, itty_router.Router)({ base: `/_electric/pg-sync` });
|
|
9407
|
+
pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
|
|
9408
|
+
async function registerPgSync(request, ctx) {
|
|
9409
|
+
const { options, metadata } = routeBody(request);
|
|
9410
|
+
if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
|
|
9411
|
+
if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
|
|
9412
|
+
try {
|
|
9413
|
+
const requestMetadata$1 = {
|
|
9414
|
+
tenantId: ctx.service,
|
|
9415
|
+
principalKind: ctx.principal.kind,
|
|
9416
|
+
principalId: ctx.principal.id,
|
|
9417
|
+
principalKey: ctx.principal.key,
|
|
9418
|
+
principalUrl: ctx.principal.url,
|
|
9419
|
+
...metadata ?? {}
|
|
9420
|
+
};
|
|
9421
|
+
const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
|
|
9422
|
+
return (0, itty_router.json)(result);
|
|
9423
|
+
} catch (error) {
|
|
9424
|
+
return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
9425
|
+
}
|
|
9426
|
+
}
|
|
9427
|
+
|
|
8896
9428
|
//#endregion
|
|
8897
9429
|
//#region src/routing/hooks.ts
|
|
8898
9430
|
const SPAN_KEY = Symbol(`agents-server.otel-span`);
|
|
@@ -8967,6 +9499,10 @@ function errorMapper(err, req) {
|
|
|
8967
9499
|
});
|
|
8968
9500
|
}
|
|
8969
9501
|
if (err instanceof ElectricAgentsError) return apiError(err.status, err.code, err.message, err.details);
|
|
9502
|
+
if (err instanceof ElectricProxyError) {
|
|
9503
|
+
serverLog.warn(`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`);
|
|
9504
|
+
return apiError(err.status, err.code, err.message);
|
|
9505
|
+
}
|
|
8970
9506
|
serverLog.error(`[agent-server] Unhandled error:`, err);
|
|
8971
9507
|
return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`);
|
|
8972
9508
|
}
|
|
@@ -9417,6 +9953,7 @@ internalRouter.all(`/runners`, runnersRouter.fetch);
|
|
|
9417
9953
|
internalRouter.all(`/runners/*`, runnersRouter.fetch);
|
|
9418
9954
|
internalRouter.all(`/entities/*`, entitiesRouter.fetch);
|
|
9419
9955
|
internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch);
|
|
9956
|
+
internalRouter.all(`/pg-sync/*`, pgSyncRouter.fetch);
|
|
9420
9957
|
internalRouter.all(`/observations/*`, observationsRouter.fetch);
|
|
9421
9958
|
internalRouter.get(`/electric/*`, electricProxyRouter.fetch);
|
|
9422
9959
|
internalRouter.all(`*`, () => (0, itty_router.status)(404));
|
|
@@ -9800,6 +10337,9 @@ const globalRouter = (0, itty_router.AutoRouter)({
|
|
|
9800
10337
|
finally: [otelEndSpan, applyCors]
|
|
9801
10338
|
});
|
|
9802
10339
|
globalRouter.all(`/_electric/shared-state/*`, durableStreamsRouter.fetch);
|
|
10340
|
+
globalRouter.all(`/_electric/pg-sync/register`, internalRouter.fetch);
|
|
10341
|
+
globalRouter.get(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
|
|
10342
|
+
globalRouter.head(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
|
|
9803
10343
|
globalRouter.all(`/_electric/*`, internalRouter.fetch);
|
|
9804
10344
|
globalRouter.all(`*`, durableStreamsRouter.fetch);
|
|
9805
10345
|
|