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.
- package/dist/{keys-BTsqwfEa.js → createReplicaEngine-CdR-URHo.js} +275 -243
- package/dist/createReplicaEngine-CdR-URHo.js.map +1 -0
- package/dist/{index-CFbCHM7Q.d.ts → index-DsElitHD.d.ts} +296 -311
- package/dist/index.d.ts +2 -2
- package/dist/index.js +97 -55
- package/dist/index.js.map +1 -1
- package/dist/zustand/index.d.ts +38 -47
- package/dist/zustand/index.js +383 -322
- package/dist/zustand/index.js.map +1 -1
- package/package.json +2 -1
- package/dist/keys-BTsqwfEa.js.map +0 -1
package/dist/zustand/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
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/
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
*
|
|
206
|
-
* request
|
|
207
|
-
*
|
|
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 =
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
*
|
|
353
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
495
|
-
*
|
|
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
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|