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
@@ -69,6 +69,42 @@ export interface SchemaColumnDecl extends ColumnDef {
69
69
  getSQL(): never;
70
70
  }
71
71
 
72
+ /** Policy command on a {@link SchemaPolicyDecl}. */
73
+ export type SchemaPolicyFor = "all" | "select" | "insert" | "update" | "delete";
74
+
75
+ /** Policy behavior on a {@link SchemaPolicyDecl}. */
76
+ export type SchemaPolicyAs = "permissive" | "restrictive";
77
+
78
+ /**
79
+ * Drizzle-shaped policy extra (`pgPolicy` emit).
80
+ */
81
+ export interface SchemaPolicyDecl {
82
+ readonly kind: "schema-policy";
83
+ readonly name: string;
84
+ readonly as?: SchemaPolicyAs;
85
+ readonly to?: string | readonly string[];
86
+ readonly for?: SchemaPolicyFor;
87
+ readonly using?: string;
88
+ readonly withCheck?: string;
89
+ }
90
+
91
+ /** Enable RLS with no policies (`pgTable.withRLS`). */
92
+ export interface SchemaRlsEnableDecl {
93
+ readonly kind: "schema-rls";
94
+ }
95
+
96
+ /** Third-arg extra for {@link schemaTable}. */
97
+ export type SchemaTableExtra = SchemaPolicyDecl | SchemaRlsEnableDecl;
98
+
99
+ /** Options for {@link schemaPolicy} / Gate helpers. */
100
+ export interface SchemaPolicyOptions {
101
+ readonly as?: SchemaPolicyAs;
102
+ readonly to?: string | readonly string[];
103
+ readonly for?: SchemaPolicyFor;
104
+ readonly using?: string;
105
+ readonly withCheck?: string;
106
+ }
107
+
72
108
  /** Table from {@link store.schema.table} — extends {@link TableHandle}. */
