okengine 0.15.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +20 -12
- package/site/content/docs/elements/gate.mdx +108 -3
- package/site/content/docs/elements/signal.mdx +46 -17
- package/site/content/docs/elements/store.mdx +36 -32
- package/site/content/docs/elements/vault.mdx +26 -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 +75 -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 +39 -22
- package/site/content/docs/reference/security.md +1 -1
- package/src/auth/api-key-sql.test.ts +47 -0
- package/src/auth/api-key-sql.ts +261 -0
- package/src/auth/api-keys.ts +276 -14
- package/src/auth/auth.test.ts +56 -0
- package/src/auth/config.ts +24 -5
- package/src/auth/index.ts +62 -1
- package/src/auth/plugin.ts +6 -1
- package/src/auth/schema.ts +8 -0
- 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/attach-host-console.test.ts +37 -0
- package/src/cli/attach-host-console.ts +6 -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 +37 -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/access.test.ts +116 -8
- package/src/console/server/access.ts +70 -23
- package/src/console/server/app.ts +45 -11
- package/src/console/server/console.test.ts +6 -8
- package/src/console/server/dev-identities.test.ts +127 -0
- package/src/console/server/dev-identities.ts +229 -0
- package/src/console/server/flows-invoke.test.ts +21 -4
- package/src/console/server/flows.ts +113 -58
- package/src/console/server/gates.test.ts +24 -0
- package/src/console/server/gates.ts +10 -30
- package/src/console/server/index.ts +1 -0
- package/src/console/server/live.test.ts +26 -1
- package/src/console/server/live.ts +4 -2
- package/src/console/server/operator-db.ts +2 -0
- package/src/console/server/state.ts +29 -55
- 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-CXVWWMmD.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
- package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
- package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
- package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
- package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-XDpAJO8f.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
- package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
- package/src/console/ui-next/dist/assets/index-BBj2QJCu.js +66 -0
- package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
- package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
- package/src/console/ui-next/dist/assets/observability-page-BFaay44m.js +4 -0
- package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
- package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
- package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-CS5-aETQ.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BtyhmWb4.js +55 -0
- package/src/console/ui-next/dist/assets/units-page-Ca2Z-E52.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
- package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
- package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-BmOeAFwg.js +2 -0
- package/src/console/ui-next/dist/index.html +11 -9
- package/src/console/ui-next/index.html +3 -3
- package/src/console/ui-next/src/client.ts +167 -0
- package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
- package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
- package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
- package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
- package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
- package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
- package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
- package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
- package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
- package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
- package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
- package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
- package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
- package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
- package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
- package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
- package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
- package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
- package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
- package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
- package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -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/src/lib/document-meta.test.ts +1 -0
- package/src/console/ui-next/src/lib/document-meta.ts +2 -1
- package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
- package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
- package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
- package/src/console/ui-next/src/lib/shortcut.ts +3 -0
- package/src/console/ui-next/src/router.tsx +10 -1
- package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
- 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/console/ui-next/ui-next-seed-store.ts +43 -15
- package/src/console/xss.gate.test.ts +1 -0
- 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/declare.ts +2 -0
- 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/cache.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/runtime.ts +14 -0
- package/src/elements/store/schema-decl.ts +162 -44
- package/src/elements/store/schema-tenant.ts +41 -0
- package/src/elements/store/sql-rls-isolation.test.ts +39 -0
- package/src/elements/store/sql-select.types.test.ts +71 -0
- package/src/elements/store/sql-session.ts +54 -32
- package/src/elements/store.ts +5 -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 +2 -0
- package/src/kernel/adopt-routes.ts +56 -8
- package/src/kernel/api-key-host-persist.test.ts +143 -0
- package/src/kernel/app-tenant.ts +122 -0
- package/src/kernel/app.ts +196 -57
- package/src/kernel/auth-resolve.ts +13 -5
- package/src/kernel/boot-bind/clock.ts +9 -3
- package/src/kernel/boot.test.ts +4 -4
- 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.test.ts +88 -0
- package/src/kernel/fx-auth-keys.ts +203 -0
- 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 +108 -1
- package/src/kernel/fx.ts +206 -28
- 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-api-key.test.ts +192 -7
- package/src/kernel/pipeline-tenant.ts +49 -0
- package/src/kernel/pipeline.test.ts +1 -1
- package/src/kernel/pipeline.ts +41 -4
- 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 +58 -6
- package/src/release/build-lib.ts +14 -1
- package/src/release/limits.ts +2 -2
- package/src/release/measure.ts +55 -1
- package/src/client/live-gap.test.ts +0 -35
- package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
- package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
- package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
- package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
- package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
- package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
- package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
- package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
- package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
- package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
- package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fx.auth` tenant methods — session-only, lazy-attached like API keys.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AUTH_TENANTS_RESOURCE,
|
|
7
|
+
addMember,
|
|
8
|
+
createTenant,
|
|
9
|
+
deleteTenant,
|
|
10
|
+
isMember,
|
|
11
|
+
listMembers,
|
|
12
|
+
listTenantsForUser,
|
|
13
|
+
removeMember,
|
|
14
|
+
TenantError,
|
|
15
|
+
upsertTenantRole,
|
|
16
|
+
type TenantMemberRow,
|
|
17
|
+
type TenantPublicRow,
|
|
18
|
+
type TenantRoleRow,
|
|
19
|
+
type TenantRow,
|
|
20
|
+
type TenantStore,
|
|
21
|
+
} from "../auth/tenants.ts";
|
|
22
|
+
import {
|
|
23
|
+
issueSession,
|
|
24
|
+
type IssuedSession,
|
|
25
|
+
type SessionCrypto,
|
|
26
|
+
type SessionStore,
|
|
27
|
+
} from "../auth/sessions.ts";
|
|
28
|
+
import type { Manifest } from "../manifest/types.ts";
|
|
29
|
+
import { deriveModuleActions } from "../elements/gate/permissions.ts";
|
|
30
|
+
import type { EffectKind } from "./effects.ts";
|
|
31
|
+
import { fail, type FlowFailure } from "./errors.ts";
|
|
32
|
+
import type { FxAuthIdentity } from "./fx-auth-keys.ts";
|
|
33
|
+
|
|
34
|
+
/** Options for {@link FxAuth.createTenant}. */
|
|
35
|
+
export interface FxCreateTenantInput {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly slug?: string | null;
|
|
38
|
+
readonly id?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Options for {@link FxAuth.addMember}. */
|
|
42
|
+
export interface FxAddMemberInput {
|
|
43
|
+
readonly tenantId: string;
|
|
44
|
+
readonly userId: string;
|
|
45
|
+
readonly role?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Options for {@link FxAuth.upsertTenantRole}. */
|
|
49
|
+
export interface FxUpsertTenantRoleInput {
|
|
50
|
+
readonly tenantId: string;
|
|
51
|
+
readonly roleName: string;
|
|
52
|
+
readonly scopes: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Session-only tenant primitives attached onto {@link FxAuthIdentity}.
|
|
57
|
+
*/
|
|
58
|
+
export interface FxAuthTenantMethods {
|
|
59
|
+
listTenants(): Promise<readonly TenantPublicRow[]>;
|
|
60
|
+
switchTenant(id: string): Promise<IssuedSession>;
|
|
61
|
+
createTenant(input: FxCreateTenantInput): Promise<TenantRow>;
|
|
62
|
+
deleteTenant(id: string): Promise<TenantRow>;
|
|
63
|
+
addMember(input: FxAddMemberInput): Promise<TenantMemberRow>;
|
|
64
|
+
removeMember(tenantId: string, userId: string): Promise<TenantMemberRow>;
|
|
65
|
+
listMembers(tenantId: string): Promise<readonly TenantMemberRow[]>;
|
|
66
|
+
upsertTenantRole(input: FxUpsertTenantRoleInput): Promise<TenantRoleRow>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Dependencies for {@link attachTenantMethods}. */
|
|
70
|
+
export interface AttachAuthTenantMethodsOptions {
|
|
71
|
+
readonly auth: FxAuthIdentity;
|
|
72
|
+
readonly store: TenantStore | undefined;
|
|
73
|
+
readonly sessions: SessionStore | undefined;
|
|
74
|
+
readonly crypto: SessionCrypto | undefined;
|
|
75
|
+
readonly manifest: Manifest | undefined;
|
|
76
|
+
readonly now: () => number;
|
|
77
|
+
readonly gated: <T>(kind: EffectKind, resource: string, body: () => T | Promise<T>) => Promise<T>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sessionOnly(auth: FxAuthIdentity): FlowFailure | null {
|
|
81
|
+
if (auth.apiKeyId) {
|
|
82
|
+
return fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "session_only" });
|
|
83
|
+
}
|
|
84
|
+
if (!auth.userId) {
|
|
85
|
+
return fail("Unauthorized", {});
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireStore(store: TenantStore | undefined): TenantStore {
|
|
91
|
+
if (!store) {
|
|
92
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "no_store" });
|
|
93
|
+
}
|
|
94
|
+
return store;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mapTenantError(err: unknown): never {
|
|
98
|
+
if (err instanceof TenantError) {
|
|
99
|
+
throw fail("Forbidden", {
|
|
100
|
+
gate: AUTH_TENANTS_RESOURCE,
|
|
101
|
+
reason: err.reason,
|
|
102
|
+
...(err.scope !== undefined ? { scope: err.scope } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Attach tenant primitives onto a live identity bag.
|
|
110
|
+
*
|
|
111
|
+
* @param options - Auth bag + tenant store + session crypto
|
|
112
|
+
*/
|
|
113
|
+
export function attach(
|
|
114
|
+
options: AttachAuthTenantMethodsOptions,
|
|
115
|
+
): FxAuthIdentity & FxAuthTenantMethods {
|
|
116
|
+
const { auth, now, gated } = options;
|
|
117
|
+
|
|
118
|
+
const methods: FxAuthTenantMethods = {
|
|
119
|
+
listTenants() {
|
|
120
|
+
return gated("read", AUTH_TENANTS_RESOURCE, async () => {
|
|
121
|
+
const denied = sessionOnly(auth);
|
|
122
|
+
if (denied) throw denied;
|
|
123
|
+
const store = requireStore(options.store);
|
|
124
|
+
return listTenantsForUser(store, auth.userId!);
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
switchTenant(id) {
|
|
128
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
129
|
+
const denied = sessionOnly(auth);
|
|
130
|
+
if (denied) throw denied;
|
|
131
|
+
const store = requireStore(options.store);
|
|
132
|
+
if (!isMember(store, id, auth.userId!)) {
|
|
133
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
134
|
+
}
|
|
135
|
+
const sessions = options.sessions;
|
|
136
|
+
const crypto = options.crypto;
|
|
137
|
+
if (!sessions || !crypto) {
|
|
138
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "no_sessions" });
|
|
139
|
+
}
|
|
140
|
+
// New family — tab A rotation cannot reuse-detect tab B.
|
|
141
|
+
// Mint from session scopes, never the live tenant-unioned set.
|
|
142
|
+
const minted = auth.sessionScopes ?? auth.scopes;
|
|
143
|
+
return issueSession(sessions, crypto, {
|
|
144
|
+
id: auth.userId!,
|
|
145
|
+
plane: "user",
|
|
146
|
+
scopes: [...minted],
|
|
147
|
+
tenantId: id,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
createTenant(input) {
|
|
152
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
153
|
+
const denied = sessionOnly(auth);
|
|
154
|
+
if (denied) throw denied;
|
|
155
|
+
const store = requireStore(options.store);
|
|
156
|
+
try {
|
|
157
|
+
return await createTenant(store, {
|
|
158
|
+
name: input.name,
|
|
159
|
+
createdBy: auth.userId!,
|
|
160
|
+
...(input.slug !== undefined ? { slug: input.slug } : {}),
|
|
161
|
+
...(input.id !== undefined ? { id: input.id } : {}),
|
|
162
|
+
now,
|
|
163
|
+
});
|
|
164
|
+
} catch (err) {
|
|
165
|
+
mapTenantError(err);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
deleteTenant(id) {
|
|
170
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
171
|
+
const denied = sessionOnly(auth);
|
|
172
|
+
if (denied) throw denied;
|
|
173
|
+
const store = requireStore(options.store);
|
|
174
|
+
if (!isMember(store, id, auth.userId!)) {
|
|
175
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
176
|
+
}
|
|
177
|
+
const row = await deleteTenant(store, id);
|
|
178
|
+
if (!row) {
|
|
179
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "unknown_tenant" });
|
|
180
|
+
}
|
|
181
|
+
return row;
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
addMember(input) {
|
|
185
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
186
|
+
const denied = sessionOnly(auth);
|
|
187
|
+
if (denied) throw denied;
|
|
188
|
+
const store = requireStore(options.store);
|
|
189
|
+
if (!isMember(store, input.tenantId, auth.userId!)) {
|
|
190
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
return addMember(store, {
|
|
194
|
+
tenantId: input.tenantId,
|
|
195
|
+
userId: input.userId,
|
|
196
|
+
...(input.role !== undefined ? { role: input.role } : {}),
|
|
197
|
+
now,
|
|
198
|
+
});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
mapTenantError(err);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
removeMember(tenantId, userId) {
|
|
205
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
206
|
+
const denied = sessionOnly(auth);
|
|
207
|
+
if (denied) throw denied;
|
|
208
|
+
const store = requireStore(options.store);
|
|
209
|
+
if (!isMember(store, tenantId, auth.userId!)) {
|
|
210
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
211
|
+
}
|
|
212
|
+
const row = removeMember(store, tenantId, userId);
|
|
213
|
+
if (!row) {
|
|
214
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
215
|
+
}
|
|
216
|
+
return row;
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
listMembers(tenantId) {
|
|
220
|
+
return gated("read", AUTH_TENANTS_RESOURCE, async () => {
|
|
221
|
+
const denied = sessionOnly(auth);
|
|
222
|
+
if (denied) throw denied;
|
|
223
|
+
const store = requireStore(options.store);
|
|
224
|
+
if (!isMember(store, tenantId, auth.userId!)) {
|
|
225
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
226
|
+
}
|
|
227
|
+
return listMembers(store, tenantId);
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
upsertTenantRole(input) {
|
|
231
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
232
|
+
const denied = sessionOnly(auth);
|
|
233
|
+
if (denied) throw denied;
|
|
234
|
+
const store = requireStore(options.store);
|
|
235
|
+
if (!isMember(store, input.tenantId, auth.userId!)) {
|
|
236
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
237
|
+
}
|
|
238
|
+
const catalog = options.manifest ? deriveModuleActions(options.manifest) : [];
|
|
239
|
+
try {
|
|
240
|
+
return upsertTenantRole(store, {
|
|
241
|
+
tenantId: input.tenantId,
|
|
242
|
+
roleName: input.roleName,
|
|
243
|
+
scopes: input.scopes,
|
|
244
|
+
catalog,
|
|
245
|
+
now,
|
|
246
|
+
});
|
|
247
|
+
} catch (err) {
|
|
248
|
+
mapTenantError(err);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return Object.assign(auth, methods);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Compact attach from `createFx` so Store-only graphs do not spell the
|
|
259
|
+
* tenant store / session crypto field names.
|
|
260
|
+
*
|
|
261
|
+
* @param auth - Identity after API-key methods
|
|
262
|
+
* @param options - createFx options bag
|
|
263
|
+
* @param now - Clock
|
|
264
|
+
* @param gated - Capability gate
|
|
265
|
+
*/
|
|
266
|
+
export function bind(
|
|
267
|
+
auth: FxAuthIdentity,
|
|
268
|
+
options: {
|
|
269
|
+
readonly tenantStore?: TenantStore;
|
|
270
|
+
readonly sessions?: SessionStore;
|
|
271
|
+
readonly sessionCrypto?: SessionCrypto;
|
|
272
|
+
readonly manifest?: Manifest;
|
|
273
|
+
},
|
|
274
|
+
now: () => number,
|
|
275
|
+
gated: AttachAuthTenantMethodsOptions["gated"],
|
|
276
|
+
): FxAuthIdentity & FxAuthTenantMethods {
|
|
277
|
+
return attach({
|
|
278
|
+
auth,
|
|
279
|
+
store: options.tenantStore,
|
|
280
|
+
sessions: options.sessions,
|
|
281
|
+
crypto: options.sessionCrypto,
|
|
282
|
+
manifest: options.manifest ?? undefined,
|
|
283
|
+
now,
|
|
284
|
+
gated,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
@@ -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";
|