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.
Files changed (197) hide show
  1. package/README.md +9 -5
  2. package/manifest.v1.schema.json +61 -2
  3. package/package.json +18 -4
  4. package/site/content/docs/elements/clock.mdx +1 -1
  5. package/site/content/docs/elements/flow.mdx +25 -1
  6. package/site/content/docs/elements/store.mdx +287 -341
  7. package/site/content/docs/elements/vault.mdx +5 -5
  8. package/site/content/docs/get-started/installation.mdx +1 -2
  9. package/site/content/docs/get-started/introduction.mdx +54 -110
  10. package/site/content/docs/get-started/meta.json +9 -1
  11. package/site/content/docs/get-started/testing.mdx +328 -0
  12. package/site/content/docs/get-started/why.mdx +94 -70
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/plugins/apple.mdx +151 -0
  15. package/site/content/docs/plugins/discord.mdx +139 -0
  16. package/site/content/docs/plugins/facebook.mdx +134 -0
  17. package/site/content/docs/plugins/figma.mdx +138 -0
  18. package/site/content/docs/plugins/github.mdx +138 -0
  19. package/site/content/docs/plugins/google.mdx +153 -0
  20. package/site/content/docs/plugins/index.mdx +47 -1
  21. package/site/content/docs/plugins/meta.json +10 -0
  22. package/site/content/docs/plugins/microsoft.mdx +151 -0
  23. package/site/content/docs/plugins/oauth.mdx +188 -0
  24. package/site/content/docs/plugins/x.mdx +125 -0
  25. package/site/content/docs/reference/cli.md +3 -2
  26. package/site/content/docs/reference/client.mdx +58 -1
  27. package/site/content/docs/reference/configuration.mdx +2 -4
  28. package/site/content/docs/reference/fx.mdx +3 -1
  29. package/site/content/docs/reference/index.mdx +0 -5
  30. package/site/content/docs/reference/meta.json +2 -2
  31. package/site/content/docs/reference/okid.mdx +137 -0
  32. package/src/auth/bindings.ts +1 -1
  33. package/src/auth/config.ts +9 -0
  34. package/src/auth/identity-sql.ts +314 -0
  35. package/src/auth/identity.ts +140 -2
  36. package/src/auth/index.ts +17 -1
  37. package/src/auth/method-context.ts +3 -0
  38. package/src/auth/oauth-as/cimd.ts +132 -0
  39. package/src/auth/oauth-as/crypto.test.ts +101 -0
  40. package/src/auth/oauth-as/crypto.ts +393 -0
  41. package/src/auth/oauth-as/errors.ts +68 -0
  42. package/src/auth/oauth-as/http.test.ts +419 -0
  43. package/src/auth/oauth-as/http.ts +842 -0
  44. package/src/auth/oauth-as/stores.ts +61 -0
  45. package/src/auth/oauth-as/tables.ts +142 -0
  46. package/src/auth/tables.ts +0 -11
  47. package/src/bench/README.md +83 -0
  48. package/src/bench/REPORT.md +176 -0
  49. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  50. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  51. package/src/bench/g03-signal-once.bench.ts +157 -0
  52. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  53. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  54. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  55. package/src/bench/g05-sustained-full.bench.ts +265 -0
  56. package/src/bench/g06-mixed-load.bench.ts +260 -0
  57. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  58. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  59. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  60. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  61. package/src/bench/g08-store-sql.bench.ts +178 -0
  62. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  63. package/src/bench/g10-observability-contention.bench.ts +246 -0
  64. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  65. package/src/bench/g13-elements.bench.ts +427 -0
  66. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  67. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  68. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  69. package/src/bench/lib/event-loop-lag.ts +26 -0
  70. package/src/bench/lib/infra.ts +60 -0
  71. package/src/bench/lib/report.ts +52 -0
  72. package/src/bench/lib/rss-sampler.ts +61 -0
  73. package/src/bench/lib/signal-pg.ts +88 -0
  74. package/src/bench/load-app.ts +337 -0
  75. package/src/bench/load-child.ts +108 -0
  76. package/src/bench/smoke.bench.ts +43 -0
  77. package/src/cli/competitor-mention-removal.test.ts +28 -0
  78. package/src/cli/doctor-fd.ts +117 -0
  79. package/src/cli/doctor.test.ts +192 -0
  80. package/src/cli/doctor.ts +129 -1
  81. package/src/client/create.ts +95 -1
  82. package/src/client/index.ts +9 -2
  83. package/src/client/transport.ts +11 -4
  84. package/src/client/use-live-query.ts +154 -0
  85. package/src/client-react/index.ts +15 -1
  86. package/src/client-react/live-resource.ts +246 -0
  87. package/src/client-react/use-live-query.test.ts +475 -0
  88. package/src/client-react/use-live-query.ts +530 -0
  89. package/src/compiler/extract.test.ts +518 -0
  90. package/src/compiler/extract.ts +386 -19
  91. package/src/console/server/invoke-user-flow.ts +2 -1
  92. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
  95. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
  96. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  97. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
  98. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
  99. package/src/console/ui-next/dist/index.html +1 -1
  100. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  101. package/src/drivers/cdc-outbox.ts +389 -0
  102. package/src/drivers/memory.ts +20 -0
  103. package/src/drivers/oauth-apple.ts +156 -0
  104. package/src/drivers/oauth-discord.ts +79 -0
  105. package/src/drivers/oauth-facebook.ts +80 -0
  106. package/src/drivers/oauth-figma.ts +116 -0
  107. package/src/drivers/oauth-github.ts +92 -0
  108. package/src/drivers/oauth-google.ts +142 -0
  109. package/src/drivers/oauth-microsoft.ts +174 -0
  110. package/src/drivers/oauth-oidc.ts +293 -0
  111. package/src/drivers/oauth-shared.ts +326 -0
  112. package/src/drivers/oauth-types.ts +159 -0
  113. package/src/drivers/oauth-x.ts +77 -0
  114. package/src/drivers/oauth2-common.ts +95 -0
  115. package/src/drivers/oauth2-token.ts +61 -0
  116. package/src/drivers/pg-rls-row-passes.ts +251 -0
  117. package/src/drivers/pg-rls.ts +2 -0
  118. package/src/drivers/postgres.ts +45 -2
  119. package/src/drivers/signal-postgres.ts +2 -1
  120. package/src/elements/channel/runtime.ts +29 -2
  121. package/src/elements/channel.test.ts +52 -0
  122. package/src/elements/gate/boot.ts +29 -2
  123. package/src/elements/store/emit-drizzle.ts +147 -14
  124. package/src/elements/store/field-ddl.test.ts +118 -0
  125. package/src/elements/store/field-types.test.ts +455 -0
  126. package/src/elements/store/list-query.golden.json +777 -0
  127. package/src/elements/store/list-query.parity.test.ts +396 -0
  128. package/src/elements/store/list-query.ts +792 -0
  129. package/src/elements/store/live-default.test.ts +136 -0
  130. package/src/elements/store/live-http.test.ts +160 -0
  131. package/src/elements/store/live-isolation.test.ts +291 -0
  132. package/src/elements/store/live-query-runtime.test.ts +323 -0
  133. package/src/elements/store/live-query-runtime.ts +403 -0
  134. package/src/elements/store/live-query-server.test.ts +377 -0
  135. package/src/elements/store/live-query-server.ts +102 -0
  136. package/src/elements/store/live-query.ts +97 -0
  137. package/src/elements/store/resource.ts +189 -680
  138. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  139. package/src/elements/store/schema-decl.ts +539 -41
  140. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  141. package/src/elements/store/sql-session.ts +297 -35
  142. package/src/elements/store/table.ts +102 -21
  143. package/src/elements/store.test.ts +3 -1
  144. package/src/elements/store.ts +12 -1
  145. package/src/elements/vault/chaos-child.ts +74 -1
  146. package/src/elements/vault/chaos.test.ts +4 -2
  147. package/src/elements/vault/storage.ts +4 -2
  148. package/src/index.ts +4 -1
  149. package/src/kernel/app-auth.ts +1 -0
  150. package/src/kernel/app.ts +116 -2
  151. package/src/kernel/auth-sharing.test.ts +196 -0
  152. package/src/kernel/boot.test.ts +3 -3
  153. package/src/kernel/errors.ts +8 -0
  154. package/src/kernel/fx.test.ts +1 -0
  155. package/src/kernel/fx.ts +14 -2
  156. package/src/kernel/horizontal-child.ts +2 -1
  157. package/src/kernel/http-resource.ts +33 -7
  158. package/src/kernel/identity-host-persist.test.ts +119 -0
  159. package/src/kernel/instance-id.ts +4 -2
  160. package/src/kernel/journal.ts +2 -1
  161. package/src/kernel/mcp-tool.test.ts +95 -0
  162. package/src/kernel/on.ts +9 -0
  163. package/src/kernel/realtime-bind.ts +326 -0
  164. package/src/kernel/resource-live.ts +117 -0
  165. package/src/kernel/triggers.ts +86 -4
  166. package/src/manifest/diff.ts +37 -0
  167. package/src/manifest/types.ts +64 -2
  168. package/src/okid.bench.test.ts +64 -0
  169. package/src/okid.test.ts +338 -0
  170. package/src/okid.ts +245 -0
  171. package/src/plugins/anonymous.ts +19 -1
  172. package/src/plugins/auth/shared.ts +15 -0
  173. package/src/plugins/index.ts +2 -0
  174. package/src/plugins/magic-link.ts +10 -8
  175. package/src/plugins/mcp-oauth.ts +208 -0
  176. package/src/plugins/oauth/flow-store.ts +117 -0
  177. package/src/plugins/oauth/link.ts +69 -0
  178. package/src/plugins/oauth/shared.ts +108 -0
  179. package/src/plugins/oauth/token-vault.ts +100 -0
  180. package/src/plugins/oauth.security.test.ts +535 -0
  181. package/src/plugins/oauth.ts +532 -0
  182. package/src/plugins/otp.ts +48 -6
  183. package/src/plugins/passkey.ts +20 -1
  184. package/src/plugins/two-factor.ts +11 -0
  185. package/src/plugins/username.ts +40 -7
  186. package/src/release/build-lib.ts +7 -1
  187. package/src/release/measure.ts +1 -0
  188. package/src/release/official-plugins.ts +4 -1
  189. package/src/runs/collect.ts +2 -1
  190. package/src/runs/drivers/files.ts +2 -1
  191. package/src/test/create-test-app.ts +114 -5
  192. package/src/test/export-bundle.test.ts +33 -0
  193. package/src/test/live-signals.test.ts +83 -0
  194. package/src/test/tenant-isolation.test.ts +175 -0
  195. package/src/testing.ts +26 -0
  196. package/src/upgrade/codemods.ts +1 -1
  197. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Live query client core — reducer + snapshot/SSE ordering primitives.