73
109
  export interface SchemaTableDecl extends TableHandle {
74
110
  readonly kind: "schema-table";
@@ -78,6 +114,10 @@ export interface SchemaTableDecl extends TableHandle {
78
114
  * (spreading columns would otherwise shadow {@link TableHandle.name}).
79
115
  */
80
116
  readonly tableName?: string;
117
+ /** `true` when {@link schemaRls} was declared or any policy is present. */
118
+ readonly rls?: boolean;
119
+ /** Declared policies (emit + Manifest). */
120
+ readonly policies?: readonly SchemaPolicyDecl[];
81
121
  }
82
122
 
83
123
  /** Fluent field builder before key finalization. */
@@ -310,20 +350,28 @@ export type SchemaTableWithColumns<
310
350
  *
311
351
  * @param name - SQL table name
312
352
  * @param columns - Column map using {@link field} builders
353
+ * @param extras - RLS enable + {@link schemaPolicy} extras (Drizzle third arg)
313
354
  */
314
355
  export function schemaTable<C extends Record<string, SchemaColumnInput>>(
315
356
  name: string,
316
357
  columns: C,
358
+ extras: readonly SchemaTableExtra[] = [],
317
359
  ): SchemaTableWithColumns<{ [K in keyof C]: SchemaColumnDecl }> {
318
360
  const finalized = finalizeColumnMap(columns);
319
361
  const stamped: Record<string, SchemaColumnDecl> = {};
320
362
  for (const [key, col] of Object.entries(finalized)) {
321
363
  stamped[key] = { ...col, tableName: name };
322
364
  }
365
+ const policies = extras.filter(
366
+ (extra): extra is SchemaPolicyDecl => extra.kind === "schema-policy",
367
+ );
368
+ const rls = extras.some((extra) => extra.kind === "schema-rls") || policies.length > 0;
323
369
  const table = {
324
370
  name,
325
371
  columns: stamped,
326
372
  ...stamped,
373
+ ...(rls ? { rls: true } : {}),
374
+ ...(policies.length > 0 ? { policies } : {}),
327
375
  };
328
376
  // Survive columns named `name` or `kind` (they would otherwise shadow
329
377
  // the table discriminant / SQL name).
@@ -332,6 +380,117 @@ export function schemaTable<C extends Record<string, SchemaColumnInput>>(
332
380
  return table as SchemaTableWithColumns<{ [K in keyof C]: SchemaColumnDecl }>;
333
381
  }
334
382
 
383
+ /**
384
+ * Enable RLS with no policies (`pgTable.withRLS`).
385
+ */
386
+ export function schemaRls(): SchemaRlsEnableDecl {
387
+ return { kind: "schema-rls" };
388
+ }
389
+
390
+ /**
391
+ * Raw Drizzle-shaped policy extra.
392
+ *
393
+ * @param name - Policy name
394
+ * @param options - `as` / `to` / `for` / `using` / `withCheck`
395
+ */
396
+ export function schemaPolicy(name: string, options: SchemaPolicyOptions = {}): SchemaPolicyDecl {
397
+ return {
398
+ kind: "schema-policy",
399
+ name,
400
+ ...(options.as !== undefined ? { as: options.as } : {}),
401
+ ...(options.to !== undefined ? { to: options.to } : {}),
402
+ ...(options.for !== undefined ? { for: options.for } : {}),
403
+ ...(options.using !== undefined ? { using: options.using } : {}),
404
+ ...(options.withCheck !== undefined ? { withCheck: options.withCheck } : {}),
405
+ };
406
+ }
407
+
408
+ function sqlStringLiteral(value: string): string {
409
+ return `'${value.replaceAll("'", "''")}'`;
410
+ }
411
+
412
+ function helperPolicyName(prefix: string, key: string, command: SchemaPolicyFor): string {
413
+ const slug = key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_|_$/g, "");
414
+ return `${prefix}_${slug}_${command}`;
415
+ }
416
+
417
+ function policyPredicates(
418
+ command: SchemaPolicyFor,
419
+ expr: string,
420
+ ): { readonly using?: string; readonly withCheck?: string } {
421
+ if (command === "insert") return { withCheck: expr };
422
+ if (command === "update" || command === "all") return { using: expr, withCheck: expr };
423
+ return { using: expr };
424
+ }
425
+
426
+ /**
427
+ * Gate-name policy — `oke.gate() = 'member'`.
428
+ *
429
+ * @param gate - Policy / public Gate name
430
+ * @param options - Command (default `select`)
431
+ */
432
+ export function schemaPolicyGate(
433
+ gate: string,
434
+ options: Pick<SchemaPolicyOptions, "for" | "as" | "to"> = {},
435
+ ): SchemaPolicyDecl {
436
+ const command = options.for ?? "select";
437
+ return schemaPolicy(helperPolicyName("gate", gate, command), {
438
+ ...options,
439
+ for: command,
440
+ ...policyPredicates(command, `oke.gate() = ${sqlStringLiteral(gate)}`),
441
+ });
442
+ }
443
+
444
+ /**
445
+ * Owner-column policy — `owner = oke.user()`.
446
+ *
447
+ * @param column - SQL / JS column name
448
+ * @param options - Command (default `all`)
449
+ */
450
+ export function schemaPolicyOwner(
451
+ column: string,
452
+ options: Pick<SchemaPolicyOptions, "for" | "as" | "to"> = {},
453
+ ): SchemaPolicyDecl {
454
+ const command = options.for ?? "all";
455
+ return schemaPolicy(helperPolicyName("owner", column, command), {
456
+ ...options,
457
+ for: command,
458
+ ...policyPredicates(command, `${column} = oke.user()`),
459
+ });
460
+ }
461
+
462
+ /**
463
+ * Scope policy — `oke.has_scope('booking:create')`.
464
+ *
465
+ * @param scope - Scope string
466
+ * @param options - Command (default `insert`)
467
+ */
468
+ export function schemaPolicyScope(
469
+ scope: string,
470
+ options: Pick<SchemaPolicyOptions, "for" | "as" | "to"> = {},
471
+ ): SchemaPolicyDecl {
472
+ const command = options.for ?? "insert";
473
+ return schemaPolicy(helperPolicyName("scope", scope, command), {
474
+ ...options,
475
+ for: command,
476
+ ...policyPredicates(command, `oke.has_scope(${sqlStringLiteral(scope)})`),
477
+ });
478
+ }
479
+
480
+ /** Callable `store.schema.policy` plus Gate helpers. */
481
+ export type SchemaPolicyApi = typeof schemaPolicy & {
482
+ readonly gate: typeof schemaPolicyGate;
483
+ readonly owner: typeof schemaPolicyOwner;
484
+ readonly scope: typeof schemaPolicyScope;
485
+ };
486
+
487
+ /** `store.schema.policy` — raw + Gate helpers. */
488
+ export const schemaPolicyApi: SchemaPolicyApi = Object.assign(schemaPolicy, {
489
+ gate: schemaPolicyGate,
490
+ owner: schemaPolicyOwner,
491
+ scope: schemaPolicyScope,
492
+ });
493
+
335
494
  // ─── Relations (mirrors drizzle-orm `defineRelations`) ───────────────────────
336
495
 
337
496
  /** Column path recorded by relation helpers (`r.links.code`). */
@@ -527,6 +686,8 @@ export function schemaRelations<T extends Readonly<Record<string, SchemaTableDec
527
686
  export const schema = {
528
687
  table: schemaTable,
529
688
  relations: schemaRelations,
689
+ rls: schemaRls,
690
+ policy: schemaPolicyApi,
530
691
  } as const;
531
692
 
532
693
  /**
@@ -61,7 +61,7 @@ export function resolveSeedIdentity(
61
61
  }
62
62
 
63
63
  /**
64
- * Confirm copy for `oke dev` / `oke db seed` — names this app's seed.
64
+ * Confirm copy for `oke db seed` — names this app's seed.
65
65
  *
66
66
  * @param identity - Resolved {@link SeedIdentity}
67
67
  */
@@ -86,6 +86,18 @@ describe("compileWhere", () => {
86
86
  expect(c.params).toEqual(["n1", "t"]);
87
87
  });
88
88
 
89
+ test("legacy array-valued StringChunk fragments still compile", () => {
90
+ const c = compileWhere({
91
+ queryChunks: [
92
+ notes.id,
93
+ { constructor: { name: "StringChunk" }, value: [" = "] },
94
+ { value: "n1" },
95
+ ],
96
+ });
97
+ expect(c.clause).toBe(`"id" = ?`);
98
+ expect(c.params).toEqual(["n1"]);
99
+ });
100
+
89
101
  test("inArray expands one placeholder per value", () => {
90
102
  const c = compileWhere(inArray(notes.id, ["a", "b", "c"]));
91
103
  expect(c.clause).toBe(`"id" in (?, ?, ?)`);
@@ -333,10 +333,33 @@ function chunksOf(node: unknown): readonly unknown[] {
333
333
  return (node as { queryChunks: unknown[] }).queryChunks;
334
334
  }
335
335
 
336
- /** Joined text of a Drizzle `StringChunk` (`{ value: string[] }`). */
336
+ /**
337
+ * True when `chunk` is a Drizzle `StringChunk` (or the synthetic array-valued
338
+ * shape `resource.ts` still emits). `Param` also has a `value` field — never
339
+ * treat those as SQL text.
340
+ */
341
+ function isStringChunk(chunk: object): boolean {
342
+ const ctor = (chunk as { constructor?: { name?: unknown } }).constructor?.name;
343
+ if (ctor === "StringChunk") return true;
344
+ const value = (chunk as { value?: unknown }).value;
345
+ return (
346
+ Array.isArray(value) &&
347
+ value.every((part) => typeof part === "string") &&
348
+ !("encoder" in chunk) &&
349
+ !("name" in chunk)
350
+ );
351
+ }
352
+
353
+ /**
354
+ * Joined text of a Drizzle `StringChunk`.
355
+ *
356
+ * drizzle-orm `1.0.0-rc.5-169397b` stores `value` as a `string`. Earlier
357
+ * snapshots (and the synthetic chunks in `resource.ts`) store `string[]`.
358
+ */
337
359
  function chunkText(chunk: unknown): string | undefined {
338
- if (!chunk || typeof chunk !== "object") return undefined;
360
+ if (!chunk || typeof chunk !== "object" || !isStringChunk(chunk)) return undefined;
339
361
  const value = (chunk as { value?: unknown }).value;
362
+ if (typeof value === "string") return value;
340
363
  return Array.isArray(value) ? value.join("") : undefined;
341
364
  }
342
365
 
@@ -394,6 +417,7 @@ function asParamValue(chunk: unknown): { readonly found: boolean; readonly value
394
417
  if (chunk === undefined) return { found: false };
395
418
  if (chunk === null || typeof chunk !== "object") return { found: true, value: chunk };
396
419
  if (Array.isArray(chunk)) return { found: false };
420
+ if (isStringChunk(chunk)) return { found: false };
397
421
  if (!("value" in (chunk as object))) return { found: false };
398
422
  if (Array.isArray((chunk as { value: unknown }).value)) return { found: false };
399
423
  if ((chunk as { name?: unknown }).name) return { found: false };
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Real PGlite RLS isolation through {@link createSqlStoreHandle}.
3
+ *
4
+ * Covers the gap unit tests missed: a live policy, two concurrent identity
5
+ * bags on one shared connection, and row-level isolation. PGlite `query`
6
+ * rejects multi-command batches — the stamp is split statements pinned
7
+ * through `SqlConnection.transaction`.
8
+ */
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { connectPglite } from "../../drivers/pglite.ts";
12
+ import { connectPostgres } from "../../drivers/postgres.ts";
13
+ import { installOkeRlsHelpers } from "../../drivers/pg-rls.ts";
14
+ import type { SqlConnection } from "../../drivers/types.ts";
15
+ import { createSqlStoreHandle, type SqlStoreHandle } from "./sql-session.ts";
16
+
17
+ const NOTES_DDL = `CREATE TABLE notes (
18
+ id text PRIMARY KEY,
19
+ owner text NOT NULL,
20
+ body text NOT NULL
21
+ )`;
22
+
23
+ const OWNER_POLICY = `CREATE POLICY owner_select ON notes
24
+ AS PERMISSIVE FOR SELECT TO public
25
+ USING (owner = oke.user())`;
26
+
27
+ /** File-scoped warmed PGlite. */
28
+ let conn: SqlConnection;
29
+ /** Stamped as alice. */
30
+ let asAlice: SqlStoreHandle;
31
+ /** Stamped as bob. */
32
+ let asBob: SqlStoreHandle;
33
+ /** No stamp — table-owner / superuser bypass. */
34
+ let asOwner: SqlStoreHandle;
35
+
36
+ beforeAll(async () => {
37
+ conn = await connectPglite({ url: "memory://sql-rls-isolation", role: "primary" });
38
+ await conn.exec(NOTES_DDL);
39
+ await conn.exec(`INSERT INTO notes VALUES ('a', 'alice', 'hi'), ('b', 'bob', 'yo')`);
40
+ await conn.exec(`ALTER TABLE notes ENABLE ROW LEVEL SECURITY`);
41
+ await installOkeRlsHelpers((sql) => conn.exec(sql));
42
+ await conn.exec(OWNER_POLICY);
43
+ asAlice = createSqlStoreHandle("sql:app", {
44
+ connection: conn,
45
+ classifications: new Map(),
46
+ routedRole: "primary",
47
+ domainDdl: "off",
48
+ rls: { gate: "member", userId: "alice", scopes: ["member"] },
49
+ });
50
+ asBob = createSqlStoreHandle("sql:app", {
51
+ connection: conn,
52
+ classifications: new Map(),
53
+ routedRole: "primary",
54
+ domainDdl: "off",
55
+ rls: { gate: "member", userId: "bob", scopes: ["member"] },
56
+ });
57
+ asOwner = createSqlStoreHandle("sql:app", {
58
+ connection: conn,
59
+ classifications: new Map(),
60
+ routedRole: "primary",
61
+ domainDdl: "off",
62
+ });
63
+ }, 15_000);
64
+
65
+ afterAll(async () => {
66
+ await conn.close();
67
+ });
68
+
69
+ describe("sql-session RLS isolation (pglite)", () => {
70
+ test("pglite query rejects a multi-command batch (the old prelude)", async () => {
71
+ await expect(
72
+ conn.query("BEGIN; SET LOCAL row_security = on; SELECT set_config('oke.user', $1, true)", [
73
+ "alice",
74
+ ]),
75
+ ).rejects.toThrow(/multiple commands/i);
76
+ });
77
+
78
+ test("sequential identities see only their rows", async () => {
79
+ const alice = await asAlice.raw(`SELECT id, owner FROM notes ORDER BY id`);
80
+ const bob = await asBob.raw(`SELECT id, owner FROM notes ORDER BY id`);
81
+ expect(alice).toEqual([{ id: "a", owner: "alice" }]);
82
+ expect(bob).toEqual([{ id: "b", owner: "bob" }]);
83
+ });
84
+
85
+ test("concurrent identities do not leak across the shared connection", async () => {
86
+ const [alice, bob, aliceAgain] = await Promise.all([
87
+ asAlice.raw(`SELECT id, owner FROM notes ORDER BY id`),
88
+ asBob.raw(`SELECT id, owner FROM notes ORDER BY id`),
89
+ asAlice.raw(`SELECT id, owner FROM notes ORDER BY id`),
90
+ ]);
91
+ expect(alice).toEqual([{ id: "a", owner: "alice" }]);
92
+ expect(bob).toEqual([{ id: "b", owner: "bob" }]);
93
+ expect(aliceAgain).toEqual([{ id: "a", owner: "alice" }]);
94
+ });
95
+
96
+ test("unstamped owner/superuser still sees every row", async () => {
97
+ const rows = await asOwner.raw(`SELECT id, owner FROM notes ORDER BY id`);
98
+ expect(rows).toEqual([
99
+ { id: "a", owner: "alice" },
100
+ { id: "b", owner: "bob" },
101
+ ]);
102
+ });
103
+ });
104
+
105
+ const LIVE_PG =
106
+ process.env.OKE_TEST_DOCKER === "1" &&
107
+ (process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL)?.startsWith("postgres");
108
+ if (!LIVE_PG) {
109
+ console.log("skip: live Postgres RLS isolation needs OKE_TEST_DOCKER=1 and DATABASE_URL");
110
+ }
111
+
112
+ describe("sql-session RLS isolation (live postgres)", () => {
113
+ const live = LIVE_PG ? test : test.skip;
114
+ const table = "oke_rls_isolation_notes";
115
+
116
+ live(
117
+ "split stamp isolates concurrent identities",
118
+ async () => {
119
+ const pg = await connectPostgres({
120
+ url: process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL,
121
+ role: "primary",
122
+ });
123
+ try {
124
+ await pg.exec(`DROP TABLE IF EXISTS ${table}`);
125
+ await pg.exec(
126
+ `CREATE TABLE ${table} (id text PRIMARY KEY, owner text NOT NULL, body text NOT NULL)`,
127
+ );
128
+ await pg.exec(`INSERT INTO ${table} VALUES ('a', 'alice', 'hi'), ('b', 'bob', 'yo')`);
129
+ await pg.exec(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`);
130
+ await installOkeRlsHelpers((sql) => pg.exec(sql));
131
+ await pg.exec(
132
+ `CREATE POLICY owner_select ON ${table} AS PERMISSIVE FOR SELECT TO public USING (owner = oke.user())`,
133
+ );
134
+ const alice = createSqlStoreHandle("sql:app", {
135
+ connection: pg,
136
+ classifications: new Map(),
137
+ routedRole: "primary",
138
+ domainDdl: "off",
139
+ rls: { gate: "member", userId: "alice", scopes: ["member"] },
140
+ });
141
+ const bob = createSqlStoreHandle("sql:app", {
142
+ connection: pg,
143
+ classifications: new Map(),
144
+ routedRole: "primary",
145
+ domainDdl: "off",
146
+ rls: { gate: "member", userId: "bob", scopes: ["member"] },
147
+ });
148
+ const [a, b] = await Promise.all([
149
+ alice.raw(`SELECT id, owner FROM ${table} ORDER BY id`),
150
+ bob.raw(`SELECT id, owner FROM ${table} ORDER BY id`),
151
+ ]);
152
+ expect(a).toEqual([{ id: "a", owner: "alice" }]);
153
+ expect(b).toEqual([{ id: "b", owner: "bob" }]);
154
+ } finally {
155
+ try {
156
+ await pg.exec(`DROP TABLE IF EXISTS ${table}`);
157
+ } catch {
158
+ // Best-effort cleanup.
159
+ }
160
+ await pg.close();
161
+ }
162
+ },
163
+ 20_000,
164
+ );
165
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * RLS stamps on pooled postgres must `reserve()` one slot, then BEGIN/SET
3
+ * on that slot. `begin()` + parent `unsafe()` checkouts a second slot and
4
+ * deadlocks a tiny pool (`checkout timeout` from PgDog).
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { connectPostgres, type PostgresClientLike } from "../../drivers/postgres.ts";
9
+ import { createSqlStoreHandle } from "./sql-session.ts";
10
+
11
+ /**
12
+ * Two-slot pool. `reserve()` holds a slot until `release()`. Unreserved
13
+ * `BEGIN` occupies a slot until COMMIT on that same checkout.
14
+ */
15
+ function createTinyPoolClient(size = 2): PostgresClientLike & {
16
+ readonly reserveCalls: { n: number };
17
+ readonly rootBegins: { n: number };
18
+ } {
19
+ const slots = Array.from({ length: size }, () => ({ held: false }));
20
+ const reserveCalls = { n: 0 };
21
+ const rootBegins = { n: 0 };
22
+
23
+ function checkout(): { held: boolean } {
24
+ const idle = slots.find((slot) => !slot.held);
25
+ if (!idle) {
26
+ throw new Error("checkout timeout");
27
+ }
28
+ return idle;
29
+ }
30
+
31
+ function rowsFor(sql: string): Record<string, unknown>[] {
32
+ return /select/i.test(sql) && !/set_config/i.test(sql) ? [{ id: "a", owner: "alice" }] : [];
33
+ }
34
+
35
+ const client: PostgresClientLike & {
36
+ readonly reserveCalls: { n: number };
37
+ readonly rootBegins: { n: number };
38
+ } = {
39
+ reserveCalls,
40
+ rootBegins,
41
+ async reserve() {
42
+ reserveCalls.n += 1;
43
+ const slot = checkout();
44
+ slot.held = true;
45
+ return {
46
+ async unsafe(sql) {
47
+ return rowsFor(sql);
48
+ },
49
+ release() {
50
+ slot.held = false;
51
+ },
52
+ };
53
+ },
54
+ async unsafe(sql) {
55
+ const slot = checkout();
56
+ const head = sql.trim().split(/\s+/)[0]?.toUpperCase();
57
+ if (head === "BEGIN") {
58
+ rootBegins.n += 1;
59
+ slot.held = true;
60
+ return [];
61
+ }
62
+ if (head === "COMMIT" || head === "ROLLBACK") {
63
+ slot.held = false;
64
+ return [];
65
+ }
66
+ return rowsFor(sql);
67
+ },
68
+ };
69
+ return client;
70
+ }
71
+
72
+ describe("sql-session RLS stamp on pooled postgres", () => {
73
+ test("pins the stamp through reserve — no leaked root BEGIN", async () => {
74
+ const pool = createTinyPoolClient(2);
75
+ const connection = await connectPostgres({ client: pool, role: "primary" });
76
+ const handle = createSqlStoreHandle("sql:app", {
77
+ connection,
78
+ classifications: new Map(),
79
+ routedRole: "primary",
80
+ domainDdl: "off",
81
+ rls: { gate: "member", userId: "alice", scopes: ["member"] },
82
+ });
83
+
84
+ const [a, b] = await Promise.all([
85
+ handle.raw(`SELECT id, owner FROM notes`),
86
+ handle.raw(`SELECT id, owner FROM notes`),
87
+ ]);
88
+
89
+ expect(a).toEqual([{ id: "a", owner: "alice" }]);
90
+ expect(b).toEqual([{ id: "a", owner: "alice" }]);
91
+ expect(pool.reserveCalls.n).toBe(2);
92
+ expect(pool.rootBegins.n).toBe(0);
93
+ });
94
+
95
+ test("table browse SELECT pins through reserve like any other stamped query", async () => {
96
+ const pool = createTinyPoolClient(2);
97
+ const connection = await connectPostgres({ client: pool, role: "primary" });
98
+ const handle = createSqlStoreHandle("sql:app", {
99
+ connection,
100
+ classifications: new Map(),
101
+ routedRole: "primary",
102
+ domainDdl: "off",
103
+ rls: { gate: "member", userId: "alice", scopes: ["member"] },
104
+ });
105
+ const rows = await handle.raw(`SELECT * FROM "notes" LIMIT 50`);
106
+ expect(rows).toEqual([{ id: "a", owner: "alice" }]);
107
+ expect(pool.reserveCalls.n).toBe(1);
108
+ expect(pool.rootBegins.n).toBe(0);
109
+ });
110
+
111
+ test("standalone BEGIN on a 2-slot pool reproduces checkout timeout", async () => {
112
+ const pool = createTinyPoolClient(2);
113
+ await expect(pool.unsafe("BEGIN")).resolves.toEqual([]);
114
+ await expect(pool.unsafe("BEGIN")).resolves.toEqual([]);
115
+ await expect(pool.unsafe("SET LOCAL ROLE oke_app")).rejects.toThrow("checkout timeout");
116
+ });
117
+ });
@@ -7,6 +7,12 @@
7
7
 
8
8
  import type { DomainDdlMode } from "../../config/index.ts";
9
9
  import type { ClassificationMap, SqlConnection, SqlRow } from "../../drivers/types.ts";
10
+ import {
11
+ buildRlsIdentityPreludeSql,
12
+ installOkeRlsHelpers,
13
+ RLS_CONTEXT_DRIVERS,
14
+ type RlsIdentity,
15
+ } from "../../drivers/pg-rls.ts";
10
16
  import { throwOke } from "../../kernel/errors.ts";
11
17
  import { maskRows, tableFromSql } from "./classify.ts";
12
18
  import { isMissingDomainRelationError } from "./missing-relation.ts";
@@ -28,6 +34,45 @@ import {
28
34
 
29
35
  export type { WhereMap } from "./sql-condition.ts";
30
36
 
37
+ /** Serialize stamp frames on a shared connection so concurrent identities cannot interleave. */
38
+ const rlsStampTails = new WeakMap<SqlConnection, Promise<unknown>>();
39
+
40
+ /**
41
+ * True when SQL must not enter an RLS identity frame (txn control or DDL).
42
+ *
43
+ * @param sql - Statement
44
+ */
45
+ export function isRlsStampExemptSql(sql: string): boolean {
46
+ return /^\s*(begin|commit|rollback|create|alter|drop|truncate|grant|revoke|comment)\b/i.test(sql);
47
+ }
48
+
49
+ /**
50
+ * Run `fn` after any in-flight stamp on `connection` finishes.
51
+ *
52
+ * @param connection - Shared SQL connection
53
+ * @param fn - Stamp frame
54
+ */
55
+ async function withRlsStampLock<T>(connection: SqlConnection, fn: () => Promise<T>): Promise<T> {
56
+ const prev = rlsStampTails.get(connection) ?? Promise.resolve();
57
+ let release!: () => void;
58
+ const gate = new Promise<void>((resolve) => {
59
+ release = resolve;
60
+ });
61
+ rlsStampTails.set(
62
+ connection,
63
+ prev.then(
64
+ () => gate,
65
+ () => gate,
66
+ ),
67
+ );
68
+ await prev.catch(() => undefined);
69
+ try {
70
+ return await fn();
71
+ } finally {
72
+ release();
73
+ }
74
+ }
75
+
31
76
  /** Options for a SQL session. */
32
77
  export interface SqlSessionOptions {
33
78
  /** Open connection (already role-routed). */
@@ -44,6 +89,8 @@ export interface SqlSessionOptions {
44
89
  * and local+autoPush). Default `ensure` for backward-compatible tests.
45
90
  */
46
91
  readonly domainDdl?: DomainDdlMode;
92
+ /** Gate identity — stamps `oke.*` on postgres / pglite. */
93
+ readonly rls?: RlsIdentity;
47
94
  }
48
95
 
49
96
  /**
@@ -339,8 +386,9 @@ export function createSqlStoreHandle(
339
386
  ref: `sql:${string}`,
340
387
  options: SqlSessionOptions,
341
388
  ): SqlStoreHandle {
342
- const { connection, classifications, revealPii } = options;
389
+ const { connection, classifications, revealPii, rls } = options;
343
390
  const domainDdl: DomainDdlMode = options.domainDdl ?? "ensure";
391
+ let helpersReady = false;
344
392
 
345
393
  function mask(rows: SqlRow[], table?: string): SqlRow[] {
346
394
  return maskRows(rows, { classifications, table, revealPii });
@@ -362,11 +410,41 @@ export function createSqlStoreHandle(
362
410
  }
363
411
 
364
412
  function query(sql: string, params: readonly unknown[] = []): Promise<SqlRow[]> {
365
- return withSchemaGuard(() => connection.query(sql, params));
413
+ return withSchemaGuard(() => withRlsStamp((conn) => conn.query(sql, params), sql));
366
414
  }
367
415
 
368
416
  function exec(sql: string, params: readonly unknown[] = []): Promise<{ changes: number }> {
369
- return withSchemaGuard(() => connection.exec(sql, params));
417
+ return withSchemaGuard(() => withRlsStamp((conn) => conn.exec(sql, params), sql));
418
+ }
419
+
420
+ async function withRlsStamp<T>(fn: (conn: SqlConnection) => Promise<T>, sql: string): Promise<T> {
421
+ if (!rls || !RLS_CONTEXT_DRIVERS.has(connection.driverId)) return fn(connection);
422
+ if (isRlsStampExemptSql(sql)) return fn(connection);
423
+ const run = (): Promise<T> => applyRlsStamp(fn);
424
+ // PGlite is one backend session — concurrent identities must not interleave.
425
+ // Pooled postgres pins each stamp via `transaction()` instead.
426
+ if (connection.driverId === "pglite") {
427
+ return withRlsStampLock(connection, run);
428
+ }
429
+ return run();
430
+ }
431
+
432
+ async function applyRlsStamp<T>(fn: (conn: SqlConnection) => Promise<T>): Promise<T> {
433
+ if (!rls) return fn(connection);
434
+ if (!helpersReady) {
435
+ await installOkeRlsHelpers((sql) => connection.exec(sql));
436
+ helpersReady = true;
437
+ }
438
+ const frame = async (tx: SqlConnection): Promise<T> => {
439
+ for (const stmt of buildRlsIdentityPreludeSql(rls)) {
440
+ await tx.exec(stmt.sql, stmt.params ?? []);
441
+ }
442
+ return fn(tx);
443
+ };
444
+ if (!connection.transaction) {
445
+ throw new Error("RLS stamp needs SqlConnection.transaction to pin SET LOCAL");
446
+ }
447
+ return connection.transaction(frame);
370
448
  }
371
449
 
372
450
  async function ensureFromMeta(table: TableHandle | unknown): Promise<void> {
@@ -45,7 +45,7 @@ describe("fx.store(db).upsert via app.fetch (real boot)", () => {
45
45
  const db = store.sql("app", { schema: { notes } });
46
46
 
47
47
  const seed = on(
48
- http.post("/seed").gate.public,
48
+ http.post("/seed").public(),
49
49
  flow("notes.seed", {
50
50
  in: UpsertIn,
51
51
  out: UpsertOut,