@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.
Files changed (3) hide show
  1. package/README.md +175 -30
  2. package/lib/index.mjs +24 -13
  3. 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; choose Vue in the selector or pass `--framework vue`.
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 if available, otherwise fetches from the network.
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 references never fetch missing view fields. 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.
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` | A cache-only request needs data that isn't cached. |
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; handle pending state and errors in the calling code.
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, saved reads and `persist: false` calls still work, but new durable mutations fail before being queued.
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
- The mutation call shape is the same as `react-fate`: `input`, `optimistic`, `insert`, and `view` work the same way. Vue does not have React Actions or `useActionState`, so Vue components should model pending and error state with Vue refs or your application state library.
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 return the entity type selected by the fate view.
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
- Entries that wrap their view in `list(...)` are treated as list resolvers. 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. If you omit `list(...)`, fate treats the entry as a standard query.
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`, `field`, and `count` to describe the hidden data needed to resolve a public field:
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, field } from '@nkzw/fate/server';
3115
+ import { computed, count } from '@nkzw/fate/server';
2977
3116
 
2978
3117
  export const userDataView = dataView<UserItem>('User')({
2979
- email: computed<UserItem, string | null, AppContext>({
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: field('email'),
3121
+ email: true,
2983
3122
  },
2984
- resolve: (_item, deps) => (deps.email as string | null) ?? null,
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<PostItem, number>({
3129
+ commentCount: computed({
2991
3130
  select: {
2992
3131
  count: count('comments'),
2993
3132
  },
2994
- resolve: (_item, deps) => (deps.count as number) ?? 0,
3133
+ resolve: (_item: PostItem, { count }) => count,
2995
3134
  }),
2996
3135
  id: true,
2997
3136
  });
2998
3137
  ```
2999
3138
 
3000
- The adapters fetch the hidden `field(...)` and `count(...)` dependencies for you. This keeps private fields like `email` available to the resolver without exposing them to the client selection.
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
- return navigator.locks.request(`fate:${JSON.stringify([name, key])}`, async () => {
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 entries = [];
68
- let cursor = await transaction.store.openCursor(range);
69
- while (cursor && String(cursor.key).startsWith(prefix) && entries.length < limit) {
70
- entries.push({
71
- key: String(cursor.key),
72
- value: cursor.value
73
- });
74
- cursor = await cursor.continue();
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.14.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.14.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"