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
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { splitContractInput } from "./contract-input.ts";
6
+ import { pickSeedFields, splitCallApiInput, splitContractInput } from "./contract-input.ts";
7
7
  import { fieldsFromSchema } from "./fields-from-schema.ts";
8
8
 
9
9
  const LIST_IN = fieldsFromSchema({
@@ -45,3 +45,208 @@ describe("splitContractInput", () => {
45
45
  expect(split.fields.map((f) => f.name)).toEqual(["title"]);
46
46
  });
47
47
  });
48
+
49
+ describe("splitCallApiInput", () => {
50
+ const idOnly = fieldsFromSchema({
51
+ type: "object",
52
+ required: ["id"],
53
+ properties: { id: { type: "string", minLength: 1 } },
54
+ });
55
+ const list = LIST_IN;
56
+ const nestedList = fieldsFromSchema({
57
+ type: "object",
58
+ required: ["id"],
59
+ properties: {
60
+ id: { type: "string", minLength: 1 },
61
+ q: { type: "string" },
62
+ limit: { type: "integer" },
63
+ },
64
+ });
65
+ const create = fieldsFromSchema({
66
+ type: "object",
67
+ required: ["title"],
68
+ properties: { title: { type: "string" }, spaceKey: { type: "string" } },
69
+ });
70
+ const assign = fieldsFromSchema({
71
+ type: "object",
72
+ required: ["id", "assigneeEmail"],
73
+ properties: { id: { type: "string" }, assigneeEmail: { type: "string" } },
74
+ });
75
+ const patch = fieldsFromSchema({
76
+ type: "object",
77
+ required: ["id"],
78
+ properties: {
79
+ id: { type: "string" },
80
+ title: { type: "string" },
81
+ status: { type: "string" },
82
+ },
83
+ });
84
+ const putDraft = fieldsFromSchema({
85
+ type: "object",
86
+ required: ["id", "title"],
87
+ properties: { id: { type: "string" }, title: { type: "string" }, body: { type: "string" } },
88
+ });
89
+ const search = fieldsFromSchema({
90
+ type: "object",
91
+ required: ["q"],
92
+ properties: { q: { type: "string" }, limit: { type: "integer" } },
93
+ });
94
+
95
+ const cases: readonly {
96
+ readonly name: string;
97
+ readonly method: string;
98
+ readonly pathParams: readonly string[];
99
+ readonly fields: ReturnType<typeof fieldsFromSchema>;
100
+ readonly path: readonly string[];
101
+ readonly query: readonly string[];
102
+ readonly body: readonly string[];
103
+ }[] = [
104
+ {
105
+ name: "GET tasks.get",
106
+ method: "GET",
107
+ pathParams: ["id"],
108
+ fields: idOnly,
109
+ path: ["id"],
110
+ query: [],
111
+ body: [],
112
+ },
113
+ {
114
+ name: "GET tasks.list",
115
+ method: "GET",
116
+ pathParams: [],
117
+ fields: list,
118
+ path: [],
119
+ query: ["q", "teamKey", "limit", "offset", "cursor", "orderBy", "order"],
120
+ body: [],
121
+ },
122
+ {
123
+ name: "GET comments.list /tasks/:id/comments",
124
+ method: "GET",
125
+ pathParams: ["id"],
126
+ fields: nestedList,
127
+ path: ["id"],
128
+ query: ["q", "limit"],
129
+ body: [],
130
+ },
131
+ {
132
+ name: "HEAD main.health",
133
+ method: "HEAD",
134
+ pathParams: [],
135
+ fields: [],
136
+ path: [],
137
+ query: [],
138
+ body: [],
139
+ },
140
+ {
141
+ name: "DELETE tasks.delete",
142
+ method: "DELETE",
143
+ pathParams: ["id"],
144
+ fields: idOnly,
145
+ path: ["id"],
146
+ query: [],
147
+ body: [],
148
+ },
149
+ {
150
+ name: "POST tasks.create",
151
+ method: "POST",
152
+ pathParams: [],
153
+ fields: create,
154
+ path: [],
155
+ query: [],
156
+ body: ["title", "spaceKey"],
157
+ },
158
+ {
159
+ name: "POST tasks.assign /tasks/:id/assign",
160
+ method: "POST",
161
+ pathParams: ["id"],
162
+ fields: assign,
163
+ path: ["id"],
164
+ query: [],
165
+ body: ["assigneeEmail"],
166
+ },
167
+ {
168
+ name: "POST tasks.archive path-only",
169
+ method: "POST",
170
+ pathParams: ["id"],
171
+ fields: idOnly,
172
+ path: ["id"],
173
+ query: [],
174
+ body: [],
175
+ },
176
+ {
177
+ name: "PATCH tasks.update",
178
+ method: "PATCH",
179
+ pathParams: ["id"],
180
+ fields: patch,
181
+ path: ["id"],
182
+ query: [],
183
+ body: ["title", "status"],
184
+ },
185
+ {
186
+ name: "PUT drafts.save",
187
+ method: "PUT",
188
+ pathParams: ["id"],
189
+ fields: putDraft,
190
+ path: ["id"],
191
+ query: [],
192
+ body: ["title", "body"],
193
+ },
194
+ {
195
+ name: "QUERY search.query",
196
+ method: "QUERY",
197
+ pathParams: [],
198
+ fields: search,
199
+ path: [],
200
+ query: [],
201
+ body: ["q", "limit"],
202
+ },
203
+ {
204
+ name: "OPTIONS leftover stays body",
205
+ method: "OPTIONS",
206
+ pathParams: [],
207
+ fields: search,
208
+ path: [],
209
+ query: [],
210
+ body: ["q", "limit"],
211
+ },
212
+ ];
213
+
214
+ for (const row of cases) {
215
+ test(row.name, () => {
216
+ const split = splitCallApiInput(row.fields, {
217
+ http: true,
218
+ method: row.method,
219
+ pathParams: row.pathParams,
220
+ });
221
+ expect(split.path).toEqual([...row.path]);
222
+ expect(split.query.map((f) => f.name)).toEqual([...row.query]);
223
+ expect(split.body.map((f) => f.name)).toEqual([...row.body]);
224
+ });
225
+ }
226
+
227
+ test("non-HTTP keeps the full input as body", () => {
228
+ const split = splitCallApiInput(idOnly);
229
+ expect(split.path).toEqual([]);
230
+ expect(split.query).toEqual([]);
231
+ expect(split.body.map((f) => f.name)).toEqual(["id"]);
232
+ });
233
+
234
+ test("Signal / CDC / Call-only never grow Path params from an id field", () => {
235
+ for (const kind of ["signal", "cdc", "internal"] as const) {
236
+ const split = splitCallApiInput(idOnly, { http: false, method: kind });
237
+ expect(split.path).toEqual([]);
238
+ expect(split.query).toEqual([]);
239
+ expect(split.body.map((f) => f.name)).toEqual(["id"]);
240
+ }
241
+ });
242
+ });
243
+
244
+ describe("pickSeedFields", () => {
245
+ test("keeps only named section keys", () => {
246
+ const title = fieldsFromSchema({
247
+ type: "object",
248
+ properties: { title: { type: "string" } },
249
+ });
250
+ expect(pickSeedFields({ id: "x", title: "n" }, title)).toEqual({ title: "n" });
251
+ });
252
+ });
@@ -2,6 +2,7 @@
2
2
  * Split flow input into query/path parameters vs body / filter fields.
