@nkzw/fate-indexeddb 1.16.0 → 2.0.0
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 +81 -12
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -71,7 +71,7 @@ Explore the [fate stack](https://fate.technology/#stack) to see the tools includ
|
|
|
71
71
|
|
|
72
72
|
Choose a backend in the template selector: Void, tRPC with Drizzle or Prisma, GraphQL with Prisma, or a client for an existing GraphQL server. React is the default. Choose Vue in the selector or pass `--framework vue`.
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
If you want to build an app entirely with client state, choose **Client Only** or pass `--template client-only`. This template includes Void Pages Routing and a task list saved in IndexedDB. Try editing a task and reloading the page to see persistence in action. See [Client State](/docs/guide/client-state.md).
|
|
75
75
|
|
|
76
76
|
### Manual Installation
|
|
77
77
|
|
|
@@ -1239,6 +1239,8 @@ fate does not provide hooks for mutations like traditional data fetching librari
|
|
|
1239
1239
|
|
|
1240
1240
|
fate's Vite plugin makes your server mutations available on the client. The API works the same way with native HTTP, tRPC, GraphQL, and Void.
|
|
1241
1241
|
|
|
1242
|
+
You can also define mutations for [Client State](/docs/guide/client-state.md). Components call them through the same `fate.actions` and `fate.mutations` APIs.
|
|
1243
|
+
|
|
1242
1244
|
If you have a mutation named `post.like`, a `LikeButton` component using fate Actions and an async component library could look like this:
|
|
1243
1245
|
|
|
1244
1246
|
```tsx
|
|
@@ -1353,6 +1355,8 @@ const result = await fate.mutations.comment.add({
|
|
|
1353
1355
|
|
|
1354
1356
|
The options are the same as for Actions, including `optimistic`, `insert`, `remove`, `invalidate`, and `view`. The call returns a promise. Handle pending state and errors in the calling code.
|
|
1355
1357
|
|
|
1358
|
+
If a mutation doesn't take any input, you can omit the arguments entirely. For example, our [client counter](/docs/guide/client-state.md#reading-and-updating-state) increments with `fate.mutations.counter.increment()`. You can still pass options such as `{ view: CounterView }`. Actions take an options object, so dispatch the same mutation with `increment({})` inside a React Action or transition. Mutations that declare input fields still require `input`, even if every field is optional.
|
|
1359
|
+
|
|
1356
1360
|
### Mutation Server Implementation
|
|
1357
1361
|
|
|
1358
1362
|
Define mutations in your backend and return the fields requested by the client. See [Server Integration](/docs/integrations/server.md#custom-mutations) for native HTTP and tRPC, [GraphQL Integration](/docs/integrations/graphql.md#mutations) for an existing GraphQL API, or [Void Integration](/docs/integrations/void.md) for a Void app.
|
|
@@ -1518,6 +1522,8 @@ When the mutation succeeds, fate marks the post's cached fields stale. A `cache-
|
|
|
1518
1522
|
|
|
1519
1523
|
fate can keep data across page reloads and save mutations while a user is offline. When they come back, their views can show previously loaded data and their pending changes continue where they left off.
|
|
1520
1524
|
|
|
1525
|
+
The same persistence setup can save [Client State](/docs/guide/client-state.md#persisting-client-state), such as drafts or bookmarks. Client data stays saved when the remote cache expires or is cleared. You can use [migrations](/docs/guide/client-state.md#migrations) to update its shape as your app changes.
|
|
1526
|
+
|
|
1521
1527
|
### Client Setup
|
|
1522
1528
|
|
|
1523
1529
|
For a browser app, install the IndexedDB adapter:
|
|
@@ -1606,7 +1612,7 @@ Pending mutations are never evicted to make room for cached data. If a new mutat
|
|
|
1606
1612
|
|
|
1607
1613
|
### Actions & Mutations
|
|
1608
1614
|
|
|
1609
|
-
With persistence and a durable mutation transport configured, fate saves
|
|
1615
|
+
Let's say a user wants to like a post while offline. With persistence and a [durable mutation transport](#server-deduplication) configured, fate saves the mutation before applying its optimistic update or sending it to the server. We can use the same API as for regular [Actions](/docs/guide/actions.md):
|
|
1610
1616
|
|
|
1611
1617
|
```tsx
|
|
1612
1618
|
const [result, like] = useActionState(fate.actions.post.like, null);
|
|
@@ -1623,6 +1629,31 @@ Mutations are delivered in the order they were saved, one at a time per persiste
|
|
|
1623
1629
|
|
|
1624
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.
|
|
1625
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
|
+
|
|
1626
1657
|
#### Skipping Persistence
|
|
1627
1658
|
|
|
1628
1659
|
For a call that should not be saved or retried, pass `persist: false`:
|
|
@@ -1677,7 +1708,35 @@ const server = createFateServer({
|
|
|
1677
1708
|
|
|
1678
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).
|
|
1679
1710
|
|
|
1680
|
-
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.
|
|
1681
1740
|
|
|
1682
1741
|
### tRPC, GraphQL, and Custom Transports
|
|
1683
1742
|
|
|
@@ -1712,7 +1771,15 @@ return idempotency.execute({
|
|
|
1712
1771
|
|
|
1713
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.
|
|
1714
1773
|
|
|
1715
|
-
|
|
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.
|
|
1779
|
+
|
|
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.
|
|
1781
|
+
|
|
1782
|
+
Mutations that were already queued stay saved and report a delivery error until the adapter is available again.
|
|
1716
1783
|
|
|
1717
1784
|
For GraphQL, return a standard `extensions.code` or numeric `extensions.http.status` for terminal client errors. Unrecognized execution errors remain retryable for saved mutations.
|
|
1718
1785
|
|
|
@@ -1767,7 +1834,7 @@ To remove the saved read cache, call `clearCache()`:
|
|
|
1767
1834
|
await fate.persistence!.clearCache();
|
|
1768
1835
|
```
|
|
1769
1836
|
|
|
1770
|
-
This leaves in-memory data
|
|
1837
|
+
This leaves in-memory data, queued mutations, and saved client state intact. For example, clearing cached posts keeps the bookmarks defined in your client schema.
|
|
1771
1838
|
|
|
1772
1839
|
Change `hydrationScope` when deploying incompatible data models or mutation inputs. Incompatible saved reads are discarded; incompatible queued mutations remain visible as failures so the app can handle them.
|
|
1773
1840
|
|
|
@@ -3109,13 +3176,13 @@ For Prisma, pass Prisma query options such as `{ where: { ... } }` in `extra` in
|
|
|
3109
3176
|
|
|
3110
3177
|
### Data View Resolvers
|
|
3111
3178
|
|
|
3112
|
-
|
|
3179
|
+
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`:
|
|
3113
3180
|
|
|
3114
3181
|
```tsx
|
|
3115
|
-
import {
|
|
3182
|
+
import { memo, count } from '@nkzw/fate/server';
|
|
3116
3183
|
|
|
3117
3184
|
export const userDataView = dataView<UserItem>('User')({
|
|
3118
|
-
email:
|
|
3185
|
+
email: memo({
|
|
3119
3186
|
authorize: ({ id }, context?: AppContext) => context?.sessionUser?.id === id,
|
|
3120
3187
|
select: {
|
|
3121
3188
|
email: true,
|
|
@@ -3126,7 +3193,7 @@ export const userDataView = dataView<UserItem>('User')({
|
|
|
3126
3193
|
});
|
|
3127
3194
|
|
|
3128
3195
|
export const postDataView = dataView<PostItem>('Post')({
|
|
3129
|
-
commentCount:
|
|
3196
|
+
commentCount: memo({
|
|
3130
3197
|
select: {
|
|
3131
3198
|
count: count('comments'),
|
|
3132
3199
|
},
|
|
@@ -3136,7 +3203,9 @@ export const postDataView = dataView<PostItem>('Post')({
|
|
|
3136
3203
|
});
|
|
3137
3204
|
```
|
|
3138
3205
|
|
|
3139
|
-
|
|
3206
|
+
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.
|
|
3207
|
+
|
|
3208
|
+
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).
|
|
3140
3209
|
|
|
3141
3210
|
### Connecting the Client
|
|
3142
3211
|
|
|
@@ -3571,9 +3640,9 @@ See [Void's Cloudflare deployment guide](https://void.cloud/integrations/cloudfl
|
|
|
3571
3640
|
|
|
3572
3641
|
### Client-only Apps
|
|
3573
3642
|
|
|
3574
|
-
Choose `--template client-only` when creating the app. The template
|
|
3643
|
+
You can also deploy an app built entirely with [Client State](/docs/guide/client-state.md). Choose `--template client-only` when creating the app. The template includes Void Pages Routing and saves each user's data in their browser with IndexedDB.
|
|
3575
3644
|
|
|
3576
|
-
Run `vp exec void deploy --platform cloudflare` from the generated app to deploy
|
|
3645
|
+
Run `vp exec void deploy --platform cloudflare` from the generated app to deploy its static pages and assets. You can also run `vp run build` and serve `dist/client/` from another static host that supports clean URLs.
|
|
3577
3646
|
|
|
3578
3647
|
### Migrating from cf-fate
|
|
3579
3648
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nkzw/fate-indexeddb",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Optional IndexedDB persistence adapter for fate.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -19,10 +19,10 @@
|
|
|
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.0",
|
|
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
28
|
"@nkzw/fate": "^1.5.1"
|