local-replica 0.0.0 → 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/README.md +152 -2
- package/dist/createReplicaEngine-CdR-URHo.js +2018 -0
- package/dist/createReplicaEngine-CdR-URHo.js.map +1 -0
- package/dist/index-DsElitHD.d.ts +856 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +281 -0
- package/dist/index.js.map +1 -0
- package/dist/zustand/index.d.ts +353 -1
- package/dist/zustand/index.js +641 -0
- package/dist/zustand/index.js.map +1 -0
- package/package.json +10 -1
|
@@ -0,0 +1,2018 @@
|
|
|
1
|
+
import isEqual from "fast-deep-equal";
|
|
2
|
+
// src/core/resource/storageKey.ts
|
|
3
|
+
/** Persisted row key: one row per entry key and query. */
|
|
4
|
+
const replicaStorageKey = (key, query) => query ? `${key}?${query}` : key;
|
|
5
|
+
/**
|
|
6
|
+
* Whether `storageKey` is a persisted row of entry `key`, under any query.
|
|
7
|
+
*
|
|
8
|
+
* Entry keys are free-form, so a `?` prefix alone cannot tell `foo` + a query
|
|
9
|
+
* from a plain entry `foo?bar`. A row only counts as a variant when it is
|
|
10
|
+
* exactly `replicaStorageKey(key, query)` for a query this module could have
|
|
11
|
+
* written: the rest after `key?` must be a canonical `stableQueryKey` (it
|
|
12
|
+
* parses as JSON and serializes back to itself). Row keys and the index keep
|
|
13
|
+
* their existing flat format, so rows already persisted on devices match the
|
|
14
|
+
* same way.
|
|
15
|
+
*/
|
|
16
|
+
const isReplicaStorageKeyOf = (storageKey, key) => {
|
|
17
|
+
if (storageKey === key) return true;
|
|
18
|
+
if (!storageKey.startsWith(`${key}?`)) return false;
|
|
19
|
+
const query = storageKey.slice(key.length + 1);
|
|
20
|
+
try {
|
|
21
|
+
return stableQueryKey(JSON.parse(query)) === query;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/** Deterministic JSON: sorted keys, `undefined` dropped. */
|
|
27
|
+
const stableQueryKey = (value) => JSON.stringify(value, (_key, item) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.keys(item).sort().filter((key) => item[key] !== void 0).map((key) => [key, item[key]])) : item) ?? "";
|
|
28
|
+
// src/core/resource/queryKeys.ts
|
|
29
|
+
/**
|
|
30
|
+
* Keys of the queries a fetch adapter runs for a replica. Hosts that sweep
|
|
31
|
+
* their query cache by prefix (e.g. "refetch everything after a restore")
|
|
32
|
+
* match on {@link REPLICA_KEY_PREFIX}.
|
|
33
|
+
*/
|
|
34
|
+
const REPLICA_KEY_PREFIX = "replica:";
|
|
35
|
+
const replicaKeys = {
|
|
36
|
+
/** One-shot read of the persisted row for `storageKey`. */
|
|
37
|
+
hydrate: (name, version, scope, storageKey) => [
|
|
38
|
+
"replica:hydrate",
|
|
39
|
+
name,
|
|
40
|
+
version,
|
|
41
|
+
scope,
|
|
42
|
+
storageKey
|
|
43
|
+
],
|
|
44
|
+
/** Network sync of the head of one entry. */
|
|
45
|
+
sync: (name, version, scope, key, params) => [
|
|
46
|
+
"replica:sync",
|
|
47
|
+
name,
|
|
48
|
+
version,
|
|
49
|
+
scope,
|
|
50
|
+
key,
|
|
51
|
+
params
|
|
52
|
+
]
|
|
53
|
+
};
|
|
54
|
+
/** Match a resource's sync keys, optionally narrowed to one scope and/or entry key. */
|
|
55
|
+
const isReplicaSyncKey = (queryKey, name, filter = {}) => Array.isArray(queryKey) && queryKey[0] === "replica:sync" && queryKey[1] === name && (filter.scope === void 0 || queryKey[3] === filter.scope) && (filter.key === void 0 || queryKey[4] === filter.key);
|
|
56
|
+
// src/core/paging/operations.ts
|
|
57
|
+
/**
|
|
58
|
+
* First-class pagination for replicas.
|
|
59
|
+
*
|
|
60
|
+
* Vocabulary (direction-agnostic):
|
|
61
|
+
* - HEAD page: the newest window, what a refresh re-fetches (`cursor` undefined).
|
|
62
|
+
* - NEXT pages: further pages in the paging direction (older history).
|
|
63
|
+
* - `forward`: the view is head-first, next pages append at the END
|
|
64
|
+
* (sidebar lists: newest topic first, "load more" below).
|
|
65
|
+
* - `backward`: the view is oldest-first, next pages PREPEND at the start
|
|
66
|
+
* (chat transcripts: newest message last, "load older" on scroll-up).
|
|
67
|
+
* - `offset` mode: the cursor is the page index (0 = head).
|
|
68
|
+
* `cursor` mode: the server hands back an opaque `nextCursor`.
|
|
69
|
+
*
|
|
70
|
+
* Every function is pure; the store binding owns staleness and scope.
|
|
71
|
+
*/
|
|
72
|
+
const dedupe = (items, getId, seen = /* @__PURE__ */ new Set()) => {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
const id = getId(item);
|
|
76
|
+
if (seen.has(id)) continue;
|
|
77
|
+
seen.add(id);
|
|
78
|
+
out.push(item);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
const ordered = (items, config) => config.sort ? [...items].sort(config.sort) : items;
|
|
83
|
+
/** Concatenate in view order: forward = head first, backward = head last. */
|
|
84
|
+
const joinPages = (head, rest, direction) => direction === "forward" ? [...head, ...rest] : [...rest, ...head];
|
|
85
|
+
/** The paging config one entry reads with: `directionOf` resolved for `key`. */
|
|
86
|
+
const pagingFor = (config, key) => config.directionOf ? {
|
|
87
|
+
...config,
|
|
88
|
+
direction: config.directionOf(key)
|
|
89
|
+
} : config;
|
|
90
|
+
const deriveHasMore = (data, serverCount) => data.total !== void 0 ? data.total > serverCount : data.nextCursor !== null;
|
|
91
|
+
const offsetNext = (more, nextIndex) => more ? nextIndex : null;
|
|
92
|
+
/** Offset mode: whether a page past the loaded ones exists. */
|
|
93
|
+
const offsetHasMore = (page, total, serverCount, pageSize) => {
|
|
94
|
+
if (page.nextCursor !== void 0) return page.nextCursor !== null;
|
|
95
|
+
return total !== void 0 ? total > serverCount : page.items.length >= pageSize;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Cursor to request the next page with; `null` → exhausted, `undefined` →
|
|
99
|
+
* cannot page yet. Offset views without bookkeeping (seeded by legacy code or
|
|
100
|
+
* an older persisted shape) derive it from `currentPage` / `hasMore`.
|
|
101
|
+
*/
|
|
102
|
+
const getNextPageCursor = (data, config) => {
|
|
103
|
+
if (!data) return void 0;
|
|
104
|
+
if (data.nextCursor !== void 0) return data.nextCursor;
|
|
105
|
+
if (config.mode === "offset") return data.hasMore ? (data.currentPage ?? 0) + 1 : null;
|
|
106
|
+
if (!config.deriveCursor || !data.hasMore) return void 0;
|
|
107
|
+
const boundary = farBoundary(data.items, config);
|
|
108
|
+
return boundary === void 0 ? void 0 : config.deriveCursor(boundary);
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* The boundary row on the far side of `items` (forward: last, backward: first)
|
|
112
|
+
* that may anchor a join or derive a cursor.
|
|
113
|
+
*/
|
|
114
|
+
const farBoundary = (items, config) => {
|
|
115
|
+
const eligible = config.isCursorable ?? (() => true);
|
|
116
|
+
if (config.direction === "forward") return items.findLast((item) => eligible(item));
|
|
117
|
+
return items.find((item) => eligible(item));
|
|
118
|
+
};
|
|
119
|
+
const syntheticIds = (items, config) => {
|
|
120
|
+
if (!config.isCursorable) return void 0;
|
|
121
|
+
const ids = items.filter((item) => !config.isCursorable(item)).map(config.getId);
|
|
122
|
+
return ids.length ? ids : void 0;
|
|
123
|
+
};
|
|
124
|
+
const splitClientOnly = (items, ctx) => {
|
|
125
|
+
if (!ctx.isClientOnly) return {
|
|
126
|
+
clientOnly: [],
|
|
127
|
+
server: items
|
|
128
|
+
};
|
|
129
|
+
const clientOnly = [];
|
|
130
|
+
const server = [];
|
|
131
|
+
for (const item of items) (ctx.isClientOnly(item) ? clientOnly : server).push(item);
|
|
132
|
+
return {
|
|
133
|
+
clientOnly,
|
|
134
|
+
server
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Fold a fresh HEAD page into the view.
|
|
139
|
+
*
|
|
140
|
+
* - First load, a query change (`reset`) or an unextended view → the head page.
|
|
141
|
+
* - Extended view, `offset` mode → keep the loaded depth: fresh head first,
|
|
142
|
+
* then previously loaded rows not in it (shifted offsets self-heal by id).
|
|
143
|
+
* - Extended view, `cursor` mode → keep older pages only when the fresh head
|
|
144
|
+
* still contains the join anchor and the next cursor is known; otherwise
|
|
145
|
+
* collapse to the head page (never show a hidden gap).
|
|
146
|
+
*
|
|
147
|
+
* Client-only rows still in the view are kept on the head side.
|
|
148
|
+
*/
|
|
149
|
+
const applyHeadPage = (current, page, options, config, ctx = {}) => {
|
|
150
|
+
const { getId, direction } = config;
|
|
151
|
+
const fresh = dedupe(page.items, getId);
|
|
152
|
+
const freshIds = new Set(fresh.map(getId));
|
|
153
|
+
const { clientOnly, server: currentServer } = splitClientOnly(current?.items ?? [], ctx);
|
|
154
|
+
const survivors = clientOnly.filter((item) => !freshIds.has(getId(item)));
|
|
155
|
+
const total = page.total ?? (options.reset ? void 0 : current?.total);
|
|
156
|
+
const headItems = direction === "forward" ? [...survivors, ...fresh] : [...fresh, ...survivors];
|
|
157
|
+
const headOnly = () => {
|
|
158
|
+
const nextCursor = config.mode === "offset" ? offsetNext(offsetHasMore(page, total, fresh.length, options.pageSize), 1) : page.nextCursor;
|
|
159
|
+
const data = {
|
|
160
|
+
anchorId: void 0,
|
|
161
|
+
currentPage: 0,
|
|
162
|
+
headSyntheticIds: syntheticIds(fresh, config),
|
|
163
|
+
items: ordered(headItems, config),
|
|
164
|
+
nextCursor,
|
|
165
|
+
pageSize: options.pageSize,
|
|
166
|
+
pages: [{
|
|
167
|
+
count: fresh.length,
|
|
168
|
+
next: nextCursor
|
|
169
|
+
}],
|
|
170
|
+
total
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
...data,
|
|
174
|
+
hasMore: deriveHasMore(data, fresh.length),
|
|
175
|
+
isLoadingMore: current?.isLoadingMore,
|
|
176
|
+
loadMoreError: void 0
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
if (!(!!current && current.currentPage > 0 && !options.reset) || config.mode === "offset" && current.pageSize !== options.pageSize) return headOnly();
|
|
180
|
+
let rest;
|
|
181
|
+
let anchorId;
|
|
182
|
+
if (config.mode === "offset") {
|
|
183
|
+
const restCandidates = currentServer.filter((item) => !freshIds.has(getId(item)));
|
|
184
|
+
const visible = Math.min(currentServer.length, total ?? Number.POSITIVE_INFINITY);
|
|
185
|
+
const keep = Math.max(0, visible - fresh.length);
|
|
186
|
+
rest = direction === "forward" ? restCandidates.slice(0, keep) : restCandidates.slice(Math.max(0, restCandidates.length - keep));
|
|
187
|
+
} else {
|
|
188
|
+
const anchorIndex = current.anchorId ? currentServer.findIndex((item) => getId(item) === current.anchorId) : -1;
|
|
189
|
+
const cursorKnown = current.nextCursor !== void 0 || !!config.deriveCursor;
|
|
190
|
+
if (anchorIndex === -1 || !freshIds.has(current.anchorId) || !cursorKnown) return headOnly();
|
|
191
|
+
const farSide = direction === "forward" ? currentServer.slice(anchorIndex + 1) : currentServer.slice(0, anchorIndex);
|
|
192
|
+
const staleHead = new Set(current.headSyntheticIds);
|
|
193
|
+
rest = farSide.filter((item) => !freshIds.has(getId(item)) && !staleHead.has(getId(item)));
|
|
194
|
+
const boundary = farBoundary(fresh, config);
|
|
195
|
+
anchorId = boundary ? getId(boundary) : current.anchorId;
|
|
196
|
+
}
|
|
197
|
+
const headNext = config.mode === "cursor" ? page.nextCursor : current.pages?.[0]?.next;
|
|
198
|
+
let overlap = (current.pages ?? []).slice(1).reduce((sum, info) => sum + info.count, 0) - rest.length;
|
|
199
|
+
const olderPages = (current.pages ?? []).slice(1).map((info) => {
|
|
200
|
+
const shed = Math.min(info.count, Math.max(0, overlap));
|
|
201
|
+
overlap -= shed;
|
|
202
|
+
return shed ? {
|
|
203
|
+
...info,
|
|
204
|
+
count: info.count - shed
|
|
205
|
+
} : info;
|
|
206
|
+
});
|
|
207
|
+
const pages = [{
|
|
208
|
+
count: fresh.length,
|
|
209
|
+
next: headNext
|
|
210
|
+
}, ...olderPages];
|
|
211
|
+
const data = {
|
|
212
|
+
anchorId: anchorId ?? current.anchorId,
|
|
213
|
+
currentPage: current.currentPage,
|
|
214
|
+
headSyntheticIds: syntheticIds(fresh, config),
|
|
215
|
+
items: ordered(joinPages(headItems, rest, direction), config),
|
|
216
|
+
nextCursor: current.nextCursor,
|
|
217
|
+
pageSize: options.pageSize,
|
|
218
|
+
pages,
|
|
219
|
+
total
|
|
220
|
+
};
|
|
221
|
+
return {
|
|
222
|
+
...data,
|
|
223
|
+
hasMore: deriveHasMore(data, fresh.length + rest.length),
|
|
224
|
+
isLoadingMore: current.isLoadingMore,
|
|
225
|
+
loadMoreError: void 0
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
/** Merge the NEXT page (fetched with `getNextPageCursor`) into the view. */
|
|
229
|
+
const applyNextPage = (current, page, config, ctx = {}) => {
|
|
230
|
+
const { getId, direction } = config;
|
|
231
|
+
const seen = new Set(current.items.map(getId));
|
|
232
|
+
const added = dedupe(page.items, getId, seen);
|
|
233
|
+
const total = page.total ?? current.total;
|
|
234
|
+
const serverCount = splitClientOnly(current.items, ctx).server.length + added.length;
|
|
235
|
+
let next;
|
|
236
|
+
if (config.mode === "offset") {
|
|
237
|
+
const more = offsetHasMore(page, total, serverCount, current.pageSize);
|
|
238
|
+
next = offsetNext(more, current.currentPage + 2);
|
|
239
|
+
} else next = page.items.length === 0 ? null : page.nextCursor;
|
|
240
|
+
let anchorId = current.anchorId;
|
|
241
|
+
if (current.currentPage === 0) {
|
|
242
|
+
const boundary = farBoundary(splitClientOnly(current.items, ctx).server, config);
|
|
243
|
+
anchorId = boundary ? getId(boundary) : void 0;
|
|
244
|
+
}
|
|
245
|
+
const items = ordered(direction === "forward" ? [...current.items, ...added] : [...added, ...current.items], config);
|
|
246
|
+
const data = {
|
|
247
|
+
...current,
|
|
248
|
+
anchorId,
|
|
249
|
+
currentPage: current.currentPage + 1,
|
|
250
|
+
isLoadingMore: false,
|
|
251
|
+
items,
|
|
252
|
+
loadMoreError: void 0,
|
|
253
|
+
nextCursor: next,
|
|
254
|
+
pages: [...current.pages?.length ? current.pages : [{ count: current.items.length }], {
|
|
255
|
+
count: added.length,
|
|
256
|
+
next
|
|
257
|
+
}],
|
|
258
|
+
total
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
...data,
|
|
262
|
+
hasMore: deriveHasMore(data, serverCount)
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
/**
|
|
266
|
+
* Insert new rows on the HEAD side (a streamed message, a created topic).
|
|
267
|
+
* Cursors point at the far end, so they stay valid; offsets shift and are
|
|
268
|
+
* healed by id de-duplication on the next page merge.
|
|
269
|
+
*/
|
|
270
|
+
const insertHeadItems = (current, items, config) => {
|
|
271
|
+
const seen = new Set(current.items.map(config.getId));
|
|
272
|
+
const added = dedupe(items, config.getId, seen);
|
|
273
|
+
if (added.length === 0) return current;
|
|
274
|
+
const pages = current.pages?.length ? current.pages : [{ count: current.items.length }];
|
|
275
|
+
return {
|
|
276
|
+
...current,
|
|
277
|
+
items: ordered(joinPages(added, current.items, config.direction), config),
|
|
278
|
+
pages: [{
|
|
279
|
+
...pages[0],
|
|
280
|
+
count: pages[0].count + added.length
|
|
281
|
+
}, ...pages.slice(1)],
|
|
282
|
+
total: current.total === void 0 ? void 0 : current.total + added.length
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
/**
|
|
286
|
+
* Patch (or remove, when `fn` returns `undefined`) one row by id. Returns the
|
|
287
|
+
* same reference when the row is absent or unchanged.
|
|
288
|
+
*/
|
|
289
|
+
const mapPagedItem = (current, id, fn, config) => {
|
|
290
|
+
const index = current.items.findIndex((item) => config.getId(item) === id);
|
|
291
|
+
if (index === -1) return current;
|
|
292
|
+
const next = fn(current.items[index]);
|
|
293
|
+
if (next === current.items[index]) return current;
|
|
294
|
+
if (next !== void 0) {
|
|
295
|
+
const items = [...current.items];
|
|
296
|
+
items[index] = next;
|
|
297
|
+
return {
|
|
298
|
+
...current,
|
|
299
|
+
items
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const items = current.items.filter((_, i) => i !== index);
|
|
303
|
+
const total = current.total === void 0 ? void 0 : Math.max(items.length, current.total - 1);
|
|
304
|
+
return {
|
|
305
|
+
...current,
|
|
306
|
+
hasMore: total !== void 0 ? total > items.length : current.hasMore,
|
|
307
|
+
items,
|
|
308
|
+
total
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
/** Whether a paged view holds a row. */
|
|
312
|
+
const hasPagedItem = (data, id, config) => !!data?.items.some((item) => config.getId(item) === id);
|
|
313
|
+
/** Select head-side rows without losing synthetic head rows sorted among older pages. */
|
|
314
|
+
const takeHeadItems = (items, count, data, config) => {
|
|
315
|
+
if (count === 0) return [];
|
|
316
|
+
const synthetic = new Set(data.headSyntheticIds);
|
|
317
|
+
const syntheticCount = items.filter((item) => synthetic.has(config.getId(item))).length;
|
|
318
|
+
const regular = items.filter((item) => !synthetic.has(config.getId(item)));
|
|
319
|
+
const regularCount = Math.max(0, count - syntheticCount);
|
|
320
|
+
const selected = config.direction === "forward" ? regular.slice(0, regularCount) : regular.slice(Math.max(0, regular.length - regularCount));
|
|
321
|
+
const ids = new Set(selected.map(config.getId));
|
|
322
|
+
return items.filter((item) => synthetic.has(config.getId(item)) || ids.has(config.getId(item))).slice(config.direction === "forward" ? 0 : -count, config.direction === "forward" ? count : void 0);
|
|
323
|
+
};
|
|
324
|
+
/** Drop every loaded page but the head (e.g. after an edit inside older history). */
|
|
325
|
+
const collapseToHead = (current, config) => {
|
|
326
|
+
if (current.currentPage === 0) return current;
|
|
327
|
+
const head = current.pages?.[0];
|
|
328
|
+
const count = head?.count ?? current.pageSize;
|
|
329
|
+
const items = takeHeadItems(current.items, count, current, config);
|
|
330
|
+
const nextCursor = config.mode === "offset" ? 1 : head?.next ?? void 0;
|
|
331
|
+
return {
|
|
332
|
+
...current,
|
|
333
|
+
anchorId: void 0,
|
|
334
|
+
currentPage: 0,
|
|
335
|
+
hasMore: true,
|
|
336
|
+
items,
|
|
337
|
+
nextCursor,
|
|
338
|
+
pages: [{
|
|
339
|
+
count: items.length,
|
|
340
|
+
next: nextCursor
|
|
341
|
+
}]
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
/**
|
|
345
|
+
* What survives a reload: whole pages from the head side, bounded by
|
|
346
|
+
* `persist.pages` and `persist.maxItems`, client-only rows and transient
|
|
347
|
+
* flags stripped. Offset views are cut at page-size boundaries so the
|
|
348
|
+
* persisted `currentPage` stays a valid offset; a cut inside a cursor page
|
|
349
|
+
* forgets the cursor (pagination resumes after the next server head page).
|
|
350
|
+
*/
|
|
351
|
+
const toPersistedPage = (data, config, ctx = {}) => {
|
|
352
|
+
const { server } = splitClientOnly(data.items, ctx);
|
|
353
|
+
const pageLimit = Math.max(1, config.persist?.pages ?? 1);
|
|
354
|
+
const maxItems = config.persist?.maxItems ?? Number.POSITIVE_INFINITY;
|
|
355
|
+
const olderCount = (data.pages ?? []).slice(1).reduce((sum, page) => sum + page.count, 0);
|
|
356
|
+
const pages = data.pages?.length ? [{
|
|
357
|
+
...data.pages[0],
|
|
358
|
+
count: Math.max(0, server.length - olderCount)
|
|
359
|
+
}, ...data.pages.slice(1)] : [{
|
|
360
|
+
count: server.length,
|
|
361
|
+
next: data.nextCursor
|
|
362
|
+
}];
|
|
363
|
+
let count = 0;
|
|
364
|
+
let keptPages = 0;
|
|
365
|
+
let aligned = true;
|
|
366
|
+
if (config.mode === "offset") {
|
|
367
|
+
const loadedPages = Math.min(pageLimit, data.currentPage + 1);
|
|
368
|
+
const byItems = Math.floor(maxItems / Math.max(1, data.pageSize));
|
|
369
|
+
keptPages = Math.max(1, Math.min(loadedPages, byItems || 1));
|
|
370
|
+
count = Math.min(server.length, keptPages * data.pageSize, maxItems);
|
|
371
|
+
} else {
|
|
372
|
+
for (const page of pages.slice(0, pageLimit)) {
|
|
373
|
+
if (count + page.count > maxItems) {
|
|
374
|
+
aligned = false;
|
|
375
|
+
count = keptPages === 0 ? Math.min(page.count, maxItems) : count;
|
|
376
|
+
if (keptPages === 0) keptPages = 1;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
count += page.count;
|
|
380
|
+
keptPages += 1;
|
|
381
|
+
}
|
|
382
|
+
count = Math.min(count, server.length);
|
|
383
|
+
}
|
|
384
|
+
const items = takeHeadItems(server, count, data, config);
|
|
385
|
+
const lastKept = pages[keptPages - 1];
|
|
386
|
+
const nextCursor = config.mode === "offset" ? count < (data.total ?? server.length) || count === server.length && data.total === void 0 && data.nextCursor != null ? keptPages : null : aligned ? lastKept?.next : void 0;
|
|
387
|
+
const { isLoadingMore: _loading, loadMoreError: _error, ...rest } = data;
|
|
388
|
+
return {
|
|
389
|
+
...rest,
|
|
390
|
+
anchorId: keptPages > 1 ? data.anchorId : void 0,
|
|
391
|
+
currentPage: keptPages - 1,
|
|
392
|
+
hasMore: data.total !== void 0 ? data.total > items.length : nextCursor !== null,
|
|
393
|
+
items,
|
|
394
|
+
nextCursor,
|
|
395
|
+
pages: [...pages.slice(0, keptPages - 1), {
|
|
396
|
+
count: Math.max(0, items.length - pages.slice(0, keptPages - 1).reduce((sum, page) => sum + page.count, 0)),
|
|
397
|
+
next: nextCursor
|
|
398
|
+
}]
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
// src/core/persistence/writeQueue.ts
|
|
402
|
+
/**
|
|
403
|
+
* Strict read of one row (see `ReplicaStorage.getStrict`). Rejects when the
|
|
404
|
+
* storage has no strict read: its `get` swallows I/O failures into a miss, so a
|
|
405
|
+
* destructive pass reading through it could take an unreadable row (the index)
|
|
406
|
+
* for an empty one and report success for rows it never saw.
|
|
407
|
+
*/
|
|
408
|
+
const readReplicaRowStrict = (storage, key) => storage.getStrict ? storage.getStrict(key) : Promise.reject(/* @__PURE__ */ new Error("[replica] storage has no `getStrict`: destructive passes are disabled"));
|
|
409
|
+
/**
|
|
410
|
+
* Serializes writes for one projection key. A later snapshot can never be overwritten by an
|
|
411
|
+
* earlier, slower IndexedDB write.
|
|
412
|
+
*/
|
|
413
|
+
var ReplicaWriteQueue = class {
|
|
414
|
+
#pending = /* @__PURE__ */ new Map();
|
|
415
|
+
#storage;
|
|
416
|
+
constructor(storage) {
|
|
417
|
+
this.#storage = storage;
|
|
418
|
+
}
|
|
419
|
+
#id = ({ queryKey, scope }) => `${scope}:${queryKey}`;
|
|
420
|
+
/** Resolves `false` when storage rejected the delete (see `ReplicaStorage.remove`). */
|
|
421
|
+
remove = (key) => this.#enqueue(key, () => this.#storage.remove(key));
|
|
422
|
+
set = (key, value) => this.#enqueue(key, () => this.#storage.set(key, value));
|
|
423
|
+
/**
|
|
424
|
+
* Serialized read-modify-write: `fn` sees the row as of every earlier queued
|
|
425
|
+
* write for this key. Return a projection to write it, `null` to remove the
|
|
426
|
+
* row, or `undefined` to leave it untouched (a missing row stays missing).
|
|
427
|
+
*
|
|
428
|
+
* `read` picks how the row is read:
|
|
429
|
+
* - `'get'` (default): best-effort `get`, a failed read passes for a miss.
|
|
430
|
+
* - `'prefer-strict'`: `getStrict` when the storage has one, so a read failure
|
|
431
|
+
* fails the update (resolves `false`) instead of passing for a missing row
|
|
432
|
+
* that `fn` would then overwrite; `get` otherwise.
|
|
433
|
+
* - `'strict'`: `getStrict` only; a storage without it fails the update.
|
|
434
|
+
* Required for destructive passes (see `readReplicaRowStrict`).
|
|
435
|
+
*/
|
|
436
|
+
update = (key, fn, { read = "get" } = {}) => this.#enqueue(key, async () => {
|
|
437
|
+
const storage = this.#storage;
|
|
438
|
+
const next = fn(await (read === "strict" || read === "prefer-strict" && !!storage.getStrict ? readReplicaRowStrict(storage, key) : storage.get(key)));
|
|
439
|
+
if (next === null) await this.#storage.remove(key);
|
|
440
|
+
else if (next !== void 0) await this.#storage.set(key, next);
|
|
441
|
+
});
|
|
442
|
+
/**
|
|
443
|
+
* Queue `operation` behind the earlier writes for this key and report whether
|
|
444
|
+
* it settled successfully. A failed write never rejects the returned promise:
|
|
445
|
+
* the caller decides what to do (e.g. keep a removal retryable), while a
|
|
446
|
+
* rejection on the queue itself is already swallowed so one bad write cannot
|
|
447
|
+
* poison the chain.
|
|
448
|
+
*/
|
|
449
|
+
#enqueue = (key, operation) => {
|
|
450
|
+
const id = this.#id(key);
|
|
451
|
+
const current = (this.#pending.get(id) ?? Promise.resolve()).catch(() => void 0).then(operation);
|
|
452
|
+
this.#pending.set(id, current);
|
|
453
|
+
const settled = current.then(() => true, () => false);
|
|
454
|
+
settled.finally(() => {
|
|
455
|
+
if (this.#pending.get(id) === current) this.#pending.delete(id);
|
|
456
|
+
});
|
|
457
|
+
return settled;
|
|
458
|
+
};
|
|
459
|
+
};
|
|
460
|
+
// src/core/persistence/persistedRows.ts
|
|
461
|
+
/** Reserved storage key of the per-scope index of persisted rows. */
|
|
462
|
+
const REPLICA_INDEX_KEY = "__replica:index";
|
|
463
|
+
/** Returned by a `patchRow` transform to delete the row (`null` stays a value). */
|
|
464
|
+
const DROP_ROW = Symbol("replica.dropRow");
|
|
465
|
+
/**
|
|
466
|
+
* Bookkeeping of the rows one engine persisted: the per-scope index, the latest
|
|
467
|
+
* queued write per row, what each row is known to hold, and the bounded
|
|
468
|
+
* (`persist.maxEntries`) eviction. Every row write and delete of the engine
|
|
469
|
+
* goes through here, so the index never misses or resurrects a row.
|
|
470
|
+
*/
|
|
471
|
+
const createPersistedRows = ({ commit, getSlot, isRemoved, loadingMore, resource, writeQueue }) => {
|
|
472
|
+
const indexKey = (scope) => ({
|
|
473
|
+
queryKey: REPLICA_INDEX_KEY,
|
|
474
|
+
scope
|
|
475
|
+
});
|
|
476
|
+
/** Keys known to be in the index row, per scope (skips redundant index writes). */
|
|
477
|
+
const indexed = /* @__PURE__ */ new Map();
|
|
478
|
+
const indexedKeys = (scope) => indexed.get(scope);
|
|
479
|
+
/**
|
|
480
|
+
* Latest queued write per row (`scope` + storage key), from one monotonic
|
|
481
|
+
* counter. A confirmed delete only takes its key out of the index when no
|
|
482
|
+
* later write re-created the row in the meantime.
|
|
483
|
+
*/
|
|
484
|
+
const rowWrites = /* @__PURE__ */ new Map();
|
|
485
|
+
let writeCounter = 0;
|
|
486
|
+
const writeCount = () => writeCounter;
|
|
487
|
+
const rowId = (scope, queryKey) => `${scope}\u0000${queryKey}`;
|
|
488
|
+
const noteWrite = (scope, queryKey) => {
|
|
489
|
+
const seq = ++writeCounter;
|
|
490
|
+
rowWrites.set(rowId(scope, queryKey), seq);
|
|
491
|
+
return seq;
|
|
492
|
+
};
|
|
493
|
+
const storedValues = /* @__PURE__ */ new Map();
|
|
494
|
+
const rememberStored = (scope, queryKey, data) => {
|
|
495
|
+
const rows = storedValues.get(scope) ?? /* @__PURE__ */ new Map();
|
|
496
|
+
storedValues.set(scope, rows);
|
|
497
|
+
rows.set(queryKey, data);
|
|
498
|
+
};
|
|
499
|
+
const forgetStored = (scope, queryKeys) => {
|
|
500
|
+
const rows = storedValues.get(scope);
|
|
501
|
+
if (!rows) return;
|
|
502
|
+
for (const queryKey of queryKeys) rows.delete(queryKey);
|
|
503
|
+
if (rows.size === 0) storedValues.delete(scope);
|
|
504
|
+
};
|
|
505
|
+
/** Forget every row of `scope` (memory moved to another identity, or a clear). */
|
|
506
|
+
const forgetScope = (scope) => {
|
|
507
|
+
storedValues.delete(scope);
|
|
508
|
+
};
|
|
509
|
+
const isStored = (scope, queryKey, data) => {
|
|
510
|
+
const rows = storedValues.get(scope);
|
|
511
|
+
return !!rows?.has(queryKey) && isEqual(rows.get(queryKey), data);
|
|
512
|
+
};
|
|
513
|
+
/**
|
|
514
|
+
* A hydrate read `data` back from the row: the row holds it, unless a write
|
|
515
|
+
* was queued while reading (`writesBefore` is `writeCount()` before the read).
|
|
516
|
+
*/
|
|
517
|
+
const rememberRead = (scope, queryKey, data, writesBefore) => {
|
|
518
|
+
if (writeCounter === writesBefore && !storedValues.get(scope)?.has(queryKey)) rememberStored(scope, queryKey, data);
|
|
519
|
+
};
|
|
520
|
+
const maxEntries = resource.maxPersistedEntries;
|
|
521
|
+
/** Mounted readers per storage key and per entry key (see `retain`). */
|
|
522
|
+
const retainedRows = /* @__PURE__ */ new Map();
|
|
523
|
+
const retainedKeys = /* @__PURE__ */ new Map();
|
|
524
|
+
/** Rows whose eviction delete is queued, per scope. */
|
|
525
|
+
const evicting = /* @__PURE__ */ new Map();
|
|
526
|
+
const bump = (counts, id, by) => {
|
|
527
|
+
const next = (counts.get(id) ?? 0) + by;
|
|
528
|
+
if (next > 0) counts.set(id, next);
|
|
529
|
+
else counts.delete(id);
|
|
530
|
+
};
|
|
531
|
+
/**
|
|
532
|
+
* Pin an entry (and the row of the query it asks for) while something shows
|
|
533
|
+
* it: eviction never drops a retained row or the memory entry of a retained
|
|
534
|
+
* key. `useSync` retains for as long as it is mounted. Returns the release.
|
|
535
|
+
*/
|
|
536
|
+
const retain = (key, queryKey = replicaStorageKey(key)) => {
|
|
537
|
+
bump(retainedKeys, key, 1);
|
|
538
|
+
bump(retainedRows, queryKey, 1);
|
|
539
|
+
let released = false;
|
|
540
|
+
return () => {
|
|
541
|
+
if (released) return;
|
|
542
|
+
released = true;
|
|
543
|
+
bump(retainedKeys, key, -1);
|
|
544
|
+
bump(retainedRows, queryKey, -1);
|
|
545
|
+
};
|
|
546
|
+
};
|
|
547
|
+
/** Rows the memory of `scope` holds, by storage key → entry key. */
|
|
548
|
+
const loadedRows = (scope) => {
|
|
549
|
+
const slot = getSlot();
|
|
550
|
+
const rows = /* @__PURE__ */ new Map();
|
|
551
|
+
if (slot.scope !== scope) return rows;
|
|
552
|
+
for (const [key, entry] of Object.entries(slot.entries)) rows.set(replicaStorageKey(key, entry.query), key);
|
|
553
|
+
return rows;
|
|
554
|
+
};
|
|
555
|
+
/**
|
|
556
|
+
* A row on screen, or whose entry has writes / a page load in flight.
|
|
557
|
+
* `entryKey` comes from `loadedRows`, so its entry is in the slot.
|
|
558
|
+
*/
|
|
559
|
+
const isPinned = (queryKey, entryKey) => retainedRows.has(queryKey) || entryKey !== void 0 && (retainedKeys.has(entryKey) || getSlot().entries[entryKey].pending.length > 0 || loadingMore.has(entryKey));
|
|
560
|
+
/** The oldest unpinned rows past the cap (`keys` is oldest first). */
|
|
561
|
+
const pickEvictions = (scope, keys, touched) => {
|
|
562
|
+
const going = evicting.get(scope);
|
|
563
|
+
let excess = keys.length - maxEntries - keys.filter((key) => going?.has(key)).length;
|
|
564
|
+
if (excess <= 0) return [];
|
|
565
|
+
const loaded = loadedRows(scope);
|
|
566
|
+
const picked = [];
|
|
567
|
+
for (const queryKey of keys) {
|
|
568
|
+
if (excess <= 0) break;
|
|
569
|
+
if (queryKey === touched || going?.has(queryKey)) continue;
|
|
570
|
+
if (isPinned(queryKey, loaded.get(queryKey))) continue;
|
|
571
|
+
picked.push(queryKey);
|
|
572
|
+
excess -= 1;
|
|
573
|
+
}
|
|
574
|
+
return picked;
|
|
575
|
+
};
|
|
576
|
+
/**
|
|
577
|
+
* Evict rows: drop the memory entries that answer them (one commit), then
|
|
578
|
+
* delete the rows. A row leaves the index only once its delete landed, so a
|
|
579
|
+
* failed eviction is picked again by a later use.
|
|
580
|
+
*/
|
|
581
|
+
const evictRows = (scope, queryKeys) => {
|
|
582
|
+
if (resource.scope.get() !== scope || !resource.scope.canPersist()) return;
|
|
583
|
+
const going = evicting.get(scope) ?? /* @__PURE__ */ new Set();
|
|
584
|
+
evicting.set(scope, going);
|
|
585
|
+
const loaded = loadedRows(scope);
|
|
586
|
+
const rows = queryKeys.filter((queryKey) => !going.has(queryKey) && !isPinned(queryKey, loaded.get(queryKey)));
|
|
587
|
+
if (rows.length === 0) return;
|
|
588
|
+
for (const queryKey of rows) going.add(queryKey);
|
|
589
|
+
const dropped = rows.flatMap((queryKey) => {
|
|
590
|
+
const key = loaded.get(queryKey);
|
|
591
|
+
return key === void 0 ? [] : [key];
|
|
592
|
+
});
|
|
593
|
+
if (dropped.length > 0) {
|
|
594
|
+
const entries = { ...getSlot().entries };
|
|
595
|
+
for (const key of dropped) delete entries[key];
|
|
596
|
+
commit(dropped, {
|
|
597
|
+
entries,
|
|
598
|
+
scope
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
deleteRows(scope, rows).finally(() => {
|
|
602
|
+
for (const queryKey of rows) going.delete(queryKey);
|
|
603
|
+
if (going.size === 0 && evicting.get(scope) === going) evicting.delete(scope);
|
|
604
|
+
});
|
|
605
|
+
};
|
|
606
|
+
/**
|
|
607
|
+
* A bounded resource used a row: move it to the end of the index and evict
|
|
608
|
+
* past the cap. No-op for an unbounded resource.
|
|
609
|
+
*/
|
|
610
|
+
const touchStorageKey = (scope, queryKey) => {
|
|
611
|
+
if (!writeQueue || maxEntries === void 0) return;
|
|
612
|
+
const known = indexed.get(scope) ?? /* @__PURE__ */ new Set();
|
|
613
|
+
indexed.set(scope, known);
|
|
614
|
+
known.add(queryKey);
|
|
615
|
+
let picked = [];
|
|
616
|
+
writeQueue.update(indexKey(scope), (current) => {
|
|
617
|
+
const keys = current?.data ?? [];
|
|
618
|
+
const next = [...keys.filter((key) => key !== queryKey), queryKey];
|
|
619
|
+
picked = pickEvictions(scope, next, queryKey);
|
|
620
|
+
if (keys.length === next.length && keys.at(-1) === queryKey) return void 0;
|
|
621
|
+
return {
|
|
622
|
+
data: next,
|
|
623
|
+
updatedAt: Date.now()
|
|
624
|
+
};
|
|
625
|
+
}, { read: "prefer-strict" }).then((touched) => {
|
|
626
|
+
if (!touched) return void known.delete(queryKey);
|
|
627
|
+
if (picked.length > 0) evictRows(scope, picked);
|
|
628
|
+
});
|
|
629
|
+
};
|
|
630
|
+
/** Record a persisted row in the index (queued after the row write; see `writeRow`). */
|
|
631
|
+
const trackStorageKey = (scope, queryKey) => {
|
|
632
|
+
noteWrite(scope, queryKey);
|
|
633
|
+
if (maxEntries !== void 0) return touchStorageKey(scope, queryKey);
|
|
634
|
+
const known = indexed.get(scope) ?? /* @__PURE__ */ new Set();
|
|
635
|
+
indexed.set(scope, known);
|
|
636
|
+
if (known.has(queryKey)) return;
|
|
637
|
+
known.add(queryKey);
|
|
638
|
+
writeQueue.update(indexKey(scope), (current) => {
|
|
639
|
+
const keys = new Set(current?.data ?? []);
|
|
640
|
+
if (keys.has(queryKey)) return void 0;
|
|
641
|
+
keys.add(queryKey);
|
|
642
|
+
return {
|
|
643
|
+
data: [...keys],
|
|
644
|
+
updatedAt: Date.now()
|
|
645
|
+
};
|
|
646
|
+
}, { read: "prefer-strict" }).then((tracked) => {
|
|
647
|
+
if (!tracked) known.delete(queryKey);
|
|
648
|
+
});
|
|
649
|
+
};
|
|
650
|
+
/**
|
|
651
|
+
* Read the index for a destructive pass (remove / clear / entity patch).
|
|
652
|
+
* Uses the storage's strict read when it has one, so an unreadable index
|
|
653
|
+
* rejects instead of looking empty — a purge must never report success for
|
|
654
|
+
* rows it could not enumerate.
|
|
655
|
+
*/
|
|
656
|
+
const readIndex = async (scope) => {
|
|
657
|
+
return (await readReplicaRowStrict(resource.storage, indexKey(scope)))?.data ?? [];
|
|
658
|
+
};
|
|
659
|
+
/**
|
|
660
|
+
* Drop storage keys from the index (both the session's view of it and the
|
|
661
|
+
* persisted row, which may list keys this session never wrote).
|
|
662
|
+
*/
|
|
663
|
+
const untrackStorageKeys = (scope, queryKeys) => {
|
|
664
|
+
if (!writeQueue || queryKeys.length === 0) return;
|
|
665
|
+
const known = indexed.get(scope);
|
|
666
|
+
for (const queryKey of queryKeys) known?.delete(queryKey);
|
|
667
|
+
const dropped = new Set(queryKeys);
|
|
668
|
+
writeQueue.update(indexKey(scope), (current) => {
|
|
669
|
+
const keys = current?.data ?? [];
|
|
670
|
+
const next = keys.filter((queryKey) => !dropped.has(queryKey));
|
|
671
|
+
if (!current || next.length === keys.length) return void 0;
|
|
672
|
+
return {
|
|
673
|
+
data: next,
|
|
674
|
+
updatedAt: Date.now()
|
|
675
|
+
};
|
|
676
|
+
}, { read: "prefer-strict" });
|
|
677
|
+
};
|
|
678
|
+
/** Take rows whose delete landed out of the index, unless rewritten since. */
|
|
679
|
+
const confirmDeleted = (scope, deleted) => {
|
|
680
|
+
const gone = deleted.filter(([queryKey, seq]) => rowWrites.get(rowId(scope, queryKey)) === seq).map(([queryKey]) => queryKey);
|
|
681
|
+
for (const queryKey of gone) rowWrites.delete(rowId(scope, queryKey));
|
|
682
|
+
untrackStorageKeys(scope, gone);
|
|
683
|
+
};
|
|
684
|
+
/**
|
|
685
|
+
* Queue row deletes now (ordered before any later write to the same rows).
|
|
686
|
+
* A key leaves the index only once its delete is confirmed: a failed delete
|
|
687
|
+
* stays indexed, so the next `remove` / `clear` finds and retries it.
|
|
688
|
+
* Resolves `true` when every delete landed.
|
|
689
|
+
*/
|
|
690
|
+
const deleteRows = (scope, queryKeys) => {
|
|
691
|
+
const queue = writeQueue;
|
|
692
|
+
forgetStored(scope, queryKeys);
|
|
693
|
+
const queued = queryKeys.map((queryKey) => {
|
|
694
|
+
const seq = noteWrite(scope, queryKey);
|
|
695
|
+
return queue.remove({
|
|
696
|
+
queryKey,
|
|
697
|
+
scope
|
|
698
|
+
}).then((removed) => removed ? [queryKey, seq] : void 0);
|
|
699
|
+
});
|
|
700
|
+
return Promise.all(queued).then((results) => {
|
|
701
|
+
confirmDeleted(scope, results.filter((result) => !!result));
|
|
702
|
+
return results.every(Boolean);
|
|
703
|
+
});
|
|
704
|
+
};
|
|
705
|
+
/**
|
|
706
|
+
* Delete every persisted row of entry `key` in `scope` — `current` (the row
|
|
707
|
+
* memory knows about, queued right away so it orders before any later write)
|
|
708
|
+
* plus every other query variant the index lists. Resolves `true` once all
|
|
709
|
+
* deletes landed.
|
|
710
|
+
*/
|
|
711
|
+
const removeStoredVariants = (scope, key, current) => {
|
|
712
|
+
const first = deleteRows(scope, [current]);
|
|
713
|
+
const rest = readIndex(scope).then((persisted) => {
|
|
714
|
+
const slot = getSlot();
|
|
715
|
+
const live = !isRemoved(scope, key) && slot.scope === scope ? slot.entries[key] : void 0;
|
|
716
|
+
const liveKey = live && replicaStorageKey(key, live.query);
|
|
717
|
+
const variants = new Set([...indexed.get(scope) ?? [], ...persisted].filter((queryKey) => queryKey !== current && queryKey !== liveKey && isReplicaStorageKeyOf(queryKey, key)));
|
|
718
|
+
return variants.size === 0 ? true : deleteRows(scope, [...variants]);
|
|
719
|
+
}, () => false);
|
|
720
|
+
return Promise.all([first, rest]).then((results) => results.every(Boolean));
|
|
721
|
+
};
|
|
722
|
+
/** Persist `data` to one row (the write path of a `persist` effect). */
|
|
723
|
+
const writeRow = (scope, queryKey, data) => {
|
|
724
|
+
const queue = writeQueue;
|
|
725
|
+
if (isStored(scope, queryKey, data)) {
|
|
726
|
+
touchStorageKey(scope, queryKey);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
forgetStored(scope, [queryKey]);
|
|
730
|
+
trackStorageKey(scope, queryKey);
|
|
731
|
+
const id = rowId(scope, queryKey);
|
|
732
|
+
const seq = rowWrites.get(id);
|
|
733
|
+
queue.set({
|
|
734
|
+
queryKey,
|
|
735
|
+
scope
|
|
736
|
+
}, {
|
|
737
|
+
data,
|
|
738
|
+
updatedAt: Date.now()
|
|
739
|
+
}).then((written) => {
|
|
740
|
+
if (written && rowWrites.get(id) === seq) rememberStored(scope, queryKey, data);
|
|
741
|
+
});
|
|
742
|
+
};
|
|
743
|
+
/**
|
|
744
|
+
* Read-modify-write one row in the per-key write queue, so it sees every
|
|
745
|
+
* earlier write; a missing row is never recreated. `transform` returns the
|
|
746
|
+
* next data, `undefined` to leave the row, or `DROP_ROW` to drop it (the row
|
|
747
|
+
* leaves the index once its delete landed). Resolves whether it settled.
|
|
748
|
+
*/
|
|
749
|
+
const patchRow = (scope, queryKey, transform) => {
|
|
750
|
+
const seq = noteWrite(scope, queryKey);
|
|
751
|
+
forgetStored(scope, [queryKey]);
|
|
752
|
+
let dropped = false;
|
|
753
|
+
return writeQueue.update({
|
|
754
|
+
queryKey,
|
|
755
|
+
scope
|
|
756
|
+
}, (current) => {
|
|
757
|
+
if (!current) return void 0;
|
|
758
|
+
const next = transform(current.data);
|
|
759
|
+
if (next === void 0) return void 0;
|
|
760
|
+
if (next === DROP_ROW) {
|
|
761
|
+
dropped = true;
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
return {
|
|
765
|
+
data: next,
|
|
766
|
+
updatedAt: Date.now()
|
|
767
|
+
};
|
|
768
|
+
}, { read: "strict" }).then((written) => {
|
|
769
|
+
if (written && dropped) confirmDeleted(scope, [[queryKey, seq]]);
|
|
770
|
+
return written;
|
|
771
|
+
});
|
|
772
|
+
};
|
|
773
|
+
/**
|
|
774
|
+
* Read-modify-write the persisted rows memory does not hold, keyed by row
|
|
775
|
+
* rather than by entity: a cached list for a folder the user is not viewing
|
|
776
|
+
* gets its projection patched in the per-key write queue, so a later visit
|
|
777
|
+
* hydrates the reconciled value instead of a stale page. `fn` returns the
|
|
778
|
+
* next data, the same reference (or `undefined`) to skip, or `null` to drop
|
|
779
|
+
* the row. Runs in the write queue, so it sees every earlier write.
|
|
780
|
+
*/
|
|
781
|
+
const patchStoredRows = async (fn) => {
|
|
782
|
+
if (!writeQueue || !resource.scope.canPersist()) return;
|
|
783
|
+
const scope = resource.scope.get();
|
|
784
|
+
const slot = getSlot();
|
|
785
|
+
const loaded = new Set(slot.scope === scope ? Object.entries(slot.entries).map(([key, entry]) => replicaStorageKey(key, entry.query)) : []);
|
|
786
|
+
let keys;
|
|
787
|
+
try {
|
|
788
|
+
keys = await readIndex(scope);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
console.error(`[replica:${resource.name}] failed to read the persisted index`, error);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (resource.scope.get() !== scope) return;
|
|
794
|
+
const patches = [];
|
|
795
|
+
for (const queryKey of keys) {
|
|
796
|
+
if (loaded.has(queryKey)) continue;
|
|
797
|
+
patches.push(patchRow(scope, queryKey, (data) => {
|
|
798
|
+
const next = fn(data);
|
|
799
|
+
if (next === data) return void 0;
|
|
800
|
+
return next === null ? DROP_ROW : next;
|
|
801
|
+
}));
|
|
802
|
+
}
|
|
803
|
+
await Promise.all(patches);
|
|
804
|
+
};
|
|
805
|
+
return {
|
|
806
|
+
deleteRows,
|
|
807
|
+
forgetScope,
|
|
808
|
+
forgetStored,
|
|
809
|
+
indexedKeys,
|
|
810
|
+
patchRow,
|
|
811
|
+
patchStoredRows,
|
|
812
|
+
readIndex,
|
|
813
|
+
rememberRead,
|
|
814
|
+
removeStoredVariants,
|
|
815
|
+
retain,
|
|
816
|
+
touchStorageKey,
|
|
817
|
+
writeCount,
|
|
818
|
+
writeRow
|
|
819
|
+
};
|
|
820
|
+
};
|
|
821
|
+
// src/core/entity/propagation.ts
|
|
822
|
+
/**
|
|
823
|
+
* Entity propagation: a change to one entity (e.g. a topic) applied to every
|
|
824
|
+
* entry that holds it — in memory, in persisted rows memory does not hold,
|
|
825
|
+
* and in rows a hydrate reads back while a removal is still landing.
|
|
826
|
+
*/
|
|
827
|
+
const createEntityPropagation = ({ beginOptimistic, entity, entityGuards, getSlot, paging, port, remove, resource, rows, update }) => {
|
|
828
|
+
const entityKeys = (id) => {
|
|
829
|
+
return (port.keys?.() ?? Object.keys(getSlot().entries)).filter((key) => {
|
|
830
|
+
const data = port.read(key);
|
|
831
|
+
if (data === void 0) return false;
|
|
832
|
+
if (paging) return hasPagedItem(data, id, paging);
|
|
833
|
+
return entity ? entity.has(data, id) : false;
|
|
834
|
+
});
|
|
835
|
+
};
|
|
836
|
+
/** Map one entity inside a value; `undefined` means the whole value goes away. */
|
|
837
|
+
const mapEntity = (data, id, fn) => {
|
|
838
|
+
if (paging) return mapPagedItem(data, id, fn, paging);
|
|
839
|
+
if (!entity || !entity.has(data, id)) return data;
|
|
840
|
+
return entity.map(data, id, fn);
|
|
841
|
+
};
|
|
842
|
+
/** Which of `ids` are held by `data`. */
|
|
843
|
+
const heldEntities = (data, ids) => {
|
|
844
|
+
if (paging) {
|
|
845
|
+
const items = data.items ?? [];
|
|
846
|
+
const present = new Set(items.map((item) => paging.getId(item)));
|
|
847
|
+
return ids.filter((id) => present.has(id));
|
|
848
|
+
}
|
|
849
|
+
return entity ? ids.filter((id) => entity.has(data, id)) : [];
|
|
850
|
+
};
|
|
851
|
+
/** Strip entities removed in `scope` from a value read back from storage. */
|
|
852
|
+
const withoutRemovedEntities = (scope, data) => {
|
|
853
|
+
const removed = entityGuards.removedIds(scope);
|
|
854
|
+
if (removed.length === 0) return data;
|
|
855
|
+
let next = data;
|
|
856
|
+
for (const id of heldEntities(data, removed)) {
|
|
857
|
+
next = mapEntity(next, id, () => void 0);
|
|
858
|
+
if (next === void 0) break;
|
|
859
|
+
}
|
|
860
|
+
return next;
|
|
861
|
+
};
|
|
862
|
+
/** A server value holding a removed entity again (restored) lifts its guard. */
|
|
863
|
+
const forgetRestoredEntities = (scope, data) => {
|
|
864
|
+
const removed = entityGuards.removedIds(scope);
|
|
865
|
+
if (removed.length === 0 || data === void 0) return;
|
|
866
|
+
entityGuards.liftEntityGuards(scope, heldEntities(data, removed));
|
|
867
|
+
};
|
|
868
|
+
/**
|
|
869
|
+
* Apply an entity change to persisted rows that memory does not hold (the
|
|
870
|
+
* memory path already persists loaded entries). Read-modify-write runs in
|
|
871
|
+
* the per-key write queue, so it sees every earlier write; a missing row is
|
|
872
|
+
* never recreated. Resolves once the patches have settled: `true` when every
|
|
873
|
+
* unloaded row is known to be patched (or nothing is persisted), `false` when
|
|
874
|
+
* a row may still hold the old entity (untrusted scope, unreadable index or
|
|
875
|
+
* row, a failed write, an identity switch mid-pass).
|
|
876
|
+
*/
|
|
877
|
+
const patchStoredEntityRows = async (id, fn, scope = resource.scope.get()) => {
|
|
878
|
+
if (!resource.storage) return true;
|
|
879
|
+
if (!resource.scope.canPersist()) return false;
|
|
880
|
+
const offScope = scope !== resource.scope.get();
|
|
881
|
+
const slot = getSlot();
|
|
882
|
+
const loaded = new Set(!offScope && slot.scope === scope ? Object.entries(slot.entries).map(([key, entry]) => replicaStorageKey(key, entry.query)) : []);
|
|
883
|
+
let keys;
|
|
884
|
+
try {
|
|
885
|
+
keys = await rows.readIndex(scope);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
console.error(`[replica:${resource.name}] failed to read the persisted index`, error);
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
if (!offScope && resource.scope.get() !== scope) return false;
|
|
891
|
+
for (const queryKey of rows.indexedKeys(scope) ?? []) if (!keys.includes(queryKey)) keys.push(queryKey);
|
|
892
|
+
const patches = [];
|
|
893
|
+
for (const queryKey of keys) {
|
|
894
|
+
if (loaded.has(queryKey)) continue;
|
|
895
|
+
patches.push(rows.patchRow(scope, queryKey, (data) => {
|
|
896
|
+
const next = mapEntity(data, id, fn);
|
|
897
|
+
if (next === data) return void 0;
|
|
898
|
+
return next === void 0 ? DROP_ROW : next;
|
|
899
|
+
}));
|
|
900
|
+
}
|
|
901
|
+
return (await Promise.all(patches)).every(Boolean);
|
|
902
|
+
};
|
|
903
|
+
const patchStoredEntity = async (id, fn) => {
|
|
904
|
+
await patchStoredEntityRows(id, fn);
|
|
905
|
+
};
|
|
906
|
+
/**
|
|
907
|
+
* `updateEntity`, returning the persisted-row patch (if any) to wait on —
|
|
908
|
+
* `true` once every unloaded row is patched.
|
|
909
|
+
*/
|
|
910
|
+
const applyEntity = (id, fn, { persist = true } = {}) => {
|
|
911
|
+
const patch = persist ? patchStoredEntityRows(id, fn) : void 0;
|
|
912
|
+
for (const key of entityKeys(id)) {
|
|
913
|
+
const current = port.read(key);
|
|
914
|
+
if (current === void 0) continue;
|
|
915
|
+
if (mapEntity(current, id, fn) === void 0) remove(key);
|
|
916
|
+
else update(key, (data) => data === void 0 ? data : mapEntity(data, id, fn) ?? data, { persist });
|
|
917
|
+
}
|
|
918
|
+
return patch;
|
|
919
|
+
};
|
|
920
|
+
/**
|
|
921
|
+
* An entity patch started for a scope that was not active settled. If the
|
|
922
|
+
* user switched back to that scope meanwhile, an entry may have hydrated the
|
|
923
|
+
* pre-patch row before the patch landed: apply the same change to what is
|
|
924
|
+
* loaded now, in memory only — storage already holds it.
|
|
925
|
+
*/
|
|
926
|
+
const reconcileLoaded = (scope, id, fn) => {
|
|
927
|
+
if (resource.scope.get() !== scope || getSlot().scope !== scope) return;
|
|
928
|
+
for (const key of entityKeys(id)) {
|
|
929
|
+
const current = port.read(key);
|
|
930
|
+
if (current === void 0) continue;
|
|
931
|
+
rows.forgetStored(scope, [replicaStorageKey(key, getSlot().entries[key]?.query)]);
|
|
932
|
+
if (mapEntity(current, id, fn) === void 0) remove(key);
|
|
933
|
+
else update(key, (data) => data === void 0 ? data : mapEntity(data, id, fn) ?? data, { persist: false });
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
/**
|
|
937
|
+
* Confirmed patch of one entity everywhere it is loaded and in the persisted
|
|
938
|
+
* rows of unloaded entries. `scope` targets a scope captured earlier (a
|
|
939
|
+
* write confirmed after the account switched): when it is no longer active,
|
|
940
|
+
* only that scope's persisted rows are patched and the view on screen —
|
|
941
|
+
* another identity's — is left alone.
|
|
942
|
+
*/
|
|
943
|
+
const updateEntity = (id, fn, options) => {
|
|
944
|
+
if (options?.scope !== void 0 && options.scope !== resource.scope.get()) {
|
|
945
|
+
const scope = options.scope;
|
|
946
|
+
if (options.persist === false) return;
|
|
947
|
+
const settle = () => reconcileLoaded(scope, id, fn);
|
|
948
|
+
patchStoredEntityRows(id, fn, scope).then(settle, settle);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
applyEntity(id, fn, options);
|
|
952
|
+
};
|
|
953
|
+
/**
|
|
954
|
+
* Confirmed removal of one entity everywhere (see `updateEntity`, including
|
|
955
|
+
* its captured `scope`). Unlike a plain mapper returning `undefined`, it is
|
|
956
|
+
* also remembered for that scope:
|
|
957
|
+
* a value hydrated afterwards — even by a read already in flight — comes
|
|
958
|
+
* back without the entity (see `createEntityRemovalGuards`).
|
|
959
|
+
*/
|
|
960
|
+
const removeEntity = (id, options) => {
|
|
961
|
+
const scope = options?.scope ?? resource.scope.get();
|
|
962
|
+
const offScope = scope !== resource.scope.get();
|
|
963
|
+
const release = entityGuards.guardEntity(scope, id);
|
|
964
|
+
const patch = offScope ? options?.persist === false ? void 0 : patchStoredEntityRows(id, () => void 0, scope).then((patched) => {
|
|
965
|
+
reconcileLoaded(scope, id, () => void 0);
|
|
966
|
+
return patched;
|
|
967
|
+
}) : applyEntity(id, () => void 0, options);
|
|
968
|
+
if (patch) patch.then((patched) => {
|
|
969
|
+
if (patched) release();
|
|
970
|
+
}, () => void 0);
|
|
971
|
+
else release();
|
|
972
|
+
};
|
|
973
|
+
const beginEntityOptimistic = (id, fn) => entityKeys(id).flatMap((key) => {
|
|
974
|
+
if (!paging) {
|
|
975
|
+
const current = port.read(key);
|
|
976
|
+
if (current !== void 0 && mapEntity(current, id, fn) === void 0) return [];
|
|
977
|
+
}
|
|
978
|
+
return [beginOptimistic(key, (data) => mapEntity(data, id, fn) ?? data)];
|
|
979
|
+
});
|
|
980
|
+
return {
|
|
981
|
+
beginEntityOptimistic,
|
|
982
|
+
entityKeys,
|
|
983
|
+
forgetRestoredEntities,
|
|
984
|
+
patchStoredEntity,
|
|
985
|
+
removeEntity,
|
|
986
|
+
updateEntity,
|
|
987
|
+
withoutRemovedEntities
|
|
988
|
+
};
|
|
989
|
+
};
|
|
990
|
+
// src/core/state/reducer.ts
|
|
991
|
+
const createReplicaState = () => ({ entries: {} });
|
|
992
|
+
const materialize = (base, pending) => base === void 0 ? void 0 : pending.reduce((data, mutation) => mutation.apply(data), base);
|
|
993
|
+
const noop = (state) => ({
|
|
994
|
+
effects: [],
|
|
995
|
+
state,
|
|
996
|
+
writes: []
|
|
997
|
+
});
|
|
998
|
+
/**
|
|
999
|
+
* Drop the seed patches a new seed write of `tag` supersedes: those of the same
|
|
1000
|
+
* tag after the last confirmed patch. Repeated refreshes of one source then keep
|
|
1001
|
+
* one patch instead of one per refresh, while the order relative to confirmed
|
|
1002
|
+
* writes (and to other sources' seeds) is preserved.
|
|
1003
|
+
*/
|
|
1004
|
+
const coalesceSeedPatches = (patches, isSeedWrite, tag) => {
|
|
1005
|
+
if (!isSeedWrite) return patches;
|
|
1006
|
+
let lastConfirmed = -1;
|
|
1007
|
+
patches.forEach((patch, index) => {
|
|
1008
|
+
if (!patch.seed) lastConfirmed = index;
|
|
1009
|
+
});
|
|
1010
|
+
return patches.filter((patch, index) => index <= lastConfirmed || !patch.seed || patch.tag !== tag);
|
|
1011
|
+
};
|
|
1012
|
+
const replicaReducer = (state, action, readView, now = Date.now()) => {
|
|
1013
|
+
if (action.type === "resetScope") {
|
|
1014
|
+
if (state.scope === action.scope) return noop(state);
|
|
1015
|
+
return {
|
|
1016
|
+
effects: [],
|
|
1017
|
+
state: {
|
|
1018
|
+
entries: {},
|
|
1019
|
+
scope: action.scope
|
|
1020
|
+
},
|
|
1021
|
+
writes: state.scope === void 0 ? [] : [{ type: "clear" }]
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
if (state.scope !== void 0 && state.scope !== action.scope) return noop(state);
|
|
1025
|
+
const scope = action.scope;
|
|
1026
|
+
const entry = state.entries[action.key];
|
|
1027
|
+
const view = readView(action.key);
|
|
1028
|
+
const confirmed = entry?.pending.length ? entry.base : view;
|
|
1029
|
+
const withEntry = (next) => {
|
|
1030
|
+
const entries = { ...state.entries };
|
|
1031
|
+
if (next) entries[action.key] = next;
|
|
1032
|
+
else delete entries[action.key];
|
|
1033
|
+
return {
|
|
1034
|
+
entries,
|
|
1035
|
+
scope
|
|
1036
|
+
};
|
|
1037
|
+
};
|
|
1038
|
+
switch (action.type) {
|
|
1039
|
+
case "hydrate": {
|
|
1040
|
+
const provisionalSeed = !!entry && entry.source === "seed" && entry.pending.length === 0;
|
|
1041
|
+
if (entry && !provisionalSeed) {
|
|
1042
|
+
if (!action.overwrite || entry.query === action.query || entry.pending.some((mutation) => mutation.query === void 0)) return noop(state);
|
|
1043
|
+
} else if (!entry && view !== void 0) return noop(state);
|
|
1044
|
+
const patches = provisionalSeed && entry.seedPatches || [];
|
|
1045
|
+
const data = patches.reduce((value, patch) => patch.apply(value) ?? value, action.data);
|
|
1046
|
+
const confirmedPatches = patches.filter((patch) => !patch.seed);
|
|
1047
|
+
return {
|
|
1048
|
+
effects: confirmedPatches.some((patch) => patch.persist) ? [{
|
|
1049
|
+
data,
|
|
1050
|
+
key: action.key,
|
|
1051
|
+
query: action.query,
|
|
1052
|
+
scope,
|
|
1053
|
+
type: "persist"
|
|
1054
|
+
}] : [],
|
|
1055
|
+
state: withEntry({
|
|
1056
|
+
params: action.params,
|
|
1057
|
+
pending: [],
|
|
1058
|
+
query: action.query,
|
|
1059
|
+
source: confirmedPatches.length ? "local" : "storage",
|
|
1060
|
+
updatedAt: confirmedPatches.length ? now : action.updatedAt ?? now
|
|
1061
|
+
}),
|
|
1062
|
+
writes: [{
|
|
1063
|
+
data,
|
|
1064
|
+
key: action.key
|
|
1065
|
+
}]
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
case "replace": {
|
|
1069
|
+
const pending = (entry?.pending ?? []).filter((mutation) => mutation.query === void 0 || mutation.query === action.query);
|
|
1070
|
+
const basis = entry && entry.query !== action.query ? entry.base ?? view : confirmed;
|
|
1071
|
+
const next = action.data(basis) ?? basis;
|
|
1072
|
+
const query = "query" in action ? action.query : entry?.query;
|
|
1073
|
+
const nextState = withEntry({
|
|
1074
|
+
base: pending.length ? next : void 0,
|
|
1075
|
+
params: action.params ?? entry?.params,
|
|
1076
|
+
pending,
|
|
1077
|
+
query,
|
|
1078
|
+
source: "server",
|
|
1079
|
+
updatedAt: now
|
|
1080
|
+
});
|
|
1081
|
+
if (next === void 0) return {
|
|
1082
|
+
effects: [],
|
|
1083
|
+
state: nextState,
|
|
1084
|
+
writes: []
|
|
1085
|
+
};
|
|
1086
|
+
const nextView = materialize(next, pending);
|
|
1087
|
+
return {
|
|
1088
|
+
effects: [{
|
|
1089
|
+
data: next,
|
|
1090
|
+
key: action.key,
|
|
1091
|
+
query,
|
|
1092
|
+
scope,
|
|
1093
|
+
type: "persist"
|
|
1094
|
+
}],
|
|
1095
|
+
state: nextState,
|
|
1096
|
+
writes: nextView === view ? [] : [{
|
|
1097
|
+
data: nextView,
|
|
1098
|
+
key: action.key
|
|
1099
|
+
}]
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
case "update": {
|
|
1103
|
+
const nextView = action.apply(view);
|
|
1104
|
+
const pending = entry?.pending ?? [];
|
|
1105
|
+
const nextBase = pending.length ? action.apply(entry.base) : nextView;
|
|
1106
|
+
if (nextView === view && nextBase === confirmed) return noop(state);
|
|
1107
|
+
const source = entry?.source ?? (view === void 0 ? action.source ?? "local" : "local");
|
|
1108
|
+
const persist = action.persist && source !== "seed";
|
|
1109
|
+
const isSeedWrite = action.source === "seed";
|
|
1110
|
+
const seedPatches = source === "seed" ? [...coalesceSeedPatches(entry?.seedPatches ?? [], isSeedWrite, action.seedTag), {
|
|
1111
|
+
apply: action.apply,
|
|
1112
|
+
persist: !isSeedWrite && action.persist,
|
|
1113
|
+
...isSeedWrite && {
|
|
1114
|
+
seed: true,
|
|
1115
|
+
tag: action.seedTag
|
|
1116
|
+
}
|
|
1117
|
+
}] : entry?.seedPatches;
|
|
1118
|
+
return {
|
|
1119
|
+
effects: persist && nextBase !== void 0 ? [{
|
|
1120
|
+
data: nextBase,
|
|
1121
|
+
key: action.key,
|
|
1122
|
+
query: entry?.query,
|
|
1123
|
+
scope,
|
|
1124
|
+
type: "persist"
|
|
1125
|
+
}] : [],
|
|
1126
|
+
state: withEntry({
|
|
1127
|
+
...entry,
|
|
1128
|
+
base: pending.length ? nextBase : void 0,
|
|
1129
|
+
pending,
|
|
1130
|
+
seedPatches,
|
|
1131
|
+
source,
|
|
1132
|
+
updatedAt: now
|
|
1133
|
+
}),
|
|
1134
|
+
writes: nextView === view ? [] : [{
|
|
1135
|
+
data: nextView,
|
|
1136
|
+
key: action.key
|
|
1137
|
+
}]
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
case "optimistic": {
|
|
1141
|
+
if (view === void 0) return noop(state);
|
|
1142
|
+
const pending = [...entry?.pending ?? [], {
|
|
1143
|
+
apply: action.apply,
|
|
1144
|
+
id: action.id,
|
|
1145
|
+
query: action.query
|
|
1146
|
+
}];
|
|
1147
|
+
return {
|
|
1148
|
+
effects: [],
|
|
1149
|
+
state: withEntry({
|
|
1150
|
+
...entry,
|
|
1151
|
+
base: confirmed,
|
|
1152
|
+
pending,
|
|
1153
|
+
source: entry?.source ?? "local",
|
|
1154
|
+
updatedAt: entry?.updatedAt ?? now
|
|
1155
|
+
}),
|
|
1156
|
+
writes: [{
|
|
1157
|
+
data: action.apply(view),
|
|
1158
|
+
key: action.key
|
|
1159
|
+
}]
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
case "commit":
|
|
1163
|
+
case "rollback": {
|
|
1164
|
+
const mutation = entry?.pending.find((item) => item.id === action.id);
|
|
1165
|
+
if (!entry || !mutation) return noop(state);
|
|
1166
|
+
const pending = entry.pending.filter((item) => item.id !== action.id);
|
|
1167
|
+
const base = action.type === "commit" && entry.base !== void 0 ? (action.confirm ?? mutation.apply)(entry.base) : entry.base;
|
|
1168
|
+
const nextView = action.type === "commit" && !action.confirm ? view : materialize(base, pending);
|
|
1169
|
+
return {
|
|
1170
|
+
effects: action.type === "commit" && base !== void 0 ? [{
|
|
1171
|
+
data: base,
|
|
1172
|
+
key: action.key,
|
|
1173
|
+
query: entry.query,
|
|
1174
|
+
scope,
|
|
1175
|
+
type: "persist"
|
|
1176
|
+
}] : [],
|
|
1177
|
+
state: withEntry({
|
|
1178
|
+
...entry,
|
|
1179
|
+
base: pending.length ? base : void 0,
|
|
1180
|
+
pending,
|
|
1181
|
+
source: entry.source,
|
|
1182
|
+
updatedAt: action.type === "commit" ? now : entry.updatedAt
|
|
1183
|
+
}),
|
|
1184
|
+
writes: nextView === view ? [] : [{
|
|
1185
|
+
data: nextView,
|
|
1186
|
+
key: action.key
|
|
1187
|
+
}]
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
case "remove": return {
|
|
1191
|
+
effects: [{
|
|
1192
|
+
key: action.key,
|
|
1193
|
+
query: entry?.query,
|
|
1194
|
+
scope,
|
|
1195
|
+
type: "remove"
|
|
1196
|
+
}],
|
|
1197
|
+
state: withEntry(void 0),
|
|
1198
|
+
writes: view === void 0 ? [] : [{
|
|
1199
|
+
data: void 0,
|
|
1200
|
+
key: action.key
|
|
1201
|
+
}]
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
// src/core/engine/pagination.ts
|
|
1206
|
+
/**
|
|
1207
|
+
* The engine's paged-resource transitions: folding a head page into the loaded
|
|
1208
|
+
* pages, fetching the next page, head inserts and collapsing back to the head.
|
|
1209
|
+
* The pure page math lives in `paging/operations.ts`; this adds the entry,
|
|
1210
|
+
* scope and in-flight bookkeeping around it. Every method is a no-op (or a
|
|
1211
|
+
* pass-through) for a non-paged resource.
|
|
1212
|
+
*/
|
|
1213
|
+
const createPagination = ({ fetcher, getSlot, loadingMore, options, port, resource, update }) => {
|
|
1214
|
+
const paging = resource.paging;
|
|
1215
|
+
const at = (key) => pagingFor(paging, key);
|
|
1216
|
+
const pagingCtx = { isClientOnly: options.isClientOnly };
|
|
1217
|
+
/** The persistable part of a paged value (the value itself when not paged). */
|
|
1218
|
+
const toPersisted = (data) => paging ? toPersistedPage(data, paging, pagingCtx) : data;
|
|
1219
|
+
/**
|
|
1220
|
+
* An overwrite hydrate repaints the entry from another query's persisted page.
|
|
1221
|
+
* Client-only rows (an in-flight upload / create) have no server row yet, so
|
|
1222
|
+
* that page cannot contain them — keep the ones still in the view, so a
|
|
1223
|
+
* same-pool query change (a re-sort, a view-mode switch) never drops them.
|
|
1224
|
+
* A cross-pool navigate is not "client-only" for the new params and is dropped.
|
|
1225
|
+
*/
|
|
1226
|
+
const keepClientOnlyRows = (incoming, current, key) => {
|
|
1227
|
+
if (!paging || !options.isClientOnly || !current) return incoming;
|
|
1228
|
+
const page = incoming;
|
|
1229
|
+
const currentItems = current.items ?? [];
|
|
1230
|
+
const incomingIds = new Set(page.items.map((item) => paging.getId(item)));
|
|
1231
|
+
const survivors = currentItems.filter((item) => options.isClientOnly(item) && !incomingIds.has(paging.getId(item)));
|
|
1232
|
+
if (survivors.length === 0) return incoming;
|
|
1233
|
+
const items = joinPages(survivors, page.items, at(key).direction);
|
|
1234
|
+
return {
|
|
1235
|
+
...incoming,
|
|
1236
|
+
items
|
|
1237
|
+
};
|
|
1238
|
+
};
|
|
1239
|
+
const viewMatchesFields = (current, params) => {
|
|
1240
|
+
const fields = options.viewFields?.(params);
|
|
1241
|
+
if (!current || !fields) return true;
|
|
1242
|
+
const norm = (value) => stableQueryKey(value || null);
|
|
1243
|
+
return Object.entries(fields).every(([field, value]) => norm(value) === norm(current[field]));
|
|
1244
|
+
};
|
|
1245
|
+
const mergeHead = (key, incoming, confirmed, params, reset) => {
|
|
1246
|
+
const page = incoming;
|
|
1247
|
+
const pageSize = params.pageSize ?? page.items.length;
|
|
1248
|
+
const merged = {
|
|
1249
|
+
...applyHeadPage(confirmed, page, {
|
|
1250
|
+
pageSize,
|
|
1251
|
+
reset
|
|
1252
|
+
}, at(key), pagingCtx),
|
|
1253
|
+
...options.viewFields?.(params),
|
|
1254
|
+
isLoadingMore: loadingMore.has(key)
|
|
1255
|
+
};
|
|
1256
|
+
const next = {
|
|
1257
|
+
...confirmed,
|
|
1258
|
+
...merged
|
|
1259
|
+
};
|
|
1260
|
+
return confirmed !== void 0 && isEqual(next, confirmed) ? void 0 : next;
|
|
1261
|
+
};
|
|
1262
|
+
/**
|
|
1263
|
+
* Fetch and merge the next page with the params of the loaded head page
|
|
1264
|
+
* (`fallbackParams` covers views seeded outside `useSync`). In `cursor` mode
|
|
1265
|
+
* paging only starts from a server-confirmed head page: a hydrated cursor
|
|
1266
|
+
* may be stale. A result is dropped when the scope, the query or the loaded
|
|
1267
|
+
* depth changed while it was in flight.
|
|
1268
|
+
*/
|
|
1269
|
+
const loadMore = async (key, fallbackParams) => {
|
|
1270
|
+
if (!paging || !fetcher) return;
|
|
1271
|
+
const entry = getSlot().entries[key];
|
|
1272
|
+
const current = port.read(key);
|
|
1273
|
+
if (!current || loadingMore.has(key)) return;
|
|
1274
|
+
if (paging.mode === "cursor" && entry?.source === "storage") return;
|
|
1275
|
+
const params = entry?.params ?? fallbackParams;
|
|
1276
|
+
if (params === void 0) return;
|
|
1277
|
+
const cursor = getNextPageCursor(current, at(key));
|
|
1278
|
+
if (cursor === null || cursor === void 0) return;
|
|
1279
|
+
const query = entry?.query;
|
|
1280
|
+
const scope = resource.scope.get();
|
|
1281
|
+
const depth = current.currentPage;
|
|
1282
|
+
const setLoading = (patch) => update(key, (data) => data && {
|
|
1283
|
+
...data,
|
|
1284
|
+
...patch
|
|
1285
|
+
}, { persist: false });
|
|
1286
|
+
loadingMore.add(key);
|
|
1287
|
+
setLoading({
|
|
1288
|
+
isLoadingMore: true,
|
|
1289
|
+
loadMoreError: void 0
|
|
1290
|
+
});
|
|
1291
|
+
const isCurrent = () => {
|
|
1292
|
+
const latest = port.read(key);
|
|
1293
|
+
return resource.scope.get() === scope && getSlot().entries[key]?.query === query && latest?.currentPage === depth;
|
|
1294
|
+
};
|
|
1295
|
+
try {
|
|
1296
|
+
const page = await fetcher(params, cursor);
|
|
1297
|
+
if (!isCurrent()) return void setLoading({ isLoadingMore: false });
|
|
1298
|
+
update(key, (data) => data && applyNextPage(data, page, at(key), pagingCtx), { persist: (paging.persist?.pages ?? 1) > 1 });
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
setLoading({
|
|
1301
|
+
isLoadingMore: false,
|
|
1302
|
+
loadMoreError: isCurrent() ? error : void 0
|
|
1303
|
+
});
|
|
1304
|
+
} finally {
|
|
1305
|
+
loadingMore.delete(key);
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
/** Insert rows at the head (new / streamed items). */
|
|
1309
|
+
const insertHead = (key, items, { persist = false } = {}) => paging ? update(key, (data) => data && insertHeadItems(data, items, at(key)), { persist }) : false;
|
|
1310
|
+
/** Drop loaded pages, keeping the head (e.g. after an edit inside older pages). */
|
|
1311
|
+
const collapse = (key) => paging ? update(key, (data) => data && collapseToHead(data, at(key)), { persist: false }) : false;
|
|
1312
|
+
return {
|
|
1313
|
+
collapse,
|
|
1314
|
+
insertHead,
|
|
1315
|
+
keepClientOnlyRows,
|
|
1316
|
+
loadMore,
|
|
1317
|
+
mergeHead,
|
|
1318
|
+
toPersisted,
|
|
1319
|
+
viewMatchesFields
|
|
1320
|
+
};
|
|
1321
|
+
};
|
|
1322
|
+
// src/core/engine/tombstones.ts
|
|
1323
|
+
/**
|
|
1324
|
+
* How many removal tombstones one engine remembers. The guards exist to win a
|
|
1325
|
+
* race with a hydrate that started around a removal, so a modest window is
|
|
1326
|
+
* plenty; the cap is what keeps a long-lived session from growing forever.
|
|
1327
|
+
*/
|
|
1328
|
+
const DEFAULT_REMOVAL_TOMBSTONE_LIMIT = 5e3;
|
|
1329
|
+
/**
|
|
1330
|
+
* Scope-keyed removal tombstones with a hard size bound.
|
|
1331
|
+
*
|
|
1332
|
+
* A removal must stay visible until a server value supersedes it — including
|
|
1333
|
+
* across a scope switch, so the keys are bucketed per scope rather than in one
|
|
1334
|
+
* flat set. But the engine lives as long as the page: a session that visits tens
|
|
1335
|
+
* of thousands of subjects (a task list full of items that simply have no
|
|
1336
|
+
* acceptance) would otherwise pin one entry per subject forever. So the oldest
|
|
1337
|
+
* keys are evicted once the cap is reached.
|
|
1338
|
+
*
|
|
1339
|
+
* Eviction is safe for what the tombstone is for: it closes a race with a
|
|
1340
|
+
* hydrate that was already in flight when the removal landed. By the time a key
|
|
1341
|
+
* is old enough to be evicted, any such read has long since settled.
|
|
1342
|
+
*/
|
|
1343
|
+
const createTombstones = (limit = DEFAULT_REMOVAL_TOMBSTONE_LIMIT) => {
|
|
1344
|
+
const byScope = /* @__PURE__ */ new Map();
|
|
1345
|
+
let tracked = 0;
|
|
1346
|
+
const evictOverflow = () => {
|
|
1347
|
+
while (tracked > limit) {
|
|
1348
|
+
const oldestScope = byScope.keys().next().value;
|
|
1349
|
+
const keys = byScope.get(oldestScope);
|
|
1350
|
+
keys.delete(keys.values().next().value);
|
|
1351
|
+
tracked -= 1;
|
|
1352
|
+
if (keys.size === 0) byScope.delete(oldestScope);
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
return {
|
|
1356
|
+
add: (scope, key) => {
|
|
1357
|
+
const keys = byScope.get(scope) ?? /* @__PURE__ */ new Set();
|
|
1358
|
+
byScope.set(scope, keys);
|
|
1359
|
+
if (keys.has(key)) return;
|
|
1360
|
+
keys.add(key);
|
|
1361
|
+
tracked += 1;
|
|
1362
|
+
evictOverflow();
|
|
1363
|
+
},
|
|
1364
|
+
clear: (scope, key) => {
|
|
1365
|
+
const keys = byScope.get(scope);
|
|
1366
|
+
if (!keys?.delete(key)) return;
|
|
1367
|
+
tracked -= 1;
|
|
1368
|
+
if (keys.size === 0) byScope.delete(scope);
|
|
1369
|
+
},
|
|
1370
|
+
has: (scope, key) => byScope.get(scope)?.has(key) ?? false,
|
|
1371
|
+
size: () => tracked
|
|
1372
|
+
};
|
|
1373
|
+
};
|
|
1374
|
+
// src/core/engine/removalGuards.ts
|
|
1375
|
+
/**
|
|
1376
|
+
* Removal guard of one engine.
|
|
1377
|
+
*
|
|
1378
|
+
* An explicit removal (a deleted / unauthorized aggregate, or a subject whose
|
|
1379
|
+
* attachment is gone) drops the entry AND its persisted row — but a read of
|
|
1380
|
+
* that row that started before the removal still holds the old value, and a
|
|
1381
|
+
* removal leaves no trace in the slots for the `hydrate` guard to see. So a
|
|
1382
|
+
* scope+key is remembered as removed and refuses hydration until a server
|
|
1383
|
+
* value supersedes it, otherwise the late read resurrects the exact value the
|
|
1384
|
+
* removal just dropped.
|
|
1385
|
+
*/
|
|
1386
|
+
const createRemovalGuards = () => {
|
|
1387
|
+
const removedEntries = createTombstones();
|
|
1388
|
+
/**
|
|
1389
|
+
* Keys whose persisted row delete was actually queued. `runEffects` refuses to
|
|
1390
|
+
* write while the scope is untrusted, so a removal made on a cold boot can arm
|
|
1391
|
+
* the guard without clearing the row. The guard alone must therefore not turn
|
|
1392
|
+
* a later removal into a no-op, or the stale row would survive and hydrate
|
|
1393
|
+
* again once the scope becomes trusted.
|
|
1394
|
+
*/
|
|
1395
|
+
const purgedEntries = createTombstones();
|
|
1396
|
+
/**
|
|
1397
|
+
* Bumped on every removal of a scope+key. A network response whose request
|
|
1398
|
+
* started before the entry was removed (deleted, access revoked, pruned by an
|
|
1399
|
+
* authoritative parent list) must not `replace` it back: the slice records
|
|
1400
|
+
* the generation a request starts under and drops a response that crossed a
|
|
1401
|
+
* removal. Bounded like the tombstones; an evicted key reads 0, which only
|
|
1402
|
+
* ever drops a response (the next revalidate refetches), never admits one.
|
|
1403
|
+
*/
|
|
1404
|
+
const removalGenerations = /* @__PURE__ */ new Map();
|
|
1405
|
+
const removalId = (scope, key) => `${scope}\u0000${key}`;
|
|
1406
|
+
const removalGeneration = (scope, key) => removalGenerations.get(removalId(scope, key)) ?? 0;
|
|
1407
|
+
const markRemoved = (scope, key) => {
|
|
1408
|
+
removedEntries.add(scope, key);
|
|
1409
|
+
const id = removalId(scope, key);
|
|
1410
|
+
const next = (removalGenerations.get(id) ?? 0) + 1;
|
|
1411
|
+
removalGenerations.delete(id);
|
|
1412
|
+
removalGenerations.set(id, next);
|
|
1413
|
+
if (removalGenerations.size > 5e3) removalGenerations.delete(removalGenerations.keys().next().value);
|
|
1414
|
+
};
|
|
1415
|
+
const clearRemoved = (scope, key) => {
|
|
1416
|
+
removedEntries.clear(scope, key);
|
|
1417
|
+
purgedEntries.clear(scope, key);
|
|
1418
|
+
};
|
|
1419
|
+
const isRemoved = (scope, key) => removedEntries.has(scope, key);
|
|
1420
|
+
const markPurged = (scope, key) => purgedEntries.add(scope, key);
|
|
1421
|
+
const isPurged = (scope, key) => purgedEntries.has(scope, key);
|
|
1422
|
+
return {
|
|
1423
|
+
clearRemoved,
|
|
1424
|
+
isPurged,
|
|
1425
|
+
isRemoved,
|
|
1426
|
+
markPurged,
|
|
1427
|
+
markRemoved,
|
|
1428
|
+
removalGeneration
|
|
1429
|
+
};
|
|
1430
|
+
};
|
|
1431
|
+
/**
|
|
1432
|
+
* Entity removal guards of one engine.
|
|
1433
|
+
*
|
|
1434
|
+
* A hydrate that read its row before `removeEntity` patched storage still
|
|
1435
|
+
* holds the entity, so it is stripped from every value that hydrates while
|
|
1436
|
+
* such a read may be pending. A guard's lifetime follows the hydrates, not a
|
|
1437
|
+
* count: it is released once its storage patch landed (`releasedAt`) and is
|
|
1438
|
+
* dropped as soon as no hydrate that started before that point is pending.
|
|
1439
|
+
* So memory stays bounded by in-flight reads, and no guard a pending read
|
|
1440
|
+
* still needs is ever evicted. A patch that did not land is never released:
|
|
1441
|
+
* the unloaded row still holds the entity, so every later hydrate needs the
|
|
1442
|
+
* guard. A server value holding the entity again (restored) lifts it early.
|
|
1443
|
+
*/
|
|
1444
|
+
const createEntityRemovalGuards = () => {
|
|
1445
|
+
/** Logical clock shared by hydrate starts and guard releases. */
|
|
1446
|
+
let guardClock = 0;
|
|
1447
|
+
/** Start ticks of the hydrates in flight, per scope. */
|
|
1448
|
+
const pendingHydrates = /* @__PURE__ */ new Map();
|
|
1449
|
+
/** Removed entity ids per scope; `releasedAt` is unset until the storage patch landed. */
|
|
1450
|
+
const removedEntities = /* @__PURE__ */ new Map();
|
|
1451
|
+
const removedIds = (scope) => [...removedEntities.get(scope)?.keys() ?? []];
|
|
1452
|
+
/** Drop the guards no pending hydrate of `scope` can still need. */
|
|
1453
|
+
const pruneEntityGuards = (scope) => {
|
|
1454
|
+
const guards = removedEntities.get(scope);
|
|
1455
|
+
if (!guards) return;
|
|
1456
|
+
let oldestPending = Infinity;
|
|
1457
|
+
for (const start of pendingHydrates.get(scope) ?? []) oldestPending = Math.min(oldestPending, start);
|
|
1458
|
+
for (const [id, guard] of guards) if (guard.releasedAt !== void 0 && guard.releasedAt <= oldestPending) guards.delete(id);
|
|
1459
|
+
if (guards.size === 0) removedEntities.delete(scope);
|
|
1460
|
+
};
|
|
1461
|
+
/**
|
|
1462
|
+
* Mark a hydrate of `scope` pending until settled: entity guards outlive every
|
|
1463
|
+
* read that may hold them. Returns the settle.
|
|
1464
|
+
*/
|
|
1465
|
+
const startHydrate = (scope) => {
|
|
1466
|
+
const start = ++guardClock;
|
|
1467
|
+
const pending = pendingHydrates.get(scope) ?? /* @__PURE__ */ new Set();
|
|
1468
|
+
pendingHydrates.set(scope, pending);
|
|
1469
|
+
pending.add(start);
|
|
1470
|
+
return () => {
|
|
1471
|
+
pending.delete(start);
|
|
1472
|
+
if (pending.size === 0) pendingHydrates.delete(scope);
|
|
1473
|
+
pruneEntityGuards(scope);
|
|
1474
|
+
};
|
|
1475
|
+
};
|
|
1476
|
+
/** Arm the guard of entity `id` in `scope`. Returns its release (call once the patch landed). */
|
|
1477
|
+
const guardEntity = (scope, id) => {
|
|
1478
|
+
const guard = {};
|
|
1479
|
+
const guards = removedEntities.get(scope) ?? /* @__PURE__ */ new Map();
|
|
1480
|
+
removedEntities.set(scope, guards);
|
|
1481
|
+
guards.set(id, guard);
|
|
1482
|
+
return () => {
|
|
1483
|
+
if (guards.get(id) !== guard) return;
|
|
1484
|
+
guard.releasedAt = ++guardClock;
|
|
1485
|
+
pruneEntityGuards(scope);
|
|
1486
|
+
};
|
|
1487
|
+
};
|
|
1488
|
+
/** Lift the guards of `ids` early (a server value holds them again). */
|
|
1489
|
+
const liftEntityGuards = (scope, ids) => {
|
|
1490
|
+
const guards = removedEntities.get(scope);
|
|
1491
|
+
if (!guards) return;
|
|
1492
|
+
for (const id of ids) guards.delete(id);
|
|
1493
|
+
if (guards.size === 0) removedEntities.delete(scope);
|
|
1494
|
+
};
|
|
1495
|
+
return {
|
|
1496
|
+
guardEntity,
|
|
1497
|
+
liftEntityGuards,
|
|
1498
|
+
removedIds,
|
|
1499
|
+
startHydrate
|
|
1500
|
+
};
|
|
1501
|
+
};
|
|
1502
|
+
// src/core/engine/scopeClear.ts
|
|
1503
|
+
/**
|
|
1504
|
+
* Resource-wide clear of one scope: memory, every persisted row and reads in
|
|
1505
|
+
* flight. A clear bumps the scope's generation so reads started before it are
|
|
1506
|
+
* dropped, and records its row purge so a later hydrate waits for it.
|
|
1507
|
+
*/
|
|
1508
|
+
const createScopeClear = ({ port, prefix, resource, rows, writeQueue }) => {
|
|
1509
|
+
const { deleteRows, readIndex } = rows;
|
|
1510
|
+
/** Bumped by every `clear()` of a scope; reads started before it are stale. */
|
|
1511
|
+
const clearGenerations = /* @__PURE__ */ new Map();
|
|
1512
|
+
const clearGeneration = (scope) => clearGenerations.get(scope) ?? 0;
|
|
1513
|
+
/**
|
|
1514
|
+
* The persisted-row purge of the last `clear()` per scope: `true` once every
|
|
1515
|
+
* row delete landed. A hydrate waits for it, and refuses to read while the
|
|
1516
|
+
* purge failed (an untrusted scope, a storage error) so a wiped resource can
|
|
1517
|
+
* never paint a row that survived it.
|
|
1518
|
+
*/
|
|
1519
|
+
const scopePurges = /* @__PURE__ */ new Map();
|
|
1520
|
+
const lastPurge = (scope) => scopePurges.get(scope);
|
|
1521
|
+
/**
|
|
1522
|
+
* Wipe the resource in the active scope: every entry leaves memory in one
|
|
1523
|
+
* host commit, every persisted row (all keys, all query variants) and the
|
|
1524
|
+
* index are deleted, and reads already in flight are dropped — a hydrate
|
|
1525
|
+
* started before the clear never dispatches, and `useSync` discards head
|
|
1526
|
+
* responses whose request started before it. Requests started afterwards
|
|
1527
|
+
* apply normally. Resolves `true` once the persisted rows are gone.
|
|
1528
|
+
*/
|
|
1529
|
+
const clear = () => {
|
|
1530
|
+
const scope = resource.scope.get();
|
|
1531
|
+
clearGenerations.set(scope, clearGeneration(scope) + 1);
|
|
1532
|
+
port.commit([{ type: "clear" }], {
|
|
1533
|
+
entries: {},
|
|
1534
|
+
scope
|
|
1535
|
+
}, `${prefix}/clear`);
|
|
1536
|
+
rows.forgetScope(scope);
|
|
1537
|
+
const purge = purgeScope(scope);
|
|
1538
|
+
scopePurges.set(scope, purge);
|
|
1539
|
+
return purge;
|
|
1540
|
+
};
|
|
1541
|
+
/**
|
|
1542
|
+
* `clear()` for any scope, including one that is not active (a user who
|
|
1543
|
+
* switched away while their data was being deleted): every persisted row of
|
|
1544
|
+
* `scope` and its index go, and for the active scope memory is wiped too
|
|
1545
|
+
* (exactly `clear()`). An inactive scope's memory is not on screen and is
|
|
1546
|
+
* left alone. The row deletes go through the write queue, so they land after
|
|
1547
|
+
* any write this session already queued for that scope — a row (or index
|
|
1548
|
+
* entry) written just before is purged rather than landing after the purge.
|
|
1549
|
+
* A later hydrate of `scope` waits for the purge. Resolves `true` once the
|
|
1550
|
+
* persisted rows are gone.
|
|
1551
|
+
*/
|
|
1552
|
+
const clearScope = (scope) => {
|
|
1553
|
+
if (scope === resource.scope.get()) return clear();
|
|
1554
|
+
clearGenerations.set(scope, clearGeneration(scope) + 1);
|
|
1555
|
+
const purge = purgeScope(scope);
|
|
1556
|
+
scopePurges.set(scope, purge);
|
|
1557
|
+
return purge;
|
|
1558
|
+
};
|
|
1559
|
+
/** Delete every persisted row of `scope` (see `clear`). */
|
|
1560
|
+
const purgeScope = async (scope) => {
|
|
1561
|
+
if (!writeQueue) return true;
|
|
1562
|
+
if (!resource.scope.canPersist()) return false;
|
|
1563
|
+
const queued = deleteRows(scope, [...rows.indexedKeys(scope) ?? []]);
|
|
1564
|
+
let persisted;
|
|
1565
|
+
try {
|
|
1566
|
+
persisted = await readIndex(scope);
|
|
1567
|
+
} catch {
|
|
1568
|
+
return false;
|
|
1569
|
+
}
|
|
1570
|
+
const known = rows.indexedKeys(scope);
|
|
1571
|
+
const stale = persisted.filter((queryKey) => !known?.has(queryKey));
|
|
1572
|
+
const [first, rest] = await Promise.all([queued, deleteRows(scope, stale)]);
|
|
1573
|
+
return first && rest;
|
|
1574
|
+
};
|
|
1575
|
+
return {
|
|
1576
|
+
clear,
|
|
1577
|
+
clearGeneration,
|
|
1578
|
+
clearScope,
|
|
1579
|
+
lastPurge
|
|
1580
|
+
};
|
|
1581
|
+
};
|
|
1582
|
+
// src/core/engine/createReplicaEngine.ts
|
|
1583
|
+
/**
|
|
1584
|
+
* The replica engine: every transition of one resource, independent of any
|
|
1585
|
+
* UI framework or state library.
|
|
1586
|
+
*
|
|
1587
|
+
* It owns hydrate-if-empty, server replace, pagination, optimistic overlays
|
|
1588
|
+
* with commit/rollback, entity propagation, scope isolation and serialized
|
|
1589
|
+
* persistence. The host store keeps the rendered value (see
|
|
1590
|
+
* {@link ReplicaStorePort}); fetch scheduling (when to sync, dedupe, focus
|
|
1591
|
+
* revalidation) belongs to an adapter such as `local-replica/zustand`.
|
|
1592
|
+
*/
|
|
1593
|
+
const createReplicaEngine = (resource, options) => {
|
|
1594
|
+
const { port } = options;
|
|
1595
|
+
const paging = resource.paging;
|
|
1596
|
+
const prefix = options.actionPrefix ?? resource.name;
|
|
1597
|
+
const writeQueue = resource.storage ? new ReplicaWriteQueue(resource.storage) : void 0;
|
|
1598
|
+
const fetcher = options.fetcher ?? resource.fetcher;
|
|
1599
|
+
let mutationSeq = 0;
|
|
1600
|
+
/** Keys with a `loadMore` request in flight (the only valid `isLoadingMore`). */
|
|
1601
|
+
const loadingMore = /* @__PURE__ */ new Set();
|
|
1602
|
+
const { clearRemoved, isPurged, isRemoved, markPurged, markRemoved, removalGeneration } = createRemovalGuards();
|
|
1603
|
+
const entityGuards = createEntityRemovalGuards();
|
|
1604
|
+
const getSlot = port.getState;
|
|
1605
|
+
const storageKey = (key, query) => ({ queryKey: replicaStorageKey(key, query) });
|
|
1606
|
+
const toPersisted = (data) => {
|
|
1607
|
+
const paged = pagination.toPersisted(data);
|
|
1608
|
+
return options.toPersisted ? options.toPersisted(paged) : paged;
|
|
1609
|
+
};
|
|
1610
|
+
const rows = createPersistedRows({
|
|
1611
|
+
commit: (keys, state) => port.commit(keys.map((key) => ({
|
|
1612
|
+
data: void 0,
|
|
1613
|
+
key
|
|
1614
|
+
})), state, `${prefix}/evict`),
|
|
1615
|
+
getSlot,
|
|
1616
|
+
isRemoved,
|
|
1617
|
+
loadingMore,
|
|
1618
|
+
resource,
|
|
1619
|
+
writeQueue
|
|
1620
|
+
});
|
|
1621
|
+
const { deleteRows, removeStoredVariants, retain, touchStorageKey } = rows;
|
|
1622
|
+
const { clear, clearGeneration, clearScope, lastPurge } = createScopeClear({
|
|
1623
|
+
port,
|
|
1624
|
+
prefix,
|
|
1625
|
+
resource,
|
|
1626
|
+
rows,
|
|
1627
|
+
writeQueue
|
|
1628
|
+
});
|
|
1629
|
+
const runEffects = (effects) => {
|
|
1630
|
+
if (!writeQueue || effects.length === 0) return;
|
|
1631
|
+
if (!resource.scope.canPersist()) return;
|
|
1632
|
+
for (const effect of effects) {
|
|
1633
|
+
if (!resource.persistKey(effect.key)) continue;
|
|
1634
|
+
const key = {
|
|
1635
|
+
...storageKey(effect.key, effect.query),
|
|
1636
|
+
scope: effect.scope
|
|
1637
|
+
};
|
|
1638
|
+
if (effect.type === "remove") {
|
|
1639
|
+
removeStoredVariants(effect.scope, effect.key, key.queryKey).then((removed) => {
|
|
1640
|
+
if (removed) markPurged(effect.scope, effect.key);
|
|
1641
|
+
});
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
const data = toPersisted(effect.data);
|
|
1645
|
+
if (data === void 0) continue;
|
|
1646
|
+
if (data === null) {
|
|
1647
|
+
deleteRows(effect.scope, [key.queryKey]);
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
rows.writeRow(effect.scope, key.queryKey, data);
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
/** Read an entry as it will be once `writes` are applied (latest write wins). */
|
|
1654
|
+
const readThrough = (writes, key) => {
|
|
1655
|
+
for (let i = writes.length - 1; i >= 0; i--) {
|
|
1656
|
+
const write = writes[i];
|
|
1657
|
+
if ("type" in write) return void 0;
|
|
1658
|
+
if (write.key === key) return write.data;
|
|
1659
|
+
}
|
|
1660
|
+
return port.read(key);
|
|
1661
|
+
};
|
|
1662
|
+
const dispatch = (action) => runActions([action]);
|
|
1663
|
+
/**
|
|
1664
|
+
* Fold one or more actions into a single host commit. A caller that seeds
|
|
1665
|
+
* many keys (a list refresh) must not clone the view and notify subscribers
|
|
1666
|
+
* once per key; every action is still reduced in order, and the accumulated
|
|
1667
|
+
* writes are applied as ONE host update. A batch is dropped whole when any
|
|
1668
|
+
* action belongs to another identity (see {@link dispatch}).
|
|
1669
|
+
*/
|
|
1670
|
+
function runActions(actions) {
|
|
1671
|
+
if (actions.length === 0) return false;
|
|
1672
|
+
const activeScope = resource.scope.get();
|
|
1673
|
+
if (actions.some((action) => action.scope !== activeScope)) return false;
|
|
1674
|
+
for (const action of actions) {
|
|
1675
|
+
if (action.type === "remove") markRemoved(action.scope, action.key);
|
|
1676
|
+
if (action.type === "replace") clearRemoved(action.scope, action.key);
|
|
1677
|
+
}
|
|
1678
|
+
const initial = getSlot();
|
|
1679
|
+
let slot = initial;
|
|
1680
|
+
const writes = [];
|
|
1681
|
+
const effects = [];
|
|
1682
|
+
const read = (key) => readThrough(writes, key);
|
|
1683
|
+
if (slot.scope !== void 0 && slot.scope !== activeScope) {
|
|
1684
|
+
rows.forgetScope(slot.scope);
|
|
1685
|
+
const reset = replicaReducer(slot, {
|
|
1686
|
+
scope: activeScope,
|
|
1687
|
+
type: "resetScope"
|
|
1688
|
+
}, read);
|
|
1689
|
+
writes.push(...reset.writes);
|
|
1690
|
+
slot = reset.state;
|
|
1691
|
+
}
|
|
1692
|
+
let changed = false;
|
|
1693
|
+
for (const action of actions) {
|
|
1694
|
+
const transition = replicaReducer(slot, action, read);
|
|
1695
|
+
writes.push(...transition.writes);
|
|
1696
|
+
effects.push(...transition.effects);
|
|
1697
|
+
if (transition.state !== slot) {
|
|
1698
|
+
slot = transition.state;
|
|
1699
|
+
changed = true;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
if (!changed && writes.length === 0 && slot === initial) return false;
|
|
1703
|
+
port.commit(writes, slot, `${prefix}/${actions.length > 1 ? "batch" : actions[0].type}`);
|
|
1704
|
+
runEffects(effects);
|
|
1705
|
+
return true;
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Drop memory owned by another identity as soon as a new scope is active —
|
|
1709
|
+
* even when the new scope has nothing persisted and its fetch is slow, the
|
|
1710
|
+
* previous user's / workspace's rows must not stay on screen.
|
|
1711
|
+
*/
|
|
1712
|
+
const ensureScope = (scope) => {
|
|
1713
|
+
if (scope === resource.scope.get() && getSlot().scope !== scope) dispatch({
|
|
1714
|
+
scope,
|
|
1715
|
+
type: "resetScope"
|
|
1716
|
+
});
|
|
1717
|
+
};
|
|
1718
|
+
const getConfirmed = (key) => {
|
|
1719
|
+
const entry = getSlot().entries[key];
|
|
1720
|
+
return entry?.pending.length ? entry.base : port.read(key);
|
|
1721
|
+
};
|
|
1722
|
+
const hydrate = async (params, scope = resource.scope.get(), { overwrite = false } = {}) => {
|
|
1723
|
+
if (!resource.storage) return false;
|
|
1724
|
+
if (resource.scope.canHydrate && !resource.scope.canHydrate()) return false;
|
|
1725
|
+
const key = resource.key(params);
|
|
1726
|
+
if (!resource.persistKey(key)) return false;
|
|
1727
|
+
if (isRemoved(scope, key)) return false;
|
|
1728
|
+
const settle = entityGuards.startHydrate(scope);
|
|
1729
|
+
try {
|
|
1730
|
+
return await readAndHydrate(params, key, scope, overwrite);
|
|
1731
|
+
} finally {
|
|
1732
|
+
settle();
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
const readAndHydrate = async (params, key, scope, overwrite) => {
|
|
1736
|
+
const storage = resource.storage;
|
|
1737
|
+
const generation = clearGeneration(scope);
|
|
1738
|
+
const purge = lastPurge(scope);
|
|
1739
|
+
if (purge && !await purge) return false;
|
|
1740
|
+
const query = resource.query(params);
|
|
1741
|
+
const { queryKey } = storageKey(key, query);
|
|
1742
|
+
const writesBefore = rows.writeCount();
|
|
1743
|
+
const cached = await storage.get({
|
|
1744
|
+
queryKey,
|
|
1745
|
+
scope
|
|
1746
|
+
});
|
|
1747
|
+
if (!cached) return false;
|
|
1748
|
+
if (isRemoved(scope, key) || clearGeneration(scope) !== generation) return false;
|
|
1749
|
+
rows.rememberRead(scope, queryKey, cached.data, writesBefore);
|
|
1750
|
+
if (options.isHydratable && !options.isHydratable(cached.data, params)) return false;
|
|
1751
|
+
const stored = withoutRemovedEntities(scope, cached.data);
|
|
1752
|
+
if (stored === void 0) return false;
|
|
1753
|
+
const data = overwrite ? pagination.keepClientOnlyRows(stored, port.read(key), key) : stored;
|
|
1754
|
+
const applied = dispatch({
|
|
1755
|
+
data,
|
|
1756
|
+
key,
|
|
1757
|
+
overwrite,
|
|
1758
|
+
params,
|
|
1759
|
+
query,
|
|
1760
|
+
scope,
|
|
1761
|
+
type: "hydrate",
|
|
1762
|
+
updatedAt: cached.updatedAt
|
|
1763
|
+
});
|
|
1764
|
+
if (applied && resource.scope.canPersist()) touchStorageKey(scope, queryKey);
|
|
1765
|
+
return applied;
|
|
1766
|
+
};
|
|
1767
|
+
/** Non-paged: whether a (prepared) head response is a terminal absence (`isMissing`). */
|
|
1768
|
+
const isMissingHead = (incoming) => !paging && !!options.isMissing?.(incoming);
|
|
1769
|
+
/**
|
|
1770
|
+
* Fold a head response in and report what it was: dropped by `prepareHead`,
|
|
1771
|
+
* a terminal absence (`isMissing`, applied as a removal) or a replacement.
|
|
1772
|
+
* `changed` is whether the store moved. `replace` is the boolean shorthand;
|
|
1773
|
+
* the zustand slice's settle pipeline reads the outcome to pick its effects.
|
|
1774
|
+
*/
|
|
1775
|
+
const applyHead = (params, fetched, scope = resource.scope.get()) => {
|
|
1776
|
+
const key = resource.key(params);
|
|
1777
|
+
const incoming = options.prepareHead ? options.prepareHead(fetched, port.read(key), params) : fetched;
|
|
1778
|
+
if (incoming === void 0) return {
|
|
1779
|
+
changed: false,
|
|
1780
|
+
outcome: "dropped"
|
|
1781
|
+
};
|
|
1782
|
+
if (isMissingHead(incoming)) return {
|
|
1783
|
+
changed: remove(key, scope),
|
|
1784
|
+
outcome: "missing"
|
|
1785
|
+
};
|
|
1786
|
+
const query = resource.query(params);
|
|
1787
|
+
const entry = getSlot().entries[key];
|
|
1788
|
+
const reset = entry ? entry.query !== query : paging !== void 0 && !pagination.viewMatchesFields(port.read(key), params);
|
|
1789
|
+
const applied = dispatch({
|
|
1790
|
+
data: (confirmed) => paging ? pagination.mergeHead(key, incoming, confirmed, params, reset) : options.merge ? options.merge(incoming, confirmed, params) : incoming,
|
|
1791
|
+
key,
|
|
1792
|
+
params,
|
|
1793
|
+
query,
|
|
1794
|
+
scope,
|
|
1795
|
+
type: "replace"
|
|
1796
|
+
});
|
|
1797
|
+
if (applied) forgetRestoredEntities(scope, getConfirmed(key));
|
|
1798
|
+
return {
|
|
1799
|
+
changed: applied,
|
|
1800
|
+
outcome: "replaced"
|
|
1801
|
+
};
|
|
1802
|
+
};
|
|
1803
|
+
const replace = (params, fetched, scope = resource.scope.get()) => applyHead(params, fetched, scope).changed;
|
|
1804
|
+
/**
|
|
1805
|
+
* Confirmed local write: patches the view (and the base under any overlay).
|
|
1806
|
+
* Pass `source: 'seed'` for a provisional row (a list row standing in for a
|
|
1807
|
+
* detail): it is hydratable and never persisted, so the authoritative value it
|
|
1808
|
+
* stands in for is preserved (see `ReplicaSource`).
|
|
1809
|
+
*/
|
|
1810
|
+
const update = (key, apply, { persist = true, seedTag, source } = {}) => dispatch({
|
|
1811
|
+
apply,
|
|
1812
|
+
key,
|
|
1813
|
+
persist,
|
|
1814
|
+
scope: resource.scope.get(),
|
|
1815
|
+
seedTag,
|
|
1816
|
+
source,
|
|
1817
|
+
type: "update"
|
|
1818
|
+
});
|
|
1819
|
+
/**
|
|
1820
|
+
* Confirmed local write to several keys as ONE host update — same per-key
|
|
1821
|
+
* semantics as {@link update}, but a whole list seeds in a single commit
|
|
1822
|
+
* instead of one per row (no repeated view cloning or subscriber churn).
|
|
1823
|
+
*/
|
|
1824
|
+
const updateMany = (entries, { persist = true, seedTag, source } = {}) => {
|
|
1825
|
+
const scope = resource.scope.get();
|
|
1826
|
+
return runActions(entries.map(({ apply, key }) => ({
|
|
1827
|
+
apply,
|
|
1828
|
+
key,
|
|
1829
|
+
persist,
|
|
1830
|
+
scope,
|
|
1831
|
+
seedTag,
|
|
1832
|
+
source,
|
|
1833
|
+
type: "update"
|
|
1834
|
+
})));
|
|
1835
|
+
};
|
|
1836
|
+
/**
|
|
1837
|
+
* Purge a removal that belongs to a scope which is no longer active.
|
|
1838
|
+
*
|
|
1839
|
+
* The in-memory view is not ours to touch — the active scope owns it — but the
|
|
1840
|
+
* guard and the persisted row both belong to THAT scope. Arming the one and
|
|
1841
|
+
* deleting the other keeps a switch back from hydrating a value the server
|
|
1842
|
+
* just deleted or denied. `dispatch` would reject the off-scope action
|
|
1843
|
+
* outright (see `replace`'s stale-scope guard), so the row would otherwise
|
|
1844
|
+
* survive untouched.
|
|
1845
|
+
*
|
|
1846
|
+
* Like `remove`, the delete only counts as done once it was actually queued:
|
|
1847
|
+
* an untrusted scope refuses writes, so the guard stays armed as `unpurged`
|
|
1848
|
+
* and a later removal retries instead of reporting a no-op that never deletes.
|
|
1849
|
+
*/
|
|
1850
|
+
const purge = (key, scope) => {
|
|
1851
|
+
if (isRemoved(scope, key) && isPurged(scope, key)) return false;
|
|
1852
|
+
markRemoved(scope, key);
|
|
1853
|
+
if (!writeQueue || !resource.persistKey(key)) {
|
|
1854
|
+
markPurged(scope, key);
|
|
1855
|
+
return false;
|
|
1856
|
+
}
|
|
1857
|
+
if (!resource.scope.canPersist()) return false;
|
|
1858
|
+
removeStoredVariants(scope, key, storageKey(key).queryKey).then((removed) => {
|
|
1859
|
+
if (removed) markPurged(scope, key);
|
|
1860
|
+
});
|
|
1861
|
+
return true;
|
|
1862
|
+
};
|
|
1863
|
+
/**
|
|
1864
|
+
* Drop an entry and its persisted row.
|
|
1865
|
+
*
|
|
1866
|
+
* A removal is a no-op only once there is genuinely nothing left to do: the
|
|
1867
|
+
* key is guarded, its value is gone, AND its stale row was already scheduled
|
|
1868
|
+
* for deletion (or there is no persisted row to clear). A subject with no
|
|
1869
|
+
* acceptance polls `null` every couple of seconds, and re-emitting the same
|
|
1870
|
+
* delete on every tick would only churn storage. But a removal whose delete
|
|
1871
|
+
* was skipped — an untrusted scope refuses writes — keeps retrying, so the row
|
|
1872
|
+
* cannot survive to hydrate again once the scope is trusted.
|
|
1873
|
+
*
|
|
1874
|
+
* A removal captured under another identity (a late NOT_FOUND arriving after a
|
|
1875
|
+
* workspace switch) is routed to {@link purge}: it must not touch the scope on
|
|
1876
|
+
* screen, yet its own scope's row still has to go.
|
|
1877
|
+
*/
|
|
1878
|
+
const remove = (key, scope = resource.scope.get()) => {
|
|
1879
|
+
if (scope !== resource.scope.get()) return purge(key, scope);
|
|
1880
|
+
if ((!writeQueue || !resource.persistKey(key) || isPurged(scope, key)) && isRemoved(scope, key) && port.read(key) === void 0) return false;
|
|
1881
|
+
return dispatch({
|
|
1882
|
+
key,
|
|
1883
|
+
scope,
|
|
1884
|
+
type: "remove"
|
|
1885
|
+
});
|
|
1886
|
+
};
|
|
1887
|
+
/**
|
|
1888
|
+
* Persist the confirmed value of an entry as it is now — the flush after a
|
|
1889
|
+
* burst of in-memory `update(..., { persist: false })` writes (a stream).
|
|
1890
|
+
*/
|
|
1891
|
+
const persist = (key) => {
|
|
1892
|
+
const scope = resource.scope.get();
|
|
1893
|
+
if (getSlot().scope !== void 0 && getSlot().scope !== scope) return;
|
|
1894
|
+
const data = getConfirmed(key);
|
|
1895
|
+
if (data === void 0) return;
|
|
1896
|
+
runEffects([{
|
|
1897
|
+
data,
|
|
1898
|
+
key,
|
|
1899
|
+
query: getSlot().entries[key]?.query,
|
|
1900
|
+
scope,
|
|
1901
|
+
type: "persist"
|
|
1902
|
+
}]);
|
|
1903
|
+
};
|
|
1904
|
+
const revalidate = (key) => options.revalidate ? options.revalidate(key) : Promise.resolve();
|
|
1905
|
+
/**
|
|
1906
|
+
* Start an optimistic overlay now and settle it later (multi-resource flows).
|
|
1907
|
+
* `query` tags the overlay with the query it describes; a caller that knows
|
|
1908
|
+
* the requested query (e.g. before the first page painted) should pass it, so
|
|
1909
|
+
* the overlay is scoped to that query rather than kept across every change.
|
|
1910
|
+
*/
|
|
1911
|
+
const beginOptimistic = (key, apply, query) => {
|
|
1912
|
+
const scope = resource.scope.get();
|
|
1913
|
+
const id = ++mutationSeq;
|
|
1914
|
+
dispatch({
|
|
1915
|
+
apply,
|
|
1916
|
+
id,
|
|
1917
|
+
key,
|
|
1918
|
+
query: query ?? getSlot().entries[key]?.query,
|
|
1919
|
+
scope,
|
|
1920
|
+
type: "optimistic"
|
|
1921
|
+
});
|
|
1922
|
+
return {
|
|
1923
|
+
commit: (confirm) => {
|
|
1924
|
+
dispatch({
|
|
1925
|
+
confirm,
|
|
1926
|
+
id,
|
|
1927
|
+
key,
|
|
1928
|
+
scope,
|
|
1929
|
+
type: "commit"
|
|
1930
|
+
});
|
|
1931
|
+
},
|
|
1932
|
+
rollback: () => {
|
|
1933
|
+
dispatch({
|
|
1934
|
+
id,
|
|
1935
|
+
key,
|
|
1936
|
+
scope,
|
|
1937
|
+
type: "rollback"
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
};
|
|
1942
|
+
/**
|
|
1943
|
+
* Apply `apply` to the view right away, run `serverCall`, then commit (the
|
|
1944
|
+
* confirmed value is persisted) or roll back (the view is rebuilt from the
|
|
1945
|
+
* confirmed base plus any other in-flight overlays) and rethrow.
|
|
1946
|
+
*/
|
|
1947
|
+
const optimistic = async (key, apply, serverCall, mutationOptions = {}) => {
|
|
1948
|
+
const token = beginOptimistic(key, apply);
|
|
1949
|
+
try {
|
|
1950
|
+
const result = await serverCall();
|
|
1951
|
+
token.commit(mutationOptions.confirm?.(result));
|
|
1952
|
+
return result;
|
|
1953
|
+
} catch (error) {
|
|
1954
|
+
token.rollback();
|
|
1955
|
+
throw error;
|
|
1956
|
+
} finally {
|
|
1957
|
+
if (mutationOptions.revalidate) revalidate(key);
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1960
|
+
const pagination = createPagination({
|
|
1961
|
+
fetcher,
|
|
1962
|
+
getSlot,
|
|
1963
|
+
loadingMore,
|
|
1964
|
+
options,
|
|
1965
|
+
port,
|
|
1966
|
+
resource,
|
|
1967
|
+
update
|
|
1968
|
+
});
|
|
1969
|
+
const { collapse, insertHead, loadMore } = pagination;
|
|
1970
|
+
const { beginEntityOptimistic, entityKeys, forgetRestoredEntities, patchStoredEntity, removeEntity, updateEntity, withoutRemovedEntities } = createEntityPropagation({
|
|
1971
|
+
beginOptimistic,
|
|
1972
|
+
entity: options.entity,
|
|
1973
|
+
entityGuards,
|
|
1974
|
+
getSlot,
|
|
1975
|
+
paging,
|
|
1976
|
+
port,
|
|
1977
|
+
remove,
|
|
1978
|
+
resource,
|
|
1979
|
+
rows,
|
|
1980
|
+
update
|
|
1981
|
+
});
|
|
1982
|
+
const { patchStoredRows } = rows;
|
|
1983
|
+
return {
|
|
1984
|
+
applyHead,
|
|
1985
|
+
beginEntityOptimistic,
|
|
1986
|
+
beginOptimistic,
|
|
1987
|
+
clear,
|
|
1988
|
+
clearGeneration,
|
|
1989
|
+
clearScope,
|
|
1990
|
+
removalGeneration,
|
|
1991
|
+
collapse,
|
|
1992
|
+
dispatch,
|
|
1993
|
+
ensureScope,
|
|
1994
|
+
entityKeys,
|
|
1995
|
+
fetcher,
|
|
1996
|
+
getConfirmed,
|
|
1997
|
+
hydrate,
|
|
1998
|
+
insertHead,
|
|
1999
|
+
isMissingHead,
|
|
2000
|
+
loadMore,
|
|
2001
|
+
optimistic,
|
|
2002
|
+
patchStoredEntity,
|
|
2003
|
+
patchStoredRows,
|
|
2004
|
+
persist,
|
|
2005
|
+
remove,
|
|
2006
|
+
removeEntity,
|
|
2007
|
+
replace,
|
|
2008
|
+
resource,
|
|
2009
|
+
retain,
|
|
2010
|
+
revalidate,
|
|
2011
|
+
update,
|
|
2012
|
+
updateEntity,
|
|
2013
|
+
updateMany
|
|
2014
|
+
};
|
|
2015
|
+
};
|
|
2016
|
+
export { stableQueryKey as S, REPLICA_KEY_PREFIX as _, ReplicaWriteQueue as a, isReplicaStorageKeyOf as b, applyNextPage as c, hasPagedItem as d, insertHeadItems as f, toPersistedPage as g, pagingFor as h, REPLICA_INDEX_KEY as i, collapseToHead as l, mapPagedItem as m, createReplicaState as n, readReplicaRowStrict as o, joinPages as p, replicaReducer as r, applyHeadPage as s, createReplicaEngine as t, getNextPageCursor as u, isReplicaSyncKey as v, replicaStorageKey as x, replicaKeys as y };
|
|
2017
|
+
|
|
2018
|
+
//# sourceMappingURL=createReplicaEngine-CdR-URHo.js.map
|