@open-mercato/core 0.6.7-develop.6660.1.90e1e2eef6 → 0.6.7-develop.6661.1.0043ed1d03

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 (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/communication_channels/lib/pg-errors.js +1 -7
  3. package/dist/modules/communication_channels/lib/pg-errors.js.map +2 -2
  4. package/dist/modules/query_index/api/openapi.js +2 -2
  5. package/dist/modules/query_index/api/openapi.js.map +2 -2
  6. package/dist/modules/query_index/api/status.js +6 -4
  7. package/dist/modules/query_index/api/status.js.map +2 -2
  8. package/dist/modules/query_index/cli.js +31 -28
  9. package/dist/modules/query_index/cli.js.map +2 -2
  10. package/dist/modules/query_index/components/QueryIndexesTable.js +4 -1
  11. package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
  12. package/dist/modules/query_index/lib/batch.js +163 -44
  13. package/dist/modules/query_index/lib/batch.js.map +3 -3
  14. package/dist/modules/query_index/lib/jobs.js +3 -2
  15. package/dist/modules/query_index/lib/jobs.js.map +2 -2
  16. package/dist/modules/query_index/lib/reindexer.js +50 -16
  17. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  18. package/dist/modules/query_index/lib/stale.js +3 -0
  19. package/dist/modules/query_index/lib/stale.js.map +2 -2
  20. package/package.json +7 -7
  21. package/src/modules/communication_channels/lib/pg-errors.ts +3 -10
  22. package/src/modules/query_index/api/openapi.ts +2 -2
  23. package/src/modules/query_index/api/status.ts +14 -5
  24. package/src/modules/query_index/cli.ts +36 -28
  25. package/src/modules/query_index/components/QueryIndexesTable.tsx +7 -4
  26. package/src/modules/query_index/i18n/de.json +2 -0
  27. package/src/modules/query_index/i18n/en.json +2 -0
  28. package/src/modules/query_index/i18n/es.json +2 -0
  29. package/src/modules/query_index/i18n/pl.json +2 -0
  30. package/src/modules/query_index/lib/batch.ts +244 -67
  31. package/src/modules/query_index/lib/jobs.ts +11 -0
  32. package/src/modules/query_index/lib/reindexer.ts +69 -16
  33. package/src/modules/query_index/lib/stale.ts +10 -0
@@ -1,9 +1,39 @@
1
1
  import { sql } from "kysely";
2
2
  import { recordIndexerError } from "@open-mercato/shared/lib/indexers/error-log";
3
+ import { isUniqueViolation } from "@open-mercato/shared/lib/db/pg-errors";
3
4
  import { buildIndexDocument } from "./document.js";
4
5
  import { replaceSearchTokensForBatch, isSearchDebugEnabled } from "./search-tokens.js";
5
6
  import { createLogger } from "@open-mercato/shared/lib/logger";
6
7
  const logger = createLogger("query_index").child({ component: "reindex-batch" });
8
+ const MAX_RECORDED_ROW_ERRORS_PER_BATCH = 50;
9
+ class QueryIndexBatchWriteError extends Error {
10
+ constructor(entityType, result) {
11
+ const preview = result.failedRecordIds.slice(0, 10);
12
+ const suffix = result.failedRecordIds.length > preview.length ? ", ..." : "";
13
+ super(
14
+ `[internal] Query index write incomplete for ${entityType}: wrote ${result.written} of ${result.attempted} records` + (preview.length ? ` (failed: ${preview.join(", ")}${suffix})` : "")
15
+ );
16
+ this.name = "QueryIndexBatchWriteError";
17
+ this.entityType = entityType;
18
+ this.attempted = result.attempted;
19
+ this.written = result.written;
20
+ this.failedRecordIds = result.failedRecordIds;
21
+ }
22
+ }
23
+ function assertIndexBatchWritesLanded(entityType, result) {
24
+ if (result.written >= result.attempted && result.failedRecordIds.length === 0) return;
25
+ throw new QueryIndexBatchWriteError(entityType, result);
26
+ }
27
+ function createEmptyUpsertIndexBatchResult() {
28
+ return { attempted: 0, written: 0, failedRecordIds: [], searchTokenFailures: 0 };
29
+ }
30
+ function mergeUpsertIndexBatchResults(target, addition) {
31
+ target.attempted += addition.attempted;
32
+ target.written += addition.written;
33
+ target.searchTokenFailures += addition.searchTokenFailures;
34
+ target.failedRecordIds.push(...addition.failedRecordIds);
35
+ return target;
36
+ }
7
37
  function normalizeId(value) {
8
38
  return String(value);
9
39
  }
@@ -11,9 +41,42 @@ function normalizeScopedValue(value) {
11
41
  if (value === void 0 || value === null || value === "") return null;
12
42
  return String(value);
13
43
  }
44
+ async function updateIndexRow(db, payload) {
45
+ let updateQuery = db.updateTable("entity_indexes").set({
46
+ doc: sql`${JSON.stringify(payload.doc)}::jsonb`,
47
+ index_version: payload.index_version,
48
+ organization_id: payload.organization_id ?? null,
49
+ tenant_id: payload.tenant_id ?? null,
50
+ updated_at: sql`now()`,
51
+ deleted_at: null
52
+ }).where("entity_type", "=", payload.entity_type).where("entity_id", "=", payload.entity_id);
53
+ updateQuery = payload.organization_id == null ? updateQuery.where("organization_id", "is", null) : updateQuery.where("organization_id", "=", payload.organization_id);
54
+ const result = await updateQuery.executeTakeFirst();
55
+ return Number(result?.numUpdatedRows ?? 0);
56
+ }
14
57
  async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
15
- if (!rows.length) return;
58
+ if (!rows.length) return createEmptyUpsertIndexBatchResult();
16
59
  const recordIds = rows.map((row) => normalizeId(row.id));
60
+ const failedRecordIds = [];
61
+ let searchTokenFailures = 0;
62
+ let recordedRowErrors = 0;
63
+ const recordRowFailure = async (recordId, error, handler, rowScope) => {
64
+ failedRecordIds.push(recordId);
65
+ if (recordedRowErrors >= MAX_RECORDED_ROW_ERRORS_PER_BATCH) return;
66
+ recordedRowErrors += 1;
67
+ await recordIndexerError(
68
+ { db },
69
+ {
70
+ source: "query_index",
71
+ handler,
72
+ error,
73
+ entityType,
74
+ recordId,
75
+ tenantId: rowScope.tenantId,
76
+ organizationId: rowScope.organizationId
77
+ }
78
+ ).catch(() => void 0);
79
+ };
17
80
  const shouldMergeCustomerEntity = entityType === "customers:customer_person_profile" || entityType === "customers:customer_company_profile";
18
81
  let customerEntitiesById = null;
19
82
  if (shouldMergeCustomerEntity) {
@@ -84,7 +147,12 @@ async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
84
147
  doc = encrypted;
85
148
  tokenDoc = encrypted;
86
149
  }
87
- } catch {
150
+ } catch (encryptError) {
151
+ await recordRowFailure(recordId, encryptError, "query_index:reindex-batch:encrypt", {
152
+ organizationId: scopeOrg ?? null,
153
+ tenantId: scopeTenant ?? null
154
+ });
155
+ continue;
88
156
  }
89
157
  }
90
158
  if (typeof options.decryptDoc === "function") {
@@ -96,7 +164,20 @@ async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
96
164
  if (decrypted && typeof decrypted === "object") {
97
165
  tokenDoc = decrypted;
98
166
  }
99
- } catch {
167
+ } catch (decryptError) {
168
+ logger.warn("Failed to decrypt index document for search tokens", { entityType, recordId });
169
+ await recordIndexerError(
170
+ { db },
171
+ {
172
+ source: "fulltext",
173
+ handler: "query_index:reindex-batch:decrypt",
174
+ error: decryptError,
175
+ entityType,
176
+ recordId,
177
+ tenantId: scopeTenant ?? null,
178
+ organizationId: scopeOrg ?? null
179
+ }
180
+ ).catch(() => void 0);
100
181
  }
101
182
  }
102
183
  basePayloads.push({
@@ -143,6 +224,26 @@ async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
143
224
  tenantId: payload.tenant_id,
144
225
  doc: payload.tokenDoc
145
226
  }));
