@open-mercato/core 0.6.8-develop.7082.1.f2a845b020 → 0.6.8-develop.7088.1.3873d81f42

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.
@@ -0,0 +1,87 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ // sales_notes and sales_document_addresses each carried a single index —
4
+ // (organization_id, tenant_id) — and are never read by that predicate alone.
5
+ // loadOrderSnapshot / loadQuoteSnapshot (commands/documents.ts) read them by
6
+ // context in the same Promise.all:
7
+ //
8
+ // findWithDecryption(em, SalesNote, { contextType, contextId, ...scope })
9
+ // findWithDecryption(em, SalesDocumentAddress, { documentId, documentKind, ...scope })
10
+ //
11
+ // On a single-organization deployment the scope index matches every row, so the
12
+ // planner discards it and scans. The cost is CPU filtering, so a warm cache does
13
+ // not reduce it. Measured on a table of ~331k rows / 110 MB: a parallel
14
+ // sequential scan removing 110,457 rows per worker, 82 ms per read to return 0
15
+ // rows. The command bus runs prepare() and captureAfter() around every command
16
+ // and both load a document snapshot, so one written document — itself several
17
+ // commands (the document, one per changed line, the payment reconcile) —
18
+ // performs roughly 20 of these reads. Counters over one bulk-import batch of 100
19
+ // records showed 1,971 sequential scans reading 217,670,528 rows, 2.18M rows per
20
+ // imported record; repeatable across consecutive batches (1,971 / 1,974 / 1,911).
21
+ //
22
+ // context_type and document_kind are deliberately omitted, as they were for
23
+ // sales_document_tag_assignments_document_idx (Migration20260806120000), and the
24
+ // scope columns with them. sales_orders.id and sales_quotes.id are both
25
+ // gen_random_uuid() from their own tables, so a context_id already determines its
26
+ // context_type and its tenant; the seek returns the handful of rows belonging to
27
+ // one document and the remaining terms are rechecked over those. Folding three
28
+ // more columns in — one text and two uuids — roughly triples the index entry for
29
+ // no selectivity, on tables that take a write per document save and a note per
30
+ // order status change. The sibling scope indexes that do fold scope in
31
+ // (sales_order_lines_scope_idx, sales_payments_scope_idx) predate that reasoning.
32
+ //
33
+ // order_id and quote_id are separate indexes rather than a widening of the above:
34
+ // both tables are the child side of `on delete set null` constraints, which
35
+ // PostgreSQL does not index automatically, and the constraint check probes
36
+ // order_id / quote_id — columns the context index cannot serve even though they
37
+ // hold the same uuid. Without them every delete of a parent order or quote scans
38
+ // the child table in full.
39
+ //
40
+ // Both tables are high-churn, so the indexes are built CONCURRENTLY to avoid
41
+ // blocking writes during the build. CREATE INDEX CONCURRENTLY cannot run inside a
42
+ // transaction, hence isTransactional() => false; the migration runner applies
43
+ // migrations one-by-one, so this opt-out is safe. Drop first so retrying a failed
44
+ // concurrent build removes PostgreSQL's invalid index stub instead of letting IF
45
+ // NOT EXISTS silently accept it: a half-built concurrent index is left INVALID,
46
+ // the planner ignores it, and IF NOT EXISTS would report success while the table
47
+ // stays unindexed. Same shape as Migration20260806120000 and
48
+ // Migration20260731105052_query_index.
49
+ //
50
+ // On a database that does not have these indexes yet — every fresh install — the
51
+ // drops are no-ops and there is no window without them. An operator who already
52
+ // created one out of band should expect it to be rebuilt here, and the reads that
53
+ // depend on it to fall back to their pre-index cost until the concurrent build
54
+ // finishes; on a large table, run this while the writers that depend on it are
55
+ // paused.
56
+ export class Migration20260821120000_sales_notes_addresses_context_idx extends Migration {
57
+
58
+ override isTransactional(): boolean {
59
+ return false;
60
+ }
61
+
62
+ override up(): void | Promise<void> {
63
+ this.addSql(`drop index concurrently if exists "sales_notes_context_idx";`);
64
+ this.addSql(`create index concurrently "sales_notes_context_idx" on "sales_notes" ("context_id");`);
65
+ this.addSql(`drop index concurrently if exists "sales_notes_order_idx";`);
66
+ this.addSql(`create index concurrently "sales_notes_order_idx" on "sales_notes" ("order_id");`);
67
+ this.addSql(`drop index concurrently if exists "sales_notes_quote_idx";`);
68
+ this.addSql(`create index concurrently "sales_notes_quote_idx" on "sales_notes" ("quote_id");`);
69
+
70
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_document_idx";`);
71
+ this.addSql(`create index concurrently "sales_document_addresses_document_idx" on "sales_document_addresses" ("document_id");`);
72
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_order_idx";`);
73
+ this.addSql(`create index concurrently "sales_document_addresses_order_idx" on "sales_document_addresses" ("order_id");`);
74
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_quote_idx";`);
75
+ this.addSql(`create index concurrently "sales_document_addresses_quote_idx" on "sales_document_addresses" ("quote_id");`);
76
+ }
77
+
78
+ override down(): void | Promise<void> {
79
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_quote_idx";`);
80
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_order_idx";`);
81
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_document_idx";`);
82
+ this.addSql(`drop index concurrently if exists "sales_notes_quote_idx";`);
83
+ this.addSql(`drop index concurrently if exists "sales_notes_order_idx";`);
84
+ this.addSql(`drop index concurrently if exists "sales_notes_context_idx";`);
85
+ }
86
+
87
+ }
@@ -178,6 +178,12 @@ export default function MyTimesheetsPage() {
178
178
  blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
179
179
  })
