94 lines
3.6 KiB
JavaScript
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 };
|