@open-mercato/core 0.7.1-develop.7113.1.9d83dca10c → 0.7.1-develop.7121.1.734c263bda

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 (95) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/crmFixtures.js.map +1 -1
  3. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -2
  4. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
  5. package/dist/modules/communication_channels/lib/outbound-recipient.js +5 -1
  6. package/dist/modules/communication_channels/lib/outbound-recipient.js.map +2 -2
  7. package/dist/modules/communication_channels/lib/test-seed.js +5 -0
  8. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  9. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +1 -1
  10. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +1 -1
  11. package/dist/modules/customers/backend/customers/deals/page.js +4 -3
  12. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  13. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +2 -2
  14. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
  15. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +1 -1
  16. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +1 -1
  17. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  18. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  19. package/dist/modules/customers/cli.js +5 -5
  20. package/dist/modules/customers/cli.js.map +2 -2
  21. package/dist/modules/customers/commands/deals.js.map +2 -2
  22. package/dist/modules/customers/components/detail/pipelineStageUtils.js +1 -0
  23. package/dist/modules/customers/components/detail/pipelineStageUtils.js.map +2 -2
  24. package/dist/modules/customers/components/linking/adapters/dealAdapter.js +2 -2
  25. package/dist/modules/customers/components/linking/adapters/dealAdapter.js.map +2 -2
  26. package/dist/modules/customers/lib/closureStage.js.map +2 -2
  27. package/dist/modules/customers/lib/dealStatus.js +6 -4
  28. package/dist/modules/customers/lib/dealStatus.js.map +2 -2
  29. package/dist/modules/customers/lib/dealsSummaryQueries.js +3 -3
  30. package/dist/modules/customers/lib/dealsSummaryQueries.js.map +1 -1
  31. package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js +30 -0
  32. package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js.map +7 -0
  33. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +1 -1
  34. package/dist/modules/eudr/notifications.js +5 -0
  35. package/dist/modules/eudr/notifications.js.map +2 -2
  36. package/dist/modules/query_index/lib/search-tokens.js +59 -28
  37. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  38. package/dist/modules/sales/commands/documents.js +146 -99
  39. package/dist/modules/sales/commands/documents.js.map +2 -2
  40. package/dist/modules/sales/commands/returns.js +1 -30
  41. package/dist/modules/sales/commands/returns.js.map +2 -2
  42. package/dist/modules/sales/commands/shared.js +1 -4
  43. package/dist/modules/sales/commands/shared.js.map +2 -2
  44. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +2 -1
  45. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +2 -2
  46. package/dist/modules/sales/data/validators.js +3 -0
  47. package/dist/modules/sales/data/validators.js.map +2 -2
  48. package/dist/modules/sales/lib/calculations.js +14 -2
  49. package/dist/modules/sales/lib/calculations.js.map +2 -2
  50. package/dist/modules/sales/lib/json.js +8 -0
  51. package/dist/modules/sales/lib/json.js.map +7 -0
  52. package/dist/modules/sales/lib/lineSnapshots.js +66 -0
  53. package/dist/modules/sales/lib/lineSnapshots.js.map +7 -0
  54. package/dist/modules/warranty_claims/notifications.js +5 -0
  55. package/dist/modules/warranty_claims/notifications.js.map +2 -2
  56. package/dist/modules/wms/notifications.js +2 -0
  57. package/dist/modules/wms/notifications.js.map +2 -2
  58. package/package.json +7 -7
  59. package/src/helpers/integration/crmFixtures.ts +1 -1
  60. package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +21 -9
  61. package/src/modules/communication_channels/lib/adapter.ts +11 -0
  62. package/src/modules/communication_channels/lib/outbound-recipient.ts +14 -1
  63. package/src/modules/communication_channels/lib/test-seed.ts +19 -0
  64. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +1 -1
  65. package/src/modules/customers/backend/customers/deals/page.tsx +4 -3
  66. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +5 -5
  67. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +3 -3
  68. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2 -2
  69. package/src/modules/customers/cli.ts +5 -5
  70. package/src/modules/customers/commands/deals.ts +4 -3
  71. package/src/modules/customers/components/detail/pipelineStageUtils.ts +1 -0
  72. package/src/modules/customers/components/linking/adapters/dealAdapter.tsx +2 -2
  73. package/src/modules/customers/i18n/de.json +1 -1
  74. package/src/modules/customers/i18n/en.json +1 -1
  75. package/src/modules/customers/i18n/es.json +1 -1
  76. package/src/modules/customers/i18n/ko.json +1 -1
  77. package/src/modules/customers/i18n/pl.json +1 -1
  78. package/src/modules/customers/lib/closureStage.ts +2 -1
  79. package/src/modules/customers/lib/dealStatus.ts +23 -12
  80. package/src/modules/customers/lib/dealsSummaryQueries.ts +3 -3
  81. package/src/modules/customers/migrations/Migration20260824180000_deal_status_lost.ts +55 -0
  82. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +1 -1
  83. package/src/modules/eudr/notifications.ts +5 -0
  84. package/src/modules/query_index/lib/search-tokens.ts +100 -43
  85. package/src/modules/sales/commands/documents.ts +185 -105
  86. package/src/modules/sales/commands/returns.ts +1 -31
  87. package/src/modules/sales/commands/shared.ts +1 -4
  88. package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +12 -1
  89. package/src/modules/sales/data/validators.ts +3 -0
  90. package/src/modules/sales/lib/calculations.ts +33 -7
  91. package/src/modules/sales/lib/json.ts +4 -0
  92. package/src/modules/sales/lib/lineSnapshots.ts +98 -0
  93. package/src/modules/sales/lib/types.ts +20 -0
  94. package/src/modules/warranty_claims/notifications.ts +5 -0
  95. package/src/modules/wms/notifications.ts +2 -0
