@lunora/cli 1.0.0-alpha.361 → 1.0.0-alpha.363

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.
@@ -1,38 +1,30 @@
1
1
  ---
2
2
  name: lunora-realtime
3
- description: Wires Lunora's live data into a client. Use for `LunoraClient`/`LunoraProvider`,
4
- reactive `useQuery`/`useSubscription`, `useMutation` with optimistic updates,
5
- pagination, connection status, the React/Vue/Solid/Svelte/Angular/React Native
6
- adapters, and the `@lunora/db` TanStack binding.
3
+ description: Wires Lunora's live data into a client app. Covers `LunoraClient` and the per-framework provider, reactive `useQuery`/`useSubscription`, `useMutation` with optimistic updates, `usePaginatedQuery`, `shardKey` routing, connection status, presence, the React/Vue/Solid/Svelte/Angular/React Native adapters, and the `@lunora/db` TanStack DB binding. Use when building UI that reads or writes Lunora functions, when asked to "show live data", "make this update in real time", "add optimistic updates", "add infinite scroll/pagination", or "show offline/reconnecting state", when editing components that import `@lunora/react`/`vue`/`solid`/`svelte`/`angular`/`react-native` or `lunorash/client`, or when a live query stays `undefined`, freezes, or doesn't update.
7
4
  ---
8
5
 
9
6
  # Lunora Realtime
10
7
 
11
8
  Consume Lunora functions reactively from the client. Queries are live
12
- subscriptions over WebSocket — a `useQuery` re-renders the instant a mutation
9
+ subscriptions over WebSocket — a `useQuery` re-renders as soon as a mutation
13
10
  changes the rows it reads — and mutations can paint optimistically with
14
11
  automatic rollback.
15
12
 
16
- ## When to Use
17
-
18
- - Wiring a frontend to a Lunora backend (React, Vue, Solid, Svelte, Angular,
19
- React Native/Expo, or an Astro/Nuxt meta-framework).
20
- - Adding optimistic updates, pagination, or presence to the UI.
21
- - Choosing between live hooks and the `@lunora/db` collection layer.
22
-
23
13
  ## When Not to Use
24
14
 
25
15
  - Writing the server functions themselves — that's `lunora-functions`.
26
16
  - Initial project/provider scaffolding — that's `lunora-quickstart`.
17
+ - Sign-in UI and the `Authenticated` gates — that's `lunora-setup-auth`.
27
18
 
28
19
  ## Provider Setup
29
20
 
30
- Create the `LunoraClient` once at module scope and wrap the app. The client owns
31
- the WebSocket, the optimistic cache, and the offline queue.
21
+ Create the `LunoraClient` once at module scope; it owns the WebSocket, the
22
+ optimistic cache, and the offline queue, so creating it inside a component
23
+ reopens the socket on every render.
32
24
 
33
25
  ```tsx
34
- import { LunoraClient } from "@lunora/client";
35
26
  import { LunoraProvider } from "@lunora/react";
27
+ import { LunoraClient } from "lunorash/client";
36
28
 
37
29
  const url = (import.meta.env.VITE_LUNORA_URL as string | undefined) ?? globalThis.location.origin;
38
30
  const client = new LunoraClient({ url });
@@ -40,84 +32,89 @@ const client = new LunoraClient({ url });
40
32
  // <LunoraProvider client={client}>…</LunoraProvider>
41
33
  ```
42
34
 
43
- Vue / Solid / Svelte have matching providers in `@lunora/vue`, `@lunora/solid`,
44
- `@lunora/svelte`; the hook names and semantics below mirror across them.
35
+ The adapters differ in how they register the client and name their primitives:
45
36
 
46
- Two adapters differ in shape:
37
+ | Package | Register the client | Live read / write |
38
+ | ---------------------- | ------------------------------------------------------------ | ---------------------------------------------- |
39
+ | `@lunora/react` | `<LunoraProvider client={client}>` | `useQuery` / `useMutation` |
40
+ | `@lunora/vue` | `app.use(createLunora(client))` (or `provideLunora(client)`) | `useQuery` / `useMutation` |
41
+ | `@lunora/solid` | `<LunoraProvider client={client}>` | `createQuery` / `createMutation` |
42
+ | `@lunora/svelte` | `setLunoraClient(client)` in the root layout | `query` / `mutation` stores |
43
+ | `@lunora/angular` | `provideLunora()` in `ApplicationConfig` | `liveQuery` / `mutate`, `injectLunoraClient()` |
44
+ | `@lunora/react-native` | `createLunoraClient({ url, storage })` + `LunoraProvider` | re-exports `@lunora/react` hooks |
47
45
 
