okengine 0.15.2 → 0.17.0

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 (256) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +7 -0
  4. package/site/content/docs/elements/flow.mdx +20 -12
  5. package/site/content/docs/elements/gate.mdx +108 -3
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +36 -32
  8. package/site/content/docs/elements/vault.mdx +26 -0
  9. package/site/content/docs/get-started/project-structure.mdx +4 -4
  10. package/site/content/docs/reference/cli.md +1 -1
  11. package/site/content/docs/reference/client.mdx +75 -17
  12. package/site/content/docs/reference/configuration.mdx +7 -4
  13. package/site/content/docs/reference/errors.mdx +24 -17
  14. package/site/content/docs/reference/fx.mdx +39 -22
  15. package/site/content/docs/reference/security.md +1 -1
  16. package/src/auth/api-key-sql.test.ts +47 -0
  17. package/src/auth/api-key-sql.ts +261 -0
  18. package/src/auth/api-keys.ts +276 -14
  19. package/src/auth/auth.test.ts +56 -0
  20. package/src/auth/config.ts +24 -5
  21. package/src/auth/index.ts +62 -1
  22. package/src/auth/plugin.ts +6 -1
  23. package/src/auth/schema.ts +8 -0
  24. package/src/auth/sessions.ts +18 -0
  25. package/src/auth/tables.ts +32 -0
  26. package/src/auth/tenant-config.ts +74 -0
  27. package/src/auth/tenant-tables.ts +11 -0
  28. package/src/auth/tenants.test.ts +63 -0
  29. package/src/auth/tenants.ts +360 -0
  30. package/src/cli/attach-host-console.test.ts +37 -0
  31. package/src/cli/attach-host-console.ts +6 -0
  32. package/src/cli/build.ts +2 -2
  33. package/src/client/budget.test.ts +1 -1
  34. package/src/client/create.ts +75 -5
  35. package/src/client/index.ts +13 -0
  36. package/src/client/live.test.ts +422 -0
  37. package/src/client/live.ts +389 -0
  38. package/src/client/notes-contract.test.ts +41 -0
  39. package/src/client/types.ts +85 -6
  40. package/src/client-react/index.ts +85 -1
  41. package/src/client-react/use-live.test.ts +129 -0
  42. package/src/compiler/effects-infer.ts +37 -2
  43. package/src/compiler/extract.test.ts +143 -11
  44. package/src/compiler/extract.ts +210 -30
  45. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  46. package/src/compiler/fixtures/skyport.expected.json +2 -3
  47. package/src/compiler/response.ts +41 -11
  48. package/src/console/server/access.test.ts +116 -8
  49. package/src/console/server/access.ts +70 -23
  50. package/src/console/server/app.ts +45 -11
  51. package/src/console/server/console.test.ts +6 -8
  52. package/src/console/server/dev-identities.test.ts +127 -0
  53. package/src/console/server/dev-identities.ts +229 -0
  54. package/src/console/server/flows-invoke.test.ts +21 -4
  55. package/src/console/server/flows.ts +113 -58
  56. package/src/console/server/gates.test.ts +24 -0
  57. package/src/console/server/gates.ts +10 -30
  58. package/src/console/server/index.ts +1 -0
  59. package/src/console/server/live.test.ts +26 -1
  60. package/src/console/server/live.ts +4 -2
  61. package/src/console/server/operator-db.ts +2 -0
  62. package/src/console/server/state.ts +29 -55
  63. package/src/console/server/store.test.ts +17 -0
  64. package/src/console/server/store.ts +43 -1
  65. package/src/console/ui-next/dist/assets/access-page-CXVWWMmD.js +4 -0
  66. package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
  67. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  68. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  69. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  70. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  71. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  72. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  73. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  74. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  75. package/src/console/ui-next/dist/assets/flows-page-XDpAJO8f.js +1 -0
  76. package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
  77. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  78. package/src/console/ui-next/dist/assets/index-BBj2QJCu.js +66 -0
  79. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  80. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  81. package/src/console/ui-next/dist/assets/observability-page-BFaay44m.js +4 -0
  82. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  83. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  84. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  85. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  86. package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
  87. package/src/console/ui-next/dist/assets/store-page-CS5-aETQ.js +41 -0
  88. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  89. package/src/console/ui-next/dist/assets/tree-expand-toggle-BtyhmWb4.js +55 -0
  90. package/src/console/ui-next/dist/assets/units-page-Ca2Z-E52.js +1 -0
  91. package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
  92. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  93. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  94. package/src/console/ui-next/dist/assets/vault-page-BmOeAFwg.js +2 -0
  95. package/src/console/ui-next/dist/index.html +11 -9
  96. package/src/console/ui-next/index.html +3 -3
  97. package/src/console/ui-next/src/client.ts +167 -0
  98. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  99. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  100. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  101. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  102. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  103. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  104. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  105. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  106. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  107. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  108. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  109. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  110. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  111. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  112. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  113. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  114. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  115. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  116. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  117. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  118. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  119. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  120. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  121. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  122. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  123. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  124. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  125. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  126. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  127. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  128. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  129. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  130. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  131. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  132. package/src/console/ui-next/src/router.tsx +10 -1
  133. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  134. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  135. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  136. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  137. package/src/console/xss.gate.test.ts +1 -0
  138. package/src/drivers/index.ts +2 -0
  139. package/src/drivers/journal-postgres.ts +12 -3
  140. package/src/drivers/pg-rls.ts +26 -2
  141. package/src/drivers/pg-vault-rls.ts +68 -0
  142. package/src/drivers/signal-engine.ts +69 -15
  143. package/src/drivers/signal-live-iter.ts +65 -0
  144. package/src/drivers/signal-nats.ts +2 -1
  145. package/src/drivers/signal-postgres.ts +95 -12
  146. package/src/drivers/signal-redis.ts +2 -1
  147. package/src/drivers/signal-retention.ts +64 -0
  148. package/src/drivers/signal-types.ts +35 -5
  149. package/src/elements/clock/declare.ts +64 -2
  150. package/src/elements/clock/reconcile.ts +98 -25
  151. package/src/elements/clock/runtime.ts +13 -2
  152. package/src/elements/clock.test.ts +28 -0
  153. package/src/elements/clock.ts +9 -1
  154. package/src/elements/gate/declare.ts +2 -0
  155. package/src/elements/gate/permissions.ts +12 -0
  156. package/src/elements/gate.ts +6 -1
  157. package/src/elements/signal/declare.ts +44 -10
  158. package/src/elements/signal/delivery-modes.test.ts +90 -4
  159. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  160. package/src/elements/signal/runtime.ts +42 -0
  161. package/src/elements/signal.test.ts +21 -8
  162. package/src/elements/signal.ts +1 -1
  163. package/src/elements/store/cache.ts +1 -1
  164. package/src/elements/store/declare.ts +7 -0
  165. package/src/elements/store/rls-identity.test.ts +29 -0
  166. package/src/elements/store/rls-identity.ts +3 -0
  167. package/src/elements/store/runtime.ts +14 -0
  168. package/src/elements/store/schema-decl.ts +162 -44
  169. package/src/elements/store/schema-tenant.ts +41 -0
  170. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  171. package/src/elements/store/sql-select.types.test.ts +71 -0
  172. package/src/elements/store/sql-session.ts +54 -32
  173. package/src/elements/store.ts +5 -0
  174. package/src/elements/vault/builtin-adapter.ts +15 -2
  175. package/src/elements/vault/declare.ts +8 -0
  176. package/src/elements/vault/runtime.ts +7 -0
  177. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  178. package/src/elements/vault/storage.ts +9 -0
  179. package/src/elements/vault/test-helpers.ts +2 -1
  180. package/src/i18n/catalogs/ar.ts +19 -0
  181. package/src/i18n/catalogs/en.ts +19 -0
  182. package/src/index.ts +2 -0
  183. package/src/kernel/adopt-routes.ts +56 -8
  184. package/src/kernel/api-key-host-persist.test.ts +143 -0
  185. package/src/kernel/app-tenant.ts +122 -0
  186. package/src/kernel/app.ts +196 -57
  187. package/src/kernel/auth-resolve.ts +13 -5
  188. package/src/kernel/boot-bind/clock.ts +9 -3
  189. package/src/kernel/boot.test.ts +4 -4
  190. package/src/kernel/boot.ts +2 -0
  191. package/src/kernel/budget.test.ts +1 -1
  192. package/src/kernel/clock-durable.ts +8 -0
  193. package/src/kernel/clock-per-tenant-name.ts +5 -0
  194. package/src/kernel/clock-reconcile.ts +8 -0
  195. package/src/kernel/errors-live-resume.ts +15 -0
  196. package/src/kernel/errors-tenant.ts +29 -0
  197. package/src/kernel/errors.registry.test.ts +31 -3
  198. package/src/kernel/errors.ts +43 -3
  199. package/src/kernel/flow.ts +26 -5
  200. package/src/kernel/fx-auth-keys.test.ts +88 -0
  201. package/src/kernel/fx-auth-keys.ts +203 -0
  202. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  203. package/src/kernel/fx-auth-tenants.ts +286 -0
  204. package/src/kernel/fx-live-stream.ts +149 -0
  205. package/src/kernel/fx-live.test.ts +157 -0
  206. package/src/kernel/fx-runtime.ts +15 -0
  207. package/src/kernel/fx-tenant-store.ts +213 -0
  208. package/src/kernel/fx.test.ts +108 -1
  209. package/src/kernel/fx.ts +206 -28
  210. package/src/kernel/hooks.ts +2 -2
  211. package/src/kernel/http-resource.ts +9 -18
  212. package/src/kernel/index.ts +2 -0
  213. package/src/kernel/journal.ts +12 -0
  214. package/src/kernel/live-http.test.ts +78 -0
  215. package/src/kernel/live-http.ts +114 -0
  216. package/src/kernel/live-resume.test.ts +125 -0
  217. package/src/kernel/on.ts +51 -0
  218. package/src/kernel/pipeline-api-key.test.ts +192 -7
  219. package/src/kernel/pipeline-tenant.ts +49 -0
  220. package/src/kernel/pipeline.test.ts +1 -1
  221. package/src/kernel/pipeline.ts +41 -4
  222. package/src/kernel/resource-mount.test.ts +10 -27
  223. package/src/kernel/tenant-resolve.test.ts +101 -0
  224. package/src/kernel/tenant-resolve.ts +124 -0
  225. package/src/kernel/tenant-roles.test.ts +87 -0
  226. package/src/kernel/triggers.ts +59 -21
  227. package/src/manifest/diff.test.ts +11 -2
  228. package/src/manifest/diff.ts +53 -11
  229. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  230. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  231. package/src/manifest/types.ts +58 -6
  232. package/src/release/build-lib.ts +14 -1
  233. package/src/release/limits.ts +2 -2
  234. package/src/release/measure.ts +55 -1
  235. package/src/client/live-gap.test.ts +0 -35
  236. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
  237. package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
  238. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
  239. package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
  240. package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
  241. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
  242. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
  243. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
  244. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
  245. package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
  246. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
  247. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
  248. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
  249. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  250. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
  251. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
  252. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
  253. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
  254. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
  255. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
  256. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