180
180
 
181
+ const gridInstanceId = React.useId()
182
+ const durationHintId = `${gridInstanceId}-duration-hint`
183
+ const buildDurationErrorId = React.useCallback(
184
+ (projectId: string, dateKey: string): string => `${gridInstanceId}-duration-error-${projectId}-${dateKey}`,
185
+ [gridInstanceId],
186
+ )
181
187
  const durationFormatHint = t(
182
188
  'staff.timesheets.my.duration.hint',
183
189
  'Enter hours (8 or 1.5), h:mm (1:30) or minutes (90m). Max 24h per day.',
@@ -919,6 +925,7 @@ export default function MyTimesheetsPage() {
919
925
  const isDirty = dirty[project.id]?.[dateKey] !== undefined
920
926
  const cellError = cellErrors[project.id]?.[dateKey]
921
927
  const cellErrorMessage = cellError ? describeDurationError(cellError) : undefined
928
+ const cellErrorId = cellErrorMessage ? buildDurationErrorId(project.id, dateKey) : undefined
922
929
  return (
923
930
  <td key={dateKey} className={`px-0.5 py-0.5 ${weekend ? 'bg-muted/40' : ''}`}>
924
931
  {weekend ? (
@@ -943,9 +950,13 @@ export default function MyTimesheetsPage() {
943
950
  placeholder={t('staff.timesheets.my.durationPlaceholder', '0')}
944
951
  aria-invalid={cellError !== undefined}
945
952
  aria-label={describeDurationCell(project.name, date)}
953
+ aria-describedby={[durationHintId, cellErrorId].filter(Boolean).join(' ')}
946
954
  title={cellErrorMessage ?? durationFormatHint}
947
955
  />
948
956
  )}
957
+ {cellErrorMessage ? (
958
+ <span id={cellErrorId} role="alert" className="sr-only">{cellErrorMessage}</span>
959
+ ) : null}
949
960
  </td>
950
961
  )
951
962
  })}
@@ -984,7 +995,7 @@ export default function MyTimesheetsPage() {
984
995
  </tr>
985
996
  </tfoot>
986
997
  </table>
987
- <p className="px-3 py-2 text-xs text-muted-foreground">{durationFormatHint}</p>
998
+ <p id={durationHintId} className="px-3 py-2 text-xs text-muted-foreground">{durationFormatHint}</p>
988
999
  </div>
989
1000
  )}
990
1001
  </div>