create-oke 0.10.1 → 0.10.3
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 +1 -1
- package/src/ai-setup/apply.ts +10 -14
- package/src/cli.test.ts +8 -6
- package/src/transform.test.ts +7 -3
- package/src/transform.ts +93 -22
- package/templates/advanced/oke.config.ts +16 -14
- package/templates/advanced/src/app.ts +8 -11
- package/templates/advanced/src/core/store.ts +1 -1
- package/templates/advanced/src/flows/generated.ts +4 -0
- package/templates/advanced/src/flows/main/index.ts +2 -6
- package/templates/advanced/src/flows/notes/index.ts +10 -26
- package/templates/advanced/tsconfig.json +3 -0
- package/templates/standard/oke.config.ts +9 -6
- package/templates/standard/src/app.ts +8 -11
- package/templates/standard/src/core/store.ts +1 -1
- package/templates/standard/src/flows/generated.ts +4 -0
- package/templates/standard/src/flows/main/index.ts +2 -6
- package/templates/standard/src/flows/notes/index.ts +7 -17
- package/templates/standard/tsconfig.json +3 -0
package/package.json
CHANGED
package/src/agents-md.ts
CHANGED
|
@@ -89,7 +89,7 @@ App \`:6530\` · Console \`:6533\` · MCP \`:6535\`.
|
|
|
89
89
|
- ❌ Inventing a ninth “element” or a parallel handler stack beside Flows
|
|
90
90
|
- ✅ New capability = new **driver** on an existing element, or a new Flow
|
|
91
91
|
- ❌ Untyped HTTP handlers that skip \`on\` / \`flow\` / contracts
|
|
92
|
-
- ✅ \`on(http.get("/…"), flow({ in, out, do }))\`
|
|
92
|
+
- ✅ \`on(http.get("/…"), flow(name, { in, out, do }))\`
|
|
93
93
|
|
|
94
94
|
## Learn more
|
|
95
95
|
|
package/src/ai-setup/apply.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { OLLAMA_IMAGE } from "../drivers-catalog.ts";
|
|
8
|
+
import { extractImages, findImagesBlock, replaceImagesBlock } from "../transform.ts";
|
|
8
9
|
|
|
9
10
|
/** Choices applied to the project. */
|
|
10
11
|
export type AiSetupApplyInput = {
|
|
@@ -127,24 +128,19 @@ export function upsertAiDrivers(source: string, driver: string): string {
|
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
/**
|
|
131
|
+
* Set one dotted role's image pin, preserving every other pin (including
|
|
132
|
+
* `store.*` / `channel.*` nesting) via {@link extractImages} /
|
|
133
|
+
* {@link replaceImagesBlock} — a parse/set/render round-trip rather than a
|
|
134
|
+
* single-line regex, so nested sub-objects are never corrupted.
|
|
135
|
+
*
|
|
130
136
|
* @param source - Config source
|
|
131
|
-
* @param key - Image role
|
|
137
|
+
* @param key - Image role (dotted for `store.*` / `channel.*`, flat otherwise)
|
|
132
138
|
* @param image - Image ref
|
|
133
139
|
*/
|
|
134
140
|
export function upsertImage(source: string, key: string, image: string): string {
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
const m = imagesRe.exec(source);
|
|
139
|
-
if (!m) return source;
|
|
140
|
-
const body = m[1]!;
|
|
141
|
-
if (new RegExp(`${keyLit}\\s*:`).test(body) || new RegExp(`"${key}"\\s*:`).test(body)) {
|
|
142
|
-
return source.replace(
|
|
143
|
-
new RegExp(`(["']?${key.replace(".", "\\.")}["']?\\s*:\\s*)"[^"]*"`),
|
|
144
|
-
`$1"${image}"`,
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
return source.replace(imagesRe, `images: {${body}\n${line}\n }`);
|
|
141
|
+
if (!findImagesBlock(source)) return source;
|
|
142
|
+
const images = { ...extractImages(source), [key]: image };
|
|
143
|
+
return replaceImagesBlock(source, images);
|
|
148
144
|
}
|
|
149
145
|
|
|
150
146
|
/** Options for {@link upsertEnv}. */
|
package/src/cli.test.ts
CHANGED
|
@@ -261,8 +261,8 @@ describe("interactive branches", () => {
|
|
|
261
261
|
expect(code).toBe(0);
|
|
262
262
|
expect(readFileSync(join(dir, ".oke", "mode"), "utf8").trim()).toBe("docker");
|
|
263
263
|
const notes = readFileSync(join(dir, "src", "flows", "notes", "index.ts"), "utf8");
|
|
264
|
-
expect(notes).toContain('
|
|
265
|
-
expect(notes).toContain('
|
|
264
|
+
expect(notes).toContain('flow("notes.digest"');
|
|
265
|
+
expect(notes).toContain('flow("notes.attach"');
|
|
266
266
|
} finally {
|
|
267
267
|
rmSync(dir, { recursive: true, force: true });
|
|
268
268
|
}
|
|
@@ -564,8 +564,10 @@ describe("scaffold structure", () => {
|
|
|
564
564
|
const appTs = readFileSync(join(result.targetDir, "src/app.ts"), "utf8");
|
|
565
565
|
expect(appTs).not.toMatch(/Object\.assign/);
|
|
566
566
|
expect(appTs).not.toMatch(/env:\s*["']test["']/);
|
|
567
|
-
|
|
568
|
-
|
|
567
|
+
// stores/secrets/signals/channel.templates auto-register — the
|
|
568
|
+
// minimal `oke({ name: "notes" })` shape carries no explicit arrays.
|
|
569
|
+
expect(appTs).not.toMatch(/stores:\s*\[/);
|
|
570
|
+
expect(appTs).toMatch(/oke\(\{\s*name:\s*["']notes["']\s*\}\)/);
|
|
569
571
|
const pkg = JSON.parse(readFileSync(join(result.targetDir, "package.json"), "utf8")) as {
|
|
570
572
|
name: string;
|
|
571
573
|
dependencies: { okengine: string };
|
|
@@ -610,8 +612,8 @@ describe("scaffold structure", () => {
|
|
|
610
612
|
expect(result.files).toContain(path);
|
|
611
613
|
}
|
|
612
614
|
const notes = readFileSync(join(result.targetDir, "src/flows/notes/index.ts"), "utf8");
|
|
613
|
-
expect(notes).toContain('
|
|
614
|
-
expect(notes).not.toContain('
|
|
615
|
+
expect(notes).toContain('flow("notes.create"');
|
|
616
|
+
expect(notes).not.toContain('flow("notes.digest"');
|
|
615
617
|
const all = result.files
|
|
616
618
|
.filter((f) => f.endsWith(".ts") || f.endsWith(".md"))
|
|
617
619
|
.map((f) => readFileSync(join(result.targetDir, f), "utf8"))
|
package/src/transform.test.ts
CHANGED
|
@@ -21,7 +21,11 @@ function evalConfig(source: string): {
|
|
|
21
21
|
ai?: unknown;
|
|
22
22
|
channel?: { ai?: unknown; email?: unknown };
|
|
23
23
|
};
|
|
24
|
-
images?:
|
|
24
|
+
images?: {
|
|
25
|
+
store?: Record<string, string>;
|
|
26
|
+
channel?: Record<string, string>;
|
|
27
|
+
ai?: string;
|
|
28
|
+
};
|
|
25
29
|
} {
|
|
26
30
|
const body = source
|
|
27
31
|
.replace(/^import\s+[\s\S]*?from\s+["'][^"']+["'];?\s*/m, "")
|
|
@@ -60,7 +64,7 @@ describe("applyCreateAnswers images", () => {
|
|
|
60
64
|
expect(next).not.toMatch(/images:\s*\{[^}]*\blocal:\s*"/s);
|
|
61
65
|
expect(next).not.toMatch(/images:\s*\{[^}]*\bdocker:\s*"/s);
|
|
62
66
|
expect(next).not.toMatch(/images:\s*\{[^}]*:\s*"libsql"/s);
|
|
63
|
-
expect(next).
|
|
67
|
+
expect(next).toMatch(/images:\s*\{\s*store:\s*\{[^}]*\bsql: "postgres:18-alpine"/s);
|
|
64
68
|
});
|
|
65
69
|
|
|
66
70
|
test("index meilisearch pins store.index image without comment leakage", () => {
|
|
@@ -68,7 +72,7 @@ describe("applyCreateAnswers images", () => {
|
|
|
68
72
|
templateConfig(),
|
|
69
73
|
defaultsWithIndex("meilisearch", "meilisearch"),
|
|
70
74
|
);
|
|
71
|
-
expect(next).
|
|
75
|
+
expect(next).toMatch(/images:\s*\{\s*store:\s*\{[^}]*\bindex: "getmeili\/meilisearch:v1.37"/s);
|
|
72
76
|
expect(next).not.toMatch(/images:\s*\{[^}]*\btest:\s*"memory"/s);
|
|
73
77
|
});
|
|
74
78
|
});
|
package/src/transform.ts
CHANGED
|
@@ -298,8 +298,11 @@ ${close}}`;
|
|
|
298
298
|
/** Env-column keys that must never appear under `images`. */
|
|
299
299
|
const IMAGE_ENV_COLUMNS = new Set(["local", "docker", "test", "prod"]);
|
|
300
300
|
|
|
301
|
-
/** Known compose role keys written into `images
|
|
302
|
-
const IMAGE_ROLE_KEY = /^(?:store\.(?:sql|kv|files|index)|channel\.email|vault|ai|pgdog)$/;
|
|
301
|
+
/** Known compose role keys written into `images` (dotted, post-flatten). */
|
|
302
|
+
const IMAGE_ROLE_KEY = /^(?:store\.(?:sql|kv|files|index)|channel\.email|vault|ai|pgdog|proxy)$/;
|
|
303
|
+
|
|
304
|
+
/** `images` sub-object keys that nest role facets (mirrors `drivers` nesting). */
|
|
305
|
+
const IMAGE_NEST_KEYS = ["store", "channel"] as const;
|
|
303
306
|
|
|
304
307
|
/**
|
|
305
308
|
* Keep `images` in sync with chosen docker drivers.
|
|
@@ -374,43 +377,111 @@ function aiImageForDefaults(defaults: CreateDefaults): string {
|
|
|
374
377
|
}
|
|
375
378
|
|
|
376
379
|
/**
|
|
377
|
-
*
|
|
380
|
+
* Locate the `images: { … }` block by brace depth (not the first `}`) so
|
|
381
|
+
* nested `store: { … }` / `channel: { … }` sub-blocks don't close the match
|
|
382
|
+
* early.
|
|
383
|
+
*
|
|
384
|
+
* @param source - Config source
|
|
385
|
+
*/
|
|
386
|
+
export function findImagesBlock(source: string): {
|
|
387
|
+
readonly start: number;
|
|
388
|
+
readonly bodyStart: number;
|
|
389
|
+
readonly bodyEnd: number;
|
|
390
|
+
readonly end: number;
|
|
391
|
+
} | null {
|
|
392
|
+
const m = /images:\s*\{/.exec(source);
|
|
393
|
+
if (!m) return null;
|
|
394
|
+
const start = m.index;
|
|
395
|
+
const openIdx = start + m[0].length - 1;
|
|
396
|
+
let depth = 0;
|
|
397
|
+
for (let i = openIdx; i < source.length; i++) {
|
|
398
|
+
const ch = source[i];
|
|
399
|
+
if (ch === "{") depth++;
|
|
400
|
+
else if (ch === "}") {
|
|
401
|
+
depth--;
|
|
402
|
+
if (depth === 0) return { start, bodyStart: openIdx + 1, bodyEnd: i, end: i + 1 };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Parse dotted role→image pins from an `images` block, flattening one level
|
|
410
|
+
* of `store` / `channel` nesting (mirrors {@link flattenImagesConfig} in
|
|
411
|
+
* `okengine/config`).
|
|
378
412
|
*
|
|
379
413
|
* Skips `//` comment lines and rejects env-column keys (`local`/`docker`/…).
|
|
380
414
|
*
|
|
381
415
|
* @param source - Config source
|
|
382
416
|
*/
|
|
383
|
-
function extractImages(source: string): Record<string, string> {
|
|
384
|
-
const
|
|
385
|
-
if (!
|
|
417
|
+
export function extractImages(source: string): Record<string, string> {
|
|
418
|
+
const block = findImagesBlock(source);
|
|
419
|
+
if (!block) return {};
|
|
386
420
|
const out: Record<string, string> = {};
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
421
|
+
let context: (typeof IMAGE_NEST_KEYS)[number] | null = null;
|
|
422
|
+
for (const raw of source.slice(block.bodyStart, block.bodyEnd).split("\n")) {
|
|
423
|
+
const line = raw.trim();
|
|
424
|
+
if (!line || line.startsWith("//")) continue;
|
|
425
|
+
const nestOpen = /^(store|channel):\s*\{\s*$/.exec(line);
|
|
426
|
+
if (nestOpen) {
|
|
427
|
+
context = nestOpen[1] as (typeof IMAGE_NEST_KEYS)[number];
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (line === "}," || line === "}") {
|
|
431
|
+
context = null;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const hit = /^["']?([\w.]+)["']?\s*:\s*"([^"]+)"/.exec(line);
|
|
391
435
|
if (!hit) continue;
|
|
392
|
-
const
|
|
393
|
-
if (IMAGE_ENV_COLUMNS.has(
|
|
436
|
+
const rawKey = hit[1]!;
|
|
437
|
+
if (IMAGE_ENV_COLUMNS.has(rawKey)) continue;
|
|
438
|
+
const key = context ? `${context}.${rawKey}` : rawKey;
|
|
439
|
+
if (!IMAGE_ROLE_KEY.test(key)) continue;
|
|
394
440
|
out[key] = hit[2]!;
|
|
395
441
|
}
|
|
396
442
|
return out;
|
|
397
443
|
}
|
|
398
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Render dotted role→image pins back into a nested `images: { … }` literal —
|
|
447
|
+
* `store.*` / `channel.*` under their sub-object, everything else flat.
|
|
448
|
+
*
|
|
449
|
+
* @param images - Dotted role → image
|
|
450
|
+
*/
|
|
451
|
+
function formatImagesBlock(images: Record<string, string>): string {
|
|
452
|
+
const store: Array<[string, string]> = [];
|
|
453
|
+
const channel: Array<[string, string]> = [];
|
|
454
|
+
const flat: Array<[string, string]> = [];
|
|
455
|
+
for (const [key, value] of Object.entries(images)) {
|
|
456
|
+
if (key.startsWith("store.")) store.push([key.slice("store.".length), value]);
|
|
457
|
+
else if (key.startsWith("channel.")) channel.push([key.slice("channel.".length), value]);
|
|
458
|
+
else flat.push([key, value]);
|
|
459
|
+
}
|
|
460
|
+
const lines: string[] = [];
|
|
461
|
+
if (store.length > 0) {
|
|
462
|
+
lines.push(" store: {");
|
|
463
|
+
for (const [k, v] of store) lines.push(` ${k}: "${v}",`);
|
|
464
|
+
lines.push(" },");
|
|
465
|
+
}
|
|
466
|
+
if (channel.length > 0) {
|
|
467
|
+
lines.push(" channel: {");
|
|
468
|
+
for (const [k, v] of channel) lines.push(` ${k}: "${v}",`);
|
|
469
|
+
lines.push(" },");
|
|
470
|
+
}
|
|
471
|
+
for (const [k, v] of flat) lines.push(` ${k}: "${v}",`);
|
|
472
|
+
return `images: {\n${lines.join("\n")}\n }`;
|
|
473
|
+
}
|
|
474
|
+
|
|
399
475
|
/**
|
|
400
476
|
* @param source - Config source
|
|
401
|
-
* @param images -
|
|
477
|
+
* @param images - Dotted role → image
|
|
402
478
|
*/
|
|
403
|
-
function replaceImagesBlock(source: string, images: Record<string, string>): string {
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
return ` ${key}: "${v}",`;
|
|
407
|
-
});
|
|
408
|
-
const block = `images: {\n${lines.join("\n")}\n }`;
|
|
409
|
-
const re = /images:\s*\{[\s\S]*?\n\s*\}/;
|
|
410
|
-
if (!re.test(source)) {
|
|
479
|
+
export function replaceImagesBlock(source: string, images: Record<string, string>): string {
|
|
480
|
+
const block = findImagesBlock(source);
|
|
481
|
+
if (!block) {
|
|
411
482
|
throw new Error("create-oke: oke.config.ts missing images block");
|
|
412
483
|
}
|
|
413
|
-
return source.
|
|
484
|
+
return `${source.slice(0, block.start)}${formatImagesBlock(images)}${source.slice(block.end)}`;
|
|
414
485
|
}
|
|
415
486
|
|
|
416
487
|
/**
|
|
@@ -29,13 +29,12 @@ export default defineConfig({
|
|
|
29
29
|
test: "memory",
|
|
30
30
|
prod: "s3",
|
|
31
31
|
},
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// },
|
|
32
|
+
index: {
|
|
33
|
+
local: "memory",
|
|
34
|
+
docker: "meilisearch",
|
|
35
|
+
test: "memory",
|
|
36
|
+
prod: "meilisearch",
|
|
37
|
+
},
|
|
39
38
|
},
|
|
40
39
|
signal: {
|
|
41
40
|
local: "memory",
|
|
@@ -49,7 +48,6 @@ export default defineConfig({
|
|
|
49
48
|
test: "frozen",
|
|
50
49
|
prod: "postgres",
|
|
51
50
|
},
|
|
52
|
-
// Durable-flow journal (durable: true runs) — shared + leased in docker/prod.
|
|
53
51
|
journal: {
|
|
54
52
|
local: "memory",
|
|
55
53
|
docker: "postgres",
|
|
@@ -73,13 +71,17 @@ export default defineConfig({
|
|
|
73
71
|
// Opt in: create-oke --ai / oke ai setup writes drivers.ai + src/core/ai.ts
|
|
74
72
|
},
|
|
75
73
|
images: {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
store: {
|
|
75
|
+
sql: "postgres:18-alpine",
|
|
76
|
+
kv: "redis:8-alpine",
|
|
77
|
+
files: "rustfs/rustfs:1.0.0-beta.11",
|
|
78
|
+
// index: "getmeili/meilisearch:v1.37",
|
|
79
|
+
},
|
|
80
|
+
channel: {
|
|
81
|
+
email: "axllent/mailpit:v1.22.3",
|
|
82
|
+
},
|
|
81
83
|
vault: "openbao/openbao:2.6.1",
|
|
82
|
-
|
|
84
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
|
|
83
85
|
// ai: "ghcr.io/ggml-org/llama.cpp:server-b10290", // or ollama/ollama:0.32.6
|
|
84
86
|
},
|
|
85
87
|
i18n: { locales: ["en", "ar"], default: "en", dir: { ar: "rtl" } },
|
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import "./core";
|
|
2
2
|
import "./locales/en";
|
|
3
3
|
import "./locales/ar";
|
|
4
4
|
|
|
5
5
|
import { oke } from "okengine";
|
|
6
|
-
import * as
|
|
7
|
-
import * as notes from "./flows/notes";
|
|
8
|
-
import { noteCreated } from "./flows/notes/signals";
|
|
6
|
+
import * as routes from "./flows/generated";
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}).adopt({ main, notes });
|
|
8
|
+
// stores (store.sql/store.files) · secrets (vault.secret) · signals (signal())
|
|
9
|
+
// · channel templates (channel.<medium>().template()) auto-register from
|
|
10
|
+
// `./core` (and `./flows/notes/signals`) — no explicit arrays needed.
|
|
11
|
+
// `./flows/generated` is a real file, regenerated by `oke dev` / `oke build`
|
|
12
|
+
// from every `src/flows/<unit>/index.ts` folder — never edit it by hand.
|
|
13
|
+
export const app = oke({ name: "notes" }).adopt(routes);
|
|
17
14
|
|
|
18
15
|
export type App = typeof app;
|
|
@@ -4,9 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
/** First-run welcome — visit :6530/ after `oke dev`. */
|
|
5
5
|
export const root = on(
|
|
6
6
|
http.get("/").gate(gate.public),
|
|
7
|
-
flow({
|
|
8
|
-
name: "main.root",
|
|
9
|
-
unit: "main",
|
|
7
|
+
flow("main.root", {
|
|
10
8
|
out: z.object({
|
|
11
9
|
ok: z.literal(true),
|
|
12
10
|
app: z.string(),
|
|
@@ -25,9 +23,7 @@ export const root = on(
|
|
|
25
23
|
/** Liveness for probes and `bun test`. */
|
|
26
24
|
export const health = on(
|
|
27
25
|
http.get("/health").gate(gate.public),
|
|
28
|
-
flow({
|
|
29
|
-
name: "main.health",
|
|
30
|
-
unit: "main",
|
|
26
|
+
flow("main.health", {
|
|
31
27
|
out: z.object({ ok: z.literal(true) }),
|
|
32
28
|
do: () => ({ ok: true as const }),
|
|
33
29
|
}),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { on, flow, http, every, gate, fail } from "okengine";
|
|
2
2
|
import { eq, isNull } from "drizzle-orm";
|
|
3
3
|
|
|
4
|
-
import { db, files, noteCreatedMail, webhookSecret } from "
|
|
5
|
-
import { notes } from "
|
|
4
|
+
import { db, files, noteCreatedMail, webhookSecret } from "@/core";
|
|
5
|
+
import { notes } from "@/db/schema.decl";
|
|
6
6
|
import {
|
|
7
7
|
NoteAttachIn,
|
|
8
8
|
NoteAttachOut,
|
|
@@ -23,9 +23,7 @@ import "./signals";
|
|
|
23
23
|
/** List active (non-archived) notes, newest first. */
|
|
24
24
|
export const list = on(
|
|
25
25
|
http.get("/notes").gate(gate.public),
|
|
26
|
-
flow({
|
|
27
|
-
name: "notes.list",
|
|
28
|
-
unit: "notes",
|
|
26
|
+
flow("notes.list", {
|
|
29
27
|
out: NoteListOut,
|
|
30
28
|
effects: { reads: ["sql:app"] },
|
|
31
29
|
do: async (_input, fx) => {
|
|
@@ -47,9 +45,7 @@ export const list = on(
|
|
|
47
45
|
/** Create a note, emit `note-created`, touch vault. */
|
|
48
46
|
export const create = on(
|
|
49
47
|
http.post("/notes").gate(gate.public),
|
|
50
|
-
flow({
|
|
51
|
-
name: "notes.create",
|
|
52
|
-
unit: "notes",
|
|
48
|
+
flow("notes.create", {
|
|
53
49
|
in: NoteCreateIn,
|
|
54
50
|
out: NoteOut,
|
|
55
51
|
effects: {
|
|
@@ -84,9 +80,7 @@ export const create = on(
|
|
|
84
80
|
/** Fetch one note by id. */
|
|
85
81
|
export const get = on(
|
|
86
82
|
http.get("/notes/:id").gate(gate.public),
|
|
87
|
-
flow({
|
|
88
|
-
name: "notes.get",
|
|
89
|
-
unit: "notes",
|
|
83
|
+
flow("notes.get", {
|
|
90
84
|
in: NoteIdIn,
|
|
91
85
|
out: NoteOut,
|
|
92
86
|
errors: { NotFound },
|
|
@@ -108,9 +102,7 @@ export const get = on(
|
|
|
108
102
|
/** Soft-archive a note. */
|
|
109
103
|
export const archive = on(
|
|
110
104
|
http.post("/notes/:id/archive").gate(gate.public),
|
|
111
|
-
flow({
|
|
112
|
-
name: "notes.archive",
|
|
113
|
-
unit: "notes",
|
|
105
|
+
flow("notes.archive", {
|
|
114
106
|
in: NoteIdIn,
|
|
115
107
|
out: NoteOut,
|
|
116
108
|
errors: { NotFound },
|
|
@@ -138,9 +130,7 @@ export const archive = on(
|
|
|
138
130
|
/** On create → send the note-created email template. */
|
|
139
131
|
export const onCreated = on(
|
|
140
132
|
noteCreated,
|
|
141
|
-
flow({
|
|
142
|
-
name: "notes.onCreated",
|
|
143
|
-
unit: "notes",
|
|
133
|
+
flow("notes.onCreated", {
|
|
144
134
|
effects: { sends: ["note-created"] },
|
|
145
135
|
do: async (payload, fx) => {
|
|
146
136
|
await fx.send(noteCreatedMail, {
|
|
@@ -154,9 +144,7 @@ export const onCreated = on(
|
|
|
154
144
|
/** Store a text attachment next to a note (`files:uploads`). */
|
|
155
145
|
export const attach = on(
|
|
156
146
|
http.post("/notes/:id/attach").gate(gate.public),
|
|
157
|
-
flow({
|
|
158
|
-
name: "notes.attach",
|
|
159
|
-
unit: "notes",
|
|
147
|
+
flow("notes.attach", {
|
|
160
148
|
in: NoteAttachIn,
|
|
161
149
|
out: NoteAttachOut,
|
|
162
150
|
errors: { NotFound },
|
|
@@ -174,9 +162,7 @@ export const attach = on(
|
|
|
174
162
|
/** Daily count of active notes (frozen under test drivers). */
|
|
175
163
|
export const digest = on(
|
|
176
164
|
every("1d"),
|
|
177
|
-
flow({
|
|
178
|
-
name: "notes.digest",
|
|
179
|
-
unit: "notes",
|
|
165
|
+
flow("notes.digest", {
|
|
180
166
|
out: NoteDigestOut,
|
|
181
167
|
effects: { reads: ["sql:app"] },
|
|
182
168
|
do: async (_input, fx) => {
|
|
@@ -192,9 +178,7 @@ export const digest = on(
|
|
|
192
178
|
*/
|
|
193
179
|
export const summarize = on(
|
|
194
180
|
http.post("/notes/:id/summarize").gate(gate.public),
|
|
195
|
-
flow({
|
|
196
|
-
name: "notes.summarize",
|
|
197
|
-
unit: "notes",
|
|
181
|
+
flow("notes.summarize", {
|
|
198
182
|
in: NoteSummarizeIn,
|
|
199
183
|
out: NoteSummarizeOut,
|
|
200
184
|
errors: { NotFound },
|
|
@@ -42,7 +42,6 @@ export default defineConfig({
|
|
|
42
42
|
test: "frozen",
|
|
43
43
|
prod: "postgres",
|
|
44
44
|
},
|
|
45
|
-
// Durable-flow journal (durable: true runs) — shared + leased in docker/prod.
|
|
46
45
|
journal: {
|
|
47
46
|
local: "memory",
|
|
48
47
|
docker: "postgres",
|
|
@@ -65,12 +64,16 @@ export default defineConfig({
|
|
|
65
64
|
},
|
|
66
65
|
},
|
|
67
66
|
images: {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
store: {
|
|
68
|
+
sql: "postgres:18-alpine",
|
|
69
|
+
kv: "redis:8-alpine",
|
|
70
|
+
files: "rustfs/rustfs:1.0.0-beta.11",
|
|
71
|
+
},
|
|
72
|
+
channel: {
|
|
73
|
+
email: "axllent/mailpit:v1.22.3",
|
|
74
|
+
},
|
|
73
75
|
vault: "openbao/openbao:2.6.1",
|
|
76
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
|
|
74
77
|
},
|
|
75
78
|
i18n: { locales: ["en", "ar"], default: "en", dir: { ar: "rtl" } },
|
|
76
79
|
});
|
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import "./core";
|
|
2
2
|
import "./locales/en";
|
|
3
3
|
import "./locales/ar";
|
|
4
4
|
|
|
5
5
|
import { oke } from "okengine";
|
|
6
|
-
import * as
|
|
7
|
-
import * as notes from "./flows/notes";
|
|
8
|
-
import { noteCreated } from "./flows/notes/signals";
|
|
6
|
+
import * as routes from "./flows/generated";
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}).adopt({ main, notes });
|
|
8
|
+
// stores (store.sql) · secrets (vault.secret) · signals (signal()) · channel
|
|
9
|
+
// templates (channel.<medium>().template()) auto-register from `./core`
|
|
10
|
+
// (and `./flows/notes/signals`) — no explicit arrays needed.
|
|
11
|
+
// `./flows/generated` is a real file, regenerated by `oke dev` / `oke build`
|
|
12
|
+
// from every `src/flows/<unit>/index.ts` folder — never edit it by hand.
|
|
13
|
+
export const app = oke({ name: "notes" }).adopt(routes);
|
|
17
14
|
|
|
18
15
|
export type App = typeof app;
|
|
@@ -4,9 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
/** First-run welcome — visit :6530/ after `oke dev`. */
|
|
5
5
|
export const root = on(
|
|
6
6
|
http.get("/").gate(gate.public),
|
|
7
|
-
flow({
|
|
8
|
-
name: "main.root",
|
|
9
|
-
unit: "main",
|
|
7
|
+
flow("main.root", {
|
|
10
8
|
out: z.object({
|
|
11
9
|
ok: z.literal(true),
|
|
12
10
|
app: z.string(),
|
|
@@ -25,9 +23,7 @@ export const root = on(
|
|
|
25
23
|
/** Liveness for probes and `bun test`. */
|
|
26
24
|
export const health = on(
|
|
27
25
|
http.get("/health").gate(gate.public),
|
|
28
|
-
flow({
|
|
29
|
-
name: "main.health",
|
|
30
|
-
unit: "main",
|
|
26
|
+
flow("main.health", {
|
|
31
27
|
out: z.object({ ok: z.literal(true) }),
|
|
32
28
|
do: () => ({ ok: true as const }),
|
|
33
29
|
}),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { on, flow, http, gate, fail } from "okengine";
|
|
2
2
|
import { eq, isNull } from "drizzle-orm";
|
|
3
3
|
|
|
4
|
-
import { db, noteCreatedMail, webhookSecret } from "
|
|
5
|
-
import { notes } from "
|
|
4
|
+
import { db, noteCreatedMail, webhookSecret } from "@/core";
|
|
5
|
+
import { notes } from "@/db/schema.decl";
|
|
6
6
|
import { NoteCreateIn, NoteIdIn, NoteListOut, NoteOut, NotFound } from "./shapes";
|
|
7
7
|
import { noteCreated } from "./signals";
|
|
8
8
|
|
|
@@ -12,9 +12,7 @@ import "./signals";
|
|
|
12
12
|
/** List active (non-archived) notes, newest first. */
|
|
13
13
|
export const list = on(
|
|
14
14
|
http.get("/notes").gate(gate.public),
|
|
15
|
-
flow({
|
|
16
|
-
name: "notes.list",
|
|
17
|
-
unit: "notes",
|
|
15
|
+
flow("notes.list", {
|
|
18
16
|
out: NoteListOut,
|
|
19
17
|
effects: { reads: ["sql:app"] },
|
|
20
18
|
do: async (_input, fx) => {
|
|
@@ -36,9 +34,7 @@ export const list = on(
|
|
|
36
34
|
/** Create a note, emit `note-created`, touch vault. */
|
|
37
35
|
export const create = on(
|
|
38
36
|
http.post("/notes").gate(gate.public),
|
|
39
|
-
flow({
|
|
40
|
-
name: "notes.create",
|
|
41
|
-
unit: "notes",
|
|
37
|
+
flow("notes.create", {
|
|
42
38
|
in: NoteCreateIn,
|
|
43
39
|
out: NoteOut,
|
|
44
40
|
effects: {
|
|
@@ -73,9 +69,7 @@ export const create = on(
|
|
|
73
69
|
/** Fetch one note by id. */
|
|
74
70
|
export const get = on(
|
|
75
71
|
http.get("/notes/:id").gate(gate.public),
|
|
76
|
-
flow({
|
|
77
|
-
name: "notes.get",
|
|
78
|
-
unit: "notes",
|
|
72
|
+
flow("notes.get", {
|
|
79
73
|
in: NoteIdIn,
|
|
80
74
|
out: NoteOut,
|
|
81
75
|
errors: { NotFound },
|
|
@@ -97,9 +91,7 @@ export const get = on(
|
|
|
97
91
|
/** Soft-archive a note. */
|
|
98
92
|
export const archive = on(
|
|
99
93
|
http.post("/notes/:id/archive").gate(gate.public),
|
|
100
|
-
flow({
|
|
101
|
-
name: "notes.archive",
|
|
102
|
-
unit: "notes",
|
|
94
|
+
flow("notes.archive", {
|
|
103
95
|
in: NoteIdIn,
|
|
104
96
|
out: NoteOut,
|
|
105
97
|
errors: { NotFound },
|
|
@@ -127,9 +119,7 @@ export const archive = on(
|
|
|
127
119
|
/** On create → send the note-created email template. */
|
|
128
120
|
export const onCreated = on(
|
|
129
121
|
noteCreated,
|
|
130
|
-
flow({
|
|
131
|
-
name: "notes.onCreated",
|
|
132
|
-
unit: "notes",
|
|
122
|
+
flow("notes.onCreated", {
|
|
133
123
|
effects: { sends: ["note-created"] },
|
|
134
124
|
do: async (payload, fx) => {
|
|
135
125
|
await fx.send(noteCreatedMail, {
|