local-replica 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -11
- package/README.zh-CN.md +281 -0
- package/dist/zustand/index.d.ts +12 -3
- package/dist/zustand/index.js +9 -11
- package/dist/zustand/index.js.map +1 -1
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -1,12 +1,131 @@
|
|
|
1
1
|
# local-replica
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**English** · [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
**A Linear-like, local-first feel, without rewriting your backend or adopting a sync engine.**
|
|
6
|
+
|
|
7
|
+
Keep your APIs, your fetcher and your store. local-replica keeps a local replica of what the server last returned, so every list and detail paints on the first frame, writes land instantly, and the server reconciles quietly in the background.
|
|
8
|
+
|
|
9
|
+
<!-- TODO: before / after reload GIF (skeleton flash vs instant paint) -->
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
### Everyone wants the Linear feel
|
|
14
|
+
|
|
15
|
+
Linear feels fast because of three things:
|
|
16
|
+
|
|
17
|
+
- **Instant open.** No spinner, no skeleton. A reload or a relaunch shows the data straight away.
|
|
18
|
+
- **Instant writes.** A click takes effect before the server answers.
|
|
19
|
+
- **Consistent everywhere.** Edit something once and the list, the detail page and the sidebar all update together.
|
|
20
|
+
|
|
21
|
+
That is the local-first experience, and once users have it they don't want to go back.
|
|
22
|
+
|
|
23
|
+
### Sync engines are a heavy price for application developers
|
|
24
|
+
|
|
25
|
+
Linear gets there with a purpose-built sync engine. Getting the same experience the same way, whether you build your own or adopt Replicache, Zero, ElectricSQL, PowerSync and the like, usually means accepting some of these costs:
|
|
26
|
+
|
|
27
|
+
| Cost | What it means in practice |
|
|
28
|
+
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
|
+
| **Backend reshaped around sync** | Change logs, versions or sync ids, push / pull endpoints. Writes go through the engine's mutation channel, so your existing REST / tRPC / server actions don't fit as they are. |
|
|
30
|
+
| **A database in the client** | A full object graph or table set lives locally, and every schema change needs a client-side migration too. |
|
|
31
|
+
| **Permissions expressed twice** | "What may this user sync" has to be restated in the sync layer, apart from the authorization your business logic already has. |
|
|
32
|
+
| **Bootstrap and data volume** | Initial sync of a large workspace, incremental sync and local storage growth all need their own design. |
|
|
33
|
+
| **Conflicts and debugging** | The truth is spread over the client, the sync layer and the server, so a wrong value is hard to trace back. |
|
|
34
|
+
| **Lock-in** | Your data layer is bound to one engine, often one database. Moving off it is expensive. |
|
|
35
|
+
|
|
36
|
+
To see what this takes in practice, read [Reverse Engineering Linear's Sync Engine](https://github.com/wzhudev/reverse-linear-sync-engine). Its author reconstructed Linear Sync Engine (LSE) from Linear's shipped (minified) frontend code, annotated it, and checked the result against Linear's public talks. Linear's CTO later called it more complete than Linear's own internal docs. What it uncovers:
|
|
37
|
+
|
|
38
|
+
- **Models are a client-side ORM.** About 80 models are declared with decorators into a `ModelRegistry`, with load strategies (`instant`, `lazy`, `partial`, …), references and back-references. MobX makes them observable, and they live in an in-memory object pool.
|
|
39
|
+
- **The client runs a database.** Bootstrapping fills one IndexedDB database per workspace (full, partial or local bootstrap). A hash of all model metadata decides when that database must be migrated, and data is hydrated lazily from it.
|
|
40
|
+
- **Writes are transactions, not API calls.** `issue.title = '…'; issue.save()` produces a transaction. It is queued and cached in IndexedDB, sent in batches, and can be undone if the server rejects it.
|
|
41
|
+
- **The server is a sequencer.** It executes every transaction under one global, increasing `syncId` and broadcasts delta packets over a WebSocket to every client, including the one that sent it. Access control is expressed as sync groups the client subscribes to.
|
|
42
|
+
|
|
43
|
+
The resulting developer experience is excellent, but only because a frontend runtime, a client database with migrations, a transaction protocol and a server-side sequencer are all built to match.
|
|
44
|
+
|
|
45
|
+
For a product designed around a sync engine from day one, like Linear, that is a sound investment. For most apps, which already have server APIs, a fetch layer and a store, it means starting the data layer over for the sake of UX, and then writing every new feature inside that mental model.
|
|
46
|
+
|
|
47
|
+
### Most of the perceived speed doesn't need a sync engine
|
|
48
|
+
|
|
49
|
+
Break down what users actually notice:
|
|
50
|
+
|
|
51
|
+
- **Instant open** → paint the last confirmed value from local storage on the first frame.
|
|
52
|
+
- **Instant writes** → optimistic updates.
|
|
53
|
+
- **Consistent everywhere** → propagate one entity to every copy that holds it.
|
|
54
|
+
|
|
55
|
+
All three can be done on the client, as long as data keeps coming from the server the normal way and the client keeps a local replica whose lifecycle is managed carefully.
|
|
56
|
+
|
|
57
|
+
## Local Replica: the sweet spot between local-first and server-first
|
|
58
|
+
|
|
59
|
+
We call this approach **Local Replica**: server data fetch + local data caching. It sits between the two usual architectures and takes the part of each that matters most:
|
|
60
|
+
|
|
61
|
+
| | Server-first (fetch on demand) | **Local Replica** | Local-first (sync engine) |
|
|
62
|
+
| ------------------- | ------------------------------ | ----------------------------------------- | ---------------------------------- |
|
|
63
|
+
| Source of truth | Server | **Server** | Client database, synced to server |
|
|
64
|
+
| What the client holds | An in-memory cache | **A persisted replica of what it fetched** | A synced copy of the dataset |
|
|
65
|
+
| First paint | After the network | **From the replica** | From the local database |
|
|
66
|
+
| Writes | Wait for the server | **Optimistic, reconciled by the server** | Local transactions, synced later |
|
|
67
|
+
| Backend | Your APIs | **Your APIs** | Built around the sync protocol |
|
|
68
|
+
|
|
69
|
+
From server-first it keeps the simple architecture: your APIs, your auth, one source of truth. From local-first it takes the experience: instant paint, instant writes, consistent views. It skips the expensive middle (a client database, a sync protocol, offline conflict resolution), which is where most of a sync engine's cost lives.
|
|
70
|
+
|
|
71
|
+
### How it works
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
Your existing APIs (REST / tRPC / server actions) ← unchanged
|
|
75
|
+
│ fetcher
|
|
76
|
+
▼
|
|
77
|
+
Your existing fetch layer (SWR) ← unchanged
|
|
78
|
+
│
|
|
79
|
+
▼
|
|
80
|
+
┌────────────── local-replica ──────────────┐
|
|
81
|
+
│ first frame painted from the local replica │
|
|
82
|
+
│ server responses reconcile in background │
|
|
83
|
+
│ optimistic writes overlaid, rolled back │
|
|
84
|
+
│ one entity propagated to every replica │
|
|
85
|
+
│ partitioned by user + workspace │
|
|
86
|
+
└────────────────────────────────────────────┘
|
|
87
|
+
│
|
|
88
|
+
▼
|
|
89
|
+
Your existing store (Zustand) ← still the only UI source of truth
|
|
90
|
+
│ selectors
|
|
91
|
+
▼
|
|
92
|
+
Components ← read the store as before
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- **The server stays the source of truth.** Data comes from your existing endpoints; your auth, business logic and database don't change.
|
|
96
|
+
- **The local copy is only a replica.** It paints the first frame and carries optimistic writes, and every server response reconciles it.
|
|
97
|
+
- **Adopt it incrementally.** One list at a time, no big-bang migration of the data layer.
|
|
98
|
+
|
|
99
|
+
## What you don't have to think about
|
|
100
|
+
|
|
101
|
+
"Add a local cache" sounds simple. The hard part is that once a replica is persisted, it outlives the session, the signed-in user and the write in flight. local-replica owns those problems so your feature code doesn't:
|
|
4
102
|
|
|
5
103
|
- **First frame from the replica.** The last confirmed value is read from persistent storage before the network answers, so a reload or a revisit never flashes a skeleton.
|
|
6
104
|
- **Server confirms quietly.** The head page from the network replaces the replica. Loaded pages, client-only rows and pending writes survive the replace.
|
|
7
105
|
- **Writes land immediately.** Optimistic overlays apply right away. A failed write rolls back to the confirmed base plus any other writes still in flight. Optimistic values never reach storage.
|
|
8
|
-
- **One entity, every copy.** `linkReplicaEntity` sends a patch or delete to every replica that holds the entity: the list row, the management page and the detail cache.
|
|
106
|
+
- **One entity, every copy.** `linkReplicaEntity` sends a patch or delete to every replica that holds the entity: the list row, the management page and the detail cache, including persisted rows that aren't loaded.
|
|
107
|
+
- **Deletes stay deleted.** Dedicated primitives for an entity, a key or a whole resource purge every persisted variant and block hydrates already in flight, so a reload never resurrects deleted data.
|
|
9
108
|
- **Identity-safe.** Memory and storage are partitioned by scope (user + workspace). On a scope switch the view is cleared before paint, and late results from the previous scope are dropped.
|
|
109
|
+
- **Bounded storage.** Opt in to a per-scope cap; the least recently used rows are evicted, never the ones on screen or with writes in flight.
|
|
110
|
+
|
|
111
|
+
What you write: a resource definition, a fetcher, and a `useSync` call.
|
|
112
|
+
|
|
113
|
+
## Comparison
|
|
114
|
+
|
|
115
|
+
| | Plain SWR + store | **local-replica** | Linear-style sync engine |
|
|
116
|
+
| ------------------------------------- | ----------------- | ----------------- | ------------------------ |
|
|
117
|
+
| Instant open | ✗ | ✓ | ✓ |
|
|
118
|
+
| Instant writes | hand-written | ✓ | ✓ |
|
|
119
|
+
| Entity consistent across views | hand-written | ✓ | ✓ |
|
|
120
|
+
| Backend changes | none | **none** | significant |
|
|
121
|
+
| Client database / schema migrations | none | **none** | yes |
|
|
122
|
+
| Reuses your existing APIs and auth | ✓ | **✓** | restated in sync layer |
|
|
123
|
+
| Incremental adoption | – | **✓** | hard |
|
|
124
|
+
| Offline writes / real-time multiplayer | ✗ | ✗ | ✓ |
|
|
125
|
+
|
|
126
|
+
### Non-goals
|
|
127
|
+
|
|
128
|
+
local-replica deliberately does **not** queue writes while offline, merge conflicts (CRDT / OT) or push server changes in real time. Giving those up is what keeps the backend untouched. If your product is built around multiplayer editing or offline authoring, a sync engine is the right tool.
|
|
10
129
|
|
|
11
130
|
## Install
|
|
12
131
|
|
|
@@ -18,14 +137,14 @@ npm i local-replica
|
|
|
18
137
|
|
|
19
138
|
## Layers
|
|
20
139
|
|
|
21
|
-
| Entry
|
|
22
|
-
|
|
|
140
|
+
| Entry | What it owns |
|
|
141
|
+
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
23
142
|
| `local-replica` | Core engine, framework-agnostic: resource definition, pure reducer, paging (offset / cursor, forward / backward), entity links, serialized persistence, storage adapters (see [Storage](#storage)). |
|
|
24
143
|
| `local-replica/zustand` | Zustand adapter: binds a resource to one location in a domain store (`recordLens`). Adds a `useSync` hook that schedules fetches through a `ReplicaSyncDriver` (`createSWRDriver` for SWR). |
|
|
25
144
|
|
|
26
145
|
The host store stays the only UI source of truth. Components read the store through their usual selectors. `useSync` only reports fetch flags (`isHydrated`, `isValidating`, `error`, `revalidate`) and never returns data.
|
|
27
146
|
|
|
28
|
-
##
|
|
147
|
+
## Quick start
|
|
29
148
|
|
|
30
149
|
```ts
|
|
31
150
|
import { definePagedReplica, KeyValueReplicaStorage } from 'local-replica';
|
|
@@ -59,7 +178,9 @@ A persisted read is asynchronous, so a list hydrated only by `useSync` paints it
|
|
|
59
178
|
|
|
60
179
|
A persisted row is only rewritten when its value changes: a persist whose value is deep-equal to what the engine last wrote to (or hydrated from) that row is skipped, so an unchanged revalidation costs no storage write. A failed write, a delete, an entity patch, an eviction or a `clear()` forgets what the row holds, so the next persist writes it again. The engine only tracks its own writes: a row changed behind it (another tab) is corrected by the next changed value or the next session.
|
|
61
180
|
|
|
62
|
-
##
|
|
181
|
+
## Guides
|
|
182
|
+
|
|
183
|
+
### Storage
|
|
63
184
|
|
|
64
185
|
A resource persists through any `ReplicaStorage` (`get` / `getStrict` / `set` / `remove`; see [Invalidation](#invalidation) for the failure contract). Two adapters ship with the package, both keyed `<namespace>:<scope>:<queryKey>`:
|
|
65
186
|
|
|
@@ -70,7 +191,7 @@ A resource persists through any `ReplicaStorage` (`get` / `getStrict` / `set` /
|
|
|
70
191
|
|
|
71
192
|
Both reject failed writes and deletes and implement `getStrict`, so every destructive pass works on them. Outside a browser `LocalStorageReplicaStorage` is a no-op miss.
|
|
72
193
|
|
|
73
|
-
|
|
194
|
+
### Fetching without a mounted view
|
|
74
195
|
|
|
75
196
|
`slice.revalidate(key)` only re-runs the `useSync` queries that are mounted. A store that needs the data while no view is mounted — a hover prefetch, a refresh after a mutation on a route without the list — calls `slice.fetch(params)` instead of calling the service and `replace` by hand:
|
|
76
197
|
|
|
@@ -89,6 +210,7 @@ Every head response, from `useSync` or `fetch`, goes through one settle pipeline
|
|
|
89
210
|
```ts
|
|
90
211
|
createReplicaSlice(resource, {
|
|
91
212
|
effects: {
|
|
213
|
+
onSettled: (data, params, ctx) => {}, // every successful response, even one the guards drop
|
|
92
214
|
onApplied: (data, params, ctx) => {}, // folded into the active scope
|
|
93
215
|
onMissing: (params, ctx) => {}, // `isMissing`, removed from the active scope
|
|
94
216
|
onPurge: (params, ctx) => {}, // `isMissing` captured under a scope left behind
|
|
@@ -99,11 +221,11 @@ Every head response, from `useSync` or `fetch`, goes through one settle pipeline
|
|
|
99
221
|
});
|
|
100
222
|
```
|
|
101
223
|
|
|
102
|
-
They run for `useSync` responses and `fetch` responses alike. A `useSync` request settles on its own response even when the hook has moved to another key and the driver skips its callback. `useSync`'s per-call `onSuccess` / `onError` remain for UI callbacks of that one view, and `onSuccess` is skipped for a key the hook has left.
|
|
224
|
+
They run for `useSync` responses and `fetch` responses alike. `onSettled` runs first, for every successful response, so per-request bookkeeping is released even when the response is never folded in. A `useSync` request settles on its own response even when the hook has moved to another key and the driver skips its callback. `useSync`'s per-call `onSuccess` / `onError` remain for UI callbacks of that one view, and `onSuccess` is skipped for a key the hook has left.
|
|
103
225
|
|
|
104
226
|
Use `revalidate` when the views that show the data are mounted and should refetch on their own schedule. Use `fetch` when the caller needs the data now, needs to know whether it applied, or may run with nothing mounted.
|
|
105
227
|
|
|
106
|
-
|
|
228
|
+
### Invalidation
|
|
107
229
|
|
|
108
230
|
Persisted rows are kept per entry key **and** per query (`key?<query>`), so a resource can hold several rows for one key. Never delete through a plain `set` or by clearing SWR keys: the persisted rows survive and a reload paints the deleted data. Use the primitive that matches what is gone:
|
|
109
231
|
|
|
@@ -120,7 +242,7 @@ A `ReplicaStorage` must reject from `remove` when a delete did not land, and mus
|
|
|
120
242
|
|
|
121
243
|
Prefer the narrowest call: an entity delete keeps the rest of every list; `remove(key)` is for a key whose whole value is gone or denied (NOT_FOUND / FORBIDDEN); `clear()` is for flows that invalidate everything the user has of that resource. `clear()` does not refetch: mounted views show their loading state until the next sync, so call `slice.revalidate()` after it when the server still has data to show.
|
|
122
244
|
|
|
123
|
-
|
|
245
|
+
### Bounding persisted query variants
|
|
124
246
|
|
|
125
247
|
Every distinct key + query a resource persists is its own row, so a resource keyed (or queried) by free text — a search, a market filter, a per-topic bundle — would grow storage with every query the user ever ran. Opt in to a cap instead of hand-rolling a recency window around `remove`:
|
|
126
248
|
|
|
@@ -139,7 +261,7 @@ const searchList = defineReplica<Params, Result>({
|
|
|
139
261
|
- **Never evicted.** A row with a mounted `useSync` (pinned for as long as it is mounted), an entry with pending optimistic writes, and an entry with a `loadMore` in flight. A view that renders an entry without `useSync` pins it with `slice.retain(key, storageKey)`, which returns the release.
|
|
140
262
|
- **Failure.** A failed delete stays indexed and is picked again on the next use. An unreadable index (see `getStrict`) evicts nothing.
|
|
141
263
|
|
|
142
|
-
|
|
264
|
+
### Transcripts and other irregular pages
|
|
143
265
|
|
|
144
266
|
Message-like resources page backward by cursor, with windows whose length changes on every refresh. The paging config covers them:
|
|
145
267
|
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# local-replica
|
|
2
|
+
|
|
3
|
+
[English](./README.md) · **简体中文**
|
|
4
|
+
|
|
5
|
+
**Linear 般的 local-first 体验,但不用重写后端,也不用引入一套同步引擎。**
|
|
6
|
+
|
|
7
|
+
你的 API、请求层和 store 都保持原样。local-replica 在本地保存一份服务端最近一次返回的副本:每个列表和详情页首帧就有数据,写操作即时生效,服务端在后台静默校准。
|
|
8
|
+
|
|
9
|
+
<!-- TODO: 刷新前后对比 GIF(骨架屏闪烁 vs 直接出数据) -->
|
|
10
|
+
|
|
11
|
+
## 为什么
|
|
12
|
+
|
|
13
|
+
### 大家都想要 Linear 的手感
|
|
14
|
+
|
|
15
|
+
Linear 用起来"快",靠的是三件事:
|
|
16
|
+
|
|
17
|
+
- **打开就有**:没有 loading,没有骨架屏,刷新或重开应用都直接出数据。
|
|
18
|
+
- **操作即生效**:点完立刻变,不用等服务端返回。
|
|
19
|
+
- **到处一致**:一处改了,列表、详情页、侧边栏同时更新。
|
|
20
|
+
|
|
21
|
+
这就是 local-first 的体验,用户用过就回不去了。
|
|
22
|
+
|
|
23
|
+
### 但同步引擎对应用开发者来说代价很高
|
|
24
|
+
|
|
25
|
+
Linear 能做到这些,靠的是一套专门打造的同步引擎。如果想用同样的方式获得同样的体验(不管是自研,还是采用 Replicache、Zero、ElectricSQL、PowerSync 这类方案),通常要接受下面的部分代价:
|
|
26
|
+
|
|
27
|
+
| 代价 | 实际意味着什么 |
|
|
28
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
|
29
|
+
| **后端围绕同步协议改造** | 变更日志、版本号或 sync id、push / pull 端点。写操作要走引擎的 mutation 通道,现有的 REST / tRPC / server action 不能直接用。 |
|
|
30
|
+
| **客户端多了一个数据库** | 本地要维护完整的对象图或表结构,每次 schema 变更客户端也要跟着迁移。 |
|
|
31
|
+
| **权限要表达两遍** | "这个用户能同步哪些数据"要在同步层重新表达一遍,和业务里已有的鉴权逻辑分开维护。 |
|
|
32
|
+
| **首次同步与数据量** | 大工作区的首次同步、增量同步、本地存储膨胀,都需要专门设计。 |
|
|
33
|
+
| **冲突与调试** | 数据的真相分散在客户端、同步层、服务端三处,一个值不对很难追溯来源。 |
|
|
34
|
+
| **锁定** | 数据层绑定在某个引擎上,往往还绑定某种数据库,迁移成本很高。 |
|
|
35
|
+
|
|
36
|
+
想直观感受这需要什么,可以读 [Reverse Engineering Linear's Sync Engine](https://github.com/wzhudev/reverse-linear-sync-engine)。作者从 Linear 线上发布的(经过混淆的)前端代码中逆向还原了 Linear Sync Engine(LSE),给代码逐段加了注释,并和 Linear 公开的演讲内容做了对照。Linear 的 CTO 后来评价它比 Linear 内部的文档还要完整。从中可以看到:
|
|
37
|
+
|
|
38
|
+
- **模型是一套客户端 ORM**:约 80 个模型通过装饰器注册到 `ModelRegistry`,带有加载策略(`instant`、`lazy`、`partial` 等)、引用和反向引用。模型用 MobX 实现可观察,存放在内存对象池里。
|
|
39
|
+
- **客户端跑着一个数据库**:启动时要把数据装进每个工作区一个的 IndexedDB 数据库(分为完整、部分、本地三种启动方式)。所有模型元数据会算出一个 hash,用来判断这个数据库何时需要迁移;数据再从库里按需懒加载到内存。
|
|
40
|
+
- **写操作是事务,不是 API 调用**:`issue.title = '…'; issue.save()` 会生成一个事务。事务先排队并缓存进 IndexedDB,再批量发给服务端;服务端拒绝时可以在客户端撤销。
|
|
41
|
+
- **服务端是一个排序器**:服务端用一个全局递增的 `syncId` 执行每个事务,再通过 WebSocket 把 delta packet 广播给所有客户端,包括发起事务的那个。权限则表达为客户端订阅的 sync group。
|
|
42
|
+
|
|
43
|
+
最终的开发体验非常好,但前提是前端运行时、带迁移的客户端数据库、事务协议和服务端排序器都要为它配套建好。
|
|
44
|
+
|
|
45
|
+
对 Linear 这种从第一天就围绕同步引擎设计的产品来说,这是合理的投资。但对大多数已经有服务端 API、请求层和 store 的应用来说,这等于为了体验把数据层推倒重来,而且之后的每个新功能都要在这套心智模型里写。
|
|
46
|
+
|
|
47
|
+
### 用户感知到的"快",大部分不需要同步引擎
|
|
48
|
+
|
|
49
|
+
拆开来看,用户真正感知到的是:
|
|
50
|
+
|
|
51
|
+
- **打开就有** → 首帧从本地存储读出上次确认的值来绘制。
|
|
52
|
+
- **操作即生效** → 乐观更新。
|
|
53
|
+
- **到处一致** → 把同一个实体的变更同步到所有持有它的副本。
|
|
54
|
+
|
|
55
|
+
这三件事都可以在客户端完成,前提是数据仍然从服务端正常获取,同时客户端保存一份生命周期被严格管理的本地副本。
|
|
56
|
+
|
|
57
|
+
## Local Replica:介于 local-first 和 server-first 之间的甜品方案
|
|
58
|
+
|
|
59
|
+
我们把这种方式称为 **Local Replica**:server data fetch + local data caching。它介于两种常见架构之间,各取其中最重要的那一部分:
|
|
60
|
+
|
|
61
|
+
| | Server-first(按需请求) | **Local Replica** | Local-first(同步引擎) |
|
|
62
|
+
| ---------------- | ------------------------ | -------------------------------- | ---------------------------- |
|
|
63
|
+
| 真相来源 | 服务端 | **服务端** | 客户端数据库,同步到服务端 |
|
|
64
|
+
| 客户端持有什么 | 内存缓存 | **请求结果的持久化副本** | 数据集的同步副本 |
|
|
65
|
+
| 首帧绘制 | 等网络返回 | **从副本绘制** | 从本地数据库绘制 |
|
|
66
|
+
| 写操作 | 等服务端返回 | **乐观更新,由服务端校准** | 本地事务,稍后同步 |
|
|
67
|
+
| 后端 | 现有 API | **现有 API** | 围绕同步协议构建 |
|
|
68
|
+
|
|
69
|
+
它从 server-first 保留了简单的架构:现有的 API、现有的鉴权、唯一的真相来源;从 local-first 拿到了体验:首帧即有数据、写操作即时生效、视图之间保持一致。它跳过了代价最高的中间层(客户端数据库、同步协议、离线冲突处理),而同步引擎的大部分成本恰恰在这里。
|
|
70
|
+
|
|
71
|
+
### 工作方式
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
你现有的 API(REST / tRPC / server action) ← 不用改
|
|
75
|
+
│ fetcher
|
|
76
|
+
▼
|
|
77
|
+
你现有的请求层(SWR) ← 不用换
|
|
78
|
+
│
|
|
79
|
+
▼
|
|
80
|
+
┌─────────── local-replica ───────────┐
|
|
81
|
+
│ 首帧从本地副本绘制 │
|
|
82
|
+
│ 服务端响应在后台静默校准 │
|
|
83
|
+
│ 乐观写入叠加,失败自动回滚 │
|
|
84
|
+
│ 一个实体同步到所有副本 │
|
|
85
|
+
│ 按用户 + 工作区隔离 │
|
|
86
|
+
└──────────────────────────────────────┘
|
|
87
|
+
│
|
|
88
|
+
▼
|
|
89
|
+
你现有的 store(Zustand) ← 仍然是 UI 唯一的数据源
|
|
90
|
+
│ selector
|
|
91
|
+
▼
|
|
92
|
+
组件 ← 读法不变
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- **服务端仍然是真相**:数据来自你现有的接口,鉴权、业务逻辑、数据库都不用动。
|
|
96
|
+
- **本地只是副本**:用来绘制首帧、承接乐观写入,每次服务端响应都会校准它。
|
|
97
|
+
- **渐进接入**:一次接一个列表,不需要一次性迁移整个数据层。
|
|
98
|
+
|
|
99
|
+
## 你不用再操心的事
|
|
100
|
+
|
|
101
|
+
"加一层本地缓存"听起来很简单。难点在于副本一旦持久化,它就活得比当前会话、当前登录用户、当前进行中的写操作都长。local-replica 把这些问题收进了包里,业务代码不用管:
|
|
102
|
+
|
|
103
|
+
- **首帧来自副本**:在网络返回之前,先从持久化存储读出上次确认的值,刷新或重新进入页面不会闪骨架屏。
|
|
104
|
+
- **服务端静默确认**:网络返回的头页会替换副本,但已加载的分页、仅存在于客户端的行、进行中的写操作都会保留。
|
|
105
|
+
- **写操作立即生效**:乐观更新会立刻叠加上去。写失败时回滚到"已确认的基线+其它仍在进行中的写操作"。乐观值永远不会写入存储。
|
|
106
|
+
- **一个实体,所有副本**:`linkReplicaEntity` 会把 patch 或 delete 发送到每一个持有该实体的副本,包括列表行、管理页、详情缓存,以及未加载的持久化行。
|
|
107
|
+
- **删了就是删了**:针对实体、key、整个资源分别提供删除原语,会清除每一个持久化变体,并拦截已经在路上的 hydrate,刷新后不会让已删除的数据复活。
|
|
108
|
+
- **身份安全**:内存和存储都按 scope(用户+工作区)分区。切换 scope 时先清空视图再绘制,上一个 scope 晚到的结果直接丢弃。
|
|
109
|
+
- **存储有界**:可以按 scope 设置上限,超出时淘汰最久未使用的行,但不会淘汰正在显示或有写操作进行中的条目。
|
|
110
|
+
|
|
111
|
+
你需要写的只有:一个资源定义、一个 fetcher、一次 `useSync` 调用。
|
|
112
|
+
|
|
113
|
+
## 对比
|
|
114
|
+
|
|
115
|
+
| | 传统 SWR + store | **local-replica** | Linear 式同步引擎 |
|
|
116
|
+
| ---------------------------- | ---------------- | ----------------- | ----------------- |
|
|
117
|
+
| 打开就有数据 | ✗ | ✓ | ✓ |
|
|
118
|
+
| 操作即生效 | 手写 | ✓ | ✓ |
|
|
119
|
+
| 实体跨视图一致 | 手写 | ✓ | ✓ |
|
|
120
|
+
| 后端改造 | 无 | **无** | 大 |
|
|
121
|
+
| 客户端数据库 / schema 迁移 | 无 | **无** | 有 |
|
|
122
|
+
| 复用现有 API 和鉴权 | ✓ | **✓** | 需在同步层重新表达 |
|
|
123
|
+
| 渐进接入 | – | **✓** | 难 |
|
|
124
|
+
| 离线写入 / 多人实时协作 | ✗ | ✗ | ✓ |
|
|
125
|
+
|
|
126
|
+
### 非目标
|
|
127
|
+
|
|
128
|
+
local-replica 刻意**不做**离线写入队列、冲突合并(CRDT / OT),也不做服务端变更的实时推送。正是放弃了这些能力,后端才能保持不动。如果你的产品是围绕多人协同编辑或离线创作来构建的,同步引擎才是合适的工具。
|
|
129
|
+
|
|
130
|
+
## 安装
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npm i local-replica
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`react`(`useSync` 需要)和 `zustand` 是可选的 peer 依赖,只有 `local-replica/zustand` 需要它们。核心入口与框架无关。
|
|
137
|
+
|
|
138
|
+
## 分层
|
|
139
|
+
|
|
140
|
+
| 入口 | 职责 |
|
|
141
|
+
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
142
|
+
| `local-replica` | 核心引擎,与框架无关:资源定义、纯 reducer、分页(offset / cursor,向前 / 向后)、实体关联、串行化持久化、存储适配器(见[存储](#存储))。 |
|
|
143
|
+
| `local-replica/zustand` | Zustand 适配器:把资源绑定到领域 store 中的某个位置(`recordLens`)。提供 `useSync` hook,通过 `ReplicaSyncDriver` 调度请求(SWR 用 `createSWRDriver`)。 |
|
|
144
|
+
|
|
145
|
+
宿主 store 始终是 UI 唯一的数据源,组件照常用 selector 读取 store。`useSync` 只返回请求状态(`isHydrated`、`isValidating`、`error`、`revalidate`),从不返回数据。
|
|
146
|
+
|
|
147
|
+
## 快速开始
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { definePagedReplica, KeyValueReplicaStorage } from 'local-replica';
|
|
151
|
+
import { createReplicaSlice, createSWRDriver, recordLens } from 'local-replica/zustand';
|
|
152
|
+
|
|
153
|
+
const topicList = definePagedReplica<Params, Topic, number, TopicPage>({
|
|
154
|
+
key: ({ agentId }) => `agent_${agentId}`,
|
|
155
|
+
name: 'topicList',
|
|
156
|
+
paging: { direction: 'forward', getId: (t) => t.id, mode: 'offset', persist: { pages: 1 } },
|
|
157
|
+
query: ({ sortBy }) => ({ sortBy }), // key 之外的查询标识
|
|
158
|
+
scope, // ReplicaScope: { get, use, canPersist }
|
|
159
|
+
storage: (namespace) => new KeyValueReplicaStorage({ namespace, store }), // 见「存储」
|
|
160
|
+
version: 1,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const slice = createReplicaSlice(topicList, {
|
|
164
|
+
driver: createSWRDriver({ mutate, useSWR }),
|
|
165
|
+
fetcher: fetchTopicPage, // (params, cursor) => { items, total?, nextCursor? }
|
|
166
|
+
get,
|
|
167
|
+
set,
|
|
168
|
+
stateKey: 'topicListReplica', // 记账用的 slot:createReplicaState()
|
|
169
|
+
view: recordLens('topicDataMap'), // 渲染值所在的位置
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
slice.useSync(params); // 在组件中:先 hydrate,再同步
|
|
173
|
+
slice.loadMore(key);
|
|
174
|
+
slice.optimistic(key, apply, serverCall);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
持久化读取是异步的,所以只靠 `useSync` hydrate 的列表,在冷启动时会先显示几帧 loading。如果这个列表必须无骨架屏绘制,就在路由提交之前从 route loader 里预先填充:`slice.ensureScope(scope)` + `slice.hydrate(params, scope)`,并用 `settleWithin(hydrate, ms)` 设上限,避免存储卡住时拖住路由。slot 填充之后,后续 `useSync` 的 hydrate 不会再执行。
|
|
178
|
+
|
|
179
|
+
持久化行只在值变化时才会重写:如果要持久化的值与引擎上次写入(或 hydrate 读出)的值深度相等,就跳过写入,所以一次没有变化的重新校验不产生存储写入。写入失败、删除、实体 patch、淘汰或 `clear()` 之后,引擎会忘记该行的内容,下一次持久化会重新写入。引擎只追踪自己的写入:如果某行在它背后被修改了(比如另一个标签页),会在下一次值变化或下一次会话时得到纠正。
|
|
180
|
+
|
|
181
|
+
## 使用指南
|
|
182
|
+
|
|
183
|
+
### 存储
|
|
184
|
+
|
|
185
|
+
资源可以通过任意 `ReplicaStorage`(`get` / `getStrict` / `set` / `remove`;失败约定见[失效](#失效))持久化。包内自带两个适配器,key 格式都是 `<namespace>:<scope>:<queryKey>`:
|
|
186
|
+
|
|
187
|
+
| 适配器 | 底层 | 适用场景 |
|
|
188
|
+
| ---------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
189
|
+
| `KeyValueReplicaStorage` | 任意异步 key-value 存储 `{ get, set, delete, initialize? }`:IndexedDB object store、走 IPC 的 SQLite… | 任意大小的列表和详情。行以对象形式存储,能克隆什么由存储自己决定。 |
|
|
190
|
+
| `LocalStorageReplicaStorage` | 通过 codec 使用 `window.localStorage`(默认 JSON;需要 `Date`、`Map` 等可以传入 superjson 之类) | 必须在绘制前初始化阶段就渲染出来的小数据:`getSync(key)` 读取一行时不会让出帧。 |
|
|
191
|
+
|
|
192
|
+
两者在写入、删除失败时都会 reject,也都实现了 `getStrict`,所以所有破坏性操作都能在它们上面正常工作。在浏览器之外,`LocalStorageReplicaStorage` 始终返回未命中,不做任何操作。
|
|
193
|
+
|
|
194
|
+
### 没有挂载视图时的请求
|
|
195
|
+
|
|
196
|
+
`slice.revalidate(key)` 只会重新执行已挂载的 `useSync` 查询。如果 store 在没有视图挂载时也需要数据,比如 hover 预取,或者在没有该列表的路由上执行写操作后刷新,就调用 `slice.fetch(params)`,不要手动调用服务再 `replace`:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
const { applied, data, missing } = await slice.fetch(params); // 复用进行中的请求
|
|
200
|
+
await slice.fetch(params, { fresh: true }); // 写操作之后:发起新请求,并作废旧请求
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
所有头页响应,不管来自 `useSync` 还是 `fetch`,都经过同一条 settle 流水线。请求发出时会记录它的 scope、`clear()` 代数、条目的删除代数和查询条件。只要其中任何一项已经变化(切换身份、`clear()`、`remove` 或实体被裁剪、视图已切到另一个查询),或者该条目已经应用了更新的响应,这个响应就会被丢弃。否则用 `replace` 合并进去;`isMissing` 的响应会删除条目,而在 store 已离开的 scope 下捕获的 `isMissing` 响应,则改为清除那个 scope 的行。`fetch` resolve 为 `{ applied, data, missing }`,请求失败时 reject。
|
|
204
|
+
|
|
205
|
+
- **去重**:`fetch` 会复用同一条目上进行中的请求,不管它是由 `useSync` 还是另一次 `fetch` 发起的。请求只 settle 一次,所以 effect 不会执行两次。`{ fresh: true }` 会发起新请求并作废旧请求,因为旧请求可能还带着写操作之前的值。
|
|
206
|
+
- **捕获 scope**:从先前响应展开的流程(比如预热时为拿到的根节点再请求回复)会传入开始时捕获的 `{ scope }`,之后每一步都 settle 到那个 scope。一旦该 scope 不再是当前 scope,`fetch` 会以 `ReplicaScopeChangedError` reject 且不发请求,否则这个请求会以旧身份的名义读取新身份的数据。
|
|
207
|
+
- **Driver 缓存**:`fetch` 应用响应后,会把数据写入 driver 在 sync key 下的缓存(`driver.seed`;对 SWR 来说是 `mutate(key, data, { revalidate: false })`)。如果该 key 有 `useSync` 请求正在进行,就跳过这次写入:SWR 会丢弃被写入抢先的进行中请求,此时写入会导致那个请求更新的响应被丢掉。之后再挂载时 SWR 仍然会重新校验,除非 sync 设置了 `revalidateIfStale: false`。值已经在 store 里了,那次重新校验只是确认它。
|
|
208
|
+
- **Effects**:每个响应都需要的副作用,放在 slice 上,不要放在某个视图的回调里:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
createReplicaSlice(resource, {
|
|
212
|
+
effects: {
|
|
213
|
+
onSettled: (data, params, ctx) => {}, // 每个成功的响应,包括被守卫丢弃的
|
|
214
|
+
onApplied: (data, params, ctx) => {}, // 已合并进当前 scope
|
|
215
|
+
onMissing: (params, ctx) => {}, // `isMissing`,已从当前 scope 删除
|
|
216
|
+
onPurge: (params, ctx) => {}, // 在已离开的 scope 下捕获的 `isMissing`
|
|
217
|
+
onError: (error, params, { isActive, key, scope }) => {},
|
|
218
|
+
},
|
|
219
|
+
isMissing: (data) => data === null,
|
|
220
|
+
// …
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
它们对 `useSync` 和 `fetch` 的响应同样生效。`onSettled` 最先执行,并且对每个成功的响应都会执行,所以即使响应最终没有被合并,按请求维护的记账状态也能被释放。即使 hook 已经切到另一个 key、driver 跳过了它的回调,`useSync` 请求也会按自己的响应 settle。`useSync` 每次调用传入的 `onSuccess` / `onError` 仍然用于那一个视图的 UI 回调,hook 已经离开的 key 不会触发 `onSuccess`。
|
|
225
|
+
|
|
226
|
+
当展示数据的视图已经挂载、应该按它们自己的节奏重新请求时,用 `revalidate`。当调用方需要立即拿到数据、需要知道是否已应用,或者可能在没有任何视图挂载时运行,用 `fetch`。
|
|
227
|
+
|
|
228
|
+
### 失效
|
|
229
|
+
|
|
230
|
+
持久化行按条目 key **和**查询条件(`key?<query>`)分别保存,所以一个资源可以为同一个 key 保存多行。不要通过普通的 `set` 或清除 SWR key 来删除:持久化行还在,刷新后会把已删除的数据画出来。按"消失的是什么"选择对应的原语:
|
|
231
|
+
|
|
232
|
+
| 消失的是什么 | 调用 | 作用 |
|
|
233
|
+
| --------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
234
|
+
| 一个实体(删掉的话题、文件、记忆) | `linkReplicaEntity(...).remove(id)` / `slice.removeEntity(id)` | 从所有已加载条目以及未加载条目的持久化行中删除它。之后 hydrate 出的值(哪怕是已经在路上的读取)都不会包含它,直到服务端的值再次包含它;如果持久化 patch 没有成功(scope 不可信、存储不可读),该守卫会在本次会话内一直保留。 |
|
|
235
|
+
| 一个条目 key,所有筛选 / 排序条件下的变体 | `slice.remove(key)` | 删除该条目及该 key 的所有持久化查询变体。在服务端针对该 key 的 `replace` 覆盖这次删除之前,任何变体都不会再被 hydrate。 |
|
|
236
|
+
| 整个资源("全部删除"、重置) | `slice.clear()` / `linkReplicaEntity(...).clear()` | 在一次提交中清空内存,删除当前 scope 的所有持久化行和索引,并丢弃在它之前发起的 hydrate 和 `useSync` 响应。存储清除完成后 resolve 为 `true`。 |
|
|
237
|
+
| 某个已不是当前 scope 的整个资源("全部删除"进行到一半时切换了账号) | `slice.clearScope(scope)` | 在本次会话已为该 scope 排队的写入之后,删除 `scope` 的所有持久化行和索引;内存(屏幕上另一个 scope 的数据)不受影响。对当前 scope 调用等同于 `clear()`。存储清除完成后 resolve 为 `true`。 |
|
|
238
|
+
|
|
239
|
+
`slice.updateEntity(id, (item) => undefined)` 也会把实体从已加载的值和持久化行中删除,但它只是一次普通 patch:只有 `removeEntity`(`linkReplicaEntity.remove` 和乐观 `'remove'` 调用的就是它)会拦截已经在路上的 hydrate。
|
|
240
|
+
|
|
241
|
+
`ReplicaStorage` 在删除没有成功时必须让 `remove` reject,并且必须实现 `getStrict`(I/O 失败时 reject 的读取),破坏性操作才能成功:`remove(key)` 的变体扫描、`clear()`、非当前 scope 的清除、未加载行的实体 patch 和淘汰都通过它读取,所以索引不可读时操作会失败,而不是当成空索引蒙混过去。没有 `getStrict` 的存储仍然可以 hydrate 和持久化,但所有破坏性操作都会失败:`clear()` resolve 为 `false`,删除保持未清除状态并会重试,实体守卫会保留。`get` 和 `set` 仍然是尽力而为。这时引擎会保留该行的索引、保留未完成的删除,下一次 `remove(key)` / `clear()` 会重试。
|
|
242
|
+
|
|
243
|
+
优先用范围最小的调用:删除实体会保留每个列表的其余部分;`remove(key)` 用于某个 key 的整个值都已不存在或被拒绝访问(NOT_FOUND / FORBIDDEN);`clear()` 用于让用户在该资源下的所有数据都失效的流程。`clear()` 不会重新请求:已挂载的视图在下一次同步前会显示 loading,所以如果服务端仍有数据要显示,记得在它之后调用 `slice.revalidate()`。
|
|
244
|
+
|
|
245
|
+
### 限制持久化查询变体的数量
|
|
246
|
+
|
|
247
|
+
资源持久化的每个不同的 key + 查询条件都是独立的一行,所以如果资源的 key(或查询条件)是自由文本,比如搜索、市场筛选、按话题的数据包,存储就会随用户执行过的每一次查询不断增长。与其自己围绕 `remove` 手写一个最近使用窗口,不如开启上限:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
const searchList = defineReplica<Params, Result>({
|
|
251
|
+
key: ({ keyword }) => keyword,
|
|
252
|
+
name: 'search',
|
|
253
|
+
persist: { maxEntries: 20 }, // 每个 scope 最多持久化 20 行
|
|
254
|
+
// …
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
- **计数方式**:按 scope 统计持久化的存储 key,每个 `key?query` 行计一次。
|
|
259
|
+
- **最近使用**:一次 hydrate、一次持久化,或者一次因值未变而跳过写入的重新校验,都会把该行标记为已使用。顺序保存在每个 scope 的索引中(最旧的在前),刷新后依然有效。由未设上限的旧版本建立索引的行,保持其追加顺序。
|
|
260
|
+
- **淘汰**:超出上限时淘汰最旧的行,包括该行本身、它的索引项,以及对应的内存条目。被淘汰的条目只是缓存未命中,再次访问时会重新请求;它不是删除,也不会像 `remove` 那样拦截 hydrate。
|
|
261
|
+
- **永不淘汰**:有已挂载 `useSync` 的行(挂载期间一直被固定)、有待提交乐观写入的条目、有 `loadMore` 进行中的条目。不通过 `useSync` 渲染条目的视图,可以用 `slice.retain(key, storageKey)` 固定它,返回值用于释放。
|
|
262
|
+
- **失败**:删除失败的行会保留在索引中,下次使用时再次被选中。索引不可读时(见 `getStrict`)不淘汰任何行。
|
|
263
|
+
|
|
264
|
+
### 对话记录等不规则分页
|
|
265
|
+
|
|
266
|
+
类消息资源按 cursor 向后分页,而且每次刷新时窗口长度都会变化。分页配置可以覆盖这些情况:
|
|
267
|
+
|
|
268
|
+
| 选项 / API | 用途 |
|
|
269
|
+
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
|
|
270
|
+
| `paging.isCursorable(item)` | 在确定拼接锚点或推导 cursor 时跳过合成行(分组 / 摘要节点)。头页刷新时会按 id 替换头页里的合成行。 |
|
|
271
|
+
| `paging.deriveCursor(item)` | 服务端不返回 `nextCursor` 的普通列表:下一个 cursor 由最旧的合格行推导。仅适用于位置信息无损的行。 |
|
|
272
|
+
| `paging.sort` | 稳定排序:比较结果相等的行保持服务端顺序。只比较位置。 |
|
|
273
|
+
| `persistKey(key)` | 只保留在内存中的条目(不 hydrate、不持久化),例如按 scope 划分的桶。 |
|
|
274
|
+
| `slice.update(key, fn, { persist: false })` + `slice.persist(key)` | 先流式写入内存,再把确认后的值持久化一次。 |
|
|
275
|
+
| `splitPagedLens({ itemsField, metaField, derive })` | 原始行放在读取方期望的位置,分页记账放在旁边,派生字段(解析后的展示列表)在同一次提交中更新。 |
|
|
276
|
+
|
|
277
|
+
`useSync(params, options)` 会把同步调度参数(`refreshInterval`、`dedupingInterval`、`revalidateIfStale`、`revalidateOnFocus` 等)转发给 driver,并在响应合并后执行 `onSuccess` / `onError` 副作用(资源级别的副作用应放在 slice 的 `effects` 中,见上文)。在没有网络的冷启动场景(离线或网络很慢)下,由于没有响应可供 `onSuccess` 处理,`onHydrated` 会基于 hydrate 绘制出的持久化值执行同样的"成功"逻辑。
|
|
278
|
+
|
|
279
|
+
## 许可证
|
|
280
|
+
|
|
281
|
+
[MIT](./LICENSE) © LobeHub
|
package/dist/zustand/index.d.ts
CHANGED
|
@@ -180,9 +180,10 @@ interface ReplicaSettleContext {
|
|
|
180
180
|
* every response the resource settles, whichever path requested it — so a
|
|
181
181
|
* store that fetches while no view is mounted keeps the same side effects.
|
|
182
182
|
*
|
|
183
|
-
*
|
|
184
|
-
* query, a newer response already applied) and, for
|
|
185
|
-
* only when its captured scope is still the active
|
|
183
|
+
* Except `onSettled`, they run only for a response that passed every guard
|
|
184
|
+
* (clear, removal, active query, a newer response already applied) and, for
|
|
185
|
+
* `onApplied` / `onMissing`, only when its captured scope is still the active
|
|
186
|
+
* one.
|
|
186
187
|
*/
|
|
187
188
|
interface ReplicaEffects<TParams, TFetched> {
|
|
188
189
|
/**
|
|
@@ -208,6 +209,14 @@ interface ReplicaEffects<TParams, TFetched> {
|
|
|
208
209
|
* keep of it there (a linked list row). Never touch the view on screen.
|
|
209
210
|
*/
|
|
210
211
|
onPurge?: (params: TParams, context: ReplicaSettleContext) => void;
|
|
212
|
+
/**
|
|
213
|
+
* Every successful response, including one the guards discard (cleared or
|
|
214
|
+
* removed entry, superseded query, off-scope) and one `prepareHead` drops.
|
|
215
|
+
* Use it for per-request bookkeeping that must be released even when the
|
|
216
|
+
* response is never folded. Runs before the other effects of the response;
|
|
217
|
+
* not called for errors.
|
|
218
|
+
*/
|
|
219
|
+
onSettled?: (data: TFetched, params: TParams, context: ReplicaSettleContext) => void;
|
|
211
220
|
}
|
|
212
221
|
interface ReplicaFetchOptions {
|
|
213
222
|
/**
|
package/dist/zustand/index.js
CHANGED
|
@@ -505,7 +505,14 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
|
|
|
505
505
|
isActive,
|
|
506
506
|
missing: false
|
|
507
507
|
};
|
|
508
|
-
|
|
508
|
+
const context = {
|
|
509
|
+
key,
|
|
510
|
+
scope,
|
|
511
|
+
source: request.source
|
|
512
|
+
};
|
|
513
|
+
const discarded = request.clearGeneration !== clearGeneration(scope) || request.removalGeneration !== removalGeneration(scope, key) || requests.isRetired(request) || !isActiveQuery(request);
|
|
514
|
+
if (!("error" in outcome)) effects?.onSettled?.(outcome.data, params, context);
|
|
515
|
+
if (discarded) return dropped;
|
|
509
516
|
if ("error" in outcome) {
|
|
510
517
|
effects?.onError?.(outcome.error, params, {
|
|
511
518
|
isActive,
|
|
@@ -521,11 +528,7 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
|
|
|
521
528
|
const missing = isMissingHead(outcome.data);
|
|
522
529
|
if (missing) {
|
|
523
530
|
remove(key, scope);
|
|
524
|
-
effects?.onPurge?.(params,
|
|
525
|
-
key,
|
|
526
|
-
scope,
|
|
527
|
-
source: request.source
|
|
528
|
-
});
|
|
531
|
+
effects?.onPurge?.(params, context);
|
|
529
532
|
}
|
|
530
533
|
return {
|
|
531
534
|
...dropped,
|
|
@@ -535,11 +538,6 @@ const createReplicaSlice = (resource, { driver, effects, get, set, stateKey, vie
|
|
|
535
538
|
}
|
|
536
539
|
requests.retire(request.requestId, request.seq - 1);
|
|
537
540
|
const { outcome: folded } = applyHead(params, outcome.data, scope);
|
|
538
|
-
const context = {
|
|
539
|
-
key,
|
|
540
|
-
scope,
|
|
541
|
-
source: request.source
|
|
542
|
-
};
|
|
543
541
|
if (folded === "replaced") effects?.onApplied?.(outcome.data, params, context);
|
|
544
542
|
else if (folded === "missing") effects?.onMissing?.(params, context);
|
|
545
543
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/zustand/errors.ts","../../src/zustand/lens.ts","../../src/zustand/requests.ts","../../src/zustand/useSync.ts","../../src/zustand/createReplicaSlice.ts","../../src/zustand/driver.ts"],"sourcesContent":["/** `slice.fetch` was asked to fetch for a scope that is no longer active. */\nexport class ReplicaScopeChangedError extends Error {\n constructor(name: string, scope: string) {\n super(`[replica] ${name}: scope ${scope} is no longer active`);\n this.name = 'ReplicaScopeChangedError';\n }\n}\n","import type { ReplicaPagedData } from '../core/paging/types';\nimport type { ReplicaViewWrite } from '../core/state/reducer';\n\n/**\n * One `key` write in a batched lens update (see {@link ReplicaLens.setMany}).\n */\nexport interface ReplicaViewSet<TData> {\n data: TData | undefined;\n key: string;\n}\n\n/**\n * Where the materialized value lives in the domain store. Selectors keep\n * reading this location; the slice is the only writer.\n */\nexport interface ReplicaLens<TStore, TData> {\n clear: (state: TStore) => Partial<TStore>;\n get: (state: TStore, key: string) => TData | undefined;\n /** Enumerate loaded keys (needed for entity propagation). */\n keys?: (state: TStore) => string[];\n set: (state: TStore, key: string, data: TData | undefined) => Partial<TStore>;\n /**\n * Apply several key writes in one pass — one clone of the backing field\n * instead of one per key. Optional: the slice falls back to repeated `set`\n * when a lens does not implement it.\n */\n setMany?: (state: TStore, entries: ReplicaViewSet<TData>[]) => Partial<TStore>;\n}\n\n/**\n * Apply every view write as ONE host update, so subscribers never observe a\n * view out of step with its bookkeeping. Consecutive key writes go through\n * the lens' `setMany` when it has one, so seeding N rows clones the backing\n * field once instead of N times.\n */\nexport const applyLensWrites = <TStore, TData>(\n view: ReplicaLens<TStore, TData>,\n state: TStore,\n writes: ReplicaViewWrite<TData>[],\n): Partial<TStore> => {\n let patch: Partial<TStore> = {};\n let current = state;\n let pending: ReplicaViewSet<TData>[] = [];\n\n const flush = () => {\n if (pending.length === 0) return;\n const entries = pending;\n pending = [];\n if (view.setMany && entries.length > 1) {\n const next = view.setMany(current, entries);\n patch = { ...patch, ...next };\n current = { ...current, ...next };\n return;\n }\n for (const entry of entries) {\n const next = view.set(current, entry.key, entry.data);\n patch = { ...patch, ...next };\n current = { ...current, ...next };\n }\n };\n\n for (const write of writes) {\n if ('type' in write) {\n flush();\n const next = view.clear(current);\n patch = { ...patch, ...next };\n current = { ...current, ...next };\n } else {\n pending.push({ data: write.data, key: write.key });\n }\n }\n flush();\n return patch;\n};\n\n/** Lens for the common case: a `Record<key, TData>` field on the store. */\nexport const recordLens = <TStore, TData>(\n field: keyof TStore & string,\n): ReplicaLens<TStore, TData> => ({\n clear: () => ({ [field]: {} }) as Partial<TStore>,\n get: (state, key) => (state[field] as Record<string, TData> | undefined)?.[key],\n keys: (state) => Object.keys((state[field] as Record<string, TData> | undefined) ?? {}),\n set: (state, key, data) => {\n const next = { ...(state[field] as Record<string, TData> | undefined) };\n if (data === undefined) delete next[key];\n else next[key] = data;\n return { [field]: next } as Partial<TStore>;\n },\n setMany: (state, entries) => {\n // One clone of the record, then every key — a list refresh seeds N groups\n // in a single copy instead of N.\n const next = { ...(state[field] as Record<string, TData> | undefined) };\n for (const { key, data } of entries) {\n if (data === undefined) delete next[key];\n else next[key] = data;\n }\n return { [field]: next } as Partial<TStore>;\n },\n});\n\n/**\n * Lens for a paged view split across store fields: the rows stay where\n * existing readers expect them (`itemsField[key]`, a plain array), the paging\n * bookkeeping lives in `metaField[key]`, and `derive` recomputes fields built\n * from the rows (e.g. a parsed display list) in the same commit — so a derived\n * field is never one frame behind its rows.\n *\n * Rows seeded without bookkeeping (by code outside the replica) read as a\n * single head page.\n */\nexport const splitPagedLens = <TStore, TItem, TCursor = unknown>({\n clearDerived,\n derive,\n itemsField,\n metaField,\n}: {\n /** Derived fields to reset on a scope change. */\n clearDerived?: () => Partial<TStore>;\n /** Fields computed from an entry's rows (`undefined` = the entry is gone). */\n derive?: (state: TStore, key: string, items: TItem[] | undefined) => Partial<TStore>;\n itemsField: keyof TStore & string;\n metaField: keyof TStore & string;\n}): ReplicaLens<TStore, ReplicaPagedData<TItem, TCursor>> => {\n type Meta = Omit<ReplicaPagedData<TItem, TCursor>, 'items'>;\n const NO_META = {} as Meta;\n // Same rows + same bookkeeping → the same view object, so the engine's\n // reference checks see \"unchanged\".\n const views = new WeakMap<TItem[], WeakMap<Meta, ReplicaPagedData<TItem, TCursor>>>();\n const remember = (items: TItem[], meta: Meta, view: ReplicaPagedData<TItem, TCursor>) => {\n let byMeta = views.get(items);\n if (!byMeta) views.set(items, (byMeta = new WeakMap()));\n byMeta.set(meta, view);\n return view;\n };\n const rowsOf = (state: TStore) => (state[itemsField] ?? {}) as Record<string, TItem[]>;\n const metaOf = (state: TStore) => (state[metaField] ?? {}) as Record<string, Meta>;\n\n return {\n clear: () => ({ [itemsField]: {}, [metaField]: {}, ...clearDerived?.() }) as Partial<TStore>,\n get: (state, key) => {\n const items = rowsOf(state)[key];\n if (!items) return undefined;\n const meta = metaOf(state)[key] ?? NO_META;\n const cached = views.get(items)?.get(meta);\n if (cached) return cached;\n const view =\n meta === NO_META\n ? { currentPage: 0, hasMore: true, items, pageSize: items.length }\n : { ...meta, items };\n return remember(items, meta, view);\n },\n keys: (state) => Object.keys(rowsOf(state)),\n set: (state, key, data) => {\n const rows = { ...rowsOf(state) };\n const metas = { ...metaOf(state) };\n if (data === undefined) {\n delete rows[key];\n delete metas[key];\n } else {\n const { items, ...meta } = data;\n rows[key] = items;\n metas[key] = meta;\n remember(items, meta, data);\n }\n const next = { ...state, [itemsField]: rows, [metaField]: metas } as TStore;\n return {\n [itemsField]: rows,\n [metaField]: metas,\n ...derive?.(next, key, data?.items),\n } as Partial<TStore>;\n },\n };\n};\n","import { replicaKeys } from '../core/resource/queryKeys';\nimport { stableQueryKey } from '../core/resource/storageKey';\nimport type { ReplicaResource } from '../core/resource/types';\n\n/** One head request, from `begin` to `settle`. */\nexport interface ReplicaRequest<TParams, TFetched> {\n /** `clear()` generation of `scope` when the request started. */\n clearGeneration: number;\n key: string;\n params: TParams;\n /** Settles the request; set once it is sent. */\n promise?: Promise<TFetched>;\n /** Row key of the query it asks for (`headQuery` guard). */\n queryKey: string;\n /** `remove` generation of `scope` + `key` when the request started. */\n removalGeneration: number;\n /** Scope + sync key: requests of the same id dedupe and supersede each other. */\n requestId: string;\n /** The cache scope it was captured under. */\n scope: string;\n /** Start order across the slice. */\n seq: number;\n /** What it settled to; a request is settled once (a joined fetch and its sync share it). */\n settled?: ReplicaSettled;\n /** The response (or failure) it settled with. */\n settledWith?: unknown;\n source: 'fetch' | 'sync';\n syncKey: readonly unknown[];\n}\n\nexport interface ReplicaSettled {\n /** Settled into the active scope (folded in or removed as missing). */\n applied: boolean;\n /** Passed every guard; `isActive` says whether its scope is still on screen. */\n current: boolean;\n isActive: boolean;\n missing: boolean;\n}\n\nexport type ReplicaOutcome<TFetched> = { data: TFetched } | { error: unknown };\n\nexport interface ReplicaRequestsDeps<TParams, TFetched> {\n clearGeneration: (scope: string) => number;\n fetcher?: (params: TParams, cursor?: any) => Promise<TFetched>;\n /** Fold a request's outcome into the replica (guards, `applyHead`, effects); runs once. */\n fold: (\n request: ReplicaRequest<TParams, TFetched>,\n outcome: ReplicaOutcome<TFetched>,\n ) => ReplicaSettled;\n removalGeneration: (scope: string, key: string) => number;\n resource: ReplicaResource<TParams, any, TFetched>;\n}\n\n/** Request id of a sync key in a scope: requests of the same id dedupe and supersede. */\nexport const replicaRequestId = (scope: string, syncKey: unknown) =>\n `${scope}\\u0000${stableQueryKey(syncKey)}`;\n\n/**\n * Lifecycle bookkeeping of head requests: capture (`begin`), send and track in\n * flight, retire older requests once a newer response applied, and settle each\n * request exactly once. What settling *does* is the slice's `fold`; this only\n * guarantees every path — a mounted `useSync`, or `fetch` with no view mounted\n * — goes through it the same way.\n */\nexport const createRequests = <TParams, TFetched>({\n clearGeneration,\n fetcher,\n fold,\n removalGeneration,\n resource,\n}: ReplicaRequestsDeps<TParams, TFetched>) => {\n let requestSeq = 0;\n\n /**\n * Requests whose start order is at or below this number are retired, per\n * request id: a newer response already applied, or a `fresh` fetch started\n * after them. Their late response would repaint an older value.\n *\n * An entry only matters while a request it retires is still unsettled:\n * every request started later has a higher sequence, so a missing entry\n * (read as 0) admits exactly the same responses. Entries are pruned as soon\n * as no sent request at or below them is pending, so the map holds only the\n * ids with an older request in flight.\n */\n const retiredThrough = new Map<string, number>();\n /** Sequences of the sent requests that have not settled yet, per request id. */\n const unsettled = new Map<string, Set<number>>();\n\n /** Drop the retirement of `requestId` once no request it retires is pending. */\n const pruneRetired = (requestId: string) => {\n const through = retiredThrough.get(requestId);\n if (through === undefined) return;\n for (const seq of unsettled.get(requestId) ?? []) if (seq <= through) return;\n retiredThrough.delete(requestId);\n };\n\n const retire = (requestId: string, seq: number) => {\n if ((retiredThrough.get(requestId) ?? 0) < seq) retiredThrough.set(requestId, seq);\n // Every entry is pruned the moment it stops retiring a pending request\n // (here, and as each request settles), so the map stays bounded by the ids\n // with an older request in flight — no size backstop needed. Evicting an\n // entry that still retires one would let its stale response win.\n pruneRetired(requestId);\n };\n\n /** A newer response already applied, or a `fresh` fetch started after it. */\n const isRetired = ({ requestId, seq }: ReplicaRequest<TParams, TFetched>) =>\n seq <= (retiredThrough.get(requestId) ?? 0);\n\n const trackUnsettled = ({ requestId, seq }: ReplicaRequest<TParams, TFetched>) => {\n const pending = unsettled.get(requestId) ?? new Set<number>();\n unsettled.set(requestId, pending);\n pending.add(seq);\n };\n\n const untrackUnsettled = ({ requestId, seq }: ReplicaRequest<TParams, TFetched>) => {\n const pending = unsettled.get(requestId);\n if (!pending?.delete(seq)) return;\n if (pending.size === 0) unsettled.delete(requestId);\n pruneRetired(requestId);\n };\n\n /** The latest request in flight per id, which a non-`fresh` `fetch` joins. */\n const inflight = new Map<string, ReplicaRequest<TParams, TFetched>>();\n /** `useSync` requests in flight per id (`fetch` seeds the driver only while none is). */\n const syncInflight = new Map<string, number>();\n\n /**\n * `useSync` request each sync id last started, settled by the driver's\n * callback. The driver settles one request per key at a time (a newer\n * request supersedes the older one), so the latest start is the one that\n * settles.\n */\n const syncRequests = new Map<string, ReplicaRequest<TParams, TFetched>>();\n\n const syncKeyOf = (params: TParams, scope: string, key: string) =>\n resource.syncKey?.(params) ??\n replicaKeys.sync(resource.name, resource.version, scope, key, params);\n\n /** Capture what a head request for `params` started under. */\n const begin = (\n params: TParams,\n scope: string,\n source: 'fetch' | 'sync',\n ): ReplicaRequest<TParams, TFetched> => {\n const key = resource.key(params);\n const syncKey = syncKeyOf(params, scope, key);\n return {\n clearGeneration: clearGeneration(scope),\n key,\n params,\n queryKey: resource.storageKey(params),\n removalGeneration: removalGeneration(scope, key),\n requestId: replicaRequestId(scope, syncKey),\n scope,\n seq: ++requestSeq,\n source,\n syncKey,\n };\n };\n\n /** Send the request and track it in flight until it settles. */\n const send = (request: ReplicaRequest<TParams, TFetched>) => {\n const { requestId } = request;\n const sent = fetcher!(request.params, undefined);\n // Tolerate a fetcher (a test double) that does not return a promise: the\n // driver still gets exactly what it returned.\n const promise = Promise.resolve(sent);\n request.promise = promise;\n // Until it settles, a retirement that covers it must be kept.\n trackUnsettled(request);\n inflight.set(requestId, request);\n if (request.source === 'sync')\n syncInflight.set(requestId, (syncInflight.get(requestId) ?? 0) + 1);\n const done = () => {\n if (inflight.get(requestId) === request) inflight.delete(requestId);\n if (request.source !== 'sync') return;\n // Incremented before this `done` was attached, so the count is present.\n const count = syncInflight.get(requestId)! - 1;\n if (count > 0) syncInflight.set(requestId, count);\n else syncInflight.delete(requestId);\n };\n promise.then(done, done);\n // A `useSync` request settles on its own response, not only through the\n // driver's callback: a driver skips that callback once the hook moved to\n // another key, unmounted, or a newer request superseded it — and the\n // resource effects (a terminal absence, a purge) must not depend on which\n // view is still mounted. The callback then reads the memoized result.\n if (request.source === 'sync')\n promise.then(\n (data) => settle(request, { data }),\n (error: unknown) => settle(request, { error }),\n );\n return sent;\n };\n\n /**\n * Fold a settled head request into the replica (see the slice's `fold`). A\n * request is settled once: a `fetch` that joined a `useSync` request shares\n * its result.\n */\n const settle = (\n request: ReplicaRequest<TParams, TFetched>,\n outcome: ReplicaOutcome<TFetched>,\n ): ReplicaSettled => {\n if (request.settled) return request.settled;\n request.settledWith = 'error' in outcome ? outcome.error : outcome.data;\n request.settled = fold(request, outcome);\n untrackUnsettled(request);\n if (request.source === 'sync') releaseSyncRequest(request);\n return request.settled;\n };\n\n /**\n * Drop a settled `useSync` request's record once its driver callback had its\n * chance to read it. The record is normally taken by that callback, but the\n * driver skips it when the hook changed key, unmounted or was superseded —\n * the request still settles on its own promise, and its record (holding the\n * response) must not stay behind. A driver runs the callback in the same\n * turn the response resolves (SWR does, in microtasks), so a macrotask later\n * the record is only a leak.\n */\n const releaseSyncRequest = (request: ReplicaRequest<TParams, TFetched>) => {\n setTimeout(() => {\n if (syncRequests.get(request.requestId) === request) syncRequests.delete(request.requestId);\n }, 0);\n };\n\n /** Start a `useSync` request and record it for the driver's callback. */\n const startSync = (params: TParams, scope: string) => {\n const request = begin(params, scope, 'sync');\n syncRequests.set(request.requestId, request);\n return send(request);\n };\n\n /**\n * The `useSync` request settling now: the one `requestId` last started. A\n * response the driver settles without having run our fetcher (a\n * cache-shared key), or one the recorded request did not produce, is\n * captured now: nothing it started under is known.\n */\n const takeSyncRequest = (\n requestId: string,\n response: unknown,\n params: TParams,\n scope: string,\n ): ReplicaRequest<TParams, TFetched> => {\n const request = syncRequests.get(requestId);\n syncRequests.delete(requestId);\n // The request already settled on its own response, and the driver now\n // reports that same response: read the memoized result.\n if (request && (!request.settled || request.settledWith === response)) return request;\n return begin(params, scope, 'sync');\n };\n\n return {\n begin,\n /** Records of `useSync` requests not yet released (leak checks in tests). */\n debugPendingSyncRequests: () => syncRequests.size,\n /** Retirement records still held (leak checks in tests). */\n debugRetiredRequests: () => retiredThrough.size,\n /** The request in flight for `requestId`, which a non-`fresh` `fetch` joins. */\n inflight: (requestId: string) => inflight.get(requestId),\n isRetired,\n /** Whether a `useSync` request of `requestId` is in flight. */\n isSyncInflight: (requestId: string) => syncInflight.has(requestId),\n retire,\n send,\n settle,\n startSync,\n syncKeyOf,\n takeSyncRequest,\n };\n};\n\nexport type ReplicaRequests<TParams, TFetched> = ReturnType<\n typeof createRequests<TParams, TFetched>\n>;\n","import { useEffect, useLayoutEffect, useRef } from 'react';\n\nimport type { ReplicaEngine } from '../core/engine/createReplicaEngine';\nimport { replicaKeys } from '../core/resource/queryKeys';\nimport type { ReplicaResource } from '../core/resource/types';\nimport type { ReplicaSyncDriver } from './driver';\nimport type { ReplicaLens } from './lens';\nimport { replicaRequestId, type ReplicaRequests } from './requests';\nimport type { ReplicaSyncOptions, ReplicaSyncResult } from './types';\n\nexport interface ReplicaUseSyncDeps<TStore, TParams, TData, TFetched> {\n driver: ReplicaSyncDriver;\n engine: Pick<\n ReplicaEngine<TParams, TData, TFetched>,\n 'ensureScope' | 'fetcher' | 'hydrate' | 'retain'\n >;\n get: () => TStore;\n /** Row key of the head query each entry last asked for (see the slice's `headQuery`). */\n headQuery: Map<string, string>;\n requests: ReplicaRequests<TParams, TFetched>;\n resource: ReplicaResource<TParams, TData, TFetched>;\n view: ReplicaLens<TStore, TData>;\n}\n\n/**\n * Hydrates the persisted row once per scope/key/query, then lets the driver\n * fetch and revalidate the head. Read the data from the store.\n */\nexport const createUseSync = <TStore, TParams, TData, TFetched>({\n driver,\n engine: { ensureScope, fetcher, hydrate, retain },\n get,\n headQuery,\n requests,\n resource,\n view,\n}: ReplicaUseSyncDeps<TStore, TParams, TData, TFetched>) => {\n /**\n * Entries (`scope` + key) whose one-shot hydration actually painted a\n * persisted row. The hydration query settles `true` on a cache miss too, so\n * a reactivated key may only replay `onHydrated` when its row really came from\n * storage — not off a provisional seed or another source that happens to sit\n * in the view.\n */\n const hydratedEntries = new Set<string>();\n const hydratedEntryId = (scope: string, key: string) => `${scope}\\u0000${key}`;\n\n const useSync = (\n params: TParams | null | undefined,\n {\n enabled = true,\n onError,\n onHydrated,\n onSuccess,\n ...schedule\n }: ReplicaSyncOptions<TFetched, TData> = {},\n ): ReplicaSyncResult => {\n const scope = resource.scope.use();\n const key = params ? resource.key(params) : undefined;\n const active = enabled && !!params && key !== undefined;\n const queryKey = params ? resource.storageKey(params) : undefined;\n\n // Layout effect: runs before paint, so a scope switch never shows a frame\n // of the previous identity's data.\n useLayoutEffect(() => {\n if (active) ensureScope(scope);\n }, [active, scope]);\n\n // Remember the query this entry now asks for (see `headQuery`).\n useLayoutEffect(() => {\n if (active && key !== undefined && queryKey !== undefined) headQuery.set(key, queryKey);\n }, [active, key, queryKey]);\n\n // Pin the entry on screen against eviction (`persist.maxEntries`).\n useLayoutEffect(() => {\n if (active && key !== undefined && queryKey !== undefined) return retain(key, queryKey);\n }, [active, key, queryKey]);\n\n // The entry this hook currently asks for. A hydrate that resolves after the\n // hook moved to another key still fills its own cache entry (harmless), but\n // it must not replay `onHydrated`: those side effects act on the *current*\n // view, so they would re-adopt a superseded object (e.g. navigate g1 → g2\n // while g1's storage read is slow, then g1's hydrate resets the active group\n // back to g1's supervisor).\n const activeKey = useRef<string | undefined>(undefined);\n // The entry (scope + key) whose hydration side effects already ran in its\n // current activation. Reset on every key or scope change, so an entry that\n // comes back (g1 → g2 → g1, or scope A → B → A with the same key) gets them\n // replayed even though its `once` hydration is not re-run.\n const hydratedKey = useRef<string | undefined>(undefined);\n useLayoutEffect(() => {\n activeKey.current = active ? key : undefined;\n hydratedKey.current = undefined;\n }, [active, key, scope]);\n\n const hydration = driver.useQuery<boolean>(\n active && resource.persisted && resource.persistKey(key!)\n ? replicaKeys.hydrate(resource.name, resource.version, scope, resource.storageKey(params!))\n : null,\n async () => {\n const didHydrate = await hydrate(params!, scope);\n if (didHydrate) hydratedEntries.add(hydratedEntryId(scope, key!));\n // A superseded key (the hook moved on) runs no side effects at all.\n if (activeKey.current !== key) return true;\n hydratedKey.current = hydratedEntryId(scope, key!);\n // A persisted row is a successful value with no network response: replay\n // the success side effects off it, so an offline / slow first paint still\n // adopts the roster and settles the flags the response path would.\n if (didHydrate) {\n const hydrated = view.get(get(), key!);\n if (hydrated !== undefined) onHydrated?.(hydrated);\n }\n return true;\n },\n { once: true },\n );\n\n // Reactivating a key whose hydration already settled: the driver reuses the\n // `once` result and the fetcher above does not run, so replay the side\n // effects off the value in memory. Without it, returning to g1 offline\n // leaves them acting on g2 until a response lands (or forever, offline).\n useEffect(() => {\n if (!active || key === undefined || hydration.data !== true) return;\n const entry = hydratedEntryId(scope, key);\n if (hydratedKey.current === entry) return;\n hydratedKey.current = entry;\n const current = view.get(get(), key);\n if (current !== undefined) {\n // A lookup that missed storage hydrated nothing to replay.\n if (hydratedEntries.has(entry)) onHydrated?.(current);\n return;\n }\n // Back on a scope whose view `ensureScope` cleared (A → B → A): the cached\n // `once` result means the fetcher will not read storage again, so read it\n // here — otherwise a row still in A's partition stays off screen while\n // the network is slow or offline.\n void hydrate(params!, scope).then((didHydrate) => {\n if (!didHydrate) return;\n hydratedEntries.add(entry);\n if (activeKey.current !== key || resource.scope.get() !== scope) return;\n const hydrated = view.get(get(), key);\n if (hydrated !== undefined) onHydrated?.(hydrated);\n });\n }, [active, key, scope, hydration.data]);\n\n const syncKey = active && fetcher ? requests.syncKeyOf(params!, scope, key!) : null;\n /** The request settling now: the one this sync id last started. */\n const takeRequest = (response: unknown) =>\n requests.takeSyncRequest(replicaRequestId(scope, syncKey), response, params!, scope);\n\n const sync = driver.useQuery<TFetched>(\n syncKey,\n () => requests.startSync(params!, scope),\n {\n ...schedule,\n // Always installed, so a failed request is settled (and its capture\n // dropped) even when the caller has no error side effects. A failure\n // of a query the entry has moved past (or of a request from before a\n // clear) never surfaces on the query now on screen; an off-scope one is\n // still forwarded with the captured key/scope (see `settle`).\n onError: (error: unknown) => {\n const settled = requests.settle(takeRequest(error), { error });\n if (settled.current) onError?.(error, { isActive: settled.isActive, key, scope });\n },\n onSuccess: (data) => {\n const settled = requests.settle(takeRequest(data), { data });\n if (!settled.current || !settled.isActive) return;\n // A key the hook has moved past (g1 → g2 while g1's request is in\n // flight): the response still fills g1's own entry, but this view's\n // side effects would re-adopt g1 over the g2 view.\n if (activeKey.current !== key) return;\n onSuccess?.(data);\n },\n },\n );\n\n return {\n error: sync.error,\n // A read with no key has nothing to hydrate, so it counts as hydrated: a\n // conditional consumer must not show \"loading\" for an absent id (the\n // former SWR disabled-key behaviour). A read that is disabled but *has* a\n // key still waits for its persisted row.\n isHydrated:\n key === undefined ||\n !resource.persisted ||\n !resource.persistKey(key) ||\n hydration.data === true,\n isValidating: sync.isValidating,\n revalidate: () => sync.mutate(),\n };\n };\n\n return useSync;\n};\n","import { createReplicaEngine } from '../core/engine/createReplicaEngine';\nimport { isReplicaSyncKey } from '../core/resource/queryKeys';\nimport type { ReplicaResource } from '../core/resource/types';\nimport type { ReplicaState } from '../core/state/types';\nimport { ReplicaScopeChangedError } from './errors';\nimport { applyLensWrites } from './lens';\nimport {\n createRequests,\n type ReplicaOutcome,\n type ReplicaRequest,\n type ReplicaSettled,\n} from './requests';\nimport type {\n CreateReplicaSliceOptions,\n ReplicaFetchOptions,\n ReplicaFetchResult,\n ReplicaSettleContext,\n} from './types';\nimport { createUseSync } from './useSync';\n\n/**\n * Bind a replica to a domain Zustand store.\n *\n * The domain store stays the only UI source of truth: components read the\n * `view` location through their usual selectors. The engine owns every\n * transition of that location; this slice adds the Zustand port and a\n * `useSync` hook that only orchestrates fetching — data never flows through\n * its return value.\n */\nexport const createReplicaSlice = <TStore, TParams, TData, TFetched = TData>(\n resource: ReplicaResource<TParams, TData, TFetched>,\n {\n driver,\n effects,\n get,\n set,\n stateKey,\n view,\n ...options\n }: CreateReplicaSliceOptions<TStore, TParams, TData, TFetched>,\n) => {\n const revalidate = (key?: string) =>\n driver.revalidate((queryKey) =>\n isReplicaSyncKey(queryKey, resource.name, { key, scope: resource.scope.get() }),\n );\n\n const engine = createReplicaEngine(resource, {\n ...options,\n port: {\n // One host update for the view writes and the bookkeeping slot together.\n commit: (writes, state, label) =>\n set(\n { ...applyLensWrites(view, get(), writes), [stateKey]: state } as Partial<TStore>,\n false,\n label,\n ),\n getState: () => get()[stateKey] as unknown as ReplicaState<TData>,\n keys: view.keys && (() => view.keys!(get())),\n read: (key) => view.get(get(), key),\n },\n revalidate,\n });\n const { applyHead, clearGeneration, fetcher, isMissingHead, removalGeneration } = engine;\n\n /**\n * Row key of the head query each entry last asked for. One entry backs a\n * single view, so a response that belongs to a query the entry has already\n * moved past must not repaint it — it would show a superseded query, and a\n * view gated on the current query would then wait forever. Making this an\n * invariant of the slice (not of one driver) keeps it true for drivers that\n * leave a request's own callback live after the query changed.\n */\n const headQuery = new Map<string, string>();\n\n // ---- one settle pipeline ---------------------------------------------\n // Every head response — a mounted `useSync`, or `fetch` with no view mounted\n // — is captured by `begin` when its request starts and folded in by `fold`\n // when it settles (see `requests.ts`), so every path applies the same guards\n // and the same resource effects.\n\n const requests = createRequests<TParams, TFetched>({\n clearGeneration,\n fetcher,\n fold: (request, outcome) => fold(request, outcome),\n removalGeneration,\n resource,\n });\n\n /**\n * Whether the request still answers the query its entry shows. A `useSync`\n * request must match the query the entry last asked for (see `headQuery`). A\n * `fetch` may also land with no view mounted, but never over a view that\n * shows another query of the key.\n */\n const isActiveQuery = ({ key, queryKey, source }: ReplicaRequest<TParams, TFetched>) => {\n const head = headQuery.get(key);\n return source === 'sync' ? head === queryKey : head === undefined || head === queryKey;\n };\n\n /**\n * Fold a settled head request into the replica: apply every guard, then\n * `replace` / remove a terminal absence (in the captured scope) / purge one\n * captured off-scope, then run the resource effects.\n */\n const fold = (\n request: ReplicaRequest<TParams, TFetched>,\n outcome: ReplicaOutcome<TFetched>,\n ): ReplicaSettled => {\n const { key, params, scope } = request;\n /**\n * Whether the entry is still on the scope the request was captured under.\n *\n * A response can settle after an identity/workspace switch. `replace` drops\n * it — the engine refuses off-scope actions — but no side effect may run\n * either: it carries the previous identity's state (onboarding redirect,\n * display snapshot, analytics identify, settings writes). The captured\n * key/scope is not enough on its own, because a switch to a scope that\n * reads the same key/query satisfies the other guards.\n */\n const isActive = resource.scope.get() === scope;\n const dropped: ReplicaSettled = { applied: false, current: false, isActive, missing: false };\n\n // Discard a response that started before a `clear()` of its scope or a\n // `remove` of its entry (it holds wiped data), one for a query the entry\n // has moved past (the newer query owns the view, see `headQuery`), and one\n // older than a response already applied or a `fresh` fetch.\n if (\n request.clearGeneration !== clearGeneration(scope) ||\n request.removalGeneration !== removalGeneration(scope, key) ||\n requests.isRetired(request) ||\n !isActiveQuery(request)\n )\n return dropped;\n\n if ('error' in outcome) {\n // Forwarded even off-scope: the captured `key`/`scope` lets a consumer\n // purge that scope's row (a late NOT_FOUND / FORBIDDEN) without touching\n // the scope on screen; `isActive` tells it whether the failure also\n // belongs to the view on screen.\n effects?.onError?.(outcome.error, params, { isActive, key, scope });\n return { ...dropped, current: true };\n }\n\n if (!isActive) {\n // Off-scope: never fold a value into the scope on screen. A terminal\n // absence still purges its own scope's row (`remove` routes it there), so\n // a switch back cannot hydrate a value the server no longer has.\n const missing = isMissingHead(outcome.data);\n if (missing) {\n remove(key, scope);\n effects?.onPurge?.(params, { key, scope, source: request.source });\n }\n return { ...dropped, current: true, missing };\n }\n\n requests.retire(request.requestId, request.seq - 1);\n const { outcome: folded } = applyHead(params, outcome.data, scope);\n const context: ReplicaSettleContext = { key, scope, source: request.source };\n if (folded === 'replaced') effects?.onApplied?.(outcome.data, params, context);\n else if (folded === 'missing') effects?.onMissing?.(params, context);\n return {\n applied: folded !== 'dropped',\n current: true,\n isActive,\n missing: folded === 'missing',\n };\n };\n\n /**\n * Fetch the head of an entry now and settle it like a `useSync` response —\n * for stores that need the data while no view is mounted (a hover prefetch,\n * a refresh after a mutation on a route without the list). `revalidate` only\n * re-runs mounted queries.\n *\n * Joins a request in flight for the same entry unless `fresh`. Resolves with\n * what was fetched and whether it was applied; rejects when the request\n * fails, after routing the failure through `effects.onError`.\n */\n const fetch = async (\n params: TParams,\n { fresh = false, scope = resource.scope.get() }: ReplicaFetchOptions = {},\n ): Promise<ReplicaFetchResult<TFetched>> => {\n if (!fetcher) throw new Error(`[replica] ${resource.name} has no fetcher`);\n if (scope !== resource.scope.get()) throw new ReplicaScopeChangedError(resource.name, scope);\n let request = requests.begin(params, scope, 'fetch');\n const joined = fresh ? undefined : requests.inflight(request.requestId);\n if (joined) request = joined;\n else {\n if (fresh) requests.retire(request.requestId, request.seq - 1);\n requests.send(request);\n }\n\n let data: TFetched;\n try {\n data = await request.promise!;\n } catch (error) {\n requests.settle(request, { error });\n throw error;\n }\n const settled = requests.settle(request, { data });\n // Seed the driver's cache with what this fetch applied, unless a `useSync`\n // request of the key is in flight: it fills the cache itself, and a write\n // overtaking it could make the driver discard its newer response.\n if (\n settled.applied &&\n request.source === 'fetch' &&\n !requests.isSyncInflight(request.requestId)\n )\n driver.seed?.(request.syncKey, data);\n return { applied: settled.applied, data, missing: settled.missing };\n };\n\n /**\n * Forget the key's head query as well as the entry. A `useSync` request\n * already in flight for it would otherwise pass the settle guards (the\n * marker still matches), `replace` the entry and persist pre-mutation rows\n * back over an invalidation that has already completed. (The engine's\n * removal generation drops a `fetch` captured before the removal.)\n */\n const remove = (key: string, scope?: string) => {\n // Only the active scope's marker belongs to the entry on screen; an\n // off-scope removal (see `engine.remove`) must not drop it.\n if (scope === undefined || scope === resource.scope.get()) headQuery.delete(key);\n return engine.remove(key, scope);\n };\n\n const useSync = createUseSync<TStore, TParams, TData, TFetched>({\n driver,\n engine,\n get,\n headQuery,\n requests,\n resource,\n view,\n });\n\n return {\n ...engine,\n /** @internal Records of `useSync` requests not yet released (leak checks in tests). */\n debugPendingSyncRequests: requests.debugPendingSyncRequests,\n /** @internal Retirement records still held (leak checks in tests). */\n debugRetiredRequests: requests.debugRetiredRequests,\n fetch,\n remove,\n useSync,\n };\n};\n\nexport type ReplicaSlice<TStore, TParams, TData, TFetched = TData> = ReturnType<\n typeof createReplicaSlice<TStore, TParams, TData, TFetched>\n>;\n","/**\n * Scheduling knobs a sync can ask its driver for. Named after SWR's options;\n * a driver maps them onto its own cache (or ignores what it cannot honor).\n */\nexport interface ReplicaSyncSchedule {\n /** Collapse identical requests within this window (ms). */\n dedupingInterval?: number;\n /** Skip focus revalidation while the last one is younger than this (ms). */\n focusThrottleInterval?: number;\n /**\n * Hold every network request (mount, stale, focus, revalidate) while it\n * returns `true`, for a read whose input outside the sync key is not ready\n * yet. Unlike `revalidateOnMount: false` it also covers a later key change.\n * Read on each request, so it may close over a ref.\n */\n isPaused?: () => boolean;\n /** Poll every N ms while mounted (0 = off); a function decides per tick from the latest data. */\n refreshInterval?: number | ((latest: any) => number);\n /** Keep polling while the page is hidden. */\n refreshWhenHidden?: boolean;\n /** Keep polling while the browser is offline. */\n refreshWhenOffline?: boolean;\n revalidateIfStale?: boolean;\n revalidateOnFocus?: boolean;\n /**\n * Whether the first mount fetches. `false` leaves the first request to an\n * explicit `revalidate`, for a read whose input outside the sync key is not\n * ready yet. SWR only honors it on the first mount: a later key change still\n * fetches when the new key has no cached data.\n */\n revalidateOnMount?: boolean;\n revalidateOnReconnect?: boolean;\n}\n\nexport interface ReplicaQueryOptions<T> extends ReplicaSyncSchedule {\n /** Run once per key: no refetch on focus, reconnect or remount (hydration reads). */\n once?: boolean;\n onError?: (error: unknown) => void;\n onSuccess?: (data: T) => void;\n}\n\nexport interface ReplicaQueryResult<T> {\n data?: T;\n error?: unknown;\n isValidating: boolean;\n /** Re-run this query. */\n mutate: () => Promise<unknown>;\n}\n\n/**\n * The query cache that schedules a replica's fetches: dedupe, focus /\n * reconnect revalidation, retries. The replica decides what a response means;\n * the driver only decides when to ask. `createSWRDriver` adapts SWR.\n */\nexport interface ReplicaSyncDriver {\n /** Re-run every mounted query whose key matches. */\n revalidate: (match: (key: unknown) => boolean) => Promise<unknown>;\n /**\n * Write a head response that `slice.fetch` requested outside `useQuery` into\n * the query cache under its sync key, without re-running the query — so a\n * `useQuery` of that key sees the latest data (a `revalidateIfStale: false`\n * mount does not refetch it). Optional: a driver without a cache of its own\n * leaves it out; requests still dedupe in the slice.\n *\n * The slice calls it only while no `useQuery` request of the key is in\n * flight. A cache that discards in-flight requests a write overtakes (SWR\n * does) would otherwise drop a newer response than the one written.\n */\n seed?: (key: readonly unknown[], data: unknown) => void;\n useQuery: <T>(\n key: readonly unknown[] | null,\n fetcher: () => Promise<T>,\n options: ReplicaQueryOptions<T>,\n ) => ReplicaQueryResult<T>;\n}\n\ntype SWRLikeHook = (\n key: any,\n fetcher: any,\n config?: any,\n) => { data?: any; error?: unknown; isValidating: boolean; mutate: () => Promise<any> };\n\n/**\n * Driver over SWR. Pass the app's own hook and scoped `mutate` when it wraps\n * SWR (custom cache provider, key augmentation, retry policy).\n */\nexport const createSWRDriver = ({\n mutate,\n useSWR,\n}: {\n /**\n * SWR's (scoped) `mutate`. Called as `mutate(match)` to revalidate, and as\n * `mutate(key, data, { revalidate: false })` to seed — pass the arguments\n * through unchanged: SWR tells a revalidation from a write by their count.\n */\n mutate: (\n ...args: [match: (key: any) => boolean] | [key: unknown, data: unknown, options: object]\n ) => Promise<unknown>;\n useSWR: SWRLikeHook;\n}): ReplicaSyncDriver => ({\n revalidate: (match) => mutate(match),\n // Populate the cache only: re-running the query would refetch what was just fetched.\n seed: (key, data) => {\n void mutate(key, data, { revalidate: false }).catch(() => undefined);\n },\n useQuery: (key, fetcher, { once, onError, onSuccess, ...schedule }) =>\n useSWR(key, fetcher, {\n ...Object.fromEntries(Object.entries(schedule).filter(([, value]) => value !== undefined)),\n ...(once && {\n revalidateIfStale: false,\n revalidateOnFocus: false,\n revalidateOnReconnect: false,\n }),\n ...(onError && { onError: (error: unknown) => onError(error) }),\n ...(onSuccess && { onSuccess: (data: unknown) => onSuccess(data as never) }),\n }),\n});\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/zustand/errors.ts","../../src/zustand/lens.ts","../../src/zustand/requests.ts","../../src/zustand/useSync.ts","../../src/zustand/createReplicaSlice.ts","../../src/zustand/driver.ts"],"sourcesContent":["/** `slice.fetch` was asked to fetch for a scope that is no longer active. */\nexport class ReplicaScopeChangedError extends Error {\n constructor(name: string, scope: string) {\n super(`[replica] ${name}: scope ${scope} is no longer active`);\n this.name = 'ReplicaScopeChangedError';\n }\n}\n","import type { ReplicaPagedData } from '../core/paging/types';\nimport type { ReplicaViewWrite } from '../core/state/reducer';\n\n/**\n * One `key` write in a batched lens update (see {@link ReplicaLens.setMany}).\n */\nexport interface ReplicaViewSet<TData> {\n data: TData | undefined;\n key: string;\n}\n\n/**\n * Where the materialized value lives in the domain store. Selectors keep\n * reading this location; the slice is the only writer.\n */\nexport interface ReplicaLens<TStore, TData> {\n clear: (state: TStore) => Partial<TStore>;\n get: (state: TStore, key: string) => TData | undefined;\n /** Enumerate loaded keys (needed for entity propagation). */\n keys?: (state: TStore) => string[];\n set: (state: TStore, key: string, data: TData | undefined) => Partial<TStore>;\n /**\n * Apply several key writes in one pass — one clone of the backing field\n * instead of one per key. Optional: the slice falls back to repeated `set`\n * when a lens does not implement it.\n */\n setMany?: (state: TStore, entries: ReplicaViewSet<TData>[]) => Partial<TStore>;\n}\n\n/**\n * Apply every view write as ONE host update, so subscribers never observe a\n * view out of step with its bookkeeping. Consecutive key writes go through\n * the lens' `setMany` when it has one, so seeding N rows clones the backing\n * field once instead of N times.\n */\nexport const applyLensWrites = <TStore, TData>(\n view: ReplicaLens<TStore, TData>,\n state: TStore,\n writes: ReplicaViewWrite<TData>[],\n): Partial<TStore> => {\n let patch: Partial<TStore> = {};\n let current = state;\n let pending: ReplicaViewSet<TData>[] = [];\n\n const flush = () => {\n if (pending.length === 0) return;\n const entries = pending;\n pending = [];\n if (view.setMany && entries.length > 1) {\n const next = view.setMany(current, entries);\n patch = { ...patch, ...next };\n current = { ...current, ...next };\n return;\n }\n for (const entry of entries) {\n const next = view.set(current, entry.key, entry.data);\n patch = { ...patch, ...next };\n current = { ...current, ...next };\n }\n };\n\n for (const write of writes) {\n if ('type' in write) {\n flush();\n const next = view.clear(current);\n patch = { ...patch, ...next };\n current = { ...current, ...next };\n } else {\n pending.push({ data: write.data, key: write.key });\n }\n }\n flush();\n return patch;\n};\n\n/** Lens for the common case: a `Record<key, TData>` field on the store. */\nexport const recordLens = <TStore, TData>(\n field: keyof TStore & string,\n): ReplicaLens<TStore, TData> => ({\n clear: () => ({ [field]: {} }) as Partial<TStore>,\n get: (state, key) => (state[field] as Record<string, TData> | undefined)?.[key],\n keys: (state) => Object.keys((state[field] as Record<string, TData> | undefined) ?? {}),\n set: (state, key, data) => {\n const next = { ...(state[field] as Record<string, TData> | undefined) };\n if (data === undefined) delete next[key];\n else next[key] = data;\n return { [field]: next } as Partial<TStore>;\n },\n setMany: (state, entries) => {\n // One clone of the record, then every key — a list refresh seeds N groups\n // in a single copy instead of N.\n const next = { ...(state[field] as Record<string, TData> | undefined) };\n for (const { key, data } of entries) {\n if (data === undefined) delete next[key];\n else next[key] = data;\n }\n return { [field]: next } as Partial<TStore>;\n },\n});\n\n/**\n * Lens for a paged view split across store fields: the rows stay where\n * existing readers expect them (`itemsField[key]`, a plain array), the paging\n * bookkeeping lives in `metaField[key]`, and `derive` recomputes fields built\n * from the rows (e.g. a parsed display list) in the same commit — so a derived\n * field is never one frame behind its rows.\n *\n * Rows seeded without bookkeeping (by code outside the replica) read as a\n * single head page.\n */\nexport const splitPagedLens = <TStore, TItem, TCursor = unknown>({\n clearDerived,\n derive,\n itemsField,\n metaField,\n}: {\n /** Derived fields to reset on a scope change. */\n clearDerived?: () => Partial<TStore>;\n /** Fields computed from an entry's rows (`undefined` = the entry is gone). */\n derive?: (state: TStore, key: string, items: TItem[] | undefined) => Partial<TStore>;\n itemsField: keyof TStore & string;\n metaField: keyof TStore & string;\n}): ReplicaLens<TStore, ReplicaPagedData<TItem, TCursor>> => {\n type Meta = Omit<ReplicaPagedData<TItem, TCursor>, 'items'>;\n const NO_META = {} as Meta;\n // Same rows + same bookkeeping → the same view object, so the engine's\n // reference checks see \"unchanged\".\n const views = new WeakMap<TItem[], WeakMap<Meta, ReplicaPagedData<TItem, TCursor>>>();\n const remember = (items: TItem[], meta: Meta, view: ReplicaPagedData<TItem, TCursor>) => {\n let byMeta = views.get(items);\n if (!byMeta) views.set(items, (byMeta = new WeakMap()));\n byMeta.set(meta, view);\n return view;\n };\n const rowsOf = (state: TStore) => (state[itemsField] ?? {}) as Record<string, TItem[]>;\n const metaOf = (state: TStore) => (state[metaField] ?? {}) as Record<string, Meta>;\n\n return {\n clear: () => ({ [itemsField]: {}, [metaField]: {}, ...clearDerived?.() }) as Partial<TStore>,\n get: (state, key) => {\n const items = rowsOf(state)[key];\n if (!items) return undefined;\n const meta = metaOf(state)[key] ?? NO_META;\n const cached = views.get(items)?.get(meta);\n if (cached) return cached;\n const view =\n meta === NO_META\n ? { currentPage: 0, hasMore: true, items, pageSize: items.length }\n : { ...meta, items };\n return remember(items, meta, view);\n },\n keys: (state) => Object.keys(rowsOf(state)),\n set: (state, key, data) => {\n const rows = { ...rowsOf(state) };\n const metas = { ...metaOf(state) };\n if (data === undefined) {\n delete rows[key];\n delete metas[key];\n } else {\n const { items, ...meta } = data;\n rows[key] = items;\n metas[key] = meta;\n remember(items, meta, data);\n }\n const next = { ...state, [itemsField]: rows, [metaField]: metas } as TStore;\n return {\n [itemsField]: rows,\n [metaField]: metas,\n ...derive?.(next, key, data?.items),\n } as Partial<TStore>;\n },\n };\n};\n","import { replicaKeys } from '../core/resource/queryKeys';\nimport { stableQueryKey } from '../core/resource/storageKey';\nimport type { ReplicaResource } from '../core/resource/types';\n\n/** One head request, from `begin` to `settle`. */\nexport interface ReplicaRequest<TParams, TFetched> {\n /** `clear()` generation of `scope` when the request started. */\n clearGeneration: number;\n key: string;\n params: TParams;\n /** Settles the request; set once it is sent. */\n promise?: Promise<TFetched>;\n /** Row key of the query it asks for (`headQuery` guard). */\n queryKey: string;\n /** `remove` generation of `scope` + `key` when the request started. */\n removalGeneration: number;\n /** Scope + sync key: requests of the same id dedupe and supersede each other. */\n requestId: string;\n /** The cache scope it was captured under. */\n scope: string;\n /** Start order across the slice. */\n seq: number;\n /** What it settled to; a request is settled once (a joined fetch and its sync share it). */\n settled?: ReplicaSettled;\n /** The response (or failure) it settled with. */\n settledWith?: unknown;\n source: 'fetch' | 'sync';\n syncKey: readonly unknown[];\n}\n\nexport interface ReplicaSettled {\n /** Settled into the active scope (folded in or removed as missing). */\n applied: boolean;\n /** Passed every guard; `isActive` says whether its scope is still on screen. */\n current: boolean;\n isActive: boolean;\n missing: boolean;\n}\n\nexport type ReplicaOutcome<TFetched> = { data: TFetched } | { error: unknown };\n\nexport interface ReplicaRequestsDeps<TParams, TFetched> {\n clearGeneration: (scope: string) => number;\n fetcher?: (params: TParams, cursor?: any) => Promise<TFetched>;\n /** Fold a request's outcome into the replica (guards, `applyHead`, effects); runs once. */\n fold: (\n request: ReplicaRequest<TParams, TFetched>,\n outcome: ReplicaOutcome<TFetched>,\n ) => ReplicaSettled;\n removalGeneration: (scope: string, key: string) => number;\n resource: ReplicaResource<TParams, any, TFetched>;\n}\n\n/** Request id of a sync key in a scope: requests of the same id dedupe and supersede. */\nexport const replicaRequestId = (scope: string, syncKey: unknown) =>\n `${scope}\\u0000${stableQueryKey(syncKey)}`;\n\n/**\n * Lifecycle bookkeeping of head requests: capture (`begin`), send and track in\n * flight, retire older requests once a newer response applied, and settle each\n * request exactly once. What settling *does* is the slice's `fold`; this only\n * guarantees every path — a mounted `useSync`, or `fetch` with no view mounted\n * — goes through it the same way.\n */\nexport const createRequests = <TParams, TFetched>({\n clearGeneration,\n fetcher,\n fold,\n removalGeneration,\n resource,\n}: ReplicaRequestsDeps<TParams, TFetched>) => {\n let requestSeq = 0;\n\n /**\n * Requests whose start order is at or below this number are retired, per\n * request id: a newer response already applied, or a `fresh` fetch started\n * after them. Their late response would repaint an older value.\n *\n * An entry only matters while a request it retires is still unsettled:\n * every request started later has a higher sequence, so a missing entry\n * (read as 0) admits exactly the same responses. Entries are pruned as soon\n * as no sent request at or below them is pending, so the map holds only the\n * ids with an older request in flight.\n */\n const retiredThrough = new Map<string, number>();\n /** Sequences of the sent requests that have not settled yet, per request id. */\n const unsettled = new Map<string, Set<number>>();\n\n /** Drop the retirement of `requestId` once no request it retires is pending. */\n const pruneRetired = (requestId: string) => {\n const through = retiredThrough.get(requestId);\n if (through === undefined) return;\n for (const seq of unsettled.get(requestId) ?? []) if (seq <= through) return;\n retiredThrough.delete(requestId);\n };\n\n const retire = (requestId: string, seq: number) => {\n if ((retiredThrough.get(requestId) ?? 0) < seq) retiredThrough.set(requestId, seq);\n // Every entry is pruned the moment it stops retiring a pending request\n // (here, and as each request settles), so the map stays bounded by the ids\n // with an older request in flight — no size backstop needed. Evicting an\n // entry that still retires one would let its stale response win.\n pruneRetired(requestId);\n };\n\n /** A newer response already applied, or a `fresh` fetch started after it. */\n const isRetired = ({ requestId, seq }: ReplicaRequest<TParams, TFetched>) =>\n seq <= (retiredThrough.get(requestId) ?? 0);\n\n const trackUnsettled = ({ requestId, seq }: ReplicaRequest<TParams, TFetched>) => {\n const pending = unsettled.get(requestId) ?? new Set<number>();\n unsettled.set(requestId, pending);\n pending.add(seq);\n };\n\n const untrackUnsettled = ({ requestId, seq }: ReplicaRequest<TParams, TFetched>) => {\n const pending = unsettled.get(requestId);\n if (!pending?.delete(seq)) return;\n if (pending.size === 0) unsettled.delete(requestId);\n pruneRetired(requestId);\n };\n\n /** The latest request in flight per id, which a non-`fresh` `fetch` joins. */\n const inflight = new Map<string, ReplicaRequest<TParams, TFetched>>();\n /** `useSync` requests in flight per id (`fetch` seeds the driver only while none is). */\n const syncInflight = new Map<string, number>();\n\n /**\n * `useSync` request each sync id last started, settled by the driver's\n * callback. The driver settles one request per key at a time (a newer\n * request supersedes the older one), so the latest start is the one that\n * settles.\n */\n const syncRequests = new Map<string, ReplicaRequest<TParams, TFetched>>();\n\n const syncKeyOf = (params: TParams, scope: string, key: string) =>\n resource.syncKey?.(params) ??\n replicaKeys.sync(resource.name, resource.version, scope, key, params);\n\n /** Capture what a head request for `params` started under. */\n const begin = (\n params: TParams,\n scope: string,\n source: 'fetch' | 'sync',\n ): ReplicaRequest<TParams, TFetched> => {\n const key = resource.key(params);\n const syncKey = syncKeyOf(params, scope, key);\n return {\n clearGeneration: clearGeneration(scope),\n key,\n params,\n queryKey: resource.storageKey(params),\n removalGeneration: removalGeneration(scope, key),\n requestId: replicaRequestId(scope, syncKey),\n scope,\n seq: ++requestSeq,\n source,\n syncKey,\n };\n };\n\n /** Send the request and track it in flight until it settles. */\n const send = (request: ReplicaRequest<TParams, TFetched>) => {\n const { requestId } = request;\n const sent = fetcher!(request.params, undefined);\n // Tolerate a fetcher (a test double) that does not return a promise: the\n // driver still gets exactly what it returned.\n const promise = Promise.resolve(sent);\n request.promise = promise;\n // Until it settles, a retirement that covers it must be kept.\n trackUnsettled(request);\n inflight.set(requestId, request);\n if (request.source === 'sync')\n syncInflight.set(requestId, (syncInflight.get(requestId) ?? 0) + 1);\n const done = () => {\n if (inflight.get(requestId) === request) inflight.delete(requestId);\n if (request.source !== 'sync') return;\n // Incremented before this `done` was attached, so the count is present.\n const count = syncInflight.get(requestId)! - 1;\n if (count > 0) syncInflight.set(requestId, count);\n else syncInflight.delete(requestId);\n };\n promise.then(done, done);\n // A `useSync` request settles on its own response, not only through the\n // driver's callback: a driver skips that callback once the hook moved to\n // another key, unmounted, or a newer request superseded it — and the\n // resource effects (a terminal absence, a purge) must not depend on which\n // view is still mounted. The callback then reads the memoized result.\n if (request.source === 'sync')\n promise.then(\n (data) => settle(request, { data }),\n (error: unknown) => settle(request, { error }),\n );\n return sent;\n };\n\n /**\n * Fold a settled head request into the replica (see the slice's `fold`). A\n * request is settled once: a `fetch` that joined a `useSync` request shares\n * its result.\n */\n const settle = (\n request: ReplicaRequest<TParams, TFetched>,\n outcome: ReplicaOutcome<TFetched>,\n ): ReplicaSettled => {\n if (request.settled) return request.settled;\n request.settledWith = 'error' in outcome ? outcome.error : outcome.data;\n request.settled = fold(request, outcome);\n untrackUnsettled(request);\n if (request.source === 'sync') releaseSyncRequest(request);\n return request.settled;\n };\n\n /**\n * Drop a settled `useSync` request's record once its driver callback had its\n * chance to read it. The record is normally taken by that callback, but the\n * driver skips it when the hook changed key, unmounted or was superseded —\n * the request still settles on its own promise, and its record (holding the\n * response) must not stay behind. A driver runs the callback in the same\n * turn the response resolves (SWR does, in microtasks), so a macrotask later\n * the record is only a leak.\n */\n const releaseSyncRequest = (request: ReplicaRequest<TParams, TFetched>) => {\n setTimeout(() => {\n if (syncRequests.get(request.requestId) === request) syncRequests.delete(request.requestId);\n }, 0);\n };\n\n /** Start a `useSync` request and record it for the driver's callback. */\n const startSync = (params: TParams, scope: string) => {\n const request = begin(params, scope, 'sync');\n syncRequests.set(request.requestId, request);\n return send(request);\n };\n\n /**\n * The `useSync` request settling now: the one `requestId` last started. A\n * response the driver settles without having run our fetcher (a\n * cache-shared key), or one the recorded request did not produce, is\n * captured now: nothing it started under is known.\n */\n const takeSyncRequest = (\n requestId: string,\n response: unknown,\n params: TParams,\n scope: string,\n ): ReplicaRequest<TParams, TFetched> => {\n const request = syncRequests.get(requestId);\n syncRequests.delete(requestId);\n // The request already settled on its own response, and the driver now\n // reports that same response: read the memoized result.\n if (request && (!request.settled || request.settledWith === response)) return request;\n return begin(params, scope, 'sync');\n };\n\n return {\n begin,\n /** Records of `useSync` requests not yet released (leak checks in tests). */\n debugPendingSyncRequests: () => syncRequests.size,\n /** Retirement records still held (leak checks in tests). */\n debugRetiredRequests: () => retiredThrough.size,\n /** The request in flight for `requestId`, which a non-`fresh` `fetch` joins. */\n inflight: (requestId: string) => inflight.get(requestId),\n isRetired,\n /** Whether a `useSync` request of `requestId` is in flight. */\n isSyncInflight: (requestId: string) => syncInflight.has(requestId),\n retire,\n send,\n settle,\n startSync,\n syncKeyOf,\n takeSyncRequest,\n };\n};\n\nexport type ReplicaRequests<TParams, TFetched> = ReturnType<\n typeof createRequests<TParams, TFetched>\n>;\n","import { useEffect, useLayoutEffect, useRef } from 'react';\n\nimport type { ReplicaEngine } from '../core/engine/createReplicaEngine';\nimport { replicaKeys } from '../core/resource/queryKeys';\nimport type { ReplicaResource } from '../core/resource/types';\nimport type { ReplicaSyncDriver } from './driver';\nimport type { ReplicaLens } from './lens';\nimport { replicaRequestId, type ReplicaRequests } from './requests';\nimport type { ReplicaSyncOptions, ReplicaSyncResult } from './types';\n\nexport interface ReplicaUseSyncDeps<TStore, TParams, TData, TFetched> {\n driver: ReplicaSyncDriver;\n engine: Pick<\n ReplicaEngine<TParams, TData, TFetched>,\n 'ensureScope' | 'fetcher' | 'hydrate' | 'retain'\n >;\n get: () => TStore;\n /** Row key of the head query each entry last asked for (see the slice's `headQuery`). */\n headQuery: Map<string, string>;\n requests: ReplicaRequests<TParams, TFetched>;\n resource: ReplicaResource<TParams, TData, TFetched>;\n view: ReplicaLens<TStore, TData>;\n}\n\n/**\n * Hydrates the persisted row once per scope/key/query, then lets the driver\n * fetch and revalidate the head. Read the data from the store.\n */\nexport const createUseSync = <TStore, TParams, TData, TFetched>({\n driver,\n engine: { ensureScope, fetcher, hydrate, retain },\n get,\n headQuery,\n requests,\n resource,\n view,\n}: ReplicaUseSyncDeps<TStore, TParams, TData, TFetched>) => {\n /**\n * Entries (`scope` + key) whose one-shot hydration actually painted a\n * persisted row. The hydration query settles `true` on a cache miss too, so\n * a reactivated key may only replay `onHydrated` when its row really came from\n * storage — not off a provisional seed or another source that happens to sit\n * in the view.\n */\n const hydratedEntries = new Set<string>();\n const hydratedEntryId = (scope: string, key: string) => `${scope}\\u0000${key}`;\n\n const useSync = (\n params: TParams | null | undefined,\n {\n enabled = true,\n onError,\n onHydrated,\n onSuccess,\n ...schedule\n }: ReplicaSyncOptions<TFetched, TData> = {},\n ): ReplicaSyncResult => {\n const scope = resource.scope.use();\n const key = params ? resource.key(params) : undefined;\n const active = enabled && !!params && key !== undefined;\n const queryKey = params ? resource.storageKey(params) : undefined;\n\n // Layout effect: runs before paint, so a scope switch never shows a frame\n // of the previous identity's data.\n useLayoutEffect(() => {\n if (active) ensureScope(scope);\n }, [active, scope]);\n\n // Remember the query this entry now asks for (see `headQuery`).\n useLayoutEffect(() => {\n if (active && key !== undefined && queryKey !== undefined) headQuery.set(key, queryKey);\n }, [active, key, queryKey]);\n\n // Pin the entry on screen against eviction (`persist.maxEntries`).\n useLayoutEffect(() => {\n if (active && key !== undefined && queryKey !== undefined) return retain(key, queryKey);\n }, [active, key, queryKey]);\n\n // The entry this hook currently asks for. A hydrate that resolves after the\n // hook moved to another key still fills its own cache entry (harmless), but\n // it must not replay `onHydrated`: those side effects act on the *current*\n // view, so they would re-adopt a superseded object (e.g. navigate g1 → g2\n // while g1's storage read is slow, then g1's hydrate resets the active group\n // back to g1's supervisor).\n const activeKey = useRef<string | undefined>(undefined);\n // The entry (scope + key) whose hydration side effects already ran in its\n // current activation. Reset on every key or scope change, so an entry that\n // comes back (g1 → g2 → g1, or scope A → B → A with the same key) gets them\n // replayed even though its `once` hydration is not re-run.\n const hydratedKey = useRef<string | undefined>(undefined);\n useLayoutEffect(() => {\n activeKey.current = active ? key : undefined;\n hydratedKey.current = undefined;\n }, [active, key, scope]);\n\n const hydration = driver.useQuery<boolean>(\n active && resource.persisted && resource.persistKey(key!)\n ? replicaKeys.hydrate(resource.name, resource.version, scope, resource.storageKey(params!))\n : null,\n async () => {\n const didHydrate = await hydrate(params!, scope);\n if (didHydrate) hydratedEntries.add(hydratedEntryId(scope, key!));\n // A superseded key (the hook moved on) runs no side effects at all.\n if (activeKey.current !== key) return true;\n hydratedKey.current = hydratedEntryId(scope, key!);\n // A persisted row is a successful value with no network response: replay\n // the success side effects off it, so an offline / slow first paint still\n // adopts the roster and settles the flags the response path would.\n if (didHydrate) {\n const hydrated = view.get(get(), key!);\n if (hydrated !== undefined) onHydrated?.(hydrated);\n }\n return true;\n },\n { once: true },\n );\n\n // Reactivating a key whose hydration already settled: the driver reuses the\n // `once` result and the fetcher above does not run, so replay the side\n // effects off the value in memory. Without it, returning to g1 offline\n // leaves them acting on g2 until a response lands (or forever, offline).\n useEffect(() => {\n if (!active || key === undefined || hydration.data !== true) return;\n const entry = hydratedEntryId(scope, key);\n if (hydratedKey.current === entry) return;\n hydratedKey.current = entry;\n const current = view.get(get(), key);\n if (current !== undefined) {\n // A lookup that missed storage hydrated nothing to replay.\n if (hydratedEntries.has(entry)) onHydrated?.(current);\n return;\n }\n // Back on a scope whose view `ensureScope` cleared (A → B → A): the cached\n // `once` result means the fetcher will not read storage again, so read it\n // here — otherwise a row still in A's partition stays off screen while\n // the network is slow or offline.\n void hydrate(params!, scope).then((didHydrate) => {\n if (!didHydrate) return;\n hydratedEntries.add(entry);\n if (activeKey.current !== key || resource.scope.get() !== scope) return;\n const hydrated = view.get(get(), key);\n if (hydrated !== undefined) onHydrated?.(hydrated);\n });\n }, [active, key, scope, hydration.data]);\n\n const syncKey = active && fetcher ? requests.syncKeyOf(params!, scope, key!) : null;\n /** The request settling now: the one this sync id last started. */\n const takeRequest = (response: unknown) =>\n requests.takeSyncRequest(replicaRequestId(scope, syncKey), response, params!, scope);\n\n const sync = driver.useQuery<TFetched>(\n syncKey,\n () => requests.startSync(params!, scope),\n {\n ...schedule,\n // Always installed, so a failed request is settled (and its capture\n // dropped) even when the caller has no error side effects. A failure\n // of a query the entry has moved past (or of a request from before a\n // clear) never surfaces on the query now on screen; an off-scope one is\n // still forwarded with the captured key/scope (see `settle`).\n onError: (error: unknown) => {\n const settled = requests.settle(takeRequest(error), { error });\n if (settled.current) onError?.(error, { isActive: settled.isActive, key, scope });\n },\n onSuccess: (data) => {\n const settled = requests.settle(takeRequest(data), { data });\n if (!settled.current || !settled.isActive) return;\n // A key the hook has moved past (g1 → g2 while g1's request is in\n // flight): the response still fills g1's own entry, but this view's\n // side effects would re-adopt g1 over the g2 view.\n if (activeKey.current !== key) return;\n onSuccess?.(data);\n },\n },\n );\n\n return {\n error: sync.error,\n // A read with no key has nothing to hydrate, so it counts as hydrated: a\n // conditional consumer must not show \"loading\" for an absent id (the\n // former SWR disabled-key behaviour). A read that is disabled but *has* a\n // key still waits for its persisted row.\n isHydrated:\n key === undefined ||\n !resource.persisted ||\n !resource.persistKey(key) ||\n hydration.data === true,\n isValidating: sync.isValidating,\n revalidate: () => sync.mutate(),\n };\n };\n\n return useSync;\n};\n","import { createReplicaEngine } from '../core/engine/createReplicaEngine';\nimport { isReplicaSyncKey } from '../core/resource/queryKeys';\nimport type { ReplicaResource } from '../core/resource/types';\nimport type { ReplicaState } from '../core/state/types';\nimport { ReplicaScopeChangedError } from './errors';\nimport { applyLensWrites } from './lens';\nimport {\n createRequests,\n type ReplicaOutcome,\n type ReplicaRequest,\n type ReplicaSettled,\n} from './requests';\nimport type {\n CreateReplicaSliceOptions,\n ReplicaFetchOptions,\n ReplicaFetchResult,\n ReplicaSettleContext,\n} from './types';\nimport { createUseSync } from './useSync';\n\n/**\n * Bind a replica to a domain Zustand store.\n *\n * The domain store stays the only UI source of truth: components read the\n * `view` location through their usual selectors. The engine owns every\n * transition of that location; this slice adds the Zustand port and a\n * `useSync` hook that only orchestrates fetching — data never flows through\n * its return value.\n */\nexport const createReplicaSlice = <TStore, TParams, TData, TFetched = TData>(\n resource: ReplicaResource<TParams, TData, TFetched>,\n {\n driver,\n effects,\n get,\n set,\n stateKey,\n view,\n ...options\n }: CreateReplicaSliceOptions<TStore, TParams, TData, TFetched>,\n) => {\n const revalidate = (key?: string) =>\n driver.revalidate((queryKey) =>\n isReplicaSyncKey(queryKey, resource.name, { key, scope: resource.scope.get() }),\n );\n\n const engine = createReplicaEngine(resource, {\n ...options,\n port: {\n // One host update for the view writes and the bookkeeping slot together.\n commit: (writes, state, label) =>\n set(\n { ...applyLensWrites(view, get(), writes), [stateKey]: state } as Partial<TStore>,\n false,\n label,\n ),\n getState: () => get()[stateKey] as unknown as ReplicaState<TData>,\n keys: view.keys && (() => view.keys!(get())),\n read: (key) => view.get(get(), key),\n },\n revalidate,\n });\n const { applyHead, clearGeneration, fetcher, isMissingHead, removalGeneration } = engine;\n\n /**\n * Row key of the head query each entry last asked for. One entry backs a\n * single view, so a response that belongs to a query the entry has already\n * moved past must not repaint it — it would show a superseded query, and a\n * view gated on the current query would then wait forever. Making this an\n * invariant of the slice (not of one driver) keeps it true for drivers that\n * leave a request's own callback live after the query changed.\n */\n const headQuery = new Map<string, string>();\n\n // ---- one settle pipeline ---------------------------------------------\n // Every head response — a mounted `useSync`, or `fetch` with no view mounted\n // — is captured by `begin` when its request starts and folded in by `fold`\n // when it settles (see `requests.ts`), so every path applies the same guards\n // and the same resource effects.\n\n const requests = createRequests<TParams, TFetched>({\n clearGeneration,\n fetcher,\n fold: (request, outcome) => fold(request, outcome),\n removalGeneration,\n resource,\n });\n\n /**\n * Whether the request still answers the query its entry shows. A `useSync`\n * request must match the query the entry last asked for (see `headQuery`). A\n * `fetch` may also land with no view mounted, but never over a view that\n * shows another query of the key.\n */\n const isActiveQuery = ({ key, queryKey, source }: ReplicaRequest<TParams, TFetched>) => {\n const head = headQuery.get(key);\n return source === 'sync' ? head === queryKey : head === undefined || head === queryKey;\n };\n\n /**\n * Fold a settled head request into the replica: apply every guard, then\n * `replace` / remove a terminal absence (in the captured scope) / purge one\n * captured off-scope, then run the resource effects.\n */\n const fold = (\n request: ReplicaRequest<TParams, TFetched>,\n outcome: ReplicaOutcome<TFetched>,\n ): ReplicaSettled => {\n const { key, params, scope } = request;\n /**\n * Whether the entry is still on the scope the request was captured under.\n *\n * A response can settle after an identity/workspace switch. `replace` drops\n * it — the engine refuses off-scope actions — but no side effect may run\n * either: it carries the previous identity's state (onboarding redirect,\n * display snapshot, analytics identify, settings writes). The captured\n * key/scope is not enough on its own, because a switch to a scope that\n * reads the same key/query satisfies the other guards.\n */\n const isActive = resource.scope.get() === scope;\n const dropped: ReplicaSettled = { applied: false, current: false, isActive, missing: false };\n const context: ReplicaSettleContext = { key, scope, source: request.source };\n\n // Discard a response that started before a `clear()` of its scope or a\n // `remove` of its entry (it holds wiped data), one for a query the entry\n // has moved past (the newer query owns the view, see `headQuery`), and one\n // older than a response already applied or a `fresh` fetch.\n const discarded =\n request.clearGeneration !== clearGeneration(scope) ||\n request.removalGeneration !== removalGeneration(scope, key) ||\n requests.isRetired(request) ||\n !isActiveQuery(request);\n\n // Every successful response settles the request — discarded, off-scope or\n // dropped by `prepareHead` below — so per-request bookkeeping is released.\n if (!('error' in outcome)) effects?.onSettled?.(outcome.data, params, context);\n if (discarded) return dropped;\n\n if ('error' in outcome) {\n // Forwarded even off-scope: the captured `key`/`scope` lets a consumer\n // purge that scope's row (a late NOT_FOUND / FORBIDDEN) without touching\n // the scope on screen; `isActive` tells it whether the failure also\n // belongs to the view on screen.\n effects?.onError?.(outcome.error, params, { isActive, key, scope });\n return { ...dropped, current: true };\n }\n\n if (!isActive) {\n // Off-scope: never fold a value into the scope on screen. A terminal\n // absence still purges its own scope's row (`remove` routes it there), so\n // a switch back cannot hydrate a value the server no longer has.\n const missing = isMissingHead(outcome.data);\n if (missing) {\n remove(key, scope);\n effects?.onPurge?.(params, context);\n }\n return { ...dropped, current: true, missing };\n }\n\n requests.retire(request.requestId, request.seq - 1);\n const { outcome: folded } = applyHead(params, outcome.data, scope);\n if (folded === 'replaced') effects?.onApplied?.(outcome.data, params, context);\n else if (folded === 'missing') effects?.onMissing?.(params, context);\n return {\n applied: folded !== 'dropped',\n current: true,\n isActive,\n missing: folded === 'missing',\n };\n };\n\n /**\n * Fetch the head of an entry now and settle it like a `useSync` response —\n * for stores that need the data while no view is mounted (a hover prefetch,\n * a refresh after a mutation on a route without the list). `revalidate` only\n * re-runs mounted queries.\n *\n * Joins a request in flight for the same entry unless `fresh`. Resolves with\n * what was fetched and whether it was applied; rejects when the request\n * fails, after routing the failure through `effects.onError`.\n */\n const fetch = async (\n params: TParams,\n { fresh = false, scope = resource.scope.get() }: ReplicaFetchOptions = {},\n ): Promise<ReplicaFetchResult<TFetched>> => {\n if (!fetcher) throw new Error(`[replica] ${resource.name} has no fetcher`);\n if (scope !== resource.scope.get()) throw new ReplicaScopeChangedError(resource.name, scope);\n let request = requests.begin(params, scope, 'fetch');\n const joined = fresh ? undefined : requests.inflight(request.requestId);\n if (joined) request = joined;\n else {\n if (fresh) requests.retire(request.requestId, request.seq - 1);\n requests.send(request);\n }\n\n let data: TFetched;\n try {\n data = await request.promise!;\n } catch (error) {\n requests.settle(request, { error });\n throw error;\n }\n const settled = requests.settle(request, { data });\n // Seed the driver's cache with what this fetch applied, unless a `useSync`\n // request of the key is in flight: it fills the cache itself, and a write\n // overtaking it could make the driver discard its newer response.\n if (\n settled.applied &&\n request.source === 'fetch' &&\n !requests.isSyncInflight(request.requestId)\n )\n driver.seed?.(request.syncKey, data);\n return { applied: settled.applied, data, missing: settled.missing };\n };\n\n /**\n * Forget the key's head query as well as the entry. A `useSync` request\n * already in flight for it would otherwise pass the settle guards (the\n * marker still matches), `replace` the entry and persist pre-mutation rows\n * back over an invalidation that has already completed. (The engine's\n * removal generation drops a `fetch` captured before the removal.)\n */\n const remove = (key: string, scope?: string) => {\n // Only the active scope's marker belongs to the entry on screen; an\n // off-scope removal (see `engine.remove`) must not drop it.\n if (scope === undefined || scope === resource.scope.get()) headQuery.delete(key);\n return engine.remove(key, scope);\n };\n\n const useSync = createUseSync<TStore, TParams, TData, TFetched>({\n driver,\n engine,\n get,\n headQuery,\n requests,\n resource,\n view,\n });\n\n return {\n ...engine,\n /** @internal Records of `useSync` requests not yet released (leak checks in tests). */\n debugPendingSyncRequests: requests.debugPendingSyncRequests,\n /** @internal Retirement records still held (leak checks in tests). */\n debugRetiredRequests: requests.debugRetiredRequests,\n fetch,\n remove,\n useSync,\n };\n};\n\nexport type ReplicaSlice<TStore, TParams, TData, TFetched = TData> = ReturnType<\n typeof createReplicaSlice<TStore, TParams, TData, TFetched>\n>;\n","/**\n * Scheduling knobs a sync can ask its driver for. Named after SWR's options;\n * a driver maps them onto its own cache (or ignores what it cannot honor).\n */\nexport interface ReplicaSyncSchedule {\n /** Collapse identical requests within this window (ms). */\n dedupingInterval?: number;\n /** Skip focus revalidation while the last one is younger than this (ms). */\n focusThrottleInterval?: number;\n /**\n * Hold every network request (mount, stale, focus, revalidate) while it\n * returns `true`, for a read whose input outside the sync key is not ready\n * yet. Unlike `revalidateOnMount: false` it also covers a later key change.\n * Read on each request, so it may close over a ref.\n */\n isPaused?: () => boolean;\n /** Poll every N ms while mounted (0 = off); a function decides per tick from the latest data. */\n refreshInterval?: number | ((latest: any) => number);\n /** Keep polling while the page is hidden. */\n refreshWhenHidden?: boolean;\n /** Keep polling while the browser is offline. */\n refreshWhenOffline?: boolean;\n revalidateIfStale?: boolean;\n revalidateOnFocus?: boolean;\n /**\n * Whether the first mount fetches. `false` leaves the first request to an\n * explicit `revalidate`, for a read whose input outside the sync key is not\n * ready yet. SWR only honors it on the first mount: a later key change still\n * fetches when the new key has no cached data.\n */\n revalidateOnMount?: boolean;\n revalidateOnReconnect?: boolean;\n}\n\nexport interface ReplicaQueryOptions<T> extends ReplicaSyncSchedule {\n /** Run once per key: no refetch on focus, reconnect or remount (hydration reads). */\n once?: boolean;\n onError?: (error: unknown) => void;\n onSuccess?: (data: T) => void;\n}\n\nexport interface ReplicaQueryResult<T> {\n data?: T;\n error?: unknown;\n isValidating: boolean;\n /** Re-run this query. */\n mutate: () => Promise<unknown>;\n}\n\n/**\n * The query cache that schedules a replica's fetches: dedupe, focus /\n * reconnect revalidation, retries. The replica decides what a response means;\n * the driver only decides when to ask. `createSWRDriver` adapts SWR.\n */\nexport interface ReplicaSyncDriver {\n /** Re-run every mounted query whose key matches. */\n revalidate: (match: (key: unknown) => boolean) => Promise<unknown>;\n /**\n * Write a head response that `slice.fetch` requested outside `useQuery` into\n * the query cache under its sync key, without re-running the query — so a\n * `useQuery` of that key sees the latest data (a `revalidateIfStale: false`\n * mount does not refetch it). Optional: a driver without a cache of its own\n * leaves it out; requests still dedupe in the slice.\n *\n * The slice calls it only while no `useQuery` request of the key is in\n * flight. A cache that discards in-flight requests a write overtakes (SWR\n * does) would otherwise drop a newer response than the one written.\n */\n seed?: (key: readonly unknown[], data: unknown) => void;\n useQuery: <T>(\n key: readonly unknown[] | null,\n fetcher: () => Promise<T>,\n options: ReplicaQueryOptions<T>,\n ) => ReplicaQueryResult<T>;\n}\n\ntype SWRLikeHook = (\n key: any,\n fetcher: any,\n config?: any,\n) => { data?: any; error?: unknown; isValidating: boolean; mutate: () => Promise<any> };\n\n/**\n * Driver over SWR. Pass the app's own hook and scoped `mutate` when it wraps\n * SWR (custom cache provider, key augmentation, retry policy).\n */\nexport const createSWRDriver = ({\n mutate,\n useSWR,\n}: {\n /**\n * SWR's (scoped) `mutate`. Called as `mutate(match)` to revalidate, and as\n * `mutate(key, data, { revalidate: false })` to seed — pass the arguments\n * through unchanged: SWR tells a revalidation from a write by their count.\n */\n mutate: (\n ...args: [match: (key: any) => boolean] | [key: unknown, data: unknown, options: object]\n ) => Promise<unknown>;\n useSWR: SWRLikeHook;\n}): ReplicaSyncDriver => ({\n revalidate: (match) => mutate(match),\n // Populate the cache only: re-running the query would refetch what was just fetched.\n seed: (key, data) => {\n void mutate(key, data, { revalidate: false }).catch(() => undefined);\n },\n useQuery: (key, fetcher, { once, onError, onSuccess, ...schedule }) =>\n useSWR(key, fetcher, {\n ...Object.fromEntries(Object.entries(schedule).filter(([, value]) => value !== undefined)),\n ...(once && {\n revalidateIfStale: false,\n revalidateOnFocus: false,\n revalidateOnReconnect: false,\n }),\n ...(onError && { onError: (error: unknown) => onError(error) }),\n ...(onSuccess && { onSuccess: (data: unknown) => onSuccess(data as never) }),\n }),\n});\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-replica",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Local-first replicas: paint the last known state on the first frame, reconcile with the server in the background",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"local-first",
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsdown",
|
|
45
|
+
"docs:build": "lobedocs build",
|
|
46
|
+
"docs:dev": "lobedocs dev",
|
|
45
47
|
"prepare": "husky",
|
|
46
48
|
"prepublishOnly": "bun run build",
|
|
47
49
|
"release": "semantic-release",
|
|
@@ -55,7 +57,9 @@
|
|
|
55
57
|
"devDependencies": {
|
|
56
58
|
"@commitlint/cli": "^21.2.4",
|
|
57
59
|
"@lobehub/commitlint-config": "^2.1.7",
|
|
60
|
+
"@lobehub/docs-kit": "^6.3.1",
|
|
58
61
|
"@lobehub/semantic-release-config": "^2.1.7",
|
|
62
|
+
"@react-router/dev": "^8.5.0",
|
|
59
63
|
"@testing-library/react": "^16.3.3",
|
|
60
64
|
"@types/react": "^19.3.0",
|
|
61
65
|
"@vitest/coverage-v8": "^5.0.5",
|
|
@@ -63,10 +67,14 @@
|
|
|
63
67
|
"happy-dom": "^21.0.4",
|
|
64
68
|
"husky": "^9.1.7",
|
|
65
69
|
"react": "^19.3.1",
|
|
70
|
+
"react-dom": "^19.3.1",
|
|
71
|
+
"react-router": "^8.5.0",
|
|
66
72
|
"semantic-release": "^25.0.9",
|
|
67
73
|
"swr": "^2.5.1",
|
|
68
74
|
"tsdown": "^0.23.1",
|
|
75
|
+
"tsx": "^4.23.15",
|
|
69
76
|
"typescript": "^7.0.2",
|
|
77
|
+
"vite": "^8.3.5",
|
|
70
78
|
"vitest": "^5.0.5",
|
|
71
79
|
"zustand": "^5.0.16"
|
|
72
80
|
},
|