@recapi/client 0.0.0-stage → 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.
package/src/history.js ADDED
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Decayed user history, implemented exactly as docs/history-spec.md so the
3
+ * SDK, the Go server and the Python trainer produce the same numbers.
4
+ *
5
+ * All functions are pure: they never mutate their inputs and return a new
6
+ * state, which keeps the client free to reload/replace state from storage.
7
+ *
8
+ * @module history
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} HistoryConfig
13
+ * @property {number} halfLifeSeconds Time for a score to halve.
14
+ * @property {number} topK Max items kept in the weighted history.
15
+ * @property {number} recentSize Max items kept in the recent list.
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} TopEntry
20
+ * @property {number} s Score valid at time `t`.
21
+ * @property {number} t Unix ms of the last in-order interaction.
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} RecentEntry
26
+ * @property {string} item
27
+ * @property {number} t Unix ms of the interaction that placed the item here.
28
+ * Not part of the wire format; kept so two recent lists can be merged.
29
+ */
30
+
31
+ /**
32
+ * @typedef {object} HistoryState
33
+ * @property {Map<string, TopEntry>} top A Map (not an object) so item ids
34
+ * such as `__proto__` cannot collide with prototype properties.
35
+ * @property {RecentEntry[]} recent Oldest to newest.
36
+ * @property {Map<string, number>} rejected Items the user rejected, with the
37
+ * unix ms of the rejection. Never recommended until a newer positive event.
38
+ * @property {number|null} lastT Unix ms of the newest event applied to the
39
+ * recent list; `null` when unknown (empty or hydrated from a snapshot).
40
+ */
41
+
42
+ /**
43
+ * @typedef {[item: string, weight: number, ageSeconds: number]} WireEntry
44
+ */
45
+
46
+ /**
47
+ * @typedef {object} ServerSnapshot
48
+ * @property {Array<[string, number]>} top
49
+ * @property {string[]} recent
50
+ * @property {string[]} [rejected] Absent from servers older than rejections.
51
+ * @property {number} as_of Unix ms at which the `top` weights are valid.
52
+ */
53
+
54
+ /** Spec defaults; the server uses the same values unless configured otherwise. */
55
+ export const DEFAULT_CONFIG = Object.freeze({
56
+ halfLifeSeconds: 604800,
57
+ topK: 50,
58
+ recentSize: 20
59
+ });
60
+
61
+ /**
62
+ * Most rejection marks kept (the newest), so a user who rejects a lot cannot
63
+ * grow the stored history for ever. Not a setting: the spec fixes it.
64
+ */
65
+ export const REJECTED_SIZE = 200;
66
+
67
+ /**
68
+ * Fills missing fields with spec defaults so callers can override a single
69
+ * parameter (e.g. a shorter half-life for news sites).
70
+ *
71
+ * @param {Partial<HistoryConfig>} [partial]
72
+ * @returns {HistoryConfig}
73
+ */
74
+ export function resolveConfig(partial = {}) {
75
+ const config = { ...DEFAULT_CONFIG, ...partial };
76
+ for (const key of Object.keys(DEFAULT_CONFIG)) {
77
+ if (!(Number.isFinite(config[key]) && config[key] > 0)) {
78
+ throw new TypeError(`historyConfig.${key} must be a positive number`);
79
+ }
80
+ }
81
+ return config;
82
+ }
83
+
84
+ /**
85
+ * @returns {HistoryState}
86
+ */
87
+ export function emptyHistory() {
88
+ return { top: new Map(), recent: [], rejected: new Map(), lastT: null };
89
+ }
90
+
91
+ /**
92
+ * True when there is nothing to send, which is when the server falls back to
93
+ * its own stored history. Rejections alone don't count: the server still
94
+ * has the user's positive events, and it excludes both sets.
95
+ *
96
+ * @param {HistoryState} state
97
+ * @returns {boolean}
98
+ */
99
+ export function isEmpty(state) {
100
+ return state.top.size === 0 && state.recent.length === 0;
101
+ }
102
+
103
+ function decay(config, dtMs) {
104
+ return Math.exp((-Math.LN2 / config.halfLifeSeconds) * (dtMs / 1000));
105
+ }
106
+
107
+ /**
108
+ * Weight of an entry decayed to time `T`.
109
+ *
110
+ * @param {TopEntry} entry
111
+ * @param {number} T Unix ms.
112
+ * @param {HistoryConfig} config
113
+ * @returns {number}
114
+ */
115
+ export function weightAt(entry, T, config) {
116
+ return entry.s * decay(config, T - entry.t);
117
+ }
118
+
119
+ /**
120
+ * Entries sorted by weight at `T` descending, ties by item id ascending (code
121
+ * unit order, which matches Go and Python byte-wise comparison for ASCII ids).
122
+ *
123
+ * @param {HistoryState} state
124
+ * @param {number} T Unix ms.
125
+ * @param {HistoryConfig} config
126
+ * @returns {Array<{ item: string, weight: number, t: number }>}
127
+ */
128
+ export function sortedEntries(state, T, config) {
129
+ return rank(state.top, T, config);
130
+ }
131
+
132
+ /**
133
+ * @param {Map<string, TopEntry>} top
134
+ * @param {number} T
135
+ * @param {HistoryConfig} config
136
+ */
137
+ function rank(top, T, config) {
138
+ const entries = [];
139
+ for (const [item, entry] of top) {
140
+ entries.push({ item, weight: weightAt(entry, T, config), t: entry.t });
141
+ }
142
+ return entries.sort((a, b) => b.weight - a.weight || compareIds(a.item, b.item));
143
+ }
144
+
145
+ function compareIds(a, b) {
146
+ if (a < b) return -1;
147
+ return a > b ? 1 : 0;
148
+ }
149
+
150
+ /**
151
+ * @param {Map<string, TopEntry>} top
152
+ * @param {number} T
153
+ * @param {HistoryConfig} config
154
+ */
155
+ function prune(top, T, config) {
156
+ if (top.size <= config.topK) return top;
157
+ const kept = rank(top, T, config).slice(0, config.topK);
158
+ return new Map(kept.map(({ item }) => [item, top.get(item)]));
159
+ }
160
+
161
+ /**
162
+ * Keeps the newest {@link REJECTED_SIZE} marks; the order is oldest mark
163
+ * first, ties by item id, as in the spec.
164
+ *
165
+ * @param {Map<string, number>} rejected
166
+ */
167
+ function pruneRejected(rejected) {
168
+ if (rejected.size <= REJECTED_SIZE) return rejected;
169
+ return new Map(rejectedOrder(rejected).slice(-REJECTED_SIZE).map((item) => [item, rejected.get(item)]));
170
+ }
171
+
172
+ /** @param {Map<string, number>} rejected */
173
+ function rejectedOrder(rejected) {
174
+ return [...rejected.keys()].sort((a, b) => rejected.get(a) - rejected.get(b) || compareIds(a, b));
175
+ }
176
+
177
+ /**
178
+ * A rejection drops the item's score and recent position and marks it,
179
+ * unless a newer positive event on it was already applied. Dropped, not
180
+ * lowered: a lowered score would decay back towards 0 and the item would
181
+ * return. It never moves the recent list's clock (`lastT`).
182
+ *
183
+ * @param {HistoryState} state
184
+ * @param {string} item
185
+ * @param {number} t
186
+ * @returns {HistoryState}
187
+ */
188
+ function reject(state, item, t) {
189
+ const current = state.top.get(item);
190
+ if (current && current.t > t) return state;
191
+ const top = new Map(state.top);
192
+ top.delete(item);
193
+ const rejected = new Map(state.rejected);
194
+ rejected.set(item, Math.max(t, rejected.get(item) ?? t));
195
+ return {
196
+ top,
197
+ recent: state.recent.filter((entry) => entry.item !== item),
198
+ rejected: pruneRejected(rejected),
199
+ lastT: state.lastT
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Applies one interaction. Level 0 (impression) returns the state unchanged,
205
+ * a negative level is a rejection. Events older than the newest one already
206
+ * applied ("out of order") still add their decayed weight but never touch the
207
+ * recent list, so the result does not depend on network reordering more than
208
+ * the spec allows.
209
+ *
210
+ * @param {HistoryState} state
211
+ * @param {{ item: string, level: number, t: number }} event `t` in unix ms.
212
+ * @param {HistoryConfig} config
213
+ * @returns {HistoryState}
214
+ */
215
+ export function applyEvent(state, { item, level, t }, config) {
216
+ if (level === 0) return state;
217
+ if (level < 0) return reject(state, item, t);
218
+
219
+ let rejected = state.rejected;
220
+ if (rejected.has(item)) {
221
+ if (t < rejected.get(item)) return state;
222
+ rejected = new Map(rejected);
223
+ rejected.delete(item);
224
+ }
225
+
226
+ const top = new Map(state.top);
227
+ const current = top.get(item);
228
+ if (!current) {
229
+ top.set(item, { s: level, t });
230
+ } else if (t >= current.t) {
231
+ top.set(item, { s: current.s * decay(config, t - current.t) + level, t });
232
+ } else {
233
+ top.set(item, { s: current.s + level * decay(config, current.t - t), t: current.t });
234
+ }
235
+
236
+ const inOrder = state.lastT === null || t >= state.lastT;
237
+ let recent = state.recent;
238
+ if (inOrder) {
239
+ recent = state.recent.filter((entry) => entry.item !== item);
240
+ recent.push({ item, t });
241
+ recent = recent.slice(-config.recentSize);
242
+ }
243
+
244
+ return {
245
+ top: prune(top, t, config),
246
+ recent,
247
+ rejected,
248
+ lastT: inOrder ? t : state.lastT
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Wire format of a recommendation request: weights decayed to `T` and
254
+ * rounded to 4 decimals, integer age in seconds (clamped at 0 so a skewed
255
+ * clock never yields negative ages). `rejected` (oldest mark first) is sent
256
+ * in the request's `exclude`.
257
+ *
258
+ * @param {HistoryState} state
259
+ * @param {number} T Unix ms of the request.
260
+ * @param {HistoryConfig} config
261
+ * @returns {{ history: WireEntry[], recent: string[], rejected: string[] }}
262
+ */
263
+ export function toWire(state, T, config) {
264
+ const history = sortedEntries(state, T, config)
265
+ .slice(0, config.topK)
266
+ .map(({ item, weight, t }) => /** @type {WireEntry} */ ([
267
+ item,
268
+ Math.round(weight * 1e4) / 1e4,
269
+ Math.max(0, Math.floor((T - t) / 1000))
270
+ ]));
271
+ const recent = state.recent.slice(-config.recentSize).map((entry) => entry.item);
272
+ return { history, recent, rejected: rejectedOrder(state.rejected) };
273
+ }
274
+
275
+ /**
276
+ * Builds a state from the server's fallback snapshot. Each top entry becomes
277
+ * `{ s: weight, t: as_of }` as the spec requires, each rejected item a mark
278
+ * at `as_of`. `lastT` is left unknown
279
+ * rather than set to `as_of`: `as_of` is the server clock, and a device clock
280
+ * running behind it would otherwise treat every new local event as out of
281
+ * order and freeze the recent list.
282
+ *
283
+ * Invalid entries are skipped instead of rejecting the whole snapshot.
284
+ *
285
+ * @param {ServerSnapshot} snapshot
286
+ * @param {HistoryConfig} config
287
+ * @returns {HistoryState}
288
+ */
289
+ export function fromSnapshot(snapshot, config) {
290
+ const asOf = snapshot?.as_of;
291
+ if (!Number.isFinite(asOf)) return emptyHistory();
292
+
293
+ const top = new Map();
294
+ for (const entry of Array.isArray(snapshot.top) ? snapshot.top : []) {
295
+ if (Array.isArray(entry) && isId(entry[0]) && isWeight(entry[1])) {
296
+ top.set(entry[0], { s: entry[1], t: asOf });
297
+ }
298
+ }
299
+ const seen = new Set();
300
+ const recent = [];
301
+ for (const item of Array.isArray(snapshot.recent) ? snapshot.recent : []) {
302
+ if (isId(item) && !seen.has(item)) {
303
+ seen.add(item);
304
+ recent.push({ item, t: asOf });
305
+ }
306
+ }
307
+ const rejected = new Map();
308
+ for (const item of Array.isArray(snapshot.rejected) ? snapshot.rejected : []) {
309
+ if (isId(item)) rejected.set(item, asOf);
310
+ }
311
+ return {
312
+ top: prune(top, asOf, config),
313
+ recent: recent.slice(-config.recentSize),
314
+ rejected: pruneRejected(rejected),
315
+ lastT: null
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Merges two histories built from disjoint events (e.g. the anonymous device
321
+ * history and the account history fetched at login). Summing assumes the
322
+ * events are disjoint: merging a history with itself doubles every weight.
323
+ *
324
+ * Top: per item, the sum of both decayed scores. The sum is expressed at the
325
+ * later of the two `t`s, which keeps the true last-interaction time (and so
326
+ * the wire `age_seconds`) instead of resetting it to the merge time; the
327
+ * weight at any later time equals the sum of the two weights.
328
+ *
329
+ * Recent: union of both lists ordered by interaction time (oldest first); an
330
+ * item in both lists takes its most recent time. Equal times keep `a`'s order
331
+ * before `b`'s. The last `recentSize` items are kept.
332
+ *
333
+ * Rejected: union of both marks (the newer time when in both). As in
334
+ * {@link applyEvent}, a mark older than the item's merged top entry is
335
+ * dropped; otherwise the item leaves the top and the recent list.
336
+ *
337
+ * @param {HistoryState} a
338
+ * @param {HistoryState} b
339
+ * @param {number} T Unix ms of the merge, used for pruning.
340
+ * @param {HistoryConfig} config
341
+ * @returns {HistoryState}
342
+ */
343
+ export function merge(a, b, T, config) {
344
+ const top = new Map(a.top);
345
+ for (const [item, entry] of b.top) {
346
+ const other = top.get(item);
347
+ if (!other) {
348
+ top.set(item, entry);
349
+ continue;
350
+ }
351
+ const t = Math.max(entry.t, other.t);
352
+ top.set(item, { s: weightAt(entry, t, config) + weightAt(other, t, config), t });
353
+ }
354
+
355
+ const rejected = new Map(a.rejected);
356
+ for (const [item, t] of b.rejected) rejected.set(item, Math.max(t, rejected.get(item) ?? t));
357
+ for (const [item, t] of rejected) {
358
+ const entry = top.get(item);
359
+ if (entry && entry.t > t) rejected.delete(item);
360
+ else top.delete(item);
361
+ }
362
+
363
+ const byItem = new Map();
364
+ [...a.recent, ...b.recent].forEach((entry, order) => {
365
+ const existing = byItem.get(entry.item);
366
+ if (!existing || entry.t > existing.t) byItem.set(entry.item, { ...entry, order });
367
+ });
368
+ // Rejected items go before the trim, so they don't cost the list its places.
369
+ const recent = [...byItem.values()]
370
+ .filter(({ item }) => !rejected.has(item))
371
+ .sort((x, y) => x.t - y.t || x.order - y.order)
372
+ .slice(-config.recentSize)
373
+ .map(({ item, t }) => ({ item, t }));
374
+
375
+ const times = [a.lastT, b.lastT].filter((t) => t !== null);
376
+ return {
377
+ top: prune(top, T, config),
378
+ recent,
379
+ rejected: pruneRejected(rejected),
380
+ lastT: times.length ? Math.max(...times) : null
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Merges a server snapshot ({@link fromSnapshot}) into the local history,
386
+ * as {@link merge} does, except for the items this device rejected. The
387
+ * snapshot dates its scores `as_of`, which only says their events came
388
+ * before it; a local rejection is usually one the server hasn't stored yet
389
+ * (still queued, or tracked before login). Compared with `as_of`, it would
390
+ * lose to the very events the user then said no to, so it wins instead.
391
+ *
392
+ * @param {HistoryState} local
393
+ * @param {HistoryState} snapshot
394
+ * @param {number} T Unix ms of the merge, used for pruning.
395
+ * @param {HistoryConfig} config
396
+ * @returns {HistoryState}
397
+ */
398
+ export function mergeSnapshot(local, snapshot, T, config) {
399
+ const top = new Map([...snapshot.top].filter(([item]) => !local.rejected.has(item)));
400
+ return merge(local, { ...snapshot, top }, T, config);
401
+ }
402
+
403
+ /**
404
+ * Compact JSON-safe form for storage. Kept separate from the wire format
405
+ * because storage needs raw `{s, t}` (not rounded weights) to stay exact.
406
+ *
407
+ * @param {HistoryState} state
408
+ * @returns {{ top: Array<[string, number, number]>, recent: Array<[string, number]>,
409
+ * rejected: Array<[string, number]>, lastT: number|null }}
410
+ */
411
+ export function encodeState(state) {
412
+ return {
413
+ top: [...state.top].map(([item, { s, t }]) => [item, s, t]),
414
+ recent: state.recent.map(({ item, t }) => [item, t]),
415
+ rejected: [...state.rejected],
416
+ lastT: state.lastT
417
+ };
418
+ }
419
+
420
+ /**
421
+ * Inverse of {@link encodeState}. Returns `null` on any malformed field so
422
+ * the caller discards corrupt storage instead of sending garbage. A state
423
+ * stored before rejections existed has no `rejected` and decodes as none.
424
+ *
425
+ * An entry whose id the API refuses is dropped and the rest kept: earlier
426
+ * SDKs counted characters, not bytes, and stored such ids, which made every
427
+ * recommendation request fail until they aged out. Discarding the whole
428
+ * state would also lose the device's anonymous id.
429
+ *
430
+ * @param {any} raw Parsed JSON of unknown shape.
431
+ * @returns {HistoryState|null}
432
+ */
433
+ export function decodeState(raw) {
434
+ if (!raw || !Array.isArray(raw.top) || !Array.isArray(raw.recent)) return null;
435
+ if (raw.lastT !== null && !Number.isFinite(raw.lastT)) return null;
436
+
437
+ const top = new Map();
438
+ for (const entry of raw.top) {
439
+ if (!Array.isArray(entry) || typeof entry[0] !== 'string' || !isWeight(entry[1]) || !Number.isFinite(entry[2])) {
440
+ return null;
441
+ }
442
+ if (isId(entry[0])) top.set(entry[0], { s: entry[1], t: entry[2] });
443
+ }
444
+ const recent = [];
445
+ for (const entry of raw.recent) {
446
+ if (!Array.isArray(entry) || typeof entry[0] !== 'string' || !Number.isFinite(entry[1])) return null;
447
+ if (isId(entry[0])) recent.push({ item: entry[0], t: entry[1] });
448
+ }
449
+ if (raw.rejected !== undefined && !Array.isArray(raw.rejected)) return null;
450
+ const rejected = new Map();
451
+ for (const entry of raw.rejected ?? []) {
452
+ if (!Array.isArray(entry) || typeof entry[0] !== 'string' || !Number.isFinite(entry[1])) return null;
453
+ if (isId(entry[0])) rejected.set(entry[0], entry[1]);
454
+ }
455
+ return { top, recent, rejected: pruneRejected(rejected), lastT: raw.lastT };
456
+ }
457
+
458
+ /** The API's limit on item and user ids, in bytes of UTF-8. */
459
+ const MAX_ID_BYTES = 256;
460
+
461
+ // Go's unicode.IsControl, which the API applies to every id: C0, DEL and C1.
462
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
463
+
464
+ /**
465
+ * Ids the API accepts (item and user ids): 1–256 bytes of UTF-8 without
466
+ * control characters. Counting characters instead let a 200-character
467
+ * Cyrillic or CJK id into the history, and the API refused every request
468
+ * that carried it.
469
+ *
470
+ * @param {unknown} value
471
+ * @returns {value is string}
472
+ */
473
+ export function isId(value) {
474
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ID_BYTES || CONTROL.test(value)) {
475
+ return false;
476
+ }
477
+ // A UTF-16 code unit is at most 3 bytes of UTF-8 (a surrogate pair, two
478
+ // units, is 4), so ids of up to 64 units fit without counting.
479
+ return value.length <= 64 || utf8Length(value) <= MAX_ID_BYTES;
480
+ }
481
+
482
+ /**
483
+ * A lone surrogate counts 3 bytes: JSON sends it escaped, and the API
484
+ * decodes it as U+FFFD.
485
+ *
486
+ * @param {string} text
487
+ * @returns {number}
488
+ */
489
+ function utf8Length(text) {
490
+ let bytes = 0;
491
+ for (const char of text) {
492
+ const code = /** @type {number} */ (char.codePointAt(0));
493
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
494
+ }
495
+ return bytes;
496
+ }
497
+
498
+ function isWeight(value) {
499
+ return Number.isFinite(value) && value >= 0;
500
+ }
package/src/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Public entry point of `@recapi/client`.
3
+ *
4
+ * @module @recapi/client
5
+ */
6
+
7
+ export { createClient } from './client.js';
8
+ export { RecapiError } from './transport.js';
9
+ export { memoryStorage, localStorageAdapter } from './storage.js';
10
+ export {
11
+ DEFAULT_CONFIG,
12
+ resolveConfig,
13
+ emptyHistory,
14
+ applyEvent,
15
+ weightAt,
16
+ sortedEntries,
17
+ toWire,
18
+ fromSnapshot,
19
+ merge
20
+ } from './history.js';
package/src/storage.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Storage adapters and the versioned, corruption-tolerant store the client
3
+ * persists its state in.
4
+ *
5
+ * Adapters are synchronous key/value stores (the `localStorage` shape) so the
6
+ * history is available immediately on page load without awaiting anything.
7
+ * Async stores such as React Native's AsyncStorage can be used by wrapping
8
+ * them in an in-memory cache that is preloaded before `createClient()`.
9
+ *
10
+ * @module storage
11
+ */
12
+
13
+ /**
14
+ * @typedef {object} StorageAdapter
15
+ * @property {(key: string) => string | null} getItem
16
+ * @property {(key: string, value: string) => void} setItem
17
+ * @property {(key: string) => void} removeItem
18
+ */
19
+
20
+ /** Bumped when the stored shape changes; old keys are then simply ignored. */
21
+ export const STORAGE_VERSION = 1;
22
+
23
+ /**
24
+ * In-memory adapter: the default in Node and the fallback when
25
+ * `localStorage` is unavailable. State lives as long as the process/page.
26
+ *
27
+ * @returns {StorageAdapter}
28
+ */
29
+ export function memoryStorage() {
30
+ const data = new Map();
31
+ return {
32
+ getItem: (key) => (data.has(key) ? data.get(key) : null),
33
+ setItem: (key, value) => {
34
+ data.set(key, String(value));
35
+ },
36
+ removeItem: (key) => {
37
+ data.delete(key);
38
+ }
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Wraps `window.localStorage`, or returns `null` when it is unusable.
44
+ * Merely reading `window.localStorage` throws when cookies are blocked, and
45
+ * Safari private mode / full quotas throw on `setItem`; every access is
46
+ * therefore guarded so storage problems never reach the host page. Failed
47
+ * writes are reported through `onError` and otherwise ignored (the in-memory
48
+ * state keeps working for the current page).
49
+ *
50
+ * @param {(error: unknown) => void} [onError]
51
+ * @returns {StorageAdapter | null}
52
+ */
53
+ export function localStorageAdapter(onError = () => {}) {
54
+ let ls;
55
+ try {
56
+ ls = globalThis.localStorage;
57
+ if (!ls) return null;
58
+ const probe = '__recapi_probe__';
59
+ ls.setItem(probe, probe);
60
+ ls.removeItem(probe);
61
+ } catch {
62
+ return null;
63
+ }
64
+ return {
65
+ getItem(key) {
66
+ try {
67
+ return ls.getItem(key);
68
+ } catch (error) {
69
+ onError(error);
70
+ return null;
71
+ }
72
+ },
73
+ setItem(key, value) {
74
+ try {
75
+ ls.setItem(key, value);
76
+ } catch (error) {
77
+ onError(error);
78
+ }
79
+ },
80
+ removeItem(key) {
81
+ try {
82
+ ls.removeItem(key);
83
+ } catch (error) {
84
+ onError(error);
85
+ }
86
+ }
87
+ };
88
+ }
89
+
90
+ /**
91
+ * `localStorage` when usable, otherwise memory.
92
+ *
93
+ * @param {(error: unknown) => void} [onError]
94
+ * @returns {StorageAdapter}
95
+ */
96
+ export function defaultStorage(onError) {
97
+ return localStorageAdapter(onError) ?? memoryStorage();
98
+ }
99
+
100
+ /**
101
+ * Key under which the client state is stored. Namespaced per project so two
102
+ * recapi tenants embedded on the same origin never share a history.
103
+ *
104
+ * @param {string} namespace
105
+ * @returns {string}
106
+ */
107
+ export function storageKey(namespace) {
108
+ return `recapi:v${STORAGE_VERSION}:${namespace}`;
109
+ }
110
+
111
+ /**
112
+ * JSON store over an adapter. Even custom adapters may throw, so every call
113
+ * is guarded. Data that fails to parse or validate is removed so it cannot
114
+ * break every subsequent page load.
115
+ *
116
+ * @template T
117
+ * @param {StorageAdapter} adapter
118
+ * @param {string} key
119
+ * @param {(raw: unknown) => T | null} validate Returns `null` for corrupt data.
120
+ * @param {(error: unknown) => void} onError
121
+ * @returns {{ load: () => T | null, save: (value: unknown) => boolean, remove: () => void }}
122
+ * `save` returns whether the write succeeded.
123
+ */
124
+ export function createStore(adapter, key, validate, onError) {
125
+ const guard = (fn, fallback) => {
126
+ try {
127
+ return fn();
128
+ } catch (error) {
129
+ onError(error);
130
+ return fallback;
131
+ }
132
+ };
133
+ return {
134
+ load() {
135
+ const text = guard(() => adapter.getItem(key), null);
136
+ if (text === null || text === undefined) return null;
137
+ let value = null;
138
+ try {
139
+ value = validate(JSON.parse(text));
140
+ } catch {
141
+ value = null;
142
+ }
143
+ if (value === null) guard(() => adapter.removeItem(key));
144
+ return value;
145
+ },
146
+ save(value) {
147
+ return guard(() => {
148
+ adapter.setItem(key, JSON.stringify(value));
149
+ return true;
150
+ }, false);
151
+ },
152
+ remove() {
153
+ guard(() => adapter.removeItem(key));
154
+ }
155
+ };
156
+ }