@doync/react 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 +169 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +146 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.cjs +0 -0
- package/dist/internal.d.cts +1 -0
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +0 -0
- package/dist/mobile.cjs +1 -0
- package/dist/mobile.d.cts +13 -0
- package/dist/mobile.d.cts.map +1 -0
- package/dist/mobile.d.ts +13 -0
- package/dist/mobile.d.ts.map +1 -0
- package/dist/mobile.js +2 -0
- package/dist/mobile.js.map +1 -0
- package/dist/use-client-D1I3o0t5.cjs +1 -0
- package/dist/use-client-DanwzoBF.js +2 -0
- package/dist/use-client-DanwzoBF.js.map +1 -0
- package/dist/web.cjs +1 -0
- package/dist/web.d.cts +13 -0
- package/dist/web.d.cts.map +1 -0
- package/dist/web.d.ts +13 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +2 -0
- package/dist/web.js.map +1 -0
- package/package.json +104 -0
- package/src/hooks.tsx +442 -0
- package/src/index.tsx +61 -0
- package/src/internal.ts +11 -0
- package/src/mobile.ts +68 -0
- package/src/provider.tsx +35 -0
- package/src/use-client.ts +299 -0
- package/src/web.ts +71 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
let e=require("react");function t(t,r,a){let[o,s]=(0,e.useState)(null),c=(0,e.useRef)({client:null,options:null}),l=(0,e.useRef)(r);l.current=r;let u=(0,e.useRef)(a);return u.current=a,(0,e.useLayoutEffect)(()=>{let e=c.current.options,r=t,a=c.current.client,o=l.current,d=u.current;if(r!==null&&e!==null&&a!==null){if(i(e,r,d)){c.current.options=r;return}let t=n(e,r,a,o,d);t===null?c.current.options=r:(c.current={client:t,options:r},s(t));return}if(r!==null&&(e===null||a===null)){let e=o(r);c.current={client:e,options:r},s(e);return}r===null&&a!==null&&(a.close(),c.current={client:null,options:null},s(null))}),(0,e.useLayoutEffect)(()=>()=>{c.current.client?.close(),c.current={client:null,options:null}},[]),o}function n(e,t,n,i,o){for(let n of s(o))if(o[n]===`throw`&&!a(e[n],t[n]))throw Error(`doync: useClient cannot change ${String(n)} after creation — close and recreate with the new value`);for(let r of s(o))if(o[r]===`identity`&&!a(e[r],t[r]))return n.close(),i(t);let c=r(e,t,o);c!==null&&n.setAuth(c);for(let r of s(o))if(o[r]===`logoutBehavior`&&!a(e[r],t[r])){let e=t[r];e!==void 0&&n.setLogoutBehavior(e)}return null}function r(e,t,n){let r=!1,i=!1,o=!1,c=!1,l,u,d;for(let f of s(n)){if(n[f]!==`auth`)continue;let s=f,p=s===`ctx`;a(e[f],t[f],p)||(r=!0),s===`token`?(i=!0,l=t[f]):s===`userId`?(o=!0,u=t[f]):s===`ctx`&&(c=!0,d=t[f])}if(!r)return null;let f={token:i?l??null:e.token??null,userId:o?u??null:e.userId??null};return c?f.ctx=d:`ctx`in e&&(f.ctx=e.ctx),f}function i(e,t,n){for(let r of s(n)){let i=n[r];if(i===void 0||i===`ignore`)continue;let o=i===`auth`&&r===`ctx`;if(!a(e[r],t[r],o))return!1}return!0}function a(e,t,n=!1){return n?o(e)===o(t):Object.is(e,t)}function o(e){try{return JSON.stringify(e)??`undefined`}catch{return`\0ref:${typeof e}`}}function s(e){return Object.keys(e)}Object.defineProperty(exports,"t",{enumerable:!0,get:function(){return t}});
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{useLayoutEffect as e,useRef as t,useState as n}from"react";function r(r,a,s){let[c,l]=n(null),u=t({client:null,options:null}),d=t(a);d.current=a;let f=t(s);return f.current=s,e(()=>{let e=u.current.options,t=r,n=u.current.client,a=d.current,s=f.current;if(t!==null&&e!==null&&n!==null){if(o(e,t,s)){u.current.options=t;return}let r=i(e,t,n,a,s);r===null?u.current.options=t:(u.current={client:r,options:t},l(r));return}if(t!==null&&(e===null||n===null)){let e=a(t);u.current={client:e,options:t},l(e);return}t===null&&n!==null&&(n.close(),u.current={client:null,options:null},l(null))}),e(()=>()=>{u.current.client?.close(),u.current={client:null,options:null}},[]),c}function i(e,t,n,r,i){for(let n of l(i))if(i[n]===`throw`&&!s(e[n],t[n]))throw Error(`doync: useClient cannot change ${String(n)} after creation — close and recreate with the new value`);for(let a of l(i))if(i[a]===`identity`&&!s(e[a],t[a]))return n.close(),r(t);let o=a(e,t,i);o!==null&&n.setAuth(o);for(let r of l(i))if(i[r]===`logoutBehavior`&&!s(e[r],t[r])){let e=t[r];e!==void 0&&n.setLogoutBehavior(e)}return null}function a(e,t,n){let r=!1,i=!1,a=!1,o=!1,c,u,d;for(let f of l(n)){if(n[f]!==`auth`)continue;let l=f,p=l===`ctx`;s(e[f],t[f],p)||(r=!0),l===`token`?(i=!0,c=t[f]):l===`userId`?(a=!0,u=t[f]):l===`ctx`&&(o=!0,d=t[f])}if(!r)return null;let f={token:i?c??null:e.token??null,userId:a?u??null:e.userId??null};return o?f.ctx=d:`ctx`in e&&(f.ctx=e.ctx),f}function o(e,t,n){for(let r of l(n)){let i=n[r];if(i===void 0||i===`ignore`)continue;let a=i===`auth`&&r===`ctx`;if(!s(e[r],t[r],a))return!1}return!0}function s(e,t,n=!1){return n?c(e)===c(t):Object.is(e,t)}function c(e){try{return JSON.stringify(e)??`undefined`}catch{return`\0ref:${typeof e}`}}function l(e){return Object.keys(e)}export{r as t};
|
|
2
|
+
//# sourceMappingURL=use-client-DanwzoBF.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-client-DanwzoBF.js","names":[],"sources":["../src/use-client.ts"],"sourcesContent":["/**\n * Generic client lifecycle hook. Holds a client for as long as the options bag\n * is non-null and routes option changes onto `createClient` / `close` /\n * `setAuth` / `setLogoutBehavior`, or throws — never invents its own lifecycle.\n * Platform wrappers (`useMobileClient`, `useWebClient`) supply the factory and\n * a {@link ClientOptionPolicy}; this module imports no platform package.\n *\n * Options are compared by value (fresh object literals every render are fine;\n * no `useMemo` required). `ctx` is compared by JSON. StrictMode double-mount is\n * create → close → create and is safe: ownership lives in a layout-effect\n * cleanup, not in render.\n */\n\nimport type { LogoutBehavior } from '@doync/client'\n\nimport { useLayoutEffect, useRef, useState } from 'react'\n\n/**\n * Minimum call surface the hook routes onto. Platform clients (`MobileClient`,\n * `WebClient`) both satisfy this; tests inject a fake.\n */\nexport interface ClientLifecycle {\n close(): void\n setAuth(auth: {\n token: string | null\n ctx?: unknown\n userId: string | null\n }): void\n setLogoutBehavior(behavior: LogoutBehavior): void\n}\n\n/**\n * How a single options-bag key is handled when its value changes between\n * renders. Keys absent from the policy are not compared (construction-only\n * values like `schema` / `queries` — module-level constants in real apps).\n *\n * - `identity` — close the old client and create a new one (e.g. a different\n * database `name` is a different client; no in-place path).\n * - `auth` — `token` / `userId` / `ctx` feed a single `setAuth` call (`ctx` is\n * compared by JSON).\n * - `logoutBehavior` — call `setLogoutBehavior`.\n * - `throw` — value change throws (e.g. `url`). Checked before `identity`, so a\n * same-render name+url swap cannot silently reconnect elsewhere.\n * - `ignore` — captured at creation; later changes are ignored (e.g. web's\n * `worker` factory, whose required inline-literal form makes the reference\n * unstable across renders).\n */\nexport type OptionDisposition =\n | 'identity'\n | 'auth'\n | 'logoutBehavior'\n | 'throw'\n | 'ignore'\n\n/**\n * Per-platform table mapping option keys to {@link OptionDisposition}. Keys not\n * listed are not compared; unknown keys on the options bag are ignored.\n */\nexport type ClientOptionPolicy<TOptions extends object> = {\n readonly [K in keyof TOptions]?: OptionDisposition\n}\n\n/** Live ownership slot shared by the diff effect and the unmount cleanup. */\ninterface Owned<TOptions, TClient> {\n client: TClient | null\n options: TOptions | null\n}\n\n// ADR-0034 / closeio/doync#281 — routes onto create/close/setAuth/\n// setLogoutBehavior; throw-before-identity; name-swap recreates.\n/**\n * Hold a client for as long as `options` is non-null. Pass `null` until the\n * database id is known (e.g. after a create-session mutation acks). Option\n * changes route onto client verbs per `policy` — see\n * {@link OptionDisposition}.\n */\nexport function useClient<\n TOptions extends object,\n TClient extends ClientLifecycle,\n>(\n options: TOptions | null,\n createClient: (options: TOptions) => TClient,\n policy: ClientOptionPolicy<TOptions>,\n): TClient | null {\n const [client, setClient] = useState<TClient | null>(null)\n // Ownership lives in a ref so the unmount cleanup and the diff path share one\n // source of truth without putting unstable identities into effect deps.\n const owned = useRef<Owned<TOptions, TClient>>({\n client: null,\n options: null,\n })\n const createRef = useRef(createClient)\n createRef.current = createClient\n const policyRef = useRef(policy)\n policyRef.current = policy\n\n // Diff-and-route runs after every commit. Equal-by-value options are a no-op\n // so a fresh options literal every render neither recreates nor re-verbs.\n // Creation/teardown ownership lives here — NOT in render — so StrictMode's\n // double-mount is create → cleanup-close → create, and nothing leaks.\n useLayoutEffect(() => {\n const prev = owned.current.options\n const next = options\n const current = owned.current.client\n const create = createRef.current\n const pol = policyRef.current\n\n if (next !== null && prev !== null && current !== null) {\n if (optionsEqualByPolicy(prev, next, pol)) {\n // Keep the latest reference so subsequent diffs start from what the\n // caller last passed (in case a nested object was mutated — rare; the\n // equality check already saw them as equal).\n owned.current.options = next\n return\n }\n const created = applyChange(prev, next, current, create, pol)\n if (created !== null) {\n owned.current = { client: created, options: next }\n setClient(created)\n } else {\n // In-place verb path: live client kept, snapshot advances.\n owned.current.options = next\n }\n return\n }\n\n if (next !== null && (prev === null || current === null)) {\n // null → options (or remount after StrictMode cleanup cleared the refs).\n const created = create(next)\n owned.current = { client: created, options: next }\n setClient(created)\n return\n }\n\n if (next === null && current !== null) {\n // options → null: close and drop.\n current.close()\n owned.current = { client: null, options: null }\n setClient(null)\n }\n })\n\n // Unmount-only cleanup. Separate effect so the diff path above can re-run on\n // every options-change commit WITHOUT closing between equal renders.\n useLayoutEffect(() => {\n return () => {\n owned.current.client?.close()\n owned.current = { client: null, options: null }\n }\n }, [])\n\n return client\n}\n\n// ── internals ──────────────────────────────────────────────────────────────\n\n/**\n * Route a non-equal options change onto existing verbs. Returns a freshly\n * created client when identity keys moved (after closing the old one); returns\n * null when the live client was kept (auth / logoutBehavior routing, or throw\n * aborted before any mutation — throw propagates).\n */\nfunction applyChange<TOptions extends object, TClient extends ClientLifecycle>(\n prev: TOptions,\n next: TOptions,\n current: TClient,\n create: (options: TOptions) => TClient,\n policy: ClientOptionPolicy<TOptions>,\n): TClient | null {\n // Throw-on-change FIRST (url): a config bug must never silently reconnect\n // elsewhere, even when bundled with a name swap that would otherwise rebuild.\n for (const key of policyKeys(policy)) {\n if (policy[key] !== 'throw') continue\n if (!valueEqual(prev[key], next[key])) {\n throw new Error(\n `doync: useClient cannot change ${String(key)} after creation — close and recreate with the new value`,\n )\n }\n }\n\n // Identity keys — a name swap is a different Client; no in-place path. Fresh\n // create carries the full next bag (auth/logout already in the new options).\n for (const key of policyKeys(policy)) {\n if (policy[key] !== 'identity') continue\n if (!valueEqual(prev[key], next[key])) {\n current.close()\n return create(next)\n }\n }\n\n // Auth triple — one setAuth if any of token/userId/ctx drifted.\n const auth = resolveAuthTriple(prev, next, policy)\n if (auth !== null) current.setAuth(auth)\n\n // logoutBehavior — only call when it actually moved.\n for (const key of policyKeys(policy)) {\n if (policy[key] !== 'logoutBehavior') continue\n if (!valueEqual(prev[key], next[key])) {\n const behavior = next[key] as LogoutBehavior | undefined\n if (behavior !== undefined) current.setLogoutBehavior(behavior)\n }\n }\n\n return null\n}\n\n/**\n * Returns the asserted auth triple when any policy-`auth` key moved, else null.\n * setAuth's contract is a full identity assert, never a patch — fall back to\n * prev for any auth key the next bag omitted.\n */\nfunction resolveAuthTriple<TOptions extends object>(\n prev: TOptions,\n next: TOptions,\n policy: ClientOptionPolicy<TOptions>,\n): {\n token: string | null\n userId: string | null\n ctx?: unknown\n} | null {\n let changed = false\n let sawToken = false\n let sawUserId = false\n let sawCtx = false\n let token: unknown\n let userId: unknown\n let ctx: unknown\n for (const key of policyKeys(policy)) {\n if (policy[key] !== 'auth') continue\n const pk = key as keyof TOptions & string\n const serialize = pk === 'ctx'\n if (!valueEqual(prev[key], next[key], serialize)) changed = true\n if (pk === 'token') {\n sawToken = true\n token = next[key]\n } else if (pk === 'userId') {\n sawUserId = true\n userId = next[key]\n } else if (pk === 'ctx') {\n sawCtx = true\n ctx = next[key]\n }\n }\n if (!changed) return null\n const auth: {\n token: string | null\n userId: string | null\n ctx?: unknown\n } = {\n token: sawToken\n ? ((token as string | null | undefined) ?? null)\n : ((prev as { token?: string | null }).token ?? null),\n userId: sawUserId\n ? ((userId as string | null | undefined) ?? null)\n : ((prev as { userId?: string | null }).userId ?? null),\n }\n if (sawCtx) auth.ctx = ctx\n else if ('ctx' in (prev as object)) {\n auth.ctx = (prev as { ctx?: unknown }).ctx\n }\n return auth\n}\n\nfunction optionsEqualByPolicy<TOptions extends object>(\n a: TOptions,\n b: TOptions,\n policy: ClientOptionPolicy<TOptions>,\n): boolean {\n for (const key of policyKeys(policy)) {\n const disposition = policy[key]\n if (disposition === undefined || disposition === 'ignore') continue\n const serialize = disposition === 'auth' && key === 'ctx'\n if (!valueEqual(a[key], b[key], serialize)) return false\n }\n return true\n}\n\nfunction valueEqual(a: unknown, b: unknown, serialize = false): boolean {\n // ctx compared serialized (shared-hub posture, ADR-0034) when asked.\n if (serialize) return jsonStable(a) === jsonStable(b)\n return Object.is(a, b)\n}\n\nfunction jsonStable(value: unknown): string {\n try {\n return JSON.stringify(value) ?? 'undefined'\n } catch {\n // Non-serializable ctx: fall back to type tag so a throw here never\n // bubbles out of a compare. Reference identity is lost — non-JSON ctx is\n // already outside the hub's contract.\n return `\\0ref:${typeof value}`\n }\n}\n\nfunction policyKeys<TOptions extends object>(\n policy: ClientOptionPolicy<TOptions>,\n): (keyof TOptions)[] {\n return Object.keys(policy) as (keyof TOptions)[]\n}\n"],"mappings":"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"}
|
package/dist/web.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./use-client-D1I3o0t5.cjs");let t=require("@doync/web");const n={name:`identity`,url:`throw`,token:`auth`,userId:`auth`,ctx:`auth`,logoutBehavior:`logoutBehavior`,worker:`ignore`};function r(r){return e.t(r,t.createWebClient,n)}exports.useWebClient=r;
|
package/dist/web.d.cts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { CreateWebClientOptions, WebClient } from "@doync/web";
|
|
2
|
+
|
|
3
|
+
//#region src/web.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Hold a {@link WebClient} for as long as `options` is non-null. Auth and
|
|
6
|
+
* logout-behavior changes apply in place; a `name` change recreates the client;
|
|
7
|
+
* a `url` change throws; `worker` is fixed at creation. Pass `null` until the
|
|
8
|
+
* database id is known, then hand the result to `<DoyncProvider>`.
|
|
9
|
+
*/
|
|
10
|
+
declare function useWebClient<TAuthData = unknown>(options: CreateWebClientOptions<TAuthData> | null): WebClient | null;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { type CreateWebClientOptions, type WebClient, useWebClient };
|
|
13
|
+
//# sourceMappingURL=web.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web.d.cts","names":[],"sources":["../src/web.ts"],"mappings":";;;;;;;;;iBA4DgB,YAAA,sBACd,OAAA,EAAS,sBAAA,CAAuB,SAAA,WAC/B,SAAA"}
|
package/dist/web.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { CreateWebClientOptions, WebClient } from "@doync/web";
|
|
2
|
+
|
|
3
|
+
//#region src/web.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Hold a {@link WebClient} for as long as `options` is non-null. Auth and
|
|
6
|
+
* logout-behavior changes apply in place; a `name` change recreates the client;
|
|
7
|
+
* a `url` change throws; `worker` is fixed at creation. Pass `null` until the
|
|
8
|
+
* database id is known, then hand the result to `<DoyncProvider>`.
|
|
9
|
+
*/
|
|
10
|
+
declare function useWebClient<TAuthData = unknown>(options: CreateWebClientOptions<TAuthData> | null): WebClient | null;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { type CreateWebClientOptions, type WebClient, useWebClient };
|
|
13
|
+
//# sourceMappingURL=web.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web.d.ts","names":[],"sources":["../src/web.ts"],"mappings":";;;;;;;;;iBA4DgB,YAAA,sBACd,OAAA,EAAS,sBAAA,CAAuB,SAAA,WAC/B,SAAA"}
|
package/dist/web.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./use-client-DanwzoBF.js";import{createWebClient as t}from"@doync/web";const n={name:`identity`,url:`throw`,token:`auth`,userId:`auth`,ctx:`auth`,logoutBehavior:`logoutBehavior`,worker:`ignore`};function r(r){return e(r,t,n)}export{r as useWebClient};
|
|
2
|
+
//# sourceMappingURL=web.js.map
|
package/dist/web.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web.js","names":[],"sources":["../src/web.ts"],"sourcesContent":["/**\n * `@doync/react/web` — {@link useWebClient}, a thin wrapper around\n * {@link useClient} that supplies `createWebClient` and the web option policy.\n * Imports `@doync/web` as an optional peer of `@doync/react`, so a mobile-only\n * app never pulls it in.\n *\n * ```ts\n * import { useWebClient } from '@doync/react/web'\n *\n * const client = useWebClient(\n * id\n * ? {\n * name: id,\n * worker: () =>\n * new Worker(new URL('./worker.ts', import.meta.url), {\n * type: 'module',\n * }),\n * url: 'wss://example.com/sync',\n * token,\n * userId,\n * ctx,\n * }\n * : null,\n * )\n * // pass client to <DoyncProvider> once non-null\n * ```\n */\n\nimport type { CreateWebClientOptions, WebClient } from '@doync/web'\n\nimport { createWebClient } from '@doync/web'\n\nimport { useClient, type ClientOptionPolicy } from './use-client'\n\n/**\n * Web option dispositions (ADR-0034). Schema / queries / mutations (and any\n * monorepo-only injects on `CreateWebClientOptionsForTest`) are construction-\n * time constants — the hook does not police them (absent from the policy = not\n * compared).\n *\n * `url` is throw-on-change: a config bug cannot silently reconnect elsewhere.\n * `worker` is IGNORE: captured at creation; later changes are ignored — its\n * required inline-literal form makes the reference unstable across renders.\n */\nconst WEB_CLIENT_POLICY: ClientOptionPolicy<CreateWebClientOptions> = {\n name: 'identity',\n url: 'throw',\n token: 'auth',\n userId: 'auth',\n ctx: 'auth',\n logoutBehavior: 'logoutBehavior',\n worker: 'ignore',\n}\n\n/**\n * Hold a {@link WebClient} for as long as `options` is non-null. Auth and\n * logout-behavior changes apply in place; a `name` change recreates the client;\n * a `url` change throws; `worker` is fixed at creation. Pass `null` until the\n * database id is known, then hand the result to `<DoyncProvider>`.\n */\nexport function useWebClient<TAuthData = unknown>(\n options: CreateWebClientOptions<TAuthData> | null,\n): WebClient | null {\n return useClient(\n options as CreateWebClientOptions | null,\n createWebClient,\n WEB_CLIENT_POLICY as ClientOptionPolicy<CreateWebClientOptions>,\n )\n}\n\nexport type { CreateWebClientOptions, WebClient }\n"],"mappings":"0FA4CA,MAAM,EAAgE,CACpE,KAAM,WACN,IAAK,QACL,MAAO,OACP,OAAQ,OACR,IAAK,OACL,eAAgB,iBAChB,OAAQ,QACV,EAQA,SAAgB,EACd,EACkB,CAClB,OAAO,EACL,EACA,EACA,CACF,CACF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@doync/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "doync React adapter: thin useQuery / useQueryOnce / useLocalQuery / useMutation hooks over the client call surface",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"doync",
|
|
7
|
+
"hooks",
|
|
8
|
+
"local-first",
|
|
9
|
+
"react",
|
|
10
|
+
"sync-engine"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Vitor Buzinaro",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/closeio/doync.git",
|
|
17
|
+
"directory": "packages/react"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./dist/index.cjs",
|
|
25
|
+
"module": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"import": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"require": {
|
|
34
|
+
"types": "./dist/index.d.cts",
|
|
35
|
+
"default": "./dist/index.cjs"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"./web": {
|
|
39
|
+
"import": {
|
|
40
|
+
"types": "./dist/web.d.ts",
|
|
41
|
+
"default": "./dist/web.js"
|
|
42
|
+
},
|
|
43
|
+
"require": {
|
|
44
|
+
"types": "./dist/web.d.cts",
|
|
45
|
+
"default": "./dist/web.cjs"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"./mobile": {
|
|
49
|
+
"import": {
|
|
50
|
+
"types": "./dist/mobile.d.ts",
|
|
51
|
+
"default": "./dist/mobile.js"
|
|
52
|
+
},
|
|
53
|
+
"require": {
|
|
54
|
+
"types": "./dist/mobile.d.cts",
|
|
55
|
+
"default": "./dist/mobile.cjs"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"./internal": {
|
|
59
|
+
"import": {
|
|
60
|
+
"types": "./dist/internal.d.ts",
|
|
61
|
+
"default": "./dist/internal.js"
|
|
62
|
+
},
|
|
63
|
+
"require": {
|
|
64
|
+
"types": "./dist/internal.d.cts",
|
|
65
|
+
"default": "./dist/internal.cjs"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"@doync/client": "0.1.0",
|
|
71
|
+
"@doync/core": "0.1.0"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@testing-library/react": "^16.1.0",
|
|
75
|
+
"@types/node": "^24.13.2",
|
|
76
|
+
"@types/react": "^19.0.0",
|
|
77
|
+
"jsdom": "^25.0.1",
|
|
78
|
+
"react": "^19.0.0",
|
|
79
|
+
"react-dom": "^19.0.0",
|
|
80
|
+
"tsdown": "^0.22.3",
|
|
81
|
+
"typescript": "^7.0.2",
|
|
82
|
+
"vitest": "4.1.9",
|
|
83
|
+
"@doync/mobile": "0.1.0",
|
|
84
|
+
"@doync/web": "0.1.0"
|
|
85
|
+
},
|
|
86
|
+
"peerDependencies": {
|
|
87
|
+
"@doync/mobile": "0.x",
|
|
88
|
+
"@doync/web": "0.x",
|
|
89
|
+
"react": ">=18"
|
|
90
|
+
},
|
|
91
|
+
"peerDependenciesMeta": {
|
|
92
|
+
"@doync/mobile": {
|
|
93
|
+
"optional": true
|
|
94
|
+
},
|
|
95
|
+
"@doync/web": {
|
|
96
|
+
"optional": true
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"scripts": {
|
|
100
|
+
"build": "rm -rf dist && tsdown",
|
|
101
|
+
"test": "vitest run",
|
|
102
|
+
"typecheck": "tsc --noEmit"
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/hooks.tsx
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConnectionStatus,
|
|
3
|
+
FalsyQuery,
|
|
4
|
+
MutationOptions,
|
|
5
|
+
MutationResult,
|
|
6
|
+
OnceView,
|
|
7
|
+
SchemaEvent,
|
|
8
|
+
SubscribeOptions,
|
|
9
|
+
View,
|
|
10
|
+
ViewStatus,
|
|
11
|
+
} from '@doync/client'
|
|
12
|
+
import type {
|
|
13
|
+
BoundQuery,
|
|
14
|
+
MutationDefinition,
|
|
15
|
+
QueryDefinition,
|
|
16
|
+
SqlValue,
|
|
17
|
+
} from '@doync/core'
|
|
18
|
+
|
|
19
|
+
import { isBoundQuery } from '@doync/core/internal'
|
|
20
|
+
import {
|
|
21
|
+
useCallback,
|
|
22
|
+
useEffect,
|
|
23
|
+
useMemo,
|
|
24
|
+
useState,
|
|
25
|
+
useSyncExternalStore,
|
|
26
|
+
} from 'react'
|
|
27
|
+
|
|
28
|
+
import { useDoyncClient } from './provider'
|
|
29
|
+
|
|
30
|
+
// ── useQuery (subscribe — the norm) ───────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Options for {@link useQuery}: `ttl` (server warm-grace after unmount) and
|
|
34
|
+
* `skip`. One-ness is inferred from the bound query — there is no `one`
|
|
35
|
+
* option.
|
|
36
|
+
*/
|
|
37
|
+
export type UseQueryOptions = SubscribeOptions
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Live subscription to a bound query. Pass `queries.issues.open(args)`. Returns
|
|
41
|
+
* `[rows, status]` and updates as local writes and server sync move the rows.
|
|
42
|
+
*
|
|
43
|
+
* - Multi-row queries → `[readonly Row[], status]`
|
|
44
|
+
* - One-row (`` sql.one`…` `` / `findFirst`) → `[Row | undefined, status]`
|
|
45
|
+
* - Falsy (`cond && bound`) → `[undefined, { status: 'unknown' }]` (stable hooks)
|
|
46
|
+
* - `skip: true` → empty shaped like the query (`[]` or `undefined`), status
|
|
47
|
+
* `unknown`
|
|
48
|
+
*
|
|
49
|
+
* Status runs `unknown` → `complete` on first confirmation, or `error` if the
|
|
50
|
+
* subscribe fails. Mount retains the shared handle; unmount releases it.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
// Non-falsy one-query.
|
|
54
|
+
export function useQuery<
|
|
55
|
+
Row extends Record<string, unknown> = Record<string, SqlValue>,
|
|
56
|
+
>(
|
|
57
|
+
query: BoundQuery<Row, true>,
|
|
58
|
+
options?: UseQueryOptions,
|
|
59
|
+
): [Row | undefined, ViewStatus]
|
|
60
|
+
// Non-falsy multi-row.
|
|
61
|
+
export function useQuery<
|
|
62
|
+
Row extends Record<string, unknown> = Record<string, SqlValue>,
|
|
63
|
+
>(
|
|
64
|
+
query: BoundQuery<Row, false>,
|
|
65
|
+
options?: UseQueryOptions,
|
|
66
|
+
): [readonly Row[], ViewStatus]
|
|
67
|
+
// Maybe one-query (argument can be falsy) — `| undefined` only here.
|
|
68
|
+
export function useQuery<
|
|
69
|
+
Row extends Record<string, unknown> = Record<string, SqlValue>,
|
|
70
|
+
>(
|
|
71
|
+
query: BoundQuery<Row, true> | FalsyQuery,
|
|
72
|
+
options?: UseQueryOptions,
|
|
73
|
+
): [Row | undefined, ViewStatus]
|
|
74
|
+
// Maybe multi-row — rows themselves gain `| undefined` when no query.
|
|
75
|
+
export function useQuery<
|
|
76
|
+
Row extends Record<string, unknown> = Record<string, SqlValue>,
|
|
77
|
+
>(
|
|
78
|
+
query: BoundQuery<Row, false> | FalsyQuery,
|
|
79
|
+
options?: UseQueryOptions,
|
|
80
|
+
): [readonly Row[] | undefined, ViewStatus]
|
|
81
|
+
// Implementation (boolean One + maybe).
|
|
82
|
+
export function useQuery<
|
|
83
|
+
Row extends Record<string, unknown> = Record<string, SqlValue>,
|
|
84
|
+
>(
|
|
85
|
+
query: BoundQuery<Row, boolean> | FalsyQuery,
|
|
86
|
+
options?: UseQueryOptions,
|
|
87
|
+
): [readonly Row[] | (Row | undefined) | undefined, ViewStatus] {
|
|
88
|
+
const client = useDoyncClient()
|
|
89
|
+
const skip = options?.skip ?? false
|
|
90
|
+
const falsy = isFalsyQuery(query)
|
|
91
|
+
// Falsy+skip in the key so toggles swap Views without reordering hooks.
|
|
92
|
+
// Creation pure (ADR-0023/Q3); bind pure (ADR-0027) — key name+JSON args.
|
|
93
|
+
const key = falsy
|
|
94
|
+
? `|ttl=${options?.ttl ?? ''}|skip=${skip}|falsy=1`
|
|
95
|
+
: `${boundQueryKey(query)}|ttl=${options?.ttl ?? ''}|skip=${skip}|falsy=0`
|
|
96
|
+
const view = useMemo((): View<Row> => {
|
|
97
|
+
if (falsy) return INERT_VIEW as unknown as View<Row>
|
|
98
|
+
// BoundQuery through (ADR-0027 / #200); reject truthy impostors.
|
|
99
|
+
const bound = requireBoundQuery(query, 'useQuery')
|
|
100
|
+
return client.subscribe(bound as BoundQuery<Row, boolean>, {
|
|
101
|
+
ttl: options?.ttl,
|
|
102
|
+
skip,
|
|
103
|
+
}) as View<Row>
|
|
104
|
+
// identity is `key`, not object identity
|
|
105
|
+
}, [client, key])
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
view.retain()
|
|
108
|
+
return () => view.release()
|
|
109
|
+
}, [view])
|
|
110
|
+
const rows = useViewRows(view)
|
|
111
|
+
const status = useViewStatus(view)
|
|
112
|
+
// One-ness inferred (#115): resolve locally for immediate shape (topology
|
|
113
|
+
// view.one is late; skipped never resolves). Falsy keep slot only.
|
|
114
|
+
const resolvedOne = useMemo(() => {
|
|
115
|
+
if (falsy) return false
|
|
116
|
+
const bound = query as BoundQuery<Row, boolean>
|
|
117
|
+
return resolvedOneness(bound.query, bound.args)
|
|
118
|
+
}, [key])
|
|
119
|
+
|
|
120
|
+
// Falsy (ADR-0027): rows undefined for both shapes; after hooks for order.
|
|
121
|
+
// Distinct from skip (shape-preserving empties with a bound query).
|
|
122
|
+
if (falsy) {
|
|
123
|
+
return [undefined, status]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// view.one is authoritative once known — must agree with local resolve or the
|
|
127
|
+
// resolve boundary disagrees (throw). LIMITATION: mixed resolvers
|
|
128
|
+
// (`args.x ? sql.one : sql`) have type-level One=false but runtime agreement,
|
|
129
|
+
// so this guard cannot fire; prefer pure sql.one/findFirst.
|
|
130
|
+
if (view.one !== undefined && view.one !== resolvedOne) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
view.one
|
|
133
|
+
? 'doync: the subscribed statement is a one-query (sql.one / findFirst) but this client resolved it as multi-row — the query definition disagrees across the resolve boundary'
|
|
134
|
+
: 'doync: the subscribed statement is multi-row but this client resolved it as a one-query — the query definition disagrees across the resolve boundary',
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
const one = view.one ?? resolvedOne
|
|
138
|
+
const value = one ? (rows.length > 0 ? (rows[0] as Row) : undefined) : rows
|
|
139
|
+
return [value, status]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Local one-ness (#115): same resolve the engine does at subscribe so shape is
|
|
144
|
+
* known before topology `view.one`. Unresolvable args ⇒ false (defer to
|
|
145
|
+
* view.one).
|
|
146
|
+
*/
|
|
147
|
+
function resolvedOneness(
|
|
148
|
+
query: QueryDefinition<unknown, unknown, boolean>,
|
|
149
|
+
args: unknown,
|
|
150
|
+
): boolean {
|
|
151
|
+
try {
|
|
152
|
+
return query.resolve({ args })?.one ?? false
|
|
153
|
+
} catch {
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── useQueryOnce (cache-and-network Once) ─────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Network half of a {@link useQueryOnce} read: `loading` → `success` / `error`,
|
|
162
|
+
* or `skipped` when the argument is falsy (no request started).
|
|
163
|
+
*/
|
|
164
|
+
export type OnceStatus = 'loading' | 'success' | 'error' | 'skipped'
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One-shot cache-and-network read. Rows come from the local replica
|
|
168
|
+
* immediately, then update when the server answer lands. `status` tracks the
|
|
169
|
+
* network half. Falsy argument → `[undefined, { status: 'skipped' }]` with
|
|
170
|
+
* stable hooks. StrictMode-safe (remount keeps the in-flight request).
|
|
171
|
+
*/
|
|
172
|
+
// Non-falsy: today's exact rows type.
|
|
173
|
+
export function useQueryOnce<
|
|
174
|
+
Row extends Record<string, unknown> = Record<string, SqlValue>,
|
|
175
|
+
>(
|
|
176
|
+
query: BoundQuery<Row, boolean>,
|
|
177
|
+
): [readonly Row[], { readonly status: OnceStatus }]
|
|
178
|
+
// Maybe (argument can be falsy) — rows gain `| undefined` only here.
|
|
179
|
+
export function useQueryOnce<
|
|
180
|
+
Row extends Record<string, unknown> = Record<string, SqlValue>,
|
|
181
|
+
>(
|
|
182
|
+
query: BoundQuery<Row, boolean> | FalsyQuery,
|
|
183
|
+
): [readonly Row[] | undefined, { readonly status: OnceStatus }]
|
|
184
|
+
export function useQueryOnce<
|
|
185
|
+
Row extends Record<string, unknown> = Record<string, SqlValue>,
|
|
186
|
+
>(
|
|
187
|
+
query: BoundQuery<Row, boolean> | FalsyQuery,
|
|
188
|
+
): [readonly Row[] | undefined, { readonly status: OnceStatus }] {
|
|
189
|
+
const client = useDoyncClient()
|
|
190
|
+
const falsy = isFalsyQuery(query)
|
|
191
|
+
const key = falsy ? 'falsy=1' : `${boundQueryKey(query)}|falsy=0`
|
|
192
|
+
const view = useMemo((): OnceView<Row> => {
|
|
193
|
+
if (falsy) return INERT_ONCE as unknown as OnceView<Row>
|
|
194
|
+
// BoundQuery through (ADR-0027 / #200).
|
|
195
|
+
const bound = requireBoundQuery(query, 'useQueryOnce')
|
|
196
|
+
return client.once(bound as BoundQuery<Row, boolean>) as OnceView<Row>
|
|
197
|
+
}, [client, key])
|
|
198
|
+
// Live network status; falsy returns skipped sync so bound→falsy never flash
|
|
199
|
+
// stale success/error (#197).
|
|
200
|
+
const [liveStatus, setLiveStatus] =
|
|
201
|
+
useState<Exclude<OnceStatus, 'skipped'>>('loading')
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
// Commit owns Once (Q7). Falsy → nothing. Live starts network (cancels
|
|
204
|
+
// StrictMode dispose arm). Cleanup = Warm-tick dispose.
|
|
205
|
+
if (falsy) return
|
|
206
|
+
let active = true
|
|
207
|
+
setLiveStatus('loading')
|
|
208
|
+
view.server.then(
|
|
209
|
+
() => {
|
|
210
|
+
if (active) setLiveStatus('success')
|
|
211
|
+
},
|
|
212
|
+
() => {
|
|
213
|
+
if (active) setLiveStatus('error')
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
return () => {
|
|
217
|
+
active = false
|
|
218
|
+
view.dispose()
|
|
219
|
+
}
|
|
220
|
+
}, [view, falsy])
|
|
221
|
+
const status: OnceStatus = falsy ? 'skipped' : liveStatus
|
|
222
|
+
// Always read store (hook order); falsy → undefined.
|
|
223
|
+
const rows = useViewRows(view)
|
|
224
|
+
return [falsy ? undefined : rows, { status }]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── useLocalQuery (arbitrary SQL over the replica) ─────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* SQL source for {@link useLocalQuery}: a string, `{ sql, params }`, a callback
|
|
231
|
+
* returning either, or falsy ("no local read").
|
|
232
|
+
*/
|
|
233
|
+
export type LocalSource =
|
|
234
|
+
| string
|
|
235
|
+
| { readonly sql: string; readonly params?: readonly SqlValue[] }
|
|
236
|
+
| (() =>
|
|
237
|
+
| string
|
|
238
|
+
| { readonly sql: string; readonly params?: readonly SqlValue[] })
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Run arbitrary SQL over the local replica reactively (aggregates, joins, …).
|
|
242
|
+
* Re-runs on local commits; never registered with the server — offline-capable
|
|
243
|
+
* and free to the Mirror. Falsy source → `[undefined, { status: 'unknown' }]`.
|
|
244
|
+
*/
|
|
245
|
+
export function useLocalQuery<
|
|
246
|
+
Row extends Record<string, unknown> = Record<string, SqlValue>,
|
|
247
|
+
>(
|
|
248
|
+
source: LocalSource,
|
|
249
|
+
params?: readonly SqlValue[],
|
|
250
|
+
): [readonly Row[], ViewStatus]
|
|
251
|
+
export function useLocalQuery<
|
|
252
|
+
Row extends Record<string, unknown> = Record<string, SqlValue>,
|
|
253
|
+
>(
|
|
254
|
+
source: LocalSource | FalsyQuery,
|
|
255
|
+
params?: readonly SqlValue[],
|
|
256
|
+
): [readonly Row[] | undefined, ViewStatus]
|
|
257
|
+
export function useLocalQuery<
|
|
258
|
+
Row extends Record<string, unknown> = Record<string, SqlValue>,
|
|
259
|
+
>(
|
|
260
|
+
source: LocalSource | FalsyQuery,
|
|
261
|
+
params: readonly SqlValue[] = [],
|
|
262
|
+
): [readonly Row[] | undefined, ViewStatus] {
|
|
263
|
+
const client = useDoyncClient()
|
|
264
|
+
const falsy = isFalsyQuery(source)
|
|
265
|
+
const resolved = falsy
|
|
266
|
+
? null
|
|
267
|
+
: typeof source === 'function'
|
|
268
|
+
? source()
|
|
269
|
+
: source
|
|
270
|
+
const sql =
|
|
271
|
+
resolved === null
|
|
272
|
+
? ''
|
|
273
|
+
: typeof resolved === 'string'
|
|
274
|
+
? resolved
|
|
275
|
+
: resolved.sql
|
|
276
|
+
const sqlParams =
|
|
277
|
+
resolved === null
|
|
278
|
+
? params
|
|
279
|
+
: typeof resolved === 'string'
|
|
280
|
+
? params
|
|
281
|
+
: (resolved.params ?? [])
|
|
282
|
+
const key = falsy ? 'falsy=1' : `${sql}|${JSON.stringify(sqlParams)}|falsy=0`
|
|
283
|
+
const view = useMemo((): View<Row> => {
|
|
284
|
+
if (falsy) return INERT_VIEW as unknown as View<Row>
|
|
285
|
+
return client.local<Row>(sql, ...sqlParams)
|
|
286
|
+
}, [client, key])
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
view.retain()
|
|
289
|
+
return () => view.release()
|
|
290
|
+
}, [view])
|
|
291
|
+
// Both stores always (hook order). Live Local is always complete (#104).
|
|
292
|
+
const rows = useViewRows(view)
|
|
293
|
+
const status = useViewStatus(view)
|
|
294
|
+
if (falsy) return [undefined, status]
|
|
295
|
+
return [rows, status]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── useConnectionStatus (topology socket health) ──────────────────────────
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Live {@link ConnectionStatus} to the Mirror: `connecting` / `connected` /
|
|
302
|
+
* `disconnected` / `error` / `needs-auth`. Re-renders on every transition — use
|
|
303
|
+
* for a status pill, offline banner, or needs-auth → logout flow.
|
|
304
|
+
*/
|
|
305
|
+
export function useConnectionStatus(): ConnectionStatus {
|
|
306
|
+
const client = useDoyncClient()
|
|
307
|
+
return useSyncExternalStore(
|
|
308
|
+
useCallback(
|
|
309
|
+
(onChange: () => void) => client.onConnectionChange(onChange),
|
|
310
|
+
[client],
|
|
311
|
+
),
|
|
312
|
+
() => client.connectionStatus,
|
|
313
|
+
() => client.connectionStatus,
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── useMutation ────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Registered mutation as a callable. Call with `args` to apply optimistically
|
|
321
|
+
* and push; returns `{ client, server }` — paint from `client`, await `server`
|
|
322
|
+
* for Origin confirmation.
|
|
323
|
+
*/
|
|
324
|
+
export function useMutation<Input = unknown>(
|
|
325
|
+
mutation: MutationDefinition<Input>,
|
|
326
|
+
): (args: Input, options?: MutationOptions) => MutationResult {
|
|
327
|
+
const client = useDoyncClient()
|
|
328
|
+
return useCallback(
|
|
329
|
+
(args: Input, options?: MutationOptions) =>
|
|
330
|
+
client.mutate(mutation, args, options),
|
|
331
|
+
[client, mutation],
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── useSchemaStatus ───────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Current schema/recovery state, or `null` when nominal. Use for a deploy-skew
|
|
339
|
+
* / recovery banner — re-renders when the state clears too (a one-shot
|
|
340
|
+
* `onSchemaEvent` callback alone would stick after recovery).
|
|
341
|
+
*/
|
|
342
|
+
export function useSchemaStatus(): SchemaEvent | null {
|
|
343
|
+
const client = useDoyncClient()
|
|
344
|
+
return useSyncExternalStore(
|
|
345
|
+
useCallback(
|
|
346
|
+
(onChange: () => void) => client.onSchemaChange(onChange),
|
|
347
|
+
[client],
|
|
348
|
+
),
|
|
349
|
+
() => client.schemaStatus,
|
|
350
|
+
() => client.schemaStatus,
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── shared internals ───────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
/** Stable empty snapshots (fresh ones tear the store). */
|
|
357
|
+
const EMPTY_ROWS: readonly Record<string, unknown>[] = Object.freeze([])
|
|
358
|
+
const UNKNOWN_STATUS: ViewStatus = Object.freeze({ status: 'unknown' })
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Inert View for falsy useQuery/useLocalQuery (ADR-0027): no desire, empty,
|
|
362
|
+
* unknown, no-op retain. Hook adds undefined-rows (vs skip's shape-preserving
|
|
363
|
+
* empty). Module-stable.
|
|
364
|
+
*/
|
|
365
|
+
const INERT_VIEW: View<Record<string, unknown>> = {
|
|
366
|
+
current: () => EMPTY_ROWS,
|
|
367
|
+
status: () => UNKNOWN_STATUS,
|
|
368
|
+
onChange: () => () => {},
|
|
369
|
+
retain: () => {},
|
|
370
|
+
release: () => {},
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Inert Once for falsy useQueryOnce (ADR-0027): no network. Hook reports
|
|
375
|
+
* skipped; never awaits server (does not settle).
|
|
376
|
+
*/
|
|
377
|
+
const INERT_ONCE: OnceView<Record<string, unknown>> = {
|
|
378
|
+
current: () => EMPTY_ROWS,
|
|
379
|
+
onChange: () => () => {},
|
|
380
|
+
dispose: () => {},
|
|
381
|
+
// Never settles — unused for the skipped status path.
|
|
382
|
+
server: new Promise(() => {}),
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Track View rows via useSyncExternalStore (stable current() until change). */
|
|
386
|
+
function useViewRows<Row extends Record<string, unknown>>(
|
|
387
|
+
view: View<Row> | OnceView<Row>,
|
|
388
|
+
): readonly Row[] {
|
|
389
|
+
const subscribe = useCallback(
|
|
390
|
+
(onStoreChange: () => void) => view.onChange(onStoreChange),
|
|
391
|
+
[view],
|
|
392
|
+
)
|
|
393
|
+
const getSnapshot = useCallback(() => view.current(), [view])
|
|
394
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Track {@link ViewStatus} via useSyncExternalStore (#105): same onChange as
|
|
399
|
+
* rows; stable until visible status changes.
|
|
400
|
+
*/
|
|
401
|
+
function useViewStatus<Row extends Record<string, unknown>>(
|
|
402
|
+
view: View<Row>,
|
|
403
|
+
): ViewStatus {
|
|
404
|
+
const subscribe = useCallback(
|
|
405
|
+
(onStoreChange: () => void) => view.onChange(onStoreChange),
|
|
406
|
+
[view],
|
|
407
|
+
)
|
|
408
|
+
const getSnapshot = useCallback(() => view.status(), [view])
|
|
409
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Dedup key for a bound-query call (name + args). */
|
|
413
|
+
function boundQueryKey(bound: {
|
|
414
|
+
readonly query: { readonly name?: string }
|
|
415
|
+
readonly args: unknown
|
|
416
|
+
}): string {
|
|
417
|
+
return `${bound.query.name ?? ''}|${JSON.stringify(bound.args ?? null)}`
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** `false | null | undefined` — the "no query" sentinel (ADR-0027). */
|
|
421
|
+
function isFalsyQuery(value: unknown): value is FalsyQuery {
|
|
422
|
+
return value === false || value === null || value === undefined
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* BoundQuery check at the react surface (ADR-0027). Bare function → forget-
|
|
427
|
+
* to-call wording.
|
|
428
|
+
*/
|
|
429
|
+
function requireBoundQuery(
|
|
430
|
+
value: unknown,
|
|
431
|
+
surface: 'useQuery' | 'useQueryOnce',
|
|
432
|
+
): BoundQuery {
|
|
433
|
+
if (isBoundQuery(value)) return value
|
|
434
|
+
if (typeof value === 'function') {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`doync: ${surface} expected a BoundQuery — received a function; did you forget to call it?`,
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
throw new Error(
|
|
440
|
+
`doync: ${surface} expected a BoundQuery (produced by queries.…(args)), got ${typeof value === 'object' && value !== null ? 'an object' : String(value)}`,
|
|
441
|
+
)
|
|
442
|
+
}
|