local-replica 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import isEqual from "fast-deep-equal";
2
- // src/core/defineReplica.ts
2
+ // src/core/resource/storageKey.ts
3
3
  /** Persisted row key: one row per entry key and query. */
4
4
  const replicaStorageKey = (key, query) => query ? `${key}?${query}` : key;
5
5
  /**
@@ -25,47 +25,50 @@ const isReplicaStorageKeyOf = (storageKey, key) => {
25
25
  };
26
26
  /** Deterministic JSON: sorted keys, `undefined` dropped. */
27
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
28
29
  /**
29
- * A fixed identity partition. The default for apps without accounts; an app
30
- * with sign-in passes a scope derived from the signed-in user / workspace so
31
- * one browser profile never mixes two identities' rows.
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}.
32
33
  */
33
- const staticReplicaScope = (scope = "default") => ({
34
- canPersist: () => true,
35
- get: () => scope,
36
- use: () => scope
37
- });
38
- const DEFAULT_SCOPE = staticReplicaScope();
39
- const defineReplica = (options) => {
40
- const namespace = `replica:${options.name}:v${options.version}`;
41
- const storage = typeof options.storage === "function" ? options.storage(namespace) : options.storage;
42
- return {
43
- fetcher: options.fetcher,
44
- key: options.key,
45
- maxPersistedEntries: options.persist?.maxEntries,
46
- name: options.name,
47
- namespace,
48
- paging: options.paging,
49
- persistKey: options.persistKey ?? (() => true),
50
- persisted: !!storage,
51
- query: (params) => options.query ? stableQueryKey(options.query(params)) : void 0,
52
- storageKey: (params) => replicaStorageKey(options.key(params), options.query ? stableQueryKey(options.query(params)) : void 0),
53
- scope: options.scope ?? DEFAULT_SCOPE,
54
- syncKey: options.syncKey,
55
- storage,
56
- version: options.version
57
- };
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
+ ]
58
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
59
57
  /**
60
- * A resource whose value is a paged list ({@link ReplicaPagedData}). The
61
- * store view type may extend the paged shape with domain fields.
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.
62
71
  */
63
- const definePagedReplica = (options) => defineReplica({
64
- ...options,
65
- fetcher: options.fetchPage,
66
- storage: options.storage
67
- });
68
- // src/core/paging.ts
69
72
  const dedupe = (items, getId, seen = /* @__PURE__ */ new Set()) => {
70
73
  const out = [];
71
74
  for (const item of items) {
@@ -395,7 +398,7 @@ const toPersistedPage = (data, config, ctx = {}) => {
395
398
  }]
396
399
  };
397
400
  };
398
- // src/core/writeQueue.ts
401
+ // src/core/persistence/writeQueue.ts
399
402
  /**
400
403
  * Strict read of one row (see `ReplicaStorage.getStrict`). Rejects when the
401
404
  * storage has no strict read: its `get` swallows I/O failures into a miss, so a
@@ -454,7 +457,7 @@ var ReplicaWriteQueue = class {
454
457
  return settled;
455
458
  };
456
459
  };
457
- // src/core/persistedRows.ts
460
+ // src/core/persistence/persistedRows.ts
458
461
  /** Reserved storage key of the per-scope index of persisted rows. */
459
462
  const REPLICA_INDEX_KEY = "__replica:index";
460
463
  /** Returned by a `patchRow` transform to delete the row (`null` stays a value). */
@@ -549,8 +552,11 @@ const createPersistedRows = ({ commit, getSlot, isRemoved, loadingMore, resource
549
552
  for (const [key, entry] of Object.entries(slot.entries)) rows.set(replicaStorageKey(key, entry.query), key);
550
553
  return rows;
551
554
  };
552
- /** A row on screen, or whose entry has writes / a page load in flight. */
553
- const isPinned = (queryKey, entryKey) => retainedRows.has(queryKey) || entryKey !== void 0 && (retainedKeys.has(entryKey) || (getSlot().entries[entryKey]?.pending.length ?? 0) > 0 || loadingMore.has(entryKey));
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));
554
560
  /** The oldest unpinned rows past the cap (`keys` is oldest first). */
