okengine 0.13.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/manifest.v1.schema.json +7 -1
  2. package/package.json +4 -5
  3. package/site/content/docs/ai/mcp.mdx +27 -2
  4. package/site/content/docs/elements/ai.mdx +59 -11
  5. package/site/content/docs/elements/channel.mdx +1 -0
  6. package/site/content/docs/elements/clock.mdx +30 -12
  7. package/site/content/docs/elements/flow.mdx +9 -3
  8. package/site/content/docs/elements/gate.mdx +25 -22
  9. package/site/content/docs/elements/signal.mdx +71 -25
  10. package/site/content/docs/elements/store.mdx +87 -28
  11. package/site/content/docs/elements/vault.mdx +1 -0
  12. package/site/content/docs/get-started/basic-usage.mdx +22 -13
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +2 -2
  15. package/site/content/docs/get-started/introduction.mdx +11 -3
  16. package/site/content/docs/get-started/meta.json +1 -1
  17. package/site/content/docs/get-started/project-structure.mdx +932 -0
  18. package/site/content/docs/reference/cli.md +13 -10
  19. package/site/content/docs/reference/client.mdx +25 -21
  20. package/site/content/docs/reference/fx.mdx +27 -22
  21. package/site/content/docs/reference/security.md +1 -1
  22. package/src/auth/bindings.ts +14 -5
  23. package/src/auth/cross-plane.ts +10 -3
  24. package/src/cli/ai-setup/ai-setup.test.ts +62 -0
  25. package/src/cli/ai-setup/apply.ts +32 -1
  26. package/src/cli/ai-setup/recommend.test.ts +25 -0
  27. package/src/cli/ai-setup/recommend.ts +8 -3
  28. package/src/cli/attach-host-console.test.ts +3 -2
  29. package/src/cli/attach-host-console.ts +3 -0
  30. package/src/cli/build.test.ts +4 -2
  31. package/src/cli/db-seed.ts +48 -15
  32. package/src/cli/db.test.ts +104 -0
  33. package/src/cli/db.ts +122 -10
  34. package/src/cli/dev-controls.test.ts +20 -0
  35. package/src/cli/dev-controls.ts +13 -0
  36. package/src/cli/dev.test.ts +15 -4
  37. package/src/cli/dev.ts +102 -29
  38. package/src/cli/ensure-drizzle-config.ts +2 -1
  39. package/src/cli/tui/DevLive.tsx +12 -2
  40. package/src/cli/tui/keys.test.ts +2 -1
  41. package/src/cli/tui/keys.ts +3 -2
  42. package/src/compiler/aot.test.ts +1 -1
  43. package/src/compiler/effects-infer.ts +58 -3
  44. package/src/compiler/extract.test.ts +458 -29
  45. package/src/compiler/extract.ts +387 -69
  46. package/src/compiler/flow-path.test.ts +96 -0
  47. package/src/compiler/flow-path.ts +173 -0
  48. package/src/compiler/generate-adopt.test.ts +112 -3
  49. package/src/compiler/generate-adopt.ts +280 -26
  50. package/src/compiler/response.ts +45 -1
  51. package/src/compiler/schema-from-ast.ts +609 -0
  52. package/src/console/server/ai.ts +5 -2
  53. package/src/console/server/bind.ts +2 -2
  54. package/src/console/server/console.test.ts +3 -0
  55. package/src/console/server/flows.ts +121 -40
  56. package/src/console/server/invoke-user-flow.test.ts +3 -1
  57. package/src/console/server/invoke-user-flow.ts +17 -2
  58. package/src/console/server/iso-at.test.ts +9 -0
  59. package/src/console/server/iso-at.ts +11 -0
  60. package/src/console/server/security-headers.ts +6 -3
  61. package/src/console/server/serve.ts +17 -2
  62. package/src/console/server/spa-proxy.test.ts +1 -0
  63. package/src/console/server/sql-catalog.ts +147 -7
  64. package/src/console/server/sql-rls.test.ts +16 -0
  65. package/src/console/server/state.ts +8 -0
  66. package/src/console/server/store.test.ts +7 -4
  67. package/src/console/server/store.ts +142 -58
  68. package/src/console/ui-next/dist/assets/agent-disclosure-C0X1fbWF.js +1 -0
  69. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +1 -0
  70. package/src/console/ui-next/dist/assets/{call-pii-button-bqkxMrJH.js → call-pii-button-DEDCl_j3.js} +1 -1
  71. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +1 -0
  72. package/src/console/ui-next/dist/assets/{confirm-sheet-DDCRmG62.js → confirm-sheet-3ptDzXbz.js} +1 -1
  73. package/src/console/ui-next/dist/assets/{duration-tone-oiRxPVsZ.js → duration-tone-sC3lGABz.js} +1 -1
  74. package/src/console/ui-next/dist/assets/{explorer-empty-HjTnVQoR.js → explorer-empty-BvYrygyO.js} +1 -1
  75. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +1 -0
  76. package/src/console/ui-next/dist/assets/{highlighted-json-CvDPvveV.js → highlighted-json-CS_O8L-r.js} +1 -1
  77. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +1 -0
  78. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +66 -0
  79. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +2 -0
  80. package/src/console/ui-next/dist/assets/{link-DF7SZ9Ek.js → link-COeyggt-.js} +1 -1
  81. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +4 -0
  82. package/src/console/ui-next/dist/assets/react-D8E3mtu1.js +1 -0
  83. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +18 -0
  84. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +1 -0
  85. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +1 -0
  86. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +1 -0
  87. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +41 -0
  88. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +2 -0
  89. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +54 -0
  90. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +1 -0
  91. package/src/console/ui-next/dist/assets/{use-vault-list-Cl79j_ku.js → use-vault-list-BLbZhzyF.js} +1 -1
  92. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +2 -0
  93. package/src/console/ui-next/dist/index.html +6 -6
  94. package/src/console/ui-next/seed-invoke-host.ts +2 -0
  95. package/src/console/ui-next/src/client.ts +35 -7
  96. package/src/console/ui-next/src/components/motion/table/index.tsx +16 -0
  97. package/src/console/ui-next/src/components/motion/table/types.ts +4 -0
  98. package/src/console/ui-next/src/components/ui/kbd.tsx +2 -2
  99. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
  100. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
  101. package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
  102. package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
  103. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
  104. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  105. package/src/console/ui-next/src/features/flows/traces/http-method.ts +19 -0
  106. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
  107. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +29 -2
  108. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +10 -3
  109. package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
  110. package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
  111. package/src/console/ui-next/src/features/store/detail/browse-section.tsx +45 -1
  112. package/src/console/ui-next/src/features/store/detail/resource-panel.tsx +78 -16
  113. package/src/console/ui-next/src/features/store/detail/store-row-detail-sheet.tsx +155 -14
  114. package/src/console/ui-next/src/features/store/explorer/store-tree.tsx +33 -133
  115. package/src/console/ui-next/src/features/store/files/file-preview.tsx +4 -1
  116. package/src/console/ui-next/src/features/store/grid/rls-policy-sheet.tsx +326 -165
  117. package/src/console/ui-next/src/features/store/grid/sql-insert-sheet.tsx +6 -0
  118. package/src/console/ui-next/src/features/store/grid/store-data-grid.tsx +50 -3
  119. package/src/console/ui-next/src/features/store/lib/files-meta.test.ts +3 -0
  120. package/src/console/ui-next/src/features/store/lib/files-meta.ts +12 -0
  121. package/src/console/ui-next/src/features/store/lib/query-defaults.test.ts +25 -0
  122. package/src/console/ui-next/src/features/store/lib/query-defaults.ts +85 -3
  123. package/src/console/ui-next/src/features/store/lib/query-gate.ts +30 -4
  124. package/src/console/ui-next/src/features/store/lib/rls-policy.test.ts +139 -0
  125. package/src/console/ui-next/src/features/store/lib/rls-policy.ts +223 -21
  126. package/src/console/ui-next/src/features/store/lib/schema-graph.ts +4 -0
  127. package/src/console/ui-next/src/features/store/lib/sql-catalog.test.ts +24 -0
  128. package/src/console/ui-next/src/features/store/lib/sql-catalog.ts +19 -0
  129. package/src/console/ui-next/src/features/store/lib/store-tree.test.ts +6 -0
  130. package/src/console/ui-next/src/features/store/lib/store-tree.ts +13 -0
  131. package/src/console/ui-next/src/features/store/query/query-console.tsx +48 -14
  132. package/src/console/ui-next/src/features/store/query/query-gate-menu.tsx +19 -20
  133. package/src/console/ui-next/src/features/store/query/query-gate-parts.tsx +2 -1
  134. package/src/console/ui-next/src/features/store/query/query-results.tsx +110 -83
  135. package/src/console/ui-next/src/features/store/schema/schema-table-node.tsx +17 -0
  136. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +112 -72
  137. package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
  138. package/src/console/ui-next/src/features/units/lib/contract-input.test.ts +206 -1
  139. package/src/console/ui-next/src/features/units/lib/contract-input.ts +55 -0
  140. package/src/console/ui-next/src/features/vault/lib/types.ts +1 -1
  141. package/src/docker/compose-health.test.ts +27 -0
  142. package/src/docker/compose-health.ts +19 -3
  143. package/src/docker/docker.test.ts +4 -1
  144. package/src/docker/dockerfile.ts +1 -1
  145. package/src/docker/recipes/pgdog.ts +2 -0
  146. package/src/drivers/ai-anthropic.ts +5 -0
  147. package/src/drivers/ai-ollama.ts +49 -30
  148. package/src/drivers/ai-openai-compatible.ts +57 -46
  149. package/src/drivers/ai-providers.test.ts +3 -0
  150. package/src/drivers/bun-native-completeness.test.ts +7 -9
  151. package/src/drivers/clock-postgres.ts +13 -9
  152. package/src/drivers/instances-postgres.ts +4 -6
  153. package/src/drivers/journal-postgres.ts +13 -9
  154. package/src/drivers/pg-rls.test.ts +87 -0
  155. package/src/drivers/pg-rls.ts +233 -7
  156. package/src/drivers/pglite.ts +26 -3
  157. package/src/drivers/postgres.test.ts +23 -0
  158. package/src/drivers/postgres.ts +133 -9
  159. package/src/drivers/redis.ts +11 -4
  160. package/src/drivers/signal-redis.ts +24 -14
  161. package/src/drivers/signal-types.ts +2 -1
  162. package/src/drivers/types.ts +10 -0
  163. package/src/elements/ai/declare.ts +109 -0
  164. package/src/elements/ai/errors.test.ts +5 -1
  165. package/src/elements/ai/errors.ts +30 -2
  166. package/src/elements/ai/eval.ts +4 -6
  167. package/src/elements/ai/mcp-client.test.ts +206 -0
  168. package/src/elements/ai/mcp-client.ts +362 -0
  169. package/src/elements/ai/mcp-http.ts +159 -0
  170. package/src/elements/ai/mcp-mock.ts +134 -0
  171. package/src/elements/ai/mcp-protocol.ts +234 -0
  172. package/src/elements/ai/mcp-stdio.test.ts +50 -0
  173. package/src/elements/ai/mcp-stdio.ts +212 -0
  174. package/src/elements/ai/mcp-transport.ts +70 -0
  175. package/src/elements/ai/runtime.ts +159 -29
  176. package/src/elements/ai.test.ts +139 -0
  177. package/src/elements/ai.ts +16 -0
  178. package/src/elements/clock/declare.ts +26 -1
  179. package/src/elements/clock/health.test.ts +43 -0
  180. package/src/elements/clock/runtime.ts +55 -2
  181. package/src/elements/clock/schedule.ts +71 -142
  182. package/src/elements/clock.test.ts +9 -40
  183. package/src/elements/clock.ts +6 -1
  184. package/src/elements/gate/boot.ts +5 -5
  185. package/src/elements/gate/declare.ts +36 -4
  186. package/src/elements/gate/flatten.ts +1 -1
  187. package/src/elements/gate/runtime.ts +1 -1
  188. package/src/elements/index.ts +2 -0
  189. package/src/elements/signal/declare.ts +2 -1
  190. package/src/elements/signal/runtime.ts +16 -1
  191. package/src/elements/signal.ts +1 -0
  192. package/src/elements/store/cache.test.ts +2 -0
  193. package/src/elements/store/cache.ts +3 -3
  194. package/src/elements/store/declare.ts +9 -6
  195. package/src/elements/store/emit-drizzle.ts +44 -8
  196. package/src/elements/store/resource.test.ts +1 -1
  197. package/src/elements/store/rls-identity.test.ts +95 -0
  198. package/src/elements/store/rls-identity.ts +116 -0
  199. package/src/elements/store/runtime.ts +4 -0
  200. package/src/elements/store/schema-decl.test.ts +37 -0
  201. package/src/elements/store/schema-decl.ts +161 -0
  202. package/src/elements/store/seed.ts +1 -1
  203. package/src/elements/store/sql-condition.test.ts +12 -0
  204. package/src/elements/store/sql-condition.ts +26 -2
  205. package/src/elements/store/sql-rls-isolation.test.ts +165 -0
  206. package/src/elements/store/sql-rls-stamp.test.ts +117 -0
  207. package/src/elements/store/sql-session.ts +81 -3
  208. package/src/elements/store/upsert-app.test.ts +1 -1
  209. package/src/elements/store.ts +14 -0
  210. package/src/elements/vault/builtin-adapter.ts +17 -0
  211. package/src/full.ts +2 -0
  212. package/src/http.ts +20 -3
  213. package/src/index.ts +14 -1
  214. package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
  215. package/src/kernel/adopt-routes.ts +30 -2
  216. package/src/kernel/app.ts +227 -77
  217. package/src/kernel/auto-cache.test.ts +8 -8
  218. package/src/kernel/auto-registry.test.ts +54 -9
  219. package/src/kernel/boot-bind/ai.ts +24 -0
  220. package/src/kernel/boot-bind/clock.ts +2 -0
  221. package/src/kernel/boot.test.ts +4 -4
  222. package/src/kernel/boot.ts +5 -3
  223. package/src/kernel/effects-stamping.test.ts +3 -3
  224. package/src/kernel/element-registries.ts +15 -5
  225. package/src/kernel/errors.ts +24 -0
  226. package/src/kernel/flow-units.ts +64 -0
  227. package/src/kernel/flow.ts +29 -17
  228. package/src/kernel/fx-dead-letters.test.ts +77 -0
  229. package/src/kernel/fx.test.ts +22 -0
  230. package/src/kernel/fx.ts +135 -6
  231. package/src/kernel/horizontal-child.ts +4 -4
  232. package/src/kernel/http-path-pending.ts +20 -0
  233. package/src/kernel/http-resource.ts +4 -0
  234. package/src/kernel/http-stream.test.ts +174 -0
  235. package/src/kernel/index.ts +2 -0
  236. package/src/kernel/pipeline.test.ts +2 -2
  237. package/src/kernel/plugin-elements.test.ts +1 -1
  238. package/src/kernel/plugin-needs.test.ts +1 -1
  239. package/src/kernel/ready.test.ts +2 -2
  240. package/src/kernel/resource-mount.test.ts +14 -0
  241. package/src/kernel/run-duration.test.ts +2 -2
  242. package/src/kernel/stamp-http.test.ts +79 -0
  243. package/src/kernel/stamp-http.ts +75 -0
  244. package/src/kernel/triggers.ts +52 -27
  245. package/src/manifest/mcp-ref.ts +88 -0
  246. package/src/manifest/types.ts +41 -2
  247. package/src/manifest/validate.test.ts +20 -0
  248. package/src/mcp/docs-server.ts +1 -1
  249. package/src/mcp/server.ts +1 -1
  250. package/src/plugins/compression.test.ts +21 -0
  251. package/src/plugins/compression.ts +1 -0
  252. package/src/release/measure.ts +2 -2
  253. package/src/runtime/bun.ts +41 -4
  254. package/src/runtime/json-code-block.test.ts +2 -2
  255. package/src/runtime/serve.test.ts +4 -4
  256. package/src/term.test.ts +52 -4
  257. package/src/term.ts +125 -17
  258. package/src/test/reset-element-registries.ts +10 -3
  259. package/src/cli/ask-seed.test.ts +0 -96
  260. package/src/cli/ask-seed.ts +0 -82
  261. package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
  262. package/src/console/ui-next/dist/assets/collapsible-DYb0xU8C.js +0 -1
  263. package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
  264. package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
  265. package/src/console/ui-next/dist/assets/index-Bp-R7jtM.js +0 -66
  266. package/src/console/ui-next/dist/assets/index-D4Ldtj79.css +0 -2
  267. package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
  268. package/src/console/ui-next/dist/assets/query-gate-parts-1m8m1iNp.js +0 -2
  269. package/src/console/ui-next/dist/assets/react-B1ML8gxg.js +0 -1
  270. package/src/console/ui-next/dist/assets/replica-lag-C8_BRt2x.js +0 -18
  271. package/src/console/ui-next/dist/assets/sheet-form-D-ata7jy.js +0 -1
  272. package/src/console/ui-next/dist/assets/shortcut-keys-DO4IsVqv.js +0 -1
  273. package/src/console/ui-next/dist/assets/skeleton-CL_X0GCj.js +0 -1
  274. package/src/console/ui-next/dist/assets/store-page-v3LXdYpr.js +0 -45
  275. package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
  276. package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
  277. package/src/console/ui-next/dist/assets/vault-page-Ca-MvcmJ.js +0 -2
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Postgres row-level security — identifiers and `CREATE POLICY` SQL.
2
+ * Postgres row-level security — identifiers, `CREATE POLICY` SQL,
3
+ * Drizzle emit, and Gate identity helpers (`oke.gate` / `oke.user` / `oke.has_scope`).
3
4
  */
