okengine 0.17.2 → 0.18.3

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 (197) hide show
  1. package/README.md +9 -5
  2. package/manifest.v1.schema.json +61 -2
  3. package/package.json +18 -4
  4. package/site/content/docs/elements/clock.mdx +1 -1
  5. package/site/content/docs/elements/flow.mdx +25 -1
  6. package/site/content/docs/elements/store.mdx +287 -341
  7. package/site/content/docs/elements/vault.mdx +5 -5
  8. package/site/content/docs/get-started/installation.mdx +1 -2
  9. package/site/content/docs/get-started/introduction.mdx +54 -110
  10. package/site/content/docs/get-started/meta.json +9 -1
  11. package/site/content/docs/get-started/testing.mdx +328 -0
  12. package/site/content/docs/get-started/why.mdx +94 -70
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/plugins/apple.mdx +151 -0
  15. package/site/content/docs/plugins/discord.mdx +139 -0
  16. package/site/content/docs/plugins/facebook.mdx +134 -0
  17. package/site/content/docs/plugins/figma.mdx +138 -0
  18. package/site/content/docs/plugins/github.mdx +138 -0
  19. package/site/content/docs/plugins/google.mdx +153 -0
  20. package/site/content/docs/plugins/index.mdx +47 -1
  21. package/site/content/docs/plugins/meta.json +10 -0
  22. package/site/content/docs/plugins/microsoft.mdx +151 -0
  23. package/site/content/docs/plugins/oauth.mdx +188 -0
  24. package/site/content/docs/plugins/x.mdx +125 -0
  25. package/site/content/docs/reference/cli.md +3 -2
  26. package/site/content/docs/reference/client.mdx +58 -1
  27. package/site/content/docs/reference/configuration.mdx +2 -4
  28. package/site/content/docs/reference/fx.mdx +3 -1
  29. package/site/content/docs/reference/index.mdx +0 -5
  30. package/site/content/docs/reference/meta.json +2 -2
  31. package/site/content/docs/reference/okid.mdx +137 -0
  32. package/src/auth/bindings.ts +1 -1
  33. package/src/auth/config.ts +9 -0
  34. package/src/auth/identity-sql.ts +314 -0
  35. package/src/auth/identity.ts +140 -2
  36. package/src/auth/index.ts +17 -1
  37. package/src/auth/method-context.ts +3 -0
  38. package/src/auth/oauth-as/cimd.ts +132 -0
  39. package/src/auth/oauth-as/crypto.test.ts +101 -0
  40. package/src/auth/oauth-as/crypto.ts +393 -0
  41. package/src/auth/oauth-as/errors.ts +68 -0
  42. package/src/auth/oauth-as/http.test.ts +419 -0
  43. package/src/auth/oauth-as/http.ts +842 -0
  44. package/src/auth/oauth-as/stores.ts +61 -0
  45. package/src/auth/oauth-as/tables.ts +142 -0
  46. package/src/auth/tables.ts +0 -11
  47. package/src/bench/README.md +83 -0
  48. package/src/bench/REPORT.md +176 -0
  49. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  50. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  51. package/src/bench/g03-signal-once.bench.ts +157 -0
  52. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  53. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  54. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  55. package/src/bench/g05-sustained-full.bench.ts +265 -0
  56. package/src/bench/g06-mixed-load.bench.ts +260 -0
  57. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  58. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  59. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  60. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  61. package/src/bench/g08-store-sql.bench.ts +178 -0
  62. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  63. package/src/bench/g10-observability-contention.bench.ts +246 -0
  64. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  65. package/src/bench/g13-elements.bench.ts +427 -0
  66. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  67. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  68. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  69. package/src/bench/lib/event-loop-lag.ts +26 -0
  70. package/src/bench/lib/infra.ts +60 -0
  71. package/src/bench/lib/report.ts +52 -0
  72. package/src/bench/lib/rss-sampler.ts +61 -0
  73. package/src/bench/lib/signal-pg.ts +88 -0
  74. package/src/bench/load-app.ts +337 -0
  75. package/src/bench/load-child.ts +108 -0
  76. package/src/bench/smoke.bench.ts +43 -0
  77. package/src/cli/competitor-mention-removal.test.ts +28 -0
  78. package/src/cli/doctor-fd.ts +117 -0
  79. package/src/cli/doctor.test.ts +192 -0
  80. package/src/cli/doctor.ts +129 -1
  81. package/src/client/create.ts +95 -1
  82. package/src/client/index.ts +9 -2
  83. package/src/client/transport.ts +11 -4
  84. package/src/client/use-live-query.ts +154 -0
  85. package/src/client-react/index.ts +15 -1
  86. package/src/client-react/live-resource.ts +246 -0
  87. package/src/client-react/use-live-query.test.ts +475 -0
  88. package/src/client-react/use-live-query.ts +530 -0
  89. package/src/compiler/extract.test.ts +518 -0
  90. package/src/compiler/extract.ts +386 -19
  91. package/src/console/server/invoke-user-flow.ts +2 -1
  92. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
  95. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
  96. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  97. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
  98. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
  99. package/src/console/ui-next/dist/index.html +1 -1
  100. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  101. package/src/drivers/cdc-outbox.ts +389 -0
  102. package/src/drivers/memory.ts +20 -0
  103. package/src/drivers/oauth-apple.ts +156 -0
  104. package/src/drivers/oauth-discord.ts +79 -0
  105. package/src/drivers/oauth-facebook.ts +80 -0
  106. package/src/drivers/oauth-figma.ts +116 -0
  107. package/src/drivers/oauth-github.ts +92 -0
  108. package/src/drivers/oauth-google.ts +142 -0
  109. package/src/drivers/oauth-microsoft.ts +174 -0
  110. package/src/drivers/oauth-oidc.ts +293 -0
  111. package/src/drivers/oauth-shared.ts +326 -0
  112. package/src/drivers/oauth-types.ts +159 -0
  113. package/src/drivers/oauth-x.ts +77 -0
  114. package/src/drivers/oauth2-common.ts +95 -0
  115. package/src/drivers/oauth2-token.ts +61 -0
  116. package/src/drivers/pg-rls-row-passes.ts +251 -0
  117. package/src/drivers/pg-rls.ts +2 -0
  118. package/src/drivers/postgres.ts +45 -2
  119. package/src/drivers/signal-postgres.ts +2 -1
  120. package/src/elements/channel/runtime.ts +29 -2
  121. package/src/elements/channel.test.ts +52 -0
  122. package/src/elements/gate/boot.ts +29 -2
  123. package/src/elements/store/emit-drizzle.ts +147 -14
  124. package/src/elements/store/field-ddl.test.ts +118 -0
  125. package/src/elements/store/field-types.test.ts +455 -0
  126. package/src/elements/store/list-query.golden.json +777 -0
  127. package/src/elements/store/list-query.parity.test.ts +396 -0
  128. package/src/elements/store/list-query.ts +792 -0
  129. package/src/elements/store/live-default.test.ts +136 -0
  130. package/src/elements/store/live-http.test.ts +160 -0
  131. package/src/elements/store/live-isolation.test.ts +291 -0
  132. package/src/elements/store/live-query-runtime.test.ts +323 -0
  133. package/src/elements/store/live-query-runtime.ts +403 -0
  134. package/src/elements/store/live-query-server.test.ts +377 -0
  135. package/src/elements/store/live-query-server.ts +102 -0
  136. package/src/elements/store/live-query.ts +97 -0
  137. package/src/elements/store/resource.ts +189 -680
  138. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  139. package/src/elements/store/schema-decl.ts +539 -41
  140. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  141. package/src/elements/store/sql-session.ts +297 -35
  142. package/src/elements/store/table.ts +102 -21
  143. package/src/elements/store.test.ts +3 -1
  144. package/src/elements/store.ts +12 -1
  145. package/src/elements/vault/chaos-child.ts +74 -1
  146. package/src/elements/vault/chaos.test.ts +4 -2
  147. package/src/elements/vault/storage.ts +4 -2
  148. package/src/index.ts +4 -1
  149. package/src/kernel/app-auth.ts +1 -0
  150. package/src/kernel/app.ts +116 -2
  151. package/src/kernel/auth-sharing.test.ts +196 -0
  152. package/src/kernel/boot.test.ts +3 -3
  153. package/src/kernel/errors.ts +8 -0
  154. package/src/kernel/fx.test.ts +1 -0
  155. package/src/kernel/fx.ts +14 -2
  156. package/src/kernel/horizontal-child.ts +2 -1
  157. package/src/kernel/http-resource.ts +33 -7
  158. package/src/kernel/identity-host-persist.test.ts +119 -0
  159. package/src/kernel/instance-id.ts +4 -2
  160. package/src/kernel/journal.ts +2 -1
  161. package/src/kernel/mcp-tool.test.ts +95 -0
  162. package/src/kernel/on.ts +9 -0
  163. package/src/kernel/realtime-bind.ts +326 -0
  164. package/src/kernel/resource-live.ts +117 -0
  165. package/src/kernel/triggers.ts +86 -4
  166. package/src/manifest/diff.ts +37 -0
  167. package/src/manifest/types.ts +64 -2
  168. package/src/okid.bench.test.ts +64 -0
  169. package/src/okid.test.ts +338 -0
  170. package/src/okid.ts +245 -0
  171. package/src/plugins/anonymous.ts +19 -1
  172. package/src/plugins/auth/shared.ts +15 -0
  173. package/src/plugins/index.ts +2 -0
  174. package/src/plugins/magic-link.ts +10 -8
  175. package/src/plugins/mcp-oauth.ts +208 -0
  176. package/src/plugins/oauth/flow-store.ts +117 -0
  177. package/src/plugins/oauth/link.ts +69 -0
  178. package/src/plugins/oauth/shared.ts +108 -0
  179. package/src/plugins/oauth/token-vault.ts +100 -0
  180. package/src/plugins/oauth.security.test.ts +535 -0
  181. package/src/plugins/oauth.ts +532 -0
  182. package/src/plugins/otp.ts +48 -6
  183. package/src/plugins/passkey.ts +20 -1
  184. package/src/plugins/two-factor.ts +11 -0
  185. package/src/plugins/username.ts +40 -7
  186. package/src/release/build-lib.ts +7 -1
  187. package/src/release/measure.ts +1 -0
  188. package/src/release/official-plugins.ts +4 -1
  189. package/src/runs/collect.ts +2 -1
  190. package/src/runs/drivers/files.ts +2 -1
  191. package/src/test/create-test-app.ts +114 -5
  192. package/src/test/export-bundle.test.ts +33 -0
  193. package/src/test/live-signals.test.ts +83 -0
  194. package/src/test/tenant-isolation.test.ts +175 -0
  195. package/src/testing.ts +26 -0
  196. package/src/upgrade/codemods.ts +1 -1
  197. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -5,9 +5,7 @@ icon: "Database"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
