@bb-labs/local-db 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,179 @@
1
+ ## Introduction
2
+
3
+ A tiny **client-side React hook** for **persistent state**, backed by **IndexedDB**, with:
4
+
5
+ - ✅ Zod-based schema validation
6
+ - ✅ Cross-tab synchronization (BroadcastChannel)
7
+ - ✅ Repair of invalid persisted data
8
+ - ✅ SSR-safe hydration (`value: undefined` while loading)
9
+ - ✅ No external state library required
10
+ - ✅ Per-hook database & store targeting
11
+ - ✅ Deduplicated shared store per key (`useSyncExternalStore`)
12
+
13
+ Perfect for saving **user settings, UI state, drafts, local preferences**, or anything that should persist across reloads and sync across tabs.
14
+
15
+ ---
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @bigbang-sdk/local-db
21
+ # or
22
+ yarn add @bigbang-sdk/local-db
23
+ # or
24
+ pnpm add @bigbang-sdk/local-db
25
+ # or
26
+ bun add @bigbang-sdk/local-db
27
+ ```
28
+
29
+ Requires React 18+.
30
+
31
+ ---
32
+
33
+ ## Quick Example
34
+
35
+ ```tsx
36
+ import { useLocalDb } from "@bigbang-sdk/local-db";
37
+ import { z } from "zod";
38
+
39
+ const SettingsSchema = z.object({
40
+ fontSize: z.number().min(10).max(32),
41
+ });
42
+
43
+ export default function SettingsPanel() {
44
+ const { value, setValue } = useLocalDb({
45
+ key: "settings",
46
+ schema: SettingsSchema,
47
+ initialValue: { fontSize: 16 },
48
+ });
49
+
50
+ if (value === undefined) return <div>Loading…</div>; // hydrating
51
+
52
+ return (
53
+ <div>
54
+ <div>Font size: {value.fontSize}px</div>
55
+ <button onClick={() => setValue({ fontSize: value.fontSize + 1 })}>Increase</button>
56
+ <button onClick={() => setValue({ fontSize: value.fontSize - 1 })}>Decrease</button>
57
+ </div>
58
+ );
59
+ }
60
+ ```
61
+
62
+ ---
63
+
64
+ ## API
65
+
66
+ ### `useLocalDb(options)`
67
+
68
+ | Option | Type | Default | Description |
69
+ | -------------- | ---------------- | ------------ | --------------------------------------------------------------------------- |
70
+ | `key` | `string` | **required** | The logical key used in IndexedDB & cross-tab sync. |
71
+ | `schema` | `z.ZodSchema<T>` | **required** | Validates and repairs stored values. |
72
+ | `initialValue` | `T \| null` | `null` | Used if no data exists or persisted data is invalid. Captured on first use. |
73
+ | `dbName` | `string` | `"local-db"` | Optional database name override. |
74
+ | `storeName` | `string` | `"local-db"` | Optional object store name override. |
75
+
76
+ ### Returns
77
+
78
+ ```ts
79
+ {
80
+ value: T | null | undefined;
81
+ setValue(next: T | null): void;
82
+ }
83
+ ```
84
+
85
+ - **`value === undefined`** → still hydrating from IndexedDB
86
+ - **`value === null`** → no stored value
87
+ - **`value === T`** → validated, ready to use
88
+
89
+ ---
90
+
91
+ ## Cross-Tab Sync
92
+
93
+ Updates propagate instantly across open browser tabs/windows:
94
+
95
+ - Automatically via **BroadcastChannel**
96
+ - Repairs apply consistently across all tabs
97
+ - No unnecessary re-renders (deep-equality guarded)
98
+
99
+ ---
100
+
101
+ ## Validation & Repair
102
+
103
+ Every read and write is validated using your Zod schema.
104
+
105
+ If data is corrupted or has an outdated shape:
106
+
107
+ ```diff
108
+ Persisted Data → schema.safeParse() → ✅ valid → used
109
+ Persisted Data → schema.safeParse() → ❌ invalid → repaired to initialValue
110
+ ```
111
+
112
+ The repaired value is **automatically written back** to IndexedDB.
113
+
114
+ ---
115
+
116
+ ## SSR Behavior (Next.js Friendly)
117
+
118
+ This hook is designed for **client components**.
119
+
120
+ - It does **not** access `window`, `indexedDB` or `BroadcastChannel` on the server.
121
+ - `value` begins as **`undefined`** so server and client HTML always match.
122
+ - Hydration + IDB read happens **only in the browser**.
123
+
124
+ ---
125
+
126
+ ## Using Multiple Databases / Stores
127
+
128
+ ```ts
129
+ const { value, setValue } = useLocalDb({
130
+ key: "draft",
131
+ schema: DraftSchema,
132
+ initialValue: null,
133
+ dbName: "editor-db",
134
+ storeName: "drafts",
135
+ });
136
+ ```
137
+
138
+ Tabs will **only sync if both `dbName` and `storeName` match**.
139
+
140
+ ---
141
+
142
+ ## Performance
143
+
144
+ - No-op writes are skipped using deep-equality.
145
+ - Broadcasts are only emitted on real changes.
146
+ - Hydration is lazy-per-key with `useSyncExternalStore`.
147
+ - UI remains responsive even if IndexedDB is slow.
148
+
149
+ ### If you expect _high-frequency updates_ (e.g., live typing):
150
+
151
+ Consider batching or debouncing writes:
152
+
153
+ ```ts
154
+ setValue(next);
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Why not localStorage?
160
+
161
+ | Feature | localStorage | @bigbang-sdk/local-db |
162
+ | ------------------------ | ------------------ | --------------------------------- |
163
+ | Large data | ❌ slow / capped | ✅ stores MBs efficiently |
164
+ | Cross-tab sync | ⚠️ yes, but coarse | ✅ fine-grained, structured clone |
165
+ | Validation | ❌ no | ✅ Zod |
166
+ | React state sync | ❌ manual | ✅ automatic |
167
+ | Safe corruption handling | ❌ none | ✅ self-repair |
168
+
169
+ ---
170
+
171
+ ## License
172
+
173
+ MIT
174
+
175
+ ---
176
+
177
+ ## Contributing
178
+
179
+ PRs welcome!
@@ -0,0 +1,4 @@
1
+ export declare function getBroadcastChannel(opts?: {
2
+ dbName?: string;
3
+ storeName?: string;
4
+ }): BroadcastChannel | null;
@@ -0,0 +1,28 @@
1
+ import { DEFAULT_DB, DEFAULT_STORE } from "./idb";
2
+ const bcCache = new Map();
3
+ function nsKey(dbName, storeName) {
4
+ return `${dbName}/${storeName}`;
5
+ }
6
+ function channelName(dbName, storeName) {
7
+ // Namespaced channel; safe across multiple store pairs
8
+ return `local-db:${dbName}:${storeName}`;
9
+ }
10
+ export function getBroadcastChannel(opts) {
11
+ if (typeof window === "undefined")
12
+ return null;
13
+ const db = opts?.dbName ?? DEFAULT_DB;
14
+ const st = opts?.storeName ?? DEFAULT_STORE;
15
+ const k = nsKey(db, st);
16
+ const cached = bcCache.get(k);
17
+ if (cached !== undefined)
18
+ return cached;
19
+ try {
20
+ const bc = new BroadcastChannel(channelName(db, st));
21
+ bcCache.set(k, bc);
22
+ return bc;
23
+ }
24
+ catch {
25
+ bcCache.set(k, null);
26
+ return null;
27
+ }
28
+ }
@@ -0,0 +1,12 @@
1
+ import { type UseStore } from "idb-keyval";
2
+ export declare const DEFAULT_DB = "local-db";
3
+ export declare const DEFAULT_STORE = "local-db";
4
+ export declare function getOrCreateIdbStore(dbName?: string, storeName?: string): UseStore | null;
5
+ export declare function idbGet<T>(key: string, opts?: {
6
+ dbName?: string;
7
+ storeName?: string;
8
+ }): Promise<T | undefined>;
9
+ export declare function idbSet<T>(key: string, value: T | null, opts?: {
10
+ dbName?: string;
11
+ storeName?: string;
12
+ }): Promise<void>;
@@ -0,0 +1,45 @@
1
+ import { createStore, del, get, set } from "idb-keyval";
2
+ export const DEFAULT_DB = "local-db";
3
+ export const DEFAULT_STORE = "local-db";
4
+ const storeCache = new Map();
5
+ function keyFor(dbName, storeName) {
6
+ return `${dbName}/${storeName}`;
7
+ }
8
+ export function getOrCreateIdbStore(dbName = DEFAULT_DB, storeName = DEFAULT_STORE) {
9
+ if (typeof indexedDB === "undefined")
10
+ return null;
11
+ const k = keyFor(dbName, storeName);
12
+ if (storeCache.has(k))
13
+ return storeCache.get(k);
14
+ try {
15
+ const s = createStore(dbName, storeName);
16
+ storeCache.set(k, s);
17
+ return s;
18
+ }
19
+ catch {
20
+ storeCache.set(k, null);
21
+ return null;
22
+ }
23
+ }
24
+ export async function idbGet(key, opts) {
25
+ const s = getOrCreateIdbStore(opts?.dbName, opts?.storeName);
26
+ if (!s)
27
+ return undefined;
28
+ try {
29
+ return await get(key, s);
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
35
+ export async function idbSet(key, value, opts) {
36
+ const s = getOrCreateIdbStore(opts?.dbName, opts?.storeName);
37
+ if (!s)
38
+ return;
39
+ try {
40
+ value === null ? await del(key, s) : await set(key, value, s);
41
+ }
42
+ catch {
43
+ /* swallow to keep UI responsive */
44
+ }
45
+ }
@@ -0,0 +1,17 @@
1
+ export type StoreState<T> = {
2
+ value: T | null | undefined;
3
+ repairTo: T | null;
4
+ hydrated: boolean;
5
+ listeners: Set<() => void>;
6
+ };
7
+ type Ns = {
8
+ dbName?: string;
9
+ storeName?: string;
10
+ };
11
+ type T_GetStore<T> = StoreState<T> & {
12
+ hydrate: () => void;
13
+ setValue: (next: T | null) => void;
14
+ isEqual: (a: unknown, b: unknown) => boolean;
15
+ };
16
+ export declare function getStore<T>(key: string, initial: T | null, isValid: (v: T | null) => boolean, isEqual: (a: T | null | undefined, b: T | null | undefined) => boolean, ns?: Ns): T_GetStore<T>;
17
+ export {};
@@ -0,0 +1,87 @@
1
+ import { getBroadcastChannel } from "./broadcast";
2
+ import { DEFAULT_DB, DEFAULT_STORE, idbGet, idbSet } from "./idb";
3
+ // Composite registry key: namespace + logical key
4
+ function regKey(key, ns) {
5
+ const db = ns.dbName ?? DEFAULT_DB;
6
+ const st = ns.storeName ?? DEFAULT_STORE;
7
+ return `${db}/${st}|${key}`;
8
+ }
9
+ const stores = new Map();
10
+ export function getStore(key, initial, isValid, isEqual, ns) {
11
+ const k = regKey(key, ns ?? {});
12
+ if (stores.has(k))
13
+ return stores.get(k);
14
+ const state = {
15
+ value: undefined,
16
+ repairTo: initial,
17
+ hydrated: false,
18
+ listeners: new Set(),
19
+ };
20
+ const notify = () => {
21
+ for (const fn of state.listeners)
22
+ fn();
23
+ };
24
+ const persist = async (val) => {
25
+ await idbSet(key, val, ns);
26
+ };
27
+ const setValue = (next) => {
28
+ const safe = isValid(next) ? next : state.repairTo;
29
+ if (isEqual(state.value, safe))
30
+ return;
31
+ state.value = safe;
32
+ (async () => {
33
+ await persist(safe);
34
+ getBroadcastChannel(ns)?.postMessage({
35
+ key,
36
+ value: safe,
37
+ removed: safe === null,
38
+ });
39
+ })();
40
+ notify();
41
+ };
42
+ const hydrate = () => {
43
+ if (state.hydrated)
44
+ return;
45
+ state.hydrated = true;
46
+ // client-only work; on server this early return avoids starting async tasks
47
+ if (typeof window === "undefined")
48
+ return;
49
+ (async () => {
50
+ const persisted = (await idbGet(key, ns)) ?? state.repairTo;
51
+ const finalVal = isValid(persisted) ? persisted : state.repairTo;
52
+ state.value = finalVal;
53
+ if (!isEqual(persisted, finalVal)) {
54
+ try {
55
+ await persist(finalVal);
56
+ }
57
+ catch {
58
+ /* noop */
59
+ }
60
+ }
61
+ notify();
62
+ })();
63
+ const bc = getBroadcastChannel(ns);
64
+ if (bc) {
65
+ const handler = (event) => {
66
+ const d = event?.data;
67
+ if (!d || d.key !== key)
68
+ return;
69
+ const incoming = d.removed ? state.repairTo : d.value;
70
+ const safe = isValid(incoming) ? incoming : state.repairTo;
71
+ if (!isEqual(state.value, safe)) {
72
+ state.value = safe;
73
+ notify();
74
+ }
75
+ };
76
+ bc.addEventListener("message", handler);
77
+ // no teardown needed for app lifetime; add resetStores() if you need HMR/test cleanup
78
+ }
79
+ };
80
+ const store = Object.assign(state, {
81
+ hydrate,
82
+ setValue: (n) => setValue(n),
83
+ isEqual: (a, b) => isEqual(a, b),
84
+ });
85
+ stores.set(k, store);
86
+ return store;
87
+ }
@@ -0,0 +1,15 @@
1
+ import type { z } from "zod";
2
+ export type T_UseLocalDb<T> = {
3
+ key: string;
4
+ schema: z.ZodSchema<T>;
5
+ initialValue?: T | null;
6
+ /** Optional: choose a specific IndexedDB database & object store */
7
+ dbName?: string;
8
+ storeName?: string;
9
+ };
10
+ export type UseLocalDbReturn<T> = {
11
+ value: T | null | undefined;
12
+ setValue: (next: T | null) => void;
13
+ };
14
+ export declare const validateWithSchema: <T>(schema: z.ZodSchema<T>, val: T | null) => boolean;
15
+ export declare function useLocalDb<T>({ key, schema, initialValue, dbName, storeName }: T_UseLocalDb<T>): UseLocalDbReturn<T>;
@@ -0,0 +1,29 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
2
+ import { getStore } from "./components/store-registry";
3
+ import { isDeepEqual } from "@bb-labs/deep-equal";
4
+ export const validateWithSchema = (schema, val) => {
5
+ return val === null ? true : schema.safeParse(val).success;
6
+ };
7
+ export function useLocalDb({ key, schema, initialValue = null, dbName, storeName }) {
8
+ const initialRef = useRef(initialValue);
9
+ const ns = useMemo(() => ({ dbName, storeName }), [dbName, storeName]);
10
+ const isValid = useCallback((val) => validateWithSchema(schema, val), [schema]);
11
+ const isEqual = isDeepEqual;
12
+ const store = useMemo(() => getStore(key, initialRef.current, isValid, isEqual, ns), [key, isValid, isEqual, ns]);
13
+ const subscribe = useCallback((listener) => {
14
+ store.listeners.add(listener);
15
+ return () => store.listeners.delete(listener);
16
+ }, [store]);
17
+ const getSnapshot = useCallback(() => store.value, [store]);
18
+ const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
19
+ const setValue = useCallback((next) => {
20
+ if (store.isEqual(value, next))
21
+ return;
22
+ const safe = isValid(next) ? next : store.repairTo;
23
+ store.setValue(safe);
24
+ }, [store, value, isValid]);
25
+ useEffect(() => {
26
+ store.hydrate();
27
+ }, [store]);
28
+ return { value, setValue };
29
+ }
@@ -0,0 +1 @@
1
+ export * from "./app/use-local-db";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./app/use-local-db";
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@bb-labs/local-db",
3
+ "version": "0.0.1",
4
+ "description": "A library for local database using IndexedDB and Zod for React",
5
+ "homepage": "https://github.com/beepbop-labs/local-db",
6
+ "keywords": [
7
+ "local-db",
8
+ "indexeddb",
9
+ "keyval",
10
+ "zod",
11
+ "react",
12
+ "javascript",
13
+ "typescript",
14
+ "js",
15
+ "ts"
16
+ ],
17
+ "author": "Beepbop",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/beepbop-labs/local-db.git"
22
+ },
23
+ "main": "dist/index.js",
24
+ "files": [
25
+ "dist",
26
+ "README.md"
27
+ ],
28
+ "scripts": {
29
+ "clean": "rm -rf dist",
30
+ "build": "npm run clean && tsc -p tsconfig.json",
31
+ "pack": "npm run build && npm pack --pack-destination ./archive/"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "latest",
35
+ "@types/react": "^19.2.7"
36
+ },
37
+ "peerDependencies": {
38
+ "react": "^19",
39
+ "typescript": "^5"
40
+ },
41
+ "dependencies": {
42
+ "@bb-labs/deep-equal": "^0.0.1",
43
+ "fast-deep-equal": "^3.1.3",
44
+ "idb-keyval": "^6.2.2",
45
+ "zod": "^4.1.12"
46
+ }
47
+ }