create-oke 0.6.1 → 0.7.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 (72) hide show
  1. package/README.md +23 -8
  2. package/package.json +3 -3
  3. package/src/ai-setup/apply.ts +214 -0
  4. package/src/ai-setup/catalog.ts +263 -0
  5. package/src/ai-setup/detect-ollama.ts +180 -0
  6. package/src/ai-setup/from-pref.ts +99 -0
  7. package/src/ai-setup/prompts.ts +649 -0
  8. package/src/ai-setup/recommend.ts +203 -0
  9. package/src/cli.test.ts +385 -28
  10. package/src/cli.ts +332 -29
  11. package/src/create-defaults.test.ts +92 -0
  12. package/src/create-defaults.ts +246 -0
  13. package/src/customize-flow.test.ts +82 -0
  14. package/src/customize-flow.ts +432 -0
  15. package/src/drivers-catalog.ts +217 -0
  16. package/src/scaffold.ts +97 -10
  17. package/src/templates.ts +14 -9
  18. package/src/transform.test.ts +105 -0
  19. package/src/transform.ts +227 -0
  20. package/src/wizard-select.ts +50 -0
  21. package/{template → templates/advanced}/.env.example +1 -0
  22. package/templates/advanced/README.md +32 -0
  23. package/templates/advanced/oke.config.ts +74 -0
  24. package/templates/advanced/package.json +18 -0
  25. package/templates/advanced/src/app.ts +30 -0
  26. package/templates/advanced/src/channels.ts +13 -0
  27. package/templates/advanced/src/core.ts +8 -0
  28. package/{template → templates/advanced}/src/flows/main/index.ts +9 -6
  29. package/templates/advanced/src/flows/main/shapes.ts +1 -0
  30. package/templates/advanced/src/flows/main/signals.ts +1 -0
  31. package/templates/advanced/src/flows/notes/index.ts +224 -0
  32. package/templates/advanced/src/flows/notes/shapes.ts +52 -0
  33. package/templates/advanced/src/flows/notes/signals.ts +13 -0
  34. package/templates/advanced/src/gates.ts +12 -0
  35. package/templates/advanced/src/locales/ar.ts +18 -0
  36. package/templates/advanced/src/locales/en.ts +22 -0
  37. package/templates/advanced/src/schema.decl.ts +13 -0
  38. package/templates/advanced/src/vault.ts +7 -0
  39. package/templates/advanced/tests/advanced.test.ts +42 -0
  40. package/templates/standard/.env.example +94 -0
  41. package/templates/standard/README.md +46 -0
  42. package/templates/standard/drizzle.config.ts +25 -0
  43. package/{template → templates/standard}/oke.config.ts +9 -9
  44. package/{template → templates/standard}/package.json +1 -1
  45. package/templates/standard/src/app.ts +30 -0
  46. package/templates/standard/src/channels.ts +13 -0
  47. package/{template → templates/standard}/src/core.ts +1 -0
  48. package/templates/standard/src/flows/main/index.ts +34 -0
  49. package/templates/standard/src/flows/main/shapes.ts +1 -0
  50. package/templates/standard/src/flows/main/signals.ts +1 -0
  51. package/templates/standard/src/flows/notes/index.ts +143 -0
  52. package/templates/standard/src/flows/notes/shapes.ts +26 -0
  53. package/templates/standard/src/flows/notes/signals.ts +13 -0
  54. package/templates/standard/src/gates.ts +12 -0
  55. package/templates/standard/src/locales/ar.ts +18 -0
  56. package/templates/standard/src/locales/en.ts +22 -0
  57. package/templates/standard/src/schema.decl.ts +13 -0
  58. package/templates/standard/src/vault.ts +7 -0
  59. package/templates/standard/tests/standard.test.ts +51 -0
  60. package/template/README.md +0 -77
  61. package/template/bun.lock +0 -845
  62. package/template/src/app.ts +0 -18
  63. package/template/src/channels.ts +0 -4
  64. package/template/src/flows/main/shapes.ts +0 -2
  65. package/template/src/flows/main/signals.ts +0 -4
  66. package/template/src/gates.ts +0 -4
  67. package/template/src/locales/ar.ts +0 -20
  68. package/template/src/locales/en.ts +0 -26
  69. package/template/src/schema.decl.ts +0 -12
  70. package/template/src/vault.ts +0 -4
  71. package/template/tests/standard.test.ts +0 -18
  72. /package/{template → templates/advanced}/drizzle.config.ts +0 -0
