@tanstack/preact-db 0.0.0-stage → 1.0.0-rc.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kyle Mathews
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 CHANGED
@@ -1,3 +1,59 @@
1
- # Temporary Holding Version
1
+ # @tanstack/preact-db
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Preact hooks for TanStack DB. See [TanStack/db](https://github.com/TanStack/db) for more details.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install @tanstack/preact-db
9
+ ```
10
+
11
+ `@tanstack/preact-db` supports Preact 10 and later and ships as ES modules.
12
+
13
+ Queries read concrete source Collections. Preact has no `DbProvider` yet, so it does not bind collection descriptors or read from a `DbClient`; a query over a descriptor throws when it is consumed. Use `@tanstack/react-db` or `@tanstack/svelte-db` for `DbClient`-based SSR and hydration.
14
+
15
+ ## Usage
16
+
17
+ ```tsx
18
+ import { eq, useLiveQuery } from '@tanstack/preact-db'
19
+
20
+ function TodoList({ userId }: { userId?: string }) {
21
+ const { data, isLoading } = useLiveQuery((q) =>
22
+ userId
23
+ ? q
24
+ .from({ todos: todosCollection })
25
+ .where(({ todos }) => eq(todos.userId, userId))
26
+ : undefined,
27
+ )
28
+
29
+ if (isLoading) return <div>Loading...</div>
30
+ return (
31
+ <ul>
32
+ {data?.map((todo) => (
33
+ <li key={todo.id}>{todo.text}</li>
34
+ ))}
35
+ </ul>
36
+ )
37
+ }
38
+ ```
39
+
40
+ `useLiveQuery` accepts a query callback, a `{ query }` config object, or an
41
+ existing live-query Collection. Return `undefined` or `null` from the callback
42
+ to disable the query. `useLiveInfiniteQuery` pages an ordered live query.
43
+
44
+ The hooks derive each query's identity from its structured IR, so values the
45
+ callback captures recompile the query when they change. A query that uses
46
+ functional variants such as `fn.where` has no stable IR hash and throws
47
+ `UnhashableQueryIRError` unless a config object names it with `queryKey`:
48
+
49
+ ```ts
50
+ const { data } = useLiveQuery({
51
+ queryKey: [`todos-by-text`, search],
52
+ query: (q) =>
53
+ q
54
+ .from({ todos: todosCollection })
55
+ .fn.where(({ todos }) => todos.text.includes(search)),
56
+ })
57
+ ```
58
+
59
+ See the [Preact adapter documentation](https://tanstack.com/db/latest/docs/framework/preact/overview) for more.
@@ -0,0 +1,7 @@
1
+ export { useLiveQuery } from './useLiveQuery.js';
2
+ export type { ConditionalUseLiveQueryConfig, UseConditionalLiveQueryResult, UseLiveQueryConfig, UseLiveQueryResult, UseLiveQueryStatus, } from './useLiveQuery.js';
3
+ export { useLiveInfiniteQuery } from './useLiveInfiniteQuery.js';
4
+ export type { UseLiveInfiniteQueryConfig, UseLiveInfiniteQueryReturn, UseLiveInfiniteQueryReturnWithCollection, } from './useLiveInfiniteQuery.js';
5
+ export * from '@tanstack/db';
6
+ export type { Collection } from '@tanstack/db';
7
+ export { createTransaction } from '@tanstack/db';
@@ -0,0 +1,10 @@
1
+ import { useLiveQuery } from "./useLiveQuery.js";
2
+ import { useLiveInfiniteQuery } from "./useLiveInfiniteQuery.js";
3
+ export * from "@tanstack/db";
4
+ import { createTransaction } from "@tanstack/db";
5
+ export {
6
+ createTransaction,
7
+ useLiveInfiniteQuery,
8
+ useLiveQuery
9
+ };
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;"}
@@ -0,0 +1,42 @@
1
+ import { Collection, Context, InferResultType, InitialQueryBuilder, LiveQueryKey, NonSingleResult, QueryBuilder } from '@tanstack/db';
2
+ import { UseLiveQueryResult } from './useLiveQuery.js';
3
+ export type UseLiveInfiniteQueryConfig<_TContext extends Context> = {
4
+ /**
5
+ * Explicit identity for a query whose IR cannot derive one, such as a query
6
+ * with functional variants. Structured queries should omit it.
7
+ */
8
+ queryKey?: LiveQueryKey;
9
+ pageSize?: number;
10
+ /** First result-page label, not a server cursor or remote offset. */
11
+ initialPageParam?: number;
12
+ };
13
+ interface InfiniteQueryPagination<TRow> {
14
+ pages: Array<Array<TRow>>;
15
+ pageParams: Array<number>;
16
+ fetchNextPage: () => Promise<void>;
17
+ hasNextPage: boolean;
18
+ isFetchingNextPage: boolean;
19
+ }
20
+ export type UseLiveInfiniteQueryReturn<TContext extends Context> = UseLiveQueryResult<InferResultType<TContext>[number], string | number, InferResultType<TContext>, Collection<InferResultType<TContext>[number], string | number, {}>> & InfiniteQueryPagination<InferResultType<TContext>[number]>;
21
+ export type UseLiveInfiniteQueryReturnWithCollection<TResult extends object, TKey extends string | number, TUtils extends Record<string, any>> = UseLiveQueryResult<TResult, TKey, Array<TResult>, Collection<TResult, TKey, TUtils> & NonSingleResult> & InfiniteQueryPagination<TResult>;
22
+ /** What one hook instance renders until its input or page shape changes. */
23
+ /**
24
+ * Page through an ordered live query, growing one live-query Collection's
25
+ * window with `utils.setWindow()` instead of recreating it per page.
26
+ *
27
+ * The query must include `.orderBy()`. Its identity follows `useLiveQuery`:
28
+ * derived from the query IR, or named by `queryKey`. A query whose IR cannot be
29
+ * hashed throws `UnhashableQueryIRError` unless `queryKey` is supplied.
30
+ *
31
+ * @example
32
+ * const { pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(
33
+ * (q) =>
34
+ * q
35
+ * .from({ posts: postsCollection })
36
+ * .orderBy(({ posts }) => posts.createdAt, `desc`),
37
+ * { pageSize: 20 },
38
+ * )
39
+ */
40
+ export declare function useLiveInfiniteQuery<TResult extends object, TKey extends string | number, TUtils extends Record<string, any>>(liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult, config: UseLiveInfiniteQueryConfig<any>): UseLiveInfiniteQueryReturnWithCollection<TResult, TKey, TUtils>;
41
+ export declare function useLiveInfiniteQuery<TContext extends Context>(queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>, config: UseLiveInfiniteQueryConfig<TContext>): UseLiveInfiniteQueryReturn<TContext>;
42
+ export {};
@@ -0,0 +1,76 @@
1
+ import { useRef, useState, useLayoutEffect, useCallback } from "preact/hooks";
2
+ import { readLiveQueryWindowPageShape, getLiveQueryKey, getLiveQueryHash, prepareLiveQueryValue, identifyLiveQuery, createLiveQuerySourceIdClaims, bindLiveQueryWindow, fetchNextLiveQueryWindowPage } from "@tanstack/db";
3
+ function useLiveInfiniteQuery(queryFnOrCollection, config) {
4
+ const pageShape = readLiveQueryWindowPageShape(config);
5
+ const claimsRef = useRef(null);
6
+ const bindingRef = useRef(null);
7
+ let preparedValue;
8
+ let identity;
9
+ if (typeof queryFnOrCollection === `function`) {
10
+ const queryKey = getLiveQueryKey(config);
11
+ if (queryKey) {
12
+ identity = getLiveQueryHash(void 0, queryKey);
13
+ } else {
14
+ preparedValue = prepareLiveQueryValue(queryFnOrCollection, void 0);
15
+ identity = identifyLiveQuery(
16
+ preparedValue,
17
+ {
18
+ claims: claimsRef.current ??= createLiveQuerySourceIdClaims(),
19
+ client: void 0,
20
+ bindingName: `useLiveInfiniteQuery`
21
+ },
22
+ getLiveQueryHash
23
+ );
24
+ }
25
+ }
26
+ const binding = bindingRef.current = bindLiveQueryWindow(
27
+ bindingRef.current,
28
+ {
29
+ ...pageShape,
30
+ // Compile what this render prepared, so the callback runs once.
31
+ input: preparedValue === void 0 ? queryFnOrCollection : () => preparedValue,
32
+ dependencies: identity === void 0 ? void 0 : [identity],
33
+ recompile: `identity`
34
+ }
35
+ );
36
+ const { controller } = binding;
37
+ const snapshot = controller.getSnapshot();
38
+ const [, setRevision] = useState(0);
39
+ useLayoutEffect(() => {
40
+ const rerender = () => setRevision((revision) => revision + 1);
41
+ controller.subscribe(rerender);
42
+ if (controller.getSnapshot() !== snapshot) rerender();
43
+ if (binding.warning) console.warn(binding.warning);
44
+ return () => controller.dispose();
45
+ }, [controller]);
46
+ const fetchNextPage = useCallback(
47
+ () => fetchNextLiveQueryWindowPage(controller),
48
+ [controller]
49
+ );
50
+ return {
51
+ data: snapshot.data,
52
+ get state() {
53
+ return snapshot.state;
54
+ },
55
+ status: snapshot.status,
56
+ isLoading: snapshot.isLoading,
57
+ isReady: snapshot.isReady,
58
+ isIdle: snapshot.isIdle,
59
+ isLoadingError: snapshot.isLoadingError,
60
+ isCrashed: snapshot.isCrashed,
61
+ isCleanedUp: snapshot.isCleanedUp,
62
+ isSyncError: snapshot.isSyncError,
63
+ collection: snapshot.collection,
64
+ isEnabled: true,
65
+ pages: snapshot.pages,
66
+ pageParams: snapshot.pageParams,
67
+ fetchNextPage,
68
+ hasNextPage: snapshot.hasNextPage,
69
+ isFetchingNextPage: snapshot.isFetchingNextPage,
70
+ health: snapshot.health
71
+ };
72
+ }
73
+ export {
74
+ useLiveInfiniteQuery
75
+ };
76
+ //# sourceMappingURL=useLiveInfiniteQuery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useLiveInfiniteQuery.js","sources":["../../src/useLiveInfiniteQuery.ts"],"sourcesContent":["import { useCallback, useLayoutEffect, useRef, useState } from 'preact/hooks'\nimport {\n bindLiveQueryWindow,\n createLiveQuerySourceIdClaims,\n fetchNextLiveQueryWindowPage,\n getLiveQueryHash,\n getLiveQueryKey,\n identifyLiveQuery,\n prepareLiveQueryValue,\n readLiveQueryWindowPageShape,\n} from '@tanstack/db'\nimport type {\n Collection,\n CollectionStatus,\n Context,\n InferResultType,\n InitialQueryBuilder,\n LiveQueryKey,\n LiveQuerySourceIdClaims,\n LiveQueryWindowBinding,\n NonSingleResult,\n QueryBuilder,\n} from '@tanstack/db'\nimport type { UseLiveQueryResult } from './useLiveQuery'\n\n// Keep the generic parameter for typed config wrappers.\nexport type UseLiveInfiniteQueryConfig<_TContext extends Context> = {\n /**\n * Explicit identity for a query whose IR cannot derive one, such as a query\n * with functional variants. Structured queries should omit it.\n */\n queryKey?: LiveQueryKey\n pageSize?: number\n /** First result-page label, not a server cursor or remote offset. */\n initialPageParam?: number\n}\n\ninterface InfiniteQueryPagination<TRow> {\n pages: Array<Array<TRow>>\n pageParams: Array<number>\n fetchNextPage: () => Promise<void>\n hasNextPage: boolean\n isFetchingNextPage: boolean\n}\n\nexport type UseLiveInfiniteQueryReturn<TContext extends Context> =\n UseLiveQueryResult<\n InferResultType<TContext>[number],\n string | number,\n InferResultType<TContext>,\n Collection<InferResultType<TContext>[number], string | number, {}>\n > &\n InfiniteQueryPagination<InferResultType<TContext>[number]>\n\nexport type UseLiveInfiniteQueryReturnWithCollection<\n TResult extends object,\n TKey extends string | number,\n TUtils extends Record<string, any>,\n> = UseLiveQueryResult<\n TResult,\n TKey,\n Array<TResult>,\n Collection<TResult, TKey, TUtils> & NonSingleResult\n> &\n InfiniteQueryPagination<TResult>\n\n/** What one hook instance renders until its input or page shape changes. */\n/**\n * Page through an ordered live query, growing one live-query Collection's\n * window with `utils.setWindow()` instead of recreating it per page.\n *\n * The query must include `.orderBy()`. Its identity follows `useLiveQuery`:\n * derived from the query IR, or named by `queryKey`. A query whose IR cannot be\n * hashed throws `UnhashableQueryIRError` unless `queryKey` is supplied.\n *\n * @example\n * const { pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(\n * (q) =>\n * q\n * .from({ posts: postsCollection })\n * .orderBy(({ posts }) => posts.createdAt, `desc`),\n * { pageSize: 20 },\n * )\n */\nexport function useLiveInfiniteQuery<\n TResult extends object,\n TKey extends string | number,\n TUtils extends Record<string, any>,\n>(\n liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,\n config: UseLiveInfiniteQueryConfig<any>,\n): UseLiveInfiniteQueryReturnWithCollection<TResult, TKey, TUtils>\n\nexport function useLiveInfiniteQuery<TContext extends Context>(\n queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,\n config: UseLiveInfiniteQueryConfig<TContext>,\n): UseLiveInfiniteQueryReturn<TContext>\n\nexport function useLiveInfiniteQuery(\n queryFnOrCollection: unknown,\n config: UseLiveInfiniteQueryConfig<any>,\n): UseLiveInfiniteQueryReturn<any> {\n const pageShape = readLiveQueryWindowPageShape(config)\n const claimsRef = useRef<LiveQuerySourceIdClaims | null>(null)\n const bindingRef = useRef<LiveQueryWindowBinding | null>(null)\n\n // A query input's identity is a hash, so a structurally equal query keeps\n // its loaded pages and a changed one compiles again.\n let preparedValue: unknown\n let identity: string | undefined\n if (typeof queryFnOrCollection === `function`) {\n const queryKey = getLiveQueryKey(config)\n if (queryKey) {\n identity = getLiveQueryHash(undefined, queryKey)\n } else {\n preparedValue = prepareLiveQueryValue(queryFnOrCollection, undefined)\n identity = identifyLiveQuery(\n preparedValue,\n {\n claims: (claimsRef.current ??= createLiveQuerySourceIdClaims()),\n client: undefined,\n bindingName: `useLiveInfiniteQuery`,\n },\n getLiveQueryHash,\n )\n }\n }\n\n const binding = (bindingRef.current = bindLiveQueryWindow(\n bindingRef.current,\n {\n ...pageShape,\n // Compile what this render prepared, so the callback runs once.\n input:\n preparedValue === undefined ? queryFnOrCollection : () => preparedValue,\n dependencies: identity === undefined ? undefined : [identity],\n recompile: `identity`,\n },\n ))\n\n const { controller } = binding\n const snapshot = controller.getSnapshot()\n const [, setRevision] = useState(0)\n\n useLayoutEffect(() => {\n const rerender = () => setRevision((revision) => revision + 1)\n controller.subscribe(rerender)\n // The query can publish between this render and its subscription.\n if (controller.getSnapshot() !== snapshot) rerender()\n if (binding.warning) console.warn(binding.warning)\n // Each binding owns its controller, so disposal also detaches this\n // subscription and releases its window lease.\n return () => controller.dispose()\n }, [controller])\n\n const fetchNextPage = useCallback(\n () => fetchNextLiveQueryWindowPage(controller),\n [controller],\n )\n\n return {\n data: snapshot.data,\n get state() {\n return snapshot.state\n },\n status: snapshot.status as CollectionStatus,\n isLoading: snapshot.isLoading,\n isReady: snapshot.isReady,\n isIdle: snapshot.isIdle,\n isLoadingError: snapshot.isLoadingError,\n isCrashed: snapshot.isCrashed,\n isCleanedUp: snapshot.isCleanedUp,\n isSyncError: snapshot.isSyncError,\n collection: snapshot.collection,\n isEnabled: true,\n pages: snapshot.pages,\n pageParams: snapshot.pageParams,\n fetchNextPage,\n hasNextPage: snapshot.hasNextPage,\n isFetchingNextPage: snapshot.isFetchingNextPage,\n health: snapshot.health,\n } as UseLiveInfiniteQueryReturn<any>\n}\n"],"names":[],"mappings":";;AAkGO,SAAS,qBACd,qBACA,QACiC;AACjC,QAAM,YAAY,6BAA6B,MAAM;AACrD,QAAM,YAAY,OAAuC,IAAI;AAC7D,QAAM,aAAa,OAAsC,IAAI;AAI7D,MAAI;AACJ,MAAI;AACJ,MAAI,OAAO,wBAAwB,YAAY;AAC7C,UAAM,WAAW,gBAAgB,MAAM;AACvC,QAAI,UAAU;AACZ,iBAAW,iBAAiB,QAAW,QAAQ;AAAA,IACjD,OAAO;AACL,sBAAgB,sBAAsB,qBAAqB,MAAS;AACpE,iBAAW;AAAA,QACT;AAAA,QACA;AAAA,UACE,QAAS,UAAU,YAAY,8BAAA;AAAA,UAC/B,QAAQ;AAAA,UACR,aAAa;AAAA,QAAA;AAAA,QAEf;AAAA,MAAA;AAAA,IAEJ;AAAA,EACF;AAEA,QAAM,UAAW,WAAW,UAAU;AAAA,IACpC,WAAW;AAAA,IACX;AAAA,MACE,GAAG;AAAA;AAAA,MAEH,OACE,kBAAkB,SAAY,sBAAsB,MAAM;AAAA,MAC5D,cAAc,aAAa,SAAY,SAAY,CAAC,QAAQ;AAAA,MAC5D,WAAW;AAAA,IAAA;AAAA,EACb;AAGF,QAAM,EAAE,eAAe;AACvB,QAAM,WAAW,WAAW,YAAA;AAC5B,QAAM,GAAG,WAAW,IAAI,SAAS,CAAC;AAElC,kBAAgB,MAAM;AACpB,UAAM,WAAW,MAAM,YAAY,CAAC,aAAa,WAAW,CAAC;AAC7D,eAAW,UAAU,QAAQ;AAE7B,QAAI,WAAW,kBAAkB,SAAU,UAAA;AAC3C,QAAI,QAAQ,QAAS,SAAQ,KAAK,QAAQ,OAAO;AAGjD,WAAO,MAAM,WAAW,QAAA;AAAA,EAC1B,GAAG,CAAC,UAAU,CAAC;AAEf,QAAM,gBAAgB;AAAA,IACpB,MAAM,6BAA6B,UAAU;AAAA,IAC7C,CAAC,UAAU;AAAA,EAAA;AAGb,SAAO;AAAA,IACL,MAAM,SAAS;AAAA,IACf,IAAI,QAAQ;AACV,aAAO,SAAS;AAAA,IAClB;AAAA,IACA,QAAQ,SAAS;AAAA,IACjB,WAAW,SAAS;AAAA,IACpB,SAAS,SAAS;AAAA,IAClB,QAAQ,SAAS;AAAA,IACjB,gBAAgB,SAAS;AAAA,IACzB,WAAW,SAAS;AAAA,IACpB,aAAa,SAAS;AAAA,IACtB,aAAa,SAAS;AAAA,IACtB,YAAY,SAAS;AAAA,IACrB,WAAW;AAAA,IACX,OAAO,SAAS;AAAA,IAChB,YAAY,SAAS;AAAA,IACrB;AAAA,IACA,aAAa,SAAS;AAAA,IACtB,oBAAoB,SAAS;AAAA,IAC7B,QAAQ,SAAS;AAAA,EAAA;AAErB;"}
@@ -0,0 +1,93 @@
1
+ import { Collection, CollectionStatus, Context, GetResult, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryKey, LiveQueryStatusFlags, NonSingleResult, PooledLiveQueries, QueryBuilder, SingleResult } from '@tanstack/db';
2
+ export type UseLiveQueryStatus = CollectionStatus | `disabled`;
3
+ type ConfiguredQueryBuilder<TContext extends Context> = Extract<LiveQueryCollectionConfig<TContext>[`query`], QueryBuilder<TContext>>;
4
+ type UseLiveQueryConfigOptions<TContext extends Context> = Omit<LiveQueryCollectionConfig<TContext>, `query`> & {
5
+ /**
6
+ * Explicit identity for a query whose IR cannot derive one, such as a query
7
+ * with functional variants. Structured queries should omit it.
8
+ */
9
+ queryKey?: LiveQueryKey;
10
+ /**
11
+ * Pass `pooledLiveQueries` to share one partition among equality-filtered
12
+ * queries of one shape.
13
+ */
14
+ pool?: PooledLiveQueries;
15
+ };
16
+ export type UseLiveQueryConfig<TContext extends Context> = UseLiveQueryConfigOptions<TContext> & Pick<LiveQueryCollectionConfig<TContext>, `query`>;
17
+ export type ConditionalUseLiveQueryConfig<TContext extends Context> = UseLiveQueryConfigOptions<TContext> & {
18
+ query: ConfiguredQueryBuilder<TContext> | ((q: InitialQueryBuilder) => ConfiguredQueryBuilder<TContext> | undefined | null);
19
+ };
20
+ /** The result of a live query that is always enabled. */
21
+ export interface UseLiveQueryResult<TResult extends object, TKey extends string | number, TData, TCollection> extends LiveQueryStatusFlags {
22
+ state: ReadonlyMap<TKey, TResult>;
23
+ data: TData;
24
+ collection: TCollection;
25
+ status: CollectionStatus;
26
+ isEnabled: true;
27
+ }
28
+ /** The result of a live query whose callback can disable it. */
29
+ export interface UseConditionalLiveQueryResult<TResult extends object, TKey extends string | number, TData, TCollection> extends LiveQueryStatusFlags {
30
+ state: ReadonlyMap<TKey, TResult> | undefined;
31
+ data: TData | undefined;
32
+ collection: TCollection | undefined;
33
+ status: UseLiveQueryStatus;
34
+ isEnabled: boolean;
35
+ }
36
+ type QueryResult<TContext extends Context> = UseLiveQueryResult<GetResult<TContext>, string | number, InferResultType<TContext>, Collection<GetResult<TContext>, string | number, {}>>;
37
+ type ConditionalQueryResult<TContext extends Context> = UseConditionalLiveQueryResult<GetResult<TContext>, string | number, InferResultType<TContext>, Collection<GetResult<TContext>, string | number, {}>>;
38
+ /**
39
+ * Subscribe a Preact component to a live query.
40
+ *
41
+ * The hook derives the query's identity from its structured IR on each render,
42
+ * so values captured by the callback recompile the query when they change. A
43
+ * query whose IR cannot be hashed, such as one using `fn.where`, throws
44
+ * `UnhashableQueryIRError` unless its config supplies `queryKey`. While the
45
+ * hook is mounted, each Collection ID its queries read must keep naming one
46
+ * source object, or it throws `SourceCollectionIdReuseError`.
47
+ * Return `undefined` or `null` from the callback to disable the query.
48
+ *
49
+ * @example
50
+ * const { data, isLoading } = useLiveQuery((q) =>
51
+ * q
52
+ * .from({ todos: todosCollection })
53
+ * .where(({ todos }) => eq(todos.completed, false)),
54
+ * )
55
+ *
56
+ * @example
57
+ * // Disable the query until its input exists
58
+ * const { data, isEnabled } = useLiveQuery((q) =>
59
+ * userId
60
+ * ? q
61
+ * .from({ todos: todosCollection })
62
+ * .where(({ todos }) => eq(todos.userId, userId))
63
+ * : undefined,
64
+ * )
65
+ */
66
+ export declare function useLiveQuery<TContext extends Context>(queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>): QueryResult<TContext>;
67
+ export declare function useLiveQuery<TContext extends Context>(queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext> | undefined | null): ConditionalQueryResult<TContext>;
68
+ export declare function useLiveQuery<TContext extends Context>(queryFn: (q: InitialQueryBuilder) => LiveQueryCollectionConfig<TContext> | undefined | null): ConditionalQueryResult<TContext>;
69
+ export declare function useLiveQuery<TResult extends object, TKey extends string | number, TUtils extends Record<string, any>>(queryFn: (q: InitialQueryBuilder) => Collection<TResult, TKey, TUtils> | undefined | null): UseConditionalLiveQueryResult<TResult, TKey, Array<TResult>, Collection<TResult, TKey, TUtils>>;
70
+ /**
71
+ * Subscribe to a live query described by a config object.
72
+ *
73
+ * @example
74
+ * const { data } = useLiveQuery({
75
+ * query: (q) => q.from({ todos: todosCollection }),
76
+ * gcTime: 60_000,
77
+ * })
78
+ */
79
+ export declare function useLiveQuery<TContext extends Context>(config: UseLiveQueryConfig<TContext>): QueryResult<TContext>;
80
+ export declare function useLiveQuery<TContext extends Context>(config: ConditionalUseLiveQueryConfig<TContext>): ConditionalQueryResult<TContext>;
81
+ /**
82
+ * Subscribe to an existing live-query Collection. The hook starts its sync and
83
+ * leaves its retention policy to the caller.
84
+ *
85
+ * @example
86
+ * const activeTodos = createLiveQueryCollection((q) =>
87
+ * q.from({ todos: todosCollection }).where(({ todos }) => todos.active),
88
+ * )
89
+ * const { data, collection } = useLiveQuery(activeTodos)
90
+ */
91
+ export declare function useLiveQuery<TResult extends object, TKey extends string | number, TUtils extends Record<string, any>>(liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult): UseLiveQueryResult<TResult, TKey, Array<TResult>, Collection<TResult, TKey, TUtils>>;
92
+ export declare function useLiveQuery<TResult extends object, TKey extends string | number, TUtils extends Record<string, any>>(liveQueryCollection: Collection<TResult, TKey, TUtils> & SingleResult): UseLiveQueryResult<TResult, TKey, TResult | undefined, Collection<TResult, TKey, TUtils> & SingleResult>;
93
+ export {};
@@ -0,0 +1,44 @@
1
+ import { useRef, useState, useLayoutEffect } from "preact/hooks";
2
+ import { prepareLiveQueryValue, getLiveQueryKey, isCollection, getLiveQueryHash, identifyLiveQuery, createLiveQuerySourceIdClaims, createLiveQueryObserver, resolveLiveQueryValue } from "@tanstack/db";
3
+ const DEFAULT_GC_TIME_MS = 1;
4
+ function useLiveQuery(input) {
5
+ const claimsRef = useRef(null);
6
+ const preparedValue = prepareLiveQueryValue(input, void 0);
7
+ const queryKey = getLiveQueryKey(input);
8
+ const identity = isCollection(input) ? input : queryKey ? getLiveQueryHash(preparedValue, queryKey) : identifyLiveQuery(
9
+ preparedValue,
10
+ {
11
+ claims: claimsRef.current ??= createLiveQuerySourceIdClaims(),
12
+ client: void 0,
13
+ bindingName: `useLiveQuery`
14
+ },
15
+ getLiveQueryHash
16
+ );
17
+ const bindingRef = useRef(null);
18
+ let binding = bindingRef.current;
19
+ if (binding?.identity !== identity) {
20
+ binding = bindingRef.current = {
21
+ identity,
22
+ // Wholesale mode publishes nothing during subscribe; the hook re-reads
23
+ // the observer's snapshot on each notification.
24
+ observer: createLiveQueryObserver(
25
+ resolveLiveQueryValue(preparedValue, { gcTime: DEFAULT_GC_TIME_MS }),
26
+ { mode: `wholesale` }
27
+ )
28
+ };
29
+ }
30
+ const { observer } = binding;
31
+ const snapshot = observer.getSnapshot();
32
+ const [, setRevision] = useState(0);
33
+ useLayoutEffect(() => {
34
+ const rerender = () => setRevision((revision) => revision + 1);
35
+ observer.subscribe(rerender);
36
+ if (observer.getSnapshot() !== snapshot) rerender();
37
+ return () => observer.dispose();
38
+ }, [observer]);
39
+ return snapshot;
40
+ }
41
+ export {
42
+ useLiveQuery
43
+ };
44
+ //# sourceMappingURL=useLiveQuery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useLiveQuery.js","sources":["../../src/useLiveQuery.ts"],"sourcesContent":["import { useLayoutEffect, useRef, useState } from 'preact/hooks'\nimport {\n createLiveQueryObserver,\n createLiveQuerySourceIdClaims,\n getLiveQueryHash,\n getLiveQueryKey,\n identifyLiveQuery,\n isCollection,\n prepareLiveQueryValue,\n resolveLiveQueryValue,\n} from '@tanstack/db'\nimport type {\n Collection,\n CollectionStatus,\n Context,\n GetResult,\n InferResultType,\n InitialQueryBuilder,\n LiveQueryCollectionConfig,\n LiveQueryKey,\n LiveQueryObserver,\n LiveQuerySourceIdClaims,\n LiveQueryStatusFlags,\n NonSingleResult,\n PooledLiveQueries,\n QueryBuilder,\n SingleResult,\n} from '@tanstack/db'\n\n// A live query this hook creates from a callback or config retires 1 ms after\n// its last subscriber leaves. A supplied Collection, or a config with an\n// explicit `gcTime`, keeps the caller's retention policy.\nconst DEFAULT_GC_TIME_MS = 1\n\nexport type UseLiveQueryStatus = CollectionStatus | `disabled`\n\ntype ConfiguredQueryBuilder<TContext extends Context> = Extract<\n LiveQueryCollectionConfig<TContext>[`query`],\n QueryBuilder<TContext>\n>\n\ntype UseLiveQueryConfigOptions<TContext extends Context> = Omit<\n LiveQueryCollectionConfig<TContext>,\n `query`\n> & {\n /**\n * Explicit identity for a query whose IR cannot derive one, such as a query\n * with functional variants. Structured queries should omit it.\n */\n queryKey?: LiveQueryKey\n /**\n * Pass `pooledLiveQueries` to share one partition among equality-filtered\n * queries of one shape.\n */\n pool?: PooledLiveQueries\n}\n\nexport type UseLiveQueryConfig<TContext extends Context> =\n UseLiveQueryConfigOptions<TContext> &\n Pick<LiveQueryCollectionConfig<TContext>, `query`>\n\nexport type ConditionalUseLiveQueryConfig<TContext extends Context> =\n UseLiveQueryConfigOptions<TContext> & {\n query:\n | ConfiguredQueryBuilder<TContext>\n | ((\n q: InitialQueryBuilder,\n ) => ConfiguredQueryBuilder<TContext> | undefined | null)\n }\n\n/** The result of a live query that is always enabled. */\nexport interface UseLiveQueryResult<\n TResult extends object,\n TKey extends string | number,\n TData,\n TCollection,\n> extends LiveQueryStatusFlags {\n state: ReadonlyMap<TKey, TResult>\n data: TData\n collection: TCollection\n status: CollectionStatus\n isEnabled: true\n}\n\n/** The result of a live query whose callback can disable it. */\nexport interface UseConditionalLiveQueryResult<\n TResult extends object,\n TKey extends string | number,\n TData,\n TCollection,\n> extends LiveQueryStatusFlags {\n state: ReadonlyMap<TKey, TResult> | undefined\n data: TData | undefined\n collection: TCollection | undefined\n status: UseLiveQueryStatus\n isEnabled: boolean\n}\n\ntype QueryResult<TContext extends Context> = UseLiveQueryResult<\n GetResult<TContext>,\n string | number,\n InferResultType<TContext>,\n Collection<GetResult<TContext>, string | number, {}>\n>\n\ntype ConditionalQueryResult<TContext extends Context> =\n UseConditionalLiveQueryResult<\n GetResult<TContext>,\n string | number,\n InferResultType<TContext>,\n Collection<GetResult<TContext>, string | number, {}>\n >\n\n/**\n * Subscribe a Preact component to a live query.\n *\n * The hook derives the query's identity from its structured IR on each render,\n * so values captured by the callback recompile the query when they change. A\n * query whose IR cannot be hashed, such as one using `fn.where`, throws\n * `UnhashableQueryIRError` unless its config supplies `queryKey`. While the\n * hook is mounted, each Collection ID its queries read must keep naming one\n * source object, or it throws `SourceCollectionIdReuseError`.\n * Return `undefined` or `null` from the callback to disable the query.\n *\n * @example\n * const { data, isLoading } = useLiveQuery((q) =>\n * q\n * .from({ todos: todosCollection })\n * .where(({ todos }) => eq(todos.completed, false)),\n * )\n *\n * @example\n * // Disable the query until its input exists\n * const { data, isEnabled } = useLiveQuery((q) =>\n * userId\n * ? q\n * .from({ todos: todosCollection })\n * .where(({ todos }) => eq(todos.userId, userId))\n * : undefined,\n * )\n */\nexport function useLiveQuery<TContext extends Context>(\n queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,\n): QueryResult<TContext>\n\nexport function useLiveQuery<TContext extends Context>(\n queryFn: (\n q: InitialQueryBuilder,\n ) => QueryBuilder<TContext> | undefined | null,\n): ConditionalQueryResult<TContext>\n\nexport function useLiveQuery<TContext extends Context>(\n queryFn: (\n q: InitialQueryBuilder,\n ) => LiveQueryCollectionConfig<TContext> | undefined | null,\n): ConditionalQueryResult<TContext>\n\nexport function useLiveQuery<\n TResult extends object,\n TKey extends string | number,\n TUtils extends Record<string, any>,\n>(\n queryFn: (\n q: InitialQueryBuilder,\n ) => Collection<TResult, TKey, TUtils> | undefined | null,\n): UseConditionalLiveQueryResult<\n TResult,\n TKey,\n Array<TResult>,\n Collection<TResult, TKey, TUtils>\n>\n\n/**\n * Subscribe to a live query described by a config object.\n *\n * @example\n * const { data } = useLiveQuery({\n * query: (q) => q.from({ todos: todosCollection }),\n * gcTime: 60_000,\n * })\n */\nexport function useLiveQuery<TContext extends Context>(\n config: UseLiveQueryConfig<TContext>,\n): QueryResult<TContext>\n\nexport function useLiveQuery<TContext extends Context>(\n config: ConditionalUseLiveQueryConfig<TContext>,\n): ConditionalQueryResult<TContext>\n\n/**\n * Subscribe to an existing live-query Collection. The hook starts its sync and\n * leaves its retention policy to the caller.\n *\n * @example\n * const activeTodos = createLiveQueryCollection((q) =>\n * q.from({ todos: todosCollection }).where(({ todos }) => todos.active),\n * )\n * const { data, collection } = useLiveQuery(activeTodos)\n */\nexport function useLiveQuery<\n TResult extends object,\n TKey extends string | number,\n TUtils extends Record<string, any>,\n>(\n liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,\n): UseLiveQueryResult<\n TResult,\n TKey,\n Array<TResult>,\n Collection<TResult, TKey, TUtils>\n>\n\nexport function useLiveQuery<\n TResult extends object,\n TKey extends string | number,\n TUtils extends Record<string, any>,\n>(\n liveQueryCollection: Collection<TResult, TKey, TUtils> & SingleResult,\n): UseLiveQueryResult<\n TResult,\n TKey,\n TResult | undefined,\n Collection<TResult, TKey, TUtils> & SingleResult\n>\n\nexport function useLiveQuery(\n input: unknown,\n): UseConditionalLiveQueryResult<\n object,\n string | number,\n unknown,\n Collection<object, string | number, any>\n> {\n const claimsRef = useRef<LiveQuerySourceIdClaims | null>(null)\n const preparedValue = prepareLiveQueryValue(input, undefined)\n const queryKey = getLiveQueryKey(input)\n // A supplied Collection is not compiled, so its object is its identity.\n const identity = isCollection(input)\n ? input\n : queryKey\n ? getLiveQueryHash(preparedValue, queryKey)\n : identifyLiveQuery(\n preparedValue,\n {\n claims: (claimsRef.current ??= createLiveQuerySourceIdClaims()),\n client: undefined,\n bindingName: `useLiveQuery`,\n },\n getLiveQueryHash,\n )\n\n const bindingRef = useRef<LiveQueryBinding | null>(null)\n let binding = bindingRef.current\n if (binding?.identity !== identity) {\n binding = bindingRef.current = {\n identity,\n // Wholesale mode publishes nothing during subscribe; the hook re-reads\n // the observer's snapshot on each notification.\n observer: createLiveQueryObserver(\n resolveLiveQueryValue(preparedValue, { gcTime: DEFAULT_GC_TIME_MS }),\n { mode: `wholesale` },\n ),\n }\n }\n\n const { observer } = binding\n const snapshot = observer.getSnapshot()\n const [, setRevision] = useState(0)\n\n useLayoutEffect(() => {\n const rerender = () => setRevision((revision) => revision + 1)\n observer.subscribe(rerender)\n // The query can publish between this render and its subscription.\n if (observer.getSnapshot() !== snapshot) rerender()\n // Each binding owns its observer, so disposal also detaches this\n // subscription and releases the live-query Collection.\n return () => observer.dispose()\n }, [observer])\n\n return snapshot\n}\n\n/** The live query one hook instance reads until its identity changes. */\ninterface LiveQueryBinding {\n identity: unknown\n observer: LiveQueryObserver<object, string | number>\n}\n"],"names":[],"mappings":";;AAgCA,MAAM,qBAAqB;AAiMpB,SAAS,aACd,OAMA;AACA,QAAM,YAAY,OAAuC,IAAI;AAC7D,QAAM,gBAAgB,sBAAsB,OAAO,MAAS;AAC5D,QAAM,WAAW,gBAAgB,KAAK;AAEtC,QAAM,WAAW,aAAa,KAAK,IAC/B,QACA,WACE,iBAAiB,eAAe,QAAQ,IACxC;AAAA,IACE;AAAA,IACA;AAAA,MACE,QAAS,UAAU,YAAY,8BAAA;AAAA,MAC/B,QAAQ;AAAA,MACR,aAAa;AAAA,IAAA;AAAA,IAEf;AAAA,EAAA;AAGR,QAAM,aAAa,OAAgC,IAAI;AACvD,MAAI,UAAU,WAAW;AACzB,MAAI,SAAS,aAAa,UAAU;AAClC,cAAU,WAAW,UAAU;AAAA,MAC7B;AAAA;AAAA;AAAA,MAGA,UAAU;AAAA,QACR,sBAAsB,eAAe,EAAE,QAAQ,oBAAoB;AAAA,QACnE,EAAE,MAAM,YAAA;AAAA,MAAY;AAAA,IACtB;AAAA,EAEJ;AAEA,QAAM,EAAE,aAAa;AACrB,QAAM,WAAW,SAAS,YAAA;AAC1B,QAAM,GAAG,WAAW,IAAI,SAAS,CAAC;AAElC,kBAAgB,MAAM;AACpB,UAAM,WAAW,MAAM,YAAY,CAAC,aAAa,WAAW,CAAC;AAC7D,aAAS,UAAU,QAAQ;AAE3B,QAAI,SAAS,kBAAkB,SAAU,UAAA;AAGzC,WAAO,MAAM,SAAS,QAAA;AAAA,EACxB,GAAG,CAAC,QAAQ,CAAC;AAEb,SAAO;AACT;"}
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@tanstack/preact-db",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0-rc.1",
4
+ "description": "Preact integration for @tanstack/db",
5
+ "author": "Kyle Mathews",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/TanStack/db.git",
10
+ "directory": "packages/preact-db"
11
+ },
12
+ "homepage": "https://tanstack.com/db",
13
+ "keywords": [
14
+ "optimistic",
15
+ "preact",
16
+ "typescript",
17
+ "tanstack-intent"
18
+ ],
19
+ "type": "module",
20
+ "main": "dist/esm/index.js",
21
+ "module": "dist/esm/index.js",
22
+ "types": "dist/esm/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "import": {
26
+ "types": "./dist/esm/index.d.ts",
27
+ "default": "./dist/esm/index.js"
28
+ }
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "sideEffects": false,
33
+ "files": [
34
+ "dist",
35
+ "src",
36
+ "skills"
37
+ ],
38
+ "dependencies": {
39
+ "@tanstack/db": "1.0.0-rc.1"
40
+ },
41
+ "peerDependencies": {
42
+ "preact": ">=10.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@vitest/coverage-istanbul": "^4.1.12",
46
+ "preact": "^11.0.1"
47
+ },
48
+ "scripts": {
49
+ "build": "vite build",
50
+ "dev": "vite build --watch",
51
+ "test": "vitest --run",
52
+ "lint": "eslint . --fix"
53
+ }
6
54
  }
@@ -0,0 +1,153 @@
1
+ ---
2
+ name: preact-db
3
+ description: >
4
+ Preact bindings for TanStack DB. useLiveQuery hook with query callbacks,
5
+ { query, queryKey } config objects, or pre-created collections; identity is
6
+ derived from query IR, with no dependency arrays. Conditional queries via
7
+ returning undefined/null. useLiveInfiniteQuery for paging an ordered live
8
+ query. Functional variants (fn.where) require queryKey. Import from
9
+ @tanstack/preact-db (re-exports all of @tanstack/db).
10
+ type: framework
11
+ library: db
12
+ framework: preact
13
+ library_version: '0.6.17'
14
+ requires:
15
+ - db-core
16
+ sources:
17
+ - 'TanStack/db:docs/framework/preact/overview.md'
18
+ - 'TanStack/db:packages/preact-db/src/useLiveQuery.ts'
19
+ - 'TanStack/db:packages/preact-db/src/useLiveInfiniteQuery.ts'
20
+ ---
21
+
22
+ This skill builds on db-core. Read it first for collection setup, query builder, and mutation patterns.
23
+
24
+ Queries read concrete source Collections. Preact has no `DbProvider` yet, so it does not bind collection descriptors or read from a `DbClient`; a query over a descriptor throws when it is consumed. Use `@tanstack/react-db` or `@tanstack/svelte-db` for `DbClient`-based SSR and hydration.
25
+
26
+ # TanStack DB — Preact
27
+
28
+ ## Setup
29
+
30
+ ```tsx
31
+ import { eq, not, useLiveQuery } from '@tanstack/preact-db'
32
+
33
+ function TodoList() {
34
+ const { data, isLoading } = useLiveQuery((q) =>
35
+ q
36
+ .from({ todo: todoCollection })
37
+ .where(({ todo }) => not(todo.completed))
38
+ .orderBy(({ todo }) => todo.created_at, 'asc'),
39
+ )
40
+
41
+ if (isLoading) return <div>Loading...</div>
42
+ return (
43
+ <ul>
44
+ {data.map((todo) => (
45
+ <li key={todo.id}>{todo.text}</li>
46
+ ))}
47
+ </ul>
48
+ )
49
+ }
50
+ ```
51
+
52
+ `@tanstack/preact-db` re-exports everything from `@tanstack/db`. It supports Preact 10+ and ships ES modules only.
53
+
54
+ ## Hooks
55
+
56
+ ### useLiveQuery
57
+
58
+ ```tsx
59
+ // Query callback
60
+ const { data, state, status, isReady, isEnabled } = useLiveQuery((q) =>
61
+ q.from({ todo: todoCollection }),
62
+ )
63
+
64
+ // Config object (required for queryKey)
65
+ const { data } = useLiveQuery({
66
+ query: (q) => q.from({ todo: todoCollection }),
67
+ gcTime: 60_000,
68
+ })
69
+
70
+ // Pre-created live query collection
71
+ const { data } = useLiveQuery(activeTodosCollection)
72
+
73
+ // Disabled until an input exists: data and state are undefined
74
+ const { data, isEnabled } = useLiveQuery((q) =>
75
+ userId
76
+ ? q
77
+ .from({ todo: todoCollection })
78
+ .where(({ todo }) => eq(todo.userId, userId))
79
+ : undefined,
80
+ )
81
+ ```
82
+
83
+ `isLoadingError` means the first load failed and there is nothing to show; offer `collection.reload()`. `isCrashed` means local storage broke; only `cleanup({ dependents: true })` and a fresh start recover. After the query is ready, sync problems never change `status`: read `isSyncError` or the `health` tree to show stale-data warnings. There is no `isError`.
84
+
85
+ ### useLiveInfiniteQuery
86
+
87
+ ```tsx
88
+ const { data, pages, fetchNextPage, hasNextPage, isFetchingNextPage } =
89
+ useLiveInfiniteQuery(
90
+ (q) =>
91
+ q
92
+ .from({ post: postsCollection })
93
+ .orderBy(({ post }) => post.createdAt, 'desc'),
94
+ { pageSize: 20 },
95
+ )
96
+ ```
97
+
98
+ The query must include `.orderBy()`. `fetchNextPage()` resolves when the page request settles; a failed page load does not reject or change `status`; it appears as a request error in the returned `health` tree. `getNextPageParam` is rejected.
99
+
100
+ ## Preact-Specific Patterns
101
+
102
+ ### Query identity
103
+
104
+ The hook derives identity from the query IR on every render. Captured values in structured expressions recompile the query when they change, so there is no dependency array:
105
+
106
+ ```tsx
107
+ function Filtered({ minPriority }: { minPriority: number }) {
108
+ const { data } = useLiveQuery((q) =>
109
+ q
110
+ .from({ todo: todoCollection })
111
+ .where(({ todo }) => gt(todo.priority, minPriority)),
112
+ )
113
+ return <div>{data.length}</div>
114
+ }
115
+ ```
116
+
117
+ While a hook is mounted, a source Collection ID must keep naming the same Collection object. A different object under a previously read ID throws `SourceCollectionIdReuseError`.
118
+
119
+ ## Common Mistakes
120
+
121
+ ### CRITICAL Using opaque query logic without queryKey
122
+
123
+ Wrong:
124
+
125
+ ```tsx
126
+ const { data } = useLiveQuery((q) =>
127
+ q
128
+ .from({ todo: todoCollection })
129
+ .fn.where(({ todo }) => todo.text.includes(search)),
130
+ )
131
+ ```
132
+
133
+ Correct:
134
+
135
+ ```tsx
136
+ const { data } = useLiveQuery({
137
+ queryKey: [todoCollection.id, 'search', search],
138
+ query: (q) =>
139
+ q
140
+ .from({ todo: todoCollection })
141
+ .fn.where(({ todo }) => todo.text.includes(search)),
142
+ })
143
+ ```
144
+
145
+ Functional variants (`fn.where`, `fn.select`, `fn.having`) have no stable IR identity, so without `queryKey` the hook throws `UnhashableQueryIRError`. Put every value the function captures into the key.
146
+
147
+ ### HIGH Using === instead of eq() in .where()
148
+
149
+ `.where(({ todo }) => todo.completed === false)` compares a query reference, not a value. Use `eq(todo.completed, false)` or `not(todo.completed)`.
150
+
151
+ ### HIGH Reading data of a disabled query without narrowing
152
+
153
+ A callback that can return `undefined` makes `data` possibly `undefined`. Check `isEnabled` or use `data?.`.
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ // Re-export all public APIs
2
+ export { useLiveQuery } from './useLiveQuery'
3
+ export type {
4
+ ConditionalUseLiveQueryConfig,
5
+ UseConditionalLiveQueryResult,
6
+ UseLiveQueryConfig,
7
+ UseLiveQueryResult,
8
+ UseLiveQueryStatus,
9
+ } from './useLiveQuery'
10
+ export { useLiveInfiniteQuery } from './useLiveInfiniteQuery'
11
+ export type {
12
+ UseLiveInfiniteQueryConfig,
13
+ UseLiveInfiniteQueryReturn,
14
+ UseLiveInfiniteQueryReturnWithCollection,
15
+ } from './useLiveInfiniteQuery'
16
+
17
+ // Re-export everything from @tanstack/db
18
+ export * from '@tanstack/db'
19
+
20
+ // Re-export some stuff explicitly to ensure the type & value is exported
21
+ export type { Collection } from '@tanstack/db'
22
+ export { createTransaction } from '@tanstack/db'
@@ -0,0 +1,183 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState } from 'preact/hooks'
2
+ import {
3
+ bindLiveQueryWindow,
4
+ createLiveQuerySourceIdClaims,
5
+ fetchNextLiveQueryWindowPage,
6
+ getLiveQueryHash,
7
+ getLiveQueryKey,
8
+ identifyLiveQuery,
9
+ prepareLiveQueryValue,
10
+ readLiveQueryWindowPageShape,
11
+ } from '@tanstack/db'
12
+ import type {
13
+ Collection,
14
+ CollectionStatus,
15
+ Context,
16
+ InferResultType,
17
+ InitialQueryBuilder,
18
+ LiveQueryKey,
19
+ LiveQuerySourceIdClaims,
20
+ LiveQueryWindowBinding,
21
+ NonSingleResult,
22
+ QueryBuilder,
23
+ } from '@tanstack/db'
24
+ import type { UseLiveQueryResult } from './useLiveQuery'
25
+
26
+ // Keep the generic parameter for typed config wrappers.
27
+ export type UseLiveInfiniteQueryConfig<_TContext extends Context> = {
28
+ /**
29
+ * Explicit identity for a query whose IR cannot derive one, such as a query
30
+ * with functional variants. Structured queries should omit it.
31
+ */
32
+ queryKey?: LiveQueryKey
33
+ pageSize?: number
34
+ /** First result-page label, not a server cursor or remote offset. */
35
+ initialPageParam?: number
36
+ }
37
+
38
+ interface InfiniteQueryPagination<TRow> {
39
+ pages: Array<Array<TRow>>
40
+ pageParams: Array<number>
41
+ fetchNextPage: () => Promise<void>
42
+ hasNextPage: boolean
43
+ isFetchingNextPage: boolean
44
+ }
45
+
46
+ export type UseLiveInfiniteQueryReturn<TContext extends Context> =
47
+ UseLiveQueryResult<
48
+ InferResultType<TContext>[number],
49
+ string | number,
50
+ InferResultType<TContext>,
51
+ Collection<InferResultType<TContext>[number], string | number, {}>
52
+ > &
53
+ InfiniteQueryPagination<InferResultType<TContext>[number]>
54
+
55
+ export type UseLiveInfiniteQueryReturnWithCollection<
56
+ TResult extends object,
57
+ TKey extends string | number,
58
+ TUtils extends Record<string, any>,
59
+ > = UseLiveQueryResult<
60
+ TResult,
61
+ TKey,
62
+ Array<TResult>,
63
+ Collection<TResult, TKey, TUtils> & NonSingleResult
64
+ > &
65
+ InfiniteQueryPagination<TResult>
66
+
67
+ /** What one hook instance renders until its input or page shape changes. */
68
+ /**
69
+ * Page through an ordered live query, growing one live-query Collection's
70
+ * window with `utils.setWindow()` instead of recreating it per page.
71
+ *
72
+ * The query must include `.orderBy()`. Its identity follows `useLiveQuery`:
73
+ * derived from the query IR, or named by `queryKey`. A query whose IR cannot be
74
+ * hashed throws `UnhashableQueryIRError` unless `queryKey` is supplied.
75
+ *
76
+ * @example
77
+ * const { pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(
78
+ * (q) =>
79
+ * q
80
+ * .from({ posts: postsCollection })
81
+ * .orderBy(({ posts }) => posts.createdAt, `desc`),
82
+ * { pageSize: 20 },
83
+ * )
84
+ */
85
+ export function useLiveInfiniteQuery<
86
+ TResult extends object,
87
+ TKey extends string | number,
88
+ TUtils extends Record<string, any>,
89
+ >(
90
+ liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,
91
+ config: UseLiveInfiniteQueryConfig<any>,
92
+ ): UseLiveInfiniteQueryReturnWithCollection<TResult, TKey, TUtils>
93
+
94
+ export function useLiveInfiniteQuery<TContext extends Context>(
95
+ queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
96
+ config: UseLiveInfiniteQueryConfig<TContext>,
97
+ ): UseLiveInfiniteQueryReturn<TContext>
98
+
99
+ export function useLiveInfiniteQuery(
100
+ queryFnOrCollection: unknown,
101
+ config: UseLiveInfiniteQueryConfig<any>,
102
+ ): UseLiveInfiniteQueryReturn<any> {
103
+ const pageShape = readLiveQueryWindowPageShape(config)
104
+ const claimsRef = useRef<LiveQuerySourceIdClaims | null>(null)
105
+ const bindingRef = useRef<LiveQueryWindowBinding | null>(null)
106
+
107
+ // A query input's identity is a hash, so a structurally equal query keeps
108
+ // its loaded pages and a changed one compiles again.
109
+ let preparedValue: unknown
110
+ let identity: string | undefined
111
+ if (typeof queryFnOrCollection === `function`) {
112
+ const queryKey = getLiveQueryKey(config)
113
+ if (queryKey) {
114
+ identity = getLiveQueryHash(undefined, queryKey)
115
+ } else {
116
+ preparedValue = prepareLiveQueryValue(queryFnOrCollection, undefined)
117
+ identity = identifyLiveQuery(
118
+ preparedValue,
119
+ {
120
+ claims: (claimsRef.current ??= createLiveQuerySourceIdClaims()),
121
+ client: undefined,
122
+ bindingName: `useLiveInfiniteQuery`,
123
+ },
124
+ getLiveQueryHash,
125
+ )
126
+ }
127
+ }
128
+
129
+ const binding = (bindingRef.current = bindLiveQueryWindow(
130
+ bindingRef.current,
131
+ {
132
+ ...pageShape,
133
+ // Compile what this render prepared, so the callback runs once.
134
+ input:
135
+ preparedValue === undefined ? queryFnOrCollection : () => preparedValue,
136
+ dependencies: identity === undefined ? undefined : [identity],
137
+ recompile: `identity`,
138
+ },
139
+ ))
140
+
141
+ const { controller } = binding
142
+ const snapshot = controller.getSnapshot()
143
+ const [, setRevision] = useState(0)
144
+
145
+ useLayoutEffect(() => {
146
+ const rerender = () => setRevision((revision) => revision + 1)
147
+ controller.subscribe(rerender)
148
+ // The query can publish between this render and its subscription.
149
+ if (controller.getSnapshot() !== snapshot) rerender()
150
+ if (binding.warning) console.warn(binding.warning)
151
+ // Each binding owns its controller, so disposal also detaches this
152
+ // subscription and releases its window lease.
153
+ return () => controller.dispose()
154
+ }, [controller])
155
+
156
+ const fetchNextPage = useCallback(
157
+ () => fetchNextLiveQueryWindowPage(controller),
158
+ [controller],
159
+ )
160
+
161
+ return {
162
+ data: snapshot.data,
163
+ get state() {
164
+ return snapshot.state
165
+ },
166
+ status: snapshot.status as CollectionStatus,
167
+ isLoading: snapshot.isLoading,
168
+ isReady: snapshot.isReady,
169
+ isIdle: snapshot.isIdle,
170
+ isLoadingError: snapshot.isLoadingError,
171
+ isCrashed: snapshot.isCrashed,
172
+ isCleanedUp: snapshot.isCleanedUp,
173
+ isSyncError: snapshot.isSyncError,
174
+ collection: snapshot.collection,
175
+ isEnabled: true,
176
+ pages: snapshot.pages,
177
+ pageParams: snapshot.pageParams,
178
+ fetchNextPage,
179
+ hasNextPage: snapshot.hasNextPage,
180
+ isFetchingNextPage: snapshot.isFetchingNextPage,
181
+ health: snapshot.health,
182
+ } as UseLiveInfiniteQueryReturn<any>
183
+ }
@@ -0,0 +1,287 @@
1
+ import { useLayoutEffect, useRef, useState } from 'preact/hooks'
2
+ import {
3
+ createLiveQueryObserver,
4
+ createLiveQuerySourceIdClaims,
5
+ getLiveQueryHash,
6
+ getLiveQueryKey,
7
+ identifyLiveQuery,
8
+ isCollection,
9
+ prepareLiveQueryValue,
10
+ resolveLiveQueryValue,
11
+ } from '@tanstack/db'
12
+ import type {
13
+ Collection,
14
+ CollectionStatus,
15
+ Context,
16
+ GetResult,
17
+ InferResultType,
18
+ InitialQueryBuilder,
19
+ LiveQueryCollectionConfig,
20
+ LiveQueryKey,
21
+ LiveQueryObserver,
22
+ LiveQuerySourceIdClaims,
23
+ LiveQueryStatusFlags,
24
+ NonSingleResult,
25
+ PooledLiveQueries,
26
+ QueryBuilder,
27
+ SingleResult,
28
+ } from '@tanstack/db'
29
+
30
+ // A live query this hook creates from a callback or config retires 1 ms after
31
+ // its last subscriber leaves. A supplied Collection, or a config with an
32
+ // explicit `gcTime`, keeps the caller's retention policy.
33
+ const DEFAULT_GC_TIME_MS = 1
34
+
35
+ export type UseLiveQueryStatus = CollectionStatus | `disabled`
36
+
37
+ type ConfiguredQueryBuilder<TContext extends Context> = Extract<
38
+ LiveQueryCollectionConfig<TContext>[`query`],
39
+ QueryBuilder<TContext>
40
+ >
41
+
42
+ type UseLiveQueryConfigOptions<TContext extends Context> = Omit<
43
+ LiveQueryCollectionConfig<TContext>,
44
+ `query`
45
+ > & {
46
+ /**
47
+ * Explicit identity for a query whose IR cannot derive one, such as a query
48
+ * with functional variants. Structured queries should omit it.
49
+ */
50
+ queryKey?: LiveQueryKey
51
+ /**
52
+ * Pass `pooledLiveQueries` to share one partition among equality-filtered
53
+ * queries of one shape.
54
+ */
55
+ pool?: PooledLiveQueries
56
+ }
57
+
58
+ export type UseLiveQueryConfig<TContext extends Context> =
59
+ UseLiveQueryConfigOptions<TContext> &
60
+ Pick<LiveQueryCollectionConfig<TContext>, `query`>
61
+
62
+ export type ConditionalUseLiveQueryConfig<TContext extends Context> =
63
+ UseLiveQueryConfigOptions<TContext> & {
64
+ query:
65
+ | ConfiguredQueryBuilder<TContext>
66
+ | ((
67
+ q: InitialQueryBuilder,
68
+ ) => ConfiguredQueryBuilder<TContext> | undefined | null)
69
+ }
70
+
71
+ /** The result of a live query that is always enabled. */
72
+ export interface UseLiveQueryResult<
73
+ TResult extends object,
74
+ TKey extends string | number,
75
+ TData,
76
+ TCollection,
77
+ > extends LiveQueryStatusFlags {
78
+ state: ReadonlyMap<TKey, TResult>
79
+ data: TData
80
+ collection: TCollection
81
+ status: CollectionStatus
82
+ isEnabled: true
83
+ }
84
+
85
+ /** The result of a live query whose callback can disable it. */
86
+ export interface UseConditionalLiveQueryResult<
87
+ TResult extends object,
88
+ TKey extends string | number,
89
+ TData,
90
+ TCollection,
91
+ > extends LiveQueryStatusFlags {
92
+ state: ReadonlyMap<TKey, TResult> | undefined
93
+ data: TData | undefined
94
+ collection: TCollection | undefined
95
+ status: UseLiveQueryStatus
96
+ isEnabled: boolean
97
+ }
98
+
99
+ type QueryResult<TContext extends Context> = UseLiveQueryResult<
100
+ GetResult<TContext>,
101
+ string | number,
102
+ InferResultType<TContext>,
103
+ Collection<GetResult<TContext>, string | number, {}>
104
+ >
105
+
106
+ type ConditionalQueryResult<TContext extends Context> =
107
+ UseConditionalLiveQueryResult<
108
+ GetResult<TContext>,
109
+ string | number,
110
+ InferResultType<TContext>,
111
+ Collection<GetResult<TContext>, string | number, {}>
112
+ >
113
+
114
+ /**
115
+ * Subscribe a Preact component to a live query.
116
+ *
117
+ * The hook derives the query's identity from its structured IR on each render,
118
+ * so values captured by the callback recompile the query when they change. A
119
+ * query whose IR cannot be hashed, such as one using `fn.where`, throws
120
+ * `UnhashableQueryIRError` unless its config supplies `queryKey`. While the
121
+ * hook is mounted, each Collection ID its queries read must keep naming one
122
+ * source object, or it throws `SourceCollectionIdReuseError`.
123
+ * Return `undefined` or `null` from the callback to disable the query.
124
+ *
125
+ * @example
126
+ * const { data, isLoading } = useLiveQuery((q) =>
127
+ * q
128
+ * .from({ todos: todosCollection })
129
+ * .where(({ todos }) => eq(todos.completed, false)),
130
+ * )
131
+ *
132
+ * @example
133
+ * // Disable the query until its input exists
134
+ * const { data, isEnabled } = useLiveQuery((q) =>
135
+ * userId
136
+ * ? q
137
+ * .from({ todos: todosCollection })
138
+ * .where(({ todos }) => eq(todos.userId, userId))
139
+ * : undefined,
140
+ * )
141
+ */
142
+ export function useLiveQuery<TContext extends Context>(
143
+ queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
144
+ ): QueryResult<TContext>
145
+
146
+ export function useLiveQuery<TContext extends Context>(
147
+ queryFn: (
148
+ q: InitialQueryBuilder,
149
+ ) => QueryBuilder<TContext> | undefined | null,
150
+ ): ConditionalQueryResult<TContext>
151
+
152
+ export function useLiveQuery<TContext extends Context>(
153
+ queryFn: (
154
+ q: InitialQueryBuilder,
155
+ ) => LiveQueryCollectionConfig<TContext> | undefined | null,
156
+ ): ConditionalQueryResult<TContext>
157
+
158
+ export function useLiveQuery<
159
+ TResult extends object,
160
+ TKey extends string | number,
161
+ TUtils extends Record<string, any>,
162
+ >(
163
+ queryFn: (
164
+ q: InitialQueryBuilder,
165
+ ) => Collection<TResult, TKey, TUtils> | undefined | null,
166
+ ): UseConditionalLiveQueryResult<
167
+ TResult,
168
+ TKey,
169
+ Array<TResult>,
170
+ Collection<TResult, TKey, TUtils>
171
+ >
172
+
173
+ /**
174
+ * Subscribe to a live query described by a config object.
175
+ *
176
+ * @example
177
+ * const { data } = useLiveQuery({
178
+ * query: (q) => q.from({ todos: todosCollection }),
179
+ * gcTime: 60_000,
180
+ * })
181
+ */
182
+ export function useLiveQuery<TContext extends Context>(
183
+ config: UseLiveQueryConfig<TContext>,
184
+ ): QueryResult<TContext>
185
+
186
+ export function useLiveQuery<TContext extends Context>(
187
+ config: ConditionalUseLiveQueryConfig<TContext>,
188
+ ): ConditionalQueryResult<TContext>
189
+
190
+ /**
191
+ * Subscribe to an existing live-query Collection. The hook starts its sync and
192
+ * leaves its retention policy to the caller.
193
+ *
194
+ * @example
195
+ * const activeTodos = createLiveQueryCollection((q) =>
196
+ * q.from({ todos: todosCollection }).where(({ todos }) => todos.active),
197
+ * )
198
+ * const { data, collection } = useLiveQuery(activeTodos)
199
+ */
200
+ export function useLiveQuery<
201
+ TResult extends object,
202
+ TKey extends string | number,
203
+ TUtils extends Record<string, any>,
204
+ >(
205
+ liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,
206
+ ): UseLiveQueryResult<
207
+ TResult,
208
+ TKey,
209
+ Array<TResult>,
210
+ Collection<TResult, TKey, TUtils>
211
+ >
212
+
213
+ export function useLiveQuery<
214
+ TResult extends object,
215
+ TKey extends string | number,
216
+ TUtils extends Record<string, any>,
217
+ >(
218
+ liveQueryCollection: Collection<TResult, TKey, TUtils> & SingleResult,
219
+ ): UseLiveQueryResult<
220
+ TResult,
221
+ TKey,
222
+ TResult | undefined,
223
+ Collection<TResult, TKey, TUtils> & SingleResult
224
+ >
225
+
226
+ export function useLiveQuery(
227
+ input: unknown,
228
+ ): UseConditionalLiveQueryResult<
229
+ object,
230
+ string | number,
231
+ unknown,
232
+ Collection<object, string | number, any>
233
+ > {
234
+ const claimsRef = useRef<LiveQuerySourceIdClaims | null>(null)
235
+ const preparedValue = prepareLiveQueryValue(input, undefined)
236
+ const queryKey = getLiveQueryKey(input)
237
+ // A supplied Collection is not compiled, so its object is its identity.
238
+ const identity = isCollection(input)
239
+ ? input
240
+ : queryKey
241
+ ? getLiveQueryHash(preparedValue, queryKey)
242
+ : identifyLiveQuery(
243
+ preparedValue,
244
+ {
245
+ claims: (claimsRef.current ??= createLiveQuerySourceIdClaims()),
246
+ client: undefined,
247
+ bindingName: `useLiveQuery`,
248
+ },
249
+ getLiveQueryHash,
250
+ )
251
+
252
+ const bindingRef = useRef<LiveQueryBinding | null>(null)
253
+ let binding = bindingRef.current
254
+ if (binding?.identity !== identity) {
255
+ binding = bindingRef.current = {
256
+ identity,
257
+ // Wholesale mode publishes nothing during subscribe; the hook re-reads
258
+ // the observer's snapshot on each notification.
259
+ observer: createLiveQueryObserver(
260
+ resolveLiveQueryValue(preparedValue, { gcTime: DEFAULT_GC_TIME_MS }),
261
+ { mode: `wholesale` },
262
+ ),
263
+ }
264
+ }
265
+
266
+ const { observer } = binding
267
+ const snapshot = observer.getSnapshot()
268
+ const [, setRevision] = useState(0)
269
+
270
+ useLayoutEffect(() => {
271
+ const rerender = () => setRevision((revision) => revision + 1)
272
+ observer.subscribe(rerender)
273
+ // The query can publish between this render and its subscription.
274
+ if (observer.getSnapshot() !== snapshot) rerender()
275
+ // Each binding owns its observer, so disposal also detaches this
276
+ // subscription and releases the live-query Collection.
277
+ return () => observer.dispose()
278
+ }, [observer])
279
+
280
+ return snapshot
281
+ }
282
+
283
+ /** The live query one hook instance reads until its identity changes. */
284
+ interface LiveQueryBinding {
285
+ identity: unknown
286
+ observer: LiveQueryObserver<object, string | number>
287
+ }