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
@@ -5,6 +5,7 @@
5
5
  import { describe, expect, test } from "bun:test";
6
6
  import type { Manifest } from "../manifest/types.ts";
7
7
  import { runDoctor } from "./doctor.ts";
8
+ import { estimatePeakFds } from "./doctor-fd.ts";
8
9
 
9
10
  describe("oke doctor", () => {
10
11
  test("catches a missing secret", async () => {
@@ -210,3 +211,194 @@ describe("oke doctor", () => {
210
211
  expect(findings.some((f) => f.code === "pii_ask")).toBe(false);
211
212
  });
212
213
  });
214
+
215
+ describe("doctor file_descriptor_limit", () => {
216
+ const base = {
217
+ secrets: [] as string[],
218
+ ports: [] as number[],
219
+ skipDbDrift: true,
220
+ expectedSchemaFingerprint: "same",
221
+ currentSchemaFingerprint: "same",
222
+ write: () => {},
223
+ };
224
+
225
+ test("warns when soft limit is low relative to estimated need", async () => {
226
+ const { code, findings } = await runDoctor({
227
+ ...base,
228
+ detectFdPressure: async () => ({ softLimit: 256, estimatedNeed: 400, headroom: -144 }),
229
+ });
230
+ expect(code).toBe(0);
231
+ expect(findings.some((f) => f.code === "file_descriptor_limit" && f.severity === "warn")).toBe(
232
+ true,
233
+ );
234
+ });
235
+
236
+ test("errors when soft limit below estimated need", async () => {
237
+ const { code, findings } = await runDoctor({
238
+ ...base,
239
+ detectFdPressure: async () => ({ softLimit: 200, estimatedNeed: 400, headroom: -200 }),
240
+ });
241
+ expect(code).toBe(2);
242
+ const finding = findings.find((f) => f.code === "file_descriptor_limit");
243
+ expect(finding?.severity).toBe("error");
244
+ expect(finding?.message).toContain("file descriptor");
245
+ });
246
+
247
+ test("no finding with ample headroom", async () => {
248
+ const { code, findings } = await runDoctor({
249
+ ...base,
250
+ detectFdPressure: async () => ({ softLimit: 65536, estimatedNeed: 500, headroom: 65036 }),
251
+ });
252
+ expect(code).toBe(0);
253
+ expect(findings.some((f) => f.code === "file_descriptor_limit")).toBe(false);
254
+ });
255
+
256
+ test("skips conservatively without manifest/live signals", async () => {
257
+ const findings: unknown[] = [];
258
+ const { code } = await runDoctor({
259
+ ...base,
260
+ write: (t) => findings.push(t),
261
+ });
262
+ // Real probe runs; on typical dev limits it must not throw and must exit ok.
263
+ expect([0, 2]).toContain(code);
264
+ });
265
+
266
+ test("estimatePeakFds counts live signals and http routes", async () => {
267
+ const manifest = {
268
+ oke: "1.0",
269
+ app: "t",
270
+ signals: {
271
+ tick: { delivery: "live" },
272
+ once: { delivery: "once" },
273
+ fan: { delivery: "broadcast" },
274
+ },
275
+ flows: {
276
+ sse: { trigger: { http: { method: "GET", path: "/live" } }, live: "tick" },
277
+ plain: { trigger: { http: { method: "POST", path: "/x" } } },
278
+ },
279
+ } as unknown as Manifest;
280
+ const need = estimatePeakFds(manifest);
281
+ expect(need).toBeGreaterThan(estimatePeakFds(null));
282
+ expect(need).toBeGreaterThanOrEqual(64 * 2 * 2); // live signal + SSE route, ≥2 fds/sub
283
+ });
284
+
285
+ test("estimatePeakFds subscriber term is linear in live signals (G3b-calibrated)", () => {
286
+ const one = {
287
+ oke: "1.0",
288
+ app: "t",
289
+ signals: { a: { delivery: "live" } },
290
+ flows: {},
291
+ } as unknown as Manifest;
292
+ const two = {
293
+ ...one,
294
+ signals: { a: { delivery: "live" }, b: { delivery: "live" } },
295
+ } as unknown as Manifest;
296
+ const base = estimatePeakFds(null);
297
+ const deltaOne = estimatePeakFds(one) - base;
298
+ const deltaTwo = estimatePeakFds(two) - base;
299
+ // Each additional live signal adds the same subscriber-fd budget.
300
+ expect(deltaTwo).toBe(deltaOne * 2);
301
+ // G3b measured 1.53 fds/subscriber; budget must be at least 1 fd/sub.
302
+ expect(deltaOne).toBeGreaterThanOrEqual(64);
303
+ });
304
+ });
305
+
306
+ describe("doctor realtime checks", () => {
307
+ const base = {
308
+ secrets: [] as string[],
309
+ ports: [] as number[],
310
+ skipDbDrift: true,
311
+ write: () => {},
312
+ currentSchemaFingerprint: null,
313
+ };
314
+
315
+ test("cdc_outbox_backlog warn at >10k pending", async () => {
316
+ const { findings } = await runDoctor({
317
+ ...base,
318
+ detectRealtimeMetrics: async () => ({
319
+ subscribers: 10,
320
+ queueDepth: 0,
321
+ fanout: { eventsIn: 1, eventsShed: 0, checksRun: 0, checkFailures: 0 },
322
+ outbox: { pending: 20_000, dispatchedOverCap: 0 },
323
+ }),
324
+ });
325
+ const finding = findings.find((f) => f.code === "cdc_outbox_backlog");
326
+ expect(finding?.severity).toBe("warn");
327
+ });
328
+
329
+ test("cdc_outbox_backlog error at >100k pending", async () => {
330
+ const { code, findings } = await runDoctor({
331
+ ...base,
332
+ detectRealtimeMetrics: async () => ({
333
+ subscribers: 10,
334
+ queueDepth: 0,
335
+ fanout: { eventsIn: 1, eventsShed: 0, checksRun: 0, checkFailures: 0 },
336
+ outbox: { pending: 150_000, dispatchedOverCap: 0 },
337
+ }),
338
+ });
339
+ expect(code).toBe(2);
340
+ expect(findings.find((f) => f.code === "cdc_outbox_backlog")?.severity).toBe("error");
341
+ });
342
+
343
+ test("cdc_outbox_retention warns when delivered rows exceed maxCount cap", async () => {
344
+ const { findings } = await runDoctor({
345
+ ...base,
346
+ detectRealtimeMetrics: async () => ({
347
+ subscribers: 10,
348
+ queueDepth: 0,
349
+ fanout: { eventsIn: 1, eventsShed: 0, checksRun: 0, checkFailures: 0 },
350
+ outbox: { pending: 0, dispatchedOverCap: 500 },
351
+ }),
352
+ });
353
+ expect(findings.some((f) => f.code === "cdc_outbox_retention")).toBe(true);
354
+ });
355
+
356
+ test("live_subscriber_pressure warns above ~150 active subs", async () => {
357
+ const { findings } = await runDoctor({
358
+ ...base,
359
+ detectRealtimeMetrics: async () => ({
360
+ subscribers: 200,
361
+ queueDepth: 0,
362
+ fanout: { eventsIn: 1, eventsShed: 0, checksRun: 0, checkFailures: 0 },
363
+ outbox: { unavailable: "no_bridge" },
364
+ }),
365
+ });
366
+ expect(findings.some((f) => f.code === "live_subscriber_pressure")).toBe(true);
367
+ });
368
+
369
+ test("live_fanout_queue_saturated errors on near-cap depth plus shedding; no finding when healthy", async () => {
370
+ const saturated = await runDoctor({
371
+ ...base,
372
+ detectRealtimeMetrics: async () => ({
373
+ subscribers: 100,
374
+ queueDepth: 9_000,
375
+ fanout: { eventsIn: 50_000, eventsShed: 12, checksRun: 40_000, checkFailures: 2 },
376
+ outbox: { unavailable: "no_bridge" },
377
+ }),
378
+ });
379
+ expect(saturated.findings.some((f) => f.code === "live_fanout_queue_saturated")).toBe(true);
380
+
381
+ const healthy = await runDoctor({
382
+ ...base,
383
+ expectedSchemaFingerprint: "same",
384
+ currentSchemaFingerprint: "same",
385
+ detectRealtimeMetrics: async () => ({
386
+ subscribers: 100,
387
+ queueDepth: 4,
388
+ fanout: { eventsIn: 5_000, eventsShed: 0, checksRun: 4_800, checkFailures: 0 },
389
+ outbox: { unavailable: "no_bridge" },
390
+ }),
391
+ });
392
+ expect(healthy.findings).toHaveLength(0);
393
+ });
394
+
395
+ test("no realtime findings when no bridge is bound (metrics null)", async () => {
396
+ const { findings } = await runDoctor({
397
+ ...base,
398
+ detectRealtimeMetrics: async () => null,
399
+ });
400
+ expect(
401
+ findings.filter((f) => f.code.startsWith("cdc_") || f.code.startsWith("live_")),
402
+ ).toHaveLength(0);
403
+ });
404
+ });
package/src/cli/doctor.ts CHANGED
@@ -17,11 +17,24 @@ import { hasFlag, wantsJson } from "./args.ts";
17
17
  import { checkManifestPiiAsks } from "./doctor-pii.ts";
18
18
  import { EXIT_OK, EXIT_RUNTIME } from "./exit.ts";
19
19
  import { loadManifest, loadOkeConfig } from "./load-config.ts";
20
+ import { OUTBOX_PRUNE_INTERVAL_MS } from "../drivers/cdc-outbox.ts";
20
21
  import { isPortInUse } from "./ports.ts";
21
22
  import { schemaFingerprint, readSchemaFingerprint } from "./schema.ts";
22
23
 
23
24
  export { isPortInUse } from "./ports.ts";
24
25
 
26
+ /** Warn when the CDC outbox drain rate looks stalled below this backlog. */
27
+ const OUTBOX_BACKLOG_WARN = 10_000;
28
+
29
+ /** Error-severity outbox backlog — poller considered down. */
30
+ const OUTBOX_BACKLOG_ERROR = 100_000;
31
+
32
+ /** v1 documented fan-out ceiling per table (Hardening 2 / plan §Operations). */
33
+ const LIVE_SUBSCRIBER_SOFT_CAP = 150;
34
+
35
+ /** Queue-depth threshold where fan-out health findings start firing. */
36
+ const LIVE_FANOUT_QUEUE_WARN_DEPTH = 8_000;
37
+
25
38
  /**
26
39
  * `label → merged EnvDriverMap` pairs, in the fixed order shown by
27
40
  * {@link formatDriversSummary} / the `drivers` JSON key.
@@ -84,7 +97,12 @@ export interface DoctorFinding {
84
97
  | "tenancy"
85
98
  | "driver"
86
99
  | "pii_ask"
87
- | "vault_master_key";
100
+ | "vault_master_key"
101
+ | "file_descriptor_limit"
102
+ | "cdc_outbox_backlog"
103
+ | "cdc_outbox_retention"
104
+ | "live_subscriber_pressure"
105
+ | "live_fanout_queue_saturated";
88
106
  readonly severity: "error" | "warn";
89
107
  readonly message: string;
90
108
  }
@@ -138,8 +156,31 @@ export interface DoctorOptions {
138
156
  readonly writeErr?: (text: string) => void;
139
157
  /** Emit only JSON on stdout. */
140
158
  readonly json?: boolean;
159
+ /**
160
+ * Inject the file-descriptor pressure probe (tests). When unset, the real
161
+ * probe runs only when a manifest was loaded (conservative skip otherwise).
162
+ */
163
+ readonly detectFdPressure?: () => Promise<{
164
+ readonly softLimit: number;
165
+ readonly estimatedNeed: number;
166
+ readonly headroom: number;
167
+ }>;
168
+ /**
169
+ * Inject the realtime metrics probe (tests). When unset, the real probe
170
+ * reads the bound realtime bridge in-process (returns `null` — no findings
171
+ * — when this CLI process has none, e.g. the app runs in another host).
172
+ */
173
+ readonly detectRealtimeMetrics?: () => Promise<RealtimeMetricsSnapshot | null>;
141
174
  }
142
175
 
176
+ /** Realtime metrics shape used by the doctor checks (see {@link realtimeMetrics}). */
177
+ export type RealtimeMetricsSnapshot = {
178
+ subscribers: number;
179
+ queueDepth: number;
180
+ fanout: { eventsIn: number; eventsShed: number; checksRun: number; checkFailures: number };
181
+ outbox: { pending: number; dispatchedOverCap: number } | { unavailable: "no_bridge" };
182
+ };
183
+
143
184
  /**
144
185
  * Run doctor checks.
145
186
  *
@@ -232,6 +273,93 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<{
232
273
  }
233
274
  }
234
275
 
276
+ // File-descriptor headroom — live SSE holds one fd per open subscriber.
277
+ // Severity: error when the soft limit dips under ~60% of estimated peak
278
+ // need (exhaustion is imminent); warn when it is under 2× need.
279
+ const fd =
280
+ options.detectFdPressure !== undefined
281
+ ? await options.detectFdPressure()
282
+ : manifest
283
+ ? await (async () => {
284
+ const { checkFileDescriptorHeadroom } = await import("./doctor-fd.ts");
285
+ return checkFileDescriptorHeadroom(manifest);
286
+ })()
287
+ : null;
288
+ if (fd) {
289
+ if (fd.softLimit < fd.estimatedNeed * 0.6) {
290
+ findings.push({
291
+ code: "file_descriptor_limit",
292
+ severity: "error",
293
+ message: `file descriptor soft limit ${fd.softLimit} is below estimated peak need ${fd.estimatedNeed} (headroom ${fd.headroom}) — raise with \`ulimit -n\` or launchd Limits`,
294
+ });
295
+ } else if (fd.softLimit < fd.estimatedNeed * 2) {
296
+ findings.push({
297
+ code: "file_descriptor_limit",
298
+ severity: "warn",
299
+ message: `file descriptor soft limit ${fd.softLimit} leaves thin headroom over estimated peak need ${fd.estimatedNeed} (headroom ${fd.headroom}) — consider raising with \`ulimit -n\``,
300
+ });
301
+ }
302
+ }
303
+
304
+ // Realtime health — CDC outbox backlog/retention + live fan-out pressure.
305
+ // Thresholds from the Realtime plan's operations section; all probes are
306
+ // injectable so tests never need a bound bridge.
307
+ if (options.detectRealtimeMetrics !== undefined || manifest !== undefined) {
308
+ const rt =
309
+ options.detectRealtimeMetrics !== undefined
310
+ ? await options.detectRealtimeMetrics()
311
+ : await (async () => {
312
+ const { realtimeMetrics } = await import("../kernel/realtime-bind.ts");
313
+ return realtimeMetrics() as Promise<RealtimeMetricsSnapshot | null>;
314
+ })();
315
+ if (rt !== null) {
316
+ const outbox = rt.outbox;
317
+ if (!("unavailable" in outbox)) {
318
+ if (outbox.pending > OUTBOX_BACKLOG_ERROR) {
319
+ findings.push({
320
+ code: "cdc_outbox_backlog",
321
+ severity: "error",
322
+ message: `CDC outbox backlog ${outbox.pending} rows exceeds ${OUTBOX_BACKLOG_ERROR} — poller stalled or consumers down; check \`oke_cdc_outbox\` and runner metrics`,
323
+ });
324
+ } else if (outbox.pending > OUTBOX_BACKLOG_WARN) {
325
+ findings.push({
326
+ code: "cdc_outbox_backlog",
327
+ severity: "warn",
328
+ message: `CDC outbox backlog ${outbox.pending} rows exceeds ${OUTBOX_BACKLOG_WARN} — watch the poller drain rate`,
329
+ });
330
+ }
331
+ if (outbox.dispatchedOverCap > 0) {
332
+ findings.push({
333
+ code: "cdc_outbox_retention",
334
+ severity: "warn",
335
+ message: `${outbox.dispatchedOverCap} delivered outbox rows exceed the maxCount retention cap — pruner may be stuck; verify \`${OUTBOX_PRUNE_INTERVAL_MS}ms\` prune cadence`,
336
+ });
337
+ }
338
+ }
339
+ if (rt.subscribers > LIVE_SUBSCRIBER_SOFT_CAP) {
340
+ findings.push({
341
+ code: "live_subscriber_pressure",
342
+ severity: "warn",
343
+ message: `${rt.subscribers} active live subscribers exceeds the ~150 fan-out ceiling documented for v1 — expect rising p99 latency; identity dedup lands in Round 2+`,
344
+ });
345
+ }
346
+ const saturated = rt.queueDepth >= LIVE_FANOUT_QUEUE_WARN_DEPTH && rt.fanout.eventsShed > 0;
347
+ if (saturated) {
348
+ findings.push({
349
+ code: "live_fanout_queue_saturated",
350
+ severity: "error",
351
+ message: `fan-out queue depth ${rt.queueDepth} at cap with ${rt.fanout.eventsShed} events shed — subscribers are losing events now; raise pool concurrency or shed rate first`,
352
+ });
353
+ } else if (rt.queueDepth >= LIVE_FANOUT_QUEUE_WARN_DEPTH) {
354
+ findings.push({
355
+ code: "live_fanout_queue_saturated",
356
+ severity: "warn",
357
+ message: `fan-out queue depth ${rt.queueDepth} near cap (${rt.fanout.checksRun} checks in flight historically) — headroom thin before shedding begins`,
358
+ });
359
+ }
360
+ }
361
+ }
362
+
235
363
  const current =
236
364
  options.currentSchemaFingerprint !== undefined
237
365
  ? options.currentSchemaFingerprint
@@ -24,6 +24,7 @@ import {
24
24
  } from "./live.ts";
25
25
  import type {
26
26
  Client,
27
+ ClientHeaders,
27
28
  ClientLive,
28
29
  ClientOptions,
29
30
  ClientRouteMap,
@@ -97,11 +98,13 @@ function buildClient(url: string, opts: ClientOptions = {}): Client {
97
98
  const routes = opts.routes ?? flattenRoutes(opts.$routes);
98
99
  const live = flattenLiveRoutes(opts.$routes);
99
100
  const transport = createTransport(base, { ...opts, routes });
101
+ const perCallHeaders = createPerCallHeaders();
100
102
  return proxy(transport, [], {
101
103
  base,
102
104
  opts,
103
105
  liveBySignal: live.bySignal,
104
106
  liveByFlow: live.byFlow,
107
+ perCallHeaders,
105
108
  }) as Client;
106
109
  }
107
110
 
@@ -110,8 +113,48 @@ type ProxyCtx = {
110
113
  readonly opts: ClientOptions;
111
114
  readonly liveBySignal: LiveRouteTable;
112
115
  readonly liveByFlow: LiveByFlow;
116
+ /**
117
+ * Extra headers attached to the next transport call, then cleared — the
118
+ * `X-Oke-Mutation-Id` channel for optimistic dedupe (one-shot by design;
119
+ * a mutated header must not leak into unrelated calls).
120
+ */
121
+ perCallHeaders: PerCallHeaders;
113
122
  };
