okengine 0.10.0 → 0.10.2

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 (105) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +2 -2
  4. package/site/content/docs/elements/flow.mdx +10 -10
  5. package/site/content/docs/elements/gate.mdx +4 -4
  6. package/site/content/docs/elements/signal.mdx +4 -7
  7. package/site/content/docs/elements/store.mdx +1 -1
  8. package/site/content/docs/elements/vault.mdx +1 -1
  9. package/site/content/docs/get-started/basic-usage.mdx +1 -3
  10. package/site/content/docs/get-started/introduction.mdx +1 -1
  11. package/site/content/docs/reference/cli.md +12 -12
  12. package/site/content/docs/reference/fx.mdx +3 -4
  13. package/src/auth/auth.test.ts +1 -2
  14. package/src/auth/bindings.ts +5 -15
  15. package/src/auth/gate-auth.test.ts +2 -2
  16. package/src/cli/ai-setup/apply.ts +13 -0
  17. package/src/cli/build.test.ts +67 -0
  18. package/src/cli/build.ts +34 -0
  19. package/src/cli/db-seed.ts +1 -1
  20. package/src/cli/dev-app-runner.ts +6 -0
  21. package/src/cli/dev-controls.test.ts +43 -48
  22. package/src/cli/dev-controls.ts +23 -172
  23. package/src/cli/dev.test.ts +2 -4
  24. package/src/cli/dev.ts +63 -16
  25. package/src/cli/docker-cli.test.ts +15 -0
  26. package/src/cli/start.ts +12 -1
  27. package/src/client/notes-contract.test.ts +3 -4
  28. package/src/compiler/aot.test.ts +1 -2
  29. package/src/compiler/effects-infer.ts +37 -8
  30. package/src/compiler/extract.test.ts +186 -6
  31. package/src/compiler/extract.ts +31 -6
  32. package/src/compiler/fixtures/raw/raw-unannotated.ts +2 -4
  33. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +4 -8
  34. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -2
  35. package/src/compiler/fixtures/skyport/src/flows/support/index.ts +1 -2
  36. package/src/compiler/fixtures/skyport.expected.json +3 -3
  37. package/src/compiler/fixtures/triggers/five-triggers.ts +5 -10
  38. package/src/compiler/generate-adopt.test.ts +85 -0
  39. package/src/compiler/generate-adopt.ts +85 -0
  40. package/src/console/server/dry-run.audit.test.ts +2 -2
  41. package/src/console/server/flows.ts +50 -150
  42. package/src/docker/compose.ts +13 -3
  43. package/src/docker/recipes/llama-cpp.ts +10 -1
  44. package/src/docker/stack-id.ts +1 -0
  45. package/src/elements/channel/declare.ts +27 -1
  46. package/src/elements/clock/chaos-child.ts +3 -6
  47. package/src/elements/clock/chaos.test.ts +1 -2
  48. package/src/elements/clock.test.ts +2 -4
  49. package/src/elements/signal/declare.ts +25 -1
  50. package/src/elements/store/cache.test.ts +28 -0
  51. package/src/elements/store/declare.ts +27 -1
  52. package/src/elements/store/resource.ts +6 -11
  53. package/src/elements/store/upsert-app.test.ts +1 -2
  54. package/src/elements/vault/declare.ts +29 -1
  55. package/src/kernel/adopt-barrel-fresh.test.ts +103 -0
  56. package/src/kernel/app.ts +80 -9
  57. package/src/kernel/auto-registry.test.ts +198 -0
  58. package/src/kernel/boot-bind/honor-config.test.ts +5 -5
  59. package/src/kernel/boot.test.ts +19 -15
  60. package/src/kernel/boot.ts +186 -8
  61. package/src/kernel/call.test.ts +5 -10
  62. package/src/kernel/compensate.test.ts +3 -6
  63. package/src/kernel/concurrency.test.ts +3 -5
  64. package/src/kernel/correlation.test.ts +4 -8
  65. package/src/kernel/edge.test.ts +1 -1
  66. package/src/kernel/effects-stamping.test.ts +204 -0
  67. package/src/kernel/element-registries.ts +27 -0
  68. package/src/kernel/errors.ts +22 -0
  69. package/src/kernel/flow.test.ts +8 -15
  70. package/src/kernel/flow.ts +12 -14
  71. package/src/kernel/fx.ts +42 -13
  72. package/src/kernel/hook-timing.test.ts +2 -2
  73. package/src/kernel/hooks.test.ts +5 -10
  74. package/src/kernel/horizontal-child.ts +5 -10
  75. package/src/kernel/journal-boot.test.ts +2 -4
  76. package/src/kernel/pipeline.test.ts +6 -12
  77. package/src/kernel/plugin/capabilities.test.ts +1 -1
  78. package/src/kernel/plugin/decorate.test.ts +3 -8
  79. package/src/kernel/plugin/scoping.test.ts +6 -16
  80. package/src/kernel/plugin-elements.test.ts +1 -1
  81. package/src/kernel/plugin-needs.test.ts +1 -1
  82. package/src/kernel/ready.test.ts +1 -2
  83. package/src/kernel/registry-isolation.test.ts +5 -5
  84. package/src/kernel/triggers.ts +1 -1
  85. package/src/manifest/sql-resource.ts +41 -0
  86. package/src/plugins/anonymous.ts +1 -3
  87. package/src/plugins/compression.test.ts +6 -8
  88. package/src/plugins/config-source.test.ts +1 -1
  89. package/src/plugins/config-source.ts +1 -2
  90. package/src/plugins/cors.test.ts +10 -10
  91. package/src/plugins/csrf.test.ts +2 -2
  92. package/src/plugins/headers.test.ts +14 -15
  93. package/src/plugins/ip-allowlist.test.ts +9 -9
  94. package/src/plugins/magic-link.ts +2 -6
  95. package/src/plugins/maintenance-mode.test.ts +6 -6
  96. package/src/plugins/otp.ts +5 -15
  97. package/src/plugins/passkey.ts +4 -12
  98. package/src/plugins/two-factor.ts +3 -9
  99. package/src/plugins/username.ts +2 -6
  100. package/src/release/measure.ts +1 -1
  101. package/src/runs/runs.test.ts +3 -5
  102. package/src/runtime/serve.test.ts +2 -4
  103. package/src/test/create-test-app.test.ts +3 -7
  104. package/src/test/provisions.integration.test.ts +4 -9
  105. package/src/test/reset-element-registries.ts +31 -0
