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,326 @@
1
+ /**
2
+ * Boot-time Realtime wire-up (Phase 2 of the realtime round).
3
+ *
4
+ * When the booted Store exposes a primary SQL connection on a
5
+ * `row_passes_policies`-capable driver (`postgres` / `pglite`):
6
+ * 1. Install the process-wide `sql-session` CDC sink → LiveQuery runtime
7
+ * ingest, outbox append (multi-host durability), and user-declared CDC
8
+ * flow dispatch.
9
+ * 2. Start a {@link CdcOutboxRunner} SKIP LOCKED poller draining durable
10
+ * events back into the same runtime.
11
+ * 3. Register each subscribed table's live signal on the Signal bus so
12
+ * existing `fx.live` SSE routes deliver classified events to clients.
13
+ *
14
+ * Torn down by {@link unbindRealtimeBridge} on app stop.
15
+ */
16
+
17
+ import { CdcOutbox, CdcOutboxRunner, type OutboxRow } from "../drivers/cdc-outbox.ts";
18
+ import { RLS_CONTEXT_DRIVERS, type RlsIdentity } from "../drivers/pg-rls.ts";
19
+ import type { SqlConnection } from "../drivers/types.ts";
20
+ import { liveQueryRuntimeFromConn } from "../elements/store/live-query-server.ts";
21
+ import { setSqlCdcSink, type SqlCdcSink } from "../elements/store/sql-session.ts";
22
+ import type { LiveQueryEvent, LiveSubscription } from "../elements/store/live-query-runtime.ts";
23
+ import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
24
+
25
+ /**
26
+ * Column-kind hint for one SQL column — drives {@link restoreImage} type
27
+ * restoration for outbox-round-tripped CDC images.
28
+ */
29
+ export type LiveColumnKind = "string" | "number" | "boolean";
30
+
31
+ /** Internal Signal name prefix carrying classified live-query events. */
32
+ const LIVE_SIGNAL_PREFIX = "oke/live/sql:";
33
+
34
+ /**
35
+ * Reserved live-signal name for one table. Exported for compiler synthesis —
36
+ * manifest effects reference the exact same string.
37
+ *
38
+ * @param table - Physical table name
39
+ */
40
+ export function liveSignalName(table: string): string {
41
+ return `${LIVE_SIGNAL_PREFIX}${table}`;
42
+ }
43
+
44
+ /** Mutation header echoed into `mutationId` on write-path events. */
45
+ export const MUTATION_ID_HEADER = "x-oke-mutation-id";
46
+
47
+ /** Per-stream event buffer cap (bounded memory per SSE session). */
48
+ const LIVE_STREAM_BUFFER_MAX = 512;
49
+
50
+ /** Active bridge state (singleton per process). */
51
+ let active: {
52
+ /** Primary connection the runtime / outbox classify and poll against. */
53
+ readonly primary: SqlConnection;
54
+ readonly runtime: ReturnType<typeof liveQueryRuntimeFromConn>;
55
+ readonly outbox: CdcOutbox;
56
+ readonly runner?: CdcOutboxRunner;
57
+ } | null = null;
58
+
59
+ /** Delivery subscription for one SSE client (returned to `fx.live` glue). */
60
+ export interface LiveSubscriptionHandle {
61
+ /** Push one classified event to this subscriber's stream. */
62
+ deliver(event: LiveQueryEvent): void;
63
+ /** Detach from fan-out (idempotent). */
64
+ unsubscribe(): void;
65
+ }
66
+
67
+ /**
68
+ * Wire the realtime stack for this process. Idempotent for the **same**
69
+ * primary connection (test graphs re-`oke()` freely). A different primary
70
+ * (e.g. a fresh PGLite instance on the next test) tears down the old bridge
71
+ * and rebinds — otherwise classification probes the previous DB and live
72
+ * SSE frames never fire.
73
+ *
74
+ * @param primary - Shared primary SQL connection (postgres/pglite only)
75
+ * @param dispatchCdc - App-level dispatcher for user-declared CDC flows
76
+ * @returns The process runtime, or `null` when the SQL driver cannot host it
77
+ */
78
+ export function bindRealtimeBridge(
79
+ primary: SqlConnection,
80
+ dispatchCdc: (
81
+ tableName: string,
82
+ payload: { readonly before: unknown; readonly after: unknown },
83
+ column?: string,
84
+ ) => Promise<unknown[]> | unknown[],
85
+ ): ReturnType<typeof liveQueryRuntimeFromConn> | null {
86
+ if (!RLS_CONTEXT_DRIVERS.has(primary.driverId)) return null;
87
+ if (active) {
88
+ if (active.primary === primary) return active.runtime;
89
+ unbindRealtimeBridge();
90
+ }
91
+
92
+ const runtime = liveQueryRuntimeFromConn(primary);
93
+ const outbox = new CdcOutbox(primary);
94
+ // First tick creates the table + indexes without blocking boot.
95
+ void outbox.ensure().catch(() => undefined);
96
+ const sink: SqlCdcSink = (event) => {
97
+ // In-process leg first — classification needs no outbox round-trip.
98
+ runtime.onCdc(event);
99
+ // Durable multi-host leg: other hosts drain via the poller. Local events
100
+ // are also appended so a restarted process replays un-delivered rows;
101
+ // the poller redelivery is idempotent at the client by PK+seq.
102
+ void outbox.append(event).catch(() => undefined);
103
+ void dispatchCdc(event.tableName, { before: event.before, after: event.after });
104
+ };
105
+ setSqlCdcSink(sink);
106
+
107
+ const runner = new CdcOutboxRunner(
108
+ outbox,
109
+ async (rows: readonly OutboxRow[]) => {
110
+ for (const row of rows) {
111
+ runtime.onCdc({
112
+ tableName: row.tableName,
113
+ op: row.op,
114
+ before: row.before,
115
+ after: row.after,
116
+ ...(Number.isFinite(row.seq) ? { seq: row.seq } : {}),
117
+ ...(row.mutationId !== undefined ? { mutationId: row.mutationId } : {}),
118
+ });
119
+ }
120
+ },
121
+ {},
122
+ );
123
+ runner.start();
124
+
125
+ active = { primary, runtime, outbox, runner };
126
+ return runtime;
127
+ }
128
+
129
+ /**
130
+ * Push→pull bridge: convert runtime `deliver` callbacks into an SSE async
131
+ * iterable. Bounded per-stream buffer — a slow client sheds its own events
132
+ * (silently stale until resync; the v1 documented trade-off) instead of
133
+ * pinning the fan-out worker pool.
134
+ */
135
+ export interface LiveEventStream {
136
+ /** Classified events as SSE frames (already JSON-shaped). */
137
+ readonly chunks: AsyncIterable<{ readonly data: LiveQueryEvent }>;
138
+ /** Stop fan-out and close the iterator (idempotent). */
139
+ close(): Promise<void>;
140
+ }
141
+
142
+ /** Options for {@link openLiveStream} — one SSE subscriber's window. */
143
+ export interface OpenLiveStreamOptions {
144
+ /** Physical table name. */
145
+ readonly table: string;
146
+ /** Subscriber stamp (gate/user/scopes/tenant). */
147
+ readonly identity: RlsIdentity;
148
+ /** Primary key column. */
149
+ readonly pkColumn: string;
150
+ /** List query window compiled to SQL (`?` placeholders), when any. */
151
+ readonly whereSql?: string;
152
+ readonly whereParams?: readonly unknown[];
153
+ /**
154
+ * Column-kind hints for JSONB image restoration on outbox replay
155
+ * (`jsKey → primitive kind`). Optional.
156
+ */
157
+ readonly tableColumns?: Readonly<Record<string, LiveColumnKind>>;
158
+ }
159
+ /**
160
+ * Subscribe this SSE session to classified live events for one table.
161
+ *
162
+ * The returned handle implements the {@link Fx} live-stream physics without
163
+ * touching a signal tape: per-subscriber classification makes shared-tape
164
+ * delivery impossible to do safely — each identity's verdict differs.
165
+ *
166
+ * Links the ambient structured-concurrency abort signal: when the request
167
+ * branch aborts (client disconnect), the subscription detaches itself —
168
+ * the same ALS physics `fx.live` SSE streams use.
169
+ *
170
+ * @param id - Stable stream id (used for registry dedupe + metrics)
171
+ * @param options - Table, stamp, query window, column hints
172
+ */
173
+ export function openLiveStream(
174
+ id: string,
175
+ options: OpenLiveStreamOptions,
176
+ runtimeOverride?: ReturnType<typeof liveQueryRuntimeFromConn>,
177
+ ): LiveEventStream {
178
+ const runtime = runtimeOverride ?? active?.runtime;
179
+ if (!runtime) throw new Error("live subscribe before realtime bridge bind");
180
+ const buffer: { readonly data: LiveQueryEvent }[] = [];
181
+ let woke = false;
182
+ let done = false;
183
+ let notify: (() => void) | undefined;
184
+ const wake = (): void => {
185
+ if (!woke || done) return;
186
+ woke = false;
187
+ const n = notify;
188
+ notify = undefined;
189
+ n?.();
190
+ };
191
+ const sub: LiveSubscription = {
192
+ ...options,
193
+ id,
194
+ ref: `sql:${options.table}`,
195
+ deliver(event) {
196
+ if (done) return;
197
+ // Per-stream cap: bounded memory per subscriber regardless of churn.
198
+ if (buffer.length >= LIVE_STREAM_BUFFER_MAX) {
199
+ // Shed from the front — the oldest missed event is re-fetchable via
200
+ // list refetch; newer state wins for eventual convergence.
201
+ buffer.shift();
202
+ }
203
+ buffer.push({ data: event });
204
+ wake();
205
+ },
206
+ };
207
+ const unsubscribe = runtime.subscribe(sub);
208
+ const chunks: AsyncIterable<{ readonly data: LiveQueryEvent }> = {
209
+ [Symbol.asyncIterator]() {
210
+ return {
211
+ async next(): Promise<IteratorResult<{ readonly data: LiveQueryEvent }>> {
212
+ for (;;) {
213
+ const frame = buffer.shift();
214
+ if (frame !== undefined) return { done: false as const, value: frame };
215
+ if (done) return { done: true as const, value: undefined };
216
+ await new Promise<void>((resolve) => {
217
+ notify = resolve;
218
+ woke = true;
219
+ });
220
+ }
221
+ },
222
+ async return(): Promise<IteratorResult<{ readonly data: LiveQueryEvent }>> {
223
+ await close();
224
+ return { done: true as const, value: undefined };
225
+ },
226
+ };
227
+ },
228
+ };
229
+ // Ambient-branch abort (client disconnect) detaches the subscription —
230
+ // same ALS physics `createLiveStream` uses for `fx.live` SSE.
231
+ const local = new AbortController();
232
+ const unlink = linkAbort(currentAbortSignal(), local);
233
+ const onAbort = (): void => {
234
+ void close();
235
+ };
236
+ local.signal.addEventListener("abort", onAbort, { once: true });
237
+
238
+ async function close(): Promise<void> {
239
+ if (done) return;
240
+ done = true;
241
+ unsubscribe();
242
+ local.signal.removeEventListener("abort", onAbort);
243
+ unlink();
244
+ wake();
245
+ }
246
+
247
+ return { chunks, close };
248
+ }
249
+
250
+ /**
251
+ * Register an SSE-side subscription without opening a stream. Kept for
252
+ * transport tests that drive `deliver` manually.
253
+ *
254
+ * @param table - Physical table name
255
+ * @param id - Stable stream/session id
256
+ * @param identity - Subscriber stamp
257
+ * @param sub - Query window + delivery sink
258
+ */
259
+ export function subscribeLive(
260
+ table: string,
261
+ id: string,
262
+ identity: RlsIdentity,
263
+ sub: {
264
+ readonly pkColumn: string;
265
+ readonly whereSql?: string;
266
+ readonly whereParams?: readonly unknown[];
267
+ deliver(event: LiveQueryEvent): void;
268
+ },
269
+ ): LiveSubscriptionHandle {
270
+ if (!active) throw new Error("live subscribe before realtime bridge bind");
271
+ const unsub = active.runtime.subscribe({ ...sub, id, ref: `sql:${table}`, table, identity });
272
+ return { deliver: sub.deliver, unsubscribe: unsub };
273
+ }
274
+
275
+ /** Tear down polling and the process-wide sink (app stop). */
276
+ export function unbindRealtimeBridge(): void {
277
+ if (!active) return;
278
+ active.runner?.stop();
279
+ setSqlCdcSink(null);
280
+ active = null;
281
+ }
282
+
283
+ /** Test/doctor visibility into the bound bridge. */
284
+ export function realtimeBridgeRuntime(): ReturnType<typeof liveQueryRuntimeFromConn> | undefined {
285
+ return active?.runtime;
286
+ }
287
+
288
+ /**
289
+ * Realtime observability snapshot (metrics / doctor / Console):
290
+ *
291
+ * - `subscribers` — active live subscriptions right now
292
+ * - `queueDepth` — pending fan-out jobs (architecture contract gauge)
293
+ * - `fanout` — LiveQuery runtime counters (events in/shed, checks run/failed)
294
+ * - `outbox` — pending rows + delivered-over-cap gauge, plus runner counters
295
+ * (`eventsDrained`, `pruneFailures`, …) when the poller leg is bound
296
+ *
297
+ * Returns `null` when no realtime bridge is bound (no Postgres-capable
298
+ * primary SQL driver, or app already stopped).
299
+ */
300
+ export async function realtimeMetrics(): Promise<{
301
+ subscribers: number;
302
+ queueDepth: number;
303
+ fanout: { eventsIn: number; eventsShed: number; checksRun: number; checkFailures: number };
304
+ outbox:
305
+ | {
306
+ pending: number;
307
+ dispatchedOverCap: number;
308
+ runner?: { polls: number; batches: number; eventsDrained: number; pruneFailures: number };
309
+ }
310
+ | { unavailable: "no_bridge" };
311
+ } | null> {
312
+ if (!active) return null;
313
+ const outboxStats = await active.outbox.stats().catch(() => null);
314
+ return {
315
+ subscribers: active.runtime.size,
316
+ queueDepth: active.runtime.queueDepth,
317
+ fanout: { ...active.runtime.metrics },
318
+ outbox:
319
+ outboxStats === null
320
+ ? { unavailable: "no_bridge" as const }
321
+ : {
322
+ ...outboxStats,
323
+ ...(active.runner !== undefined ? { runner: { ...active.runner.metrics } } : {}),
324
+ },
325
+ };
326
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Project-wide `store.live` default — deferred live-query mounts.
3
+ *
4
+ * `oke({ store: { live: true } })` flips the default posture for NEW
5
+ * `store.schema.table()` declarations to live-by-default. `store.resource`
6
+ * and `http.resource` both run at module evaluation, before `oke()` is
7
+ * constructed, so the flag is not yet known when they execute. This module
8
+ * records their pending live surfaces and mount slots; the `oke()`
9
+ * constructor drains them and adopts the synthesized `GET <path>/live`
10
+ * bindings only when the flag is on. When the flag is off, the pending
11
+ * entries are discarded — 100% of today's explicit-only behavior.
12
+ *
13
+ * Declaration ergonomics only: draining mounts the same
14
+ * `store.resource({ live: true })` live surface. It never changes the
15
+ * underlying CDC / RLS-per-event cost model.
16
+ */
17
+
18
+ import { createHttpTrigger, type GateRef } from "./triggers.ts";
19
+ import type { AnyFlowDef } from "./flow.ts";
20
+ import type { Binding } from "./on.ts";
21
+
22
+ /** Marker placed on resource ops bags whose live surface is flag-dependent. */
23
+ export const PENDING_RESOURCE_LIVE: unique symbol = Symbol.for("oke.resource.pending-live");
24
+
25
+ /** A lazily-built live surface for a resource whose `live` was omitted. */
26
+ export interface PendingResourceLive {
27
+ /** Signal name — `oke/live/sql:<table>`. */
28
+ readonly signalName: string;
29
+ /** Resource definition to stamp `live` onto when the flag resolves on. */
30
+ readonly def: object;
31
+ /** Ops bag (`resource.all()`) to stamp `live` onto when resolved on. */
32
+ readonly bag: object;
33
+ /** Build the surface on demand (never called when the flag stays off). */
34
+ readonly build: () => { readonly signal: string; readonly flow: AnyFlowDef };
35
+ }
36
+
37
+ /** Mount slot left by `http.resource` for a resource with a pending surface. */
38
+ export interface PendingResourceLiveMount {
39
+ readonly path: string;
40
+ readonly gates: readonly GateRef[];
41
+ readonly signalName: string;
42
+ }
43
+
44
+ const pendingSurfaces = new Map<string, PendingResourceLive>();
45
+ const pendingMounts = new Map<string, PendingResourceLiveMount>();
46
+
47
+ /**
48
+ * Record a resource whose `live` was omitted (flag-dependent).
49
+ *
50
+ * @param surface - Pending live surface
51
+ */
52
+ export function registerPendingResourceLive(surface: PendingResourceLive): void {
53
+ pendingSurfaces.set(surface.signalName, surface);
54
+ }
55
+
56
+ /**
57
+ * Record a `http.resource(path, ops)` mount slot whose ops bag carries a
58
+ * pending live surface. Keyed by path so gate-chaining rebuilds overwrite
59
+ * earlier partial-gate registrations.
60
+ *
61
+ * @param mount - Path, gates, and the pending surface's signal name
62
+ */
63
+ export function registerPendingResourceLiveMount(mount: PendingResourceLiveMount): void {
64
+ pendingMounts.set(mount.path, mount);
65
+ }
66
+
67
+ /**
68
+ * Resolve every pending mount. Always clears the pending state; bindings
69
+ * are only emitted (and surfaces built) when `enabled` is true.
70
+ *
71
+ * @param enabled - Whether the project-wide `store.live` default is on
72
+ * @param adopt - Adopt one binding (`oke`'s `adoptBinding`)
73
+ * @returns The adopted bindings (empty when the flag is off)
74
+ */
75
+ export function drainPendingResourceLiveMounts(
76
+ enabled: boolean,
77
+ adopt: (binding: Binding) => void,
78
+ ): Binding[] {
79
+ const mounts = [...pendingMounts.values()];
80
+ pendingMounts.clear();
81
+ const adopted: Binding[] = [];
82
+ if (!enabled) {
83
+ pendingSurfaces.clear();
84
+ return adopted;
85
+ }
86
+ for (const mount of mounts) {
87
+ const surface = pendingSurfaces.get(mount.signalName);
88
+ if (!surface) continue;
89
+ const built = surface.build();
90
+ const liveFlow = built.flow as AnyFlowDef & { live?: string };
91
+ // Match what `on(http.get(...).live(signal), flow)` stamps on the flow —
92
+ // `flow.live` is the signal name the Manifest / client tooling reads.
93
+ liveFlow.live = mount.signalName;
94
+ // Stamp the resolved surface onto the resource + bag so post-construction
95
+ // `resource.live` reads are precise per object (never a global flag).
96
+ Object.defineProperty(surface.def, "live", { value: built, configurable: true });
97
+ Object.defineProperty(surface.bag, "live", { value: built, configurable: true });
98
+ const binding: Binding = {
99
+ trigger: createHttpTrigger("GET", `${mount.path}/live`, mount.gates, {
100
+ name: mount.signalName,
101
+ }),
102
+ flow: built.flow,
103
+ };
104
+ adopt(binding);
105
+ adopted.push(binding);
106
+ }
107
+ pendingSurfaces.clear();
108
+ return adopted;
109
+ }
110
+
111
+ /**
112
+ * Clear pending live state without draining (test reset).
113
+ */
114
+ export function resetPendingResourceLive(): void {
115
+ pendingMounts.clear();
116
+ pendingSurfaces.clear();
117
+ }
@@ -54,6 +54,15 @@ export interface HttpTrigger<M extends HttpMethod = HttpMethod, P extends string
54
54
  * @param signal - Live signal handle
55
55
  */
56
56
  live(signal: SignalSource): LiveHttpTrigger<M, P>;
57
+ /**
58
+ * Declare a live query surface over a table on this GET: same CDC +
59
+ * per-subscriber RLS classification physics as
60
+ * `store.resource(…, { live: true })`, for a hand-written flow. The
61
+ * flow body opens the stream via `liveQuery(fx, table, input)`.
62
+ *
63
+ * @param table - `store.schema.table` (or drizzle/table handle) binding
64
+ */
65
+ live(table: object): LiveHttpTrigger<M, P>;
57
66
  }
