@nkzw/fate-indexeddb 1.15.0 → 1.16.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 +33 -15
- package/lib/index.mjs +24 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -69,7 +69,9 @@ vp create fate
|
|
|
69
69
|
|
|
70
70
|
Explore the [fate stack](https://fate.technology/#stack) to see the tools included in your new project.
|
|
71
71
|
|
|
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
|
|
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
|
+
|
|
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).
|
|
73
75
|
|
|
74
76
|
### Manual Installation
|
|
75
77
|
|
|
@@ -880,8 +882,6 @@ For GraphQL servers with incremental delivery enabled, [streaming batched reads]
|
|
|
880
882
|
|
|
881
883
|
You can wrap a list of references using `useListView` to enable connection-style lists with pagination support.
|
|
882
884
|
|
|
883
|
-
Run the regular example with `vp run dev` (tRPC) or `vp run dev:graphql` (GraphQL), then open **Pagination** from its header (`http://localhost:6001/pagination/preview`). The demo uses the existing posts, client, and router to exercise independent view ranges, navigation, refreshes, and optimistic insertion and rollback. Reading and pagination work without signing in; insertions require an example account and successful insertions create real posts in its database. Browser network throttling makes pending work easier to observe.
|
|
884
|
-
|
|
885
885
|
For example, you can define a `CommentView` and reuse it inside of a `CommentConnectionView`:
|
|
886
886
|
|
|
887
887
|
```tsx
|
|
@@ -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 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);
|
|
@@ -1712,7 +1718,11 @@ return idempotency.execute({
|
|
|
1712
1718
|
|
|
1713
1719
|
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
1720
|
|
|
1715
|
-
|
|
1721
|
+
For a 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
|
+
|
|
1723
|
+
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
|
+
|
|
1725
|
+
Mutations that were already queued stay saved and report a delivery error until the adapter is available again.
|
|
1716
1726
|
|
|
1717
1727
|
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
1728
|
|
|
@@ -1767,7 +1777,7 @@ To remove the saved read cache, call `clearCache()`:
|
|
|
1767
1777
|
await fate.persistence!.clearCache();
|
|
1768
1778
|
```
|
|
1769
1779
|
|
|
1770
|
-
This leaves in-memory data
|
|
1780
|
+
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
1781
|
|
|
1772
1782
|
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
1783
|
|
|
@@ -3109,34 +3119,36 @@ For Prisma, pass Prisma query options such as `{ where: { ... } }` in `extra` in
|
|
|
3109
3119
|
|
|
3110
3120
|
### Data View Resolvers
|
|
3111
3121
|
|
|
3112
|
-
|
|
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 `computed`:
|
|
3113
3123
|
|
|
3114
3124
|
```tsx
|
|
3115
|
-
import { computed, count
|
|
3125
|
+
import { computed, count } from '@nkzw/fate/server';
|
|
3116
3126
|
|
|
3117
3127
|
export const userDataView = dataView<UserItem>('User')({
|
|
3118
|
-
email: computed
|
|
3119
|
-
authorize: ({ id }, context) => context?.sessionUser?.id === id,
|
|
3128
|
+
email: computed({
|
|
3129
|
+
authorize: ({ id }, context?: AppContext) => context?.sessionUser?.id === id,
|
|
3120
3130
|
select: {
|
|
3121
|
-
email:
|
|
3131
|
+
email: true,
|
|
3122
3132
|
},
|
|
3123
|
-
resolve: (_item,
|
|
3133
|
+
resolve: (_item: UserItem, { email }) => email ?? null,
|
|
3124
3134
|
}),
|
|
3125
3135
|
id: true,
|
|
3126
3136
|
});
|
|
3127
3137
|
|
|
3128
3138
|
export const postDataView = dataView<PostItem>('Post')({
|
|
3129
|
-
commentCount: computed
|
|
3139
|
+
commentCount: computed({
|
|
3130
3140
|
select: {
|
|
3131
3141
|
count: count('comments'),
|
|
3132
3142
|
},
|
|
3133
|
-
resolve: (_item,
|
|
3143
|
+
resolve: (_item: PostItem, { count }) => count,
|
|
3134
3144
|
}),
|
|
3135
3145
|
id: true,
|
|
3136
3146
|
});
|
|
3137
3147
|
```
|
|
3138
3148
|
|
|
3139
|
-
The
|
|
3149
|
+
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
|
+
|
|
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 `computed` helpers are available for [client computed fields](/docs/guide/client-state.md#computed-fields).
|
|
3140
3152
|
|
|
3141
3153
|
### Connecting the Client
|
|
3142
3154
|
|
|
@@ -3569,6 +3581,12 @@ After changing your database schema or auth configuration, run `vp run db:genera
|
|
|
3569
3581
|
|
|
3570
3582
|
See [Void's Cloudflare deployment guide](https://void.cloud/integrations/cloudflare) for custom domains, secrets, and CI configuration.
|
|
3571
3583
|
|
|
3584
|
+
### Client-only Apps
|
|
3585
|
+
|
|
3586
|
+
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.
|
|
3587
|
+
|
|
3588
|
+
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.
|
|
3589
|
+
|
|
3572
3590
|
### Migrating from cf-fate
|
|
3573
3591
|
|
|
3574
3592
|
Fate 1.6 replaces the standalone Cloudflare adapter and template with the Void integration. Use `--template void` for new projects and `transport: 'void'` in the fate Vite plugin.
|
package/lib/index.mjs
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { openDB } from "idb";
|
|
2
2
|
// src/index.ts
|
|
3
|
+
const prefixEnd = (prefix) => {
|
|
4
|
+
for (let index = prefix.length - 1; index >= 0; index--) {
|
|
5
|
+
const unit = prefix.charCodeAt(index);
|
|
6
|
+
if (unit < 65535) return prefix.slice(0, index) + String.fromCharCode(unit + 1);
|
|
7
|
+
}
|
|
8
|
+
};
|
|
3
9
|
function createIndexedDBStorage({ name = "fate" } = {}) {
|
|
4
10
|
let connectionLeases = 0;
|
|
5
11
|
let connection;
|
|
@@ -42,7 +48,8 @@ function createIndexedDBStorage({ name = "fate" } = {}) {
|
|
|
42
48
|
return {
|
|
43
49
|
exclusive(key, run) {
|
|
44
50
|
if (typeof navigator === "undefined" || !navigator.locks) throw new Error("fate(indexeddb): Web Locks are required for safe durable writes across tabs.");
|
|
45
|
-
|
|
51
|
+
const lockName = `fate:${JSON.stringify([name, key])}`;
|
|
52
|
+
const request = (lockName) => navigator.locks.request(lockName, async () => {
|
|
46
53
|
connectionLeases++;
|
|
47
54
|
try {
|
|
48
55
|
return await run();
|
|
@@ -50,6 +57,7 @@ function createIndexedDBStorage({ name = "fate" } = {}) {
|
|
|
50
57
|
releaseDatabase();
|
|
51
58
|
}
|
|
52
59
|
});
|
|
60
|
+
return lockName.length <= 1024 ? request(lockName) : crypto.subtle.digest("SHA-256", new TextEncoder().encode(lockName)).then((digest) => request(`fate:sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`));
|
|
53
61
|
},
|
|
54
62
|
async read(key) {
|
|
55
63
|
const db = await acquireDatabase();
|
|
@@ -60,21 +68,24 @@ function createIndexedDBStorage({ name = "fate" } = {}) {
|
|
|
60
68
|
}
|
|
61
69
|
},
|
|
62
70
|
async scan(prefix, after, limit = 64) {
|
|
71
|
+
const lowerOpen = after !== void 0 && after >= prefix;
|
|
72
|
+
const lower = lowerOpen ? after : prefix;
|
|
73
|
+
if (!(limit > 0) || !lower.startsWith(prefix)) return [];
|
|
74
|
+
const upper = prefixEnd(prefix);
|
|
75
|
+
const range = upper === void 0 ? IDBKeyRange.lowerBound(lower, lowerOpen) : IDBKeyRange.bound(lower, upper, lowerOpen, true);
|
|
76
|
+
const count = Math.min(Math.ceil(limit), 4294967295);
|
|
63
77
|
const db = await acquireDatabase();
|
|
64
78
|
try {
|
|
65
|
-
const range = IDBKeyRange.lowerBound(after !== void 0 && after >= prefix ? after : prefix, after !== void 0 && after >= prefix);
|
|
66
79
|
const transaction = db.transaction("fate");
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
await transaction.done;
|
|
77
|
-
return entries;
|
|
80
|
+
const [keys, values] = await Promise.all([
|
|
81
|
+
transaction.store.getAllKeys(range, count),
|
|
82
|
+
transaction.store.getAll(range, count),
|
|
83
|
+
transaction.done
|
|
84
|
+
]);
|
|
85
|
+
return keys.map((key, index) => ({
|
|
86
|
+
key: String(key),
|
|
87
|
+
value: values[index]
|
|
88
|
+
}));
|
|
78
89
|
} finally {
|
|
79
90
|
releaseDatabase();
|
|
80
91
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nkzw/fate-indexeddb",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.1",
|
|
4
4
|
"description": "Optional IndexedDB persistence adapter for fate.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"idb": "^8.0.4"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@nkzw/fate": "1.
|
|
22
|
+
"@nkzw/fate": "1.16.1",
|
|
23
23
|
"fake-indexeddb": "^6.2.7",
|
|
24
24
|
"vite": "npm:@voidzero-dev/vite-plus-core@1.5.0",
|
|
25
25
|
"vite-plus": "1.5.0"
|