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:
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;