@lunora/cli 1.0.0-alpha.42 → 1.0.0-alpha.420

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.
Files changed (138) hide show
  1. package/LICENSE.md +33 -0
  2. package/README.md +3 -1
  3. package/dist/bin.mjs +2 -10
  4. package/dist/index.d.mts +1069 -374
  5. package/dist/index.d.ts +1069 -374
  6. package/dist/index.mjs +1 -19
  7. package/dist/packem_chunks/dispatch.mjs +1 -0
  8. package/dist/packem_chunks/handler.mjs +23 -158
  9. package/dist/packem_chunks/handler10.mjs +1 -16
  10. package/dist/packem_chunks/handler11.mjs +1 -22
  11. package/dist/packem_chunks/handler12.mjs +1 -192
  12. package/dist/packem_chunks/handler13.mjs +1 -131
  13. package/dist/packem_chunks/handler14.mjs +3 -65
  14. package/dist/packem_chunks/handler15.mjs +1 -58
  15. package/dist/packem_chunks/handler16.mjs +1 -80
  16. package/dist/packem_chunks/handler17.mjs +1 -43
  17. package/dist/packem_chunks/handler18.mjs +7 -105
  18. package/dist/packem_chunks/handler19.mjs +2 -170
  19. package/dist/packem_chunks/handler2.mjs +1 -114
  20. package/dist/packem_chunks/handler20.mjs +1 -94
  21. package/dist/packem_chunks/handler21.mjs +3 -94
  22. package/dist/packem_chunks/handler22.mjs +3 -0
  23. package/dist/packem_chunks/handler23.mjs +1 -0
  24. package/dist/packem_chunks/handler24.mjs +2 -0
  25. package/dist/packem_chunks/handler25.mjs +99 -0
  26. package/dist/packem_chunks/handler26.mjs +2 -0
  27. package/dist/packem_chunks/handler27.mjs +9 -0
  28. package/dist/packem_chunks/handler28.mjs +1 -0
  29. package/dist/packem_chunks/handler3.mjs +1 -204
  30. package/dist/packem_chunks/handler4.mjs +1 -33
  31. package/dist/packem_chunks/handler5.mjs +3 -49
  32. package/dist/packem_chunks/handler6.mjs +1 -91
  33. package/dist/packem_chunks/handler7.mjs +1 -42
  34. package/dist/packem_chunks/handler8.mjs +1 -174
  35. package/dist/packem_chunks/handler9.mjs +2 -315
  36. package/dist/packem_chunks/runCodegenCommand.mjs +4 -52
  37. package/dist/packem_chunks/runDevCommand.mjs +2 -0
  38. package/dist/packem_chunks/runInitCommand.mjs +165 -1432
  39. package/dist/packem_chunks/runResetCommand.mjs +1 -41
  40. package/dist/packem_chunks/runRpcCommand.mjs +1 -68
  41. package/dist/packem_shared/COMMANDS-BCSDC-pv.mjs +1 -0
  42. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-CyIsI3S1.mjs +1 -0
  43. package/dist/packem_shared/EXIT_CODE-D_XCixv8.mjs +1 -0
  44. package/dist/packem_shared/admin-token-VdUnvnKW.mjs +1 -0
  45. package/dist/packem_shared/admin-url-DJB8P6Jh.mjs +1 -0
  46. package/dist/packem_shared/api-spec-lYyq4vL6.mjs +1 -0
  47. package/dist/packem_shared/binding-manifest-file-CQ1eQYy1.mjs +2 -0
  48. package/dist/packem_shared/buildRegistryIndex-Dc8D8AR6.mjs +1 -0
  49. package/dist/packem_shared/cap-error-body-YBKO32BF.mjs +1 -0
  50. package/dist/packem_shared/catalog-CahzmDLV.mjs +1 -0
  51. package/dist/packem_shared/cli-BbgDNGew.mjs +2 -0
  52. package/dist/packem_shared/cli-manifest-DoQ_3bzs.mjs +1 -0
  53. package/dist/packem_shared/cloudflare-credentials-BVmZnDZk.mjs +1 -0
  54. package/dist/packem_shared/codegen-error-AmH54ofi.mjs +3 -0
  55. package/dist/packem_shared/command-CB4JM-Jl.mjs +1 -0
  56. package/dist/packem_shared/commands-DvSfLvyt.mjs +13 -0
  57. package/dist/packem_shared/createLogger-4TujevyB.mjs +2 -0
  58. package/dist/packem_shared/createRecordingSpawner-Ch7zuQQE.mjs +1 -0
  59. package/dist/packem_shared/deploy-target-resolve-BTCW1Iv4.mjs +1 -0
  60. package/dist/packem_shared/diffSnapshots-htqNZT8Q.mjs +7 -0
  61. package/dist/packem_shared/docker-DpVxvYpL.mjs +1 -0
  62. package/dist/packem_shared/free-port-DjbFhaaT.mjs +1 -0
  63. package/dist/packem_shared/health-probe-Bw9JFfIy.mjs +1 -0
  64. package/dist/packem_shared/import-BRM1hs7j.mjs +12 -0
  65. package/dist/packem_shared/index-oHZBGBwB.mjs +1 -0
  66. package/dist/packem_shared/insertSchemaExtension-DuiV6cba.mjs +8 -0
  67. package/dist/packem_shared/lint-ignore-report-DKZagpqk.mjs +2 -0
  68. package/dist/packem_shared/mcp-config-file-CcDTeXIq.mjs +2 -0
  69. package/dist/packem_shared/open-url-EnKy--w-.mjs +1 -0
  70. package/dist/packem_shared/output-format-DWe_tkTR.mjs +2 -0
  71. package/dist/packem_shared/parseManifest-CwPTKdtS.mjs +1 -0
  72. package/dist/packem_shared/path-containment-CgxYZggb.mjs +1 -0
  73. package/dist/packem_shared/plan-DFY3CqHD.mjs +5 -0
  74. package/dist/packem_shared/planDevCommand-BDoJUgQ4.mjs +1 -0
  75. package/dist/packem_shared/platform-diagnostics-Cn2g6Jh-.mjs +4 -0
  76. package/dist/packem_shared/prompt-cancelled-BvsNxg_Q.mjs +1 -0
  77. package/dist/packem_shared/render-lunora-error-B6893vbU.mjs +2 -0
  78. package/dist/packem_shared/resolve-Bdc9KEUv.mjs +5 -0
  79. package/dist/packem_shared/resolve-target-C_ZloTvd.mjs +1 -0
  80. package/dist/packem_shared/runAddCommand-BChdJQ5L.mjs +1 -0
  81. package/dist/packem_shared/runDeployCommand-D40F3PLQ.mjs +1 -0
  82. package/dist/packem_shared/runExportCommand-slAq5BCF.mjs +5 -0
  83. package/dist/packem_shared/runMigrateGenerateCommand-BwemPIOz.mjs +11 -0
  84. package/dist/packem_shared/schemaIrToSnapshot-BHuNOfgs.mjs +1 -0
  85. package/dist/packem_shared/secret-key-8DPOH98z.mjs +1 -0
  86. package/dist/packem_shared/shared-D-zCOmgY.mjs +1 -0
  87. package/dist/packem_shared/storage-DNzYhXR-.mjs +1 -0
  88. package/dist/packem_shared/tui-prompts-B3YwUhGw.mjs +4 -0
  89. package/dist/packem_shared/variables-CPYyJtsL.mjs +1 -0
  90. package/dist/packem_shared/wrangler-name-Dsk5K1f-.mjs +1 -0
  91. package/dist/packem_shared/wrangler-secrets-CTQPpalP.mjs +1 -0
  92. package/package.json +39 -19
  93. package/skills/README.md +36 -17
  94. package/skills/lunora/SKILL.md +158 -65
  95. package/skills/lunora-cloudflare-audit/SKILL.md +163 -0
  96. package/skills/lunora-create-package/SKILL.md +97 -84
  97. package/skills/lunora-deploy/SKILL.md +175 -105
  98. package/skills/lunora-functions/SKILL.md +363 -56
  99. package/skills/lunora-migration-helper/SKILL.md +162 -137
  100. package/skills/lunora-performance-audit/SKILL.md +137 -95
  101. package/skills/lunora-quickstart/SKILL.md +136 -136
  102. package/skills/lunora-realtime/SKILL.md +217 -97
  103. package/skills/lunora-setup-auth/SKILL.md +162 -102
  104. package/skills/lunora-setup-hyperdrive/SKILL.md +130 -109
  105. package/skills/lunora-setup-hyperdrive-global/SKILL.md +93 -90
  106. package/skills/lunora-setup-mail/SKILL.md +85 -113
  107. package/skills/lunora-setup-scheduler/SKILL.md +132 -96
  108. package/skills/lunora-setup-storage/SKILL.md +189 -95
  109. package/dist/packem_chunks/planDevCommand.mjs +0 -541
  110. package/dist/packem_chunks/runDeployCommand.mjs +0 -594
  111. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +0 -397
  112. package/dist/packem_shared/COMMANDS-B0ftFD_3.mjs +0 -948
  113. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +0 -244
  114. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +0 -19
  115. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +0 -13
  116. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +0 -38
  117. package/dist/packem_shared/codegen-error-DJG-ghs_.mjs +0 -31
  118. package/dist/packem_shared/command-D3lB_4Az.mjs +0 -19
  119. package/dist/packem_shared/commands-B-gR09Z_.mjs +0 -845
  120. package/dist/packem_shared/createLogger-B40gPzQo.mjs +0 -78
  121. package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +0 -43
  122. package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +0 -61
  123. package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +0 -161
  124. package/dist/packem_shared/docker-hMQ97KSQ.mjs +0 -21
  125. package/dist/packem_shared/insertSchemaExtension-DAqbfr9Z.mjs +0 -64
  126. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +0 -41
  127. package/dist/packem_shared/output-format-wUvAN6AL.mjs +0 -17
  128. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +0 -56
  129. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +0 -94
  130. package/dist/packem_shared/prompt-cancelled-APzX1Im-.mjs +0 -9
  131. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +0 -16
  132. package/dist/packem_shared/runAddCommand-bnY6-HKb.mjs +0 -4
  133. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +0 -79
  134. package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +0 -43
  135. package/dist/packem_shared/storage-BIsph-Vk.mjs +0 -84
  136. package/dist/packem_shared/tui-prompts-BjEN8XgP.mjs +0 -658
  137. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +0 -12
  138. package/dist/packem_shared/wrangler-secrets-P2_ZUR-k.mjs +0 -47