@@ -91,6 +91,18 @@ function collectEffects(manifest: Manifest, kind: "reads" | "writes"): string[]
91
91
  return out;
92
92
  }
93
93
 
94
+ /**
95
+ * Whether a Module:Action is an application (user-plane) scope.
96
+ *
97
+ * Console / operator scopes are `console:*` (Access · API-key plane split).
98
+ * Tenant roles may only grant application scopes.
99
+ *
100
+ * @param action - Module:Action pair
101
+ */
102
+ export function isApplicationScope(action: string): boolean {
103
+ return !action.startsWith("console:");
104
+ }
105
+
94
106
  /**
95
107
  * Format pairs for `oke gates list` stdout.
96
108
  *
@@ -63,4 +63,9 @@ export {
63
63
  type TakeRateOptions,
64
64
  } from "./gate/strategies.ts";
65
65
 
66
- export { deriveModuleActions, flowIdToAction, formatGatesList } from "./gate/permissions.ts";
66
+ export {
67
+ deriveModuleActions,
68
+ flowIdToAction,
69
+ formatGatesList,
70
+ isApplicationScope,
71
+ } from "./gate/permissions.ts";
@@ -7,17 +7,18 @@
7
7
 
8
8
  import type { SignalDelivery } from "../../manifest/types.ts";
9
9
  import { signalRegistry } from "../../kernel/element-registries.ts";
10
+ import { parseDurationMs } from "../clock/duration.ts";
10
11
 
11
- /** Options for {@link signal}. */
12
- export interface SignalOptions {
13
- /**
14
- * Delivery physics — required, no default.
15
- *
16
- * - `once` — competing consumers, retries, DLQ
17
- * - `broadcast` — every subscriber receives a copy
18
- * - `live` — client-subscribable stream (replayable)
19
- */
20
- readonly delivery: SignalDelivery;
12
+ /** Live-tape cap. Omit both fields (or omit `retention`) for an unbounded tape. */
13
+ export interface SignalRetention {
14
+ /** Drop events older than this duration (`"7d"`, `"1h"`, `"30s"`, …). */
15
+ readonly maxAge?: string;
16
+ /** Keep only the newest N live events. */
17
+ readonly maxCount?: number;
18
+ }
19
+
20
+ /** Shared options for every {@link signal} delivery mode. */
21
+ interface SignalOptionsBase {
21
22
  /** Optional human description for Console / docs (falls back to the signal name). */
22
23
  readonly description?: string;
23
24
  /** Max delivery attempts before dead-letter (once). */
@@ -33,6 +34,21 @@ export interface SignalOptions {
33
34
  readonly optional?: boolean;
34
35
  }
35
36
 
37
+ /**
38
+ * Options for {@link signal}.
39
+ *
40
+ * `retention` is live-only — a type error on `once` / `broadcast`.
41
+ */
42
+ export type SignalOptions =
43
+ | (SignalOptionsBase & {
44
+ readonly delivery: "once" | "broadcast";
45
+ readonly retention?: never;
46
+ })
47
+ | (SignalOptionsBase & {
48
+ readonly delivery: "live";
49
+ readonly retention?: SignalRetention;
50
+ });
51
+
36
52
  /**
37
53
  * Declared signal handle — usable as `on(signal, flow)`, `fx.emit(signal, …)`,
38
54
  * and `fx.deadLetters(signal)`.
@@ -52,6 +68,8 @@ export interface SignalDecl<T = unknown> {
52
68
  readonly schema?: unknown;
53
69
  /** Optional orphan-emit allowance. */
54
70
  readonly optional: boolean;
71
+ /** Live-tape cap (`delivery: "live"` only). Omitted = unbounded. */
72
+ readonly retention?: SignalRetention;
55
73
  /** Phantom payload type for typed emits. */
56
74
  readonly _payload?: T;
57
75
  }
@@ -91,6 +109,21 @@ export function signal<T = unknown>(name: string, options: SignalOptions): Signa
91
109
  ) {
92
110
  throw new TypeError(`signal("${name}"): delivery is mandatory (once | broadcast | live)`);
93
111
  }
