@synapsor/runner 0.1.0-alpha.10 → 0.1.0-alpha.13
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 +203 -21
- package/dist/cli.d.ts.map +1 -1
- package/dist/runner.mjs +1103 -115
- package/docs/README.md +38 -0
- package/docs/app-owned-executors.md +26 -0
- package/docs/capability-authoring.md +265 -0
- package/docs/cloud-mode.md +24 -0
- package/docs/current-scope.md +24 -0
- package/docs/dependency-license-inventory.md +35 -0
- package/docs/doctor.md +98 -0
- package/docs/handler-helper.md +200 -0
- package/docs/http-mcp.md +35 -1
- package/docs/licensing.md +36 -0
- package/docs/local-mode.md +13 -2
- package/docs/mcp-client-setup.md +39 -0
- package/docs/openai-agents-sdk.md +57 -0
- package/docs/release-notes.md +76 -2
- package/docs/release-policy.md +86 -0
- package/docs/result-envelope-v2.md +148 -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/use-your-own-database.md +18 -0
- package/docs/writeback-executors.md +29 -0
- package/examples/app-owned-writeback/README.md +1 -0
- package/examples/mcp-postgres-billing-app-handler/README.md +86 -0
- package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +125 -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/openai-agents-http/README.md +10 -2
- package/examples/openai-agents-stdio/README.md +8 -4
- package/examples/openai-agents-stdio/agent.py +2 -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 +4 -1
- 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 +125 -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 +412 -0
- package/schemas/writeback-job.v1.schema.json +121 -0
|
@@ -88,6 +88,7 @@ literal values.
|
|
|
88
88
|
"type": "bearer_env",
|
|
89
89
|
"token_env": "SYNAPSOR_BILLING_HANDLER_TOKEN"
|
|
90
90
|
},
|
|
91
|
+
"signing_secret_env": "SYNAPSOR_BILLING_HANDLER_SIGNING_SECRET",
|
|
91
92
|
"timeout_ms": 5000
|
|
92
93
|
}
|
|
93
94
|
},
|
|
@@ -114,6 +115,17 @@ The handler receives proposal fields, the exact patch, evidence metadata,
|
|
|
114
115
|
guards, and an idempotency key. It does not receive arbitrary model SQL or DB
|
|
115
116
|
credentials from Synapsor Runner.
|
|
116
117
|
|
|
118
|
+
When `signing_secret_env` is set, Runner signs the exact JSON body with HMAC
|
|
119
|
+
SHA-256 and sends:
|
|
120
|
+
|
|
121
|
+
- `X-Synapsor-Signature: sha256=...`
|
|
122
|
+
- `X-Synapsor-Issued-At: ...`
|
|
123
|
+
- `X-Synapsor-Proposal-Id: ...`
|
|
124
|
+
- `Idempotency-Key: ...`
|
|
125
|
+
|
|
126
|
+
Use signing for any handler that is not strictly loopback-only and protected by
|
|
127
|
+
another trusted boundary.
|
|
128
|
+
|
|
117
129
|
Handler responses:
|
|
118
130
|
|
|
119
131
|
```json
|
|
@@ -139,6 +151,12 @@ receipt is stored in replay.
|
|
|
139
151
|
Use your application/API for business logic. Use Synapsor Runner for proposal,
|
|
140
152
|
approval, evidence, policy boundary, and replay.
|
|
141
153
|
|
|
154
|
+
For TypeScript services, use the source-level helper in `packages/handler`.
|
|
155
|
+
It verifies bearer/HMAC auth, parses the request, locks the target row with the
|
|
156
|
+
tenant guard, checks the expected version, handles idempotency, wraps the
|
|
157
|
+
business effect in a transaction, and returns safe receipts without raw driver
|
|
158
|
+
errors. See [Handler Helper](handler-helper.md).
|
|
159
|
+
|
|
142
160
|
This is the recommended path for writes that are richer than the current
|
|
143
161
|
`sql_update` scope, such as:
|
|
144
162
|
|
|
@@ -159,6 +177,17 @@ Concrete business-action examples are in:
|
|
|
159
177
|
examples/app-owned-writeback/business-actions.md
|
|
160
178
|
```
|
|
161
179
|
|
|
180
|
+
The full disposable Postgres account-credit demo is in:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
examples/mcp-postgres-billing-app-handler/
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
It proves the rich-write path end to end: the model creates a proposal, the
|
|
187
|
+
source DB is unchanged before approval, the app-owned handler inserts an
|
|
188
|
+
`account_credits` row after approval, retry is idempotent, and replay stores the
|
|
189
|
+
handler receipt.
|
|
190
|
+
|
|
162
191
|
Or generate one into your app:
|
|
163
192
|
|
|
164
193
|
```bash
|
|
@@ -0,0 +1,86 @@
|
|
|
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 `@synapsor/handler` helper to verify bearer
|
|
19
|
+
auth, HMAC signature, tenant scope, expected row version, idempotency, and
|
|
20
|
+
transaction/receipt shape;
|
|
21
|
+
- the handler business code inserts an `account_credits` row and updates the
|
|
22
|
+
invoice inside the helper-owned transaction;
|
|
23
|
+
- Runner records the handler receipt and replay.
|
|
24
|
+
|
|
25
|
+
The model never receives `execute_sql`, approval tools, commit/apply tools,
|
|
26
|
+
database URLs, or write credentials.
|
|
27
|
+
|
|
28
|
+
## Run
|
|
29
|
+
|
|
30
|
+
From the repository root:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Expected ending:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
App-owned billing handler demo passed.
|
|
40
|
+
Verified: proposal first, source unchanged before approval, account credit inserted by app handler, idempotent retry, replay.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Manual Start
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
docker compose -f examples/mcp-postgres-billing-app-handler/docker-compose.yml up -d
|
|
47
|
+
|
|
48
|
+
export BILLING_APP_READ_URL="postgresql://synapsor_reader:synapsor_reader_password@localhost:55437/synapsor_billing_app_handler"
|
|
49
|
+
export BILLING_APP_WRITE_URL="postgresql://synapsor_writer:synapsor_writer_password@localhost:55437/synapsor_billing_app_handler"
|
|
50
|
+
export BILLING_APP_HANDLER_URL="http://127.0.0.1:8787/synapsor/writeback"
|
|
51
|
+
export BILLING_APP_HANDLER_TOKEN="dev-handler-token"
|
|
52
|
+
export BILLING_APP_HANDLER_SIGNING_SECRET="dev-handler-signing-secret"
|
|
53
|
+
export SYNAPSOR_TENANT_ID="acme"
|
|
54
|
+
export SYNAPSOR_PRINCIPAL="local_billing_operator"
|
|
55
|
+
|
|
56
|
+
node examples/mcp-postgres-billing-app-handler/app-handler.mjs
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Then, in another terminal:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
synapsor-runner tools preview \
|
|
63
|
+
--config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
|
|
64
|
+
--store ./tmp/billing-app-handler/local.db
|
|
65
|
+
|
|
66
|
+
synapsor-runner propose billing.propose_account_credit \
|
|
67
|
+
--json '{"invoice_id":"INV-3001","amount_cents":2500,"reason":"support-approved credit"}' \
|
|
68
|
+
--config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
|
|
69
|
+
--store ./tmp/billing-app-handler/local.db
|
|
70
|
+
|
|
71
|
+
synapsor-runner proposals approve latest --yes --store ./tmp/billing-app-handler/local.db
|
|
72
|
+
synapsor-runner apply latest \
|
|
73
|
+
--config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
|
|
74
|
+
--store ./tmp/billing-app-handler/local.db
|
|
75
|
+
synapsor-runner replay show latest --store ./tmp/billing-app-handler/local.db
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Why This Exists
|
|
79
|
+
|
|
80
|
+
Direct Runner SQL writeback should stay intentionally narrow. It is good for
|
|
81
|
+
simple, bounded, single-row updates.
|
|
82
|
+
|
|
83
|
+
For richer business transactions such as creating credits, refund reviews,
|
|
84
|
+
ledger rows, tickets, events, or multi-row updates, keep execution in your
|
|
85
|
+
application service. Synapsor Runner still owns proposal creation, evidence,
|
|
86
|
+
approval boundary, idempotency, receipt storage, and replay.
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
const amountCents = Number(job.patch.credit_requested_cents);
|
|
58
|
+
const reason = String(job.patch.credit_reason || "approved account credit");
|
|
59
|
+
if (!Number.isInteger(amountCents) || amountCents <= 0) {
|
|
60
|
+
throw new Error("credit amount must be a positive integer");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const creditId = `CR-${job.proposalId.replace(/[^A-Za-z0-9]/g, "").slice(-12) || Date.now()}`;
|
|
64
|
+
await tx.insert("account_credits", {
|
|
65
|
+
id: creditId,
|
|
66
|
+
tenant_id: job.tenantId,
|
|
67
|
+
invoice_id: job.objectId,
|
|
68
|
+
customer_id: String(job.row.customer_id),
|
|
69
|
+
amount_cents: amountCents,
|
|
70
|
+
reason,
|
|
71
|
+
idempotency_key: job.idempotencyKey,
|
|
72
|
+
created_by: job.principal,
|
|
73
|
+
}, { schema: "public" });
|
|
74
|
+
|
|
75
|
+
const newVersion = new Date().toISOString();
|
|
76
|
+
const update = await tx.update("invoices", {
|
|
77
|
+
id: job.objectId,
|
|
78
|
+
tenant_id: job.tenantId,
|
|
79
|
+
}, {
|
|
80
|
+
credit_requested_cents: amountCents,
|
|
81
|
+
credit_reason: reason,
|
|
82
|
+
credited_cents: Number(job.row.credited_cents ?? 0) + amountCents,
|
|
83
|
+
updated_at: newVersion,
|
|
84
|
+
}, {
|
|
85
|
+
schema: "public",
|
|
86
|
+
returning: ["updated_at"],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (update.rowCount !== 1) {
|
|
90
|
+
throw new Error("invoice update affected an unexpected number of rows");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
rowsAffected: 2,
|
|
95
|
+
newVersion: timestampString(update.rows[0]?.updated_at ?? newVersion),
|
|
96
|
+
effects: [
|
|
97
|
+
{ type: "db.insert", table: "account_credits", id: creditId },
|
|
98
|
+
{ type: "db.update", table: "invoices", id: job.objectId },
|
|
99
|
+
{ type: "event", name: "billing.account_credit_created" },
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function timestampString(value) {
|
|
105
|
+
if (value instanceof Date) return value.toISOString();
|
|
106
|
+
return new Date(String(value)).toISOString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function writeJson(response, statusCode, body) {
|
|
110
|
+
response.statusCode = statusCode;
|
|
111
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
112
|
+
response.end(`${JSON.stringify(body, null, 2)}\n`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function loadHandlerHelper() {
|
|
116
|
+
try {
|
|
117
|
+
return await import("@synapsor/handler");
|
|
118
|
+
} catch (workspaceError) {
|
|
119
|
+
try {
|
|
120
|
+
return await import(new URL("./synapsor-handler.mjs", import.meta.url));
|
|
121
|
+
} catch {
|
|
122
|
+
throw workspaceError;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -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;
|