okengine 0.17.2 → 0.18.3
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/README.md +9 -5
- package/manifest.v1.schema.json +61 -2
- package/package.json +18 -4
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/store.mdx +287 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/installation.mdx +1 -2
- package/site/content/docs/get-started/introduction.mdx +54 -110
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +94 -70
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +4 -1
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- 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;
|