create-oke 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -20
- package/examples/linkly/.env.example +15 -0
- package/examples/linkly/drizzle.config.ts +11 -0
- package/examples/linkly/oke.config.ts +6 -4
- package/examples/linkly/package.json +7 -4
- package/examples/linkly/src/flows/analytics/index.ts +29 -14
- package/examples/linkly/src/flows/links/index.ts +58 -35
- package/examples/linkly/src/flows/links/signals.ts +4 -3
- package/examples/linkly/src/gates.ts +4 -2
- package/examples/linkly/src/schema.decl.ts +39 -0
- package/examples/linkly/src/schema.generated.ts +30 -0
- package/examples/linkly/src/schema.ts +2 -17
- package/examples/linkly/tests/linkly.test.ts +9 -9
- package/examples/notes/.env.example +12 -0
- package/examples/notes/drizzle.config.ts +11 -0
- package/examples/notes/oke.config.ts +2 -2
- package/examples/notes/package.json +7 -4
- package/examples/notes/src/app.ts +1 -1
- package/examples/notes/src/flows/notes/index.ts +44 -30
- package/examples/notes/src/schema.ts +3 -3
- package/examples/notes/tests/notes.test.ts +1 -1
- package/examples/provisions/.env.example +19 -0
- package/examples/provisions/drizzle.config.ts +11 -0
- package/examples/provisions/oke.config.ts +10 -8
- package/examples/provisions/package.json +7 -4
- package/examples/provisions/src/app.ts +3 -3
- package/examples/provisions/src/channels.ts +4 -3
- package/examples/provisions/src/flows/notifications/index.ts +13 -7
- package/examples/provisions/src/flows/orders/index.ts +58 -31
- package/examples/provisions/src/flows/payments/index.ts +12 -6
- package/examples/provisions/src/schema.ts +7 -7
- package/examples/provisions/src/vault.ts +2 -2
- package/examples/provisions/tests/orders.test.ts +9 -8
- package/examples/skyport/.env.example +34 -0
- package/examples/skyport/drizzle.config.ts +11 -0
- package/examples/skyport/oke.config.ts +25 -22
- package/examples/skyport/package.json +8 -5
- package/examples/skyport/src/ai.ts +4 -4
- package/examples/skyport/src/app.ts +2 -13
- package/examples/skyport/src/flows/bookings/index.ts +45 -22
- package/examples/skyport/src/flows/bookings/shapes.ts +4 -4
- package/examples/skyport/src/flows/bookings/signals.ts +6 -2
- package/examples/skyport/src/flows/support/index.ts +37 -25
- package/examples/skyport/src/schema.ts +11 -8
- package/examples/skyport/src/vault.ts +1 -1
- package/examples/skyport/tests/support.test.ts +6 -6
- package/package.json +9 -9
- package/src/cli.test.ts +132 -64
- package/src/cli.ts +58 -33
- package/src/scaffold.ts +53 -15
- package/src/sync-templates.ts +2 -11
- package/src/templates.ts +1 -2
- package/src/transform.ts +59 -9
- package/templates/full/.env.example +24 -0
- package/templates/full/README.md +22 -6
- package/templates/full/drizzle.config.ts +14 -0
- package/templates/full/oke.config.ts +14 -26
- package/templates/full/package.json +3 -0
- package/templates/hello/.env.example +9 -0
- package/templates/hello/README.md +6 -4
- package/templates/minimal/.env.example +13 -0
- package/templates/minimal/README.md +15 -7
- package/templates/minimal/drizzle.config.ts +14 -0
- package/templates/minimal/oke.config.ts +2 -2
- package/templates/minimal/package.json +3 -0
- package/templates/standard/.env.example +21 -0
- package/templates/standard/README.md +25 -8
- package/templates/standard/drizzle.config.ts +14 -0
- package/templates/standard/oke.config.ts +12 -24
- package/templates/standard/package.json +3 -0
package/README.md
CHANGED
|
@@ -12,11 +12,11 @@ On a TTY, create-oke opens a Clack wizard: confirm name, pick a template, and
|
|
|
12
12
|
optionally install + start `oke dev`. Pass `--yes` or `--template` /
|
|
13
13
|
`--from-example` for non-interactive (CI / agents).
|
|
14
14
|
|
|
15
|
-
| Port
|
|
16
|
-
|
|
17
|
-
| `:6530` | your app
|
|
18
|
-
| `:6533` | Console
|
|
19
|
-
| `:6535` | MCP |
|
|
15
|
+
| Port | What |
|
|
16
|
+
| ------- | ------------------- |
|
|
17
|
+
| `:6530` | your app |
|
|
18
|
+
| `:6533` | Console |
|
|
19
|
+
| `:6535` | MCP (live Manifest) |
|
|
20
20
|
|
|
21
21
|
Default template is **standard** (full recommended file layout, empty scaffolding).
|
|
22
22
|
Pick another with `--template`, or start from a teaching example with `--from-example`:
|
|
@@ -25,16 +25,28 @@ Pick another with `--template`, or start from a teaching example with `--from-ex
|
|
|
25
25
|
bunx create-oke@latest my-app --template hello
|
|
26
26
|
bunx create-oke@latest my-app --template minimal
|
|
27
27
|
bunx create-oke@latest my-app --template full
|
|
28
|
+
bunx create-oke@latest my-app --sql postgres
|
|
28
29
|
bunx create-oke@latest my-app --yes
|
|
29
30
|
bunx create-oke@latest my-notes --from-example notes
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
`--sql sqlite|postgres` is opt-in. The default keeps the dual-mode Store
|
|
34
|
+
config (`local: sqlite` · `docker`/`prod: postgres`) and a `sqliteTable`
|
|
35
|
+
schema. Pass `--sql postgres` to rewrite `src/schema.ts` to `pgTable` /
|
|
36
|
+
`drizzle-orm/pg-core` and pin `oke.config.ts` `store.sql` local/docker/prod to
|
|
37
|
+
`postgres`. The interactive wizard does not ask — same dual-mode default
|
|
38
|
+
(skipped entirely for `hello`, which has no Store).
|
|
39
|
+
|
|
40
|
+
Store-bearing scaffolds ship `drizzle.config.ts` and `.env.example`. Local
|
|
41
|
+
`oke dev` auto-runs `oke db push` on schema change; use `oke mode docker` /
|
|
42
|
+
`oke dev --docker` for compose infra.
|
|
43
|
+
|
|
44
|
+
| Template | Purpose |
|
|
45
|
+
| ---------- | --------------------------------------------------------- |
|
|
46
|
+
| `hello` | Fastest possible "it works" — one flow, no Store |
|
|
47
|
+
| `minimal` | Smallest shape you'd actually ship — Store + 1–2 flows |
|
|
36
48
|
| `standard` | Full recommended file layout, empty scaffolding (default) |
|
|
37
|
-
| `full`
|
|
49
|
+
| `full` | Every element present and wired, no business logic |
|
|
38
50
|
|
|
39
51
|
Every scaffold writes an `AGENTS.md` so coding agents know the OKE contract
|
|
40
52
|
(`--no-agents-md` to skip).
|
|
@@ -42,18 +54,18 @@ Every scaffold writes an `AGENTS.md` so coding agents know the OKE contract
|
|
|
42
54
|
`--from-example` copies a teaching example (including its business logic and
|
|
43
55
|
comments) — most new projects want `--template` instead.
|
|
44
56
|
|
|
45
|
-
| Example
|
|
46
|
-
|
|
47
|
-
| `notes`
|
|
48
|
-
| `linkly`
|
|
49
|
-
| `provisions` | durable flows · `vault` · `channel` · plugins
|
|
50
|
-
| `skyport`
|
|
57
|
+
| Example | Adds |
|
|
58
|
+
| ------------ | ---------------------------------------------------- |
|
|
59
|
+
| `notes` | `oke` · `flow` · `http` · `store.sql` · typed client |
|
|
60
|
+
| `linkly` | `signal` · `clock` · `gate` · abstract `schema.decl` |
|
|
61
|
+
| `provisions` | durable flows · `vault` · `channel` · plugins |
|
|
62
|
+
| `skyport` | `ai` · multi-tenancy · distributed topology |
|
|
51
63
|
|
|
52
64
|
## Registries
|
|
53
65
|
|
|
54
|
-
| Registry
|
|
55
|
-
|
|
56
|
-
| npm (CLI) | [`create-oke`](https://www.npmjs.com/package/create-oke) — use this with `bunx`
|
|
57
|
-
| JSR
|
|
66
|
+
| Registry | Package |
|
|
67
|
+
| --------- | ------------------------------------------------------------------------------------------------------- |
|
|
68
|
+
| npm (CLI) | [`create-oke`](https://www.npmjs.com/package/create-oke) — use this with `bunx` |
|
|
69
|
+
| JSR | [`@omqkhafi/create-oke`](https://jsr.io/@omqkhafi/create-oke) — same sources; prefer npm for the binary |
|
|
58
70
|
|
|
59
71
|
Requires **Bun ≥ 1.3**. Docs: [okengine.vercel.app](https://okengine.vercel.app) · [handbook](https://okengine.vercel.app/docs) · [unified theory](https://github.com/omqkhafi/okengine/blob/main/docs/spec/unified-theory.md).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Environment template — copy to `.env.local` for local overrides.
|
|
2
|
+
# create-oke copies this file to `.env.local` when you scaffold a new project.
|
|
3
|
+
#
|
|
4
|
+
# Resolution order (first hit wins):
|
|
5
|
+
# process.env → .env.local → docker/.env.docker → vault driver → dev fallback
|
|
6
|
+
#
|
|
7
|
+
# --- Infrastructure (optional) ---
|
|
8
|
+
# This app pins postgres (store.sql) and redis (store.kv) for prod in oke.config.ts.
|
|
9
|
+
# Local dev uses sqlite + in-memory kv; uncomment only for prod overrides.
|
|
10
|
+
|
|
11
|
+
# DATABASE_URL — primary Postgres connection; from your host (Neon, RDS, …)
|
|
12
|
+
# DATABASE_URL=postgres://user:password@host:5432/linkly
|
|
13
|
+
|
|
14
|
+
# REDIS_URL — Redis/Valkey connection for store.kv; from your host (Upstash, ElastiCache, …)
|
|
15
|
+
# REDIS_URL=redis://:password@host:6379
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineConfig } from "drizzle-kit";
|
|
2
|
+
|
|
3
|
+
/** Domain schema sync for `oke db push|generate|migrate`. */
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
dialect: "sqlite",
|
|
6
|
+
schema: "./src/schema.generated.ts",
|
|
7
|
+
out: "./drizzle",
|
|
8
|
+
dbCredentials: {
|
|
9
|
+
url: process.env.OKE_SQLITE_URL ?? "file:.oke/app.sqlite",
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -2,9 +2,11 @@ import { defineConfig } from "okengine/config";
|
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
4
|
drivers: {
|
|
5
|
-
store:
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
store: {
|
|
6
|
+
sql: { local: "sqlite", test: "memory", prod: "postgres" },
|
|
7
|
+
kv: { local: "memory", test: "memory", prod: "redis" },
|
|
8
|
+
},
|
|
9
|
+
signal: { local: "memory", test: "memory", prod: "postgres" },
|
|
10
|
+
clock: { local: "memory", test: "frozen", prod: "postgres" },
|
|
9
11
|
},
|
|
10
12
|
});
|
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
"version": "0.0.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "bun test",
|
|
8
|
+
"dev": "oke dev"
|
|
9
|
+
},
|
|
6
10
|
"dependencies": {
|
|
7
|
-
"okengine": "file:../..",
|
|
8
11
|
"drizzle-orm": "^1.0.0-rc.4",
|
|
12
|
+
"okengine": "file:../..",
|
|
9
13
|
"zod": "^4.4.3"
|
|
10
14
|
},
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"dev": "oke dev"
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"drizzle-kit": "^1.0.0-rc.4"
|
|
14
17
|
}
|
|
15
18
|
}
|
|
@@ -6,19 +6,34 @@ import { db } from "../../core";
|
|
|
6
6
|
import { member } from "../../gates";
|
|
7
7
|
import { daily } from "../../schema";
|
|
8
8
|
|
|
9
|
-
on(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
on(
|
|
10
|
+
linkClicked,
|
|
11
|
+
flow({
|
|
12
|
+
// a second consumer of the same signal
|
|
13
|
+
do: async ({ code, at }, fx) => {
|
|
14
|
+
const day = new Date(at).toISOString().slice(0, 10);
|
|
15
|
+
const [row] = await fx
|
|
16
|
+
.store(db)
|
|
17
|
+
.select()
|
|
18
|
+
.from(daily)
|
|
19
|
+
.where(and(eq(daily.code, code), eq(daily.day, day)))
|
|
20
|
+
.limit(1);
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
})
|
|
22
|
+
if (row) await fx.store(db).increment(daily, row.id, "clicks");
|
|
23
|
+
else await fx.store(db).insert(daily).values({ id: fx.id(), code, day, clicks: 1 });
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
19
27
|
|
|
20
|
-
export const report = on(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.
|
|
24
|
-
})
|
|
28
|
+
export const report = on(
|
|
29
|
+
http.get("/links/:code/report").gate(member),
|
|
30
|
+
flow({
|
|
31
|
+
out: z.array(z.object({ day: z.string(), clicks: z.number() })),
|
|
32
|
+
do: ({ code }, fx) =>
|
|
33
|
+
fx
|
|
34
|
+
.store(db)
|
|
35
|
+
.select({ day: daily.day, clicks: daily.clicks })
|
|
36
|
+
.from(daily)
|
|
37
|
+
.where(eq(daily.code, code)),
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
@@ -8,52 +8,75 @@ import { NewLink, LinkCode, Link, NotFound, Taken } from "./shapes";
|
|
|
8
8
|
import { links } from "../../schema";
|
|
9
9
|
|
|
10
10
|
// ① HTTP — "an endpoint"
|
|
11
|
-
export const shorten = on(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
export const shorten = on(
|
|
12
|
+
http.post("/links").gate(member, fair),
|
|
13
|
+
flow({
|
|
14
|
+
in: NewLink,
|
|
15
|
+
out: LinkCode,
|
|
16
|
+
errors: { Taken },
|
|
17
|
+
do: async ({ url, code }, fx) => {
|
|
18
|
+
if (await fx.store(db).exists(links, { code })) return fx.fail("Taken", {});
|
|
19
|
+
const id = fx.id();
|
|
20
|
+
await fx
|
|
21
|
+
.store(db)
|
|
22
|
+
.insert(links)
|
|
23
|
+
.values({ id, code, url, userId: fx.auth.userId, clicks: 0, createdAt: Date.now() });
|
|
24
|
+
return { code };
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
21
28
|
|
|
22
29
|
// ② HTTP — the hot path
|
|
23
|
-
export const redirect = on(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
export const redirect = on(
|
|
31
|
+
http.get("/:code").gate(fair),
|
|
32
|
+
flow({
|
|
33
|
+
in: LinkCode,
|
|
34
|
+
out: Link,
|
|
35
|
+
errors: { NotFound },
|
|
36
|
+
do: async ({ code }, fx) => {
|
|
37
|
+
const [link] = await fx.store(db).select().from(links).where(eq(links.code, code)).limit(1);
|
|
38
|
+
if (!link) return fx.fail("NotFound", {});
|
|
28
39
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
})
|
|
40
|
+
await fx.emit(linkClicked, { code, at: Date.now() }); // same transaction as any write
|
|
41
|
+
return link;
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
33
45
|
|
|
34
46
|
// ③ SIGNAL — "a queue consumer", and the same species as ① and ②
|
|
35
|
-
on(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
})
|
|
47
|
+
on(
|
|
48
|
+
linkClicked,
|
|
49
|
+
flow({
|
|
50
|
+
do: async ({ code }, fx) => {
|
|
51
|
+
const [link] = await fx.store(db).select().from(links).where(eq(links.code, code)).limit(1);
|
|
52
|
+
const clicks = await fx.store(db).increment(links, link.id, "clicks");
|
|
53
|
+
await fx.emit(linkStats, { code, clicks }); // live: pushed to subscribers
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
42
57
|
|
|
43
58
|
// ④ CLOCK — "a cron job", and still the same species
|
|
44
|
-
on(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
on(
|
|
60
|
+
every("1h"),
|
|
61
|
+
flow({
|
|
62
|
+
do: (_, fx) => {
|
|
63
|
+
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
64
|
+
return fx.store(db).delete(links).where(lt(links.createdAt, cutoff));
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
50
68
|
|
|
51
69
|
// ⑤ A plain flow with no trigger — callable, not "private"
|
|
52
70
|
export const stats = flow({
|
|
53
|
-
in: LinkCode,
|
|
71
|
+
in: LinkCode,
|
|
72
|
+
out: z.object({ clicks: z.number() }),
|
|
54
73
|
do: async ({ code }, fx) => {
|
|
55
|
-
const [link] = await fx
|
|
56
|
-
.
|
|
74
|
+
const [link] = await fx
|
|
75
|
+
.store(db)
|
|
76
|
+
.select({ clicks: links.clicks })
|
|
77
|
+
.from(links)
|
|
78
|
+
.where(eq(links.code, code))
|
|
79
|
+
.limit(1);
|
|
57
80
|
return link ?? { clicks: 0 };
|
|
58
81
|
},
|
|
59
82
|
});
|
|
@@ -3,11 +3,12 @@ import { z } from "zod";
|
|
|
3
3
|
|
|
4
4
|
export const linkClicked = signal("link-clicked", {
|
|
5
5
|
schema: z.object({ code: z.string(), at: z.number(), referrer: z.string().optional() }),
|
|
6
|
-
delivery: "once",
|
|
7
|
-
retries: 3,
|
|
6
|
+
delivery: "once", // queue physics: one consumer, retries, DLQ
|
|
7
|
+
retries: 3,
|
|
8
|
+
deadLetter: true,
|
|
8
9
|
});
|
|
9
10
|
|
|
10
11
|
export const linkStats = signal("link-stats", {
|
|
11
12
|
schema: z.object({ code: z.string(), clicks: z.number() }),
|
|
12
|
-
delivery: "live",
|
|
13
|
+
delivery: "live", // stream physics: clients subscribe
|
|
13
14
|
});
|
|
@@ -3,6 +3,8 @@ import { gate } from "okengine";
|
|
|
3
3
|
export const member = gate.policy("member", ({ auth }) => !!auth?.verified);
|
|
4
4
|
|
|
5
5
|
export const fair = gate.rate({
|
|
6
|
-
strategy: "sliding-window-counter",
|
|
7
|
-
max: 60,
|
|
6
|
+
strategy: "sliding-window-counter", // near-exact, two keys, no boundary bursts
|
|
7
|
+
max: 60,
|
|
8
|
+
per: "1m",
|
|
9
|
+
keyBy: "ip",
|
|
8
10
|
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { store, field } from "okengine";
|
|
2
|
+
|
|
3
|
+
/** Short links — `code` is the human-facing unique key. */
|
|
4
|
+
export const links = store.schema.table("links", {
|
|
5
|
+
id: field.text().primaryKey(), // `increment` targets this column
|
|
6
|
+
code: field.text().notNull().unique(), // the short, human-facing key
|
|
7
|
+
url: field.text().notNull(),
|
|
8
|
+
userId: field.text().notNull(),
|
|
9
|
+
clicks: field.integer().notNull().default(0),
|
|
10
|
+
createdAt: field.integer().notNull(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** Per-link daily click counts — FK to `links.code`. */
|
|
14
|
+
export const daily = store.schema.table("daily", {
|
|
15
|
+
id: field.text().primaryKey(),
|
|
16
|
+
code: field
|
|
17
|
+
.text()
|
|
18
|
+
.notNull()
|
|
19
|
+
.references(() => links.code),
|
|
20
|
+
day: field.text().notNull(), // "YYYY-MM-DD"
|
|
21
|
+
clicks: field.integer().notNull().default(0),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** links ↔ daily — one link has many daily rows. */
|
|
25
|
+
export const relations = store.schema.relations({ links, daily }, (r) => ({
|
|
26
|
+
links: {
|
|
27
|
+
daily: r.many.daily({
|
|
28
|
+
from: r.links.code,
|
|
29
|
+
to: r.daily.code,
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
daily: {
|
|
33
|
+
link: r.one.links({
|
|
34
|
+
from: r.daily.code,
|
|
35
|
+
to: r.links.code,
|
|
36
|
+
optional: false,
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// generated by oke — do not edit
|
|
2
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
3
|
+
import { defineRelations } from "drizzle-orm";
|
|
4
|
+
|
|
5
|
+
export const links = sqliteTable("links", {
|
|
6
|
+
id: text("id").primaryKey(),
|
|
7
|
+
code: text("code").notNull().unique(),
|
|
8
|
+
url: text("url").notNull(),
|
|
9
|
+
userId: text("user_id").notNull(),
|
|
10
|
+
clicks: integer("clicks").notNull().default(0),
|
|
11
|
+
createdAt: integer("created_at").notNull(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const daily = sqliteTable("daily", {
|
|
15
|
+
id: text("id").primaryKey(),
|
|
16
|
+
code: text("code")
|
|
17
|
+
.notNull()
|
|
18
|
+
.references(() => links.code),
|
|
19
|
+
day: text("day").notNull(),
|
|
20
|
+
clicks: integer("clicks").notNull().default(0),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const relations = defineRelations({ links, daily }, (r) => ({
|
|
24
|
+
links: {
|
|
25
|
+
daily: r.many.daily({ from: r.links.code, to: r.daily.code }),
|
|
26
|
+
},
|
|
27
|
+
daily: {
|
|
28
|
+
link: r.one.links({ from: r.daily.code, to: r.links.code, optional: false }),
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
@@ -1,17 +1,2 @@
|
|
|
1
|
-
import
|
|
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
|
-
});
|
|
1
|
+
/** Stable import surface — re-exports generated Drizzle from abstract decls. */
|
|
2
|
+
export { links, daily, relations } from "./schema.generated";
|
|
@@ -3,17 +3,17 @@ import { createTestApp } from "okengine/test";
|
|
|
3
3
|
import { app } from "../src/app";
|
|
4
4
|
|
|
5
5
|
test("shorten → redirect → report · time travel", async () => {
|
|
6
|
-
const t = await createTestApp(app);
|
|
7
|
-
const u = await t.auth.loginAs({});
|
|
6
|
+
const t = await createTestApp(app); // memory drivers, frozen clock
|
|
7
|
+
const u = await t.auth.loginAs({});
|
|
8
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();
|
|
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
12
|
|
|
13
|
-
const { data } = await t.api.links.report({ code: "sa" }, { as: u });
|
|
14
|
-
expect(data![0].clicks).toBe(1);
|
|
13
|
+
const { data } = await t.api.links.report({ code: "sa" }, { as: u });
|
|
14
|
+
expect(data![0].clicks).toBe(1);
|
|
15
15
|
|
|
16
|
-
await t.clock.advance("31d");
|
|
17
|
-
await t.cron.run("1h");
|
|
16
|
+
await t.clock.advance("31d");
|
|
17
|
+
await t.cron.run("1h"); // time travel
|
|
18
18
|
await t.close();
|
|
19
19
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Environment template — copy to `.env.local` for local overrides.
|
|
2
|
+
# create-oke copies this file to `.env.local` when you scaffold a new project.
|
|
3
|
+
#
|
|
4
|
+
# Resolution order (first hit wins):
|
|
5
|
+
# process.env → .env.local → docker/.env.docker → vault driver → dev fallback
|
|
6
|
+
#
|
|
7
|
+
# --- Infrastructure (optional) ---
|
|
8
|
+
# This app pins postgres for prod SQL in oke.config.ts (local dev uses sqlite).
|
|
9
|
+
# Uncomment only when overriding the prod connection URL.
|
|
10
|
+
|
|
11
|
+
# DATABASE_URL — primary Postgres connection; from your host (Neon, RDS, …)
|
|
12
|
+
# DATABASE_URL=postgres://user:password@host:5432/notes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineConfig } from "drizzle-kit";
|
|
2
|
+
|
|
3
|
+
/** Domain schema sync for `oke db push|generate|migrate`. */
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
dialect: "sqlite",
|
|
6
|
+
schema: "./src/schema.ts",
|
|
7
|
+
out: "./drizzle",
|
|
8
|
+
dbCredentials: {
|
|
9
|
+
url: process.env.OKE_SQLITE_URL ?? "file:.oke/app.sqlite",
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
"version": "0.0.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "bun test",
|
|
8
|
+
"dev": "oke dev"
|
|
9
|
+
},
|
|
6
10
|
"dependencies": {
|
|
7
|
-
"okengine": "file:../..",
|
|
8
11
|
"drizzle-orm": "^1.0.0-rc.4",
|
|
9
12
|
"drizzle-zod": "^0.8.3",
|
|
13
|
+
"okengine": "file:../..",
|
|
10
14
|
"zod": "^4.4.3"
|
|
11
15
|
},
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"dev": "oke dev"
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"drizzle-kit": "^1.0.0-rc.4"
|
|
15
18
|
}
|
|
16
19
|
}
|
|
@@ -5,7 +5,7 @@ import * as notes from "./flows/notes";
|
|
|
5
5
|
|
|
6
6
|
export const app = oke({ name: "notes" }).adopt({ notes });
|
|
7
7
|
|
|
8
|
-
export type App = typeof app;
|
|
8
|
+
export type App = typeof app; // ← the client needs nothing else
|
|
9
9
|
|
|
10
10
|
Object.assign(app.$options, {
|
|
11
11
|
env: "test",
|
|
@@ -5,40 +5,54 @@ import { db } from "../../core";
|
|
|
5
5
|
import { notes } from "../../schema";
|
|
6
6
|
|
|
7
7
|
// Contracts derived from the schema — one source of truth, refined where the API is stricter
|
|
8
|
-
const NewNote
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
const NewNote = createInsertSchema(notes, { title: (s) => s.min(1).max(120) }).omit({
|
|
9
|
+
id: true,
|
|
10
|
+
createdAt: true,
|
|
11
|
+
});
|
|
12
|
+
const Note = createSelectSchema(notes);
|
|
13
|
+
const NoteId = z.object({ id: z.string() });
|
|
12
14
|
const NotFound = z.object({});
|
|
13
15
|
|
|
14
|
-
export const create = on(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
16
|
+
export const create = on(
|
|
17
|
+
http.post("/notes"),
|
|
18
|
+
flow({
|
|
19
|
+
in: NewNote,
|
|
20
|
+
out: NoteId,
|
|
21
|
+
do: async (input, fx) => {
|
|
22
|
+
const [note] = await fx.store(db).insert(notes).values(input).returning();
|
|
23
|
+
return { id: note.id };
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
22
27
|
// effects → writes[sql:notes]
|
|
23
28
|
|
|
24
|
-
export const list = on(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
export const list = on(
|
|
30
|
+
http.get("/notes"),
|
|
31
|
+
flow({
|
|
32
|
+
out: Note.array(),
|
|
33
|
+
do: (_, fx) => fx.store(db).select().from(notes),
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
28
36
|
// effects → reads[sql:notes]
|
|
29
37
|
|
|
30
|
-
export const get = on(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
38
|
+
export const get = on(
|
|
39
|
+
http.get("/notes/:id"),
|
|
40
|
+
flow({
|
|
41
|
+
in: NoteId,
|
|
42
|
+
out: Note,
|
|
43
|
+
errors: { NotFound },
|
|
44
|
+
do: async ({ id }, fx) => (await fx.store(db).findById(notes, id)) ?? fx.fail("NotFound", {}),
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
36
47
|
|
|
37
|
-
export const remove = on(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
48
|
+
export const remove = on(
|
|
49
|
+
http.delete("/notes/:id"),
|
|
50
|
+
flow({
|
|
51
|
+
in: NoteId,
|
|
52
|
+
errors: { NotFound },
|
|
53
|
+
do: async ({ id }, fx) => {
|
|
54
|
+
const deleted = await fx.store(db).delete(notes, id);
|
|
55
|
+
if (!deleted) return fx.fail("NotFound", {});
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
@@ -2,8 +2,8 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
|
2
2
|
import { id, now } from "okengine/store";
|
|
3
3
|
|
|
4
4
|
export const notes = sqliteTable("notes", {
|
|
5
|
-
id:
|
|
6
|
-
title:
|
|
7
|
-
body:
|
|
5
|
+
id: text("id").primaryKey().$defaultFn(id),
|
|
6
|
+
title: text("title").notNull(),
|
|
7
|
+
body: text("body").notNull(),
|
|
8
8
|
createdAt: integer("created_at").notNull().$defaultFn(now),
|
|
9
9
|
});
|
|
@@ -3,7 +3,7 @@ import { createTestApp } from "okengine/test";
|
|
|
3
3
|
import { app } from "../src/app";
|
|
4
4
|
|
|
5
5
|
test("create then read", async () => {
|
|
6
|
-
const t = await createTestApp(app);
|
|
6
|
+
const t = await createTestApp(app); // memory driver, automatic
|
|
7
7
|
const { data } = await t.api.notes.create({ title: "First", body: "Hello" });
|
|
8
8
|
const { data: note } = await t.api.notes.get({ id: data!.id });
|
|
9
9
|
expect(note!.title).toBe("First");
|