@nkzw/fate-indexeddb 1.14.1 → 1.16.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 +175 -30
- 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
|
+
Choose **Client Only** or pass `--template client-only` to start with a static Void app that saves its data in IndexedDB. See [Persisting Client State](/docs/guide/client-state.md#persisting-client-state).
|
|
73
75
|
|
|
74
76
|
### Manual Installation
|
|
75
77
|
|
|
@@ -380,6 +382,8 @@ Vue provides the same API as a reactive resource. See [Resolving Arrays of Refs
|
|
|
380
382
|
|
|
381
383
|
We learned that `useRequest` is responsible for fetching data from the server and `useView` is used for reading data from the cache. In some situations data may not be available in the cache and `useView` might need to suspend the component to fetch only the missing data. Once that data is fetched and written to the cache, the component resumes rendering.
|
|
382
384
|
|
|
385
|
+
Let's say `PostCard` needs a post's `title` while a request for the same post's `title` and `content` is already in flight. fate can share a request by ID when it selects all the missing scalar fields with the same arguments. `PostCard` waits for that request, then fetches any fields it still needs.
|
|
386
|
+
|
|
383
387
|
_Tip: You can test this behavior in development mode with Fast Refresh (HMR) enabled in your bundler. When you edit the selection of a view, components using that view will suspend, fetch the missing data, and then resume rendering._
|
|
384
388
|
|
|
385
389
|
### Type Safety and Data Masking
|
|
@@ -600,7 +604,7 @@ Lists with different filters or sorting arguments stay separate. Loading another
|
|
|
600
604
|
|
|
601
605
|
`useRequest` supports different request modes to control caching and data freshness. The available modes are:
|
|
602
606
|
|
|
603
|
-
- `cache-first` (_default_): Returns data from the cache
|
|
607
|
+
- `cache-first` (_default_): Returns data from the cache when available and fresh. Fetches missing data and data marked stale by [list invalidation](/docs/guide/actions.md#invalidating-lists) or [record invalidation](/docs/guide/actions.md#invalidating-records).
|
|
604
608
|
- `stale-while-revalidate`: Returns data from the cache and simultaneously fetches fresh data from the network.
|
|
605
609
|
- `network-only`: Always fetches data from the network, bypassing the cache.
|
|
606
610
|
- `cache-only` (React): Observes cached data without fetching or suspending. Returns `undefined` when the selection is missing.
|
|
@@ -618,6 +622,8 @@ const { posts } = useRequest(
|
|
|
618
622
|
);
|
|
619
623
|
```
|
|
620
624
|
|
|
625
|
+
When another component mounts with the same `network-only` or `stale-while-revalidate` request, it shares a request that's still in flight or starts a fresh one.
|
|
626
|
+
|
|
621
627
|
For screens that also work offline, switch the same hook to `cache-only`:
|
|
622
628
|
|
|
623
629
|
```tsx
|
|
@@ -628,7 +634,7 @@ const result = useRequest(
|
|
|
628
634
|
const viewer = useView(ViewerView, result?.viewer);
|
|
629
635
|
```
|
|
630
636
|
|
|
631
|
-
Cache-only results stay subscribed to cache changes. Their
|
|
637
|
+
Cache-only results stay subscribed to cache changes. Their refs read selected fields, including deferred fields, from the cache. Switching offline bypasses a pending Suspense gate immediately, so the component can keep its local state and render its offline UI. The existing network modes keep their required result type; modes that can be `cache-only` return an optional result.
|
|
632
638
|
|
|
633
639
|
### Refreshing Requests
|
|
634
640
|
|
|
@@ -771,13 +777,13 @@ The snapshot contains `data`, `error`, `isFetching`, `status`, and `refetch`. Da
|
|
|
771
777
|
|
|
772
778
|
Reading a snapshot does not start a request. The first subscription starts an enabled request and retains its data; the last unsubscribe releases it. Use `enabled: false` to disable fetching, or `mode: 'cache-only'` to observe cached data without fetching missing fields. The possible statuses are:
|
|
773
779
|
|
|
774
|
-
| Status | Meaning
|
|
775
|
-
| ---------- |
|
|
776
|
-
| `disabled` | The request is not enabled.
|
|
777
|
-
| `missing` |
|
|
778
|
-
| `pending` | The initial request is loading.
|
|
779
|
-
| `ready` | Data is available.
|
|
780
|
-
| `error` | The initial request failed.
|
|
780
|
+
| Status | Meaning |
|
|
781
|
+
| ---------- | ---------------------------------------------------------------- |
|
|
782
|
+
| `disabled` | The request is not enabled. |
|
|
783
|
+
| `missing` | Data is missing after a cache-only read or a successful request. |
|
|
784
|
+
| `pending` | The initial request is loading. |
|
|
785
|
+
| `ready` | Data is available. |
|
|
786
|
+
| `error` | The initial request failed. |
|
|
781
787
|
|
|
782
788
|
`observer.getSnapshot().refetch()` returns a promise and requires an enabled request that isn't cache-only. Handle its rejected promise in the calling code.
|
|
783
789
|
|
|
@@ -876,8 +882,6 @@ For GraphQL servers with incremental delivery enabled, [streaming batched reads]
|
|
|
876
882
|
|
|
877
883
|
You can wrap a list of references using `useListView` to enable connection-style lists with pagination support.
|
|
878
884
|
|
|
879
|
-
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.
|
|
880
|
-
|
|
881
885
|
For example, you can define a `CommentView` and reuse it inside of a `CommentConnectionView`:
|
|
882
886
|
|
|
883
887
|
```tsx
|
|
@@ -1347,7 +1351,7 @@ const result = await fate.mutations.comment.add({
|
|
|
1347
1351
|
});
|
|
1348
1352
|
```
|
|
1349
1353
|
|
|
1350
|
-
The options are the same as for Actions, including `optimistic`, `insert`, and `view`. The call returns a promise
|
|
1354
|
+
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.
|
|
1351
1355
|
|
|
1352
1356
|
### Mutation Server Implementation
|
|
1353
1357
|
|
|
@@ -1453,6 +1457,63 @@ Insertions work with pagination, optimistic updates, and [offline mutations](/do
|
|
|
1453
1457
|
|
|
1454
1458
|
Earlier versions appended mutation results automatically. Add an `insert` option to calls that relied on that behavior. The strings `'before'` and `'after'` still enable automatic list matching, but prefer an explicit target. `'none'` is now the default.
|
|
1455
1459
|
|
|
1460
|
+
### Removing Items from Lists
|
|
1461
|
+
|
|
1462
|
+
Let's say we want to move a post from the inbox to an archive. Pass the inbox list as `remove.from` and the post's view ref as `remove.node`. Use `insert` in the same call to add the post to the archive.
|
|
1463
|
+
|
|
1464
|
+
```tsx
|
|
1465
|
+
await fate.mutations.post.archive({
|
|
1466
|
+
input: { id: postRef.id },
|
|
1467
|
+
optimistic: { archived: true },
|
|
1468
|
+
remove: { from: inbox, node: postRef },
|
|
1469
|
+
insert: { into: archivedPosts, position: 'before' },
|
|
1470
|
+
});
|
|
1471
|
+
```
|
|
1472
|
+
|
|
1473
|
+
The post disappears from the inbox immediately. If the mutation fails, fate rolls back both list changes. The post itself stays in the cache. Pass an array of removal objects to remove more than one item or update several lists.
|
|
1474
|
+
|
|
1475
|
+
```tsx
|
|
1476
|
+
await fate.mutations.post.archive({
|
|
1477
|
+
input: { id: postRef.id },
|
|
1478
|
+
remove: [
|
|
1479
|
+
{ from: inbox, node: postRef },
|
|
1480
|
+
{ from: starredPosts, node: postRef },
|
|
1481
|
+
],
|
|
1482
|
+
});
|
|
1483
|
+
```
|
|
1484
|
+
|
|
1485
|
+
Use the original array, connection, or deferred list returned by the same client. A deferred list still loads when a component reads it. If it loads while the mutation is pending, fate applies the removal to the loaded items. Removals also work with [offline mutations](/docs/guide/persistence.md).
|
|
1486
|
+
|
|
1487
|
+
If archiving the post changes which items the server would return on the first page, add the list to [`invalidate`](#invalidating-lists).
|
|
1488
|
+
|
|
1489
|
+
### Invalidating Lists
|
|
1490
|
+
|
|
1491
|
+
Archiving a post might bring the next post into the first page of the inbox. Pass the inbox list to `invalidate` to fetch the updated page on the next request.
|
|
1492
|
+
|
|
1493
|
+
```tsx
|
|
1494
|
+
await fate.mutations.post.archive({
|
|
1495
|
+
input: { id: postRef.id },
|
|
1496
|
+
invalidate: [inbox],
|
|
1497
|
+
});
|
|
1498
|
+
```
|
|
1499
|
+
|
|
1500
|
+
Use the original array or connection returned by the same client. When the mutation succeeds, fate marks the list stale and keeps its current items visible. The next `cache-first` request fetches the list again. Lists with other filters keep their cached results. To refresh the list immediately, use [`useRefetch`](/docs/guide/requests.md#refreshing-requests).
|
|
1501
|
+
|
|
1502
|
+
Refreshing the first page replaces the previously loaded pages. You can load more pages from the refreshed list.
|
|
1503
|
+
|
|
1504
|
+
### Invalidating Records
|
|
1505
|
+
|
|
1506
|
+
Let's add a comment to a post. If the mutation doesn't return the post's updated comment count, pass the post's view ref to `invalidate`.
|
|
1507
|
+
|
|
1508
|
+
```tsx
|
|
1509
|
+
await fate.mutations.comment.add({
|
|
1510
|
+
input: { content, postId: postRef.id },
|
|
1511
|
+
invalidate: [postRef],
|
|
1512
|
+
});
|
|
1513
|
+
```
|
|
1514
|
+
|
|
1515
|
+
When the mutation succeeds, fate marks the post's cached fields stale. A `cache-first` request refreshes the fields it selects. If it selects only the title, the comment count stays stale until a request selects it. You can pass both view refs and lists to `invalidate` in the same call.
|
|
1516
|
+
|
|
1456
1517
|
## Persistence
|
|
1457
1518
|
|
|
1458
1519
|
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.
|
|
@@ -1545,7 +1606,7 @@ Pending mutations are never evicted to make room for cached data. If a new mutat
|
|
|
1545
1606
|
|
|
1546
1607
|
### Actions & Mutations
|
|
1547
1608
|
|
|
1548
|
-
With persistence configured, fate saves mutations before applying their optimistic updates or sending them to the server. The API is the same as for regular [Actions](/docs/guide/actions.md):
|
|
1609
|
+
With persistence and a durable mutation transport configured, fate saves server mutations before applying their optimistic updates or sending them to the server. The API is the same as for regular [Actions](/docs/guide/actions.md):
|
|
1549
1610
|
|
|
1550
1611
|
```tsx
|
|
1551
1612
|
const [result, like] = useActionState(fate.actions.post.like, null);
|
|
@@ -1651,7 +1712,7 @@ return idempotency.execute({
|
|
|
1651
1712
|
|
|
1652
1713
|
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.
|
|
1653
1714
|
|
|
1654
|
-
Without a durable adapter,
|
|
1715
|
+
Without a durable adapter, server mutations send immediately by default. Use `persist: true` to require durable delivery. The call fails before sending if persistence or the adapter is missing. Existing queued mutations stay saved and report a delivery error until the adapter is available again. Remote read caching and client state persistence work independently of the adapter.
|
|
1655
1716
|
|
|
1656
1717
|
For GraphQL, return a standard `extensions.code` or numeric `extensions.http.status` for terminal client errors. Unrecognized execution errors remain retryable for saved mutations.
|
|
1657
1718
|
|
|
@@ -2019,7 +2080,7 @@ const like = async () => {
|
|
|
2019
2080
|
|
|
2020
2081
|
Expected mutation errors are returned in `result.error`; unexpected failures reject the promise. See [Action & Mutation Error Handling](/docs/guide/actions.md#action--mutation-error-handling).
|
|
2021
2082
|
|
|
2022
|
-
|
|
2083
|
+
Use Vue refs or your application state library to track pending and error state. The mutation options work the same way in both frameworks, including `optimistic`, `insert`, `remove`, `invalidate`, and `view`. See [Mutations](/docs/guide/actions.md#mutations).
|
|
2023
2084
|
|
|
2024
2085
|
### API Differences from React
|
|
2025
2086
|
|
|
@@ -2052,7 +2113,7 @@ The GraphQL transport expects a schema with Relay-style object identity and pagi
|
|
|
2052
2113
|
- Entity objects include `id` and `__typename`.
|
|
2053
2114
|
- Object fetches use `nodes(ids:)` by default, or configured per-type query fields.
|
|
2054
2115
|
- List fields return Relay connections with `edges`, `cursor`, `node`, and `pageInfo`.
|
|
2055
|
-
- Root queries and mutations
|
|
2116
|
+
- Root queries and mutations that use a fate view return objects of the selected type.
|
|
2056
2117
|
|
|
2057
2118
|
For example, a `Post` list can be exposed as a normal GraphQL connection:
|
|
2058
2119
|
|
|
@@ -2155,6 +2216,40 @@ Custom scalars default to `unknown`. Set `fateGraphQL.scalars` to describe their
|
|
|
2155
2216
|
|
|
2156
2217
|
Use `import type` for generated types. After updating the schema, run `fate generate` to refresh them.
|
|
2157
2218
|
|
|
2219
|
+
### Views on Unions and Interfaces
|
|
2220
|
+
|
|
2221
|
+
Let's say our `search` connection returns a `SearchResult` union of `Post` and `User`. We can compose `PostView` and `UserView` into a single view using `viewFor`.
|
|
2222
|
+
|
|
2223
|
+
```tsx
|
|
2224
|
+
import { viewFor, useRequest, view } from 'react-fate';
|
|
2225
|
+
import type { GraphQLEntities } from '../../.fate/client.generated.ts';
|
|
2226
|
+
import { PostCard, PostView } from './PostCard.tsx';
|
|
2227
|
+
import { UserCard, UserView } from './UserCard.tsx';
|
|
2228
|
+
|
|
2229
|
+
const SearchResultView = view<GraphQLEntities['SearchResult']>()({
|
|
2230
|
+
...viewFor('Post', PostView),
|
|
2231
|
+
...viewFor('User', UserView),
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
export function Search({ query }: { query: string }) {
|
|
2235
|
+
const { search } = useRequest({ search: { args: { query }, list: SearchResultView } });
|
|
2236
|
+
|
|
2237
|
+
return search.map((result) => {
|
|
2238
|
+
const key = `${result.__typename}:${result.id}`;
|
|
2239
|
+
|
|
2240
|
+
switch (result.__typename) {
|
|
2241
|
+
case 'Post':
|
|
2242
|
+
return <PostCard key={key} post={result} />;
|
|
2243
|
+
case 'User':
|
|
2244
|
+
return <UserCard key={key} user={result} />;
|
|
2245
|
+
}
|
|
2246
|
+
return null;
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
```
|
|
2250
|
+
|
|
2251
|
+
fate fetches the fields from `PostView` for posts and from `UserView` for users. Checking `__typename` gives each card a ref of the right type to resolve with `useView`. The same composition works for interfaces. Each concrete type needs an `id` so fate can identify its objects in the cache.
|
|
2252
|
+
|
|
2158
2253
|
### Creating a Client
|
|
2159
2254
|
|
|
2160
2255
|
Create the client with your GraphQL endpoint and provide it through the `FateClient` provider:
|
|
@@ -2344,6 +2439,16 @@ const { posts } = useRequest({
|
|
|
2344
2439
|
|
|
2345
2440
|
This produces a root `posts(first: 10)` field and a nested `comments(first: 3)` field in the generated GraphQL selection.
|
|
2346
2441
|
|
|
2442
|
+
#### Requesting Scalars
|
|
2443
|
+
|
|
2444
|
+
Let's say the schema defines `today: Date!` with a custom `Date` scalar. Add `today: { embedded: true, type: 'Date' }` to `fateGraphQL.roots`, then use `value: true` to request it.
|
|
2445
|
+
|
|
2446
|
+
```tsx
|
|
2447
|
+
const { today } = useRequest({ today: { value: true } });
|
|
2448
|
+
```
|
|
2449
|
+
|
|
2450
|
+
fate returns the value as sent by the server. This also applies to custom scalars that contain objects or arrays, such as `JSON`.
|
|
2451
|
+
|
|
2347
2452
|
### Mutations
|
|
2348
2453
|
|
|
2349
2454
|
Map fate mutation names to GraphQL mutation fields:
|
|
@@ -2428,6 +2533,8 @@ const fate = createFateClient({
|
|
|
2428
2533
|
|
|
2429
2534
|
Your server must implement these subscription fields. See the [GraphQL subscription example](https://github.com/nkzw-tech/fate/blob/main/example/server-graphql/src/graphql/live.tsx) for their event shapes and resolvers. For servers without these subscriptions, set `live: false`.
|
|
2430
2535
|
|
|
2536
|
+
For a list with several entity types, fate subscribes to membership events with an empty `select`. Return an event with `type: 'invalidate'` when membership changes. fate reruns the list query with the original arguments and view selection.
|
|
2537
|
+
|
|
2431
2538
|
### Existing Servers
|
|
2432
2539
|
|
|
2433
2540
|
Your GraphQL server continues to handle authorization, validation, pagination, and mutation side effects. You don't need fate's server or ORM adapters.
|
|
@@ -2586,7 +2693,7 @@ export const Root = {
|
|
|
2586
2693
|
};
|
|
2587
2694
|
```
|
|
2588
2695
|
|
|
2589
|
-
|
|
2696
|
+
Use `list(...)` for a list resolver and a plain data view for a standard query. In the native protocol, the root key is the operation name used by the client. In the tRPC adapter, `procedure` can point that root at a specific router procedure.
|
|
2590
2697
|
|
|
2591
2698
|
Each `list(...)` can have its own ordering. For example, the feed can show newest posts first while comments use oldest first. Without `orderBy`, fate sorts by `id asc`. It also adds `id asc` as a tie-breaker when your ordering doesn't include `id`. Use an array when ordering by several fields.
|
|
2592
2699
|
|
|
@@ -2609,6 +2716,38 @@ const { posts, categories, viewer } = useRequest({
|
|
|
2609
2716
|
});
|
|
2610
2717
|
```
|
|
2611
2718
|
|
|
2719
|
+
#### Lists with Several Types
|
|
2720
|
+
|
|
2721
|
+
Let's add a `search` list that returns both posts and users. Pass a data view for each type to `list`, along with a query that finds the results for each page. The data view keys match the type names, and each view selects `id`. The `typeName` gives the combined type its own name in the generated client.
|
|
2722
|
+
|
|
2723
|
+
Mixed-list membership grants access through the list query. Register an authorized standalone read for members that need direct by-ID lookups, including deferred reads.
|
|
2724
|
+
|
|
2725
|
+
```tsx
|
|
2726
|
+
import { list } from '@nkzw/fate/server';
|
|
2727
|
+
|
|
2728
|
+
export const Root = {
|
|
2729
|
+
search: list(
|
|
2730
|
+
{ Post: postDataView, User: userDataView },
|
|
2731
|
+
{
|
|
2732
|
+
query: querySearch,
|
|
2733
|
+
typeName: 'SearchResult',
|
|
2734
|
+
},
|
|
2735
|
+
),
|
|
2736
|
+
};
|
|
2737
|
+
```
|
|
2738
|
+
|
|
2739
|
+
Your `querySearch` function receives `args`, `ctx`, `cursor`, `direction`, and `take`. Return up to `take` entries in display order, starting after the cursor for forward pagination or ending before it for backward pagination. Each entry has a `__typename`, a string `id`, and a unique nonempty string `cursor`. Include each `(__typename, id)` pair once across the results. `take` includes one extra entry so fate can tell whether there's another page. fate fetches the selected fields in batches for each type and applies the permissions from its data view.
|
|
2740
|
+
|
|
2741
|
+
For native HTTP, pass `Root` to `createFateServer`. For tRPC, use `fate.procedures` and register the router as `searchResult` in `appRouter`.
|
|
2742
|
+
|
|
2743
|
+
```tsx
|
|
2744
|
+
const searchResultRouter = router(fate.procedures({ view: Root.search }));
|
|
2745
|
+
```
|
|
2746
|
+
|
|
2747
|
+
To use a different router name, set `router` on the entry in `Root`, for example `search: { router: 'search', view: searchList }`. On the client, use [`viewFor` to compose the views](/docs/integrations/graphql.md#views-on-unions-and-interfaces) for the search results.
|
|
2748
|
+
|
|
2749
|
+
Live membership events refresh mixed lists through the same scoped query. This applies the subscriber’s access rules and reloads the fields selected by each view.
|
|
2750
|
+
|
|
2612
2751
|
### Native fate protocol
|
|
2613
2752
|
|
|
2614
2753
|
Create a source adapter for your ORM, pass it to `createFateServer`, and add a route for the HTTP handler:
|
|
@@ -2970,34 +3109,34 @@ For Prisma, pass Prisma query options such as `{ where: { ... } }` in `extra` in
|
|
|
2970
3109
|
|
|
2971
3110
|
### Data View Resolvers
|
|
2972
3111
|
|
|
2973
|
-
fate data views support computed fields. Use `computed
|
|
3112
|
+
fate data views support computed fields. Use `computed` to describe the hidden data needed to resolve a public field.
|
|
2974
3113
|
|
|
2975
3114
|
```tsx
|
|
2976
|
-
import { computed, count
|
|
3115
|
+
import { computed, count } from '@nkzw/fate/server';
|
|
2977
3116
|
|
|
2978
3117
|
export const userDataView = dataView<UserItem>('User')({
|
|
2979
|
-
email: computed
|
|
2980
|
-
authorize: ({ id }, context) => context?.sessionUser?.id === id,
|
|
3118
|
+
email: computed({
|
|
3119
|
+
authorize: ({ id }, context?: AppContext) => context?.sessionUser?.id === id,
|
|
2981
3120
|
select: {
|
|
2982
|
-
email:
|
|
3121
|
+
email: true,
|
|
2983
3122
|
},
|
|
2984
|
-
resolve: (_item,
|
|
3123
|
+
resolve: (_item: UserItem, { email }) => email ?? null,
|
|
2985
3124
|
}),
|
|
2986
3125
|
id: true,
|
|
2987
3126
|
});
|
|
2988
3127
|
|
|
2989
3128
|
export const postDataView = dataView<PostItem>('Post')({
|
|
2990
|
-
commentCount: computed
|
|
3129
|
+
commentCount: computed({
|
|
2991
3130
|
select: {
|
|
2992
3131
|
count: count('comments'),
|
|
2993
3132
|
},
|
|
2994
|
-
resolve: (_item,
|
|
3133
|
+
resolve: (_item: PostItem, { count }) => count,
|
|
2995
3134
|
}),
|
|
2996
3135
|
id: true,
|
|
2997
3136
|
});
|
|
2998
3137
|
```
|
|
2999
3138
|
|
|
3000
|
-
|
|
3139
|
+
Set a dependency to `true` to select a field by the same name. Use `field('path')` for aliases or nested paths and `count('relation')` for relation counts. The adapters fetch these dependencies for you. This keeps private fields like `email` available to the resolver without exposing them to the client selection.
|
|
3001
3140
|
|
|
3002
3141
|
### Connecting the Client
|
|
3003
3142
|
|
|
@@ -3248,13 +3387,13 @@ Your app can publish live updates through the normal fate live bus:
|
|
|
3248
3387
|
|
|
3249
3388
|
```tsx
|
|
3250
3389
|
live.update('Post', postId, { changed: ['likes'] });
|
|
3251
|
-
live.connection('Post.comments', { id: postId }).appendNode('Comment', commentId
|
|
3252
|
-
node: comment,
|
|
3253
|
-
});
|
|
3390
|
+
live.connection('Post.comments', { id: postId }).appendNode('Comment', commentId);
|
|
3254
3391
|
```
|
|
3255
3392
|
|
|
3256
3393
|
Pass `changed` to refresh only those fields, or omit it to refresh the whole selected view.
|
|
3257
3394
|
|
|
3395
|
+
Void sends IDs and change metadata with live events. Each subscriber fetches the selected fields using its own credentials.
|
|
3396
|
+
|
|
3258
3397
|
### Routes
|
|
3259
3398
|
|
|
3260
3399
|
Define a live stream once in a server-only module:
|
|
@@ -3430,6 +3569,12 @@ After changing your database schema or auth configuration, run `vp run db:genera
|
|
|
3430
3569
|
|
|
3431
3570
|
See [Void's Cloudflare deployment guide](https://void.cloud/integrations/cloudflare) for custom domains, secrets, and CI configuration.
|
|
3432
3571
|
|
|
3572
|
+
### Client-only Apps
|
|
3573
|
+
|
|
3574
|
+
Choose `--template client-only` when creating the app. The template uses Void Pages Routing and prerenders both routes as static shells. IndexedDB stores application data in each browser.
|
|
3575
|
+
|
|
3576
|
+
Run `vp exec void deploy --platform cloudflare` from the generated app to deploy the assets. You can also run `vp run build` and serve `dist/client/` from another static host that supports clean URLs.
|
|
3577
|
+
|
|
3433
3578
|
### Migrating from cf-fate
|
|
3434
3579
|
|
|
3435
3580
|
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.0",
|
|
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.0",
|
|
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"
|