@@ -0,0 +1,94 @@
1
+ # Environment template — create-oke copies this to `.env.local` when you scaffold.
2
+ #
3
+ # Resolution order (first hit wins):
4
+ # process.env → .env.local → docker/.env.docker → vault driver → dev fallback
5
+ #
6
+ # --- Vault (app secrets) ---
7
+ # Declare secrets in src/vault.ts with `vault.secret(...)`, then uncomment lines here.
8
+ #
9
+ # --- Infrastructure ---
10
+ # Same keys as `docker/.env.docker` after `oke dev --docker`.
11
+ # Uncomment to override compose credentials for a host-managed service.
12
+ # Host ports under docker mode are unique per project — do not assume defaults.
13
+
14
+ # ── App / Console ───────────────────────────────────────────
15
+ # PORT=6530
16
+ # OKE_APP_URL=http://127.0.0.1:6530
17
+ # OKE_CONSOLE_SECRET=
18
+ # MAINTENANCE_MODE=0
19
+ # APP_WEBHOOK_SECRET=dev-webhook-secret-change-me
20
+
21
+ # ── channel.email — Mailpit (SMTP + UI) ─────────────────────
22
+ # OKE_CHANNEL_EMAIL_URL=smtp://127.0.0.1:1025
23
+ # SMTP_URL=smtp://127.0.0.1:1025
24
+ # SMTP_HOST=127.0.0.1
25
+ # SMTP_PORT=1025
26
+ # MAILPIT_UI_URL=http://127.0.0.1:8025
27
+ # Optional controls:
28
+ # SMTP_USER=
29
+ # SMTP_PASSWORD=
30
+ # MP_MAX_MESSAGES=500
31
+ # MP_SMTP_AUTH_ACCEPT_ANY=1
32
+ # MP_SMTP_AUTH_ALLOW_INSECURE=1
33
+
34
+ # ── channel.email — HTTP providers ──────────────────────────
35
+ # RESEND_API_KEY=
36
+ # SNDR_API_KEY=
37
+ # SNDR_BASE_URL=https://api.sndr.sh
38
+ # SNDR_FROM=hello@your-verified-domain.com
39
+
40
+ # ── channel.sms — Taqnyat / Msegat / Unifonic ───────────────
41
+ # TAQNYAT_BEARER_TOKEN=
42
+ # TAQNYAT_SENDER=
43
+ # MSEGAT_USERNAME=
44
+ # MSEGAT_API_KEY=
45
+ # MSEGAT_SENDER=
46
+ # UNIFONIC_APPSID=
47
+ # UNIFONIC_SENDER=
48
+
49
+ # ── channel.whatsapp / push ─────────────────────────────────
50
+ # WHATSAPP_TOKEN=
51
+ # WHATSAPP_PHONE_NUMBER_ID=
52
+ # VAPID_PUBLIC_KEY=
53
+ # VAPID_PRIVATE_KEY=
54
+ # VAPID_SUBJECT=mailto:ops@example.com
55
+ # FCM_PROJECT_ID=
56
+ # FCM_CLIENT_EMAIL=
57
+ # FCM_PRIVATE_KEY=
58
+
59
+ # ── store.files — object storage (S3) ───────────────────────
60
+ # OKE_STORE_FILES_URL=http://access-key:secret-key@127.0.0.1:9000/oke
61
+ # S3_ACCESS_KEY_ID=access-key
62
+ # S3_SECRET_ACCESS_KEY=secret-key
63
+ # S3_BUCKET=oke
64
+ # S3_URL=http://access-key:secret-key@127.0.0.1:9000/oke
65
+ # S3_ENDPOINT=http://127.0.0.1:9000
66
+ # S3_CONSOLE_URL=http://127.0.0.1:9001
67
+ # S3_REGION=us-east-1
68
+ # Optional controls:
69
+ # S3_SESSION_TOKEN=
70
+
71
+ # ── store.kv — Redis ────────────────────────────────────────
72
+ # OKE_STORE_KV_PASSWORD=password
73
+ # OKE_STORE_KV_URL=redis://:password@127.0.0.1:6379
74
+ # REDIS_URL=redis://:password@127.0.0.1:6379
75
+ # Optional controls:
76
+ # OKE_STORE_KV_MAXMEMORY=256mb
77
+ # OKE_STORE_KV_MAXMEMORY_POLICY=allkeys-lru
78
+
79
+ # ── store.sql — Postgres ────────────────────────────────────
80
+ # OKE_STORE_SQL_USER=oke
81
+ # OKE_STORE_SQL_PASSWORD=password
82
+ # OKE_STORE_SQL_DB=oke
83
+ # OKE_STORE_SQL_URL=postgres://oke:password@127.0.0.1:5432/oke
84
+ # DATABASE_URL=postgres://oke:password@127.0.0.1:5432/oke
85
+ # Optional controls:
86
+ # PGDATA=/var/lib/postgresql/data/pgdata
87
+ # POSTGRES_INITDB_ARGS=--data-checksums
88
+
89
+ # ── AI ──────────────────────────────────────────────────────
90
+ # OKE_AI_DRIVER=ollama
91
+ # OKE_AI_URL=http://127.0.0.1:11434
92
+ # OKE_AI_MODEL=qwen3.5:9b
93
+ # ANTHROPIC_API_KEY=
94
+ # OPENAI_API_KEY=
@@ -0,0 +1,46 @@
1
+ # Notes (standard)
2
+
3
+ Local-first [okengine](https://oke.omqkhafi.dev) starter: a small **Notes** API with
4
+ named Flows, Store, Signal → email, and Vault.
5
+
6
+ ```bash
7
+ bun install
8
+ oke dev
9
+ ```
10
+
11
+ | Surface | URL |
12
+ | ------- | --- |
13
+ | App | http://127.0.0.1:6530 |
14
+ | Console | http://127.0.0.1:6533 |
15
+ | MCP | http://127.0.0.1:6535 |
16
+
17
+ ## Try it
18
+
19
+ ```bash
20
+ curl -s http://127.0.0.1:6530/health
21
+ curl -s -X POST http://127.0.0.1:6530/notes \
22
+ -H 'content-type: application/json' \
23
+ -d '{"title":"Hello","body":"First note"}'
24
+ curl -s http://127.0.0.1:6530/notes
25
+ ```
26
+
27
+ Flow names in the `oke dev` log are stable (`main.health`, `notes.create`, …) —
28
+ never anonymous `flow_1`.
29
+
30
+ ## Layout
31
+
32
+ | Path | Role |
33
+ | ---- | ---- |
34
+ | `src/schema.decl.ts` | `notes` table |
35
+ | `src/flows/notes/` | list · create · get · archive · onCreated |
36
+ | `src/flows/main/` | root · health |
37
+ | `src/channels.ts` | `note-created` email template |
38
+ | `src/vault.ts` | `APP_WEBHOOK_SECRET` |
39
+ | `oke.config.ts` | local sqlite · docker postgres (+ redis/s3/openbao/smtp) |
40
+
41
+ Switch mode anytime: `oke mode docker` then `oke dev` (or `oke dev --docker`).
42
+
43
+ ## AI
44
+
45
+ Not baked into this starter. Re-run create-oke with `--ai`, or `oke ai setup`, to
46
+ add `drivers.ai` + `src/ai.ts`.
@@ -0,0 +1,25 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ /**
4
+ * Domain schema sync for `oke db push|generate|migrate`.
5
+ *
6
+ * Dialect comes from OKE's `store.sql` driver map (resolved by the CLI and
7
+ * injected as `OKE_DRIZZLE_DIALECT`) — never inferred from `DATABASE_URL`
8
+ * presence. `src/schema.generated.ts` is emitted from `src/schema.decl.ts`
9
+ * for the active dialect as a pre-step of `oke db` / `oke dev`.
10
+ *
11
+ * Versioned prod migrations land in `./drizzle` — never mix with `.oke/`.
12
+ */
13
+ const dialect = (process.env.OKE_DRIZZLE_DIALECT ?? "sqlite") as "sqlite" | "postgresql";
14
+
15
+ export default defineConfig({
16
+ dialect,
17
+ schema: "./src/schema.generated.ts",
18
+ out: "./drizzle",
19
+ dbCredentials: {
20
+ url:
21
+ dialect === "postgresql"
22
+ ? (process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL!)
23
+ : (process.env.OKE_SQLITE_URL ?? "file:.oke/app.sqlite"),
24
+ },
25
+ });
@@ -1,5 +1,9 @@
1
1
  import { defineConfig } from "okengine/config";
2
2
 
3
+ /**
4
+ * Standard starter — local-first Notes.
5
+ * Docker/prod pins stay ready when you switch `oke mode docker`.
6
+ */
3
7
  export default defineConfig({
4
8
  drivers: {
5
9
  store: {
@@ -24,18 +28,18 @@ export default defineConfig({
24
28
  },
25
29
  signal: {
26
30
  local: "memory",
27
- docker: "postgres",
31
+ docker: "redis",
28
32
  test: "memory",
29
- prod: "postgres",
33
+ prod: "redis",
30
34
  },
31
35
  clock: {
32
36
  local: "memory",
33
- docker: "postgres",
37
+ docker: "file",
34
38
  test: "frozen",
35
- prod: "postgres",
39
+ prod: "file",
36
40
  },
37
41
  vault: {
38
- local: "dotenv",
42
+ local: "env",
39
43
  docker: "openbao",
40
44
  test: "memory",
41
45
  prod: "openbao",
@@ -55,10 +59,6 @@ export default defineConfig({
55
59
  "store.files": "rustfs/rustfs:1.0.0-beta.11",
56
60
  "channel.email": "axllent/mailpit:v1.22.3",
57
61
  vault: "openbao/openbao:2.6.1",
58
- // Opt in to full-text search (meilisearch store.index driver):
59
- // drivers.store.index: { local: "meilisearch", docker: "meilisearch", test: "memory", prod: "meilisearch" }
60
- // and pin its image. Local mode then needs the `meilisearch` binary on PATH.
61
- // "store.index": "getmeili/meilisearch:v1.37",
62
62
  },
63
63
  i18n: { locales: ["en", "ar"], default: "en", dir: { ar: "rtl" } },
64
64
  });
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "okengine": "file:../..",
7
+ "okengine": "file:../../../..",
8
8
  "drizzle-orm": "^1.0.0-rc.4",
9
9
  "zod": "^4.4.3"
10
10
  },
@@ -0,0 +1,30 @@
1
+ import { db } from "./core";
2
+ import "./gates";
3
+ import "./vault";
4
+ import "./channels";
5
+ import "./locales/en";
6
+ import "./locales/ar";
7
+
8
+ import { oke } from "okengine";
9
+ import * as main from "./flows/main";
10
+ import * as notes from "./flows/notes";
11
+ import { noteCreated } from "./flows/notes/signals";
12
+ import { noteCreatedMail } from "./channels";
13
+ import { webhookSecret } from "./vault";
14
+
15
+ export const app = oke({
16
+ name: "notes",
17
+ secrets: [webhookSecret],
18
+ signals: [noteCreated],
19
+ channel: { templates: [noteCreatedMail] },
20
+ }).adopt({ main, notes });
21
+
22
+ export type App = typeof app;
23
+
24
+ Object.assign(app.$options, {
25
+ env: "test",
26
+ stores: [db],
27
+ secrets: [webhookSecret],
28
+ signals: [noteCreated],
29
+ channel: { templates: [noteCreatedMail] },
30
+ });
@@ -0,0 +1,13 @@
1
+ import { channel } from "okengine";
2
+ import { z } from "zod";
3
+
4
+ const mail = channel.email({ from: "Notes <notes@localhost>" });
5
+
6
+ /** Email when a note is created (console driver locally · SMTP in docker). */
7
+ export const noteCreatedMail = mail.template("note-created", {
8
+ locales: ["en", "ar"],
9
+ schema: z.object({
10
+ id: z.string(),
11
+ title: z.string(),
12
+ }),
13
+ });
@@ -1,4 +1,5 @@
1
1
  import { store } from "okengine";
2
2
  import * as schema from "./schema.decl";
3
3
 
4
+ /** App SQL store — tables from {@link schema}. */
4
5
  export const db = store.sql("app", { schema });
@@ -0,0 +1,34 @@
1
+ import { on, flow, http, gate } from "okengine";
2
+ import { z } from "zod";
3
+
4
+ /** First-run welcome — visit :6530/ after `oke dev`. */
5
+ export const root = on(
6
+ http.get("/").gate(gate.public),
7
+ flow({
8
+ name: "main.root",
9
+ unit: "main",
10
+ out: z.object({
11
+ ok: z.literal(true),
12
+ app: z.string(),
13
+ try: z.array(z.string()),
14
+ console: z.string(),
15
+ }),
16
+ do: () => ({
17
+ ok: true as const,
18
+ app: "notes",
19
+ try: ["GET /notes", "POST /notes", "GET /health"],
20
+ console: "http://127.0.0.1:6533",
21
+ }),
22
+ }),
23
+ );
24
+
25
+ /** Liveness for probes and `bun test`. */
26
+ export const health = on(
27
+ http.get("/health").gate(gate.public),
28
+ flow({
29
+ name: "main.health",
30
+ unit: "main",
31
+ out: z.object({ ok: z.literal(true) }),
32
+ do: () => ({ ok: true as const }),
33
+ }),
34
+ );
@@ -0,0 +1 @@
1
+ /** Main-unit contracts — keep route-local shapes next to flows when they grow. */
@@ -0,0 +1 @@
1
+ /** Main-unit signals — none in the standard starter. */
@@ -0,0 +1,143 @@
1
+ import { on, flow, http, gate, fail } from "okengine";
2
+ import { eq, isNull } from "drizzle-orm";
3
+
4
+ import { db } from "../../core";
5
+ import { notes } from "../../schema.decl";
6
+ import { webhookSecret } from "../../vault";
7
+ import { noteCreatedMail } from "../../channels";
8
+ import { NoteCreateIn, NoteIdIn, NoteListOut, NoteOut, NotFound } from "./shapes";
9
+ import { noteCreated } from "./signals";
10
+
11
+ import "./shapes";
12
+ import "./signals";
13
+
14
+ /** List active (non-archived) notes, newest first. */
15
+ export const list = on(
16
+ http.get("/notes").gate(gate.public),
17
+ flow({
18
+ name: "notes.list",
19
+ unit: "notes",
20
+ out: NoteListOut,
21
+ effects: { reads: ["sql:app"] },
22
+ do: async (_input, fx) => {
23
+ const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
24
+ const sorted = [...rows].sort((a, b) => Number(b.createdAt) - Number(a.createdAt));
25
+ return {
26
+ notes: sorted.map((r) => ({
27
+ id: String(r.id),
28
+ title: String(r.title),
29
+ body: String(r.body),
30
+ archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
31
+ createdAt: Number(r.createdAt),
32
+ })),
33
+ };
34
+ },
35
+ }),
36
+ );
37
+
38
+ /** Create a note, emit `note-created`, touch vault. */
39
+ export const create = on(
40
+ http.post("/notes").gate(gate.public),
41
+ flow({
42
+ name: "notes.create",
43
+ unit: "notes",
44
+ in: NoteCreateIn,
45
+ out: NoteOut,
46
+ effects: {
47
+ writes: ["sql:app"],
48
+ emits: ["note-created"],
49
+ secrets: ["APP_WEBHOOK_SECRET"],
50
+ },
51
+ do: async (input, fx) => {
52
+ // Prove Vault is wired (local: env/dev fallback).
53
+ fx.vault(webhookSecret);
54
+ const id = fx.id();
55
+ const createdAt = fx.clock.now();
56
+ await fx.store(db).insert(notes).values({
57
+ id,
58
+ title: input.title,
59
+ body: input.body,
60
+ archivedAt: null,
61
+ createdAt,
62
+ });
63
+ await fx.emit(noteCreated, { id, title: input.title }, { key: id });
64
+ return {
65
+ id,
66
+ title: input.title,
67
+ body: input.body,
68
+ archivedAt: null,
69
+ createdAt,
70
+ };
71
+ },
72
+ }),
73
+ );
74
+
75
+ /** Fetch one note by id. */
76
+ export const get = on(
77
+ http.get("/notes/:id").gate(gate.public),
78
+ flow({
79
+ name: "notes.get",
80
+ unit: "notes",
81
+ in: NoteIdIn,
82
+ out: NoteOut,
83
+ errors: { NotFound },
84
+ effects: { reads: ["sql:app"] },
85
+ do: async (input, fx) => {
86
+ const row = await fx.store(db).findById(notes, input.id);
87
+ if (!row) return fail("NotFound", { id: input.id });
88
+ return {
89
+ id: String(row.id),
90
+ title: String(row.title),
91
+ body: String(row.body),
92
+ archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
93
+ createdAt: Number(row.createdAt),
94
+ };
95
+ },
96
+ }),
97
+ );
98
+
99
+ /** Soft-archive a note. */
100
+ export const archive = on(
101
+ http.post("/notes/:id/archive").gate(gate.public),
102
+ flow({
103
+ name: "notes.archive",
104
+ unit: "notes",
105
+ in: NoteIdIn,
106
+ out: NoteOut,
107
+ errors: { NotFound },
108
+ effects: { reads: ["sql:app"], writes: ["sql:app"] },
109
+ do: async (input, fx) => {
110
+ const row = await fx.store(db).findById(notes, input.id);
111
+ if (!row) return fail("NotFound", { id: input.id });
112
+ const archivedAt = fx.clock.now();
113
+ await fx
114
+ .store(db)
115
+ .update(notes)
116
+ .set({ archivedAt })
117
+ .where(eq(notes.id, input.id));
118
+ return {
119
+ id: String(row.id),
120
+ title: String(row.title),
121
+ body: String(row.body),
122
+ archivedAt,
123
+ createdAt: Number(row.createdAt),
124
+ };
125
+ },
126
+ }),
127
+ );
128
+
129
+ /** On create → send the note-created email template. */
130
+ export const onCreated = on(
131
+ noteCreated,
132
+ flow({
133
+ name: "notes.onCreated",
134
+ unit: "notes",
135
+ effects: { sends: ["note-created"] },
136
+ do: async (payload, fx) => {
137
+ await fx.send(noteCreatedMail, {
138
+ to: "you@localhost",
139
+ data: { id: payload.id, title: payload.title },
140
+ });
141
+ },
142
+ }),
143
+ );
@@ -0,0 +1,26 @@
1
+ import { z } from "zod";
2
+
3
+ export const NoteCreateIn = z.object({
4
+ title: z.string().min(1).max(200),
5
+ body: z.string().min(1).max(10_000),
6
+ });
7
+
8
+ export const NoteOut = z.object({
9
+ id: z.string(),
10
+ title: z.string(),
11
+ body: z.string(),
12
+ archivedAt: z.number().nullable(),
13
+ createdAt: z.number(),
14
+ });
15
+
16
+ export const NoteListOut = z.object({
17
+ notes: z.array(NoteOut),
18
+ });
19
+
20
+ export const NoteIdIn = z.object({
21
+ id: z.string().min(1),
22
+ });
23
+
24
+ export const NotFound = z.object({
25
+ id: z.string(),
26
+ });
@@ -0,0 +1,13 @@
1
+ import { signal } from "okengine";
2
+ import { z } from "zod";
3
+
4
+ /** Fired after a note is persisted — subscriber sends email. */
5
+ export const noteCreated = signal("note-created", {
6
+ delivery: "once",
7
+ retries: 3,
8
+ deadLetter: true,
9
+ schema: z.object({
10
+ id: z.string(),
11
+ title: z.string(),
12
+ }),
13
+ });
@@ -0,0 +1,12 @@
1
+ import { gate } from "okengine";
2
+
3
+ /**
4
+ * Starter keeps HTTP public. Replace with real policies when you add auth:
5
+ *
6
+ * ```ts
7
+ * export const canWriteNotes = gate.policy("notes:write", ({ auth }) =>
8
+ * auth.scopes.has("notes:write"),
9
+ * );
10
+ * ```
11
+ */
12
+ export const notesWrite = gate.policy("notes:write", () => true);
@@ -0,0 +1,18 @@
1
+ import { defineLocale } from "okengine";
2
+ import type { MessagesFor } from "okengine";
3
+ import type { en } from "./en";
4
+
5
+ const ar = {
6
+ errors: {
7
+ notFound: "غير موجود",
8
+ unauthorized: "غير مصرح",
9
+ },
10
+ notes: {
11
+ created: "تم إنشاء الملاحظة «{title}».",
12
+ archived: "تم أرشفة الملاحظة.",
13
+ empty: "لا توجد ملاحظات نشطة بعد.",
14
+ count: "{count, plural, zero {لا ملاحظات} one {ملاحظة واحدة} two {ملاحظتان} few {# ملاحظات} many {# ملاحظة} other {# ملاحظة}}",
15
+ },
16
+ } satisfies MessagesFor<typeof en>;
17
+
18
+ defineLocale("ar", ar);
@@ -0,0 +1,22 @@
1
+ import { defineMessages, defineLocale } from "okengine";
2
+
3
+ export const en = defineMessages({
4
+ errors: {
5
+ notFound: "Not found",
6
+ unauthorized: "Unauthorized",
7
+ },
8
+ notes: {
9
+ created: "Note “{title}” was created.",
10
+ archived: "Note archived.",
11
+ empty: "No active notes yet.",
12
+ count: "{count, plural, =0 {no notes} one {# note} other {# notes}}",
13
+ },
14
+ });
15
+
16
+ defineLocale("en", en);
17
+
18
+ declare module "okengine" {
19
+ interface Register {
20
+ messages: typeof en;
21
+ }
22
+ }
@@ -0,0 +1,13 @@
1
+ import { store, field, id, now } from "okengine";
2
+
3
+ /**
4
+ * Notes domain — abstract declarations, emitted to
5
+ * `src/schema.generated.ts` for the active dialect by `oke db` / `oke dev`.
6
+ */
7
+ export const notes = store.schema.table("notes", {
8
+ id: field.text().primaryKey().defaultFn(id),
9
+ title: field.text().notNull(),
10
+ body: field.text().notNull(),
11
+ archivedAt: field.integer(),
12
+ createdAt: field.integer().notNull().defaultFn(now),
13
+ });
@@ -0,0 +1,7 @@
1
+ import { vault } from "okengine";
2
+
3
+ /** Shared webhook HMAC secret — prove Vault wiring on note create. */
4
+ export const webhookSecret = vault.secret("APP_WEBHOOK_SECRET", {
5
+ description: "HMAC secret for outbound note webhooks",
6
+ dev: "dev-webhook-secret-change-me",
7
+ });
@@ -0,0 +1,51 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { createTestApp, type TestApp } from "okengine/test";
3
+ import { app, type App } from "../src/app";
4
+
5
+ let t: TestApp<App>;
6
+
7
+ beforeAll(async () => {
8
+ t = await createTestApp(app);
9
+ });
10
+
11
+ afterAll(async () => {
12
+ await t.close();
13
+ });
14
+
15
+ test("boots — health flow is named main.health", async () => {
16
+ const { data, error } = await t.api.main.health({});
17
+ expect(error).toBeNull();
18
+ expect(data).toEqual({ ok: true });
19
+ });
20
+
21
+ test("notes create → list → archive", async () => {
22
+ const created = await t.api.notes.create({
23
+ title: "Hello",
24
+ body: "World from the standard starter",
25
+ });
26
+ expect(created.error).toBeNull();
27
+ expect(created.data?.title).toBe("Hello");
28
+ expect(created.data?.id).toBeTruthy();
29
+
30
+ await t.signals.drain();
31
+ expect(t.channels.sent().some((s) => s.template === "note-created")).toBe(true);
32
+
33
+ const listed = await t.api.notes.list({});
34
+ expect(listed.error).toBeNull();
35
+ expect(listed.data?.notes.some((n) => n.id === created.data!.id)).toBe(true);
36
+
37
+ const archived = await t.api.notes.archive({ id: created.data!.id });
38
+ expect(archived.error).toBeNull();
39
+ expect(archived.data?.archivedAt).toBeTypeOf("number");
40
+
41
+ const after = await t.api.notes.list({});
42
+ expect(after.data?.notes.some((n) => n.id === created.data!.id)).toBe(false);
43
+ });
44
+
45
+ /** Flag-free `app.fetch` must auto-boot. */
46
+ test("bare fetch auto-boots the public health route", async () => {
47
+ const res = await app.fetch(new Request("http://localhost/health"));
48
+ expect(res.status).toBe(200);
49
+ expect(app.booted).toBe(true);
50
+ expect(await res.json()).toEqual({ data: { ok: true }, error: null });
51
+ });