okengine 0.21.0 → 0.22.0

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 (160) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +15 -9
  5. package/manifest.v1.schema.json +10 -0
  6. package/package.json +22 -25
  7. package/site/content/docs/ai/skills.mdx +1 -1
  8. package/site/content/docs/client/calling.mdx +27 -19
  9. package/site/content/docs/client/index.mdx +1 -1
  10. package/site/content/docs/elements/channel/email.mdx +1 -1
  11. package/site/content/docs/elements/channel/index.mdx +1 -1
  12. package/site/content/docs/elements/flow/http.mdx +4 -4
  13. package/site/content/docs/elements/flow/index.mdx +3 -3
  14. package/site/content/docs/elements/flow/meta.json +1 -1
  15. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  16. package/site/content/docs/elements/vault/rotation.mdx +15 -0
  17. package/site/content/docs/plugins/otp.mdx +31 -31
  18. package/site/content/docs/recipes/mailpit.mdx +1 -1
  19. package/site/content/docs/recipes/meilisearch.mdx +1 -1
  20. package/site/content/docs/recipes/pgdog.mdx +1 -1
  21. package/site/content/docs/reference/configuration.mdx +2 -2
  22. package/site/content/docs/reference/errors.mdx +12 -2
  23. package/site/content/docs/reference/fx.mdx +28 -15
  24. package/site/content/docs/reference/idempotency.mdx +192 -0
  25. package/site/content/docs/reference/index.mdx +6 -1
  26. package/site/content/docs/reference/meta.json +2 -1
  27. package/site/content/docs/understand/meta.json +1 -1
  28. package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
  29. package/site/content/docs/understand/the-architecture.mdx +14 -12
  30. package/site/content/docs/understand/try-it.mdx +3 -3
  31. package/src/cli/competitor-mention-removal.test.ts +3 -3
  32. package/src/cli/dev.test.ts +105 -2
  33. package/src/cli/dev.ts +37 -2
  34. package/src/cli/docker-cli.test.ts +1 -1
  35. package/src/cli/index.ts +9 -1
  36. package/src/cli/load-config.images.test.ts +10 -10
  37. package/src/cli/load-config.ts +1 -1
  38. package/src/cli/vault-cmd.test.ts +23 -0
  39. package/src/cli/vault-cmd.ts +7 -0
  40. package/src/client/create.ts +31 -27
  41. package/src/client/live.ts +44 -101
  42. package/src/client/sse.ts +20 -66
  43. package/src/client/stream.ts +25 -67
  44. package/src/client/transport.test.ts +225 -0
  45. package/src/client/transport.ts +158 -90
  46. package/src/client/types.ts +29 -3
  47. package/src/client/wire.ts +119 -0
  48. package/src/compiler/aot.ts +3 -32
  49. package/src/compiler/dynamic.ts +13 -11
  50. package/src/compiler/effects-infer.ts +726 -16
  51. package/src/compiler/extract.test.ts +37 -0
  52. package/src/compiler/extract.ts +113 -12
  53. package/src/compiler/fixtures/skyport.expected.json +24 -0
  54. package/src/compiler/fx-follow.test.ts +164 -0
  55. package/src/compiler/fx-index.ts +399 -0
  56. package/src/compiler/interpret.ts +45 -0
  57. package/src/console/server/flows-invoke.test.ts +2 -2
  58. package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
  59. package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
  60. package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
  66. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
  67. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
  68. package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
  72. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
  73. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
  74. package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
  75. package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
  76. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
  77. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
  78. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
  79. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
  80. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
  81. package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
  82. package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
  83. package/src/console/ui-next/dist/index.html +2 -2
  84. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
  85. package/src/docker/docker.test.ts +13 -13
  86. package/src/docker/images-config.test.ts +12 -12
  87. package/src/docker/stack-id.test.ts +1 -1
  88. package/src/drivers/journal-postgres.ts +168 -3
  89. package/src/drivers/meilisearch.ts +2 -0
  90. package/src/drivers/vault-builtin.test.ts +10 -0
  91. package/src/drivers/vault-builtin.ts +13 -1
  92. package/src/drivers/vault-remote-bag.ts +5 -1
  93. package/src/drivers/vault-types.ts +6 -1
  94. package/src/elements/store/emit-drizzle.ts +2 -2
  95. package/src/elements/store/live-default.test.ts +2 -0
  96. package/src/elements/store/live-http.test.ts +1 -0
  97. package/src/elements/store/resource-list-docs.test.ts +2 -2
  98. package/src/elements/store/resource.test.ts +3 -3
  99. package/src/elements/store/schema-decl.test.ts +1 -0
  100. package/src/elements/store/sql-condition.ts +24 -7
  101. package/src/elements/store/sql-session.ts +20 -4
  102. package/src/elements/store/upsert-app.test.ts +1 -1
  103. package/src/elements/vault/audit.test.ts +137 -0
  104. package/src/elements/vault/audit.ts +106 -13
  105. package/src/elements/vault/boot-chain.ts +9 -1
  106. package/src/elements/vault/builtin-adapter.ts +33 -3
  107. package/src/i18n/catalogs/ar.ts +4 -0
  108. package/src/i18n/catalogs/en.ts +4 -0
  109. package/src/kernel/abort-scope.ts +33 -2
  110. package/src/kernel/app.ts +200 -20
  111. package/src/kernel/auto-cache.test.ts +6 -6
  112. package/src/kernel/boot-bind/vault.ts +1 -0
  113. package/src/kernel/boot.ts +19 -1
  114. package/src/kernel/builtin-errors.ts +8 -0
  115. package/src/kernel/client-descriptor.test.ts +78 -0
  116. package/src/kernel/client-descriptor.ts +24 -0
  117. package/src/kernel/concurrency.test.ts +33 -0
  118. package/src/kernel/effects-stamping.test.ts +2 -2
  119. package/src/kernel/errors-compiler.ts +20 -0
  120. package/src/kernel/errors-text.ts +99 -0
  121. package/src/kernel/errors.ts +90 -138
  122. package/src/kernel/external-effects.test.ts +36 -0
  123. package/src/kernel/flow.ts +24 -0
  124. package/src/kernel/fx-fetch.ts +5 -1
  125. package/src/kernel/fx-sql-handle.ts +305 -0
  126. package/src/kernel/fx.ts +42 -330
  127. package/src/kernel/idempotency-store.ts +578 -0
  128. package/src/kernel/idempotency.test.ts +556 -0
  129. package/src/kernel/idempotency.ts +304 -0
  130. package/src/kernel/index.ts +1 -0
  131. package/src/kernel/journal.ts +56 -0
  132. package/src/kernel/json-result.ts +59 -0
  133. package/src/kernel/project-out.ts +6 -1
  134. package/src/manifest/types.ts +8 -0
  135. package/src/okid-extended.ts +175 -0
  136. package/src/okid-shared.ts +103 -0
  137. package/src/okid.ts +30 -213
  138. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  139. package/src/plugins/otp.test.ts +65 -1
  140. package/src/plugins/otp.ts +59 -7
  141. package/src/release/absolute-regression.test.ts +63 -0
  142. package/src/release/build-lib.ts +9 -0
  143. package/src/release/http-graph.test.ts +25 -0
  144. package/src/release/http-graph.ts +90 -0
  145. package/src/release/index.ts +3 -0
  146. package/src/release/limits.ts +17 -2
  147. package/src/release/measure.ts +105 -14
  148. package/src/test/create-test-app.test.ts +3 -1
  149. package/src/test/create-test-app.ts +40 -0
  150. package/src/test/live-signals.test.ts +1 -0
  151. package/src/test/provisions.integration.test.ts +1 -0
  152. package/src/test/tenant-isolation.test.ts +1 -0
  153. package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
  154. package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
  155. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  156. package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
  157. package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
  158. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  159. package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
  160. package/src/console/ui-next/dist/assets/units-page-1PlT19ft.js +0 -1
