feat(server): gate writes outside a transaction with lint:tx

The rule that a multi-statement write runs in uow.transactional was
written down but nothing held it. scripts/tx-writes.mjs reads every class
under src/ with the TypeScript parser and counts, per method, the writes
it issues outside a transactional callback: repository calls that build
an INSERT, UPDATE or DELETE, calls to methods that write (on the class
itself or on a constructor-injected one), and a transaction holding
writes as one. A write in a loop counts twice, of two branches only the
larger counts, local helpers count where they are called, and a helper
whose every call sits inside a transaction is not reported. --explain
lists the line of every call that counted.

Today's 102 reported methods sit in scripts/tx-writes-baseline.json and
may only shrink; most are syncs with network I/O between their writes,
plugin lifecycle steps and adapters calling two service writes. A new
method fails CI, and an entry above what its method holds fails until
--update lowers it, the same ratchet as lint:size and lint:boundaries.

Two JSDoc tags mark the deliberate cases with their reason in the tag:
@txStandalone for a write that stands on its own whatever the caller does
(the audit row, notification sends, the booking price mirror, the
assignment journey refresh, the quota retention purge), @txIndependent
for writes complete one by one on purpose (the OAuth token retention
sweep, the notification claim and its release). Runs in CI after
lint:boundaries; documented in server/CLAUDE.md, the root CLAUDE.md and
the wiki's dev setup page.
This commit is contained in:
Maurice
2026-10-08 15:38:06 +02:00
parent 9dd2e1b983
commit 8018f10273
14 changed files with 901 additions and 5 deletions
@@ -352,6 +352,9 @@ export class OauthTokensRepository extends TrekRepository<OauthTokens> {
* live chain is what replay detection walks. Each pass removes the current
* leaves, so a whole expired chain goes over a few passes; `maxPasses`
* bounds a chain that is still growing. Returns the number deleted.
*
* @txIndependent a retention sweep: every pass leaves a consistent table, and the
* next sweep picks up what a failed one left.
*/
async deleteExpiredBefore(cutoffIso: string, maxPasses = 50): Promise<number> {
let total = 0;
@@ -132,6 +132,9 @@ export class AssignmentsService {
* Re-mirror the trip's day-assigned places onto every linked journey's skeleton
* suggestions. Called after any assignment mutation (create/delete/move/time) so
* the journey stays in sync. Non-fatal, like the route's try/catch.
*
* @txStandalone a derived refresh that runs after the assignment write and
* never fails it.
*/
async reconcile(tripId: string | number, socketId?: string): Promise<void> {
try {
+3
View File
@@ -105,6 +105,9 @@ export class AuditService {
* gets a message distinct from every other write failure (below), so an
* unwrapped entrypoint stays visible in the log even though it never
* throws.
*
* @txStandalone the row records a change that already happened and never
* fails it, so it is not part of the caller's transaction.
*/
async writeAudit(entry: {
userId: number | null;
+2 -2
View File
@@ -536,8 +536,7 @@ export class BudgetService {
* otherwise a €15 museum on a trip switched to JPY starts reading as ¥15. They
* carry no frozen rate, so pinning the currency is all they need.
*
* Must run *before* the (synchronous) trip update, while the old currency is
* still in `trips`, and is a no-op when the currency isn't actually changing.
* Runs before the trip update, while the old currency is still in `trips`; a no-op without a change.
*/
async rebaseTripCurrency(
tripId: string | number,
@@ -910,6 +909,7 @@ export class BudgetService {
* it (#2084): their sum while they share one currency, the first one's total
* when they don't (a sum across currencies would be meaningless), and no
* price at all once none is linked. One expense gives exactly the old mirror.
* @txStandalone a non-fatal mirror onto the booking, worked out again on every change.
*/
async resyncReservationPrice(tripId: string | number, reservationId: number, socketId?: string): Promise<void> {
const linked = await this.budgetItemsRepo.listLinkedToReservation(tripId, reservationId);
@@ -67,6 +67,10 @@ export class GoogleQuotaService {
await this.usage.recordCall(todayUtc()); // GQ2
}
/**
* The ceiling and today's count, after dropping days past retention.
* @txStandalone the retention purge stands on its own, whatever the caller wrote.
*/
async status(): Promise<GoogleQuotaStatus> {
await this.usage.purgeExpired(RETENTION_DAYS); // GQ3
const limit = await this.dailyLimit();
@@ -599,6 +599,11 @@ export class NotificationsService {
return await this.notificationsRepo.deleteAllForRecipient(userId);
}
/**
* Run a notification's action for the recipient's answer.
* @txIndependent the claim commits on its own before the action runs, so a second
* submit sees it; a failing action releases it again.
*/
async respond(
notificationId: number,
userId: number,
@@ -655,6 +660,13 @@ export class NotificationsService {
// ── Unified dispatcher (from services/notificationService.ts) ─────────────
/**
* Resolves the recipients of one event and delivers it on every active channel.
*
* @txStandalone a notification reports a change after it is written; email
* and webhook delivery are network I/O, which never runs inside a transaction.
* @txIndependent each recipient's row is written on its own, between deliveries.
*/
async send(payload: NotificationPayload): Promise<NotificationDelivery> {
const { event, actorId, params, scope, targetId, inApp } = payload;
const delivery: NotificationDelivery = { attempted: 0, delivered: 0 };