@@ -1,25 +1,36 @@
1
1
  // Single source of truth for deal open/closed (terminal) semantics.
2
2
  //
3
3
  // `customer_deals.status` is a lenient text column and the supported writers do not agree on
4
- // one spelling: the closure hooks and the kanban board persist `win` / `loose`, the AI tool
5
- // `customers.update_deal_stage` persists `won` / `lost`, and the seeded vocabulary also carries
6
- // `closed`. The terminal set below therefore covers every spelling a status writer persists, so
7
- // status-based readers can share one classification. `closureOutcome` is a separate signal and
8
- // callers that combine both columns must handle it explicitly.
4
+ // one spelling: the closure hooks and the kanban board persist `win` / `lost`, the AI tool
5
+ // `customers.update_deal_stage` persists `won` / `lost`, the seeded vocabulary also carries
6
+ // `closed`, and rows written before 0.7.1 carry the misspelled `loose`. The terminal set
7
+ // below therefore covers every spelling a status writer persists, so status-based readers
8
+ // can share one classification. `closureOutcome` is a separate signal, and callers that
9
+ // combine both columns must handle it explicitly.
9
10
  //
10
11
  // A status not known to code counts as OPEN. Deal stages are configurable per tenant, so the
11
12
  // lenient column can hold values this module has never seen — treating them as open keeps them
12
13
  // visible in active-deal counts instead of silently reclassifying them as closed.
13
14
 
14
15
  export const DEAL_STATUS_WIN = 'win' as const
15
- export const DEAL_STATUS_LOSE = 'loose' as const
16
+ export const DEAL_STATUS_LOST = 'lost' as const
16
17
  export const DEAL_STATUS_CLOSED = 'closed' as const
17
18
 
18
- // `won` / `lost` are the spellings the AI stage tool writes; `win` / `loose` are what the UI
19
- // closure flows persist. Both are accepted so neither writer produces an unreadable status.
19
+ /**
20
+ * @deprecated Misspelling of the lost-deal status, canonical until 0.7.0. Writers now
21
+ * persist {@link DEAL_STATUS_LOST}. This export stays so downstream modules keep
22
+ * compiling and readers keep matching rows written before the 0.7.1 migration; it is
23
+ * scheduled for removal no earlier than 0.9.0.
24
+ */
25
+ export const DEAL_STATUS_LOSE = 'loose' as const
26
+
27
+ // `won` is the spelling the AI stage tool writes; `win` is what the UI closure flows
28
+ // persist. Both are accepted so neither writer produces an unreadable status.
20
29
  export const WON_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_WIN, 'won']
21
30
 
22
- export const LOST_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_LOSE, 'lost']
31
+ // `lost` is canonical. `loose` is the pre-0.7.1 spelling the UI closure flows and the
32
+ // kanban board persisted; unmigrated rows and tenant dictionaries still carry it.
33
+ export const LOST_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_LOST, DEAL_STATUS_LOSE]
23
34
 