112
+ const retention = "retention" in options ? options.retention : undefined;
113
+ if (retention !== undefined && options.delivery !== "live") {
114
+ throw new TypeError(`signal("${name}"): retention is only valid with delivery: "live"`);
115
+ }
116
+ if (retention?.maxAge !== undefined && parseDurationMs(retention.maxAge) <= 0) {
117
+ throw new TypeError(
118
+ `signal("${name}"): retention.maxAge must be a duration like "24h" or "30s"`,
119
+ );
120
+ }
121
+ if (
122
+ retention?.maxCount !== undefined &&
123
+ (!Number.isInteger(retention.maxCount) || retention.maxCount < 1)
124
+ ) {
125
+ throw new TypeError(`signal("${name}"): retention.maxCount must be an integer ≥ 1`);
126
+ }
94
127
  const decl: SignalDecl<T> = {
95
128
  name,
96
129
  delivery: options.delivery,
@@ -99,6 +132,7 @@ export function signal<T = unknown>(name: string, options: SignalOptions): Signa
99
132
  deadLetter: options.deadLetter ?? true,
100
133
  schema: options.schema,
101
134
  optional: options.optional ?? false,
135
+ ...(options.delivery === "live" && retention !== undefined ? { retention } : {}),
102
136
  };
103
137
  signalRegistry.push(decl as SignalDecl);
