Files
picpeak/backend/src/utils/documentSequences.js
T
2026-07-03 11:45:07 +02:00

94 lines
3.6 KiB
JavaScript

/**
* documentSequences — atomic gap-free sequence generator for CRM
* document numbers (invoices, quotes, contracts, future doc kinds).
*
* **Contract**: `claimNextSequence(kind, year, [trx])` returns the
* next integer in the (kind, year) series. Atomic against concurrent
* callers: two simultaneous claims for the same row return strictly
* increasing values, no collisions, no gaps.
*
* **How the atomicity works**
*
* Postgres: a single `UPDATE … SET current_value = current_value + 1
* WHERE kind = ? AND year = ? RETURNING current_value` holds a row
* lock for the duration of the statement; concurrent callers
* serialize on the lock.
*
* SQLite: knex does not expose `BEGIN IMMEDIATE` declaratively, but
* SQLite's default journal mode (or WAL) gives us per-row serialization
* via the transaction. We wrap the UPDATE + re-SELECT in a transaction
* which acquires the write lock; concurrent transactions queue.
*
* **First-claim path** (no row yet for the (kind, year))
*
* Migration 132 seeded rows for every existing year via MAX(...)
* backfill. New years need an INSERT on first use. We do an
* INSERT-OR-IGNORE then UPDATE … RETURNING. Both steps are inside
* the same transaction so the year row is guaranteed to exist when
* the UPDATE fires.
*/
const { db } = require('../database/db');
const { AppError } = require('./errors');
/**
* Claim the next sequence value for (kind, year). Returns the new
* integer. Throws AppError on DB failure; caller composes the
* formatted document number from this integer via formatNumberInTemplate.
*
* @param {string} kind 'invoice' | 'quote' | 'contract' | ...
* @param {number} year 4-digit year
* @param {object} [trx] optional knex transaction. When supplied the
* claim joins the caller's transaction so the
* sequence increment and the row INSERT can
* commit-or-roll-back together. Otherwise we
* run our own micro-transaction.
*/
async function claimNextSequence(kind, year, trx) {
if (!kind || typeof kind !== 'string') {
throw new AppError('claimNextSequence: kind required', 500);
}
const yr = parseInt(year, 10);
if (!Number.isFinite(yr)) {
throw new AppError('claimNextSequence: invalid year', 500);
}
const exec = async (q) => {
// Step 1: ensure the (kind, year) row exists. INSERT...ON CONFLICT
// DO NOTHING is the Postgres-native form; SQLite supports the same
// syntax (3.24+). knex's `onConflict('...').ignore()` paves over
// the differences.
await q('document_sequences')
.insert({
kind, year: yr, current_value: 0,
created_at: new Date(), updated_at: new Date(),
})
.onConflict(['kind', 'year']).ignore();
// Step 2: atomic claim. We do UPDATE … (no RETURNING because
// knex's returning() is uneven across drivers) then re-SELECT.
// The transaction wrapper (or the caller's trx) keeps the two
// statements on the same row lock, so concurrent claimers
// serialize.
await q('document_sequences')
.where({ kind, year: yr })
.increment('current_value', 1)
.update({ updated_at: new Date() });
const row = await q('document_sequences')
.where({ kind, year: yr })
.select('current_value')
.first();
if (!row) {
throw new AppError(`claimNextSequence: row vanished for ${kind}/${yr}`, 500);
}
return row.current_value;
};
if (trx) {
return await exec(trx);
}
return await db.transaction(async (innerTrx) => exec(innerTrx));
}
module.exports = { claimNextSequence };