@cosmicdrift/kumiko-framework 0.156.2 → 0.157.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 +2 -2
- package/src/__tests__/{raw-table.integration.test.ts → store-table.integration.test.ts} +10 -10
- package/src/api/__tests__/batch.integration.test.ts +68 -0
- package/src/db/__tests__/collect-table-metas.test.ts +6 -6
- package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
- package/src/db/__tests__/feature-table-sources.test.ts +7 -7
- package/src/db/collect-table-metas.ts +4 -4
- package/src/db/entity-table-meta.ts +5 -4
- package/src/db/feature-table-sources.ts +3 -3
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +96 -0
- package/src/engine/__tests__/{raw-table.test.ts → store-table.test.ts} +59 -42
- package/src/engine/boot-validator/action-wiring.ts +67 -17
- package/src/engine/define-feature.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/extractors/index.ts +1 -1
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/parse.ts +3 -3
- package/src/engine/feature-builder-state.ts +3 -3
- package/src/engine/feature-ui-extensions.ts +54 -33
- package/src/engine/registry-facade.ts +3 -3
- package/src/engine/registry-ingest.ts +6 -6
- package/src/engine/registry-state.ts +3 -3
- package/src/engine/types/feature.ts +17 -17
- package/src/engine/types/index.ts +3 -3
- package/src/files/__tests__/file-ref-entity.test.ts +8 -0
- package/src/files/file-ref-entity.ts +2 -2
- package/src/migrations/pending-rebuilds.ts +1 -1
- 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/src/stack/push-entity-projection-tables.ts +2 -2
|
@@ -23,7 +23,7 @@ export function populateFeatureCore(state: RegistryState, feature: FeatureDefini
|
|
|
23
23
|
if (clash?.kind === "raw") {
|
|
24
24
|
throw new Error(
|
|
25
25
|
`Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
|
|
26
|
-
`collides with r.
|
|
26
|
+
`collides with r.storeTable("${physical}") (feature "${clash.featureName}"). ` +
|
|
27
27
|
`Pick a different tableName — both would emit CREATE TABLE "${physical}".`,
|
|
28
28
|
);
|
|
29
29
|
}
|
|
@@ -249,7 +249,7 @@ export function populateMetricsAndSecrets(state: RegistryState, feature: Feature
|
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
// Explicit + multi-stream projections (source-entity indexed) +
|
|
252
|
+
// Explicit + multi-stream projections (source-entity indexed) + store tables +
|
|
253
253
|
// unmanaged tables (both cross-feature-uniqueness-by-physical-name guarded).
|
|
254
254
|
export function populateProjectionsAndTables(
|
|
255
255
|
state: RegistryState,
|
|
@@ -300,15 +300,15 @@ export function populateProjectionsAndTables(
|
|
|
300
300
|
state.multiStreamProjectionFeatureMap.set(qualified, feature.name);
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
//
|
|
303
|
+
// Store tables: aggregated by feature-local short name (unprefixed —
|
|
304
304
|
// these bypass the qualified-name namespace because they have no
|
|
305
305
|
// event-stream binding to disambiguate). Reject cross-feature
|
|
306
306
|
// duplicates at boot so the dev-server doesn't race two CREATE TABLE
|
|
307
307
|
// statements that target the same physical table name. Two features
|
|
308
308
|
// registering the same physical tableName would also race two CREATE
|
|
309
309
|
// TABLE statements via migrate-runner.
|
|
310
|
-
for (const [rawName, rawDef] of Object.entries(feature.
|
|
311
|
-
const existing = state.
|
|
310
|
+
for (const [rawName, rawDef] of Object.entries(feature.storeTables ?? {})) {
|
|
311
|
+
const existing = state.storeTableMap.get(rawName);
|
|
312
312
|
if (existing) {
|
|
313
313
|
throw new Error(
|
|
314
314
|
`Raw-table "${rawName}" registered by both feature "${existing.featureName}" and ` +
|
|
@@ -337,7 +337,7 @@ export function populateProjectionsAndTables(
|
|
|
337
337
|
owner: rawName,
|
|
338
338
|
featureName: feature.name,
|
|
339
339
|
});
|
|
340
|
-
state.
|
|
340
|
+
state.storeTableMap.set(rawName, { ...rawDef, featureName: feature.name });
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
343
|
|
|
@@ -29,7 +29,6 @@ import type {
|
|
|
29
29
|
PreSaveHookFn,
|
|
30
30
|
ProjectionDefinition,
|
|
31
31
|
QueryHandlerDef,
|
|
32
|
-
RawTableDef,
|
|
33
32
|
ReferenceDataDef,
|
|
34
33
|
RegistrarExtensionDef,
|
|
35
34
|
RegistrarExtensionRegistration,
|
|
@@ -37,6 +36,7 @@ import type {
|
|
|
37
36
|
ScreenDefinition,
|
|
38
37
|
SearchPayloadContributorFn,
|
|
39
38
|
SecretKeyDefinition,
|
|
39
|
+
StoreTableDef,
|
|
40
40
|
TreeActionDef,
|
|
41
41
|
WorkspaceDefinition,
|
|
42
42
|
WriteHandlerDef,
|
|
@@ -199,7 +199,7 @@ export type RegistryState = {
|
|
|
199
199
|
projectionsBySource: Map<string, ProjectionDefinition[]>;
|
|
200
200
|
multiStreamProjectionMap: Map<string, MultiStreamProjectionDefinition>;
|
|
201
201
|
multiStreamProjectionFeatureMap: Map<string, string>;
|
|
202
|
-
|
|
202
|
+
storeTableMap: Map<string, StoreTableDef>;
|
|
203
203
|
physicalTableOwners: Map<string, { kind: "entity" | "raw"; owner: string; featureName: string }>;
|
|
204
204
|
authClaimsHooks: AuthClaimsHookDef[];
|
|
205
205
|
claimKeyMap: Map<string, ClaimKeyDefinition>;
|
|
@@ -260,7 +260,7 @@ export function createInitialState(): RegistryState {
|
|
|
260
260
|
projectionsBySource: new Map(),
|
|
261
261
|
multiStreamProjectionMap: new Map(),
|
|
262
262
|
multiStreamProjectionFeatureMap: new Map(),
|
|
263
|
-
|
|
263
|
+
storeTableMap: new Map(),
|
|
264
264
|
physicalTableOwners: new Map(),
|
|
265
265
|
authClaimsHooks: [],
|
|
266
266
|
claimKeyMap: new Map(),
|
|
@@ -125,18 +125,18 @@ export type SecretKeyHandle = {
|
|
|
125
125
|
readonly name: string;
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
-
// ---
|
|
128
|
+
// --- Store tables (declared by features via r.storeTable()) ---
|
|
129
129
|
// Post-drizzle-cut: unified with the former r.unmanagedTable() — both
|
|
130
130
|
// carried the same reason/audit contract, differing only in whether the
|
|
131
|
-
// table value was a legacy Drizzle PgTable (
|
|
131
|
+
// table value was a legacy Drizzle PgTable (storeTable) or the framework-
|
|
132
132
|
// native EntityTableMeta (unmanagedTable, consumed by migrate-runner).
|
|
133
|
-
// EntityTableMeta was the forward-compatible shape, so
|
|
133
|
+
// EntityTableMeta was the forward-compatible shape, so storeTable adopted it.
|
|
134
134
|
|
|
135
|
-
/** Options accepted by `r.
|
|
135
|
+
/** Options accepted by `r.storeTable()`. The `reason` is required so the
|
|
136
136
|
* bypass leaves an audit trail at the registration site — reviewers can
|
|
137
137
|
* judge legitimacy without spelunking into history, and a future cleanup
|
|
138
138
|
* pass can find candidates for migration to `r.entity()`. */
|
|
139
|
-
export type
|
|
139
|
+
export type StoreTableOptions = {
|
|
140
140
|
/** Why this table needs to bypass the event-sourcing system. Examples:
|
|
141
141
|
* "imported from pre-ES system, read-only", "external Stripe webhook
|
|
142
142
|
* payload cache, write-only by webhook handler", "denormalised
|
|
@@ -149,22 +149,22 @@ export type RawTableOptions = {
|
|
|
149
149
|
readonly piiEncryptedOnWrite?: true;
|
|
150
150
|
};
|
|
151
151
|
|
|
152
|
-
/** Per-feature
|
|
152
|
+
/** Per-feature store-table registration. `meta` is the `EntityTableMeta`
|
|
153
153
|
* (framework-native shape used by `migrate-runner`). Carries the
|
|
154
154
|
* bypass-justification reason but knows nothing about the owning
|
|
155
155
|
* feature — that's added when the registry aggregates entries
|
|
156
|
-
* cross-feature into `
|
|
157
|
-
export type
|
|
156
|
+
* cross-feature into `StoreTableDef`. */
|
|
157
|
+
export type StoreTableEntry = {
|
|
158
158
|
readonly name: string;
|
|
159
159
|
readonly meta: EntityTableMeta;
|
|
160
160
|
readonly reason: string;
|
|
161
161
|
readonly piiEncryptedOnWrite?: true;
|
|
162
162
|
};
|
|
163
163
|
|
|
164
|
-
/** Registry-aggregated
|
|
165
|
-
* the owning feature name. This is what `Registry.
|
|
164
|
+
/** Registry-aggregated store-table — the per-feature `StoreTableEntry` plus
|
|
165
|
+
* the owning feature name. This is what `Registry.getAllStoreTables()`
|
|
166
166
|
* exposes to readers (dev-server, ops UIs). */
|
|
167
|
-
export type
|
|
167
|
+
export type StoreTableDef = StoreTableEntry & {
|
|
168
168
|
readonly featureName: string;
|
|
169
169
|
};
|
|
170
170
|
|
|
@@ -341,13 +341,13 @@ export type FeatureDefinition = {
|
|
|
341
341
|
// den Hono-app (außerhalb /api/*). Pattern symmetrisch zu queryHandlers/
|
|
342
342
|
// writeHandlers — Routes leben mit dem Feature, nicht im Bootstrap.
|
|
343
343
|
readonly httpRoutes: Readonly<Record<string, HttpRouteDefinition>>;
|
|
344
|
-
//
|
|
344
|
+
// Store tables declared via r.storeTable() — bypass the event-sourcing
|
|
345
345
|
// system. Keyed by feature-local short name (derived from
|
|
346
346
|
// meta.tableName). The registry attaches featureName on aggregation,
|
|
347
|
-
// lifting
|
|
347
|
+
// lifting StoreTableEntry → StoreTableDef. `kumiko schema generate`
|
|
348
348
|
// aggregates these alongside r.entity()-derived metas to build the
|
|
349
349
|
// full schema.
|
|
350
|
-
readonly
|
|
350
|
+
readonly storeTables: Readonly<Record<string, StoreTableEntry>>;
|
|
351
351
|
// Optional Zod-schema for env-vars this feature reads at runtime.
|
|
352
352
|
// Declared via `r.envSchema(z.object({...}))`. `composeEnvSchema` reads
|
|
353
353
|
// this to build one app-wide schema for boot-validation + dry-run
|
|
@@ -735,7 +735,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
735
735
|
// The required `reason` string is the marker that justifies the bypass —
|
|
736
736
|
// a non-empty string is the contract. If you can't write a reason,
|
|
737
737
|
// declare data via `r.entity()` instead.
|
|
738
|
-
|
|
738
|
+
storeTable(meta: EntityTableMeta, options: StoreTableOptions): void;
|
|
739
739
|
|
|
740
740
|
// Register the tree-actions schema for this feature — a map of
|
|
741
741
|
// action-name → action-definition (with optional typed args). At-most-
|
|
@@ -894,12 +894,12 @@ export type Registry = {
|
|
|
894
894
|
getProjectionsForSource(entityName: string): readonly ProjectionDefinition[];
|
|
895
895
|
getAllProjections(): ReadonlyMap<string, ProjectionDefinition>;
|
|
896
896
|
|
|
897
|
-
// All r.
|
|
897
|
+
// All r.storeTable() registrations across all features, keyed by
|
|
898
898
|
// feature-local short name. The dev-server iterates this alongside
|
|
899
899
|
// implicit projections at boot. Cross-feature uniqueness is enforced
|
|
900
900
|
// at registry-build — duplicate names from different features fail
|
|
901
901
|
// the boot, so callers can rely on a flat keyspace.
|
|
902
|
-
|
|
902
|
+
getAllStoreTables(): ReadonlyMap<string, StoreTableDef>;
|
|
903
903
|
|
|
904
904
|
// Multi-stream projections registered via r.multiStreamProjection().
|
|
905
905
|
// Keyed by qualified name. The server wires each into the event-dispatcher
|
|
@@ -67,13 +67,13 @@ export type {
|
|
|
67
67
|
FeatureMetricType,
|
|
68
68
|
FeatureRegistrar,
|
|
69
69
|
MetricOptions,
|
|
70
|
-
RawTableDef,
|
|
71
|
-
RawTableEntry,
|
|
72
|
-
RawTableOptions,
|
|
73
70
|
Registry,
|
|
74
71
|
SecretKeyDefinition,
|
|
75
72
|
SecretKeyHandle,
|
|
76
73
|
SecretOptions,
|
|
74
|
+
StoreTableDef,
|
|
75
|
+
StoreTableEntry,
|
|
76
|
+
StoreTableOptions,
|
|
77
77
|
UiHintOption,
|
|
78
78
|
UiHints,
|
|
79
79
|
} from "./feature";
|
|
@@ -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(),
|
|
@@ -200,7 +200,7 @@ export type EnqueueProjectionRebuildDeps = {
|
|
|
200
200
|
readonly db: DbConnection;
|
|
201
201
|
readonly registry: Registry;
|
|
202
202
|
// Present + projection-rebuild job registered (jobs composed) → tracked,
|
|
203
|
-
// retryable job (read_job_runs +
|
|
203
|
+
// retryable job (read_job_runs + store_job_run_logs). Absent → inline rebuild.
|
|
204
204
|
readonly jobRunner?: JobRunner;
|
|
205
205
|
};
|
|
206
206
|
|
|
@@ -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
|
+
}
|
|
@@ -9,7 +9,7 @@ const logInfo = (msg: string): void => console.log(msg);
|
|
|
9
9
|
/**
|
|
10
10
|
* Push all implicit-projection tables — one per `r.entity()` — that the
|
|
11
11
|
* registry knows about. setupTestStack already handles explicit
|
|
12
|
-
* projections, MSPs, and `r.
|
|
12
|
+
* projections, MSPs, and `r.storeTable()` declarations in its own loop;
|
|
13
13
|
* implicit projections are the missing piece for a fresh boot.
|
|
14
14
|
*
|
|
15
15
|
* Idempotent via `tableExists` so a persistent dev DB
|
|
@@ -20,7 +20,7 @@ const logInfo = (msg: string): void => console.log(msg);
|
|
|
20
20
|
* Lives next to `setupTestStack` because both are stack-bootstrap
|
|
21
21
|
* helpers that legitimately speak the `unsafe*`-DDL layer; the
|
|
22
22
|
* Table-DDL Guard's stack/** allowlist is the single shared exemption
|
|
23
|
-
* site. Apps still declare data via `r.entity()` / `r.
|
|
23
|
+
* site. Apps still declare data via `r.entity()` / `r.storeTable()` and
|
|
24
24
|
* never call this directly.
|
|
25
25
|
*/
|
|
26
26
|
export async function pushEntityProjectionTables(
|