create-oke 0.18.4 → 0.19.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 (62) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/agents-md.ts +6 -4
  4. package/src/ai-setup/apply.test.ts +278 -0
  5. package/src/ai-setup/apply.ts +430 -52
  6. package/src/ai-setup/catalog.ts +250 -1343
  7. package/src/ai-setup/from-pref.ts +3 -71
  8. package/src/ai-setup/prompts.ts +60 -443
  9. package/src/cli.test.ts +21 -15
  10. package/src/cli.ts +20 -11
  11. package/src/create-defaults.test.ts +4 -4
  12. package/src/create-defaults.ts +3 -3
  13. package/src/customize-flow.test.ts +9 -15
  14. package/src/customize-flow.ts +59 -66
  15. package/src/drivers-catalog.ts +18 -23
  16. package/src/transform.test.ts +69 -10
  17. package/src/transform.ts +10 -35
  18. package/templates/advanced/.env.example +28 -11
  19. package/templates/advanced/.github/workflows/ci.yml +1 -1
  20. package/templates/advanced/oke.config.ts +3 -4
  21. package/templates/advanced/package.json +11 -10
  22. package/templates/advanced/src/app.ts +36 -1
  23. package/templates/advanced/src/core.ts +12 -11
  24. package/templates/advanced/src/db/schema.decl.ts +6 -2
  25. package/templates/advanced/src/db/seed/index.ts +4 -4
  26. package/templates/advanced/src/flows/main/route.ts +3 -2
  27. package/templates/advanced/src/flows/notes/[id]/archive.ts +5 -8
  28. package/templates/advanced/src/flows/notes/[id]/attach.ts +1 -4
  29. package/templates/advanced/src/flows/notes/[id]/get.ts +4 -7
  30. package/templates/advanced/src/flows/notes/[id]/summarize.ts +3 -4
  31. package/templates/advanced/src/flows/notes/create.ts +4 -6
  32. package/templates/advanced/src/flows/notes/digest.ts +6 -5
  33. package/templates/advanced/src/flows/notes/list.ts +4 -5
  34. package/templates/advanced/src/flows/notes/shapes.ts +13 -3
  35. package/templates/advanced/src/flows/notes/signals.ts +1 -2
  36. package/templates/advanced/src/vault.ts +138 -0
  37. package/templates/advanced/tests/advanced.test.ts +11 -8
  38. package/templates/advanced/web/src/App.css +7 -0
  39. package/templates/advanced/web/src/App.tsx +101 -1
  40. package/templates/advanced/web/src/client.ts +16 -3
  41. package/templates/advanced/web/vite.config.ts +1 -0
  42. package/templates/standard/.env.example +22 -11
  43. package/templates/standard/.github/workflows/ci.yml +1 -1
  44. package/templates/standard/oke.config.ts +3 -3
  45. package/templates/standard/package.json +11 -10
  46. package/templates/standard/src/app.ts +6 -1
  47. package/templates/standard/src/core.ts +10 -11
  48. package/templates/standard/src/db/schema.decl.ts +6 -2
  49. package/templates/standard/src/db/seed/index.ts +3 -3
  50. package/templates/standard/src/flows/main/route.ts +3 -2
  51. package/templates/standard/src/flows/notes/[id]/archive.ts +5 -8
  52. package/templates/standard/src/flows/notes/[id]/get.ts +4 -7
  53. package/templates/standard/src/flows/notes/create.ts +4 -6
  54. package/templates/standard/src/flows/notes/list.ts +4 -5
  55. package/templates/standard/src/flows/notes/shapes.ts +12 -2
  56. package/templates/standard/src/flows/notes/signals.ts +1 -2
  57. package/templates/standard/src/vault.ts +123 -0
  58. package/templates/standard/tests/standard.test.ts +3 -1
  59. package/templates/standard/web/src/client.ts +2 -2
  60. package/templates/standard/web/vite.config.ts +1 -0
  61. package/src/ai-setup/detect-ollama.ts +0 -228
  62. package/src/ai-setup/recommend.ts +0 -220
