@cosmicdrift/kumiko-framework 0.158.2 → 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 +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- 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__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- 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/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +5 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +51 -17
- package/src/api/index.ts +3 -3
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/routes.ts +57 -0
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +46 -27
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -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__/store-table.test.ts +12 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +2 -0
- package/src/engine/define-handler.ts +11 -91
- package/src/engine/entity-handlers.ts +15 -27
- 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 +6 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +36 -2
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +5 -2
- package/src/engine/ownership.ts +22 -76
- 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/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -976
- package/src/engine/types/fields.ts +2 -697
- package/src/engine/types/handlers.ts +2 -839
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +66 -33
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -793
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/engine/types/workspace.ts +2 -42
- package/src/entrypoint/index.ts +2 -2
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +28 -51
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +51 -3
- 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/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/rate-limit/resolver.ts +10 -30
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +17 -45
- package/src/secrets/types.ts +2 -177
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/time/tz-context.ts +9 -56
- package/src/ui-types/index.ts +7 -7
|
@@ -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)} }`;
|
|
@@ -2,6 +2,7 @@ import type { ZodType, z } from "zod";
|
|
|
2
2
|
import { LifecycleHookTypes } from "./constants";
|
|
3
3
|
import type {
|
|
4
4
|
AuthClaimsFn,
|
|
5
|
+
BootCheckFn,
|
|
5
6
|
ClaimKeyDefinition,
|
|
6
7
|
ConfigKeyDefinition,
|
|
7
8
|
ConfigSeedDef,
|
|
@@ -29,6 +30,7 @@ import type {
|
|
|
29
30
|
SearchPayloadContributorFn,
|
|
30
31
|
SecretKeyDefinition,
|
|
31
32
|
StoreTableEntry,
|
|
33
|
+
StreamHandlerDef,
|
|
32
34
|
TranslationKeys,
|
|
33
35
|
TreeActionDef,
|
|
34
36
|
UiHints,
|
|
@@ -56,6 +58,7 @@ export type FeatureBuilderState = {
|
|
|
56
58
|
relations: Record<string, Record<string, RelationDefinition>>;
|
|
57
59
|
writeHandlers: Record<string, WriteHandlerDef>;
|
|
58
60
|
queryHandlers: Record<string, QueryHandlerDef>;
|
|
61
|
+
streamHandlers: Record<string, StreamHandlerDef>;
|
|
59
62
|
validationHooks: Record<string, ValidationHookFn>;
|
|
60
63
|
lifecycleHooks: Record<string, Record<string, OwnedFn<LifecycleHookFn>[]>>;
|
|
61
64
|
phasedLifecycleHooks: Record<
|
|
@@ -79,6 +82,7 @@ export type FeatureBuilderState = {
|
|
|
79
82
|
extensionSelectors: ExtensionSelectorDef[];
|
|
80
83
|
exposedApis: Set<string>;
|
|
81
84
|
usedApis: Set<string>;
|
|
85
|
+
bootChecks: BootCheckFn[];
|
|
82
86
|
referenceData: ReferenceDataDef[];
|
|
83
87
|
handlerEntityMappings: Record<string, string>;
|
|
84
88
|
metrics: Record<string, FeatureMetricDef>;
|
|
@@ -117,6 +121,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
|
|
|
117
121
|
relations: {},
|
|
118
122
|
writeHandlers: {},
|
|
119
123
|
queryHandlers: {},
|
|
124
|
+
streamHandlers: {},
|
|
120
125
|
validationHooks: {},
|
|
121
126
|
lifecycleHooks,
|
|
122
127
|
phasedLifecycleHooks: { postSave: {}, preDelete: {}, postDelete: {} },
|
|
@@ -137,6 +142,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
|
|
|
137
142
|
extensionSelectors: [],
|
|
138
143
|
exposedApis: new Set(),
|
|
139
144
|
usedApis: new Set(),
|
|
145
|
+
bootChecks: [],
|
|
140
146
|
referenceData: [],
|
|
141
147
|
handlerEntityMappings: {},
|
|
142
148
|
metrics: {},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ZodObject, type ZodType, type z } from "zod";
|
|
2
2
|
import type { FeatureBuilderState } from "./feature-builder-state";
|
|
3
|
+
import { resolveName } from "./handler-helpers";
|
|
3
4
|
import { splitNamedDefinition, unwrapArrayForm } from "./object-form";
|
|
4
5
|
import { QnTypes, qn, toKebab } from "./qualified-name";
|
|
5
6
|
import type {
|
|
@@ -26,7 +27,6 @@ import type {
|
|
|
26
27
|
SecretOptions,
|
|
27
28
|
TranslationsDef,
|
|
28
29
|
} from "./types";
|
|
29
|
-
import { resolveName } from "./types/handlers";
|
|
30
30
|
|
|
31
31
|
// Builds config/secrets/claims/events/jobs/notifications registrar methods.
|
|
32
32
|
export function buildConfigEventsJobsMethods<TName extends string>(
|
|
@@ -1,9 +1,14 @@
|
|
|
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";
|
|
11
|
+
import { resolveName } from "./handler-helpers";
|
|
7
12
|
import { splitNamedDefinition } from "./object-form";
|
|
8
13
|
import type {
|
|
9
14
|
AccessRule,
|
|
@@ -14,9 +19,9 @@ import type {
|
|
|
14
19
|
QueryHandlerFn,
|
|
15
20
|
RateLimitOption,
|
|
16
21
|
RelationDefinition,
|
|
22
|
+
StreamHandlerFn,
|
|
17
23
|
WriteHandlerFn,
|
|
18
24
|
} from "./types";
|
|
19
|
-
import { resolveName } from "./types/handlers";
|
|
20
25
|
import type { PipelineDef } from "./types/step";
|
|
21
26
|
|
|
22
27
|
const CRUD_VERBS = new Set(["create", "update", "delete"]);
|
|
@@ -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,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
2
2
|
import { LifecycleHookTypes } from "./constants";
|
|
3
3
|
import type { FeatureBuilderState } from "./feature-builder-state";
|
|
4
|
+
import { resolveName } from "./handler-helpers";
|
|
4
5
|
import { isKebabSegment, toKebab } from "./qualified-name";
|
|
5
6
|
import type {
|
|
7
|
+
BootCheckFn,
|
|
6
8
|
EntityProjectionExtension,
|
|
7
9
|
HookPhase,
|
|
8
10
|
LifecycleHookFn,
|
|
@@ -22,7 +24,6 @@ import type {
|
|
|
22
24
|
ValidationHookFn,
|
|
23
25
|
} from "./types";
|
|
24
26
|
import { HookPhases } from "./types";
|
|
25
|
-
import { resolveName } from "./types/handlers";
|
|
26
27
|
import type { HttpRouteDefinition } from "./types/http-route";
|
|
27
28
|
import type { NavDefinition } from "./types/nav";
|
|
28
29
|
import type { ScreenDefinition } from "./types/screen";
|
|
@@ -255,6 +256,9 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
255
256
|
usesApi(apiName: string): void {
|
|
256
257
|
state.usedApis.add(apiName);
|
|
257
258
|
},
|
|
259
|
+
bootCheck(fn: BootCheckFn): void {
|
|
260
|
+
state.bootChecks.push(fn);
|
|
261
|
+
},
|
|
258
262
|
projection(definition: ProjectionDefinition): void {
|
|
259
263
|
// Reject names that would blow up at registry-boot when we qualify them.
|
|
260
264
|
// Catch it at the registration site so the stack trace points at the
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AnyFileFieldDef, FieldDefinition } from "./types/fields";
|
|
2
|
+
|
|
3
|
+
// --- Currency ---
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CURRENCIES = [
|
|
6
|
+
"EUR",
|
|
7
|
+
"USD",
|
|
8
|
+
"GBP",
|
|
9
|
+
"CHF",
|
|
10
|
+
"JPY",
|
|
11
|
+
"SEK",
|
|
12
|
+
"NOK",
|
|
13
|
+
"DKK",
|
|
14
|
+
"PLN",
|
|
15
|
+
"CZK",
|
|
16
|
+
"CAD",
|
|
17
|
+
"AUD",
|
|
18
|
+
"NZD",
|
|
19
|
+
"CNY",
|
|
20
|
+
"INR",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export function isFileField(field: FieldDefinition | undefined): field is AnyFileFieldDef {
|
|
24
|
+
if (!field) return false;
|
|
25
|
+
return (
|
|
26
|
+
field.type === "file" ||
|
|
27
|
+
field.type === "image" ||
|
|
28
|
+
field.type === "files" ||
|
|
29
|
+
field.type === "images"
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { NameOrRef, WriteResult } from "./types/handlers";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Override the success-side `data` of a WriteResult while forwarding the
|
|
5
|
+
* failure half untouched. Useful for handlers that delegate to the
|
|
6
|
+
* event-store executor (which returns a SaveContext / DeleteContext
|
|
7
|
+
* envelope) but want to keep their own response shape — caller contract
|
|
8
|
+
* stays flat instead of leaking the executor's internals.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const result = await executor.delete({ id }, user, db);
|
|
12
|
+
* return withResponseData(result, { userId, tenantId });
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* On failure the same WriteFailure instance is returned — the error
|
|
16
|
+
* object round-trips without any wrapping, so the dispatcher / HTTP layer
|
|
17
|
+
* still read the original error code + httpStatus + i18nKey.
|
|
18
|
+
*/
|
|
19
|
+
export function withResponseData<T>(result: WriteResult<unknown>, data: T): WriteResult<T> {
|
|
20
|
+
if (!result.isSuccess) return result;
|
|
21
|
+
return { isSuccess: true, data };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveName(ref: NameOrRef): string {
|
|
25
|
+
return typeof ref === "string" ? ref : ref.name;
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// --- Hook Phases ---
|
|
2
|
+
//
|
|
3
|
+
// inTransaction: Hook runs inside the DB transaction. Failures roll back
|
|
4
|
+
// the entire write. Use for: DB-based side-effects (counter updates,
|
|
5
|
+
// dependent entity writes).
|
|
6
|
+
//
|
|
7
|
+
// afterCommit (default): Hook runs after the transaction commits. Failures
|
|
8
|
+
// are logged but don't affect the write. Use for: external systems
|
|
9
|
+
// (SSE broadcast, search index, email, webhooks).
|
|
10
|
+
|
|
11
|
+
export const HookPhases = {
|
|
12
|
+
inTransaction: "inTransaction",
|
|
13
|
+
afterCommit: "afterCommit",
|
|
14
|
+
} as const;
|