okengine 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/AGENTS.md +7 -0
  2. package/manifest.v1.schema.json +44 -0
  3. package/package.json +32 -19
  4. package/site/content/docs/ai/mcp.mdx +14 -10
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/agents.mdx +61 -13
  7. package/site/content/docs/elements/ai/decide.mdx +536 -0
  8. package/site/content/docs/elements/ai/events.mdx +373 -0
  9. package/site/content/docs/elements/ai/index.mdx +9 -3
  10. package/site/content/docs/elements/ai/meta.json +1 -1
  11. package/site/content/docs/elements/ai/prompts.mdx +4 -2
  12. package/site/content/docs/elements/channel/index.mdx +4 -4
  13. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  14. package/site/content/docs/elements/channel/sms.mdx +2 -1
  15. package/site/content/docs/elements/flow/index.mdx +22 -18
  16. package/site/content/docs/elements/gate/auth.mdx +4 -1
  17. package/site/content/docs/elements/signal/index.mdx +15 -14
  18. package/site/content/docs/elements/store/index.mdx +1 -1
  19. package/site/content/docs/elements/store/kv.mdx +5 -5
  20. package/site/content/docs/plugins/otp.mdx +3 -3
  21. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  22. package/site/content/docs/recipes/index.mdx +6 -6
  23. package/site/content/docs/recipes/postgres.mdx +12 -8
  24. package/site/content/docs/recipes/redis.mdx +4 -4
  25. package/site/content/docs/reference/cli.mdx +2 -1
  26. package/site/content/docs/reference/configuration.mdx +9 -7
  27. package/site/content/docs/reference/fx.mdx +19 -10
  28. package/src/auth/api-key-sql.test.ts +44 -0
  29. package/src/auth/api-key-sql.ts +1 -0
  30. package/src/auth/api-keys.ts +30 -12
  31. package/src/auth/config.ts +6 -2
  32. package/src/auth/gate-auth.test.ts +20 -0
  33. package/src/auth/tenants.ts +17 -0
  34. package/src/cli/agents-md.test.ts +25 -0
  35. package/src/cli/ask-vault-gaps.test.ts +38 -1
  36. package/src/cli/ask-vault-gaps.ts +35 -2
  37. package/src/cli/decide.ts +174 -0
  38. package/src/cli/decision-lock-watch.test.ts +45 -0
  39. package/src/cli/decision-lock-watch.ts +51 -0
  40. package/src/cli/dev-app-runner.ts +34 -8
  41. package/src/cli/dev-auth-secret.test.ts +49 -0
  42. package/src/cli/dev-auth-secret.ts +49 -0
  43. package/src/cli/dev-hot.test.ts +41 -0
  44. package/src/cli/dev-hot.ts +48 -0
  45. package/src/cli/dev.test.ts +1 -1
  46. package/src/cli/dev.ts +24 -6
  47. package/src/cli/doctor-diff.test.ts +29 -1
  48. package/src/cli/doctor-diff.ts +35 -0
  49. package/src/cli/eval.ts +142 -1
  50. package/src/cli/index.ts +5 -0
  51. package/src/cli/load-config.images.test.ts +2 -2
  52. package/src/cli/load-config.ts +1 -1
  53. package/src/cli/manifest-pr-diff.ts +16 -0
  54. package/src/cli/mcp-from-console.ts +48 -0
  55. package/src/cli/registry.ts +35 -1
  56. package/src/client/agent.ts +307 -0
  57. package/src/client/transport.test.ts +20 -0
  58. package/src/client/transport.ts +2 -1
  59. package/src/client/types.ts +1 -1
  60. package/src/client-react/index.ts +7 -1
  61. package/src/client-react/use-agent-run.test.ts +271 -0
  62. package/src/client-react/use-agent-run.ts +193 -0
  63. package/src/compiler/ai-approval.test.ts +36 -0
  64. package/src/compiler/ai-repair.test.ts +22 -0
  65. package/src/compiler/decisions.extract.test.ts +164 -0
  66. package/src/compiler/effects-fetch.test.ts +77 -0
  67. package/src/compiler/effects-infer.ts +114 -17
  68. package/src/compiler/effects-join.test.ts +48 -0
  69. package/src/compiler/extract.test.ts +28 -0
  70. package/src/compiler/extract.ts +279 -13
  71. package/src/compiler/fx-index.ts +58 -4
  72. package/src/compiler/response.ts +35 -2
  73. package/src/config/index.ts +21 -3
  74. package/src/console/index.ts +1 -1
  75. package/src/console/server/ai-runs-flows.ts +269 -0
  76. package/src/console/server/ai-runs.test.ts +323 -0
  77. package/src/console/server/ai-runs.ts +562 -0
  78. package/src/console/server/ai.ts +12 -1
  79. package/src/console/server/app.ts +10 -1
  80. package/src/console/server/decisions-flows.ts +157 -0
  81. package/src/console/server/decisions.test.ts +66 -0
  82. package/src/console/server/decisions.ts +229 -0
  83. package/src/console/server/flows.ts +50 -2
  84. package/src/console/server/invoke-user-flow.ts +9 -0
  85. package/src/console/server/runs-ingest.ts +16 -0
  86. package/src/console/server/serve.ts +5 -0
  87. package/src/console/server/state.ts +25 -1
  88. package/src/console/server/store-stats.test.ts +47 -0
  89. package/src/console/server/store-stats.ts +92 -18
  90. package/src/console/ui-next/dist/assets/access-page-C_N4qvai.js +4 -0
  91. package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-Cq1TxniW.js} +1 -1
  92. package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-BaI4uxZz.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DNyK7IIY.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-CRhBJMZR.js} +1 -1
  95. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  96. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  97. package/src/console/ui-next/dist/assets/decisions-page-D9twj8Xy.js +1 -0
  98. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  99. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  100. package/src/console/ui-next/dist/assets/duration-tone-CsHWlVPU.js +9 -0
  101. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  102. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  103. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  104. package/src/console/ui-next/dist/assets/flows-page-ZaTJAhR9.js +1 -0
  105. package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-DQ-EMAfz.js} +1 -1
  106. package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-Cn_Fl79s.js} +1 -1
  107. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  108. package/src/console/ui-next/dist/assets/index-CmRFeWdT.js +58 -0
  109. package/src/console/ui-next/dist/assets/observability-page-Cn6R_dXz.js +8 -0
  110. package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bx6EZhSe.js} +7 -7
  111. package/src/console/ui-next/dist/assets/request-meta-DmdiUrfh.js +1 -0
  112. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  113. package/src/console/ui-next/dist/assets/store-page-8P6vGXnn.js +41 -0
  114. package/src/console/ui-next/dist/assets/trace-detail-sheet-BfGvpFSv.js +2 -0
  115. package/src/console/ui-next/dist/assets/tree-expand-toggle-CWdUHbc0.js +55 -0
  116. package/src/console/ui-next/dist/assets/units-page-CZOFZzay.js +1 -0
  117. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  118. package/src/console/ui-next/dist/assets/vault-page-ki_ishmX.js +2 -0
  119. package/src/console/ui-next/dist/index.html +4 -3
  120. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  121. package/src/console/ui-next/src/client.ts +224 -1
  122. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  123. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  124. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  125. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  126. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  127. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  128. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  129. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  130. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  131. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  132. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  133. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  134. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  135. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  136. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  137. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  138. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  139. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  140. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  141. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  142. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  143. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  144. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  145. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  146. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  147. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  148. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  149. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  150. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  151. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  152. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
  153. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  154. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  155. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  156. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  157. package/src/console/ui-next/src/router.tsx +17 -0
  158. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  159. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  160. package/src/console/ui-next/vite.config.ts +9 -0
  161. package/src/docker/recipes/dragonfly.ts +3 -2
  162. package/src/docker/recipes/redis.ts +1 -1
  163. package/src/drivers/ai-anthropic.ts +212 -21
  164. package/src/drivers/ai-mock.ts +34 -1
  165. package/src/drivers/ai-openai-compatible.ts +67 -26
  166. package/src/drivers/ai-preconnect.ts +18 -0
  167. package/src/drivers/ai-stream.test.ts +62 -0
  168. package/src/drivers/ai-types.ts +20 -0
  169. package/src/drivers/journal-postgres.test.ts +22 -1
  170. package/src/drivers/journal-postgres.ts +511 -13
  171. package/src/drivers/memory-ddl.test.ts +18 -0
  172. package/src/drivers/memory.ts +21 -1
  173. package/src/drivers/pg-rls.test.ts +31 -0
  174. package/src/drivers/pg-rls.ts +15 -0
  175. package/src/drivers/postgres.test.ts +9 -0
  176. package/src/drivers/postgres.ts +49 -11
  177. package/src/drivers/signal-compete.test.ts +234 -0
  178. package/src/drivers/signal-postgres.ts +39 -19
  179. package/src/drivers/signal-redis.ts +167 -11
  180. package/src/drivers/signal-types.ts +12 -0
  181. package/src/elements/ai/agent-event-slot.ts +27 -0
  182. package/src/elements/ai/approval-http.ts +207 -0
  183. package/src/elements/ai/approval.test.ts +741 -0
  184. package/src/elements/ai/approval.ts +228 -0
  185. package/src/elements/ai/decisions/bind.ts +163 -0
  186. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  187. package/src/elements/ai/decisions/certificate.ts +377 -0
  188. package/src/elements/ai/decisions/certify.ts +314 -0
  189. package/src/elements/ai/decisions/decide.test.ts +953 -0
  190. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  191. package/src/elements/ai/decisions/export.test.ts +120 -0
  192. package/src/elements/ai/decisions/export.ts +146 -0
  193. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  194. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  195. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  196. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  197. package/src/elements/ai/decisions/http.test.ts +154 -0
  198. package/src/elements/ai/decisions/http.ts +240 -0
  199. package/src/elements/ai/decisions/labels.test.ts +282 -0
  200. package/src/elements/ai/decisions/labels.ts +257 -0
  201. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  202. package/src/elements/ai/decisions/openrouter.ts +36 -0
  203. package/src/elements/ai/decisions/provider.ts +102 -0
  204. package/src/elements/ai/decisions/typesafe.ts +36 -0
  205. package/src/elements/ai/declare.ts +252 -1
  206. package/src/elements/ai/events.test.ts +379 -0
  207. package/src/elements/ai/events.ts +138 -0
  208. package/src/elements/ai/mcp-protocol.ts +5 -6
  209. package/src/elements/ai/run-events.test.ts +461 -0
  210. package/src/elements/ai/run-events.ts +522 -0
  211. package/src/elements/ai/runtime.ts +1187 -105
  212. package/src/elements/ai/stream-turn.ts +214 -0
  213. package/src/elements/ai/stream.live.test.ts +76 -0
  214. package/src/elements/ai/subagent.test.ts +136 -0
  215. package/src/elements/ai.test.ts +497 -3
  216. package/src/elements/ai.ts +16 -1
  217. package/src/elements/channel/runtime.ts +11 -0
  218. package/src/elements/channel/sql-ledger.ts +251 -0
  219. package/src/elements/clock/durable.ts +27 -1
  220. package/src/elements/signal/runtime.ts +9 -0
  221. package/src/elements/signal.test.ts +1 -0
  222. package/src/elements/store/cache.test.ts +120 -15
  223. package/src/elements/store/cache.ts +200 -39
  224. package/src/elements/store/prepare-row.test.ts +6 -2
  225. package/src/elements/store/sql-session.test.ts +53 -0
  226. package/src/elements/store/sql-session.ts +206 -10
  227. package/src/elements/store.ts +1 -0
  228. package/src/i18n/catalogs/ar.ts +5 -0
  229. package/src/i18n/catalogs/en.ts +5 -0
  230. package/src/index.ts +2 -0
  231. package/src/kernel/agent-event-store.ts +448 -0
  232. package/src/kernel/app.ts +135 -17
  233. package/src/kernel/auto-cache.test.ts +228 -0
  234. package/src/kernel/boot-bind/ai.ts +3 -0
  235. package/src/kernel/boot-bind/channel.ts +63 -2
  236. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  237. package/src/kernel/boot-bind/signal.ts +61 -25
  238. package/src/kernel/boot-bind/store.test.ts +26 -0
  239. package/src/kernel/boot-bind/store.ts +82 -6
  240. package/src/kernel/boot.ts +31 -6
  241. package/src/kernel/builtin-errors.ts +2 -0
  242. package/src/kernel/capability.ts +3 -0
  243. package/src/kernel/decision-label-store.ts +441 -0
  244. package/src/kernel/effects.test.ts +2 -1
  245. package/src/kernel/effects.ts +13 -1
  246. package/src/kernel/element-registries.ts +3 -0
  247. package/src/kernel/errors-compiler.ts +11 -0
  248. package/src/kernel/errors-text.ts +20 -0
  249. package/src/kernel/errors.ts +10 -0
  250. package/src/kernel/flow.test.ts +2 -3
  251. package/src/kernel/flow.ts +33 -3
  252. package/src/kernel/fx-call-types.test.ts +32 -0
  253. package/src/kernel/fx-decide.ts +765 -0
  254. package/src/kernel/fx-sql-handle.ts +46 -7
  255. package/src/kernel/fx.test.ts +13 -8
  256. package/src/kernel/fx.ts +339 -45
  257. package/src/kernel/http-frame.test.ts +94 -0
  258. package/src/kernel/http-frame.ts +203 -0
  259. package/src/kernel/idempotency.test.ts +2 -1
  260. package/src/kernel/journal.test.ts +114 -0
  261. package/src/kernel/journal.ts +471 -51
  262. package/src/kernel/json-result.ts +10 -0
  263. package/src/kernel/pipeline-tenant.ts +5 -1
  264. package/src/kernel/pipeline.test.ts +45 -0
  265. package/src/kernel/pipeline.ts +5 -0
  266. package/src/kernel/signal-tx.ts +28 -0
  267. package/src/kernel/sse-id.ts +32 -0
  268. package/src/kernel/store-transaction.test.ts +95 -0
  269. package/src/manifest/diff.ts +1 -0
  270. package/src/manifest/types.ts +29 -2
  271. package/src/mcp/ai-tools.test.ts +202 -0
  272. package/src/mcp/authorization.ts +66 -0
  273. package/src/mcp/docs-server.ts +19 -2
  274. package/src/mcp/mcp.test.ts +83 -0
  275. package/src/mcp/protocol.ts +8 -2
  276. package/src/mcp/server.ts +23 -2
  277. package/src/mcp/session.ts +3 -0
  278. package/src/mcp/tools.ts +168 -0
  279. package/src/mcp/versions.ts +60 -0
  280. package/src/plugins/index.ts +1 -0
  281. package/src/plugins/mena.ts +56 -0
  282. package/src/release/http-graph.test.ts +2 -0
  283. package/src/release/http-graph.ts +5 -0
  284. package/src/release/measure.ts +39 -14
  285. package/src/runs/collect.ts +4 -1
  286. package/src/runs/parquet.test.ts +27 -1
  287. package/src/runs/parquet.ts +19 -0
  288. package/src/runs/types.ts +31 -0
  289. package/src/runtime/json-code-block.test.ts +106 -47
  290. package/src/runtime/json-code-block.ts +1209 -46
  291. package/src/term.test.ts +13 -0
  292. package/src/term.ts +81 -49
  293. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  294. package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
  295. package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
  296. package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
  297. package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
  298. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
  299. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
  300. package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
  301. package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
  302. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  303. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
  304. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
  305. package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
  306. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
  307. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
  308. package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
  309. package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
  310. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
  311. package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
  312. package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