227
+ const writeSearchTokens = async (payloads = tokenPayloads) => {
228
+ if (!payloads.length) return;
229
+ try {
230
+ await replaceSearchTokensForBatch(db, payloads);
231
+ } catch (searchTokenError) {
232
+ searchTokenFailures += 1;
233
+ await recordIndexerError(
234
+ { db },
235
+ { source: "fulltext", handler: "query_index:reindex-batch", error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null }
236
+ ).catch(() => void 0);
237
+ }
238
+ };
239
+ const buildResult = (written2) => ({
240
+ attempted: rows.length,
241
+ written: written2,
242
+ failedRecordIds,
243
+ searchTokenFailures
244
+ });
245
+ if (!basePayloads.length) return buildResult(0);
246
+ let bulkWriteFailed = false;
146
247
  try {
147
248
  await db.insertInto("entity_indexes").values(insertRows).onConflict((oc) => oc.columns(["entity_type", "entity_id", "organization_id_coalesced"]).doUpdateSet({
148
249
  doc: sql`excluded.doc`,
@@ -152,14 +253,26 @@ async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
152
253
  deleted_at: sql`excluded.deleted_at`,
153
254
  updated_at: sql`now()`
154
255
  })).execute();
155
- try {
156
- await replaceSearchTokensForBatch(db, tokenPayloads);
157
- } catch (searchTokenError) {
158
- await recordIndexerError(
159
- { db },
160
- { source: "fulltext", handler: "query_index:reindex-batch", error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null }
161
- ).catch(() => void 0);
162
- }
256
+ } catch (bulkError) {
257
+ bulkWriteFailed = true;
258
+ logger.warn("Bulk index upsert failed; falling back to per-row writes", {
259
+ entityType,
260
+ records: basePayloads.length
261
+ });
262
+ await recordIndexerError(
263
+ { db },
264
+ {
265
+ source: "query_index",
266
+ handler: "query_index:reindex-batch:bulk",
267
+ error: bulkError,
268
+ entityType,
269
+ tenantId: scope.tenantId ?? null,
270
+ organizationId: scope.orgId ?? null
271
+ }
272
+ ).catch(() => void 0);
273
+ }
274
+ if (!bulkWriteFailed) {
275
+ await writeSearchTokens();
163
276
  if (debugEnabled) {
164
277
  logger.debug("Reindex batch tokens", {
165
278
  entityType,
@@ -168,48 +281,54 @@ async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
168
281
  scopeTenant: scope.tenantId ?? null
169
282
  });
170
283
  }
171
- return;
172
- } catch {
173
- await db.transaction().execute(async (trx) => {
174
- for (const payload of basePayloads) {
175
- let updateQuery = trx.updateTable("entity_indexes").set({
284
+ return buildResult(basePayloads.length);
285
+ }
286
+ let written = 0;
287
+ for (const payload of basePayloads) {
288
+ try {
289
+ if (await updateIndexRow(db, payload) > 0) {
290
+ written += 1;
291
+ continue;
292
+ }
293
+ try {
294
+ await db.insertInto("entity_indexes").values({
295
+ entity_type: payload.entity_type,
296
+ entity_id: payload.entity_id,
297
+ organization_id: payload.organization_id,
298
+ tenant_id: payload.tenant_id,
176
299
  doc: sql`${JSON.stringify(payload.doc)}::jsonb`,
177
300
  index_version: payload.index_version,
178
- organization_id: payload.organization_id ?? null,
179
- tenant_id: payload.tenant_id ?? null,
301
+ created_at: sql`now()`,
180
302
  updated_at: sql`now()`,
181
303
  deleted_at: null
182
- }).where("entity_type", "=", payload.entity_type).where("entity_id", "=", payload.entity_id);
183
- updateQuery = payload.organization_id == null ? updateQuery.where("organization_id", "is", null) : updateQuery.where("organization_id", "=", payload.organization_id);
184
- const result = await updateQuery.executeTakeFirst();
185
- if (result && Number(result.numUpdatedRows ?? 0) > 0) continue;
186
- try {
187
- await trx.insertInto("entity_indexes").values({
188
- entity_type: payload.entity_type,
189
- entity_id: payload.entity_id,
190
- organization_id: payload.organization_id,
191
- tenant_id: payload.tenant_id,
192
- doc: sql`${JSON.stringify(payload.doc)}::jsonb`,
193
- index_version: payload.index_version,
194
- created_at: sql`now()`,
195
- updated_at: sql`now()`,
196
- deleted_at: null
197
- }).execute();
198
- } catch {
304
+ }).execute();
305
+ written += 1;
306
+ } catch (insertError) {
307
+ if (isUniqueViolation(insertError) && await updateIndexRow(db, payload) > 0) {
308
+ written += 1;
309
+ continue;
199
310
  }
311
+ await recordRowFailure(payload.entity_id, insertError, "query_index:reindex-batch:row", {
312
+ organizationId: payload.organization_id,
313
+ tenantId: payload.tenant_id
314
+ });
200
315
  }
201
- });
202
- }
203
- try {
204
- await replaceSearchTokensForBatch(db, tokenPayloads);
205
- } catch (searchTokenError) {
206
- await recordIndexerError(
207
- { db },
208
- { source: "fulltext", handler: "query_index:reindex-batch", error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null }
209
- ).catch(() => void 0);
316
+ } catch (rowError) {
317
+ await recordRowFailure(payload.entity_id, rowError, "query_index:reindex-batch:row", {
318
+ organizationId: payload.organization_id,
319
+ tenantId: payload.tenant_id
320
+ });
321
+ }
210
322
  }
323
+ const failedRecordIdSet = new Set(failedRecordIds);
324
+ await writeSearchTokens(tokenPayloads.filter((payload) => !failedRecordIdSet.has(payload.recordId)));
325
+ return buildResult(written);
211
326
  }
212
327
  export {
328
+ QueryIndexBatchWriteError,
329
+ assertIndexBatchWritesLanded,
330
+ createEmptyUpsertIndexBatchResult,
331
+ mergeUpsertIndexBatchResults,
213
332
  upsertIndexBatch
214
333
  };
215
334
  //# sourceMappingURL=batch.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/query_index/lib/batch.ts"],
4
- "sourcesContent": ["import { type Kysely, sql } from 'kysely'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\nimport { buildIndexDocument, type IndexCustomFieldValue } from './document'\nimport { replaceSearchTokensForBatch, isSearchDebugEnabled } from './search-tokens'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'reindex-batch' })\n\nexport type AnyRow = Record<string, any> & { id: string | number }\n\nexport type ScopeOverrides = {\n orgId?: string\n tenantId?: string\n}\n\ntype CustomFieldRow = {\n record_id: string\n field_key: string\n value_text: string | null\n value_multiline: string | null\n value_int: number | null\n value_float: number | null\n value_bool: boolean | null\n organization_id: string | null\n tenant_id: string | null\n}\n\nexport type IndexBatchOptions = {\n deriveOrganizationId?: (row: AnyRow) => string | null | undefined\n encryptDoc?: (\n entityType: string,\n doc: Record<string, unknown>,\n scope: { organizationId: string | null; tenantId: string | null },\n ) => Promise<Record<string, unknown> | null | undefined>\n decryptDoc?: (\n entityType: string,\n doc: Record<string, unknown>,\n scope: { organizationId: string | null; tenantId: string | null },\n ) => Promise<Record<string, unknown> | null | undefined>\n}\n\nfunction normalizeId(value: unknown): string {\n return String(value)\n}\n\nfunction normalizeScopedValue(value: unknown): string | null {\n if (value === undefined || value === null || value === '') return null\n return String(value)\n}\n\nexport async function upsertIndexBatch(\n db: Kysely<any>,\n entityType: string,\n rows: AnyRow[],\n scope: ScopeOverrides,\n options: IndexBatchOptions = {},\n): Promise<void> {\n if (!rows.length) return\n const recordIds = rows.map((row) => normalizeId(row.id))\n\n const shouldMergeCustomerEntity =\n entityType === 'customers:customer_person_profile' || entityType === 'customers:customer_company_profile'\n\n let customerEntitiesById: Map<string, AnyRow> | null = null\n if (shouldMergeCustomerEntity) {\n const entityIds = Array.from(\n new Set(\n rows\n .map((row) => (row as AnyRow).entity_id || (row as AnyRow).entityId)\n .filter((value): value is string | number => value !== undefined && value !== null && `${value}` !== '')\n .map((value) => normalizeId(value)),\n ),\n )\n if (entityIds.length) {\n const entityRows = await db\n .selectFrom('customer_entities' as any)\n .selectAll()\n .where('id' as any, 'in', entityIds)\n .execute() as AnyRow[]\n customerEntitiesById = new Map(entityRows.map((row) => [normalizeId(row.id), row]))\n }\n }\n\n const customFieldRows = await db\n .selectFrom('custom_field_values' as any)\n .select([\n 'record_id' as any,\n 'field_key' as any,\n 'value_text' as any,\n 'value_multiline' as any,\n 'value_int' as any,\n 'value_float' as any,\n 'value_bool' as any,\n 'organization_id' as any,\n 'tenant_id' as any,\n ])\n .where('entity_id' as any, '=', entityType)\n .where('record_id' as any, 'in', recordIds)\n .execute() as CustomFieldRow[]\n\n const customFieldMap = new Map<string, CustomFieldRow[]>()\n for (const fieldRow of customFieldRows) {\n const key = normalizeId(fieldRow.record_id)\n const bucket = customFieldMap.get(key)\n if (bucket) bucket.push(fieldRow)\n else customFieldMap.set(key, [fieldRow])\n }\n\n const basePayloads: Array<{\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n doc: Record<string, unknown>\n tokenDoc: Record<string, unknown>\n index_version: number\n }> = []\n\n const debugEnabled = isSearchDebugEnabled()\n\n for (const row of rows) {\n const recordId = normalizeId(row.id)\n const baseOrg = normalizeScopedValue((row as AnyRow).organization_id)\n const baseTenant = normalizeScopedValue((row as AnyRow).tenant_id)\n const derivedOrg = options?.deriveOrganizationId\n ? normalizeScopedValue(options.deriveOrganizationId(row))\n : undefined\n const scopeOrg =\n scope.orgId !== undefined\n ? scope.orgId\n : derivedOrg !== undefined\n ? derivedOrg\n : baseOrg\n const scopeTenant = scope.tenantId !== undefined ? scope.tenantId : baseTenant\n const inputRows = customFieldMap.get(recordId) ?? []\n const values: IndexCustomFieldValue[] = inputRows.map((fieldRow) => ({\n key: fieldRow.field_key,\n value:\n fieldRow.value_bool ??\n fieldRow.value_int ??\n fieldRow.value_float ??\n fieldRow.value_text ??\n fieldRow.value_multiline ??\n null,\n organizationId: normalizeScopedValue(fieldRow.organization_id),\n tenantId: normalizeScopedValue(fieldRow.tenant_id),\n }))\n const mergedRow = (() => {\n if (!shouldMergeCustomerEntity || !customerEntitiesById) return row\n const entityId = (row as AnyRow).entity_id || (row as AnyRow).entityId\n if (!entityId) return row\n const entityRow = customerEntitiesById.get(normalizeId(entityId))\n if (!entityRow) return row\n return { ...entityRow, ...row }\n })()\n let doc = buildIndexDocument(mergedRow, values, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n let tokenDoc: Record<string, unknown> = doc\n if (typeof options.encryptDoc === 'function') {\n try {\n const encrypted = await options.encryptDoc(entityType, doc, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n if (encrypted && typeof encrypted === 'object') {\n doc = encrypted\n tokenDoc = encrypted\n }\n } catch {\n // best-effort; ignore encrypt errors during indexing\n }\n }\n if (typeof options.decryptDoc === 'function') {\n try {\n const decrypted = await options.decryptDoc(entityType, doc, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n if (decrypted && typeof decrypted === 'object') {\n tokenDoc = decrypted\n }\n } catch {\n // best-effort; ignore decrypt errors during indexing\n }\n }\n basePayloads.push({\n entity_type: entityType,\n entity_id: recordId,\n organization_id: scopeOrg ?? null,\n tenant_id: scopeTenant ?? null,\n doc,\n tokenDoc,\n index_version: 1,\n })\n if (debugEnabled) {\n const sample = {\n display_name: (tokenDoc as any).display_name,\n first_name: (tokenDoc as any).first_name,\n last_name: (tokenDoc as any).last_name,\n brand_name: (tokenDoc as any).brand_name,\n legal_name: (tokenDoc as any).legal_name,\n }\n logger.debug('Reindex batch document', {\n entityType,\n recordId,\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n sample,\n })\n }\n }\n\n const insertRows = basePayloads.map((payload) => ({\n entity_type: payload.entity_type,\n entity_id: payload.entity_id,\n organization_id: payload.organization_id,\n tenant_id: payload.tenant_id,\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n created_at: sql`now()`,\n updated_at: sql`now()`,\n deleted_at: null,\n }))\n\n const tokenPayloads = basePayloads.map((payload) => ({\n entityType: payload.entity_type,\n recordId: payload.entity_id,\n organizationId: payload.organization_id,\n tenantId: payload.tenant_id,\n doc: payload.tokenDoc,\n }))\n\n try {\n await db\n .insertInto('entity_indexes' as any)\n .values(insertRows as any)\n .onConflict((oc: any) => oc\n .columns(['entity_type', 'entity_id', 'organization_id_coalesced'])\n .doUpdateSet({\n doc: sql`excluded.doc`,\n index_version: sql`excluded.index_version`,\n organization_id: sql`excluded.organization_id`,\n tenant_id: sql`excluded.tenant_id`,\n deleted_at: sql`excluded.deleted_at`,\n updated_at: sql`now()`,\n } as any))\n .execute()\n try {\n await replaceSearchTokensForBatch(db, tokenPayloads)\n } catch (searchTokenError) {\n // Record instead of swallowing: a failed token write leaves fulltext search stale.\n await recordIndexerError(\n { db },\n { source: 'fulltext', handler: 'query_index:reindex-batch', error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null },\n ).catch(() => undefined)\n }\n if (debugEnabled) {\n logger.debug('Reindex batch tokens', {\n entityType,\n records: basePayloads.length,\n scopeOrg: scope.orgId ?? null,\n scopeTenant: scope.tenantId ?? null,\n })\n }\n return\n } catch {\n await db.transaction().execute(async (trx) => {\n for (const payload of basePayloads) {\n let updateQuery = trx\n .updateTable('entity_indexes' as any)\n .set({\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n organization_id: payload.organization_id ?? null,\n tenant_id: payload.tenant_id ?? null,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any)\n .where('entity_type' as any, '=', payload.entity_type)\n .where('entity_id' as any, '=', payload.entity_id)\n updateQuery = payload.organization_id == null\n ? updateQuery.where('organization_id' as any, 'is', null as any)\n : updateQuery.where('organization_id' as any, '=', payload.organization_id)\n const result = await updateQuery.executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined\n if (result && Number(result.numUpdatedRows ?? 0) > 0) continue\n try {\n await trx\n .insertInto('entity_indexes' as any)\n .values({\n entity_type: payload.entity_type,\n entity_id: payload.entity_id,\n organization_id: payload.organization_id,\n tenant_id: payload.tenant_id,\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n created_at: sql`now()`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any)\n .execute()\n } catch {\n // ignore duplicate insert race; another concurrent worker updated the row\n }\n }\n })\n }\n try {\n await replaceSearchTokensForBatch(db, tokenPayloads)\n } catch (searchTokenError) {\n // Record instead of swallowing: a failed token write leaves fulltext search stale.\n await recordIndexerError(\n { db },\n { source: 'fulltext', handler: 'query_index:reindex-batch', error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null },\n ).catch(() => undefined)\n }\n}\n"],
5
- "mappings": "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",
6
- "names": []
4
+ "sourcesContent": ["import { type Kysely, sql } from 'kysely'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\nimport { isUniqueViolation } from '@open-mercato/shared/lib/db/pg-errors'\nimport { buildIndexDocument, type IndexCustomFieldValue } from './document'\nimport { replaceSearchTokensForBatch, isSearchDebugEnabled } from './search-tokens'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'reindex-batch' })\n\n/**\n * Cap on how many per-row failures a single batch persists to `indexer_error_logs`.\n * Under a systemic failure (pool exhausted, disk full, KMS down) every row fails, and\n * writing one error row per record would hammer the database that is already failing.\n * The count of suppressed failures still reaches the caller via `failedRecordIds`.\n */\nconst MAX_RECORDED_ROW_ERRORS_PER_BATCH = 50\n\nexport type AnyRow = Record<string, any> & { id: string | number }\n\nexport type ScopeOverrides = {\n orgId?: string\n tenantId?: string\n}\n\n/**\n * What a batch actually wrote, as opposed to what it was asked to write. Callers MUST\n * reconcile `written` against `attempted` \u2014 a batch that loses rows and reports success\n * is how records go permanently missing from the index (issue GSM-266).\n */\nexport type UpsertIndexBatchResult = {\n attempted: number\n written: number\n failedRecordIds: string[]\n searchTokenFailures: number\n}\n\nexport class QueryIndexBatchWriteError extends Error {\n readonly entityType: string\n readonly attempted: number\n readonly written: number\n readonly failedRecordIds: string[]\n\n constructor(entityType: string, result: UpsertIndexBatchResult) {\n const preview = result.failedRecordIds.slice(0, 10)\n const suffix = result.failedRecordIds.length > preview.length ? ', ...' : ''\n super(\n `[internal] Query index write incomplete for ${entityType}: wrote ${result.written} of ${result.attempted} records` +\n (preview.length ? ` (failed: ${preview.join(', ')}${suffix})` : ''),\n )\n this.name = 'QueryIndexBatchWriteError'\n this.entityType = entityType\n this.attempted = result.attempted\n this.written = result.written\n this.failedRecordIds = result.failedRecordIds\n }\n}\n\nexport function assertIndexBatchWritesLanded(entityType: string, result: UpsertIndexBatchResult): void {\n if (result.written >= result.attempted && result.failedRecordIds.length === 0) return\n throw new QueryIndexBatchWriteError(entityType, result)\n}\n\nexport function createEmptyUpsertIndexBatchResult(): UpsertIndexBatchResult {\n return { attempted: 0, written: 0, failedRecordIds: [], searchTokenFailures: 0 }\n}\n\nexport function mergeUpsertIndexBatchResults(\n target: UpsertIndexBatchResult,\n addition: UpsertIndexBatchResult,\n): UpsertIndexBatchResult {\n target.attempted += addition.attempted\n target.written += addition.written\n target.searchTokenFailures += addition.searchTokenFailures\n target.failedRecordIds.push(...addition.failedRecordIds)\n return target\n}\n\ntype CustomFieldRow = {\n record_id: string\n field_key: string\n value_text: string | null\n value_multiline: string | null\n value_int: number | null\n value_float: number | null\n value_bool: boolean | null\n organization_id: string | null\n tenant_id: string | null\n}\n\nexport type IndexBatchOptions = {\n deriveOrganizationId?: (row: AnyRow) => string | null | undefined\n encryptDoc?: (\n entityType: string,\n doc: Record<string, unknown>,\n scope: { organizationId: string | null; tenantId: string | null },\n ) => Promise<Record<string, unknown> | null | undefined>\n decryptDoc?: (\n entityType: string,\n doc: Record<string, unknown>,\n scope: { organizationId: string | null; tenantId: string | null },\n ) => Promise<Record<string, unknown> | null | undefined>\n}\n\nfunction normalizeId(value: unknown): string {\n return String(value)\n}\n\nfunction normalizeScopedValue(value: unknown): string | null {\n if (value === undefined || value === null || value === '') return null\n return String(value)\n}\n\ntype IndexRowPayload = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n doc: Record<string, unknown>\n tokenDoc: Record<string, unknown>\n index_version: number\n}\n\nasync function updateIndexRow(db: Kysely<any>, payload: IndexRowPayload): Promise<number> {\n let updateQuery = db\n .updateTable('entity_indexes' as any)\n .set({\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n organization_id: payload.organization_id ?? null,\n tenant_id: payload.tenant_id ?? null,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any)\n .where('entity_type' as any, '=', payload.entity_type)\n .where('entity_id' as any, '=', payload.entity_id)\n updateQuery = payload.organization_id == null\n ? updateQuery.where('organization_id' as any, 'is', null as any)\n : updateQuery.where('organization_id' as any, '=', payload.organization_id)\n const result = await updateQuery.executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined\n return Number(result?.numUpdatedRows ?? 0)\n}\n\nexport async function upsertIndexBatch(\n db: Kysely<any>,\n entityType: string,\n rows: AnyRow[],\n scope: ScopeOverrides,\n options: IndexBatchOptions = {},\n): Promise<UpsertIndexBatchResult> {\n if (!rows.length) return createEmptyUpsertIndexBatchResult()\n const recordIds = rows.map((row) => normalizeId(row.id))\n\n const failedRecordIds: string[] = []\n let searchTokenFailures = 0\n let recordedRowErrors = 0\n\n const recordRowFailure = async (\n recordId: string,\n error: unknown,\n handler: string,\n rowScope: { organizationId: string | null; tenantId: string | null },\n ): Promise<void> => {\n failedRecordIds.push(recordId)\n if (recordedRowErrors >= MAX_RECORDED_ROW_ERRORS_PER_BATCH) return\n recordedRowErrors += 1\n await recordIndexerError(\n { db },\n {\n source: 'query_index',\n handler,\n error,\n entityType,\n recordId,\n tenantId: rowScope.tenantId,\n organizationId: rowScope.organizationId,\n },\n ).catch(() => undefined)\n }\n\n const shouldMergeCustomerEntity =\n entityType === 'customers:customer_person_profile' || entityType === 'customers:customer_company_profile'\n\n let customerEntitiesById: Map<string, AnyRow> | null = null\n if (shouldMergeCustomerEntity) {\n const entityIds = Array.from(\n new Set(\n rows\n .map((row) => (row as AnyRow).entity_id || (row as AnyRow).entityId)\n .filter((value): value is string | number => value !== undefined && value !== null && `${value}` !== '')\n .map((value) => normalizeId(value)),\n ),\n )\n if (entityIds.length) {\n const entityRows = await db\n .selectFrom('customer_entities' as any)\n .selectAll()\n .where('id' as any, 'in', entityIds)\n .execute() as AnyRow[]\n customerEntitiesById = new Map(entityRows.map((row) => [normalizeId(row.id), row]))\n }\n }\n\n const customFieldRows = await db\n .selectFrom('custom_field_values' as any)\n .select([\n 'record_id' as any,\n 'field_key' as any,\n 'value_text' as any,\n 'value_multiline' as any,\n 'value_int' as any,\n 'value_float' as any,\n 'value_bool' as any,\n 'organization_id' as any,\n 'tenant_id' as any,\n ])\n .where('entity_id' as any, '=', entityType)\n .where('record_id' as any, 'in', recordIds)\n .execute() as CustomFieldRow[]\n\n const customFieldMap = new Map<string, CustomFieldRow[]>()\n for (const fieldRow of customFieldRows) {\n const key = normalizeId(fieldRow.record_id)\n const bucket = customFieldMap.get(key)\n if (bucket) bucket.push(fieldRow)\n else customFieldMap.set(key, [fieldRow])\n }\n\n const basePayloads: IndexRowPayload[] = []\n\n const debugEnabled = isSearchDebugEnabled()\n\n for (const row of rows) {\n const recordId = normalizeId(row.id)\n const baseOrg = normalizeScopedValue((row as AnyRow).organization_id)\n const baseTenant = normalizeScopedValue((row as AnyRow).tenant_id)\n const derivedOrg = options?.deriveOrganizationId\n ? normalizeScopedValue(options.deriveOrganizationId(row))\n : undefined\n const scopeOrg =\n scope.orgId !== undefined\n ? scope.orgId\n : derivedOrg !== undefined\n ? derivedOrg\n : baseOrg\n const scopeTenant = scope.tenantId !== undefined ? scope.tenantId : baseTenant\n const inputRows = customFieldMap.get(recordId) ?? []\n const values: IndexCustomFieldValue[] = inputRows.map((fieldRow) => ({\n key: fieldRow.field_key,\n value:\n fieldRow.value_bool ??\n fieldRow.value_int ??\n fieldRow.value_float ??\n fieldRow.value_text ??\n fieldRow.value_multiline ??\n null,\n organizationId: normalizeScopedValue(fieldRow.organization_id),\n tenantId: normalizeScopedValue(fieldRow.tenant_id),\n }))\n const mergedRow = (() => {\n if (!shouldMergeCustomerEntity || !customerEntitiesById) return row\n const entityId = (row as AnyRow).entity_id || (row as AnyRow).entityId\n if (!entityId) return row\n const entityRow = customerEntitiesById.get(normalizeId(entityId))\n if (!entityRow) return row\n return { ...entityRow, ...row }\n })()\n let doc = buildIndexDocument(mergedRow, values, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n let tokenDoc: Record<string, unknown> = doc\n if (typeof options.encryptDoc === 'function') {\n try {\n const encrypted = await options.encryptDoc(entityType, doc, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n if (encrypted && typeof encrypted === 'object') {\n doc = encrypted\n tokenDoc = encrypted\n }\n } catch (encryptError) {\n // Falling through would write the plaintext document into entity_indexes,\n // which must stay encrypted at rest. Skip the row instead so the previously\n // encrypted row survives, and let the caller fail the run.\n await recordRowFailure(recordId, encryptError, 'query_index:reindex-batch:encrypt', {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n continue\n }\n }\n if (typeof options.decryptDoc === 'function') {\n try {\n const decrypted = await options.decryptDoc(entityType, doc, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n if (decrypted && typeof decrypted === 'object') {\n tokenDoc = decrypted\n }\n } catch (decryptError) {\n // Only affects the search tokens built below; the indexed document itself is\n // unharmed, so the row still counts as written.\n logger.warn('Failed to decrypt index document for search tokens', { entityType, recordId })\n await recordIndexerError(\n { db },\n {\n source: 'fulltext',\n handler: 'query_index:reindex-batch:decrypt',\n error: decryptError,\n entityType,\n recordId,\n tenantId: scopeTenant ?? null,\n organizationId: scopeOrg ?? null,\n },\n ).catch(() => undefined)\n }\n }\n basePayloads.push({\n entity_type: entityType,\n entity_id: recordId,\n organization_id: scopeOrg ?? null,\n tenant_id: scopeTenant ?? null,\n doc,\n tokenDoc,\n index_version: 1,\n })\n if (debugEnabled) {\n const sample = {\n display_name: (tokenDoc as any).display_name,\n first_name: (tokenDoc as any).first_name,\n last_name: (tokenDoc as any).last_name,\n brand_name: (tokenDoc as any).brand_name,\n legal_name: (tokenDoc as any).legal_name,\n }\n logger.debug('Reindex batch document', {\n entityType,\n recordId,\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n sample,\n })\n }\n }\n\n const insertRows = basePayloads.map((payload) => ({\n entity_type: payload.entity_type,\n entity_id: payload.entity_id,\n organization_id: payload.organization_id,\n tenant_id: payload.tenant_id,\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n created_at: sql`now()`,\n updated_at: sql`now()`,\n deleted_at: null,\n }))\n\n const tokenPayloads = basePayloads.map((payload) => ({\n entityType: payload.entity_type,\n recordId: payload.entity_id,\n organizationId: payload.organization_id,\n tenantId: payload.tenant_id,\n doc: payload.tokenDoc,\n }))\n\n const writeSearchTokens = async (payloads = tokenPayloads): Promise<void> => {\n if (!payloads.length) return\n try {\n await replaceSearchTokensForBatch(db, payloads)\n } catch (searchTokenError) {\n // Record instead of swallowing: a failed token write leaves fulltext search stale.\n // Not counted as a write failure \u2014 replaceSearchTokensForBatch is transactional, so\n // tokens end up stale rather than missing, and the record's next write rebuilds them.\n searchTokenFailures += 1\n await recordIndexerError(\n { db },\n { source: 'fulltext', handler: 'query_index:reindex-batch', error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null },\n ).catch(() => undefined)\n }\n }\n\n const buildResult = (written: number): UpsertIndexBatchResult => ({\n attempted: rows.length,\n written,\n failedRecordIds,\n searchTokenFailures,\n })\n\n if (!basePayloads.length) return buildResult(0)\n\n let bulkWriteFailed = false\n try {\n // One statement, so it is atomic on its own. Note the arbiter is\n // (entity_type, entity_id, organization_id_coalesced): two payloads sharing a\n // conflict key in the same batch would raise 21000 for the whole statement and\n // fall through to the per-row path below.\n await db\n .insertInto('entity_indexes' as any)\n .values(insertRows as any)\n .onConflict((oc: any) => oc\n .columns(['entity_type', 'entity_id', 'organization_id_coalesced'])\n .doUpdateSet({\n doc: sql`excluded.doc`,\n index_version: sql`excluded.index_version`,\n organization_id: sql`excluded.organization_id`,\n tenant_id: sql`excluded.tenant_id`,\n deleted_at: sql`excluded.deleted_at`,\n updated_at: sql`now()`,\n } as any))\n .execute()\n } catch (bulkError) {\n bulkWriteFailed = true\n logger.warn('Bulk index upsert failed; falling back to per-row writes', {\n entityType,\n records: basePayloads.length,\n })\n await recordIndexerError(\n { db },\n {\n source: 'query_index',\n handler: 'query_index:reindex-batch:bulk',\n error: bulkError,\n entityType,\n tenantId: scope.tenantId ?? null,\n organizationId: scope.orgId ?? null,\n },\n ).catch(() => undefined)\n }\n\n if (!bulkWriteFailed) {\n await writeSearchTokens()\n if (debugEnabled) {\n logger.debug('Reindex batch tokens', {\n entityType,\n records: basePayloads.length,\n scopeOrg: scope.orgId ?? null,\n scopeTenant: scope.tenantId ?? null,\n })\n }\n return buildResult(basePayloads.length)\n }\n\n // Deliberately NOT wrapped in a transaction. In Postgres a single failing statement\n // aborts the surrounding transaction, and COMMIT on an aborted transaction answers\n // with a ROLLBACK tag instead of raising \u2014 so one bad row used to discard the entire\n // batch while this function reported success (issue GSM-266). Each row here is an\n // independent, idempotent upsert; they share no invariant worth a transaction.\n let written = 0\n for (const payload of basePayloads) {\n try {\n if (await updateIndexRow(db, payload) > 0) {\n written += 1\n continue\n }\n try {\n await db\n .insertInto('entity_indexes' as any)\n .values({\n entity_type: payload.entity_type,\n entity_id: payload.entity_id,\n organization_id: payload.organization_id,\n tenant_id: payload.tenant_id,\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n created_at: sql`now()`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any)\n .execute()\n written += 1\n } catch (insertError) {\n // A concurrent worker may have inserted the row between our UPDATE and INSERT.\n // Only a successful retry of the UPDATE proves the row is actually there.\n if (isUniqueViolation(insertError) && await updateIndexRow(db, payload) > 0) {\n written += 1\n continue\n }\n await recordRowFailure(payload.entity_id, insertError, 'query_index:reindex-batch:row', {\n organizationId: payload.organization_id,\n tenantId: payload.tenant_id,\n })\n }\n } catch (rowError) {\n await recordRowFailure(payload.entity_id, rowError, 'query_index:reindex-batch:row', {\n organizationId: payload.organization_id,\n tenantId: payload.tenant_id,\n })\n }\n }\n\n const failedRecordIdSet = new Set(failedRecordIds)\n await writeSearchTokens(tokenPayloads.filter((payload) => !failedRecordIdSet.has(payload.recordId)))\n return buildResult(written)\n}\n"],
5
+ "mappings": "AAAA,SAAsB,WAAW;AACjC,SAAS,0BAA0B;AACnC,SAAS,yBAAyB;AAClC,SAAS,0BAAsD;AAC/D,SAAS,6BAA6B,4BAA4B;AAClE,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,aAAa,EAAE,MAAM,EAAE,WAAW,gBAAgB,CAAC;AAQ/E,MAAM,oCAAoC;AAqBnC,MAAM,kCAAkC,MAAM;AAAA,EAMnD,YAAY,YAAoB,QAAgC;AAC9D,UAAM,UAAU,OAAO,gBAAgB,MAAM,GAAG,EAAE;AAClD,UAAM,SAAS,OAAO,gBAAgB,SAAS,QAAQ,SAAS,UAAU;AAC1E;AAAA,MACE,+CAA+C,UAAU,WAAW,OAAO,OAAO,OAAO,OAAO,SAAS,cACtG,QAAQ,SAAS,aAAa,QAAQ,KAAK,IAAI,CAAC,GAAG,MAAM,MAAM;AAAA,IACpE;AACA,SAAK,OAAO;AACZ,SAAK,aAAa;AAClB,SAAK,YAAY,OAAO;AACxB,SAAK,UAAU,OAAO;AACtB,SAAK,kBAAkB,OAAO;AAAA,EAChC;AACF;AAEO,SAAS,6BAA6B,YAAoB,QAAsC;AACrG,MAAI,OAAO,WAAW,OAAO,aAAa,OAAO,gBAAgB,WAAW,EAAG;AAC/E,QAAM,IAAI,0BAA0B,YAAY,MAAM;AACxD;AAEO,SAAS,oCAA4D;AAC1E,SAAO,EAAE,WAAW,GAAG,SAAS,GAAG,iBAAiB,CAAC,GAAG,qBAAqB,EAAE;AACjF;AAEO,SAAS,6BACd,QACA,UACwB;AACxB,SAAO,aAAa,SAAS;AAC7B,SAAO,WAAW,SAAS;AAC3B,SAAO,uBAAuB,SAAS;AACvC,SAAO,gBAAgB,KAAK,GAAG,SAAS,eAAe;AACvD,SAAO;AACT;AA4BA,SAAS,YAAY,OAAwB;AAC3C,SAAO,OAAO,KAAK;AACrB;AAEA,SAAS,qBAAqB,OAA+B;AAC3D,MAAI,UAAU,UAAa,UAAU,QAAQ,UAAU,GAAI,QAAO;AAClE,SAAO,OAAO,KAAK;AACrB;AAYA,eAAe,eAAe,IAAiB,SAA2C;AACxF,MAAI,cAAc,GACf,YAAY,gBAAuB,EACnC,IAAI;AAAA,IACH,KAAK,MAAM,KAAK,UAAU,QAAQ,GAAG,CAAC;AAAA,IACtC,eAAe,QAAQ;AAAA,IACvB,iBAAiB,QAAQ,mBAAmB;AAAA,IAC5C,WAAW,QAAQ,aAAa;AAAA,IAChC,YAAY;AAAA,IACZ,YAAY;AAAA,EACd,CAAQ,EACP,MAAM,eAAsB,KAAK,QAAQ,WAAW,EACpD,MAAM,aAAoB,KAAK,QAAQ,SAAS;AACnD,gBAAc,QAAQ,mBAAmB,OACrC,YAAY,MAAM,mBAA0B,MAAM,IAAW,IAC7D,YAAY,MAAM,mBAA0B,KAAK,QAAQ,eAAe;AAC5E,QAAM,SAAS,MAAM,YAAY,iBAAiB;AAClD,SAAO,OAAO,QAAQ,kBAAkB,CAAC;AAC3C;AAEA,eAAsB,iBACpB,IACA,YACA,MACA,OACA,UAA6B,CAAC,GACG;AACjC,MAAI,CAAC,KAAK,OAAQ,QAAO,kCAAkC;AAC3D,QAAM,YAAY,KAAK,IAAI,CAAC,QAAQ,YAAY,IAAI,EAAE,CAAC;AAEvD,QAAM,kBAA4B,CAAC;AACnC,MAAI,sBAAsB;AAC1B,MAAI,oBAAoB;AAExB,QAAM,mBAAmB,OACvB,UACA,OACA,SACA,aACkB;AAClB,oBAAgB,KAAK,QAAQ;AAC7B,QAAI,qBAAqB,kCAAmC;AAC5D,yBAAqB;AACrB,UAAM;AAAA,MACJ,EAAE,GAAG;AAAA,MACL;AAAA,QACE,QAAQ;AAAA,QACR;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,UAAU,SAAS;AAAA,QACnB,gBAAgB,SAAS;AAAA,MAC3B;AAAA,IACF,EAAE,MAAM,MAAM,MAAS;AAAA,EACzB;AAEA,QAAM,4BACJ,eAAe,uCAAuC,eAAe;AAEvE,MAAI,uBAAmD;AACvD,MAAI,2BAA2B;AAC7B,UAAM,YAAY,MAAM;AAAA,MACtB,IAAI;AAAA,QACF,KACG,IAAI,CAAC,QAAS,IAAe,aAAc,IAAe,QAAQ,EAClE,OAAO,CAAC,UAAoC,UAAU,UAAa,UAAU,QAAQ,GAAG,KAAK,OAAO,EAAE,EACtG,IAAI,CAAC,UAAU,YAAY,KAAK,CAAC;AAAA,MACtC;AAAA,IACF;AACA,QAAI,UAAU,QAAQ;AACpB,YAAM,aAAa,MAAM,GACtB,WAAW,mBAA0B,EACrC,UAAU,EACV,MAAM,MAAa,MAAM,SAAS,EAClC,QAAQ;AACX,6BAAuB,IAAI,IAAI,WAAW,IAAI,CAAC,QAAQ,CAAC,YAAY,IAAI,EAAE,GAAG,GAAG,CAAC,CAAC;AAAA,IACpF;AAAA,EACF;AAEA,QAAM,kBAAkB,MAAM,GAC3B,WAAW,qBAA4B,EACvC,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,MAAM,aAAoB,KAAK,UAAU,EACzC,MAAM,aAAoB,MAAM,SAAS,EACzC,QAAQ;AAEX,QAAM,iBAAiB,oBAAI,IAA8B;AACzD,aAAW,YAAY,iBAAiB;AACtC,UAAM,MAAM,YAAY,SAAS,SAAS;AAC1C,UAAM,SAAS,eAAe,IAAI,GAAG;AACrC,QAAI,OAAQ,QAAO,KAAK,QAAQ;AAAA,QAC3B,gBAAe,IAAI,KAAK,CAAC,QAAQ,CAAC;AAAA,EACzC;AAEA,QAAM,eAAkC,CAAC;AAEzC,QAAM,eAAe,qBAAqB;AAE1C,aAAW,OAAO,MAAM;AACtB,UAAM,WAAW,YAAY,IAAI,EAAE;AACnC,UAAM,UAAU,qBAAsB,IAAe,eAAe;AACpE,UAAM,aAAa,qBAAsB,IAAe,SAAS;AACjE,UAAM,aAAa,SAAS,uBACxB,qBAAqB,QAAQ,qBAAqB,GAAG,CAAC,IACtD;AACJ,UAAM,WACJ,MAAM,UAAU,SACZ,MAAM,QACN,eAAe,SACb,aACA;AACR,UAAM,cAAc,MAAM,aAAa,SAAY,MAAM,WAAW;AACpE,UAAM,YAAY,eAAe,IAAI,QAAQ,KAAK,CAAC;AACnD,UAAM,SAAkC,UAAU,IAAI,CAAC,cAAc;AAAA,MACnE,KAAK,SAAS;AAAA,MACd,OACE,SAAS,cACT,SAAS,aACT,SAAS,eACT,SAAS,cACT,SAAS,mBACT;AAAA,MACF,gBAAgB,qBAAqB,SAAS,eAAe;AAAA,MAC7D,UAAU,qBAAqB,SAAS,SAAS;AAAA,IACnD,EAAE;AACF,UAAM,aAAa,MAAM;AACvB,UAAI,CAAC,6BAA6B,CAAC,qBAAsB,QAAO;AAChE,YAAM,WAAY,IAAe,aAAc,IAAe;AAC9D,UAAI,CAAC,SAAU,QAAO;AACtB,YAAM,YAAY,qBAAqB,IAAI,YAAY,QAAQ,CAAC;AAChE,UAAI,CAAC,UAAW,QAAO;AACvB,aAAO,EAAE,GAAG,WAAW,GAAG,IAAI;AAAA,IAChC,GAAG;AACH,QAAI,MAAM,mBAAmB,WAAW,QAAQ;AAAA,MAC9C,gBAAgB,YAAY;AAAA,MAC5B,UAAU,eAAe;AAAA,IAC3B,CAAC;AACD,QAAI,WAAoC;AACxC,QAAI,OAAO,QAAQ,eAAe,YAAY;AAC5C,UAAI;AACF,cAAM,YAAY,MAAM,QAAQ,WAAW,YAAY,KAAK;AAAA,UAC1D,gBAAgB,YAAY;AAAA,UAC5B,UAAU,eAAe;AAAA,QAC3B,CAAC;AACD,YAAI,aAAa,OAAO,cAAc,UAAU;AAC9C,gBAAM;AACN,qBAAW;AAAA,QACb;AAAA,MACF,SAAS,cAAc;AAIrB,cAAM,iBAAiB,UAAU,cAAc,qCAAqC;AAAA,UAClF,gBAAgB,YAAY;AAAA,UAC5B,UAAU,eAAe;AAAA,QAC3B,CAAC;AACD;AAAA,MACF;AAAA,IACF;AACA,QAAI,OAAO,QAAQ,eAAe,YAAY;AAC5C,UAAI;AACF,cAAM,YAAY,MAAM,QAAQ,WAAW,YAAY,KAAK;AAAA,UAC1D,gBAAgB,YAAY;AAAA,UAC5B,UAAU,eAAe;AAAA,QAC3B,CAAC;AACD,YAAI,aAAa,OAAO,cAAc,UAAU;AAC9C,qBAAW;AAAA,QACb;AAAA,MACF,SAAS,cAAc;AAGrB,eAAO,KAAK,sDAAsD,EAAE,YAAY,SAAS,CAAC;AAC1F,cAAM;AAAA,UACJ,EAAE,GAAG;AAAA,UACL;AAAA,YACE,QAAQ;AAAA,YACR,SAAS;AAAA,YACT,OAAO;AAAA,YACP;AAAA,YACA;AAAA,YACA,UAAU,eAAe;AAAA,YACzB,gBAAgB,YAAY;AAAA,UAC9B;AAAA,QACF,EAAE,MAAM,MAAM,MAAS;AAAA,MACzB;AAAA,IACF;AACA,iBAAa,KAAK;AAAA,MAChB,aAAa;AAAA,MACb,WAAW;AAAA,MACX,iBAAiB,YAAY;AAAA,MAC7B,WAAW,eAAe;AAAA,MAC1B;AAAA,MACA;AAAA,MACA,eAAe;AAAA,IACjB,CAAC;AACD,QAAI,cAAc;AAChB,YAAM,SAAS;AAAA,QACb,cAAe,SAAiB;AAAA,QAChC,YAAa,SAAiB;AAAA,QAC9B,WAAY,SAAiB;AAAA,QAC7B,YAAa,SAAiB;AAAA,QAC9B,YAAa,SAAiB;AAAA,MAChC;AACA,aAAO,MAAM,0BAA0B;AAAA,QACrC;AAAA,QACA;AAAA,QACA,gBAAgB,YAAY;AAAA,QAC5B,UAAU,eAAe;AAAA,QACzB;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AAEA,QAAM,aAAa,aAAa,IAAI,CAAC,aAAa;AAAA,IAChD,aAAa,QAAQ;AAAA,IACrB,WAAW,QAAQ;AAAA,IACnB,iBAAiB,QAAQ;AAAA,IACzB,WAAW,QAAQ;AAAA,IACnB,KAAK,MAAM,KAAK,UAAU,QAAQ,GAAG,CAAC;AAAA,IACtC,eAAe,QAAQ;AAAA,IACvB,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ,YAAY;AAAA,EACd,EAAE;AAEF,QAAM,gBAAgB,aAAa,IAAI,CAAC,aAAa;AAAA,IACnD,YAAY,QAAQ;AAAA,IACpB,UAAU,QAAQ;AAAA,IAClB,gBAAgB,QAAQ;AAAA,IACxB,UAAU,QAAQ;AAAA,IAClB,KAAK,QAAQ;AAAA,EACf,EAAE;AAEF,QAAM,oBAAoB,OAAO,WAAW,kBAAiC;AAC3E,QAAI,CAAC,SAAS,OAAQ;AACtB,QAAI;AACF,YAAM,4BAA4B,IAAI,QAAQ;AAAA,IAChD,SAAS,kBAAkB;AAIzB,6BAAuB;AACvB,YAAM;AAAA,QACJ,EAAE,GAAG;AAAA,QACL,EAAE,QAAQ,YAAY,SAAS,6BAA6B,OAAO,kBAAkB,YAAY,UAAU,MAAM,YAAY,MAAM,gBAAgB,MAAM,SAAS,KAAK;AAAA,MACzK,EAAE,MAAM,MAAM,MAAS;AAAA,IACzB;AAAA,EACF;AAEA,QAAM,cAAc,CAACA,cAA6C;AAAA,IAChE,WAAW,KAAK;AAAA,IAChB,SAAAA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,MAAI,CAAC,aAAa,OAAQ,QAAO,YAAY,CAAC;AAE9C,MAAI,kBAAkB;AACtB,MAAI;AAKF,UAAM,GACH,WAAW,gBAAuB,EAClC,OAAO,UAAiB,EACxB,WAAW,CAAC,OAAY,GACtB,QAAQ,CAAC,eAAe,aAAa,2BAA2B,CAAC,EACjE,YAAY;AAAA,MACX,KAAK;AAAA,MACL,eAAe;AAAA,MACf,iBAAiB;AAAA,MACjB,WAAW;AAAA,MACX,YAAY;AAAA,MACZ,YAAY;AAAA,IACd,CAAQ,CAAC,EACV,QAAQ;AAAA,EACb,SAAS,WAAW;AAClB,sBAAkB;AAClB,WAAO,KAAK,4DAA4D;AAAA,MACtE;AAAA,MACA,SAAS,aAAa;AAAA,IACxB,CAAC;AACD,UAAM;AAAA,MACJ,EAAE,GAAG;AAAA,MACL;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,OAAO;AAAA,QACP;AAAA,QACA,UAAU,MAAM,YAAY;AAAA,QAC5B,gBAAgB,MAAM,SAAS;AAAA,MACjC;AAAA,IACF,EAAE,MAAM,MAAM,MAAS;AAAA,EACzB;AAEA,MAAI,CAAC,iBAAiB;AACpB,UAAM,kBAAkB;AACxB,QAAI,cAAc;AAChB,aAAO,MAAM,wBAAwB;AAAA,QACnC;AAAA,QACA,SAAS,aAAa;AAAA,QACtB,UAAU,MAAM,SAAS;AAAA,QACzB,aAAa,MAAM,YAAY;AAAA,MACjC,CAAC;AAAA,IACH;AACA,WAAO,YAAY,aAAa,MAAM;AAAA,EACxC;AAOA,MAAI,UAAU;AACd,aAAW,WAAW,cAAc;AAClC,QAAI;AACF,UAAI,MAAM,eAAe,IAAI,OAAO,IAAI,GAAG;AACzC,mBAAW;AACX;AAAA,MACF;AACA,UAAI;AACF,cAAM,GACH,WAAW,gBAAuB,EAClC,OAAO;AAAA,UACN,aAAa,QAAQ;AAAA,UACrB,WAAW,QAAQ;AAAA,UACnB,iBAAiB,QAAQ;AAAA,UACzB,WAAW,QAAQ;AAAA,UACnB,KAAK,MAAM,KAAK,UAAU,QAAQ,GAAG,CAAC;AAAA,UACtC,eAAe,QAAQ;AAAA,UACvB,YAAY;AAAA,UACZ,YAAY;AAAA,UACZ,YAAY;AAAA,QACd,CAAQ,EACP,QAAQ;AACX,mBAAW;AAAA,MACb,SAAS,aAAa;AAGpB,YAAI,kBAAkB,WAAW,KAAK,MAAM,eAAe,IAAI,OAAO,IAAI,GAAG;AAC3E,qBAAW;AACX;AAAA,QACF;AACA,cAAM,iBAAiB,QAAQ,WAAW,aAAa,iCAAiC;AAAA,UACtF,gBAAgB,QAAQ;AAAA,UACxB,UAAU,QAAQ;AAAA,QACpB,CAAC;AAAA,MACH;AAAA,IACF,SAAS,UAAU;AACjB,YAAM,iBAAiB,QAAQ,WAAW,UAAU,iCAAiC;AAAA,QACnF,gBAAgB,QAAQ;AAAA,QACxB,UAAU,QAAQ;AAAA,MACpB,CAAC;AAAA,IACH;AAAA,EACF;AAEA,QAAM,oBAAoB,IAAI,IAAI,eAAe;AACjD,QAAM,kBAAkB,cAAc,OAAO,CAAC,YAAY,CAAC,kBAAkB,IAAI,QAAQ,QAAQ,CAAC,CAAC;AACnG,SAAO,YAAY,OAAO;AAC5B;",
6
+ "names": ["written"]
7
7
  }
