@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 +1 -1
- package/src/api/auth-routes.ts +6 -0
- 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/__tests__/table-builder-meta-lockstep.test.ts +62 -0
- package/src/db/collect-table-metas.ts +81 -0
- package/src/db/dialect.ts +4 -1
- package/src/db/feature-table-sources.ts +35 -0
- package/src/db/index.ts +5 -0
- package/src/db/table-builder.ts +10 -3
- package/src/engine/__tests__/registry.test.ts +75 -0
- package/src/engine/config-helpers.ts +2 -0
- package/src/engine/define-feature.ts +14 -0
- package/src/engine/feature-ast/patterns.ts +175 -17
- package/src/engine/registry.ts +37 -1
- package/src/engine/types/config.ts +16 -0
- package/src/engine/types/feature.ts +25 -4
- package/src/engine/types/index.ts +1 -0
- 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/secrets/types.ts +3 -0
- package/src/stack/test-stack.ts +13 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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>",
|
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/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,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,
|
package/src/db/table-builder.ts
CHANGED
|
@@ -80,7 +80,12 @@ function fieldToColumns(
|
|
|
80
80
|
: boolean(snakeName),
|
|
81
81
|
};
|
|
82
82
|
case "select": {
|
|
83
|
-
|
|
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
|
|
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
|
|
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,
|