okengine 0.7.0 → 0.9.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 (125) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. package/src/plugins/phone-number.ts +0 -149
@@ -202,7 +202,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
202
202
  },
203
203
  {
204
204
  name: "db",
205
- summary: "domain schema — push · generate · migrate",
205
+ summary: "domain schema — push · generate · migrate · seed",
206
206
  subcommands: [
207
207
  {
208
208
  name: "push",
@@ -215,6 +215,12 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
215
215
  valueName: "path",
216
216
  summary: "drizzle.config.ts path",
217
217
  },
218
+ {
219
+ long: "--env",
220
+ takesValue: true,
221
+ valueName: "name",
222
+ summary: "Config env (local|docker|test|prod)",
223
+ },
218
224
  HELP,
219
225
  ],
220
226
  },
@@ -229,6 +235,12 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
229
235
  valueName: "path",
230
236
  summary: "drizzle.config.ts path",
231
237
  },
238
+ {
239
+ long: "--env",
240
+ takesValue: true,
241
+ valueName: "name",
242
+ summary: "Config env (local|docker|test|prod)",
243
+ },
232
244
  HELP,
233
245
  ],
234
246
  },
@@ -243,6 +255,30 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
243
255
  valueName: "path",
244
256
  summary: "drizzle.config.ts path",
245
257
  },
258
+ {
259
+ long: "--env",
260
+ takesValue: true,
261
+ valueName: "name",
262
+ summary: "Config env (local|docker|test|prod)",
263
+ },
264
+ HELP,
265
+ ],
266
+ },
267
+ {
268
+ name: "seed",
269
+ summary: "Run defineSeed (essential + env category); never at boot",
270
+ flags: [
271
+ {
272
+ long: "--env",
273
+ takesValue: true,
274
+ valueName: "name",
275
+ summary: "Config env (local|docker|test|prod)",
276
+ },
277
+ {
278
+ long: "--force",
279
+ summary: "Skip docker/prod confirmation prompt",
280
+ },
281
+ ENTRY,
246
282
  HELP,
247
283
  ],
248
284
  },
@@ -293,7 +329,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
293
329
  {
294
330
  long: "--prod",
295
331
  short: "-p",
296
- summary: "Prod overlays (healthchecks, limits, replicas)",
332
+ summary: "Prod overlays (app /_/ready healthcheck, deploy, limits)",
297
333
  },
298
334
  OUT,
299
335
  CONFIG,
@@ -114,6 +114,7 @@ const TABLE_ARG_METHODS = new Set([
114
114
  "findById",
115
115
  "exists",
116
116
  "update",
117
+ "upsert",
117
118
  "increment",
118
119
  "delete",
119
120
  ]);