114
123
 
124
+ /**
125
+ * One-shot header bag: set before a mutation call, drained after it. Safe
126
+ * across concurrent calls — each `run` stages its own merge and restores the
127
+ * previous stage on completion (LIFO), so nested/parallel runs don't clobber.
128
+ */
129
+ function createPerCallHeaders(): PerCallHeaders {
130
+ let extra: ClientHeaders | undefined;
131
+ return {
132
+ /** Stage headers consumed by the next transport call only. */
133
+ run<T>(headers: ClientHeaders | undefined, fn: () => Promise<T>): Promise<T> {
134
+ const prev = extra;
135
+ extra = headers === undefined ? prev : mergeHeaders(prev, headers);
136
+ return fn().finally(() => {
137
+ extra = prev;
138
+ });
139
+ },
140
+ /** Transport-side read (invoked inside `invoke`). */
141
+ read(): ClientHeaders | undefined {
142
+ return extra;
143
+ },
144
+ };
145
+ }
146
+
147
+ /** Shallow-merge two header bags (later wins on key conflicts). */
148
+ function mergeHeaders(base: ClientHeaders | undefined, over: ClientHeaders): ClientHeaders {
149
+ if (base === undefined) return over;
150
+ if (!Array.isArray(base) && !Array.isArray(over)) {
151
+ return { ...base, ...over };
152
+ }
153
+ const toEntries = (h: ClientHeaders): [string, string][] =>
154
+ Array.isArray(h) ? h : Object.entries(h);
155
+ return [...toEntries(base), ...toEntries(over)];
156
+ }
157
+
115
158
  /**
116
159
  * Flatten `app.$routes` into the transport REST table (`unit.flow` → method/path).
117
160
  * Entries without both method and path are omitted (RPC fallback).
@@ -162,7 +205,7 @@ function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): un
162
205
  }
163
206
  const unit = path[0]!;
164
207
  const flow = path.slice(1).join(".");
165
- const result = await transport.call(`${unit}/${flow}`, input);
208
+ const result = await transport.call(`${unit}/${flow}`, input, ctx.perCallHeaders.read());
166
209
  return attachPager(result, invoke, input);
167
210
  };
168
211
  const call = (a?: unknown, b?: unknown) => {
@@ -181,6 +224,13 @@ function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): un
181
224
  return new Proxy(call, {
182
225
  get(_target, prop, receiver) {
183
226
  if (typeof prop === "symbol") {
227
+ if (prop === TRANSPORT_BRAND) {
228
+ return {
229
+ base: ctx.base,
230
+ opts: ctx.opts,
231
+ perCallHeaders: ctx.perCallHeaders,
232
+ } satisfies TransportBag;
233
+ }
184
234
  return Reflect.get(_target, prop, receiver);
185
235
  }
186
236
  if (prop === "then") return undefined;
@@ -192,6 +242,50 @@ function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): un
192
242
  });
193
243
  }
194
244
 
245
+ /** Symbol brand exposing `{ base, opts }` from a client Proxy instance. */
246
+ const TRANSPORT_BRAND = Symbol("oke.transportBag");
247
+
248
+ /** One-shot header channel surface (see {@link createPerCallHeaders}). */
249
+ export interface PerCallHeaders {
250
+ /**
251
+ * Run `fn` with `headers` merged onto its transport call (and any pager
252
+ * walks the result spawns). Restores prior state afterwards.
253
+ */
254
+ run<T>(headers: ClientHeaders | undefined, fn: () => Promise<T>): Promise<T>;
255
+ /** Transport-side read (invoked inside `invoke`). */
256
+ read(): ClientHeaders | undefined;
257
+ }
258
+
259
+ /** Transport surface carried by a built client — read via {@link transportOf}. */
260
+ export interface TransportBag {
261
+ readonly base: string;
262
+ readonly opts: ClientOptions;
263
+ /** One-shot extra headers for a mutation (e.g. `X-Oke-Mutation-Id`). */
264
+ readonly perCallHeaders: PerCallHeaders;
265
+ }
266
+
267
+ /**
268
+ * Read the origin + client options from any {@link createClient} instance
269
+ * (symbol-brand channel on the root proxy — invisible to `get` traps beyond
270
+ * symbol reflection). The root proxy targets a callable, so both `function`
271
+ * and `object` receivers are accepted.
272
+ *
273
+ * @param api - Client instance or its typed surface
274
+ */
275
+ export function transportOf(api: unknown): TransportBag | undefined {
276
+ if (typeof api !== "object" && typeof api !== "function") return undefined;
277
+ const bag = Reflect.get(api as object, TRANSPORT_BRAND) as unknown;
278
+ if (
279
+ bag !== null &&
280
+ typeof bag === "object" &&
281
+ "base" in bag &&
282
+ typeof (bag as { base: unknown }).base === "string"
283
+ ) {
284
+ return bag as TransportBag;
285
+ }
286
+ return undefined;
287
+ }
288
+
195
289
  function makeLive(ctx: ProxyCtx): ClientLive {
196
290
  return ((signalOrName: unknown, inputOrHandlers: unknown, maybeHandlers?: unknown) => {
197
291
  const name =
@@ -13,11 +13,18 @@ export {
13
13
  nextResubscribeDelay,
14
14
  pickLiveExposure,
15
15
  } from "./live.ts";
16
- export { createClient, flattenRoutes } from "./create.ts";
17
- export type { AppWithRoutes } from "./create.ts";
16
+ export { createClient, flattenRoutes, transportOf } from "./create.ts";
17
+ export type { AppWithRoutes, TransportBag } from "./create.ts";
18
18
  export { isErrorCode, isFail, isOk, isTransportError } from "./errors.ts";
19
19
  export { isPagerMeta, pagerLink } from "./pager.ts";
20
20
  export type { ClientPager, PagerLink } from "./pager.ts";
21
+ export {
22
+ applyOptimisticPatch,
23
+ clearOptimisticPatch,
24
+ isStaleUpsert,
25
+ reduceLiveQueryRows,
26
+ } from "./use-live-query.ts";
27
+ export type { LiveQueryError, LiveQueryEvent } from "./use-live-query.ts";
21
28
  export { createTransport } from "./transport.ts";
22
29
  export type { Transport } from "./transport.ts";
23
30
  export type {
@@ -5,7 +5,7 @@
5
5
  * Optional `routes` map switches to REST (`method` + path template).
6
6
  */
7
7
 
8
- import type { ClientEnvelope, ClientFetch, ClientOptions } from "./types.ts";
8
+ import type { ClientEnvelope, ClientFetch, ClientHeaders, ClientOptions } from "./types.ts";
9
9
 
10
10
  /** Internal transport handle. */
11
11
  export interface Transport {
@@ -14,8 +14,9 @@ export interface Transport {
14
14
  *
15
15
  * @param key - `unit/flow`
16
16
  * @param input - JSON body / path-param source
17
+ * @param headers - Per-call extra headers (merged after static ones)
17
18
  */
18
- call(key: string, input: unknown): Promise<ClientEnvelope>;
19
+ call(key: string, input: unknown, headers?: ClientHeaders): Promise<ClientEnvelope>;
19
20
  }
20
21
 
21
22
  /**
@@ -31,14 +32,14 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
31
32
  const backoff = opts.retry?.backoff ?? 2;
32
33
 
33
34
  return {
34
- async call(key, input) {
35
+ async call(key, input, callHeaders) {
35
36
  let refreshed = false;
36
37
  let attempt = 0;
37
38
  let delay = delay0;
38
39
 
39
40
  for (;;) {
40
41
  try {
41
- const res = await once(base, key, input, opts, fetchFn);
42
+ const res = await once(base, key, input, opts, fetchFn, callHeaders);
42
43
  if (res.status === 401 && opts.auth?.refresh && !refreshed) {
43
44
  refreshed = true;
44
45
  await opts.auth.refresh();
@@ -88,6 +89,7 @@ async function once(
88
89
  input: unknown,
89
90
  opts: ClientOptions,
90
91
  fetchFn: ClientFetch,
92
+ callHeaders?: ClientHeaders,
91
93
  ): Promise<Response> {
92
94
  const route = opts.routes?.[key.replace("/", ".")];
93
95
  const { url, method, body } = route
@@ -101,6 +103,11 @@ async function once(
101
103
  } else if (extra) {
102
104
  for (const [k, v] of Object.entries(extra)) headers.set(k, v);
103
105
  }
106
+ if (Array.isArray(callHeaders)) {
107
+ for (const [k, v] of callHeaders) headers.set(k, v);
108
+ } else if (callHeaders) {
109
+ for (const [k, v] of Object.entries(callHeaders)) headers.set(k, v);
110
+ }
104
111
  if (body !== undefined && !headers.has("content-type")) {
105
112
  headers.set("content-type", "application/json");
106
113
  }