create-oke 0.13.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +2 -2
  2. package/src/agents-md.ts +1 -1
  3. package/src/ai-setup/apply.ts +32 -1
  4. package/src/cli.test.ts +14 -9
  5. package/src/local-okengine.test.ts +2 -2
  6. package/src/local-okengine.ts +1 -3
  7. package/src/transform.ts +2 -2
  8. package/templates/advanced/.github/workflows/ci.yml +1 -1
  9. package/templates/advanced/drizzle.config.ts +2 -1
  10. package/templates/advanced/package.json +2 -2
  11. package/templates/advanced/src/app.ts +2 -3
  12. package/templates/advanced/src/flows/generated.ts +38 -3
  13. package/templates/advanced/src/flows/main/health.ts +11 -0
  14. package/templates/advanced/src/flows/main/{index.ts → route.ts} +2 -11
  15. package/templates/advanced/src/flows/notes/[id]/archive.ts +29 -0
  16. package/templates/advanced/src/flows/notes/[id]/attach.ts +22 -0
  17. package/templates/advanced/src/flows/notes/[id]/get.ts +26 -0
  18. package/templates/advanced/src/flows/notes/[id]/summarize.ts +83 -0
  19. package/templates/advanced/src/flows/notes/create.ts +35 -0
  20. package/templates/advanced/src/flows/notes/digest.ts +18 -0
  21. package/templates/advanced/src/flows/notes/list.ts +27 -0
  22. package/templates/advanced/src/flows/notes/on-created.ts +17 -0
  23. package/templates/standard/.github/workflows/ci.yml +1 -1
  24. package/templates/standard/drizzle.config.ts +2 -1
  25. package/templates/standard/package.json +2 -2
  26. package/templates/standard/src/app.ts +2 -3
  27. package/templates/standard/src/flows/generated.ts +32 -3
  28. package/templates/standard/src/flows/main/health.ts +11 -0
  29. package/templates/standard/src/flows/main/{index.ts → route.ts} +2 -11
  30. package/templates/standard/src/flows/notes/[id]/archive.ts +29 -0
  31. package/templates/standard/src/flows/notes/[id]/get.ts +26 -0
  32. package/templates/standard/src/flows/notes/create.ts +35 -0
  33. package/templates/standard/src/flows/notes/list.ts +27 -0
  34. package/templates/standard/src/flows/notes/on-created.ts +17 -0
  35. package/templates/advanced/src/flows/notes/index.ts +0 -241
  36. package/templates/standard/src/flows/notes/index.ts +0 -122
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-oke",
3
- "version": "0.13.0",
3
+ "version": "0.15.2",
4
4
  "description": "Scaffold an okengine app — bunx create-oke@latest <name>",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,6 +32,6 @@
32
32
  "typescript": "^7.0.2"
33
33
  },
34
34
  "engines": {
35
- "bun": ">=1.3.14"
35
+ "bun": ">=1.4.0"
36
36
  }
37
37
  }
