@nkzw/fate-indexeddb 1.16.1 → 2.0.1
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 +180 -7
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1629,6 +1629,31 @@ Mutations are delivered in the order they were saved, one at a time per persiste
|
|
|
1629
1629
|
|
|
1630
1630
|
The mutation promise stays pending until confirmation, which can take until the user is back online. After a reload, use [Persistence State](#persistence-state) to show pending or failed changes.
|
|
1631
1631
|
|
|
1632
|
+
We can add client fields or change computations without invalidating saved server mutations. Their recovery data contains server fields; saved client fields use [migrations](/docs/guide/client-state.md#migrations). If the server schema scope changes or a mutation is removed, fate keeps the mutation's input and marks it as failed.
|
|
1633
|
+
|
|
1634
|
+
#### Waiting for Local Persistence
|
|
1635
|
+
|
|
1636
|
+
Let's say we want to close our post editor once the change is saved on the device, even while offline. Each mutation promise has a `locallyPersisted` promise that we can wait for:
|
|
1637
|
+
|
|
1638
|
+
```tsx
|
|
1639
|
+
const pending = fate.mutations.post.edit({
|
|
1640
|
+
input: { id: post.id, title },
|
|
1641
|
+
optimistic: { title },
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
void reportResult(pending);
|
|
1645
|
+
await pending.locallyPersisted;
|
|
1646
|
+
closeEditor();
|
|
1647
|
+
```
|
|
1648
|
+
|
|
1649
|
+
Our editor closes after fate saves the mutation and the data needed to restore it after a reload. If the mutation is still waiting to be sent, it is also available in the [persistence state](#persistence-state) at this point. Awaiting `pending` waits for the server response instead.
|
|
1650
|
+
|
|
1651
|
+
The server can still reject the change after it was saved locally. Replace `reportResult` with your app's handler for the eventual result, including returned errors and rejected promises. A later rejection doesn't change the resolved `locallyPersisted` promise.
|
|
1652
|
+
|
|
1653
|
+
For [client mutations](/docs/guide/client-state.md#reading-and-updating-state), `locallyPersisted` waits for the client data to be saved. The same property is available when calling `fate.actions` directly, including actions bound with `.with()`.
|
|
1654
|
+
|
|
1655
|
+
If validation or storage fails, `locallyPersisted` rejects, so our editor can stay open and show the error. It also rejects if the mutation isn't saved: when using `persist: false`, without persistence configured, or for a server mutation without durable delivery. Each mutation is saved separately; waiting for one save doesn't combine it with a later mutation.
|
|
1656
|
+
|
|
1632
1657
|
#### Skipping Persistence
|
|
1633
1658
|
|
|
1634
1659
|
For a call that should not be saved or retried, pass `persist: false`:
|
|
@@ -1683,7 +1708,35 @@ const server = createFateServer({
|
|
|
1683
1708
|
|
|
1684
1709
|
Your app supplies the database and receipt helpers. Lock each `(scope, id)` across server processes, and use `ctx.db` for mutation writes so they share the receipt's transaction. See the [complete SQLite example](https://github.com/nkzw-tech/fate/blob/main/example/persistence/server.ts).
|
|
1685
1710
|
|
|
1686
|
-
Keep receipts for as long as an offline client might retry. For effects outside the database, such as payments or emails, use the destination's idempotency support and a transactional outbox.
|
|
1711
|
+
Keep receipts for as long as an offline client might retry. For mutations with an expiry, we can clean up their receipts as described in [Receipt Retention](#receipt-retention). For effects outside the database, such as payments or emails, use the destination's idempotency support and a transactional outbox.
|
|
1712
|
+
|
|
1713
|
+
### Receipt Retention
|
|
1714
|
+
|
|
1715
|
+
Let's say a user likes a post, but closes the app before receiving the server response. When they return, fate retries the mutation with the same ID. The server reads its saved receipt and returns the result without adding another like.
|
|
1716
|
+
|
|
1717
|
+
By default, a queued mutation expires after **30 days**, including time spent offline. We can change this with `mutationMaxAge` on `createPersistence`. The server's `createMutationIdempotency` also defaults to 30 days through its `maxAge` option. To allow a longer lifetime, configure both sides.
|
|
1718
|
+
|
|
1719
|
+
The mutation ID includes its creation time and expiry. Retries and page reloads keep the same ID, so changing the configuration only affects new mutations. The server rejects mutations from a client clock more than five minutes ahead. A clock that is behind gives the mutation less time to reach the server.
|
|
1720
|
+
|
|
1721
|
+
#### Cleaning Up Receipts
|
|
1722
|
+
|
|
1723
|
+
Save the receipt's optional `expiresAt` timestamp with its result. We can delete expired receipts once every server handling these mutations enforces expiry. Allow some extra time for differences between server clocks. For example, a daily job could delete receipts that expired more than a day ago.
|
|
1724
|
+
|
|
1725
|
+
This is why mutations have a fixed deadline. If we deleted a receipt while its mutation could still be retried, the server would have no way to know that it had already applied the change. Rejecting expired mutations lets us clean up receipts without applying the same mutation again.
|
|
1726
|
+
|
|
1727
|
+
Native HTTP delivery uses protocol version 3 for expiring mutations. Older native servers reject these requests before executing them. Custom durable transports must also enforce expiry and preserve the `MUTATION_EXPIRED` error (HTTP 410).
|
|
1728
|
+
|
|
1729
|
+
#### Handling Expired Mutations
|
|
1730
|
+
|
|
1731
|
+
When a mutation expires, the server returns `MUTATION_EXPIRED`, even if its receipt has already been deleted. fate rolls back the optimistic update and keeps the input in persistence with `status: 'failed'` and `failureReason: 'expired'`.
|
|
1732
|
+
|
|
1733
|
+
For our like button, the server may have applied the like before the user closed the app. We should refresh the post before deciding whether to offer another attempt. fate doesn't assign a new ID or retry the expired mutation automatically. After handling the failure, call `session.discard(id)` to remove it from the queue.
|
|
1734
|
+
|
|
1735
|
+
When the receipt and database changes share a transaction, the server applies each mutation ID at most once within its lifetime. A mutation can still expire before reaching the server. Effects outside that transaction need their own idempotency support.
|
|
1736
|
+
|
|
1737
|
+
#### Existing Receipts
|
|
1738
|
+
|
|
1739
|
+
Older mutation IDs have no expiry. `createMutationIdempotency` accepts them by default, so their receipts need to stay saved. After retiring older clients, set `allowLegacyIdentities: false` to reject these mutations with the same expiry error. Once all server instances use that setting, their receipts can be deleted too.
|
|
1687
1740
|
|
|
1688
1741
|
### tRPC, GraphQL, and Custom Transports
|
|
1689
1742
|
|
|
@@ -1718,7 +1771,11 @@ return idempotency.execute({
|
|
|
1718
1771
|
|
|
1719
1772
|
Forward the entire identity on every attempt. When `identity.replayOnly` is true, the endpoint must return the saved result or a 404 without running the mutation. `createMutationIdempotency` handles this for you.
|
|
1720
1773
|
|
|
1721
|
-
|
|
1774
|
+
We can also send saved mutations through an existing GraphQL route. Set `durableMutations` to the supported mutation names without passing a custom `mutateDurably` adapter. fate sends each listed mutation separately, adding `@fateDurable` to the operation and its identity to `extensions.fateMutation`.
|
|
1775
|
+
|
|
1776
|
+
Set up the server before enabling this option. Declare `directive @fateDurable on MUTATION`, then wrap the single mutation with `createMutationIdempotency` in your execution middleware. For requests with `identity.replayOnly`, return the saved GraphQL result or a 404 without running the mutation. A server without the directive rejects the operation during GraphQL validation.
|
|
1777
|
+
|
|
1778
|
+
For a custom GraphQL adapter that supports only some mutations, pass `durableMutations: ['setLocale', 'updateSettings']` alongside `mutateDurably`. Other mutations send immediately. Explicit `persist: true` fails for an unsupported mutation, and previously queued calls remain saved if the adapter stops supporting them. Omitting the list means the adapter supports every mutation.
|
|
1722
1779
|
|
|
1723
1780
|
You can use persistence for cached reads and client state before adding a durable mutation adapter. Server mutations send immediately by default in that setup. Pass `persist: true` when a call must be saved before delivery. It fails before sending if persistence or the adapter is missing.
|
|
1724
1781
|
|
|
@@ -2096,6 +2153,122 @@ Use Vue refs or your application state library to track pending and error state.
|
|
|
2096
2153
|
|
|
2097
2154
|
Views, requests, caching, and mutations work the same way in both adapters. Vue composables return reactive resources; await `ready()` to use Suspense, or read their pending and error state. For mutations, use `fate.mutations` and Vue refs for local state instead of React Actions.
|
|
2098
2155
|
|
|
2156
|
+
## Devtools
|
|
2157
|
+
|
|
2158
|
+
_fate_ comes with devtools that show what your client is doing while you build your app. They run as a panel in [Vite DevTools](https://devtools.vite.dev/), next to the other tools for your app.
|
|
2159
|
+
|
|
2160
|
+
<p align="center">
|
|
2161
|
+
<img alt="The fate devtools panel in Vite DevTools" src="/public/devtools.png#gh-light-mode-only" width="100%">
|
|
2162
|
+
<img alt="The fate devtools panel in Vite DevTools" src="/public/devtools-dark.png#gh-dark-mode-only" width="100%">
|
|
2163
|
+
</p>
|
|
2164
|
+
|
|
2165
|
+
The panel shows:
|
|
2166
|
+
|
|
2167
|
+
- **Cache:** every normalized record, grouped by type. For each record you can see the fields fate has loaded, which fields are stale, which lists contain it, and which records refer to it. Click a reference to jump to that record.
|
|
2168
|
+
- **Requests:** the requests your screens keep alive, their roots and arguments, the fields they select, and how many components are observing them.
|
|
2169
|
+
- **Network:** every call fate makes through its transport (`byId`, `list`, `query`, mutations, and live subscriptions), with timing, selections, arguments, and responses or errors.
|
|
2170
|
+
- **Lists:** connection and list state, including items, cursors, and pagination.
|
|
2171
|
+
- **Changes:** a log of cache writes, including invalidations and optimistic updates.
|
|
2172
|
+
|
|
2173
|
+
From the panel you can invalidate or remove a record, log a record to the browser console (as `$fate`), and run garbage collection.
|
|
2174
|
+
|
|
2175
|
+
### Installation
|
|
2176
|
+
|
|
2177
|
+
Install the devtools and Vite DevTools as development dependencies of your client:
|
|
2178
|
+
|
|
2179
|
+
::: code-group
|
|
2180
|
+
|
|
2181
|
+
```bash [npm]
|
|
2182
|
+
npm add -D @nkzw/fate-devtools @vitejs/devtools
|
|
2183
|
+
```
|
|
2184
|
+
|
|
2185
|
+
```bash [pnpm]
|
|
2186
|
+
pnpm add -D @nkzw/fate-devtools @vitejs/devtools
|
|
2187
|
+
```
|
|
2188
|
+
|
|
2189
|
+
```bash [yarn]
|
|
2190
|
+
yarn add -D @nkzw/fate-devtools @vitejs/devtools
|
|
2191
|
+
```
|
|
2192
|
+
|
|
2193
|
+
:::
|
|
2194
|
+
|
|
2195
|
+
### Vite Plugin
|
|
2196
|
+
|
|
2197
|
+
Enable Vite DevTools and add the devtools plugin to your Vite config:
|
|
2198
|
+
|
|
2199
|
+
```ts
|
|
2200
|
+
import { fateDevtools } from '@nkzw/fate-devtools/vite';
|
|
2201
|
+
import { fate } from 'react-fate/vite';
|
|
2202
|
+
import { defineConfig } from 'vite';
|
|
2203
|
+
|
|
2204
|
+
export default defineConfig({
|
|
2205
|
+
devtools: true,
|
|
2206
|
+
plugins: [
|
|
2207
|
+
fate({
|
|
2208
|
+
module: '@your-org/server/trpc/router.ts',
|
|
2209
|
+
}),
|
|
2210
|
+
fateDevtools(),
|
|
2211
|
+
],
|
|
2212
|
+
});
|
|
2213
|
+
```
|
|
2214
|
+
|
|
2215
|
+
The plugin only runs during development. It adds a _fate_ entry to the Vite DevTools dock and serves the panel at `/__fate-devtools/`. You can also open that URL in its own tab, which is handy when you don't use Vite DevTools or want the panel on a second screen.
|
|
2216
|
+
|
|
2217
|
+
### Connecting a Client
|
|
2218
|
+
|
|
2219
|
+
With React, render `<FateDevtools />` inside your `<FateClient>`. It connects the client from context and renders nothing:
|
|
2220
|
+
|
|
2221
|
+
```tsx
|
|
2222
|
+
import { FateDevtools } from '@nkzw/fate-devtools/react';
|
|
2223
|
+
import { FateClient } from 'react-fate';
|
|
2224
|
+
|
|
2225
|
+
export function App() {
|
|
2226
|
+
return (
|
|
2227
|
+
<FateClient client={fate}>
|
|
2228
|
+
{import.meta.env.DEV && <FateDevtools />}
|
|
2229
|
+
<Root />
|
|
2230
|
+
</FateClient>
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
```
|
|
2234
|
+
|
|
2235
|
+
For Vue or other setups, call `connectFateDevtools` with your client. It returns a function that disconnects the client again:
|
|
2236
|
+
|
|
2237
|
+
```ts
|
|
2238
|
+
import { connectFateDevtools } from '@nkzw/fate-devtools';
|
|
2239
|
+
|
|
2240
|
+
if (import.meta.env.DEV) {
|
|
2241
|
+
connectFateDevtools(fate);
|
|
2242
|
+
}
|
|
2243
|
+
```
|
|
2244
|
+
|
|
2245
|
+
Guard the devtools with `import.meta.env.DEV` so they are not included in production builds. If your app creates more than one client, pass a `name` to tell them apart in the panel:
|
|
2246
|
+
|
|
2247
|
+
```tsx
|
|
2248
|
+
<FateDevtools name="admin" />
|
|
2249
|
+
```
|
|
2250
|
+
|
|
2251
|
+
### Server-Rendered Apps
|
|
2252
|
+
|
|
2253
|
+
Vite DevTools adds its dock to pages through Vite's HTML transform. Frameworks that render HTML on the server, such as [Void](/docs/integrations/void.md), don't run that transform, so load the dock yourself in development:
|
|
2254
|
+
|
|
2255
|
+
```ts
|
|
2256
|
+
if (import.meta.env.DEV && typeof document !== 'undefined') {
|
|
2257
|
+
const script = document.createElement('script');
|
|
2258
|
+
script.type = 'module';
|
|
2259
|
+
script.src = '/__devtools/embedded.js';
|
|
2260
|
+
document.head.append(script);
|
|
2261
|
+
}
|
|
2262
|
+
```
|
|
2263
|
+
|
|
2264
|
+
The first time you open Vite DevTools in a browser, it asks you to authorize that browser with a code printed in your terminal.
|
|
2265
|
+
|
|
2266
|
+
### How It Works
|
|
2267
|
+
|
|
2268
|
+
The devtools don't change how _fate_ behaves. When you connect a client, the devtools wrap its transport to record network activity and listen to cache changes. The page then sends snapshots to the panel over a [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel). Snapshots are only sent while a panel is open. When you disconnect, the client's original transport is restored.
|
|
2269
|
+
|
|
2270
|
+
Because the panel and your app talk over a `BroadcastChannel`, the panel works anywhere on the same origin: in the Vite DevTools dock, in a separate tab, or in a popup. When more than one page or client is connected, use the picker in the panel's toolbar to switch between them.
|
|
2271
|
+
|
|
2099
2272
|
## GraphQL Integration
|
|
2100
2273
|
|
|
2101
2274
|
If your backend already exposes GraphQL, you can use it with fate. Define views and call requests and mutations as usual; fate builds the GraphQL operations for you.
|
|
@@ -3119,13 +3292,13 @@ For Prisma, pass Prisma query options such as `{ where: { ... } }` in `extra` in
|
|
|
3119
3292
|
|
|
3120
3293
|
### Data View Resolvers
|
|
3121
3294
|
|
|
3122
|
-
Data views can define computed fields with their own data dependencies. Let's say we want to show a user's email only to that user, and include a comment count on each post. We can define both fields with `
|
|
3295
|
+
Data views can define computed fields with their own data dependencies. Let's say we want to show a user's email only to that user, and include a comment count on each post. We can define both fields with `memo`:
|
|
3123
3296
|
|
|
3124
3297
|
```tsx
|
|
3125
|
-
import {
|
|
3298
|
+
import { memo, count } from '@nkzw/fate/server';
|
|
3126
3299
|
|
|
3127
3300
|
export const userDataView = dataView<UserItem>('User')({
|
|
3128
|
-
email:
|
|
3301
|
+
email: memo({
|
|
3129
3302
|
authorize: ({ id }, context?: AppContext) => context?.sessionUser?.id === id,
|
|
3130
3303
|
select: {
|
|
3131
3304
|
email: true,
|
|
@@ -3136,7 +3309,7 @@ export const userDataView = dataView<UserItem>('User')({
|
|
|
3136
3309
|
});
|
|
3137
3310
|
|
|
3138
3311
|
export const postDataView = dataView<PostItem>('Post')({
|
|
3139
|
-
commentCount:
|
|
3312
|
+
commentCount: memo({
|
|
3140
3313
|
select: {
|
|
3141
3314
|
count: count('comments'),
|
|
3142
3315
|
},
|
|
@@ -3148,7 +3321,7 @@ export const postDataView = dataView<PostItem>('Post')({
|
|
|
3148
3321
|
|
|
3149
3322
|
The `select` object describes what each resolver needs. `email: true` selects the user's stored email, and `count('comments')` asks the adapter to count the post's comments. The resolver receives those values in its second argument. Annotating the first argument with `UserItem` or `PostItem` lets TypeScript infer the dependency types.
|
|
3150
3323
|
|
|
3151
|
-
Use `field('path')` to give a dependency a different name or read through a relationship. The adapters fetch the dependencies for you, while the client only receives the computed field's result. The same `dataView` and `
|
|
3324
|
+
Use `field('path')` to give a dependency a different name or read through a relationship. The adapters fetch the dependencies for you, while the client only receives the computed field's result. The same `dataView` and `memo` helpers are available for [client computed fields](/docs/guide/client-state.md#computed-fields).
|
|
3152
3325
|
|
|
3153
3326
|
### Connecting the Client
|
|
3154
3327
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nkzw/fate-indexeddb",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Optional IndexedDB persistence adapter for fate.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -19,12 +19,12 @@
|
|
|
19
19
|
"idb": "^8.0.4"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@nkzw/fate": "
|
|
23
|
-
"fake-indexeddb": "^6.2.
|
|
24
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@1.
|
|
25
|
-
"vite-plus": "1.
|
|
22
|
+
"@nkzw/fate": "2.0.1",
|
|
23
|
+
"fake-indexeddb": "^6.2.8",
|
|
24
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.6.0",
|
|
25
|
+
"vite-plus": "1.6.0"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
|
-
"@nkzw/fate": "^
|
|
28
|
+
"@nkzw/fate": "^2.0.0"
|
|
29
29
|
}
|
|
30
30
|
}
|