@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/crmFixtures.js.map +1 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
- package/dist/modules/communication_channels/lib/outbound-recipient.js +5 -1
- package/dist/modules/communication_channels/lib/outbound-recipient.js.map +2 -2
- package/dist/modules/communication_channels/lib/test-seed.js +5 -0
- package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +1 -1
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +1 -1
- package/dist/modules/customers/backend/customers/deals/page.js +4 -3
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/cli.js +5 -5
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/components/detail/pipelineStageUtils.js +1 -0
- package/dist/modules/customers/components/detail/pipelineStageUtils.js.map +2 -2
- package/dist/modules/customers/components/linking/adapters/dealAdapter.js +2 -2
- package/dist/modules/customers/components/linking/adapters/dealAdapter.js.map +2 -2
- package/dist/modules/customers/lib/closureStage.js.map +2 -2
- package/dist/modules/customers/lib/dealStatus.js +6 -4
- package/dist/modules/customers/lib/dealStatus.js.map +2 -2
- package/dist/modules/customers/lib/dealsSummaryQueries.js +3 -3
- package/dist/modules/customers/lib/dealsSummaryQueries.js.map +1 -1
- package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js +30 -0
- package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +1 -1
- package/dist/modules/eudr/notifications.js +5 -0
- package/dist/modules/eudr/notifications.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +59 -28
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +146 -99
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +1 -30
- package/dist/modules/sales/commands/returns.js.map +2 -2
- package/dist/modules/sales/commands/shared.js +1 -4
- package/dist/modules/sales/commands/shared.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +2 -1
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +2 -2
- package/dist/modules/sales/data/validators.js +3 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/lib/calculations.js +14 -2
- package/dist/modules/sales/lib/calculations.js.map +2 -2
- package/dist/modules/sales/lib/json.js +8 -0
- package/dist/modules/sales/lib/json.js.map +7 -0
- package/dist/modules/sales/lib/lineSnapshots.js +66 -0
- package/dist/modules/sales/lib/lineSnapshots.js.map +7 -0
- package/dist/modules/warranty_claims/notifications.js +5 -0
- package/dist/modules/warranty_claims/notifications.js.map +2 -2
- package/dist/modules/wms/notifications.js +2 -0
- package/dist/modules/wms/notifications.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/crmFixtures.ts +1 -1
- package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +21 -9
- package/src/modules/communication_channels/lib/adapter.ts +11 -0
- package/src/modules/communication_channels/lib/outbound-recipient.ts +14 -1
- package/src/modules/communication_channels/lib/test-seed.ts +19 -0
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +1 -1
- package/src/modules/customers/backend/customers/deals/page.tsx +4 -3
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +5 -5
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +3 -3
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2 -2
- package/src/modules/customers/cli.ts +5 -5
- package/src/modules/customers/commands/deals.ts +4 -3
- package/src/modules/customers/components/detail/pipelineStageUtils.ts +1 -0
- package/src/modules/customers/components/linking/adapters/dealAdapter.tsx +2 -2
- package/src/modules/customers/i18n/de.json +1 -1
- package/src/modules/customers/i18n/en.json +1 -1
- package/src/modules/customers/i18n/es.json +1 -1
- package/src/modules/customers/i18n/ko.json +1 -1
- package/src/modules/customers/i18n/pl.json +1 -1
- package/src/modules/customers/lib/closureStage.ts +2 -1
- package/src/modules/customers/lib/dealStatus.ts +23 -12
- package/src/modules/customers/lib/dealsSummaryQueries.ts +3 -3
- package/src/modules/customers/migrations/Migration20260824180000_deal_status_lost.ts +55 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +1 -1
- package/src/modules/eudr/notifications.ts +5 -0
- package/src/modules/query_index/lib/search-tokens.ts +100 -43
- package/src/modules/sales/commands/documents.ts +185 -105
- package/src/modules/sales/commands/returns.ts +1 -31
- package/src/modules/sales/commands/shared.ts +1 -4
- package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +12 -1
- package/src/modules/sales/data/validators.ts +3 -0
- package/src/modules/sales/lib/calculations.ts +33 -7
- package/src/modules/sales/lib/json.ts +4 -0
- package/src/modules/sales/lib/lineSnapshots.ts +98 -0
- package/src/modules/sales/lib/types.ts +20 -0
- package/src/modules/warranty_claims/notifications.ts +5 -0
- 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` / `
|
|
5
|
-
// `customers.update_deal_stage` persists `won` / `lost`,
|
|
6
|
-
// `closed
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
16
|
+
export const DEAL_STATUS_LOST = 'lost' as const
|
|
16
17
|
export const DEAL_STATUS_CLOSED = 'closed' as const
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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 === '
|
|
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 === '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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` / `
|
|
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> }>
|