okengine 0.16.0 → 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 (149) 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 +14 -12
  5. package/site/content/docs/elements/gate.mdx +58 -8
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +23 -17
  8. package/site/content/docs/elements/vault.mdx +23 -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 +72 -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 +15 -9
  15. package/src/auth/api-key-sql.ts +11 -4
  16. package/src/auth/api-keys.ts +3 -0
  17. package/src/auth/config.ts +19 -0
  18. package/src/auth/index.ts +37 -0
  19. package/src/auth/plugin.ts +6 -1
  20. package/src/auth/sessions.ts +18 -0
  21. package/src/auth/tables.ts +32 -0
  22. package/src/auth/tenant-config.ts +74 -0
  23. package/src/auth/tenant-tables.ts +11 -0
  24. package/src/auth/tenants.test.ts +63 -0
  25. package/src/auth/tenants.ts +360 -0
  26. package/src/cli/build.ts +2 -2
  27. package/src/client/budget.test.ts +1 -1
  28. package/src/client/create.ts +75 -5
  29. package/src/client/index.ts +13 -0
  30. package/src/client/live.test.ts +422 -0
  31. package/src/client/live.ts +389 -0
  32. package/src/client/notes-contract.test.ts +41 -0
  33. package/src/client/types.ts +85 -6
  34. package/src/client-react/index.ts +85 -1
  35. package/src/client-react/use-live.test.ts +129 -0
  36. package/src/compiler/effects-infer.ts +22 -2
  37. package/src/compiler/extract.test.ts +143 -11
  38. package/src/compiler/extract.ts +210 -30
  39. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  40. package/src/compiler/fixtures/skyport.expected.json +2 -3
  41. package/src/compiler/response.ts +41 -11
  42. package/src/console/server/app.ts +44 -11
  43. package/src/console/server/console.test.ts +6 -8
  44. package/src/console/server/gates.ts +2 -9
  45. package/src/console/server/store.test.ts +17 -0
  46. package/src/console/server/store.ts +43 -1
  47. package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-CXVWWMmD.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-XDpAJO8f.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-BBj2QJCu.js} +3 -3
  50. package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-BFaay44m.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
  53. package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-Ca2Z-E52.js} +1 -1
  54. package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-BmOeAFwg.js} +1 -1
  55. package/src/console/ui-next/dist/index.html +1 -1
  56. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  57. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  58. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  59. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  60. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  61. package/src/drivers/index.ts +2 -0
  62. package/src/drivers/journal-postgres.ts +12 -3
  63. package/src/drivers/pg-rls.ts +26 -2
  64. package/src/drivers/pg-vault-rls.ts +68 -0
  65. package/src/drivers/signal-engine.ts +69 -15
  66. package/src/drivers/signal-live-iter.ts +65 -0
  67. package/src/drivers/signal-nats.ts +2 -1
  68. package/src/drivers/signal-postgres.ts +95 -12
  69. package/src/drivers/signal-redis.ts +2 -1
  70. package/src/drivers/signal-retention.ts +64 -0
  71. package/src/drivers/signal-types.ts +35 -5
  72. package/src/elements/clock/declare.ts +64 -2
  73. package/src/elements/clock/reconcile.ts +98 -25
  74. package/src/elements/clock/runtime.ts +13 -2
  75. package/src/elements/clock.test.ts +28 -0
  76. package/src/elements/clock.ts +9 -1
  77. package/src/elements/gate/permissions.ts +12 -0
  78. package/src/elements/gate.ts +6 -1
  79. package/src/elements/signal/declare.ts +44 -10
  80. package/src/elements/signal/delivery-modes.test.ts +90 -4
  81. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  82. package/src/elements/signal/runtime.ts +42 -0
  83. package/src/elements/signal.test.ts +21 -8
  84. package/src/elements/signal.ts +1 -1
  85. package/src/elements/store/declare.ts +7 -0
  86. package/src/elements/store/rls-identity.test.ts +29 -0
  87. package/src/elements/store/rls-identity.ts +3 -0
  88. package/src/elements/store/schema-decl.ts +63 -3
  89. package/src/elements/store/schema-tenant.ts +41 -0
  90. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  91. package/src/elements/store.ts +2 -0
  92. package/src/elements/vault/builtin-adapter.ts +15 -2
  93. package/src/elements/vault/declare.ts +8 -0
  94. package/src/elements/vault/runtime.ts +7 -0
  95. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  96. package/src/elements/vault/storage.ts +9 -0
  97. package/src/elements/vault/test-helpers.ts +2 -1
  98. package/src/i18n/catalogs/ar.ts +19 -0
  99. package/src/i18n/catalogs/en.ts +19 -0
  100. package/src/index.ts +1 -0
  101. package/src/kernel/adopt-routes.ts +56 -8
  102. package/src/kernel/app-tenant.ts +122 -0
  103. package/src/kernel/app.ts +183 -56
  104. package/src/kernel/auth-resolve.ts +3 -0
  105. package/src/kernel/boot-bind/clock.ts +9 -3
  106. package/src/kernel/boot.ts +2 -0
  107. package/src/kernel/budget.test.ts +1 -1
  108. package/src/kernel/clock-durable.ts +8 -0
  109. package/src/kernel/clock-per-tenant-name.ts +5 -0
  110. package/src/kernel/clock-reconcile.ts +8 -0
  111. package/src/kernel/errors-live-resume.ts +15 -0
  112. package/src/kernel/errors-tenant.ts +29 -0
  113. package/src/kernel/errors.registry.test.ts +31 -3
  114. package/src/kernel/errors.ts +43 -3
  115. package/src/kernel/flow.ts +26 -5
  116. package/src/kernel/fx-auth-keys.ts +6 -1
  117. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  118. package/src/kernel/fx-auth-tenants.ts +286 -0
  119. package/src/kernel/fx-live-stream.ts +149 -0
  120. package/src/kernel/fx-live.test.ts +157 -0
  121. package/src/kernel/fx-runtime.ts +15 -0
  122. package/src/kernel/fx-tenant-store.ts +213 -0
  123. package/src/kernel/fx.test.ts +91 -0
  124. package/src/kernel/fx.ts +173 -13
  125. package/src/kernel/hooks.ts +2 -2
  126. package/src/kernel/http-resource.ts +9 -18
  127. package/src/kernel/index.ts +2 -0
  128. package/src/kernel/journal.ts +12 -0
  129. package/src/kernel/live-http.test.ts +78 -0
  130. package/src/kernel/live-http.ts +114 -0
  131. package/src/kernel/live-resume.test.ts +125 -0
  132. package/src/kernel/on.ts +51 -0
  133. package/src/kernel/pipeline-tenant.ts +49 -0
  134. package/src/kernel/pipeline.test.ts +1 -1
  135. package/src/kernel/pipeline.ts +37 -2
  136. package/src/kernel/resource-mount.test.ts +10 -27
  137. package/src/kernel/tenant-resolve.test.ts +101 -0
  138. package/src/kernel/tenant-resolve.ts +124 -0
  139. package/src/kernel/tenant-roles.test.ts +87 -0
  140. package/src/kernel/triggers.ts +59 -21
  141. package/src/manifest/diff.test.ts +11 -2
  142. package/src/manifest/diff.ts +53 -11
  143. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  144. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  145. package/src/manifest/types.ts +52 -6
  146. package/src/release/build-lib.ts +13 -1
  147. package/src/release/limits.ts +2 -2
  148. package/src/release/measure.ts +55 -1
  149. package/src/client/live-gap.test.ts +0 -35