@@ -74,11 +74,12 @@ async function updateJobProgress(db, scope, processedDelta) {
74
74
  scope
75
75
  ).execute();
76
76
  }
77
- async function finalizeJob(db, scope) {
77
+ async function finalizeJob(db, scope, options = {}) {
78
78
  await applyScopeWhere(
79
79
  db.updateTable("entity_index_jobs").set({
80
80
  finished_at: sql`now()`,
81
- heartbeat_at: sql`now()`
81
+ heartbeat_at: sql`now()`,
82
+ ...options.status ? { status: options.status } : {}
82
83
  }),
83
84
  scope
84
85
  ).execute();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/query_index/lib/jobs.ts"],
4
- "sourcesContent": ["import { type Kysely, sql } from 'kysely'\n\nexport type JobScope = {\n entityType: string\n organizationId?: string | null\n tenantId?: string | null\n partitionIndex?: number | null\n partitionCount?: number | null\n}\n\nfunction applyScopeWhere<QB extends { where: (...args: any[]) => QB }>(\n builder: QB,\n scope: JobScope,\n): QB {\n let q = builder.where('entity_type' as any, '=', scope.entityType)\n q = q.where(sql`organization_id is not distinct from ${scope.organizationId ?? null}`)\n q = q.where(sql`tenant_id is not distinct from ${scope.tenantId ?? null}`)\n q = q.where(sql`partition_index is not distinct from ${scope.partitionIndex ?? null}`)\n q = q.where(sql`partition_count is not distinct from ${scope.partitionCount ?? null}`)\n return q\n}\n\n// True when ON CONFLICT could not infer an arbiter index (Postgres SQLSTATE\n// 42P10) \u2014 i.e. entity_index_jobs_scope_unique is not present yet. Used to scope\n// prepareJob's fallback to the missing-index case only.\nfunction isMissingConflictArbiterError(err: unknown): boolean {\n const code = (err as { code?: unknown } | null | undefined)?.code\n if (code === '42P10') return true\n const message = (err as { message?: unknown } | null | undefined)?.message\n return typeof message === 'string'\n && /no unique or exclusion constraint matching the on conflict/i.test(message)\n}\n\nexport async function prepareJob(\n db: Kysely<any>,\n scope: JobScope,\n status: 'reindexing' | 'purging',\n options: { totalCount?: number | null } = {},\n): Promise<string | null> {\n const base = {\n organization_id: scope.organizationId ?? null,\n tenant_id: scope.tenantId ?? null,\n partition_index: scope.partitionIndex ?? null,\n partition_count: scope.partitionCount ?? null,\n status,\n started_at: sql`now()`,\n finished_at: null,\n heartbeat_at: sql`now()`,\n processed_count: 0,\n total_count: options.totalCount ?? null,\n }\n\n // Single atomic upsert keyed by the coalesced scope tuple. This closes the\n // read-then-write race where two concurrent schedulers each see \"no row\" and\n // both INSERT a duplicate for the same scope, after which updateJobProgress /\n // finalizeJob corrupt each other's progress and finish state (#2739). The\n // ON CONFLICT target must match entity_index_jobs_scope_unique exactly.\n try {\n const upserted = await db\n .insertInto('entity_index_jobs' as any)\n .values({ entity_type: scope.entityType, ...base } as any)\n .onConflict((oc: any) => oc\n .expression(sql`\n \"entity_type\",\n coalesce(\"organization_id\", '00000000-0000-0000-0000-000000000000'::uuid),\n coalesce(\"tenant_id\", '00000000-0000-0000-0000-000000000000'::uuid),\n coalesce(\"partition_index\", -1),\n coalesce(\"partition_count\", -1)\n `)\n .doUpdateSet({\n status,\n started_at: sql`now()`,\n finished_at: null,\n heartbeat_at: sql`now()`,\n processed_count: 0,\n total_count: options.totalCount ?? null,\n } as any))\n .returning(['id' as any])\n .execute() as Array<{ id: string }>\n return upserted?.[0]?.id ?? null\n } catch (err) {\n // Only degrade to the legacy path when the scope unique index is absent\n // (e.g. a rolling deploy running this code against the pre-migration schema):\n // Postgres raises 42P10. Any other failure is real and must surface rather\n // than silently fall back to the racy read-then-write path and re-open #2739.\n if (!isMissingConflictArbiterError(err)) throw err\n return prepareJobLegacy(db, scope, base)\n }\n}\n\n// Pre-#2739 read-then-write path. Retained only as a fallback when the\n// entity_index_jobs scope unique index is unavailable; it carries the original\n// (racy) semantics and must never be reached once the migration has applied.\nasync function prepareJobLegacy(\n db: Kysely<any>,\n scope: JobScope,\n base: Record<string, unknown>,\n): Promise<string | null> {\n const existing = await applyScopeWhere(\n db.selectFrom('entity_index_jobs' as any).select(['id' as any]),\n scope,\n ).executeTakeFirst() as { id: string } | undefined\n\n if (existing) {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set(base as any) as any,\n scope,\n ).execute()\n return existing.id\n }\n\n const inserted = await db\n .insertInto('entity_index_jobs' as any)\n .values({\n entity_type: scope.entityType,\n ...base,\n } as any)\n .returning(['id' as any])\n .execute() as Array<{ id: string }>\n\n return inserted?.[0]?.id ?? null\n}\n\nexport async function updateJobProgress(\n db: Kysely<any>,\n scope: JobScope,\n processedDelta: number,\n): Promise<void> {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set({\n processed_count: sql`coalesce(processed_count, 0) + ${Math.max(0, processedDelta)}`,\n heartbeat_at: sql`now()`,\n } as any) as any,\n scope,\n ).execute()\n}\n\nexport async function finalizeJob(\n db: Kysely<any>,\n scope: JobScope,\n): Promise<void> {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set({\n finished_at: sql`now()`,\n heartbeat_at: sql`now()`,\n } as any) as any,\n scope,\n ).execute()\n}\n"],
5
- "mappings": "AAAA,SAAsB,WAAW;AAUjC,SAAS,gBACP,SACA,OACI;AACJ,MAAI,IAAI,QAAQ,MAAM,eAAsB,KAAK,MAAM,UAAU;AACjE,MAAI,EAAE,MAAM,2CAA2C,MAAM,kBAAkB,IAAI,EAAE;AACrF,MAAI,EAAE,MAAM,qCAAqC,MAAM,YAAY,IAAI,EAAE;AACzE,MAAI,EAAE,MAAM,2CAA2C,MAAM,kBAAkB,IAAI,EAAE;AACrF,MAAI,EAAE,MAAM,2CAA2C,MAAM,kBAAkB,IAAI,EAAE;AACrF,SAAO;AACT;AAKA,SAAS,8BAA8B,KAAuB;AAC5D,QAAM,OAAQ,KAA+C;AAC7D,MAAI,SAAS,QAAS,QAAO;AAC7B,QAAM,UAAW,KAAkD;AACnE,SAAO,OAAO,YAAY,YACrB,8DAA8D,KAAK,OAAO;AACjF;AAEA,eAAsB,WACpB,IACA,OACA,QACA,UAA0C,CAAC,GACnB;AACxB,QAAM,OAAO;AAAA,IACX,iBAAiB,MAAM,kBAAkB;AAAA,IACzC,WAAW,MAAM,YAAY;AAAA,IAC7B,iBAAiB,MAAM,kBAAkB;AAAA,IACzC,iBAAiB,MAAM,kBAAkB;AAAA,IACzC;AAAA,IACA,YAAY;AAAA,IACZ,aAAa;AAAA,IACb,cAAc;AAAA,IACd,iBAAiB;AAAA,IACjB,aAAa,QAAQ,cAAc;AAAA,EACrC;AAOA,MAAI;AACF,UAAM,WAAW,MAAM,GACpB,WAAW,mBAA0B,EACrC,OAAO,EAAE,aAAa,MAAM,YAAY,GAAG,KAAK,CAAQ,EACxD,WAAW,CAAC,OAAY,GACtB,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,SAMX,EACA,YAAY;AAAA,MACX;AAAA,MACA,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,cAAc;AAAA,MACd,iBAAiB;AAAA,MACjB,aAAa,QAAQ,cAAc;AAAA,IACrC,CAAQ,CAAC,EACV,UAAU,CAAC,IAAW,CAAC,EACvB,QAAQ;AACX,WAAO,WAAW,CAAC,GAAG,MAAM;AAAA,EAC9B,SAAS,KAAK;AAKZ,QAAI,CAAC,8BAA8B,GAAG,EAAG,OAAM;AAC/C,WAAO,iBAAiB,IAAI,OAAO,IAAI;AAAA,EACzC;AACF;AAKA,eAAe,iBACb,IACA,OACA,MACwB;AACxB,QAAM,WAAW,MAAM;AAAA,IACrB,GAAG,WAAW,mBAA0B,EAAE,OAAO,CAAC,IAAW,CAAC;AAAA,IAC9D;AAAA,EACF,EAAE,iBAAiB;AAEnB,MAAI,UAAU;AACZ,UAAM;AAAA,MACJ,GAAG,YAAY,mBAA0B,EAAE,IAAI,IAAW;AAAA,MAC1D;AAAA,IACF,EAAE,QAAQ;AACV,WAAO,SAAS;AAAA,EAClB;AAEA,QAAM,WAAW,MAAM,GACpB,WAAW,mBAA0B,EACrC,OAAO;AAAA,IACN,aAAa,MAAM;AAAA,IACnB,GAAG;AAAA,EACL,CAAQ,EACP,UAAU,CAAC,IAAW,CAAC,EACvB,QAAQ;AAEX,SAAO,WAAW,CAAC,GAAG,MAAM;AAC9B;AAEA,eAAsB,kBACpB,IACA,OACA,gBACe;AACf,QAAM;AAAA,IACJ,GAAG,YAAY,mBAA0B,EAAE,IAAI;AAAA,MAC7C,iBAAiB,qCAAqC,KAAK,IAAI,GAAG,cAAc,CAAC;AAAA,MACjF,cAAc;AAAA,IAChB,CAAQ;AAAA,IACR;AAAA,EACF,EAAE,QAAQ;AACZ;AAEA,eAAsB,YACpB,IACA,OACe;AACf,QAAM;AAAA,IACJ,GAAG,YAAY,mBAA0B,EAAE,IAAI;AAAA,MAC7C,aAAa;AAAA,MACb,cAAc;AAAA,IAChB,CAAQ;AAAA,IACR;AAAA,EACF,EAAE,QAAQ;AACZ;",
4
+ "sourcesContent": ["import { type Kysely, sql } from 'kysely'\n\nexport type JobScope = {\n entityType: string\n organizationId?: string | null\n tenantId?: string | null\n partitionIndex?: number | null\n partitionCount?: number | null\n}\n\nfunction applyScopeWhere<QB extends { where: (...args: any[]) => QB }>(\n builder: QB,\n scope: JobScope,\n): QB {\n let q = builder.where('entity_type' as any, '=', scope.entityType)\n q = q.where(sql`organization_id is not distinct from ${scope.organizationId ?? null}`)\n q = q.where(sql`tenant_id is not distinct from ${scope.tenantId ?? null}`)\n q = q.where(sql`partition_index is not distinct from ${scope.partitionIndex ?? null}`)\n q = q.where(sql`partition_count is not distinct from ${scope.partitionCount ?? null}`)\n return q\n}\n\n// True when ON CONFLICT could not infer an arbiter index (Postgres SQLSTATE\n// 42P10) \u2014 i.e. entity_index_jobs_scope_unique is not present yet. Used to scope\n// prepareJob's fallback to the missing-index case only.\nfunction isMissingConflictArbiterError(err: unknown): boolean {\n const code = (err as { code?: unknown } | null | undefined)?.code\n if (code === '42P10') return true\n const message = (err as { message?: unknown } | null | undefined)?.message\n return typeof message === 'string'\n && /no unique or exclusion constraint matching the on conflict/i.test(message)\n}\n\nexport async function prepareJob(\n db: Kysely<any>,\n scope: JobScope,\n status: 'reindexing' | 'purging',\n options: { totalCount?: number | null } = {},\n): Promise<string | null> {\n const base = {\n organization_id: scope.organizationId ?? null,\n tenant_id: scope.tenantId ?? null,\n partition_index: scope.partitionIndex ?? null,\n partition_count: scope.partitionCount ?? null,\n status,\n started_at: sql`now()`,\n finished_at: null,\n heartbeat_at: sql`now()`,\n processed_count: 0,\n total_count: options.totalCount ?? null,\n }\n\n // Single atomic upsert keyed by the coalesced scope tuple. This closes the\n // read-then-write race where two concurrent schedulers each see \"no row\" and\n // both INSERT a duplicate for the same scope, after which updateJobProgress /\n // finalizeJob corrupt each other's progress and finish state (#2739). The\n // ON CONFLICT target must match entity_index_jobs_scope_unique exactly.\n try {\n const upserted = await db\n .insertInto('entity_index_jobs' as any)\n .values({ entity_type: scope.entityType, ...base } as any)\n .onConflict((oc: any) => oc\n .expression(sql`\n \"entity_type\",\n coalesce(\"organization_id\", '00000000-0000-0000-0000-000000000000'::uuid),\n coalesce(\"tenant_id\", '00000000-0000-0000-0000-000000000000'::uuid),\n coalesce(\"partition_index\", -1),\n coalesce(\"partition_count\", -1)\n `)\n .doUpdateSet({\n status,\n started_at: sql`now()`,\n finished_at: null,\n heartbeat_at: sql`now()`,\n processed_count: 0,\n total_count: options.totalCount ?? null,\n } as any))\n .returning(['id' as any])\n .execute() as Array<{ id: string }>\n return upserted?.[0]?.id ?? null\n } catch (err) {\n // Only degrade to the legacy path when the scope unique index is absent\n // (e.g. a rolling deploy running this code against the pre-migration schema):\n // Postgres raises 42P10. Any other failure is real and must surface rather\n // than silently fall back to the racy read-then-write path and re-open #2739.\n if (!isMissingConflictArbiterError(err)) throw err\n return prepareJobLegacy(db, scope, base)\n }\n}\n\n// Pre-#2739 read-then-write path. Retained only as a fallback when the\n// entity_index_jobs scope unique index is unavailable; it carries the original\n// (racy) semantics and must never be reached once the migration has applied.\nasync function prepareJobLegacy(\n db: Kysely<any>,\n scope: JobScope,\n base: Record<string, unknown>,\n): Promise<string | null> {\n const existing = await applyScopeWhere(\n db.selectFrom('entity_index_jobs' as any).select(['id' as any]),\n scope,\n ).executeTakeFirst() as { id: string } | undefined\n\n if (existing) {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set(base as any) as any,\n scope,\n ).execute()\n return existing.id\n }\n\n const inserted = await db\n .insertInto('entity_index_jobs' as any)\n .values({\n entity_type: scope.entityType,\n ...base,\n } as any)\n .returning(['id' as any])\n .execute() as Array<{ id: string }>\n\n return inserted?.[0]?.id ?? null\n}\n\nexport async function updateJobProgress(\n db: Kysely<any>,\n scope: JobScope,\n processedDelta: number,\n): Promise<void> {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set({\n processed_count: sql`coalesce(processed_count, 0) + ${Math.max(0, processedDelta)}`,\n heartbeat_at: sql`now()`,\n } as any) as any,\n scope,\n ).execute()\n}\n\nexport type FinalizeJobOptions = {\n /**\n * Marks the finished job as failed. `finished_at` alone cannot express this \u2014 readers\n * derive \"completed\" from its presence \u2014 so a run that lost records would otherwise\n * still report success in the status API and UI.\n */\n status?: 'failed'\n}\n\nexport async function finalizeJob(\n db: Kysely<any>,\n scope: JobScope,\n options: FinalizeJobOptions = {},\n): Promise<void> {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set({\n finished_at: sql`now()`,\n heartbeat_at: sql`now()`,\n ...(options.status ? { status: options.status } : {}),\n } as any) as any,\n scope,\n ).execute()\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -2,7 +2,13 @@ import { sql } from "kysely";
2
2
  import { resolveRegisteredEntityTableName } from "@open-mercato/shared/lib/query/engine";
3
3
  import { resolveTenantEncryptionService } from "@open-mercato/shared/lib/encryption/customFieldValues";
4
4
  import { decryptIndexDocForSearch, encryptIndexDocForStorage } from "@open-mercato/shared/lib/encryption/indexDoc";
5
- import { upsertIndexBatch } from "./batch.js";
5
+ import {
6
+ upsertIndexBatch,
7
+ assertIndexBatchWritesLanded,
8
+ createEmptyUpsertIndexBatchResult,
9
+ mergeUpsertIndexBatchResults,
10
+ QueryIndexBatchWriteError
11
+ } from "./batch.js";
6
12
  import { refreshCoverageSnapshot, writeCoverageCounts, applyCoverageAdjustments } from "./coverage.js";
7
13
  import { prepareJob, updateJobProgress, finalizeJob } from "./jobs.js";
8
14
  import { purgeOrphans } from "./stale.js";
@@ -11,6 +17,7 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
11
17
  const logger = createLogger("query_index").child({ component: "reindexer" });
12
18
  const DEFAULT_REINDEX_PARTITIONS = 5;
13
19
  const DEFAULT_BATCH_SIZE = 500;
20
+ const MAX_PURGE_EXCLUSIONS = 1e3;
14
21
  const deriveOrgFromId = /* @__PURE__ */ new Set(["directory:organization"]);
15
22
  const COVERAGE_REFRESH_THROTTLE_MS = 5 * 60 * 1e3;
16
23
  const lastCoverageReset = /* @__PURE__ */ new Map();
@@ -183,6 +190,8 @@ async function reindexEntity(em, options) {
183
190
  );
184
191
  let processed = 0;
185
192
  let lastId = null;
193
+ let jobFailed = false;
194
+ const writeTotals = createEmptyUpsertIndexBatchResult();
186
195
  options?.onProgress?.({ processed, total, chunkSize: 0 });
187
196
  if (resetCoverage) {
188
197
  if (force) {
@@ -279,9 +288,15 @@ async function reindexEntity(em, options) {
279
288
  }
280
289
  return result;
281
290
  };
282
- await upsertIndexBatch(db, entityType, rows, scopeOverrides, { deriveOrganizationId: deriveOrg, encryptDoc, decryptDoc });
291
+ const batchResult = await upsertIndexBatch(db, entityType, rows, scopeOverrides, { deriveOrganizationId: deriveOrg, encryptDoc, decryptDoc });
292
+ mergeUpsertIndexBatchResults(writeTotals, batchResult);
293
+ if (batchResult.written === 0 && batchResult.attempted > 0) {
294
+ throw new QueryIndexBatchWriteError(entityType, writeTotals);
295
+ }
296
+ const failedInBatch = new Set(batchResult.failedRecordIds);
297
+ const writtenRows = failedInBatch.size ? rows.filter((row) => !failedInBatch.has(String(row.id))) : rows;
283
298
  const coverageDeltas = /* @__PURE__ */ new Map();
284
- for (const row of rows) {
299
+ for (const row of writtenRows) {
285
300
  const scopeTenant = tenantId !== void 0 ? tenantId ?? null : hasTenantCol ? row.tenant_id ?? null : null;
286
301
  const scopeOrg = organizationId !== void 0 ? organizationId ?? null : hasOrgCol ? row.organization_id ?? null : deriveOrg ? deriveOrg(row) ?? null : null;
287
302
  const key = scopeKey(scopeTenant ?? null, scopeOrg ?? null);
@@ -308,7 +323,7 @@ async function reindexEntity(em, options) {
308
323
  }
309
324
  if (emitVectorize && eventBus) {
310
325
  await Promise.all(
311
- rows.map((row) => {
326
+ writtenRows.map((row) => {
312
327
  const scopeOrg = organizationId !== void 0 ? organizationId ?? null : hasOrgCol ? row.organization_id ?? null : deriveOrg ? deriveOrg(row) ?? null : null;
313
328
  const scopeTenant = tenantId !== void 0 ? tenantId ?? null : hasTenantCol ? row.tenant_id ?? null : null;
314
329
  return eventBus.emitEvent("query_index.vectorize_one", {
@@ -320,19 +335,28 @@ async function reindexEntity(em, options) {
320
335
  })
321
336
  );
322
337
  }
323
- processed += rows.length;
338
+ processed += batchResult.written;
324
339
  lastId = String(rows[rows.length - 1].id);
325
- options?.onProgress?.({ processed, total, chunkSize: rows.length });
326
- await updateJobProgress(db, jobScope, rows.length);
340
+ options?.onProgress?.({ processed, total, chunkSize: batchResult.written });
341
+ await updateJobProgress(db, jobScope, batchResult.written);
342
+ }
343
+ const purgeExclusions = writeTotals.failedRecordIds;
344
+ if (purgeExclusions.length > MAX_PURGE_EXCLUSIONS) {
345
+ logger.warn("Skipping orphan purge after widespread write failures", {
346
+ entityType,
347
+ failedRecords: purgeExclusions.length
348
+ });
349
+ } else {
350
+ await purgeOrphans(db, {
351
+ entityType,
352
+ tenantId,
353
+ organizationId,
354
+ partitionIndex: usingPartitions ? partitionIndex : null,
355
+ partitionCount: usingPartitions ? partitionCountRaw : null,
356
+ startedAt: jobStartedAt,
357
+ excludeRecordIds: purgeExclusions
358
+ });
327
359
  }
328
- await purgeOrphans(db, {
329
- entityType,
330
- tenantId,
331
- organizationId,
332
- partitionIndex: usingPartitions ? partitionIndex : null,
333
- partitionCount: usingPartitions ? partitionCountRaw : null,
334
- startedAt: jobStartedAt
335
- });
336
360
  if (force && vectorService && (!usingPartitions || partitionIndex === null)) {
337
361
  try {
338
362
  await vectorService.removeOrphans({
@@ -361,8 +385,18 @@ async function reindexEntity(em, options) {
361
385
  }
362
386
  );
363
387
  }
388
+ if (writeTotals.searchTokenFailures > 0) {
389
+ logger.warn("Search token writes failed during reindex", {
390
+ entityType,
391
+ batches: writeTotals.searchTokenFailures
392
+ });
393
+ }
394
+ assertIndexBatchWritesLanded(entityType, writeTotals);
395
+ } catch (error) {
396
+ jobFailed = true;
397
+ throw error;
364
398
  } finally {
365
- await finalizeJob(db, jobScope);
399
+ await finalizeJob(db, jobScope, jobFailed ? { status: "failed" } : {});
366
400
  }
367
401
  return {
368
402
  processed,