@open-mercato/core 0.8.1-develop.7239.1.d90c352076 → 0.8.1-develop.7241.1.fad7b2ce2a

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 (26) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/dbFixtures.js +4 -0
  3. package/dist/helpers/integration/dbFixtures.js.map +2 -2
  4. package/dist/modules/customers/index.js +1 -0
  5. package/dist/modules/customers/index.js.map +2 -2
  6. package/dist/modules/query_index/lib/coverage.js +84 -15
  7. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  8. package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
  9. package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
  10. package/dist/modules/sales/data/entities.js.map +2 -2
  11. package/dist/modules/sales/migrations/Migration20260825120000_sales_document_sequences_backing_sequences.js +49 -0
  12. package/dist/modules/sales/migrations/Migration20260825120000_sales_document_sequences_backing_sequences.js.map +7 -0
  13. package/dist/modules/sales/services/salesDocumentNumberGenerator.js +126 -24
  14. package/dist/modules/sales/services/salesDocumentNumberGenerator.js.map +2 -2
  15. package/dist/modules/sales/setup.js +16 -12
  16. package/dist/modules/sales/setup.js.map +2 -2
  17. package/package.json +7 -7
  18. package/src/helpers/integration/dbFixtures.ts +10 -0
  19. package/src/modules/customers/index.ts +1 -0
  20. package/src/modules/query_index/lib/coverage.ts +168 -16
  21. package/src/modules/query_index/subscribers/delete_one.ts +4 -0
  22. package/src/modules/query_index/subscribers/upsert_one.ts +4 -0
  23. package/src/modules/sales/data/entities.ts +8 -0
  24. package/src/modules/sales/migrations/Migration20260825120000_sales_document_sequences_backing_sequences.ts +61 -0
  25. package/src/modules/sales/services/salesDocumentNumberGenerator.ts +161 -24
  26. package/src/modules/sales/setup.ts +16 -4
@@ -140,6 +140,147 @@ async function pruneDuplicateCoverageRows(
140
140
  await query.execute()
141
141
  }
142
142
 
