create-oke 0.1.4
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 +36 -0
- package/src/cli.test.ts +110 -0
- package/src/cli.ts +156 -0
- package/src/index.ts +8 -0
- package/src/scaffold.ts +160 -0
- package/src/sync-templates.ts +65 -0
- package/src/templates.ts +85 -0
- package/src/transform.ts +124 -0
- package/templates/linkly/oke.config.ts +10 -0
- package/templates/linkly/package.json +15 -0
- package/templates/linkly/src/app.ts +21 -0
- package/templates/linkly/src/core.ts +4 -0
- package/templates/linkly/src/flows/analytics/index.ts +24 -0
- package/templates/linkly/src/flows/links/index.ts +59 -0
- package/templates/linkly/src/flows/links/shapes.ts +22 -0
- package/templates/linkly/src/flows/links/signals.ts +13 -0
- package/templates/linkly/src/gates.ts +8 -0
- package/templates/linkly/src/schema.ts +17 -0
- package/templates/linkly/tests/linkly.test.ts +19 -0
- package/templates/notes/oke.config.ts +7 -0
- package/templates/notes/package.json +16 -0
- package/templates/notes/src/app.ts +13 -0
- package/templates/notes/src/core.ts +4 -0
- package/templates/notes/src/flows/notes/index.ts +44 -0
- package/templates/notes/src/schema.ts +9 -0
- package/templates/notes/tests/notes.test.ts +10 -0
- package/templates/provisions/oke.config.ts +17 -0
- package/templates/provisions/package.json +15 -0
- package/templates/provisions/src/app.ts +60 -0
- package/templates/provisions/src/channels.ts +14 -0
- package/templates/provisions/src/core.ts +4 -0
- package/templates/provisions/src/flows/notifications/index.ts +19 -0
- package/templates/provisions/src/flows/orders/index.ts +70 -0
- package/templates/provisions/src/flows/orders/shapes.ts +21 -0
- package/templates/provisions/src/flows/orders/signals.ts +19 -0
- package/templates/provisions/src/flows/payments/index.ts +37 -0
- package/templates/provisions/src/flows/payments/shapes.ts +3 -0
- package/templates/provisions/src/flows/payments/stripe.ts +18 -0
- package/templates/provisions/src/gates.ts +4 -0
- package/templates/provisions/src/locales/ar.ts +1 -0
- package/templates/provisions/src/locales/en.ts +1 -0
- package/templates/provisions/src/plugins/audit-schema.ts +2 -0
- package/templates/provisions/src/plugins/audit.ts +14 -0
- package/templates/provisions/src/schema.ts +16 -0
- package/templates/provisions/src/vault.ts +16 -0
- package/templates/provisions/tests/orders.test.ts +18 -0
- package/templates/skyport/evals/triage.jsonl +1 -0
- package/templates/skyport/oke.config.ts +44 -0
- package/templates/skyport/oke.images.lock +4 -0
- package/templates/skyport/package.json +16 -0
- package/templates/skyport/src/ai.ts +25 -0
- package/templates/skyport/src/app.ts +57 -0
- package/templates/skyport/src/channels.ts +12 -0
- package/templates/skyport/src/core.ts +4 -0
- package/templates/skyport/src/flows/bookings/index.ts +50 -0
- package/templates/skyport/src/flows/bookings/shapes.ts +6 -0
- package/templates/skyport/src/flows/bookings/signals.ts +9 -0
- package/templates/skyport/src/flows/notifications/index.ts +16 -0
- package/templates/skyport/src/flows/payments/index.ts +14 -0
- package/templates/skyport/src/flows/payments/shapes.ts +5 -0
- package/templates/skyport/src/flows/support/index.ts +38 -0
- package/templates/skyport/src/flows/users/elements.ts +2 -0
- package/templates/skyport/src/flows/users/index.ts +12 -0
- package/templates/skyport/src/flows/users/shapes.ts +6 -0
- package/templates/skyport/src/gates.ts +10 -0
- package/templates/skyport/src/journeys.ts +9 -0
- package/templates/skyport/src/locales/ar.ts +4 -0
- package/templates/skyport/src/locales/en.ts +4 -0
- package/templates/skyport/src/plugins/audit-schema.ts +7 -0
- package/templates/skyport/src/plugins/audit.ts +14 -0
- package/templates/skyport/src/plugins/panel.tsx +4 -0
- package/templates/skyport/src/schema.ts +22 -0
- package/templates/skyport/src/vault.ts +24 -0
- package/templates/skyport/tests/support.test.ts +16 -0
package/src/transform.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rewrites applied when copying an `examples/<template>` tree into a new project.
|
|
3
|
+
*
|
|
4
|
+
* Exactly:
|
|
5
|
+
* 1. `package.json` `"name"` → the user-provided project name
|
|
6
|
+
* 2. `package.json` `"okengine": "file:../.."` → an installable reference
|
|
7
|
+
* (absolute `file:<okengine-root>` in the monorepo; registry version otherwise)
|
|
8
|
+
* 3. Drop monorepo-only files that import paths outside the example tree
|
|
9
|
+
* (today: `tests/docker.test.ts`)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { packageRoot } from "./templates.ts";
|
|
15
|
+
|
|
16
|
+
/** Shape of an example / scaffolded `package.json`. */
|
|
17
|
+
export type ScaffoldPackageJson = {
|
|
18
|
+
name?: string;
|
|
19
|
+
version?: string;
|
|
20
|
+
private?: boolean;
|
|
21
|
+
type?: string;
|
|
22
|
+
dependencies?: Record<string, string>;
|
|
23
|
+
scripts?: Record<string, string>;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the `okengine` dependency string written into the scaffolded package.json.
|
|
29
|
+
*
|
|
30
|
+
* - Monorepo / local: `file:<absolute-okengine-root>` (installable; not `file:../..`)
|
|
31
|
+
* - Published create-oke: the version of this package (kept in lockstep with okengine)
|
|
32
|
+
*
|
|
33
|
+
* @param localOkengineRoot - Absolute path when available
|
|
34
|
+
*/
|
|
35
|
+
export function resolveOkengineDependency(
|
|
36
|
+
localOkengineRoot: string | null,
|
|
37
|
+
): string {
|
|
38
|
+
if (localOkengineRoot) return `file:${localOkengineRoot}`;
|
|
39
|
+
const pkgPath = join(packageRoot(), "package.json");
|
|
40
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string };
|
|
41
|
+
return pkg.version;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rewrite an example `package.json` for a new project.
|
|
46
|
+
*
|
|
47
|
+
* @param source - Parsed example package.json
|
|
48
|
+
* @param projectName - npm package name for the new project
|
|
49
|
+
* @param okengineDep - Installable okengine dependency string
|
|
50
|
+
*/
|
|
51
|
+
export function transformPackageJson(
|
|
52
|
+
source: ScaffoldPackageJson,
|
|
53
|
+
projectName: string,
|
|
54
|
+
okengineDep: string,
|
|
55
|
+
): ScaffoldPackageJson {
|
|
56
|
+
const dependencies = { ...source.dependencies };
|
|
57
|
+
if (dependencies["okengine"] !== undefined) {
|
|
58
|
+
dependencies["okengine"] = okengineDep;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...source,
|
|
63
|
+
name: projectName,
|
|
64
|
+
dependencies,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether a relative path inside a template should be omitted from the scaffold.
|
|
70
|
+
*
|
|
71
|
+
* Skips install/VCS artefacts and monorepo-only tests that import outside the
|
|
72
|
+
* example (e.g. skyport `tests/docker.test.ts` → `../../../src/cli/docker.ts`).
|
|
73
|
+
*
|
|
74
|
+
* @param relativePath - Path relative to the template root (`/`-separated)
|
|
75
|
+
*/
|
|
76
|
+
export function shouldSkipTemplatePath(relativePath: string): boolean {
|
|
77
|
+
const parts = relativePath.split(/[/\\]/);
|
|
78
|
+
if (parts.includes("node_modules") || parts.includes(".git")) return true;
|
|
79
|
+
const base = parts[parts.length - 1] ?? "";
|
|
80
|
+
if (
|
|
81
|
+
base === "bun.lock" ||
|
|
82
|
+
base === "bun.lockb" ||
|
|
83
|
+
base === "package-lock.json" ||
|
|
84
|
+
base === "yarn.lock" ||
|
|
85
|
+
base === "pnpm-lock.yaml"
|
|
86
|
+
) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
// Monorepo CI fixture — not part of the four-applications app tree.
|
|
90
|
+
if (relativePath.replace(/\\/g, "/") === "tests/docker.test.ts") return true;
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Sanitize a directory / package name for npm.
|
|
96
|
+
*
|
|
97
|
+
* @param raw - User-provided name
|
|
98
|
+
*/
|
|
99
|
+
export function sanitizeProjectName(raw: string): string {
|
|
100
|
+
const trimmed = raw.trim();
|
|
101
|
+
if (!trimmed) {
|
|
102
|
+
throw new Error("create-oke: project name must not be empty");
|
|
103
|
+
}
|
|
104
|
+
// Allow scoped names; otherwise lowercase npm-safe slug.
|
|
105
|
+
if (trimmed.startsWith("@")) {
|
|
106
|
+
const m = /^(@[a-z0-9-~][a-z0-9-._~]*\/[a-z0-9-~][a-z0-9-._~]*)$/.exec(
|
|
107
|
+
trimmed,
|
|
108
|
+
);
|
|
109
|
+
if (!m) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`create-oke: invalid scoped package name "${trimmed}"`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return trimmed;
|
|
115
|
+
}
|
|
116
|
+
const name = trimmed
|
|
117
|
+
.toLowerCase()
|
|
118
|
+
.replace(/[^a-z0-9._~-]+/g, "-")
|
|
119
|
+
.replace(/^-+|-+$/g, "");
|
|
120
|
+
if (!name || !/^[a-z0-9-~]/.test(name)) {
|
|
121
|
+
throw new Error(`create-oke: invalid project name "${raw}"`);
|
|
122
|
+
}
|
|
123
|
+
return name;
|
|
124
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineConfig } from "okengine/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
drivers: {
|
|
5
|
+
store: { sql: { dev: "sqlite", test: "memory", prod: "postgres" },
|
|
6
|
+
kv: { dev: "memory", test: "memory", prod: "redis" } },
|
|
7
|
+
signal: { dev: "memory", test: "memory", prod: "postgres" },
|
|
8
|
+
clock: { dev: "memory", test: "frozen", prod: "postgres" },
|
|
9
|
+
},
|
|
10
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oke/example-linkly",
|
|
3
|
+
"version": "0.1.4",
|
|
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
|
+
"scripts": {
|
|
12
|
+
"test": "bun test",
|
|
13
|
+
"dev": "oke dev"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { db } from "./core";
|
|
2
|
+
import { member, fair } from "./gates";
|
|
3
|
+
import { linkClicked, linkStats } from "./flows/links/signals";
|
|
4
|
+
|
|
5
|
+
import { oke } from "okengine";
|
|
6
|
+
import * as links from "./flows/links";
|
|
7
|
+
import * as analytics from "./flows/analytics";
|
|
8
|
+
|
|
9
|
+
export const app = oke({ name: "linkly" }).adopt({ links, analytics });
|
|
10
|
+
|
|
11
|
+
export type App = typeof app;
|
|
12
|
+
|
|
13
|
+
// Spec test calls `t.api.links.report` though `report` lives in analytics.
|
|
14
|
+
app.adopt({ links: { report: analytics.report } });
|
|
15
|
+
|
|
16
|
+
Object.assign(app.$options, {
|
|
17
|
+
env: "test",
|
|
18
|
+
gates: [member, fair],
|
|
19
|
+
signals: [linkClicked, linkStats],
|
|
20
|
+
stores: [db],
|
|
21
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { on, flow, http } from "okengine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { eq, and } from "drizzle-orm";
|
|
4
|
+
import { linkClicked } from "../links/signals";
|
|
5
|
+
import { db } from "../../core";
|
|
6
|
+
import { member } from "../../gates";
|
|
7
|
+
import { daily } from "../../schema";
|
|
8
|
+
|
|
9
|
+
on(linkClicked, flow({ // a second consumer of the same signal
|
|
10
|
+
do: async ({ code, at }, fx) => {
|
|
11
|
+
const day = new Date(at).toISOString().slice(0, 10);
|
|
12
|
+
const [row] = await fx.store(db).select().from(daily)
|
|
13
|
+
.where(and(eq(daily.code, code), eq(daily.day, day))).limit(1);
|
|
14
|
+
|
|
15
|
+
if (row) await fx.store(db).increment(daily, row.id, "clicks");
|
|
16
|
+
else await fx.store(db).insert(daily).values({ id: fx.id(), code, day, clicks: 1 });
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
export const report = on(http.get("/links/:code/report").gate(member), flow({
|
|
21
|
+
out: z.array(z.object({ day: z.string(), clicks: z.number() })),
|
|
22
|
+
do: ({ code }, fx) => fx.store(db).select({ day: daily.day, clicks: daily.clicks })
|
|
23
|
+
.from(daily).where(eq(daily.code, code)),
|
|
24
|
+
}));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { on, flow, http, every } from "okengine";
|
|
3
|
+
import { eq, lt } from "drizzle-orm";
|
|
4
|
+
import { db } from "../../core";
|
|
5
|
+
import { member, fair } from "../../gates";
|
|
6
|
+
import { linkClicked, linkStats } from "./signals";
|
|
7
|
+
import { NewLink, LinkCode, Link, NotFound, Taken } from "./shapes";
|
|
8
|
+
import { links } from "../../schema";
|
|
9
|
+
|
|
10
|
+
// ① HTTP — "an endpoint"
|
|
11
|
+
export const shorten = on(http.post("/links").gate(member, fair), flow({
|
|
12
|
+
in: NewLink, out: LinkCode, errors: { Taken },
|
|
13
|
+
do: async ({ url, code }, fx) => {
|
|
14
|
+
if (await fx.store(db).exists(links, { code })) return fx.fail("Taken", {});
|
|
15
|
+
const id = fx.id();
|
|
16
|
+
await fx.store(db).insert(links).values(
|
|
17
|
+
{ id, code, url, userId: fx.auth.userId, clicks: 0, createdAt: Date.now() });
|
|
18
|
+
return { code };
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// ② HTTP — the hot path
|
|
23
|
+
export const redirect = on(http.get("/:code").gate(fair), flow({
|
|
24
|
+
in: LinkCode, out: Link, errors: { NotFound },
|
|
25
|
+
do: async ({ code }, fx) => {
|
|
26
|
+
const [link] = await fx.store(db).select().from(links).where(eq(links.code, code)).limit(1);
|
|
27
|
+
if (!link) return fx.fail("NotFound", {});
|
|
28
|
+
|
|
29
|
+
await fx.emit(linkClicked, { code, at: Date.now() }); // same transaction as any write
|
|
30
|
+
return link;
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// ③ SIGNAL — "a queue consumer", and the same species as ① and ②
|
|
35
|
+
on(linkClicked, flow({
|
|
36
|
+
do: async ({ code }, fx) => {
|
|
37
|
+
const [link] = await fx.store(db).select().from(links).where(eq(links.code, code)).limit(1);
|
|
38
|
+
const clicks = await fx.store(db).increment(links, link.id, "clicks");
|
|
39
|
+
await fx.emit(linkStats, { code, clicks }); // live: pushed to subscribers
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// ④ CLOCK — "a cron job", and still the same species
|
|
44
|
+
on(every("1h"), flow({
|
|
45
|
+
do: (_, fx) => {
|
|
46
|
+
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
47
|
+
return fx.store(db).delete(links).where(lt(links.createdAt, cutoff));
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
// ⑤ A plain flow with no trigger — callable, not "private"
|
|
52
|
+
export const stats = flow({
|
|
53
|
+
in: LinkCode, out: z.object({ clicks: z.number() }),
|
|
54
|
+
do: async ({ code }, fx) => {
|
|
55
|
+
const [link] = await fx.store(db).select({ clicks: links.clicks })
|
|
56
|
+
.from(links).where(eq(links.code, code)).limit(1);
|
|
57
|
+
return link ?? { clicks: 0 };
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const NewLink = z.object({
|
|
4
|
+
url: z.string().url(),
|
|
5
|
+
code: z.string().min(1).max(32),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const LinkCode = z.object({
|
|
9
|
+
code: z.string(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const Link = z.object({
|
|
13
|
+
id: z.string(),
|
|
14
|
+
code: z.string(),
|
|
15
|
+
url: z.string(),
|
|
16
|
+
userId: z.string(),
|
|
17
|
+
clicks: z.number(),
|
|
18
|
+
createdAt: z.number(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const Taken = z.object({});
|
|
22
|
+
export const NotFound = z.object({});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { signal } from "okengine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const linkClicked = signal("link-clicked", {
|
|
5
|
+
schema: z.object({ code: z.string(), at: z.number(), referrer: z.string().optional() }),
|
|
6
|
+
delivery: "once", // queue physics: one consumer, retries, DLQ
|
|
7
|
+
retries: 3, deadLetter: true,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const linkStats = signal("link-stats", {
|
|
11
|
+
schema: z.object({ code: z.string(), clicks: z.number() }),
|
|
12
|
+
delivery: "live", // stream physics: clients subscribe
|
|
13
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { gate } from "okengine";
|
|
2
|
+
|
|
3
|
+
export const member = gate.policy("member", ({ auth }) => !!auth?.verified);
|
|
4
|
+
|
|
5
|
+
export const fair = gate.rate({
|
|
6
|
+
strategy: "sliding-window-counter", // near-exact, two keys, no boundary bursts
|
|
7
|
+
max: 60, per: "1m", keyBy: "ip",
|
|
8
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
2
|
+
|
|
3
|
+
export const links = sqliteTable("links", {
|
|
4
|
+
id: text("id").primaryKey(), // `increment` targets this column
|
|
5
|
+
code: text("code").notNull().unique(), // the short, human-facing key
|
|
6
|
+
url: text("url").notNull(),
|
|
7
|
+
userId: text("user_id").notNull(),
|
|
8
|
+
clicks: integer("clicks").notNull().default(0),
|
|
9
|
+
createdAt: integer("created_at").notNull(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const daily = sqliteTable("daily", {
|
|
13
|
+
id: text("id").primaryKey(),
|
|
14
|
+
code: text("code").notNull(),
|
|
15
|
+
day: text("day").notNull(), // "YYYY-MM-DD"
|
|
16
|
+
clicks: integer("clicks").notNull().default(0),
|
|
17
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { createTestApp } from "okengine/test";
|
|
3
|
+
import { app } from "../src/app";
|
|
4
|
+
|
|
5
|
+
test("shorten → redirect → report · time travel", async () => {
|
|
6
|
+
const t = await createTestApp(app); // memory drivers, frozen clock
|
|
7
|
+
const u = await t.auth.loginAs({});
|
|
8
|
+
|
|
9
|
+
await t.api.links.shorten({ url: "https://example.com", code: "sa" }, { as: u });
|
|
10
|
+
await t.api.links.redirect({ code: "sa" });
|
|
11
|
+
await t.signals.drain(); // run queued work deterministically
|
|
12
|
+
|
|
13
|
+
const { data } = await t.api.links.report({ code: "sa" }, { as: u });
|
|
14
|
+
expect(data![0].clicks).toBe(1);
|
|
15
|
+
|
|
16
|
+
await t.clock.advance("31d");
|
|
17
|
+
await t.cron.run("1h"); // time travel
|
|
18
|
+
await t.close();
|
|
19
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oke/example-notes",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"okengine": "file:../..",
|
|
8
|
+
"drizzle-orm": "^1.0.0-rc.4",
|
|
9
|
+
"drizzle-zod": "^0.8.3",
|
|
10
|
+
"zod": "^4.4.3"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "bun test",
|
|
14
|
+
"dev": "oke dev"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { db } from "./core";
|
|
2
|
+
|
|
3
|
+
import { oke } from "okengine";
|
|
4
|
+
import * as notes from "./flows/notes";
|
|
5
|
+
|
|
6
|
+
export const app = oke({ name: "notes" }).adopt({ notes });
|
|
7
|
+
|
|
8
|
+
export type App = typeof app; // ← the client needs nothing else
|
|
9
|
+
|
|
10
|
+
Object.assign(app.$options, {
|
|
11
|
+
env: "test",
|
|
12
|
+
stores: [db],
|
|
13
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { on, flow, http } from "okengine";
|
|
2
|
+
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { db } from "../../core";
|
|
5
|
+
import { notes } from "../../schema";
|
|
6
|
+
|
|
7
|
+
// Contracts derived from the schema — one source of truth, refined where the API is stricter
|
|
8
|
+
const NewNote = createInsertSchema(notes, { title: (s) => s.min(1).max(120) })
|
|
9
|
+
.omit({ id: true, createdAt: true });
|
|
10
|
+
const Note = createSelectSchema(notes);
|
|
11
|
+
const NoteId = z.object({ id: z.string() });
|
|
12
|
+
const NotFound = z.object({});
|
|
13
|
+
|
|
14
|
+
export const create = on(http.post("/notes"), flow({
|
|
15
|
+
in: NewNote,
|
|
16
|
+
out: NoteId,
|
|
17
|
+
do: async (input, fx) => {
|
|
18
|
+
const [note] = await fx.store(db).insert(notes).values(input).returning();
|
|
19
|
+
return { id: note.id };
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
// effects → writes[sql:notes]
|
|
23
|
+
|
|
24
|
+
export const list = on(http.get("/notes"), flow({
|
|
25
|
+
out: Note.array(),
|
|
26
|
+
do: (_, fx) => fx.store(db).select().from(notes),
|
|
27
|
+
}));
|
|
28
|
+
// effects → reads[sql:notes]
|
|
29
|
+
|
|
30
|
+
export const get = on(http.get("/notes/:id"), flow({
|
|
31
|
+
in: NoteId,
|
|
32
|
+
out: Note,
|
|
33
|
+
errors: { NotFound },
|
|
34
|
+
do: async ({ id }, fx) => (await fx.store(db).findById(notes, id)) ?? fx.fail("NotFound", {}),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
export const remove = on(http.delete("/notes/:id"), flow({
|
|
38
|
+
in: NoteId,
|
|
39
|
+
errors: { NotFound },
|
|
40
|
+
do: async ({ id }, fx) => {
|
|
41
|
+
const deleted = await fx.store(db).delete(notes, id);
|
|
42
|
+
if (!deleted) return fx.fail("NotFound", {});
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
2
|
+
import { id, now } from "okengine/store";
|
|
3
|
+
|
|
4
|
+
export const notes = sqliteTable("notes", {
|
|
5
|
+
id: text("id").primaryKey().$defaultFn(id),
|
|
6
|
+
title: text("title").notNull(),
|
|
7
|
+
body: text("body").notNull(),
|
|
8
|
+
createdAt: integer("created_at").notNull().$defaultFn(now),
|
|
9
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { createTestApp } from "okengine/test";
|
|
3
|
+
import { app } from "../src/app";
|
|
4
|
+
|
|
5
|
+
test("create then read", async () => {
|
|
6
|
+
const t = await createTestApp(app); // memory driver, automatic
|
|
7
|
+
const { data } = await t.api.notes.create({ title: "First", body: "Hello" });
|
|
8
|
+
const { data: note } = await t.api.notes.get({ id: data!.id });
|
|
9
|
+
expect(note!.title).toBe("First");
|
|
10
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from "okengine/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
drivers: {
|
|
5
|
+
store: { sql: { dev: "sqlite", test: "memory", prod: "postgres" },
|
|
6
|
+
kv: { dev: "memory", test: "memory", prod: "redis" } },
|
|
7
|
+
signal: { dev: "memory", test: "memory", prod: "postgres" },
|
|
8
|
+
clock: { dev: "memory", test: "frozen", prod: "postgres" },
|
|
9
|
+
vault: { dev: "dotenv", test: "memory", prod: "sops" },
|
|
10
|
+
channel: {
|
|
11
|
+
email: { dev: "console", test: "console", prod: "smtp" },
|
|
12
|
+
sms: { dev: "console", test: "console", prod: "unifonic" },
|
|
13
|
+
whatsapp: { dev: "console", test: "console", prod: "wa-cloud" },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
i18n: { locales: ["en", "ar"], default: "ar", dir: { ar: "rtl" } },
|
|
17
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oke/example-provisions",
|
|
3
|
+
"version": "0.1.4",
|
|
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
|
+
"scripts": {
|
|
12
|
+
"test": "bun test",
|
|
13
|
+
"dev": "oke dev"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { rateLimit } from "okengine";
|
|
2
|
+
import { member } from "./gates";
|
|
3
|
+
import { canOrder } from "./flows/orders";
|
|
4
|
+
import { stripeKey, dbUrl } from "./vault";
|
|
5
|
+
import { orderPlaced, orderNews } from "./flows/orders/signals";
|
|
6
|
+
import { orderConfirmed, otpCode } from "./channels";
|
|
7
|
+
import { db } from "./core";
|
|
8
|
+
import { products } from "./schema";
|
|
9
|
+
|
|
10
|
+
import { oke } from "okengine";
|
|
11
|
+
import { auth } from "okengine/auth";
|
|
12
|
+
import { audit } from "./plugins/audit";
|
|
13
|
+
import * as orders from "./flows/orders";
|
|
14
|
+
import * as payments from "./flows/payments";
|
|
15
|
+
import * as notifications from "./flows/notifications";
|
|
16
|
+
|
|
17
|
+
export const app = oke({ name: "provisions" })
|
|
18
|
+
.adopt({ orders, payments, notifications })
|
|
19
|
+
.plug(auth()) // zero ceremony: uses your configured store
|
|
20
|
+
.plug(audit) // app-wide
|
|
21
|
+
.hook("onError", (ctx, err, fx) => fx.log.error(err));
|
|
22
|
+
|
|
23
|
+
app.unit("orders").plug(rateLimit({ max: 30 })); // this unit only
|
|
24
|
+
|
|
25
|
+
export type App = typeof app;
|
|
26
|
+
|
|
27
|
+
Object.assign(app.$options, {
|
|
28
|
+
env: "test",
|
|
29
|
+
gates: [member, canOrder],
|
|
30
|
+
secrets: [stripeKey, dbUrl],
|
|
31
|
+
signals: [orderPlaced, orderNews],
|
|
32
|
+
stores: [db],
|
|
33
|
+
channel: {
|
|
34
|
+
templates: [orderConfirmed, otpCode],
|
|
35
|
+
defaultLocale: "ar",
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const boot = app.boot.bind(app);
|
|
40
|
+
app.boot = async (opts) => {
|
|
41
|
+
const result = await boot(opts);
|
|
42
|
+
// `app.boot()` returns the app; the runtime is on `bootResult`.
|
|
43
|
+
const store = app.bootResult?.store;
|
|
44
|
+
if (store) {
|
|
45
|
+
const sql = await store.open(db, {
|
|
46
|
+
effects: { writes: ["sql:provisions"] },
|
|
47
|
+
});
|
|
48
|
+
if ("exists" in sql && "insert" in sql) {
|
|
49
|
+
const existing = await sql.exists(products, { sku: "COFFEE" });
|
|
50
|
+
if (!existing) {
|
|
51
|
+
await sql.insert(products).values({
|
|
52
|
+
sku: "COFFEE",
|
|
53
|
+
name: "Coffee",
|
|
54
|
+
stock: 100,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { channel } from "okengine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const mail = channel.email({ from: "Provisions <no-reply@provisions.sa>" });
|
|
5
|
+
export const sms = channel.sms({ sender: "PROVISIONS" });
|
|
6
|
+
export const wa = channel.whatsapp();
|
|
7
|
+
|
|
8
|
+
export const orderConfirmed = mail.template("order-confirmed", {
|
|
9
|
+
schema: z.object({ name: z.string(), orderId: z.string(), total: z.number() }),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const otpCode = channel.template("otp-code", { // medium-agnostic
|
|
13
|
+
schema: z.object({ code: z.string() }),
|
|
14
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { on, flow } from "okengine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { orderNews } from "../orders/signals";
|
|
4
|
+
import { getOrder } from "../orders";
|
|
5
|
+
import { orderConfirmed, otpCode, wa, sms } from "../../channels";
|
|
6
|
+
|
|
7
|
+
on(orderNews, flow({
|
|
8
|
+
do: async ({ orderId, status }, fx) => {
|
|
9
|
+
if (status !== "confirmed") return;
|
|
10
|
+
const o = await fx.call(getOrder, { id: orderId });
|
|
11
|
+
await fx.send(orderConfirmed, { to: o.userId, data: { name: o.userName, orderId, total: o.total } });
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
export const sendOtp = flow({
|
|
16
|
+
in: z.object({ userId: z.string(), code: z.string() }),
|
|
17
|
+
do: ({ userId, code }, fx) => fx.send(otpCode, { to: userId, via: [wa, sms], data: { code } }),
|
|
18
|
+
// ↑ fallback chain: WhatsApp, else SMS
|
|
19
|
+
});
|