@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/LICENSE +21 -0
- package/README.md +562 -2
- package/index.d.ts +188 -0
- package/package.json +26 -4
- package/src/client.js +678 -0
- package/src/history.js +500 -0
- package/src/index.js +20 -0
- package/src/storage.js +156 -0
- package/src/transport.js +195 -0
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
|
+
}
|