143
+ function coverageInitializationLockKey(scope: CoverageScope): string {
144
+ return [
145
+ scope.entityType,
146
+ scope.tenantId ?? '__null_tenant__',
147
+ normalizeOrganizationForStore(scope.organizationId ?? null),
148
+ scope.withDeleted === true ? 'with_deleted' : 'active_only',
149
+ ].join('|')
150
+ }
151
+
152
+ async function lockCoverageInitialization(db: CoverageExecutor, scope: CoverageScope): Promise<void> {
153
+ await sql`
154
+ select pg_advisory_xact_lock(
155
+ hashtextextended(${coverageInitializationLockKey(scope)}, 0)
156
+ )
157
+ `.execute(db)
158
+ }
159
+
160
+ async function updateCoverageRows(
161
+ db: CoverageExecutor,
162
+ scope: CoverageScope,
163
+ deltas: { deltaBase: number; deltaIndex: number; deltaVector: number },
164
+ ): Promise<Array<{ id: string }>> {
165
+ let update = db
166
+ .updateTable('entity_index_coverage' as any)
167
+ .set({
168
+ base_count: sql`greatest(${sql.ref('base_count')} + ${deltas.deltaBase}, 0)`,
169
+ indexed_count: sql`greatest(${sql.ref('indexed_count')} + ${deltas.deltaIndex}, 0)`,
170
+ vector_indexed_count: sql`greatest(${sql.ref('vector_indexed_count')} + ${deltas.deltaVector}, 0)`,
171
+ refreshed_at: sql`now()`,
172
+ } as any)
173
+ .where('entity_type' as any, '=', scope.entityType)
174
+ .where('with_deleted' as any, '=', scope.withDeleted === true)
175
+ update = scope.tenantId == null
176
+ ? update.where('tenant_id' as any, 'is', null as any)
177
+ : update.where('tenant_id' as any, '=', scope.tenantId)
178
+ update = applyOrganizationCondition(update as any, 'organization_id', scope.organizationId ?? null)
179
+ return update.returning(['id' as any]).execute() as Promise<Array<{ id: string }>>
180
+ }
181
+
182
+ // Applies a coverage delta by incrementing the stored columns in SQL (#5604). The obvious
183
+ // alternative — read the row, add the delta in JavaScript, write the total back — is a
184
+ // read-modify-write: two adjustments for the same scope that overlap both read the same row
185
+ // and both write the same total, so one of them is silently lost. Incrementing in SQL makes
186
+ // overlapping adjustments compose, and drops the `SELECT` (and the window between it and the
187
+ // write) that made the coverage row a contention point in the first place.
188
+ //
189
+ // In steady state that is one statement. The extra statements are recovery paths that do not
190
+ // fire once a scope has settled: the legacy NULL-organization fold below runs only for
191
+ // global-organization scopes, the insert only until the scope's row exists, and the duplicate
192
+ // prune only when the scope somehow holds more than one row.
193
+ //
194
+ // The statement is an `UPDATE` rather than an `INSERT … ON CONFLICT DO UPDATE`, because the
195
+ // conflict target cannot see every scope this table stores: `entity_index_coverage_scope_idx`
196
+ // is a plain `UNIQUE` constraint, and Postgres treats NULLs in one as distinct. For a scope
197
+ // whose tenant is NULL — `resolveQueryIndexRecordScope`'s `global` branch, or any source row
198
+ // with a NULL tenant column — the conflict branch would never fire, so every adjustment would
199
+ // insert a fresh row carrying only its own delta and `pruneDuplicateCoverageRows` would then
200
+ // delete the accumulated one. A NULL-aware `UPDATE` matches those rows, and it still adds the
201
+ // delta to the column's own value, so it composes exactly the way the conflict branch does.
202
+ async function incrementCoverageRow(
203
+ db: CoverageExecutor,
204
+ scope: CoverageScope,
205
+ deltas: { deltaBase: number; deltaIndex: number; deltaVector: number }
206
+ ): Promise<void> {
207
+ const storedOrgId = normalizeOrganizationForStore(scope.organizationId ?? null)
208
+ let deltaBase = deltas.deltaBase
209
+ let deltaIndex = deltas.deltaIndex
210
+ let deltaVector = deltas.deltaVector
211
+
212
+ if (scope.organizationId == null) {
213
+ // Legacy rows stored the global scope as a NULL organization; they are replaced by the
214
+ // placeholder-organization row. `RETURNING` carries their counts into this delta so the
215
+ // migration does not reset the counters (the read-modify-write this replaced folded them
216
+ // in via its `SELECT`, which matched NULL and placeholder alike).
217
+ let purge = db
218
+ .deleteFrom('entity_index_coverage' as any)
219
+ .where('entity_type' as any, '=', scope.entityType)
220
+ .where('with_deleted' as any, '=', scope.withDeleted === true)
221
+ .where('organization_id' as any, 'is', null as any)
222
+ purge = scope.tenantId == null
223
+ ? purge.where('tenant_id' as any, 'is', null as any)
224
+ : purge.where('tenant_id' as any, '=', scope.tenantId)
225
+ const purged = await purge
226
+ .returning(['base_count' as any, 'indexed_count' as any, 'vector_indexed_count' as any])
227
+ .execute() as Array<CoverageRow>
228
+ for (const row of purged ?? []) {
229
+ deltaBase += toCount(row.base_count)
230
+ deltaIndex += toCount(row.indexed_count)
231
+ deltaVector += toCount(row.vector_indexed_count)
232
+ }
233
+ }
234
+
235
+ let updated = await updateCoverageRows(db, scope, { deltaBase, deltaIndex, deltaVector })
236
+
237
+ if (updated.length === 0 && scope.tenantId == null) {
238
+ // The unique constraint cannot serialize a first insert whose tenant is NULL. Take a
239
+ // transaction-scoped lock only on this rowless branch, then repeat the UPDATE: a creator
240
+ // that won while we waited has committed its row, so this adjustment composes into it.
241
+ await lockCoverageInitialization(db, scope)
242
+ updated = await updateCoverageRows(db, scope, { deltaBase, deltaIndex, deltaVector })
243
+ }
244
+
245
+ if (updated.length > 0) {
246
+ // In steady state the scope holds exactly the row just incremented, so the adjustment is
247
+ // the single statement it is described as. Only a scope that somehow accumulated more than
248
+ // one row — a legacy import, or a concurrent first insert on a NULL-tenant scope, which the
249
+ // unique constraint cannot prevent — pays for the collapse, and only until it is collapsed.
250
+ if (updated.length > 1) await pruneDuplicateCoverageRows(db, scope, updated[0]!.id)
251
+ return
252
+ }
253
+
254
+ // No row for this scope yet. `ON CONFLICT` closes the insert race for scopes the unique
255
+ // constraint can see. NULL-tenant initialization reaches here only while holding the scoped
256
+ // transaction advisory lock above, so it has the same single-creator guarantee.
257
+ const rows = await db
258
+ .insertInto('entity_index_coverage' as any)
259
+ .values({
260
+ entity_type: scope.entityType,
261
+ tenant_id: scope.tenantId ?? null,
262
+ organization_id: storedOrgId,
263
+ with_deleted: scope.withDeleted === true,
264
+ base_count: Math.max(deltaBase, 0),
265
+ indexed_count: Math.max(deltaIndex, 0),
266
+ vector_indexed_count: Math.max(deltaVector, 0),
267
+ refreshed_at: sql`now()`,
268
+ } as any)
269
+ .onConflict((oc: any) => oc
270
+ .columns(['entity_type', 'tenant_id', 'organization_id', 'with_deleted'])
271
+ .doUpdateSet({
272
+ base_count: sql`greatest(${sql.ref('entity_index_coverage.base_count')} + ${deltaBase}, 0)`,
273
+ indexed_count: sql`greatest(${sql.ref('entity_index_coverage.indexed_count')} + ${deltaIndex}, 0)`,
274
+ vector_indexed_count: sql`greatest(${sql.ref('entity_index_coverage.vector_indexed_count')} + ${deltaVector}, 0)`,
275
+ refreshed_at: sql`now()`,
276
+ } as any))
277
+ .returning(['id' as any])
278
+ .execute() as Array<{ id: string }>
279
+
280
+ const keepId = rows?.[0]?.id ?? null
281
+ await pruneDuplicateCoverageRows(db, scope, keepId)
282
+ }
283
+
143
284
  async function upsertCoverageRow(
144
285
  db: CoverageExecutor,
145
286
  scope: CoverageScope,
@@ -266,23 +407,34 @@ export async function applyCoverageAdjustments(
266
407
  options?: { trx?: CoverageExecutor },
267
408
  ): Promise<void> {
268
409
  if (!adjustments.length) return
269
- const db = options?.trx ?? ((em as any).getKysely() as Kysely<any>)
270
- const aggregated = aggregateAdjustments(adjustments)
410
+ const db = (em as any).getKysely() as Kysely<any>
411
+ // `em.getKysely()` hands back the EntityManager's transaction context whenever one is open,
412
+ // and Kysely's `Transaction` throws rather than nesting a second one. A caller already inside
413
+ // `em.transactional(...)` therefore gets the same guarantee the rowless nullable-tenant branch
414
+ // opens a transaction for, so use the ambient one instead of asking for another.
415
+ const ambientTrx = (db as { isTransaction?: boolean }).isTransaction === true
416
+ ? (db as CoverageExecutor)
417
+ : null
418
+ // Keep scope initialization in a stable order. A caller-supplied transaction retains
419
+ // advisory and unique-index locks until commit, so two batches that initialize the same
420
+ // scopes in opposite orders could otherwise deadlock.
421
+ const aggregated = aggregateAdjustments(adjustments).sort((left, right) => (
422
+ scopeKey(left.scope).localeCompare(scopeKey(right.scope))
423
+ ))
424
+ const executor = options?.trx ?? ambientTrx
271
425
  for (const entry of aggregated) {
272
- const scope = entry.scope
273
- const existing = await fetchCoverageRow(db, scope)
274
- const currentBase = existing ? toCount(existing.base_count) : 0
275
- const currentIndex = existing ? toCount(existing.indexed_count) : 0
276
- const currentVector = existing ? toCount(existing.vector_indexed_count) : 0
277
- const nextBase = Math.max(currentBase + entry.deltaBase, 0)
278
- const nextIndex = Math.max(currentIndex + entry.deltaIndex, 0)
279
- const nextVector = Math.max(currentVector + entry.deltaVector, 0)
280
-
281
- await upsertCoverageRow(db, scope, {
282
- baseCount: nextBase,
283
- indexedCount: nextIndex,
284
- vectorIndexedCount: nextVector,
285
- })
426
+ const deltas = {
427
+ deltaBase: entry.deltaBase,
428
+ deltaIndex: entry.deltaIndex,
429
+ deltaVector: entry.deltaVector,
430
+ }
431
+ if (executor) {
432
+ await incrementCoverageRow(executor, entry.scope, deltas)
433
+ } else if (entry.scope.tenantId == null) {
434
+ await db.transaction().execute((trx) => incrementCoverageRow(trx, entry.scope, deltas))
435
+ } else {
436
+ await incrementCoverageRow(db, entry.scope, deltas)
437
+ }
286
438
  }
287
439
  }
288
440
 
@@ -99,6 +99,10 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
99
99
  indexDelta: effectiveIndexDelta,
100
100
  })
