create-oke 0.10.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-oke",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "Scaffold an okengine app — bunx create-oke@latest <name>",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/agents-md.ts CHANGED
@@ -89,7 +89,7 @@ App \`:6530\` · Console \`:6533\` · MCP \`:6535\`.
89
89
  - ❌ Inventing a ninth “element” or a parallel handler stack beside Flows
90
90
  - ✅ New capability = new **driver** on an existing element, or a new Flow
91
91
  - ❌ Untyped HTTP handlers that skip \`on\` / \`flow\` / contracts
92
- - ✅ \`on(http.get("/…"), flow({ in, out, do }))\`
92
+ - ✅ \`on(http.get("/…"), flow(name, { in, out, do }))\`
93
93
 
94
94
  ## Learn more
95
95
 
@@ -5,6 +5,7 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { OLLAMA_IMAGE } from "../drivers-catalog.ts";
8
+ import { extractImages, findImagesBlock, replaceImagesBlock } from "../transform.ts";
8
9
 
9
10
  /** Choices applied to the project. */
10
11
  export type AiSetupApplyInput = {
@@ -127,24 +128,19 @@ export function upsertAiDrivers(source: string, driver: string): string {
127
128
  }
128
129
 
129
130
  /**
131
+ * Set one dotted role's image pin, preserving every other pin (including
132
+ * `store.*` / `channel.*` nesting) via {@link extractImages} /
133
+ * {@link replaceImagesBlock} — a parse/set/render round-trip rather than a
134
+ * single-line regex, so nested sub-objects are never corrupted.
135
+ *
130
136
  * @param source - Config source
131
- * @param key - Image role
137
+ * @param key - Image role (dotted for `store.*` / `channel.*`, flat otherwise)
132
138
  * @param image - Image ref
133
139
  */
134
140
  export function upsertImage(source: string, key: string, image: string): string {
135
- const keyLit = key.includes(".") ? `"${key}"` : key;
136
- const line = ` ${keyLit}: "${image}",`;
137
- const imagesRe = /images:\s*\{([\s\S]*?)\n\s*\}/;
138
- const m = imagesRe.exec(source);
139
- if (!m) return source;
140
- const body = m[1]!;
141
- if (new RegExp(`${keyLit}\\s*:`).test(body) || new RegExp(`"${key}"\\s*:`).test(body)) {
142
- return source.replace(
143
- new RegExp(`(["']?${key.replace(".", "\\.")}["']?\\s*:\\s*)"[^"]*"`),
144
- `$1"${image}"`,
145
- );
146
- }
147
- return source.replace(imagesRe, `images: {${body}\n${line}\n }`);
141
+ if (!findImagesBlock(source)) return source;
142
+ const images = { ...extractImages(source), [key]: image };
143
+ return replaceImagesBlock(source, images);
148
144
  }
149
145
 
150
146
  /** Options for {@link upsertEnv}. */