3
+ *
4
+ * Framework-agnostic so React (`useLiveQuery` in `client-react`) and other
5
+ * renderers share one implementation. The wire taxonomy mirrors the server's
6
+ * `LiveQueryEvent` exactly: classified per-subscriber RLS verdicts pushed
7
+ * directly per subscriber (never a shared tape).
8
+ *
9
+ * @module
10
+ */
11
+
12
+ /** Wire event kinds served by a resource live route (`GET <path>/live`). */
13
+ export type LiveQueryEvent<Row = Record<string, unknown>> =
14
+ | {
15
+ readonly kind: "upsert";
16
+ readonly row: Row;
17
+ readonly seq?: number;
18
+ /** Echoed `X-Oke-Mutation-Id` — identifies the write that caused it. */
19
+ readonly mutationId?: string;
20
+ }
21
+ | {
22
+ readonly kind: "revoked";
23
+ readonly id: string;
24
+ readonly reason: "rls" | "query";
25
+ readonly seq?: number;
26
+ }
27
+ | { readonly kind: "delete"; readonly id: string; readonly seq?: number };
28
+
29
+ /** Error codes surfaced on `error` in hook state. */
30
+ export type LiveQueryError =
31
+ | { readonly kind: "initial"; readonly error: unknown }
32
+ | { readonly kind: "connection"; readonly error: unknown };
33
+
34
+ /** Field names probed for LWW guards when no explicit `versionOf` is given. */
35
+ const VERSION_FIELD_CANDIDATES = ["updatedAt", "updated_at", "version"] as const;
36
+
37
+ /**
38
+ * Pure row-list reducer for the three event kinds.
39
+ *
40
+ * - `upsert` → replace-or-append by PK (optimistic overrides re-applied)
41
+ * - `revoked` / `delete` → remove by PK (silent staleness impossible)
42
+ *
43
+ * @param rows - Current row list
44
+ * @param idOf - Primary-key extractor
45
+ * @param event - Classified server event
46
+ * @param overrides - Optimistic patches keyed by PK
47
+ */
48
+ export function reduceLiveQueryRows<Row>(
49
+ rows: readonly Row[],
50
+ idOf: (row: Row) => string,
51
+ event: LiveQueryEvent<Row>,
52
+ overrides?: ReadonlyMap<string, Partial<Row>>,
53
+ ): Row[] {
54
+ if (event.kind === "upsert") {
55
+ const id = idOf(event.row);
56
+ const patch = overrides?.get(id);
57
+ const patched = patch !== undefined ? ({ ...event.row, ...patch } as Row) : event.row;
58
+ const idx = rows.findIndex((r) => idOf(r) === id);
59
+ if (idx === -1) return [...rows, patched];
60
+ const next = [...rows];
61
+ next[idx] = patched;
62
+ return next;
63
+ }
64
+ return rows.filter((r) => idOf(r) !== event.id);
65
+ }
66
+
67
+ /**
68
+ * Idempotence guard for replayed SSE events: drop anything at or below the
69
+ * highest sequence number already applied for this subscription (Realtime
70
+ * correctness contract — reconnect replay must not double-apply). Events
71
+ * without a `seq` always apply.
72
+ *
73
+ * @param lastAppliedSeq - Highest seq applied so far (`0` = none)
74
+ * @param event - Incoming classified event
75
+ * @returns True when the event must be ignored
76
+ */
77
+ export function isReplayedEvent(
78
+ lastAppliedSeq: number,
79
+ event: { readonly kind: string; readonly seq?: number },
80
+ ): boolean {
81
+ return event.seq !== undefined && event.seq <= lastAppliedSeq;
82
+ }
83
+
84
+ /**
85
+ * LWW guard between an existing row image and an incoming event row.
86
+ *
87
+ * True when `incoming` is provably older: both sides expose a comparable
88
+ * version field (`versionOf` override, else `updatedAt`/`updated_at`/
89
+ * `version`) and `existing >= incoming`. Uncomparable → never stale.
90
+ *
91
+ * @param existing - Row currently held client-side
92
+ * @param incoming - Row carried by the CDC upsert
93
+ * @param versionOf - Optional explicit version extractor
94
+ */
95
+ export function isStaleUpsert<Row>(
96
+ existing: Row,
97
+ incoming: Row,
98
+ versionOf?: ((row: Row) => number | string | null) | undefined,
99
+ ): boolean {
100
+ const extract =
101
+ versionOf ??
102
+ ((row: Row): number | string | null => {
103
+ const bag = row as Record<string, unknown>;
104
+ for (const f of VERSION_FIELD_CANDIDATES) {
105
+ const v = bag[f];
106
+ if (typeof v === "number" || typeof v === "string") return v;
107
+ }
108
+ return null;
109
+ });
110
+ const a = extract(existing);
111
+ const b = extract(incoming);
112
+ if (a === null || b === null) return false;
113
+ if (typeof a === "number" && typeof b === "number") return a >= b;
114
+ // ISO-ish strings compare lexicographically; mixed kinds fall back safe.
115
+ if (typeof a === "string" && typeof b === "string") return a >= b;
116
+ return false;
117
+ }
118
+
119
+ /**
120
+ * Reconcile one row into the optimistic override map.
121
+ *
122
+ * The map only carries the fields the caller patched, so a later server
123
+ * upsert re-applies them over the possibly-older wire image. Cleared when a
124
+ * PK round-trips (confirm from response or CDC) or rolls back on error.
125
+ *
126
+ * @param current - Existing override map (or `undefined`)
127
+ * @param id - Row PK
128
+ * @param patch - Fields to overlay
129
+ */
130
+ export function applyOptimisticPatch<Row>(
131
+ current: ReadonlyMap<string, Partial<Row>> | undefined,
132
+ id: string,
133
+ patch: Partial<Row>,
134
+ ): ReadonlyMap<string, Partial<Row>> {
135
+ const next = new Map(current ?? []);
136
+ next.set(id, { ...(next.get(id) ?? {}), ...patch });
137
+ return next;
138
+ }
139
+
140
+ /**
141
+ * Drop overrides for ids confirmed gone (delete/revoked, failed mutation).
142
+ *
143
+ * @param current - Override map
144
+ * @param removedIds - Ids to clear
145
+ */
146
+ export function clearOptimisticPatch<Row>(
147
+ current: ReadonlyMap<string, Partial<Row>> | undefined,
148
+ removedIds: readonly string[],
149
+ ): ReadonlyMap<string, Partial<Row>> {
150
+ if (!current || removedIds.length === 0) return current ?? new Map();
151
+ const next = new Map(current);
152
+ for (const id of removedIds) next.delete(id);
153
+ return next;
154
+ }
@@ -1,9 +1,23 @@
1
1
  /**
2
- * React helpers for okengine clients — `useSession` / `useLive` over {@link createClient}.
2
+ * React helpers for okengine clients — `useSession` / `useLive` /
3
+ * `useLiveQuery` over {@link createClient}.
3
4
  *
4
5
  * @module
5
6
  */
