@cosmicdrift/kumiko-framework 0.156.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.156.2",
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.2",
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.
@@ -1,4 +1,11 @@
1
- import type { EditFieldSpec, FeatureDefinition, RowAction, ToolbarAction } from "../types";
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}" (entityEdit) field "${normalized.field}" ` +
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}" (entityEdit) field "${normalized.field}" ` +
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
- for (const col of screen.columns) {
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 (screen.type !== "entityEdit") continue;
92
- for (const section of screen.layout.sections) {
93
- if (isExtensionEditSection(section)) continue;
94
- for (const fieldSpec of section.fields) {
95
- validateEditFieldNoFunctions(feature.name, screen.id, fieldSpec);
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
  }
@@ -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
- if (state.navs[definition.id]) {
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
- // Reject kebab-drift at registration-time so the stack trace points at
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 { createEntity, createNumberField, createTextField } from "../engine";
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: createNumberField({ required: true }),
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
+ });
@@ -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
+ }