@@ -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";
@@ -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
 
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ColumnClassification } from "../../manifest/types.ts";
9
+ import { lazyRequire } from "../../kernel/lazy-require.ts";
9
10
  import type { ColumnDef, TableHandle } from "./table.ts";
10
11
  import { id as idHelper, now as nowHelper } from "./table.ts";
11
12
 
@@ -104,8 +105,14 @@ export interface SchemaRlsEnableDecl {
104
105
  readonly kind: "schema-rls";
105
106
  }
106
107
 
108
+ /** Opt out of tenant isolation for a schema table when tenancy is on. */
109
+ export interface SchemaTenantScopedDecl {
110
+ readonly kind: "schema-tenant-scoped";
111
+ readonly tenantScoped: false;
112
+ }
113
+
107
114
  /** Third-arg extra for {@link schemaTable}. */
108
- export type SchemaTableExtra = SchemaPolicyDecl | SchemaRlsEnableDecl;
115
+ export type SchemaTableExtra = SchemaPolicyDecl | SchemaRlsEnableDecl | SchemaTenantScopedDecl;
109
116
 
110
117
  /** Options for {@link schemaPolicy} / Gate helpers. */
111
118
  export interface SchemaPolicyOptions {
@@ -129,6 +136,8 @@ export interface SchemaTableDecl extends TableHandle {
129
136
  readonly rls?: boolean;
130
137
  /** Declared policies (emit + Manifest). */
131
138
  readonly policies?: readonly SchemaPolicyDecl[];
139
+ /** When `false`, skip fail-loud tenant-policy requirement. */
140
+ readonly tenantScoped?: boolean;
132
141
  }
133
142
 
134
143
  /**
@@ -424,12 +433,16 @@ export function schemaTable<C extends Record<string, SchemaColumnInput>>(
424
433
  (extra): extra is SchemaPolicyDecl => extra.kind === "schema-policy",
425
434
  );
426
435
  const rls = extras.some((extra) => extra.kind === "schema-rls") || policies.length > 0;
436
+ const unscoped = extras.some(
437
+ (extra) => extra.kind === "schema-tenant-scoped" && extra.tenantScoped === false,
438
+ );
427
439
  const table = {
428
440
  name,
429
441
  columns: stamped,
430
442
  ...stamped,
431
443
  ...(rls ? { rls: true } : {}),
432
444
  ...(policies.length > 0 ? { policies } : {}),
445
+ ...(unscoped ? { tenantScoped: false } : {}),
433
446
  };
434
447
  // Survive columns named `name` or `kind` (they would otherwise shadow
435
448
  // the table discriminant / SQL name).
@@ -467,12 +480,25 @@ function sqlStringLiteral(value: string): string {
467
480
  return `'${value.replaceAll("'", "''")}'`;
468
481
  }
469
482
 
470
- function helperPolicyName(prefix: string, key: string, command: SchemaPolicyFor): string {
483
+ /**
484
+ * Policy name `prefix_key_command`.
485
+ *
486
+ * @param prefix - Helper kind
487
+ * @param key - Gate / column / scope
488
+ * @param command - SQL command
489
+ */
490
+ export function helperPolicyName(prefix: string, key: string, command: SchemaPolicyFor): string {
471
491
  const slug = key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_|_$/g, "");
472
492
  return `${prefix}_${slug}_${command}`;
473
493
  }
