local-replica 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,580 @@
1
+ import { T as stableQueryKey, i as createReplicaEngine, n as isReplicaSyncKey, r as replicaKeys } from "../keys-BTsqwfEa.js";
2
+ import { useEffect, useLayoutEffect, useRef } from "react";
3
+ // src/zustand/createReplicaSlice.ts
4
+ /** `slice.fetch` was asked to fetch for a scope that is no longer active. */
5
+ var ReplicaScopeChangedError = class extends Error {
6
+ constructor(name, scope) {
7
+ super(`[replica] ${name}: scope ${scope} is no longer active`);
8
+ this.name = "ReplicaScopeChangedError";
9
+ }
10
+ };
11
+ /**
12
+ * Bind a replica to a domain Zustand store.
13
+ *
14
+ * The domain store stays the only UI source of truth: components read the
15
+ * `view` location through their usual selectors. The engine owns every
16
+ * transition of that location; this slice adds the Zustand port and a
17
+ * `useSync` hook that only orchestrates fetching — data never flows through
18
+ * its return value.
19
+ */
20
+ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, view, ...options }) => {
21
+ /**
22
+ * Apply every view write as ONE host update, so subscribers never observe a
23
+ * view out of step with its bookkeeping. Consecutive key writes go through
24
+ * the lens' `setMany` when it has one, so seeding N rows clones the backing
25
+ * field once instead of N times.
26
+ */
27
+ const applyWrites = (state, writes) => {
28
+ let patch = {};
29
+ let current = state;
30
+ let pending = [];
31
+ const flush = () => {
32
+ if (pending.length === 0) return;
33
+ const entries = pending;
34
+ pending = [];
35
+ if (view.setMany && entries.length > 1) {
36
+ const next = view.setMany(current, entries);
37
+ patch = {
38
+ ...patch,
39
+ ...next
40
+ };
41
+ current = {
42
+ ...current,
43
+ ...next
44
+ };
45
+ return;
46
+ }
47
+ for (const entry of entries) {
48
+ const next = view.set(current, entry.key, entry.data);
49
+ patch = {
50
+ ...patch,
51
+ ...next
52
+ };
53
+ current = {
54
+ ...current,
55
+ ...next
56
+ };
57
+ }
58
+ };
59
+ for (const write of writes) if ("type" in write) {
60
+ flush();
61
+ const next = view.clear(current);
62
+ patch = {
63
+ ...patch,
64
+ ...next
65
+ };
66
+ current = {
67
+ ...current,
68
+ ...next
69
+ };
70
+ } else pending.push({
71
+ data: write.data,
72
+ key: write.key
73
+ });
74
+ flush();
75
+ return patch;
76
+ };
77
+ const revalidate = (key) => driver.revalidate((queryKey) => isReplicaSyncKey(queryKey, resource.name, {
78
+ key,
79
+ scope: resource.scope.get()
80
+ }));
81
+ const engine = createReplicaEngine(resource, {
82
+ ...options,
83
+ port: {
84
+ commit: (writes, state, label) => set({
85
+ ...applyWrites(get(), writes),
86
+ [stateKey]: state
87
+ }, false, label),
88
+ getState: () => get()[stateKey],
89
+ keys: view.keys && (() => view.keys(get())),
90
+ read: (key) => view.get(get(), key)
91
+ },
92
+ revalidate
93
+ });
94
+ const { applyHead, clearGeneration, ensureScope, fetcher, hydrate, isMissingHead, removalGeneration, retain } = engine;
95
+ /**
96
+ * Row key of the head query each entry last asked for. One entry backs a
97
+ * single view, so a response that belongs to a query the entry has already
98
+ * moved past must not repaint it — it would show a superseded query, and a
99
+ * view gated on the current query would then wait forever. Making this an
100
+ * invariant of the slice (not of one driver) keeps it true for drivers that
101
+ * leave a request's own callback live after the query changed.
102
+ */
103
+ const headQuery = /* @__PURE__ */ new Map();
104
+ /**
105
+ * Entries (`scope` + key) whose one-shot hydration actually painted a
106
+ * persisted row. The hydration query settles `true` on a cache miss too, so
107
+ * a reactivated key may only replay `onHydrated` when its row really came from
108
+ * storage — not off a provisional seed or another source that happens to sit
109
+ * in the view.
110
+ */
111
+ const hydratedEntries = /* @__PURE__ */ new Set();
112
+ const hydratedEntryId = (scope, key) => `${scope}\u0000${key}`;
113
+ let requestSeq = 0;
114
+ /**
115
+ * Requests whose start order is at or below this number are retired, per
116
+ * request id: a newer response already applied, or a `fresh` fetch started
117
+ * after them. Their late response would repaint an older value.
118
+ *
119
+ * An entry only matters while a request it retires is still unsettled:
120
+ * every request started later has a higher sequence, so a missing entry
121
+ * (read as 0) admits exactly the same responses. Entries are pruned as soon
122
+ * as no sent request at or below them is pending, so the map holds only the
123
+ * ids with an older request in flight.
124
+ */
125
+ const retiredThrough = /* @__PURE__ */ new Map();
126
+ /** Sequences of the sent requests that have not settled yet, per request id. */
127
+ const unsettled = /* @__PURE__ */ new Map();
128
+ /** Drop the retirement of `requestId` once no request it retires is pending. */
129
+ const pruneRetired = (requestId) => {
130
+ const through = retiredThrough.get(requestId);
131
+ if (through === void 0) return;
132
+ for (const seq of unsettled.get(requestId) ?? []) if (seq <= through) return;
133
+ retiredThrough.delete(requestId);
134
+ };
135
+ const retire = (requestId, seq) => {
136
+ if ((retiredThrough.get(requestId) ?? 0) < seq) retiredThrough.set(requestId, seq);
137
+ pruneRetired(requestId);
138
+ if (retiredThrough.size <= 5e3) return;
139
+ for (const id of retiredThrough.keys()) {
140
+ pruneRetired(id);
141
+ if (retiredThrough.size <= 5e3) return;
142
+ }
143
+ };
144
+ const trackUnsettled = ({ requestId, seq }) => {
145
+ const pending = unsettled.get(requestId) ?? /* @__PURE__ */ new Set();
146
+ unsettled.set(requestId, pending);
147
+ pending.add(seq);
148
+ };
149
+ const untrackUnsettled = ({ requestId, seq }) => {
150
+ const pending = unsettled.get(requestId);
151
+ if (!pending?.delete(seq)) return;
152
+ if (pending.size === 0) unsettled.delete(requestId);
153
+ pruneRetired(requestId);
154
+ };
155
+ /** The latest request in flight per id, which a non-`fresh` `fetch` joins. */
156
+ const inflight = /* @__PURE__ */ new Map();
157
+ /** `useSync` requests in flight per id (`fetch` seeds the driver only while none is). */
158
+ const syncInflight = /* @__PURE__ */ new Map();
159
+ /**
160
+ * `useSync` request each sync id last started, settled by the driver's
161
+ * callback. The driver settles one request per key at a time (a newer
162
+ * request supersedes the older one), so the latest start is the one that
163
+ * settles.
164
+ */
165
+ const syncRequests = /* @__PURE__ */ new Map();
166
+ const syncKeyOf = (params, scope, key) => resource.syncKey?.(params) ?? replicaKeys.sync(resource.name, resource.version, scope, key, params);
167
+ /** Capture what a head request for `params` started under. */
168
+ const begin = (params, scope, source) => {
169
+ const key = resource.key(params);
170
+ const syncKey = syncKeyOf(params, scope, key);
171
+ return {
172
+ clearGeneration: clearGeneration(scope),
173
+ key,
174
+ params,
175
+ queryKey: resource.storageKey(params),
176
+ removalGeneration: removalGeneration(scope, key),
177
+ requestId: `${scope}\u0000${stableQueryKey(syncKey)}`,
178
+ scope,
179
+ seq: ++requestSeq,
180
+ source,
181
+ syncKey
182
+ };
183
+ };
184
+ /** Send the request and track it in flight until it settles. */
185
+ const send = (request) => {
186
+ const { requestId } = request;
187
+ const sent = fetcher(request.params, void 0);
188
+ const promise = Promise.resolve(sent);
189
+ request.promise = promise;
190
+ trackUnsettled(request);
191
+ inflight.set(requestId, request);
192
+ if (request.source === "sync") syncInflight.set(requestId, (syncInflight.get(requestId) ?? 0) + 1);
193
+ const done = () => {
194
+ if (inflight.get(requestId) === request) inflight.delete(requestId);
195
+ if (request.source !== "sync") return;
196
+ const count = (syncInflight.get(requestId) ?? 1) - 1;
197
+ if (count > 0) syncInflight.set(requestId, count);
198
+ else syncInflight.delete(requestId);
199
+ };
200
+ promise.then(done, done);
201
+ if (request.source === "sync") promise.then((data) => settle(request, { data }), (error) => settle(request, { error }));
202
+ return sent;
203
+ };
204
+ /**
205
+ * Whether the request still answers the query its entry shows. A `useSync`
206
+ * request must match the query the entry last asked for (see `headQuery`). A
207
+ * `fetch` may also land with no view mounted, but never over a view that
208
+ * shows another query of the key.
209
+ */
210
+ const isActiveQuery = ({ key, queryKey, source }) => {
211
+ const head = headQuery.get(key);
212
+ return source === "sync" ? head === queryKey : head === void 0 || head === queryKey;
213
+ };
214
+ const settleOnce = (request, outcome) => {
215
+ const { key, params, scope } = request;
216
+ /**
217
+ * Whether the entry is still on the scope the request was captured under.
218
+ *
219
+ * A response can settle after an identity/workspace switch. `replace` drops
220
+ * it — the engine refuses off-scope actions — but no side effect may run
221
+ * either: it carries the previous identity's state (onboarding redirect,
222
+ * display snapshot, analytics identify, settings writes). The captured
223
+ * key/scope is not enough on its own, because a switch to a scope that
224
+ * reads the same key/query satisfies the other guards.
225
+ */
226
+ const isActive = resource.scope.get() === scope;
227
+ const dropped = {
228
+ applied: false,
229
+ current: false,
230
+ isActive,
231
+ missing: false
232
+ };
233
+ if (request.clearGeneration !== clearGeneration(scope) || request.removalGeneration !== removalGeneration(scope, key) || request.seq <= (retiredThrough.get(request.requestId) ?? 0) || !isActiveQuery(request)) return dropped;
234
+ if ("error" in outcome) {
235
+ effects?.onError?.(outcome.error, params, {
236
+ isActive,
237
+ key,
238
+ scope
239
+ });
240
+ return {
241
+ ...dropped,
242
+ current: true
243
+ };
244
+ }
245
+ if (!isActive) {
246
+ const missing = isMissingHead(outcome.data);
247
+ if (missing) {
248
+ remove(key, scope);
249
+ effects?.onPurge?.(params, {
250
+ key,
251
+ scope,
252
+ source: request.source
253
+ });
254
+ }
255
+ return {
256
+ ...dropped,
257
+ current: true,
258
+ missing
259
+ };
260
+ }
261
+ retire(request.requestId, request.seq - 1);
262
+ const { outcome: folded } = applyHead(params, outcome.data, scope);
263
+ const context = {
264
+ key,
265
+ scope,
266
+ source: request.source
267
+ };
268
+ if (folded === "replaced") effects?.onApplied?.(outcome.data, params, context);
269
+ else if (folded === "missing") effects?.onMissing?.(params, context);
270
+ return {
271
+ applied: folded !== "dropped",
272
+ current: true,
273
+ isActive,
274
+ missing: folded === "missing"
275
+ };
276
+ };
277
+ /**
278
+ * Fold a settled head request into the replica: apply every guard, then
279
+ * `replace` / remove a terminal absence (in the captured scope) / purge one
280
+ * captured off-scope, then run the resource effects. A request is settled
281
+ * once: a `fetch` that joined a `useSync` request shares its result.
282
+ */
283
+ const settle = (request, outcome) => {
284
+ if (request.settled) return request.settled;
285
+ request.settledWith = "error" in outcome ? outcome.error : outcome.data;
286
+ request.settled = settleOnce(request, outcome);
287
+ untrackUnsettled(request);
288
+ if (request.source === "sync") releaseSyncRequest(request);
289
+ return request.settled;
290
+ };
291
+ /**
292
+ * Drop a settled `useSync` request's record once its driver callback had its
293
+ * chance to read it. The record is normally taken by that callback, but the
294
+ * driver skips it when the hook changed key, unmounted or was superseded —
295
+ * the request still settles on its own promise, and its record (holding the
296
+ * response) must not stay behind. A driver runs the callback in the same
297
+ * turn the response resolves (SWR does, in microtasks), so a macrotask later
298
+ * the record is only a leak.
299
+ */
300
+ const releaseSyncRequest = (request) => {
301
+ setTimeout(() => {
302
+ if (syncRequests.get(request.requestId) === request) syncRequests.delete(request.requestId);
303
+ }, 0);
304
+ };
305
+ /**
306
+ * Fetch the head of an entry now and settle it like a `useSync` response —
307
+ * for stores that need the data while no view is mounted (a hover prefetch,
308
+ * a refresh after a mutation on a route without the list). `revalidate` only
309
+ * re-runs mounted queries.
310
+ *
311
+ * Joins a request in flight for the same entry unless `fresh`. Resolves with
312
+ * what was fetched and whether it was applied; rejects when the request
313
+ * fails, after routing the failure through `effects.onError`.
314
+ */
315
+ const fetch = async (params, { fresh = false, scope = resource.scope.get() } = {}) => {
316
+ if (!fetcher) throw new Error(`[replica] ${resource.name} has no fetcher`);
317
+ if (scope !== resource.scope.get()) throw new ReplicaScopeChangedError(resource.name, scope);
318
+ let request = begin(params, scope, "fetch");
319
+ const joined = fresh ? void 0 : inflight.get(request.requestId);
320
+ if (joined) request = joined;
321
+ else {
322
+ if (fresh) retire(request.requestId, request.seq - 1);
323
+ send(request);
324
+ }
325
+ let data;
326
+ try {
327
+ data = await request.promise;
328
+ } catch (error) {
329
+ settle(request, { error });
330
+ throw error;
331
+ }
332
+ const settled = settle(request, { data });
333
+ if (settled.applied && request.source === "fetch" && !syncInflight.has(request.requestId)) driver.seed?.(request.syncKey, data);
334
+ return {
335
+ applied: settled.applied,
336
+ data,
337
+ missing: settled.missing
338
+ };
339
+ };
340
+ /**
341
+ * Forget the key's head query as well as the entry. A `useSync` request
342
+ * already in flight for it would otherwise pass the settle guards (the
343
+ * marker still matches), `replace` the entry and persist pre-mutation rows
344
+ * back over an invalidation that has already completed. (The engine's
345
+ * removal generation drops a `fetch` captured before the removal.)
346
+ */
347
+ const remove = (key, scope) => {
348
+ if (scope === void 0 || scope === resource.scope.get()) headQuery.delete(key);
349
+ return engine.remove(key, scope);
350
+ };
351
+ /**
352
+ * Hydrates the persisted row once per scope/key/query, then lets the driver
353
+ * fetch and revalidate the head. Read the data from the store.
354
+ */
355
+ const useSync = (params, { enabled = true, onError, onHydrated, onSuccess, ...schedule } = {}) => {
356
+ const scope = resource.scope.use();
357
+ const key = params ? resource.key(params) : void 0;
358
+ const active = enabled && !!params && key !== void 0;
359
+ const queryKey = params ? resource.storageKey(params) : void 0;
360
+ useLayoutEffect(() => {
361
+ if (active) ensureScope(scope);
362
+ }, [active, scope]);
363
+ useLayoutEffect(() => {
364
+ if (active && key !== void 0 && queryKey !== void 0) headQuery.set(key, queryKey);
365
+ }, [
366
+ active,
367
+ key,
368
+ queryKey
369
+ ]);
370
+ useLayoutEffect(() => {
371
+ if (active && key !== void 0 && queryKey !== void 0) return retain(key, queryKey);
372
+ }, [
373
+ active,
374
+ key,
375
+ queryKey
376
+ ]);
377
+ const activeKey = useRef(void 0);
378
+ const hydratedKey = useRef(void 0);
379
+ useLayoutEffect(() => {
380
+ activeKey.current = active ? key : void 0;
381
+ hydratedKey.current = void 0;
382
+ }, [
383
+ active,
384
+ key,
385
+ scope
386
+ ]);
387
+ const hydration = driver.useQuery(active && resource.persisted && resource.persistKey(key) ? replicaKeys.hydrate(resource.name, resource.version, scope, resource.storageKey(params)) : null, async () => {
388
+ const didHydrate = await hydrate(params, scope);
389
+ if (didHydrate) hydratedEntries.add(hydratedEntryId(scope, key));
390
+ if (activeKey.current !== key) return true;
391
+ hydratedKey.current = hydratedEntryId(scope, key);
392
+ if (didHydrate) {
393
+ const hydrated = view.get(get(), key);
394
+ if (hydrated !== void 0) onHydrated?.(hydrated);
395
+ }
396
+ return true;
397
+ }, { once: true });
398
+ useEffect(() => {
399
+ if (!active || key === void 0 || hydration.data !== true) return;
400
+ const entry = hydratedEntryId(scope, key);
401
+ if (hydratedKey.current === entry) return;
402
+ hydratedKey.current = entry;
403
+ const current = view.get(get(), key);
404
+ if (current !== void 0) {
405
+ if (hydratedEntries.has(entry)) onHydrated?.(current);
406
+ return;
407
+ }
408
+ hydrate(params, scope).then((didHydrate) => {
409
+ if (!didHydrate) return;
410
+ hydratedEntries.add(entry);
411
+ if (activeKey.current !== key || resource.scope.get() !== scope) return;
412
+ const hydrated = view.get(get(), key);
413
+ if (hydrated !== void 0) onHydrated?.(hydrated);
414
+ });
415
+ }, [
416
+ active,
417
+ key,
418
+ scope,
419
+ hydration.data
420
+ ]);
421
+ const syncKey = active && fetcher ? syncKeyOf(params, scope, key) : null;
422
+ /** The request settling now: the one this sync id last started. */
423
+ const takeRequest = (response) => {
424
+ const requestId = `${scope}\u0000${stableQueryKey(syncKey)}`;
425
+ const request = syncRequests.get(requestId);
426
+ syncRequests.delete(requestId);
427
+ if (request && (!request.settled || request.settledWith === response)) return request;
428
+ return begin(params, scope, "sync");
429
+ };
430
+ const sync = driver.useQuery(syncKey, () => {
431
+ const request = begin(params, scope, "sync");
432
+ syncRequests.set(request.requestId, request);
433
+ return send(request);
434
+ }, {
435
+ ...schedule,
436
+ onError: (error) => {
437
+ const settled = settle(takeRequest(error), { error });
438
+ if (settled.current) onError?.(error, {
439
+ isActive: settled.isActive,
440
+ key,
441
+ scope
442
+ });
443
+ },
444
+ onSuccess: (data) => {
445
+ const settled = settle(takeRequest(data), { data });
446
+ if (!settled.current || !settled.isActive) return;
447
+ if (activeKey.current !== key) return;
448
+ onSuccess?.(data);
449
+ }
450
+ });
451
+ return {
452
+ error: sync.error,
453
+ isHydrated: key === void 0 || !resource.persisted || !resource.persistKey(key) || hydration.data === true,
454
+ isValidating: sync.isValidating,
455
+ revalidate: () => sync.mutate()
456
+ };
457
+ };
458
+ return {
459
+ ...engine,
460
+ /** @internal Records of `useSync` requests not yet released (leak checks in tests). */
461
+ debugPendingSyncRequests: () => syncRequests.size,
462
+ /** @internal Retirement records still held (leak checks in tests). */
463
+ debugRetiredRequests: () => retiredThrough.size,
464
+ fetch,
465
+ remove,
466
+ useSync
467
+ };
468
+ };
469
+ /** Lens for the common case: a `Record<key, TData>` field on the store. */
470
+ const recordLens = (field) => ({
471
+ clear: () => ({ [field]: {} }),
472
+ get: (state, key) => state[field]?.[key],
473
+ keys: (state) => Object.keys(state[field] ?? {}),
474
+ set: (state, key, data) => {
475
+ const next = { ...state[field] };
476
+ if (data === void 0) delete next[key];
477
+ else next[key] = data;
478
+ return { [field]: next };
479
+ },
480
+ setMany: (state, entries) => {
481
+ const next = { ...state[field] };
482
+ for (const { key, data } of entries) if (data === void 0) delete next[key];
483
+ else next[key] = data;
484
+ return { [field]: next };
485
+ }
486
+ });
487
+ /**
488
+ * Lens for a paged view split across store fields: the rows stay where
489
+ * existing readers expect them (`itemsField[key]`, a plain array), the paging
490
+ * bookkeeping lives in `metaField[key]`, and `derive` recomputes fields built
491
+ * from the rows (e.g. a parsed display list) in the same commit — so a derived
492
+ * field is never one frame behind its rows.
493
+ *
494
+ * Rows seeded without bookkeeping (by code outside the replica) read as a
495
+ * single head page.
496
+ */
497
+ const splitPagedLens = ({ clearDerived, derive, itemsField, metaField }) => {
498
+ const NO_META = {};
499
+ const views = /* @__PURE__ */ new WeakMap();
500
+ const remember = (items, meta, view) => {
501
+ let byMeta = views.get(items);
502
+ if (!byMeta) views.set(items, byMeta = /* @__PURE__ */ new WeakMap());
503
+ byMeta.set(meta, view);
504
+ return view;
505
+ };
506
+ const rowsOf = (state) => state[itemsField] ?? {};
507
+ const metaOf = (state) => state[metaField] ?? {};
508
+ return {
509
+ clear: () => ({
510
+ [itemsField]: {},
511
+ [metaField]: {},
512
+ ...clearDerived?.()
513
+ }),
514
+ get: (state, key) => {
515
+ const items = rowsOf(state)[key];
516
+ if (!items) return void 0;
517
+ const meta = metaOf(state)[key] ?? NO_META;
518
+ const cached = views.get(items)?.get(meta);
519
+ if (cached) return cached;
520
+ const view = meta === NO_META ? {
521
+ currentPage: 0,
522
+ hasMore: true,
523
+ items,
524
+ pageSize: items.length
525
+ } : {
526
+ ...meta,
527
+ items
528
+ };
529
+ return remember(items, meta, view);
530
+ },
531
+ keys: (state) => Object.keys(rowsOf(state)),
532
+ set: (state, key, data) => {
533
+ const rows = { ...rowsOf(state) };
534
+ const metas = { ...metaOf(state) };
535
+ if (data === void 0) {
536
+ delete rows[key];
537
+ delete metas[key];
538
+ } else {
539
+ const { items, ...meta } = data;
540
+ rows[key] = items;
541
+ metas[key] = meta;
542
+ remember(items, meta, data);
543
+ }
544
+ const next = {
545
+ ...state,
546
+ [itemsField]: rows,
547
+ [metaField]: metas
548
+ };
549
+ return {
550
+ [itemsField]: rows,
551
+ [metaField]: metas,
552
+ ...derive?.(next, key, data?.items)
553
+ };
554
+ }
555
+ };
556
+ };
557
+ // src/zustand/driver.ts
558
+ /**
559
+ * Driver over SWR. Pass the app's own hook and scoped `mutate` when it wraps
560
+ * SWR (custom cache provider, key augmentation, retry policy).
561
+ */
562
+ const createSWRDriver = ({ mutate, useSWR }) => ({
563
+ revalidate: (match) => mutate(match),
564
+ seed: (key, data) => {
565
+ mutate(key, data, { revalidate: false }).catch(() => void 0);
566
+ },
567
+ useQuery: (key, fetcher, { once, onError, onSuccess, ...schedule }) => useSWR(key, fetcher, {
568
+ ...Object.fromEntries(Object.entries(schedule).filter(([, value]) => value !== void 0)),
569
+ ...once && {
570
+ revalidateIfStale: false,
571
+ revalidateOnFocus: false,
572
+ revalidateOnReconnect: false
573
+ },
574
+ ...onError && { onError: (error) => onError(error) },
575
+ ...onSuccess && { onSuccess: (data) => onSuccess(data) }
576
+ })
577
+ });
578
+ export { ReplicaScopeChangedError, createReplicaSlice, createSWRDriver, recordLens, splitPagedLens };
579
+
580
+ //# sourceMappingURL=index.js.map