555
561
  const pickEvictions = (scope, keys, touched) => {
556
562
  const going = evicting.get(scope);
@@ -621,9 +627,8 @@ const createPersistedRows = ({ commit, getSlot, isRemoved, loadingMore, resource
621
627
  if (picked.length > 0) evictRows(scope, picked);
622
628
  });
623
629
  };
624
- /** Record a persisted row in the index (queued after the row write). */
630
+ /** Record a persisted row in the index (queued after the row write; see `writeRow`). */
625
631
  const trackStorageKey = (scope, queryKey) => {
626
- if (!writeQueue) return;
627
632
  noteWrite(scope, queryKey);
628
633
  if (maxEntries !== void 0) return touchStorageKey(scope, queryKey);
629
634
  const known = indexed.get(scope) ?? /* @__PURE__ */ new Set();
@@ -813,7 +818,7 @@ const createPersistedRows = ({ commit, getSlot, isRemoved, loadingMore, resource
813
818
  writeRow
814
819
  };
815
820
  };
816
- // src/core/entityPropagation.ts
821
+ // src/core/entity/propagation.ts
817
822
  /**
818
823
  * Entity propagation: a change to one entity (e.g. a topic) applied to every
819
824
  * entry that holds it — in memory, in persisted rows memory does not hold,
@@ -982,7 +987,7 @@ const createEntityPropagation = ({ beginOptimistic, entity, entityGuards, getSlo
982
987
  withoutRemovedEntities
983
988
  };
984
989
  };
985
- // src/core/reducer.ts
990
+ // src/core/state/reducer.ts
986
991
  const createReplicaState = () => ({ entries: {} });
987
992
  const materialize = (base, pending) => base === void 0 ? void 0 : pending.reduce((data, mutation) => mutation.apply(data), base);
988
993
  const noop = (state) => ({
@@ -1197,7 +1202,124 @@ const replicaReducer = (state, action, readView, now = Date.now()) => {
1197
1202
  };
1198
1203
  }
1199
1204
  };
1200
- // src/core/tombstones.ts
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
1201
1323
  /**
1202
1324
  * How many removal tombstones one engine remembers. The guards exist to win a
1203
1325
  * race with a hydrate that started around a removal, so a modest window is
@@ -1249,7 +1371,7 @@ const createTombstones = (limit = DEFAULT_REMOVAL_TOMBSTONE_LIMIT) => {
1249
1371
  size: () => tracked
1250
1372
  };
1251
1373
  };
1252
- // src/core/removalGuards.ts
1374
+ // src/core/engine/removalGuards.ts
1253
1375
  /**
1254
1376
  * Removal guard of one engine.
1255
1377
  *
@@ -1377,7 +1499,87 @@ const createEntityRemovalGuards = () => {
1377
1499
  startHydrate
1378
1500
  };
1379
1501
  };
1380
- // src/core/engine.ts
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
1381
1583
  /**
1382
1584
  * The replica engine: every transition of one resource, independent of any
1383
1585
  * UI framework or state library.
@@ -1391,8 +1593,6 @@ const createEntityRemovalGuards = () => {
1391
1593
  const createReplicaEngine = (resource, options) => {
1392
1594
  const { port } = options;
1393
1595
  const paging = resource.paging;
1394
- const at = (key) => pagingFor(paging, key);
1395
- const pagingCtx = { isClientOnly: options.isClientOnly };
1396
1596
  const prefix = options.actionPrefix ?? resource.name;
1397
1597
  const writeQueue = resource.storage ? new ReplicaWriteQueue(resource.storage) : void 0;
1398
1598
  const fetcher = options.fetcher ?? resource.fetcher;
@@ -1401,20 +1601,10 @@ const createReplicaEngine = (resource, options) => {
1401
1601
  const loadingMore = /* @__PURE__ */ new Set();
1402
1602
  const { clearRemoved, isPurged, isRemoved, markPurged, markRemoved, removalGeneration } = createRemovalGuards();
1403
1603
  const entityGuards = createEntityRemovalGuards();
1404
- /** Bumped by every `clear()` of a scope; reads started before it are stale. */
1405
- const clearGenerations = /* @__PURE__ */ new Map();
1406
- const clearGeneration = (scope) => clearGenerations.get(scope) ?? 0;
1407
- /**
1408
- * The persisted-row purge of the last `clear()` per scope: `true` once every
1409
- * row delete landed. A hydrate waits for it, and refuses to read while the
1410
- * purge failed (an untrusted scope, a storage error) so a wiped resource can
1411
- * never paint a row that survived it.
1412
- */
1413
- const scopePurges = /* @__PURE__ */ new Map();
1414
1604
  const getSlot = port.getState;
1415
1605
  const storageKey = (key, query) => ({ queryKey: replicaStorageKey(key, query) });
1416
1606
  const toPersisted = (data) => {
1417
- const paged = paging ? toPersistedPage(data, paging, pagingCtx) : data;
1607
+ const paged = pagination.toPersisted(data);
1418
1608
  return options.toPersisted ? options.toPersisted(paged) : paged;
1419
1609
  };
1420
1610
  const rows = createPersistedRows({
@@ -1428,7 +1618,14 @@ const createReplicaEngine = (resource, options) => {
1428
1618
  resource,
1429
1619
  writeQueue
1430
1620
  });
1431
- const { deleteRows, readIndex, removeStoredVariants, retain, touchStorageKey } = rows;
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
+ });
1432
1629
  const runEffects = (effects) => {
1433
1630
  if (!writeQueue || effects.length === 0) return;
1434
1631
  if (!resource.scope.canPersist()) return;
@@ -1522,26 +1719,6 @@ const createReplicaEngine = (resource, options) => {
1522
1719
  const entry = getSlot().entries[key];
1523
1720
  return entry?.pending.length ? entry.base : port.read(key);
1524
1721
  };
1525
- /**
1526
- * An overwrite hydrate repaints the entry from another query's persisted page.
1527
- * Client-only rows (an in-flight upload / create) have no server row yet, so
1528
- * that page cannot contain them — keep the ones still in the view, so a
1529
- * same-pool query change (a re-sort, a view-mode switch) never drops them.
1530
- * A cross-pool navigate is not "client-only" for the new params and is dropped.
1531
- */
1532
- const keepClientOnlyRows = (incoming, current, key) => {
1533
- if (!paging || !options.isClientOnly || !current) return incoming;
1534
- const page = incoming;
1535
- const currentItems = current.items ?? [];
1536
- const incomingIds = new Set(page.items.map((item) => paging.getId(item)));
1537
- const survivors = currentItems.filter((item) => options.isClientOnly(item) && !incomingIds.has(paging.getId(item)));
1538
- if (survivors.length === 0) return incoming;
1539
- const items = joinPages(survivors, page.items, at(key).direction);
1540
- return {
1541
- ...incoming,
1542
- items
1543
- };
1544
- };
1545
1722
  const hydrate = async (params, scope = resource.scope.get(), { overwrite = false } = {}) => {
1546
1723
  if (!resource.storage) return false;
1547
1724
  if (resource.scope.canHydrate && !resource.scope.canHydrate()) return false;
@@ -1558,7 +1735,7 @@ const createReplicaEngine = (resource, options) => {
1558
1735
  const readAndHydrate = async (params, key, scope, overwrite) => {
1559
1736
  const storage = resource.storage;
1560
1737
  const generation = clearGeneration(scope);
1561
- const purge = scopePurges.get(scope);
1738
+ const purge = lastPurge(scope);
1562
1739
  if (purge && !await purge) return false;
1563
1740
  const query = resource.query(params);
1564
1741
  const { queryKey } = storageKey(key, query);
@@ -1573,7 +1750,7 @@ const createReplicaEngine = (resource, options) => {
1573
1750
  if (options.isHydratable && !options.isHydratable(cached.data, params)) return false;
1574
1751
  const stored = withoutRemovedEntities(scope, cached.data);
1575
1752
  if (stored === void 0) return false;
1576
- const data = overwrite ? keepClientOnlyRows(stored, port.read(key), key) : stored;
1753
+ const data = overwrite ? pagination.keepClientOnlyRows(stored, port.read(key), key) : stored;
1577
1754
  const applied = dispatch({
1578
1755
  data,
1579
1756
  key,
@@ -1587,31 +1764,8 @@ const createReplicaEngine = (resource, options) => {
1587
1764
  if (applied && resource.scope.canPersist()) touchStorageKey(scope, queryKey);
1588
1765
  return applied;
1589
1766
  };
1590
- const viewMatchesFields = (current, params) => {
1591
- const fields = options.viewFields?.(params);
1592
- if (!current || !fields) return true;
1593
- const norm = (value) => stableQueryKey(value || null);
1594
- return Object.entries(fields).every(([field, value]) => norm(value) === norm(current[field]));
1595
- };
1596
1767
  /** Non-paged: whether a (prepared) head response is a terminal absence (`isMissing`). */
1597
1768
  const isMissingHead = (incoming) => !paging && !!options.isMissing?.(incoming);
1598
- const mergeHead = (key, incoming, confirmed, params, reset) => {
1599
- const page = incoming;
1600
- const pageSize = params.pageSize ?? page.items.length;
1601
- const merged = {
1602
- ...applyHeadPage(confirmed, page, {
1603
- pageSize,
1604
- reset
1605
- }, at(key), pagingCtx),
1606
- ...options.viewFields?.(params),
1607
- isLoadingMore: loadingMore.has(key)
1608
- };
1609
- const next = {
1610
- ...confirmed,
1611
- ...merged
1612
- };
1613
- return confirmed !== void 0 && isEqual(next, confirmed) ? void 0 : next;
1614
- };
1615
1769
  /**
1616
1770
  * Fold a head response in and report what it was: dropped by `prepareHead`,
1617
1771
  * a terminal absence (`isMissing`, applied as a removal) or a replacement.
@@ -1631,9 +1785,9 @@ const createReplicaEngine = (resource, options) => {
1631
1785
  };
1632
1786
  const query = resource.query(params);
1633
1787
  const entry = getSlot().entries[key];
1634
- const reset = entry ? entry.query !== query : paging !== void 0 && !viewMatchesFields(port.read(key), params);
1788
+ const reset = entry ? entry.query !== query : paging !== void 0 && !pagination.viewMatchesFields(port.read(key), params);
1635
1789
  const applied = dispatch({
1636
- data: (confirmed) => paging ? mergeHead(key, incoming, confirmed, params, reset) : options.merge ? options.merge(incoming, confirmed, params) : incoming,
1790
+ data: (confirmed) => paging ? pagination.mergeHead(key, incoming, confirmed, params, reset) : options.merge ? options.merge(incoming, confirmed, params) : incoming,
1637
1791
  key,
1638
1792
  params,
1639
1793
  query,
@@ -1747,60 +1901,6 @@ const createReplicaEngine = (resource, options) => {
1747
1901
  type: "persist"
1748
1902
  }]);
1749
1903
  };
1750
- /**
1751
- * Wipe the resource in the active scope: every entry leaves memory in one
1752
- * host commit, every persisted row (all keys, all query variants) and the
1753
- * index are deleted, and reads already in flight are dropped — a hydrate
1754
- * started before the clear never dispatches, and `useSync` discards head
1755
- * responses whose request started before it. Requests started afterwards
1756
- * apply normally. Resolves `true` once the persisted rows are gone.
1757
- */
1758
- const clear = () => {
1759
- const scope = resource.scope.get();
1760
- clearGenerations.set(scope, clearGeneration(scope) + 1);
1761
- port.commit([{ type: "clear" }], {
1762
- entries: {},
1763
- scope
1764
- }, `${prefix}/clear`);
1765
- rows.forgetScope(scope);
1766
- const purge = purgeScope(scope);
1767
- scopePurges.set(scope, purge);
1768
- return purge;
1769
- };
1770
- /**
1771
- * `clear()` for any scope, including one that is not active (a user who
1772
- * switched away while their data was being deleted): every persisted row of
1773
- * `scope` and its index go, and for the active scope memory is wiped too
1774
- * (exactly `clear()`). An inactive scope's memory is not on screen and is
1775
- * left alone. The row deletes go through the write queue, so they land after
1776
- * any write this session already queued for that scope — a row (or index
1777
- * entry) written just before is purged rather than landing after the purge.
1778
- * A later hydrate of `scope` waits for the purge. Resolves `true` once the
1779
- * persisted rows are gone.
1780
- */
1781
- const clearScope = (scope) => {
1782
- if (scope === resource.scope.get()) return clear();
1783
- clearGenerations.set(scope, clearGeneration(scope) + 1);
1784
- const purge = purgeScope(scope);
1785
- scopePurges.set(scope, purge);
1786
- return purge;
1787
- };
1788
- /** Delete every persisted row of `scope` (see `clear`). */
1789
- const purgeScope = async (scope) => {
1790
- if (!writeQueue) return true;
1791
- if (!resource.scope.canPersist()) return false;
1792
- const queued = deleteRows(scope, [...rows.indexedKeys(scope) ?? []]);
1793
- let persisted;
1794
- try {
1795
- persisted = await readIndex(scope);
1796
- } catch {
1797
- return false;
1798
- }
1799
- const known = rows.indexedKeys(scope);
1800
- const stale = persisted.filter((queryKey) => !known?.has(queryKey));
1801
- const [first, rest] = await Promise.all([queued, deleteRows(scope, stale)]);
1802
- return first && rest;
1803
- };
1804
1904
  const revalidate = (key) => options.revalidate ? options.revalidate(key) : Promise.resolve();
1805
1905
  /**
1806
1906
  * Start an optimistic overlay now and settle it later (multi-resource flows).
@@ -1857,56 +1957,16 @@ const createReplicaEngine = (resource, options) => {
1857
1957
  if (mutationOptions.revalidate) revalidate(key);
1858
1958
  }
1859
1959
  };
1860
- /**
1861
- * Fetch and merge the next page with the params of the loaded head page
1862
- * (`fallbackParams` covers views seeded outside `useSync`). In `cursor` mode
1863
- * paging only starts from a server-confirmed head page: a hydrated cursor
1864
- * may be stale. A result is dropped when the scope, the query or the loaded
1865
- * depth changed while it was in flight.
1866
- */
1867
- const loadMore = async (key, fallbackParams) => {
1868
- if (!paging || !fetcher) return;
1869
- const entry = getSlot().entries[key];
1870
- const current = port.read(key);
1871
- if (!current || loadingMore.has(key)) return;
1872
- if (paging.mode === "cursor" && entry?.source === "storage") return;
1873
- const params = entry?.params ?? fallbackParams;
1874
- if (params === void 0) return;
1875
- const cursor = getNextPageCursor(current, at(key));
1876
- if (cursor === null || cursor === void 0) return;
1877
- const query = entry?.query;
1878
- const scope = resource.scope.get();
1879
- const depth = current.currentPage;
1880
- const setLoading = (patch) => update(key, (data) => data && {
1881
- ...data,
1882
- ...patch
1883
- }, { persist: false });
1884
- loadingMore.add(key);
1885
- setLoading({
1886
- isLoadingMore: true,
1887
- loadMoreError: void 0
1888
- });
1889
- const isCurrent = () => {
1890
- const latest = port.read(key);
1891
- return resource.scope.get() === scope && getSlot().entries[key]?.query === query && latest?.currentPage === depth;
1892
- };
1893
- try {
1894
- const page = await fetcher(params, cursor);
1895
- if (!isCurrent()) return void setLoading({ isLoadingMore: false });
1896
- update(key, (data) => data && applyNextPage(data, page, at(key), pagingCtx), { persist: (paging.persist?.pages ?? 1) > 1 });
1897
- } catch (error) {
1898
- setLoading({
1899
- isLoadingMore: false,
1900
- loadMoreError: isCurrent() ? error : void 0
1901
- });
1902
- } finally {
1903
- loadingMore.delete(key);
1904
- }
1905
- };
1906
- /** Insert rows at the head (new / streamed items). */
1907
- const insertHead = (key, items, { persist = false } = {}) => paging ? update(key, (data) => data && insertHeadItems(data, items, at(key)), { persist }) : false;
1908
- /** Drop loaded pages, keeping the head (e.g. after an edit inside older pages). */
1909
- const collapse = (key) => paging ? update(key, (data) => data && collapseToHead(data, at(key)), { persist: false }) : false;
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;
1910
1970
  const { beginEntityOptimistic, entityKeys, forgetRestoredEntities, patchStoredEntity, removeEntity, updateEntity, withoutRemovedEntities } = createEntityPropagation({
1911
1971
  beginOptimistic,
1912
1972
  entity: options.entity,
@@ -1953,34 +2013,6 @@ const createReplicaEngine = (resource, options) => {
1953
2013
  updateMany
1954
2014
  };
1955
2015
  };
1956
- // src/core/keys.ts
1957
- /**
1958
- * Keys of the queries a fetch adapter runs for a replica. Hosts that sweep
1959
- * their query cache by prefix (e.g. "refetch everything after a restore")
1960
- * match on {@link REPLICA_KEY_PREFIX}.
1961
- */
1962
- const REPLICA_KEY_PREFIX = "replica:";
1963
- const replicaKeys = {
1964
- /** One-shot read of the persisted row for `storageKey`. */
1965
- hydrate: (name, version, scope, storageKey) => [
1966
- "replica:hydrate",
1967
- name,
1968
- version,
1969
- scope,
1970
- storageKey
1971
- ],
1972
- /** Network sync of the head of one entry. */
1973
- sync: (name, version, scope, key, params) => [
1974
- "replica:sync",
1975
- name,
1976
- version,
1977
- scope,
1978
- key,
1979
- params
1980
- ]
1981
- };
1982
- /** Match a resource's sync keys, optionally narrowed to one scope and/or entry key. */
1983
- 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);
1984
- export { isReplicaStorageKeyOf as C, staticReplicaScope as E, defineReplica as S, stableQueryKey as T, joinPages as _, DEFAULT_REMOVAL_TOMBSTONE_LIMIT as a, toPersistedPage as b, REPLICA_INDEX_KEY as c, applyHeadPage as d, applyNextPage as f, insertHeadItems as g, hasPagedItem as h, createReplicaEngine as i, ReplicaWriteQueue as l, getNextPageCursor as m, isReplicaSyncKey as n, createReplicaState as o, collapseToHead as p, replicaKeys as r, replicaReducer as s, REPLICA_KEY_PREFIX as t, readReplicaRowStrict as u, mapPagedItem as v, replicaStorageKey as w, definePagedReplica as x, pagingFor as y };
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 };
1985
2017
 
1986
- //# sourceMappingURL=keys-BTsqwfEa.js.map
2018
+ //# sourceMappingURL=createReplicaEngine-CdR-URHo.js.map