okengine 0.2.2 → 0.2.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 (66) hide show
  1. package/README.md +30 -4
  2. package/docs/spec/example.md +12 -3
  3. package/docs/spec/four-applications.md +10 -3
  4. package/package.json +9 -40
  5. package/src/cli/dev-app-runner.ts +45 -6
  6. package/src/cli/dev.ts +204 -23
  7. package/src/cli/docker-cli.test.ts +10 -8
  8. package/src/cli/docker.ts +18 -7
  9. package/src/cli/doctor.ts +3 -18
  10. package/src/cli/hero-meta.test.ts +85 -0
  11. package/src/cli/hero-meta.ts +252 -0
  12. package/src/cli/index.ts +4 -1
  13. package/src/cli/json-out.test.ts +1 -1
  14. package/src/cli/load-config.images.test.ts +126 -0
  15. package/src/cli/load-config.ts +145 -2
  16. package/src/cli/ports.test.ts +51 -0
  17. package/src/cli/ports.ts +81 -0
  18. package/src/cli/registry.help.test.ts +18 -0
  19. package/src/cli/registry.ts +0 -4
  20. package/src/cli/safe-defaults.test.ts +3 -3
  21. package/src/config/define-config.test.ts +61 -0
  22. package/src/config/index.ts +89 -6
  23. package/src/config/resolve-driver.test.ts +30 -0
  24. package/src/console/server/app.ts +2 -1
  25. package/src/console/server/flows.ts +4 -1
  26. package/src/console/server/index.ts +5 -0
  27. package/src/console/server/operator-db.test.ts +213 -0
  28. package/src/console/server/operator-db.ts +470 -0
  29. package/src/console/server/plugin.ts +20 -0
  30. package/src/console/server/serve.ts +47 -1
  31. package/src/console/server/state.ts +25 -2
  32. package/src/console/ui/dist/assets/{index-B71Yl_SS.js → index-Dy4jht9P.js} +3 -3
  33. package/src/console/ui/dist/assets/{panel-overview-Bd48d9km.js → panel-overview-Dt_AeXgd.js} +1 -1
  34. package/src/console/ui/dist/assets/{panel-runs-BwsWqKeB.js → panel-runs-DGstFHeq.js} +1 -1
  35. package/src/console/ui/dist/assets/{panel-signals-9najbZY2.js → panel-signals-DzEa2Fnt.js} +1 -1
  36. package/src/console/ui/dist/assets/{panel-store-OHkP2pDp.js → panel-store-BJkbNgxx.js} +1 -1
  37. package/src/console/ui/dist/assets/{panel-traces-tn2JoY8U.js → panel-traces-BaRVO3gM.js} +1 -1
  38. package/src/console/ui/dist/assets/style-C8MxEWPd.css +3 -0
  39. package/src/console/ui/dist/favicon.svg +7 -0
  40. package/src/console/ui/dist/index.html +3 -2
  41. package/src/console/ui/shell/App.tsx +44 -4
  42. package/src/console/ui/shell/components/oke-logo.tsx +40 -0
  43. package/src/console/ui/shell/index.html +1 -0
  44. package/src/console/ui/shell/layout/Shell.tsx +2 -3
  45. package/src/console/ui/shell/panels/overview/OverviewPanel.tsx +8 -0
  46. package/src/console/ui/shell/public/favicon.svg +7 -0
  47. package/src/console/ui/shell/setup/Wizard.tsx +5 -2
  48. package/src/docker/compose.ts +45 -14
  49. package/src/docker/derive.ts +32 -10
  50. package/src/docker/docker.test.ts +28 -4
  51. package/src/docker/dockerfile.integration.test.ts +3 -1
  52. package/src/docker/index.ts +10 -0
  53. package/src/docker/stack-id.test.ts +86 -0
  54. package/src/docker/stack-id.ts +108 -0
  55. package/src/docker/stack.integration.test.ts +17 -8
  56. package/src/docker/types.ts +20 -1
  57. package/src/kernel/app.ts +39 -8
  58. package/src/kernel/boot-bind/store.test.ts +60 -0
  59. package/src/kernel/boot-bind/store.ts +142 -12
  60. package/src/kernel/boot.ts +28 -4
  61. package/src/mcp/server.ts +99 -57
  62. package/src/runtime/dev-request-log.test.ts +33 -0
  63. package/src/runtime/dev-request-log.ts +130 -0
  64. package/src/term.test.ts +98 -5
  65. package/src/term.ts +287 -6
  66. package/src/console/ui/dist/assets/style-Cnl7WLya.css +0 -3
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Per-project stack identity.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ hostPortForInstance,
8
+ parseStackCredentials,
9
+ stackAppSlug,
10
+ stackInstanceId,
11
+ } from "./stack-id.ts";
12
+ import { deriveInfrastructure } from "./derive.ts";
13
+
14
+ describe("stackInstanceId", () => {
15
+ test("is stable for the same cwd and differs across paths", () => {
16
+ const a = stackInstanceId("/tmp/oke-project-a");
17
+ const b = stackInstanceId("/tmp/oke-project-a");
18
+ const c = stackInstanceId("/tmp/oke-project-b");
19
+ expect(a).toBe(b);
20
+ expect(a).toHaveLength(6);
21
+ expect(a).not.toBe(c);
22
+ });
23
+
24
+ test("stackAppSlug is dev-<id>", () => {
25
+ const id = stackInstanceId("/tmp/oke-x");
26
+ expect(stackAppSlug("/tmp/oke-x")).toBe(`dev-${id}`);
27
+ });
28
+ });
29
+
30
+ describe("hostPortForInstance", () => {
31
+ test("offsets sql and kv into separate ranges", () => {
32
+ const id = "abcd12";
33
+ const sql = hostPortForInstance("store.sql", 5432, id);
34
+ const kv = hostPortForInstance("store.kv", 6379, id);
35
+ expect(sql).toBeGreaterThanOrEqual(15_000);
36
+ expect(sql).toBeLessThan(16_000);
37
+ expect(kv).toBeGreaterThanOrEqual(16_000);
38
+ expect(kv).toBeLessThan(17_000);
39
+ });
40
+ });
41
+
42
+ describe("parseStackCredentials", () => {
43
+ test("reads OKE_* credential keys", () => {
44
+ const text = `
45
+ OKE_STORE_SQL_USER=oke
46
+ OKE_STORE_SQL_PASSWORD=s3cret
47
+ OKE_STORE_SQL_DB=oke
48
+ OKE_STORE_KV_USER=oke
49
+ OKE_STORE_KV_PASSWORD=kvpass
50
+ OKE_STORE_KV_DB=oke
51
+ `;
52
+ const creds = parseStackCredentials(text, ["store.sql", "store.kv"]);
53
+ expect(creds["store.sql"]?.password).toBe("s3cret");
54
+ expect(creds["store.kv"]?.password).toBe("kvpass");
55
+ });
56
+ });
57
+
58
+ describe("deriveInfrastructure instanceId", () => {
59
+ test("unique app name and host ports land in stackEnv URLs", () => {
60
+ const result = deriveInfrastructure({
61
+ images: {
62
+ "store.sql": "postgres:18-alpine",
63
+ "store.kv": "redis:8-alpine",
64
+ },
65
+ app: "dev-abcdef",
66
+ instanceId: "abcdef",
67
+ includeApp: false,
68
+ credentials: {
69
+ "store.sql": {
70
+ user: "oke",
71
+ password: "stack-id-test-sql-password",
72
+ database: "oke",
73
+ },
74
+ "store.kv": {
75
+ user: "oke",
76
+ password: "stack-id-test-kv-password",
77
+ database: "oke",
78
+ },
79
+ },
80
+ });
81
+ const base = result.files.find((f) => f.path === "compose.yml")!.content;
82
+ expect(base).toContain("oke-dev-abcdef");
83
+ expect(result.stackEnv.DATABASE_URL).toMatch(/:15\d{3}\//);
84
+ expect(result.stackEnv.REDIS_URL).toMatch(/:16\d{3}/);
85
+ });
86
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Per-project local stack identity — unique compose project + host ports.
3
+ *
4
+ * `oke dev --stack` must not share one `oke-dev` Postgres across every app on
5
+ * the machine. Identity is a stable short hash of the project cwd.
6
+ */
7
+
8
+ import { createHash } from "node:crypto";
9
+ import { resolve } from "node:path";
10
+ import type { ServiceCredentials } from "./types.ts";
11
+ import { defaultHostPort, envPrefix } from "./helpers.ts";
12
+
13
+ /**
14
+ * Stable 6-hex id for a project directory (local stacks only).
15
+ *
16
+ * @param cwd - Project root
17
+ */
18
+ export function stackInstanceId(cwd: string): string {
19
+ return createHash("sha256")
20
+ .update(resolve(cwd))
21
+ .digest("hex")
22
+ .slice(0, 6);
23
+ }
24
+
25
+ /**
26
+ * Compose project / app slug: `dev-<id>` → Docker name `oke-dev-<id>`.
27
+ *
28
+ * @param cwd - Project root
29
+ */
30
+ export function stackAppSlug(cwd: string): string {
31
+ return `dev-${stackInstanceId(cwd)}`;
32
+ }
33
+
34
+ /**
35
+ * Host port for a role, offset by instance id so two stacks can run at once.
36
+ *
37
+ * @param role - Role key
38
+ * @param containerPort - Container listen port
39
+ * @param instanceId - 6-hex stack id
40
+ */
41
+ export function hostPortForInstance(
42
+ role: string,
43
+ containerPort: number,
44
+ instanceId: string,
45
+ ): number {
46
+ const n = Number.parseInt(instanceId.slice(0, 4), 16) % 1000;
47
+ if (role === "store.sql") return 15_000 + n;
48
+ if (role === "store.kv") return 16_000 + n;
49
+ if (role === "signal") return 17_000 + n;
50
+ return defaultHostPort(role, containerPort) + n;
51
+ }
52
+
53
+ /**
54
+ * Parse role credentials from an existing `.env.stack` body (reuse on restart).
55
+ *
56
+ * @param text - Dotenv contents
57
+ * @param roles - Roles to look up
58
+ */
59
+ export function parseStackCredentials(
60
+ text: string,
61
+ roles: readonly string[],
62
+ ): Record<string, ServiceCredentials> {
63
+ const map = new Map<string, string>();
64
+ for (const line of text.split("\n")) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed || trimmed.startsWith("#")) continue;
67
+ const eq = trimmed.indexOf("=");
68
+ if (eq <= 0) continue;
69
+ const key = trimmed.slice(0, eq);
70
+ let value = trimmed.slice(eq + 1);
71
+ if (
72
+ (value.startsWith('"') && value.endsWith('"')) ||
73
+ (value.startsWith("'") && value.endsWith("'"))
74
+ ) {
75
+ value = value.slice(1, -1);
76
+ }
77
+ map.set(key, value);
78
+ }
79
+
80
+ const out: Record<string, ServiceCredentials> = {};
81
+ for (const role of roles) {
82
+ const prefix = envPrefix(role);
83
+ const user = map.get(`${prefix}_USER`);
84
+ const password = map.get(`${prefix}_PASSWORD`);
85
+ const database = map.get(`${prefix}_DB`);
86
+ if (user && password && database) {
87
+ out[role] = { user, password, database };
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Load credentials from project `.env.stack` when present.
95
+ *
96
+ * @param cwd - Project root
97
+ * @param roles - Image roles
98
+ */
99
+ export async function loadExistingStackCredentials(
100
+ cwd: string,
101
+ roles: readonly string[],
102
+ ): Promise<Readonly<Record<string, ServiceCredentials>> | undefined> {
103
+ const path = resolve(cwd, ".env.stack");
104
+ const file = Bun.file(path);
105
+ if (!(await file.exists())) return undefined;
106
+ const parsed = parseStackCredentials(await file.text(), roles);
107
+ return Object.keys(parsed).length > 0 ? parsed : undefined;
108
+ }
@@ -32,10 +32,11 @@ describe("oke dev --stack postgres integration", () => {
32
32
  }
33
33
 
34
34
  const dir = await mkdtemp(join(tmpdir(), "oke-stack-pg-"));
35
+ const dockerDir = join(dir, "docker");
35
36
  const project = `oke-pg-${Date.now()}`;
36
37
  try {
37
38
  const derived = deriveInfrastructure({
38
- images: { "store.sql": "postgres:16-alpine" },
39
+ images: { "store.sql": "postgres:18-alpine" },
39
40
  credentials: {
40
41
  "store.sql": {
41
42
  user: "oke",
@@ -45,11 +46,16 @@ describe("oke dev --stack postgres integration", () => {
45
46
  },
46
47
  app: "stacktest",
47
48
  host: "127.0.0.1",
49
+ includeApp: false,
50
+ composeDir: "docker",
51
+ });
52
+ await writeDerivedFiles(derived, dockerDir, {
53
+ writeStackEnv: true,
54
+ stackEnvDir: dir,
48
55
  });
49
- await writeDerivedFiles(derived, dir, { writeStackEnv: true });
50
56
 
51
- // Stack-only: no app service just the role compose file.
52
- const composeFiles = ["compose.store.sql.yml"];
57
+ // Infra-only: network + role compose (no app build).
58
+ const composeFiles = ["compose.yml", "compose.store.sql.yml"];
53
59
  const up = Bun.spawn(
54
60
  [
55
61
  "docker",
@@ -61,7 +67,7 @@ describe("oke dev --stack postgres integration", () => {
61
67
  "-d",
62
68
  ],
63
69
  {
64
- cwd: dir,
70
+ cwd: dockerDir,
65
71
  stdout: "pipe",
66
72
  stderr: "pipe",
67
73
  env: {
@@ -92,7 +98,7 @@ describe("oke dev --stack postgres integration", () => {
92
98
  "--format",
93
99
  "json",
94
100
  ],
95
- { cwd: dir, stdout: "pipe", stderr: "pipe" },
101
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
96
102
  );
97
103
  const text = await new Response(ps.stdout).text();
98
104
  await ps.exited;
@@ -115,11 +121,12 @@ describe("oke dev --stack postgres integration", () => {
115
121
  }
116
122
 
117
123
  // Prove credentials live in .env.stack, not YAML.
118
- const yml = await Bun.file(join(dir, "compose.store.sql.yml")).text();
124
+ const yml = await Bun.file(join(dockerDir, "compose.store.sql.yml")).text();
119
125
  expect(yml).not.toContain("stack-integration-pass");
120
126
  expect(formatStackEnv(derived.stackEnv)).toContain(
121
127
  "stack-integration-pass",
122
128
  );
129
+ expect(await Bun.file(join(dir, ".env.stack")).exists()).toBe(true);
123
130
  } finally {
124
131
  await Bun.spawn(
125
132
  [
@@ -128,11 +135,13 @@ describe("oke dev --stack postgres integration", () => {
128
135
  "-p",
129
136
  project,
130
137
  "-f",
138
+ "compose.yml",
139
+ "-f",
131
140
  "compose.store.sql.yml",
132
141
  "down",
133
142
  "-v",
134
143
  ],
135
- { cwd: dir, stdout: "pipe", stderr: "pipe" },
144
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
136
145
  ).exited.catch(() => {});
137
146
  await rm(dir, { recursive: true, force: true }).catch(() => {});
138
147
  }
@@ -100,7 +100,18 @@ export interface DeriveOptions {
100
100
  readonly appPort?: number;
101
101
  /** When true, emit prod overlays (limits, secret refs, deploy.replicas). */
102
102
  readonly prod?: boolean;
103
- /** Output directory (default `.`). */
103
+ /**
104
+ * Include the `app` service in `compose.yml` (default true).
105
+ * `oke dev --stack` sets false — host Bun runs the app; Docker is infra only.
106
+ */
107
+ readonly includeApp?: boolean;
108
+ /**
109
+ * Compose artefact directory relative to the project root (default `docker`).
110
+ * Controls generated `env_file` / `build.context` paths (e.g. `../.env.stack`).
111
+ * Pass `"."` for legacy root-level layout.
112
+ */
113
+ readonly composeDir?: string;
114
+ /** Output directory (default `docker`). */
104
115
  readonly outDir?: string;
105
116
  /** Inject credentials (tests). Defaults to generated randoms. */
106
117
  readonly credentials?: Readonly<Record<string, ServiceCredentials>>;
@@ -108,8 +119,16 @@ export interface DeriveOptions {
108
119
  readonly host?: string;
109
120
  /** Extra image recipes (plugins). */
110
121
  readonly recipes?: readonly ImageRecipe[];
122
+ /**
123
+ * Local stack instance id (6-hex). When set, host ports are offset so two
124
+ * `oke dev -s` projects do not share one Postgres on `:5432`.
125
+ */
126
+ readonly instanceId?: string;
111
127
  }
112
128
 
129
+ /** Default relative directory for generated Docker / compose artefacts. */
130
+ export const DEFAULT_DOCKER_DIR = "docker";
131
+
113
132
  /** One generated file. */
114
133
  export interface GeneratedFile {
115
134
  /** Relative path under `outDir`. */
package/src/kernel/app.ts CHANGED
@@ -45,6 +45,11 @@ import {
45
45
  type FxOperator,
46
46
  type NamedRef,
47
47
  } from "./fx.ts";
48
+ import {
49
+ currentDevSurface,
50
+ logDevRequest,
51
+ shouldLogDevRequests,
52
+ } from "../runtime/dev-request-log.ts";
48
53
  import {
49
54
  mergeHooks,
50
55
  runPipeline,
@@ -126,6 +131,11 @@ export interface OkeOptions {
126
131
  readonly archiveInputFields?: readonly string[];
127
132
  /** Active environment for {@link OkeApp.boot} (defaults to `dev`). */
128
133
  readonly env?: BootOptions["env"];
134
+ /**
135
+ * Local-server mode (`oke dev -s` / `OKE_STACK=1`) — force the `stack`
136
+ * driver profile at boot.
137
+ */
138
+ readonly stack?: BootOptions["stack"];
129
139
  /** Optional `oke.config.ts` document consumed at boot. */
130
140
  readonly config?: BootOptions["config"];
131
141
  /** Pre-built element runtimes (skip construction at boot when present). */
@@ -483,6 +493,7 @@ export function oke(options: OkeOptions): OkeApp {
483
493
  bootEnv = overrides?.env ?? options.env ?? "dev";
484
494
  const merged: BootOptions = {
485
495
  env: bootEnv,
496
+ stack: overrides?.stack ?? options.stack,
486
497
  config: overrides?.config ?? options.config,
487
498
  elements: overrides?.elements ?? options.elements,
488
499
  secrets: overrides?.secrets ?? options.secrets,
@@ -892,13 +903,31 @@ export function oke(options: OkeOptions): OkeApp {
892
903
  },
893
904
  execute,
894
905
  async fetch(request) {
906
+ const started = performance.now();
907
+ let flowLabel: string | undefined;
895
908
  const url = new URL(request.url);
896
909
  const method = request.method.toUpperCase();
897
910
 
911
+ const respond = (response: Response): Response => {
912
+ if (shouldLogDevRequests()) {
913
+ logDevRequest({
914
+ surface: currentDevSurface(),
915
+ method,
916
+ path: url.pathname,
917
+ flow: flowLabel,
918
+ status: response.status,
919
+ ms: Math.round(performance.now() - started),
920
+ });
921
+ }
922
+ return response;
923
+ };
924
+
898
925
  if (method === "GET" && url.pathname === "/_oke/client.json") {
899
- return new Response(JSON.stringify(routes), {
900
- headers: { "content-type": "application/json" },
901
- });
926
+ return respond(
927
+ new Response(JSON.stringify(routes), {
928
+ headers: { "content-type": "application/json" },
929
+ }),
930
+ );
902
931
  }
903
932
 
904
933
  if (method === "POST" && url.pathname.startsWith("/_oke/")) {
@@ -910,8 +939,9 @@ export function oke(options: OkeOptions): OkeApp {
910
939
  const target =
911
940
  flowsByName.get(`${unit}.${flowName}`) ?? flowsByName.get(flowName);
912
941
  if (!target) {
913
- return new Response("Not Found", { status: 404 });
942
+ return respond(new Response("Not Found", { status: 404 }));
914
943
  }
944
+ flowLabel = target.name;
915
945
  let internalInput: unknown;
916
946
  try {
917
947
  internalInput = await request.json();
@@ -924,15 +954,16 @@ export function oke(options: OkeOptions): OkeApp {
924
954
  { kind: "internal" } satisfies InternalTrigger,
925
955
  { request },
926
956
  );
927
- return encodeExecuteResult(internalResult);
957
+ return respond(encodeExecuteResult(internalResult));
928
958
  }
929
959
  }
930
960
 
931
961
  const matched = router.match(method, url.pathname);
932
962
  if (!matched) {
933
- return new Response("Not Found", { status: 404 });
963
+ return respond(new Response("Not Found", { status: 404 }));
934
964
  }
935
965
  const { value: binding, params } = matched;
966
+ flowLabel = binding.flow.name;
936
967
 
937
968
  let route = compiled.get(binding);
938
969
  if (!route && binding.trigger.kind === "http") {
@@ -946,7 +977,7 @@ export function oke(options: OkeOptions): OkeApp {
946
977
  if (route) {
947
978
  const parsed = await route.parseValidate(request, params);
948
979
  if (!parsed.ok) {
949
- return encodeFailure(parsed.failure);
980
+ return respond(encodeFailure(parsed.failure));
950
981
  }
951
982
  input = parsed.input;
952
983
  validated = true;
@@ -962,7 +993,7 @@ export function oke(options: OkeOptions): OkeApp {
962
993
  { request, params, validated },
963
994
  );
964
995
 
965
- return encodeExecuteResult(result);
996
+ return respond(encodeExecuteResult(result));
966
997
  },
967
998
  async dispatchSignal(signal, payload) {
968
999
  const name = resolveName(signal);
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Store binder — stack profile (+ env overrides).
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { resolveKvDriverId, resolveSqlDriverId } from "./store.ts";
7
+
8
+ describe("bindStore driver resolution", () => {
9
+ const prev = {
10
+ stack: process.env.OKE_STACK,
11
+ sql: process.env.OKE_SQL_DRIVER,
12
+ kv: process.env.OKE_KV_DRIVER,
13
+ };
14
+
15
+ afterEach(() => {
16
+ if (prev.stack === undefined) delete process.env.OKE_STACK;
17
+ else process.env.OKE_STACK = prev.stack;
18
+ if (prev.sql === undefined) delete process.env.OKE_SQL_DRIVER;
19
+ else process.env.OKE_SQL_DRIVER = prev.sql;
20
+ if (prev.kv === undefined) delete process.env.OKE_KV_DRIVER;
21
+ else process.env.OKE_KV_DRIVER = prev.kv;
22
+ });
23
+
24
+ test("dev env keeps sqlite / memory from config", () => {
25
+ const options = {
26
+ config: {
27
+ drivers: {
28
+ store: {
29
+ sql: { dev: "sqlite", stack: "postgres", prod: "postgres" },
30
+ kv: { dev: "memory", stack: "redis", prod: "redis" },
31
+ },
32
+ },
33
+ },
34
+ };
35
+ expect(resolveSqlDriverId(options, "dev", false)).toBe("sqlite");
36
+ expect(resolveKvDriverId(options, "dev", false)).toBe("memory");
37
+ });
38
+
39
+ test("stack env uses stack profile (falls back to prod)", () => {
40
+ const options = {
41
+ config: {
42
+ drivers: {
43
+ store: {
44
+ sql: { dev: "sqlite", stack: "postgres", prod: "postgres" },
45
+ kv: { dev: "memory", prod: "redis" },
46
+ },
47
+ },
48
+ },
49
+ };
50
+ expect(resolveSqlDriverId(options, "stack", true)).toBe("postgres");
51
+ expect(resolveKvDriverId(options, "stack", true)).toBe("redis");
52
+ });
53
+
54
+ test("stack mode honours OKE_*_DRIVER overrides", () => {
55
+ process.env.OKE_SQL_DRIVER = "postgres";
56
+ process.env.OKE_KV_DRIVER = "redis";
57
+ expect(resolveSqlDriverId({}, "stack", true)).toBe("postgres");
58
+ expect(resolveKvDriverId({}, "stack", true)).toBe("redis");
59
+ });
60
+ });
@@ -2,42 +2,70 @@
2
2
  * Lazy store binder — loaded only when Store is declared.
3
3
  */
4
4
 
5
+ import {
6
+ resolveDriverId,
7
+ type ConfigEnv,
8
+ } from "../../config/index.ts";
5
9
  import { memoryDrivers } from "../../drivers/memory.ts";
10
+ import { postgresDriver } from "../../drivers/postgres.ts";
11
+ import { redisDriver } from "../../drivers/redis.ts";
12
+ import { sqliteDriver } from "../../drivers/sqlite.ts";
13
+ import type { KvDriver, SqlDriver } from "../../drivers/types.ts";
6
14
  import {
7
15
  createStoreRuntime,
8
16
  type StoreRuntime,
9
17
  } from "../../elements/store.ts";
10
- import {
11
- resolveDriverId,
12
- type ConfigEnv,
13
- } from "../../config/index.ts";
18
+ import type { StoreDecl } from "../../elements/store/declare.ts";
14
19
  import type { BootOptions } from "../boot.ts"; // type-only — no cycle at runtime
15
20
 
16
21
  /**
17
22
  * Construct a Store runtime and register facet declarations.
18
23
  *
24
+ * In stack mode (`env === "stack"` / `OKE_STACK=1`), SQL/KV resolve from the
25
+ * `stack` driver map (falling back to `prod`) and URLs from `.env.stack`.
26
+ *
19
27
  * @param options - Boot options
20
28
  * @param env - Active environment
21
29
  * @param now - Clock
30
+ * @param stack - Prefer compose URLs when opening postgres/redis
22
31
  */
23
32
  export function bindStore(
24
33
  options: BootOptions,
25
34
  env: ConfigEnv,
26
35
  now: () => number,
36
+ stack = false,
27
37
  ): StoreRuntime {
28
- const sqlId =
29
- resolveDriverId(options.config?.drivers?.store?.sql, env) ?? "memory";
30
- const kvId =
31
- resolveDriverId(options.config?.drivers?.store?.kv, env) ?? "memory";
32
- void sqlId;
33
- void kvId;
38
+ const sqlId = resolveSqlDriverId(options, env, stack);
39
+ const kvId = resolveKvDriverId(options, env, stack);
40
+ const sqlUrl = sqlUrlFor(sqlId, stack);
41
+ const kvUrl = kvUrlFor(kvId, stack);
42
+
43
+ const sqlBindings: Record<
44
+ string,
45
+ { name: string; primary: { url: string } }
46
+ > = {};
47
+ const kvBindings: Record<string, { url?: string }> = {};
48
+
49
+ for (const decl of options.stores ?? []) {
50
+ if (isSqlDecl(decl)) {
51
+ sqlBindings[decl.name] = {
52
+ name: decl.name,
53
+ primary: { url: sqlUrl },
54
+ };
55
+ } else if (isKvDecl(decl)) {
56
+ kvBindings[decl.name] = kvUrl !== undefined ? { url: kvUrl } : {};
57
+ }
58
+ }
59
+
34
60
  const store = createStoreRuntime({
35
61
  drivers: {
36
- sql: memoryDrivers.sql,
37
- kv: memoryDrivers.kv,
62
+ sql: sqlDriverFor(sqlId),
63
+ kv: kvDriverFor(kvId),
38
64
  files: memoryDrivers.files,
39
65
  index: memoryDrivers.index,
40
66
  },
67
+ sql: sqlBindings,
68
+ kv: kvBindings,
41
69
  now,
42
70
  });
43
71
  for (const decl of options.stores ?? []) {
@@ -45,3 +73,105 @@ export function bindStore(
45
73
  }
46
74
  return store;
47
75
  }
76
+
77
+ /**
78
+ * @param options - Boot options
79
+ * @param env - Active env
80
+ * @param stack - Stack mode
81
+ */
82
+ export function resolveSqlDriverId(
83
+ options: BootOptions,
84
+ env: ConfigEnv,
85
+ stack: boolean,
86
+ ): string {
87
+ const fromEnv = process.env.OKE_SQL_DRIVER?.trim();
88
+ if (stack && fromEnv) return fromEnv;
89
+ const resolved = resolveDriverId(options.config?.drivers?.store?.sql, env);
90
+ if (resolved) return resolved;
91
+ return stack ? "postgres" : "memory";
92
+ }
93
+
94
+ /**
95
+ * @param options - Boot options
96
+ * @param env - Active env
97
+ * @param stack - Stack mode
98
+ */
99
+ export function resolveKvDriverId(
100
+ options: BootOptions,
101
+ env: ConfigEnv,
102
+ stack: boolean,
103
+ ): string {
104
+ const fromEnv = process.env.OKE_KV_DRIVER?.trim();
105
+ if (stack && fromEnv) return fromEnv;
106
+ const resolved = resolveDriverId(options.config?.drivers?.store?.kv, env);
107
+ if (resolved) return resolved;
108
+ return stack ? "redis" : "memory";
109
+ }
110
+
111
+ function sqlDriverFor(id: string): SqlDriver {
112
+ switch (id) {
113
+ case "postgres":
114
+ return postgresDriver;
115
+ case "sqlite":
116
+ return sqliteDriver;
117
+ case "memory":
118
+ return memoryDrivers.sql;
119
+ default:
120
+ throw new Error(`oke boot: unknown sql driver "${id}"`);
121
+ }
122
+ }
123
+
124
+ function kvDriverFor(id: string): KvDriver {
125
+ switch (id) {
126
+ case "redis":
127
+ return redisDriver;
128
+ case "memory":
129
+ return memoryDrivers.kv;
130
+ default:
131
+ throw new Error(`oke boot: unknown kv driver "${id}"`);
132
+ }
133
+ }
134
+
135
+ function sqlUrlFor(sqlId: string, stack: boolean): string {
136
+ if (sqlId === "postgres") {
137
+ const url =
138
+ process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL ?? undefined;
139
+ if (!url) {
140
+ throw new Error(
141
+ stack
142
+ ? "oke boot: postgres driver needs DATABASE_URL (did `oke dev -s` write .env.stack?)"
143
+ : "oke boot: postgres driver needs DATABASE_URL",
144
+ );
145
+ }
146
+ return url;
147
+ }
148
+ if (sqlId === "sqlite") {
149
+ return process.env.OKE_SQLITE_URL ?? ".oke/app.sqlite";
150
+ }
151
+ return ":memory:";
152
+ }
153
+
154
+ function kvUrlFor(kvId: string, stack: boolean): string | undefined {
155
+ if (kvId !== "redis") return undefined;
156
+ const url = process.env.REDIS_URL ?? process.env.OKE_STORE_KV_URL ?? undefined;
157
+ if (!url) {
158
+ throw new Error(
159
+ stack
160
+ ? "oke boot: redis driver needs REDIS_URL (did `oke dev -s` write .env.stack?)"
161
+ : "oke boot: redis driver needs REDIS_URL",
162
+ );
163
+ }
164
+ return url;
165
+ }
166
+
167
+ function isSqlDecl(
168
+ decl: StoreDecl,
169
+ ): decl is Extract<StoreDecl, { facet: "sql" }> {
170
+ return decl.facet === "sql";
171
+ }
172
+
173
+ function isKvDecl(
174
+ decl: StoreDecl,
175
+ ): decl is Extract<StoreDecl, { facet: "kv" }> {
176
+ return decl.facet === "kv";
177
+ }