58
67
 
59
68
  /**
@@ -107,8 +116,30 @@ export interface InternalTrigger {
107
116
  readonly kind: "internal";
108
117
  }
109
118
 
119
+ /**
120
+ * MCP tool exposure — the per-flow opt-in surface for OAuth-protected
121
+ * user-plane tools (`on(mcp.tool("bookings.create").gate(...), flow)`).
122
+ *
123
+ * Deny-by-default: a flow without this trigger is never listed over MCP.
124
+ * Gates are required (typically `gate.auth` + `gate.scope(...)`) exactly
125
+ * like sensitive HTTP routes.
126
+ */
127
+ export interface McpToolTrigger {
128
+ readonly kind: "mcp";
129
+ /** Tool name exposed in MCP `tools/list`. */
130
+ readonly name: string;
131
+ readonly gates: readonly GateRef[];
132
+ readonly gate: GateAttach<McpToolTrigger>;
133
+ }
134
+
110
135
  /** Discriminated union of all trigger kinds. */
111
- export type Trigger = HttpTrigger | EveryTrigger | SignalAsTrigger | CdcTrigger | InternalTrigger;
136
+ export type Trigger =
137
+ | HttpTrigger
138
+ | EveryTrigger
139
+ | SignalAsTrigger
140
+ | CdcTrigger
141
+ | InternalTrigger
142
+ | McpToolTrigger;
112
143
 
