@cosmicdrift/kumiko-framework 0.225.0 → 0.227.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 +3 -3
- package/src/bun-db/query.ts +37 -1
- package/src/db/__tests__/migrate-generator.test.ts +173 -1
- package/src/db/event-store-executor-read.ts +115 -54
- package/src/db/migrate-generator.ts +111 -8
- package/src/db/render-ddl.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +435 -0
- package/src/engine/__tests__/build-app-schema.test.ts +215 -22
- package/src/engine/__tests__/multiselect-filter.integration.test.ts +141 -0
- package/src/engine/__tests__/screen.test.ts +68 -2
- package/src/engine/boot-validator/index.ts +4 -0
- package/src/engine/boot-validator/projection-list-screens.ts +2 -2
- package/src/engine/boot-validator/query-output-columns.ts +236 -0
- package/src/engine/boot-validator/screens.ts +31 -5
- package/src/engine/boot-validator/zod-shape.ts +51 -0
- package/src/engine/build-app-schema.ts +50 -13
- package/src/engine/feature-entity-handlers.ts +3 -1
- package/src/i18n/required-surface-keys.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.227.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.227.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.227.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
package/src/bun-db/query.ts
CHANGED
|
@@ -589,6 +589,28 @@ function buildWhereClause(
|
|
|
589
589
|
const conditions: string[] = [];
|
|
590
590
|
const values: unknown[] = [];
|
|
591
591
|
let idx = startIndex;
|
|
592
|
+
// multiSelect (and any other jsonb-array-of-scalars column) stores an
|
|
593
|
+
// array — a filter value is one option, not the whole array, so eq/ne/in
|
|
594
|
+
// must check array containment (`@>`) instead of scalar `=`/`<>`/`IN`
|
|
595
|
+
// against the jsonb column, which Postgres rejects outright (fw#2490).
|
|
596
|
+
// Mirrors event-store-executor-read.ts's applyFilter — keep in lock-step.
|
|
597
|
+
function jsonbContainsAny(col: string, candidates: readonly unknown[]): string {
|
|
598
|
+
if (candidates.length === 0) return "FALSE";
|
|
599
|
+
const parts: string[] = [];
|
|
600
|
+
for (const v of candidates) {
|
|
601
|
+
const p = prepareJsonbValue([v]);
|
|
602
|
+
// prepareJsonbValue never returns kind:"literal" (only prepareValue's
|
|
603
|
+
// isSqlExpression branch does) — narrow anyway, PreparedValue is a union.
|
|
604
|
+
if (p && p.kind === "param") {
|
|
605
|
+
parts.push(`${quoteIdent(col)} @> $${idx++}${p.sql}`);
|
|
606
|
+
values.push(p.bound);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return parts.length > 0 ? `(${parts.join(" OR ")})` : "FALSE";
|
|
610
|
+
}
|
|
611
|
+
function isJsonbScalar(value: unknown): boolean {
|
|
612
|
+
return value !== null && typeof value !== "object";
|
|
613
|
+
}
|
|
592
614
|
for (const [field, value] of Object.entries(where)) {
|
|
593
615
|
const col = info.columnOf(field);
|
|
594
616
|
const pgType = info.pgTypeOf(col);
|
|
@@ -597,6 +619,8 @@ function buildWhereClause(
|
|
|
597
619
|
} else if (Array.isArray(value)) {
|
|
598
620
|
if (value.length === 0) {
|
|
599
621
|
conditions.push("FALSE");
|
|
622
|
+
} else if (pgType === "jsonb") {
|
|
623
|
+
conditions.push(jsonbContainsAny(col, value));
|
|
600
624
|
} else {
|
|
601
625
|
const parts: string[] = [];
|
|
602
626
|
for (const v of value) {
|
|
@@ -626,6 +650,14 @@ function buildWhereClause(
|
|
|
626
650
|
for (const [opKey, opSym] of Object.entries(opMap)) {
|
|
627
651
|
const opVal = (value as Record<string, unknown>)[opKey];
|
|
628
652
|
if (opVal === undefined) continue;
|
|
653
|
+
if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
|
|
654
|
+
const p = prepareJsonbValue([opVal]);
|
|
655
|
+
if (p && p.kind === "param") {
|
|
656
|
+
conditions.push(`NOT (${quoteIdent(col)} @> $${idx++}${p.sql})`);
|
|
657
|
+
values.push(p.bound);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
629
661
|
const p = prepareValue(opVal, pgType);
|
|
630
662
|
if (p.kind === "literal") {
|
|
631
663
|
conditions.push(`${quoteIdent(col)} ${opSym} ${p.literal}`);
|
|
@@ -636,7 +668,9 @@ function buildWhereClause(
|
|
|
636
668
|
}
|
|
637
669
|
const inVal = (value as Record<string, unknown>)["in"];
|
|
638
670
|
if (Array.isArray(inVal)) {
|
|
639
|
-
if (
|
|
671
|
+
if (pgType === "jsonb") {
|
|
672
|
+
conditions.push(jsonbContainsAny(col, inVal));
|
|
673
|
+
} else if (inVal.length === 0) {
|
|
640
674
|
conditions.push("FALSE");
|
|
641
675
|
} else {
|
|
642
676
|
const parts: string[] = [];
|
|
@@ -652,6 +686,8 @@ function buildWhereClause(
|
|
|
652
686
|
conditions.push(`${quoteIdent(col)} IN (${parts.join(", ")})`);
|
|
653
687
|
}
|
|
654
688
|
}
|
|
689
|
+
} else if (pgType === "jsonb" && isJsonbScalar(value)) {
|
|
690
|
+
conditions.push(jsonbContainsAny(col, [value]));
|
|
655
691
|
} else {
|
|
656
692
|
const p = prepareValue(value, pgType);
|
|
657
693
|
if (p.kind === "literal") {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { EntityTableMeta } from "../entity-table-meta";
|
|
2
|
+
import type { EntityTableMeta, IndexMeta } from "../entity-table-meta";
|
|
3
3
|
import {
|
|
4
4
|
assertValidMigrationName,
|
|
5
5
|
diffSnapshots,
|
|
@@ -24,6 +24,19 @@ function meta(
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function metaWithIndexes(
|
|
28
|
+
tableName: string,
|
|
29
|
+
indexes: readonly IndexMeta[],
|
|
30
|
+
source: EntityTableMeta["source"] = "unmanaged",
|
|
31
|
+
): EntityTableMeta {
|
|
32
|
+
return {
|
|
33
|
+
tableName,
|
|
34
|
+
source,
|
|
35
|
+
indexes,
|
|
36
|
+
columns: [{ name: "id", pgType: "uuid", notNull: true, primaryKey: true }],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
describe("snapshotFromMetas", () => {
|
|
28
41
|
test("sorts tables by name for stable snapshots", () => {
|
|
29
42
|
const snap = snapshotFromMetas([meta("zebras"), meta("apples")]);
|
|
@@ -199,6 +212,165 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
|
|
|
199
212
|
});
|
|
200
213
|
});
|
|
201
214
|
|
|
215
|
+
describe("renderMigrationSql — changed index predicates (kumiko-framework#2492)", () => {
|
|
216
|
+
test("diffSnapshots surfaces a whereSql-only change as a changed table (not silently dropped)", () => {
|
|
217
|
+
// Regression for the exact offlot#65 scenario: an index keeps its name
|
|
218
|
+
// and columns, only its partial-index predicate widens (framework#2464
|
|
219
|
+
// adding the soft-delete guard). Before the fix diffOneTable returned
|
|
220
|
+
// null here — the table never reached changedTables at all.
|
|
221
|
+
const prev = snapshotFromMetas([
|
|
222
|
+
metaWithIndexes("read_users", [
|
|
223
|
+
{
|
|
224
|
+
name: "read_users_email_bidx",
|
|
225
|
+
columns: ["email_bidx"],
|
|
226
|
+
unique: true,
|
|
227
|
+
whereSql: '"email_bidx" IS NOT NULL',
|
|
228
|
+
},
|
|
229
|
+
]),
|
|
230
|
+
]);
|
|
231
|
+
const next = snapshotFromMetas([
|
|
232
|
+
metaWithIndexes("read_users", [
|
|
233
|
+
{
|
|
234
|
+
name: "read_users_email_bidx",
|
|
235
|
+
columns: ["email_bidx"],
|
|
236
|
+
unique: true,
|
|
237
|
+
whereSql: '"email_bidx" IS NOT NULL AND "is_deleted" = false',
|
|
238
|
+
},
|
|
239
|
+
]),
|
|
240
|
+
]);
|
|
241
|
+
const diff = diffSnapshots(prev, next);
|
|
242
|
+
expect(diff.changedTables).toHaveLength(1);
|
|
243
|
+
expect(diff.changedTables[0]?.changedIndexes).toEqual([
|
|
244
|
+
{
|
|
245
|
+
name: "read_users_email_bidx",
|
|
246
|
+
whereSqlChanged: {
|
|
247
|
+
from: '"email_bidx" IS NOT NULL',
|
|
248
|
+
to: '"email_bidx" IS NOT NULL AND "is_deleted" = false',
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("emits DROP INDEX + CREATE INDEX with the new predicate", () => {
|
|
255
|
+
const prev = snapshotFromMetas([
|
|
256
|
+
metaWithIndexes("read_users", [
|
|
257
|
+
{
|
|
258
|
+
name: "read_users_email_bidx",
|
|
259
|
+
columns: ["email_bidx"],
|
|
260
|
+
unique: true,
|
|
261
|
+
whereSql: '"email_bidx" IS NOT NULL',
|
|
262
|
+
},
|
|
263
|
+
]),
|
|
264
|
+
]);
|
|
265
|
+
const next = snapshotFromMetas([
|
|
266
|
+
metaWithIndexes("read_users", [
|
|
267
|
+
{
|
|
268
|
+
name: "read_users_email_bidx",
|
|
269
|
+
columns: ["email_bidx"],
|
|
270
|
+
unique: true,
|
|
271
|
+
whereSql: '"email_bidx" IS NOT NULL AND "is_deleted" = false',
|
|
272
|
+
},
|
|
273
|
+
]),
|
|
274
|
+
]);
|
|
275
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), {
|
|
276
|
+
name: "predicate",
|
|
277
|
+
sequenceNumber: 9,
|
|
278
|
+
});
|
|
279
|
+
expect(sql).toContain('DROP INDEX IF EXISTS "read_users_email_bidx";');
|
|
280
|
+
expect(sql).toContain(
|
|
281
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS "read_users_email_bidx" ON "read_users" ("email_bidx") WHERE "email_bidx" IS NOT NULL AND "is_deleted" = false;',
|
|
282
|
+
);
|
|
283
|
+
expect(sql).toContain("changed (where");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("emits DROP INDEX + CREATE INDEX when only the column list changes", () => {
|
|
287
|
+
const prev = snapshotFromMetas([
|
|
288
|
+
metaWithIndexes("read_orders", [{ name: "read_orders_status_idx", columns: ["status"] }]),
|
|
289
|
+
]);
|
|
290
|
+
const next = snapshotFromMetas([
|
|
291
|
+
metaWithIndexes("read_orders", [
|
|
292
|
+
{ name: "read_orders_status_idx", columns: ["status", "tenant_id"] },
|
|
293
|
+
]),
|
|
294
|
+
]);
|
|
295
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), { name: "cols", sequenceNumber: 10 });
|
|
296
|
+
expect(sql).toContain('DROP INDEX IF EXISTS "read_orders_status_idx";');
|
|
297
|
+
expect(sql).toContain(
|
|
298
|
+
'CREATE INDEX IF NOT EXISTS "read_orders_status_idx" ON "read_orders" ("status", "tenant_id");',
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("emits DROP INDEX + CREATE UNIQUE INDEX when uniqueness changes on an existing index", () => {
|
|
303
|
+
const prev = snapshotFromMetas([
|
|
304
|
+
metaWithIndexes("read_orders", [{ name: "read_orders_ref_idx", columns: ["ref"] }]),
|
|
305
|
+
]);
|
|
306
|
+
const next = snapshotFromMetas([
|
|
307
|
+
metaWithIndexes("read_orders", [
|
|
308
|
+
{ name: "read_orders_ref_idx", columns: ["ref"], unique: true },
|
|
309
|
+
]),
|
|
310
|
+
]);
|
|
311
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), {
|
|
312
|
+
name: "unique",
|
|
313
|
+
sequenceNumber: 11,
|
|
314
|
+
});
|
|
315
|
+
expect(sql).toContain('DROP INDEX IF EXISTS "read_orders_ref_idx";');
|
|
316
|
+
expect(sql).toContain('CREATE UNIQUE INDEX IF NOT EXISTS "read_orders_ref_idx"');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("needsManualWhere: keeps both DROP and CREATE commented out — never drops a live index with no executable replacement", () => {
|
|
320
|
+
const prev = snapshotFromMetas([
|
|
321
|
+
metaWithIndexes("read_widgets", [
|
|
322
|
+
{ name: "read_widgets_active_idx", columns: ["status"], whereSql: "status = 'active'" },
|
|
323
|
+
]),
|
|
324
|
+
]);
|
|
325
|
+
const next = snapshotFromMetas([
|
|
326
|
+
metaWithIndexes("read_widgets", [
|
|
327
|
+
{ name: "read_widgets_active_idx", columns: ["status"], needsManualWhere: true },
|
|
328
|
+
]),
|
|
329
|
+
]);
|
|
330
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), {
|
|
331
|
+
name: "manual",
|
|
332
|
+
sequenceNumber: 12,
|
|
333
|
+
});
|
|
334
|
+
expect(sql).toContain('-- (review) DROP INDEX IF EXISTS "read_widgets_active_idx";');
|
|
335
|
+
expect(sql).not.toMatch(/^DROP INDEX IF EXISTS "read_widgets_active_idx";$/m);
|
|
336
|
+
expect(sql).toContain('-- CREATE INDEX IF NOT EXISTS "read_widgets_active_idx"');
|
|
337
|
+
expect(sql).not.toMatch(/^CREATE INDEX IF NOT EXISTS "read_widgets_active_idx"/m);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("unchanged index (same name/columns/unique/whereSql) produces no diff at all", () => {
|
|
341
|
+
const idx: IndexMeta = {
|
|
342
|
+
name: "read_a_status_idx",
|
|
343
|
+
columns: ["status"],
|
|
344
|
+
whereSql: "status IS NOT NULL",
|
|
345
|
+
};
|
|
346
|
+
const prev = snapshotFromMetas([metaWithIndexes("read_a", [idx])]);
|
|
347
|
+
const next = snapshotFromMetas([metaWithIndexes("read_a", [{ ...idx }])]);
|
|
348
|
+
expect(diffSnapshots(prev, next).changedTables).toEqual([]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("new index with needsManualWhere is rendered commented-out, not silently as a bare CREATE INDEX (render-ddl consolidation)", () => {
|
|
352
|
+
// migrate-generator.ts used to carry its own renderIndex() copy that
|
|
353
|
+
// didn't check needsManualWhere — a new partial index with an
|
|
354
|
+
// unrenderable WHERE was emitted as a plain, uncommented CREATE INDEX
|
|
355
|
+
// with the predicate silently dropped. Now it shares render-ddl.ts's
|
|
356
|
+
// renderIndex, which comments the statement out for manual review.
|
|
357
|
+
const prev = snapshotFromMetas([meta("read_c", undefined, "unmanaged")]);
|
|
358
|
+
const next = snapshotFromMetas([
|
|
359
|
+
metaWithIndexes(
|
|
360
|
+
"read_c",
|
|
361
|
+
[{ name: "read_c_status_idx", columns: ["status"], needsManualWhere: true }],
|
|
362
|
+
"unmanaged",
|
|
363
|
+
),
|
|
364
|
+
]);
|
|
365
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), {
|
|
366
|
+
name: "newpartial",
|
|
367
|
+
sequenceNumber: 13,
|
|
368
|
+
});
|
|
369
|
+
expect(sql).toContain('-- CREATE INDEX IF NOT EXISTS "read_c_status_idx"');
|
|
370
|
+
expect(sql).not.toMatch(/^CREATE INDEX IF NOT EXISTS "read_c_status_idx"/m);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
202
374
|
describe("assertValidMigrationName", () => {
|
|
203
375
|
test("accepts alphanumeric hyphenated names", () => {
|
|
204
376
|
expect(() => assertValidMigrationName("add-user-table")).not.toThrow();
|
|
@@ -10,7 +10,7 @@ import { getStreamVersion } from "../event-store";
|
|
|
10
10
|
import { rehydrateCompoundTypes } from "./compound-types";
|
|
11
11
|
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
|
|
12
12
|
import type { EventStoreExecutor } from "./event-store-executor";
|
|
13
|
-
import { buildFilterWhere, type ExecutorContext } from "./event-store-executor-context";
|
|
13
|
+
import { buildFilterWhere, type ExecutorContext, type Table } from "./event-store-executor-context";
|
|
14
14
|
import { toSnakeCase } from "./table-builder";
|
|
15
15
|
|
|
16
16
|
// The two read verbs (list/detail) of the event-store-executor. Split out
|
|
@@ -91,6 +91,115 @@ function keysetBoundarySql(
|
|
|
91
91
|
: `(${sortCol} IS NULL OR ${beyond} OR ${tie})`;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
type ListFilter = {
|
|
95
|
+
readonly field: string;
|
|
96
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
97
|
+
readonly value: unknown;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// multiSelect stores its options as a jsonb array — a filter value is one
|
|
101
|
+
// option, not the whole array, so eq/ne/in must check array containment
|
|
102
|
+
// (`@>`) instead of scalar `=`/`<>`/`IN` against the jsonb column (fw#2490).
|
|
103
|
+
// lt/gt have no containment analogue; the boot-validator already blocks them
|
|
104
|
+
// for screen-declared filters (screen-filter-ops.ts EQUALITY_ONLY), but a
|
|
105
|
+
// client-supplied facet filter reaches here unvalidated, so treat it as
|
|
106
|
+
// unsatisfiable rather than emitting SQL Postgres would reject.
|
|
107
|
+
function applyMultiSelectFilter(
|
|
108
|
+
colSql: (field: string) => string,
|
|
109
|
+
whereSql: string[],
|
|
110
|
+
params: unknown[],
|
|
111
|
+
f: ListFilter,
|
|
112
|
+
): void {
|
|
113
|
+
if (f.op === "lt" || f.op === "gt") {
|
|
114
|
+
whereSql.push("FALSE");
|
|
115
|
+
// skip: lt/gt is unsatisfiable on a multiSelect column
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (f.op === "in") {
|
|
119
|
+
if (!Array.isArray(f.value) || f.value.length === 0) {
|
|
120
|
+
whereSql.push("FALSE");
|
|
121
|
+
// skip: empty/non-array `in` is unsatisfiable, mirrors buildFilterWhere's "in" short-circuit
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const parts = f.value.map((v) => {
|
|
125
|
+
params.push([v]);
|
|
126
|
+
return `${colSql(f.field)} @> $${params.length}::jsonb`;
|
|
127
|
+
});
|
|
128
|
+
whereSql.push(`(${parts.join(" OR ")})`);
|
|
129
|
+
// skip: containment condition already pushed — don't fall through to the eq/ne branch below
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Bind a JS array, not JSON.stringify(value) — postgres.js double-encodes
|
|
133
|
+
// a stringified array through an `::jsonb` cast, so `@>` would never match
|
|
134
|
+
// (see update-roles.ts:67-68). An array value means "contains all of
|
|
135
|
+
// these" (the natural `@>` reading).
|
|
136
|
+
params.push(Array.isArray(f.value) ? f.value : [f.value]);
|
|
137
|
+
const containment = `${colSql(f.field)} @> $${params.length}::jsonb`;
|
|
138
|
+
whereSql.push(f.op === "ne" ? `NOT (${containment})` : containment);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Falls through to the screen-filter WHERE builder shared with
|
|
142
|
+
// screen-declared filters — buildFilterWhere flattens the op into a
|
|
143
|
+
// WhereObject, which this then lowers to raw SQL fragments.
|
|
144
|
+
function applyScreenFilter(
|
|
145
|
+
table: Table,
|
|
146
|
+
colSql: (field: string) => string,
|
|
147
|
+
whereSql: string[],
|
|
148
|
+
params: unknown[],
|
|
149
|
+
f: ListFilter,
|
|
150
|
+
): void {
|
|
151
|
+
const screen = buildFilterWhere(f.field, f.op, f.value);
|
|
152
|
+
if (screen === null) {
|
|
153
|
+
whereSql.push("FALSE");
|
|
154
|
+
// skip: filter is unsatisfiable → emit FALSE, no params to bind
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
for (const [field, value] of Object.entries(screen)) {
|
|
158
|
+
// #2015: `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in bun-db/query.ts.
|
|
159
|
+
if (value === null) {
|
|
160
|
+
whereSql.push(`${colSql(field)} IS NULL`);
|
|
161
|
+
} else if (Array.isArray(value)) {
|
|
162
|
+
const placeholders = value.map((v) => {
|
|
163
|
+
params.push(v);
|
|
164
|
+
return `$${params.length}`;
|
|
165
|
+
});
|
|
166
|
+
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
167
|
+
} else if (typeof value === "object") {
|
|
168
|
+
const valueObj = value as Record<string, unknown>;
|
|
169
|
+
if (valueObj["ne"] === null && Object.keys(valueObj).length === 1) {
|
|
170
|
+
whereSql.push(`${colSql(field)} IS NOT NULL`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const opMap: Record<string, string> = {
|
|
174
|
+
gt: ">",
|
|
175
|
+
gte: ">=",
|
|
176
|
+
lt: "<",
|
|
177
|
+
lte: "<=",
|
|
178
|
+
ne: "<>",
|
|
179
|
+
};
|
|
180
|
+
for (const [opKey, opSym] of Object.entries(opMap)) {
|
|
181
|
+
if (!(opKey in value)) continue;
|
|
182
|
+
params.push((value as Record<string, unknown>)[opKey]);
|
|
183
|
+
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
// Blind-Index-OR-Rewrite (#818), lock-step mit buildWhereClause
|
|
187
|
+
// in bun-db/query.ts — Equality auf lookupable-Feldern matcht
|
|
188
|
+
// Klartext-Arm ODER HMAC-Arm.
|
|
189
|
+
const bidxKey = configuredBlindIndexKey();
|
|
190
|
+
if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
|
|
191
|
+
params.push(value, computeBlindIndex(bidxKey, value));
|
|
192
|
+
whereSql.push(
|
|
193
|
+
`(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`,
|
|
194
|
+
);
|
|
195
|
+
} else {
|
|
196
|
+
params.push(value);
|
|
197
|
+
whereSql.push(`${colSql(field)} = $${params.length}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
94
203
|
export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
|
|
95
204
|
const {
|
|
96
205
|
table,
|
|
@@ -203,65 +312,17 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
203
312
|
whereSql.push(shifted.sqlText);
|
|
204
313
|
for (const p of shifted.params) params.push(p);
|
|
205
314
|
}
|
|
206
|
-
const applyFilter = (f: {
|
|
207
|
-
readonly field: string;
|
|
208
|
-
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
209
|
-
readonly value: unknown;
|
|
210
|
-
}): void => {
|
|
315
|
+
const applyFilter = (f: ListFilter): void => {
|
|
211
316
|
if (table[f.field] === undefined) {
|
|
212
317
|
// skip: unknown field — not a real column, drop the filter (injection guard)
|
|
213
318
|
return;
|
|
214
319
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// skip: filter is unsatisfiable → emit FALSE, no params to bind
|
|
320
|
+
if (entity.fields[f.field]?.type === "multiSelect") {
|
|
321
|
+
applyMultiSelectFilter(colSql, whereSql, params, f);
|
|
322
|
+
// skip: multiSelect already handled — don't fall through to applyScreenFilter
|
|
219
323
|
return;
|
|
220
324
|
}
|
|
221
|
-
|
|
222
|
-
// #2015: `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in bun-db/query.ts.
|
|
223
|
-
if (value === null) {
|
|
224
|
-
whereSql.push(`${colSql(field)} IS NULL`);
|
|
225
|
-
} else if (Array.isArray(value)) {
|
|
226
|
-
const placeholders = value.map((v) => {
|
|
227
|
-
params.push(v);
|
|
228
|
-
return `$${params.length}`;
|
|
229
|
-
});
|
|
230
|
-
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
231
|
-
} else if (typeof value === "object") {
|
|
232
|
-
const valueObj = value as Record<string, unknown>;
|
|
233
|
-
if (valueObj["ne"] === null && Object.keys(valueObj).length === 1) {
|
|
234
|
-
whereSql.push(`${colSql(field)} IS NOT NULL`);
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
const opMap: Record<string, string> = {
|
|
238
|
-
gt: ">",
|
|
239
|
-
gte: ">=",
|
|
240
|
-
lt: "<",
|
|
241
|
-
lte: "<=",
|
|
242
|
-
ne: "<>",
|
|
243
|
-
};
|
|
244
|
-
for (const [opKey, opSym] of Object.entries(opMap)) {
|
|
245
|
-
if (!(opKey in value)) continue;
|
|
246
|
-
params.push((value as Record<string, unknown>)[opKey]);
|
|
247
|
-
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
248
|
-
}
|
|
249
|
-
} else {
|
|
250
|
-
// Blind-Index-OR-Rewrite (#818), lock-step mit buildWhereClause
|
|
251
|
-
// in bun-db/query.ts — Equality auf lookupable-Feldern matcht
|
|
252
|
-
// Klartext-Arm ODER HMAC-Arm.
|
|
253
|
-
const bidxKey = configuredBlindIndexKey();
|
|
254
|
-
if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
|
|
255
|
-
params.push(value, computeBlindIndex(bidxKey, value));
|
|
256
|
-
whereSql.push(
|
|
257
|
-
`(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`,
|
|
258
|
-
);
|
|
259
|
-
} else {
|
|
260
|
-
params.push(value);
|
|
261
|
-
whereSql.push(`${colSql(field)} = $${params.length}`);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
325
|
+
applyScreenFilter(table, colSql, whereSql, params, f);
|
|
265
326
|
};
|
|
266
327
|
if (payload.filter !== undefined) applyFilter(payload.filter);
|
|
267
328
|
if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
|
|
@@ -19,7 +19,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
19
19
|
|
|
20
20
|
import { compareByCodepoint } from "../utils";
|
|
21
21
|
import type { ColumnMeta, EntityTableMeta, IndexMeta } from "./entity-table-meta";
|
|
22
|
-
import { renderTableDdl } from "./render-ddl";
|
|
22
|
+
import { renderIndex, renderTableDdl } from "./render-ddl";
|
|
23
23
|
|
|
24
24
|
const SNAPSHOT_VERSION = 1 as const;
|
|
25
25
|
|
|
@@ -36,6 +36,14 @@ export type ColumnChange = {
|
|
|
36
36
|
readonly typeChanged?: { readonly from: string; readonly to: string };
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
export type IndexChange = {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly columnsChanged?: { readonly from: readonly string[]; readonly to: readonly string[] };
|
|
42
|
+
readonly uniqueChanged?: { readonly from: boolean; readonly to: boolean };
|
|
43
|
+
readonly whereSqlChanged?: { readonly from: string | undefined; readonly to: string | undefined };
|
|
44
|
+
readonly needsManualWhereChanged?: { readonly from: boolean; readonly to: boolean };
|
|
45
|
+
};
|
|
46
|
+
|
|
39
47
|
export type TableDiff = {
|
|
40
48
|
readonly tableName: string;
|
|
41
49
|
readonly newColumns: readonly ColumnMeta[];
|
|
@@ -43,6 +51,10 @@ export type TableDiff = {
|
|
|
43
51
|
readonly changedColumns: readonly ColumnChange[];
|
|
44
52
|
readonly newIndexes: readonly IndexMeta[];
|
|
45
53
|
readonly droppedIndexes: readonly string[];
|
|
54
|
+
// Same-named index whose shape (columns/unique/whereSql/needsManualWhere)
|
|
55
|
+
// changed — recreated via DROP+CREATE rather than silently absorbed into
|
|
56
|
+
// the new snapshot with no emitted DDL (kumiko-framework#2492).
|
|
57
|
+
readonly changedIndexes: readonly IndexChange[];
|
|
46
58
|
// Full target meta — carried so the renderer can emit DROP+CREATE for a
|
|
47
59
|
// managed projection whose change cannot apply in-place (see
|
|
48
60
|
// managedChangeRequiresRecreate). Source-discriminator reached via nextMeta.source.
|
|
@@ -94,6 +106,10 @@ function indexMetaKey(idx: IndexMeta): string {
|
|
|
94
106
|
return idx.name;
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
function indexColumnsEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
110
|
+
return a.length === b.length && a.every((col, i) => col === b[i]);
|
|
111
|
+
}
|
|
112
|
+
|
|
97
113
|
function columnsByName(meta: EntityTableMeta): Map<string, ColumnMeta> {
|
|
98
114
|
const m = new Map<string, ColumnMeta>();
|
|
99
115
|
for (const c of meta.columns) m.set(c.name, c);
|
|
@@ -148,8 +164,45 @@ function diffOneTable(prev: EntityTableMeta, next: EntityTableMeta): TableDiff |
|
|
|
148
164
|
const nextIdx = indexesByName(next);
|
|
149
165
|
const newIndexes: IndexMeta[] = [];
|
|
150
166
|
const droppedIndexes: string[] = [];
|
|
167
|
+
const changedIndexes: IndexChange[] = [];
|
|
151
168
|
for (const [name, idx] of nextIdx) {
|
|
152
|
-
|
|
169
|
+
const prevI = prevIdx.get(name);
|
|
170
|
+
if (!prevI) {
|
|
171
|
+
newIndexes.push(idx);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const change: IndexChange = { name };
|
|
175
|
+
if (!indexColumnsEqual(prevI.columns, idx.columns)) {
|
|
176
|
+
Object.assign(change, {
|
|
177
|
+
columnsChanged: { from: prevI.columns, to: idx.columns },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if ((prevI.unique ?? false) !== (idx.unique ?? false)) {
|
|
181
|
+
Object.assign(change, {
|
|
182
|
+
uniqueChanged: { from: prevI.unique ?? false, to: idx.unique ?? false },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (prevI.whereSql !== idx.whereSql) {
|
|
186
|
+
Object.assign(change, {
|
|
187
|
+
whereSqlChanged: { from: prevI.whereSql, to: idx.whereSql },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if ((prevI.needsManualWhere ?? false) !== (idx.needsManualWhere ?? false)) {
|
|
191
|
+
Object.assign(change, {
|
|
192
|
+
needsManualWhereChanged: {
|
|
193
|
+
from: prevI.needsManualWhere ?? false,
|
|
194
|
+
to: idx.needsManualWhere ?? false,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (
|
|
199
|
+
change.columnsChanged ||
|
|
200
|
+
change.uniqueChanged ||
|
|
201
|
+
change.whereSqlChanged ||
|
|
202
|
+
change.needsManualWhereChanged
|
|
203
|
+
) {
|
|
204
|
+
changedIndexes.push(change);
|
|
205
|
+
}
|
|
153
206
|
}
|
|
154
207
|
for (const name of prevIdx.keys()) {
|
|
155
208
|
if (!nextIdx.has(name)) droppedIndexes.push(name);
|
|
@@ -160,7 +213,8 @@ function diffOneTable(prev: EntityTableMeta, next: EntityTableMeta): TableDiff |
|
|
|
160
213
|
droppedColumns.length === 0 &&
|
|
161
214
|
changedColumns.length === 0 &&
|
|
162
215
|
newIndexes.length === 0 &&
|
|
163
|
-
droppedIndexes.length === 0
|
|
216
|
+
droppedIndexes.length === 0 &&
|
|
217
|
+
changedIndexes.length === 0;
|
|
164
218
|
if (isEmpty) return null;
|
|
165
219
|
return {
|
|
166
220
|
tableName: prev.tableName,
|
|
@@ -169,6 +223,7 @@ function diffOneTable(prev: EntityTableMeta, next: EntityTableMeta): TableDiff |
|
|
|
169
223
|
changedColumns,
|
|
170
224
|
newIndexes,
|
|
171
225
|
droppedIndexes,
|
|
226
|
+
changedIndexes,
|
|
172
227
|
nextMeta: next,
|
|
173
228
|
};
|
|
174
229
|
}
|
|
@@ -305,11 +360,38 @@ function renderColumnChange(tableName: string, change: ColumnChange): readonly s
|
|
|
305
360
|
return out;
|
|
306
361
|
}
|
|
307
362
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
return
|
|
363
|
+
// Collapses whitespace so a multi-line whereSql (e.g. a two-condition
|
|
364
|
+
// drizzle sql`…` predicate) can't inject a newline into this `--` comment
|
|
365
|
+
// line and escape into the executable body of the generated migration.
|
|
366
|
+
function toSingleLine(text: string): string {
|
|
367
|
+
return text.replace(/\s+/g, " ").trim();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function describeIndexChange(change: IndexChange): string {
|
|
371
|
+
const parts: string[] = [];
|
|
372
|
+
if (change.columnsChanged) {
|
|
373
|
+
parts.push(
|
|
374
|
+
`columns (${change.columnsChanged.from.join(", ")}) → (${change.columnsChanged.to.join(", ")})`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (change.uniqueChanged) {
|
|
378
|
+
parts.push(`unique ${change.uniqueChanged.from} → ${change.uniqueChanged.to}`);
|
|
379
|
+
}
|
|
380
|
+
if (change.whereSqlChanged) {
|
|
381
|
+
const from =
|
|
382
|
+
change.whereSqlChanged.from !== undefined
|
|
383
|
+
? toSingleLine(change.whereSqlChanged.from)
|
|
384
|
+
: "<none>";
|
|
385
|
+
const to =
|
|
386
|
+
change.whereSqlChanged.to !== undefined ? toSingleLine(change.whereSqlChanged.to) : "<none>";
|
|
387
|
+
parts.push(`where ${from} → ${to}`);
|
|
388
|
+
}
|
|
389
|
+
if (change.needsManualWhereChanged) {
|
|
390
|
+
parts.push(
|
|
391
|
+
`needs-manual-where ${change.needsManualWhereChanged.from} → ${change.needsManualWhereChanged.to}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return parts.join("; ");
|
|
313
395
|
}
|
|
314
396
|
|
|
315
397
|
// Render the diff as a SQL-file content with header-comment + grouped
|
|
@@ -366,6 +448,27 @@ export function renderMigrationSql(
|
|
|
366
448
|
for (const idx of td.newIndexes) {
|
|
367
449
|
lines.push(renderIndex(td.tableName, idx));
|
|
368
450
|
}
|
|
451
|
+
for (const idxChange of td.changedIndexes) {
|
|
452
|
+
const nextIdxMeta = td.nextMeta.indexes.find((i) => i.name === idxChange.name);
|
|
453
|
+
if (!nextIdxMeta) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
`Index "${idxChange.name}" changed but is missing from the next table meta — generator bug in diffOneTable.`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
lines.push(
|
|
459
|
+
`-- index ${quoteIdent(idxChange.name)} changed (${describeIndexChange(idxChange)}) — recreated`,
|
|
460
|
+
);
|
|
461
|
+
// needsManualWhere renders CREATE as a commented WARN (renderIndex),
|
|
462
|
+
// since the generator can't express the new WHERE — the DROP has to
|
|
463
|
+
// stay commented too, or the live index gets dropped with no
|
|
464
|
+
// executable replacement (kumiko-framework#2492 review).
|
|
465
|
+
lines.push(
|
|
466
|
+
nextIdxMeta.needsManualWhere === true
|
|
467
|
+
? `-- (review) DROP INDEX IF EXISTS ${quoteIdent(idxChange.name)};`
|
|
468
|
+
: `DROP INDEX IF EXISTS ${quoteIdent(idxChange.name)};`,
|
|
469
|
+
);
|
|
470
|
+
lines.push(renderIndex(td.tableName, nextIdxMeta));
|
|
471
|
+
}
|
|
369
472
|
for (const name of td.droppedIndexes) {
|
|
370
473
|
lines.push(`-- (review) DROP INDEX IF EXISTS ${quoteIdent(name)};`);
|
|
371
474
|
}
|
package/src/db/render-ddl.ts
CHANGED
|
@@ -23,7 +23,7 @@ function renderColumn(col: ColumnMeta): string {
|
|
|
23
23
|
return parts.join(" ");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function renderIndex(tableName: string, idx: IndexMeta): string {
|
|
26
|
+
export function renderIndex(tableName: string, idx: IndexMeta): string {
|
|
27
27
|
const kind = idx.unique === true ? "UNIQUE INDEX" : "INDEX";
|
|
28
28
|
const colList = idx.columns.map(quoteIdent).join(", ");
|
|
29
29
|
if (idx.needsManualWhere === true) {
|