48
- - **`@lunora/angular`** is signal-based rather than hook-based: register with
49
- `provideLunora(...)`, read the client via `injectLunoraClient()`, and use
50
- `liveQuery(...)` / `mutate(...)` / `connectionStatus(...)` in place of the
51
- hooks below.
52
- - **`@lunora/react-native`** re-exports all of `@lunora/react` and adds
53
- `createLunoraClient` (plus `@lunora/react-native/auth` for the better-auth
54
- Expo bridge) — build the client with that instead of `new LunoraClient`.
55
-
56
- For SSR meta-frameworks, `@lunora/astro` and `@lunora/nuxt` compose Lunora into
57
- the server (Nitro for Nuxt) and ship reactive-loader server helpers.
46
+ The rest of this skill uses the React names; the others mirror the same
47
+ semantics (`usePaginatedQuery` → `createPaginatedQuery` / `paginatedQuery`,
48
+ `useConnectionStatus` → `createConnectionStatus` / `connectionStatus`, …).
49
+ React Native passes `storage: AsyncStorage` so the offline queue survives a
50
+ restart; `@lunora/react-native/auth` bridges a better-auth Expo session.
51
+ `@lunora/astro` and `@lunora/nuxt` (Nitro) integrate Lunora into the SSR server.
58
52
 
59
53
  ## Live Queries
60
54
 
61
- `useQuery(reference, args)` opens a subscription and returns the value, or
62
- `undefined` while it loads. The reference comes from codegen
63
- (`api.<file>.<name>`).
55
+ `useQuery(reference, args, options?)` opens a subscription and returns the
56
+ value, or `undefined` while it loads. References come from codegen and are
57
+ typed end to end, so no cast is needed:
64
58
 
65
59
  ```tsx
66
60
  import { useQuery } from "@lunora/react";
67
61
 
68
- import { api } from "../../lunora/_generated/api";
69
- import type { Doc } from "../../lunora/_generated/dataModel";
62
+ import { api } from "../lunora/_generated/api";
70
63
 
71
- const todos = useQuery(api.todos.list, {}) as Doc<"todos">[] | undefined;
64
+ const todos = useQuery(api.todos.list, {});
65
+ const messages = useQuery(api.messages.list, { channelId }, { shardKey: channelId, onError: report });
72
66
  ```
73
67
 
74
- - `undefined` means "loading" — render a skeleton/spinner for it.
75
- - The subscription tears down on unmount and re-runs only when `args` change or
76
- the underlying rows change. Keep `args` narrow so a write elsewhere doesn't
77
- re-push unrelated data (see `lunora-performance-audit`).
78
- - `useSubscription` is the lower-level primitive for streaming subscriptions;
79
- `usePreloadedQuery` + `lunoraQueryOptions` support SSR/preload handoff via
80
- `@lunora/react/server`.
68
+ - `undefined` means loading and `[]` means loaded-and-empty; branch on both.
69
+ - Pass `"skip"` as `args` to disable the query (no network call, no
70
+ subscription) — e.g. until a required id is known.
71
+ - Pass `onError` to see a server-pushed error (an RLS denial, a query that starts
72
+ throwing). Without it the error is dropped and the value silently freezes at
73
+ its last good result.
74
+ - `shardKey`: for a `.shardBy(...)` table, pin every read and write to the
75
+ shard's key (`{ shardKey: channelId }` on both `useQuery` and the mutation
76
+ call). Without it the runtime has to fan out across every shard.
77
+ - The subscription re-runs only when `args` or the rows it read change. Keep
78
+ `args` narrow so unrelated writes don't re-push data (see
79
+ `lunora-performance-audit`).
80
+ - `useSubscription` is the push-only variant (no initial HTTP fetch) returning
81
+ `{ data, error }`. For SSR, `@lunora/react/server` exports `preloadQuery`,
82
+ `fetchQuery`, and `lunoraQueryOptions`; hand the result to
83
+ `usePreloadedQuery` on the client.
84
+ - References in folders are nested: `lunora/billing/invoices.ts` →
85
+ `api.billing.invoices.<fn>`. Client code only ever imports `api`;
86
+ `internal` functions are not callable from the client.
81
87
 
