@cosmicdrift/kumiko-framework 0.159.1 → 0.160.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/package.json +3 -3
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +1 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/api-constants.ts +1 -0
- package/src/api/routes.ts +57 -0
- package/src/bun-db/query.ts +12 -25
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/entity-table-meta-types.ts +2 -92
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-entity-handlers.ts +35 -1
- package/src/engine/index.ts +3 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -94
- package/src/engine/types/entity-handlers.ts +2 -30
- package/src/engine/types/feature.ts +2 -1021
- package/src/engine/types/fields.ts +2 -685
- package/src/engine/types/handlers.ts +2 -820
- package/src/engine/types/hooks.ts +2 -170
- package/src/engine/types/index.ts +44 -36
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -83
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/screen.ts +2 -747
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/workspace.ts +2 -42
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +2 -21
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -22
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +39 -1
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/rate-limit/resolver.ts +10 -30
- package/src/secrets/envelope-cipher.ts +4 -6
- package/src/secrets/types.ts +2 -177
- package/src/time/tz-context.ts +9 -56
|
@@ -384,3 +384,171 @@ defineFeature("legacy", (r) => {
|
|
|
384
384
|
expect(text).not.toContain('r.entity("item"');
|
|
385
385
|
});
|
|
386
386
|
});
|
|
387
|
+
|
|
388
|
+
describe("addPattern — error paths", () => {
|
|
389
|
+
test("throws when no defineFeature call is present", () => {
|
|
390
|
+
const sf = makeSourceFile("export const x = 1;");
|
|
391
|
+
expect(() => addPattern(sf, newSecretPattern)).toThrow(/no defineFeature/);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe("callMatchesId — positional and object-form lookups", () => {
|
|
396
|
+
const LEGACY = `
|
|
397
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
398
|
+
|
|
399
|
+
defineFeature("legacy", (r) => {
|
|
400
|
+
r.relation("task", "owner", { kind: "belongsTo", to: "user" });
|
|
401
|
+
|
|
402
|
+
r.hook("postSave", "task", () => {});
|
|
403
|
+
|
|
404
|
+
r.hook("preDelete", { allOf: "task" }, () => {});
|
|
405
|
+
|
|
406
|
+
r.job("nightly", { trigger: { cron: "0 0 * * *" } }, async () => {});
|
|
407
|
+
|
|
408
|
+
r.writeHandler("task:create", z.object({}), async () => ({}));
|
|
409
|
+
|
|
410
|
+
r.queryHandler("task:list", z.object({}), async () => []);
|
|
411
|
+
|
|
412
|
+
r.notification("taskCreated", {
|
|
413
|
+
trigger: { on: "task" },
|
|
414
|
+
recipient: () => ({}),
|
|
415
|
+
data: () => ({}),
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
`;
|
|
419
|
+
|
|
420
|
+
test("removePattern finds a positional relation by entity+name", () => {
|
|
421
|
+
const sf = makeSourceFile(LEGACY);
|
|
422
|
+
removePattern(sf, { kind: "relation", entityName: "task", relationName: "owner" });
|
|
423
|
+
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "relation")).toBeUndefined();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("removePattern finds a positional hook by type+string target", () => {
|
|
427
|
+
const sf = makeSourceFile(LEGACY);
|
|
428
|
+
removePattern(sf, { kind: "hook", hookType: "postSave", target: "task" });
|
|
429
|
+
expect(parseSourceFile(sf).patterns.filter((p) => p.kind === "hook")).toHaveLength(1);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("removePattern finds a positional hook with an allOf target", () => {
|
|
433
|
+
const sf = makeSourceFile(LEGACY);
|
|
434
|
+
removePattern(sf, { kind: "hook", hookType: "preDelete", target: { allOf: "task" } });
|
|
435
|
+
expect(parseSourceFile(sf).patterns.filter((p) => p.kind === "hook")).toHaveLength(1);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("removePattern finds a positional job by name", () => {
|
|
439
|
+
const sf = makeSourceFile(LEGACY);
|
|
440
|
+
removePattern(sf, { kind: "job", jobName: "nightly" });
|
|
441
|
+
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "job")).toBeUndefined();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("removePattern finds a writeHandler by positional name", () => {
|
|
445
|
+
const sf = makeSourceFile(LEGACY);
|
|
446
|
+
removePattern(sf, { kind: "writeHandler", handlerName: "task:create" });
|
|
447
|
+
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "writeHandler")).toBeUndefined();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("removePattern finds a queryHandler by positional name", () => {
|
|
451
|
+
const sf = makeSourceFile(LEGACY);
|
|
452
|
+
removePattern(sf, { kind: "queryHandler", handlerName: "task:list" });
|
|
453
|
+
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "queryHandler")).toBeUndefined();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("removePattern finds a positional notification by name", () => {
|
|
457
|
+
const sf = makeSourceFile(LEGACY);
|
|
458
|
+
removePattern(sf, { kind: "notification", notificationName: "taskCreated" });
|
|
459
|
+
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "notification")).toBeUndefined();
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("removePattern — blank-line collapse", () => {
|
|
464
|
+
test("removing an appended pattern collapses the preceding blank line", () => {
|
|
465
|
+
const sf = makeSourceFile(STARTER);
|
|
466
|
+
addPattern(sf, newSecretPattern);
|
|
467
|
+
const linesBefore = sf.getFullText().split("\n").length;
|
|
468
|
+
removePattern(sf, { kind: "secret", shortName: "stripeKey" });
|
|
469
|
+
const linesAfter = sf.getFullText().split("\n").length;
|
|
470
|
+
expect(linesAfter).toBeLessThan(linesBefore);
|
|
471
|
+
expect(parseSourceFile(sf).patterns.map((p) => p.kind)).toEqual(["entity", "metric"]);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
describe("replacePattern — object-form hook with allOf target", () => {
|
|
476
|
+
test("replaces an object-form hook registered with an allOf target", () => {
|
|
477
|
+
const file = `
|
|
478
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
479
|
+
|
|
480
|
+
defineFeature("hooks", (r) => {
|
|
481
|
+
r.hook({ type: "postSave", target: { allOf: "task" }, handler: () => {} });
|
|
482
|
+
});
|
|
483
|
+
`;
|
|
484
|
+
const sf = makeSourceFile(file);
|
|
485
|
+
replacePattern(
|
|
486
|
+
sf,
|
|
487
|
+
{ kind: "hook", hookType: "postSave", target: { allOf: "task" } },
|
|
488
|
+
{
|
|
489
|
+
kind: "hook",
|
|
490
|
+
source: SAMPLE_LOC,
|
|
491
|
+
hookType: "postSave",
|
|
492
|
+
target: { allOf: "task" },
|
|
493
|
+
fnBody: { ...SAMPLE_LOC, raw: "() => { /* updated */ }" },
|
|
494
|
+
},
|
|
495
|
+
);
|
|
496
|
+
expect(sf.getFullText()).toContain("/* updated */");
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
describe("callMatchesId — object-form lookups", () => {
|
|
500
|
+
const OBJECT_FORM = `
|
|
501
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
502
|
+
|
|
503
|
+
defineFeature("object", (r) => {
|
|
504
|
+
r.relation({ entity: "task", name: "owner", kind: "belongsTo", to: "user" });
|
|
505
|
+
|
|
506
|
+
r.job({ name: "nightly", trigger: { cron: "0 0 * * *" }, handler: async () => {} });
|
|
507
|
+
|
|
508
|
+
r.useExtension({ name: "audit", entity: "task" });
|
|
509
|
+
|
|
510
|
+
r.writeHandler({
|
|
511
|
+
name: "task:create",
|
|
512
|
+
schema: z.object({}),
|
|
513
|
+
handler: async () => ({}),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
r.notification({
|
|
517
|
+
name: "taskCreated",
|
|
518
|
+
trigger: { on: "task" },
|
|
519
|
+
recipient: () => ({}),
|
|
520
|
+
data: () => ({}),
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
`;
|
|
524
|
+
|
|
525
|
+
test("removePattern finds an object-form relation", () => {
|
|
526
|
+
const sf = makeSourceFile(OBJECT_FORM);
|
|
527
|
+
removePattern(sf, { kind: "relation", entityName: "task", relationName: "owner" });
|
|
528
|
+
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "relation")).toBeUndefined();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("removePattern finds an object-form job", () => {
|
|
532
|
+
const sf = makeSourceFile(OBJECT_FORM);
|
|
533
|
+
removePattern(sf, { kind: "job", jobName: "nightly" });
|
|
534
|
+
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "job")).toBeUndefined();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("removePattern finds an object-form useExtension", () => {
|
|
538
|
+
const sf = makeSourceFile(OBJECT_FORM);
|
|
539
|
+
removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "task" });
|
|
540
|
+
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "useExtension")).toBeUndefined();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("removePattern finds an object-form writeHandler", () => {
|
|
544
|
+
const sf = makeSourceFile(OBJECT_FORM);
|
|
545
|
+
removePattern(sf, { kind: "writeHandler", handlerName: "task:create" });
|
|
546
|
+
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "writeHandler")).toBeUndefined();
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("removePattern finds an object-form notification", () => {
|
|
550
|
+
const sf = makeSourceFile(OBJECT_FORM);
|
|
551
|
+
removePattern(sf, { kind: "notification", notificationName: "taskCreated" });
|
|
552
|
+
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "notification")).toBeUndefined();
|
|
553
|
+
});
|
|
554
|
+
});
|
|
@@ -442,6 +442,12 @@ defineFeature("tasks", (r) => {
|
|
|
442
442
|
handlerSource: "async (q, ctx) => []",
|
|
443
443
|
access: { openToAll: true },
|
|
444
444
|
});
|
|
445
|
+
p.addStreamHandler({
|
|
446
|
+
name: "task:stream",
|
|
447
|
+
schemaSource: "z.object({ prompt: z.string() })",
|
|
448
|
+
handlerSource: 'async function* (input, ctx) { yield "token"; }',
|
|
449
|
+
access: { roles: ["user"] },
|
|
450
|
+
});
|
|
445
451
|
p.addHook({
|
|
446
452
|
type: "postDelete",
|
|
447
453
|
target: { allOf: "task" },
|
|
@@ -453,6 +459,7 @@ defineFeature("tasks", (r) => {
|
|
|
453
459
|
"entity",
|
|
454
460
|
"writeHandler",
|
|
455
461
|
"queryHandler",
|
|
462
|
+
"streamHandler",
|
|
456
463
|
"hook",
|
|
457
464
|
]);
|
|
458
465
|
});
|
|
@@ -206,6 +206,15 @@ defineFeature("workflow", (r) => {
|
|
|
206
206
|
{ access: { openToAll: true } },
|
|
207
207
|
);
|
|
208
208
|
|
|
209
|
+
r.streamHandler(
|
|
210
|
+
"step:stream",
|
|
211
|
+
z.object({ prompt: z.string() }),
|
|
212
|
+
async function* (input, ctx) {
|
|
213
|
+
yield "token";
|
|
214
|
+
},
|
|
215
|
+
{ access: { roles: ["user"] } },
|
|
216
|
+
);
|
|
217
|
+
|
|
209
218
|
r.hook("postSave", "step", async (event, ctx) => {
|
|
210
219
|
console.log("step saved");
|
|
211
220
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CallExpression, Node, SourceFile } from "ts-morph";
|
|
2
2
|
import { SyntaxKind } from "ts-morph";
|
|
3
3
|
import type { AccessRule, RateLimitOption } from "../../types/handlers";
|
|
4
|
-
import type { QueryHandlerPattern, WriteHandlerPattern } from "../patterns";
|
|
4
|
+
import type { QueryHandlerPattern, StreamHandlerPattern, WriteHandlerPattern } from "../patterns";
|
|
5
5
|
import type { SourceLocation } from "../source-location";
|
|
6
6
|
import { sourceLocationFromNode } from "../source-location";
|
|
7
7
|
import { readOptionalAccessRule, readOptionalRateLimit } from "./hooks";
|
|
@@ -44,7 +44,7 @@ function resolveObjectLiteralArg(node: Node) {
|
|
|
44
44
|
export function parseHandlerCall(
|
|
45
45
|
call: CallExpression,
|
|
46
46
|
sourceFile: SourceFile,
|
|
47
|
-
methodName: "writeHandler" | "queryHandler",
|
|
47
|
+
methodName: "writeHandler" | "queryHandler" | "streamHandler",
|
|
48
48
|
): ExtractOutput<ParsedHandlerCall> {
|
|
49
49
|
const args = call.getArguments();
|
|
50
50
|
const first = args[0];
|
|
@@ -220,3 +220,20 @@ export function extractQueryHandler(
|
|
|
220
220
|
...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
|
|
221
221
|
});
|
|
222
222
|
}
|
|
223
|
+
|
|
224
|
+
export function extractStreamHandler(
|
|
225
|
+
call: CallExpression,
|
|
226
|
+
sourceFile: SourceFile,
|
|
227
|
+
): ExtractOutput<StreamHandlerPattern> {
|
|
228
|
+
const parsed = parseHandlerCall(call, sourceFile, "streamHandler");
|
|
229
|
+
if (parsed.kind === "error") return parsed;
|
|
230
|
+
return ok({
|
|
231
|
+
kind: "streamHandler",
|
|
232
|
+
source: parsed.pattern.source,
|
|
233
|
+
handlerName: parsed.pattern.handlerName,
|
|
234
|
+
schemaSource: parsed.pattern.schemaSource,
|
|
235
|
+
handlerBody: parsed.pattern.handlerBody,
|
|
236
|
+
...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
|
|
237
|
+
...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -43,6 +43,7 @@ export type {
|
|
|
43
43
|
AddRequiresArgs,
|
|
44
44
|
AddScreenArgs,
|
|
45
45
|
AddSecretArgs,
|
|
46
|
+
AddStreamHandlerArgs,
|
|
46
47
|
AddToggleableArgs,
|
|
47
48
|
AddTranslationsArgs,
|
|
48
49
|
AddUseExtensionArgs,
|
|
@@ -79,6 +80,7 @@ export type {
|
|
|
79
80
|
// Mixed patterns
|
|
80
81
|
ScreenPattern,
|
|
81
82
|
SecretPattern,
|
|
83
|
+
StreamHandlerPattern,
|
|
82
84
|
SystemScopePattern,
|
|
83
85
|
ToggleablePattern,
|
|
84
86
|
TranslationsPattern,
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
extractScreen,
|
|
59
59
|
extractSecret,
|
|
60
60
|
extractStoreTable,
|
|
61
|
+
extractStreamHandler,
|
|
61
62
|
extractSystemScope,
|
|
62
63
|
extractToggleable,
|
|
63
64
|
extractTranslations,
|
|
@@ -447,6 +448,8 @@ function dispatchExtractor(
|
|
|
447
448
|
return extractWriteHandler(call, sourceFile);
|
|
448
449
|
case "queryHandler":
|
|
449
450
|
return extractQueryHandler(call, sourceFile);
|
|
451
|
+
case "streamHandler":
|
|
452
|
+
return extractStreamHandler(call, sourceFile);
|
|
450
453
|
case "job":
|
|
451
454
|
return extractJob(call, sourceFile);
|
|
452
455
|
case "httpRoute":
|
|
@@ -53,6 +53,7 @@ export type PatternId =
|
|
|
53
53
|
| { readonly kind: "screen"; readonly id: string }
|
|
54
54
|
| { readonly kind: "writeHandler"; readonly handlerName: string }
|
|
55
55
|
| { readonly kind: "queryHandler"; readonly handlerName: string }
|
|
56
|
+
| { readonly kind: "streamHandler"; readonly handlerName: string }
|
|
56
57
|
| {
|
|
57
58
|
readonly kind: "hook";
|
|
58
59
|
readonly hookType: string;
|
|
@@ -353,6 +354,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
|
|
|
353
354
|
return matchObjectProperty(call, "id", id.id);
|
|
354
355
|
case "writeHandler":
|
|
355
356
|
case "queryHandler":
|
|
357
|
+
case "streamHandler":
|
|
356
358
|
return (
|
|
357
359
|
matchFirstArgString(call, id.handlerName) ||
|
|
358
360
|
matchObjectProperty(call, "name", id.handlerName)
|
|
@@ -104,6 +104,14 @@ export type AddQueryHandlerArgs = {
|
|
|
104
104
|
readonly rateLimit?: RateLimitOption;
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
+
export type AddStreamHandlerArgs = {
|
|
108
|
+
readonly name: string;
|
|
109
|
+
readonly schemaSource: string;
|
|
110
|
+
readonly handlerSource: string;
|
|
111
|
+
readonly access?: AccessRule;
|
|
112
|
+
readonly rateLimit?: RateLimitOption;
|
|
113
|
+
};
|
|
114
|
+
|
|
107
115
|
export type AddHookArgs = {
|
|
108
116
|
readonly type: LifecycleHookType | "validation";
|
|
109
117
|
// `{ allOf: entity }` — entity-wide target, fires for every write/query
|
|
@@ -233,6 +241,7 @@ export type FeaturePatcher = {
|
|
|
233
241
|
readonly addScreen: (args: AddScreenArgs) => void;
|
|
234
242
|
readonly addWriteHandler: (args: AddWriteHandlerArgs) => void;
|
|
235
243
|
readonly addQueryHandler: (args: AddQueryHandlerArgs) => void;
|
|
244
|
+
readonly addStreamHandler: (args: AddStreamHandlerArgs) => void;
|
|
236
245
|
readonly addHook: (args: AddHookArgs) => void;
|
|
237
246
|
readonly addJob: (args: AddJobArgs) => void;
|
|
238
247
|
readonly addNotification: (args: AddNotificationArgs) => void;
|
|
@@ -392,6 +401,18 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
392
401
|
});
|
|
393
402
|
},
|
|
394
403
|
|
|
404
|
+
addStreamHandler({ name, schemaSource, handlerSource, access, rateLimit }) {
|
|
405
|
+
add({
|
|
406
|
+
kind: "streamHandler",
|
|
407
|
+
source: SYNTHETIC_LOC,
|
|
408
|
+
handlerName: name,
|
|
409
|
+
schemaSource: rawLoc(schemaSource),
|
|
410
|
+
handlerBody: rawLoc(handlerSource),
|
|
411
|
+
...(access !== undefined && { access }),
|
|
412
|
+
...(rateLimit !== undefined && { rateLimit }),
|
|
413
|
+
});
|
|
414
|
+
},
|
|
415
|
+
|
|
395
416
|
addHook({ type, target, handlerSource, phase }) {
|
|
396
417
|
add({
|
|
397
418
|
kind: "hook",
|
|
@@ -374,6 +374,20 @@ export type QueryHandlerPattern = {
|
|
|
374
374
|
readonly rateLimit?: RateLimitOption;
|
|
375
375
|
};
|
|
376
376
|
|
|
377
|
+
// `r.streamHandler(...)` — registers a streaming read handler: name, Zod
|
|
378
|
+
// input schema, async-generator handler closure, plus optional `access` and
|
|
379
|
+
// `rateLimit` rules. Same header/body split and opaque single-reference
|
|
380
|
+
// case as QueryHandlerPattern.
|
|
381
|
+
export type StreamHandlerPattern = {
|
|
382
|
+
readonly kind: "streamHandler";
|
|
383
|
+
readonly source: SourceLocation;
|
|
384
|
+
readonly handlerName?: string;
|
|
385
|
+
readonly schemaSource?: SourceLocation;
|
|
386
|
+
readonly handlerBody?: SourceLocation;
|
|
387
|
+
readonly access?: AccessRule;
|
|
388
|
+
readonly rateLimit?: RateLimitOption;
|
|
389
|
+
};
|
|
390
|
+
|
|
377
391
|
// `r.hook(type, target, fn, options?)` — attaches a lifecycle hook
|
|
378
392
|
// (`validation`, `preSave`, `postSave`, `preDelete`, `postDelete`,
|
|
379
393
|
// `preQuery`, `postQuery`) to one or more target handlers, or — for
|
|
@@ -592,6 +606,7 @@ export type FeaturePattern =
|
|
|
592
606
|
| ScreenPattern
|
|
593
607
|
| WriteHandlerPattern
|
|
594
608
|
| QueryHandlerPattern
|
|
609
|
+
| StreamHandlerPattern
|
|
595
610
|
| HookPattern
|
|
596
611
|
| JobPattern
|
|
597
612
|
| NotificationPattern
|
|
@@ -647,6 +662,7 @@ export function getEditability(pattern: FeaturePattern): Editability {
|
|
|
647
662
|
case "screen":
|
|
648
663
|
case "writeHandler":
|
|
649
664
|
case "queryHandler":
|
|
665
|
+
case "streamHandler":
|
|
650
666
|
case "hook":
|
|
651
667
|
case "job":
|
|
652
668
|
case "notification":
|
|
@@ -44,6 +44,7 @@ import type {
|
|
|
44
44
|
RequiresPattern,
|
|
45
45
|
ScreenPattern,
|
|
46
46
|
SecretPattern,
|
|
47
|
+
StreamHandlerPattern,
|
|
47
48
|
SystemScopePattern,
|
|
48
49
|
ToggleablePattern,
|
|
49
50
|
TranslationsPattern,
|
|
@@ -109,6 +110,8 @@ export function renderPattern(pattern: FeaturePattern): string {
|
|
|
109
110
|
return renderWriteHandler(pattern);
|
|
110
111
|
case "queryHandler":
|
|
111
112
|
return renderQueryHandler(pattern);
|
|
113
|
+
case "streamHandler":
|
|
114
|
+
return renderStreamHandler(pattern);
|
|
112
115
|
case "hook":
|
|
113
116
|
return renderHook(pattern);
|
|
114
117
|
case "job":
|
|
@@ -395,6 +398,18 @@ function renderQueryHandler(p: QueryHandlerPattern): string {
|
|
|
395
398
|
return lines.join("\n");
|
|
396
399
|
}
|
|
397
400
|
|
|
401
|
+
function renderStreamHandler(p: StreamHandlerPattern): string {
|
|
402
|
+
if (p.handlerName === undefined) return p.source.raw;
|
|
403
|
+
const lines: string[] = ["r.streamHandler({"];
|
|
404
|
+
lines.push(` name: ${JSON.stringify(p.handlerName)},`);
|
|
405
|
+
lines.push(` schema: ${reindentBody(p.schemaSource?.raw ?? "", PATTERN_INDENT)},`);
|
|
406
|
+
lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
|
|
407
|
+
if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
|
|
408
|
+
if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
|
|
409
|
+
lines.push("});");
|
|
410
|
+
return lines.join("\n");
|
|
411
|
+
}
|
|
412
|
+
|
|
398
413
|
function renderHookTarget(target: HookPattern["target"]): string {
|
|
399
414
|
if (typeof target === "string") return renderValue(target);
|
|
400
415
|
if ("allOf" in target) return `{ allOf: ${JSON.stringify(target.allOf)} }`;
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
SearchPayloadContributorFn,
|
|
31
31
|
SecretKeyDefinition,
|
|
32
32
|
StoreTableEntry,
|
|
33
|
+
StreamHandlerDef,
|
|
33
34
|
TranslationKeys,
|
|
34
35
|
TreeActionDef,
|
|
35
36
|
UiHints,
|
|
@@ -57,6 +58,7 @@ export type FeatureBuilderState = {
|
|
|
57
58
|
relations: Record<string, Record<string, RelationDefinition>>;
|
|
58
59
|
writeHandlers: Record<string, WriteHandlerDef>;
|
|
59
60
|
queryHandlers: Record<string, QueryHandlerDef>;
|
|
61
|
+
streamHandlers: Record<string, StreamHandlerDef>;
|
|
60
62
|
validationHooks: Record<string, ValidationHookFn>;
|
|
61
63
|
lifecycleHooks: Record<string, Record<string, OwnedFn<LifecycleHookFn>[]>>;
|
|
62
64
|
phasedLifecycleHooks: Record<
|
|
@@ -119,6 +121,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
|
|
|
119
121
|
relations: {},
|
|
120
122
|
writeHandlers: {},
|
|
121
123
|
queryHandlers: {},
|
|
124
|
+
streamHandlers: {},
|
|
122
125
|
validationHooks: {},
|
|
123
126
|
lifecycleHooks,
|
|
124
127
|
phasedLifecycleHooks: { postSave: {}, preDelete: {}, postDelete: {} },
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ZodType, z } from "zod";
|
|
2
2
|
import { toTableName } from "../db/table-builder";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
QueryHandlerDefinition,
|
|
5
|
+
StreamHandlerDefinition,
|
|
6
|
+
WriteHandlerDefinition,
|
|
7
|
+
} from "./define-handler";
|
|
4
8
|
import type { RegisterEntityCrudOptions } from "./entity-handlers";
|
|
5
9
|
import { registerEntityCrud } from "./entity-handlers";
|
|
6
10
|
import type { FeatureBuilderState } from "./feature-builder-state";
|
|
@@ -15,6 +19,7 @@ import type {
|
|
|
15
19
|
QueryHandlerFn,
|
|
16
20
|
RateLimitOption,
|
|
17
21
|
RelationDefinition,
|
|
22
|
+
StreamHandlerFn,
|
|
18
23
|
WriteHandlerFn,
|
|
19
24
|
} from "./types";
|
|
20
25
|
import type { PipelineDef } from "./types/step";
|
|
@@ -164,6 +169,35 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
164
169
|
tryMapEntity(state, name, nameOrDef);
|
|
165
170
|
return { name: nameOrDef };
|
|
166
171
|
},
|
|
172
|
+
streamHandler<TName extends string, TSchema extends ZodType>(
|
|
173
|
+
nameOrDef: string | StreamHandlerDefinition<TName, TSchema>,
|
|
174
|
+
schema?: TSchema,
|
|
175
|
+
handler?: StreamHandlerFn<z.infer<TSchema>>,
|
|
176
|
+
options?: { access?: AccessRule; rateLimit?: RateLimitOption },
|
|
177
|
+
): HandlerRef {
|
|
178
|
+
if (typeof nameOrDef === "object") {
|
|
179
|
+
const def = nameOrDef;
|
|
180
|
+
state.streamHandlers[def.name] = {
|
|
181
|
+
name: def.name,
|
|
182
|
+
schema: def.schema,
|
|
183
|
+
// @cast-boundary engine-bridge — typed Dev-API → erased internal storage
|
|
184
|
+
handler: def.handler as StreamHandlerFn, // @cast-boundary engine-bridge
|
|
185
|
+
...(def.access && { access: def.access }),
|
|
186
|
+
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
187
|
+
};
|
|
188
|
+
return { name: def.name };
|
|
189
|
+
}
|
|
190
|
+
if (!schema || !handler)
|
|
191
|
+
throw new Error("streamHandler inline form requires schema + handler");
|
|
192
|
+
state.streamHandlers[nameOrDef] = {
|
|
193
|
+
name: nameOrDef,
|
|
194
|
+
schema,
|
|
195
|
+
handler: handler as StreamHandlerFn, // @cast-boundary engine-bridge
|
|
196
|
+
...(options?.access && { access: options.access }),
|
|
197
|
+
...(options?.rateLimit && { rateLimit: options.rateLimit }),
|
|
198
|
+
};
|
|
199
|
+
return { name: nameOrDef };
|
|
200
|
+
},
|
|
167
201
|
};
|
|
168
202
|
return {
|
|
169
203
|
...methods,
|
package/src/engine/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ export { crossTenantOverrideDenied } from "./cross-tenant";
|
|
|
42
42
|
export { defineFeature } from "./define-feature";
|
|
43
43
|
export type {
|
|
44
44
|
QueryHandlerDefinition,
|
|
45
|
+
StreamHandlerDefinition,
|
|
45
46
|
WriteHandlerDefinition,
|
|
46
47
|
WriteHandlerInput,
|
|
47
48
|
} from "./define-handler";
|
|
@@ -364,6 +365,8 @@ export type {
|
|
|
364
365
|
SecretKeyHandle,
|
|
365
366
|
SelectFieldDef,
|
|
366
367
|
SessionUser,
|
|
368
|
+
StreamHandlerDef,
|
|
369
|
+
StreamHandlerFn,
|
|
367
370
|
Subscribe,
|
|
368
371
|
TargetRef,
|
|
369
372
|
TenantId,
|
|
@@ -46,6 +46,7 @@ const ALL_KINDS: FeaturePatternKind[] = [
|
|
|
46
46
|
"screen",
|
|
47
47
|
"writeHandler",
|
|
48
48
|
"queryHandler",
|
|
49
|
+
"streamHandler",
|
|
49
50
|
"hook",
|
|
50
51
|
"job",
|
|
51
52
|
"notification",
|
|
@@ -270,6 +271,14 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
|
|
|
270
271
|
schemaSource: PLACEHOLDER_BODY_LOC,
|
|
271
272
|
handlerBody: PLACEHOLDER_BODY_LOC,
|
|
272
273
|
};
|
|
274
|
+
case "streamHandler":
|
|
275
|
+
return {
|
|
276
|
+
kind,
|
|
277
|
+
source: PLACEHOLDER_LOC,
|
|
278
|
+
handlerName: "x",
|
|
279
|
+
schemaSource: PLACEHOLDER_BODY_LOC,
|
|
280
|
+
handlerBody: PLACEHOLDER_BODY_LOC,
|
|
281
|
+
};
|
|
273
282
|
case "hook":
|
|
274
283
|
return {
|
|
275
284
|
kind,
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
projectionSchema,
|
|
26
26
|
queryHandlerSchema,
|
|
27
27
|
screenSchema,
|
|
28
|
+
streamHandlerSchema,
|
|
28
29
|
writeHandlerSchema,
|
|
29
30
|
} from "./mixed-schemas";
|
|
30
31
|
import {
|
|
@@ -79,6 +80,7 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
|
|
|
79
80
|
screen: screenSchema,
|
|
80
81
|
writeHandler: writeHandlerSchema,
|
|
81
82
|
queryHandler: queryHandlerSchema,
|
|
83
|
+
streamHandler: streamHandlerSchema,
|
|
82
84
|
hook: hookSchema,
|
|
83
85
|
job: jobSchema,
|
|
84
86
|
notification: notificationSchema,
|
|
@@ -125,6 +125,43 @@ export const queryHandlerSchema: PatternFormSchema = {
|
|
|
125
125
|
],
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
+
export const streamHandlerSchema: PatternFormSchema = {
|
|
129
|
+
kind: "streamHandler",
|
|
130
|
+
label: { en: "Stream handler", de: "Stream-Handler" },
|
|
131
|
+
summary: { en: "Streaming read endpoint (SSE) with Zod schema + async-generator body." },
|
|
132
|
+
category: "behaviour",
|
|
133
|
+
editability: "mixed",
|
|
134
|
+
fields: [
|
|
135
|
+
{
|
|
136
|
+
path: "handlerName",
|
|
137
|
+
label: { en: "Name", de: "Name" },
|
|
138
|
+
input: "text",
|
|
139
|
+
required: true,
|
|
140
|
+
placeholder: "chat:complete",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
path: "schemaSource",
|
|
144
|
+
label: { en: "Zod schema (source)", de: "Zod-Schema (Source)" },
|
|
145
|
+
input: "code-block",
|
|
146
|
+
language: "zod",
|
|
147
|
+
readOnly: true,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
path: "handlerBody",
|
|
151
|
+
label: { en: "Handler body (source)", de: "Handler-Body (Source)" },
|
|
152
|
+
input: "code-block",
|
|
153
|
+
language: "typescript",
|
|
154
|
+
readOnly: true,
|
|
155
|
+
},
|
|
156
|
+
accessRuleField,
|
|
157
|
+
{
|
|
158
|
+
path: "rateLimit",
|
|
159
|
+
label: { en: "Rate limit", de: "Rate-Limit" },
|
|
160
|
+
input: "json-readonly",
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
|
|
128
165
|
export const hookSchema: PatternFormSchema = {
|
|
129
166
|
kind: "hook",
|
|
130
167
|
label: { en: "Lifecycle hook", de: "Lifecycle-Hook" },
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
SearchPayloadContributorFn,
|
|
31
31
|
SecretKeyDefinition,
|
|
32
32
|
StoreTableDef,
|
|
33
|
+
StreamHandlerDef,
|
|
33
34
|
TranslationKeys,
|
|
34
35
|
TreeActionDef,
|
|
35
36
|
WorkspaceDefinition,
|
|
@@ -72,6 +73,14 @@ export function buildRegistryFacade(state: RegistryState): Registry {
|
|
|
72
73
|
return state.queryHandlerMap;
|
|
73
74
|
},
|
|
74
75
|
|
|
76
|
+
getStreamHandler(name: string): StreamHandlerDef | undefined {
|
|
77
|
+
return state.streamHandlerMap.get(name);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
getAllStreamHandlers(): ReadonlyMap<string, StreamHandlerDef> {
|
|
81
|
+
return state.streamHandlerMap;
|
|
82
|
+
},
|
|
83
|
+
|
|
75
84
|
getSearchableFields(entityName: string): readonly string[] {
|
|
76
85
|
return state.searchableFieldsCache.get(entityName) ?? [];
|
|
77
86
|
},
|
|
@@ -80,6 +80,16 @@ export function populateHandlers(state: RegistryState, feature: FeatureDefinitio
|
|
|
80
80
|
state.queryHandlerMap.set(qualified, { ...handler, name: qualified });
|
|
81
81
|
state.handlerFeatureMap.set(qualified, feature.name);
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
// Stream handlers: scope:stream:name
|
|
85
|
+
for (const [name, handler] of Object.entries(feature.streamHandlers ?? {})) {
|
|
86
|
+
const qualified = qualify(feature.name, "stream", name);
|
|
87
|
+
if (state.streamHandlerMap.has(qualified)) {
|
|
88
|
+
throw new Error(`Duplicate stream handler: "${qualified}" (registered by multiple features)`);
|
|
89
|
+
}
|
|
90
|
+
state.streamHandlerMap.set(qualified, { ...handler, name: qualified });
|
|
91
|
+
state.handlerFeatureMap.set(qualified, feature.name);
|
|
92
|
+
}
|
|
83
93
|
}
|
|
84
94
|
|
|
85
95
|
// Config keys: scope:config:name, duplicate-guarded.
|
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
SearchPayloadContributorFn,
|
|
38
38
|
SecretKeyDefinition,
|
|
39
39
|
StoreTableDef,
|
|
40
|
+
StreamHandlerDef,
|
|
40
41
|
TreeActionDef,
|
|
41
42
|
WorkspaceDefinition,
|
|
42
43
|
WriteHandlerDef,
|
|
@@ -165,6 +166,7 @@ export type RegistryState = {
|
|
|
165
166
|
relationMap: Map<string, Record<string, RelationDefinition>>;
|
|
166
167
|
writeHandlerMap: Map<string, WriteHandlerDef>;
|
|
167
168
|
queryHandlerMap: Map<string, QueryHandlerDef>;
|
|
169
|
+
streamHandlerMap: Map<string, StreamHandlerDef>;
|
|
168
170
|
preSaveHooks: Map<string, OwnedFn<PreSaveHookFn>[]>;
|
|
169
171
|
postSaveHooks: Map<string, PhasedHook<PostSaveHookFn>[]>;
|
|
170
172
|
preDeleteHooks: Map<string, PhasedHook<PreDeleteHookFn>[]>;
|
|
@@ -229,6 +231,7 @@ export function createInitialState(): RegistryState {
|
|
|
229
231
|
relationMap: new Map(),
|
|
230
232
|
writeHandlerMap: new Map(),
|
|
231
233
|
queryHandlerMap: new Map(),
|
|
234
|
+
streamHandlerMap: new Map(),
|
|
232
235
|
preSaveHooks: new Map(),
|
|
233
236
|
postSaveHooks: new Map(),
|
|
234
237
|
preDeleteHooks: new Map(),
|