113
144
  /** Trigger kind string. */
114
145
  export type TriggerKind = Trigger["kind"];
@@ -162,8 +193,19 @@ export function createHttpTrigger<M extends HttpMethod, P extends string>(
162
193
  public() {
163
194
  return createHttpTrigger(method, path, [...gates, GATE_PUBLIC_NAME], liveSignal);
164
195
  },
165
- live(signal: SignalSource) {
166
- return createHttpTrigger(method, path, gates, signal);
196
+ live(source: SignalSource | object) {
197
+ if (typeof source === "object" && !("name" in source)) {
198
+ // Table binding — declare a live query surface over this table.
199
+ // Same signal name the `store.resource` live surface synthesizes;
200
+ // resolved lazily so triggers.ts stays free of store imports.
201
+ const storeTable = lazyRequire<typeof import("../elements/store/table.ts")>(
202
+ "../elements/store",
203
+ "table",
204
+ );
205
+ const signal = { name: `oke/live/sql:${storeTable.resolveTableName(source)}` };
206
+ return createHttpTrigger(method, path, gates, signal);
207
+ }
208
+ return createHttpTrigger(method, path, gates, source as SignalSource);
167
209
  },
168
210
  };
169
211
  return trigger;
@@ -179,6 +221,8 @@ export interface ResourceFlowBag {
179
221
  readonly get: unknown;
180
222
  readonly update: unknown;
181
223
  readonly remove: unknown;
224
+ /** Live surface when present — `store.resource(…, { live: true }).all()`. */
225
+ readonly live?: { readonly signal: string; readonly flow: unknown };
182
226
  }
183
227
 
184
228
  /**
@@ -190,6 +234,12 @@ export interface ResourceMount {
190
234
  readonly [resourceMountBrand]: true;
191
235
  readonly mounts: ReadonlyArray<{ readonly trigger: HttpTrigger; readonly flow: unknown }>;
192
236
  readonly gates: readonly GateRef[];
237
+ /**
238
+ * Optional live surface — set by `httpResource` when the ops bag carries
239
+ * `live` (from `store.resource(…, { live: true }).all()`). `on()` then
240
+ * synthesizes the SSE exposure on `<path>/live`.
241
+ */
242
+ readonly live?: { readonly signal: string; readonly flow: unknown };
193
243
  /**
194
244
  * Attach gates to every verb (registration order). `gate.all` and arrays flatten.
195
245
  */
@@ -411,6 +461,37 @@ export function table(name: string, store?: string): TableHandle {
411
461
  */
412
462
  export const internal: InternalTrigger = { kind: "internal" };
413
463
 
464
+ /**
465
+ * MCP tool namespace — `mcp.tool("bookings.create")` marks a flow as an
466
+ * explicitly exposed MCP tool for OAuth user-plane clients. Chain `.gate(...)`
467
+ * like HTTP triggers; no gates ⇒ not exposed (deny-by-default).
468
+ *
469
+ * @param name - Tool name (namespaced, e.g. `bookings.create`)
470
+ */
471
+ export const mcp: {
472
+ /**
473
+ * @param name - Tool name
474
+ */
475
+ tool(name: string): McpToolTrigger;
476
+ } = {
477
+ tool(name: string): McpToolTrigger {
478
+ if (typeof name !== "string" || name.trim().length === 0) {
479
+ throw new TypeError("mcp.tool(name): name is required");
480
+ }
481
+ return withMcpGates(name, []);
482
+ },
483
+ };
484
+
485
+ /** @internal Attach a resolved gate list onto a fresh {@link McpToolTrigger}. */
486
+ function withMcpGates(name: string, gates: readonly GateRef[]): McpToolTrigger {
487
+ return {
488
+ kind: "mcp",
489
+ name,
490
+ gates,
491
+ gate: createGateAttach((next) => withMcpGates(name, next), gates),
492
+ };
493
+ }
494
+
414
495
  /**
415
496
  * Normalize anything accepted by {@link on} into a {@link Trigger}.
416
497
  *
@@ -427,7 +508,8 @@ export function normalizeTrigger(value: Trigger | SignalSource): Trigger {
427
508
  kind === "every" ||
428
509
  kind === "signal" ||
429
510
  kind === "cdc" ||
430
- kind === "internal"
511
+ kind === "internal" ||
512
+ kind === "mcp"
431
513
  ) {
432
514
  return value as Trigger;
433
515
  }
@@ -29,6 +29,7 @@ import type {
29
29
  Signal,
30
30
  Slo,
31
31
  Store,
32
+ StoreLive,
32
33
  Table,
33
34
  Tenancy,
34
35
  Trigger,
@@ -102,6 +103,7 @@ export function diffManifest(before: Manifest, after: Manifest): ManifestDiffRes
102
103
  diffAi(before.ai, after.ai, changes);
103
104
  diffDrivers(before.drivers, after.drivers, changes);
104
105
  diffTenancy(before.tenancy, after.tenancy, changes);
106
+ diffStoreLive(before.store, after.store, changes);
105
107
  diffI18n(before.i18n, after.i18n, changes);
106
108
  diffTopology(before.topology, after.topology, changes);
107
109
  diffImages(before.images, after.images, changes);
@@ -849,6 +851,18 @@ function diffTable(before: Table, after: Table, path: string, out: ManifestChang
849
851
  ),
850
852
  );
851
853
  }
854
+ if (before.live !== after.live && (before.live !== undefined || after.live !== undefined)) {
855
+ out.push(
856
+ change(
857
+ `${path}/live`,
858
+ "effect-widening",
859
+ kindOf(before.live, after.live),
860
+ before.live,
861
+ after.live,
862
+ `live ${String(before.live)} → ${String(after.live)}`,
863
+ ),
864
+ );
865
+ }
852
866
  }
853
867
 
854
868
  function diffClock(before: Clock, after: Clock, path: string, out: ManifestChange[]): void {
@@ -1289,6 +1303,29 @@ function diffTenancy(
1289
1303
  }
1290
1304
  }
1291
1305
 
1306
+ function diffStoreLive(
1307
+ before: StoreLive | undefined,
1308
+ after: StoreLive | undefined,
1309
+ out: ManifestChange[],
1310
+ ): void {
1311
+ if (deepEqual(before, after)) return;
1312
+ const beforeEnabled = before?.live === true;
1313
+ const afterEnabled = after?.live === true;
1314
+ if (!beforeEnabled && afterEnabled) {
1315
+ // New default live surfaces under the flag are synthesized /live routes
1316
+ // over SQL tables — effect-widening (behavioral, but not a contract break).
1317
+ out.push(
1318
+ change("/store/live", "effect-widening", "changed", undefined, true, "store.live enabled"),
1319
+ );
1320
+ return;
1321
+ }
1322
+ if (beforeEnabled && !afterEnabled) {
1323
+ out.push(
1324
+ change("/store/live", "effect-widening", "changed", true, undefined, "store.live disabled"),
1325
+ );
1326
+ }
1327
+ }
1328
+
1292
1329
  function diffI18n(before: I18n | undefined, after: I18n | undefined, out: ManifestChange[]): void {
1293
1330
  if (deepEqual(before, after)) return;
1294
1331
  if (before === undefined && after !== undefined) {