@@ -1,37 +1,52 @@
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 adapters, and the
6
- `@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`, when a live query stays `undefined`, freezes, or doesn't update, or when choosing between a live query, a stream, a live topic (`defineLiveStream`, `useLiveTopic`, `ctx.live.publish`, SSE), a whisper, a room and a socket, such as "push an event to the browser" or "notify the page when X happens".
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
13
+ ## Choosing a Channel
17
14
 
18
- - Wiring a frontend to a Lunora backend (React, Vue, Solid, Svelte).
19
- - Adding optimistic updates, pagination, or presence to the UI.
20
- - Choosing between live hooks and the `@lunora/db` collection layer.
15
+ Decide by what you are sending. If the client could get it by running a query,
16
+ use a live query: it is typed, runs under the caller's identity, and works across
17
+ shards. Reach past it only for the rest.
18
+
19
+ | You want | Use |
20
+ | -------------------------------------------------- | ------------------------------------------------------ |
21
+ | Rows that stay fresh | `useQuery` (WebSocket, typed through `api.*`) |
22
+ | A write / a side effect | `useMutation` / `useAction` |
23
+ | Chunks of one long call (model tokens) | `query.stream()` + `useStream` (`durable` to resume) |
24
+ | Chunks of one HTTP request | `httpRoute.get(path).stream()` + `useHttpStream` |
25
+ | A server-published event that is not a row | live topic: `defineLiveStream` + `useLiveTopic` |
26
+ | A client-to-clients ephemeral message (cursors) | whisper (`client.whisper`; authorize with `onWhisper`) |
27
+ | A room with server join / message / leave handlers | `defineRoom` + `client.connectRoom` |
28
+ | A dedicated socket at your own path | `defineWebSocket` + `client.connectWebSocket(path)` |
29
+ | Who is online | `usePresence` |
30
+
31
+ Live topics are typed from their declaration: each topic's key and payload are
32
+ validators on the stream, and codegen emits `_generated/live.ts` with a typed
33
+ reference per topic. Full guide: the "Choosing a data channel" docs page.
21
34
 
