okengine 0.16.0 → 0.17.1
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/ai/llms-txt.mdx +9 -3
- 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-9Wwx1g4Q.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-DydRDi70.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-Cg9nxd6m.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-D-OzmFOI.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-B_zeux2Y.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-CsMg7XDW.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
package/src/compiler/extract.ts
CHANGED
|
@@ -80,6 +80,7 @@ type DeclaredSchemaTable = {
|
|
|
80
80
|
readonly columns: Record<string, DeclaredColumn>;
|
|
81
81
|
readonly rls?: boolean;
|
|
82
82
|
readonly policies?: Record<string, TablePolicy>;
|
|
83
|
+
readonly tenantScoped?: boolean;
|
|
83
84
|
};
|
|
84
85
|
|
|
85
86
|
interface ProjectScope {
|
|
@@ -212,6 +213,9 @@ export async function extractManifest(options: ExtractManifestOptions = {}): Pro
|
|
|
212
213
|
if (scope.topology) manifest.topology = scope.topology;
|
|
213
214
|
if (scope.images) manifest.images = scope.images;
|
|
214
215
|
|
|
216
|
+
applyTenancyDefaults(manifest);
|
|
217
|
+
assertTenantSafeSchema(manifest);
|
|
218
|
+
|
|
215
219
|
return manifest;
|
|
216
220
|
}
|
|
217
221
|
|
|
@@ -258,7 +262,7 @@ function collectDeclarations(filePath: string, program: AstNode, scope: ProjectS
|
|
|
258
262
|
}
|
|
259
263
|
});
|
|
260
264
|
|
|
261
|
-
// oke({ name })
|
|
265
|
+
// oke({ name, gate: { auth: { tenant } } })
|
|
262
266
|
walk(program, (node) => {
|
|
263
267
|
if (node.type !== "CallExpression") return;
|
|
264
268
|
const call = node as CallExpression;
|
|
@@ -266,6 +270,7 @@ function collectDeclarations(filePath: string, program: AstNode, scope: ProjectS
|
|
|
266
270
|
const opts = objectArg(call.arguments[0]);
|
|
267
271
|
const name = stringProp(opts, "name");
|
|
268
272
|
if (name) scope.app = name;
|
|
273
|
+
applyOkeTenantConfig(opts, scope);
|
|
269
274
|
});
|
|
270
275
|
}
|
|
271
276
|
|
|
@@ -464,21 +469,24 @@ function tableFromDeclared(t: DeclaredSchemaTable): {
|
|
|
464
469
|
columns: Record<string, DeclaredColumn>;
|
|
465
470
|
rls?: boolean;
|
|
466
471
|
policies?: Record<string, TablePolicy>;
|
|
472
|
+
tenantScoped?: boolean;
|
|
467
473
|
} {
|
|
468
474
|
return {
|
|
469
475
|
columns: t.columns,
|
|
470
476
|
...(t.rls ? { rls: true } : {}),
|
|
471
477
|
...(t.policies ? { policies: t.policies } : {}),
|
|
478
|
+
...(t.tenantScoped === false ? { tenantScoped: false } : {}),
|
|
472
479
|
};
|
|
473
480
|
}
|
|
474
481
|
|
|
475
482
|
function parseSchemaTableExtras(
|
|
476
483
|
node: AstNode | undefined,
|
|
477
|
-
): Pick<DeclaredSchemaTable, "rls" | "policies"> {
|
|
484
|
+
): Pick<DeclaredSchemaTable, "rls" | "policies" | "tenantScoped"> {
|
|
478
485
|
if (!node || node.type !== "ArrayExpression") return {};
|
|
479
486
|
const els = (node as AstNode & { elements?: AstNode[] }).elements ?? [];
|
|
480
487
|
const policies: Record<string, TablePolicy> = {};
|
|
481
488
|
let rls = false;
|
|
489
|
+
let tenantScoped: boolean | undefined;
|
|
482
490
|
for (const el of els) {
|
|
483
491
|
if (!el || el.type !== "CallExpression") continue;
|
|
484
492
|
const extra = parseSchemaTableExtra(el as CallExpression);
|
|
@@ -487,18 +495,27 @@ function parseSchemaTableExtras(
|
|
|
487
495
|
rls = true;
|
|
488
496
|
continue;
|
|
489
497
|
}
|
|
498
|
+
if (extra.kind === "unscoped") {
|
|
499
|
+
tenantScoped = false;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
490
502
|
policies[extra.name] = extra.policy;
|
|
491
503
|
rls = true;
|
|
492
504
|
}
|
|
493
505
|
return {
|
|
494
506
|
...(rls ? { rls: true } : {}),
|
|
495
507
|
...(Object.keys(policies).length > 0 ? { policies } : {}),
|
|
508
|
+
...(tenantScoped === false ? { tenantScoped: false } : {}),
|
|
496
509
|
};
|
|
497
510
|
}
|
|
498
511
|
|
|
499
512
|
function parseSchemaTableExtra(
|
|
500
513
|
call: CallExpression,
|
|
501
|
-
):
|
|
514
|
+
):
|
|
515
|
+
| { kind: "rls" }
|
|
516
|
+
| { kind: "unscoped" }
|
|
517
|
+
| { kind: "policy"; name: string; policy: TablePolicy }
|
|
518
|
+
| undefined {
|
|
502
519
|
const callee = call.callee;
|
|
503
520
|
if (callee.type !== "MemberExpression") return undefined;
|
|
504
521
|
const member = callee as AstNode & { object: AstNode; property: AstNode };
|
|
@@ -506,13 +523,16 @@ function parseSchemaTableExtra(
|
|
|
506
523
|
if (method === "rls" && isStoreSchemaCallee(member.object)) {
|
|
507
524
|
return { kind: "rls" };
|
|
508
525
|
}
|
|
526
|
+
if (method === "unscoped" && isStoreSchemaCallee(member.object)) {
|
|
527
|
+
return { kind: "unscoped" };
|
|
528
|
+
}
|
|
509
529
|
if (method === "policy" && isStoreSchemaCallee(member.object)) {
|
|
510
530
|
const name = stringArg(call.arguments[0]);
|
|
511
531
|
if (!name) return undefined;
|
|
512
532
|
return { kind: "policy", name, policy: parseTablePolicyOptions(objectArg(call.arguments[1])) };
|
|
513
533
|
}
|
|
514
534
|
if (
|
|
515
|
-
(method === "gate" || method === "owner" || method === "scope") &&
|
|
535
|
+
(method === "gate" || method === "owner" || method === "scope" || method === "tenant") &&
|
|
516
536
|
isStoreSchemaPolicyCallee(member.object)
|
|
517
537
|
) {
|
|
518
538
|
const key = stringArg(call.arguments[0]);
|
|
@@ -521,9 +541,11 @@ function parseSchemaTableExtra(
|
|
|
521
541
|
const command = (stringProp(opts, "for") ??
|
|
522
542
|
(method === "owner"
|
|
523
543
|
? "all"
|
|
524
|
-
: method === "
|
|
525
|
-
? "
|
|
526
|
-
:
|
|
544
|
+
: method === "tenant"
|
|
545
|
+
? "all"
|
|
546
|
+
: method === "scope"
|
|
547
|
+
? "insert"
|
|
548
|
+
: "select")) as TablePolicy["for"];
|
|
527
549
|
const slug = key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_|_$/g, "");
|
|
528
550
|
const name = `${method}_${slug}_${command}`;
|
|
529
551
|
const lit = `'${key.replaceAll("'", "''")}'`;
|
|
@@ -532,7 +554,9 @@ function parseSchemaTableExtra(
|
|
|
532
554
|
? `oke.gate() = ${lit}`
|
|
533
555
|
: method === "owner"
|
|
534
556
|
? `${key} = oke.user()`
|
|
535
|
-
:
|
|
557
|
+
: method === "tenant"
|
|
558
|
+
? `${key} = oke.tenant()`
|
|
559
|
+
: `oke.has_scope(${lit})`;
|
|
536
560
|
const policy: TablePolicy = {
|
|
537
561
|
for: command,
|
|
538
562
|
...(command === "insert"
|
|
@@ -798,6 +822,8 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
|
|
|
798
822
|
namespaces.add(storeName);
|
|
799
823
|
storeEntry.namespaces = [...namespaces].sort();
|
|
800
824
|
if (boolProp(storeOpts, "durable")) storeEntry.durable = true;
|
|
825
|
+
if (boolProp(storeOpts, "tenantScoped") === false) storeEntry.tenantScoped = false;
|
|
826
|
+
else if (boolProp(storeOpts, "tenantScoped") === true) storeEntry.tenantScoped = true;
|
|
801
827
|
} else if (facet === "files") {
|
|
802
828
|
const buckets = new Set(storeEntry.buckets ?? []);
|
|
803
829
|
buckets.add(storeName);
|
|
@@ -844,6 +870,24 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
|
|
|
844
870
|
}
|
|
845
871
|
}
|
|
846
872
|
|
|
873
|
+
if (obj === "clock" && prop === "perTenant") {
|
|
874
|
+
const name = stringArg(call.arguments[0]);
|
|
875
|
+
const opts = objectArg(call.arguments[1]);
|
|
876
|
+
if (name) {
|
|
877
|
+
scope.clocks[name] = {
|
|
878
|
+
...(stringProp(opts, "every") ? { every: stringProp(opts, "every") } : {}),
|
|
879
|
+
...(stringProp(opts, "cron") ? { cron: stringProp(opts, "cron") } : {}),
|
|
880
|
+
...(boolProp(opts, "overridable") !== undefined
|
|
881
|
+
? { overridable: boolProp(opts, "overridable") }
|
|
882
|
+
: {}),
|
|
883
|
+
...(stringProp(opts, "description")
|
|
884
|
+
? { description: stringProp(opts, "description") }
|
|
885
|
+
: {}),
|
|
886
|
+
perTenant: true,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
847
891
|
if (obj === "gate" && prop === "policy") {
|
|
848
892
|
const policyName = stringArg(call.arguments[0]);
|
|
849
893
|
if (policyName) {
|
|
@@ -1070,6 +1114,8 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
|
|
|
1070
1114
|
: {}),
|
|
1071
1115
|
...(stringProp(opts, "rotate") ? { rotate: stringProp(opts, "rotate") } : {}),
|
|
1072
1116
|
...(prop === "config" ? { sensitive: false } : {}),
|
|
1117
|
+
...(boolProp(opts, "perTenant") === true ? { perTenant: true } : {}),
|
|
1118
|
+
...(boolProp(opts, "perTenant") === false ? { perTenant: false } : {}),
|
|
1073
1119
|
};
|
|
1074
1120
|
const bindingName = enclosingConstName(call, program);
|
|
1075
1121
|
if (bindingName) {
|
|
@@ -1089,6 +1135,16 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
|
|
|
1089
1135
|
if (signalName) {
|
|
1090
1136
|
const delivery = stringProp(opts, "delivery") as SignalDelivery | undefined;
|
|
1091
1137
|
if (delivery) {
|
|
1138
|
+
const retentionObj = objectProp(opts, "retention");
|
|
1139
|
+
const maxAge = retentionObj ? stringProp(retentionObj, "maxAge") : undefined;
|
|
1140
|
+
const maxCount = retentionObj ? numberProp(retentionObj, "maxCount") : undefined;
|
|
1141
|
+
const retention =
|
|
1142
|
+
maxAge !== undefined || maxCount !== undefined
|
|
1143
|
+
? {
|
|
1144
|
+
...(maxAge !== undefined ? { maxAge } : {}),
|
|
1145
|
+
...(maxCount !== undefined ? { maxCount } : {}),
|
|
1146
|
+
}
|
|
1147
|
+
: undefined;
|
|
1092
1148
|
const signal: Signal = {
|
|
1093
1149
|
delivery,
|
|
1094
1150
|
...(stringProp(opts, "description")
|
|
@@ -1100,6 +1156,7 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
|
|
|
1100
1156
|
...(boolProp(opts, "deadLetter") !== undefined
|
|
1101
1157
|
? { deadLetter: boolProp(opts, "deadLetter") }
|
|
1102
1158
|
: {}),
|
|
1159
|
+
...(retention !== undefined ? { retention } : {}),
|
|
1103
1160
|
};
|
|
1104
1161
|
scope.signals[signalName] = signal;
|
|
1105
1162
|
const bindingName = enclosingConstName(call, program);
|
|
@@ -1127,6 +1184,7 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
|
|
|
1127
1184
|
...(stringProp(opts, "description")
|
|
1128
1185
|
? { description: stringProp(opts, "description") }
|
|
1129
1186
|
: {}),
|
|
1187
|
+
...(boolProp(opts, "perTenant") === true ? { perTenant: true } : {}),
|
|
1130
1188
|
};
|
|
1131
1189
|
}
|
|
1132
1190
|
}
|
|
@@ -1177,6 +1235,8 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
|
|
|
1177
1235
|
? { description: stringProp(opts, "description") }
|
|
1178
1236
|
: {}),
|
|
1179
1237
|
...(stringProp(opts, "rotate") ? { rotate: stringProp(opts, "rotate") } : {}),
|
|
1238
|
+
...(boolProp(opts, "perTenant") === true ? { perTenant: true } : {}),
|
|
1239
|
+
...(boolProp(opts, "perTenant") === false ? { perTenant: false } : {}),
|
|
1180
1240
|
};
|
|
1181
1241
|
const bindingName = enclosingConstName(call, program);
|
|
1182
1242
|
if (bindingName) {
|
|
@@ -1250,7 +1310,7 @@ function collectConfig(opts: AstNode | undefined, scope: ProjectScope): void {
|
|
|
1250
1310
|
if (tenancy) {
|
|
1251
1311
|
const isolation = stringProp(tenancy, "isolation");
|
|
1252
1312
|
if (isolation === "row" || isolation === "schema" || isolation === "database") {
|
|
1253
|
-
scope.tenancy = { isolation };
|
|
1313
|
+
scope.tenancy = { ...scope.tenancy, isolation };
|
|
1254
1314
|
}
|
|
1255
1315
|
}
|
|
1256
1316
|
|
|
@@ -1363,8 +1423,9 @@ function collectFlows(file: SourceFile, program: AstNode, scope: ProjectScope):
|
|
|
1363
1423
|
const flowNode = call.arguments[1];
|
|
1364
1424
|
if (!triggerNode) return;
|
|
1365
1425
|
|
|
1366
|
-
// on(http.resource(
|
|
1426
|
+
// on(http.resource(...)) or on(http.get(...).live(signal))
|
|
1367
1427
|
if (!flowNode) {
|
|
1428
|
+
if (registerLiveHttpMount(call, triggerNode, file, program, scope)) return;
|
|
1368
1429
|
registerResourceMount(call, triggerNode, file, program, scope);
|
|
1369
1430
|
return;
|
|
1370
1431
|
}
|
|
@@ -1523,7 +1584,7 @@ function registerFlow(args: {
|
|
|
1523
1584
|
flow.source = `${args.file.path}:${line}`;
|
|
1524
1585
|
|
|
1525
1586
|
if (boolProp(opts, "durable")) flow.durable = true;
|
|
1526
|
-
if (
|
|
1587
|
+
if (typeof liveFromTrigger === "string") flow.live = liveFromTrigger;
|
|
1527
1588
|
if (boolProp(opts, "breaking")) flow.breaking = true;
|
|
1528
1589
|
|
|
1529
1590
|
const slo = parseSlo(objectProp(opts, "slo"));
|
|
@@ -1573,6 +1634,8 @@ function registerFlow(args: {
|
|
|
1573
1634
|
|
|
1574
1635
|
const plane = stringProp(opts, "plane");
|
|
1575
1636
|
if (plane === "user" || plane === "operator") flow.plane = plane;
|
|
1637
|
+
if (boolProp(opts, "tenantScoped") === false) flow.tenantScoped = false;
|
|
1638
|
+
else if (boolProp(opts, "tenantScoped") === true) flow.tenantScoped = true;
|
|
1576
1639
|
|
|
1577
1640
|
if (inferred.cacheIneligible) {
|
|
1578
1641
|
flow.cache = false;
|
|
@@ -1592,7 +1655,7 @@ function registerFlow(args: {
|
|
|
1592
1655
|
const cacheKeys = stringProp(opts, "cacheKeys");
|
|
1593
1656
|
if (cacheKeys) {
|
|
1594
1657
|
flow.cacheKeys = cacheKeys;
|
|
1595
|
-
} else if (
|
|
1658
|
+
} else if (inferred.readsUserId && effects?.reads?.[0]) {
|
|
1596
1659
|
flow.cacheKeys = `computed:${effects.reads[0]}/userId`;
|
|
1597
1660
|
}
|
|
1598
1661
|
|
|
@@ -1600,7 +1663,50 @@ function registerFlow(args: {
|
|
|
1600
1663
|
}
|
|
1601
1664
|
|
|
1602
1665
|
/**
|
|
1603
|
-
* `http.
|
|
1666
|
+
* Synthesize a Manifest flow for one-arg `on(http.get(path).live(signal))`.
|
|
1667
|
+
*
|
|
1668
|
+
* @param onCall - The outer `on(…)` call
|
|
1669
|
+
* @param triggerNode - HTTP trigger expression
|
|
1670
|
+
* @param file - Source file
|
|
1671
|
+
* @param program - Program root (for export-name lookup)
|
|
1672
|
+
* @param scope - Project scope
|
|
1673
|
+
* @returns True when a live HTTP exposure was registered
|
|
1674
|
+
*/
|
|
1675
|
+
function registerLiveHttpMount(
|
|
1676
|
+
onCall: CallExpression,
|
|
1677
|
+
triggerNode: AstNode,
|
|
1678
|
+
file: SourceFile,
|
|
1679
|
+
program: AstNode,
|
|
1680
|
+
scope: ProjectScope,
|
|
1681
|
+
): boolean {
|
|
1682
|
+
const parsed = parseHttpTrigger(triggerNode, scope, file.path);
|
|
1683
|
+
if (typeof parsed?.live !== "string" || parsed.trigger.http === undefined) {
|
|
1684
|
+
return false;
|
|
1685
|
+
}
|
|
1686
|
+
const exportName = enclosingConstName(onCall, program);
|
|
1687
|
+
const name =
|
|
1688
|
+
nameFromFlowFile(file.path, exportName) ??
|
|
1689
|
+
exportName ??
|
|
1690
|
+
`flow_${Object.keys(scope.flows).length + 1}`;
|
|
1691
|
+
if (exportName) {
|
|
1692
|
+
scope.flowExports.set(exportName, name);
|
|
1693
|
+
scope.bindings.set(exportName, { kind: "flow", ref: name });
|
|
1694
|
+
}
|
|
1695
|
+
scope.bindings.set(name, { kind: "flow", ref: name });
|
|
1696
|
+
const line = lineAt(file.source, onCall.start ?? 0);
|
|
1697
|
+
const flow: Flow = {
|
|
1698
|
+
trigger: { http: parsed.trigger.http },
|
|
1699
|
+
live: parsed.live,
|
|
1700
|
+
effects: { reads: [`signal:${parsed.live}`] },
|
|
1701
|
+
source: `${file.path}:${line}`,
|
|
1702
|
+
};
|
|
1703
|
+
if (parsed.gates && parsed.gates.length > 0) flow.gates = parsed.gates;
|
|
1704
|
+
scope.flows[name] = flow;
|
|
1705
|
+
return true;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/**
|
|
1709
|
+
* `http.resource(path, bag).gate(...).public()` — find the resource call
|
|
1604
1710
|
* and collect the same chain {@link parseHttpTrigger} reads on a single verb.
|
|
1605
1711
|
*
|
|
1606
1712
|
* @param node - First argument to `on(…)`
|
|
@@ -1609,11 +1715,10 @@ function registerFlow(args: {
|
|
|
1609
1715
|
function unwrapResourceMount(
|
|
1610
1716
|
node: AstNode,
|
|
1611
1717
|
scope: ProjectScope,
|
|
1612
|
-
): { httpCall: CallExpression; gates?: string[]
|
|
1718
|
+
): { httpCall: CallExpression; gates?: string[] } | undefined {
|
|
1613
1719
|
const chain = flattenMemberCallChain(node);
|
|
1614
1720
|
let httpCall: CallExpression | undefined;
|
|
1615
1721
|
const gateNames: string[] = [];
|
|
1616
|
-
let live = false;
|
|
1617
1722
|
for (const item of chain) {
|
|
1618
1723
|
if (item.type === "CallExpression") {
|
|
1619
1724
|
const c = item as CallExpression;
|
|
@@ -1633,14 +1738,12 @@ function unwrapResourceMount(
|
|
|
1633
1738
|
continue;
|
|
1634
1739
|
}
|
|
1635
1740
|
if (prop === "public") gateNames.push("public");
|
|
1636
|
-
if (prop === "live") live = true;
|
|
1637
1741
|
}
|
|
1638
1742
|
}
|
|
1639
1743
|
if (!httpCall) return undefined;
|
|
1640
1744
|
return {
|
|
1641
1745
|
httpCall,
|
|
1642
1746
|
gates: gateNames.length > 0 ? gateNames : undefined,
|
|
1643
|
-
live: live || undefined,
|
|
1644
1747
|
};
|
|
1645
1748
|
}
|
|
1646
1749
|
|
|
@@ -1666,7 +1769,7 @@ function registerResourceMount(
|
|
|
1666
1769
|
): void {
|
|
1667
1770
|
const parsed = unwrapResourceMount(triggerNode, scope);
|
|
1668
1771
|
if (!parsed) return;
|
|
1669
|
-
const { httpCall, gates
|
|
1772
|
+
const { httpCall, gates } = parsed;
|
|
1670
1773
|
|
|
1671
1774
|
const path = stringArg(httpCall.arguments[0]);
|
|
1672
1775
|
if (!path) return;
|
|
@@ -1713,7 +1816,6 @@ function registerResourceMount(
|
|
|
1713
1816
|
trigger: { http: { method: v.method as never, path: v.p } },
|
|
1714
1817
|
...(v.eff ? { effects: v.eff as Effects } : {}),
|
|
1715
1818
|
...(gates && gates.length > 0 ? { gates } : {}),
|
|
1716
|
-
...(live && v.method === "GET" ? { live: true } : {}),
|
|
1717
1819
|
...(resource?.breaking ? { breaking: true } : {}),
|
|
1718
1820
|
source: `${file.path}:${line}`,
|
|
1719
1821
|
};
|
|
@@ -1753,7 +1855,6 @@ function registerNamedTableCrud(call: CallExpression, file: SourceFile, scope: P
|
|
|
1753
1855
|
: undefined;
|
|
1754
1856
|
const idPath = `${path}/:id`;
|
|
1755
1857
|
const line = lineAt(file.source, call.start ?? 0);
|
|
1756
|
-
const live = boolProp(opts, "liveList") === true;
|
|
1757
1858
|
const skipCreate = boolProp(opts, "skipCreate") === true;
|
|
1758
1859
|
const ctx = schemaExpandContext(scope, file.path);
|
|
1759
1860
|
const createIn = jsonSchemaFromAst(objectProp(opts, "createIn"), ctx);
|
|
@@ -1775,7 +1876,6 @@ function registerNamedTableCrud(call: CallExpression, file: SourceFile, scope: P
|
|
|
1775
1876
|
readonly method: string;
|
|
1776
1877
|
readonly p: string;
|
|
1777
1878
|
readonly eff: Effects | undefined;
|
|
1778
|
-
readonly live?: boolean;
|
|
1779
1879
|
readonly in?: JsonSchema;
|
|
1780
1880
|
readonly out?: JsonSchema;
|
|
1781
1881
|
}[] = [
|
|
@@ -1784,7 +1884,6 @@ function registerNamedTableCrud(call: CallExpression, file: SourceFile, scope: P
|
|
|
1784
1884
|
method: "GET",
|
|
1785
1885
|
p: path,
|
|
1786
1886
|
eff: storeRef ? { reads: [storeRef] } : undefined,
|
|
1787
|
-
live,
|
|
1788
1887
|
in: defaultListInSchema(),
|
|
1789
1888
|
...(itemOut ? { out: { type: "array", items: itemOut } } : {}),
|
|
1790
1889
|
},
|
|
@@ -1831,7 +1930,6 @@ function registerNamedTableCrud(call: CallExpression, file: SourceFile, scope: P
|
|
|
1831
1930
|
const flow: Flow = {
|
|
1832
1931
|
trigger: { http: { method: v.method as never, path: v.p } },
|
|
1833
1932
|
...(v.eff ? { effects: v.eff as Effects } : {}),
|
|
1834
|
-
...(v.live ? { live: true } : {}),
|
|
1835
1933
|
...(v.in !== undefined ? { in: v.in } : {}),
|
|
1836
1934
|
...(v.out !== undefined ? { out: v.out } : {}),
|
|
1837
1935
|
source: `${file.path}:${line}`,
|
|
@@ -1844,7 +1942,8 @@ function registerNamedTableCrud(call: CallExpression, file: SourceFile, scope: P
|
|
|
1844
1942
|
interface ParsedTrigger {
|
|
1845
1943
|
trigger: Trigger;
|
|
1846
1944
|
gates?: string[];
|
|
1847
|
-
live
|
|
1945
|
+
/** Live signal name from `.live(signal)` / `http.live(signal)`. */
|
|
1946
|
+
live?: string;
|
|
1848
1947
|
}
|
|
1849
1948
|
|
|
1850
1949
|
function parseTrigger(
|
|
@@ -1864,7 +1963,7 @@ function parseTrigger(
|
|
|
1864
1963
|
return { trigger: {} };
|
|
1865
1964
|
}
|
|
1866
1965
|
|
|
1867
|
-
// http.post("/x").gate(...)
|
|
1966
|
+
// http.post("/x").gate(...) / http.get("/x").live(signal) / http.live(signal)
|
|
1868
1967
|
const http = parseHttpTrigger(call, scope, filePath);
|
|
1869
1968
|
if (http) return http;
|
|
1870
1969
|
|
|
@@ -1894,16 +1993,27 @@ function parseTrigger(
|
|
|
1894
1993
|
return undefined;
|
|
1895
1994
|
}
|
|
1896
1995
|
|
|
1996
|
+
function resolveSignalName(node: AstNode | undefined, scope: ProjectScope): string | undefined {
|
|
1997
|
+
const lit = stringArg(node);
|
|
1998
|
+
if (lit) return lit;
|
|
1999
|
+
const id = identifierName(node);
|
|
2000
|
+
if (!id) return undefined;
|
|
2001
|
+
const binding = scope.bindings.get(id);
|
|
2002
|
+
if (binding?.kind === "signal") return binding.ref;
|
|
2003
|
+
if (scope.signals[id]) return id;
|
|
2004
|
+
return id;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
1897
2007
|
function parseHttpTrigger(
|
|
1898
2008
|
leaf: AstNode,
|
|
1899
2009
|
scope: ProjectScope,
|
|
1900
2010
|
filePath?: string,
|
|
1901
2011
|
): ParsedTrigger | undefined {
|
|
1902
|
-
// Walk: http.METHOD(path).gate(...).live() / .public()
|
|
2012
|
+
// Walk: http.METHOD(path).gate(...).live(signal) / http.live(signal) / .public()
|
|
1903
2013
|
const chain = flattenMemberCallChain(leaf);
|
|
1904
2014
|
let method: string | undefined;
|
|
1905
2015
|
let path: string | undefined;
|
|
1906
|
-
let live
|
|
2016
|
+
let live: string | undefined;
|
|
1907
2017
|
const gateNames: string[] = [];
|
|
1908
2018
|
|
|
1909
2019
|
for (const node of chain) {
|
|
@@ -1916,6 +2026,15 @@ function parseHttpTrigger(
|
|
|
1916
2026
|
const obj = member.object;
|
|
1917
2027
|
|
|
1918
2028
|
if (obj.type === "Identifier" && (obj as Identifier).name === "http" && prop) {
|
|
2029
|
+
if (prop === "live") {
|
|
2030
|
+
method = "GET";
|
|
2031
|
+
const signalName = resolveSignalName(c.arguments[0], scope);
|
|
2032
|
+
if (signalName) {
|
|
2033
|
+
live = signalName;
|
|
2034
|
+
path = `/_oke/live/${encodeURIComponent(signalName)}`;
|
|
2035
|
+
}
|
|
2036
|
+
continue;
|
|
2037
|
+
}
|
|
1919
2038
|
method = prop.toUpperCase();
|
|
1920
2039
|
path = stringArg(c.arguments[0]);
|
|
1921
2040
|
continue;
|
|
@@ -1929,7 +2048,10 @@ function parseHttpTrigger(
|
|
|
1929
2048
|
}
|
|
1930
2049
|
|
|
1931
2050
|
if (prop === "public") gateNames.push("public");
|
|
1932
|
-
if (prop === "live")
|
|
2051
|
+
if (prop === "live") {
|
|
2052
|
+
const signalName = resolveSignalName(c.arguments[0], scope);
|
|
2053
|
+
if (signalName) live = signalName;
|
|
2054
|
+
}
|
|
1933
2055
|
}
|
|
1934
2056
|
}
|
|
1935
2057
|
|
|
@@ -1959,7 +2081,7 @@ function parseHttpTrigger(
|
|
|
1959
2081
|
},
|
|
1960
2082
|
},
|
|
1961
2083
|
gates: gateNames.length > 0 ? gateNames : undefined,
|
|
1962
|
-
live
|
|
2084
|
+
...(live !== undefined ? { live } : {}),
|
|
1963
2085
|
};
|
|
1964
2086
|
}
|
|
1965
2087
|
|
|
@@ -2126,7 +2248,7 @@ function schemaProp(
|
|
|
2126
2248
|
|
|
2127
2249
|
// ── AST helpers ────────────────────────────────────────────────────────────
|
|
2128
2250
|
|
|
2129
|
-
/** Leaf-to-root walk of `http.get("/x").public().live()`. */
|
|
2251
|
+
/** Leaf-to-root walk of `http.get("/x").public().live(signal)`. */
|
|
2130
2252
|
function flattenMemberCallChain(leaf: AstNode): AstNode[] {
|
|
2131
2253
|
const chain: AstNode[] = [];
|
|
2132
2254
|
let current: AstNode = leaf;
|
|
@@ -2334,6 +2456,64 @@ export function lineAt(source: string, offset: number): number {
|
|
|
2334
2456
|
return line;
|
|
2335
2457
|
}
|
|
2336
2458
|
|
|
2459
|
+
function applyOkeTenantConfig(opts: AstNode | undefined, scope: ProjectScope): void {
|
|
2460
|
+
const gate = objectProp(opts, "gate");
|
|
2461
|
+
const auth = objectProp(gate, "auth");
|
|
2462
|
+
const tenant = objectProp(auth, "tenant");
|
|
2463
|
+
if (!tenant) return;
|
|
2464
|
+
if (tenant.type === "Literal" && (tenant as Literal).value === true) {
|
|
2465
|
+
scope.tenancy = {
|
|
2466
|
+
...scope.tenancy,
|
|
2467
|
+
enabled: true,
|
|
2468
|
+
isolation: scope.tenancy?.isolation ?? "row",
|
|
2469
|
+
membershipRequired: false,
|
|
2470
|
+
};
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
if (tenant.type === "ObjectExpression") {
|
|
2474
|
+
scope.tenancy = {
|
|
2475
|
+
...scope.tenancy,
|
|
2476
|
+
enabled: true,
|
|
2477
|
+
isolation: scope.tenancy?.isolation ?? "row",
|
|
2478
|
+
membershipRequired: boolProp(tenant, "required") === true,
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function applyTenancyDefaults(manifest: Manifest): void {
|
|
2484
|
+
if (manifest.tenancy?.enabled !== true) return;
|
|
2485
|
+
for (const flow of Object.values(manifest.flows ?? {})) {
|
|
2486
|
+
if (flow.tenantScoped === undefined) flow.tenantScoped = true;
|
|
2487
|
+
}
|
|
2488
|
+
for (const store of Object.values(manifest.stores ?? {})) {
|
|
2489
|
+
if (store.facet === "kv" && store.tenantScoped === undefined) store.tenantScoped = true;
|
|
2490
|
+
}
|
|
2491
|
+
for (const secret of Object.values(manifest.vault ?? {})) {
|
|
2492
|
+
if (secret.perTenant === undefined) secret.perTenant = true;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
function assertTenantSafeSchema(manifest: Manifest): void {
|
|
2497
|
+
if (manifest.tenancy?.enabled !== true) return;
|
|
2498
|
+
for (const [storeName, store] of Object.entries(manifest.stores ?? {})) {
|
|
2499
|
+
if (store.facet !== "sql" || !store.tables) continue;
|
|
2500
|
+
for (const [tableName, table] of Object.entries(store.tables)) {
|
|
2501
|
+
if (table.tenantScoped === false) continue;
|
|
2502
|
+
const policies = Object.values(table.policies ?? {});
|
|
2503
|
+
const hasTenant = policies.some(
|
|
2504
|
+
(p) =>
|
|
2505
|
+
(typeof p.using === "string" && p.using.includes("oke.tenant()")) ||
|
|
2506
|
+
(typeof p.withCheck === "string" && p.withCheck.includes("oke.tenant()")),
|
|
2507
|
+
);
|
|
2508
|
+
if (!hasTenant) {
|
|
2509
|
+
throw new Error(
|
|
2510
|
+
`extract: table "${storeName}.${tableName}" needs store.schema.policy.tenant(...) or store.schema.unscoped() when gate.auth.tenant is on`,
|
|
2511
|
+
);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2337
2517
|
function sortRecord<T>(record: Record<string, T>): Record<string, T> {
|
|
2338
2518
|
const out: Record<string, T> = {};
|
|
2339
2519
|
for (const key of Object.keys(record).sort()) {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"effects": {
|
|
33
33
|
"reads": ["sql:bookings"]
|
|
34
34
|
},
|
|
35
|
-
"source": "src/flows/bookings/index.ts:
|
|
35
|
+
"source": "src/flows/bookings/index.ts:47"
|
|
36
36
|
},
|
|
37
37
|
"bookings.mine": {
|
|
38
38
|
"trigger": {
|
|
@@ -46,7 +46,6 @@
|
|
|
46
46
|
"reads": ["sql:bookings"]
|
|
47
47
|
},
|
|
48
48
|
"source": "src/flows/bookings/index.ts:37",
|
|
49
|
-
"live": true,
|
|
50
49
|
"cacheKeys": "computed:sql:bookings/userId"
|
|
51
50
|
},
|
|
52
51
|
"bookings.refundBooking": {
|
|
@@ -54,7 +53,7 @@
|
|
|
54
53
|
"reads": ["sql:bookings"],
|
|
55
54
|
"writes": ["sql:bookings"]
|
|
56
55
|
},
|
|
57
|
-
"source": "src/flows/bookings/index.ts:
|
|
56
|
+
"source": "src/flows/bookings/index.ts:51"
|
|
58
57
|
},
|
|
59
58
|
"payments.chargeBooking": {
|
|
60
59
|
"effects": {
|
package/src/compiler/response.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { FlowFailure } from "../kernel/errors.ts";
|
|
8
|
-
import {
|
|
8
|
+
import type { JsonResult, JsonStreamResult, SseFrame } from "../kernel/fx.ts";
|
|
9
9
|
import { isFlowFailure } from "../kernel/hooks.ts";
|
|
10
|
+
import { lazyRequire } from "../kernel/lazy-require.ts";
|
|
10
11
|
import { VALIDATION_ERROR_CODE } from "../validation/standard-schema.ts";
|
|
11
12
|
|
|
12
13
|
/** Successful envelope. */
|
|
@@ -53,10 +54,11 @@ export function statusForFailure(failure: FlowFailure): number {
|
|
|
53
54
|
* @param output - Handler output (`undefined` → 204)
|
|
54
55
|
*/
|
|
55
56
|
export function encodeSuccess(output: unknown): Response {
|
|
56
|
-
|
|
57
|
+
const json = loadFxJson();
|
|
58
|
+
if (json.isJsonStreamResult(output)) {
|
|
57
59
|
return encodeSseStream(output);
|
|
58
60
|
}
|
|
59
|
-
if (isJsonResult(output)) {
|
|
61
|
+
if (json.isJsonResult(output)) {
|
|
60
62
|
if (output.status === 204) {
|
|
61
63
|
return new Response(null, { status: 204 });
|
|
62
64
|
}
|
|
@@ -81,23 +83,46 @@ export function encodeFailure(failure: FlowFailure): Response {
|
|
|
81
83
|
});
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
function loadFxLiveStream(): typeof import("../kernel/fx-live-stream.ts") {
|
|
87
|
+
return lazyRequire(`${import.meta.dir}/../kernel`, ["fx", "live", "stream"].join("-"));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loadFxJson(): {
|
|
91
|
+
isJsonResult: (value: unknown) => value is JsonResult;
|
|
92
|
+
isJsonStreamResult: (value: unknown) => value is JsonStreamResult;
|
|
93
|
+
isSseFrame: (value: unknown) => value is SseFrame;
|
|
94
|
+
} {
|
|
95
|
+
return lazyRequire(`${import.meta.dir}/../kernel`, ["fx", "runtime"].join("-"));
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
/**
|
|
85
99
|
* Encode an execute-style result (response / failure / output).
|
|
86
100
|
*
|
|
101
|
+
* Awaits {@link JsonStreamResult.ready} before the 200 SSE body so OKE1014
|
|
102
|
+
* can return 410 instead of a half-open stream.
|
|
103
|
+
*
|
|
87
104
|
* @param result - Pipeline outcome pieces
|
|
88
105
|
*/
|
|
89
|
-
export function encodeExecuteResult(result: {
|
|
106
|
+
export async function encodeExecuteResult(result: {
|
|
90
107
|
readonly response?: Response | undefined;
|
|
91
108
|
readonly failure?: FlowFailure | undefined;
|
|
92
109
|
readonly output?: unknown;
|
|
93
110
|
readonly error?: unknown;
|
|
94
|
-
}): Response {
|
|
111
|
+
}): Promise<Response> {
|
|
95
112
|
if (result.response) return result.response;
|
|
96
113
|
if (result.failure) return encodeFailure(result.failure);
|
|
97
114
|
if (result.error !== undefined) {
|
|
98
115
|
if (isFlowFailure(result.error)) {
|
|
99
116
|
return encodeFailure(result.error);
|
|
100
117
|
}
|
|
118
|
+
const gap =
|
|
119
|
+
result.error !== null &&
|
|
120
|
+
typeof result.error === "object" &&
|
|
121
|
+
"code" in result.error &&
|
|
122
|
+
(result.error as { code: unknown }).code === 1014
|
|
123
|
+
? loadFxLiveStream().encodeGap(result.error)
|
|
124
|
+
: undefined;
|
|
125
|
+
if (gap) return gap;
|
|
101
126
|
// Unhandled throws must never look like success (`undefined` → 204).
|
|
102
127
|
return Response.json(
|
|
103
128
|
{
|
|
@@ -111,14 +136,13 @@ export function encodeExecuteResult(result: {
|
|
|
111
136
|
{ status: 500 },
|
|
112
137
|
);
|
|
113
138
|
}
|
|
139
|
+
if (loadFxJson().isJsonStreamResult(result.output) && result.output.ready) {
|
|
140
|
+
const early = await loadFxLiveStream().awaitLiveReady(result.output);
|
|
141
|
+
if (early) return early;
|
|
142
|
+
}
|
|
114
143
|
return encodeSuccess(result.output);
|
|
115
144
|
}
|
|
116
145
|
|
|
117
|
-
/**
|
|
118
|
-
* Encode {@link JsonStreamResult} as SSE (`text/event-stream`).
|
|
119
|
-
*
|
|
120
|
-
* @param carrier - Stream carrier from `fx.json.stream`
|
|
121
|
-
*/
|
|
122
146
|
function encodeSseStream(carrier: JsonStreamResult): Response {
|
|
123
147
|
const encoder = new TextEncoder();
|
|
124
148
|
let finalized = false;
|
|
@@ -131,7 +155,13 @@ function encodeSseStream(carrier: JsonStreamResult): Response {
|
|
|
131
155
|
async start(controller) {
|
|
132
156
|
try {
|
|
133
157
|
for await (const chunk of carrier.chunks) {
|
|
134
|
-
|
|
158
|
+
const frame = loadFxJson().isSseFrame(chunk)
|
|
159
|
+
? chunk
|
|
160
|
+
: { data: chunk as unknown, id: undefined as string | undefined };
|
|
161
|
+
const lines: string[] = [];
|
|
162
|
+
if (frame.id !== undefined) lines.push(`id: ${frame.id}`);
|
|
163
|
+
lines.push(`data: ${JSON.stringify(frame.data)}`);
|
|
164
|
+
controller.enqueue(encoder.encode(`${lines.join("\n")}\n\n`));
|
|
135
165
|
}
|
|
136
166
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
137
167
|
controller.close();
|