okengine 0.14.1 → 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 (205) hide show
  1. package/package.json +3 -4
  2. package/site/content/docs/elements/ai.mdx +2 -1
  3. package/site/content/docs/elements/channel.mdx +1 -0
  4. package/site/content/docs/elements/clock.mdx +3 -0
  5. package/site/content/docs/elements/flow.mdx +3 -0
  6. package/site/content/docs/elements/gate.mdx +25 -22
  7. package/site/content/docs/elements/signal.mdx +4 -4
  8. package/site/content/docs/elements/store.mdx +86 -27
  9. package/site/content/docs/elements/vault.mdx +1 -0
  10. package/site/content/docs/get-started/basic-usage.mdx +22 -13
  11. package/site/content/docs/get-started/index.mdx +5 -0
  12. package/site/content/docs/get-started/introduction.mdx +11 -3
  13. package/site/content/docs/get-started/meta.json +1 -1
  14. package/site/content/docs/get-started/project-structure.mdx +932 -0
  15. package/site/content/docs/reference/cli.md +13 -10
  16. package/site/content/docs/reference/client.mdx +25 -21
  17. package/site/content/docs/reference/security.md +1 -1
  18. package/src/auth/bindings.ts +14 -5
  19. package/src/auth/cross-plane.ts +10 -3
  20. package/src/cli/ai-setup/ai-setup.test.ts +62 -0
  21. package/src/cli/ai-setup/apply.ts +32 -1
  22. package/src/cli/attach-host-console.test.ts +3 -2
  23. package/src/cli/attach-host-console.ts +3 -0
  24. package/src/cli/build.test.ts +4 -2
  25. package/src/cli/db-seed.ts +48 -15
  26. package/src/cli/db.test.ts +104 -0
  27. package/src/cli/db.ts +122 -10
  28. package/src/cli/dev-controls.test.ts +20 -0
  29. package/src/cli/dev-controls.ts +13 -0
  30. package/src/cli/dev.test.ts +15 -4
  31. package/src/cli/dev.ts +102 -29
  32. package/src/cli/ensure-drizzle-config.ts +2 -1
  33. package/src/cli/tui/DevLive.tsx +12 -2
  34. package/src/cli/tui/keys.test.ts +2 -1
  35. package/src/cli/tui/keys.ts +3 -2
  36. package/src/compiler/aot.test.ts +1 -1
  37. package/src/compiler/extract.test.ts +409 -33
  38. package/src/compiler/extract.ts +318 -67
  39. package/src/compiler/flow-path.test.ts +96 -0
  40. package/src/compiler/flow-path.ts +173 -0
  41. package/src/compiler/generate-adopt.test.ts +112 -3
  42. package/src/compiler/generate-adopt.ts +280 -26
  43. package/src/compiler/schema-from-ast.ts +609 -0
  44. package/src/console/server/bind.ts +2 -2
  45. package/src/console/server/console.test.ts +3 -0
  46. package/src/console/server/flows.ts +120 -40
  47. package/src/console/server/invoke-user-flow.test.ts +3 -1
  48. package/src/console/server/invoke-user-flow.ts +17 -2
  49. package/src/console/server/iso-at.test.ts +9 -0
  50. package/src/console/server/iso-at.ts +11 -0
  51. package/src/console/server/security-headers.ts +6 -3
  52. package/src/console/server/spa-proxy.test.ts +1 -0
  53. package/src/console/server/sql-catalog.ts +147 -7
  54. package/src/console/server/sql-rls.test.ts +16 -0
  55. package/src/console/server/state.ts +8 -0
  56. package/src/console/server/store.test.ts +7 -4
  57. package/src/console/server/store.ts +142 -58
  58. package/src/console/ui-next/dist/assets/agent-disclosure-C0X1fbWF.js +1 -0
  59. package/src/console/ui-next/dist/assets/{cache-glyph-BanhLsEY.js → cache-glyph-F1FI122b.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{call-pii-button-bqkxMrJH.js → call-pii-button-DEDCl_j3.js} +1 -1
  61. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +1 -0
  62. package/src/console/ui-next/dist/assets/{confirm-sheet-DDCRmG62.js → confirm-sheet-3ptDzXbz.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{duration-tone-oiRxPVsZ.js → duration-tone-sC3lGABz.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{explorer-empty-HjTnVQoR.js → explorer-empty-BvYrygyO.js} +1 -1
  65. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +1 -0
  66. package/src/console/ui-next/dist/assets/{highlighted-json-CvDPvveV.js → highlighted-json-CS_O8L-r.js} +1 -1
  67. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +1 -0
  68. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +66 -0
  69. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +2 -0
  70. package/src/console/ui-next/dist/assets/{link-DF7SZ9Ek.js → link-COeyggt-.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{observability-page-BEZDzyYh.js → observability-page-HK9-BO8z.js} +2 -2
  72. package/src/console/ui-next/dist/assets/react-D8E3mtu1.js +1 -0
  73. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +18 -0
  74. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +1 -0
  75. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +1 -0
  76. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +1 -0
  77. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +41 -0
  78. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +2 -0
  79. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +54 -0
  80. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +1 -0
  81. package/src/console/ui-next/dist/assets/{use-vault-list-Cl79j_ku.js → use-vault-list-BLbZhzyF.js} +1 -1
  82. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +2 -0
  83. package/src/console/ui-next/dist/index.html +6 -6
  84. package/src/console/ui-next/src/client.ts +35 -7
  85. package/src/console/ui-next/src/components/motion/table/index.tsx +16 -0
  86. package/src/console/ui-next/src/components/motion/table/types.ts +4 -0
  87. package/src/console/ui-next/src/components/ui/kbd.tsx +2 -2
  88. package/src/console/ui-next/src/features/flows/traces/http-method.ts +19 -0
  89. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +19 -1
  90. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +10 -3
  91. package/src/console/ui-next/src/features/store/detail/browse-section.tsx +45 -1
  92. package/src/console/ui-next/src/features/store/detail/resource-panel.tsx +78 -16
  93. package/src/console/ui-next/src/features/store/detail/store-row-detail-sheet.tsx +155 -14
  94. package/src/console/ui-next/src/features/store/explorer/store-tree.tsx +33 -133
  95. package/src/console/ui-next/src/features/store/files/file-preview.tsx +4 -1
  96. package/src/console/ui-next/src/features/store/grid/rls-policy-sheet.tsx +326 -165
  97. package/src/console/ui-next/src/features/store/grid/sql-insert-sheet.tsx +6 -0
  98. package/src/console/ui-next/src/features/store/grid/store-data-grid.tsx +50 -3
  99. package/src/console/ui-next/src/features/store/lib/files-meta.test.ts +3 -0
  100. package/src/console/ui-next/src/features/store/lib/files-meta.ts +12 -0
  101. package/src/console/ui-next/src/features/store/lib/query-defaults.test.ts +25 -0
  102. package/src/console/ui-next/src/features/store/lib/query-defaults.ts +85 -3
  103. package/src/console/ui-next/src/features/store/lib/query-gate.ts +30 -4
  104. package/src/console/ui-next/src/features/store/lib/rls-policy.test.ts +139 -0
  105. package/src/console/ui-next/src/features/store/lib/rls-policy.ts +223 -21
  106. package/src/console/ui-next/src/features/store/lib/schema-graph.ts +4 -0
  107. package/src/console/ui-next/src/features/store/lib/sql-catalog.test.ts +24 -0
  108. package/src/console/ui-next/src/features/store/lib/sql-catalog.ts +19 -0
  109. package/src/console/ui-next/src/features/store/lib/store-tree.test.ts +6 -0
  110. package/src/console/ui-next/src/features/store/lib/store-tree.ts +13 -0
  111. package/src/console/ui-next/src/features/store/query/query-console.tsx +48 -14
  112. package/src/console/ui-next/src/features/store/query/query-gate-menu.tsx +19 -20
  113. package/src/console/ui-next/src/features/store/query/query-gate-parts.tsx +2 -1
  114. package/src/console/ui-next/src/features/store/query/query-results.tsx +110 -83
  115. package/src/console/ui-next/src/features/store/schema/schema-table-node.tsx +17 -0
  116. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +112 -72
  117. package/src/console/ui-next/src/features/units/lib/contract-input.test.ts +206 -1
  118. package/src/console/ui-next/src/features/units/lib/contract-input.ts +55 -0
  119. package/src/console/ui-next/src/features/vault/lib/types.ts +1 -1
  120. package/src/docker/compose-health.test.ts +27 -0
  121. package/src/docker/compose-health.ts +19 -3
  122. package/src/docker/docker.test.ts +3 -0
  123. package/src/docker/recipes/pgdog.ts +2 -0
  124. package/src/drivers/clock-postgres.ts +13 -9
  125. package/src/drivers/instances-postgres.ts +4 -6
  126. package/src/drivers/journal-postgres.ts +13 -9
  127. package/src/drivers/pg-rls.test.ts +87 -0
  128. package/src/drivers/pg-rls.ts +233 -7
  129. package/src/drivers/pglite.ts +26 -3
  130. package/src/drivers/postgres.test.ts +23 -0
  131. package/src/drivers/postgres.ts +133 -9
  132. package/src/drivers/types.ts +10 -0
  133. package/src/elements/clock/declare.ts +26 -1
  134. package/src/elements/gate/boot.ts +5 -5
  135. package/src/elements/gate/declare.ts +36 -4
  136. package/src/elements/gate/flatten.ts +1 -1
  137. package/src/elements/gate/runtime.ts +1 -1
  138. package/src/elements/store/declare.ts +9 -6
  139. package/src/elements/store/emit-drizzle.ts +44 -8
  140. package/src/elements/store/resource.test.ts +1 -1
  141. package/src/elements/store/rls-identity.test.ts +95 -0
  142. package/src/elements/store/rls-identity.ts +116 -0
  143. package/src/elements/store/runtime.ts +4 -0
  144. package/src/elements/store/schema-decl.test.ts +37 -0
  145. package/src/elements/store/schema-decl.ts +161 -0
  146. package/src/elements/store/seed.ts +1 -1
  147. package/src/elements/store/sql-condition.test.ts +12 -0
  148. package/src/elements/store/sql-condition.ts +26 -2
  149. package/src/elements/store/sql-rls-isolation.test.ts +165 -0
  150. package/src/elements/store/sql-rls-stamp.test.ts +117 -0
  151. package/src/elements/store/sql-session.ts +81 -3
  152. package/src/elements/store/upsert-app.test.ts +1 -1
  153. package/src/elements/store.ts +14 -0
  154. package/src/http.ts +20 -3
  155. package/src/index.ts +10 -1
  156. package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
  157. package/src/kernel/adopt-routes.ts +30 -2
  158. package/src/kernel/app.ts +131 -14
  159. package/src/kernel/auto-cache.test.ts +8 -8
  160. package/src/kernel/auto-registry.test.ts +49 -9
  161. package/src/kernel/boot.test.ts +4 -4
  162. package/src/kernel/boot.ts +2 -2
  163. package/src/kernel/effects-stamping.test.ts +3 -3
  164. package/src/kernel/element-registries.ts +12 -5
  165. package/src/kernel/errors.ts +24 -0
  166. package/src/kernel/flow-units.ts +64 -0
  167. package/src/kernel/flow.ts +29 -17
  168. package/src/kernel/fx.ts +23 -0
  169. package/src/kernel/horizontal-child.ts +4 -4
  170. package/src/kernel/http-path-pending.ts +20 -0
  171. package/src/kernel/http-resource.ts +4 -0
  172. package/src/kernel/http-stream.test.ts +4 -4
  173. package/src/kernel/pipeline.test.ts +2 -2
  174. package/src/kernel/plugin-elements.test.ts +1 -1
  175. package/src/kernel/plugin-needs.test.ts +1 -1
  176. package/src/kernel/ready.test.ts +2 -2
  177. package/src/kernel/resource-mount.test.ts +14 -0
  178. package/src/kernel/run-duration.test.ts +2 -2
  179. package/src/kernel/stamp-http.test.ts +79 -0
  180. package/src/kernel/stamp-http.ts +75 -0
  181. package/src/kernel/triggers.ts +52 -27
  182. package/src/manifest/types.ts +13 -0
  183. package/src/release/measure.ts +2 -2
  184. package/src/runtime/json-code-block.test.ts +2 -2
  185. package/src/runtime/serve.test.ts +4 -4
  186. package/src/term.test.ts +52 -4
  187. package/src/term.ts +125 -17
  188. package/src/test/reset-element-registries.ts +8 -3
  189. package/src/cli/ask-seed.test.ts +0 -96
  190. package/src/cli/ask-seed.ts +0 -82
  191. package/src/console/ui-next/dist/assets/collapsible-DYb0xU8C.js +0 -1
  192. package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +0 -1
  193. package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +0 -1
  194. package/src/console/ui-next/dist/assets/index-Ce6WKWKM.js +0 -66
  195. package/src/console/ui-next/dist/assets/index-D4Ldtj79.css +0 -2
  196. package/src/console/ui-next/dist/assets/query-gate-parts-1m8m1iNp.js +0 -2
  197. package/src/console/ui-next/dist/assets/react-B1ML8gxg.js +0 -1
  198. package/src/console/ui-next/dist/assets/replica-lag-C8_BRt2x.js +0 -18
  199. package/src/console/ui-next/dist/assets/sheet-form-D-ata7jy.js +0 -1
  200. package/src/console/ui-next/dist/assets/shortcut-keys-DO4IsVqv.js +0 -1
  201. package/src/console/ui-next/dist/assets/skeleton-CL_X0GCj.js +0 -1
  202. package/src/console/ui-next/dist/assets/store-page-v3LXdYpr.js +0 -45
  203. package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +0 -2
  204. package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +0 -1
  205. package/src/console/ui-next/dist/assets/vault-page-DuKzqwzW.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(
@@ -62,6 +62,16 @@ export interface SqlConnection {
62
62
  * @param params - Bound parameters
63
63
  */
64
64
  exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
65
+ /**
66
+ * Pin `fn` to one backend connection for a single transaction.
67
+ *
68
+ * Required for `SET LOCAL` RLS stamps on pooled `postgres` (Bun.SQL /
69
+ * PgDog transaction mode). A standalone `BEGIN` via {@link exec} returns
70
+ * the connection to the pool still in a transaction and exhausts checkout.
71
+ *
72
+ * @param fn - Work that must share the reserved connection
73
+ */
74
+ transaction?<T>(fn: (tx: SqlConnection) => Promise<T>): Promise<T>;
65
75
  /** Close the connection. */
66
76
  close(): Promise<void>;
67
77
  }
@@ -5,6 +5,8 @@
5
5
  * the effective state from the Store (console §5), never the code directly.
6
6
  */
7
7
 
8
+ import { clockRegistry } from "../../kernel/element-registries.ts";
9
+
8
10
  /** Options for {@link clock}. */
9
11
  export interface ClockOptions {
10
12
  /** Cron expression (`m h dom mon dow`). */
@@ -40,6 +42,27 @@ export interface ClockDecl {
40
42
  readonly description?: string;
41
43
  }
42
44
 
45
+ /**
46
+ * `clock()` pushes into the shared {@link clockRegistry}
47
+ * (`src/kernel/element-registries.ts`) so {@link oke} can auto-populate
48
+ * `clocks` with zero explicit array — mirrors the {@link on} trigger-drain
49
+ * registry (`src/kernel/on.ts`).
50
+ *
51
+ * Snapshot of every clock declared since the last reset.
52
+ */
53
+ export function listClocks(): readonly ClockDecl[] {
54
+ return clockRegistry.slice();
55
+ }
56
+
57
+ /**
58
+ * Clear the clock registry (tests / fresh app adopt).
59
+ *
60
+ * @internal
61
+ */
62
+ export function resetClocks(): void {
63
+ clockRegistry.length = 0;
64
+ }
65
+
43
66
  /**
44
67
  * Declare a named clock / cron schedule.
45
68
  *
@@ -50,7 +73,7 @@ export function clock(name: string, options: ClockOptions = {}): ClockDecl {
50
73
  if (!options.cron && !options.every) {
51
74
  throw new TypeError(`clock("${name}"): require cron or every`);
52
75
  }
53
- return {
76
+ const decl: ClockDecl = {
54
77
  name,
55
78
  cron: options.cron,
56
79
  every: options.every,
@@ -58,4 +81,6 @@ export function clock(name: string, options: ClockOptions = {}): ClockDecl {
58
81
  overridable: options.overridable ?? false,
59
82
  ...(options.description !== undefined ? { description: options.description } : {}),
60
83
  };
84
+ clockRegistry.push(decl);
85
+ return decl;
61
86
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Gate boot audit — every HTTP trigger must declare auth posture.
3
3
  *
4
- * Fail loud: missing gate and missing {@link gate.public} → {@link GateBootError}.
4
+ * Fail loud: missing gate and missing `.public()` → {@link GateBootError}.
5
5
  * `unguardedHttp: "allow"` is honoured **only** when `env === "test"` — never a
6
- * production-wide bypass. Migrate real apps with per-trigger `gate.public`.
6
+ * production-wide bypass. Migrate real apps with per-trigger `.public()`.
7
7
  */
8
8
 
9
9
  import { GATE_PUBLIC_NAME } from "./declare.ts";
@@ -19,7 +19,7 @@ export interface GatePostureGap {
19
19
  }
20
20
 
21
21
  /**
22
- * Thrown when HTTP triggers omit both a gate chain and {@link gate.public}.
22
+ * Thrown when HTTP triggers omit both a gate chain and `.public()`.
23
23
  * Lists every gap (Vault-style — fail once with the full set).
24
24
  */
25
25
  export class GateBootError extends Error {
@@ -28,7 +28,7 @@ export class GateBootError extends Error {
28
28
  constructor(gaps: readonly GatePostureGap[]) {
29
29
  const lines = gaps.map((g) => ` - ${g.flowId} ${g.method} ${g.path}`);
30
30
  super(
31
- `gate boot failed — ${gaps.length} HTTP trigger(s) missing auth posture (attach a gate or gate.public):\n${lines.join("\n")}`,
31
+ `gate boot failed — ${gaps.length} HTTP trigger(s) missing auth posture (attach a gate or .public()):\n${lines.join("\n")}`,
32
32
  );
33
33
  this.name = "GateBootError";
34
34
  this.gaps = gaps;
@@ -84,7 +84,7 @@ export function hasHttpGatePosture(
84
84
  }
85
85
 
86
86
  /**
87
- * Collect HTTP triggers with neither a gate nor {@link gate.public}.
87
+ * Collect HTTP triggers with neither a gate nor `.public()`.
88
88
  *
89
89
  * @param bindings - Adopted / registered bindings
90
90
  */