@@ -73,6 +73,8 @@ export interface DriversConfig {
73
73
  readonly store?: StoreDriversConfig;
74
74
  readonly signal?: EnvDriverMap;
75
75
  readonly clock?: EnvDriverMap;
76
+ /** Durable-run journal: `memory` · `file` · `postgres`. */
77
+ readonly journal?: EnvDriverMap;
76
78
  readonly vault?: EnvDriverMap;
77
79
  readonly channel?: ChannelDriversConfig;
78
80
  readonly ai?: EnvDriverMap;
@@ -283,6 +285,7 @@ export function normalizeDriversConfig(
283
285
  ...(store !== undefined ? { store } : {}),
284
286
  signal: normalizeEnvDriverMap(drivers.signal as RawEnvDriverMap),
285
287
  clock: normalizeEnvDriverMap(drivers.clock as RawEnvDriverMap),
288
+ journal: normalizeEnvDriverMap(drivers.journal as RawEnvDriverMap),
286
289
  vault: normalizeEnvDriverMap(drivers.vault as RawEnvDriverMap),
287
290
  ...(channel !== undefined ? { channel } : {}),
288
291
  ai: normalizeEnvDriverMap(drivers.ai as RawEnvDriverMap),
@@ -333,6 +336,7 @@ export function fillDriversDockerFromProd(
333
336
  ...(store !== undefined ? { store } : {}),
334
337
  signal: fillDockerFromProd(drivers.signal),
335
338
  clock: fillDockerFromProd(drivers.clock),
339
+ journal: fillDockerFromProd(drivers.journal),
336
340
  vault: fillDockerFromProd(drivers.vault),
337
341
  ...(channel !== undefined ? { channel } : {}),
338
342
  ai: fillDockerFromProd(drivers.ai),
@@ -158,7 +158,9 @@ export function migrateOperatorSchema(db: Database): void {
158
158
  revoked_at INTEGER,
159
159
  created_at INTEGER NOT NULL,
160
160
  expires_at INTEGER NOT NULL,
161
- last_active_at INTEGER
161
+ last_active_at INTEGER,
162
+ scopes TEXT NOT NULL DEFAULT '[]',
163
+ audience TEXT
162
164
  );
163
165
  CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.refreshTokens} (
164
166
  id TEXT PRIMARY KEY NOT NULL,
@@ -170,20 +172,28 @@ export function migrateOperatorSchema(db: Database): void {
170
172
  revoked_at INTEGER
171
173
  );
172
174
  `);
173
- ensureSessionLastActiveColumn(db);
175
+ ensureSessionColumns(db);
174
176
  }
175
177
 
176
178
  /**
177
- * Add `last_active_at` when opening an older console.sqlite that predates idle TTL.
179
+ * Add session columns when opening an older console.sqlite.
178
180
  *
179
181
  * @param db - Open database
180
182
  */
181
- function ensureSessionLastActiveColumn(db: Database): void {
183
+ function ensureSessionColumns(db: Database): void {
182
184
  const cols = db.query(`PRAGMA table_info(${AUTH_TABLES.sessions})`).all() as Array<{
183
185
  name: string;
184
186
  }>;
185
- if (cols.some((c) => c.name === "last_active_at")) return;
186
- db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN last_active_at INTEGER`);
187
+ const names = new Set(cols.map((c) => c.name));
188
+ if (!names.has("last_active_at")) {
189
+ db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN last_active_at INTEGER`);
190
+ }
191
+ if (!names.has("scopes")) {
192
+ db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN scopes TEXT NOT NULL DEFAULT '[]'`);
193
+ }
194
+ if (!names.has("audience")) {
195
+ db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN audience TEXT`);
196
+ }
187
197
  }
188
198
 
189
199
  /**
@@ -196,7 +206,7 @@ export function loadSessionStore(db: Database): SessionStore {
196
206
 
197
207
  const sessionRows = db
198
208
  .query(
199
- `SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at
209
+ `SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at, scopes, audience
200
210
  FROM ${AUTH_TABLES.sessions}`,
201
211
  )
202
212
  .all() as Array<{
@@ -208,9 +218,20 @@ export function loadSessionStore(db: Database): SessionStore {
208
218
  created_at: number;
209
219
  expires_at: number;
210
220
  last_active_at: number | null;
221
+ scopes: string | null;
222
+ audience: string | null;
211
223
  }>;
212
224
 
213
225
  for (const row of sessionRows) {
226
+ let scopes: string[] = [];
227
+ if (row.scopes) {
228
+ try {
229
+ const parsed = JSON.parse(row.scopes) as unknown;
230
+ if (Array.isArray(parsed)) scopes = parsed.map(String);
231
+ } catch {
232
+ scopes = [];
233
+ }
234
+ }
214
235
  const session: SessionRow = {
215
236
  id: row.id,
216
237
  plane: row.plane,
@@ -220,6 +241,8 @@ export function loadSessionStore(db: Database): SessionStore {
220
241
  createdAt: row.created_at,
221
242
  expiresAt: row.expires_at,
222
243
  lastActiveAt: row.last_active_at ?? row.created_at,
244
+ scopes,
245
+ ...(row.audience ? { audience: row.audience } : {}),
223
246
  };
224
247
  store.sessions.set(session.id, session);
225
248
  }
@@ -267,8 +290,8 @@ export function persistSessions(db: Database, store: SessionStore): void {
267
290
 
268
291
  const insertSession = db.query(
269
292
  `INSERT INTO ${AUTH_TABLES.sessions}
270
- (id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at)
271
- VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires, $lastActive)`,
293
+ (id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at, scopes, audience)
294
+ VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires, $lastActive, $scopes, $audience)`,
272
295
  );
273
296
  for (const session of store.sessions.values()) {
274
297
  insertSession.run({
@@ -280,6 +303,8 @@ export function persistSessions(db: Database, store: SessionStore): void {
280
303
  $created: session.createdAt,
281
304
  $expires: session.expiresAt,
282
305
  $lastActive: session.lastActiveAt,
306
+ $scopes: JSON.stringify(session.scopes ?? []),
307
+ $audience: session.audience ?? null,
283
308
  });
284
309
  }
285
310
 
@@ -5,6 +5,10 @@
5
5
  * 2. `compose.<role>.yml` — per-role services (generated)
6
6
  * 3. `compose.prod.yml` — prod overlays (generated when `--prod`)
7
7
  * 4. `compose.override.yml` — user-owned; oke never writes it
8
+ *
9
+ * Also emits {@link COMPOSE_ALL} — layers 1–3 deep-merged into one file
10
+ * (for Swarm `stack deploy -c` and single-file preferrers). Not part of the
11
+ * `-f` merge order; never includes layer 4.
8
12
  */
9
13
 
10
14
  import { defaultHostPort, envPrefix, serviceNameFor, toYaml } from "./helpers.ts";
@@ -24,6 +28,12 @@ import { APP_PORT } from "../runtime/types.ts";
24
28
  /** Canonical layer-4 filename — never written by derivation. */
25
29
  export const COMPOSE_OVERRIDE = "compose.override.yml";
26
30
 
31
+ /**
32
+ * Fully merged compose (layers 1–3) — additive single-file alternative.
33
+ * Does not replace or shadow the base {@code compose.yml} layer.
34
+ */
35
+ export const COMPOSE_ALL = "compose.all.yml";
36
+
27
37
  /**
28
38
  * Relative path refs for compose files living under {@link DeriveOptions.composeDir}.
29
39
  *
@@ -85,6 +95,8 @@ export function emitComposeLayers(
85
95
  const includeApp = options.includeApp !== false;
86
96
  const paths = composePathRefs(options.composeDir ?? DEFAULT_DOCKER_DIR);
87
97
  const files: GeneratedFile[] = [];
98
+ /** Layers 1–3 as objects — same merge Compose would apply via `-f` order. */
99
+ const mergeLayers: Record<string, unknown>[] = [];
88
100
 
89
101
  // Layer 1 — project name + network (+ optional app for deploy / oke docker)
90
102
  const base: Record<string, unknown> = {
@@ -92,21 +104,29 @@ export function emitComposeLayers(
92
104
  networks: { oke: { driver: "bridge" } },
93
105
  };
94
106
  if (includeApp) {
107
+ // `image` + `build`: local compose builds and tags; Swarm stack deploy
108
+ // ignores `build` and pulls/uses the pre-built tag.
109
+ // When an opt-in `proxy` role is present, the edge publishes 80/443 —
110
+ // leave `app` unpublished so `docker compose up --scale app=N` works.
111
+ const hasProxy = specs.some((s) => s.role === "proxy");
112
+ const backendDeps = specs.filter((s) => s.role !== "proxy");
95
113
  base.services = {
96
114
  app: {
115
+ image: `oke-${app}:latest`,
97
116
  build: {
98
117
  context: paths.buildContext,
99
118
  dockerfile: paths.dockerfile,
100
119
  },
101
- ports: [`${appPort}:${appPort}`],
120
+ ...(hasProxy ? {} : { ports: [`${appPort}:${appPort}`] }),
102
121
  env_file: [paths.envFile],
103
122
  depends_on: Object.fromEntries(
104
- specs.map((s) => [s.serviceName, { condition: "service_healthy" }]),
123
+ backendDeps.map((s) => [s.serviceName, { condition: "service_healthy" }]),
105
124
  ),
106
125
  networks: ["oke"],
107
126
  },
108
127
  };
109
128
  }
129
+ mergeLayers.push(base);
110
130
  files.push({ path: "compose.yml", content: `${toYaml(base)}\n` });
111
131
 
112
132
  // Layer 2 — per-role
@@ -134,26 +154,67 @@ export function emitComposeLayers(
134
154
  if (applied.healthcheck) service.healthcheck = applied.healthcheck;
135
155
  if (applied.volumes) service.volumes = applied.volumes;
136
156
  if (applied.user) service.user = applied.user;
157
+ if (applied.ulimits) service.ulimits = applied.ulimits;
158
+ if (applied.labels) service.labels = applied.labels;
159
+ if (applied.dependsOn) {
160
+ const deps = { ...applied.dependsOn };
161
+ if (!includeApp) delete deps.app;
162
+ if (Object.keys(deps).length > 0) service.depends_on = deps;
163
+ }
137
164
 
138
- const namedVolumes = namedVolumeDecls(applied.volumes);
165
+ const namedVolumes = namedVolumeDecls([
166
+ ...(applied.volumes ?? []),
167
+ ...extraServiceVolumes(applied.services),
168
+ ]);
139
169
  const doc: Record<string, unknown> = {
140
- services: { [spec.serviceName]: service },
170
+ services: { [spec.serviceName]: service, ...applied.services },
141
171
  networks: { oke: { external: false } },
142
172
  };
143
173
  if (Object.keys(namedVolumes).length > 0) {
144
174
  doc.volumes = namedVolumes;
145
175
  }
176
+ mergeLayers.push(doc);
146
177
  const path = `compose.${spec.role}.yml`;
147
178
  files.push({ path, content: `${toYaml(doc)}\n` });
148
179
  }
149
180
 
150
- // Layer 3 — prod overlay
181
+ // Layer 3 — prod overlay (Swarm-aware deploy + app readiness healthcheck)
151
182
  if (options.prod) {
152
183
  const prodServices: Record<string, unknown> = {};
153
184
  if (includeApp) {
154
185
  prodServices.app = {
186
+ // Single Docker HEALTHCHECK — Swarm has no separate readiness/liveness.
187
+ // Prefer kernel readiness so the routing mesh and rolling updates wait
188
+ // out booting / orphan_scan (see GET /_/ready). App GET /health stays
189
+ // for external monitors only.
190
+ healthcheck: {
191
+ test: [
192
+ "CMD",
193
+ "bun",
194
+ "-e",
195
+ `fetch("http://127.0.0.1:${appPort}/_/ready").then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))`,
196
+ ],
197
+ interval: "10s",
198
+ timeout: "3s",
199
+ retries: 3,
200
+ start_period: "60s",
201
+ },
202
+ // Match installGracefulShutdown lease release window (≥ Signal TTL).
203
+ stop_grace_period: "30s",
155
204
  deploy: {
156
205
  replicas: 1,
206
+ update_config: {
207
+ parallelism: 1,
208
+ delay: "10s",
209
+ failure_action: "rollback",
210
+ order: "start-first",
211
+ },
212
+ restart_policy: {
213
+ condition: "on-failure",
214
+ delay: "5s",
215
+ max_attempts: 3,
216
+ window: "120s",
217
+ },
157
218
  resources: {
158
219
  limits: { cpus: "1.0", memory: "512M" },
159
220
  },
@@ -171,12 +232,21 @@ export function emitComposeLayers(
171
232
  secrets: secretNames(spec),
172
233
  };
173
234
  }
235
+ const prodDoc = { services: prodServices };
236
+ mergeLayers.push(prodDoc);
174
237
  files.push({
175
238
  path: "compose.prod.yml",
176
- content: `${toYaml({ services: prodServices })}\n`,
239
+ content: `${toYaml(prodDoc)}\n`,
177
240
  });
178
241
  }
179
242
 
243
+ // Additive single-file merge of layers 1–3 (not in `-f` order; no layer 4).
244
+ const merged = mergeLayers.reduce<Record<string, unknown>>(
245
+ (acc, layer) => deepMergeCompose(acc, layer),
246
+ {},
247
+ );
248
+ files.push({ path: COMPOSE_ALL, content: `${toYaml(merged)}\n` });
249
+
180
250
  // Layer 4 — never written. Document merge order only.
181
251
  const composeFiles = [
182
252
  "compose.yml",
@@ -188,6 +258,33 @@ export function emitComposeLayers(
188
258
  return { files, composeFiles };
189
259
  }
190
260
 
261
+ /**
262
+ * Deep-merge compose documents the way Docker Compose merges `-f` overlays:
263
+ * maps recurse; sequences and scalars from the later document replace earlier.
264
+ *
265
+ * @param base - Earlier layer
266
+ * @param overlay - Later layer
267
+ */
268
+ export function deepMergeCompose(
269
+ base: Record<string, unknown>,
270
+ overlay: Record<string, unknown>,
271
+ ): Record<string, unknown> {
272
+ const out: Record<string, unknown> = { ...base };
273
+ for (const [key, value] of Object.entries(overlay)) {
274
+ const prev = out[key];
275
+ if (isPlainObject(prev) && isPlainObject(value)) {
276
+ out[key] = deepMergeCompose(prev, value);
277
+ } else {
278
+ out[key] = value;
279
+ }
280
+ }
281
+ return out;
282
+ }
283
+
284
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
285
+ return value !== null && typeof value === "object" && !Array.isArray(value);
286
+ }
287
+
191
288
  /**
192
289
  * Build docker env key/value map (credentials + connection URLs).
193
290
  *
@@ -227,6 +324,19 @@ export function buildStackEnv(
227
324
  env[`${prefix}_DB`] = spec.credentials.database;
228
325
  env[`${prefix}_URL`] = url;
229
326
  env.DATABASE_URL = url;
327
+ } else if (spec.role === "pgdog") {
328
+ // URL uses store.sql credentials (same user/db the pooler proxies).
329
+ const sql = specs.find((s) => s.role === "store.sql");
330
+ const creds = sql?.credentials ?? spec.credentials;
331
+ const poolUrl = recipe.url(spec, {
332
+ host,
333
+ port: spec.hostPort,
334
+ user: creds.user,
335
+ password: creds.password,
336
+ database: creds.database,
337
+ });
338
+ env[`${prefix}_URL`] = poolUrl;
339
+ env.OKE_PGDOG_URL = poolUrl;
230
340
  } else if (spec.role === "store.kv") {
231
341
  env[`${prefix}_PASSWORD`] = spec.credentials.password;
232
342
  env[`${prefix}_URL`] = url;
@@ -259,6 +369,10 @@ export function buildStackEnv(
259
369
  // Ollama: standalone HTTP URL; model is a stack control (OKE_AI_MODEL).
260
370
  env[`${prefix}_URL`] = url;
261
371
  env.OKE_AI_URL = url;
372
+ } else if (spec.role === "proxy") {
373
+ // Edge TLS terminator — host is the public URL, not a driver DSN.
374
+ env[`${prefix}_URL`] = url;
375
+ env.OKE_PROXY_URL = url;
262
376
  } else {
263
377
  env[`${prefix}_USER`] = spec.credentials.user;
264
378
  env[`${prefix}_PASSWORD`] = spec.credentials.password;
@@ -266,6 +380,21 @@ export function buildStackEnv(
266
380
  env[`${prefix}_URL`] = url;
267
381
  }
268
382
  }
383
+ // When PgDog sits in front of Postgres, apps talk to the pooler — wire-protocol
384
+ // transparent; zero application changes (Bun.SQL / Drizzle / any Postgres client).
385
+ const sqlSpec = specs.find((s) => s.role === "store.sql");
386
+ const pgdogSpec = specs.find((s) => s.role === "pgdog");
387
+ if (sqlSpec && pgdogSpec) {
388
+ const poolRecipe = recipeFor(pgdogSpec.image, recipes);
389
+ env.DATABASE_URL = poolRecipe.url(pgdogSpec, {
390
+ host,
391
+ port: pgdogSpec.hostPort,
392
+ user: sqlSpec.credentials.user,
393
+ password: sqlSpec.credentials.password,
394
+ database: sqlSpec.credentials.database,
395
+ });
396
+ }
397
+
269
398
  for (const key of STACK_CONTROL_KEYS) {
270
399
  const value = controls[key];
271
400
  if (value !== undefined) env[key] = value;
@@ -276,6 +405,7 @@ export function buildStackEnv(
276
405
  /** Friendly section titles for known compose roles. */
277
406
  const ROLE_SECTION_TITLE: Readonly<Record<string, string>> = {
278
407
  "store.sql": "store.sql — Postgres",
408
+ pgdog: "pgdog — connection pooler (in front of Postgres)",
279
409
  "store.kv": "store.kv — Redis",
280
410
  "store.files": "store.files — object storage (S3)",
281
411
  "store.index": "store.index — search index",
@@ -283,11 +413,13 @@ const ROLE_SECTION_TITLE: Readonly<Record<string, string>> = {
283
413
  signal: "signal — message bus",
284
414
  vault: "vault — OpenBao",
285
415
  ai: "ai — Ollama (local models)",
416
+ proxy: "proxy — TLS terminator (Caddy / Traefik)",
286
417
  };
287
418
 
288
419
  /** Friendly aliases emitted beside their role block. */
289
420
  const ROLE_ALIASES: Readonly<Record<string, readonly string[]>> = {
290
421
  "store.sql": ["DATABASE_URL", "PGDATA", "POSTGRES_INITDB_ARGS"],
422
+ pgdog: ["OKE_PGDOG_URL"],
291
423
  "store.kv": ["REDIS_URL", "OKE_STORE_KV_MAXMEMORY", "OKE_STORE_KV_MAXMEMORY_POLICY"],
292
424
  "store.files": [
293
425
  "S3_ACCESS_KEY_ID",
@@ -311,6 +443,7 @@ const ROLE_ALIASES: Readonly<Record<string, readonly string[]>> = {
311
443
  "MP_SMTP_AUTH_ALLOW_INSECURE",
312
444
  ],
313
445
  ai: ["OKE_AI_URL", "OKE_AI_MODEL"],
446
+ proxy: ["OKE_PROXY_HOST", "OKE_PROXY_ACME_EMAIL"],
314
447
  };
315
448
 
316
449
  /** Optional controls documented in `.env.docker` and preserved on regeneration. */
@@ -327,6 +460,7 @@ const ROLE_CONTROL_EXAMPLES: Readonly<Record<string, readonly string[]>> = {
327
460
  ],
328
461
  // qwen3.5:9b is a balanced local-dev starting point — override freely.
329
462
  ai: ["OKE_AI_MODEL=qwen3.5:9b"],
463
+ proxy: ["OKE_PROXY_HOST=localhost", "OKE_PROXY_ACME_EMAIL=admin@example.com"],
330
464
  };
331
465
 
332
466
  /**
@@ -344,6 +478,9 @@ function roleFromEnvKey(key: string): string | undefined {
344
478
  if (key === "PGDATA" || key === "POSTGRES_INITDB_ARGS") return "store.sql";
345
479
  if (key.startsWith("OKE_STORE_KV_MAXMEMORY")) return "store.kv";
346
480
  if (key === "OKE_AI_URL" || key === "OKE_AI_MODEL" || key === "OLLAMA_HOST") return "ai";
481
+ if (key === "OKE_PROXY_URL" || key === "OKE_PROXY_HOST" || key === "OKE_PROXY_ACME_EMAIL") {
482
+ return "proxy";
483
+ }
347
484
  return undefined;
348
485
  }
349
486
 
@@ -451,6 +588,25 @@ function namedVolumeDecls(
451
588
  return out;
452
589
  }
453
590
 
591
+ /**
592
+ * Volume mounts declared on companion services from {@link RecipeApplyResult.services}.
593
+ *
594
+ * @param services - Extra compose service fragments
595
+ */
596
+ function extraServiceVolumes(
597
+ services: Readonly<Record<string, Record<string, unknown>>> | undefined,
598
+ ): string[] {
599
+ const out: string[] = [];
600
+ for (const svc of Object.values(services ?? {})) {
601
+ const vols = svc.volumes;
602
+ if (!Array.isArray(vols)) continue;
603
+ for (const v of vols) {
604
+ if (typeof v === "string") out.push(v);
605
+ }
606
+ }
607
+ return out;
608
+ }
609
+
454
610
  function escapeEnv(value: string): string {
455
611
  if (/[\s#"'$\\]/.test(value)) {
456
612
  return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
@@ -11,14 +11,17 @@ import {
11
11
  formatStackEnv,
12
12
  } from "./compose.ts";
13
13
  import { emitDockerfile } from "./dockerfile.ts";
14
+ import { buildCaddyfile } from "./recipes/caddy.ts";
15
+ import { buildPgDogToml, buildPgDogUsersToml } from "./recipes/pgdog.ts";
14
16
  import type { DeriveOptions, DeriveResult, GeneratedFile } from "./types.ts";
15
17
  import { DEFAULT_DOCKER_DIR } from "./types.ts";
18
+ import { APP_PORT } from "../runtime/types.ts";
16
19
 
17
20
  /**
18
21
  * Derive infrastructure files from normalised image pins.
19
22
  *
20
- * Credentials land only in the returned `stackEnv` (for `.env.docker`);
21
- * generated YAML never contains cleartext secrets. Layer 4
23
+ * Credentials land only in the returned `stackEnv` (for `.env.docker`) and
24
+ * in `users.toml` when PgDog is present — never in generated YAML. Layer 4
22
25
  * (`compose.override.yml`) is listed in `composeFiles` but never written.
23
26
  *
24
27
  * @param options - Images / app / prod flag
@@ -43,7 +46,12 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
43
46
  content: emitDockerfile({ appPort: normalised.appPort }),
44
47
  };
45
48
  // Always emit Dockerfile for deploy; stack-only runs ignore it.
46
- const files = [dockerfile, ...composeFilesContent];
49
+ const files = [
50
+ dockerfile,
51
+ ...composeFilesContent,
52
+ ...pgdogConfigFiles(specs),
53
+ ...proxyConfigFiles(specs, normalised.appPort),
54
+ ];
47
55
 
48
56
  for (const f of files) {
49
57
  if (f.path.endsWith(".yml") || f.path === "Dockerfile") {
@@ -65,6 +73,55 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
65
73
  return { specs, files, stackEnv, composeFiles };
66
74
  }
67
75
 
76
+ /**
77
+ * Emit PgDog TOML configs when both `pgdog` and `store.sql` are in the stack.
78
+ *
79
+ * `pgdog.toml` has no secrets; `users.toml` mirrors store.sql credentials
80
+ * (same trust boundary as `.env.docker` — do not commit).
81
+ *
82
+ * @param specs - Normalised services
83
+ */
84
+ function pgdogConfigFiles(specs: DeriveResult["specs"]): GeneratedFile[] {
85
+ const sql = specs.find((s) => s.role === "store.sql");
86
+ const pooler = specs.find((s) => s.role === "pgdog");
87
+ if (!sql || !pooler) return [];
88
+ return [
89
+ {
90
+ path: "pgdog.toml",
91
+ content: buildPgDogToml({ database: sql.credentials.database }),
92
+ },
93
+ {
94
+ path: "users.toml",
95
+ content: buildPgDogUsersToml({
96
+ user: sql.credentials.user,
97
+ password: sql.credentials.password,
98
+ database: sql.credentials.database,
99
+ }),
100
+ },
101
+ ];
102
+ }
103
+
104
+ /**
105
+ * Emit proxy companion configs when `images.proxy` is pinned.
106
+ *
107
+ * Caddy gets a generated `Caddyfile`. Traefik configures via Docker labels
108
+ * (no companion file).
109
+ *
110
+ * @param specs - Normalised services
111
+ * @param appPort - App listen port (default 6530)
112
+ */
113
+ function proxyConfigFiles(
114
+ specs: DeriveResult["specs"],
115
+ appPort: number | undefined,
116
+ ): GeneratedFile[] {
117
+ const proxy = specs.find((s) => s.role === "proxy");
118
+ if (!proxy) return [];
119
+ if (/caddy/i.test(proxy.image)) {
120
+ return [{ path: "Caddyfile", content: buildCaddyfile({ appPort: appPort ?? APP_PORT }) }];
121
+ }
122
+ return [];
123
+ }
124
+
68
125
  /**
69
126
  * Write derived files to disk. Never writes `compose.override.yml` or
70
127
  * credential values into YAML. Optionally writes `docker/.env.docker`.