okengine 0.2.8 → 0.3.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.
Files changed (180) hide show
  1. package/AGENTS.md +17 -15
  2. package/README.md +59 -31
  3. package/package.json +11 -16
  4. package/site/content/docs/ai/llms-txt.mdx +54 -0
  5. package/site/content/docs/ai/mcp.mdx +123 -0
  6. package/site/content/docs/ai/meta.json +5 -0
  7. package/site/content/docs/ai/skills.mdx +53 -0
  8. package/site/content/docs/console/access.mdx +29 -0
  9. package/site/content/docs/console/ai.mdx +35 -0
  10. package/site/content/docs/console/architecture.mdx +35 -0
  11. package/site/content/docs/console/channels.mdx +37 -0
  12. package/site/content/docs/console/clock.mdx +31 -0
  13. package/site/content/docs/console/flows.mdx +31 -0
  14. package/site/content/docs/console/gates.mdx +35 -0
  15. package/site/content/docs/console/manifest-diff.mdx +34 -0
  16. package/site/content/docs/console/meta.json +23 -0
  17. package/site/content/docs/console/overview.mdx +40 -0
  18. package/site/content/docs/console/plugins.mdx +41 -0
  19. package/site/content/docs/console/privacy.mdx +32 -0
  20. package/site/content/docs/console/runs.mdx +40 -0
  21. package/site/content/docs/console/signals.mdx +31 -0
  22. package/site/content/docs/console/store.mdx +32 -0
  23. package/site/content/docs/console/tenancy.mdx +32 -0
  24. package/site/content/docs/console/traces.mdx +34 -0
  25. package/site/content/docs/console/vault.mdx +37 -0
  26. package/site/content/docs/elements/ai.mdx +180 -0
  27. package/site/content/docs/elements/channel.mdx +167 -0
  28. package/site/content/docs/elements/clock.mdx +182 -0
  29. package/site/content/docs/elements/flow.mdx +288 -0
  30. package/site/content/docs/elements/gate.mdx +171 -0
  31. package/site/content/docs/elements/meta.json +5 -0
  32. package/site/content/docs/elements/signal.mdx +171 -0
  33. package/site/content/docs/elements/store.mdx +320 -0
  34. package/site/content/docs/elements/vault.mdx +263 -0
  35. package/site/content/docs/get-started/basic-usage.mdx +124 -0
  36. package/site/content/docs/get-started/comparison.mdx +65 -0
  37. package/site/content/docs/get-started/installation.mdx +113 -0
  38. package/site/content/docs/get-started/introduction.mdx +123 -0
  39. package/site/content/docs/get-started/meta.json +5 -0
  40. package/site/content/docs/index.mdx +63 -0
  41. package/site/content/docs/meta.json +5 -0
  42. package/site/content/docs/plugins/compression.mdx +60 -0
  43. package/site/content/docs/plugins/cors.mdx +98 -0
  44. package/site/content/docs/plugins/csrf.mdx +96 -0
  45. package/site/content/docs/plugins/ip-allowlist.mdx +93 -0
  46. package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
  47. package/site/content/docs/plugins/meta.json +15 -0
  48. package/site/content/docs/plugins/security-headers.mdx +136 -0
  49. package/site/content/docs/reference/cli.md +101 -0
  50. package/site/content/docs/reference/configuration.mdx +159 -0
  51. package/site/content/docs/reference/environment-variables.mdx +87 -0
  52. package/site/content/docs/reference/errors.mdx +80 -0
  53. package/site/content/docs/reference/fx.mdx +117 -0
  54. package/site/content/docs/reference/meta.json +5 -0
  55. package/site/content/docs/reference/plugins.mdx +249 -0
  56. package/site/content/docs/reference/security.md +63 -0
  57. package/src/auth/auth.test.ts +3 -0
  58. package/src/cli/ask-dev-mode.ts +1 -1
  59. package/src/cli/db.ts +87 -17
  60. package/src/cli/dev-db-push.test.ts +32 -2
  61. package/src/cli/dev-schema-sync.test.ts +66 -0
  62. package/src/cli/dev-schema-sync.ts +139 -0
  63. package/src/cli/dev.test.ts +123 -1
  64. package/src/cli/dev.ts +150 -8
  65. package/src/cli/doc-staleness.test.ts +4 -4
  66. package/src/cli/docker-cli.test.ts +20 -0
  67. package/src/cli/docker.ts +10 -0
  68. package/src/cli/drizzle-env.test.ts +67 -0
  69. package/src/cli/drizzle-env.ts +78 -0
  70. package/src/cli/ensure-drizzle-config.ts +50 -0
  71. package/src/cli/hero-meta.test.ts +1 -1
  72. package/src/cli/load-config.ts +6 -0
  73. package/src/cli/mode.ts +24 -4
  74. package/src/cli/openbao-bootstrap.test.ts +149 -0
  75. package/src/cli/openbao-bootstrap.ts +314 -0
  76. package/src/cli/openbao-restart.integration.test.ts +136 -0
  77. package/src/cli/ports.test.ts +7 -5
  78. package/src/cli/ports.ts +6 -2
  79. package/src/cli/resolve-dev-sql-env.test.ts +48 -0
  80. package/src/cli/resolve-dev-sql-env.ts +42 -0
  81. package/src/cli/stack.ts +7 -4
  82. package/src/cli/vault-cmd.ts +63 -0
  83. package/src/client/types.ts +7 -1
  84. package/src/compiler/extract.test.ts +40 -0
  85. package/src/compiler/extract.ts +123 -1
  86. package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
  87. package/src/compiler/fixtures/skyport.expected.json +1 -1
  88. package/src/compiler/response.ts +12 -0
  89. package/src/config/define-config.test.ts +4 -6
  90. package/src/config/index.ts +2 -15
  91. package/src/console/server/app.ts +2 -0
  92. package/src/console/server/vault.ts +21 -6
  93. package/src/docker/compose.ts +112 -16
  94. package/src/docker/derive.ts +7 -1
  95. package/src/docker/docker.test.ts +103 -0
  96. package/src/docker/index.ts +11 -1
  97. package/src/docker/recipes/index.ts +3 -2
  98. package/src/docker/recipes/openbao.ts +47 -0
  99. package/src/docker/recipes/redis.ts +5 -1
  100. package/src/docker/recipes/rustfs.ts +2 -3
  101. package/src/docker/stack-id.test.ts +43 -8
  102. package/src/docker/stack-id.ts +99 -20
  103. package/src/docker/stack.ts +36 -4
  104. package/src/docker/types.ts +3 -0
  105. package/src/docs-origin.ts +4 -4
  106. package/src/drivers/drizzle-dialect.test.ts +20 -0
  107. package/src/drivers/drizzle-dialect.ts +37 -0
  108. package/src/drivers/index.ts +1 -2
  109. package/src/drivers/memory.ts +278 -39
  110. package/src/drivers/s3.ts +10 -1
  111. package/src/drivers/vault-driver-removal.test.ts +55 -0
  112. package/src/drivers/vault-openbao.test.ts +97 -0
  113. package/src/drivers/vault-openbao.ts +102 -35
  114. package/src/drivers/vault-types.ts +3 -10
  115. package/src/elements/store/declare.ts +4 -1
  116. package/src/elements/store/resource-list-docs.fixture.ts +56 -0
  117. package/src/elements/store/resource-list-docs.test.ts +79 -0
  118. package/src/elements/store/resource.test.ts +253 -0
  119. package/src/elements/store/resource.ts +786 -0
  120. package/src/elements/store/sql-condition.test.ts +132 -0
  121. package/src/elements/store/sql-condition.ts +284 -46
  122. package/src/elements/store/sql-session.test.ts +86 -1
  123. package/src/elements/store/sql-session.ts +187 -27
  124. package/src/elements/store/table.ts +34 -4
  125. package/src/elements/store.ts +16 -0
  126. package/src/elements/vault/runtime.ts +1 -1
  127. package/src/elements/vault.test.ts +1 -28
  128. package/src/elements/vault.ts +1 -1
  129. package/src/kernel/app.ts +59 -25
  130. package/src/kernel/boot-bind/channel.test.ts +60 -0
  131. package/src/kernel/boot-bind/channel.ts +64 -2
  132. package/src/kernel/boot-bind/store.test.ts +10 -1
  133. package/src/kernel/boot-bind/store.ts +49 -2
  134. package/src/kernel/boot.test.ts +0 -1
  135. package/src/kernel/boot.ts +1 -1
  136. package/src/kernel/edge.test.ts +68 -0
  137. package/src/kernel/errors.registry.test.ts +1 -1
  138. package/src/kernel/flow.ts +8 -0
  139. package/src/kernel/fx.test.ts +23 -3
  140. package/src/kernel/fx.ts +115 -18
  141. package/src/kernel/hooks.test.ts +33 -0
  142. package/src/kernel/hooks.ts +22 -0
  143. package/src/kernel/index.ts +7 -0
  144. package/src/kernel/on.ts +44 -3
  145. package/src/kernel/plugin.ts +33 -3
  146. package/src/kernel/registry-isolation.test.ts +74 -0
  147. package/src/kernel/registry.ts +22 -1
  148. package/src/kernel/triggers.ts +59 -0
  149. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  150. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  151. package/src/manifest/index.ts +1 -1
  152. package/src/manifest/types.ts +1 -1
  153. package/src/manifest/validate.ts +2 -2
  154. package/src/plugins/compression.test.ts +127 -0
  155. package/src/plugins/compression.ts +94 -0
  156. package/src/plugins/config-source.test.ts +204 -0
  157. package/src/plugins/config-source.ts +209 -0
  158. package/src/plugins/cors.test.ts +172 -0
  159. package/src/plugins/cors.ts +153 -0
  160. package/src/plugins/csrf.test.ts +102 -0
  161. package/src/plugins/csrf.ts +86 -0
  162. package/src/plugins/headers.ts +54 -0
  163. package/src/plugins/index.ts +26 -0
  164. package/src/plugins/ip-allowlist.test.ts +139 -0
  165. package/src/plugins/ip-allowlist.ts +129 -0
  166. package/src/plugins/maintenance-mode.test.ts +91 -0
  167. package/src/plugins/maintenance-mode.ts +85 -0
  168. package/src/plugins/security-headers.test.ts +243 -0
  169. package/src/plugins/security-headers.ts +255 -0
  170. package/src/release/measure.ts +1 -2
  171. package/src/test/create-test-app.ts +14 -2
  172. package/docs/spec/console.md +0 -762
  173. package/docs/spec/example.md +0 -1374
  174. package/docs/spec/four-applications.md +0 -1376
  175. package/docs/spec/unified-theory.md +0 -498
  176. package/src/cli/doc-drift.test.ts +0 -147
  177. package/src/cli/doc-drift.ts +0 -401
  178. package/src/cli/doctor-diff-examples.ts +0 -90
  179. package/src/drivers/vault-sops.ts +0 -246
  180. /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
