@cosmicdrift/kumiko-framework 0.27.0 → 0.31.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 +1 -1
- package/src/api/auth-routes.ts +6 -0
- package/src/api/server.ts +7 -1
- package/src/bun-db/index.ts +3 -1
- package/src/bun-db/query.ts +1 -1
- package/src/db/__tests__/collect-table-metas.test.ts +126 -0
- package/src/db/collect-table-metas.ts +81 -0
- package/src/db/feature-table-sources.ts +35 -0
- package/src/db/index.ts +5 -0
- package/src/engine/__tests__/engine.test.ts +29 -0
- package/src/engine/__tests__/registry.test.ts +75 -0
- package/src/engine/config-helpers.ts +2 -0
- package/src/engine/define-feature.ts +28 -0
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/extractors/round1.ts +22 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +3 -0
- package/src/engine/feature-ast/patcher.ts +5 -0
- package/src/engine/feature-ast/patterns.ts +183 -17
- package/src/engine/feature-ast/render.ts +7 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +3 -0
- package/src/engine/pattern-library/library.ts +19 -0
- package/src/engine/registry.ts +37 -1
- package/src/engine/system-user.ts +10 -2
- package/src/engine/types/config.ts +16 -0
- package/src/engine/types/feature.ts +31 -4
- package/src/engine/types/index.ts +1 -0
- package/src/entrypoint/index.ts +8 -3
- package/src/errors/classes.ts +29 -1
- package/src/errors/i18n/de.yaml +4 -4
- package/src/errors/i18n/en.yaml +4 -4
- package/src/errors/index.ts +2 -0
- package/src/event-store/__tests__/perf.integration.test.ts +6 -3
- package/src/files/feature.ts +3 -0
- package/src/secrets/types.ts +3 -0
- package/src/stack/test-stack.ts +18 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -115,6 +115,10 @@ type MembershipRow = {
|
|
|
115
115
|
userId: string;
|
|
116
116
|
tenantId: TenantId;
|
|
117
117
|
roles: string[];
|
|
118
|
+
/** Display-Name des Tenants — liefert die membershipQuery seit dem
|
|
119
|
+
* tenant-switcher-Fix mit; optional für ältere App-eigene Queries. */
|
|
120
|
+
tenantName?: string;
|
|
121
|
+
tenantKey?: string;
|
|
118
122
|
};
|
|
119
123
|
|
|
120
124
|
// Guest identity used for unauthenticated calls (e.g. login). The "all" role
|
|
@@ -754,6 +758,8 @@ export function createAuthRoutes(
|
|
|
754
758
|
tenants: memberships.map((m) => ({
|
|
755
759
|
tenantId: m.tenantId,
|
|
756
760
|
roles: m.roles,
|
|
761
|
+
...(m.tenantName !== undefined && { name: m.tenantName }),
|
|
762
|
+
...(m.tenantKey !== undefined && { key: m.tenantKey }),
|
|
757
763
|
})),
|
|
758
764
|
activeTenantId: user.tenantId,
|
|
759
765
|
});
|
package/src/api/server.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
wrapRedisClient,
|
|
24
24
|
} from "../observability";
|
|
25
25
|
import type { DispatcherOptions } from "../pipeline/dispatcher";
|
|
26
|
-
import { createDispatcher } from "../pipeline/dispatcher";
|
|
26
|
+
import { createDispatcher, type Dispatcher } from "../pipeline/dispatcher";
|
|
27
27
|
import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state";
|
|
28
28
|
import type { EventDedup } from "../pipeline/event-dedup";
|
|
29
29
|
import type { EventConsumer, EventDispatcher } from "../pipeline/event-dispatcher";
|
|
@@ -195,6 +195,11 @@ export type KumikoServer = {
|
|
|
195
195
|
jwt: JwtHelper;
|
|
196
196
|
sseBroker: SseBroker;
|
|
197
197
|
observability: ObservabilityProvider;
|
|
198
|
+
// The command-dispatcher behind /api/* — same idempotency/jobRunner/
|
|
199
|
+
// lifecycle wiring as HTTP-dispatched writes. For dispatching outside
|
|
200
|
+
// the HTTP pipeline, e.g. provider-webhook routes that authenticate
|
|
201
|
+
// via signature instead of JWT (subscription-stripe et al.).
|
|
202
|
+
dispatcher: Dispatcher;
|
|
198
203
|
// Present when at least one consumer is wired and context.db is a
|
|
199
204
|
// DbConnection. Caller owns the lifecycle: `.start()` in boot, `.stop()`
|
|
200
205
|
// in shutdown. Tests drain via `.runOnce()` instead.
|
|
@@ -619,6 +624,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
619
624
|
jwt,
|
|
620
625
|
sseBroker,
|
|
621
626
|
observability,
|
|
627
|
+
dispatcher,
|
|
622
628
|
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
623
629
|
...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
|
|
624
630
|
};
|
package/src/bun-db/index.ts
CHANGED
|
@@ -9,14 +9,16 @@ export type {
|
|
|
9
9
|
PgListenClient,
|
|
10
10
|
} from "./connection";
|
|
11
11
|
export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection";
|
|
12
|
-
export type { SelectOptions, WhereObject, WhereOperator, WhereValue } from "./query";
|
|
12
|
+
export type { SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue } from "./query";
|
|
13
13
|
export {
|
|
14
|
+
asEntityTableMeta,
|
|
14
15
|
asRawClient,
|
|
15
16
|
countWhere,
|
|
16
17
|
type DeleteManyBatchedOptions,
|
|
17
18
|
type DeleteManyBatchedResult,
|
|
18
19
|
deleteMany,
|
|
19
20
|
deleteManyBatched,
|
|
21
|
+
extractTableInfo,
|
|
20
22
|
fetchOne,
|
|
21
23
|
type IncrementCounterOptions,
|
|
22
24
|
incrementCounter,
|
package/src/bun-db/query.ts
CHANGED
|
@@ -60,7 +60,7 @@ function isEntityTableMeta(v: unknown): v is EntityTableMeta {
|
|
|
60
60
|
// to a column-handle shadowing a meta key.
|
|
61
61
|
// - buildEntityTableMeta / defineUnmanagedTable return a plain meta with no
|
|
62
62
|
// handle-spread, so its structural shape is itself unshadowable.
|
|
63
|
-
function asEntityTableMeta(table: unknown): EntityTableMeta | undefined {
|
|
63
|
+
export function asEntityTableMeta(table: unknown): EntityTableMeta | undefined {
|
|
64
64
|
if (table === null || typeof table !== "object") return undefined;
|
|
65
65
|
const fromSymbol = (table as Record<symbol, unknown>)[KUMIKO_META_SYMBOL];
|
|
66
66
|
if (isEntityTableMeta(fromSymbol)) return fromSymbol;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineFeature } from "../../engine/define-feature";
|
|
3
|
+
import { createEntity, createTextField } from "../../engine/factories";
|
|
4
|
+
import { collectTableMetas } from "../collect-table-metas";
|
|
5
|
+
import { integer, type SchemaTable, table, text, uuid } from "../dialect";
|
|
6
|
+
import { defineUnmanagedTable } from "../entity-table-meta";
|
|
7
|
+
import { buildEntityTable } from "../table-builder";
|
|
8
|
+
|
|
9
|
+
function exampleEntity() {
|
|
10
|
+
return createEntity({
|
|
11
|
+
table: "read_units",
|
|
12
|
+
fields: { name: createTextField() },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const counterTable = table("read_unit_counters", {
|
|
17
|
+
id: uuid("id").primaryKey(),
|
|
18
|
+
count: integer("count").notNull().default(0),
|
|
19
|
+
}) as unknown as SchemaTable;
|
|
20
|
+
|
|
21
|
+
describe("collectTableMetas (#255)", () => {
|
|
22
|
+
test("collects entity, projection, MSP and rawTable tables — same sources as the test-stack auto-push", () => {
|
|
23
|
+
const feature = defineFeature("test", (r) => {
|
|
24
|
+
r.entity("unit", exampleEntity());
|
|
25
|
+
r.projection({
|
|
26
|
+
name: "unit-counters",
|
|
27
|
+
source: "unit",
|
|
28
|
+
table: counterTable,
|
|
29
|
+
apply: {},
|
|
30
|
+
});
|
|
31
|
+
r.multiStreamProjection({
|
|
32
|
+
name: "unit-audit",
|
|
33
|
+
table: table("read_unit_audit", {
|
|
34
|
+
id: uuid("id").primaryKey(),
|
|
35
|
+
detail: text("detail"),
|
|
36
|
+
}) as unknown as SchemaTable,
|
|
37
|
+
apply: { "unit.created": async () => {} },
|
|
38
|
+
});
|
|
39
|
+
// side-effect-only MSP — no table, must be skipped without throwing
|
|
40
|
+
r.multiStreamProjection({ name: "unit-notify", apply: { "unit.created": async () => {} } });
|
|
41
|
+
r.rawTable(
|
|
42
|
+
"cache",
|
|
43
|
+
table("unit_cache", {
|
|
44
|
+
id: uuid("id").primaryKey(),
|
|
45
|
+
}) as unknown as SchemaTable,
|
|
46
|
+
{ reason: "test fixture" },
|
|
47
|
+
);
|
|
48
|
+
r.unmanagedTable(
|
|
49
|
+
defineUnmanagedTable({
|
|
50
|
+
tableName: "read_unit_log",
|
|
51
|
+
columns: [{ name: "id", pgType: "serial", notNull: true, primaryKey: true }],
|
|
52
|
+
}),
|
|
53
|
+
{ reason: "test fixture" },
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const names = collectTableMetas([feature]).map((m) => m.tableName);
|
|
58
|
+
expect(names).toContain("read_units"); // entity
|
|
59
|
+
expect(names).toContain("read_unit_counters"); // r.projection
|
|
60
|
+
expect(names).toContain("read_unit_audit"); // r.multiStreamProjection
|
|
61
|
+
expect(names).toContain("unit_cache"); // r.rawTable
|
|
62
|
+
expect(names).toContain("read_unit_log"); // r.unmanagedTable
|
|
63
|
+
expect(names).toHaveLength(5);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("dedupes a projection that materializes into an entity table — entity meta wins", () => {
|
|
67
|
+
const entity = exampleEntity();
|
|
68
|
+
const feature = defineFeature("test", (r) => {
|
|
69
|
+
r.entity("unit", entity);
|
|
70
|
+
r.projection({
|
|
71
|
+
name: "unit-alt",
|
|
72
|
+
source: "unit",
|
|
73
|
+
table: buildEntityTable("unit", entity) as unknown as SchemaTable,
|
|
74
|
+
apply: {},
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const metas = collectTableMetas([feature]);
|
|
79
|
+
expect(metas.filter((m) => m.tableName === "read_units")).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("throws when two table-bearing registrations declare the same table with diverging columns", () => {
|
|
83
|
+
const a = defineFeature("feat-a", (r) => {
|
|
84
|
+
r.entity("unit", exampleEntity());
|
|
85
|
+
r.projection({
|
|
86
|
+
name: "conflict-a",
|
|
87
|
+
source: "unit",
|
|
88
|
+
table: table("read_conflict", {
|
|
89
|
+
id: uuid("id").primaryKey(),
|
|
90
|
+
count: integer("count").notNull(),
|
|
91
|
+
}) as unknown as SchemaTable,
|
|
92
|
+
apply: {},
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
const b = defineFeature("feat-b", (r) => {
|
|
96
|
+
r.entity("thing", exampleEntity());
|
|
97
|
+
r.projection({
|
|
98
|
+
name: "conflict-b",
|
|
99
|
+
source: "thing",
|
|
100
|
+
table: table("read_conflict", {
|
|
101
|
+
id: uuid("id").primaryKey(),
|
|
102
|
+
label: text("label").notNull(),
|
|
103
|
+
}) as unknown as SchemaTable,
|
|
104
|
+
apply: {},
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(() => collectTableMetas([a, b])).toThrow(/read_conflict.*diverging/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("throws when a projection table carries no EntityTableMeta", () => {
|
|
112
|
+
const feature = defineFeature("test", (r) => {
|
|
113
|
+
r.entity("unit", exampleEntity());
|
|
114
|
+
r.projection({
|
|
115
|
+
name: "broken",
|
|
116
|
+
source: "unit",
|
|
117
|
+
// System-Grenze: absichtlich kaputtes table-Objekt — der Cast
|
|
118
|
+
// simuliert eine fremd-konstruierte Tabelle ohne kumiko-Meta.
|
|
119
|
+
table: { tableName: "read_broken" } as unknown as SchemaTable,
|
|
120
|
+
apply: {},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(() => collectTableMetas([feature])).toThrow(/no EntityTableMeta/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// collectTableMetas — kanonische ENTITY_METAS-Quelle für `kumiko schema
|
|
2
|
+
// generate`. Erfasst dieselben Tabellen-Quellen wie der setupTestStack-
|
|
3
|
+
// auto-push (entities, unmanagedTables, projections, multiStreamProjections,
|
|
4
|
+
// rawTables) — die frühere Template-Variante sammelte nur entities +
|
|
5
|
+
// unmanagedTables, wodurch projection-only-Tabellen (z.B. billing-foundation
|
|
6
|
+
// read_subscriptions) nie in Migrations landeten und der erste Prod-Write
|
|
7
|
+
// crashte (#255).
|
|
8
|
+
|
|
9
|
+
import { asEntityTableMeta } from "../bun-db/query";
|
|
10
|
+
import type { FeatureDefinition } from "../engine/types";
|
|
11
|
+
import { buildEntityTableMeta, type EntityTableMeta } from "./entity-table-meta";
|
|
12
|
+
import { enumerateFeatureTableSources } from "./feature-table-sources";
|
|
13
|
+
|
|
14
|
+
function canonicalColumnsKey(meta: EntityTableMeta): string {
|
|
15
|
+
// Spalten-Identität unabhängig von Deklarations-Reihenfolge und
|
|
16
|
+
// Objekt-Key-Order. Indexes bleiben außen vor: eine Projection-Table
|
|
17
|
+
// (buildEntityTable ohne relations) trägt legitim weniger FK-Indexes
|
|
18
|
+
// als das Entity-Meta derselben Tabelle.
|
|
19
|
+
return [...meta.columns]
|
|
20
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
21
|
+
.map(
|
|
22
|
+
(c) =>
|
|
23
|
+
`${c.name}|${c.pgType}|${c.notNull}|${c.defaultSql ?? ""}|${c.primaryKey ?? false}|${c.identity ?? false}|${c.bigintJsMode ?? ""}`,
|
|
24
|
+
)
|
|
25
|
+
.join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function collectTableMetas(
|
|
29
|
+
features: readonly FeatureDefinition[],
|
|
30
|
+
): readonly EntityTableMeta[] {
|
|
31
|
+
const metas: EntityTableMeta[] = [];
|
|
32
|
+
const byName = new Map<string, { meta: EntityTableMeta; origin: string }>();
|
|
33
|
+
|
|
34
|
+
// Pass 1: kanonische Schema-Quellen, identisch zum bisherigen Template-
|
|
35
|
+
// Verhalten (gleiche Reihenfolge, gleiche buildEntityTableMeta-Optionen).
|
|
36
|
+
for (const feature of features) {
|
|
37
|
+
for (const [name, ent] of Object.entries(feature.entities)) {
|
|
38
|
+
const meta = buildEntityTableMeta(name, ent, { relations: feature.relations[name] });
|
|
39
|
+
metas.push(meta);
|
|
40
|
+
byName.set(meta.tableName, { meta, origin: `entity "${name}" (${feature.name})` });
|
|
41
|
+
}
|
|
42
|
+
for (const entry of Object.values(feature.unmanagedTables)) {
|
|
43
|
+
metas.push(entry.meta);
|
|
44
|
+
byName.set(entry.meta.tableName, {
|
|
45
|
+
meta: entry.meta,
|
|
46
|
+
origin: `unmanagedTable "${entry.name}" (${feature.name})`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Pass 2: table-tragende Registrierungen. Zwei Pässe, damit eine Entity-
|
|
52
|
+
// Tabelle aus Feature B gegen eine gleichnamige Projection-Table aus
|
|
53
|
+
// Feature A gewinnt — Entity-Metas sind die reichere Quelle (FK-Indexes
|
|
54
|
+
// aus relations).
|
|
55
|
+
for (const feature of features) {
|
|
56
|
+
for (const { table, origin } of enumerateFeatureTableSources(feature)) {
|
|
57
|
+
const meta = asEntityTableMeta(table);
|
|
58
|
+
if (!meta) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`collectTableMetas: ${origin} carries no EntityTableMeta — ` +
|
|
61
|
+
"build the table via table() / buildEntityTable / defineUnmanagedTable.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const existing = byName.get(meta.tableName);
|
|
65
|
+
if (existing) {
|
|
66
|
+
if (canonicalColumnsKey(existing.meta) !== canonicalColumnsKey(meta)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`collectTableMetas: table "${meta.tableName}" is declared with diverging ` +
|
|
69
|
+
`columns by ${origin} and ${existing.origin}. Align the column ` +
|
|
70
|
+
"definitions or rename one of the tables.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
metas.push(meta);
|
|
76
|
+
byName.set(meta.tableName, { meta, origin });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return metas;
|
|
81
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Single enumeration of every table-bearing registration on a feature
|
|
2
|
+
// (r.projection, r.multiStreamProjection with table, r.rawTable). Consumed
|
|
3
|
+
// by BOTH the setupTestStack auto-push and collectTableMetas — one list, so
|
|
4
|
+
// test-DB-push and `kumiko schema generate` cannot drift apart again (#255).
|
|
5
|
+
// A new table-bearing registrar must be added HERE, not in the consumers.
|
|
6
|
+
|
|
7
|
+
import type { FeatureDefinition } from "../engine/types";
|
|
8
|
+
|
|
9
|
+
export type FeatureTableSource = {
|
|
10
|
+
readonly table: unknown;
|
|
11
|
+
// Stable human-readable label, unique across features — used as push-key
|
|
12
|
+
// and in error messages.
|
|
13
|
+
readonly origin: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function enumerateFeatureTableSources(
|
|
17
|
+
feature: FeatureDefinition,
|
|
18
|
+
): readonly FeatureTableSource[] {
|
|
19
|
+
const sources: FeatureTableSource[] = [];
|
|
20
|
+
for (const [name, proj] of Object.entries(feature.projections)) {
|
|
21
|
+
sources.push({ table: proj.table, origin: `projection "${name}" (${feature.name})` });
|
|
22
|
+
}
|
|
23
|
+
for (const [name, msp] of Object.entries(feature.multiStreamProjections)) {
|
|
24
|
+
// table omitted = side-effect-only MSP, materialises nothing.
|
|
25
|
+
if (!msp.table) continue;
|
|
26
|
+
sources.push({
|
|
27
|
+
table: msp.table,
|
|
28
|
+
origin: `multiStreamProjection "${name}" (${feature.name})`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
for (const [name, raw] of Object.entries(feature.rawTables)) {
|
|
32
|
+
sources.push({ table: raw.table, origin: `rawTable "${name}" (${feature.name})` });
|
|
33
|
+
}
|
|
34
|
+
return sources;
|
|
35
|
+
}
|
package/src/db/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in";
|
|
2
|
+
export { collectTableMetas } from "./collect-table-metas";
|
|
2
3
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
3
4
|
export { seedConfigValues } from "./config-seed";
|
|
4
5
|
export type { DbConnection, DbConnectionOptions, DbRow, DbRunner, DbTx } from "./connection";
|
|
@@ -50,6 +51,10 @@ export type {
|
|
|
50
51
|
EventStoreExecutorOptions,
|
|
51
52
|
} from "./event-store-executor";
|
|
52
53
|
export { createEventStoreExecutor, entityEventName } from "./event-store-executor";
|
|
54
|
+
export {
|
|
55
|
+
enumerateFeatureTableSources,
|
|
56
|
+
type FeatureTableSource,
|
|
57
|
+
} from "./feature-table-sources";
|
|
53
58
|
export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp";
|
|
54
59
|
export {
|
|
55
60
|
diffSnapshots,
|
|
@@ -23,6 +23,35 @@ describe("defineFeature", () => {
|
|
|
23
23
|
expect(feature.name).toBe("test");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
test("r.describe() flows into the definition, trimmed", () => {
|
|
27
|
+
const feature = defineFeature("test", (r) => {
|
|
28
|
+
r.describe(" Stores per-tenant widgets. ");
|
|
29
|
+
});
|
|
30
|
+
expect(feature.description).toBe("Stores per-tenant widgets.");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("description is absent when r.describe() is not called", () => {
|
|
34
|
+
const feature = defineFeature("test", () => {});
|
|
35
|
+
expect("description" in feature).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("r.describe() throws when called twice", () => {
|
|
39
|
+
expect(() =>
|
|
40
|
+
defineFeature("test", (r) => {
|
|
41
|
+
r.describe("first");
|
|
42
|
+
r.describe("second");
|
|
43
|
+
}),
|
|
44
|
+
).toThrow(/r\.describe\(\) called twice/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("r.describe() throws on empty or whitespace-only text", () => {
|
|
48
|
+
expect(() =>
|
|
49
|
+
defineFeature("test", (r) => {
|
|
50
|
+
r.describe(" ");
|
|
51
|
+
}),
|
|
52
|
+
).toThrow(/non-empty string/);
|
|
53
|
+
});
|
|
54
|
+
|
|
26
55
|
test("collects entities", () => {
|
|
27
56
|
const feature = defineFeature("test", (r) => {
|
|
28
57
|
r.entity(
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createTenantConfig } from "../config-helpers";
|
|
3
|
+
import { defineFeature } from "../define-feature";
|
|
2
4
|
import { createRegistry } from "../registry";
|
|
3
5
|
import type { FeatureDefinition } from "../types/feature";
|
|
4
6
|
|
|
@@ -56,3 +58,76 @@ describe("createRegistry slot robustness", () => {
|
|
|
56
58
|
).not.toThrow();
|
|
57
59
|
});
|
|
58
60
|
});
|
|
61
|
+
|
|
62
|
+
describe("extensionSelector boot-validation", () => {
|
|
63
|
+
function foundationFeature() {
|
|
64
|
+
return defineFeature("probe-foundation", (r) => {
|
|
65
|
+
r.extendsRegistrar("probeTransport", { onRegister: () => undefined });
|
|
66
|
+
const configKeys = r.config({
|
|
67
|
+
keys: { provider: createTenantConfig("text", { default: "" }) },
|
|
68
|
+
});
|
|
69
|
+
r.extensionSelector("probeTransport", configKeys.provider);
|
|
70
|
+
return { configKeys };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("valid declaration lands in getAllExtensionSelectors", () => {
|
|
75
|
+
const registry = createRegistry([foundationFeature()]);
|
|
76
|
+
expect(registry.getAllExtensionSelectors().get("probeTransport")).toBe(
|
|
77
|
+
"probe-foundation:config:provider",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("usages carry the owning featureName after merge", () => {
|
|
82
|
+
const provider = defineFeature("probe-smtp", (r) => {
|
|
83
|
+
r.useExtension("probeTransport", "smtp");
|
|
84
|
+
});
|
|
85
|
+
const registry = createRegistry([foundationFeature(), provider]);
|
|
86
|
+
const usage = registry.getExtensionUsages("probeTransport")[0];
|
|
87
|
+
expect(usage?.featureName).toBe("probe-smtp");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("duplicate selector across features fails the boot", () => {
|
|
91
|
+
const rival = defineFeature("probe-rival", (r) => {
|
|
92
|
+
const configKeys = r.config({
|
|
93
|
+
keys: { provider: createTenantConfig("text", { default: "" }) },
|
|
94
|
+
});
|
|
95
|
+
r.extensionSelector("probeTransport", configKeys.provider);
|
|
96
|
+
return { configKeys };
|
|
97
|
+
});
|
|
98
|
+
expect(() => createRegistry([foundationFeature(), rival])).toThrow(
|
|
99
|
+
/Duplicate extension selector/,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("selector for an undeclared extension fails the boot", () => {
|
|
104
|
+
const orphan = defineFeature("probe-orphan", (r) => {
|
|
105
|
+
const configKeys = r.config({
|
|
106
|
+
keys: { provider: createTenantConfig("text", { default: "" }) },
|
|
107
|
+
});
|
|
108
|
+
r.extensionSelector("ghostTransport", configKeys.provider);
|
|
109
|
+
return { configKeys };
|
|
110
|
+
});
|
|
111
|
+
expect(() => createRegistry([orphan])).toThrow(/no feature registers that extension/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("selector pointing at an unknown config key fails the boot", () => {
|
|
115
|
+
const typo = defineFeature("probe-typo", (r) => {
|
|
116
|
+
r.extendsRegistrar("probeTransport", { onRegister: () => undefined });
|
|
117
|
+
r.extensionSelector("probeTransport", "probe-typo:config:does-not-exist");
|
|
118
|
+
});
|
|
119
|
+
expect(() => createRegistry([typo])).toThrow(/unknown config key/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("declaring the selector twice in one feature fails at define-time", () => {
|
|
123
|
+
expect(() =>
|
|
124
|
+
defineFeature("probe-double", (r) => {
|
|
125
|
+
const configKeys = r.config({
|
|
126
|
+
keys: { provider: createTenantConfig("text", { default: "" }) },
|
|
127
|
+
});
|
|
128
|
+
r.extensionSelector("probeTransport", configKeys.provider);
|
|
129
|
+
r.extensionSelector("probeTransport", configKeys.provider);
|
|
130
|
+
}),
|
|
131
|
+
).toThrow(/declared twice/);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -49,6 +49,7 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
|
|
|
49
49
|
bounds?: T extends "number" ? ConfigBounds : never;
|
|
50
50
|
computed?: ConfigComputedFn<T>;
|
|
51
51
|
allowPerRequest?: T extends "text" ? never : boolean;
|
|
52
|
+
required?: boolean;
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
// --- Scope Defaults ---
|
|
@@ -80,6 +81,7 @@ function createConfigKey<T extends ConfigKeyType>(
|
|
|
80
81
|
bounds: opts.bounds as ConfigBounds | undefined, // @cast-boundary schema-walk
|
|
81
82
|
computed: opts.computed,
|
|
82
83
|
...(opts.allowPerRequest === true ? { allowPerRequest: true } : {}),
|
|
84
|
+
...(opts.required === true ? { required: true } : {}),
|
|
83
85
|
};
|
|
84
86
|
}
|
|
85
87
|
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
EventDef,
|
|
20
20
|
EventMigrationDef,
|
|
21
21
|
EventUpcastFn,
|
|
22
|
+
ExtensionSelectorDef,
|
|
22
23
|
FeatureDefinition,
|
|
23
24
|
FeatureMetricDef,
|
|
24
25
|
FeatureRegistrar,
|
|
@@ -129,6 +130,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
129
130
|
const notifications: Record<string, NotificationDefinition> = {};
|
|
130
131
|
const registrarExtensions: Record<string, RegistrarExtensionDef> = {};
|
|
131
132
|
const extensionUsages: RegistrarExtensionRegistration[] = [];
|
|
133
|
+
const extensionSelectors: ExtensionSelectorDef[] = [];
|
|
132
134
|
const exposedApis: Set<string> = new Set();
|
|
133
135
|
const usedApis: Set<string> = new Set();
|
|
134
136
|
const referenceData: ReferenceDataDef[] = [];
|
|
@@ -153,6 +155,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
153
155
|
|
|
154
156
|
let isSystemScoped = false;
|
|
155
157
|
let toggleableDefault: boolean | undefined;
|
|
158
|
+
let description: string | undefined;
|
|
156
159
|
// Visual-Tree-Slots — at-most-one per feature, only-once-guard im
|
|
157
160
|
// registrar (siehe r.treeActions / r.tree). Undefined wenn das Feature
|
|
158
161
|
// keinen Visual-Tree-Beitrag liefert (Zero-Whitelist-Filter).
|
|
@@ -182,6 +185,18 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
182
185
|
isSystemScoped = true;
|
|
183
186
|
},
|
|
184
187
|
|
|
188
|
+
describe(text: string): void {
|
|
189
|
+
if (description !== undefined) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`[Feature ${name}] r.describe() called twice — a feature's description is declared once`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
if (typeof text !== "string" || text.trim().length === 0) {
|
|
195
|
+
throw new Error(`[Feature ${name}] r.describe(): text must be a non-empty string`);
|
|
196
|
+
}
|
|
197
|
+
description = text.trim();
|
|
198
|
+
},
|
|
199
|
+
|
|
185
200
|
requires: (() => {
|
|
186
201
|
const fn = (...featureNames: string[]) => {
|
|
187
202
|
requires.push(...featureNames);
|
|
@@ -552,6 +567,17 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
552
567
|
extensionUsages.push({ extensionName, entityName: resolveName(entityRef), options });
|
|
553
568
|
},
|
|
554
569
|
|
|
570
|
+
extensionSelector(extensionName: string, key: { readonly name: string } | string): void {
|
|
571
|
+
if (extensionSelectors.some((s) => s.extensionName === extensionName)) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`[Feature ${name}] extensionSelector("${extensionName}") declared twice — ` +
|
|
574
|
+
`one selector key per extension point.`,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
const qualifiedKey = typeof key === "string" ? key : key.name;
|
|
578
|
+
extensionSelectors.push({ extensionName, qualifiedKey });
|
|
579
|
+
},
|
|
580
|
+
|
|
555
581
|
/**
|
|
556
582
|
* Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
|
|
557
583
|
* unter dem genannten Namen bereit. Die eigentliche Implementation
|
|
@@ -888,6 +914,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
888
914
|
|
|
889
915
|
return {
|
|
890
916
|
name,
|
|
917
|
+
...(description !== undefined && { description }),
|
|
891
918
|
systemScope: isSystemScoped,
|
|
892
919
|
exports,
|
|
893
920
|
requires,
|
|
@@ -922,6 +949,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
922
949
|
notifications,
|
|
923
950
|
registrarExtensions,
|
|
924
951
|
extensionUsages,
|
|
952
|
+
extensionSelectors,
|
|
925
953
|
exposedApis,
|
|
926
954
|
usedApis,
|
|
927
955
|
referenceData,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CallExpression, SourceFile } from "ts-morph";
|
|
2
2
|
import type {
|
|
3
|
+
DescribePattern,
|
|
3
4
|
OptionalRequiresPattern,
|
|
4
5
|
ReadsConfigPattern,
|
|
5
6
|
RequiresPattern,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
fail,
|
|
13
14
|
ok,
|
|
14
15
|
readBooleanProperty,
|
|
16
|
+
readStringLiteralArgs,
|
|
15
17
|
readVarargsOrArrayProp,
|
|
16
18
|
} from "./shared";
|
|
17
19
|
|
|
@@ -82,6 +84,26 @@ export function extractSystemScope(
|
|
|
82
84
|
});
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
export function extractDescribe(
|
|
88
|
+
call: CallExpression,
|
|
89
|
+
sourceFile: SourceFile,
|
|
90
|
+
): ExtractOutput<DescribePattern> {
|
|
91
|
+
const args = readStringLiteralArgs(call);
|
|
92
|
+
const text = args?.[0];
|
|
93
|
+
if (text === undefined || args?.length !== 1) {
|
|
94
|
+
return fail(
|
|
95
|
+
"describe",
|
|
96
|
+
sourceLocationFromNode(call, sourceFile),
|
|
97
|
+
"expected a single string literal",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return ok({
|
|
101
|
+
kind: "describe",
|
|
102
|
+
source: sourceLocationFromNode(call, sourceFile),
|
|
103
|
+
text,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
export function extractToggleable(
|
|
86
108
|
call: CallExpression,
|
|
87
109
|
sourceFile: SourceFile,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
extractClaimKey,
|
|
31
31
|
extractConfig,
|
|
32
32
|
extractDefineEvent,
|
|
33
|
+
extractDescribe,
|
|
33
34
|
extractEntity,
|
|
34
35
|
extractEntityHook,
|
|
35
36
|
extractEnvSchema,
|
|
@@ -297,6 +298,8 @@ function dispatchExtractor(
|
|
|
297
298
|
return extractSystemScope(call, sourceFile);
|
|
298
299
|
case "toggleable":
|
|
299
300
|
return extractToggleable(call, sourceFile);
|
|
301
|
+
case "describe":
|
|
302
|
+
return extractDescribe(call, sourceFile);
|
|
300
303
|
// Round 2 — object-literal-based static patterns
|
|
301
304
|
case "entity":
|
|
302
305
|
return extractEntity(call, sourceFile);
|
|
@@ -79,6 +79,7 @@ export type PatternId =
|
|
|
79
79
|
| { readonly kind: "readsConfig" }
|
|
80
80
|
| { readonly kind: "systemScope" }
|
|
81
81
|
| { readonly kind: "toggleable" }
|
|
82
|
+
| { readonly kind: "describe" }
|
|
82
83
|
| { readonly kind: "config" }
|
|
83
84
|
| { readonly kind: "translations" }
|
|
84
85
|
| { readonly kind: "authClaims" }
|
|
@@ -270,6 +271,7 @@ export const SINGLETON_KINDS: ReadonlySet<PatternId["kind"]> = new Set([
|
|
|
270
271
|
"readsConfig",
|
|
271
272
|
"systemScope",
|
|
272
273
|
"toggleable",
|
|
274
|
+
"describe",
|
|
273
275
|
"config",
|
|
274
276
|
"translations",
|
|
275
277
|
"authClaims",
|
|
@@ -326,6 +328,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
|
|
|
326
328
|
case "readsConfig":
|
|
327
329
|
case "systemScope":
|
|
328
330
|
case "toggleable":
|
|
331
|
+
case "describe":
|
|
329
332
|
case "config":
|
|
330
333
|
case "translations":
|
|
331
334
|
case "authClaims":
|
|
@@ -197,6 +197,7 @@ export type AddRequiresArgs = { readonly features: readonly string[] };
|
|
|
197
197
|
export type AddOptionalRequiresArgs = { readonly features: readonly string[] };
|
|
198
198
|
export type AddReadsConfigArgs = { readonly keys: readonly string[] };
|
|
199
199
|
export type AddToggleableArgs = { readonly default: boolean };
|
|
200
|
+
export type AddDescribeArgs = { readonly text: string };
|
|
200
201
|
export type AddNavArgs = { readonly definition: NavDefinition };
|
|
201
202
|
export type AddWorkspaceArgs = { readonly definition: WorkspaceDefinition };
|
|
202
203
|
export type AddConfigArgs = {
|
|
@@ -223,6 +224,7 @@ export type FeaturePatcher = {
|
|
|
223
224
|
readonly addReadsConfig: (args: AddReadsConfigArgs) => void;
|
|
224
225
|
readonly addSystemScope: () => void;
|
|
225
226
|
readonly addToggleable: (args: AddToggleableArgs) => void;
|
|
227
|
+
readonly addDescribe: (args: AddDescribeArgs) => void;
|
|
226
228
|
readonly addEntity: (args: AddEntityArgs) => void;
|
|
227
229
|
readonly addRelation: (args: AddRelationArgs) => void;
|
|
228
230
|
readonly addNav: (args: AddNavArgs) => void;
|
|
@@ -300,6 +302,9 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
300
302
|
addToggleable({ default: defaultOn }) {
|
|
301
303
|
add({ kind: "toggleable", source: SYNTHETIC_LOC, default: defaultOn });
|
|
302
304
|
},
|
|
305
|
+
addDescribe({ text }) {
|
|
306
|
+
add({ kind: "describe", source: SYNTHETIC_LOC, text });
|
|
307
|
+
},
|
|
303
308
|
addEntity({ name, definition }) {
|
|
304
309
|
add({
|
|
305
310
|
kind: "entity",
|