web_lib

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

commit bd8fba32906b776cad81fb5165a570be011f2f15
parent ff686a496ef787f2735e87049b1729cb02bf6e10
Author: triesap <triesap@radroots.dev>
Date:   Sat, 27 Dec 2025 15:04:27 +0000

tangle: harden web db lifecycle and inactive pane focus

- Add runtime/init failure error keys for tangle web db
- Gate wasm init with singleton promise and runtime checks
- Add close() + ensure_ready() to serialize init and reset state on failure
- Make inactive view panes inert and blur focused descendants on deactivate

Diffstat:
Mapps-lib/src/lib/components/view-pane.svelte | 15++++++++++++++-
Mclient/src/tangle/error.ts | 4+++-
Mclient/src/tangle/types.ts | 4++--
Mclient/src/tangle/web.ts | 138++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
4 files changed, 139 insertions(+), 22 deletions(-)

diff --git a/apps-lib/src/lib/components/view-pane.svelte b/apps-lib/src/lib/components/view-pane.svelte @@ -199,14 +199,27 @@ const style = $derived( `position: ${position}; inset: ${inset}; width: ${width}; height: ${height}; min-height: ${min_height}; display: ${display}; flex-direction: ${direction}; align-items: ${align}; justify-content: ${justify}; gap: ${gap}; padding: ${padding}; overflow: ${overflow}; background: ${background}; opacity: ${opacity}; transform: ${transform}; filter: ${filter}; transition: ${transition}; pointer-events: ${pointer_events}; z-index: ${z_index}; box-sizing: border-box; will-change: transform, opacity, filter; backface-visibility: hidden; transform-style: preserve-3d; ${basis.style ?? ""} ${is_active ? basis.style_active ?? "" : basis.style_inactive ?? ""}`, ); + + let pane_el: HTMLDivElement | null = $state(null); + + $effect(() => { + if (is_active) return; + if (typeof document === "undefined") return; + if (!pane_el) return; + const active_el = document.activeElement; + if (!active_el) return; + if (!pane_el.contains(active_el)) return; + (active_el as HTMLElement).blur(); + }); </script> <div + bind:this={pane_el} data-view={basis.view} style={style} role={basis.role ?? undefined} aria-label={basis.aria_label ?? undefined} - aria-hidden={!is_active} + inert={!is_active} > {@render children()} </div> diff --git a/client/src/tangle/error.ts b/client/src/tangle/error.ts @@ -1,6 +1,8 @@ export const cl_tangle_error = { + init_failure: "error.client.tangle.init_failure", parse_failure: "error.client.tangle.parse_failure", - invalid_response: "error.client.tangle.invalid_response" + invalid_response: "error.client.tangle.invalid_response", + runtime_unavailable: "error.client.tangle.runtime_unavailable" } 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,6 +84,7 @@ import type { TangleDatabaseBackup } from "./web.js"; export interface IClientTangleDatabase { init(): Promise<void>; + close(): Promise<void>; migration_state(): Promise<SqlJsMigrationState | IError<string>>; reset(): Promise<SqlJsMigrationState | IError<string>>; reinit(): Promise<SqlJsMigrationState | IError<string>>; @@ -136,4 +137,4 @@ export interface IClientTangleDatabase { } export interface IWebTangleDatabase extends IClientTangleDatabase { -} -\ No newline at end of file +} diff --git a/client/src/tangle/web.ts b/client/src/tangle/web.ts @@ -213,12 +213,32 @@ const is_tangle_database_backup = (value: unknown): value is TangleDatabaseBacku const DEFAULT_TANGLE_STORE_KEY = "radroots-pwa-v1-tangle-db"; const DEFAULT_TANGLE_IDB_CONFIG: IdbClientConfig = IDB_CONFIG_TANGLE; +let wasm_init_promise: Promise<void> | null = null; + +const runtime_available = (): boolean => { + return typeof window !== "undefined" || typeof self !== "undefined"; +}; + +const wasm_init_once = async (): Promise<void> => { + if (!wasm_init_promise) { + wasm_init_promise = (async () => { + await init_wasm(); + })(); + } + try { + await wasm_init_promise; + } catch (e) { + wasm_init_promise = null; + throw e; + } +}; export class WebTangleDatabase implements IWebTangleDatabase { private engine: WebSqlEngine | null = null; private readonly store_key: string; private readonly idb_config: IdbClientConfig; private readonly cipher_config: IdbClientConfig | null; + private init_promise: Promise<void> | null = null; constructor(config?: WebTangleDatabaseConfig) { this.store_key = config?.store_key ?? DEFAULT_TANGLE_STORE_KEY; @@ -250,16 +270,40 @@ export class WebTangleDatabase implements IWebTangleDatabase { }; } + private async ensure_ready(): Promise<void> { + await this.init(); + if (!this.engine) throw new Error(cl_tangle_error.init_failure); + } + async init(): Promise<void> { if (this.engine) return; - await init_wasm(); - this.engine = await WebSqlEngine.create(this.get_engine_config()); - radroots_sql_install_bridges(this.engine); - tangle_db_run_migrations(); + if (!runtime_available()) throw new Error(cl_tangle_error.runtime_unavailable); + if (!this.init_promise) { + this.init_promise = (async () => { + await wasm_init_once(); + this.engine = await WebSqlEngine.create(this.get_engine_config()); + radroots_sql_install_bridges(this.engine); + tangle_db_run_migrations(); + })(); + } + try { + await this.init_promise; + } catch (e) { + this.engine = null; + this.init_promise = null; + throw e; + } + } + + async close(): Promise<void> { + if (this.engine) await this.engine.close(); + this.engine = null; + this.init_promise = null; } async migration_state(): Promise<SqlJsMigrationState | IError<string>> { try { + await this.ensure_ready(); const res = await query_sql("select id, name, applied_at from __migrations order by id asc", "[]"); let parsed: unknown = res; if (typeof res === "string") { @@ -278,25 +322,35 @@ export class WebTangleDatabase implements IWebTangleDatabase { } async reset(): Promise<SqlJsMigrationState | IError<string>> { - tangle_db_reset_database(); - tangle_db_run_migrations(); - return this.migration_state(); + try { + await this.ensure_ready(); + tangle_db_reset_database(); + tangle_db_run_migrations(); + return this.migration_state(); + } catch (e) { + return handle_err(e); + } } async reinit(): Promise<SqlJsMigrationState | IError<string>> { - if (this.engine) { - await this.engine.purge_storage(); - await this.engine.close(); + try { + await this.ensure_ready(); + if (this.engine) { + await this.engine.purge_storage(); + await this.engine.close(); + } + this.engine = await WebSqlEngine.create(this.get_engine_config()); + radroots_sql_install_bridges(this.engine); + tangle_db_run_migrations(); + return this.migration_state(); + } catch (e) { + return handle_err(e); } - 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 | IError<string>> { try { - await this.init(); + await this.ensure_ready(); const res = await tangle_db_export_backup(); let parsed: unknown = res; if (typeof res === "string") { @@ -315,7 +369,7 @@ export class WebTangleDatabase implements IWebTangleDatabase { async import_backup(backup: TangleDatabaseBackup): Promise<void | IError<string>> { try { - await this.init(); + await this.ensure_ready(); tangle_db_import_backup(this.serialize(backup)); return; } catch (e) { @@ -324,218 +378,267 @@ export class WebTangleDatabase implements IWebTangleDatabase { } async farm_create(opts: IFarmCreate): Promise<IFarmCreateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_farm_create(this.serialize(opts)); return this.deserialize<IFarmCreateResolve>(res); } async farm_find_one(opts: IFarmFindOne): Promise<IFarmFindOneResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_farm_find_one(this.serialize(opts)); return this.deserialize<IFarmFindOneResolve>(res); } async farm_find_many(opts?: IFarmFindMany): Promise<IFarmFindManyResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_farm_find_many(this.serialize(opts ?? {})); return this.deserialize<IFarmFindManyResolve>(res); } async farm_delete(opts: IFarmDelete): Promise<IFarmDeleteResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_farm_delete(this.serialize(opts)); return this.deserialize<IFarmDeleteResolve>(res); } async farm_update(opts: IFarmUpdate): Promise<IFarmUpdateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_farm_update(this.serialize(opts)); return this.deserialize<IFarmUpdateResolve>(res); } async location_gcs_create(opts: ILocationGcsCreate): Promise<ILocationGcsCreateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_location_gcs_create(this.serialize(opts)); return this.deserialize<ILocationGcsCreateResolve>(res); } async location_gcs_find_one(opts: ILocationGcsFindOne): Promise<ILocationGcsFindOneResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_location_gcs_find_one(this.serialize(opts)); return this.deserialize<ILocationGcsFindOneResolve>(res); } async location_gcs_find_many(opts?: ILocationGcsFindMany): Promise<ILocationGcsFindManyResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_location_gcs_find_many(this.serialize(opts ?? {})); return this.deserialize<ILocationGcsFindManyResolve>(res); } async location_gcs_delete(opts: ILocationGcsDelete): Promise<ILocationGcsDeleteResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_location_gcs_delete(this.serialize(opts)); return this.deserialize<ILocationGcsDeleteResolve>(res); } async location_gcs_update(opts: ILocationGcsUpdate): Promise<ILocationGcsUpdateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_location_gcs_update(this.serialize(opts)); return this.deserialize<ILocationGcsUpdateResolve>(res); } async log_error_create(opts: ILogErrorCreate): Promise<ILogErrorCreateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_log_error_create(this.serialize(opts)); return this.deserialize<ILogErrorCreateResolve>(res); } async log_error_find_one(opts: ILogErrorFindOne): Promise<ILogErrorFindOneResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_log_error_find_one(this.serialize(opts)); return this.deserialize<ILogErrorFindOneResolve>(res); } async log_error_find_many(opts?: ILogErrorFindMany): Promise<ILogErrorFindManyResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_log_error_find_many(this.serialize(opts ?? {})); return this.deserialize<ILogErrorFindManyResolve>(res); } async log_error_delete(opts: ILogErrorDelete): Promise<ILogErrorDeleteResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_log_error_delete(this.serialize(opts)); return this.deserialize<ILogErrorDeleteResolve>(res); } async log_error_update(opts: ILogErrorUpdate): Promise<ILogErrorUpdateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_log_error_update(this.serialize(opts)); return this.deserialize<ILogErrorUpdateResolve>(res); } async media_image_create(opts: IMediaImageCreate): Promise<IMediaImageCreateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_media_image_create(this.serialize(opts)); return this.deserialize<IMediaImageCreateResolve>(res); } async media_image_find_one(opts: IMediaImageFindOne): Promise<IMediaImageFindOneResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_media_image_find_one(this.serialize(opts)); return this.deserialize<IMediaImageFindOneResolve>(res); } async media_image_find_many(opts?: IMediaImageFindMany): Promise<IMediaImageFindManyResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_media_image_find_many(this.serialize(opts ?? {})); return this.deserialize<IMediaImageFindManyResolve>(res); } async media_image_delete(opts: IMediaImageDelete): Promise<IMediaImageDeleteResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_media_image_delete(this.serialize(opts)); return this.deserialize<IMediaImageDeleteResolve>(res); } async media_image_update(opts: IMediaImageUpdate): Promise<IMediaImageUpdateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_media_image_update(this.serialize(opts)); return this.deserialize<IMediaImageUpdateResolve>(res); } async nostr_profile_create(opts: INostrProfileCreate): Promise<INostrProfileCreateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_profile_create(this.serialize(opts)); return this.deserialize<INostrProfileCreateResolve>(res); } async nostr_profile_find_one(opts: INostrProfileFindOne): Promise<INostrProfileFindOneResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_profile_find_one(this.serialize(opts)); return this.deserialize<INostrProfileFindOneResolve>(res); } async nostr_profile_find_many(opts?: INostrProfileFindMany): Promise<INostrProfileFindManyResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_profile_find_many(this.serialize(opts ?? {})); return this.deserialize<INostrProfileFindManyResolve>(res); } async nostr_profile_delete(opts: INostrProfileDelete): Promise<INostrProfileDeleteResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_profile_delete(this.serialize(opts)); return this.deserialize<INostrProfileDeleteResolve>(res); } async nostr_profile_update(opts: INostrProfileUpdate): Promise<INostrProfileUpdateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_profile_update(this.serialize(opts)); return this.deserialize<INostrProfileUpdateResolve>(res); } async nostr_relay_create(opts: INostrRelayCreate): Promise<INostrRelayCreateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_relay_create(this.serialize(opts)); return this.deserialize<INostrRelayCreateResolve>(res); } async nostr_relay_find_one(opts: INostrRelayFindOne): Promise<INostrRelayFindOneResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_relay_find_one(this.serialize(opts)); return this.deserialize<INostrRelayFindOneResolve>(res); } async nostr_relay_find_many(opts?: INostrRelayFindMany): Promise<INostrRelayFindManyResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_relay_find_many(this.serialize(opts ?? {})); return this.deserialize<INostrRelayFindManyResolve>(res); } async nostr_relay_delete(opts: INostrRelayDelete): Promise<INostrRelayDeleteResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_relay_delete(this.serialize(opts)); return this.deserialize<INostrRelayDeleteResolve>(res); } async nostr_relay_update(opts: INostrRelayUpdate): Promise<INostrRelayUpdateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_relay_update(this.serialize(opts)); return this.deserialize<INostrRelayUpdateResolve>(res); } async trade_product_create(opts: ITradeProductCreate): Promise<ITradeProductCreateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_trade_product_create(this.serialize(opts)); return this.deserialize<ITradeProductCreateResolve>(res); } async trade_product_find_one(opts: ITradeProductFindOne): Promise<ITradeProductFindOneResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_trade_product_find_one(this.serialize(opts)); return this.deserialize<ITradeProductFindOneResolve>(res); } async trade_product_find_many(opts?: ITradeProductFindMany): Promise<ITradeProductFindManyResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_trade_product_find_many(this.serialize(opts ?? {})); return this.deserialize<ITradeProductFindManyResolve>(res); } async trade_product_delete(opts: ITradeProductDelete): Promise<ITradeProductDeleteResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_trade_product_delete(this.serialize(opts)); return this.deserialize<ITradeProductDeleteResolve>(res); } async trade_product_update(opts: ITradeProductUpdate): Promise<ITradeProductUpdateResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_trade_product_update(this.serialize(opts)); return this.deserialize<ITradeProductUpdateResolve>(res); } async farm_location_set(opts: IFarmLocationRelation): Promise<IFarmLocationResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_farm_location_set(this.serialize(opts)); return this.deserialize<IFarmLocationResolve>(res); } async farm_location_unset(opts: IFarmLocationRelation): Promise<IFarmLocationResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_farm_location_unset(this.serialize(opts)); return this.deserialize<IFarmLocationResolve>(res); } async nostr_profile_relay_set(opts: INostrProfileRelayRelation): Promise<INostrProfileRelayResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_profile_relay_set(this.serialize(opts)); return this.deserialize<INostrProfileRelayResolve>(res); } async nostr_profile_relay_unset(opts: INostrProfileRelayRelation): Promise<INostrProfileRelayResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_nostr_profile_relay_unset(this.serialize(opts)); return this.deserialize<INostrProfileRelayResolve>(res); } async trade_product_location_set(opts: ITradeProductLocationRelation): Promise<ITradeProductLocationResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_trade_product_location_set(this.serialize(opts)); return this.deserialize<ITradeProductLocationResolve>(res); } async trade_product_location_unset(opts: ITradeProductLocationRelation): Promise<ITradeProductLocationResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_trade_product_location_unset(this.serialize(opts)); return this.deserialize<ITradeProductLocationResolve>(res); } async trade_product_media_set(opts: ITradeProductMediaRelation): Promise<ITradeProductMediaResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_trade_product_media_set(this.serialize(opts)); return this.deserialize<ITradeProductMediaResolve>(res); } async trade_product_media_unset(opts: ITradeProductMediaRelation): Promise<ITradeProductMediaResolve | IError<string>> { + await this.ensure_ready(); const res = await tangle_db_trade_product_media_unset(this.serialize(opts)); return this.deserialize<ITradeProductMediaResolve>(res); } -} -\ No newline at end of file +} + +export const web_tangle_database_create = async (config?: WebTangleDatabaseConfig): Promise<WebTangleDatabase> => { + const db = new WebTangleDatabase(config); + await db.init(); + return db; +};