@victorylabs/params 0.1.0 → 0.2.0

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.
@@ -1,5 +1,21 @@
1
1
  import { P as ParamsStorage } from '../storage-DBLIRR-4.cjs';
2
2
 
3
+ /**
4
+ * Consumer-supplied schema-migration callback. Invoked inside
5
+ * `onupgradeneeded` after the lib ensures the configured object store exists,
6
+ * so consumers don't need to defensively check for it. The upgrade
7
+ * `transaction` is provided so consumers can iterate cursors against the
8
+ * existing store synchronously to migrate record shapes.
9
+ *
10
+ * Errors thrown here abort the upgrade — the open request fails and
11
+ * `idbStorage`'s silent-fallback contract returns `undefined` to the engine.
12
+ */
13
+ type IdbUpgradeCallback = (ctx: {
14
+ readonly db: IDBDatabase;
15
+ readonly transaction: IDBTransaction;
16
+ readonly oldVersion: number;
17
+ readonly newVersion: number;
18
+ }) => void;
3
19
  interface IdbStorageOptions {
4
20
  /** Database name. Required. */
5
21
  readonly db: string;
@@ -9,6 +25,25 @@ interface IdbStorageOptions {
9
25
  readonly key?: string;
10
26
  /** Schema version. Bump if you change the value shape across releases. Default: `1`. */
11
27
  readonly version?: number;
28
+ /**
29
+ * Cross-tab sync via {@link BroadcastChannel}. When `true`, every successful
30
+ * write broadcasts the new values to other tabs subscribed to the same
31
+ * `(db, store, key)` triple; the receiving tab feeds them back through the
32
+ * `subscribe` callback exactly as it would for a `popstate` or `storage`
33
+ * event. Default: `false` — preserves the v0.5 "no subscribe" behavior so
34
+ * existing consumers see no change.
35
+ *
36
+ * Channel name is derived from `(dbName, storeName, recordKey)`. SSR-safe:
37
+ * skips registration when `BroadcastChannel` is unavailable.
38
+ */
39
+ readonly broadcast?: boolean;
40
+ /**
41
+ * Schema-migration callback. See {@link IdbUpgradeCallback}. Most consumers
42
+ * don't need this — the lib creates the object store on first open. Reach
43
+ * for it only when you bump `version` and need to add indexes or migrate
44
+ * record shapes.
45
+ */
46
+ readonly onUpgrade?: IdbUpgradeCallback;
12
47
  }
13
48
  /**
14
49
  * IndexedDB storage backend.
@@ -18,14 +53,11 @@ interface IdbStorageOptions {
18
53
  * `key` option), stored as a structured-clonable object — no JSON
19
54
  * serialization layer.
20
55
  *
21
- * v0.5 limitations (likely v0.6+ candidates if a consumer asks):
22
- * - **No cross-tab sync** (no BroadcastChannel). The same database is shared
23
- * across tabs, but writes from one tab don't push to another. Subscribe is
24
- * intentionally absent.
25
- * - **No consumer-supplied `onupgradeneeded` hook.** The `version` option
26
- * triggers an upgrade transaction and the lib creates the object store on
27
- * first open, but you can't migrate from a previous shape.
56
+ * v0.6 capabilities (opt-in, both backward-compatible):
57
+ * - **`broadcast: true`** for cross-tab sync via `BroadcastChannel`.
58
+ * - **`onUpgrade`** for consumer-supplied schema migrations across `version`
59
+ * bumps (add indexes, transform records).
28
60
  */
29
61
  declare function idbStorage<T = Record<string, unknown>>(opts: IdbStorageOptions): ParamsStorage<T>;
30
62
 
31
- export { type IdbStorageOptions, idbStorage };
63
+ export { type IdbStorageOptions, type IdbUpgradeCallback, idbStorage };
@@ -1,5 +1,21 @@
1
1
  import { P as ParamsStorage } from '../storage-DBLIRR-4.js';
2
2
 
3
+ /**
4
+ * Consumer-supplied schema-migration callback. Invoked inside
5
+ * `onupgradeneeded` after the lib ensures the configured object store exists,
6
+ * so consumers don't need to defensively check for it. The upgrade
7
+ * `transaction` is provided so consumers can iterate cursors against the
8
+ * existing store synchronously to migrate record shapes.
9
+ *
10
+ * Errors thrown here abort the upgrade — the open request fails and
11
+ * `idbStorage`'s silent-fallback contract returns `undefined` to the engine.
12
+ */
13
+ type IdbUpgradeCallback = (ctx: {
14
+ readonly db: IDBDatabase;
15
+ readonly transaction: IDBTransaction;
16
+ readonly oldVersion: number;
17
+ readonly newVersion: number;
18
+ }) => void;
3
19
  interface IdbStorageOptions {
4
20
  /** Database name. Required. */
5
21
  readonly db: string;
@@ -9,6 +25,25 @@ interface IdbStorageOptions {
9
25
  readonly key?: string;
10
26
  /** Schema version. Bump if you change the value shape across releases. Default: `1`. */
11
27
  readonly version?: number;
28
+ /**
29
+ * Cross-tab sync via {@link BroadcastChannel}. When `true`, every successful
30
+ * write broadcasts the new values to other tabs subscribed to the same
31
+ * `(db, store, key)` triple; the receiving tab feeds them back through the
32
+ * `subscribe` callback exactly as it would for a `popstate` or `storage`
33
+ * event. Default: `false` — preserves the v0.5 "no subscribe" behavior so
34
+ * existing consumers see no change.
35
+ *
36
+ * Channel name is derived from `(dbName, storeName, recordKey)`. SSR-safe:
37
+ * skips registration when `BroadcastChannel` is unavailable.
38
+ */
39
+ readonly broadcast?: boolean;
40
+ /**
41
+ * Schema-migration callback. See {@link IdbUpgradeCallback}. Most consumers
42
+ * don't need this — the lib creates the object store on first open. Reach
43
+ * for it only when you bump `version` and need to add indexes or migrate
44
+ * record shapes.
45
+ */
46
+ readonly onUpgrade?: IdbUpgradeCallback;
12
47
  }
13
48
  /**
14
49
  * IndexedDB storage backend.
@@ -18,14 +53,11 @@ interface IdbStorageOptions {
18
53
  * `key` option), stored as a structured-clonable object — no JSON
19
54
  * serialization layer.
20
55
  *
21
- * v0.5 limitations (likely v0.6+ candidates if a consumer asks):
22
- * - **No cross-tab sync** (no BroadcastChannel). The same database is shared
23
- * across tabs, but writes from one tab don't push to another. Subscribe is
24
- * intentionally absent.
25
- * - **No consumer-supplied `onupgradeneeded` hook.** The `version` option
26
- * triggers an upgrade transaction and the lib creates the object store on
27
- * first open, but you can't migrate from a previous shape.
56
+ * v0.6 capabilities (opt-in, both backward-compatible):
57
+ * - **`broadcast: true`** for cross-tab sync via `BroadcastChannel`.
58
+ * - **`onUpgrade`** for consumer-supplied schema migrations across `version`
59
+ * bumps (add indexes, transform records).
28
60
  */
29
61
  declare function idbStorage<T = Record<string, unknown>>(opts: IdbStorageOptions): ParamsStorage<T>;
30
62
 
31
- export { type IdbStorageOptions, idbStorage };
63
+ export { type IdbStorageOptions, type IdbUpgradeCallback, idbStorage };
@@ -5,23 +5,41 @@ function idbStorage(opts) {
5
5
  const storeName = opts.store ?? "params";
6
6
  const recordKey = opts.key ?? "default";
7
7
  const version = opts.version ?? 1;
8
+ const broadcastEnabled = opts.broadcast === true;
9
+ const onUpgrade = opts.onUpgrade;
10
+ const channelName = `@victorylabs/params:${dbName}:${storeName}:${recordKey}`;
8
11
  let dbHandle;
9
12
  const openDb = async () => {
10
13
  if (!isClient) throw new Error("idbStorage: window.indexedDB unavailable");
11
14
  if (dbHandle && dbHandle.version === version) return dbHandle;
12
15
  return new Promise((resolve, reject) => {
13
16
  const req = window.indexedDB.open(dbName, version);
14
- req.onupgradeneeded = () => {
17
+ req.onupgradeneeded = (event) => {
15
18
  const db = req.result;
16
19
  if (!db.objectStoreNames.contains(storeName)) {
17
20
  db.createObjectStore(storeName);
18
21
  }
22
+ if (onUpgrade && req.transaction) {
23
+ onUpgrade({
24
+ db,
25
+ transaction: req.transaction,
26
+ oldVersion: event.oldVersion,
27
+ newVersion: event.newVersion ?? version
28
+ });
29
+ }
19
30
  };
20
31
  req.onsuccess = () => {
21
32
  dbHandle = req.result;
22
33
  dbHandle.onclose = () => {
23
34
  dbHandle = void 0;
24
35
  };
36
+ dbHandle.onversionchange = () => {
37
+ try {
38
+ dbHandle?.close();
39
+ } catch {
40
+ }
41
+ dbHandle = void 0;
42
+ };
25
43
  resolve(req.result);
26
44
  };
27
45
  req.onerror = () => reject(req.error);
@@ -41,7 +59,30 @@ function idbStorage(opts) {
41
59
  return op(await openDb());
42
60
  }
43
61
  };
44
- return {
62
+ const broadcastSupported = () => broadcastEnabled && typeof BroadcastChannel !== "undefined";
63
+ let sharedChannel;
64
+ const subscribers = /* @__PURE__ */ new Set();
65
+ const ensureChannel = () => {
66
+ if (!broadcastSupported()) return void 0;
67
+ if (sharedChannel === void 0) {
68
+ sharedChannel = new BroadcastChannel(channelName);
69
+ sharedChannel.onmessage = (event) => {
70
+ const data = event.data;
71
+ const values = data?.values ?? {};
72
+ for (const cb of [...subscribers]) cb(values);
73
+ };
74
+ }
75
+ return sharedChannel;
76
+ };
77
+ const broadcast = (values) => {
78
+ const ch = ensureChannel();
79
+ if (ch === void 0) return;
80
+ try {
81
+ ch.postMessage({ values });
82
+ } catch {
83
+ }
84
+ };
85
+ const storage = {
45
86
  name: "idbStorage",
46
87
  clientOnly: true,
47
88
  // IDB has no synchronous API. Engine consults `readAsync` for hydration;
@@ -73,6 +114,7 @@ function idbStorage(opts) {
73
114
  req.onerror = () => reject(req.error);
74
115
  })
75
116
  );
117
+ broadcast(values);
76
118
  } catch {
77
119
  }
78
120
  },
@@ -88,6 +130,7 @@ function idbStorage(opts) {
88
130
  req.onerror = () => reject(req.error);
89
131
  })
90
132
  );
133
+ broadcast(void 0);
91
134
  return;
92
135
  }
93
136
  const current = await withVersionRetry(
@@ -107,11 +150,21 @@ function idbStorage(opts) {
107
150
  req.onerror = () => reject(req.error);
108
151
  })
109
152
  );
