@cosmicdrift/kumiko-framework 0.2.2 → 0.3.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/CHANGELOG.md +54 -0
- package/package.json +124 -38
- package/src/__tests__/full-stack.integration.ts +2 -2
- package/src/api/auth-routes.ts +5 -5
- package/src/api/jwt.ts +2 -2
- package/src/api/route-registrars.ts +1 -1
- package/src/api/routes.ts +3 -3
- package/src/api/server.ts +6 -7
- package/src/auth/__tests__/roles.test.ts +24 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/roles.ts +42 -0
- package/src/compliance/__tests__/duration-spec.test.ts +72 -0
- package/src/compliance/__tests__/profiles.test.ts +308 -0
- package/src/compliance/__tests__/sub-processors.test.ts +139 -0
- package/src/compliance/duration-spec.ts +44 -0
- package/src/compliance/index.ts +31 -0
- package/src/compliance/override-schema.ts +136 -0
- package/src/compliance/profiles.ts +427 -0
- package/src/compliance/sub-processors.ts +152 -0
- package/src/db/__tests__/big-int-field.test.ts +131 -0
- package/src/db/assert-exists-in.ts +2 -2
- package/src/db/cursor.ts +3 -3
- package/src/db/event-store-executor.ts +19 -13
- package/src/db/located-timestamp.ts +1 -1
- package/src/db/money.ts +12 -2
- package/src/db/pg-error.ts +1 -1
- package/src/db/row-helpers.ts +1 -1
- package/src/db/table-builder.ts +20 -5
- package/src/db/tenant-db.ts +9 -9
- package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
- package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
- package/src/engine/__tests__/build-target.test.ts +135 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
- package/src/engine/__tests__/entity-handlers.test.ts +3 -3
- package/src/engine/__tests__/event-helpers.test.ts +4 -4
- package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
- package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
- package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
- package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
- package/src/engine/__tests__/raw-table.test.ts +2 -2
- package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
- package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
- package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
- package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
- package/src/engine/__tests__/steps-read.test.ts +142 -0
- package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
- package/src/engine/__tests__/steps-workflow.test.ts +198 -0
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
- package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
- package/src/engine/boot-validator/api-ext.ts +77 -0
- package/src/engine/boot-validator/config-deps.ts +163 -0
- package/src/engine/boot-validator/entity-handler.ts +466 -0
- package/src/engine/boot-validator/index.ts +159 -0
- package/src/engine/boot-validator/ownership.ts +198 -0
- package/src/engine/boot-validator/pii-retention.ts +155 -0
- package/src/engine/boot-validator/screens-nav.ts +624 -0
- package/src/engine/boot-validator.ts +1 -1528
- package/src/engine/build-app-schema.ts +1 -1
- package/src/engine/build-target.ts +99 -0
- package/src/engine/codemod/index.ts +15 -0
- package/src/engine/codemod/pipeline-codemod.ts +641 -0
- package/src/engine/config-helpers.ts +9 -19
- package/src/engine/constants.ts +1 -1
- package/src/engine/define-feature.ts +127 -9
- package/src/engine/define-handler.ts +89 -3
- package/src/engine/define-roles.ts +2 -2
- package/src/engine/define-step.ts +28 -0
- package/src/engine/define-workflow.ts +110 -0
- package/src/engine/entity-handlers.ts +10 -9
- package/src/engine/event-helpers.ts +4 -4
- package/src/engine/extension-names.ts +105 -0
- package/src/engine/extensions/user-data.ts +106 -0
- package/src/engine/factories.ts +26 -16
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
- package/src/engine/feature-ast/extractors/index.ts +74 -0
- package/src/engine/feature-ast/extractors/round1.ts +110 -0
- package/src/engine/feature-ast/extractors/round2.ts +253 -0
- package/src/engine/feature-ast/extractors/round3.ts +471 -0
- package/src/engine/feature-ast/extractors/round4.ts +1365 -0
- package/src/engine/feature-ast/extractors/round5.ts +72 -0
- package/src/engine/feature-ast/extractors/round6.ts +66 -0
- package/src/engine/feature-ast/extractors/shared.ts +177 -0
- package/src/engine/feature-ast/parse.ts +13 -0
- package/src/engine/feature-ast/patch.ts +9 -1
- package/src/engine/feature-ast/patcher.ts +10 -3
- package/src/engine/feature-ast/patterns.ts +71 -1
- package/src/engine/feature-ast/render.ts +31 -1
- package/src/engine/index.ts +66 -2
- package/src/engine/pattern-library/__tests__/library.test.ts +11 -0
- package/src/engine/pattern-library/library.ts +78 -2
- package/src/engine/pipeline.ts +88 -0
- package/src/engine/projection-helpers.ts +1 -1
- package/src/engine/read-claim.ts +1 -1
- package/src/engine/registry.ts +30 -2
- package/src/engine/resolve-config-or-param.ts +4 -0
- package/src/engine/run-pipeline.ts +162 -0
- package/src/engine/schema-builder.ts +10 -4
- package/src/engine/state-machine.ts +1 -1
- package/src/engine/steps/_drizzle-boundary.ts +19 -0
- package/src/engine/steps/_duration-utils.ts +33 -0
- package/src/engine/steps/_no-return-guard.ts +21 -0
- package/src/engine/steps/_resolver-utils.ts +42 -0
- package/src/engine/steps/_step-dispatch-constants.ts +38 -0
- package/src/engine/steps/aggregate-append-event.ts +56 -0
- package/src/engine/steps/aggregate-create.ts +56 -0
- package/src/engine/steps/aggregate-update.ts +68 -0
- package/src/engine/steps/branch.ts +84 -0
- package/src/engine/steps/call-feature.ts +49 -0
- package/src/engine/steps/compute.ts +41 -0
- package/src/engine/steps/for-each.ts +111 -0
- package/src/engine/steps/mail-send.ts +44 -0
- package/src/engine/steps/read-find-many.ts +51 -0
- package/src/engine/steps/read-find-one.ts +58 -0
- package/src/engine/steps/retry.ts +87 -0
- package/src/engine/steps/return.ts +34 -0
- package/src/engine/steps/unsafe-projection-delete.ts +46 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
- package/src/engine/steps/wait-for-event.ts +71 -0
- package/src/engine/steps/wait.ts +69 -0
- package/src/engine/steps/webhook-send.ts +71 -0
- package/src/engine/system-user.ts +1 -1
- package/src/engine/types/feature.ts +143 -1
- package/src/engine/types/fields.ts +134 -10
- package/src/engine/types/handlers.ts +18 -10
- package/src/engine/types/identifiers.ts +1 -0
- package/src/engine/types/index.ts +15 -1
- package/src/engine/types/step.ts +334 -0
- package/src/engine/types/target-ref.ts +21 -0
- package/src/engine/types/tree-node.ts +130 -0
- package/src/engine/types/workspace.ts +7 -0
- package/src/engine/validate-projection-allowlist.ts +161 -0
- package/src/event-store/snapshot.ts +1 -1
- package/src/event-store/upcaster-dead-letter.ts +1 -1
- package/src/event-store/upcaster.ts +1 -1
- package/src/files/__tests__/read-stream.test.ts +105 -0
- package/src/files/__tests__/write-stream.test.ts +233 -0
- package/src/files/__tests__/zip-stream.test.ts +357 -0
- package/src/files/file-routes.ts +1 -1
- package/src/files/in-memory-provider.ts +38 -0
- package/src/files/index.ts +3 -0
- package/src/files/local-provider.ts +58 -1
- package/src/files/types.ts +36 -8
- package/src/files/zip-stream.ts +251 -0
- package/src/jobs/job-runner.ts +10 -10
- package/src/lifecycle/lifecycle.ts +0 -3
- package/src/logging/index.ts +1 -0
- package/src/logging/pino-logger.ts +11 -7
- package/src/logging/utils.ts +24 -0
- package/src/observability/prometheus-meter.ts +7 -5
- package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
- package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
- package/src/pipeline/append-event-core.ts +22 -6
- package/src/pipeline/dispatcher-utils.ts +188 -0
- package/src/pipeline/dispatcher.ts +63 -283
- package/src/pipeline/distributed-lock.ts +1 -1
- package/src/pipeline/entity-cache.ts +2 -2
- package/src/pipeline/event-consumer-state.ts +0 -13
- package/src/pipeline/event-dispatcher.ts +4 -4
- package/src/pipeline/index.ts +0 -2
- package/src/pipeline/lifecycle-pipeline.ts +6 -12
- package/src/pipeline/msp-rebuild.ts +5 -5
- package/src/pipeline/multi-stream-apply-context.ts +6 -7
- package/src/pipeline/projection-rebuild.ts +2 -2
- package/src/pipeline/projection-state.ts +0 -12
- package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
- package/src/rate-limit/resolver.ts +1 -1
- package/src/search/in-memory-adapter.ts +1 -1
- package/src/search/meilisearch-adapter.ts +3 -3
- package/src/search/types.ts +1 -1
- package/src/secrets/leak-guard.ts +2 -2
- package/src/stack/request-helper.ts +9 -5
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/handler-context.ts +4 -4
- package/src/testing/http-cookies.ts +1 -1
- package/src/time/tz-context.ts +1 -2
- package/src/ui-types/index.ts +4 -0
- package/src/engine/feature-ast/extractors.ts +0 -2562
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Unit-Tests fuer den BigInt-Field-Type — Atom 1a aus dem User-Data-
|
|
2
|
+
// Rights Async-Export-Plan.
|
|
3
|
+
//
|
|
4
|
+
// Pinst:
|
|
5
|
+
// - createBigIntField liefert FieldDefinition.type === "bigInt"
|
|
6
|
+
// - buildDrizzleTable mappt auf bigint(name, mode:"number"), nicht
|
|
7
|
+
// integer (32-bit) → kein silent 2 GB-Cap
|
|
8
|
+
// - Zod-Schema akzeptiert int + safe-integer + lehnt non-int + Float
|
|
9
|
+
// + non-safe-integer ab
|
|
10
|
+
// - required + sortable + filterable + default reisen durch
|
|
11
|
+
//
|
|
12
|
+
// Echter DB-Roundtrip-Test (Insert >2^31, Select, identisch zurueck)
|
|
13
|
+
// kommt mit Atom 1b sobald `exportJobEntity.bytesWritten` auf bigInt
|
|
14
|
+
// migriert ist + die existing integration-Tests die Tabelle wieder
|
|
15
|
+
// einrichten — pinst dort auf realer Postgres + Drizzle-customType-
|
|
16
|
+
// Path statt parallel-mock hier.
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from "vitest";
|
|
19
|
+
import { createBigIntField, createEntity, createNumberField } from "../../engine";
|
|
20
|
+
import { buildInsertSchema } from "../../engine/schema-builder";
|
|
21
|
+
import { buildDrizzleTable } from "../table-builder";
|
|
22
|
+
|
|
23
|
+
function colByName(table: ReturnType<typeof buildDrizzleTable>, dbName: string) {
|
|
24
|
+
for (const col of Object.values(table) as Array<{
|
|
25
|
+
name?: string;
|
|
26
|
+
notNull?: boolean;
|
|
27
|
+
columnType?: string;
|
|
28
|
+
dataType?: string;
|
|
29
|
+
}>) {
|
|
30
|
+
if (col && typeof col === "object" && col.name === dbName) return col;
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Column ${dbName} not found in table`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("createBigIntField factory", () => {
|
|
36
|
+
test("liefert FieldDef mit type='bigInt' + default required=false", () => {
|
|
37
|
+
const f = createBigIntField();
|
|
38
|
+
expect(f.type).toBe("bigInt");
|
|
39
|
+
expect(f.required).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("Overrides reisen durch (required, sortable, filterable, default)", () => {
|
|
43
|
+
const f = createBigIntField({
|
|
44
|
+
required: true,
|
|
45
|
+
sortable: true,
|
|
46
|
+
filterable: true,
|
|
47
|
+
default: 42,
|
|
48
|
+
});
|
|
49
|
+
expect(f.required).toBe(true);
|
|
50
|
+
expect(f.sortable).toBe(true);
|
|
51
|
+
expect(f.filterable).toBe(true);
|
|
52
|
+
expect(f.default).toBe(42);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("buildDrizzleTable — bigInt-Mapping", () => {
|
|
57
|
+
test("bigInt-Spalte ist DISTINCT von number-Spalte (number=integer/32-bit, bigInt=bigint/64-bit)", () => {
|
|
58
|
+
const entity = createEntity({
|
|
59
|
+
fields: {
|
|
60
|
+
smallCount: createNumberField({}),
|
|
61
|
+
bigCount: createBigIntField({}),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const table = buildDrizzleTable("counters", entity);
|
|
65
|
+
|
|
66
|
+
const small = colByName(table, "small_count");
|
|
67
|
+
const big = colByName(table, "big_count");
|
|
68
|
+
|
|
69
|
+
// PgInteger vs PgBigint sind unterschiedliche columnType-Klassen in
|
|
70
|
+
// Drizzle — der genaue String ist Drizzle-Internal aber MUSS
|
|
71
|
+
// unterschiedlich sein, sonst geht der ganze 64-bit-Punkt verloren.
|
|
72
|
+
expect(small.columnType).not.toBe(big.columnType);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("required bigInt wird NOT NULL", () => {
|
|
76
|
+
const entity = createEntity({
|
|
77
|
+
fields: {
|
|
78
|
+
requiredBig: createBigIntField({ required: true }),
|
|
79
|
+
optionalBig: createBigIntField({}),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const table = buildDrizzleTable("t", entity);
|
|
83
|
+
expect(colByName(table, "required_big").notNull).toBe(true);
|
|
84
|
+
expect(colByName(table, "optional_big").notNull).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("buildInsertSchema — bigInt-Validation", () => {
|
|
89
|
+
test("akzeptiert safe-integer-Werte inkl. >2^31", () => {
|
|
90
|
+
const entity = createEntity({
|
|
91
|
+
fields: { bytesWritten: createBigIntField({ required: true }) },
|
|
92
|
+
});
|
|
93
|
+
const schema = buildInsertSchema(entity);
|
|
94
|
+
|
|
95
|
+
// 2^31 = 2_147_483_648 — Klassisches integer-Overflow-Pattern.
|
|
96
|
+
expect(schema.parse({ bytesWritten: 2_147_483_648 })).toEqual({
|
|
97
|
+
bytesWritten: 2_147_483_648,
|
|
98
|
+
});
|
|
99
|
+
// 2^50 — weit ueber integer, klar in bigInt-Territorium.
|
|
100
|
+
expect(schema.parse({ bytesWritten: 2 ** 50 })).toEqual({
|
|
101
|
+
bytesWritten: 2 ** 50,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("lehnt Float ab (silent-Truncation-Schutz)", () => {
|
|
106
|
+
const entity = createEntity({
|
|
107
|
+
fields: { count: createBigIntField({ required: true }) },
|
|
108
|
+
});
|
|
109
|
+
const schema = buildInsertSchema(entity);
|
|
110
|
+
expect(() => schema.parse({ count: 1.5 })).toThrow();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("lehnt non-safe-integer ab (>2^53)", () => {
|
|
114
|
+
const entity = createEntity({
|
|
115
|
+
fields: { count: createBigIntField({ required: true }) },
|
|
116
|
+
});
|
|
117
|
+
const schema = buildInsertSchema(entity);
|
|
118
|
+
// 2^53 = 9_007_199_254_740_992 ist Number.MAX_SAFE_INTEGER.
|
|
119
|
+
// Werte ueber dem Cap koennen nicht round-trip-en ohne Praezisions-
|
|
120
|
+
// Verlust — Zod's .safe() greift hier.
|
|
121
|
+
expect(() => schema.parse({ count: Number.MAX_SAFE_INTEGER + 2 })).toThrow();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("default-Wert reist in Zod-Schema durch", () => {
|
|
125
|
+
const entity = createEntity({
|
|
126
|
+
fields: { count: createBigIntField({ default: 100 }) },
|
|
127
|
+
});
|
|
128
|
+
const schema = buildInsertSchema(entity);
|
|
129
|
+
expect(schema.parse({})).toEqual({ count: 100 });
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
1
|
import { and, eq, type SQL } from "drizzle-orm";
|
|
2
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
3
3
|
import { NotFoundError } from "../errors";
|
|
4
4
|
import type { DbConnection } from "./connection";
|
|
5
5
|
import type { TenantDb } from "./tenant-db";
|
|
@@ -43,7 +43,7 @@ export async function assertExistsIn(
|
|
|
43
43
|
const [row] = await db
|
|
44
44
|
.select()
|
|
45
45
|
.from(entity)
|
|
46
|
-
.where(and(...conditions) as SQL);
|
|
46
|
+
.where(and(...conditions) as SQL); // @cast-boundary db-operator
|
|
47
47
|
|
|
48
48
|
if (!row) {
|
|
49
49
|
const entityName = options.entityName ?? String(options.field).replace(/Id$/, "");
|
package/src/db/cursor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { EntityId, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
1
|
import { and, asc, desc, eq, gt, inArray, type SQL, sql } from "drizzle-orm";
|
|
2
|
+
import type { EntityId, TenantId } from "../engine/types/identifiers";
|
|
3
3
|
import type { SelectQuery as PgSelect } from "./dialect";
|
|
4
4
|
|
|
5
5
|
export type CursorQueryOptions = {
|
|
@@ -61,7 +61,7 @@ export function applyCursorQuery<T extends PgSelect>(
|
|
|
61
61
|
// werfen.
|
|
62
62
|
conditions.push(sql`false`);
|
|
63
63
|
} else {
|
|
64
|
-
conditions.push(inArray(table.id, options.filterIds as readonly string[]));
|
|
64
|
+
conditions.push(inArray(table.id, options.filterIds as readonly string[])); // @cast-boundary db-operator
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -79,5 +79,5 @@ export function applyCursorQuery<T extends PgSelect>(
|
|
|
79
79
|
options.sortDirection === "desc" ? result.orderBy(desc(column)) : result.orderBy(asc(column));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
return result as T;
|
|
82
|
+
return result as T; // @cast-boundary engine-bridge
|
|
83
83
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { and, asc, desc, eq, gt, inArray, lt, ne, type SQL, sql } from "drizzle-orm";
|
|
2
|
+
import type { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
2
3
|
import { requestContext } from "../api/request-context";
|
|
3
4
|
import { checkWriteFieldOwnership } from "../engine/field-access";
|
|
4
5
|
import {
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
} from "../event-store";
|
|
33
34
|
import type { EntityCache } from "../pipeline/entity-cache";
|
|
34
35
|
import type { SearchAdapter } from "../search/types";
|
|
35
|
-
import { generateId } from "../utils";
|
|
36
|
+
import { assertUnreachable, generateId } from "../utils";
|
|
36
37
|
import { applyEntityEvent } from "./apply-entity-event";
|
|
37
38
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
38
39
|
import type { DbRow } from "./connection";
|
|
@@ -54,8 +55,11 @@ type Table = TableColumns<any>;
|
|
|
54
55
|
// vs-Type-Compat ohnehin Kontrolle was reinkommt.
|
|
55
56
|
//
|
|
56
57
|
// Empty-array IN ist explizit "no match" (SQL false), nicht "match all".
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
function buildFilterCondition(
|
|
59
|
+
col: AnyPgColumn,
|
|
60
|
+
op: "eq" | "ne" | "lt" | "gt" | "in",
|
|
61
|
+
value: unknown,
|
|
62
|
+
): SQL {
|
|
59
63
|
switch (op) {
|
|
60
64
|
case "eq":
|
|
61
65
|
return eq(col, value as never); // @cast-boundary db-operator
|
|
@@ -70,6 +74,8 @@ function buildFilterCondition(col: any, op: "eq" | "ne" | "lt" | "gt" | "in", va
|
|
|
70
74
|
return inArray(col, value as never); // @cast-boundary db-operator
|
|
71
75
|
}
|
|
72
76
|
return sql`false`;
|
|
77
|
+
default:
|
|
78
|
+
assertUnreachable(op, "filter op");
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
81
|
|
|
@@ -281,7 +287,7 @@ export function createEventStoreExecutor(
|
|
|
281
287
|
}
|
|
282
288
|
// Drizzle's variadic `and()` is typed `SQL | undefined`; conditions is
|
|
283
289
|
// guaranteed non-empty above (we pushed at least one).
|
|
284
|
-
return and(...conditions) as SQL;
|
|
290
|
+
return and(...conditions) as SQL; // @cast-boundary db-operator
|
|
285
291
|
}
|
|
286
292
|
|
|
287
293
|
async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
|
|
@@ -295,7 +301,7 @@ export function createEventStoreExecutor(
|
|
|
295
301
|
// Respect an explicit id in the payload (seed pattern, SCIM import). Without
|
|
296
302
|
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
297
303
|
// event payload so defaults + downstream consumers don't see a redundant id field.
|
|
298
|
-
const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined;
|
|
304
|
+
const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
|
|
299
305
|
const aggregateId = explicitId ?? generateId();
|
|
300
306
|
const { id: _id, ...payloadWithoutId } = payload;
|
|
301
307
|
const data = applyDefaults(payloadWithoutId);
|
|
@@ -561,7 +567,7 @@ export function createEventStoreExecutor(
|
|
|
561
567
|
isSuccess: true,
|
|
562
568
|
data: {
|
|
563
569
|
kind: "save",
|
|
564
|
-
id: data["id"] as EntityId,
|
|
570
|
+
id: data["id"] as EntityId, // @cast-boundary engine-payload
|
|
565
571
|
data,
|
|
566
572
|
changes: payload.changes,
|
|
567
573
|
previous,
|
|
@@ -793,7 +799,7 @@ export function createEventStoreExecutor(
|
|
|
793
799
|
}
|
|
794
800
|
}
|
|
795
801
|
|
|
796
|
-
const whereClause = conditions.length > 0 ? (and(...conditions) as SQL) : undefined;
|
|
802
|
+
const whereClause = conditions.length > 0 ? (and(...conditions) as SQL) : undefined; // @cast-boundary db-operator
|
|
797
803
|
let query = whereClause
|
|
798
804
|
? db.select().from(table).where(whereClause)
|
|
799
805
|
: db.select().from(table);
|
|
@@ -822,13 +828,13 @@ export function createEventStoreExecutor(
|
|
|
822
828
|
await entityCache.mset(
|
|
823
829
|
user.tenantId,
|
|
824
830
|
entityName,
|
|
825
|
-
rows.map((r) => ({ id: r["id"] as EntityId, data: r })),
|
|
831
|
+
rows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
|
|
826
832
|
);
|
|
827
833
|
}
|
|
828
834
|
|
|
829
835
|
const lastRow = rows[rows.length - 1];
|
|
830
836
|
const nextCursor =
|
|
831
|
-
rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null;
|
|
837
|
+
rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null; // @cast-boundary engine-payload
|
|
832
838
|
|
|
833
839
|
// total: extra COUNT(*) — nur wenn explizit angefordert (Pager-UI).
|
|
834
840
|
// Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
|
|
@@ -842,7 +848,7 @@ export function createEventStoreExecutor(
|
|
|
842
848
|
const countQuery = whereClause
|
|
843
849
|
? db.select({ count: sql<number>`count(*)::int` }).from(table).where(whereClause)
|
|
844
850
|
: db.select({ count: sql<number>`count(*)::int` }).from(table);
|
|
845
|
-
const countRow = (await countQuery) as Array<{ count: number }>;
|
|
851
|
+
const countRow = (await countQuery) as Array<{ count: number }>; // @cast-boundary db-row
|
|
846
852
|
total = countRow[0]?.count ?? 0;
|
|
847
853
|
}
|
|
848
854
|
}
|
|
@@ -872,7 +878,7 @@ export function createEventStoreExecutor(
|
|
|
872
878
|
const checked = await db
|
|
873
879
|
.select()
|
|
874
880
|
.from(table)
|
|
875
|
-
.where(and(idFilter(payload.id), ownership.sql) as SQL)
|
|
881
|
+
.where(and(idFilter(payload.id), ownership.sql) as SQL) // @cast-boundary db-operator
|
|
876
882
|
.limit(1);
|
|
877
883
|
if (checked.length === 0) return null;
|
|
878
884
|
}
|
|
@@ -887,11 +893,11 @@ export function createEventStoreExecutor(
|
|
|
887
893
|
// thread the ownership clause.
|
|
888
894
|
const baseFilter = idFilter(payload.id);
|
|
889
895
|
const whereClause =
|
|
890
|
-
ownership.kind === "sql" ? (and(baseFilter, ownership.sql) as SQL) : baseFilter;
|
|
896
|
+
ownership.kind === "sql" ? (and(baseFilter, ownership.sql) as SQL) : baseFilter; // @cast-boundary db-operator
|
|
891
897
|
const rows = (await db.select().from(table).where(whereClause).limit(1)) as Record<
|
|
892
898
|
string,
|
|
893
899
|
unknown
|
|
894
|
-
>[];
|
|
900
|
+
>[]; // @cast-boundary db-row
|
|
895
901
|
const raw = rows[0];
|
|
896
902
|
if (!raw) return null;
|
|
897
903
|
const row = rehydrateCompoundTypes(raw, entity);
|
|
@@ -51,7 +51,7 @@ export function flattenLocatedTimestamp(
|
|
|
51
51
|
`flattenLocatedTimestamp: field "${name}" expects { at, tz } or { utc, tz } object, got ${typeof raw}`,
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
|
-
const pair = raw as { at?: string; tz?: string; utc?: string };
|
|
54
|
+
const pair = raw as { at?: string; tz?: string; utc?: string }; // @cast-boundary schema-walk
|
|
55
55
|
|
|
56
56
|
delete result[name];
|
|
57
57
|
|
package/src/db/money.ts
CHANGED
|
@@ -26,6 +26,11 @@ const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
|
|
|
26
26
|
*
|
|
27
27
|
* Pure — mutiert nicht.
|
|
28
28
|
*/
|
|
29
|
+
interface MoneyPair {
|
|
30
|
+
amount: number;
|
|
31
|
+
currency?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
export function flattenMoney(
|
|
30
35
|
payload: Record<string, unknown>,
|
|
31
36
|
entity: EntityDefinition,
|
|
@@ -42,8 +47,13 @@ export function flattenMoney(
|
|
|
42
47
|
let amount: number;
|
|
43
48
|
let currency: string;
|
|
44
49
|
|
|
45
|
-
if (
|
|
46
|
-
|
|
50
|
+
if (
|
|
51
|
+
typeof raw === "object" &&
|
|
52
|
+
raw !== null &&
|
|
53
|
+
"amount" in raw &&
|
|
54
|
+
typeof (raw as MoneyPair).amount === "number" // @cast-boundary schema-walk
|
|
55
|
+
) {
|
|
56
|
+
const pair = raw as MoneyPair; // @cast-boundary schema-walk
|
|
47
57
|
amount = pair.amount;
|
|
48
58
|
currency = pair.currency ?? fallbackCurrency;
|
|
49
59
|
} else if (typeof raw === "number") {
|
package/src/db/pg-error.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function extractPgError(e: unknown): PgErrorInfo | null {
|
|
|
20
20
|
for (const layer of layers) {
|
|
21
21
|
// @cast-boundary error-details — postgres-js error shape (code, constraint_name)
|
|
22
22
|
const code = (layer as { code?: string }).code;
|
|
23
|
-
const constraintName = (layer as { constraint_name?: string }).constraint_name;
|
|
23
|
+
const constraintName = (layer as { constraint_name?: string }).constraint_name; // @cast-boundary error-details
|
|
24
24
|
if (code !== undefined || constraintName !== undefined) {
|
|
25
25
|
return { code, constraint_name: constraintName };
|
|
26
26
|
}
|
package/src/db/row-helpers.ts
CHANGED
|
@@ -49,5 +49,5 @@ export async function fetchOne<TRow = DbRow>(
|
|
|
49
49
|
): Promise<TRow | undefined> {
|
|
50
50
|
const where = conditions.length === 1 ? conditions[0] : and(...conditions);
|
|
51
51
|
const rows = await db.select().from(table).where(where).limit(1);
|
|
52
|
-
return rows[0] as TRow | undefined;
|
|
52
|
+
return rows[0] as TRow | undefined; // @cast-boundary db-row
|
|
53
53
|
}
|
package/src/db/table-builder.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from "../engine/types";
|
|
9
9
|
import { assertUnreachable } from "../utils";
|
|
10
10
|
import {
|
|
11
|
+
bigint,
|
|
11
12
|
boolean,
|
|
12
13
|
index,
|
|
13
14
|
instant,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
type ColumnBuilder =
|
|
26
27
|
| ReturnType<typeof text>
|
|
27
28
|
| ReturnType<typeof integer>
|
|
29
|
+
| ReturnType<typeof bigint>
|
|
28
30
|
| ReturnType<typeof boolean>
|
|
29
31
|
| ReturnType<typeof moneyAmount>
|
|
30
32
|
| ReturnType<typeof jsonb>
|
|
@@ -92,6 +94,15 @@ function fieldToColumns(
|
|
|
92
94
|
const col = integer(snakeName);
|
|
93
95
|
return { [name]: field.required ? col.notNull() : col };
|
|
94
96
|
}
|
|
97
|
+
case "bigInt": {
|
|
98
|
+
// 64-bit-Integer fuer Audit-Counter, Byte-Sizes, Cumulative-Sums.
|
|
99
|
+
// mode:"number" liefert JS-`number` (sicher bis 2^53 ≈ 9 PB) statt
|
|
100
|
+
// JS-`bigint` — JSON-serialisierbar, Frontend-tauglich. Wer >2^53
|
|
101
|
+
// braucht (Astronomie-Astronomie), nutzt einen Text-Field mit
|
|
102
|
+
// eigenem Codec.
|
|
103
|
+
const col = bigint(snakeName, { mode: "number" });
|
|
104
|
+
return { [name]: field.required ? col.notNull() : col };
|
|
105
|
+
}
|
|
95
106
|
case "reference":
|
|
96
107
|
// Tier 2.7e-3: FK-Style UUID-Spalte. Multi-Mode (Tier 2.7e-Multi)
|
|
97
108
|
// speichert UUIDs als jsonb-Array<string>. Single-Mode bleibt
|
|
@@ -122,10 +133,8 @@ function fieldToColumns(
|
|
|
122
133
|
// wrapper-feld mit boolean-flag oder discriminierte-union.
|
|
123
134
|
return { [name]: jsonb(snakeName).default({}).notNull() };
|
|
124
135
|
case "date": {
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// instant() = TIMESTAMPTZ damit Caller die gleiche API nutzen wie für
|
|
128
|
-
// type:"timestamp". Echte PlainDate-Migration kommt nach Sprint F.
|
|
136
|
+
// `type:"date"` aliased auf instant() = TIMESTAMPTZ. Echte
|
|
137
|
+
// PlainDate-Migration (PG `date` Spalte, kein TZ) kommt später.
|
|
129
138
|
const col = instant(snakeName);
|
|
130
139
|
return { [name]: field.required ? col.notNull() : col };
|
|
131
140
|
}
|
|
@@ -467,7 +476,13 @@ export function buildDrizzleTable<E extends EntityDefinition>(
|
|
|
467
476
|
def.name ?? `${tableName}_${def.columns.map((c) => toSnakeCase(c)).join("_")}_${suffix}`;
|
|
468
477
|
const builder = def.unique === true ? uniqueIndex(indexName) : index(indexName);
|
|
469
478
|
// biome-ignore lint/suspicious/noExplicitAny: drizzle's .on(...cols) is variadic generic
|
|
470
|
-
|
|
479
|
+
let chain = (builder.on as any)(...cols); // @cast-boundary drizzle-bridge
|
|
480
|
+
if (def.where !== undefined) {
|
|
481
|
+
// Partial-Index: drizzle's IndexBuilder.where(SQL) emittiert das
|
|
482
|
+
// `WHERE <condition>` ans Ende der `CREATE [UNIQUE] INDEX`-DDL.
|
|
483
|
+
chain = chain.where(def.where);
|
|
484
|
+
}
|
|
485
|
+
indexes.push(chain);
|
|
471
486
|
}
|
|
472
487
|
return indexes;
|
|
473
488
|
},
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
1
|
import { and, type Column, eq, getTableName, or, type SQL } from "drizzle-orm";
|
|
2
|
+
import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
|
|
3
3
|
import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
|
|
4
4
|
import type { DbRunner } from "./connection";
|
|
5
5
|
import type { TableColumns } from "./dialect";
|
|
@@ -148,7 +148,7 @@ export function createTenantDb(
|
|
|
148
148
|
// types don't include PromiseLike. Cast via this helper so the double-
|
|
149
149
|
// cast is named and lives in exactly one place per scope.
|
|
150
150
|
function asDrizzleThenable<T>(builder: unknown): PromiseLike<T> {
|
|
151
|
-
return builder as PromiseLike<T>;
|
|
151
|
+
return builder as PromiseLike<T>; // @cast-boundary engine-bridge
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
// Wrap a DB query promise in a `db.query` span + emit the DB duration
|
|
@@ -229,7 +229,7 @@ export function createTenantDb(
|
|
|
229
229
|
const ownOrGlobal = or(
|
|
230
230
|
eq(table["tenantId"], tenantId),
|
|
231
231
|
eq(table["tenantId"], SYSTEM_TENANT_ID),
|
|
232
|
-
) as SQL;
|
|
232
|
+
) as SQL; // @cast-boundary db-operator
|
|
233
233
|
return extra.length > 0 ? and(ownOrGlobal, ...extra) : ownOrGlobal;
|
|
234
234
|
}
|
|
235
235
|
|
|
@@ -309,7 +309,7 @@ export function createTenantDb(
|
|
|
309
309
|
reject ?? undefined,
|
|
310
310
|
);
|
|
311
311
|
},
|
|
312
|
-
} as TenantSelectQuery;
|
|
312
|
+
} as TenantSelectQuery; // @cast-boundary drizzle-bridge
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
// --- Where helper for update/delete ---
|
|
@@ -342,7 +342,7 @@ export function createTenantDb(
|
|
|
342
342
|
return withDbSpan<Record<string, unknown>[]>(
|
|
343
343
|
"insert",
|
|
344
344
|
table,
|
|
345
|
-
() => q.returning() as PromiseLike<Record<string, unknown>[]>,
|
|
345
|
+
() => q.returning() as PromiseLike<Record<string, unknown>[]>, // @cast-boundary db-runner
|
|
346
346
|
);
|
|
347
347
|
},
|
|
348
348
|
onConflictDoUpdate(spec: ConflictUpdate) {
|
|
@@ -370,7 +370,7 @@ export function createTenantDb(
|
|
|
370
370
|
reject,
|
|
371
371
|
);
|
|
372
372
|
},
|
|
373
|
-
} as TenantInsertValues;
|
|
373
|
+
} as TenantInsertValues; // @cast-boundary drizzle-bridge
|
|
374
374
|
},
|
|
375
375
|
};
|
|
376
376
|
},
|
|
@@ -387,7 +387,7 @@ export function createTenantDb(
|
|
|
387
387
|
return withDbSpan<Record<string, unknown>[]>(
|
|
388
388
|
"update",
|
|
389
389
|
table,
|
|
390
|
-
() => wq.returning() as PromiseLike<Record<string, unknown>[]>,
|
|
390
|
+
() => wq.returning() as PromiseLike<Record<string, unknown>[]>, // @cast-boundary db-runner
|
|
391
391
|
);
|
|
392
392
|
},
|
|
393
393
|
// biome-ignore lint/suspicious/noThenProperty: thenable for await
|
|
@@ -397,7 +397,7 @@ export function createTenantDb(
|
|
|
397
397
|
reject,
|
|
398
398
|
);
|
|
399
399
|
},
|
|
400
|
-
} as TenantUpdateWhere;
|
|
400
|
+
} as TenantUpdateWhere; // @cast-boundary drizzle-bridge
|
|
401
401
|
},
|
|
402
402
|
returning(): PromiseLike<Record<string, unknown>[]> {
|
|
403
403
|
return Promise.reject(
|
|
@@ -416,7 +416,7 @@ export function createTenantDb(
|
|
|
416
416
|
),
|
|
417
417
|
).then(resolve, reject);
|
|
418
418
|
},
|
|
419
|
-
} as TenantUpdateSet;
|
|
419
|
+
} as TenantUpdateSet; // @cast-boundary drizzle-bridge
|
|
420
420
|
},
|
|
421
421
|
};
|
|
422
422
|
},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Shared helpers for unit-tests of pipeline.ts / run-pipeline.ts and the
|
|
2
|
+
// step-builders. The minimal-ctx helper was 4 lines duplicated in every
|
|
3
|
+
// pipeline-* test file; centralised here for the M.1.6 cleanup-pass
|
|
4
|
+
// (Followup #7 splitting).
|
|
5
|
+
//
|
|
6
|
+
// Real-ctx integration lives in pipeline-handler.integration.ts —
|
|
7
|
+
// these helpers are deliberately for the no-DB tests where step-args
|
|
8
|
+
// + assembly + boot-time guards are what's exercised.
|
|
9
|
+
|
|
10
|
+
import type { HandlerContext } from "../types/handlers";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns an empty object cast as HandlerContext. Steps that only
|
|
14
|
+
* exercise their own arg-resolution + step-list-assembly don't read
|
|
15
|
+
* any ctx field; the runner needs an object-shaped ctx but no surface
|
|
16
|
+
* beyond that.
|
|
17
|
+
*
|
|
18
|
+
* Tests that actually use ctx fields (db, query, appendEvent, etc.)
|
|
19
|
+
* belong in pipeline-handler.integration.ts against the real stack.
|
|
20
|
+
*/
|
|
21
|
+
export function buildMinimalCtx(): HandlerContext {
|
|
22
|
+
return {} as HandlerContext;
|
|
23
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Boot-Validator-Tests fuer r.exposesApi / r.usesApi (S0.4).
|
|
2
|
+
//
|
|
3
|
+
// Pflicht-Validierungen (Error / throw):
|
|
4
|
+
// - r.usesApi(name) ohne passenden r.exposesApi(name) → throw
|
|
5
|
+
// - r.usesApi(name) ohne r.requires(providerFeature) → throw
|
|
6
|
+
// - r.exposesApi(name) zweimal in einem Feature → throw
|
|
7
|
+
// - Globale Doppel-Exposure (zwei Features, gleicher Name) → throw
|
|
8
|
+
//
|
|
9
|
+
// Soft-Warning (console.warn):
|
|
10
|
+
// - Feature ruft eigene exposesApi via usesApi (Refactor-Leftover)
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
13
|
+
import { validateBoot } from "../boot-validator";
|
|
14
|
+
import { defineFeature } from "../define-feature";
|
|
15
|
+
|
|
16
|
+
describe("validateBoot — r.exposesApi / r.usesApi", () => {
|
|
17
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
warnSpy.mockRestore();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("matching exposesApi/usesApi with requires() passes", () => {
|
|
28
|
+
const provider = defineFeature("compliance-profiles", (r) => {
|
|
29
|
+
r.exposesApi("compliance.forTenant");
|
|
30
|
+
});
|
|
31
|
+
const consumer = defineFeature("user-data-rights", (r) => {
|
|
32
|
+
r.requires("compliance-profiles");
|
|
33
|
+
r.usesApi("compliance.forTenant");
|
|
34
|
+
});
|
|
35
|
+
expect(() => validateBoot([provider, consumer])).not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("matching exposesApi/usesApi with optionalRequires() passes", () => {
|
|
39
|
+
const provider = defineFeature("compliance-profiles", (r) => {
|
|
40
|
+
r.exposesApi("compliance.forTenant");
|
|
41
|
+
});
|
|
42
|
+
const consumer = defineFeature("user-data-rights", (r) => {
|
|
43
|
+
r.optionalRequires("compliance-profiles");
|
|
44
|
+
r.usesApi("compliance.forTenant");
|
|
45
|
+
});
|
|
46
|
+
expect(() => validateBoot([provider, consumer])).not.toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("usesApi without any exposer throws with known-list", () => {
|
|
50
|
+
const consumer = defineFeature("user-data-rights", (r) => {
|
|
51
|
+
r.usesApi("compliance.forTenant");
|
|
52
|
+
});
|
|
53
|
+
expect(() => validateBoot([consumer])).toThrow(
|
|
54
|
+
/r\.usesApi\("compliance\.forTenant"\) but no feature exposes that API/,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("usesApi with typo throws and lists known APIs", () => {
|
|
59
|
+
const provider = defineFeature("compliance-profiles", (r) => {
|
|
60
|
+
r.exposesApi("compliance.forTenant");
|
|
61
|
+
});
|
|
62
|
+
const consumer = defineFeature("user-data-rights", (r) => {
|
|
63
|
+
r.requires("compliance-profiles");
|
|
64
|
+
r.usesApi("compliance.fortenant"); // typo: lowercase t
|
|
65
|
+
});
|
|
66
|
+
expect(() => validateBoot([provider, consumer])).toThrow(
|
|
67
|
+
/Known exposed APIs: compliance\.forTenant/,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("usesApi exists but missing requires() throws", () => {
|
|
72
|
+
const provider = defineFeature("compliance-profiles", (r) => {
|
|
73
|
+
r.exposesApi("compliance.forTenant");
|
|
74
|
+
});
|
|
75
|
+
const consumer = defineFeature("user-data-rights", (r) => {
|
|
76
|
+
// missing r.requires("compliance-profiles")
|
|
77
|
+
r.usesApi("compliance.forTenant");
|
|
78
|
+
});
|
|
79
|
+
expect(() => validateBoot([provider, consumer])).toThrow(
|
|
80
|
+
/not in requires\/optionalRequires\. Add r\.requires\("compliance-profiles"\)/,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("exposesApi twice in same feature throws", () => {
|
|
85
|
+
expect(() =>
|
|
86
|
+
defineFeature("dup", (r) => {
|
|
87
|
+
r.exposesApi("api.foo");
|
|
88
|
+
r.exposesApi("api.foo");
|
|
89
|
+
}),
|
|
90
|
+
).toThrow(/r\.exposesApi\("api\.foo"\) called twice/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("two features expose the same API throws on boot", () => {
|
|
94
|
+
const a = defineFeature("a", (r) => {
|
|
95
|
+
r.exposesApi("shared.api");
|
|
96
|
+
});
|
|
97
|
+
const b = defineFeature("b", (r) => {
|
|
98
|
+
r.exposesApi("shared.api");
|
|
99
|
+
});
|
|
100
|
+
expect(() => validateBoot([a, b])).toThrow(
|
|
101
|
+
/Cross-feature API "shared\.api" exposed by both "a" and "b"/,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("self-exposure (feature uses its own exposed API) warns", () => {
|
|
106
|
+
const f = defineFeature("self-loop", (r) => {
|
|
107
|
+
r.exposesApi("self.api");
|
|
108
|
+
r.usesApi("self.api");
|
|
109
|
+
});
|
|
110
|
+
validateBoot([f]);
|
|
111
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
112
|
+
String(args[0]).includes("typically a refactor leftover"),
|
|
113
|
+
);
|
|
114
|
+
expect(matchingWarn).toBeDefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("feature with no API surface boots clean (regression guard)", () => {
|
|
118
|
+
const plain = defineFeature("plain", (r) => {
|
|
119
|
+
r.requires();
|
|
120
|
+
});
|
|
121
|
+
expect(() => validateBoot([plain])).not.toThrow();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("global double-exposure throws before consumer-resolution kicks in", () => {
|
|
125
|
+
// Edge-case: zwei Features exposen denselben Namen UND ein drittes
|
|
126
|
+
// Feature ruft den Namen. Erwartet: Doppel-Exposure-Error wirft im
|
|
127
|
+
// Pre-Walk (validateBoot) BEVOR validateApiExposureMatching laeuft.
|
|
128
|
+
const a = defineFeature("provider-a", (r) => {
|
|
129
|
+
r.exposesApi("shared.api");
|
|
130
|
+
});
|
|
131
|
+
const b = defineFeature("provider-b", (r) => {
|
|
132
|
+
r.exposesApi("shared.api");
|
|
133
|
+
});
|
|
134
|
+
const consumer = defineFeature("consumer", (r) => {
|
|
135
|
+
r.requires("provider-a");
|
|
136
|
+
r.usesApi("shared.api");
|
|
137
|
+
});
|
|
138
|
+
expect(() => validateBoot([a, b, consumer])).toThrow(
|
|
139
|
+
/Cross-feature API "shared\.api" exposed by both/,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|