@octanejs/dexie 0.1.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/LICENSE +21 -0
- package/README.md +60 -0
- package/package.json +61 -0
- package/src/index.ts +8 -0
- package/src/internal.ts +23 -0
- package/src/useDocument.ts +89 -0
- package/src/useLiveQuery.ts +23 -0
- package/src/useObservable.ts +123 -0
- package/src/usePermissions.ts +102 -0
- package/src/useSuspendingLiveQuery.ts +21 -0
- package/src/useSuspendingObservable.ts +197 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dominic Gannaway
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @octanejs/dexie
|
|
2
|
+
|
|
3
|
+
[Dexie](https://dexie.org/) bindings for the [octane](https://github.com/octanejs/octane)
|
|
4
|
+
UI framework.
|
|
5
|
+
|
|
6
|
+
This package re-exports Dexie's framework-neutral IndexedDB API and ports the
|
|
7
|
+
reactive hooks from `dexie-react-hooks` to Octane. Existing Dexie database,
|
|
8
|
+
schema, transaction, and query code can remain unchanged.
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import Dexie from '@octanejs/dexie';
|
|
12
|
+
import { useLiveQuery } from '@octanejs/dexie';
|
|
13
|
+
|
|
14
|
+
const db = new Dexie('friends');
|
|
15
|
+
db.version(1).stores({ friends: '++id, name' });
|
|
16
|
+
|
|
17
|
+
function FriendList() @{
|
|
18
|
+
const friends = useLiveQuery(() => db.table('friends').toArray(), [], []);
|
|
19
|
+
<ul>
|
|
20
|
+
{friends.map((friend) => <li key={friend.id}>{friend.name as string}</li>)}
|
|
21
|
+
</ul>
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Ported hooks
|
|
26
|
+
|
|
27
|
+
- `useObservable`
|
|
28
|
+
- `useLiveQuery`
|
|
29
|
+
- `useSuspendingObservable`
|
|
30
|
+
- `useSuspendingLiveQuery`
|
|
31
|
+
- `usePermissions` for Dexie Cloud databases
|
|
32
|
+
- `useDocument` for optional `y-dexie` document providers
|
|
33
|
+
|
|
34
|
+
The suspending hooks use Octane's `use()` integration and work with Octane
|
|
35
|
+
Suspense or `@try` / `@pending` / `@catch` boundaries.
|
|
36
|
+
|
|
37
|
+
Conformance coverage includes the upstream `dexie-react-hooks` QUnit/Karma
|
|
38
|
+
integration scenarios, run in Playwright Chromium against real IndexedDB
|
|
39
|
+
(`packages/dexie/tests/browser/`).
|
|
40
|
+
|
|
41
|
+
Dexie only observes changes made through Dexie. For async work outside Dexie
|
|
42
|
+
inside a live query, follow Dexie's guidance and wrap the returned promise with
|
|
43
|
+
`Promise.resolve()` so the observation context remains active.
|
|
44
|
+
|
|
45
|
+
`useDocument` requires the consumer to install and import `y-dexie` and `yjs`
|
|
46
|
+
before calling the hook. Those packages are intentionally not dependencies of
|
|
47
|
+
`@octanejs/dexie`.
|
|
48
|
+
|
|
49
|
+
## SSR and hydration
|
|
50
|
+
|
|
51
|
+
Non-suspending live queries are SSR-safe: the configured default result renders
|
|
52
|
+
without opening IndexedDB, and the client hydrates the existing host before
|
|
53
|
+
replacing that default with live data. Suspending live queries are intended for
|
|
54
|
+
client-side Suspense boundaries and do not load IndexedDB data during SSR.
|
|
55
|
+
|
|
56
|
+
## Status
|
|
57
|
+
|
|
58
|
+
Current scope, known divergences, and verification status are tracked in the
|
|
59
|
+
generated [bindings status table](../../docs/bindings-status.md), sourced from
|
|
60
|
+
this package's [`status.json`](./status.json).
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/dexie",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"octane": {
|
|
10
|
+
"hookSlots": {
|
|
11
|
+
"manual": [
|
|
12
|
+
"src"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"description": "Dexie bindings for the octane renderer — reuses Dexie's IndexedDB core and ports dexie-react-hooks onto octane hooks.",
|
|
17
|
+
"author": {
|
|
18
|
+
"name": "Dominic Gannaway",
|
|
19
|
+
"email": "dg@domgan.com"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
27
|
+
"directory": "packages/dexie"
|
|
28
|
+
},
|
|
29
|
+
"main": "src/index.ts",
|
|
30
|
+
"module": "src/index.ts",
|
|
31
|
+
"types": "src/index.ts",
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"exports": {
|
|
37
|
+
".": "./src/index.ts"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"dexie": "4.4.4"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"octane": "0.1.7"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@tsrx/react": "^0.2.37",
|
|
47
|
+
"dexie": "4.4.4",
|
|
48
|
+
"dexie-react-hooks": "4.4.0",
|
|
49
|
+
"esbuild": "^0.28.1",
|
|
50
|
+
"fake-indexeddb": "6.2.5",
|
|
51
|
+
"playwright": "^1.61.0",
|
|
52
|
+
"react": "^19.2.0",
|
|
53
|
+
"react-dom": "^19.2.0",
|
|
54
|
+
"vite": "^8.0.16",
|
|
55
|
+
"vitest": "^4.1.9",
|
|
56
|
+
"octane": "0.1.7"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"test": "vitest run"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from 'dexie';
|
|
2
|
+
export { default } from 'dexie';
|
|
3
|
+
export * from './useDocument';
|
|
4
|
+
export * from './useLiveQuery';
|
|
5
|
+
export * from './useObservable';
|
|
6
|
+
export * from './usePermissions';
|
|
7
|
+
export * from './useSuspendingLiveQuery';
|
|
8
|
+
export * from './useSuspendingObservable';
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
2
|
+
const bareTagCache = new Map<string, symbol>();
|
|
3
|
+
|
|
4
|
+
export function subSlot(slot: symbol | undefined, tag: string): symbol {
|
|
5
|
+
if (slot === undefined) {
|
|
6
|
+
let bare = bareTagCache.get(tag);
|
|
7
|
+
if (bare === undefined) bareTagCache.set(tag, (bare = Symbol.for(':' + tag)));
|
|
8
|
+
return bare;
|
|
9
|
+
}
|
|
10
|
+
let byTag = subSlotCache.get(slot);
|
|
11
|
+
if (byTag === undefined) subSlotCache.set(slot, (byTag = new Map()));
|
|
12
|
+
let derived = byTag.get(tag);
|
|
13
|
+
if (derived === undefined) {
|
|
14
|
+
byTag.set(tag, (derived = Symbol.for((slot.description ?? '') + ':' + tag)));
|
|
15
|
+
}
|
|
16
|
+
return derived;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function splitSlot(args: any[]): [any[], symbol | undefined] {
|
|
20
|
+
const tail = args[args.length - 1];
|
|
21
|
+
const slot = typeof tail === 'symbol' ? tail : undefined;
|
|
22
|
+
return [slot === undefined ? args : args.slice(0, -1), slot];
|
|
23
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Dexie } from 'dexie';
|
|
2
|
+
import { useEffect, useRef } from 'octane';
|
|
3
|
+
import { splitSlot, subSlot } from './internal';
|
|
4
|
+
|
|
5
|
+
export interface DexieYProvider<TDoc extends object = object> {
|
|
6
|
+
doc: TDoc;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface DexieYProviderConstructor<TDoc extends object> {
|
|
10
|
+
load(doc: TDoc, options: { gracePeriod: number }): DexieYProvider<TDoc>;
|
|
11
|
+
for(doc: TDoc): DexieYProvider<TDoc> | undefined;
|
|
12
|
+
release(doc: TDoc): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type DexieWithYProvider = Dexie & {
|
|
16
|
+
DexieYProvider?: DexieYProviderConstructor<object>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const gracePeriod = 100;
|
|
20
|
+
const getProviderConstructor = () =>
|
|
21
|
+
(Dexie as unknown as DexieWithYProvider).DexieYProvider as
|
|
22
|
+
| DexieYProviderConstructor<object>
|
|
23
|
+
| undefined;
|
|
24
|
+
|
|
25
|
+
const finalizationRegistry =
|
|
26
|
+
typeof FinalizationRegistry !== 'undefined'
|
|
27
|
+
? new FinalizationRegistry<object>((doc) => {
|
|
28
|
+
getProviderConstructor()?.release(doc);
|
|
29
|
+
})
|
|
30
|
+
: undefined;
|
|
31
|
+
|
|
32
|
+
export function useDocument<TDoc extends object>(
|
|
33
|
+
doc: TDoc | null | undefined,
|
|
34
|
+
...rest: [symbol?]
|
|
35
|
+
): DexieYProvider<TDoc> | null {
|
|
36
|
+
const [args, slot] = splitSlot(rest);
|
|
37
|
+
if (args.length !== 0) {
|
|
38
|
+
throw new TypeError('useDocument() accepts one document argument.');
|
|
39
|
+
}
|
|
40
|
+
if (!finalizationRegistry) {
|
|
41
|
+
throw new TypeError('useDocument() requires FinalizationRegistry support.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const providerConstructor = getProviderConstructor() as
|
|
45
|
+
| DexieYProviderConstructor<TDoc>
|
|
46
|
+
| undefined;
|
|
47
|
+
if (!providerConstructor) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'DexieYProvider is not available. Make sure y-dexie is installed and imported.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const providerRef = useRef<DexieYProvider<TDoc> | null>(null, subSlot(slot, 'document:provider'));
|
|
54
|
+
const unregisterTokenRef = useRef<object | undefined>(
|
|
55
|
+
undefined,
|
|
56
|
+
subSlot(slot, 'document:unregister'),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (doc) {
|
|
60
|
+
if (doc !== providerRef.current?.doc) {
|
|
61
|
+
providerRef.current = providerConstructor.load(doc, { gracePeriod });
|
|
62
|
+
unregisterTokenRef.current = Object.create(null);
|
|
63
|
+
finalizationRegistry.register(providerRef, doc, unregisterTokenRef.current);
|
|
64
|
+
}
|
|
65
|
+
} else if (providerRef.current?.doc) {
|
|
66
|
+
providerRef.current = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
useEffect(
|
|
70
|
+
() => {
|
|
71
|
+
if (!doc) return;
|
|
72
|
+
if (unregisterTokenRef.current) {
|
|
73
|
+
finalizationRegistry.unregister(unregisterTokenRef.current);
|
|
74
|
+
unregisterTokenRef.current = undefined;
|
|
75
|
+
}
|
|
76
|
+
const provider = providerConstructor.for(doc);
|
|
77
|
+
if (!provider) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'DexieYProvider.release() was called before useDocument() could take ownership.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return () => providerConstructor.release(doc);
|
|
83
|
+
},
|
|
84
|
+
[doc],
|
|
85
|
+
subSlot(slot, 'document:effect'),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return providerRef.current;
|
|
89
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Dexie } from 'dexie';
|
|
2
|
+
import { useObservable } from './useObservable';
|
|
3
|
+
|
|
4
|
+
export function useLiveQuery<T>(querier: () => Promise<T> | T, deps?: unknown[]): T | undefined;
|
|
5
|
+
export function useLiveQuery<T, TDefault>(
|
|
6
|
+
querier: () => Promise<T> | T,
|
|
7
|
+
deps: unknown[],
|
|
8
|
+
defaultResult: TDefault,
|
|
9
|
+
): T | TDefault;
|
|
10
|
+
export function useLiveQuery<T, TDefault>(
|
|
11
|
+
querier: () => Promise<T> | T,
|
|
12
|
+
...rest: [unknown?, unknown?, symbol?]
|
|
13
|
+
): T | TDefault | undefined {
|
|
14
|
+
const slot = typeof rest[rest.length - 1] === 'symbol' ? rest.pop() : undefined;
|
|
15
|
+
const deps = (rest[0] as unknown[] | undefined) ?? [];
|
|
16
|
+
const defaultResult = rest[1] as TDefault | undefined;
|
|
17
|
+
return useObservable(
|
|
18
|
+
() => Dexie.liveQuery(querier) as any,
|
|
19
|
+
deps,
|
|
20
|
+
defaultResult,
|
|
21
|
+
slot as symbol | undefined,
|
|
22
|
+
) as T | TDefault | undefined;
|
|
23
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useDebugValue, useEffect, useMemo, useReducer, useRef } from 'octane';
|
|
2
|
+
import { splitSlot, subSlot } from './internal';
|
|
3
|
+
|
|
4
|
+
export interface InteropableObservable<T> {
|
|
5
|
+
subscribe(
|
|
6
|
+
onNext: (value: T) => unknown,
|
|
7
|
+
onError?: (error: unknown) => unknown,
|
|
8
|
+
): { unsubscribe(): unknown } | (() => unknown);
|
|
9
|
+
getValue?(): T;
|
|
10
|
+
hasValue?(): boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type ObservableFactory<T> = () => InteropableObservable<T>;
|
|
14
|
+
|
|
15
|
+
function unsubscribe(subscription: { unsubscribe(): unknown } | (() => unknown)) {
|
|
16
|
+
if (typeof subscription === 'function') subscription();
|
|
17
|
+
else subscription.unsubscribe();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useObservable<T>(observable: InteropableObservable<T>): T | undefined;
|
|
21
|
+
export function useObservable<T, TDefault>(
|
|
22
|
+
observable: InteropableObservable<T>,
|
|
23
|
+
defaultResult: TDefault,
|
|
24
|
+
): T | TDefault;
|
|
25
|
+
export function useObservable<T>(
|
|
26
|
+
observableFactory: ObservableFactory<T>,
|
|
27
|
+
deps?: unknown[],
|
|
28
|
+
): T | undefined;
|
|
29
|
+
export function useObservable<T, TDefault>(
|
|
30
|
+
observableFactory: ObservableFactory<T>,
|
|
31
|
+
deps: unknown[],
|
|
32
|
+
defaultResult: TDefault,
|
|
33
|
+
): T | TDefault;
|
|
34
|
+
export function useObservable<T, TDefault>(
|
|
35
|
+
observableFactory: ObservableFactory<T>,
|
|
36
|
+
deps: unknown[],
|
|
37
|
+
defaultResult: TDefault | undefined,
|
|
38
|
+
slot: symbol | undefined,
|
|
39
|
+
): T | TDefault | undefined;
|
|
40
|
+
export function useObservable<T, TDefault>(
|
|
41
|
+
observableOrFactory: InteropableObservable<T> | ObservableFactory<T>,
|
|
42
|
+
...rest: [unknown?, unknown?, symbol?]
|
|
43
|
+
): T | TDefault | undefined {
|
|
44
|
+
const [args, slot] = splitSlot(rest);
|
|
45
|
+
const deps = typeof observableOrFactory === 'function' ? ((args[0] as unknown[]) ?? []) : [];
|
|
46
|
+
const defaultResult = (typeof observableOrFactory === 'function' ? args[1] : args[0]) as
|
|
47
|
+
| TDefault
|
|
48
|
+
| undefined;
|
|
49
|
+
const monitor = useRef(
|
|
50
|
+
{
|
|
51
|
+
hasResult: false,
|
|
52
|
+
result: defaultResult as T | TDefault | undefined,
|
|
53
|
+
error: null as unknown,
|
|
54
|
+
},
|
|
55
|
+
subSlot(slot, 'observable:monitor'),
|
|
56
|
+
);
|
|
57
|
+
const [, triggerUpdate] = useReducer(
|
|
58
|
+
(value: number) => value + 1,
|
|
59
|
+
0,
|
|
60
|
+
subSlot(slot, 'observable:update'),
|
|
61
|
+
);
|
|
62
|
+
const observable = useMemo(
|
|
63
|
+
() => {
|
|
64
|
+
const value =
|
|
65
|
+
typeof observableOrFactory === 'function' ? observableOrFactory() : observableOrFactory;
|
|
66
|
+
if (!value || typeof value.subscribe !== 'function') {
|
|
67
|
+
throw new TypeError(
|
|
68
|
+
typeof observableOrFactory === 'function'
|
|
69
|
+
? 'Observable factory did not return a valid observable.'
|
|
70
|
+
: 'Given argument was neither a valid observable nor a function.',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
!monitor.current.hasResult &&
|
|
75
|
+
typeof window !== 'undefined' &&
|
|
76
|
+
(typeof value.hasValue !== 'function' || value.hasValue())
|
|
77
|
+
) {
|
|
78
|
+
if (typeof value.getValue === 'function') {
|
|
79
|
+
monitor.current.result = value.getValue();
|
|
80
|
+
monitor.current.hasResult = true;
|
|
81
|
+
} else {
|
|
82
|
+
const subscription = value.subscribe((next) => {
|
|
83
|
+
monitor.current.result = next;
|
|
84
|
+
monitor.current.hasResult = true;
|
|
85
|
+
});
|
|
86
|
+
unsubscribe(subscription);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
},
|
|
91
|
+
deps,
|
|
92
|
+
subSlot(slot, 'observable:memo'),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
useDebugValue(monitor.current.result, subSlot(slot, 'observable:debug'));
|
|
96
|
+
useEffect(
|
|
97
|
+
() => {
|
|
98
|
+
const subscription = observable.subscribe(
|
|
99
|
+
(next) => {
|
|
100
|
+
const current = monitor.current;
|
|
101
|
+
if (current.error !== null || !Object.is(current.result, next)) {
|
|
102
|
+
current.error = null;
|
|
103
|
+
current.result = next;
|
|
104
|
+
current.hasResult = true;
|
|
105
|
+
triggerUpdate(0);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
(error) => {
|
|
109
|
+
if (monitor.current.error !== error) {
|
|
110
|
+
monitor.current.error = error;
|
|
111
|
+
triggerUpdate(0);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
return () => unsubscribe(subscription);
|
|
116
|
+
},
|
|
117
|
+
[observable],
|
|
118
|
+
subSlot(slot, 'observable:effect'),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (monitor.current.error !== null) throw monitor.current.error;
|
|
122
|
+
return monitor.current.result;
|
|
123
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Dexie } from 'dexie';
|
|
2
|
+
import { useObservable } from './useObservable';
|
|
3
|
+
|
|
4
|
+
type PermissionKeyPaths<T> = {
|
|
5
|
+
[P in keyof T]: P extends string
|
|
6
|
+
? T[P] extends readonly (infer K)[]
|
|
7
|
+
? K extends object
|
|
8
|
+
? P | `${P}.${number}` | `${P}.${number}.${PermissionKeyPaths<K>}`
|
|
9
|
+
: P | `${P}.${number}`
|
|
10
|
+
: T[P] extends (...args: any[]) => any
|
|
11
|
+
? never
|
|
12
|
+
: T[P] extends object
|
|
13
|
+
? P | `${P}.${PermissionKeyPaths<T[P]>}`
|
|
14
|
+
: P
|
|
15
|
+
: never;
|
|
16
|
+
}[keyof T];
|
|
17
|
+
|
|
18
|
+
export type PermissionChecker<T, TableName extends string> = {
|
|
19
|
+
add(...tableNames: TableName[]): boolean;
|
|
20
|
+
update(...props: PermissionKeyPaths<T>[]): boolean;
|
|
21
|
+
delete(): boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type Entity = {
|
|
25
|
+
table?: () => string;
|
|
26
|
+
realmId?: string;
|
|
27
|
+
owner?: string;
|
|
28
|
+
db?: Dexie;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function usePermissions<T extends Entity>(
|
|
32
|
+
entity: T,
|
|
33
|
+
): PermissionChecker<T, T extends { table: () => infer Name } ? Extract<Name, string> : string>;
|
|
34
|
+
export function usePermissions<TDB extends Dexie, T>(
|
|
35
|
+
db: TDB,
|
|
36
|
+
table: string,
|
|
37
|
+
obj: T,
|
|
38
|
+
): PermissionChecker<T, string>;
|
|
39
|
+
export function usePermissions(...rest: any[]) {
|
|
40
|
+
const [args, slot] = (() => {
|
|
41
|
+
const tail = rest[rest.length - 1];
|
|
42
|
+
return typeof tail === 'symbol' ? [rest.slice(0, -1), tail as symbol] : [rest, undefined];
|
|
43
|
+
})();
|
|
44
|
+
const firstArg = args[0] as Entity | Dexie | undefined;
|
|
45
|
+
if (!firstArg) throw new TypeError('Invalid arguments to usePermissions(): undefined or null');
|
|
46
|
+
|
|
47
|
+
let db: Dexie;
|
|
48
|
+
let table: string;
|
|
49
|
+
let obj: Entity;
|
|
50
|
+
if (args.length >= 3) {
|
|
51
|
+
if (!('transaction' in firstArg)) {
|
|
52
|
+
throw new TypeError(
|
|
53
|
+
'Invalid arguments to usePermissions(db, table, obj): 1st arg must be a Dexie instance',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (typeof args[1] !== 'string') {
|
|
57
|
+
throw new TypeError(
|
|
58
|
+
'Invalid arguments to usePermissions(db, table, obj): 2nd arg must be string',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (!args[2] || typeof args[2] !== 'object') {
|
|
62
|
+
throw new TypeError(
|
|
63
|
+
'Invalid arguments to usePermissions(db, table, obj): 3rd arg must be an object',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
db = firstArg as Dexie;
|
|
67
|
+
table = args[1];
|
|
68
|
+
obj = args[2];
|
|
69
|
+
} else if (
|
|
70
|
+
typeof firstArg !== 'object' ||
|
|
71
|
+
typeof (firstArg as Entity).table !== 'function' ||
|
|
72
|
+
!(firstArg as Entity).db
|
|
73
|
+
) {
|
|
74
|
+
throw new TypeError(
|
|
75
|
+
'Invalid arguments to usePermissions(). Expected a Dexie Cloud entity or (db, table, obj).',
|
|
76
|
+
);
|
|
77
|
+
} else {
|
|
78
|
+
const entity = firstArg as Entity;
|
|
79
|
+
db = entity.db!;
|
|
80
|
+
table = entity.table!();
|
|
81
|
+
obj = entity;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cloud = (db as Dexie & { cloud?: { permissions: (obj: Entity, table: string) => unknown } })
|
|
85
|
+
.cloud;
|
|
86
|
+
if (!cloud) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"usePermissions() is only for Dexie Cloud but there's no dexie-cloud-addon active in the given db.",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (typeof cloud.permissions !== 'function') {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'usePermissions() requires a newer version of dexie-cloud-addon. Please upgrade it.',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return useObservable(
|
|
97
|
+
() => cloud.permissions(obj, table) as any,
|
|
98
|
+
[obj.realmId, obj.owner, table],
|
|
99
|
+
undefined,
|
|
100
|
+
slot,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Dexie } from 'dexie';
|
|
2
|
+
import { useSuspendingObservable } from './useSuspendingObservable';
|
|
3
|
+
|
|
4
|
+
export function useSuspendingLiveQuery<T>(
|
|
5
|
+
querier: () => Promise<T> | T,
|
|
6
|
+
cacheKey: readonly unknown[],
|
|
7
|
+
): T;
|
|
8
|
+
export function useSuspendingLiveQuery<T>(
|
|
9
|
+
querier: () => Promise<T> | T,
|
|
10
|
+
...rest: [readonly unknown[], symbol?]
|
|
11
|
+
): T {
|
|
12
|
+
const [args, slot] =
|
|
13
|
+
typeof rest[rest.length - 1] === 'symbol'
|
|
14
|
+
? [rest.slice(0, -1), rest[rest.length - 1] as symbol]
|
|
15
|
+
: [rest, undefined];
|
|
16
|
+
return useSuspendingObservable(
|
|
17
|
+
() => Dexie.liveQuery(querier),
|
|
18
|
+
['dexie', ...(args[0] as readonly unknown[])],
|
|
19
|
+
slot,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { use, useEffect, useReducer, useRef } from 'octane';
|
|
2
|
+
import type { InteropableObservable } from './useObservable';
|
|
3
|
+
import { splitSlot, subSlot } from './internal';
|
|
4
|
+
|
|
5
|
+
type Observer<T> = {
|
|
6
|
+
next?: (value: T) => void;
|
|
7
|
+
error?: (error: unknown) => void;
|
|
8
|
+
complete?: () => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type Subscription = { unsubscribe(): unknown } | (() => unknown);
|
|
12
|
+
|
|
13
|
+
type CacheEntry<T> = {
|
|
14
|
+
key: readonly unknown[];
|
|
15
|
+
source: any;
|
|
16
|
+
current?: T;
|
|
17
|
+
hasValue: boolean;
|
|
18
|
+
promise?: Promise<T>;
|
|
19
|
+
observers: Set<Observer<T>>;
|
|
20
|
+
sourceSubscription?: Subscription;
|
|
21
|
+
cleanup?: ReturnType<typeof setTimeout>;
|
|
22
|
+
subscribe(observer: Observer<T>): Subscription;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const CLEANUP_DELAY = 3000;
|
|
26
|
+
const cache: CacheEntry<any>[] = [];
|
|
27
|
+
const sameKey = (left: readonly unknown[], right: readonly unknown[]) =>
|
|
28
|
+
left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
|
|
29
|
+
|
|
30
|
+
function stop(subscription: Subscription | undefined) {
|
|
31
|
+
if (subscription === undefined) return;
|
|
32
|
+
if (typeof subscription === 'function') subscription();
|
|
33
|
+
else subscription.unsubscribe();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getEntry<T>(
|
|
37
|
+
getObservable: (() => InteropableObservable<T>) | InteropableObservable<T>,
|
|
38
|
+
cacheKey: readonly unknown[],
|
|
39
|
+
): CacheEntry<T> {
|
|
40
|
+
const existing = cache.find((entry) => sameKey(entry.key, cacheKey)) as CacheEntry<T> | undefined;
|
|
41
|
+
if (existing) return existing;
|
|
42
|
+
|
|
43
|
+
const source: any =
|
|
44
|
+
typeof getObservable === 'function'
|
|
45
|
+
? getObservable()
|
|
46
|
+
: (getObservable as InteropableObservable<T>);
|
|
47
|
+
const entry = {
|
|
48
|
+
key: [...cacheKey],
|
|
49
|
+
source,
|
|
50
|
+
current: undefined,
|
|
51
|
+
hasValue: false,
|
|
52
|
+
observers: new Set<Observer<T>>(),
|
|
53
|
+
} as unknown as CacheEntry<T>;
|
|
54
|
+
|
|
55
|
+
const emit = (observer: Observer<T>, method: 'next' | 'error' | 'complete', value?: unknown) => {
|
|
56
|
+
observer[method]?.(value as never);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const scheduleCleanup = () => {
|
|
60
|
+
if (entry.cleanup !== undefined) return;
|
|
61
|
+
entry.cleanup = setTimeout(() => {
|
|
62
|
+
stop(entry.sourceSubscription);
|
|
63
|
+
entry.sourceSubscription = undefined;
|
|
64
|
+
const index = cache.indexOf(entry);
|
|
65
|
+
if (index !== -1) cache.splice(index, 1);
|
|
66
|
+
}, CLEANUP_DELAY);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Match dexie-react-hooks handleFinalize: drop promise/value/observers so a later
|
|
70
|
+
// subscribe (same cache key) can start fresh. Do not unsubscribe the source here —
|
|
71
|
+
// upstream clears its subscription pointer without stopping, so Dexie can finish
|
|
72
|
+
// handling a querier rejection without leaving an unhandled rejection.
|
|
73
|
+
const handleFinalize = () => {
|
|
74
|
+
entry.sourceSubscription = undefined;
|
|
75
|
+
entry.promise = undefined;
|
|
76
|
+
entry.hasValue = false;
|
|
77
|
+
entry.current = undefined;
|
|
78
|
+
entry.observers.clear();
|
|
79
|
+
scheduleCleanup();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const subscribeSource = () => {
|
|
83
|
+
if (entry.sourceSubscription) return;
|
|
84
|
+
entry.sourceSubscription = source.subscribe({
|
|
85
|
+
next: (value: T) => {
|
|
86
|
+
entry.current = value;
|
|
87
|
+
entry.hasValue = true;
|
|
88
|
+
for (const observer of [...entry.observers]) emit(observer, 'next', value);
|
|
89
|
+
},
|
|
90
|
+
error: (error: unknown) => {
|
|
91
|
+
const lastObservers = new Set(entry.observers);
|
|
92
|
+
handleFinalize();
|
|
93
|
+
for (const observer of lastObservers) emit(observer, 'error', error);
|
|
94
|
+
},
|
|
95
|
+
complete: () => {
|
|
96
|
+
const lastObservers = new Set(entry.observers);
|
|
97
|
+
handleFinalize();
|
|
98
|
+
for (const observer of lastObservers) emit(observer, 'complete');
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
entry.subscribe = (observer) => {
|
|
104
|
+
if (entry.cleanup !== undefined) {
|
|
105
|
+
clearTimeout(entry.cleanup);
|
|
106
|
+
entry.cleanup = undefined;
|
|
107
|
+
}
|
|
108
|
+
entry.observers.add(observer);
|
|
109
|
+
subscribeSource();
|
|
110
|
+
if (entry.hasValue) observer.next?.(entry.current as T);
|
|
111
|
+
return () => {
|
|
112
|
+
if (!entry.observers.has(observer)) return;
|
|
113
|
+
entry.observers.delete(observer);
|
|
114
|
+
if (entry.observers.size === 0) scheduleCleanup();
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
cache.push(entry);
|
|
118
|
+
return entry;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function useSuspendingObservable<T>(
|
|
122
|
+
getObservable: (() => InteropableObservable<T>) | InteropableObservable<T>,
|
|
123
|
+
cacheKey: readonly unknown[],
|
|
124
|
+
): T;
|
|
125
|
+
export function useSuspendingObservable<T>(
|
|
126
|
+
getObservable: (() => InteropableObservable<T>) | InteropableObservable<T>,
|
|
127
|
+
cacheKey: readonly unknown[],
|
|
128
|
+
slot: symbol | undefined,
|
|
129
|
+
): T;
|
|
130
|
+
export function useSuspendingObservable<T>(
|
|
131
|
+
getObservable: (() => InteropableObservable<T>) | InteropableObservable<T>,
|
|
132
|
+
...rest: [readonly unknown[], symbol?]
|
|
133
|
+
): T {
|
|
134
|
+
const [args, slot] = splitSlot(rest);
|
|
135
|
+
const entry = getEntry(getObservable, args[0] as readonly unknown[]);
|
|
136
|
+
if (!entry.promise) {
|
|
137
|
+
entry.promise = new Promise<T>((resolve, reject) => {
|
|
138
|
+
let subscription: Subscription;
|
|
139
|
+
subscription = entry.subscribe({
|
|
140
|
+
next: (value) => {
|
|
141
|
+
resolve(value);
|
|
142
|
+
queueMicrotask(() => stop(subscription));
|
|
143
|
+
},
|
|
144
|
+
error: (error) => {
|
|
145
|
+
reject(error);
|
|
146
|
+
queueMicrotask(() => stop(subscription));
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
// Octane's use() may reuse a prior promise on replay and abandon a freshly
|
|
151
|
+
// created one after handleFinalize cleared entry.promise. Attach a sink so
|
|
152
|
+
// that wasted rejection is not reported as unhandled (the active use()
|
|
153
|
+
// slot still observes the settled promise).
|
|
154
|
+
entry.promise.catch(() => {});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const initialValue = use(entry.promise);
|
|
158
|
+
const value = useRef(initialValue, subSlot(slot, 'suspending:value'));
|
|
159
|
+
const error = useRef<unknown>(undefined, subSlot(slot, 'suspending:error'));
|
|
160
|
+
const activeEntry = useRef(entry, subSlot(slot, 'suspending:entry'));
|
|
161
|
+
const [, rerender] = useReducer(
|
|
162
|
+
(count: number) => count + 1,
|
|
163
|
+
0,
|
|
164
|
+
subSlot(slot, 'suspending:update'),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Per Bugbot discussion_r3597801534: a prior entry's error must not stick after cacheKey changes.
|
|
168
|
+
if (activeEntry.current !== entry) {
|
|
169
|
+
activeEntry.current = entry;
|
|
170
|
+
error.current = undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
value.current = entry.hasValue ? (entry.current as T) : initialValue;
|
|
174
|
+
|
|
175
|
+
useEffect(
|
|
176
|
+
() => {
|
|
177
|
+
const subscription = entry.subscribe({
|
|
178
|
+
next: (next) => {
|
|
179
|
+
if (!Object.is(value.current, next)) {
|
|
180
|
+
value.current = next;
|
|
181
|
+
rerender(0);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
error: (nextError) => {
|
|
185
|
+
error.current = nextError;
|
|
186
|
+
rerender(0);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
return () => stop(subscription);
|
|
190
|
+
},
|
|
191
|
+
[entry],
|
|
192
|
+
subSlot(slot, 'suspending:effect'),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (error.current !== undefined) throw error.current;
|
|
196
|
+
return value.current;
|
|
197
|
+
}
|