@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.
Files changed (31) hide show
  1. package/dist/modules/customers/api/companies/[id]/people/route.js +19 -63
  2. package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
  3. package/dist/modules/customers/api/companies/[id]/route.js +9 -85
  4. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  5. package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js +4 -3
  6. package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js.map +2 -2
  7. package/dist/modules/customers/commands/personCompanyLinks.js +131 -2
  8. package/dist/modules/customers/commands/personCompanyLinks.js.map +2 -2
  9. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +3 -1
  10. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  11. package/dist/modules/customers/components/detail/PersonCompaniesSection.js +3 -1
  12. package/dist/modules/customers/components/detail/PersonCompaniesSection.js.map +2 -2
  13. package/dist/modules/customers/data/validators.js +10 -2
  14. package/dist/modules/customers/data/validators.js.map +2 -2
  15. package/dist/modules/customers/events.js +5 -0
  16. package/dist/modules/customers/events.js.map +2 -2
  17. package/dist/modules/customers/lib/personCompanies.js +100 -2
  18. package/dist/modules/customers/lib/personCompanies.js.map +2 -2
  19. package/dist/modules/query_index/lib/search-tokens.js +79 -3
  20. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  21. package/package.json +7 -7
  22. package/src/modules/customers/api/companies/[id]/people/route.ts +20 -72
  23. package/src/modules/customers/api/companies/[id]/route.ts +11 -97
  24. package/src/modules/customers/api/people/[id]/companies/[linkId]/route.ts +12 -4
  25. package/src/modules/customers/commands/personCompanyLinks.ts +224 -8
  26. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +8 -1
  27. package/src/modules/customers/components/detail/PersonCompaniesSection.tsx +8 -1
  28. package/src/modules/customers/data/validators.ts +18 -3
  29. package/src/modules/customers/events.ts +5 -0
  30. package/src/modules/customers/lib/personCompanies.ts +136 -1
  31. 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(bucket.ids))
398
+ .where('entity_id' as any, 'in', Array.from(changed))
269
399
  await deleteQuery.execute()
270
400
  }
271
- const payloadWithTimestamps = rows.map((row) => ({ ...row, created_at: sql`now()` }))
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
  }