package/src/transform.ts CHANGED
@@ -15,14 +15,7 @@
15
15
  import { readFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import type { CreateDefaults, CreateProxyId, EnvDriverPins } from "./create-defaults.ts";
18
- import {
19
- DEFAULT_IMAGES,
20
- LLAMA_CPP_IMAGE,
21
- OLLAMA_IMAGE,
22
- PROXY_IMAGES,
23
- SGLANG_IMAGE,
24
- VLLM_IMAGE,
25
- } from "./drivers-catalog.ts";
18
+ import { DEFAULT_IMAGES, PROXY_IMAGES } from "./drivers-catalog.ts";
26
19
  import { materializeLocalOkengineDependency } from "./local-okengine.ts";
27
20
  import { packageRoot } from "./templates.ts";
28
21
 
@@ -35,6 +28,12 @@ export type SqlDriverId = (typeof SQL_DRIVERS)[number];
35
28
  /** Default when `--sql` / wizard choice is omitted (matches template sources). */
36
29
  export const DEFAULT_SQL_DRIVER: SqlDriverId = "postgres";
37
30
 
31
+ /**
32
+ * Manifest extract peer for `oke dev` / boot effects stamping.
33
+ * Kept in sync with root `okengine` `dependencies["oxc-parser"]`.
34
+ */
35
+ export const OXC_PARSER_RANGE = "^0.142.0";
36
+
38
37
  /**
39
38
  * Whether `value` is a known {@link SqlDriverId}.
40
39
  *
@@ -88,6 +87,9 @@ export function transformPackageJson(
88
87
  if (dependencies["okengine"] !== undefined) {
89
88
  dependencies["okengine"] = okengineDep;
90
89
  }
90
+ // Always pin — published okengine ≤0.18.5 left oxc-parser optional; without it,
91
+ // Docker-first `oke dev` hard-fails OKE1020 (Manifest extract cannot run).
92
+ dependencies["oxc-parser"] ??= OXC_PARSER_RANGE;
91
93
 
92
94
  return {
93
95
  ...source,
@@ -503,15 +505,6 @@ function syncImages(source: string, defaults: CreateDefaults): string {
503
505
  if (d.store.index?.dev === "meilisearch") {
504
506
  pin("store.index", DEFAULT_IMAGES["store.index"]!);
505
507
  }
506
- if (
507
- d.ai &&
508
- (d.ai.dev === "ollama" ||
509
- d.ai.prod === "ollama" ||
510
- d.ai.dev === "openai-compatible" ||
511
- d.ai.prod === "openai-compatible")
512
- ) {
513
- pin("ai", aiImageForDefaults(defaults));
514
- }
515
508
  if (defaults.proxy !== "none") {
516
509
  pin("proxy", PROXY_IMAGES[defaults.proxy]);
517
510
  }
@@ -519,24 +512,6 @@ function syncImages(source: string, defaults: CreateDefaults): string {
519
512
  return replaceImagesBlock(source, images);
520
513
  }
521
514
 
522
- /**
523
- * Resolve `images.ai` from create-defaults provider / driver pins.
524
- *
525
- * @param defaults - Create answers
526
- */
527
- function aiImageForDefaults(defaults: CreateDefaults): string {
528
- const provider = defaults.ai.provider;
529
- if (provider === "ollama" || defaults.drivers.ai?.dev === "ollama") {
530
- return OLLAMA_IMAGE;
531
- }
532
- if (provider === "vllm") return VLLM_IMAGE;
533
- if (provider === "sglang") return SGLANG_IMAGE;
534
- if (provider === "llama-cpp" || defaults.drivers.ai?.dev === "openai-compatible") {
535
- return LLAMA_CPP_IMAGE;
536
- }
537
- return DEFAULT_IMAGES.ai!;
538
- }
539
-
540
515
  /**
541
516
  * Locate the `images: { … }` block by brace depth (not the first `}`) so
542
517
  * nested `store: { … }` / `channel: { … }` sub-blocks don't close the match
@@ -1,15 +1,16 @@
1
1
  # Environment template — create-oke copies this to `.env.local` when you scaffold.
2
2
  #
3
3
  # Resolution order (first hit wins):
4
- # vault driver → process.env → .env.local → dev fallback
4
+ # vault driver → process.env → .env.local → dev / fromDocker fallback
5
5
  #
6
- # --- Vault (app secrets) ---
7
- # Declare secrets in src/vault.ts with `vault.secret(...)`, then uncomment lines here.
6
+ # --- Vault (contracts in src/vault.ts) ---
7
+ # `vault.secret` / `vault.config` names are declared in source so Console Vault
8
+ # lists them. Put values here, in process.env, via `oke vault set`, or Console —
9
+ # same contract either way. Uncomment a line to pin a dotenv value.
8
10
  #
9
11
  # ── vault — built-in encrypted-at-rest store ────────────────
10
- # `drivers.vault` is "vault": secrets live encrypted in Postgres, no extra service.
11
- # Nothing below is required to start — a value already in the vault backend wins
12
- # over leftover process.env / dotenv pins.
12
+ # `drivers.vault` is "vault": encrypted values live in Postgres, no extra service.
13
+ # A value already in the vault backend wins over leftover process.env / dotenv.
13
14
  #
14
15
  # oke vault init # creates the store, prints the master key ONCE
15
16
  # export OKE_VAULT_MASTER_KEY=… # every later command / boot unseals with this
@@ -36,10 +37,11 @@
36
37
  # Uncomment to override compose credentials for a host-managed service.
37
38
  # Host ports under docker mode are unique per project — do not assume defaults.
38
39
 
39
- # ── App / Console ───────────────────────────────────────────
40
+ # ── App / Console (declared in src/vault.ts) ────────────────
40
41
  # PORT=6530
41
42
  # OKE_APP_URL=http://127.0.0.1:6530
42
- # Vite web (`bun run web`): empty = same-origin proxy to oke dev
43
+ # PUBLIC_API_URL=http://127.0.0.1:6530
44
+ # Vite web (`bun run web`): leave VITE_API_URL unset = same-origin proxy to oke dev
43
45
  # VITE_API_URL=
44
46
  # OKE_CONSOLE_SECRET=
45
47
  # MAINTENANCE_MODE=0
@@ -113,10 +115,25 @@
113
115
  # PGDATA=/var/lib/postgresql/data/pgdata
114
116
  # POSTGRES_INITDB_ARGS=--data-checksums
115
117
 
118
+ # ── store.index — Meilisearch (when images.store.index is set) ─
119
+ # MEILI_URL=http://127.0.0.1:7700
120
+ # MEILI_MASTER_KEY=dev-notes-meili
121
+ # OKE_STORE_INDEX_URL=http://127.0.0.1:7700
122
+ # OKE_STORE_INDEX_KEY=dev-notes-meili
123
+
116
124
  # ── AI ──────────────────────────────────────────────────────
117
- # Default local: llama.cpp openai-compatible (Ollama remains supported)
125
+ # Prefer `oke ai setup` writes ai.model() with registry providers.
126
+ # Zero Docker: OpenRouter (baseUrl auto-resolved)
127
+ # OPENROUTER_API_KEY=
128
+ # OKE_AI_CLOUD_MODEL=openrouter/free
129
+ # BYO OpenAI-compatible `/v1` — set OKE_AI_URL yourself (Compose does not manage inference)
118
130
  # OKE_AI_DRIVER=openai-compatible
119
- # OKE_AI_URL=http://127.0.0.1:8080/v1
120
- # OKE_AI_MODEL=granite3.3:2b
131
+ # OKE_AI_URL=http://127.0.0.1:1234/v1
132
+ # OKE_AI_MODEL=your-model-id
133
+ # Native Anthropic (production Claude)
121
134
  # ANTHROPIC_API_KEY=
135
+ # Other registry keys (when selected in setup): GROQ_API_KEY, TOGETHER_API_KEY,
136
+ # DEEPSEEK_API_KEY, MISTRAL_API_KEY, XAI_API_KEY, GEMINI_API_KEY, …
122
137
  # OPENAI_API_KEY=
138
+ # Optional proxy override for any registry provider:
139
+ # OPENAI_BASE_URL=
@@ -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: "1.4.0"
18
+ bun-version: "1.4.2"
19
19
  - name: Install
20
20
  run: bun install
21
21
  - name: Typecheck
@@ -30,15 +30,14 @@ export default defineConfig({
30
30
  store: {
31
31
  sql: "postgres:18-alpine",
32
32
  kv: "redis:8-alpine",
33
- files: "rustfs/rustfs:1.0.0-rc.2",
33
+ files: "rustfs/rustfs:1.0.0-rc.5",
34
34
  // index: "getmeili/meilisearch:v1.53",
35
35
  },
36
36
  channel: {
37
- email: "axllent/mailpit:v1.30.7",
37
+ email: "axllent/mailpit:v1.31.1",
38
38
  },
39
- // pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.53", // create-oke wizard / --pgdog
39
+ // pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57", // create-oke wizard / --pgdog
40
40
  // proxy: "caddy:2-alpine", // or traefik:v3.7 / nginx:1.31-alpine
41
- // ai: "ghcr.io/ggml-org/llama.cpp:server-b10450", // or ollama/ollama:0.32.13
42
41
  },
43
42
  i18n: { locales: ["en"], default: "en" },
44
43
  });
@@ -5,22 +5,23 @@
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "okengine": "file:../../../..",
8
- "@duckdb/node-api": "^1.5.5-r.2",
8
+ "@duckdb/node-api": "^1.5.5-r.4",
9
9
  "drizzle-orm": "1.0.0-rc.5-169397b",
10
- "react": "^19.2.8",
11
- "react-dom": "^19.2.8",
12
- "zod": "^4.4.3"
10
+ "oxc-parser": "^0.149.0",
11
+ "react": "^19.3.0",
12
+ "react-dom": "^19.3.0",
13
+ "zod": "^4.6.1"
13
14
  },
14
15
  "devDependencies": {
15
- "@electric-sql/pglite": "^0.5.4",
16
- "@electric-sql/pglite-pgvector": "^0.0.5",
16
+ "@electric-sql/pglite": "^0.5.8",
17
+ "@electric-sql/pglite-pgvector": "^0.0.9",
17
18
  "@types/bun": "latest",
18
- "@types/react": "^19.2.18",
19
- "@types/react-dom": "^19.2.4",
20
- "@vitejs/plugin-react": "^6.0.5",
19
+ "@types/react": "^19.3.0",
20
+ "@types/react-dom": "^19.3.0",
21
+ "@vitejs/plugin-react": "^6.1.1",
21
22
  "drizzle-kit": "1.0.0-rc.5-ab785fc",
22
23
  "typescript": "^7.0.2",
23
- "vite": "^8.2.0"
24
+ "vite": "^8.3.0"
24
25
  },
25
26
  "trustedDependencies": ["@duckdb/node-api"],
26
27
  "scripts": {
@@ -2,7 +2,42 @@ import "@/core";
2
2
  import "@/flows/generated";
3
3
 
4
4
  import { oke } from "okengine/http";
5
+ import { cors, csrf, passkey } from "okengine/plugins";
6
+ import { NOTES_VAULT } from "@/vault";
5
7
 
6
- export const app = oke({ name: "notes" });
8
+ const viteOrigins = ["http://127.0.0.1:5173", "http://localhost:5173"] as const;
9
+
10
+ /**
11
+ * Notes app — cookie auth + CSRF + passkey demo (additive to Notes Flows).
12
+ * `vault.config` is not auto-registered (only `vault.secret` is), so pass
13
+ * {@link NOTES_VAULT} for configs + secrets to resolve together.
14
+ */
15
+ export const app = oke({
16
+ name: "notes",
17
+ secrets: NOTES_VAULT,
18
+ gate: {
19
+ auth: {
20
+ cookies: { enabled: true },
21
+ emailAndPassword: { enabled: true },
22
+ },
23
+ },
24
+ })
25
+ .plug(
26
+ csrf({
27
+ allowNoHeader: false,
28
+ allowOrigins: [...viteOrigins],
29
+ }),
30
+ )
31
+ .plug(
32
+ cors({
33
+ origin: [...viteOrigins],
34
+ credentials: true,
35
+ }),
36
+ )
37
+ .plug(
38
+ passkey({
39
+ origins: [...viteOrigins, "http://127.0.0.1:6530", "http://localhost:6530"],
40
+ }),
41
+ );
7
42
 
8
43
  export type App = typeof app;
@@ -3,15 +3,17 @@
3
3
  *
4
4
  * Order matches how you usually extend the starter:
5
5
  * locales → store → gate → vault → channel → (AI via `oke ai setup`).
6
- * Keep this file until a section outgrows it, then split by element.
6
+ * Vault contracts live in `src/vault.ts` (re-exported below).
7
7
  */
8
8
 
9
9
  import "@/locales";
10
10
 
11
- import { channel, gate, store, vault } from "okengine";
11
+ import { channel, gate, store } from "okengine";
12
12
  import { z } from "zod";
13
13
  import * as schema from "@/db/schema.decl";
14
14
 
15
+ export * from "@/vault";
16
+
15
17
  // --- Store -------------------------------------------------------------------
16
18
 
17
19
  /** SQL store for Notes (`schema.decl`). Drivers: pglite locally · postgres in docker. */
@@ -31,6 +33,8 @@ export const files = store.files("uploads");
31
33
  * );
