@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.
Files changed (32) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/{raw-table.integration.test.ts → store-table.integration.test.ts} +10 -10
  3. package/src/api/__tests__/batch.integration.test.ts +68 -0
  4. package/src/db/__tests__/collect-table-metas.test.ts +6 -6
  5. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  6. package/src/db/__tests__/feature-table-sources.test.ts +7 -7
  7. package/src/db/collect-table-metas.ts +4 -4
  8. package/src/db/entity-table-meta.ts +5 -4
  9. package/src/db/feature-table-sources.ts +3 -3
  10. package/src/db/queries/shadow-swap.ts +1 -1
  11. package/src/engine/__tests__/boot-validator.test.ts +96 -0
  12. package/src/engine/__tests__/{raw-table.test.ts → store-table.test.ts} +59 -42
  13. package/src/engine/boot-validator/action-wiring.ts +67 -17
  14. package/src/engine/define-feature.ts +1 -1
  15. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  16. package/src/engine/feature-ast/extractors/index.ts +1 -1
  17. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  18. package/src/engine/feature-ast/parse.ts +3 -3
  19. package/src/engine/feature-builder-state.ts +3 -3
  20. package/src/engine/feature-ui-extensions.ts +54 -33
  21. package/src/engine/registry-facade.ts +3 -3
  22. package/src/engine/registry-ingest.ts +6 -6
  23. package/src/engine/registry-state.ts +3 -3
  24. package/src/engine/types/feature.ts +17 -17
  25. package/src/engine/types/index.ts +3 -3
  26. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  27. package/src/files/file-ref-entity.ts +2 -2
  28. package/src/migrations/pending-rebuilds.ts +1 -1
  29. package/src/search/__tests__/reindex-entity.integration.test.ts +121 -0
  30. package/src/search/index.ts +6 -0
  31. package/src/search/reindex-entity.ts +148 -0
  32. 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.rawTable("${physical}") (feature "${clash.featureName}"). ` +
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) + raw tables +
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
- // Raw tables: aggregated by feature-local short name (unprefixed —
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.rawTables ?? {})) {
311
- const existing = state.rawTableMap.get(rawName);
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.rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
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
- rawTableMap: Map<string, RawTableDef>;
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
- rawTableMap: new Map(),
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
- // --- Raw tables (declared by features via r.rawTable()) ---
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 (rawTable) or the framework-
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 rawTable adopted it.
133
+ // EntityTableMeta was the forward-compatible shape, so storeTable adopted it.
134
134
 
135
- /** Options accepted by `r.rawTable()`. The `reason` is required so the
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 RawTableOptions = {
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 raw-table registration. `meta` is the `EntityTableMeta`
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 `RawTableDef`. */
157
- export type RawTableEntry = {
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 raw-table — the per-feature `RawTableEntry` plus
165
- * the owning feature name. This is what `Registry.getAllRawTables()`
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 RawTableDef = RawTableEntry & {
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
- // Raw tables declared via r.rawTable() — bypass the event-sourcing
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 RawTableEntryRawTableDef. `kumiko schema generate`
347
+ // lifting StoreTableEntryStoreTableDef. `kumiko schema generate`
348
348
  // aggregates these alongside r.entity()-derived metas to build the
349
349
  // full schema.
350
- readonly rawTables: Readonly<Record<string, RawTableEntry>>;
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
- rawTable(meta: EntityTableMeta, options: RawTableOptions): void;
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.rawTable() registrations across all features, keyed by
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
- getAllRawTables(): ReadonlyMap<string, RawTableDef>;
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 { 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(),
@@ -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 + read_job_run_logs). Absent → inline rebuild.
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
+ });
@@ -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.rawTable()` declarations in its own loop;
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.rawTable()` and
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(