@usagekit/store-postgres 0.0.0-stage → 0.6.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 +2 -0
- package/dist/accounting-lock.d.ts +5 -0
- package/dist/accounting-lock.js +15 -0
- package/dist/billing-alerts.d.ts +3 -0
- package/dist/billing-alerts.js +29 -0
- package/dist/billing-candidates.d.ts +5 -0
- package/dist/billing-candidates.js +23 -0
- package/dist/billing.d.ts +3 -0
- package/dist/billing.js +78 -0
- package/dist/budgets.d.ts +37 -0
- package/dist/budgets.js +125 -0
- package/dist/codec.d.ts +7 -0
- package/dist/codec.js +19 -0
- package/dist/command-state.d.ts +13 -0
- package/dist/command-state.js +155 -0
- package/dist/commands.d.ts +6 -0
- package/dist/commands.js +80 -0
- package/dist/counters.d.ts +3 -0
- package/dist/counters.js +75 -0
- package/dist/driver.d.ts +27 -0
- package/dist/driver.js +64 -0
- package/dist/index.d.ts +92 -0
- package/dist/index.js +60 -0
- package/dist/migrate.d.ts +15 -0
- package/dist/migrate.js +38 -0
- package/dist/operations.d.ts +4 -0
- package/dist/operations.js +73 -0
- package/dist/reads.d.ts +4 -0
- package/dist/reads.js +119 -0
- package/dist/records.d.ts +32 -0
- package/dist/records.js +68 -0
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +152 -0
- package/dist/sql.d.ts +18 -0
- package/dist/sql.js +38 -0
- package/dist/statement.d.ts +22 -0
- package/dist/statement.js +40 -0
- package/package.json +43 -4
- package/README.md +0 -3
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { resolveWindow } from "@usagekit/core";
|
|
2
|
+
import { currentBudgets, difference, historicalBudget, lockScopes, matchingScopes, updateUsage, usage, } from "./budgets.js";
|
|
3
|
+
import { canonical, decode, encode, hash, identity, key, plus } from "./codec.js";
|
|
4
|
+
import { appendReceipt, event, hydrate, insert, operationRow, update } from "./records.js";
|
|
5
|
+
import { lock, SQL } from "./sql.js";
|
|
6
|
+
export const newState = (clock) => ({
|
|
7
|
+
clock,
|
|
8
|
+
budgets: [],
|
|
9
|
+
operations: new Map(),
|
|
10
|
+
identities: new Map(),
|
|
11
|
+
commands: new Map(),
|
|
12
|
+
leaseKinds: new Map(),
|
|
13
|
+
warnings: new Map(),
|
|
14
|
+
reserveAlerts: new Map(),
|
|
15
|
+
alerts: new Set(),
|
|
16
|
+
requestCommands: new Map(),
|
|
17
|
+
cursors: new Map(),
|
|
18
|
+
});
|
|
19
|
+
export async function load(sql, state, namespace, id) {
|
|
20
|
+
const row = await operationRow(sql, namespace, id);
|
|
21
|
+
if (!row)
|
|
22
|
+
return;
|
|
23
|
+
const op = await hydrate(sql, row), k = key(namespace, id);
|
|
24
|
+
state.operations.set(k, op);
|
|
25
|
+
state.identities.set(k, identity(decode(row.input)));
|
|
26
|
+
state.warnings.set(k, decode(row.warnings));
|
|
27
|
+
state.reserveAlerts.set(k, decode(row.alerts));
|
|
28
|
+
if (op.lease && row.lease_kind)
|
|
29
|
+
state.leaseKinds.set(op.lease.leaseId, row.lease_kind);
|
|
30
|
+
}
|
|
31
|
+
export async function loadReplay(sql, state, input) {
|
|
32
|
+
const [row] = await sql.query(SQL.sql `
|
|
33
|
+
SELECT input_hash,result,kind FROM metering_command WHERE operation_pk=${key(input.namespace, input.operationId)} AND command_id=${input.commandId}`);
|
|
34
|
+
if (row)
|
|
35
|
+
state.commands.set(canonical([input.namespace, input.operationId, input.commandId]), {
|
|
36
|
+
identity: hash(canonical({ kind: row.kind, input })) === row.input_hash
|
|
37
|
+
? canonical({ kind: row.kind, input })
|
|
38
|
+
: "semantic_conflict",
|
|
39
|
+
result: decode(row.result),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export async function loadExpired(sql, state, namespace, limit) {
|
|
43
|
+
const due = await sql.query(SQL.sql `SELECT operation_id FROM metering_operation
|
|
44
|
+
WHERE namespace=${namespace} AND state='reserved' AND reservation_expires_at<=${state.clock.now()}
|
|
45
|
+
ORDER BY reservation_expires_at,operation_id LIMIT ${limit + 1}`);
|
|
46
|
+
let skipped = false;
|
|
47
|
+
for (const row of due.slice(0, limit)) {
|
|
48
|
+
const pk = key(namespace, row.operation_id);
|
|
49
|
+
if (state.operations.has(pk))
|
|
50
|
+
continue;
|
|
51
|
+
if (await lock(sql, `metering:operation:${pk}`, true))
|
|
52
|
+
await load(sql, state, namespace, row.operation_id);
|
|
53
|
+
else
|
|
54
|
+
skipped = true;
|
|
55
|
+
}
|
|
56
|
+
return skipped || due.length > limit;
|
|
57
|
+
}
|
|
58
|
+
export async function lockAccounting(sql, state, input) {
|
|
59
|
+
if (input) {
|
|
60
|
+
const scopes = matchingScopes(input);
|
|
61
|
+
await lockScopes(sql, scopes);
|
|
62
|
+
state.budgets = await currentBudgets(sql, input.scope.namespace, scopes, input.surface, input.source);
|
|
63
|
+
}
|
|
64
|
+
const ids = new Set(state.budgets.map((b) => canonical([b.scope.namespace, b.id])));
|
|
65
|
+
for (const op of state.operations.values())
|
|
66
|
+
for (const e of op.budgetEpochs)
|
|
67
|
+
ids.add(canonical([op.scope.namespace, e.budgetId]));
|
|
68
|
+
for (const id of [...ids].sort())
|
|
69
|
+
await lock(sql, `metering:budget:${id}`);
|
|
70
|
+
for (const op of state.operations.values())
|
|
71
|
+
for (const epoch of op.budgetEpochs)
|
|
72
|
+
if (!state.budgets.some((b) => b.scope.namespace === op.scope.namespace &&
|
|
73
|
+
b.id === epoch.budgetId &&
|
|
74
|
+
b.version === epoch.budgetVersion))
|
|
75
|
+
state.budgets.push(await historicalBudget(sql, op.scope.namespace, epoch.budgetId, epoch.budgetVersion));
|
|
76
|
+
}
|
|
77
|
+
export async function projectUsage(sql, state, before) {
|
|
78
|
+
const counters = new Map();
|
|
79
|
+
for (const budget of state.budgets) {
|
|
80
|
+
const epochs = new Set([resolveWindow(budget.window, state.clock.now()).epoch]);
|
|
81
|
+
for (const op of state.operations.values())
|
|
82
|
+
if (op.scope.namespace === budget.scope.namespace)
|
|
83
|
+
for (const epoch of op.budgetEpochs)
|
|
84
|
+
if (epoch.budgetId === budget.id && epoch.budgetVersion === budget.version)
|
|
85
|
+
epochs.add(epoch.epoch);
|
|
86
|
+
for (const epoch of epochs) {
|
|
87
|
+
counters.set(canonical([budget.scope.namespace, budget.id, epoch]), await usage(sql, budget, epoch));
|
|
88
|
+
if (budget.alerts?.length) {
|
|
89
|
+
const alerts = await sql.query(SQL.sql `SELECT alert_key FROM metering_alert
|
|
90
|
+
WHERE namespace=${budget.scope.namespace} AND budget_id=${budget.id} AND epoch=${epoch}`);
|
|
91
|
+
for (const alert of alerts)
|
|
92
|
+
state.alerts.add(alert.alert_key);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
state.readBudgetUsage = (budget, epoch) => {
|
|
97
|
+
const baseline = counters.get(canonical([budget.scope.namespace, budget.id, epoch]));
|
|
98
|
+
if (!baseline)
|
|
99
|
+
throw new Error("Missing locked budget projection");
|
|
100
|
+
let used = baseline.used, reserved = baseline.reserved;
|
|
101
|
+
for (const [pk, op] of state.operations) {
|
|
102
|
+
if (op.scope.namespace !== budget.scope.namespace ||
|
|
103
|
+
!op.budgetEpochs.some((e) => e.budgetId === budget.id && e.epoch === epoch))
|
|
104
|
+
continue;
|
|
105
|
+
const delta = difference(before.get(pk) ?? null, op, budget.unit);
|
|
106
|
+
used = plus(used, delta.used);
|
|
107
|
+
reserved = plus(reserved, delta.reserved);
|
|
108
|
+
}
|
|
109
|
+
return { used, reserved };
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export async function persist(sql, before, state, afterOperationWrite) {
|
|
113
|
+
const changed = [];
|
|
114
|
+
for (const [pk, op] of state.operations) {
|
|
115
|
+
const old = before.operations.get(pk);
|
|
116
|
+
if (old && canonical(old) === canonical(op))
|
|
117
|
+
continue;
|
|
118
|
+
if (!old)
|
|
119
|
+
await insert(sql, op, state.warnings.get(pk) ?? [], state.reserveAlerts.get(pk) ?? []);
|
|
120
|
+
else if (!(await update(sql, old, op, op.lease ? (state.leaseKinds.get(op.lease.leaseId) ?? null) : null)))
|
|
121
|
+
throw new VersionConflict();
|
|
122
|
+
afterOperationWrite?.();
|
|
123
|
+
for (let n = old?.receipts.length ?? 0; n < op.receipts.length; n++) {
|
|
124
|
+
const receipt = op.receipts[n];
|
|
125
|
+
if (!receipt)
|
|
126
|
+
throw new Error("Missing appended receipt");
|
|
127
|
+
await appendReceipt(sql, op, receipt, n);
|
|
128
|
+
}
|
|
129
|
+
await updateUsage(sql, old ?? null, op);
|
|
130
|
+
if (!old || old.version !== op.version)
|
|
131
|
+
changed.push(op);
|
|
132
|
+
}
|
|
133
|
+
for (const [commandKey, entry] of state.commands) {
|
|
134
|
+
if (before.commands.has(commandKey))
|
|
135
|
+
continue;
|
|
136
|
+
const [namespace, id, commandId] = JSON.parse(commandKey);
|
|
137
|
+
if (!namespace || !id || !commandId)
|
|
138
|
+
throw new Error("Invalid stored command identity");
|
|
139
|
+
const identityBody = JSON.parse(entry.identity);
|
|
140
|
+
// Canonical identities encode bigint as strings. The stored identity remains exact for replay.
|
|
141
|
+
await sql.execute(SQL.sql `INSERT INTO metering_command(operation_pk,command_id,kind,input_hash,input,result)
|
|
142
|
+
VALUES(${key(namespace, id)},${commandId},${identityBody.kind},${hash(entry.identity)},${encode(identityBody.input)}::jsonb,${encode(entry.result)}::jsonb)`);
|
|
143
|
+
}
|
|
144
|
+
for (const op of changed)
|
|
145
|
+
await event(sql, op);
|
|
146
|
+
for (const value of state.alerts) {
|
|
147
|
+
if (before.alerts.has(value))
|
|
148
|
+
continue;
|
|
149
|
+
const [namespace, budgetId, epoch, threshold] = JSON.parse(value);
|
|
150
|
+
await sql.execute(SQL.sql `INSERT INTO metering_alert(alert_key,namespace,budget_id,epoch,threshold)
|
|
151
|
+
VALUES(${value},${namespace},${budgetId},${epoch},${threshold})`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export class VersionConflict extends Error {
|
|
155
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Clock, Store } from "@usagekit/store";
|
|
2
|
+
import { type transactions } from "./sql.js";
|
|
3
|
+
type Transactions = ReturnType<typeof transactions>;
|
|
4
|
+
type CommandStore = Pick<Store, "reserve" | "expireReservations" | "markDispatchIntent" | "renewLease" | "claimForRecovery" | "settle" | "correct" | "releaseUndispatched">;
|
|
5
|
+
export declare function commands(tx: Transactions, clock: Clock, hook?: () => void): CommandStore;
|
|
6
|
+
export {};
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { InvalidInput } from "@usagekit/store";
|
|
2
|
+
import * as reference from "@usagekit/store/reference";
|
|
3
|
+
import { lockConnectionAccounting, lockOperationAccounting } from "./accounting-lock.js";
|
|
4
|
+
import { key } from "./codec.js";
|
|
5
|
+
import { load, loadExpired, loadReplay, lockAccounting, newState, persist, projectUsage, VersionConflict, } from "./command-state.js";
|
|
6
|
+
import { lock, SQL } from "./sql.js";
|
|
7
|
+
const snapshot = (s) => ({
|
|
8
|
+
...s,
|
|
9
|
+
operations: structuredClone(s.operations),
|
|
10
|
+
commands: structuredClone(s.commands),
|
|
11
|
+
reserveAlerts: structuredClone(s.reserveAlerts),
|
|
12
|
+
alerts: new Set(s.alerts),
|
|
13
|
+
});
|
|
14
|
+
export function commands(tx, clock, hook) {
|
|
15
|
+
const execute = async (sql, s, before, run) => {
|
|
16
|
+
const result = run();
|
|
17
|
+
await persist(sql, before, s, hook);
|
|
18
|
+
return result;
|
|
19
|
+
};
|
|
20
|
+
const command = async (input, run, conflict) => {
|
|
21
|
+
try {
|
|
22
|
+
return await tx.write(async (sql) => {
|
|
23
|
+
await lockOperationAccounting(sql, input);
|
|
24
|
+
await lock(sql, `metering:operation:${key(input.namespace, input.operationId)}`);
|
|
25
|
+
const s = newState(clock);
|
|
26
|
+
await load(sql, s, input.namespace, input.operationId);
|
|
27
|
+
if ("commandId" in input)
|
|
28
|
+
await loadReplay(sql, s, input);
|
|
29
|
+
await lockAccounting(sql, s);
|
|
30
|
+
await projectUsage(sql, s, snapshot(s).operations);
|
|
31
|
+
return execute(sql, s, snapshot(s), () => run(s));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (!(error instanceof VersionConflict))
|
|
36
|
+
throw error;
|
|
37
|
+
// The advisory lock normally prevents this. Preserve typed CAS refusal if another writer ignores it.
|
|
38
|
+
return tx.read(async (sql) => {
|
|
39
|
+
const s = newState(clock);
|
|
40
|
+
await load(sql, s, input.namespace, input.operationId);
|
|
41
|
+
const operation = reference.find(s, input);
|
|
42
|
+
return conflict(operation);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
reserve: (input, policy) => tx.write(async (sql) => {
|
|
48
|
+
await lockConnectionAccounting(sql, input.scope.namespace, input.scope.connection);
|
|
49
|
+
await lock(sql, `metering:operation:${key(input.scope.namespace, input.operationId)}`);
|
|
50
|
+
const s = newState(clock);
|
|
51
|
+
await load(sql, s, input.scope.namespace, input.operationId);
|
|
52
|
+
const counted = await sql.query(SQL.sql `SELECT identity_hash FROM metering_request_command
|
|
53
|
+
WHERE namespace=${input.scope.namespace} AND command_id=${input.operationId}`);
|
|
54
|
+
if (counted[0])
|
|
55
|
+
s.requestCommands?.set(key(input.scope.namespace, input.operationId), counted[0].identity_hash);
|
|
56
|
+
await loadExpired(sql, s, input.scope.namespace, 100);
|
|
57
|
+
await lockAccounting(sql, s, input);
|
|
58
|
+
await projectUsage(sql, s, snapshot(s).operations);
|
|
59
|
+
const before = snapshot(s);
|
|
60
|
+
return execute(sql, s, before, () => reference.reserve(s, input, policy));
|
|
61
|
+
}),
|
|
62
|
+
expireReservations: (input) => tx.write(async (sql) => {
|
|
63
|
+
const limit = input.limit ?? 100;
|
|
64
|
+
if (!input.namespace.trim() || !Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
65
|
+
throw new InvalidInput("expiration");
|
|
66
|
+
const s = newState(clock), more = await loadExpired(sql, s, input.namespace, limit);
|
|
67
|
+
await lockAccounting(sql, s);
|
|
68
|
+
return execute(sql, s, snapshot(s), () => {
|
|
69
|
+
const result = reference.expireReservations(s, input);
|
|
70
|
+
return { ...result, hasMore: result.hasMore || more };
|
|
71
|
+
});
|
|
72
|
+
}),
|
|
73
|
+
markDispatchIntent: (input) => command(input, (s) => reference.intent(s, input), (operation) => ({ granted: false, reason: "version_conflict", operation })),
|
|
74
|
+
renewLease: (input) => command(input, (s) => reference.renew(s, input), () => ({ outcome: "invalid", field: "version", reason: "version_conflict" })),
|
|
75
|
+
claimForRecovery: (input) => command(input, (s) => reference.claim(s, input), () => ({ outcome: "invalid", field: "version", reason: "version_conflict" })),
|
|
76
|
+
settle: (input) => command(input, (s) => reference.settle(s, input), (operation) => ({ outcome: "rejected", reason: "version_conflict", operation })),
|
|
77
|
+
correct: (input) => command(input, (s) => reference.settle(s, input, true), (operation) => ({ outcome: "rejected", reason: "version_conflict", operation })),
|
|
78
|
+
releaseUndispatched: (input) => command(input, (s) => reference.release(s, input), (operation) => ({ outcome: "rejected", reason: "version_conflict", operation })),
|
|
79
|
+
};
|
|
80
|
+
}
|
package/dist/counters.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { bucketOf, countIdentity, validateCountRequest, validateRequestCountsQuery, } from "@usagekit/store";
|
|
2
|
+
import { canonical, encode, hash, key } from "./codec.js";
|
|
3
|
+
import { lock, SQL } from "./sql.js";
|
|
4
|
+
export function requestCounters(tx, clock) {
|
|
5
|
+
return {
|
|
6
|
+
async countRequest(input) {
|
|
7
|
+
validateCountRequest(input);
|
|
8
|
+
const identity = hash(countIdentity(input));
|
|
9
|
+
return tx.write(async (sql) => {
|
|
10
|
+
await lock(sql, `metering:operation:${key(input.scope.namespace, input.commandId)}`);
|
|
11
|
+
const [previous] = await sql.query(SQL.sql `
|
|
12
|
+
SELECT identity_hash FROM metering_request_command
|
|
13
|
+
WHERE namespace=${input.scope.namespace} AND command_id=${input.commandId}`);
|
|
14
|
+
if (previous)
|
|
15
|
+
return previous.identity_hash === identity
|
|
16
|
+
? { outcome: "counted", replayed: true }
|
|
17
|
+
: { outcome: "conflict", reason: "command_mismatch" };
|
|
18
|
+
const [operation] = await sql.query(SQL.sql `
|
|
19
|
+
SELECT operation_id FROM metering_operation
|
|
20
|
+
WHERE namespace=${input.scope.namespace} AND operation_id=${input.commandId}`);
|
|
21
|
+
if (operation)
|
|
22
|
+
return { outcome: "conflict", reason: "command_mismatch" };
|
|
23
|
+
const bucket = bucketOf(input, clock.now());
|
|
24
|
+
await sql.execute(SQL.sql `
|
|
25
|
+
INSERT INTO metering_request_count(bucket_key,namespace,day,body,count)
|
|
26
|
+
VALUES(${canonical(bucket)},${bucket.namespace},${new Date(`${bucket.day}T00:00:00Z`)},${encode(bucket)}::jsonb,1)
|
|
27
|
+
ON CONFLICT(bucket_key) DO UPDATE SET count=metering_request_count.count+1`);
|
|
28
|
+
await sql.execute(SQL.sql `
|
|
29
|
+
INSERT INTO metering_request_command(namespace,command_id,identity_hash)
|
|
30
|
+
VALUES(${input.scope.namespace},${input.commandId},${identity})`);
|
|
31
|
+
return { outcome: "counted", replayed: false };
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
async requestCounts(query) {
|
|
35
|
+
validateRequestCountsQuery(query);
|
|
36
|
+
return tx.read(async (sql) => {
|
|
37
|
+
const clauses = [
|
|
38
|
+
SQL.sql `namespace=${query.scope.namespace}`,
|
|
39
|
+
SQL.sql `day>=${new Date(query.from)}`,
|
|
40
|
+
SQL.sql `day<${new Date(query.to)}`,
|
|
41
|
+
];
|
|
42
|
+
if (query.scope.kind === "principal")
|
|
43
|
+
clauses.push(SQL.sql `body->>'principal'=${query.scope.principal}`);
|
|
44
|
+
if (query.scope.kind === "group")
|
|
45
|
+
clauses.push(SQL.sql `body->>'group'=${query.scope.group}`);
|
|
46
|
+
if (query.scope.kind === "platform_pool")
|
|
47
|
+
clauses.push(SQL.sql `body->'pools' @> ${JSON.stringify([query.scope.poolId])}::jsonb`);
|
|
48
|
+
if (query.connection)
|
|
49
|
+
clauses.push(SQL.sql `body->>'connection'=${query.connection}`);
|
|
50
|
+
const dimensions = query.groupBy.flatMap((dimension) => [
|
|
51
|
+
SQL.sql `${dimension}::text`,
|
|
52
|
+
SQL.sql `body->>${dimension}`,
|
|
53
|
+
]);
|
|
54
|
+
const dimensionsSql = dimensions.length
|
|
55
|
+
? SQL.sql `jsonb_build_object(${SQL.join(dimensions)})`
|
|
56
|
+
: SQL.sql `'{}'::jsonb`;
|
|
57
|
+
const order = [
|
|
58
|
+
...query.groupBy.map((dimension) => SQL.sql `dimensions->>${dimension} COLLATE "C"`),
|
|
59
|
+
SQL.sql `state COLLATE "C"`,
|
|
60
|
+
];
|
|
61
|
+
const limit = query.limit ?? 1000;
|
|
62
|
+
const rows = await sql.query(SQL.sql `
|
|
63
|
+
SELECT dimensions,state,count FROM (
|
|
64
|
+
SELECT ${dimensionsSql} AS dimensions,body->>'state' AS state,sum(count)::text AS count
|
|
65
|
+
FROM metering_request_count WHERE ${SQL.join(clauses, " AND ")} GROUP BY 1,2
|
|
66
|
+
) grouped ORDER BY ${SQL.join(order)} LIMIT ${limit + 1}`);
|
|
67
|
+
return {
|
|
68
|
+
rows: rows.slice(0, limit).map((row) => ({ ...row, count: BigInt(row.count) })),
|
|
69
|
+
asOf: clock.now().toISOString(),
|
|
70
|
+
truncated: rows.length > limit,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
package/dist/driver.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Pool, PoolClient } from "pg";
|
|
2
|
+
import type { ParameterizedQuery } from "./statement.js";
|
|
3
|
+
/** Values are bound separately from text by every driver implementation. */
|
|
4
|
+
export interface PostgresExecutor {
|
|
5
|
+
query<T>(query: ParameterizedQuery): Promise<T[]>;
|
|
6
|
+
execute(query: ParameterizedQuery): Promise<number>;
|
|
7
|
+
}
|
|
8
|
+
export interface PostgresDriver {
|
|
9
|
+
transaction<T>(readOnly: boolean, body: (sql: PostgresExecutor) => Promise<T>): Promise<T>;
|
|
10
|
+
}
|
|
11
|
+
export declare function nodePostgresExecutor(client: PoolClient): PostgresExecutor;
|
|
12
|
+
/** One checked-out connection owns BEGIN, every statement, COMMIT and ROLLBACK. */
|
|
13
|
+
export declare function createNodePostgresDriver(pool: Pool): PostgresDriver;
|
|
14
|
+
export interface PrismaPostgresTransaction {
|
|
15
|
+
$queryRawUnsafe<T = unknown>(query: string, ...values: unknown[]): Promise<T>;
|
|
16
|
+
$executeRawUnsafe(query: string, ...values: unknown[]): Promise<number>;
|
|
17
|
+
}
|
|
18
|
+
export interface PrismaPostgresClient {
|
|
19
|
+
$transaction<T>(body: (transaction: PrismaPostgresTransaction) => Promise<T>, options: {
|
|
20
|
+
timeout: number;
|
|
21
|
+
maxWait: number;
|
|
22
|
+
isolationLevel: "RepeatableRead" | "ReadCommitted";
|
|
23
|
+
}): Promise<T>;
|
|
24
|
+
}
|
|
25
|
+
/** The Unsafe-named Prisma methods still bind every value as a positional parameter. */
|
|
26
|
+
export declare function prismaPostgresExecutor(transaction: PrismaPostgresTransaction): PostgresExecutor;
|
|
27
|
+
export declare function createPrismaPostgresDriver(prisma: PrismaPostgresClient): PostgresDriver;
|
package/dist/driver.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export function nodePostgresExecutor(client) {
|
|
2
|
+
return {
|
|
3
|
+
async query({ text, values }) {
|
|
4
|
+
return (await client.query(text, values)).rows;
|
|
5
|
+
},
|
|
6
|
+
async execute({ text, values }) {
|
|
7
|
+
return (await client.query(text, values)).rowCount ?? 0;
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** One checked-out connection owns BEGIN, every statement, COMMIT and ROLLBACK. */
|
|
12
|
+
export function createNodePostgresDriver(pool) {
|
|
13
|
+
return {
|
|
14
|
+
async transaction(readOnly, body) {
|
|
15
|
+
const client = await pool.connect();
|
|
16
|
+
let destroy = false;
|
|
17
|
+
try {
|
|
18
|
+
await client.query(readOnly
|
|
19
|
+
? "BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY"
|
|
20
|
+
: "BEGIN ISOLATION LEVEL READ COMMITTED");
|
|
21
|
+
await client.query("SET LOCAL statement_timeout = '30s'");
|
|
22
|
+
await client.query("SET LOCAL lock_timeout = '10s'");
|
|
23
|
+
const result = await body(nodePostgresExecutor(client));
|
|
24
|
+
await client.query("COMMIT");
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
try {
|
|
29
|
+
await client.query("ROLLBACK");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
destroy = true;
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
client.release(destroy);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** The Unsafe-named Prisma methods still bind every value as a positional parameter. */
|
|
43
|
+
export function prismaPostgresExecutor(transaction) {
|
|
44
|
+
// Prisma's pg adapter encodes Date without an offset. Preserve the UTC instant for timestamptz
|
|
45
|
+
// even when a host connection uses a non-UTC TimeZone, including caller-owned transactions.
|
|
46
|
+
const parameters = (values) => values.map((value) => (value instanceof Date ? value.toISOString() : value));
|
|
47
|
+
return {
|
|
48
|
+
query: ({ text, values }) => transaction.$queryRawUnsafe(text, ...parameters(values)),
|
|
49
|
+
execute: ({ text, values }) => transaction.$executeRawUnsafe(text, ...parameters(values)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function createPrismaPostgresDriver(prisma) {
|
|
53
|
+
return {
|
|
54
|
+
transaction: (readOnly, body) => prisma.$transaction(async (transaction) => {
|
|
55
|
+
if (readOnly)
|
|
56
|
+
await transaction.$executeRawUnsafe("SET TRANSACTION READ ONLY");
|
|
57
|
+
return body(prismaPostgresExecutor(transaction));
|
|
58
|
+
}, {
|
|
59
|
+
timeout: 30000,
|
|
60
|
+
maxWait: 10000,
|
|
61
|
+
isolationLevel: readOnly ? "RepeatableRead" : "ReadCommitted",
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Pool } from "pg";
|
|
2
|
+
import type { Budget } from "@usagekit/core";
|
|
3
|
+
import type { Clock } from "@usagekit/store";
|
|
4
|
+
import { type PostgresDriver, type PostgresExecutor } from "./driver.js";
|
|
5
|
+
import { type Counters } from "./sql.js";
|
|
6
|
+
export { createNodePostgresDriver, createPrismaPostgresDriver, nodePostgresExecutor, prismaPostgresExecutor, } from "./driver.js";
|
|
7
|
+
export type { PostgresDriver, PostgresExecutor, PrismaPostgresClient, PrismaPostgresTransaction, } from "./driver.js";
|
|
8
|
+
export type { ParameterizedQuery } from "./statement.js";
|
|
9
|
+
export type { Counters as PostgresStoreCounters } from "./sql.js";
|
|
10
|
+
export { migratePostgresStore } from "./migrate.js";
|
|
11
|
+
type Storage = {
|
|
12
|
+
pool: Pool;
|
|
13
|
+
driver?: never;
|
|
14
|
+
} | {
|
|
15
|
+
driver: PostgresDriver;
|
|
16
|
+
pool?: never;
|
|
17
|
+
};
|
|
18
|
+
export type PostgresStoreOptions = Storage & {
|
|
19
|
+
clock: Clock;
|
|
20
|
+
schema?: string;
|
|
21
|
+
counters?: Counters;
|
|
22
|
+
testHooks?: {
|
|
23
|
+
afterOperationWrite?: () => void;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export declare function createPostgresStore(options: PostgresStoreOptions): Promise<{
|
|
27
|
+
putBudget: (budget: Budget) => Promise<{
|
|
28
|
+
outcome: "conflict";
|
|
29
|
+
reason: "budget_version";
|
|
30
|
+
budget?: never;
|
|
31
|
+
} | {
|
|
32
|
+
outcome: "saved";
|
|
33
|
+
budget: Budget;
|
|
34
|
+
reason?: never;
|
|
35
|
+
}>;
|
|
36
|
+
listBudgets: (namespace: string) => Promise<Budget[]>;
|
|
37
|
+
counters: Counters;
|
|
38
|
+
importBilling(input: import("@usagekit/core").BillingImportInput): Promise<import("@usagekit/core").BillingImportResult>;
|
|
39
|
+
billingImports(query: import("@usagekit/core").BillingImportsQuery): Promise<import("@usagekit/core").BillingImportsPage>;
|
|
40
|
+
expireReservations(input: import("@usagekit/core").ExpireReservationsInput): Promise<import("@usagekit/core").ExpireReservationsResult>;
|
|
41
|
+
reserve(input: import("@usagekit/core").ReserveInput, policy?: import("@usagekit/core").AdmissionPolicy): Promise<import("@usagekit/core").ReserveResult>;
|
|
42
|
+
markDispatchIntent(input: import("@usagekit/core").DispatchIntentInput): Promise<import("@usagekit/core").DispatchGrant>;
|
|
43
|
+
renewLease(input: import("@usagekit/core").LeaseRenewalInput): Promise<import("@usagekit/core").LeaseRenewal>;
|
|
44
|
+
claimForRecovery(input: import("@usagekit/core").RecoveryClaimInput): Promise<import("@usagekit/core").RecoveryClaim>;
|
|
45
|
+
settle(input: import("@usagekit/core").SettleInput): Promise<import("@usagekit/core").SettleResult>;
|
|
46
|
+
correct(input: import("@usagekit/core").CorrectionInput): Promise<import("@usagekit/core").SettleResult>;
|
|
47
|
+
releaseUndispatched(input: import("@usagekit/core").ReleaseInput): Promise<import("@usagekit/core").ReleaseResult>;
|
|
48
|
+
countRequest(input: import("@usagekit/core").CountRequestInput): Promise<import("@usagekit/core").CountRequestResult>;
|
|
49
|
+
getOperation(input: import("@usagekit/core").OperationRef): Promise<import("@usagekit/core").Operation | null>;
|
|
50
|
+
aggregate(query: import("@usagekit/core").UsageQuery): Promise<import("@usagekit/core").UsagePage>;
|
|
51
|
+
listOperations(query: import("@usagekit/core").OperationsQuery): Promise<import("@usagekit/core").OperationsPage>;
|
|
52
|
+
requestCounts(query: import("@usagekit/core").RequestCountsQuery): Promise<import("@usagekit/core").RequestCountsPage>;
|
|
53
|
+
definedBudgets(query: import("@usagekit/core").DefinedBudgetsQuery): Promise<readonly Budget[]>;
|
|
54
|
+
applicableBudgets(query: import("@usagekit/core").ApplicableBudgetsQuery): Promise<readonly import("@usagekit/core").BudgetStatus[]>;
|
|
55
|
+
}>;
|
|
56
|
+
/** Caller owns the transaction, search_path, isolation, commit and rollback. */
|
|
57
|
+
export declare function createTransactionBoundStore({ transaction, clock, counters, afterOperationWrite, }: {
|
|
58
|
+
transaction: PostgresExecutor;
|
|
59
|
+
clock: Clock;
|
|
60
|
+
counters?: Counters;
|
|
61
|
+
afterOperationWrite?: () => void;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
putBudget: (budget: Budget) => Promise<{
|
|
64
|
+
outcome: "conflict";
|
|
65
|
+
reason: "budget_version";
|
|
66
|
+
budget?: never;
|
|
67
|
+
} | {
|
|
68
|
+
outcome: "saved";
|
|
69
|
+
budget: Budget;
|
|
70
|
+
reason?: never;
|
|
71
|
+
}>;
|
|
72
|
+
listBudgets: (namespace: string) => Promise<Budget[]>;
|
|
73
|
+
counters: Counters;
|
|
74
|
+
importBilling(input: import("@usagekit/core").BillingImportInput): Promise<import("@usagekit/core").BillingImportResult>;
|
|
75
|
+
billingImports(query: import("@usagekit/core").BillingImportsQuery): Promise<import("@usagekit/core").BillingImportsPage>;
|
|
76
|
+
expireReservations(input: import("@usagekit/core").ExpireReservationsInput): Promise<import("@usagekit/core").ExpireReservationsResult>;
|
|
77
|
+
reserve(input: import("@usagekit/core").ReserveInput, policy?: import("@usagekit/core").AdmissionPolicy): Promise<import("@usagekit/core").ReserveResult>;
|
|
78
|
+
markDispatchIntent(input: import("@usagekit/core").DispatchIntentInput): Promise<import("@usagekit/core").DispatchGrant>;
|
|
79
|
+
renewLease(input: import("@usagekit/core").LeaseRenewalInput): Promise<import("@usagekit/core").LeaseRenewal>;
|
|
80
|
+
claimForRecovery(input: import("@usagekit/core").RecoveryClaimInput): Promise<import("@usagekit/core").RecoveryClaim>;
|
|
81
|
+
settle(input: import("@usagekit/core").SettleInput): Promise<import("@usagekit/core").SettleResult>;
|
|
82
|
+
correct(input: import("@usagekit/core").CorrectionInput): Promise<import("@usagekit/core").SettleResult>;
|
|
83
|
+
releaseUndispatched(input: import("@usagekit/core").ReleaseInput): Promise<import("@usagekit/core").ReleaseResult>;
|
|
84
|
+
countRequest(input: import("@usagekit/core").CountRequestInput): Promise<import("@usagekit/core").CountRequestResult>;
|
|
85
|
+
getOperation(input: import("@usagekit/core").OperationRef): Promise<import("@usagekit/core").Operation | null>;
|
|
86
|
+
aggregate(query: import("@usagekit/core").UsageQuery): Promise<import("@usagekit/core").UsagePage>;
|
|
87
|
+
listOperations(query: import("@usagekit/core").OperationsQuery): Promise<import("@usagekit/core").OperationsPage>;
|
|
88
|
+
requestCounts(query: import("@usagekit/core").RequestCountsQuery): Promise<import("@usagekit/core").RequestCountsPage>;
|
|
89
|
+
definedBudgets(query: import("@usagekit/core").DefinedBudgetsQuery): Promise<readonly Budget[]>;
|
|
90
|
+
applicableBudgets(query: import("@usagekit/core").ApplicableBudgetsQuery): Promise<readonly import("@usagekit/core").BudgetStatus[]>;
|
|
91
|
+
}>;
|
|
92
|
+
export type PostgresStore = Awaited<ReturnType<typeof createPostgresStore>>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { billingImports } from "./billing.js";
|
|
3
|
+
import { currentBudgets, matchingScopes, saveBudget, status } from "./budgets.js";
|
|
4
|
+
import { commands } from "./commands.js";
|
|
5
|
+
import { requestCounters } from "./counters.js";
|
|
6
|
+
import { createNodePostgresDriver } from "./driver.js";
|
|
7
|
+
import { operationsReader } from "./operations.js";
|
|
8
|
+
import { usageReader } from "./reads.js";
|
|
9
|
+
import { find } from "./records.js";
|
|
10
|
+
import { createSql, SQL, transactions } from "./sql.js";
|
|
11
|
+
export { createNodePostgresDriver, createPrismaPostgresDriver, nodePostgresExecutor, prismaPostgresExecutor, } from "./driver.js";
|
|
12
|
+
export { migratePostgresStore } from "./migrate.js";
|
|
13
|
+
export async function createPostgresStore(options) {
|
|
14
|
+
const counters = options.counters ?? { statements: 0, changes: 0n, rowsRead: 0 };
|
|
15
|
+
const driver = options.driver ?? createNodePostgresDriver(options.pool);
|
|
16
|
+
return compose(transactions(driver, options.schema ?? "public", counters), options.clock, counters, options.testHooks?.afterOperationWrite);
|
|
17
|
+
}
|
|
18
|
+
/** Caller owns the transaction, search_path, isolation, commit and rollback. */
|
|
19
|
+
export async function createTransactionBoundStore({ transaction, clock, counters = {
|
|
20
|
+
statements: 0,
|
|
21
|
+
changes: 0n,
|
|
22
|
+
rowsRead: 0,
|
|
23
|
+
}, afterOperationWrite, }) {
|
|
24
|
+
const sql = createSql(transaction, counters);
|
|
25
|
+
const run = (body) => body(sql);
|
|
26
|
+
return compose({ read: run, write: run }, clock, counters, afterOperationWrite);
|
|
27
|
+
}
|
|
28
|
+
async function compose(tx, clock, counters, afterOperationWrite) {
|
|
29
|
+
const secret = await tx.write(async (sql) => {
|
|
30
|
+
await sql.execute(SQL.sql `INSERT INTO metering_metadata(key,value)
|
|
31
|
+
VALUES('cursor_key',${randomBytes(32).toString("hex")}) ON CONFLICT(key) DO NOTHING`);
|
|
32
|
+
const [row] = await sql.query(SQL.sql `SELECT value FROM metering_metadata WHERE key='cursor_key'`);
|
|
33
|
+
if (!row)
|
|
34
|
+
throw new Error("Missing metering cursor key");
|
|
35
|
+
return row.value;
|
|
36
|
+
});
|
|
37
|
+
const aggregate = usageReader(clock, secret), operations = operationsReader(clock, secret);
|
|
38
|
+
const store = {
|
|
39
|
+
...billingImports(tx, clock, afterOperationWrite),
|
|
40
|
+
...commands(tx, clock, afterOperationWrite),
|
|
41
|
+
...requestCounters(tx, clock),
|
|
42
|
+
listOperations: (query) => tx.read((sql) => operations(sql, query)),
|
|
43
|
+
getOperation: (ref) => tx.read(async (sql) => (await find(sql, ref))?.op ?? null),
|
|
44
|
+
aggregate: (query) => tx.read((sql) => aggregate(sql, query)),
|
|
45
|
+
definedBudgets: (query) => tx.read((sql) => currentBudgets(sql, query.scope.namespace, [query.scope])),
|
|
46
|
+
applicableBudgets: (query) => tx.read(async (sql) => {
|
|
47
|
+
const budgets = await currentBudgets(sql, query.scope.namespace, matchingScopes(query), query.surface, query.source);
|
|
48
|
+
const result = [];
|
|
49
|
+
for (const budget of budgets)
|
|
50
|
+
result.push(await status(sql, budget, clock.now()));
|
|
51
|
+
return result;
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
...store,
|
|
56
|
+
putBudget: (budget) => tx.write((sql) => saveBudget(sql, budget)),
|
|
57
|
+
listBudgets: (namespace) => tx.read((sql) => currentBudgets(sql, namespace)),
|
|
58
|
+
counters,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Pool } from "pg";
|
|
2
|
+
import { type PostgresDriver } from "./driver.js";
|
|
3
|
+
type Options = ({
|
|
4
|
+
pool: Pool;
|
|
5
|
+
driver?: never;
|
|
6
|
+
} | {
|
|
7
|
+
driver: PostgresDriver;
|
|
8
|
+
pool?: never;
|
|
9
|
+
}) & {
|
|
10
|
+
schema?: string;
|
|
11
|
+
createSchema?: boolean;
|
|
12
|
+
};
|
|
13
|
+
/** Explicit, atomic and serialized. Store creation never applies migrations implicitly. */
|
|
14
|
+
export declare function migratePostgresStore(options: Options): Promise<void>;
|
|
15
|
+
export {};
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createNodePostgresDriver } from "./driver.js";
|
|
3
|
+
import { schemaStatements } from "./schema.js";
|
|
4
|
+
import { schemaIdentifier } from "./statement.js";
|
|
5
|
+
/** Explicit, atomic and serialized. Store creation never applies migrations implicitly. */
|
|
6
|
+
export async function migratePostgresStore(options) {
|
|
7
|
+
const schema = options.schema ?? "public", identifier = schemaIdentifier(schema);
|
|
8
|
+
const driver = options.driver ?? createNodePostgresDriver(options.pool);
|
|
9
|
+
const checksum = createHash("sha256").update(schemaStatements.join("\n")).digest("hex");
|
|
10
|
+
await driver.transaction(false, async (sql) => {
|
|
11
|
+
await sql.query({
|
|
12
|
+
text: "SELECT 1 AS acquired FROM pg_advisory_xact_lock(hashtextextended($1,0))",
|
|
13
|
+
values: [`usagekit:migrate:${schema}`],
|
|
14
|
+
});
|
|
15
|
+
if (options.createSchema)
|
|
16
|
+
await sql.execute({ text: `CREATE SCHEMA IF NOT EXISTS ${identifier}`, values: [] });
|
|
17
|
+
await sql.query({ text: "SELECT set_config('search_path',$1,true)", values: [identifier] });
|
|
18
|
+
await sql.execute({
|
|
19
|
+
text: "CREATE TABLE IF NOT EXISTS metering_schema_version (version integer PRIMARY KEY, checksum text NOT NULL)",
|
|
20
|
+
values: [],
|
|
21
|
+
});
|
|
22
|
+
const rows = await sql.query({
|
|
23
|
+
text: "SELECT version,checksum FROM metering_schema_version ORDER BY version",
|
|
24
|
+
values: [],
|
|
25
|
+
});
|
|
26
|
+
if (rows.length) {
|
|
27
|
+
if (rows.length !== 1 || rows[0]?.version !== 1 || rows[0]?.checksum !== checksum)
|
|
28
|
+
throw new Error("Unsupported or modified Postgres Store schema version");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const text of schemaStatements)
|
|
32
|
+
await sql.execute({ text, values: [] });
|
|
33
|
+
await sql.execute({
|
|
34
|
+
text: "INSERT INTO metering_schema_version(version,checksum) VALUES(1,$1)",
|
|
35
|
+
values: [checksum],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { OperationsPage, OperationsQuery } from "@usagekit/core";
|
|
2
|
+
import { type Clock } from "@usagekit/store";
|
|
3
|
+
import { type Sql } from "./sql.js";
|
|
4
|
+
export declare function operationsReader(clock: Clock, secret: string): (sql: Sql, q: OperationsQuery) => Promise<OperationsPage>;
|