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