taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit d768391c048a6581ad719a21d83c1c544a4540d3
parent e07929117f2c9493442cf52a31b0123e3463115a
Author: Florian Dold <dold@taler.net>
Date:   Sun, 19 Jul 2026 23:20:03 +0200

db: document what each sqlite column type means

sqlite's type affinity accepts a wrong-typed value and only misbehaves at
comparison time.  Documents what each declared type actually means:
microsecond timestamps, amount strings, BLOB key material, JSON payloads,
numeric vs string enums.

Diffstat:
Mpackages/taler-wallet-core/src/db-indexeddb.ts | 7++++---
Mpackages/taler-wallet-core/src/db-sqlite-schema.ts | 94++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mpackages/taler-wallet-core/src/dbtx-sqlite.ts | 2+-
Mpackages/taler-wallet-core/src/host-impl.node.ts | 3++-
4 files changed, 69 insertions(+), 37 deletions(-)

diff --git a/packages/taler-wallet-core/src/db-indexeddb.ts b/packages/taler-wallet-core/src/db-indexeddb.ts @@ -2129,9 +2129,10 @@ export async function deleteTalerDatabase( * new store is covered without anyone remembering to add it here. * * When this stops compiling: do not widen it. It means a stored type has - * gained a field IndexedDB cannot persist, and the fix is to decouple the - * IndexedDB record types from the DAL types (see SQLITE_BLOB_PLAN.md, the - * "decouple the storage types" note), not to relax the check. + * gained a field IndexedDB cannot persist -- typically a Uint8Array, which + * structuredEncapsulate cannot represent. The fix is to give the IndexedDB + * store its own record type holding whatever it can persist, and convert at + * the DAL boundary, not to relax the check. */ type Persistable<T> = T extends ArrayBufferView | ArrayBuffer ? never diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts @@ -19,31 +19,62 @@ * * This is a clean-slate schema: it is not a translation of the IndexedDB * object stores at the storage level, and it carries none of the historical - * fixups. Converting an existing IndexedDB wallet is a separate concern (see - * SQLITE_SCHEMA_DESIGN.md, phase D), and that converter is responsible for - * producing data as if every fixup had already run. + * fixups. Converting an existing IndexedDB wallet is a separate concern, and + * that converter is responsible for producing data as if every fixup had + * already run. * - * Conventions, applied throughout: + * Naming: snake_case tables and columns. The mapping to camelCase record + * fields is written out explicitly in dbtx-sqlite.ts, never derived by string + * munging, so a rename on either side is a compile error rather than a + * column that silently stops being read. * - * - snake_case tables and columns; the mapping to camelCase record fields is - * explicit in dbtx-sqlite.ts, never derived by string munging. - * - timestamps are INTEGER microseconds; Number.MAX_SAFE_INTEGER means "never". - * - amounts stay TEXT in their canonical string form. - * - keys, hashes and signatures are BLOB. The record types expose them as - * Crockford base32 strings, and dbtx-sqlite.ts converts at the field - * mapping -- see BLOB_COLUMNS below, which is the single source of truth, - * and SQLITE_BLOB_PLAN.md for why. A new column holding key material - * should be BLOB and listed there; a TEXT one silently fails to match a - * correctly-encoded parameter, because sqlite never compares TEXT equal to - * BLOB. - * - status enums are INTEGER, so the non-final range is a BETWEEN. Several - * enums are *string* enums upstream and are TEXT instead: CoinStatus, - * ExchangeMigrationReason, RefreshReason, DenomLossEventType, - * MerchantContractTokenKind and WithdrawalRecordType. Each such column - * says so where it is declared. - * - values that are only ever read and written whole are JSON TEXT. - * - fields that an index needs are real columns, even when they also appear - * inside a JSON payload conceptually. + * The declared type of a column is not just documentation here: sqlite's type + * affinity means a value of the "wrong" type is usually stored happily and + * only misbehaves at comparison time. The conventions below say what each + * declared type actually means, and every column in this file is one of them. + * + * - INTEGER timestamps are microseconds since the epoch. + * Number.MAX_SAFE_INTEGER is the sentinel for "never"; NULL means "not + * known / not set", which is a different thing and is used deliberately. + * Column names end in _time, _stamp, or say so where declared. + * + * - TEXT amounts are the canonical Taler amount string, "CURRENCY:X.Y", not + * a number. They are compared for equality and grouped, never summed in + * SQL: amount arithmetic is done in TypeScript, where the currency is + * checked. Column names end in _amount, or name the amount they hold. + * + * - BLOB is key material: public and private keys, hashes, signatures, + * blinding factors, nonces. The record types expose these as Crockford + * base32 strings and dbtx-sqlite.ts converts at the field mapping. + * BLOB_COLUMNS below is the single source of truth for which columns these + * are. A new column holding key material must be BLOB and listed there; a + * TEXT one silently fails to match a correctly-encoded parameter, because + * sqlite never compares a TEXT value equal to a BLOB one. + * + * - TEXT that is neither of the above is a plain string: a base URL, a + * currency name, a label, an order or transaction id supplied by a + * merchant or exchange. + * + * - INTEGER status enums come from the numeric enums in db-common.ts, whose + * values are laid out so that "still active" is a contiguous range and the + * query is a BETWEEN. Six enums are *string* enums upstream and their + * columns are TEXT accordingly: CoinStatus, ExchangeMigrationReason, + * RefreshReason, DenomLossEventType, MerchantContractTokenKind and + * WithdrawalRecordType. Each such column says so where it is declared. + * + * - INTEGER 0/1 is a boolean; sqlite has no boolean type. These columns are + * named is_*, has_*, or similar, and carry a CHECK constraint. + * + * - "JSON" in a column comment means TEXT holding a JSON document, written + * and read whole by JSON.stringify/JSON.parse and never inspected by SQL. + * Anything that has to be filtered, sorted or joined on is a real column + * instead, even where it conceptually belongs to such a payload -- and in + * a few places it is deliberately both, with the column authoritative for + * queries and the payload authoritative for the record. Those say so. + * + * - INTEGER PRIMARY KEY columns named *_serial are sqlite rowids, handed out + * by the database and stable for the life of a row. They are the targets + * of every foreign key here. */ /** @@ -93,12 +124,10 @@ export interface SchemaMigration { * compares equal to a BLOB one, so a half-converted column returns no rows * and raises no error. * - * Phase 1 of SQLITE_BLOB_PLAN.md: the hot, high-row-count tables. Columns in - * other tables holding the same kind of value (for example - * refund_items.coin_pub) are still TEXT. That is safe because no query joins - * across them — they are separate tables compared only against bound - * parameters — but it is why the phases have to be finished rather than left - * half-done. + * Every column holding key material is listed, not just the ones in the + * high-row-count tables: a value of the same kind stored as TEXT in one table + * and BLOB in another compares unequal across the two, so a partial + * conversion is a worse state to be in than none at all. */ export const BLOB_COLUMNS: Readonly<Record<string, readonly string[]>> = { coin_availability: ["denom_pub_hash", "exchange_master_pub"], @@ -861,9 +890,10 @@ CREATE TABLE IF NOT EXISTS exchange_base_url_migration_log ( PRIMARY KEY (old_exchange_base_url, new_exchange_base_url) ); --- wgInfo follows "Option 2b" from SQLITE_SCHEMA_DESIGN.md: a discriminant, --- two promoted scalars and two JSON columns, rather than a side table per --- variant or one opaque blob. +-- The wgInfo union is stored as a discriminant, two promoted scalars and two +-- JSON columns, rather than as a side table per variant or one opaque blob: +-- the promoted columns are the ones queries filter on, and the variants differ +-- too little to justify a table each. CREATE TABLE IF NOT EXISTS withdrawal_groups ( withdrawal_group_id TEXT PRIMARY KEY, -- The wgInfo discriminant (WithdrawalRecordType, a string enum). diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts @@ -311,7 +311,7 @@ function optStr(v: Sqlite3Value | undefined): string | undefined { * * Applied at the individual field mapping, never folded into a shared row * builder: when a record field eventually becomes Uint8Array end to end, that - * one line loses its call and nothing else moves. See SQLITE_BLOB_PLAN.md. + * one line loses its call and nothing else moves. */ function crockToDb(v: string): Uint8Array { return decodeCrock(v); diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts @@ -86,7 +86,8 @@ async function makeSqliteDb( // Opt-in native backend. The IndexedDB emulation stays open either way: // the stored-backup database and the fixup bookkeeping still live there, // and the native schema deliberately starts clean instead of replaying - // fixups (see SQLITE_SCHEMA_DESIGN.md). + // fixups: every fixup repairs records written by an older version of the + // IndexedDB schema, which the native schema never had. let nativeSqliteDb: NativeSqliteWalletDb | undefined; if (useNativeDb) { const nativeFilename = dbFilename;