22
35
  ## When Not to Use
23
36
 
24
37
  - Writing the server functions themselves — that's `lunora-functions`.
25
38
  - Initial project/provider scaffolding — that's `lunora-quickstart`.
39
+ - Sign-in UI and the `Authenticated` gates — that's `lunora-setup-auth`.
26
40
 
27
41
  ## Provider Setup
28
42
 
29
- Create the `LunoraClient` once at module scope and wrap the app. The client owns
30
- the WebSocket, the optimistic cache, and the offline queue.
43
+ Create the `LunoraClient` once at module scope; it owns the WebSocket, the
44
+ optimistic cache, and the offline queue, so creating it inside a component
45
+ reopens the socket on every render.
31
46
 
32
47
  ```tsx
33
- import { LunoraClient } from "@lunora/client";
34
48
  import { LunoraProvider } from "@lunora/react";
49
+ import { LunoraClient } from "lunorash/client";
35
50
 
36
51
  const url = (import.meta.env.VITE_LUNORA_URL as string | undefined) ?? globalThis.location.origin;
37
52
  const client = new LunoraClient({ url });
@@ -39,80 +54,97 @@ const client = new LunoraClient({ url });
39
54
  // <LunoraProvider client={client}>…</LunoraProvider>
40
55
  ```
41
56
 
42
- Vue / Solid / Svelte have matching providers in `@lunora/vue`, `@lunora/solid`,
43
- `@lunora/svelte`; the hook names and semantics below mirror across them.
57
+ The adapters differ in how they register the client and name their primitives:
58
+
59
+ | Package | Register the client | Live read / write |
60
+ | ---------------------- | ------------------------------------------------------------ | ---------------------------------------------- |
61
+ | `@lunora/react` | `<LunoraProvider client={client}>` | `useQuery` / `useMutation` |
62
+ | `@lunora/vue` | `app.use(createLunora(client))` (or `provideLunora(client)`) | `useQuery` / `useMutation` |
63
+ | `@lunora/solid` | `<LunoraProvider client={client}>` | `createQuery` / `createMutation` |
64
+ | `@lunora/svelte` | `setLunoraClient(client)` in the root layout | `query` / `mutation` stores |
65
+ | `@lunora/angular` | `provideLunora()` in `ApplicationConfig` | `liveQuery` / `mutate`, `injectLunoraClient()` |
66
+ | `@lunora/react-native` | `createLunoraClient({ url, storage })` + `LunoraProvider` | re-exports `@lunora/react` hooks |
67
+
68
+ The rest of this skill uses the React names; the others mirror the same
69
+ semantics (`usePaginatedQuery` → `createPaginatedQuery` / `paginatedQuery`,
70
+ `useConnectionStatus` → `createConnectionStatus` / `connectionStatus`, …).
71
+ React Native passes `storage: AsyncStorage` so the offline queue survives a
72
+ restart; `@lunora/react-native/auth` bridges a better-auth Expo session.
73
+ `@lunora/astro` and `@lunora/nuxt` (Nitro) integrate Lunora into the SSR server.
44
74
 
45
75
  ## Live Queries
46
76
 
47
- `useQuery(reference, args)` opens a subscription and returns the value, or
48
- `undefined` while it loads. The reference comes from codegen
49
- (`api.<file>.<name>`).
77
+ `useQuery(reference, args, options?)` opens a subscription and returns the
78
+ value, or `undefined` while it loads. References come from codegen and are
79
+ typed end to end, so no cast is needed:
50
80
 
51
81
  ```tsx
52
82
  import { useQuery } from "@lunora/react";
53
83
 
54
- import { api } from "../../lunora/_generated/api";
55
- import type { Doc } from "../../lunora/_generated/dataModel";
84
+ import { api } from "../lunora/_generated/api";
56
85
 
57
- const todos = useQuery(api.todos.list, {}) as Doc<"todos">[] | undefined;
86
+ const todos = useQuery(api.todos.list, {});
87
+ const messages = useQuery(api.messages.list, { channelId }, { shardKey: channelId, onError: report });
58
88
  ```
59
89
 
60
- - `undefined` means "loading" — render a skeleton/spinner for it.
61
- - The subscription tears down on unmount and re-runs only when `args` change or
62
- the underlying rows change. Keep `args` narrow so a write elsewhere doesn't
63
- re-push unrelated data (see `lunora-performance-audit`).
64
- - `useSubscription` is the lower-level primitive for streaming subscriptions;
65
- `usePreloadedQuery` + `lunoraQueryOptions` support SSR/preload handoff via
66
- `@lunora/react/server`.
90
+ - `undefined` means loading and `[]` means loaded-and-empty; branch on both.
91
+ - Pass `"skip"` as `args` to disable the query (no network call, no
92
+ subscription) — e.g. until a required id is known.
93
+ - Pass `onError` to see a server-pushed error (an RLS denial, a query that starts
94
+ throwing). Without it the error is dropped and the value silently freezes at
95
+ its last good result.
96
+ - `shardKey`: for a `.shardBy(...)` table, pin every read and write to the
97
+ shard's key (`{ shardKey: channelId }` on both `useQuery` and the mutation
98
+ call). Without it the runtime has to fan out across every shard.
99
+ - The subscription re-runs only when `args` or the rows it read change. Keep
100
+ `args` narrow so unrelated writes don't re-push data (see
101
+ `lunora-performance-audit`).
102
+ - `useSubscription` is the push-only variant (no initial HTTP fetch) returning
103
+ `{ data, error }`. For SSR, `@lunora/react/server` exports `preloadQuery`,
104
+ `fetchQuery`, and `lunoraQueryOptions`; hand the result to
105
+ `usePreloadedQuery` on the client.
106
+ - References in folders are nested: `lunora/billing/invoices.ts` →
107
+ `api.billing.invoices.<fn>`. Client code only ever imports `api`;
108
+ `internal` functions are not callable from the client.
67
109
 
68
110
  ## Authorization & Live Queries
69
111
 
70
- Subscriptions re-run the query handler **server-side under anonymous
71
- identity**. The one-shot `fetch` RPC behind the initial load carries the
72
- caller's identity, but the live WebSocket channel (the subscription seed and
73
- every write-driven refresh) does not — it evaluates as anonymous.
74
-
75
- This matters for any query that authorizes or filters on the authenticated
76
- user:
112
+ Live queries are identity-aware. At the WebSocket upgrade the runtime stamps
113
+ the caller's verified identity onto the socket, and every subscription re-run
114
+ executes under that identity, so:
77
115
 
78
- - A query guarded by `.use(rls(...))` or one that reads `ctx.auth.userId`
79
- directly returns the user's rows on the **initial** HTTP fetch, but its
80
- **live** updates evaluate anonymously and may resolve to an empty/denied
81
- set.
82
- - This **fails closed** — the live channel shows _less_ data, never another
83
- user's data, so there is no leak. But it is a correctness caveat: the
84
- initial render and the live updates can disagree.
116
+ - `.use(rls(...))` and `ctx.auth.userId` behave the same in a subscription as in
117
+ a one-shot read. Shape subscriptions AND-merge their predicate with the
118
+ table's RLS read policy.
119
+ - An anonymous socket carries no identity, so an RLS/`ctx.auth` query fails
120
+ closed (empty or denied) rather than leaking rows.
121
+ - When the credential carries an expiry, the server sends `TOKEN_EXPIRED` and
122
+ closes with code `4001`. `LunoraClient` reconnects and re-resolves identity
123
+ automatically; surface it in the UI only if a re-login is required.
85
124
 
86
- The supported pattern today is to scope per-user data **outside** of
87
- `ctx.auth` inside a subscribed query:
88
-
89
- - Partition the data by shard with `.shardBy(userId)` (or tenant/room), so the
90
- subscription is already scoped to the right state, or
91
- - Pass the identifier as an **explicit query arg**
92
- (`useQuery(api.todos.list, { userId })`) and filter on the arg rather than on
93
- `ctx.auth`.
94
-
95
- Reserve `rls()` / `ctx.auth`-based filtering for non-subscribed reads (one-shot
96
- actions/queries) where identity is always present.
125
+ Use `.shardBy(...)` and narrow args for performance, `rls()` / `ctx.auth` for
126
+ authorization. They compose.
97
127
 
98
128
  ## Mutations + Optimistic Updates
99
129
 
100
- `useMutation(reference)` returns `{ mutate, pending }`. Pass an `optimistic`
101
- callback to paint the next state immediately; if the server rejects the call the
102
- runtime rolls the cache back automatically.
130
+ `useMutation(reference)` returns `{ mutate, pending, data, error, reset }`.
131
+ Destructure it — the hook result itself isn't callable. `mutate` returns a
132
+ promise that rejects on failure. Pass `optimisticUpdate` to paint the next state
133
+ immediately; if the server rejects the call the cache rolls back.
103
134
 
104
135
  ```tsx
105
136
  import { useMutation } from "@lunora/react";
106
137
 
107
- import type { Doc, Id } from "../../lunora/_generated/dataModel";
138
+ import { api } from "../lunora/_generated/api";
139
+ import type { Doc, Id } from "../lunora/_generated/dataModel";
108
140
 
109
141
  const { mutate: add, pending } = useMutation(api.todos.add);
110
142
 
111
143
  await add(
112
144
  { text },
113
145
  {
114
- optimistic: (current) => {
115
- const list = (current as Doc<"todos">[] | undefined) ?? [];
146
+ optimisticUpdate: (store) => {
147
+ const list = store.getQuery(api.todos.list, {}) ?? [];
116
148
  const provisional: Doc<"todos"> = {
117
149
  _id: `optimistic_${Date.now()}` as Id<"todos">,
118
150
  _creationTime: Date.now(),
@@ -120,58 +152,146 @@ await add(
120
152
  done: false,
121
153
  createdAt: Date.now(),
122
154
  };
123
- return [provisional, ...list];
155
+ store.setQuery(api.todos.list, {}, [provisional, ...list]);
124
156
  },
125
157
  },
126
158
  );
127
159
  ```
128
160
 
129
- - The `optimistic` callback receives the current cached value and returns the
130
- provisional one. When the server delta arrives it replaces the optimistic
131
- entry; on failure the cache reverts.
132
- - `pending` is `true` while the call is in flight — disable the submit button
133
- with it.
134
- - **Offline queue:** mutations made while disconnected are queued by
135
- `LunoraClient` and replayed on reconnect (client-id-keyed, so they aren't
136
- double-applied).
161
+ - `optimisticUpdate` names the query it patches because nothing can infer that
162
+ `todos.add` affects `todos.list`. `store.getAllQueries(fn)` lists every
163
+ subscribed args variant of a query so you can patch each one.
164
+ - The provisional row must match the query's element shape, including
165
+ `_id`/`_creationTime`, or the UI flickers when the real row lands.
166
+ - The per-call `optimistic: (current) => next` shortcut only patches a
167
+ subscription under the mutation's own reference and args (a counter, a doc by
168
+ id). For the add-updates-list shape above it is a silent no-op.
169
+ - `pending` stays `true` until every overlapping call from this hook settles;
170
+ use it to disable the submit button.
171
+ - Offline, mutations are queued by `LunoraClient` and replayed on reconnect,
172
+ keyed by client id so they aren't applied twice.
137
173
 
138
174
  ## Pagination, Connection, Presence
139
175
 
140
- - `usePaginatedQuery` / `useInfiniteQuery` — cursor pagination over a query that
141
- ends in `.paginate(...)` on the server.
176
+ The server query takes `paginationOpts` and returns `.paginate(...)` (see
177
+ `lunora-functions`). The hook supplies the cursor:
178
+
179
+ ```tsx
180
+ const { results, status, loadMore, error } = usePaginatedQuery(api.messages.page, { channelId }, { initialNumItems: 20 });
181
+ // status: "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted"
182
+ ```
183
+
184
+ Pages are live ranges: inserts and deletes inside a loaded page grow or shrink
185
+ it in place without duplicating rows. There is no total count; subscribe to a
186
+ separate count query for "42 of 1,203". `useInfiniteQuery` is the
187
+ TanStack-style alternative.
188
+
142
189
  - `useConnectionStatus` — live socket state for an offline/reconnecting banner.
143
- - `usePresence` — who's-here + heartbeat (pairs with the `presence` registry
190
+ - `usePresence` — who's-here with heartbeat (pairs with the `presence` registry
144
191
  item).
145
- - `useAuth` + the `Authenticated` / `Unauthenticated` / `AuthLoading` gates —
146
- see `lunora-setup-auth`.
192
+ - `useAuth` + `Authenticated` / `Unauthenticated` / `AuthLoading` — see
193
+ `lunora-setup-auth`.
194
+
195
+ ## Live Topics
196
+
197
+ For an event with no query behind it ("a comment was posted"), the server
198
+ publishes to a declared topic and the browser follows topics over one shared SSE
199
+ connection (shared when the connection options match). Declare the stream and its
200
+ topics once (`defineLiveStream({ id, topics: { post: { key: { postId: v.id("posts") },
201
+ payload: v.object({...}) } }, onSubscribe })`), mount its two handlers on the HTTP
202
+ router, and follow topics with the adapter's hook (all return `{ lastEvent, error }`).
203
+ `onSubscribe` receives the parsed `{ ctx, topic, key, raw }`; do not split a string.
204
+ A subscribe for an undeclared name, wrong arity or a non-canonical spelling is denied
205
+ before `onSubscribe` runs. Codegen writes `_generated/live.ts`:
206
+
207
+ | Package | Primitive |
208
+ | ---------------------- | ------------------------- |
209
+ | `@lunora/react`, `vue` | `useLiveTopic` |
210
+ | `@lunora/solid` | `createLiveTopic` |
211
+ | `@lunora/svelte` | `liveTopic` (a store) |
212
+ | `@lunora/angular` | `liveTopic` (signals) |
213
+ | `@lunora/react-native` | re-exports `useLiveTopic` |
214
+
215
+ ```tsx
216
+ import { live } from "../lunora/_generated/live";
217
+
218
+ // the route comes from the client in <LunoraProvider>; lastEvent / onEvent are typed by the payload
219
+ const { lastEvent, error } = useLiveTopic(live.posts.post({ postId }), { onEvent: (payload) => refresh(payload) });
220
+ ```
221
+
222
+ Svelte's `liveTopic` and Angular's `liveTopic` read the client when called: call
223
+ them during component init (Angular: or pass `client` and `destroyRef`). Pass
224
+ `shardKey` in the options to follow a `.shardBy` shard.
225
+
226
+ - Several hooks with the same URL, credentials, headers, `retryDelay`, `maxDelayMs` and
227
+ `sessionKey` share a single connection; the last to unmount closes it. On a
228
+ cookie-authenticated stream pass `sessionKey` (the user id), or the connection
229
+ outlives a sign-out and keeps the previous user's topics.
230
+ - Publish from server code with `ctx.live.publish(live.posts.post({ postId }), payload)`:
231
+ the ref names the stream, the payload is typed, and the shard refuses a payload the
232
+ topic's validator rejects (`BAD_REQUEST`). Topic names and key fields are plain
233
+ identifiers, key validators are string-valued, and a topic named `room` is refused.
234
+ `ctx.live` exists only on actions, HTTP actions, queue consumers and scheduled
235
+ actions, NOT on a query or mutation. It is always present on an HTTP action
236
+ but typed optional on an `action` and a queue consumer, so write
237
+ `ctx.live?.publish(...)` there. To tell a page about a write, have the
238
+ mutation `ctx.scheduler.runAfter(0, internal.x.publish, args)`: it is held until
239
+ the commit, so a rolled-back write sends nothing. If the page already has a
240
+ `useQuery` over those rows, skip the event.
241
+ - Without `onSubscribe` every subscribe is DENIED (unless `allowAnonymousControl`
242
+ admits anonymous callers). Only a literal `true` allows.
243
+ - Delivery is at least once (a retried action publishes again) and shard-local
244
+ (no fan-out across Durable Objects). Put an id in the payload to dedupe.
245
+ - React Native's stock `fetch` does not stream, so events never arrive; use a
246
+ streaming `fetch` such as `expo/fetch`.
247
+ - Live streams are native on Cloudflare and celld, emulated on Node. Codegen
248
+ refuses an app that reads `ctx.live` on a target that withholds them.
147
249
 
148
250
  ## `@lunora/db` — TanStack DB Collections
149
251
 
