web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

commit 2edab708aa8920b8b8eaf7381478afdaed12010d
parent db8a7e02867e1585220c8d9a2936362b2a7f2dbb
Author: triesap <triesap@radroots.dev>
Date:   Sun, 21 Dec 2025 22:51:17 +0000

client: optimized binary base64 encoding and hardened web backup, filesystem, and database operations with validated, typed errors

Diffstat:
Mclient/src/backup/codec.ts | 6+++---
Mclient/src/backup/index.ts | 87++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mclient/src/cipher/web.ts | 1-
Mclient/src/datastore/web.ts | 2+-
Mclient/src/fs/error.ts | 3++-
Mclient/src/fs/web.ts | 5++++-
Mclient/src/geolocation/web.ts | 4+++-
Mclient/src/keystore/web.ts | 2+-
Mclient/src/notifications/error.ts | 3++-
Mclient/src/notifications/web.ts | 51+++++++++++++++++++++++++++++++++++----------------
Mclient/src/sql/types.ts | 7++++++-
Mclient/src/sql/web.ts | 52+++++++++++++++++++++++++++++++++++-----------------
Mclient/src/tangle/bridge.ts | 25++++++++++++++++++++-----
Mclient/src/tangle/error.ts | 2++
Mclient/src/tangle/types.ts | 16+++++++++-------
Mclient/src/tangle/web.ts | 162++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
16 files changed, 304 insertions(+), 124 deletions(-)