@@ -503,7 +503,7 @@ function createMemorySqlConnection(role: "primary" | "replica"): SqlConnection {
503
503
  }
504
504
 
505
505
  const create =
506
- /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\((.+)\)\s*$/i.exec(
506
+ /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\((.+)\)\s*$/is.exec(
507
507
  text,
508
508
  );
509
509
  if (create) {
@@ -519,6 +519,26 @@ function createMemorySqlConnection(role: "primary" | "replica"): SqlConnection {
519
519
  return { changes: 0 };
520
520
  }
521
521
 
522
+ const addColumn =
523
+ /^ALTER\s+TABLE\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s+ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\b/is.exec(
524
+ text,
525
+ );
526
+ if (addColumn) {
527
+ const name = parseIdent(addColumn[1]!);
528
+ const column = parseIdent(addColumn[2]!);
529
+ const table = tables.get(name);
530
+ if (table && !table.columns.includes(column)) table.columns.push(column);
531
+ return { changes: 0 };
532
+ }
533
+
534
+ // Scan results do not depend on indexes. Identity and vault DDL still
535
+ // issue CREATE INDEX IF NOT EXISTS on the shared memory connection.
536
+ const createIndex =
537
+ /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:"?[a-zA-Z_][a-zA-Z0-9_]*"?)\s+ON\s+/is.exec(
538
+ text,
539
+ );
540
+ if (createIndex) return { changes: 0 };
541
+
522
542
  const insert =
523
543
  /^INSERT\s+INTO\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*$/i.exec(
524
544
  text,
@@ -5,6 +5,7 @@ import {
5
5
  buildRlsIdentityPreludeSql,
6
6
  emitPgPolicySource,
7
7
  formatPolicyRole,
8
+ OKE_PG_STAT_STATEMENTS_SQL,
8
9
  OKE_RLS_HELPER_STATEMENTS,
9
10
  OKE_RLS_ROLE,
10
11
  parseSqlPolicySpec,
@@ -149,6 +150,36 @@ describe("OKE_RLS_HELPER_STATEMENTS", () => {
149
150
  expect(hasScope).not.toMatch(/jsonb\s+\?/);
150
151
  });
151
152
 
153
+ test("initial oke SQL creates pg_stat_statements before helpers", async () => {
154
+ const ran: string[] = [];
155
+ const { installOkeRlsHelpers } = await import("./pg-rls.ts");
156
+ await installOkeRlsHelpers(async (sql) => {
157
+ ran.push(sql);
158
+ if (sql.startsWith("CREATE EXTENSION")) return;
159
+ if (sql.includes("pg_vault") || sql.includes("vault.")) {
160
+ throw new Error("stop before vault attach");
161
+ }
162
+ }).catch(() => undefined);
163
+ expect(ran[0]).toBe(OKE_PG_STAT_STATEMENTS_SQL);
164
+ expect(ran[1]).toBe(OKE_RLS_HELPER_STATEMENTS[0]);
165
+ });
166
+
167
+ test("a rejected pg_stat_statements create still installs helpers", async () => {
168
+ const ran: string[] = [];
169
+ const { installOkeRlsHelpers } = await import("./pg-rls.ts");
170
+ await installOkeRlsHelpers(async (sql) => {
171
+ ran.push(sql);
172
+ if (sql.startsWith("CREATE EXTENSION")) {
173
+ throw new Error('extension "pg_stat_statements" is not available');
174
+ }
175
+ if (sql.includes("CREATE OR REPLACE FUNCTION oke.gate")) {
176
+ throw new Error("stop");
177
+ }
178
+ }).catch(() => undefined);
179
+ expect(ran[0]).toBe(OKE_PG_STAT_STATEMENTS_SQL);
180
+ expect(ran.some((sql) => sql.includes("CREATE SCHEMA IF NOT EXISTS oke"))).toBe(true);
181
+ });
182
+
152
183
  test("pins search_path to public so schema oke is not the table home", () => {
153
184
  expect(OKE_RLS_HELPER_STATEMENTS).toContain("SET search_path TO public, oke");
154
185
  expect(
@@ -135,12 +135,27 @@ $oke_app_search_path$`,
135
135
  */
136
136
  export const OKE_RLS_HELPER_SQL = `${OKE_RLS_HELPER_STATEMENTS.join(";\n")};`;
137
137
 
138
+ /**
139
+ * Enable query telemetry on postgres. Preload is still required
140
+ * (`shared_preload_libraries=pg_stat_statements`). PGlite and Cockroach
141
+ * reject this; callers swallow that failure so the rest of the oke SQL runs.
142
+ */
143
+ export const OKE_PG_STAT_STATEMENTS_SQL = "CREATE EXTENSION IF NOT EXISTS pg_stat_statements";
144
+
138
145
  /**
139
146
  * Install `oke.*` helpers one statement at a time.
140
147
  *
148
+ * `pg_stat_statements` is created first when the engine allows it, so Store
149
+ * performance does not depend on a later manual `CREATE EXTENSION`.
150
+ *
141
151
  * @param exec - Statement runner
142
152
  */
143
153
  export async function installOkeRlsHelpers(exec: (sql: string) => Promise<unknown>): Promise<void> {
154
+ try {
155
+ await exec(OKE_PG_STAT_STATEMENTS_SQL);
156
+ } catch {
157
+ // Not preloaded, or this engine has no pg_stat_statements (pglite, cockroach).
158
+ }
144
159
  for (const stmt of OKE_RLS_HELPER_STATEMENTS) {
145
160
  await exec(stmt);
146
161
  }
@@ -38,6 +38,15 @@ describe("toPostgresParams", () => {
38
38
  });
39
39
 
40
40
  describe("closeSharedPostgresClients", () => {
41
+ test("a different max is a different pool on the same URL", () => {
42
+ const url = "postgres://127.0.0.1:9/oke-pool-max-shared-test";
43
+ const small = sharedPostgresClient(url, 8);
44
+ const large = sharedPostgresClient(url, 20);
45
+ expect(large).not.toBe(small);
46
+ expect(sharedPostgresClient(url, 20)).toBe(large);
47
+ expect(sharedPostgresClient(url)).toBe(small);
48
+ });
49
+
41
50
  test("drops cached pools so the next checkout is a new client", async () => {
42
51
  const url = "postgres://127.0.0.1:9/oke-close-shared-test";
43
52
  const first = sharedPostgresClient(url);
@@ -43,7 +43,27 @@ type SharedPostgresEntry = {
43
43
  readonly guarded: PostgresClientLike;
44
44
  };
45
45
 
46
- const sharedClients = new Map<string, SharedPostgresEntry>();
46
+ /**
47
+ * Process-wide pool registry.
48
+ *
49
+ * `bun --hot` re-evaluates this module on each save. A module-local `Map`
50
+ * would drop the only handle to the previous Bun.SQL pools and leave them
51
+ * connected. PgDog then queues every checkout — Console login included —
52
+ * until its server pool times out.
53
+ */
54
+ const SHARED_POSTGRES_CLIENTS = Symbol.for("oke.sharedPostgresClients");
55
+
56
+ function sharedClientMap(): Map<string, SharedPostgresEntry> {
57
+ const g = globalThis as typeof globalThis & {
58
+ [SHARED_POSTGRES_CLIENTS]?: Map<string, SharedPostgresEntry>;
59
+ };
60
+ let map = g[SHARED_POSTGRES_CLIENTS];
61
+ if (!map) {
62
+ map = new Map();
63
+ g[SHARED_POSTGRES_CLIENTS] = map;
64
+ }
65
+ return map;
66
+ }
47
67
 
48
68
  /**
49
69
  * When true, shared pools stay closed and every query fails fast — no Bun.SQL
@@ -160,21 +180,38 @@ function guardSharedPostgresClient(raw: PostgresClientLike): PostgresClientLike
160
180
  }
161
181
 
162
182
  /**
163
- * One Bun.SQL pool per URL for store / journal / clock / vault / console.
183
+ * Bun.SQL pool size. Unset, non-finite, or below 1 keeps {@link POSTGRES_POOL_MAX}.
184
+ * A configured max is not capped — callers that need a larger pool ask for it.
185
+ *
186
+ * @param poolMax - Requested `max`
187
+ */
188
+ function postgresPoolMax(poolMax: number | undefined): number {
189
+ if (poolMax === undefined || !Number.isFinite(poolMax) || poolMax < 1) return POSTGRES_POOL_MAX;
190
+ return Math.floor(poolMax);
191
+ }
192
+
193
+ /**
194
+ * One Bun.SQL pool per URL and `max` for store / journal / clock / vault / console.
164
195
  *
165
196
  * Returns a pause-aware client — holders (including fleet store wrappers)
166
197
  * fail soft after {@link pauseSharedPostgresClients} instead of reconnecting.
167
198
  *
168
199
  * @param url - Connection URL
200
+ * @param poolMax - Bun.SQL `max`. Omitted uses {@link POSTGRES_POOL_MAX}. A
201
+ * different max is a different pool — a store pin of 20 must not reuse a
202
+ * pool another subsystem already opened at the default.
169
203
  */
170
- export function sharedPostgresClient(url?: string): PostgresClientLike {
204
+ export function sharedPostgresClient(url?: string, poolMax?: number): PostgresClientLike {
171
205
  assertSharedPostgresReady();
172
- const key = resolvePostgresUrl(url);
173
- const existing = sharedClients.get(key);
206
+ const resolved = resolvePostgresUrl(url);
207
+ const max = postgresPoolMax(poolMax);
208
+ const key = `${resolved}\0${String(max)}`;
209
+ const clients = sharedClientMap();
210
+ const existing = clients.get(key);
174
211
  if (existing) return existing.guarded;
175
- const raw = new Bun.SQL(key, { max: POSTGRES_POOL_MAX }) as unknown as PostgresClientLike;
212
+ const raw = new Bun.SQL(resolved, { max }) as unknown as PostgresClientLike;
176
213
  const guarded = guardSharedPostgresClient(raw);
177
- sharedClients.set(key, { raw, guarded });
214
+ clients.set(key, { raw, guarded });
178
215
  return guarded;
179
216
  }
180
217
 
@@ -185,8 +222,8 @@ export function sharedPostgresClient(url?: string): PostgresClientLike {
185
222
  * not stop Console from opening a fresh pool on the next poll.
186
223
  */
187
224
  export async function closeSharedPostgresClients(): Promise<void> {
188
- const clients = [...sharedClients.values()];
189
- sharedClients.clear();
225
+ const clients = [...sharedClientMap().values()];
226
+ sharedClientMap().clear();
190
227
  await Promise.all(
191
228
  clients.map(async (entry) => {
192
229
  try {
@@ -283,12 +320,13 @@ export async function connectPostgres(options: SqlConnectOptions = {}): Promise<
283
320
  // must not share a pooled client — Bun raises ERR_POSTGRES_UNSAFE_TRANSACTION
284
321
  // when raw statements interleave with open transaction state on the shared
285
322
  // pool. Opt in explicitly; default behavior is unchanged.
286
- const dedicated = !injected && options.pool?.max === 1;
323
+ const requestedMax = options.pool?.max;
324
+ const dedicated = !injected && requestedMax === 1;
287
325
  const client =
288
326
  injected ??
289
327
  (dedicated
290
328
  ? dedicatedPostgresClient(resolvePostgresUrl(options.url))
291
- : sharedPostgresClient(options.url));
329
+ : sharedPostgresClient(options.url, requestedMax));
292
330
  return wrapPostgresClient(client, role, {
293
331
  shared: injected === undefined && !dedicated,
294
332
  ...(options.url !== undefined ? { url: options.url } : {}),
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Two consumers, one bus: postgres SKIP LOCKED and redis consumer groups.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { signal } from "../elements/signal.ts";
7
+ import {
8
+ openPostgresChannelLedger,
9
+ type ChannelLedgerSql,
10
+ } from "../elements/channel/sql-ledger.ts";
11
+ import { createPostgresSignalFake, openPostgresSignal } from "./signal-postgres.ts";
12
+ import {
13
+ createBunSignalRedisClient,
14
+ createSignalRedisFake,
15
+ openRedisSignal,
16
+ } from "./signal-redis.ts";
17
+
18
+ function decls(name: string, retries = 3) {
19
+ const decl = signal.once(name, { retries, deadLetter: true });
20
+ return new Map([[decl.name, decl]]);
21
+ }
22
+
23
+ describe("postgres signal competing consumers", () => {
24
+ test("one message is delivered once across two buses", async () => {
25
+ const sql = createPostgresSignalFake();
26
+ const name = "order-placed";
27
+ const a = await openPostgresSignal({ signals: decls(name), sql });
28
+ const b = await openPostgresSignal({ signals: decls(name), sql });
29
+ const got: string[] = [];
30
+ await a.subscribe(name, "a", async () => {
31
+ got.push("a");
32
+ });
33
+ await b.subscribe(name, "b", async () => {
34
+ got.push("b");
35
+ });
36
+ await a.emit(name, { id: "1" });
37
+ await Bun.sleep(5);
38
+ await a.drain();
39
+ await b.drain();
40
+ expect(got).toHaveLength(1);
41
+ await a.close();
42
+ await b.close();
43
+ });
44
+ });
45
+
46
+ describe("redis streams competing consumers", () => {
47
+ test("one message is delivered once across two groups", async () => {
48
+ const redis = createSignalRedisFake();
49
+ const name = "order-placed";
50
+ const shared = { signals: decls(name), redis, compete: true as const };
51
+ const a = await openRedisSignal({ ...shared, consumerId: "a" });
52
+ const b = await openRedisSignal({ ...shared, consumerId: "b" });
53
+ const got: string[] = [];
54
+ await a.subscribe(name, "a", async () => {
55
+ got.push("a");
56
+ });
57
+ await b.subscribe(name, "b", async () => {
58
+ got.push("b");
59
+ });
60
+ await a.emit(name, { id: "1" });
61
+ await a.drain();
62
+ await b.drain();
63
+ expect(got).toHaveLength(1);
64
+ await a.close();
65
+ await b.close();
66
+ });
67
+
68
+ test("a failed handler leaves the entry pending for redelivery", async () => {
69
+ const redis = createSignalRedisFake();
70
+ const name = "flaky";
71
+ const bus = await openRedisSignal({
72
+ signals: decls(name, 2),
73
+ redis,
74
+ compete: true,
75
+ consumerId: "a",
76
+ });
77
+ let calls = 0;
78
+ await bus.subscribe(name, "a", async () => {
79
+ calls += 1;
80
+ if (calls === 1) throw new Error("boom");
81
+ });
82
+ await bus.emit(name, { id: "1" });
83
+ await bus.drain();
84
+ expect(calls).toBe(1);
85
+ await bus.drain();
86
+ expect(calls).toBe(2);
87
+ await bus.close();
88
+ });
89
+
90
+ test("another consumer reclaims an unacked entry", async () => {
91
+ const redis = createSignalRedisFake();
92
+ const name = "handoff";
93
+ const shared = { signals: decls(name, 3), redis, compete: true as const };
94
+ const a = await openRedisSignal({ ...shared, consumerId: "a" });
95
+ const b = await openRedisSignal({ ...shared, consumerId: "b" });
96
+ let aCalls = 0;
97
+ const got: string[] = [];
98
+ await a.subscribe(name, "a", async () => {
99
+ aCalls += 1;
100
+ throw new Error("down");
101
+ });
102
+ await b.subscribe(name, "b", async () => {
103
+ got.push("b");
104
+ });
105
+ await a.emit(name, { id: "1" });
106
+ await a.drain();
107
+ await b.drain();
108
+ expect(aCalls).toBe(1);
109
+ expect(got).toEqual(["b"]);
110
+ await a.close();
111
+ await b.close();
112
+ });
113
+
114
+ test.skipIf(!process.env.REDIS_URL)("REDIS_URL: two clients ack once", async () => {
115
+ const url = process.env.REDIS_URL;
116
+ const name = `oke-compete-${Date.now()}`;
117
+ const redisA = createBunSignalRedisClient(url);
118
+ const redisB = createBunSignalRedisClient(url);
119
+ const a = await openRedisSignal({
120
+ signals: decls(name),
121
+ redis: redisA,
122
+ compete: true,
123
+ consumerId: "a",
124
+ });
125
+ const b = await openRedisSignal({
126
+ signals: decls(name),
127
+ redis: redisB,
128
+ compete: true,
129
+ consumerId: "b",
130
+ });
131
+ const got: string[] = [];
132
+ await a.subscribe(name, "a", async () => {
133
+ got.push("a");
134
+ });
135
+ await b.subscribe(name, "b", async () => {
136
+ got.push("b");
137
+ });
138
+ await a.emit(name, { id: "1" });
139
+ await a.drain();
140
+ await b.drain();
141
+ expect(got).toHaveLength(1);
142
+ await a.close();
143
+ await b.close();
144
+ });
145
+ });
146
+
147
+ function memoryLedgerSql(): ChannelLedgerSql {
148
+ const consent = new Map<string, { subject: string; medium: string; at: number }>();
149
+ const bounce = new Map<string, { subject: string; medium: string; at: number }>();
150
+ const receipts = new Map<string, string>();
151
+
152
+ function pair(params: readonly unknown[]): string {
153
+ return `${String(params[0])}\0${String(params[1])}`;
154
+ }
155
+
156
+ return {
157
+ async query(sql) {
158
+ if (sql.includes("oke_channel_consent")) {
159
+ return [...consent.values()];
160
+ }
161
+ if (sql.includes("oke_channel_bounce")) {
162
+ return [...bounce.values()];
163
+ }
164
+ if (sql.includes("oke_channel_receipt")) {
165
+ return [...receipts.entries()].map(([id, body]) => ({ id, body }));
166
+ }
167
+ return [];
168
+ },
169
+ async exec(sql, params = []) {
170
+ if (sql.startsWith("CREATE")) return { changes: 0 };
171
+ if (sql.includes("INSERT INTO oke_channel_consent")) {
172
+ consent.set(pair(params), {
173
+ subject: String(params[0]),
174
+ medium: String(params[1]),
175
+ at: Number(params[2]),
176
+ });
177
+ return { changes: 1 };
178
+ }
179
+ if (sql.includes("DELETE FROM oke_channel_consent")) {
180
+ consent.delete(pair(params));
181
+ return { changes: 1 };
182
+ }
183
+ if (sql.includes("INSERT INTO oke_channel_bounce")) {
184
+ bounce.set(pair(params), {
185
+ subject: String(params[0]),
186
+ medium: String(params[1]),
187
+ at: Number(params[2]),
188
+ });
189
+ return { changes: 1 };
190
+ }
191
+ if (sql.includes("DELETE FROM oke_channel_bounce")) {
192
+ bounce.delete(pair(params));
193
+ return { changes: 1 };
194
+ }
195
+ if (sql.includes("INSERT INTO oke_channel_receipt")) {
196
+ receipts.set(String(params[0]), String(params[1]));
197
+ return { changes: 1 };
198
+ }
199
+ if (sql.includes("UPDATE oke_channel_receipt")) {
200
+ receipts.set(String(params[1]), String(params[0]));
201
+ return { changes: 1 };
202
+ }
203
+ return { changes: 0 };
204
+ },
205
+ };
206
+ }
207
+
208
+ describe("channel ledger across instances", () => {
209
+ test("consent and suppression written on A are visible to B", async () => {
210
+ const sql = memoryLedgerSql();
211
+ const a = await openPostgresChannelLedger(sql);
212
+ const b = await openPostgresChannelLedger(sql);
213
+ a.suppression.optOut("a@b.c", "email");
214
+ a.suppression.addPriorBounce("c@d.e", "sms");
215
+ a.receipts.record({
216
+ id: "r1",
217
+ template: "note",
218
+ to: "a@b.c",
219
+ medium: "email",
220
+ status: "sent",
221
+ attempts: [],
222
+ at: 1,
223
+ });
224
+ await a.flush();
225
+ await b.reload();
226
+ expect(b.consent.isOptedOut("a@b.c", "email")).toBe(true);
227
+ expect(b.suppression.isSuppressed("c@d.e", "sms")).toEqual({
228
+ suppressed: true,
229
+ reason: "prior-bounce",
230
+ });
231
+ expect(b.receipts.all()).toHaveLength(1);
232
+ expect(b.receipts.all()[0]?.id).toBe("r1");
233
+ });
234
+ });
@@ -2,7 +2,8 @@
2
2
  * `postgres` signal driver — transactional emit (dual-write fix).
3
3
  *
4
4
  * `fx.emit` inserts into the outbox on the caller's connection. Consumers
5
- * claim with `FOR UPDATE SKIP LOCKED`; wakeups use `LISTEN` / `NOTIFY`.
5
+ * claim with `FOR UPDATE SKIP LOCKED`. Wakeups use `LISTEN` / `NOTIFY` when
6
+ * the client has them; otherwise the boot scheduler polls `drain`.
6
7
  */
7
8
 
8
9
  import type { SignalDecl } from "../elements/signal/declare.ts";
@@ -65,18 +66,19 @@ export interface PostgresSignalSql {
65
66
  begin<T>(fn: (sql: PostgresSignalSql) => Promise<T>): Promise<T>;
66
67
  /**
67
68
  * LISTEN channel; returns unsubscribe.
69
+ * Optional — Bun.SQL has no listen API. Omit it and poll {@link SignalBus.drain}.
68
70
  *
69
71
  * @param channel - Channel name
70
72
  * @param onNotify - Payload callback
71
73
  */
72
- listen(channel: string, onNotify: (payload: string) => void): Promise<() => void>;
74
+ listen?(channel: string, onNotify: (payload: string) => void): Promise<() => void>;
73
75
  /**
74
- * NOTIFY channel.
76
+ * NOTIFY channel. Optional when the client cannot notify.
75
77
  *
76
78
  * @param channel - Channel name
77
79
  * @param payload - Payload
78
80
  */
79
- notify(channel: string, payload: string): Promise<void>;
81
+ notify?(channel: string, payload: string): Promise<void>;
80
82
  close(): Promise<void>;
81
83
  }
82
84
 
@@ -205,6 +207,9 @@ export function createPostgresSignalFake(options?: {
205
207
  return true;
206
208
  });
207
209
  if (!row) return [];
210
+ // Hold the row before yielding so a concurrent claim skips it.
211
+ row.status = "inflight";
212
+ if (row.locked_by === null) row.locked_by = "claimed";
208
213
  return [{ ...row }];
209
214
  }
210
215
 
@@ -565,9 +570,18 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
565
570
  return n;
566
571
  }
567
572
 
568
- unlisten = await sql.listen(CHANNEL, () => {
569
- void drainQuiet();
570
- });
573
+ if (typeof sql.listen === "function") {
574
+ unlisten = await sql.listen(CHANNEL, () => {
575
+ void drainQuiet();
576
+ });
577
+ } else if ((options.pollMs ?? 0) > 0) {
578
+ const timer = setInterval(() => {
579
+ void drainQuiet();
580
+ }, options.pollMs);
581
+ unlisten = () => {
582
+ clearInterval(timer);
583
+ };
584
+ }
571
585
 
572
586
  function requireDecl(name: string): SignalDecl {
573
587
  const decl = signals.get(name);
@@ -663,7 +677,9 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
663
677
  await insertEmit(tx, e.signal, e.payload, e.options);
664
678
  }
665
679
  });
666
- await sql.notify(CHANNEL, "commit");
680
+ if (typeof sql.notify === "function") {
681
+ await sql.notify(CHANNEL, "commit");
682
+ }
667
683
  },
668
684
  async rollback() {
669
685
  if (finished) throw new Error("transaction finished");
@@ -827,20 +843,24 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
827
843
  for (const consumer of consumers) {
828
844
  const decl = signals.get(consumer.signal);
829
845
  if (decl?.delivery !== "once") continue;
830
- const claimed = await sql.query(CLAIM_ONCE_SQL, [consumer.signal, t, t, t]);
831
- if (claimed[0]) {
832
- progress = true;
833
- const row = claimed[0];
834
- const attempts = Number(row.attempts) + 1;
846
+ const row = await sql.begin(async (tx) => {
847
+ const claimed = await tx.query(CLAIM_ONCE_SQL, [consumer.signal, t, t, t]);
848
+ const hit = claimed[0];
849
+ if (!hit) return undefined;
850
+ const attempts = Number(hit.attempts) + 1;
835
851
  const leaseExpiresAt = t + leaseMs;
836
- await sql.exec(
852
+ await tx.exec(
837
853
  `UPDATE oke_signal_messages SET status = 'inflight', locked_by = ?, attempts = ?, lease_expires_at = ? WHERE id = ?`,
838
- [consumer.subscriberId, attempts, leaseExpiresAt, row.id],
854
+ [consumer.subscriberId, attempts, leaseExpiresAt, hit.id],
839
855
  );
840
- row.locked_by = consumer.subscriberId;
841
- row.attempts = attempts;
842
- row.status = "inflight";
843
- row.lease_expires_at = leaseExpiresAt;
856
+ hit.locked_by = consumer.subscriberId;
857
+ hit.attempts = attempts;
858
+ hit.status = "inflight";
859
+ hit.lease_expires_at = leaseExpiresAt;
860
+ return hit;
861
+ });
862
+ if (row) {
863
+ progress = true;
844
864
  await deliverOnce(row, consumer);
845
865
  }
846
866
  }