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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fx.live` SSE + Last-Event-ID resume — lazy so Store-only `oke()` graphs
|
|
3
|
+
* do not pin `checkLiveResume` / 410 encoding.
|
|
4
|
+
*
|
|
5
|
+
* Do not import `fx.ts` (type or value): that cycle pulls `createFx` into
|
|
6
|
+
* this chunk the same way a static `fx-auth-keys` import would.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
|
|
10
|
+
import { isDryRun } from "./dry-run.ts";
|
|
11
|
+
import type { EffectKind } from "./effects.ts";
|
|
12
|
+
|
|
13
|
+
const jsonResultBrand = Symbol.for("oke.json");
|
|
14
|
+
const sseFrameBrand = Symbol.for("oke.sse.frame");
|
|
15
|
+
|
|
16
|
+
function sseFrame(data: unknown, id?: string): unknown {
|
|
17
|
+
return id !== undefined ? { [sseFrameBrand]: true, data, id } : { [sseFrameBrand]: true, data };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Minimal bus surface used by {@link createLiveStream}. */
|
|
21
|
+
export interface LiveStreamRuntime {
|
|
22
|
+
live(
|
|
23
|
+
name: string,
|
|
24
|
+
opts?: { readonly afterId?: string },
|
|
25
|
+
): AsyncIterable<{ readonly id: string; readonly payload: unknown }>;
|
|
26
|
+
checkLiveResume(name: string, afterId: string): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Capability gate used by {@link createLiveStream}. */
|
|
30
|
+
export type LiveStreamGate = <T>(
|
|
31
|
+
kind: EffectKind,
|
|
32
|
+
resource: string,
|
|
33
|
+
body: () => T | Promise<T>,
|
|
34
|
+
) => Promise<T>;
|
|
35
|
+
|
|
36
|
+
/** Options for {@link createLiveStream}. */
|
|
37
|
+
export interface CreateLiveStreamOptions {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly afterId?: string;
|
|
40
|
+
readonly match?: (payload: unknown) => boolean;
|
|
41
|
+
readonly gated: LiveStreamGate;
|
|
42
|
+
readonly signalRuntime: LiveStreamRuntime | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* SSE carrier from {@link createLiveStream}.
|
|
47
|
+
*
|
|
48
|
+
* Runtime brand is `Symbol.for("oke.json")` — same as `fx.json.stream`.
|
|
49
|
+
* Callers in `fx.ts` assert this onto `JsonStreamResult`.
|
|
50
|
+
*/
|
|
51
|
+
export interface LiveStreamResult {
|
|
52
|
+
readonly kind: "stream";
|
|
53
|
+
readonly status: 200;
|
|
54
|
+
readonly chunks: AsyncIterable<unknown>;
|
|
55
|
+
ready?: () => Promise<void>;
|
|
56
|
+
finalize?: () => Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the SSE carrier for `fx.live`.
|
|
61
|
+
*
|
|
62
|
+
* @param options - Signal name, cursor, gate, runtime
|
|
63
|
+
*/
|
|
64
|
+
export function createLiveStream(options: CreateLiveStreamOptions): LiveStreamResult {
|
|
65
|
+
const { name, afterId, match, gated, signalRuntime } = options;
|
|
66
|
+
const bind = async (): Promise<boolean> => {
|
|
67
|
+
await gated("read", `signal:${name}`, async () => undefined);
|
|
68
|
+
if (isDryRun()) return false;
|
|
69
|
+
if (!signalRuntime) throw new Error("fx.live requires a bound signal runtime");
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
const chunks = (async function* () {
|
|
73
|
+
if (!(await bind())) return;
|
|
74
|
+
const ambient = currentAbortSignal();
|
|
75
|
+
const local = new AbortController();
|
|
76
|
+
const unlink = linkAbort(ambient, local);
|
|
77
|
+
const iter = signalRuntime!.live(name, { afterId })[Symbol.asyncIterator]();
|
|
78
|
+
const onAbort = (): void => {
|
|
79
|
+
void iter.return?.();
|
|
80
|
+
};
|
|
81
|
+
local.signal.addEventListener("abort", onAbort, { once: true });
|
|
82
|
+
try {
|
|
83
|
+
for (;;) {
|
|
84
|
+
if (local.signal.aborted) break;
|
|
85
|
+
const step = await iter.next();
|
|
86
|
+
if (step.done || local.signal.aborted) break;
|
|
87
|
+
if (match && !match(step.value.payload)) continue;
|
|
88
|
+
yield sseFrame(step.value.payload, step.value.id);
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
local.signal.removeEventListener("abort", onAbort);
|
|
92
|
+
unlink();
|
|
93
|
+
await iter.return?.();
|
|
94
|
+
if (!local.signal.aborted) local.abort();
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
const carrier: LiveStreamResult = {
|
|
98
|
+
kind: "stream",
|
|
99
|
+
status: 200,
|
|
100
|
+
chunks,
|
|
101
|
+
ready: async () => {
|
|
102
|
+
if (afterId && (await bind())) await signalRuntime!.checkLiveResume(name, afterId);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
// Brand at runtime (`isJsonStreamResult`); not on {@link LiveStreamResult}
|
|
106
|
+
// so this file does not import `fx.ts` unique-symbol types (cycle / TS2353).
|
|
107
|
+
return Object.assign(carrier, { [jsonResultBrand]: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Map OKE1014 to HTTP 410 `{ error: { code: "LiveResumeGap" } }`.
|
|
112
|
+
*
|
|
113
|
+
* @param err - Thrown value
|
|
114
|
+
*/
|
|
115
|
+
export function encodeGap(err: unknown): Response | undefined {
|
|
116
|
+
const o = err as { code?: unknown; params?: { signal?: string; afterId?: string } };
|
|
117
|
+
if (o?.code !== 1014) return;
|
|
118
|
+
return Response.json(
|
|
119
|
+
{
|
|
120
|
+
data: null,
|
|
121
|
+
error: {
|
|
122
|
+
code: "LiveResumeGap",
|
|
123
|
+
data: { signal: o.params?.signal ?? "", afterId: o.params?.afterId ?? "" },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{ status: 410 },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Await stream `ready` before the 200 SSE body.
|
|
132
|
+
*
|
|
133
|
+
* @param stream - Live / json.stream carrier
|
|
134
|
+
* @returns 410 response when the cursor is missing; otherwise `undefined`
|
|
135
|
+
*/
|
|
136
|
+
export async function awaitLiveReady(stream: {
|
|
137
|
+
readonly ready?: () => Promise<void>;
|
|
138
|
+
readonly finalize?: () => Promise<void>;
|
|
139
|
+
}): Promise<Response | undefined> {
|
|
140
|
+
try {
|
|
141
|
+
await stream.ready?.();
|
|
142
|
+
} catch (err) {
|
|
143
|
+
await stream.finalize?.();
|
|
144
|
+
const gap = encodeGap(err);
|
|
145
|
+
if (gap) return gap;
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { encodeExecuteResult } from "../compiler/response.ts";
|
|
3
|
+
import { memorySignalDriver } from "../drivers/index.ts";
|
|
4
|
+
import { createSignalRuntime, signal, type SignalDecl } from "../elements/signal.ts";
|
|
5
|
+
import { withAbortSignal } from "./abort-scope.ts";
|
|
6
|
+
import { OkeError } from "./errors.ts";
|
|
7
|
+
import { createFx, createFxContext, isSseFrame, signalReadRef } from "./fx.ts";
|
|
8
|
+
|
|
9
|
+
describe("fx.live", () => {
|
|
10
|
+
test("undeclared signal read throws OKE1001", async () => {
|
|
11
|
+
const orderStatus = signal("order-status", { delivery: "live", optional: true });
|
|
12
|
+
const runtime = openRuntime(orderStatus);
|
|
13
|
+
const fx = createFx({
|
|
14
|
+
flow: "orders.events",
|
|
15
|
+
effects: { reads: [signalReadRef("order-status")] },
|
|
16
|
+
signalRuntime: runtime,
|
|
17
|
+
});
|
|
18
|
+
const other = signal("other-status", { delivery: "live", optional: true });
|
|
19
|
+
let err: unknown;
|
|
20
|
+
try {
|
|
21
|
+
const stream = fx.live(other);
|
|
22
|
+
await stream.chunks[Symbol.asyncIterator]().next();
|
|
23
|
+
} catch (e) {
|
|
24
|
+
err = e;
|
|
25
|
+
}
|
|
26
|
+
expect(err).toBeInstanceOf(OkeError);
|
|
27
|
+
const oke = err as OkeError;
|
|
28
|
+
expect(oke.code).toBe(1001);
|
|
29
|
+
expect(oke.causeText).toBe(
|
|
30
|
+
'Flow "orders.events" reads "signal:other-status" without declaring it.',
|
|
31
|
+
);
|
|
32
|
+
await runtime.close();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("throws when no signal runtime is bound", async () => {
|
|
36
|
+
const fx = createFx({
|
|
37
|
+
flow: "orders.events",
|
|
38
|
+
effects: { reads: [signalReadRef("order-status")] },
|
|
39
|
+
});
|
|
40
|
+
const stream = fx.live("order-status");
|
|
41
|
+
await expect(stream.chunks[Symbol.asyncIterator]().next()).rejects.toThrow(
|
|
42
|
+
"fx.live requires a bound signal runtime",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("ALS abort unsubscribes the live handler", async () => {
|
|
47
|
+
const orderStatus = signal("order-status", { delivery: "live", optional: true });
|
|
48
|
+
const runtime = openRuntime(orderStatus);
|
|
49
|
+
const bus = await runtime.start();
|
|
50
|
+
const { fx } = createFxContext({
|
|
51
|
+
flow: "orders.events",
|
|
52
|
+
effects: { reads: [signalReadRef("order-status")] },
|
|
53
|
+
signalRuntime: runtime,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const ctrl = new AbortController();
|
|
57
|
+
await withAbortSignal(ctrl.signal, async () => {
|
|
58
|
+
const stream = fx.live(orderStatus);
|
|
59
|
+
const it = stream.chunks[Symbol.asyncIterator]();
|
|
60
|
+
const pending = it.next();
|
|
61
|
+
const started = Date.now();
|
|
62
|
+
while ((await bus.inspect("order-status"))[0]?.connections !== 1) {
|
|
63
|
+
if (Date.now() - started > 500) throw new Error("live handler did not attach");
|
|
64
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
65
|
+
}
|
|
66
|
+
ctrl.abort();
|
|
67
|
+
const step = await pending;
|
|
68
|
+
expect(step.done).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const after = await bus.inspect("order-status");
|
|
72
|
+
expect(after[0]?.connections).toBe(0);
|
|
73
|
+
await runtime.close();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("yields branded SSE frames with id + payload", async () => {
|
|
77
|
+
const orderStatus = signal("order-status", { delivery: "live", optional: true });
|
|
78
|
+
const runtime = openRuntime(orderStatus);
|
|
79
|
+
const bus = await runtime.start();
|
|
80
|
+
await runtime.emit("order-status", { orderId: "ord_1", status: "placed" });
|
|
81
|
+
await bus.drain();
|
|
82
|
+
|
|
83
|
+
const { fx, ledger } = createFxContext({
|
|
84
|
+
flow: "orders.events",
|
|
85
|
+
effects: { reads: [signalReadRef("order-status")] },
|
|
86
|
+
signalRuntime: runtime,
|
|
87
|
+
});
|
|
88
|
+
const stream = fx.live(orderStatus);
|
|
89
|
+
const it = stream.chunks[Symbol.asyncIterator]();
|
|
90
|
+
const step = await it.next();
|
|
91
|
+
expect(step.done).toBe(false);
|
|
92
|
+
expect(isSseFrame(step.value)).toBe(true);
|
|
93
|
+
if (isSseFrame(step.value)) {
|
|
94
|
+
expect(step.value.data).toEqual({ orderId: "ord_1", status: "placed" });
|
|
95
|
+
expect(typeof step.value.id).toBe("string");
|
|
96
|
+
}
|
|
97
|
+
expect(ledger.entries.map((e) => `${e.kind}:${e.resource}`)).toEqual([
|
|
98
|
+
"read:signal:order-status",
|
|
99
|
+
]);
|
|
100
|
+
await it.return?.();
|
|
101
|
+
await runtime.close();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("afterId skips already-delivered events", async () => {
|
|
105
|
+
const orderStatus = signal("order-status", { delivery: "live", optional: true });
|
|
106
|
+
const runtime = openRuntime(orderStatus);
|
|
107
|
+
const bus = await runtime.start();
|
|
108
|
+
await runtime.emit("order-status", { orderId: "ord_1", status: "placed" });
|
|
109
|
+
await runtime.emit("order-status", { orderId: "ord_1", status: "shipped" });
|
|
110
|
+
await bus.drain();
|
|
111
|
+
|
|
112
|
+
const first = bus.live("order-status")[Symbol.asyncIterator]();
|
|
113
|
+
const a = await first.next();
|
|
114
|
+
await first.return?.();
|
|
115
|
+
const firstId = a.value?.id as string;
|
|
116
|
+
|
|
117
|
+
const { fx } = createFxContext({
|
|
118
|
+
flow: "orders.events",
|
|
119
|
+
effects: { reads: [signalReadRef("order-status")] },
|
|
120
|
+
signalRuntime: runtime,
|
|
121
|
+
});
|
|
122
|
+
const stream = fx.live(orderStatus, { afterId: firstId });
|
|
123
|
+
const it = stream.chunks[Symbol.asyncIterator]();
|
|
124
|
+
const step = await it.next();
|
|
125
|
+
expect(isSseFrame(step.value) && step.value.data).toEqual({
|
|
126
|
+
orderId: "ord_1",
|
|
127
|
+
status: "shipped",
|
|
128
|
+
});
|
|
129
|
+
await it.return?.();
|
|
130
|
+
await runtime.close();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("ready throws OKE1014 for a missing afterId", async () => {
|
|
134
|
+
const orderStatus = signal("order-status", { delivery: "live", optional: true });
|
|
135
|
+
const runtime = openRuntime(orderStatus);
|
|
136
|
+
const fx = createFx({
|
|
137
|
+
flow: "orders.events",
|
|
138
|
+
effects: { reads: [signalReadRef("order-status")] },
|
|
139
|
+
signalRuntime: runtime,
|
|
140
|
+
});
|
|
141
|
+
const stream = fx.live(orderStatus, { afterId: "missing" });
|
|
142
|
+
await expect(stream.ready?.()).rejects.toMatchObject({ code: 1014 });
|
|
143
|
+
const res = await encodeExecuteResult({ output: stream });
|
|
144
|
+
expect(res.status).toBe(410);
|
|
145
|
+
expect(await res.json()).toMatchObject({
|
|
146
|
+
data: null,
|
|
147
|
+
error: { code: "LiveResumeGap", data: { signal: "order-status", afterId: "missing" } },
|
|
148
|
+
});
|
|
149
|
+
await runtime.close();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
function openRuntime(decl: SignalDecl) {
|
|
154
|
+
const runtime = createSignalRuntime({ driver: memorySignalDriver });
|
|
155
|
+
runtime.register(decl);
|
|
156
|
+
return runtime;
|
|
157
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute-time `fx` helpers — lazy chunk off Store-only `oke()` construction.
|
|
3
|
+
*
|
|
4
|
+
* `app.ts` / HTTP encode load this via computed require so `createFx` does
|
|
5
|
+
* not sit on graphs that never run a Flow.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
createFxContext,
|
|
10
|
+
freezePrincipal,
|
|
11
|
+
isJsonResult,
|
|
12
|
+
isJsonStreamResult,
|
|
13
|
+
isSseFrame,
|
|
14
|
+
resolveName,
|
|
15
|
+
} from "./fx.ts";
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant KV prefix + vault request-time path — lazy chunk.
|
|
3
|
+
*
|
|
4
|
+
* Inlined wrappers would sit on every `createFx` / Store-only `oke()` graph
|
|
5
|
+
* even when `gate.auth.tenant` is off.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { KvStoreDecl } from "../elements/store.ts";
|
|
9
|
+
import type { VaultRuntime } from "../elements/vault.ts";
|
|
10
|
+
import type { Manifest } from "../manifest/types.ts";
|
|
11
|
+
import type { SessionCrypto, SessionStore } from "../auth/sessions.ts";
|
|
12
|
+
import type { TenantStore } from "../auth/tenants.ts";
|
|
13
|
+
import { throwOke } from "./errors.ts";
|
|
14
|
+
import type { FxAuthIdentity } from "./fx-auth-keys.ts";
|
|
15
|
+
import { bind, type AttachAuthTenantMethodsOptions } from "./fx-auth-tenants.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether this KV namespace is tenant-prefixed.
|
|
19
|
+
*
|
|
20
|
+
* @param decl - KV store decl
|
|
21
|
+
* @param tenantEnabled - `gate.auth.tenant` is on
|
|
22
|
+
*/
|
|
23
|
+
export function kvTenantScoped(decl: KvStoreDecl, tenantEnabled: boolean): boolean {
|
|
24
|
+
if (decl.tenantScoped === false) return false;
|
|
25
|
+
if (decl.tenantScoped === true) return true;
|
|
26
|
+
return tenantEnabled;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Prefix KV args with `{tenantId}:`. Fails loud when no tenant is resolved.
|
|
31
|
+
*
|
|
32
|
+
* @param tenantId - Live `fx.tenant.id`
|
|
33
|
+
* @param decl - KV store decl
|
|
34
|
+
* @param tenantEnabled - Tenancy switch
|
|
35
|
+
* @param prop - Handle method
|
|
36
|
+
* @param args - Original args
|
|
37
|
+
*/
|
|
38
|
+
export function rewriteKvArgs(
|
|
39
|
+
tenantId: string | null,
|
|
40
|
+
decl: KvStoreDecl,
|
|
41
|
+
tenantEnabled: boolean,
|
|
42
|
+
prop: string | symbol,
|
|
43
|
+
args: unknown[],
|
|
44
|
+
): unknown[] {
|
|
45
|
+
if (!kvTenantScoped(decl, tenantEnabled)) return args;
|
|
46
|
+
if (!tenantId) throwOke("TENANT_REQUIRED");
|
|
47
|
+
const prefix = `${tenantId}:`;
|
|
48
|
+
if (prop === "list") {
|
|
49
|
+
const userPrefix = typeof args[0] === "string" ? args[0] : "";
|
|
50
|
+
return [`${prefix}${userPrefix}`];
|
|
51
|
+
}
|
|
52
|
+
if (typeof args[0] !== "string") return args;
|
|
53
|
+
return [`${prefix}${args[0]}`, ...args.slice(1)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Strip the tenant prefix from `list()` results.
|
|
58
|
+
*
|
|
59
|
+
* @param tenantId - Live `fx.tenant.id`
|
|
60
|
+
* @param decl - KV store decl
|
|
61
|
+
* @param tenantEnabled - Tenancy switch
|
|
62
|
+
* @param keys - Physical keys
|
|
63
|
+
*/
|
|
64
|
+
export function stripKvPrefix(
|
|
65
|
+
tenantId: string | null,
|
|
66
|
+
decl: KvStoreDecl,
|
|
67
|
+
tenantEnabled: boolean,
|
|
68
|
+
keys: string[],
|
|
69
|
+
): string[] {
|
|
70
|
+
if (!kvTenantScoped(decl, tenantEnabled) || !tenantId) return keys;
|
|
71
|
+
const prefix = `${tenantId}:`;
|
|
72
|
+
return keys.map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Request-time vault path `{tenantId}/{contract}` when the contract is per-tenant.
|
|
77
|
+
*
|
|
78
|
+
* @param tenantId - Live `fx.tenant.id`
|
|
79
|
+
* @param tenantEnabled - Tenancy switch
|
|
80
|
+
* @param contracts - Vault runtime contracts
|
|
81
|
+
* @param contractName - Capability name
|
|
82
|
+
*/
|
|
83
|
+
export function vaultStoragePath(
|
|
84
|
+
tenantId: string | null,
|
|
85
|
+
tenantEnabled: boolean,
|
|
86
|
+
contracts: VaultRuntime["contracts"] | undefined,
|
|
87
|
+
contractName: string,
|
|
88
|
+
): string {
|
|
89
|
+
const decl = contracts?.get(contractName);
|
|
90
|
+
const perTenant =
|
|
91
|
+
decl?.perTenant === true || (tenantEnabled && decl !== undefined && decl.perTenant !== false);
|
|
92
|
+
if (!perTenant) return contractName;
|
|
93
|
+
if (!tenantId) throwOke("TENANT_REQUIRED");
|
|
94
|
+
return `${tenantId}/${contractName}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Compact KV rewrite used by `createFx` so long helper names stay off the
|
|
99
|
+
* Store-only `oke()` graph.
|
|
100
|
+
*
|
|
101
|
+
* @param mode - `in` prefixes args; `out` strips `list()` keys
|
|
102
|
+
* @param tenantId - Live `fx.tenant.id`
|
|
103
|
+
* @param decl - KV store decl
|
|
104
|
+
* @param tenantEnabled - Tenancy switch
|
|
105
|
+
* @param prop - Handle method
|
|
106
|
+
* @param payload - Args or list keys
|
|
107
|
+
*/
|
|
108
|
+
export function kv(
|
|
109
|
+
mode: "in" | "out",
|
|
110
|
+
tenantId: string | null,
|
|
111
|
+
decl: KvStoreDecl,
|
|
112
|
+
tenantEnabled: boolean,
|
|
113
|
+
prop: string | symbol,
|
|
114
|
+
payload: unknown,
|
|
115
|
+
): unknown {
|
|
116
|
+
if (mode === "out") return stripKvPrefix(tenantId, decl, tenantEnabled, payload as string[]);
|
|
117
|
+
return rewriteKvArgs(tenantId, decl, tenantEnabled, prop, payload as unknown[]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Compact vault path used by `createFx`.
|
|
122
|
+
*
|
|
123
|
+
* @param tenantId - Live `fx.tenant.id`
|
|
124
|
+
* @param tenantEnabled - Tenancy switch
|
|
125
|
+
* @param contracts - Vault runtime contracts
|
|
126
|
+
* @param contractName - Capability name
|
|
127
|
+
*/
|
|
128
|
+
export function path(
|
|
129
|
+
tenantId: string | null,
|
|
130
|
+
tenantEnabled: boolean,
|
|
131
|
+
contracts: VaultRuntime["contracts"] | undefined,
|
|
132
|
+
contractName: string,
|
|
133
|
+
): string {
|
|
134
|
+
return vaultStoragePath(tenantId, tenantEnabled, contracts, contractName);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fail loud when a per-tenant vault row is missing.
|
|
139
|
+
*
|
|
140
|
+
* @param storagePath - Physical `{tenantId}/{contract}` path
|
|
141
|
+
*/
|
|
142
|
+
export function missingVault(storagePath: string): Error {
|
|
143
|
+
return new Error(`fx.vault.get: missing per-tenant secret "${storagePath}"`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Attach tenant auth methods and KV prefixing onto a live `fx` bag.
|
|
148
|
+
* Called from `createFx` only when tenancy is on.
|
|
149
|
+
*
|
|
150
|
+
* @param fx - Mutable fx bag
|
|
151
|
+
* @param ctx - Tenant id + createFx options + gates
|
|
152
|
+
*/
|
|
153
|
+
export function install(
|
|
154
|
+
fx: {
|
|
155
|
+
auth: FxAuthIdentity;
|
|
156
|
+
store: (ref: never) => object;
|
|
157
|
+
},
|
|
158
|
+
ctx: {
|
|
159
|
+
readonly tenantId: string | null;
|
|
160
|
+
readonly options: {
|
|
161
|
+
readonly tenantStore?: TenantStore;
|
|
162
|
+
readonly tenantEnabled?: boolean;
|
|
163
|
+
readonly sessions?: SessionStore;
|
|
164
|
+
readonly sessionCrypto?: SessionCrypto;
|
|
165
|
+
readonly manifest?: Manifest | null;
|
|
166
|
+
};
|
|
167
|
+
readonly now: () => number;
|
|
168
|
+
readonly gated: AttachAuthTenantMethodsOptions["gated"];
|
|
169
|
+
},
|
|
170
|
+
): void {
|
|
171
|
+
const next = bind(
|
|
172
|
+
fx.auth,
|
|
173
|
+
{
|
|
174
|
+
tenantStore: ctx.options.tenantStore,
|
|
175
|
+
sessions: ctx.options.sessions,
|
|
176
|
+
sessionCrypto: ctx.options.sessionCrypto,
|
|
177
|
+
manifest: ctx.options.manifest ?? undefined,
|
|
178
|
+
},
|
|
179
|
+
ctx.now,
|
|
180
|
+
ctx.gated,
|
|
181
|
+
);
|
|
182
|
+
fx.auth = next;
|
|
183
|
+
if (ctx.options.tenantEnabled !== true) return;
|
|
184
|
+
const inner = fx.store;
|
|
185
|
+
(fx as { store: typeof inner }).store = ((ref: never) => {
|
|
186
|
+
const handle = inner(ref);
|
|
187
|
+
if (
|
|
188
|
+
typeof ref === "object" &&
|
|
189
|
+
ref !== null &&
|
|
190
|
+
"facet" in ref &&
|
|
191
|
+
(ref as KvStoreDecl).facet === "kv"
|
|
192
|
+
) {
|
|
193
|
+
const decl = ref as KvStoreDecl;
|
|
194
|
+
return new Proxy(handle, {
|
|
195
|
+
get(target, prop, receiver) {
|
|
196
|
+
const val = Reflect.get(target, prop, receiver);
|
|
197
|
+
if (typeof val !== "function" || prop === "then") return val;
|
|
198
|
+
return (...args: unknown[]) => {
|
|
199
|
+
const callArgs = rewriteKvArgs(ctx.tenantId, decl, true, prop, args);
|
|
200
|
+
const result = (val as (...a: unknown[]) => unknown).apply(target, callArgs);
|
|
201
|
+
if (prop === "list") {
|
|
202
|
+
return Promise.resolve(result as Promise<string[]>).then((keys) =>
|
|
203
|
+
stripKvPrefix(ctx.tenantId, decl, true, keys),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return handle;
|
|
212
|
+
}) as typeof inner;
|
|
213
|
+
}
|
package/src/kernel/fx.test.ts
CHANGED
|
@@ -86,6 +86,52 @@ describe("fx — capability enforcement", () => {
|
|
|
86
86
|
expect(ttl).toBeGreaterThan(0);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
test("kv prefixes keys with tenant id and fails loud without one", async () => {
|
|
90
|
+
const { createStoreRuntime, store } = await import("../elements/store.ts");
|
|
91
|
+
const { memoryKvDriver } = await import("../drivers/index.ts");
|
|
92
|
+
const decl = store.kv("drafts");
|
|
93
|
+
const rt = createStoreRuntime({
|
|
94
|
+
drivers: { kv: memoryKvDriver },
|
|
95
|
+
kv: { drafts: {} },
|
|
96
|
+
});
|
|
97
|
+
rt.register(decl);
|
|
98
|
+
const acme = createFx({
|
|
99
|
+
flow: "drafts.acme",
|
|
100
|
+
effects: { reads: ["kv:drafts"], writes: ["kv:drafts"] },
|
|
101
|
+
storeRuntime: rt,
|
|
102
|
+
tenantEnabled: true,
|
|
103
|
+
tenant: { id: "acme" },
|
|
104
|
+
});
|
|
105
|
+
const globex = createFx({
|
|
106
|
+
flow: "drafts.globex",
|
|
107
|
+
effects: { reads: ["kv:drafts"], writes: ["kv:drafts"] },
|
|
108
|
+
storeRuntime: rt,
|
|
109
|
+
tenantEnabled: true,
|
|
110
|
+
tenant: { id: "globex" },
|
|
111
|
+
});
|
|
112
|
+
await acme.store(decl).set("note", "alice");
|
|
113
|
+
await globex.store(decl).set("note", "bob");
|
|
114
|
+
expect(await acme.store(decl).get("note")).toBe("alice");
|
|
115
|
+
expect(await globex.store(decl).get("note")).toBe("bob");
|
|
116
|
+
expect(await acme.store(decl).list()).toEqual(["note"]);
|
|
117
|
+
|
|
118
|
+
const none = createFx({
|
|
119
|
+
flow: "drafts.none",
|
|
120
|
+
effects: { writes: ["kv:drafts"] },
|
|
121
|
+
storeRuntime: rt,
|
|
122
|
+
tenantEnabled: true,
|
|
123
|
+
tenant: { id: null },
|
|
124
|
+
});
|
|
125
|
+
let err: unknown;
|
|
126
|
+
try {
|
|
127
|
+
await none.store(decl).set("note", "x");
|
|
128
|
+
} catch (e) {
|
|
129
|
+
err = e;
|
|
130
|
+
}
|
|
131
|
+
expect(err).toBeInstanceOf(OkeError);
|
|
132
|
+
expect((err as OkeError).code).toBe(1015);
|
|
133
|
+
});
|
|
134
|
+
|
|
89
135
|
test("index driverId is meilisearch before first I/O (not a function, not memory)", async () => {
|
|
90
136
|
const { createStoreRuntime, store } = await import("../elements/store.ts");
|
|
91
137
|
const { meilisearchDriver } = await import("../drivers/index.ts");
|
|
@@ -334,6 +380,28 @@ describe("fx.vault — object surface", () => {
|
|
|
334
380
|
/fx\.vault\.status needs a bound Vault backend/,
|
|
335
381
|
);
|
|
336
382
|
});
|
|
383
|
+
|
|
384
|
+
test("per-tenant contracts store under {tenantId}/{contract}", async () => {
|
|
385
|
+
const { createVaultRuntime, vault } = await import("../elements/vault.ts");
|
|
386
|
+
const { adapter, rows } = memoryAdapter();
|
|
387
|
+
const stripe = vault.secret("STRIPE_KEY", { perTenant: true, dev: "sk_unused" });
|
|
388
|
+
const runtime = createVaultRuntime({
|
|
389
|
+
secrets: [stripe],
|
|
390
|
+
tenantEnabled: true,
|
|
391
|
+
});
|
|
392
|
+
const fx = createFx({
|
|
393
|
+
flow: "billing.key",
|
|
394
|
+
effects: { secrets: ["STRIPE_KEY"] },
|
|
395
|
+
vaultRuntime: runtime,
|
|
396
|
+
vaultAdapter: adapter as never,
|
|
397
|
+
tenantEnabled: true,
|
|
398
|
+
tenant: { id: "acme" },
|
|
399
|
+
});
|
|
400
|
+
await fx.vault.set("STRIPE_KEY", "sk_acme");
|
|
401
|
+
expect([...rows.keys()]).toEqual(["acme/STRIPE_KEY"]);
|
|
402
|
+
const redacted = await fx.vault.get("STRIPE_KEY");
|
|
403
|
+
expect(redacted.reveal()).toBe("sk_acme");
|
|
404
|
+
});
|
|
337
405
|
});
|
|
338
406
|
|
|
339
407
|
describe("fx — wholesale swap", () => {
|
|
@@ -364,6 +432,9 @@ describe("fx — wholesale swap", () => {
|
|
|
364
432
|
async deadLetters() {
|
|
365
433
|
return [];
|
|
366
434
|
},
|
|
435
|
+
live() {
|
|
436
|
+
throw new Error("live must not be used");
|
|
437
|
+
},
|
|
367
438
|
async call() {
|
|
368
439
|
return null;
|
|
369
440
|
},
|
|
@@ -448,6 +519,26 @@ describe("fx — wholesale swap", () => {
|
|
|
448
519
|
updateApiKey: async () => {
|
|
449
520
|
throw new Error("auth.updateApiKey must not be used");
|
|
450
521
|
},
|
|
522
|
+
listTenants: async () => [],
|
|
523
|
+
switchTenant: async () => {
|
|
524
|
+
throw new Error("auth.switchTenant must not be used");
|
|
525
|
+
},
|
|
526
|
+
createTenant: async () => {
|
|
527
|
+
throw new Error("auth.createTenant must not be used");
|
|
528
|
+
},
|
|
529
|
+
deleteTenant: async () => {
|
|
530
|
+
throw new Error("auth.deleteTenant must not be used");
|
|
531
|
+
},
|
|
532
|
+
addMember: async () => {
|
|
533
|
+
throw new Error("auth.addMember must not be used");
|
|
534
|
+
},
|
|
535
|
+
removeMember: async () => {
|
|
536
|
+
throw new Error("auth.removeMember must not be used");
|
|
537
|
+
},
|
|
538
|
+
listMembers: async () => [],
|
|
539
|
+
upsertTenantRole: async () => {
|
|
540
|
+
throw new Error("auth.upsertTenantRole must not be used");
|
|
541
|
+
},
|
|
451
542
|
},
|
|
452
543
|
operator: { id: null },
|
|
453
544
|
principal: { userId: "u1", operatorId: null, scopes: new Set(["a"]) },
|