okengine 0.10.2 → 0.10.3

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 (57) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +0 -1
  3. package/site/content/docs/elements/ai.mdx +0 -6
  4. package/site/content/docs/elements/channel.mdx +0 -6
  5. package/site/content/docs/elements/clock.mdx +0 -6
  6. package/site/content/docs/elements/flow.mdx +0 -7
  7. package/site/content/docs/elements/gate.mdx +0 -6
  8. package/site/content/docs/elements/signal.mdx +0 -6
  9. package/site/content/docs/elements/store.mdx +8 -15
  10. package/site/content/docs/elements/vault.mdx +0 -6
  11. package/site/content/docs/get-started/basic-usage.mdx +0 -6
  12. package/site/content/docs/get-started/why.mdx +1 -2
  13. package/site/content/docs/index.mdx +0 -1
  14. package/site/content/docs/meta.json +0 -1
  15. package/site/content/docs/reference/fx.mdx +3 -3
  16. package/src/cli/ai-setup/ai-setup.test.ts +3 -1
  17. package/src/cli/build.ts +7 -8
  18. package/src/cli/dev.test.ts +88 -1
  19. package/src/cli/dev.ts +15 -13
  20. package/src/cli/doctor.ts +98 -5
  21. package/src/cli/load-config.images.test.ts +31 -1
  22. package/src/cli/load-config.ts +2 -2
  23. package/src/cli/safe-defaults.test.ts +1 -1
  24. package/src/compiler/extract.ts +12 -0
  25. package/src/compiler/fixtures/skyport/oke.config.ts +4 -2
  26. package/src/compiler/generate-adopt.ts +27 -1
  27. package/src/config/driver-defaults.test.ts +122 -0
  28. package/src/config/driver-defaults.ts +186 -0
  29. package/src/config/index.ts +97 -7
  30. package/src/docker/images-config.test.ts +168 -0
  31. package/src/kernel/boot-bind/channel.ts +12 -4
  32. package/src/kernel/boot-bind/clock.ts +3 -3
  33. package/src/kernel/boot-bind/gate.ts +7 -4
  34. package/src/kernel/boot-bind/journal.ts +3 -3
  35. package/src/kernel/boot-bind/signal.ts +3 -1
  36. package/src/kernel/boot-bind/store.test.ts +19 -0
  37. package/src/kernel/boot-bind/store.ts +24 -9
  38. package/src/kernel/boot-bind/vault.ts +3 -3
  39. package/site/content/docs/console/access.mdx +0 -29
  40. package/site/content/docs/console/ai.mdx +0 -35
  41. package/site/content/docs/console/architecture.mdx +0 -35
  42. package/site/content/docs/console/channels.mdx +0 -37
  43. package/site/content/docs/console/clock.mdx +0 -51
  44. package/site/content/docs/console/flows.mdx +0 -31
  45. package/site/content/docs/console/gates.mdx +0 -73
  46. package/site/content/docs/console/index.mdx +0 -54
  47. package/site/content/docs/console/manifest-diff.mdx +0 -34
  48. package/site/content/docs/console/meta.json +0 -24
  49. package/site/content/docs/console/overview.mdx +0 -40
  50. package/site/content/docs/console/plugins.mdx +0 -41
  51. package/site/content/docs/console/privacy.mdx +0 -32
  52. package/site/content/docs/console/runs.mdx +0 -50
  53. package/site/content/docs/console/signals.mdx +0 -40
  54. package/site/content/docs/console/store.mdx +0 -32
  55. package/site/content/docs/console/tenancy.mdx +0 -32
  56. package/site/content/docs/console/traces.mdx +0 -38
  57. package/site/content/docs/console/vault.mdx +0 -41
@@ -3,12 +3,42 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
+ import { flattenImagesConfig } from "../config/index.ts";
6
7
  import {
7
8
  defaultImagesFromConfig,
8
9
  resolveImages,
9
10
  dockerDevDriverMismatches,
10
11
  } from "./load-config.ts";
11
12
 