24
35
  export const CLOSED_DEAL_STATUS_LIST: readonly string[] = [
25
36
  ...WON_DEAL_STATUS_LIST,
@@ -50,10 +61,10 @@ export function isOpenDealStatus(value: string | null | undefined): boolean {
50
61
  export function canonicalDealStatus(value: string): string {
51
62
  const lower = value.toLowerCase()
52
63
  if (lower === 'won') return DEAL_STATUS_WIN
53
- if (lower === 'lost') return DEAL_STATUS_LOSE
64
+ if (lower === 'loose') return DEAL_STATUS_LOST
54
65
  // Canonical spellings normalize to lower-case too, so an upper-case selection still
55
66
  // matches the lower-case persisted values and dictionary options.
56
- if (lower === 'win' || lower === 'loose' || lower === 'open' || lower === 'closed') return lower
67
+ if (lower === 'win' || lower === 'lost' || lower === 'open' || lower === 'closed') return lower
57
68
  return value
58
69
  }
59
70
 
@@ -61,7 +72,7 @@ export function canonicalDealStatus(value: string): string {
61
72
  * Expand operator-selected status filter values into the canonical vocabulary so a
62
73
  * single selection matches every spelling the supported writers persist (#5107).
63
74
  *
64
- * Alias groups (`win`/`won`, `loose`/`lost`, `closed`) are matched case-insensitively,
75
+ * Alias groups (`win`/`won`, `lost`/`loose`, `closed`) are matched case-insensitively,
65
76
  * expanded to their canonical spellings, and also keep the caller's original token so
66
77
  * the result is always a strict superset of a verbatim match. Unknown values emit both
67
78
  * the trimmed original and its lower-case form — mixed-case tenant dictionary statuses
@@ -146,9 +146,9 @@ export async function loadDealsSummaryQueryRows(input: DealsSummaryQueryInput):
146
146
  const winLossRows = await connection.execute<WinLossRow[]>(
147
147
  `SELECT
148
148
  COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,
149
- COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,
149
+ COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,
150
150
  COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,
151
- COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost
151
+ COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost
152
152
  FROM customer_deals
153
153
  WHERE ${scopeWhere}`,
154
154
  [
@@ -168,7 +168,7 @@ export async function loadDealsSummaryQueryRows(input: DealsSummaryQueryInput):
168
168
  `SELECT
169
169
  to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,
170
170
  COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,
171
- COUNT(*) FILTER (WHERE status = 'loose' OR closure_outcome = 'lost') AS lost
171
+ COUNT(*) FILTER (WHERE status IN ('lost', 'loose') OR closure_outcome = 'lost') AS lost
172
172
  FROM customer_deals
173
173
  WHERE ${scopeWhere} AND updated_at >= ?
174
174
  GROUP BY 1`,
@@ -0,0 +1,55 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ /**
4
+ * Rename the misspelled lost-deal vocabulary `loose` to `lost`.
5
+ *
6
+ * `loose` was the canonical spelling the UI closure flows, the kanban board and the seeded
7
+ * dictionaries persisted up to 0.7.0. `lost` is what every other surface already used
8
+ * (`closure_outcome`, the AI stage tool, every operator-facing label), so the two spellings
9
+ * had to be reconciled anywhere a reader joined them.
10
+ *
11
+ * Forward-only and conservative. Nothing is deleted, and a row is left alone whenever
12
+ * renaming it would collide with an entry the tenant already has:
13
+ *
14
+ * - `customer_deals.status` / `pipeline_stage`: `loose` -> `lost`.
15
+ * - `customer_dictionary_entries` (`deal_status`, `pipeline_stage`): the `loose` entry is
16
+ * renamed only when the same scope has no `lost` entry, since
17
+ * `customer_dictionary_entries_unique` covers (org, tenant, kind, normalized_value).
18
+ * Its label is corrected only when it is still the seeded `Loose`, so a tenant that
19
+ * renamed the option keeps its own wording.
20
+ * - `customer_pipeline_stages.name`: the seeded `Loose` label becomes `Lost`.
21
+ *
22
+ * Readers keep accepting `loose` (see `lib/dealStatus.ts`), so an instance that skips this
23
+ * migration, or a row this migration deliberately leaves behind, still classifies correctly.
24
+ */
25
+ export class Migration20260824180000_deal_status_lost extends Migration {
26
+ override async up(): Promise<void> {
27
+ this.addSql(`update "customer_deals" set "status" = 'lost' where lower("status") = 'loose';`);
28
+ this.addSql(`update "customer_deals" set "pipeline_stage" = 'lost' where lower("pipeline_stage") = 'loose';`);
29
+
30
+ this.addSql(`
31
+ update "customer_dictionary_entries" as e
32
+ set "value" = 'lost',
33
+ "normalized_value" = 'lost',
34
+ "label" = case when e."label" = 'Loose' then 'Lost' else e."label" end,
35
+ "updated_at" = now()
36
+ where e."kind" in ('deal_status', 'pipeline_stage')
37
+ and e."normalized_value" = 'loose'
38
+ and not exists (
39
+ select 1 from "customer_dictionary_entries" as other
40
+ where other."organization_id" = e."organization_id"
41
+ and other."tenant_id" = e."tenant_id"
42
+ and other."kind" = e."kind"
43
+ and other."normalized_value" = 'lost'
44
+ );
45
+ `);
46
+
47
+ this.addSql(`update "customer_pipeline_stages" set "name" = 'Lost', "updated_at" = now() where "name" = 'Loose';`);
48
+ }
49
+
50
+ override async down(): Promise<void> {
51
+ // Reverting is lossy: an instance may have carried `lost` rows before this migration ran,
52
+ // and those are indistinguishable afterwards from the ones it renamed. Readers accept
53
+ // both spellings, so leaving the data on the canonical `lost` is the safe no-op.
54
+ }
55
+ }
@@ -9,7 +9,7 @@ import type { WidgetDataRequest } from '../../../services/widgetDataService'
9
9
  // `customers/lib/interactionStatus.ts`, which treats an unknown interaction status as open.
10
10
  //
11
11
  // Every vocabulary that reaches the column has to be listed here:
12
- // - `win` / `loose` are written by the deal closure UI and the kanban board.
12
+ // - `win` / `lost` are written by the deal closure UI and the kanban board.
13
13
  // - `won` / `lost` are written verbatim by the `customers.update_deal_stage` AI tool, whose
14
14
  // free-form `toStage` is passed straight through to `status`.
15
15
  // - `closed` is a seeded `deal_status` dictionary value, persisted by the dashboards
@@ -4,6 +4,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
4
4
  {
5
5
  type: 'eudr.statement.submitted',
6
6
  module: 'eudr',
7
+ channels: ['in_app', 'email'],
7
8
  titleKey: 'eudr.notifications.statement.submitted.title',
8
9
  bodyKey: 'eudr.notifications.statement.submitted.body',
9
10
  icon: 'file-check',
@@ -14,6 +15,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
14
15
  {
15
16
  type: 'eudr.statement.reference_issued',
16
17
  module: 'eudr',
18
+ channels: ['in_app', 'email'],
17
19
  titleKey: 'eudr.notifications.statement.referenceIssued.title',
18
20
  bodyKey: 'eudr.notifications.statement.referenceIssued.body',
19
21
  icon: 'badge-check',
@@ -24,6 +26,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
24
26
  {
25
27
  type: 'eudr.statement.withdrawn',
26
28
  module: 'eudr',
29
+ channels: ['in_app', 'email'],
27
30
  titleKey: 'eudr.notifications.statement.withdrawn.title',
28
31
  bodyKey: 'eudr.notifications.statement.withdrawn.body',
29
32
  icon: 'undo',
@@ -34,6 +37,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
34
37
  {
35
38
  type: 'eudr.risk.non_negligible',
36
39
  module: 'eudr',
40
+ channels: ['in_app', 'email'],
37
41
  titleKey: 'eudr.notifications.risk.nonNegligible.title',
38
42
  bodyKey: 'eudr.notifications.risk.nonNegligible.body',
39
43
  icon: 'alert-triangle',
@@ -44,6 +48,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
44
48
  {
45
49
  type: 'eudr.mitigation.completed',
46
50
  module: 'eudr',
51
+ channels: ['in_app', 'email'],
47
52
  titleKey: 'eudr.notifications.mitigation.completed.title',
48
53
  bodyKey: 'eudr.notifications.mitigation.completed.body',
49
54
  icon: 'check-circle',
@@ -162,6 +162,60 @@ function buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null)
162
162
  return pairs
163
163
  }
164
164
 
165
+ type TokenRowLike = { field?: unknown; token_hash?: unknown; token?: unknown }
166
+
167
+ // NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`
168
+ // would otherwise sign identically to field `a` + hash `b c`.
169
+ const SIGNATURE_SEPARATOR = String.fromCharCode(0)
170
+
171
+ // Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a
172
+ // stored NULL has to sign the same as the `null` a freshly built row carries — otherwise every
173
+ // record compares as changed and the skip never fires.
174
+ function tokenSignature(row: TokenRowLike): string {
175
+ return [
176
+ String(row.field ?? ''),
177
+ String(row.token_hash ?? ''),
178
+ row.token == null ? '' : String(row.token),
179
+ ].join(SIGNATURE_SEPARATOR)
180
+ }
181
+
182
+ // Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement
183
+ // defect, and a set comparison reads such a record as already correct and preserves the duplicates
184
+ // forever. Counting sends it through a full rewrite, which collapses them.
185
+ function tallyOf(rows: Iterable<TokenRowLike>): Map<string, number> {
186
+ const tally = new Map<string, number>()
187
+ for (const row of rows) {
188
+ const signature = tokenSignature(row)
189
+ tally.set(signature, (tally.get(signature) ?? 0) + 1)
190
+ }
191
+ return tally
192
+ }
193
+
194
+ function tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {
195
+ const left = a ?? new Map<string, number>()
196
+ const right = b ?? new Map<string, number>()
197
+ if (left.size !== right.size) return false
198
+ for (const [key, count] of left.entries()) {
199
+ if (right.get(key) !== count) return false
200
+ }
201
+ return true
202
+ }
203
+
204
+ function tallyTokenRows<TRow extends TokenRowLike>(
205
+ rows: Iterable<TRow>,
206
+ keyOf: (row: TRow) => string
207
+ ): Map<string, Map<string, number>> {
208
+ const tallies = new Map<string, Map<string, number>>()
209
+ for (const row of rows) {
210
+ const key = keyOf(row)
211
+ const tally = tallies.get(key) ?? new Map<string, number>()
212
+ const signature = tokenSignature(row)
213
+ tally.set(signature, (tally.get(signature) ?? 0) + 1)
214
+ tallies.set(key, tally)
215
+ }
216
+ return tallies
217
+ }
218
+
165
219
  export async function replaceSearchTokensForRecord(
166
220
  db: Kysely<any>,
167
221
  params: BuildTokenOptions,
@@ -174,6 +228,52 @@ export async function replaceSearchTokensForRecord(
174
228
  const tenantId = params.tenantId ?? null
175
229
  const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)
176
230
 
231
+ // Same comparison #5402 gave the batch path, over the scope this path actually writes: the
232
+ // delete below is narrowed to the document's own `(entity_id, field)` pairs, so the comparison
233
+ // has to be narrowed the same way. Reading wider would let a token row under a field this
234
+ // document does not carry — the `cf_` twin the search module used to write, say — read as a
235
+ // difference forever and defeat the skip on every write.
236
+ const scopeTokenQuery = (query: any): any => {
237
+ let scoped = query
238
+ .where('entity_type' as any, '=', params.entityType)
239
+ .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)
240
+ .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)
241
+ .where('entity_id' as any, '=', String(params.recordId))
242
+ if (fieldPairs.length) {
243
+ scoped = scoped.where('field' as any, 'in', fieldPairs.map(([, field]) => field))
244
+ }
245
+ return scoped
246
+ }
247
+
248
+ // Read through the caller's transaction when there is one. A separate connection cannot see that
249
+ // transaction's own uncommitted writes, so it could report rows a pending delete has already
250
+ // removed and talk this call out of re-inserting them.
251
+ const reader = options?.trx ?? db
252
+
253
+ // Count probe first, as in the batch path: it returns one row whatever the table holds, so a
254
+ // record whose stored rows have run away (#4681) is settled without materializing them.
255
+ const storedCountRows = await scopeTokenQuery(
256
+ reader.selectFrom('search_tokens' as any).select(sql<number>`count(*)`.as('token_count') as any),
257
+ ).execute()
258
+ const storedCount = Number((storedCountRows as any[])[0]?.token_count ?? 0)
259
+
260
+ let unchanged = storedCount === rows.length
261
+ if (unchanged && rows.length) {
262
+ const stored = await scopeTokenQuery(
263
+ reader.selectFrom('search_tokens' as any).select(['field' as any, 'token_hash' as any, 'token' as any]),
264
+ )
265
+ // Counts already match, so this cannot truncate. It bounds the read if a concurrent writer
266
+ // inserts between the probe and here; a truncated read compares as changed, which costs a
267
+ // rewrite rather than a wrong skip.
268
+ .limit(rows.length)
269
+ .execute()
270
+ unchanged = tallyEquals(tallyOf(rows), tallyOf(stored as any[]))
271
+ }
272
+ if (unchanged) {
273
+ debug('record.skip', { entityType: params.entityType, recordId: params.recordId, tokenCount: rows.length })
274
+ return
275
+ }
276
+
177
277
  const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {
178
278
  let deleteQuery = executor
179
279
  .deleteFrom('search_tokens' as any)
@@ -223,49 +323,6 @@ export async function deleteSearchTokensForRecord(
223
323
  .execute()
224
324
  }
225
325
 
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
-
269
326
  export async function replaceSearchTokensForBatch(
270
327
  db: Kysely<any>,
271
328
  payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>