3
3
  */
4
4
 
5
+ import { httpMethodHasBody } from "@/features/flows/traces/http-method.ts";
5
6
  import type { FormField } from "./fields-from-schema.ts";
6
7
 
7
8
  /** List / page / sort keys — query parameters, not resource fields. */
@@ -66,3 +67,57 @@ function pathParamField(name: string): FormField {
66
67
  required: true,
67
68
  };
68
69
  }
70
+
71
+ /** Call API request sections — path vs query vs JSON body. */
72
+ export type CallApiInputSplit = {
73
+ readonly path: readonly string[];
74
+ readonly query: readonly FormField[];
75
+ readonly body: readonly FormField[];
76
+ };
77
+
78
+ /**
79
+ * Partition `flow.in` the way the typed client puts fields on the wire.
80
+ *
81
+ * Path tokens never appear in query or body. `GET` / `HEAD` / `DELETE`
82
+ * leftover fields are query. Other HTTP verbs and non-HTTP triggers keep a
83
+ * JSON body (minus path tokens).
84
+ *
85
+ * @param input - `flow.in` fields
86
+ * @param options - Route tokens + HTTP method
87
+ */
88
+ export function splitCallApiInput(
89
+ input: readonly FormField[],
90
+ options: {
91
+ readonly pathParams?: readonly string[];
92
+ readonly method?: string | null;
93
+ readonly http?: boolean;
94
+ } = {},
95
+ ): CallApiInputSplit {
96
+ const pathParams = options.pathParams ?? [];
97
+ const pathSet = new Set(pathParams);
98
+ if (options.http !== true) {
99
+ return { path: [], query: [], body: input };
100
+ }
101
+ const leftover = input.filter((field) => !pathSet.has(field.name));
102
+ if (!httpMethodHasBody(options.method)) {
103
+ return { path: pathParams, query: leftover, body: [] };
104
+ }
105
+ return { path: pathParams, query: [], body: leftover };
106
+ }
107
+
108
+ /**
109
+ * Keep schema seed keys that belong to a Call API section.
110
+ *
111
+ * @param seed - Full `flow.in` seed
112
+ * @param fields - Section fields
113
+ */
114
+ export function pickSeedFields(
115
+ seed: Record<string, unknown>,
116
+ fields: readonly FormField[],
117
+ ): Record<string, unknown> {
118
+ const out: Record<string, unknown> = {};
119
+ for (const field of fields) {
120
+ if (Object.hasOwn(seed, field.name)) out[field.name] = seed[field.name];
121
+ }
122
+ return out;
123
+ }
@@ -110,5 +110,5 @@ export interface VaultRotateMasterResult {
110
110
  readonly kekVersion: number;
111
111
  readonly remaining: number;
112
112
  readonly masterKey: string | null;
113
- readonly at: number;
113
+ readonly at: string;
114
114
  }