@@ -128,6 +128,7 @@ export const STACK_CONTROL_KEYS = [
128
128
  "MP_SMTP_AUTH_ACCEPT_ANY",
129
129
  "MP_SMTP_AUTH_ALLOW_INSECURE",
130
130
  "OKE_AI_MODEL",
131
+ "OKE_AI_CTX_SIZE",
131
132
  "OKE_PROXY_HOST",
132
133
  "OKE_PROXY_ACME_EMAIL",
133
134
  // OpenBao app token — minted by bootstrap, must survive `writeDerivedFiles`.
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ChannelMedium } from "../../manifest/types.ts";
8
+ import { channelTemplateRegistry } from "../../kernel/element-registries.ts";
8
9
 
9
10
  /** Options for a medium binder (`channel.email`, …). */
10
11
  export interface ChannelMediumOptions {
@@ -49,6 +50,29 @@ export interface ChannelMediumBinder {
49
50
  ): ChannelTemplateDecl;
50
51
  }
51
52
 
53
+ /**
54
+ * Medium binders' `.template()` (email / sms / whatsapp / push) push into
55
+ * the shared {@link channelTemplateRegistry} (`src/kernel/element-registries.ts`)
56
+ * so {@link oke} can auto-populate `channel.templates` with zero explicit
57
+ * array — mirrors the {@link on} trigger-drain registry
58
+ * (`src/kernel/on.ts`). The medium-agnostic `channel.template()` is
59
+ * intentionally not auto-registered (out of scope).
60
+ *
61
+ * Snapshot of every medium-binder template declared since the last reset.
62
+ */
63
+ export function listChannelTemplates(): readonly ChannelTemplateDecl[] {
64
+ return channelTemplateRegistry.slice();
65
+ }
66
+
67
+ /**
68
+ * Clear the channel-template registry (tests / fresh app adopt).
69
+ *
70
+ * @internal
71
+ */
72
+ export function resetChannelTemplates(): void {
73
+ channelTemplateRegistry.length = 0;
74
+ }
75
+
52
76
  /**
53
77
  * Create a medium binder.
54
78
  *
@@ -64,7 +88,7 @@ function mediumBinder(
64
88
  medium,
65
89
  ...(from !== undefined ? { from } : {}),
66
90
  template(name, opts = {}) {
67
- return {
91
+ const decl: ChannelTemplateDecl = {
68
92
  kind: "template",
69
93
  name,
70
94
  medium,
@@ -73,6 +97,8 @@ function mediumBinder(
73
97
  ...(opts.schema !== undefined ? { schema: opts.schema } : {}),
74
98
  ...(from !== undefined ? { from } : {}),
75
99
  };
100
+ channelTemplateRegistry.push(decl);
101
+ return decl;
76
102
  },
77
103
  };
78
104
  }
@@ -169,8 +169,7 @@ if (mode === "tick-loop") {
169
169
  }
170
170
 
171
171
  const journalStore = createFileJournalStore(join(journalPath));
172
- const charge = flow({
173
- name: "chaos.charge",
172
+ const charge = flow("chaos.charge", {
174
173
  durable: true,
175
174
  do: async (_input, fx) => {
176
175
  const intent = await fx.step("create-intent", async () => {
@@ -329,8 +328,7 @@ async function logHasStep(path: string, step: string, instanceId: string): Promi
329
328
 
330
329
  /** Durable charge flow: step 1 → block → step 2 (both logged). */