104
138
  return decl;
@@ -20,6 +20,26 @@ import {
20
20
  type SignalDriver,
21
21
  } from "../../drivers/index.ts";
22
22
  import { signal, type SignalDecl } from "./declare.ts";
23
+ import type { LiveEvent } from "../../drivers/signal-types.ts";
24
+
25
+ async function takeLivePayloads(iter: AsyncIterable<LiveEvent>, n: number): Promise<unknown[]> {
26
+ return (await takeLiveEvents(iter, n)).map((e) => e.payload);
27
+ }
28
+
29
+ async function takeLiveEvents(iter: AsyncIterable<LiveEvent>, n: number): Promise<LiveEvent[]> {
30
+ const out: LiveEvent[] = [];
31
+ const it = iter[Symbol.asyncIterator]();
32
+ try {
33
+ while (out.length < n) {
34
+ const step = await it.next();
35
+ if (step.done) break;
36
+ out.push(step.value);
37
+ }
38
+ } finally {
39
+ await it.return?.();
40
+ }
41
+ return out;
42
+ }
23
43
 
24
44
  const drivers: Array<{
25
45
  label: string;
@@ -167,13 +187,79 @@ for (const { label, driver, setup } of drivers) {
167
187
  await bus.emit("seat-feed", { seat: "12C" });
168
188
  await bus.drain();
169
189
 
170
- const late: unknown[] = [];
171
- await bus.live("seat-feed", (payload) => {
172
- late.push(payload);
173
- });
190
+ const late = await takeLivePayloads(bus.live("seat-feed"), 3);
174
191
 
175
192
  // All retained live messages replay — not the Console recentLive cap of 50.
176
193
  expect(late).toEqual([{ seat: "12A" }, { seat: "12B" }, { seat: "12C" }]);
177
194
  });
