@open-mercato/core 0.6.8-develop.7012.1.1dbd6f5fbd → 0.6.8-develop.7015.1.af90a2ddc7
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/customers/api/companies/[id]/people/route.js +19 -63
- package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +9 -85
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js +4 -3
- package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js.map +2 -2
- package/dist/modules/customers/commands/personCompanyLinks.js +131 -2
- package/dist/modules/customers/commands/personCompanyLinks.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js +3 -1
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonCompaniesSection.js +3 -1
- package/dist/modules/customers/components/detail/PersonCompaniesSection.js.map +2 -2
- package/dist/modules/customers/data/validators.js +10 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/events.js +5 -0
- package/dist/modules/customers/events.js.map +2 -2
- package/dist/modules/customers/lib/personCompanies.js +100 -2
- package/dist/modules/customers/lib/personCompanies.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +79 -3
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/api/companies/[id]/people/route.ts +20 -72
- package/src/modules/customers/api/companies/[id]/route.ts +11 -97
- package/src/modules/customers/api/people/[id]/companies/[linkId]/route.ts +12 -4
- package/src/modules/customers/commands/personCompanyLinks.ts +224 -8
- package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +8 -1
- package/src/modules/customers/components/detail/PersonCompaniesSection.tsx +8 -1
- package/src/modules/customers/data/validators.ts +18 -3
- package/src/modules/customers/events.ts +5 -0
- package/src/modules/customers/lib/personCompanies.ts +136 -1
- package/src/modules/query_index/lib/search-tokens.ts +135 -3
|
@@ -223,6 +223,49 @@ export async function deleteSearchTokensForRecord(
|
|
|
223
223
|
.execute()
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`
|
|
227
|
+
// would otherwise sign identically to field `a` + hash `b c`.
|
|
228
|
+
const SIGNATURE_SEPARATOR = String.fromCharCode(0)
|
|
229
|
+
|
|
230
|
+
// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a
|
|
231
|
+
// stored NULL has to sign the same as the `null` a freshly built row carries — otherwise every
|
|
232
|
+
// record compares as changed and the skip never fires.
|
|
233
|
+
function tokenSignature(row: { field?: unknown; token_hash?: unknown; token?: unknown }): string {
|
|
234
|
+
return [
|
|
235
|
+
String(row.field ?? ''),
|
|
236
|
+
String(row.token_hash ?? ''),
|
|
237
|
+
row.token == null ? '' : String(row.token),
|
|
238
|
+
].join(SIGNATURE_SEPARATOR)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement
|
|
242
|
+
// defect, and a set comparison reads such a record as already correct and preserves the duplicates
|
|
243
|
+
// forever. Counting sends it through a full rewrite, which collapses them.
|
|
244
|
+
function tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {
|
|
245
|
+
const left = a ?? new Map<string, number>()
|
|
246
|
+
const right = b ?? new Map<string, number>()
|
|
247
|
+
if (left.size !== right.size) return false
|
|
248
|
+
for (const [key, count] of left.entries()) {
|
|
249
|
+
if (right.get(key) !== count) return false
|
|
250
|
+
}
|
|
251
|
+
return true
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function tallyTokenRows<TRow extends { field?: unknown; token_hash?: unknown; token?: unknown }>(
|
|
255
|
+
rows: Iterable<TRow>,
|
|
256
|
+
keyOf: (row: TRow) => string
|
|
257
|
+
): Map<string, Map<string, number>> {
|
|
258
|
+
const tallies = new Map<string, Map<string, number>>()
|
|
259
|
+
for (const row of rows) {
|
|
260
|
+
const key = keyOf(row)
|
|
261
|
+
const tally = tallies.get(key) ?? new Map<string, number>()
|
|
262
|
+
const signature = tokenSignature(row)
|
|
263
|
+
tally.set(signature, (tally.get(signature) ?? 0) + 1)
|
|
264
|
+
tallies.set(key, tally)
|
|
265
|
+
}
|
|
266
|
+
return tallies
|
|
267
|
+
}
|
|
268
|
+
|
|
226
269
|
export async function replaceSearchTokensForBatch(
|
|
227
270
|
db: Kysely<any>,
|
|
228
271
|
payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>
|
|
@@ -256,8 +299,95 @@ export async function replaceSearchTokensForBatch(
|
|
|
256
299
|
scopeBuckets.set(key, bucket)
|
|
257
300
|
}
|
|
258
301
|
|
|
302
|
+
const recordKeyOf = (row: SearchTokenRow) =>
|
|
303
|
+
`${scopeKey(row.organization_id ?? null, row.tenant_id ?? null)}|${String(row.entity_id)}`
|
|
304
|
+
const builtTally = tallyTokenRows(rows, recordKeyOf)
|
|
305
|
+
|
|
306
|
+
// Read outside the transaction, deliberately. The comparison decides only whether to skip a
|
|
307
|
+
// rewrite, so a concurrent writer costs us at most a rewrite we declined — declined because the
|
|
308
|
+
// table already held exactly the rows this call wanted to write. One ordering is worth naming
|
|
309
|
+
// though: if the read matches and a concurrent writer then commits tokens built from a *staler*
|
|
310
|
+
// doc, the unconditional rewrite this call used to perform would have overwritten them by
|
|
311
|
+
// accident. It no longer does, so those stale rows survive until the record's next write. That
|
|
312
|
+
// is a repair we lose, not a guarantee we break.
|
|
313
|
+
const changedIdsByBucket = new Map<string, Set<string>>()
|
|
314
|
+
for (const [key, bucket] of scopeBuckets.entries()) {
|
|
315
|
+
const ids = Array.from(bucket.ids)
|
|
316
|
+
const builtCountById = new Map<string, number>()
|
|
317
|
+
for (const id of ids) {
|
|
318
|
+
let total = 0
|
|
319
|
+
const tally = builtTally.get(`${key}|${id}`)
|
|
320
|
+
if (tally) for (const count of tally.values()) total += count
|
|
321
|
+
builtCountById.set(id, total)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Count probe first. Its result is one row per record in the batch, so it is bounded by the
|
|
325
|
+
// batch size — unlike a bare row read, which would be bounded only by how many token rows the
|
|
326
|
+
// table already holds for these ids, a quantity this function does not control and (per #4681)
|
|
327
|
+
// has no reason to trust.
|
|
328
|
+
const storedCounts = await db
|
|
329
|
+
.selectFrom('search_tokens' as any)
|
|
330
|
+
.select(['entity_id' as any, sql<number>`count(*)`.as('token_count') as any])
|
|
331
|
+
.where('entity_type' as any, '=', payloads[0].entityType)
|
|
332
|
+
.where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)
|
|
333
|
+
.where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)
|
|
334
|
+
.where('entity_id' as any, 'in', ids)
|
|
335
|
+
.groupBy('entity_id' as any)
|
|
336
|
+
.execute()
|
|
337
|
+
const storedCountById = new Map<string, number>()
|
|
338
|
+
for (const row of storedCounts as any[]) {
|
|
339
|
+
storedCountById.set(String(row.entity_id), Number(row.token_count))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const changed = new Set<string>()
|
|
343
|
+
// A record whose stored row count already differs is changed, whatever the rows say — the
|
|
344
|
+
// duplicate case from #4681 resolves here without ever materializing the duplicated rows.
|
|
345
|
+
const contentCandidates = ids.filter((id) => {
|
|
346
|
+
const builtCount = builtCountById.get(id) ?? 0
|
|
347
|
+
if ((storedCountById.get(id) ?? 0) !== builtCount) {
|
|
348
|
+
changed.add(id)
|
|
349
|
+
return false
|
|
350
|
+
}
|
|
351
|
+
return builtCount > 0
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
if (contentCandidates.length) {
|
|
355
|
+
const rowBudget = contentCandidates.reduce((sum, id) => sum + (builtCountById.get(id) ?? 0), 0)
|
|
356
|
+
const stored = await db
|
|
357
|
+
.selectFrom('search_tokens' as any)
|
|
358
|
+
.select(['entity_id' as any, 'field' as any, 'token_hash' as any, 'token' as any])
|
|
359
|
+
.where('entity_type' as any, '=', payloads[0].entityType)
|
|
360
|
+
.where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)
|
|
361
|
+
.where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)
|
|
362
|
+
.where('entity_id' as any, 'in', contentCandidates)
|
|
363
|
+
// Counts already match, so this cannot truncate — it bounds the damage if a concurrent
|
|
364
|
+
// writer inserts between the probe and this read. A truncated read compares as changed,
|
|
365
|
+
// which costs a rewrite rather than a wrong skip.
|
|
366
|
+
.limit(rowBudget)
|
|
367
|
+
.execute()
|
|
368
|
+
const storedTally = tallyTokenRows(stored as any[], (row) => String(row.entity_id))
|
|
369
|
+
for (const id of contentCandidates) {
|
|
370
|
+
if (!tallyEquals(builtTally.get(`${key}|${id}`), storedTally.get(id))) changed.add(id)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
changedIdsByBucket.set(key, changed)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const changedRecordKeys = new Set<string>()
|
|
377
|
+
for (const [key, changed] of changedIdsByBucket.entries()) {
|
|
378
|
+
for (const id of changed) changedRecordKeys.add(`${key}|${id}`)
|
|
379
|
+
}
|
|
380
|
+
debug('batch.skip', {
|
|
381
|
+
entityType: payloads[0].entityType,
|
|
382
|
+
recordCount: payloads.length,
|
|
383
|
+
changedCount: changedRecordKeys.size,
|
|
384
|
+
})
|
|
385
|
+
if (!changedRecordKeys.size) return
|
|
386
|
+
|
|
259
387
|
await db.transaction().execute(async (trx) => {
|
|
260
|
-
for (const [, bucket] of scopeBuckets.entries()) {
|
|
388
|
+
for (const [key, bucket] of scopeBuckets.entries()) {
|
|
389
|
+
const changed = changedIdsByBucket.get(key)
|
|
390
|
+
if (!changed?.size) continue
|
|
261
391
|
// Delete by entity_id: a batch replaces all of a record's tokens, and a per-field OR over the
|
|
262
392
|
// whole batch overflows the query compiler's call stack on large batches.
|
|
263
393
|
const deleteQuery = trx
|
|
@@ -265,10 +395,12 @@ export async function replaceSearchTokensForBatch(
|
|
|
265
395
|
.where('entity_type' as any, '=', payloads[0].entityType)
|
|
266
396
|
.where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)
|
|
267
397
|
.where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)
|
|
268
|
-
.where('entity_id' as any, 'in', Array.from(
|
|
398
|
+
.where('entity_id' as any, 'in', Array.from(changed))
|
|
269
399
|
await deleteQuery.execute()
|
|
270
400
|
}
|
|
271
|
-
const payloadWithTimestamps = rows
|
|
401
|
+
const payloadWithTimestamps = rows
|
|
402
|
+
.filter((row) => changedRecordKeys.has(recordKeyOf(row)))
|
|
403
|
+
.map((row) => ({ ...row, created_at: sql`now()` }))
|
|
272
404
|
for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {
|
|
273
405
|
await trx.insertInto('search_tokens' as any).values(batch as any).execute()
|
|
274
406
|
}
|