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.
- package/AGENTS.md +5 -3
- package/README.md +14 -10
- package/manifest.v1.schema.json +61 -2
- package/package.json +19 -5
- package/site/content/docs/ai/index.mdx +1 -1
- package/site/content/docs/ai/mcp.mdx +10 -1
- package/site/content/docs/ai/skills.mdx +9 -9
- package/site/content/docs/elements/ai.mdx +1 -1
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/gate.mdx +3 -2
- package/site/content/docs/elements/store.mdx +289 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/basic-usage.mdx +3 -10
- package/site/content/docs/get-started/index.mdx +1 -1
- package/site/content/docs/get-started/installation.mdx +2 -3
- package/site/content/docs/get-started/introduction.mdx +58 -121
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/project-structure.mdx +4 -11
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +93 -71
- package/site/content/docs/index.mdx +44 -11
- package/site/content/docs/meta.json +8 -5
- 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/providers/index.mdx +2 -0
- package/site/content/docs/recipes/index.mdx +2 -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-De7Lc2JC.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-RGy7VEA_.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-_rgpdVzo.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-Ds6pcnh-.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-4rHOePuE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-DISPgxLM.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 +5 -2
- 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,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`
|
|
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
|
+
}
|