195
+
196
+ test("live: maxCount keeps the newest N", async () => {
197
+ const live = signal("seat-feed", {
198
+ delivery: "live",
199
+ optional: true,
200
+ retention: { maxCount: 2 },
201
+ });
202
+ const bus = await openBus(driver, [live], setup?.() ?? {});
203
+
204
+ for (const seat of ["12A", "12B", "12C", "12D", "12E"]) {
205
+ await bus.emit("seat-feed", { seat });
206
+ }
207
+ await bus.drain();
208
+
209
+ const late = await takeLivePayloads(bus.live("seat-feed"), 2);
210
+ expect(late).toEqual([{ seat: "12D" }, { seat: "12E" }]);
211
+ });
212
+
213
+ test("live: maxAge prunes on emit and on live() open", async () => {
214
+ let t = 1_000;
215
+ const live = signal("seat-feed", {
216
+ delivery: "live",
217
+ optional: true,
218
+ retention: { maxAge: "1s" },
219
+ });
220
+ const bus = await openBus(driver, [live], { now: () => t, ...(setup?.() ?? {}) });
221
+
222
+ await bus.emit("seat-feed", { seat: "old" });
223
+ await bus.drain();
224
+ t += 2_000;
225
+ await bus.emit("seat-feed", { seat: "new" });
226
+ await bus.drain();
227
+
228
+ const late = await takeLivePayloads(bus.live("seat-feed"), 1);
229
+ expect(late).toEqual([{ seat: "new" }]);
230
+ });
231
+
232
+ test("live: afterId skips already-delivered events", async () => {
233
+ const live = signal("seat-feed", { delivery: "live", optional: true });
234
+ const bus = await openBus(driver, [live], setup?.() ?? {});
235
+
236
+ await bus.emit("seat-feed", { seat: "12A" });
237
+ await bus.emit("seat-feed", { seat: "12B" });
238
+ await bus.emit("seat-feed", { seat: "12C" });
239
+ await bus.drain();
240
+
241
+ const first = await takeLiveEvents(bus.live("seat-feed"), 3);
242
+ expect(first.map((e) => e.payload)).toEqual([
243
+ { seat: "12A" },
244
+ { seat: "12B" },
245
+ { seat: "12C" },
246
+ ]);
247
+ const rest = await takeLiveEvents(bus.live("seat-feed", { afterId: first[0]!.id }), 2);
248
+ expect(rest.map((e) => e.payload)).toEqual([{ seat: "12B" }, { seat: "12C" }]);
249
+ });
250
+
251
+ test("live: unknown afterId throws OKE1014", async () => {
252
+ const live = signal("seat-feed", { delivery: "live", optional: true });
253
+ const bus = await openBus(driver, [live], setup?.() ?? {});
254
+ await bus.emit("seat-feed", { seat: "12A" });
255
+ await bus.drain();
256
+
257
+ const it = bus.live("seat-feed", { afterId: "missing" })[Symbol.asyncIterator]();
258
+ try {
259
+ await expect(it.next()).rejects.toMatchObject({ code: 1014 });
260
+ } finally {
261
+ await it.return?.();
262
+ }
263
+ });
178
264
  });
