@syncular/react 0.2.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 +182 -0
- package/dist/client.d.ts +58 -0
- package/dist/client.js +31 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +18 -0
- package/dist/infer-tables.d.ts +20 -0
- package/dist/infer-tables.js +33 -0
- package/dist/provider.d.ts +16 -0
- package/dist/provider.js +15 -0
- package/dist/query-churn.d.ts +92 -0
- package/dist/query-churn.js +216 -0
- package/dist/typed.d.ts +6 -0
- package/dist/typed.js +6 -0
- package/dist/use-client.d.ts +3 -0
- package/dist/use-client.js +10 -0
- package/dist/use-conflicts.d.ts +12 -0
- package/dist/use-conflicts.js +37 -0
- package/dist/use-mutation.d.ts +18 -0
- package/dist/use-mutation.js +34 -0
- package/dist/use-named-query.d.ts +34 -0
- package/dist/use-named-query.js +13 -0
- package/dist/use-presence.d.ts +10 -0
- package/dist/use-presence.js +37 -0
- package/dist/use-sync-query.d.ts +39 -0
- package/dist/use-sync-query.js +179 -0
- package/dist/use-sync-status.d.ts +29 -0
- package/dist/use-sync-status.js +67 -0
- package/dist/use-typed-query.d.ts +32 -0
- package/dist/use-typed-query.js +44 -0
- package/dist/use-window.d.ts +27 -0
- package/dist/use-window.js +49 -0
- package/package.json +85 -0
- package/src/client.ts +130 -0
- package/src/index.ts +38 -0
- package/src/infer-tables.ts +35 -0
- package/src/provider.ts +36 -0
- package/src/query-churn.ts +248 -0
- package/src/typed.ts +6 -0
- package/src/use-client.ts +14 -0
- package/src/use-conflicts.ts +47 -0
- package/src/use-mutation.ts +47 -0
- package/src/use-named-query.ts +66 -0
- package/src/use-presence.ts +40 -0
- package/src/use-sync-query.ts +223 -0
- package/src/use-sync-status.ts +87 -0
- package/src/use-typed-query.ts +67 -0
- package/src/use-window.ts +88 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useSyncStatus` — the client's operational status: outbox depth,
|
|
3
|
+
* upgrading (§7.4.5), leaseState (§7.3.5), schemaFloor (§1.6), and
|
|
4
|
+
* syncNeeded (§8.4). Status is derived by polling the normalized accessors
|
|
5
|
+
* after every apply batch (invalidation is the natural "something changed"
|
|
6
|
+
* signal) plus an initial read; `refresh()` re-reads on demand.
|
|
7
|
+
*
|
|
8
|
+
* `online` is not a protocol concept the client exposes directly (§1.3
|
|
9
|
+
* transport-owned), so it is reported as `undefined` unless a future
|
|
10
|
+
* accessor lands — the hook surfaces what the core actually knows, never a
|
|
11
|
+
* guessed value.
|
|
12
|
+
*/
|
|
13
|
+
import type { LeaseState, SchemaFloor } from '@syncular/client';
|
|
14
|
+
export interface SyncStatus {
|
|
15
|
+
/** Pending outbox commits (unpushed local writes). */
|
|
16
|
+
readonly outbox: number;
|
|
17
|
+
/** §7.4.5: a schema-bump reset + first re-bootstrap is in flight. */
|
|
18
|
+
readonly upgrading: boolean;
|
|
19
|
+
/** §7.3.5: opaque auth-lease state, or undefined. */
|
|
20
|
+
readonly leaseState: LeaseState | undefined;
|
|
21
|
+
/** §1.6: server-declared schema floor (syncing stopped), or undefined. */
|
|
22
|
+
readonly schemaFloor: SchemaFloor | undefined;
|
|
23
|
+
/** §8.4: the host loop should run a pull soon. */
|
|
24
|
+
readonly syncNeeded: boolean;
|
|
25
|
+
/** True until the first status read resolves. */
|
|
26
|
+
readonly isLoading: boolean;
|
|
27
|
+
readonly refresh: () => void;
|
|
28
|
+
}
|
|
29
|
+
export declare function useSyncStatus(): SyncStatus;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useSyncStatus` — the client's operational status: outbox depth,
|
|
3
|
+
* upgrading (§7.4.5), leaseState (§7.3.5), schemaFloor (§1.6), and
|
|
4
|
+
* syncNeeded (§8.4). Status is derived by polling the normalized accessors
|
|
5
|
+
* after every apply batch (invalidation is the natural "something changed"
|
|
6
|
+
* signal) plus an initial read; `refresh()` re-reads on demand.
|
|
7
|
+
*
|
|
8
|
+
* `online` is not a protocol concept the client exposes directly (§1.3
|
|
9
|
+
* transport-owned), so it is reported as `undefined` unless a future
|
|
10
|
+
* accessor lands — the hook surfaces what the core actually knows, never a
|
|
11
|
+
* guessed value.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
14
|
+
import { useSyncClient } from './use-client.js';
|
|
15
|
+
const INITIAL = {
|
|
16
|
+
outbox: 0,
|
|
17
|
+
upgrading: false,
|
|
18
|
+
leaseState: undefined,
|
|
19
|
+
schemaFloor: undefined,
|
|
20
|
+
syncNeeded: false,
|
|
21
|
+
isLoading: true,
|
|
22
|
+
};
|
|
23
|
+
export function useSyncStatus() {
|
|
24
|
+
const client = useSyncClient();
|
|
25
|
+
const [state, setState] = useState(INITIAL);
|
|
26
|
+
// `refresh` re-reads through a ref set by the effect (identity-stable, no
|
|
27
|
+
// tick state — biome-clean deps).
|
|
28
|
+
const readRef = useRef(() => { });
|
|
29
|
+
const refresh = useCallback(() => readRef.current(), []);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
let cancelled = false;
|
|
32
|
+
const read = () => {
|
|
33
|
+
Promise.all([
|
|
34
|
+
client.pendingCommits(),
|
|
35
|
+
client.upgrading(),
|
|
36
|
+
client.leaseState(),
|
|
37
|
+
client.schemaFloor(),
|
|
38
|
+
client.syncNeeded(),
|
|
39
|
+
])
|
|
40
|
+
.then(([pending, upgrading, leaseState, schemaFloor, syncNeeded]) => {
|
|
41
|
+
if (cancelled)
|
|
42
|
+
return;
|
|
43
|
+
setState({
|
|
44
|
+
outbox: pending.length,
|
|
45
|
+
upgrading,
|
|
46
|
+
leaseState,
|
|
47
|
+
schemaFloor,
|
|
48
|
+
syncNeeded,
|
|
49
|
+
isLoading: false,
|
|
50
|
+
});
|
|
51
|
+
})
|
|
52
|
+
.catch(() => {
|
|
53
|
+
if (!cancelled)
|
|
54
|
+
setState((s) => ({ ...s, isLoading: false }));
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
readRef.current = read;
|
|
58
|
+
read();
|
|
59
|
+
// Re-read on every apply batch — the "state changed" edge.
|
|
60
|
+
const unsubscribe = client.onInvalidate(read);
|
|
61
|
+
return () => {
|
|
62
|
+
cancelled = true;
|
|
63
|
+
unsubscribe();
|
|
64
|
+
};
|
|
65
|
+
}, [client]);
|
|
66
|
+
return { ...state, refresh };
|
|
67
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useTypedQuery` — the typed twin of {@link useSyncQuery}. You write a
|
|
3
|
+
* Kysely query builder; the hook compiles it to SQL, runs it live against the
|
|
4
|
+
* client, and extracts its `{tables}` dependency set from the compiled query's
|
|
5
|
+
* AST automatically — so invalidation is exact (no SQL-text heuristic) and
|
|
6
|
+
* fully typed by the schema's generated `Database` interface.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const { rows } = useTypedQuery<Database, Pick<TodosRow, 'id' | 'title'>>(
|
|
10
|
+
* (db) => db.selectFrom('todos').select(['id', 'title']).where('list_id', '=', listId),
|
|
11
|
+
* [listId],
|
|
12
|
+
* );
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* It reuses {@link useSyncQuery}'s invalidation machinery verbatim — the only
|
|
16
|
+
* additions are compilation and AST-based table extraction. Read-only, like
|
|
17
|
+
* the dialect: a write builder throws at execution (SPEC §7.1 → use
|
|
18
|
+
* `useMutation`). `@syncular/kysely` and `kysely` are PEER dependencies of
|
|
19
|
+
* this package (both are `optional`), so apps that only use `useSyncQuery`
|
|
20
|
+
* never pull Kysely in.
|
|
21
|
+
*/
|
|
22
|
+
import type { SqlRow } from '@syncular/client';
|
|
23
|
+
import type { Compilable, Kysely } from 'kysely';
|
|
24
|
+
import { type UseSyncQueryOptions, type UseSyncQueryResult } from './use-sync-query.js';
|
|
25
|
+
/**
|
|
26
|
+
* Build a live typed query. `build` receives a `Kysely<Database>` bound to the
|
|
27
|
+
* context client and returns any compilable query builder. `deps` re-keys the
|
|
28
|
+
* builder the same way a `useEffect` dep array does (values the query closes
|
|
29
|
+
* over, e.g. filter inputs). `options.enabled`/`scopeKeys` pass through; the
|
|
30
|
+
* `{tables}` set is derived from the compiled query, never guessed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function useTypedQuery<Database, Row = SqlRow>(build: (db: Kysely<Database>) => Compilable<Row>, deps?: readonly unknown[], options?: Omit<UseSyncQueryOptions, 'tables'>): UseSyncQueryResult<Row>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useTypedQuery` — the typed twin of {@link useSyncQuery}. You write a
|
|
3
|
+
* Kysely query builder; the hook compiles it to SQL, runs it live against the
|
|
4
|
+
* client, and extracts its `{tables}` dependency set from the compiled query's
|
|
5
|
+
* AST automatically — so invalidation is exact (no SQL-text heuristic) and
|
|
6
|
+
* fully typed by the schema's generated `Database` interface.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const { rows } = useTypedQuery<Database, Pick<TodosRow, 'id' | 'title'>>(
|
|
10
|
+
* (db) => db.selectFrom('todos').select(['id', 'title']).where('list_id', '=', listId),
|
|
11
|
+
* [listId],
|
|
12
|
+
* );
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* It reuses {@link useSyncQuery}'s invalidation machinery verbatim — the only
|
|
16
|
+
* additions are compilation and AST-based table extraction. Read-only, like
|
|
17
|
+
* the dialect: a write builder throws at execution (SPEC §7.1 → use
|
|
18
|
+
* `useMutation`). `@syncular/kysely` and `kysely` are PEER dependencies of
|
|
19
|
+
* this package (both are `optional`), so apps that only use `useSyncQuery`
|
|
20
|
+
* never pull Kysely in.
|
|
21
|
+
*/
|
|
22
|
+
import { createSyncularKysely, extractTables } from '@syncular/kysely';
|
|
23
|
+
import { useMemo } from 'react';
|
|
24
|
+
import { useSyncClient } from './use-client.js';
|
|
25
|
+
import { useSyncQuery, } from './use-sync-query.js';
|
|
26
|
+
/**
|
|
27
|
+
* Build a live typed query. `build` receives a `Kysely<Database>` bound to the
|
|
28
|
+
* context client and returns any compilable query builder. `deps` re-keys the
|
|
29
|
+
* builder the same way a `useEffect` dep array does (values the query closes
|
|
30
|
+
* over, e.g. filter inputs). `options.enabled`/`scopeKeys` pass through; the
|
|
31
|
+
* `{tables}` set is derived from the compiled query, never guessed.
|
|
32
|
+
*/
|
|
33
|
+
export function useTypedQuery(build, deps = [], options) {
|
|
34
|
+
const client = useSyncClient();
|
|
35
|
+
// One Kysely instance per client identity — the dialect drives the same
|
|
36
|
+
// normalized `query` surface the other hooks use, so every host works.
|
|
37
|
+
const db = useMemo(() => createSyncularKysely(client), [client]);
|
|
38
|
+
// Compile on the deps the caller declared. The compiled query yields SQL +
|
|
39
|
+
// parameters (for execution) and its AST (for exact table extraction).
|
|
40
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `deps` is the caller-declared re-key, `build`/`db` are stable-by-design
|
|
41
|
+
const compiled = useMemo(() => build(db).compile(), [db, ...deps]);
|
|
42
|
+
const tables = useMemo(() => extractTables(compiled), [compiled]);
|
|
43
|
+
return useSyncQuery(compiled.sql, compiled.parameters, { ...options, tables });
|
|
44
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useWindow(base)` — the windowed-sync surface for a component
|
|
3
|
+
* (SPEC.md §4.8 / DESIGN-eviction.md W1, I3). It manages the live window
|
|
4
|
+
* units for a base and exposes the **completeness oracle**: which scope
|
|
5
|
+
* values are held locally in full, so a consumer can render "this data may
|
|
6
|
+
* be partial" honestly instead of silently serving a partial replica as
|
|
7
|
+
* complete.
|
|
8
|
+
*
|
|
9
|
+
* - `setWindow(units)` swaps the live set (added units bootstrap via the
|
|
10
|
+
* image lane; removed units are evicted, fused with unsubscription).
|
|
11
|
+
* - `units` is the current windowed-in set (re-read on mount and whenever
|
|
12
|
+
* the base's table is invalidated — so a deferred eviction draining, or
|
|
13
|
+
* a re-entry bootstrapping, updates the verdict).
|
|
14
|
+
* - `isComplete(unit)` is the per-value verdict: a live query whose scope
|
|
15
|
+
* footprint includes a non-`isComplete` unit is a **window miss** — widen
|
|
16
|
+
* or show partial, never claim complete.
|
|
17
|
+
*/
|
|
18
|
+
import type { WindowBase } from '@syncular/client';
|
|
19
|
+
export interface UseWindowResult {
|
|
20
|
+
/** The scope values currently windowed-in for this base. */
|
|
21
|
+
readonly units: readonly string[];
|
|
22
|
+
/** Set the live units (widen/shrink diff, §4.8). */
|
|
23
|
+
readonly setWindow: (units: readonly string[]) => Promise<void>;
|
|
24
|
+
/** True iff `unit` is windowed-in (answerable in full locally, I3). */
|
|
25
|
+
readonly isComplete: (unit: string) => boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare function useWindow(base: WindowBase): UseWindowResult;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useSyncClient } from './use-client.js';
|
|
3
|
+
export function useWindow(base) {
|
|
4
|
+
const client = useSyncClient();
|
|
5
|
+
const [units, setUnits] = useState([]);
|
|
6
|
+
// A stable key so the effects re-run only when the base identity changes,
|
|
7
|
+
// not on every render's fresh object. The latest `base` is read via a ref
|
|
8
|
+
// inside the closures (the useSyncQuery pattern), so the dep list stays on
|
|
9
|
+
// primitive keys.
|
|
10
|
+
const baseKey = `${base.table} ${base.variable} ${JSON.stringify(base.fixedScopes ?? {})} ${base.params ?? ''}`;
|
|
11
|
+
const baseRef = useRef(base);
|
|
12
|
+
baseRef.current = base;
|
|
13
|
+
// `baseKey` re-keys the effect without being read in the body (biome cannot
|
|
14
|
+
// see the ref indirection) — the dep list is pinned deliberately.
|
|
15
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: baseKey re-keys the effect for a fresh base object
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
const read = () => {
|
|
19
|
+
Promise.resolve(client.windowState(baseRef.current))
|
|
20
|
+
.then((state) => {
|
|
21
|
+
if (!cancelled)
|
|
22
|
+
setUnits(state.units);
|
|
23
|
+
})
|
|
24
|
+
.catch(() => {
|
|
25
|
+
/* transient — the next invalidation re-reads */
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
read();
|
|
29
|
+
// Re-read when the base's table changes locally: a deferred eviction
|
|
30
|
+
// (E1) completing or a re-entry bootstrapping both invalidate it.
|
|
31
|
+
const unsubscribe = client.onInvalidate((event) => {
|
|
32
|
+
if (event.tables.has(baseRef.current.table))
|
|
33
|
+
read();
|
|
34
|
+
});
|
|
35
|
+
return () => {
|
|
36
|
+
cancelled = true;
|
|
37
|
+
unsubscribe();
|
|
38
|
+
};
|
|
39
|
+
}, [client, baseKey]);
|
|
40
|
+
const setWindow = useCallback((next) => {
|
|
41
|
+
const result = Promise.resolve(client.setWindow(baseRef.current, next));
|
|
42
|
+
// Optimistically reflect the new set; the invalidation-driven re-read
|
|
43
|
+
// reconciles against the registry (e.g. a pinned unit lingering).
|
|
44
|
+
setUnits(next);
|
|
45
|
+
return result;
|
|
46
|
+
}, [client]);
|
|
47
|
+
const isComplete = useCallback((unit) => units.includes(unit), [units]);
|
|
48
|
+
return { units, setWindow, isComplete };
|
|
49
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@syncular/react",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "React hooks for Syncular offline-first sync",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Benjamin Kniffler",
|
|
7
|
+
"homepage": "https://syncular.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/syncular/syncular.git",
|
|
11
|
+
"directory": "packages/react"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/syncular/syncular/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"sync",
|
|
18
|
+
"offline-first",
|
|
19
|
+
"realtime",
|
|
20
|
+
"database",
|
|
21
|
+
"typescript"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"bun": "./src/index.ts",
|
|
31
|
+
"browser": "./src/index.ts",
|
|
32
|
+
"import": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"./typed": {
|
|
38
|
+
"bun": "./src/typed.ts",
|
|
39
|
+
"browser": "./src/typed.ts",
|
|
40
|
+
"import": {
|
|
41
|
+
"types": "./dist/typed.d.ts",
|
|
42
|
+
"default": "./dist/typed.js"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"src",
|
|
49
|
+
"README.md",
|
|
50
|
+
"!src/**/*.test.ts",
|
|
51
|
+
"!src/**/*.test.tsx",
|
|
52
|
+
"!dist/**/*.test.js",
|
|
53
|
+
"!dist/**/*.test.d.ts"
|
|
54
|
+
],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"test": "bun test --preload ./test/setup.ts"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@syncular/client": "0.2.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"@syncular/kysely": "0.2.0",
|
|
63
|
+
"kysely": ">=0.27.0",
|
|
64
|
+
"react": ">=18.0.0"
|
|
65
|
+
},
|
|
66
|
+
"peerDependenciesMeta": {
|
|
67
|
+
"@syncular/kysely": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"kysely": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"@happy-dom/global-registrator": "^15.11.0",
|
|
76
|
+
"@syncular/core": "0.2.0",
|
|
77
|
+
"@syncular/kysely": "0.2.0",
|
|
78
|
+
"@syncular/server": "0.2.0",
|
|
79
|
+
"@testing-library/react": "^16.1.0",
|
|
80
|
+
"@types/react": "^18.3.0",
|
|
81
|
+
"kysely": "^0.29.2",
|
|
82
|
+
"react": "^18.3.1",
|
|
83
|
+
"react-dom": "^18.3.1"
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE client interface the React bindings target. Both the direct
|
|
3
|
+
* `SyncClient` (constructed on the current thread) and the worker-mode
|
|
4
|
+
* `SyncClientHandle` (a promise proxy over the OPFS worker) satisfy it —
|
|
5
|
+
* their public surfaces diverge (getters vs methods, sync vs promise), so
|
|
6
|
+
* this module normalizes both into a single async-friendly facade the hooks
|
|
7
|
+
* consume. That is the "one interface across direct and worker-handle modes"
|
|
8
|
+
* the invalidation seam (TODO 3.1) was standardized to enable.
|
|
9
|
+
*
|
|
10
|
+
* The normalizer resolves each accessor at call time (function → call it,
|
|
11
|
+
* value → read it) and wraps every result in `Promise.resolve`, so a hook
|
|
12
|
+
* never has to care which core it holds.
|
|
13
|
+
*/
|
|
14
|
+
import type {
|
|
15
|
+
ConflictRecord,
|
|
16
|
+
InvalidationListener,
|
|
17
|
+
LeaseState,
|
|
18
|
+
MutationInput,
|
|
19
|
+
PresencePeer,
|
|
20
|
+
RejectionRecord,
|
|
21
|
+
SchemaFloor,
|
|
22
|
+
SqlRow,
|
|
23
|
+
SqlValue,
|
|
24
|
+
WindowBase,
|
|
25
|
+
WindowState,
|
|
26
|
+
} from '@syncular/client';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The structural union of `SyncClient` and `SyncClientHandle`. Members that
|
|
30
|
+
* diverge are typed as "value or method, sync or promise"; {@link normalizeClient}
|
|
31
|
+
* collapses the divergence. Only what the hooks use is listed — the bindings
|
|
32
|
+
* never reach past this surface.
|
|
33
|
+
*/
|
|
34
|
+
export interface SyncClientLike {
|
|
35
|
+
onInvalidate(listener: InvalidationListener): () => void;
|
|
36
|
+
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
37
|
+
query(
|
|
38
|
+
sql: string,
|
|
39
|
+
params?: readonly SqlValue[],
|
|
40
|
+
): SqlRow[] | Promise<SqlRow[]>;
|
|
41
|
+
mutate(mutations: readonly MutationInput[]): string | Promise<string>;
|
|
42
|
+
conflicts:
|
|
43
|
+
| readonly ConflictRecord[]
|
|
44
|
+
| (() => readonly ConflictRecord[] | Promise<readonly ConflictRecord[]>);
|
|
45
|
+
rejections:
|
|
46
|
+
| readonly RejectionRecord[]
|
|
47
|
+
| (() => readonly RejectionRecord[] | Promise<readonly RejectionRecord[]>);
|
|
48
|
+
schemaFloor:
|
|
49
|
+
| SchemaFloor
|
|
50
|
+
| undefined
|
|
51
|
+
| (() => SchemaFloor | undefined | Promise<SchemaFloor | undefined>);
|
|
52
|
+
leaseState:
|
|
53
|
+
| LeaseState
|
|
54
|
+
| undefined
|
|
55
|
+
| (() => LeaseState | undefined | Promise<LeaseState | undefined>);
|
|
56
|
+
upgrading: boolean | (() => boolean | Promise<boolean>);
|
|
57
|
+
syncNeeded: boolean | (() => boolean | Promise<boolean>);
|
|
58
|
+
pendingCommits: () => unknown[] | Promise<unknown[]>;
|
|
59
|
+
presence(
|
|
60
|
+
scopeKey: string,
|
|
61
|
+
): readonly PresencePeer[] | Promise<readonly PresencePeer[]>;
|
|
62
|
+
setPresence(
|
|
63
|
+
scopeKey: string,
|
|
64
|
+
doc: Record<string, unknown> | null,
|
|
65
|
+
): void | Promise<void>;
|
|
66
|
+
/** §4.8 windowed subscriptions: set the live units for a window base. */
|
|
67
|
+
setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
|
|
68
|
+
/** §4.8 completeness oracle (I3): the windowed-in units for a base. */
|
|
69
|
+
windowState(base: WindowBase): WindowState | Promise<WindowState>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read a member by key that is EITHER a value getter (SyncClient) or a
|
|
74
|
+
* method returning a value/promise (SyncClientHandle). Read from the client
|
|
75
|
+
* so a method keeps its `this` binding; if the read is a function, call it.
|
|
76
|
+
*/
|
|
77
|
+
function resolveMember<T>(
|
|
78
|
+
client: SyncClientLike,
|
|
79
|
+
key: keyof SyncClientLike,
|
|
80
|
+
): Promise<T> {
|
|
81
|
+
const member = client[key] as unknown;
|
|
82
|
+
const value =
|
|
83
|
+
typeof member === 'function'
|
|
84
|
+
? (member as (this: SyncClientLike) => T | Promise<T>).call(client)
|
|
85
|
+
: (member as T);
|
|
86
|
+
return Promise.resolve(value as T);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The uniform async facade the hooks actually call. */
|
|
90
|
+
export interface NormalizedClient {
|
|
91
|
+
onInvalidate(listener: InvalidationListener): () => void;
|
|
92
|
+
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
93
|
+
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
|
94
|
+
mutate(mutations: readonly MutationInput[]): Promise<string>;
|
|
95
|
+
conflicts(): Promise<readonly ConflictRecord[]>;
|
|
96
|
+
rejections(): Promise<readonly RejectionRecord[]>;
|
|
97
|
+
schemaFloor(): Promise<SchemaFloor | undefined>;
|
|
98
|
+
leaseState(): Promise<LeaseState | undefined>;
|
|
99
|
+
upgrading(): Promise<boolean>;
|
|
100
|
+
syncNeeded(): Promise<boolean>;
|
|
101
|
+
pendingCommits(): Promise<unknown[]>;
|
|
102
|
+
presence(scopeKey: string): Promise<readonly PresencePeer[]>;
|
|
103
|
+
setPresence(
|
|
104
|
+
scopeKey: string,
|
|
105
|
+
doc: Record<string, unknown> | null,
|
|
106
|
+
): Promise<void>;
|
|
107
|
+
setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
|
|
108
|
+
windowState(base: WindowBase): Promise<WindowState>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function normalizeClient(client: SyncClientLike): NormalizedClient {
|
|
112
|
+
return {
|
|
113
|
+
onInvalidate: (listener) => client.onInvalidate(listener),
|
|
114
|
+
onPresence: (listener) => client.onPresence(listener),
|
|
115
|
+
query: (sql, params) => Promise.resolve(client.query(sql, params)),
|
|
116
|
+
mutate: (mutations) => Promise.resolve(client.mutate(mutations)),
|
|
117
|
+
conflicts: () => resolveMember(client, 'conflicts'),
|
|
118
|
+
rejections: () => resolveMember(client, 'rejections'),
|
|
119
|
+
schemaFloor: () => resolveMember(client, 'schemaFloor'),
|
|
120
|
+
leaseState: () => resolveMember(client, 'leaseState'),
|
|
121
|
+
upgrading: () => resolveMember(client, 'upgrading'),
|
|
122
|
+
syncNeeded: () => resolveMember(client, 'syncNeeded'),
|
|
123
|
+
pendingCommits: () => resolveMember(client, 'pendingCommits'),
|
|
124
|
+
presence: (scopeKey) => Promise.resolve(client.presence(scopeKey)),
|
|
125
|
+
setPresence: (scopeKey, doc) =>
|
|
126
|
+
Promise.resolve(client.setPresence(scopeKey, doc)),
|
|
127
|
+
setWindow: (base, units) => Promise.resolve(client.setWindow(base, units)),
|
|
128
|
+
windowState: (base) => Promise.resolve(client.windowState(base)),
|
|
129
|
+
};
|
|
130
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/react — React bindings with fine-grained live queries
|
|
3
|
+
* (TODO 3.1 / DESIGN-eviction I1–I4). Works against BOTH `SyncClient`
|
|
4
|
+
* (direct) and `SyncClientHandle` (worker) through one normalized client
|
|
5
|
+
* interface. React 18+ (react is a peer dependency); no other runtime deps.
|
|
6
|
+
*
|
|
7
|
+
* See README.md for the invalidation granularity truth and the `tables`
|
|
8
|
+
* option.
|
|
9
|
+
*/
|
|
10
|
+
export type {
|
|
11
|
+
NormalizedClient,
|
|
12
|
+
SyncClientLike,
|
|
13
|
+
} from './client';
|
|
14
|
+
// `normalizeClient` is the runtime facade the hooks consume; exported so
|
|
15
|
+
// alternate hosts (e.g. `@syncular/tauri`) can assert shape-parity against
|
|
16
|
+
// the exact normalizer the bindings use.
|
|
17
|
+
export { normalizeClient } from './client';
|
|
18
|
+
export { inferTables } from './infer-tables';
|
|
19
|
+
export { SyncContext, SyncProvider, type SyncProviderProps } from './provider';
|
|
20
|
+
export { useSyncClient } from './use-client';
|
|
21
|
+
export { type UseConflictsResult, useConflicts } from './use-conflicts';
|
|
22
|
+
export { type UseMutationResult, useMutation } from './use-mutation';
|
|
23
|
+
export {
|
|
24
|
+
type NamedQueryDescriptor,
|
|
25
|
+
useNamedQuery,
|
|
26
|
+
} from './use-named-query';
|
|
27
|
+
export { usePresence } from './use-presence';
|
|
28
|
+
export {
|
|
29
|
+
type UseSyncQueryOptions,
|
|
30
|
+
type UseSyncQueryResult,
|
|
31
|
+
useSyncQuery,
|
|
32
|
+
} from './use-sync-query';
|
|
33
|
+
export { type SyncStatus, useSyncStatus } from './use-sync-status';
|
|
34
|
+
export { type UseWindowResult, useWindow } from './use-window';
|
|
35
|
+
// NOTE: `useTypedQuery` is intentionally NOT re-exported here — it needs the
|
|
36
|
+
// `@syncular/kysely` + `kysely` peers. It lives behind the `./typed`
|
|
37
|
+
// subpath so apps using only `useSyncQuery` never pull Kysely into their
|
|
38
|
+
// bundle. Import it as `@syncular/react/typed`.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative table inference for `useSyncQuery` (TODO 3.1: "infer
|
|
3
|
+
* conservatively from the SQL text's table names … documented as a
|
|
4
|
+
* heuristic with the explicit option as the escape hatch").
|
|
5
|
+
*
|
|
6
|
+
* This is a SIMPLE identifier scan, NOT a SQL parser. It collects the
|
|
7
|
+
* identifiers that follow `FROM` and `JOIN` — the tables a SELECT reads.
|
|
8
|
+
* It is deliberately over-inclusive at the edges (a table aliased in a CTE,
|
|
9
|
+
* a function-table, an odd quoting style) because the failure mode of
|
|
10
|
+
* over-inclusion is a harmless extra re-run, whereas under-inclusion is a
|
|
11
|
+
* stale query — the one thing live queries must never do. When a query's
|
|
12
|
+
* real dependencies cannot be read off its text (dynamic SQL, views,
|
|
13
|
+
* unusual syntax), pass the explicit `tables` option; that always wins.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const FROM_JOIN_RE =
|
|
17
|
+
/\b(?:from|join)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([a-zA-Z_][a-zA-Z0-9_$]*))/gi;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract the table names a SELECT reads. Returns a de-duplicated,
|
|
21
|
+
* lower-nothing set (identifiers are compared to invalidation table names
|
|
22
|
+
* as-is — SQLite table names are case-sensitive for our generated schema).
|
|
23
|
+
*/
|
|
24
|
+
export function inferTables(sql: string): Set<string> {
|
|
25
|
+
const tables = new Set<string>();
|
|
26
|
+
// Strip a leading schema qualifier (`main.tasks` → `tasks`) so the name
|
|
27
|
+
// matches the invalidation event's bare table name.
|
|
28
|
+
for (const match of sql.matchAll(FROM_JOIN_RE)) {
|
|
29
|
+
const raw = match[1] ?? match[2] ?? match[3] ?? match[4];
|
|
30
|
+
if (raw === undefined) continue;
|
|
31
|
+
const dot = raw.lastIndexOf('.');
|
|
32
|
+
tables.add(dot === -1 ? raw : raw.slice(dot + 1));
|
|
33
|
+
}
|
|
34
|
+
return tables;
|
|
35
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SyncProvider` — supplies a `SyncClient` or `SyncClientHandle` to the
|
|
3
|
+
* hook tree through React context. One provider per client; the hooks read
|
|
4
|
+
* the normalized facade. Written with `createElement` (no JSX) so the whole
|
|
5
|
+
* package typechecks under the repo's `.ts`-only root tsconfig with no jsx
|
|
6
|
+
* setting — the bindings are plain function components either way.
|
|
7
|
+
*/
|
|
8
|
+
import { createContext, createElement, type ReactNode, useMemo } from 'react';
|
|
9
|
+
import {
|
|
10
|
+
type NormalizedClient,
|
|
11
|
+
normalizeClient,
|
|
12
|
+
type SyncClientLike,
|
|
13
|
+
} from './client';
|
|
14
|
+
|
|
15
|
+
export const SyncContext = createContext<NormalizedClient | undefined>(
|
|
16
|
+
undefined,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export interface SyncProviderProps {
|
|
20
|
+
/** A `SyncClient` (direct) or `SyncClientHandle` (worker) — both satisfy it. */
|
|
21
|
+
readonly client: SyncClientLike;
|
|
22
|
+
readonly children?: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SyncProvider(props: SyncProviderProps): ReactNode {
|
|
26
|
+
// Re-normalize only when the client identity changes.
|
|
27
|
+
const normalized = useMemo(
|
|
28
|
+
() => normalizeClient(props.client),
|
|
29
|
+
[props.client],
|
|
30
|
+
);
|
|
31
|
+
return createElement(
|
|
32
|
+
SyncContext.Provider,
|
|
33
|
+
{ value: normalized },
|
|
34
|
+
props.children,
|
|
35
|
+
);
|
|
36
|
+
}
|