create-oke 0.11.2 → 0.13.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/package.json +1 -1
- package/src/agents-md.ts +4 -1
- package/src/ai-setup/apply.ts +3 -5
- package/src/ai-setup/from-pref.ts +4 -4
- package/src/ai-setup/prompts.ts +1 -1
- package/src/cli.test.ts +86 -14
- package/src/cli.ts +145 -29
- package/src/create-defaults.test.ts +9 -0
- package/src/create-defaults.ts +27 -0
- package/src/customize-flow.ts +2 -1
- package/src/drivers-catalog.ts +18 -9
- package/src/local-okengine.test.ts +2 -0
- package/src/local-okengine.ts +3 -0
- package/src/scaffold.ts +17 -5
- package/src/transform.test.ts +6 -3
- package/src/transform.ts +24 -1
- package/templates/advanced/.env.example +13 -5
- package/templates/advanced/README.md +3 -0
- package/templates/advanced/oke.config.ts +6 -5
- package/templates/advanced/package.json +14 -3
- package/templates/advanced/src/app.ts +2 -1
- package/templates/advanced/src/core.ts +12 -0
- package/templates/advanced/src/db/seed/index.ts +2 -0
- package/templates/advanced/src/flows/main/index.ts +4 -4
- package/templates/advanced/src/flows/notes/index.ts +14 -14
- package/templates/advanced/src/flows/notes/shapes.ts +2 -3
- package/templates/advanced/web/index.html +12 -0
- package/templates/advanced/web/src/App.css +78 -0
- package/templates/advanced/web/src/App.tsx +100 -0
- package/templates/advanced/web/src/client.ts +38 -0
- package/templates/advanced/web/src/index.css +18 -0
- package/templates/advanced/web/src/main.tsx +13 -0
- package/templates/advanced/web/src/vite-env.d.ts +17 -0
- package/templates/advanced/web/tsconfig.app.json +24 -0
- package/templates/advanced/web/tsconfig.json +4 -0
- package/templates/advanced/web/tsconfig.node.json +20 -0
- package/templates/advanced/web/vite.config.ts +32 -0
- package/templates/standard/.env.example +13 -5
- package/templates/standard/README.md +3 -0
- package/templates/standard/oke.config.ts +4 -3
- package/templates/standard/package.json +14 -3
- package/templates/standard/src/app.ts +2 -1
- package/templates/standard/src/core.ts +12 -0
- package/templates/standard/src/db/seed/index.ts +2 -0
- package/templates/standard/src/flows/main/index.ts +4 -4
- package/templates/standard/src/flows/notes/index.ts +12 -12
- package/templates/standard/src/flows/notes/shapes.ts +2 -3
- package/templates/standard/tests/standard.test.ts +3 -2
- package/templates/standard/web/index.html +12 -0
- package/templates/standard/web/src/App.css +78 -0
- package/templates/standard/web/src/App.tsx +100 -0
- package/templates/standard/web/src/client.ts +38 -0
- package/templates/standard/web/src/index.css +18 -0
- package/templates/standard/web/src/main.tsx +13 -0
- package/templates/standard/web/src/vite-env.d.ts +17 -0
- package/templates/standard/web/tsconfig.app.json +24 -0
- package/templates/standard/web/tsconfig.json +4 -0
- package/templates/standard/web/tsconfig.node.json +20 -0
- package/templates/standard/web/vite.config.ts +32 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
4
|
+
"target": "es2023",
|
|
5
|
+
"lib": ["ES2023", "DOM"],
|
|
6
|
+
"module": "esnext",
|
|
7
|
+
"types": ["vite/client"],
|
|
8
|
+
"allowArbitraryExtensions": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"moduleDetection": "force",
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
"strict": true,
|
|
17
|
+
"noUnusedLocals": true,
|
|
18
|
+
"noUnusedParameters": true,
|
|
19
|
+
"erasableSyntaxOnly": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true
|
|
22
|
+
},
|
|
23
|
+
"include": ["src"]
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
4
|
+
"target": "es2023",
|
|
5
|
+
"lib": ["ES2023"],
|
|
6
|
+
"types": ["bun"],
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"module": "nodenext",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"moduleDetection": "force",
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"strict": true,
|
|
14
|
+
"noUnusedLocals": true,
|
|
15
|
+
"noUnusedParameters": true,
|
|
16
|
+
"erasableSyntaxOnly": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true
|
|
18
|
+
},
|
|
19
|
+
"include": ["vite.config.ts"]
|
|
20
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite 8 SPA for the Notes starter.
|
|
3
|
+
*
|
|
4
|
+
* `index.html` is the documented entry (not backend-integration HTML).
|
|
5
|
+
* The oke app stays on :6530. `server.proxy` keeps `createClient("")`
|
|
6
|
+
* same-origin — leave CORS at Vite's localhost default. `preview.proxy` inherits
|
|
7
|
+
* this map. Do not proxy `/` — `main.root` is `GET /` and would steal the SPA.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import react from "@vitejs/plugin-react";
|
|
11
|
+
import { dirname, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { defineConfig, type ProxyOptions } from "vite";
|
|
14
|
+
|
|
15
|
+
const root = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
/** Local oke app (`oke dev`). */
|
|
18
|
+
const APP_ORIGIN = "http://127.0.0.1:6530";
|
|
19
|
+
|
|
20
|
+
const proxy: Record<string, ProxyOptions> = {
|
|
21
|
+
"/health": { target: APP_ORIGIN, changeOrigin: true },
|
|
22
|
+
"/notes": { target: APP_ORIGIN, changeOrigin: true },
|
|
23
|
+
"/_oke": { target: APP_ORIGIN, changeOrigin: true },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default defineConfig({
|
|
27
|
+
root,
|
|
28
|
+
plugins: [react()],
|
|
29
|
+
// Load `VITE_*` from the app root (same place as `.env.example`).
|
|
30
|
+
envDir: resolve(root, ".."),
|
|
31
|
+
server: { proxy },
|
|
32
|
+
});
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# Environment template — create-oke copies this to `.env.local` when you scaffold.
|
|
2
2
|
#
|
|
3
3
|
# Resolution order (first hit wins):
|
|
4
|
-
# process.env → .env.local →
|
|
4
|
+
# vault driver → process.env → .env.local → dev fallback
|
|
5
5
|
#
|
|
6
6
|
# --- Vault (app secrets) ---
|
|
7
7
|
# Declare secrets in src/vault.ts with `vault.secret(...)`, then uncomment lines here.
|
|
8
8
|
#
|
|
9
9
|
# ── vault — built-in encrypted-at-rest store ────────────────
|
|
10
10
|
# `drivers.vault` is "vault": secrets live encrypted in Postgres, no extra service.
|
|
11
|
-
# Nothing below is required to start —
|
|
11
|
+
# Nothing below is required to start — a value already in the vault backend wins
|
|
12
|
+
# over leftover process.env / dotenv pins.
|
|
12
13
|
#
|
|
13
14
|
# oke vault init # creates the store, prints the master key ONCE
|
|
14
15
|
# export OKE_VAULT_MASTER_KEY=… # every later command / boot unseals with this
|
|
@@ -19,20 +20,27 @@
|
|
|
19
20
|
# secret manager at boot. Committing it defeats encryption at rest.
|
|
20
21
|
# OKE_VAULT_MASTER_KEY=
|
|
21
22
|
#
|
|
22
|
-
# For `drivers.vault: "managed"` instead, point at
|
|
23
|
+
# For `drivers.vault: "managed"` instead, point at an official provider:
|
|
24
|
+
# aws-secrets-manager · azure-key-vault · gcp-secret-manager · doppler · 1password
|
|
23
25
|
# OKE_VAULT_PROVIDER=aws-secrets-manager
|
|
24
26
|
# OKE_VAULT_REGION=us-east-1
|
|
25
|
-
#
|
|
27
|
+
# Scope: AWS/Azure prefix, GCP project[/prefix], Doppler project/config, 1Password vault
|
|
26
28
|
# OKE_VAULT_MOUNT=oke/prod/
|
|
29
|
+
# Azure Key Vault URI / 1Password Connect host / optional Doppler origin:
|
|
30
|
+
# OKE_VAULT_URL=
|
|
31
|
+
# Doppler token / 1Password Connect token:
|
|
32
|
+
# OKE_VAULT_TOKEN=
|
|
27
33
|
#
|
|
28
34
|
# --- Infrastructure ---
|
|
29
|
-
# Same keys
|
|
35
|
+
# Same keys `oke dev --docker` merges into `.env.local`.
|
|
30
36
|
# Uncomment to override compose credentials for a host-managed service.
|
|
31
37
|
# Host ports under docker mode are unique per project — do not assume defaults.
|
|
32
38
|
|
|
33
39
|
# ── App / Console ───────────────────────────────────────────
|
|
34
40
|
# PORT=6530
|
|
35
41
|
# OKE_APP_URL=http://127.0.0.1:6530
|
|
42
|
+
# Vite web (`bun run web`): empty = same-origin proxy to oke dev
|
|
43
|
+
# VITE_API_URL=
|
|
36
44
|
# OKE_CONSOLE_SECRET=
|
|
37
45
|
# MAINTENANCE_MODE=0
|
|
38
46
|
# APP_WEBHOOK_SECRET=dev-webhook-secret-change-me
|
|
@@ -7,6 +7,7 @@ product — every Flow and table here is real wiring you keep or replace.
|
|
|
7
7
|
```bash
|
|
8
8
|
bun install
|
|
9
9
|
oke dev # auto db push; asks once whether to seed
|
|
10
|
+
bun run web # Vite SPA — proxies /notes · /health to the app
|
|
10
11
|
```
|
|
11
12
|
|
|
12
13
|
| Surface | URL |
|
|
@@ -14,6 +15,7 @@ oke dev # auto db push; asks once whether to seed
|
|
|
14
15
|
| Backend | http://127.0.0.1:6530 |
|
|
15
16
|
| Console | http://127.0.0.1:6533 |
|
|
16
17
|
| MCP | http://127.0.0.1:6535 |
|
|
18
|
+
| Web | `bun run web` (Vite; API via proxy) |
|
|
17
19
|
|
|
18
20
|
## Try it
|
|
19
21
|
|
|
@@ -48,6 +50,7 @@ never anonymous `flow_1`.
|
|
|
48
50
|
| `src/db/migrations/` | versioned drizzle migrations |
|
|
49
51
|
| `src/flows/notes/` | list · create · get · archive · onCreated |
|
|
50
52
|
| `src/flows/main/` | root · health |
|
|
53
|
+
| `web/` | Vite 8 SPA — `createClient` + proxy to the app |
|
|
51
54
|
| `oke.config.ts` | local env · docker postgres (+ redis/s3/smtp); vault built-in |
|
|
52
55
|
| `.github/workflows/ci.yml` | `bun run typecheck` + `bun test` on push/PR |
|
|
53
56
|
|
|
@@ -22,12 +22,13 @@ export default defineConfig({
|
|
|
22
22
|
store: {
|
|
23
23
|
sql: "postgres:18-alpine",
|
|
24
24
|
kv: "redis:8-alpine",
|
|
25
|
-
files: "rustfs/rustfs:1.0.0-
|
|
25
|
+
files: "rustfs/rustfs:1.0.0-rc.2",
|
|
26
26
|
},
|
|
27
27
|
channel: {
|
|
28
|
-
email: "axllent/mailpit:v1.
|
|
28
|
+
email: "axllent/mailpit:v1.30.7",
|
|
29
29
|
},
|
|
30
|
-
// pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.
|
|
30
|
+
// pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.53", // create-oke wizard / --pgdog
|
|
31
|
+
// proxy: "caddy:2-alpine", // or traefik:v3.7 / nginx:1.31-alpine
|
|
31
32
|
},
|
|
32
33
|
i18n: { locales: ["en"], default: "en" },
|
|
33
34
|
});
|
|
@@ -5,19 +5,30 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"okengine": "file:../../../..",
|
|
8
|
+
"@duckdb/node-api": "^1.5.5-r.2",
|
|
8
9
|
"drizzle-orm": "1.0.0-rc.4",
|
|
10
|
+
"react": "^19.2.8",
|
|
11
|
+
"react-dom": "^19.2.8",
|
|
9
12
|
"zod": "^4.4.3"
|
|
10
13
|
},
|
|
11
14
|
"devDependencies": {
|
|
12
15
|
"@electric-sql/pglite": "^0.5.4",
|
|
13
16
|
"@electric-sql/pglite-pgvector": "^0.0.5",
|
|
14
17
|
"@types/bun": "latest",
|
|
18
|
+
"@types/react": "^19.2.18",
|
|
19
|
+
"@types/react-dom": "^19.2.4",
|
|
20
|
+
"@vitejs/plugin-react": "^6.0.5",
|
|
15
21
|
"drizzle-kit": "1.0.0-rc.4",
|
|
16
|
-
"typescript": "^7.0.2"
|
|
22
|
+
"typescript": "^7.0.2",
|
|
23
|
+
"vite": "^8.2.0"
|
|
17
24
|
},
|
|
25
|
+
"trustedDependencies": ["@duckdb/node-api"],
|
|
18
26
|
"scripts": {
|
|
19
|
-
"typecheck": "tsc --noEmit",
|
|
27
|
+
"typecheck": "tsc --noEmit && tsc -b -p web/tsconfig.json",
|
|
20
28
|
"test": "oke test",
|
|
21
|
-
"dev": "oke dev"
|
|
29
|
+
"dev": "oke dev",
|
|
30
|
+
"web": "bunx --bun vite --config web/vite.config.ts",
|
|
31
|
+
"web:build": "tsc -b -p web/tsconfig.json && bunx --bun vite build --config web/vite.config.ts",
|
|
32
|
+
"web:preview": "bunx --bun vite preview --config web/vite.config.ts"
|
|
22
33
|
}
|
|
23
34
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import "@/core";
|
|
2
|
+
import { notesMutate } from "@/core";
|
|
2
3
|
|
|
3
4
|
import { oke } from "okengine/http";
|
|
4
5
|
import * as routes from "@/flows/generated";
|
|
5
6
|
|
|
6
|
-
export const app = oke({ name: "notes" }).adopt(routes);
|
|
7
|
+
export const app = oke({ name: "notes", gate: { policies: [notesMutate] } }).adopt(routes);
|
|
7
8
|
|
|
8
9
|
export type App = typeof app;
|
|
@@ -26,10 +26,22 @@ export const db = store.sql("app", { schema });
|
|
|
26
26
|
* export const notesWrite = gate.policy("notes:write", ({ auth }) =>
|
|
27
27
|
* auth.scopes.has("notes:write"),
|
|
28
28
|
* );
|
|
29
|
+
* export const notesMutate = gate.all(notesWrite, notesWriteRate);
|
|
29
30
|
* ```
|
|
30
31
|
*/
|
|
31
32
|
export const notesWrite = gate.policy("notes:write", () => true);
|
|
32
33
|
|
|
34
|
+
/** Note write throttle. */
|
|
35
|
+
export const notesWriteRate = gate.rate({
|
|
36
|
+
max: 60,
|
|
37
|
+
per: "1m",
|
|
38
|
+
keyBy: "ip",
|
|
39
|
+
description: "Note write throttle",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Reuse on every notes mutate route. */
|
|
43
|
+
export const notesMutate = gate.all(notesWrite, notesWriteRate);
|
|
44
|
+
|
|
33
45
|
// --- Vault -------------------------------------------------------------------
|
|
34
46
|
|
|
35
47
|
/** HMAC secret for outbound note webhooks (`fx.vault.get` on create). */
|
|
@@ -50,6 +50,8 @@ async function sampleNotes(fx: Fx) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export default defineSeed({
|
|
53
|
+
name: "notes",
|
|
54
|
+
description: "Welcome + sample notes",
|
|
53
55
|
essential: welcomeNote,
|
|
54
56
|
dev: sampleNotes,
|
|
55
57
|
// prod: async (fx) => { /* real production-only data, e.g. register a webhook */ },
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { on, flow, http
|
|
1
|
+
import { on, flow, http } from "okengine";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
|
-
/** First-run welcome — visit :6530/ after `oke dev
|
|
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
|
|
6
|
+
http.get("/").gate.public,
|
|
7
7
|
flow("main.root", {
|
|
8
8
|
out: z.object({
|
|
9
9
|
ok: z.literal(true),
|
|
@@ -22,7 +22,7 @@ export const root = on(
|
|
|
22
22
|
|
|
23
23
|
/** Liveness for probes and `bun test`. */
|
|
24
24
|
export const health = on(
|
|
25
|
-
http.get("/health").gate
|
|
25
|
+
http.get("/health").gate.public,
|
|
26
26
|
flow("main.health", {
|
|
27
27
|
out: z.object({ ok: z.literal(true) }),
|
|
28
28
|
do: () => ({ ok: true as const }),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { on, flow, http,
|
|
1
|
+
import { on, flow, http, fail } from "okengine";
|
|
2
2
|
import { eq, isNull } from "drizzle-orm";
|
|
3
3
|
|
|
4
|
-
import { db, noteCreatedMail, webhookSecret } from "@/core";
|
|
4
|
+
import { db, noteCreatedMail, notesMutate, webhookSecret } from "@/core";
|
|
5
5
|
import { notes } from "@/db/schema.decl";
|
|
6
6
|
import { NoteCreateIn, NoteIdIn, NoteListOut, NoteOut, NotFound } from "./shapes";
|
|
7
7
|
import { noteCreated } from "./signals";
|
|
@@ -11,28 +11,28 @@ import "./signals";
|
|
|
11
11
|
|
|
12
12
|
/** List active (non-archived) notes, newest first. */
|
|
13
13
|
export const list = on(
|
|
14
|
-
http.get("/notes").gate
|
|
14
|
+
http.get("/notes").gate.public,
|
|
15
15
|
flow("notes.list", {
|
|
16
16
|
out: NoteListOut,
|
|
17
|
-
do: async (
|
|
17
|
+
do: async (input, fx) => {
|
|
18
18
|
const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const data = [...rows]
|
|
20
|
+
.sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
|
|
21
|
+
.map((r) => ({
|
|
22
22
|
id: String(r.id),
|
|
23
23
|
title: String(r.title),
|
|
24
24
|
body: String(r.body),
|
|
25
25
|
archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
|
|
26
26
|
createdAt: Number(r.createdAt),
|
|
27
|
-
}))
|
|
28
|
-
|
|
27
|
+
}));
|
|
28
|
+
return fx.json.withQuery(data, input);
|
|
29
29
|
},
|
|
30
30
|
}),
|
|
31
31
|
);
|
|
32
32
|
|
|
33
33
|
/** Create a note, emit `note-created`, touch vault. */
|
|
34
34
|
export const create = on(
|
|
35
|
-
http.post("/notes").gate(
|
|
35
|
+
http.post("/notes").gate(notesMutate),
|
|
36
36
|
flow("notes.create", {
|
|
37
37
|
in: NoteCreateIn,
|
|
38
38
|
out: NoteOut,
|
|
@@ -62,7 +62,7 @@ export const create = on(
|
|
|
62
62
|
|
|
63
63
|
/** Fetch one note by id. */
|
|
64
64
|
export const get = on(
|
|
65
|
-
http.get("/notes/:id").gate
|
|
65
|
+
http.get("/notes/:id").gate.public,
|
|
66
66
|
flow("notes.get", {
|
|
67
67
|
in: NoteIdIn,
|
|
68
68
|
out: NoteOut,
|
|
@@ -83,7 +83,7 @@ export const get = on(
|
|
|
83
83
|
|
|
84
84
|
/** Soft-archive a note. */
|
|
85
85
|
export const archive = on(
|
|
86
|
-
http.post("/notes/:id/archive").gate(
|
|
86
|
+
http.post("/notes/:id/archive").gate(notesMutate),
|
|
87
87
|
flow("notes.archive", {
|
|
88
88
|
in: NoteIdIn,
|
|
89
89
|
out: NoteOut,
|
|
@@ -13,9 +13,8 @@ export const NoteOut = z.object({
|
|
|
13
13
|
createdAt: z.number(),
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
});
|
|
16
|
+
/** List `data` is the item array. Pagination lives in HTTP `meta`. */
|
|
17
|
+
export const NoteListOut = z.array(NoteOut);
|
|
19
18
|
|
|
20
19
|
export const NoteIdIn = z.object({
|
|
21
20
|
id: z.string().min(1),
|
|
@@ -33,7 +33,8 @@ test("notes create → list → archive", async () => {
|
|
|
33
33
|
|
|
34
34
|
const listed = await t.api.notes!.list!({});
|
|
35
35
|
expect(listed.error).toBeNull();
|
|
36
|
-
const notes =
|
|
36
|
+
const notes = listed.data as { id: string }[];
|
|
37
|
+
expect(listed.meta).toMatchObject({ mode: "offset", offset: 0, next: null, prev: null });
|
|
37
38
|
expect(notes.some((n) => n.id === row.id)).toBe(true);
|
|
38
39
|
|
|
39
40
|
const archived = await t.api.notes!.archive!({ id: row.id });
|
|
@@ -41,7 +42,7 @@ test("notes create → list → archive", async () => {
|
|
|
41
42
|
expect((archived.data as { archivedAt: number }).archivedAt).toBeTypeOf("number");
|
|
42
43
|
|
|
43
44
|
const after = await t.api.notes!.list!({});
|
|
44
|
-
const afterNotes =
|
|
45
|
+
const afterNotes = after.data as { id: string }[];
|
|
45
46
|
expect(afterNotes.some((n) => n.id === row.id)).toBe(false);
|
|
46
47
|
});
|
|
47
48
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Notes</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
.page {
|
|
2
|
+
max-width: 40rem;
|
|
3
|
+
margin: 0 auto;
|
|
4
|
+
padding: 2rem 1.25rem 4rem;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.head {
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: baseline;
|
|
10
|
+
justify-content: space-between;
|
|
11
|
+
gap: 1rem;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.head h1 {
|
|
15
|
+
margin: 0;
|
|
16
|
+
font-size: 1.5rem;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.ok,
|
|
20
|
+
.down {
|
|
21
|
+
margin: 0;
|
|
22
|
+
font-size: 0.875rem;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.ok {
|
|
26
|
+
color: #047857;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.down {
|
|
30
|
+
color: #b45309;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.compose {
|
|
34
|
+
display: grid;
|
|
35
|
+
gap: 0.5rem;
|
|
36
|
+
margin: 1.5rem 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.compose input,
|
|
40
|
+
.compose textarea,
|
|
41
|
+
.compose button,
|
|
42
|
+
.list button {
|
|
43
|
+
font: inherit;
|
|
44
|
+
padding: 0.5rem 0.75rem;
|
|
45
|
+
border: 1px solid #cbd5e1;
|
|
46
|
+
border-radius: 0.375rem;
|
|
47
|
+
background: #fff;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.compose button,
|
|
51
|
+
.list button {
|
|
52
|
+
cursor: pointer;
|
|
53
|
+
width: fit-content;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.err {
|
|
57
|
+
color: #b91c1c;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.list {
|
|
61
|
+
list-style: none;
|
|
62
|
+
padding: 0;
|
|
63
|
+
margin: 0;
|
|
64
|
+
display: grid;
|
|
65
|
+
gap: 0.75rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.list li {
|
|
69
|
+
display: flex;
|
|
70
|
+
justify-content: space-between;
|
|
71
|
+
gap: 1rem;
|
|
72
|
+
padding: 0.75rem 0;
|
|
73
|
+
border-top: 1px solid #e2e8f0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.list p {
|
|
77
|
+
margin: 0.25rem 0 0;
|
|
78
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, type FormEvent, type JSX } from "react";
|
|
2
|
+
import { isOk } from "okengine/client";
|
|
3
|
+
import { api, type Note } from "./client.ts";
|
|
4
|
+
import "./App.css";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Notes SPA — health + list + create + archive against the oke app.
|
|
8
|
+
*/
|
|
9
|
+
export function App(): JSX.Element {
|
|
10
|
+
const [health, setHealth] = useState<"unknown" | "ok" | "down">("unknown");
|
|
11
|
+
const [notes, setNotes] = useState<readonly Note[]>([]);
|
|
12
|
+
const [title, setTitle] = useState("");
|
|
13
|
+
const [body, setBody] = useState("");
|
|
14
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
const refresh = useCallback(async () => {
|
|
17
|
+
const live = await api.main.health({});
|
|
18
|
+
setHealth(isOk(live) && live.data.ok ? "ok" : "down");
|
|
19
|
+
const listed = await api.notes.list({});
|
|
20
|
+
if (isOk(listed) && Array.isArray(listed.data)) {
|
|
21
|
+
setNotes(listed.data as readonly Note[]);
|
|
22
|
+
setMessage(null);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
setMessage(listed.error?.code ?? "notes.list failed");
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
void refresh();
|
|
30
|
+
}, [refresh]);
|
|
31
|
+
|
|
32
|
+
async function onCreate(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
const created = await api.notes.create({ title, body });
|
|
35
|
+
if (!isOk(created)) {
|
|
36
|
+
setMessage(created.error.code);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setTitle("");
|
|
40
|
+
setBody("");
|
|
41
|
+
await refresh();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function onArchive(id: string): Promise<void> {
|
|
45
|
+
const archived = await api.notes.archive({ id });
|
|
46
|
+
if (!isOk(archived)) {
|
|
47
|
+
setMessage(archived.error.code);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await refresh();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<main className="page">
|
|
55
|
+
<header className="head">
|
|
56
|
+
<h1>Notes</h1>
|
|
57
|
+
<p className={health === "ok" ? "ok" : "down"}>
|
|
58
|
+
{health === "unknown" ? "checking…" : health === "ok" ? "app up" : "app down — start oke dev"}
|
|
59
|
+
</p>
|
|
60
|
+
</header>
|
|
61
|
+
|
|
62
|
+
<form className="compose" onSubmit={(event) => void onCreate(event)}>
|
|
63
|
+
<input
|
|
64
|
+
name="title"
|
|
65
|
+
placeholder="Title"
|
|
66
|
+
value={title}
|
|
67
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
68
|
+
required
|
|
69
|
+
maxLength={200}
|
|
70
|
+
/>
|
|
71
|
+
<textarea
|
|
72
|
+
name="body"
|
|
73
|
+
placeholder="Body"
|
|
74
|
+
value={body}
|
|
75
|
+
onChange={(event) => setBody(event.target.value)}
|
|
76
|
+
required
|
|
77
|
+
maxLength={10_000}
|
|
78
|
+
rows={4}
|
|
79
|
+
/>
|
|
80
|
+
<button type="submit">Create</button>
|
|
81
|
+
</form>
|
|
82
|
+
|
|
83
|
+
{message ? <p className="err">{message}</p> : null}
|
|
84
|
+
|
|
85
|
+
<ul className="list">
|
|
86
|
+
{notes.map((note) => (
|
|
87
|
+
<li key={note.id}>
|
|
88
|
+
<div>
|
|
89
|
+
<strong>{note.title}</strong>
|
|
90
|
+
<p>{note.body}</p>
|
|
91
|
+
</div>
|
|
92
|
+
<button type="button" onClick={() => void onArchive(note.id)}>
|
|
93
|
+
Archive
|
|
94
|
+
</button>
|
|
95
|
+
</li>
|
|
96
|
+
))}
|
|
97
|
+
</ul>
|
|
98
|
+
</main>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser client — Vite proxies these paths to `oke dev` (:6530).
|
|
3
|
+
*
|
|
4
|
+
* `$routes` is passed at runtime so HTTP triggers use REST, not `/_oke` RPC.
|
|
5
|
+
* Do not import `src/app.ts` here — that graph is Bun/server-only.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createClient } from "okengine/client";
|
|
9
|
+
|
|
10
|
+
/** One note from `notes.list` / `notes.create`. */
|
|
11
|
+
export type Note = {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly body: string;
|
|
15
|
+
readonly archivedAt: number | null;
|
|
16
|
+
readonly createdAt: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const $routes = {
|
|
20
|
+
main: {
|
|
21
|
+
health: { method: "GET", path: "/health" },
|
|
22
|
+
},
|
|
23
|
+
notes: {
|
|
24
|
+
list: { method: "GET", path: "/notes" },
|
|
25
|
+
create: { method: "POST", path: "/notes" },
|
|
26
|
+
get: { method: "GET", path: "/notes/:id" },
|
|
27
|
+
archive: { method: "POST", path: "/notes/:id/archive" },
|
|
28
|
+
},
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
type AppRoutes = { readonly $routes: typeof $routes };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Typed caller for starter Flows. `VITE_API_URL` is empty in dev (proxy).
|
|
35
|
+
*/
|
|
36
|
+
export const api = createClient<AppRoutes>(import.meta.env.VITE_API_URL ?? "", {
|
|
37
|
+
$routes,
|
|
38
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { App } from "./App.tsx";
|
|
4
|
+
import "./index.css";
|
|
5
|
+
|
|
6
|
+
const root = document.getElementById("root");
|
|
7
|
+
if (!root) throw new Error("web: #root missing");
|
|
8
|
+
|
|
9
|
+
createRoot(root).render(
|
|
10
|
+
<StrictMode>
|
|
11
|
+
<App />
|
|
12
|
+
</StrictMode>,
|
|
13
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface ViteTypeOptions {
|
|
2
|
+
// By adding this line, you can make the type of ImportMetaEnv strict
|
|
3
|
+
// to disallow unknown keys.
|
|
4
|
+
// strictImportMetaEnv: unknown
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface ImportMetaEnv {
|
|
8
|
+
/**
|
|
9
|
+
* App origin for `createClient`. Unset / empty = same origin so Vite's
|
|
10
|
+
* `server.proxy` forwards `/notes` · `/health` · `/_oke` to `oke dev`.
|
|
11
|
+
*/
|
|
12
|
+
readonly VITE_API_URL?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ImportMeta {
|
|
16
|
+
readonly env: ImportMetaEnv;
|
|
17
|
+
}
|