@@ -405,7 +405,7 @@ test("creates a user", async () => {
405
405
  bun test # [!code focus]
406
406
  ```
407
407
 
408
- `createTestApp` swaps every driver behind `fx` for a deterministic one.
408
+ `createTestApp` swaps every driver behind `fx` for a deterministic one. It stamps Manifest tokens. Inline flows that are not an app pass `capability: "open"`.
409
409
 
410
410
  </Step>
411
411
 
@@ -762,7 +762,7 @@ return await fx.ask(triage, { message }); // [!code focus]
762
762
 
763
763
  ## Learn more
764
764
 
765
- - [Routing](/docs/elements/flow/routing) — when to omit the path, `list` / `create` / `get` filenames
765
+ - [Routing](/docs/understand/routing) — when to omit the path, `list` / `create` / `get` filenames
766
766
  - [Flow](/docs/elements/flow) — one species; `fx` is the door
767
767
  - [Signal](/docs/elements/signal) — `once` / `broadcast` / `live`
768
768
  - [Store](/docs/elements/store) — SQL, KV, files, search
@@ -775,7 +775,7 @@ return await fx.ask(triage, { message }); // [!code focus]
775
775
  <Card
776
776
  title="Routing"
777
777
  description="File-tree stamps, many-routes files, and reserved leaves."
778
- href="/docs/elements/flow/routing"
778
+ href="/docs/understand/routing"
779
779
  />
780
780
  <Card title="Flow" description="on(trigger) → do through fx." href="/docs/elements/flow" />
781
781
  <Card title="Signal" description="Move work off the request." href="/docs/elements/signal" />
@@ -11,8 +11,8 @@ import { join } from "node:path";
11
11
 
12
12
  const ROOT = join(import.meta.dir, "../..");
13
13
 
14
- /** Forbidden peer name — split so this file does not match itself. */
15
- const FORBIDDEN_HONO = ["Ho", "no"].join("");
14
+ /** Word-boundary router peer — `-F` false-positives on `Honored` / `canHonor`. */
15
+ const FORBIDDEN_HONO = ["\\b", "Ho", "no", "\\b"].join("");
16
16
  /** Forbidden peer name. */
17
17
  const FORBIDDEN_ELYSIA = ["Ely", "sia"].join("");
18
18
  /** Forbidden peer name (covers Enc + ore.ts). */
@@ -104,7 +104,7 @@ function assertZeroGitGrepRegex(pattern: string, ignorePrefixes: readonly string
104
104
 
105
105
  describe("named competitor mention removal gate", () => {
106
106
  test("tracked tree has zero mentions of the thin multi-runtime router peer", () => {
107
- assertZeroGitGrepFixed(FORBIDDEN_HONO);
107
+ assertZeroGitGrepRegex(FORBIDDEN_HONO);
108
108
  });
109
109
 
110
110
  test("tracked tree has zero mentions of the Bun-first peer framework", () => {
@@ -6,7 +6,7 @@
6
6
  * context and exercises `oke.manifest.get` over HTTP on :6535.
7
7
  */
8
8
 
9
- import { afterEach, describe, expect, test } from "bun:test";
9
+ import { afterEach, describe, expect, mock, test } from "bun:test";
10
10
  import { mkdir, mkdtemp, readdir } from "node:fs/promises";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join, resolve } from "node:path";
@@ -17,9 +17,19 @@ import {
17
17
  } from "../compiler/generate-adopt.ts";
18
18
  import type { Manifest } from "../manifest/types.ts";
19
19
  import type { WideEvent } from "../runs/types.ts";
20
+ import type { ConsoleAppHandle } from "../console/server/app.ts";
21
+ import { createConsoleState } from "../console/server/state.ts";
20
22
  import { isDataEnvelope, MCP_DATA_KIND } from "../mcp/data.ts";
21
23
  import { mintMcpSession } from "../mcp/session.ts";
22
- import { isFlowsTreeWatchPath, runDev, type DevOptions, type DevSession } from "./dev.ts";
24
+ import {
25
+ CLIENT_REGEN_DEBOUNCE_MS,
26
+ isClientRegenWatchPath,
27
+ isFlowsTreeWatchPath,
28
+ runDev,
29
+ type DevOptions,
30
+ type DevSession,
31
+ type DevWatchFn,
32
+ } from "./dev.ts";
23
33
  import { mcpContextFromConsole } from "./mcp-from-console.ts";
24
34
 
25
35
  /** Repo public entry — absolute import so temp apps need no install. */
@@ -803,6 +813,99 @@ describe("isFlowsTreeWatchPath", () => {
803
813
  });
804
814
  });
805
815
 
816
+ describe("isClientRegenWatchPath", () => {
817
+ test("flow files and schema declares regenerate the client; unrelated files do not", () => {
818
+ expect(isClientRegenWatchPath("flows/notes/get.ts")).toBe(true);
819
+ expect(isClientRegenWatchPath("schema.ts")).toBe(true);
820
+ expect(isClientRegenWatchPath("db/schema/links.ts")).toBe(true);
821
+ expect(isClientRegenWatchPath("lib/util.ts")).toBe(false);
822
+ expect(isClientRegenWatchPath("flows/index.ts")).toBe(false);
823
+ expect(isClientRegenWatchPath("flows/notes/get.ts.tmp")).toBe(false);
824
+ expect(isClientRegenWatchPath("db/drizzle/schema.ts")).toBe(false);
825
+ });
826
+ });
827
+
828
+ describe("oke dev client regen watch", () => {
829
+ let session: DevSession | undefined;
830
+
831
+ afterEach(() => {
832
+ session?.stop();
833
+ session = undefined;
834
+ mock.restore();
835
+ });
836
+
837
+ test("three flow saves within 50ms regenerate the client once", async () => {
838
+ const dir = await mkdtemp(join(tmpdir(), "oke-dev-regen-"));
839
+ await mkdir(join(dir, "src"), { recursive: true });
840
+ await Bun.write(join(dir, "src/app.ts"), "export {};\n");
841
+
842
+ const extractMod = await import("../compiler/extract.ts");
843
+ const originalExtract = extractMod.extractManifest;
844
+ let extracts = 0;
845
+ mock.module(resolve(import.meta.dir, "../compiler/extract.ts"), () => ({
846
+ ...extractMod,
847
+ extractManifest: (opts: Parameters<typeof originalExtract>[0]) => {
848
+ extracts++;
849
+ return originalExtract(opts);
850
+ },
851
+ }));
852
+
853
+ let regens = 0;
854
+ let listener: Parameters<DevWatchFn>[2] | undefined;
855
+ const result = await runDev({
856
+ cwd: dir,
857
+ stdinIsTTY: false,
858
+ appPort: 0,
859
+ consolePort: 0,
860
+ mcpPort: 0,
861
+ ...stubSurfaces(),
862
+ serveConsole: async () => ({
863
+ port: 0,
864
+ stop() {},
865
+ console: {
866
+ state: createConsoleState({ cwd: dir, secret: SECRET }),
867
+ } as ConsoleAppHandle,
868
+ }),
869
+ startApp: async () => ({
870
+ port: 0,
871
+ url: new URL("http://127.0.0.1:9"),
872
+ stop() {},
873
+ }),
874
+ syncAdoptBarrel: async () => [],
875
+ regenClient: async () => {
876
+ regens++;
877
+ },
878
+ watchFs: (_path, _options, next) => {
879
+ listener = next;
880
+ return { close() {} };
881
+ },
882
+ });
883
+
884
+ expect(result.code).toBe(0);
885
+ session = result.session;
886
+ const boot = regens;
887
+ expect(boot).toBe(1);
888
+ const extractsAfterBoot = extracts;
889
+ expect(extractsAfterBoot).toBeGreaterThan(0);
890
+ if (!listener) throw new Error("watcher not started");
891
+
892
+ listener("change", "flows/notes/get.ts");
893
+ listener("change", "flows/notes/list.ts");
894
+ listener("change", "flows/notes/[id]/delete.ts");
895
+ await Bun.sleep(50);
896
+ expect(regens).toBe(boot);
897
+ expect(extracts).toBe(extractsAfterBoot);
898
+
899
+ await Bun.sleep(CLIENT_REGEN_DEBOUNCE_MS + 80);
900
+ expect(regens).toBe(boot + 1);
901
+ expect(extracts).toBe(extractsAfterBoot + 1);
902
+
903
+ listener("change", "lib/util.ts");
904
+ await Bun.sleep(CLIENT_REGEN_DEBOUNCE_MS + 80);
905
+ expect(regens).toBe(boot + 1);
906
+ });
907
+ });
908
+
806
909
  describe("oke dev Docker-first", () => {
807
910
  test("dryRun always plans compose (no mode ask)", async () => {
808
911
  const dir = await mkdtemp(join(tmpdir(), "oke-dev-always-compose-"));
package/src/cli/dev.ts CHANGED
@@ -1417,15 +1417,26 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
1417
1417
 
1418
1418
  const watchFs: DevWatchFn =
1419
1419
  options.watchFs ?? ((path, watchOptions, listener) => watch(path, watchOptions, listener));
1420
+ // Trailing timer, always re-armed. A burst resets the window and still
1421
+ // fires once after it goes quiet — a timestamp check that only `return`s
1422
+ // drops the save and never regenerates.
1423
+ const clientRegen = createDebouncedRunner(() => {
1424
+ void regen(appUrl);
1425
+ }, CLIENT_REGEN_DEBOUNCE_MS);
1426
+ const manifestRefresh = createDebouncedRunner(() => {
1427
+ void refreshManifestInto(consoleState);
1428
+ }, CLIENT_REGEN_DEBOUNCE_MS);
1420
1429
  const watcher = watchFs(resolve(cwd, "src"), { recursive: true }, (_event, filename) => {
1421
1430
  const rel = (filename?.toString() ?? "").replace(/\\/g, "/");
1422
1431
  if (isFlowsTreeWatchPath(rel)) {
1423
1432
  void syncAdoptBarrel(cwd);
1424
1433
  }
1425
- void regen(appUrl);
1434
+ if (isClientRegenWatchPath(rel)) {
1435
+ clientRegen.trigger();
1436
+ }
1426
1437
  // Only live-extract when the host did not pin a Manifest (tests).
1427
1438
  if (options.manifest === undefined) {
1428
- void refreshManifestInto(consoleState);
1439
+ manifestRefresh.trigger();
1429
1440
  }
1430
1441
  if (autoPushEnabled && isDomainSchemaWatchPath(filename?.toString())) {
1431
1442
  lastSchemaFilename = filename?.toString() ?? "schema.ts";
@@ -1444,6 +1455,8 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
1444
1455
  stopComposeHealthWatch = null;
1445
1456
  bootBoard.stop();
1446
1457
  autoPushRunner.cancel();
1458
+ clientRegen.cancel();
1459
+ manifestRefresh.cancel();
1447
1460
  watcher.close();
1448
1461
  const host = attachedHost;
1449
1462
  attachedHost = null;
@@ -1613,6 +1626,28 @@ export function isFlowsTreeWatchPath(rel: string): boolean {
1613
1626
  return true;
1614
1627
  }
1615
1628
 
1629
+ /** Quiet window before one `oke-client.d.ts` regen after a burst of saves. */
1630
+ export const CLIENT_REGEN_DEBOUNCE_MS = 200;
1631
+
1632
+ /**
1633
+ * True when a `src/` watcher event can change the typed client
1634
+ * (flow files, or a schema declare module those flows import).
1635
+ *
1636
+ * Editor temp files and the adopt barrel are excluded so a regen cannot
1637
+ * retrigger itself.
1638
+ *
1639
+ * @param rel - Watcher filename (POSIX or Windows)
1640
+ */
1641
+ export function isClientRegenWatchPath(rel: string): boolean {
1642
+ const posix = rel.replace(/\\/g, "/");
1643
+ if (posix.endsWith(".tmp")) return false;
1644
+ if (isFlowsTreeWatchPath(posix)) return true;
1645
+ if (/(^|\/)drizzle\//.test(posix)) return false;
1646
+ if (/(^|\/)schema(\.decl)?\.tsx?$/.test(posix)) return true;
1647
+ if (/(^|\/)schema\/.+\.tsx?$/.test(posix)) return true;
1648
+ return false;
1649
+ }
1650
+
1616
1651
  /**
1617
1652
  * Frame a schema-sync failure for the `oke dev` loop: warn loud for developer
1618
1653
  * bugs, soft-skip only known environmental gaps. Never crashes the session.
@@ -72,7 +72,7 @@ describe("oke stack", () => {
72
72
  const code = await runStackPreview({
73
73
  cwd: dir,
74
74
  images: {
75
- "channel.email": "axllent/mailpit:v1.31.1",
75
+ "channel.email": "axllent/mailpit:v1.31.2",
76
76
  },
77
77
  write: (t) => {
78
78
  out += t;
package/src/cli/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * Bare `oke` (TTY) → interactive Ink TUI. `oke <command>` runs directly.
6
6
  */
7
7
 
8
+ import type { VaultAuditConfig } from "../elements/vault/types.ts";
8
9
  import { aiCli } from "./ai.ts";
9
10
  import { branchCli } from "./branch.ts";
10
11
  import { buildCli } from "./build.ts";
@@ -28,6 +29,7 @@ import { stackCli } from "./stack.ts";
28
29
  import { startCli } from "./start.ts";
29
30
  import { testCli } from "./test.ts";
30
31
  import { upgradeCli } from "./upgrade.ts";
32
+ import { loadOkeConfig } from "./load-config.ts";
31
33
  import { vaultCli } from "./vault-cmd.ts";
32
34
 
33
35
  const rawArgv = process.argv.slice(2);
@@ -90,7 +92,13 @@ if (cmd === "client" && sub === "add") {
90
92
  }
91
93
 
92
94
  if (cmd === "vault") {
93
- process.exit(await vaultCli(sub ? [sub, ...rest] : rest));
95
+ let audit: VaultAuditConfig | undefined;
96
+ try {
97
+ audit = (await loadOkeConfig()).config.vault?.audit;
98
+ } catch {
99
+ audit = undefined;
100
+ }
101
+ process.exit(await vaultCli(sub ? [sub, ...rest] : rest, audit === undefined ? {} : { audit }));
94
102
  }
95
103
 
96
104
  if (cmd === "docker") {
@@ -18,19 +18,19 @@ describe("flattenImagesConfig", () => {
18
18
  sql: "postgres:18-alpine",
19
19
  kv: "redis:8-alpine",
20
20
  files: "rustfs/rustfs:1.0.0",
21
- index: "getmeili/meilisearch:v1.53",
21
+ index: "getmeili/meilisearch:v1.54",
22
22
  },
23
- channel: { email: "axllent/mailpit:v1.31.1" },
24
- pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57",
23
+ channel: { email: "axllent/mailpit:v1.31.2" },
24
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
25
25
  proxy: "caddy:2-alpine",
26
26
  }),
27
27
  ).toEqual({
28
28
  "store.sql": "postgres:18-alpine",
29
29
  "store.kv": "redis:8-alpine",
30
30
  "store.files": "rustfs/rustfs:1.0.0",
31
- "store.index": "getmeili/meilisearch:v1.53",
32
- "channel.email": "axllent/mailpit:v1.31.1",
33
- pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57",
31
+ "store.index": "getmeili/meilisearch:v1.54",
32
+ "channel.email": "axllent/mailpit:v1.31.2",
33
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
34
34
  proxy: "caddy:2-alpine",
35
35
  });
36
36
  });
@@ -53,7 +53,7 @@ describe("defaultImagesFromConfig", () => {
53
53
  });
54
54
  expect(images).toEqual({
55
55
  "store.sql": "postgres:18-alpine",
56
- pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57",
56
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
57
57
  "store.kv": "redis:8-alpine",
58
58
  });
59
59
  });
@@ -68,7 +68,7 @@ describe("defaultImagesFromConfig", () => {
68
68
  },
69
69
  });
70
70
  expect(images["store.sql"]).toBe("pgvector/pgvector:pg17");
71
- expect(images.pgdog).toBe("ghcr.io/pgdogdev/pgdog:v0.1.57");
71
+ expect(images.pgdog).toBe("ghcr.io/pgdogdev/pgdog:v0.1.59");
72
72
  });
73
73
 
74
74
  test("clock postgres alone pulls store.sql image", () => {
@@ -79,7 +79,7 @@ describe("defaultImagesFromConfig", () => {
79
79
  });
80
80
  expect(images).toEqual({
81
81
  "store.sql": "postgres:18-alpine",
82
- pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57",
82
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
83
83
  });
84
84
  });
85
85
 
@@ -91,7 +91,7 @@ describe("defaultImagesFromConfig", () => {
91
91
  });
92
92
  expect(images).toEqual({
93
93
  "store.sql": "postgres:18-alpine",
94
- pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57",
94
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
95
95
  });
96
96
  });
97
97
 
@@ -15,7 +15,7 @@ import type { Manifest } from "../manifest/types.ts";
15
15
  /** Default image pins when `images` is omitted but prod drivers need containers. */
16
16
  const DEFAULT_SQL_IMAGE = "postgres:18-alpine";
17
17
  const DEFAULT_PGVECTOR_IMAGE = "pgvector/pgvector:pg17";
18
- const DEFAULT_PGDOG_IMAGE = "ghcr.io/pgdogdev/pgdog:v0.1.57";
18
+ const DEFAULT_PGDOG_IMAGE = "ghcr.io/pgdogdev/pgdog:v0.1.59";
19
19
  const DEFAULT_KV_IMAGE = "redis:8-alpine";
20
20
 
21
21
  /**
@@ -282,6 +282,29 @@ describe("oke vault audit", () => {
282
282
  await h.close();
283
283
  }
284
284
  });
285
+
286
+ test("audit verify refuses a webhook sink", async () => {
287
+ const sql = createMemoryVaultSql();
288
+ const errors: string[] = [];
289
+ const original = console.error;
290
+ console.error = (...args: unknown[]) => {
291
+ errors.push(args.map((part) => String(part)).join(" "));
292
+ };
293
+ try {
294
+ const code = await vaultCli(["audit", "verify"], {
295
+ sql,
296
+ env: {},
297
+ stdinIsTTY: false,
298
+ audit: { sink: "webhook", webhookUrl: "https://audit.example/hook" },
299
+ write: () => undefined,
300
+ });
301
+ expect(code).toBe(1);
302
+ expect(errors.join("\n")).toContain('audit.sink "db"');
303
+ } finally {
304
+ console.error = original;
305
+ await sql.close();
306
+ }
307
+ });
285
308
  });
286
309
 
287
310
  describe("oke vault backup / restore", () => {
@@ -24,6 +24,7 @@ import type { SqlConnection } from "../drivers/types.ts";
24
24
  import { openBuiltinVaultAdapter, VAULT_MASTER_KEY_ENV } from "../drivers/vault-builtin.ts";
25
25
  import { parseDotenv, formatDotenv } from "../drivers/vault-dotenv-parse.ts";
26
26
  import type { BuiltinVaultAdapter } from "../elements/vault/builtin-adapter.ts";
27
+ import type { VaultAuditConfig } from "../elements/vault/types.ts";
27
28
  import { promptHidden, readStdinSecure } from "./vault-secure-input.ts";
28
29
 
29
30
  /** Env var for the *new* master key when resuming an interrupted `rotate-master`. */
@@ -70,6 +71,11 @@ export interface VaultCmdOptions {
70
71
  * closes a connection it did not open.
71
72
  */
72
73
  readonly sql?: SqlConnection;
74
+ /**
75
+ * `vault.audit` from `oke.config.ts`. The `oke` binary loads this; tests
76
+ * pass it directly. Omitted means the SQL hash chain.
77
+ */
78
+ readonly audit?: VaultAuditConfig;
73
79
  }
74
80
 
75
81
  /**
@@ -332,6 +338,7 @@ async function withVault<T>(
332
338
  masterKey,
333
339
  ...(flags.url === undefined ? {} : { url: flags.url }),
334
340
  ...(options.sql === undefined ? {} : { connection: options.sql }),
341
+ ...(options.audit === undefined ? {} : { audit: options.audit }),
335
342
  });
336
343
  try {
337
344
  return await run(opened.adapter);
@@ -12,7 +12,7 @@
12
12
  * Untriggered flows fall back to `POST /_oke/{unit}/{flow}` RPC.
13
13
  */
14
14
 
15
- import { createTransport, type Transport } from "./transport.ts";
15
+ import { createTransport, transportEnvelope, type Transport } from "./transport.ts";
16
16
  import { asThenableIterable, attachPager } from "./pager.ts";
17
17
  import {
18
18
  flattenLiveRoutes,
@@ -33,6 +33,7 @@ import type {
33
33
  LiveHandlers,
34
34
  ResolveApp,
35
35
  } from "./types.ts";
36
+ import { methodAndPath, walkContracts } from "./wire.ts";
36
37
 
37
38
  /** App-shaped value that carries a runtime `$routes` table from typed adopt. */
38
39
  export interface AppWithRoutes {
@@ -169,19 +170,11 @@ function mergeHeaders(base: ClientHeaders | undefined, over: ClientHeaders): Cli
169
170
  export function flattenRoutes(
170
171
  $routes: ClientRouteMap | undefined,
171
172
  ): ClientOptions["routes"] | undefined {
172
- if (!$routes) return undefined;
173
173
  const out: Record<string, { readonly method: string; readonly path: string }> = {};
174
- for (const [unit, flows] of Object.entries($routes)) {
175
- if (!flows || typeof flows !== "object") continue;
176
- for (const [flow, contract] of Object.entries(flows)) {
177
- if (!contract || typeof contract !== "object") continue;
178
- const method = "method" in contract ? contract.method : undefined;
179
- const path = "path" in contract ? contract.path : undefined;
180
- if (typeof method === "string" && typeof path === "string") {
181
- out[`${unit}.${flow}`] = { method, path };
182
- }
183
- }
184
- }
174
+ walkContracts($routes, (unit, flow, contract) => {
175
+ const route = methodAndPath(contract);
176
+ if (route) out[`${unit}.${flow}`] = route;
177
+ });
185
178
  return Object.keys(out).length > 0 ? out : undefined;
186
179
  }
187
180
 
@@ -196,25 +189,19 @@ function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): un
196
189
  if (path.length < 2) {
197
190
  const message = `Incomplete path: api.${path.join(".") || "?"}(…)`;
198
191
  return attachPager(
199
- {
200
- data: null,
201
- error: {
202
- code: "TransportError" as const,
203
- message,
204
- data: { message },
205
- },
206
- },
192
+ transportEnvelope(message),
207
193
  (nextInput) => invoke(nextInput, callOpts),
208
194
  input,
209
195
  );
210
196
  }
211
197
  const unit = path[0]!;
212
198
  const flow = path.slice(1).join(".");
213
- const result = await transport.call(`${unit}/${flow}`, input, {
214
- headers: ctx.perCallHeaders.read(),
215
- ...(callOpts?.response !== undefined ? { response: callOpts.response } : {}),
216
- ...(callOpts?.signal !== undefined ? { signal: callOpts.signal } : {}),
217
- });
199
+ const headers = ctx.perCallHeaders.read();
200
+ const result = await transport.call(
201
+ `${unit}/${flow}`,
202
+ input,
203
+ callOpts ? { headers, ...callOpts } : headers,
204
+ );
218
205
  return attachPager(result, (nextInput) => invoke(nextInput, callOpts), input);
219
206
  };
220
207
  const call = (a?: unknown, b?: unknown): unknown => {
@@ -351,11 +338,28 @@ function isCallOpts(value: unknown): value is CallOpts {
351
338
  if ("signal" in v) {
352
339
  return v.signal === undefined || v.signal instanceof AbortSignal;
353
340
  }
341
+ const keyOk =
342
+ "idempotencyKey" in v && (typeof v.idempotencyKey === "string" || v.idempotencyKey === false);
343
+ if (("retry" in v && typeof v.retry === "boolean") || keyOk) {
344
+ return Object.keys(v).every(
345
+ (k) => k === "response" || k === "signal" || k === "retry" || k === "idempotencyKey",
346
+ );
347
+ }
354
348
  return false;
355
349
  }
356
350
 
357
- /** Per-call options on a Flow invoke (binary decode / abort). */
351
+ /** Per-call options on a Flow invoke (binary decode, abort, retry opt-in). */
358
352
  export interface CallOpts {
359
353
  readonly response?: "json" | "blob" | "arrayBuffer";
360
354
  readonly signal?: AbortSignal;
355
+ /**
356
+ * Repeat this call under the client `retry` policy even when it sends
357
+ * no idempotency key.
358
+ */
359
+ readonly retry?: boolean;
360
+ /**
361
+ * Stable `Idempotency-Key`, or `false` to send none.
362
+ * See {@link ClientCallOpts.idempotencyKey}.
363
+ */
364
+ readonly idempotencyKey?: string | false;
361
365
  }