474
494
 
475
- function policyPredicates(
495
+ /**
496
+ * USING / WITH CHECK split for a helper expression.
497
+ *
498
+ * @param command - SQL command
499
+ * @param expr - Predicate
500
+ */
501
+ export function policyPredicates(
476
502
  command: SchemaPolicyFor,
477
503
  expr: string,
478
504
  ): { readonly using?: string; readonly withCheck?: string } {
@@ -540,13 +566,46 @@ export type SchemaPolicyApi = typeof schemaPolicy & {
540
566
  readonly gate: typeof schemaPolicyGate;
541
567
  readonly owner: typeof schemaPolicyOwner;
542
568
  readonly scope: typeof schemaPolicyScope;
569
+ readonly tenant: (
570
+ column: string,
571
+ options?: Pick<SchemaPolicyOptions, "for" | "as" | "to">,
572
+ ) => SchemaPolicyDecl;
543
573
  };
544
574
 
575
+ function loadSchemaTenant(): {
576
+ tenant: SchemaPolicyApi["tenant"];
577
+ unscoped: () => SchemaTenantScopedDecl;
578
+ } {
579
+ return lazyRequire(import.meta.dir, ["schema", "tenant"].join("-"));
580
+ }
581
+
582
+ /**
583
+ * Tenant-column policy — `tenant_id = oke.tenant()`.
584
+ *
585
+ * @param column - SQL / JS column name
586
+ * @param options - Command (default `all`)
587
+ */
588
+ export function schemaPolicyTenant(
589
+ column: string,
590
+ options: Pick<SchemaPolicyOptions, "for" | "as" | "to"> = {},
591
+ ): SchemaPolicyDecl {
592
+ return loadSchemaTenant().tenant(column, options);
593
+ }
594
+
595
+ /**
596
+ * Mark a table as globally shared (`tenantScoped: false`).
597
+ * Required when `gate.auth.tenant` is on and the table has no tenant policy.
598
+ */
599
+ export function schemaUnscoped(): SchemaTenantScopedDecl {
600
+ return loadSchemaTenant().unscoped();
601
+ }
602
+
545
603
  /** `store.schema.policy` — raw + Gate helpers. */
546
604
  export const schemaPolicyApi: SchemaPolicyApi = Object.assign(schemaPolicy, {
547
605
  gate: schemaPolicyGate,
548
606
  owner: schemaPolicyOwner,
549
607
  scope: schemaPolicyScope,
608
+ tenant: schemaPolicyTenant,
550
609
  });
551
610
 
552
611
  // ─── Relations (mirrors drizzle-orm `defineRelations`) ───────────────────────
@@ -746,6 +805,7 @@ export const schema = {
746
805
  relations: schemaRelations,
747
806
  rls: schemaRls,
748
807
  policy: schemaPolicyApi,
808
+ unscoped: schemaUnscoped,
749
809
  } as const;
750
810
 
751
811
  /**
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Tenant schema helpers — lazy chunk off Store-only `oke()` graphs.
3
+ *
4
+ * `store.schema.policy.tenant` / `store.schema.unscoped` stay on the public
5
+ * API via getters; this module is loaded on first access.
6
+ */
7
+
8
+ import {
9
+ schemaPolicy,
10
+ helperPolicyName,
11
+ policyPredicates,
12
+ type SchemaPolicyDecl,
13
+ type SchemaPolicyOptions,
14
+ type SchemaTenantScopedDecl,
15
+ } from "./schema-decl.ts";
16
+
17
+ /**
18
+ * Tenant-column policy — `tenant_id = oke.tenant()`.
19
+ *
20
+ * @param column - SQL / JS column name
21
+ * @param options - Command (default `all`)
22
+ */
23
+ export function tenant(
24
+ column: string,
25
+ options: Pick<SchemaPolicyOptions, "for" | "as" | "to"> = {},
26
+ ): SchemaPolicyDecl {
27
+ const command = options.for ?? "all";
28
+ return schemaPolicy(helperPolicyName("tenant", column, command), {
29
+ ...options,
30
+ for: command,
31
+ ...policyPredicates(command, `${column} = oke.tenant()`),
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Mark a table as globally shared (`tenantScoped: false`).
37
+ * Required when `gate.auth.tenant` is on and the table has no tenant policy.
38
+ */
39
+ export function unscoped(): SchemaTenantScopedDecl {
40
+ return { kind: "schema-tenant-scoped", tenantScoped: false };
41
+ }