6
7
 
8
+ export {
9
+ subscribeLiveResource,
10
+ type ResourceStreamHandlers,
11
+ type ResourceStreamOptions,
12
+ type ResourceStreamStop,
13
+ } from "./live-resource.ts";
14
+ export type { LiveRouteContract } from "./use-live-query.ts";
15
+ export {
16
+ useLiveQuery,
17
+ type UseLiveQueryOptions,
18
+ type UseLiveQueryState,
19
+ } from "./use-live-query.ts";
20
+
7
21
  import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
8
22
  import type { MemorySession } from "../client/auth.ts";
9
23
  import type { ClientLive, LiveInput, LiveSignalHandle } from "../client/types.ts";
@@ -0,0 +1,246 @@
1
+ /**
2
+ * SSE subscription to a resource live route (`GET <path>/live`).
3
+ *
4
+ * Same fetch-SSE physics as `client/live.ts` (frame parsing, backoff,
5
+ * auth), typed for the classified row-event taxonomy. Classified events are
6
+ * per-connection — there is no shared tape, so gaps cannot resume by
7
+ * cursor; consumers heal with a list refetch instead.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import type { ClientFetch, ClientHeaders } from "../client/types.ts";
13
+ import type { LiveQueryEvent } from "../client/use-live-query.ts";
14
+
15
+ /** First reconnect wait after a drop (mirrors `LIVE_RESUBSCRIBE_INITIAL_MS`). */
16
+ const RESUBSCRIBE_INITIAL_MS = 500;
17
+
18
+ /** Reconnect cap (mirrors `LIVE_RESUBSCRIBE_MAX_MS`). */
19
+ const RESUBSCRIBE_MAX_MS = 30_000;
20
+
21
+ /** Frames accepted by {@link subscribeLiveResource}. */
22
+ export interface ResourceStreamHandlers<Row> {
23
+ readonly onEvent: (event: LiveQueryEvent<Row>) => void;
24
+ readonly onOpen?: () => void;
25
+ /** Stream error or close while `autoResubscribe` recovers (or terminally). */
26
+ readonly onError?: (error: unknown) => void;
27
+ }
28
+
29
+ /** Options for {@link subscribeLiveResource}. */
30
+ export interface ResourceStreamOptions {
31
+ /** Re-open after a drop with 500ms→30s backoff. Default false. */
32
+ readonly autoResubscribe?: boolean;
33
+ /** Headers or per-request getter (Bearer token etc.). */
34
+ readonly headers?: ClientHeaders | (() => ClientHeaders | Promise<ClientHeaders>);
35
+ /** Auth token getter (`opts.auth.getToken` passthrough). */
36
+ readonly getToken?: () => string | null | undefined | Promise<string | null | undefined>;
37
+ /** Override `globalThis.fetch`. */
38
+ readonly fetch?: ClientFetch;
39
+ /** Cross-cutting abort (component teardown uses the returned stop fn). */
40
+ readonly signal?: AbortSignal;
41
+ }
42
+
43
+ /** Stop consuming and close the stream. Idempotent. */
44
+ export type ResourceStreamStop = () => void;
45
+
46
+ /**
47
+ * Open an SSE subscription to one resource's live route.
48
+ *
49
+ * Query fields serialize onto the URL exactly like the list request
50
+ * (`restGet` grammar: `:param` substitution then query string), so the
51
+ * server re-derives the identical list window for classification.
52
+ *
53
+ * @param base - App origin
54
+ * @param route - `{ method, path }` from `$routes`
55
+ * @param query - List filters
56
+ * @param handlers - Event callbacks
57
+ * @param options - Backoff / headers / auth token / fetch
58
+ */
59
+ export function subscribeLiveResource<Row extends Record<string, unknown>>(
60
+ base: string,
61
+ route: { readonly method: string; readonly path: string },
62
+ query: unknown,
63
+ handlers: ResourceStreamHandlers<Row>,
64
+ options: ResourceStreamOptions = {},
65
+ ): ResourceStreamStop {
66
+ const ctrl = new AbortController();
67
+ const outerAbort = (): void => {
68
+ ctrl.abort();
69
+ };
70
+ options.signal?.addEventListener("abort", outerAbort, { once: true });
71
+ void pump(base, route, query, handlers, options, ctrl.signal);
72
+ return () => {
73
+ options.signal?.removeEventListener("abort", outerAbort);
74
+ ctrl.abort();
75
+ };
76
+ }
77
+
78
+ async function pump<Row>(
79
+ base: string,
80
+ route: { readonly method: string; readonly path: string },
81
+ query: unknown,
82
+ handlers: ResourceStreamHandlers<Row>,
83
+ options: ResourceStreamOptions,
84
+ signal: AbortSignal,
85
+ ): Promise<void> {
86
+ const auto = options.autoResubscribe === true;
87
+ let delayMs = RESUBSCRIBE_INITIAL_MS;
88
+ let first = true;
89
+ for (;;) {
90
+ if (signal.aborted) return;
91
+ if (!first && auto) {
92
+ await sleep(delayMs, signal);
93
+ if (signal.aborted) return;
94
+ delayMs = Math.min(delayMs * 2, RESUBSCRIBE_MAX_MS);
95
+ }
96
+ first = false;
97
+ try {
98
+ const res = await openSse(base, route, query, options, signal);
99
+ if (signal.aborted) return;
100
+ handlers.onOpen?.();
101
+ await consume(res, handlers, signal);
102
+ if (signal.aborted) return;
103
+ handlers.onError?.(new Error("live connection closed"));
104
+ if (!auto) return;
105
+ } catch (err) {
106
+ if (signal.aborted) return;
107
+ handlers.onError?.(err);
108
+ if (!auto) return;
109
+ }
110
+ }
111
+ }
112
+
113
+ async function openSse(
114
+ base: string,
115
+ route: { readonly method: string; readonly path: string },
116
+ query: unknown,
117
+ options: ResourceStreamOptions,
118
+ signal: AbortSignal,
119
+ ): Promise<Response> {
120
+ const url = streamUrl(base, route.path, query);
121
+ const headers = new Headers({ accept: "text/event-stream" });
122
+ const extra = typeof options.headers === "function" ? await options.headers() : options.headers;
123
+ if (Array.isArray(extra)) {
124
+ for (const [k, v] of extra) headers.set(k, v);
125
+ } else if (extra) {
126
+ for (const [k, v] of Object.entries(extra)) headers.set(k, v);
127
+ }
128
+ const token = options.getToken ? await options.getToken() : undefined;
129
+ if (token && !headers.has("authorization")) {
130
+ headers.set("authorization", `Bearer ${token}`);
131
+ }
132
+ const fetchFn: ClientFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
133
+ return fetchFn(url, { method: "GET", headers, signal });
134
+ }
135
+
136
+ function streamUrl(base: string, path: string, query: unknown): string {
137
+ const params = query !== null && typeof query === "object" ? query : {};
138
+ let out = path;
139
+ const qs: string[] = [];
140
+ for (const [k, v] of Object.entries(params)) {
141
+ const token = `:${k}`;
142
+ if (out.includes(token)) {
143
+ out = out.replaceAll(token, encodeURIComponent(String(v)));
144
+ } else if (v !== undefined) {
145
+ qs.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
146
+ }
147
+ }
148
+ return `${base}${out}${qs.length > 0 ? `?${qs.join("&")}` : ""}`;
149
+ }
150
+
151
+ async function consume<Row>(
152
+ res: Response,
153
+ handlers: ResourceStreamHandlers<Row>,
154
+ signal: AbortSignal,
155
+ ): Promise<void> {
156
+ if (!res.ok || !res.body) {
157
+ const text = await res.text().catch(() => "");
158
+ throw sseError(res.status, text);
159
+ }
160
+ const ct = res.headers.get("content-type") ?? "";
161
+ if (!ct.includes("text/event-stream")) {
162
+ throw sseError(res.status, "");
163
+ }
164
+ const reader = res.body.getReader();
165
+ const dec = new TextDecoder();
166
+ let buf = "";
167
+ try {
168
+ for (;;) {
169
+ if (signal.aborted) return;
170
+ const { done, value } = await reader.read();
171
+ if (done) break;
172
+ buf += dec.decode(value, { stream: true });
173
+ let sep = buf.indexOf("\n\n");
174
+ while (sep >= 0) {
175
+ const raw = buf.slice(0, sep);
176
+ buf = buf.slice(sep + 2);
177
+ dispatchFrame(raw, handlers, signal);
178
+ if (signal.aborted) return;
179
+ sep = buf.indexOf("\n\n");
180
+ }
181
+ }
182
+ } finally {
183
+ reader.releaseLock();
184
+ }
185
+ }
186
+
187
+ function dispatchFrame<Row>(
188
+ raw: string,
189
+ handlers: ResourceStreamHandlers<Row>,
190
+ signal: AbortSignal,
191
+ ): void {
192
+ const dataLines: string[] = [];
193
+ for (const line of raw.split("\n")) {
194
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
195
+ }
196
+ if (dataLines.length === 0 || signal.aborted) return;
197
+ const data = dataLines.join("\n");
198
+ if (data === "[DONE]") return;
199
+ try {
200
+ const parsed: unknown = JSON.parse(data);
201
+ if (!isLiveQueryEvent(parsed)) return;
202
+ handlers.onEvent(parsed as LiveQueryEvent<Row>);
203
+ } catch {
204
+ // Malformed frame — skip; next valid frame keeps state convergent.
205
+ }
206
+ }
207
+
208
+ function isLiveQueryEvent(value: unknown): boolean {
209
+ if (value === null || typeof value !== "object") return false;
210
+ const v = value as Record<string, unknown>;
211
+ if (typeof v.kind !== "string") return false;
212
+ if (v.kind === "upsert") return typeof v.row === "object" && v.row !== null;
213
+ return (v.kind === "revoked" || v.kind === "delete") && typeof v.id === "string";
214
+ }
215
+
216
+ function sseError(status: number, body: string): Error {
217
+ let message = `HTTP ${status}`;
218
+ if (body) {
219
+ try {
220
+ const json: unknown = JSON.parse(body);
221
+ if (json !== null && typeof json === "object" && "error" in json) {
222
+ const err = (json as { error?: { code?: string; data?: { message?: string } } }).error;
223
+ message = err?.data?.message ?? err?.code ?? message;
224
+ }
225
+ } catch {
226
+ message = body.slice(0, 200);
227
+ }
228
+ }
229
+ const e = new Error(message);
230
+ (e as Error & { status?: number }).status = status;
231
+ return e;
232
+ }
233
+
234
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
235
+ if (signal.aborted || ms <= 0) return Promise.resolve();
236
+ return new Promise((resolve) => {
237
+ function finish(): void {
238
+ clearTimeout(timer);
239
+ signal.removeEventListener("abort", finish);
240
+ resolve();
241
+ }
242
+ const timer = setTimeout(finish, ms);
243
+ signal.addEventListener("abort", finish, { once: true });
244
+ if (signal.aborted) finish();
245
+ });
246
+ }