@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/dbFixtures.js +4 -0
- package/dist/helpers/integration/dbFixtures.js.map +2 -2
- package/dist/modules/customers/index.js +1 -0
- package/dist/modules/customers/index.js.map +2 -2
- package/dist/modules/query_index/lib/coverage.js +84 -15
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
- package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
- package/dist/modules/sales/data/entities.js.map +2 -2
- package/dist/modules/sales/migrations/Migration20260825120000_sales_document_sequences_backing_sequences.js +49 -0
- package/dist/modules/sales/migrations/Migration20260825120000_sales_document_sequences_backing_sequences.js.map +7 -0
- package/dist/modules/sales/services/salesDocumentNumberGenerator.js +126 -24
- package/dist/modules/sales/services/salesDocumentNumberGenerator.js.map +2 -2
- package/dist/modules/sales/setup.js +16 -12
- package/dist/modules/sales/setup.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/dbFixtures.ts +10 -0
- package/src/modules/customers/index.ts +1 -0
- package/src/modules/query_index/lib/coverage.ts +168 -16
- package/src/modules/query_index/subscribers/delete_one.ts +4 -0
- package/src/modules/query_index/subscribers/upsert_one.ts +4 -0
- package/src/modules/sales/data/entities.ts +8 -0
- package/src/modules/sales/migrations/Migration20260825120000_sales_document_sequences_backing_sequences.ts +61 -0
- package/src/modules/sales/services/salesDocumentNumberGenerator.ts +161 -24
- 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 =
|
|
270
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 {
|
|
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.
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
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(), ?, ?, ?,
|
|
271
|
+
values (gen_random_uuid(), ?, ?, ?, 0, now(), now())
|
|
137
272
|
on conflict (organization_id, tenant_id, document_kind)
|
|
138
|
-
do update set
|
|
139
|
-
returning
|
|
273
|
+
do update set updated_at = now()
|
|
274
|
+
returning id
|
|
140
275
|
`,
|
|
141
|
-
[scope.organizationId, scope.tenantId, kind
|
|
276
|
+
[scope.organizationId, scope.tenantId, kind]
|
|
142
277
|
)
|
|
143
|
-
const
|
|
144
|
-
if (!
|
|
145
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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 }) {
|