82
88
  ## Authorization & Live Queries
83
89
 
84
- **Live queries are identity-aware.** At the WebSocket upgrade the runtime
85
- stamps the caller's verified identity onto the socket (from the server-minted
86
- `x-lunora-userid` / `x-lunora-identity` headers, which a client cannot forge).
87
- Every subscription re-run, shape resolution, and poke-driven refresh executes
88
- under that socket's own identity, passed by value so a concurrent RPC can't
89
- clobber it.
90
-
91
- Practically, this means:
92
-
93
- - `.use(rls(...))` and `ctx.auth.userId` work the same in a subscribed query as
94
- in a one-shot read. Shape subscriptions AND-merge the shape predicate with
95
- the table's RLS read base-where, so the membership query the poke protocol
96
- runs is RLS-correct by construction — never the client's word for it.
97
- - An **anonymous** socket carries no identity, so an RLS/`ctx.auth` query fails
98
- closed (empty/denied) rather than leaking another user's rows.
99
- - **Token expiry is enforced on the socket.** When the resolved credential
100
- carries an expiry, the DO sends a `TOKEN_EXPIRED` error frame and closes with
101
- code `4001` at the next send at or after that instant. `LunoraClient`
102
- reconnects automatically and re-resolves a fresh identity — but surface it in
103
- the UI if a re-login is required.
104
-
105
- You can still scope data structurally when it fits the domain — `.shardBy(userId)`
106
- (or tenant/room) partitions state so a subscription is narrow by construction,
107
- and explicit query args keep subscriptions cheap (see the args-scoping note
108
- above). Prefer those for _performance_; use `rls()` / `ctx.auth` for
109
- _authorization_. They compose.
90
+ Live queries are identity-aware. At the WebSocket upgrade the runtime stamps
91
+ the caller's verified identity onto the socket, and every subscription re-run
92
+ executes under that identity, so:
93
+
94
+ - `.use(rls(...))` and `ctx.auth.userId` behave the same in a subscription as in
95
+ a one-shot read. Shape subscriptions AND-merge their predicate with the
96
+ table's RLS read policy.
97
+ - An anonymous socket carries no identity, so an RLS/`ctx.auth` query fails
98
+ closed (empty or denied) rather than leaking rows.
99
+ - When the credential carries an expiry, the server sends `TOKEN_EXPIRED` and
100
+ closes with code `4001`. `LunoraClient` reconnects and re-resolves identity
101
+ automatically; surface it in the UI only if a re-login is required.
102
+
103
+ Use `.shardBy(...)` and narrow args for performance, `rls()` / `ctx.auth` for
104
+ authorization. They compose.
110
105
 
111
106
  ## Mutations + Optimistic Updates
112
107
 
113
- `useMutation(reference)` returns `{ mutate, pending }`. Pass an
114
- `optimisticUpdate` callback to paint the next state immediately; if the server
115
- rejects the call the runtime rolls the cache back automatically.
108
+ `useMutation(reference)` returns `{ mutate, pending, data, error, reset }`.
109
+ Destructure it — the hook result itself isn't callable. `mutate` returns a
110
+ promise that rejects on failure. Pass `optimisticUpdate` to paint the next state
111
+ immediately; if the server rejects the call the cache rolls back.
116
112
 
117
113
  ```tsx
118
114
  import { useMutation } from "@lunora/react";
119
115
 
120
- import type { Doc, Id } from "../../lunora/_generated/dataModel";
116
+ import { api } from "../lunora/_generated/api";
117
+ import type { Doc, Id } from "../lunora/_generated/dataModel";
121
118
 
122
119
  const { mutate: add, pending } = useMutation(api.todos.add);
123
120
 
@@ -139,73 +136,83 @@ await add(
139
136
  );
140
137
  ```
