local-replica 0.0.1 → 0.0.3

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,6 +1,6 @@
1
- import { T as stableQueryKey, i as createReplicaEngine, n as isReplicaSyncKey, r as replicaKeys } from "../keys-BTsqwfEa.js";
1
+ import { S as stableQueryKey, t as createReplicaEngine, v as isReplicaSyncKey, y as replicaKeys } from "../createReplicaEngine-CdR-URHo.js";
2
2
  import { useEffect, useLayoutEffect, useRef } from "react";
3
- // src/zustand/createReplicaSlice.ts
3
+ // src/zustand/errors.ts
4
4
  /** `slice.fetch` was asked to fetch for a scope that is no longer active. */
5
5
  var ReplicaScopeChangedError = class extends Error {
6
6
  constructor(name, scope) {
@@ -8,57 +8,23 @@ var ReplicaScopeChangedError = class extends Error {
8
8
  this.name = "ReplicaScopeChangedError";
9
9
  }
10
10
  };
11
+ // src/zustand/lens.ts
11
12
  /**
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.
13
+ * Apply every view write as ONE host update, so subscribers never observe a
14
+ * view out of step with its bookkeeping. Consecutive key writes go through
15
+ * the lens' `setMany` when it has one, so seeding N rows clones the backing
16
+ * field once instead of N times.
19
17
  */
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);
18
+ const applyLensWrites = (view, state, writes) => {
19
+ let patch = {};
20
+ let current = state;
21
+ let pending = [];
22
+ const flush = () => {
23
+ if (pending.length === 0) return;
24
+ const entries = pending;
25
+ pending = [];
26
+ if (view.setMany && entries.length > 1) {
27
+ const next = view.setMany(current, entries);
62
28
  patch = {
63
29
  ...patch,
64
30
  ...next
@@ -67,49 +33,137 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
67
33
  ...current,
68
34
  ...next
69
35
  };
70
- } else pending.push({
71
- data: write.data,
72
- key: write.key
73
- });
36
+ return;
37
+ }
38
+ for (const entry of entries) {
39
+ const next = view.set(current, entry.key, entry.data);
40
+ patch = {
41
+ ...patch,
42
+ ...next
43
+ };
44
+ current = {
45
+ ...current,
46
+ ...next
47
+ };
48
+ }
49
+ };
50
+ for (const write of writes) if ("type" in write) {
74
51
  flush();
75
- return patch;
52
+ const next = view.clear(current);
53
+ patch = {
54
+ ...patch,
55
+ ...next
56
+ };
57
+ current = {
58
+ ...current,
59
+ ...next
60
+ };
61
+ } else pending.push({
62
+ data: write.data,
63
+ key: write.key
64
+ });
65
+ flush();
66
+ return patch;
67
+ };
68
+ /** Lens for the common case: a `Record<key, TData>` field on the store. */
69
+ const recordLens = (field) => ({
70
+ clear: () => ({ [field]: {} }),
71
+ get: (state, key) => state[field]?.[key],
72
+ keys: (state) => Object.keys(state[field] ?? {}),
73
+ set: (state, key, data) => {
74
+ const next = { ...state[field] };
75
+ if (data === void 0) delete next[key];
76
+ else next[key] = data;
77
+ return { [field]: next };
78
+ },
79
+ setMany: (state, entries) => {
80
+ const next = { ...state[field] };
81
+ for (const { key, data } of entries) if (data === void 0) delete next[key];
82
+ else next[key] = data;
83
+ return { [field]: next };
84
+ }
85
+ });
86
+ /**
87
+ * Lens for a paged view split across store fields: the rows stay where
88
+ * existing readers expect them (`itemsField[key]`, a plain array), the paging
89
+ * bookkeeping lives in `metaField[key]`, and `derive` recomputes fields built
90
+ * from the rows (e.g. a parsed display list) in the same commit — so a derived
91
+ * field is never one frame behind its rows.
92
+ *
93
+ * Rows seeded without bookkeeping (by code outside the replica) read as a
94
+ * single head page.
95
+ */
96
+ const splitPagedLens = ({ clearDerived, derive, itemsField, metaField }) => {
97
+ const NO_META = {};
98
+ const views = /* @__PURE__ */ new WeakMap();
99
+ const remember = (items, meta, view) => {
100
+ let byMeta = views.get(items);
101
+ if (!byMeta) views.set(items, byMeta = /* @__PURE__ */ new WeakMap());
102
+ byMeta.set(meta, view);
103
+ return view;
76
104
  };
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)
105
+ const rowsOf = (state) => state[itemsField] ?? {};
106
+ const metaOf = (state) => state[metaField] ?? {};
107
+ return {
108
+ clear: () => ({
109
+ [itemsField]: {},
110
+ [metaField]: {},
111
+ ...clearDerived?.()
112
+ }),
113
+ get: (state, key) => {
114
+ const items = rowsOf(state)[key];
115
+ if (!items) return void 0;
116
+ const meta = metaOf(state)[key] ?? NO_META;
117
+ const cached = views.get(items)?.get(meta);
118
+ if (cached) return cached;
119
+ const view = meta === NO_META ? {
120
+ currentPage: 0,
121
+ hasMore: true,
122
+ items,
123
+ pageSize: items.length
124
+ } : {
125
+ ...meta,
126
+ items
127
+ };
128
+ return remember(items, meta, view);
91
129
  },
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}`;
130
+ keys: (state) => Object.keys(rowsOf(state)),
131
+ set: (state, key, data) => {
132
+ const rows = { ...rowsOf(state) };
133
+ const metas = { ...metaOf(state) };
134
+ if (data === void 0) {
135
+ delete rows[key];
136
+ delete metas[key];
137
+ } else {
138
+ const { items, ...meta } = data;
139
+ rows[key] = items;
140
+ metas[key] = meta;
141
+ remember(items, meta, data);
142
+ }
143
+ const next = {
144
+ ...state,
145
+ [itemsField]: rows,
146
+ [metaField]: metas
147
+ };
148
+ return {
149
+ [itemsField]: rows,
150
+ [metaField]: metas,
151
+ ...derive?.(next, key, data?.items)
152
+ };
153
+ }
154
+ };
155
+ };
156
+ // src/zustand/requests.ts
157
+ /** Request id of a sync key in a scope: requests of the same id dedupe and supersede. */
158
+ const replicaRequestId = (scope, syncKey) => `${scope}\u0000${stableQueryKey(syncKey)}`;
159
+ /**
160
+ * Lifecycle bookkeeping of head requests: capture (`begin`), send and track in
161
+ * flight, retire older requests once a newer response applied, and settle each
162
+ * request exactly once. What settling *does* is the slice's `fold`; this only
163
+ * guarantees every path — a mounted `useSync`, or `fetch` with no view mounted
164
+ * — goes through it the same way.
165
+ */
166
+ const createRequests = ({ clearGeneration, fetcher, fold, removalGeneration, resource }) => {
113
167
  let requestSeq = 0;
114
168
  /**
115
169
  * Requests whose start order is at or below this number are retired, per
@@ -135,12 +189,9 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
135
189
  const retire = (requestId, seq) => {
136
190
  if ((retiredThrough.get(requestId) ?? 0) < seq) retiredThrough.set(requestId, seq);
137
191
  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
192
  };
193
+ /** A newer response already applied, or a `fresh` fetch started after it. */
194
+ const isRetired = ({ requestId, seq }) => seq <= (retiredThrough.get(requestId) ?? 0);
144
195
  const trackUnsettled = ({ requestId, seq }) => {
145
196
  const pending = unsettled.get(requestId) ?? /* @__PURE__ */ new Set();
146
197
  unsettled.set(requestId, pending);
@@ -174,7 +225,7 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
174
225
  params,
175
226
  queryKey: resource.storageKey(params),
176
227
  removalGeneration: removalGeneration(scope, key),
177
- requestId: `${scope}\u0000${stableQueryKey(syncKey)}`,
228
+ requestId: replicaRequestId(scope, syncKey),
178
229
  scope,
179
230
  seq: ++requestSeq,
180
231
  source,
@@ -193,7 +244,7 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
193
244
  const done = () => {
194
245
  if (inflight.get(requestId) === request) inflight.delete(requestId);
195
246
  if (request.source !== "sync") return;
196
- const count = (syncInflight.get(requestId) ?? 1) - 1;
247
+ const count = syncInflight.get(requestId) - 1;
197
248
  if (count > 0) syncInflight.set(requestId, count);
198
249
  else syncInflight.delete(requestId);
199
250
  };
@@ -202,88 +253,14 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
202
253
  return sent;
203
254
  };
204
255
  /**
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.
256
+ * Fold a settled head request into the replica (see the slice's `fold`). A
257
+ * request is settled once: a `fetch` that joined a `useSync` request shares
258
+ * its result.
282
259
  */
283
260
  const settle = (request, outcome) => {
284
261
  if (request.settled) return request.settled;
285
262
  request.settledWith = "error" in outcome ? outcome.error : outcome.data;
286
- request.settled = settleOnce(request, outcome);
263
+ request.settled = fold(request, outcome);
287
264
  untrackUnsettled(request);
288
265
  if (request.source === "sync") releaseSyncRequest(request);
289
266
  return request.settled;
@@ -302,56 +279,58 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
302
279
  if (syncRequests.get(request.requestId) === request) syncRequests.delete(request.requestId);
303
280
  }, 0);
304
281
  };
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
- };
282
+ /** Start a `useSync` request and record it for the driver's callback. */
283
+ const startSync = (params, scope) => {
284
+ const request = begin(params, scope, "sync");
285
+ syncRequests.set(request.requestId, request);
286
+ return send(request);
339
287
  };
340
288
  /**
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.)
289
+ * The `useSync` request settling now: the one `requestId` last started. A
290
+ * response the driver settles without having run our fetcher (a
291
+ * cache-shared key), or one the recorded request did not produce, is
292
+ * captured now: nothing it started under is known.
346
293
  */
347
- const remove = (key, scope) => {
348
- if (scope === void 0 || scope === resource.scope.get()) headQuery.delete(key);
349
- return engine.remove(key, scope);
294
+ const takeSyncRequest = (requestId, response, params, scope) => {
295
+ const request = syncRequests.get(requestId);
296
+ syncRequests.delete(requestId);
297
+ if (request && (!request.settled || request.settledWith === response)) return request;
298
+ return begin(params, scope, "sync");
299
+ };
300
+ return {
301
+ begin,
302
+ /** Records of `useSync` requests not yet released (leak checks in tests). */
303
+ debugPendingSyncRequests: () => syncRequests.size,
304
+ /** Retirement records still held (leak checks in tests). */
305
+ debugRetiredRequests: () => retiredThrough.size,
306
+ /** The request in flight for `requestId`, which a non-`fresh` `fetch` joins. */
307
+ inflight: (requestId) => inflight.get(requestId),
308
+ isRetired,
309
+ /** Whether a `useSync` request of `requestId` is in flight. */
310
+ isSyncInflight: (requestId) => syncInflight.has(requestId),
311
+ retire,
312
+ send,
313
+ settle,
314
+ startSync,
315
+ syncKeyOf,
316
+ takeSyncRequest
350
317
  };
318
+ };
319
+ // src/zustand/useSync.ts
320
+ /**
321
+ * Hydrates the persisted row once per scope/key/query, then lets the driver
322
+ * fetch and revalidate the head. Read the data from the store.
323
+ */
324
+ const createUseSync = ({ driver, engine: { ensureScope, fetcher, hydrate, retain }, get, headQuery, requests, resource, view }) => {
351
325
  /**
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.
326
+ * Entries (`scope` + key) whose one-shot hydration actually painted a
327
+ * persisted row. The hydration query settles `true` on a cache miss too, so
328
+ * a reactivated key may only replay `onHydrated` when its row really came from
329
+ * storage — not off a provisional seed or another source that happens to sit
330
+ * in the view.
354
331
  */
332
+ const hydratedEntries = /* @__PURE__ */ new Set();
333
+ const hydratedEntryId = (scope, key) => `${scope}\u0000${key}`;
355
334
  const useSync = (params, { enabled = true, onError, onHydrated, onSuccess, ...schedule } = {}) => {
356
335
  const scope = resource.scope.use();
357
336
  const key = params ? resource.key(params) : void 0;
@@ -418,23 +397,13 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
418
397
  scope,
419
398
  hydration.data
420
399
  ]);
421
- const syncKey = active && fetcher ? syncKeyOf(params, scope, key) : null;
400
+ const syncKey = active && fetcher ? requests.syncKeyOf(params, scope, key) : null;
422
401
  /** 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
- }, {
402
+ const takeRequest = (response) => requests.takeSyncRequest(replicaRequestId(scope, syncKey), response, params, scope);
403
+ const sync = driver.useQuery(syncKey, () => requests.startSync(params, scope), {
435
404
  ...schedule,
436
405
  onError: (error) => {
437
- const settled = settle(takeRequest(error), { error });
406
+ const settled = requests.settle(takeRequest(error), { error });
438
407
  if (settled.current) onError?.(error, {
439
408
  isActive: settled.isActive,
440
409
  key,
@@ -442,7 +411,7 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
442
411
  });
443
412
  },
444
413
  onSuccess: (data) => {
445
- const settled = settle(takeRequest(data), { data });
414
+ const settled = requests.settle(takeRequest(data), { data });
446
415
  if (!settled.current || !settled.isActive) return;
447
416
  if (activeKey.current !== key) return;
448
417
  onSuccess?.(data);
@@ -455,103 +424,195 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
455
424
  revalidate: () => sync.mutate()
456
425
  };
457
426
  };
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
- };
427
+ return useSync;
468
428
  };
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
- });
429
+ // src/zustand/createReplicaSlice.ts
487
430
  /**
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.
431
+ * Bind a replica to a domain Zustand store.
493
432
  *
494
- * Rows seeded without bookkeeping (by code outside the replica) read as a
495
- * single head page.
433
+ * The domain store stays the only UI source of truth: components read the
434
+ * `view` location through their usual selectors. The engine owns every
435
+ * transition of that location; this slice adds the Zustand port and a
436
+ * `useSync` hook that only orchestrates fetching — data never flows through
437
+ * its return value.
496
438
  */
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;
439
+ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, view, ...options }) => {
440
+ const revalidate = (key) => driver.revalidate((queryKey) => isReplicaSyncKey(queryKey, resource.name, {
441
+ key,
442
+ scope: resource.scope.get()
443
+ }));
444
+ const engine = createReplicaEngine(resource, {
445
+ ...options,
446
+ port: {
447
+ commit: (writes, state, label) => set({
448
+ ...applyLensWrites(view, get(), writes),
449
+ [stateKey]: state
450
+ }, false, label),
451
+ getState: () => get()[stateKey],
452
+ keys: view.keys && (() => view.keys(get())),
453
+ read: (key) => view.get(get(), key)
454
+ },
455
+ revalidate
456
+ });
457
+ const { applyHead, clearGeneration, fetcher, isMissingHead, removalGeneration } = engine;
458
+ /**
459
+ * Row key of the head query each entry last asked for. One entry backs a
460
+ * single view, so a response that belongs to a query the entry has already
461
+ * moved past must not repaint it — it would show a superseded query, and a
462
+ * view gated on the current query would then wait forever. Making this an
463
+ * invariant of the slice (not of one driver) keeps it true for drivers that
464
+ * leave a request's own callback live after the query changed.
465
+ */
466
+ const headQuery = /* @__PURE__ */ new Map();
467
+ const requests = createRequests({
468
+ clearGeneration,
469
+ fetcher,
470
+ fold: (request, outcome) => fold(request, outcome),
471
+ removalGeneration,
472
+ resource
473
+ });
474
+ /**
475
+ * Whether the request still answers the query its entry shows. A `useSync`
476
+ * request must match the query the entry last asked for (see `headQuery`). A
477
+ * `fetch` may also land with no view mounted, but never over a view that
478
+ * shows another query of the key.
479
+ */
480
+ const isActiveQuery = ({ key, queryKey, source }) => {
481
+ const head = headQuery.get(key);
482
+ return source === "sync" ? head === queryKey : head === void 0 || head === queryKey;
505
483
  };
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
484
+ /**
485
+ * Fold a settled head request into the replica: apply every guard, then
486
+ * `replace` / remove a terminal absence (in the captured scope) / purge one
487
+ * captured off-scope, then run the resource effects.
488
+ */
489
+ const fold = (request, outcome) => {
490
+ const { key, params, scope } = request;
491
+ /**
492
+ * Whether the entry is still on the scope the request was captured under.
493
+ *
494
+ * A response can settle after an identity/workspace switch. `replace` drops
495
+ * it — the engine refuses off-scope actions — but no side effect may run
496
+ * either: it carries the previous identity's state (onboarding redirect,
497
+ * display snapshot, analytics identify, settings writes). The captured
498
+ * key/scope is not enough on its own, because a switch to a scope that
499
+ * reads the same key/query satisfies the other guards.
500
+ */
501
+ const isActive = resource.scope.get() === scope;
502
+ const dropped = {
503
+ applied: false,
504
+ current: false,
505
+ isActive,
506
+ missing: false
507
+ };
508
+ if (request.clearGeneration !== clearGeneration(scope) || request.removalGeneration !== removalGeneration(scope, key) || requests.isRetired(request) || !isActiveQuery(request)) return dropped;
509
+ if ("error" in outcome) {
510
+ effects?.onError?.(outcome.error, params, {
511
+ isActive,
512
+ key,
513
+ scope
514
+ });
515
+ return {
516
+ ...dropped,
517
+ current: true
528
518
  };
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);
519
+ }
520
+ if (!isActive) {
521
+ const missing = isMissingHead(outcome.data);
522
+ if (missing) {
523
+ remove(key, scope);
524
+ effects?.onPurge?.(params, {
525
+ key,
526
+ scope,
527
+ source: request.source
528
+ });
543
529
  }
544
- const next = {
545
- ...state,
546
- [itemsField]: rows,
547
- [metaField]: metas
548
- };
549
530
  return {
550
- [itemsField]: rows,
551
- [metaField]: metas,
552
- ...derive?.(next, key, data?.items)
531
+ ...dropped,
532
+ current: true,
533
+ missing
553
534
  };
554
535
  }
536
+ requests.retire(request.requestId, request.seq - 1);
537
+ const { outcome: folded } = applyHead(params, outcome.data, scope);
538
+ const context = {
539
+ key,
540
+ scope,
541
+ source: request.source
542
+ };
543
+ if (folded === "replaced") effects?.onApplied?.(outcome.data, params, context);
544
+ else if (folded === "missing") effects?.onMissing?.(params, context);
545
+ return {
546
+ applied: folded !== "dropped",
547
+ current: true,
548
+ isActive,
549
+ missing: folded === "missing"
550
+ };
551
+ };
552
+ /**
553
+ * Fetch the head of an entry now and settle it like a `useSync` response —
554
+ * for stores that need the data while no view is mounted (a hover prefetch,
555
+ * a refresh after a mutation on a route without the list). `revalidate` only
556
+ * re-runs mounted queries.
557
+ *
558
+ * Joins a request in flight for the same entry unless `fresh`. Resolves with
559
+ * what was fetched and whether it was applied; rejects when the request
560
+ * fails, after routing the failure through `effects.onError`.
561
+ */
562
+ const fetch = async (params, { fresh = false, scope = resource.scope.get() } = {}) => {
563
+ if (!fetcher) throw new Error(`[replica] ${resource.name} has no fetcher`);
564
+ if (scope !== resource.scope.get()) throw new ReplicaScopeChangedError(resource.name, scope);
565
+ let request = requests.begin(params, scope, "fetch");
566
+ const joined = fresh ? void 0 : requests.inflight(request.requestId);
567
+ if (joined) request = joined;
568
+ else {
569
+ if (fresh) requests.retire(request.requestId, request.seq - 1);
570
+ requests.send(request);
571
+ }
572
+ let data;
573
+ try {
574
+ data = await request.promise;
575
+ } catch (error) {
576
+ requests.settle(request, { error });
577
+ throw error;
578
+ }
579
+ const settled = requests.settle(request, { data });
580
+ if (settled.applied && request.source === "fetch" && !requests.isSyncInflight(request.requestId)) driver.seed?.(request.syncKey, data);
581
+ return {
582
+ applied: settled.applied,
583
+ data,
584
+ missing: settled.missing
585
+ };
586
+ };
587
+ /**
588
+ * Forget the key's head query as well as the entry. A `useSync` request
589
+ * already in flight for it would otherwise pass the settle guards (the
590
+ * marker still matches), `replace` the entry and persist pre-mutation rows
591
+ * back over an invalidation that has already completed. (The engine's
592
+ * removal generation drops a `fetch` captured before the removal.)
593
+ */
594
+ const remove = (key, scope) => {
595
+ if (scope === void 0 || scope === resource.scope.get()) headQuery.delete(key);
596
+ return engine.remove(key, scope);
597
+ };
598
+ const useSync = createUseSync({
599
+ driver,
600
+ engine,
601
+ get,
602
+ headQuery,
603
+ requests,
604
+ resource,
605
+ view
606
+ });
607
+ return {
608
+ ...engine,
609
+ /** @internal Records of `useSync` requests not yet released (leak checks in tests). */
610
+ debugPendingSyncRequests: requests.debugPendingSyncRequests,
611
+ /** @internal Retirement records still held (leak checks in tests). */
612
+ debugRetiredRequests: requests.debugRetiredRequests,
613
+ fetch,
614
+ remove,
615
+ useSync
555
616
  };
556
617
  };
557
618
  // src/zustand/driver.ts