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,856 @@
1
+ /** Address of one persisted row: the resource's row key inside an identity scope. */
2
+ interface ReplicaRowKey {
3
+ queryKey: string;
4
+ scope: string;
5
+ }
6
+ interface ReplicaRow<T> {
7
+ data: T;
8
+ updatedAt: number;
9
+ }
10
+ /**
11
+ * Where a replica keeps its rows between sessions (IndexedDB, localStorage …).
12
+ * The server stays the durable source of truth, so `get` is best-effort: it
13
+ * swallows its own I/O failures and reports a miss. Writes are not: `set` and
14
+ * `remove` must reject when they did not land (see each method), so the
15
+ * engine can retry them instead of treating a dropped row as persisted.
16
+ */
17
+ interface ReplicaStorage<T> {
18
+ get: (key: ReplicaRowKey) => Promise<ReplicaRow<T> | undefined>;
19
+ /**
20
+ * Strict read for destructive passes: like `get`, but **rejects** when the
21
+ * row could not be read (I/O error, unparsable value) instead of reporting a
22
+ * miss. The engine reads its per-scope index (and every row it patches)
23
+ * through it before `remove` / `clear` / an entity patch / an eviction, so an
24
+ * unreadable index fails the pass rather than passing for an empty one.
25
+ *
26
+ * Optional only for compatibility: a storage without it still hydrates and
27
+ * persists, but every destructive pass **fails** (`clear()` resolves `false`,
28
+ * a removal stays unpurged and is retried, an entity guard is kept) because
29
+ * the engine cannot tell a miss from a failed read through `get`.
30
+ */
31
+ getStrict?: (key: ReplicaRowKey) => Promise<ReplicaRow<T> | undefined>;
32
+ /**
33
+ * Delete a row. Must **reject** when the delete did not land (a closed
34
+ * IndexedDB, a quota or security error): the engine keeps the row indexed
35
+ * and the removal unpurged, so the next `remove` / `clear` retries it instead
36
+ * of letting a row the user deleted hydrate again. A missing row is a success.
37
+ */
38
+ remove: (key: ReplicaRowKey) => Promise<void>;
39
+ /**
40
+ * Write a row. Should **reject** when the write did not land: the engine only
41
+ * treats a row as holding a value after `set` resolved, so a rejected write is
42
+ * retried by the next persist rather than skipped as redundant. A storage that
43
+ * swallows failures still works, but a dropped write then survives until the
44
+ * value changes or the next session.
45
+ */
46
+ set: (key: ReplicaRowKey, row: ReplicaRow<T>) => Promise<void>;
47
+ }
48
+ type ReplicaPagingMode = 'cursor' | 'offset';
49
+ type ReplicaPagingDirection = 'backward' | 'forward';
50
+ interface ReplicaPagingConfig<TItem, TCursor = any> {
51
+ /**
52
+ * Cursor mode: cursor to the page past `item`, the last cursor-eligible row
53
+ * on the far side, for a server that pages by row but reports no
54
+ * `nextCursor` (a plain list). Only safe when the row carries a lossless
55
+ * position; omit it when the server owns the cursor, and paging then waits
56
+ * for one.
57
+ */
58
+ deriveCursor?: (item: TItem) => TCursor;
59
+ direction: ReplicaPagingDirection;
60
+ /**
61
+ * Per-entry direction, for a resource whose entries read differently (a
62
+ * transcript read from its first round). Defaults to `direction`. Persisting
63
+ * an entry always uses `direction`, so keep such entries in memory.
64
+ */
65
+ directionOf?: (key: string) => ReplicaPagingDirection;
66
+ getId: (item: TItem) => string;
67
+ /**
68
+ * Rows that may anchor a join or derive a cursor. Synthetic rows the query
69
+ * injects (group / summary nodes) have no position of their own on the
70
+ * server, so they are skipped. Defaults to every row.
71
+ */
72
+ isCursorable?: (item: TItem) => boolean;
73
+ mode: ReplicaPagingMode;
74
+ /** Which part survives a reload. Defaults to the head page only. */
75
+ persist?: {
76
+ /** Hard cap on persisted items (taken from the head side). */
77
+ maxItems?: number;
78
+ /** Number of pages from the head side. Default 1. */
79
+ pages?: number;
80
+ };
81
+ /**
82
+ * Optional total order re-applied after every merge (e.g. messages by
83
+ * createdAt). The sort is stable: rows the comparator ties keep the order
84
+ * the server sent them in, so compare positions only — an id tie-break
85
+ * would reorder rows created within the same tick.
86
+ */
87
+ sort?: (a: TItem, b: TItem) => number;
88
+ }
89
+ interface ReplicaPageResult<TItem, TCursor> {
90
+ items: TItem[];
91
+ /**
92
+ * Cursor mode: next page cursor; `null` = exhausted, `undefined` = unknown.
93
+ * Offset mode: the server's own verdict — `null` = exhausted, any other
94
+ * value = more pages (the engine still advances by page index); `undefined`
95
+ * falls back to `total` / a full page. A server that filters rows after
96
+ * paging returns short pages that are not the end, so it must report this.
97
+ */
98
+ nextCursor?: TCursor | null;
99
+ total?: number;
100
+ }
101
+ interface ReplicaPageInfo<TCursor> {
102
+ /** Items this page contributed after de-duplication. */
103
+ count: number;
104
+ /** Cursor of the page after this one; `null` = exhausted, `undefined` = unknown. */
105
+ next?: TCursor | null;
106
+ }
107
+ /**
108
+ * The paged view a resource keeps in the store. Domain views extend it, so
109
+ * selectors read `items` / `hasMore` / `isLoadingMore` as before.
110
+ */
111
+ interface ReplicaPagedData<TItem, TCursor = number> {
112
+ /**
113
+ * Boundary row of the head page once more pages are loaded (forward: its
114
+ * last row, backward: its first row). A refreshed head page that no longer
115
+ * contains it has slid past the loaded pages — merging would hide a gap.
116
+ */
117
+ anchorId?: string;
118
+ /** Index of the last merged page (0 = head only). */
119
+ currentPage: number;
120
+ hasMore: boolean;
121
+ /**
122
+ * Synthetic (non-cursorable) rows of the head page. A sort can place them
123
+ * among older pages, so a head refresh drops them by id rather than by
124
+ * position — the fresh head brings its own.
125
+ */
126
+ headSyntheticIds?: string[];
127
+ isLoadingMore?: boolean;
128
+ items: TItem[];
129
+ loadMoreError?: unknown;
130
+ /** Cursor for the next page (offset: page index). `null` = exhausted. */
131
+ nextCursor?: TCursor | null;
132
+ /** Per-page bookkeeping, head first. Missing on legacy data = one page. */
133
+ pages?: ReplicaPageInfo<TCursor>[];
134
+ pageSize: number;
135
+ total?: number;
136
+ }
137
+ interface ReplicaPagingContext<TItem> {
138
+ /**
139
+ * Client-only rows (optimistic inserts whose server row may not exist yet).
140
+ * They survive head refreshes and are never persisted.
141
+ */
142
+ isClientOnly?: (item: TItem) => boolean;
143
+ }
144
+ /**
145
+ * Identity partition of the persisted projection (user + workspace by default).
146
+ * `use` feeds the sync hook, `get` imperative actions, `canPersist` gates writes
147
+ * while the scope is still an optimistic guess (identity not resolved yet).
148
+ */
149
+ interface ReplicaScope {
150
+ /**
151
+ * Whether this runtime may read the persisted projection back into memory.
152
+ * Defaults to `true`. A surface that never resolves an identity (e.g. a
153
+ * public embed that does not mount the user store) opts out: its scope is
154
+ * only the last-known guess, so hydrating it could paint a different — or a
155
+ * since-invalidated — session's private rows before authorization is checked.
156
+ */
157
+ canHydrate?: () => boolean;
158
+ canPersist: () => boolean;
159
+ get: () => string;
160
+ use: () => string;
161
+ }
162
+ interface ReplicaResource<TParams, TData, TFetched = TData, TCursor = any> {
163
+ /** Paged resources receive the page cursor (`undefined` = head page). */
164
+ fetcher?: (params: TParams, cursor?: TCursor) => Promise<TFetched>;
165
+ key: (params: TParams) => string;
166
+ /** Cap of persisted rows per scope (LRU eviction, see `DefineReplicaOptions.persist`). */
167
+ maxPersistedEntries?: number;
168
+ name: string;
169
+ /** Storage namespace — `name` + `version`, so a version bump orphans old rows. */
170
+ namespace: string;
171
+ /** Present on paged resources (`definePagedReplica`). */
172
+ paging?: ReplicaPagingConfig<any, TCursor>;
173
+ /** Whether the resource survives a reload (it has a storage). */
174
+ persisted: boolean;
175
+ /** Whether an entry key may be hydrated and persisted (see `persistKey`). */
176
+ persistKey: (key: string) => boolean;
177
+ /**
178
+ * Query identity beyond `key` (filters, page size). Persisted rows are
179
+ * stored per query, so a different query never hydrates; in memory a query
180
+ * change resets loaded pages.
181
+ */
182
+ query: (params: TParams) => string | undefined;
183
+ scope: ReplicaScope;
184
+ storage?: ReplicaStorage<TData>;
185
+ /** Row key in `storage` for these params (`key`, plus `?query` when set). */
186
+ storageKey: (params: TParams) => string;
187
+ /** Custom query-cache key of the network sync (see `DefineReplicaOptions.syncKey`). */
188
+ syncKey?: (params: TParams) => readonly unknown[];
189
+ version: number;
190
+ }
191
+ /** Builds the storage of one resource from its namespace (`replica:<name>:v<version>`). */
192
+ type ReplicaStorageFactory<TData> = (namespace: string) => ReplicaStorage<TData> | undefined;
193
+ interface DefineReplicaOptions<TParams, TData, TFetched = TData, TCursor = any> {
194
+ /** Default network fetcher; a store binding may override it. */
195
+ fetcher?: (params: TParams, cursor?: TCursor) => Promise<TFetched>;
196
+ /** Entry identity inside one scope (memory bucket + persisted row). */
197
+ key: (params: TParams) => string;
198
+ name: string;
199
+ paging?: ReplicaPagingConfig<any, TCursor>;
200
+ /**
201
+ * Bound what the resource keeps in storage. `maxEntries` caps the persisted
202
+ * rows (storage keys: one per entry key and query) per scope; past it the
203
+ * least recently used rows are evicted — their persisted row, index entry
204
+ * and, when nothing holds it, their memory entry. Use rows (a hydrate or a
205
+ * persist, including a skipped unchanged one) count as recent, and the order
206
+ * lives in the persisted index, so it survives reloads. A row on screen (a
207
+ * mounted `useSync`, see `retain`), with pending optimistic writes or a
208
+ * `loadMore` in flight is never evicted. Unbounded by default.
209
+ */
210
+ persist?: {
211
+ maxEntries?: number;
212
+ };
213
+ /**
214
+ * Entries that never touch storage (no hydrate, no persist) — e.g. scoped
215
+ * buckets whose rows must not land under an ordinary entry. Defaults to all.
216
+ */
217
+ persistKey?: (key: string) => boolean;
218
+ /**
219
+ * Query identity beyond `key` (filters, sort, page size) — anything that
220
+ * changes the rows without changing the key. Persisted rows are kept per
221
+ * query, so a projection taken under other filters never hydrates; in memory
222
+ * a query change resets loaded pages.
223
+ */
224
+ query?: (params: TParams) => unknown;
225
+ /** Identity partition of memory and persisted rows. Defaults to one static scope. */
226
+ scope?: ReplicaScope;
227
+ /** Persisted copy of the replica. Omit for a memory-only replica (no first-frame paint). */
228
+ storage?: ReplicaStorage<TData> | ReplicaStorageFactory<TData>;
229
+ /**
230
+ * Query-cache key of the network sync, for a resource adopting an existing
231
+ * key that other code already revalidates by. Defaults to `replicaKeys.sync`.
232
+ * With a custom key, revalidate through that key — `slice.revalidate` only
233
+ * matches default keys.
234
+ */
235
+ syncKey?: (params: TParams) => readonly unknown[];
236
+ /** Bump to invalidate every persisted row written by older shapes. */
237
+ version: number;
238
+ }
239
+ declare const defineReplica: <TParams, TData, TFetched = TData, TCursor = any>(options: DefineReplicaOptions<TParams, TData, TFetched, TCursor>) => ReplicaResource<TParams, TData, TFetched, TCursor>;
240
+ interface DefinePagedReplicaOptions<TParams, TItem, TCursor> extends Omit<DefineReplicaOptions<TParams, ReplicaPagedData<TItem, TCursor>, ReplicaPageResult<TItem, TCursor>>, 'fetcher' | 'paging' | 'storage'> {
241
+ /** `cursor` is `undefined` for the head page. */
242
+ fetchPage?: (params: TParams, cursor: TCursor | undefined) => Promise<ReplicaPageResult<TItem, TCursor>>;
243
+ paging: ReplicaPagingConfig<TItem, TCursor>;
244
+ storage?: ReplicaStorage<any> | ReplicaStorageFactory<any>;
245
+ }
246
+ /**
247
+ * A resource whose value is a paged list ({@link ReplicaPagedData}). The
248
+ * store view type may extend the paged shape with domain fields.
249
+ */
250
+ declare const definePagedReplica: <TParams, TItem, TCursor = number, TData extends ReplicaPagedData<TItem, TCursor> = ReplicaPagedData<TItem, TCursor>>(options: DefinePagedReplicaOptions<TParams, TItem, TCursor>) => ReplicaResource<TParams, TData, ReplicaPageResult<TItem, TCursor>, TCursor>;
251
+ /**
252
+ * Keys of the queries a fetch adapter runs for a replica. Hosts that sweep
253
+ * their query cache by prefix (e.g. "refetch everything after a restore")
254
+ * match on {@link REPLICA_KEY_PREFIX}.
255
+ */
256
+ declare const REPLICA_KEY_PREFIX = "replica:";
257
+ declare const replicaKeys: {
258
+ /** One-shot read of the persisted row for `storageKey`. */
259
+ hydrate: (name: string, version: number, scope: string, storageKey: string) => readonly ['replica:hydrate', string, number, string, string];
260
+ /** Network sync of the head of one entry. */
261
+ sync: (name: string, version: number, scope: string, key: string, params: unknown) => readonly ['replica:sync', string, number, string, string, unknown];
262
+ };
263
+ /** Match a resource's sync keys, optionally narrowed to one scope and/or entry key. */
264
+ declare const isReplicaSyncKey: (queryKey: unknown, name: string, filter?: {
265
+ key?: string;
266
+ scope?: string;
267
+ }) => boolean;
268
+ /**
269
+ * A fixed identity partition. The default for apps without accounts; an app
270
+ * with sign-in passes a scope derived from the signed-in user / workspace so
271
+ * one browser profile never mixes two identities' rows.
272
+ */
273
+ declare const staticReplicaScope: (scope?: string) => ReplicaScope;
274
+ /** Persisted row key: one row per entry key and query. */
275
+ declare const replicaStorageKey: (key: string, query?: string) => string;
276
+ /**
277
+ * Whether `storageKey` is a persisted row of entry `key`, under any query.
278
+ *
279
+ * Entry keys are free-form, so a `?` prefix alone cannot tell `foo` + a query
280
+ * from a plain entry `foo?bar`. A row only counts as a variant when it is
281
+ * exactly `replicaStorageKey(key, query)` for a query this module could have
282
+ * written: the rest after `key?` must be a canonical `stableQueryKey` (it
283
+ * parses as JSON and serializes back to itself). Row keys and the index keep
284
+ * their existing flat format, so rows already persisted on devices match the
285
+ * same way.
286
+ */
287
+ declare const isReplicaStorageKeyOf: (storageKey: string, key: string) => boolean;
288
+ /** Deterministic JSON: sorted keys, `undefined` dropped. */
289
+ declare const stableQueryKey: (value: unknown) => string;
290
+ /**
291
+ * Where the confirmed value of an entry came from.
292
+ * - `storage`: hydrated from the persisted projection (may be stale)
293
+ * - `server`: confirmed by a network response
294
+ * - `local`: written locally before any hydrate/replace landed
295
+ * - `seed`: a provisional local row (e.g. a list row standing in for a detail so
296
+ * an object stays resolvable). Unlike `local` it is *not* authoritative: a
297
+ * persisted `storage` value may replace it on hydrate, and it never persists —
298
+ * so it cannot overwrite the detail it is only standing in for.
299
+ */
300
+ type ReplicaSource = 'local' | 'seed' | 'server' | 'storage';
301
+ interface ReplicaPendingMutation<T> {
302
+ apply: (data: T) => T;
303
+ id: number;
304
+ /**
305
+ * Query the overlay was started under. An entry key can be reused for another
306
+ * query (a navigation), and an overlay only ever describes the query that
307
+ * created it — carrying it over would apply (and later persist) one query's
308
+ * row inside another.
309
+ */
310
+ query?: string;
311
+ }
312
+ interface ReplicaEntryMeta<T> {
313
+ /**
314
+ * Confirmed snapshot. Only kept while optimistic mutations are in flight —
315
+ * otherwise the view itself is the confirmed value.
316
+ */
317
+ base?: T;
318
+ /** Params of the last hydrate/replace — what `loadMore` pages with. */
319
+ params?: unknown;
320
+ pending: ReplicaPendingMutation<T>[];
321
+ /** Stable query identity beyond the key (filters, page size). */
322
+ query?: string;
323
+ /**
324
+ * Every write made while the entry is still a `seed`, in order — the seed
325
+ * writes themselves and the confirmed writes made over them. A hydrate that
326
+ * replaces the seed replays them onto the stored value: the seed's fresher
327
+ * fields (a list row's renamed title) and a confirmed edit made before
328
+ * storage answered both survive, instead of storage painting older values.
329
+ */
330
+ seedPatches?: ReplicaSeedPatch<T>[];
331
+ source: ReplicaSource;
332
+ updatedAt: number;
333
+ }
334
+ interface ReplicaSeedPatch<T> {
335
+ /**
336
+ * Replayed onto the stored value, so a seed write must merge the fields it
337
+ * owns into its input rather than ignore it (its first run sees `undefined`).
338
+ */
339
+ apply: (data: T | undefined) => T | undefined;
340
+ persist: boolean;
341
+ /** A provisional seed write: replayed, but never makes the result confirmed. */
342
+ seed?: boolean;
343
+ /**
344
+ * Source of a seed write. A later seed of the same tag supersedes this one
345
+ * (since the last confirmed write), so it must carry every field this one set.
346
+ */
347
+ tag?: string;
348
+ }
349
+ /** Bookkeeping slot a replica keeps inside its domain store. */
350
+ interface ReplicaState<T> {
351
+ entries: Record<string, ReplicaEntryMeta<T>>;
352
+ /** The scope every entry in memory belongs to. */
353
+ scope?: string;
354
+ }
355
+ /**
356
+ * Pure transition core of a replica. It never touches the store
357
+ * or storage: it returns the next bookkeeping slot, the view writes the store
358
+ * binding must apply, and the persistence effects it must run.
359
+ *
360
+ * The VIEW (what selectors read) lives in the domain store, wherever the
361
+ * binding's lens puts it. The reducer only reads it through `readView`, so a
362
+ * domain can keep its existing state shape (e.g. `topicDataMap`).
363
+ */
364
+ type ReplicaAction<T> = {
365
+ data: T;
366
+ key: string;
367
+ /**
368
+ * Repaint a slot that answers a *different* query from the persisted page
369
+ * (a navigation). Only the caller that knows the entry has moved on
370
+ * (the requested query changed) may ask for it; by default a hydrate only
371
+ * fills an empty slot.
372
+ */
373
+ overwrite?: boolean;
374
+ params?: unknown;
375
+ query?: string;
376
+ scope: string;
377
+ type: 'hydrate';
378
+ updatedAt?: number;
379
+ } | {
380
+ /** Receives the confirmed value (base or view); `undefined` keeps it. */
381
+ data: (confirmed: T | undefined) => T | undefined;
382
+ key: string;
383
+ params?: unknown;
384
+ query?: string;
385
+ scope: string;
386
+ type: 'replace';
387
+ } | {
388
+ apply: (data: T | undefined) => T | undefined;
389
+ key: string;
390
+ persist: boolean;
391
+ scope: string;
392
+ /** Marks a provisional write (`'seed'`); defaults to the entry's own source. */
393
+ source?: ReplicaSource;
394
+ /**
395
+ * Seed writes of one source (a list refresh, a session refresh). A later
396
+ * seed write supersedes an earlier one of the same tag since the last
397
+ * confirmed write, so a remembered seed history stays bounded.
398
+ */
399
+ seedTag?: string;
400
+ type: 'update';
401
+ } | {
402
+ apply: (data: T) => T;
403
+ id: number;
404
+ key: string;
405
+ /** Query the overlay is started under (see `ReplicaPendingMutation`). */
406
+ query?: string;
407
+ scope: string;
408
+ type: 'optimistic';
409
+ } | {
410
+ confirm?: (data: T) => T;
411
+ id: number;
412
+ key: string;
413
+ scope: string;
414
+ type: 'commit';
415
+ } | {
416
+ id: number;
417
+ key: string;
418
+ scope: string;
419
+ type: 'rollback';
420
+ } | {
421
+ key: string;
422
+ scope: string;
423
+ type: 'remove';
424
+ } | {
425
+ scope: string;
426
+ type: 'resetScope';
427
+ };
428
+ type ReplicaEffect<T> = {
429
+ data: T;
430
+ key: string;
431
+ query?: string;
432
+ scope: string;
433
+ type: 'persist';
434
+ } | {
435
+ key: string;
436
+ query?: string;
437
+ scope: string;
438
+ type: 'remove';
439
+ };
440
+ type ReplicaViewWrite<T> = {
441
+ data: T | undefined;
442
+ key: string;
443
+ } | {
444
+ type: 'clear';
445
+ };
446
+ interface ReplicaTransition<T> {
447
+ effects: ReplicaEffect<T>[];
448
+ state: ReplicaState<T>;
449
+ writes: ReplicaViewWrite<T>[];
450
+ }
451
+ declare const createReplicaState: <T>() => ReplicaState<T>;
452
+ declare const replicaReducer: <T>(state: ReplicaState<T>, action: ReplicaAction<T>, readView: (key: string) => T | undefined, now?: number) => ReplicaTransition<T>;
453
+ /**
454
+ * How entities (e.g. topics) appear inside a non-paged resource value. Paged
455
+ * resources get this for free from `paging.getId`. Build one with
456
+ * `singleEntity` (a detail value), `arrayEntity` (a plain list) or by hand for
457
+ * nested shapes (grouped lists).
458
+ */
459
+ interface ReplicaEntityAdapter<TData, TItem> {
460
+ /** Whether `data` holds the entity. */
461
+ has: (data: TData, id: string) => boolean;
462
+ /**
463
+ * Map the entity inside `data` (`fn` returning `undefined` deletes it).
464
+ * Return `data` itself when nothing changed, or `undefined` when the whole
465
+ * value goes away with the entity (a detail value).
466
+ */
467
+ map: (data: TData, id: string, fn: (item: TItem) => TItem | undefined) => TData | undefined;
468
+ }
469
+ /**
470
+ * The engine's view of the host store. The engine never owns the rendered
471
+ * value — the host does (a Zustand slice, a signal, a plain object) — it only
472
+ * reads entries through `read` and hands every transition to `commit`.
473
+ */
474
+ interface ReplicaStorePort<TData> {
475
+ /**
476
+ * Apply the view writes and the next bookkeeping slot as ONE host update, so
477
+ * subscribers never observe a view out of step with its bookkeeping.
478
+ */
479
+ commit: (writes: ReplicaViewWrite<TData>[], state: ReplicaState<TData>, label: string) => void;
480
+ /** Current bookkeeping slot. */
481
+ getState: () => ReplicaState<TData>;
482
+ /** Enumerate loaded keys (entity propagation). Defaults to the bookkeeping entries. */
483
+ keys?: () => string[];
484
+ /** Materialized value of one entry, as the UI sees it. */
485
+ read: (key: string) => TData | undefined;
486
+ }
487
+ interface ReplicaEngineOptions<TParams, TData, TFetched> {
488
+ /** Label prefix of host updates (devtools action names). Defaults to the resource name. */
489
+ actionPrefix?: string;
490
+ /** Non-paged resources: how entities sit in the value (see `linkReplicaEntity`). */
491
+ entity?: ReplicaEntityAdapter<TData, any>;
492
+ /** Overrides `resource.fetcher` when the fetch needs store context. */
493
+ fetcher?: (params: TParams, cursor?: any) => Promise<TFetched>;
494
+ /** Paged: rows that only exist client-side (kept across refreshes, never persisted). */
495
+ isClientOnly?: (item: any) => boolean;
496
+ /** Reject a persisted value that cannot serve these params. Rarely needed: rows are stored per query. */
497
+ isHydratable?: (cached: TData, params: TParams) => boolean;
498
+ /**
499
+ * Non-paged: whether a head response means the entry no longer exists. Such a
500
+ * response is applied as an explicit removal (memory + persisted row) instead
501
+ * of through `merge` — otherwise a `null` would fall back to the confirmed
502
+ * value (the reducer resolves `action.data(confirmed) ?? confirmed`), so a
503
+ * deleted aggregate would be retained and re-persisted.
504
+ */
505
+ isMissing?: (incoming: TFetched) => boolean;
506
+ /**
507
+ * Non-paged: fold a server response into the confirmed value. Return
508
+ * `undefined` to keep the current value. Defaults to "the response is the value".
509
+ */
510
+ merge?: (incoming: TFetched, confirmed: TData | undefined, params: TParams) => TData | undefined;
511
+ /** Where the engine reads and commits the view. */
512
+ port: ReplicaStorePort<TData>;
513
+ /**
514
+ * Reconcile a server response with the value currently shown before it is
515
+ * folded in — e.g. keep a row the client knows is newer, or ignore a
516
+ * response that raced a newer local state. Return `undefined` to drop the
517
+ * response entirely.
518
+ */
519
+ prepareHead?: (incoming: TFetched, current: TData | undefined, params: TParams) => TFetched | undefined;
520
+ /** Re-run the network sync of one entry (or all); wired by the fetch adapter. */
521
+ revalidate?: (key?: string) => Promise<unknown>;
522
+ /**
523
+ * Strip transient / client-only parts before persisting.
524
+ *
525
+ * - a value → persisted
526
+ * - `undefined` → skip this write (keep whatever is already stored)
527
+ * - `null` → the entry is a server-confirmed absence: drop any prior
528
+ * persisted projection, so a later hydrate cannot paint a value the server
529
+ * no longer has (the view keeps the confirmed value).
530
+ */
531
+ toPersisted?: (data: TData) => TData | null | undefined;
532
+ /** Paged: domain fields derived from params, written with every head page. */
533
+ viewFields?: (params: TParams) => Partial<TData>;
534
+ }
535
+ /** What a head response did once folded in (see `applyHead`). */
536
+ type ReplicaHeadOutcome = 'dropped' | 'missing' | 'replaced';
537
+ interface OptimisticMutationOptions<TData, TResult> {
538
+ /** Turn the server result into the confirmed value; defaults to re-applying `apply`. */
539
+ confirm?: (result: TResult) => (data: TData) => TData;
540
+ /** Revalidate the entry after the server call settles. */
541
+ revalidate?: boolean;
542
+ }
543
+ /** Handle of an optimistic overlay that is settled later (see `beginOptimistic`). */
544
+ interface ReplicaOptimisticToken<TData> {
545
+ commit: (confirm?: (data: TData) => TData) => void;
546
+ rollback: () => void;
547
+ }
548
+ /**
549
+ * The replica engine: every transition of one resource, independent of any
550
+ * UI framework or state library.
551
+ *
552
+ * It owns hydrate-if-empty, server replace, pagination, optimistic overlays
553
+ * with commit/rollback, entity propagation, scope isolation and serialized
554
+ * persistence. The host store keeps the rendered value (see
555
+ * {@link ReplicaStorePort}); fetch scheduling (when to sync, dedupe, focus
556
+ * revalidation) belongs to an adapter such as `local-replica/zustand`.
557
+ */
558
+ declare const createReplicaEngine: <TParams, TData, TFetched = TData>(resource: ReplicaResource<TParams, TData, TFetched>, options: ReplicaEngineOptions<TParams, TData, TFetched>) => {
559
+ applyHead: (params: TParams, fetched: TFetched, scope?: string) => {
560
+ changed: boolean;
561
+ outcome: ReplicaHeadOutcome;
562
+ };
563
+ beginEntityOptimistic: <TItem>(id: string, fn: (item: TItem) => TItem | undefined) => ReplicaOptimisticToken<TData>[];
564
+ beginOptimistic: (key: string, apply: (data: TData) => TData, query?: string) => ReplicaOptimisticToken<TData>;
565
+ clear: () => Promise<boolean>;
566
+ clearGeneration: (scope: string) => number;
567
+ clearScope: (scope: string) => Promise<boolean>;
568
+ removalGeneration: (scope: string, key: string) => number;
569
+ collapse: (key: string) => boolean;
570
+ dispatch: (action: ReplicaAction<TData>) => boolean;
571
+ ensureScope: (scope: string) => void;
572
+ entityKeys: (id: string) => string[];
573
+ fetcher: ((params: TParams, cursor?: any) => Promise<TFetched>) | undefined;
574
+ getConfirmed: (key: string) => TData | undefined;
575
+ hydrate: (params: TParams, scope?: string, { overwrite }?: {
576
+ overwrite?: boolean;
577
+ }) => Promise<boolean>;
578
+ insertHead: <TItem>(key: string, items: TItem[], { persist }?: {
579
+ persist?: boolean | undefined;
580
+ }) => boolean;
581
+ isMissingHead: (incoming: TFetched) => boolean;
582
+ loadMore: (key: string, fallbackParams?: TParams | undefined) => Promise<void>;
583
+ optimistic: <TResult>(key: string, apply: (data: TData) => TData, serverCall: () => Promise<TResult>, mutationOptions?: OptimisticMutationOptions<TData, TResult>) => Promise<TResult>;
584
+ patchStoredEntity: <TItem>(id: string, fn: (item: TItem) => TItem | undefined) => Promise<void>;
585
+ patchStoredRows: (fn: (data: TData) => TData | null | undefined) => Promise<void>;
586
+ persist: (key: string) => void;
587
+ remove: (key: string, scope?: string) => boolean;
588
+ removeEntity: (id: string, options?: {
589
+ persist?: boolean;
590
+ scope?: string;
591
+ } | undefined) => void;
592
+ replace: (params: TParams, fetched: TFetched, scope?: string) => boolean;
593
+ resource: ReplicaResource<TParams, TData, TFetched, any>;
594
+ retain: (key: string, queryKey?: string) => () => void;
595
+ revalidate: (key?: string) => Promise<unknown>;
596
+ update: (key: string, apply: (data: TData | undefined) => TData | undefined, { persist, seedTag, source }?: {
597
+ persist?: boolean;
598
+ seedTag?: string;
599
+ source?: ReplicaSource;
600
+ }) => boolean;
601
+ updateEntity: <TItem>(id: string, fn: (item: TItem) => TItem | undefined, options?: {
602
+ persist?: boolean;
603
+ scope?: string;
604
+ } | undefined) => void;
605
+ updateMany: (entries: {
606
+ apply: (data: TData | undefined) => TData | undefined;
607
+ key: string;
608
+ }[], { persist, seedTag, source }?: {
609
+ persist?: boolean;
610
+ seedTag?: string;
611
+ source?: ReplicaSource;
612
+ }) => boolean;
613
+ };
614
+ type ReplicaEngine<TParams, TData, TFetched = TData> = ReturnType<typeof createReplicaEngine<TParams, TData, TFetched>>;
615
+ /** Concatenate in view order: forward = head first, backward = head last. */
616
+ declare const joinPages: <TItem>(head: TItem[], rest: TItem[], direction: ReplicaPagingDirection) => TItem[];
617
+ /** The paging config one entry reads with: `directionOf` resolved for `key`. */
618
+ declare const pagingFor: <TItem, TCursor>(config: ReplicaPagingConfig<TItem, TCursor>, key: string) => ReplicaPagingConfig<TItem, TCursor>;
619
+ /**
620
+ * Cursor to request the next page with; `null` → exhausted, `undefined` →
621
+ * cannot page yet. Offset views without bookkeeping (seeded by legacy code or
622
+ * an older persisted shape) derive it from `currentPage` / `hasMore`.
623
+ */
624
+ declare const getNextPageCursor: <TItem, TCursor>(data: ReplicaPagedData<TItem, TCursor> | undefined, config: Pick<ReplicaPagingConfig<TItem, TCursor>, 'deriveCursor' | 'direction' | 'isCursorable' | 'mode'>) => TCursor | null | undefined;
625
+ /**
626
+ * Fold a fresh HEAD page into the view.
627
+ *
628
+ * - First load, a query change (`reset`) or an unextended view → the head page.
629
+ * - Extended view, `offset` mode → keep the loaded depth: fresh head first,
630
+ * then previously loaded rows not in it (shifted offsets self-heal by id).
631
+ * - Extended view, `cursor` mode → keep older pages only when the fresh head
632
+ * still contains the join anchor and the next cursor is known; otherwise
633
+ * collapse to the head page (never show a hidden gap).
634
+ *
635
+ * Client-only rows still in the view are kept on the head side.
636
+ */
637
+ declare const applyHeadPage: <TItem, TCursor>(current: ReplicaPagedData<TItem, TCursor> | undefined, page: ReplicaPageResult<TItem, TCursor>, options: {
638
+ pageSize: number;
639
+ reset?: boolean;
640
+ }, config: ReplicaPagingConfig<TItem, TCursor>, ctx?: ReplicaPagingContext<TItem>) => ReplicaPagedData<TItem, TCursor>;
641
+ /** Merge the NEXT page (fetched with `getNextPageCursor`) into the view. */
642
+ declare const applyNextPage: <TItem, TCursor>(current: ReplicaPagedData<TItem, TCursor>, page: ReplicaPageResult<TItem, TCursor>, config: ReplicaPagingConfig<TItem, TCursor>, ctx?: ReplicaPagingContext<TItem>) => ReplicaPagedData<TItem, TCursor>;
643
+ /**
644
+ * Insert new rows on the HEAD side (a streamed message, a created topic).
645
+ * Cursors point at the far end, so they stay valid; offsets shift and are
646
+ * healed by id de-duplication on the next page merge.
647
+ */
648
+ declare const insertHeadItems: <TItem, TCursor>(current: ReplicaPagedData<TItem, TCursor>, items: TItem[], config: ReplicaPagingConfig<TItem, TCursor>) => ReplicaPagedData<TItem, TCursor>;
649
+ /**
650
+ * Patch (or remove, when `fn` returns `undefined`) one row by id. Returns the
651
+ * same reference when the row is absent or unchanged.
652
+ */
653
+ declare const mapPagedItem: <TItem, TCursor, TData extends ReplicaPagedData<TItem, TCursor>>(current: TData, id: string, fn: (item: TItem) => TItem | undefined, config: Pick<ReplicaPagingConfig<TItem>, 'getId'>) => TData;
654
+ /** Whether a paged view holds a row. */
655
+ declare const hasPagedItem: <TItem>(data: ReplicaPagedData<TItem, unknown> | undefined, id: string, config: Pick<ReplicaPagingConfig<TItem>, 'getId'>) => boolean;
656
+ /** Drop every loaded page but the head (e.g. after an edit inside older history). */
657
+ declare const collapseToHead: <TItem, TCursor>(current: ReplicaPagedData<TItem, TCursor>, config: ReplicaPagingConfig<TItem, TCursor>) => ReplicaPagedData<TItem, TCursor>;
658
+ /**
659
+ * What survives a reload: whole pages from the head side, bounded by
660
+ * `persist.pages` and `persist.maxItems`, client-only rows and transient
661
+ * flags stripped. Offset views are cut at page-size boundaries so the
662
+ * persisted `currentPage` stays a valid offset; a cut inside a cursor page
663
+ * forgets the cursor (pagination resumes after the next server head page).
664
+ */
665
+ declare const toPersistedPage: <TItem, TCursor, TData extends ReplicaPagedData<TItem, TCursor>>(data: TData, config: ReplicaPagingConfig<TItem, TCursor>, ctx?: ReplicaPagingContext<TItem>) => TData;
666
+ /**
667
+ * The value IS one entity, or wraps one (`{ project, members }`). Deleting the
668
+ * entity drops the whole value.
669
+ */
670
+ declare const singleEntity: <TData, TItem = TData>(getId: (item: TItem) => string, lens?: {
671
+ get: (data: TData) => TItem;
672
+ set: (data: TData, item: TItem) => TData;
673
+ }) => ReplicaEntityAdapter<TData, TItem>;
674
+ /** The value is a plain list of entities; deleting one filters it out. */
675
+ declare const arrayEntity: <TItem>(getId: (item: TItem) => string) => ReplicaEntityAdapter<TItem[], TItem>;
676
+ /** The part of a slice entity propagation needs (structural, so any slice fits). */
677
+ interface ReplicaEntityTarget {
678
+ beginEntityOptimistic: <TItem>(id: string, fn: (item: TItem) => TItem | undefined) => ReplicaOptimisticToken<any>[];
679
+ /** Wipe the resource in the active scope (memory + every persisted row). */
680
+ clear?: () => Promise<boolean>;
681
+ /** Patch persisted rows whose entry is not loaded in memory. */
682
+ patchStoredEntity: <TItem>(id: string, fn: (item: TItem) => TItem | undefined) => Promise<void>;
683
+ /**
684
+ * Explicit entity removal that also guards later hydrates. Optional for
685
+ * custom targets; `linkReplicaEntity` falls back to `updateEntity`.
686
+ */
687
+ removeEntity?: (id: string, options?: {
688
+ persist?: boolean;
689
+ scope?: string;
690
+ }) => void;
691
+ revalidate: (key?: string) => Promise<unknown>;
692
+ updateEntity: <TItem>(id: string, fn: (item: TItem) => TItem | undefined, options?: {
693
+ persist?: boolean;
694
+ }) => void;
695
+ }
696
+ /**
697
+ * Link resources that hold copies of the same entity (e.g. a topic in the
698
+ * sidebar list, the management page and the detail cache).
699
+ *
700
+ * Each resource keeps exactly one store location and decides how the entity
701
+ * maps into its own shape (paged resources by `getId`, single-entity ones by
702
+ * their entity adapter). The link only fans an entity-level change out to the
703
+ * resources that currently hold it — no global event bus, no shared owner.
704
+ */
705
+ declare const linkReplicaEntity: <TItem>(targets: ReplicaEntityTarget[]) => {
706
+ clear: () => Promise<boolean>;
707
+ optimistic: <TResult>(id: string, fn: ((item: TItem) => TItem) | 'remove', serverCall: () => Promise<TResult>) => Promise<TResult>;
708
+ remove: (id: string, options?: {
709
+ persist?: boolean;
710
+ scope?: string;
711
+ }) => void;
712
+ revalidate: () => Promise<unknown[]>;
713
+ update: (id: string, fn: (item: TItem) => TItem, options?: {
714
+ persist?: boolean;
715
+ }) => void;
716
+ };
717
+ /**
718
+ * Strict read of one row (see `ReplicaStorage.getStrict`). Rejects when the
719
+ * storage has no strict read: its `get` swallows I/O failures into a miss, so a
720
+ * destructive pass reading through it could take an unreadable row (the index)
721
+ * for an empty one and report success for rows it never saw.
722
+ */
723
+ declare const readReplicaRowStrict: <T>(storage: ReplicaStorage<T>, key: ReplicaRowKey) => Promise<ReplicaRow<T> | undefined>;
724
+ /**
725
+ * Serializes writes for one projection key. A later snapshot can never be overwritten by an
726
+ * earlier, slower IndexedDB write.
727
+ */
728
+ declare class ReplicaWriteQueue<T> {
729
+ #private;
730
+ constructor(storage: ReplicaStorage<T>);
731
+ /** Resolves `false` when storage rejected the delete (see `ReplicaStorage.remove`). */
732
+ remove: (key: ReplicaRowKey) => Promise<boolean>;
733
+ set: (key: ReplicaRowKey, value: ReplicaRow<T>) => Promise<boolean>;
734
+ /**
735
+ * Serialized read-modify-write: `fn` sees the row as of every earlier queued
736
+ * write for this key. Return a projection to write it, `null` to remove the
737
+ * row, or `undefined` to leave it untouched (a missing row stays missing).
738
+ *
739
+ * `read` picks how the row is read:
740
+ * - `'get'` (default): best-effort `get`, a failed read passes for a miss.
741
+ * - `'prefer-strict'`: `getStrict` when the storage has one, so a read failure
742
+ * fails the update (resolves `false`) instead of passing for a missing row
743
+ * that `fn` would then overwrite; `get` otherwise.
744
+ * - `'strict'`: `getStrict` only; a storage without it fails the update.
745
+ * Required for destructive passes (see `readReplicaRowStrict`).
746
+ */
747
+ update: (key: ReplicaRowKey, fn: (current: ReplicaRow<T> | undefined) => ReplicaRow<T> | null | undefined, { read }?: {
748
+ read?: 'get' | 'prefer-strict' | 'strict';
749
+ }) => Promise<boolean>;
750
+ }
751
+ /** Reserved storage key of the per-scope index of persisted rows. */
752
+ declare const REPLICA_INDEX_KEY = "__replica:index";
753
+ /**
754
+ * The entry storage keys this resource has a persisted row for in the active
755
+ * scope, oldest first.
756
+ *
757
+ * Storage has no key listing of its own; the engine keeps one index row per
758
+ * scope (`REPLICA_INDEX_KEY`) as it persists and drops entries, and this reads
759
+ * it. That is how code outside the owning store reaches rows an *earlier
760
+ * session* wrote — bounding how many survive a reload, or invalidating a row
761
+ * whose entry is not loaded in memory.
762
+ *
763
+ * The keys are storage keys (`key`, plus `?query` when the resource sets one),
764
+ * so a caller only matches them against entry keys when the resource leaves
765
+ * `query` unset.
766
+ */
767
+ declare const readReplicaStoredKeys: (resource: Pick<ReplicaResource<any, any, any>, 'scope' | 'storage'>) => Promise<string[]>;
768
+ /**
769
+ * Resolve once `promise` settles or `ms` elapses — never rejects, never hangs.
770
+ *
771
+ * Bounds a pre-paint hydrate in a route loader: the persisted read is usually
772
+ * sub-millisecond, and the deadline only fires on a pathological storage stall,
773
+ * so a route transition never waits longer than `ms` for it.
774
+ */
775
+ declare const settleWithin: (promise: Promise<unknown>, ms: number) => Promise<void>;
776
+ /**
777
+ * An async key-value store a replica can persist into: an IndexedDB object
778
+ * store, a SQLite table behind IPC, a `Map` in tests. Values are stored as
779
+ * given (structured clone), so the store decides what it can hold.
780
+ */
781
+ interface ReplicaKeyValueStore {
782
+ /** Rejects when the delete did not land. A missing key is a success. */
783
+ delete: (key: string) => Promise<void>;
784
+ /** Rejects when the read failed; resolves `undefined` for a missing key. */
785
+ get: (key: string) => Promise<unknown>;
786
+ /** Opens the store; awaited before every operation. */
787
+ initialize?: () => Promise<void>;
788
+ /** Rejects when the write did not land. */
789
+ set: (key: string, value: unknown) => Promise<void>;
790
+ }
791
+ interface KeyValueReplicaStorageOptions {
792
+ /** Prefix of every row of this storage, usually the resource name. */
793
+ namespace: string;
794
+ store: ReplicaKeyValueStore;
795
+ }
796
+ /** Store key of one row: `<namespace>:<scope>:<queryKey>`, each part URI-encoded but the namespace. */
797
+ declare const replicaRowStoreKey: (namespace: string, { queryKey, scope }: ReplicaRowKey) => string;
798
+ /** {@link ReplicaStorage} over an async key-value store, one record per row. */
799
+ declare class KeyValueReplicaStorage<T> implements ReplicaStorage<T> {
800
+ #private;
801
+ constructor({ namespace, store }: KeyValueReplicaStorageOptions);
802
+ get: (key: ReplicaRowKey) => Promise<ReplicaRow<T> | undefined>;
803
+ /** Like `get`, but rejects when the row could not be read (see `ReplicaStorage.getStrict`). */
804
+ getStrict: (key: ReplicaRowKey) => Promise<ReplicaRow<T> | undefined>;
805
+ /**
806
+ * Rejects when the delete did not land, unlike reads: a caller that removes a
807
+ * row the user deleted must be able to retry it.
808
+ */
809
+ remove: (key: ReplicaRowKey) => Promise<void>;
810
+ /**
811
+ * Rejects when the write did not land (see `ReplicaStorage.set`): the replica
812
+ * engine only treats a row as holding a value after a confirmed write, so a
813
+ * dropped write is retried by the next persist instead of being skipped as
814
+ * redundant.
815
+ */
816
+ set: (key: ReplicaRowKey, row: ReplicaRow<T>) => Promise<void>;
817
+ }
818
+ /** Turns a row into the string `localStorage` holds, and back. */
819
+ interface ReplicaRowCodec<T> {
820
+ parse: (value: string) => ReplicaRow<T>;
821
+ stringify: (row: ReplicaRow<T>) => string;
822
+ }
823
+ interface LocalStorageReplicaStorageOptions<T> {
824
+ /** Defaults to JSON. Pass a richer codec (e.g. superjson) for `Date`, `Map` … */
825
+ codec?: ReplicaRowCodec<T>;
826
+ /** Prefix of every row of this storage, usually the resource name. */
827
+ namespace: string;
828
+ }
829
+ /**
830
+ * {@link ReplicaStorage} over `window.localStorage`. Small values only (the
831
+ * origin shares a ~5 MB quota), but the read is synchronous: `getSync` restores
832
+ * a row inside pre-paint initialization without yielding a frame. Outside a
833
+ * browser every operation is a no-op miss.
834
+ */
835
+ declare class LocalStorageReplicaStorage<T> implements ReplicaStorage<T> {
836
+ #private;
837
+ constructor({ codec, namespace }: LocalStorageReplicaStorageOptions<T>);
838
+ get: (key: ReplicaRowKey) => Promise<ReplicaRow<T> | undefined>;
839
+ /** Restore a row inside pre-paint initialization without yielding a frame. */
840
+ getSync: (key: ReplicaRowKey) => ReplicaRow<T> | undefined;
841
+ /** Like `get`, but rejects when the row could not be read or parsed (see `ReplicaStorage.getStrict`). */
842
+ getStrict: (key: ReplicaRowKey) => Promise<ReplicaRow<T> | undefined>;
843
+ /**
844
+ * Rejects when the delete did not land (e.g. storage access denied), unlike
845
+ * reads: a caller removing a row the user deleted must be able to retry it.
846
+ */
847
+ remove: (key: ReplicaRowKey) => Promise<void>;
848
+ /**
849
+ * Rejects when the write did not land (a quota error, see `ReplicaStorage.set`):
850
+ * the replica engine only treats a row as holding a value after a confirmed
851
+ * write.
852
+ */
853
+ set: (key: ReplicaRowKey, row: ReplicaRow<T>) => Promise<void>;
854
+ }
855
+ export { DefinePagedReplicaOptions as $, ReplicaEngineOptions as A, replicaReducer as B, joinPages as C, ReplicaEngine as D, toPersistedPage as E, ReplicaAction as F, ReplicaState as G, ReplicaPendingMutation as H, ReplicaEffect as I, stableQueryKey as J, isReplicaStorageKeyOf as K, ReplicaTransition as L, ReplicaOptimisticToken as M, ReplicaStorePort as N, createReplicaEngine as O, ReplicaEntityAdapter as P, replicaKeys as Q, ReplicaViewWrite as R, insertHeadItems as S, pagingFor as T, ReplicaSeedPatch as U, ReplicaEntryMeta as V, ReplicaSource as W, REPLICA_KEY_PREFIX as X, staticReplicaScope as Y, isReplicaSyncKey as Z, applyHeadPage as _, KeyValueReplicaStorageOptions as a, ReplicaScope as at, getNextPageCursor as b, settleWithin as c, ReplicaPagedData as ct, ReplicaWriteQueue as d, ReplicaPagingDirection as dt, DefineReplicaOptions as et, readReplicaRowStrict as f, ReplicaPagingMode as ft, singleEntity as g, arrayEntity as h, ReplicaStorage as ht, KeyValueReplicaStorage as i, ReplicaResource as it, ReplicaHeadOutcome as j, OptimisticMutationOptions as k, readReplicaStoredKeys as l, ReplicaPagingConfig as lt, linkReplicaEntity as m, ReplicaRowKey as mt, LocalStorageReplicaStorageOptions as n, definePagedReplica as nt, ReplicaKeyValueStore as o, ReplicaPageInfo as ot, ReplicaEntityTarget as p, ReplicaRow as pt, replicaStorageKey as q, ReplicaRowCodec as r, defineReplica as rt, replicaRowStoreKey as s, ReplicaPageResult as st, LocalStorageReplicaStorage as t, ReplicaStorageFactory as tt, REPLICA_INDEX_KEY as u, ReplicaPagingContext as ut, applyNextPage as v, mapPagedItem as w, hasPagedItem as x, collapseToHead as y, createReplicaState as z };
856
+ //# sourceMappingURL=index-DsElitHD.d.ts.map