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.
- package/README.md +23 -8
- package/package.json +3 -3
- package/src/ai-setup/apply.ts +214 -0
- package/src/ai-setup/catalog.ts +263 -0
- package/src/ai-setup/detect-ollama.ts +180 -0
- package/src/ai-setup/from-pref.ts +99 -0
- package/src/ai-setup/prompts.ts +649 -0
- package/src/ai-setup/recommend.ts +203 -0
- package/src/cli.test.ts +385 -28
- package/src/cli.ts +332 -29
- package/src/create-defaults.test.ts +92 -0
- package/src/create-defaults.ts +246 -0
- package/src/customize-flow.test.ts +82 -0
- package/src/customize-flow.ts +432 -0
- package/src/drivers-catalog.ts +217 -0
- package/src/scaffold.ts +97 -10
- package/src/templates.ts +14 -9
- package/src/transform.test.ts +105 -0
- package/src/transform.ts +227 -0
- package/src/wizard-select.ts +50 -0
- package/{template → templates/advanced}/.env.example +1 -0
- package/templates/advanced/README.md +32 -0
- package/templates/advanced/oke.config.ts +74 -0
- package/templates/advanced/package.json +18 -0
- package/templates/advanced/src/app.ts +30 -0
- package/templates/advanced/src/channels.ts +13 -0
- package/templates/advanced/src/core.ts +8 -0
- package/{template → templates/advanced}/src/flows/main/index.ts +9 -6
- package/templates/advanced/src/flows/main/shapes.ts +1 -0
- package/templates/advanced/src/flows/main/signals.ts +1 -0
- package/templates/advanced/src/flows/notes/index.ts +224 -0
- package/templates/advanced/src/flows/notes/shapes.ts +52 -0
- package/templates/advanced/src/flows/notes/signals.ts +13 -0
- package/templates/advanced/src/gates.ts +12 -0
- package/templates/advanced/src/locales/ar.ts +18 -0
- package/templates/advanced/src/locales/en.ts +22 -0
- package/templates/advanced/src/schema.decl.ts +13 -0
- package/templates/advanced/src/vault.ts +7 -0
- package/templates/advanced/tests/advanced.test.ts +42 -0
- package/templates/standard/.env.example +94 -0
- package/templates/standard/README.md +46 -0
- package/templates/standard/drizzle.config.ts +25 -0
- package/{template → templates/standard}/oke.config.ts +9 -9
- package/{template → templates/standard}/package.json +1 -1
- package/templates/standard/src/app.ts +30 -0
- package/templates/standard/src/channels.ts +13 -0
- package/{template → templates/standard}/src/core.ts +1 -0
- package/templates/standard/src/flows/main/index.ts +34 -0
- package/templates/standard/src/flows/main/shapes.ts +1 -0
- package/templates/standard/src/flows/main/signals.ts +1 -0
- package/templates/standard/src/flows/notes/index.ts +143 -0
- package/templates/standard/src/flows/notes/shapes.ts +26 -0
- package/templates/standard/src/flows/notes/signals.ts +13 -0
- package/templates/standard/src/gates.ts +12 -0
- package/templates/standard/src/locales/ar.ts +18 -0
- package/templates/standard/src/locales/en.ts +22 -0
- package/templates/standard/src/schema.decl.ts +13 -0
- package/templates/standard/src/vault.ts +7 -0
- package/templates/standard/tests/standard.test.ts +51 -0
- package/template/README.md +0 -77
- package/template/bun.lock +0 -845
- package/template/src/app.ts +0 -18
- package/template/src/channels.ts +0 -4
- package/template/src/flows/main/shapes.ts +0 -2
- package/template/src/flows/main/signals.ts +0 -4
- package/template/src/gates.ts +0 -4
- package/template/src/locales/ar.ts +0 -20
- package/template/src/locales/en.ts +0 -26
- package/template/src/schema.decl.ts +0 -12
- package/template/src/vault.ts +0 -4
- package/template/tests/standard.test.ts +0 -18
- /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: "
|
|
31
|
+
docker: "redis",
|
|
28
32
|
test: "memory",
|
|
29
|
-
prod: "
|
|
33
|
+
prod: "redis",
|
|
30
34
|
},
|
|
31
35
|
clock: {
|
|
32
36
|
local: "memory",
|
|
33
|
-
docker: "
|
|
37
|
+
docker: "file",
|
|
34
38
|
test: "frozen",
|
|
35
|
-
prod: "
|
|
39
|
+
prod: "file",
|
|
36
40
|
},
|
|
37
41
|
vault: {
|
|
38
|
-
local: "
|
|
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
|
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|