@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
package/src/db/tenant-db.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
|
+
import type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
1
3
|
import {
|
|
2
4
|
asEntityTableMeta,
|
|
3
5
|
asRawClient,
|
|
@@ -12,61 +14,10 @@ import {
|
|
|
12
14
|
import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
|
|
13
15
|
import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
|
|
14
16
|
import type { DbRunner } from "./connection";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// biome-ignore lint/suspicious/noExplicitAny: Dynamic tables — keys depend on user-defined schema.
|
|
20
|
-
type Table = TableColumns<any>;
|
|
21
|
-
|
|
22
|
-
// Method-form writes reject the executor-only brand exactly like the free-function
|
|
23
|
-
// helpers (#742): a managed EntityTable is a rebuildable projection, so writing it
|
|
24
|
-
// directly — free-function OR method-form — drifts the row past its event stream and
|
|
25
|
-
// a rebuild wipes it. The permissive base stays (raw pgTables AND unmanaged entity
|
|
26
|
-
// metas are not projections → writable); `& NotExecutorOnly` strips only branded
|
|
27
|
-
// EntityTables (its `[EXECUTOR_ONLY]: true` violates the optional-never). Reads keep
|
|
28
|
-
// the plain `Table` param.
|
|
29
|
-
type WritableTable = (Table | EntityTableMeta) & NotExecutorOnly;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* TenantDb scope modes:
|
|
33
|
-
*
|
|
34
|
-
* - "tenant" (default): SELECT/UPDATE/DELETE filtered by tenantId + reference data (tenantId=SYSTEM_TENANT_ID).
|
|
35
|
-
* INSERT forces tenantId — handler cannot override.
|
|
36
|
-
*
|
|
37
|
-
* - "system" (r.systemScope()): No tenant filter on reads/updates/deletes.
|
|
38
|
-
* INSERT uses tenantId as default but handler can override.
|
|
39
|
-
*
|
|
40
|
-
* Tables without a tenantId column are always unfiltered regardless of mode.
|
|
41
|
-
*/
|
|
42
|
-
export type TenantDbMode = "tenant" | "system";
|
|
43
|
-
|
|
44
|
-
export type TenantDb = {
|
|
45
|
-
readonly tenantId: TenantId;
|
|
46
|
-
readonly mode: TenantDbMode;
|
|
47
|
-
/**
|
|
48
|
-
* Underlying DbRunner. Framework-internal use (event-store, migrations) —
|
|
49
|
-
* bypasses tenant-filter. Feature code uses the typed helpers above so the
|
|
50
|
-
* automatic scoping stays intact.
|
|
51
|
-
*/
|
|
52
|
-
readonly raw: DbRunner;
|
|
53
|
-
selectMany<T = Record<string, unknown>>(
|
|
54
|
-
table: Table,
|
|
55
|
-
where?: WhereObject,
|
|
56
|
-
options?: SelectOptions,
|
|
57
|
-
): Promise<readonly T[]>;
|
|
58
|
-
fetchOne<T = Record<string, unknown>>(table: Table, where: WhereObject): Promise<T | undefined>;
|
|
59
|
-
insertOne<T = Record<string, unknown>>(
|
|
60
|
-
table: WritableTable,
|
|
61
|
-
values: Record<string, unknown>,
|
|
62
|
-
): Promise<T | undefined>;
|
|
63
|
-
updateMany<T = Record<string, unknown>>(
|
|
64
|
-
table: WritableTable,
|
|
65
|
-
set: Record<string, unknown>,
|
|
66
|
-
where: WhereObject,
|
|
67
|
-
): Promise<readonly T[]>;
|
|
68
|
-
deleteMany(table: WritableTable, where: WhereObject): Promise<void>;
|
|
69
|
-
};
|
|
17
|
+
|
|
18
|
+
type Table = SchemaTable;
|
|
19
|
+
|
|
20
|
+
export type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
70
21
|
|
|
71
22
|
// @cast-boundary tenant-db-row
|
|
72
23
|
export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[] {
|
|
@@ -590,6 +590,52 @@ describe("boot-validator", () => {
|
|
|
590
590
|
expect(() => validateBoot(features)).not.toThrow();
|
|
591
591
|
});
|
|
592
592
|
|
|
593
|
+
test("throws when a stream handler has no access rule", () => {
|
|
594
|
+
const features = [
|
|
595
|
+
defineFeature("a", (r) => {
|
|
596
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
597
|
+
}),
|
|
598
|
+
];
|
|
599
|
+
expect(() => validateBoot(features)).toThrow(/a:stream:chat:complete.*missing an access rule/i);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("accepts openToAll access rule on a stream handler", () => {
|
|
603
|
+
const features = [
|
|
604
|
+
defineFeature("a", (r) => {
|
|
605
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {}, {
|
|
606
|
+
access: { openToAll: true },
|
|
607
|
+
});
|
|
608
|
+
}),
|
|
609
|
+
];
|
|
610
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("throws when an anonymous stream handler uses a user-bucketed rate limit", () => {
|
|
614
|
+
const features = [
|
|
615
|
+
defineFeature("a", (r) => {
|
|
616
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {}, {
|
|
617
|
+
access: { roles: ["anonymous"] },
|
|
618
|
+
rateLimit: { per: "user", limit: 10, windowSeconds: 60 },
|
|
619
|
+
});
|
|
620
|
+
}),
|
|
621
|
+
];
|
|
622
|
+
expect(() => validateBoot(features)).toThrow(
|
|
623
|
+
/stream handler "a:stream:chat:complete" allows anonymous callers/i,
|
|
624
|
+
);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("accepts an anonymous stream handler with an ip-bucketed rate limit", () => {
|
|
628
|
+
const features = [
|
|
629
|
+
defineFeature("a", (r) => {
|
|
630
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {}, {
|
|
631
|
+
access: { roles: ["anonymous"] },
|
|
632
|
+
rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
|
|
633
|
+
});
|
|
634
|
+
}),
|
|
635
|
+
];
|
|
636
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
637
|
+
});
|
|
638
|
+
|
|
593
639
|
describe("config key bounds consistency", () => {
|
|
594
640
|
test("accepts number key with consistent bounds + default", () => {
|
|
595
641
|
const features = [
|
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
|
|
2
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
// Bun.Glob is only used by scanForCandidates / runCodemod
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
// Bun.Glob is only used by scanForCandidates / runCodemod. Emulate
|
|
6
|
+
// **/*.write.ts via node:fs so scan/convert paths stay testable.
|
|
7
|
+
function collectWriteTsRelPaths(rootDir: string, dir = rootDir, prefix = ""): string[] {
|
|
8
|
+
const out: string[] = [];
|
|
9
|
+
for (const name of readdirSync(dir)) {
|
|
10
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
11
|
+
const full = join(dir, name);
|
|
12
|
+
if (statSync(full).isDirectory()) {
|
|
13
|
+
out.push(...collectWriteTsRelPaths(rootDir, full, rel));
|
|
14
|
+
} else if (name.endsWith(".write.ts")) {
|
|
15
|
+
out.push(rel);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
mock.module("bun", () => {
|
|
11
|
-
class
|
|
12
|
-
|
|
13
|
-
|
|
22
|
+
class FsGlob {
|
|
23
|
+
constructor(private readonly pattern: string) {}
|
|
24
|
+
scanSync(rootDir: string): string[] {
|
|
25
|
+
if (this.pattern !== "**/*.write.ts") return [];
|
|
26
|
+
return collectWriteTsRelPaths(rootDir).sort();
|
|
14
27
|
}
|
|
15
28
|
}
|
|
16
|
-
return { Glob:
|
|
29
|
+
return { Glob: FsGlob };
|
|
17
30
|
});
|
|
18
31
|
|
|
19
32
|
import {
|
|
@@ -21,6 +34,8 @@ import {
|
|
|
21
34
|
analyzeHandlerArrow,
|
|
22
35
|
convertFile,
|
|
23
36
|
generatePerformBlock,
|
|
37
|
+
runCodemod,
|
|
38
|
+
scanForCandidates,
|
|
24
39
|
} from "../codemod/index";
|
|
25
40
|
|
|
26
41
|
const tmpDir = join(__dirname, "__codemod_fixtures__");
|
|
@@ -31,6 +46,20 @@ function writeFixture(name: string, content: string): string {
|
|
|
31
46
|
return p;
|
|
32
47
|
}
|
|
33
48
|
|
|
49
|
+
function writeFixtureIn(dir: string, name: string, content: string): string {
|
|
50
|
+
const p = join(dir, name);
|
|
51
|
+
mkdirSync(join(p, ".."), { recursive: true });
|
|
52
|
+
writeFileSync(p, content, "utf8");
|
|
53
|
+
return p;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function freshScanRoot(name: string): string {
|
|
57
|
+
const dir = join(tmpDir, name);
|
|
58
|
+
rmSync(dir, { recursive: true, force: true });
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
|
|
34
63
|
beforeAll(() => {
|
|
35
64
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
36
65
|
mkdirSync(tmpDir, { recursive: true });
|
|
@@ -399,6 +428,31 @@ export const h = defineWriteHandler({
|
|
|
399
428
|
// ts-morph should handle this gracefully (parse error → no call found)
|
|
400
429
|
expect(result.status).toBe("skipped");
|
|
401
430
|
});
|
|
431
|
+
|
|
432
|
+
it("returns error when the path is not a readable file", async () => {
|
|
433
|
+
const dirPath = join(tmpDir, "dir-not-file.write.ts");
|
|
434
|
+
rmSync(dirPath, { recursive: true, force: true });
|
|
435
|
+
mkdirSync(dirPath, { recursive: true });
|
|
436
|
+
const result = await convertFile(dirPath);
|
|
437
|
+
expect(result.status).toBe("error");
|
|
438
|
+
expect(result.reason).toBeDefined();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("verbose logs when skipping a non-trivial handler", async () => {
|
|
442
|
+
const p = writeFixture("verbose-skip-file.write.ts", complexContent);
|
|
443
|
+
const logs: string[] = [];
|
|
444
|
+
const origLog = console.log;
|
|
445
|
+
console.log = (...args: unknown[]) => {
|
|
446
|
+
logs.push(args.map(String).join(" "));
|
|
447
|
+
};
|
|
448
|
+
try {
|
|
449
|
+
const result = await convertFile(p, undefined, { verbose: true });
|
|
450
|
+
expect(result.status).toBe("skipped");
|
|
451
|
+
expect(logs.some((l) => l.includes("non-trivial handler"))).toBe(true);
|
|
452
|
+
} finally {
|
|
453
|
+
console.log = origLog;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
402
456
|
});
|
|
403
457
|
});
|
|
404
458
|
|
|
@@ -509,6 +563,81 @@ describe("analyzeHandlerArrow", () => {
|
|
|
509
563
|
expect(result.isGuardedCreate).toBe(true);
|
|
510
564
|
expect(result.isSimpleExecutorCreate).toBe(false);
|
|
511
565
|
});
|
|
566
|
+
|
|
567
|
+
it("returns non-convertible defaults for async ( without =>", () => {
|
|
568
|
+
const result = analyzeHandlerArrow("async (event, ctx) { return { isSuccess: true } }");
|
|
569
|
+
expect(result.isStaticReturn).toBe(false);
|
|
570
|
+
expect(result.isSimpleExecutorCreate).toBe(false);
|
|
571
|
+
expect(result.isSimpleExecutorUpdate).toBe(false);
|
|
572
|
+
expect(result.hasConditionalLogic).toBe(false);
|
|
573
|
+
expect(result.isExpressionBodyCreate).toBe(false);
|
|
574
|
+
expect(result.isGuardedCreate).toBe(false);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
describe("scanForCandidates", () => {
|
|
579
|
+
it("returns empty list when no *.write.ts files exist", () => {
|
|
580
|
+
const root = freshScanRoot("scan-empty");
|
|
581
|
+
expect(scanForCandidates(root)).toEqual([]);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("finds and classifies *.write.ts fixtures under rootDir", () => {
|
|
585
|
+
const root = freshScanRoot("scan-mixed");
|
|
586
|
+
writeFixtureIn(root, "freeform.write.ts", staticReturnContent);
|
|
587
|
+
writeFixtureIn(root, "already-pipeline.write.ts", alreadyPipelineContent);
|
|
588
|
+
writeFixtureIn(root, "nested/deep.write.ts", queryHandlerContent);
|
|
589
|
+
|
|
590
|
+
const results = scanForCandidates(root);
|
|
591
|
+
expect(results.map((r) => r.filePath).sort()).toEqual(
|
|
592
|
+
[
|
|
593
|
+
join(root, "already-pipeline.write.ts"),
|
|
594
|
+
join(root, "freeform.write.ts"),
|
|
595
|
+
join(root, "nested/deep.write.ts"),
|
|
596
|
+
].sort(),
|
|
597
|
+
);
|
|
598
|
+
expect(results.find((r) => r.pattern === "free-form-write")?.convertible).toBe(true);
|
|
599
|
+
expect(results.find((r) => r.pattern === "pipeline-write")?.convertible).toBe(false);
|
|
600
|
+
expect(results.find((r) => r.pattern === "query-handler")?.convertible).toBe(false);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
describe("runCodemod", () => {
|
|
605
|
+
it("returns empty report when scan finds no convertible handlers", async () => {
|
|
606
|
+
const root = freshScanRoot("run-empty");
|
|
607
|
+
const report = await runCodemod(root);
|
|
608
|
+
expect(report).toEqual({ results: [], converted: 0, skipped: 0, errors: 0 });
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("converts free-form write handlers discovered by scan", async () => {
|
|
612
|
+
const root = freshScanRoot("run-convert");
|
|
613
|
+
const p = writeFixtureIn(root, "to-convert.write.ts", staticReturnContent);
|
|
614
|
+
const report = await runCodemod(root);
|
|
615
|
+
expect(report.converted).toBe(1);
|
|
616
|
+
expect(report.skipped).toBe(0);
|
|
617
|
+
expect(report.errors).toBe(0);
|
|
618
|
+
expect(readFileSync(p, "utf8")).toContain("perform: stepsPipeline");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("verbose logs per-file outcomes", async () => {
|
|
622
|
+
const root = freshScanRoot("run-verbose");
|
|
623
|
+
writeFixtureIn(root, "verbose-convert.write.ts", staticReturnContent);
|
|
624
|
+
writeFixtureIn(root, "verbose-skip.write.ts", complexContent);
|
|
625
|
+
|
|
626
|
+
const logs: string[] = [];
|
|
627
|
+
const origLog = console.log;
|
|
628
|
+
console.log = (...args: unknown[]) => {
|
|
629
|
+
logs.push(args.map(String).join(" "));
|
|
630
|
+
};
|
|
631
|
+
try {
|
|
632
|
+
const report = await runCodemod(root, { verbose: true });
|
|
633
|
+
expect(report.converted).toBe(1);
|
|
634
|
+
expect(report.skipped).toBe(1);
|
|
635
|
+
expect(logs.some((l) => l.includes("verbose-convert.write.ts"))).toBe(true);
|
|
636
|
+
expect(logs.some((l) => l.includes("verbose-skip.write.ts"))).toBe(true);
|
|
637
|
+
} finally {
|
|
638
|
+
console.log = origLog;
|
|
639
|
+
}
|
|
640
|
+
});
|
|
512
641
|
});
|
|
513
642
|
|
|
514
643
|
describe("generatePerformBlock", () => {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
createTextField,
|
|
15
15
|
} from "../factories";
|
|
16
16
|
import { createApp, createRegistry, defineFeature } from "../index";
|
|
17
|
+
import { buildMinimalCtx } from "./_pipeline-test-utils";
|
|
17
18
|
|
|
18
19
|
// --- defineFeature ---
|
|
19
20
|
|
|
@@ -118,6 +119,33 @@ describe("defineFeature", () => {
|
|
|
118
119
|
expect(ref?.name).toBe("order:list");
|
|
119
120
|
});
|
|
120
121
|
|
|
122
|
+
test("streamHandler registers on feature.streamHandlers with schema/access/handler intact", async () => {
|
|
123
|
+
let ref: { name: string } | undefined;
|
|
124
|
+
const feature = defineFeature("test", (r) => {
|
|
125
|
+
ref = r.streamHandler(
|
|
126
|
+
"chat:complete",
|
|
127
|
+
z.object({ prompt: z.string() }),
|
|
128
|
+
async function* (query) {
|
|
129
|
+
yield `echo:${query.payload.prompt}`;
|
|
130
|
+
},
|
|
131
|
+
{ access: { openToAll: true } },
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
expect(ref?.name).toBe("chat:complete");
|
|
135
|
+
|
|
136
|
+
const stored = feature.streamHandlers["chat:complete"];
|
|
137
|
+
expect(stored?.access).toEqual({ openToAll: true });
|
|
138
|
+
|
|
139
|
+
const chunks: string[] = [];
|
|
140
|
+
for await (const chunk of stored!.handler(
|
|
141
|
+
{ type: "chat:complete", payload: { prompt: "hi" }, user: createTestUser() },
|
|
142
|
+
buildMinimalCtx(),
|
|
143
|
+
)) {
|
|
144
|
+
chunks.push(chunk as string);
|
|
145
|
+
}
|
|
146
|
+
expect(chunks).toEqual(["echo:hi"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
121
149
|
test("r.defineEvent returns typed EventDef and registers on feature", () => {
|
|
122
150
|
let eventRef: { name: string } | undefined;
|
|
123
151
|
const feature = defineFeature("orders", (r) => {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Dilution reclaim: integration lcov registers every facade getter as DA:N,0
|
|
2
|
+
// when the file loads, but unit only instruments getters that a test touches.
|
|
3
|
+
// Calling every getter once in unit raises LF+LH so the badge merge stops
|
|
4
|
+
// counting those one-liners as uncovered.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { createRegistry } from "../registry";
|
|
8
|
+
|
|
9
|
+
describe("buildRegistryFacade — getter sweep", () => {
|
|
10
|
+
test("empty registry: every getter is callable without throw", () => {
|
|
11
|
+
const r = createRegistry([]);
|
|
12
|
+
|
|
13
|
+
expect(r.features.size).toBe(0);
|
|
14
|
+
expect(r.getFeature("x")).toBeUndefined();
|
|
15
|
+
expect(r.hasRateLimitedHandler()).toBe(false);
|
|
16
|
+
expect(r.getEntity("x")).toBeUndefined();
|
|
17
|
+
expect(r.getAllEntities().size).toBe(0);
|
|
18
|
+
expect(r.getWriteHandler("x")).toBeUndefined();
|
|
19
|
+
expect(r.getQueryHandler("x")).toBeUndefined();
|
|
20
|
+
expect(r.getAllQueryHandlers().size).toBe(0);
|
|
21
|
+
expect(r.getSearchableFields("x")).toEqual([]);
|
|
22
|
+
expect(r.getSortableFields("x")).toEqual([]);
|
|
23
|
+
expect(r.getRelations("x")).toEqual({});
|
|
24
|
+
expect(r.getSearchIncludes("x").size).toBe(0);
|
|
25
|
+
expect(r.getIncomingRelations("x")).toEqual([]);
|
|
26
|
+
expect(r.getPreSaveHooks("x")).toEqual([]);
|
|
27
|
+
expect(r.getPostSaveHooks("x")).toEqual([]);
|
|
28
|
+
expect(r.getPreDeleteHooks("x")).toEqual([]);
|
|
29
|
+
expect(r.getPostDeleteHooks("x")).toEqual([]);
|
|
30
|
+
expect(r.getPreQueryHooks("x")).toEqual([]);
|
|
31
|
+
expect(r.getPostQueryHooks("x")).toEqual([]);
|
|
32
|
+
expect(r.getEntityPostSaveHooks("x")).toEqual([]);
|
|
33
|
+
expect(r.getEntityPreDeleteHooks("x")).toEqual([]);
|
|
34
|
+
expect(r.getEntityPostDeleteHooks("x")).toEqual([]);
|
|
35
|
+
expect(r.getEntityPostQueryHooks("x")).toEqual([]);
|
|
36
|
+
expect(r.getSearchPayloadExtensions("x")).toEqual([]);
|
|
37
|
+
expect(r.getAllTranslations()).toBeDefined();
|
|
38
|
+
expect(r.getHandlerEntity("x")).toBeUndefined();
|
|
39
|
+
expect(r.isHandlerSystemScoped("x")).toBe(false);
|
|
40
|
+
expect(r.getHandlerFeature("x")).toBeUndefined();
|
|
41
|
+
expect(r.getAllMetrics().size).toBe(0);
|
|
42
|
+
expect(r.getAllSecretKeys().size).toBe(0);
|
|
43
|
+
expect(r.getSecretKey("x")).toBeUndefined();
|
|
44
|
+
expect(r.getConfigKey("x")).toBeUndefined();
|
|
45
|
+
expect(r.getAllConfigKeys().size).toBe(0);
|
|
46
|
+
expect(r.getJob("x")).toBeUndefined();
|
|
47
|
+
expect(r.getAllJobs().size).toBe(0);
|
|
48
|
+
expect(r.getEvent("x")).toBeUndefined();
|
|
49
|
+
expect(r.getEventUpcasters().size).toBe(0);
|
|
50
|
+
expect(r.getExtension("x")).toBeUndefined();
|
|
51
|
+
expect(r.getExtensionUsages("x")).toEqual([]);
|
|
52
|
+
expect(r.getAllExtensionSelectors().size).toBe(0);
|
|
53
|
+
expect(r.getAllNotifications().size).toBe(0);
|
|
54
|
+
expect(r.getAllReferenceData()).toEqual([]);
|
|
55
|
+
expect(r.getAllConfigSeeds()).toEqual([]);
|
|
56
|
+
expect(r.getProjectionsForSource("x")).toEqual([]);
|
|
57
|
+
expect(r.getAllProjections().size).toBe(0);
|
|
58
|
+
expect(r.getAllStoreTables().size).toBe(0);
|
|
59
|
+
expect(r.getAllMultiStreamProjections().size).toBe(0);
|
|
60
|
+
expect(r.getMultiStreamProjectionFeature("x")).toBeUndefined();
|
|
61
|
+
expect(r.getAuthClaimsHooks()).toEqual([]);
|
|
62
|
+
expect(r.getAllClaimKeys().size).toBe(0);
|
|
63
|
+
expect(r.getClaimKey("x")).toBeUndefined();
|
|
64
|
+
expect(r.getAllScreens().size).toBe(0);
|
|
65
|
+
expect(r.getScreen("x")).toBeUndefined();
|
|
66
|
+
expect(r.getScreenFeature("x")).toBeUndefined();
|
|
67
|
+
expect(r.getScreensByEntity("x")).toEqual([]);
|
|
68
|
+
expect(r.getAllNavs().size).toBe(0);
|
|
69
|
+
expect(r.getNav("x")).toBeUndefined();
|
|
70
|
+
expect(r.getNavFeature("x")).toBeUndefined();
|
|
71
|
+
expect(r.getNavsByParent("x")).toEqual([]);
|
|
72
|
+
expect(r.getTopLevelNavs()).toEqual([]);
|
|
73
|
+
expect(r.getAllWorkspaces().size).toBe(0);
|
|
74
|
+
expect(r.getWorkspace("x")).toBeUndefined();
|
|
75
|
+
expect(r.getWorkspaceFeature("x")).toBeUndefined();
|
|
76
|
+
expect(r.getWorkspaceNavs("x")).toEqual([]);
|
|
77
|
+
expect(r.getDefaultWorkspace()).toBeUndefined();
|
|
78
|
+
expect(r.getTreeActions("x")).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -101,6 +101,46 @@ describe("getAllQueryHandlers", () => {
|
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
describe("getAllStreamHandlers", () => {
|
|
105
|
+
test("returns every registered stream handler, qualified, across multiple features", () => {
|
|
106
|
+
const aiFeature = defineFeature("registry-test-ai", (r) => {
|
|
107
|
+
r.streamHandler("chat:complete", z.object({ prompt: z.string() }), async function* () {}, {
|
|
108
|
+
access: { openToAll: true },
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
const otherFeature = defineFeature("registry-test-other", (r) => {
|
|
112
|
+
r.streamHandler("chat:complete", z.object({ prompt: z.string() }), async function* () {}, {
|
|
113
|
+
access: { openToAll: true },
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const registry = createRegistry([aiFeature, otherFeature]);
|
|
118
|
+
const handlers = registry.getAllStreamHandlers();
|
|
119
|
+
|
|
120
|
+
expect(handlers.get("registry-test-ai:stream:chat:complete")).toBeDefined();
|
|
121
|
+
expect(handlers.get("registry-test-other:stream:chat:complete")).toBeDefined();
|
|
122
|
+
// Same Map instance getStreamHandler reads from, not a copy that can drift.
|
|
123
|
+
expect(handlers.get("registry-test-ai:stream:chat:complete")).toBe(
|
|
124
|
+
registry.getStreamHandler("registry-test-ai:stream:chat:complete"),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("empty registry returns an empty map, not undefined", () => {
|
|
129
|
+
const registry = createRegistry([]);
|
|
130
|
+
expect(registry.getAllStreamHandlers().size).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("duplicate stream-handler short-name across features qualifies independently, no collision", () => {
|
|
134
|
+
const aiFeature = defineFeature("registry-test-dup-a", (r) => {
|
|
135
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
136
|
+
});
|
|
137
|
+
const otherFeature = defineFeature("registry-test-dup-b", (r) => {
|
|
138
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
139
|
+
});
|
|
140
|
+
expect(() => createRegistry([aiFeature, otherFeature])).not.toThrow();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
104
144
|
describe("extensionSelector boot-validation", () => {
|
|
105
145
|
function foundationFeature() {
|
|
106
146
|
return defineFeature("probe-foundation", (r) => {
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
7
|
import { createEntity, defineFeature } from "../index";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
findTierResolverUsage,
|
|
10
|
+
isTierResolverPlugin,
|
|
11
|
+
TENANT_TIER_RESOLVER_EXT,
|
|
12
|
+
} from "../tier-resolver-extension";
|
|
9
13
|
|
|
10
14
|
function tierResolverFeature(name: string) {
|
|
11
15
|
return defineFeature(name, (r) => {
|
|
@@ -49,3 +53,17 @@ describe("findTierResolverUsage", () => {
|
|
|
49
53
|
expect(findTierResolverUsage([])).toBeUndefined();
|
|
50
54
|
});
|
|
51
55
|
});
|
|
56
|
+
|
|
57
|
+
describe("isTierResolverPlugin", () => {
|
|
58
|
+
test("true when build is a function", () => {
|
|
59
|
+
expect(isTierResolverPlugin({ build: async () => new Set() })).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("false for null, non-objects, and missing/non-fn build", () => {
|
|
63
|
+
expect(isTierResolverPlugin(null)).toBe(false);
|
|
64
|
+
expect(isTierResolverPlugin(undefined)).toBe(false);
|
|
65
|
+
expect(isTierResolverPlugin("x")).toBe(false);
|
|
66
|
+
expect(isTierResolverPlugin({})).toBe(false);
|
|
67
|
+
expect(isTierResolverPlugin({ build: 1 })).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -119,11 +119,20 @@ export function validateHandlerAccess(feature: FeatureDefinition): void {
|
|
|
119
119
|
}
|
|
120
120
|
validateAnonymousRateLimit(feature.name, "query", name, handler.access, handler.rateLimit);
|
|
121
121
|
}
|
|
122
|
+
for (const [name, handler] of Object.entries(feature.streamHandlers)) {
|
|
123
|
+
if (!handler.access) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Stream handler "${feature.name}:stream:${name}" is missing an access rule. ` +
|
|
126
|
+
`Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
validateAnonymousRateLimit(feature.name, "stream", name, handler.access, handler.rateLimit);
|
|
130
|
+
}
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
export function validateAnonymousRateLimit(
|
|
125
134
|
featureName: string,
|
|
126
|
-
kind: "write" | "query",
|
|
135
|
+
kind: "write" | "query" | "stream",
|
|
127
136
|
handlerName: string,
|
|
128
137
|
access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
|
|
129
138
|
rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
|
|
@@ -106,6 +106,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
106
106
|
relations: state.relations,
|
|
107
107
|
writeHandlers: state.writeHandlers,
|
|
108
108
|
queryHandlers: state.queryHandlers,
|
|
109
|
+
streamHandlers: state.streamHandlers,
|
|
109
110
|
translations: state.translations,
|
|
110
111
|
hooks: {
|
|
111
112
|
validation: state.validationHooks,
|
|
@@ -78,6 +78,15 @@ defineFeature("todoList", (r) => {
|
|
|
78
78
|
access: { openToAll: true },
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
r.streamHandler({
|
|
82
|
+
name: "task:stream",
|
|
83
|
+
schema: z.object({ prompt: z.string() }),
|
|
84
|
+
handler: async function* (input, ctx) {
|
|
85
|
+
yield "token";
|
|
86
|
+
},
|
|
87
|
+
access: { roles: ["user"] },
|
|
88
|
+
});
|
|
89
|
+
|
|
81
90
|
r.hook({
|
|
82
91
|
type: "postSave",
|
|
83
92
|
target: "task",
|
|
@@ -195,7 +204,7 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
|
|
|
195
204
|
expect(unknowns).toEqual([]);
|
|
196
205
|
});
|
|
197
206
|
|
|
198
|
-
test("alle
|
|
207
|
+
test("alle Pattern-Kinds sind erfasst", () => {
|
|
199
208
|
const kinds: ReadonlySet<string> = new Set(result.patterns.map((p) => p.kind));
|
|
200
209
|
const expected = [
|
|
201
210
|
// Static
|
|
@@ -219,6 +228,7 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
|
|
|
219
228
|
"screen",
|
|
220
229
|
"writeHandler",
|
|
221
230
|
"queryHandler",
|
|
231
|
+
"streamHandler",
|
|
222
232
|
"hook",
|
|
223
233
|
"job",
|
|
224
234
|
"notification",
|