@electric-ax/agents-server 0.4.20 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/entrypoint.js +1003 -834
- package/dist/index.cjs +241 -72
- package/dist/index.d.cts +2507 -2440
- package/dist/index.d.ts +2506 -2441
- package/dist/index.js +242 -73
- package/drizzle/0016_entity_type_externally_writable_collections.sql +1 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/src/db/schema.ts +4 -0
- package/src/electric-agents-types.ts +23 -0
- package/src/entity-manager.ts +157 -7
- package/src/entity-registry.ts +25 -1
- package/src/index.ts +6 -6
- package/src/manifest-side-effects.ts +2 -6
- package/src/pg-sync-bridge-manager.ts +147 -47
- package/src/routing/context.ts +11 -11
- package/src/routing/entities-router.ts +112 -30
- package/src/routing/entity-types-router.ts +56 -0
- package/src/routing/internal-router.ts +9 -7
- package/src/routing/pg-sync-router.ts +14 -1
- package/src/server.ts +8 -8
- package/src/wake-registry.ts +2 -0
package/dist/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,
|
|
11
|
+
import { COMMENTS_CONTRACT, COMPOSER_INPUT_MESSAGE_TYPE, appendPathToUrl, assertTags, buildTagsIndex, buildWebhookSourceManifestEntry, canonicalPgSyncOptions, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getPgSyncStreamPath, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveWebhookSourceSubscription, sourceRefForPgSync, sourceRefForTags, validateComposerInputPayload, validateSlashCommandDefinitions, verifyWebhookSignature, webhookSourceSubscriptionManifestKey } from "@electric-ax/agents-runtime";
|
|
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";
|
|
@@ -50,6 +50,7 @@ const entityTypes = pgTable(`entity_types`, {
|
|
|
50
50
|
creationSchema: jsonb(`creation_schema`),
|
|
51
51
|
inboxSchemas: jsonb(`inbox_schemas`),
|
|
52
52
|
stateSchemas: jsonb(`state_schemas`),
|
|
53
|
+
externallyWritableCollections: jsonb(`externally_writable_collections`).$type(),
|
|
53
54
|
slashCommands: jsonb(`slash_commands`),
|
|
54
55
|
serveEndpoint: text(`serve_endpoint`),
|
|
55
56
|
defaultDispatchPolicy: jsonb(`default_dispatch_policy`),
|
|
@@ -837,6 +838,7 @@ var PostgresRegistry = class {
|
|
|
837
838
|
creationSchema: et.creation_schema ?? null,
|
|
838
839
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
839
840
|
stateSchemas: et.state_schemas ?? null,
|
|
841
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
840
842
|
slashCommands: et.slash_commands ?? null,
|
|
841
843
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
842
844
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -850,6 +852,7 @@ var PostgresRegistry = class {
|
|
|
850
852
|
creationSchema: et.creation_schema ?? null,
|
|
851
853
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
852
854
|
stateSchemas: et.state_schemas ?? null,
|
|
855
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
853
856
|
slashCommands: et.slash_commands ?? null,
|
|
854
857
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
855
858
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -868,6 +871,7 @@ var PostgresRegistry = class {
|
|
|
868
871
|
creationSchema: et.creation_schema ?? null,
|
|
869
872
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
870
873
|
stateSchemas: et.state_schemas ?? null,
|
|
874
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
871
875
|
slashCommands: et.slash_commands ?? null,
|
|
872
876
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
873
877
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -895,6 +899,7 @@ var PostgresRegistry = class {
|
|
|
895
899
|
creationSchema: et.creation_schema ?? null,
|
|
896
900
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
897
901
|
stateSchemas: et.state_schemas ?? null,
|
|
902
|
+
externallyWritableCollections: et.externally_writable_collections ?? null,
|
|
898
903
|
slashCommands: et.slash_commands ?? null,
|
|
899
904
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
900
905
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
@@ -1339,7 +1344,6 @@ var PostgresRegistry = class {
|
|
|
1339
1344
|
set: {
|
|
1340
1345
|
options: row.options,
|
|
1341
1346
|
streamUrl: row.streamUrl,
|
|
1342
|
-
initialSnapshotComplete: false,
|
|
1343
1347
|
lastTouchedAt: new Date(),
|
|
1344
1348
|
updatedAt: new Date()
|
|
1345
1349
|
}
|
|
@@ -1378,6 +1382,9 @@ var PostgresRegistry = class {
|
|
|
1378
1382
|
updatedAt: new Date()
|
|
1379
1383
|
}).where(this.pgSyncBridgeWhere(sourceRef));
|
|
1380
1384
|
}
|
|
1385
|
+
async deletePgSyncBridge(sourceRef) {
|
|
1386
|
+
await this.db.delete(pgSyncBridges).where(this.pgSyncBridgeWhere(sourceRef));
|
|
1387
|
+
}
|
|
1381
1388
|
async upsertEntityBridge(row) {
|
|
1382
1389
|
await this.db.insert(entityBridges).values({
|
|
1383
1390
|
tenantId: this.tenantId,
|
|
@@ -1540,6 +1547,7 @@ var PostgresRegistry = class {
|
|
|
1540
1547
|
creation_schema: row.creationSchema,
|
|
1541
1548
|
inbox_schemas: row.inboxSchemas,
|
|
1542
1549
|
state_schemas: row.stateSchemas,
|
|
1550
|
+
externally_writable_collections: row.externallyWritableCollections ?? void 0,
|
|
1543
1551
|
slash_commands: row.slashCommands ?? void 0,
|
|
1544
1552
|
serve_endpoint: row.serveEndpoint ?? void 0,
|
|
1545
1553
|
default_dispatch_policy: row.defaultDispatchPolicy ?? void 0,
|
|
@@ -3258,9 +3266,6 @@ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
|
|
|
3258
3266
|
function isRecord$1(value) {
|
|
3259
3267
|
return typeof value === `object` && value !== null && !Array.isArray(value);
|
|
3260
3268
|
}
|
|
3261
|
-
function getPgSyncManifestStreamPath(sourceRef) {
|
|
3262
|
-
return `/_electric/pg-sync/${sourceRef}`;
|
|
3263
|
-
}
|
|
3264
3269
|
function extractManifestSourceUrl(manifest) {
|
|
3265
3270
|
if (!manifest) return void 0;
|
|
3266
3271
|
if (manifest.kind === `child` || manifest.kind === `observe`) return typeof manifest.entity_url === `string` ? manifest.entity_url : void 0;
|
|
@@ -3273,7 +3278,7 @@ function extractManifestSourceUrl(manifest) {
|
|
|
3273
3278
|
}
|
|
3274
3279
|
if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
|
|
3275
3280
|
if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
|
|
3276
|
-
if (manifest.sourceType === `pgSync`) return typeof
|
|
3281
|
+
if (manifest.sourceType === `pgSync`) return typeof config?.streamUrl === `string` ? config.streamUrl : void 0;
|
|
3277
3282
|
if (manifest.sourceType === `webhook`) {
|
|
3278
3283
|
if (typeof config?.streamUrl === `string`) return config.streamUrl;
|
|
3279
3284
|
if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
|
|
@@ -3469,6 +3474,7 @@ var EntityManager = class {
|
|
|
3469
3474
|
creation_schema: req.creation_schema,
|
|
3470
3475
|
inbox_schemas: req.inbox_schemas,
|
|
3471
3476
|
state_schemas: req.state_schemas,
|
|
3477
|
+
externally_writable_collections: req.externally_writable_collections,
|
|
3472
3478
|
slash_commands: req.slash_commands,
|
|
3473
3479
|
serve_endpoint: req.serve_endpoint,
|
|
3474
3480
|
default_dispatch_policy: defaultDispatchPolicy,
|
|
@@ -4564,6 +4570,40 @@ var EntityManager = class {
|
|
|
4564
4570
|
throw err;
|
|
4565
4571
|
}
|
|
4566
4572
|
}
|
|
4573
|
+
async writeCollection(entityUrl, collection, req) {
|
|
4574
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
4575
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4576
|
+
const { externallyWritableCollections } = await this.getEffectiveSchemas(entity);
|
|
4577
|
+
const config = externallyWritableCollections?.[collection];
|
|
4578
|
+
if (!config) throw new ElectricAgentsError(ErrCodeUnauthorized, `Collection "${collection}" is not writable`, 403);
|
|
4579
|
+
const allowedOperations = config.operations ?? [`insert`];
|
|
4580
|
+
if (!allowedOperations.includes(req.operation)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Operation "${req.operation}" is not allowed on collection "${collection}"`, 403);
|
|
4581
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4582
|
+
if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
|
|
4583
|
+
if (req.operation !== `delete` && (req.value === void 0 || req.value === null)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `value is required for ${req.operation}`, 400);
|
|
4584
|
+
if (req.operation !== `insert` && !req.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `key is required for ${req.operation}`, 400);
|
|
4585
|
+
const key = req.key ?? `${collection}-${randomUUID()}`;
|
|
4586
|
+
const event = {
|
|
4587
|
+
type: config.type,
|
|
4588
|
+
key,
|
|
4589
|
+
headers: {
|
|
4590
|
+
operation: req.operation,
|
|
4591
|
+
timestamp: new Date().toISOString(),
|
|
4592
|
+
principal: req.principal
|
|
4593
|
+
}
|
|
4594
|
+
};
|
|
4595
|
+
if (req.operation !== `delete`) event.value = req.value;
|
|
4596
|
+
const validationError = await this.validateWriteEvent(entity, event);
|
|
4597
|
+
if (validationError) throw new ElectricAgentsError(validationError.code, validationError.message, validationError.status);
|
|
4598
|
+
const encoded = this.encodeChangeEvent(event);
|
|
4599
|
+
try {
|
|
4600
|
+
await this.streamClient.append(entity.streams.main, encoded);
|
|
4601
|
+
} catch (err) {
|
|
4602
|
+
if (this.isClosedStreamError(err)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409);
|
|
4603
|
+
throw err;
|
|
4604
|
+
}
|
|
4605
|
+
return { key };
|
|
4606
|
+
}
|
|
4567
4607
|
async updateInboxMessage(entityUrl, key, req) {
|
|
4568
4608
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4569
4609
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
@@ -4830,7 +4870,7 @@ var EntityManager = class {
|
|
|
4830
4870
|
await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
|
|
4831
4871
|
return { txid };
|
|
4832
4872
|
}
|
|
4833
|
-
async
|
|
4873
|
+
async upsertWebhookSourceSubscription(entityUrl, req) {
|
|
4834
4874
|
const manifestKey = req.subscription.manifestKey;
|
|
4835
4875
|
const txid = randomUUID();
|
|
4836
4876
|
await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
|
|
@@ -4852,8 +4892,20 @@ var EntityManager = class {
|
|
|
4852
4892
|
subscription: req.subscription
|
|
4853
4893
|
};
|
|
4854
4894
|
}
|
|
4855
|
-
async
|
|
4856
|
-
const manifestKey =
|
|
4895
|
+
async deleteWebhookSourceSubscription(entityUrl, req) {
|
|
4896
|
+
const manifestKey = webhookSourceSubscriptionManifestKey(req.id);
|
|
4897
|
+
const txid = randomUUID();
|
|
4898
|
+
await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
|
|
4899
|
+
await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
|
|
4900
|
+
return { txid };
|
|
4901
|
+
}
|
|
4902
|
+
/**
|
|
4903
|
+
* Stop this entity observing a pg-sync source: drop its manifest entry and
|
|
4904
|
+
* the wake it anchors. The shared pg-sync bridge (keyed by sourceRef, not by
|
|
4905
|
+
* subscriber) is intentionally left running for any other observers.
|
|
4906
|
+
*/
|
|
4907
|
+
async deletePgSyncObservation(entityUrl, req) {
|
|
4908
|
+
const manifestKey = `source:pgSync:${req.sourceRef}`;
|
|
4857
4909
|
const txid = randomUUID();
|
|
4858
4910
|
await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
|
|
4859
4911
|
await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
|
|
@@ -5256,7 +5308,8 @@ var EntityManager = class {
|
|
|
5256
5308
|
async getEffectiveSchemas(entity) {
|
|
5257
5309
|
if (!entity.type) return {
|
|
5258
5310
|
inboxSchemas: entity.inbox_schemas,
|
|
5259
|
-
stateSchemas: entity.state_schemas
|
|
5311
|
+
stateSchemas: entity.state_schemas,
|
|
5312
|
+
externallyWritableCollections: void 0
|
|
5260
5313
|
};
|
|
5261
5314
|
const latestType = await this.registry.getEntityType(entity.type);
|
|
5262
5315
|
return {
|
|
@@ -5267,7 +5320,8 @@ var EntityManager = class {
|
|
|
5267
5320
|
stateSchemas: latestType?.state_schemas ? {
|
|
5268
5321
|
...entity.state_schemas ?? {},
|
|
5269
5322
|
...latestType.state_schemas
|
|
5270
|
-
} : entity.state_schemas
|
|
5323
|
+
} : entity.state_schemas,
|
|
5324
|
+
externallyWritableCollections: latestType?.externally_writable_collections
|
|
5271
5325
|
};
|
|
5272
5326
|
}
|
|
5273
5327
|
isClosedStreamError(err) {
|
|
@@ -5957,9 +6011,13 @@ var Scheduler = class {
|
|
|
5957
6011
|
|
|
5958
6012
|
//#endregion
|
|
5959
6013
|
//#region src/pg-sync-bridge-manager.ts
|
|
5960
|
-
|
|
6014
|
+
/** Registration was rejected because the source itself is invalid — map to a 4xx. */
|
|
6015
|
+
var PgSyncSourceValidationError = class extends Error {
|
|
6016
|
+
name = `PgSyncSourceValidationError`;
|
|
6017
|
+
};
|
|
5961
6018
|
const DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
|
|
5962
6019
|
const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
|
|
6020
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 1e4;
|
|
5963
6021
|
function buildElectricShapeParams(options) {
|
|
5964
6022
|
return {
|
|
5965
6023
|
table: options.table,
|
|
@@ -5979,6 +6037,31 @@ function buildElectricShapeParams(options) {
|
|
|
5979
6037
|
...options.metadata?.wakeId ? { electric_agents_wake_id: options.metadata.wakeId } : {}
|
|
5980
6038
|
};
|
|
5981
6039
|
}
|
|
6040
|
+
/**
|
|
6041
|
+
* Build the one-shot URL used to validate a shape source at registration
|
|
6042
|
+
* time. Approximates the query-param encoding of the Electric TS client
|
|
6043
|
+
* (arrays comma-joined, where-clause params as `params[n]`) — unlike the
|
|
6044
|
+
* client it does not quote column identifiers, so probe and stream encoding
|
|
6045
|
+
* can diverge for exotic column names.
|
|
6046
|
+
*/
|
|
6047
|
+
function buildShapeProbeUrl(sourceUrl, options) {
|
|
6048
|
+
let url;
|
|
6049
|
+
try {
|
|
6050
|
+
url = new URL(sourceUrl);
|
|
6051
|
+
} catch {
|
|
6052
|
+
throw new PgSyncSourceValidationError(`pgSync url "${sourceUrl}" is not a valid URL`);
|
|
6053
|
+
}
|
|
6054
|
+
if (url.protocol !== `http:` && url.protocol !== `https:`) throw new PgSyncSourceValidationError(`pgSync url "${sourceUrl}" must be an HTTP(S) Electric shape endpoint, not a database connection string`);
|
|
6055
|
+
for (const [key, value] of Object.entries(buildElectricShapeParams(options))) {
|
|
6056
|
+
if (value === void 0 || value === null) continue;
|
|
6057
|
+
if (Array.isArray(value)) if (key === `params`) value.forEach((item, index$1) => url.searchParams.set(`params[${index$1 + 1}]`, String(item)));
|
|
6058
|
+
else url.searchParams.set(key, value.join(`,`));
|
|
6059
|
+
else if (typeof value === `object`) for (const [k, v] of Object.entries(value)) url.searchParams.set(`${key}[${k}]`, String(v));
|
|
6060
|
+
else url.searchParams.set(key, String(value));
|
|
6061
|
+
}
|
|
6062
|
+
url.searchParams.set(`offset`, `now`);
|
|
6063
|
+
return url;
|
|
6064
|
+
}
|
|
5982
6065
|
function jsonSafe(value) {
|
|
5983
6066
|
if (typeof value === `bigint`) return value.toString();
|
|
5984
6067
|
if (value === null || typeof value !== `object`) return value;
|
|
@@ -6000,37 +6083,19 @@ function rowKeyForMessage(message) {
|
|
|
6000
6083
|
const candidate = headers.key ?? headers.rowKey ?? message.value?.id ?? message.value?.key ?? message.old_value?.id ?? message.old_value?.key;
|
|
6001
6084
|
return candidate === void 0 ? void 0 : stableJson(candidate);
|
|
6002
6085
|
}
|
|
6003
|
-
function pgSyncMessageToDurableEvent(message
|
|
6086
|
+
function pgSyncMessageToDurableEvent(message) {
|
|
6004
6087
|
const operation = message.headers.operation;
|
|
6005
6088
|
if (operation !== `insert` && operation !== `update` && operation !== `delete`) return null;
|
|
6006
|
-
const
|
|
6007
|
-
|
|
6008
|
-
const
|
|
6009
|
-
if (typeof offset !== `string` || offset.length === 0) return null;
|
|
6010
|
-
const messageKeyPart = offset;
|
|
6011
|
-
const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`;
|
|
6012
|
-
const timestamp$1 = new Date().toISOString();
|
|
6013
|
-
const oldValue = message.old_value;
|
|
6014
|
-
const safeValue = jsonSafe(message.value);
|
|
6015
|
-
const safeOldValue = jsonSafe(oldValue);
|
|
6016
|
-
const safeHeaders = jsonSafe(message.headers);
|
|
6089
|
+
const key = message.key ?? (typeof message.headers.key === `string` ? message.headers.key : void 0) ?? rowKeyForMessage(message);
|
|
6090
|
+
if (!key) return null;
|
|
6091
|
+
const safeMessage = jsonSafe(message);
|
|
6017
6092
|
return {
|
|
6018
6093
|
type: `pg_sync_change`,
|
|
6019
|
-
key
|
|
6020
|
-
value:
|
|
6021
|
-
key: messageKey,
|
|
6022
|
-
table: typeof optionsOrSourceRef === `string` ? void 0 : optionsOrSourceRef.table,
|
|
6023
|
-
operation,
|
|
6024
|
-
...rowKey !== void 0 ? { rowKey } : {},
|
|
6025
|
-
...message.value !== void 0 ? { value: safeValue } : {},
|
|
6026
|
-
...oldValue !== void 0 ? { oldValue: safeOldValue } : {},
|
|
6027
|
-
headers: safeHeaders,
|
|
6028
|
-
...typeof offset === `string` ? { offset } : {},
|
|
6029
|
-
receivedAt: timestamp$1
|
|
6030
|
-
},
|
|
6094
|
+
key,
|
|
6095
|
+
value: safeMessage,
|
|
6031
6096
|
headers: {
|
|
6032
|
-
|
|
6033
|
-
|
|
6097
|
+
...jsonSafe(message.headers),
|
|
6098
|
+
operation
|
|
6034
6099
|
}
|
|
6035
6100
|
};
|
|
6036
6101
|
}
|
|
@@ -6120,13 +6185,13 @@ var PgSyncBridge = class {
|
|
|
6120
6185
|
}
|
|
6121
6186
|
if (!isChangeMessage(message)) continue;
|
|
6122
6187
|
if (!this.skipChangesUntilUpToDate) {
|
|
6123
|
-
const event = pgSyncMessageToDurableEvent(message
|
|
6188
|
+
const event = pgSyncMessageToDurableEvent(message);
|
|
6124
6189
|
if (event) {
|
|
6125
6190
|
if (!this.producer) throw new Error(`pg-sync producer is not started`);
|
|
6126
6191
|
await this.producer.append(JSON.stringify(event));
|
|
6127
6192
|
await this.producer.flush?.();
|
|
6128
6193
|
await this.evaluateWakes?.(this.streamUrl, event);
|
|
6129
|
-
}
|
|
6194
|
+
} else serverLog.warn(`[pg-sync-bridge] dropped change message for ${this.sourceRef} (unknown operation or missing row key):`, message.headers);
|
|
6130
6195
|
}
|
|
6131
6196
|
await this.persistCursor(stream);
|
|
6132
6197
|
this.retryAttempt = 0;
|
|
@@ -6175,13 +6240,15 @@ var PgSyncBridge = class {
|
|
|
6175
6240
|
var PgSyncBridgeManager = class {
|
|
6176
6241
|
bridges = new Map();
|
|
6177
6242
|
starting = new Map();
|
|
6178
|
-
url;
|
|
6179
6243
|
retry;
|
|
6244
|
+
fetchFn;
|
|
6245
|
+
probeTimeoutMs;
|
|
6180
6246
|
constructor(streamClient, evaluateWakes, registry, options = {}) {
|
|
6181
6247
|
this.streamClient = streamClient;
|
|
6182
6248
|
this.evaluateWakes = evaluateWakes;
|
|
6183
6249
|
this.registry = registry;
|
|
6184
|
-
this.
|
|
6250
|
+
this.fetchFn = options.fetchFn;
|
|
6251
|
+
this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
6185
6252
|
this.retry = {
|
|
6186
6253
|
initialDelayMs: options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
|
|
6187
6254
|
maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
|
|
@@ -6192,9 +6259,16 @@ var PgSyncBridgeManager = class {
|
|
|
6192
6259
|
async start() {
|
|
6193
6260
|
const rows = await this.registry?.listPgSyncBridges?.();
|
|
6194
6261
|
if (!rows) return;
|
|
6195
|
-
await Promise.all(rows.map(
|
|
6196
|
-
|
|
6197
|
-
|
|
6262
|
+
await Promise.all(rows.map(async (row) => {
|
|
6263
|
+
if (!row.options.url) {
|
|
6264
|
+
serverLog.warn(`[pg-sync-bridge] deleting registration ${row.sourceRef}: it predates required source URLs; re-register the observation with an explicit Electric shape URL`);
|
|
6265
|
+
await this.registry?.deletePgSyncBridge?.(row.sourceRef);
|
|
6266
|
+
return;
|
|
6267
|
+
}
|
|
6268
|
+
await this.ensureBridge(row).catch((error) => {
|
|
6269
|
+
serverLog.warn(`[pg-sync-bridge] failed to start ${row.sourceRef}:`, error);
|
|
6270
|
+
});
|
|
6271
|
+
}));
|
|
6198
6272
|
}
|
|
6199
6273
|
async register(options, metadata) {
|
|
6200
6274
|
const mergedMetadata = {
|
|
@@ -6208,6 +6282,7 @@ var PgSyncBridgeManager = class {
|
|
|
6208
6282
|
const resolvedSource = this.resolveSource(canonicalOptions);
|
|
6209
6283
|
const sourceRef = sourceRefForPgSync(canonicalOptions);
|
|
6210
6284
|
const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId);
|
|
6285
|
+
if (!this.bridges.has(sourceRef) && !this.starting.has(sourceRef)) await this.probeSource(resolvedSource, canonicalOptions);
|
|
6211
6286
|
const row = await this.registry?.upsertPgSyncBridge({
|
|
6212
6287
|
sourceRef,
|
|
6213
6288
|
options: canonicalOptions,
|
|
@@ -6248,7 +6323,32 @@ var PgSyncBridgeManager = class {
|
|
|
6248
6323
|
await start;
|
|
6249
6324
|
}
|
|
6250
6325
|
resolveSource(options) {
|
|
6251
|
-
|
|
6326
|
+
if (!options.url) throw new PgSyncSourceValidationError(`pgSync source url is required; no server default is configured`);
|
|
6327
|
+
return { url: options.url };
|
|
6328
|
+
}
|
|
6329
|
+
/**
|
|
6330
|
+
* One-shot fetch of the shape log before a bridge is created, so a bad
|
|
6331
|
+
* URL or rejected shape fails the registration instead of dying silently
|
|
6332
|
+
* in the bridge's retry loop.
|
|
6333
|
+
*/
|
|
6334
|
+
async probeSource(source, options) {
|
|
6335
|
+
const probeUrl = buildShapeProbeUrl(source.url, options);
|
|
6336
|
+
const fetchFn = this.fetchFn ?? globalThis.fetch;
|
|
6337
|
+
let response;
|
|
6338
|
+
try {
|
|
6339
|
+
response = await fetchFn(probeUrl, { signal: AbortSignal.timeout(this.probeTimeoutMs) });
|
|
6340
|
+
} catch (error) {
|
|
6341
|
+
throw new PgSyncSourceValidationError(`pgSync source at ${source.url} is unreachable: ${error instanceof Error ? error.message : String(error)}`);
|
|
6342
|
+
}
|
|
6343
|
+
if (!response.ok) {
|
|
6344
|
+
const body = (await response.text().catch(() => `<failed to read body>`)).slice(0, 500);
|
|
6345
|
+
throw new PgSyncSourceValidationError(`pgSync source at ${source.url} rejected the shape request (${response.status})${body ? `: ${body}` : ``}`);
|
|
6346
|
+
}
|
|
6347
|
+
if (!response.headers.get(`electric-handle`)) {
|
|
6348
|
+
const suggestion = new URL(source.url);
|
|
6349
|
+
suggestion.pathname = `/v1/shape`;
|
|
6350
|
+
throw new PgSyncSourceValidationError(`pgSync source at ${source.url} responded but is not a shape log (missing electric-handle header) — the Electric shape API is usually served at ${suggestion.origin}/v1/shape`);
|
|
6351
|
+
}
|
|
6252
6352
|
}
|
|
6253
6353
|
async stop() {
|
|
6254
6354
|
await Promise.allSettled(this.starting.values());
|
|
@@ -7210,6 +7310,7 @@ var WakeRegistry = class {
|
|
|
7210
7310
|
};
|
|
7211
7311
|
if (value && `value` in value) change.value = value.value;
|
|
7212
7312
|
if (value && `oldValue` in value) change.oldValue = value.oldValue;
|
|
7313
|
+
else if (value && `old_value` in value) change.oldValue = value.old_value;
|
|
7213
7314
|
if (eventType === `inbox`) {
|
|
7214
7315
|
if (typeof value?.from === `string`) change.from = value.from;
|
|
7215
7316
|
if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
|
|
@@ -8505,6 +8606,15 @@ const spawnBodySchema = Type.Object({
|
|
|
8505
8606
|
manifestKey: Type.Optional(Type.String())
|
|
8506
8607
|
}))
|
|
8507
8608
|
});
|
|
8609
|
+
const writeCollectionBodySchema = Type.Object({
|
|
8610
|
+
operation: Type.Union([
|
|
8611
|
+
Type.Literal(`insert`),
|
|
8612
|
+
Type.Literal(`update`),
|
|
8613
|
+
Type.Literal(`delete`)
|
|
8614
|
+
]),
|
|
8615
|
+
key: Type.Optional(Type.String()),
|
|
8616
|
+
value: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
|
|
8617
|
+
}, { additionalProperties: false });
|
|
8508
8618
|
const sendBodySchema = Type.Object({
|
|
8509
8619
|
payload: Type.Optional(Type.Unknown()),
|
|
8510
8620
|
key: Type.Optional(Type.String()),
|
|
@@ -8614,8 +8724,8 @@ const subscriptionLifetimeSchema = Type.Union([
|
|
|
8614
8724
|
}),
|
|
8615
8725
|
Type.Object({ kind: Type.Literal(`manual`) })
|
|
8616
8726
|
]);
|
|
8617
|
-
const
|
|
8618
|
-
|
|
8727
|
+
const webhookSourceSubscriptionBodySchema = Type.Object({
|
|
8728
|
+
webhookKey: Type.String(),
|
|
8619
8729
|
bucketKey: Type.Optional(Type.String()),
|
|
8620
8730
|
params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
8621
8731
|
filterKey: Type.Optional(Type.String()),
|
|
@@ -8637,6 +8747,7 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermissi
|
|
|
8637
8747
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
|
|
8638
8748
|
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
|
|
8639
8749
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
|
|
8750
|
+
entitiesRouter.post(`/:type/:instanceId/collections/:collection`, withExistingEntity, withSchema(writeCollectionBodySchema), withEntityPermission(`write`), writeCollection);
|
|
8640
8751
|
entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
|
|
8641
8752
|
entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
|
|
8642
8753
|
entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
|
|
@@ -8647,8 +8758,9 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
|
|
|
8647
8758
|
entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withEntityPermission(`write`), deleteTag);
|
|
8648
8759
|
entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), withEntityPermission(`schedule`), upsertSchedule);
|
|
8649
8760
|
entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withEntityPermission(`schedule`), deleteSchedule);
|
|
8650
|
-
entitiesRouter.put(`/:type/:instanceId/
|
|
8651
|
-
entitiesRouter.delete(`/:type/:instanceId/
|
|
8761
|
+
entitiesRouter.put(`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(webhookSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertWebhookSourceSubscription);
|
|
8762
|
+
entitiesRouter.delete(`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`, withExistingEntity, withEntityPermission(`write`), deleteWebhookSourceSubscription);
|
|
8763
|
+
entitiesRouter.delete(`/:type/:instanceId/pg-sync-observations/:sourceRef`, withExistingEntity, withEntityPermission(`write`), deletePgSyncObservation);
|
|
8652
8764
|
entitiesRouter.get(`/:type/:instanceId/grants`, withExistingEntity, withEntityPermission(`manage`), listEntityPermissionGrants);
|
|
8653
8765
|
entitiesRouter.post(`/:type/:instanceId/grants`, withExistingEntity, withSchema(entityPermissionGrantInputSchema), withEntityPermission(`manage`), createEntityPermissionGrant);
|
|
8654
8766
|
entitiesRouter.delete(`/:type/:instanceId/grants/:grantId`, withExistingEntity, withEntityPermission(`manage`), deleteEntityPermissionGrant);
|
|
@@ -8899,22 +9011,22 @@ async function deleteSchedule(request, ctx) {
|
|
|
8899
9011
|
const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
|
|
8900
9012
|
return json(result);
|
|
8901
9013
|
}
|
|
8902
|
-
async function
|
|
8903
|
-
const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to
|
|
9014
|
+
async function upsertWebhookSourceSubscription(request, ctx) {
|
|
9015
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to webhook sources`);
|
|
8904
9016
|
if (principalMutationError) return principalMutationError;
|
|
8905
|
-
const catalog = ctx.
|
|
8906
|
-
if (!catalog) return apiError(404, ErrCodeNotFound, `No
|
|
9017
|
+
const catalog = ctx.webhookSources;
|
|
9018
|
+
if (!catalog) return apiError(404, ErrCodeNotFound, `No webhook source catalog is configured`);
|
|
8907
9019
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8908
9020
|
const parsed = routeBody(request);
|
|
8909
|
-
const source = await catalog.
|
|
8910
|
-
if (!source) return apiError(404, ErrCodeNotFound, `
|
|
9021
|
+
const source = await catalog.getWebhookSource(parsed.webhookKey);
|
|
9022
|
+
if (!source) return apiError(404, ErrCodeNotFound, `Webhook source "${parsed.webhookKey}" not found`);
|
|
8911
9023
|
if (parsed.lifetime?.kind === `expires_at`) {
|
|
8912
9024
|
const expiresAt = new Date(parsed.lifetime.at);
|
|
8913
9025
|
if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
|
|
8914
9026
|
}
|
|
8915
9027
|
let resolved;
|
|
8916
9028
|
try {
|
|
8917
|
-
resolved =
|
|
9029
|
+
resolved = resolveWebhookSourceSubscription({
|
|
8918
9030
|
contract: source,
|
|
8919
9031
|
entityUrl,
|
|
8920
9032
|
request: {
|
|
@@ -8926,18 +9038,25 @@ async function upsertEventSourceSubscription(request, ctx) {
|
|
|
8926
9038
|
} catch (error) {
|
|
8927
9039
|
return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
|
|
8928
9040
|
}
|
|
8929
|
-
await ctx.
|
|
8930
|
-
const result = await ctx.entityManager.
|
|
9041
|
+
await ctx.ensureWebhookSourceWakeSource?.(resolved.subscription.sourceUrl);
|
|
9042
|
+
const result = await ctx.entityManager.upsertWebhookSourceSubscription(entityUrl, {
|
|
8931
9043
|
subscription: resolved.subscription,
|
|
8932
|
-
manifest:
|
|
9044
|
+
manifest: buildWebhookSourceManifestEntry(resolved)
|
|
8933
9045
|
});
|
|
8934
9046
|
return json(result);
|
|
8935
9047
|
}
|
|
8936
|
-
async function
|
|
8937
|
-
const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from
|
|
9048
|
+
async function deleteWebhookSourceSubscription(request, ctx) {
|
|
9049
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from webhook sources`);
|
|
9050
|
+
if (principalMutationError) return principalMutationError;
|
|
9051
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
9052
|
+
const result = await ctx.entityManager.deleteWebhookSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
|
|
9053
|
+
return json(result);
|
|
9054
|
+
}
|
|
9055
|
+
async function deletePgSyncObservation(request, ctx) {
|
|
9056
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `unobserved a pg-sync source`);
|
|
8938
9057
|
if (principalMutationError) return principalMutationError;
|
|
8939
9058
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8940
|
-
const result = await ctx.entityManager.
|
|
9059
|
+
const result = await ctx.entityManager.deletePgSyncObservation(entityUrl, { sourceRef: decodeURIComponent(request.params.sourceRef) });
|
|
8941
9060
|
return json(result);
|
|
8942
9061
|
}
|
|
8943
9062
|
function tagResponseBody(entity) {
|
|
@@ -9035,6 +9154,23 @@ async function sendEntity(request, ctx) {
|
|
|
9035
9154
|
const result = await ctx.entityManager.send(entityUrl, sendReq);
|
|
9036
9155
|
return json(result);
|
|
9037
9156
|
}
|
|
9157
|
+
async function writeCollection(request, ctx) {
|
|
9158
|
+
const parsed = routeBody(request);
|
|
9159
|
+
await ctx.entityManager.ensurePrincipal(ctx.principal);
|
|
9160
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
9161
|
+
const collection = request.params.collection;
|
|
9162
|
+
const result = await ctx.entityManager.writeCollection(entityUrl, collection, {
|
|
9163
|
+
operation: parsed.operation,
|
|
9164
|
+
key: parsed.key,
|
|
9165
|
+
value: parsed.value,
|
|
9166
|
+
principal: {
|
|
9167
|
+
url: ctx.principal.url,
|
|
9168
|
+
kind: ctx.principal.kind,
|
|
9169
|
+
id: ctx.principal.id
|
|
9170
|
+
}
|
|
9171
|
+
});
|
|
9172
|
+
return json(result, { status: parsed.operation === `insert` ? 201 : 200 });
|
|
9173
|
+
}
|
|
9038
9174
|
async function createAttachment(request, ctx) {
|
|
9039
9175
|
const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
|
|
9040
9176
|
if (principalMutationError) return principalMutationError;
|
|
@@ -9132,8 +9268,13 @@ async function spawnEntity(request, ctx) {
|
|
|
9132
9268
|
headers: { "x-write-token": entity.write_token }
|
|
9133
9269
|
});
|
|
9134
9270
|
}
|
|
9135
|
-
function getEntity(request) {
|
|
9136
|
-
|
|
9271
|
+
async function getEntity(request, ctx) {
|
|
9272
|
+
const { entity } = requireExistingEntityRoute(request);
|
|
9273
|
+
const entityType = entity.type ? await ctx.entityManager.registry.getEntityType(entity.type) : null;
|
|
9274
|
+
return json({
|
|
9275
|
+
...toPublicEntity(entity),
|
|
9276
|
+
...entityType?.externally_writable_collections && { externally_writable_collections: entityType.externally_writable_collections }
|
|
9277
|
+
});
|
|
9137
9278
|
}
|
|
9138
9279
|
function headEntity() {
|
|
9139
9280
|
return status(200);
|
|
@@ -9168,6 +9309,16 @@ async function signalEntity(request, ctx) {
|
|
|
9168
9309
|
//#region src/routing/entity-types-router.ts
|
|
9169
9310
|
const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
|
|
9170
9311
|
const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
|
|
9312
|
+
const externallyWritableCollectionsSchema = Type.Record(Type.String(), Type.Object({
|
|
9313
|
+
type: Type.String(),
|
|
9314
|
+
contract: Type.Optional(Type.String()),
|
|
9315
|
+
operations: Type.Optional(Type.Array(Type.Union([
|
|
9316
|
+
Type.Literal(`insert`),
|
|
9317
|
+
Type.Literal(`update`),
|
|
9318
|
+
Type.Literal(`delete`)
|
|
9319
|
+
]))),
|
|
9320
|
+
principalColumn: Type.Optional(Type.String())
|
|
9321
|
+
}, { additionalProperties: false }));
|
|
9171
9322
|
const slashCommandArgumentSchema = Type.Object({
|
|
9172
9323
|
name: Type.String(),
|
|
9173
9324
|
type: Type.Union([
|
|
@@ -9198,7 +9349,8 @@ const registerEntityTypeBodySchema = Type.Object({
|
|
|
9198
9349
|
slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
|
|
9199
9350
|
serve_endpoint: Type.Optional(Type.String()),
|
|
9200
9351
|
default_dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
9201
|
-
permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
|
|
9352
|
+
permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema)),
|
|
9353
|
+
externally_writable_collections: Type.Optional(externallyWritableCollectionsSchema)
|
|
9202
9354
|
}, { additionalProperties: false });
|
|
9203
9355
|
const amendEntityTypeSchemasBodySchema = Type.Object({
|
|
9204
9356
|
inbox_schemas: Type.Optional(schemaMapSchema),
|
|
@@ -9329,7 +9481,20 @@ function parseExpiresAt(value) {
|
|
|
9329
9481
|
if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
|
|
9330
9482
|
return expiresAt;
|
|
9331
9483
|
}
|
|
9484
|
+
/**
|
|
9485
|
+
* The `comments` collection name is reserved for the canonical comments
|
|
9486
|
+
* contract: the UI keys its comment affordances on it, so a divergent
|
|
9487
|
+
* collection registered under that name (or the contract mounted under
|
|
9488
|
+
* another name) would break that assumption silently.
|
|
9489
|
+
*/
|
|
9490
|
+
function validateExternallyWritableCollections(collections) {
|
|
9491
|
+
for (const [name, config] of Object.entries(collections ?? {})) {
|
|
9492
|
+
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);
|
|
9493
|
+
if (config.contract === COMMENTS_CONTRACT && name !== `comments`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `The "${COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`, 400);
|
|
9494
|
+
}
|
|
9495
|
+
}
|
|
9332
9496
|
function normalizeEntityTypeRequest(parsed) {
|
|
9497
|
+
validateExternallyWritableCollections(parsed.externally_writable_collections);
|
|
9333
9498
|
const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
|
|
9334
9499
|
return {
|
|
9335
9500
|
name: parsed.name ?? ``,
|
|
@@ -9343,7 +9508,8 @@ function normalizeEntityTypeRequest(parsed) {
|
|
|
9343
9508
|
type: `webhook`,
|
|
9344
9509
|
url: serveEndpoint
|
|
9345
9510
|
}] } : void 0),
|
|
9346
|
-
permission_grants: parsed.permission_grants
|
|
9511
|
+
permission_grants: parsed.permission_grants,
|
|
9512
|
+
externally_writable_collections: parsed.externally_writable_collections
|
|
9347
9513
|
};
|
|
9348
9514
|
}
|
|
9349
9515
|
function toPublicEntityType(entityType) {
|
|
@@ -9356,7 +9522,7 @@ function toPublicEntityType(entityType) {
|
|
|
9356
9522
|
//#endregion
|
|
9357
9523
|
//#region src/routing/pg-sync-router.ts
|
|
9358
9524
|
const pgSyncOptionsSchema = Type.Object({
|
|
9359
|
-
url: Type.
|
|
9525
|
+
url: Type.String(),
|
|
9360
9526
|
table: Type.String(),
|
|
9361
9527
|
columns: Type.Optional(Type.Array(Type.String())),
|
|
9362
9528
|
where: Type.Optional(Type.String()),
|
|
@@ -9378,6 +9544,7 @@ const pgSyncRouter = Router({ base: `/_electric/pg-sync` });
|
|
|
9378
9544
|
pgSyncRouter.post(`/register`, withSchema(pgSyncRegisterBodySchema), registerPgSync);
|
|
9379
9545
|
async function registerPgSync(request, ctx) {
|
|
9380
9546
|
const { options, metadata } = routeBody(request);
|
|
9547
|
+
if (options.url.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync url must be non-empty`);
|
|
9381
9548
|
if (options.table.trim() === ``) return apiError(400, ErrCodeInvalidRequest, `pgSync table must be non-empty`);
|
|
9382
9549
|
if (!ctx.pgSyncBridgeManager) return apiError(503, ErrCodeInvalidRequest, `pgSync bridge manager is not configured`);
|
|
9383
9550
|
try {
|
|
@@ -9392,6 +9559,8 @@ async function registerPgSync(request, ctx) {
|
|
|
9392
9559
|
const result = await ctx.pgSyncBridgeManager.register(options, requestMetadata$1);
|
|
9393
9560
|
return json(result);
|
|
9394
9561
|
} catch (error) {
|
|
9562
|
+
if (error instanceof PgSyncSourceValidationError) return apiError(400, ErrCodeInvalidRequest, error.message);
|
|
9563
|
+
serverLog.error(`[pg-sync] registration failed for table "${options.table}":`, error);
|
|
9395
9564
|
return apiError(500, ErrCodeInvalidRequest, `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
9396
9565
|
}
|
|
9397
9566
|
}
|
|
@@ -9916,7 +10085,7 @@ const wakeCallbackBodySchema = Type.Object({
|
|
|
9916
10085
|
const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
|
|
9917
10086
|
const internalRouter = Router({ base: `/_electric` });
|
|
9918
10087
|
internalRouter.get(`/health`, () => json({ status: `ok` }));
|
|
9919
|
-
internalRouter.get(`/
|
|
10088
|
+
internalRouter.get(`/webhook-sources`, listWebhookSources);
|
|
9920
10089
|
internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
|
|
9921
10090
|
internalRouter.post(`/subscription-webhooks/:subscriptionId`, subscriptionWebhook);
|
|
9922
10091
|
internalRouter.post(`/wake-callbacks/:consumerId`, wakeCallback);
|
|
@@ -10038,11 +10207,11 @@ async function registerWake(request, ctx) {
|
|
|
10038
10207
|
await ctx.entityManager.registerWake(opts);
|
|
10039
10208
|
return status(204);
|
|
10040
10209
|
}
|
|
10041
|
-
async function
|
|
10042
|
-
const
|
|
10043
|
-
return json({
|
|
10210
|
+
async function listWebhookSources(_request, ctx) {
|
|
10211
|
+
const webhookSources = ctx.webhookSources ? await ctx.webhookSources.listWebhookSources() : [];
|
|
10212
|
+
return json({ webhookSources: webhookSources.filter(isAgentVisibleWebhookSource) });
|
|
10044
10213
|
}
|
|
10045
|
-
function
|
|
10214
|
+
function isAgentVisibleWebhookSource(source) {
|
|
10046
10215
|
return source.agentVisible === true && source.status === `active`;
|
|
10047
10216
|
}
|
|
10048
10217
|
async function subscriptionWebhook(request, ctx) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "entity_types" ADD COLUMN "externally_writable_collections" jsonb;
|
|
@@ -113,6 +113,13 @@
|
|
|
113
113
|
"when": 1779728400000,
|
|
114
114
|
"tag": "0015_pg_sync_bridges",
|
|
115
115
|
"breakpoints": true
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"idx": 16,
|
|
119
|
+
"version": "7",
|
|
120
|
+
"when": 1781200000000,
|
|
121
|
+
"tag": "0016_entity_type_externally_writable_collections",
|
|
122
|
+
"breakpoints": true
|
|
116
123
|
}
|
|
117
124
|
]
|
|
118
125
|
}
|