@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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/api/auth-routes.ts +6 -0
  3. package/src/api/server.ts +7 -1
  4. package/src/bun-db/index.ts +3 -1
  5. package/src/bun-db/query.ts +1 -1
  6. package/src/db/__tests__/collect-table-metas.test.ts +126 -0
  7. package/src/db/collect-table-metas.ts +81 -0
  8. package/src/db/feature-table-sources.ts +35 -0
  9. package/src/db/index.ts +5 -0
  10. package/src/engine/__tests__/engine.test.ts +29 -0
  11. package/src/engine/__tests__/registry.test.ts +75 -0
  12. package/src/engine/config-helpers.ts +2 -0
  13. package/src/engine/define-feature.ts +28 -0
  14. package/src/engine/feature-ast/extractors/index.ts +1 -0
  15. package/src/engine/feature-ast/extractors/round1.ts +22 -0
  16. package/src/engine/feature-ast/parse.ts +3 -0
  17. package/src/engine/feature-ast/patch.ts +3 -0
  18. package/src/engine/feature-ast/patcher.ts +5 -0
  19. package/src/engine/feature-ast/patterns.ts +183 -17
  20. package/src/engine/feature-ast/render.ts +7 -0
  21. package/src/engine/pattern-library/__tests__/library.test.ts +3 -0
  22. package/src/engine/pattern-library/library.ts +19 -0
  23. package/src/engine/registry.ts +37 -1
  24. package/src/engine/system-user.ts +10 -2
  25. package/src/engine/types/config.ts +16 -0
  26. package/src/engine/types/feature.ts +31 -4
  27. package/src/engine/types/index.ts +1 -0
  28. package/src/entrypoint/index.ts +8 -3
  29. package/src/errors/classes.ts +29 -1
  30. package/src/errors/i18n/de.yaml +4 -4
  31. package/src/errors/i18n/en.yaml +4 -4
  32. package/src/errors/index.ts +2 -0
  33. package/src/event-store/__tests__/perf.integration.test.ts +6 -3
  34. package/src/files/feature.ts +3 -0
  35. package/src/secrets/types.ts +3 -0
  36. 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.27.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>",
@@ -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
  };
@@ -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,
@@ -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,4 +1,5 @@
1
1
  export {
2
+ extractDescribe,
2
3
  extractOptionalRequires,
3
4
  extractReadsConfig,
4
5
  extractRequires,
@@ -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",