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.
- package/manifest.v1.schema.json +21 -2
- package/package.json +1 -1
- package/site/content/docs/elements/clock.mdx +7 -0
- package/site/content/docs/elements/flow.mdx +14 -12
- package/site/content/docs/elements/gate.mdx +58 -8
- package/site/content/docs/elements/signal.mdx +46 -17
- package/site/content/docs/elements/store.mdx +23 -17
- package/site/content/docs/elements/vault.mdx +23 -0
- package/site/content/docs/get-started/project-structure.mdx +4 -4
- package/site/content/docs/reference/cli.md +1 -1
- package/site/content/docs/reference/client.mdx +72 -17
- package/site/content/docs/reference/configuration.mdx +7 -4
- package/site/content/docs/reference/errors.mdx +24 -17
- package/site/content/docs/reference/fx.mdx +15 -9
- package/src/auth/api-key-sql.ts +11 -4
- package/src/auth/api-keys.ts +3 -0
- package/src/auth/config.ts +19 -0
- package/src/auth/index.ts +37 -0
- package/src/auth/plugin.ts +6 -1
- package/src/auth/sessions.ts +18 -0
- package/src/auth/tables.ts +32 -0
- package/src/auth/tenant-config.ts +74 -0
- package/src/auth/tenant-tables.ts +11 -0
- package/src/auth/tenants.test.ts +63 -0
- package/src/auth/tenants.ts +360 -0
- package/src/cli/build.ts +2 -2
- package/src/client/budget.test.ts +1 -1
- package/src/client/create.ts +75 -5
- package/src/client/index.ts +13 -0
- package/src/client/live.test.ts +422 -0
- package/src/client/live.ts +389 -0
- package/src/client/notes-contract.test.ts +41 -0
- package/src/client/types.ts +85 -6
- package/src/client-react/index.ts +85 -1
- package/src/client-react/use-live.test.ts +129 -0
- package/src/compiler/effects-infer.ts +22 -2
- package/src/compiler/extract.test.ts +143 -11
- package/src/compiler/extract.ts +210 -30
- package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
- package/src/compiler/fixtures/skyport.expected.json +2 -3
- package/src/compiler/response.ts +41 -11
- package/src/console/server/app.ts +44 -11
- package/src/console/server/console.test.ts +6 -8
- package/src/console/server/gates.ts +2 -9
- package/src/console/server/store.test.ts +17 -0
- package/src/console/server/store.ts +43 -1
- package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-CXVWWMmD.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-XDpAJO8f.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-BBj2QJCu.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-BFaay44m.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
- package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-Ca2Z-E52.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-BmOeAFwg.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
- package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
- package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
- package/src/drivers/index.ts +2 -0
- package/src/drivers/journal-postgres.ts +12 -3
- package/src/drivers/pg-rls.ts +26 -2
- package/src/drivers/pg-vault-rls.ts +68 -0
- package/src/drivers/signal-engine.ts +69 -15
- package/src/drivers/signal-live-iter.ts +65 -0
- package/src/drivers/signal-nats.ts +2 -1
- package/src/drivers/signal-postgres.ts +95 -12
- package/src/drivers/signal-redis.ts +2 -1
- package/src/drivers/signal-retention.ts +64 -0
- package/src/drivers/signal-types.ts +35 -5
- package/src/elements/clock/declare.ts +64 -2
- package/src/elements/clock/reconcile.ts +98 -25
- package/src/elements/clock/runtime.ts +13 -2
- package/src/elements/clock.test.ts +28 -0
- package/src/elements/clock.ts +9 -1
- package/src/elements/gate/permissions.ts +12 -0
- package/src/elements/gate.ts +6 -1
- package/src/elements/signal/declare.ts +44 -10
- package/src/elements/signal/delivery-modes.test.ts +90 -4
- package/src/elements/signal/order-lifecycle.test.ts +20 -4
- package/src/elements/signal/runtime.ts +42 -0
- package/src/elements/signal.test.ts +21 -8
- package/src/elements/signal.ts +1 -1
- package/src/elements/store/declare.ts +7 -0
- package/src/elements/store/rls-identity.test.ts +29 -0
- package/src/elements/store/rls-identity.ts +3 -0
- package/src/elements/store/schema-decl.ts +63 -3
- package/src/elements/store/schema-tenant.ts +41 -0
- package/src/elements/store/sql-rls-isolation.test.ts +39 -0
- package/src/elements/store.ts +2 -0
- package/src/elements/vault/builtin-adapter.ts +15 -2
- package/src/elements/vault/declare.ts +8 -0
- package/src/elements/vault/runtime.ts +7 -0
- package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
- package/src/elements/vault/storage.ts +9 -0
- package/src/elements/vault/test-helpers.ts +2 -1
- package/src/i18n/catalogs/ar.ts +19 -0
- package/src/i18n/catalogs/en.ts +19 -0
- package/src/index.ts +1 -0
- package/src/kernel/adopt-routes.ts +56 -8
- package/src/kernel/app-tenant.ts +122 -0
- package/src/kernel/app.ts +183 -56
- package/src/kernel/auth-resolve.ts +3 -0
- package/src/kernel/boot-bind/clock.ts +9 -3
- package/src/kernel/boot.ts +2 -0
- package/src/kernel/budget.test.ts +1 -1
- package/src/kernel/clock-durable.ts +8 -0
- package/src/kernel/clock-per-tenant-name.ts +5 -0
- package/src/kernel/clock-reconcile.ts +8 -0
- package/src/kernel/errors-live-resume.ts +15 -0
- package/src/kernel/errors-tenant.ts +29 -0
- package/src/kernel/errors.registry.test.ts +31 -3
- package/src/kernel/errors.ts +43 -3
- package/src/kernel/flow.ts +26 -5
- package/src/kernel/fx-auth-keys.ts +6 -1
- package/src/kernel/fx-auth-tenants.test.ts +87 -0
- package/src/kernel/fx-auth-tenants.ts +286 -0
- package/src/kernel/fx-live-stream.ts +149 -0
- package/src/kernel/fx-live.test.ts +157 -0
- package/src/kernel/fx-runtime.ts +15 -0
- package/src/kernel/fx-tenant-store.ts +213 -0
- package/src/kernel/fx.test.ts +91 -0
- package/src/kernel/fx.ts +173 -13
- package/src/kernel/hooks.ts +2 -2
- package/src/kernel/http-resource.ts +9 -18
- package/src/kernel/index.ts +2 -0
- package/src/kernel/journal.ts +12 -0
- package/src/kernel/live-http.test.ts +78 -0
- package/src/kernel/live-http.ts +114 -0
- package/src/kernel/live-resume.test.ts +125 -0
- package/src/kernel/on.ts +51 -0
- package/src/kernel/pipeline-tenant.ts +49 -0
- package/src/kernel/pipeline.test.ts +1 -1
- package/src/kernel/pipeline.ts +37 -2
- package/src/kernel/resource-mount.test.ts +10 -27
- package/src/kernel/tenant-resolve.test.ts +101 -0
- package/src/kernel/tenant-resolve.ts +124 -0
- package/src/kernel/tenant-roles.test.ts +87 -0
- package/src/kernel/triggers.ts +59 -21
- package/src/manifest/diff.test.ts +11 -2
- package/src/manifest/diff.ts +53 -11
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +0 -1
- package/src/manifest/types.ts +52 -6
- package/src/release/build-lib.ts +13 -1
- package/src/release/limits.ts +2 -2
- package/src/release/measure.ts +55 -1
- 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
|
*
|
package/src/elements/gate.ts
CHANGED
|
@@ -63,4 +63,9 @@ export {
|
|
|
63
63
|
type TakeRateOptions,
|
|
64
64
|
} from "./gate/strategies.ts";
|
|
65
65
|
|
|
66
|
-
export {
|
|
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
|
-
/**
|
|
12
|
-
export interface
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
190
|
-
|
|
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(
|
|
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 () => {
|
package/src/elements/signal.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|