@open-mercato/core 0.8.1-develop.7294.1.0ef99fc518 → 0.8.1-develop.7296.1.2111d779db
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/dist/modules/query_index/lib/engine.js +76 -1
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/query_index/lib/stale.js +42 -0
- package/dist/modules/query_index/lib/stale.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/query_index/lib/engine.ts +127 -1
- package/src/modules/query_index/lib/stale.ts +93 -0
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { sql } from "kysely";
|
|
2
|
+
import { recordIndexerError } from "@open-mercato/shared/lib/indexers/error-log";
|
|
3
|
+
async function searchTokensTableExists(db) {
|
|
4
|
+
const row = await db.selectFrom("information_schema.tables").select(sql`1`.as("one")).where("table_name", "=", "search_tokens").limit(1).executeTakeFirst();
|
|
5
|
+
return !!row;
|
|
6
|
+
}
|
|
2
7
|
async function purgeOrphans(db, options) {
|
|
3
8
|
const { entityType, tenantId, partitionIndex, partitionCount, startedAt } = options;
|
|
4
9
|
let q = db.deleteFrom("entity_indexes").where("entity_type", "=", entityType);
|
|
@@ -16,6 +21,43 @@ async function purgeOrphans(db, options) {
|
|
|
16
21
|
q = q.where("entity_id", "not in", options.excludeRecordIds);
|
|
17
22
|
}
|
|
18
23
|
await q.execute();
|
|
24
|
+
try {
|
|
25
|
+
if (!await searchTokensTableExists(db)) return;
|
|
26
|
+
let tokens = db.deleteFrom("search_tokens").where("entity_type", "=", entityType);
|
|
27
|
+
if (tenantId !== void 0) {
|
|
28
|
+
tokens = tenantId === null ? tokens.where("tenant_id", "is", null) : tokens.where("tenant_id", "=", tenantId);
|
|
29
|
+
}
|
|
30
|
+
if (options.organizationId !== void 0) {
|
|
31
|
+
tokens = options.organizationId === null ? tokens.where("organization_id", "is", null) : tokens.where("organization_id", "=", options.organizationId);
|
|
32
|
+
}
|
|
33
|
+
if (partitionIndex != null && partitionCount != null) {
|
|
34
|
+
tokens = tokens.where(sql`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`);
|
|
35
|
+
}
|
|
36
|
+
tokens = tokens.where("created_at", "<", startedAt);
|
|
37
|
+
if (options.excludeRecordIds?.length) {
|
|
38
|
+
tokens = tokens.where("entity_id", "not in", options.excludeRecordIds);
|
|
39
|
+
}
|
|
40
|
+
tokens = tokens.where(sql`not exists (
|
|
41
|
+
select 1 from entity_indexes as surviving
|
|
42
|
+
where surviving.entity_type = ${entityType}
|
|
43
|
+
and surviving.entity_id = search_tokens.entity_id
|
|
44
|
+
and surviving.tenant_id is not distinct from search_tokens.tenant_id
|
|
45
|
+
and surviving.organization_id is not distinct from search_tokens.organization_id
|
|
46
|
+
)`);
|
|
47
|
+
await tokens.execute();
|
|
48
|
+
} catch (searchTokenError) {
|
|
49
|
+
await recordIndexerError(
|
|
50
|
+
{ db },
|
|
51
|
+
{
|
|
52
|
+
source: "fulltext",
|
|
53
|
+
handler: "query_index:purge-orphans",
|
|
54
|
+
error: searchTokenError,
|
|
55
|
+
entityType,
|
|
56
|
+
tenantId: tenantId ?? null,
|
|
57
|
+
organizationId: options.organizationId ?? null
|
|
58
|
+
}
|
|
59
|
+
).catch(() => void 0);
|
|
60
|
+
}
|
|
19
61
|
}
|
|
20
62
|
export {
|
|
21
63
|
purgeOrphans
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/query_index/lib/stale.ts"],
|
|
4
|
-
"sourcesContent": ["import { type Kysely, sql } from 'kysely'\n\ntype PurgeOrphansOptions = {\n entityType: string\n tenantId?: string | null\n organizationId?: string | null\n partitionIndex: number | null\n partitionCount: number | null\n startedAt: Date\n /**\n * Records whose index rows must be preserved even though this run did not refresh\n * them. A row that failed to be written still looks untouched to the `updated_at`\n * predicate below, so without this the purge would delete the very entries the run\n * failed to rebuild.\n */\n excludeRecordIds?: string[]\n}\n\nexport async function purgeOrphans(\n db: Kysely<any>,\n options: PurgeOrphansOptions,\n): Promise<void> {\n const { entityType, tenantId, partitionIndex, partitionCount, startedAt } = options\n let q = db.deleteFrom('entity_indexes' as any).where('entity_type' as any, '=', entityType)\n if (tenantId !== undefined) {\n q = q.where(sql<boolean>`tenant_id is not distinct from ${tenantId ?? null}`)\n }\n if (options.organizationId !== undefined) {\n q = q.where(sql<boolean>`organization_id is not distinct from ${options.organizationId ?? null}`)\n }\n if (partitionIndex != null && partitionCount != null) {\n q = q.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)\n }\n q = q.where('updated_at' as any, '<', startedAt as any)\n if (options.excludeRecordIds?.length) {\n q = q.where('entity_id' as any, 'not in', options.excludeRecordIds)\n }\n await q.execute()\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAsB,WAAW;
|
|
4
|
+
"sourcesContent": ["import { type Kysely, sql } from 'kysely'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\n\ntype PurgeOrphansOptions = {\n entityType: string\n tenantId?: string | null\n organizationId?: string | null\n partitionIndex: number | null\n partitionCount: number | null\n startedAt: Date\n /**\n * Records whose index rows must be preserved even though this run did not refresh\n * them. A row that failed to be written still looks untouched to the `updated_at`\n * predicate below, so without this the purge would delete the very entries the run\n * failed to rebuild.\n */\n excludeRecordIds?: string[]\n}\n\n/**\n * `entity_indexes` ships in an earlier migration than `search_tokens`, so a deployment\n * running this code against a not-yet-migrated database has the projection table and not\n * the token table. The query engine probes the same way before reading the table\n * (`shared/lib/search/availability.ts`).\n */\nasync function searchTokensTableExists(db: Kysely<any>): Promise<boolean> {\n const row = await db\n .selectFrom('information_schema.tables' as any)\n .select(sql<number>`1`.as('one') as any)\n .where('table_name' as any, '=', 'search_tokens')\n .limit(1)\n .executeTakeFirst()\n return !!row\n}\n\nexport async function purgeOrphans(\n db: Kysely<any>,\n options: PurgeOrphansOptions,\n): Promise<void> {\n const { entityType, tenantId, partitionIndex, partitionCount, startedAt } = options\n\n let q = db.deleteFrom('entity_indexes' as any).where('entity_type' as any, '=', entityType)\n if (tenantId !== undefined) {\n q = q.where(sql<boolean>`tenant_id is not distinct from ${tenantId ?? null}`)\n }\n if (options.organizationId !== undefined) {\n q = q.where(sql<boolean>`organization_id is not distinct from ${options.organizationId ?? null}`)\n }\n if (partitionIndex != null && partitionCount != null) {\n q = q.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)\n }\n q = q.where('updated_at' as any, '<', startedAt as any)\n if (options.excludeRecordIds?.length) {\n q = q.where('entity_id' as any, 'not in', options.excludeRecordIds)\n }\n await q.execute()\n\n // The token sweep runs as its own statement, after the projection delete has committed, and\n // deliberately cannot fail the run. Its anti-join only needs those deletes to be *visible*,\n // and a committed delete is as visible as an uncommitted one inside a shared transaction \u2014\n // so wrapping the pair buys no correctness, only a new fatal path: `reindexer.ts` does not\n // guard this call and marks the whole job failed on a throw, so a statement timeout on a\n // large token delete would discard a rebuild whose projections all wrote successfully. The\n // fallback is the pre-fix steady state: orphan tokens survive, benign, until the next run\n // sweeps them. This matches how the module already treats token-side failures \u2014\n // `batch.ts` records a failed token write via `recordIndexerError` rather than counting it\n // as a write failure, and `indexer.ts` swallows the token delete outside a transaction.\n try {\n if (!(await searchTokensTableExists(db))) return\n\n // Search tokens are written from the same (entity_type, entity_id, tenant_id,\n // organization_id) tuple as the projection, but nothing above ever touched them, so\n // every record this run stopped covering kept a queryable token with no presenter \u2014\n // and token-only orphans left by earlier runs survived every rebuild since.\n let tokens = db.deleteFrom('search_tokens' as any).where('entity_type' as any, '=', entityType)\n // `= / is null` rather than the projection sweep's `is not distinct from`: these are bound\n // constants, so the two spellings are semantically identical here, but only the former can\n // serve as a btree index condition. Without it the delete uses just the `entity_type` prefix\n // of `search_tokens_presence_idx (entity_type, tenant_id, organization_id)` and a\n // tenant-scoped, unpartitioned run reads every tenant's tokens for that entity type \u2014\n // the same planner trap `shared/lib/search/availability.ts` documents for this table (#4723).\n // The anti-join below keeps the null-safe form: it compares columns, not constants.\n if (tenantId !== undefined) {\n tokens = tenantId === null\n ? tokens.where('tenant_id' as any, 'is', null)\n : tokens.where('tenant_id' as any, '=', tenantId)\n }\n if (options.organizationId !== undefined) {\n tokens = options.organizationId === null\n ? tokens.where('organization_id' as any, 'is', null)\n : tokens.where('organization_id' as any, '=', options.organizationId)\n }\n if (partitionIndex != null && partitionCount != null) {\n tokens = tokens.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)\n }\n // Secondary guard only. `replaceSearchTokensForBatch` returns early for a record whose\n // tokens are unchanged, so a perfectly current record keeps tokens older than this run\n // \u2014 a timestamp-driven sweep would make live records unfindable. What this predicate\n // does earn is the concurrent-writer case: a batch inserts tokens before the matching\n // projection, so a token written during the run can outrun its own presenter.\n tokens = tokens.where('created_at' as any, '<', startedAt as any)\n if (options.excludeRecordIds?.length) {\n tokens = tokens.where('entity_id' as any, 'not in', options.excludeRecordIds)\n }\n // The load-bearing predicate: a token survives while any projection for its own record\n // and scope survives, soft-deleted ones included. `search_tokens` has a second writer \u2014\n // `@open-mercato/search`'s token strategy writes rows for the same records under its own\n // `field` spellings \u2014 and this sweep is safe for those rows only because that strategy\n // draws its record set from the query engine, i.e. from the very projections consulted\n // here. Changing where that strategy gets its records would make this delete eat its rows.\n tokens = tokens.where(sql<boolean>`not exists (\n select 1 from entity_indexes as surviving\n where surviving.entity_type = ${entityType}\n and surviving.entity_id = search_tokens.entity_id\n and surviving.tenant_id is not distinct from search_tokens.tenant_id\n and surviving.organization_id is not distinct from search_tokens.organization_id\n )`)\n await tokens.execute()\n } catch (searchTokenError) {\n await recordIndexerError(\n { db },\n {\n source: 'fulltext',\n handler: 'query_index:purge-orphans',\n error: searchTokenError,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: options.organizationId ?? null,\n },\n ).catch(() => undefined)\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAsB,WAAW;AACjC,SAAS,0BAA0B;AAwBnC,eAAe,wBAAwB,IAAmC;AACxE,QAAM,MAAM,MAAM,GACf,WAAW,2BAAkC,EAC7C,OAAO,OAAe,GAAG,KAAK,CAAQ,EACtC,MAAM,cAAqB,KAAK,eAAe,EAC/C,MAAM,CAAC,EACP,iBAAiB;AACpB,SAAO,CAAC,CAAC;AACX;AAEA,eAAsB,aACpB,IACA,SACe;AACf,QAAM,EAAE,YAAY,UAAU,gBAAgB,gBAAgB,UAAU,IAAI;AAE5E,MAAI,IAAI,GAAG,WAAW,gBAAuB,EAAE,MAAM,eAAsB,KAAK,UAAU;AAC1F,MAAI,aAAa,QAAW;AAC1B,QAAI,EAAE,MAAM,qCAA8C,YAAY,IAAI,EAAE;AAAA,EAC9E;AACA,MAAI,QAAQ,mBAAmB,QAAW;AACxC,QAAI,EAAE,MAAM,2CAAoD,QAAQ,kBAAkB,IAAI,EAAE;AAAA,EAClG;AACA,MAAI,kBAAkB,QAAQ,kBAAkB,MAAM;AACpD,QAAI,EAAE,MAAM,0CAAmD,cAAc,OAAO,cAAc,EAAE;AAAA,EACtG;AACA,MAAI,EAAE,MAAM,cAAqB,KAAK,SAAgB;AACtD,MAAI,QAAQ,kBAAkB,QAAQ;AACpC,QAAI,EAAE,MAAM,aAAoB,UAAU,QAAQ,gBAAgB;AAAA,EACpE;AACA,QAAM,EAAE,QAAQ;AAYhB,MAAI;AACF,QAAI,CAAE,MAAM,wBAAwB,EAAE,EAAI;AAM1C,QAAI,SAAS,GAAG,WAAW,eAAsB,EAAE,MAAM,eAAsB,KAAK,UAAU;AAQ9F,QAAI,aAAa,QAAW;AAC1B,eAAS,aAAa,OAClB,OAAO,MAAM,aAAoB,MAAM,IAAI,IAC3C,OAAO,MAAM,aAAoB,KAAK,QAAQ;AAAA,IACpD;AACA,QAAI,QAAQ,mBAAmB,QAAW;AACxC,eAAS,QAAQ,mBAAmB,OAChC,OAAO,MAAM,mBAA0B,MAAM,IAAI,IACjD,OAAO,MAAM,mBAA0B,KAAK,QAAQ,cAAc;AAAA,IACxE;AACA,QAAI,kBAAkB,QAAQ,kBAAkB,MAAM;AACpD,eAAS,OAAO,MAAM,0CAAmD,cAAc,OAAO,cAAc,EAAE;AAAA,IAChH;AAMA,aAAS,OAAO,MAAM,cAAqB,KAAK,SAAgB;AAChE,QAAI,QAAQ,kBAAkB,QAAQ;AACpC,eAAS,OAAO,MAAM,aAAoB,UAAU,QAAQ,gBAAgB;AAAA,IAC9E;AAOA,aAAS,OAAO,MAAM;AAAA;AAAA,sCAEY,UAAU;AAAA;AAAA;AAAA;AAAA,MAI1C;AACF,UAAM,OAAO,QAAQ;AAAA,EACvB,SAAS,kBAAkB;AACzB,UAAM;AAAA,MACJ,EAAE,GAAG;AAAA,MACL;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,OAAO;AAAA,QACP;AAAA,QACA,UAAU,YAAY;AAAA,QACtB,gBAAgB,QAAQ,kBAAkB;AAAA,MAC5C;AAAA,IACF,EAAE,MAAM,MAAM,MAAS;AAAA,EACzB;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7296.1.2111d779db",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -253,16 +253,16 @@
|
|
|
253
253
|
"zod": "^4.4.3"
|
|
254
254
|
},
|
|
255
255
|
"peerDependencies": {
|
|
256
|
-
"@open-mercato/ai-assistant": "0.8.1-develop.
|
|
257
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
258
|
-
"@open-mercato/ui": "0.8.1-develop.
|
|
256
|
+
"@open-mercato/ai-assistant": "0.8.1-develop.7296.1.2111d779db",
|
|
257
|
+
"@open-mercato/shared": "0.8.1-develop.7296.1.2111d779db",
|
|
258
|
+
"@open-mercato/ui": "0.8.1-develop.7296.1.2111d779db",
|
|
259
259
|
"react": "^19.0.0",
|
|
260
260
|
"react-dom": "^19.0.0"
|
|
261
261
|
},
|
|
262
262
|
"devDependencies": {
|
|
263
|
-
"@open-mercato/ai-assistant": "0.8.1-develop.
|
|
264
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
265
|
-
"@open-mercato/ui": "0.8.1-develop.
|
|
263
|
+
"@open-mercato/ai-assistant": "0.8.1-develop.7296.1.2111d779db",
|
|
264
|
+
"@open-mercato/shared": "0.8.1-develop.7296.1.2111d779db",
|
|
265
|
+
"@open-mercato/ui": "0.8.1-develop.7296.1.2111d779db",
|
|
266
266
|
"@testing-library/dom": "^10.4.1",
|
|
267
267
|
"@testing-library/jest-dom": "^7.0.1",
|
|
268
268
|
"@testing-library/react": "^16.3.3",
|
|
@@ -39,10 +39,12 @@ import {
|
|
|
39
39
|
type SearchTokenProbeQueryBuilder,
|
|
40
40
|
} from '@open-mercato/shared/lib/search/availability'
|
|
41
41
|
import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'
|
|
42
|
+
import { buildContainmentPatterns } from '@open-mercato/shared/lib/search/containment'
|
|
42
43
|
import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from '@open-mercato/shared/lib/query/query-extension-runner'
|
|
43
44
|
import { warnOnCiphertextLikeFallback } from '@open-mercato/shared/lib/query/ciphertext-search-warning'
|
|
44
45
|
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from '@open-mercato/shared/lib/query/encrypted-sort'
|
|
45
46
|
import { resolveListCountCap } from '@open-mercato/shared/lib/query/count-cap'
|
|
47
|
+
import { resolveCfDefIndexOrgCandidates } from '@open-mercato/shared/lib/crud/custom-field-definition-index'
|
|
46
48
|
import { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'
|
|
47
49
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
48
50
|
import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
|
|
@@ -71,6 +73,9 @@ const CF_FILTER_SUPPORTED_OPS = new Set<FilterOp>([
|
|
|
71
73
|
'eq', 'ne', 'in', 'nin', 'like', 'ilike', 'exists', 'gt', 'gte', 'lt', 'lte',
|
|
72
74
|
])
|
|
73
75
|
|
|
76
|
+
/** Custom field kinds stored numerically (`custom_field_values.value_int`/`value_float`) — sort numerically, not as text (#5674). */
|
|
77
|
+
const NUMERIC_CF_SORT_KINDS = new Set(['integer', 'float'])
|
|
78
|
+
|
|
74
79
|
const DECRYPT_CONCURRENCY = 8
|
|
75
80
|
const AUTO_REINDEX_DEBOUNCE_DEFAULT_MS = 30_000
|
|
76
81
|
const AUTO_REINDEX_DEBOUNCE_MAX_SCOPES = 10_000
|
|
@@ -245,6 +250,7 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
245
250
|
private coverageStatsTtlMs: number
|
|
246
251
|
private customFieldDefsCache = new Map<string, { expiresAt: number; value: CustomFieldDefRow[] }>()
|
|
247
252
|
private customFieldKeysTtlMs: number
|
|
253
|
+
private customFieldKindCache = new Map<string, { expiresAt: number; value: Map<string, string> }>()
|
|
248
254
|
private columnCache = new Map<string, boolean>()
|
|
249
255
|
private customEntityCache = new Map<string, boolean>()
|
|
250
256
|
private debugVerbosity: boolean | null = null
|
|
@@ -568,6 +574,8 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
568
574
|
searchConfig.useIlikeForNonEncryptedFields === true &&
|
|
569
575
|
sourceSearchFilters.some((filter) => !String(filter.field).startsWith('cf:'))
|
|
570
576
|
) {
|
|
577
|
+
// Gated on OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS (default false per #5383; set it
|
|
578
|
+
// to true to opt into the #5803 fix ahead of that follow-up).
|
|
571
579
|
// `ignoreRuntimeHealth` asks the on-disk question -- a column holds ciphertext even while
|
|
572
580
|
// the KMS is down -- so an outage keeps encrypted columns on the token path (#4622).
|
|
573
581
|
// `organizationId: null` is deliberate, not an omission: the service then unions in every
|
|
@@ -781,6 +789,18 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
781
789
|
)
|
|
782
790
|
const requiresPlaintextSort = encryptedSortFields.size > 0
|
|
783
791
|
|
|
792
|
+
const cfSortKeys = Array.from(new Set(
|
|
793
|
+
resolvedSorts.filter((sort) => sort.field.startsWith('cf:')).map((sort) => sort.field.slice(3))
|
|
794
|
+
))
|
|
795
|
+
const cfSortKinds = cfSortKeys.length
|
|
796
|
+
? await this.resolveCustomFieldSortKinds(
|
|
797
|
+
indexSources.map((source) => String(source.entityId)),
|
|
798
|
+
opts.tenantId ?? null,
|
|
799
|
+
resolveCfDefIndexOrgCandidates(opts.organizationIds, fallbackOrgId),
|
|
800
|
+
cfSortKeys,
|
|
801
|
+
)
|
|
802
|
+
: new Map<string, string>()
|
|
803
|
+
|
|
784
804
|
// ────────────────────────────────────────────────────────────────
|
|
785
805
|
// Build a reusable "applyQueryShape" function that applies every
|
|
786
806
|
// WHERE/JOIN/scope to a fresh SelectQueryBuilder. We use this in
|
|
@@ -1188,7 +1208,22 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1188
1208
|
const textExpr = this.buildCfTextExprSql(fieldName, indexSources)
|
|
1189
1209
|
if (textExpr) {
|
|
1190
1210
|
const direction = sql.raw(coerceSortDirection(s.dir))
|
|
1191
|
-
|
|
1211
|
+
const kind = cfSortKinds.get(fieldName.slice(3))
|
|
1212
|
+
let sortExpr: RawBuilder<unknown> = textExpr
|
|
1213
|
+
if (kind && NUMERIC_CF_SORT_KINDS.has(kind)) {
|
|
1214
|
+
const jsonExpr = this.buildCfJsonExprSql(fieldName, indexSources)
|
|
1215
|
+
// The index doc does not guarantee a numeric scalar for a
|
|
1216
|
+
// numeric-kind field (ciphertext on an encrypted field, a JSON
|
|
1217
|
+
// array from a multi-value/duplicated-row source, or a stale
|
|
1218
|
+
// value from before the kind changed) — casting unconditionally
|
|
1219
|
+
// turns one bad row into a 500 for the whole list. Only cast
|
|
1220
|
+
// when the doc value is actually a JSON number; anything else
|
|
1221
|
+
// sorts as NULL, same as an unset field (#5674 review).
|
|
1222
|
+
sortExpr = jsonExpr
|
|
1223
|
+
? sql`CASE WHEN jsonb_typeof(${jsonExpr}) = 'number' THEN (${textExpr})::numeric END`
|
|
1224
|
+
: textExpr
|
|
1225
|
+
}
|
|
1226
|
+
next = next.orderBy(sql`${sortExpr} ${direction} NULLS LAST`)
|
|
1192
1227
|
}
|
|
1193
1228
|
} else {
|
|
1194
1229
|
const baseField = resolveBaseColumn(fieldName)
|
|
@@ -1196,6 +1231,10 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1196
1231
|
next = next.orderBy(qualify(baseField), s.dir ?? SortDir.Asc)
|
|
1197
1232
|
}
|
|
1198
1233
|
}
|
|
1234
|
+
const lastSortField = resolvedSorts.length ? String(resolvedSorts[resolvedSorts.length - 1].field) : null
|
|
1235
|
+
if (resolvedSorts.length && lastSortField !== 'id') {
|
|
1236
|
+
next = next.orderBy(qualify('id'), SortDir.Asc)
|
|
1237
|
+
}
|
|
1199
1238
|
return next
|
|
1200
1239
|
}
|
|
1201
1240
|
|
|
@@ -1995,6 +2034,20 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1995
2034
|
? sql<boolean>`false`
|
|
1996
2035
|
: sql<boolean>`true`
|
|
1997
2036
|
}
|
|
2037
|
+
// Reaching here with a resolved encryption map means this is a PLAINTEXT column the gate above
|
|
2038
|
+
// deliberately kept off the token path. Apply the declared containment per word so the token
|
|
2039
|
+
// path's word-order-independent matching survives the reroute (#5803 / TC-RESO-009).
|
|
2040
|
+
if (
|
|
2041
|
+
(filter.op === 'like' || filter.op === 'ilike') &&
|
|
2042
|
+
typeof filter.value === 'string' &&
|
|
2043
|
+
searchRuntime?.enabled &&
|
|
2044
|
+
searchRuntime.encryptedFields != null
|
|
2045
|
+
) {
|
|
2046
|
+
const patterns = buildContainmentPatterns(filter.value)
|
|
2047
|
+
if (patterns.length > 1) {
|
|
2048
|
+
return eb.and(patterns.map((pattern) => eb(qualify(baseField), filter.op, pattern)))
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
1998
2051
|
return this.buildColumnFilterExpression(eb, qualify(baseField), filter.op, filter.value)
|
|
1999
2052
|
}
|
|
2000
2053
|
|
|
@@ -2277,6 +2330,64 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2277
2330
|
return result.slice()
|
|
2278
2331
|
}
|
|
2279
2332
|
|
|
2333
|
+
/**
|
|
2334
|
+
* Resolve the declared `kind` for a set of `cf:` sort keys so `applySort` can
|
|
2335
|
+
* cast numeric kinds instead of ordering by jsonb text (#5674). A same-org
|
|
2336
|
+
* definition wins over a tenant-wide one, which wins over a global one, for
|
|
2337
|
+
* the same key — a same-key definition scoped to a *different* organization
|
|
2338
|
+
* in the tenant never gets to decide the kind (#5674 review).
|
|
2339
|
+
*/
|
|
2340
|
+
private async resolveCustomFieldSortKinds(
|
|
2341
|
+
entityIds: string[],
|
|
2342
|
+
tenantId: string | null,
|
|
2343
|
+
organizationIds: string[],
|
|
2344
|
+
keys: string[]
|
|
2345
|
+
): Promise<Map<string, string>> {
|
|
2346
|
+
if (!entityIds.length || !keys.length) return new Map()
|
|
2347
|
+
const sortedOrgIds = organizationIds.slice().sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
2348
|
+
const cacheKey = `${this.customFieldKeysCacheKey(entityIds, tenantId)}|org:${sortedOrgIds.join(',')}|${keys.slice().sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(',')}`
|
|
2349
|
+
const now = Date.now()
|
|
2350
|
+
const cached = this.customFieldKindCache.get(cacheKey)
|
|
2351
|
+
if (cached && cached.expiresAt > now) return new Map(cached.value)
|
|
2352
|
+
|
|
2353
|
+
const db = this.getDb() as any
|
|
2354
|
+
const rows = await db
|
|
2355
|
+
.selectFrom('custom_field_defs')
|
|
2356
|
+
.select(['key', 'kind', 'organization_id', 'tenant_id'])
|
|
2357
|
+
.where('entity_id', 'in', entityIds)
|
|
2358
|
+
.where('key', 'in', keys)
|
|
2359
|
+
.where('is_active', '=', true)
|
|
2360
|
+
.where((eb: any) => eb.or([
|
|
2361
|
+
eb('tenant_id', '=', tenantId),
|
|
2362
|
+
eb('tenant_id', 'is', null),
|
|
2363
|
+
]))
|
|
2364
|
+
.where((eb: any) => sortedOrgIds.length
|
|
2365
|
+
? eb.or([
|
|
2366
|
+
eb('organization_id', 'is', null),
|
|
2367
|
+
eb('organization_id', 'in', sortedOrgIds),
|
|
2368
|
+
])
|
|
2369
|
+
: eb('organization_id', 'is', null))
|
|
2370
|
+
.execute() as Array<{ key: unknown; kind: unknown; organization_id: unknown; tenant_id: unknown }>
|
|
2371
|
+
|
|
2372
|
+
const winners = new Map<string, { kind: string; specificity: number }>()
|
|
2373
|
+
for (const row of rows) {
|
|
2374
|
+
const key = typeof row.key === 'string' ? row.key : String(row.key)
|
|
2375
|
+
const kind = typeof row.kind === 'string' ? row.kind : null
|
|
2376
|
+
if (!kind) continue
|
|
2377
|
+
// Org-scoped beats tenant-wide beats global for the same key; rows from
|
|
2378
|
+
// an unrelated org were already excluded by the WHERE clause above.
|
|
2379
|
+
const specificity = row.organization_id != null ? 2 : row.tenant_id != null ? 1 : 0
|
|
2380
|
+
const existing = winners.get(key)
|
|
2381
|
+
if (!existing || specificity >= existing.specificity) winners.set(key, { kind, specificity })
|
|
2382
|
+
}
|
|
2383
|
+
const result = new Map<string, string>()
|
|
2384
|
+
for (const [key, entry] of winners) result.set(key, entry.kind)
|
|
2385
|
+
if (this.customFieldKeysTtlMs > 0) {
|
|
2386
|
+
this.customFieldKindCache.set(cacheKey, { expiresAt: now + this.customFieldKeysTtlMs, value: new Map(result) })
|
|
2387
|
+
}
|
|
2388
|
+
return result
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2280
2391
|
private async resolveAvailableCustomFieldKeys(entityIds: string[], tenantId: string | null): Promise<string[]> {
|
|
2281
2392
|
const defs = await this.resolveCustomFieldDefs(entityIds, tenantId)
|
|
2282
2393
|
return Array.from(new Set(defs.map((def) => def.key)))
|
|
@@ -2735,6 +2846,21 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2735
2846
|
return q
|
|
2736
2847
|
}
|
|
2737
2848
|
const col: any = column
|
|
2849
|
+
// A PLAINTEXT column the gate kept off the token path: AND one containment predicate per word,
|
|
2850
|
+
// which is what the token subquery matched (#5803 / TC-RESO-009). Chained `where`s ARE the AND.
|
|
2851
|
+
if (
|
|
2852
|
+
(filter.op === 'like' || filter.op === 'ilike') &&
|
|
2853
|
+
typeof filter.value === 'string' &&
|
|
2854
|
+
search?.enabled &&
|
|
2855
|
+
search.encryptedFields != null
|
|
2856
|
+
) {
|
|
2857
|
+
const patterns = buildContainmentPatterns(filter.value)
|
|
2858
|
+
if (patterns.length > 1) {
|
|
2859
|
+
let next = q
|
|
2860
|
+
for (const pattern of patterns) next = next.where(col, filter.op, pattern as any)
|
|
2861
|
+
return next
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2738
2864
|
switch (filter.op) {
|
|
2739
2865
|
case 'eq':
|
|
2740
2866
|
return filter.value === null
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Kysely, sql } from 'kysely'
|
|
2
|
+
import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
|
|
2
3
|
|
|
3
4
|
type PurgeOrphansOptions = {
|
|
4
5
|
entityType: string
|
|
@@ -16,11 +17,28 @@ type PurgeOrphansOptions = {
|
|
|
16
17
|
excludeRecordIds?: string[]
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* `entity_indexes` ships in an earlier migration than `search_tokens`, so a deployment
|
|
22
|
+
* running this code against a not-yet-migrated database has the projection table and not
|
|
23
|
+
* the token table. The query engine probes the same way before reading the table
|
|
24
|
+
* (`shared/lib/search/availability.ts`).
|
|
25
|
+
*/
|
|
26
|
+
async function searchTokensTableExists(db: Kysely<any>): Promise<boolean> {
|
|
27
|
+
const row = await db
|
|
28
|
+
.selectFrom('information_schema.tables' as any)
|
|
29
|
+
.select(sql<number>`1`.as('one') as any)
|
|
30
|
+
.where('table_name' as any, '=', 'search_tokens')
|
|
31
|
+
.limit(1)
|
|
32
|
+
.executeTakeFirst()
|
|
33
|
+
return !!row
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
export async function purgeOrphans(
|
|
20
37
|
db: Kysely<any>,
|
|
21
38
|
options: PurgeOrphansOptions,
|
|
22
39
|
): Promise<void> {
|
|
23
40
|
const { entityType, tenantId, partitionIndex, partitionCount, startedAt } = options
|
|
41
|
+
|
|
24
42
|
let q = db.deleteFrom('entity_indexes' as any).where('entity_type' as any, '=', entityType)
|
|
25
43
|
if (tenantId !== undefined) {
|
|
26
44
|
q = q.where(sql<boolean>`tenant_id is not distinct from ${tenantId ?? null}`)
|
|
@@ -36,4 +54,79 @@ export async function purgeOrphans(
|
|
|
36
54
|
q = q.where('entity_id' as any, 'not in', options.excludeRecordIds)
|
|
37
55
|
}
|
|
38
56
|
await q.execute()
|
|
57
|
+
|
|
58
|
+
// The token sweep runs as its own statement, after the projection delete has committed, and
|
|
59
|
+
// deliberately cannot fail the run. Its anti-join only needs those deletes to be *visible*,
|
|
60
|
+
// and a committed delete is as visible as an uncommitted one inside a shared transaction —
|
|
61
|
+
// so wrapping the pair buys no correctness, only a new fatal path: `reindexer.ts` does not
|
|
62
|
+
// guard this call and marks the whole job failed on a throw, so a statement timeout on a
|
|
63
|
+
// large token delete would discard a rebuild whose projections all wrote successfully. The
|
|
64
|
+
// fallback is the pre-fix steady state: orphan tokens survive, benign, until the next run
|
|
65
|
+
// sweeps them. This matches how the module already treats token-side failures —
|
|
66
|
+
// `batch.ts` records a failed token write via `recordIndexerError` rather than counting it
|
|
67
|
+
// as a write failure, and `indexer.ts` swallows the token delete outside a transaction.
|
|
68
|
+
try {
|
|
69
|
+
if (!(await searchTokensTableExists(db))) return
|
|
70
|
+
|
|
71
|
+
// Search tokens are written from the same (entity_type, entity_id, tenant_id,
|
|
72
|
+
// organization_id) tuple as the projection, but nothing above ever touched them, so
|
|
73
|
+
// every record this run stopped covering kept a queryable token with no presenter —
|
|
74
|
+
// and token-only orphans left by earlier runs survived every rebuild since.
|
|
75
|
+
let tokens = db.deleteFrom('search_tokens' as any).where('entity_type' as any, '=', entityType)
|
|
76
|
+
// `= / is null` rather than the projection sweep's `is not distinct from`: these are bound
|
|
77
|
+
// constants, so the two spellings are semantically identical here, but only the former can
|
|
78
|
+
// serve as a btree index condition. Without it the delete uses just the `entity_type` prefix
|
|
79
|
+
// of `search_tokens_presence_idx (entity_type, tenant_id, organization_id)` and a
|
|
80
|
+
// tenant-scoped, unpartitioned run reads every tenant's tokens for that entity type —
|
|
81
|
+
// the same planner trap `shared/lib/search/availability.ts` documents for this table (#4723).
|
|
82
|
+
// The anti-join below keeps the null-safe form: it compares columns, not constants.
|
|
83
|
+
if (tenantId !== undefined) {
|
|
84
|
+
tokens = tenantId === null
|
|
85
|
+
? tokens.where('tenant_id' as any, 'is', null)
|
|
86
|
+
: tokens.where('tenant_id' as any, '=', tenantId)
|
|
87
|
+
}
|
|
88
|
+
if (options.organizationId !== undefined) {
|
|
89
|
+
tokens = options.organizationId === null
|
|
90
|
+
? tokens.where('organization_id' as any, 'is', null)
|
|
91
|
+
: tokens.where('organization_id' as any, '=', options.organizationId)
|
|
92
|
+
}
|
|
93
|
+
if (partitionIndex != null && partitionCount != null) {
|
|
94
|
+
tokens = tokens.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)
|
|
95
|
+
}
|
|
96
|
+
// Secondary guard only. `replaceSearchTokensForBatch` returns early for a record whose
|
|
97
|
+
// tokens are unchanged, so a perfectly current record keeps tokens older than this run
|
|
98
|
+
// — a timestamp-driven sweep would make live records unfindable. What this predicate
|
|
99
|
+
// does earn is the concurrent-writer case: a batch inserts tokens before the matching
|
|
100
|
+
// projection, so a token written during the run can outrun its own presenter.
|
|
101
|
+
tokens = tokens.where('created_at' as any, '<', startedAt as any)
|
|
102
|
+
if (options.excludeRecordIds?.length) {
|
|
103
|
+
tokens = tokens.where('entity_id' as any, 'not in', options.excludeRecordIds)
|
|
104
|
+
}
|
|
105
|
+
// The load-bearing predicate: a token survives while any projection for its own record
|
|
106
|
+
// and scope survives, soft-deleted ones included. `search_tokens` has a second writer —
|
|
107
|
+
// `@open-mercato/search`'s token strategy writes rows for the same records under its own
|
|
108
|
+
// `field` spellings — and this sweep is safe for those rows only because that strategy
|
|
109
|
+
// draws its record set from the query engine, i.e. from the very projections consulted
|
|
110
|
+
// here. Changing where that strategy gets its records would make this delete eat its rows.
|
|
111
|
+
tokens = tokens.where(sql<boolean>`not exists (
|
|
112
|
+
select 1 from entity_indexes as surviving
|
|
113
|
+
where surviving.entity_type = ${entityType}
|
|
114
|
+
and surviving.entity_id = search_tokens.entity_id
|
|
115
|
+
and surviving.tenant_id is not distinct from search_tokens.tenant_id
|
|
116
|
+
and surviving.organization_id is not distinct from search_tokens.organization_id
|
|
117
|
+
)`)
|
|
118
|
+
await tokens.execute()
|
|
119
|
+
} catch (searchTokenError) {
|
|
120
|
+
await recordIndexerError(
|
|
121
|
+
{ db },
|
|
122
|
+
{
|
|
123
|
+
source: 'fulltext',
|
|
124
|
+
handler: 'query_index:purge-orphans',
|
|
125
|
+
error: searchTokenError,
|
|
126
|
+
entityType,
|
|
127
|
+
tenantId: tenantId ?? null,
|
|
128
|
+
organizationId: options.organizationId ?? null,
|
|
129
|
+
},
|
|
130
|
+
).catch(() => undefined)
|
|
131
|
+
}
|
|
39
132
|
}
|