@supersolutions/super-router-storage-postgres 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +14 -0
- package/dist/access.d.ts +4 -0
- package/dist/access.js +33 -0
- package/dist/control.d.ts +175 -0
- package/dist/control.js +808 -0
- package/dist/index.d.ts +440 -0
- package/dist/index.js +563 -0
- package/migrations/001_initial.sql +68 -0
- package/migrations/002_integrity.sql +22 -0
- package/migrations/003_membership_subscriptions.sql +64 -0
- package/migrations/004_integration_bindings.sql +4 -0
- package/migrations/005_checkouts.sql +9 -0
- package/package.json +38 -4
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { revalidate } from "./access.js";
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { Service, RouterError, EventSchema, } from "@supersolutions/super-router-contracts";
|
|
6
|
+
import { charge, } from "@supersolutions/super-router-core";
|
|
7
|
+
export const LOCAL_DATABASE_URL = "postgresql://superrouter:local-development-only@127.0.0.1:25432/superrouter";
|
|
8
|
+
const scope = (p) => [p.workspace, p.environment];
|
|
9
|
+
export class PostgresStore {
|
|
10
|
+
pool;
|
|
11
|
+
constructor(url) {
|
|
12
|
+
this.pool = new pg.Pool({ connectionString: url, max: 10 });
|
|
13
|
+
}
|
|
14
|
+
async close() {
|
|
15
|
+
await this.pool.end();
|
|
16
|
+
}
|
|
17
|
+
async migrate() {
|
|
18
|
+
const c = await this.pool.connect();
|
|
19
|
+
try {
|
|
20
|
+
await c.query("BEGIN");
|
|
21
|
+
await c.query("SELECT pg_advisory_xact_lock(hashtextextended('superrouter:migrate',0))");
|
|
22
|
+
await c.query("CREATE TABLE IF NOT EXISTS sr_migrations(version integer PRIMARY KEY)");
|
|
23
|
+
const r = await c.query("SELECT 1 FROM sr_migrations WHERE version=1");
|
|
24
|
+
if (!r.rowCount) {
|
|
25
|
+
await c.query(await readFile(new URL("../migrations/001_initial.sql", import.meta.url), "utf8"));
|
|
26
|
+
await c.query("INSERT INTO sr_migrations VALUES(1)");
|
|
27
|
+
}
|
|
28
|
+
for (const [version, file] of [
|
|
29
|
+
[2, "002_integrity.sql"],
|
|
30
|
+
[3, "003_membership_subscriptions.sql"],
|
|
31
|
+
[4, "004_integration_bindings.sql"],
|
|
32
|
+
[5, "005_checkouts.sql"],
|
|
33
|
+
]) {
|
|
34
|
+
const done = await c.query("SELECT 1 FROM sr_migrations WHERE version=$1", [version]);
|
|
35
|
+
if (!done.rowCount) {
|
|
36
|
+
await c.query(await readFile(new URL("../migrations/" + file, import.meta.url), "utf8"));
|
|
37
|
+
await c.query("INSERT INTO sr_migrations VALUES($1)", [version]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await c.query("COMMIT");
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
await c.query("ROLLBACK");
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
c.release();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async tx(p, fn) {
|
|
51
|
+
const c = await this.pool.connect();
|
|
52
|
+
try {
|
|
53
|
+
await c.query("BEGIN");
|
|
54
|
+
await c.query("SELECT pg_advisory_xact_lock(hashtextextended($1,0))", [
|
|
55
|
+
JSON.stringify(scope(p)),
|
|
56
|
+
]);
|
|
57
|
+
const out = await fn(c);
|
|
58
|
+
await c.query("COMMIT");
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
await c.query("ROLLBACK");
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
c.release();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async row(c, p, id) {
|
|
70
|
+
const r = await c.query("SELECT * FROM sr_requests WHERE workspace=$1 AND environment=$2 AND id=$3 AND customer=$4", [...scope(p), id, p.customer]);
|
|
71
|
+
if (!r.rows[0])
|
|
72
|
+
throw new RouterError("resource_not_found", 404);
|
|
73
|
+
if (p.service_ids && !p.service_ids.includes(r.rows[0].service.id))
|
|
74
|
+
throw new RouterError("scope_denied", 403);
|
|
75
|
+
return {
|
|
76
|
+
...r.rows[0],
|
|
77
|
+
service: Service.parse(r.rows[0].service),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async replay(p, key, digest) {
|
|
81
|
+
const r = await this.pool.query("SELECT * FROM sr_requests WHERE workspace=$1 AND environment=$2 AND idem=$3", [...scope(p), key]);
|
|
82
|
+
if (!r.rows[0])
|
|
83
|
+
return null;
|
|
84
|
+
if (r.rows[0].digest !== digest || r.rows[0].customer !== p.customer)
|
|
85
|
+
throw new RouterError("idempotency_conflict", 409);
|
|
86
|
+
return r.rows[0];
|
|
87
|
+
}
|
|
88
|
+
async get(p, id) {
|
|
89
|
+
return this.row(this.pool, p, id);
|
|
90
|
+
}
|
|
91
|
+
async publish(p, raw, mode = "self-host", expectedVersion) {
|
|
92
|
+
if (!p.scopes.includes("service:write"))
|
|
93
|
+
throw new RouterError("scope_denied", 403);
|
|
94
|
+
const s = Service.parse(raw);
|
|
95
|
+
if (p.service_ids && !p.service_ids.includes(s.id))
|
|
96
|
+
throw new RouterError("scope_denied", 403);
|
|
97
|
+
if (mode === "cloud" && s.offerings.some((o) => o.source === "byok"))
|
|
98
|
+
throw new RouterError("byok_not_allowed", 403);
|
|
99
|
+
await this.tx(p, async (c) => {
|
|
100
|
+
await revalidate(c, p, "service:write");
|
|
101
|
+
const latest = await c.query("SELECT config,version FROM sr_services WHERE workspace=$1 AND environment=$2 AND id=$3 ORDER BY version DESC LIMIT 1", [...scope(p), s.id]);
|
|
102
|
+
const previous = latest.rows[0]
|
|
103
|
+
? Service.parse(latest.rows[0].config)
|
|
104
|
+
: undefined;
|
|
105
|
+
if (expectedVersion !== undefined &&
|
|
106
|
+
previous?.version !== expectedVersion)
|
|
107
|
+
throw new RouterError("service_version_conflict", 409);
|
|
108
|
+
if (mode === "cloud" && p.auth_key_id) {
|
|
109
|
+
// Database-authenticated developers cannot provision or rewrite platform supply economics.
|
|
110
|
+
if (!previous ||
|
|
111
|
+
JSON.stringify(previous.platform_price) !==
|
|
112
|
+
JSON.stringify(s.platform_price) ||
|
|
113
|
+
JSON.stringify(previous.offerings) !== JSON.stringify(s.offerings))
|
|
114
|
+
throw new RouterError("platform_pricing_protected", 403);
|
|
115
|
+
}
|
|
116
|
+
const r = await c.query("SELECT config FROM sr_services WHERE workspace=$1 AND environment=$2 AND id=$3 AND version=$4", [...scope(p), s.id, s.version]);
|
|
117
|
+
if (r.rowCount) {
|
|
118
|
+
if (JSON.stringify(Service.parse(r.rows[0].config)) !== JSON.stringify(s))
|
|
119
|
+
throw new RouterError("version_exists", 409);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (previous && s.version <= previous.version)
|
|
123
|
+
throw new RouterError("service_version_conflict", 409);
|
|
124
|
+
await c.query("UPDATE sr_services SET active=false WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), s.id]);
|
|
125
|
+
await c.query("INSERT INTO sr_services(workspace,environment,id,version,config) VALUES($1,$2,$3,$4,$5)", [...scope(p), s.id, s.version, s]);
|
|
126
|
+
await c.query("INSERT INTO sr_audit(id,workspace,environment,actor,action,resource,details) VALUES($1,$2,$3,$4,$5,$6,$7)", [
|
|
127
|
+
randomUUID(),
|
|
128
|
+
...scope(p),
|
|
129
|
+
p.member_id ?? "trusted-host",
|
|
130
|
+
"service.published",
|
|
131
|
+
s.id,
|
|
132
|
+
{ version: s.version },
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
return s;
|
|
136
|
+
}
|
|
137
|
+
async getService(p, id, version) {
|
|
138
|
+
if (p.service_ids && !p.service_ids.includes(id))
|
|
139
|
+
throw new RouterError("scope_denied", 403);
|
|
140
|
+
const r = await this.pool.query(`SELECT config FROM sr_services WHERE workspace=$1 AND environment=$2 AND id=$3 AND ${version === undefined ? "active=true" : "version=$4"}`, [...scope(p), id, ...(version === undefined ? [] : [version])]);
|
|
141
|
+
if (!r.rows[0])
|
|
142
|
+
throw new RouterError("resource_not_found", 404);
|
|
143
|
+
return Service.parse(r.rows[0].config);
|
|
144
|
+
}
|
|
145
|
+
async list(p) {
|
|
146
|
+
const r = await this.pool.query("SELECT config FROM sr_services WHERE workspace=$1 AND environment=$2 AND active=true ORDER BY id LIMIT 100", scope(p));
|
|
147
|
+
return r.rows
|
|
148
|
+
.map((x) => Service.parse(x.config))
|
|
149
|
+
.filter((s) => !p.service_ids || p.service_ids.includes(s.id));
|
|
150
|
+
}
|
|
151
|
+
async grant(p, kind, asset, amount, source, expires) {
|
|
152
|
+
if (!p.scopes.includes("billing:write"))
|
|
153
|
+
throw new RouterError("scope_denied", 403);
|
|
154
|
+
if (!/^[1-9][0-9]{0,29}$/.test(amount))
|
|
155
|
+
throw new RouterError("invalid_amount");
|
|
156
|
+
return this.tx(p, async (c) => {
|
|
157
|
+
await revalidate(c, p, "billing:write");
|
|
158
|
+
return this.grantTx(c, p, kind, asset, amount, source, expires);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/** Internal trusted composition: caller owns the tenant transaction and authorization. */
|
|
162
|
+
async grantTx(c, p, kind, asset, amount, source, expires, serviceIds, sourceKind = "purchased") {
|
|
163
|
+
const owner = kind === "platform_sale" ? p.workspace : p.customer;
|
|
164
|
+
const prior = await c.query("SELECT b.*,a.asset,a.owner,a.kind FROM sr_buckets b JOIN sr_accounts a ON (a.workspace,a.environment,a.id)=(b.workspace,b.environment,b.account) WHERE b.workspace=$1 AND b.environment=$2 AND b.source=$3", [...scope(p), source]);
|
|
165
|
+
if (prior.rows[0]) {
|
|
166
|
+
const b = prior.rows[0];
|
|
167
|
+
if (b.granted !== amount ||
|
|
168
|
+
b.asset !== asset ||
|
|
169
|
+
b.owner !== owner ||
|
|
170
|
+
b.kind !== kind ||
|
|
171
|
+
(b.expires_at?.toISOString() ?? null) !==
|
|
172
|
+
(expires?.toISOString() ?? null))
|
|
173
|
+
throw new RouterError("idempotency_conflict", 409);
|
|
174
|
+
if (JSON.stringify(b.service_ids ?? null) !==
|
|
175
|
+
JSON.stringify(serviceIds ?? null) ||
|
|
176
|
+
b.source_kind !== sourceKind)
|
|
177
|
+
throw new RouterError("idempotency_conflict", 409);
|
|
178
|
+
return b.account;
|
|
179
|
+
}
|
|
180
|
+
await c.query("INSERT INTO sr_accounts(workspace,environment,id,owner,kind,asset) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(workspace,environment,owner,kind,asset) DO NOTHING", [...scope(p), randomUUID(), owner, kind, asset]);
|
|
181
|
+
const r = await c.query("SELECT id FROM sr_accounts WHERE workspace=$1 AND environment=$2 AND owner=$3 AND kind=$4 AND asset=$5", [...scope(p), owner, kind, asset]);
|
|
182
|
+
const account = r.rows[0].id;
|
|
183
|
+
await c.query("UPDATE sr_accounts SET balance=balance+$4 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), account, amount]);
|
|
184
|
+
await c.query("INSERT INTO sr_buckets(workspace,environment,id,account,source,expires_at,remaining,granted,service_ids,source_kind) VALUES($1,$2,$3,$4,$5,$6,$7,$7,$8,$9)", [
|
|
185
|
+
...scope(p),
|
|
186
|
+
randomUUID(),
|
|
187
|
+
account,
|
|
188
|
+
source,
|
|
189
|
+
expires ?? null,
|
|
190
|
+
amount,
|
|
191
|
+
serviceIds ?? null,
|
|
192
|
+
sourceKind,
|
|
193
|
+
]);
|
|
194
|
+
await c.query("INSERT INTO sr_journal(workspace,environment,business_key,asset,debit,credit,amount,reason) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", [
|
|
195
|
+
...scope(p),
|
|
196
|
+
"grant:" + source,
|
|
197
|
+
asset,
|
|
198
|
+
"issuer:" + asset,
|
|
199
|
+
account,
|
|
200
|
+
amount,
|
|
201
|
+
"grant",
|
|
202
|
+
]);
|
|
203
|
+
return account;
|
|
204
|
+
}
|
|
205
|
+
async accounts(p, s) {
|
|
206
|
+
const legs = [];
|
|
207
|
+
for (const [kind, price] of [
|
|
208
|
+
["customer_sale", s.price],
|
|
209
|
+
["platform_sale", s.platform_price],
|
|
210
|
+
]) {
|
|
211
|
+
if (!price)
|
|
212
|
+
continue;
|
|
213
|
+
const owner = kind === "platform_sale" ? p.workspace : p.customer;
|
|
214
|
+
const r = await this.pool.query("SELECT id FROM sr_accounts WHERE workspace=$1 AND environment=$2 AND owner=$3 AND kind=$4 AND asset=$5", [...scope(p), owner, kind, price.asset]);
|
|
215
|
+
if (!r.rows[0])
|
|
216
|
+
throw new RouterError("insufficient_balance", 402);
|
|
217
|
+
legs.push({ kind, account: r.rows[0].id, price, reserved: "0" });
|
|
218
|
+
}
|
|
219
|
+
return legs;
|
|
220
|
+
}
|
|
221
|
+
async reserve(p, key, digest, service, legs, deadline, decision) {
|
|
222
|
+
return this.tx(p, async (c) => {
|
|
223
|
+
await revalidate(c, p, "invoke");
|
|
224
|
+
if (p.service_ids && !p.service_ids.includes(service.id))
|
|
225
|
+
throw new RouterError("scope_denied", 403);
|
|
226
|
+
if (service.requires_subscription) {
|
|
227
|
+
const entitlement = await c.query("SELECT 1 FROM sr_entitlements WHERE workspace=$1 AND environment=$2 AND customer=$3 AND $4=ANY(service_ids) AND period_start<=now() AND period_end>now()", [...scope(p), p.customer, service.id]);
|
|
228
|
+
if (!entitlement.rowCount)
|
|
229
|
+
throw new RouterError("subscription_required", 403);
|
|
230
|
+
}
|
|
231
|
+
const old = await c.query("SELECT * FROM sr_requests WHERE workspace=$1 AND environment=$2 AND idem=$3", [...scope(p), key]);
|
|
232
|
+
if (old.rows[0]) {
|
|
233
|
+
if (old.rows[0].digest !== digest ||
|
|
234
|
+
old.rows[0].customer !== p.customer)
|
|
235
|
+
throw new RouterError("idempotency_conflict", 409);
|
|
236
|
+
return { request: old.rows[0], fresh: false };
|
|
237
|
+
}
|
|
238
|
+
// Admission and service publication share the tenant transaction lock.
|
|
239
|
+
// Replays keep their admitted snapshot; fresh work must reserve the active price.
|
|
240
|
+
const active = await c.query("SELECT 1 FROM sr_services WHERE workspace=$1 AND environment=$2 AND id=$3 AND version=$4 AND active=true", [...scope(p), service.id, service.version]);
|
|
241
|
+
if (!active.rowCount)
|
|
242
|
+
throw new RouterError("inactive_service_version", 409);
|
|
243
|
+
const id = randomUUID();
|
|
244
|
+
await c.query("INSERT INTO sr_requests(workspace,environment,id,customer,idem,digest,service,legs,deadline,route_decision) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", [
|
|
245
|
+
...scope(p),
|
|
246
|
+
id,
|
|
247
|
+
p.customer,
|
|
248
|
+
key,
|
|
249
|
+
digest,
|
|
250
|
+
service,
|
|
251
|
+
JSON.stringify(legs),
|
|
252
|
+
deadline,
|
|
253
|
+
decision ?? null,
|
|
254
|
+
]);
|
|
255
|
+
for (const leg of [...legs].sort((a, b) => a.account.localeCompare(b.account))) {
|
|
256
|
+
let remaining = BigInt(leg.reserved);
|
|
257
|
+
const buckets = await c.query("SELECT * FROM sr_buckets WHERE workspace=$1 AND environment=$2 AND account=$3 AND (expires_at IS NULL OR expires_at>now()) AND (service_ids IS NULL OR $4=ANY(service_ids)) ORDER BY expires_at ASC NULLS LAST, CASE source_kind WHEN 'subscription' THEN 0 WHEN 'promotion' THEN 1 ELSE 2 END,id FOR UPDATE", [...scope(p), leg.account, service.id]);
|
|
258
|
+
for (const b of buckets.rows) {
|
|
259
|
+
const available = BigInt(b.remaining) - BigInt(b.held);
|
|
260
|
+
const take = available < remaining ? available : remaining;
|
|
261
|
+
if (take <= 0n)
|
|
262
|
+
continue;
|
|
263
|
+
await c.query("UPDATE sr_buckets SET held=held+$4 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), b.id, take.toString()]);
|
|
264
|
+
await c.query("INSERT INTO sr_allocations(workspace,environment,request,bucket,account,amount) VALUES($1,$2,$3,$4,$5,$6)", [...scope(p), id, b.id, leg.account, take.toString()]);
|
|
265
|
+
remaining -= take;
|
|
266
|
+
if (!remaining)
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
if (remaining > 0n)
|
|
270
|
+
throw new RouterError("insufficient_balance", 402);
|
|
271
|
+
await c.query("UPDATE sr_accounts SET held=held+$4 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), leg.account, leg.reserved]);
|
|
272
|
+
}
|
|
273
|
+
return { request: await this.row(c, p, id), fresh: true };
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
async begin(p, id, o, bound) {
|
|
277
|
+
return this.tx(p, async (c) => {
|
|
278
|
+
await revalidate(c, p, "invoke");
|
|
279
|
+
const r = await this.row(c, p, id);
|
|
280
|
+
if (r.cancel_requested || Date.now() > new Date(r.deadline).getTime())
|
|
281
|
+
throw new RouterError("cancelled", 409);
|
|
282
|
+
if (r.billing !== "reserved")
|
|
283
|
+
throw new RouterError("state_conflict", 409);
|
|
284
|
+
const disabled = await c.query("SELECT 1 FROM sr_disabled_connections WHERE workspace=$1 AND environment=$2 AND offering=$3", [...scope(p), o.id]);
|
|
285
|
+
if (disabled.rowCount)
|
|
286
|
+
throw new RouterError("connection_disabled", 409);
|
|
287
|
+
const snapshot = r.service.offerings.find((x) => x.id === o.id);
|
|
288
|
+
if (!snapshot || JSON.stringify(snapshot) !== JSON.stringify(o))
|
|
289
|
+
throw new RouterError("state_conflict", 409);
|
|
290
|
+
const sum = await c.query("SELECT provider_spent+provider_held+$4::numeric AS total FROM sr_requests WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id, bound]);
|
|
291
|
+
if (BigInt(sum.rows[0].total) > BigInt(r.service.max_provider_cost))
|
|
292
|
+
throw new RouterError("budget_exceeded", 402);
|
|
293
|
+
const attempt = randomUUID();
|
|
294
|
+
await c.query("INSERT INTO sr_attempts(workspace,environment,id,request,offering,cost_bound) VALUES($1,$2,$3,$4,$5,$6)", [...scope(p), attempt, id, o.id, bound]);
|
|
295
|
+
await c.query("UPDATE sr_requests SET execution='running',provider_held=provider_held+$4 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id, bound]);
|
|
296
|
+
return attempt;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
async output(p, id) {
|
|
300
|
+
await this.tx(p, async (c) => {
|
|
301
|
+
await this.row(c, p, id);
|
|
302
|
+
await c.query("UPDATE sr_requests SET output_started=true WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id]);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async attemptResult(p, id, attempt, status, usage, cost, upstream) {
|
|
306
|
+
await this.tx(p, async (c) => {
|
|
307
|
+
const r = await this.row(c, p, id);
|
|
308
|
+
if (!["reserved", "pending"].includes(r.billing))
|
|
309
|
+
throw new RouterError("state_conflict", 409);
|
|
310
|
+
const a = await c.query("SELECT * FROM sr_attempts WHERE workspace=$1 AND environment=$2 AND id=$3 AND request=$4 FOR UPDATE", [...scope(p), attempt, id]);
|
|
311
|
+
const row = a.rows[0];
|
|
312
|
+
if (!row)
|
|
313
|
+
throw new RouterError("resource_not_found", 404);
|
|
314
|
+
if (row.status !== "dispatched")
|
|
315
|
+
return;
|
|
316
|
+
await c.query("UPDATE sr_attempts SET status=$4,usage=$5,cost=$6,upstream_id=$7 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), attempt, status, usage, cost, upstream ?? null]);
|
|
317
|
+
if (cost !== null)
|
|
318
|
+
await c.query("UPDATE sr_requests SET provider_spent=provider_spent+$4,provider_held=provider_held-$5 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id, cost, row.cost_bound]);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
async event(c, p, id, type) {
|
|
322
|
+
const payload = EventSchema.parse({
|
|
323
|
+
id: randomUUID(),
|
|
324
|
+
type,
|
|
325
|
+
schema_version: 1,
|
|
326
|
+
workspace_id: p.workspace,
|
|
327
|
+
environment_id: p.environment,
|
|
328
|
+
request_id: id,
|
|
329
|
+
occurred_at: new Date().toISOString(),
|
|
330
|
+
});
|
|
331
|
+
await c.query("INSERT INTO sr_outbox(id,workspace,environment,request,payload) VALUES($1,$2,$3,$4,$5)", [payload.id, ...scope(p), id, payload]);
|
|
332
|
+
}
|
|
333
|
+
async finish(p, id, execution, usage, pending) {
|
|
334
|
+
await this.tx(p, (c) => this.finishTx(c, p, id, execution, usage, pending));
|
|
335
|
+
}
|
|
336
|
+
async finishTx(c, p, id, execution, usage, pending) {
|
|
337
|
+
const r = await this.row(c, p, id);
|
|
338
|
+
if (["settled", "released"].includes(r.billing))
|
|
339
|
+
return;
|
|
340
|
+
if (pending && !r.output_started) {
|
|
341
|
+
const attempts = await c.query("SELECT 1 FROM sr_attempts WHERE workspace=$1 AND environment=$2 AND request=$3", [...scope(p), id]);
|
|
342
|
+
if (!attempts.rowCount) {
|
|
343
|
+
pending = false;
|
|
344
|
+
execution = "cancelled";
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (usage) {
|
|
348
|
+
const over = await c.query("SELECT 1 FROM sr_attempts WHERE workspace=$1 AND environment=$2 AND request=$3 AND cost>cost_bound", [...scope(p), id]);
|
|
349
|
+
if (over.rowCount)
|
|
350
|
+
pending = true;
|
|
351
|
+
}
|
|
352
|
+
if (pending ||
|
|
353
|
+
(usage && r.legs.some((l) => charge(usage, l.price) > BigInt(l.reserved)))) {
|
|
354
|
+
if (r.billing !== "pending")
|
|
355
|
+
await this.event(c, p, id, "reconciliation.required");
|
|
356
|
+
await c.query("UPDATE sr_requests SET execution=$4,billing='pending' WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id, execution]);
|
|
357
|
+
await c.query("INSERT INTO sr_reconciliation(workspace,environment,request,reason) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING", [...scope(p), id, pending ? "unknown_outcome" : "reservation_exceeded"]);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
for (const leg of r.legs) {
|
|
361
|
+
let due = usage ? charge(usage, leg.price) : 0n;
|
|
362
|
+
const amount = due;
|
|
363
|
+
const allocations = await c.query("SELECT a.*,b.expires_at FROM sr_allocations a JOIN sr_buckets b ON (a.workspace,a.environment,a.bucket)=(b.workspace,b.environment,b.id) WHERE a.workspace=$1 AND a.environment=$2 AND a.request=$3 AND a.account=$4 ORDER BY b.expires_at ASC NULLS LAST, CASE b.source_kind WHEN 'subscription' THEN 0 WHEN 'promotion' THEN 1 ELSE 2 END,b.id", [...scope(p), id, leg.account]);
|
|
364
|
+
for (const a of allocations.rows) {
|
|
365
|
+
const take = due < BigInt(a.amount) ? due : BigInt(a.amount);
|
|
366
|
+
due -= take;
|
|
367
|
+
await c.query("UPDATE sr_allocations SET charged=$5 WHERE workspace=$1 AND environment=$2 AND request=$3 AND bucket=$4", [...scope(p), id, a.bucket, take.toString()]);
|
|
368
|
+
await c.query("UPDATE sr_buckets SET remaining=remaining-$4,held=held-$5 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), a.bucket, take.toString(), a.amount]);
|
|
369
|
+
}
|
|
370
|
+
await c.query("UPDATE sr_accounts SET balance=balance-$4,held=held-$5 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), leg.account, amount.toString(), leg.reserved]);
|
|
371
|
+
await c.query("INSERT INTO sr_settlements VALUES($1,$2,$3,$4,$5,$6)", [
|
|
372
|
+
...scope(p),
|
|
373
|
+
id,
|
|
374
|
+
leg.kind,
|
|
375
|
+
leg.account,
|
|
376
|
+
amount.toString(),
|
|
377
|
+
]);
|
|
378
|
+
await c.query("INSERT INTO sr_journal(workspace,environment,business_key,asset,debit,credit,amount,reason) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", [
|
|
379
|
+
...scope(p),
|
|
380
|
+
"settle:" + id + ":" + leg.kind,
|
|
381
|
+
leg.price.asset,
|
|
382
|
+
leg.account,
|
|
383
|
+
"consumed:" + leg.price.asset,
|
|
384
|
+
amount.toString(),
|
|
385
|
+
"settlement",
|
|
386
|
+
]);
|
|
387
|
+
}
|
|
388
|
+
await c.query("UPDATE sr_requests SET execution=$4,billing=$5 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id, execution, usage ? "settled" : "released"]);
|
|
389
|
+
await this.expire(c, p);
|
|
390
|
+
await this.event(c, p, id, usage ? "billing.settled" : "billing.released");
|
|
391
|
+
}
|
|
392
|
+
async expire(c, p) {
|
|
393
|
+
const rows = await c.query("SELECT * FROM sr_buckets WHERE workspace=$1 AND environment=$2 AND expires_at<=now() AND remaining>held FOR UPDATE", scope(p));
|
|
394
|
+
for (const b of rows.rows) {
|
|
395
|
+
const n = BigInt(b.remaining) - BigInt(b.held);
|
|
396
|
+
await c.query("UPDATE sr_buckets SET remaining=held WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), b.id]);
|
|
397
|
+
await c.query("UPDATE sr_accounts SET balance=balance-$4 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), b.account, n.toString()]);
|
|
398
|
+
const asset = await c.query("SELECT asset FROM sr_accounts WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), b.account]);
|
|
399
|
+
await c.query("INSERT INTO sr_journal(workspace,environment,business_key,asset,debit,credit,amount,reason) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", [
|
|
400
|
+
...scope(p),
|
|
401
|
+
"expire:" + randomUUID(),
|
|
402
|
+
asset.rows[0].asset,
|
|
403
|
+
b.account,
|
|
404
|
+
"expired:" + asset.rows[0].asset,
|
|
405
|
+
n.toString(),
|
|
406
|
+
"expiry",
|
|
407
|
+
]);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async balance(p) {
|
|
411
|
+
return this.tx(p, async (c) => {
|
|
412
|
+
await this.expire(c, p);
|
|
413
|
+
const r = await c.query("SELECT id,kind,asset,balance::text,held::text,(balance-held)::text AS available FROM sr_accounts WHERE workspace=$1 AND environment=$2 AND (owner=$3 OR (owner=$1 AND kind='platform_sale')) ORDER BY kind", [...scope(p), p.customer]);
|
|
414
|
+
return r.rows;
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
async cancel(p, id) {
|
|
418
|
+
await this.tx(p, async (c) => {
|
|
419
|
+
await this.row(c, p, id);
|
|
420
|
+
await c.query("UPDATE sr_requests SET cancel_requested=true WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), id]);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
async disable(p, offering) {
|
|
424
|
+
if (!p.scopes.includes("service:write"))
|
|
425
|
+
throw new RouterError("scope_denied", 403);
|
|
426
|
+
await this.pool.query("INSERT INTO sr_disabled_connections VALUES($1,$2,$3) ON CONFLICT DO NOTHING", [...scope(p), offering]);
|
|
427
|
+
}
|
|
428
|
+
async trace(p, id) {
|
|
429
|
+
if (!p.scopes.includes("operations:read"))
|
|
430
|
+
throw new RouterError("scope_denied", 403);
|
|
431
|
+
const request = await this.get(p, id);
|
|
432
|
+
const attempts = await this.pool.query("SELECT id,offering,status,cost,usage FROM sr_attempts WHERE workspace=$1 AND environment=$2 AND request=$3 ORDER BY created_at", [...scope(p), id]);
|
|
433
|
+
const settlements = await this.pool.query("SELECT kind,amount FROM sr_settlements WHERE workspace=$1 AND environment=$2 AND request=$3", [...scope(p), id]);
|
|
434
|
+
return { request, attempts: attempts.rows, settlements: settlements.rows };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Recent requests across all customers of the workspace/environment, newest first,
|
|
438
|
+
* with each attempt's offering, status and provider cost and the settled amount per ledger.
|
|
439
|
+
* Read-only operator view; amounts are atom strings.
|
|
440
|
+
*/
|
|
441
|
+
async recent(p, limit = 50, customer) {
|
|
442
|
+
if (!p.scopes.includes("operations:read"))
|
|
443
|
+
throw new RouterError("scope_denied", 403);
|
|
444
|
+
const n = Math.min(Math.max(Math.trunc(limit) || 50, 1), 200);
|
|
445
|
+
const r = await this.pool.query(`SELECT r.id, r.customer, r.service->>'id' AS service, (r.service->>'version')::int AS service_version,
|
|
446
|
+
r.execution, r.billing, r.provider_spent::text AS provider_cost, r.created_at,
|
|
447
|
+
COALESCE((SELECT json_agg(json_build_object('offering',a.offering,'status',a.status,'cost',a.cost::text,'usage',a.usage) ORDER BY a.created_at)
|
|
448
|
+
FROM sr_attempts a WHERE a.workspace=r.workspace AND a.environment=r.environment AND a.request=r.id),'[]') AS attempts,
|
|
449
|
+
COALESCE((SELECT json_object_agg(s.kind,s.amount::text)
|
|
450
|
+
FROM sr_settlements s WHERE s.workspace=r.workspace AND s.environment=r.environment AND s.request=r.id),'{}') AS settled
|
|
451
|
+
FROM sr_requests r WHERE r.workspace=$1 AND r.environment=$2 AND ($4::text IS NULL OR r.customer=$4)
|
|
452
|
+
ORDER BY r.created_at DESC, r.id DESC LIMIT $3`, [...scope(p), n, customer ?? null]);
|
|
453
|
+
return r.rows;
|
|
454
|
+
}
|
|
455
|
+
/** Never redispatch an expired uncertain attempt. Operator resolution requires evidence. */
|
|
456
|
+
async recover() {
|
|
457
|
+
const rows = await this.pool.query("SELECT * FROM sr_requests WHERE deadline<now() AND billing='reserved'");
|
|
458
|
+
for (const r of rows.rows) {
|
|
459
|
+
const p = {
|
|
460
|
+
workspace: r.workspace,
|
|
461
|
+
environment: r.environment,
|
|
462
|
+
customer: r.customer,
|
|
463
|
+
scopes: [],
|
|
464
|
+
};
|
|
465
|
+
await this.tx(p, async (c) => {
|
|
466
|
+
const current = await this.row(c, p, r.id);
|
|
467
|
+
if (current.billing !== "reserved")
|
|
468
|
+
return;
|
|
469
|
+
const a = await c.query("SELECT * FROM sr_attempts WHERE workspace=$1 AND environment=$2 AND request=$3 ORDER BY created_at", [...scope(p), r.id]);
|
|
470
|
+
const last = a.rows.at(-1);
|
|
471
|
+
const knownSuccess = ["succeeded", "partial"].includes(last?.status) && last?.usage;
|
|
472
|
+
await this.finishTx(c, p, r.id, knownSuccess ? last.status : a.rowCount ? "unknown" : "cancelled", knownSuccess ? last.usage : null, Boolean(a.rowCount) && !knownSuccess);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return rows.rowCount ?? 0;
|
|
476
|
+
}
|
|
477
|
+
async resolveWaiver(p, id, evidence) {
|
|
478
|
+
if (!p.scopes.includes("billing:write") || evidence.length < 10)
|
|
479
|
+
throw new RouterError("scope_denied", 403);
|
|
480
|
+
await this.tx(p, async (c) => {
|
|
481
|
+
const r = await this.row(c, p, id);
|
|
482
|
+
if (r.billing !== "pending")
|
|
483
|
+
throw new RouterError("state_conflict", 409);
|
|
484
|
+
await this.finishTx(c, p, id, "failed", null, false);
|
|
485
|
+
await c.query("UPDATE sr_reconciliation SET state='open',reason='provider_cost_pending_after_customer_waiver',resolution=$4,resolved_at=NULL WHERE workspace=$1 AND environment=$2 AND request=$3", [...scope(p), id, evidence]);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
async refund(p, id, kind, amount, key, reason) {
|
|
489
|
+
if (!p.scopes.includes("billing:write"))
|
|
490
|
+
throw new RouterError("scope_denied", 403);
|
|
491
|
+
if (!/^[1-9][0-9]{0,29}$/.test(amount) || reason.length < 5 || !key)
|
|
492
|
+
throw new RouterError("invalid_refund");
|
|
493
|
+
return this.tx(p, async (c) => {
|
|
494
|
+
const r = await this.row(c, p, id);
|
|
495
|
+
const leg = r.legs.find((l) => l.kind === kind);
|
|
496
|
+
if (!leg)
|
|
497
|
+
throw new RouterError("resource_not_found", 404);
|
|
498
|
+
const old = await c.query("SELECT * FROM sr_refunds WHERE workspace=$1 AND environment=$2 AND business_key=$3", [...scope(p), key]);
|
|
499
|
+
if (old.rows[0]) {
|
|
500
|
+
const o = old.rows[0];
|
|
501
|
+
if (o.request !== id ||
|
|
502
|
+
o.kind !== kind ||
|
|
503
|
+
o.amount !== amount ||
|
|
504
|
+
o.reason !== reason)
|
|
505
|
+
throw new RouterError("idempotency_conflict", 409);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const result = await c.query("SELECT * FROM sr_settlements WHERE workspace=$1 AND environment=$2 AND request=$3 AND kind=$4", [...scope(p), id, kind]);
|
|
509
|
+
if (!result.rows[0])
|
|
510
|
+
throw new RouterError("state_conflict", 409);
|
|
511
|
+
const refunded = await c.query("SELECT COALESCE(sum(amount),0)::text AS total FROM sr_refunds WHERE workspace=$1 AND environment=$2 AND request=$3 AND kind=$4", [...scope(p), id, kind]);
|
|
512
|
+
if (BigInt(amount) + BigInt(refunded.rows[0].total) >
|
|
513
|
+
BigInt(result.rows[0].amount))
|
|
514
|
+
throw new RouterError("refund_exceeded", 409);
|
|
515
|
+
let remaining = BigInt(amount);
|
|
516
|
+
const allocations = await c.query("SELECT * FROM sr_allocations WHERE workspace=$1 AND environment=$2 AND request=$3 AND account=$4 ORDER BY bucket", [...scope(p), id, leg.account]);
|
|
517
|
+
for (const a of allocations.rows) {
|
|
518
|
+
const available = BigInt(a.charged) - BigInt(a.refunded);
|
|
519
|
+
const n = available < remaining ? available : remaining;
|
|
520
|
+
if (n <= 0n)
|
|
521
|
+
continue;
|
|
522
|
+
await c.query("UPDATE sr_allocations SET refunded=refunded+$5 WHERE workspace=$1 AND environment=$2 AND request=$3 AND bucket=$4", [...scope(p), id, a.bucket, n.toString()]);
|
|
523
|
+
await c.query("UPDATE sr_buckets SET remaining=remaining+$4 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), a.bucket, n.toString()]);
|
|
524
|
+
remaining -= n;
|
|
525
|
+
}
|
|
526
|
+
if (remaining !== 0n)
|
|
527
|
+
throw new RouterError("allocation_invariant", 500);
|
|
528
|
+
await c.query("UPDATE sr_accounts SET balance=balance+$4 WHERE workspace=$1 AND environment=$2 AND id=$3", [...scope(p), leg.account, amount]);
|
|
529
|
+
await c.query("INSERT INTO sr_refunds(workspace,environment,request,kind,business_key,amount,reason) VALUES($1,$2,$3,$4,$5,$6,$7)", [...scope(p), id, kind, key, amount, reason]);
|
|
530
|
+
await c.query("INSERT INTO sr_journal(workspace,environment,business_key,asset,debit,credit,amount,reason) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", [
|
|
531
|
+
...scope(p),
|
|
532
|
+
"refund:" + key,
|
|
533
|
+
leg.price.asset,
|
|
534
|
+
"consumed:" + leg.price.asset,
|
|
535
|
+
leg.account,
|
|
536
|
+
amount,
|
|
537
|
+
reason,
|
|
538
|
+
]);
|
|
539
|
+
await this.expire(c, p);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
async claimEvents(limit = 20, tenant) {
|
|
543
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
544
|
+
throw new RouterError("invalid_limit");
|
|
545
|
+
return this.tx({
|
|
546
|
+
workspace: "system",
|
|
547
|
+
environment: "outbox",
|
|
548
|
+
customer: "worker",
|
|
549
|
+
scopes: [],
|
|
550
|
+
}, async (c) => {
|
|
551
|
+
const rows = await c.query(`SELECT id FROM sr_outbox WHERE (state='pending' OR (state='delivering' AND lease_until<now())) ${tenant ? "AND workspace=$2 AND environment=$3" : ""} ORDER BY created_at,id LIMIT $1 FOR UPDATE SKIP LOCKED`, [limit, ...(tenant ? [tenant.workspace, tenant.environment] : [])]);
|
|
552
|
+
if (!rows.rowCount)
|
|
553
|
+
return [];
|
|
554
|
+
const claimed = await c.query("UPDATE sr_outbox SET state='delivering',generation=generation+1,lease_until=now()+interval '30 seconds' WHERE id=ANY($1) RETURNING id,generation,payload", [rows.rows.map((x) => x.id)]);
|
|
555
|
+
return claimed.rows;
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
async ackEvent(id, generation) {
|
|
559
|
+
const r = await this.pool.query("UPDATE sr_outbox SET state='delivered',lease_until=NULL WHERE id=$1 AND generation=$2 AND state='delivering' AND lease_until>now()", [id, generation]);
|
|
560
|
+
return r.rowCount === 1;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
export { ControlPlane } from "./control.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS sr_services (
|
|
2
|
+
workspace text NOT NULL, environment text NOT NULL, id text NOT NULL, version integer NOT NULL CHECK(version>0),
|
|
3
|
+
config jsonb NOT NULL, active boolean NOT NULL DEFAULT true, PRIMARY KEY(workspace,environment,id,version)
|
|
4
|
+
);
|
|
5
|
+
CREATE UNIQUE INDEX IF NOT EXISTS sr_service_active ON sr_services(workspace,environment,id) WHERE active;
|
|
6
|
+
CREATE TABLE IF NOT EXISTS sr_accounts (
|
|
7
|
+
workspace text NOT NULL, environment text NOT NULL, id text NOT NULL, owner text NOT NULL, kind text NOT NULL,
|
|
8
|
+
asset text NOT NULL, balance numeric(38,0) NOT NULL DEFAULT 0 CHECK(balance>=0), held numeric(38,0) NOT NULL DEFAULT 0 CHECK(held>=0 AND held<=balance),
|
|
9
|
+
PRIMARY KEY(workspace,environment,id), UNIQUE(workspace,environment,owner,kind,asset)
|
|
10
|
+
);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS sr_buckets (
|
|
12
|
+
workspace text NOT NULL, environment text NOT NULL, id text NOT NULL, account text NOT NULL, source text NOT NULL,
|
|
13
|
+
expires_at timestamptz, remaining numeric(38,0) NOT NULL CHECK(remaining>=0), held numeric(38,0) NOT NULL DEFAULT 0 CHECK(held>=0 AND held<=remaining),
|
|
14
|
+
PRIMARY KEY(workspace,environment,id), UNIQUE(workspace,environment,source),
|
|
15
|
+
FOREIGN KEY(workspace,environment,account) REFERENCES sr_accounts(workspace,environment,id)
|
|
16
|
+
);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS sr_requests (
|
|
18
|
+
workspace text NOT NULL, environment text NOT NULL, id text NOT NULL, customer text NOT NULL, idem text NOT NULL, digest text NOT NULL,
|
|
19
|
+
service jsonb NOT NULL, legs jsonb NOT NULL, execution text NOT NULL DEFAULT 'accepted', billing text NOT NULL DEFAULT 'reserved',
|
|
20
|
+
cancel_requested boolean NOT NULL DEFAULT false, output_started boolean NOT NULL DEFAULT false,
|
|
21
|
+
provider_spent numeric(38,0) NOT NULL DEFAULT 0 CHECK(provider_spent>=0), provider_held numeric(38,0) NOT NULL DEFAULT 0 CHECK(provider_held>=0),
|
|
22
|
+
deadline timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
|
|
23
|
+
PRIMARY KEY(workspace,environment,id), UNIQUE(workspace,environment,idem)
|
|
24
|
+
);
|
|
25
|
+
CREATE TABLE IF NOT EXISTS sr_allocations (
|
|
26
|
+
workspace text NOT NULL, environment text NOT NULL, request text NOT NULL, bucket text NOT NULL, account text NOT NULL,
|
|
27
|
+
amount numeric(38,0) NOT NULL CHECK(amount>=0), PRIMARY KEY(workspace,environment,request,bucket),
|
|
28
|
+
FOREIGN KEY(workspace,environment,request) REFERENCES sr_requests(workspace,environment,id),
|
|
29
|
+
FOREIGN KEY(workspace,environment,bucket) REFERENCES sr_buckets(workspace,environment,id),
|
|
30
|
+
FOREIGN KEY(workspace,environment,account) REFERENCES sr_accounts(workspace,environment,id)
|
|
31
|
+
);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS sr_attempts (
|
|
33
|
+
workspace text NOT NULL, environment text NOT NULL, id text NOT NULL, request text NOT NULL, offering text NOT NULL,
|
|
34
|
+
status text NOT NULL DEFAULT 'dispatched', cost_bound numeric(38,0) NOT NULL, cost numeric(38,0), usage jsonb, upstream_id text,
|
|
35
|
+
created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(workspace,environment,id),
|
|
36
|
+
FOREIGN KEY(workspace,environment,request) REFERENCES sr_requests(workspace,environment,id)
|
|
37
|
+
);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS sr_disabled_connections (
|
|
39
|
+
workspace text NOT NULL, environment text NOT NULL, offering text NOT NULL, PRIMARY KEY(workspace,environment,offering)
|
|
40
|
+
);
|
|
41
|
+
CREATE TABLE IF NOT EXISTS sr_settlements (
|
|
42
|
+
workspace text NOT NULL, environment text NOT NULL, request text NOT NULL, kind text NOT NULL, account text NOT NULL, amount numeric(38,0) NOT NULL CHECK(amount>=0),
|
|
43
|
+
PRIMARY KEY(workspace,environment,request,kind), FOREIGN KEY(workspace,environment,request) REFERENCES sr_requests(workspace,environment,id),
|
|
44
|
+
FOREIGN KEY(workspace,environment,account) REFERENCES sr_accounts(workspace,environment,id)
|
|
45
|
+
);
|
|
46
|
+
-- A transfer encodes BOTH sides in one immutable row; equal amounts by construction.
|
|
47
|
+
-- Counterpart names are not spendable accounts and may hold issuer/consumed balances.
|
|
48
|
+
CREATE TABLE IF NOT EXISTS sr_journal (
|
|
49
|
+
workspace text NOT NULL, environment text NOT NULL, business_key text NOT NULL, asset text NOT NULL,
|
|
50
|
+
debit text NOT NULL, credit text NOT NULL, amount numeric(38,0) NOT NULL CHECK(amount>=0), reason text NOT NULL,
|
|
51
|
+
created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(workspace,environment,business_key), CHECK(debit<>credit)
|
|
52
|
+
);
|
|
53
|
+
CREATE OR REPLACE FUNCTION sr_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'append-only record'; END $$;
|
|
54
|
+
DROP TRIGGER IF EXISTS sr_journal_immutable ON sr_journal;
|
|
55
|
+
CREATE TRIGGER sr_journal_immutable BEFORE UPDATE OR DELETE ON sr_journal FOR EACH ROW EXECUTE FUNCTION sr_immutable();
|
|
56
|
+
CREATE TABLE IF NOT EXISTS sr_outbox (
|
|
57
|
+
id text PRIMARY KEY, workspace text NOT NULL, environment text NOT NULL, request text NOT NULL, payload jsonb NOT NULL,
|
|
58
|
+
state text NOT NULL DEFAULT 'pending', generation integer NOT NULL DEFAULT 0, lease_until timestamptz,
|
|
59
|
+
created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(workspace,environment,request,payload),
|
|
60
|
+
FOREIGN KEY(workspace,environment,request) REFERENCES sr_requests(workspace,environment,id)
|
|
61
|
+
);
|
|
62
|
+
CREATE TABLE IF NOT EXISTS sr_reconciliation (
|
|
63
|
+
workspace text NOT NULL, environment text NOT NULL, request text NOT NULL, reason text NOT NULL,
|
|
64
|
+
state text NOT NULL DEFAULT 'open', resolution text, resolved_at timestamptz,
|
|
65
|
+
PRIMARY KEY(workspace,environment,request), FOREIGN KEY(workspace,environment,request) REFERENCES sr_requests(workspace,environment,id)
|
|
66
|
+
);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS sr_requests_pending ON sr_requests(deadline) WHERE billing IN ('reserved','pending');
|
|
68
|
+
CREATE INDEX IF NOT EXISTS sr_outbox_pending ON sr_outbox(state,lease_until,created_at);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
ALTER TABLE sr_requests ADD COLUMN IF NOT EXISTS route_decision jsonb;
|
|
2
|
+
ALTER TABLE sr_buckets ADD COLUMN IF NOT EXISTS granted numeric(38,0);
|
|
3
|
+
UPDATE sr_buckets b SET granted=j.amount FROM sr_journal j WHERE (b.workspace,b.environment,'grant:'||b.source)=(j.workspace,j.environment,j.business_key) AND b.granted IS NULL;
|
|
4
|
+
CREATE OR REPLACE FUNCTION sr_service_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
5
|
+
BEGIN
|
|
6
|
+
IF OLD.config IS DISTINCT FROM NEW.config OR OLD.version<>NEW.version OR OLD.id<>NEW.id OR OLD.workspace<>NEW.workspace OR OLD.environment<>NEW.environment THEN
|
|
7
|
+
RAISE EXCEPTION 'published service is immutable';
|
|
8
|
+
END IF;
|
|
9
|
+
RETURN NEW;
|
|
10
|
+
END $$;
|
|
11
|
+
CREATE TRIGGER sr_service_immutable BEFORE UPDATE ON sr_services FOR EACH ROW EXECUTE FUNCTION sr_service_immutable();
|
|
12
|
+
CREATE TRIGGER sr_settlement_immutable BEFORE UPDATE OR DELETE ON sr_settlements FOR EACH ROW EXECUTE FUNCTION sr_immutable();
|
|
13
|
+
CREATE TABLE sr_refunds (
|
|
14
|
+
workspace text NOT NULL, environment text NOT NULL, request text NOT NULL, kind text NOT NULL, business_key text NOT NULL,
|
|
15
|
+
amount numeric(38,0) NOT NULL CHECK(amount>0), reason text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
|
|
16
|
+
PRIMARY KEY(workspace,environment,business_key),
|
|
17
|
+
FOREIGN KEY(workspace,environment,request,kind) REFERENCES sr_settlements(workspace,environment,request,kind)
|
|
18
|
+
);
|
|
19
|
+
CREATE TRIGGER sr_refund_immutable BEFORE UPDATE OR DELETE ON sr_refunds FOR EACH ROW EXECUTE FUNCTION sr_immutable();
|
|
20
|
+
ALTER TABLE sr_allocations ADD COLUMN IF NOT EXISTS charged numeric(38,0) NOT NULL DEFAULT 0;
|
|
21
|
+
ALTER TABLE sr_allocations ADD COLUMN IF NOT EXISTS refunded numeric(38,0) NOT NULL DEFAULT 0;
|
|
22
|
+
ALTER TABLE sr_allocations ADD CONSTRAINT sr_allocation_charges CHECK(charged>=0 AND refunded>=0 AND refunded<=charged AND charged<=amount);
|