@electric-ax/agents-server 0.4.19 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/entrypoint.js +692 -45
- package/dist/index.cjs +678 -41
- package/dist/index.d.cts +2519 -2216
- package/dist/index.d.ts +2518 -2217
- package/dist/index.js +679 -42
- package/drizzle/0015_pg_sync_bridges.sql +14 -0
- package/drizzle/0016_entity_type_externally_writable_collections.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +6 -6
- package/src/db/schema.ts +32 -0
- package/src/electric-agents-types.ts +23 -0
- package/src/entity-manager.ts +160 -29
- package/src/entity-registry.ts +158 -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 +89 -18
- package/src/routing/entity-types-router.ts +56 -0
- 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/entrypoint.js
CHANGED
|
@@ -4,7 +4,7 @@ import { DurableStreamTestServer } from "@durable-streams/server";
|
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { createServerAdapter } from "@whatwg-node/server";
|
|
6
6
|
import { Agent } from "undici";
|
|
7
|
-
import { COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, createEntityRegistry, createRuntimeHandler, 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";
|
|
7
|
+
import { COMMENTS_CONTRACT, COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, canonicalPgSyncOptions, createEntityRegistry, createRuntimeHandler, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
8
8
|
import fs, { existsSync } from "node:fs";
|
|
9
9
|
import path, { dirname, resolve } from "node:path";
|
|
10
10
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
@@ -47,6 +47,7 @@ __export(schema_exports, {
|
|
|
47
47
|
entityPermissionGrants: () => entityPermissionGrants,
|
|
48
48
|
entityTypePermissionGrants: () => entityTypePermissionGrants,
|
|
49
49
|
entityTypes: () => entityTypes,
|
|
50
|
+
pgSyncBridges: () => pgSyncBridges,
|
|
50
51
|
runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
|
|
51
52
|
runners: () => runners,
|
|
52
53
|
scheduledTasks: () => scheduledTasks,
|
|
@@ -64,6 +65,7 @@ const entityTypes = pgTable(`entity_types`, {
|
|
|
64
65
|
creationSchema: jsonb(`creation_schema`),
|
|
65
66
|
inboxSchemas: jsonb(`inbox_schemas`),
|
|
66
67
|
stateSchemas: jsonb(`state_schemas`),
|
|
68
|
+
externallyWritableCollections: jsonb(`externally_writable_collections`).$type(),
|
|
67
69
|
slashCommands: jsonb(`slash_commands`),
|
|
68
70
|
serveEndpoint: text(`serve_endpoint`),
|
|
69
71
|
defaultDispatchPolicy: jsonb(`default_dispatch_policy`),
|
|
@@ -368,6 +370,18 @@ const scheduledTasks = pgTable(`scheduled_tasks`, {
|
|
|
368
370
|
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`),
|
|
369
371
|
index(`idx_scheduled_tasks_stale_claims`).on(table.tenantId, table.claimedAt).where(sql`${table.completedAt} IS NULL AND ${table.claimedAt} IS NOT NULL`)
|
|
370
372
|
]);
|
|
373
|
+
const pgSyncBridges = pgTable(`pg_sync_bridges`, {
|
|
374
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
375
|
+
sourceRef: text(`source_ref`).notNull(),
|
|
376
|
+
options: jsonb(`options`).notNull(),
|
|
377
|
+
streamUrl: text(`stream_url`).notNull(),
|
|
378
|
+
shapeHandle: text(`shape_handle`),
|
|
379
|
+
shapeOffset: text(`shape_offset`),
|
|
380
|
+
initialSnapshotComplete: boolean(`initial_snapshot_complete`).notNull().default(false),
|
|
381
|
+
lastTouchedAt: timestamp(`last_touched_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
382
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
383
|
+
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
384
|
+
}, (table) => [primaryKey({ columns: [table.tenantId, table.sourceRef] }), unique(`uq_pg_sync_bridges_stream_url`).on(table.tenantId, table.streamUrl)]);
|
|
371
385
|
const entityBridges = pgTable(`entity_bridges`, {
|
|
372
386
|
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
373
387
|
sourceRef: text(`source_ref`).notNull(),
|
|
@@ -1134,6 +1148,22 @@ async function fileExists(filePath) {
|
|
|
1134
1148
|
return false;
|
|
1135
1149
|
}
|
|
1136
1150
|
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Raised when an Electric shape proxy request must be rejected for security
|
|
1153
|
+
* reasons (an un-scoped table, or a client `where` clause that could escape the
|
|
1154
|
+
* enforced per-tenant/per-principal scoping). The global `errorMapper` hook
|
|
1155
|
+
* maps this to an HTTP error response. Defined here (rather than reusing
|
|
1156
|
+
* `ElectricAgentsError`) to keep this module free of the heavy entity-manager
|
|
1157
|
+
* import graph.
|
|
1158
|
+
*/
|
|
1159
|
+
var ElectricProxyError = class extends Error {
|
|
1160
|
+
constructor(code, message, status$1) {
|
|
1161
|
+
super(message);
|
|
1162
|
+
this.code = code;
|
|
1163
|
+
this.status = status$1;
|
|
1164
|
+
this.name = `ElectricProxyError`;
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1137
1167
|
function buildElectricProxyTarget(options) {
|
|
1138
1168
|
const targetPath = options.incomingUrl.pathname.replace(`/_electric/electric`, ``);
|
|
1139
1169
|
const target = electricUrlWithPath(options.electricUrl, targetPath);
|
|
@@ -1143,7 +1173,12 @@ function buildElectricProxyTarget(options) {
|
|
|
1143
1173
|
applyElectricUrlQueryParams(target, options.electricUrl);
|
|
1144
1174
|
if (targetPath !== `/v1/shape`) return target;
|
|
1145
1175
|
if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
|
|
1146
|
-
const
|
|
1176
|
+
const clientWhere = options.incomingUrl.searchParams.get(`where`);
|
|
1177
|
+
if (clientWhere !== null && !isSelfContainedWhereClause(clientWhere)) throw new ElectricProxyError(`INVALID_WHERE`, `Invalid where clause`, 400);
|
|
1178
|
+
const tableParams = options.incomingUrl.searchParams.getAll(`table`);
|
|
1179
|
+
if (tableParams.length !== 1) throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
|
|
1180
|
+
const table = tableParams[0];
|
|
1181
|
+
target.searchParams.set(`table`, table);
|
|
1147
1182
|
if (table === `entities`) {
|
|
1148
1183
|
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"`);
|
|
1149
1184
|
applyShapeWhere(target, buildReadableEntitiesWhere({
|
|
@@ -1201,9 +1236,39 @@ function buildElectricProxyTarget(options) {
|
|
|
1201
1236
|
principalKind: options.principalKind ?? ``,
|
|
1202
1237
|
permissionBypass: options.permissionBypass
|
|
1203
1238
|
}));
|
|
1204
|
-
}
|
|
1239
|
+
} else throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
|
|
1205
1240
|
return target;
|
|
1206
1241
|
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Returns true when a client-supplied Electric `where` clause is self-contained:
|
|
1244
|
+
* its parentheses are balanced, never close below the top level, all string
|
|
1245
|
+
* (`'`) and identifier (`"`) literals are terminated, and it contains no SQL
|
|
1246
|
+
* comment markers. Such a clause cannot break out of the `(...)` group it is
|
|
1247
|
+
* wrapped in when AND-combined with the enforced scoping predicate, nor comment
|
|
1248
|
+
* out the trailing paren the proxy appends. Characters inside string/identifier
|
|
1249
|
+
* literals are ignored. Comment markers are rejected unconditionally (even where
|
|
1250
|
+
* harmless) as a conservative defensive measure; dollar-quoted and `E''` strings
|
|
1251
|
+
* are not modeled and only ever cause fail-safe over-rejection, never a bypass.
|
|
1252
|
+
*/
|
|
1253
|
+
function isSelfContainedWhereClause(where) {
|
|
1254
|
+
let depth = 0;
|
|
1255
|
+
let quote = null;
|
|
1256
|
+
for (let i = 0; i < where.length; i++) {
|
|
1257
|
+
const ch = where[i];
|
|
1258
|
+
if (quote !== null) {
|
|
1259
|
+
if (ch === quote) if (where[i + 1] === quote) i++;
|
|
1260
|
+
else quote = null;
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
if (ch === `'` || ch === `"`) quote = ch;
|
|
1264
|
+
else if (ch === `(`) depth++;
|
|
1265
|
+
else if (ch === `)`) {
|
|
1266
|
+
depth--;
|
|
1267
|
+
if (depth < 0) return false;
|
|
1268
|
+
} else if (ch === `-` && where[i + 1] === `-` || ch === `/` && where[i + 1] === `*`) return false;
|
|
1269
|
+
}
|
|
1270
|
+
return depth === 0 && quote === null;
|
|
1271
|
+
}
|
|
1207
1272
|
function buildReadableEntitiesWhere(options) {
|
|
1208
1273
|
const tenant = sqlStringLiteral$2(options.tenantId);
|
|
1209
1274
|
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
@@ -2721,6 +2786,9 @@ var PostgresRegistry = class {
|
|
|
2721
2786
|
entityBridgeWhere(sourceRef) {
|
|
2722
2787
|
return and(eq(entityBridges.tenantId, this.tenantId), eq(entityBridges.sourceRef, sourceRef));
|
|
2723
2788
|
}
|
|
2789
|
+
pgSyncBridgeWhere(sourceRef) {
|
|
2790
|
+
return and(eq(pgSyncBridges.tenantId, this.tenantId), eq(pgSyncBridges.sourceRef, sourceRef));
|
|
2791
|
+
}
|
|
2724
2792
|
async createEntityType(et) {
|
|
2725
2793
|
await this.db.insert(entityTypes).values({
|
|
2726
2794
|
tenantId: this.tenantId,
|
|
@@ -2729,6 +2797,7 @@ var PostgresRegistry = class {
|
|
|
2729
2797
|
creationSchema: et.creation_schema ?? null,
|
|
2730
2798
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
2731
2799
|
stateSchemas: et.state_schemas ?? null,
|
|
2800
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
2732
2801
|
slashCommands: et.slash_commands ?? null,
|
|
2733
2802
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
2734
2803
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -2742,6 +2811,7 @@ var PostgresRegistry = class {
|
|
|
2742
2811
|
creationSchema: et.creation_schema ?? null,
|
|
2743
2812
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
2744
2813
|
stateSchemas: et.state_schemas ?? null,
|
|
2814
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
2745
2815
|
slashCommands: et.slash_commands ?? null,
|
|
2746
2816
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
2747
2817
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -2760,6 +2830,7 @@ var PostgresRegistry = class {
|
|
|
2760
2830
|
creationSchema: et.creation_schema ?? null,
|
|
2761
2831
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
2762
2832
|
stateSchemas: et.state_schemas ?? null,
|
|
2833
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
2763
2834
|
slashCommands: et.slash_commands ?? null,
|
|
2764
2835
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
2765
2836
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -2787,6 +2858,7 @@ var PostgresRegistry = class {
|
|
|
2787
2858
|
creationSchema: et.creation_schema ?? null,
|
|
2788
2859
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
2789
2860
|
stateSchemas: et.state_schemas ?? null,
|
|
2861
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
2790
2862
|
slashCommands: et.slash_commands ?? null,
|
|
2791
2863
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
2792
2864
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -3190,11 +3262,12 @@ var PostgresRegistry = class {
|
|
|
3190
3262
|
};
|
|
3191
3263
|
const nextTags = normalizeTags(mutation.nextTags);
|
|
3192
3264
|
const updatedAt = Date.now();
|
|
3193
|
-
await tx.update(entities).set({
|
|
3265
|
+
const [updateResult] = await tx.update(entities).set({
|
|
3194
3266
|
tags: nextTags,
|
|
3195
3267
|
tagsIndex: buildTagsIndex(nextTags),
|
|
3196
3268
|
updatedAt
|
|
3197
|
-
}).where(this.entityWhere(url));
|
|
3269
|
+
}).where(this.entityWhere(url)).returning({ txid: sql`pg_current_xact_id()::xid::text` });
|
|
3270
|
+
const txid = updateResult ? parseInt(updateResult.txid) : void 0;
|
|
3198
3271
|
await tx.insert(tagStreamOutbox).values({
|
|
3199
3272
|
tenantId: this.tenantId,
|
|
3200
3273
|
entityUrl: url,
|
|
@@ -3212,10 +3285,63 @@ var PostgresRegistry = class {
|
|
|
3212
3285
|
return {
|
|
3213
3286
|
entity,
|
|
3214
3287
|
changed: true,
|
|
3215
|
-
...op === `insert` || op === `update` ? { op } : {}
|
|
3288
|
+
...op === `insert` || op === `update` ? { op } : {},
|
|
3289
|
+
...txid !== void 0 ? { txid } : {}
|
|
3216
3290
|
};
|
|
3217
3291
|
});
|
|
3218
3292
|
}
|
|
3293
|
+
async upsertPgSyncBridge(row) {
|
|
3294
|
+
await this.db.insert(pgSyncBridges).values({
|
|
3295
|
+
tenantId: this.tenantId,
|
|
3296
|
+
sourceRef: row.sourceRef,
|
|
3297
|
+
options: row.options,
|
|
3298
|
+
streamUrl: row.streamUrl,
|
|
3299
|
+
lastTouchedAt: new Date(),
|
|
3300
|
+
updatedAt: new Date()
|
|
3301
|
+
}).onConflictDoUpdate({
|
|
3302
|
+
target: [pgSyncBridges.tenantId, pgSyncBridges.sourceRef],
|
|
3303
|
+
set: {
|
|
3304
|
+
options: row.options,
|
|
3305
|
+
streamUrl: row.streamUrl,
|
|
3306
|
+
initialSnapshotComplete: false,
|
|
3307
|
+
lastTouchedAt: new Date(),
|
|
3308
|
+
updatedAt: new Date()
|
|
3309
|
+
}
|
|
3310
|
+
});
|
|
3311
|
+
const existing = await this.getPgSyncBridge(row.sourceRef);
|
|
3312
|
+
if (!existing) throw new Error(`Failed to load pgSync bridge ${row.sourceRef}`);
|
|
3313
|
+
return existing;
|
|
3314
|
+
}
|
|
3315
|
+
async getPgSyncBridge(sourceRef) {
|
|
3316
|
+
const rows = await this.db.select().from(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef)).limit(1);
|
|
3317
|
+
return rows[0] ? this.rowToPgSyncBridge(rows[0]) : null;
|
|
3318
|
+
}
|
|
3319
|
+
async listPgSyncBridges(tenantId = this.tenantId) {
|
|
3320
|
+
const rows = tenantId === null ? await this.db.select().from(pgSyncBridges) : await this.db.select().from(pgSyncBridges).where(eq(pgSyncBridges.tenantId, tenantId));
|
|
3321
|
+
return rows.map((row) => this.rowToPgSyncBridge(row));
|
|
3322
|
+
}
|
|
3323
|
+
async touchPgSyncBridge(sourceRef) {
|
|
3324
|
+
await this.db.update(pgSyncBridges).set({
|
|
3325
|
+
lastTouchedAt: new Date(),
|
|
3326
|
+
updatedAt: new Date()
|
|
3327
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
3328
|
+
}
|
|
3329
|
+
async updatePgSyncBridgeCursor(sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete) {
|
|
3330
|
+
await this.db.update(pgSyncBridges).set({
|
|
3331
|
+
shapeHandle,
|
|
3332
|
+
shapeOffset,
|
|
3333
|
+
...initialSnapshotComplete !== void 0 ? { initialSnapshotComplete } : {},
|
|
3334
|
+
updatedAt: new Date()
|
|
3335
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
3336
|
+
}
|
|
3337
|
+
async clearPgSyncBridgeCursor(sourceRef) {
|
|
3338
|
+
await this.db.update(pgSyncBridges).set({
|
|
3339
|
+
shapeHandle: null,
|
|
3340
|
+
shapeOffset: null,
|
|
3341
|
+
initialSnapshotComplete: false,
|
|
3342
|
+
updatedAt: new Date()
|
|
3343
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
3344
|
+
}
|
|
3219
3345
|
async upsertEntityBridge(row) {
|
|
3220
3346
|
await this.db.insert(entityBridges).values({
|
|
3221
3347
|
tenantId: this.tenantId,
|
|
@@ -3378,6 +3504,7 @@ var PostgresRegistry = class {
|
|
|
3378
3504
|
creation_schema: row.creationSchema,
|
|
3379
3505
|
inbox_schemas: row.inboxSchemas,
|
|
3380
3506
|
state_schemas: row.stateSchemas,
|
|
3507
|
+
externally_writable_collections: row.externallyWritableCollections ?? void 0,
|
|
3381
3508
|
slash_commands: row.slashCommands ?? void 0,
|
|
3382
3509
|
serve_endpoint: row.serveEndpoint ?? void 0,
|
|
3383
3510
|
default_dispatch_policy: row.defaultDispatchPolicy ?? void 0,
|
|
@@ -3435,6 +3562,20 @@ var PostgresRegistry = class {
|
|
|
3435
3562
|
updated_at: row.updatedAt
|
|
3436
3563
|
};
|
|
3437
3564
|
}
|
|
3565
|
+
rowToPgSyncBridge(row) {
|
|
3566
|
+
return {
|
|
3567
|
+
tenantId: row.tenantId,
|
|
3568
|
+
sourceRef: row.sourceRef,
|
|
3569
|
+
options: row.options,
|
|
3570
|
+
streamUrl: row.streamUrl,
|
|
3571
|
+
shapeHandle: row.shapeHandle ?? void 0,
|
|
3572
|
+
shapeOffset: row.shapeOffset ?? void 0,
|
|
3573
|
+
initialSnapshotComplete: row.initialSnapshotComplete,
|
|
3574
|
+
lastTouchedAt: row.lastTouchedAt,
|
|
3575
|
+
createdAt: row.createdAt,
|
|
3576
|
+
updatedAt: row.updatedAt
|
|
3577
|
+
};
|
|
3578
|
+
}
|
|
3438
3579
|
rowToEntityBridge(row) {
|
|
3439
3580
|
return {
|
|
3440
3581
|
tenantId: row.tenantId,
|
|
@@ -3515,6 +3656,9 @@ var PostgresRegistry = class {
|
|
|
3515
3656
|
function isRecord$1(value) {
|
|
3516
3657
|
return typeof value === `object` && value !== null && !Array.isArray(value);
|
|
3517
3658
|
}
|
|
3659
|
+
function getPgSyncManifestStreamPath(sourceRef) {
|
|
3660
|
+
return `/_electric/pg-sync/${sourceRef}`;
|
|
3661
|
+
}
|
|
3518
3662
|
function extractManifestSourceUrl(manifest) {
|
|
3519
3663
|
if (!manifest) return void 0;
|
|
3520
3664
|
if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
|
|
@@ -3527,6 +3671,7 @@ function extractManifestSourceUrl(manifest) {
|
|
|
3527
3671
|
}
|
|
3528
3672
|
if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
|
|
3529
3673
|
if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
|
|
3674
|
+
if (manifest.sourceType === `pgSync`) return typeof manifest.sourceRef === `string` ? getPgSyncManifestStreamPath(manifest.sourceRef) : void 0;
|
|
3530
3675
|
if (manifest.sourceType === `webhook`) {
|
|
3531
3676
|
if (typeof config?.streamUrl === `string`) return config.streamUrl;
|
|
3532
3677
|
if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
|
|
@@ -3641,6 +3786,13 @@ function isRecord(value) {
|
|
|
3641
3786
|
function cloneRecord(value) {
|
|
3642
3787
|
return JSON.parse(JSON.stringify(value));
|
|
3643
3788
|
}
|
|
3789
|
+
function withOptionalTxid(entity, txid) {
|
|
3790
|
+
if (txid === void 0) return entity;
|
|
3791
|
+
return {
|
|
3792
|
+
...entity,
|
|
3793
|
+
txid
|
|
3794
|
+
};
|
|
3795
|
+
}
|
|
3644
3796
|
/**
|
|
3645
3797
|
* Orchestrates the Electric Agents entity lifecycle: register types, spawn, send, kill.
|
|
3646
3798
|
*
|
|
@@ -3715,6 +3867,7 @@ var EntityManager = class {
|
|
|
3715
3867
|
creation_schema: req.creation_schema,
|
|
3716
3868
|
inbox_schemas: req.inbox_schemas,
|
|
3717
3869
|
state_schemas: req.state_schemas,
|
|
3870
|
+
externally_writable_collections: req.externally_writable_collections,
|
|
3718
3871
|
slash_commands: req.slash_commands,
|
|
3719
3872
|
serve_endpoint: req.serve_endpoint,
|
|
3720
3873
|
default_dispatch_policy: defaultDispatchPolicy,
|
|
@@ -4782,15 +4935,17 @@ var EntityManager = class {
|
|
|
4782
4935
|
await this.registry.updateStatus(entityUrl, `idle`);
|
|
4783
4936
|
await this.entityBridgeManager?.onEntityChanged(entityUrl);
|
|
4784
4937
|
}
|
|
4938
|
+
const txid = crypto.randomUUID();
|
|
4785
4939
|
const envelope = entityStateSchema.inbox.insert({
|
|
4786
4940
|
key,
|
|
4787
|
-
value
|
|
4941
|
+
value,
|
|
4942
|
+
headers: { txid }
|
|
4788
4943
|
});
|
|
4789
4944
|
const encoded = this.encodeChangeEvent(envelope);
|
|
4790
4945
|
try {
|
|
4791
4946
|
if (opts?.producerId) {
|
|
4792
4947
|
await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
|
|
4793
|
-
return;
|
|
4948
|
+
return { txid };
|
|
4794
4949
|
}
|
|
4795
4950
|
await this.streamClient.append(entity.streams.main, encoded);
|
|
4796
4951
|
if (entity.type === `principal` && req.type === `update_identity`) {
|
|
@@ -4798,13 +4953,49 @@ var EntityManager = class {
|
|
|
4798
4953
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent({
|
|
4799
4954
|
type: `identity`,
|
|
4800
4955
|
key: `self`,
|
|
4801
|
-
value: identity
|
|
4956
|
+
value: identity,
|
|
4957
|
+
headers: { txid }
|
|
4802
4958
|
}));
|
|
4803
4959
|
}
|
|
4960
|
+
return { txid };
|
|
4961
|
+
} catch (err) {
|
|
4962
|
+
if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
|
|
4963
|
+
throw err;
|
|
4964
|
+
}
|
|
4965
|
+
}
|
|
4966
|
+
async writeCollection(entityUrl, collection, req) {
|
|
4967
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
4968
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4969
|
+
const { externallyWritableCollections } = await this.getEffectiveSchemas(entity);
|
|
4970
|
+
const config = externallyWritableCollections?.[collection];
|
|
4971
|
+
if (!config) throw new ElectricAgentsError(ErrCodeUnauthorized, `Collection "${collection}" is not writable`, 403);
|
|
4972
|
+
const allowedOperations = config.operations ?? [`insert`];
|
|
4973
|
+
if (!allowedOperations.includes(req.operation)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Operation "${req.operation}" is not allowed on collection "${collection}"`, 403);
|
|
4974
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4975
|
+
if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
|
|
4976
|
+
if (req.operation !== `delete` && (req.value === void 0 || req.value === null)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `value is required for ${req.operation}`, 400);
|
|
4977
|
+
if (req.operation !== `insert` && !req.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `key is required for ${req.operation}`, 400);
|
|
4978
|
+
const key = req.key ?? `${collection}-${randomUUID()}`;
|
|
4979
|
+
const event = {
|
|
4980
|
+
type: config.type,
|
|
4981
|
+
key,
|
|
4982
|
+
headers: {
|
|
4983
|
+
operation: req.operation,
|
|
4984
|
+
timestamp: new Date().toISOString(),
|
|
4985
|
+
principal: req.principal
|
|
4986
|
+
}
|
|
4987
|
+
};
|
|
4988
|
+
if (req.operation !== `delete`) event.value = req.value;
|
|
4989
|
+
const validationError = await this.validateWriteEvent(entity, event);
|
|
4990
|
+
if (validationError) throw new ElectricAgentsError(validationError.code, validationError.message, validationError.status);
|
|
4991
|
+
const encoded = this.encodeChangeEvent(event);
|
|
4992
|
+
try {
|
|
4993
|
+
await this.streamClient.append(entity.streams.main, encoded);
|
|
4804
4994
|
} catch (err) {
|
|
4805
4995
|
if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
|
|
4806
4996
|
throw err;
|
|
4807
4997
|
}
|
|
4998
|
+
return { key };
|
|
4808
4999
|
}
|
|
4809
5000
|
async updateInboxMessage(entityUrl, key, req) {
|
|
4810
5001
|
const entity = await this.registry.getEntity(entityUrl);
|
|
@@ -4821,18 +5012,26 @@ var EntityManager = class {
|
|
|
4821
5012
|
if (req.status === `cancelled`) value.cancelled_at = now;
|
|
4822
5013
|
}
|
|
4823
5014
|
if (Object.keys(value).length === 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `No inbox fields to update`, 400);
|
|
5015
|
+
const txid = crypto.randomUUID();
|
|
4824
5016
|
const envelope = entityStateSchema.inbox.update({
|
|
4825
5017
|
key,
|
|
4826
|
-
value
|
|
5018
|
+
value,
|
|
5019
|
+
headers: { txid }
|
|
4827
5020
|
});
|
|
4828
5021
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
5022
|
+
return { txid };
|
|
4829
5023
|
}
|
|
4830
5024
|
async deleteInboxMessage(entityUrl, key) {
|
|
4831
5025
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4832
5026
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4833
5027
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4834
|
-
const
|
|
5028
|
+
const txid = crypto.randomUUID();
|
|
5029
|
+
const envelope = entityStateSchema.inbox.delete({
|
|
5030
|
+
key,
|
|
5031
|
+
headers: { txid }
|
|
5032
|
+
});
|
|
4835
5033
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
5034
|
+
return { txid };
|
|
4836
5035
|
}
|
|
4837
5036
|
isAttachmentStreamPath(path$1) {
|
|
4838
5037
|
return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$1);
|
|
@@ -4921,28 +5120,26 @@ var EntityManager = class {
|
|
|
4921
5120
|
await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
|
|
4922
5121
|
return { txid };
|
|
4923
5122
|
}
|
|
4924
|
-
async setTag(entityUrl, key, req
|
|
5123
|
+
async setTag(entityUrl, key, req) {
|
|
4925
5124
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4926
5125
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4927
|
-
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
4928
5126
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4929
5127
|
if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
|
|
4930
5128
|
const result = await this.registry.setEntityTag(entityUrl, key, req.value);
|
|
4931
5129
|
const updated = result.entity;
|
|
4932
5130
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag write`, 500);
|
|
4933
5131
|
if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4934
|
-
return updated;
|
|
5132
|
+
return withOptionalTxid(updated, result.txid);
|
|
4935
5133
|
}
|
|
4936
|
-
async deleteTag(entityUrl, key
|
|
5134
|
+
async deleteTag(entityUrl, key) {
|
|
4937
5135
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4938
5136
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4939
|
-
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
4940
5137
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4941
5138
|
const result = await this.registry.removeEntityTag(entityUrl, key);
|
|
4942
5139
|
const updated = result.entity;
|
|
4943
5140
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
|
|
4944
5141
|
if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4945
|
-
return updated;
|
|
5142
|
+
return withOptionalTxid(updated, result.txid);
|
|
4946
5143
|
}
|
|
4947
5144
|
async ensureEntitiesMembershipStream(tags, principal) {
|
|
4948
5145
|
if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
|
|
@@ -5492,7 +5689,8 @@ var EntityManager = class {
|
|
|
5492
5689
|
async getEffectiveSchemas(entity) {
|
|
5493
5690
|
if (!entity.type) return {
|
|
5494
5691
|
inboxSchemas: entity.inbox_schemas,
|
|
5495
|
-
stateSchemas: entity.state_schemas
|
|
5692
|
+
stateSchemas: entity.state_schemas,
|
|
5693
|
+
externallyWritableCollections: void 0
|
|
5496
5694
|
};
|
|
5497
5695
|
const latestType = await this.registry.getEntityType(entity.type);
|
|
5498
5696
|
return {
|
|
@@ -5503,7 +5701,8 @@ var EntityManager = class {
|
|
|
5503
5701
|
stateSchemas: latestType?.state_schemas ? {
|
|
5504
5702
|
...entity.state_schemas ?? {},
|
|
5505
5703
|
...latestType.state_schemas
|
|
5506
|
-
} : entity.state_schemas
|
|
5704
|
+
} : entity.state_schemas,
|
|
5705
|
+
externallyWritableCollections: latestType?.externally_writable_collections
|
|
5507
5706
|
};
|
|
5508
5707
|
}
|
|
5509
5708
|
isClosedStreamError(err) {
|
|
@@ -5766,6 +5965,15 @@ const spawnBodySchema = Type.Object({
|
|
|
5766
5965
|
manifestKey: Type.Optional(Type.String())
|
|
5767
5966
|
}))
|
|
5768
5967
|
});
|
|
5968
|
+
const writeCollectionBodySchema = Type.Object({
|
|
5969
|
+
operation: Type.Union([
|
|
5970
|
+
Type.Literal(`insert`),
|
|
5971
|
+
Type.Literal(`update`),
|
|
5972
|
+
Type.Literal(`delete`)
|
|
5973
|
+
]),
|
|
5974
|
+
key: Type.Optional(Type.String()),
|
|
5975
|
+
value: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
|
|
5976
|
+
}, { additionalProperties: false });
|
|
5769
5977
|
const sendBodySchema = Type.Object({
|
|
5770
5978
|
payload: Type.Optional(Type.Unknown()),
|
|
5771
5979
|
key: Type.Optional(Type.String()),
|
|
@@ -5898,6 +6106,7 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermissi
|
|
|
5898
6106
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
|
|
5899
6107
|
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
|
|
5900
6108
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
|
|
6109
|
+
entitiesRouter.post(`/:type/:instanceId/collections/:collection`, withExistingEntity, withSchema(writeCollectionBodySchema), withEntityPermission(`write`), writeCollection);
|
|
5901
6110
|
entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
|
|
5902
6111
|
entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
|
|
5903
6112
|
entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
|
|
@@ -6003,7 +6212,7 @@ async function parseAttachmentForm(request) {
|
|
|
6003
6212
|
};
|
|
6004
6213
|
}
|
|
6005
6214
|
function contentDisposition(filename) {
|
|
6006
|
-
const fallback = filename.replace(/["
|
|
6215
|
+
const fallback = filename.replace(/[^\x20-\x7e]|["\\]/g, `_`);
|
|
6007
6216
|
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
6008
6217
|
}
|
|
6009
6218
|
function rejectPrincipalEntityMutation(request, action) {
|
|
@@ -6201,22 +6410,28 @@ async function deleteEventSourceSubscription(request, ctx) {
|
|
|
6201
6410
|
const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
|
|
6202
6411
|
return json(result);
|
|
6203
6412
|
}
|
|
6413
|
+
function tagResponseBody(entity) {
|
|
6414
|
+
const publicEntity = toPublicEntity(entity);
|
|
6415
|
+
if (entity.txid !== void 0) return {
|
|
6416
|
+
...publicEntity,
|
|
6417
|
+
txid: entity.txid
|
|
6418
|
+
};
|
|
6419
|
+
return publicEntity;
|
|
6420
|
+
}
|
|
6204
6421
|
async function setTag(request, ctx) {
|
|
6205
6422
|
const principalMutationError = rejectPrincipalEntityMutation(request, `tag updated`);
|
|
6206
6423
|
if (principalMutationError) return principalMutationError;
|
|
6207
6424
|
const parsed = routeBody(request);
|
|
6208
6425
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
6209
|
-
const
|
|
6210
|
-
|
|
6211
|
-
return json(toPublicEntity(updated));
|
|
6426
|
+
const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value });
|
|
6427
|
+
return json(tagResponseBody(updated));
|
|
6212
6428
|
}
|
|
6213
6429
|
async function deleteTag(request, ctx) {
|
|
6214
6430
|
const principalMutationError = rejectPrincipalEntityMutation(request, `tag deleted`);
|
|
6215
6431
|
if (principalMutationError) return principalMutationError;
|
|
6216
6432
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
6217
|
-
const
|
|
6218
|
-
|
|
6219
|
-
return json(toPublicEntity(updated));
|
|
6433
|
+
const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey));
|
|
6434
|
+
return json(tagResponseBody(updated));
|
|
6220
6435
|
}
|
|
6221
6436
|
async function forkEntity(request, ctx) {
|
|
6222
6437
|
const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
|
|
@@ -6283,9 +6498,29 @@ async function sendEntity(request, ctx) {
|
|
|
6283
6498
|
mode: parsed.mode,
|
|
6284
6499
|
position: parsed.position
|
|
6285
6500
|
};
|
|
6286
|
-
if (parsed.afterMs && parsed.afterMs > 0)
|
|
6287
|
-
|
|
6288
|
-
|
|
6501
|
+
if (parsed.afterMs && parsed.afterMs > 0) {
|
|
6502
|
+
await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
|
|
6503
|
+
return status(204);
|
|
6504
|
+
}
|
|
6505
|
+
const result = await ctx.entityManager.send(entityUrl, sendReq);
|
|
6506
|
+
return json(result);
|
|
6507
|
+
}
|
|
6508
|
+
async function writeCollection(request, ctx) {
|
|
6509
|
+
const parsed = routeBody(request);
|
|
6510
|
+
await ctx.entityManager.ensurePrincipal(ctx.principal);
|
|
6511
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
6512
|
+
const collection = request.params.collection;
|
|
6513
|
+
const result = await ctx.entityManager.writeCollection(entityUrl, collection, {
|
|
6514
|
+
operation: parsed.operation,
|
|
6515
|
+
key: parsed.key,
|
|
6516
|
+
value: parsed.value,
|
|
6517
|
+
principal: {
|
|
6518
|
+
url: ctx.principal.url,
|
|
6519
|
+
kind: ctx.principal.kind,
|
|
6520
|
+
id: ctx.principal.id
|
|
6521
|
+
}
|
|
6522
|
+
});
|
|
6523
|
+
return json(result, { status: parsed.operation === `insert` ? 201 : 200 });
|
|
6289
6524
|
}
|
|
6290
6525
|
async function createAttachment(request, ctx) {
|
|
6291
6526
|
const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
|
|
@@ -6328,13 +6563,13 @@ async function deleteAttachment(request, ctx) {
|
|
|
6328
6563
|
async function updateInboxMessage(request, ctx) {
|
|
6329
6564
|
const parsed = routeBody(request);
|
|
6330
6565
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
6331
|
-
await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
|
|
6332
|
-
return
|
|
6566
|
+
const result = await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
|
|
6567
|
+
return json(result);
|
|
6333
6568
|
}
|
|
6334
6569
|
async function deleteInboxMessage(request, ctx) {
|
|
6335
6570
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
6336
|
-
await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
|
|
6337
|
-
return
|
|
6571
|
+
const result = await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
|
|
6572
|
+
return json(result);
|
|
6338
6573
|
}
|
|
6339
6574
|
async function spawnEntity(request, ctx) {
|
|
6340
6575
|
const parsed = routeBody(request);
|
|
@@ -6384,8 +6619,13 @@ async function spawnEntity(request, ctx) {
|
|
|
6384
6619
|
headers: { "x-write-token": entity.write_token }
|
|
6385
6620
|
});
|
|
6386
6621
|
}
|
|
6387
|
-
function getEntity(request) {
|
|
6388
|
-
|
|
6622
|
+
async function getEntity(request, ctx) {
|
|
6623
|
+
const { entity } = requireExistingEntityRoute(request);
|
|
6624
|
+
const entityType = entity.type ? await ctx.entityManager.registry.getEntityType(entity.type) : null;
|
|
6625
|
+
return json({
|
|
6626
|
+
...toPublicEntity(entity),
|
|
6627
|
+
...entityType?.externally_writable_collections && { externally_writable_collections: entityType.externally_writable_collections }
|
|
6628
|
+
});
|
|
6389
6629
|
}
|
|
6390
6630
|
function headEntity() {
|
|
6391
6631
|
return status(200);
|
|
@@ -6420,6 +6660,16 @@ async function signalEntity(request, ctx) {
|
|
|
6420
6660
|
//#region src/routing/entity-types-router.ts
|
|
6421
6661
|
const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
|
|
6422
6662
|
const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
|
|
6663
|
+
const externallyWritableCollectionsSchema = Type.Record(Type.String(), Type.Object({
|
|
6664
|
+
type: Type.String(),
|
|
6665
|
+
contract: Type.Optional(Type.String()),
|
|
6666
|
+
operations: Type.Optional(Type.Array(Type.Union([
|
|
6667
|
+
Type.Literal(`insert`),
|
|
6668
|
+
Type.Literal(`update`),
|
|
6669
|
+
Type.Literal(`delete`)
|
|
6670
|
+
]))),
|
|
6671
|
+
principalColumn: Type.Optional(Type.String())
|
|
6672
|
+
}, { additionalProperties: false }));
|
|
6423
6673
|
const slashCommandArgumentSchema = Type.Object({
|
|
6424
6674
|
name: Type.String(),
|
|
6425
6675
|
type: Type.Union([
|
|
@@ -6450,7 +6700,8 @@ const registerEntityTypeBodySchema = Type.Object({
|
|
|
6450
6700
|
slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
|
|
6451
6701
|
serve_endpoint: Type.Optional(Type.String()),
|
|
6452
6702
|
default_dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
6453
|
-
permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
|
|
6703
|
+
permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema)),
|
|
6704
|
+
externally_writable_collections: Type.Optional(externallyWritableCollectionsSchema)
|
|
6454
6705
|
}, { additionalProperties: false });
|
|
6455
6706
|
const amendEntityTypeSchemasBodySchema = Type.Object({
|
|
6456
6707
|
inbox_schemas: Type.Optional(schemaMapSchema),
|
|
@@ -6581,7 +6832,20 @@ function parseExpiresAt(value) {
|
|
|
6581
6832
|
if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
|
|
6582
6833
|
return expiresAt;
|
|
6583
6834
|
}
|
|
6835
|
+
/**
|
|
6836
|
+
* The `comments` collection name is reserved for the canonical comments
|
|
6837
|
+
* contract: the UI keys its comment affordances on it, so a divergent
|
|
6838
|
+
* collection registered under that name (or the contract mounted under
|
|
6839
|
+
* another name) would break that assumption silently.
|
|
6840
|
+
*/
|
|
6841
|
+
function validateExternallyWritableCollections(collections) {
|
|
6842
|
+
for (const [name, config] of Object.entries(collections ?? {})) {
|
|
6843
|
+
if (name === `comments` && config.contract !== COMMENTS_CONTRACT) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The externally-writable collection name "comments" is reserved for the "${COMMENTS_CONTRACT}" contract`, 400);
|
|
6844
|
+
if (config.contract === COMMENTS_CONTRACT && name !== `comments`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The "${COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`, 400);
|
|
6845
|
+
}
|
|
6846
|
+
}
|
|
6584
6847
|
function normalizeEntityTypeRequest(parsed) {
|
|
6848
|
+
validateExternallyWritableCollections(parsed.externally_writable_collections);
|
|
6585
6849
|
const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
|
|
6586
6850
|
return {
|
|
6587
6851
|
name: parsed.name ?? ``,
|
|
@@ -6595,7 +6859,8 @@ function normalizeEntityTypeRequest(parsed) {
|
|
|
6595
6859
|
type: `webhook`,
|
|
6596
6860
|
url: serveEndpoint
|
|
6597
6861
|
}] } : void 0),
|
|
6598
|
-
permission_grants: parsed.permission_grants
|
|
6862
|
+
permission_grants: parsed.permission_grants,
|
|
6863
|
+
externally_writable_collections: parsed.externally_writable_collections
|
|
6599
6864
|
};
|
|
6600
6865
|
}
|
|
6601
6866
|
function toPublicEntityType(entityType) {
|
|
@@ -6605,6 +6870,49 @@ function toPublicEntityType(entityType) {
|
|
|
6605
6870
|
};
|
|
6606
6871
|
}
|
|
6607
6872
|
|
|
6873
|
+
//#endregion
|
|
6874
|
+
//#region src/routing/pg-sync-router.ts
|
|
6875
|
+
const pgSyncOptionsSchema = Type.Object({
|
|
6876
|
+
url: Type.Optional(Type.String()),
|
|
6877
|
+
table: Type.String(),
|
|
6878
|
+
columns: Type.Optional(Type.Array(Type.String())),
|
|
6879
|
+
where: Type.Optional(Type.String()),
|
|
6880
|
+
params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
|
|
6881
|
+
replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)]))
|
|
6882
|
+
});
|
|
6883
|
+
const pgSyncRequestMetadataSchema = Type.Object({
|
|
6884
|
+
entityUrl: Type.Optional(Type.String()),
|
|
6885
|
+
entityType: Type.Optional(Type.String()),
|
|
6886
|
+
streamPath: Type.Optional(Type.String()),
|
|
6887
|
+
runtimeConsumerId: Type.Optional(Type.String()),
|
|
6888
|
+
wakeId: Type.Optional(Type.String())
|
|
6889
|
+
});
|
|
6890
|
+
const pgSyncRegisterBodySchema = Type.Object({
|
|
6891
|
+
options: pgSyncOptionsSchema,
|
|
6892
|
+
metadata: Type.Optional(pgSyncRequestMetadataSchema)
|
|
6893
|
+
});
|
|
6894
|
+
const pgSyncRouter = Router({ base: `/_electric/pg-sync` });
|
|
6895
|
+
pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
|
|
6896
|
+
async function registerPgSync(request, ctx) {
|
|
6897
|
+
const { options, metadata } = routeBody(request);
|
|
6898
|
+
if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
|
|
6899
|
+
if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
|
|
6900
|
+
try {
|
|
6901
|
+
const requestMetadata$1 = {
|
|
6902
|
+
tenantId: ctx.service,
|
|
6903
|
+
principalKind: ctx.principal.kind,
|
|
6904
|
+
principalId: ctx.principal.id,
|
|
6905
|
+
principalKey: ctx.principal.key,
|
|
6906
|
+
principalUrl: ctx.principal.url,
|
|
6907
|
+
...metadata ?? {}
|
|
6908
|
+
};
|
|
6909
|
+
const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
|
|
6910
|
+
return json(result);
|
|
6911
|
+
} catch (error) {
|
|
6912
|
+
return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
6913
|
+
}
|
|
6914
|
+
}
|
|
6915
|
+
|
|
6608
6916
|
//#endregion
|
|
6609
6917
|
//#region src/routing/hooks.ts
|
|
6610
6918
|
const SPAN_KEY = Symbol(`agents-server.otel-span`);
|
|
@@ -6679,6 +6987,10 @@ function errorMapper(err, req) {
|
|
|
6679
6987
|
});
|
|
6680
6988
|
}
|
|
6681
6989
|
if (err instanceof ElectricAgentsError) return apiError(err.status, err.code, err.message, err.details);
|
|
6990
|
+
if (err instanceof ElectricProxyError) {
|
|
6991
|
+
serverLog.warn(`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`);
|
|
6992
|
+
return apiError(err.status, err.code, err.message);
|
|
6993
|
+
}
|
|
6682
6994
|
serverLog.error(`[agent-server] Unhandled error:`, err);
|
|
6683
6995
|
return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`);
|
|
6684
6996
|
}
|
|
@@ -7129,6 +7441,7 @@ internalRouter.all(`/runners`, runnersRouter.fetch);
|
|
|
7129
7441
|
internalRouter.all(`/runners/*`, runnersRouter.fetch);
|
|
7130
7442
|
internalRouter.all(`/entities/*`, entitiesRouter.fetch);
|
|
7131
7443
|
internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch);
|
|
7444
|
+
internalRouter.all(`/pg-sync/*`, pgSyncRouter.fetch);
|
|
7132
7445
|
internalRouter.all(`/observations/*`, observationsRouter.fetch);
|
|
7133
7446
|
internalRouter.get(`/electric/*`, electricProxyRouter.fetch);
|
|
7134
7447
|
internalRouter.all(`*`, () => status(404));
|
|
@@ -7512,6 +7825,9 @@ const globalRouter = AutoRouter({
|
|
|
7512
7825
|
finally: [otelEndSpan, applyCors]
|
|
7513
7826
|
});
|
|
7514
7827
|
globalRouter.all(`/_electric/shared-state/*`, durableStreamsRouter.fetch);
|
|
7828
|
+
globalRouter.all(`/_electric/pg-sync/register`, internalRouter.fetch);
|
|
7829
|
+
globalRouter.get(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
|
|
7830
|
+
globalRouter.head(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
|
|
7515
7831
|
globalRouter.all(`/_electric/*`, internalRouter.fetch);
|
|
7516
7832
|
globalRouter.all(`*`, durableStreamsRouter.fetch);
|
|
7517
7833
|
|
|
@@ -7554,7 +7870,7 @@ const ENTITY_SHAPE_COLUMNS = [
|
|
|
7554
7870
|
`created_at`,
|
|
7555
7871
|
`updated_at`
|
|
7556
7872
|
];
|
|
7557
|
-
function parseElectricOffset(offset) {
|
|
7873
|
+
function parseElectricOffset$1(offset) {
|
|
7558
7874
|
if (offset === `-1`) return offset;
|
|
7559
7875
|
return /^\d+_\d+$/.test(offset) ? offset : null;
|
|
7560
7876
|
}
|
|
@@ -7646,7 +7962,7 @@ var EntityBridge = class {
|
|
|
7646
7962
|
});
|
|
7647
7963
|
await this.loadCurrentMembers();
|
|
7648
7964
|
if (this.initialShapeHandle && this.initialShapeOffset) {
|
|
7649
|
-
const initialOffset = parseElectricOffset(this.initialShapeOffset);
|
|
7965
|
+
const initialOffset = parseElectricOffset$1(this.initialShapeOffset);
|
|
7650
7966
|
if (initialOffset) {
|
|
7651
7967
|
this.startLiveStream(initialOffset, this.initialShapeHandle);
|
|
7652
7968
|
return;
|
|
@@ -8132,6 +8448,9 @@ function isPermanentElectricAgentsError(err) {
|
|
|
8132
8448
|
const name = typeof err === `object` && err !== null && `name` in err ? err.name : void 0;
|
|
8133
8449
|
return name === `ElectricAgentsError` && typeof status$1 === `number` && status$1 >= 400 && status$1 < 500;
|
|
8134
8450
|
}
|
|
8451
|
+
function cronTaskStreamPath(payload) {
|
|
8452
|
+
return typeof payload.streamPath === `string` ? payload.streamPath : null;
|
|
8453
|
+
}
|
|
8135
8454
|
function normalizeTask(row) {
|
|
8136
8455
|
return {
|
|
8137
8456
|
id: Number(row.id),
|
|
@@ -8474,6 +8793,15 @@ var Scheduler = class {
|
|
|
8474
8793
|
`;
|
|
8475
8794
|
if (completed.length === 0) return;
|
|
8476
8795
|
const nextFireAt = getNextCronFireAt(task.cronExpression, task.cronTimezone, task.fireAt);
|
|
8796
|
+
const streamPath = cronTaskStreamPath(task.payload);
|
|
8797
|
+
const subscriberRows = streamPath ? await sql$1`
|
|
8798
|
+
select 1 as exists
|
|
8799
|
+
from wake_registrations
|
|
8800
|
+
where tenant_id = ${tenantId}
|
|
8801
|
+
and source_url = ${streamPath}
|
|
8802
|
+
limit 1
|
|
8803
|
+
` : [];
|
|
8804
|
+
if (subscriberRows.length === 0) return;
|
|
8477
8805
|
await sql$1`
|
|
8478
8806
|
insert into scheduled_tasks (
|
|
8479
8807
|
tenant_id,
|
|
@@ -8573,6 +8901,308 @@ var Scheduler = class {
|
|
|
8573
8901
|
}
|
|
8574
8902
|
};
|
|
8575
8903
|
|
|
8904
|
+
//#endregion
|
|
8905
|
+
//#region src/pg-sync-bridge-manager.ts
|
|
8906
|
+
const PG_SYNC_ELECTRIC_SHAPE_URL = process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ?? `http://localhost:3000/v1/shape`;
|
|
8907
|
+
const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
|
|
8908
|
+
const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
|
|
8909
|
+
function buildElectricShapeParams(options) {
|
|
8910
|
+
return {
|
|
8911
|
+
table: options.table,
|
|
8912
|
+
...options.columns !== void 0 ? { columns: [...options.columns] } : {},
|
|
8913
|
+
...options.where !== void 0 ? { where: options.where } : {},
|
|
8914
|
+
...options.params !== void 0 ? { params: Array.isArray(options.params) ? [...options.params] : { ...options.params } } : {},
|
|
8915
|
+
...options.replica !== void 0 ? { replica: options.replica } : {},
|
|
8916
|
+
...options.metadata?.tenantId ? { electric_agents_tenant_id: options.metadata.tenantId } : {},
|
|
8917
|
+
...options.metadata?.principalKind ? { electric_agents_principal_kind: options.metadata.principalKind } : {},
|
|
8918
|
+
...options.metadata?.principalId ? { electric_agents_principal_id: options.metadata.principalId } : {},
|
|
8919
|
+
...options.metadata?.principalKey ? { electric_agents_principal_key: options.metadata.principalKey } : {},
|
|
8920
|
+
...options.metadata?.principalUrl ? { electric_agents_principal_url: options.metadata.principalUrl } : {},
|
|
8921
|
+
...options.metadata?.entityUrl ? { electric_agents_entity_url: options.metadata.entityUrl } : {},
|
|
8922
|
+
...options.metadata?.entityType ? { electric_agents_entity_type: options.metadata.entityType } : {},
|
|
8923
|
+
...options.metadata?.streamPath ? { electric_agents_stream_path: options.metadata.streamPath } : {},
|
|
8924
|
+
...options.metadata?.runtimeConsumerId ? { electric_agents_runtime_consumer_id: options.metadata.runtimeConsumerId } : {},
|
|
8925
|
+
...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
|
|
8926
|
+
};
|
|
8927
|
+
}
|
|
8928
|
+
function jsonSafe(value) {
|
|
8929
|
+
if (typeof value === `bigint`) return value.toString();
|
|
8930
|
+
if (value === null || typeof value !== `object`) return value;
|
|
8931
|
+
if (Array.isArray(value)) return value.map(jsonSafe);
|
|
8932
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
|
|
8933
|
+
}
|
|
8934
|
+
function stableJson(value) {
|
|
8935
|
+
if (typeof value === `bigint`) return JSON.stringify(value.toString());
|
|
8936
|
+
if (value === null || typeof value !== `object`) return JSON.stringify(value);
|
|
8937
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`;
|
|
8938
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(`,`)}}`;
|
|
8939
|
+
}
|
|
8940
|
+
function parseElectricOffset(offset) {
|
|
8941
|
+
if (offset === `-1`) return offset;
|
|
8942
|
+
return /^\d+_\d+$/.test(offset) ? offset : null;
|
|
8943
|
+
}
|
|
8944
|
+
function rowKeyForMessage(message) {
|
|
8945
|
+
const headers = message.headers;
|
|
8946
|
+
const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
|
|
8947
|
+
return candidate === void 0 ? void 0 : stableJson(candidate);
|
|
8948
|
+
}
|
|
8949
|
+
function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
|
|
8950
|
+
const operation = message.headers.operation;
|
|
8951
|
+
if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
|
|
8952
|
+
const sourceRef = typeof optionsOrSourceRef === `string` ? optionsOrSourceRef : sourceRefForPgSync(optionsOrSourceRef);
|
|
8953
|
+
const rowKey = rowKeyForMessage(message);
|
|
8954
|
+
const offset = message.headers.offset;
|
|
8955
|
+
if (typeof offset !== `string` || offset.length === 0) return null;
|
|
8956
|
+
const messageKeyPart = offset;
|
|
8957
|
+
const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
|
|
8958
|
+
const timestamp$1 = new Date().toISOString();
|
|
8959
|
+
const oldValue = message.old_value;
|
|
8960
|
+
const safeValue = jsonSafe(message.value);
|
|
8961
|
+
const safeOldValue = jsonSafe(oldValue);
|
|
8962
|
+
const safeHeaders = jsonSafe(message.headers);
|
|
8963
|
+
return {
|
|
8964
|
+
type: `pg_sync_change`,
|
|
8965
|
+
key: messageKey,
|
|
8966
|
+
value: {
|
|
8967
|
+
key: messageKey,
|
|
8968
|
+
table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
|
|
8969
|
+
operation,
|
|
8970
|
+
...rowKey !== void 0 ? { rowKey } : {},
|
|
8971
|
+
...message.value !== void 0 ? { value: safeValue } : {},
|
|
8972
|
+
...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
|
|
8973
|
+
headers: safeHeaders,
|
|
8974
|
+
...typeof offset === `string` ? { offset } : {},
|
|
8975
|
+
receivedAt: timestamp$1
|
|
8976
|
+
},
|
|
8977
|
+
headers: {
|
|
8978
|
+
operation,
|
|
8979
|
+
timestamp: timestamp$1
|
|
8980
|
+
}
|
|
8981
|
+
};
|
|
8982
|
+
}
|
|
8983
|
+
function cursorFromRow(row) {
|
|
8984
|
+
return row?.shapeHandle && row.shapeOffset ? {
|
|
8985
|
+
handle: row.shapeHandle,
|
|
8986
|
+
offset: row.shapeOffset,
|
|
8987
|
+
initialSnapshotComplete: row.initialSnapshotComplete
|
|
8988
|
+
} : void 0;
|
|
8989
|
+
}
|
|
8990
|
+
var PgSyncBridge = class {
|
|
8991
|
+
producer = null;
|
|
8992
|
+
unsubscribe = null;
|
|
8993
|
+
abortController = null;
|
|
8994
|
+
skipChangesUntilUpToDate = false;
|
|
8995
|
+
recovering = false;
|
|
8996
|
+
committedCursor;
|
|
8997
|
+
retryAttempt = 0;
|
|
8998
|
+
constructor(sourceRef, streamUrl, options, resolvedSource, retry, streamClient, registry, evaluateWakes, initialCursor) {
|
|
8999
|
+
this.sourceRef = sourceRef;
|
|
9000
|
+
this.streamUrl = streamUrl;
|
|
9001
|
+
this.options = options;
|
|
9002
|
+
this.resolvedSource = resolvedSource;
|
|
9003
|
+
this.retry = retry;
|
|
9004
|
+
this.streamClient = streamClient;
|
|
9005
|
+
this.registry = registry;
|
|
9006
|
+
this.evaluateWakes = evaluateWakes;
|
|
9007
|
+
this.initialCursor = initialCursor;
|
|
9008
|
+
this.committedCursor = initialCursor;
|
|
9009
|
+
}
|
|
9010
|
+
async start() {
|
|
9011
|
+
if (!this.producer) this.producer = new IdempotentProducer(new DurableStream({
|
|
9012
|
+
url: `${this.streamClient.baseUrl}${this.streamUrl}`,
|
|
9013
|
+
contentType: `application/json`
|
|
9014
|
+
}), `pg-sync-bridge-${this.sourceRef}`);
|
|
9015
|
+
if (this.initialCursor) {
|
|
9016
|
+
const offset = parseElectricOffset(this.initialCursor.offset);
|
|
9017
|
+
if (offset) {
|
|
9018
|
+
this.startStream(offset, this.initialCursor.handle, !this.initialCursor.initialSnapshotComplete);
|
|
9019
|
+
return;
|
|
9020
|
+
}
|
|
9021
|
+
}
|
|
9022
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
9023
|
+
this.startStream(`now`, void 0, true);
|
|
9024
|
+
}
|
|
9025
|
+
async stop() {
|
|
9026
|
+
this.unsubscribe?.();
|
|
9027
|
+
this.abortController?.abort();
|
|
9028
|
+
this.unsubscribe = null;
|
|
9029
|
+
this.abortController = null;
|
|
9030
|
+
try {
|
|
9031
|
+
await this.producer?.flush();
|
|
9032
|
+
} finally {
|
|
9033
|
+
await this.producer?.detach();
|
|
9034
|
+
this.producer = null;
|
|
9035
|
+
}
|
|
9036
|
+
}
|
|
9037
|
+
startStream(offset, handle, skipChangesUntilUpToDate = false, log = offset === `now` ? `changes_only` : `full`) {
|
|
9038
|
+
this.unsubscribe?.();
|
|
9039
|
+
this.abortController?.abort();
|
|
9040
|
+
this.skipChangesUntilUpToDate = skipChangesUntilUpToDate;
|
|
9041
|
+
this.abortController = new AbortController();
|
|
9042
|
+
const stream = new ShapeStream({
|
|
9043
|
+
url: this.resolvedSource.url,
|
|
9044
|
+
params: buildElectricShapeParams(this.options),
|
|
9045
|
+
offset,
|
|
9046
|
+
log,
|
|
9047
|
+
...handle ? { handle } : {},
|
|
9048
|
+
signal: this.abortController.signal
|
|
9049
|
+
});
|
|
9050
|
+
this.unsubscribe = stream.subscribe(async (messages) => {
|
|
9051
|
+
try {
|
|
9052
|
+
for (const message of messages) {
|
|
9053
|
+
if (isControlMessage(message)) {
|
|
9054
|
+
if (message.headers.control === `must-refetch`) {
|
|
9055
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
9056
|
+
this.startStream(`now`, void 0, true);
|
|
9057
|
+
return;
|
|
9058
|
+
}
|
|
9059
|
+
if (message.headers.control === `up-to-date`) {
|
|
9060
|
+
this.skipChangesUntilUpToDate = false;
|
|
9061
|
+
await this.persistCursor(stream, true);
|
|
9062
|
+
continue;
|
|
9063
|
+
}
|
|
9064
|
+
await this.persistCursor(stream);
|
|
9065
|
+
continue;
|
|
9066
|
+
}
|
|
9067
|
+
if (!isChangeMessage(message)) continue;
|
|
9068
|
+
if (!this.skipChangesUntilUpToDate) {
|
|
9069
|
+
const event = pgSyncMessageToDurableEvent(message, this.options);
|
|
9070
|
+
if (event) {
|
|
9071
|
+
if (!this.producer) throw new Error(`pg-sync producer is not started`);
|
|
9072
|
+
await this.producer.append(JSON.stringify(event));
|
|
9073
|
+
await this.producer.flush?.();
|
|
9074
|
+
await this.evaluateWakes?.(this.streamUrl, event);
|
|
9075
|
+
}
|
|
9076
|
+
}
|
|
9077
|
+
await this.persistCursor(stream);
|
|
9078
|
+
this.retryAttempt = 0;
|
|
9079
|
+
}
|
|
9080
|
+
} catch (error) {
|
|
9081
|
+
serverLog.warn(`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`, error);
|
|
9082
|
+
await this.recoverStream();
|
|
9083
|
+
}
|
|
9084
|
+
}, (error) => {
|
|
9085
|
+
if (this.abortController?.signal.aborted) return;
|
|
9086
|
+
serverLog.warn(`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`, error);
|
|
9087
|
+
this.recoverStream();
|
|
9088
|
+
});
|
|
9089
|
+
}
|
|
9090
|
+
async recoverStream() {
|
|
9091
|
+
if (this.recovering) return;
|
|
9092
|
+
this.recovering = true;
|
|
9093
|
+
try {
|
|
9094
|
+
const attempt = this.retryAttempt++;
|
|
9095
|
+
const baseDelay = Math.min(this.retry.initialDelayMs * 2 ** attempt, this.retry.maxDelayMs);
|
|
9096
|
+
const jitter = Math.floor(baseDelay * .2 * this.retry.random());
|
|
9097
|
+
const delay = baseDelay + jitter;
|
|
9098
|
+
if (delay > 0) await this.retry.sleep(delay);
|
|
9099
|
+
const offset = this.committedCursor ? parseElectricOffset(this.committedCursor.offset) : null;
|
|
9100
|
+
if (offset && this.committedCursor) this.startStream(offset, this.committedCursor.handle, !this.committedCursor.initialSnapshotComplete);
|
|
9101
|
+
else {
|
|
9102
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
9103
|
+
this.startStream(`now`, void 0, true);
|
|
9104
|
+
}
|
|
9105
|
+
} finally {
|
|
9106
|
+
this.recovering = false;
|
|
9107
|
+
}
|
|
9108
|
+
}
|
|
9109
|
+
async persistCursor(stream, initialSnapshotComplete = !this.skipChangesUntilUpToDate) {
|
|
9110
|
+
const shapeHandle = stream.shapeHandle;
|
|
9111
|
+
const shapeOffset = stream.lastOffset;
|
|
9112
|
+
if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return;
|
|
9113
|
+
await this.registry?.updatePgSyncBridgeCursor(this.sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete);
|
|
9114
|
+
this.committedCursor = {
|
|
9115
|
+
handle: shapeHandle,
|
|
9116
|
+
offset: shapeOffset,
|
|
9117
|
+
initialSnapshotComplete
|
|
9118
|
+
};
|
|
9119
|
+
}
|
|
9120
|
+
};
|
|
9121
|
+
var PgSyncBridgeManager = class {
|
|
9122
|
+
bridges = new Map();
|
|
9123
|
+
starting = new Map();
|
|
9124
|
+
url;
|
|
9125
|
+
retry;
|
|
9126
|
+
constructor(streamClient, evaluateWakes, registry, options = {}) {
|
|
9127
|
+
this.streamClient = streamClient;
|
|
9128
|
+
this.evaluateWakes = evaluateWakes;
|
|
9129
|
+
this.registry = registry;
|
|
9130
|
+
this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
|
|
9131
|
+
this.retry = {
|
|
9132
|
+
initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
|
|
9133
|
+
maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
|
|
9134
|
+
random: options.retry?.random ?? Math.random,
|
|
9135
|
+
sleep: options.retry?.sleep ?? ((ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)))
|
|
9136
|
+
};
|
|
9137
|
+
}
|
|
9138
|
+
async start() {
|
|
9139
|
+
const rows = await this.registry?.listPgSyncBridges?.();
|
|
9140
|
+
if (!rows) return;
|
|
9141
|
+
await Promise.all(rows.map((row) => this.ensureBridge(row).catch((error) => {
|
|
9142
|
+
serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
|
|
9143
|
+
})));
|
|
9144
|
+
}
|
|
9145
|
+
async register(options, metadata) {
|
|
9146
|
+
const mergedMetadata = {
|
|
9147
|
+
...options.metadata,
|
|
9148
|
+
...metadata
|
|
9149
|
+
};
|
|
9150
|
+
const canonicalOptions = {
|
|
9151
|
+
...canonicalPgSyncOptions(options),
|
|
9152
|
+
...Object.keys(mergedMetadata).length > 0 ? { metadata: mergedMetadata } : {}
|
|
9153
|
+
};
|
|
9154
|
+
const resolvedSource = this.resolveSource(canonicalOptions);
|
|
9155
|
+
const sourceRef = sourceRefForPgSync(canonicalOptions);
|
|
9156
|
+
const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId);
|
|
9157
|
+
const row = await this.registry?.upsertPgSyncBridge({
|
|
9158
|
+
sourceRef,
|
|
9159
|
+
options: canonicalOptions,
|
|
9160
|
+
streamUrl
|
|
9161
|
+
});
|
|
9162
|
+
await this.streamClient.ensure(streamUrl, { contentType: `application/json` });
|
|
9163
|
+
if (!this.bridges.has(sourceRef)) {
|
|
9164
|
+
let start = this.starting.get(sourceRef);
|
|
9165
|
+
if (!start) {
|
|
9166
|
+
start = (async () => {
|
|
9167
|
+
const bridge = new PgSyncBridge(sourceRef, streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
|
|
9168
|
+
await bridge.start();
|
|
9169
|
+
this.bridges.set(sourceRef, bridge);
|
|
9170
|
+
})().finally(() => this.starting.delete(sourceRef));
|
|
9171
|
+
this.starting.set(sourceRef, start);
|
|
9172
|
+
}
|
|
9173
|
+
await start;
|
|
9174
|
+
}
|
|
9175
|
+
return {
|
|
9176
|
+
sourceRef,
|
|
9177
|
+
streamUrl
|
|
9178
|
+
};
|
|
9179
|
+
}
|
|
9180
|
+
async ensureBridge(row) {
|
|
9181
|
+
if (this.bridges.has(row.sourceRef)) return;
|
|
9182
|
+
let start = this.starting.get(row.sourceRef);
|
|
9183
|
+
if (!start) {
|
|
9184
|
+
start = (async () => {
|
|
9185
|
+
await this.streamClient.ensure(row.streamUrl, { contentType: `application/json` });
|
|
9186
|
+
const canonicalOptions = canonicalPgSyncOptions(row.options);
|
|
9187
|
+
const resolvedSource = this.resolveSource(canonicalOptions);
|
|
9188
|
+
const bridge = new PgSyncBridge(row.sourceRef, row.streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
|
|
9189
|
+
await bridge.start();
|
|
9190
|
+
this.bridges.set(row.sourceRef, bridge);
|
|
9191
|
+
})().finally(() => this.starting.delete(row.sourceRef));
|
|
9192
|
+
this.starting.set(row.sourceRef, start);
|
|
9193
|
+
}
|
|
9194
|
+
await start;
|
|
9195
|
+
}
|
|
9196
|
+
resolveSource(options) {
|
|
9197
|
+
return { url: options.url ?? this.url };
|
|
9198
|
+
}
|
|
9199
|
+
async stop() {
|
|
9200
|
+
await Promise.allSettled(this.starting.values());
|
|
9201
|
+
await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()));
|
|
9202
|
+
this.bridges.clear();
|
|
9203
|
+
}
|
|
9204
|
+
};
|
|
9205
|
+
|
|
8576
9206
|
//#endregion
|
|
8577
9207
|
//#region src/runtime.ts
|
|
8578
9208
|
function omitUndefined(value) {
|
|
@@ -8587,6 +9217,7 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
8587
9217
|
wakeRegistry;
|
|
8588
9218
|
scheduler;
|
|
8589
9219
|
entityBridgeManager;
|
|
9220
|
+
pgSyncBridgeManager;
|
|
8590
9221
|
claimWriteTokens;
|
|
8591
9222
|
manager;
|
|
8592
9223
|
constructor(options) {
|
|
@@ -8611,9 +9242,10 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
8611
9242
|
writeTokenValidator: (entity, token) => this.claimWriteTokens.isValid(this.serviceId, entity.streams.main, token),
|
|
8612
9243
|
stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false
|
|
8613
9244
|
});
|
|
9245
|
+
this.pgSyncBridgeManager = options.pgSyncBridgeManager ?? new PgSyncBridgeManager(this.streamClient, (sourceUrl, event) => this.manager.evaluateWakes(sourceUrl, event), this.registry, options.pgSync);
|
|
8614
9246
|
}
|
|
8615
9247
|
async stop() {
|
|
8616
|
-
await this.manager.shutdown();
|
|
9248
|
+
await Promise.all([this.manager.shutdown(), this.pgSyncBridgeManager.stop()]);
|
|
8617
9249
|
}
|
|
8618
9250
|
async rehydrateCronSchedules() {
|
|
8619
9251
|
const rows = await this.db.select({ sourceUrl: wakeRegistrations.sourceUrl }).from(wakeRegistrations).where(eq(wakeRegistrations.tenantId, this.serviceId));
|
|
@@ -9377,7 +10009,10 @@ var WakeRegistry = class {
|
|
|
9377
10009
|
}
|
|
9378
10010
|
if (!isChangeMessage(message)) return;
|
|
9379
10011
|
if (message.headers.operation === `delete`) {
|
|
9380
|
-
|
|
10012
|
+
const oldValue = message.old_value;
|
|
10013
|
+
const oldId = Number(oldValue?.id);
|
|
10014
|
+
if (Number.isFinite(oldId)) this.removeCachedRegistrationByDbId(oldId);
|
|
10015
|
+
else this.resetCachedRegistrations();
|
|
9381
10016
|
return;
|
|
9382
10017
|
}
|
|
9383
10018
|
this.upsertCachedRegistration(this.normalizeShapeRow(message.value));
|
|
@@ -9488,9 +10123,9 @@ var WakeRegistry = class {
|
|
|
9488
10123
|
matchCondition(reg, event) {
|
|
9489
10124
|
if (reg.condition === `runFinished`) {
|
|
9490
10125
|
if (event.type !== `run`) return null;
|
|
9491
|
-
const value = event.value;
|
|
10126
|
+
const value$1 = event.value;
|
|
9492
10127
|
const headers$1 = event.headers;
|
|
9493
|
-
const status$1 = value?.status;
|
|
10128
|
+
const status$1 = value$1?.status;
|
|
9494
10129
|
const operation$1 = headers$1?.operation;
|
|
9495
10130
|
if (operation$1 !== `update`) return null;
|
|
9496
10131
|
if (status$1 !== `completed` && status$1 !== `failed`) return null;
|
|
@@ -9513,13 +10148,15 @@ var WakeRegistry = class {
|
|
|
9513
10148
|
}
|
|
9514
10149
|
const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
|
|
9515
10150
|
if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
|
|
10151
|
+
const value = event.value;
|
|
9516
10152
|
const change = {
|
|
9517
10153
|
collection: eventType,
|
|
9518
10154
|
kind,
|
|
9519
10155
|
key: event.key || ``
|
|
9520
10156
|
};
|
|
10157
|
+
if (value && `value` in value) change.value = value.value;
|
|
10158
|
+
if (value && `oldValue` in value) change.oldValue = value.oldValue;
|
|
9521
10159
|
if (eventType === `inbox`) {
|
|
9522
|
-
const value = event.value;
|
|
9523
10160
|
if (typeof value?.from === `string`) change.from = value.from;
|
|
9524
10161
|
if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
|
|
9525
10162
|
if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
|
|
@@ -9563,12 +10200,15 @@ async function startStandaloneAgentsRuntime(options) {
|
|
|
9563
10200
|
wakeRegistry,
|
|
9564
10201
|
scheduler,
|
|
9565
10202
|
entityBridgeManager,
|
|
10203
|
+
pgSyncBridgeManager: options.pgSyncBridgeManager,
|
|
10204
|
+
pgSync: options.pgSync,
|
|
9566
10205
|
stopWakeRegistryOnShutdown: options.wakeRegistry ? false : true
|
|
9567
10206
|
});
|
|
9568
10207
|
const startWakeRegistry = options.startWakeRegistry ?? true;
|
|
9569
10208
|
const startScheduler = options.startScheduler ?? true;
|
|
9570
10209
|
const startTagStreamOutboxDrainer = options.startTagStreamOutboxDrainer ?? true;
|
|
9571
10210
|
const startEntityBridgeManager = options.startEntityBridgeManager ?? true;
|
|
10211
|
+
const startPgSyncBridgeManager = options.startPgSyncBridgeManager ?? true;
|
|
9572
10212
|
const rehydrateOnStart = options.rehydrateOnStart ?? true;
|
|
9573
10213
|
let entityBridgeManagerStarted = false;
|
|
9574
10214
|
let tagStreamOutboxDrainerStarted = false;
|
|
@@ -9605,6 +10245,10 @@ async function startStandaloneAgentsRuntime(options) {
|
|
|
9605
10245
|
await entityBridgeManager.start();
|
|
9606
10246
|
entityBridgeManagerStarted = true;
|
|
9607
10247
|
}
|
|
10248
|
+
if (startPgSyncBridgeManager) {
|
|
10249
|
+
serverLog.info(`[agent-server] starting pg-sync bridge manager...`);
|
|
10250
|
+
await runtime.pgSyncBridgeManager.start?.();
|
|
10251
|
+
}
|
|
9608
10252
|
if (startTagStreamOutboxDrainer) {
|
|
9609
10253
|
serverLog.info(`[agent-server] starting tag stream outbox drainer...`);
|
|
9610
10254
|
tagStreamOutboxDrainer.start();
|
|
@@ -9632,6 +10276,7 @@ async function startStandaloneAgentsRuntime(options) {
|
|
|
9632
10276
|
manager: runtime.manager,
|
|
9633
10277
|
scheduler,
|
|
9634
10278
|
entityBridgeManager,
|
|
10279
|
+
pgSyncBridgeManager: runtime.pgSyncBridgeManager,
|
|
9635
10280
|
tagStreamOutboxDrainer,
|
|
9636
10281
|
stop
|
|
9637
10282
|
};
|
|
@@ -9749,7 +10394,8 @@ var ElectricAgentsServer = class {
|
|
|
9749
10394
|
pgClient: client,
|
|
9750
10395
|
streamClient: this.streamClient,
|
|
9751
10396
|
electricUrl: this.options.electricUrl,
|
|
9752
|
-
electricSecret: this.options.electricSecret
|
|
10397
|
+
electricSecret: this.options.electricSecret,
|
|
10398
|
+
pgSync: this.options.pgSync
|
|
9753
10399
|
});
|
|
9754
10400
|
this.electricAgentsManager = this.standaloneRuntime.manager;
|
|
9755
10401
|
this.entityBridgeManager = this.standaloneRuntime.entityBridgeManager;
|
|
@@ -9871,6 +10517,7 @@ var ElectricAgentsServer = class {
|
|
|
9871
10517
|
streamClient: this.streamClient,
|
|
9872
10518
|
runtime: this.standaloneRuntime.runtime,
|
|
9873
10519
|
entityBridgeManager: this.entityBridgeManager,
|
|
10520
|
+
pgSyncBridgeManager: this.standaloneRuntime.runtime.pgSyncBridgeManager,
|
|
9874
10521
|
...this.options.eventSources ? { eventSources: this.options.eventSources } : {},
|
|
9875
10522
|
...this.options.ensureEventSourceWakeSource ? { ensureEventSourceWakeSource: this.options.ensureEventSourceWakeSource } : {},
|
|
9876
10523
|
...this.options.authorizeRequest ? { authorizeRequest: this.options.authorizeRequest } : {},
|