@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/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import postgres from "postgres";
|
|
|
8
8
|
import { and, desc, eq, inArray, lt, ne, sql } from "drizzle-orm";
|
|
9
9
|
import { bigint, bigserial, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
|
|
10
10
|
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
|
|
11
|
-
import { COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
11
|
+
import { COMMENTS_CONTRACT, COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, canonicalPgSyncOptions, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
12
12
|
import { DurableStream, DurableStreamError, FetchError, IdempotentProducer } from "@durable-streams/client";
|
|
13
13
|
import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
|
|
14
14
|
import pino from "pino";
|
|
@@ -32,6 +32,7 @@ __export(schema_exports, {
|
|
|
32
32
|
entityPermissionGrants: () => entityPermissionGrants,
|
|
33
33
|
entityTypePermissionGrants: () => entityTypePermissionGrants,
|
|
34
34
|
entityTypes: () => entityTypes,
|
|
35
|
+
pgSyncBridges: () => pgSyncBridges,
|
|
35
36
|
runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
|
|
36
37
|
runners: () => runners,
|
|
37
38
|
scheduledTasks: () => scheduledTasks,
|
|
@@ -49,6 +50,7 @@ const entityTypes = pgTable(`entity_types`, {
|
|
|
49
50
|
creationSchema: jsonb(`creation_schema`),
|
|
50
51
|
inboxSchemas: jsonb(`inbox_schemas`),
|
|
51
52
|
stateSchemas: jsonb(`state_schemas`),
|
|
53
|
+
externallyWritableCollections: jsonb(`externally_writable_collections`).$type(),
|
|
52
54
|
slashCommands: jsonb(`slash_commands`),
|
|
53
55
|
serveEndpoint: text(`serve_endpoint`),
|
|
54
56
|
defaultDispatchPolicy: jsonb(`default_dispatch_policy`),
|
|
@@ -353,6 +355,18 @@ const scheduledTasks = pgTable(`scheduled_tasks`, {
|
|
|
353
355
|
index(`idx_scheduled_tasks_manifest_pending`).on(table.tenantId, table.ownerEntityUrl, table.manifestKey).where(sql`${table.kind} = 'delayed_send' AND ${table.completedAt} IS NULL AND ${table.manifestKey} IS NOT NULL`),
|
|
354
356
|
index(`idx_scheduled_tasks_stale_claims`).on(table.tenantId, table.claimedAt).where(sql`${table.completedAt} IS NULL AND ${table.claimedAt} IS NOT NULL`)
|
|
355
357
|
]);
|
|
358
|
+
const pgSyncBridges = pgTable(`pg_sync_bridges`, {
|
|
359
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
360
|
+
sourceRef: text(`source_ref`).notNull(),
|
|
361
|
+
options: jsonb(`options`).notNull(),
|
|
362
|
+
streamUrl: text(`stream_url`).notNull(),
|
|
363
|
+
shapeHandle: text(`shape_handle`),
|
|
364
|
+
shapeOffset: text(`shape_offset`),
|
|
365
|
+
initialSnapshotComplete: boolean(`initial_snapshot_complete`).notNull().default(false),
|
|
366
|
+
lastTouchedAt: timestamp(`last_touched_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
367
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
368
|
+
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
369
|
+
}, (table) => [primaryKey({ columns: [table.tenantId, table.sourceRef] }), unique(`uq_pg_sync_bridges_stream_url`).on(table.tenantId, table.streamUrl)]);
|
|
356
370
|
const entityBridges = pgTable(`entity_bridges`, {
|
|
357
371
|
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
358
372
|
sourceRef: text(`source_ref`).notNull(),
|
|
@@ -813,6 +827,9 @@ var PostgresRegistry = class {
|
|
|
813
827
|
entityBridgeWhere(sourceRef) {
|
|
814
828
|
return and(eq(entityBridges.tenantId, this.tenantId), eq(entityBridges.sourceRef, sourceRef));
|
|
815
829
|
}
|
|
830
|
+
pgSyncBridgeWhere(sourceRef) {
|
|
831
|
+
return and(eq(pgSyncBridges.tenantId, this.tenantId), eq(pgSyncBridges.sourceRef, sourceRef));
|
|
832
|
+
}
|
|
816
833
|
async createEntityType(et) {
|
|
817
834
|
await this.db.insert(entityTypes).values({
|
|
818
835
|
tenantId: this.tenantId,
|
|
@@ -821,6 +838,7 @@ var PostgresRegistry = class {
|
|
|
821
838
|
creationSchema: et.creation_schema ?? null,
|
|
822
839
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
823
840
|
stateSchemas: et.state_schemas ?? null,
|
|
841
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
824
842
|
slashCommands: et.slash_commands ?? null,
|
|
825
843
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
826
844
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -834,6 +852,7 @@ var PostgresRegistry = class {
|
|
|
834
852
|
creationSchema: et.creation_schema ?? null,
|
|
835
853
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
836
854
|
stateSchemas: et.state_schemas ?? null,
|
|
855
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
837
856
|
slashCommands: et.slash_commands ?? null,
|
|
838
857
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
839
858
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -852,6 +871,7 @@ var PostgresRegistry = class {
|
|
|
852
871
|
creationSchema: et.creation_schema ?? null,
|
|
853
872
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
854
873
|
stateSchemas: et.state_schemas ?? null,
|
|
874
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
855
875
|
slashCommands: et.slash_commands ?? null,
|
|
856
876
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
857
877
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -879,6 +899,7 @@ var PostgresRegistry = class {
|
|
|
879
899
|
creationSchema: et.creation_schema ?? null,
|
|
880
900
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
881
901
|
stateSchemas: et.state_schemas ?? null,
|
|
902
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
882
903
|
slashCommands: et.slash_commands ?? null,
|
|
883
904
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
884
905
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -1282,11 +1303,12 @@ var PostgresRegistry = class {
|
|
|
1282
1303
|
};
|
|
1283
1304
|
const nextTags = normalizeTags(mutation.nextTags);
|
|
1284
1305
|
const updatedAt = Date.now();
|
|
1285
|
-
await tx.update(entities).set({
|
|
1306
|
+
const [updateResult] = await tx.update(entities).set({
|
|
1286
1307
|
tags: nextTags,
|
|
1287
1308
|
tagsIndex: buildTagsIndex(nextTags),
|
|
1288
1309
|
updatedAt
|
|
1289
|
-
}).where(this.entityWhere(url));
|
|
1310
|
+
}).where(this.entityWhere(url)).returning({ txid: sql`pg_current_xact_id()::xid::text` });
|
|
1311
|
+
const txid = updateResult ? parseInt(updateResult.txid) : void 0;
|
|
1290
1312
|
await tx.insert(tagStreamOutbox).values({
|
|
1291
1313
|
tenantId: this.tenantId,
|
|
1292
1314
|
entityUrl: url,
|
|
@@ -1304,10 +1326,63 @@ var PostgresRegistry = class {
|
|
|
1304
1326
|
return {
|
|
1305
1327
|
entity,
|
|
1306
1328
|
changed: true,
|
|
1307
|
-
...op === `insert` || op === `update` ? { op } : {}
|
|
1329
|
+
...op === `insert` || op === `update` ? { op } : {},
|
|
1330
|
+
...txid !== void 0 ? { txid } : {}
|
|
1308
1331
|
};
|
|
1309
1332
|
});
|
|
1310
1333
|
}
|
|
1334
|
+
async upsertPgSyncBridge(row) {
|
|
1335
|
+
await this.db.insert(pgSyncBridges).values({
|
|
1336
|
+
tenantId: this.tenantId,
|
|
1337
|
+
sourceRef: row.sourceRef,
|
|
1338
|
+
options: row.options,
|
|
1339
|
+
streamUrl: row.streamUrl,
|
|
1340
|
+
lastTouchedAt: new Date(),
|
|
1341
|
+
updatedAt: new Date()
|
|
1342
|
+
}).onConflictDoUpdate({
|
|
1343
|
+
target: [pgSyncBridges.tenantId, pgSyncBridges.sourceRef],
|
|
1344
|
+
set: {
|
|
1345
|
+
options: row.options,
|
|
1346
|
+
streamUrl: row.streamUrl,
|
|
1347
|
+
initialSnapshotComplete: false,
|
|
1348
|
+
lastTouchedAt: new Date(),
|
|
1349
|
+
updatedAt: new Date()
|
|
1350
|
+
}
|
|
1351
|
+
});
|
|
1352
|
+
const existing = await this.getPgSyncBridge(row.sourceRef);
|
|
1353
|
+
if (!existing) throw new Error(`Failed to load pgSync bridge ${row.sourceRef}`);
|
|
1354
|
+
return existing;
|
|
1355
|
+
}
|
|
1356
|
+
async getPgSyncBridge(sourceRef) {
|
|
1357
|
+
const rows = await this.db.select().from(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef)).limit(1);
|
|
1358
|
+
return rows[0] ? this.rowToPgSyncBridge(rows[0]) : null;
|
|
1359
|
+
}
|
|
1360
|
+
async listPgSyncBridges(tenantId = this.tenantId) {
|
|
1361
|
+
const rows = tenantId === null ? await this.db.select().from(pgSyncBridges) : await this.db.select().from(pgSyncBridges).where(eq(pgSyncBridges.tenantId, tenantId));
|
|
1362
|
+
return rows.map((row) => this.rowToPgSyncBridge(row));
|
|
1363
|
+
}
|
|
1364
|
+
async touchPgSyncBridge(sourceRef) {
|
|
1365
|
+
await this.db.update(pgSyncBridges).set({
|
|
1366
|
+
lastTouchedAt: new Date(),
|
|
1367
|
+
updatedAt: new Date()
|
|
1368
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
1369
|
+
}
|
|
1370
|
+
async updatePgSyncBridgeCursor(sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete) {
|
|
1371
|
+
await this.db.update(pgSyncBridges).set({
|
|
1372
|
+
shapeHandle,
|
|
1373
|
+
shapeOffset,
|
|
1374
|
+
...initialSnapshotComplete !== void 0 ? { initialSnapshotComplete } : {},
|
|
1375
|
+
updatedAt: new Date()
|
|
1376
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
1377
|
+
}
|
|
1378
|
+
async clearPgSyncBridgeCursor(sourceRef) {
|
|
1379
|
+
await this.db.update(pgSyncBridges).set({
|
|
1380
|
+
shapeHandle: null,
|
|
1381
|
+
shapeOffset: null,
|
|
1382
|
+
initialSnapshotComplete: false,
|
|
1383
|
+
updatedAt: new Date()
|
|
1384
|
+
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
1385
|
+
}
|
|
1311
1386
|
async upsertEntityBridge(row) {
|
|
1312
1387
|
await this.db.insert(entityBridges).values({
|
|
1313
1388
|
tenantId: this.tenantId,
|
|
@@ -1470,6 +1545,7 @@ var PostgresRegistry = class {
|
|
|
1470
1545
|
creation_schema: row.creationSchema,
|
|
1471
1546
|
inbox_schemas: row.inboxSchemas,
|
|
1472
1547
|
state_schemas: row.stateSchemas,
|
|
1548
|
+
externally_writable_collections: row.externallyWritableCollections ?? void 0,
|
|
1473
1549
|
slash_commands: row.slashCommands ?? void 0,
|
|
1474
1550
|
serve_endpoint: row.serveEndpoint ?? void 0,
|
|
1475
1551
|
default_dispatch_policy: row.defaultDispatchPolicy ?? void 0,
|
|
@@ -1527,6 +1603,20 @@ var PostgresRegistry = class {
|
|
|
1527
1603
|
updated_at: row.updatedAt
|
|
1528
1604
|
};
|
|
1529
1605
|
}
|
|
1606
|
+
rowToPgSyncBridge(row) {
|
|
1607
|
+
return {
|
|
1608
|
+
tenantId: row.tenantId,
|
|
1609
|
+
sourceRef: row.sourceRef,
|
|
1610
|
+
options: row.options,
|
|
1611
|
+
streamUrl: row.streamUrl,
|
|
1612
|
+
shapeHandle: row.shapeHandle ?? void 0,
|
|
1613
|
+
shapeOffset: row.shapeOffset ?? void 0,
|
|
1614
|
+
initialSnapshotComplete: row.initialSnapshotComplete,
|
|
1615
|
+
lastTouchedAt: row.lastTouchedAt,
|
|
1616
|
+
createdAt: row.createdAt,
|
|
1617
|
+
updatedAt: row.updatedAt
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1530
1620
|
rowToEntityBridge(row) {
|
|
1531
1621
|
return {
|
|
1532
1622
|
tenantId: row.tenantId,
|
|
@@ -3174,6 +3264,9 @@ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
|
|
|
3174
3264
|
function isRecord$1(value) {
|
|
3175
3265
|
return typeof value === `object` && value !== null && !Array.isArray(value);
|
|
3176
3266
|
}
|
|
3267
|
+
function getPgSyncManifestStreamPath(sourceRef) {
|
|
3268
|
+
return `/_electric/pg-sync/${sourceRef}`;
|
|
3269
|
+
}
|
|
3177
3270
|
function extractManifestSourceUrl(manifest) {
|
|
3178
3271
|
if (!manifest) return void 0;
|
|
3179
3272
|
if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
|
|
@@ -3186,6 +3279,7 @@ function extractManifestSourceUrl(manifest) {
|
|
|
3186
3279
|
}
|
|
3187
3280
|
if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
|
|
3188
3281
|
if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
|
|
3282
|
+
if (manifest.sourceType === `pgSync`) return typeof manifest.sourceRef === `string` ? getPgSyncManifestStreamPath(manifest.sourceRef) : void 0;
|
|
3189
3283
|
if (manifest.sourceType === `webhook`) {
|
|
3190
3284
|
if (typeof config?.streamUrl === `string`) return config.streamUrl;
|
|
3191
3285
|
if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
|
|
@@ -3300,6 +3394,13 @@ function isRecord(value) {
|
|
|
3300
3394
|
function cloneRecord(value) {
|
|
3301
3395
|
return JSON.parse(JSON.stringify(value));
|
|
3302
3396
|
}
|
|
3397
|
+
function withOptionalTxid(entity, txid) {
|
|
3398
|
+
if (txid === void 0) return entity;
|
|
3399
|
+
return {
|
|
3400
|
+
...entity,
|
|
3401
|
+
txid
|
|
3402
|
+
};
|
|
3403
|
+
}
|
|
3303
3404
|
/**
|
|
3304
3405
|
* Orchestrates the Electric Agents entity lifecycle: register types, spawn, send, kill.
|
|
3305
3406
|
*
|
|
@@ -3374,6 +3475,7 @@ var EntityManager = class {
|
|
|
3374
3475
|
creation_schema: req.creation_schema,
|
|
3375
3476
|
inbox_schemas: req.inbox_schemas,
|
|
3376
3477
|
state_schemas: req.state_schemas,
|
|
3478
|
+
externally_writable_collections: req.externally_writable_collections,
|
|
3377
3479
|
slash_commands: req.slash_commands,
|
|
3378
3480
|
serve_endpoint: req.serve_endpoint,
|
|
3379
3481
|
default_dispatch_policy: defaultDispatchPolicy,
|
|
@@ -4441,15 +4543,17 @@ var EntityManager = class {
|
|
|
4441
4543
|
await this.registry.updateStatus(entityUrl, `idle`);
|
|
4442
4544
|
await this.entityBridgeManager?.onEntityChanged(entityUrl);
|
|
4443
4545
|
}
|
|
4546
|
+
const txid = crypto.randomUUID();
|
|
4444
4547
|
const envelope = entityStateSchema.inbox.insert({
|
|
4445
4548
|
key,
|
|
4446
|
-
value
|
|
4549
|
+
value,
|
|
4550
|
+
headers: { txid }
|
|
4447
4551
|
});
|
|
4448
4552
|
const encoded = this.encodeChangeEvent(envelope);
|
|
4449
4553
|
try {
|
|
4450
4554
|
if (opts?.producerId) {
|
|
4451
4555
|
await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
|
|
4452
|
-
return;
|
|
4556
|
+
return { txid };
|
|
4453
4557
|
}
|
|
4454
4558
|
await this.streamClient.append(entity.streams.main, encoded);
|
|
4455
4559
|
if (entity.type === `principal` && req.type === `update_identity`) {
|
|
@@ -4457,14 +4561,50 @@ var EntityManager = class {
|
|
|
4457
4561
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent({
|
|
4458
4562
|
type: `identity`,
|
|
4459
4563
|
key: `self`,
|
|
4460
|
-
value: identity
|
|
4564
|
+
value: identity,
|
|
4565
|
+
headers: { txid }
|
|
4461
4566
|
}));
|
|
4462
4567
|
}
|
|
4568
|
+
return { txid };
|
|
4463
4569
|
} catch (err) {
|
|
4464
4570
|
if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
|
|
4465
4571
|
throw err;
|
|
4466
4572
|
}
|
|
4467
4573
|
}
|
|
4574
|
+
async writeCollection(entityUrl, collection, req) {
|
|
4575
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
4576
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4577
|
+
const { externallyWritableCollections } = await this.getEffectiveSchemas(entity);
|
|
4578
|
+
const config = externallyWritableCollections?.[collection];
|
|
4579
|
+
if (!config) throw new ElectricAgentsError(ErrCodeUnauthorized, `Collection "${collection}" is not writable`, 403);
|
|
4580
|
+
const allowedOperations = config.operations ?? [`insert`];
|
|
4581
|
+
if (!allowedOperations.includes(req.operation)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Operation "${req.operation}" is not allowed on collection "${collection}"`, 403);
|
|
4582
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4583
|
+
if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
|
|
4584
|
+
if (req.operation !== `delete` && (req.value === void 0 || req.value === null)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `value is required for ${req.operation}`, 400);
|
|
4585
|
+
if (req.operation !== `insert` && !req.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `key is required for ${req.operation}`, 400);
|
|
4586
|
+
const key = req.key ?? `${collection}-${randomUUID()}`;
|
|
4587
|
+
const event = {
|
|
4588
|
+
type: config.type,
|
|
4589
|
+
key,
|
|
4590
|
+
headers: {
|
|
4591
|
+
operation: req.operation,
|
|
4592
|
+
timestamp: new Date().toISOString(),
|
|
4593
|
+
principal: req.principal
|
|
4594
|
+
}
|
|
4595
|
+
};
|
|
4596
|
+
if (req.operation !== `delete`) event.value = req.value;
|
|
4597
|
+
const validationError = await this.validateWriteEvent(entity, event);
|
|
4598
|
+
if (validationError) throw new ElectricAgentsError(validationError.code, validationError.message, validationError.status);
|
|
4599
|
+
const encoded = this.encodeChangeEvent(event);
|
|
4600
|
+
try {
|
|
4601
|
+
await this.streamClient.append(entity.streams.main, encoded);
|
|
4602
|
+
} catch (err) {
|
|
4603
|
+
if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
|
|
4604
|
+
throw err;
|
|
4605
|
+
}
|
|
4606
|
+
return { key };
|
|
4607
|
+
}
|
|
4468
4608
|
async updateInboxMessage(entityUrl, key, req) {
|
|
4469
4609
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4470
4610
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
@@ -4480,18 +4620,26 @@ var EntityManager = class {
|
|
|
4480
4620
|
if (req.status === `cancelled`) value.cancelled_at = now;
|
|
4481
4621
|
}
|
|
4482
4622
|
if (Object.keys(value).length === 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `No inbox fields to update`, 400);
|
|
4623
|
+
const txid = crypto.randomUUID();
|
|
4483
4624
|
const envelope = entityStateSchema.inbox.update({
|
|
4484
4625
|
key,
|
|
4485
|
-
value
|
|
4626
|
+
value,
|
|
4627
|
+
headers: { txid }
|
|
4486
4628
|
});
|
|
4487
4629
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
4630
|
+
return { txid };
|
|
4488
4631
|
}
|
|
4489
4632
|
async deleteInboxMessage(entityUrl, key) {
|
|
4490
4633
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4491
4634
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4492
4635
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4493
|
-
const
|
|
4636
|
+
const txid = crypto.randomUUID();
|
|
4637
|
+
const envelope = entityStateSchema.inbox.delete({
|
|
4638
|
+
key,
|
|
4639
|
+
headers: { txid }
|
|
4640
|
+
});
|
|
4494
4641
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
4642
|
+
return { txid };
|
|
4495
4643
|
}
|
|
4496
4644
|
isAttachmentStreamPath(path$1) {
|
|
4497
4645
|
return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$1);
|
|
@@ -4580,28 +4728,26 @@ var EntityManager = class {
|
|
|
4580
4728
|
await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
|
|
4581
4729
|
return { txid };
|
|
4582
4730
|
}
|
|
4583
|
-
async setTag(entityUrl, key, req
|
|
4731
|
+
async setTag(entityUrl, key, req) {
|
|
4584
4732
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4585
4733
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4586
|
-
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
4587
4734
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4588
4735
|
if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
|
|
4589
4736
|
const result = await this.registry.setEntityTag(entityUrl, key, req.value);
|
|
4590
4737
|
const updated = result.entity;
|
|
4591
4738
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag write`, 500);
|
|
4592
4739
|
if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4593
|
-
return updated;
|
|
4740
|
+
return withOptionalTxid(updated, result.txid);
|
|
4594
4741
|
}
|
|
4595
|
-
async deleteTag(entityUrl, key
|
|
4742
|
+
async deleteTag(entityUrl, key) {
|
|
4596
4743
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4597
4744
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4598
|
-
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
4599
4745
|
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4600
4746
|
const result = await this.registry.removeEntityTag(entityUrl, key);
|
|
4601
4747
|
const updated = result.entity;
|
|
4602
4748
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
|
|
4603
4749
|
if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4604
|
-
return updated;
|
|
4750
|
+
return withOptionalTxid(updated, result.txid);
|
|
4605
4751
|
}
|
|
4606
4752
|
async ensureEntitiesMembershipStream(tags, principal) {
|
|
4607
4753
|
if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
|
|
@@ -5151,7 +5297,8 @@ var EntityManager = class {
|
|
|
5151
5297
|
async getEffectiveSchemas(entity) {
|
|
5152
5298
|
if (!entity.type) return {
|
|
5153
5299
|
inboxSchemas: entity.inbox_schemas,
|
|
5154
|
-
stateSchemas: entity.state_schemas
|
|
5300
|
+
stateSchemas: entity.state_schemas,
|
|
5301
|
+
externallyWritableCollections: void 0
|
|
5155
5302
|
};
|
|
5156
5303
|
const latestType = await this.registry.getEntityType(entity.type);
|
|
5157
5304
|
return {
|
|
@@ -5162,7 +5309,8 @@ var EntityManager = class {
|
|
|
5162
5309
|
stateSchemas: latestType?.state_schemas ? {
|
|
5163
5310
|
...entity.state_schemas ?? {},
|
|
5164
5311
|
...latestType.state_schemas
|
|
5165
|
-
} : entity.state_schemas
|
|
5312
|
+
} : entity.state_schemas,
|
|
5313
|
+
externallyWritableCollections: latestType?.externally_writable_collections
|
|
5166
5314
|
};
|
|
5167
5315
|
}
|
|
5168
5316
|
isClosedStreamError(err) {
|
|
@@ -5397,6 +5545,9 @@ function isPermanentElectricAgentsError(err) {
|
|
|
5397
5545
|
const name = typeof err === `object` && err !== null && `name` in err ? err.name : void 0;
|
|
5398
5546
|
return name === `ElectricAgentsError` && typeof status$1 === `number` && status$1 >= 400 && status$1 < 500;
|
|
5399
5547
|
}
|
|
5548
|
+
function cronTaskStreamPath(payload) {
|
|
5549
|
+
return typeof payload.streamPath === `string` ? payload.streamPath : null;
|
|
5550
|
+
}
|
|
5400
5551
|
function normalizeTask(row) {
|
|
5401
5552
|
return {
|
|
5402
5553
|
id: Number(row.id),
|
|
@@ -5739,6 +5890,15 @@ var Scheduler = class {
|
|
|
5739
5890
|
`;
|
|
5740
5891
|
if (completed.length === 0) return;
|
|
5741
5892
|
const nextFireAt = getNextCronFireAt(task.cronExpression, task.cronTimezone, task.fireAt);
|
|
5893
|
+
const streamPath = cronTaskStreamPath(task.payload);
|
|
5894
|
+
const subscriberRows = streamPath ? await sql$1`
|
|
5895
|
+
select 1 as exists
|
|
5896
|
+
from wake_registrations
|
|
5897
|
+
where tenant_id = ${tenantId}
|
|
5898
|
+
and source_url = ${streamPath}
|
|
5899
|
+
limit 1
|
|
5900
|
+
` : [];
|
|
5901
|
+
if (subscriberRows.length === 0) return;
|
|
5742
5902
|
await sql$1`
|
|
5743
5903
|
insert into scheduled_tasks (
|
|
5744
5904
|
tenant_id,
|
|
@@ -5838,6 +5998,308 @@ var Scheduler = class {
|
|
|
5838
5998
|
}
|
|
5839
5999
|
};
|
|
5840
6000
|
|
|
6001
|
+
//#endregion
|
|
6002
|
+
//#region src/pg-sync-bridge-manager.ts
|
|
6003
|
+
const PG_SYNC_ELECTRIC_SHAPE_URL = process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ?? `http://localhost:3000/v1/shape`;
|
|
6004
|
+
const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
|
|
6005
|
+
const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
|
|
6006
|
+
function buildElectricShapeParams(options) {
|
|
6007
|
+
return {
|
|
6008
|
+
table: options.table,
|
|
6009
|
+
...options.columns !== void 0 ? { columns: [...options.columns] } : {},
|
|
6010
|
+
...options.where !== void 0 ? { where: options.where } : {},
|
|
6011
|
+
...options.params !== void 0 ? { params: Array.isArray(options.params) ? [...options.params] : { ...options.params } } : {},
|
|
6012
|
+
...options.replica !== void 0 ? { replica: options.replica } : {},
|
|
6013
|
+
...options.metadata?.tenantId ? { electric_agents_tenant_id: options.metadata.tenantId } : {},
|
|
6014
|
+
...options.metadata?.principalKind ? { electric_agents_principal_kind: options.metadata.principalKind } : {},
|
|
6015
|
+
...options.metadata?.principalId ? { electric_agents_principal_id: options.metadata.principalId } : {},
|
|
6016
|
+
...options.metadata?.principalKey ? { electric_agents_principal_key: options.metadata.principalKey } : {},
|
|
6017
|
+
...options.metadata?.principalUrl ? { electric_agents_principal_url: options.metadata.principalUrl } : {},
|
|
6018
|
+
...options.metadata?.entityUrl ? { electric_agents_entity_url: options.metadata.entityUrl } : {},
|
|
6019
|
+
...options.metadata?.entityType ? { electric_agents_entity_type: options.metadata.entityType } : {},
|
|
6020
|
+
...options.metadata?.streamPath ? { electric_agents_stream_path: options.metadata.streamPath } : {},
|
|
6021
|
+
...options.metadata?.runtimeConsumerId ? { electric_agents_runtime_consumer_id: options.metadata.runtimeConsumerId } : {},
|
|
6022
|
+
...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
|
|
6023
|
+
};
|
|
6024
|
+
}
|
|
6025
|
+
function jsonSafe(value) {
|
|
6026
|
+
if (typeof value === `bigint`) return value.toString();
|
|
6027
|
+
if (value === null || typeof value !== `object`) return value;
|
|
6028
|
+
if (Array.isArray(value)) return value.map(jsonSafe);
|
|
6029
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
|
|
6030
|
+
}
|
|
6031
|
+
function stableJson(value) {
|
|
6032
|
+
if (typeof value === `bigint`) return JSON.stringify(value.toString());
|
|
6033
|
+
if (value === null || typeof value !== `object`) return JSON.stringify(value);
|
|
6034
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`;
|
|
6035
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(`,`)}}`;
|
|
6036
|
+
}
|
|
6037
|
+
function parseElectricOffset(offset) {
|
|
6038
|
+
if (offset === `-1`) return offset;
|
|
6039
|
+
return /^\d+_\d+$/.test(offset) ? offset : null;
|
|
6040
|
+
}
|
|
6041
|
+
function rowKeyForMessage(message) {
|
|
6042
|
+
const headers = message.headers;
|
|
6043
|
+
const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
|
|
6044
|
+
return candidate === void 0 ? void 0 : stableJson(candidate);
|
|
6045
|
+
}
|
|
6046
|
+
function pgSyncMessageToDurableEvent(message, optionsOrSourceRef) {
|
|
6047
|
+
const operation = message.headers.operation;
|
|
6048
|
+
if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
|
|
6049
|
+
const sourceRef = typeof optionsOrSourceRef === `string` ? optionsOrSourceRef : sourceRefForPgSync(optionsOrSourceRef);
|
|
6050
|
+
const rowKey = rowKeyForMessage(message);
|
|
6051
|
+
const offset = message.headers.offset;
|
|
6052
|
+
if (typeof offset !== `string` || offset.length === 0) return null;
|
|
6053
|
+
const messageKeyPart = offset;
|
|
6054
|
+
const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
|
|
6055
|
+
const timestamp$1 = new Date().toISOString();
|
|
6056
|
+
const oldValue = message.old_value;
|
|
6057
|
+
const safeValue = jsonSafe(message.value);
|
|
6058
|
+
const safeOldValue = jsonSafe(oldValue);
|
|
6059
|
+
const safeHeaders = jsonSafe(message.headers);
|
|
6060
|
+
return {
|
|
6061
|
+
type: `pg_sync_change`,
|
|
6062
|
+
key: messageKey,
|
|
6063
|
+
value: {
|
|
6064
|
+
key: messageKey,
|
|
6065
|
+
table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
|
|
6066
|
+
operation,
|
|
6067
|
+
...rowKey !== void 0 ? { rowKey } : {},
|
|
6068
|
+
...message.value !== void 0 ? { value: safeValue } : {},
|
|
6069
|
+
...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
|
|
6070
|
+
headers: safeHeaders,
|
|
6071
|
+
...typeof offset === `string` ? { offset } : {},
|
|
6072
|
+
receivedAt: timestamp$1
|
|
6073
|
+
},
|
|
6074
|
+
headers: {
|
|
6075
|
+
operation,
|
|
6076
|
+
timestamp: timestamp$1
|
|
6077
|
+
}
|
|
6078
|
+
};
|
|
6079
|
+
}
|
|
6080
|
+
function cursorFromRow(row) {
|
|
6081
|
+
return row?.shapeHandle && row.shapeOffset ? {
|
|
6082
|
+
handle: row.shapeHandle,
|
|
6083
|
+
offset: row.shapeOffset,
|
|
6084
|
+
initialSnapshotComplete: row.initialSnapshotComplete
|
|
6085
|
+
} : void 0;
|
|
6086
|
+
}
|
|
6087
|
+
var PgSyncBridge = class {
|
|
6088
|
+
producer = null;
|
|
6089
|
+
unsubscribe = null;
|
|
6090
|
+
abortController = null;
|
|
6091
|
+
skipChangesUntilUpToDate = false;
|
|
6092
|
+
recovering = false;
|
|
6093
|
+
committedCursor;
|
|
6094
|
+
retryAttempt = 0;
|
|
6095
|
+
constructor(sourceRef, streamUrl, options, resolvedSource, retry, streamClient, registry, evaluateWakes, initialCursor) {
|
|
6096
|
+
this.sourceRef = sourceRef;
|
|
6097
|
+
this.streamUrl = streamUrl;
|
|
6098
|
+
this.options = options;
|
|
6099
|
+
this.resolvedSource = resolvedSource;
|
|
6100
|
+
this.retry = retry;
|
|
6101
|
+
this.streamClient = streamClient;
|
|
6102
|
+
this.registry = registry;
|
|
6103
|
+
this.evaluateWakes = evaluateWakes;
|
|
6104
|
+
this.initialCursor = initialCursor;
|
|
6105
|
+
this.committedCursor = initialCursor;
|
|
6106
|
+
}
|
|
6107
|
+
async start() {
|
|
6108
|
+
if (!this.producer) this.producer = new IdempotentProducer(new DurableStream({
|
|
6109
|
+
url: `${this.streamClient.baseUrl}${this.streamUrl}`,
|
|
6110
|
+
contentType: `application/json`
|
|
6111
|
+
}), `pg-sync-bridge-${this.sourceRef}`);
|
|
6112
|
+
if (this.initialCursor) {
|
|
6113
|
+
const offset = parseElectricOffset(this.initialCursor.offset);
|
|
6114
|
+
if (offset) {
|
|
6115
|
+
this.startStream(offset, this.initialCursor.handle, !this.initialCursor.initialSnapshotComplete);
|
|
6116
|
+
return;
|
|
6117
|
+
}
|
|
6118
|
+
}
|
|
6119
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
6120
|
+
this.startStream(`now`, void 0, true);
|
|
6121
|
+
}
|
|
6122
|
+
async stop() {
|
|
6123
|
+
this.unsubscribe?.();
|
|
6124
|
+
this.abortController?.abort();
|
|
6125
|
+
this.unsubscribe = null;
|
|
6126
|
+
this.abortController = null;
|
|
6127
|
+
try {
|
|
6128
|
+
await this.producer?.flush();
|
|
6129
|
+
} finally {
|
|
6130
|
+
await this.producer?.detach();
|
|
6131
|
+
this.producer = null;
|
|
6132
|
+
}
|
|
6133
|
+
}
|
|
6134
|
+
startStream(offset, handle, skipChangesUntilUpToDate = false, log = offset === `now` ? `changes_only` : `full`) {
|
|
6135
|
+
this.unsubscribe?.();
|
|
6136
|
+
this.abortController?.abort();
|
|
6137
|
+
this.skipChangesUntilUpToDate = skipChangesUntilUpToDate;
|
|
6138
|
+
this.abortController = new AbortController();
|
|
6139
|
+
const stream = new ShapeStream({
|
|
6140
|
+
url: this.resolvedSource.url,
|
|
6141
|
+
params: buildElectricShapeParams(this.options),
|
|
6142
|
+
offset,
|
|
6143
|
+
log,
|
|
6144
|
+
...handle ? { handle } : {},
|
|
6145
|
+
signal: this.abortController.signal
|
|
6146
|
+
});
|
|
6147
|
+
this.unsubscribe = stream.subscribe(async (messages) => {
|
|
6148
|
+
try {
|
|
6149
|
+
for (const message of messages) {
|
|
6150
|
+
if (isControlMessage(message)) {
|
|
6151
|
+
if (message.headers.control === `must-refetch`) {
|
|
6152
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
6153
|
+
this.startStream(`now`, void 0, true);
|
|
6154
|
+
return;
|
|
6155
|
+
}
|
|
6156
|
+
if (message.headers.control === `up-to-date`) {
|
|
6157
|
+
this.skipChangesUntilUpToDate = false;
|
|
6158
|
+
await this.persistCursor(stream, true);
|
|
6159
|
+
continue;
|
|
6160
|
+
}
|
|
6161
|
+
await this.persistCursor(stream);
|
|
6162
|
+
continue;
|
|
6163
|
+
}
|
|
6164
|
+
if (!isChangeMessage(message)) continue;
|
|
6165
|
+
if (!this.skipChangesUntilUpToDate) {
|
|
6166
|
+
const event = pgSyncMessageToDurableEvent(message, this.options);
|
|
6167
|
+
if (event) {
|
|
6168
|
+
if (!this.producer) throw new Error(`pg-sync producer is not started`);
|
|
6169
|
+
await this.producer.append(JSON.stringify(event));
|
|
6170
|
+
await this.producer.flush?.();
|
|
6171
|
+
await this.evaluateWakes?.(this.streamUrl, event);
|
|
6172
|
+
}
|
|
6173
|
+
}
|
|
6174
|
+
await this.persistCursor(stream);
|
|
6175
|
+
this.retryAttempt = 0;
|
|
6176
|
+
}
|
|
6177
|
+
} catch (error) {
|
|
6178
|
+
serverLog.warn(`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`, error);
|
|
6179
|
+
await this.recoverStream();
|
|
6180
|
+
}
|
|
6181
|
+
}, (error) => {
|
|
6182
|
+
if (this.abortController?.signal.aborted) return;
|
|
6183
|
+
serverLog.warn(`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`, error);
|
|
6184
|
+
this.recoverStream();
|
|
6185
|
+
});
|
|
6186
|
+
}
|
|
6187
|
+
async recoverStream() {
|
|
6188
|
+
if (this.recovering) return;
|
|
6189
|
+
this.recovering = true;
|
|
6190
|
+
try {
|
|
6191
|
+
const attempt = this.retryAttempt++;
|
|
6192
|
+
const baseDelay = Math.min(this.retry.initialDelayMs * 2 ** attempt, this.retry.maxDelayMs);
|
|
6193
|
+
const jitter = Math.floor(baseDelay * .2 * this.retry.random());
|
|
6194
|
+
const delay = baseDelay + jitter;
|
|
6195
|
+
if (delay > 0) await this.retry.sleep(delay);
|
|
6196
|
+
const offset = this.committedCursor ? parseElectricOffset(this.committedCursor.offset) : null;
|
|
6197
|
+
if (offset && this.committedCursor) this.startStream(offset, this.committedCursor.handle, !this.committedCursor.initialSnapshotComplete);
|
|
6198
|
+
else {
|
|
6199
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef);
|
|
6200
|
+
this.startStream(`now`, void 0, true);
|
|
6201
|
+
}
|
|
6202
|
+
} finally {
|
|
6203
|
+
this.recovering = false;
|
|
6204
|
+
}
|
|
6205
|
+
}
|
|
6206
|
+
async persistCursor(stream, initialSnapshotComplete = !this.skipChangesUntilUpToDate) {
|
|
6207
|
+
const shapeHandle = stream.shapeHandle;
|
|
6208
|
+
const shapeOffset = stream.lastOffset;
|
|
6209
|
+
if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return;
|
|
6210
|
+
await this.registry?.updatePgSyncBridgeCursor(this.sourceRef, shapeHandle, shapeOffset, initialSnapshotComplete);
|
|
6211
|
+
this.committedCursor = {
|
|
6212
|
+
handle: shapeHandle,
|
|
6213
|
+
offset: shapeOffset,
|
|
6214
|
+
initialSnapshotComplete
|
|
6215
|
+
};
|
|
6216
|
+
}
|
|
6217
|
+
};
|
|
6218
|
+
var PgSyncBridgeManager = class {
|
|
6219
|
+
bridges = new Map();
|
|
6220
|
+
starting = new Map();
|
|
6221
|
+
url;
|
|
6222
|
+
retry;
|
|
6223
|
+
constructor(streamClient, evaluateWakes, registry, options = {}) {
|
|
6224
|
+
this.streamClient = streamClient;
|
|
6225
|
+
this.evaluateWakes = evaluateWakes;
|
|
6226
|
+
this.registry = registry;
|
|
6227
|
+
this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL;
|
|
6228
|
+
this.retry = {
|
|
6229
|
+
initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
|
|
6230
|
+
maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
|
|
6231
|
+
random: options.retry?.random ?? Math.random,
|
|
6232
|
+
sleep: options.retry?.sleep ?? ((ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)))
|
|
6233
|
+
};
|
|
6234
|
+
}
|
|
6235
|
+
async start() {
|
|
6236
|
+
const rows = await this.registry?.listPgSyncBridges?.();
|
|
6237
|
+
if (!rows) return;
|
|
6238
|
+
await Promise.all(rows.map((row) => this.ensureBridge(row).catch((error) => {
|
|
6239
|
+
serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
|
|
6240
|
+
})));
|
|
6241
|
+
}
|
|
6242
|
+
async register(options, metadata) {
|
|
6243
|
+
const mergedMetadata = {
|
|
6244
|
+
...options.metadata,
|
|
6245
|
+
...metadata
|
|
6246
|
+
};
|
|
6247
|
+
const canonicalOptions = {
|
|
6248
|
+
...canonicalPgSyncOptions(options),
|
|
6249
|
+
...Object.keys(mergedMetadata).length > 0 ? { metadata: mergedMetadata } : {}
|
|
6250
|
+
};
|
|
6251
|
+
const resolvedSource = this.resolveSource(canonicalOptions);
|
|
6252
|
+
const sourceRef = sourceRefForPgSync(canonicalOptions);
|
|
6253
|
+
const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId);
|
|
6254
|
+
const row = await this.registry?.upsertPgSyncBridge({
|
|
6255
|
+
sourceRef,
|
|
6256
|
+
options: canonicalOptions,
|
|
6257
|
+
streamUrl
|
|
6258
|
+
});
|
|
6259
|
+
await this.streamClient.ensure(streamUrl, { contentType: `application/json` });
|
|
6260
|
+
if (!this.bridges.has(sourceRef)) {
|
|
6261
|
+
let start = this.starting.get(sourceRef);
|
|
6262
|
+
if (!start) {
|
|
6263
|
+
start = (async () => {
|
|
6264
|
+
const bridge = new PgSyncBridge(sourceRef, streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
|
|
6265
|
+
await bridge.start();
|
|
6266
|
+
this.bridges.set(sourceRef, bridge);
|
|
6267
|
+
})().finally(() => this.starting.delete(sourceRef));
|
|
6268
|
+
this.starting.set(sourceRef, start);
|
|
6269
|
+
}
|
|
6270
|
+
await start;
|
|
6271
|
+
}
|
|
6272
|
+
return {
|
|
6273
|
+
sourceRef,
|
|
6274
|
+
streamUrl
|
|
6275
|
+
};
|
|
6276
|
+
}
|
|
6277
|
+
async ensureBridge(row) {
|
|
6278
|
+
if (this.bridges.has(row.sourceRef)) return;
|
|
6279
|
+
let start = this.starting.get(row.sourceRef);
|
|
6280
|
+
if (!start) {
|
|
6281
|
+
start = (async () => {
|
|
6282
|
+
await this.streamClient.ensure(row.streamUrl, { contentType: `application/json` });
|
|
6283
|
+
const canonicalOptions = canonicalPgSyncOptions(row.options);
|
|
6284
|
+
const resolvedSource = this.resolveSource(canonicalOptions);
|
|
6285
|
+
const bridge = new PgSyncBridge(row.sourceRef, row.streamUrl, canonicalOptions, resolvedSource, this.retry, this.streamClient, this.registry, this.evaluateWakes, cursorFromRow(row));
|
|
6286
|
+
await bridge.start();
|
|
6287
|
+
this.bridges.set(row.sourceRef, bridge);
|
|
6288
|
+
})().finally(() => this.starting.delete(row.sourceRef));
|
|
6289
|
+
this.starting.set(row.sourceRef, start);
|
|
6290
|
+
}
|
|
6291
|
+
await start;
|
|
6292
|
+
}
|
|
6293
|
+
resolveSource(options) {
|
|
6294
|
+
return { url: options.url ?? this.url };
|
|
6295
|
+
}
|
|
6296
|
+
async stop() {
|
|
6297
|
+
await Promise.allSettled(this.starting.values());
|
|
6298
|
+
await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()));
|
|
6299
|
+
this.bridges.clear();
|
|
6300
|
+
}
|
|
6301
|
+
};
|
|
6302
|
+
|
|
5841
6303
|
//#endregion
|
|
5842
6304
|
//#region src/runtime.ts
|
|
5843
6305
|
function omitUndefined(value) {
|
|
@@ -5852,6 +6314,7 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
5852
6314
|
wakeRegistry;
|
|
5853
6315
|
scheduler;
|
|
5854
6316
|
entityBridgeManager;
|
|
6317
|
+
pgSyncBridgeManager;
|
|
5855
6318
|
claimWriteTokens;
|
|
5856
6319
|
manager;
|
|
5857
6320
|
constructor(options) {
|
|
@@ -5876,9 +6339,10 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
5876
6339
|
writeTokenValidator: (entity, token) => this.claimWriteTokens.isValid(this.serviceId, entity.streams.main, token),
|
|
5877
6340
|
stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false
|
|
5878
6341
|
});
|
|
6342
|
+
this.pgSyncBridgeManager = options.pgSyncBridgeManager ?? new PgSyncBridgeManager(this.streamClient, (sourceUrl, event) => this.manager.evaluateWakes(sourceUrl, event), this.registry, options.pgSync);
|
|
5879
6343
|
}
|
|
5880
6344
|
async stop() {
|
|
5881
|
-
await this.manager.shutdown();
|
|
6345
|
+
await Promise.all([this.manager.shutdown(), this.pgSyncBridgeManager.stop()]);
|
|
5882
6346
|
}
|
|
5883
6347
|
async rehydrateCronSchedules() {
|
|
5884
6348
|
const rows = await this.db.select({ sourceUrl: wakeRegistrations.sourceUrl }).from(wakeRegistrations).where(eq(wakeRegistrations.tenantId, this.serviceId));
|
|
@@ -6642,7 +7106,10 @@ var WakeRegistry = class {
|
|
|
6642
7106
|
}
|
|
6643
7107
|
if (!isChangeMessage(message)) return;
|
|
6644
7108
|
if (message.headers.operation === `delete`) {
|
|
6645
|
-
|
|
7109
|
+
const oldValue = message.old_value;
|
|
7110
|
+
const oldId = Number(oldValue?.id);
|
|
7111
|
+
if (Number.isFinite(oldId)) this.removeCachedRegistrationByDbId(oldId);
|
|
7112
|
+
else this.resetCachedRegistrations();
|
|
6646
7113
|
return;
|
|
6647
7114
|
}
|
|
6648
7115
|
this.upsertCachedRegistration(this.normalizeShapeRow(message.value));
|
|
@@ -6753,9 +7220,9 @@ var WakeRegistry = class {
|
|
|
6753
7220
|
matchCondition(reg, event) {
|
|
6754
7221
|
if (reg.condition === `runFinished`) {
|
|
6755
7222
|
if (event.type !== `run`) return null;
|
|
6756
|
-
const value = event.value;
|
|
7223
|
+
const value$1 = event.value;
|
|
6757
7224
|
const headers$1 = event.headers;
|
|
6758
|
-
const status$1 = value?.status;
|
|
7225
|
+
const status$1 = value$1?.status;
|
|
6759
7226
|
const operation$1 = headers$1?.operation;
|
|
6760
7227
|
if (operation$1 !== `update`) return null;
|
|
6761
7228
|
if (status$1 !== `completed` && status$1 !== `failed`) return null;
|
|
@@ -6778,13 +7245,15 @@ var WakeRegistry = class {
|
|
|
6778
7245
|
}
|
|
6779
7246
|
const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
|
|
6780
7247
|
if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
|
|
7248
|
+
const value = event.value;
|
|
6781
7249
|
const change = {
|
|
6782
7250
|
collection: eventType,
|
|
6783
7251
|
kind,
|
|
6784
7252
|
key: event.key || ``
|
|
6785
7253
|
};
|
|
7254
|
+
if (value && `value` in value) change.value = value.value;
|
|
7255
|
+
if (value && `oldValue` in value) change.oldValue = value.oldValue;
|
|
6786
7256
|
if (eventType === `inbox`) {
|
|
6787
|
-
const value = event.value;
|
|
6788
7257
|
if (typeof value?.from === `string`) change.from = value.from;
|
|
6789
7258
|
if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
|
|
6790
7259
|
if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
|
|
@@ -7188,6 +7657,22 @@ function resolveDurableStreamsRoutingAdapter(adapter, _durableStreamsUrl) {
|
|
|
7188
7657
|
|
|
7189
7658
|
//#endregion
|
|
7190
7659
|
//#region src/utils/server-utils.ts
|
|
7660
|
+
/**
|
|
7661
|
+
* Raised when an Electric shape proxy request must be rejected for security
|
|
7662
|
+
* reasons (an un-scoped table, or a client `where` clause that could escape the
|
|
7663
|
+
* enforced per-tenant/per-principal scoping). The global `errorMapper` hook
|
|
7664
|
+
* maps this to an HTTP error response. Defined here (rather than reusing
|
|
7665
|
+
* `ElectricAgentsError`) to keep this module free of the heavy entity-manager
|
|
7666
|
+
* import graph.
|
|
7667
|
+
*/
|
|
7668
|
+
var ElectricProxyError = class extends Error {
|
|
7669
|
+
constructor(code, message, status$1) {
|
|
7670
|
+
super(message);
|
|
7671
|
+
this.code = code;
|
|
7672
|
+
this.status = status$1;
|
|
7673
|
+
this.name = `ElectricProxyError`;
|
|
7674
|
+
}
|
|
7675
|
+
};
|
|
7191
7676
|
function buildElectricProxyTarget(options) {
|
|
7192
7677
|
const targetPath = options.incomingUrl.pathname.replace(`/_electric/electric`, ``);
|
|
7193
7678
|
const target = electricUrlWithPath(options.electricUrl, targetPath);
|
|
@@ -7197,7 +7682,12 @@ function buildElectricProxyTarget(options) {
|
|
|
7197
7682
|
applyElectricUrlQueryParams(target, options.electricUrl);
|
|
7198
7683
|
if (targetPath !== `/v1/shape`) return target;
|
|
7199
7684
|
if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
|
|
7200
|
-
const
|
|
7685
|
+
const clientWhere = options.incomingUrl.searchParams.get(`where`);
|
|
7686
|
+
if (clientWhere !== null && !isSelfContainedWhereClause(clientWhere)) throw new ElectricProxyError(`INVALID_WHERE`, `Invalid where clause`, 400);
|
|
7687
|
+
const tableParams = options.incomingUrl.searchParams.getAll(`table`);
|
|
7688
|
+
if (tableParams.length !== 1) throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
|
|
7689
|
+
const table = tableParams[0];
|
|
7690
|
+
target.searchParams.set(`table`, table);
|
|
7201
7691
|
if (table === `entities`) {
|
|
7202
7692
|
target.searchParams.set(`columns`, `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","sandbox","parent","created_by","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`);
|
|
7203
7693
|
applyShapeWhere(target, buildReadableEntitiesWhere({
|
|
@@ -7255,9 +7745,39 @@ function buildElectricProxyTarget(options) {
|
|
|
7255
7745
|
principalKind: options.principalKind ?? ``,
|
|
7256
7746
|
permissionBypass: options.permissionBypass
|
|
7257
7747
|
}));
|
|
7258
|
-
}
|
|
7748
|
+
} else throw new ElectricProxyError(`TABLE_NOT_ALLOWED`, `Table is not available through the Electric proxy`, 403);
|
|
7259
7749
|
return target;
|
|
7260
7750
|
}
|
|
7751
|
+
/**
|
|
7752
|
+
* Returns true when a client-supplied Electric `where` clause is self-contained:
|
|
7753
|
+
* its parentheses are balanced, never close below the top level, all string
|
|
7754
|
+
* (`'`) and identifier (`"`) literals are terminated, and it contains no SQL
|
|
7755
|
+
* comment markers. Such a clause cannot break out of the `(...)` group it is
|
|
7756
|
+
* wrapped in when AND-combined with the enforced scoping predicate, nor comment
|
|
7757
|
+
* out the trailing paren the proxy appends. Characters inside string/identifier
|
|
7758
|
+
* literals are ignored. Comment markers are rejected unconditionally (even where
|
|
7759
|
+
* harmless) as a conservative defensive measure; dollar-quoted and `E''` strings
|
|
7760
|
+
* are not modeled and only ever cause fail-safe over-rejection, never a bypass.
|
|
7761
|
+
*/
|
|
7762
|
+
function isSelfContainedWhereClause(where) {
|
|
7763
|
+
let depth = 0;
|
|
7764
|
+
let quote = null;
|
|
7765
|
+
for (let i = 0; i < where.length; i++) {
|
|
7766
|
+
const ch = where[i];
|
|
7767
|
+
if (quote !== null) {
|
|
7768
|
+
if (ch === quote) if (where[i + 1] === quote) i++;
|
|
7769
|
+
else quote = null;
|
|
7770
|
+
continue;
|
|
7771
|
+
}
|
|
7772
|
+
if (ch === `'` || ch === `"`) quote = ch;
|
|
7773
|
+
else if (ch === `(`) depth++;
|
|
7774
|
+
else if (ch === `)`) {
|
|
7775
|
+
depth--;
|
|
7776
|
+
if (depth < 0) return false;
|
|
7777
|
+
} else if (ch === `-` && where[i + 1] === `-` || ch === `/` && where[i + 1] === `*`) return false;
|
|
7778
|
+
}
|
|
7779
|
+
return depth === 0 && quote === null;
|
|
7780
|
+
}
|
|
7261
7781
|
function buildReadableEntitiesWhere(options) {
|
|
7262
7782
|
const tenant = sqlStringLiteral(options.tenantId);
|
|
7263
7783
|
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
@@ -8028,6 +8548,15 @@ const spawnBodySchema = Type.Object({
|
|
|
8028
8548
|
manifestKey: Type.Optional(Type.String())
|
|
8029
8549
|
}))
|
|
8030
8550
|
});
|
|
8551
|
+
const writeCollectionBodySchema = Type.Object({
|
|
8552
|
+
operation: Type.Union([
|
|
8553
|
+
Type.Literal(`insert`),
|
|
8554
|
+
Type.Literal(`update`),
|
|
8555
|
+
Type.Literal(`delete`)
|
|
8556
|
+
]),
|
|
8557
|
+
key: Type.Optional(Type.String()),
|
|
8558
|
+
value: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
|
|
8559
|
+
}, { additionalProperties: false });
|
|
8031
8560
|
const sendBodySchema = Type.Object({
|
|
8032
8561
|
payload: Type.Optional(Type.Unknown()),
|
|
8033
8562
|
key: Type.Optional(Type.String()),
|
|
@@ -8160,6 +8689,7 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermissi
|
|
|
8160
8689
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
|
|
8161
8690
|
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
|
|
8162
8691
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
|
|
8692
|
+
entitiesRouter.post(`/:type/:instanceId/collections/:collection`, withExistingEntity, withSchema(writeCollectionBodySchema), withEntityPermission(`write`), writeCollection);
|
|
8163
8693
|
entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
|
|
8164
8694
|
entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
|
|
8165
8695
|
entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
|
|
@@ -8265,7 +8795,7 @@ async function parseAttachmentForm(request) {
|
|
|
8265
8795
|
};
|
|
8266
8796
|
}
|
|
8267
8797
|
function contentDisposition(filename) {
|
|
8268
|
-
const fallback = filename.replace(/["
|
|
8798
|
+
const fallback = filename.replace(/[^\x20-\x7e]|["\\]/g, `_`);
|
|
8269
8799
|
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
8270
8800
|
}
|
|
8271
8801
|
function rejectPrincipalEntityMutation(request, action) {
|
|
@@ -8463,22 +8993,28 @@ async function deleteEventSourceSubscription(request, ctx) {
|
|
|
8463
8993
|
const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
|
|
8464
8994
|
return json(result);
|
|
8465
8995
|
}
|
|
8996
|
+
function tagResponseBody(entity) {
|
|
8997
|
+
const publicEntity = toPublicEntity(entity);
|
|
8998
|
+
if (entity.txid !== void 0) return {
|
|
8999
|
+
...publicEntity,
|
|
9000
|
+
txid: entity.txid
|
|
9001
|
+
};
|
|
9002
|
+
return publicEntity;
|
|
9003
|
+
}
|
|
8466
9004
|
async function setTag(request, ctx) {
|
|
8467
9005
|
const principalMutationError = rejectPrincipalEntityMutation(request, `tag updated`);
|
|
8468
9006
|
if (principalMutationError) return principalMutationError;
|
|
8469
9007
|
const parsed = routeBody(request);
|
|
8470
9008
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8471
|
-
const
|
|
8472
|
-
|
|
8473
|
-
return json(toPublicEntity(updated));
|
|
9009
|
+
const updated = await ctx.entityManager.setTag(entityUrl, decodeURIComponent(request.params.tagKey), { value: parsed.value });
|
|
9010
|
+
return json(tagResponseBody(updated));
|
|
8474
9011
|
}
|
|
8475
9012
|
async function deleteTag(request, ctx) {
|
|
8476
9013
|
const principalMutationError = rejectPrincipalEntityMutation(request, `tag deleted`);
|
|
8477
9014
|
if (principalMutationError) return principalMutationError;
|
|
8478
9015
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8479
|
-
const
|
|
8480
|
-
|
|
8481
|
-
return json(toPublicEntity(updated));
|
|
9016
|
+
const updated = await ctx.entityManager.deleteTag(entityUrl, decodeURIComponent(request.params.tagKey));
|
|
9017
|
+
return json(tagResponseBody(updated));
|
|
8482
9018
|
}
|
|
8483
9019
|
async function forkEntity(request, ctx) {
|
|
8484
9020
|
const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
|
|
@@ -8545,9 +9081,29 @@ async function sendEntity(request, ctx) {
|
|
|
8545
9081
|
mode: parsed.mode,
|
|
8546
9082
|
position: parsed.position
|
|
8547
9083
|
};
|
|
8548
|
-
if (parsed.afterMs && parsed.afterMs > 0)
|
|
8549
|
-
|
|
8550
|
-
|
|
9084
|
+
if (parsed.afterMs && parsed.afterMs > 0) {
|
|
9085
|
+
await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
|
|
9086
|
+
return status(204);
|
|
9087
|
+
}
|
|
9088
|
+
const result = await ctx.entityManager.send(entityUrl, sendReq);
|
|
9089
|
+
return json(result);
|
|
9090
|
+
}
|
|
9091
|
+
async function writeCollection(request, ctx) {
|
|
9092
|
+
const parsed = routeBody(request);
|
|
9093
|
+
await ctx.entityManager.ensurePrincipal(ctx.principal);
|
|
9094
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
9095
|
+
const collection = request.params.collection;
|
|
9096
|
+
const result = await ctx.entityManager.writeCollection(entityUrl, collection, {
|
|
9097
|
+
operation: parsed.operation,
|
|
9098
|
+
key: parsed.key,
|
|
9099
|
+
value: parsed.value,
|
|
9100
|
+
principal: {
|
|
9101
|
+
url: ctx.principal.url,
|
|
9102
|
+
kind: ctx.principal.kind,
|
|
9103
|
+
id: ctx.principal.id
|
|
9104
|
+
}
|
|
9105
|
+
});
|
|
9106
|
+
return json(result, { status: parsed.operation === `insert` ? 201 : 200 });
|
|
8551
9107
|
}
|
|
8552
9108
|
async function createAttachment(request, ctx) {
|
|
8553
9109
|
const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
|
|
@@ -8590,13 +9146,13 @@ async function deleteAttachment(request, ctx) {
|
|
|
8590
9146
|
async function updateInboxMessage(request, ctx) {
|
|
8591
9147
|
const parsed = routeBody(request);
|
|
8592
9148
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8593
|
-
await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
|
|
8594
|
-
return
|
|
9149
|
+
const result = await ctx.entityManager.updateInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey), parsed);
|
|
9150
|
+
return json(result);
|
|
8595
9151
|
}
|
|
8596
9152
|
async function deleteInboxMessage(request, ctx) {
|
|
8597
9153
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8598
|
-
await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
|
|
8599
|
-
return
|
|
9154
|
+
const result = await ctx.entityManager.deleteInboxMessage(entityUrl, decodeURIComponent(request.params.messageKey));
|
|
9155
|
+
return json(result);
|
|
8600
9156
|
}
|
|
8601
9157
|
async function spawnEntity(request, ctx) {
|
|
8602
9158
|
const parsed = routeBody(request);
|
|
@@ -8646,8 +9202,13 @@ async function spawnEntity(request, ctx) {
|
|
|
8646
9202
|
headers: { "x-write-token": entity.write_token }
|
|
8647
9203
|
});
|
|
8648
9204
|
}
|
|
8649
|
-
function getEntity(request) {
|
|
8650
|
-
|
|
9205
|
+
async function getEntity(request, ctx) {
|
|
9206
|
+
const { entity } = requireExistingEntityRoute(request);
|
|
9207
|
+
const entityType = entity.type ? await ctx.entityManager.registry.getEntityType(entity.type) : null;
|
|
9208
|
+
return json({
|
|
9209
|
+
...toPublicEntity(entity),
|
|
9210
|
+
...entityType?.externally_writable_collections && { externally_writable_collections: entityType.externally_writable_collections }
|
|
9211
|
+
});
|
|
8651
9212
|
}
|
|
8652
9213
|
function headEntity() {
|
|
8653
9214
|
return status(200);
|
|
@@ -8682,6 +9243,16 @@ async function signalEntity(request, ctx) {
|
|
|
8682
9243
|
//#region src/routing/entity-types-router.ts
|
|
8683
9244
|
const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
|
|
8684
9245
|
const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
|
|
9246
|
+
const externallyWritableCollectionsSchema = Type.Record(Type.String(), Type.Object({
|
|
9247
|
+
type: Type.String(),
|
|
9248
|
+
contract: Type.Optional(Type.String()),
|
|
9249
|
+
operations: Type.Optional(Type.Array(Type.Union([
|
|
9250
|
+
Type.Literal(`insert`),
|
|
9251
|
+
Type.Literal(`update`),
|
|
9252
|
+
Type.Literal(`delete`)
|
|
9253
|
+
]))),
|
|
9254
|
+
principalColumn: Type.Optional(Type.String())
|
|
9255
|
+
}, { additionalProperties: false }));
|
|
8685
9256
|
const slashCommandArgumentSchema = Type.Object({
|
|
8686
9257
|
name: Type.String(),
|
|
8687
9258
|
type: Type.Union([
|
|
@@ -8712,7 +9283,8 @@ const registerEntityTypeBodySchema = Type.Object({
|
|
|
8712
9283
|
slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
|
|
8713
9284
|
serve_endpoint: Type.Optional(Type.String()),
|
|
8714
9285
|
default_dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
8715
|
-
permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
|
|
9286
|
+
permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema)),
|
|
9287
|
+
externally_writable_collections: Type.Optional(externallyWritableCollectionsSchema)
|
|
8716
9288
|
}, { additionalProperties: false });
|
|
8717
9289
|
const amendEntityTypeSchemasBodySchema = Type.Object({
|
|
8718
9290
|
inbox_schemas: Type.Optional(schemaMapSchema),
|
|
@@ -8843,7 +9415,20 @@ function parseExpiresAt(value) {
|
|
|
8843
9415
|
if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
|
|
8844
9416
|
return expiresAt;
|
|
8845
9417
|
}
|
|
9418
|
+
/**
|
|
9419
|
+
* The `comments` collection name is reserved for the canonical comments
|
|
9420
|
+
* contract: the UI keys its comment affordances on it, so a divergent
|
|
9421
|
+
* collection registered under that name (or the contract mounted under
|
|
9422
|
+
* another name) would break that assumption silently.
|
|
9423
|
+
*/
|
|
9424
|
+
function validateExternallyWritableCollections(collections) {
|
|
9425
|
+
for (const [name, config] of Object.entries(collections ?? {})) {
|
|
9426
|
+
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);
|
|
9427
|
+
if (config.contract === COMMENTS_CONTRACT && name !== `comments`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The "${COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`, 400);
|
|
9428
|
+
}
|
|
9429
|
+
}
|
|
8846
9430
|
function normalizeEntityTypeRequest(parsed) {
|
|
9431
|
+
validateExternallyWritableCollections(parsed.externally_writable_collections);
|
|
8847
9432
|
const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
|
|
8848
9433
|
return {
|
|
8849
9434
|
name: parsed.name ?? ``,
|
|
@@ -8857,7 +9442,8 @@ function normalizeEntityTypeRequest(parsed) {
|
|
|
8857
9442
|
type: `webhook`,
|
|
8858
9443
|
url: serveEndpoint
|
|
8859
9444
|
}] } : void 0),
|
|
8860
|
-
permission_grants: parsed.permission_grants
|
|
9445
|
+
permission_grants: parsed.permission_grants,
|
|
9446
|
+
externally_writable_collections: parsed.externally_writable_collections
|
|
8861
9447
|
};
|
|
8862
9448
|
}
|
|
8863
9449
|
function toPublicEntityType(entityType) {
|
|
@@ -8867,6 +9453,49 @@ function toPublicEntityType(entityType) {
|
|
|
8867
9453
|
};
|
|
8868
9454
|
}
|
|
8869
9455
|
|
|
9456
|
+
//#endregion
|
|
9457
|
+
//#region src/routing/pg-sync-router.ts
|
|
9458
|
+
const pgSyncOptionsSchema = Type.Object({
|
|
9459
|
+
url: Type.Optional(Type.String()),
|
|
9460
|
+
table: Type.String(),
|
|
9461
|
+
columns: Type.Optional(Type.Array(Type.String())),
|
|
9462
|
+
where: Type.Optional(Type.String()),
|
|
9463
|
+
params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
|
|
9464
|
+
replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)]))
|
|
9465
|
+
});
|
|
9466
|
+
const pgSyncRequestMetadataSchema = Type.Object({
|
|
9467
|
+
entityUrl: Type.Optional(Type.String()),
|
|
9468
|
+
entityType: Type.Optional(Type.String()),
|
|
9469
|
+
streamPath: Type.Optional(Type.String()),
|
|
9470
|
+
runtimeConsumerId: Type.Optional(Type.String()),
|
|
9471
|
+
wakeId: Type.Optional(Type.String())
|
|
9472
|
+
});
|
|
9473
|
+
const pgSyncRegisterBodySchema = Type.Object({
|
|
9474
|
+
options: pgSyncOptionsSchema,
|
|
9475
|
+
metadata: Type.Optional(pgSyncRequestMetadataSchema)
|
|
9476
|
+
});
|
|
9477
|
+
const pgSyncRouter = Router({ base: `/_electric/pg-sync` });
|
|
9478
|
+
pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
|
|
9479
|
+
async function registerPgSync(request, ctx) {
|
|
9480
|
+
const { options, metadata } = routeBody(request);
|
|
9481
|
+
if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
|
|
9482
|
+
if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
|
|
9483
|
+
try {
|
|
9484
|
+
const requestMetadata$1 = {
|
|
9485
|
+
tenantId: ctx.service,
|
|
9486
|
+
principalKind: ctx.principal.kind,
|
|
9487
|
+
principalId: ctx.principal.id,
|
|
9488
|
+
principalKey: ctx.principal.key,
|
|
9489
|
+
principalUrl: ctx.principal.url,
|
|
9490
|
+
...metadata ?? {}
|
|
9491
|
+
};
|
|
9492
|
+
const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
|
|
9493
|
+
return json(result);
|
|
9494
|
+
} catch (error) {
|
|
9495
|
+
return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
9496
|
+
}
|
|
9497
|
+
}
|
|
9498
|
+
|
|
8870
9499
|
//#endregion
|
|
8871
9500
|
//#region src/routing/hooks.ts
|
|
8872
9501
|
const SPAN_KEY = Symbol(`agents-server.otel-span`);
|
|
@@ -8941,6 +9570,10 @@ function errorMapper(err, req) {
|
|
|
8941
9570
|
});
|
|
8942
9571
|
}
|
|
8943
9572
|
if (err instanceof ElectricAgentsError) return apiError(err.status, err.code, err.message, err.details);
|
|
9573
|
+
if (err instanceof ElectricProxyError) {
|
|
9574
|
+
serverLog.warn(`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`);
|
|
9575
|
+
return apiError(err.status, err.code, err.message);
|
|
9576
|
+
}
|
|
8944
9577
|
serverLog.error(`[agent-server] Unhandled error:`, err);
|
|
8945
9578
|
return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`);
|
|
8946
9579
|
}
|
|
@@ -9391,6 +10024,7 @@ internalRouter.all(`/runners`, runnersRouter.fetch);
|
|
|
9391
10024
|
internalRouter.all(`/runners/*`, runnersRouter.fetch);
|
|
9392
10025
|
internalRouter.all(`/entities/*`, entitiesRouter.fetch);
|
|
9393
10026
|
internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch);
|
|
10027
|
+
internalRouter.all(`/pg-sync/*`, pgSyncRouter.fetch);
|
|
9394
10028
|
internalRouter.all(`/observations/*`, observationsRouter.fetch);
|
|
9395
10029
|
internalRouter.get(`/electric/*`, electricProxyRouter.fetch);
|
|
9396
10030
|
internalRouter.all(`*`, () => status(404));
|
|
@@ -9774,6 +10408,9 @@ const globalRouter = AutoRouter({
|
|
|
9774
10408
|
finally: [otelEndSpan, applyCors]
|
|
9775
10409
|
});
|
|
9776
10410
|
globalRouter.all(`/_electric/shared-state/*`, durableStreamsRouter.fetch);
|
|
10411
|
+
globalRouter.all(`/_electric/pg-sync/register`, internalRouter.fetch);
|
|
10412
|
+
globalRouter.get(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
|
|
10413
|
+
globalRouter.head(`/_electric/pg-sync/*`, durableStreamsRouter.fetch);
|
|
9777
10414
|
globalRouter.all(`/_electric/*`, internalRouter.fetch);
|
|
9778
10415
|
globalRouter.all(`*`, durableStreamsRouter.fetch);
|
|
9779
10416
|
|