13
+ describe("flattenImagesConfig", () => {
14
+ test("flattens store/channel nesting to dotted keys; flat roles pass through", () => {
15
+ expect(
16
+ flattenImagesConfig({
17
+ store: { sql: "postgres:18-alpine", kv: "redis:8-alpine", files: "rustfs/rustfs:1.0.0" },
18
+ channel: { email: "axllent/mailpit:v1.22.3" },
19
+ vault: "openbao/openbao:2.6.1",
20
+ ai: "ollama/ollama:0.32.6",
21
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
22
+ proxy: "caddy:2-alpine",
23
+ }),
24
+ ).toEqual({
25
+ "store.sql": "postgres:18-alpine",
26
+ "store.kv": "redis:8-alpine",
27
+ "store.files": "rustfs/rustfs:1.0.0",
28
+ "channel.email": "axllent/mailpit:v1.22.3",
29
+ vault: "openbao/openbao:2.6.1",
30
+ ai: "ollama/ollama:0.32.6",
31
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
32
+ proxy: "caddy:2-alpine",
33
+ });
34
+ });
35
+
36
+ test("undefined / empty input yields empty map", () => {
37
+ expect(flattenImagesConfig(undefined)).toEqual({});
38
+ expect(flattenImagesConfig({})).toEqual({});
39
+ });
40
+ });
41
+
12
42
  describe("defaultImagesFromConfig", () => {
13
43
  test("maps postgres + redis prod drivers", () => {
14
44
  const images = defaultImagesFromConfig({
@@ -82,7 +112,7 @@ describe("resolveImages", () => {
82
112
  kv: { prod: "redis" },
83
113
  },
84
114
  },
85
- images: { "store.sql": "pgvector/pgvector:pg17" },
115
+ images: { store: { sql: "pgvector/pgvector:pg17" } },
86
116
  });
87
117
  expect(images).toEqual({ "store.sql": "pgvector/pgvector:pg17" });
88
118
  });
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { resolve } from "node:path";
6
- import type { DriverRef, OkeConfig } from "../config/index.ts";
6
+ import { flattenImagesConfig, type DriverRef, type OkeConfig } from "../config/index.ts";
7
7
  import type { Manifest } from "../manifest/types.ts";
8
8
 
9
9
  /** Default image pins when `images` is omitted but prod drivers need containers. */
@@ -145,7 +145,7 @@ export function resolveImages(
145
145
  config?: OkeConfig,
146
146
  manifest?: Manifest,
147
147
  ): Readonly<Record<string, string>> {
148
- const explicit = config?.images ?? manifest?.images;
148
+ const explicit = config?.images ? flattenImagesConfig(config.images) : manifest?.images;
149
149
  if (explicit && Object.keys(explicit).length > 0) return explicit;
150
150
  if (config) return defaultImagesFromConfig(config);
151
151
  return {};
@@ -16,7 +16,7 @@ describe("oke safe-default overrides", () => {
16
16
  const dir = await mkdtemp(join(tmpdir(), "oke-safe-docker-"));
17
17
  await Bun.write(
18
18
  join(dir, "oke.config.ts"),
19
- `export default { images: { "store.sql": "postgres:18-alpine" } }\n`,
19
+ `export default { images: { store: { sql: "postgres:18-alpine" } } }\n`,
20
20
  );
21
21
  // Without --prod, derive still runs but prod overlays are not requested.
22
22
  // We assert the parser default by calling runDockerDerive via dockerCli
@@ -1022,8 +1022,20 @@ function collectConfig(opts: AstNode | undefined, scope: ProjectScope): void {
1022
1022
  const images = objectProp(opts, "images");
1023
1023
  if (images) {
1024
1024
  const map: Record<string, string> = {};
1025
+ // Nested like `drivers`: `store.*` / `channel.*` flatten to dotted keys;
1026
+ // everything else (`vault`, `ai`, `pgdog`, `proxy`) is already flat.
1027
+ for (const nestKey of ["store", "channel"] as const) {
1028
+ const nested = objectProp(images, nestKey);
1029
+ if (!nested || nested.type !== "ObjectExpression") continue;
1030
+ for (const prop of objectProperties(nested)) {
1031
+ const key = propKey(prop);
1032
+ const val = stringArg((prop as AstNode & { value?: AstNode }).value);
1033
+ if (key && val) map[`${nestKey}.${key}`] = val;
1034
+ }
1035
+ }
1025
1036
  for (const prop of objectProperties(images)) {
1026
1037
  const key = propKey(prop);
1038
+ if (key === "store" || key === "channel") continue;
1027
1039
  const val = stringArg((prop as AstNode & { value?: AstNode }).value);
1028
1040
  if (key && val) map[key] = val;
1029
1041
  }
@@ -15,8 +15,10 @@ export default defineConfig({
15
15
  ai: { prod: { driver: "anthropic" } },
16
16
  },
17
17
  images: {
18
- "store.sql": "pgvector/pgvector:pg17",
19
- "store.kv": "valkey/valkey:8-alpine",
18
+ store: {
19
+ sql: "pgvector/pgvector:pg17",
20
+ kv: "valkey/valkey:8-alpine",
21
+ },
20
22
  },
21
23
  i18n: { locales: ["en", "ar"], default: "ar", dir: { ar: "rtl" } },
22
24
  tenancy: { isolation: "row" },
@@ -12,7 +12,7 @@
12
12
  * `<rootDir>/src/flows/*` has an `index.ts` barrel.
13
13
  */
14
14
 
15
- import { readdir } from "node:fs/promises";
15
+ import { mkdir, readdir, rename, writeFile } from "node:fs/promises";
16
16
  import { join } from "node:path";
17
17
 
18
18
  /** Options for {@link generateAdoptBarrel}. */
@@ -37,9 +37,35 @@ const GENERATED_HEADER =
37
37
  "// AUTO-GENERATED by `oke dev` / `oke build` — do not edit by hand.\n" +
38
38
  "// Regenerated from every `src/flows/<unit>/index.ts` unit folder.\n";
39
39
 
40
+ /** Filename of the on-disk `.adopt()` barrel under the flows directory. */
41
+ export const ADOPT_BARREL_FILE = "generated.ts";
42
+
40
43
  /** A folder name usable as an `export * as <name>` binding. */
41
44
  const VALID_UNIT_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
42
45
 
46
+ /**
47
+ * Write the adopt barrel atomically: `generated.ts.tmp` → rename to
48
+ * `generated.ts`. Concurrent readers (e.g. `bun --hot` racing a save) never
49
+ * observe a torn/partial file — plain `writeFile` on the destination would
50
+ * truncate first.
51
+ *
52
+ * @param rootDir - Project root
53
+ * @param source - Full barrel source from {@link generateAdoptBarrel}
54
+ * @param flowsDir - Flows directory relative to root (default `"src/flows"`)
55
+ */
56
+ export async function writeAdoptBarrel(
57
+ rootDir: string,
58
+ source: string,
59
+ flowsDir = "src/flows",
60
+ ): Promise<void> {
61
+ const dir = join(rootDir, flowsDir);
62
+ const target = join(dir, ADOPT_BARREL_FILE);
63
+ const tmp = `${target}.tmp`;
64
+ await mkdir(dir, { recursive: true });
65
+ await writeFile(tmp, source);
66
+ await rename(tmp, target);
67
+ }
68
+
43
69
  /**
44
70
  * Scan `<rootDir>/<flowsDir>/*` and emit a barrel re-exporting every unit
45
71
  * folder's `index.ts` under its folder name — the same shape
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Driver-map defaults — per-key merge onto the real established defaults,
3
+ * never a whole-object replace and never leaking across driver maps.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { mergeEnvDriverMap } from "./index.ts";
8
+ import {
9
+ CHANNEL_EMAIL_DEFAULTS,
10
+ CHANNEL_SMS_DEFAULTS,
11
+ CLOCK_DEFAULTS,
12
+ JOURNAL_DEFAULTS,
13
+ resolveEffectiveDrivers,
14
+ SIGNAL_DEFAULTS,
15
+ STORE_FILES_DEFAULTS,
16
+ STORE_KV_DEFAULTS,
17
+ STORE_SQL_DEFAULTS,
18
+ VAULT_DEFAULTS,
19
+ } from "./driver-defaults.ts";
20
+
21
+ describe("mergeEnvDriverMap", () => {
22
+ test("an unset key keeps the real default, not a sibling key's value", () => {
23
+ const merged = mergeEnvDriverMap({ local: "pglite" }, STORE_SQL_DEFAULTS);
24
+ expect(merged).toEqual({
25
+ local: "pglite",
26
+ docker: "postgres",
27
+ test: "memory",
28
+ prod: "postgres",
29
+ });
30
+ });
31
+
32
+ test("undefined override yields the untouched default map", () => {
33
+ expect(mergeEnvDriverMap(undefined, STORE_KV_DEFAULTS)).toEqual(STORE_KV_DEFAULTS);
34
+ });
35
+
36
+ test("a fully-specified override wins on every key", () => {
37
+ const override = { local: "a", docker: "b", test: "c", prod: "d" };
38
+ expect(mergeEnvDriverMap(override, STORE_SQL_DEFAULTS)).toEqual(override);
39
+ });
40
+ });
41
+
42
+ describe("resolveEffectiveDrivers — the drivers.store.sql.local discussion scenario", () => {
43
+ test("`{ store: { sql: { local: 'pglite' } } }` resolves sql to the real map, pglite only on local", () => {
44
+ const effective = resolveEffectiveDrivers({
45
+ store: { sql: { local: "pglite" } },
46
+ });
47
+
48
+ expect(effective.store.sql).toEqual({
49
+ local: "pglite",
50
+ docker: "postgres",
51
+ test: "memory",
52
+ prod: "postgres",
53
+ });
54
+ });
55
+
56
+ test("every other driver stays at its full, real, untouched default map", () => {
57
+ const effective = resolveEffectiveDrivers({
58
+ store: { sql: { local: "pglite" } },
59
+ });
60
+
61
+ expect(effective.store.kv).toEqual(STORE_KV_DEFAULTS);
62
+ expect(effective.store.files).toEqual(STORE_FILES_DEFAULTS);
63
+ expect(effective.signal).toEqual(SIGNAL_DEFAULTS);
64
+ expect(effective.clock).toEqual(CLOCK_DEFAULTS);
65
+ expect(effective.journal).toEqual(JOURNAL_DEFAULTS);
66
+ expect(effective.vault).toEqual(VAULT_DEFAULTS);
67
+ expect(effective.channel.email).toEqual(CHANNEL_EMAIL_DEFAULTS);
68
+ expect(effective.channel.sms).toEqual(CHANNEL_SMS_DEFAULTS);
69
+ });
70
+
71
+ test("overriding one driver's map has zero effect on a sibling driver's defaults", () => {
72
+ const effective = resolveEffectiveDrivers({
73
+ store: { kv: { local: "redis" } },
74
+ });
75
+
76
+ // The overridden driver (kv) reflects the override on its one set key.
77
+ expect(effective.store.kv).toEqual({
78
+ local: "redis",
79
+ docker: "redis",
80
+ test: "memory",
81
+ prod: "redis",
82
+ });
83
+ // sql / files (same element, different facet) stay fully at real defaults.
84
+ expect(effective.store.sql).toEqual(STORE_SQL_DEFAULTS);
85
+ expect(effective.store.files).toEqual(STORE_FILES_DEFAULTS);
86
+ // Unrelated elements stay fully at real defaults too.
87
+ expect(effective.signal).toEqual(SIGNAL_DEFAULTS);
88
+ expect(effective.vault).toEqual(VAULT_DEFAULTS);
89
+ });
90
+
91
+ test("overriding one env key leaves the other three at real defaults for that driver", () => {
92
+ const effective = resolveEffectiveDrivers({
93
+ vault: { local: "keychain" },
94
+ });
95
+
96
+ expect(effective.vault).toEqual({
97
+ local: "keychain",
98
+ docker: "env",
99
+ test: "memory",
100
+ prod: "env",
101
+ });
102
+ });
103
+
104
+ test("no drivers config at all yields the real, untouched defaults for every map", () => {
105
+ const effective = resolveEffectiveDrivers(undefined);
106
+ expect(effective).toEqual({
107
+ store: {
108
+ sql: STORE_SQL_DEFAULTS,
109
+ kv: STORE_KV_DEFAULTS,
110
+ files: STORE_FILES_DEFAULTS,
111
+ },
112
+ signal: SIGNAL_DEFAULTS,
113
+ clock: CLOCK_DEFAULTS,
114
+ journal: JOURNAL_DEFAULTS,
115
+ vault: VAULT_DEFAULTS,
116
+ channel: {
117
+ email: CHANNEL_EMAIL_DEFAULTS,
118
+ sms: CHANNEL_SMS_DEFAULTS,
119
+ },
120
+ });
121
+ });
122
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Real, already-established default driver ids per {@link ConfigEnv} —
3
+ * one source of truth for every driver map that has a single default table.
4
+ *
5
+ * These mirror the literal fallbacks that used to live inline in each
6
+ * `src/kernel/boot-bind/*.ts` resolver (`docker ? "postgres" : "memory"`,
7
+ * `env === "test" ? "frozen" : "memory"`, …). Kept here so
8
+ * {@link mergeEnvDriverMap} has one real map to merge a developer's partial
9
+ * override onto, instead of every resolver re-deriving its own default.
10
+ *
11
+ * `store.index`, `channel.whatsapp` / `push`, and `ai` are intentionally
12
+ * absent — they don't have one established default across all four envs
13
+ * (index/whatsapp/push default to nothing until configured; ai has no prod
14
+ * default at all and must be declared) and keep using
15
+ * {@link resolveDriverId}'s legacy cascade.
16
+ * @module
17
+ */
18
+
19
+ import {
20
+ mergeEnvDriverMap,
21
+ normalizeEnvDriverMap,
22
+ type DriversConfig,
23
+ type EnvDriverMap,
24
+ type RawEnvDriverMap,
25
+ } from "./index.ts";
26
+
27
+ /** `drivers.store.sql` — Postgres for docker/prod, in-memory otherwise. */
28
+ export const STORE_SQL_DEFAULTS: EnvDriverMap = {
29
+ local: "memory",
30
+ docker: "postgres",
31
+ test: "memory",
32
+ prod: "postgres",
33
+ };
34
+
35
+ /** `drivers.store.kv` — Redis for docker/prod, in-memory otherwise. */
36
+ export const STORE_KV_DEFAULTS: EnvDriverMap = {
37
+ local: "memory",
38
+ docker: "redis",
39
+ test: "memory",
40
+ prod: "redis",
41
+ };
42
+
43
+ /** `drivers.store.files` — S3 for docker/prod, in-memory otherwise. */
44
+ export const STORE_FILES_DEFAULTS: EnvDriverMap = {
45
+ local: "memory",
46
+ docker: "s3",
47
+ test: "memory",
48
+ prod: "s3",
49
+ };
50
+
51
+ /** `drivers.signal` — in-memory bus everywhere. */
52
+ export const SIGNAL_DEFAULTS: EnvDriverMap = {
53
+ local: "memory",
54
+ docker: "memory",
55
+ test: "memory",
56
+ prod: "memory",
57
+ };
58
+
59
+ /** `drivers.clock` — frozen test clock in `test`, in-memory cron elsewhere. */
60
+ export const CLOCK_DEFAULTS: EnvDriverMap = {
61
+ local: "memory",
62
+ docker: "memory",
63
+ test: "frozen",
64
+ prod: "memory",
65
+ };
66
+
67
+ /** `drivers.journal` — in-memory durable-run store everywhere. */
68
+ export const JOURNAL_DEFAULTS: EnvDriverMap = {
69
+ local: "memory",
70
+ docker: "memory",
71
+ test: "memory",
72
+ prod: "memory",
73
+ };
74
+
75
+ /** `drivers.vault` — OS env vars everywhere except `test` (in-memory). */
76
+ export const VAULT_DEFAULTS: EnvDriverMap = {
77
+ local: "env",
78
+ docker: "env",
79
+ test: "memory",
80
+ prod: "env",
81
+ };
82
+
83
+ /** `drivers.channel.email` — SMTP for docker/prod, console inbox otherwise. */
84
+ export const CHANNEL_EMAIL_DEFAULTS: EnvDriverMap = {
85
+ local: "console",
86
+ docker: "smtp",
87
+ test: "console",
88
+ prod: "smtp",
89
+ };
90
+
91
+ /**
92
+ * `drivers.channel.sms` — no default provider in any env; SMS is opt-in
93
+ * only (an unconfigured env simply gets no SMS channel).
94
+ */
95
+ export const CHANNEL_SMS_DEFAULTS: EnvDriverMap = {};
96
+
97
+ /**
98
+ * Effective default map for a driver whose real default also depends on the
99
+ * boot-time `docker` flag (real docker-compose infra available), not just
100
+ * the active {@link ConfigEnv} key — `store.sql` / `store.kv` /
101
+ * `store.files` / `channel.email` / the Gate KV facet. Real usage always
102
+ * pairs `docker: true` with `env: "docker"` / `"prod"`, so the static table
103
+ * (`docker`/`prod` → real infra id) applies as-is; with the flag `false`
104
+ * (`oke dev -l`, `test`, or a boot that deliberately decouples `env` from
105
+ * infra availability) every key falls back to the same safe, no-external-
106
+ * deps id the real table already uses for `test`.
107
+ *
108
+ * @param defaults - Real per-env default table (its `docker`/`prod` slots
109
+ * assume real infra is actually up)
110
+ * @param docker - Boot-time docker/compose-infra flag
111
+ */
112
+ export function dockerFlagDefaults(defaults: EnvDriverMap, docker: boolean): EnvDriverMap {
113
+ if (docker) return defaults;
114
+ const safe = defaults.test;
115
+ return { local: safe, docker: safe, test: safe, prod: safe };
116
+ }
117
+
118
+ /** Every driver map with one established default table, grouped by element. */
119
+ export const DRIVER_DEFAULTS = {
120
+ store: {
121
+ sql: STORE_SQL_DEFAULTS,
122
+ kv: STORE_KV_DEFAULTS,
123
+ files: STORE_FILES_DEFAULTS,
124
+ },
125
+ signal: SIGNAL_DEFAULTS,
126
+ clock: CLOCK_DEFAULTS,
127
+ journal: JOURNAL_DEFAULTS,
128
+ vault: VAULT_DEFAULTS,
129
+ channel: {
130
+ email: CHANNEL_EMAIL_DEFAULTS,
131
+ sms: CHANNEL_SMS_DEFAULTS,
132
+ },
133
+ } as const;
134
+
135
+ /**
136
+ * Every driver map from {@link DRIVER_DEFAULTS}, fully resolved — the
137
+ * developer's `oke.config.ts` override merged onto the real default,
138
+ * per-key, for all four {@link ConfigEnv} slots at once. This is the "full
139
+ * picture" `oke doctor` prints: what boot actually uses for every env, even
140
+ * when the config on disk only pins one key.
141
+ */
142
+ export interface EffectiveDriversConfig {
143
+ readonly store: {
144
+ readonly sql: EnvDriverMap;
145
+ readonly kv: EnvDriverMap;
146
+ readonly files: EnvDriverMap;
147
+ };
148
+ readonly signal: EnvDriverMap;
149
+ readonly clock: EnvDriverMap;
150
+ readonly journal: EnvDriverMap;
151
+ readonly vault: EnvDriverMap;
152
+ readonly channel: {
153
+ readonly email: EnvDriverMap;
154
+ readonly sms: EnvDriverMap;
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Merge every driver map in {@link DRIVER_DEFAULTS} against the matching
160
+ * slot of a loaded `oke.config.ts` `drivers` block.
161
+ *
162
+ * @param drivers - `drivers` block from a loaded `oke.config.ts` (already
163
+ * normalized if it came through `defineConfig`; normalized again here so
164
+ * an un-normalized/raw block is still handled)
165
+ */
166
+ export function resolveEffectiveDrivers(
167
+ drivers: DriversConfig | undefined,
168
+ ): EffectiveDriversConfig {
169
+ const merge = (map: EnvDriverMap | undefined, defaults: EnvDriverMap) =>
170
+ mergeEnvDriverMap(normalizeEnvDriverMap(map as RawEnvDriverMap), defaults);
171
+ return {
172
+ store: {
173
+ sql: merge(drivers?.store?.sql, STORE_SQL_DEFAULTS),
174
+ kv: merge(drivers?.store?.kv, STORE_KV_DEFAULTS),
175
+ files: merge(drivers?.store?.files, STORE_FILES_DEFAULTS),
176
+ },
177
+ signal: merge(drivers?.signal, SIGNAL_DEFAULTS),
178
+ clock: merge(drivers?.clock, CLOCK_DEFAULTS),
179
+ journal: merge(drivers?.journal, JOURNAL_DEFAULTS),
180
+ vault: merge(drivers?.vault, VAULT_DEFAULTS),
181
+ channel: {
182
+ email: merge(drivers?.channel?.email, CHANNEL_EMAIL_DEFAULTS),
183
+ sms: merge(drivers?.channel?.sms, CHANNEL_SMS_DEFAULTS),
184
+ },
185
+ };
186
+ }
@@ -81,8 +81,55 @@ export interface DriversConfig {
81
81
  readonly runs?: EnvDriverMap;
82
82
  }
83
83
 
84
- /** Image lock entries keyed by element role (`store.sql`, …). */
85
- export type ImagesConfig = Readonly<Record<string, string>>;
84
+ /**
85
+ * Container image pins, nested the same way as {@link DriversConfig}:
86
+ * `store.*` / `channel.*` facets nest under their owning element; roles with
87
+ * no driver counterpart (`vault` picks a driver but pins its own image;
88
+ * `ai`, `pgdog`, `proxy` have no config-level element at all) stay flat.
89
+ *
90
+ * Always fully explicit — `images` has no hidden default merge. Omitted keys
91
+ * mean "no container for that role," never "inherit a default image."
92
+ */
93
+ export interface ImagesConfig {
94
+ readonly store?: {
95
+ readonly sql?: string;
96
+ readonly kv?: string;
97
+ readonly files?: string;
98
+ readonly index?: string;
99
+ };
100
+ readonly channel?: {
101
+ readonly email?: string;
102
+ };
103
+ readonly vault?: string;
104
+ readonly ai?: string;
105
+ readonly pgdog?: string;
106
+ readonly proxy?: string;
107
+ }
108
+
109
+ /**
110
+ * Flatten {@link ImagesConfig} to the dotted-role `Record<string, string>`
111
+ * every internal consumer (compose role matching, credentials, env prefixes)
112
+ * has always kept working with — the nesting is a config-surface / type-safety
113
+ * concern only, not an internal representation change.
114
+ *
115
+ * @param images - Nested config-surface images (or `undefined`)
116
+ */
117
+ export function flattenImagesConfig(
118
+ images: ImagesConfig | undefined,
119
+ ): Readonly<Record<string, string>> {
120
+ if (!images) return {};
121
+ const out: Record<string, string> = {};
122
+ if (images.store?.sql) out["store.sql"] = images.store.sql;
123
+ if (images.store?.kv) out["store.kv"] = images.store.kv;
124
+ if (images.store?.files) out["store.files"] = images.store.files;
125
+ if (images.store?.index) out["store.index"] = images.store.index;
126
+ if (images.channel?.email) out["channel.email"] = images.channel.email;
127
+ if (images.vault) out.vault = images.vault;
128
+ if (images.ai) out.ai = images.ai;
129
+ if (images.pgdog) out.pgdog = images.pgdog;
130
+ if (images.proxy) out.proxy = images.proxy;
131
+ return out;
132
+ }
86
133
 
87
134
  /** i18n config. */
88
135
  export interface I18nConfig {
@@ -293,6 +340,32 @@ export function normalizeDriversConfig(
293
340
  };
294
341
  }
295
342
 
343
+ /**
344
+ * Merge a developer's partial {@link EnvDriverMap} onto the real default map
345
+ * for one specific driver — per environment key, never a whole-object
346
+ * replace. An unset key keeps that driver's own real default; it never
347
+ * inherits a sibling key's value (the old `docker → prod → local → test`
348
+ * cascade in {@link resolveDriverId} did that, which is exactly the bug this
349
+ * fixes — `{ local: "pglite" }` no longer leaks `pglite` into `docker`).
350
+ *
351
+ * Scoped strictly to the `{ local?, docker?, test?, prod? }` shape — not a
352
+ * generic deep merge, and never applied to a whole config object.
353
+ *
354
+ * @param override - Developer-supplied partial map (may be `undefined`)
355
+ * @param defaults - Real, already-established default map for this driver
356
+ */
357
+ export function mergeEnvDriverMap(
358
+ override: EnvDriverMap | undefined,
359
+ defaults: EnvDriverMap,
360
+ ): EnvDriverMap {
361
+ return {
362
+ local: override?.local ?? defaults.local,
363
+ docker: override?.docker ?? defaults.docker,
364
+ test: override?.test ?? defaults.test,
365
+ prod: override?.prod ?? defaults.prod,
366
+ };
367
+ }
368
+
296
369
  /**
297
370
  * Fill missing `docker` pins from `prod` (compose infra ≈ production protocols).
298
371
  *
@@ -365,22 +438,39 @@ export function defineConfig(config: OkeConfig): OkeConfig {
365
438
  /**
366
439
  * Resolve a driver id for an env from an {@link EnvDriverMap}.
367
440
  *
368
- * `docker` falls back to `prod` then `local` so existing configs keep working
369
- * until an explicit `docker:` pin is added. Legacy keys are normalized first.
441
+ * With `defaults` given, the developer's map is merged onto it per-key via
442
+ * {@link mergeEnvDriverMap} and the active env's slot is read directly a
443
+ * key the developer never set keeps that driver's real default, full stop.
444
+ * This is the path every driver map with an established default should use.
445
+ *
446
+ * Without `defaults` (legacy path — `store.index`, `channel.whatsapp` /
447
+ * `push`, `ai`, which have no single established default table): `docker`
448
+ * falls back to `prod` then `local` then `test` so existing configs keep
449
+ * working until an explicit `docker:` pin is added. Legacy `dev`/`stack`
450
+ * keys are normalized first either way.
370
451
  *
371
452
  * @param map - Env → driver map
372
453
  * @param env - Active environment
454
+ * @param defaults - Real default map for this specific driver (per-key merge)
373
455
  */
374
456
  export function resolveDriverId(
375
457
  map: EnvDriverMap | RawEnvDriverMap | undefined,
376
458
  env: ConfigEnv,
459
+ defaults?: EnvDriverMap,
377
460
  ): string | undefined {
461
+ const normalized = normalizeEnvDriverMap(map as RawEnvDriverMap);
462
+ if (defaults) {
463
+ const merged = mergeEnvDriverMap(normalized, defaults);
464
+ const ref = merged[env];
465
+ if (ref === undefined) return undefined;
466
+ return typeof ref === "string" ? ref : ref.driver;
467
+ }
378
468
  if (!map) return undefined;
379
- const normalized = normalizeEnvDriverMap(map as RawEnvDriverMap) ?? map;
469
+ const resolved = normalized ?? map;
380
470
  const ref =
381
471
  env === "docker"
382
- ? (normalized.docker ?? normalized.prod ?? normalized.local ?? normalized.test)
383
- : (normalized[env] ?? normalized.local ?? normalized.test);
472
+ ? (resolved.docker ?? resolved.prod ?? resolved.local ?? resolved.test)
473
+ : (resolved[env] ?? resolved.local ?? resolved.test);
384
474
  if (ref === undefined) return undefined;
385
475
  return typeof ref === "string" ? ref : ref.driver;
386
476
  }