@synapsor/runner 0.1.0-alpha.9 → 0.1.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/CHANGELOG.md +189 -0
- package/README.md +949 -164
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/runner.mjs +2982 -238
- package/docs/README.md +90 -15
- package/docs/app-owned-executors.md +38 -0
- package/docs/capability-authoring.md +265 -0
- package/docs/cloud-mode.md +24 -0
- package/docs/current-scope.md +29 -0
- package/docs/dependency-license-inventory.md +35 -0
- package/docs/doctor.md +98 -0
- package/docs/getting-started-own-database.md +131 -46
- package/docs/handler-helper.md +228 -0
- package/docs/http-mcp.md +85 -17
- package/docs/licensing.md +36 -0
- package/docs/local-mode.md +44 -25
- package/docs/mcp-audit.md +8 -8
- package/docs/mcp-client-setup.md +59 -21
- package/docs/openai-agents-sdk.md +57 -0
- package/docs/recipes.md +6 -6
- package/docs/release-notes.md +348 -0
- package/docs/release-policy.md +125 -0
- package/docs/result-envelope-v2.md +151 -0
- package/docs/rfcs/001-result-envelope-v2.md +143 -0
- package/docs/rfcs/002-app-owned-handler-helper.md +161 -0
- package/docs/rfcs/003-integrator-feedback-teardown.md +97 -0
- package/docs/store-lifecycle.md +83 -0
- package/docs/troubleshooting-first-run.md +6 -6
- package/docs/use-your-own-database.md +18 -0
- package/docs/writeback-executors.md +92 -1
- package/examples/app-owned-writeback/README.md +128 -0
- package/examples/app-owned-writeback/business-actions.md +221 -0
- package/examples/app-owned-writeback/command-handler.mjs +55 -0
- package/examples/app-owned-writeback/node-fastify-handler.mjs +64 -0
- package/examples/app-owned-writeback/python-fastapi-handler.py +66 -0
- package/examples/claude-desktop-postgres/Makefile +6 -0
- package/examples/claude-desktop-postgres/README.md +40 -0
- package/examples/cursor-postgres/Makefile +6 -0
- package/examples/cursor-postgres/README.md +30 -0
- package/examples/mcp-postgres-billing-app-handler/README.md +94 -0
- package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +123 -0
- package/examples/mcp-postgres-billing-app-handler/docker-compose.yml +13 -0
- package/examples/mcp-postgres-billing-app-handler/schema.sql +59 -0
- package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +100 -0
- package/examples/mcp-postgres-billing-app-handler/seed.sql +39 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +158 -0
- package/examples/mysql-refund-agent/Makefile +4 -0
- package/examples/mysql-refund-agent/README.md +36 -0
- package/examples/openai-agents-http/Makefile +6 -0
- package/examples/openai-agents-http/README.md +33 -12
- package/examples/openai-agents-http/agent.py +29 -65
- package/examples/openai-agents-stdio/Makefile +6 -0
- package/examples/openai-agents-stdio/README.md +24 -6
- package/examples/openai-agents-stdio/agent.py +4 -2
- package/examples/raw-sql-vs-synapsor/Makefile +11 -0
- package/examples/raw-sql-vs-synapsor/README.md +41 -0
- package/examples/reference-support-billing-app/README.md +16 -16
- package/examples/reference-support-billing-app/mcp-client.generic.json +1 -1
- package/examples/support-billing-agent/Makefile +19 -0
- package/examples/support-billing-agent/README.md +89 -0
- package/examples/support-billing-agent/app/README.md +13 -0
- package/examples/support-billing-agent/db/schema.sql +91 -0
- package/examples/support-billing-agent/db/seed.sql +43 -0
- package/examples/support-billing-agent/docker-compose.yml +13 -0
- package/examples/support-billing-agent/scripts/run-demo.sh +15 -0
- package/examples/support-billing-agent/synapsor.runner.json +233 -0
- package/fixtures/benchmark/mcp-efficiency.json +53 -0
- package/fixtures/benchmark/mcp-efficiency.txt +25 -0
- package/fixtures/protocol/MANIFEST.json +54 -0
- package/fixtures/protocol/change-set.late-fee-waiver.v1.json +72 -0
- package/fixtures/protocol/execution-receipt.applied.v1.json +14 -0
- package/fixtures/protocol/execution-receipt.conflict.v1.json +15 -0
- package/fixtures/protocol/runner-registration.v1.json +22 -0
- package/fixtures/protocol/writeback-job.late-fee-waiver.v1.json +44 -0
- package/package.json +27 -4
- package/schemas/change-set.v1.schema.json +140 -0
- package/schemas/execution-receipt.v1.schema.json +34 -0
- package/schemas/onboarding-selection.v1.schema.json +132 -0
- package/schemas/runner-registration.v1.schema.json +48 -0
- package/schemas/synapsor.app-handler-receipt.v1.json +39 -0
- package/schemas/synapsor.app-handler-request.v1.json +119 -0
- package/schemas/synapsor.runner.schema.json +415 -0
- package/schemas/writeback-job.v1.schema.json +121 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Claude Desktop + Postgres
|
|
2
|
+
|
|
3
|
+
This example prints a Claude Desktop MCP config for a reviewed Postgres-backed
|
|
4
|
+
Synapsor Runner capability set.
|
|
5
|
+
|
|
6
|
+
It uses the local `examples/mcp-postgres-billing/synapsor.runner.json` fixture.
|
|
7
|
+
The config contains command paths and environment variable names only; it does
|
|
8
|
+
not include database URLs or write credentials.
|
|
9
|
+
|
|
10
|
+
## Run
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
make config
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Expected output includes:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"mcpServers": {
|
|
21
|
+
"synapsor": {
|
|
22
|
+
"command": "...",
|
|
23
|
+
"args": ["...", "mcp", "serve", "..."]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then paste the JSON into Claude Desktop's MCP settings and set these
|
|
30
|
+
environment variables in the client environment:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export BILLING_POSTGRES_READ_URL="postgres://readonly:..."
|
|
34
|
+
export SYNAPSOR_TENANT_ID="acme"
|
|
35
|
+
export SYNAPSOR_PRINCIPAL="local_billing_agent"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The model sees semantic tools such as `billing.inspect_invoice` and
|
|
39
|
+
`billing.propose_late_fee_waiver`; it does not see raw SQL or approval/commit
|
|
40
|
+
tools.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Cursor + Postgres
|
|
2
|
+
|
|
3
|
+
This example prints a Cursor MCP config for a reviewed Postgres-backed Synapsor
|
|
4
|
+
Runner capability set.
|
|
5
|
+
|
|
6
|
+
It uses the local `examples/mcp-postgres-billing/synapsor.runner.json` fixture.
|
|
7
|
+
The config contains command paths and environment variable names only; it does
|
|
8
|
+
not include database URLs or write credentials.
|
|
9
|
+
|
|
10
|
+
## Run
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
make config
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Expected output includes a Cursor-compatible `mcpServers.synapsor` entry that
|
|
17
|
+
launches:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
synapsor-runner mcp serve --config ./examples/mcp-postgres-billing/synapsor.runner.json --store ./.synapsor/local.db
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Set the database and trusted-context variables in the environment that launches
|
|
24
|
+
Cursor:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
export BILLING_POSTGRES_READ_URL="postgres://readonly:..."
|
|
28
|
+
export SYNAPSOR_TENANT_ID="acme"
|
|
29
|
+
export SYNAPSOR_PRINCIPAL="local_billing_agent"
|
|
30
|
+
```
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Postgres Billing App Handler
|
|
2
|
+
|
|
3
|
+
This example shows the two Synapsor Runner commit paths against one disposable
|
|
4
|
+
Postgres billing database.
|
|
5
|
+
|
|
6
|
+
Direct guarded SQL writeback:
|
|
7
|
+
|
|
8
|
+
- `billing.propose_late_fee_waiver`
|
|
9
|
+
- one-row `UPDATE`;
|
|
10
|
+
- tenant guard, allowed-column guard, conflict guard, idempotency receipt.
|
|
11
|
+
|
|
12
|
+
App-owned rich writeback:
|
|
13
|
+
|
|
14
|
+
- `billing.propose_account_credit`
|
|
15
|
+
- model-facing MCP only creates a proposal;
|
|
16
|
+
- approval happens outside MCP;
|
|
17
|
+
- Runner calls `billing_app_handler`;
|
|
18
|
+
- the app uses the first-party handler helper from the source workspace, or the
|
|
19
|
+
bundled `synapsor-handler.mjs` shim included in the runner npm package, to
|
|
20
|
+
verify bearer auth, HMAC signature, tenant scope, expected row version,
|
|
21
|
+
idempotency, and transaction/receipt shape;
|
|
22
|
+
- the handler business code inserts an `account_credits` row and updates the
|
|
23
|
+
invoice inside the helper-owned transaction;
|
|
24
|
+
- Runner records the handler receipt and replay.
|
|
25
|
+
|
|
26
|
+
The model never receives `execute_sql`, approval tools, commit/apply tools,
|
|
27
|
+
database URLs, or write credentials.
|
|
28
|
+
|
|
29
|
+
> **Important:** the app handler owns the final business write. Runner creates
|
|
30
|
+
> the proposal and calls the handler only after approval, but the handler must
|
|
31
|
+
> still enforce tenant/scope checks, expected-version or conflict guards,
|
|
32
|
+
> idempotency keys, allowed business actions, transaction/rollback, and safe
|
|
33
|
+
> error receipts. If those checks are skipped, the app can reintroduce
|
|
34
|
+
> cross-tenant writes, lost updates, or duplicate writes.
|
|
35
|
+
|
|
36
|
+
## Run
|
|
37
|
+
|
|
38
|
+
From the repository root:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Expected ending:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
App-owned billing handler demo passed.
|
|
48
|
+
Verified: proposal first, source unchanged before approval, account credit inserted by app handler, idempotent retry, replay.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Manual Start
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
docker compose -f examples/mcp-postgres-billing-app-handler/docker-compose.yml up -d
|
|
55
|
+
|
|
56
|
+
export BILLING_APP_READ_URL="postgresql://synapsor_reader:synapsor_reader_password@localhost:55437/synapsor_billing_app_handler"
|
|
57
|
+
export BILLING_APP_WRITE_URL="postgresql://synapsor_writer:synapsor_writer_password@localhost:55437/synapsor_billing_app_handler"
|
|
58
|
+
export BILLING_APP_HANDLER_URL="http://127.0.0.1:8787/synapsor/writeback"
|
|
59
|
+
export BILLING_APP_HANDLER_TOKEN="dev-handler-token"
|
|
60
|
+
export BILLING_APP_HANDLER_SIGNING_SECRET="dev-handler-signing-secret"
|
|
61
|
+
export SYNAPSOR_TENANT_ID="acme"
|
|
62
|
+
export SYNAPSOR_PRINCIPAL="local_billing_operator"
|
|
63
|
+
|
|
64
|
+
node examples/mcp-postgres-billing-app-handler/app-handler.mjs
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then, in another terminal:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
synapsor-runner tools preview \
|
|
71
|
+
--config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
|
|
72
|
+
--store ./tmp/billing-app-handler/local.db
|
|
73
|
+
|
|
74
|
+
synapsor-runner propose billing.propose_account_credit \
|
|
75
|
+
--json '{"invoice_id":"INV-3001","amount_cents":2500,"reason":"support-approved credit"}' \
|
|
76
|
+
--config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
|
|
77
|
+
--store ./tmp/billing-app-handler/local.db
|
|
78
|
+
|
|
79
|
+
synapsor-runner proposals approve latest --yes --store ./tmp/billing-app-handler/local.db
|
|
80
|
+
synapsor-runner apply latest \
|
|
81
|
+
--config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
|
|
82
|
+
--store ./tmp/billing-app-handler/local.db
|
|
83
|
+
synapsor-runner replay show latest --store ./tmp/billing-app-handler/local.db
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Why This Exists
|
|
87
|
+
|
|
88
|
+
Direct Runner SQL writeback should stay intentionally narrow. It is good for
|
|
89
|
+
simple, bounded, single-row updates.
|
|
90
|
+
|
|
91
|
+
For richer business transactions such as creating credits, refund reviews,
|
|
92
|
+
ledger rows, tickets, events, or multi-row updates, keep execution in your
|
|
93
|
+
application service. Synapsor Runner still owns proposal creation, evidence,
|
|
94
|
+
approval boundary, idempotency, receipt storage, and replay.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
|
|
4
|
+
const { createWritebackHandler } = await loadHandlerHelper();
|
|
5
|
+
|
|
6
|
+
const port = Number(process.env.BILLING_APP_HANDLER_PORT || "8787");
|
|
7
|
+
|
|
8
|
+
if (!process.env.BILLING_APP_WRITE_URL) {
|
|
9
|
+
console.error("BILLING_APP_WRITE_URL is required.");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const writebackHandler = createWritebackHandler({
|
|
14
|
+
tokenEnv: "BILLING_APP_HANDLER_TOKEN",
|
|
15
|
+
signingSecretEnv: "BILLING_APP_HANDLER_SIGNING_SECRET",
|
|
16
|
+
source: {
|
|
17
|
+
engine: "postgres",
|
|
18
|
+
writeUrlEnv: "BILLING_APP_WRITE_URL",
|
|
19
|
+
receiptTable: {
|
|
20
|
+
schema: "public",
|
|
21
|
+
table: "synapsor_handler_receipts",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
capabilities: {
|
|
25
|
+
"billing.propose_account_credit": applyAccountCredit,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const server = http.createServer(async (request, response) => {
|
|
30
|
+
if (request.method === "GET" && request.url === "/healthz") {
|
|
31
|
+
return writeJson(response, 200, { ok: true });
|
|
32
|
+
}
|
|
33
|
+
if (request.method !== "POST" || request.url !== "/synapsor/writeback") {
|
|
34
|
+
return writeJson(response, 404, {
|
|
35
|
+
status: "failed",
|
|
36
|
+
rows_affected: 0,
|
|
37
|
+
safe_error_code: "NOT_FOUND",
|
|
38
|
+
source_database_mutated: false,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
await writebackHandler(request, response);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
server.listen(port, "127.0.0.1", () => {
|
|
45
|
+
console.error(`Billing app handler listening on http://127.0.0.1:${port}/synapsor/writeback`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
process.once("SIGINT", shutdown);
|
|
49
|
+
process.once("SIGTERM", shutdown);
|
|
50
|
+
|
|
51
|
+
async function shutdown() {
|
|
52
|
+
server.close();
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function applyAccountCredit(job, tx) {
|
|
57
|
+
/*
|
|
58
|
+
* IMPORTANT: this app handler owns the final business write.
|
|
59
|
+
* The helper has already verified auth, tenant scope, expected version,
|
|
60
|
+
* idempotency, and transaction wrapping before this function runs. Keep that
|
|
61
|
+
* pattern if you replace the helper or move this logic into your app.
|
|
62
|
+
*/
|
|
63
|
+
const amountCents = Number(job.patch.credit_requested_cents);
|
|
64
|
+
const reason = String(job.patch.credit_reason || "approved account credit");
|
|
65
|
+
if (!Number.isInteger(amountCents) || amountCents <= 0) {
|
|
66
|
+
throw new Error("credit amount must be a positive integer");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const creditId = `CR-${job.proposalId.replace(/[^A-Za-z0-9]/g, "").slice(-12) || Date.now()}`;
|
|
70
|
+
await tx.insert("account_credits", {
|
|
71
|
+
id: creditId,
|
|
72
|
+
tenant_id: job.tenantId,
|
|
73
|
+
invoice_id: job.objectId,
|
|
74
|
+
customer_id: String(job.row.customer_id),
|
|
75
|
+
amount_cents: amountCents,
|
|
76
|
+
reason,
|
|
77
|
+
idempotency_key: job.idempotencyKey,
|
|
78
|
+
created_by: job.principal,
|
|
79
|
+
}, { schema: "public" });
|
|
80
|
+
|
|
81
|
+
const newVersion = new Date().toISOString();
|
|
82
|
+
const update = await tx.update("invoices", {
|
|
83
|
+
id: job.objectId,
|
|
84
|
+
tenant_id: job.tenantId,
|
|
85
|
+
}, {
|
|
86
|
+
credit_requested_cents: amountCents,
|
|
87
|
+
credit_reason: reason,
|
|
88
|
+
credited_cents: Number(job.row.credited_cents ?? 0) + amountCents,
|
|
89
|
+
updated_at: newVersion,
|
|
90
|
+
}, {
|
|
91
|
+
schema: "public",
|
|
92
|
+
returning: ["updated_at"],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (update.rowCount !== 1) {
|
|
96
|
+
throw new Error("invoice update affected an unexpected number of rows");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
rowsAffected: 2,
|
|
101
|
+
newVersion: timestampString(update.rows[0]?.updated_at ?? newVersion),
|
|
102
|
+
effects: [
|
|
103
|
+
{ type: "db.insert", table: "account_credits", id: creditId },
|
|
104
|
+
{ type: "db.update", table: "invoices", id: job.objectId },
|
|
105
|
+
{ type: "event", name: "billing.account_credit_created" },
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function timestampString(value) {
|
|
111
|
+
if (value instanceof Date) return value.toISOString();
|
|
112
|
+
return new Date(String(value)).toISOString();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeJson(response, statusCode, body) {
|
|
116
|
+
response.statusCode = statusCode;
|
|
117
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
118
|
+
response.end(`${JSON.stringify(body, null, 2)}\n`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function loadHandlerHelper() {
|
|
122
|
+
return await import(new URL("./synapsor-handler.mjs", import.meta.url));
|
|
123
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:16
|
|
4
|
+
container_name: synapsor_runner_billing_app_handler
|
|
5
|
+
environment:
|
|
6
|
+
POSTGRES_DB: synapsor_billing_app_handler
|
|
7
|
+
POSTGRES_USER: synapsor_admin
|
|
8
|
+
POSTGRES_PASSWORD: synapsor_admin_password
|
|
9
|
+
ports:
|
|
10
|
+
- "55437:5432"
|
|
11
|
+
volumes:
|
|
12
|
+
- ./schema.sql:/docker-entrypoint-initdb.d/001_schema.sql:ro
|
|
13
|
+
- ./seed.sql:/docker-entrypoint-initdb.d/002_seed.sql:ro
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS public.tenants (
|
|
2
|
+
id text PRIMARY KEY,
|
|
3
|
+
name text NOT NULL,
|
|
4
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
5
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
CREATE TABLE IF NOT EXISTS public.customers (
|
|
9
|
+
id text PRIMARY KEY,
|
|
10
|
+
tenant_id text NOT NULL REFERENCES public.tenants(id),
|
|
11
|
+
name text NOT NULL,
|
|
12
|
+
plan text NOT NULL,
|
|
13
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
14
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS public.invoices (
|
|
18
|
+
id text PRIMARY KEY,
|
|
19
|
+
tenant_id text NOT NULL REFERENCES public.tenants(id),
|
|
20
|
+
customer_id text NOT NULL REFERENCES public.customers(id),
|
|
21
|
+
status text NOT NULL,
|
|
22
|
+
balance_cents integer NOT NULL,
|
|
23
|
+
late_fee_cents integer NOT NULL,
|
|
24
|
+
waiver_reason text,
|
|
25
|
+
credit_requested_cents integer NOT NULL DEFAULT 0,
|
|
26
|
+
credit_reason text,
|
|
27
|
+
credited_cents integer NOT NULL DEFAULT 0,
|
|
28
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS public.account_credits (
|
|
32
|
+
id text PRIMARY KEY,
|
|
33
|
+
tenant_id text NOT NULL REFERENCES public.tenants(id),
|
|
34
|
+
invoice_id text NOT NULL REFERENCES public.invoices(id),
|
|
35
|
+
customer_id text NOT NULL REFERENCES public.customers(id),
|
|
36
|
+
amount_cents integer NOT NULL CHECK (amount_cents > 0),
|
|
37
|
+
reason text NOT NULL,
|
|
38
|
+
idempotency_key text NOT NULL UNIQUE,
|
|
39
|
+
created_by text NOT NULL,
|
|
40
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
DO $$
|
|
44
|
+
BEGIN
|
|
45
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'synapsor_reader') THEN
|
|
46
|
+
CREATE ROLE synapsor_reader LOGIN PASSWORD 'synapsor_reader_password';
|
|
47
|
+
END IF;
|
|
48
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'synapsor_writer') THEN
|
|
49
|
+
CREATE ROLE synapsor_writer LOGIN PASSWORD 'synapsor_writer_password';
|
|
50
|
+
END IF;
|
|
51
|
+
END
|
|
52
|
+
$$;
|
|
53
|
+
|
|
54
|
+
GRANT CONNECT ON DATABASE synapsor_billing_app_handler TO synapsor_reader, synapsor_writer;
|
|
55
|
+
GRANT USAGE ON SCHEMA public TO synapsor_reader, synapsor_writer;
|
|
56
|
+
GRANT CREATE ON SCHEMA public TO synapsor_writer;
|
|
57
|
+
GRANT SELECT ON public.tenants, public.customers, public.invoices, public.account_credits TO synapsor_reader, synapsor_writer;
|
|
58
|
+
GRANT UPDATE (late_fee_cents, waiver_reason, credit_requested_cents, credit_reason, credited_cents, updated_at) ON public.invoices TO synapsor_writer;
|
|
59
|
+
GRANT INSERT ON public.account_credits TO synapsor_writer;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
|
5
|
+
EXAMPLE_DIR="$ROOT/examples/mcp-postgres-billing-app-handler"
|
|
6
|
+
COMPOSE_FILE="$EXAMPLE_DIR/docker-compose.yml"
|
|
7
|
+
CONFIG="$EXAMPLE_DIR/synapsor.runner.json"
|
|
8
|
+
STORE="$ROOT/tmp/billing-app-handler/local.db"
|
|
9
|
+
BIN="${SYNAPSOR_RUNNER_BIN:-$ROOT/bin/synapsor-runner}"
|
|
10
|
+
HANDLER_LOG="$ROOT/tmp/billing-app-handler/handler.log"
|
|
11
|
+
|
|
12
|
+
cleanup() {
|
|
13
|
+
if [[ -n "${HANDLER_PID:-}" ]]; then
|
|
14
|
+
kill "$HANDLER_PID" >/dev/null 2>&1 || true
|
|
15
|
+
wait "$HANDLER_PID" >/dev/null 2>&1 || true
|
|
16
|
+
fi
|
|
17
|
+
docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
|
|
18
|
+
}
|
|
19
|
+
trap cleanup EXIT
|
|
20
|
+
|
|
21
|
+
mkdir -p "$ROOT/tmp/billing-app-handler"
|
|
22
|
+
rm -f "$STORE" "$HANDLER_LOG"
|
|
23
|
+
|
|
24
|
+
docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
|
|
25
|
+
docker compose -f "$COMPOSE_FILE" up -d >/dev/null
|
|
26
|
+
|
|
27
|
+
for _ in $(seq 1 90); do
|
|
28
|
+
invoice_count="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.invoices" 2>/dev/null || true)"
|
|
29
|
+
if [[ "$invoice_count" == "2" ]]; then
|
|
30
|
+
sleep 1
|
|
31
|
+
invoice_count="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.invoices" 2>/dev/null || true)"
|
|
32
|
+
if [[ "$invoice_count" == "2" ]]; then
|
|
33
|
+
break
|
|
34
|
+
fi
|
|
35
|
+
fi
|
|
36
|
+
sleep 1
|
|
37
|
+
done
|
|
38
|
+
invoice_count="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.invoices")"
|
|
39
|
+
if [[ "$invoice_count" != "2" ]]; then
|
|
40
|
+
echo "Postgres fixture did not finish seeding invoices." >&2
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
export BILLING_APP_READ_URL="postgresql://synapsor_reader:synapsor_reader_password@localhost:55437/synapsor_billing_app_handler"
|
|
45
|
+
export BILLING_APP_WRITE_URL="postgresql://synapsor_writer:synapsor_writer_password@localhost:55437/synapsor_billing_app_handler"
|
|
46
|
+
export BILLING_APP_HANDLER_URL="http://127.0.0.1:8787/synapsor/writeback"
|
|
47
|
+
export BILLING_APP_HANDLER_TOKEN="dev-handler-token"
|
|
48
|
+
export BILLING_APP_HANDLER_SIGNING_SECRET="dev-handler-signing-secret"
|
|
49
|
+
export SYNAPSOR_TENANT_ID="acme"
|
|
50
|
+
export SYNAPSOR_PRINCIPAL="local_billing_operator"
|
|
51
|
+
|
|
52
|
+
node "$EXAMPLE_DIR/app-handler.mjs" >"$HANDLER_LOG" 2>&1 &
|
|
53
|
+
HANDLER_PID="$!"
|
|
54
|
+
|
|
55
|
+
for _ in $(seq 1 90); do
|
|
56
|
+
if node -e "fetch('http://127.0.0.1:8787/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then
|
|
57
|
+
break
|
|
58
|
+
fi
|
|
59
|
+
sleep 1
|
|
60
|
+
done
|
|
61
|
+
node -e "fetch('http://127.0.0.1:8787/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
62
|
+
|
|
63
|
+
"$BIN" config validate --config "$CONFIG" >/dev/null
|
|
64
|
+
"$BIN" tools preview --config "$CONFIG" --store "$STORE" > "$ROOT/tmp/billing-app-handler/tools.txt"
|
|
65
|
+
grep -F "billing.propose_account_credit" "$ROOT/tmp/billing-app-handler/tools.txt" >/dev/null
|
|
66
|
+
grep -F "execute_sql absent" "$ROOT/tmp/billing-app-handler/tools.txt" >/dev/null
|
|
67
|
+
|
|
68
|
+
printf '{"invoice_id":"INV-3001","amount_cents":2500,"reason":"support-approved credit"}\n' > "$ROOT/tmp/billing-app-handler/credit-input.json"
|
|
69
|
+
"$BIN" propose billing.propose_account_credit \
|
|
70
|
+
--input "$ROOT/tmp/billing-app-handler/credit-input.json" \
|
|
71
|
+
--config "$CONFIG" \
|
|
72
|
+
--store "$STORE" > "$ROOT/tmp/billing-app-handler/propose-credit.txt"
|
|
73
|
+
grep -F "Source DB changed:" "$ROOT/tmp/billing-app-handler/propose-credit.txt" >/dev/null
|
|
74
|
+
grep -F "no" "$ROOT/tmp/billing-app-handler/propose-credit.txt" >/dev/null
|
|
75
|
+
|
|
76
|
+
credit_count_before="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.account_credits")"
|
|
77
|
+
if [[ "$credit_count_before" != "0" ]]; then
|
|
78
|
+
echo "expected no account credits before approval, got $credit_count_before" >&2
|
|
79
|
+
exit 1
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
"$BIN" proposals approve latest --yes --store "$STORE" >/dev/null
|
|
83
|
+
"$BIN" apply latest --config "$CONFIG" --store "$STORE" > "$ROOT/tmp/billing-app-handler/apply-credit.txt"
|
|
84
|
+
grep -F "App-owned writeback applied." "$ROOT/tmp/billing-app-handler/apply-credit.txt" >/dev/null
|
|
85
|
+
grep -F "source database changed by handler: yes" "$ROOT/tmp/billing-app-handler/apply-credit.txt" >/dev/null
|
|
86
|
+
|
|
87
|
+
credit_count_after="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.account_credits WHERE invoice_id = 'INV-3001' AND amount_cents = 2500")"
|
|
88
|
+
if [[ "$credit_count_after" != "1" ]]; then
|
|
89
|
+
echo "expected one inserted account credit, got $credit_count_after" >&2
|
|
90
|
+
exit 1
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
"$BIN" apply latest --config "$CONFIG" --store "$STORE" > "$ROOT/tmp/billing-app-handler/apply-credit-retry.txt"
|
|
94
|
+
grep -F "App-owned writeback already applied." "$ROOT/tmp/billing-app-handler/apply-credit-retry.txt" >/dev/null
|
|
95
|
+
|
|
96
|
+
"$BIN" replay show latest --store "$STORE" > "$ROOT/tmp/billing-app-handler/replay.txt"
|
|
97
|
+
grep -F "billing.propose_account_credit" "$ROOT/tmp/billing-app-handler/replay.txt" >/dev/null
|
|
98
|
+
|
|
99
|
+
echo "App-owned billing handler demo passed."
|
|
100
|
+
echo "Verified: proposal first, source unchanged before approval, account credit inserted by app handler, idempotent retry, replay."
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
INSERT INTO public.tenants (id, name, created_at, updated_at)
|
|
2
|
+
VALUES
|
|
3
|
+
('acme', 'Acme Robotics', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z'),
|
|
4
|
+
('globex', 'Globex Labs', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z')
|
|
5
|
+
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = EXCLUDED.updated_at;
|
|
6
|
+
|
|
7
|
+
INSERT INTO public.customers (id, tenant_id, name, plan, created_at, updated_at)
|
|
8
|
+
VALUES
|
|
9
|
+
('cust_acme_1', 'acme', 'Acme Robotics', 'enterprise', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z'),
|
|
10
|
+
('cust_globex_1', 'globex', 'Globex Labs', 'builder', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z')
|
|
11
|
+
ON CONFLICT (id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, name = EXCLUDED.name, plan = EXCLUDED.plan, updated_at = EXCLUDED.updated_at;
|
|
12
|
+
|
|
13
|
+
INSERT INTO public.invoices (
|
|
14
|
+
id,
|
|
15
|
+
tenant_id,
|
|
16
|
+
customer_id,
|
|
17
|
+
status,
|
|
18
|
+
balance_cents,
|
|
19
|
+
late_fee_cents,
|
|
20
|
+
waiver_reason,
|
|
21
|
+
credit_requested_cents,
|
|
22
|
+
credit_reason,
|
|
23
|
+
credited_cents,
|
|
24
|
+
updated_at
|
|
25
|
+
)
|
|
26
|
+
VALUES
|
|
27
|
+
('INV-3001', 'acme', 'cust_acme_1', 'overdue', 25500, 5500, NULL, 0, NULL, 0, '2026-06-20T14:31:08Z'),
|
|
28
|
+
('INV-9001', 'globex', 'cust_globex_1', 'overdue', 25500, 5500, NULL, 0, NULL, 0, '2026-06-20T14:31:08Z')
|
|
29
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
30
|
+
tenant_id = EXCLUDED.tenant_id,
|
|
31
|
+
customer_id = EXCLUDED.customer_id,
|
|
32
|
+
status = EXCLUDED.status,
|
|
33
|
+
balance_cents = EXCLUDED.balance_cents,
|
|
34
|
+
late_fee_cents = EXCLUDED.late_fee_cents,
|
|
35
|
+
waiver_reason = EXCLUDED.waiver_reason,
|
|
36
|
+
credit_requested_cents = EXCLUDED.credit_requested_cents,
|
|
37
|
+
credit_reason = EXCLUDED.credit_reason,
|
|
38
|
+
credited_cents = EXCLUDED.credited_cents,
|
|
39
|
+
updated_at = EXCLUDED.updated_at;
|