diff --git a/client/src/backup/codec.ts b/client/src/backup/codec.ts @@ -10,9 +10,9 @@ const ensure_crypto = (): void => { export const backup_bytes_to_b64 = (bytes: Uint8Array): string => { if (typeof btoa === "undefined") throw new Error(cl_backup_error.encode_failure); - let binary = ""; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); - return btoa(binary); + const chars: string[] = new Array(bytes.length); + for (let i = 0; i < bytes.length; i++) chars[i] = String.fromCharCode(bytes[i]); + return btoa(chars.join("")); }; export const backup_b64_to_bytes = (value: string): Uint8Array => { diff --git a/client/src/backup/index.ts b/client/src/backup/index.ts @@ -12,7 +12,7 @@ import type { } from "./types.js"; import type { IWebCryptoService, KeyMaterialProvider } from "../crypto/types.js"; import { DeviceKeyMaterialProvider } from "../crypto/provider.js"; -import type { ResolveError } from "@radroots/utils"; +import { handle_err, type ResolveError } from "@radroots/utils"; import type { IError } from "@radroots/types-bindings"; export type BackupBundleBuildOpts = { @@ -33,10 +33,15 @@ export type BackupBundleImportOpts = { import_registry?: boolean; }; -const collect_payloads = async (opts: BackupBundleBuildOpts): Promise<BackupBundlePayload[]> => { +const is_error = <T>(value: ResolveError<T>): value is IError<string> => { + return typeof value === "object" && value !== null && "err" in value; +}; + +const collect_payloads = async (opts: BackupBundleBuildOpts): Promise<ResolveError<BackupBundlePayload[]>> => { const payloads: BackupBundlePayload[] = []; if (opts.sql_store) { - const data = unwrap_resolve(await opts.sql_store.export_backup()); + const data = await opts.sql_store.export_backup(); + if (is_error(data)) return data; payloads.push({ store_id: opts.sql_store.get_store_id(), store_type: "sql", @@ -44,7 +49,8 @@ const collect_payloads = async (opts: BackupBundleBuildOpts): Promise<BackupBund }); } if (opts.keystore_store) { - const data = unwrap_resolve(await opts.keystore_store.export_backup()); + const data = await opts.keystore_store.export_backup(); + if (is_error(data)) return data; payloads.push({ store_id: opts.keystore_store.get_store_id(), store_type: "keystore", @@ -52,7 +58,8 @@ const collect_payloads = async (opts: BackupBundleBuildOpts): Promise<BackupBund }); } if (opts.datastore_store) { - const data = unwrap_resolve(await opts.datastore_store.export_backup()); + const data = await opts.datastore_store.export_backup(); + if (is_error(data)) return data; payloads.push({ store_id: opts.datastore_store.get_store_id(), store_type: "datastore", @@ -62,17 +69,9 @@ const collect_payloads = async (opts: BackupBundleBuildOpts): Promise<BackupBund return payloads; }; -const is_error = <T>(value: ResolveError<T>): value is IError<string> => { - return typeof value === "object" && value !== null && "err" in value; -}; - -const unwrap_resolve = <T>(value: ResolveError<T>): T => { - if (is_error(value)) throw new Error(value.err); - return value; -}; - -export const backup_bundle_build = async (opts: BackupBundleBuildOpts): Promise<BackupBundle> => { +export const backup_bundle_build = async (opts: BackupBundleBuildOpts): Promise<ResolveError<BackupBundle>> => { const payloads = await collect_payloads(opts); + if (is_error(payloads)) return payloads; const stores = payloads.map((payload) => ({ store_id: payload.store_id, store_type: payload.store_type @@ -92,34 +91,46 @@ export const backup_bundle_build = async (opts: BackupBundleBuildOpts): Promise< }; }; -export const backup_bundle_export = async (opts: BackupBundleBuildOpts): Promise<Uint8Array> => { - const provider = opts.key_material_provider ?? new DeviceKeyMaterialProvider(); - const bundle = await backup_bundle_build(opts); - return await backup_bundle_encode(bundle, provider); +export const backup_bundle_export = async (opts: BackupBundleBuildOpts): Promise<ResolveError<Uint8Array>> => { + try { + const provider = opts.key_material_provider ?? new DeviceKeyMaterialProvider(); + const bundle = await backup_bundle_build(opts); + if (is_error(bundle)) return bundle; + return await backup_bundle_encode(bundle, provider); + } catch (e) { + return handle_err(e); + } }; -export const backup_bundle_import = async (blob: Uint8Array, opts: BackupBundleImportOpts): Promise<BackupBundle> => { - const provider = opts.key_material_provider ?? new DeviceKeyMaterialProvider(); - const bundle = await backup_bundle_decode(blob, provider); - if (opts.import_registry && opts.crypto_service) { - await opts.crypto_service.import_registry(bundle.manifest.crypto_registry); - } - for (const payload of bundle.payloads) { - if (payload.store_type === "sql" && opts.sql_store) { - if (opts.sql_store.get_store_id() === payload.store_id) { - unwrap_resolve(await opts.sql_store.import_backup(payload.data)); - } +export const backup_bundle_import = async (blob: Uint8Array, opts: BackupBundleImportOpts): Promise<ResolveError<BackupBundle>> => { + try { + const provider = opts.key_material_provider ?? new DeviceKeyMaterialProvider(); + const bundle = await backup_bundle_decode(blob, provider); + if (opts.import_registry && opts.crypto_service) { + await opts.crypto_service.import_registry(bundle.manifest.crypto_registry); } - if (payload.store_type === "keystore" && opts.keystore_store) { - if (opts.keystore_store.get_store_id() === payload.store_id) { - unwrap_resolve(await opts.keystore_store.import_backup(payload.data)); + for (const payload of bundle.payloads) { + if (payload.store_type === "sql" && opts.sql_store) { + if (opts.sql_store.get_store_id() === payload.store_id) { + const res = await opts.sql_store.import_backup(payload.data); + if (is_error(res)) return res; + } } - } - if (payload.store_type === "datastore" && opts.datastore_store) { - if (opts.datastore_store.get_store_id() === payload.store_id) { - unwrap_resolve(await opts.datastore_store.import_backup(payload.data)); + if (payload.store_type === "keystore" && opts.keystore_store) { + if (opts.keystore_store.get_store_id() === payload.store_id) { + const res = await opts.keystore_store.import_backup(payload.data); + if (is_error(res)) return res; + } + } + if (payload.store_type === "datastore" && opts.datastore_store) { + if (opts.datastore_store.get_store_id() === payload.store_id) { + const res = await opts.datastore_store.import_backup(payload.data); + if (is_error(res)) return res; + } } } + return bundle; + } catch (e) { + return handle_err(e); } - return bundle; }; diff --git a/client/src/cipher/web.ts b/client/src/cipher/web.ts @@ -81,7 +81,6 @@ export class WebAesGcmCipher implements IWebAesGcmCipher { } public async encrypt(data: Uint8Array): Promise<Uint8Array> { - if (data.byteLength === 0) return data; return await this.crypto.encrypt(this.store_id, data); } diff --git a/client/src/datastore/web.ts b/client/src/datastore/web.ts @@ -152,7 +152,7 @@ export class WebDatastore< const decrypted = await this.decrypt_value(k, curr); if ("err" in decrypted) return decrypted; const parsed: unknown = JSON.parse(decrypted.result); - if (is_record(parsed)) for (const [curr_key, curr_val] of Object.entries(parsed)) if (curr_val) obj_curr[curr_key] = curr_val; + if (is_record(parsed)) for (const [curr_key, curr_val] of Object.entries(parsed)) obj_curr[curr_key] = curr_val; } const obj: T = { ...obj_curr, ...value } as T; const serialized = JSON.stringify(obj); diff --git a/client/src/fs/error.ts b/client/src/fs/error.ts @@ -1,5 +1,6 @@ export const cl_fs_error = { - + not_found: "error.client.fs.not_found", + request_failure: "error.client.fs.request_failure" } as const; export type ClientFsError = keyof typeof cl_fs_error; diff --git a/client/src/fs/web.ts b/client/src/fs/web.ts @@ -1,4 +1,5 @@ -import { handle_err, type ResolveError } from "@radroots/utils"; +import { err_msg, handle_err, type ResolveError } from "@radroots/utils"; +import { cl_fs_error } from "./error.js"; import type { IClientFs, IClientFsFileInfo, IClientFsOpenResult, IClientFsReadBinResolve } from "./types.js"; export interface IWebFs extends IClientFs {} @@ -20,6 +21,7 @@ export class WebFs implements IWebFs { public async info(path: string): Promise<ResolveError<IClientFsFileInfo>> { try { const res = await fetch(path, { method: 'HEAD' }); + if (!res.ok) return err_msg(res.status === 404 ? cl_fs_error.not_found : cl_fs_error.request_failure); const size_header = res.headers.get('Content-Length'); const size = size_header ? Number(size_header) : 0; return { size, isFile: true, isDirectory: false }; @@ -31,6 +33,7 @@ export class WebFs implements IWebFs { public async read_bin(path: string): Promise<IClientFsReadBinResolve> { try { const res = await fetch(path); + if (!res.ok) return err_msg(res.status === 404 ? cl_fs_error.not_found : cl_fs_error.request_failure); const buf = await res.arrayBuffer(); return new Uint8Array(buf); } catch (e) { diff --git a/client/src/geolocation/web.ts b/client/src/geolocation/web.ts @@ -106,12 +106,14 @@ export interface IWebGeolocation extends IClientGeolocation {} export class WebGeolocation implements IWebGeolocation { public async current(): Promise<ResolveErrorMsg<IClientGeolocationPosition, ClientGeolocationErrorMessage>> { + if (typeof navigator === "undefined" || typeof document === "undefined") return err_msg(cl_geolocation_error.location_unavailable); if (!navigator.geolocation) return err_msg(cl_geolocation_error.location_unavailable); const policy_allows = read_policy_allows_geolocation(document); const permission_state = await read_permission_state_geolocation(navigator); const base_debug = create_debug(policy_allows, permission_state); + const has_geo_error = typeof GeolocationPositionError !== "undefined"; if (policy_allows === false) { log_geo_debug("[geolocation] blocked_by_policy", base_debug); @@ -127,7 +129,7 @@ export class WebGeolocation implements IWebGeolocation { accuracy: position.coords.accuracy }; } catch (e) { - if (e instanceof GeolocationPositionError) { + if (has_geo_error && e instanceof GeolocationPositionError) { const debug: GeoDebug = { ...base_debug, error_code: e.code, diff --git a/client/src/keystore/web.ts b/client/src/keystore/web.ts @@ -143,7 +143,7 @@ export class WebKeystore implements IWebKeystore { if (typeof key !== "string") continue; const value = await this.read(key); if ("err" in value) return value; - if (!value.result) return err_msg(cl_keystore_error.corrupt_data); + if (typeof value.result !== "string") return err_msg(cl_keystore_error.corrupt_data); entries.push({ key, value: value.result }); } return { entries }; diff --git a/client/src/notifications/error.ts b/client/src/notifications/error.ts @@ -1,5 +1,6 @@ export const cl_notifications_error = { - unavailable: "error.client.notifications.unavailable" + unavailable: "error.client.notifications.unavailable", + read_failure: "error.client.notifications.read_failure" } as const; export type ClientNotificationsError = keyof typeof cl_notifications_error; diff --git a/client/src/notifications/web.ts b/client/src/notifications/web.ts @@ -65,23 +65,42 @@ export class WebNotifications implements IWebNotifications { } } + private async read_photo_data(file: File): Promise<string> { + return await new Promise<string>((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") return resolve(reader.result); + return reject(new Error(cl_notifications_error.read_failure)); + }; + reader.onerror = () => { + if (reader.error) return reject(reader.error); + return reject(new Error(cl_notifications_error.read_failure)); + }; + reader.readAsDataURL(file); + }); + } + public async open_photos(): Promise<ResolveError<IResultList<string> | undefined>> { - return await new Promise<IResultList<string> | undefined>((resolve) => { - const input = document.createElement('input'); - input.type = 'file'; - input.multiple = true; - input.accept = 'image/png,image/jpg'; - input.onchange = () => { - const files = input.files; - if (!files) return resolve(undefined); - const results: string[] = []; - for (let i = 0; i < files.length; i++) { - const url = URL.createObjectURL(files[i]!); - results.push(url); - } - resolve({ results }); + try { + const files = await new Promise<FileList | null>((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.accept = 'image/png,image/jpg'; + input.onchange = () => resolve(input.files); + input.click(); + }); + if (!files) return; + const results: string[] = []; + for (let i = 0; i < files.length; i++) { + const file = files.item(i); + if (!file) continue; + const data_url = await this.read_photo_data(file); + results.push(data_url); } - input.click(); - }) + return { results }; + } catch (e) { + return handle_err(e); + } } } diff --git a/client/src/sql/types.ts b/client/src/sql/types.ts @@ -1,4 +1,4 @@ -import type { ResolveError } from "@radroots/utils"; +import type { IdbClientConfig, ResolveError } from "@radroots/utils"; import type { SqlValue } from "sql.js"; import type { BackupSqlPayload } from "../backup/types.js"; @@ -24,6 +24,11 @@ export type SqlJsValue = SqlValue; export type SqlJsParams = Readonly<Record<string, SqlJsValue>> | ReadonlyArray<SqlJsValue>; +export type WebSqlEngineConfig = { + store_key: string; + idb_config: IdbClientConfig; + cipher_config?: IdbClientConfig | null; +}; export interface IClientSqlEncryptedStore { load(): Promise<Uint8Array | null>; diff --git a/client/src/sql/web.ts b/client/src/sql/web.ts @@ -1,12 +1,12 @@ import { handle_err, type IdbClientConfig, type ResolveError } from "@radroots/utils"; -import { del as idb_del, get as idb_get, set as idb_set } from "idb-keyval"; +import { createStore, del as idb_del, get as idb_get, set as idb_set, type UseStore } from "idb-keyval"; import type { BindParams, Database, SqlJsStatic, SqlValue, Statement } from "sql.js"; import init_sql_js from "sql.js/dist/sql-wasm.js"; import { backup_b64_to_bytes, backup_bytes_to_b64 } from "../backup/codec.js"; import type { BackupSqlPayload } from "../backup/types.js"; import { WebCryptoService } from "../crypto/service.js"; import type { LegacyKeyConfig } from "../crypto/types.js"; -import type { IClientSqlEncryptedStore, IWebSqlEngine, SqlJsExecOutcome, SqlJsParams, SqlJsResultRow } from "./types.js"; +import type { IClientSqlEncryptedStore, IWebSqlEngine, SqlJsExecOutcome, SqlJsParams, SqlJsResultRow, WebSqlEngineConfig } from "./types.js"; const DEFAULT_SQL_CIPHER_CONFIG: IdbClientConfig = { database: "radroots-web-sql-cipher", @@ -18,17 +18,23 @@ interface IWebSqlEngineEncryptedStore extends IClientSqlEncryptedStore { } class WebSqlEngineEncryptedStore implements IWebSqlEngineEncryptedStore { - private readonly db_key: string; + private readonly store_key: string; private readonly store_id: string; private readonly crypto: WebCryptoService; - - constructor(key: string, cipher_config: IdbClientConfig | null) { - this.db_key = key; - this.store_id = `sql:${key}`; + private readonly db_name: string; + private readonly store_name: string; + private store: UseStore | null; + + constructor(config: WebSqlEngineConfig) { + this.store_key = config.store_key; + this.db_name = config.idb_config.database; + this.store_name = config.idb_config.store; + this.store = null; + this.store_id = `sql:${this.store_key}`; this.crypto = new WebCryptoService(); const legacy_config: LegacyKeyConfig = { - idb_config: cipher_config ?? DEFAULT_SQL_CIPHER_CONFIG, - key_name: `radroots.sql.${key}.aes-gcm.key`, + idb_config: config.cipher_config ?? DEFAULT_SQL_CIPHER_CONFIG, + key_name: `radroots.sql.${this.store_key}.aes-gcm.key`, iv_length: 12, algorithm: "AES-GCM" }; @@ -50,24 +56,30 @@ class WebSqlEngineEncryptedStore implements IWebSqlEngineEncryptedStore { return null; } + private get_store(): UseStore { + if (!this.store) this.store = createStore(this.db_name, this.store_name); + return this.store; + } + async load(): Promise<Uint8Array | null> { if (typeof indexedDB === "undefined") return null; - const data = await idb_get(this.db_key); + const data = await idb_get(this.store_key, this.get_store()); const bytes = this.as_bytes(data); if (!bytes) return null; const outcome = await this.crypto.decrypt_record(this.store_id, bytes); - if (outcome.reencrypted) await idb_set(this.db_key, outcome.reencrypted); + if (outcome.reencrypted) await idb_set(this.store_key, outcome.reencrypted, this.get_store()); return outcome.plaintext; } async save(bytes: Uint8Array): Promise<void> { if (typeof indexedDB === "undefined") return; const enc = await this.crypto.encrypt(this.store_id, bytes); - await idb_set(this.db_key, enc); + await idb_set(this.store_key, enc, this.get_store()); } async remove(): Promise<void> { - await idb_del(this.db_key); + if (typeof indexedDB === "undefined") return; + await idb_del(this.store_key, this.get_store()); } } @@ -85,15 +97,21 @@ export class WebSqlEngine implements IWebSqlEngine { this.store_id = store.get_store_id(); } - static async create(store_key: string, cipher_config: IdbClientConfig | null): Promise<WebSqlEngine> { + static async create(config: WebSqlEngineConfig): Promise<WebSqlEngine> { const sql = await init_sql_js({ locateFile: f => `/assets/${f}` }); - const kv = new WebSqlEngineEncryptedStore(store_key, cipher_config); - const existing = await kv.load(); + const store = new WebSqlEngineEncryptedStore(config); + const existing = await store.load(); const db = existing ? new sql.Database(existing) : new sql.Database(); - return new WebSqlEngine(sql, db, kv); + return new WebSqlEngine(sql, db, store); } async close(): Promise<void> { + if (this.save_timer) { + self.clearTimeout(this.save_timer); + this.save_timer = undefined; + const bytes = this.db.export(); + await this.store.save(bytes); + } this.db.close(); } diff --git a/client/src/tangle/bridge.ts b/client/src/tangle/bridge.ts @@ -1,13 +1,29 @@ import type { SqlJsParams, SqlJsValue } from "../sql/types.js"; import { WebSqlEngine } from "../sql/web.js"; +const is_record = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const is_sql_value = (value: unknown): value is SqlJsValue => { + if (value === null) return true; + if (typeof value === "string") return true; + if (typeof value === "number") return Number.isFinite(value); + return value instanceof Uint8Array; +}; + +const is_sql_value_array = (value: unknown): value is ReadonlyArray<SqlJsValue> => + Array.isArray(value) && value.every(is_sql_value); + +const is_sql_value_record = (value: unknown): value is Readonly<Record<string, SqlJsValue>> => + is_record(value) && Object.values(value).every(is_sql_value); + function parse_sql_params(params_json: string): SqlJsParams { const trimmed = params_json.trim(); if (!trimmed) return []; try { - const raw = JSON.parse(trimmed) as unknown; - if (Array.isArray(raw)) return raw as ReadonlyArray<SqlJsValue>; - if (raw && typeof raw === "object") return raw as Readonly<Record<string, SqlJsValue>>; + const raw = JSON.parse(trimmed); + if (is_sql_value_array(raw)) return raw; + if (is_sql_value_record(raw)) return raw; return []; } catch { return []; @@ -27,4 +43,4 @@ export function radroots_sql_install_bridges(engine: WebSqlEngine): void { return res; } }); -} -\ No newline at end of file +} diff --git a/client/src/tangle/error.ts b/client/src/tangle/error.ts @@ -1,4 +1,6 @@ export const cl_tangle_error = { + parse_failure: "error.client.tangle.parse_failure", + invalid_response: "error.client.tangle.invalid_response" } as const; export type ClientTangleError = keyof typeof cl_tangle_error; diff --git a/client/src/tangle/types.ts b/client/src/tangle/types.ts @@ -84,12 +84,12 @@ import type { TangleDatabaseBackup } from "./web.js"; export interface IClientTangleDatabase { init(): Promise<void>; - migration_state(): Promise<SqlJsMigrationState>; - reset(): Promise<SqlJsMigrationState>; - reinit(): Promise<SqlJsMigrationState>; + migration_state(): Promise<SqlJsMigrationState | IError<string>>; + reset(): Promise<SqlJsMigrationState | IError<string>>; + reinit(): Promise<SqlJsMigrationState | IError<string>>; get_store_key(): string; - export_backup(): Promise<TangleDatabaseBackup>; - import_backup(backup: TangleDatabaseBackup): Promise<void>; + export_backup(): Promise<TangleDatabaseBackup | IError<string>>; + import_backup(backup: TangleDatabaseBackup): Promise<void | IError<string>>; farm_create(opts: IFarmCreate): Promise<IFarmCreateResolve | IError<string>>; farm_find_one(opts: IFarmFindOne): Promise<IFarmFindOneResolve | IError<string>>; farm_find_many(opts?: IFarmFindMany): Promise<IFarmFindManyResolve | IError<string>>; @@ -133,4 +133,7 @@ export interface IClientTangleDatabase { trade_product_location_unset(opts: ITradeProductLocationRelation): Promise<ITradeProductLocationResolve | IError<string>>; trade_product_media_set(opts: ITradeProductMediaRelation): Promise<ITradeProductMediaResolve | IError<string>>; trade_product_media_unset(opts: ITradeProductMediaRelation): Promise<ITradeProductMediaResolve | IError<string>>; -} -\ No newline at end of file +} + +export interface IWebTangleDatabase extends IClientTangleDatabase { +} diff --git a/client/src/tangle/web.ts b/client/src/tangle/web.ts @@ -129,11 +129,12 @@ import init_wasm, { tangle_db_trade_product_update } from "@radroots/tangle-sql-wasm"; import type { IError } from "@radroots/types-bindings"; -import { type IdbClientConfig } from "@radroots/utils"; -import type { SqlJsMigrationRow, SqlJsMigrationState } from "../sql/types.js"; +import { err_msg, handle_err, type IdbClientConfig } from "@radroots/utils"; +import type { SqlJsMigrationRow, SqlJsMigrationState, WebSqlEngineConfig } from "../sql/types.js"; import { WebSqlEngine } from "../sql/web.js"; import { radroots_sql_install_bridges } from "./bridge.js"; -import type { IClientTangleDatabase } from "./types.js"; +import { cl_tangle_error } from "./error.js"; +import type { IWebTangleDatabase } from "./types.js"; export type TangleDatabaseBackup = { format_version: string; @@ -155,17 +156,76 @@ export type TangleDatabaseBackup = { }[]; }; -export class WebTangleDatabase implements IClientTangleDatabase { +export type WebTangleDatabaseConfig = { + store_key?: string; + idb_config?: IdbClientConfig; + cipher_config?: IdbClientConfig | null; +}; + +const is_record = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const is_sql_migration_row = (value: unknown): value is SqlJsMigrationRow => { + if (!is_record(value)) return false; + return typeof value.id === "number" + && Number.isFinite(value.id) + && typeof value.name === "string" + && typeof value.applied_at === "string"; +}; + +const is_sql_migration_row_list = (value: unknown): value is SqlJsMigrationRow[] => + Array.isArray(value) && value.every(is_sql_migration_row); + +const is_backup_schema_entry = (value: unknown): value is TangleDatabaseBackup["schema"][number] => { + if (!is_record(value)) return false; + if (typeof value.object_type !== "string") return false; + if (typeof value.name !== "string") return false; + if ("table_name" in value && typeof value.table_name !== "undefined" && typeof value.table_name !== "string") return false; + if ("sql" in value && typeof value.sql !== "undefined" && typeof value.sql !== "string") return false; + return true; +}; + +const is_backup_data_entry = (value: unknown): value is TangleDatabaseBackup["data"][number] => { + if (!is_record(value)) return false; + if (typeof value.name !== "string") return false; + if (!Array.isArray(value.rows)) return false; + if (!value.rows.every(is_record)) return false; + return true; +}; + +const is_backup_migration_entry = (value: unknown): value is TangleDatabaseBackup["migrations"][number] => { + if (!is_record(value)) return false; + return typeof value.name === "string" + && typeof value.up_sql === "string" + && typeof value.down_sql === "string"; +}; + +const is_tangle_database_backup = (value: unknown): value is TangleDatabaseBackup => { + if (!is_record(value)) return false; + if (typeof value.format_version !== "string") return false; + if (typeof value.tangle_sql_version !== "string") return false; + if (!Array.isArray(value.schema) || !value.schema.every(is_backup_schema_entry)) return false; + if (!Array.isArray(value.data) || !value.data.every(is_backup_data_entry)) return false; + if (!Array.isArray(value.migrations) || !value.migrations.every(is_backup_migration_entry)) return false; + return true; +}; + +const DEFAULT_TANGLE_STORE_KEY = "radroots-pwa-v1-tangle-db"; +const DEFAULT_TANGLE_IDB_CONFIG: IdbClientConfig = { + database: "radroots-pwa-v1-tangle", + store: "default" +}; + +export class WebTangleDatabase implements IWebTangleDatabase { private engine: WebSqlEngine | null = null; - private readonly store_key: string = "radroots.tangle-db-v1.key"; - private cipher_config: IdbClientConfig | null = null; + private readonly store_key: string; + private readonly idb_config: IdbClientConfig; + private readonly cipher_config: IdbClientConfig | null; - constructor(config?: { - store_key?: string; - cipher?: IdbClientConfig; - }) { - if (config?.store_key) this.store_key = config?.store_key; - if (config?.cipher) this.cipher_config = config.cipher; + constructor(config?: WebTangleDatabaseConfig) { + this.store_key = config?.store_key ?? DEFAULT_TANGLE_STORE_KEY; + this.idb_config = config?.idb_config ?? DEFAULT_TANGLE_IDB_CONFIG; + this.cipher_config = config?.cipher_config ?? null; } get_store_key(): string { @@ -176,51 +236,93 @@ export class WebTangleDatabase implements IClientTangleDatabase { return JSON.stringify(opts); } - private deserialize<T>(data: string): T { - return JSON.parse(data); + private deserialize<T>(data: string): T | IError<string> { + try { + return JSON.parse(data); + } catch { + return err_msg(cl_tangle_error.parse_failure); + } + } + + private get_engine_config(): WebSqlEngineConfig { + return { + store_key: this.store_key, + idb_config: this.idb_config, + cipher_config: this.cipher_config + }; } async init(): Promise<void> { if (this.engine) return; await init_wasm(); - this.engine = await WebSqlEngine.create(this.store_key, this.cipher_config); + this.engine = await WebSqlEngine.create(this.get_engine_config()); radroots_sql_install_bridges(this.engine); tangle_db_run_migrations(); } - async migration_state(): Promise<SqlJsMigrationState> { - const res = await query_sql("select id, name, applied_at from __migrations order by id asc", "[]"); - const rows = (typeof res === "string" ? JSON.parse(res) : res) as SqlJsMigrationRow[]; - const names = rows.map((r) => r.name); - return { applied_names: names, applied_count: names.length }; + async migration_state(): Promise<SqlJsMigrationState | IError<string>> { + try { + const res = await query_sql("select id, name, applied_at from __migrations order by id asc", "[]"); + let parsed: unknown = res; + if (typeof res === "string") { + try { + parsed = JSON.parse(res); + } catch { + return err_msg(cl_tangle_error.parse_failure); + } + } + if (!is_sql_migration_row_list(parsed)) return err_msg(cl_tangle_error.invalid_response); + const names = parsed.map((row) => row.name); + return { applied_names: names, applied_count: names.length }; + } catch (e) { + return handle_err(e); + } } - async reset(): Promise<SqlJsMigrationState> { + async reset(): Promise<SqlJsMigrationState | IError<string>> { tangle_db_reset_database(); tangle_db_run_migrations(); return this.migration_state(); } - async reinit(): Promise<SqlJsMigrationState> { + async reinit(): Promise<SqlJsMigrationState | IError<string>> { if (this.engine) { await this.engine.purge_storage(); await this.engine.close(); } - this.engine = await WebSqlEngine.create(this.store_key, this.cipher_config); + this.engine = await WebSqlEngine.create(this.get_engine_config()); radroots_sql_install_bridges(this.engine); tangle_db_run_migrations(); return this.migration_state(); } - async export_backup(): Promise<TangleDatabaseBackup> { - await this.init(); - const res = await tangle_db_export_backup(); - return (typeof res === "string" ? JSON.parse(res) : res) as TangleDatabaseBackup; + async export_backup(): Promise<TangleDatabaseBackup | IError<string>> { + try { + await this.init(); + const res = await tangle_db_export_backup(); + let parsed: unknown = res; + if (typeof res === "string") { + try { + parsed = JSON.parse(res); + } catch { + return err_msg(cl_tangle_error.parse_failure); + } + } + if (!is_tangle_database_backup(parsed)) return err_msg(cl_tangle_error.invalid_response); + return parsed; + } catch (e) { + return handle_err(e); + } } - async import_backup(backup: TangleDatabaseBackup): Promise<void> { - await this.init(); - tangle_db_import_backup(this.serialize(backup)); + async import_backup(backup: TangleDatabaseBackup): Promise<void | IError<string>> { + try { + await this.init(); + tangle_db_import_backup(this.serialize(backup)); + return; + } catch (e) { + return handle_err(e); + } } async farm_create(opts: IFarmCreate): Promise<IFarmCreateResolve | IError<string>> {