okengine 0.2.8 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/AGENTS.md +17 -15
  2. package/README.md +57 -29
  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 +92 -0
  44. package/site/content/docs/plugins/csrf.mdx +96 -0
  45. package/site/content/docs/plugins/ip-allowlist.mdx +92 -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 +147 -0
  75. package/src/cli/openbao-bootstrap.ts +280 -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 +138 -0
  159. package/src/plugins/cors.ts +129 -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 +105 -0
  165. package/src/plugins/ip-allowlist.ts +76 -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
@@ -0,0 +1,171 @@
1
+ ---
2
+ title: "Gate"
3
+ description: "Permission to act — auth policies and rate limits attached to the trigger, evaluated before any effect runs, denied as typed errors."
4
+ icon: "ShieldCheck"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ Gate is how your app answers **"may this happen?"** before it happens: is this request from a verified member? has this IP exhausted its minute quota? Gates attach to the trigger — the pipeline evaluates them before a single store write, emit, or channel send runs.
9
+
10
+ <Callout title="The one rule">
11
+ Permission sits **at the trigger**, and denial is a typed error value — never a thrown exception,
12
+ never an effect that runs halfway and rolls back.
13
+ </Callout>
14
+
15
+ ## Quick start
16
+
17
+ <Steps>
18
+
19
+ <Step>
20
+ ### Declare your gates
21
+
22
+ Policies are named predicates; rate limits are declarative budgets. Both live once, app-wide:
23
+
24
+ ```typescript title="src/gates.ts"
25
+ import { gate } from "okengine";
26
+
27
+ export const member = gate.policy("member", ({ auth }) => !!auth?.verified);
28
+
29
+ export const fair = gate.rate({
30
+ strategy: "sliding-window-counter", // the default — near-exact, no boundary bursts
31
+ max: 60,
32
+ per: "1m",
33
+ keyBy: "ip",
34
+ });
35
+ ```
36
+
37
+ </Step>
38
+
39
+ <Step>
40
+ ### Attach them to triggers
41
+
42
+ `.gate(...)` composes in registration order — evaluated left to right, first denial wins:
43
+
44
+ ```typescript title="src/flows/links/shorten.ts"
45
+ export const shorten = on(
46
+ http.post("/links").gate(member, fair),
47
+ flow({
48
+ in: NewLink,
49
+ out: LinkCode,
50
+ errors: { Taken },
51
+ do: async ({ url, code }, fx) => {
52
+ /* runs only if both gates passed */
53
+ },
54
+ }),
55
+ );
56
+ ```
57
+
58
+ </Step>
59
+
60
+ <Step>
61
+ ### Denials are typed failures
62
+
63
+ A denied request never reaches `do`. It returns one of three typed failures, like any entry in `errors: { … }`:
64
+
65
+ | Gate result | Typed failure |
66
+ | -------------------------------- | ------------------------------------- |
67
+ | Policy denied, not authenticated | `Unauthorized` |
68
+ | Policy denied, authenticated | `Forbidden` (with gate name + reason) |
69
+ | Rate limit exceeded | `RateLimited` (with `retryAfterMs`) |
70
+
71
+ </Step>
72
+
73
+ </Steps>
74
+
75
+ ## Two kinds of gates
76
+
77
+ | Declaration | Question it answers | Evaluated against |
78
+ | -------------------------- | -------------------------------------- | ---------------------------------- |
79
+ | `gate.policy(name, check)` | Is this principal allowed? (ABAC) | auth / operator / request metadata |
80
+ | `gate.rate(options)` | Is there budget left for this subject? | an atomic counter on the kv driver |
81
+
82
+ ### The policy context
83
+
84
+ The predicate receives everything it may decide with — nothing else:
85
+
86
+ | Field | Contents |
87
+ | ---------- | -------------------------------------------------------- |
88
+ | `auth` | User principal: `userId`, `scopes` (a `Set`), `verified` |
89
+ | `operator` | Operator principal (Console plane): `id` |
90
+ | `meta` | Request metadata for keying decisions: `ip`, `userId`, … |
91
+
92
+ ```typescript
93
+ export const admin = gate.policy("admin", ({ auth }) => auth.scopes.has("admin"));
94
+ ```
95
+
96
+ A policy name containing `:` is also a `Module:Action` permission — the same declaration feeds role-based access reviews in the Console.
97
+
98
+ ### Rate options
99
+
100
+ | Option | Type | Default | Meaning |
101
+ | ------------- | ------------ | -------------------------- | -------------------------------------------- |
102
+ | `max` | number | — (**required**) | Takes allowed within `per` |
103
+ | `per` | string | — (**required**) | Window / refill period (`"1m"`, `"60s"`, …) |
104
+ | `keyBy` | string | — | Subject dimension (`"ip"`, `"user"`, …) |
105
+ | `strategy` | RateStrategy | `"sliding-window-counter"` | Algorithm (below) |
106
+ | `overridable` | boolean | `false` | Allow the Console to retune `max`/`per` live |
107
+
108
+ | Strategy | Behavior |
109
+ | ------------------------ | ------------------------------------------------------ |
110
+ | `sliding-window-counter` | Near-exact, two keys, no boundary bursts — the default |
111
+ | `fixed-window` | Cheapest; allows bursts at window boundaries |
112
+ | `sliding-log` | Exact; stores one timestamp per take |
113
+ | `token-bucket` | Steady refill; permits saved-up bursts |
114
+ | `leaky-bucket` | Smooths output to a constant rate |
115
+
116
+ All five run as atomic Lua on the kv driver — correct under concurrency, identical on the memory driver in tests.
117
+
118
+ ## Identity comes from auth
119
+
120
+ Gates don't log users in — they decide about principals that already exist. The built-in auth plugin (`okengine/auth`) provides the user plane: hybrid sessions, argon2id password hashing, roles, API keys, and invites, with zero-config defaults. Whatever it establishes lands on `fx.auth`, which is exactly what your policies read.
121
+
122
+ ## Every decision is recorded
123
+
124
+ Pass **and** deny, every evaluation lands on the run's `gates` dimension in the telemetry trace. The Console (`:6533` → Gates) shows which gates fired on a request and their outcomes — an audit trail of permission decisions you never had to build. Rate gates declared `overridable: true` can also be retuned (`max`/`per`) from the Console without a redeploy; anything else rejects edits by design.
125
+
126
+ ## Troubleshooting
127
+
128
+ <Accordions>
129
+ <Accordion title="Can a policy read the request body?">
130
+
131
+ No — and that is deliberate. Gates run before the flow, so they see only the policy context (`auth`, `operator`, `meta`). Body-dependent decisions belong inside `do`, expressed as typed `errors`.
132
+
133
+ </Accordion>
134
+ <Accordion title="Logged-in users still get Forbidden">
135
+
136
+ `Forbidden` means the request _was_ authenticated but a policy said no. The failure includes the gate name and reason — check whether your predicate requires `verified` or a scope the user lacks.
137
+
138
+ </Accordion>
139
+ <Accordion title="Which keyBy should I use?">
140
+
141
+ `"ip"` for public unauthenticated surfaces (sign-up, password reset), `"user"` for authenticated quotas. Keying an authenticated endpoint by IP punishes users behind shared NAT; keying a public endpoint by user is impossible.
142
+
143
+ </Accordion>
144
+ <Accordion title="The Console won't retune my rate limit">
145
+
146
+ The gate was declared without `overridable: true`. Add it and redeploy — live retuning is opt-in per gate so a mistyped change cannot silently open a floodgate.
147
+
148
+ </Accordion>
149
+ </Accordions>
150
+
151
+ ## Learn more
152
+
153
+ - [Flow](/docs/elements/flow) — the trigger pipeline gates plug into
154
+ - [Console · Gates](/docs/console/gates) — decision audit and live retuning
155
+ - [Vault](/docs/elements/vault) — the credentials your policies protect
156
+
157
+ ## Next
158
+
159
+ <Cards>
160
+ <Card title="Vault" description="Continue to Vault." href="/docs/elements/vault" />
161
+ <Card
162
+ title="Introduction"
163
+ description="Eight elements overview."
164
+ href="/docs/get-started/introduction"
165
+ />
166
+ <Card
167
+ title="Console"
168
+ description="Panels derived from the Manifest."
169
+ href="/docs/console/overview"
170
+ />
171
+ </Cards>
@@ -0,0 +1,5 @@
1
+ {
2
+ "title": "Elements",
3
+ "icon": "Boxes",
4
+ "pages": ["flow", "signal", "store", "clock", "gate", "vault", "channel", "ai"]
5
+ }
@@ -0,0 +1,171 @@
1
+ ---
2
+ title: "Signal"
3
+ description: "Data in motion — queues, pub/sub, and streams as one declaration whose delivery physics you choose explicitly."
4
+ icon: "Radio"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ Signal is how your app moves data between flows and to clients: background jobs, events, fan-out notifications, live feeds. What other stacks split into a queue library, a pub/sub client, and a streaming platform is **one declaration** here — you pick the delivery physics, and the driver underneath is swappable per environment.
9
+
10
+ <Callout title="The one rule">
11
+ `delivery` is **mandatory with no default**. Delivery physics is a semantic decision about your
12
+ data — guessing it produces silent, expensive bugs, so omitting it is a type error.
13
+ </Callout>
14
+
15
+ ## Quick start
16
+
17
+ <Steps>
18
+
19
+ <Step>
20
+ ### Declare the signal
21
+
22
+ A signal is a named, typed channel. The schema describes the payload; `delivery` describes its physics:
23
+
24
+ ```typescript title="src/signals.ts"
25
+ import { signal } from "okengine";
26
+ import { z } from "zod";
27
+
28
+ export const orderPlaced = signal("order-placed", {
29
+ schema: z.object({ orderId: z.string(), total: z.number() }),
30
+ delivery: "once", // queue physics: one consumer, retries, DLQ
31
+ retries: 3,
32
+ deadLetter: true,
33
+ });
34
+ ```
35
+
36
+ </Step>
37
+
38
+ <Step>
39
+ ### Emit it from a Flow
40
+
41
+ Producers never touch a broker client — they call `fx.emit`:
42
+
43
+ ```typescript title="src/flows/orders/place.ts"
44
+ do: async (input, fx) => {
45
+ await fx.store(db).insert(orders).values({ id: input.id /* … */ });
46
+ await fx.emit(orderPlaced, { orderId: input.id, total: input.total });
47
+ // With the postgres driver this enrols in the same transaction as the insert —
48
+ // the message is only ever sent if the row is committed.
49
+ };
50
+ ```
51
+
52
+ </Step>
53
+
54
+ <Step>
55
+ ### Consume it with a Flow
56
+
57
+ A signal is a trigger like any other — the consumer is an ordinary Flow:
58
+
59
+ ```typescript title="src/flows/orders/send-confirmation.ts"
60
+ export const sendConfirmation = on(
61
+ orderPlaced,
62
+ flow({
63
+ in: z.object({ orderId: z.string(), total: z.number() }),
64
+ do: async (input, fx) => {
65
+ await fx.channel(email).send(/* … */);
66
+ },
67
+ }),
68
+ );
69
+ ```
70
+
71
+ </Step>
72
+
73
+ </Steps>
74
+
75
+ That's the loop: declare → `fx.emit` → `on(signal, flow)`. The runtime handles routing, retries, and dead-lettering.
76
+
77
+ ## The three delivery physics
78
+
79
+ | `delivery` | Semantics | Use for |
80
+ | ------------- | ------------------------------------------------------ | ---------------------------------------------------------- |
81
+ | `"once"` | Queue: competing consumers, retries, dead-letter queue | Jobs that must happen exactly once — emails, payments sync |
82
+ | `"broadcast"` | Pub/sub: **every** subscriber receives a copy | Cache invalidation, cross-service events |
83
+ | `"live"` | Stream: client-subscribable and replayable | Live feeds, dashboards, progress updates |
84
+
85
+ The declaration is identical in shape for all three — switching physics later is a one-word change, not a migration to another library.
86
+
87
+ ### Options
88
+
89
+ | Option | Type | Default | Meaning |
90
+ | ------------ | --------------------------------- | ---------------- | -------------------------------------------------------------- |
91
+ | `delivery` | `"once" \| "broadcast" \| "live"` | — (**required**) | Delivery physics |
92
+ | `schema` | zod / Standard Schema | — | Payload contract; typed emits and Manifest docs |
93
+ | `retries` | number | `3` | Max delivery attempts before dead-letter (`once`) |
94
+ | `deadLetter` | boolean | `true` | Preserve exhausted messages in the DLQ (`once`) |
95
+ | `optional` | boolean | `false` | Allow emitting while nobody subscribes (skip the orphan check) |
96
+
97
+ ## When delivery fails
98
+
99
+ `once` signals retry automatically. Every attempt keeps a **typed failure reason**, and the full attempt history survives into the DLQ — so when a message lands there you see _why_ each attempt failed, not just that it did.
100
+
101
+ The **Console** (`:6533` → Signals) shows the topology (which flows emit and consume each signal), per-subscriber delivery stats, and the DLQ contents with replay / discard controls — no separate broker UI to run.
102
+
103
+ <Callout title="Orphan emits fail loudly">
104
+ Emitting a signal with zero subscribers is normally an error — it almost always means a typo or a
105
+ forgotten consumer. Set `optional: true` on the declaration only when zero-subscriber emits are
106
+ genuinely expected (e.g. an integration hook).
107
+ </Callout>
108
+
109
+ ## Per-environment drivers
110
+
111
+ ```typescript title="oke.config.ts"
112
+ drivers: {
113
+ signal: { local: "memory", docker: "postgres", test: "memory", prod: "postgres" },
114
+ },
115
+ ```
116
+
117
+ | Driver | Runs as | Best for |
118
+ | ---------- | -------------------------------- | ------------------------------------------------------------------------- |
119
+ | `memory` | in-process | Local loop + tests — zero infrastructure |
120
+ | `postgres` | your existing Postgres container | Default for docker/prod — emits join your DB transaction (outbox pattern) |
121
+ | `redis` | Redis container | Explicit alternative when throughput outgrows Postgres |
122
+ | `nats` | NATS container | High-throughput fan-out |
123
+
124
+ `postgres` is the default outside local dev for a reason: `fx.emit` enrols in the caller's transaction, so "write row + emit event" commits or rolls back **atomically** — the classic dual-write bug is designed out.
125
+
126
+ ## Troubleshooting
127
+
128
+ <Accordions>
129
+ <Accordion title="Emit fails with a 'no subscribers' error">
130
+
131
+ You emitted a signal that no flow consumes. Either wire a consumer with `on(signal, flow)`, or set `optional: true` on the declaration if zero-subscriber emits are intentional.
132
+
133
+ </Accordion>
134
+ <Accordion title="A message keeps retrying and then disappears">
135
+
136
+ After `retries` attempts the message moves to the DLQ — it is not lost. Open Console → Signals, inspect the typed failure reasons on each attempt, fix the consumer, then replay.
137
+
138
+ </Accordion>
139
+ <Accordion title="once vs broadcast vs live — how do I choose?">
140
+
141
+ Ask: _how many consumers should process each message?_ One → `once`. All of them → `broadcast`. Clients over time, with replay → `live`. If you need retries and a DLQ, you want `once`.
142
+
143
+ </Accordion>
144
+ <Accordion title="My emit happened but the row didn't (or vice versa)">
145
+
146
+ That is the dual-write problem, and it means the signal driver is not `postgres`. With the `postgres` driver the emit joins your SQL transaction; with `redis` / `nats` an outbox relay keeps the guarantee, but `postgres` is the only mode where atomicity is literal.
147
+
148
+ </Accordion>
149
+ </Accordions>
150
+
151
+ ## Learn more
152
+
153
+ - [Flow](/docs/elements/flow) — `on(trigger, flow)` and `fx.emit`
154
+ - [Console · Signals](/docs/console/signals) — topology, delivery stats, DLQ replay
155
+ - [Clock](/docs/elements/clock) — scheduled and delayed work
156
+
157
+ ## Next
158
+
159
+ <Cards>
160
+ <Card title="Store" description="Continue to Store." href="/docs/elements/store" />
161
+ <Card
162
+ title="Introduction"
163
+ description="Eight elements overview."
164
+ href="/docs/get-started/introduction"
165
+ />
166
+ <Card
167
+ title="Console"
168
+ description="Panels derived from the Manifest."
169
+ href="/docs/console/overview"
170
+ />
171
+ </Cards>
@@ -0,0 +1,320 @@
1
+ ---
2
+ title: "Store"
3
+ description: "Data at rest — SQL tables, KV cache, file blobs, and vector search, declared once and swapped per environment by driver."
4
+ icon: "Database"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ Store is where your app's **data at rest** lives. It covers four kinds of storage — relational tables, key-value cache, file blobs, and vector search — behind one declaration style. Your flow code never changes between SQLite on your laptop and Postgres in production; only the driver does.
9
+
10
+ <Callout title="The one rule">
11
+ Drivers are named after **protocols**, not vendors (`postgres`, `redis`, `s3` — never `neon` or
12
+ `minio`). Vendor choice lives in the `images` map of `oke.config.ts`.
13
+ </Callout>
14
+
15
+ ## Quick start
16
+
17
+ <Steps>
18
+
19
+ <Step>
20
+ ### Declare a table
21
+
22
+ In `src/schema.decl.ts`, describe your tables with plain field builders — no ORM syntax:
23
+
24
+ ```typescript title="src/schema.decl.ts"
25
+ import { store, field, id, now } from "okengine";
26
+
27
+ export const notes = store.schema.table("notes", {
28
+ id: field.text().primaryKey().defaultFn(id),
29
+ title: field.text().notNull(),
30
+ body: field.text().notNull(),
31
+ createdAt: field.integer().notNull().defaultFn(now),
32
+ });
33
+ ```
34
+
35
+ </Step>
36
+
37
+ <Step>
38
+ ### Declare the store
39
+
40
+ Bind the schema to a SQL store in `src/core.ts`:
41
+
42
+ ```typescript title="src/core.ts"
43
+ import { store } from "okengine";
44
+ import { notes } from "./schema.decl";
45
+
46
+ export const db = store.sql("notes", { schema: { notes } });
47
+ ```
48
+
49
+ </Step>
50
+
51
+ <Step>
52
+ ### Push the schema
53
+
54
+ `oke dev` runs this automatically on save; or run it by hand:
55
+
56
+ ```bash
57
+ oke db push # dev — applies the schema to your local database
58
+ ```
59
+
60
+ </Step>
61
+
62
+ <Step>
63
+ ### Read and write in a Flow
64
+
65
+ All data access goes through `fx.store(db)` — a typed session, one table at a time:
66
+
67
+ ```typescript title="src/flows/notes/create.ts"
68
+ export const createNote = on(
69
+ http.post("/notes"),
70
+ flow({
71
+ in: z.object({ title: z.string(), body: z.string() }),
72
+ out: z.object({ id: z.string() }),
73
+ do: async (input, fx) => {
74
+ const id = fx.id();
75
+ await fx
76
+ .store(db)
77
+ .insert(notes)
78
+ .values({ id, ...input, createdAt: Date.now() });
79
+ return { id };
80
+ },
81
+ }),
82
+ );
83
+ ```
84
+
85
+ </Step>
86
+
87
+ </Steps>
88
+
89
+ ## The four facets
90
+
91
+ Pick the facet that matches the physics of your data. Each declaration is one line; the runtime handle shows what flows can do with it.
92
+
93
+ | Facet | Declares | Best for | `fx.store(…)` handle |
94
+ | ------------------------- | ----------------- | ------------------------------------- | ------------------------------------------------------ |
95
+ | `store.sql(name, opts)` | a SQL database | domain tables, relations, constraints | `select` · `insert` · `update` · `delete` · `findById` |
96
+ | `store.kv(name)` | a key-value space | cache, sessions, rate limits | `get` · `set(key, value, ttl?)` · `delete` · `list` |
97
+ | `store.files(name)` | a blob bucket | uploads, exports, attachments | `put` · `get` · `delete` · `list(prefix?)` |
98
+ | `store.index(name, opts)` | a vector index | semantic search / RAG (`dims`) | `upsert` · `search(vector, topK?)` · `delete` |
99
+
100
+ ```typescript
101
+ export const cache = store.kv("sessions");
102
+ export const uploads = store.files("attachments");
103
+ export const embeddings = store.index("docs", { dims: 1536 });
104
+ ```
105
+
106
+ ## CRUD without boilerplate — `store.resource`
107
+
108
+ Five conventional endpoints (list, create, get, update, remove) expand from one declaration. Each is an ordinary Flow underneath — same contracts, same `fx`:
109
+
110
+ ```typescript
111
+ const notesR = store.resource(db, notes, {
112
+ in: NewNote, // create/update input schema
113
+ out: Note, // response schema
114
+ update: NewNote.partial(),
115
+ list: {
116
+ cursor: [notes.createdAt, notes.id], // keyset pagination columns
117
+ direction: "desc",
118
+ search: [notes.title], // ?search= / ?q=
119
+ filter: "all", // ?col=op.value — "all" | Column[] | "none"
120
+ order: "all", // ?order=col.desc
121
+ },
122
+ unit: "notes",
123
+ });
124
+
125
+ const mounted = on(http.resource("/notes", notesR.all()));
126
+ ```
127
+
128
+ The list endpoint's URL is the whole query language:
129
+
130
+ | Param | Meaning | Example |
131
+ | ----------------------------------- | --------------------------------------------------- | ----------------------- |
132
+ | `?cursor=` / `?offset=` / `?limit=` | paginate (keyset when `cursor` columns are set) | `?limit=20&cursor=eyJ…` |
133
+ | `?search=` (`?q=`) | substring match over `search` columns | `?q=invoice` |
134
+ | `?col=op.value` | filter — ops `eq ne gt gte lt lte like ilike in is` | `?title=like.%draft%` |
135
+ | `?or=(…)` / `?and=(…)` | grouped boolean filters | `?or=(a.eq.1,b.eq.2)` |
136
+ | `?order=` | sort | `?order=createdAt.desc` |
137
+ | `?select=` | project columns | `?select=id,title` |
138
+
139
+ Responses follow the Stripe-style envelope: `{ data, meta: { nextCursor, hasNextPage }, error }`. `create` answers **201**, `remove` answers **204**, and a missing row is a typed `NotFound` — never a crash.
140
+
141
+ ## Querying by hand
142
+
143
+ When `store.resource` is too conventional, `fx.store(db)` is the full single-table session:
144
+
145
+ ```typescript
146
+ // select — chain where / orderBy / limit / offset in any order
147
+ const latest = await fx
148
+ .store(db)
149
+ .select()
150
+ .from(notes)
151
+ .where(like(notes.title, `%${input.q}%`))
152
+ .orderBy(desc(notes.createdAt))
153
+ .limit(20);
154
+
155
+ // findById, insert, update, delete
156
+ const one = await fx.store(db).findById(notes, input.id);
157
+ await fx.store(db).update(notes).set({ title: input.title }).where(eq(notes.id, input.id));
158
+ await fx.store(db).delete(notes).where(lt(notes.createdAt, cutoff));
159
+ ```
160
+
161
+ <Callout title="One table per call — no relational with:">
162
+ `fx.store` is deliberately **single-table**: Drizzle's relational `findMany({ with: … })` is not
163
+ available through `fx`. Compose joins as separate single-table reads (or `fx.call`) so every
164
+ table shows up explicitly in the Manifest's `reads` / `writes` — powering caching and PII masking.
165
+ </Callout>
166
+
167
+ ## Schema — declare once, generate per dialect
168
+
169
+ The recommended path: declare tables ORM-agnostically, then let `oke db` emit real Drizzle for the active dialect (`sqliteTable` locally, `pgTable` in docker/prod) into `src/schema.generated.ts`.
170
+
171
+ | Field API | Meaning |
172
+ | -------------------------------------------- | ----------------------------------------------- |
173
+ | `field.text()` / `field.integer()` | v1 column primitives |
174
+ | `.primaryKey()` · `.notNull()` · `.unique()` | constraints |
175
+ | `.default(v)` · `.defaultFn(id \| now)` | defaults |
176
+ | `.pii()` · `.sensitive()` · `.retain("30d")` | privacy classification |
177
+ | `.as("sql_name")` | override the automatic `camelCase → snake_case` |
178
+ | `.references(() => col, { onDelete })` | foreign key |
179
+
180
+ ### Foreign keys and relations
181
+
182
+ Declare FKs on fields, and relation metadata once per schema:
183
+
184
+ ```typescript
185
+ export const daily = store.schema.table("daily", {
186
+ id: field.text().primaryKey(),
187
+ code: field
188
+ .text()
189
+ .notNull()
190
+ .references(() => links.code),
191
+ day: field.text().notNull(),
192
+ clicks: field.integer().notNull().default(0),
193
+ });
194
+
195
+ export const relations = store.schema.relations({ links, daily }, (r) => ({
196
+ links: { daily: r.many.daily({ from: r.links.code, to: r.daily.code }) },
197
+ daily: { link: r.one.links({ from: r.daily.code, to: r.links.code, optional: false }) },
198
+ }));
199
+ ```
200
+
201
+ <Callout title="Many-to-many">
202
+ A junction table with two foreign keys plus two `one` / `many` relations composes many-to-many.
203
+ There is no separate API and no `.through()` — the junction is an ordinary table.
204
+ </Callout>
205
+
206
+ ### Syncing the schema
207
+
208
+ | Command | When |
209
+ | ----------------- | -------------------------------------------------------------- |
210
+ | `oke db push` | Dev — apply directly to the live local DB (no migration files) |
211
+ | `oke db generate` | Write versioned SQL under `drizzle/` for review |
212
+ | `oke db migrate` | Apply those files — human or CI, **never** at boot |
213
+
214
+ `oke dev` (local) auto-pushes when the schema file changes — opt out with `--no-db-push` or `db: { autoPush: false }`. Docker/prod **never** auto-apply DDL; a missing table fails loudly as **OKE1101**, telling you to run `oke db migrate`.
215
+
216
+ <Callout title="Escape hatch">
217
+ Hand-written Drizzle in `src/schema.ts` stays supported — if there is nothing to emit, the emit
218
+ step is skipped and your file is used as-is. Plugins may contribute **whole new tables**;
219
+ extending an app-owned table with plugin columns is not supported in v1.
220
+ </Callout>
221
+
222
+ ## Per-environment drivers
223
+
224
+ Same flow code, different backends — configured once in `oke.config.ts`:
225
+
226
+ ```typescript title="oke.config.ts"
227
+ drivers: {
228
+ store: {
229
+ sql: { local: "sqlite", docker: "postgres", test: "memory", prod: "postgres" },
230
+ kv: { local: "memory", docker: "redis", test: "memory", prod: "redis" },
231
+ files: { local: "fs", docker: "s3", test: "memory", prod: "s3" },
232
+ },
233
+ },
234
+ ```
235
+
236
+ | Facet | Local | Docker / prod | Runs as |
237
+ | ------- | -------- | ------------- | --------------------------------------- |
238
+ | `sql` | `sqlite` | `postgres` | file on disk → container + named volume |
239
+ | `kv` | `memory` | `redis` | in-process → container |
240
+ | `files` | `fs` | `s3` | project folder → RustFS container |
241
+ | `index` | `memory` | `pgvector` | in-process → pgvector image |
242
+
243
+ Container images come from the `images` map — change the vendor by changing the pin, never the driver id.
244
+
245
+ ## Privacy built in
246
+
247
+ Columns tagged `.pii()` or `.sensitive()` are masked at the store boundary — flows, logs, and the Console see a mask, not the value. Revealing cleartext PII requires an explicit `pii:reveal` gate on the flow, so access is a permission, not a convention.
248
+
249
+ ## Examples — what follows from each choice
250
+
251
+ ### An admin table
252
+
253
+ ```typescript
254
+ list: { mode: "offset", count: "exact", limit: 20 },
255
+ ```
256
+
257
+ **Consequence:** `count: "exact"` (the offset default) runs `COUNT(*)` to fill `meta.total`. On a huge table that count is the real cost — set `count: "none"` to return only `meta.offset`.
258
+
259
+ ### An infinite feed
260
+
261
+ ```typescript
262
+ list: { cursor: [notes.createdAt, notes.id], direction: "desc", limit: 20, maxLimit: 100 },
263
+ ```
264
+
265
+ **Consequence:** keyset (cursor) paging is the default when `cursor` columns are set — pages stay stable while new rows are inserted, where offset pages would shift and show duplicates.
266
+
267
+ ### A public, restricted endpoint
268
+
269
+ ```typescript
270
+ list: { mode: "offset", filter: "none", limit: 20 },
271
+ ```
272
+
273
+ **Consequence:** a request that filters on a forbidden column — `?secret=eq.x` — fails with **422** and the exact message `unknown list param "secret"`. Filterable columns are a whitelist, never an accident.
274
+
275
+ ## Troubleshooting
276
+
277
+ <Accordions>
278
+ <Accordion title="OKE1101 — missing table in docker or prod">
279
+
280
+ Schema DDL never runs automatically outside local dev. Run `oke db push` against the target database for a dev-style sync, or `oke db generate` + `oke db migrate` for reviewed, versioned migrations.
281
+
282
+ </Accordion>
283
+ <Accordion title="I need a join — with: is not supported">
284
+
285
+ `fx.store` is one table per call by design. Read each table separately and compose in the flow (or extract a shared flow and `fx.call` it). Every table then appears explicitly in the Manifest's effect graph — which is what powers cache invalidation and PII masking.
286
+
287
+ </Accordion>
288
+ <Accordion title="Pagination shows duplicates when rows are inserted">
289
+
290
+ You are on offset paging. Switch `list` to keyset by setting `cursor` columns with a stable order (e.g. `[createdAt, id]`) — the page boundary becomes a row predicate, not a row count.
291
+
292
+ </Accordion>
293
+ <Accordion title="meta.total is slow on a big table">
294
+
295
+ `count: "exact"` runs `COUNT(*)` per page. Set `count: "none"` in the `list` options to skip it, or use keyset mode where totals are rarely needed.
296
+
297
+ </Accordion>
298
+ </Accordions>
299
+
300
+ ## Learn more
301
+
302
+ - [Flow](/docs/elements/flow) — the `fx.store` session inside `do`
303
+ - [Console · Store](/docs/console/store) — browse data, cache keys, PII masking
304
+ - [CLI Reference](/docs/reference/cli) — `oke db push` · `generate` · `migrate`
305
+
306
+ ## Next
307
+
308
+ <Cards>
309
+ <Card title="Clock" description="Continue to Clock." href="/docs/elements/clock" />
310
+ <Card
311
+ title="Introduction"
312
+ description="Eight elements overview."
313
+ href="/docs/get-started/introduction"
314
+ />
315
+ <Card
316
+ title="Console"
317
+ description="Panels derived from the Manifest."
318
+ href="/docs/console/overview"
319
+ />
320
+ </Cards>