179
265
  }
@@ -13,8 +13,24 @@
13
13
  import { afterEach, describe, expect, test } from "bun:test";
14
14
 
15
15
  import { memorySignalDriver, type SignalBus } from "../../drivers/index.ts";
16
+ import type { LiveEvent } from "../../drivers/signal-types.ts";
16
17
  import { signal } from "./declare.ts";
17
18
 
19
+ async function takeLivePayloads(iter: AsyncIterable<LiveEvent>, n: number): Promise<unknown[]> {
20
+ const out: unknown[] = [];
21
+ const it = iter[Symbol.asyncIterator]();
22
+ try {
23
+ while (out.length < n) {
24
+ const step = await it.next();
25
+ if (step.done) break;
26
+ out.push(step.value.payload);
27
+ }
28
+ } finally {
29
+ await it.return?.();
30
+ }
31
+ return out;
32
+ }
33
+
18
34
  const openBuses: SignalBus[] = [];
19
35
 
20
36
  afterEach(async () => {
@@ -89,10 +105,10 @@ describe("signal order lifecycle · once + broadcast + live", () => {
89
105
  expect(notifyHits).toEqual(["ord_42"]);
90
106
 
91
107
  // live: late subscriber replays the full retained status history
92
- const feed: Array<{ orderId: string; status: string }> = [];
93
- await bus.live("order-status", (payload) => {
94
- feed.push(payload as { orderId: string; status: string });
95
- });
108
+ const feed = (await takeLivePayloads(bus.live("order-status"), 3)) as Array<{
109
+ orderId: string;
110
+ status: string;
111
+ }>;
96
112
  expect(feed).toEqual([
97
113
  { orderId: "ord_42", status: "placed" },
98
114
  { orderId: "ord_42", status: "fulfilling" },
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type {
7
7
  DeadLetter,
8
+ LiveEvent,
8
9
  SignalBus,
9
10
  SignalDriver,
10
11
  SignalEmitOptions,
@@ -59,6 +60,20 @@ export interface SignalRuntime {
59
60
  * @param name - Signal name
60
61
  */
61
62
  deadLetters(name: string): Promise<readonly DeadLetter[]>;
63
+ /**
64
+ * Live feed (auto-starts). Replays history then new events.
65
+ *
66
+ * @param name - Signal name
67
+ * @param opts - Optional resume cursor
68
+ */
69
+ live(name: string, opts?: { readonly afterId?: string }): AsyncIterable<LiveEvent>;
70
+ /**
71
+ * Validate a live resume cursor (auto-starts). Throws OKE1014 when missing.
72
+ *
73
+ * @param name - Signal name
74
+ * @param afterId - SSE cursor
75
+ */
76
+ checkLiveResume(name: string, afterId: string): Promise<void>;
62
77
  /** Close the bus. */
63
78
  close(): Promise<void>;
64
79
  }
@@ -102,6 +117,33 @@ export function createSignalRuntime(options: CreateSignalRuntimeOptions): Signal
102
117
  const b = await this.start();
103
118
  return b.deadLetters(name);
104
119
  },
120
+ live(name, opts) {
121
+ return {
122
+ [Symbol.asyncIterator]() {
123
+ let inner: AsyncIterator<LiveEvent> | undefined;
124
+ return {
125
+ async next() {
126
+ if (!inner) {
127
+ const b = await runtime.start();
128
+ inner = b.live(name, opts)[Symbol.asyncIterator]();
129
+ }
130
+ return inner.next();
131
+ },
132
+ async return() {
133
+ if (!inner) {
134
+ const b = await runtime.start();
135
+ inner = b.live(name, opts)[Symbol.asyncIterator]();
136
+ }
137
+ return inner.return?.() ?? { done: true, value: undefined };
138
+ },
139
+ };
140
+ },
141
+ };
142
+ },
143
+ async checkLiveResume(name, afterId) {
144
+ const b = await runtime.start();
145
+ await b.checkLiveResume(name, afterId);
146
+ },
105
147
  async close() {
106
148
  if (bus) {
107
149
  await bus.close();
@@ -86,6 +86,20 @@ describe("signal declaration", () => {
86
86
  expect(s.delivery).toBe("once");
87
87
  expect(s.name).toBe("order-placed");
88
88
  });
89
+
90
+ test("retention is live-only", () => {
91
+ expect(() =>
92
+ // @ts-expect-error retention is live-only
93
+ signal("order-placed", { delivery: "once", retention: { maxCount: 2 } }),
94
+ ).toThrow(/retention is only valid/);
95
+
96
+ const live = signal("order-status", {
97
+ delivery: "live",
98
+ optional: true,
99
+ retention: { maxAge: "24h", maxCount: 500 },
100
+ });
101
+ expect(live.retention).toEqual({ maxAge: "24h", maxCount: 500 });
102
+ });
89
103
  });
90
104
 
91
105
  for (const { label, driver, setup } of drivers) {
@@ -184,17 +198,16 @@ for (const { label, driver, setup } of drivers) {
184
198
  });
185
199
 
186
200
  test("live is client-subscribable", async () => {
187
- const live = signal("seat-feed", { delivery: "live" });
201
+ const live = signal("seat-feed", { delivery: "live", optional: true });
188
202
  const bus = await openBus(driver, [live], setup?.() ?? {});
189
- const frames: unknown[] = [];
190
- await bus.live("seat-feed", (payload) => {
191
- frames.push(payload);
192
- });
193
-
203
+ const it = bus.live("seat-feed")[Symbol.asyncIterator]();
204
+ const pending = it.next();
194
205
  await bus.emit("seat-feed", { seat: "12A" });
195
206
  await bus.drain();
196
-
197
- expect(frames).toEqual([{ seat: "12A" }]);
207
+ const step = await pending;
208
+ expect(step.done).toBe(false);
209
+ expect(step.value?.payload).toEqual({ seat: "12A" });
210
+ await it.return?.();
198
211
  });
199
212
 
200
213
  test("DLQ preserves typed failure reasons per attempt", async () => {
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  export { signal } from "./signal/declare.ts";
10
- export type { SignalDecl, SignalOptions } from "./signal/declare.ts";
10
+ export type { SignalDecl, SignalOptions, SignalRetention } from "./signal/declare.ts";
11
11
  export type { DeadLetter, SignalFailureReason } from "../drivers/signal-types.ts";
12
12
 
13
13
  export { createSignalRuntime } from "./signal/runtime.ts";
@@ -63,7 +63,7 @@ export function tier1KeysForReads(
63
63
  * @param effects - Write flow effects
64
64
  */
65
65
  export function resourcesTouchedByWrites(effects: Effects): ResourceRef[] {
66
- return [...(effects.writes ?? [])];
66
+ return (effects.writes ?? []).filter((r): r is ResourceRef => isStoreResourceRef(r));
67
67
  }
68
68
 
69
69
  /**
@@ -79,6 +79,11 @@ export interface KvStoreOptions {
79
79
  * not the cache Redis (`REDIS_URL`). Distinct from Flow `durable`.
80
80
  */
81
81
  readonly durable?: boolean;
82
+ /**
83
+ * Prefix keys with `{tenantId}:` when `gate.auth.tenant` is on.
84
+ * Default `true` then; set `false` for genuinely global namespaces.
85
+ */
86
+ readonly tenantScoped?: boolean;
82
87
  }
83
88
 
84
89
  /** KV store declaration. */
@@ -87,6 +92,7 @@ export interface KvStoreDecl extends StoreDeclBase {
87
92
  readonly ref: `kv:${string}`;
88
93
  readonly description?: string;
89
94
  readonly durable?: boolean;
95
+ readonly tenantScoped?: boolean;
90
96
  }
91
97
 
92
98
  /** Options for {@link store.files}. */
@@ -185,6 +191,7 @@ export function kv(name: string, options: KvStoreOptions = {}): KvStoreDecl {
185
191
  ref: `kv:${name}`,
186
192
  ...(options.description !== undefined ? { description: options.description } : {}),
187
193
  ...(options.durable === true ? { durable: true } : {}),
194
+ ...(options.tenantScoped !== undefined ? { tenantScoped: options.tenantScoped } : {}),
188
195
  };
189
196
  storeRegistry.push(decl);
190
197
  return decl;
@@ -93,3 +93,32 @@ describe("firstPolicyOrPublic", () => {
93
93
  expect(firstPolicyOrPublic(["public"])).toBe("public");
94
94
  });
95
95
  });
96
+
97
+ describe("rlsIdentityFromAuth — tenant GUC", () => {
98
+ test("stamps tenantId only when the field is provided", () => {
99
+ expect(
100
+ rlsIdentityFromAuth({
101
+ userId: "u1",
102
+ scopes: new Set(["member"]),
103
+ gateNames: ["member"],
104
+ tenantId: "acme",
105
+ }),
106
+ ).toEqual({
107
+ gate: "member",
108
+ userId: "u1",
109
+ scopes: ["member"],
110
+ tenantId: "acme",
111
+ });
112
+ expect(
113
+ rlsIdentityFromAuth({
114
+ userId: "u1",
115
+ scopes: new Set(["member"]),
116
+ gateNames: ["member"],
117
+ }),
118
+ ).toEqual({
119
+ gate: "member",
120
+ userId: "u1",
121
+ scopes: ["member"],
122
+ });
123
+ });
124
+ });
@@ -66,6 +66,8 @@ export function rlsIdentityFromAuth(input: {
66
66
  readonly gateNames: readonly string[];
67
67
  readonly bypass?: boolean;
68
68
  readonly operator?: boolean;
69
+ /** When set (tenancy on), stamp `oke.tenant` — empty string if unresolved. */
70
+ readonly tenantId?: string | null;
69
71
  }): RlsIdentity | null {
70
72
  if (input.bypass === true || input.operator === true) return null;
71
73
  const gate = firstPolicyOrPublic(input.gateNames);
@@ -75,6 +77,7 @@ export function rlsIdentityFromAuth(input: {
75
77
  gate,
76
78
  userId: input.userId ?? "",
77
79
  scopes,
80
+ ...(input.tenantId !== undefined ? { tenantId: input.tenantId ?? "" } : {}),
78
81
  };
79
82
  }
80
83
 
@@ -239,6 +239,13 @@ export interface StoreRuntime {
239
239
  * @param ref - `kv:name`
240
240
  */
241
241
  kvNamespace(ref: ResourceRef): Promise<KvNamespace>;
242
+ /**
243
+ * Shared primary SQL connection (`sharedSqlConn`).
244
+ *
245
+ * Host auth persist (`oke_api_keys`) and durable KV borrow this same
246
+ * connection — never a second open against `store.sql()` / `DATABASE_URL`.
247
+ */
248
+ primarySql(): Promise<SqlConnection | undefined>;
242
249
  /**
243
250
  * Register a declaration (usually done at module load).
244
251
  *
@@ -545,6 +552,13 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
545
552
  if (!ns) throw new Error(`Unknown kv ref: ${ref}`);
546
553
  return ns;
547
554
  },
555
+ async primarySql() {
556
+ const sqlDriver = options.drivers.sql;
557
+ if (!sqlDriver) return undefined;
558
+ const firstSql = Object.values(options.sql ?? [])[0];
559
+ const url = options.sqlUrl ?? firstSql?.primary.url;
560
+ return sharedSqlConn(sqlDriver, "primary", { url });
561
+ },
548
562
  onWriteEffects(effects) {
549
563
  return cache.invalidateFromEffects(effects);
550
564
  },