package/src/agents-md.ts CHANGED
@@ -90,7 +90,7 @@ App \`:6530\` · Console \`:6533\` · MCP \`:6535\`. Vite web is \`bun run web\`
90
90
  - ❌ Inventing a ninth “element” or a parallel handler stack beside Flows
91
91
  - ✅ New capability = new **driver** on an existing element, or a new Flow
92
92
  - ❌ Untyped HTTP handlers that skip \`on\` / \`flow\` / contracts
93
- - ✅ \`on(http.get("/…"), flow(name, { in, out, do }))\`
93
+ - ✅ \`on(http.get(), flow({ in, out, do }))\` — or \`http.get("/…")\` / \`flow("unit.export", {…})\` when the folder is not the URL
94
94
  - ❌ Returning \`{ items, count }\` from a list \`do\` (nests the pager inside \`data\`)
95
95
  - ✅ \`out: z.array(Item)\` + \`fx.json.withQuery(rows, input)\` — or any other \`out\` you declare
96
96
 
@@ -241,7 +241,9 @@ export function renderAiTs(input: AiSetupApplyInput): string {
241
241
  }
242
242
 
243
243
  /**
244
- * Write AI model declarations into `src/core.ts` (preferred) or legacy `src/core/ai.ts`.
244
+ * Write AI model declarations into `src/core/ai.ts` when that split exists
245
+ * (so a thin `src/core.ts` barrel stays a re-export), else `src/core.ts`,
246
+ * else legacy `src/core/index.ts` + sidecar.
245
247
  *
246
248
  * @param cwd - Project root
247
249
  * @param input - Setup choices
@@ -249,9 +251,25 @@ export function renderAiTs(input: AiSetupApplyInput): string {
249
251
  */
250
252
  function writeAiModels(cwd: string, input: AiSetupApplyInput): string {
251
253
  const rendered = renderAiTs(input);
254
+ const coreAiPath = join(cwd, "src", "core", "ai.ts");
252
255
  const coreTsPath = join(cwd, "src", "core.ts");
253
256
  const legacyIndex = join(cwd, "src", "core", "index.ts");
254
257
 
258
+ if (existsSync(coreAiPath)) {
259
+ const existing = readFileSync(coreAiPath, "utf8");
260
+ if (hasAiModels(existing)) {
261
+ const withPrompt = ensureSummarizeNotePrompt(existing);
262
+ if (withPrompt !== existing) {
263
+ writeFileSync(coreAiPath, withPrompt, "utf8");
264
+ }
265
+ } else {
266
+ writeFileSync(coreAiPath, mergeAiIntoCore(existing, rendered), "utf8");
267
+ }
268
+ ensureCoreBarrelExportsAi(cwd);
269
+ ensureCoreImported(cwd);
270
+ return coreAiPath;
271
+ }
272
+
255
273
  // Folder layout still in the wild — keep writing a sidecar.
256
274
  if (!existsSync(coreTsPath) && existsSync(legacyIndex)) {
257
275
  const aiTsPath = join(cwd, "src", "core", "ai.ts");
@@ -361,6 +379,19 @@ export function ensureNamedOkengineImport(source: string, name: string): string
361
379
  return source.replace(re, `import { ${sorted.join(", ")} } from "okengine";`);
362
380
  }
363
381
 
382
+ /**
383
+ * Keep a `src/core.ts` barrel re-exporting `./core/ai.ts` after a split write.
384
+ *
385
+ * @param cwd - Project root
386
+ */
387
+ function ensureCoreBarrelExportsAi(cwd: string): void {
388
+ const coreTsPath = join(cwd, "src", "core.ts");
389
+ if (!existsSync(coreTsPath)) return;
390
+ const src = readFileSync(coreTsPath, "utf8");
391
+ if (/from\s+["']\.\/core\/ai/.test(src)) return;
392
+ writeFileSync(coreTsPath, `${src.trimEnd()}\nexport * from "./core/ai.ts";\n`, "utf8");
393
+ }
394
+
364
395
  /**
365
396
  * Ensure `src/app.ts` loads `@/core` / `./core` so merged AI registers.
366
397
  *
package/src/cli.test.ts CHANGED
@@ -319,7 +319,7 @@ describe("interactive branches", () => {
319
319
  expect(config).not.toMatch(/^\s*sql:\s*\{/m);
320
320
  expect(existsSync(join(dir, ".oke", "mode"))).toBe(false);
321
321
  expect(existsSync(join(dir, "docker", "docker-compose.yml"))).toBe(true);
322
- expect(existsSync(join(dir, "src", "flows", "notes", "index.ts"))).toBe(true);
322
+ expect(existsSync(join(dir, "src", "flows", "notes", "create.ts"))).toBe(true);
323
323
  } finally {
324
324
  rmSync(dir, { recursive: true, force: true });
325
325
  }
@@ -336,9 +336,12 @@ describe("interactive branches", () => {
336
336
  });
337
337
  expect(code).toBe(0);
338
338
  expect(existsSync(join(dir, ".oke", "mode"))).toBe(false);
339
- const notes = readFileSync(join(dir, "src", "flows", "notes", "index.ts"), "utf8");
340
- expect(notes).toContain('flow("notes.digest"');
341
- expect(notes).toContain('flow("notes.attach"');
339
+ expect(existsSync(join(dir, "src", "flows", "notes", "digest.ts"))).toBe(true);
340
+ expect(existsSync(join(dir, "src", "flows", "notes", "[id]", "attach.ts"))).toBe(true);
341
+ const digest = readFileSync(join(dir, "src", "flows", "notes", "digest.ts"), "utf8");
342
+ expect(digest).toContain('every("1d")');
343
+ const attach = readFileSync(join(dir, "src", "flows", "notes", "[id]", "attach.ts"), "utf8");
344
+ expect(attach).toContain("export const attach");
342
345
  expect(readFileSync(join(dir, "oke.config.ts"), "utf8")).toMatch(
343
346
  /index:\s*\{\s*test:\s*"memory",\s*prod:\s*"meilisearch"/,
344
347
  );
@@ -668,7 +671,8 @@ describe("scaffold structure", () => {
668
671
  "src/locales/index.ts",
669
672
  "src/flows/main/shapes.ts",
670
673
  "src/flows/main/signals.ts",
671
- "src/flows/notes/index.ts",
674
+ "src/flows/notes/create.ts",
675
+ "src/flows/notes/[id]/get.ts",
672
676
  "src/db/schema.decl.ts",
673
677
  "src/db/seed/index.ts",
674
678
  "src/app.ts",
@@ -676,10 +680,11 @@ describe("scaffold structure", () => {
676
680
  ]) {
677
681
  expect(result.files).toContain(path);
678
682
  }
679
- const notes = readFileSync(join(result.targetDir, "src/flows/notes/index.ts"), "utf8");
680
- expect(notes).toContain('flow("notes.create"');
681
- expect(notes).toContain("fx.json.withQuery");
682
- expect(notes).not.toContain('flow("notes.digest"');
683
+ const create = readFileSync(join(result.targetDir, "src/flows/notes/create.ts"), "utf8");
684
+ const list = readFileSync(join(result.targetDir, "src/flows/notes/list.ts"), "utf8");
685
+ expect(create).toContain("export const create");
686
+ expect(list).toContain("fx.json.withQuery");
687
+ expect(existsSync(join(result.targetDir, "src/flows/notes/digest.ts"))).toBe(false);
683
688
  expect(existsSync(join(result.targetDir, "src/locales/ar.ts"))).toBe(false);
684
689
  expect(readFileSync(join(result.targetDir, "oke.config.ts"), "utf8")).toContain(
685
690
  'locales: ["en"]',
@@ -37,8 +37,8 @@ describe("materializeLocalOkengineDependency", () => {
37
37
  expect(pkg.peerDependencies).toBeUndefined();
38
38
  expect(pkg.dependencies?.["oxc-parser"]).toBeDefined();
39
39
  // Peers folded into dependencies so the out-of-tree stage can resolve them.
40
- expect(pkg.dependencies?.["drizzle-orm"]).toBe("1.0.0-rc.4");
41
- expect(pkg.dependencies?.["drizzle-kit"]).toBe("1.0.0-rc.4");
40
+ expect(pkg.dependencies?.["drizzle-orm"]).toBe("1.0.0-rc.5-169397b");
41
+ expect(pkg.dependencies?.["drizzle-kit"]).toBe("1.0.0-rc.5-ab785fc");
42
42
  expect(pkg.dependencies?.["zod"]).toBeDefined();
43
43
  expect(pkg.exports?.["./config"]).toBe("./src/config/index.ts");
44
44
  expect(pkg.trustedDependencies).toContain("@duckdb/node-api");
@@ -2,9 +2,7 @@
2
2
  * Consumer-facing `file:` package for local / monorepo create-oke.
3
3
  *
4
4
  * Linking the monorepo root pulls `workspaces` + `devDependencies` into the
5
- * scaffold (Console UI, drizzle-zod, …). Bun then warns that RC
6
- * `drizzle-orm` fails drizzle-zod’s `>=0.36` peer. Stage a publish-shaped
7
- * tree instead:
5
+ * scaffold (Console UI, …). Stage a publish-shaped tree instead:
8
6
  *
9
7
  * 1. Copy `files` (Bun’s `file:` install drops directory symlinks).
10
8
  * 2. Install dependencies **in the stage** — Bun keeps `package.json` as a
package/src/transform.ts CHANGED
@@ -59,8 +59,8 @@ export type ScaffoldPackageJson = {
59
59
  * Resolve the `okengine` dependency string written into the scaffolded package.json.
60
60
  *
61
61
  * - Monorepo / local: staged publish-shaped `file:` package (no workspaces /
62
- * no monorepo `devDependencies` — those pull drizzle-zod and trip Bun’s RC
63
- * peer check)
62
+ * no monorepo `devDependencies` — those pull Console UI and other
63
+ * workspace-only packages into the scaffold)
64
64
  * - Published create-oke: the version of this package (kept in lockstep with okengine)
65
65
  *
66
66
  * @param localOkengineRoot - Absolute path when available
@@ -15,7 +15,7 @@ jobs:
15
15
  - uses: actions/checkout@v6
16
16
  - uses: oven-sh/setup-bun@v2.2.0
17
17
  with:
18
- bun-version: latest
18
+ bun-version: "1.4.0"
19
19
  - name: Install
20
20
  run: bun install
21
21
  - name: Typecheck
@@ -16,7 +16,8 @@ export default defineConfig({
16
16
  dialect: "postgresql",
17
17
  schema: "./src/db/schema.drizzle.ts",
18
18
  out: "./src/db/migrations",
19
- // Core runtime tables (oke_crons, …) are created by drivers not domain schema.
19
+ // Domain tables live in public. Schema `oke` (RLS) and `oke_console` are engine-owned.
20
+ schemaFilter: ["public"],
20
21
  tablesFilter: ["!oke_*"],
21
22
  dbCredentials: {
22
23
  url: process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL!,
@@ -6,7 +6,7 @@
6
6
  "dependencies": {
7
7
  "okengine": "file:../../../..",
8
8
  "@duckdb/node-api": "^1.5.5-r.2",
9
- "drizzle-orm": "1.0.0-rc.4",
9
+ "drizzle-orm": "1.0.0-rc.5-169397b",
10
10
  "react": "^19.2.8",
11
11
  "react-dom": "^19.2.8",
12
12
  "zod": "^4.4.3"
@@ -18,7 +18,7 @@
18
18
  "@types/react": "^19.2.18",
19
19
  "@types/react-dom": "^19.2.4",
20
20
  "@vitejs/plugin-react": "^6.0.5",
21
- "drizzle-kit": "1.0.0-rc.4",
21
+ "drizzle-kit": "1.0.0-rc.5-ab785fc",
22
22
  "typescript": "^7.0.2",
23
23
  "vite": "^8.2.0"
24
24
  },
@@ -1,9 +1,8 @@
1
1
  import "@/core";
2
- import { notesMutate } from "@/core";
2
+ import "@/flows/generated";
3
3
 
4
4
  import { oke } from "okengine/http";
5
- import * as routes from "@/flows/generated";
6
5
 
7
- export const app = oke({ name: "notes", gate: { policies: [notesMutate] } }).adopt(routes);
6
+ export const app = oke({ name: "notes" });
8
7
 
9
8
  export type App = typeof app;
@@ -1,4 +1,39 @@
1
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";
2
+ // Regenerated from every `src/flows/<unit>/` folder (barrel or file tree).
3
+ import { registerFlowUnits, stampFlowName, stampHttpPath } from "okengine";
4
+ import * as main_health from "./main/health.ts";
5
+ import * as main_route from "./main/route.ts";
6
+
7
+ const main = {
8
+ health: stampHttpPath(stampFlowName(main_health.health, "main.health"), "/health"),
9
+ root: stampHttpPath(stampFlowName(main_route.root, "main.root"), "/"),
10
+ };
11
+ export { main };
12
+
13
+ import * as notes_$id$_archive from "./notes/[id]/archive.ts";
14
+ import * as notes_$id$_attach from "./notes/[id]/attach.ts";
15
+ import * as notes_$id$_get from "./notes/[id]/get.ts";
16
+ import * as notes_$id$_summarize from "./notes/[id]/summarize.ts";
17
+ import * as notes_create from "./notes/create.ts";
18
+ import * as notes_digest from "./notes/digest.ts";
19
+ import * as notes_list from "./notes/list.ts";
20
+ import * as notes_on_created from "./notes/on-created.ts";
21
+
22
+ const notes = {
23
+ archive: stampHttpPath(stampFlowName(notes_$id$_archive.archive, "notes.archive"), "/notes/:id/archive"),
24
+ attach: stampHttpPath(stampFlowName(notes_$id$_attach.attach, "notes.attach"), "/notes/:id/attach"),
25
+ get: stampHttpPath(stampFlowName(notes_$id$_get.get, "notes.get"), "/notes/:id"),
26
+ summarize: stampHttpPath(stampFlowName(notes_$id$_summarize.summarize, "notes.summarize"), "/notes/:id/summarize"),
27
+ create: stampHttpPath(stampFlowName(notes_create.create, "notes.create"), "/notes"),
28
+ digest: stampHttpPath(stampFlowName(notes_digest.digest, "notes.digest"), "/notes/digest"),
29
+ list: stampHttpPath(stampFlowName(notes_list.list, "notes.list"), "/notes"),
30
+ onCreated: stampHttpPath(stampFlowName(notes_on_created.onCreated, "notes.onCreated"), "/notes/on-created"),
31
+ };
32
+ export { notes };
33
+ registerFlowUnits({ main, notes });
34
+ declare module "okengine" {
35
+ interface RegisteredFlowUnits {
36
+ readonly main: typeof main;
37
+ readonly notes: typeof notes;
38
+ }
39
+ }
@@ -0,0 +1,11 @@
1
+ import { on, flow, http } from "okengine";
2
+ import { z } from "zod";
3
+
4
+ /** Liveness for probes and `bun test`. */
5
+ export const health = on(
6
+ http.get().public(),
7
+ flow({
8
+ out: z.object({ ok: z.literal(true) }),
9
+ do: () => ({ ok: true as const }),
10
+ }),
11
+ );
@@ -3,8 +3,8 @@ import { z } from "zod";
3
3
 
4
4
  /** First-run welcome — visit :6530/ after `oke dev` (browser code block; curl stays JSON). */
5
5
  export const root = on(
6
- http.get("/").gate.public,
7
- flow("main.root", {
6
+ http.get().public(),
7
+ flow({
8
8
  out: z.object({
9
9
  ok: z.literal(true),
10
10
  app: z.string(),
@@ -19,12 +19,3 @@ export const root = on(
19
19
  }),
20
20
  }),
21
21
  );
22
-
23
- /** Liveness for probes and `bun test`. */
24
- export const health = on(
25
- http.get("/health").gate.public,
26
- flow("main.health", {
27
- out: z.object({ ok: z.literal(true) }),
28
- do: () => ({ ok: true as const }),
29
- }),
30
- );
@@ -0,0 +1,29 @@
1
+ import { on, flow, http, fail } from "okengine";
2
+ import { eq } from "drizzle-orm";
3
+
4
+ import { db, notesMutate } from "@/core";
5
+ import { notes } from "@/db/schema.decl";
6
+ import { NoteIdIn, NoteOut, NotFound } from "../shapes";
7
+
8
+ /** Soft-archive a note. */
9
+ export const archive = on(
10
+ http.post().gate(notesMutate),
11
+ flow({
12
+ in: NoteIdIn,
13
+ out: NoteOut,
14
+ errors: { NotFound },
15
+ do: async (input, fx) => {
16
+ const row = await fx.store(db).findById(notes, input.id);
17
+ if (!row) return fail("NotFound", { id: input.id });
18
+ const archivedAt = fx.clock.now();
19
+ await fx.store(db).update(notes).set({ archivedAt }).where(eq(notes.id, input.id));
20
+ return {
21
+ id: String(row.id),
22
+ title: String(row.title),
23
+ body: String(row.body),
24
+ archivedAt,
25
+ createdAt: Number(row.createdAt),
26
+ };
27
+ },
28
+ }),
29
+ );
@@ -0,0 +1,22 @@
1
+ import { on, flow, http, fail } from "okengine";
2
+
3
+ import { db, files, notesMutate } from "@/core";
4
+ import { notes } from "@/db/schema.decl";
5
+ import { NoteAttachIn, NoteAttachOut, NotFound } from "../shapes";
6
+
7
+ /** Store a text attachment next to a note (`files:uploads`). */
8
+ export const attach = on(
9
+ http.post().gate(notesMutate),
10
+ flow({
11
+ in: NoteAttachIn,
12
+ out: NoteAttachOut,
13
+ errors: { NotFound },
14
+ do: async (input, fx) => {
15
+ const row = await fx.store(db).findById(notes, input.id);
16
+ if (!row) return fail("NotFound", { id: input.id });
17
+ const key = `notes/${input.id}/attachment.txt`;
18
+ await fx.store(files).put(key, input.text);
19
+ return { key, bytes: new TextEncoder().encode(input.text).byteLength };
20
+ },
21
+ }),
22
+ );
@@ -0,0 +1,26 @@
1
+ import { on, flow, http, fail } from "okengine";
2
+
3
+ import { db } from "@/core";
4
+ import { notes } from "@/db/schema.decl";
5
+ import { NoteIdIn, NoteOut, NotFound } from "../shapes";
6
+
7
+ /** Fetch one note by id. */
8
+ export const get = on(
9
+ http.get().public(),
10
+ flow({
11
+ in: NoteIdIn,
12
+ out: NoteOut,
13
+ errors: { NotFound },
14
+ do: async (input, fx) => {
15
+ const row = await fx.store(db).findById(notes, input.id);
16
+ if (!row) return fail("NotFound", { id: input.id });
17
+ return {
18
+ id: String(row.id),
19
+ title: String(row.title),
20
+ body: String(row.body),
21
+ archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
22
+ createdAt: Number(row.createdAt),
23
+ };
24
+ },
25
+ }),
26
+ );
@@ -0,0 +1,83 @@
1
+ import { on, flow, http, fail } from "okengine";
2
+
3
+ import { db, notesMutate } from "@/core";
4
+ import { notes } from "@/db/schema.decl";
5
+ import { NoteSummarizeIn, NoteSummarizeOut, NotFound, Unavailable } from "../shapes";
6
+
7
+ /**
8
+ * Pull a usable summary string from an `fx.ask` payload.
9
+ *
10
+ * @param out - Model output object
11
+ */
12
+ function extractSummary(out: unknown): string {
13
+ if (typeof out === "string") return unwrapSummaryText(out);
14
+ if (!out || typeof out !== "object") return "";
15
+ const record = out as Record<string, unknown>;
16
+ if (typeof record.summary === "string") return record.summary.trim();
17
+ if (typeof record.text === "string") return unwrapSummaryText(record.text);
18
+ return "";
19
+ }
20
+
21
+ /**
22
+ * Local models sometimes return over-escaped JSON (`{\\"summary\\":...}`).
23
+ * Peel one or two JSON layers, then fall back to the raw text.
24
+ *
25
+ * @param text - Model text payload
26
+ */
27
+ function unwrapSummaryText(text: string): string {
28
+ let current = text.trim();
29
+ for (let i = 0; i < 2; i++) {
30
+ if (!(current.startsWith("{") || current.startsWith('"'))) break;
31
+ try {
32
+ const parsed = JSON.parse(current) as unknown;
33
+ if (typeof parsed === "string") {
34
+ current = parsed.trim();
35
+ continue;
36
+ }
37
+ if (parsed && typeof parsed === "object" && "summary" in parsed) {
38
+ return String((parsed as { summary: unknown }).summary).trim();
39
+ }
40
+ break;
41
+ } catch {
42
+ break;
43
+ }
44
+ }
45
+ return current;
46
+ }
47
+
48
+ /**
49
+ * Summarize a note via the prompt's declared recovery chain.
50
+ * Exhausted / failed asks surface as Unavailable — never a body excerpt.
51
+ */
52
+ export const summarize = on(
53
+ http.post().gate(notesMutate),
54
+ flow({
55
+ in: NoteSummarizeIn,
56
+ out: NoteSummarizeOut,
57
+ errors: { NotFound, Unavailable },
58
+ do: async (input, fx) => {
59
+ const row = await fx.store(db).findById(notes, input.id);
60
+ if (!row) return fail("NotFound", { id: input.id });
61
+ try {
62
+ const out = await fx.ask("summarize-note", {
63
+ instruction:
64
+ 'Summarize this note in one or two sentences. Reply with JSON only: {"summary":"..."}',
65
+ title: String(row.title),
66
+ body: String(row.body),
67
+ });
68
+ const summary = extractSummary(out);
69
+ const via = typeof out.via === "string" ? out.via.trim() : "";
70
+ if (!summary || !via) {
71
+ return fail("Unavailable", {
72
+ message: "AI service unavailable. Try again later.",
73
+ });
74
+ }
75
+ return { id: input.id, summary, via };
76
+ } catch {
77
+ return fail("Unavailable", {
78
+ message: "AI service unavailable. Try again later.",
79
+ });
80
+ }
81
+ },
82
+ }),
83
+ );
@@ -0,0 +1,35 @@
1
+ import { on, flow, http } from "okengine";
2
+
3
+ import { db, notesMutate, webhookSecret } from "@/core";
4
+ import { notes } from "@/db/schema.decl";
5
+ import { NoteCreateIn, NoteOut } from "./shapes";
6
+ import { noteCreated } from "./signals";
7
+
8
+ /** Create a note, emit `note-created`, touch vault. */
9
+ export const create = on(
10
+ http.post().gate(notesMutate),
11
+ flow({
12
+ in: NoteCreateIn,
13
+ out: NoteOut,
14
+ do: async (input, fx) => {
15
+ await fx.vault.get(webhookSecret);
16
+ const id = fx.id();
17
+ const createdAt = fx.clock.now();
18
+ await fx.store(db).insert(notes).values({
19
+ id,
20
+ title: input.title,
21
+ body: input.body,
22
+ archivedAt: null,
23
+ createdAt,
24
+ });
25
+ await fx.emit(noteCreated, { id, title: input.title }, { key: id });
26
+ return {
27
+ id,
28
+ title: input.title,
29
+ body: input.body,
30
+ archivedAt: null,
31
+ createdAt,
32
+ };
33
+ },
34
+ }),
35
+ );
@@ -0,0 +1,18 @@
1
+ import { on, flow, every } from "okengine";
2
+ import { isNull } from "drizzle-orm";
3
+
4
+ import { db } from "@/core";
5
+ import { notes } from "@/db/schema.decl";
6
+ import { NoteDigestOut } from "./shapes";
7
+
8
+ /** Daily count of active notes (frozen under test drivers). */
9
+ export const digest = on(
10
+ every("1d"),
11
+ flow({
12
+ out: NoteDigestOut,
13
+ do: async (_input, fx) => {
14
+ const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
15
+ return { active: rows.length, at: fx.clock.now() };
16
+ },
17
+ }),
18
+ );
@@ -0,0 +1,27 @@
1
+ import { on, flow, http } from "okengine";
2
+ import { isNull } from "drizzle-orm";
3
+
4
+ import { db } from "@/core";
5
+ import { notes } from "@/db/schema.decl";
6
+ import { NoteListOut } from "./shapes";
7
+
8
+ /** List active (non-archived) notes, newest first. */
9
+ export const list = on(
10
+ http.get().public(),
11
+ flow({
12
+ out: NoteListOut,
13
+ do: async (input, fx) => {
14
+ const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
15
+ const data = [...rows]
16
+ .sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
17
+ .map((r) => ({
18
+ id: String(r.id),
19
+ title: String(r.title),
20
+ body: String(r.body),
21
+ archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
22
+ createdAt: Number(r.createdAt),
23
+ }));
24
+ return fx.json.withQuery(data, input);
25
+ },
26
+ }),
27
+ );
@@ -0,0 +1,17 @@
1
+ import { on, flow } from "okengine";
2
+
3
+ import { noteCreatedMail } from "@/core";
4
+ import { noteCreated } from "./signals";
5
+
6
+ /** On create → send the note-created email template. */
7
+ export const onCreated = on(
8
+ noteCreated,
9
+ flow({
10
+ do: async (payload, fx) => {
11
+ await fx.send(noteCreatedMail, {
12
+ to: "you@localhost",
13
+ data: { id: payload.id, title: payload.title },
14
+ });
15
+ },
16
+ }),
17
+ );
@@ -15,7 +15,7 @@ jobs:
15
15
  - uses: actions/checkout@v6
16
16
  - uses: oven-sh/setup-bun@v2.2.0
17
17
  with:
18
- bun-version: latest
18
+ bun-version: "1.4.0"
19
19
  - name: Install
20
20
  run: bun install
21
21
  - name: Typecheck
@@ -16,7 +16,8 @@ export default defineConfig({
16
16
  dialect: "postgresql",
17
17
  schema: "./src/db/schema.drizzle.ts",
18
18
  out: "./src/db/migrations",
19
- // Core runtime tables (oke_crons, …) are created by drivers not domain schema.
19
+ // Domain tables live in public. Schema `oke` (RLS) and `oke_console` are engine-owned.
20
+ schemaFilter: ["public"],
20
21
  tablesFilter: ["!oke_*"],
21
22
  dbCredentials: {
22
23
  url: process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL!,
@@ -6,7 +6,7 @@
6
6
  "dependencies": {
7
7
  "okengine": "file:../../../..",
8
8
  "@duckdb/node-api": "^1.5.5-r.2",
9
- "drizzle-orm": "1.0.0-rc.4",
9
+ "drizzle-orm": "1.0.0-rc.5-169397b",
10
10
  "react": "^19.2.8",
11
11
  "react-dom": "^19.2.8",
12
12
  "zod": "^4.4.3"
@@ -18,7 +18,7 @@
18
18
  "@types/react": "^19.2.18",
19
19
  "@types/react-dom": "^19.2.4",
20
20
  "@vitejs/plugin-react": "^6.0.5",
21
- "drizzle-kit": "1.0.0-rc.4",
21
+ "drizzle-kit": "1.0.0-rc.5-ab785fc",
22
22
  "typescript": "^7.0.2",
23
23
  "vite": "^8.2.0"
24
24
  },
@@ -1,9 +1,8 @@
1
1
  import "@/core";
2
- import { notesMutate } from "@/core";
2
+ import "@/flows/generated";
3
3
 
4
4
  import { oke } from "okengine/http";
5
- import * as routes from "@/flows/generated";
6
5
 
7
- export const app = oke({ name: "notes", gate: { policies: [notesMutate] } }).adopt(routes);
6
+ export const app = oke({ name: "notes" });
8
7
 
9
8
  export type App = typeof app;
@@ -1,4 +1,33 @@
1
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";
2
+ // Regenerated from every `src/flows/<unit>/` folder (barrel or file tree).
3
+ import { registerFlowUnits, stampFlowName, stampHttpPath } from "okengine";
4
+ import * as main_health from "./main/health.ts";
5
+ import * as main_route from "./main/route.ts";
6
+
7
+ const main = {
8
+ health: stampHttpPath(stampFlowName(main_health.health, "main.health"), "/health"),
9
+ root: stampHttpPath(stampFlowName(main_route.root, "main.root"), "/"),
10
+ };
11
+ export { main };
12
+
13
+ import * as notes_$id$_archive from "./notes/[id]/archive.ts";
14
+ import * as notes_$id$_get from "./notes/[id]/get.ts";
15
+ import * as notes_create from "./notes/create.ts";
16
+ import * as notes_list from "./notes/list.ts";
17
+ import * as notes_on_created from "./notes/on-created.ts";
18
+
19
+ const notes = {
20
+ archive: stampHttpPath(stampFlowName(notes_$id$_archive.archive, "notes.archive"), "/notes/:id/archive"),
21
+ get: stampHttpPath(stampFlowName(notes_$id$_get.get, "notes.get"), "/notes/:id"),
22
+ create: stampHttpPath(stampFlowName(notes_create.create, "notes.create"), "/notes"),
23
+ list: stampHttpPath(stampFlowName(notes_list.list, "notes.list"), "/notes"),
24
+ onCreated: stampHttpPath(stampFlowName(notes_on_created.onCreated, "notes.onCreated"), "/notes/on-created"),
25
+ };
26
+ export { notes };
27
+ registerFlowUnits({ main, notes });
28
+ declare module "okengine" {
29
+ interface RegisteredFlowUnits {
30
+ readonly main: typeof main;
31
+ readonly notes: typeof notes;
32
+ }
33
+ }
@@ -0,0 +1,11 @@
1
+ import { on, flow, http } from "okengine";
2
+ import { z } from "zod";
3
+
4
+ /** Liveness for probes and `bun test`. */
5
+ export const health = on(
6
+ http.get().public(),
7
+ flow({
8
+ out: z.object({ ok: z.literal(true) }),
9
+ do: () => ({ ok: true as const }),
10
+ }),
11
+ );
@@ -3,8 +3,8 @@ import { z } from "zod";
3
3
 
4
4
  /** First-run welcome — visit :6530/ after `oke dev` (browser code block; curl stays JSON). */
5
5
  export const root = on(
6
- http.get("/").gate.public,
7
- flow("main.root", {
6
+ http.get().public(),
7
+ flow({
8
8
  out: z.object({
9
9
  ok: z.literal(true),
10
10
  app: z.string(),
@@ -19,12 +19,3 @@ export const root = on(
19
19
  }),
20
20
  }),
21
21
  );
22
-
23
- /** Liveness for probes and `bun test`. */
24
- export const health = on(
25
- http.get("/health").gate.public,
26
- flow("main.health", {
27
- out: z.object({ ok: z.literal(true) }),
28
- do: () => ({ ok: true as const }),
29
- }),
30
- );
@@ -0,0 +1,29 @@
1
+ import { on, flow, http, fail } from "okengine";
2
+ import { eq } from "drizzle-orm";
3
+
4
+ import { db, notesMutate } from "@/core";
5
+ import { notes } from "@/db/schema.decl";
6
+ import { NoteIdIn, NoteOut, NotFound } from "../shapes";
7
+
8
+ /** Soft-archive a note. */
9
+ export const archive = on(
10
+ http.post().gate(notesMutate),
11
+ flow({
12
+ in: NoteIdIn,
13
+ out: NoteOut,
14
+ errors: { NotFound },
15
+ do: async (input, fx) => {
16
+ const row = await fx.store(db).findById(notes, input.id);
17
+ if (!row) return fail("NotFound", { id: input.id });
18
+ const archivedAt = fx.clock.now();
19
+ await fx.store(db).update(notes).set({ archivedAt }).where(eq(notes.id, input.id));
20
+ return {
21
+ id: String(row.id),
22
+ title: String(row.title),
23
+ body: String(row.body),
24
+ archivedAt,
25
+ createdAt: Number(row.createdAt),
26
+ };
27
+ },
28
+ }),
29
+ );
@@ -0,0 +1,26 @@
1
+ import { on, flow, http, fail } from "okengine";
2
+
3
+ import { db } from "@/core";
4
+ import { notes } from "@/db/schema.decl";
5
+ import { NoteIdIn, NoteOut, NotFound } from "../shapes";
6
+
7
+ /** Fetch one note by id. */
8
+ export const get = on(
9
+ http.get().public(),
10
+ flow({
11
+ in: NoteIdIn,
12
+ out: NoteOut,
13
+ errors: { NotFound },
14
+ do: async (input, fx) => {
15
+ const row = await fx.store(db).findById(notes, input.id);
16
+ if (!row) return fail("NotFound", { id: input.id });
17
+ return {
18
+ id: String(row.id),
19
+ title: String(row.title),
20
+ body: String(row.body),
21
+ archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
22
+ createdAt: Number(row.createdAt),
23
+ };
24
+ },
25
+ }),
26
+ );
@@ -0,0 +1,35 @@
1
+ import { on, flow, http } from "okengine";
2
+
3
+ import { db, notesMutate, webhookSecret } from "@/core";
4
+ import { notes } from "@/db/schema.decl";
5
+ import { NoteCreateIn, NoteOut } from "./shapes";
6
+ import { noteCreated } from "./signals";
7
+
8
+ /** Create a note, emit `note-created`, touch vault. */
9
+ export const create = on(
10
+ http.post().gate(notesMutate),
11
+ flow({
12
+ in: NoteCreateIn,
13
+ out: NoteOut,
14
+ do: async (input, fx) => {
15
+ await fx.vault.get(webhookSecret);
16
+ const id = fx.id();
17
+ const createdAt = fx.clock.now();
18
+ await fx.store(db).insert(notes).values({
19
+ id,
20
+ title: input.title,
21
+ body: input.body,
22
+ archivedAt: null,
23
+ createdAt,
24
+ });
25
+ await fx.emit(noteCreated, { id, title: input.title }, { key: id });
26
+ return {
27
+ id,
28
+ title: input.title,
29
+ body: input.body,
30
+ archivedAt: null,
31
+ createdAt,
32
+ };
33
+ },
34
+ }),
35
+ );
@@ -0,0 +1,27 @@
1
+ import { on, flow, http } from "okengine";
2
+ import { isNull } from "drizzle-orm";
3
+
4
+ import { db } from "@/core";
5
+ import { notes } from "@/db/schema.decl";
6
+ import { NoteListOut } from "./shapes";
7
+
8
+ /** List active (non-archived) notes, newest first. */
9
+ export const list = on(
10
+ http.get().public(),
11
+ flow({
12
+ out: NoteListOut,
13
+ do: async (input, fx) => {
14
+ const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
15
+ const data = [...rows]
16
+ .sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
17
+ .map((r) => ({
18
+ id: String(r.id),
19
+ title: String(r.title),
20
+ body: String(r.body),
21
+ archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
22
+ createdAt: Number(r.createdAt),
23
+ }));
24
+ return fx.json.withQuery(data, input);
25
+ },
26
+ }),
27
+ );
@@ -0,0 +1,17 @@
1
+ import { on, flow } from "okengine";
2
+
3
+ import { noteCreatedMail } from "@/core";
4
+ import { noteCreated } from "./signals";
5
+
6
+ /** On create → send the note-created email template. */
7
+ export const onCreated = on(
8
+ noteCreated,
9
+ flow({
10
+ do: async (payload, fx) => {
11
+ await fx.send(noteCreatedMail, {
12
+ to: "you@localhost",
13
+ data: { id: payload.id, title: payload.title },
14
+ });
15
+ },
16
+ }),
17
+ );
@@ -1,241 +0,0 @@
1
- import { on, flow, http, every, fail } from "okengine";
2
- import { eq, isNull } from "drizzle-orm";
3
-
4
- import { db, files, noteCreatedMail, notesMutate, webhookSecret } from "@/core";
5
- import { notes } from "@/db/schema.decl";
6
- import {
7
- NoteAttachIn,
8
- NoteAttachOut,
9
- NoteCreateIn,
10
- NoteDigestOut,
11
- NoteIdIn,
12
- NoteListOut,
13
- NoteOut,
14
- NoteSummarizeIn,
15
- NoteSummarizeOut,
16
- NotFound,
17
- Unavailable,
18
- } from "./shapes";
19
- import { noteCreated } from "./signals";
20
-
21
- import "./shapes";
22
- import "./signals";
23
-
24
- /**
25
- * Pull a usable summary string from an `fx.ask` payload.
26
- *
27
- * @param out - Model output object
28
- */
29
- function extractSummary(out: unknown): string {
30
- if (typeof out === "string") return unwrapSummaryText(out);
31
- if (!out || typeof out !== "object") return "";
32
- const record = out as Record<string, unknown>;
33
- if (typeof record.summary === "string") return record.summary.trim();
34
- if (typeof record.text === "string") return unwrapSummaryText(record.text);
35
- return "";
36
- }
37
-
38
- /**
39
- * Local models sometimes return over-escaped JSON (`{\\"summary\\":...}`).
40
- * Peel one or two JSON layers, then fall back to the raw text.
41
- *
42
- * @param text - Model text payload
43
- */
44
- function unwrapSummaryText(text: string): string {
45
- let current = text.trim();
46
- for (let i = 0; i < 2; i++) {
47
- if (!(current.startsWith("{") || current.startsWith('"'))) break;
48
- try {
49
- const parsed = JSON.parse(current) as unknown;
50
- if (typeof parsed === "string") {
51
- current = parsed.trim();
52
- continue;
53
- }
54
- if (parsed && typeof parsed === "object" && "summary" in parsed) {
55
- return String((parsed as { summary: unknown }).summary).trim();
56
- }
57
- break;
58
- } catch {
59
- break;
60
- }
61
- }
62
- return current;
63
- }
64
-
65
- /** List active (non-archived) notes, newest first. */
66
- export const list = on(
67
- http.get("/notes").gate.public,
68
- flow("notes.list", {
69
- out: NoteListOut,
70
- do: async (input, fx) => {
71
- const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
72
- const data = [...rows]
73
- .sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
74
- .map((r) => ({
75
- id: String(r.id),
76
- title: String(r.title),
77
- body: String(r.body),
78
- archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
79
- createdAt: Number(r.createdAt),
80
- }));
81
- return fx.json.withQuery(data, input);
82
- },
83
- }),
84
- );
85
-
86
- /** Create a note, emit `note-created`, touch vault. */
87
- export const create = on(
88
- http.post("/notes").gate(notesMutate),
89
- flow("notes.create", {
90
- in: NoteCreateIn,
91
- out: NoteOut,
92
- do: async (input, fx) => {
93
- // Prove Vault is wired (local: env/dev fallback).
94
- await fx.vault.get(webhookSecret);
95
- const id = fx.id();
96
- const createdAt = fx.clock.now();
97
- await fx.store(db).insert(notes).values({
98
- id,
99
- title: input.title,
100
- body: input.body,
101
- archivedAt: null,
102
- createdAt,
103
- });
104
- await fx.emit(noteCreated, { id, title: input.title }, { key: id });
105
- return {
106
- id,
107
- title: input.title,
108
- body: input.body,
109
- archivedAt: null,
110
- createdAt,
111
- };
112
- },
113
- }),
114
- );
115
-
116
- /** Fetch one note by id. */
117
- export const get = on(
118
- http.get("/notes/:id").gate.public,
119
- flow("notes.get", {
120
- in: NoteIdIn,
121
- out: NoteOut,
122
- errors: { NotFound },
123
- do: async (input, fx) => {
124
- const row = await fx.store(db).findById(notes, input.id);
125
- if (!row) return fail("NotFound", { id: input.id });
126
- return {
127
- id: String(row.id),
128
- title: String(row.title),
129
- body: String(row.body),
130
- archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
131
- createdAt: Number(row.createdAt),
132
- };
133
- },
134
- }),
135
- );
136
-
137
- /** Soft-archive a note. */
138
- export const archive = on(
139
- http.post("/notes/:id/archive").gate(notesMutate),
140
- flow("notes.archive", {
141
- in: NoteIdIn,
142
- out: NoteOut,
143
- errors: { NotFound },
144
- do: async (input, fx) => {
145
- const row = await fx.store(db).findById(notes, input.id);
146
- if (!row) return fail("NotFound", { id: input.id });
147
- const archivedAt = fx.clock.now();
148
- await fx
149
- .store(db)
150
- .update(notes)
151
- .set({ archivedAt })
152
- .where(eq(notes.id, input.id));
153
- return {
154
- id: String(row.id),
155
- title: String(row.title),
156
- body: String(row.body),
157
- archivedAt,
158
- createdAt: Number(row.createdAt),
159
- };
160
- },
161
- }),
162
- );
163
-
164
- /** On create → send the note-created email template. */
165
- export const onCreated = on(
166
- noteCreated,
167
- flow("notes.onCreated", {
168
- do: async (payload, fx) => {
169
- await fx.send(noteCreatedMail, {
170
- to: "you@localhost",
171
- data: { id: payload.id, title: payload.title },
172
- });
173
- },
174
- }),
175
- );
176
-
177
- /** Store a text attachment next to a note (`files:uploads`). */
178
- export const attach = on(
179
- http.post("/notes/:id/attach").gate(notesMutate),
180
- flow("notes.attach", {
181
- in: NoteAttachIn,
182
- out: NoteAttachOut,
183
- errors: { NotFound },
184
- do: async (input, fx) => {
185
- const row = await fx.store(db).findById(notes, input.id);
186
- if (!row) return fail("NotFound", { id: input.id });
187
- const key = `notes/${input.id}/attachment.txt`;
188
- await fx.store(files).put(key, input.text);
189
- return { key, bytes: new TextEncoder().encode(input.text).byteLength };
190
- },
191
- }),
192
- );
193
-
194
- /** Daily count of active notes (frozen under test drivers). */
195
- export const digest = on(
196
- every("1d"),
197
- flow("notes.digest", {
198
- out: NoteDigestOut,
199
- do: async (_input, fx) => {
200
- const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
201
- return { active: rows.length, at: fx.clock.now() };
202
- },
203
- }),
204
- );
205
-
206
- /**
207
- * Summarize a note via the prompt's declared recovery chain.
208
- * Exhausted / failed asks surface as Unavailable — never a body excerpt.
209
- */
210
- export const summarize = on(
211
- http.post("/notes/:id/summarize").gate(notesMutate),
212
- flow("notes.summarize", {
213
- in: NoteSummarizeIn,
214
- out: NoteSummarizeOut,
215
- errors: { NotFound, Unavailable },
216
- do: async (input, fx) => {
217
- const row = await fx.store(db).findById(notes, input.id);
218
- if (!row) return fail("NotFound", { id: input.id });
219
- try {
220
- const out = await fx.ask("summarize-note", {
221
- instruction:
222
- 'Summarize this note in one or two sentences. Reply with JSON only: {"summary":"..."}',
223
- title: String(row.title),
224
- body: String(row.body),
225
- });
226
- const summary = extractSummary(out);
227
- const via = typeof out.via === "string" ? out.via.trim() : "";
228
- if (!summary || !via) {
229
- return fail("Unavailable", {
230
- message: "AI service unavailable. Try again later.",
231
- });
232
- }
233
- return { id: input.id, summary, via };
234
- } catch {
235
- return fail("Unavailable", {
236
- message: "AI service unavailable. Try again later.",
237
- });
238
- }
239
- },
240
- }),
241
- );
@@ -1,122 +0,0 @@
1
- import { on, flow, http, fail } from "okengine";
2
- import { eq, isNull } from "drizzle-orm";
3
-
4
- import { db, noteCreatedMail, notesMutate, webhookSecret } from "@/core";
5
- import { notes } from "@/db/schema.decl";
6
- import { NoteCreateIn, NoteIdIn, NoteListOut, NoteOut, NotFound } from "./shapes";
7
- import { noteCreated } from "./signals";
8
-
9
- import "./shapes";
10
- import "./signals";
11
-
12
- /** List active (non-archived) notes, newest first. */
13
- export const list = on(
14
- http.get("/notes").gate.public,
15
- flow("notes.list", {
16
- out: NoteListOut,
17
- do: async (input, fx) => {
18
- const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
19
- const data = [...rows]
20
- .sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
21
- .map((r) => ({
22
- id: String(r.id),
23
- title: String(r.title),
24
- body: String(r.body),
25
- archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
26
- createdAt: Number(r.createdAt),
27
- }));
28
- return fx.json.withQuery(data, input);
29
- },
30
- }),
31
- );
32
-
33
- /** Create a note, emit `note-created`, touch vault. */
34
- export const create = on(
35
- http.post("/notes").gate(notesMutate),
36
- flow("notes.create", {
37
- in: NoteCreateIn,
38
- out: NoteOut,
39
- do: async (input, fx) => {
40
- // Prove Vault is wired (local: env/dev fallback).
41
- await fx.vault.get(webhookSecret);
42
- const id = fx.id();
43
- const createdAt = fx.clock.now();
44
- await fx.store(db).insert(notes).values({
45
- id,
46
- title: input.title,
47
- body: input.body,
48
- archivedAt: null,
49
- createdAt,
50
- });
51
- await fx.emit(noteCreated, { id, title: input.title }, { key: id });
52
- return {
53
- id,
54
- title: input.title,
55
- body: input.body,
56
- archivedAt: null,
57
- createdAt,
58
- };
59
- },
60
- }),
61
- );
62
-
63
- /** Fetch one note by id. */
64
- export const get = on(
65
- http.get("/notes/:id").gate.public,
66
- flow("notes.get", {
67
- in: NoteIdIn,
68
- out: NoteOut,
69
- errors: { NotFound },
70
- do: async (input, fx) => {
71
- const row = await fx.store(db).findById(notes, input.id);
72
- if (!row) return fail("NotFound", { id: input.id });
73
- return {
74
- id: String(row.id),
75
- title: String(row.title),
76
- body: String(row.body),
77
- archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
78
- createdAt: Number(row.createdAt),
79
- };
80
- },
81
- }),
82
- );
83
-
84
- /** Soft-archive a note. */
85
- export const archive = on(
86
- http.post("/notes/:id/archive").gate(notesMutate),
87
- flow("notes.archive", {
88
- in: NoteIdIn,
89
- out: NoteOut,
90
- errors: { NotFound },
91
- do: async (input, fx) => {
92
- const row = await fx.store(db).findById(notes, input.id);
93
- if (!row) return fail("NotFound", { id: input.id });
94
- const archivedAt = fx.clock.now();
95
- await fx
96
- .store(db)
97
- .update(notes)
98
- .set({ archivedAt })
99
- .where(eq(notes.id, input.id));
100
- return {
101
- id: String(row.id),
102
- title: String(row.title),
103
- body: String(row.body),
104
- archivedAt,
105
- createdAt: Number(row.createdAt),
106
- };
107
- },
108
- }),
109
- );
110
-
111
- /** On create → send the note-created email template. */
112
- export const onCreated = on(
113
- noteCreated,
114
- flow("notes.onCreated", {
115
- do: async (payload, fx) => {
116
- await fx.send(noteCreatedMail, {
117
- to: "you@localhost",
118
- data: { id: payload.id, title: payload.title },
119
- });
120
- },
121
- }),
122
- );