@cosmicdrift/kumiko-framework 0.28.0 → 0.31.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.28.0",
3
+ "version": "0.31.1",
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
  });
@@ -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,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { asEntityTableMeta } from "../../bun-db/query";
3
+ import { createEntity } from "../../engine/factories";
4
+ import type { ColumnMeta, IndexMeta } from "../entity-table-meta";
5
+ import { buildEntityTableMeta } from "../entity-table-meta";
6
+ import { buildEntityTable } from "../table-builder";
7
+
8
+ // Lock-step-Guard: buildEntityTable (Runtime-/Test-Stack-Pfad, Meta am
9
+ // KUMIKO_META_SYMBOL) und buildEntityTableMeta (Migrations-Pfad) müssen
10
+ // für dieselbe EntityDefinition identische Spalten + Indexes produzieren.
11
+ // Drift hier = Migration und Prod-Tabelle (bzw. collectTableMetas-Output)
12
+ // gehen auseinander — gefunden als #255-Follow-up: select/number/bigInt
13
+ // verloren ihre deklarierten defaults auf dem Builder-Pfad.
14
+
15
+ const entityWithDefaults = createEntity({
16
+ table: "read_lockstep_probe",
17
+ fields: {
18
+ title: { type: "text", required: true, default: "untitled" },
19
+ active: { type: "boolean", default: true },
20
+ status: { type: "select", options: ["open", "done"], required: true, default: "open" },
21
+ tags: { type: "multiSelect", options: ["a", "b"] },
22
+ attempt: { type: "number", required: true, default: 1 },
23
+ bytes: { type: "bigInt", default: 0 },
24
+ price: { type: "money" },
25
+ meta: { type: "embedded", fields: {} },
26
+ startedAt: { type: "timestamp", required: true },
27
+ },
28
+ });
29
+
30
+ function byName<T extends { name: string }>(items: readonly T[]): readonly T[] {
31
+ return [...items].sort((a, b) => a.name.localeCompare(b.name));
32
+ }
33
+
34
+ describe("buildEntityTable ↔ buildEntityTableMeta lock-step", () => {
35
+ const fromBuilder = asEntityTableMeta(buildEntityTable("lockstepProbe", entityWithDefaults));
36
+ const fromMeta = buildEntityTableMeta("lockstepProbe", entityWithDefaults);
37
+
38
+ test("builder table carries an EntityTableMeta", () => {
39
+ expect(fromBuilder).toBeDefined();
40
+ });
41
+
42
+ test("identical columns (incl. declared defaults)", () => {
43
+ expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
44
+ byName<ColumnMeta>(fromMeta.columns),
45
+ );
46
+ });
47
+
48
+ test("identical indexes", () => {
49
+ expect(byName<IndexMeta>(fromBuilder?.indexes ?? [])).toEqual(
50
+ byName<IndexMeta>(fromMeta.indexes),
51
+ );
52
+ });
53
+
54
+ test("declared defaults survive the builder path", () => {
55
+ const cols = new Map((fromBuilder?.columns ?? []).map((c) => [c.name, c]));
56
+ expect(cols.get("status")?.defaultSql).toBe("'open'");
57
+ expect(cols.get("attempt")?.defaultSql).toBe("1");
58
+ expect(cols.get("bytes")?.defaultSql).toBe("0");
59
+ expect(cols.get("title")?.defaultSql).toBe("'untitled'");
60
+ expect(cols.get("active")?.defaultSql).toBe("true");
61
+ });
62
+ });
@@ -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
+ }
package/src/db/dialect.ts CHANGED
@@ -272,8 +272,11 @@ export function instant(
272
272
  }
273
273
 
274
274
  // moneyAmount kept as a customType-style API but produces a bigint column.
275
+ // bigintJsMode "bigint" — money cents must round-trip as JS bigint (lock-step
276
+ // with entity-table-meta's money rendering; without it bun-db reads the
277
+ // column as number and loses precision past 2^53).
275
278
  export const moneyAmount = (name: string): ColumnBuilder<number> =>
276
- buildColumn(name, "bigint") as ColumnBuilder<number>;
279
+ buildColumn(name, "bigint", { bigintJsMode: "bigint" }) as ColumnBuilder<number>;
277
280
 
278
281
  // ---- Index + primaryKey helpers ----
279
282
 
@@ -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,
@@ -80,7 +80,12 @@ function fieldToColumns(
80
80
  : boolean(snakeName),
81
81
  };
82
82
  case "select": {
83
- const col = text(snakeName);
83
+ // default() durchreichen — entity-table-meta rendert deklarierte
84
+ // defaults, dieser Builder MUSS lock-step bleiben (sonst trägt das
85
+ // Meta am buildEntityTable-Objekt eine andere Spalte als die
86
+ // Migration, #255-Follow-up-Befund).
87
+ const base = text(snakeName);
88
+ const col = field.default !== undefined ? base.default(field.default) : base;
84
89
  return { [name]: field.required ? col.notNull() : col };
85
90
  }
86
91
  case "multiSelect":
@@ -95,7 +100,8 @@ function fieldToColumns(
95
100
  // multi-select.
96
101
  return { [name]: jsonb(snakeName).default([]).notNull() };
97
102
  case "number": {
98
- const col = integer(snakeName);
103
+ const base = integer(snakeName);
104
+ const col = field.default !== undefined ? base.default(field.default) : base;
99
105
  return { [name]: field.required ? col.notNull() : col };
100
106
  }
101
107
  case "bigInt": {
@@ -104,7 +110,8 @@ function fieldToColumns(
104
110
  // JS-`bigint` — JSON-serialisierbar, Frontend-tauglich. Wer >2^53
105
111
  // braucht (Astronomie-Astronomie), nutzt einen Text-Field mit
106
112
  // eigenem Codec.
107
- const col = bigint(snakeName, { mode: "number" });
113
+ const base = bigint(snakeName, { mode: "number" });
114
+ const col = field.default !== undefined ? base.default(field.default) : base;
108
115
  return { [name]: field.required ? col.notNull() : col };
109
116
  }
110
117
  case "reference":
@@ -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[] = [];
@@ -565,6 +567,17 @@ export function defineFeature<const TName extends string, TExports = undefined>(
565
567
  extensionUsages.push({ extensionName, entityName: resolveName(entityRef), options });
566
568
  },
567
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
+
568
581
  /**
569
582
  * Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
570
583
  * unter dem genannten Namen bereit. Die eigentliche Implementation
@@ -936,6 +949,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
936
949
  notifications,
937
950
  registrarExtensions,
938
951
  extensionUsages,
952
+ extensionSelectors,
939
953
  exposedApis,
940
954
  usedApis,
941
955
  referenceData,