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.
@@ -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