141
138
 
142
- - `optimisticUpdate` names the query it patches, because `todos.add` and
143
- `todos.list` are different functions and nothing can infer the link. When the
144
- server delta arrives it replaces the optimistic entry; on failure the cache
145
- reverts. `store.getAllQueries(fn)` patches every subscribed variant of a query
146
- at once.
147
- - The per-call `optimistic: (current) => next` shortcut exists too, but it only
148
- patches a subscription registered under the **mutation's own** reference and
149
- args — a counter or a document by id. It is a silent no-op for the
150
- `add`-updates-`list` shape above.
151
- - `pending` is `true` while the call is in flight — disable the submit button
152
- with it.
153
- - **Offline queue:** mutations made while disconnected are queued by
154
- `LunoraClient` and replayed on reconnect (client-id-keyed, so they aren't
155
- double-applied).
139
+ - `optimisticUpdate` names the query it patches because nothing can infer that
140
+ `todos.add` affects `todos.list`. `store.getAllQueries(fn)` lists every
141
+ subscribed args variant of a query so you can patch each one.
142
+ - The provisional row must match the query's element shape, including
143
+ `_id`/`_creationTime`, or the UI flickers when the real row lands.
144
+ - The per-call `optimistic: (current) => next` shortcut only patches a
145
+ subscription under the mutation's own reference and args (a counter, a doc by
146
+ id). For the add-updates-list shape above it is a silent no-op.
147
+ - `pending` stays `true` until every overlapping call from this hook settles;
148
+ use it to disable the submit button.
149
+ - Offline, mutations are queued by `LunoraClient` and replayed on reconnect,
150
+ keyed by client id so they aren't applied twice.
156
151
 
157
152
  ## Pagination, Connection, Presence
158
153
 
159
- - `usePaginatedQuery` / `useInfiniteQuery` — cursor pagination over a query that
160
- ends in `.paginate(...)` on the server.
154
+ The server query takes `paginationOpts` and returns `.paginate(...)` (see
155
+ `lunora-functions`). The hook supplies the cursor:
156
+
157
+ ```tsx
158
+ const { results, status, loadMore, error } = usePaginatedQuery(api.messages.page, { channelId }, { initialNumItems: 20 });
159
+ // status: "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted"
160
+ ```
161
+
162
+ Pages are live ranges: inserts and deletes inside a loaded page grow or shrink
163
+ it in place without duplicating rows. There is no total count; subscribe to a
164
+ separate count query for "42 of 1,203". `useInfiniteQuery` is the
165
+ TanStack-style alternative.
166
+
161
167
  - `useConnectionStatus` — live socket state for an offline/reconnecting banner.
162
- - `usePresence` — who's-here + heartbeat (pairs with the `presence` registry
168
+ - `usePresence` — who's-here with heartbeat (pairs with the `presence` registry
163
169
  item).
164
- - `useAuth` + the `Authenticated` / `Unauthenticated` / `AuthLoading` gates —
165
- see `lunora-setup-auth`.
170
+ - `useAuth` + `Authenticated` / `Unauthenticated` / `AuthLoading` — see
171
+ `lunora-setup-auth`.
166
172
 
167
173
  ## `@lunora/db` — TanStack DB Collections
168
174
 