4
5
 
5
6
  /** Policy command (`FOR`). */
@@ -31,6 +32,110 @@ export type SqlPolicyAlterSpec = {
31
32
  const POLICY_COMMANDS = new Set<string>(["ALL", "SELECT", "INSERT", "UPDATE", "DELETE"]);
32
33
  const POLICY_BEHAVIORS = new Set<string>(["PERMISSIVE", "RESTRICTIVE"]);
33
34
 
35
+ /** Drizzle / Postgres `TO` targets that must not be quoted. */
36
+ const SPECIAL_POLICY_ROLES = new Set(["public", "current_role", "current_user", "session_user"]);
37
+
38
+ /** Drivers that honor `SET LOCAL row_security` + `oke.*` GUCs. */
39
+ export const RLS_CONTEXT_DRIVERS = new Set<string>(["postgres", "pglite"]);
40
+
41
+ /**
42
+ * Non-superuser role the stamp `SET LOCAL ROLE`s into so RLS applies.
43
+ * Table owners and PGlite's `postgres` superuser otherwise bypass policies.
44
+ */
45
+ export const OKE_RLS_ROLE = "oke_app";
46
+
47
+ /** Live Gate principal stamped onto a SQL statement. */
48
+ export type RlsIdentity = {
49
+ readonly gate: string;
50
+ readonly userId: string;
51
+ readonly scopes: readonly string[];
52
+ };
53
+
54
+ /**
55
+ * One statement at a time — Bun.SQL `unsafe` / PGlite `query` reject
56
+ * multi-command batches. Safe to run repeatedly.
57
+ */
58
+ export const OKE_RLS_HELPER_STATEMENTS: readonly string[] = [
59
+ "CREATE SCHEMA IF NOT EXISTS oke",
60
+ // DB user is often `oke` — default search_path `"$user", public` then
61
+ // creates unqualified tables in schema `oke`. Domain tables belong in public.
62
+ "SET search_path TO public, oke",
63
+ `DO $oke_search_path$
64
+ BEGIN
65
+ EXECUTE 'ALTER ROLE CURRENT_USER SET search_path TO public, oke';
66
+ EXCEPTION
67
+ WHEN others THEN NULL;
68
+ END
69
+ $oke_search_path$`,
70
+ `DO $oke_rehome$
71
+ DECLARE r record;
72
+ BEGIN
73
+ FOR r IN
74
+ SELECT c.relname AS name
75
+ FROM pg_class c
76
+ JOIN pg_namespace n ON n.oid = c.relnamespace
77
+ WHERE n.nspname = 'oke' AND c.relkind = 'r'
78
+ LOOP
79
+ IF NOT EXISTS (
80
+ SELECT 1
81
+ FROM pg_class c2
82
+ JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
83
+ WHERE n2.nspname = 'public' AND c2.relname = r.name AND c2.relkind = 'r'
84
+ ) THEN
85
+ EXECUTE format('ALTER TABLE oke.%I SET SCHEMA public', r.name);
86
+ END IF;
87
+ END LOOP;
88
+ END
89
+ $oke_rehome$`,
90
+ `CREATE OR REPLACE FUNCTION oke.gate() RETURNS text
91
+ LANGUAGE sql STABLE AS $$ SELECT current_setting('oke.gate', true) $$`,
92
+ `CREATE OR REPLACE FUNCTION oke.user() RETURNS text
93
+ LANGUAGE sql STABLE AS $$ SELECT current_setting('oke.user', true) $$`,
94
+ `CREATE OR REPLACE FUNCTION oke.has_scope(p_scope text) RETURNS boolean
95
+ LANGUAGE sql STABLE AS $$
96
+ SELECT CASE
97
+ WHEN current_setting('oke.scopes', true) IN ('') THEN false
98
+ ELSE jsonb_exists(current_setting('oke.scopes', true)::jsonb, p_scope)
99
+ END
100
+ $$`,
101
+ `DO $oke_rls_role$
102
+ BEGIN
103
+ CREATE ROLE ${OKE_RLS_ROLE} NOSUPERUSER NOBYPASSRLS NOLOGIN;
104
+ EXCEPTION
105
+ WHEN duplicate_object THEN NULL;
106
+ END
107
+ $oke_rls_role$`,
108
+ `GRANT USAGE ON SCHEMA public TO ${OKE_RLS_ROLE}`,
109
+ `GRANT USAGE ON SCHEMA oke TO ${OKE_RLS_ROLE}`,
110
+ `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA oke TO ${OKE_RLS_ROLE}`,
111
+ `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${OKE_RLS_ROLE}`,
112
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${OKE_RLS_ROLE}`,
113
+ `GRANT ${OKE_RLS_ROLE} TO CURRENT_USER`,
114
+ `DO $oke_app_search_path$
115
+ BEGIN
116
+ EXECUTE 'ALTER ROLE ${OKE_RLS_ROLE} SET search_path TO public, oke';
117
+ EXCEPTION
118
+ WHEN others THEN NULL;
119
+ END
120
+ $oke_app_search_path$`,
121
+ ];
122
+
123
+ /**
124
+ * All {@link OKE_RLS_HELPER_STATEMENTS} as one script (PGlite `exec` / `oke db push`).
125
+ */
126
+ export const OKE_RLS_HELPER_SQL = `${OKE_RLS_HELPER_STATEMENTS.join(";\n")};`;
127
+
128
+ /**
129
+ * Install `oke.*` helpers one statement at a time.
130
+ *
131
+ * @param exec - Statement runner
132
+ */
133
+ export async function installOkeRlsHelpers(exec: (sql: string) => Promise<unknown>): Promise<void> {
134
+ for (const stmt of OKE_RLS_HELPER_STATEMENTS) {
135
+ await exec(stmt);
136
+ }
137
+ }
138
+
34
139
  /**
35
140
  * Quote a Postgres identifier (`"`), stripping embedded quotes.
36
141
  *
@@ -102,11 +207,38 @@ export function parseSqlPolicyRoles(raw: string): readonly string[] | null {
102
207
  .filter((part) => part.length > 0);
103
208
  if (parts.length === 0) return ["public"];
104
209
  for (const part of parts) {
105
- if (part !== "public" && !isPgIdent(part)) return null;
210
+ if (!isPolicyRoleName(part)) return null;
106
211
  }
107
212
  return parts;
108
213
  }
109
214
 
215
+ /**
216
+ * True when `name` is `public`, a Drizzle special role, or a simple identifier.
217
+ *
218
+ * @param name - Role token
219
+ */
220
+ export function isPolicyRoleName(name: string): boolean {
221
+ return SPECIAL_POLICY_ROLES.has(name.toLowerCase()) || isPgIdent(name);
222
+ }
223
+
224
+ /**
225
+ * Format a `TO` role — special names stay unquoted.
226
+ *
227
+ * @param role - Role token
228
+ */
229
+ export function formatPolicyRole(role: string): string {
230
+ return SPECIAL_POLICY_ROLES.has(role.toLowerCase()) ? role.toLowerCase() : quotePgIdent(role);
231
+ }
232
+
233
+ /**
234
+ * JSON text stored in `oke.scopes` (sorted unique).
235
+ *
236
+ * @param scopes - Scope strings
237
+ */
238
+ export function rlsScopesJson(scopes: readonly string[]): string {
239
+ return JSON.stringify([...new Set(scopes)].sort());
240
+ }
241
+
110
242
  /**
111
243
  * `CREATE POLICY` from a validated spec.
112
244
  *
@@ -116,7 +248,7 @@ export function buildCreatePolicySql(spec: SqlPolicySpec): string {
116
248
  const roles =
117
249
  spec.roles.length === 0
118
250
  ? "public"
119
- : spec.roles.map((role) => (role === "public" ? "public" : quotePgIdent(role))).join(", ");
251
+ : spec.roles.map((role) => formatPolicyRole(role)).join(", ");
120
252
  let text = `CREATE POLICY ${quotePgIdent(spec.name)} ON ${quotePgIdent(spec.table)} AS ${spec.behavior} FOR ${spec.command} TO ${roles}`;
121
253
  if (spec.using !== undefined && spec.using.trim() !== "") {
122
254
  text += ` USING (${spec.using.trim()})`;
@@ -153,7 +285,7 @@ export function buildAlterPolicySql(spec: SqlPolicyAlterSpec): string {
153
285
  const roles =
154
286
  spec.roles.length === 0
155
287
  ? "public"
156
- : spec.roles.map((role) => (role === "public" ? "public" : quotePgIdent(role))).join(", ");
288
+ : spec.roles.map((role) => formatPolicyRole(role)).join(", ");
157
289
  parts.push(`TO ${roles}`);
158
290
  }
159
291
  if (spec.using !== undefined && spec.using.trim() !== "") {
@@ -189,9 +321,9 @@ export function parseSqlPolicySpec(raw: Readonly<Record<string, unknown>>): SqlP
189
321
  const table = typeof raw.table === "string" ? raw.table.trim() : "";
190
322
  if (!isPgPolicyName(name)) throw new Error(`invalid policy name "${name}"`);
191
323
  if (!isPgIdent(table)) throw new Error(`invalid table name "${table}"`);
192
- const command = parseSqlPolicyCommand(raw.command) ?? "SELECT";
193
- const behavior = parseSqlPolicyBehavior(raw.behavior ?? raw.permissive) ?? "PERMISSIVE";
194
- const rolesRaw = typeof raw.roles === "string" ? raw.roles : "public";
324
+ const command = parseSqlPolicyCommand(raw.command) ?? parseSqlPolicyCommand(raw.for) ?? "SELECT";
325
+ const behavior = parseSqlPolicyBehavior(raw.behavior ?? raw.permissive ?? raw.as) ?? "PERMISSIVE";
326
+ const rolesRaw = rolesField(raw.roles ?? raw.to);
195
327
  const roles = parseSqlPolicyRoles(rolesRaw);
196
328
  if (!roles) throw new Error("invalid policy roles");
197
329
  const using = typeof raw.using === "string" ? raw.using.trim() : "";
@@ -232,3 +364,97 @@ export function parseSqlPolicyRowId(id: string): { readonly table: string; reado
232
364
  }
233
365
  return { table: id.slice(0, cut), name: id.slice(cut + 1) };
234
366
  }
367
+
368
+ function rolesField(raw: unknown): string {
369
+ if (typeof raw === "string") return raw;
370
+ if (Array.isArray(raw)) {
371
+ return raw.filter((part): part is string => typeof part === "string").join(", ");
372
+ }
373
+ return "public";
374
+ }
375
+
376
+ /**
377
+ * Drizzle `pgPolicy(...)` source for emit / Console copy-as-code.
378
+ *
379
+ * @param spec - Validated policy
380
+ */
381
+ export function emitPgPolicySource(spec: SqlPolicySpec): string {
382
+ const to =
383
+ spec.roles.length === 1
384
+ ? JSON.stringify(formatEmitRole(spec.roles[0]!))
385
+ : `[${spec.roles.map((role) => JSON.stringify(formatEmitRole(role))).join(", ")}]`;
386
+ const lines = [
387
+ `pgPolicy(${JSON.stringify(spec.name)}, {`,
388
+ ` as: ${JSON.stringify(spec.behavior.toLowerCase())},`,
389
+ ` to: ${to},`,
390
+ ` for: ${JSON.stringify(spec.command.toLowerCase())},`,
391
+ ];
392
+ if (spec.using !== undefined && spec.using.trim() !== "") {
393
+ lines.push(` using: sql\`${escapeSqlTemplate(spec.using.trim())}\`,`);
394
+ }
395
+ if (spec.withCheck !== undefined && spec.withCheck.trim() !== "") {
396
+ lines.push(` withCheck: sql\`${escapeSqlTemplate(spec.withCheck.trim())}\`,`);
397
+ }
398
+ lines.push(`})`);
399
+ return lines.join("\n");
400
+ }
401
+
402
+ /**
403
+ * `store.schema.policy(...)` source for Console copy-as-code.
404
+ *
405
+ * @param spec - Validated policy
406
+ */
407
+ export function emitStoreSchemaPolicySource(spec: SqlPolicySpec): string {
408
+ const to =
409
+ spec.roles.length === 1
410
+ ? JSON.stringify(formatEmitRole(spec.roles[0]!))
411
+ : `[${spec.roles.map((role) => JSON.stringify(formatEmitRole(role))).join(", ")}]`;
412
+ const lines = [
413
+ `store.schema.policy(${JSON.stringify(spec.name)}, {`,
414
+ ` as: ${JSON.stringify(spec.behavior.toLowerCase())},`,
415
+ ` to: ${to},`,
416
+ ` for: ${JSON.stringify(spec.command.toLowerCase())},`,
417
+ ];
418
+ if (spec.using !== undefined && spec.using.trim() !== "") {
419
+ lines.push(` using: ${JSON.stringify(spec.using.trim())},`);
420
+ }
421
+ if (spec.withCheck !== undefined && spec.withCheck.trim() !== "") {
422
+ lines.push(` withCheck: ${JSON.stringify(spec.withCheck.trim())},`);
423
+ }
424
+ lines.push(`})`);
425
+ return lines.join("\n");
426
+ }
427
+
428
+ function formatEmitRole(role: string): string {
429
+ return SPECIAL_POLICY_ROLES.has(role.toLowerCase()) ? role.toLowerCase() : role;
430
+ }
431
+
432
+ function escapeSqlTemplate(expr: string): string {
433
+ return expr.replaceAll("\\", "\\\\").replaceAll("`", "\\`");
434
+ }
435
+
436
+ /** One statement in the RLS identity prelude (never a multi-command batch). */
437
+ export type RlsPreludeStatement = {
438
+ readonly sql: string;
439
+ readonly params?: readonly string[];
440
+ };
441
+
442
+ /**
443
+ * Per-statement identity frame (no user SQL, no `BEGIN`).
444
+ *
445
+ * The caller pins these to one backend connection via `SqlConnection.transaction`.
446
+ * PGlite's `query` path is prepared statements and rejects `BEGIN; SET; SELECT`
447
+ * as one script.
448
+ *
449
+ * @param identity - Gate principal
450
+ */
451
+ export function buildRlsIdentityPreludeSql(identity: RlsIdentity): readonly RlsPreludeStatement[] {
452
+ return [
453
+ { sql: `SET LOCAL ROLE ${OKE_RLS_ROLE}` },
454
+ { sql: "SET LOCAL row_security = on" },
455
+ {
456
+ sql: `SELECT set_config('oke.gate', ?, true), set_config('oke.user', ?, true), set_config('oke.scopes', ?, true)`,
457
+ params: [identity.gate, identity.userId, rlsScopesJson(identity.scopes)],
458
+ },
459
+ ];
460
+ }
@@ -77,21 +77,44 @@ export async function connectPglite(options: SqlConnectOptions = {}): Promise<Sq
77
77
  resolved === "memory"
78
78
  ? await PGlite.create({ extensions: { vector } })
79
79
  : await PGlite.create(resolved, { extensions: { vector } });
80
- return {
80
+ const connection: SqlConnection = {
81
81
  driverId: "pglite",
82
82
  role,
83
83
  async query(sql, params = []) {
84
- const rs = await db.query<SqlRow>(toPostgresParams(sql), [...params]);
84
+ const rs = await db.query<SqlRow>(toPostgresParams(sql, params), [...params]);
85
85
  return rs.rows;
86
86
  },
87
87
  async exec(sql, params = []) {
88
- const rs = await db.query<SqlRow>(toPostgresParams(sql), [...params]);
88
+ // Prepared `query` rejects multi-command scripts (`BEGIN; SET; …`).
89
+ // Param-less SQL uses simple-protocol `exec` (helpers, BEGIN, SET).
90
+ if (params.length === 0) {
91
+ const results = await db.exec(sql);
92
+ const last = results[results.length - 1];
93
+ return { changes: last?.affectedRows ?? 0 };
94
+ }
95
+ const rs = await db.query<SqlRow>(toPostgresParams(sql, params), [...params]);
89
96
  return { changes: rs.affectedRows ?? 0 };
90
97
  },
98
+ async transaction(fn) {
99
+ await db.exec("BEGIN");
100
+ try {
101
+ const result = await fn(connection);
102
+ await db.exec("COMMIT");
103
+ return result;
104
+ } catch (err) {
105
+ try {
106
+ await db.exec("ROLLBACK");
107
+ } catch {
108
+ // Already aborted.
109
+ }
110
+ throw err;
111
+ }
112
+ },
91
113
  async close() {
92
114
  await db.close();
93
115
  },
94
116
  };
117
+ return connection;
95
118
  }
96
119
 
97
120
  /** Protocol-named pglite driver. */
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Postgres placeholder conversion.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { toPostgresParams } from "./postgres.ts";
7
+
8
+ describe("toPostgresParams", () => {
9
+ test("rewrites ? only when values are bound", () => {
10
+ expect(toPostgresParams("SELECT * FROM t WHERE id = ?", ["a"])).toBe(
11
+ "SELECT * FROM t WHERE id = $1",
12
+ );
13
+ expect(toPostgresParams("INSERT INTO t (a, b) VALUES (?, ?)", [1, 2])).toBe(
14
+ "INSERT INTO t (a, b) VALUES ($1, $2)",
15
+ );
16
+ });
17
+
18
+ test("leaves jsonb ? operators unchanged when there are no values", () => {
19
+ const sql = "SELECT current_setting('oke.scopes', true)::jsonb ? p_scope";
20
+ expect(toPostgresParams(sql)).toBe(sql);
21
+ expect(toPostgresParams(sql, [])).toBe(sql);
22
+ });
23
+ });
@@ -4,7 +4,13 @@
4
4
  * Protocol-named: Neon, Supabase, RDS, Timescale all speak postgres.
5
5
  */
6
6
 
7
- import type { SqlConnectOptions, SqlConnection, SqlDriver, SqlRow } from "./types.ts";
7
+ import type { SqlConnectOptions, SqlConnection, SqlDriver, SqlRole, SqlRow } from "./types.ts";
8
+
9
+ /** Bun.SQL client checked out via {@link PostgresClientLike.reserve}. */
10
+ export interface PostgresReservedClient extends PostgresClientLike {
11
+ /** Return the connection to the pool. */
12
+ release(): void;
13
+ }
8
14
 
9
15
  /** Minimal surface we use from Bun.SQL (and test fakes). */
10
16
  export interface PostgresClientLike {
@@ -12,15 +18,98 @@ export interface PostgresClientLike {
12
18
  sql: string,
13
19
  values?: unknown[],
14
20
  ): PromiseLike<SqlRow[] | { length: number; changes?: number } | SqlRow[]>;
21
+ /**
22
+ * Pin one pooled TCP connection. Required in front of PgDog — `begin()`
23
+ * then `unsafe()` on the parent client can checkout a second slot and
24
+ * deadlock the pool (`checkout timeout`).
25
+ */
26
+ reserve?(): Promise<PostgresReservedClient>;
27
+ /**
28
+ * Reserve one pooled connection for a transaction (Bun.SQL).
29
+ *
30
+ * @param fn - Work on the reserved client
31
+ */
32
+ begin?<T>(fn: (tx: PostgresClientLike) => Promise<T> | T): Promise<T>;
15
33
  close?(options?: { timeout?: number }): Promise<void>;
16
34
  }
17
35
 
36
+ /** Cap shared Bun.SQL pools so one process cannot outrun PgDog (default 10). */
37
+ export const POSTGRES_POOL_MAX = 8;
38
+
39
+ const sharedClients = new Map<string, PostgresClientLike>();
40
+
41
+ /**
42
+ * Resolve the Postgres URL (injected, `DATABASE_URL`, then localhost).
43
+ *
44
+ * @param url - Explicit URL
45
+ */
46
+ export function resolvePostgresUrl(url?: string): string {
47
+ return url ?? process.env.DATABASE_URL ?? "postgres://localhost:5432/oke";
48
+ }
49
+
50
+ /**
51
+ * One Bun.SQL pool per URL for store / journal / clock / vault / console.
52
+ *
53
+ * @param url - Connection URL
54
+ */
55
+ export function sharedPostgresClient(url?: string): PostgresClientLike {
56
+ const key = resolvePostgresUrl(url);
57
+ const existing = sharedClients.get(key);
58
+ if (existing) return existing;
59
+ const created = new Bun.SQL(key, { max: POSTGRES_POOL_MAX }) as unknown as PostgresClientLike;
60
+ sharedClients.set(key, created);
61
+ return created;
62
+ }
63
+
64
+ /**
65
+ * Pin one pooled connection, `BEGIN`, run `fn`, `COMMIT`/`ROLLBACK`, release.
66
+ *
67
+ * Prefer `reserve()` — Bun.SQL `begin()` then `unsafe()` on the parent
68
+ * client can checkout a second slot and deadlock PgDog.
69
+ *
70
+ * @param client - Pool or test fake
71
+ * @param fn - Work on the pinned client
72
+ */
73
+ export async function withPinnedPostgres<T>(
74
+ client: PostgresClientLike,
75
+ fn: (tx: PostgresClientLike) => Promise<T>,
76
+ ): Promise<T> {
77
+ if (typeof client.reserve === "function") {
78
+ const reserved = await client.reserve();
79
+ try {
80
+ await reserved.unsafe("BEGIN");
81
+ try {
82
+ const result = await fn(reserved);
83
+ await reserved.unsafe("COMMIT");
84
+ return result;
85
+ } catch (err) {
86
+ try {
87
+ await reserved.unsafe("ROLLBACK");
88
+ } catch {
89
+ // Already aborted.
90
+ }
91
+ throw err;
92
+ }
93
+ } finally {
94
+ reserved.release();
95
+ }
96
+ }
97
+ if (typeof client.begin === "function") {
98
+ return client.begin(fn);
99
+ }
100
+ throw new Error("postgres client needs reserve() or begin() to pin a transaction");
101
+ }
102
+
18
103
  /**
19
104
  * Convert `?` placeholders to Postgres `$1…$n`.
105
+ * Leaves SQL unchanged when there are no bound values so operators
106
+ * like `jsonb ? key` are not rewritten.
20
107
  *
21
108
  * @param sql - SQL with `?` placeholders
109
+ * @param values - Bound values (empty skips conversion)
22
110
  */
23
- export function toPostgresParams(sql: string): string {
111
+ export function toPostgresParams(sql: string, values: readonly unknown[] = []): string {
112
+ if (values.length === 0) return sql;
24
113
  let i = 0;
25
114
  return sql.replace(/\?/g, () => `$${++i}`);
26
115
  }
@@ -32,21 +121,34 @@ export function toPostgresParams(sql: string): string {
32
121
  */
33
122
  export async function connectPostgres(options: SqlConnectOptions = {}): Promise<SqlConnection> {
34
123
  const role = options.role ?? "primary";
35
- const client =
36
- (options.client as PostgresClientLike | undefined) ??
37
- new Bun.SQL(options.url ?? process.env.DATABASE_URL ?? "postgres://localhost:5432/oke");
124
+ const injected = options.client as PostgresClientLike | undefined;
125
+ const client = injected ?? sharedPostgresClient(options.url);
126
+ return wrapPostgresClient(client, role, { shared: injected === undefined });
127
+ }
38
128
 
39
- return {
129
+ /**
130
+ * Bind a Bun.SQL-compatible client as {@link SqlConnection}.
131
+ *
132
+ * @param client - Pool or reserved transaction client
133
+ * @param role - Primary vs replica
134
+ * @param opts - Shared-pool / already-pinned flags
135
+ */
136
+ function wrapPostgresClient(
137
+ client: PostgresClientLike,
138
+ role: SqlRole,
139
+ opts: { readonly shared?: boolean; readonly pinned?: boolean } = {},
140
+ ): SqlConnection {
141
+ const connection: SqlConnection = {
40
142
  driverId: "postgres",
41
143
  role,
42
144
  async query(sql, params = []) {
43
- const pg = toPostgresParams(sql);
145
+ const pg = toPostgresParams(sql, params);
44
146
  const result = await client.unsafe(pg, [...params]);
45
147
  if (Array.isArray(result)) return result as SqlRow[];
46
148
  return Array.from(result as ArrayLike<SqlRow>);
47
149
  },
48
150
  async exec(sql, params = []) {
49
- const pg = toPostgresParams(sql);
151
+ const pg = toPostgresParams(sql, params);
50
152
  const result = await client.unsafe(pg, [...params]);
51
153
  if (
52
154
  result &&
@@ -61,10 +163,15 @@ export async function connectPostgres(options: SqlConnectOptions = {}): Promise<
61
163
  }
62
164
  return { changes: 0 };
63
165
  },
166
+ async transaction(fn) {
167
+ if (opts.pinned) return fn(connection);
168
+ return withPinnedPostgres(client, (tx) => fn(wrapPostgresClient(tx, role, { pinned: true })));
169
+ },
64
170
  async close() {
65
- await client.close?.();
171
+ if (!opts.shared) await client.close?.();
66
172
  },
67
173
  };
174
+ return connection;
68
175
  }
69
176
 
70
177
  /**
@@ -82,10 +189,27 @@ export function createPostgresFakeClient(): PostgresClientLike & {
82
189
 
83
190
  return {
84
191
  tables,
192
+ async begin(fn) {
193
+ return fn(this);
194
+ },
195
+ async reserve() {
196
+ return Object.assign(this, {
197
+ release() {
198
+ /* fake has no pool */
199
+ },
200
+ });
201
+ },
85
202
  async unsafe(sql, values = []) {
86
203
  const text = sql.trim();
87
204
  // Accept both ? (pre-conversion) and $n forms.
88
205
  const normalised = text.replace(/\$\d+/g, "?");
206
+ if (
207
+ /^(begin|commit|rollback|set\b|select set_config|create\b|grant\b|do\b|alter\b)\b/i.test(
208
+ normalised,
209
+ )
210
+ ) {
211
+ return [];
212
+ }
89
213
 
90
214
  const create =
91
215
  /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\((.+)\)\s*$/i.exec(
@@ -140,11 +140,19 @@ function ttlToSeconds(ttl: string): number {
140
140
  }
141
141
  }
142
142
 
143
+ /** Bun 1.4 typed methods — `@types/bun` may lag the runtime. */
144
+ interface BunRedisEval {
145
+ eval(script: string, numkeys: number, ...keysAndArgs: string[]): Promise<unknown>;
146
+ }
147
+
148
+ function typedRedis(redis: Bun.RedisClient): Bun.RedisClient & BunRedisEval {
149
+ return redis as Bun.RedisClient & BunRedisEval;
150
+ }
151
+
143
152
  /**
144
153
  * Bind {@link Bun.RedisClient} / {@link Bun.redis} to {@link KvClientLike}.
145
154
  *
146
- * Uses typed `scan` (Bun ≥1.3). `EVAL` still goes through `send` — Bun 1.3.14
147
- * has no typed `eval` yet (platform limitation, not an OKE stub).
155
+ * Uses typed `scan` / `eval` (Bun ≥1.4). `send` remains the escape hatch.
148
156
  *
149
157
  * @param url - Optional Redis URL
150
158
  */
@@ -174,8 +182,7 @@ export function createBunRedisClient(url?: string): KvClientLike {
174
182
  return redis.scan(cursor);
175
183
  },
176
184
  async eval(script, numkeys, ...keysAndArgs) {
177
- // Bun 1.3.14: no typed eval raw send remains the native path.
178
- return redis.send("EVAL", [script, String(numkeys), ...keysAndArgs]);
185
+ return typedRedis(redis).eval(script, numkeys, ...keysAndArgs);
179
186
  },
180
187
  send: (command, args) => redis.send(command, args),
181
188
  };
@@ -7,7 +7,7 @@
7
7
  * exactly-once / fan-out physics stay correct under `drain()`.
8
8
  *
9
9
  * Production bind: {@link createBunSignalRedisClient} (typed `publish` /
10
- * `subscribe`; Streams via `send` Bun 1.3.14 has no typed `xadd` yet).
10
+ * `subscribe` / `xadd` / `xreadgroup` / `xack` / `xgroup` on Bun ≥1.4).
11
11
  */
