/** * @pwngh/money * * Copyright (c) Preston Neal * * This source code is licensed under the MIT license found in the * LICENSE.md file in the root directory of this source tree. * * @license MIT */ /** * The database carrier, as the /db subpath the importer composes in. A host that * keeps its own database — managed Postgres, a MySQL cluster, anything this file * has a dialect for — never gets trusted to conform; it gets asked. `install*` * applies the idempotent DDL (shipped as string constants, the schema-as-string * rule, so nothing here can drift from a file), and `prove*` re-runs the * conformance vectors against the live engine and returns the failures, empty when * the database demonstrably implements the semantics. Assert empty at boot, the * way consumers assert `selfTest()` on a vendored file. * * Zero imports, like every sibling: the caller passes `vectors` from the main * entry, and the database driver enters structurally as `SqlRunner` — a pg pool, * a mysql2 connection, or a test fake all adapt in one line: * * pg: { run: (sql, p) => pool.query(sql, p as unknown[]).then((r) => r.rows) } * mysql2: { run: (sql, p) => conn.query(sql, p as unknown[]).then(([rows]) => rows) } * * Only the div, muldiv, and bps vector families run here — divRound and splitBps * are the reconciliation semantics a database re-derives; parsing and the wire * codec stay application-side. Results cross the wire as strings so no driver's * number handling can corrupt an i64. */ /** Bumped on any breaking change to the DDL below; prove* fails on a mismatch. */ export declare const DB_VERSION = 1; /** The minimal slice of a database driver this file needs. Rows come back as objects. */ export interface SqlRunner { run(sql: string, params?: readonly unknown[]): Promise[]>; } /** One conformance vector, structurally — pass `vectors` from '@pwngh/money'. */ export type Vector = readonly unknown[]; /** * PostgreSQL DDL: schema `money`, `div_round` and `split_bps` on exact numeric * div()/mod() (truncation toward zero, matching BigInt), the psql-side vectors * table with `conformance()` and `prove()`, and the version stamp. Idempotent; * apply with installPostgres or psql. `scripts/emit.ts` projects this constant to * `out/money.sql` for psql-only consumers; nothing imports the projection back. */ export declare const moneySql = "\ncreate schema if not exists money;\n\ncreate table if not exists money.meta (\n version integer not null\n);\n\ndelete from money.meta where version <> 1;\ninsert into money.meta (version)\n select 1 where not exists (select 1 from money.meta);\n\n-- Integer division with the rounding mode named at the call site, the divRound\n-- carrier. q and r come from div()/mod(), which truncate toward zero exactly like\n-- BigInt; the mode adjustments and the i64 range check mirror src/money.ts line\n-- for line, including the quirk that an unknown mode with a zero remainder\n-- returns rather than raises.\ncreate or replace function money.div_round(num numeric, den numeric, mode text)\nreturns bigint\nlanguage plpgsql immutable\nas $fn$\ndeclare\n q numeric;\n r numeric;\n negative boolean;\n twice numeric;\n magnitude numeric;\n step numeric;\nbegin\n if den = 0 then\n raise exception 'div_round: zero divisor';\n end if;\n q := div(num, den);\n r := mod(num, den);\n if r <> 0 then\n negative := (num < 0) <> (den < 0);\n if mode = 'trunc' then\n null;\n elsif mode = 'floor' then\n if negative then q := q - 1; end if;\n elsif mode = 'ceil' then\n if not negative then q := q + 1; end if;\n elsif mode = 'halfEven' or mode = 'halfUp' then\n twice := 2 * abs(r);\n magnitude := abs(den);\n step := case when negative then -1 else 1 end;\n if twice > magnitude then\n q := q + step;\n elsif twice = magnitude then\n if mode = 'halfUp' or mod(q, 2) <> 0 then q := q + step; end if;\n end if;\n else\n raise exception 'div_round: unknown mode %', mode;\n end if;\n end if;\n if q < -9223372036854775808 or q > 9223372036854775807 then\n raise exception 'i64 overflow';\n end if;\n return q;\nend\n$fn$;\n\n-- Basis-point split, floor per share, remainder to the caller: the splitBps\n-- carrier. minor * b is computed in numeric so a near-I64_MAX minor cannot\n-- overflow the intermediate; div() truncation toward zero reproduces the pinned\n-- negative-split behavior (toward-zero shares, negative remainder).\ncreate or replace function money.split_bps(\n minor bigint,\n bps integer[],\n out shares bigint[],\n out remainder bigint\n)\nlanguage plpgsql immutable\nas $fn$\ndeclare\n total integer := 0;\n b integer;\n share numeric;\n used numeric := 0;\nbegin\n shares := array[]::bigint[];\n foreach b in array bps loop\n if b < 0 then raise exception 'split_bps: bad bps'; end if;\n total := total + b;\n end loop;\n if total > 10000 then\n raise exception 'split_bps: bps exceed 10000';\n end if;\n foreach b in array bps loop\n share := div(minor::numeric * b, 10000);\n used := used + share;\n shares := shares || share::bigint;\n end loop;\n remainder := (minor::numeric - used)::bigint;\nend\n$fn$;\n\n-- One row per conformance vector, for the pure-psql path (db/prove.sql loads it).\ncreate table if not exists money.vectors (\n v jsonb not null\n);\n\n-- Runs every div, muldiv, and bps vector and returns one text row per failure;\n-- empty means conformant. 'throws' vectors must raise; anything else must match\n-- byte for byte, and bps rows must also conserve.\ncreate or replace function money.conformance()\nreturns setof text\nlanguage plpgsql\nas $fn$\ndeclare\n vec jsonb;\n kind text;\n want text;\n got text;\n got_shares bigint[];\n got_remainder bigint;\n want_shares bigint[];\n in_bps integer[];\n minor bigint;\nbegin\n for vec in select v from money.vectors where v->>0 in ('div', 'muldiv', 'bps') loop\n kind := vec->>0;\n if kind = 'div' or kind = 'muldiv' then\n want := case when kind = 'div' then vec->>4 else vec->>5 end;\n begin\n if kind = 'div' then\n got := money.div_round((vec->>1)::numeric, (vec->>2)::numeric, vec->>3)::text;\n else\n got := money.div_round(\n (vec->>1)::numeric * (vec->>2)::numeric, (vec->>3)::numeric, vec->>4)::text;\n end if;\n if want = 'throws' then\n return next format('%s got %s, wanted raise', vec::text, got);\n elsif got <> want then\n return next format('%s got %s', vec::text, got);\n end if;\n exception when others then\n if want <> 'throws' then\n return next format('%s raised: %s', vec::text, sqlerrm);\n end if;\n end;\n else\n begin\n minor := (vec->>1)::bigint;\n select coalesce(array_agg(x.val::integer order by x.ord), array[]::integer[])\n into in_bps\n from jsonb_array_elements_text(vec->2) with ordinality as x(val, ord);\n select coalesce(array_agg(x.val::bigint order by x.ord), array[]::bigint[])\n into want_shares\n from jsonb_array_elements_text(vec->3) with ordinality as x(val, ord);\n select p.shares, p.remainder into got_shares, got_remainder\n from money.split_bps(minor, in_bps) p;\n if got_shares <> want_shares or got_remainder <> (vec->>4)::bigint then\n return next format('%s got %s r %s', vec::text, got_shares, got_remainder);\n elsif coalesce((select sum(s) from unnest(got_shares) s), 0) + got_remainder\n <> minor then\n return next format('%s does not conserve', vec::text);\n end if;\n exception when others then\n return next format('%s raised: %s', vec::text, sqlerrm);\n end;\n end if;\n end loop;\nend\n$fn$;\n\n-- The assertion psql runs: raises unless vectors are loaded and every one passes,\n-- so `psql -c 'select money.prove()'` is a red/green exit code.\ncreate or replace function money.prove()\nreturns void\nlanguage plpgsql\nas $fn$\ndeclare\n failure text;\n failures integer := 0;\n loaded integer;\nbegin\n select count(*) into loaded\n from money.vectors where v->>0 in ('div', 'muldiv', 'bps');\n if loaded = 0 then\n raise exception 'money.prove: no vectors loaded';\n end if;\n for failure in select money.conformance() loop\n raise warning '%', failure;\n failures := failures + 1;\n end loop;\n if failures > 0 then\n raise exception 'money.prove: % of % vectors failed', failures, loaded;\n end if;\n raise notice 'money.prove: % vectors conformant', loaded;\nend\n$fn$;\n"; /** * MySQL DDL as one statement per element, because a driver must send each * separately (DELIMITER is a CLI-client construct, not server syntax; a function * body's inner semicolons are fine within a single statement). Same semantics as * the Postgres pair: DECIMAL(40,0) intermediates (the muldiv vectors overflow * BIGINT by design), DIV/MOD truncation toward zero matching BigInt, i64 range * enforced with SIGNAL. split_bps returns JSON with shares and remainder as * strings so an i64 never rides a JSON number. Apply with installMysql; * `scripts/emit.ts` projects a DELIMITER-wrapped `out/money.mysql.sql` for the * mysql CLI. */ export declare const moneyMysql: readonly string[]; /** Applies the Postgres DDL. Idempotent; safe to run at every boot. */ export declare function installPostgres(db: SqlRunner): Promise; /** Applies the MySQL DDL. Idempotent; safe to run at every boot. */ export declare function installMysql(db: SqlRunner): Promise; /** * Proves a live Postgres implements the semantics: returns failures, empty when * conformant. Assert empty at boot. */ export declare function provePostgres(db: SqlRunner, vectors: readonly Vector[]): Promise; /** * Proves a live MySQL implements the semantics: returns failures, empty when * conformant. Assert empty at boot. */ export declare function proveMysql(db: SqlRunner, vectors: readonly Vector[]): Promise;