@cosmicdrift/kumiko-framework 0.15.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/auth-routes.ts +2 -5
- package/src/api/routes.ts +9 -3
- package/src/bun-db/__tests__/query-guards.test.ts +53 -0
- package/src/bun-db/query.ts +164 -23
- package/src/compliance/profiles.ts +1 -4
- package/src/db/__tests__/cursor.test.ts +17 -0
- package/src/db/__tests__/migrate-generator.test.ts +71 -0
- package/src/db/__tests__/migrate-runner.test.ts +19 -0
- package/src/db/__tests__/pg-error.test.ts +43 -0
- package/src/db/assert-exists-in.ts +5 -1
- package/src/db/dialect.ts +11 -6
- package/src/db/entity-table-meta.ts +23 -8
- package/src/db/index.ts +25 -0
- package/src/db/migrate-runner.ts +35 -1
- package/src/db/money.ts +5 -0
- package/src/db/queries/test-stack.ts +3 -1
- package/src/db/table-builder.ts +6 -6
- package/src/engine/__tests__/duration-utils.test.ts +16 -0
- package/src/engine/__tests__/field-access.test.ts +38 -0
- package/src/engine/__tests__/no-return-guard.test.ts +17 -0
- package/src/engine/__tests__/unmanaged-table.test.ts +98 -0
- package/src/engine/define-feature.ts +36 -0
- package/src/engine/feature-ast/extractors/shared.ts +2 -3
- package/src/engine/index.ts +1 -1
- package/src/engine/registry.ts +19 -0
- package/src/engine/schema-builder.ts +1 -1
- package/src/engine/types/feature.ts +40 -0
- package/src/engine/types/index.ts +2 -0
- package/src/errors/__tests__/error-helpers.test.ts +44 -0
- package/src/errors/__tests__/field-issue-compat.test.ts +16 -0
- package/src/errors/classes.ts +5 -19
- package/src/errors/field-issue.ts +11 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/zod-bridge.ts +3 -2
- package/src/es-ops/context.ts +2 -13
- package/src/event-store/__tests__/get-stream-version-perf.integration.test.ts +15 -12
- package/src/event-store/admin-api.ts +5 -4
- package/src/event-store/event-store.ts +3 -2
- package/src/event-store/snapshot.ts +2 -1
- package/src/migrations/__tests__/kumiko-drift.integration.test.ts +161 -0
- package/src/migrations/index.ts +11 -1
- package/src/migrations/kumiko-drift.ts +122 -0
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +107 -0
- package/src/pipeline/__tests__/redis-keys.test.ts +12 -0
- package/src/pipeline/dispatcher-utils.ts +8 -7
- package/src/stack/request-helper.ts +39 -4
- package/src/stack/table-helpers.ts +1 -1
- package/src/stack/test-stack.ts +2 -2
- package/src/utils/__tests__/case.test.ts +16 -0
- package/src/utils/__tests__/is-plain-object.test.ts +16 -0
- package/src/utils/__tests__/parse-string-array-json.test.ts +16 -0
- package/src/utils/__tests__/safe-json.test.ts +22 -0
- package/src/utils/case.ts +6 -0
- package/src/utils/index.ts +4 -1
- package/src/utils/is-plain-object.ts +4 -0
- package/src/utils/parse-string-array-json.ts +14 -0
- package/src/utils/safe-json.ts +19 -0
package/src/db/dialect.ts
CHANGED
|
@@ -94,6 +94,7 @@ type ColumnFinal = {
|
|
|
94
94
|
readonly unique: boolean;
|
|
95
95
|
readonly identity: boolean;
|
|
96
96
|
readonly defaultSql?: string;
|
|
97
|
+
readonly bigintJsMode?: "number" | "bigint";
|
|
97
98
|
};
|
|
98
99
|
|
|
99
100
|
export type ColumnBuilder<TValue = unknown> = {
|
|
@@ -112,7 +113,11 @@ export type ColumnBuilder<TValue = unknown> = {
|
|
|
112
113
|
$onUpdate(fn: () => unknown): ColumnBuilder<TValue>;
|
|
113
114
|
};
|
|
114
115
|
|
|
115
|
-
function buildColumn(
|
|
116
|
+
function buildColumn(
|
|
117
|
+
sqlName: string,
|
|
118
|
+
pgType: PgType,
|
|
119
|
+
opts?: { bigintJsMode?: "number" | "bigint" },
|
|
120
|
+
): ColumnBuilder<unknown> {
|
|
116
121
|
let notNull = false;
|
|
117
122
|
let primaryKey = false;
|
|
118
123
|
let unique = false;
|
|
@@ -152,6 +157,7 @@ function buildColumn(sqlName: string, pgType: PgType): ColumnBuilder<unknown> {
|
|
|
152
157
|
unique,
|
|
153
158
|
identity,
|
|
154
159
|
...(defaultSql !== undefined && { defaultSql }),
|
|
160
|
+
...(opts?.bigintJsMode !== undefined && { bigintJsMode: opts.bigintJsMode }),
|
|
155
161
|
};
|
|
156
162
|
},
|
|
157
163
|
notNull() {
|
|
@@ -219,11 +225,9 @@ export function serial(name: string): ColumnBuilder<number> {
|
|
|
219
225
|
return buildColumn(name, "serial") as ColumnBuilder<number>;
|
|
220
226
|
}
|
|
221
227
|
|
|
222
|
-
export function bigint(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
): ColumnBuilder<bigint> {
|
|
226
|
-
return buildColumn(name, "bigint") as ColumnBuilder<bigint>;
|
|
228
|
+
export function bigint(name: string, opts?: { mode?: "bigint" | "number" }): ColumnBuilder<bigint> {
|
|
229
|
+
const jsMode = opts?.mode === "number" ? "number" : "bigint";
|
|
230
|
+
return buildColumn(name, "bigint", { bigintJsMode: jsMode }) as ColumnBuilder<bigint>;
|
|
227
231
|
}
|
|
228
232
|
|
|
229
233
|
export function bigserial(
|
|
@@ -407,6 +411,7 @@ export function table<TCols extends ColumnMap>(
|
|
|
407
411
|
...(final.primaryKey && { primaryKey: true }),
|
|
408
412
|
...(final.identity && { identity: true }),
|
|
409
413
|
...(final.defaultSql !== undefined && { defaultSql: final.defaultSql }),
|
|
414
|
+
...(final.bigintJsMode !== undefined && { bigintJsMode: final.bigintJsMode }),
|
|
410
415
|
};
|
|
411
416
|
columnMetas.push(meta);
|
|
412
417
|
|
|
@@ -51,6 +51,9 @@ export type ColumnMeta = {
|
|
|
51
51
|
readonly defaultSql?: string;
|
|
52
52
|
readonly primaryKey?: boolean;
|
|
53
53
|
readonly identity?: boolean;
|
|
54
|
+
// bigint/bigserial only: JS round-trip mode. `number` = createBigIntField /
|
|
55
|
+
// drizzle mode:"number" (safe ≤2^53). `bigint` = money cents, raw unmanaged.
|
|
56
|
+
readonly bigintJsMode?: "number" | "bigint";
|
|
54
57
|
};
|
|
55
58
|
|
|
56
59
|
export type IndexMeta = {
|
|
@@ -199,6 +202,7 @@ function fieldToColumnMeta(
|
|
|
199
202
|
name: snake,
|
|
200
203
|
pgType: "bigint",
|
|
201
204
|
notNull: field.required === true,
|
|
205
|
+
bigintJsMode: "number",
|
|
202
206
|
...(def !== undefined && { defaultSql: def }),
|
|
203
207
|
},
|
|
204
208
|
];
|
|
@@ -211,7 +215,7 @@ function fieldToColumnMeta(
|
|
|
211
215
|
case "money": {
|
|
212
216
|
const cur = entity.defaultCurrency ?? "EUR";
|
|
213
217
|
return [
|
|
214
|
-
{ name: snake, pgType: "bigint", notNull: field.required === true },
|
|
218
|
+
{ name: snake, pgType: "bigint", notNull: field.required === true, bigintJsMode: "bigint" },
|
|
215
219
|
{
|
|
216
220
|
name: `${snake}_currency`,
|
|
217
221
|
pgType: "text",
|
|
@@ -329,23 +333,22 @@ export function buildEntityTableMeta(
|
|
|
329
333
|
indexes.push({ name: `${tableName}_${snake}_idx`, columns: [snake] });
|
|
330
334
|
}
|
|
331
335
|
|
|
332
|
-
// Explizit deklarierte indexes (EntityIndexDef). `def.where` ist
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
// ist, markieren wir den IndexMeta mit needsManualWhere=true; der DDL-
|
|
336
|
-
// Renderer emittiert das Statement dann als AUSKOMMENTIERT mit Warn-
|
|
337
|
-
// Hinweis. App-Author muss das im generierten SQL-File hand-editieren.
|
|
336
|
+
// Explizit deklarierte indexes (EntityIndexDef). `def.where` ist ein
|
|
337
|
+
// SqlExpression (`sql\`…\`` aus @cosmicdrift/kumiko-framework/db) —
|
|
338
|
+
// renderbar via `.text`. Unbekannte where-Shapes bleiben needsManualWhere.
|
|
338
339
|
for (const def of (entity.indexes ?? []) as readonly EntityIndexDef[]) {
|
|
339
340
|
const cols = def.columns.map(
|
|
340
341
|
(fieldName) => fieldNameToSnake.get(fieldName) ?? toSnakeCase(fieldName),
|
|
341
342
|
);
|
|
342
343
|
const suffix = def.unique === true ? "unique" : "idx";
|
|
343
344
|
const indexName = def.name ?? `${tableName}_${cols.join("_")}_${suffix}`;
|
|
345
|
+
const whereSql = sqlExpressionText(def.where);
|
|
344
346
|
indexes.push({
|
|
345
347
|
name: indexName,
|
|
346
348
|
columns: cols,
|
|
347
349
|
...(def.unique === true && { unique: true }),
|
|
348
|
-
...(
|
|
350
|
+
...(whereSql !== undefined && { whereSql }),
|
|
351
|
+
...(def.where !== undefined && whereSql === undefined && { needsManualWhere: true }),
|
|
349
352
|
});
|
|
350
353
|
}
|
|
351
354
|
|
|
@@ -377,6 +380,18 @@ export type UnmanagedTableInput = {
|
|
|
377
380
|
readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
|
|
378
381
|
};
|
|
379
382
|
|
|
383
|
+
function sqlExpressionText(where: unknown): string | undefined {
|
|
384
|
+
if (
|
|
385
|
+
typeof where === "object" &&
|
|
386
|
+
where !== null &&
|
|
387
|
+
(where as { kind?: unknown }).kind === "sql-expr" &&
|
|
388
|
+
typeof (where as { text?: unknown }).text === "string"
|
|
389
|
+
) {
|
|
390
|
+
return (where as { text: string }).text;
|
|
391
|
+
}
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
380
395
|
export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
|
|
381
396
|
return {
|
|
382
397
|
tableName: input.tableName,
|
package/src/db/index.ts
CHANGED
|
@@ -51,6 +51,31 @@ export type {
|
|
|
51
51
|
} from "./event-store-executor";
|
|
52
52
|
export { createEventStoreExecutor, entityEventName } from "./event-store-executor";
|
|
53
53
|
export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp";
|
|
54
|
+
export {
|
|
55
|
+
diffSnapshots,
|
|
56
|
+
type GenerateMigrationInput,
|
|
57
|
+
type GenerateMigrationOutput,
|
|
58
|
+
generateMigration,
|
|
59
|
+
loadSnapshotJson,
|
|
60
|
+
renderMigrationSql,
|
|
61
|
+
type SchemaDiff,
|
|
62
|
+
type Snapshot,
|
|
63
|
+
snapshotFromMetas,
|
|
64
|
+
writeSnapshotJson,
|
|
65
|
+
} from "./migrate-generator";
|
|
66
|
+
export {
|
|
67
|
+
type AppliedMigration,
|
|
68
|
+
type ApplyResult,
|
|
69
|
+
type BaselineResult,
|
|
70
|
+
baselineMigrations,
|
|
71
|
+
fetchAppliedMigrations,
|
|
72
|
+
loadMigrationsFromDir,
|
|
73
|
+
type Migration,
|
|
74
|
+
MigrationChecksumMismatchError,
|
|
75
|
+
runMigrations,
|
|
76
|
+
runMigrationsFromDir,
|
|
77
|
+
splitSqlStatements,
|
|
78
|
+
} from "./migrate-runner";
|
|
54
79
|
export { flattenMoney, rehydrateMoney } from "./money";
|
|
55
80
|
export {
|
|
56
81
|
constraintOf,
|
package/src/db/migrate-runner.ts
CHANGED
|
@@ -111,7 +111,9 @@ async function executeRaw(db: DbRunner, sqlText: string): Promise<void> {
|
|
|
111
111
|
await rawClient(db).unsafe(sqlText);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
async function fetchAppliedMigrations(
|
|
114
|
+
export async function fetchAppliedMigrations(
|
|
115
|
+
db: DbConnection,
|
|
116
|
+
): Promise<readonly AppliedMigration[]> {
|
|
115
117
|
const result = await rawClient(db).unsafe(
|
|
116
118
|
`SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`,
|
|
117
119
|
);
|
|
@@ -204,3 +206,35 @@ export async function runMigrationsFromDir(db: DbConnection, dir: string): Promi
|
|
|
204
206
|
const migrations = loadMigrationsFromDir(dir);
|
|
205
207
|
return runMigrations(db, migrations);
|
|
206
208
|
}
|
|
209
|
+
|
|
210
|
+
export type BaselineResult = {
|
|
211
|
+
readonly marked: readonly string[];
|
|
212
|
+
readonly alreadyTracked: readonly string[];
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// Marks migrations as applied in `_kumiko_migrations` WITHOUT executing their
|
|
216
|
+
// SQL. For adopting an existing DB whose tables already exist — e.g. the
|
|
217
|
+
// cutover from the legacy drizzle-kit system, where re-running 0001_init would
|
|
218
|
+
// hit CREATE-TABLE conflicts. Idempotent: already-tracked ids are left as-is.
|
|
219
|
+
export async function baselineMigrations(
|
|
220
|
+
db: DbConnection,
|
|
221
|
+
migrations: readonly Migration[],
|
|
222
|
+
): Promise<BaselineResult> {
|
|
223
|
+
await executeRaw(db, MIGRATIONS_TABLE_DDL);
|
|
224
|
+
const applied = new Set((await fetchAppliedMigrations(db)).map((a) => a.id));
|
|
225
|
+
const marked: string[] = [];
|
|
226
|
+
const alreadyTracked: string[] = [];
|
|
227
|
+
const client = rawClient(db);
|
|
228
|
+
for (const m of migrations) {
|
|
229
|
+
if (applied.has(m.id)) {
|
|
230
|
+
alreadyTracked.push(m.id);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
await client.unsafe(
|
|
234
|
+
`INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2) ON CONFLICT ("id") DO NOTHING`,
|
|
235
|
+
[m.id, m.checksum],
|
|
236
|
+
);
|
|
237
|
+
marked.push(m.id);
|
|
238
|
+
}
|
|
239
|
+
return { marked, alreadyTracked };
|
|
240
|
+
}
|
package/src/db/money.ts
CHANGED
|
@@ -106,6 +106,11 @@ export function rehydrateMoney(
|
|
|
106
106
|
let amount: number;
|
|
107
107
|
if (typeof amountRaw === "number") {
|
|
108
108
|
amount = amountRaw;
|
|
109
|
+
} else if (typeof amountRaw === "bigint") {
|
|
110
|
+
amount = Number(amountRaw);
|
|
111
|
+
if (Number.isNaN(amount)) {
|
|
112
|
+
throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a number`);
|
|
113
|
+
}
|
|
109
114
|
} else if (typeof amountRaw === "string" && amountRaw !== "") {
|
|
110
115
|
// PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
|
|
111
116
|
amount = Number(amountRaw);
|
|
@@ -25,9 +25,11 @@ export async function createIndexIfNotExists(
|
|
|
25
25
|
indexName: string,
|
|
26
26
|
tableName: string,
|
|
27
27
|
columnList: string,
|
|
28
|
+
whereSql?: string,
|
|
28
29
|
): Promise<void> {
|
|
30
|
+
const where = whereSql !== undefined ? ` WHERE ${whereSql}` : "";
|
|
29
31
|
await asRawClient(db).unsafe(
|
|
30
|
-
`CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})`,
|
|
32
|
+
`CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})${where}`,
|
|
31
33
|
);
|
|
32
34
|
}
|
|
33
35
|
|
package/src/db/table-builder.ts
CHANGED
|
@@ -5,6 +5,10 @@ import type {
|
|
|
5
5
|
FieldsMap,
|
|
6
6
|
} from "../engine/types";
|
|
7
7
|
import { assertUnreachable } from "../utils";
|
|
8
|
+
import { toSnakeCase } from "../utils/case";
|
|
9
|
+
|
|
10
|
+
export { toSnakeCase } from "../utils/case";
|
|
11
|
+
|
|
8
12
|
import {
|
|
9
13
|
bigint,
|
|
10
14
|
boolean,
|
|
@@ -192,12 +196,8 @@ function fieldToColumns(
|
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
// Accepts both camelCase (`tenantMembership`) and kebab-case (`tenant-membership`)
|
|
195
|
-
// entity / field names.
|
|
196
|
-
//
|
|
197
|
-
// kept working for already-shipped code.
|
|
198
|
-
export function toSnakeCase(str: string): string {
|
|
199
|
-
return str.replace(/-/g, "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
200
|
-
}
|
|
199
|
+
// entity / field names. Implementation lives in utils/case — re-exported here
|
|
200
|
+
// for backwards-compatible imports from db/table-builder.
|
|
201
201
|
|
|
202
202
|
/**
|
|
203
203
|
* Derives a table name from an entity name:
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { addDuration } from "../steps/_duration-utils";
|
|
3
|
+
|
|
4
|
+
describe("addDuration", () => {
|
|
5
|
+
test("adds ISO duration to base instant", () => {
|
|
6
|
+
const base = "2024-01-01T00:00:00Z";
|
|
7
|
+
const result = addDuration(base, "PT1H");
|
|
8
|
+
expect(result).toBe(Temporal.Instant.from(base).add({ hours: 1 }).toString());
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("throws on invalid duration", () => {
|
|
12
|
+
expect(() => addDuration("2024-01-01T00:00:00Z", "not-a-duration")).toThrow(
|
|
13
|
+
/Invalid ISO-8601 duration/,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { checkWriteFieldRoles, filterReadFields } from "../field-access";
|
|
3
|
+
import type { EntityDefinition } from "../types";
|
|
4
|
+
|
|
5
|
+
const entity: EntityDefinition = {
|
|
6
|
+
fields: {
|
|
7
|
+
title: { type: "text", access: { read: { editor: "all" }, write: { editor: "all" } } },
|
|
8
|
+
secret: { type: "text", access: { read: { admin: "all" }, write: { admin: "all" } } },
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const editor = { id: "u1", tenantId: "t1", roles: ["editor"] as const };
|
|
13
|
+
const admin = { id: "u2", tenantId: "t1", roles: ["admin"] as const };
|
|
14
|
+
|
|
15
|
+
describe("filterReadFields", () => {
|
|
16
|
+
test("strips fields the user cannot read", () => {
|
|
17
|
+
const row = { id: 1, title: "Hello", secret: "hidden" };
|
|
18
|
+
const filtered = filterReadFields(entity, row, editor);
|
|
19
|
+
expect(filtered["title"]).toBe("Hello");
|
|
20
|
+
expect(filtered["secret"]).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("keeps restricted fields for allowed roles", () => {
|
|
24
|
+
const row = { id: 1, title: "Hello", secret: "visible" };
|
|
25
|
+
const filtered = filterReadFields(entity, row, admin);
|
|
26
|
+
expect(filtered["secret"]).toBe("visible");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("checkWriteFieldRoles", () => {
|
|
31
|
+
test("returns denied field name when role missing", () => {
|
|
32
|
+
expect(checkWriteFieldRoles(entity, { secret: "x" }, editor)).toBe("secret");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("returns null when all changed fields are allowed", () => {
|
|
36
|
+
expect(checkWriteFieldRoles(entity, { title: "x" }, editor)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateNoReturnSteps } from "../steps/_no-return-guard";
|
|
3
|
+
import type { StepInstance } from "../types/step";
|
|
4
|
+
|
|
5
|
+
describe("validateNoReturnSteps", () => {
|
|
6
|
+
test("passes when no return steps present", () => {
|
|
7
|
+
const steps = [{ kind: "noop" }] as unknown as readonly StepInstance[];
|
|
8
|
+
expect(() => validateNoReturnSteps(steps, "r.step.branch.onTrue")).not.toThrow();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("throws when return step is nested", () => {
|
|
12
|
+
const steps = [{ kind: "return" }] as unknown as readonly StepInstance[];
|
|
13
|
+
expect(() => validateNoReturnSteps(steps, "r.step.forEach.do")).toThrow(
|
|
14
|
+
/not allowed inside r\.step\.forEach\.do/,
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Unit tests for r.unmanagedTable() — the EntityTableMeta cousin of
|
|
2
|
+
// r.rawTable. Same audit-trail contract, different storage shape (post-
|
|
3
|
+
// drizzle migrate-runner). See define-feature.ts / DX-4.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { defineUnmanagedTable } from "../../db/entity-table-meta";
|
|
7
|
+
import { defineFeature } from "../define-feature";
|
|
8
|
+
import { createRegistry } from "../registry";
|
|
9
|
+
|
|
10
|
+
const probeMeta = defineUnmanagedTable({
|
|
11
|
+
tableName: "ut_probe",
|
|
12
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
13
|
+
});
|
|
14
|
+
const probeMetaTwo = defineUnmanagedTable({
|
|
15
|
+
tableName: "ut_probe_two",
|
|
16
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("r.unmanagedTable — declaration", () => {
|
|
20
|
+
test("rejects duplicate registrations within one feature", () => {
|
|
21
|
+
expect(() =>
|
|
22
|
+
defineFeature("probe", (r) => {
|
|
23
|
+
r.unmanagedTable(probeMeta, { reason: "test" });
|
|
24
|
+
r.unmanagedTable(probeMeta, { reason: "test" });
|
|
25
|
+
}),
|
|
26
|
+
).toThrow(/already registered/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("rejects empty reason", () => {
|
|
30
|
+
expect(() =>
|
|
31
|
+
defineFeature("probe", (r) => {
|
|
32
|
+
r.unmanagedTable(probeMeta, { reason: "" });
|
|
33
|
+
}),
|
|
34
|
+
).toThrow(/options\.reason must be a non-empty string/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("rejects whitespace-only reason", () => {
|
|
38
|
+
expect(() =>
|
|
39
|
+
defineFeature("probe", (r) => {
|
|
40
|
+
r.unmanagedTable(probeMeta, { reason: " " });
|
|
41
|
+
}),
|
|
42
|
+
).toThrow(/options\.reason must be a non-empty string/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("accepts valid registration and stores meta + reason", () => {
|
|
46
|
+
const feature = defineFeature("probe", (r) => {
|
|
47
|
+
r.unmanagedTable(probeMeta, {
|
|
48
|
+
reason: "read-side projection of an event-stream",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
expect(feature.unmanagedTables).toHaveProperty("ut_probe");
|
|
52
|
+
expect(feature.unmanagedTables["ut_probe"]?.reason).toBe(
|
|
53
|
+
"read-side projection of an event-stream",
|
|
54
|
+
);
|
|
55
|
+
expect(feature.unmanagedTables["ut_probe"]?.meta).toBe(probeMeta);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("two unmanaged tables on one feature register under their tableName", () => {
|
|
59
|
+
const feature = defineFeature("dual", (r) => {
|
|
60
|
+
r.unmanagedTable(probeMeta, { reason: "one" });
|
|
61
|
+
r.unmanagedTable(probeMetaTwo, { reason: "two" });
|
|
62
|
+
});
|
|
63
|
+
expect(Object.keys(feature.unmanagedTables).sort()).toEqual(["ut_probe", "ut_probe_two"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("absent unmanagedTables on a feature is ok", () => {
|
|
67
|
+
const feat = defineFeature("plain", () => {
|
|
68
|
+
// no r.unmanagedTable calls
|
|
69
|
+
});
|
|
70
|
+
expect(feat.unmanagedTables).toEqual({});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("createRegistry — unmanagedTable aggregation", () => {
|
|
75
|
+
test("rejects cross-feature tableName collisions at boot", () => {
|
|
76
|
+
// Two features can't share the same physical tableName — migrate-runner
|
|
77
|
+
// would race two CREATE TABLE statements. Boot-validator catches it.
|
|
78
|
+
const featA = defineFeature("a", (r) => {
|
|
79
|
+
r.unmanagedTable(probeMeta, { reason: "first" });
|
|
80
|
+
});
|
|
81
|
+
const featB = defineFeature("b", (r) => {
|
|
82
|
+
r.unmanagedTable(probeMeta, { reason: "second" });
|
|
83
|
+
});
|
|
84
|
+
expect(() => createRegistry([featA, featB])).toThrow(
|
|
85
|
+
/Unmanaged-table "ut_probe" registered by both feature "a" and "b"/,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("two features with distinct tableNames register cleanly", () => {
|
|
90
|
+
const featA = defineFeature("a", (r) => {
|
|
91
|
+
r.unmanagedTable(probeMeta, { reason: "first" });
|
|
92
|
+
});
|
|
93
|
+
const featB = defineFeature("b", (r) => {
|
|
94
|
+
r.unmanagedTable(probeMetaTwo, { reason: "second" });
|
|
95
|
+
});
|
|
96
|
+
expect(() => createRegistry([featA, featB])).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ZodType, z } from "zod";
|
|
2
|
+
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
2
3
|
import { toTableName } from "../db/table-builder";
|
|
3
4
|
import { LifecycleHookTypes } from "./constants";
|
|
4
5
|
import type { QueryHandlerDefinition, WriteHandlerDefinition } from "./define-handler";
|
|
@@ -61,6 +62,7 @@ import type {
|
|
|
61
62
|
TreeActionDef,
|
|
62
63
|
TreeActionsHandle,
|
|
63
64
|
TreeChildrenSubscribe,
|
|
65
|
+
UnmanagedTableEntry,
|
|
64
66
|
ValidationHookFn,
|
|
65
67
|
WriteHandlerDef,
|
|
66
68
|
WriteHandlerFn,
|
|
@@ -136,6 +138,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
136
138
|
const projections: Record<string, ProjectionDefinition> = {};
|
|
137
139
|
const multiStreamProjections: Record<string, MultiStreamProjectionDefinition> = {};
|
|
138
140
|
const rawTables: Record<string, RawTableEntry> = {};
|
|
141
|
+
const unmanagedTables: Record<string, UnmanagedTableEntry> = {};
|
|
139
142
|
const authClaimsHooks: AuthClaimsFn[] = [];
|
|
140
143
|
const claimKeys: Record<string, ClaimKeyDefinition> = {};
|
|
141
144
|
const screens: Record<string, ScreenDefinition> = {};
|
|
@@ -791,6 +794,38 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
791
794
|
};
|
|
792
795
|
},
|
|
793
796
|
|
|
797
|
+
unmanagedTable(meta: EntityTableMeta, options: RawTableOptions): void {
|
|
798
|
+
// Name comes from the meta itself — apps already give the table a
|
|
799
|
+
// name when calling defineUnmanagedTable, no need to repeat it.
|
|
800
|
+
const tableName = meta.tableName;
|
|
801
|
+
if (!isKebabSegment(tableName.replace(/_/g, "-"))) {
|
|
802
|
+
// EntityTableMeta uses snake_case for tableName (matches Postgres
|
|
803
|
+
// convention); we just guard against truly broken input.
|
|
804
|
+
throw new Error(
|
|
805
|
+
`[Feature ${name}] Unmanaged-table name "${tableName}" must be a ` +
|
|
806
|
+
`valid identifier (lowercase letters, digits, underscores; start with a letter).`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
if (unmanagedTables[tableName]) {
|
|
810
|
+
throw new Error(
|
|
811
|
+
`[Feature ${name}] r.unmanagedTable("${tableName}") already registered. ` +
|
|
812
|
+
`Unmanaged-table names must be unique per feature.`,
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
|
|
816
|
+
throw new Error(
|
|
817
|
+
`[Feature ${name}] r.unmanagedTable("${tableName}"): options.reason must be a ` +
|
|
818
|
+
`non-empty string. The reason justifies the audit-trail bypass — ` +
|
|
819
|
+
`if you can't write one, declare data via r.entity() instead.`,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
unmanagedTables[tableName] = {
|
|
823
|
+
name: tableName,
|
|
824
|
+
meta,
|
|
825
|
+
reason: options.reason,
|
|
826
|
+
};
|
|
827
|
+
},
|
|
828
|
+
|
|
794
829
|
claimKey<T extends ClaimKeyType>(
|
|
795
830
|
shortName: string,
|
|
796
831
|
options: { readonly type: T },
|
|
@@ -905,6 +940,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
905
940
|
workspaces,
|
|
906
941
|
httpRoutes,
|
|
907
942
|
rawTables,
|
|
943
|
+
unmanagedTables,
|
|
908
944
|
...(treeActions !== undefined && { treeActions }),
|
|
909
945
|
...(treeProvider !== undefined && { treeProvider }),
|
|
910
946
|
...(envSchema !== undefined && { envSchema }),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CallExpression, Node } from "ts-morph";
|
|
2
2
|
import { SyntaxKind } from "ts-morph";
|
|
3
|
+
import { isPlainObject } from "../../../utils/is-plain-object";
|
|
3
4
|
import type { ParseError } from "../parse";
|
|
4
5
|
|
|
5
6
|
export type ExtractOutput<TPattern> =
|
|
@@ -107,9 +108,7 @@ export function readDataLiteralNode(node: Node): unknown {
|
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
export
|
|
111
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
-
}
|
|
111
|
+
export { isPlainObject } from "../../../utils/is-plain-object";
|
|
113
112
|
|
|
114
113
|
export function readPropertyKey(propAssign: import("ts-morph").PropertyAssignment): string {
|
|
115
114
|
const nameNode = propAssign.getNameNode();
|
package/src/engine/index.ts
CHANGED
|
@@ -154,7 +154,7 @@ export { resolveConfigOrParam } from "./resolve-config-or-param";
|
|
|
154
154
|
export { runsInLane } from "./run-in";
|
|
155
155
|
export type { StepListOutcome } from "./run-pipeline";
|
|
156
156
|
export { runPipeline, runStepList } from "./run-pipeline";
|
|
157
|
-
export { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
|
|
157
|
+
export { buildInsertSchema, buildUpdateSchema, fieldToZod } from "./schema-builder";
|
|
158
158
|
export type { TransitionGraph } from "./state-machine";
|
|
159
159
|
export { defineTransitions, guardTransition } from "./state-machine";
|
|
160
160
|
export {
|
package/src/engine/registry.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
TranslationKeys,
|
|
41
41
|
TreeActionDef,
|
|
42
42
|
TreeChildrenSubscribe,
|
|
43
|
+
UnmanagedTableDef,
|
|
43
44
|
WorkspaceDefinition,
|
|
44
45
|
WriteHandlerDef,
|
|
45
46
|
} from "./types";
|
|
@@ -169,6 +170,10 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
169
170
|
// enforced at ingest below (collisions would race two CREATE TABLE
|
|
170
171
|
// statements at the same physical name and break boot).
|
|
171
172
|
const rawTableMap = new Map<string, RawTableDef>();
|
|
173
|
+
// Unmanaged tables — declared via r.unmanagedTable() (EntityTableMeta).
|
|
174
|
+
// Cousin of rawTables: same uniqueness-by-tableName invariant, different
|
|
175
|
+
// storage shape (post-drizzle migrate-runner consumes EntityTableMeta).
|
|
176
|
+
const unmanagedTableMap = new Map<string, UnmanagedTableDef>();
|
|
172
177
|
// Auth-claims hooks — tagged with featureName so the login resolver can
|
|
173
178
|
// auto-prefix each hook's returned keys with "<feature>:".
|
|
174
179
|
const authClaimsHooks: AuthClaimsHookDef[] = [];
|
|
@@ -543,6 +548,20 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
543
548
|
rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
|
|
544
549
|
}
|
|
545
550
|
|
|
551
|
+
// Unmanaged tables — same cross-feature uniqueness invariant as rawTables.
|
|
552
|
+
// Two features registering the same physical tableName would race two
|
|
553
|
+
// CREATE TABLE statements via migrate-runner.
|
|
554
|
+
for (const [umName, umDef] of Object.entries(feature.unmanagedTables ?? {})) {
|
|
555
|
+
const existing = unmanagedTableMap.get(umName);
|
|
556
|
+
if (existing) {
|
|
557
|
+
throw new Error(
|
|
558
|
+
`Unmanaged-table "${umName}" registered by both feature "${existing.featureName}" and ` +
|
|
559
|
+
`"${feature.name}". Pick a feature-prefixed tableName to disambiguate.`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
unmanagedTableMap.set(umName, { ...umDef, featureName: feature.name });
|
|
563
|
+
}
|
|
564
|
+
|
|
546
565
|
// Claim keys: aggregated by qualified name. Two features cannot collide
|
|
547
566
|
// here (qualified by feature name), but we still guard for explicit
|
|
548
567
|
// correctness — the only way to hit this is a hand-built FeatureDefinition
|
|
@@ -18,7 +18,7 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function fieldToZod(field: FieldDefinition, currencies: readonly string[]): z.ZodTypeAny {
|
|
21
|
+
export function fieldToZod(field: FieldDefinition, currencies: readonly string[]): z.ZodTypeAny {
|
|
22
22
|
switch (field.type) {
|
|
23
23
|
case "text": {
|
|
24
24
|
let schema = z.string();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ZodType, z } from "zod";
|
|
2
|
+
import type { EntityTableMeta } from "../../db/entity-table-meta";
|
|
2
3
|
|
|
3
4
|
// PgTable historically came from drizzle-orm/pg-core; the native dialect
|
|
4
5
|
// no longer carries drizzle internal class types. Every caller really
|
|
@@ -148,6 +149,23 @@ export type RawTableDef = RawTableEntry & {
|
|
|
148
149
|
readonly featureName: string;
|
|
149
150
|
};
|
|
150
151
|
|
|
152
|
+
// --- Unmanaged tables (declared by features via r.unmanagedTable()) ---
|
|
153
|
+
|
|
154
|
+
/** Per-feature unmanaged-table registration. `meta` is the
|
|
155
|
+
* `EntityTableMeta` (framework-native shape used by `migrate-runner`).
|
|
156
|
+
* The `reason` justifies the bypass at the registration site — same
|
|
157
|
+
* contract as `r.rawTable`. */
|
|
158
|
+
export type UnmanagedTableEntry = {
|
|
159
|
+
readonly name: string;
|
|
160
|
+
readonly meta: EntityTableMeta;
|
|
161
|
+
readonly reason: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Registry-aggregated unmanaged-table — adds the owning feature name. */
|
|
165
|
+
export type UnmanagedTableDef = UnmanagedTableEntry & {
|
|
166
|
+
readonly featureName: string;
|
|
167
|
+
};
|
|
168
|
+
|
|
151
169
|
// --- Feature Definition (output of defineFeature) ---
|
|
152
170
|
|
|
153
171
|
export type FeatureDefinition = {
|
|
@@ -274,6 +292,12 @@ export type FeatureDefinition = {
|
|
|
274
292
|
// system. Keyed by feature-local short name. The registry attaches
|
|
275
293
|
// featureName on aggregation, lifting RawTableEntry → RawTableDef.
|
|
276
294
|
readonly rawTables: Readonly<Record<string, RawTableEntry>>;
|
|
295
|
+
// Unmanaged tables declared via r.unmanagedTable() — `EntityTableMeta`
|
|
296
|
+
// shape (post-drizzle), keyed by feature-local table-name. Cousin of
|
|
297
|
+
// rawTables: same bypass-justification contract, different storage
|
|
298
|
+
// shape. `kumiko schema generate` aggregates these alongside
|
|
299
|
+
// r.entity()-derived metas to build the full schema.
|
|
300
|
+
readonly unmanagedTables: Readonly<Record<string, UnmanagedTableEntry>>;
|
|
277
301
|
// Optional Zod-schema for env-vars this feature reads at runtime.
|
|
278
302
|
// Declared via `r.envSchema(z.object({...}))`. `composeEnvSchema` reads
|
|
279
303
|
// this to build one app-wide schema for boot-validation + dry-run
|
|
@@ -582,6 +606,22 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
582
606
|
// declare data via `r.entity()` instead.
|
|
583
607
|
rawTable(name: string, table: PgTable, options: RawTableOptions): void;
|
|
584
608
|
|
|
609
|
+
// Declare an "unmanaged" framework-native table (post-drizzle).
|
|
610
|
+
// EntityTableMeta carries the same column-shape that r.entity() builds,
|
|
611
|
+
// minus the audit-trail + base-columns scaffolding — used for read-side
|
|
612
|
+
// projections of event-streams (delivery-attempts, job-run-logs) where
|
|
613
|
+
// r.entity()'s aggregate-lifecycle assumptions don't fit.
|
|
614
|
+
//
|
|
615
|
+
// The `meta` argument is the result of `defineUnmanagedTable(...)` from
|
|
616
|
+
// `@cosmicdrift/kumiko-framework/db`. Reason-justification + audit-trail
|
|
617
|
+
// contract identical to `r.rawTable`.
|
|
618
|
+
//
|
|
619
|
+
// Why this exists separate from `r.rawTable`: rawTable carries a Drizzle
|
|
620
|
+
// `PgTable` (legacy), unmanagedTable carries the new `EntityTableMeta`
|
|
621
|
+
// shape that `migrate-runner` consumes. After the full drizzle-cut they
|
|
622
|
+
// will likely merge; for now they coexist.
|
|
623
|
+
unmanagedTable(meta: EntityTableMeta, options: RawTableOptions): void;
|
|
624
|
+
|
|
585
625
|
// Register the tree-actions schema for this feature — a map of
|
|
586
626
|
// action-name → action-definition (with optional typed args). At-most-
|
|
587
627
|
// one call per feature.
|