12
12
 
13
13
  import { createSignalEngine } from "./signal-engine.ts";
@@ -18,12 +18,23 @@ import type {
18
18
  SignalRedisClientLike,
19
19
  } from "./signal-types.ts";
20
20
 
21
+ /** Bun 1.4 typed stream methods — `@types/bun` may lag the runtime. */
22
+ interface BunRedisStreams {
23
+ xadd(key: string, ...args: (string | number)[]): Promise<unknown>;
24
+ xreadgroup(...args: (string | number)[]): Promise<unknown>;
25
+ xack(key: string, group: string, id: string): Promise<unknown>;
26
+ xgroup(subcommand: string, ...args: (string | number)[]): Promise<unknown>;
27
+ }
28
+
29
+ function typedStreams(redis: Bun.RedisClient): Bun.RedisClient & BunRedisStreams {
30
+ return redis as Bun.RedisClient & BunRedisStreams;
31
+ }
32
+
21
33
  /**
22
34
  * Bind {@link Bun.RedisClient} to {@link SignalRedisClientLike}.
23
35
  *
24
- * Streams (`XADD` / `XGROUP` / `XREADGROUP` / `XACK`) use `send` because
25
- * Bun 1.3.14 still lacks typed stream helpers — that is a Bun limitation.
26
- * Pub/sub uses typed `publish` / `subscribe` (gap closed vs earlier fake-only).
36
+ * Streams use typed `xadd` / `xgroup` / `xreadgroup` / `xack` (Bun ≥1.4).
37
+ * Pub/sub uses typed `publish` / `subscribe`.
27
38
  *
28
39
  * @param url - Optional Redis URL
29
40
  */