8
- Store is one handle, four independent facets — **sql · kv · files · index**. Each has its own
9
- shape; nothing forces you to use more than one. Flow code stays the same across environments;
10
- only the driver changes.
8
+ Store is one handle, four independent facets — **sql · kv · files · index**. Each has its own shape; nothing forces you to use more than one. Flow code stays the same across environments; only the driver changes. Every facet accepts an optional `description` — a human title in the Console (falls back to the store name) — and auto-registers when its declaring module is imported before `oke()` (`oke({ stores })` is still valid and additive).
11
9
 
12
10
  <Callout title="The one rule">
13
11
  Drivers are named after **protocols**, not vendors (`postgres`, `redis`, `s3` — never `neon` or
@@ -16,98 +14,7 @@ only the driver changes.
16
14
 
17
15
  <StoreFacets />
18
16
 
19
- Every facet accepts optional `description` — a human title in the Console (falls back to the store
20
- name).
21
-
22
- `store.sql` / `kv` / `files` / `index` auto-register when the declaring module is imported before
23
- `oke()`. `oke({ stores })` is still valid and additive.
24
-
25
- ## SQL
26
-
27
- <StoreFacetMark facet="sql" />
28
-
29
- The relational facet: tables, schema, and typed single-table sessions through `fx.store`.
30
-
31
- The Console SQL band opens a query console (`POST /console/store/sql`) for
32
- DML and DDL. ⌘Enter runs the current statement or selection; ⌘⇧Enter runs
33
- the whole script sequentially and stops on the first error.
34
-
35
- EXPLAIN / EXPLAIN ANALYZE wrap the current statement. A script with more
36
- than one result opens a tab per statement.
37
-
38
- The table grid can insert a row (`POST /console/store/edit` without `id`).
39
- Double-click a tab to rename; Save keeps named queries. Completions cover
40
- Manifest tables, columns, and keywords.
41
-
42
- Classified PII stays masked unless the toolbar **PII** control is on
43
- (`revealPii: true`, audited).
44
-
45
- **Gate** views as Operator (bypass), public, a user, or a policy. SQL,
46
- browse, and grid edit send `{ asGate, asUserId }`. postgres / pglite stamp
47
- `oke.gate()` / `oke.user()` / `oke.has_scope()`; memory is catalog-only.
48
-
49
- The SQL band **Schema** control opens a visualizer of Manifest tables and
50
- relations — declared `.references()` plus inferred `*_id` columns — not the
51
- query-console Schema rail.
52
-
53
- <Callout title="Query performance">
54
- SQL **Performance** reads `pg_stat_statements` and live locks
55
- (`QUERY /console/store/sql/stats` and `/locks`).
56
-
57
- Alpine needs preload plus `CREATE EXTENSION`. Index Advisor is opt-in on
58
- [Postgres](/docs/recipes/postgres#query-performance).
59
-
60
- KV **Performance** reads Redis-wire `INFO` / `COMMANDSTATS` / `SLOWLOG`
61
- (`QUERY /console/store/kv/stats`). In-process `memory` is unsupported.
62
-
63
- INFO is instance-wide (`StoreKvStatsServerWideGap`). SLOWLOG args collapse
64
- until reveal (`StoreKvStatsSlowlogArgsGap`). No hot-key table.
65
-
66
- </Callout>
67
-
68
- **Colorize** groups tables by prefix and FK family. Click a related table to
69
- dim the rest; click the canvas to show all again.
70
-
71
- Edges mark cardinality with icons (many-to-one, one-to-one, many-to-many,
72
- self). Cards and the query Schema rail mark primary (key), foreign (link),
73
- and unique (diamond) columns.
74
-
75
- Each SQL store also lists **Indexes**, **Functions**, **Triggers**,
76
- **Extensions**, and **RLS Policies**.
77
-
78
- Indexes, Functions, and Triggers use the same flat form as RLS Policies —
79
- paired fields, compact templates, and a pinned SQL editor — then run
80
- `CREATE INDEX` / `CREATE FUNCTION` / `CREATE TRIGGER`.
81
-
82
- **Advanced** unlocks the leftover Postgres knobs (`CONCURRENTLY` / `INCLUDE`,
83
- `SECURITY DEFINER` / `PARALLEL`, `CONSTRAINT` / `UPDATE OF`).
84
-
85
- Extensions list built-in contrib plus a **Library** (featured cards, search,
86
- category filter) for TimescaleDB, PostGIS, and `pg_cron`.
87
-
88
- Install reviews the `CREATE EXTENSION` SQL; **Advanced** can set `SCHEMA`,
89
- `VERSION`, and `CASCADE` (`pg_catalog` is refused).
90
-
91
- When a newer packaged version exists, the name chip shows **Upgrade**
92
- (`ALTER EXTENSION … UPDATE`). Names link to the project or Postgres docs.
93
-
94
- SQL table rows and the resource header show RLS (emerald / muted shield;
95
- **N RLS** when policies exist) before **PII**. KV, files, and index have
96
- no badge — enabling RLS on create flips the shield.
97
-
98
- **RLS Policies** lists `pg_policies` plus Manifest `declared` / `live`.
99
- Two-finger click a row for its sheet, or empty grid to create. Create
100
- pins SQL | Code (`store.schema.policy` + `pgPolicy`); drag to resize.
101
-
102
- **Templates** use `oke.user()` / `oke.gate()` / `oke.has_scope()`.
103
- Owner cards pick a **Column** (guess `creator_email` / `owner_email`)
104
- with PK / FK marks. Create stays blocked until a column is picked.
105
-
106
- Create policy picks a Gate posture (Read / Write / Both), or **Policy &
107
- scope**. Those picks fill `USING` / `WITH CHECK`. `TO` stays `public` —
108
- Gate names are not Postgres roles.
109
-
110
- ### Quick start
17
+ ## Quick start
111
18
 
112
19
  <Steps>
113
20
 
@@ -117,22 +24,22 @@ Gate names are not Postgres roles.
117
24
  In `src/schema.decl.ts`, describe your tables with plain field builders — no ORM syntax:
118
25
 
119
26
  ```typescript title="src/schema.decl.ts"
120
- import { store, field, id, now } from "okengine";
27
+ import { store, field } from "okengine";
121
28
 
122
29
  export const notes = store.schema.table("notes", {
123
- id: field.text().primaryKey().defaultFn(id),
30
+ id: field.id().primaryKey(),
124
31
  title: field.text().notNull(),
125
32
  body: field.text().notNull(),
126
- createdAt: field.integer().notNull().defaultFn(now),
33
+ createdAt: field.integer().notNull().now(),
127
34
  });
128
35
  ```
129
36
 
130
37
  </Step>
131
38
 
132
39
  <Step>
133
- ### Declare the store
40
+ ### Declare the store and push the schema
134
41
 
135
- Bind the schema to a SQL store in `src/core.ts`:
42
+ Bind the schema to a SQL store in `src/core.ts`. `oke dev` auto-pushes on save — or run it by hand:
136
43
 
137
44
  ```typescript title="src/core.ts"
138
45
  import { store } from "okengine";
@@ -141,13 +48,6 @@ import { notes } from "./schema.decl";
141
48
  export const db = store.sql("notes", { schema: { notes } });
142
49
  ```
143
50
 
144
- </Step>
145
-
146
- <Step>
147
- ### Push the schema
148
-
149
- `oke dev` runs this automatically on save; or run it by hand:
150
-
151
51
  ```bash
152
52
  oke db push # dev — applies the schema to your local database
153
53
  ```
@@ -181,7 +81,13 @@ export const createNote = on(
181
81
 
182
82
  </Steps>
183
83
 
184
- ### CRUD without boilerplate — `store.resource`
84
+ ## SQL
85
+
86
+ <StoreFacetMark facet="sql" />
87
+
88
+ The relational facet: tables, schema, and typed single-table sessions through `fx.store`.
89
+
90
+ ### Resource routes — `store.resource`
185
91
 
186
92
  Five conventional endpoints (list, create, get, update, remove) expand from one declaration. Each is an ordinary Flow underneath — same contracts, same `fx`:
187
93
 
@@ -202,7 +108,7 @@ const notesR = store.resource(db, notes, {
202
108
  const mounted = on(http.resource("/notes", notesR.all()).public());
203
109
  ```
204
110
 
205
- `.gate(...)` / `.public()` chain like `http.get` — gates on every verb. Live SSE is `.live(signal)` on a GET, not on `http.resource`.
111
+ `.gate(...)` / `.public()` chain like `http.get` — gates on every verb. Passing `live: true` also mounts an SSE `GET <path>/live`; see Live queries below.
206
112
 
207
113
  The list endpoint's URL is the whole query language:
208
114
 
@@ -217,29 +123,123 @@ The list endpoint's URL is the whole query language:
217
123
 
218
124
  Responses follow the Stripe-style envelope: `{ data, meta, error }`. Clients page with `page.next()` or `for await (const page of api.notes.list({ limit: 20 }))` — `meta.next` / `meta.prev` are the next request (`{ cursor }`), not flags. `mode` is diagnostic. `create` answers **201**, `remove` answers **204**, and a missing row is a typed `NotFound` — never a crash.
219
125
 
220
- #### An admin table
126
+ **Consequence:** `count: "exact"` (the offset default) runs `COUNT(*)` per page. Set `count: "none"` to return only `meta.offset`. 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.
127
+
128
+ 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.
129
+
130
+ ### Live queries — `live: true` and `.live(table)`
131
+
132
+ Pass `live: true` to also mount `GET <path>/live` — an SSE feed of per-subscriber classified events (`upsert` / `revoked` / `delete`), gated the same way as the CRUD verbs:
221
133
 
222
134
  ```typescript
223
- list: { mode: "offset", count: "exact", limit: 20 },
135
+ const notesR = store.resource(db, notes, {
136
+ in: NewNote,
137
+ out: Note,
138
+ live: true, // synthesizes GET /notes/live + internal live signal
139
+ });
140
+
141
+ const mounted = on(http.resource("/notes", notesR.all()).gate(member));
224
142
  ```
225
143
 
226
- **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`.
144
+ The live route re-checks RLS for every CDC event under the subscriber's stamped identity. A row that leaves the caller's visibility window arrives as `{ kind: "revoked", id, reason: "rls" | "query" }` never as a silent stale upsert. `"rls"` means the row no longer passes the subscriber's policies; `"query"` means RLS still allows it but it fell out of the filter or search window. Clients consume it with `useLiveQuery` from `okengine/client-react`.
227
145
 
228
- #### An infinite feed
146
+ `store.resource` is optional. A hand-written flow declares the same live surface with the `.live(table)` trigger modifier and the `liveQuery` helper — identical stream, guardrails, and per-subscriber classification:
229
147
 
230
148
  ```typescript
231
- list: { cursor: [notes.createdAt, notes.id], direction: "desc", limit: 20, maxLimit: 100 },
149
+ import { liveQuery } from "okengine";
150
+
151
+ export const live = on(
152
+ http.get("/tasks/live").gate(member).live(tasks),
153
+ flow("tasks.live", {
154
+ in: { unknown: true },
155
+ do: async (input, fx) =>
156
+ liveQuery(fx, tasks, input, {
157
+ search: [tasks.title],
158
+ filter: [tasks.status], // same list grammar as store.resource
159
+ order: "all",
160
+ }),
161
+ }),
162
+ );
232
163
  ```
233
164
 
234
- **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.
165
+ `.live(tasks)` stamps the internal `oke/live/sql:tasks` signal at extract and enforces the same guardrails as `live: true` (PK required; `updatedAt` / RLS warnings). The flow body parses the request's query params with the exact list grammar, then opens the classified CDC stream.
235
166
 
236
- #### A public, restricted endpoint
167
+ - `?status=eq.open` filters the live window the same way it filters `GET /tasks`.
168
+ - A flow that also writes other tables (an activity log alongside the main write) never leaks those tables into the stream: classification is per-table.
169
+
170
+ #### The project-wide default — `oke({ store: { live: true } })`
171
+
172
+ Live remains **explicitly per-table** (`live: true` / `.live(table)`) unless you flip the whole project. Pass `store: { live: true }` to `oke()` to make every **new** `store.schema.table()` declaration live-by-default — the same automatic CDC + RLS-per-event stream `live: true` provides, without writing it on each declaration:
173
+
174
+ ```typescript
175
+ const app = oke({
176
+ name: "shop",
177
+ store: { live: true }, // new tables are live by default from here on
178
+ });
179
+ ```
237
180
 
238
181
  ```typescript
239
- list: { mode: "offset", filter: "none", limit: 20 },
182
+ // No explicit live needed this table is live by default.
183
+ export const notes = store.schema.table(
184
+ "notes",
185
+ {
186
+ id: field.text().primaryKey(),
187
+ updatedAt: field.integer().notNull(),
188
+ },
189
+ [store.schema.policy.owner("owner")],
190
+ );
191
+
192
+ const notesR = store.resource(db, notes, {}); // GET /notes/live synthesized
193
+ const mounted = on(http.resource("/notes", notesR.all()).gate(member));
240
194
  ```
241
195
 
242
- **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.
196
+ Three rules keep the flip predictable:
197
+
198
+ - **Default `false`** — today's explicit-only behavior is unchanged. An app that never sets `store.live` pays zero: no signals synthesized, no `/live` routes, no bundle or runtime difference.
199
+ - **Per-table opt-out** — a specific table stays NOT live under the project flag by declaring `store.schema.live(false)` in its extras array (the same third-argument slot as `store.schema.unscoped()` / `rls()` / policies). `store.resource(db, table, { live: false })` is the equivalent opt-out at the resource layer.
200
+ - **Explicit still wins** — `live: true` on a resource or `.live(table)` on a flow opts a specific surface back in even under an opt-out, matching today's behavior.
201
+
202
+ ```typescript
203
+ export const archive = store.schema.table(
204
+ "archive",
205
+ {
206
+ id: field.text().primaryKey(),
207
+ },
208
+ [
209
+ store.schema.policy.owner("owner"),
210
+ store.schema.live(false), // exempt from the project-wide default
211
+ ],
212
+ );
213
+ ```
214
+
215
+ **This flag changes declaration defaults only — never the runtime cost model.** Live-by-default consumes the exact same per-event RLS stamping as an explicit `live: true`. The guardrails below (PK required; `updatedAt` / RLS warnings) fire identically whether a table became live explicitly or via the default flip — there is no separate, weaker code path. What you are trading against is the fan-out profile of the writes below.
216
+
217
+ #### Live fan-out latency — the real cost model
218
+
219
+ Every live write fans out one **stamped RLS visibility check per subscriber** (per-event, per-connection): p50 fan-out for one CDC event to `S` subscribers grows linearly with `S` (default Postgres pool concurrency ~16–32, `stamp_p50` ≈ 0.5–1 ms):
220
+
221
+ ```
222
+ p50_fanout ≈ ceil(S / pool_concurrency) × stamp_p50
223
+ ```
224
+
225
+ Measured from the benchmark sweep (G8a / G8c):
226
+
227
+ | Active subscribers (same table/query) | Estimated p50 fan-out | UX |
228
+ | ------------------------------------- | --------------------- | --------------------------------------------- |
229
+ | **1–30** | 1–15 ms | Instant |
230
+ | **30–80** | 15–50 ms | Instant to snappy |
231
+ | **80–150** | 50–100 ms | Snappy; power users may notice on hot tables |
232
+ | **150–300** | 100–200 ms | **Perceptible lag** — updates arrive in waves |
233
+ | **300+** | 200 ms+ linear | **Not instant** — capacity planning required |
234
+
235
+ So before you flip `store.live` project-wide on a large application, weigh it against how many concurrent subscribers each hot table is likely to hold. **>150 concurrent live subscribers on the same resource starts to feel like perceptible lag; >300 is the soft ceiling** for the "real-time feel" without an architectural change. Same cost either way — explicit `live: true` or the default flip — this table is the tradeoff you are enabling. (PGlite dev serializes stamping behind a lock; treat fan-out numbers there as correctness-only, never prod latency.)
236
+
237
+ `live: true` carries compile-time guardrails at extract, plus durable delivery and runtime watchtowers:
238
+
239
+ - A table with **no primary key fails extract loud** — upsert/revoked/delete address rows by PK.
240
+ - Missing `updatedAt` / `updated_at` warns (no `rowVersion` LWW guard; races rely on `X-Oke-Mutation-Id` echo dedupe), missing RLS policies warns (every gated subscriber would see every row).
241
+ - CDC events ride the durable `oke_cdc_outbox`; delivered rows prune on retention defaults (24h / 50k) so the outbox never grows unbounded.
242
+ - `oke doctor` findings watch the runtime side: `cdc_outbox_backlog`, `cdc_outbox_retention`, `live_subscriber_pressure` (~150 subs/table in v1), and `live_fanout_queue_saturated`.
243
243
 
244
244
  ### Handwritten HTTP lists
245
245
 
@@ -267,10 +267,6 @@ Zero-config: `?q=` searches every string field, extra keys auto-eq except path `
267
267
  shape matches `store.resource` and the typed client (`data` is the page, `meta` is the pager).
268
268
  </Callout>
269
269
 
270
- **Consequence:** returning `{ items, count }` from `do` nests as `{ data: { items, count }, error: null }` — the pager is no longer top-level `meta`.
271
-
272
- `fx.json.with({ data, meta })` stays for a page you already built. A third argument locks the surface or switches to keyset (`mode: "cursor"`, `cursor: ["createdAt", "id"]`).
273
-
274
270
  ### Querying by hand
275
271
 
276
272
  When the list is not conventional CRUD, `fx.store(db)` is the full single-table session:
@@ -291,8 +287,7 @@ await fx.store(db).update(notes).set({ title: input.title }).where(eq(notes.id,
291
287
  await fx.store(db).delete(notes).where(lt(notes.createdAt, cutoff));
292
288
  ```
293
289
 
294
- `select().from(notes)` infers the declared row (`string` / `number` / `null` from
295
- `field.*`). A `select({ title: notes.title })` projection stays untyped.
290
+ `select().from(notes)` infers the declared row (`string` / `number` / `null` from `field.*`). A `select({ title: notes.title })` projection stays untyped.
296
291
 
297
292
  <Callout title="One table per call — no relational with:">
298
293
  `fx.store` is deliberately **single-table**: Drizzle's relational `findMany({ with: … })` is not
@@ -304,46 +299,37 @@ await fx.store(db).delete(notes).where(lt(notes.createdAt, cutoff));
304
299
 
305
300
  The recommended path: declare tables ORM-agnostically, then let `oke db` emit real Drizzle (`pgTable` for Postgres / PGLite) into `src/db/schema.drizzle.ts`.
306
301
 
307
- | Field API | Meaning |
308
- | --------------------------------------------- | --------------------------------------------------------------------------------- |
309
- | `field.text()` / `field.integer()` | v1 column primitives |
310
- | `.primaryKey()` · `.notNull()` · `.unique()` | constraints |
311
- | `.default(v)` · `.defaultFn(id \| now)` | defaults |
312
- | `.pii()` · `.sensitive()` · `.retain("30d")` | privacy classification |
313
- | `.as("sql_name")` | override the automatic `camelCase → snake_case` |
314
- | `.describe("…")` | human title in the Console (falls back to key) |
315
- | `.references(() => col, { onDelete })` | foreign key |
316
- | `store.schema.rls()` | `pgTable.withRLS` when there are no policies |
317
- | `store.schema.policy.gate/owner/scope/tenant` | happy-path RLS (`oke.gate()` / `oke.user()` / `oke.has_scope()` / `oke.tenant()`) |
318
- | `store.schema.unscoped()` | Shared table required when tenancy is on and there is no tenant policy |
319
-
320
- Third argument on `store.schema.table` is Drizzle-shaped extras:
321
-
322
- ```typescript
323
- export const bookings = store.schema.table(
324
- "bookings",
325
- {
326
- id: field.text().primaryKey(),
327
- owner: field.text().notNull(),
328
- },
329
- [
330
- store.schema.policy.gate("member", { for: "select" }),
331
- store.schema.policy.owner("owner", { for: "all" }),
332
- store.schema.policy.scope("booking:create", { for: "insert" }),
333
- store.schema.policy.tenant("tenant_id"),
334
- ],
335
- );
336
- ```
337
-
338
- Helpers emit stable names (`gate_member_select`). Raw
339
- `store.schema.policy("name", { as, to, for, using, withCheck })` is the
340
- escape hatch — predicates use `oke.*`, never `current_setting`.
341
-
342
- <Callout title="User-plane fx.store applies RLS">
343
- HTTP / resource flows stamp Gate identity (`SET LOCAL ROLE oke_app` + `set_config`). Operator /
344
- cron / CDC / signal stay unstamped. Tenancy on: also `oke.tenant()`; tables need `policy.tenant`
345
- or `unscoped()`.
346
- </Callout>
302
+ | Field API | Meaning |
303
+ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
304
+ | `field.text({ enum })` | text; enum narrows to a literal union |
305
+ | `field.id()` | id column with the default generation id (currently OK ID) minted on insert |
306
+ | `field.okid()` | id column explicitly generated with OK ID |
307
+ | `field.varchar({ length })` · `field.char` | bounded strings (same enum support) |
308
+ | `field.boolean()` | boolean |
309
+ | `field.smallint()` · `field.integer()` | ints (abstract integer emits as Drizzle `bigint`) |
310
+ | `field.bigint({ mode })` | int8 JS `number` (default) / `bigint` / `string` |
311
+ | `field.serial()` · `smallserial` · `bigserial` | auto-increment NOT NULL by SQL physics |
312
+ | `field.numeric({ precision, scale })` | exact decimals — infers `string`; `{ mode }` opts into `number` / `bigint` |
313
+ | `field.real()` · `doublePrecision()` | floats (~6 / ~15 significant digits) |
314
+ | `field.json<T>()` · `jsonb<T>()` | JSON payloads with a generic type |
315
+ | `field.uuid()` | UUID strings |
316
+ | `field.time()` · `timestamp()` · `date()` | temporals — `Date` by default; `{ mode: "string" }` opts into ISO `string` |
317
+ | `field.interval({ fields })` | time spans |
318
+ | `field.point({ mode })` · `line()` | geometric tuples or objects |
319
+ | `field.bytea()` | binary (`Buffer`) |
320
+ | `field.inet()` · `cidr` · `macaddr8` | network addresses as strings |
321
+ | `.primaryKey()` · `.notNull()` · `.unique()` | constraints |
322
+ | `.default(v)` · `.defaultFn(id \| now \| nowIso \| nowDate)` | defaults — typed against the column's JS type |
323
+ | `.okid()` · `.now()` | prepared defaults — fresh OKID / current instant. `.now()` resolves by column: epoch-ms on numbers, ISO-8601 on string-mode temporals, `Date` on default / `{ mode: "date" }` temporals |
324
+ | `.type<T>()` | override the inferred JS type (mirrors Drizzle `$type<T>`) |
325
+ | `.pii()` · `.sensitive()` · `.retain("30d")` | privacy classification |
326
+ | `.as("sql_name")` | override the automatic `camelCase → snake_case` |
327
+ | `.describe("")` | human title in the Console (falls back to key) |
328
+ | `.references(() => col, { onDelete })` | foreign key |
329
+ | `store.schema.rls()` | `pgTable.withRLS` when there are no policies |
330
+ | `store.schema.policy.gate/owner/scope/tenant` | happy-path RLS (`oke.gate()` / `oke.user()` / `oke.has_scope()` / `oke.tenant()`) |
331
+ | `store.schema.unscoped()` | Shared table — required when tenancy is on and there is no tenant policy |
332
+ | `store.schema.live(false)` | Per-table opt-out from the project-wide `store.live` default — not live-by-default, no synthesized `/live` surface |
347
333
 
348
334
  #### Foreign keys and relations
349
335
 
@@ -379,20 +365,12 @@ export const relations = store.schema.relations({ links, daily }, (r) => ({
379
365
  | `oke db generate` | Write versioned SQL under `drizzle/` for review |
380
366
  | `oke db migrate` | Apply those files — human or CI, **never** at boot |
381
367
 
382
- `oke dev` auto-pushes on schema _inputs_ (`schema.decl.ts`, hand-written `schema.ts`,
383
- `app.ts` plugin tables, `drizzle.config.ts`) — not when emit rewrites `schema.drizzle.ts`.
384
- A `schema.decl.ts` error prints `schema.decl.ts has an error — …` (red ●) and keeps running.
368
+ `oke dev` auto-pushes on schema _inputs_ (`schema.decl.ts`, hand-written `schema.ts`, `app.ts` plugin tables, `drizzle.config.ts`) — not when emit rewrites `schema.drizzle.ts`. A `schema.decl.ts` error prints `schema.decl.ts has an error — …` (red ●) and keeps running.
385
369
 
386
- Opt out with `--no-db-push` or `db: { autoPush: false }`. `prod` **never**
387
- auto-applies DDL; a missing table fails as **OKE1101** (`oke db migrate`).
370
+ Opt out with `--no-db-push` or `db: { autoPush: false }`. `prod` **never** auto-applies DDL; a missing table fails as **OKE1101** (`oke db migrate`).
388
371
 
389
372
  #### Multiple environments
390
373
 
391
- `dev` can `oke db push` freely — safe to wipe and regenerate.
392
-
393
- Staging/prod accumulate versioned SQL under `drizzle/` (`oke db generate`).
394
- `oke db migrate` applies only unrecorded files in order — behind DBs catch up.
395
-
396
374
  | Environment | Sync |
397
375
  | -------------- | -------------------------------------------------------------- |
398
376
  | `dev` | `oke db push` (or auto-push from `oke dev`) |
@@ -404,19 +382,13 @@ Staging/prod accumulate versioned SQL under `drizzle/` (`oke db generate`).
404
382
  that transaction — a leaked `BEGIN` exhausts checkout.
405
383
  </Callout>
406
384
 
407
- **Why PgDog (not PgBouncer as the default).** Transaction pooling fixes the
408
- math; naive poolers can leak session state (`SET`, RLS vars, `LISTEN`/`NOTIFY`)
409
- across clients. PgDog re-applies those under transaction mode (PgBouncer is fine — not shipped).
385
+ **Why PgDog (not PgBouncer as the default).** Transaction pooling fixes the math; naive poolers can leak session state (`SET`, RLS vars, `LISTEN`/`NOTIFY`) across clients. PgDog re-applies those under transaction mode (PgBouncer is fine — not shipped).
410
386
 
411
- **Read replicas later, zero app changes.** Add `role = "replica"` in
412
- `pgdog.toml` later: `BEGIN READ ONLY` → replica, failover on promotion.
413
- Not wired this round — readiness only.
387
+ **Read replicas later, zero app changes.** Add `role = "replica"` in `pgdog.toml` later: `BEGIN READ ONLY` → replica, failover on promotion. Not wired this round — readiness only.
414
388
 
415
389
  #### Seeding
416
390
 
417
- `oke db seed` loads `defineSeed` from `src/db/seed/index.ts` — a separate
418
- command, never at boot. Not every project has a seed. In `oke dev`, `s` runs
419
- the same command. `name` is this app's seed (template or example).
391
+ `oke db seed` loads `defineSeed` from `src/db/seed/index.ts` — a separate command, never at boot. Not every project has a seed. In `oke dev`, `s` runs the same command. `name` is this app's seed (template or example).
420
392
 
421
393
  <StoreSeeding />
422
394
 
@@ -483,39 +455,30 @@ export default defineSeed({
483
455
  | `already-existed` | match found; default leaves it alone |
484
456
  | `changed` | match found and `{ onExisting: "update" }` |
485
457
 
486
- Default upsert is **insert-if-missing**. Pass `{ onExisting: "update" }` only when this
487
- call should also rewrite other columns on a match — opt-in per call, never global.
458
+ Default upsert is **insert-if-missing**. Pass `{ onExisting: "update" }` only when this call should also rewrite other columns on a match — opt-in per call, never global.
488
459
 
489
460
  ```ts
490
461
  // Safe bootstrap — second seed run leaves the row alone
491
- await fx.store(db).upsert(
492
- notes,
493
- { id: "welcome" },
494
- {
495
- id: "welcome",
496
- title: "Welcome",
497
- body: "Your Notes API is ready.",
498
- createdAt: 1,
499
- },
500
- );
462
+ await fx
463
+ .store(db)
464
+ .upsert(
465
+ notes,
466
+ { id: "welcome" },
467
+ { id: "welcome", title: "Welcome", body: "Your Notes API is ready.", createdAt: 1 },
468
+ );
501
469
 
502
470
  // Refresh copy on every seed — match stays; title/body are rewritten
503
- await fx.store(db).upsert(
504
- notes,
505
- { id: "welcome" },
506
- {
507
- id: "welcome",
508
- title: "Welcome (updated)",
509
- body: "New bootstrap copy.",
510
- createdAt: 1,
511
- },
512
- { onExisting: "update" },
513
- );
471
+ await fx
472
+ .store(db)
473
+ .upsert(
474
+ notes,
475
+ { id: "welcome" },
476
+ { id: "welcome", title: "Welcome (updated)", body: "New bootstrap copy.", createdAt: 1 },
477
+ { onExisting: "update" },
478
+ );
514
479
  ```
515
480
 
516
- **Consequence:** default keeps operator edits safe across re-seeds; `onExisting: "update"`
517
- is for seed-owned rows you intentionally overwrite. Schema-wide data fixes still belong in
518
- migrations, not seed.
481
+ Default keeps operator edits safe across re-seeds; `onExisting: "update"` is for seed-owned rows you intentionally overwrite. Schema-wide data fixes still belong in migrations, not seed.
519
482
 
520
483
  | Env | Confirm |
521
484
  | -------------- | ----------------------------------------------------- |
@@ -540,6 +503,34 @@ oke db seed --env prod --force
540
503
  extending an app-owned table with plugin columns is not supported in v1.
541
504
  </Callout>
542
505
 
506
+ ### Access policies — owner, tenant, scope
507
+
508
+ Row-level security is declared per table as the third argument to `store.schema.table` — the policy list is Drizzle-shaped extras:
509
+
510
+ ```typescript
511
+ export const bookings = store.schema.table(
512
+ "bookings",
513
+ {
514
+ id: field.text().primaryKey(),
515
+ owner: field.text().notNull(),
516
+ },
517
+ [
518
+ store.schema.policy.gate("member", { for: "select" }),
519
+ store.schema.policy.owner("owner", { for: "all" }),
520
+ store.schema.policy.scope("booking:create", { for: "insert" }),
521
+ store.schema.policy.tenant("tenant_id"),
522
+ ],
523
+ );
524
+ ```
525
+
526
+ Helpers emit stable names (`gate_member_select`). Raw `store.schema.policy("name", { as, to, for, using, withCheck })` is the escape hatch — predicates use `oke.*`, never `current_setting`. `policy.scope` takes a scope name or the `gate.scope("…")` declaration, so you can reuse the same scope in both the gate and the policy instead of repeating the string.
527
+
528
+ <Callout title="User-plane fx.store applies RLS">
529
+ HTTP / resource flows stamp Gate identity (`SET LOCAL ROLE oke_app` + `set_config`). Operator /
530
+ cron / CDC / signal stay unstamped. Tenancy on: also `oke.tenant()`; tables need `policy.tenant`
531
+ or `unscoped()`.
532
+ </Callout>
533
+
543
534
  ### Per-environment SQL drivers
544
535
 
545
536
  Same flow code, different backends — configured once in `oke.config.ts`:
@@ -568,14 +559,11 @@ SQL-backed index driver `pgvector` shares this facet's connection — see [Advan
568
559
 
569
560
  <StoreFacetMark facet="kv" />
570
561
 
571
- The key-value facet: short-lived cache and session data behind `fx.store`. One namespace per
572
- declaration; drivers swap `memory` ↔ `redis` per environment.
562
+ The key-value facet: short-lived cache and session data behind `fx.store`. One namespace per declaration; drivers swap `memory` ↔ `redis` per environment.
573
563
 
574
- The Console KV band opens a query console for `list`, `get`, `set`, `delete`, and `ttl`.
575
- Prettify rewrites commands to call form and indents `set` JSON.
564
+ The Console KV band opens a query console for `list`, `get`, `set`, `delete`, and `ttl`. Prettify rewrites commands to call form and indents `set` JSON.
576
565
 
577
- The same bands **Performance** control opens engine telemetry on the `redis`
578
- driver. In-process `memory` is unsupported (`KvStatsUnsupported`).
566
+ The same band's **Performance** control opens engine telemetry on the `redis` driver. In-process `memory` is unsupported (`KvStatsUnsupported`).
579
567
 
580
568
  ### Quick start
581
569
 
@@ -620,68 +608,17 @@ do: async ({ userId, data }, fx) => {
620
608
 
621
609
  ### TTL
622
610
 
623
- Optional third argument on `set` is a duration string: `(\d+)(ms|s|m|h|d)` — e.g. `"30m"`, `"1h"`,
624
- `"5000ms"`. Same call — opposite physics under each driver. Console KV browse edits that TTL
625
- (empty clears expiry) and can add a key through the same `set` path.
611
+ Optional third argument on `set` is a duration string: `(\d+)(ms|s|m|h|d)` — e.g. `"30m"`, `"1h"`, `"5000ms"`. Same call — opposite physics under each driver. Console KV browse edits that TTL (empty clears expiry) and can add a key through the same `set` path.
626
612
 
627
613
  <StoreKvTtl />
628
614
 
629
- **Consequence:** a TTL you rely on in prod does nothing under the `test` `memory` driver. Exercise
630
- expiry against `redis` under `oke dev` when lifetime matters.
631
-
632
- ### Examples — what follows from each choice
633
-
634
- #### A session with expiry
635
-
636
- ```typescript
637
- await fx.store(sessions).set(`sess:${token}`, { userId }, "7d");
638
- ```
639
-
640
- **Consequence:** under `redis`, the key vanishes after seven days without a sweeper Flow. Under
641
- `memory`, it lives until process exit or an explicit `delete`.
642
-
643
- #### Browse by prefix
644
-
645
- ```typescript
646
- const keys = await fx.store(sessions).list("sess:");
647
- ```
648
-
649
- **Consequence:** `list` enumerates a namespace. Prefer stable key prefixes (`user:`, `sess:`) so
650
- your Flows and operator tools stay readable.
651
-
652
- ### Per-environment KV drivers
653
-
654
- ```typescript title="oke.config.ts"
655
- drivers: {
656
- store: {
657
- kv: { dev: "redis", test: "memory", prod: "redis" },
658
- },
659
- },
660
- images: {
661
- store: { kv: "redis:8-alpine" },
662
- // or: "valkey/valkey:8-alpine"
663
- // or: "docker.dragonflydb.io/dragonflydb/dragonfly"
664
- },
665
- ```
666
-
667
- | Facet | `dev` / `prod` | `test` | Runs as |
668
- | ----- | -------------- | -------- | ---------------------- |
669
- | `kv` | `redis` | `memory` | container → in-process |
670
-
671
- | Variable | When |
672
- | ------------------ | -------------------------------- |
673
- | `REDIS_URL` | Required for the `redis` driver |
674
- | `OKE_STORE_KV_URL` | Override if `REDIS_URL` is unset |
675
- | `OKE_KV_DRIVER` | Force a driver id |
676
-
677
- Missing Redis URL fails boot loudly: `oke boot: redis driver needs REDIS_URL`.
615
+ **Consequence:** a TTL you rely on in prod does nothing under the `test` `memory` driver. Exercise expiry against `redis` under `oke dev` when lifetime matters.
678
616
 
679
617
  ### Durable KV
680
618
 
681
619
  Default `store.kv("sessions")` is cache-shaped — a Redis recreate drops keys.
682
620
 
683
- `{ durable: true }` persists that namespace in your SQL database (`oke_kv` JSONB on
684
- `DATABASE_URL`). Not a per-key Redis flag, not Flow `durable`, not tier-1 auto-cache.
621
+ `{ durable: true }` persists that namespace in your SQL database (`oke_kv` JSONB on `DATABASE_URL`). Not a per-key Redis flag, not Flow `durable`, not tier-1 auto-cache.
685
622
 
686
623
  ```typescript
687
624
  export const sessions = store.kv("sessions", { description: "Session cache" });
@@ -689,24 +626,18 @@ export const ledger = store.kv("ledger", { durable: true, description: "Idempote
689
626
  export const drafts = store.kv("drafts", { durable: true, description: "Compose drafts" });
690
627
  ```
691
628
 
692
- **Consequence:** `oke db seed` into cache Redis looks fine until compose recreates the
693
- container — Console Store then shows **No rows.** Seeded namespaces need `{ durable: true }`.
629
+ **Consequence:** `oke db seed` into cache Redis looks fine until compose recreates the container — Console Store then shows **No rows.** Seeded namespaces need `{ durable: true }`.
694
630
 
695
- With `gate.auth.tenant` on, KV keys are prefixed `{tenantId}:` (logical keys in `do` stay
696
- unprefixed). Missing `fx.tenant.id` throws **OKE1015**. Opt out with
697
- `store.kv("sessions", { tenantScoped: false })`.
631
+ With `gate.auth.tenant` on, KV keys are prefixed `{tenantId}:` (logical keys in `do` stay unprefixed). Missing `fx.tenant.id` throws **OKE1015**. Opt out with `store.kv("sessions", { tenantScoped: false })`.
698
632
 
699
- Gate rates and Signal stay on `REDIS_URL`. Missing `DATABASE_URL` with the postgres driver
700
- fails boot: `oke boot: durable store.kv needs DATABASE_URL`.
633
+ Gate rates and Signal stay on `REDIS_URL`. Missing `DATABASE_URL` with the postgres driver fails boot: `oke boot: durable store.kv needs DATABASE_URL`.
701
634
 
702
635
  <Callout title="Not per-key, not Flow durable, not auto-cache">
703
636
  Durable KV is a JSONB table on the same Postgres as `store.sql`. Flow `durable` journals steps.
704
637
  Tier-1 auto-cache is an in-process Map and never reads `store.kv`.
705
638
  </Callout>
706
639
 
707
- Driver id stays `redis` for every image below — same `REDIS_URL`, zero Flow changes.
708
- Redis is the default because it is the most mature and battle-tested; Valkey and
709
- Dragonfly are equally legitimate opt-in pins.
640
+ Driver id stays `redis` for every image below — same `REDIS_URL`, zero Flow changes. Redis is the default because it is the most mature and battle-tested; Valkey and Dragonfly are equally legitimate opt-in pins.
710
641
 
711
642
  | Image | Pin | Why pick it | License |
712
643
  | ------------- | --------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -717,7 +648,7 @@ Dragonfly are equally legitimate opt-in pins.
717
648
  <Callout title="Licenses bite managed-service resellers — read this once" type="warn">
718
649
  RSAL, SSPL, and BSL service restrictions apply when you offer that specific datastore **as a
719
650
  service** to third parties — not when you run it for your own app. Pick for your situation; OKE
720
- does not pick a safer default for you.
651
+ does not pick a "safer" default for you.
721
652
  </Callout>
722
653
 
723
654
  <Callout title="Dry-run refuses KV writes">
@@ -725,13 +656,38 @@ Dragonfly are equally legitimate opt-in pins.
725
656
  double-write against a shared Redis. Reads (`get` / `list`) still run.
726
657
  </Callout>
727
658
 
659
+ ### Per-environment KV drivers
660
+
661
+ ```typescript title="oke.config.ts"
662
+ drivers: {
663
+ store: {
664
+ kv: { dev: "redis", test: "memory", prod: "redis" },
665
+ },
666
+ },
667
+ images: {
668
+ store: { kv: "redis:8-alpine" },
669
+ // or: "valkey/valkey:8-alpine"
670
+ // or: "docker.dragonflydb.io/dragonflydb/dragonfly"
671
+ },
672
+ ```
673
+
674
+ | Facet | `dev` / `prod` | `test` | Runs as |
675
+ | ----- | -------------- | -------- | ---------------------- |
676
+ | `kv` | `redis` | `memory` | container → in-process |
677
+
678
+ | Variable | When |
679
+ | ------------------ | -------------------------------- |
680
+ | `REDIS_URL` | Required for the `redis` driver |
681
+ | `OKE_STORE_KV_URL` | Override if `REDIS_URL` is unset |
682
+ | `OKE_KV_DRIVER` | Force a driver id |
683
+
684
+ Missing Redis URL fails boot loudly: `oke boot: redis driver needs REDIS_URL`.
685
+
728
686
  ## Files
729
687
 
730
688
  <StoreFacetMark facet="files" />
731
689
 
732
- The blob facet: opaque objects (`Uint8Array` or string) on the same handle as the optional image
733
- pipeline. Drivers swap `fs` ↔ `s3` (plus `memory` for tests). The Console Files band is a folder
734
- browser — upload, download, delete — not a signed-URL mint.
690
+ The blob facet: opaque objects (`Uint8Array` or string) on the same handle as the optional image pipeline. Drivers swap `fs` ↔ `s3` (plus `memory` for tests). The Console Files band is a folder browser — upload, download, delete — not a signed-URL mint.
735
691
 
736
692
  ### Quick start
737
693
 
@@ -829,8 +785,7 @@ images: {
829
785
 
830
786
  ### Images — `image` / `putImage`
831
787
 
832
- For photos, use Bun's built-in `Bun.Image` pipeline on the same handle (requires Bun `>=1.4.0`).
833
- `putImage` is one write that fans into several keys:
788
+ For photos, use Bun's built-in `Bun.Image` pipeline on the same handle (requires Bun `>=1.4.0`). `putImage` is one write that fans into several keys:
834
789
 
835
790
  <StoreFilesVariants />
836
791
 
@@ -876,8 +831,7 @@ const result = await fx.store(uploads).putImage("photos/x.jpg", bytes, {
876
831
 
877
832
  <StoreFacetMark facet="index" />
878
833
 
879
- The search / similarity facet: vector ANN and opt-in full-text, both behind `store.index`. The
880
- handle is a **discriminated union** on `driverId` — vector methods and text methods never mix.
834
+ The search / similarity facet: vector ANN and opt-in full-text, both behind `store.index`. The handle is a **discriminated union** on `driverId` — vector methods and text methods never mix.
881
835
 
882
836
  ### Quick start
883
837
 
@@ -934,8 +888,7 @@ One declaration shape — two search physics. The handle is a **discriminated un
934
888
  probe. Meilisearch uses full-text `q`.
935
889
  </Callout>
936
890
 
937
- TypeScript rejects a vector query against a text index (and vice versa) at compile time — never at
938
- runtime.
891
+ TypeScript rejects a vector query against a text index (and vice versa) at compile time — never at runtime.
939
892
 
940
893
  ### Vector ANN
941
894
 
@@ -944,16 +897,13 @@ runtime.
944
897
  | `memory` | — (in-process, default) | Full scan cosine |
945
898
  | `pgvector` | `postgres` or `pglite` sql | HNSW + `cosineDistance` |
946
899
 
947
- **Consequence:** a configured SQL-backed index that cannot reach its engine — missing peer, missing
948
- `vector` extension, wrong sql driver — fails loudly at first use. It never silently falls back to
949
- `memory`. A vector whose length ≠ `dims` throws `vector dims X !== index dims Y`.
900
+ **Consequence:** a configured SQL-backed index that cannot reach its engine — missing peer, missing `vector` extension, wrong sql driver — fails loudly at first use. It never silently falls back to `memory`. A vector whose length ≠ `dims` throws `vector dims X !== index dims Y`.
950
901
 
951
902
  Pair SQL + index under [Advanced — composing facets together](#advanced--composing-facets-together).
952
903
 
953
904
  ### Full-text search — `meilisearch` (opt-in)
954
905
 
955
- `meilisearch` is a fourth `store.index` backend, opt-in only — `memory` stays the default when
956
- `drivers.store.index` is unset. Typo-tolerant, faceted full-text relevance — not cosine similarity.
906
+ `meilisearch` is a fourth `store.index` backend, opt-in only — `memory` stays the default when `drivers.store.index` is unset. Typo-tolerant, faceted full-text relevance — not cosine similarity.
957
907
 
958
908
  ```typescript
959
909
  export const kb = store.index("kb", { description: "Help articles" });
@@ -1012,9 +962,7 @@ drivers: {
1012
962
 
1013
963
  ### With AI embeddings
1014
964
 
1015
- `ai.embed` writes into a vector `store.index`; Flows search it with `fx.search` (or
1016
- `fx.store(index).search`). See [AI](/docs/elements/ai) — this path is vector-only and will not
1017
- target meilisearch.
965
+ `ai.embed` writes into a vector `store.index`; Flows search it with `fx.search` (or `fx.store(index).search`). See [AI](/docs/elements/ai) — this path is vector-only and will not target meilisearch.
1018
966
 
1019
967
  ## Advanced — composing facets together
1020
968
 
@@ -1049,84 +997,82 @@ Container images come from the `images` map — change the vendor by changing th
1049
997
 
1050
998
  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.
1051
999
 
1052
- Optional `description` on a store (and `.describe("…")` on fields) becomes its human title in
1053
- operator tools. Single-table `reads` / `writes` in the Manifest power cache invalidation and that
1054
- same PII masking — see the Callout under [Querying by hand](#querying-by-hand).
1000
+ Optional `description` on a store (and `.describe("…")` on fields) becomes its human title in operator tools. Single-table `reads` / `writes` in the Manifest power cache invalidation and that same PII masking — see the Callout under [Querying by hand](#querying-by-hand).
1055
1001
 
1056
1002
  ## Troubleshooting
1057
1003
 
1058
1004
  <Accordions>
1059
- <Accordion title="Unable to resolve table name from value">
1060
-
1061
- A `store.schema.table` column named `name` used to shadow the SQL table name.
1062
- Inserts and selects now resolve it from the declaration. Keep the `name` column.
1063
1005
 
1006
+ <Accordion title="Unable to resolve table name from value">
1007
+ A `store.schema.table` column named `name` used to shadow the SQL table name. Inserts and selects
1008
+ now resolve it from the declaration. Keep the `name` column.
1064
1009
  </Accordion>
1065
- <Accordion title="OKE1101 — missing table in prod">
1066
-
1067
- Schema DDL never runs automatically in `prod`. Under `oke dev`, `db.autoPush` (default `true`) runs `oke db push`. For production use `oke db generate` + `oke db migrate`.
1068
1010
 
1011
+ <Accordion title="OKE1101 — missing table in prod">
1012
+ Schema DDL never runs automatically in `prod`. Under `oke dev`, `db.autoPush` (default `true`)
1013
+ runs `oke db push`. For production use `oke db generate` + `oke db migrate`.
1069
1014
  </Accordion>
1070
- <Accordion title="oke db push: missing_hints">
1071
-
1072
- New tables are created automatically. Push only manages `public` — it will not drop schema `oke` (RLS helpers) or `oke_console`. Remaining `missing_hints` are destructive (drop a non-empty public table, type change). Review with `oke db generate`.
1073
1015
 
1016
+ <Accordion title="oke db push: missing_hints">
1017
+ New tables are created automatically. Push only manages `public` — it will not drop schema `oke`
1018
+ (RLS helpers) or `oke_console`. Remaining `missing_hints` are destructive (drop a non-empty public
1019
+ table, type change). Review with `oke db generate`.
1074
1020
  </Accordion>
1075
- <Accordion title="I need a join — with: is not supported">
1076
-
1077
- `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.
1078
1021
 
1022
+ <Accordion title="I need a join — with: is not supported">
1023
+ `fx.store` is one table per call by design. Read each table separately and compose in the flow (or
1024
+ extract a shared flow and `fx.call` it). Every table then appears explicitly in the Manifest's
1025
+ effect graph — which is what powers cache invalidation and PII masking.
1079
1026
  </Accordion>
1080
- <Accordion title="Pagination shows duplicates when rows are inserted">
1081
-
1082
- 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.
1083
1027
 
1028
+ <Accordion title="Pagination shows duplicates when rows are inserted">
1029
+ You are on offset paging. Switch `list` to keyset by setting `cursor` columns with a stable order
1030
+ (e.g. `[createdAt, id]`) — the page boundary becomes a row predicate, not a row count.
1084
1031
  </Accordion>
1085
- <Accordion title="meta.total is slow on a big table">
1086
-
1087
- `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.
1088
1032
 
1033
+ <Accordion title="meta.total is slow on a big table">
1034
+ `count: "exact"` runs `COUNT(*)` per page. Set `count: "none"` in the `list` options to skip it,
1035
+ or use keyset mode where totals are rarely needed.
1089
1036
  </Accordion>
1090
- <Accordion title="List data is { items, count } instead of an array">
1091
1037
 
1038
+ <Accordion title="List data is { items, count } instead of an array">
1092
1039
  Success already wraps `{ data, error: null }`. Returning a page object from `do` nests the pager inside `data`. Prefer `out: z.array(Item)` and `return fx.json.withQuery(rows, input)`. Any other declared `out` is still valid.
1093
-
1094
1040
  </Accordion>
1095
- <Accordion title="KV TTL never expires in tests">
1096
-
1097
- The `test` default is `memory`, which ignores TTL on `set`. Under `oke dev`, KV is `redis` by default — use that when you need real expiry.
1098
1041
 
1042
+ <Accordion title="KV TTL never expires in tests">
1043
+ The `test` default is `memory`, which ignores TTL on `set`. Under `oke dev`, KV is `redis` by
1044
+ default — use that when you need real expiry.
1099
1045
  </Accordion>
1100
- <Accordion title="oke boot: redis / meilisearch / S3 URL missing">
1101
-
1102
- Driver-backed facets fail boot when their URL is absent — Redis needs `REDIS_URL`, meilisearch needs `OKE_STORE_INDEX_URL`, S3 needs `S3_BUCKET`. `oke dev` writes these into the Compose env.
1103
1046
 
1047
+ <Accordion title="oke boot: redis / meilisearch / S3 URL missing">
1048
+ Driver-backed facets fail boot when their URL is absent — Redis needs `REDIS_URL`, meilisearch
1049
+ needs `OKE_STORE_INDEX_URL`, S3 needs `S3_BUCKET`. `oke dev` writes these into the Compose env.
1104
1050
  </Accordion>
1105
- <Accordion title="vector dims X !== index dims Y">
1106
-
1107
- The vector length must match `store.index(…, { dims })` (runtime default `3` if unset). Align `dims` with your embedder, or fix the vector you pass to `upsert` / `search`.
1108
1051
 
1052
+ <Accordion title="vector dims X !== index dims Y">
1053
+ The vector length must match `store.index(…, {dims})` (runtime default `3` if unset). Align `dims`
1054
+ with your embedder, or fix the vector you pass to `upsert` / `search`.
1109
1055
  </Accordion>
1110
- <Accordion title="Index stays on memory under oke dev">
1111
-
1112
- Unlike KV/Files, unset `drivers.store.index` does **not** promote to `pgvector` in `dev` — it stays `memory`. Set the map explicitly (and pair SQL for `pgvector`).
1113
1056
 
1057
+ <Accordion title="Index stays on memory under oke dev">
1058
+ Unlike KV/Files, unset `drivers.store.index` does **not** promote to `pgvector` in `dev` — it
1059
+ stays `memory`. Set the map explicitly (and pair SQL for `pgvector`).
1114
1060
  </Accordion>
1115
- <Accordion title="Console warns Non-ASCII object key">
1116
-
1117
- `fx.store(files).put` stores the key as-is. Console uploads mint an ASCII key and keep the original name in `.oke/catalog.json`. Re-upload to rewrite an existing object.
1118
1061
 
1062
+ <Accordion title="Console warns Non-ASCII object key">
1063
+ `fx.store(files).put` stores the key as-is. Console uploads mint an ASCII key and keep the
1064
+ original name in `.oke/catalog.json`. Re-upload to rewrite an existing object.
1119
1065
  </Accordion>
1120
- <Accordion title="oke dev skipped the seed prompt">
1121
-
1122
- `.oke/state.json` stores `seededAt` plus this app's `defineSeed({ name })` — notes vs keel are different ids. A new name re-asks. Clear those keys to prompt again.
1123
1066
 
1067
+ <Accordion title="oke dev skipped the seed prompt">
1068
+ `.oke/state.json` stores `seededAt` plus this app's `defineSeed({name})` — notes vs keel are
1069
+ different ids. A new name re-asks. Clear those keys to prompt again.
1124
1070
  </Accordion>
1125
- <Accordion title="Console KV shows No rows after oke db seed">
1126
1071
 
1072
+ <Accordion title="Console KV shows No rows after oke db seed">
1127
1073
  Default `store.kv` is cache Redis with no AOF — a recreate drops keys. SQL seed still looks full. Mark seeded namespaces `{ durable: true }` so they live in `oke_kv`, then restart and seed again.
1128
-
1129
1074
  </Accordion>
1075
+
1130
1076
  </Accordions>
1131
1077
 
1132
1078
  ## Learn more