okengine 0.19.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/site/content/docs/ai/meta.json +1 -1
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/client/index.mdx +1 -2
- package/site/content/docs/elements/ai/agents.mdx +5 -2
- package/site/content/docs/elements/ai/index.mdx +4 -3
- package/site/content/docs/elements/ai/prompts.mdx +3 -2
- package/site/content/docs/elements/channel/email.mdx +5 -2
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/clock/index.mdx +3 -2
- package/site/content/docs/elements/clock/sleep.mdx +10 -7
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/routing.mdx +1 -1
- package/site/content/docs/elements/gate/tenancy.mdx +5 -2
- package/site/content/docs/elements/signal/broadcast.mdx +6 -4
- package/site/content/docs/elements/signal/index.mdx +1 -1
- package/site/content/docs/elements/signal/live.mdx +3 -2
- package/site/content/docs/elements/signal/once.mdx +3 -2
- package/site/content/docs/elements/store/files.mdx +24 -16
- package/site/content/docs/elements/store/index.mdx +11 -8
- package/site/content/docs/elements/store/kv.mdx +24 -16
- package/site/content/docs/elements/store/search.mdx +18 -14
- package/site/content/docs/elements/store/sql.mdx +14 -10
- package/site/content/docs/elements/vault/config.mdx +5 -2
- package/site/content/docs/elements/vault/rotation.mdx +5 -2
- package/site/content/docs/elements/vault/secrets.mdx +5 -2
- package/site/content/docs/index.mdx +4 -19
- package/site/content/docs/reference/cli.mdx +1 -1
- package/site/content/docs/reference/fx.mdx +3 -2
- package/site/content/docs/reference/security.mdx +2 -2
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/understand/the-architecture.mdx +264 -0
- package/src/bench/REPORT.md +48 -17
- package/src/bench/g17-hybrid-search.bench.ts +13 -13
- package/src/console/ui-next/dist/assets/{access-page-DFeymU07.js → access-page-C_qLDhTq.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-U1rdfblp.js → agent-disclosure-BHVqr3TN.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BeFJeqBG.js → cache-glyph-CKe92lRQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-CVAONPii.js → call-pii-button-DEwTl8ZX.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-D2A6NJ-3.js → collapsible-BCBtDrCt.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Cgk_h5ja.js → duration-tone-JroqeuCp.js} +1 -1
- package/src/console/ui-next/dist/assets/flows-page-CVHa0RTt.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-MYZQtRnw.js → highlighted-json-DjJW6hqe.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-Jrh39p7A.js → http-method-DC5HBdLU.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-DXP2dBIF.js → index-CYjiZ3WO.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-HvolXxTI.js → observability-page-CAYMyKb3.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-CSh2dzrb.js → replica-lag-yAQYLv75.js} +1 -1
- package/src/console/ui-next/dist/assets/request-meta-D0yusGxJ.js +1 -0
- package/src/console/ui-next/dist/assets/{store-page-eiKiHnNe.js → store-page-BTKJeJ02.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CZkMeKS-.js → trace-detail-sheet-Bp-Yygs5.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CW8y5A2h.js → tree-expand-toggle-DoaVDfAM.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-B_RWJrEO.js → units-page-BRz7xyYL.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-CWrg-A68.js → vault-page-3jQt-bOJ.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/flows/graph/element-map.test.ts +1 -1
- package/src/console/ui-next/src/features/flows/graph/element-map.ts +3 -3
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +4 -5
- package/src/console/ui-next/src/features/flows/traces/trace-gates.ts +3 -6
- package/src/elements/gate/declare.ts +1 -1
- package/src/elements/gate.ts +1 -1
- package/src/elements/store/search-lsh.ts +35 -0
- package/src/elements/store/search-runtime.pglite.test.ts +186 -0
- package/src/elements/store/search-runtime.ts +30 -16
- package/src/elements/store/search.test.ts +83 -1
- package/src/elements/store.ts +2 -0
- package/src/mcp/docs-index.ts +3 -3
- package/src/mcp/docs-mcp.test.ts +2 -2
- package/src/mcp/docs-tools.ts +2 -1
- package/site/content/docs/understand/the-anatomy.mdx +0 -132
- package/site/content/docs/understand/the-model.mdx +0 -32
- package/site/content/docs/understand/the-problem.mdx +0 -74
- package/site/content/docs/understand/the-vocabulary.mdx +0 -26
- package/src/console/ui-next/dist/assets/flows-page-Dss7941e.js +0 -1
- package/src/console/ui-next/dist/assets/request-meta-BatF8KrK.js +0 -1
- /package/site/content/docs/{ai → understand}/try-it.mdx +0 -0
|
@@ -291,11 +291,12 @@ import { eq } from "drizzle-orm";
|
|
|
291
291
|
import { db, notes } from "@/schema";
|
|
292
292
|
|
|
293
293
|
export const get = on(
|
|
294
|
-
http.get(
|
|
295
|
-
flow({
|
|
294
|
+
http.get({
|
|
296
295
|
in: z.object({ id: z.string() }),
|
|
297
296
|
out: z.object({ id: z.string(), title: z.string() }),
|
|
298
297
|
errors: { NotFound: z.object({ id: z.string() }) },
|
|
298
|
+
}),
|
|
299
|
+
flow({
|
|
299
300
|
do: async ({ id }, fx) => {
|
|
300
301
|
const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
|
|
301
302
|
if (!note) return fx.fail("NotFound", { id });
|
|
@@ -317,10 +318,11 @@ import { z } from "zod";
|
|
|
317
318
|
import { db, notes } from "@/schema";
|
|
318
319
|
|
|
319
320
|
export const create = on(
|
|
320
|
-
http.post(
|
|
321
|
-
flow({
|
|
321
|
+
http.post({
|
|
322
322
|
in: z.object({ title: z.string().min(1) }),
|
|
323
323
|
out: z.object({ id: z.string(), title: z.string() }),
|
|
324
|
+
}),
|
|
325
|
+
flow({
|
|
324
326
|
do: async ({ title }, fx) => {
|
|
325
327
|
const id = fx.id();
|
|
326
328
|
const [row] = await fx.store(db).insert(notes).values({ id, title }).returning();
|
|
@@ -344,13 +346,14 @@ import { eq } from "drizzle-orm";
|
|
|
344
346
|
import { db, notes } from "@/schema";
|
|
345
347
|
|
|
346
348
|
export const update = on(
|
|
347
|
-
http.patch(
|
|
348
|
-
flow({
|
|
349
|
+
http.patch({
|
|
349
350
|
in: z.object({
|
|
350
351
|
id: z.string(),
|
|
351
352
|
title: z.string().min(1).optional(),
|
|
352
353
|
}),
|
|
353
354
|
errors: { NotFound: z.object({ id: z.string() }) },
|
|
355
|
+
}),
|
|
356
|
+
flow({
|
|
354
357
|
do: async ({ id, title }, fx) => {
|
|
355
358
|
if (title !== undefined) {
|
|
356
359
|
await fx.store(db).update(notes).set({ title }).where(eq(notes.id, id));
|
|
@@ -376,9 +379,10 @@ import { z } from "zod";
|
|
|
376
379
|
import { db, notes } from "@/schema";
|
|
377
380
|
|
|
378
381
|
export const remove = on(
|
|
379
|
-
http.delete(
|
|
380
|
-
flow({
|
|
382
|
+
http.delete({
|
|
381
383
|
in: z.object({ id: z.string() }),
|
|
384
|
+
}),
|
|
385
|
+
flow({
|
|
382
386
|
do: async ({ id }, fx) => {
|
|
383
387
|
await fx.store(db).delete(notes, id);
|
|
384
388
|
return fx.json.empty();
|
|
@@ -422,11 +426,11 @@ prefer them for live feeds. Offset is fine for admin tables.
|
|
|
422
426
|
Default is insert-once. Pass `{ onExisting: "update" }` to overwrite a match:
|
|
423
427
|
|
|
424
428
|
```typescript title="src/flows/notes/ensure.ts"
|
|
425
|
-
import {
|
|
429
|
+
import { call } from "okengine";
|
|
426
430
|
import { z } from "zod";
|
|
427
431
|
import { db, notes } from "@/schema";
|
|
428
432
|
|
|
429
|
-
export const ensureWelcome =
|
|
433
|
+
export const ensureWelcome = call("notes.ensureWelcome", {
|
|
430
434
|
in: z.object({ title: z.string() }),
|
|
431
435
|
do: async ({ title }, fx) => {
|
|
432
436
|
const result = await fx.store(db).upsert(
|
|
@@ -48,9 +48,12 @@ import { member } from "@/core/gate";
|
|
|
48
48
|
import { publicAppUrl, noteCreatedMail } from "@/core";
|
|
49
49
|
|
|
50
50
|
export const create = on(
|
|
51
|
-
http
|
|
51
|
+
http
|
|
52
|
+
.post({
|
|
53
|
+
in: z.object({ email: z.string().email() }),
|
|
54
|
+
})
|
|
55
|
+
.gate(member),
|
|
52
56
|
flow({
|
|
53
|
-
in: z.object({ email: z.string().email() }),
|
|
54
57
|
do: async ({ email }, fx) => {
|
|
55
58
|
const origin = await fx.vault.get(publicAppUrl);
|
|
56
59
|
await fx.send(noteCreatedMail, {
|
|
@@ -93,10 +93,13 @@ import { gate } from "okengine";
|
|
|
93
93
|
const operator = gate.policy("operator", ({ operator: op }) => !!op);
|
|
94
94
|
|
|
95
95
|
export const rotateStripe = on(
|
|
96
|
-
http
|
|
96
|
+
http
|
|
97
|
+
.post({
|
|
98
|
+
in: z.object({ value: z.string().min(1) }),
|
|
99
|
+
})
|
|
100
|
+
.gate(operator),
|
|
97
101
|
flow({
|
|
98
102
|
plane: "operator",
|
|
99
|
-
in: z.object({ value: z.string().min(1) }),
|
|
100
103
|
effects: { secrets: ["STRIPE_KEY"] },
|
|
101
104
|
do: async ({ value }, fx) => {
|
|
102
105
|
const result = await fx.vault.rotate("STRIPE_KEY", value);
|
|
@@ -51,9 +51,12 @@ import { member } from "@/core/gate";
|
|
|
51
51
|
import { stripeKey } from "@/core/vault";
|
|
52
52
|
|
|
53
53
|
export const charge = on(
|
|
54
|
-
http
|
|
54
|
+
http
|
|
55
|
+
.post({
|
|
56
|
+
in: z.object({ amount: z.number().int().positive() }),
|
|
57
|
+
})
|
|
58
|
+
.gate(member),
|
|
55
59
|
flow({
|
|
56
|
-
in: z.object({ amount: z.number().int().positive() }),
|
|
57
60
|
do: async ({ amount }, fx) => {
|
|
58
61
|
const key = await fx.vault.get(stripeKey);
|
|
59
62
|
const stripe = new Stripe(key.reveal());
|
|
@@ -23,29 +23,14 @@ Master the mental model before exploring features:
|
|
|
23
23
|
|
|
24
24
|
<Cards>
|
|
25
25
|
<Card
|
|
26
|
-
title="The
|
|
27
|
-
description="
|
|
28
|
-
href="/docs/understand/the-
|
|
29
|
-
/>
|
|
30
|
-
<Card
|
|
31
|
-
title="The Model"
|
|
32
|
-
description="The one rule that removes the disagreement between systems — stated plainly, in two parts."
|
|
33
|
-
href="/docs/understand/the-model"
|
|
34
|
-
/>
|
|
35
|
-
<Card
|
|
36
|
-
title="The Vocabulary"
|
|
37
|
-
description="The eight things the door recognizes — what each one replaces, and why nothing else made the cut."
|
|
38
|
-
href="/docs/understand/the-vocabulary"
|
|
39
|
-
/>
|
|
40
|
-
<Card
|
|
41
|
-
title="The Anatomy"
|
|
42
|
-
description="The five pieces behind on(trigger, flow) — on, trigger, flow, do, and fx — explained one at a time."
|
|
43
|
-
href="/docs/understand/the-anatomy"
|
|
26
|
+
title="The Architecture"
|
|
27
|
+
description="Why backends drift apart, the one rule that stops it, and the five pieces behind every Flow."
|
|
28
|
+
href="/docs/understand/the-architecture"
|
|
44
29
|
/>
|
|
45
30
|
<Card
|
|
46
31
|
title="Try It"
|
|
47
32
|
description="From an empty folder to a Flow running in the Console — one sitting, minimal detour."
|
|
48
|
-
href="/docs/
|
|
33
|
+
href="/docs/understand/try-it"
|
|
49
34
|
/>
|
|
50
35
|
</Cards>
|
|
51
36
|
|
|
@@ -211,7 +211,7 @@ oke db search-backfill notes --batch 500
|
|
|
211
211
|
- [Configuration](/docs/reference/configuration) — `oke.config.ts` drivers and ports
|
|
212
212
|
- [Environment Variables](/docs/reference/environment-variables) — what Compose writes
|
|
213
213
|
- [Security](/docs/reference/security) — Host / Origin / `allowedHosts`
|
|
214
|
-
- [The
|
|
214
|
+
- [The Architecture](/docs/understand/the-architecture) — one contract; `oke doctor --diff` reviews it
|
|
215
215
|
- [Vault](/docs/elements/vault) — `oke vault` rotate / unseal
|
|
216
216
|
|
|
217
217
|
## Next
|
|
@@ -28,9 +28,10 @@ import { z } from "zod";
|
|
|
28
28
|
import { db, notes } from "@/schema";
|
|
29
29
|
|
|
30
30
|
export const create = on(
|
|
31
|
-
http.post(
|
|
32
|
-
flow({
|
|
31
|
+
http.post({
|
|
33
32
|
in: z.object({ title: z.string().min(1) }),
|
|
33
|
+
}),
|
|
34
|
+
flow({
|
|
34
35
|
do: async ({ title }, fx) => {
|
|
35
36
|
const id = fx.id();
|
|
36
37
|
await fx.store(db).insert(notes).values({ id, title });
|
|
@@ -172,7 +172,7 @@ the family.
|
|
|
172
172
|
|
|
173
173
|
- [CLI](/docs/reference/cli) — `oke dev`, `oke console claim-code`
|
|
174
174
|
- [Gate](/docs/elements/gate) — policies, API keys, tenancy
|
|
175
|
-
- [The
|
|
175
|
+
- [The Architecture](/docs/understand/the-architecture) — one shape, one door, fixed vocabulary
|
|
176
176
|
- [Errors](/docs/reference/errors) — `CrossPlaneError`, `SessionError`, `AttenuationError`
|
|
177
177
|
- [Environment Variables](/docs/reference/environment-variables) — `OKE_CONSOLE_SECRET`
|
|
178
178
|
|
|
@@ -192,6 +192,6 @@ the family.
|
|
|
192
192
|
<Card
|
|
193
193
|
title="The Model"
|
|
194
194
|
description="One shape for every trigger, one door for every effect."
|
|
195
|
-
href="/docs/understand/the-
|
|
195
|
+
href="/docs/understand/the-architecture"
|
|
196
196
|
/>
|
|
197
197
|
</Cards>
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "The Architecture"
|
|
3
|
+
description: "Why backends drift apart, the one rule that stops it, and the five pieces behind every Flow."
|
|
4
|
+
icon: "Compass"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Four lines ship the signup flow. Eight months later it is six files that disagree about retries, audits, and who is allowed to do what.
|
|
8
|
+
|
|
9
|
+
This page shows that drift, the one rule that stops it, and the exact anatomy behind every Flow — in one sitting.
|
|
10
|
+
|
|
11
|
+
<Callout title="The Law">
|
|
12
|
+
Every backend behavior is a Flow: `on(Trigger) → Effects`. One species; triggers are typed values.
|
|
13
|
+
All world access goes through `fx`.
|
|
14
|
+
</Callout>
|
|
15
|
+
|
|
16
|
+
## The problem: three features, same wall
|
|
17
|
+
|
|
18
|
+
**A signup flow.** A user registers. Send them a welcome email:
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
app.post("/signup", async (req, res) => {
|
|
22
|
+
const user = await db.users.create(req.body);
|
|
23
|
+
await sendMail(user.email, "Welcome!", welcomeTemplate(user));
|
|
24
|
+
res.json(user);
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
It works. It ships. Then a traffic spike, a silent failure, a compliance question, and a double-submit turn those four lines into six files — endpoint, queue, worker, Redis connection, mail client, audit table — that never agreed with each other about anything.
|
|
29
|
+
|
|
30
|
+
**A payment webhook.** A provider confirms a charge. Mark the order paid. Simple — until the provider retries the same webhook twice during a network hiccup, and "mark the order paid" needs to somehow know it already ran.
|
|
31
|
+
|
|
32
|
+
**A nightly report.** Summarize yesterday's activity and email it to managers. Trivial — until a manager's access gets revoked at 11:58pm and the report that runs at midnight has no idea the permission it checked when the feature was built isn't the permission that holds right now.
|
|
33
|
+
|
|
34
|
+
Three teams. Three domains. Nobody on any of them talked to the other two. And all three land on the identical fork: **something has to happen later, exactly once, provably — and nothing in the original four lines said what "provably" would end up costing.**
|
|
35
|
+
|
|
36
|
+
### Follow one all the way through
|
|
37
|
+
|
|
38
|
+
Two weeks later, a launch drives a traffic spike, the mail provider starts returning `429`, and signups start failing because an unrelated email is slow. You move the send off the request path:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
app.post("/signup", async (req, res) => {
|
|
42
|
+
const user = await db.users.create(req.body);
|
|
43
|
+
emailQueue.add("welcome", { userId: user.id });
|
|
44
|
+
res.json(user);
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The endpoint is fast again. It's also no longer one system — it's an endpoint, a queue, a worker, and a Redis connection nobody else on the team knew existed until a missing `REDIS_URL` broke staging.
|
|
49
|
+
|
|
50
|
+
<SixSystemsDrift />
|
|
51
|
+
|
|
52
|
+
From here the same pattern repeats on a longer clock. A support ticket reveals a job failed silently — nobody had configured retries, so you add them, and now a specific number (3? 5? with what backoff?) lives in a file that nobody will remember the reasoning for in six weeks. Compliance asks for proof of every email sent — you add a table written to from inside the worker, and now that worker has two jobs instead of one, quietly capable of disagreeing with itself if the second write fails. Someone double-clicks submit — two jobs enqueue, two emails send, and idempotency becomes a fact that has to live in two systems that were never introduced to each other.
|
|
53
|
+
|
|
54
|
+
None of these were mistakes. Each one was the correct call, made by a competent engineer, in direct response to something that actually happened.
|
|
55
|
+
|
|
56
|
+
### What's actually going on
|
|
57
|
+
|
|
58
|
+
Look at what the six resulting files have in common: none of them agree with each other about the same three things. What counts as "done." What happens on failure. Who's allowed to do this at all.
|
|
59
|
+
|
|
60
|
+
That's the real cost — not the number of tools, but what sits between them:
|
|
61
|
+
|
|
62
|
+
- **Failure means something different in each one.** A queue retry, an HTTP 500, and a rejected promise from a mail SDK are three unrelated shapes that all happen to mean "this didn't work."
|
|
63
|
+
- **Permission has no fixed address.** It lives wherever whoever wrote that file remembered to put it — which means a reviewer can't point at one place and ask "is this checked?"
|
|
64
|
+
- **Two systems both think they own the same fact.** The database says an order is paid. The already-running webhook handler doesn't know that yet. Nothing keeps them honest with each other in the gap.
|
|
65
|
+
- **Nobody can see the whole thing at once.** There is no file, diagram, or dashboard where "the signup flow" exists as one object — only as the sum of files that happen to call each other.
|
|
66
|
+
|
|
67
|
+
What would have to be true on day one for month eight to never happen?
|
|
68
|
+
|
|
69
|
+
## The model: one rule, in two parts
|
|
70
|
+
|
|
71
|
+
Every seam above came from the same root cause: each system involved had its own idea of when it should run and what it was allowed to touch, and nothing forced those ideas to agree with each other.
|
|
72
|
+
|
|
73
|
+
OKE removes the disagreement by removing the choice. It's one rule, in two parts.
|
|
74
|
+
|
|
75
|
+
**First: every trigger reduces to the same shape.** An HTTP request, a scheduled tick, a queue message, a database change — whatever wakes the code up, what follows has one identical anatomy: `on(Trigger) → Effects`. Not four systems that happen to look similar. One system, with four ways to wake it up.
|
|
76
|
+
|
|
77
|
+
**Second: every effect passes through one door.** Nothing is allowed to touch a database, send an email, check a permission, or read the clock on its own — all of it goes through a single surface. Not because that's tidier. Because it's the only way retries, auditing, idempotency, and permission checks stop being infrastructure every team reinvents at the exact moment they get burned by not having it.
|
|
78
|
+
|
|
79
|
+
<FlowShape />
|
|
80
|
+
|
|
81
|
+
That's the whole model. Not a bigger toolbox — a smaller number of things that are allowed to happen at all.
|
|
82
|
+
|
|
83
|
+
### What OKE is — and isn't
|
|
84
|
+
|
|
85
|
+
OKE is a backend programming model: behavior is expressed as Flows, effects are captured through `fx`, and the compiler turns that model into a versioned Manifest that powers the rest of the backend.
|
|
86
|
+
|
|
87
|
+
| OKE is not … | OKE is … |
|
|
88
|
+
| --------------------------------------------- | ----------------------------------------------------------------------- |
|
|
89
|
+
| Another queue, ORM, or mailer to wire up | One Flow species every trigger wakes up |
|
|
90
|
+
| A toolbox of forty clients with forty configs | Eight elements with irreducible physics — nothing else gets added |
|
|
91
|
+
| A platform you deploy into | TypeScript you host; Client, Console, and MCP derive from your Manifest |
|
|
92
|
+
|
|
93
|
+
One shape for every trigger, one door for every effect, a fixed vocabulary for what the door allows — that's what this project built. It's called **OKE**.
|
|
94
|
+
|
|
95
|
+
<sub>
|
|
96
|
+
*OKE isn't an acronym for anything in the code. It comes from Omq Khafi — the organization this
|
|
97
|
+
engine grew out of — with "Engine" appended: **O**mq **K**hafi **E**ngine.*
|
|
98
|
+
</sub>
|
|
99
|
+
|
|
100
|
+
## The vocabulary: eight elements, closed set
|
|
101
|
+
|
|
102
|
+
The door from the last section isn't open-ended — it recognizes a fixed set of things it's willing to do. Each one made the cut because it has _irreducible physics_: behavior that breaks if you tried to fake it using one of the others.
|
|
103
|
+
|
|
104
|
+
<Features />
|
|
105
|
+
|
|
106
|
+
| Element | What it is | What it replaces |
|
|
107
|
+
| ----------- | -------------------- | ----------------------------------------------------------------------- |
|
|
108
|
+
| **Flow** | Execution & behavior | endpoint, handler, consumer, job, workflow, webhook |
|
|
109
|
+
| **Signal** | Data in motion | queue, pub/sub, stream, websocket, SSE, event bus |
|
|
110
|
+
| **Store** | Data at rest | relational database, cache, key-value store, file storage, search index |
|
|
111
|
+
| **Clock** | Time & schedules | cron, every, delay, durable sleep, timeout |
|
|
112
|
+
| **Gate** | Permission to act | auth, session, tenancy, RBAC, scope, rate limit, public |
|
|
113
|
+
| **Vault** | Protected knowledge | secrets, encryption keys, rotation, environment variables |
|
|
114
|
+
| **Channel** | Reaching a human | transactional email, SMS, push notifications, receipts |
|
|
115
|
+
| **AI** | Machine intelligence | model calls, structured prompts, embeddings, agents, RAG |
|
|
116
|
+
|
|
117
|
+
Read the right column as the honest answer to "what would I have reached for before this?" Every item in it is a separate tool with its own configuration, its own failure modes, and its own place to go wrong.
|
|
118
|
+
|
|
119
|
+
The left column is the same ground, covered by something with one shared door and one shared set of guarantees. Where that distinction is real, it gets a name. Where it isn't, it doesn't — which is why the list stops at eight instead of growing indefinitely.
|
|
120
|
+
|
|
121
|
+
## The anatomy: five pieces behind every Flow
|
|
122
|
+
|
|
123
|
+
Everything a Flow does reduces to one line:
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
on(
|
|
127
|
+
trigger,
|
|
128
|
+
flow({
|
|
129
|
+
do: (input, fx) => {
|
|
130
|
+
/* ... */
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
If that line doesn't mean much yet, that's what this section is for. Five pieces make it up. We'll take them one at a time, then put them together using a complete signup example.
|
|
137
|
+
|
|
138
|
+
### `on(...)` — wires a trigger to a flow
|
|
139
|
+
|
|
140
|
+
`on` does exactly one thing: it connects "something that can happen" to "code that should run when it does." Nothing executes until this connection exists.
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
on(someTrigger, someFlow);
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
That's the whole job. The interesting parts are what goes in each slot.
|
|
147
|
+
|
|
148
|
+
### A trigger — the answer to "when"
|
|
149
|
+
|
|
150
|
+
The first argument to `on` is the trigger: whatever wakes the code up. A trigger doesn't run any of your logic — it only answers one question: _when should this happen?_
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
http.post(); // path from the file tree — e.g. src/flows/users/signup.ts → POST /users/signup
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
There are five kinds of trigger in total — the table at the end of this page lists them. For now: the trigger is the _when_, and it's the only thing that changes between an endpoint, a scheduled job, and everything else.
|
|
157
|
+
|
|
158
|
+
<FlowTriggers />
|
|
159
|
+
|
|
160
|
+
### `flow(...)` — the actual unit of work
|
|
161
|
+
|
|
162
|
+
The second argument to `on` is a Flow — declared with the `flow()` function. It answers _what_: what work is this, and what does it promise about its inputs and outputs?
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
flow({
|
|
166
|
+
do: /* the actual code — next */,
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Omit the name on tree files — the compiler stamps `unit.export` (e.g. `users.signup`).
|
|
171
|
+
Pass `flow("users.signup", { … })` only for control: barrels, stable names across
|
|
172
|
+
moves, or call-only Flows you `fx.call` by name.
|
|
173
|
+
|
|
174
|
+
### `do` — the code that actually runs
|
|
175
|
+
|
|
176
|
+
`do` is a function you write. It answers _how_. It receives two things: `input` (your data) and `fx` (next). Everything your Flow actually does lives here.
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
do: async (input, fx) => {
|
|
180
|
+
return { ok: true };
|
|
181
|
+
};
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### `fx` — the only door to the outside world
|
|
185
|
+
|
|
186
|
+
`fx` is the second argument to `do`, and it's the piece the other four exist to protect. The rule is simple and absolute: **your Flow is not allowed to read a database, send an email, check a clock, or touch anything outside itself except through `fx`.**
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
do: async (input, fx) => {
|
|
190
|
+
const user = await fx.store(db).insert(users).values(input); // the database, through fx
|
|
191
|
+
await fx.send(welcomeEmail, { to: user.email }); // another system, through fx
|
|
192
|
+
return user;
|
|
193
|
+
};
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
This one rule is what made the month-8 drift above avoidable: if `fx` is the only door, retries, auditing, and idempotency stop being separate systems teams build by hand, and become properties of the one boundary everything already passes through.
|
|
197
|
+
|
|
198
|
+
### Putting the five pieces together
|
|
199
|
+
|
|
200
|
+
Here is the complete signup flow, with every piece labeled where it sits:
|
|
201
|
+
|
|
202
|
+
```typescript title="src/flows/users/signup.ts"
|
|
203
|
+
export const signup = on(
|
|
204
|
+
http.post(), // ← trigger: when (stamped POST /users/signup)
|
|
205
|
+
flow({
|
|
206
|
+
// ↑ flow: what (stamped users.signup)
|
|
207
|
+
do: async (input, fx) => {
|
|
208
|
+
// ← do: how
|
|
209
|
+
const user = await fx.store(db).insert(users).values(input); // ← fx: the only way out
|
|
210
|
+
await fx.send(welcomeEmail, { to: user.email, data: { name: user.name } });
|
|
211
|
+
return user;
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
<Callout title="Call-only flows">
|
|
218
|
+
A `flow(...)` declared without `on(...)` around it is internal — nothing outside your code can
|
|
219
|
+
start it. Other flows invoke it directly with `fx.call(flowRef, input)`.
|
|
220
|
+
</Callout>
|
|
221
|
+
|
|
222
|
+
### Checking it against the timeline
|
|
223
|
+
|
|
224
|
+
Nothing about the code above looks more complicated than the four lines that started the drift — because it isn't. The difference only shows up when the same pressure from that timeline hits it. Every fork was really the same question — _is this safe to retry, safe to audit, safe to run twice?_ — and now it has one home:
|
|
225
|
+
|
|
226
|
+
| Then: a new system per incident | Now: one door, fixed answer |
|
|
227
|
+
| -------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
228
|
+
| **Week 2 spike** — hand-build a queue + worker to run the send later | Running later, safely, is asked of the trigger or the effect itself |
|
|
229
|
+
| **Month 2 silence** — a retry count in a worker nobody remembers | Retries are a property of the `fx` boundary every effect passes through |
|
|
230
|
+
| **Month 4 audit** — a `sent_emails` table written by hand from a job | What was sent is already known — nothing sends outside `fx` |
|
|
231
|
+
| **Month 6 double-submit** — dedup split across a queue and a DB | One call through one door — no second path for a duplicate |
|
|
232
|
+
|
|
233
|
+
None of that required new code beyond what's above. It required the four lines to already be the kind of thing where those questions have a fixed answer, instead of a new one invented per team, per incident.
|
|
234
|
+
|
|
235
|
+
### Five kinds of trigger
|
|
236
|
+
|
|
237
|
+
`flow`, `do`, and `fx` never change shape. Only the trigger does — and there are exactly five kinds, one per element that can independently wake a Flow up:
|
|
238
|
+
|
|
239
|
+
| Trigger | Element | Starts When |
|
|
240
|
+
| --------------------------------------------------- | ------- | -------------------------------- |
|
|
241
|
+
| `http.post()` (path from file tree) | Flow | A request arrives |
|
|
242
|
+
| `clock("name", { every: "10m" })` | Clock | A time interval elapses |
|
|
243
|
+
| `signal.once("name", {…})` / `.broadcast` / `.live` | Signal | Another flow announces something |
|
|
244
|
+
| `db.table(users).changed("email")` | Store | A database row changes |
|
|
245
|
+
| `mcp.tool("name")` | AI | An AI agent calls it |
|
|
246
|
+
|
|
247
|
+
The Elements section walks through each element in depth — this is just enough to recognize them when you see them.
|
|
248
|
+
|
|
249
|
+
## Where this goes next
|
|
250
|
+
|
|
251
|
+
You now hold the whole model: the drift, the rule, the eight elements, the five-piece anatomy. Don't read more — run it. From an empty folder to this exact signup Flow answering a real request, in one sitting:
|
|
252
|
+
|
|
253
|
+
<Cards>
|
|
254
|
+
<Card
|
|
255
|
+
title="Try It"
|
|
256
|
+
description="From an empty folder to a Flow running in the Console — one sitting, minimal detour."
|
|
257
|
+
href="/docs/understand/try-it"
|
|
258
|
+
/>
|
|
259
|
+
<Card
|
|
260
|
+
title="Elements"
|
|
261
|
+
description="When you're back: Flow, Signal, Store, Clock, Gate, Vault, Channel, AI — each in depth."
|
|
262
|
+
href="/docs/elements"
|
|
263
|
+
/>
|
|
264
|
+
</Cards>
|
package/src/bench/REPORT.md
CHANGED
|
@@ -180,13 +180,16 @@ Artifact: `G15-postgres-degradation-…json` (+ extra stress-level artifacts)
|
|
|
180
180
|
## G17 — Hybrid SQL search (BM25 / LSH / fusion)
|
|
181
181
|
|
|
182
182
|
**Command:** `OKE_BENCH=1 OKE_TEST_POSTGRES=1 DATABASE_URL=$DATABASE_URL bun test ./src/bench/g17-hybrid-search.bench.ts --timeout 3600000`
|
|
183
|
-
**
|
|
184
|
-
**Hardware:** Apple M4, 24 GB · trend-analysis only (not SLA; macOS ≠ Linux prod)
|
|
185
|
-
**Artifact:** `g17-hybrid-search-1789039639500.json`
|
|
183
|
+
**Hardware:** Apple M4, 24 GB · trend-analysis only (not SLA; macOS ≠ Linux prod)
|
|
186
184
|
|
|
187
|
-
Synthetic 32-dim embeddings (deterministic hash bag — not a production model). Modes: text-only (BM25/GIN), vector-only (LSH
|
|
185
|
+
Synthetic 32-dim embeddings (deterministic hash bag — not a production model). Modes: text-only (BM25/GIN), vector-only (LSH + cosine), hybrid (RRF k=60). Recall = precision@10 of the LSH/hybrid path vs **exact brute-force cosine** on the same in-memory vectors.
|
|
188
186
|
|
|
189
|
-
###
|
|
187
|
+
### Before — v0.19.0 (2026-09-10)
|
|
188
|
+
|
|
189
|
+
**Postgres:** 16.15 · **Artifact:** `g17-hybrid-search-1789039639500.json`
|
|
190
|
+
Query path: K=64 bucket equality plus Hamming-1 (`= ANY(65 buckets)`), oversample ≤50.
|
|
191
|
+
|
|
192
|
+
#### Latency (p50 / p99 ms)
|
|
190
193
|
|
|
191
194
|
| N | text p50 | text p99 | vector p50 | vector p99 | hybrid p50 | hybrid p99 |
|
|
192
195
|
| ---- | -------- | -------- | ---------- | ---------- | ---------- | ---------- |
|
|
@@ -195,7 +198,7 @@ Synthetic 32-dim embeddings (deterministic hash bag — not a production model).
|
|
|
195
198
|
| 100k | 39.86 | 59.73 | 57.05 | 126.14 | 52.08 | 288.09 |
|
|
196
199
|
| 1M | 877.57 | 2243.68 | 1165.5 | 3370.19 | 649.14 | 1861.91 |
|
|
197
200
|
|
|
198
|
-
|
|
201
|
+
#### Recall — LSH approx vs exact cosine (precision@10)
|
|
199
202
|
|
|
200
203
|
| N | vector P@10 | hybrid P@10 | exact cosine p50 (ms) | approx p50 (ms) |
|
|
201
204
|
| ---- | ----------- | ----------- | --------------------- | --------------- |
|
|
@@ -204,22 +207,50 @@ Synthetic 32-dim embeddings (deterministic hash bag — not a production model).
|
|
|
204
207
|
| 100k | **0.000** | **0.000** | ~36 | ~54 |
|
|
205
208
|
| 1M | 0.008 | **0.000** | ~445 | ~600 |
|
|
206
209
|
|
|
207
|
-
**
|
|
210
|
+
**Root cause (not an LSH-vs-HNSW tradeoff):** write-time and query-time buckets were byte-identical; `neighborBuckets` did emit 65 probes; `= ANY(?::bigint[])` matched stored rows. Hamming-1 at K=64 cannot see true neighbors. On this generator, same-topic docs sit at cosine ≈0.96 / Hamming ~5; a topic-string query vs those docs is cosine ≈0.82 / expected Hamming ~12. P(Hamming ≤ 1) at K=64 is ~10⁻⁵. A 40-row hand case recovered **0/10** exact neighbors via Hamming-1 and **10/10** via Hamming-rank + cosine. The hash-bag embedding is clustered (8 topics, near-duplicate bodies) — that makes exact top-10 at large N a lottery among the cluster, but it does **not** explain the zero collapse at N=40.
|
|
211
|
+
|
|
212
|
+
The 2026-09-10 EXPLAIN (Seq Scan, `tsv @@ q OR lsh = ANY(…)`) was a separate planner finding on that query shape.
|
|
213
|
+
|
|
214
|
+
### After — Hamming-rank candidate path (2026-09-11)
|
|
215
|
+
|
|
216
|
+
**Date:** 2026-09-11 · **Engine:** Bun 1.4.2 · **Postgres:** 16 (dedicated live instance, not PGlite)
|
|
217
|
+
**Artifact:** `g17-hybrid-search-1789135622349.json`
|
|
218
|
+
Fix: UNION of GIN lexical `LIMIT` and SimHash k-NN (`ORDER BY bit_count((lsh # query)::bit(64)) LIMIT oversample`). Same K=64, same oversample formula `max(limit×5, 50)` capped at 500. Not a K/oversample paper-over.
|
|
219
|
+
|
|
220
|
+
#### Latency (p50 / p99 ms)
|
|
221
|
+
|
|
222
|
+
| N | text p50 | text p99 | vector p50 | vector p99 | hybrid p50 | hybrid p99 |
|
|
223
|
+
| ---- | -------- | -------- | ---------- | ---------- | ---------- | ---------- |
|
|
224
|
+
| 1k | 0.88 | 2.8 | 1.27 | 2.37 | 1.28 | 1.54 |
|
|
225
|
+
| 10k | 4.5 | 17.21 | 9.36 | 29.03 | 6.88 | 32.48 |
|
|
226
|
+
| 100k | 21.03 | 27.61 | 30.61 | 49.8 | 34.08 | 89.99 |
|
|
227
|
+
| 1M | 305.61 | 377.7 | 388.98 | 548.36 | 391.96 | 469.76 |
|
|
228
|
+
|
|
229
|
+
#### Recall — LSH approx vs exact cosine (precision@10)
|
|
230
|
+
|
|
231
|
+
| N | vector P@10 | hybrid P@10 | exact cosine p50 (ms) | approx p50 (ms) |
|
|
232
|
+
| ---- | ----------- | ----------- | --------------------- | --------------- |
|
|
233
|
+
| 1k | 0.167 | 0.117 | 0.6 / 0.41 | 1.25 / 1.3 |
|
|
234
|
+
| 10k | 0.100 | 0.017 | ~6 | ~9 |
|
|
235
|
+
| 100k | 0.017 | 0.008 | ~45 | ~32 |
|
|
236
|
+
| 1M | 0.017 | 0.000 | ~535 | ~363 |
|
|
237
|
+
|
|
238
|
+
**Honest verdict after the fix:** the near-total collapse at 10k/100k is gone on the vector path (smooth 0.17 → 0.10 → 0.017). Remaining low precision@10 vs exact cosine is the real K=64 SimHash + 50-candidate oversample tradeoff on this near-duplicate hash-bag corpus — not a hashing mismatch. Still **not** a drop-in for HNSW/pgvector. Prefer BM25-only or an external `store.index` (`pgvector` / Meilisearch) when semantic recall matters.
|
|
208
239
|
|
|
209
|
-
### EXPLAIN (ANALYZE, BUFFERS) at N=100k
|
|
240
|
+
### EXPLAIN (ANALYZE, BUFFERS) at N=100k (after)
|
|
210
241
|
|
|
211
|
-
|
|
242
|
+
UNION Append (~14.7 ms): **Bitmap Index Scan** on the GIN tsvector (50 lexical rows) plus **Parallel Seq Scan** + top-N heapsort on `bit_count((lsh # query)::bit(64))` (50 SimHash-nearest). Hamming-rank cannot use the LSH B-tree (equality index). Do not claim BitmapOr from design alone — re-check `EXPLAIN` on production data volumes and `ANALYZE`d tables.
|
|
212
243
|
|
|
213
244
|
### Backfill kill / resume (50k pre-populated rows)
|
|
214
245
|
|
|
215
|
-
| Metric |
|
|
216
|
-
| ---------------------------------- |
|
|
217
|
-
| Kill after embeds | 2,000
|
|
218
|
-
| Embeds at kill | 2,000
|
|
219
|
-
| Kill wall | 726 ms |
|
|
220
|
-
| Resume wall (re-run to completion) | 29.3 s |
|
|
221
|
-
| Final embedded rows | 50,000 |
|
|
246
|
+
| Metric | Before (2026-09-10) | After (2026-09-11) |
|
|
247
|
+
| ---------------------------------- | ------------------- | ------------------ |
|
|
248
|
+
| Kill after embeds | 2,000 | 2,000 |
|
|
249
|
+
| Embeds at kill | 2,000 | 2,000 |
|
|
250
|
+
| Kill wall | 726 ms | 732 ms |
|
|
251
|
+
| Resume wall (re-run to completion) | 29.3 s | 28.3 s |
|
|
252
|
+
| Final embedded rows | 50,000 | 50,000 |
|
|
222
253
|
|
|
223
254
|
Re-running `oke db search-backfill` after interrupt completed idempotently (safe re-entry; DF/stats rebuilt).
|
|
224
255
|
|
|
225
|
-
**Issues recorded in artifact:** eight low-recall notes (
|
|
256
|
+
**Issues recorded in after artifact:** eight low-recall notes (P@10 still < 0.3 at every size — honest SimHash-vs-exact remainder, not the Hamming-1 zero collapse).
|
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
deserializePlanes,
|
|
32
32
|
lshBucket,
|
|
33
33
|
lshBucketToSql,
|
|
34
|
-
neighborBuckets,
|
|
35
34
|
} from "../elements/store/search-lsh.ts";
|
|
36
35
|
import { runSqlSearch, type SearchColumnMeta } from "../elements/store/search-runtime.ts";
|
|
37
36
|
import { resolveLivePg } from "./lib/infra.ts";
|
|
@@ -404,24 +403,25 @@ describe.skipIf(!ENABLED)("G17 hybrid search", () => {
|
|
|
404
403
|
const kPlanes = Number(prow["k"] ?? LSH_DEFAULT_K);
|
|
405
404
|
const planes = deserializePlanes(Buffer.from(prow["planes"] as Buffer), kPlanes);
|
|
406
405
|
const bucket = lshBucket(qVec, planes);
|
|
407
|
-
const
|
|
408
|
-
const explainSql = `EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
|
|
409
|
-
SELECT id FROM ${TABLE}
|
|
410
|
-
WHERE ${OKE_TSV_COL} @@ plainto_tsquery('english', $1)
|
|
411
|
-
OR ${lshColumn("body")} = ANY($2::bigint[])
|
|
412
|
-
LIMIT 50`;
|
|
406
|
+
const bucketSql = lshBucketToSql(bucket);
|
|
413
407
|
// Bun.SQL uses ? placeholders — fall back to interpolated literals for EXPLAIN only.
|
|
414
408
|
const safeQ = TOPICS[0]!.replaceAll("'", "''");
|
|
415
|
-
const bucketList = buckets.join(",");
|
|
416
409
|
const explainRows = await conn.query(
|
|
417
410
|
`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
411
|
+
(
|
|
412
|
+
SELECT id FROM ${TABLE}
|
|
413
|
+
WHERE ${OKE_TSV_COL} @@ plainto_tsquery('english', '${safeQ}')
|
|
414
|
+
LIMIT 50
|
|
415
|
+
)
|
|
416
|
+
UNION
|
|
417
|
+
(
|
|
418
|
+
SELECT id FROM ${TABLE}
|
|
419
|
+
WHERE ${lshColumn("body")} IS NOT NULL
|
|
420
|
+
ORDER BY bit_count((${lshColumn("body")} # ${bucketSql}::bigint)::bit(64)) ASC NULLS LAST
|
|
421
|
+
LIMIT 50
|
|
422
|
+
)`,
|
|
422
423
|
);
|
|
423
424
|
explainText = explainRows.map((r) => String(Object.values(r)[0] ?? "")).join("\n");
|
|
424
|
-
void explainSql;
|
|
425
425
|
console.log("G17 EXPLAIN (ANALYZE, BUFFERS):\n" + explainText);
|
|
426
426
|
if (!/Bitmap|Index|Seq Scan|BitmapOr|Bitmap Heap/i.test(explainText)) {
|
|
427
427
|
issues.push("EXPLAIN text lacked recognizable Postgres plan nodes");
|