taler-typescript-core

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

commit 1b732e5afc4cecf7ab7bfb5b9b4df8fe40af3aae
parent f847430fd1bf458215541b96416cb342a4c4c213
Author: Florian Dold <dold@taler.net>
Date:   Sun, 19 Jul 2026 13:53:15 +0200

db: fail the build if an IndexedDB record type stops being persistable

Persistable<T> rejects what structuredEncapsulate cannot store.

Diffstat:
Mpackages/taler-wallet-core/src/db-indexeddb.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 82 insertions(+), 0 deletions(-)

diff --git a/packages/taler-wallet-core/src/db-indexeddb.ts b/packages/taler-wallet-core/src/db-indexeddb.ts @@ -2093,3 +2093,85 @@ export async function deleteTalerDatabase( req.onsuccess = () => resolve(); }); } + +/** + * Compile-time proof that every IndexedDB record type can actually be stored. + * + * The store definitions below use the DAL's `Wallet<Name>` types directly as + * their record types, so a change made for the native sqlite backend changes + * what IndexedDB persists. That is fine while the types stay + * structured-clone friendly, and fatal the moment one does not: the + * serialiser these values pass through on their way to storage + * (structuredEncapsulate in idb-bridge) handles arrays, dates, plain objects, + * bigint, boolean, number and string, and throws on anything else. Typed + * arrays included -- IndexedDB here cannot store binary at all. + * + * Without this, giving a record field a Uint8Array type -- the direction the + * native backend wants to go, to stop encoding keys as Crockford base32 -- + * would compile cleanly and fail at runtime, on write, in production. + * + * The record types are derived from the store map rather than listed, so a + * 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. + */ +type Persistable<T> = T extends ArrayBufferView | ArrayBuffer + ? never + : T extends (...args: any[]) => any + ? never + : // Primitives are checked before objects on purpose: the branded types + // used throughout the records (DbPreciseTimestamp is number & {...}, + // AmountString is string & {...}) are intersections that satisfy + // `extends object`, and mapping over one turns a number into an object + // type. That flagged perfectly persistable records. + T extends string | number | boolean | bigint | null | undefined + ? T + : T extends Date + ? T + : T extends Array<infer U> + ? Array<Persistable<U>> + : T extends object + ? { [K in keyof T]: Persistable<T[K]> } + : T; + +/** + * The tuple wrappers stop the outer conditional from distributing over a + * union of record types, which would let a single unpersistable member hide + * behind its persistable siblings. + */ +type IsPersistable<T> = [T] extends [Persistable<T>] ? true : false; + +type AssertTrue<T extends true> = T; + +type RecordTypeOf<S> = + S extends StoreWithIndexes<any, infer R, any> ? R : never; + +type StoreMapV1 = typeof WalletIndexedDbStoresV1; + +/** + * The names of stores whose record type cannot be persisted, or never. + * + * Checked per store rather than over the union of all record types: two + * obsolete stores are typed `any`, and `any` in a union makes every other + * member assignable to it, which made an earlier version of this check pass + * a record type containing a Uint8Array. A vacuous guard is worse than no + * guard, because it is trusted. + */ +type UnpersistableStores = { + [K in keyof StoreMapV1]: IsPersistable< + RecordTypeOf<StoreMapV1[K]> + > extends true + ? never + : K; +}[keyof StoreMapV1]; + +/** + * The assertion itself. Unused at runtime; its only job is to fail the build, + * naming the offending store in the error. + */ +export type _AllIndexedDbRecordsArePersistable = AssertTrue< + [UnpersistableStores] extends [never] ? true : UnpersistableStores +>;