@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,8 +1,9 @@
1
1
  const DEAL_STATUS_WIN = "win";
2
- const DEAL_STATUS_LOSE = "loose";
2
+ const DEAL_STATUS_LOST = "lost";
3
3
  const DEAL_STATUS_CLOSED = "closed";
4
+ const DEAL_STATUS_LOSE = "loose";
4
5
  const WON_DEAL_STATUS_LIST = [DEAL_STATUS_WIN, "won"];
5
- const LOST_DEAL_STATUS_LIST = [DEAL_STATUS_LOSE, "lost"];
6
+ const LOST_DEAL_STATUS_LIST = [DEAL_STATUS_LOST, DEAL_STATUS_LOSE];
6
7
  const CLOSED_DEAL_STATUS_LIST = [
7
8
  ...WON_DEAL_STATUS_LIST,
8
9
  ...LOST_DEAL_STATUS_LIST,
@@ -26,8 +27,8 @@ function isOpenDealStatus(value) {
26
27
  function canonicalDealStatus(value) {
27
28
  const lower = value.toLowerCase();
28
29
  if (lower === "won") return DEAL_STATUS_WIN;
29
- if (lower === "lost") return DEAL_STATUS_LOSE;
30
- if (lower === "win" || lower === "loose" || lower === "open" || lower === "closed") return lower;
30
+ if (lower === "loose") return DEAL_STATUS_LOST;
31
+ if (lower === "win" || lower === "lost" || lower === "open" || lower === "closed") return lower;
31
32
  return value;
32
33
  }
33
34
  function expandDealStatusAliases(values) {
@@ -55,6 +56,7 @@ export {
55
56
  CLOSED_DEAL_STATUS_LIST,
56
57
  DEAL_STATUS_CLOSED,
57
58
  DEAL_STATUS_LOSE,
59
+ DEAL_STATUS_LOST,
58
60
  DEAL_STATUS_WIN,
59
61
  LOST_DEAL_STATUS_LIST,
60
62
  WON_DEAL_STATUS_LIST,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/customers/lib/dealStatus.ts"],
4
- "sourcesContent": ["// Single source of truth for deal open/closed (terminal) semantics.\n//\n// `customer_deals.status` is a lenient text column and the supported writers do not agree on\n// one spelling: the closure hooks and the kanban board persist `win` / `loose`, the AI tool\n// `customers.update_deal_stage` persists `won` / `lost`, and the seeded vocabulary also carries\n// `closed`. The terminal set below therefore covers every spelling a status writer persists, so\n// status-based readers can share one classification. `closureOutcome` is a separate signal and\n// callers that combine both columns must handle it explicitly.\n//\n// A status not known to code counts as OPEN. Deal stages are configurable per tenant, so the\n// lenient column can hold values this module has never seen \u2014 treating them as open keeps them\n// visible in active-deal counts instead of silently reclassifying them as closed.\n\nexport const DEAL_STATUS_WIN = 'win' as const\nexport const DEAL_STATUS_LOSE = 'loose' as const\nexport const DEAL_STATUS_CLOSED = 'closed' as const\n\n// `won` / `lost` are the spellings the AI stage tool writes; `win` / `loose` are what the UI\n// closure flows persist. Both are accepted so neither writer produces an unreadable status.\nexport const WON_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_WIN, 'won']\n\nexport const LOST_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_LOSE, 'lost']\n\nexport const CLOSED_DEAL_STATUS_LIST: readonly string[] = [\n ...WON_DEAL_STATUS_LIST,\n ...LOST_DEAL_STATUS_LIST,\n DEAL_STATUS_CLOSED,\n]\n\nconst WON_DEAL_STATUSES = new Set<string>(WON_DEAL_STATUS_LIST)\nconst LOST_DEAL_STATUSES = new Set<string>(LOST_DEAL_STATUS_LIST)\nconst CLOSED_DEAL_STATUSES = new Set<string>(CLOSED_DEAL_STATUS_LIST)\n\nexport function isWonDealStatus(value: string | null | undefined): boolean {\n return value != null && WON_DEAL_STATUSES.has(value)\n}\n\nexport function isLostDealStatus(value: string | null | undefined): boolean {\n return value != null && LOST_DEAL_STATUSES.has(value)\n}\n\nexport function isClosedDealStatus(value: string | null | undefined): boolean {\n return value != null && CLOSED_DEAL_STATUSES.has(value)\n}\n\nexport function isOpenDealStatus(value: string | null | undefined): boolean {\n return !isClosedDealStatus(value)\n}\n\nexport function canonicalDealStatus(value: string): string {\n const lower = value.toLowerCase()\n if (lower === 'won') return DEAL_STATUS_WIN\n if (lower === 'lost') return DEAL_STATUS_LOSE\n // Canonical spellings normalize to lower-case too, so an upper-case selection still\n // matches the lower-case persisted values and dictionary options.\n if (lower === 'win' || lower === 'loose' || lower === 'open' || lower === 'closed') return lower\n return value\n}\n\n/**\n * Expand operator-selected status filter values into the canonical vocabulary so a\n * single selection matches every spelling the supported writers persist (#5107).\n *\n * Alias groups (`win`/`won`, `loose`/`lost`, `closed`) are matched case-insensitively,\n * expanded to their canonical spellings, and also keep the caller's original token so\n * the result is always a strict superset of a verbatim match. Unknown values emit both\n * the trimmed original and its lower-case form \u2014 mixed-case tenant dictionary statuses\n * match exactly while `?status=Open` still hits lower-case stored rows.\n */\nexport function expandDealStatusAliases(values: string[]): string[] {\n const out = new Set<string>()\n for (const value of values) {\n const trimmed = value.trim()\n const lower = trimmed.toLowerCase()\n if (lower === 'win' || lower === 'won') {\n WON_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))\n out.add(trimmed)\n } else if (lower === 'loose' || lower === 'lost') {\n LOST_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))\n out.add(trimmed)\n } else if (lower === 'closed') {\n CLOSED_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))\n out.add(trimmed)\n } else {\n out.add(trimmed)\n out.add(lower)\n }\n }\n return Array.from(out)\n}\n"],
5
- "mappings": "AAaO,MAAM,kBAAkB;AACxB,MAAM,mBAAmB;AACzB,MAAM,qBAAqB;AAI3B,MAAM,uBAA0C,CAAC,iBAAiB,KAAK;AAEvE,MAAM,wBAA2C,CAAC,kBAAkB,MAAM;AAE1E,MAAM,0BAA6C;AAAA,EACxD,GAAG;AAAA,EACH,GAAG;AAAA,EACH;AACF;AAEA,MAAM,oBAAoB,IAAI,IAAY,oBAAoB;AAC9D,MAAM,qBAAqB,IAAI,IAAY,qBAAqB;AAChE,MAAM,uBAAuB,IAAI,IAAY,uBAAuB;AAE7D,SAAS,gBAAgB,OAA2C;AACzE,SAAO,SAAS,QAAQ,kBAAkB,IAAI,KAAK;AACrD;AAEO,SAAS,iBAAiB,OAA2C;AAC1E,SAAO,SAAS,QAAQ,mBAAmB,IAAI,KAAK;AACtD;AAEO,SAAS,mBAAmB,OAA2C;AAC5E,SAAO,SAAS,QAAQ,qBAAqB,IAAI,KAAK;AACxD;AAEO,SAAS,iBAAiB,OAA2C;AAC1E,SAAO,CAAC,mBAAmB,KAAK;AAClC;AAEO,SAAS,oBAAoB,OAAuB;AACzD,QAAM,QAAQ,MAAM,YAAY;AAChC,MAAI,UAAU,MAAO,QAAO;AAC5B,MAAI,UAAU,OAAQ,QAAO;AAG7B,MAAI,UAAU,SAAS,UAAU,WAAW,UAAU,UAAU,UAAU,SAAU,QAAO;AAC3F,SAAO;AACT;AAYO,SAAS,wBAAwB,QAA4B;AAClE,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,SAAS,QAAQ;AAC1B,UAAM,UAAU,MAAM,KAAK;AAC3B,UAAM,QAAQ,QAAQ,YAAY;AAClC,QAAI,UAAU,SAAS,UAAU,OAAO;AACtC,2BAAqB,QAAQ,CAAC,UAAU,IAAI,IAAI,KAAK,CAAC;AACtD,UAAI,IAAI,OAAO;AAAA,IACjB,WAAW,UAAU,WAAW,UAAU,QAAQ;AAChD,4BAAsB,QAAQ,CAAC,UAAU,IAAI,IAAI,KAAK,CAAC;AACvD,UAAI,IAAI,OAAO;AAAA,IACjB,WAAW,UAAU,UAAU;AAC7B,8BAAwB,QAAQ,CAAC,UAAU,IAAI,IAAI,KAAK,CAAC;AACzD,UAAI,IAAI,OAAO;AAAA,IACjB,OAAO;AACL,UAAI,IAAI,OAAO;AACf,UAAI,IAAI,KAAK;AAAA,IACf;AAAA,EACF;AACA,SAAO,MAAM,KAAK,GAAG;AACvB;",
4
+ "sourcesContent": ["// Single source of truth for deal open/closed (terminal) semantics.\n//\n// `customer_deals.status` is a lenient text column and the supported writers do not agree on\n// one spelling: the closure hooks and the kanban board persist `win` / `lost`, the AI tool\n// `customers.update_deal_stage` persists `won` / `lost`, the seeded vocabulary also carries\n// `closed`, and rows written before 0.7.1 carry the misspelled `loose`. The terminal set\n// below therefore covers every spelling a status writer persists, so status-based readers\n// can share one classification. `closureOutcome` is a separate signal, and callers that\n// combine both columns must handle it explicitly.\n//\n// A status not known to code counts as OPEN. Deal stages are configurable per tenant, so the\n// lenient column can hold values this module has never seen \u2014 treating them as open keeps them\n// visible in active-deal counts instead of silently reclassifying them as closed.\n\nexport const DEAL_STATUS_WIN = 'win' as const\nexport const DEAL_STATUS_LOST = 'lost' as const\nexport const DEAL_STATUS_CLOSED = 'closed' as const\n\n/**\n * @deprecated Misspelling of the lost-deal status, canonical until 0.7.0. Writers now\n * persist {@link DEAL_STATUS_LOST}. This export stays so downstream modules keep\n * compiling and readers keep matching rows written before the 0.7.1 migration; it is\n * scheduled for removal no earlier than 0.9.0.\n */\nexport const DEAL_STATUS_LOSE = 'loose' as const\n\n// `won` is the spelling the AI stage tool writes; `win` is what the UI closure flows\n// persist. Both are accepted so neither writer produces an unreadable status.\nexport const WON_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_WIN, 'won']\n\n// `lost` is canonical. `loose` is the pre-0.7.1 spelling the UI closure flows and the\n// kanban board persisted; unmigrated rows and tenant dictionaries still carry it.\nexport const LOST_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_LOST, DEAL_STATUS_LOSE]\n\nexport const CLOSED_DEAL_STATUS_LIST: readonly string[] = [\n ...WON_DEAL_STATUS_LIST,\n ...LOST_DEAL_STATUS_LIST,\n DEAL_STATUS_CLOSED,\n]\n\nconst WON_DEAL_STATUSES = new Set<string>(WON_DEAL_STATUS_LIST)\nconst LOST_DEAL_STATUSES = new Set<string>(LOST_DEAL_STATUS_LIST)\nconst CLOSED_DEAL_STATUSES = new Set<string>(CLOSED_DEAL_STATUS_LIST)\n\nexport function isWonDealStatus(value: string | null | undefined): boolean {\n return value != null && WON_DEAL_STATUSES.has(value)\n}\n\nexport function isLostDealStatus(value: string | null | undefined): boolean {\n return value != null && LOST_DEAL_STATUSES.has(value)\n}\n\nexport function isClosedDealStatus(value: string | null | undefined): boolean {\n return value != null && CLOSED_DEAL_STATUSES.has(value)\n}\n\nexport function isOpenDealStatus(value: string | null | undefined): boolean {\n return !isClosedDealStatus(value)\n}\n\nexport function canonicalDealStatus(value: string): string {\n const lower = value.toLowerCase()\n if (lower === 'won') return DEAL_STATUS_WIN\n if (lower === 'loose') return DEAL_STATUS_LOST\n // Canonical spellings normalize to lower-case too, so an upper-case selection still\n // matches the lower-case persisted values and dictionary options.\n if (lower === 'win' || lower === 'lost' || lower === 'open' || lower === 'closed') return lower\n return value\n}\n\n/**\n * Expand operator-selected status filter values into the canonical vocabulary so a\n * single selection matches every spelling the supported writers persist (#5107).\n *\n * Alias groups (`win`/`won`, `lost`/`loose`, `closed`) are matched case-insensitively,\n * expanded to their canonical spellings, and also keep the caller's original token so\n * the result is always a strict superset of a verbatim match. Unknown values emit both\n * the trimmed original and its lower-case form \u2014 mixed-case tenant dictionary statuses\n * match exactly while `?status=Open` still hits lower-case stored rows.\n */\nexport function expandDealStatusAliases(values: string[]): string[] {\n const out = new Set<string>()\n for (const value of values) {\n const trimmed = value.trim()\n const lower = trimmed.toLowerCase()\n if (lower === 'win' || lower === 'won') {\n WON_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))\n out.add(trimmed)\n } else if (lower === 'loose' || lower === 'lost') {\n LOST_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))\n out.add(trimmed)\n } else if (lower === 'closed') {\n CLOSED_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))\n out.add(trimmed)\n } else {\n out.add(trimmed)\n out.add(lower)\n }\n }\n return Array.from(out)\n}\n"],
5
+ "mappings": "AAcO,MAAM,kBAAkB;AACxB,MAAM,mBAAmB;AACzB,MAAM,qBAAqB;AAQ3B,MAAM,mBAAmB;AAIzB,MAAM,uBAA0C,CAAC,iBAAiB,KAAK;AAIvE,MAAM,wBAA2C,CAAC,kBAAkB,gBAAgB;AAEpF,MAAM,0BAA6C;AAAA,EACxD,GAAG;AAAA,EACH,GAAG;AAAA,EACH;AACF;AAEA,MAAM,oBAAoB,IAAI,IAAY,oBAAoB;AAC9D,MAAM,qBAAqB,IAAI,IAAY,qBAAqB;AAChE,MAAM,uBAAuB,IAAI,IAAY,uBAAuB;AAE7D,SAAS,gBAAgB,OAA2C;AACzE,SAAO,SAAS,QAAQ,kBAAkB,IAAI,KAAK;AACrD;AAEO,SAAS,iBAAiB,OAA2C;AAC1E,SAAO,SAAS,QAAQ,mBAAmB,IAAI,KAAK;AACtD;AAEO,SAAS,mBAAmB,OAA2C;AAC5E,SAAO,SAAS,QAAQ,qBAAqB,IAAI,KAAK;AACxD;AAEO,SAAS,iBAAiB,OAA2C;AAC1E,SAAO,CAAC,mBAAmB,KAAK;AAClC;AAEO,SAAS,oBAAoB,OAAuB;AACzD,QAAM,QAAQ,MAAM,YAAY;AAChC,MAAI,UAAU,MAAO,QAAO;AAC5B,MAAI,UAAU,QAAS,QAAO;AAG9B,MAAI,UAAU,SAAS,UAAU,UAAU,UAAU,UAAU,UAAU,SAAU,QAAO;AAC1F,SAAO;AACT;AAYO,SAAS,wBAAwB,QAA4B;AAClE,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,SAAS,QAAQ;AAC1B,UAAM,UAAU,MAAM,KAAK;AAC3B,UAAM,QAAQ,QAAQ,YAAY;AAClC,QAAI,UAAU,SAAS,UAAU,OAAO;AACtC,2BAAqB,QAAQ,CAAC,UAAU,IAAI,IAAI,KAAK,CAAC;AACtD,UAAI,IAAI,OAAO;AAAA,IACjB,WAAW,UAAU,WAAW,UAAU,QAAQ;AAChD,4BAAsB,QAAQ,CAAC,UAAU,IAAI,IAAI,KAAK,CAAC;AACvD,UAAI,IAAI,OAAO;AAAA,IACjB,WAAW,UAAU,UAAU;AAC7B,8BAAwB,QAAQ,CAAC,UAAU,IAAI,IAAI,KAAK,CAAC;AACzD,UAAI,IAAI,OAAO;AAAA,IACjB,OAAO;AACL,UAAI,IAAI,OAAO;AACf,UAAI,IAAI,KAAK;AAAA,IACf;AAAA,EACF;AACA,SAAO,MAAM,KAAK,GAAG;AACvB;",
6
6
  "names": []
7
7
  }
@@ -78,9 +78,9 @@ async function loadDealsSummaryQueryRows(input) {
78
78
  const winLossRows = await connection.execute(
79
79
  `SELECT
80
80
  COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,
81
- COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,
81
+ COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,
82
82
  COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,
83
- COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost
83
+ COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost
84
84
  FROM customer_deals
85
85
  WHERE ${scopeWhere}`,
86
86
  [
@@ -99,7 +99,7 @@ async function loadDealsSummaryQueryRows(input) {
99
99
  `SELECT
100
100
  to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,
101
101
  COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,
102
- COUNT(*) FILTER (WHERE status = 'loose' OR closure_outcome = 'lost') AS lost
102
+ COUNT(*) FILTER (WHERE status IN ('lost', 'loose') OR closure_outcome = 'lost') AS lost
103
103
  FROM customer_deals
104
104
  WHERE ${scopeWhere} AND updated_at >= ?
105
105
  GROUP BY 1`,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/customers/lib/dealsSummaryQueries.ts"],
4
- "sourcesContent": ["import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'\nimport { fetchStuckDealIds } from './stuckDeals'\n\ntype DateWindow = {\n start: Date\n end: Date\n}\n\nexport type OpenPipelineRow = {\n stage: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n owner_user_id: string | null\n}\n\nexport type WindowSumRow = {\n currency: string | null\n current_total: string | number | null\n current_count: string | number\n previous_total: string | number | null\n previous_count: string | number\n}\n\nexport type WinLossRow = {\n current_won: string | number\n current_lost: string | number\n previous_won: string | number\n previous_lost: string | number\n}\n\nexport type WinRateMonthRow = {\n period: string\n won: string | number\n lost: string | number\n}\n\nexport type DealsSummaryQueryRows = {\n openRows: OpenPipelineRow[]\n inflowRows: WindowSumRow[]\n wonRows: WindowSumRow[]\n winLossRows: WinLossRow[]\n seriesRows: WinRateMonthRow[]\n overdueRows: Array<{ id: string }>\n openStuckIds: string[]\n}\n\nexport type DealsSummaryQueryInput = {\n em: CoreEntityManager\n tenantId: string\n organizationIds: string[]\n currentQuarter: DateWindow\n previousQuarter: DateWindow\n seriesStart: Date\n}\n\ntype SqlPredicate = {\n clause: string\n values: string[]\n}\n\nexport function buildOpenDealPredicate(statuses: readonly string[]): SqlPredicate {\n if (statuses.length === 0) {\n throw new Error('[internal] Open-deal predicate requires at least one status')\n }\n\n const placeholders = statuses.map(() => '?').join(',')\n return {\n clause: `status IN (${placeholders}) AND closure_outcome IS NULL`,\n values: [...statuses],\n }\n}\n\nexport async function loadDealsSummaryQueryRows(input: DealsSummaryQueryInput): Promise<DealsSummaryQueryRows> {\n if (input.organizationIds.length === 0) {\n throw new Error('[internal] Deals summary query requires an organization scope')\n }\n\n const connection = input.em.getConnection()\n const organizationPlaceholders = input.organizationIds.map(() => '?').join(',')\n const scopeWhere = `tenant_id = ? AND organization_id IN (${organizationPlaceholders}) AND deleted_at IS NULL`\n const scopeValues: Array<string | number | null> = [input.tenantId, ...input.organizationIds]\n const openDealsPredicate = buildOpenDealPredicate(['open', 'in_progress'])\n\n const openRows = await connection.execute<OpenPipelineRow[]>(\n `SELECT\n pipeline_stage AS stage,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n owner_user_id\n FROM customer_deals\n WHERE ${scopeWhere} AND ${openDealsPredicate.clause}\n GROUP BY pipeline_stage, UPPER(COALESCE(value_currency, '')), owner_user_id`,\n [...scopeValues, ...openDealsPredicate.values],\n )\n\n const inflowRows = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND ${openDealsPredicate.clause}\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n ...scopeValues,\n ...openDealsPredicate.values,\n ],\n )\n\n const wonRows = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND (status = 'win' OR closure_outcome = 'won')\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n ...scopeValues,\n ],\n )\n\n const winLossRows = await connection.execute<WinLossRow[]>(\n `SELECT\n COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,\n COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,\n COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,\n COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost\n FROM customer_deals\n WHERE ${scopeWhere}`,\n [\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n ...scopeValues,\n ],\n )\n\n const seriesRows = await connection.execute<WinRateMonthRow[]>(\n `SELECT\n to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,\n COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,\n COUNT(*) FILTER (WHERE status = 'loose' OR closure_outcome = 'lost') AS lost\n FROM customer_deals\n WHERE ${scopeWhere} AND updated_at >= ?\n GROUP BY 1`,\n [...scopeValues, input.seriesStart.toISOString()],\n )\n\n const overdueDealsPredicate = buildOpenDealPredicate(['open'])\n const overdueRows = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE ${scopeWhere} AND ${overdueDealsPredicate.clause} AND expected_close_at IS NOT NULL AND expected_close_at < CURRENT_DATE`,\n [...scopeValues, ...overdueDealsPredicate.values],\n )\n\n const stuckIdLists = await Promise.all(\n input.organizationIds.map((organizationId) =>\n fetchStuckDealIds(input.em as unknown as PgEntityManager, organizationId, input.tenantId),\n ),\n )\n const stuckIdValues = Array.from(new Set(stuckIdLists.flat()))\n let openStuckIds: string[] = []\n if (stuckIdValues.length > 0) {\n const stuckPlaceholders = stuckIdValues.map(() => '?').join(',')\n const openStuckRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE ${scopeWhere} AND ${openDealsPredicate.clause} AND id IN (${stuckPlaceholders})`,\n [...scopeValues, ...openDealsPredicate.values, ...stuckIdValues],\n )\n openStuckIds = openStuckRows.map((row) => row.id)\n }\n\n return {\n openRows,\n inflowRows,\n wonRows,\n winLossRows,\n seriesRows,\n overdueRows,\n openStuckIds,\n }\n}\n"],
4
+ "sourcesContent": ["import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'\nimport { fetchStuckDealIds } from './stuckDeals'\n\ntype DateWindow = {\n start: Date\n end: Date\n}\n\nexport type OpenPipelineRow = {\n stage: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n owner_user_id: string | null\n}\n\nexport type WindowSumRow = {\n currency: string | null\n current_total: string | number | null\n current_count: string | number\n previous_total: string | number | null\n previous_count: string | number\n}\n\nexport type WinLossRow = {\n current_won: string | number\n current_lost: string | number\n previous_won: string | number\n previous_lost: string | number\n}\n\nexport type WinRateMonthRow = {\n period: string\n won: string | number\n lost: string | number\n}\n\nexport type DealsSummaryQueryRows = {\n openRows: OpenPipelineRow[]\n inflowRows: WindowSumRow[]\n wonRows: WindowSumRow[]\n winLossRows: WinLossRow[]\n seriesRows: WinRateMonthRow[]\n overdueRows: Array<{ id: string }>\n openStuckIds: string[]\n}\n\nexport type DealsSummaryQueryInput = {\n em: CoreEntityManager\n tenantId: string\n organizationIds: string[]\n currentQuarter: DateWindow\n previousQuarter: DateWindow\n seriesStart: Date\n}\n\ntype SqlPredicate = {\n clause: string\n values: string[]\n}\n\nexport function buildOpenDealPredicate(statuses: readonly string[]): SqlPredicate {\n if (statuses.length === 0) {\n throw new Error('[internal] Open-deal predicate requires at least one status')\n }\n\n const placeholders = statuses.map(() => '?').join(',')\n return {\n clause: `status IN (${placeholders}) AND closure_outcome IS NULL`,\n values: [...statuses],\n }\n}\n\nexport async function loadDealsSummaryQueryRows(input: DealsSummaryQueryInput): Promise<DealsSummaryQueryRows> {\n if (input.organizationIds.length === 0) {\n throw new Error('[internal] Deals summary query requires an organization scope')\n }\n\n const connection = input.em.getConnection()\n const organizationPlaceholders = input.organizationIds.map(() => '?').join(',')\n const scopeWhere = `tenant_id = ? AND organization_id IN (${organizationPlaceholders}) AND deleted_at IS NULL`\n const scopeValues: Array<string | number | null> = [input.tenantId, ...input.organizationIds]\n const openDealsPredicate = buildOpenDealPredicate(['open', 'in_progress'])\n\n const openRows = await connection.execute<OpenPipelineRow[]>(\n `SELECT\n pipeline_stage AS stage,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n owner_user_id\n FROM customer_deals\n WHERE ${scopeWhere} AND ${openDealsPredicate.clause}\n GROUP BY pipeline_stage, UPPER(COALESCE(value_currency, '')), owner_user_id`,\n [...scopeValues, ...openDealsPredicate.values],\n )\n\n const inflowRows = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND ${openDealsPredicate.clause}\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n ...scopeValues,\n ...openDealsPredicate.values,\n ],\n )\n\n const wonRows = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND (status = 'win' OR closure_outcome = 'won')\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n ...scopeValues,\n ],\n )\n\n const winLossRows = await connection.execute<WinLossRow[]>(\n `SELECT\n COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,\n COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,\n COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,\n COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost\n FROM customer_deals\n WHERE ${scopeWhere}`,\n [\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n ...scopeValues,\n ],\n )\n\n const seriesRows = await connection.execute<WinRateMonthRow[]>(\n `SELECT\n to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,\n COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,\n COUNT(*) FILTER (WHERE status IN ('lost', 'loose') OR closure_outcome = 'lost') AS lost\n FROM customer_deals\n WHERE ${scopeWhere} AND updated_at >= ?\n GROUP BY 1`,\n [...scopeValues, input.seriesStart.toISOString()],\n )\n\n const overdueDealsPredicate = buildOpenDealPredicate(['open'])\n const overdueRows = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE ${scopeWhere} AND ${overdueDealsPredicate.clause} AND expected_close_at IS NOT NULL AND expected_close_at < CURRENT_DATE`,\n [...scopeValues, ...overdueDealsPredicate.values],\n )\n\n const stuckIdLists = await Promise.all(\n input.organizationIds.map((organizationId) =>\n fetchStuckDealIds(input.em as unknown as PgEntityManager, organizationId, input.tenantId),\n ),\n )\n const stuckIdValues = Array.from(new Set(stuckIdLists.flat()))\n let openStuckIds: string[] = []\n if (stuckIdValues.length > 0) {\n const stuckPlaceholders = stuckIdValues.map(() => '?').join(',')\n const openStuckRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE ${scopeWhere} AND ${openDealsPredicate.clause} AND id IN (${stuckPlaceholders})`,\n [...scopeValues, ...openDealsPredicate.values, ...stuckIdValues],\n )\n openStuckIds = openStuckRows.map((row) => row.id)\n }\n\n return {\n openRows,\n inflowRows,\n wonRows,\n winLossRows,\n seriesRows,\n overdueRows,\n openStuckIds,\n }\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,30 @@
1
+ import { Migration } from "@mikro-orm/migrations";
2
+ class Migration20260824180000_deal_status_lost extends Migration {
3
+ async up() {
4
+ this.addSql(`update "customer_deals" set "status" = 'lost' where lower("status") = 'loose';`);
5
+ this.addSql(`update "customer_deals" set "pipeline_stage" = 'lost' where lower("pipeline_stage") = 'loose';`);
6
+ this.addSql(`
7
+ update "customer_dictionary_entries" as e
8
+ set "value" = 'lost',
9
+ "normalized_value" = 'lost',
10
+ "label" = case when e."label" = 'Loose' then 'Lost' else e."label" end,
11
+ "updated_at" = now()
12
+ where e."kind" in ('deal_status', 'pipeline_stage')
13
+ and e."normalized_value" = 'loose'
14
+ and not exists (
15
+ select 1 from "customer_dictionary_entries" as other
16
+ where other."organization_id" = e."organization_id"
17
+ and other."tenant_id" = e."tenant_id"
18
+ and other."kind" = e."kind"
19
+ and other."normalized_value" = 'lost'
20
+ );
21
+ `);
22
+ this.addSql(`update "customer_pipeline_stages" set "name" = 'Lost', "updated_at" = now() where "name" = 'Loose';`);
23
+ }
24
+ async down() {
25
+ }
26
+ }
27
+ export {
28
+ Migration20260824180000_deal_status_lost
29
+ };
30
+ //# sourceMappingURL=Migration20260824180000_deal_status_lost.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/customers/migrations/Migration20260824180000_deal_status_lost.ts"],
4
+ "sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\n/**\n * Rename the misspelled lost-deal vocabulary `loose` to `lost`.\n *\n * `loose` was the canonical spelling the UI closure flows, the kanban board and the seeded\n * dictionaries persisted up to 0.7.0. `lost` is what every other surface already used\n * (`closure_outcome`, the AI stage tool, every operator-facing label), so the two spellings\n * had to be reconciled anywhere a reader joined them.\n *\n * Forward-only and conservative. Nothing is deleted, and a row is left alone whenever\n * renaming it would collide with an entry the tenant already has:\n *\n * - `customer_deals.status` / `pipeline_stage`: `loose` -> `lost`.\n * - `customer_dictionary_entries` (`deal_status`, `pipeline_stage`): the `loose` entry is\n * renamed only when the same scope has no `lost` entry, since\n * `customer_dictionary_entries_unique` covers (org, tenant, kind, normalized_value).\n * Its label is corrected only when it is still the seeded `Loose`, so a tenant that\n * renamed the option keeps its own wording.\n * - `customer_pipeline_stages.name`: the seeded `Loose` label becomes `Lost`.\n *\n * Readers keep accepting `loose` (see `lib/dealStatus.ts`), so an instance that skips this\n * migration, or a row this migration deliberately leaves behind, still classifies correctly.\n */\nexport class Migration20260824180000_deal_status_lost extends Migration {\n override async up(): Promise<void> {\n this.addSql(`update \"customer_deals\" set \"status\" = 'lost' where lower(\"status\") = 'loose';`);\n this.addSql(`update \"customer_deals\" set \"pipeline_stage\" = 'lost' where lower(\"pipeline_stage\") = 'loose';`);\n\n this.addSql(`\n update \"customer_dictionary_entries\" as e\n set \"value\" = 'lost',\n \"normalized_value\" = 'lost',\n \"label\" = case when e.\"label\" = 'Loose' then 'Lost' else e.\"label\" end,\n \"updated_at\" = now()\n where e.\"kind\" in ('deal_status', 'pipeline_stage')\n and e.\"normalized_value\" = 'loose'\n and not exists (\n select 1 from \"customer_dictionary_entries\" as other\n where other.\"organization_id\" = e.\"organization_id\"\n and other.\"tenant_id\" = e.\"tenant_id\"\n and other.\"kind\" = e.\"kind\"\n and other.\"normalized_value\" = 'lost'\n );\n `);\n\n this.addSql(`update \"customer_pipeline_stages\" set \"name\" = 'Lost', \"updated_at\" = now() where \"name\" = 'Loose';`);\n }\n\n override async down(): Promise<void> {\n // Reverting is lossy: an instance may have carried `lost` rows before this migration ran,\n // and those are indistinguishable afterwards from the ones it renamed. Readers accept\n // both spellings, so leaving the data on the canonical `lost` is the safe no-op.\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB;AAwBnB,MAAM,iDAAiD,UAAU;AAAA,EACtE,MAAe,KAAoB;AACjC,SAAK,OAAO,gFAAgF;AAC5F,SAAK,OAAO,gGAAgG;AAE5G,SAAK,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KAeX;AAED,SAAK,OAAO,qGAAqG;AAAA,EACnH;AAAA,EAEA,MAAe,OAAsB;AAAA,EAIrC;AACF;",
6
+ "names": []
7
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts"],
4
- "sourcesContent": ["import { type DateRangePreset, isValidDateRangePreset } from '@open-mercato/ui/backend/date-range'\nimport type { WidgetDataRequest } from '../../../services/widgetDataService'\n\n// Deal statuses that mean the deal is closed and must stop counting as current pipeline.\n//\n// This is a denylist, not an allowlist: `customer_deals.status` is a lenient `text` column\n// fed by the per-tenant `deal_status` dictionary, so a status unknown to code counts as OPEN\n// and keeps contributing to the chart. That keeps tenant-specific stages visible and mirrors\n// `customers/lib/interactionStatus.ts`, which treats an unknown interaction status as open.\n//\n// Every vocabulary that reaches the column has to be listed here:\n// - `win` / `loose` are written by the deal closure UI and the kanban board.\n// - `won` / `lost` are written verbatim by the `customers.update_deal_stage` AI tool, whose\n// free-form `toStage` is passed straight through to `status`.\n// - `closed` is a seeded `deal_status` dictionary value, persisted by the dashboards\n// analytics seed and treated as terminal by the demo-data generator.\nexport const CLOSED_DEAL_STATUSES = ['win', 'loose', 'won', 'lost', 'closed'] as const\n\n// A deal is also closed when `closure_outcome` records the outcome, which the deals-summary\n// KPI already treats as terminal: `api/deals/summary/route.ts` counts a deal as won or lost\n// when EITHER `status` OR `closure_outcome` says so. The two columns can disagree \u2014 the CRUD\n// API accepts `closureOutcome` on its own, and the AI tool writes `status` without ever setting\n// it \u2014 so the chart has to test both signals to agree with those KPI cards (#4668).\n//\n// The `closure_outcome` side is expressed as `IS NULL` rather than as a `neq` denylist for two\n// reasons, and swapping it for `neq` would be a silent regression:\n// - `closure_outcome` is nullable, and every OPEN deal holds NULL there. `neq` renders as\n// `column != ?`, and in SQL `NULL != 'won'` is NULL, not true \u2014 a `neq` filter would drop\n// every open deal and empty the chart. `status` has no such problem: it is `text not null\n// default 'open'`, which is why the status side stays a denylist.\n// - Unlike `status`, this column is not fed by a per-tenant dictionary. Every write path\n// validates it against the same closed `z.enum(['won', 'lost'])` (`data/validators.ts`,\n// `api/deals/[id]/route.ts`, `api/deals/[id]/stats/route.ts`), so a non-null value always\n// means closed and there is no tenant-specific vocabulary to keep visible.\nexport const OPEN_DEAL_CLOSURE_OUTCOME_FILTER = {\n field: 'closureOutcome',\n operator: 'is_null',\n} as const\n\nexport const PIPELINE_STATUS_SCOPES = ['open', 'all'] as const\n\nexport type PipelineStatusScope = typeof PIPELINE_STATUS_SCOPES[number]\n\nexport type PipelineSummarySettings = {\n dateRange: DateRangePreset\n statusScope: PipelineStatusScope\n}\n\nexport const DEFAULT_SETTINGS: PipelineSummarySettings = {\n dateRange: 'this_month',\n statusScope: 'open',\n}\n\nfunction isValidStatusScope(value: unknown): value is PipelineStatusScope {\n return typeof value === 'string' && (PIPELINE_STATUS_SCOPES as readonly string[]).includes(value)\n}\n\nexport function hydrateSettings(raw: unknown): PipelineSummarySettings {\n if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS }\n const obj = raw as Record<string, unknown>\n return {\n dateRange: isValidDateRangePreset(obj.dateRange) ? obj.dateRange : DEFAULT_SETTINGS.dateRange,\n statusScope: isValidStatusScope(obj.statusScope) ? obj.statusScope : DEFAULT_SETTINGS.statusScope,\n }\n}\n\nexport function dehydrateSettings(settings: PipelineSummarySettings): Record<string, unknown> {\n return {\n dateRange: settings.dateRange,\n statusScope: settings.statusScope,\n }\n}\n\nexport function buildPipelineDataRequest(settings: PipelineSummarySettings): WidgetDataRequest {\n const request: WidgetDataRequest = {\n entityType: 'customers:deals',\n metric: {\n field: 'valueAmount',\n aggregate: 'sum',\n },\n groupBy: {\n field: 'pipelineStage',\n resolveLabels: true,\n },\n dateRange: {\n field: 'createdAt',\n preset: settings.dateRange,\n },\n }\n\n if (settings.statusScope === 'open') {\n request.filters = [\n ...CLOSED_DEAL_STATUSES.map((status) => ({\n field: 'status',\n operator: 'neq' as const,\n value: status,\n })),\n { ...OPEN_DEAL_CLOSURE_OUTCOME_FILTER },\n ]\n }\n\n return request\n}\n"],
4
+ "sourcesContent": ["import { type DateRangePreset, isValidDateRangePreset } from '@open-mercato/ui/backend/date-range'\nimport type { WidgetDataRequest } from '../../../services/widgetDataService'\n\n// Deal statuses that mean the deal is closed and must stop counting as current pipeline.\n//\n// This is a denylist, not an allowlist: `customer_deals.status` is a lenient `text` column\n// fed by the per-tenant `deal_status` dictionary, so a status unknown to code counts as OPEN\n// and keeps contributing to the chart. That keeps tenant-specific stages visible and mirrors\n// `customers/lib/interactionStatus.ts`, which treats an unknown interaction status as open.\n//\n// Every vocabulary that reaches the column has to be listed here:\n// - `win` / `lost` are written by the deal closure UI and the kanban board.\n// - `won` / `lost` are written verbatim by the `customers.update_deal_stage` AI tool, whose\n// free-form `toStage` is passed straight through to `status`.\n// - `closed` is a seeded `deal_status` dictionary value, persisted by the dashboards\n// analytics seed and treated as terminal by the demo-data generator.\nexport const CLOSED_DEAL_STATUSES = ['win', 'loose', 'won', 'lost', 'closed'] as const\n\n// A deal is also closed when `closure_outcome` records the outcome, which the deals-summary\n// KPI already treats as terminal: `api/deals/summary/route.ts` counts a deal as won or lost\n// when EITHER `status` OR `closure_outcome` says so. The two columns can disagree \u2014 the CRUD\n// API accepts `closureOutcome` on its own, and the AI tool writes `status` without ever setting\n// it \u2014 so the chart has to test both signals to agree with those KPI cards (#4668).\n//\n// The `closure_outcome` side is expressed as `IS NULL` rather than as a `neq` denylist for two\n// reasons, and swapping it for `neq` would be a silent regression:\n// - `closure_outcome` is nullable, and every OPEN deal holds NULL there. `neq` renders as\n// `column != ?`, and in SQL `NULL != 'won'` is NULL, not true \u2014 a `neq` filter would drop\n// every open deal and empty the chart. `status` has no such problem: it is `text not null\n// default 'open'`, which is why the status side stays a denylist.\n// - Unlike `status`, this column is not fed by a per-tenant dictionary. Every write path\n// validates it against the same closed `z.enum(['won', 'lost'])` (`data/validators.ts`,\n// `api/deals/[id]/route.ts`, `api/deals/[id]/stats/route.ts`), so a non-null value always\n// means closed and there is no tenant-specific vocabulary to keep visible.\nexport const OPEN_DEAL_CLOSURE_OUTCOME_FILTER = {\n field: 'closureOutcome',\n operator: 'is_null',\n} as const\n\nexport const PIPELINE_STATUS_SCOPES = ['open', 'all'] as const\n\nexport type PipelineStatusScope = typeof PIPELINE_STATUS_SCOPES[number]\n\nexport type PipelineSummarySettings = {\n dateRange: DateRangePreset\n statusScope: PipelineStatusScope\n}\n\nexport const DEFAULT_SETTINGS: PipelineSummarySettings = {\n dateRange: 'this_month',\n statusScope: 'open',\n}\n\nfunction isValidStatusScope(value: unknown): value is PipelineStatusScope {\n return typeof value === 'string' && (PIPELINE_STATUS_SCOPES as readonly string[]).includes(value)\n}\n\nexport function hydrateSettings(raw: unknown): PipelineSummarySettings {\n if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS }\n const obj = raw as Record<string, unknown>\n return {\n dateRange: isValidDateRangePreset(obj.dateRange) ? obj.dateRange : DEFAULT_SETTINGS.dateRange,\n statusScope: isValidStatusScope(obj.statusScope) ? obj.statusScope : DEFAULT_SETTINGS.statusScope,\n }\n}\n\nexport function dehydrateSettings(settings: PipelineSummarySettings): Record<string, unknown> {\n return {\n dateRange: settings.dateRange,\n statusScope: settings.statusScope,\n }\n}\n\nexport function buildPipelineDataRequest(settings: PipelineSummarySettings): WidgetDataRequest {\n const request: WidgetDataRequest = {\n entityType: 'customers:deals',\n metric: {\n field: 'valueAmount',\n aggregate: 'sum',\n },\n groupBy: {\n field: 'pipelineStage',\n resolveLabels: true,\n },\n dateRange: {\n field: 'createdAt',\n preset: settings.dateRange,\n },\n }\n\n if (settings.statusScope === 'open') {\n request.filters = [\n ...CLOSED_DEAL_STATUSES.map((status) => ({\n field: 'status',\n operator: 'neq' as const,\n value: status,\n })),\n { ...OPEN_DEAL_CLOSURE_OUTCOME_FILTER },\n ]\n }\n\n return request\n}\n"],
5
5
  "mappings": "AAAA,SAA+B,8BAA8B;AAgBtD,MAAM,uBAAuB,CAAC,OAAO,SAAS,OAAO,QAAQ,QAAQ;AAkBrE,MAAM,mCAAmC;AAAA,EAC9C,OAAO;AAAA,EACP,UAAU;AACZ;AAEO,MAAM,yBAAyB,CAAC,QAAQ,KAAK;AAS7C,MAAM,mBAA4C;AAAA,EACvD,WAAW;AAAA,EACX,aAAa;AACf;AAEA,SAAS,mBAAmB,OAA8C;AACxE,SAAO,OAAO,UAAU,YAAa,uBAA6C,SAAS,KAAK;AAClG;AAEO,SAAS,gBAAgB,KAAuC;AACrE,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO,EAAE,GAAG,iBAAiB;AAClE,QAAM,MAAM;AACZ,SAAO;AAAA,IACL,WAAW,uBAAuB,IAAI,SAAS,IAAI,IAAI,YAAY,iBAAiB;AAAA,IACpF,aAAa,mBAAmB,IAAI,WAAW,IAAI,IAAI,cAAc,iBAAiB;AAAA,EACxF;AACF;AAEO,SAAS,kBAAkB,UAA4D;AAC5F,SAAO;AAAA,IACL,WAAW,SAAS;AAAA,IACpB,aAAa,SAAS;AAAA,EACxB;AACF;AAEO,SAAS,yBAAyB,UAAsD;AAC7F,QAAM,UAA6B;AAAA,IACjC,YAAY;AAAA,IACZ,QAAQ;AAAA,MACN,OAAO;AAAA,MACP,WAAW;AAAA,IACb;AAAA,IACA,SAAS;AAAA,MACP,OAAO;AAAA,MACP,eAAe;AAAA,IACjB;AAAA,IACA,WAAW;AAAA,MACT,OAAO;AAAA,MACP,QAAQ,SAAS;AAAA,IACnB;AAAA,EACF;AAEA,MAAI,SAAS,gBAAgB,QAAQ;AACnC,YAAQ,UAAU;AAAA,MAChB,GAAG,qBAAqB,IAAI,CAAC,YAAY;AAAA,QACvC,OAAO;AAAA,QACP,UAAU;AAAA,QACV,OAAO;AAAA,MACT,EAAE;AAAA,MACF,EAAE,GAAG,iCAAiC;AAAA,IACxC;AAAA,EACF;AAEA,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -2,6 +2,7 @@ const notificationTypes = [
2
2
  {
3
3
  type: "eudr.statement.submitted",
4
4
  module: "eudr",
5
+ channels: ["in_app", "email"],
5
6
  titleKey: "eudr.notifications.statement.submitted.title",
6
7
  bodyKey: "eudr.notifications.statement.submitted.body",
7
8
  icon: "file-check",
@@ -12,6 +13,7 @@ const notificationTypes = [
12
13
  {
13
14
  type: "eudr.statement.reference_issued",
14
15
  module: "eudr",
16
+ channels: ["in_app", "email"],
15
17
  titleKey: "eudr.notifications.statement.referenceIssued.title",
16
18
  bodyKey: "eudr.notifications.statement.referenceIssued.body",
17
19
  icon: "badge-check",
@@ -22,6 +24,7 @@ const notificationTypes = [
22
24
  {
23
25
  type: "eudr.statement.withdrawn",
24
26
  module: "eudr",
27
+ channels: ["in_app", "email"],
25
28
  titleKey: "eudr.notifications.statement.withdrawn.title",
26
29
  bodyKey: "eudr.notifications.statement.withdrawn.body",
27
30
  icon: "undo",
@@ -32,6 +35,7 @@ const notificationTypes = [
32
35
  {
33
36
  type: "eudr.risk.non_negligible",
34
37
  module: "eudr",
38
+ channels: ["in_app", "email"],
35
39
  titleKey: "eudr.notifications.risk.nonNegligible.title",
36
40
  bodyKey: "eudr.notifications.risk.nonNegligible.body",
37
41
  icon: "alert-triangle",
@@ -42,6 +46,7 @@ const notificationTypes = [
42
46
  {
43
47
  type: "eudr.mitigation.completed",
44
48
  module: "eudr",
49
+ channels: ["in_app", "email"],
45
50
  titleKey: "eudr.notifications.mitigation.completed.title",
46
51
  bodyKey: "eudr.notifications.mitigation.completed.body",
47
52
  icon: "check-circle",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/eudr/notifications.ts"],
4
- "sourcesContent": ["import type { NotificationTypeDefinition } from '@open-mercato/shared/modules/notifications/types'\n\nexport const notificationTypes: NotificationTypeDefinition[] = [\n {\n type: 'eudr.statement.submitted',\n module: 'eudr',\n titleKey: 'eudr.notifications.statement.submitted.title',\n bodyKey: 'eudr.notifications.statement.submitted.body',\n icon: 'file-check',\n severity: 'info',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.statement.reference_issued',\n module: 'eudr',\n titleKey: 'eudr.notifications.statement.referenceIssued.title',\n bodyKey: 'eudr.notifications.statement.referenceIssued.body',\n icon: 'badge-check',\n severity: 'success',\n actions: [],\n expiresAfterHours: 720,\n },\n {\n type: 'eudr.statement.withdrawn',\n module: 'eudr',\n titleKey: 'eudr.notifications.statement.withdrawn.title',\n bodyKey: 'eudr.notifications.statement.withdrawn.body',\n icon: 'undo',\n severity: 'warning',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.risk.non_negligible',\n module: 'eudr',\n titleKey: 'eudr.notifications.risk.nonNegligible.title',\n bodyKey: 'eudr.notifications.risk.nonNegligible.body',\n icon: 'alert-triangle',\n severity: 'warning',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.mitigation.completed',\n module: 'eudr',\n titleKey: 'eudr.notifications.mitigation.completed.title',\n bodyKey: 'eudr.notifications.mitigation.completed.body',\n icon: 'check-circle',\n severity: 'success',\n actions: [],\n expiresAfterHours: 168,\n },\n]\n\nexport default notificationTypes\n"],
5
- "mappings": "AAEO,MAAM,oBAAkD;AAAA,EAC7D;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AACF;AAEA,IAAO,wBAAQ;",
4
+ "sourcesContent": ["import type { NotificationTypeDefinition } from '@open-mercato/shared/modules/notifications/types'\n\nexport const notificationTypes: NotificationTypeDefinition[] = [\n {\n type: 'eudr.statement.submitted',\n module: 'eudr',\n channels: ['in_app', 'email'],\n titleKey: 'eudr.notifications.statement.submitted.title',\n bodyKey: 'eudr.notifications.statement.submitted.body',\n icon: 'file-check',\n severity: 'info',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.statement.reference_issued',\n module: 'eudr',\n channels: ['in_app', 'email'],\n titleKey: 'eudr.notifications.statement.referenceIssued.title',\n bodyKey: 'eudr.notifications.statement.referenceIssued.body',\n icon: 'badge-check',\n severity: 'success',\n actions: [],\n expiresAfterHours: 720,\n },\n {\n type: 'eudr.statement.withdrawn',\n module: 'eudr',\n channels: ['in_app', 'email'],\n titleKey: 'eudr.notifications.statement.withdrawn.title',\n bodyKey: 'eudr.notifications.statement.withdrawn.body',\n icon: 'undo',\n severity: 'warning',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.risk.non_negligible',\n module: 'eudr',\n channels: ['in_app', 'email'],\n titleKey: 'eudr.notifications.risk.nonNegligible.title',\n bodyKey: 'eudr.notifications.risk.nonNegligible.body',\n icon: 'alert-triangle',\n severity: 'warning',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.mitigation.completed',\n module: 'eudr',\n channels: ['in_app', 'email'],\n titleKey: 'eudr.notifications.mitigation.completed.title',\n bodyKey: 'eudr.notifications.mitigation.completed.body',\n icon: 'check-circle',\n severity: 'success',\n actions: [],\n expiresAfterHours: 168,\n },\n]\n\nexport default notificationTypes\n"],
5
+ "mappings": "AAEO,MAAM,oBAAkD;AAAA,EAC7D;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AACF;AAEA,IAAO,wBAAQ;",
6
6
  "names": []
7
7
  }
@@ -119,6 +119,42 @@ function buildFieldPairs(recordId, doc) {
119
119
  }
120
120
  return pairs;
121
121
  }
122
+ const SIGNATURE_SEPARATOR = String.fromCharCode(0);
123
+ function tokenSignature(row) {
124
+ return [
125
+ String(row.field ?? ""),
126
+ String(row.token_hash ?? ""),
127
+ row.token == null ? "" : String(row.token)
128
+ ].join(SIGNATURE_SEPARATOR);
129
+ }
130
+ function tallyOf(rows) {
131
+ const tally = /* @__PURE__ */ new Map();
132
+ for (const row of rows) {
133
+ const signature = tokenSignature(row);
134
+ tally.set(signature, (tally.get(signature) ?? 0) + 1);
135
+ }
136
+ return tally;
137
+ }
138
+ function tallyEquals(a, b) {
139
+ const left = a ?? /* @__PURE__ */ new Map();
140
+ const right = b ?? /* @__PURE__ */ new Map();
141
+ if (left.size !== right.size) return false;
142
+ for (const [key, count] of left.entries()) {
143
+ if (right.get(key) !== count) return false;
144
+ }
145
+ return true;
146
+ }
147
+ function tallyTokenRows(rows, keyOf) {
148
+ const tallies = /* @__PURE__ */ new Map();
149
+ for (const row of rows) {
150
+ const key = keyOf(row);
151
+ const tally = tallies.get(key) ?? /* @__PURE__ */ new Map();
152
+ const signature = tokenSignature(row);
153
+ tally.set(signature, (tally.get(signature) ?? 0) + 1);
154
+ tallies.set(key, tally);
155
+ }
156
+ return tallies;
157
+ }
122
158
  async function replaceSearchTokensForRecord(db, params, options) {
123
159
  const rows = buildSearchTokenRows(params);
124
160
  const config = params.config ?? resolveSearchConfig();
@@ -126,6 +162,29 @@ async function replaceSearchTokensForRecord(db, params, options) {
126
162
  const organizationId = params.organizationId ?? null;
127
163
  const tenantId = params.tenantId ?? null;
128
164
  const fieldPairs = buildFieldPairs(String(params.recordId), params.doc);
165
+ const scopeTokenQuery = (query) => {
166
+ let scoped = query.where("entity_type", "=", params.entityType).where(sql`organization_id is not distinct from ${organizationId}`).where(sql`tenant_id is not distinct from ${tenantId}`).where("entity_id", "=", String(params.recordId));
167
+ if (fieldPairs.length) {
168
+ scoped = scoped.where("field", "in", fieldPairs.map(([, field]) => field));
169
+ }
170
+ return scoped;
171
+ };
172
+ const reader = options?.trx ?? db;
173
+ const storedCountRows = await scopeTokenQuery(
174
+ reader.selectFrom("search_tokens").select(sql`count(*)`.as("token_count"))
175
+ ).execute();
176
+ const storedCount = Number(storedCountRows[0]?.token_count ?? 0);
177
+ let unchanged = storedCount === rows.length;
178
+ if (unchanged && rows.length) {
179
+ const stored = await scopeTokenQuery(
180
+ reader.selectFrom("search_tokens").select(["field", "token_hash", "token"])
181
+ ).limit(rows.length).execute();
182
+ unchanged = tallyEquals(tallyOf(rows), tallyOf(stored));
183
+ }
184
+ if (unchanged) {
185
+ debug("record.skip", { entityType: params.entityType, recordId: params.recordId, tokenCount: rows.length });
186
+ return;
187
+ }
129
188
  const writeTokens = async (executor) => {
130
189
  let deleteQuery = executor.deleteFrom("search_tokens").where("entity_type", "=", params.entityType).where(sql`organization_id is not distinct from ${organizationId}`).where(sql`tenant_id is not distinct from ${tenantId}`);
131
190
  if (fieldPairs.length) {
@@ -157,34 +216,6 @@ async function deleteSearchTokensForRecord(db, params, options) {
157
216
  const executor = options?.trx ?? db;
158
217
  await executor.deleteFrom("search_tokens").where("entity_type", "=", params.entityType).where("entity_id", "=", String(params.recordId)).where(sql`organization_id is not distinct from ${organizationId}`).where(sql`tenant_id is not distinct from ${tenantId}`).execute();
159
218
  }
160
- const SIGNATURE_SEPARATOR = String.fromCharCode(0);
161
- function tokenSignature(row) {
162
- return [
163
- String(row.field ?? ""),
164
- String(row.token_hash ?? ""),
165
- row.token == null ? "" : String(row.token)
166
- ].join(SIGNATURE_SEPARATOR);
167
- }
168
- function tallyEquals(a, b) {
169
- const left = a ?? /* @__PURE__ */ new Map();
170
- const right = b ?? /* @__PURE__ */ new Map();
171
- if (left.size !== right.size) return false;
172
- for (const [key, count] of left.entries()) {
173
- if (right.get(key) !== count) return false;
174
- }
175
- return true;
176
- }
177
- function tallyTokenRows(rows, keyOf) {
178
- const tallies = /* @__PURE__ */ new Map();
179
- for (const row of rows) {
180
- const key = keyOf(row);
181
- const tally = tallies.get(key) ?? /* @__PURE__ */ new Map();
182
- const signature = tokenSignature(row);
183
- tally.set(signature, (tally.get(signature) ?? 0) + 1);
184
- tallies.set(key, tally);
185
- }
186
- return tallies;
187
- }
188
219
  async function replaceSearchTokensForBatch(db, payloads) {
189
220
  if (!payloads.length) return;
190
221
  const config = resolveSearchConfig();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/query_index/lib/search-tokens.ts"],
4
- "sourcesContent": ["import { type Kysely, type Transaction, sql } from 'kysely'\nimport {\n isSearchFieldBlocklisted,\n resolveSearchConfig,\n resolveSearchTokenLimits,\n type SearchConfig,\n} from '@open-mercato/shared/lib/search/config'\nimport { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'search-tokens' })\n\nconst INSERT_BATCH_SIZE = 500\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (size <= 0) return [items]\n const out: T[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nexport type SearchTokenRow = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n field: string\n token_hash: string\n token?: string | null\n}\n\ntype BuildTokenOptions = {\n entityType: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n doc?: Record<string, unknown> | null\n config?: SearchConfig\n}\n\nconst DEFAULT_SCOPE = { organizationId: null, tenantId: null }\ntype EntityFieldPair = [string, string]\ntype SearchTokenExecutor = Kysely<any> | Transaction<any>\n\nexport const isSearchDebugEnabled = (): boolean => {\n return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true\n}\n\nconst debug = (event: string, payload: Record<string, unknown>) => {\n if (!isSearchDebugEnabled()) return\n try {\n logger.debug('Search token event', { event, payload })\n } catch {\n // ignore\n }\n}\n\nfunction collectTextValues(value: unknown): string[] {\n if (typeof value === 'string') return [value]\n if (Array.isArray(value)) {\n const out: string[] = []\n for (const entry of value) {\n if (typeof entry === 'string') out.push(entry)\n }\n return out\n }\n return []\n}\n\nfunction shouldIndexField(\n field: string,\n value: unknown,\n config: SearchConfig,\n entityType: string | null,\n): boolean {\n if (typeof value !== 'string' && !Array.isArray(value)) return false\n const lower = field.toLowerCase()\n if (lower === 'id' || lower.endsWith('_id') || lower.endsWith('.id')) return false\n if (lower.endsWith('_at')) return false\n if (['created_at', 'updated_at', 'deleted_at', 'tenant_id', 'organization_id'].includes(lower)) return false\n if (isSearchFieldBlocklisted(field, entityType, config)) return false\n return collectTextValues(value).some((text) => text.length > 0)\n}\n\nexport function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return []\n if (!params.doc) return []\n const tokens: SearchTokenRow[] = []\n const capturePairs = isSearchDebugEnabled() && params.entityType === 'customers:customer_deal'\n const debugPairs: Array<{ field: string; hash: string }> = []\n const scope = {\n organizationId: params.organizationId ?? DEFAULT_SCOPE.organizationId,\n tenantId: params.tenantId ?? DEFAULT_SCOPE.tenantId,\n }\n const limits = resolveSearchTokenLimits(config)\n const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY\n const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n\n for (const [field, rawValue] of Object.entries(params.doc)) {\n if (tokens.length >= recordLimit) break\n if (!shouldIndexField(field, rawValue, config, params.entityType)) continue\n const values = collectTextValues(rawValue)\n const seen = new Set<string>()\n let fieldTokenCount = 0\n for (const text of values) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const remainingLimit = Math.min(recordLimit - tokens.length, fieldLimit - fieldTokenCount)\n const candidateLimit = fieldTokenCount + remainingLimit\n const tokenConfig = Number.isFinite(candidateLimit)\n ? { ...config, maxTokensPerField: candidateLimit }\n : config\n const { tokens: textTokens, hashes } = tokenizeText(text, tokenConfig)\n for (let i = 0; i < textTokens.length; i += 1) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const token = textTokens[i]\n const hash = hashes[i]\n const dedupeKey = `${field}|${hash}`\n if (seen.has(dedupeKey)) continue\n seen.add(dedupeKey)\n fieldTokenCount += 1\n debug('token.generated', { entityType: params.entityType, recordId: params.recordId, field, hash })\n tokens.push({\n entity_type: params.entityType,\n entity_id: String(params.recordId),\n organization_id: scope.organizationId,\n tenant_id: scope.tenantId,\n field,\n token_hash: hash,\n token: config.storeRawTokens ? token : null,\n })\n if (capturePairs) {\n debugPairs.push({ field, hash })\n }\n }\n }\n }\n if (capturePairs) {\n debug('deal.tokens', {\n entityType: params.entityType,\n recordId: params.recordId,\n tokenCount: debugPairs.length,\n tokens: debugPairs,\n })\n }\n debug('doc.completed', { entityType: params.entityType, recordId: params.recordId, tokenCount: tokens.length })\n\n return tokens\n}\n\nfunction buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null): EntityFieldPair[] {\n if (!doc) return []\n const pairs: EntityFieldPair[] = []\n const dedupe = new Set<string>()\n for (const field of Object.keys(doc)) {\n const key = `${recordId}|${field}`\n if (dedupe.has(key)) continue\n dedupe.add(key)\n pairs.push([recordId, field])\n }\n return pairs\n}\n\nexport async function replaceSearchTokensForRecord(\n db: Kysely<any>,\n params: BuildTokenOptions,\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const rows = buildSearchTokenRows(params)\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)\n\n const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {\n let deleteQuery = executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n if (fieldPairs.length) {\n deleteQuery = deleteQuery.where((eb: any) => eb.or(\n fieldPairs.map(([rid, field]) => eb.and([\n eb('entity_id' as any, '=', rid),\n eb('field' as any, '=', field),\n ])),\n ))\n } else {\n deleteQuery = deleteQuery.where('entity_id' as any, '=', String(params.recordId))\n }\n await deleteQuery.execute()\n if (!rows.length) return\n const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {\n await executor.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n }\n\n if (options?.trx) {\n await writeTokens(options.trx)\n return\n }\n\n await db.transaction().execute(writeTokens)\n}\n\nexport async function deleteSearchTokensForRecord(\n db: Kysely<any>,\n params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const executor = options?.trx ?? db\n await executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where('entity_id' as any, '=', String(params.recordId))\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .execute()\n}\n\n// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`\n// would otherwise sign identically to field `a` + hash `b c`.\nconst SIGNATURE_SEPARATOR = String.fromCharCode(0)\n\n// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a\n// stored NULL has to sign the same as the `null` a freshly built row carries \u2014 otherwise every\n// record compares as changed and the skip never fires.\nfunction tokenSignature(row: { field?: unknown; token_hash?: unknown; token?: unknown }): string {\n return [\n String(row.field ?? ''),\n String(row.token_hash ?? ''),\n row.token == null ? '' : String(row.token),\n ].join(SIGNATURE_SEPARATOR)\n}\n\n// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement\n// defect, and a set comparison reads such a record as already correct and preserves the duplicates\n// forever. Counting sends it through a full rewrite, which collapses them.\nfunction tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {\n const left = a ?? new Map<string, number>()\n const right = b ?? new Map<string, number>()\n if (left.size !== right.size) return false\n for (const [key, count] of left.entries()) {\n if (right.get(key) !== count) return false\n }\n return true\n}\n\nfunction tallyTokenRows<TRow extends { field?: unknown; token_hash?: unknown; token?: unknown }>(\n rows: Iterable<TRow>,\n keyOf: (row: TRow) => string\n): Map<string, Map<string, number>> {\n const tallies = new Map<string, Map<string, number>>()\n for (const row of rows) {\n const key = keyOf(row)\n const tally = tallies.get(key) ?? new Map<string, number>()\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n tallies.set(key, tally)\n }\n return tallies\n}\n\nexport async function replaceSearchTokensForBatch(\n db: Kysely<any>,\n payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>\n): Promise<void> {\n if (!payloads.length) return\n const config = resolveSearchConfig()\n if (!config.enabled) return\n\n const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config }))\n if (!rows.length) {\n const entityType = payloads[0]?.entityType\n if (!entityType) return\n const ids = payloads.map((p) => String(p.recordId))\n await db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityType)\n .where('entity_id' as any, 'in', ids)\n .execute()\n return\n }\n\n const scopeKey = (org: string | null, tenant: string | null) => `${org ?? '__null__'}|${tenant ?? '__null__'}`\n const scopeBuckets = new Map<string, { organizationId: string | null; tenantId: string | null; ids: Set<string> }>()\n\n for (const payload of payloads) {\n const org = payload.organizationId ?? null\n const tenant = payload.tenantId ?? null\n const key = scopeKey(org, tenant)\n const bucket = scopeBuckets.get(key) ?? { organizationId: org, tenantId: tenant, ids: new Set<string>() }\n bucket.ids.add(String(payload.recordId))\n scopeBuckets.set(key, bucket)\n }\n\n const recordKeyOf = (row: SearchTokenRow) =>\n `${scopeKey(row.organization_id ?? null, row.tenant_id ?? null)}|${String(row.entity_id)}`\n const builtTally = tallyTokenRows(rows, recordKeyOf)\n\n // Read outside the transaction, deliberately. The comparison decides only whether to skip a\n // rewrite, so a concurrent writer costs us at most a rewrite we declined \u2014 declined because the\n // table already held exactly the rows this call wanted to write. One ordering is worth naming\n // though: if the read matches and a concurrent writer then commits tokens built from a *staler*\n // doc, the unconditional rewrite this call used to perform would have overwritten them by\n // accident. It no longer does, so those stale rows survive until the record's next write. That\n // is a repair we lose, not a guarantee we break.\n const changedIdsByBucket = new Map<string, Set<string>>()\n for (const [key, bucket] of scopeBuckets.entries()) {\n const ids = Array.from(bucket.ids)\n const builtCountById = new Map<string, number>()\n for (const id of ids) {\n let total = 0\n const tally = builtTally.get(`${key}|${id}`)\n if (tally) for (const count of tally.values()) total += count\n builtCountById.set(id, total)\n }\n\n // Count probe first. Its result is one row per record in the batch, so it is bounded by the\n // batch size \u2014 unlike a bare row read, which would be bounded only by how many token rows the\n // table already holds for these ids, a quantity this function does not control and (per #4681)\n // has no reason to trust.\n const storedCounts = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, sql<number>`count(*)`.as('token_count') as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', ids)\n .groupBy('entity_id' as any)\n .execute()\n const storedCountById = new Map<string, number>()\n for (const row of storedCounts as any[]) {\n storedCountById.set(String(row.entity_id), Number(row.token_count))\n }\n\n const changed = new Set<string>()\n // A record whose stored row count already differs is changed, whatever the rows say \u2014 the\n // duplicate case from #4681 resolves here without ever materializing the duplicated rows.\n const contentCandidates = ids.filter((id) => {\n const builtCount = builtCountById.get(id) ?? 0\n if ((storedCountById.get(id) ?? 0) !== builtCount) {\n changed.add(id)\n return false\n }\n return builtCount > 0\n })\n\n if (contentCandidates.length) {\n const rowBudget = contentCandidates.reduce((sum, id) => sum + (builtCountById.get(id) ?? 0), 0)\n const stored = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, 'field' as any, 'token_hash' as any, 'token' as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', contentCandidates)\n // Counts already match, so this cannot truncate \u2014 it bounds the damage if a concurrent\n // writer inserts between the probe and this read. A truncated read compares as changed,\n // which costs a rewrite rather than a wrong skip.\n .limit(rowBudget)\n .execute()\n const storedTally = tallyTokenRows(stored as any[], (row) => String(row.entity_id))\n for (const id of contentCandidates) {\n if (!tallyEquals(builtTally.get(`${key}|${id}`), storedTally.get(id))) changed.add(id)\n }\n }\n changedIdsByBucket.set(key, changed)\n }\n\n const changedRecordKeys = new Set<string>()\n for (const [key, changed] of changedIdsByBucket.entries()) {\n for (const id of changed) changedRecordKeys.add(`${key}|${id}`)\n }\n debug('batch.skip', {\n entityType: payloads[0].entityType,\n recordCount: payloads.length,\n changedCount: changedRecordKeys.size,\n })\n if (!changedRecordKeys.size) return\n\n await db.transaction().execute(async (trx) => {\n for (const [key, bucket] of scopeBuckets.entries()) {\n const changed = changedIdsByBucket.get(key)\n if (!changed?.size) continue\n // Delete by entity_id: a batch replaces all of a record's tokens, and a per-field OR over the\n // whole batch overflows the query compiler's call stack on large batches.\n const deleteQuery = trx\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', Array.from(changed))\n await deleteQuery.execute()\n }\n const payloadWithTimestamps = rows\n .filter((row) => changedRecordKeys.has(recordKeyOf(row)))\n .map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {\n await trx.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n })\n}\n"],
5
- "mappings": "AAAA,SAAwC,WAAW;AACnD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAClC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,aAAa,EAAE,MAAM,EAAE,WAAW,gBAAgB,CAAC;AAE/E,MAAM,oBAAoB;AAE1B,SAAS,MAAS,OAAY,MAAqB;AACjD,MAAI,QAAQ,EAAG,QAAO,CAAC,KAAK;AAC5B,QAAM,MAAa,CAAC;AACpB,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,KAAM,KAAI,KAAK,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC;AAC9E,SAAO;AACT;AAqBA,MAAM,gBAAgB,EAAE,gBAAgB,MAAM,UAAU,KAAK;AAItD,MAAM,uBAAuB,MAAe;AACjD,SAAO,kBAAkB,QAAQ,IAAI,mBAAmB,EAAE,MAAM;AAClE;AAEA,MAAM,QAAQ,CAAC,OAAe,YAAqC;AACjE,MAAI,CAAC,qBAAqB,EAAG;AAC7B,MAAI;AACF,WAAO,MAAM,sBAAsB,EAAE,OAAO,QAAQ,CAAC;AAAA,EACvD,QAAQ;AAAA,EAER;AACF;AAEA,SAAS,kBAAkB,OAA0B;AACnD,MAAI,OAAO,UAAU,SAAU,QAAO,CAAC,KAAK;AAC5C,MAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,UAAM,MAAgB,CAAC;AACvB,eAAW,SAAS,OAAO;AACzB,UAAI,OAAO,UAAU,SAAU,KAAI,KAAK,KAAK;AAAA,IAC/C;AACA,WAAO;AAAA,EACT;AACA,SAAO,CAAC;AACV;AAEA,SAAS,iBACP,OACA,OACA,QACA,YACS;AACT,MAAI,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO;AAC/D,QAAM,QAAQ,MAAM,YAAY;AAChC,MAAI,UAAU,QAAQ,MAAM,SAAS,KAAK,KAAK,MAAM,SAAS,KAAK,EAAG,QAAO;AAC7E,MAAI,MAAM,SAAS,KAAK,EAAG,QAAO;AAClC,MAAI,CAAC,cAAc,cAAc,cAAc,aAAa,iBAAiB,EAAE,SAAS,KAAK,EAAG,QAAO;AACvG,MAAI,yBAAyB,OAAO,YAAY,MAAM,EAAG,QAAO;AAChE,SAAO,kBAAkB,KAAK,EAAE,KAAK,CAAC,SAAS,KAAK,SAAS,CAAC;AAChE;AAEO,SAAS,qBAAqB,QAA6C;AAChF,QAAM,SAAS,OAAO,UAAU,oBAAoB;AACpD,MAAI,CAAC,OAAO,QAAS,QAAO,CAAC;AAC7B,MAAI,CAAC,OAAO,IAAK,QAAO,CAAC;AACzB,QAAM,SAA2B,CAAC;AAClC,QAAM,eAAe,qBAAqB,KAAK,OAAO,eAAe;AACrE,QAAM,aAAqD,CAAC;AAC5D,QAAM,QAAQ;AAAA,IACZ,gBAAgB,OAAO,kBAAkB,cAAc;AAAA,IACvD,UAAU,OAAO,YAAY,cAAc;AAAA,EAC7C;AACA,QAAM,SAAS,yBAAyB,MAAM;AAC9C,QAAM,cAAc,OAAO,qBAAqB,IAAI,OAAO,qBAAqB,OAAO;AACvF,QAAM,aAAa,OAAO,oBAAoB,IAAI,OAAO,oBAAoB,OAAO;AAEpF,aAAW,CAAC,OAAO,QAAQ,KAAK,OAAO,QAAQ,OAAO,GAAG,GAAG;AAC1D,QAAI,OAAO,UAAU,YAAa;AAClC,QAAI,CAAC,iBAAiB,OAAO,UAAU,QAAQ,OAAO,UAAU,EAAG;AACnE,UAAM,SAAS,kBAAkB,QAAQ;AACzC,UAAM,OAAO,oBAAI,IAAY;AAC7B,QAAI,kBAAkB;AACtB,eAAW,QAAQ,QAAQ;AACzB,UAAI,OAAO,UAAU,eAAe,mBAAmB,WAAY;AACnE,YAAM,iBAAiB,KAAK,IAAI,cAAc,OAAO,QAAQ,aAAa,eAAe;AACzF,YAAM,iBAAiB,kBAAkB;AACzC,YAAM,cAAc,OAAO,SAAS,cAAc,IAC9C,EAAE,GAAG,QAAQ,mBAAmB,eAAe,IAC/C;AACJ,YAAM,EAAE,QAAQ,YAAY,OAAO,IAAI,aAAa,MAAM,WAAW;AACrE,eAAS,IAAI,GAAG,IAAI,WAAW,QAAQ,KAAK,GAAG;AAC7C,YAAI,OAAO,UAAU,eAAe,mBAAmB,WAAY;AACnE,cAAM,QAAQ,WAAW,CAAC;AAC1B,cAAM,OAAO,OAAO,CAAC;AACrB,cAAM,YAAY,GAAG,KAAK,IAAI,IAAI;AAClC,YAAI,KAAK,IAAI,SAAS,EAAG;AACzB,aAAK,IAAI,SAAS;AAClB,2BAAmB;AACnB,cAAM,mBAAmB,EAAE,YAAY,OAAO,YAAY,UAAU,OAAO,UAAU,OAAO,KAAK,CAAC;AAClG,eAAO,KAAK;AAAA,UACV,aAAa,OAAO;AAAA,UACpB,WAAW,OAAO,OAAO,QAAQ;AAAA,UACjC,iBAAiB,MAAM;AAAA,UACvB,WAAW,MAAM;AAAA,UACjB;AAAA,UACA,YAAY;AAAA,UACZ,OAAO,OAAO,iBAAiB,QAAQ;AAAA,QACzC,CAAC;AACD,YAAI,cAAc;AAChB,qBAAW,KAAK,EAAE,OAAO,KAAK,CAAC;AAAA,QACjC;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,MAAI,cAAc;AAChB,UAAM,eAAe;AAAA,MACnB,YAAY,OAAO;AAAA,MACnB,UAAU,OAAO;AAAA,MACjB,YAAY,WAAW;AAAA,MACvB,QAAQ;AAAA,IACV,CAAC;AAAA,EACH;AACA,QAAM,iBAAiB,EAAE,YAAY,OAAO,YAAY,UAAU,OAAO,UAAU,YAAY,OAAO,OAAO,CAAC;AAE9G,SAAO;AACT;AAEA,SAAS,gBAAgB,UAAkB,KAAyD;AAClG,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,QAAM,QAA2B,CAAC;AAClC,QAAM,SAAS,oBAAI,IAAY;AAC/B,aAAW,SAAS,OAAO,KAAK,GAAG,GAAG;AACpC,UAAM,MAAM,GAAG,QAAQ,IAAI,KAAK;AAChC,QAAI,OAAO,IAAI,GAAG,EAAG;AACrB,WAAO,IAAI,GAAG;AACd,UAAM,KAAK,CAAC,UAAU,KAAK,CAAC;AAAA,EAC9B;AACA,SAAO;AACT;AAEA,eAAsB,6BACpB,IACA,QACA,SACe;AACf,QAAM,OAAO,qBAAqB,MAAM;AACxC,QAAM,SAAS,OAAO,UAAU,oBAAoB;AACpD,MAAI,CAAC,OAAO,QAAS;AACrB,QAAM,iBAAiB,OAAO,kBAAkB;AAChD,QAAM,WAAW,OAAO,YAAY;AACpC,QAAM,aAAa,gBAAgB,OAAO,OAAO,QAAQ,GAAG,OAAO,GAAG;AAEtE,QAAM,cAAc,OAAO,aAAiD;AAC1E,QAAI,cAAc,SACf,WAAW,eAAsB,EACjC,MAAM,eAAsB,KAAK,OAAO,UAAU,EAClD,MAAM,2CAAoD,cAAc,EAAE,EAC1E,MAAM,qCAA8C,QAAQ,EAAE;AACjE,QAAI,WAAW,QAAQ;AACrB,oBAAc,YAAY,MAAM,CAAC,OAAY,GAAG;AAAA,QAC9C,WAAW,IAAI,CAAC,CAAC,KAAK,KAAK,MAAM,GAAG,IAAI;AAAA,UACtC,GAAG,aAAoB,KAAK,GAAG;AAAA,UAC/B,GAAG,SAAgB,KAAK,KAAK;AAAA,QAC/B,CAAC,CAAC;AAAA,MACJ,CAAC;AAAA,IACH,OAAO;AACL,oBAAc,YAAY,MAAM,aAAoB,KAAK,OAAO,OAAO,QAAQ,CAAC;AAAA,IAClF;AACA,UAAM,YAAY,QAAQ;AAC1B,QAAI,CAAC,KAAK,OAAQ;AAClB,UAAM,WAAW,KAAK,IAAI,CAAC,SAAS,EAAE,GAAG,KAAK,YAAY,WAAW,EAAE;AACvE,eAAW,SAAS,MAAM,UAAU,iBAAiB,GAAG;AACtD,YAAM,SAAS,WAAW,eAAsB,EAAE,OAAO,KAAY,EAAE,QAAQ;AAAA,IACjF;AAAA,EACF;AAEA,MAAI,SAAS,KAAK;AAChB,UAAM,YAAY,QAAQ,GAAG;AAC7B;AAAA,EACF;AAEA,QAAM,GAAG,YAAY,EAAE,QAAQ,WAAW;AAC5C;AAEA,eAAsB,4BACpB,IACA,QACA,SACe;AACf,QAAM,iBAAiB,OAAO,kBAAkB;AAChD,QAAM,WAAW,OAAO,YAAY;AACpC,QAAM,WAAW,SAAS,OAAO;AACjC,QAAM,SACH,WAAW,eAAsB,EACjC,MAAM,eAAsB,KAAK,OAAO,UAAU,EAClD,MAAM,aAAoB,KAAK,OAAO,OAAO,QAAQ,CAAC,EACtD,MAAM,2CAAoD,cAAc,EAAE,EAC1E,MAAM,qCAA8C,QAAQ,EAAE,EAC9D,QAAQ;AACb;AAIA,MAAM,sBAAsB,OAAO,aAAa,CAAC;AAKjD,SAAS,eAAe,KAAyE;AAC/F,SAAO;AAAA,IACL,OAAO,IAAI,SAAS,EAAE;AAAA,IACtB,OAAO,IAAI,cAAc,EAAE;AAAA,IAC3B,IAAI,SAAS,OAAO,KAAK,OAAO,IAAI,KAAK;AAAA,EAC3C,EAAE,KAAK,mBAAmB;AAC5B;AAKA,SAAS,YAAY,GAAoC,GAA6C;AACpG,QAAM,OAAO,KAAK,oBAAI,IAAoB;AAC1C,QAAM,QAAQ,KAAK,oBAAI,IAAoB;AAC3C,MAAI,KAAK,SAAS,MAAM,KAAM,QAAO;AACrC,aAAW,CAAC,KAAK,KAAK,KAAK,KAAK,QAAQ,GAAG;AACzC,QAAI,MAAM,IAAI,GAAG,MAAM,MAAO,QAAO;AAAA,EACvC;AACA,SAAO;AACT;AAEA,SAAS,eACP,MACA,OACkC;AAClC,QAAM,UAAU,oBAAI,IAAiC;AACrD,aAAW,OAAO,MAAM;AACtB,UAAM,MAAM,MAAM,GAAG;AACrB,UAAM,QAAQ,QAAQ,IAAI,GAAG,KAAK,oBAAI,IAAoB;AAC1D,UAAM,YAAY,eAAe,GAAG;AACpC,UAAM,IAAI,YAAY,MAAM,IAAI,SAAS,KAAK,KAAK,CAAC;AACpD,YAAQ,IAAI,KAAK,KAAK;AAAA,EACxB;AACA,SAAO;AACT;AAEA,eAAsB,4BACpB,IACA,UACe;AACf,MAAI,CAAC,SAAS,OAAQ;AACtB,QAAM,SAAS,oBAAoB;AACnC,MAAI,CAAC,OAAO,QAAS;AAErB,QAAM,OAAO,SAAS,QAAQ,CAAC,YAAY,qBAAqB,EAAE,GAAG,SAAS,OAAO,CAAC,CAAC;AACvF,MAAI,CAAC,KAAK,QAAQ;AAChB,UAAM,aAAa,SAAS,CAAC,GAAG;AAChC,QAAI,CAAC,WAAY;AACjB,UAAM,MAAM,SAAS,IAAI,CAAC,MAAM,OAAO,EAAE,QAAQ,CAAC;AAClD,UAAM,GACH,WAAW,eAAsB,EACjC,MAAM,eAAsB,KAAK,UAAU,EAC3C,MAAM,aAAoB,MAAM,GAAG,EACnC,QAAQ;AACX;AAAA,EACF;AAEA,QAAM,WAAW,CAAC,KAAoB,WAA0B,GAAG,OAAO,UAAU,IAAI,UAAU,UAAU;AAC5G,QAAM,eAAe,oBAAI,IAA0F;AAEnH,aAAW,WAAW,UAAU;AAC9B,UAAM,MAAM,QAAQ,kBAAkB;AACtC,UAAM,SAAS,QAAQ,YAAY;AACnC,UAAM,MAAM,SAAS,KAAK,MAAM;AAChC,UAAM,SAAS,aAAa,IAAI,GAAG,KAAK,EAAE,gBAAgB,KAAK,UAAU,QAAQ,KAAK,oBAAI,IAAY,EAAE;AACxG,WAAO,IAAI,IAAI,OAAO,QAAQ,QAAQ,CAAC;AACvC,iBAAa,IAAI,KAAK,MAAM;AAAA,EAC9B;AAEA,QAAM,cAAc,CAAC,QACnB,GAAG,SAAS,IAAI,mBAAmB,MAAM,IAAI,aAAa,IAAI,CAAC,IAAI,OAAO,IAAI,SAAS,CAAC;AAC1F,QAAM,aAAa,eAAe,MAAM,WAAW;AASnD,QAAM,qBAAqB,oBAAI,IAAyB;AACxD,aAAW,CAAC,KAAK,MAAM,KAAK,aAAa,QAAQ,GAAG;AAClD,UAAM,MAAM,MAAM,KAAK,OAAO,GAAG;AACjC,UAAM,iBAAiB,oBAAI,IAAoB;AAC/C,eAAW,MAAM,KAAK;AACpB,UAAI,QAAQ;AACZ,YAAM,QAAQ,WAAW,IAAI,GAAG,GAAG,IAAI,EAAE,EAAE;AAC3C,UAAI,MAAO,YAAW,SAAS,MAAM,OAAO,EAAG,UAAS;AACxD,qBAAe,IAAI,IAAI,KAAK;AAAA,IAC9B;AAMA,UAAM,eAAe,MAAM,GACxB,WAAW,eAAsB,EACjC,OAAO,CAAC,aAAoB,cAAsB,GAAG,aAAa,CAAQ,CAAC,EAC3E,MAAM,eAAsB,KAAK,SAAS,CAAC,EAAE,UAAU,EACvD,MAAM,2CAAoD,OAAO,cAAc,EAAE,EACjF,MAAM,qCAA8C,OAAO,QAAQ,EAAE,EACrE,MAAM,aAAoB,MAAM,GAAG,EACnC,QAAQ,WAAkB,EAC1B,QAAQ;AACX,UAAM,kBAAkB,oBAAI,IAAoB;AAChD,eAAW,OAAO,cAAuB;AACvC,sBAAgB,IAAI,OAAO,IAAI,SAAS,GAAG,OAAO,IAAI,WAAW,CAAC;AAAA,IACpE;AAEA,UAAM,UAAU,oBAAI,IAAY;AAGhC,UAAM,oBAAoB,IAAI,OAAO,CAAC,OAAO;AAC3C,YAAM,aAAa,eAAe,IAAI,EAAE,KAAK;AAC7C,WAAK,gBAAgB,IAAI,EAAE,KAAK,OAAO,YAAY;AACjD,gBAAQ,IAAI,EAAE;AACd,eAAO;AAAA,MACT;AACA,aAAO,aAAa;AAAA,IACtB,CAAC;AAED,QAAI,kBAAkB,QAAQ;AAC5B,YAAM,YAAY,kBAAkB,OAAO,CAAC,KAAK,OAAO,OAAO,eAAe,IAAI,EAAE,KAAK,IAAI,CAAC;AAC9F,YAAM,SAAS,MAAM,GAClB,WAAW,eAAsB,EACjC,OAAO,CAAC,aAAoB,SAAgB,cAAqB,OAAc,CAAC,EAChF,MAAM,eAAsB,KAAK,SAAS,CAAC,EAAE,UAAU,EACvD,MAAM,2CAAoD,OAAO,cAAc,EAAE,EACjF,MAAM,qCAA8C,OAAO,QAAQ,EAAE,EACrE,MAAM,aAAoB,MAAM,iBAAiB,EAIjD,MAAM,SAAS,EACf,QAAQ;AACX,YAAM,cAAc,eAAe,QAAiB,CAAC,QAAQ,OAAO,IAAI,SAAS,CAAC;AAClF,iBAAW,MAAM,mBAAmB;AAClC,YAAI,CAAC,YAAY,WAAW,IAAI,GAAG,GAAG,IAAI,EAAE,EAAE,GAAG,YAAY,IAAI,EAAE,CAAC,EAAG,SAAQ,IAAI,EAAE;AAAA,MACvF;AAAA,IACF;AACA,uBAAmB,IAAI,KAAK,OAAO;AAAA,EACrC;AAEA,QAAM,oBAAoB,oBAAI,IAAY;AAC1C,aAAW,CAAC,KAAK,OAAO,KAAK,mBAAmB,QAAQ,GAAG;AACzD,eAAW,MAAM,QAAS,mBAAkB,IAAI,GAAG,GAAG,IAAI,EAAE,EAAE;AAAA,EAChE;AACA,QAAM,cAAc;AAAA,IAClB,YAAY,SAAS,CAAC,EAAE;AAAA,IACxB,aAAa,SAAS;AAAA,IACtB,cAAc,kBAAkB;AAAA,EAClC,CAAC;AACD,MAAI,CAAC,kBAAkB,KAAM;AAE7B,QAAM,GAAG,YAAY,EAAE,QAAQ,OAAO,QAAQ;AAC5C,eAAW,CAAC,KAAK,MAAM,KAAK,aAAa,QAAQ,GAAG;AAClD,YAAM,UAAU,mBAAmB,IAAI,GAAG;AAC1C,UAAI,CAAC,SAAS,KAAM;AAGpB,YAAM,cAAc,IACjB,WAAW,eAAsB,EACjC,MAAM,eAAsB,KAAK,SAAS,CAAC,EAAE,UAAU,EACvD,MAAM,2CAAoD,OAAO,cAAc,EAAE,EACjF,MAAM,qCAA8C,OAAO,QAAQ,EAAE,EACrE,MAAM,aAAoB,MAAM,MAAM,KAAK,OAAO,CAAC;AACtD,YAAM,YAAY,QAAQ;AAAA,IAC5B;AACA,UAAM,wBAAwB,KAC3B,OAAO,CAAC,QAAQ,kBAAkB,IAAI,YAAY,GAAG,CAAC,CAAC,EACvD,IAAI,CAAC,SAAS,EAAE,GAAG,KAAK,YAAY,WAAW,EAAE;AACpD,eAAW,SAAS,MAAM,uBAAuB,iBAAiB,GAAG;AACnE,YAAM,IAAI,WAAW,eAAsB,EAAE,OAAO,KAAY,EAAE,QAAQ;AAAA,IAC5E;AAAA,EACF,CAAC;AACH;",
4
+ "sourcesContent": ["import { type Kysely, type Transaction, sql } from 'kysely'\nimport {\n isSearchFieldBlocklisted,\n resolveSearchConfig,\n resolveSearchTokenLimits,\n type SearchConfig,\n} from '@open-mercato/shared/lib/search/config'\nimport { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'search-tokens' })\n\nconst INSERT_BATCH_SIZE = 500\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (size <= 0) return [items]\n const out: T[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nexport type SearchTokenRow = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n field: string\n token_hash: string\n token?: string | null\n}\n\ntype BuildTokenOptions = {\n entityType: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n doc?: Record<string, unknown> | null\n config?: SearchConfig\n}\n\nconst DEFAULT_SCOPE = { organizationId: null, tenantId: null }\ntype EntityFieldPair = [string, string]\ntype SearchTokenExecutor = Kysely<any> | Transaction<any>\n\nexport const isSearchDebugEnabled = (): boolean => {\n return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true\n}\n\nconst debug = (event: string, payload: Record<string, unknown>) => {\n if (!isSearchDebugEnabled()) return\n try {\n logger.debug('Search token event', { event, payload })\n } catch {\n // ignore\n }\n}\n\nfunction collectTextValues(value: unknown): string[] {\n if (typeof value === 'string') return [value]\n if (Array.isArray(value)) {\n const out: string[] = []\n for (const entry of value) {\n if (typeof entry === 'string') out.push(entry)\n }\n return out\n }\n return []\n}\n\nfunction shouldIndexField(\n field: string,\n value: unknown,\n config: SearchConfig,\n entityType: string | null,\n): boolean {\n if (typeof value !== 'string' && !Array.isArray(value)) return false\n const lower = field.toLowerCase()\n if (lower === 'id' || lower.endsWith('_id') || lower.endsWith('.id')) return false\n if (lower.endsWith('_at')) return false\n if (['created_at', 'updated_at', 'deleted_at', 'tenant_id', 'organization_id'].includes(lower)) return false\n if (isSearchFieldBlocklisted(field, entityType, config)) return false\n return collectTextValues(value).some((text) => text.length > 0)\n}\n\nexport function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return []\n if (!params.doc) return []\n const tokens: SearchTokenRow[] = []\n const capturePairs = isSearchDebugEnabled() && params.entityType === 'customers:customer_deal'\n const debugPairs: Array<{ field: string; hash: string }> = []\n const scope = {\n organizationId: params.organizationId ?? DEFAULT_SCOPE.organizationId,\n tenantId: params.tenantId ?? DEFAULT_SCOPE.tenantId,\n }\n const limits = resolveSearchTokenLimits(config)\n const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY\n const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n\n for (const [field, rawValue] of Object.entries(params.doc)) {\n if (tokens.length >= recordLimit) break\n if (!shouldIndexField(field, rawValue, config, params.entityType)) continue\n const values = collectTextValues(rawValue)\n const seen = new Set<string>()\n let fieldTokenCount = 0\n for (const text of values) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const remainingLimit = Math.min(recordLimit - tokens.length, fieldLimit - fieldTokenCount)\n const candidateLimit = fieldTokenCount + remainingLimit\n const tokenConfig = Number.isFinite(candidateLimit)\n ? { ...config, maxTokensPerField: candidateLimit }\n : config\n const { tokens: textTokens, hashes } = tokenizeText(text, tokenConfig)\n for (let i = 0; i < textTokens.length; i += 1) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const token = textTokens[i]\n const hash = hashes[i]\n const dedupeKey = `${field}|${hash}`\n if (seen.has(dedupeKey)) continue\n seen.add(dedupeKey)\n fieldTokenCount += 1\n debug('token.generated', { entityType: params.entityType, recordId: params.recordId, field, hash })\n tokens.push({\n entity_type: params.entityType,\n entity_id: String(params.recordId),\n organization_id: scope.organizationId,\n tenant_id: scope.tenantId,\n field,\n token_hash: hash,\n token: config.storeRawTokens ? token : null,\n })\n if (capturePairs) {\n debugPairs.push({ field, hash })\n }\n }\n }\n }\n if (capturePairs) {\n debug('deal.tokens', {\n entityType: params.entityType,\n recordId: params.recordId,\n tokenCount: debugPairs.length,\n tokens: debugPairs,\n })\n }\n debug('doc.completed', { entityType: params.entityType, recordId: params.recordId, tokenCount: tokens.length })\n\n return tokens\n}\n\nfunction buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null): EntityFieldPair[] {\n if (!doc) return []\n const pairs: EntityFieldPair[] = []\n const dedupe = new Set<string>()\n for (const field of Object.keys(doc)) {\n const key = `${recordId}|${field}`\n if (dedupe.has(key)) continue\n dedupe.add(key)\n pairs.push([recordId, field])\n }\n return pairs\n}\n\ntype TokenRowLike = { field?: unknown; token_hash?: unknown; token?: unknown }\n\n// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`\n// would otherwise sign identically to field `a` + hash `b c`.\nconst SIGNATURE_SEPARATOR = String.fromCharCode(0)\n\n// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a\n// stored NULL has to sign the same as the `null` a freshly built row carries \u2014 otherwise every\n// record compares as changed and the skip never fires.\nfunction tokenSignature(row: TokenRowLike): string {\n return [\n String(row.field ?? ''),\n String(row.token_hash ?? ''),\n row.token == null ? '' : String(row.token),\n ].join(SIGNATURE_SEPARATOR)\n}\n\n// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement\n// defect, and a set comparison reads such a record as already correct and preserves the duplicates\n// forever. Counting sends it through a full rewrite, which collapses them.\nfunction tallyOf(rows: Iterable<TokenRowLike>): Map<string, number> {\n const tally = new Map<string, number>()\n for (const row of rows) {\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n }\n return tally\n}\n\nfunction tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {\n const left = a ?? new Map<string, number>()\n const right = b ?? new Map<string, number>()\n if (left.size !== right.size) return false\n for (const [key, count] of left.entries()) {\n if (right.get(key) !== count) return false\n }\n return true\n}\n\nfunction tallyTokenRows<TRow extends TokenRowLike>(\n rows: Iterable<TRow>,\n keyOf: (row: TRow) => string\n): Map<string, Map<string, number>> {\n const tallies = new Map<string, Map<string, number>>()\n for (const row of rows) {\n const key = keyOf(row)\n const tally = tallies.get(key) ?? new Map<string, number>()\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n tallies.set(key, tally)\n }\n return tallies\n}\n\nexport async function replaceSearchTokensForRecord(\n db: Kysely<any>,\n params: BuildTokenOptions,\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const rows = buildSearchTokenRows(params)\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)\n\n // Same comparison #5402 gave the batch path, over the scope this path actually writes: the\n // delete below is narrowed to the document's own `(entity_id, field)` pairs, so the comparison\n // has to be narrowed the same way. Reading wider would let a token row under a field this\n // document does not carry \u2014 the `cf_` twin the search module used to write, say \u2014 read as a\n // difference forever and defeat the skip on every write.\n const scopeTokenQuery = (query: any): any => {\n let scoped = query\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .where('entity_id' as any, '=', String(params.recordId))\n if (fieldPairs.length) {\n scoped = scoped.where('field' as any, 'in', fieldPairs.map(([, field]) => field))\n }\n return scoped\n }\n\n // Read through the caller's transaction when there is one. A separate connection cannot see that\n // transaction's own uncommitted writes, so it could report rows a pending delete has already\n // removed and talk this call out of re-inserting them.\n const reader = options?.trx ?? db\n\n // Count probe first, as in the batch path: it returns one row whatever the table holds, so a\n // record whose stored rows have run away (#4681) is settled without materializing them.\n const storedCountRows = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(sql<number>`count(*)`.as('token_count') as any),\n ).execute()\n const storedCount = Number((storedCountRows as any[])[0]?.token_count ?? 0)\n\n let unchanged = storedCount === rows.length\n if (unchanged && rows.length) {\n const stored = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(['field' as any, 'token_hash' as any, 'token' as any]),\n )\n // Counts already match, so this cannot truncate. It bounds the read if a concurrent writer\n // inserts between the probe and here; a truncated read compares as changed, which costs a\n // rewrite rather than a wrong skip.\n .limit(rows.length)\n .execute()\n unchanged = tallyEquals(tallyOf(rows), tallyOf(stored as any[]))\n }\n if (unchanged) {\n debug('record.skip', { entityType: params.entityType, recordId: params.recordId, tokenCount: rows.length })\n return\n }\n\n const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {\n let deleteQuery = executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n if (fieldPairs.length) {\n deleteQuery = deleteQuery.where((eb: any) => eb.or(\n fieldPairs.map(([rid, field]) => eb.and([\n eb('entity_id' as any, '=', rid),\n eb('field' as any, '=', field),\n ])),\n ))\n } else {\n deleteQuery = deleteQuery.where('entity_id' as any, '=', String(params.recordId))\n }\n await deleteQuery.execute()\n if (!rows.length) return\n const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {\n await executor.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n }\n\n if (options?.trx) {\n await writeTokens(options.trx)\n return\n }\n\n await db.transaction().execute(writeTokens)\n}\n\nexport async function deleteSearchTokensForRecord(\n db: Kysely<any>,\n params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const executor = options?.trx ?? db\n await executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where('entity_id' as any, '=', String(params.recordId))\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .execute()\n}\n\nexport async function replaceSearchTokensForBatch(\n db: Kysely<any>,\n payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>\n): Promise<void> {\n if (!payloads.length) return\n const config = resolveSearchConfig()\n if (!config.enabled) return\n\n const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config }))\n if (!rows.length) {\n const entityType = payloads[0]?.entityType\n if (!entityType) return\n const ids = payloads.map((p) => String(p.recordId))\n await db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityType)\n .where('entity_id' as any, 'in', ids)\n .execute()\n return\n }\n\n const scopeKey = (org: string | null, tenant: string | null) => `${org ?? '__null__'}|${tenant ?? '__null__'}`\n const scopeBuckets = new Map<string, { organizationId: string | null; tenantId: string | null; ids: Set<string> }>()\n\n for (const payload of payloads) {\n const org = payload.organizationId ?? null\n const tenant = payload.tenantId ?? null\n const key = scopeKey(org, tenant)\n const bucket = scopeBuckets.get(key) ?? { organizationId: org, tenantId: tenant, ids: new Set<string>() }\n bucket.ids.add(String(payload.recordId))\n scopeBuckets.set(key, bucket)\n }\n\n const recordKeyOf = (row: SearchTokenRow) =>\n `${scopeKey(row.organization_id ?? null, row.tenant_id ?? null)}|${String(row.entity_id)}`\n const builtTally = tallyTokenRows(rows, recordKeyOf)\n\n // Read outside the transaction, deliberately. The comparison decides only whether to skip a\n // rewrite, so a concurrent writer costs us at most a rewrite we declined \u2014 declined because the\n // table already held exactly the rows this call wanted to write. One ordering is worth naming\n // though: if the read matches and a concurrent writer then commits tokens built from a *staler*\n // doc, the unconditional rewrite this call used to perform would have overwritten them by\n // accident. It no longer does, so those stale rows survive until the record's next write. That\n // is a repair we lose, not a guarantee we break.\n const changedIdsByBucket = new Map<string, Set<string>>()\n for (const [key, bucket] of scopeBuckets.entries()) {\n const ids = Array.from(bucket.ids)\n const builtCountById = new Map<string, number>()\n for (const id of ids) {\n let total = 0\n const tally = builtTally.get(`${key}|${id}`)\n if (tally) for (const count of tally.values()) total += count\n builtCountById.set(id, total)\n }\n\n // Count probe first. Its result is one row per record in the batch, so it is bounded by the\n // batch size \u2014 unlike a bare row read, which would be bounded only by how many token rows the\n // table already holds for these ids, a quantity this function does not control and (per #4681)\n // has no reason to trust.\n const storedCounts = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, sql<number>`count(*)`.as('token_count') as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', ids)\n .groupBy('entity_id' as any)\n .execute()\n const storedCountById = new Map<string, number>()\n for (const row of storedCounts as any[]) {\n storedCountById.set(String(row.entity_id), Number(row.token_count))\n }\n\n const changed = new Set<string>()\n // A record whose stored row count already differs is changed, whatever the rows say \u2014 the\n // duplicate case from #4681 resolves here without ever materializing the duplicated rows.\n const contentCandidates = ids.filter((id) => {\n const builtCount = builtCountById.get(id) ?? 0\n if ((storedCountById.get(id) ?? 0) !== builtCount) {\n changed.add(id)\n return false\n }\n return builtCount > 0\n })\n\n if (contentCandidates.length) {\n const rowBudget = contentCandidates.reduce((sum, id) => sum + (builtCountById.get(id) ?? 0), 0)\n const stored = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, 'field' as any, 'token_hash' as any, 'token' as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', contentCandidates)\n // Counts already match, so this cannot truncate \u2014 it bounds the damage if a concurrent\n // writer inserts between the probe and this read. A truncated read compares as changed,\n // which costs a rewrite rather than a wrong skip.\n .limit(rowBudget)\n .execute()\n const storedTally = tallyTokenRows(stored as any[], (row) => String(row.entity_id))\n for (const id of contentCandidates) {\n if (!tallyEquals(builtTally.get(`${key}|${id}`), storedTally.get(id))) changed.add(id)\n }\n }\n changedIdsByBucket.set(key, changed)\n }\n\n const changedRecordKeys = new Set<string>()\n for (const [key, changed] of changedIdsByBucket.entries()) {\n for (const id of changed) changedRecordKeys.add(`${key}|${id}`)\n }\n debug('batch.skip', {\n entityType: payloads[0].entityType,\n recordCount: payloads.length,\n changedCount: changedRecordKeys.size,\n })\n if (!changedRecordKeys.size) return\n\n await db.transaction().execute(async (trx) => {\n for (const [key, bucket] of scopeBuckets.entries()) {\n const changed = changedIdsByBucket.get(key)\n if (!changed?.size) continue\n // Delete by entity_id: a batch replaces all of a record's tokens, and a per-field OR over the\n // whole batch overflows the query compiler's call stack on large batches.\n const deleteQuery = trx\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', Array.from(changed))\n await deleteQuery.execute()\n }\n const payloadWithTimestamps = rows\n .filter((row) => changedRecordKeys.has(recordKeyOf(row)))\n .map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {\n await trx.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }