okengine 0.17.2 → 0.18.4

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 (209) hide show
  1. package/AGENTS.md +5 -3
  2. package/README.md +14 -10
  3. package/manifest.v1.schema.json +61 -2
  4. package/package.json +19 -5
  5. package/site/content/docs/ai/index.mdx +1 -1
  6. package/site/content/docs/ai/mcp.mdx +10 -1
  7. package/site/content/docs/ai/skills.mdx +9 -9
  8. package/site/content/docs/elements/ai.mdx +1 -1
  9. package/site/content/docs/elements/clock.mdx +1 -1
  10. package/site/content/docs/elements/flow.mdx +25 -1
  11. package/site/content/docs/elements/gate.mdx +3 -2
  12. package/site/content/docs/elements/store.mdx +289 -341
  13. package/site/content/docs/elements/vault.mdx +5 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +3 -10
  15. package/site/content/docs/get-started/index.mdx +1 -1
  16. package/site/content/docs/get-started/installation.mdx +2 -3
  17. package/site/content/docs/get-started/introduction.mdx +58 -121
  18. package/site/content/docs/get-started/meta.json +9 -1
  19. package/site/content/docs/get-started/project-structure.mdx +4 -11
  20. package/site/content/docs/get-started/testing.mdx +328 -0
  21. package/site/content/docs/get-started/why.mdx +93 -71
  22. package/site/content/docs/index.mdx +44 -11
  23. package/site/content/docs/meta.json +8 -5
  24. package/site/content/docs/plugins/apple.mdx +151 -0
  25. package/site/content/docs/plugins/discord.mdx +139 -0
  26. package/site/content/docs/plugins/facebook.mdx +134 -0
  27. package/site/content/docs/plugins/figma.mdx +138 -0
  28. package/site/content/docs/plugins/github.mdx +138 -0
  29. package/site/content/docs/plugins/google.mdx +153 -0
  30. package/site/content/docs/plugins/index.mdx +47 -1
  31. package/site/content/docs/plugins/meta.json +10 -0
  32. package/site/content/docs/plugins/microsoft.mdx +151 -0
  33. package/site/content/docs/plugins/oauth.mdx +188 -0
  34. package/site/content/docs/plugins/x.mdx +125 -0
  35. package/site/content/docs/providers/index.mdx +2 -0
  36. package/site/content/docs/recipes/index.mdx +2 -0
  37. package/site/content/docs/reference/cli.md +3 -2
  38. package/site/content/docs/reference/client.mdx +58 -1
  39. package/site/content/docs/reference/configuration.mdx +2 -4
  40. package/site/content/docs/reference/fx.mdx +3 -1
  41. package/site/content/docs/reference/index.mdx +0 -5
  42. package/site/content/docs/reference/meta.json +2 -2
  43. package/site/content/docs/reference/okid.mdx +137 -0
  44. package/src/auth/bindings.ts +1 -1
  45. package/src/auth/config.ts +9 -0
  46. package/src/auth/identity-sql.ts +314 -0
  47. package/src/auth/identity.ts +140 -2
  48. package/src/auth/index.ts +17 -1
  49. package/src/auth/method-context.ts +3 -0
  50. package/src/auth/oauth-as/cimd.ts +132 -0
  51. package/src/auth/oauth-as/crypto.test.ts +101 -0
  52. package/src/auth/oauth-as/crypto.ts +393 -0
  53. package/src/auth/oauth-as/errors.ts +68 -0
  54. package/src/auth/oauth-as/http.test.ts +419 -0
  55. package/src/auth/oauth-as/http.ts +842 -0
  56. package/src/auth/oauth-as/stores.ts +61 -0
  57. package/src/auth/oauth-as/tables.ts +142 -0
  58. package/src/auth/tables.ts +0 -11
  59. package/src/bench/README.md +83 -0
  60. package/src/bench/REPORT.md +176 -0
  61. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  62. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  63. package/src/bench/g03-signal-once.bench.ts +157 -0
  64. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  65. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  66. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  67. package/src/bench/g05-sustained-full.bench.ts +265 -0
  68. package/src/bench/g06-mixed-load.bench.ts +260 -0
  69. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  70. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  71. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  72. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  73. package/src/bench/g08-store-sql.bench.ts +178 -0
  74. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  75. package/src/bench/g10-observability-contention.bench.ts +246 -0
  76. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  77. package/src/bench/g13-elements.bench.ts +427 -0
  78. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  79. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  80. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  81. package/src/bench/lib/event-loop-lag.ts +26 -0
  82. package/src/bench/lib/infra.ts +60 -0
  83. package/src/bench/lib/report.ts +52 -0
  84. package/src/bench/lib/rss-sampler.ts +61 -0
  85. package/src/bench/lib/signal-pg.ts +88 -0
  86. package/src/bench/load-app.ts +337 -0
  87. package/src/bench/load-child.ts +108 -0
  88. package/src/bench/smoke.bench.ts +43 -0
  89. package/src/cli/competitor-mention-removal.test.ts +28 -0
  90. package/src/cli/doctor-fd.ts +117 -0
  91. package/src/cli/doctor.test.ts +192 -0
  92. package/src/cli/doctor.ts +129 -1
  93. package/src/client/create.ts +95 -1
  94. package/src/client/index.ts +9 -2
  95. package/src/client/transport.ts +11 -4
  96. package/src/client/use-live-query.ts +154 -0
  97. package/src/client-react/index.ts +15 -1
  98. package/src/client-react/live-resource.ts +246 -0
  99. package/src/client-react/use-live-query.test.ts +475 -0
  100. package/src/client-react/use-live-query.ts +530 -0
  101. package/src/compiler/extract.test.ts +518 -0
  102. package/src/compiler/extract.ts +386 -19
  103. package/src/console/server/invoke-user-flow.ts +2 -1
  104. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-De7Lc2JC.js} +1 -1
  105. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-RGy7VEA_.js} +1 -1
  106. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-_rgpdVzo.js} +3 -3
  107. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-Ds6pcnh-.js} +1 -1
  108. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  109. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-4rHOePuE.js} +1 -1
  110. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-DISPgxLM.js} +1 -1
  111. package/src/console/ui-next/dist/index.html +1 -1
  112. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  113. package/src/drivers/cdc-outbox.ts +389 -0
  114. package/src/drivers/memory.ts +20 -0
  115. package/src/drivers/oauth-apple.ts +156 -0
  116. package/src/drivers/oauth-discord.ts +79 -0
  117. package/src/drivers/oauth-facebook.ts +80 -0
  118. package/src/drivers/oauth-figma.ts +116 -0
  119. package/src/drivers/oauth-github.ts +92 -0
  120. package/src/drivers/oauth-google.ts +142 -0
  121. package/src/drivers/oauth-microsoft.ts +174 -0
  122. package/src/drivers/oauth-oidc.ts +293 -0
  123. package/src/drivers/oauth-shared.ts +326 -0
  124. package/src/drivers/oauth-types.ts +159 -0
  125. package/src/drivers/oauth-x.ts +77 -0
  126. package/src/drivers/oauth2-common.ts +95 -0
  127. package/src/drivers/oauth2-token.ts +61 -0
  128. package/src/drivers/pg-rls-row-passes.ts +251 -0
  129. package/src/drivers/pg-rls.ts +2 -0
  130. package/src/drivers/postgres.ts +45 -2
  131. package/src/drivers/signal-postgres.ts +2 -1
  132. package/src/elements/channel/runtime.ts +29 -2
  133. package/src/elements/channel.test.ts +52 -0
  134. package/src/elements/gate/boot.ts +29 -2
  135. package/src/elements/store/emit-drizzle.ts +147 -14
  136. package/src/elements/store/field-ddl.test.ts +118 -0
  137. package/src/elements/store/field-types.test.ts +455 -0
  138. package/src/elements/store/list-query.golden.json +777 -0
  139. package/src/elements/store/list-query.parity.test.ts +396 -0
  140. package/src/elements/store/list-query.ts +792 -0
  141. package/src/elements/store/live-default.test.ts +136 -0
  142. package/src/elements/store/live-http.test.ts +160 -0
  143. package/src/elements/store/live-isolation.test.ts +291 -0
  144. package/src/elements/store/live-query-runtime.test.ts +323 -0
  145. package/src/elements/store/live-query-runtime.ts +403 -0
  146. package/src/elements/store/live-query-server.test.ts +377 -0
  147. package/src/elements/store/live-query-server.ts +102 -0
  148. package/src/elements/store/live-query.ts +97 -0
  149. package/src/elements/store/resource.ts +189 -680
  150. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  151. package/src/elements/store/schema-decl.ts +539 -41
  152. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  153. package/src/elements/store/sql-session.ts +297 -35
  154. package/src/elements/store/table.ts +102 -21
  155. package/src/elements/store.test.ts +3 -1
  156. package/src/elements/store.ts +12 -1
  157. package/src/elements/vault/chaos-child.ts +74 -1
  158. package/src/elements/vault/chaos.test.ts +4 -2
  159. package/src/elements/vault/storage.ts +4 -2
  160. package/src/index.ts +5 -2
  161. package/src/kernel/app-auth.ts +1 -0
  162. package/src/kernel/app.ts +116 -2
  163. package/src/kernel/auth-sharing.test.ts +196 -0
  164. package/src/kernel/boot.test.ts +3 -3
  165. package/src/kernel/errors.ts +8 -0
  166. package/src/kernel/fx.test.ts +1 -0
  167. package/src/kernel/fx.ts +14 -2
  168. package/src/kernel/horizontal-child.ts +2 -1
  169. package/src/kernel/http-resource.ts +33 -7
  170. package/src/kernel/identity-host-persist.test.ts +119 -0
  171. package/src/kernel/instance-id.ts +4 -2
  172. package/src/kernel/journal.ts +2 -1
  173. package/src/kernel/mcp-tool.test.ts +95 -0
  174. package/src/kernel/on.ts +9 -0
  175. package/src/kernel/realtime-bind.ts +326 -0
  176. package/src/kernel/resource-live.ts +117 -0
  177. package/src/kernel/triggers.ts +86 -4
  178. package/src/manifest/diff.ts +37 -0
  179. package/src/manifest/types.ts +64 -2
  180. package/src/okid.bench.test.ts +64 -0
  181. package/src/okid.test.ts +338 -0
  182. package/src/okid.ts +245 -0
  183. package/src/plugins/anonymous.ts +19 -1
  184. package/src/plugins/auth/shared.ts +15 -0
  185. package/src/plugins/index.ts +2 -0
  186. package/src/plugins/magic-link.ts +10 -8
  187. package/src/plugins/mcp-oauth.ts +208 -0
  188. package/src/plugins/oauth/flow-store.ts +117 -0
  189. package/src/plugins/oauth/link.ts +69 -0
  190. package/src/plugins/oauth/shared.ts +108 -0
  191. package/src/plugins/oauth/token-vault.ts +100 -0
  192. package/src/plugins/oauth.security.test.ts +535 -0
  193. package/src/plugins/oauth.ts +532 -0
  194. package/src/plugins/otp.ts +48 -6
  195. package/src/plugins/passkey.ts +20 -1
  196. package/src/plugins/two-factor.ts +11 -0
  197. package/src/plugins/username.ts +40 -7
  198. package/src/release/build-lib.ts +7 -1
  199. package/src/release/measure.ts +1 -0
  200. package/src/release/official-plugins.ts +4 -1
  201. package/src/runs/collect.ts +2 -1
  202. package/src/runs/drivers/files.ts +2 -1
  203. package/src/test/create-test-app.ts +114 -5
  204. package/src/test/export-bundle.test.ts +33 -0
  205. package/src/test/live-signals.test.ts +83 -0
  206. package/src/test/tenant-isolation.test.ts +175 -0
  207. package/src/testing.ts +26 -0
  208. package/src/upgrade/codemods.ts +1 -1
  209. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -0,0 +1,530 @@
1
+ /**
2
+ * `useLiveQuery` — live list state + optimistic mutate over a resource.
3
+ *
4
+ * Grounded in existing client contracts: the initial load calls the list
5
+ * Flow ({@link ClientCall} envelope), updates ride the synthesized SSE route
6
+ * (`GET <path>/live`, classified per-subscriber RLS events), and `mutate`
7
+ * wraps any {@link ClientCall} with snapshot → optimistic patch →
8
+ * rollback-on-error physics. The server stays authoritative; the client only
9
+ * projects CDC verdicts.
10
+ *
11
+ * Ordering protocol (snapshot/SSE race): the stream is opened BEFORE the
12
+ * list request, and its events buffer until the snapshot lands. Buffered
13
+ * upserts merge into the snapshot; deletes/revokes are tombstoned so rows
14
+ * removed mid-load never reappear from the snapshot. After replay every
15
+ * later event applies directly.
16
+ *
17
+ * Reconnect heals by full list refetch — classified events are
18
+ * per-connection, so there is no tape cursor to resume from.
19
+ *
20
+ * @module
21
+ */
22
+
23
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
24
+ import { transportOf } from "../client/create.ts";
25
+ import type { ClientCall, ClientResult } from "../client/types.ts";
26
+ import { MUTATION_ID_HEADER } from "../kernel/realtime-bind.ts";
27
+ import {
28
+ applyOptimisticPatch,
29
+ clearOptimisticPatch,
30
+ isReplayedEvent,
31
+ isStaleUpsert,
32
+ reduceLiveQueryRows,
33
+ type LiveQueryError,
34
+ type LiveQueryEvent,
35
+ } from "../client/use-live-query.ts";
36
+ import { subscribeLiveResource, type ResourceStreamOptions } from "./live-resource.ts";
37
+
38
+ /** Resource live route descriptor — the `$routes` stamp on `_live_<table>`. */
39
+ export interface LiveRouteContract {
40
+ readonly method: string;
41
+ readonly path: string;
42
+ }
43
+
44
+ /**
45
+ * Options for {@link useLiveQuery}.
46
+ *
47
+ * @typeParam Row - Row shape
48
+ */
49
+ export interface UseLiveQueryOptions<Row> {
50
+ /** Primary-key extractor. Default `row.id`. */
51
+ readonly idOf?: (row: Row) => string;
52
+ /** Version extractor for stale-guarding server upserts against held rows. */
53
+ readonly versionOf?: (row: Row) => number | string | null;
54
+ /**
55
+ * Reactive key guarding the subscription (identity/token changes).
56
+ * Changing it resets everything and refetches.
57
+ */
58
+ readonly refreshKey?: string | number;
59
+ /**
60
+ * Subscribe this hook to auth identity changes: when the client's
61
+ * `auth.refresh()` succeeds, reconnect via the full subscribe protocol
62
+ * (new snapshot + replay) so RLS-scoped rows reflect the new identity.
63
+ */
64
+ readonly onAuthRefresh?: (cb: () => void) => () => void;
65
+ /**
66
+ * When `false`, no SSE connection and no list fetch — the hook stays idle
67
+ * (`data` remains `null`). Re-subscribes when it flips back to `true`.
68
+ * Default `true`.
69
+ */
70
+ readonly enabled?: boolean;
71
+ }
72
+
73
+ /**
74
+ * Live query state + imperative mutation.
75
+ *
76
+ * @typeParam Row - Row shape
77
+ */
78
+ export interface UseLiveQueryState<Row> {
79
+ /** Merged rows (`null` until the first snapshot lands). */
80
+ readonly data: readonly Row[] | null;
81
+ /** Initial-load failure (`initial`) or last stream failure (`connection`). */
82
+ readonly error: LiveQueryError | null;
83
+ readonly isLoading: boolean;
84
+ readonly isConnected: boolean;
85
+ /**
86
+ * A reconnect attempt is in flight (stream dropped, backoff or re-open
87
+ * pending). Distinct from {@link isLoading} — data stays rendered while
88
+ * reconnecting; it is only `true` after the first successful load.
89
+ */
90
+ readonly isReconnecting: boolean;
91
+ /**
92
+ * Manual HTTP list refresh. Does not replace the subscribe protocol —
93
+ * reconnects always re-run the full snapshot + replay cycle.
94
+ */
95
+ refetch: () => Promise<void>;
96
+ /**
97
+ * Optimistic mutation wrapping an existing Flow call.
98
+ *
99
+ * Snapshot → patch via `optimistic(rows)` → call → rollback on error. On
100
+ * success the touched PKs (from `pkFromResult` / `pkOf`) stop carrying the
101
+ * optimistic patch, so real CDC upserts replace the local image cleanly.
102
+ * Omitting `optimistic` skips local patching entirely; server CDC updates
103
+ * the list anyway.
104
+ *
105
+ * @param flow - Any typed Flow call (e.g. `api.tasks.update`)
106
+ * @param input - Flow input (`{ id, ...patch }`)
107
+ * @param options - Optimistic projection + PK sources for override clears
108
+ */
109
+ mutate<X, Y, M extends Record<string, unknown>>(
110
+ flow: ClientCall<X, Y, M>,
111
+ input: X,
112
+ options?: {
113
+ readonly optimistic?: (rows: readonly Row[]) => readonly Row[];
114
+ readonly pkOf?: (input: X) => string;
115
+ readonly pkFromResult?: (data: Y) => string | undefined;
116
+ },
117
+ ): Promise<ClientResult<Y, M>>;
118
+ }
119
+
120
+ /**
121
+ * Subscribe a component to one resource's live query stack.
122
+ *
123
+ * @param args.api - Typed client from `createClient`
124
+ * @param args.listFlow - The resource's list call (`api.tasks.list`)
125
+ * @param args.query - Same input as the list Flow (filters)
126
+ * @param args.live - SSE route from `$routes`
127
+ * @param args.options - PK/version extractors, refresh key, `enabled`
128
+ */
129
+ export function useLiveQuery<Row extends Record<string, unknown>, I = void>(args: {
130
+ readonly api: object;
131
+ readonly listFlow: ClientCall<I, Row[], Record<string, never>>;
132
+ readonly query?: I;
133
+ readonly live: LiveRouteContract;
134
+ readonly options?: UseLiveQueryOptions<Row>;
135
+ }): UseLiveQueryState<Row> {
136
+ const { api, listFlow, query, live, options } = args;
137
+ const opts = options ?? {};
138
+ const enabled = opts.enabled ?? true;
139
+ const defaultIdOf = useCallback((row: Row) => String((row as Record<string, unknown>).id), []);
140
+ const idOf = opts.idOf ?? defaultIdOf;
141
+
142
+ const queryKey = JSON.stringify(query ?? null);
143
+ const refreshKey = opts.refreshKey;
144
+
145
+ const [data, setData] = useState<readonly Row[] | null>(null);
146
+ const [error, setError] = useState<LiveQueryError | null>(null);
147
+ const [isLoading, setLoading] = useState(true);
148
+ const [isConnected, setConnected] = useState(false);
149
+ const [isReconnecting, setReconnecting] = useState(false);
150
+
151
+ // Refs mirror state so streaming/mutate callbacks read latest without
152
+ // resubscribing.
153
+ const dataRef = useRef<readonly Row[] | null>(null);
154
+ dataRef.current = data;
155
+ const idOfRef = useRef(idOf);
156
+ idOfRef.current = idOf;
157
+ const overridesRef = useRef<ReadonlyMap<string, Partial<Row>>>(new Map());
158
+ const [overridesVersion, setOverridesVersion] = useState(0);
159
+ const bumpOverrides = useCallback(() => setOverridesVersion((v) => v + 1), []);
160
+ // Highest applied event seq (0 = none) — reconnect replays skip at/below.
161
+ const lastSeqRef = useRef(0);
162
+ // mutationId → settle status for in-flight/just-settled mutations. Upserts
163
+ // echoing a pending-or-failed id are the client's own late CDC echoes.
164
+ const pendingMutationsRef = useRef<Map<string, "ok" | "error">>(new Map());
165
+
166
+ // Identity refresh (Realtime plan): when the client's `auth.refresh()`
167
+ // succeeds, re-run the full subscribe protocol (new snapshot + replay) so
168
+ // RLS-scoped rows reflect the new identity. `onAuthRefresh` registers a
169
+ // listener; each fire bumps `refreshBump`, re-triggering the main effect.
170
+ const [authVersion, setAuthVersion] = useState(0);
171
+ const [refreshBump, setRefreshBump] = useState(0);
172
+ useEffect(() => {
173
+ if (!enabled) return;
174
+ return opts.onAuthRefresh?.(() => setAuthVersion((v) => v + 1));
175
+ }, [enabled, opts.onAuthRefresh]);
176
+ useEffect(() => {
177
+ if (authVersion > 0) setRefreshBump((v) => v + 1);
178
+ }, [authVersion]);
179
+
180
+ const merged = useMemo(
181
+ () => project(data, overridesRef.current),
182
+ [
183
+ data,
184
+ overridesVersion, // eslint-disable-line react-hooks/exhaustive-deps
185
+ ],
186
+ );
187
+
188
+ useEffect(() => {
189
+ if (!enabled) {
190
+ // Idle: no SSE, no list fetch; state resets for a clean re-subscribe.
191
+ setError(null);
192
+ setLoading(true);
193
+ setConnected(false);
194
+ setReconnecting(false);
195
+ dataRef.current = null;
196
+ setData(null);
197
+ overridesRef.current = new Map();
198
+ lastSeqRef.current = 0;
199
+ return;
200
+ }
201
+ const bag = transportOf(api);
202
+ if (!bag) throw new Error("useLiveQuery requires a client from createClient");
203
+ let stopped = false;
204
+ let loaded = false;
205
+ const buffered: LiveQueryEvent<Row>[] = [];
206
+ const tombstones = new Set<string>();
207
+
208
+ setError(null);
209
+ setLoading(true);
210
+ setConnected(false);
211
+ setReconnecting(false);
212
+ dataRef.current = null;
213
+ setData(null);
214
+ overridesRef.current = new Map();
215
+ lastSeqRef.current = 0;
216
+
217
+ const applyEvent = (event: LiveQueryEvent<Row>): void => {
218
+ if (!loaded || stopped) return;
219
+ if (isReplayedEvent(lastSeqRef.current, event)) return;
220
+ if (event.seq !== undefined && event.seq > lastSeqRef.current) {
221
+ lastSeqRef.current = event.seq;
222
+ }
223
+ if (event.kind === "upsert" && event.mutationId !== undefined) {
224
+ // Optimistic race rule (Realtime plan): an upsert echoing this
225
+ // client's own mutationId is skipped until the response settles —
226
+ // and dropped entirely when the write rolled back or failed.
227
+ const status = pendingMutationsRef.current.get(event.mutationId);
228
+ if (status === "ok" || status === "error") return;
229
+ }
230
+ if (event.kind !== "upsert") {
231
+ const cleared = clearOptimisticPatch(overridesRef.current, [event.id]);
232
+ if (cleared !== overridesRef.current) {
233
+ overridesRef.current = cleared;
234
+ bumpOverrides();
235
+ }
236
+ const prev = dataRef.current;
237
+ if (prev === null) return;
238
+ const next = reduceLiveQueryRows(prev, idOfRef.current, event);
239
+ if (next !== prev) {
240
+ dataRef.current = next;
241
+ setData(next);
242
+ }
243
+ return;
244
+ }
245
+ const prev = dataRef.current;
246
+ if (prev === null) return;
247
+ const pk = idOfRef.current(event.row);
248
+ const idx = prev.findIndex((r) => idOfRef.current(r) === pk);
249
+ const stale =
250
+ idx >= 0 &&
251
+ overridesRef.current.get(pk) === undefined &&
252
+ isStaleUpsert(prev[idx]!, event.row, opts.versionOf);
253
+ if (stale) return;
254
+ const next = reduceLiveQueryRows(prev, idOfRef.current, event, overridesRef.current);
255
+ if (next !== prev) {
256
+ dataRef.current = next;
257
+ setData(next);
258
+ }
259
+ // Round-trip complete — this row no longer needs its optimistic image.
260
+ if (overridesRef.current.get(pk) !== undefined) {
261
+ overridesRef.current = clearOptimisticPatch(overridesRef.current, [pk]);
262
+ bumpOverrides();
263
+ }
264
+ };
265
+
266
+ const streamOpts: ResourceStreamOptions = {
267
+ autoResubscribe: true,
268
+ ...(bag.opts?.auth ? { getToken: bag.opts.auth.getToken } : {}),
269
+ ...(bag.opts?.headers ? { headers: bag.opts.headers } : {}),
270
+ ...(bag.opts?.fetch ? { fetch: bag.opts.fetch } : {}),
271
+ };
272
+
273
+ const stopStream = subscribeLiveResource(
274
+ bag.base,
275
+ live,
276
+ query,
277
+ {
278
+ onOpen: () => {
279
+ if (stopped) return;
280
+ setConnected(true);
281
+ setReconnecting(false);
282
+ },
283
+ onError: () => {
284
+ if (stopped) return;
285
+ setConnected(false);
286
+ if (loaded) setReconnecting(true);
287
+ setError({ kind: "connection", error: new Error("live connection lost") });
288
+ },
289
+ onEvent: (rawEvent) => {
290
+ const event = rawEvent as LiveQueryEvent<Row>;
291
+ if (loaded) {
292
+ applyEvent(event);
293
+ return;
294
+ }
295
+ buffered.push(event);
296
+ if (event.kind !== "upsert") tombstones.add(event.id);
297
+ },
298
+ },
299
+ streamOpts,
300
+ );
301
+
302
+ // Authoritative initial read — starts after the stream opens.
303
+ const loadOnce = async (): Promise<void> => {
304
+ try {
305
+ const result =
306
+ queryKey === "null"
307
+ ? await (listFlow as unknown as () => Promise<ClientListResult<Row>>)()
308
+ : await (listFlow as unknown as (i: I) => Promise<ClientListResult<Row>>)(query as I);
309
+ if (stopped) return;
310
+ if (result.error !== null) {
311
+ setError({ kind: "initial", error: result.error });
312
+ setLoading(false);
313
+ return;
314
+ }
315
+ const fetched = ((result.data ?? []) as readonly Row[]).filter(
316
+ (r) => !tombstones.has(idOf(r)),
317
+ );
318
+ loaded = true;
319
+ let rows: readonly Row[] = [...fetched];
320
+ for (const ev of buffered) {
321
+ if (ev.kind === "upsert") rows = reduceLiveQueryRows(rows, idOf, ev);
322
+ }
323
+ buffered.length = 0;
324
+ dataRef.current = rows;
325
+ setData(rows);
326
+ setLoading(false);
327
+ setError(null);
328
+ } catch (err) {
329
+ if (!stopped) {
330
+ setError({ kind: "initial", error: err });
331
+ setLoading(false);
332
+ }
333
+ }
334
+ };
335
+ void loadOnce();
336
+ refetchRef.current = loadOnce;
337
+
338
+ return () => {
339
+ stopped = true;
340
+ stopStream();
341
+ refetchRef.current = undefined;
342
+ };
343
+ }, [enabled, live.method, live.path, queryKey, refreshKey, refreshBump, bumpOverrides]);
344
+
345
+ // Manual refetch — stable identity; calls the latest list loader. Does not
346
+ // replace the subscribe protocol; reconnects re-run the full cycle.
347
+ const refetchRef = useRef<(() => Promise<void>) | undefined>(undefined);
348
+ const refetch = useCallback(async (): Promise<void> => {
349
+ await refetchRef.current?.();
350
+ }, []);
351
+
352
+ const mutate = useCallback(
353
+ async <X, Y, M extends Record<string, unknown>>(
354
+ flow: ClientCall<X, Y, M>,
355
+ input: X,
356
+ mopts?: {
357
+ readonly optimistic?: (rows: readonly Row[]) => readonly Row[];
358
+ readonly pkOf?: (input: X) => string;
359
+ readonly pkFromResult?: (data: Y) => string | undefined;
360
+ },
361
+ ): Promise<ClientResult<Y, M>> => {
362
+ const projectOptimistic = mopts?.optimistic;
363
+ const snapshot = dataRef.current;
364
+ let patchedIds: readonly string[] = [];
365
+ if (snapshot !== null && projectOptimistic !== undefined) {
366
+ const projected = projectOptimistic(snapshot);
367
+ patchedIds = collectChangedIds(snapshot, projected, idOfRef.current);
368
+ for (const id of patchedIds) {
369
+ const before = snapshot.find((r) => idOfRef.current(r) === id);
370
+ const after = projected.find((r) => idOfRef.current(r) === id);
371
+ if (!before || !after) continue;
372
+ overridesRef.current = applyOptimisticPatch(
373
+ overridesRef.current,
374
+ id,
375
+ diffRows(before, after),
376
+ );
377
+ }
378
+ if (patchedIds.length > 0) {
379
+ dataRef.current = projected;
380
+ setData(projected);
381
+ bumpOverrides();
382
+ }
383
+ }
384
+ // Required mutationId (Realtime correctness contract): a client UUID
385
+ // rides onto this call so CDC upserts echo it back and rolled-back
386
+ // writes can drop their own late events.
387
+ const mutationId = newMutationId();
388
+ const bag = transportOf(api);
389
+ let result: ClientResult<Y, M>;
390
+ try {
391
+ const send = (): Promise<ClientResult<Y, M>> =>
392
+ input === undefined
393
+ ? (flow as unknown as () => Promise<ClientResult<Y, M>>)()
394
+ : (flow as unknown as (i: X) => Promise<ClientResult<Y, M>>)(input);
395
+ result =
396
+ bag !== undefined
397
+ ? await bag.perCallHeaders.run({ [MUTATION_ID_HEADER]: mutationId }, send)
398
+ : await send();
399
+ } catch (err) {
400
+ pendingMutationsRef.current.set(mutationId, "error");
401
+ rollback(patchedIds, snapshot, dataRef, setData, overridesRef, bumpOverrides);
402
+ throw err;
403
+ }
404
+ if (result.error !== null) {
405
+ pendingMutationsRef.current.set(mutationId, "error");
406
+ rollback(patchedIds, snapshot, dataRef, setData, overridesRef, bumpOverrides);
407
+ return result;
408
+ }
409
+ // Success — events echoing this mutationId are reconciles, not foreign
410
+ // writes; drop them for a grace window instead of double-applying.
411
+ pendingMutationsRef.current.set(mutationId, "ok");
412
+ setTimeout(() => {
413
+ pendingMutationsRef.current.delete(mutationId);
414
+ }, PENDING_MUTATION_TTL_MS);
415
+ // Clear overrides where a PK round-trips so real CDC upserts replace
416
+ // the local image without re-projecting patches.
417
+ if (result.data !== null) {
418
+ const clearTargets = confirmClearTargets(result.data, input, mopts);
419
+ if (clearTargets.length > 0) {
420
+ overridesRef.current = clearOptimisticPatch(overridesRef.current, clearTargets);
421
+ bumpOverrides();
422
+ }
423
+ }
424
+ return result;
425
+ },
426
+ [bumpOverrides],
427
+ );
428
+
429
+ return { data: merged, error, isLoading, isConnected, isReconnecting, refetch, mutate };
430
+ }
431
+
432
+ type ClientListResult<Row> = ClientResult<Row[], Record<string, never>>;
433
+
434
+ function rollback<Row>(
435
+ ids: readonly string[],
436
+ snapshot: readonly Row[] | null,
437
+ dataRef: { current: readonly Row[] | null },
438
+ setData: (rows: readonly Row[] | null) => void,
439
+ overridesRef: { current: ReadonlyMap<string, Partial<Row>> },
440
+ bump: () => void,
441
+ ): void {
442
+ if (ids.length > 0 && overridesRef.current.size > 0) {
443
+ overridesRef.current = clearOptimisticPatch(overridesRef.current, ids);
444
+ }
445
+ if (snapshot !== null && dataRef.current !== snapshot) {
446
+ dataRef.current = snapshot;
447
+ setData(snapshot);
448
+ }
449
+ bump();
450
+ }
451
+
452
+ function confirmClearTargets<X, Y>(
453
+ resultData: Y,
454
+ input: X,
455
+ mopts:
456
+ | {
457
+ readonly pkOf?: (input: X) => string;
458
+ readonly pkFromResult?: (data: Y) => string | undefined;
459
+ }
460
+ | undefined,
461
+ ): string[] {
462
+ const targets: string[] = [];
463
+ const fromResult = mopts?.pkFromResult?.(resultData);
464
+ if (fromResult !== undefined) targets.push(fromResult);
465
+ const fromInput = safePk(mopts?.pkOf, input);
466
+ if (fromInput !== undefined && !targets.includes(fromInput)) targets.push(fromInput);
467
+ return targets;
468
+ }
469
+
470
+ function safePk(f: ((input: never) => string) | undefined, input: unknown): string | undefined {
471
+ if (f === undefined) return undefined;
472
+ try {
473
+ const v = f(input as never);
474
+ return v === "" ? undefined : v;
475
+ } catch {
476
+ return undefined;
477
+ }
478
+ }
479
+
480
+ function project<Row>(
481
+ rows: readonly Row[] | null,
482
+ overrides: ReadonlyMap<string, Partial<Row>>,
483
+ ): readonly Row[] | null {
484
+ if (rows === null || overrides.size === 0) return rows;
485
+ return rows.map((r) => {
486
+ const patch = overrides.get(String((r as Record<string, unknown>).id));
487
+ return patch !== undefined ? ({ ...r, ...patch } as Row) : r;
488
+ });
489
+ }
490
+
491
+ function collectChangedIds<Row>(
492
+ before: readonly Row[],
493
+ after: readonly Row[],
494
+ idOf: (row: Row) => string,
495
+ ): string[] {
496
+ const beforeById = new Map(before.map((r) => [idOf(r), r]));
497
+ const ids: string[] = [];
498
+ for (const r of after) {
499
+ const id = idOf(r);
500
+ const prev = beforeById.get(id);
501
+ if (prev === undefined || JSON.stringify(prev) !== JSON.stringify(r)) ids.push(id);
502
+ }
503
+ return ids;
504
+ }
505
+
506
+ function diffRows<Row>(before: Row, after: Row): Partial<Row> {
507
+ const out: Record<string, unknown> = {};
508
+ const b = before as Record<string, unknown>;
509
+ const a = after as Record<string, unknown>;
510
+ for (const k of Object.keys(a)) {
511
+ if (!Object.is(b[k], a[k])) out[k] = a[k];
512
+ }
513
+ return out as Partial<Row>;
514
+ }
515
+
516
+ /** Grace window a settled mutationId stays in the dedupe set (ms). */
517
+ const PENDING_MUTATION_TTL_MS = 10_000;
518
+
519
+ /**
520
+ * Client-generated UUID for the `X-Oke-Mutation-Id` header. Prefers
521
+ * `crypto.randomUUID`; falls back to a timestamp+counter composite where
522
+ * `crypto` is unavailable (old test environments).
523
+ */
524
+ function newMutationId(): string {
525
+ if (globalThis.crypto?.randomUUID !== undefined) {
526
+ return globalThis.crypto.randomUUID();
527
+ }
528
+ return `mut-${Date.now()}-${Math.random().toString(36).slice(2)}-${mutationCounter++}`;
529
+ }
530
+ let mutationCounter = 0;