@freecorps/ember-react-query 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FreeCorps
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,12 @@
1
- # Temporary Holding Version
1
+ # @freecorps/ember-react-query
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
+ TanStack Query integration for Ember: emberQuery() and live query updates.
4
+
5
+ Part of [Ember](https://github.com/freecorps/ember), a Convex-style backend on Cloudflare Workers and Durable
6
+ Objects. Source: [`packages/react-query`](https://github.com/freecorps/ember/tree/main/packages/react-query).
7
+
8
+ ```sh
9
+ npm install @freecorps/ember-react-query
10
+ ```
11
+
12
+ License: MIT.
@@ -0,0 +1,59 @@
1
+ import { type QueryClient, type QueryKey } from "@tanstack/react-query";
2
+ import { EmberError, type EmberClient, type FunctionReference, type MutationOptions, type ShardOptions } from "@freecorps/ember-client";
3
+ type QueryRef = FunctionReference<"query"> | string;
4
+ type MutationRef = FunctionReference<"mutation"> | string;
5
+ type ArgsOf<F> = F extends FunctionReference<any, infer A, any> ? A : Record<string, unknown>;
6
+ type ReturnOf<F> = F extends FunctionReference<any, any, infer R> ? R : unknown;
7
+ /** `["ember", shard | null, "module:export", args]`; a null shard means the client's `defaultShard`. */
8
+ export type EmberQueryKey = readonly ["ember", string | null, string, Record<string, unknown> | "skip"];
9
+ export declare const isEmberQueryKey: (key: QueryKey) => key is EmberQueryKey;
10
+ /** Hash for TanStack: ember keys via the tagged stable JSON (bigint/bytes safe), others via `hashKey`. */
11
+ export declare function emberQueryKeyHashFn(key: QueryKey): string;
12
+ /**
13
+ * Query options for `useQuery`, `useSuspenseQuery`, `prefetchQuery`, ... `"skip"` disables the query.
14
+ */
15
+ export declare function emberQuery<Q extends QueryRef>(ref: Q, args?: ArgsOf<Q> | "skip", opts?: ShardOptions): import("@tanstack/react-query").UseQueryOptions<ReturnOf<Q>, EmberError, ReturnOf<Q>, EmberQueryKey> & {
16
+ initialData?: import("@tanstack/react-query").NonUndefinedGuard<ReturnOf<Q>> | import("@tanstack/react-query").InitialDataFunction<import("@tanstack/react-query").NonUndefinedGuard<ReturnOf<Q>>> | undefined;
17
+ } & import("@tanstack/react-query").QueryKeyWithDataTag<EmberQueryKey, ReturnOf<Q>, EmberError>;
18
+ /**
19
+ * Same key as `emberQuery`, fetched once over HTTP (`POST /api/query`) with the given client. For SSR / loaders:
20
+ * `await queryClient.prefetchQuery(emberQueryOneShot(ember, api.x.y, args))`, dehydrate, hydrate in the browser;
21
+ * the live subscription starts when a component observes the query.
22
+ */
23
+ export declare function emberQueryOneShot<Q extends QueryRef>(client: EmberClient, ref: Q, args?: ArgsOf<Q>, opts?: ShardOptions): import("@tanstack/react-query").OmitKeyof<import("@tanstack/react-query").UseQueryOptions<ReturnOf<Q>, EmberError, ReturnOf<Q>, EmberQueryKey>, "queryFn"> & {
24
+ queryFn?: import("@tanstack/react-query").QueryFunction<ReturnOf<Q>, EmberQueryKey, never>;
25
+ } & import("@tanstack/react-query").QueryKeyWithDataTag<EmberQueryKey, ReturnOf<Q>, EmberError>;
26
+ /** `prefetchQuery` over HTTP (no socket): the SSR helper. */
27
+ export declare function prefetchEmberQuery<Q extends QueryRef>(queryClient: QueryClient, client: EmberClient, ref: Q, args?: ArgsOf<Q>, opts?: ShardOptions): Promise<void>;
28
+ /**
29
+ * `mutationFn` for `useMutation`. Runs over the EmberQueryClient connected to the mutation's QueryClient; the
30
+ * optimistic update (if any) flows into the TanStack cache through the live queries.
31
+ */
32
+ export declare function emberMutationFn<M extends MutationRef>(ref: M, opts?: MutationOptions<ArgsOf<M>>): (variables: ArgsOf<M>, context: {
33
+ client: QueryClient;
34
+ }) => Promise<ReturnOf<M>>;
35
+ export interface EmberQueryClientOptions {
36
+ /** Fetch with HTTP one-shots and never subscribe (SSR). Default: true when there is no `window`. */
37
+ ssr?: boolean;
38
+ }
39
+ export declare class EmberQueryClient {
40
+ readonly client: EmberClient;
41
+ private queryClient;
42
+ private stopCache;
43
+ private readonly live;
44
+ private readonly ssr;
45
+ constructor(client: EmberClient, options?: EmberQueryClientOptions);
46
+ /** Attach to a QueryClient: sets the ember-aware key hash and starts following its cache. */
47
+ connect(queryClient: QueryClient): this;
48
+ disconnect(): void;
49
+ /** Number of live subscriptions held for the TanStack cache. */
50
+ get subscriptionCount(): number;
51
+ private target;
52
+ /** queryFn for ember keys: the live value if there is one, else the first snapshot (or HTTP in SSR). */
53
+ fetch(key: QueryKey): Promise<unknown>;
54
+ private onCacheEvent;
55
+ private watch;
56
+ private unwatch;
57
+ private push;
58
+ }
59
+ export { EmberError };
package/dist/index.js ADDED
@@ -0,0 +1,181 @@
1
+ // TanStack Query adapter for @freecorps/ember-client (in the spirit of @convex-dev/react-query).
2
+ //
3
+ // const ember = new EmberClient({ url, defaultShard: "org:acme" });
4
+ // const queryClient = new QueryClient();
5
+ // new EmberQueryClient(ember).connect(queryClient);
6
+ //
7
+ // const { data } = useQuery(emberQuery(api.messages.list, { channel }));
8
+ // const send = useMutation({ mutationFn: emberMutationFn(api.messages.send) });
9
+ //
10
+ // Queries live in the TanStack cache under ["ember", shard, fn, args] with staleTime Infinity. A query is
11
+ // subscribed over the socket while it has observers and until TanStack garbage-collects it (gcTime); every server
12
+ // patch (and optimistic update) is pushed with setQueryData.
13
+ import { hashKey, queryOptions, skipToken } from "@tanstack/react-query";
14
+ import { EmberError, getFunctionName, stableStringify, } from "@freecorps/ember-client";
15
+ export const isEmberQueryKey = (key) => key[0] === "ember" && key.length === 4 && typeof key[2] === "string";
16
+ /** Hash for TanStack: ember keys via the tagged stable JSON (bigint/bytes safe), others via `hashKey`. */
17
+ export function emberQueryKeyHashFn(key) {
18
+ return isEmberQueryKey(key) ? `ember:${stableStringify(key)}` : hashKey(key);
19
+ }
20
+ const registry = new WeakMap();
21
+ function connected(qc) {
22
+ const e = registry.get(qc);
23
+ if (!e)
24
+ throw new Error("Ember: this QueryClient is not connected (new EmberQueryClient(client).connect(queryClient))");
25
+ return e;
26
+ }
27
+ /**
28
+ * Query options for `useQuery`, `useSuspenseQuery`, `prefetchQuery`, ... `"skip"` disables the query.
29
+ */
30
+ export function emberQuery(ref, args = {}, opts) {
31
+ const key = ["ember", opts?.shard ?? null, getFunctionName(ref), args === "skip" ? "skip" : (args ?? {})];
32
+ return queryOptions({
33
+ queryKey: key,
34
+ queryFn: args === "skip" ? skipToken : (ctx) => connected(ctx.client).fetch(ctx.queryKey),
35
+ queryKeyHashFn: emberQueryKeyHashFn,
36
+ staleTime: Infinity,
37
+ });
38
+ }
39
+ /**
40
+ * Same key as `emberQuery`, fetched once over HTTP (`POST /api/query`) with the given client. For SSR / loaders:
41
+ * `await queryClient.prefetchQuery(emberQueryOneShot(ember, api.x.y, args))`, dehydrate, hydrate in the browser;
42
+ * the live subscription starts when a component observes the query.
43
+ */
44
+ export function emberQueryOneShot(client, ref, args = {}, opts) {
45
+ const base = emberQuery(ref, args, opts);
46
+ return queryOptions({
47
+ ...base,
48
+ queryFn: () => client.query(ref, args, opts),
49
+ });
50
+ }
51
+ /** `prefetchQuery` over HTTP (no socket): the SSR helper. */
52
+ export function prefetchEmberQuery(queryClient, client, ref, args, opts) {
53
+ return queryClient.prefetchQuery(emberQueryOneShot(client, ref, args, opts));
54
+ }
55
+ /**
56
+ * `mutationFn` for `useMutation`. Runs over the EmberQueryClient connected to the mutation's QueryClient; the
57
+ * optimistic update (if any) flows into the TanStack cache through the live queries.
58
+ */
59
+ export function emberMutationFn(ref, opts) {
60
+ return (variables, context) => connected(context.client).client.mutation(ref, variables, opts);
61
+ }
62
+ export class EmberQueryClient {
63
+ client;
64
+ queryClient = null;
65
+ stopCache = null;
66
+ live = new Map();
67
+ ssr;
68
+ constructor(client, options = {}) {
69
+ this.client = client;
70
+ this.ssr = options.ssr ?? typeof globalThis.window === "undefined";
71
+ }
72
+ /** Attach to a QueryClient: sets the ember-aware key hash and starts following its cache. */
73
+ connect(queryClient) {
74
+ if (this.queryClient)
75
+ throw new Error("EmberQueryClient is already connected");
76
+ this.queryClient = queryClient;
77
+ registry.set(queryClient, this);
78
+ const defaults = queryClient.getDefaultOptions();
79
+ queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, queryKeyHashFn: emberQueryKeyHashFn } });
80
+ this.stopCache = queryClient.getQueryCache().subscribe((e) => this.onCacheEvent(e));
81
+ for (const q of queryClient.getQueryCache().getAll())
82
+ if (q.getObserversCount() > 0)
83
+ this.watch(q);
84
+ return this;
85
+ }
86
+ disconnect() {
87
+ this.stopCache?.();
88
+ this.stopCache = null;
89
+ for (const l of this.live.values())
90
+ l.off();
91
+ this.live.clear();
92
+ if (this.queryClient)
93
+ registry.delete(this.queryClient);
94
+ this.queryClient = null;
95
+ }
96
+ /** Number of live subscriptions held for the TanStack cache. */
97
+ get subscriptionCount() {
98
+ return this.live.size;
99
+ }
100
+ target(key) {
101
+ const [, shard, fn, args] = key;
102
+ return { fn, args: args === "skip" ? {} : args, opts: shard === null ? undefined : { shard } };
103
+ }
104
+ /** queryFn for ember keys: the live value if there is one, else the first snapshot (or HTTP in SSR). */
105
+ fetch(key) {
106
+ if (!isEmberQueryKey(key) || key[3] === "skip")
107
+ return Promise.reject(new Error("Ember: not an ember query key"));
108
+ const { fn, args, opts } = this.target(key);
109
+ if (this.ssr)
110
+ return this.client.query(fn, args, opts);
111
+ const now = this.client.localQueryResult(fn, args, opts);
112
+ if (now.status === "success")
113
+ return Promise.resolve(now.value);
114
+ return new Promise((resolve, reject) => {
115
+ let off = null;
116
+ let settled = false;
117
+ const done = (s) => {
118
+ if (settled || s.status === "loading")
119
+ return;
120
+ settled = true;
121
+ // unsubscribe after the current dispatch; the observer's live subscription (if any) keeps the server sub
122
+ queueMicrotask(() => off?.());
123
+ if (s.status === "success")
124
+ resolve(s.value);
125
+ else
126
+ reject(s.error);
127
+ };
128
+ off = this.client.subscribe(fn, args, opts, done);
129
+ if (settled)
130
+ off();
131
+ });
132
+ }
133
+ onCacheEvent(e) {
134
+ if (!isEmberQueryKey(e.query.queryKey))
135
+ return;
136
+ if (e.type === "observerAdded")
137
+ this.watch(e.query);
138
+ else if (e.type === "removed")
139
+ this.unwatch(e.query.queryHash);
140
+ }
141
+ watch(query) {
142
+ if (this.ssr || this.live.has(query.queryHash))
143
+ return;
144
+ const key = query.queryKey;
145
+ if (key[3] === "skip")
146
+ return;
147
+ const { fn, args, opts } = this.target(key);
148
+ const hash = query.queryHash;
149
+ const off = this.client.subscribe(fn, args, opts, (s) => this.push(hash, s));
150
+ this.live.set(hash, { off, query });
151
+ }
152
+ unwatch(hash) {
153
+ const l = this.live.get(hash);
154
+ if (!l)
155
+ return;
156
+ this.live.delete(hash);
157
+ l.off();
158
+ }
159
+ push(hash, s) {
160
+ const l = this.live.get(hash);
161
+ const qc = this.queryClient;
162
+ if (!l || !qc)
163
+ return;
164
+ if (s.status === "success") {
165
+ qc.setQueryData(l.query.queryKey, s.value);
166
+ }
167
+ else if (s.status === "error" && s.error) {
168
+ const st = l.query.state;
169
+ l.query.setState({
170
+ status: "error",
171
+ fetchStatus: "idle",
172
+ error: s.error,
173
+ errorUpdateCount: st.errorUpdateCount + 1,
174
+ errorUpdatedAt: Date.now(),
175
+ fetchFailureCount: st.fetchFailureCount + 1,
176
+ fetchFailureReason: s.error,
177
+ });
178
+ }
179
+ }
180
+ }
181
+ export { EmberError };
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@freecorps/ember-react-query",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "TanStack Query integration for Ember: emberQuery() and live query updates.",
5
+ "keywords": [
6
+ "ember",
7
+ "convex",
8
+ "cloudflare",
9
+ "workers",
10
+ "react",
11
+ "react-query",
12
+ "tanstack-query"
13
+ ],
14
+ "homepage": "https://github.com/freecorps/ember/tree/main/packages/react-query#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/freecorps/ember/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/freecorps/ember.git",
21
+ "directory": "packages/react-query"
22
+ },
23
+ "license": "MIT",
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "engines": {
38
+ "node": ">=22"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "@freecorps/ember-client": "0.2.0"
45
+ },
46
+ "peerDependencies": {
47
+ "@tanstack/react-query": ">=5.80",
48
+ "react": ">=18"
49
+ }
50
+ }