mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user