153
+ broadcast(current);
110
154
  } catch {
111
155
  }
112
156
  }
113
- // No `subscribe` — IDB doesn't fire native change events. v0.6+ via BroadcastChannel.
114
157
  };
158
+ if (broadcastSupported()) {
159
+ storage.subscribe = (callback) => {
160
+ ensureChannel();
161
+ subscribers.add(callback);
162
+ return () => {
163
+ subscribers.delete(callback);
164
+ };
165
+ };
166
+ }
167
+ return storage;
115
168
  }
116
169
  export {
117
170
  idbStorage
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/storage/idb/index.ts"],"sourcesContent":["import type { ParamsStorage } from '../../storage'\n\nexport interface IdbStorageOptions {\n /** Database name. Required. */\n readonly db: string\n /** Object store name within the database. Default: `'params'`. */\n readonly store?: string\n /** Record key within the store. Default: `'default'`. */\n readonly key?: string\n /** Schema version. Bump if you change the value shape across releases. Default: `1`. */\n readonly version?: number\n}\n\nconst isClient = typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined'\n\n/**\n * IndexedDB storage backend.\n *\n * Async-only — `read()` returns `undefined` synchronously; the engine should\n * consult `readAsync()` for hydration. Single record per definition (the\n * `key` option), stored as a structured-clonable object — no JSON\n * serialization layer.\n *\n * v0.5 limitations (likely v0.6+ candidates if a consumer asks):\n * - **No cross-tab sync** (no BroadcastChannel). The same database is shared\n * across tabs, but writes from one tab don't push to another. Subscribe is\n * intentionally absent.\n * - **No consumer-supplied `onupgradeneeded` hook.** The `version` option\n * triggers an upgrade transaction and the lib creates the object store on\n * first open, but you can't migrate from a previous shape.\n */\nexport function idbStorage<T = Record<string, unknown>>(opts: IdbStorageOptions): ParamsStorage<T> {\n const dbName = opts.db\n const storeName = opts.store ?? 'params'\n const recordKey = opts.key ?? 'default'\n const version = opts.version ?? 1\n\n // Cache the database handle. Re-opens on demand if closed.\n let dbHandle: IDBDatabase | undefined\n const openDb = async (): Promise<IDBDatabase> => {\n if (!isClient) throw new Error('idbStorage: window.indexedDB unavailable')\n if (dbHandle && dbHandle.version === version) return dbHandle\n return new Promise((resolve, reject) => {\n const req = window.indexedDB.open(dbName, version)\n req.onupgradeneeded = () => {\n const db = req.result\n if (!db.objectStoreNames.contains(storeName)) {\n db.createObjectStore(storeName)\n }\n }\n req.onsuccess = () => {\n dbHandle = req.result\n dbHandle.onclose = () => {\n dbHandle = undefined\n }\n resolve(req.result)\n }\n req.onerror = () => reject(req.error)\n })\n }\n\n /**\n * v0.5: retry once on `VersionError`. Another tab opening the DB at a\n * different version invalidates our cached handle. Close it, re-open, and\n * retry the operation. After one retry, fall back to silent (existing\n * contract).\n */\n const withVersionRetry = async <R>(op: (db: IDBDatabase) => Promise<R>): Promise<R> => {\n try {\n return await op(await openDb())\n } catch (err) {\n const isVersionError =\n typeof DOMException !== 'undefined' &&\n err instanceof DOMException &&\n err.name === 'VersionError'\n if (!isVersionError) throw err\n try {\n dbHandle?.close()\n } catch {\n // ignore — handle may already be invalid\n }\n dbHandle = undefined\n return op(await openDb())\n }\n }\n\n return {\n name: 'idbStorage',\n clientOnly: true,\n\n // IDB has no synchronous API. Engine consults `readAsync` for hydration;\n // `read()` always returns undefined.\n read: () => undefined,\n\n readAsync: async () => {\n if (!isClient) return undefined\n try {\n return await withVersionRetry(\n (db) =>\n new Promise<Partial<T> | undefined>((resolve, reject) => {\n const tx = db.transaction(storeName, 'readonly')\n const req = tx.objectStore(storeName).get(recordKey)\n req.onsuccess = () => resolve((req.result as Partial<T> | undefined) ?? undefined)\n req.onerror = () => reject(req.error)\n }),\n )\n } catch {\n return undefined\n }\n },\n\n write: async (values, _changed, _options) => {\n if (!isClient) return\n try {\n await withVersionRetry(\n (db) =>\n new Promise<void>((resolve, reject) => {\n const tx = db.transaction(storeName, 'readwrite')\n const req = tx.objectStore(storeName).put(values, recordKey)\n req.onsuccess = () => resolve()\n req.onerror = () => reject(req.error)\n }),\n )\n } catch {\n // Silent fallback per the storage error contract — values still live in memory.\n }\n },\n\n clear: async (paths, _options) => {\n if (!isClient) return\n try {\n if (paths.length === 0) {\n // Clear everything — delete the record.\n await withVersionRetry(\n (db) =>\n new Promise<void>((resolve, reject) => {\n const tx = db.transaction(storeName, 'readwrite')\n const req = tx.objectStore(storeName).delete(recordKey)\n req.onsuccess = () => resolve()\n req.onerror = () => reject(req.error)\n }),\n )\n return\n }\n // Per-path clear: read current, omit cleared paths, write back.\n const current = await withVersionRetry(\n (db) =>\n new Promise<Record<string, unknown>>((resolve, reject) => {\n const tx = db.transaction(storeName, 'readonly')\n const req = tx.objectStore(storeName).get(recordKey)\n req.onsuccess = () =>\n resolve((req.result as Record<string, unknown> | undefined) ?? {})\n req.onerror = () => reject(req.error)\n }),\n )\n for (const p of paths) delete current[p]\n await withVersionRetry(\n (db) =>\n new Promise<void>((resolve, reject) => {\n const tx = db.transaction(storeName, 'readwrite')\n const req = tx.objectStore(storeName).put(current, recordKey)\n req.onsuccess = () => resolve()\n req.onerror = () => reject(req.error)\n }),\n )\n } catch {\n // Silent fallback.\n }\n },\n\n // No `subscribe` — IDB doesn't fire native change events. v0.6+ via BroadcastChannel.\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/storage/idb/index.ts"],"sourcesContent":["import type { ParamsStorage } from '../../storage'\n\n/**\n * Consumer-supplied schema-migration callback. Invoked inside\n * `onupgradeneeded` after the lib ensures the configured object store exists,\n * so consumers don't need to defensively check for it. The upgrade\n * `transaction` is provided so consumers can iterate cursors against the\n * existing store synchronously to migrate record shapes.\n *\n * Errors thrown here abort the upgrade — the open request fails and\n * `idbStorage`'s silent-fallback contract returns `undefined` to the engine.\n */\nexport type IdbUpgradeCallback = (ctx: {\n readonly db: IDBDatabase\n readonly transaction: IDBTransaction\n readonly oldVersion: number\n readonly newVersion: number\n}) => void\n\nexport interface IdbStorageOptions {\n /** Database name. Required. */\n readonly db: string\n /** Object store name within the database. Default: `'params'`. */\n readonly store?: string\n /** Record key within the store. Default: `'default'`. */\n readonly key?: string\n /** Schema version. Bump if you change the value shape across releases. Default: `1`. */\n readonly version?: number\n /**\n * Cross-tab sync via {@link BroadcastChannel}. When `true`, every successful\n * write broadcasts the new values to other tabs subscribed to the same\n * `(db, store, key)` triple; the receiving tab feeds them back through the\n * `subscribe` callback exactly as it would for a `popstate` or `storage`\n * event. Default: `false` — preserves the v0.5 \"no subscribe\" behavior so\n * existing consumers see no change.\n *\n * Channel name is derived from `(dbName, storeName, recordKey)`. SSR-safe:\n * skips registration when `BroadcastChannel` is unavailable.\n */\n readonly broadcast?: boolean\n /**\n * Schema-migration callback. See {@link IdbUpgradeCallback}. Most consumers\n * don't need this — the lib creates the object store on first open. Reach\n * for it only when you bump `version` and need to add indexes or migrate\n * record shapes.\n */\n readonly onUpgrade?: IdbUpgradeCallback\n}\n\nconst isClient = typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined'\n\n/**\n * IndexedDB storage backend.\n *\n * Async-only — `read()` returns `undefined` synchronously; the engine should\n * consult `readAsync()` for hydration. Single record per definition (the\n * `key` option), stored as a structured-clonable object — no JSON\n * serialization layer.\n *\n * v0.6 capabilities (opt-in, both backward-compatible):\n * - **`broadcast: true`** for cross-tab sync via `BroadcastChannel`.\n * - **`onUpgrade`** for consumer-supplied schema migrations across `version`\n * bumps (add indexes, transform records).\n */\nexport function idbStorage<T = Record<string, unknown>>(opts: IdbStorageOptions): ParamsStorage<T> {\n const dbName = opts.db\n const storeName = opts.store ?? 'params'\n const recordKey = opts.key ?? 'default'\n const version = opts.version ?? 1\n const broadcastEnabled = opts.broadcast === true\n const onUpgrade = opts.onUpgrade\n const channelName = `@victorylabs/params:${dbName}:${storeName}:${recordKey}`\n\n // Cache the database handle. Re-opens on demand if closed.\n let dbHandle: IDBDatabase | undefined\n const openDb = async (): Promise<IDBDatabase> => {\n if (!isClient) throw new Error('idbStorage: window.indexedDB unavailable')\n if (dbHandle && dbHandle.version === version) return dbHandle\n return new Promise((resolve, reject) => {\n const req = window.indexedDB.open(dbName, version)\n req.onupgradeneeded = (event) => {\n const db = req.result\n if (!db.objectStoreNames.contains(storeName)) {\n db.createObjectStore(storeName)\n }\n if (onUpgrade && req.transaction) {\n onUpgrade({\n db,\n transaction: req.transaction,\n oldVersion: event.oldVersion,\n newVersion: event.newVersion ?? version,\n })\n }\n }\n req.onsuccess = () => {\n dbHandle = req.result\n dbHandle.onclose = () => {\n dbHandle = undefined\n }\n // When another tab opens the same DB at a higher version, the browser\n // fires `versionchange` against this handle. If we don't close it,\n // the other tab's upgrade blocks indefinitely. Close → fall through\n // to the `withVersionRetry` path on the next op.\n dbHandle.onversionchange = () => {\n try {\n dbHandle?.close()\n } catch {\n // ignore\n }\n dbHandle = undefined\n }\n resolve(req.result)\n }\n req.onerror = () => reject(req.error)\n })\n }\n\n /**\n * v0.5: retry once on `VersionError`. Another tab opening the DB at a\n * different version invalidates our cached handle. Close it, re-open, and\n * retry the operation. After one retry, fall back to silent (existing\n * contract).\n */\n const withVersionRetry = async <R>(op: (db: IDBDatabase) => Promise<R>): Promise<R> => {\n try {\n return await op(await openDb())\n } catch (err) {\n const isVersionError =\n typeof DOMException !== 'undefined' &&\n err instanceof DOMException &&\n err.name === 'VersionError'\n if (!isVersionError) throw err\n try {\n dbHandle?.close()\n } catch {\n // ignore — handle may already be invalid\n }\n dbHandle = undefined\n return op(await openDb())\n }\n }\n\n // ── Cross-tab broadcast (opt-in via `broadcast: true`) ────────────────\n // Per the BroadcastChannel spec, posting a message on a channel does NOT\n // deliver it back to that same channel instance — but it DOES deliver it\n // to other BroadcastChannel instances with the same name in the same agent\n // and any other agent. We exploit that by sharing ONE channel per storage\n // instance for both posting and receiving: local writes never re-trigger\n // our own subscribers; cross-tab writes do.\n const broadcastSupported = (): boolean =>\n broadcastEnabled && typeof BroadcastChannel !== 'undefined'\n let sharedChannel: BroadcastChannel | undefined\n const subscribers = new Set<(values: Partial<T>) => void>()\n const ensureChannel = (): BroadcastChannel | undefined => {\n if (!broadcastSupported()) return undefined\n if (sharedChannel === undefined) {\n sharedChannel = new BroadcastChannel(channelName)\n sharedChannel.onmessage = (event) => {\n const data = event.data as { values?: Partial<T> } | null | undefined\n const values = data?.values ?? ({} as Partial<T>)\n for (const cb of [...subscribers]) cb(values)\n }\n }\n return sharedChannel\n }\n const broadcast = (values: Partial<T> | undefined): void => {\n const ch = ensureChannel()\n if (ch === undefined) return\n try {\n ch.postMessage({ values })\n } catch {\n // BroadcastChannel can throw if the message isn't structured-cloneable;\n // silent per the storage error contract.\n }\n }\n\n const storage: ParamsStorage<T> = {\n name: 'idbStorage',\n clientOnly: true,\n\n // IDB has no synchronous API. Engine consults `readAsync` for hydration;\n // `read()` always returns undefined.\n read: () => undefined,\n\n readAsync: async () => {\n if (!isClient) return undefined\n try {\n return await withVersionRetry(\n (db) =>\n new Promise<Partial<T> | undefined>((resolve, reject) => {\n const tx = db.transaction(storeName, 'readonly')\n const req = tx.objectStore(storeName).get(recordKey)\n req.onsuccess = () => resolve((req.result as Partial<T> | undefined) ?? undefined)\n req.onerror = () => reject(req.error)\n }),\n )\n } catch {\n return undefined\n }\n },\n\n write: async (values, _changed, _options) => {\n if (!isClient) return\n try {\n await withVersionRetry(\n (db) =>\n new Promise<void>((resolve, reject) => {\n const tx = db.transaction(storeName, 'readwrite')\n const req = tx.objectStore(storeName).put(values, recordKey)\n req.onsuccess = () => resolve()\n req.onerror = () => reject(req.error)\n }),\n )\n broadcast(values)\n } catch {\n // Silent fallback per the storage error contract — values still live in memory.\n }\n },\n\n clear: async (paths, _options) => {\n if (!isClient) return\n try {\n if (paths.length === 0) {\n // Clear everything — delete the record.\n await withVersionRetry(\n (db) =>\n new Promise<void>((resolve, reject) => {\n const tx = db.transaction(storeName, 'readwrite')\n const req = tx.objectStore(storeName).delete(recordKey)\n req.onsuccess = () => resolve()\n req.onerror = () => reject(req.error)\n }),\n )\n broadcast(undefined)\n return\n }\n // Per-path clear: read current, omit cleared paths, write back.\n const current = await withVersionRetry(\n (db) =>\n new Promise<Record<string, unknown>>((resolve, reject) => {\n const tx = db.transaction(storeName, 'readonly')\n const req = tx.objectStore(storeName).get(recordKey)\n req.onsuccess = () =>\n resolve((req.result as Record<string, unknown> | undefined) ?? {})\n req.onerror = () => reject(req.error)\n }),\n )\n for (const p of paths) delete current[p]\n await withVersionRetry(\n (db) =>\n new Promise<void>((resolve, reject) => {\n const tx = db.transaction(storeName, 'readwrite')\n const req = tx.objectStore(storeName).put(current, recordKey)\n req.onsuccess = () => resolve()\n req.onerror = () => reject(req.error)\n }),\n )\n broadcast(current as Partial<T>)\n } catch {\n // Silent fallback.\n }\n },\n }\n\n if (broadcastSupported()) {\n storage.subscribe = (callback) => {\n ensureChannel()\n subscribers.add(callback)\n return () => {\n subscribers.delete(callback)\n // Keep the shared channel open for the lifetime of the storage\n // instance — the next write needs it for outbound posts, even after\n // the last subscribe teardown.\n }\n }\n }\n\n return storage\n}\n"],"mappings":"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","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/storage/url/index.ts","../../src/schema.ts"],"sourcesContent":["import { defaultSerialize } from '../../schema'\nimport type { ParamsStorage, WriteOptions } from '../../storage'\n\nexport interface UrlStorageOptions {\n /**\n * History API strategy. `'replace'` keeps the back-stack clean (recommended\n * for filters/search). `'push'` creates a new history entry for every write.\n * Default: `'replace'`.\n */\n readonly strategy?: 'replace' | 'push'\n\n /** form path → URL search-param key. Defaults to identity. */\n readonly paramMap?: Record<string, string>\n\n /** Per-path string serializer. Default: `defaultSerialize` (same as schema). */\n readonly serialize?: Record<string, (value: unknown) => string>\n\n /** Per-path string-to-value deserializer. Default: identity (raw string). */\n readonly deserialize?: Record<string, (str: string) => unknown>\n}\n\nconst isClient = typeof window !== 'undefined'\n\n/**\n * URL search-param storage backend (native History API).\n *\n * - Hydrates from `window.location.search` on store creation.\n * - Reflects writes back to the URL via `replaceState` (default) or `pushState`.\n * - Subscribes to `popstate` so back/forward navigation flows into the store.\n *\n * `omitWhenDefault` is honored at the engine level (the store sends `undefined`\n * for paths at default; the backend deletes them from the URL).\n *\n * Limitations (v0.1):\n * - History API only — `hashchange` not supported.\n * - Cross-tab URL sync not supported (History API is per-tab).\n *\n * v0.4: per-call strategy override via `WriteOptions.history` — store callers\n * can pass `params.set(path, value, { history: 'push' })` to override the\n * adapter-level default for that single write.\n */\nexport function urlStorage<T = Record<string, unknown>>(\n options: UrlStorageOptions = {},\n): ParamsStorage<T> {\n const adapterStrategy = options.strategy ?? 'replace'\n const paramMap = options.paramMap ?? {}\n const reverseMap = buildReverseMap(paramMap)\n const serialize = options.serialize ?? {}\n const deserialize = options.deserialize ?? {}\n\n const readImpl = (): Partial<T> | undefined => {\n if (!isClient) return undefined\n try {\n const params = new URLSearchParams(window.location.search)\n const partial: Record<string, unknown> = {}\n let hasAny = false\n for (const [paramKey, raw] of params.entries()) {\n const formPath = reverseMap[paramKey] ?? paramKey\n const deserializer = deserialize[formPath]\n try {\n partial[formPath] = deserializer ? deserializer(raw) : raw\n hasAny = true\n } catch {\n // Per-path deserialize failure → skip; engine falls back to default\n }\n }\n return hasAny ? (partial as Partial<T>) : undefined\n } catch {\n return undefined\n }\n }\n\n const updateUrl = (params: URLSearchParams, opts?: WriteOptions): void => {\n if (!isClient) return\n const search = params.toString()\n const url = search ? `${window.location.pathname}?${search}` : window.location.pathname\n const strategy = opts?.history ?? adapterStrategy\n try {\n if (strategy === 'push') {\n window.history.pushState(null, '', url)\n } else {\n window.history.replaceState(null, '', url)\n }\n } catch {\n // Some browsers cap History API writes (rare) — silent fallback.\n }\n }\n\n return {\n name: 'urlStorage',\n clientOnly: true,\n read: readImpl,\n write: (values, _changed, opts) => {\n if (!isClient) return\n const params = new URLSearchParams(window.location.search)\n for (const [path, value] of Object.entries(values as Record<string, unknown>)) {\n const paramKey = paramMap[path] ?? path\n if (value === undefined || value === null) {\n params.delete(paramKey)\n continue\n }\n const serializer = serialize[path]\n const str = serializer ? serializer(value) : defaultSerialize(value)\n params.set(paramKey, str)\n }\n updateUrl(params, opts)\n },\n clear: (paths, opts) => {\n if (!isClient) return\n if (paths.length === 0) {\n updateUrl(new URLSearchParams(), opts)\n return\n }\n const params = new URLSearchParams(window.location.search)\n for (const path of paths) {\n const paramKey = paramMap[path] ?? path\n params.delete(paramKey)\n }\n updateUrl(params, opts)\n },\n subscribe: (callback) => {\n if (!isClient) return () => undefined\n const handler = () => {\n const values = readImpl()\n if (values) callback(values)\n }\n window.addEventListener('popstate', handler)\n return () => window.removeEventListener('popstate', handler)\n },\n }\n}\n\nfunction buildReverseMap(map: Record<string, string>): Record<string, string> {\n const reverse: Record<string, string> = {}\n for (const [formPath, paramKey] of Object.entries(map)) {\n reverse[paramKey] = formPath\n }\n return reverse\n}\n","import type { StandardSchemaV1 } from '@standard-schema/spec'\n\nimport type { FieldSpec, PlainFieldSpec } from './types'\n\nexport function isStandardSchema(spec: unknown): spec is StandardSchemaV1 {\n return (\n typeof spec === 'object' &&\n spec !== null &&\n '~standard' in spec &&\n typeof (spec as { '~standard'?: unknown })['~standard'] === 'object'\n )\n}\n\nexport function isPlainSpec<T>(spec: FieldSpec<T>): spec is PlainFieldSpec<T> {\n return !isStandardSchema(spec) && typeof spec === 'object' && spec !== null && 'default' in spec\n}\n\n/**\n * Resolve a field's default value.\n *\n * - Plain spec: `spec.default` (always present — required by the type).\n * - Standard Schema (Zod, Valibot, ArkType, …): parse `undefined` and use the\n * schema's `.default()` if it produced a value. Schemas without a default\n * return `undefined`.\n */\nexport function getDefault<T>(spec: FieldSpec<T>): T | undefined {\n if (isStandardSchema(spec)) {\n const result = spec['~standard'].validate(undefined)\n if (result instanceof Promise) return undefined\n if ('value' in result && !result.issues) return result.value as T\n return undefined\n }\n return (spec as PlainFieldSpec<T>).default\n}\n\n/**\n * Parse a raw storage value through the field's spec. Returns the typed value\n * on success, or `undefined` on parse failure (engine falls back to default).\n *\n * The engine records the failure reason in `ParamsStore.storageErrors` for\n * debugging; consumer code doesn't see it.\n */\nexport interface ParseResult<T> {\n ok: boolean\n value?: T\n reason?: string\n}\n\nexport function parseField<T>(spec: FieldSpec<T>, raw: unknown): ParseResult<T> {\n if (isStandardSchema(spec)) {\n const result = spec['~standard'].validate(raw)\n if (result instanceof Promise) {\n return { ok: false, reason: 'async-schema-not-supported-in-v0.1' }\n }\n if ('value' in result && !result.issues) return { ok: true, value: result.value as T }\n return { ok: false, reason: result.issues?.[0]?.message ?? 'parse-failed' }\n }\n\n const plainSpec = spec as PlainFieldSpec<T>\n if (typeof raw === 'string' && plainSpec.parse) {\n try {\n return { ok: true, value: plainSpec.parse(raw) }\n } catch (err) {\n return { ok: false, reason: err instanceof Error ? err.message : String(err) }\n }\n }\n // No custom parse: accept raw value as-is if it's a usable runtime value\n // (string, number, boolean, array, object, etc.). The schema-less plain spec\n // is mostly used for non-string-coerced flows (memory storage with raw values).\n if (raw === undefined) return { ok: false, reason: 'undefined-no-parse' }\n return { ok: true, value: raw as T }\n}\n\n/**\n * Serialize a typed value to its storage string representation. Used by URL\n * storage (every value must be a string) and other string-keyed backends.\n *\n * Plain spec uses its `serialize` if provided, else `String(value)`. Standard\n * schemas don't define an inverse — we fall back to `String(value)` (or\n * `JSON.stringify` for objects) and the consumer can override via per-field\n * `serialize` in the storage backend's options.\n */\nexport function defaultSerialize(value: unknown): string {\n if (value === undefined || value === null) return ''\n if (typeof value === 'string') return value\n if (typeof value === 'boolean') return value ? 'true' : 'false'\n if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''\n return JSON.stringify(value)\n}\n\n/**\n * Extract the enum values from a field spec, if any. Returns the array of\n * enum members or `undefined` if the spec doesn't expose them.\n *\n * Supports:\n * - Zod `z.enum(['a', 'b'])` — reads `_def.values: ['a', 'b']`.\n * - Zod `z.nativeEnum(MyEnum)` — reads `_def.values: <enum-object>`.\n * For numeric enums (TypeScript bidirectional `enum Color { Red = 0 }`),\n * `Object.values` returns BOTH the numbers and the reverse-mapped strings;\n * this helper detects the case and filters to numeric values only.\n *\n * Standard Schema doesn't define a uniform enum-introspection API, so libraries\n * other than Zod return `undefined` here — `cycle()` then throws and asks the\n * caller to pass options explicitly.\n */\nexport function extractEnumValues(spec: unknown): readonly unknown[] | undefined {\n // Walk Zod wrapper types (ZodDefault, ZodOptional, ZodNullable, …) to find\n // an underlying enum. `.default(...)` / `.optional()` / `.nullable()` produce\n // a wrapper whose `_def.innerType` points to the wrapped schema. Cap the walk\n // at a small depth — pathological self-referential schemas shouldn't loop.\n // biome-ignore lint/suspicious/noExplicitAny: Zod internal `_def` API\n let current: any = spec\n for (let i = 0; i < 8 && current !== null && typeof current === 'object'; i++) {\n const def = current._def\n if (!def) return undefined\n\n // z.enum(['a', 'b']): values is already a clean string array.\n if (Array.isArray(def.values)) return def.values\n\n // z.nativeEnum(MyEnum): values is the enum object. TS numeric enums are\n // BIDIRECTIONAL — Object.values returns numbers + reverse-mapped strings:\n // enum Color { Red = 0 } → Object.values(Color) = [0, 'Red'].\n // Detect numeric and filter; pure string enums are unidirectional and\n // return only strings.\n if (typeof def.values === 'object' && def.values !== null) {\n const all = Object.values(def.values)\n const hasNumber = all.some((v) => typeof v === 'number')\n return hasNumber ? all.filter((v) => typeof v === 'number') : all\n }\n\n if (def.innerType) {\n current = def.innerType\n continue\n }\n return undefined\n }\n return undefined\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/storage/url/index.ts","../../src/schema.ts"],"sourcesContent":["import { defaultSerialize } from '../../schema'\nimport type { ParamsStorage, WriteOptions } from '../../storage'\n\nexport interface UrlStorageOptions {\n /**\n * History API strategy. `'replace'` keeps the back-stack clean (recommended\n * for filters/search). `'push'` creates a new history entry for every write.\n * Default: `'replace'`.\n */\n readonly strategy?: 'replace' | 'push'\n\n /** form path → URL search-param key. Defaults to identity. */\n readonly paramMap?: Record<string, string>\n\n /** Per-path string serializer. Default: `defaultSerialize` (same as schema). */\n readonly serialize?: Record<string, (value: unknown) => string>\n\n /** Per-path string-to-value deserializer. Default: identity (raw string). */\n readonly deserialize?: Record<string, (str: string) => unknown>\n}\n\nconst isClient = typeof window !== 'undefined'\n\n/**\n * URL search-param storage backend (native History API).\n *\n * - Hydrates from `window.location.search` on store creation.\n * - Reflects writes back to the URL via `replaceState` (default) or `pushState`.\n * - Subscribes to `popstate` so back/forward navigation flows into the store.\n *\n * `omitWhenDefault` is honored at the engine level (the store sends `undefined`\n * for paths at default; the backend deletes them from the URL).\n *\n * Limitations (v0.1):\n * - History API only — `hashchange` not supported.\n * - Cross-tab URL sync not supported (History API is per-tab).\n *\n * v0.4: per-call strategy override via `WriteOptions.history` — store callers\n * can pass `params.set(path, value, { history: 'push' })` to override the\n * adapter-level default for that single write.\n */\nexport function urlStorage<T = Record<string, unknown>>(\n options: UrlStorageOptions = {},\n): ParamsStorage<T> {\n const adapterStrategy = options.strategy ?? 'replace'\n const paramMap = options.paramMap ?? {}\n const reverseMap = buildReverseMap(paramMap)\n const serialize = options.serialize ?? {}\n const deserialize = options.deserialize ?? {}\n\n const readImpl = (): Partial<T> | undefined => {\n if (!isClient) return undefined\n try {\n const params = new URLSearchParams(window.location.search)\n const partial: Record<string, unknown> = {}\n let hasAny = false\n for (const [paramKey, raw] of params.entries()) {\n const formPath = reverseMap[paramKey] ?? paramKey\n const deserializer = deserialize[formPath]\n try {\n partial[formPath] = deserializer ? deserializer(raw) : raw\n hasAny = true\n } catch {\n // Per-path deserialize failure → skip; engine falls back to default\n }\n }\n return hasAny ? (partial as Partial<T>) : undefined\n } catch {\n return undefined\n }\n }\n\n const updateUrl = (params: URLSearchParams, opts?: WriteOptions): void => {\n if (!isClient) return\n const search = params.toString()\n const url = search ? `${window.location.pathname}?${search}` : window.location.pathname\n const strategy = opts?.history ?? adapterStrategy\n try {\n if (strategy === 'push') {\n window.history.pushState(null, '', url)\n } else {\n window.history.replaceState(null, '', url)\n }\n } catch {\n // Some browsers cap History API writes (rare) — silent fallback.\n }\n }\n\n return {\n name: 'urlStorage',\n clientOnly: true,\n read: readImpl,\n write: (values, _changed, opts) => {\n if (!isClient) return\n const params = new URLSearchParams(window.location.search)\n for (const [path, value] of Object.entries(values as Record<string, unknown>)) {\n const paramKey = paramMap[path] ?? path\n if (value === undefined || value === null) {\n params.delete(paramKey)\n continue\n }\n const serializer = serialize[path]\n const str = serializer ? serializer(value) : defaultSerialize(value)\n params.set(paramKey, str)\n }\n updateUrl(params, opts)\n },\n clear: (paths, opts) => {\n if (!isClient) return\n if (paths.length === 0) {\n updateUrl(new URLSearchParams(), opts)\n return\n }\n const params = new URLSearchParams(window.location.search)\n for (const path of paths) {\n const paramKey = paramMap[path] ?? path\n params.delete(paramKey)\n }\n updateUrl(params, opts)\n },\n subscribe: (callback) => {\n if (!isClient) return () => undefined\n const handler = () => {\n const values = readImpl()\n if (values) callback(values)\n }\n window.addEventListener('popstate', handler)\n return () => window.removeEventListener('popstate', handler)\n },\n }\n}\n\nfunction buildReverseMap(map: Record<string, string>): Record<string, string> {\n const reverse: Record<string, string> = {}\n for (const [formPath, paramKey] of Object.entries(map)) {\n reverse[paramKey] = formPath\n }\n return reverse\n}\n","import type { StandardSchemaV1 } from '@standard-schema/spec'\n\nimport type { FieldSpec, PlainFieldSpec } from './types'\n\nexport function isStandardSchema(spec: unknown): spec is StandardSchemaV1 {\n return (\n typeof spec === 'object' &&\n spec !== null &&\n '~standard' in spec &&\n typeof (spec as { '~standard'?: unknown })['~standard'] === 'object'\n )\n}\n\nexport function isPlainSpec<T>(spec: FieldSpec<T>): spec is PlainFieldSpec<T> {\n return !isStandardSchema(spec) && typeof spec === 'object' && spec !== null && 'default' in spec\n}\n\n/**\n * Resolve a field's default value.\n *\n * - Plain spec: `spec.default` (always present — required by the type).\n * - Standard Schema (Zod, Valibot, ArkType, …): parse `undefined` and use the\n * schema's `.default()` if it produced a value. Schemas without a default\n * return `undefined`.\n */\nexport function getDefault<T>(spec: FieldSpec<T>): T | undefined {\n if (isStandardSchema(spec)) {\n const result = spec['~standard'].validate(undefined)\n if (result instanceof Promise) return undefined\n if ('value' in result && !result.issues) return result.value as T\n return undefined\n }\n return (spec as PlainFieldSpec<T>).default\n}\n\n/**\n * Parse a raw storage value through the field's spec. Returns the typed value\n * on success, or `undefined` on parse failure (engine falls back to default).\n *\n * The engine records the failure reason in `ParamsStore.storageErrors` for\n * debugging; consumer code doesn't see it.\n */\nexport interface ParseResult<T> {\n ok: boolean\n value?: T\n reason?: string\n}\n\nexport function parseField<T>(spec: FieldSpec<T>, raw: unknown): ParseResult<T> {\n if (isStandardSchema(spec)) {\n const result = spec['~standard'].validate(raw)\n if (result instanceof Promise) {\n return { ok: false, reason: 'async-schema-not-supported-in-v0.1' }\n }\n if ('value' in result && !result.issues) return { ok: true, value: result.value as T }\n return { ok: false, reason: result.issues?.[0]?.message ?? 'parse-failed' }\n }\n\n const plainSpec = spec as PlainFieldSpec<T>\n if (typeof raw === 'string' && plainSpec.parse) {\n try {\n return { ok: true, value: plainSpec.parse(raw) }\n } catch (err) {\n return { ok: false, reason: err instanceof Error ? err.message : String(err) }\n }\n }\n // No custom parse: accept raw value as-is if it's a usable runtime value\n // (string, number, boolean, array, object, etc.). The schema-less plain spec\n // is mostly used for non-string-coerced flows (memory storage with raw values).\n if (raw === undefined) return { ok: false, reason: 'undefined-no-parse' }\n return { ok: true, value: raw as T }\n}\n\n/**\n * Serialize a typed value to its storage string representation. Used by URL\n * storage (every value must be a string) and other string-keyed backends.\n *\n * Plain spec uses its `serialize` if provided, else `String(value)`. Standard\n * schemas don't define an inverse — we fall back to `String(value)` (or\n * `JSON.stringify` for objects) and the consumer can override via per-field\n * `serialize` in the storage backend's options.\n */\nexport function defaultSerialize(value: unknown): string {\n if (value === undefined || value === null) return ''\n if (typeof value === 'string') return value\n if (typeof value === 'boolean') return value ? 'true' : 'false'\n if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''\n return JSON.stringify(value)\n}\n\n/**\n * Probe a schema spec and return its enum members, or `undefined` when the\n * spec doesn't expose any. Used by {@link extractEnumValues} to discover the\n * cycle values for `params.cycle(path)` calls without an explicit list.\n *\n * Built-ins ship for Zod and Valibot. Other Standard Schema libs (ArkType,\n * Effect Schema) lack a uniform introspection API — register a custom\n * extractor via {@link registerEnumExtractor} for those.\n */\nexport type EnumExtractor = (spec: unknown) => readonly unknown[] | undefined\n\nconst customExtractors = new Set<EnumExtractor>()\n\n/**\n * Register a custom enum extractor. Useful when:\n * - Your Standard Schema lib isn't covered by the built-ins (ArkType,\n * Effect Schema, …).\n * - You're using a proprietary or homegrown schema system.\n * - You need to override the built-in detection for a specific shape.\n *\n * Custom extractors run BEFORE the built-ins, so they win on overlap. Returns\n * an unregister function — capture it for clean teardown in tests or HMR.\n *\n * @example\n * ```ts\n * import { registerEnumExtractor } from '@victorylabs/params'\n * import { type } from 'arktype'\n *\n * const off = registerEnumExtractor((spec) => {\n * if (spec instanceof type.Type && spec.json?.length) {\n * return spec.json\n * .filter((node) => 'unit' in node)\n * .map((node) => node.unit)\n * }\n * return undefined\n * })\n * ```\n */\nexport function registerEnumExtractor(extractor: EnumExtractor): () => void {\n customExtractors.add(extractor)\n return () => {\n customExtractors.delete(extractor)\n }\n}\n\n/**\n * Walk Zod wrapper types (ZodDefault, ZodOptional, ZodNullable, …) to find\n * an underlying enum. `.default(...)` / `.optional()` / `.nullable()` produce\n * a wrapper whose `_def.innerType` points to the wrapped schema. Cap the walk\n * at a small depth — pathological self-referential schemas shouldn't loop.\n */\nconst extractZodEnum: EnumExtractor = (spec) => {\n // biome-ignore lint/suspicious/noExplicitAny: Zod internal `_def` API\n let current: any = spec\n for (let i = 0; i < 8 && current !== null && typeof current === 'object'; i++) {\n const def = current._def\n if (!def) return undefined\n\n // z.enum(['a', 'b']): values is already a clean string array.\n if (Array.isArray(def.values)) return def.values\n\n // z.nativeEnum(MyEnum): values is the enum object. TS numeric enums are\n // BIDIRECTIONAL — Object.values returns numbers + reverse-mapped strings:\n // enum Color { Red = 0 } → Object.values(Color) = [0, 'Red'].\n // Detect numeric and filter; pure string enums are unidirectional and\n // return only strings.\n if (typeof def.values === 'object' && def.values !== null) {\n const all = Object.values(def.values)\n const hasNumber = all.some((v) => typeof v === 'number')\n return hasNumber ? all.filter((v) => typeof v === 'number') : all\n }\n\n if (def.innerType) {\n current = def.innerType\n continue\n }\n return undefined\n }\n return undefined\n}\n\n/**\n * Walk Valibot wrapper schemas (`optional`, `nullable`, `nullish`) via the\n * `wrapped` property, then read the enum members from `picklist` / `enum`\n * schemas.\n *\n * - `v.picklist(['a','b'])` → `options: ['a','b']`.\n * - `v.enum_(MyEnum)` (or `v.enum(...)` in v1+) → `options: [...]` already\n * filtered for numeric-enum bidirectional reverse mapping by Valibot.\n *\n * Confirmed against valibot@1.3.1.\n */\nconst extractValibotEnum: EnumExtractor = (spec) => {\n // biome-ignore lint/suspicious/noExplicitAny: Valibot internal schema shape\n let current: any = spec\n for (let i = 0; i < 8 && current !== null && typeof current === 'object'; i++) {\n if (current.kind !== 'schema' || typeof current.type !== 'string') return undefined\n\n if (current.type === 'picklist' || current.type === 'enum') {\n return Array.isArray(current.options) ? current.options : undefined\n }\n\n // Wrapper schemas — `optional`, `nullable`, `nullish`, `undefinedable`.\n // Each carries a `wrapped` property pointing to the inner schema.\n if (current.wrapped) {\n current = current.wrapped\n continue\n }\n return undefined\n }\n return undefined\n}\n\nconst builtInExtractors: readonly EnumExtractor[] = [extractZodEnum, extractValibotEnum]\n\n/**\n * Extract the enum values from a field spec, if any. Returns the array of\n * enum members or `undefined` if the spec doesn't expose them.\n *\n * Built-in support:\n * - Zod `z.enum(['a', 'b'])` and `z.nativeEnum(MyEnum)`, including wrapped\n * variants (`.default()`, `.optional()`, `.nullable()`).\n * - Valibot `v.picklist(['a', 'b'])` and `v.enum_(MyEnum)`, including wrapped\n * variants (`v.optional(...)`, `v.nullable(...)`, `v.nullish(...)`).\n *\n * For other Standard Schema libs (ArkType, Effect Schema) or custom shapes,\n * register a probe via {@link registerEnumExtractor}. Custom extractors run\n * before the built-ins. When nothing matches, `cycle()` throws and asks the\n * caller to pass options explicitly.\n */\nexport function extractEnumValues(spec: unknown): readonly unknown[] | undefined {\n for (const extractor of customExtractors) {\n const result = extractor(spec)\n if (result !== undefined) return result\n }\n for (const extractor of builtInExtractors) {\n const result = extractor(spec)\n if (result !== undefined) return result\n }\n return undefined\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  defaultSerialize
3
- } from "../chunk-NUO3GOXV.js";
3
+ } from "../chunk-5UKBDZTP.js";
4
4
 
