create-oke 0.13.0 → 0.15.2
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 +2 -2
- package/src/agents-md.ts +1 -1
- package/src/ai-setup/apply.ts +32 -1
- package/src/cli.test.ts +14 -9
- package/src/local-okengine.test.ts +2 -2
- package/src/local-okengine.ts +1 -3
- package/src/transform.ts +2 -2
- package/templates/advanced/.github/workflows/ci.yml +1 -1
- package/templates/advanced/drizzle.config.ts +2 -1
- package/templates/advanced/package.json +2 -2
- package/templates/advanced/src/app.ts +2 -3
- package/templates/advanced/src/flows/generated.ts +38 -3
- package/templates/advanced/src/flows/main/health.ts +11 -0
- package/templates/advanced/src/flows/main/{index.ts → route.ts} +2 -11
- package/templates/advanced/src/flows/notes/[id]/archive.ts +29 -0
- package/templates/advanced/src/flows/notes/[id]/attach.ts +22 -0
- package/templates/advanced/src/flows/notes/[id]/get.ts +26 -0
- package/templates/advanced/src/flows/notes/[id]/summarize.ts +83 -0
- package/templates/advanced/src/flows/notes/create.ts +35 -0
- package/templates/advanced/src/flows/notes/digest.ts +18 -0
- package/templates/advanced/src/flows/notes/list.ts +27 -0
- package/templates/advanced/src/flows/notes/on-created.ts +17 -0
- package/templates/standard/.github/workflows/ci.yml +1 -1
- package/templates/standard/drizzle.config.ts +2 -1
- package/templates/standard/package.json +2 -2
- package/templates/standard/src/app.ts +2 -3
- package/templates/standard/src/flows/generated.ts +32 -3
- package/templates/standard/src/flows/main/health.ts +11 -0
- package/templates/standard/src/flows/main/{index.ts → route.ts} +2 -11
- package/templates/standard/src/flows/notes/[id]/archive.ts +29 -0
- package/templates/standard/src/flows/notes/[id]/get.ts +26 -0
- package/templates/standard/src/flows/notes/create.ts +35 -0
- package/templates/standard/src/flows/notes/list.ts +27 -0
- package/templates/standard/src/flows/notes/on-created.ts +17 -0
- package/templates/advanced/src/flows/notes/index.ts +0 -241
- package/templates/standard/src/flows/notes/index.ts +0 -122
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-oke",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"description": "Scaffold an okengine app — bunx create-oke@latest <name>",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,6 @@
|
|
|
32
32
|
"typescript": "^7.0.2"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
|
-
"bun": ">=1.
|
|
35
|
+
"bun": ">=1.4.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/agents-md.ts
CHANGED
|
@@ -90,7 +90,7 @@ App \`:6530\` · Console \`:6533\` · MCP \`:6535\`. Vite web is \`bun run web\`
|
|
|
90
90
|
- ❌ Inventing a ninth “element” or a parallel handler stack beside Flows
|
|
91
91
|
- ✅ New capability = new **driver** on an existing element, or a new Flow
|
|
92
92
|
- ❌ Untyped HTTP handlers that skip \`on\` / \`flow\` / contracts
|
|
93
|
-
- ✅ \`on(http.get(
|
|
93
|
+
- ✅ \`on(http.get(), flow({ in, out, do }))\` — or \`http.get("/…")\` / \`flow("unit.export", {…})\` when the folder is not the URL
|
|
94
94
|
- ❌ Returning \`{ items, count }\` from a list \`do\` (nests the pager inside \`data\`)
|
|
95
95
|
- ✅ \`out: z.array(Item)\` + \`fx.json.withQuery(rows, input)\` — or any other \`out\` you declare
|
|
96
96
|
|
package/src/ai-setup/apply.ts
CHANGED
|
@@ -241,7 +241,9 @@ export function renderAiTs(input: AiSetupApplyInput): string {
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
/**
|
|
244
|
-
* Write AI model declarations into `src/core.ts`
|
|
244
|
+
* Write AI model declarations into `src/core/ai.ts` when that split exists
|
|
245
|
+
* (so a thin `src/core.ts` barrel stays a re-export), else `src/core.ts`,
|
|
246
|
+
* else legacy `src/core/index.ts` + sidecar.
|
|
245
247
|
*
|
|
246
248
|
* @param cwd - Project root
|
|
247
249
|
* @param input - Setup choices
|
|
@@ -249,9 +251,25 @@ export function renderAiTs(input: AiSetupApplyInput): string {
|
|
|
249
251
|
*/
|
|
250
252
|
function writeAiModels(cwd: string, input: AiSetupApplyInput): string {
|
|
251
253
|
const rendered = renderAiTs(input);
|
|
254
|
+
const coreAiPath = join(cwd, "src", "core", "ai.ts");
|
|
252
255
|
const coreTsPath = join(cwd, "src", "core.ts");
|
|
253
256
|
const legacyIndex = join(cwd, "src", "core", "index.ts");
|
|
254
257
|
|
|
258
|
+
if (existsSync(coreAiPath)) {
|
|
259
|
+
const existing = readFileSync(coreAiPath, "utf8");
|
|
260
|
+
if (hasAiModels(existing)) {
|
|
261
|
+
const withPrompt = ensureSummarizeNotePrompt(existing);
|
|
262
|
+
if (withPrompt !== existing) {
|
|
263
|
+
writeFileSync(coreAiPath, withPrompt, "utf8");
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
writeFileSync(coreAiPath, mergeAiIntoCore(existing, rendered), "utf8");
|
|
267
|
+
}
|
|
268
|
+
ensureCoreBarrelExportsAi(cwd);
|
|
269
|
+
ensureCoreImported(cwd);
|
|
270
|
+
return coreAiPath;
|
|
271
|
+
}
|
|
272
|
+
|
|
255
273
|
// Folder layout still in the wild — keep writing a sidecar.
|
|
256
274
|
if (!existsSync(coreTsPath) && existsSync(legacyIndex)) {
|
|
257
275
|
const aiTsPath = join(cwd, "src", "core", "ai.ts");
|
|
@@ -361,6 +379,19 @@ export function ensureNamedOkengineImport(source: string, name: string): string
|
|
|
361
379
|
return source.replace(re, `import { ${sorted.join(", ")} } from "okengine";`);
|
|
362
380
|
}
|
|
363
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Keep a `src/core.ts` barrel re-exporting `./core/ai.ts` after a split write.
|
|
384
|
+
*
|
|
385
|
+
* @param cwd - Project root
|
|
386
|
+
*/
|
|
387
|
+
function ensureCoreBarrelExportsAi(cwd: string): void {
|
|
388
|
+
const coreTsPath = join(cwd, "src", "core.ts");
|
|
389
|
+
if (!existsSync(coreTsPath)) return;
|
|
390
|
+
const src = readFileSync(coreTsPath, "utf8");
|
|
391
|
+
if (/from\s+["']\.\/core\/ai/.test(src)) return;
|
|
392
|
+
writeFileSync(coreTsPath, `${src.trimEnd()}\nexport * from "./core/ai.ts";\n`, "utf8");
|
|
393
|
+
}
|
|
394
|
+
|
|
364
395
|
/**
|
|
365
396
|
* Ensure `src/app.ts` loads `@/core` / `./core` so merged AI registers.
|
|
366
397
|
*
|
package/src/cli.test.ts
CHANGED
|
@@ -319,7 +319,7 @@ describe("interactive branches", () => {
|
|
|
319
319
|
expect(config).not.toMatch(/^\s*sql:\s*\{/m);
|
|
320
320
|
expect(existsSync(join(dir, ".oke", "mode"))).toBe(false);
|
|
321
321
|
expect(existsSync(join(dir, "docker", "docker-compose.yml"))).toBe(true);
|
|
322
|
-
expect(existsSync(join(dir, "src", "flows", "notes", "
|
|
322
|
+
expect(existsSync(join(dir, "src", "flows", "notes", "create.ts"))).toBe(true);
|
|
323
323
|
} finally {
|
|
324
324
|
rmSync(dir, { recursive: true, force: true });
|
|
325
325
|
}
|
|
@@ -336,9 +336,12 @@ describe("interactive branches", () => {
|
|
|
336
336
|
});
|
|
337
337
|
expect(code).toBe(0);
|
|
338
338
|
expect(existsSync(join(dir, ".oke", "mode"))).toBe(false);
|
|
339
|
-
|
|
340
|
-
expect(
|
|
341
|
-
|
|
339
|
+
expect(existsSync(join(dir, "src", "flows", "notes", "digest.ts"))).toBe(true);
|
|
340
|
+
expect(existsSync(join(dir, "src", "flows", "notes", "[id]", "attach.ts"))).toBe(true);
|
|
341
|
+
const digest = readFileSync(join(dir, "src", "flows", "notes", "digest.ts"), "utf8");
|
|
342
|
+
expect(digest).toContain('every("1d")');
|
|
343
|
+
const attach = readFileSync(join(dir, "src", "flows", "notes", "[id]", "attach.ts"), "utf8");
|
|
344
|
+
expect(attach).toContain("export const attach");
|
|
342
345
|
expect(readFileSync(join(dir, "oke.config.ts"), "utf8")).toMatch(
|
|
343
346
|
/index:\s*\{\s*test:\s*"memory",\s*prod:\s*"meilisearch"/,
|
|
344
347
|
);
|
|
@@ -668,7 +671,8 @@ describe("scaffold structure", () => {
|
|
|
668
671
|
"src/locales/index.ts",
|
|
669
672
|
"src/flows/main/shapes.ts",
|
|
670
673
|
"src/flows/main/signals.ts",
|
|
671
|
-
"src/flows/notes/
|
|
674
|
+
"src/flows/notes/create.ts",
|
|
675
|
+
"src/flows/notes/[id]/get.ts",
|
|
672
676
|
"src/db/schema.decl.ts",
|
|
673
677
|
"src/db/seed/index.ts",
|
|
674
678
|
"src/app.ts",
|
|
@@ -676,10 +680,11 @@ describe("scaffold structure", () => {
|
|
|
676
680
|
]) {
|
|
677
681
|
expect(result.files).toContain(path);
|
|
678
682
|
}
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
expect(
|
|
682
|
-
expect(
|
|
683
|
+
const create = readFileSync(join(result.targetDir, "src/flows/notes/create.ts"), "utf8");
|
|
684
|
+
const list = readFileSync(join(result.targetDir, "src/flows/notes/list.ts"), "utf8");
|
|
685
|
+
expect(create).toContain("export const create");
|
|
686
|
+
expect(list).toContain("fx.json.withQuery");
|
|
687
|
+
expect(existsSync(join(result.targetDir, "src/flows/notes/digest.ts"))).toBe(false);
|
|
683
688
|
expect(existsSync(join(result.targetDir, "src/locales/ar.ts"))).toBe(false);
|
|
684
689
|
expect(readFileSync(join(result.targetDir, "oke.config.ts"), "utf8")).toContain(
|
|
685
690
|
'locales: ["en"]',
|
|
@@ -37,8 +37,8 @@ describe("materializeLocalOkengineDependency", () => {
|
|
|
37
37
|
expect(pkg.peerDependencies).toBeUndefined();
|
|
38
38
|
expect(pkg.dependencies?.["oxc-parser"]).toBeDefined();
|
|
39
39
|
// Peers folded into dependencies so the out-of-tree stage can resolve them.
|
|
40
|
-
expect(pkg.dependencies?.["drizzle-orm"]).toBe("1.0.0-rc.
|
|
41
|
-
expect(pkg.dependencies?.["drizzle-kit"]).toBe("1.0.0-rc.
|
|
40
|
+
expect(pkg.dependencies?.["drizzle-orm"]).toBe("1.0.0-rc.5-169397b");
|
|
41
|
+
expect(pkg.dependencies?.["drizzle-kit"]).toBe("1.0.0-rc.5-ab785fc");
|
|
42
42
|
expect(pkg.dependencies?.["zod"]).toBeDefined();
|
|
43
43
|
expect(pkg.exports?.["./config"]).toBe("./src/config/index.ts");
|
|
44
44
|
expect(pkg.trustedDependencies).toContain("@duckdb/node-api");
|
package/src/local-okengine.ts
CHANGED
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
* Consumer-facing `file:` package for local / monorepo create-oke.
|
|
3
3
|
*
|
|
4
4
|
* Linking the monorepo root pulls `workspaces` + `devDependencies` into the
|
|
5
|
-
* scaffold (Console UI,
|
|
6
|
-
* `drizzle-orm` fails drizzle-zod’s `>=0.36` peer. Stage a publish-shaped
|
|
7
|
-
* tree instead:
|
|
5
|
+
* scaffold (Console UI, …). Stage a publish-shaped tree instead:
|
|
8
6
|
*
|
|
9
7
|
* 1. Copy `files` (Bun’s `file:` install drops directory symlinks).
|
|
10
8
|
* 2. Install dependencies **in the stage** — Bun keeps `package.json` as a
|
package/src/transform.ts
CHANGED
|
@@ -59,8 +59,8 @@ export type ScaffoldPackageJson = {
|
|
|
59
59
|
* Resolve the `okengine` dependency string written into the scaffolded package.json.
|
|
60
60
|
*
|
|
61
61
|
* - Monorepo / local: staged publish-shaped `file:` package (no workspaces /
|
|
62
|
-
* no monorepo `devDependencies` — those pull
|
|
63
|
-
*
|
|
62
|
+
* no monorepo `devDependencies` — those pull Console UI and other
|
|
63
|
+
* workspace-only packages into the scaffold)
|
|
64
64
|
* - Published create-oke: the version of this package (kept in lockstep with okengine)
|
|
65
65
|
*
|
|
66
66
|
* @param localOkengineRoot - Absolute path when available
|
|
@@ -16,7 +16,8 @@ export default defineConfig({
|
|
|
16
16
|
dialect: "postgresql",
|
|
17
17
|
schema: "./src/db/schema.drizzle.ts",
|
|
18
18
|
out: "./src/db/migrations",
|
|
19
|
-
//
|
|
19
|
+
// Domain tables live in public. Schema `oke` (RLS) and `oke_console` are engine-owned.
|
|
20
|
+
schemaFilter: ["public"],
|
|
20
21
|
tablesFilter: ["!oke_*"],
|
|
21
22
|
dbCredentials: {
|
|
22
23
|
url: process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL!,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"okengine": "file:../../../..",
|
|
8
8
|
"@duckdb/node-api": "^1.5.5-r.2",
|
|
9
|
-
"drizzle-orm": "1.0.0-rc.
|
|
9
|
+
"drizzle-orm": "1.0.0-rc.5-169397b",
|
|
10
10
|
"react": "^19.2.8",
|
|
11
11
|
"react-dom": "^19.2.8",
|
|
12
12
|
"zod": "^4.4.3"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"@types/react": "^19.2.18",
|
|
19
19
|
"@types/react-dom": "^19.2.4",
|
|
20
20
|
"@vitejs/plugin-react": "^6.0.5",
|
|
21
|
-
"drizzle-kit": "1.0.0-rc.
|
|
21
|
+
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
|
22
22
|
"typescript": "^7.0.2",
|
|
23
23
|
"vite": "^8.2.0"
|
|
24
24
|
},
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import "@/core";
|
|
2
|
-
import
|
|
2
|
+
import "@/flows/generated";
|
|
3
3
|
|
|
4
4
|
import { oke } from "okengine/http";
|
|
5
|
-
import * as routes from "@/flows/generated";
|
|
6
5
|
|
|
7
|
-
export const app = oke({ name: "notes"
|
|
6
|
+
export const app = oke({ name: "notes" });
|
|
8
7
|
|
|
9
8
|
export type App = typeof app;
|
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
// AUTO-GENERATED by `oke dev` / `oke build` — do not edit by hand.
|
|
2
|
-
// Regenerated from every `src/flows/<unit
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
// Regenerated from every `src/flows/<unit>/` folder (barrel or file tree).
|
|
3
|
+
import { registerFlowUnits, stampFlowName, stampHttpPath } from "okengine";
|
|
4
|
+
import * as main_health from "./main/health.ts";
|
|
5
|
+
import * as main_route from "./main/route.ts";
|
|
6
|
+
|
|
7
|
+
const main = {
|
|
8
|
+
health: stampHttpPath(stampFlowName(main_health.health, "main.health"), "/health"),
|
|
9
|
+
root: stampHttpPath(stampFlowName(main_route.root, "main.root"), "/"),
|
|
10
|
+
};
|
|
11
|
+
export { main };
|
|
12
|
+
|
|
13
|
+
import * as notes_$id$_archive from "./notes/[id]/archive.ts";
|
|
14
|
+
import * as notes_$id$_attach from "./notes/[id]/attach.ts";
|
|
15
|
+
import * as notes_$id$_get from "./notes/[id]/get.ts";
|
|
16
|
+
import * as notes_$id$_summarize from "./notes/[id]/summarize.ts";
|
|
17
|
+
import * as notes_create from "./notes/create.ts";
|
|
18
|
+
import * as notes_digest from "./notes/digest.ts";
|
|
19
|
+
import * as notes_list from "./notes/list.ts";
|
|
20
|
+
import * as notes_on_created from "./notes/on-created.ts";
|
|
21
|
+
|
|
22
|
+
const notes = {
|
|
23
|
+
archive: stampHttpPath(stampFlowName(notes_$id$_archive.archive, "notes.archive"), "/notes/:id/archive"),
|
|
24
|
+
attach: stampHttpPath(stampFlowName(notes_$id$_attach.attach, "notes.attach"), "/notes/:id/attach"),
|
|
25
|
+
get: stampHttpPath(stampFlowName(notes_$id$_get.get, "notes.get"), "/notes/:id"),
|
|
26
|
+
summarize: stampHttpPath(stampFlowName(notes_$id$_summarize.summarize, "notes.summarize"), "/notes/:id/summarize"),
|
|
27
|
+
create: stampHttpPath(stampFlowName(notes_create.create, "notes.create"), "/notes"),
|
|
28
|
+
digest: stampHttpPath(stampFlowName(notes_digest.digest, "notes.digest"), "/notes/digest"),
|
|
29
|
+
list: stampHttpPath(stampFlowName(notes_list.list, "notes.list"), "/notes"),
|
|
30
|
+
onCreated: stampHttpPath(stampFlowName(notes_on_created.onCreated, "notes.onCreated"), "/notes/on-created"),
|
|
31
|
+
};
|
|
32
|
+
export { notes };
|
|
33
|
+
registerFlowUnits({ main, notes });
|
|
34
|
+
declare module "okengine" {
|
|
35
|
+
interface RegisteredFlowUnits {
|
|
36
|
+
readonly main: typeof main;
|
|
37
|
+
readonly notes: typeof notes;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { on, flow, http } from "okengine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/** Liveness for probes and `bun test`. */
|
|
5
|
+
export const health = on(
|
|
6
|
+
http.get().public(),
|
|
7
|
+
flow({
|
|
8
|
+
out: z.object({ ok: z.literal(true) }),
|
|
9
|
+
do: () => ({ ok: true as const }),
|
|
10
|
+
}),
|
|
11
|
+
);
|
|
@@ -3,8 +3,8 @@ import { z } from "zod";
|
|
|
3
3
|
|
|
4
4
|
/** First-run welcome — visit :6530/ after `oke dev` (browser code block; curl stays JSON). */
|
|
5
5
|
export const root = on(
|
|
6
|
-
http.get(
|
|
7
|
-
flow(
|
|
6
|
+
http.get().public(),
|
|
7
|
+
flow({
|
|
8
8
|
out: z.object({
|
|
9
9
|
ok: z.literal(true),
|
|
10
10
|
app: z.string(),
|
|
@@ -19,12 +19,3 @@ export const root = on(
|
|
|
19
19
|
}),
|
|
20
20
|
}),
|
|
21
21
|
);
|
|
22
|
-
|
|
23
|
-
/** Liveness for probes and `bun test`. */
|
|
24
|
-
export const health = on(
|
|
25
|
-
http.get("/health").gate.public,
|
|
26
|
-
flow("main.health", {
|
|
27
|
-
out: z.object({ ok: z.literal(true) }),
|
|
28
|
-
do: () => ({ ok: true as const }),
|
|
29
|
-
}),
|
|
30
|
-
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { on, flow, http, fail } from "okengine";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
import { db, notesMutate } from "@/core";
|
|
5
|
+
import { notes } from "@/db/schema.decl";
|
|
6
|
+
import { NoteIdIn, NoteOut, NotFound } from "../shapes";
|
|
7
|
+
|
|
8
|
+
/** Soft-archive a note. */
|
|
9
|
+
export const archive = on(
|
|
10
|
+
http.post().gate(notesMutate),
|
|
11
|
+
flow({
|
|
12
|
+
in: NoteIdIn,
|
|
13
|
+
out: NoteOut,
|
|
14
|
+
errors: { NotFound },
|
|
15
|
+
do: async (input, fx) => {
|
|
16
|
+
const row = await fx.store(db).findById(notes, input.id);
|
|
17
|
+
if (!row) return fail("NotFound", { id: input.id });
|
|
18
|
+
const archivedAt = fx.clock.now();
|
|
19
|
+
await fx.store(db).update(notes).set({ archivedAt }).where(eq(notes.id, input.id));
|
|
20
|
+
return {
|
|
21
|
+
id: String(row.id),
|
|
22
|
+
title: String(row.title),
|
|
23
|
+
body: String(row.body),
|
|
24
|
+
archivedAt,
|
|
25
|
+
createdAt: Number(row.createdAt),
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { on, flow, http, fail } from "okengine";
|
|
2
|
+
|
|
3
|
+
import { db, files, notesMutate } from "@/core";
|
|
4
|
+
import { notes } from "@/db/schema.decl";
|
|
5
|
+
import { NoteAttachIn, NoteAttachOut, NotFound } from "../shapes";
|
|
6
|
+
|
|
7
|
+
/** Store a text attachment next to a note (`files:uploads`). */
|
|
8
|
+
export const attach = on(
|
|
9
|
+
http.post().gate(notesMutate),
|
|
10
|
+
flow({
|
|
11
|
+
in: NoteAttachIn,
|
|
12
|
+
out: NoteAttachOut,
|
|
13
|
+
errors: { NotFound },
|
|
14
|
+
do: async (input, fx) => {
|
|
15
|
+
const row = await fx.store(db).findById(notes, input.id);
|
|
16
|
+
if (!row) return fail("NotFound", { id: input.id });
|
|
17
|
+
const key = `notes/${input.id}/attachment.txt`;
|
|
18
|
+
await fx.store(files).put(key, input.text);
|
|
19
|
+
return { key, bytes: new TextEncoder().encode(input.text).byteLength };
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { on, flow, http, fail } from "okengine";
|
|
2
|
+
|
|
3
|
+
import { db } from "@/core";
|
|
4
|
+
import { notes } from "@/db/schema.decl";
|
|
5
|
+
import { NoteIdIn, NoteOut, NotFound } from "../shapes";
|
|
6
|
+
|
|
7
|
+
/** Fetch one note by id. */
|
|
8
|
+
export const get = on(
|
|
9
|
+
http.get().public(),
|
|
10
|
+
flow({
|
|
11
|
+
in: NoteIdIn,
|
|
12
|
+
out: NoteOut,
|
|
13
|
+
errors: { NotFound },
|
|
14
|
+
do: async (input, fx) => {
|
|
15
|
+
const row = await fx.store(db).findById(notes, input.id);
|
|
16
|
+
if (!row) return fail("NotFound", { id: input.id });
|
|
17
|
+
return {
|
|
18
|
+
id: String(row.id),
|
|
19
|
+
title: String(row.title),
|
|
20
|
+
body: String(row.body),
|
|
21
|
+
archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
|
|
22
|
+
createdAt: Number(row.createdAt),
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { on, flow, http, fail } from "okengine";
|
|
2
|
+
|
|
3
|
+
import { db, notesMutate } from "@/core";
|
|
4
|
+
import { notes } from "@/db/schema.decl";
|
|
5
|
+
import { NoteSummarizeIn, NoteSummarizeOut, NotFound, Unavailable } from "../shapes";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pull a usable summary string from an `fx.ask` payload.
|
|
9
|
+
*
|
|
10
|
+
* @param out - Model output object
|
|
11
|
+
*/
|
|
12
|
+
function extractSummary(out: unknown): string {
|
|
13
|
+
if (typeof out === "string") return unwrapSummaryText(out);
|
|
14
|
+
if (!out || typeof out !== "object") return "";
|
|
15
|
+
const record = out as Record<string, unknown>;
|
|
16
|
+
if (typeof record.summary === "string") return record.summary.trim();
|
|
17
|
+
if (typeof record.text === "string") return unwrapSummaryText(record.text);
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Local models sometimes return over-escaped JSON (`{\\"summary\\":...}`).
|
|
23
|
+
* Peel one or two JSON layers, then fall back to the raw text.
|
|
24
|
+
*
|
|
25
|
+
* @param text - Model text payload
|
|
26
|
+
*/
|
|
27
|
+
function unwrapSummaryText(text: string): string {
|
|
28
|
+
let current = text.trim();
|
|
29
|
+
for (let i = 0; i < 2; i++) {
|
|
30
|
+
if (!(current.startsWith("{") || current.startsWith('"'))) break;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(current) as unknown;
|
|
33
|
+
if (typeof parsed === "string") {
|
|
34
|
+
current = parsed.trim();
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (parsed && typeof parsed === "object" && "summary" in parsed) {
|
|
38
|
+
return String((parsed as { summary: unknown }).summary).trim();
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
} catch {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Summarize a note via the prompt's declared recovery chain.
|
|
50
|
+
* Exhausted / failed asks surface as Unavailable — never a body excerpt.
|
|
51
|
+
*/
|
|
52
|
+
export const summarize = on(
|
|
53
|
+
http.post().gate(notesMutate),
|
|
54
|
+
flow({
|
|
55
|
+
in: NoteSummarizeIn,
|
|
56
|
+
out: NoteSummarizeOut,
|
|
57
|
+
errors: { NotFound, Unavailable },
|
|
58
|
+
do: async (input, fx) => {
|
|
59
|
+
const row = await fx.store(db).findById(notes, input.id);
|
|
60
|
+
if (!row) return fail("NotFound", { id: input.id });
|
|
61
|
+
try {
|
|
62
|
+
const out = await fx.ask("summarize-note", {
|
|
63
|
+
instruction:
|
|
64
|
+
'Summarize this note in one or two sentences. Reply with JSON only: {"summary":"..."}',
|
|
65
|
+
title: String(row.title),
|
|
66
|
+
body: String(row.body),
|
|
67
|
+
});
|
|
68
|
+
const summary = extractSummary(out);
|
|
69
|
+
const via = typeof out.via === "string" ? out.via.trim() : "";
|
|
70
|
+
if (!summary || !via) {
|
|
71
|
+
return fail("Unavailable", {
|
|
72
|
+
message: "AI service unavailable. Try again later.",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { id: input.id, summary, via };
|
|
76
|
+
} catch {
|
|
77
|
+
return fail("Unavailable", {
|
|
78
|
+
message: "AI service unavailable. Try again later.",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { on, flow, http } from "okengine";
|
|
2
|
+
|
|
3
|
+
import { db, notesMutate, webhookSecret } from "@/core";
|
|
4
|
+
import { notes } from "@/db/schema.decl";
|
|
5
|
+
import { NoteCreateIn, NoteOut } from "./shapes";
|
|
6
|
+
import { noteCreated } from "./signals";
|
|
7
|
+
|
|
8
|
+
/** Create a note, emit `note-created`, touch vault. */
|
|
9
|
+
export const create = on(
|
|
10
|
+
http.post().gate(notesMutate),
|
|
11
|
+
flow({
|
|
12
|
+
in: NoteCreateIn,
|
|
13
|
+
out: NoteOut,
|
|
14
|
+
do: async (input, fx) => {
|
|
15
|
+
await fx.vault.get(webhookSecret);
|
|
16
|
+
const id = fx.id();
|
|
17
|
+
const createdAt = fx.clock.now();
|
|
18
|
+
await fx.store(db).insert(notes).values({
|
|
19
|
+
id,
|
|
20
|
+
title: input.title,
|
|
21
|
+
body: input.body,
|
|
22
|
+
archivedAt: null,
|
|
23
|
+
createdAt,
|
|
24
|
+
});
|
|
25
|
+
await fx.emit(noteCreated, { id, title: input.title }, { key: id });
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
title: input.title,
|
|
29
|
+
body: input.body,
|
|
30
|
+
archivedAt: null,
|
|
31
|
+
createdAt,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { on, flow, every } from "okengine";
|
|
2
|
+
import { isNull } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
import { db } from "@/core";
|
|
5
|
+
import { notes } from "@/db/schema.decl";
|
|
6
|
+
import { NoteDigestOut } from "./shapes";
|
|
7
|
+
|
|
8
|
+
/** Daily count of active notes (frozen under test drivers). */
|
|
9
|
+
export const digest = on(
|
|
10
|
+
every("1d"),
|
|
11
|
+
flow({
|
|
12
|
+
out: NoteDigestOut,
|
|
13
|
+
do: async (_input, fx) => {
|
|
14
|
+
const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
|
|
15
|
+
return { active: rows.length, at: fx.clock.now() };
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { on, flow, http } from "okengine";
|
|
2
|
+
import { isNull } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
import { db } from "@/core";
|
|
5
|
+
import { notes } from "@/db/schema.decl";
|
|
6
|
+
import { NoteListOut } from "./shapes";
|
|
7
|
+
|
|
8
|
+
/** List active (non-archived) notes, newest first. */
|
|
9
|
+
export const list = on(
|
|
10
|
+
http.get().public(),
|
|
11
|
+
flow({
|
|
12
|
+
out: NoteListOut,
|
|
13
|
+
do: async (input, fx) => {
|
|
14
|
+
const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
|
|
15
|
+
const data = [...rows]
|
|
16
|
+
.sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
|
|
17
|
+
.map((r) => ({
|
|
18
|
+
id: String(r.id),
|
|
19
|
+
title: String(r.title),
|
|
20
|
+
body: String(r.body),
|
|
21
|
+
archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
|
|
22
|
+
createdAt: Number(r.createdAt),
|
|
23
|
+
}));
|
|
24
|
+
return fx.json.withQuery(data, input);
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { on, flow } from "okengine";
|
|
2
|
+
|
|
3
|
+
import { noteCreatedMail } from "@/core";
|
|
4
|
+
import { noteCreated } from "./signals";
|
|
5
|
+
|
|
6
|
+
/** On create → send the note-created email template. */
|
|
7
|
+
export const onCreated = on(
|
|
8
|
+
noteCreated,
|
|
9
|
+
flow({
|
|
10
|
+
do: async (payload, fx) => {
|
|
11
|
+
await fx.send(noteCreatedMail, {
|
|
12
|
+
to: "you@localhost",
|
|
13
|
+
data: { id: payload.id, title: payload.title },
|
|
14
|
+
});
|
|
15
|
+
},
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
@@ -16,7 +16,8 @@ export default defineConfig({
|
|
|
16
16
|
dialect: "postgresql",
|
|
17
17
|
schema: "./src/db/schema.drizzle.ts",
|
|
18
18
|
out: "./src/db/migrations",
|
|
19
|
-
//
|
|
19
|
+
// Domain tables live in public. Schema `oke` (RLS) and `oke_console` are engine-owned.
|
|
20
|
+
schemaFilter: ["public"],
|
|
20
21
|
tablesFilter: ["!oke_*"],
|
|
21
22
|
dbCredentials: {
|
|
22
23
|
url: process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL!,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"okengine": "file:../../../..",
|
|
8
8
|
"@duckdb/node-api": "^1.5.5-r.2",
|
|
9
|
-
"drizzle-orm": "1.0.0-rc.
|
|
9
|
+
"drizzle-orm": "1.0.0-rc.5-169397b",
|
|
10
10
|
"react": "^19.2.8",
|
|
11
11
|
"react-dom": "^19.2.8",
|
|
12
12
|
"zod": "^4.4.3"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"@types/react": "^19.2.18",
|
|
19
19
|
"@types/react-dom": "^19.2.4",
|
|
20
20
|
"@vitejs/plugin-react": "^6.0.5",
|
|
21
|
-
"drizzle-kit": "1.0.0-rc.
|
|
21
|
+
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
|
22
22
|
"typescript": "^7.0.2",
|
|
23
23
|
"vite": "^8.2.0"
|
|
24
24
|
},
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import "@/core";
|
|
2
|
-
import
|
|
2
|
+
import "@/flows/generated";
|
|
3
3
|
|
|
4
4
|
import { oke } from "okengine/http";
|
|
5
|
-
import * as routes from "@/flows/generated";
|
|
6
5
|
|
|
7
|
-
export const app = oke({ name: "notes"
|
|
6
|
+
export const app = oke({ name: "notes" });
|
|
8
7
|
|
|
9
8
|
export type App = typeof app;
|
|
@@ -1,4 +1,33 @@
|
|
|
1
1
|
// AUTO-GENERATED by `oke dev` / `oke build` — do not edit by hand.
|
|
2
|
-
// Regenerated from every `src/flows/<unit
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
// Regenerated from every `src/flows/<unit>/` folder (barrel or file tree).
|
|
3
|
+
import { registerFlowUnits, stampFlowName, stampHttpPath } from "okengine";
|
|
4
|
+
import * as main_health from "./main/health.ts";
|
|
5
|
+
import * as main_route from "./main/route.ts";
|
|
6
|
+
|
|
7
|
+
const main = {
|
|
8
|
+
health: stampHttpPath(stampFlowName(main_health.health, "main.health"), "/health"),
|
|
9
|
+
root: stampHttpPath(stampFlowName(main_route.root, "main.root"), "/"),
|
|
10
|
+
};
|
|
11
|
+
export { main };
|
|
12
|
+
|
|
13
|
+
import * as notes_$id$_archive from "./notes/[id]/archive.ts";
|
|
14
|
+
import * as notes_$id$_get from "./notes/[id]/get.ts";
|
|
15
|
+
import * as notes_create from "./notes/create.ts";
|
|
16
|
+
import * as notes_list from "./notes/list.ts";
|
|
17
|
+
import * as notes_on_created from "./notes/on-created.ts";
|
|
18
|
+
|
|
19
|
+
const notes = {
|
|
20
|
+
archive: stampHttpPath(stampFlowName(notes_$id$_archive.archive, "notes.archive"), "/notes/:id/archive"),
|
|
21
|
+
get: stampHttpPath(stampFlowName(notes_$id$_get.get, "notes.get"), "/notes/:id"),
|
|
22
|
+
create: stampHttpPath(stampFlowName(notes_create.create, "notes.create"), "/notes"),
|
|
23
|
+
list: stampHttpPath(stampFlowName(notes_list.list, "notes.list"), "/notes"),
|
|
24
|
+
onCreated: stampHttpPath(stampFlowName(notes_on_created.onCreated, "notes.onCreated"), "/notes/on-created"),
|
|
25
|
+
};
|
|
26
|
+
export { notes };
|
|
27
|
+
registerFlowUnits({ main, notes });
|
|
28
|
+
declare module "okengine" {
|
|
29
|
+
interface RegisteredFlowUnits {
|
|
30
|
+
readonly main: typeof main;
|
|
31
|
+
readonly notes: typeof notes;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { on, flow, http } from "okengine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/** Liveness for probes and `bun test`. */
|
|
5
|
+
export const health = on(
|
|
6
|
+
http.get().public(),
|
|
7
|
+
flow({
|
|
8
|
+
out: z.object({ ok: z.literal(true) }),
|
|
9
|
+
do: () => ({ ok: true as const }),
|
|
10
|
+
}),
|
|
11
|
+
);
|
|
@@ -3,8 +3,8 @@ import { z } from "zod";
|
|
|
3
3
|
|
|
4
4
|
/** First-run welcome — visit :6530/ after `oke dev` (browser code block; curl stays JSON). */
|
|
5
5
|
export const root = on(
|
|
6
|
-
http.get(
|
|
7
|
-
flow(
|
|
6
|
+
http.get().public(),
|
|
7
|
+
flow({
|
|
8
8
|
out: z.object({
|
|
9
9
|
ok: z.literal(true),
|
|
10
10
|
app: z.string(),
|
|
@@ -19,12 +19,3 @@ export const root = on(
|
|
|
19
19
|
}),
|
|
20
20
|
}),
|
|
21
21
|
);
|
|
22
|
-
|
|
23
|
-
/** Liveness for probes and `bun test`. */
|
|
24
|
-
export const health = on(
|
|
25
|
-
http.get("/health").gate.public,
|
|
26
|
-
flow("main.health", {
|
|
27
|
-
out: z.object({ ok: z.literal(true) }),
|
|
28
|
-
do: () => ({ ok: true as const }),
|
|
29
|
-
}),
|
|
30
|
-
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { on, flow, http, fail } from "okengine";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
import { db, notesMutate } from "@/core";
|
|
5
|
+
import { notes } from "@/db/schema.decl";
|
|
6
|
+
import { NoteIdIn, NoteOut, NotFound } from "../shapes";
|
|
7
|
+
|
|
8
|
+
/** Soft-archive a note. */
|
|
9
|
+
export const archive = on(
|
|
10
|
+
http.post().gate(notesMutate),
|
|
11
|
+
flow({
|
|
12
|
+
in: NoteIdIn,
|
|
13
|
+
out: NoteOut,
|
|
14
|
+
errors: { NotFound },
|
|
15
|
+
do: async (input, fx) => {
|
|
16
|
+
const row = await fx.store(db).findById(notes, input.id);
|
|
17
|
+
if (!row) return fail("NotFound", { id: input.id });
|
|
18
|
+
const archivedAt = fx.clock.now();
|
|
19
|
+
await fx.store(db).update(notes).set({ archivedAt }).where(eq(notes.id, input.id));
|
|
20
|
+
return {
|
|
21
|
+
id: String(row.id),
|
|
22
|
+
title: String(row.title),
|
|
23
|
+
body: String(row.body),
|
|
24
|
+
archivedAt,
|
|
25
|
+
createdAt: Number(row.createdAt),
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { on, flow, http, fail } from "okengine";
|
|
2
|
+
|
|
3
|
+
import { db } from "@/core";
|
|
4
|
+
import { notes } from "@/db/schema.decl";
|
|
5
|
+
import { NoteIdIn, NoteOut, NotFound } from "../shapes";
|
|
6
|
+
|
|
7
|
+
/** Fetch one note by id. */
|
|
8
|
+
export const get = on(
|
|
9
|
+
http.get().public(),
|
|
10
|
+
flow({
|
|
11
|
+
in: NoteIdIn,
|
|
12
|
+
out: NoteOut,
|
|
13
|
+
errors: { NotFound },
|
|
14
|
+
do: async (input, fx) => {
|
|
15
|
+
const row = await fx.store(db).findById(notes, input.id);
|
|
16
|
+
if (!row) return fail("NotFound", { id: input.id });
|
|
17
|
+
return {
|
|
18
|
+
id: String(row.id),
|
|
19
|
+
title: String(row.title),
|
|
20
|
+
body: String(row.body),
|
|
21
|
+
archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
|
|
22
|
+
createdAt: Number(row.createdAt),
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { on, flow, http } from "okengine";
|
|
2
|
+
|
|
3
|
+
import { db, notesMutate, webhookSecret } from "@/core";
|
|
4
|
+
import { notes } from "@/db/schema.decl";
|
|
5
|
+
import { NoteCreateIn, NoteOut } from "./shapes";
|
|
6
|
+
import { noteCreated } from "./signals";
|
|
7
|
+
|
|
8
|
+
/** Create a note, emit `note-created`, touch vault. */
|
|
9
|
+
export const create = on(
|
|
10
|
+
http.post().gate(notesMutate),
|
|
11
|
+
flow({
|
|
12
|
+
in: NoteCreateIn,
|
|
13
|
+
out: NoteOut,
|
|
14
|
+
do: async (input, fx) => {
|
|
15
|
+
await fx.vault.get(webhookSecret);
|
|
16
|
+
const id = fx.id();
|
|
17
|
+
const createdAt = fx.clock.now();
|
|
18
|
+
await fx.store(db).insert(notes).values({
|
|
19
|
+
id,
|
|
20
|
+
title: input.title,
|
|
21
|
+
body: input.body,
|
|
22
|
+
archivedAt: null,
|
|
23
|
+
createdAt,
|
|
24
|
+
});
|
|
25
|
+
await fx.emit(noteCreated, { id, title: input.title }, { key: id });
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
title: input.title,
|
|
29
|
+
body: input.body,
|
|
30
|
+
archivedAt: null,
|
|
31
|
+
createdAt,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { on, flow, http } from "okengine";
|
|
2
|
+
import { isNull } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
import { db } from "@/core";
|
|
5
|
+
import { notes } from "@/db/schema.decl";
|
|
6
|
+
import { NoteListOut } from "./shapes";
|
|
7
|
+
|
|
8
|
+
/** List active (non-archived) notes, newest first. */
|
|
9
|
+
export const list = on(
|
|
10
|
+
http.get().public(),
|
|
11
|
+
flow({
|
|
12
|
+
out: NoteListOut,
|
|
13
|
+
do: async (input, fx) => {
|
|
14
|
+
const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
|
|
15
|
+
const data = [...rows]
|
|
16
|
+
.sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
|
|
17
|
+
.map((r) => ({
|
|
18
|
+
id: String(r.id),
|
|
19
|
+
title: String(r.title),
|
|
20
|
+
body: String(r.body),
|
|
21
|
+
archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
|
|
22
|
+
createdAt: Number(r.createdAt),
|
|
23
|
+
}));
|
|
24
|
+
return fx.json.withQuery(data, input);
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { on, flow } from "okengine";
|
|
2
|
+
|
|
3
|
+
import { noteCreatedMail } from "@/core";
|
|
4
|
+
import { noteCreated } from "./signals";
|
|
5
|
+
|
|
6
|
+
/** On create → send the note-created email template. */
|
|
7
|
+
export const onCreated = on(
|
|
8
|
+
noteCreated,
|
|
9
|
+
flow({
|
|
10
|
+
do: async (payload, fx) => {
|
|
11
|
+
await fx.send(noteCreatedMail, {
|
|
12
|
+
to: "you@localhost",
|
|
13
|
+
data: { id: payload.id, title: payload.title },
|
|
14
|
+
});
|
|
15
|
+
},
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
import { on, flow, http, every, fail } from "okengine";
|
|
2
|
-
import { eq, isNull } from "drizzle-orm";
|
|
3
|
-
|
|
4
|
-
import { db, files, noteCreatedMail, notesMutate, webhookSecret } from "@/core";
|
|
5
|
-
import { notes } from "@/db/schema.decl";
|
|
6
|
-
import {
|
|
7
|
-
NoteAttachIn,
|
|
8
|
-
NoteAttachOut,
|
|
9
|
-
NoteCreateIn,
|
|
10
|
-
NoteDigestOut,
|
|
11
|
-
NoteIdIn,
|
|
12
|
-
NoteListOut,
|
|
13
|
-
NoteOut,
|
|
14
|
-
NoteSummarizeIn,
|
|
15
|
-
NoteSummarizeOut,
|
|
16
|
-
NotFound,
|
|
17
|
-
Unavailable,
|
|
18
|
-
} from "./shapes";
|
|
19
|
-
import { noteCreated } from "./signals";
|
|
20
|
-
|
|
21
|
-
import "./shapes";
|
|
22
|
-
import "./signals";
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Pull a usable summary string from an `fx.ask` payload.
|
|
26
|
-
*
|
|
27
|
-
* @param out - Model output object
|
|
28
|
-
*/
|
|
29
|
-
function extractSummary(out: unknown): string {
|
|
30
|
-
if (typeof out === "string") return unwrapSummaryText(out);
|
|
31
|
-
if (!out || typeof out !== "object") return "";
|
|
32
|
-
const record = out as Record<string, unknown>;
|
|
33
|
-
if (typeof record.summary === "string") return record.summary.trim();
|
|
34
|
-
if (typeof record.text === "string") return unwrapSummaryText(record.text);
|
|
35
|
-
return "";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Local models sometimes return over-escaped JSON (`{\\"summary\\":...}`).
|
|
40
|
-
* Peel one or two JSON layers, then fall back to the raw text.
|
|
41
|
-
*
|
|
42
|
-
* @param text - Model text payload
|
|
43
|
-
*/
|
|
44
|
-
function unwrapSummaryText(text: string): string {
|
|
45
|
-
let current = text.trim();
|
|
46
|
-
for (let i = 0; i < 2; i++) {
|
|
47
|
-
if (!(current.startsWith("{") || current.startsWith('"'))) break;
|
|
48
|
-
try {
|
|
49
|
-
const parsed = JSON.parse(current) as unknown;
|
|
50
|
-
if (typeof parsed === "string") {
|
|
51
|
-
current = parsed.trim();
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
if (parsed && typeof parsed === "object" && "summary" in parsed) {
|
|
55
|
-
return String((parsed as { summary: unknown }).summary).trim();
|
|
56
|
-
}
|
|
57
|
-
break;
|
|
58
|
-
} catch {
|
|
59
|
-
break;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return current;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** List active (non-archived) notes, newest first. */
|
|
66
|
-
export const list = on(
|
|
67
|
-
http.get("/notes").gate.public,
|
|
68
|
-
flow("notes.list", {
|
|
69
|
-
out: NoteListOut,
|
|
70
|
-
do: async (input, fx) => {
|
|
71
|
-
const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
|
|
72
|
-
const data = [...rows]
|
|
73
|
-
.sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
|
|
74
|
-
.map((r) => ({
|
|
75
|
-
id: String(r.id),
|
|
76
|
-
title: String(r.title),
|
|
77
|
-
body: String(r.body),
|
|
78
|
-
archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
|
|
79
|
-
createdAt: Number(r.createdAt),
|
|
80
|
-
}));
|
|
81
|
-
return fx.json.withQuery(data, input);
|
|
82
|
-
},
|
|
83
|
-
}),
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
/** Create a note, emit `note-created`, touch vault. */
|
|
87
|
-
export const create = on(
|
|
88
|
-
http.post("/notes").gate(notesMutate),
|
|
89
|
-
flow("notes.create", {
|
|
90
|
-
in: NoteCreateIn,
|
|
91
|
-
out: NoteOut,
|
|
92
|
-
do: async (input, fx) => {
|
|
93
|
-
// Prove Vault is wired (local: env/dev fallback).
|
|
94
|
-
await fx.vault.get(webhookSecret);
|
|
95
|
-
const id = fx.id();
|
|
96
|
-
const createdAt = fx.clock.now();
|
|
97
|
-
await fx.store(db).insert(notes).values({
|
|
98
|
-
id,
|
|
99
|
-
title: input.title,
|
|
100
|
-
body: input.body,
|
|
101
|
-
archivedAt: null,
|
|
102
|
-
createdAt,
|
|
103
|
-
});
|
|
104
|
-
await fx.emit(noteCreated, { id, title: input.title }, { key: id });
|
|
105
|
-
return {
|
|
106
|
-
id,
|
|
107
|
-
title: input.title,
|
|
108
|
-
body: input.body,
|
|
109
|
-
archivedAt: null,
|
|
110
|
-
createdAt,
|
|
111
|
-
};
|
|
112
|
-
},
|
|
113
|
-
}),
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
/** Fetch one note by id. */
|
|
117
|
-
export const get = on(
|
|
118
|
-
http.get("/notes/:id").gate.public,
|
|
119
|
-
flow("notes.get", {
|
|
120
|
-
in: NoteIdIn,
|
|
121
|
-
out: NoteOut,
|
|
122
|
-
errors: { NotFound },
|
|
123
|
-
do: async (input, fx) => {
|
|
124
|
-
const row = await fx.store(db).findById(notes, input.id);
|
|
125
|
-
if (!row) return fail("NotFound", { id: input.id });
|
|
126
|
-
return {
|
|
127
|
-
id: String(row.id),
|
|
128
|
-
title: String(row.title),
|
|
129
|
-
body: String(row.body),
|
|
130
|
-
archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
|
|
131
|
-
createdAt: Number(row.createdAt),
|
|
132
|
-
};
|
|
133
|
-
},
|
|
134
|
-
}),
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
/** Soft-archive a note. */
|
|
138
|
-
export const archive = on(
|
|
139
|
-
http.post("/notes/:id/archive").gate(notesMutate),
|
|
140
|
-
flow("notes.archive", {
|
|
141
|
-
in: NoteIdIn,
|
|
142
|
-
out: NoteOut,
|
|
143
|
-
errors: { NotFound },
|
|
144
|
-
do: async (input, fx) => {
|
|
145
|
-
const row = await fx.store(db).findById(notes, input.id);
|
|
146
|
-
if (!row) return fail("NotFound", { id: input.id });
|
|
147
|
-
const archivedAt = fx.clock.now();
|
|
148
|
-
await fx
|
|
149
|
-
.store(db)
|
|
150
|
-
.update(notes)
|
|
151
|
-
.set({ archivedAt })
|
|
152
|
-
.where(eq(notes.id, input.id));
|
|
153
|
-
return {
|
|
154
|
-
id: String(row.id),
|
|
155
|
-
title: String(row.title),
|
|
156
|
-
body: String(row.body),
|
|
157
|
-
archivedAt,
|
|
158
|
-
createdAt: Number(row.createdAt),
|
|
159
|
-
};
|
|
160
|
-
},
|
|
161
|
-
}),
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
/** On create → send the note-created email template. */
|
|
165
|
-
export const onCreated = on(
|
|
166
|
-
noteCreated,
|
|
167
|
-
flow("notes.onCreated", {
|
|
168
|
-
do: async (payload, fx) => {
|
|
169
|
-
await fx.send(noteCreatedMail, {
|
|
170
|
-
to: "you@localhost",
|
|
171
|
-
data: { id: payload.id, title: payload.title },
|
|
172
|
-
});
|
|
173
|
-
},
|
|
174
|
-
}),
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
/** Store a text attachment next to a note (`files:uploads`). */
|
|
178
|
-
export const attach = on(
|
|
179
|
-
http.post("/notes/:id/attach").gate(notesMutate),
|
|
180
|
-
flow("notes.attach", {
|
|
181
|
-
in: NoteAttachIn,
|
|
182
|
-
out: NoteAttachOut,
|
|
183
|
-
errors: { NotFound },
|
|
184
|
-
do: async (input, fx) => {
|
|
185
|
-
const row = await fx.store(db).findById(notes, input.id);
|
|
186
|
-
if (!row) return fail("NotFound", { id: input.id });
|
|
187
|
-
const key = `notes/${input.id}/attachment.txt`;
|
|
188
|
-
await fx.store(files).put(key, input.text);
|
|
189
|
-
return { key, bytes: new TextEncoder().encode(input.text).byteLength };
|
|
190
|
-
},
|
|
191
|
-
}),
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
/** Daily count of active notes (frozen under test drivers). */
|
|
195
|
-
export const digest = on(
|
|
196
|
-
every("1d"),
|
|
197
|
-
flow("notes.digest", {
|
|
198
|
-
out: NoteDigestOut,
|
|
199
|
-
do: async (_input, fx) => {
|
|
200
|
-
const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
|
|
201
|
-
return { active: rows.length, at: fx.clock.now() };
|
|
202
|
-
},
|
|
203
|
-
}),
|
|
204
|
-
);
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Summarize a note via the prompt's declared recovery chain.
|
|
208
|
-
* Exhausted / failed asks surface as Unavailable — never a body excerpt.
|
|
209
|
-
*/
|
|
210
|
-
export const summarize = on(
|
|
211
|
-
http.post("/notes/:id/summarize").gate(notesMutate),
|
|
212
|
-
flow("notes.summarize", {
|
|
213
|
-
in: NoteSummarizeIn,
|
|
214
|
-
out: NoteSummarizeOut,
|
|
215
|
-
errors: { NotFound, Unavailable },
|
|
216
|
-
do: async (input, fx) => {
|
|
217
|
-
const row = await fx.store(db).findById(notes, input.id);
|
|
218
|
-
if (!row) return fail("NotFound", { id: input.id });
|
|
219
|
-
try {
|
|
220
|
-
const out = await fx.ask("summarize-note", {
|
|
221
|
-
instruction:
|
|
222
|
-
'Summarize this note in one or two sentences. Reply with JSON only: {"summary":"..."}',
|
|
223
|
-
title: String(row.title),
|
|
224
|
-
body: String(row.body),
|
|
225
|
-
});
|
|
226
|
-
const summary = extractSummary(out);
|
|
227
|
-
const via = typeof out.via === "string" ? out.via.trim() : "";
|
|
228
|
-
if (!summary || !via) {
|
|
229
|
-
return fail("Unavailable", {
|
|
230
|
-
message: "AI service unavailable. Try again later.",
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
return { id: input.id, summary, via };
|
|
234
|
-
} catch {
|
|
235
|
-
return fail("Unavailable", {
|
|
236
|
-
message: "AI service unavailable. Try again later.",
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
},
|
|
240
|
-
}),
|
|
241
|
-
);
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { on, flow, http, fail } from "okengine";
|
|
2
|
-
import { eq, isNull } from "drizzle-orm";
|
|
3
|
-
|
|
4
|
-
import { db, noteCreatedMail, notesMutate, webhookSecret } from "@/core";
|
|
5
|
-
import { notes } from "@/db/schema.decl";
|
|
6
|
-
import { NoteCreateIn, NoteIdIn, NoteListOut, NoteOut, NotFound } from "./shapes";
|
|
7
|
-
import { noteCreated } from "./signals";
|
|
8
|
-
|
|
9
|
-
import "./shapes";
|
|
10
|
-
import "./signals";
|
|
11
|
-
|
|
12
|
-
/** List active (non-archived) notes, newest first. */
|
|
13
|
-
export const list = on(
|
|
14
|
-
http.get("/notes").gate.public,
|
|
15
|
-
flow("notes.list", {
|
|
16
|
-
out: NoteListOut,
|
|
17
|
-
do: async (input, fx) => {
|
|
18
|
-
const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
|
|
19
|
-
const data = [...rows]
|
|
20
|
-
.sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
|
|
21
|
-
.map((r) => ({
|
|
22
|
-
id: String(r.id),
|
|
23
|
-
title: String(r.title),
|
|
24
|
-
body: String(r.body),
|
|
25
|
-
archivedAt: r.archivedAt == null ? null : Number(r.archivedAt),
|
|
26
|
-
createdAt: Number(r.createdAt),
|
|
27
|
-
}));
|
|
28
|
-
return fx.json.withQuery(data, input);
|
|
29
|
-
},
|
|
30
|
-
}),
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
/** Create a note, emit `note-created`, touch vault. */
|
|
34
|
-
export const create = on(
|
|
35
|
-
http.post("/notes").gate(notesMutate),
|
|
36
|
-
flow("notes.create", {
|
|
37
|
-
in: NoteCreateIn,
|
|
38
|
-
out: NoteOut,
|
|
39
|
-
do: async (input, fx) => {
|
|
40
|
-
// Prove Vault is wired (local: env/dev fallback).
|
|
41
|
-
await fx.vault.get(webhookSecret);
|
|
42
|
-
const id = fx.id();
|
|
43
|
-
const createdAt = fx.clock.now();
|
|
44
|
-
await fx.store(db).insert(notes).values({
|
|
45
|
-
id,
|
|
46
|
-
title: input.title,
|
|
47
|
-
body: input.body,
|
|
48
|
-
archivedAt: null,
|
|
49
|
-
createdAt,
|
|
50
|
-
});
|
|
51
|
-
await fx.emit(noteCreated, { id, title: input.title }, { key: id });
|
|
52
|
-
return {
|
|
53
|
-
id,
|
|
54
|
-
title: input.title,
|
|
55
|
-
body: input.body,
|
|
56
|
-
archivedAt: null,
|
|
57
|
-
createdAt,
|
|
58
|
-
};
|
|
59
|
-
},
|
|
60
|
-
}),
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
/** Fetch one note by id. */
|
|
64
|
-
export const get = on(
|
|
65
|
-
http.get("/notes/:id").gate.public,
|
|
66
|
-
flow("notes.get", {
|
|
67
|
-
in: NoteIdIn,
|
|
68
|
-
out: NoteOut,
|
|
69
|
-
errors: { NotFound },
|
|
70
|
-
do: async (input, fx) => {
|
|
71
|
-
const row = await fx.store(db).findById(notes, input.id);
|
|
72
|
-
if (!row) return fail("NotFound", { id: input.id });
|
|
73
|
-
return {
|
|
74
|
-
id: String(row.id),
|
|
75
|
-
title: String(row.title),
|
|
76
|
-
body: String(row.body),
|
|
77
|
-
archivedAt: row.archivedAt == null ? null : Number(row.archivedAt),
|
|
78
|
-
createdAt: Number(row.createdAt),
|
|
79
|
-
};
|
|
80
|
-
},
|
|
81
|
-
}),
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
/** Soft-archive a note. */
|
|
85
|
-
export const archive = on(
|
|
86
|
-
http.post("/notes/:id/archive").gate(notesMutate),
|
|
87
|
-
flow("notes.archive", {
|
|
88
|
-
in: NoteIdIn,
|
|
89
|
-
out: NoteOut,
|
|
90
|
-
errors: { NotFound },
|
|
91
|
-
do: async (input, fx) => {
|
|
92
|
-
const row = await fx.store(db).findById(notes, input.id);
|
|
93
|
-
if (!row) return fail("NotFound", { id: input.id });
|
|
94
|
-
const archivedAt = fx.clock.now();
|
|
95
|
-
await fx
|
|
96
|
-
.store(db)
|
|
97
|
-
.update(notes)
|
|
98
|
-
.set({ archivedAt })
|
|
99
|
-
.where(eq(notes.id, input.id));
|
|
100
|
-
return {
|
|
101
|
-
id: String(row.id),
|
|
102
|
-
title: String(row.title),
|
|
103
|
-
body: String(row.body),
|
|
104
|
-
archivedAt,
|
|
105
|
-
createdAt: Number(row.createdAt),
|
|
106
|
-
};
|
|
107
|
-
},
|
|
108
|
-
}),
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
/** On create → send the note-created email template. */
|
|
112
|
-
export const onCreated = on(
|
|
113
|
-
noteCreated,
|
|
114
|
-
flow("notes.onCreated", {
|
|
115
|
-
do: async (payload, fx) => {
|
|
116
|
-
await fx.send(noteCreatedMail, {
|
|
117
|
-
to: "you@localhost",
|
|
118
|
-
data: { id: payload.id, title: payload.title },
|
|
119
|
-
});
|
|
120
|
-
},
|
|
121
|
-
}),
|
|
122
|
-
);
|