150
- For richer client state (indexed local collections, cross-query joins, a durable
151
- offline-transactions outbox), use `@lunora/db` instead of raw hooks. Scaffold
152
- with `vis generate lunora-collections` (wires `defineCollections` from your
153
- schema + functions into live TanStack DB collections). Reach for it when the app
154
- needs client-side indexes/joins or a persistent optimistic outbox; raw `useQuery`
155
- /`useMutation` are enough for straightforward live lists.
156
-
157
- ## Common Pitfalls
158
-
159
- 1. **Creating `LunoraClient` inside a component.** It re-opens the socket every
160
- render — create it once at module scope.
161
- 2. **Treating `undefined` as empty.** `undefined` is "loading", `[]` is "loaded,
162
- empty" — branch on both.
163
- 3. **Broad query args.** A subscription keyed too broadly re-renders on
164
- unrelated writes; scope `args` to what the component shows.
165
- 4. **Optimistic shape drift.** The provisional value must match the query's
166
- element shape (including `_id`/`_creationTime`) or the UI flickers when the
167
- real delta lands.
252
+ For indexed local collections, cross-query joins, or a durable offline outbox,
253
+ use `@lunora/db` (peer deps `@tanstack/db`, `@tanstack/offline-transactions`).
254
+ Plain `useQuery` / `useMutation` are enough for straightforward live lists.
255
+
256
+ ```ts
257
+ import { defineCollections } from "@lunora/db";
258
+ import type { LunoraClient } from "lunorash/client";
259
+
260
+ import { api } from "../lunora/_generated/api";
261
+ import type { Doc, Id } from "../lunora/_generated/dataModel";
262
+
263
+ export const createCollections = (client: LunoraClient) =>
264
+ defineCollections(client, {
265
+ messages: {
266
+ list: api.messages.list,
267
+ scopeBy: "channelId", // the .shardBy column
268
+ insert: {
269
+ mutation: api.messages.send,
270
+ optimistic: (input: Omit<Doc<"messages">, "_id" | "_creationTime">, id) => ({ _id: id as Id<"messages">, _creationTime: Date.now(), ...input }),
271
+ toArgs: (row) => ({ channelId: row.channelId, id: row._id, text: row.text }),
272
+ },
273
+ },
274
+ });
275
+ // → db.collections.* (feed useLiveQuery), db.actions.* (optimistic writes),
276
+ // db.scope.* (re-point sharded collections), db.executor (the outbox)
277
+ ```
278
+
279
+ Call `defineCollections` once and treat its collections as the single source of
280
+ truth per table. Optimistic writes and sync deltas land only on that instance,
281
+ so a second instance or a copy of the rows in your own store drifts: derived
282
+ indexes (trees, search, undo captures) built from the copy miss new rows and
283
+ hold stale ones, while `useLiveQuery` still looks correct.
168
284
 
169
285
  ## Checklist
170
286
 
171
- - [ ] `LunoraClient` created once at module scope; app wrapped in the provider.
172
- - [ ] Live reads use `useQuery`; `undefined` handled as loading.
173
- - [ ] Writes use `useMutation`; `pending` disables submit; `optimistic` matches
174
- the row shape.
175
- - [ ] Query `args` scoped narrowly to avoid over-broad re-renders.
176
- - [ ] Pagination via `usePaginatedQuery`/`useInfiniteQuery` over `.paginate`.
177
- - [ ] Considered `@lunora/db` if the app needs local indexes/joins or an outbox.
287
+ - [ ] `LunoraClient` created once at module scope and registered the way the
288
+ adapter expects.
289
+ - [ ] `undefined` handled as loading; `"skip"` used while args are incomplete;
290
+ `onError` wired where a failure must be visible.
291
+ - [ ] Reads and writes on `.shardBy` tables pass `shardKey`.
292
+ - [ ] Mutations destructure `mutate`; `pending` disables submit;
293
+ `optimisticUpdate` rows match the query's element shape.
294
+ - [ ] Lists that grow use `usePaginatedQuery` over a `.paginate` query.
295
+ - [ ] `@lunora/db`: one `defineCollections` instance; no parallel copy of rows.
296
+ - [ ] Events that are not rows use a live topic, published from an action (via
297
+ `ctx.scheduler` after a write); data a query could return stays a `useQuery`.