@cosmicdrift/kumiko-framework 0.156.1 → 0.156.3
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 +2 -2
- package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
- package/src/db/entity-table-meta.ts +2 -1
- package/src/engine/__tests__/boot-validator.test.ts +96 -0
- package/src/engine/__tests__/build-app-schema.test.ts +21 -0
- package/src/engine/boot-validator/action-wiring.ts +67 -17
- package/src/engine/build-app-schema.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +25 -27
- package/src/files/__tests__/file-ref-entity.test.ts +8 -0
- package/src/files/file-ref-entity.ts +2 -2
- package/src/search/__tests__/reindex-entity.integration.test.ts +121 -0
- package/src/search/index.ts +6 -0
- package/src/search/reindex-entity.ts +148 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.156.
|
|
3
|
+
"version": "0.156.3",
|
|
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>",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"zod": "^4.4.3"
|
|
194
194
|
},
|
|
195
195
|
"devDependencies": {
|
|
196
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.156.
|
|
196
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.156.3",
|
|
197
197
|
"bun-types": "^1.3.13",
|
|
198
198
|
"pino-pretty": "^13.1.3"
|
|
199
199
|
},
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// #1210: buildEntityTableMeta() hardcoded source: "managed", so unmanaged
|
|
2
|
+
// direct-write stores (read_user_sessions, read_api_tokens, mail sync/seen
|
|
3
|
+
// cursors) were misclassified as rebuildable event-sourced projections — a
|
|
4
|
+
// destructive column change would DROP+rebuild-from-events tables that have
|
|
5
|
+
// no events, wiping live data. The options.source escape hatch must produce
|
|
6
|
+
// a byte-identical column/piiSubjectFields shape (only source differs), so
|
|
7
|
+
// the migration diff for an existing table stays empty when flipping it.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { createEntity, createTextField } from "../../engine";
|
|
11
|
+
import { buildEntityTableMeta } from "../entity-table-meta";
|
|
12
|
+
import { diffSnapshots, snapshotFromMetas } from "../migrate-generator";
|
|
13
|
+
|
|
14
|
+
const entity = createEntity({
|
|
15
|
+
table: "source-probe",
|
|
16
|
+
fields: {
|
|
17
|
+
userId: createTextField({ required: true }),
|
|
18
|
+
ip: createTextField({ userOwned: { ownerField: "userId" } }),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("buildEntityTableMeta — options.source (#1210)", () => {
|
|
23
|
+
test("defaults to managed when omitted", () => {
|
|
24
|
+
expect(buildEntityTableMeta("source-probe", entity).source).toBe("managed");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("options.source: 'unmanaged' changes only source, columns + piiSubjectFields stay identical", () => {
|
|
28
|
+
const managed = buildEntityTableMeta("source-probe", entity);
|
|
29
|
+
const unmanaged = buildEntityTableMeta("source-probe", entity, { source: "unmanaged" });
|
|
30
|
+
|
|
31
|
+
expect(managed.source).toBe("managed");
|
|
32
|
+
expect(unmanaged.source).toBe("unmanaged");
|
|
33
|
+
expect(unmanaged.columns).toEqual(managed.columns);
|
|
34
|
+
expect(unmanaged.indexes).toEqual(managed.indexes);
|
|
35
|
+
expect(unmanaged.piiSubjectFields).toEqual(managed.piiSubjectFields);
|
|
36
|
+
expect(unmanaged.piiSubjectFields).toEqual(["ip"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("flipping an existing table's meta to unmanaged produces an empty migration diff", () => {
|
|
40
|
+
const prevSnapshot = snapshotFromMetas([buildEntityTableMeta("source-probe", entity)]);
|
|
41
|
+
const nextSnapshot = snapshotFromMetas([
|
|
42
|
+
buildEntityTableMeta("source-probe", entity, { source: "unmanaged" }),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const diff = diffSnapshots(prevSnapshot, nextSnapshot);
|
|
46
|
+
expect(diff.newTables).toEqual([]);
|
|
47
|
+
expect(diff.droppedTables).toEqual([]);
|
|
48
|
+
expect(diff.changedTables).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -296,6 +296,7 @@ export function resolveTableName(
|
|
|
296
296
|
export type BuildEntityTableMetaOptions = {
|
|
297
297
|
readonly featureName?: string;
|
|
298
298
|
readonly relations?: EntityRelations;
|
|
299
|
+
readonly source?: "managed" | "unmanaged";
|
|
299
300
|
};
|
|
300
301
|
|
|
301
302
|
export function buildEntityTableMeta(
|
|
@@ -415,7 +416,7 @@ export function buildEntityTableMeta(
|
|
|
415
416
|
tableName,
|
|
416
417
|
columns,
|
|
417
418
|
indexes,
|
|
418
|
-
source: "managed",
|
|
419
|
+
source: options?.source ?? "managed",
|
|
419
420
|
...(piiSubjectFields.length > 0 && { piiSubjectFields }),
|
|
420
421
|
};
|
|
421
422
|
}
|
|
@@ -1297,6 +1297,102 @@ describe("boot-validator", () => {
|
|
|
1297
1297
|
});
|
|
1298
1298
|
});
|
|
1299
1299
|
|
|
1300
|
+
// --- function-renderer check on the non-entityEdit/entityList screen
|
|
1301
|
+
// types that share the same EditLayout/ListColumnSpec shapes
|
|
1302
|
+
// (action-wiring.ts's validateEditLayoutNoFunctions/validateColumnsNoFunctions) ---
|
|
1303
|
+
describe("function-renderer check on other EditLayout/columns screens", () => {
|
|
1304
|
+
test("actionForm field renderer as a function → Throw", () => {
|
|
1305
|
+
const features = [
|
|
1306
|
+
defineFeature("shop", (r) => {
|
|
1307
|
+
r.screen({
|
|
1308
|
+
id: "restock",
|
|
1309
|
+
type: "actionForm",
|
|
1310
|
+
handler: "shop:write:restock",
|
|
1311
|
+
fields: { qty: { type: "number" } } as never,
|
|
1312
|
+
layout: {
|
|
1313
|
+
sections: [
|
|
1314
|
+
{ fields: [{ field: "qty", renderer: (v: unknown) => String(v) }] as never },
|
|
1315
|
+
],
|
|
1316
|
+
},
|
|
1317
|
+
});
|
|
1318
|
+
}),
|
|
1319
|
+
];
|
|
1320
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1321
|
+
/\(actionForm\) field "qty" renderer is a function/,
|
|
1322
|
+
);
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
test("configEdit field renderer as a function → Throw", () => {
|
|
1326
|
+
const features = [
|
|
1327
|
+
defineFeature("shop", (r) => {
|
|
1328
|
+
r.config({
|
|
1329
|
+
keys: { "site-name": createTenantConfig("text", { default: "" }) },
|
|
1330
|
+
});
|
|
1331
|
+
r.screen({
|
|
1332
|
+
id: "settings",
|
|
1333
|
+
type: "configEdit",
|
|
1334
|
+
scope: "tenant",
|
|
1335
|
+
configKeys: { siteName: "shop:config:site-name" },
|
|
1336
|
+
fields: { siteName: { type: "text" } } as never,
|
|
1337
|
+
layout: {
|
|
1338
|
+
sections: [
|
|
1339
|
+
{
|
|
1340
|
+
fields: [{ field: "siteName", renderer: (v: unknown) => String(v) }] as never,
|
|
1341
|
+
},
|
|
1342
|
+
],
|
|
1343
|
+
},
|
|
1344
|
+
});
|
|
1345
|
+
}),
|
|
1346
|
+
];
|
|
1347
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1348
|
+
/\(configEdit\) field "siteName" renderer is a function/,
|
|
1349
|
+
);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
test("projectionDetail field renderer as a function → Throw", () => {
|
|
1353
|
+
const features = [
|
|
1354
|
+
defineFeature("shop", (r) => {
|
|
1355
|
+
r.screen({
|
|
1356
|
+
id: "order-detail",
|
|
1357
|
+
type: "projectionDetail",
|
|
1358
|
+
query: "shop:query:order-detail",
|
|
1359
|
+
layout: {
|
|
1360
|
+
sections: [
|
|
1361
|
+
{ fields: [{ field: "total", renderer: (v: unknown) => String(v) }] as never },
|
|
1362
|
+
],
|
|
1363
|
+
},
|
|
1364
|
+
});
|
|
1365
|
+
}),
|
|
1366
|
+
];
|
|
1367
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1368
|
+
/\(projectionDetail\) field "total" renderer is a function/,
|
|
1369
|
+
);
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
test("dashboard list-panel column renderer as a function → Throw", () => {
|
|
1373
|
+
const features = [
|
|
1374
|
+
defineFeature("shop", (r) => {
|
|
1375
|
+
r.screen({
|
|
1376
|
+
id: "overview",
|
|
1377
|
+
type: "dashboard",
|
|
1378
|
+
panels: [
|
|
1379
|
+
{
|
|
1380
|
+
kind: "list",
|
|
1381
|
+
id: "recent-orders",
|
|
1382
|
+
label: "Recent Orders",
|
|
1383
|
+
query: "shop:query:recent-orders",
|
|
1384
|
+
columns: [{ field: "total", renderer: (v: unknown) => String(v) }] as never,
|
|
1385
|
+
},
|
|
1386
|
+
],
|
|
1387
|
+
});
|
|
1388
|
+
}),
|
|
1389
|
+
];
|
|
1390
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1391
|
+
/\(dashboard-list\) column "total" renderer is a function/,
|
|
1392
|
+
);
|
|
1393
|
+
});
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1300
1396
|
// --- entityList virtual (labeled) columns ---
|
|
1301
1397
|
// A column whose `field` is not an entity field is allowed IF it carries a
|
|
1302
1398
|
// `label` — a presentational column drawn by a columnRenderer component (e.g.
|
|
@@ -216,6 +216,27 @@ describe("buildAppSchema", () => {
|
|
|
216
216
|
expect(fields["name"]?.["filterable"]).toBeUndefined();
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
test("searchable kommt ins Client-Schema (steuert den EntityList-Default, #1194)", () => {
|
|
220
|
+
const entity = {
|
|
221
|
+
fields: {
|
|
222
|
+
title: { type: "text", searchable: true },
|
|
223
|
+
name: { type: "text" },
|
|
224
|
+
},
|
|
225
|
+
} as unknown as EntityDefinition;
|
|
226
|
+
const f = defineFeature("ent", (r) => {
|
|
227
|
+
r.entity("thing", entity);
|
|
228
|
+
});
|
|
229
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
230
|
+
const fields = (
|
|
231
|
+
app.features[0]?.entities["thing"] as unknown as {
|
|
232
|
+
fields: Record<string, Record<string, unknown>>;
|
|
233
|
+
}
|
|
234
|
+
).fields;
|
|
235
|
+
expect(fields["title"]?.["searchable"]).toBe(true);
|
|
236
|
+
// Fields without searchable don't carry the key (no false-litter).
|
|
237
|
+
expect(fields["name"]?.["searchable"]).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
|
|
219
240
|
test("AppSchema ist via JSON.stringify roundtrip-sicher", () => {
|
|
220
241
|
// Echter Smoke-Test des Vertrags — wenn jemand in den project-
|
|
221
242
|
// Helper eine Function reinschmuggelt, würde das hier brennen.
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
EditFieldSpec,
|
|
3
|
+
EditLayout,
|
|
4
|
+
FeatureDefinition,
|
|
5
|
+
ListColumnSpec,
|
|
6
|
+
RowAction,
|
|
7
|
+
ToolbarAction,
|
|
8
|
+
} from "../types";
|
|
2
9
|
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
3
10
|
|
|
4
11
|
const FUNCTION_DROPPED_HINT =
|
|
@@ -55,6 +62,7 @@ const EDIT_FIELD_FUNCTION_FIELDS = ["visible", "readOnly", "required"] as const;
|
|
|
55
62
|
function validateEditFieldNoFunctions(
|
|
56
63
|
featureName: string,
|
|
57
64
|
screenId: string,
|
|
65
|
+
screenType: string,
|
|
58
66
|
fieldSpec: EditFieldSpec,
|
|
59
67
|
): void {
|
|
60
68
|
const normalized = normalizeEditField(fieldSpec);
|
|
@@ -62,37 +70,79 @@ function validateEditFieldNoFunctions(
|
|
|
62
70
|
for (const key of EDIT_FIELD_FUNCTION_FIELDS) {
|
|
63
71
|
throwIfFunction(
|
|
64
72
|
record[key],
|
|
65
|
-
`[Feature ${featureName}] Screen "${screenId}" (
|
|
73
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) field "${normalized.field}" ` +
|
|
66
74
|
`${key} is a function — ${FUNCTION_DROPPED_HINT} Use a FieldCondition ` +
|
|
67
75
|
`(boolean or { field, eq }/{ field, ne }) instead.`,
|
|
68
76
|
);
|
|
69
77
|
}
|
|
70
78
|
throwIfFunction(
|
|
71
79
|
normalized.renderer,
|
|
72
|
-
`[Feature ${featureName}] Screen "${screenId}" (
|
|
80
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) field "${normalized.field}" ` +
|
|
73
81
|
`renderer is a function — ${FUNCTION_DROPPED_HINT} Use a FormatSpec ({ format: "..." }) instead.`,
|
|
74
82
|
);
|
|
75
83
|
}
|
|
76
84
|
|
|
85
|
+
function validateColumnsNoFunctions(
|
|
86
|
+
featureName: string,
|
|
87
|
+
screenId: string,
|
|
88
|
+
screenType: string,
|
|
89
|
+
columns: readonly ListColumnSpec[],
|
|
90
|
+
): void {
|
|
91
|
+
for (const col of columns) {
|
|
92
|
+
const normalized = normalizeListColumn(col);
|
|
93
|
+
throwIfFunction(
|
|
94
|
+
normalized.renderer,
|
|
95
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) column ` +
|
|
96
|
+
`"${normalized.field}" renderer is a function — ${FUNCTION_DROPPED_HINT} ` +
|
|
97
|
+
`Use a FormatSpec ({ format: "..." }) instead.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateEditLayoutNoFunctions(
|
|
103
|
+
featureName: string,
|
|
104
|
+
screenId: string,
|
|
105
|
+
screenType: string,
|
|
106
|
+
layout: EditLayout,
|
|
107
|
+
): void {
|
|
108
|
+
for (const section of layout.sections) {
|
|
109
|
+
if (isExtensionEditSection(section)) continue;
|
|
110
|
+
for (const fieldSpec of section.fields) {
|
|
111
|
+
validateEditFieldNoFunctions(featureName, screenId, screenType, fieldSpec);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Screen types that carry an EditLayout (entityEdit's layout shape, reused
|
|
117
|
+
// verbatim by actionForm/configEdit/projectionDetail) vs. ones that carry
|
|
118
|
+
// ListColumnSpec[] (entityList/projectionList, plus dashboard "list" panels)
|
|
119
|
+
// both funnel through the same two checks below — a function literal in
|
|
120
|
+
// either shape is dropped by JSON.stringify regardless of which screen type
|
|
121
|
+
// hosts it.
|
|
77
122
|
export function validateFieldWiring(feature: FeatureDefinition): void {
|
|
78
123
|
for (const screen of Object.values(feature.screens)) {
|
|
79
124
|
if (screen.type === "entityList" || screen.type === "projectionList") {
|
|
80
|
-
|
|
81
|
-
const normalized = normalizeListColumn(col);
|
|
82
|
-
throwIfFunction(
|
|
83
|
-
normalized.renderer,
|
|
84
|
-
`[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) column ` +
|
|
85
|
-
`"${normalized.field}" renderer is a function — ${FUNCTION_DROPPED_HINT} ` +
|
|
86
|
-
`Use a FormatSpec ({ format: "..." }) instead.`,
|
|
87
|
-
);
|
|
88
|
-
}
|
|
125
|
+
validateColumnsNoFunctions(feature.name, screen.id, screen.type, screen.columns);
|
|
89
126
|
continue;
|
|
90
127
|
}
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
128
|
+
if (
|
|
129
|
+
screen.type === "entityEdit" ||
|
|
130
|
+
screen.type === "actionForm" ||
|
|
131
|
+
screen.type === "configEdit" ||
|
|
132
|
+
screen.type === "projectionDetail"
|
|
133
|
+
) {
|
|
134
|
+
validateEditLayoutNoFunctions(feature.name, screen.id, screen.type, screen.layout);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (screen.type === "dashboard") {
|
|
138
|
+
for (const panel of screen.panels) {
|
|
139
|
+
if (panel.kind !== "list") continue;
|
|
140
|
+
validateColumnsNoFunctions(
|
|
141
|
+
feature.name,
|
|
142
|
+
`${screen.id}:${panel.id}`,
|
|
143
|
+
"dashboard-list",
|
|
144
|
+
panel.columns,
|
|
145
|
+
);
|
|
96
146
|
}
|
|
97
147
|
}
|
|
98
148
|
}
|
|
@@ -348,6 +348,7 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
|
|
|
348
348
|
// filterable steuert die Faceted-Filter-Dropdowns im Renderer (select/
|
|
349
349
|
// boolean) — muss daher ins Client-Schema.
|
|
350
350
|
if (typeof def["filterable"] === "boolean") out["filterable"] = def["filterable"];
|
|
351
|
+
if (typeof def["searchable"] === "boolean") out["searchable"] = def["searchable"];
|
|
351
352
|
if (isLiteral(def["default"])) out["default"] = def["default"];
|
|
352
353
|
// Select: options-Liste ist plain JSON, durchschicken.
|
|
353
354
|
if (Array.isArray(def["options"])) out["options"] = def["options"];
|
|
@@ -89,6 +89,28 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
89
89
|
state: FeatureBuilderState,
|
|
90
90
|
name: TName,
|
|
91
91
|
) {
|
|
92
|
+
// Shared by r.nav() and r.screen()'s inline nav-sugar path — one place
|
|
93
|
+
// for id-validation + collision checks so future registration-time
|
|
94
|
+
// checks (e.g. parent-format, reserved ids) apply to both call sites
|
|
95
|
+
// instead of only the standalone r.nav() one.
|
|
96
|
+
function registerNav(navDefinition: NavDefinition): void {
|
|
97
|
+
if (!isKebabSegment(navDefinition.id)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`[Feature ${name}] Nav id "${navDefinition.id}" must be kebab-case ` +
|
|
100
|
+
`(lowercase letters, digits, dashes; start with a letter). ` +
|
|
101
|
+
`Got "${navDefinition.id}" — try "${toKebab(navDefinition.id).replace(/_/g, "-")}".`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (state.navs[navDefinition.id]) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`[Feature ${name}] Nav entry "${navDefinition.id}" already registered. ` +
|
|
107
|
+
`Nav ids must be unique per feature — remove the standalone ` +
|
|
108
|
+
`r.nav("${navDefinition.id}", ...) call or the screen's inline nav.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
state.navs[navDefinition.id] = navDefinition;
|
|
112
|
+
}
|
|
113
|
+
|
|
92
114
|
return {
|
|
93
115
|
hook(
|
|
94
116
|
type: LifecycleHookType | "validation",
|
|
@@ -334,42 +356,18 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
334
356
|
// Sugar for the common "one nav entry pointing at this screen"
|
|
335
357
|
// case — synthesizes id/screen from the screen's own id. Beyond
|
|
336
358
|
// label/icon/parent/order, declare a standalone r.nav() instead.
|
|
337
|
-
|
|
338
|
-
throw new Error(
|
|
339
|
-
`[Feature ${name}] Nav entry "${definition.id}" already registered. ` +
|
|
340
|
-
`Nav ids must be unique per feature — remove the standalone ` +
|
|
341
|
-
`r.nav("${definition.id}", ...) call or the screen's inline nav.`,
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
const navDefinition: NavDefinition = {
|
|
359
|
+
registerNav({
|
|
345
360
|
id: definition.id,
|
|
346
361
|
label: definition.nav.label,
|
|
347
362
|
icon: definition.nav.icon,
|
|
348
363
|
parent: definition.nav.parent,
|
|
349
364
|
order: definition.nav.order,
|
|
350
365
|
screen: `${name}:screen:${definition.id}`,
|
|
351
|
-
};
|
|
352
|
-
state.navs[definition.id] = navDefinition;
|
|
366
|
+
});
|
|
353
367
|
}
|
|
354
368
|
},
|
|
355
369
|
nav(definition: NavDefinition): void {
|
|
356
|
-
|
|
357
|
-
// the feature file, not at registry-boot. Same guard pattern as
|
|
358
|
-
// r.projection / r.multiStreamProjection / r.screen.
|
|
359
|
-
if (!isKebabSegment(definition.id)) {
|
|
360
|
-
throw new Error(
|
|
361
|
-
`[Feature ${name}] Nav id "${definition.id}" must be kebab-case ` +
|
|
362
|
-
`(lowercase letters, digits, dashes; start with a letter). ` +
|
|
363
|
-
`Got "${definition.id}" — try "${toKebab(definition.id).replace(/_/g, "-")}".`,
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
if (state.navs[definition.id]) {
|
|
367
|
-
throw new Error(
|
|
368
|
-
`[Feature ${name}] Nav entry "${definition.id}" already registered. ` +
|
|
369
|
-
`Nav ids must be unique per feature.`,
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
state.navs[definition.id] = definition;
|
|
370
|
+
registerNav(definition);
|
|
373
371
|
},
|
|
374
372
|
workspace(definition: WorkspaceDefinition): void {
|
|
375
373
|
// Same kebab guard as r.screen / r.nav so authoring-time mistakes
|
|
@@ -32,3 +32,11 @@ describe("fileRefEntity base-column drift", () => {
|
|
|
32
32
|
expect(insertedById[0]?.notNull).toBe(false);
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
describe("fileRefEntity — DDL (#1205 regression)", () => {
|
|
37
|
+
test("size column is bigint, not double precision", () => {
|
|
38
|
+
const size = col("size");
|
|
39
|
+
expect(size).toHaveLength(1);
|
|
40
|
+
expect(size[0]?.pgType).toBe("bigint");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createBigIntField, createEntity, createTextField } from "../engine";
|
|
2
2
|
|
|
3
3
|
// fileRef — das File-Metadata-Entity. Ganz normales ES-Entity: Upload/Delete
|
|
4
4
|
// laufen über den Standard-Executor (file-routes.ts), die Tabelle `file_refs`
|
|
@@ -25,7 +25,7 @@ export const fileRefEntity = createEntity({
|
|
|
25
25
|
storageKey: createTextField({ required: true }),
|
|
26
26
|
fileName: createTextField({ required: true, pii: true }),
|
|
27
27
|
mimeType: createTextField({ required: true }),
|
|
28
|
-
size:
|
|
28
|
+
size: createBigIntField({ required: true }),
|
|
29
29
|
entityType: createTextField(),
|
|
30
30
|
entityId: createTextField(),
|
|
31
31
|
fieldName: createTextField(),
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// reindexEntity — Integration Test
|
|
2
|
+
// Proves: rows written before a search consumer ever indexed them (the
|
|
3
|
+
// #1206 scenario — searchable:true added retroactively) become findable
|
|
4
|
+
// after a backfill run, soft-deleted rows stay excluded, and dryRun writes
|
|
5
|
+
// nothing.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
buildEntityTable,
|
|
10
|
+
createEventStoreExecutor,
|
|
11
|
+
createTenantDb,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
|
+
import { reindexEntity } from "@cosmicdrift/kumiko-framework/search";
|
|
16
|
+
import {
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
TestUsers,
|
|
20
|
+
unsafeCreateEntityTable,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
|
|
23
|
+
const widgetEntity = createEntity({
|
|
24
|
+
table: "read_reindex_widgets",
|
|
25
|
+
softDelete: true,
|
|
26
|
+
fields: {
|
|
27
|
+
name: createTextField({ required: true, searchable: true }),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const widgetTable = buildEntityTable("widget", widgetEntity);
|
|
32
|
+
|
|
33
|
+
const widgetFeature = defineFeature("reindex-test", (r) => {
|
|
34
|
+
r.entity("widget", widgetEntity);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
let stack: TestStack;
|
|
38
|
+
const admin = TestUsers.admin;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
stack = await setupTestStack({ features: [widgetFeature] });
|
|
42
|
+
await unsafeCreateEntityTable(stack.db, widgetEntity);
|
|
43
|
+
await createEventsTable(stack.db);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await stack.cleanup();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function seedExecutor() {
|
|
51
|
+
return createEventStoreExecutor(widgetTable, widgetEntity, { entityName: "widget" });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tenantDb() {
|
|
55
|
+
return createTenantDb(stack.db, admin.tenantId, "system");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("reindexEntity", () => {
|
|
59
|
+
test("indexes rows that were never drained through the search consumer", async () => {
|
|
60
|
+
const executor = seedExecutor();
|
|
61
|
+
const created = await executor.create({ name: "Backfillable Widget" }, admin, tenantDb());
|
|
62
|
+
if (!created.isSuccess) throw new Error("seed failed");
|
|
63
|
+
|
|
64
|
+
// No stack.eventDispatcher.runOnce() call — simulates rows that existed
|
|
65
|
+
// before search indexing ever ran for this entity.
|
|
66
|
+
const preResults = await stack.search.search(admin.tenantId, "backfillable", {
|
|
67
|
+
filterType: "widget",
|
|
68
|
+
});
|
|
69
|
+
expect(preResults).toHaveLength(0);
|
|
70
|
+
|
|
71
|
+
const result = await reindexEntity(
|
|
72
|
+
stack.db,
|
|
73
|
+
stack.registry,
|
|
74
|
+
stack.search,
|
|
75
|
+
"widget",
|
|
76
|
+
admin.tenantId,
|
|
77
|
+
);
|
|
78
|
+
expect(result.indexedRows).toBe(1);
|
|
79
|
+
expect(result.failures).toHaveLength(0);
|
|
80
|
+
|
|
81
|
+
const postResults = await stack.search.search(admin.tenantId, "backfillable", {
|
|
82
|
+
filterType: "widget",
|
|
83
|
+
});
|
|
84
|
+
expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("skips soft-deleted rows", async () => {
|
|
88
|
+
const executor = seedExecutor();
|
|
89
|
+
const created = await executor.create({ name: "Erased Widget" }, admin, tenantDb());
|
|
90
|
+
if (!created.isSuccess) throw new Error("seed failed");
|
|
91
|
+
const deleted = await executor.delete({ id: created.data.id }, admin, tenantDb());
|
|
92
|
+
if (!deleted.isSuccess) throw new Error("delete failed");
|
|
93
|
+
|
|
94
|
+
await reindexEntity(stack.db, stack.registry, stack.search, "widget", admin.tenantId);
|
|
95
|
+
|
|
96
|
+
const postResults = await stack.search.search(admin.tenantId, "erased", {
|
|
97
|
+
filterType: "widget",
|
|
98
|
+
});
|
|
99
|
+
expect(postResults.some((r) => r.entityId === created.data.id)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("dryRun scans without writing to the index", async () => {
|
|
103
|
+
const executor = seedExecutor();
|
|
104
|
+
await executor.create({ name: "DryRun Widget" }, admin, tenantDb());
|
|
105
|
+
|
|
106
|
+
const result = await reindexEntity(
|
|
107
|
+
stack.db,
|
|
108
|
+
stack.registry,
|
|
109
|
+
stack.search,
|
|
110
|
+
"widget",
|
|
111
|
+
admin.tenantId,
|
|
112
|
+
{ dryRun: true },
|
|
113
|
+
);
|
|
114
|
+
expect(result.indexedRows).toBeGreaterThan(0);
|
|
115
|
+
|
|
116
|
+
const postResults = await stack.search.search(admin.tenantId, "dryrun", {
|
|
117
|
+
filterType: "widget",
|
|
118
|
+
});
|
|
119
|
+
expect(postResults).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
package/src/search/index.ts
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// von SearchAdapter-Types. Apps die Meilisearch nicht nutzen, ziehen den
|
|
4
4
|
// Client-Code nicht mit rein.
|
|
5
5
|
export { createInMemorySearchAdapter } from "./in-memory-adapter";
|
|
6
|
+
export type {
|
|
7
|
+
ReindexEntityFailure,
|
|
8
|
+
ReindexEntityOptions,
|
|
9
|
+
ReindexEntityResult,
|
|
10
|
+
} from "./reindex-entity";
|
|
11
|
+
export { reindexEntity } from "./reindex-entity";
|
|
6
12
|
export type {
|
|
7
13
|
SearchAdapter,
|
|
8
14
|
SearchAdapterConfig,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// One-time backfill for entities that only got `searchable: true` after rows
|
|
2
|
+
// already existed (#1206). buildSearchDocument() only runs on the write path
|
|
3
|
+
// (createSearchEventConsumer, system-hooks.ts) — pre-existing rows never
|
|
4
|
+
// pass through it, so they stay unfindable until their next write. This
|
|
5
|
+
// re-derives a SearchDocument straight from the read-table row for every
|
|
6
|
+
// existing row and indexes it, same as a live write would.
|
|
7
|
+
|
|
8
|
+
import type { DbRunner } from "../db/connection";
|
|
9
|
+
import { resolveTableName } from "../db/entity-table-meta";
|
|
10
|
+
import { executeRawQuery } from "../db/queries/raw-sql";
|
|
11
|
+
import type { Registry, TenantId } from "../engine/types";
|
|
12
|
+
import { buildSearchDocument } from "../pipeline/system-hooks";
|
|
13
|
+
import { toSnakeCase } from "../utils/case";
|
|
14
|
+
import type { SearchAdapter, SearchDocument } from "./types";
|
|
15
|
+
|
|
16
|
+
export type ReindexEntityFailure = {
|
|
17
|
+
readonly entityId: string;
|
|
18
|
+
readonly reason: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ReindexEntityResult = {
|
|
22
|
+
readonly scannedRows: number;
|
|
23
|
+
readonly indexedRows: number;
|
|
24
|
+
readonly failures: readonly ReindexEntityFailure[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ReindexEntityOptions = {
|
|
28
|
+
readonly batchSize?: number;
|
|
29
|
+
// Scan + build docs, write nothing.
|
|
30
|
+
readonly dryRun?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function quoteIdent(name: string): string {
|
|
34
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Read-table state, forward-mapped from entity.fields (fieldName →
|
|
38
|
+
// toSnakeCase(fieldName)). SELECT * + forward-map instead of building an
|
|
39
|
+
// explicit column list: some field types (files/images) emit no column at
|
|
40
|
+
// all, others (locatedTimestamp, money) emit companion columns under a
|
|
41
|
+
// different name — an explicit alias list breaks on those. Missing columns
|
|
42
|
+
// are just skipped, matching what a `.created` event payload would carry.
|
|
43
|
+
function rowToState(
|
|
44
|
+
row: Record<string, unknown>,
|
|
45
|
+
fieldNames: readonly string[],
|
|
46
|
+
): Record<string, unknown> {
|
|
47
|
+
const state: Record<string, unknown> = {};
|
|
48
|
+
for (const fieldName of fieldNames) {
|
|
49
|
+
const column = toSnakeCase(fieldName);
|
|
50
|
+
if (Object.hasOwn(row, column)) state[fieldName] = row[column];
|
|
51
|
+
}
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function reindexEntity(
|
|
56
|
+
db: DbRunner,
|
|
57
|
+
registry: Registry,
|
|
58
|
+
searchAdapter: SearchAdapter,
|
|
59
|
+
entityName: string,
|
|
60
|
+
tenantId: TenantId,
|
|
61
|
+
options: ReindexEntityOptions = {},
|
|
62
|
+
): Promise<ReindexEntityResult> {
|
|
63
|
+
const entity = registry.getEntity(entityName);
|
|
64
|
+
if (!entity) {
|
|
65
|
+
throw new Error(`reindexEntity: unknown entity "${entityName}"`);
|
|
66
|
+
}
|
|
67
|
+
const searchableFields = registry.getSearchableFields(entityName);
|
|
68
|
+
const extensions = registry.getSearchPayloadExtensions(entityName);
|
|
69
|
+
if (searchableFields.length === 0 && extensions.length === 0) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`reindexEntity: entity "${entityName}" has no searchable fields and no search-payload ` +
|
|
72
|
+
`extensions — nothing to index.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const batchSize = options.batchSize ?? 500;
|
|
77
|
+
const tableName = resolveTableName(entityName, entity, undefined);
|
|
78
|
+
const fieldNames = Object.keys(entity.fields);
|
|
79
|
+
// Soft-deleted rows already left the live index (the .deleted consumer
|
|
80
|
+
// calls searchAdapter.remove()) — resurrecting them here would make
|
|
81
|
+
// erased entities findable again.
|
|
82
|
+
const deletedFilter =
|
|
83
|
+
entity.softDelete === true ? `AND ${quoteIdent("is_deleted")} IS NOT TRUE` : "";
|
|
84
|
+
|
|
85
|
+
const result = { scannedRows: 0, indexedRows: 0, failures: [] as ReindexEntityFailure[] };
|
|
86
|
+
|
|
87
|
+
let offset = 0;
|
|
88
|
+
for (;;) {
|
|
89
|
+
// ponytail: LIMIT/OFFSET, not a keyset cursor — id can be uuid or
|
|
90
|
+
// serial depending on the entity, and a uniform cursor comparison
|
|
91
|
+
// across both types needs a text cast that breaks integer ordering.
|
|
92
|
+
// This is a one-time backfill over existing rows, not a live hot path;
|
|
93
|
+
// switch to keyset if it ever needs to run against a churning table.
|
|
94
|
+
const rows = await executeRawQuery<Record<string, unknown>>(
|
|
95
|
+
db,
|
|
96
|
+
`SELECT * FROM ${quoteIdent(tableName)}
|
|
97
|
+
WHERE ${quoteIdent("tenant_id")} = $1 ${deletedFilter}
|
|
98
|
+
ORDER BY ${quoteIdent("id")} ASC
|
|
99
|
+
LIMIT $2 OFFSET $3`,
|
|
100
|
+
[tenantId, batchSize, offset],
|
|
101
|
+
);
|
|
102
|
+
if (rows.length === 0) break;
|
|
103
|
+
|
|
104
|
+
const docs: Array<{ entityId: string; doc: SearchDocument }> = [];
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
result.scannedRows++;
|
|
107
|
+
const entityId = String(row["id"]);
|
|
108
|
+
try {
|
|
109
|
+
const state = rowToState(row, fieldNames);
|
|
110
|
+
const doc = await buildSearchDocument(entityName, entityId, state, registry);
|
|
111
|
+
if (doc) docs.push({ entityId, doc });
|
|
112
|
+
} catch (e) {
|
|
113
|
+
result.failures.push({ entityId, reason: e instanceof Error ? e.message : String(e) });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!options.dryRun && docs.length > 0) {
|
|
118
|
+
if (searchAdapter.indexBatch) {
|
|
119
|
+
try {
|
|
120
|
+
await searchAdapter.indexBatch(
|
|
121
|
+
tenantId,
|
|
122
|
+
docs.map((d) => d.doc),
|
|
123
|
+
);
|
|
124
|
+
result.indexedRows += docs.length;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
127
|
+
for (const { entityId } of docs) result.failures.push({ entityId, reason });
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
for (const { entityId, doc } of docs) {
|
|
131
|
+
try {
|
|
132
|
+
await searchAdapter.index(tenantId, doc);
|
|
133
|
+
result.indexedRows++;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
result.failures.push({ entityId, reason: e instanceof Error ? e.message : String(e) });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} else if (options.dryRun) {
|
|
140
|
+
result.indexedRows += docs.length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
offset += rows.length;
|
|
144
|
+
if (rows.length < batchSize) break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return result;
|
|
148
|
+
}
|