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