package/src/cli.test.ts CHANGED
@@ -261,8 +261,8 @@ describe("interactive branches", () => {
261
261
  expect(code).toBe(0);
262
262
  expect(readFileSync(join(dir, ".oke", "mode"), "utf8").trim()).toBe("docker");
263
263
  const notes = readFileSync(join(dir, "src", "flows", "notes", "index.ts"), "utf8");
264
- expect(notes).toContain('name: "notes.digest"');
265
- expect(notes).toContain('name: "notes.attach"');
264
+ expect(notes).toContain('flow("notes.digest"');
265
+ expect(notes).toContain('flow("notes.attach"');
266
266
  } finally {
267
267
  rmSync(dir, { recursive: true, force: true });
268
268
  }
@@ -564,8 +564,10 @@ describe("scaffold structure", () => {
564
564
  const appTs = readFileSync(join(result.targetDir, "src/app.ts"), "utf8");
565
565
  expect(appTs).not.toMatch(/Object\.assign/);
566
566
  expect(appTs).not.toMatch(/env:\s*["']test["']/);
567
- expect(appTs).toMatch(/stores:\s*\[/);
568
- expect(appTs).toMatch(/oke\(\{[\s\S]*stores:/);
567
+ // stores/secrets/signals/channel.templates auto-register — the
568
+ // minimal `oke({ name: "notes" })` shape carries no explicit arrays.
569
+ expect(appTs).not.toMatch(/stores:\s*\[/);
570
+ expect(appTs).toMatch(/oke\(\{\s*name:\s*["']notes["']\s*\}\)/);
569
571
  const pkg = JSON.parse(readFileSync(join(result.targetDir, "package.json"), "utf8")) as {
570
572
  name: string;
571
573
  dependencies: { okengine: string };
@@ -610,8 +612,8 @@ describe("scaffold structure", () => {
610
612
  expect(result.files).toContain(path);
611
613
  }
612
614
  const notes = readFileSync(join(result.targetDir, "src/flows/notes/index.ts"), "utf8");
613
- expect(notes).toContain('name: "notes.create"');
614
- expect(notes).not.toContain('name: "notes.digest"');
615
+ expect(notes).toContain('flow("notes.create"');
616
+ expect(notes).not.toContain('flow("notes.digest"');
615
617
  const all = result.files
616
618
  .filter((f) => f.endsWith(".ts") || f.endsWith(".md"))
617
619
  .map((f) => readFileSync(join(result.targetDir, f), "utf8"))
@@ -21,7 +21,11 @@ function evalConfig(source: string): {
21
21
  ai?: unknown;
22
22
  channel?: { ai?: unknown; email?: unknown };
23
23
  };
24
- images?: Record<string, string>;
24
+ images?: {
25
+ store?: Record<string, string>;
26
+ channel?: Record<string, string>;
27
+ ai?: string;
28
+ };
25
29
  } {
26
30
  const body = source
27
31
  .replace(/^import\s+[\s\S]*?from\s+["'][^"']+["'];?\s*/m, "")
@@ -60,7 +64,7 @@ describe("applyCreateAnswers images", () => {
60
64
  expect(next).not.toMatch(/images:\s*\{[^}]*\blocal:\s*"/s);
61
65
  expect(next).not.toMatch(/images:\s*\{[^}]*\bdocker:\s*"/s);
62
66
  expect(next).not.toMatch(/images:\s*\{[^}]*:\s*"libsql"/s);
63
- expect(next).toContain('"store.sql": "postgres:18-alpine"');
67
+ expect(next).toMatch(/images:\s*\{\s*store:\s*\{[^}]*\bsql: "postgres:18-alpine"/s);
64
68
  });
65
69
 
66
70
  test("index meilisearch pins store.index image without comment leakage", () => {
@@ -68,7 +72,7 @@ describe("applyCreateAnswers images", () => {
68
72
  templateConfig(),
69
73
  defaultsWithIndex("meilisearch", "meilisearch"),
70
74
  );
71
- expect(next).toContain('"store.index": "getmeili/meilisearch:v1.37"');
75
+ expect(next).toMatch(/images:\s*\{\s*store:\s*\{[^}]*\bindex: "getmeili\/meilisearch:v1.37"/s);
72
76
  expect(next).not.toMatch(/images:\s*\{[^}]*\btest:\s*"memory"/s);
73
77
  });
74
78
  });
package/src/transform.ts CHANGED
@@ -298,8 +298,11 @@ ${close}}`;
298
298
  /** Env-column keys that must never appear under `images`. */
299
299
  const IMAGE_ENV_COLUMNS = new Set(["local", "docker", "test", "prod"]);
300
300
 
301
- /** Known compose role keys written into `images`. */
302
- const IMAGE_ROLE_KEY = /^(?:store\.(?:sql|kv|files|index)|channel\.email|vault|ai|pgdog)$/;
301
+ /** Known compose role keys written into `images` (dotted, post-flatten). */
302
+ const IMAGE_ROLE_KEY = /^(?:store\.(?:sql|kv|files|index)|channel\.email|vault|ai|pgdog|proxy)$/;
303
+
304
+ /** `images` sub-object keys that nest role facets (mirrors `drivers` nesting). */
305
+ const IMAGE_NEST_KEYS = ["store", "channel"] as const;
303
306
 
304
307
  /**
305
308
  * Keep `images` in sync with chosen docker drivers.
@@ -374,43 +377,111 @@ function aiImageForDefaults(defaults: CreateDefaults): string {
374
377
  }
375
378
 
376
379
  /**
377
- * Parse role→image pins from an `images` block.
380
+ * Locate the `images: { }` block by brace depth (not the first `}`) so
381
+ * nested `store: { … }` / `channel: { … }` sub-blocks don't close the match
382
+ * early.
383
+ *
384
+ * @param source - Config source
385
+ */
386
+ export function findImagesBlock(source: string): {
387
+ readonly start: number;
388
+ readonly bodyStart: number;
389
+ readonly bodyEnd: number;
390
+ readonly end: number;
391
+ } | null {
392
+ const m = /images:\s*\{/.exec(source);
393
+ if (!m) return null;
394
+ const start = m.index;
395
+ const openIdx = start + m[0].length - 1;
396
+ let depth = 0;
397
+ for (let i = openIdx; i < source.length; i++) {
398
+ const ch = source[i];
399
+ if (ch === "{") depth++;
400
+ else if (ch === "}") {
401
+ depth--;
402
+ if (depth === 0) return { start, bodyStart: openIdx + 1, bodyEnd: i, end: i + 1 };
403
+ }
404
+ }
405
+ return null;
406
+ }
407
+
408
+ /**
409
+ * Parse dotted role→image pins from an `images` block, flattening one level
410
+ * of `store` / `channel` nesting (mirrors {@link flattenImagesConfig} in
411
+ * `okengine/config`).
378
412
  *
379
413
  * Skips `//` comment lines and rejects env-column keys (`local`/`docker`/…).
380
414
  *
381
415
  * @param source - Config source
382
416
  */
383
- function extractImages(source: string): Record<string, string> {
384
- const m = /images:\s*\{([\s\S]*?)\n\s*\},/.exec(source);
385
- if (!m) return {};
417
+ export function extractImages(source: string): Record<string, string> {
418
+ const block = findImagesBlock(source);
419
+ if (!block) return {};
386
420
  const out: Record<string, string> = {};
387
- for (const line of m[1]!.split("\n")) {
388
- const trimmed = line.trim();
389
- if (!trimmed || trimmed.startsWith("//")) continue;
390
- const hit = /["']?([\w.]+)["']?\s*:\s*"([^"]+)"/.exec(trimmed);
421
+ let context: (typeof IMAGE_NEST_KEYS)[number] | null = null;
422
+ for (const raw of source.slice(block.bodyStart, block.bodyEnd).split("\n")) {
423
+ const line = raw.trim();
424
+ if (!line || line.startsWith("//")) continue;
425
+ const nestOpen = /^(store|channel):\s*\{\s*$/.exec(line);
426
+ if (nestOpen) {
427
+ context = nestOpen[1] as (typeof IMAGE_NEST_KEYS)[number];
428
+ continue;
429
+ }
430
+ if (line === "}," || line === "}") {
431
+ context = null;
432
+ continue;
433
+ }
434
+ const hit = /^["']?([\w.]+)["']?\s*:\s*"([^"]+)"/.exec(line);
391
435
  if (!hit) continue;
392
- const key = hit[1]!;
393
- if (IMAGE_ENV_COLUMNS.has(key) || !IMAGE_ROLE_KEY.test(key)) continue;
436
+ const rawKey = hit[1]!;
437
+ if (IMAGE_ENV_COLUMNS.has(rawKey)) continue;
438
+ const key = context ? `${context}.${rawKey}` : rawKey;
439
+ if (!IMAGE_ROLE_KEY.test(key)) continue;
394
440
  out[key] = hit[2]!;
395
441
  }
396
442
  return out;
397
443
  }
398
444
 
445
+ /**
446
+ * Render dotted role→image pins back into a nested `images: { … }` literal —
447
+ * `store.*` / `channel.*` under their sub-object, everything else flat.
448
+ *
449
+ * @param images - Dotted role → image
450
+ */
451
+ function formatImagesBlock(images: Record<string, string>): string {
452
+ const store: Array<[string, string]> = [];
453
+ const channel: Array<[string, string]> = [];
454
+ const flat: Array<[string, string]> = [];
455
+ for (const [key, value] of Object.entries(images)) {
456
+ if (key.startsWith("store.")) store.push([key.slice("store.".length), value]);
457
+ else if (key.startsWith("channel.")) channel.push([key.slice("channel.".length), value]);
458
+ else flat.push([key, value]);
459
+ }
460
+ const lines: string[] = [];
461
+ if (store.length > 0) {
462
+ lines.push(" store: {");
463
+ for (const [k, v] of store) lines.push(` ${k}: "${v}",`);
464
+ lines.push(" },");
465
+ }
466
+ if (channel.length > 0) {
467
+ lines.push(" channel: {");
468
+ for (const [k, v] of channel) lines.push(` ${k}: "${v}",`);
469
+ lines.push(" },");
470
+ }
471
+ for (const [k, v] of flat) lines.push(` ${k}: "${v}",`);
472
+ return `images: {\n${lines.join("\n")}\n }`;
473
+ }
474
+
399
475
  /**
400
476
  * @param source - Config source
401
- * @param images - Role → image
477
+ * @param images - Dotted role → image
402
478
  */
403
- function replaceImagesBlock(source: string, images: Record<string, string>): string {
404
- const lines = Object.entries(images).map(([k, v]) => {
405
- const key = k.includes(".") ? `"${k}"` : k;
406
- return ` ${key}: "${v}",`;
407
- });
408
- const block = `images: {\n${lines.join("\n")}\n }`;
409
- const re = /images:\s*\{[\s\S]*?\n\s*\}/;
410
- if (!re.test(source)) {
479
+ export function replaceImagesBlock(source: string, images: Record<string, string>): string {
480
+ const block = findImagesBlock(source);
481
+ if (!block) {
411
482
  throw new Error("create-oke: oke.config.ts missing images block");
412
483
  }
413
- return source.replace(re, block);
484
+ return `${source.slice(0, block.start)}${formatImagesBlock(images)}${source.slice(block.end)}`;
414
485
  }
415
486
 
416
487
  /**
@@ -29,13 +29,12 @@ export default defineConfig({
29
29
  test: "memory",
30
30
  prod: "s3",
31
31
  },
32
- // Opt in via create-oke customize or uncomment:
33
- // index: {
34
- // local: "memory",
35
- // docker: "meilisearch",
36
- // test: "memory",
37
- // prod: "meilisearch",
38
- // },
32
+ index: {
33
+ local: "memory",
34
+ docker: "meilisearch",
35
+ test: "memory",
36
+ prod: "meilisearch",
37
+ },
39
38
  },
40
39
  signal: {
41
40
  local: "memory",
@@ -49,7 +48,6 @@ export default defineConfig({
49
48
  test: "frozen",
50
49
  prod: "postgres",
51
50
  },
52
- // Durable-flow journal (durable: true runs) — shared + leased in docker/prod.
53
51
  journal: {
54
52
  local: "memory",
55
53
  docker: "postgres",
@@ -73,13 +71,17 @@ export default defineConfig({
73
71
  // Opt in: create-oke --ai / oke ai setup writes drivers.ai + src/core/ai.ts
74
72
  },
75
73
  images: {
76
- "store.sql": "postgres:18-alpine",
77
- pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
78
- "store.kv": "redis:8-alpine",
79
- "store.files": "rustfs/rustfs:1.0.0-beta.11",
80
- "channel.email": "axllent/mailpit:v1.22.3",
74
+ store: {
75
+ sql: "postgres:18-alpine",
76
+ kv: "redis:8-alpine",
77
+ files: "rustfs/rustfs:1.0.0-beta.11",
78
+ // index: "getmeili/meilisearch:v1.37",
79
+ },
80
+ channel: {
81
+ email: "axllent/mailpit:v1.22.3",
82
+ },
81
83
  vault: "openbao/openbao:2.6.1",
82
- // "store.index": "getmeili/meilisearch:v1.37",
84
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
83
85
  // ai: "ghcr.io/ggml-org/llama.cpp:server-b10290", // or ollama/ollama:0.32.6
84
86
  },
85
87
  i18n: { locales: ["en", "ar"], default: "en", dir: { ar: "rtl" } },
@@ -1,18 +1,15 @@
1
- import { db, files, noteCreatedMail, webhookSecret } from "./core";
1
+ import "./core";
2
2
  import "./locales/en";
3
3
  import "./locales/ar";
4
4
 
5
5
  import { oke } from "okengine";
6
- import * as main from "./flows/main";
7
- import * as notes from "./flows/notes";
8
- import { noteCreated } from "./flows/notes/signals";
6
+ import * as routes from "./flows/generated";
9
7
 
10
- export const app = oke({
11
- name: "notes",
12
- stores: [db, files],
13
- secrets: [webhookSecret],
14
- signals: [noteCreated],
15
- channel: { templates: [noteCreatedMail] },
16
- }).adopt({ main, notes });
8
+ // stores (store.sql/store.files) · secrets (vault.secret) · signals (signal())
9
+ // · channel templates (channel.<medium>().template()) auto-register from
10
+ // `./core` (and `./flows/notes/signals`) — no explicit arrays needed.
11
+ // `./flows/generated` is a real file, regenerated by `oke dev` / `oke build`
12
+ // from every `src/flows/<unit>/index.ts` folder — never edit it by hand.
13
+ export const app = oke({ name: "notes" }).adopt(routes);
17
14
 
18
15
  export type App = typeof app;
@@ -1,5 +1,5 @@
1
1
  import { store } from "okengine";
2
- import * as schema from "../db/schema.decl";
2
+ import * as schema from "@/db/schema.decl";
3
3
 
4
4
  /** App SQL store — tables from {@link schema}. */
5
5
  export const db = store.sql("app", { schema });
@@ -0,0 +1,4 @@
1
+ // AUTO-GENERATED by `oke dev` / `oke build` — do not edit by hand.
2
+ // Regenerated from every `src/flows/<unit>/index.ts` unit folder.
3
+ export * as main from "./main/index.ts";
4
+ export * as notes from "./notes/index.ts";
@@ -4,9 +4,7 @@ import { z } from "zod";
4
4
  /** First-run welcome — visit :6530/ after `oke dev`. */
5
5
  export const root = on(
6
6
  http.get("/").gate(gate.public),
7
- flow({
8
- name: "main.root",
9
- unit: "main",
7
+ flow("main.root", {
10
8
  out: z.object({
11
9
  ok: z.literal(true),
12
10
  app: z.string(),
@@ -25,9 +23,7 @@ export const root = on(
25
23
  /** Liveness for probes and `bun test`. */
26
24
  export const health = on(
27
25
  http.get("/health").gate(gate.public),
28
- flow({
29
- name: "main.health",
30
- unit: "main",
26
+ flow("main.health", {
31
27
  out: z.object({ ok: z.literal(true) }),
32
28
  do: () => ({ ok: true as const }),
33
29
  }),
@@ -1,8 +1,8 @@
1
1
  import { on, flow, http, every, gate, fail } from "okengine";
2
2
  import { eq, isNull } from "drizzle-orm";
3
3
 
4
- import { db, files, noteCreatedMail, webhookSecret } from "../../core";
5
- import { notes } from "../../db/schema.decl";
4
+ import { db, files, noteCreatedMail, webhookSecret } from "@/core";
5
+ import { notes } from "@/db/schema.decl";
6
6
  import {
7
7
  NoteAttachIn,
8
8
  NoteAttachOut,
@@ -23,9 +23,7 @@ import "./signals";
23
23
  /** List active (non-archived) notes, newest first. */
24
24
  export const list = on(
25
25
  http.get("/notes").gate(gate.public),
26
- flow({
27
- name: "notes.list",
28
- unit: "notes",
26
+ flow("notes.list", {
29
27
  out: NoteListOut,
30
28
  effects: { reads: ["sql:app"] },
31
29
  do: async (_input, fx) => {
@@ -47,9 +45,7 @@ export const list = on(
47
45
  /** Create a note, emit `note-created`, touch vault. */
48
46
  export const create = on(
49
47
  http.post("/notes").gate(gate.public),
50
- flow({
51
- name: "notes.create",
52
- unit: "notes",
48
+ flow("notes.create", {
53
49
  in: NoteCreateIn,
54
50
  out: NoteOut,
55
51
  effects: {
@@ -84,9 +80,7 @@ export const create = on(
84
80
  /** Fetch one note by id. */
85
81
  export const get = on(
86
82
  http.get("/notes/:id").gate(gate.public),
87
- flow({
88
- name: "notes.get",
89
- unit: "notes",
83
+ flow("notes.get", {
90
84
  in: NoteIdIn,
91
85
  out: NoteOut,
92
86
  errors: { NotFound },
@@ -108,9 +102,7 @@ export const get = on(
108
102
  /** Soft-archive a note. */
109
103
  export const archive = on(
110
104
  http.post("/notes/:id/archive").gate(gate.public),
111
- flow({
112
- name: "notes.archive",
113
- unit: "notes",
105
+ flow("notes.archive", {
114
106
  in: NoteIdIn,
115
107
  out: NoteOut,
116
108
  errors: { NotFound },
@@ -138,9 +130,7 @@ export const archive = on(
138
130
  /** On create → send the note-created email template. */
139
131
  export const onCreated = on(
140
132
  noteCreated,
141
- flow({
142
- name: "notes.onCreated",
143
- unit: "notes",
133
+ flow("notes.onCreated", {
144
134
  effects: { sends: ["note-created"] },
145
135
  do: async (payload, fx) => {
146
136
  await fx.send(noteCreatedMail, {
@@ -154,9 +144,7 @@ export const onCreated = on(
154
144
  /** Store a text attachment next to a note (`files:uploads`). */
155
145
  export const attach = on(
156
146
  http.post("/notes/:id/attach").gate(gate.public),
157
- flow({
158
- name: "notes.attach",
159
- unit: "notes",
147
+ flow("notes.attach", {
160
148
  in: NoteAttachIn,
161
149
  out: NoteAttachOut,
162
150
  errors: { NotFound },
@@ -174,9 +162,7 @@ export const attach = on(
174
162
  /** Daily count of active notes (frozen under test drivers). */
175
163
  export const digest = on(
176
164
  every("1d"),
177
- flow({
178
- name: "notes.digest",
179
- unit: "notes",
165
+ flow("notes.digest", {
180
166
  out: NoteDigestOut,
181
167
  effects: { reads: ["sql:app"] },
182
168
  do: async (_input, fx) => {
@@ -192,9 +178,7 @@ export const digest = on(
192
178
  */
193
179
  export const summarize = on(
194
180
  http.post("/notes/:id/summarize").gate(gate.public),
195
- flow({
196
- name: "notes.summarize",
197
- unit: "notes",
181
+ flow("notes.summarize", {
198
182
  in: NoteSummarizeIn,
199
183
  out: NoteSummarizeOut,
200
184
  errors: { NotFound },
@@ -6,6 +6,9 @@
6
6
  "moduleDetection": "force",
7
7
  "types": ["bun"],
8
8
  "moduleResolution": "bundler",
9
+ "paths": {
10
+ "@/*": ["./src/*"]
11
+ },
9
12
  "allowImportingTsExtensions": true,
10
13
  "verbatimModuleSyntax": true,
11
14
  "noEmit": true,
@@ -42,7 +42,6 @@ export default defineConfig({
42
42
  test: "frozen",
43
43
  prod: "postgres",
44
44
  },
45
- // Durable-flow journal (durable: true runs) — shared + leased in docker/prod.
46
45
  journal: {
47
46
  local: "memory",
48
47
  docker: "postgres",
@@ -65,12 +64,16 @@ export default defineConfig({
65
64
  },
66
65
  },
67
66
  images: {
68
- "store.sql": "postgres:18-alpine",
69
- pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
70
- "store.kv": "redis:8-alpine",
71
- "store.files": "rustfs/rustfs:1.0.0-beta.11",
72
- "channel.email": "axllent/mailpit:v1.22.3",
67
+ store: {
68
+ sql: "postgres:18-alpine",
69
+ kv: "redis:8-alpine",
70
+ files: "rustfs/rustfs:1.0.0-beta.11",
71
+ },
72
+ channel: {
73
+ email: "axllent/mailpit:v1.22.3",
74
+ },
73
75
  vault: "openbao/openbao:2.6.1",
76
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
74
77
  },
75
78
  i18n: { locales: ["en", "ar"], default: "en", dir: { ar: "rtl" } },
76
79
  });
@@ -1,18 +1,15 @@
1
- import { db, noteCreatedMail, webhookSecret } from "./core";
1
+ import "./core";
2
2
  import "./locales/en";
3
3
  import "./locales/ar";
4
4
 
5
5
  import { oke } from "okengine";
6
- import * as main from "./flows/main";
7
- import * as notes from "./flows/notes";
8
- import { noteCreated } from "./flows/notes/signals";
6
+ import * as routes from "./flows/generated";
9
7
 
10
- export const app = oke({
11
- name: "notes",
12
- stores: [db],
13
- secrets: [webhookSecret],
14
- signals: [noteCreated],
15
- channel: { templates: [noteCreatedMail] },
16
- }).adopt({ main, notes });
8
+ // stores (store.sql) · secrets (vault.secret) · signals (signal()) · channel
9
+ // templates (channel.<medium>().template()) auto-register from `./core`
10
+ // (and `./flows/notes/signals`) — no explicit arrays needed.
11
+ // `./flows/generated` is a real file, regenerated by `oke dev` / `oke build`
12
+ // from every `src/flows/<unit>/index.ts` folder — never edit it by hand.
13
+ export const app = oke({ name: "notes" }).adopt(routes);
17
14
 
18
15
  export type App = typeof app;
@@ -1,5 +1,5 @@
1
1
  import { store } from "okengine";
2
- import * as schema from "../db/schema.decl";
2
+ import * as schema from "@/db/schema.decl";
3
3
 
4
4
  /** App SQL store — tables from {@link schema}. */
5
5
  export const db = store.sql("app", { schema });
@@ -0,0 +1,4 @@
1
+ // AUTO-GENERATED by `oke dev` / `oke build` — do not edit by hand.
2
+ // Regenerated from every `src/flows/<unit>/index.ts` unit folder.
3
+ export * as main from "./main/index.ts";
4
+ export * as notes from "./notes/index.ts";
@@ -4,9 +4,7 @@ import { z } from "zod";
4
4
  /** First-run welcome — visit :6530/ after `oke dev`. */
5
5
  export const root = on(
6
6
  http.get("/").gate(gate.public),
7
- flow({
8
- name: "main.root",
9
- unit: "main",
7
+ flow("main.root", {
10
8
  out: z.object({
11
9
  ok: z.literal(true),
12
10
  app: z.string(),
@@ -25,9 +23,7 @@ export const root = on(
25
23
  /** Liveness for probes and `bun test`. */
26
24
  export const health = on(
27
25
  http.get("/health").gate(gate.public),
28
- flow({
29
- name: "main.health",
30
- unit: "main",
26
+ flow("main.health", {
31
27
  out: z.object({ ok: z.literal(true) }),
32
28
  do: () => ({ ok: true as const }),
33
29
  }),
@@ -1,8 +1,8 @@
1
1
  import { on, flow, http, gate, fail } from "okengine";
2
2
  import { eq, isNull } from "drizzle-orm";
3
3
 
4
- import { db, noteCreatedMail, webhookSecret } from "../../core";
5
- import { notes } from "../../db/schema.decl";
4
+ import { db, noteCreatedMail, webhookSecret } from "@/core";
5
+ import { notes } from "@/db/schema.decl";
6
6
  import { NoteCreateIn, NoteIdIn, NoteListOut, NoteOut, NotFound } from "./shapes";
7
7
  import { noteCreated } from "./signals";
8
8
 
@@ -12,9 +12,7 @@ import "./signals";
12
12
  /** List active (non-archived) notes, newest first. */
13
13
  export const list = on(
14
14
  http.get("/notes").gate(gate.public),
15
- flow({
16
- name: "notes.list",
17
- unit: "notes",
15
+ flow("notes.list", {
18
16
  out: NoteListOut,
19
17
  effects: { reads: ["sql:app"] },
20
18
  do: async (_input, fx) => {
@@ -36,9 +34,7 @@ export const list = on(
36
34
  /** Create a note, emit `note-created`, touch vault. */
37
35
  export const create = on(
38
36
  http.post("/notes").gate(gate.public),
39
- flow({
40
- name: "notes.create",
41
- unit: "notes",
37
+ flow("notes.create", {
42
38
  in: NoteCreateIn,
43
39
  out: NoteOut,
44
40
  effects: {
@@ -73,9 +69,7 @@ export const create = on(
73
69
  /** Fetch one note by id. */
74
70
  export const get = on(
75
71
  http.get("/notes/:id").gate(gate.public),
76
- flow({
77
- name: "notes.get",
78
- unit: "notes",
72
+ flow("notes.get", {
79
73
  in: NoteIdIn,
80
74
  out: NoteOut,
81
75
  errors: { NotFound },
@@ -97,9 +91,7 @@ export const get = on(
97
91
  /** Soft-archive a note. */
98
92
  export const archive = on(
99
93
  http.post("/notes/:id/archive").gate(gate.public),
100
- flow({
101
- name: "notes.archive",
102
- unit: "notes",
94
+ flow("notes.archive", {
103
95
  in: NoteIdIn,
104
96
  out: NoteOut,
105
97
  errors: { NotFound },
@@ -127,9 +119,7 @@ export const archive = on(
127
119
  /** On create → send the note-created email template. */
128
120
  export const onCreated = on(
129
121
  noteCreated,
130
- flow({
131
- name: "notes.onCreated",
132
- unit: "notes",
122
+ flow("notes.onCreated", {
133
123
  effects: { sends: ["note-created"] },
134
124
  do: async (payload, fx) => {
135
125
  await fx.send(noteCreatedMail, {
@@ -6,6 +6,9 @@
6
6
  "moduleDetection": "force",
7
7
  "types": ["bun"],
8
8
  "moduleResolution": "bundler",
9
+ "paths": {
10
+ "@/*": ["./src/*"]
11
+ },
9
12
  "allowImportingTsExtensions": true,
10
13
  "verbatimModuleSyntax": true,
11
14
  "noEmit": true,