169
- For richer client state (indexed local collections, cross-query joins, a durable
170
- offline-transactions outbox), use `@lunora/db` instead of raw hooks. Scaffold
171
- with `vis generate lunora-collections` (wires `defineCollections` from your
172
- schema + functions into live TanStack DB collections). Reach for it when the app
173
- needs client-side indexes/joins or a persistent optimistic outbox; raw `useQuery`
174
- /`useMutation` are enough for straightforward live lists.
175
-
176
- Call `defineCollections` **once** at module scope and treat the returned
177
- collections as the single source of truth for each table. Don't mirror rows into
178
- a parallel store, and never build derived indexes (trees, search, undo captures)
179
- from a copy — optimistic writes and sync deltas land only on the Lunora
180
- collection, so code reading a copy silently reads stale rows while `useLiveQuery`
181
- renders fresh data.
182
-
183
- ## Common Pitfalls
184
-
185
- 1. **Creating `LunoraClient` inside a component.** It re-opens the socket every
186
- render — create it once at module scope.
187
- 2. **Treating `undefined` as empty.** `undefined` is "loading", `[]` is "loaded,
188
- empty" — branch on both.
189
- 3. **Broad query args.** A subscription keyed too broadly re-renders on
190
- unrelated writes; scope `args` to what the component shows.
191
- 4. **Optimistic shape drift.** The provisional value must match the query's
192
- element shape (including `_id`/`_creationTime`) or the UI flickers when the
193
- real delta lands.
194
- 5. **Two sources of truth for the same rows.** `defineCollections` more than once
195
- per table (or a parallel store of your own) mints copies that drift. Reads
196
- through `useLiveQuery` look fine, but derived indexes built from the copy can
197
- **miss rows** — rows written to the collection after the copy was taken never
198
- appear in the index — or **hold stale rows** — rows updated or deleted in the
199
- collection still show their old value. One instance, one collection per table.
175
+ For indexed local collections, cross-query joins, or a durable offline outbox,
176
+ use `@lunora/db` (peer deps `@tanstack/db`, `@tanstack/offline-transactions`).
177
+ Plain `useQuery` / `useMutation` are enough for straightforward live lists.
178
+
179
+ ```ts
180
+ import { defineCollections } from "@lunora/db";
181
+ import type { LunoraClient } from "lunorash/client";
182
+
183
+ import { api } from "../lunora/_generated/api";
184
+ import type { Doc, Id } from "../lunora/_generated/dataModel";
185
+
186
+ export const createCollections = (client: LunoraClient) =>
187
+ defineCollections(client, {
188
+ messages: {
189
+ list: api.messages.list,
190
+ scopeBy: "channelId", // the .shardBy column
191
+ insert: {
192
+ mutation: api.messages.send,
193
+ optimistic: (input: Omit<Doc<"messages">, "_id" | "_creationTime">, id) => ({ _id: id as Id<"messages">, _creationTime: Date.now(), ...input }),
194
+ toArgs: (row) => ({ channelId: row.channelId, id: row._id, text: row.text }),
195
+ },
196
+ },
197
+ });
198
+ // → db.collections.* (feed useLiveQuery), db.actions.* (optimistic writes),
199
+ // db.scope.* (re-point sharded collections), db.executor (the outbox)
200
+ ```
201
+
202
+ Call `defineCollections` once and treat its collections as the single source of
203
+ truth per table. Optimistic writes and sync deltas land only on that instance,
204
+ so a second instance or a copy of the rows in your own store drifts: derived
205
+ indexes (trees, search, undo captures) built from the copy miss new rows and
206
+ hold stale ones, while `useLiveQuery` still looks correct.
200
207
 
201
208
  ## Checklist
202
209
 
203
- - [ ] `LunoraClient` created once at module scope; app wrapped in the provider.
204
- - [ ] Live reads use `useQuery`; `undefined` handled as loading.
205
- - [ ] Writes use `useMutation`; `pending` disables submit; `optimistic` matches
206
- the row shape.
207
- - [ ] Query `args` scoped narrowly to avoid over-broad re-renders.
208
- - [ ] Pagination via `usePaginatedQuery`/`useInfiniteQuery` over `.paginate`.
209
- - [ ] Considered `@lunora/db` if the app needs local indexes/joins or an outbox.
210
- - [ ] `@lunora/db`: one `defineCollections` instance; no parallel copy of rows;
211
- derived indexes built from the same collection the UI renders.
210
+ - [ ] `LunoraClient` created once at module scope and registered the way the
211
+ adapter expects.
212
+ - [ ] `undefined` handled as loading; `"skip"` used while args are incomplete;
213
+ `onError` wired where a failure must be visible.
214
+ - [ ] Reads and writes on `.shardBy` tables pass `shardKey`.
215
+ - [ ] Mutations destructure `mutate`; `pending` disables submit;
216
+ `optimisticUpdate` rows match the query's element shape.
217
+ - [ ] Lists that grow use `usePaginatedQuery` over a `.paginate` query.
218
+ - [ ] `@lunora/db`: one `defineCollections` instance; no parallel copy of rows.