@usagekit/store-postgres 0.0.0-stage → 0.7.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.
@@ -0,0 +1,73 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { InvalidInput } from "@usagekit/store";
3
+ import { canonical } from "./codec.js";
4
+ import { hydrate } from "./records.js";
5
+ import { SQL } from "./sql.js";
6
+ export function operationsReader(clock, secret) {
7
+ const sign = (body) => createHmac("sha256", secret).update(`operations:${body}`).digest("base64url");
8
+ const open = (cursor, identity) => {
9
+ try {
10
+ const parts = cursor.split(".");
11
+ if (parts.length !== 2)
12
+ throw new Error();
13
+ const [body, signature] = parts, actual = Buffer.from(signature), expected = Buffer.from(sign(body));
14
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
15
+ throw new Error();
16
+ const snapshot = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
17
+ if (snapshot.query !== identity || snapshot.expiresAt <= clock.now().getTime())
18
+ throw new Error();
19
+ return snapshot;
20
+ }
21
+ catch {
22
+ throw new InvalidInput("cursor", "unknown cursor or query mismatch");
23
+ }
24
+ };
25
+ return async (sql, q) => {
26
+ const { cursor, ...query } = q, identity = canonical(query);
27
+ const [watermark] = cursor
28
+ ? []
29
+ : await sql.query(SQL.sql `SELECT COALESCE(MAX(event_id),0) AS n FROM metering_event`);
30
+ const snapshot = cursor
31
+ ? open(cursor, identity)
32
+ : {
33
+ query: identity,
34
+ watermark: String(watermark?.n ?? 0n),
35
+ offset: 0,
36
+ asOf: clock.now().toISOString(),
37
+ expiresAt: clock.now().getTime() + 300000,
38
+ };
39
+ const previousState = SQL.sql `(SELECT e.state FROM metering_event e WHERE e.operation_pk=o.operation_pk AND e.event_id<=${BigInt(snapshot.watermark)} ORDER BY e.event_id DESC LIMIT 1)`;
40
+ const clauses = [
41
+ SQL.sql `o.namespace=${q.scope.namespace}`,
42
+ SQL.sql `o.created_at>=${new Date(q.from)}`,
43
+ SQL.sql `o.created_at<${new Date(q.to)}`,
44
+ SQL.sql `${previousState} IS NOT NULL`,
45
+ ];
46
+ if (q.states)
47
+ clauses.push(q.states.length ? SQL.sql `${previousState} IN (${SQL.join(q.states)})` : SQL.sql `FALSE`);
48
+ if (q.scope.kind === "principal")
49
+ clauses.push(SQL.sql `o.principal=${q.scope.principal}`);
50
+ if (q.scope.kind === "group")
51
+ clauses.push(SQL.sql `o.input->'scope'->>'group'=${q.scope.group}`);
52
+ if (q.scope.kind === "platform_pool")
53
+ clauses.push(SQL.sql `o.input->'platformPools' @> ${JSON.stringify([q.scope.poolId])}::jsonb`);
54
+ if (q.connection)
55
+ clauses.push(SQL.sql `o.input->'scope'->>'connection'=${q.connection}`);
56
+ const limit = q.limit ?? 1000;
57
+ const rows = await sql.query(SQL.sql `SELECT o.* FROM metering_operation o WHERE ${SQL.join(clauses, " AND ")} ORDER BY o.created_at,o.operation_id LIMIT ${limit + 1} OFFSET ${snapshot.offset}`);
58
+ // The 0.4 contract freezes membership/order at the watermark and returns current records.
59
+ const operations = [];
60
+ for (const row of rows.slice(0, limit))
61
+ operations.push(await hydrate(sql, row));
62
+ const page = {
63
+ operations,
64
+ asOf: snapshot.asOf,
65
+ watermark: snapshot.watermark,
66
+ };
67
+ if (rows.length > limit) {
68
+ const body = Buffer.from(JSON.stringify({ ...snapshot, offset: snapshot.offset + limit })).toString("base64url");
69
+ page.nextCursor = `${body}.${sign(body)}`;
70
+ }
71
+ return page;
72
+ };
73
+ }
@@ -0,0 +1,4 @@
1
+ import type { UsagePage, UsageQuery } from "@usagekit/core";
2
+ import type { Clock } from "@usagekit/store";
3
+ import { type Sql } from "./sql.js";
4
+ export declare function usageReader(clock: Clock, secret: string): (sql: Sql, q: UsageQuery) => Promise<UsagePage>;
package/dist/reads.js ADDED
@@ -0,0 +1,119 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { InvalidInput } from "@usagekit/store";
3
+ import { aggregate as groupUsage } from "@usagekit/store/reference";
4
+ import { canonical, decode } from "./codec.js";
5
+ import { newState } from "./command-state.js";
6
+ import { SQL } from "./sql.js";
7
+ export function usageReader(clock, secret) {
8
+ const sign = (body) => createHmac("sha256", secret).update(body).digest("base64url");
9
+ return async (sql, q) => {
10
+ const { cursor, ...query } = q, identity = canonical(query);
11
+ let snapshot;
12
+ if (cursor) {
13
+ try {
14
+ const parts = cursor.split(".");
15
+ if (parts.length !== 2)
16
+ throw new Error();
17
+ const [body, signature] = parts, a = Buffer.from(signature), b = Buffer.from(sign(body));
18
+ if (a.length !== b.length || !timingSafeEqual(a, b))
19
+ throw new Error();
20
+ snapshot = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
21
+ if (snapshot.query !== identity || snapshot.expiresAt <= clock.now().getTime())
22
+ throw new Error();
23
+ }
24
+ catch {
25
+ throw new InvalidInput("cursor", "unknown cursor or query mismatch");
26
+ }
27
+ }
28
+ else {
29
+ const [row] = await sql.query(SQL.sql `SELECT COALESCE(MAX(event_id),0) AS n FROM metering_event`);
30
+ snapshot = {
31
+ query: identity,
32
+ watermark: String(row?.n ?? 0n),
33
+ offset: 0,
34
+ asOf: clock.now().toISOString(),
35
+ expiresAt: clock.now().getTime() + 300000,
36
+ };
37
+ }
38
+ const clauses = [
39
+ SQL.sql `e.namespace=${q.scope.namespace}`,
40
+ SQL.sql `e.occurred_at>=${new Date(q.from)}`,
41
+ SQL.sql `e.occurred_at<${new Date(q.to)}`,
42
+ SQL.sql `e.state IN ('pending','settled')`,
43
+ SQL.sql `e.event_id=(SELECT MAX(n.event_id) FROM metering_event n WHERE n.operation_pk=e.operation_pk AND n.event_id<=${BigInt(snapshot.watermark)})`,
44
+ ];
45
+ if (q.scope.kind === "principal")
46
+ clauses.push(SQL.sql `o.principal=${q.scope.principal}`);
47
+ if (q.scope.kind === "group")
48
+ clauses.push(SQL.sql `o.input->'scope'->>'group'=${q.scope.group}`);
49
+ if (q.scope.kind === "platform_pool")
50
+ clauses.push(SQL.sql `o.input->'platformPools' @> ${JSON.stringify([q.scope.poolId])}::jsonb`);
51
+ if (q.connection)
52
+ clauses.push(SQL.sql `o.input->'scope'->>'connection'=${q.connection}`);
53
+ const records = await sql.query(SQL.sql `SELECT o.operation_pk,o.input,e.state,r.body,r.cost_units,r.cost_certainty,m.unit,m.value,m.scale,m.certainty
54
+ FROM metering_event e JOIN metering_operation o USING(operation_pk)
55
+ JOIN metering_receipt r ON r.receipt_pk=e.receipt_pk LEFT JOIN metering_measurement m ON m.receipt_pk=r.receipt_pk
56
+ WHERE ${SQL.join(clauses, " AND ")} ORDER BY e.event_id,m.unit`);
57
+ const state = newState(clock);
58
+ for (const row of records) {
59
+ let op = state.operations.get(row.operation_pk);
60
+ if (!op) {
61
+ const receipt = decode(row.body);
62
+ receipt.measurements = [];
63
+ if (row.cost_certainty !== "unknown" && row.cost_units === null)
64
+ throw new Error("Missing stored cost");
65
+ receipt.cost =
66
+ row.cost_certainty === "unknown"
67
+ ? { certainty: "unknown", money: null }
68
+ : {
69
+ certainty: row.cost_certainty,
70
+ money: { currency: "USD", units: BigInt(row.cost_units ?? 0n) },
71
+ };
72
+ op = {
73
+ ...decode(row.input),
74
+ state: row.state,
75
+ version: 0,
76
+ createdAt: "",
77
+ updatedAt: "",
78
+ reservationExpiresAt: "",
79
+ budgetEpochs: [],
80
+ lease: null,
81
+ receipts: [receipt],
82
+ };
83
+ state.operations.set(row.operation_pk, op);
84
+ }
85
+ if (row.unit !== null) {
86
+ const receipt = op.receipts[0];
87
+ if (!receipt)
88
+ throw new Error("Missing stored receipt");
89
+ if (row.certainty !== "unknown" && (row.value === null || row.scale === null))
90
+ throw new Error("Missing stored quantity");
91
+ receipt.measurements = [
92
+ ...receipt.measurements,
93
+ row.certainty === "unknown"
94
+ ? { unit: row.unit, certainty: "unknown", quantity: null }
95
+ : {
96
+ unit: row.unit,
97
+ certainty: row.certainty,
98
+ quantity: {
99
+ unit: row.unit,
100
+ value: BigInt(row.value?.toString() ?? "0"),
101
+ scale: row.scale ?? 0,
102
+ },
103
+ },
104
+ ];
105
+ }
106
+ }
107
+ const all = groupUsage(state, { ...query, limit: Number.MAX_SAFE_INTEGER }).rows, end = snapshot.offset + (q.limit ?? 1000);
108
+ const page = {
109
+ rows: all.slice(snapshot.offset, end),
110
+ watermark: snapshot.watermark,
111
+ asOf: snapshot.asOf,
112
+ };
113
+ if (end < all.length) {
114
+ const body = Buffer.from(JSON.stringify({ ...snapshot, offset: end })).toString("base64url");
115
+ page.nextCursor = `${body}.${sign(body)}`;
116
+ }
117
+ return page;
118
+ };
119
+ }
@@ -0,0 +1,32 @@
1
+ import type { AllowanceExceeded, BudgetAlertCrossed, Operation, OperationRef, Receipt } from "@usagekit/core";
2
+ import { type Sql } from "./sql.js";
3
+ export type OperationRow = {
4
+ operation_pk: string;
5
+ namespace: string;
6
+ principal: string;
7
+ operation_id: string;
8
+ input: unknown;
9
+ state: Operation["state"];
10
+ version: number;
11
+ budget_epochs: unknown;
12
+ reservation_expires_at: Date;
13
+ lease_id: string | null;
14
+ lease_holder: string | null;
15
+ lease_expires_at: Date | null;
16
+ lease_kind: "lease" | "recovery" | null;
17
+ created_at: Date;
18
+ updated_at: Date;
19
+ semantic_hash: string;
20
+ warnings: unknown;
21
+ alerts: unknown;
22
+ };
23
+ export declare function operationRow(sql: Sql, namespace: string, id: string): Promise<OperationRow | null>;
24
+ export declare function hydrate(sql: Sql, row: OperationRow): Promise<Operation>;
25
+ export declare function find(sql: Sql, ref: OperationRef): Promise<{
26
+ row: OperationRow;
27
+ op: Operation;
28
+ } | null>;
29
+ export declare function insert(sql: Sql, op: Operation, warnings: readonly AllowanceExceeded[], alerts: readonly BudgetAlertCrossed[]): Promise<void>;
30
+ export declare function update(sql: Sql, before: Operation, op: Operation, leaseKind: string | null): Promise<boolean>;
31
+ export declare function appendReceipt(sql: Sql, op: Operation, receipt: Receipt, sequence: number): Promise<void>;
32
+ export declare function event(sql: Sql, op: Operation): Promise<void>;
@@ -0,0 +1,68 @@
1
+ import { decode, effective, encode, hash, identity, key, timestamp } from "./codec.js";
2
+ import { lock, SQL } from "./sql.js";
3
+ export async function operationRow(sql, namespace, id) {
4
+ const [row] = await sql.query(SQL.sql `SELECT * FROM metering_operation WHERE namespace=${namespace} AND operation_id=${id}`);
5
+ return row ?? null;
6
+ }
7
+ export async function hydrate(sql, row) {
8
+ const receipts = await sql.query(SQL.sql `SELECT body FROM metering_receipt WHERE operation_pk=${row.operation_pk} ORDER BY sequence`);
9
+ let lease = null;
10
+ if (row.lease_id) {
11
+ if (!row.lease_holder || !row.lease_expires_at)
12
+ throw new Error("Incomplete stored lease");
13
+ lease = {
14
+ leaseId: row.lease_id,
15
+ holder: row.lease_holder,
16
+ expiresAt: timestamp(row.lease_expires_at),
17
+ };
18
+ }
19
+ return {
20
+ ...decode(row.input),
21
+ state: row.state,
22
+ version: row.version,
23
+ reservationExpiresAt: timestamp(row.reservation_expires_at),
24
+ budgetEpochs: decode(row.budget_epochs),
25
+ createdAt: timestamp(row.created_at),
26
+ updatedAt: timestamp(row.updated_at),
27
+ receipts: receipts.map((r) => decode(r.body)),
28
+ lease,
29
+ };
30
+ }
31
+ export async function find(sql, ref) {
32
+ const row = await operationRow(sql, ref.namespace, ref.operationId);
33
+ return row && row.principal === ref.principal ? { row, op: await hydrate(sql, row) } : null;
34
+ }
35
+ export async function insert(sql, op, warnings, alerts) {
36
+ const { state: _, version: __, receipts: ___, lease: ____, createdAt: _____, updatedAt: ______, budgetEpochs: _______, reservationExpiresAt: ________, ...input } = op;
37
+ await sql.execute(SQL.sql `INSERT INTO metering_operation
38
+ (operation_pk, namespace, principal, operation_id, state, version, semantic_hash, budget_epochs,
39
+ reservation_expires_at, created_at, updated_at, input, warnings, alerts)
40
+ VALUES (${key(op.scope.namespace, op.operationId)},${op.scope.namespace},${op.scope.principal},${op.operationId},
41
+ ${op.state},${op.version},${hash(identity(input))},${encode(op.budgetEpochs)}::jsonb,
42
+ ${new Date(op.reservationExpiresAt)},${new Date(op.createdAt)},${new Date(op.updatedAt)},${encode(input)}::jsonb,${encode(warnings)}::jsonb,${encode(alerts)}::jsonb)`);
43
+ }
44
+ export async function update(sql, before, op, leaseKind) {
45
+ const count = await sql.execute(SQL.sql `UPDATE metering_operation SET state=${op.state},version=${op.version},
46
+ lease_id=${op.lease?.leaseId ?? null},lease_holder=${op.lease?.holder ?? null},
47
+ lease_expires_at=${op.lease ? new Date(op.lease.expiresAt) : null},lease_kind=${leaseKind},updated_at=${new Date(op.updatedAt)}
48
+ WHERE operation_pk=${key(op.scope.namespace, op.operationId)} AND version=${before.version}`);
49
+ return count === 1;
50
+ }
51
+ export async function appendReceipt(sql, op, receipt, sequence) {
52
+ const pk = key(op.scope.namespace, op.operationId), rpk = key(pk, receipt.id);
53
+ await sql.execute(SQL.sql `INSERT INTO metering_receipt
54
+ (receipt_pk,operation_pk,receipt_id,supersedes,body,occurred_at,recorded_at,cost_units,cost_certainty,sequence)
55
+ VALUES(${rpk},${pk},${receipt.id},${receipt.supersedes ?? null},${encode(receipt)}::jsonb,
56
+ ${new Date(receipt.occurredAt)},${new Date(receipt.recordedAt)},${receipt.cost.money?.units ?? null},${receipt.cost.certainty},${sequence})`);
57
+ for (const m of receipt.measurements) {
58
+ await sql.execute(SQL.sql `INSERT INTO metering_measurement(receipt_pk,unit,value,scale,certainty)
59
+ VALUES(${rpk},${m.unit},${m.quantity?.value.toString() ?? null}::numeric,${m.quantity?.scale ?? null},${m.certainty})`);
60
+ }
61
+ }
62
+ export async function event(sql, op) {
63
+ const pk = key(op.scope.namespace, op.operationId), receipt = effective(op);
64
+ // Keep event IDs in commit order so a later commit cannot enter an existing cursor watermark.
65
+ await lock(sql, "metering:event-commit-order");
66
+ await sql.execute(SQL.sql `INSERT INTO metering_event(operation_pk,namespace,state,receipt_pk,occurred_at)
67
+ VALUES(${pk},${op.scope.namespace},${op.state},${receipt ? key(pk, receipt.id) : null},${receipt ? new Date(receipt.occurredAt) : null})`);
68
+ }
@@ -0,0 +1 @@
1
+ export declare const schemaStatements: string[];
package/dist/schema.js ADDED
@@ -0,0 +1,152 @@
1
+ export const schemaStatements = [
2
+ `CREATE TABLE metering_operation (
3
+ operation_pk text PRIMARY KEY,
4
+ namespace text NOT NULL,
5
+ principal text NOT NULL,
6
+ operation_id text NOT NULL,
7
+ state text NOT NULL CHECK (state IN ('reserved','dispatch_intended','pending','settled','released')),
8
+ version integer NOT NULL,
9
+ semantic_hash text NOT NULL,
10
+ budget_epochs jsonb NOT NULL,
11
+ reservation_expires_at timestamptz NOT NULL,
12
+ lease_id text,
13
+ lease_holder text,
14
+ lease_expires_at timestamptz,
15
+ lease_kind text,
16
+ created_at timestamptz NOT NULL,
17
+ updated_at timestamptz NOT NULL,
18
+ input jsonb NOT NULL,
19
+ warnings jsonb NOT NULL,
20
+ UNIQUE(namespace,operation_id)
21
+ )`,
22
+ `CREATE INDEX metering_operation_expiry_idx ON metering_operation(namespace,state,reservation_expires_at)`,
23
+ `CREATE TABLE metering_receipt (
24
+ receipt_pk text PRIMARY KEY,
25
+ operation_pk text NOT NULL REFERENCES metering_operation(operation_pk),
26
+ receipt_id text NOT NULL,
27
+ supersedes text,
28
+ body jsonb NOT NULL,
29
+ occurred_at timestamptz NOT NULL,
30
+ recorded_at timestamptz NOT NULL,
31
+ cost_units bigint,
32
+ cost_certainty text NOT NULL,
33
+ sequence integer NOT NULL,
34
+ UNIQUE(operation_pk,receipt_id),
35
+ UNIQUE(operation_pk,sequence)
36
+ )`,
37
+ `CREATE TABLE metering_measurement (
38
+ receipt_pk text NOT NULL REFERENCES metering_receipt(receipt_pk),
39
+ unit text NOT NULL,
40
+ value numeric(38,0),
41
+ scale integer,
42
+ certainty text NOT NULL,
43
+ PRIMARY KEY(receipt_pk,unit)
44
+ )`,
45
+ `CREATE TABLE metering_command (
46
+ operation_pk text NOT NULL REFERENCES metering_operation(operation_pk),
47
+ command_id text NOT NULL,
48
+ kind text NOT NULL,
49
+ input_hash text NOT NULL,
50
+ input jsonb NOT NULL,
51
+ result jsonb NOT NULL,
52
+ PRIMARY KEY(operation_pk,command_id)
53
+ )`,
54
+ `CREATE TABLE metering_budget (
55
+ sequence bigserial NOT NULL,
56
+ namespace text NOT NULL,
57
+ budget_id text NOT NULL,
58
+ version integer NOT NULL,
59
+ scope_kind text NOT NULL,
60
+ scope_key text NOT NULL,
61
+ surface text NOT NULL,
62
+ unit text NOT NULL,
63
+ limit_value numeric(38,0),
64
+ limit_scale integer,
65
+ "window" jsonb NOT NULL,
66
+ on_exceed text NOT NULL,
67
+ body jsonb NOT NULL,
68
+ PRIMARY KEY(namespace,budget_id,version)
69
+ )`,
70
+ `CREATE INDEX metering_budget_scope_idx ON metering_budget(namespace,scope_key,surface)`,
71
+ `CREATE TABLE metering_budget_usage (
72
+ namespace text NOT NULL,
73
+ budget_id text NOT NULL,
74
+ epoch text NOT NULL,
75
+ settled_value numeric(38,0) NOT NULL,
76
+ outstanding_value numeric(38,0) NOT NULL,
77
+ scale integer NOT NULL,
78
+ PRIMARY KEY(namespace,budget_id,epoch)
79
+ )`,
80
+ `CREATE TABLE metering_event (
81
+ event_id bigserial PRIMARY KEY,
82
+ operation_pk text NOT NULL REFERENCES metering_operation(operation_pk),
83
+ namespace text NOT NULL,
84
+ state text NOT NULL,
85
+ receipt_pk text REFERENCES metering_receipt(receipt_pk),
86
+ occurred_at timestamptz
87
+ )`,
88
+ `CREATE INDEX metering_event_time_idx ON metering_event(namespace,occurred_at,event_id)`,
89
+ `CREATE INDEX metering_event_operation_idx ON metering_event(operation_pk,event_id DESC)`,
90
+ `CREATE TABLE metering_metadata (key text PRIMARY KEY,value text NOT NULL)`,
91
+ `ALTER TABLE metering_operation ADD COLUMN alerts jsonb NOT NULL DEFAULT '[]'`,
92
+ `CREATE TABLE metering_alert (
93
+ alert_key text PRIMARY KEY,
94
+ namespace text NOT NULL,
95
+ budget_id text NOT NULL,
96
+ epoch text NOT NULL,
97
+ threshold text NOT NULL,
98
+ UNIQUE(namespace, budget_id, epoch, threshold)
99
+ )`,
100
+ `CREATE INDEX metering_alert_budget_idx ON metering_alert(namespace, budget_id, epoch)`,
101
+ `CREATE TABLE metering_request_command (
102
+ namespace text NOT NULL,
103
+ command_id text NOT NULL,
104
+ identity_hash text NOT NULL,
105
+ PRIMARY KEY(namespace, command_id)
106
+ )`,
107
+ `CREATE TABLE metering_request_count (
108
+ bucket_key text PRIMARY KEY,
109
+ namespace text NOT NULL,
110
+ day date NOT NULL,
111
+ body jsonb NOT NULL,
112
+ count numeric(38,0) NOT NULL CHECK (count > 0)
113
+ )`,
114
+ `CREATE INDEX metering_request_count_window_idx ON metering_request_count(namespace, day)`,
115
+ `-- Immutable content-free invoice journal and serialized revision pointer.
116
+ CREATE TABLE metering_import_family (
117
+ family_id TEXT PRIMARY KEY,
118
+ latest_import_id TEXT NOT NULL
119
+ )`,
120
+ `CREATE TABLE metering_import (
121
+ import_id TEXT PRIMARY KEY,
122
+ family_id TEXT NOT NULL REFERENCES metering_import_family(family_id),
123
+ namespace TEXT NOT NULL,
124
+ principal TEXT NOT NULL,
125
+ connection TEXT NOT NULL,
126
+ provider TEXT NOT NULL,
127
+ file_hash TEXT NOT NULL CHECK (file_hash ~ '^[0-9a-f]{64}$'),
128
+ window_from TIMESTAMPTZ NOT NULL,
129
+ window_to TIMESTAMPTZ NOT NULL CHECK (window_to > window_from),
130
+ recorded_at TIMESTAMPTZ NOT NULL,
131
+ identity TEXT NOT NULL,
132
+ body JSONB NOT NULL,
133
+ UNIQUE (family_id, file_hash)
134
+ )`,
135
+ `ALTER TABLE metering_import_family ADD CONSTRAINT metering_import_latest_fk
136
+ FOREIGN KEY (latest_import_id) REFERENCES metering_import(import_id)
137
+ DEFERRABLE INITIALLY DEFERRED`,
138
+ `CREATE INDEX metering_import_scope_window_idx
139
+ ON metering_import(namespace,principal,connection,window_from,window_to)`,
140
+ `CREATE INDEX metering_operation_connection_created_idx
141
+ ON metering_operation(namespace,principal,(input->'scope'->>'connection'),created_at)`,
142
+ `CREATE INDEX metering_receipt_native_id_idx
143
+ ON metering_receipt((body->>'providerRequestId'))
144
+ WHERE body->>'providerRequestId' IS NOT NULL`,
145
+ `CREATE FUNCTION metering_import_immutable() RETURNS TRIGGER LANGUAGE plpgsql AS $$
146
+ BEGIN
147
+ RAISE EXCEPTION 'Meter billing imports are immutable' USING ERRCODE='23514';
148
+ END;
149
+ $$`,
150
+ `CREATE TRIGGER metering_import_immutable BEFORE UPDATE OR DELETE ON metering_import
151
+ FOR EACH ROW EXECUTE FUNCTION metering_import_immutable()`,
152
+ ];
package/dist/sql.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { PostgresDriver, PostgresExecutor } from "./driver.js";
2
+ import { SQL, type Statement } from "./statement.js";
3
+ export type Counters = {
4
+ statements: number;
5
+ changes: bigint;
6
+ rowsRead: number;
7
+ };
8
+ export type Sql = ReturnType<typeof createSql>;
9
+ export declare function createSql(executor: PostgresExecutor, counters: Counters): {
10
+ query<T>(query: Statement): Promise<T[]>;
11
+ execute(query: Statement): Promise<number>;
12
+ };
13
+ export declare function transactions(driver: PostgresDriver, schema: string, counters: Counters): {
14
+ read: <T>(body: (sql: Sql) => Promise<T>) => Promise<T>;
15
+ write: <T>(body: (sql: Sql) => Promise<T>) => Promise<T>;
16
+ };
17
+ export declare function lock(sql: Sql, value: string, tryOnly?: boolean): Promise<boolean>;
18
+ export { SQL };
package/dist/sql.js ADDED
@@ -0,0 +1,38 @@
1
+ import { SQL, schemaIdentifier } from "./statement.js";
2
+ export function createSql(executor, counters) {
3
+ return {
4
+ async query(query) {
5
+ counters.statements++;
6
+ const result = await executor.query(query.compile());
7
+ counters.rowsRead += result.length;
8
+ return result;
9
+ },
10
+ async execute(query) {
11
+ counters.statements++;
12
+ const count = await executor.execute(query.compile());
13
+ counters.changes += BigInt(count);
14
+ return count;
15
+ },
16
+ };
17
+ }
18
+ export function transactions(driver, schema, counters) {
19
+ const identifier = schemaIdentifier(schema);
20
+ const run = (readOnly, body) => driver.transaction(readOnly, async (executor) => {
21
+ const sql = createSql(executor, counters);
22
+ await sql.query(SQL.sql `SELECT set_config('search_path', ${identifier}, true)`);
23
+ return body(sql);
24
+ });
25
+ return {
26
+ read: (body) => run(true, body),
27
+ write: (body) => run(false, body),
28
+ };
29
+ }
30
+ export async function lock(sql, value, tryOnly = false) {
31
+ if (tryOnly) {
32
+ const [row] = await sql.query(SQL.sql `SELECT pg_try_advisory_xact_lock(hashtextextended(${value},0)) AS acquired`);
33
+ return row?.acquired === true;
34
+ }
35
+ await sql.query(SQL.sql `SELECT 1 AS acquired FROM pg_advisory_xact_lock(hashtextextended(${value},0))`);
36
+ return true;
37
+ }
38
+ export { SQL };
@@ -0,0 +1,22 @@
1
+ /** Compose fragments before numbering parameters; SQL text is never rewritten. */
2
+ declare class Parameter {
3
+ readonly value: unknown;
4
+ constructor(value: unknown);
5
+ }
6
+ type Part = string | Parameter;
7
+ export type ParameterizedQuery = {
8
+ text: string;
9
+ values: unknown[];
10
+ };
11
+ export declare class Statement {
12
+ readonly parts: readonly Part[];
13
+ constructor(parts: readonly Part[]);
14
+ compile(): ParameterizedQuery;
15
+ }
16
+ export declare const SQL: {
17
+ sql(strings: TemplateStringsArray, ...values: unknown[]): Statement;
18
+ join(values: readonly unknown[], separator?: string): Statement;
19
+ empty: Statement;
20
+ };
21
+ export declare function schemaIdentifier(schema: string): string;
22
+ export {};
@@ -0,0 +1,40 @@
1
+ /** Compose fragments before numbering parameters; SQL text is never rewritten. */
2
+ class Parameter {
3
+ value;
4
+ constructor(value) {
5
+ this.value = value;
6
+ }
7
+ }
8
+ export class Statement {
9
+ parts;
10
+ constructor(parts) {
11
+ this.parts = parts;
12
+ }
13
+ compile() {
14
+ const values = [];
15
+ const text = this.parts
16
+ .map((part) => {
17
+ if (typeof part === "string")
18
+ return part;
19
+ values.push(part.value);
20
+ return `$${values.length}`;
21
+ })
22
+ .join("");
23
+ return { text, values };
24
+ }
25
+ }
26
+ const parts = (value) => value instanceof Statement ? value.parts : [new Parameter(value)];
27
+ export const SQL = {
28
+ sql(strings, ...values) {
29
+ return new Statement(strings.flatMap((text, i) => (i < values.length ? [text, ...parts(values[i])] : [text])));
30
+ },
31
+ join(values, separator = ",") {
32
+ return new Statement(values.flatMap((value, i) => (i === 0 ? [...parts(value)] : [separator, ...parts(value)])));
33
+ },
34
+ empty: new Statement([]),
35
+ };
36
+ export function schemaIdentifier(schema) {
37
+ if (!/^[a-z_][a-z0-9_]{0,62}$/.test(schema))
38
+ throw new TypeError("Invalid metering schema");
39
+ return `"${schema}"`;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@usagekit/store-postgres",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.7.0",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "!dist/**/*.tsbuildinfo",
15
+ "LICENSE",
16
+ "NOTICE"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -b",
20
+ "typecheck": "tsc -b",
21
+ "prepublishOnly": "node ../../scripts/publish-guard.mjs",
22
+ "prepack": "node ../../scripts/package-license.mjs"
23
+ },
24
+ "dependencies": {
25
+ "@usagekit/core": "0.7.0",
26
+ "@usagekit/store": "0.7.0",
27
+ "pg": "8.23.1",
28
+ "@types/pg": "8.23.1"
29
+ },
30
+ "description": "Atomic Postgres Store with pg/Prisma drivers, explicit migrations and host transaction composition.",
31
+ "sideEffects": false,
32
+ "publishConfig": {
33
+ "access": "restricted",
34
+ "registry": "https://registry.npmjs.org/"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/msniezynski/usagekit.git",
39
+ "directory": "packages/store-postgres"
40
+ },
41
+ "homepage": "https://usagekit.dev",
42
+ "bugs": {
43
+ "url": "https://github.com/msniezynski/usagekit/issues"
44
+ }
45
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.