101
101
  if (adjustments.length) {
102
+ // Stays inside the transaction: `alwaysConsistent` exists to make the read
103
+ // projection and its coverage counters commit together, and the adjustment is a
104
+ // one incrementing statement in steady state (see `applyCoverageAdjustments`), so
105
+ // it holds the coverage row's lock only for that statement.
102
106
  await applyCoverageAdjustments(em, adjustments, { trx })
103
107
  }
104
108
  }
@@ -92,6 +92,10 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
92
92
  indexDelta,
93
93
  })
94
94
  if (adjustments.length) {
95
+ // Stays inside the transaction: `alwaysConsistent` exists to make the read
96
+ // projection and its coverage counters commit together, and the adjustment is a
97
+ // one incrementing statement in steady state (see `applyCoverageAdjustments`), so
98
+ // it holds the coverage row's lock only for that statement.
95
99
  await applyCoverageAdjustments(em, adjustments, { trx })
96
100
  }
97
101
  }
@@ -806,6 +806,14 @@ export class SalesDocumentSequence {
806
806
  @Property({ name: 'document_kind', type: 'text' })
807
807
  documentKind!: SalesDocumentNumberKind
808
808
 
809
+ /**
810
+ * @deprecated Since #5604 the authoritative counter is the Postgres sequence named after
811
+ * this row (`sales_docseq_<id without dashes>`); `nextval` on it is what issues document
812
+ * numbers. This column is retained for backward compatibility, is only refreshed by
813
+ * `setNextSequence` and by the down-migration, and must not be read as the current value —
814
+ * use `SalesDocumentNumberGenerator.peekSequences()`. Scheduled for removal one minor
815
+ * version after the deprecation window, per BACKWARD_COMPATIBILITY.md.
816
+ */
809
817
  @Property({ name: 'current_value', type: 'integer', default: 0 })
810
818
  currentValue: number = 0
811
819
 
@@ -0,0 +1,61 @@
1
+ import { Migration } from '@mikro-orm/migrations'
2
+
3
+ /**
4
+ * Backs every `sales_document_sequences` registry row with a real Postgres sequence (#5604).
5
+ *
6
+ * The table itself is unchanged, so the ORM snapshot is untouched — the sequences are
7
+ * catalog objects MikroORM does not model. Each sequence is named after the registry row
8
+ * that owns it (`sales_docseq_<row id without dashes>`), which is how
9
+ * `SalesDocumentNumberGenerator` recomputes the name in SQL without an extra column.
10
+ *
11
+ * `current_value` is carried over as the starting point so numbering continues where it left
12
+ * off, and stops being authoritative from here on — see the entity's deprecation note.
13
+ */
14
+ export class Migration20260825120000_sales_document_sequences_backing_sequences extends Migration {
15
+ override async up(): Promise<void> {
16
+ this.addSql(`
17
+ do $$
18
+ declare
19
+ registry record;
20
+ sequence_name text;
21
+ begin
22
+ for registry in select id, current_value from sales_document_sequences loop
23
+ sequence_name := 'sales_docseq_' || replace(registry.id::text, '-', '');
24
+ execute format(
25
+ 'create sequence if not exists %I as bigint minvalue 1 start with 1 no cycle',
26
+ sequence_name
27
+ );
28
+ if coalesce(registry.current_value, 0) >= 1 then
29
+ execute format('select setval(%L, %s, true)', sequence_name, registry.current_value);
30
+ end if;
31
+ end loop;
32
+ end $$;
33
+ `)
34
+ }
35
+
36
+ override async down(): Promise<void> {
37
+ // Hand authority back to `current_value` before dropping the sequences, otherwise the
38
+ // rolled-back code resumes from a value that stopped advancing when this migration ran
39
+ // and re-issues numbers that are already on documents.
40
+ this.addSql(`
41
+ do $$
42
+ declare
43
+ registry record;
44
+ sequence_name text;
45
+ last_issued bigint;
46
+ begin
47
+ for registry in select id from sales_document_sequences loop
48
+ sequence_name := 'sales_docseq_' || replace(registry.id::text, '-', '');
49
+ execute format('select pg_sequence_last_value(%L::regclass)', sequence_name) into last_issued;
50
+ if last_issued is not null then
51
+ update sales_document_sequences
52
+ set current_value = greatest(coalesce(current_value, 0), last_issued),
53
+ updated_at = now()
54
+ where id = registry.id;
55
+ end if;
56
+ execute format('drop sequence if exists %I', sequence_name);
57
+ end loop;
58
+ end $$;
59
+ `)
60
+ }
61
+ }
@@ -1,6 +1,6 @@
1
1
  import { randomBytes, randomUUID } from 'crypto'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
- import { SalesDocumentSequence, SalesSettings } from '../data/entities'
3
+ import { SalesSettings } from '../data/entities'
4
4
  import {
5
5
  DEFAULT_ORDER_NUMBER_FORMAT,
6
6
  DEFAULT_QUOTE_NUMBER_FORMAT,
@@ -57,6 +57,56 @@ const generateRandomDigits = (size = 4) => {
57
57
  return digits.join('')
58
58
  }
59
59
 
60
+ // Each (organization, tenant, kind) scope is backed by its own Postgres sequence, named
61
+ // after the `sales_document_sequences` registry row that owns it (#5604). Deriving the name
62
+ // from the row's primary key keeps the mapping recomputable from the table alone — no extra
63
+ // column, and `\ds sales_docseq_*` in psql lines up with the registry.
64
+ const SEQUENCE_NAME_PREFIX = 'sales_docseq_'
65
+ const SEQUENCE_NAME_PATTERN = /^sales_docseq_[0-9a-f]{32}$/
66
+ // Recomputes the name in SQL so a claim is a single round trip: look the registry row up by
67
+ // its unique scope index and call `nextval` on the sequence that row names.
68
+ const SEQUENCE_REGCLASS_SQL = `('${SEQUENCE_NAME_PREFIX}' || replace(id::text, '-', ''))::regclass`
69
+ const SEQUENCE_SCOPE_PREDICATE = 'organization_id = ? and tenant_id = ? and document_kind = ?'
70
+
71
+ export function documentSequenceName(registryId: string): string {
72
+ const name = `${SEQUENCE_NAME_PREFIX}${String(registryId).replace(/-/g, '').toLowerCase()}`
73
+ if (!SEQUENCE_NAME_PATTERN.test(name)) {
74
+ throw new Error('[internal] Document-sequence registry id is not a UUID')
75
+ }
76
+ return name
77
+ }
78
+
79
+ function isMissingRelationError(error: unknown): boolean {
80
+ if ((error as { code?: unknown } | null)?.code === '42P01') return true
81
+ const message = error instanceof Error ? error.message : ''
82
+ return /does not exist/i.test(message)
83
+ }
84
+
85
+ function isDuplicateRelationError(error: unknown): boolean {
86
+ const code = (error as { code?: unknown } | null)?.code
87
+ if (code === '42P07' || code === '23505') return true
88
+ const message = error instanceof Error ? error.message : ''
89
+ return /already exists/i.test(message)
90
+ }
91
+
92
+ /**
93
+ * Creates the Postgres sequence backing a `sales_document_sequences` registry row, if it is
94
+ * not there yet. Safe to call concurrently: `if not exists` still races on `pg_class`, so a
95
+ * duplicate error means another caller won and is treated as success.
96
+ */
97
+ export async function createDocumentSequence(em: EntityManager, registryId: string): Promise<void> {
98
+ const name = documentSequenceName(registryId)
99
+ try {
100
+ await em.getConnection().execute(
101
+ `create sequence if not exists "${name}" as bigint minvalue ${DEFAULT_SEQUENCE_START} start with ${DEFAULT_SEQUENCE_START} no cycle`,
102
+ [],
103
+ 'run'
104
+ )
105
+ } catch (error) {
106
+ if (!isDuplicateRelationError(error)) throw error
107
+ }
108
+ }
109
+
60
110
  export class SalesDocumentNumberGenerator {
61
111
  constructor(private readonly em: EntityManager) {}
62
112
 
@@ -81,16 +131,41 @@ export class SalesDocumentNumberGenerator {
81
131
  }
82
132
 
83
133
  async setNextSequence(kind: SalesDocumentNumberKind, scope: Scope, nextValue: number): Promise<void> {
84
- const next = Math.min(Math.max(Math.floor(nextValue), DEFAULT_SEQUENCE_START), MAX_SEQUENCE)
85
- const baseValue = next - 1
134
+ const next = Math.floor(nextValue)
135
+ // Refuse an out-of-range target rather than clamping to it, for the same reason
136
+ // `tryClaimSequence` refuses to hand out a clamped value: parking the series on the ceiling
137
+ // makes every later claim collide there, so silently accepting the value is the same "two
138
+ // documents share a number" hazard one step earlier. `salesSettingsUpsertSchema` already
139
+ // rejects the range at the API boundary, so this only fires for a direct service caller.
140
+ if (!Number.isFinite(next) || next < DEFAULT_SEQUENCE_START || next > MAX_SEQUENCE) {
141
+ throw new Error('[internal] Sales document sequence target is outside the supported range')
142
+ }
143
+ await this.ensureSequence(kind, scope)
144
+ // Park the sequence on `next - 1` as an already-issued value, so the next `nextval`
145
+ // returns exactly `next`. The `setval(next, false)` form would do that too, but it leaves
146
+ // `is_called = false`, and that is precisely the state `pg_sequence_last_value` reports as
147
+ // NULL — `peekNextSequence` would then read the series back as the start value and the
148
+ // settings form would write that rewind back on its next save.
149
+ // The start value has no predecessor to park on and `minvalue` forbids one; it is also the
150
+ // one value the untouched "never called" state already claims and reports correctly.
151
+ const isCalled = next > DEFAULT_SEQUENCE_START
152
+ const armedAt = isCalled ? next - 1 : DEFAULT_SEQUENCE_START
86
153
  await this.em.getConnection().execute(
87
154
  `
88
- insert into sales_document_sequences (id, organization_id, tenant_id, document_kind, current_value, created_at, updated_at)
89
- values (gen_random_uuid(), ?, ?, ?, ?, now(), now())
90
- on conflict (organization_id, tenant_id, document_kind)
91
- do update set current_value = ?, updated_at = now()
155
+ select setval(${SEQUENCE_REGCLASS_SQL}, ?, ?)
156
+ from sales_document_sequences
157
+ where ${SEQUENCE_SCOPE_PREDICATE}
92
158
  `,
93
- [scope.organizationId, scope.tenantId, kind, baseValue, baseValue]
159
+ [armedAt, isCalled, scope.organizationId, scope.tenantId, kind]
160
+ )
161
+ await this.em.getConnection().execute(
162
+ `
163
+ update sales_document_sequences
164
+ set current_value = ?, updated_at = now()
165
+ where ${SEQUENCE_SCOPE_PREDICATE}
166
+ `,
167
+ [next - 1, scope.organizationId, scope.tenantId, kind],
168
+ 'run'
94
169
  )
95
170
  }
96
171
 
@@ -118,31 +193,93 @@ export class SalesDocumentNumberGenerator {
118
193
  }
119
194
 
120
195
  private async peekNextSequence(kind: SalesDocumentNumberKind, scope: Scope): Promise<number> {
121
- const record = await this.em.findOne(SalesDocumentSequence, {
122
- tenantId: scope.tenantId,
123
- organizationId: scope.organizationId,
124
- documentKind: kind,
125
- })
126
- if (record && typeof record.currentValue === 'number') {
127
- return Math.min(record.currentValue + 1, MAX_SEQUENCE)
196
+ let rows: { last_value: string | number | null }[] | undefined
197
+ try {
198
+ // `pg_sequence_last_value` reports the sequence's state without consuming a value, and
199
+ // returns NULL for a sequence that has never been called — which is the only state
200
+ // `setNextSequence` leaves behind for the start value, and the value it claims next.
201
+ rows = await this.em.getConnection().execute<{ last_value: string | number | null }[]>(
202
+ `
203
+ select pg_sequence_last_value(${SEQUENCE_REGCLASS_SQL}) as last_value
204
+ from sales_document_sequences
205
+ where ${SEQUENCE_SCOPE_PREDICATE}
206
+ `,
207
+ [scope.organizationId, scope.tenantId, kind]
208
+ )
209
+ } catch (error) {
210
+ if (isMissingRelationError(error)) return DEFAULT_SEQUENCE_START
211
+ throw error
128
212
  }
129
- return DEFAULT_SEQUENCE_START
213
+ const lastValue = rows?.[0]?.last_value
214
+ if (lastValue == null) return DEFAULT_SEQUENCE_START
215
+ const value = Number(lastValue)
216
+ if (!Number.isFinite(value)) return DEFAULT_SEQUENCE_START
217
+ return Math.min(Math.max(value + 1, DEFAULT_SEQUENCE_START), MAX_SEQUENCE)
130
218
  }
131
219
 
220
+ // `nextval` is the whole point of the sequence: it is atomic, never takes a row lock, and
221
+ // produces no dead tuples, so concurrent claims for the same scope no longer serialize on a
222
+ // single `sales_document_sequences` row the way the previous `UPDATE … current_value + 1`
223
+ // did (#5604). The registry row still exists — it names the sequence and records the scope —
224
+ // but the hot path only reads it.
132
225
  private async claimSequence(kind: SalesDocumentNumberKind, scope: Scope): Promise<number> {
133
- const rows = await this.em.getConnection().execute<{ current_value: string }[]>(
226
+ const claimed = await this.tryClaimSequence(kind, scope)
227
+ if (claimed !== null) return claimed
228
+ // First claim for this scope (or a tenant created before the sequences existed): create
229
+ // the registry row and its sequence, then claim once more.
230
+ await this.ensureSequence(kind, scope)
231
+ const retried = await this.tryClaimSequence(kind, scope)
232
+ if (retried === null) {
233
+ throw new Error('[internal] Sales document sequence is missing immediately after being created')
234
+ }
235
+ return retried
236
+ }
237
+
238
+ private async tryClaimSequence(kind: SalesDocumentNumberKind, scope: Scope): Promise<number | null> {
239
+ let rows: { claimed: string | number | null }[] | undefined
240
+ try {
241
+ rows = await this.em.getConnection().execute<{ claimed: string | number | null }[]>(
242
+ `
243
+ select nextval(${SEQUENCE_REGCLASS_SQL}) as claimed
244
+ from sales_document_sequences
245
+ where ${SEQUENCE_SCOPE_PREDICATE}
246
+ `,
247
+ [scope.organizationId, scope.tenantId, kind]
248
+ )
249
+ } catch (error) {
250
+ if (isMissingRelationError(error)) return null
251
+ throw error
252
+ }
253
+ const claimed = rows?.[0]?.claimed
254
+ if (claimed == null) return null
255
+ const value = Number(claimed)
256
+ if (!Number.isFinite(value) || value < DEFAULT_SEQUENCE_START) {
257
+ throw new Error('[internal] Sales document sequence returned a value below its start')
258
+ }
259
+ if (value > MAX_SEQUENCE) {
260
+ // Never hand out a clamped value: two documents sharing a number is a worse failure
261
+ // than refusing to issue one, and the caller's transaction can still roll back cleanly.
262
+ throw new Error('[internal] Sales document sequence is exhausted')
263
+ }
264
+ return value
265
+ }
266
+
267
+ private async ensureSequence(kind: SalesDocumentNumberKind, scope: Scope): Promise<void> {
268
+ const rows = await this.em.getConnection().execute<{ id: string }[]>(
134
269
  `
135
270
  insert into sales_document_sequences (id, organization_id, tenant_id, document_kind, current_value, created_at, updated_at)
136
- values (gen_random_uuid(), ?, ?, ?, ?, now(), now())
271
+ values (gen_random_uuid(), ?, ?, ?, 0, now(), now())
137
272
  on conflict (organization_id, tenant_id, document_kind)
138
- do update set current_value = sales_document_sequences.current_value + 1, updated_at = now()
139
- returning current_value
273
+ do update set updated_at = now()
274
+ returning id
140
275
  `,
141
- [scope.organizationId, scope.tenantId, kind, DEFAULT_SEQUENCE_START]
276
+ [scope.organizationId, scope.tenantId, kind]
142
277
  )
143
- const value = Number(rows?.[0]?.current_value ?? DEFAULT_SEQUENCE_START)
144
- if (!Number.isFinite(value) || value < DEFAULT_SEQUENCE_START) return DEFAULT_SEQUENCE_START
145
- return Math.min(value, MAX_SEQUENCE)
278
+ const registryId = rows?.[0]?.id
279
+ if (!registryId) {
280
+ throw new Error('[internal] Could not resolve a sales document sequence registry row')
281
+ }
282
+ await createDocumentSequence(this.em, registryId)
146
283
  }
147
284
 
148
285
  private formatNumber(
@@ -6,6 +6,7 @@ import { seedSalesStatusDictionaries, seedSalesAdjustmentKinds } from './lib/dic
6
6
  import { seedSalesChannelsToggle } from './lib/salesChannelsToggleSeed'
7
7
  import { ensureExampleShippingMethods, ensureExamplePaymentMethods } from './seed/examples-data'
8
8
  import { seedSalesExamples } from './seed/examples'
9
+ import { createDocumentSequence } from './services/salesDocumentNumberGenerator'
9
10
 
10
11
  type SeedScope = { tenantId: string; organizationId: string }
11
12
 
@@ -87,14 +88,15 @@ export const setup: ModuleSetupConfig = {
87
88
  )
88
89
  }
89
90
 
91
+ const sequenceRows: SalesDocumentSequence[] = []
90
92
  for (const kind of ['order', 'quote', 'return', 'invoice', 'credit_memo'] as const) {
91
93
  const seq = await em.findOne(SalesDocumentSequence, {
92
94
  tenantId,
93
95
  organizationId,
94
96
  documentKind: kind,
95
97
  })
96
- if (!seq) {
97
- em.persist(
98
+ sequenceRows.push(
99
+ seq ??
98
100
  em.create(SalesDocumentSequence, {
99
101
  tenantId,
100
102
  organizationId,
@@ -103,11 +105,21 @@ export const setup: ModuleSetupConfig = {
103
105
  createdAt: new Date(),
104
106
  updatedAt: new Date(),
105
107
  })
106
- )
107
- }
108
+ )
109
+ if (!seq) em.persist(sequenceRows[sequenceRows.length - 1]!)
108
110
  }
109
111
 
110
112
  await em.flush()
113
+
114
+ // Each registry row is backed by its own Postgres sequence (#5604); create them now so the
115
+ // first document of a fresh tenant does not have to fall back to lazy creation. The ids come
116
+ // from the rows just flushed rather than from a re-read: `createDocumentSequence` issues DDL
117
+ // over `em.getConnection()`, which runs outside the EntityManager's transaction context, so
118
+ // a read on that connection would not see rows this hook wrote inside a transaction and the
119
+ // loop would silently create nothing.
120
+ for (const row of sequenceRows) {
121
+ await createDocumentSequence(em, row.id)
122
+ }
111
123
  },
112
124
 
113
125
  async seedDefaults({ em, tenantId, organizationId }) {