32
34
  * export const notesMutate = gate.all(notesWrite, notesWriteRate);
33
35
  * ```
36
+ *
37
+ * Cookie auth + passkey are wired in `app.ts`; client `Can` / `authorize` are UI-only.
34
38
  */
35
39
  export const notesWrite = gate.policy("notes:write", () => true);
36
40
 
@@ -45,14 +49,6 @@ export const notesWriteRate = gate.rate({
45
49
  /** Reuse on every notes mutate route. */
46
50
  export const notesMutate = gate.all(notesWrite, notesWriteRate);
47
51
 
48
- // --- Vault -------------------------------------------------------------------
49
-
50
- /** HMAC secret for outbound note webhooks (`fx.vault.get` on create). */
51
- export const webhookSecret = vault.secret("APP_WEBHOOK_SECRET", {
52
- description: "HMAC secret for outbound note webhooks",
53
- dev: "dev-webhook-secret-change-me",
54
- });
55
-
56
52
  // --- Channel -----------------------------------------------------------------
57
53
 
58
54
  const mail = channel.email({ from: "Notes <notes@localhost>" });
@@ -67,4 +63,9 @@ export const noteCreatedMail = mail.template("note-created", {
67
63
  });
68
64
 
69
65
  // --- AI ----------------------------------------------------------------------
70
- // `oke ai setup` / create-oke --ai appends models here.
66
+ // Appended by `oke ai setup` / `create-oke --ai`.
67
+ // When an embed model is chosen, setup also wires Notes hybrid search:
68
+ // oke({ store: { search: { embed: { model: embedModel, dims: 768 } } } })
69
+ // body: field.text().searchable().embed()
70
+ // Registry cloud: provider "openrouter" + OPENROUTER_API_KEY (no baseUrl).
71
+ // Local self-host still needs an explicit baseUrl (or OKE_AI_URL on the binding).
@@ -3,11 +3,15 @@ import { store, field } from "okengine";
3
3
  /**
4
4
  * Notes domain — abstract declarations, emitted to
5
5
  * `src/db/schema.drizzle.ts` for the active dialect by `oke db` / `oke dev`.
6
+ *
7
+ * `.searchable()` is free BM25. With `oke ai setup` (embed model chosen),
8
+ * body also gets bare `.embed()` and `oke({ store: { search: { embed } } })`
9
+ * stamps the project default (model + dims).
6
10
  */
7
11
  export const notes = store.schema.table("notes", {
8
12
  id: field.id().primaryKey(),
9
- title: field.text().notNull(),
10
- body: field.text().notNull(),
13
+ title: field.text().searchable({ weight: 2 }).notNull(),
14
+ body: field.text().searchable().notNull(),
11
15
  archivedAt: field.timestamp(),
12
16
  createdAt: field.timestamp().notNull().now(),
13
17
  });
@@ -19,7 +19,7 @@ async function welcomeNote(fx: Fx) {
19
19
  title: "Welcome",
20
20
  body: "Advanced Notes starter — attach files, summarize with AI, daily digest clock.",
21
21
  archivedAt: null,
22
- createdAt: 1,
22
+ createdAt: new Date("2026-01-15T10:00:00.000Z"),
23
23
  },
24
24
  );
25
25
  }
@@ -33,7 +33,7 @@ async function sampleNotes(fx: Fx) {
33
33
  title: "Shipping checklist",
34
34
  body: "Confirm schema with oke db push (or migrate in docker), then oke db seed.",
35
35
  archivedAt: null,
36
- createdAt: 2,
36
+ createdAt: new Date("2026-01-15T10:01:00.000Z"),
37
37
  },
38
38
  );
39
39
  await fx.store(db).upsert(
@@ -44,7 +44,7 @@ async function sampleNotes(fx: Fx) {
44
44
  title: "Try attach",
45
45
  body: "POST /notes/:id/attach stores a text blob on files:uploads.",
46
46
  archivedAt: null,
47
- createdAt: 3,
47
+ createdAt: new Date("2026-01-15T10:02:00.000Z"),
48
48
  },
49
49
  );
50
50
  await fx.store(db).upsert(
@@ -55,7 +55,7 @@ async function sampleNotes(fx: Fx) {
55
55
  title: "Try summarize",
56
56
  body: "POST /notes/:id/summarize uses fx.ask with the prompt's via recovery chain.",
57
57
  archivedAt: null,
58
- createdAt: 4,
58
+ createdAt: new Date("2026-01-15T10:03:00.000Z"),
59
59
  },
60
60
  );
61
61
  }
@@ -3,14 +3,15 @@ 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().public(),
7
- flow({
6
+ http.get({
8
7
  out: z.object({
9
8
  ok: z.literal(true),
10
9
  app: z.string(),
11
10
  try: z.array(z.string()),
12
11
  console: z.string(),
13
12
  }),
13
+ }).public(),
14
+ flow({
14
15
  do: () => ({
15
16
  ok: true as const,
16
17
  app: "notes",
@@ -3,26 +3,23 @@ import { eq } from "drizzle-orm";
3
3
 
4
4
  import { db, notesMutate } from "@/core";
5
5
  import { notes } from "@/db/schema.decl";
6
- import { NoteIdIn, NoteOut, NotFound } from "../shapes";
6
+ import { NoteIdIn, NoteOut, NotFound, toIsoInstant } from "../shapes";
7
7
 
8
8
  /** Soft-archive a note. */
9
9
  export const archive = on(
10
- http.post().gate(notesMutate),
10
+ http.post({ in: NoteIdIn, out: NoteOut, errors: { NotFound } }).gate(notesMutate),
11
11
  flow({
12
- in: NoteIdIn,
13
- out: NoteOut,
14
- errors: { NotFound },
15
12
  do: async (input, fx) => {
16
13
  const row = await fx.store(db).findById(notes, input.id);
17
14
  if (!row) return fail("NotFound", { id: input.id });
18
- const archivedAt = fx.clock.now();
15
+ const archivedAt = new Date(fx.clock.now());
19
16
  await fx.store(db).update(notes).set({ archivedAt }).where(eq(notes.id, input.id));
20
17
  return {
21
18
  id: String(row.id),
22
19
  title: String(row.title),
23
20
  body: String(row.body),
24
- archivedAt,
25
- createdAt: Number(row.createdAt),
21
+ archivedAt: toIsoInstant(archivedAt),
22
+ createdAt: toIsoInstant(row.createdAt),
26
23
  };
27
24
  },
28
25
  }),
@@ -6,11 +6,8 @@ import { NoteAttachIn, NoteAttachOut, NotFound } from "../shapes";
6
6
 
7
7
  /** Store a text attachment next to a note (`files:uploads`). */
8
8
  export const attach = on(
9
- http.post().gate(notesMutate),
9
+ http.post({ in: NoteAttachIn, out: NoteAttachOut, errors: { NotFound } }).gate(notesMutate),
10
10
  flow({
11
- in: NoteAttachIn,
12
- out: NoteAttachOut,
13
- errors: { NotFound },
14
11
  do: async (input, fx) => {
15
12
  const row = await fx.store(db).findById(notes, input.id);
16
13
  if (!row) return fail("NotFound", { id: input.id });
@@ -2,15 +2,12 @@ import { on, flow, http, fail } from "okengine/http";
2
2
 
3
3
  import { db } from "@/core";
4
4
  import { notes } from "@/db/schema.decl";
5
- import { NoteIdIn, NoteOut, NotFound } from "../shapes";
5
+ import { NoteIdIn, NoteOut, NotFound, toIsoInstant } from "../shapes";
6
6
 
7
7
  /** Fetch one note by id. */
8
8
  export const get = on(
9
- http.get().public(),
9
+ http.get({ in: NoteIdIn, out: NoteOut, errors: { NotFound } }).public(),
10
10
  flow({
11
- in: NoteIdIn,
12
- out: NoteOut,
13
- errors: { NotFound },
14
11
  do: async (input, fx) => {
15
12
  const row = await fx.store(db).findById(notes, input.id);
16
13
  if (!row) return fail("NotFound", { id: input.id });
@@ -18,8 +15,8 @@ export const get = on(
18
15
  id: String(row.id),
19
16
  title: String(row.title),
20
17
  body: String(row.body),
21
- archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
22
- createdAt: Number(row.createdAt),
18
+ archivedAt: row.archivedAt == null ? null : toIsoInstant(row.archivedAt),
19
+ createdAt: toIsoInstant(row.createdAt),
23
20
  };
24
21
  },
25
22
  }),
@@ -50,11 +50,10 @@ function unwrapSummaryText(text: string): string {
50
50
  * Exhausted / failed asks surface as Unavailable — never a body excerpt.
51
51
  */
52
52
  export const summarize = on(
53
- http.post().gate(notesMutate),
53
+ http.post({ in: NoteSummarizeIn, out: NoteSummarizeOut, errors: { NotFound, Unavailable } }).gate(
54
+ notesMutate,
55
+ ),
54
56
  flow({
55
- in: NoteSummarizeIn,
56
- out: NoteSummarizeOut,
57
- errors: { NotFound, Unavailable },
58
57
  do: async (input, fx) => {
59
58
  const row = await fx.store(db).findById(notes, input.id);
60
59
  if (!row) return fail("NotFound", { id: input.id });
@@ -2,19 +2,17 @@ import { on, flow, http } from "okengine/http";
2
2
 
3
3
  import { db, notesMutate, webhookSecret } from "@/core";
4
4
  import { notes } from "@/db/schema.decl";
5
- import { NoteCreateIn, NoteOut } from "./shapes";
5
+ import { NoteCreateIn, NoteOut, toIsoInstant } from "./shapes";
6
6
  import { noteCreated } from "./signals";
7
7
 
8
8
  /** Create a note, emit `note-created`, touch vault. */
9
9
  export const create = on(
10
- http.post().gate(notesMutate),
10
+ http.post({ in: NoteCreateIn, out: NoteOut }).gate(notesMutate),
11
11
  flow({
12
- in: NoteCreateIn,
13
- out: NoteOut,
14
12
  do: async (input, fx) => {
15
13
  await fx.vault.get(webhookSecret);
16
14
  const id = fx.id();
17
- const createdAt = fx.clock.now();
15
+ const createdAt = new Date(fx.clock.now());
18
16
  await fx.store(db).insert(notes).values({
19
17
  id,
20
18
  title: input.title,
@@ -28,7 +26,7 @@ export const create = on(
28
26
  title: input.title,
29
27
  body: input.body,
30
28
  archivedAt: null,
31
- createdAt,
29
+ createdAt: toIsoInstant(createdAt),
32
30
  };
33
31
  },
34
32
  }),
@@ -1,18 +1,19 @@
1
- import { on, flow, every } from "okengine/http";
1
+ import { on, flow, clock } from "okengine/http";
2
2
  import { isNull } from "drizzle-orm";
3
3
 
4
4
  import { db } from "@/core";
5
5
  import { notes } from "@/db/schema.decl";
6
- import { NoteDigestOut } from "./shapes";
6
+ import { toIsoInstant } from "./shapes";
7
+
8
+ export const digestClock = clock("notes.digest", { every: "1d" });
7
9
 
8
10
  /** Daily count of active notes (frozen under test drivers). */
9
11
  export const digest = on(
10
- every("1d"),
12
+ digestClock,
11
13
  flow({
12
- out: NoteDigestOut,
13
14
  do: async (_input, fx) => {
14
15
  const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
15
- return { active: rows.length, at: fx.clock.now() };
16
+ return { active: rows.length, at: toIsoInstant(new Date(fx.clock.now())) };
16
17
  },
17
18
  }),
18
19
  );
@@ -3,13 +3,12 @@ import { isNull } from "drizzle-orm";
3
3
 
4
4
  import { db } from "@/core";
5
5
  import { notes } from "@/db/schema.decl";
6
- import { NoteListOut } from "./shapes";
6
+ import { NoteListOut, toIsoInstant } from "./shapes";
7
7
 
8
8
  /** List active (non-archived) notes, newest first. */
9
9
  export const list = on(
10
- http.get().public(),
10
+ http.get({ out: NoteListOut }).public(),
11
11
  flow({
12
- out: NoteListOut,
13
12
  do: async (input, fx) => {
14
13
  const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
15
14
  const data = [...rows]
@@ -18,8 +17,8 @@ export const list = on(
18
17
  id: String(r.id),
19
18
  title: String(r.title),
20
19
  body: String(r.body),
21
- archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
22
- createdAt: Number(r.createdAt),
20
+ archivedAt: r.archivedAt == null ? null : toIsoInstant(r.archivedAt),
21
+ createdAt: toIsoInstant(r.createdAt),
23
22
  }));
24
23
  return fx.json.withQuery(data, input);
25
24
  },
@@ -1,5 +1,8 @@
1
1
  import { z } from "zod";
2
2
 
3
+ /** Instant on the HTTP wire — ISO-8601 UTC. */
4
+ export const IsoInstant = z.iso.datetime();
5
+
3
6
  export const NoteCreateIn = z.object({
4
7
  title: z.string().min(1).max(200),
5
8
  body: z.string().min(1).max(10_000),
@@ -9,8 +12,8 @@ export const NoteOut = z.object({
9
12
  id: z.string(),
10
13
  title: z.string(),
11
14
  body: z.string(),
12
- archivedAt: z.number().nullable(),
13
- createdAt: z.number(),
15
+ archivedAt: IsoInstant.nullable(),
16
+ createdAt: IsoInstant,
14
17
  });
15
18
 
16
19
  /** List `data` is the item array. Pagination lives in HTTP `meta`. */
@@ -37,7 +40,7 @@ export const NoteAttachOut = z.object({
37
40
 
38
41
  export const NoteDigestOut = z.object({
39
42
  active: z.number().int().nonnegative(),
40
- at: z.number(),
43
+ at: IsoInstant,
41
44
  });
42
45
 
43
46
  export const NoteSummarizeIn = z.object({
@@ -55,3 +58,10 @@ export const NoteSummarizeOut = z.object({
55
58
  export const Unavailable = z.object({
56
59
  message: z.string(),
57
60
  });
61
+
62
+ /** Map a store temporal (`Date` / ISO / epoch-ms) to an ISO wire string. */
63
+ export function toIsoInstant(value: unknown): string {
64
+ if (value instanceof Date) return value.toISOString();
65
+ if (typeof value === "string") return new Date(value).toISOString();
66
+ return new Date(Number(value)).toISOString();
67
+ }
@@ -2,8 +2,7 @@ import { signal } from "okengine";
2
2
  import { z } from "zod";
3
3
 
4
4
  /** Fired after a note is persisted — subscriber sends email. */
5
- export const noteCreated = signal("note-created", {
6
- delivery: "once",
5
+ export const noteCreated = signal.once("note-created", {
7
6
  retries: 3,
8
7
  deadLetter: true,
9
8
  schema: z.object({