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,74 @@
|
|
|
1
|
+
import { defineConfig } from "okengine/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Advanced starter — same Notes domain, docker-ready driver map.
|
|
5
|
+
* Recommended create path seeds `.oke/mode` as docker.
|
|
6
|
+
*/
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
drivers: {
|
|
9
|
+
store: {
|
|
10
|
+
sql: {
|
|
11
|
+
local: "sqlite",
|
|
12
|
+
docker: "postgres",
|
|
13
|
+
test: "memory",
|
|
14
|
+
prod: "postgres",
|
|
15
|
+
},
|
|
16
|
+
kv: {
|
|
17
|
+
local: "memory",
|
|
18
|
+
docker: "redis",
|
|
19
|
+
test: "memory",
|
|
20
|
+
prod: "redis",
|
|
21
|
+
},
|
|
22
|
+
files: {
|
|
23
|
+
local: "fs",
|
|
24
|
+
docker: "s3",
|
|
25
|
+
test: "memory",
|
|
26
|
+
prod: "s3",
|
|
27
|
+
},
|
|
28
|
+
// Opt in via create-oke customize or uncomment:
|
|
29
|
+
// index: {
|
|
30
|
+
// local: "memory",
|
|
31
|
+
// docker: "meilisearch",
|
|
32
|
+
// test: "memory",
|
|
33
|
+
// prod: "meilisearch",
|
|
34
|
+
// },
|
|
35
|
+
},
|
|
36
|
+
signal: {
|
|
37
|
+
local: "memory",
|
|
38
|
+
docker: "redis",
|
|
39
|
+
test: "memory",
|
|
40
|
+
prod: "redis",
|
|
41
|
+
},
|
|
42
|
+
clock: {
|
|
43
|
+
local: "memory",
|
|
44
|
+
docker: "file",
|
|
45
|
+
test: "frozen",
|
|
46
|
+
prod: "file",
|
|
47
|
+
},
|
|
48
|
+
vault: {
|
|
49
|
+
local: "env",
|
|
50
|
+
docker: "openbao",
|
|
51
|
+
test: "memory",
|
|
52
|
+
prod: "openbao",
|
|
53
|
+
},
|
|
54
|
+
channel: {
|
|
55
|
+
email: {
|
|
56
|
+
local: "console",
|
|
57
|
+
docker: "smtp",
|
|
58
|
+
test: "console",
|
|
59
|
+
prod: "smtp",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
// Opt in: create-oke --ai / oke ai setup writes drivers.ai + src/ai.ts
|
|
63
|
+
},
|
|
64
|
+
images: {
|
|
65
|
+
"store.sql": "postgres:18-alpine",
|
|
66
|
+
"store.kv": "redis:8-alpine",
|
|
67
|
+
"store.files": "rustfs/rustfs:1.0.0-beta.11",
|
|
68
|
+
"channel.email": "axllent/mailpit:v1.22.3",
|
|
69
|
+
vault: "openbao/openbao:2.6.1",
|
|
70
|
+
// "store.index": "getmeili/meilisearch:v1.37",
|
|
71
|
+
// ai: "ollama/ollama:latest",
|
|
72
|
+
},
|
|
73
|
+
i18n: { locales: ["en", "ar"], default: "en", dir: { ar: "rtl" } },
|
|
74
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oke/template-advanced",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"okengine": "file:../../../..",
|
|
8
|
+
"drizzle-orm": "^1.0.0-rc.4",
|
|
9
|
+
"zod": "^4.4.3"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"drizzle-kit": "^1.0.0-rc.4"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test",
|
|
16
|
+
"dev": "oke dev"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { db, files } 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, files],
|
|
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,8 @@
|
|
|
1
|
+
import { store } from "okengine";
|
|
2
|
+
import * as schema from "./schema.decl";
|
|
3
|
+
|
|
4
|
+
/** App SQL store — tables from {@link schema}. */
|
|
5
|
+
export const db = store.sql("app", { schema });
|
|
6
|
+
|
|
7
|
+
/** Object storage for note attachments (fs locally · s3 in docker). */
|
|
8
|
+
export const files = store.files("uploads");
|
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
import { on, flow, http, gate } from "okengine";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
|
-
import "./shapes";
|
|
5
|
-
import "./signals";
|
|
6
|
-
|
|
7
4
|
/** First-run welcome — visit :6530/ after `oke dev`. */
|
|
8
5
|
export const root = on(
|
|
9
6
|
http.get("/").gate(gate.public),
|
|
10
7
|
flow({
|
|
8
|
+
name: "main.root",
|
|
9
|
+
unit: "main",
|
|
11
10
|
out: z.object({
|
|
12
11
|
ok: z.literal(true),
|
|
12
|
+
app: z.string(),
|
|
13
|
+
try: z.array(z.string()),
|
|
13
14
|
console: z.string(),
|
|
14
|
-
try: z.string(),
|
|
15
15
|
}),
|
|
16
16
|
do: () => ({
|
|
17
17
|
ok: true as const,
|
|
18
|
+
app: "notes",
|
|
19
|
+
try: ["GET /notes", "POST /notes", "GET /health"],
|
|
18
20
|
console: "http://127.0.0.1:6533",
|
|
19
|
-
try: "/health",
|
|
20
21
|
}),
|
|
21
22
|
}),
|
|
22
23
|
);
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
+
/** Liveness for probes and `bun test`. */
|
|
25
26
|
export const health = on(
|
|
26
27
|
http.get("/health").gate(gate.public),
|
|
27
28
|
flow({
|
|
29
|
+
name: "main.health",
|
|
30
|
+
unit: "main",
|
|
28
31
|
out: z.object({ ok: z.literal(true) }),
|
|
29
32
|
do: () => ({ ok: true as const }),
|
|
30
33
|
}),
|
|
@@ -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,224 @@
|
|
|
1
|
+
import { on, flow, http, every, gate, fail } from "okengine";
|
|
2
|
+
import { eq, isNull } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
import { db, files } from "../../core";
|
|
5
|
+
import { notes } from "../../schema.decl";
|
|
6
|
+
import { webhookSecret } from "../../vault";
|
|
7
|
+
import { noteCreatedMail } from "../../channels";
|
|
8
|
+
import {
|
|
9
|
+
NoteAttachIn,
|
|
10
|
+
NoteAttachOut,
|
|
11
|
+
NoteCreateIn,
|
|
12
|
+
NoteDigestOut,
|
|
13
|
+
NoteIdIn,
|
|
14
|
+
NoteListOut,
|
|
15
|
+
NoteOut,
|
|
16
|
+
NoteSummarizeIn,
|
|
17
|
+
NoteSummarizeOut,
|
|
18
|
+
NotFound,
|
|
19
|
+
} from "./shapes";
|
|
20
|
+
import { noteCreated } from "./signals";
|
|
21
|
+
|
|
22
|
+
import "./shapes";
|
|
23
|
+
import "./signals";
|
|
24
|
+
|
|
25
|
+
/** List active (non-archived) notes, newest first. */
|
|
26
|
+
export const list = on(
|
|
27
|
+
http.get("/notes").gate(gate.public),
|
|
28
|
+
flow({
|
|
29
|
+
name: "notes.list",
|
|
30
|
+
unit: "notes",
|
|
31
|
+
out: NoteListOut,
|
|
32
|
+
effects: { reads: ["sql:app"] },
|
|
33
|
+
do: async (_input, fx) => {
|
|
34
|
+
const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
|
|
35
|
+
const sorted = [...rows].sort((a, b) => Number(b.createdAt) - Number(a.createdAt));
|
|
36
|
+
return {
|
|
37
|
+
notes: sorted.map((r) => ({
|
|
38
|
+
id: String(r.id),
|
|
39
|
+
title: String(r.title),
|
|
40
|
+
body: String(r.body),
|
|
41
|
+
archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
|
|
42
|
+
createdAt: Number(r.createdAt),
|
|
43
|
+
})),
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/** Create a note, emit `note-created`, touch vault. */
|
|
50
|
+
export const create = on(
|
|
51
|
+
http.post("/notes").gate(gate.public),
|
|
52
|
+
flow({
|
|
53
|
+
name: "notes.create",
|
|
54
|
+
unit: "notes",
|
|
55
|
+
in: NoteCreateIn,
|
|
56
|
+
out: NoteOut,
|
|
57
|
+
effects: {
|
|
58
|
+
writes: ["sql:app"],
|
|
59
|
+
emits: ["note-created"],
|
|
60
|
+
secrets: ["APP_WEBHOOK_SECRET"],
|
|
61
|
+
},
|
|
62
|
+
do: async (input, fx) => {
|
|
63
|
+
// Prove Vault is wired (local: env/dev fallback).
|
|
64
|
+
fx.vault(webhookSecret);
|
|
65
|
+
const id = fx.id();
|
|
66
|
+
const createdAt = fx.clock.now();
|
|
67
|
+
await fx.store(db).insert(notes).values({
|
|
68
|
+
id,
|
|
69
|
+
title: input.title,
|
|
70
|
+
body: input.body,
|
|
71
|
+
archivedAt: null,
|
|
72
|
+
createdAt,
|
|
73
|
+
});
|
|
74
|
+
await fx.emit(noteCreated, { id, title: input.title }, { key: id });
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
title: input.title,
|
|
78
|
+
body: input.body,
|
|
79
|
+
archivedAt: null,
|
|
80
|
+
createdAt,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
/** Fetch one note by id. */
|
|
87
|
+
export const get = on(
|
|
88
|
+
http.get("/notes/:id").gate(gate.public),
|
|
89
|
+
flow({
|
|
90
|
+
name: "notes.get",
|
|
91
|
+
unit: "notes",
|
|
92
|
+
in: NoteIdIn,
|
|
93
|
+
out: NoteOut,
|
|
94
|
+
errors: { NotFound },
|
|
95
|
+
effects: { reads: ["sql:app"] },
|
|
96
|
+
do: async (input, fx) => {
|
|
97
|
+
const row = await fx.store(db).findById(notes, input.id);
|
|
98
|
+
if (!row) return fail("NotFound", { id: input.id });
|
|
99
|
+
return {
|
|
100
|
+
id: String(row.id),
|
|
101
|
+
title: String(row.title),
|
|
102
|
+
body: String(row.body),
|
|
103
|
+
archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
|
|
104
|
+
createdAt: Number(row.createdAt),
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/** Soft-archive a note. */
|
|
111
|
+
export const archive = on(
|
|
112
|
+
http.post("/notes/:id/archive").gate(gate.public),
|
|
113
|
+
flow({
|
|
114
|
+
name: "notes.archive",
|
|
115
|
+
unit: "notes",
|
|
116
|
+
in: NoteIdIn,
|
|
117
|
+
out: NoteOut,
|
|
118
|
+
errors: { NotFound },
|
|
119
|
+
effects: { reads: ["sql:app"], writes: ["sql:app"] },
|
|
120
|
+
do: async (input, fx) => {
|
|
121
|
+
const row = await fx.store(db).findById(notes, input.id);
|
|
122
|
+
if (!row) return fail("NotFound", { id: input.id });
|
|
123
|
+
const archivedAt = fx.clock.now();
|
|
124
|
+
await fx
|
|
125
|
+
.store(db)
|
|
126
|
+
.update(notes)
|
|
127
|
+
.set({ archivedAt })
|
|
128
|
+
.where(eq(notes.id, input.id));
|
|
129
|
+
return {
|
|
130
|
+
id: String(row.id),
|
|
131
|
+
title: String(row.title),
|
|
132
|
+
body: String(row.body),
|
|
133
|
+
archivedAt,
|
|
134
|
+
createdAt: Number(row.createdAt),
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
/** On create → send the note-created email template. */
|
|
141
|
+
export const onCreated = on(
|
|
142
|
+
noteCreated,
|
|
143
|
+
flow({
|
|
144
|
+
name: "notes.onCreated",
|
|
145
|
+
unit: "notes",
|
|
146
|
+
effects: { sends: ["note-created"] },
|
|
147
|
+
do: async (payload, fx) => {
|
|
148
|
+
await fx.send(noteCreatedMail, {
|
|
149
|
+
to: "you@localhost",
|
|
150
|
+
data: { id: payload.id, title: payload.title },
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
/** Store a text attachment next to a note (`files:uploads`). */
|
|
157
|
+
export const attach = on(
|
|
158
|
+
http.post("/notes/:id/attach").gate(gate.public),
|
|
159
|
+
flow({
|
|
160
|
+
name: "notes.attach",
|
|
161
|
+
unit: "notes",
|
|
162
|
+
in: NoteAttachIn,
|
|
163
|
+
out: NoteAttachOut,
|
|
164
|
+
errors: { NotFound },
|
|
165
|
+
effects: { reads: ["sql:app"], writes: ["files:uploads"] },
|
|
166
|
+
do: async (input, fx) => {
|
|
167
|
+
const row = await fx.store(db).findById(notes, input.id);
|
|
168
|
+
if (!row) return fail("NotFound", { id: input.id });
|
|
169
|
+
const key = `notes/${input.id}/attachment.txt`;
|
|
170
|
+
await fx.store(files).put(key, input.text);
|
|
171
|
+
return { key, bytes: new TextEncoder().encode(input.text).byteLength };
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
/** Daily count of active notes (frozen under test drivers). */
|
|
177
|
+
export const digest = on(
|
|
178
|
+
every("1d"),
|
|
179
|
+
flow({
|
|
180
|
+
name: "notes.digest",
|
|
181
|
+
unit: "notes",
|
|
182
|
+
out: NoteDigestOut,
|
|
183
|
+
effects: { reads: ["sql:app"] },
|
|
184
|
+
do: async (_input, fx) => {
|
|
185
|
+
const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
|
|
186
|
+
return { active: rows.length, at: fx.clock.now() };
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Summarize a note via AI when configured (`oke ai setup` / create-oke --ai).
|
|
193
|
+
* Without an AI runtime, returns a local fallback excerpt.
|
|
194
|
+
*/
|
|
195
|
+
export const summarize = on(
|
|
196
|
+
http.post("/notes/:id/summarize").gate(gate.public),
|
|
197
|
+
flow({
|
|
198
|
+
name: "notes.summarize",
|
|
199
|
+
unit: "notes",
|
|
200
|
+
in: NoteSummarizeIn,
|
|
201
|
+
out: NoteSummarizeOut,
|
|
202
|
+
errors: { NotFound },
|
|
203
|
+
effects: { reads: ["sql:app"], asks: ["summarize-note"] },
|
|
204
|
+
do: async (input, fx) => {
|
|
205
|
+
const row = await fx.store(db).findById(notes, input.id);
|
|
206
|
+
if (!row) return fail("NotFound", { id: input.id });
|
|
207
|
+
const body = String(row.body);
|
|
208
|
+
try {
|
|
209
|
+
const out = await fx.ask("summarize-note", {
|
|
210
|
+
title: String(row.title),
|
|
211
|
+
body,
|
|
212
|
+
});
|
|
213
|
+
const summary =
|
|
214
|
+
typeof out === "object" && out !== null && "summary" in out
|
|
215
|
+
? String((out as { summary: unknown }).summary)
|
|
216
|
+
: JSON.stringify(out);
|
|
217
|
+
return { id: input.id, summary, via: "ai" as const };
|
|
218
|
+
} catch {
|
|
219
|
+
const summary = body.length > 160 ? `${body.slice(0, 157)}…` : body;
|
|
220
|
+
return { id: input.id, summary, via: "fallback" as const };
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
});
|
|
27
|
+
|
|
28
|
+
export const NoteAttachIn = z.object({
|
|
29
|
+
id: z.string().min(1),
|
|
30
|
+
/** UTF-8 text body stored under `notes/{id}/attachment.txt`. */
|
|
31
|
+
text: z.string().min(1).max(100_000),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const NoteAttachOut = z.object({
|
|
35
|
+
key: z.string(),
|
|
36
|
+
bytes: z.number().int().nonnegative(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const NoteDigestOut = z.object({
|
|
40
|
+
active: z.number().int().nonnegative(),
|
|
41
|
+
at: z.number(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const NoteSummarizeIn = z.object({
|
|
45
|
+
id: z.string().min(1),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const NoteSummarizeOut = z.object({
|
|
49
|
+
id: z.string(),
|
|
50
|
+
summary: z.string(),
|
|
51
|
+
via: z.enum(["ai", "fallback"]),
|
|
52
|
+
});
|
|
@@ -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,42 @@
|
|
|
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 → attach → summarize fallback → archive", async () => {
|
|
22
|
+
const created = await t.api.notes.create({
|
|
23
|
+
title: "Advanced",
|
|
24
|
+
body: "Body long enough to exercise attach and summarize paths in the advanced starter.",
|
|
25
|
+
});
|
|
26
|
+
expect(created.error).toBeNull();
|
|
27
|
+
const id = created.data!.id;
|
|
28
|
+
|
|
29
|
+
await t.signals.drain();
|
|
30
|
+
|
|
31
|
+
const attached = await t.api.notes.attach({ id, text: "attachment body" });
|
|
32
|
+
expect(attached.error).toBeNull();
|
|
33
|
+
expect(attached.data?.key).toBe(`notes/${id}/attachment.txt`);
|
|
34
|
+
|
|
35
|
+
const summary = await t.api.notes.summarize({ id });
|
|
36
|
+
expect(summary.error).toBeNull();
|
|
37
|
+
expect(summary.data?.via).toBe("fallback");
|
|
38
|
+
expect(summary.data?.summary.length).toBeGreaterThan(0);
|
|
39
|
+
|
|
40
|
+
const archived = await t.api.notes.archive({ id });
|
|
41
|
+
expect(archived.error).toBeNull();
|
|
42
|
+
});
|