5
5
  // src/storage/url/index.ts
6
6
  var isClient = typeof window !== "undefined";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victorylabs/params",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Centralized view-state config (filters, sort, pagination, app config) with pluggable storage. Memory by default; URL/localStorage/sessionStorage opt-in. Single React hook DX, schema-validated reads, cross-component sharing.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -85,8 +85,9 @@
85
85
  },
86
86
  "devDependencies": {
87
87
  "fake-indexeddb": "^6.0.0",
88
+ "valibot": "^1.3.1",
88
89
  "zod": "^3.23.8",
89
- "@victorylabs/forms": "0.1.0",
90
+ "@victorylabs/forms": "0.2.0",
90
91
  "@victorylabs/utils": "0.1.0"
91
92
  },
92
93
  "peerDependencies": {
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/schema.ts"],"sourcesContent":["import type { StandardSchemaV1 } from '@standard-schema/spec'\n\nimport type { FieldSpec, PlainFieldSpec } from './types'\n\nexport function isStandardSchema(spec: unknown): spec is StandardSchemaV1 {\n return (\n typeof spec === 'object' &&\n spec !== null &&\n '~standard' in spec &&\n typeof (spec as { '~standard'?: unknown })['~standard'] === 'object'\n )\n}\n\nexport function isPlainSpec<T>(spec: FieldSpec<T>): spec is PlainFieldSpec<T> {\n return !isStandardSchema(spec) && typeof spec === 'object' && spec !== null && 'default' in spec\n}\n\n/**\n * Resolve a field's default value.\n *\n * - Plain spec: `spec.default` (always present — required by the type).\n * - Standard Schema (Zod, Valibot, ArkType, …): parse `undefined` and use the\n * schema's `.default()` if it produced a value. Schemas without a default\n * return `undefined`.\n */\nexport function getDefault<T>(spec: FieldSpec<T>): T | undefined {\n if (isStandardSchema(spec)) {\n const result = spec['~standard'].validate(undefined)\n if (result instanceof Promise) return undefined\n if ('value' in result && !result.issues) return result.value as T\n return undefined\n }\n return (spec as PlainFieldSpec<T>).default\n}\n\n/**\n * Parse a raw storage value through the field's spec. Returns the typed value\n * on success, or `undefined` on parse failure (engine falls back to default).\n *\n * The engine records the failure reason in `ParamsStore.storageErrors` for\n * debugging; consumer code doesn't see it.\n */\nexport interface ParseResult<T> {\n ok: boolean\n value?: T\n reason?: string\n}\n\nexport function parseField<T>(spec: FieldSpec<T>, raw: unknown): ParseResult<T> {\n if (isStandardSchema(spec)) {\n const result = spec['~standard'].validate(raw)\n if (result instanceof Promise) {\n return { ok: false, reason: 'async-schema-not-supported-in-v0.1' }\n }\n if ('value' in result && !result.issues) return { ok: true, value: result.value as T }\n return { ok: false, reason: result.issues?.[0]?.message ?? 'parse-failed' }\n }\n\n const plainSpec = spec as PlainFieldSpec<T>\n if (typeof raw === 'string' && plainSpec.parse) {\n try {\n return { ok: true, value: plainSpec.parse(raw) }\n } catch (err) {\n return { ok: false, reason: err instanceof Error ? err.message : String(err) }\n }\n }\n // No custom parse: accept raw value as-is if it's a usable runtime value\n // (string, number, boolean, array, object, etc.). The schema-less plain spec\n // is mostly used for non-string-coerced flows (memory storage with raw values).\n if (raw === undefined) return { ok: false, reason: 'undefined-no-parse' }\n return { ok: true, value: raw as T }\n}\n\n/**\n * Serialize a typed value to its storage string representation. Used by URL\n * storage (every value must be a string) and other string-keyed backends.\n *\n * Plain spec uses its `serialize` if provided, else `String(value)`. Standard\n * schemas don't define an inverse — we fall back to `String(value)` (or\n * `JSON.stringify` for objects) and the consumer can override via per-field\n * `serialize` in the storage backend's options.\n */\nexport function defaultSerialize(value: unknown): string {\n if (value === undefined || value === null) return ''\n if (typeof value === 'string') return value\n if (typeof value === 'boolean') return value ? 'true' : 'false'\n if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''\n return JSON.stringify(value)\n}\n\n/**\n * Extract the enum values from a field spec, if any. Returns the array of\n * enum members or `undefined` if the spec doesn't expose them.\n *\n * Supports:\n * - Zod `z.enum(['a', 'b'])` — reads `_def.values: ['a', 'b']`.\n * - Zod `z.nativeEnum(MyEnum)` — reads `_def.values: <enum-object>`.\n * For numeric enums (TypeScript bidirectional `enum Color { Red = 0 }`),\n * `Object.values` returns BOTH the numbers and the reverse-mapped strings;\n * this helper detects the case and filters to numeric values only.\n *\n * Standard Schema doesn't define a uniform enum-introspection API, so libraries\n * other than Zod return `undefined` here — `cycle()` then throws and asks the\n * caller to pass options explicitly.\n */\nexport function extractEnumValues(spec: unknown): readonly unknown[] | undefined {\n // Walk Zod wrapper types (ZodDefault, ZodOptional, ZodNullable, …) to find\n // an underlying enum. `.default(...)` / `.optional()` / `.nullable()` produce\n // a wrapper whose `_def.innerType` points to the wrapped schema. Cap the walk\n // at a small depth — pathological self-referential schemas shouldn't loop.\n // biome-ignore lint/suspicious/noExplicitAny: Zod internal `_def` API\n let current: any = spec\n for (let i = 0; i < 8 && current !== null && typeof current === 'object'; i++) {\n const def = current._def\n if (!def) return undefined\n\n // z.enum(['a', 'b']): values is already a clean string array.\n if (Array.isArray(def.values)) return def.values\n\n // z.nativeEnum(MyEnum): values is the enum object. TS numeric enums are\n // BIDIRECTIONAL — Object.values returns numbers + reverse-mapped strings:\n // enum Color { Red = 0 } → Object.values(Color) = [0, 'Red'].\n // Detect numeric and filter; pure string enums are unidirectional and\n // return only strings.\n if (typeof def.values === 'object' && def.values !== null) {\n const all = Object.values(def.values)\n const hasNumber = all.some((v) => typeof v === 'number')\n return hasNumber ? all.filter((v) => typeof v === 'number') : all\n }\n\n if (def.innerType) {\n current = def.innerType\n continue\n }\n return undefined\n }\n return undefined\n}\n"],"mappings":";AAIO,SAAS,iBAAiB,MAAyC;AACxE,SACE,OAAO,SAAS,YAChB,SAAS,QACT,eAAe,QACf,OAAQ,KAAmC,WAAW,MAAM;AAEhE;AAEO,SAAS,YAAe,MAA+C;AAC5E,SAAO,CAAC,iBAAiB,IAAI,KAAK,OAAO,SAAS,YAAY,SAAS,QAAQ,aAAa;AAC9F;AAUO,SAAS,WAAc,MAAmC;AAC/D,MAAI,iBAAiB,IAAI,GAAG;AAC1B,UAAM,SAAS,KAAK,WAAW,EAAE,SAAS,MAAS;AACnD,QAAI,kBAAkB,QAAS,QAAO;AACtC,QAAI,WAAW,UAAU,CAAC,OAAO,OAAQ,QAAO,OAAO;AACvD,WAAO;AAAA,EACT;AACA,SAAQ,KAA2B;AACrC;AAeO,SAAS,WAAc,MAAoB,KAA8B;AAC9E,MAAI,iBAAiB,IAAI,GAAG;AAC1B,UAAM,SAAS,KAAK,WAAW,EAAE,SAAS,GAAG;AAC7C,QAAI,kBAAkB,SAAS;AAC7B,aAAO,EAAE,IAAI,OAAO,QAAQ,qCAAqC;AAAA,IACnE;AACA,QAAI,WAAW,UAAU,CAAC,OAAO,OAAQ,QAAO,EAAE,IAAI,MAAM,OAAO,OAAO,MAAW;AACrF,WAAO,EAAE,IAAI,OAAO,QAAQ,OAAO,SAAS,CAAC,GAAG,WAAW,eAAe;AAAA,EAC5E;AAEA,QAAM,YAAY;AAClB,MAAI,OAAO,QAAQ,YAAY,UAAU,OAAO;AAC9C,QAAI;AACF,aAAO,EAAE,IAAI,MAAM,OAAO,UAAU,MAAM,GAAG,EAAE;AAAA,IACjD,SAAS,KAAK;AACZ,aAAO,EAAE,IAAI,OAAO,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,EAAE;AAAA,IAC/E;AAAA,EACF;AAIA,MAAI,QAAQ,OAAW,QAAO,EAAE,IAAI,OAAO,QAAQ,qBAAqB;AACxE,SAAO,EAAE,IAAI,MAAM,OAAO,IAAS;AACrC;AAWO,SAAS,iBAAiB,OAAwB;AACvD,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,UAAW,QAAO,QAAQ,SAAS;AACxD,MAAI,OAAO,UAAU,SAAU,QAAO,OAAO,SAAS,KAAK,IAAI,OAAO,KAAK,IAAI;AAC/E,SAAO,KAAK,UAAU,KAAK;AAC7B;AAiBO,SAAS,kBAAkB,MAA+C;AAM/E,MAAI,UAAe;AACnB,WAAS,IAAI,GAAG,IAAI,KAAK,YAAY,QAAQ,OAAO,YAAY,UAAU,KAAK;AAC7E,UAAM,MAAM,QAAQ;AACpB,QAAI,CAAC,IAAK,QAAO;AAGjB,QAAI,MAAM,QAAQ,IAAI,MAAM,EAAG,QAAO,IAAI;AAO1C,QAAI,OAAO,IAAI,WAAW,YAAY,IAAI,WAAW,MAAM;AACzD,YAAM,MAAM,OAAO,OAAO,IAAI,MAAM;AACpC,YAAM,YAAY,IAAI,KAAK,CAAC,MAAM,OAAO,MAAM,QAAQ;AACvD,aAAO,YAAY,IAAI,OAAO,CAAC,MAAM,OAAO,MAAM,QAAQ,IAAI;AAAA,IAChE;AAEA,QAAI,IAAI,WAAW;AACjB,gBAAU,IAAI;AACd;AAAA,IACF;AACA,WAAO;AAAA,EACT;AACA,SAAO;AACT;","names":[]}