@synapsor/client 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -45,7 +45,7 @@ npm install @synapsor/client
45
45
  ```js
46
46
  import { Synapsor } from "@synapsor/client";
47
47
 
48
- const db = await Synapsor.connect("https://dev-api.synapsor.ai", {
48
+ const db = await Synapsor.connect("https://synapsor.ai", {
49
49
  apiKey: process.env.SYNAPSOR_API_KEY,
50
50
  });
51
51
 
@@ -61,11 +61,112 @@ const ctx = await db.invokeAgentCapability("chat.prepare_llm_context", { questio
61
61
 
62
62
  Use database-scoped API keys from the Synapsor control panel for hosted projects.
63
63
 
64
+ ## Agent Workflows
65
+
66
+ Use `agentRuns` when an agent framework owns routing, but Synapsor should own
67
+ the durable workflow contract: session scope, capability calls, evidence,
68
+ proposal branches, outbox actions, settlement, and replay.
69
+
70
+ ```js
71
+ const run = await db.agentRuns.start({
72
+ workflow: "billing.late_fee_waiver_flow",
73
+ version: "2026-05-27",
74
+ input: { user_request: "Can we waive this late fee?" },
75
+ });
76
+
77
+ const answer = await run.invokeCapability("support.answer_ticket_question", {
78
+ stepKey: "answer_ticket_question",
79
+ arguments: { question: "Can we waive this late fee?" },
80
+ responseEnvelope: true,
81
+ });
82
+
83
+ const proposal = await run.invokeCapability("billing.propose_late_fee_waiver", {
84
+ stepKey: "propose_waiver",
85
+ arguments: { amount_cents: 2500 },
86
+ mode: "propose_only",
87
+ autoBranch: true,
88
+ responseEnvelope: true,
89
+ });
90
+
91
+ const action = await run.proposeExternalAction("stripe.issue_refund", {
92
+ stepKey: "refund_customer",
93
+ arguments: { charge_id: "ch_123", amount_cents: 2500 },
94
+ idempotencyKey: "refund:TCK_1001:2500",
95
+ });
96
+
97
+ await run.checkpoint("before_worker_claim", {
98
+ payload: { reason: "external action queued" },
99
+ });
100
+ await run.complete({ decision: "waiver_proposed" }, { status: "waiting_approval" });
101
+ const graph = await run.explain();
102
+ ```
103
+
104
+ Replay a stored capability run by using the numeric `agent_run_id` returned by
105
+ Synapsor. Deterministic replay returns the captured persisted run; comparison
106
+ modes can inspect the original snapshot, current state, a commit version, a
107
+ timestamp, or a review branch.
108
+
109
+ ```js
110
+ const replay = await db.replayAgentRun(123, {
111
+ mode: "original_snapshot",
112
+ });
113
+
114
+ const branchReplay = await db.replayAgentRun(123, {
115
+ mode: "branch",
116
+ branchName: "review_run_123",
117
+ });
118
+ ```
119
+
120
+ Workers should claim and confirm side effects through the outbox namespace:
121
+
122
+ ```js
123
+ const task = await db.externalActions.claim({
124
+ queue: "billing_external_actions",
125
+ workerId: "billing-worker-1",
126
+ });
127
+ await db.externalActions.confirm(task.action_instance_id, {
128
+ status: "succeeded",
129
+ providerRequestId: "re_456",
130
+ response: { status: "succeeded" },
131
+ });
132
+ ```
133
+
134
+ ## External DB Writeback Worker
135
+
136
+ For existing Postgres/MySQL integrations, keep Synapsor in proposal mode. The
137
+ agent stages an evidence-backed external write proposal, a human or settlement
138
+ policy approves it, and a trusted worker in your app environment applies the
139
+ approved change back to your existing database with parameterized SQL.
140
+
141
+ ```js
142
+ import { createExternalDbWritebackWorker } from "@synapsor/client/external-db-writeback";
143
+
144
+ const worker = createExternalDbWritebackWorker({
145
+ synapsorApiKey: process.env.SYNAPSOR_API_KEY,
146
+ sourceName: "app_postgres",
147
+ execute: async ({ sql, params }) => {
148
+ // Use your own Postgres/MySQL client here. The model never provides SQL.
149
+ return appDb.query(sql, params);
150
+ },
151
+ });
152
+
153
+ await worker.pollOnce();
154
+ ```
155
+
156
+ The worker validates Synapsor-provided mapping metadata, only writes allowlisted
157
+ columns, adds tenant and primary-key guards, checks optional conflict columns,
158
+ and reports `applied`, `conflict`, or `failed` back to Synapsor idempotently.
159
+
64
160
  ## API Surface
65
161
 
66
162
  - `execute(sql)` and `query(sql)`
67
163
  - `setSession({...})`
68
164
  - `invokeAgentCapability(name, args, options)`
165
+ - `agentRuns.start(...)`, `run.invokeCapability(...)`, `run.checkpoint(...)`, `run.complete(...)`, `run.explain(...)`
166
+ - `replayAgentRun(id, { mode, version, timestamp, branchName })`
167
+ - `externalActions.claim(...)` and `externalActions.confirm(...)`
168
+ - `createAgentEval(...)` / `evals.create(...)` for run-history or `sourceTable` dataset evals
169
+ - `evals.run(...).failures()`
69
170
  - `listCapabilities(query)`
70
171
  - `rememberFact({...})`
71
172
  - `proposeMemoryFact({...})`
@@ -77,6 +178,10 @@ Use database-scoped API keys from the Synapsor control panel for hosted projects
77
178
  - `checkFactForAction({...})`
78
179
  - branch helpers: `createBranch`, `useBranch`, `diffBranch`, `mergeBranch`, `dropBranch`
79
180
  - write lifecycle helpers: `previewWrite`, `approveWrite`, `commitWrite`, `rejectWrite`, `settleWrite`
181
+ - external DB writeback worker: `createExternalDbWritebackWorker` from `@synapsor/client/external-db-writeback`
80
182
  - `readResource(uri)`
81
183
 
82
- Errors from Synapsor become `SynapsorError` with `status` and `payload`.
184
+ Errors from Synapsor become `SynapsorError` with `status`, `code`,
185
+ `requestId`, `retryable`, and the raw `payload`. Include `requestId` when
186
+ opening support or incident tickets so server, gateway, and runtime logs can be
187
+ joined without exposing secrets.
package/bin/synapsor.mjs CHANGED
@@ -2,12 +2,14 @@
2
2
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
+ import { createRequire } from "node:module";
5
6
  import { dirname, join } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { createInterface } from "node:readline/promises";
8
9
  import { stdin as input, stdout as output } from "node:process";
9
10
 
10
- const PACKAGE_VERSION = "0.1.2";
11
+ const require = createRequire(import.meta.url);
12
+ const { version: PACKAGE_VERSION } = require("../package.json");
11
13
  const DEFAULT_BASE_URL = "https://synapsor.ai";
12
14
  const ERROR_HINTS = new Map([
13
15
  ["AUTH_REQUIRED", "Set SYNAPSOR_API_KEY or run `synapsor config set api-key`."],
@@ -157,6 +159,49 @@ function mapError(error) {
157
159
  return String(error || "REQUEST_FAILED").toUpperCase();
158
160
  }
159
161
 
162
+ function flagValue(args, names, fallback = "") {
163
+ const aliases = Array.isArray(names) ? names : [names];
164
+ for (let i = 0; i < args.length; i += 1) {
165
+ if (aliases.includes(args[i])) return args[i + 1] || fallback;
166
+ }
167
+ return fallback;
168
+ }
169
+
170
+ function csvFlag(args, name) {
171
+ const value = flagValue(args, name, "");
172
+ return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : [];
173
+ }
174
+
175
+ function resolveSecretRef(value) {
176
+ const text = String(value || "").trim();
177
+ if (text.startsWith("env:")) {
178
+ const envName = text.slice(4);
179
+ const resolved = process.env[envName] || "";
180
+ if (!resolved) throw new Error(`SECRET_ENV_REQUIRED: ${envName} is not set`);
181
+ return resolved;
182
+ }
183
+ return text;
184
+ }
185
+
186
+ function scopedProject(globals) {
187
+ return globals.project || process.env.SYNAPSOR_PROJECT_ID || "";
188
+ }
189
+
190
+ function scopedDatabase(globals) {
191
+ return globals.db || process.env.SYNAPSOR_DATABASE_ID || "";
192
+ }
193
+
194
+ async function resolveSourceId(globals, nameOrId) {
195
+ const text = String(nameOrId || "").trim();
196
+ if (!text) throw new Error("SOURCE_REQUIRED: provide an external source name or source_id");
197
+ if (text.startsWith("src_")) return text;
198
+ const query = scopedProject(globals) ? `?project_id=${encodeURIComponent(scopedProject(globals))}` : "";
199
+ const listed = await request(globals, "GET", `/v1/control/external-sources${query}`);
200
+ const match = (listed.sources || []).find((source) => source.name === text || source.source_id === text);
201
+ if (!match) throw new Error(`NOT_FOUND: external source not found: ${text}`);
202
+ return match.source_id;
203
+ }
204
+
160
205
  async function confirm(globals, label) {
161
206
  if (globals.yes) return;
162
207
  const rl = createInterface({ input, output });
@@ -221,6 +266,98 @@ async function main(argv = process.argv.slice(2)) {
221
266
  }
222
267
  }
223
268
 
269
+ if (cmd === "sources") {
270
+ if (sub === "list") {
271
+ const query = scopedProject(globals) ? `?project_id=${encodeURIComponent(scopedProject(globals))}` : "";
272
+ const payload = await request(globals, "GET", `/v1/control/external-sources${query}`);
273
+ return print(payload.sources || [], globals);
274
+ }
275
+ if (sub === "show") {
276
+ const sourceId = await resolveSourceId(globals, third);
277
+ return print(await request(globals, "GET", `/v1/control/external-sources/${encodeURIComponent(sourceId)}`), globals);
278
+ }
279
+ if (sub === "create" && ["postgres", "mysql"].includes(third)) {
280
+ const kind = third;
281
+ const defaultName = kind === "mysql" ? "app_mysql" : "app_postgres";
282
+ const envHint = kind === "mysql" ? "APP_MYSQL_URL" : "APP_POSTGRES_URL";
283
+ const name = flagValue(rest, "--name", defaultName);
284
+ const url = resolveSecretRef(flagValue(rest, "--url", ""));
285
+ const ssl = flagValue(rest, "--ssl", "require");
286
+ const mode = flagValue(rest, "--mode", "read-only");
287
+ const projectId = scopedProject(globals);
288
+ const databaseId = scopedDatabase(globals);
289
+ if (!projectId || !databaseId) throw new Error("PROJECT_AND_DATABASE_REQUIRED: use --project <project_id> --db <database_id> or SYNAPSOR_PROJECT_ID/SYNAPSOR_DATABASE_ID");
290
+ if (!url) throw new Error(`${kind.toUpperCase()}_URL_REQUIRED: usage \`synapsor sources create ${kind} --name ${defaultName} --url env:${envHint} --project <project> --db <database>\``);
291
+ return print(await request(globals, "POST", "/v1/control/external-sources", {
292
+ project_id: projectId,
293
+ database_id: databaseId,
294
+ kind,
295
+ name,
296
+ connection: { url, ssl_mode: ssl },
297
+ mode,
298
+ }), globals);
299
+ }
300
+ if (["test", "inspect", "generate", "doctor", "disable"].includes(sub)) {
301
+ const sourceId = await resolveSourceId(globals, third);
302
+ if (sub === "test") return print(await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/test`, {}), globals);
303
+ if (sub === "inspect") {
304
+ const schema = flagValue(rest, "--schema", "public");
305
+ const database = flagValue(rest, "--database", "");
306
+ const payload = database ? { database, include_views: true } : { schemas: [schema], include_views: true };
307
+ return print(await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/inspect`, payload), globals);
308
+ }
309
+ if (sub === "generate") {
310
+ const generated = await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/generate`, {
311
+ template: flagValue(rest, "--template", "support-ticket-agent"),
312
+ namespace: flagValue(rest, "--namespace", "support"),
313
+ });
314
+ const outDir = flagValue(rest, "--out", "");
315
+ if (outDir && Array.isArray(generated.files)) {
316
+ await mkdir(outDir, { recursive: true });
317
+ const written = [];
318
+ for (const file of generated.files) {
319
+ const relative = String(file.path || "generated.txt").replace(/^[/\\]+/, "").replace(/\.\.[/\\]/g, "");
320
+ const path = join(outDir, relative);
321
+ await mkdir(dirname(path), { recursive: true });
322
+ await writeFile(path, String(file.contents || ""));
323
+ written.push(path);
324
+ }
325
+ return print({ ok: true, source_id: generated.source_id, capability: generated.capability, out: outDir, files_written: written }, globals);
326
+ }
327
+ return print(generated, globals);
328
+ }
329
+ if (sub === "doctor") {
330
+ const database = flagValue(rest, "--database", "");
331
+ const payload = database ? { database } : { schemas: [flagValue(rest, "--schema", "public")] };
332
+ return print(await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/doctor`, payload), globals);
333
+ }
334
+ await confirm(globals, "Disabling an external source blocks future reads but keeps audit/evidence history.");
335
+ return print(await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/disable`, {}), globals);
336
+ }
337
+ if (sub === "import") {
338
+ const sourceId = await resolveSourceId(globals, third);
339
+ const schema = flagValue(rest, "--schema", "public");
340
+ const database = flagValue(rest, "--database", "");
341
+ const tables = csvFlag(rest, "--tables");
342
+ const tenantColumn = flagValue(rest, "--tenant-column", "");
343
+ const mode = flagValue(rest, "--mode", "live-read");
344
+ if (!tables.length) throw new Error("TABLES_REQUIRED: pass --tables tickets,customers,policy_chunks");
345
+ if (!tenantColumn && !rest.includes("--single-tenant")) throw new Error("TENANT_COLUMN_REQUIRED: pass --tenant-column tenant_id or --single-tenant");
346
+ return print(await request(globals, "POST", `/v1/control/external-sources/${encodeURIComponent(sourceId)}/import`, {
347
+ tables: tables.map((table) => ({
348
+ ...(database ? { database } : {}),
349
+ schema,
350
+ name: table,
351
+ alias_schema: flagValue(rest, "--alias-schema", database ? "external_shop" : "external_support"),
352
+ alias_name: table,
353
+ tenant_column: tenantColumn || undefined,
354
+ single_tenant: rest.includes("--single-tenant"),
355
+ mode,
356
+ })),
357
+ }), globals);
358
+ }
359
+ }
360
+
224
361
  if (cmd === "sql") {
225
362
  const db = globals.db || "";
226
363
  let sql = args.slice(1).join(" ").trim();
@@ -271,6 +408,19 @@ Usage:
271
408
  synapsor projects create <name>
272
409
  synapsor db list --project <project>
273
410
  synapsor db create <name> --project <project>
411
+ synapsor sources create postgres --name app_postgres --url env:APP_POSTGRES_URL --ssl require --mode read-only --project <project> --db <database>
412
+ synapsor sources create mysql --name app_mysql --url env:APP_MYSQL_URL --ssl require --mode read-only --project <project> --db <database>
413
+ synapsor sources test app_postgres --project <project>
414
+ synapsor sources inspect app_postgres --schema public --project <project>
415
+ synapsor sources inspect app_mysql --database shopdb --project <project>
416
+ synapsor sources import app_postgres --schema public --tables tickets,customers,policy_chunks --tenant-column tenant_id --project <project>
417
+ synapsor sources import app_mysql --database shopdb --tables orders,customers,refund_policies --tenant-column tenant_id --project <project>
418
+ synapsor sources generate app_postgres --template support-ticket-agent --namespace support --project <project>
419
+ synapsor sources generate app_mysql --template ecommerce-order-agent --namespace ecommerce --project <project>
420
+ synapsor sources list --project <project>
421
+ synapsor sources show app_postgres --project <project>
422
+ synapsor sources doctor app_postgres --project <project>
423
+ synapsor sources disable app_postgres --project <project> --yes
274
424
  synapsor sql --db <database> "SELECT 1;"
275
425
  synapsor sql --db <database> --file ./query.sql
276
426
  synapsor invoke <capability> --db <database> --json '{"ticket_id":"TICK-1001"}'
@@ -0,0 +1,266 @@
1
+ function quoteIdentifier(kind, identifier) {
2
+ const text = String(identifier || "");
3
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(text)) {
4
+ throw new Error(`UNSAFE_IDENTIFIER: ${text}`);
5
+ }
6
+ return kind === "mysql" ? `\`${text}\`` : `"${text}"`;
7
+ }
8
+
9
+ function placeholder(kind, index) {
10
+ return kind === "mysql" ? "?" : `$${index}`;
11
+ }
12
+
13
+ function assertAllowedColumns(proposal, columns, label) {
14
+ const allowed = new Set((proposal.allowed_columns || []).map(String));
15
+ const blocked = columns.filter((column) => !allowed.has(String(column)));
16
+ if (blocked.length) {
17
+ throw new Error(`WRITEBACK_COLUMN_NOT_ALLOWED: ${label} ${blocked.join(",")}`);
18
+ }
19
+ }
20
+
21
+ export function buildExternalWritebackSql(proposal, { kind = undefined } = {}) {
22
+ const sourceKind = kind || proposal.source_kind || "postgres";
23
+ if (!["postgres", "mysql"].includes(sourceKind)) {
24
+ throw new Error(`UNSUPPORTED_WRITEBACK_KIND: ${sourceKind}`);
25
+ }
26
+ const operation = String(proposal.operation || "update").toLowerCase();
27
+ if (!["update", "insert", "delete"].includes(operation)) {
28
+ throw new Error(`UNSUPPORTED_WRITEBACK_OPERATION: ${operation}`);
29
+ }
30
+ const schema = String(proposal.external_schema || proposal.external_catalog || "");
31
+ const table = String(proposal.external_table || "");
32
+ if (!schema || !table) {
33
+ throw new Error("WRITEBACK_TABLE_REQUIRED: proposal must include external_schema and external_table");
34
+ }
35
+ const tableRef = `${quoteIdentifier(sourceKind, schema)}.${quoteIdentifier(sourceKind, table)}`;
36
+ const changes = proposal.changes && typeof proposal.changes === "object" ? proposal.changes : {};
37
+ const primaryKey = proposal.primary_key && typeof proposal.primary_key === "object" ? proposal.primary_key : {};
38
+ const conflict = proposal.conflict && typeof proposal.conflict === "object" ? proposal.conflict : {};
39
+ const tenantColumn = proposal.tenant_column ? String(proposal.tenant_column) : "";
40
+ const tenantId = proposal.tenant_id;
41
+ const conflictColumn = proposal.conflict_column ? String(proposal.conflict_column) : "";
42
+ const params = [];
43
+ const where = [];
44
+
45
+ function pushParam(value) {
46
+ params.push(value);
47
+ return placeholder(sourceKind, params.length);
48
+ }
49
+
50
+ function appendWhereClauses() {
51
+ for (const column of pkColumns) {
52
+ where.push(`${quoteIdentifier(sourceKind, column)} = ${pushParam(primaryKey[column])}`);
53
+ }
54
+ if (tenantColumn) {
55
+ assertAllowedColumns(proposal, [tenantColumn], "tenant");
56
+ where.push(`${quoteIdentifier(sourceKind, tenantColumn)} = ${pushParam(tenantId)}`);
57
+ }
58
+ if (conflictColumn && Object.prototype.hasOwnProperty.call(conflict, conflictColumn)) {
59
+ assertAllowedColumns(proposal, [conflictColumn], "conflict");
60
+ where.push(`${quoteIdentifier(sourceKind, conflictColumn)} = ${pushParam(conflict[conflictColumn])}`);
61
+ }
62
+ }
63
+
64
+ if (operation === "insert") {
65
+ const insertColumns = Object.keys(changes);
66
+ if (tenantColumn && !insertColumns.includes(tenantColumn)) {
67
+ insertColumns.push(tenantColumn);
68
+ changes[tenantColumn] = tenantId;
69
+ }
70
+ if (!insertColumns.length) {
71
+ throw new Error("WRITEBACK_INSERT_VALUES_REQUIRED");
72
+ }
73
+ assertAllowedColumns(proposal, insertColumns, "insert");
74
+ const columnSql = insertColumns.map((column) => quoteIdentifier(sourceKind, column)).join(", ");
75
+ const valueSql = insertColumns.map((column) => pushParam(changes[column])).join(", ");
76
+ return {
77
+ sql: `INSERT INTO ${tableRef} (${columnSql}) VALUES (${valueSql})`,
78
+ params,
79
+ operation,
80
+ expectedRowCount: 1,
81
+ };
82
+ }
83
+
84
+ const pkColumns = Object.keys(primaryKey);
85
+ if (!pkColumns.length) {
86
+ throw new Error("WRITEBACK_PRIMARY_KEY_REQUIRED");
87
+ }
88
+ assertAllowedColumns(proposal, pkColumns, "primary_key");
89
+
90
+ if (operation === "delete") {
91
+ appendWhereClauses();
92
+ return {
93
+ sql: `DELETE FROM ${tableRef} WHERE ${where.join(" AND ")}`,
94
+ params,
95
+ operation,
96
+ expectedRowCount: 1,
97
+ };
98
+ }
99
+
100
+ const changeColumns = Object.keys(changes);
101
+ if (!changeColumns.length) {
102
+ throw new Error("WRITEBACK_UPDATE_VALUES_REQUIRED");
103
+ }
104
+ assertAllowedColumns(proposal, changeColumns, "update");
105
+ const protectedColumns = new Set([...pkColumns, tenantColumn, conflictColumn].filter(Boolean));
106
+ const protectedChanges = changeColumns.filter((column) => protectedColumns.has(column));
107
+ if (protectedChanges.length) {
108
+ throw new Error(`WRITEBACK_PROTECTED_COLUMN_CHANGE: ${protectedChanges.join(",")}`);
109
+ }
110
+ const setSql = changeColumns
111
+ .map((column) => `${quoteIdentifier(sourceKind, column)} = ${pushParam(changes[column])}`)
112
+ .join(", ");
113
+ appendWhereClauses();
114
+ return {
115
+ sql: `UPDATE ${tableRef} SET ${setSql} WHERE ${where.join(" AND ")}`,
116
+ params,
117
+ operation,
118
+ expectedRowCount: 1,
119
+ };
120
+ }
121
+
122
+ async function request({ baseUrl, apiKey, fetchImpl }, method, path, body) {
123
+ const response = await fetchImpl(`${baseUrl.replace(/\/+$/, "")}${path}`, {
124
+ method,
125
+ headers: {
126
+ accept: "application/json",
127
+ authorization: `Bearer ${apiKey}`,
128
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
129
+ },
130
+ body: body === undefined ? undefined : JSON.stringify(body),
131
+ });
132
+ const text = await response.text();
133
+ let payload = {};
134
+ if (text.trim()) {
135
+ payload = JSON.parse(text);
136
+ }
137
+ if (!response.ok || payload.ok === false) {
138
+ const error = new Error(payload.message || payload.error || `HTTP ${response.status}`);
139
+ error.payload = payload;
140
+ throw error;
141
+ }
142
+ return payload;
143
+ }
144
+
145
+ function rowCountFromResult(result) {
146
+ if (typeof result === "number") return result;
147
+ if (!result || typeof result !== "object") return 0;
148
+ return Number(result.rowCount ?? result.affectedRows ?? result.rowsAffected ?? 0);
149
+ }
150
+
151
+ async function executeWithDb(db, built, proposal, kind) {
152
+ if (!db) {
153
+ throw new Error("WRITEBACK_EXECUTOR_REQUIRED: pass execute({sql, params}) or db client");
154
+ }
155
+ if (typeof db === "function") {
156
+ return db({ ...built, proposal, kind });
157
+ }
158
+ if (typeof db.execute === "function") {
159
+ return db.execute(built.sql, built.params);
160
+ }
161
+ if (typeof db.query === "function") {
162
+ return db.query(built.sql, built.params);
163
+ }
164
+ throw new Error("WRITEBACK_EXECUTOR_REQUIRED: db must expose execute() or query()");
165
+ }
166
+
167
+ export function createExternalDbWritebackWorker(options = {}) {
168
+ const {
169
+ baseUrl = "https://synapsor.ai",
170
+ synapsorApiKey = process.env.SYNAPSOR_API_KEY,
171
+ sourceId = "",
172
+ sourceName = "",
173
+ projectId = "",
174
+ kind = undefined,
175
+ db = undefined,
176
+ execute = undefined,
177
+ fetchImpl = globalThis.fetch,
178
+ pollIntervalMs = 5000,
179
+ dryRun = false,
180
+ } = options;
181
+ if (!synapsorApiKey) {
182
+ throw new Error("SYNAPSOR_API_KEY_REQUIRED");
183
+ }
184
+ if (!fetchImpl) {
185
+ throw new Error("FETCH_REQUIRED");
186
+ }
187
+ let stopped = false;
188
+ let timer = null;
189
+ const client = { baseUrl, apiKey: synapsorApiKey, fetchImpl };
190
+
191
+ async function resolveSourceId() {
192
+ if (sourceId) return sourceId;
193
+ if (!sourceName) return "";
194
+ const query = projectId ? `?project_id=${encodeURIComponent(projectId)}` : "";
195
+ const listed = await request(client, "GET", `/v1/control/external-sources${query}`);
196
+ const match = (listed.sources || []).find((source) => source.name === sourceName || source.source_id === sourceName);
197
+ if (!match) throw new Error(`SOURCE_NOT_FOUND: ${sourceName}`);
198
+ return match.source_id;
199
+ }
200
+
201
+ async function pollOnce() {
202
+ const resolvedSourceId = await resolveSourceId();
203
+ const params = new URLSearchParams();
204
+ if (resolvedSourceId) params.set("source_id", resolvedSourceId);
205
+ if (projectId) params.set("project_id", projectId);
206
+ params.set("status", "approved");
207
+ const listed = await request(client, "GET", `/v1/control/external-writebacks/proposals?${params.toString()}`);
208
+ const results = [];
209
+ for (const proposal of listed.proposals || []) {
210
+ const built = buildExternalWritebackSql(proposal, { kind: kind || proposal.source_kind });
211
+ if (dryRun) {
212
+ results.push({ proposal_id: proposal.proposal_id, dryRun: true, ...built });
213
+ continue;
214
+ }
215
+ try {
216
+ const rawResult = await executeWithDb(execute || db, built, proposal, kind || proposal.source_kind);
217
+ const affectedRows = rowCountFromResult(rawResult);
218
+ const status = affectedRows === built.expectedRowCount ? "applied" : "conflict";
219
+ const reported = await request(
220
+ client,
221
+ "POST",
222
+ `/v1/control/external-writebacks/proposals/${encodeURIComponent(proposal.proposal_id)}/apply-result`,
223
+ {
224
+ status,
225
+ idempotency_key: proposal.idempotency_key,
226
+ external_commit_metadata: {
227
+ affected_rows: String(affectedRows),
228
+ operation: built.operation,
229
+ },
230
+ },
231
+ );
232
+ results.push({ proposal_id: proposal.proposal_id, status, affectedRows, reported });
233
+ } catch (error) {
234
+ await request(
235
+ client,
236
+ "POST",
237
+ `/v1/control/external-writebacks/proposals/${encodeURIComponent(proposal.proposal_id)}/apply-result`,
238
+ {
239
+ status: "failed",
240
+ idempotency_key: proposal.idempotency_key,
241
+ error: error.message || String(error),
242
+ },
243
+ );
244
+ results.push({ proposal_id: proposal.proposal_id, status: "failed", error: error.message || String(error) });
245
+ }
246
+ }
247
+ return { ok: true, count: results.length, results };
248
+ }
249
+
250
+ async function start() {
251
+ stopped = false;
252
+ while (!stopped) {
253
+ await pollOnce();
254
+ await new Promise((resolve) => {
255
+ timer = setTimeout(resolve, pollIntervalMs);
256
+ });
257
+ }
258
+ }
259
+
260
+ function stop() {
261
+ stopped = true;
262
+ if (timer) clearTimeout(timer);
263
+ }
264
+
265
+ return { pollOnce, start, stop };
266
+ }