@@ -1,1374 +0,0 @@
1
- # OKE — Four Applications
2
-
3
- ### A progressive path from one flow to a full system
4
-
5
- **Package:** `okengine` · **CLI:** `oke`
6
-
7
- Four complete, runnable applications. Each one introduces the smallest possible set of new ideas, and each ends by naming the limitation that motivates the next. Read them in order and the eight elements arrive one or two at a time instead of all at once.
8
-
9
- | | App | Teaches | Elements | Exports | Files |
10
- | -------------------- | ---------- | ------------------------------------------------------------------ | ----------------------- | ------- | ----- |
11
- | **1 · Basic** | Notes | the one law · contracts · typed errors · the client | Flow · Store | 4 / 10 | 5 |
12
- | **2 · Intermediate** | Linkly | one species, many triggers · delivery physics · transactional emit | + Signal · Clock · Gate | 7 / 10 | 11 |
13
- | **3 · Advanced** | Provisions | durability · reaching humans · live queries · plugins | + Vault · Channel | 10 / 10 | 18 |
14
- | **4 · Complex** | Skyport | AI and agents · tenancy · SLOs · distributed topology | all eight | all ten | 24 |
15
-
16
- **The ten exports:** `on · flow · signal · store · clock · gate · vault · channel · ai · plugin`
17
- **The one law:** `on(Trigger) → Effects`
18
-
19
- ---
20
-
21
- ---
22
-
23
- # 1 · BASIC — Notes
24
-
25
- **New ideas:** `oke`, `on`, `flow`, `http`, `store.sql`, `fx`, typed errors, the typed client.
26
- **Time to running:** about two minutes.
27
-
28
- ```
29
- notes/
30
- ├── oke.config.ts
31
- ├── src/
32
- │ ├── app.ts
33
- │ ├── core.ts
34
- │ ├── schema.ts
35
- │ └── flows/notes/index.ts
36
- └── tests/notes.test.ts
37
- ```
38
-
39
- Five files. Contracts live beside the flows that use them — a separate `shapes.ts` arrives in the next app, at the size where it starts to help.
40
-
41
- ### `oke.config.ts`
42
-
43
- ```typescript
44
- import { defineConfig } from "okengine/config";
45
-
46
- export default defineConfig({
47
- drivers: {
48
- // `stack` = `oke dev -s` (local server). Omitting it copies `prod`
49
- // (defineConfig); vault `sops` becomes docker `dotenv` for `docker/.env.docker`.
50
- store: {
51
- sql: {
52
- local: "sqlite",
53
- docker: "postgres",
54
- test: "memory",
55
- prod: "postgres",
56
- },
57
- },
58
- },
59
- });
60
- ```
61
-
62
- That is the whole configuration. Drivers are named after **protocols**, so `postgres` covers Postgres, Neon, Supabase and RDS alike.
63
-
64
- ### `src/schema.ts`
65
-
66
- ```typescript
67
- import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
68
- import { id, now } from "okengine/store";
69
-
70
- export const notes = sqliteTable("notes", {
71
- id: text("id").primaryKey().$defaultFn(id),
72
- title: text("title").notNull(),
73
- body: text("body").notNull(),
74
- createdAt: integer("created_at").notNull().$defaultFn(now),
75
- });
76
- ```
77
-
78
- **Defaults belong in the schema.** `$defaultFn(id)` means no handler ever writes id-generation boilerplate. (`fx.id()` still exists and is required in one specific case — see the Store reference at the end.)
79
-
80
- Drizzle is a **required peer dependency** — never bundled, always your version, and your schema file is yours. The framework commits to Drizzle rather than abstracting over ORMs, for a reason that is architectural rather than aesthetic; the Store reference explains it.
81
-
82
- ### `src/core.ts`
83
-
84
- ```typescript
85
- import { store } from "okengine";
86
- import * as schema from "./schema";
87
-
88
- export const db = store.sql("notes", { schema });
89
- ```
90
-
91
- ### `src/flows/notes/index.ts`
92
-
93
- ```typescript
94
- import { on, flow, http } from "okengine";
95
- import { createInsertSchema, createSelectSchema } from "drizzle-zod";
96
- import { z } from "zod";
97
- import { db } from "../../core";
98
- import { notes } from "../../schema";
99
-
100
- // Contracts derived from the schema — one source of truth, refined where the API is stricter
101
- const NewNote = createInsertSchema(notes, { title: (s) => s.min(1).max(120) }).omit({
102
- id: true,
103
- createdAt: true,
104
- });
105
- const Note = createSelectSchema(notes);
106
- const NoteId = z.object({ id: z.string() });
107
- const NotFound = z.object({});
108
-
109
- export const create = on(
110
- http.post("/notes"),
111
- flow({
112
- in: NewNote,
113
- out: NoteId,
114
- do: async (input, fx) => {
115
- const [note] = await fx.store(db).insert(notes).values(input).returning();
116
- return { id: note.id };
117
- },
118
- }),
119
- );
120
- // effects → writes[sql:notes]
121
-
122
- export const list = on(
123
- http.get("/notes"),
124
- flow({
125
- out: Note.array(),
126
- do: (_, fx) => fx.store(db).select().from(notes),
127
- }),
128
- );
129
- // effects → reads[sql:notes]
130
-
131
- export const get = on(
132
- http.get("/notes/:id"),
133
- flow({
134
- in: NoteId,
135
- out: Note,
136
- errors: { NotFound },
137
- do: async ({ id }, fx) => (await fx.store(db).findById(notes, id)) ?? fx.fail("NotFound", {}),
138
- }),
139
- );
140
-
141
- export const remove = on(
142
- http.delete("/notes/:id"),
143
- flow({
144
- in: NoteId,
145
- errors: { NotFound },
146
- do: async ({ id }, fx) => {
147
- const deleted = await fx.store(db).delete(notes, id);
148
- if (!deleted) return fx.fail("NotFound", {});
149
- },
150
- }),
151
- );
152
- ```
153
-
154
- **Four things to notice.**
155
-
156
- `fx` is the only door to the outside world. Every read and every write passes through it — which is what lets the framework know that `create` writes `notes` and `list` reads it, with no annotation from you.
157
-
158
- **Contracts are derived, not retyped.** `drizzle-zod` turns the table into request and response schemas, refined where the API should be stricter than storage. When the two genuinely diverge — internal columns, computed responses, a different input shape — write the schema by hand instead. Derive when they agree; hand-write when they don't.
159
-
160
- **Errors are values, not exceptions.** `fx.fail("NotFound", {})` returns a typed error the client will narrow on. There is no `throw` and no `catch (e: any)`.
161
-
162
- **No cache configuration appears anywhere.** `list` is cached automatically, and invalidated by exactly the writes that touch the rows it read — because the compiler knows both.
163
-
164
- ### `src/app.ts`
165
-
166
- ```typescript
167
- import { oke } from "okengine";
168
- import * as notes from "./flows/notes";
169
-
170
- export const app = oke({ name: "notes" }).adopt({ notes });
171
-
172
- export type App = typeof app; // ← the client needs nothing else
173
- ```
174
-
175
- `on()` still registers each flow with the router and the Manifest — `.adopt()` exists so the type of `app` accumulates every contract in `notes`, which is what lets the client below need no hand-written types and no separate codegen step. The namespace key (`notes`) becomes the client's namespace; each export becomes a method.
176
-
177
- ### The client
178
-
179
- ```typescript
180
- import { createClient } from "okengine/client";
181
- import type { App } from "../src/app";
182
- import { app } from "../src/app";
183
-
184
- const api = createClient<App>("http://localhost:6530", { $routes: app.$routes });
185
- // equivalently: const api = createClient(app, "http://localhost:6530");
186
-
187
- const { data, error } = await api.notes.get({ id: "n_1" });
188
-
189
- if (error?.code === "NotFound") show("gone");
190
- else console.log(data.title); // ← typed, no codegen ✅
191
- // GET /notes/n_1 — the method and path are derived from the flow's own trigger,
192
- // not from a separate RPC convention.
193
- ```
194
-
195
- ### `tests/notes.test.ts`
196
-
197
- ```typescript
198
- import { test, expect } from "bun:test";
199
- import { createTestApp } from "okengine/test";
200
- import { app } from "../src/app";
201
-
202
- test("create then read", async () => {
203
- const t = await createTestApp(app); // memory driver, automatic
204
- const { data } = await t.api.notes.create({ title: "First", body: "Hello" });
205
- const { data: note } = await t.api.notes.get({ id: data!.id });
206
- expect(note!.title).toBe("First");
207
- });
208
- ```
209
-
210
- ### Run it
211
-
212
- ```bash
213
- bun add okengine
214
- oke dev # app :6530 · Console :6533 · MCP :6535
215
- bun test
216
- ```
217
-
218
- Open `:6533` and the Console already shows the four flows, their contracts, their effects, and a live architecture diagram — derived, not configured.
219
-
220
- ### What you have
221
-
222
- Four exports (`oke`, `on`, `flow`, `store`), two elements, a typed client, automatic caching, and a Console.
223
-
224
- ### What is missing
225
-
226
- Everything here is synchronous. A real application needs work that happens _later_ — after the response, on a schedule, or in reaction to something. That is the next app.
227
-
228
- ---
229
-
230
- ---
231
-
232
- # 2 · INTERMEDIATE — Linkly
233
-
234
- A URL shortener that counts clicks.
235
-
236
- **New ideas:** `signal` and its three delivery physics · `clock` · `gate` · triggers beyond HTTP · transactional emit · cross-unit decoupling.
237
-
238
- ```
239
- linkly/
240
- ├── oke.config.ts
241
- ├── src/
242
- │ ├── app.ts
243
- │ ├── core.ts
244
- │ ├── gates.ts
245
- │ ├── schema.decl.ts
246
- │ ├── schema.generated.ts
247
- │ ├── schema.ts
248
- │ └── flows/
249
- │ ├── links/
250
- │ │ ├── index.ts
251
- │ │ ├── shapes.ts
252
- │ │ └── signals.ts
253
- │ └── analytics/index.ts
254
- └── tests/linkly.test.ts
255
- ```
256
-
257
- ### `oke.config.ts`
258
-
259
- ```typescript
260
- import { defineConfig } from "okengine/config";
261
-
262
- export default defineConfig({
263
- drivers: {
264
- store: {
265
- sql: { local: "sqlite", test: "memory", prod: "postgres" },
266
- kv: { local: "memory", test: "memory", prod: "redis" },
267
- },
268
- signal: { local: "memory", test: "memory", prod: "postgres" },
269
- clock: { local: "memory", test: "frozen", prod: "postgres" },
270
- },
271
- });
272
- ```
273
-
274
- `signal` defaults to `postgres`, and the reason is correctness rather than throughput — see the note after `redirect` below.
275
-
276
- ### `src/gates.ts`
277
-
278
- ```typescript
279
- import { gate } from "okengine";
280
-
281
- export const member = gate.policy("member", ({ auth }) => !!auth?.verified);
282
-
283
- export const fair = gate.rate({
284
- strategy: "sliding-window-counter", // near-exact, two keys, no boundary bursts
285
- max: 60,
286
- per: "1m",
287
- keyBy: "ip",
288
- });
289
- ```
290
-
291
- Five strategies exist (`fixed-window`, `sliding-log`, `token-bucket`, `leaky-bucket`); this one is the default because it has the best accuracy-to-cost ratio.
292
-
293
- ### `src/schema.decl.ts`
294
-
295
- ```typescript
296
- import { store, field } from "okengine";
297
-
298
- /** Short links — `code` is the human-facing unique key. */
299
- export const links = store.schema.table("links", {
300
- id: field.text().primaryKey(), // `increment` targets this column
301
- code: field.text().notNull().unique(), // the short, human-facing key
302
- url: field.text().notNull(),
303
- userId: field.text().notNull(),
304
- clicks: field.integer().notNull().default(0),
305
- createdAt: field.integer().notNull(),
306
- });
307
-
308
- /** Per-link daily click counts — FK to `links.code`. */
309
- export const daily = store.schema.table("daily", {
310
- id: field.text().primaryKey(),
311
- code: field
312
- .text()
313
- .notNull()
314
- .references(() => links.code),
315
- day: field.text().notNull(), // "YYYY-MM-DD"
316
- clicks: field.integer().notNull().default(0),
317
- });
318
-
319
- /** links ↔ daily — one link has many daily rows. */
320
- export const relations = store.schema.relations({ links, daily }, (r) => ({
321
- links: {
322
- daily: r.many.daily({
323
- from: r.links.code,
324
- to: r.daily.code,
325
- }),
326
- },
327
- daily: {
328
- link: r.one.links({
329
- from: r.daily.code,
330
- to: r.links.code,
331
- optional: false,
332
- }),
333
- },
334
- }));
335
- ```
336
-
337
- A generated `id` plus a separate unique `code` is the standard shape for a shortener: `increment` — see below — is a store-level primitive that targets a row by its primary key, so every table it touches needs one. `daily.code` references `links.code`; `store.schema.relations` mirrors Drizzle’s `defineRelations`.
338
-
339
- ### `src/flows/links/signals.ts`
340
-
341
- ```typescript
342
- import { signal } from "okengine";
343
- import { z } from "zod";
344
-
345
- export const linkClicked = signal("link-clicked", {
346
- schema: z.object({ code: z.string(), at: z.number(), referrer: z.string().optional() }),
347
- delivery: "once", // queue physics: one consumer, retries, DLQ
348
- retries: 3,
349
- deadLetter: true,
350
- });
351
-
352
- export const linkStats = signal("link-stats", {
353
- schema: z.object({ code: z.string(), clicks: z.number() }),
354
- delivery: "live", // stream physics: clients subscribe
355
- });
356
- ```
357
-
358
- **`delivery` is mandatory with no default.** Queue, pub/sub and stream were always the same object with different delivery physics, so physics is an option — but choosing it is a semantic decision and guessing it produces silent, expensive bugs.
359
-
360
- ### `src/flows/links/index.ts`
361
-
362
- ```typescript
363
- import { on, flow, http, every } from "okengine";
364
- import { eq, lt } from "drizzle-orm";
365
- import { db } from "../../core";
366
- import { member, fair } from "../../gates";
367
- import { linkClicked, linkStats } from "./signals";
368
- import { NewLink, LinkCode, Link, NotFound, Taken } from "./shapes";
369
- import { links } from "../../schema";
370
-
371
- // ① HTTP — "an endpoint"
372
- export const shorten = on(
373
- http.post("/links").gate(member, fair),
374
- flow({
375
- in: NewLink,
376
- out: LinkCode,
377
- errors: { Taken },
378
- do: async ({ url, code }, fx) => {
379
- if (await fx.store(db).exists(links, { code })) return fx.fail("Taken", {});
380
- const id = fx.id();
381
- await fx
382
- .store(db)
383
- .insert(links)
384
- .values({ id, code, url, userId: fx.auth.userId, clicks: 0, createdAt: Date.now() });
385
- return { code };
386
- },
387
- }),
388
- );
389
-
390
- // ② HTTP — the hot path
391
- export const redirect = on(
392
- http.get("/:code").gate(fair),
393
- flow({
394
- in: LinkCode,
395
- out: Link,
396
- errors: { NotFound },
397
- do: async ({ code }, fx) => {
398
- const [link] = await fx.store(db).select().from(links).where(eq(links.code, code)).limit(1);
399
- if (!link) return fx.fail("NotFound", {});
400
-
401
- await fx.emit(linkClicked, { code, at: Date.now() }); // same transaction as any write
402
- return link;
403
- },
404
- }),
405
- );
406
-
407
- // ③ SIGNAL — "a queue consumer", and the same species as ① and ②
408
- on(
409
- linkClicked,
410
- flow({
411
- do: async ({ code }, fx) => {
412
- const [link] = await fx.store(db).select().from(links).where(eq(links.code, code)).limit(1);
413
- const clicks = await fx.store(db).increment(links, link.id, "clicks");
414
- await fx.emit(linkStats, { code, clicks }); // live: pushed to subscribers
415
- },
416
- }),
417
- );
418
-
419
- // ④ CLOCK — "a cron job", and still the same species
420
- on(
421
- every("1h"),
422
- flow({
423
- do: (_, fx) => {
424
- const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
425
- return fx.store(db).delete(links).where(lt(links.createdAt, cutoff));
426
- },
427
- }),
428
- );
429
-
430
- // ⑤ A plain flow with no trigger — callable, not "private"
431
- export const stats = flow({
432
- in: LinkCode,
433
- out: z.object({ clicks: z.number() }),
434
- do: async ({ code }, fx) => {
435
- const [link] = await fx
436
- .store(db)
437
- .select({ clicks: links.clicks })
438
- .from(links)
439
- .where(eq(links.code, code))
440
- .limit(1);
441
- return link ?? { clicks: 0 };
442
- },
443
- });
444
- ```
445
-
446
- **Why `emit` inside the transaction matters.** The dual-write bug is the most common distributed-systems mistake: you write the record, then publish the message; a crash between them loses the message, or a rollback after publishing sends mail about something that does not exist. On the Postgres driver `fx.emit` enrols in the same transaction as `fx.store` writes, automatically. When you later switch to `redis` or `nats` for throughput, the driver keeps an outbox relay internally, so the guarantee does not regress — the upgrade is purely about speed.
447
-
448
- ### `src/flows/analytics/index.ts`
449
-
450
- ```typescript
451
- import { on, flow, http } from "okengine";
452
- import { z } from "zod";
453
- import { eq, and } from "drizzle-orm";
454
- import { linkClicked } from "../links/signals";
455
- import { db } from "../../core";
456
- import { member } from "../../gates";
457
- import { daily } from "../../schema";
458
-
459
- on(
460
- linkClicked,
461
- flow({
462
- // a second consumer of the same signal
463
- do: async ({ code, at }, fx) => {
464
- const day = new Date(at).toISOString().slice(0, 10);
465
- const [row] = await fx
466
- .store(db)
467
- .select()
468
- .from(daily)
469
- .where(and(eq(daily.code, code), eq(daily.day, day)))
470
- .limit(1);
471
-
472
- if (row) await fx.store(db).increment(daily, row.id, "clicks");
473
- else await fx.store(db).insert(daily).values({ id: fx.id(), code, day, clicks: 1 });
474
- },
475
- }),
476
- );
477
-
478
- export const report = on(
479
- http.get("/links/:code/report").gate(member),
480
- flow({
481
- out: z.array(z.object({ day: z.string(), clicks: z.number() })),
482
- do: ({ code }, fx) =>
483
- fx
484
- .store(db)
485
- .select({ day: daily.day, clicks: daily.clicks })
486
- .from(daily)
487
- .where(eq(daily.code, code)),
488
- }),
489
- );
490
- ```
491
-
492
- This unit never imports anything from `links` except the signal declaration. Decoupling is structural, not a discipline.
493
-
494
- ### `src/app.ts`
495
-
496
- ```typescript
497
- import { oke } from "okengine";
498
- import * as links from "./flows/links";
499
- import * as analytics from "./flows/analytics";
500
-
501
- export const app = oke({ name: "linkly" }).adopt({ links, analytics });
502
-
503
- export type App = typeof app;
504
- ```
505
-
506
- ### The client — realtime with no realtime code
507
-
508
- ```typescript
509
- const { data, error } = await api.links.shorten({ url: "https://example.com", code: "sa" });
510
- if (error?.code === "Taken") suggestAnother();
511
-
512
- api.signals.linkStats.subscribe(({ code, clicks }) => paint(code, clicks));
513
- ```
514
-
515
- ### `tests/linkly.test.ts`
516
-
517
- ```typescript
518
- const t = await createTestApp(app); // memory drivers, frozen clock
519
- const u = await t.auth.loginAs({});
520
-
521
- await t.api.links.shorten({ url: "https://example.com", code: "sa" }, { as: u });
522
- await t.api.links.redirect({ code: "sa" });
523
- await t.signals.drain(); // run queued work deterministically
524
-
525
- const { data } = await t.api.links.report({ code: "sa" }, { as: u });
526
- expect(data![0].clicks).toBe(1);
527
-
528
- await t.clock.advance("31d");
529
- await t.cron.run("1h"); // time travel
530
- ```
531
-
532
- ### What you have
533
-
534
- Seven exports, five elements, and five different trigger kinds — all of them the same `flow` object. There is no separate API for endpoints, consumers, cron jobs or internal functions.
535
-
536
- ### What is missing
537
-
538
- Nothing here survives a deploy. A payment that must wait two minutes for confirmation, an email that must actually reach a person, an order page that updates itself — none of that is expressible yet.
539
-
540
- ---
541
-
542
- ---
543
-
544
- # 3 · ADVANCED — Provisions
545
-
546
- A subscription store: orders, payments, notifications.
547
-
548
- **New ideas:** `durable` flows and the journal · `vault` · `channel` with fallback chains and i18n · live queries · plugins · a CDC trigger · the three cache tiers.
549
-
550
- ```
551
- provisions/
552
- ├── oke.config.ts
553
- ├── src/
554
- │ ├── app.ts
555
- │ ├── core.ts # the primary database
556
- │ ├── gates.ts # shared gates
557
- │ ├── vault.ts # every secret contract, one auditable file
558
- │ ├── channels.ts # how we reach humans
559
- │ ├── locales/{en,ar}.ts
560
- │ ├── plugins/audit.ts
561
- │ ├── schema.ts
562
- │ └── flows/
563
- │ ├── orders/{index.ts,shapes.ts,signals.ts}
564
- │ ├── payments/{index.ts,shapes.ts}
565
- │ └── notifications/index.ts
566
- └── tests/orders.test.ts
567
- ```
568
-
569
- **The layout rule from here on: whoever _produces_ an element declares it; consumers import it.** Shared concerns (the database, shared gates, secrets, channels) sit at the root. The framework never forces this — the Manifest is built from the import graph — but the tree teaches the vocabulary.
570
-
571
- ### `src/schema.ts`
572
-
573
- ```typescript
574
- import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
575
-
576
- export const products = sqliteTable("products", {
577
- sku: text("sku").primaryKey(),
578
- name: text("name").notNull(),
579
- stock: integer("stock").notNull().default(0),
580
- });
581
-
582
- export const orders = sqliteTable("orders", {
583
- id: text("id").primaryKey(),
584
- userId: text("user_id").notNull(),
585
- sku: text("sku").notNull(),
586
- qty: integer("qty").notNull(),
587
- status: text("status").notNull().default("pending"),
588
- createdAt: integer("created_at").notNull(),
589
- });
590
- ```
591
-
592
- ### `src/vault.ts`
593
-
594
- ```typescript
595
- import { vault } from "okengine";
596
- import { z } from "zod";
597
-
598
- // A declaration is a CONTRACT, not a value.
599
- // Resolution: process.env → .env.local → docker/.env.docker → vault driver → dev fallback
600
- export const stripeKey = vault.secret("STRIPE_KEY", {
601
- schema: z.string().startsWith("sk_"),
602
- description: "Payments gateway key",
603
- rotate: "90d",
604
- dev: "sk_test_local",
605
- });
606
-
607
- export const dbUrl = vault.secret("DATABASE_URL", {
608
- schema: z.string().url(),
609
- dev: vault.fromDocker("store.sql"), // generated by `oke dev --docker` — zero manual setup
610
- });
611
- ```
612
-
613
- Missing or invalid at boot, `oke doctor` lists **all** of them at once with their descriptions — before a single request is served. Values are never readable from the Console; only fingerprints are shown.
614
-
615
- ### `src/channels.ts`
616
-
617
- ```typescript
618
- import { channel } from "okengine";
619
- import { z } from "zod";
620
-
621
- export const mail = channel.email({ from: "Provisions <no-reply@provisions.sa>" });
622
- export const sms = channel.sms({ sender: "PROVISIONS" });
623
- export const wa = channel.whatsapp();
624
-
625
- export const orderConfirmed = mail.template("order-confirmed", {
626
- schema: z.object({ name: z.string(), orderId: z.string(), total: z.number() }),
627
- });
628
-
629
- export const otpCode = channel.template("otp-code", {
630
- // medium-agnostic
631
- schema: z.object({ code: z.string() }),
632
- });
633
- ```
634
-
635
- Recipient address, language and opt-out consent are resolved from the user automatically. In development the `console` driver puts every medium into a built-in inbox instead of sending.
636
-
637
- ### `src/flows/orders/index.ts`
638
-
639
- ```typescript
640
- import { on, flow, gate, http } from "okengine";
641
- import { eq } from "drizzle-orm";
642
- import { db } from "../../core";
643
- import { member } from "../../gates";
644
- import { orderPlaced, orderNews } from "./signals";
645
- import { chargeOrder } from "../payments";
646
- import { NewOrder, OrderId, OrderRow, OutOfStock } from "./shapes";
647
- import { orders, products } from "../../schema";
648
-
649
- const canOrder = gate.policy("order:create", ({ auth }) => auth.scopes.has("order:create"));
650
-
651
- export const create = on(
652
- http.post("/orders").gate(member, canOrder),
653
- flow({
654
- in: NewOrder,
655
- out: OrderId,
656
- errors: { OutOfStock },
657
- do: async (input, fx) => {
658
- const [product] = await fx
659
- .store(db)
660
- .select({ stock: products.stock })
661
- .from(products)
662
- .where(eq(products.sku, input.sku))
663
- .limit(1);
664
- if (!product || product.stock < input.qty)
665
- return fx.fail(
666
- "OutOfStock",
667
- { left: product?.stock ?? 0 },
668
- { message: fx.t("order.outOfStock", { left: product?.stock ?? 0 }) },
669
- );
670
-
671
- const id = fx.id();
672
- await fx
673
- .store(db)
674
- .insert(orders)
675
- .values({ id, userId: fx.auth.userId, ...input, status: "pending", createdAt: Date.now() });
676
- await fx.emit(orderPlaced, { orderId: id });
677
- return { id };
678
- },
679
- }),
680
- );
681
-
682
- // LIVE QUERY — realtime and auto-caching from one flag
683
- export const mine = on(
684
- http.get("/orders").gate(member).live(),
685
- flow({
686
- out: OrderRow.array(),
687
- do: (_, fx) => fx.store(db).select().from(orders).where(eq(orders.userId, fx.auth.userId)),
688
- }),
689
- );
690
-
691
- export const getOrder = flow({
692
- in: OrderId,
693
- out: OrderRow,
694
- do: async ({ id }, fx) => {
695
- const [order] = await fx.store(db).select().from(orders).where(eq(orders.id, id)).limit(1);
696
- return order;
697
- },
698
- });
699
-
700
- // SIGNAL consumer
701
- on(
702
- orderPlaced,
703
- flow({
704
- do: async ({ orderId }, fx) => {
705
- const paid = await fx.call(chargeOrder, { orderId });
706
- await fx
707
- .store(db)
708
- .update(orders)
709
- .set({ status: paid ? "confirmed" : "failed" })
710
- .where(eq(orders.id, orderId));
711
- await fx.emit(orderNews, { orderId, status: paid ? "confirmed" : "failed" });
712
- },
713
- }),
714
- );
715
-
716
- // CHANGE trigger — CDC, built in
717
- on(
718
- db.table(orders).changed("status"),
719
- flow({
720
- do: ({ before, after }, fx) => fx.log.info("status", { from: before.status, to: after.status }),
721
- }),
722
- );
723
- ```
724
-
725
- **`.live()` is the whole of realtime.** The result is cached, invalidated by exactly the writes that touch those rows, and pushed to subscribed clients on exactly those writes. No cache code, no socket code.
726
-
727
- ### `src/flows/payments/index.ts` — durability is a flag
728
-
729
- ```typescript
730
- import { flow } from "okengine";
731
- import { z } from "zod";
732
- import { stripeKey } from "../../vault";
733
- import { OrderRef } from "./shapes";
734
-
735
- export const chargeOrder = flow({
736
- durable: true, // every fx call below is journaled
737
- in: OrderRef,
738
- out: z.boolean(),
739
- do: async ({ orderId }, fx) => {
740
- const intent = await fx.step("create-intent", () =>
741
- // never re-runs on replay
742
- stripe(fx.vault(stripeKey)).create(orderId),
743
- );
744
-
745
- await fx.clock.sleep("verify-window", "2m"); // survives restart and deploy
746
-
747
- return fx.step("confirm", () => stripe(fx.vault(stripeKey)).confirm(intent));
748
- },
749
- });
750
- ```
751
-
752
- **Workflows are not a separate API.** They are ordinary flows with one option. A process killed between the two steps resumes at `confirm` — the card is not charged twice.
753
-
754
- ### `src/flows/notifications/index.ts` — reaching humans
755
-
756
- ```typescript
757
- import { on, flow } from "okengine";
758
- import { z } from "zod";
759
- import { orderNews } from "../orders/signals";
760
- import { getOrder } from "../orders";
761
- import { orderConfirmed, otpCode, wa, sms } from "../../channels";
762
-
763
- on(
764
- orderNews,
765
- flow({
766
- do: async ({ orderId, status }, fx) => {
767
- if (status !== "confirmed") return;
768
- const o = await fx.call(getOrder, { id: orderId });
769
- await fx.send(orderConfirmed, {
770
- to: o.userId,
771
- data: { name: o.userName, orderId, total: o.total },
772
- });
773
- },
774
- }),
775
- );
776
-
777
- export const sendOtp = flow({
778
- in: z.object({ userId: z.string(), code: z.string() }),
779
- do: ({ userId, code }, fx) => fx.send(otpCode, { to: userId, via: [wa, sms], data: { code } }),
780
- // ↑ fallback chain: WhatsApp, else SMS
781
- });
782
- ```
783
-
784
- Fallback is recorded as a **chain**, not an outcome — so the Console can tell you that 23% of OTPs fell back to SMS this week and what that cost.
785
-
786
- ### `src/plugins/audit.ts` — every extension point in one file
787
-
788
- ```typescript
789
- import { plugin, store } from "okengine";
790
- import { z } from "zod";
791
-
792
- export const audit = plugin("audit", { version: "1.0.0" })
793
- .config(z.object({ retain: z.string().default("2y") }))
794
- .element(store.sql("audit", { schema: () => import("./audit-schema") }))
795
- .needs("store.kv")
796
- .decorate("audit", { enabled: true })
797
- .hook("afterHandle", async (ctx, fx) => {
798
- if (ctx.trigger.meta?.audit) await fx.store("audit").log(ctx);
799
- })
800
- .errors({ AuditWriteFailed: z.object({ reason: z.string() }) })
801
- .consolePanel({ id: "audit", title: "Audit Trail", entry: "./panel.tsx" })
802
- .cli("audit:export", ({ fx }) => fx.store("audit").exportCsv());
803
- ```
804
-
805
- ### `src/app.ts` — scope is the attachment point
806
-
807
- ```typescript
808
- import { oke } from "okengine";
809
- import { auth } from "okengine/auth";
810
- import { audit } from "./plugins/audit";
811
- import * as orders from "./flows/orders";
812
- import * as payments from "./flows/payments";
813
- import * as notifications from "./flows/notifications";
814
-
815
- export const app = oke({ name: "provisions" })
816
- .adopt({ orders, payments, notifications })
817
- .plug(auth()) // zero ceremony: uses your configured store
818
- .plug(audit) // app-wide
819
- .hook("onError", (ctx, err, fx) => fx.log.error(err));
820
-
821
- app.unit("orders").plug(rateLimit({ max: 30 })); // this unit only
822
-
823
- export type App = typeof app;
824
- ```
825
-
826
- `app.plug()` is app-wide, `app.unit(name).plug()` covers one unit, `flow.plug()` covers one flow. **The position is the scope** — no `global: true`, no inheritance rule to remember. `.adopt()` is what makes `typeof app` carry every flow's contract for the client; `on()` inside each flow file still does the actual trigger registration.
827
-
828
- **Auth needs no adapter.** The framework already knows your store; its tables come from `oke schema generate`. Options exist when you want them, and the identity provider is a seam — `auth({ provider: betterAuth(...) })`, `clerk()`, `supabase()`, `auth0()`, `kinde()` all normalise to the same `fx.auth`, so gates, ABAC, rate limits and channel recipients keep working unchanged when you switch.
829
-
830
- ### Cache — three visible tiers
831
-
832
- ```typescript
833
- // Tier 1 — automatic for live and read flows; invalidation computed from effects
834
- // Tier 2 — a flag on any flow
835
- export const popular = on(http.get("/popular"), flow({ cache: "5m", do: /* … */ }));
836
- // Tier 3 — manual
837
- const rate = await fx.cache.getOrSet("fx-rate:USD-SAR", "1h", fetchRate);
838
- ```
839
-
840
- ### i18n
841
-
842
- ```typescript
843
- // src/locales/ar.ts
844
- export default { "order.outOfStock": "لم يتبقَّ سوى {left} قطع" };
845
- ```
846
-
847
- Typed keys — a missing key is a compile error. Locale resolves per request: user profile → `Accept-Language` → configured default. Errors and channel templates are localised, and the `dir` flag reaches the client so the frontend gets RTL for free.
848
-
849
- ### `tests/orders.test.ts`
850
-
851
- ```typescript
852
- const t = await createTestApp(app); // memory drivers, frozen clock
853
- const u = await t.auth.loginAs({ scopes: ["order:create"] });
854
-
855
- const { data } = await t.api.orders.create({ sku: "COFFEE", qty: 2 }, { as: u });
856
- await t.signals.drain();
857
- await t.clock.advance("2m"); // the durable sleep elapses instantly
858
- await t.signals.drain();
859
-
860
- expect(t.channels.sent()).toContainEqual(
861
- expect.objectContaining({ template: "order-confirmed", to: u.id, locale: "ar" }),
862
- );
863
- ```
864
-
865
- ### What you have
866
-
867
- All ten exports and seven of the eight elements. Durable execution, human-facing delivery, realtime, plugins, i18n, and secrets with boot-time validation.
868
-
869
- ### What is missing
870
-
871
- The system serves one customer, treats every user the same, and has no way to state what "working" means. It also cannot reason about anything.
872
-
873
- ---
874
-
875
- ---
876
-
877
- # 4 · COMPLEX — Skyport
878
-
879
- A membership and booking platform, multi-tenant, with AI.
880
-
881
- **New ideas:** the `ai` element (models, prompts, RAG, agents) · multi-tenancy · SLOs and journeys · distributed topology · the three scaling axes.
882
-
883
- ```
884
- skyport/
885
- ├── oke.config.ts
886
- ├── oke.images.lock
887
- ├── src/
888
- │ ├── app.ts · core.ts · gates.ts · vault.ts · channels.ts · ai.ts
889
- │ ├── locales/{en,ar}.ts · schema.ts · schema/oke.ts (generated)
890
- │ ├── plugins/audit.ts
891
- │ └── flows/
892
- │ ├── bookings/{index.ts,shapes.ts,signals.ts} # flights + FlightFull live here
893
- │ ├── payments/{index.ts,shapes.ts}
894
- │ ├── notifications/index.ts
895
- │ ├── support/index.ts # AI triage, RAG, a bounded agent
896
- │ └── users/{index.ts,shapes.ts,elements.ts}
897
- └── tests/
898
- ```
899
-
900
- ### `oke.config.ts` — the complete surface
901
-
902
- ```typescript
903
- import { defineConfig } from "okengine/config";
904
- import { dbUrl, dbReplica1, anthropicKey } from "./src/vault";
905
-
906
- export default defineConfig({
907
- // Drivers are named after PROTOCOLS and bind through Bun's native clients
908
- // (Bun.sql, bun:sqlite, Bun.redis, Bun.S3) — zero npm client dependencies.
909
- drivers: {
910
- store: {
911
- sql: {
912
- local: "sqlite",
913
- test: "memory",
914
- prod: { driver: "postgres", url: dbUrl, pool: { max: 20 }, replicas: [dbReplica1] },
915
- }, // read-only flows auto-route here
916
- kv: { local: "memory", test: "memory", prod: "redis" }, // Redis · Valkey · Dragonfly
917
- files: { local: "fs", test: "memory", prod: "s3" }, // S3 · R2 · SeaweedFS · MinIO
918
- index: { local: "pgvector", test: "memory", prod: "pgvector" },
919
- },
920
- signal: { local: "memory", test: "memory", prod: "postgres" },
921
- clock: { local: "memory", test: "frozen", prod: "postgres" },
922
- vault: { local: "dotenv", test: "memory", prod: "sops" }, // SOPS/age — committable
923
- runs: { local: "files", test: "memory", prod: "files" }, // Parquet + DuckDB
924
- channel: {
925
- email: { local: "console", prod: "smtp" },
926
- sms: { local: "console", prod: "unifonic" },
927
- whatsapp: { local: "console", prod: "wa-cloud" },
928
- push: { local: "console", prod: "fcm" },
929
- },
930
- ai: {
931
- local: "mock", // deterministic — tests never call out
932
- prod: { driver: "anthropic", key: anthropicKey },
933
- // no prod default: model choice is never guessed.
934
- // "openai-compatible" covers vLLM · Groq · Together · LM Studio · most self-hosted
935
- },
936
- },
937
-
938
- images: {
939
- // vendor choice, keyed by ROLE
940
- "store.sql": "pgvector/pgvector:pg17",
941
- "store.kv": "valkey/valkey:8-alpine",
942
- },
943
-
944
- i18n: { locales: ["en", "ar"], default: "ar", dir: { ar: "rtl" } },
945
- tenancy: { resolve: (ctx) => ctx.auth.orgId, isolation: "row" },
946
- topology: "monolith", // flip to "services" — code unchanged
947
- ports: { app: 6530, console: 6533, mcp: 6535 }, // O·K·E = 6·5·3
948
- console: { prod: { enabled: true, auth: "required" } },
949
- });
950
- ```
951
-
952
- ### `src/schema.ts` (excerpt — the tables this section uses)
953
-
954
- ```typescript
955
- import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
956
- import { id } from "okengine/store";
957
-
958
- export const bookings = sqliteTable("bookings", {
959
- id: text("id").primaryKey().$defaultFn(id),
960
- userId: text("user_id").notNull(),
961
- flightId: text("flight_id").notNull(),
962
- seats: integer("seats").notNull(),
963
- status: text("status").notNull().default("pending"),
964
- createdAt: integer("created_at").notNull(),
965
- });
966
-
967
- export const flights = sqliteTable("flights", {
968
- id: text("id").primaryKey(),
969
- seatsAvailable: integer("seats_available").notNull(),
970
- });
971
-
972
- export const tickets = sqliteTable("tickets", {
973
- id: text("id").primaryKey(),
974
- subject: text("subject").notNull(),
975
- body: text("body").notNull(),
976
- urgency: text("urgency"),
977
- team: text("team"),
978
- summary: text("summary"),
979
- });
980
- ```
981
-
982
- ### `src/flows/bookings/shapes.ts`
983
-
984
- ```typescript
985
- import { z } from "zod";
986
-
987
- export const NewBooking = z.object({ flightId: z.string(), seats: z.number().min(1).max(9) });
988
- export const BookingId = z.object({ id: z.string() });
989
- export const BookingRow = z.object({ id: z.string(), status: z.string(), seats: z.number() });
990
- export const FlightFull = z.object({ seatsLeft: z.number() });
991
- ```
992
-
993
- ### `src/flows/bookings/signals.ts`
994
-
995
- ```typescript
996
- import { signal } from "okengine";
997
- import { z } from "zod";
998
-
999
- export const orderPlaced = signal("order-placed", {
1000
- schema: z.object({ orderId: z.string() }),
1001
- delivery: "once",
1002
- retries: 5,
1003
- deadLetter: true,
1004
- });
1005
- export const seatFeed = signal("seat-feed", {
1006
- schema: z.object({ flightId: z.string(), left: z.number() }),
1007
- delivery: "live",
1008
- });
1009
- ```
1010
-
1011
- ### `src/flows/bookings/index.ts`
1012
-
1013
- ```typescript
1014
- import { on, flow, gate, http } from "okengine";
1015
- import { eq } from "drizzle-orm";
1016
- import { db } from "../../core";
1017
- import { member, fair } from "../../gates";
1018
- import { orderPlaced, seatFeed } from "./signals";
1019
- import { NewBooking, BookingId, BookingRow, FlightFull } from "./shapes";
1020
- import { bookings, flights } from "../../schema";
1021
-
1022
- export const canBook = gate.policy("booking:create", ({ auth }) =>
1023
- auth.scopes.has("booking:create"),
1024
- );
1025
-
1026
- export const create = on(
1027
- http.post("/bookings").gate(member, canBook, fair),
1028
- flow({
1029
- slo: { availability: "99.9%", latency: { p99: "200ms" } },
1030
- in: NewBooking,
1031
- out: BookingId,
1032
- errors: { FlightFull },
1033
- do: async ({ flightId, seats }, fx) => {
1034
- const [flight] = await fx
1035
- .store(db)
1036
- .select()
1037
- .from(flights)
1038
- .where(eq(flights.id, flightId))
1039
- .limit(1);
1040
- if (!flight || flight.seatsAvailable < seats)
1041
- return fx.fail("FlightFull", { seatsLeft: flight?.seatsAvailable ?? 0 });
1042
-
1043
- const id = fx.id();
1044
- await fx.store(db).insert(bookings).values({
1045
- id,
1046
- userId: fx.auth.userId,
1047
- flightId,
1048
- seats,
1049
- status: "pending",
1050
- createdAt: Date.now(),
1051
- });
1052
- await fx.emit(orderPlaced, { orderId: id });
1053
- await fx.emit(seatFeed, { flightId, left: flight.seatsAvailable - seats });
1054
- return { id };
1055
- },
1056
- }),
1057
- );
1058
-
1059
- export const mine = on(
1060
- http.get("/bookings").gate(member).live(),
1061
- flow({
1062
- out: BookingRow.array(),
1063
- do: (_, fx) => fx.store(db).select().from(bookings).where(eq(bookings.userId, fx.auth.userId)),
1064
- }),
1065
- );
1066
-
1067
- export const getBooking = flow({
1068
- in: BookingId,
1069
- out: BookingRow,
1070
- do: async ({ id }, fx) => {
1071
- const [b] = await fx.store(db).select().from(bookings).where(eq(bookings.id, id)).limit(1);
1072
- return b;
1073
- },
1074
- });
1075
-
1076
- // The agent's second tool — refunding is a distinct, gated capability, never the same
1077
- // permission as reading a booking, since the agent's tool list is exactly its authority.
1078
- export const refundBooking = flow({
1079
- in: BookingId,
1080
- out: BookingRow,
1081
- do: async ({ id }, fx) => {
1082
- await fx.store(db).update(bookings).set({ status: "refunded" }).where(eq(bookings.id, id));
1083
- const [b] = await fx.store(db).select().from(bookings).where(eq(bookings.id, id)).limit(1);
1084
- return b;
1085
- },
1086
- });
1087
- ```
1088
-
1089
- ### `src/ai.ts` — the eighth element
1090
-
1091
- ```typescript
1092
- import { ai, store } from "okengine";
1093
- import { z } from "zod";
1094
- import { getBooking, refundBooking } from "./flows/bookings";
1095
-
1096
- export const smart = ai.model("smart", { provider: "anthropic", tier: "opus" });
1097
- export const fast = ai.model("fast", { provider: "anthropic", tier: "haiku" });
1098
-
1099
- // A prompt is a VERSIONED ARTIFACT with a validated output shape — not a string in a handler
1100
- export const triage = smart.prompt("ticket-triage", {
1101
- in: z.object({ subject: z.string(), body: z.string() }),
1102
- out: z.object({ urgency: z.enum(["low", "high"]), team: z.string(), summary: z.string() }),
1103
- version: 3,
1104
- evals: "./evals/triage.jsonl", // regression-gated in CI via `oke eval`
1105
- budget: { maxCostPerCall: 0.02 }, // cost is a first-class dimension
1106
- });
1107
-
1108
- export const embed = ai.embed("docs", { model: fast, into: store.index("kb") });
1109
-
1110
- // An agent whose tools are YOUR OWN FLOWS — each carrying its gates and effects
1111
- export const support = ai.agent("support", {
1112
- model: smart,
1113
- tools: [getBooking, refundBooking],
1114
- maxSteps: 6,
1115
- budget: { maxCostPerRun: 0.25 },
1116
- });
1117
- ```
1118
-
1119
- ### `src/flows/support/index.ts`
1120
-
1121
- ```typescript
1122
- import { on, flow, http } from "okengine";
1123
- import { z } from "zod";
1124
- import { triage, support, embed, smart, fast } from "../../ai";
1125
- import { member } from "../../gates";
1126
- import { db } from "../../core";
1127
- import { tickets } from "../../schema";
1128
-
1129
- // ① A prompt call with a provider fallback chain and a validated result
1130
- export const createTicket = on(
1131
- http.post("/tickets").gate(member),
1132
- flow({
1133
- in: z.object({ subject: z.string(), body: z.string() }),
1134
- out: z.object({ id: z.string(), urgency: z.string() }),
1135
- do: async (input, fx) => {
1136
- const t = await fx.ask(triage, input, { via: [smart, fast] });
1137
- const id = fx.id();
1138
- await fx
1139
- .store(db)
1140
- .insert(tickets)
1141
- .values({ id, ...input, ...t });
1142
- return { id, urgency: t.urgency };
1143
- },
1144
- }),
1145
- );
1146
- // effects → writes[sql:tickets] asks[ticket-triage v3] cost[~$0.01] nondeterministic
1147
-
1148
- // ② RAG — retrieve, then answer with streaming tokens
1149
- export const askDocs = on(
1150
- http.post("/ask").gate(member).live(),
1151
- flow({
1152
- in: z.object({ question: z.string() }),
1153
- do: async ({ question }, fx) => {
1154
- const context = await fx.search(embed, question, { topK: 5 });
1155
- return fx.stream(smart, { prompt: "answer-with-context", data: { question, context } });
1156
- // streaming reaches the client through the Signal element — no separate socket layer
1157
- },
1158
- }),
1159
- );
1160
-
1161
- // ③ A durable, bounded agent
1162
- export const supportAgent = on(
1163
- http.post("/support").gate(member),
1164
- flow({
1165
- durable: true, // nondeterministic calls are ALWAYS journaled
1166
- in: z.object({ message: z.string() }),
1167
- do: ({ message }, fx) => fx.run(support, { message }),
1168
- // the agent can only call getBooking and refundBooking, and only within THIS user's
1169
- // gates and tenant scope — it cannot exceed what the code declares
1170
- }),
1171
- );
1172
- ```
1173
-
1174
- **What the compiler enforces here, for free:**
1175
-
1176
- - A field tagged `pii` in the schema **cannot reach a third-party model** — the build fails unless the flow masks it or declares `allowPii` explicitly.
1177
- - `nondeterministic` forces journaling: on replay, a model is never re-called; the recorded answer is reused.
1178
- - Automatic caching is disabled for AI flows unless a semantic cache is explicitly enabled.
1179
- - Cost accumulates per flow, per tenant and per release — visible in the Console and in Manifest Diff _before_ deploy.
1180
-
1181
- ### Declaring what "working" means
1182
-
1183
- ```typescript
1184
- // on a flow — this is bookings.create, shown in full above
1185
- export const create = on(http.post("/bookings").gate(member, canBook, fair), flow({
1186
- slo: { availability: "99.9%", latency: { p99: "200ms" } },
1187
- in: NewBooking, out: BookingId, errors: { FlightFull },
1188
- do: /* as shown above */,
1189
- }));
1190
-
1191
- // on a user journey — because a service SLO is not a user SLO
1192
- journey("book-a-flight", {
1193
- path: [bookings.create, payments.charge, notifications.send],
1194
- slo: { availability: "99.5%" },
1195
- });
1196
- ```
1197
-
1198
- Forty services at 99.9% in sequence yield 96.1% for the user. Because the causal chain is known, **the compiler rejects the impossible**: _"this path composes to 99.4% but declares 99.5%."_ And because the objective lives in the Manifest, lowering a target is a code change that passes through Manifest Diff and team review — not a silent dashboard edit.
1199
-
1200
- ### Multi-tenancy as a dimension of `fx`
1201
-
1202
- `tenancy: { resolve, isolation: "row" }` in the config is the whole of it. Every store call passes through `fx`, so tenant scoping applies automatically — there is no forgotten `WHERE org_id`. Rate limits, caches, secrets and channel branding become per-tenant for free, and **`oke doctor` fails the build** if any flow reads a tenant-scoped table without a tenant in context.
1203
-
1204
- ### The three scaling axes, never conflated
1205
-
1206
- | Axis | Question | Mechanism |
1207
- | ---------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
1208
- | **Split** (`topology`) | one deployable, or one per unit? | `monolith` = in-process calls · `services` = a container per unit, `fx.call` becomes network — code unchanged |
1209
- | **Clone** (horizontal) | how many copies of the app? | run N instances: `once` signals deliver to exactly one, crons leader-elect, live queries fan out. `oke docker --prod` emits `deploy.replicas` |
1210
- | **Data replicas** | how many copies of the data? | `replicas:` on the driver; read-only flows auto-route, derived from effects |
1211
-
1212
- ### `src/app.ts`
1213
-
1214
- ```typescript
1215
- import { oke } from "okengine";
1216
- import { auth } from "okengine/auth";
1217
- import { audit } from "./plugins/audit";
1218
- import * as bookings from "./flows/bookings";
1219
- import * as payments from "./flows/payments";
1220
- import * as notifications from "./flows/notifications";
1221
- import * as support from "./flows/support";
1222
- import * as users from "./flows/users";
1223
-
1224
- export const app = oke({ name: "skyport" })
1225
- .adopt({ bookings, payments, notifications, support, users })
1226
- .plug(auth())
1227
- .plug(audit)
1228
- .hook("onError", (ctx, err, fx) => fx.log.error(err));
1229
-
1230
- export type App = typeof app;
1231
- ```
1232
-
1233
- Same shape as Provisions — `.adopt()` for the client's types, `.plug()` for cross-cutting concerns, `on()` inside each flow file for the actual trigger registration. Nothing about composition changes as an application grows from one unit to five.
1234
-
1235
- ### `tests/` — deterministic even with AI
1236
-
1237
- ```typescript
1238
- const t = await createTestApp(app);
1239
- t.ai.mock(triage, { urgency: "high", team: "ops", summary: "seat dispute" });
1240
-
1241
- const u = await t.auth.loginAs({});
1242
- const { data } = await t.api.support.createTicket({ subject: "…", body: "…" }, { as: u });
1243
-
1244
- expect(data!.urgency).toBe("high");
1245
- expect(t.ai.cost()).toBeLessThan(0.02); // budgets are assertable
1246
- ```
1247
-
1248
- ### What you have
1249
-
1250
- All eight elements, all ten exports, and one law.
1251
-
1252
- ---
1253
-
1254
- ---
1255
-
1256
- # REFERENCE
1257
-
1258
- ## What the compiler produced — `manifest.oke.json`
1259
-
1260
- ```json
1261
- {
1262
- "oke": "1.0",
1263
- "app": "skyport",
1264
- "flows": {
1265
- "bookings.create": {
1266
- "trigger": { "http": { "method": "POST", "path": "/bookings" } },
1267
- "gates": ["member", "booking:create", "rate:sliding-window-counter:300/1m"],
1268
- "in": "…",
1269
- "out": "…",
1270
- "errors": ["FlightFull"],
1271
- "effects": {
1272
- "reads": ["sql:bookings"],
1273
- "writes": ["sql:bookings"],
1274
- "emits": ["order-placed", "seat-feed"],
1275
- "secrets": []
1276
- },
1277
- "slo": { "availability": "99.9%", "latency": { "p99": "200ms" } },
1278
- "source": "src/flows/bookings/index.ts:18"
1279
- },
1280
- "bookings.mine": { "live": true, "cacheKeys": "computed:sql:bookings/userId" },
1281
- "payments.chargeBooking": {
1282
- "durable": true,
1283
- "steps": ["create-intent", "confirm"],
1284
- "effects": { "secrets": ["STRIPE_KEY"] }
1285
- },
1286
- "support.createTicket": {
1287
- "effects": { "writes": ["sql:tickets"], "asks": ["ticket-triage@3"] },
1288
- "nondeterministic": true,
1289
- "cost": { "estimatePerCall": 0.011, "budget": 0.02 },
1290
- "pii": "masked"
1291
- }
1292
- },
1293
- "signals": {
1294
- "order-placed": { "delivery": "once", "retries": 5, "deadLetter": true },
1295
- "seat-feed": { "delivery": "live" }
1296
- },
1297
- "channels": { "booking-confirmed": { "medium": "email", "locales": ["en", "ar"] } },
1298
- "ai": {
1299
- "models": { "smart": { "provider": "anthropic", "tier": "opus" } },
1300
- "prompts": { "ticket-triage": { "version": 3, "evals": "./evals/triage.jsonl" } },
1301
- "agents": {
1302
- "support": { "tools": ["bookings.getBooking", "bookings.refundBooking"], "maxSteps": 6 }
1303
- }
1304
- },
1305
- "journeys": { "book-a-flight": { "slo": { "availability": "99.5%" }, "composes": "99.6%" } },
1306
- "drivers": { "prod": ["postgres", "redis", "s3", "smtp", "sops", "anthropic", "pgvector"] },
1307
- "tenancy": { "isolation": "row" }
1308
- }
1309
- ```
1310
-
1311
- From this one file OKE derives: the typed client · OpenAPI + AsyncAPI · the Console catalogue, diagrams and traces · per-flow capabilities · cache invalidation keys · replica routing · the tree-shaken bundle · the Dockerfile and compose files · the MCP surface.
1312
-
1313
- ## Commands
1314
-
1315
- ```bash
1316
- bun add okengine # ONE package
1317
-
1318
- oke dev # watch · hot reload · Console :6533 · app :6530 · MCP :6535
1319
- # → also auto-syncs client types on every save
1320
- oke dev --local # -l session-only local (in-memory; never writes .oke/mode)
1321
- oke dev --docker # -d infra compose under docker/ (no app container; host Bun)
1322
- oke dev -d store.sql,signal # partial: only these roles get real backends
1323
- oke mode local|docker # get/set default oke dev mode (saved in .oke/mode)
1324
-
1325
- oke start # runs exactly what production runs (this is the Docker CMD)
1326
- oke doctor # verify secrets, ports, drivers, tenancy, schema drift
1327
- oke stack # preview resolved images/tags/ports — writes nothing
1328
-
1329
- oke schema generate # core + plugin tables → schema/oke.ts (--check in CI)
1330
- oke vault set STRIPE_KEY # also: list · import .env · key rotate
1331
- oke client add <url> # types for a separate frontend repo
1332
-
1333
- oke docker # docker/Dockerfile + docker/compose.<role>.yml · …
1334
- oke docker --prod # healthchecks, volumes, limits, secret refs, deploy.replicas
1335
- oke images pin # tags → digests in oke.images.lock
1336
-
1337
- oke build --target edge # < 15 kB kernel profile
1338
- oke eval # run prompt eval sets; fails CI on regression
1339
- oke branch prod --at "yesterday" # fork journaled state into a sandbox
1340
- oke privacy erase --subject <id> # crypto-shredding: deletes the key, not the terabytes
1341
- oke upgrade # run codemods for a breaking change, print the diff
1342
- ```
1343
-
1344
- ## The Console at `:6533`
1345
-
1346
- Seventeen panels, all derived and never hand-maintained: **Overview · Flows · Signals · Store · Clock · Gates · Vault · Channels · AI · Architecture · Traces · Runs · Manifest Diff · Access · Plugins**, plus **Privacy** and **Tenancy** when their optional core plugins are plugged.
1347
-
1348
- Runtime actions execute directly; structural changes arrive as reviewable diffs in your working tree. Every Console action is a real flow through `fx`, so **the audit log is the trace**.
1349
-
1350
- ## Store reference
1351
-
1352
- **Why Drizzle is a required peer dependency, not an abstraction.** The framework commits to one query builder rather than supporting several, because the effect inferencer performs real static analysis on Drizzle's own shapes — a table object, `.select().from(t)`, `.insert(t).values()` — to derive `reads`/`writes`/PII classification with no annotation from you. Supporting N ORMs would mean either analysing N different query builders (and getting it wrong for the ones nobody tests) or falling back to hints, which is exactly the annotation burden the effect system exists to remove. One committed ORM is what makes automatic cache invalidation and least-privilege capability tokens possible at all.
1353
-
1354
- **When you still need `fx.id()` despite `$defaultFn(id)`.** The schema default fills the `id` column at insert time — fine when nothing in the flow needs the value beforehand, as in Notes' `create` (the id is only read back from `.returning()`). Generate it explicitly with `fx.id()`, and pass it into `.values({ id, … })` yourself, whenever the flow needs the same id _before or alongside_ the insert — to reference it in an emitted signal payload, to use it as a foreign key in a second insert in the same flow, or to return it without a round-trip. Linkly's `shorten` is the pattern: `const id = fx.id()` because the row and any signal about it need to agree on the same identifier within one flow body.
1355
-
1356
- ## Element checklist across the four applications
1357
-
1358
- | Element | First appears | The unification it proves |
1359
- | ------------- | ------------- | -------------------------------------------------------------------------------------------- |
1360
- | **Flow** | Basic | endpoint = consumer = cron = CDC = workflow — one species |
1361
- | **Store** | Basic | sql · kv · files · index; cache and replica routing derived from effects |
1362
- | **Signal** | Intermediate | queue = pub/sub = stream; delivery is a property, not three ecosystems |
1363
- | **Clock** | Intermediate | cron = delay = durable time |
1364
- | **Gate** | Intermediate | auth = ABAC = rate limit, composable at the trigger |
1365
- | **Vault** | Advanced | typed contracts, boot-time validation, per-flow read capability |
1366
- | **Channel** | Advanced | email = sms = whatsapp = push; consent, locale and fallback built in |
1367
- | **AI** | Complex | prompts versioned · agents bounded by your own flows · cost and PII enforced by the compiler |
1368
- | **`fx` door** | Basic | journaling · tests · least privilege · transactions · tenancy · i18n · cost |
1369
- | **Manifest** | Basic | client · docs · console · security · bundle · infrastructure |
1370
- | **Plugin** | Advanced | extends through the same law; built-ins have no private API |
1371
-
1372
- **Ten exports — `on, flow, signal, store, clock, gate, vault, channel, ai, plugin` — one law.**
1373
-
1374
- That is the harmony.