@@ -16,6 +16,12 @@ describe("compose-health", () => {
16
16
  expect(composeRowToStatus({ State: "stopped" })).toBe("error");
17
17
  expect(composeRowToStatus({ Status: "Exited (0) 3 seconds ago" })).toBe("error");
18
18
  expect(composeRowToStatus({ State: "running" })).toBe("ready");
19
+ expect(composeRowToStatus({ State: "restarting" })).toBe("pending");
20
+ expect(composeRowToStatus({ State: "running", Status: "Up Less than a second" })).toBe(
21
+ "pending",
22
+ );
23
+ expect(composeRowToStatus({ State: "running", Status: "Up 2 seconds" })).toBe("pending");
24
+ expect(composeRowToStatus({ State: "running", Status: "Up 2 minutes" })).toBe("ready");
19
25
  expect(composeRowToStatus({ Status: "Up 2 seconds (health: starting)" })).toBe("pending");
20
26
  });
21
27
 
@@ -60,6 +66,27 @@ describe("compose-health", () => {
60
66
  expect(updates).toEqual(["store-sql:pending", "store-sql:ready"]);
61
67
  });
62
68
 
69
+ test("watchComposeHealth waits through compose restarting", async () => {
70
+ let calls = 0;
71
+ const map = await watchComposeHealth({
72
+ files: ["compose.yml"],
73
+ cwd: "/tmp",
74
+ env: {},
75
+ intervalMs: 1,
76
+ timeoutMs: 5_000,
77
+ sleep: async () => {},
78
+ run: async () => {
79
+ calls += 1;
80
+ if (calls === 1) {
81
+ return JSON.stringify([{ Service: "store-sql", State: "restarting" }]);
82
+ }
83
+ return JSON.stringify([{ Service: "store-sql", Health: "healthy", State: "running" }]);
84
+ },
85
+ });
86
+ expect(calls).toBe(2);
87
+ expect(map.get("store-sql")).toBe("ready");
88
+ });
89
+
63
90
  test("readComposeHealth / watch use ps -a", async () => {
64
91
  let seen: readonly string[] = [];
65
92
  await watchComposeHealth({
@@ -20,22 +20,27 @@ export type ComposePsRow = {
20
20
  export function composeRowToStatus(row: ComposePsRow): DevStatus {
21
21
  const health = (row.Health ?? "").trim().toLowerCase();
22
22
  const state = (row.State ?? "").trim().toLowerCase();
23
+ const statusText = (row.Status ?? "").toLowerCase();
23
24
  if (health === "healthy") return "ready";
24
25
  if (health === "unhealthy") return "error";
25
26
  if (health === "starting") return "pending";
26
- if (state === "running" || state === "up") return "ready";
27
+ if (state === "restarting") return "pending";
28
+ if (state === "running" || state === "up") {
29
+ // Right after `compose restart`, State is "running" before Health is
30
+ // "starting". Treat a fresh Up without (healthy) as still coming up.
31
+ if (isFreshComposeUp(statusText) && !statusText.includes("(healthy)")) return "pending";
32
+ return "ready";
33
+ }
27
34
  if (
28
35
  state === "exited" ||
29
36
  state === "dead" ||
30
37
  state === "stopped" ||
31
- state === "restarting" ||
32
38
  state === "removing" ||
33
39
  state === "oomkilled"
34
40
  ) {
35
41
  return "error";
36
42
  }
37
43
  // Status text for stopped containers (`Exited (0) 2 seconds ago`).
38
- const statusText = (row.Status ?? "").toLowerCase();
39
44
  if (statusText.startsWith("exited") || statusText.startsWith("dead")) return "error";
40
45
  if (state === "created" || state === "paused" || state.length === 0) return "pending";
41
46
  // Status text like "Up 3 seconds (healthy)"
@@ -48,6 +53,17 @@ export function composeRowToStatus(row: ComposePsRow): DevStatus {
48
53
  return "pending";
49
54
  }
50
55
 
56
+ /**
57
+ * True when compose `Status` is a just-started container (health not reported yet).
58
+ *
59
+ * @param statusText - Lowercased `Status` from `docker compose ps`
60
+ */
61
+ function isFreshComposeUp(statusText: string): boolean {
62
+ if (statusText.includes("less than a second")) return true;
63
+ const seconds = /up\s+(\d+)\s+seconds?/i.exec(statusText);
64
+ return seconds !== null && Number(seconds[1]) < 8;
65
+ }
66
+
51
67
  /**
52
68
  * Parse NDJSON or a JSON array from `docker compose ps --format json`.
53
69
  *
@@ -392,11 +392,13 @@ describe("image recipes", () => {
392
392
  expect(pgdogToml).toContain('host = "0.0.0.0"');
393
393
  expect(pgdogToml).toContain("port = 6432");
394
394
  expect(pgdogToml).toContain('pooler_mode = "transaction"');
395
+ expect(pgdogToml).toContain("default_pool_size = 20");
395
396
  expect(pgdogToml).toContain("[[databases]]");
396
397
  expect(pgdogToml).toContain('name = "oke"');
397
398
  expect(pgdogToml).toContain('host = "store-sql"');
398
399
  expect(pgdogToml).toContain("port = 5432");
399
400
  expect(pgdogToml).toContain('database_name = "oke"');
401
+ expect(pgdogToml).toContain("pool_size = 20");
400
402
 
401
403
  const usersToml = buildPgDogUsersToml({
402
404
  user: "oke",
@@ -936,6 +938,7 @@ describe("deriveInfrastructure", () => {
936
938
 
937
939
  const pgdogToml = result.files.find((f) => f.path === "pgdog/pgdog.toml")!.content;
938
940
  expect(pgdogToml).toContain('pooler_mode = "transaction"');
941
+ expect(pgdogToml).toContain("pool_size = 20");
939
942
  expect(pgdogToml).toContain('host = "store-sql"');
940
943
  expect(pgdogToml).not.toContain(fixedCreds["store.sql"].password);
941
944
 
@@ -35,12 +35,14 @@ export function buildPgDogToml(opts: {
35
35
  'host = "0.0.0.0"',
36
36
  "port = 6432",
37
37
  'pooler_mode = "transaction"',
38
+ "default_pool_size = 20",
38
39
  "",
39
40
  "[[databases]]",
40
41
  `name = ${tomlString(db)}`,
41
42
  `host = ${tomlString(host)}`,
42
43
  `port = ${port}`,
43
44
  `database_name = ${tomlString(db)}`,
45
+ "pool_size = 20",
44
46
  "",
45
47
  ].join("\n");
46
48
  }
@@ -7,7 +7,13 @@
7
7
  */
8
8
 
9
9
  import type { CronRow, CronStatus, CronStore } from "../elements/clock/reconcile.ts";
10
- import { toPostgresParams } from "./postgres.ts";
10
+ import {
11
+ resolvePostgresUrl,
12
+ sharedPostgresClient,
13
+ toPostgresParams,
14
+ withPinnedPostgres,
15
+ type PostgresClientLike,
16
+ } from "./postgres.ts";
11
17
 
12
18
  /** Row shape in `oke_crons` (lease columns mirror `oke_signal_messages`). */
13
19
  interface CronDbRow {
@@ -69,16 +75,16 @@ export interface BunCronClient {
69
75
  close?(options?: { timeout?: number }): Promise<void>;
70
76
  }
71
77
 
72
- function wrapBunClient(client: BunCronClient): PostgresCronSql {
78
+ function wrapBunClient(client: PostgresClientLike): PostgresCronSql {
73
79
  const api: PostgresCronSql = {
74
80
  async query(sql, params = []) {
75
- const pg = toPostgresParams(sql);
81
+ const pg = toPostgresParams(sql, params);
76
82
  const result = await client.unsafe(pg, [...params]);
77
83
  if (Array.isArray(result)) return result as Record<string, unknown>[];
78
84
  return Array.from(result as ArrayLike<Record<string, unknown>>);
79
85
  },
80
86
  async exec(sql, params = []) {
81
- const pg = toPostgresParams(sql);
87
+ const pg = toPostgresParams(sql, params);
82
88
  const result = await client.unsafe(pg, [...params]);
83
89
  if (
84
90
  result &&
@@ -92,7 +98,7 @@ function wrapBunClient(client: BunCronClient): PostgresCronSql {
92
98
  return { changes: 0 };
93
99
  },
94
100
  async begin(fn) {
95
- return client.begin(async (tx) => fn(wrapBunClient(tx)));
101
+ return withPinnedPostgres(client, (tx) => fn(wrapBunClient(tx)));
96
102
  },
97
103
  async close() {
98
104
  await client.close?.();
@@ -365,10 +371,8 @@ export async function createPostgresCronStore(
365
371
  const sql =
366
372
  options.sql ??
367
373
  wrapBunClient(
368
- options.client ??
369
- (new Bun.SQL(
370
- options.url ?? process.env.DATABASE_URL ?? "postgres://localhost:5432/oke",
371
- ) as unknown as BunCronClient),
374
+ (options.client ??
375
+ sharedPostgresClient(resolvePostgresUrl(options.url))) as PostgresClientLike,
372
376
  );
373
377
 
374
378
  await ensureSchema(sql);
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { ConfigEnv } from "../config/index.ts";
9
9
  import type { InstanceRow, InstanceStore } from "../kernel/instances.ts";
10
- import { toPostgresParams } from "./postgres.ts";
10
+ import { resolvePostgresUrl, sharedPostgresClient, toPostgresParams } from "./postgres.ts";
11
11
 
12
12
  /** Minimal SQL surface for the postgres instance store. */
13
13
  export interface PostgresInstanceSql {
@@ -40,13 +40,13 @@ const UPSERT_SQL = `INSERT INTO oke_instances (id, started_at, heartbeat_at, lea
40
40
  function wrapBunClient(client: BunInstanceClient): PostgresInstanceSql {
41
41
  return {
42
42
  async query(sql, params = []) {
43
- const pg = toPostgresParams(sql);
43
+ const pg = toPostgresParams(sql, params);
44
44
  const result = await client.unsafe(pg, [...params]);
45
45
  if (Array.isArray(result)) return result as Record<string, unknown>[];
46
46
  return Array.from(result as ArrayLike<Record<string, unknown>>);
47
47
  },
48
48
  async exec(sql, params = []) {
49
- const pg = toPostgresParams(sql);
49
+ const pg = toPostgresParams(sql, params);
50
50
  const result = await client.unsafe(pg, [...params]);
51
51
  if (
52
52
  result &&
@@ -109,9 +109,7 @@ export async function createPostgresInstanceStore(
109
109
  options.sql ??
110
110
  wrapBunClient(
111
111
  options.client ??
112
- (new Bun.SQL(
113
- options.url ?? process.env.DATABASE_URL ?? "postgres://localhost:5432/oke",
114
- ) as unknown as BunInstanceClient),
112
+ (sharedPostgresClient(resolvePostgresUrl(options.url)) as unknown as BunInstanceClient),
115
113
  );
116
114
 
117
115
  await ensureSchema(sql);
@@ -14,7 +14,13 @@ import {
14
14
  type JournalRun,
15
15
  type JournalStore,
16
16
  } from "../kernel/journal.ts";
17
- import { toPostgresParams } from "./postgres.ts";
17
+ import {
18
+ resolvePostgresUrl,
19
+ sharedPostgresClient,
20
+ toPostgresParams,
21
+ withPinnedPostgres,
22
+ type PostgresClientLike,
23
+ } from "./postgres.ts";
18
24
 
19
25
  /** Row shape in `oke_journal_runs` (lease columns mirror `oke_crons`). */
20
26
  interface JournalDbRow {
@@ -87,16 +93,16 @@ export interface BunJournalClient {
87
93
  close?(options?: { timeout?: number }): Promise<void>;
88
94
  }
89
95
 
90
- function wrapBunClient(client: BunJournalClient): PostgresJournalSql {
96
+ function wrapBunClient(client: PostgresClientLike): PostgresJournalSql {
91
97
  const api: PostgresJournalSql = {
92
98
  async query(sql, params = []) {
93
- const pg = toPostgresParams(sql);
99
+ const pg = toPostgresParams(sql, params);
94
100
  const result = await client.unsafe(pg, [...params]);
95
101
  if (Array.isArray(result)) return result as Record<string, unknown>[];
96
102
  return Array.from(result as ArrayLike<Record<string, unknown>>);
97
103
  },
98
104
  async exec(sql, params = []) {
99
- const pg = toPostgresParams(sql);
105
+ const pg = toPostgresParams(sql, params);
100
106
  const result = await client.unsafe(pg, [...params]);
101
107
  if (
102
108
  result &&
@@ -110,7 +116,7 @@ function wrapBunClient(client: BunJournalClient): PostgresJournalSql {
110
116
  return { changes: 0 };
111
117
  },
112
118
  async begin(fn) {
113
- return client.begin(async (tx) => fn(wrapBunClient(tx)));
119
+ return withPinnedPostgres(client, (tx) => fn(wrapBunClient(tx)));
114
120
  },
115
121
  async close() {
116
122
  await client.close?.();
@@ -437,10 +443,8 @@ export async function createPostgresJournalStore(
437
443
  const sql =
438
444
  options.sql ??
439
445
  wrapBunClient(
440
- options.client ??
441
- (new Bun.SQL(
442
- options.url ?? process.env.DATABASE_URL ?? "postgres://localhost:5432/oke",
443
- ) as unknown as BunJournalClient),
446
+ (options.client ??
447
+ sharedPostgresClient(resolvePostgresUrl(options.url))) as PostgresClientLike,
444
448
  );
445
449
 
446
450
  await ensureSchema(sql);
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  buildAlterPolicySql,
4
4
  buildCreatePolicySql,
5
+ buildRlsIdentityPreludeSql,
6
+ emitPgPolicySource,
7
+ formatPolicyRole,
8
+ OKE_RLS_HELPER_STATEMENTS,
9
+ OKE_RLS_ROLE,
5
10
  parseSqlPolicySpec,
11
+ rlsScopesJson,
6
12
  sqlPolicyRowId,
7
13
  } from "./pg-rls.ts";
8
14
 
@@ -36,6 +42,22 @@ describe("parseSqlPolicySpec", () => {
36
42
  expect(spec.withCheck).toBe("true");
37
43
  });
38
44
 
45
+ test("accepts Drizzle as/to/for aliases", () => {
46
+ const spec = parseSqlPolicySpec({
47
+ name: "gate_read",
48
+ table: "bookings",
49
+ for: "select",
50
+ as: "permissive",
51
+ to: "current_user",
52
+ using: "oke.gate() = 'member'",
53
+ });
54
+ expect(spec.command).toBe("SELECT");
55
+ expect(spec.behavior).toBe("PERMISSIVE");
56
+ expect(spec.roles).toEqual(["current_user"]);
57
+ expect(buildCreatePolicySql(spec)).toContain("TO current_user");
58
+ expect(buildCreatePolicySql(spec)).not.toContain('"current_user"');
59
+ });
60
+
39
61
  test("rejects stacked statements in USING", () => {
40
62
  expect(() =>
41
63
  parseSqlPolicySpec({
@@ -69,3 +91,68 @@ describe("buildAlterPolicySql", () => {
69
91
  ).toThrow("invalid USING");
70
92
  });
71
93
  });
94
+
95
+ describe("formatPolicyRole", () => {
96
+ test("leaves Drizzle special roles unquoted", () => {
97
+ expect(formatPolicyRole("public")).toBe("public");
98
+ expect(formatPolicyRole("current_user")).toBe("current_user");
99
+ expect(formatPolicyRole("member")).toBe('"member"');
100
+ });
101
+ });
102
+
103
+ describe("emitPgPolicySource", () => {
104
+ test("emits drizzle pgPolicy with oke helpers", () => {
105
+ const spec = parseSqlPolicySpec({
106
+ name: "gate_member_select",
107
+ table: "bookings",
108
+ command: "SELECT",
109
+ using: "oke.gate() = 'member'",
110
+ });
111
+ expect(emitPgPolicySource(spec)).toContain('pgPolicy("gate_member_select"');
112
+ expect(emitPgPolicySource(spec)).toContain('for: "select"');
113
+ expect(emitPgPolicySource(spec)).toContain("oke.gate() = 'member'");
114
+ });
115
+ });
116
+
117
+ describe("rlsScopesJson", () => {
118
+ test("sorts unique scopes", () => {
119
+ expect(rlsScopesJson(["booking:create", "member", "member"])).toBe(
120
+ '["booking:create","member"]',
121
+ );
122
+ });
123
+ });
124
+
125
+ describe("buildRlsIdentityPreludeSql", () => {
126
+ test("emits one statement per exec — never a multi-command batch", () => {
127
+ const stmts = buildRlsIdentityPreludeSql({
128
+ gate: "member",
129
+ userId: "alice",
130
+ scopes: ["member"],
131
+ });
132
+ expect(stmts.map((s) => s.sql)).toEqual([
133
+ `SET LOCAL ROLE ${OKE_RLS_ROLE}`,
134
+ "SET LOCAL row_security = on",
135
+ "SELECT set_config('oke.gate', ?, true), set_config('oke.user', ?, true), set_config('oke.scopes', ?, true)",
136
+ ]);
137
+ for (const stmt of stmts) {
138
+ expect(stmt.sql.includes(";")).toBe(false);
139
+ }
140
+ expect(stmts[2]?.params).toEqual(["member", "alice", '["member"]']);
141
+ });
142
+ });
143
+
144
+ describe("OKE_RLS_HELPER_STATEMENTS", () => {
145
+ test("has_scope uses jsonb_exists so ? is not a placeholder", () => {
146
+ const hasScope = OKE_RLS_HELPER_STATEMENTS.find((s) => s.includes("oke.has_scope"));
147
+ expect(hasScope).toBeDefined();
148
+ expect(hasScope).toContain("jsonb_exists(");
149
+ expect(hasScope).not.toMatch(/jsonb\s+\?/);
150
+ });
151
+
152
+ test("pins search_path to public so schema oke is not the table home", () => {
153
+ expect(OKE_RLS_HELPER_STATEMENTS).toContain("SET search_path TO public, oke");
154
+ expect(
155
+ OKE_RLS_HELPER_STATEMENTS.some((s) => s.includes("ALTER TABLE oke.%I SET SCHEMA public")),
156
+ ).toBe(true);
157
+ });
158
+ });