331
330
  function chargeFlow(instanceId: string, stepLogPath: string, blockMs: number): AnyFlowDef {
332
- return flow({
333
- name: "chaos.journal.charge",
331
+ return flow("chaos.journal.charge", {
334
332
  durable: true,
335
333
  do: async (_input, fx) => {
336
334
  const intent = await fx.step("create-intent", async () => {
@@ -349,8 +347,7 @@ function chargeFlow(instanceId: string, stepLogPath: string, blockMs: number): A
349
347
 
350
348
  /** Durable sleeper flow: step 1 → sleep → confirm (steps logged). */
351
349
  function sleeperFlow(instanceId: string, stepLogPath: string, wakeMs: number): AnyFlowDef {
352
- return flow({
353
- name: "chaos.journal.sleeper",
350
+ return flow("chaos.journal.sleeper", {
354
351
  durable: true,
355
352
  do: async (_input, fx) => {
356
353
  await fx.step("create-intent", async () => {
@@ -173,8 +173,7 @@ describe("chaos — SIGKILL durable mid-step", () => {
173
173
 
174
174
  const calls: string[] = [];
175
175
  const journalStore = createFileJournalStore(journalPath);
176
- const charge = flow({
177
- name: "chaos.charge",
176
+ const charge = flow("chaos.charge", {
178
177
  durable: true,
179
178
  do: async (_input, fx) => {
180
179
  const intent = await fx.step("create-intent", () => {
@@ -67,8 +67,7 @@ describe("durable journal", () => {
67
67
  const tt = createTimeTravel(0);
68
68
  let crashAfterFirstStep = true;
69
69
 
70
- const charge = flow({
71
- name: "payments.charge",
70
+ const charge = flow("payments.charge", {
72
71
  durable: true,
73
72
  do: async (_input, fx) => {
74
73
  const intent = await fx.step("create-intent", () => {
@@ -118,8 +117,7 @@ describe("durable journal", () => {
118
117
  const tt = createTimeTravel(0);
119
118
 
120
119
  try {
121
- const sleepy = flow({
122
- name: "trial.wait",
120
+ const sleepy = flow("trial.wait", {
123
121
  durable: true,
124
122
  do: async (_input, fx) => {
125
123
  await fx.step("start", () => "begun");
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { SignalDelivery } from "../../manifest/types.ts";
9
+ import { signalRegistry } from "../../kernel/element-registries.ts";
9
10
 
10
11
  /** Options for {@link signal}. */
11
12
  export interface SignalOptions {
@@ -54,6 +55,27 @@ export interface SignalDecl<T = unknown> {
54
55
  readonly _payload?: T;
55
56
  }
56
57
 
58
+ /**
59
+ * `signal()` pushes into the shared {@link signalRegistry}
60
+ * (`src/kernel/element-registries.ts`) so {@link oke} can auto-populate
61
+ * `signals` with zero explicit array — mirrors the {@link on} trigger-drain
62
+ * registry (`src/kernel/on.ts`).
63
+ *
64
+ * Snapshot of every signal declared since the last reset.
65
+ */
66
+ export function listSignals(): readonly SignalDecl[] {
67
+ return signalRegistry.slice();
68
+ }
69
+
70
+ /**
71
+ * Clear the signal registry (tests / fresh app adopt).
72
+ *
73
+ * @internal
74
+ */
75
+ export function resetSignals(): void {
76
+ signalRegistry.length = 0;
77
+ }
78
+
57
79
  /**
58
80
  * Declare a signal. `delivery` is mandatory — omitting it is a type error.
59
81
  *
@@ -68,7 +90,7 @@ export function signal<T = unknown>(name: string, options: SignalOptions): Signa
68
90
  ) {
69
91
  throw new TypeError(`signal("${name}"): delivery is mandatory (once | broadcast | live)`);
70
92
  }
71
- return {
93
+ const decl: SignalDecl<T> = {
72
94
  name,
73
95
  delivery: options.delivery,
74
96
  ...(options.description !== undefined ? { description: options.description } : {}),
@@ -77,4 +99,6 @@ export function signal<T = unknown>(name: string, options: SignalOptions): Signa
77
99
  schema: options.schema,
78
100
  optional: options.optional ?? false,
79
101
  };
102
+ signalRegistry.push(decl as SignalDecl);
103
+ return decl;
80
104
  }
@@ -43,4 +43,32 @@ describe("tier-1 cache — exact per-resource invalidation (path b)", () => {
43
43
  expect(cache.invalidateFromEffects({ writes: ["sql:links"] }).keys).toEqual(keys);
44
44
  expect(cache.get(keys[0]!)).toBeUndefined();
45
45
  });
46
+
47
+ test("write to table A does not invalidate a cached read of table B in the same sql store", () => {
48
+ // Two tables under one `store.sql("app", …)` — the exact shape Direction
49
+ // B's per-table kernel resolution (fx.ts `gatedTable`) now produces:
50
+ // `sql:notes` / `sql:orders`, not the old coarse `sql:app` for both.
51
+ // This precision was already correct in cache.ts before Direction B —
52
+ // it simply never received per-table refs to prove it with. Confirmed
53
+ // here with the exact naming the kernel now emits.
54
+ const cache = createStoreCache();
55
+ const notesRead: Effects = { reads: ["sql:notes"] };
56
+ const keys = tier1KeysForReads(notesRead);
57
+
58
+ cache.set({
59
+ tier: 1,
60
+ key: keys[0]!,
61
+ value: [{ id: "n1" }],
62
+ resources: ["sql:notes"],
63
+ expiresAt: null,
64
+ });
65
+
66
+ // A write to "orders" — a different table, same store — must not touch it.
67
+ expect(cache.invalidateFromEffects({ writes: ["sql:orders"] }).keys).toEqual([]);
68
+ expect(cache.get(keys[0]!)).toBeDefined();
69
+
70
+ // A write to "notes" itself still invalidates.
71
+ expect(cache.invalidateFromEffects({ writes: ["sql:notes"] }).keys).toEqual(keys);
72
+ expect(cache.get(keys[0]!)).toBeUndefined();
73
+ });
46
74
  });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ColumnClassification, ResourceRef, StoreFacet } from "../../manifest/types.ts";
8
+ import { storeRegistry } from "../../kernel/element-registries.ts";
8
9
  import { resource } from "./resource.ts";
9
10
  import { schema } from "./schema-decl.ts";
10
11
  import { resolveTableName, type TableHandle } from "./table.ts";
@@ -114,6 +115,28 @@ export interface IndexStoreDecl extends StoreDeclBase {
114
115
  /** Any store declaration. */
115
116
  export type StoreDecl = SqlStoreDecl | KvStoreDecl | FilesStoreDecl | IndexStoreDecl;
116
117
 
118
+ /**
119
+ * `store.sql` / `store.files` push into the shared {@link storeRegistry}
120
+ * (`src/kernel/element-registries.ts`) so {@link oke} can auto-populate
121
+ * `stores` with zero explicit array — mirrors the {@link on} trigger-drain
122
+ * registry (`src/kernel/on.ts`). `store.kv` / `store.index` are
123
+ * intentionally not auto-registered (out of scope).
124
+ *
125
+ * Snapshot of every `store.sql` / `store.files` declared since the last reset.
126
+ */
127
+ export function listStores(): readonly StoreDecl[] {
128
+ return storeRegistry.slice();
129
+ }
130
+
131
+ /**
132
+ * Clear the store registry (tests / fresh app adopt).
133
+ *
134
+ * @internal
135
+ */
136
+ export function resetStores(): void {
137
+ storeRegistry.length = 0;
138
+ }
139
+
117
140
  /**
118
141
  * Declare a SQL store.
119
142
  *
@@ -140,6 +163,7 @@ export function sql(name: string, options: SqlStoreOptions = {}): SqlStoreDecl {
140
163
  };
141
164
  },
142
165
  };
166
+ storeRegistry.push(decl);
143
167
  return decl;
144
168
  }
145
169
 
@@ -165,12 +189,14 @@ export function kv(name: string, options: KvStoreOptions = {}): KvStoreDecl {
165
189
  * @param options - Options
166
190
  */
167
191
  export function files(name: string, options: FilesStoreOptions = {}): FilesStoreDecl {
168
- return {
192
+ const decl: FilesStoreDecl = {
169
193
  facet: "files",
170
194
  name,
171
195
  ref: `files:${name}`,
172
196
  ...(options.description !== undefined ? { description: options.description } : {}),
173
197
  };
198
+ storeRegistry.push(decl);
199
+ return decl;
174
200
  }
175
201
 
176
202
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `store.resource(db, table, opts)` — a CRUD + list resource factory.
3
3
  *
4
- * Declarative sugar, never new physics: every op is an ordinary `flow({…})`
4
+ * Declarative sugar, never new physics: every op is an ordinary `flow(name, {…})`
5
5
  * whose body composes `fx.store(db)` (select/insert/update/findById/delete +
6
6
  * `page`/`count`) through the existing Drizzle-condition compiler. Wire
7
7
  * binding stays in `on(http.resource(path, resource.all()))` — the factory
@@ -649,8 +649,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
649
649
  return { data, meta };
650
650
  }
651
651
 
652
- const listFlow = flow({
653
- name: "list",
652
+ const listFlow = flow("list", {
654
653
  unit,
655
654
  ...(breaking ? { breaking: true as const } : {}),
656
655
  // Loose record so the HTTP AoT infers `query` and lets every list URL
@@ -664,8 +663,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
664
663
  },
665
664
  });
666
665
 
667
- const createFlow = flow({
668
- name: "create",
666
+ const createFlow = flow("create", {
669
667
  unit,
670
668
  ...(breaking ? { breaking: true as const } : {}),
671
669
  in: options.in as never,
@@ -682,8 +680,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
682
680
  },
683
681
  });
684
682
 
685
- const getFlow = flow({
686
- name: "get",
683
+ const getFlow = flow("get", {
687
684
  unit,
688
685
  ...(breaking ? { breaking: true as const } : {}),
689
686
  errors,
@@ -709,8 +706,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
709
706
  : patchSchema instanceof z.ZodObject
710
707
  ? patchSchema.extend({ [idKey]: z.string() })
711
708
  : patchSchema;
712
- const updateFlow = flow({
713
- name: "update",
709
+ const updateFlow = flow("update", {
714
710
  unit,
715
711
  ...(breaking ? { breaking: true as const } : {}),
716
712
  in: updateIn as never,
@@ -736,8 +732,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
736
732
  },
737
733
  });
738
734
 
739
- const removeFlow = flow({
740
- name: "remove",
735
+ const removeFlow = flow("remove", {
741
736
  unit,
742
737
  ...(breaking ? { breaking: true as const } : {}),
743
738
  errors,
@@ -46,8 +46,7 @@ describe("fx.store(db).upsert via app.fetch (real boot)", () => {
46
46
 
47
47
  const seed = on(
48
48
  http.post("/seed").gate(gate.public),
49
- flow({
50
- name: "notes.seed",
49
+ flow("notes.seed", {
51
50
  in: UpsertIn,
52
51
  out: UpsertOut,
53
52
  effects: { writes: ["sql:app"], reads: ["sql:app"] },
@@ -4,6 +4,8 @@
4
4
  * Physics: secrets · config · environment.
5
5
  */
6
6
 
7
+ import { secretRegistry } from "../../kernel/element-registries.ts";
8
+
7
9
  /** Options for {@link vault} / {@link vault.secret} / {@link vault.config}. */
8
10
  export interface VaultSecretOptions {
9
11
  /** Human description shown in boot-gap listings. */
@@ -35,6 +37,30 @@ export interface VaultSecretDecl {
35
37
  readonly sensitive: boolean;
36
38
  }
37
39
 
40
+ /**
41
+ * `vault.secret` (only) pushes into the shared {@link secretRegistry}
42
+ * (`src/kernel/element-registries.ts`) so {@link oke} can auto-populate
43
+ * `secrets` with zero explicit array — mirrors the {@link on} trigger-drain
44
+ * registry (`src/kernel/on.ts`). `vault.config` is intentionally not
45
+ * auto-registered (out of scope).
46
+ */
47
+
48
+ /**
49
+ * Snapshot of every `vault.secret` declared since the last reset.
50
+ */
51
+ export function listSecrets(): readonly VaultSecretDecl[] {
52
+ return secretRegistry.slice();
53
+ }
54
+
55
+ /**
56
+ * Clear the vault-secret registry (tests / fresh app adopt).
57
+ *
58
+ * @internal
59
+ */
60
+ export function resetSecrets(): void {
61
+ secretRegistry.length = 0;
62
+ }
63
+
38
64
  /**
39
65
  * Declare a vault secret contract (fingerprinted — never revealed).
40
66
  *
@@ -45,7 +71,7 @@ function declareSecret(name: string, options: VaultSecretOptions = {}): VaultSec
45
71
  if (!name) {
46
72
  throw new TypeError("vault.secret: name is required");
47
73
  }
48
- return {
74
+ const decl: VaultSecretDecl = {
49
75
  kind: "secret",
50
76
  name,
51
77
  sensitive: options.sensitive ?? true,
@@ -54,6 +80,8 @@ function declareSecret(name: string, options: VaultSecretOptions = {}): VaultSec
54
80
  ...(options.schema !== undefined ? { schema: options.schema } : {}),
55
81
  ...(options.dev !== undefined ? { dev: options.dev } : {}),
56
82
  };
83
+ secretRegistry.push(decl);
84
+ return decl;
57
85
  }
58
86
 
59
87
  /**
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Boot-level proof: a `src/flows/<unit>` folder on disk with no adopted
3
+ * flow under that unit — the stale/missing `.adopt()` barrel case.
4
+ *
5
+ * Mirrors `effects-stamping.test.ts`'s OKE1008 shape exactly: opt-in only
6
+ * via `rootDir`, `local`/`test` warn once (dev-loop stays unbroken),
7
+ * `docker`/`prod` hard-fail (`OKE1009`) — never a silently-incomplete route
8
+ * table in a deploy-shaped environment.
9
+ */
10
+
11
+ import { afterEach, describe, expect, test } from "bun:test";
12
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { gate } from "../elements/gate.ts";
16
+ import { assertAdoptBarrelFresh, resetStaleAdoptBarrelWarnForTests } from "./boot.ts";
17
+ import { flow, resetFlowSeq } from "./flow.ts";
18
+ import { on, resetBindings } from "./on.ts";
19
+ import { http } from "./triggers.ts";
20
+ import { oke } from "./app.ts";
21
+
22
+ afterEach(() => {
23
+ resetBindings();
24
+ resetFlowSeq();
25
+ resetStaleAdoptBarrelWarnForTests();
26
+ });
27
+
28
+ async function makeUnitDir(root: string, unit: string): Promise<void> {
29
+ const dir = join(root, "src/flows", unit);
30
+ await mkdir(dir, { recursive: true });
31
+ await writeFile(join(dir, "index.ts"), "export const nothing = 1;\n");
32
+ }
33
+
34
+ describe("assertAdoptBarrelFresh — direct", () => {
35
+ test("no rootDir: no-op, never reads the filesystem", async () => {
36
+ await expect(assertAdoptBarrelFresh([], "prod", undefined)).resolves.toBeUndefined();
37
+ });
38
+
39
+ test("fresh: every disk unit has an adopted flow — no warn, no throw, any env", async () => {
40
+ const root = await mkdtemp(join(tmpdir(), "oke-adopt-fresh-"));
41
+ try {
42
+ await makeUnitDir(root, "notes");
43
+ const notesFlow = flow("notes.create", { do: () => ({ ok: true as const }) });
44
+ await expect(assertAdoptBarrelFresh([notesFlow], "docker", root)).resolves.toBeUndefined();
45
+ } finally {
46
+ await rm(root, { recursive: true, force: true });
47
+ }
48
+ });
49
+
50
+ test("stale: unit on disk, zero adopted flows for it — docker hard-fails OKE1009", async () => {
51
+ const root = await mkdtemp(join(tmpdir(), "oke-adopt-fresh-"));
52
+ try {
53
+ await makeUnitDir(root, "notes");
54
+ await expect(assertAdoptBarrelFresh([], "docker", root)).rejects.toThrow(/OKE1009/);
55
+ } finally {
56
+ await rm(root, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ test("stale: prod — same posture as docker", async () => {
61
+ const root = await mkdtemp(join(tmpdir(), "oke-adopt-fresh-"));
62
+ try {
63
+ await makeUnitDir(root, "notes");
64
+ await expect(assertAdoptBarrelFresh([], "prod", root)).rejects.toThrow(/OKE1009/);
65
+ } finally {
66
+ await rm(root, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ test("stale: local — warns, never throws (dev-loop stays unbroken)", async () => {
71
+ const root = await mkdtemp(join(tmpdir(), "oke-adopt-fresh-"));
72
+ try {
73
+ await makeUnitDir(root, "notes");
74
+ await expect(assertAdoptBarrelFresh([], "local", root)).resolves.toBeUndefined();
75
+ } finally {
76
+ await rm(root, { recursive: true, force: true });
77
+ }
78
+ });
79
+ });
80
+
81
+ describe("boot-level: stale barrel through a real oke() boot", () => {
82
+ test("docker: real app.boot() hard-fails OKE1009 when a unit folder was never adopted", async () => {
83
+ const root = await mkdtemp(join(tmpdir(), "oke-adopt-fresh-"));
84
+ try {
85
+ // "notes" exists on disk but this app only ever adopted "main" —
86
+ // exactly what a stale `src/flows/generated.ts` after adding a unit
87
+ // (and forgetting to rerun `oke dev` / `oke build`) looks like.
88
+ await makeUnitDir(root, "notes");
89
+ const mainFlow = flow("main.health", {
90
+ effects: {},
91
+ do: () => ({ ok: true as const }),
92
+ });
93
+ on(http.get("/health").gate(gate.public), mainFlow);
94
+ const app = oke({ name: "stale-barrel", gate: { unguardedHttp: "allow" } });
95
+
96
+ await expect(
97
+ app.boot({ env: "docker", rootDir: root, unguardedHttp: "allow", startScheduler: false }),
98
+ ).rejects.toThrow(/OKE1009/);
99
+ } finally {
100
+ await rm(root, { recursive: true, force: true });
101
+ }
102
+ });
103
+ });
package/src/kernel/app.ts CHANGED
@@ -83,6 +83,12 @@ import {
83
83
  import { releaseInstanceLeases } from "./graceful-shutdown.ts";
84
84
  import type { JournalRuntime } from "./boot-bind/journal.ts";
85
85
  import { listBindings, resetBindings, type Binding } from "./on.ts";
86
+ import {
87
+ channelTemplateRegistry,
88
+ secretRegistry,
89
+ signalRegistry,
90
+ storeRegistry,
91
+ } from "./element-registries.ts";
86
92
  import {
87
93
  applyPrincipal,
88
94
  createElementPipelineHooks,
@@ -162,6 +168,18 @@ export interface OkeOptions {
162
168
  readonly docker?: BootOptions["docker"];
163
169
  /** Optional `oke.config.ts` document consumed at boot. */
164
170
  readonly config?: BootOptions["config"];
171
+ /**
172
+ * Compiled Manifest for flows with no hand-declared `effects` — highest
173
+ * priority source for the boot-time capability stamp (see
174
+ * {@link BootOptions.manifest}).
175
+ */
176
+ readonly manifest?: BootOptions["manifest"];
177
+ /**
178
+ * Project root for a lazy, best-effort effects extraction when a flow has
179
+ * no hand-declared `effects` and no {@link manifest} was given (see
180
+ * {@link BootOptions.rootDir}).
181
+ */
182
+ readonly rootDir?: BootOptions["rootDir"];
165
183
  /** Pre-built element runtimes (skip construction at boot when present). */
166
184
  readonly elements?: BootOptions["elements"];
167
185
  /** Vault create options (chain / allowDevFallbacks / secrets). */
@@ -430,6 +448,21 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
430
448
  readonly $options: OkeOptions;
431
449
  }
432
450
 
451
+ /**
452
+ * Concat two decl lists, skipping registry entries already present by
453
+ * reference (the same call-site object explicitly re-passed).
454
+ *
455
+ * @param explicit - Hand-passed decls (kept in order, always wins position)
456
+ * @param fromRegistry - Auto-drained decls to append
457
+ */
458
+ function mergeUnique<T>(explicit: readonly T[] | undefined, fromRegistry: readonly T[]): T[] {
459
+ const out: T[] = [...(explicit ?? [])];
460
+ for (const item of fromRegistry) {
461
+ if (!out.includes(item)) out.push(item);
462
+ }
463
+ return out;
464
+ }
465
+
433
466
  /**
434
467
  * Create an application. Adopts bindings registered via {@link on}.
435
468
  *
@@ -444,6 +477,42 @@ export function oke(options: OkeOptions): OkeApp {
444
477
  : [...listBindings(), ...(options.bindings ?? [])];
445
478
  if (registry === "consume") resetBindings();
446
479
 
480
+ // Same registry mode drains store.sql/store.files, vault.secret, signal(),
481
+ // and channel.<medium>().template() — module-evaluation registries that
482
+ // mirror `on`'s trigger drain (`listBindings`/`resetBindings` above).
483
+ // Explicit `options.stores` / `secrets` / `signals` / `channel.templates`
484
+ // are additive, never silently ignored — deduped by reference so an
485
+ // explicitly-passed decl that is also in the registry is not doubled.
486
+ const registrySnapshot =
487
+ registry === "ignore"
488
+ ? { stores: [], secrets: [], signals: [], channelTemplates: [] }
489
+ : {
490
+ stores: storeRegistry.slice(),
491
+ secrets: secretRegistry.slice(),
492
+ signals: signalRegistry.slice(),
493
+ channelTemplates: channelTemplateRegistry.slice(),
494
+ };
495
+ if (registry === "consume") {
496
+ storeRegistry.length = 0;
497
+ secretRegistry.length = 0;
498
+ signalRegistry.length = 0;
499
+ channelTemplateRegistry.length = 0;
500
+ }
501
+ const effectiveStores = mergeUnique(options.stores, registrySnapshot.stores);
502
+ const effectiveSecrets = mergeUnique(options.secrets, registrySnapshot.secrets);
503
+ const effectiveSignals = mergeUnique(options.signals, registrySnapshot.signals);
504
+ // Stay `undefined` (never a defined-but-empty object) when neither an
505
+ // explicit `options.channel` nor a registered template exists — a defined
506
+ // object here would flip `resolveElementNeeds`'s `channel` need to `true`
507
+ // for every app, whether or not it uses channel at all.
508
+ const effectiveChannel: BootOptions["channel"] =
509
+ options.channel === undefined && registrySnapshot.channelTemplates.length === 0
510
+ ? undefined
511
+ : {
512
+ ...(options.channel ?? {}),
513
+ templates: mergeUnique(options.channel?.templates, registrySnapshot.channelTemplates),
514
+ };
515
+
447
516
  // Resolve Gate bag early so auth HTTP Bindings join `adopted` + the router
448
517
  // before posture audit (same ensureBoot → doBoot path — never a side channel).
449
518
  const gateConfig: ResolvedGateConfig = resolveGateConfig({
@@ -760,13 +829,13 @@ export function oke(options: OkeOptions): OkeApp {
760
829
  docker: overrides?.docker ?? options.docker,
761
830
  config: overrides?.config ?? options.config,
762
831
  elements: overrides?.elements ?? options.elements,
763
- secrets: overrides?.secrets ?? options.secrets,
832
+ secrets: overrides?.secrets ?? effectiveSecrets,
764
833
  vault: overrides?.vault ?? options.vault,
765
834
  gates: [...baseGates, ...authGates],
766
- signals: overrides?.signals ?? options.signals,
835
+ signals: overrides?.signals ?? effectiveSignals,
767
836
  clocks: overrides?.clocks ?? options.clocks,
768
- stores: overrides?.stores ?? options.stores,
769
- channel: overrides?.channel ?? options.channel,
837
+ stores: overrides?.stores ?? effectiveStores,
838
+ channel: overrides?.channel ?? effectiveChannel,
770
839
  ai: overrides?.ai ?? options.ai,
771
840
  runs: overrides?.runs ?? options.runs,
772
841
  bindings: adopted,
@@ -778,7 +847,7 @@ export function oke(options: OkeOptions): OkeApp {
778
847
  if (d.startsWith("driver:")) driverIds.push(d.slice("driver:".length));
779
848
  }
780
849
  }
781
- const stores = overrides?.stores ?? options.stores ?? [];
850
+ const stores = overrides?.stores ?? effectiveStores;
782
851
  const available = buildAvailableNeedTokens({
783
852
  elements: {
784
853
  storeSql:
@@ -817,16 +886,18 @@ export function oke(options: OkeOptions): OkeApp {
817
886
  if (gateConfig.auth) available.add("auth");
818
887
  assertPluginNeeds(caps, { pluginNames, available });
819
888
 
820
- const baseSecrets = overrides?.secrets ?? options.secrets ?? [];
821
- const baseSignals = overrides?.signals ?? options.signals ?? [];
889
+ const baseSecrets = overrides?.secrets ?? effectiveSecrets;
890
+ const baseSignals = overrides?.signals ?? effectiveSignals;
822
891
  const baseClocks = overrides?.clocks ?? options.clocks ?? [];
823
- const baseChannel = overrides?.channel ?? options.channel;
892
+ const baseChannel = overrides?.channel ?? effectiveChannel;
824
893
  const mergedCatalog = mergeTemplateCatalogs(baseChannel?.catalog, ...pluginChannelCatalogs);
825
894
 
826
895
  const merged: BootOptions = {
827
896
  env: bootEnv,
828
897
  docker: overrides?.docker ?? options.docker,
829
898
  config: overrides?.config ?? options.config,
899
+ manifest: overrides?.manifest ?? options.manifest,
900
+ rootDir: overrides?.rootDir ?? options.rootDir,
830
901
  elements: overrides?.elements ?? options.elements,
831
902
  secrets: [...baseSecrets, ...pluginSecrets],
832
903
  vault: overrides?.vault ?? options.vault,
@@ -834,7 +905,7 @@ export function oke(options: OkeOptions): OkeApp {
834
905
  unguardedHttp,
835
906
  signals: [...baseSignals, ...pluginSignals],
836
907
  clocks: [...baseClocks, ...pluginClocks],
837
- stores: overrides?.stores ?? options.stores,
908
+ stores: overrides?.stores ?? effectiveStores,
838
909
  channel: {
839
910
  ...(baseChannel ?? {}),
840
911
  templates: [...(baseChannel?.templates ?? []), ...pluginChannelTemplates],