@@ -41,37 +52,36 @@ export function createBunSignalRedisClient(url?: string): SignalRedisClientLike
41
52
 
42
53
  return {
43
54
  async xadd(key, id, fields) {
44
- const args: string[] = [key, id];
55
+ const pairs: string[] = [];
45
56
  for (const [k, v] of Object.entries(fields)) {
46
- args.push(k, v);
57
+ pairs.push(k, v);
47
58
  }
48
- return String(await redis.send("XADD", args));
59
+ return String(await typedStreams(redis).xadd(key, id, ...pairs));
49
60
  },
50
61
  async xgroupCreate(key, group, id, opts) {
51
- const args = ["CREATE", key, group, id];
52
- if (opts?.mkstream) args.push("MKSTREAM");
62
+ const extra = opts?.mkstream ? (["MKSTREAM"] as const) : [];
53
63
  try {
54
- await redis.send("XGROUP", args);
64
+ await typedStreams(redis).xgroup("CREATE", key, group, id, ...extra);
55
65
  } catch (err) {
56
66
  const msg = err instanceof Error ? err.message : String(err);
57
67
  if (!/BUSYGROUP/i.test(msg)) throw err;
58
68
  }
59
69
  },
60
70
  async xreadgroup(group, consumer, key, count) {
61
- const reply = await redis.send("XREADGROUP", [
71
+ const reply = await typedStreams(redis).xreadgroup(
62
72
  "GROUP",
63
73
  group,
64
74
  consumer,
65
75
  "COUNT",
66
- String(count),
76
+ count,
67
77
  "STREAMS",
68
78
  key,
69
79
  ">",
70
- ]);
80
+ );
71
81
  return parseXreadgroupReply(reply);
72
82
  },
73
83
  async xack(key, group, id) {
74
- return Number(await redis.send("XACK", [key, group, id]));
84
+ return Number(await typedStreams(redis).xack(key, group, id));
75
85
  },
76
86
  async publish(channel, message) {
77
87
  return redis.publish(channel, message);
@@ -69,8 +69,9 @@ export interface SignalMessage {
69
69
  }
70
70
 
71
71
  /** Dead-letter entry with full attempt history. */
72
- export interface DeadLetter extends SignalMessage {
72
+ export interface DeadLetter<T = unknown> extends Omit<SignalMessage, "payload" | "status"> {
73
73
  readonly status: "dead";
74
+ readonly payload: T;
74
75
  }
75
76
 
76
77
  /** Per-subscriber lag / errors (broadcast physics). */