@cosmicdrift/kumiko-framework 0.21.1 → 0.23.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.21.1",
3
+ "version": "0.23.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>",
@@ -0,0 +1,95 @@
1
+ // Integration-Test für `runSchemaCli` `status`-Subcommand.
2
+ // Regression für review #155 finding 4: vorher schluckte `try/catch` jeden
3
+ // Fehler (Connection-, Permission-, Query-) und reportete alles als "pending".
4
+ // Jetzt: tracking-table fehlt → 0 applied (kein Fehler), echte Fehler werden
5
+ // propagiert. Konsistent zum detectKumikoDrift-Pattern.
6
+
7
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { type BunTestDb, createTestDb } from "../bun-db/__tests__/bun-test-db";
12
+ import { asRawClient } from "../db/query";
13
+ import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
14
+ import { ensureTemporalPolyfill } from "../time/polyfill";
15
+
16
+ let testDb: BunTestDb;
17
+ let appDir: string;
18
+ let testUrl: string;
19
+ let prevDbUrl: string | undefined;
20
+
21
+ beforeAll(async () => {
22
+ await ensureTemporalPolyfill();
23
+ testDb = await createTestDb();
24
+ const baseUrl = process.env["TEST_DATABASE_URL"];
25
+ if (!baseUrl) throw new Error("TEST_DATABASE_URL not set — required for this test file");
26
+ testUrl = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await testDb.cleanup();
31
+ });
32
+
33
+ beforeEach(async () => {
34
+ // runSchemaCli resolves appCwd/kumiko/migrations — writeMigration() legt das
35
+ // subdir bei Bedarf an. Tests, die den "Dir fehlt"-Pfad prüfen, rufen
36
+ // writeMigration nicht auf.
37
+ appDir = mkdtempSync(join(tmpdir(), "kumiko-cli-"));
38
+ prevDbUrl = process.env["DATABASE_URL"];
39
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "_kumiko_migrations"`);
40
+ });
41
+
42
+ afterEach(() => {
43
+ rmSync(appDir, { recursive: true, force: true });
44
+ if (prevDbUrl === undefined) delete process.env["DATABASE_URL"];
45
+ else process.env["DATABASE_URL"] = prevDbUrl;
46
+ });
47
+
48
+ function writeMigration(file: string, sql: string): void {
49
+ const migDir = join(appDir, "kumiko", "migrations");
50
+ mkdirSync(migDir, { recursive: true });
51
+ writeFileSync(join(migDir, file), sql);
52
+ }
53
+
54
+ function captureOut(): { out: SchemaCliOut; lines: string[] } {
55
+ const lines: string[] = [];
56
+ return {
57
+ out: { log: (l: string) => lines.push(l), err: (l: string) => lines.push(`ERR ${l}`) },
58
+ lines,
59
+ };
60
+ }
61
+
62
+ describe("runSchemaCli status", () => {
63
+ test("fresh DB without _kumiko_migrations → 0 applied (no throw)", async () => {
64
+ process.env["DATABASE_URL"] = testUrl;
65
+ writeMigration("0001_init.sql", `SELECT 1;`);
66
+ const { out, lines } = captureOut();
67
+ const code = await runSchemaCli(["status"], appDir, out);
68
+ expect(code).toBe(0);
69
+ const joined = lines.join("\n");
70
+ expect(joined).toContain("0 applied");
71
+ expect(joined).toContain("1 pending");
72
+ // Kein ERR-Prefix → kein silent-swallow eines echten Fehlers.
73
+ expect(joined).not.toContain("ERR ");
74
+ });
75
+
76
+ test("missing kumiko/migrations dir → no-op exit 0", async () => {
77
+ // Bestätigt symmetrisch: kein Verzeichnis = nichts zu reporten, kein Crash.
78
+ process.env["DATABASE_URL"] = testUrl;
79
+ const { out, lines } = captureOut();
80
+ const code = await runSchemaCli(["status"], appDir, out);
81
+ expect(code).toBe(0);
82
+ expect(lines.join("\n")).toContain("Kein kumiko/migrations/");
83
+ });
84
+
85
+ test("unreachable DB → propagates connection error (no silent '0 applied')", async () => {
86
+ // Der gefixte Pfad MUSS connection-failures propagieren — vor dem Fix hat
87
+ // `try { applied = new Set(...) } catch { applied = new Set() }` das zu
88
+ // "0 applied" verfälscht und den Operator in die Irre geführt.
89
+ // Port 1 wird auf POSIX-Systemen für nichts gebunden → connect refused.
90
+ process.env["DATABASE_URL"] = "postgresql://nobody:nope@127.0.0.1:1/__kumiko_unreachable__";
91
+ writeMigration("0001_init.sql", `SELECT 1;`);
92
+ const { out } = captureOut();
93
+ await expect(runSchemaCli(["status"], appDir, out)).rejects.toBeDefined();
94
+ });
95
+ });
@@ -0,0 +1,233 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { type BunTestDb, createTestDb } from "../bun-db/__tests__/bun-test-db";
6
+ import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
7
+ import { ensureTemporalPolyfill } from "../time/polyfill";
8
+
9
+ function captureOut(): { out: SchemaCliOut; log: string[]; err: string[] } {
10
+ const log: string[] = [];
11
+ const err: string[] = [];
12
+ return { out: { log: (l) => log.push(l), err: (l) => err.push(l) }, log, err };
13
+ }
14
+
15
+ function freshAppCwd(): string {
16
+ const dir = join(
17
+ tmpdir(),
18
+ `kumiko-schema-cli-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
19
+ );
20
+ mkdirSync(dir, { recursive: true });
21
+ mkdirSync(join(dir, "kumiko"), { recursive: true });
22
+ return dir;
23
+ }
24
+
25
+ function writeSchemaFile(appCwd: string, tableName: string, extraField?: string): void {
26
+ const extra = extraField ? `, { name: "${extraField}", pgType: "text", notNull: false }` : "";
27
+ const content = `export const ENTITY_METAS = [
28
+ {
29
+ tableName: "${tableName}",
30
+ source: "unmanaged",
31
+ indexes: [],
32
+ columns: [
33
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true, defaultSql: "gen_random_uuid()" }${extra}
34
+ ],
35
+ },
36
+ ];
37
+ `;
38
+ writeFileSync(join(appCwd, "kumiko/schema.ts"), content);
39
+ }
40
+
41
+ describe("runSchemaCli — no-DB paths", () => {
42
+ let appCwd: string;
43
+ beforeEach(() => {
44
+ appCwd = freshAppCwd();
45
+ });
46
+
47
+ test("default subcommand prints usage and exits 0", async () => {
48
+ const cap = captureOut();
49
+ const code = await runSchemaCli([], appCwd, cap.out);
50
+ expect(code).toBe(0);
51
+ expect(cap.log.join("\n")).toContain("Subcommands:");
52
+ expect(cap.err).toHaveLength(0);
53
+ });
54
+
55
+ test("unknown subcommand falls through to usage", async () => {
56
+ const cap = captureOut();
57
+ const code = await runSchemaCli(["lolwut"], appCwd, cap.out);
58
+ expect(code).toBe(0);
59
+ expect(cap.log.join("\n")).toContain("Subcommands:");
60
+ });
61
+
62
+ test("generate without name exits 1 with neutral usage wording", async () => {
63
+ const cap = captureOut();
64
+ const code = await runSchemaCli(["generate"], appCwd, cap.out);
65
+ expect(code).toBe(1);
66
+ expect(cap.err.join("\n")).toContain("Usage: schema generate <name>");
67
+ expect(cap.err.join("\n")).not.toContain("kumiko-schema");
68
+ });
69
+
70
+ test("generate with missing schema.ts exits 1", async () => {
71
+ const cap = captureOut();
72
+ const code = await runSchemaCli(["generate", "init"], appCwd, cap.out);
73
+ expect(code).toBe(1);
74
+ expect(cap.err.join("\n")).toContain("kumiko/schema.ts");
75
+ expect(cap.err.join("\n")).toContain("fehlt");
76
+ });
77
+
78
+ test("generate writes 0001_<name>.sql + .snapshot.json and exits 0", async () => {
79
+ writeSchemaFile(appCwd, "tbl_a");
80
+ const cap = captureOut();
81
+ const code = await runSchemaCli(["generate", "init"], appCwd, cap.out);
82
+ expect(code).toBe(0);
83
+
84
+ const migrationsDir = join(appCwd, "kumiko/migrations");
85
+ expect(existsSync(migrationsDir)).toBe(true);
86
+ const files = readdirSync(migrationsDir);
87
+ expect(files).toContain("0001_init.sql");
88
+ expect(files).toContain(".snapshot.json");
89
+
90
+ const sql = readFileSync(join(migrationsDir, "0001_init.sql"), "utf8");
91
+ expect(sql).toContain('CREATE TABLE IF NOT EXISTS "tbl_a"');
92
+ });
93
+
94
+ test("generate second time without schema changes prints skip + exits 0", async () => {
95
+ writeSchemaFile(appCwd, "tbl_a");
96
+ const first = captureOut();
97
+ await runSchemaCli(["generate", "init"], appCwd, first.out);
98
+
99
+ const cap = captureOut();
100
+ const code = await runSchemaCli(["generate", "noop"], appCwd, cap.out);
101
+ expect(code).toBe(0);
102
+ expect(cap.log.join("\n")).toContain("No schema changes detected");
103
+ const files = readdirSync(join(appCwd, "kumiko/migrations"));
104
+ expect(files).not.toContain("0002_noop.sql");
105
+ });
106
+
107
+ test("apply without DATABASE_URL exits 1", async () => {
108
+ const dbUrl = process.env["DATABASE_URL"];
109
+ delete process.env["DATABASE_URL"];
110
+ try {
111
+ const cap = captureOut();
112
+ const code = await runSchemaCli(["apply"], appCwd, cap.out);
113
+ expect(code).toBe(1);
114
+ expect(cap.err.join("\n")).toContain("DATABASE_URL not set");
115
+ } finally {
116
+ if (dbUrl !== undefined) process.env["DATABASE_URL"] = dbUrl;
117
+ }
118
+ });
119
+
120
+ test("status without DATABASE_URL exits 1", async () => {
121
+ const dbUrl = process.env["DATABASE_URL"];
122
+ delete process.env["DATABASE_URL"];
123
+ try {
124
+ const cap = captureOut();
125
+ const code = await runSchemaCli(["status"], appCwd, cap.out);
126
+ expect(code).toBe(1);
127
+ expect(cap.err.join("\n")).toContain("DATABASE_URL not set");
128
+ } finally {
129
+ if (dbUrl !== undefined) process.env["DATABASE_URL"] = dbUrl;
130
+ }
131
+ });
132
+
133
+ test("status without kumiko/migrations exits 0 (legacy drizzle path)", async () => {
134
+ const prevDbUrl = process.env["DATABASE_URL"];
135
+ process.env["DATABASE_URL"] = "postgresql://placeholder:placeholder@localhost:1/placeholder";
136
+ try {
137
+ const cap = captureOut();
138
+ const code = await runSchemaCli(["status"], appCwd, cap.out);
139
+ expect(code).toBe(0);
140
+ expect(cap.log.join("\n")).toContain("alten drizzle-Pfad");
141
+ } finally {
142
+ if (prevDbUrl !== undefined) process.env["DATABASE_URL"] = prevDbUrl;
143
+ else delete process.env["DATABASE_URL"];
144
+ }
145
+ });
146
+ });
147
+
148
+ describe("runSchemaCli — DB-backed paths", () => {
149
+ let testDb: BunTestDb;
150
+ let dbUrl: string;
151
+ let prevDbUrl: string | undefined;
152
+
153
+ beforeAll(async () => {
154
+ await ensureTemporalPolyfill();
155
+ testDb = await createTestDb();
156
+ const baseUrl =
157
+ process.env["TEST_DATABASE_URL"] ??
158
+ process.env["DATABASE_URL"] ??
159
+ "postgresql://kumiko:kumiko@localhost:15432/kumiko_test";
160
+ dbUrl = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
161
+ prevDbUrl = process.env["DATABASE_URL"];
162
+ process.env["DATABASE_URL"] = dbUrl;
163
+ });
164
+
165
+ afterAll(async () => {
166
+ if (prevDbUrl !== undefined) process.env["DATABASE_URL"] = prevDbUrl;
167
+ else delete process.env["DATABASE_URL"];
168
+ await testDb?.cleanup();
169
+ });
170
+
171
+ test("apply runs pending migrations, status reports 0 pending → exit 0", async () => {
172
+ const appCwd = freshAppCwd();
173
+ writeSchemaFile(appCwd, "tbl_apply_ok");
174
+ await runSchemaCli(["generate", "apply_ok"], appCwd, captureOut().out);
175
+
176
+ const applyCap = captureOut();
177
+ const applyCode = await runSchemaCli(["apply"], appCwd, applyCap.out);
178
+ expect(applyCode).toBe(0);
179
+ expect(applyCap.log.join("\n")).toContain("Applied 1");
180
+
181
+ const statusCap = captureOut();
182
+ const statusCode = await runSchemaCli(["status"], appCwd, statusCap.out);
183
+ expect(statusCode).toBe(0);
184
+ expect(statusCap.log.join("\n")).toContain("0 pending");
185
+
186
+ rmSync(appCwd, { recursive: true, force: true });
187
+ });
188
+
189
+ test("status with pending migrations exits 1 (regression-pin: CI-gating signal)", async () => {
190
+ const appCwd = freshAppCwd();
191
+ writeSchemaFile(appCwd, "tbl_pending");
192
+ await runSchemaCli(["generate", "pending_test"], appCwd, captureOut().out);
193
+
194
+ const statusCap = captureOut();
195
+ const statusCode = await runSchemaCli(["status"], appCwd, statusCap.out);
196
+ expect(statusCode).toBe(1);
197
+ expect(statusCap.log.join("\n")).toContain("1 pending");
198
+
199
+ rmSync(appCwd, { recursive: true, force: true });
200
+ });
201
+
202
+ test("apply on already-applied migrations is idempotent + exits 0", async () => {
203
+ const appCwd = freshAppCwd();
204
+ writeSchemaFile(appCwd, "tbl_idem");
205
+ await runSchemaCli(["generate", "idem_test"], appCwd, captureOut().out);
206
+ await runSchemaCli(["apply"], appCwd, captureOut().out);
207
+
208
+ const cap = captureOut();
209
+ const code = await runSchemaCli(["apply"], appCwd, cap.out);
210
+ expect(code).toBe(0);
211
+ expect(cap.log.join("\n")).toContain("already applied");
212
+
213
+ rmSync(appCwd, { recursive: true, force: true });
214
+ });
215
+
216
+ test("baseline marks migrations applied without running SQL", async () => {
217
+ const appCwd = freshAppCwd();
218
+ writeSchemaFile(appCwd, "tbl_baseline");
219
+ await runSchemaCli(["generate", "baseline_test"], appCwd, captureOut().out);
220
+
221
+ const cap = captureOut();
222
+ const code = await runSchemaCli(["baseline"], appCwd, cap.out);
223
+ expect(code).toBe(0);
224
+ expect(cap.log.join("\n")).toContain("Marked 1 migration(s) as applied");
225
+
226
+ const statusCap = captureOut();
227
+ const statusCode = await runSchemaCli(["status"], appCwd, statusCap.out);
228
+ expect(statusCode).toBe(0);
229
+ expect(statusCap.log.join("\n")).toContain("0 pending");
230
+
231
+ rmSync(appCwd, { recursive: true, force: true });
232
+ });
233
+ });
@@ -9,11 +9,12 @@ import { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker } from "..
9
9
  function meta(
10
10
  tableName: string,
11
11
  extraColumn?: EntityTableMeta["columns"][number],
12
+ indexes: EntityTableMeta["indexes"] = [],
12
13
  ): EntityTableMeta {
13
14
  return {
14
15
  tableName,
15
16
  source: "unmanaged",
16
- indexes: [],
17
+ indexes,
17
18
  columns: [
18
19
  { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
19
20
  ...(extraColumn ? [extraColumn] : []),
@@ -40,6 +41,30 @@ describe("rebuildTablesFromDiff", () => {
40
41
  const snap = snapshotFromMetas([meta("read_a")]);
41
42
  expect(rebuildTablesFromDiff(diffSnapshots(snap, snap))).toEqual([]);
42
43
  });
44
+
45
+ test("index-only change → no rebuild (ALTER bringt Tabelle alleine in Soll)", () => {
46
+ const prev = snapshotFromMetas([meta("read_a")]);
47
+ const next = snapshotFromMetas([
48
+ meta("read_a", undefined, [{ name: "read_a_id_idx", columns: ["id"] }]),
49
+ ]);
50
+ expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual([]);
51
+ });
52
+
53
+ test("dropped-column-only change → no rebuild", () => {
54
+ const prev = snapshotFromMetas([
55
+ meta("read_a", { name: "old", pgType: "text", notNull: false }),
56
+ ]);
57
+ const next = snapshotFromMetas([meta("read_a")]);
58
+ expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual([]);
59
+ });
60
+
61
+ test("new-column change → rebuild (Backfill aus Events nötig)", () => {
62
+ const prev = snapshotFromMetas([meta("read_a")]);
63
+ const next = snapshotFromMetas([
64
+ meta("read_a", { name: "title", pgType: "text", notNull: false }),
65
+ ]);
66
+ expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual(["read_a"]);
67
+ });
43
68
  });
44
69
 
45
70
  describe("write/read marker", () => {
@@ -83,4 +108,27 @@ describe("write/read marker", () => {
83
108
  rmSync(dir, { recursive: true, force: true });
84
109
  }
85
110
  });
111
+
112
+ test("version-mismatch marker → [] (graceful degradation, blockt v2 nicht als v1)", () => {
113
+ const dir = tmpDir();
114
+ try {
115
+ writeFileSync(
116
+ join(dir, "0005_future.rebuild.json"),
117
+ JSON.stringify({ version: 2, tables: ["read_x"] }),
118
+ );
119
+ expect(readRebuildMarker(dir, "0005_future")).toEqual([]);
120
+ } finally {
121
+ rmSync(dir, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ test("missing version field → []", () => {
126
+ const dir = tmpDir();
127
+ try {
128
+ writeFileSync(join(dir, "0006_noversion.rebuild.json"), JSON.stringify({ tables: ["x"] }));
129
+ expect(readRebuildMarker(dir, "0006_noversion")).toEqual([]);
130
+ } finally {
131
+ rmSync(dir, { recursive: true, force: true });
132
+ }
133
+ });
86
134
  });
@@ -30,7 +30,7 @@ describe("sql-inventory", () => {
30
30
  true,
31
31
  );
32
32
  expect(isRawSqlAllowed("/repo/bin/commands/schema.ts")).toBe(true);
33
- expect(isRawSqlAllowed("/repo/scripts/codemod-drizzle-chain-to-bun-db.ts")).toBe(true);
33
+ expect(isRawSqlAllowed("/repo/scripts/codemod-bun-db-swap.ts")).toBe(true);
34
34
  expect(
35
35
  isRawSqlAllowed("/repo/packages/bundled-features/src/sessions/handlers/cleanup.job.ts"),
36
36
  ).toBe(false);
@@ -33,12 +33,16 @@ function markerPathFor(migrationsDir: string, migrationId: string): string {
33
33
  }
34
34
 
35
35
  // Tabellen die nach dieser Migration einen Projection-Rebuild brauchen können:
36
- // neu angelegte + spalten-/index-geänderte. Gelöschte Tabellen NICHT (die
37
- // Projektion ist mit der Tabelle weg). Sortiert + dedupliziert für stabilen
38
- // PR-Diff.
36
+ // neu angelegte + Tabellen mit neuer Spalte. Index-/Nullability-/Default-/
37
+ // Drop-only-Änderungen brauchen KEINEN Rebuild das generierte ALTER-SQL
38
+ // bringt die Tabelle alleine in den Soll-Zustand. Über-Rebuild ist safe-but-
39
+ // wasteful (Tabellen-Lock + Full-Replay auf grossen Streams), deshalb hier
40
+ // konservativ einschränken. Sortiert + dedupliziert für stabilen PR-Diff.
39
41
  export function rebuildTablesFromDiff(diff: SchemaDiff): readonly string[] {
40
42
  const names = new Set<string>();
41
- for (const t of diff.changedTables) names.add(t.tableName);
43
+ for (const t of diff.changedTables) {
44
+ if (t.newColumns.length > 0) names.add(t.tableName);
45
+ }
42
46
  for (const t of diff.newTables) names.add(t.tableName);
43
47
  return [...names].sort();
44
48
  }
@@ -68,7 +72,9 @@ export function readRebuildMarker(migrationsDir: string, migrationId: string): r
68
72
  } catch {
69
73
  return [];
70
74
  }
71
- if (typeof parsed !== "object" || parsed === null || !("tables" in parsed)) return [];
75
+ if (typeof parsed !== "object" || parsed === null) return [];
76
+ if (!("version" in parsed) || parsed.version !== MARKER_VERSION) return [];
77
+ if (!("tables" in parsed)) return [];
72
78
  const { tables } = parsed;
73
79
  if (!Array.isArray(tables)) return [];
74
80
  return tables.filter((t): t is string => typeof t === "string");
@@ -29,10 +29,23 @@ export async function tableExists(
29
29
  unsafe?: (s: string, p?: readonly unknown[]) => Promise<readonly { exists: boolean }[]>;
30
30
  };
31
31
  const client = dbAny.$client ?? dbAny.session?.client ?? dbAny;
32
+ // quote_ident-Round-trip auf SQL-Seite: ohne Quotes folded postgres
33
+ // unquoted identifier case-insensitiv (myWidget → mywidget), während die
34
+ // generierte DDL den Namen via quoteIdent("myWidget") → "myWidget" case-
35
+ // preserved schreibt. quote_ident sorgt für identische Quotierung beidseits.
36
+ // Schema-qualifizierte Namen (`public.events`) werden per Split einzeln quotet.
37
+ const dotIdx = fullyQualifiedName.indexOf(".");
38
+ const [sql, params] =
39
+ dotIdx >= 0
40
+ ? [
41
+ `SELECT to_regclass(quote_ident($1) || '.' || quote_ident($2)) IS NOT NULL AS exists`,
42
+ [fullyQualifiedName.slice(0, dotIdx), fullyQualifiedName.slice(dotIdx + 1)],
43
+ ]
44
+ : [`SELECT to_regclass(quote_ident($1)) IS NOT NULL AS exists`, [fullyQualifiedName]];
32
45
  const rows = await (
33
46
  client as {
34
47
  unsafe: (s: string, p?: readonly unknown[]) => Promise<readonly { exists: boolean }[]>;
35
48
  }
36
- ).unsafe(`SELECT to_regclass($1) IS NOT NULL AS exists`, [fullyQualifiedName]);
49
+ ).unsafe(sql, params);
37
50
  return rows[0]?.exists ?? false;
38
51
  }
@@ -1,7 +1,7 @@
1
1
  import { qualifyEntityName } from "../qualified-name";
2
2
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
3
3
  import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
4
- import { normalizeEditField, normalizeListColumn } from "../types/screen";
4
+ import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
5
5
 
6
6
  // --- Screen validation ---
7
7
  //
@@ -62,6 +62,7 @@ export function validateScreens(
62
62
  );
63
63
  }
64
64
  for (const section of screen.layout.sections) {
65
+ if (isExtensionEditSection(section)) continue;
65
66
  if (section.fields.length === 0) {
66
67
  throw new Error(
67
68
  `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
@@ -160,6 +161,7 @@ export function validateScreens(
160
161
  );
161
162
  }
162
163
  for (const section of screen.layout.sections) {
164
+ if (isExtensionEditSection(section)) continue;
163
165
  if (section.fields.length === 0) {
164
166
  throw new Error(
165
167
  `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
@@ -341,6 +343,15 @@ export function validateScreens(
341
343
  );
342
344
  }
343
345
  for (const section of screen.layout.sections) {
346
+ if (isExtensionEditSection(section)) {
347
+ if (section.component.react === undefined && section.component.native === undefined) {
348
+ throw new Error(
349
+ `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) extension section ` +
350
+ `"${section.title}" has no component — declare a react/native component marker.`,
351
+ );
352
+ }
353
+ continue;
354
+ }
344
355
  if (section.fields.length === 0) {
345
356
  throw new Error(
346
357
  `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has a section "${section.title}" ` +
@@ -226,7 +226,9 @@ export type {
226
226
  CustomScreenRoute,
227
227
  DateFieldDef,
228
228
  DeleteContext,
229
+ EditExtensionSection,
229
230
  EditFieldSpec,
231
+ EditFieldsSection,
230
232
  EditLayout,
231
233
  EditSectionSpec,
232
234
  EntityDefinition,
@@ -325,7 +327,7 @@ export type {
325
327
  export { DEFAULT_CURRENCIES, HookPhases } from "./types";
326
328
  export { resolveName, withResponseData } from "./types/handlers";
327
329
  export { isSystemTenant, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
328
- export { normalizeEditField, normalizeListColumn } from "./types/screen";
330
+ export { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "./types/screen";
329
331
  export type {
330
332
  PipelineBuildCtx,
331
333
  PipelineCtx,
@@ -204,7 +204,9 @@ export type {
204
204
  ConfigEditScreenDefinition,
205
205
  CustomScreenDefinition,
206
206
  CustomScreenRoute,
207
+ EditExtensionSection,
207
208
  EditFieldSpec,
209
+ EditFieldsSection,
208
210
  EditLayout,
209
211
  EditSectionSpec,
210
212
  EntityEditScreenDefinition,
@@ -222,7 +224,7 @@ export type {
222
224
  ScreenSlots,
223
225
  ToolbarAction,
224
226
  } from "./screen";
225
- export { normalizeEditField, normalizeListColumn } from "./screen";
227
+ export { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "./screen";
226
228
  export type { TargetRef } from "./target-ref";
227
229
  export type {
228
230
  Subscribe,
@@ -280,12 +280,31 @@ export type EditFieldSpec =
280
280
  readonly renderer?: FieldRenderer;
281
281
  };
282
282
 
283
- export type EditSectionSpec = {
283
+ // A section is either a normal field-grid (default — `kind` omitted keeps
284
+ // every existing screen-def working) or an extension slot that mounts a
285
+ // feature-provided component. The extension component is resolved client-
286
+ // side by name (same `__component` marker as custom screens / column
287
+ // renderers) and receives the host entity name + id, so a bundled feature
288
+ // (e.g. custom-fields) can load and persist its own data inside the form.
289
+ export type EditSectionSpec = EditFieldsSection | EditExtensionSection;
290
+
291
+ export type EditFieldsSection = {
292
+ readonly kind?: "fields";
284
293
  readonly title: string;
285
294
  readonly columns?: number;
286
295
  readonly fields: readonly EditFieldSpec[];
287
296
  };
288
297
 
298
+ export type EditExtensionSection = {
299
+ readonly kind: "extension";
300
+ readonly title: string;
301
+ readonly component: PlatformComponent;
302
+ };
303
+
304
+ export function isExtensionEditSection(section: EditSectionSpec): section is EditExtensionSection {
305
+ return section.kind === "extension";
306
+ }
307
+
289
308
  export type EditLayout = {
290
309
  readonly sections: readonly EditSectionSpec[];
291
310
  };
@@ -37,6 +37,7 @@ import {
37
37
  unsafeCreateEntityTable,
38
38
  } from "../../stack";
39
39
  import { buildMultipartBody, patchFileInstanceofForBunTest } from "../../testing";
40
+ import { createFilesFeature } from "../feature";
40
41
  import { createLocalProvider } from "../local-provider";
41
42
 
42
43
  // Covers ALL four file-field variants: singular (file/image) stores a UUID in
@@ -73,7 +74,7 @@ beforeAll(async () => {
73
74
  patchFileInstanceofForBunTest();
74
75
  storagePath = await mkdtemp(join(tmpdir(), "kumiko-file-field-pipeline-"));
75
76
  stack = await setupTestStack({
76
- features: [documentFeature],
77
+ features: [createFilesFeature(), documentFeature],
77
78
  files: { storageProvider: createLocalProvider(storagePath) },
78
79
  });
79
80
  await unsafeCreateEntityTable(stack.db, documentEntity);
@@ -28,7 +28,7 @@ import {
28
28
  patchFileInstanceofForBunTest,
29
29
  } from "../../testing";
30
30
  import { fileRefsTable } from "../file-ref-table";
31
- import { FILE_UPLOADED_EVENT_TYPE, type FileRoutesOptions } from "../file-routes";
31
+ import type { FileRoutesOptions } from "../file-routes";
32
32
  import { createInMemoryFileProvider } from "../in-memory-provider";
33
33
  import { createLocalProvider } from "../local-provider";
34
34
  import type { FileStorageProvider } from "../types";
@@ -248,18 +248,17 @@ describe("file upload flow via API", () => {
248
248
  expect(downloaded[0]).toBe(0x89); // PNG magic byte
249
249
  });
250
250
 
251
- test("upload appends files:event:uploaded to the fileRef stream", async () => {
252
- // Load the full event stream for the just-uploaded FileRef. Phase 1
253
- // guarantees exactly one event per upload "uploaded" at version 1.
251
+ test("upload emits fileRef.created to the entity stream", async () => {
252
+ // fileRef is a standard ES entity the upload goes through the entity
253
+ // executor, so the stream carries exactly one `fileRef.created` at v1.
254
254
  const events = await loadAggregate(testDb.db, uploadedFileId, adminUser.tenantId);
255
255
 
256
256
  expect(events).toHaveLength(1);
257
257
  const event = events[0];
258
- expect(event?.type).toBe(FILE_UPLOADED_EVENT_TYPE);
258
+ expect(event?.type).toBe("fileRef.created");
259
259
  expect(event?.version).toBe(1);
260
260
 
261
- type UploadedPayload = {
262
- fileRefId: string;
261
+ type CreatedPayload = {
263
262
  fileName: string;
264
263
  mimeType: string;
265
264
  size: number;
@@ -269,8 +268,10 @@ describe("file upload flow via API", () => {
269
268
  data?: unknown;
270
269
  binary?: unknown;
271
270
  };
272
- const payload = event!.payload as UploadedPayload;
273
- expect(payload.fileRefId).toBe(uploadedFileId);
271
+ // The entity executor strips `id` from the event payload (it's the
272
+ // aggregateId / stream id, which we loaded by above) and threads
273
+ // insertedById through metadata — neither appears in payload.
274
+ const payload = event!.payload as CreatedPayload;
274
275
  expect(payload.fileName).toBe("logo.png");
275
276
  expect(payload.mimeType).toBe("image/png");
276
277
  expect(payload.size).toBe(testPngContent.length);