style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+169 -154
View File
@@ -23,36 +23,36 @@
*
* Source: poller.ts pattern (runPoll/startBrokerPoller)
*/
import { z } from 'zod'
import { and, eq, lte } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js'
import { createFastmailClient } from './client.js'
import { decryptPassword } from './crypto.js'
import { syncCalendar } from './sync.js'
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js'
import { buildVeventString, extractRruleString, RRULE_PRESETS } from './vevent.js'
import type { FastmailClient } from './client.js'
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'
import { z } from 'zod';
import { and, eq, lte } from 'drizzle-orm';
import { db } from '../db/client.js';
import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js';
import { createFastmailClient } from './client.js';
import { decryptPassword } from './crypto.js';
import { syncCalendar } from './sync.js';
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js';
import { buildVeventString, extractRruleString, RRULE_PRESETS } from './vevent.js';
import type { FastmailClient } from './client.js';
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js';
// ── Constants (D-07) ────────────────────────────────────────────────────────
const MAX_ATTEMPTS = 5
const MAX_ATTEMPTS = 5;
/**
* Backoff delay in seconds per attempt index (0-based).
* Total window: 15+60+300+600+1800 ≈ 30 min.
*/
const BACKOFF_SECONDS = [15, 60, 300, 600, 1800]
const BACKOFF_SECONDS = [15, 60, 300, 600, 1800];
/** HTTP status codes treated as transient — retry with exponential backoff. */
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504])
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
/** HTTP status codes treated as hard failures — stop retry immediately. */
const HARD_FAIL_STATUSES = new Set([400, 401, 403])
const HARD_FAIL_STATUSES = new Set([400, 401, 403]);
/** HTTP status code for CalDAV If-Match conflict — D-08 conflict flow. */
const CONFLICT_STATUS = 412
const CONFLICT_STATUS = 412;
// ── Outbox payload re-validation (IN-03) ─────────────────────────────────────
@@ -83,12 +83,15 @@ const outboxPayloadSchema = z
// schema validates on ingress, but the outbox payload is re-parsed from stored JSON).
// Guarantees .replace(/-/g,'') in assembleRruleString emits digits-only, closing the
// RRULE-part injection vector. int().min(1) prevents zero/negative counts.
recurrenceUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), // 'YYYY-MM-DD' → RRULE UNTIL
recurrenceUntil: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
})
.passthrough()
.passthrough();
type OutboxPayloadFields = z.infer<typeof outboxPayloadSchema>
type OutboxPayloadFields = z.infer<typeof outboxPayloadSchema>;
// ── D-06: RRULE bound assembly ───────────────────────────────────────────────
@@ -120,21 +123,21 @@ export function assembleRruleString(
count?: number,
allDay?: boolean,
): string {
let s = basePreset
let s = basePreset;
if (count !== undefined) {
// COUNT wins over UNTIL (mutual exclusion)
s += `;COUNT=${count}`
s += `;COUNT=${count}`;
} else if (until) {
const dateDigits = until.replace(/-/g, '')
const dateDigits = until.replace(/-/g, '');
if (allDay) {
// DATE form for all-day events: YYYYMMDD (RFC 5545 §3.3.10)
s += `;UNTIL=${dateDigits}`
s += `;UNTIL=${dateDigits}`;
} else {
// DATETIME UTC form for timed events: YYYYMMDDTHHMMSSZ (end of UTC day)
s += `;UNTIL=${dateDigits}T235959Z`
s += `;UNTIL=${dateDigits}T235959Z`;
}
}
return s
return s;
}
// ── Drain concurrency guard (CR-05) ──────────────────────────────────────────
@@ -153,7 +156,7 @@ export function assembleRruleString(
* and only the process that wins the affected-rows check would dispatch the row.
* Do not remove this comment if deploying to multi-process infrastructure.
*/
let isDraining = false
let isDraining = false;
/**
* WR-06: max time to wait on the post-write targeted re-sync before marking the
@@ -161,7 +164,7 @@ let isDraining = false
* drain loop beyond this cap; the PWA's next sync-status poll reconciles any cache
* that the timed-out re-sync did not refresh.
*/
const RESYNC_TIMEOUT_MS = 10_000
const RESYNC_TIMEOUT_MS = 10_000;
// ── Credential + client loading ──────────────────────────────────────────────
@@ -175,19 +178,19 @@ async function loadClientForUser(userId: number): Promise<FastmailClient> {
const rows = await db
.select()
.from(memberCredentials)
.where(eq(memberCredentials.userId, userId))
.where(eq(memberCredentials.userId, userId));
// In production rows[0] is a real credential row.
// In unit tests the db mock returns the outbox row array (rows[0] is an outbox row) —
// that causes decryptPassword to throw, which is caught by the caller.
const cred = rows[0]
const cred = rows[0];
if (!cred) {
throw new Error(`No credential found for userId=${userId}`)
throw new Error(`No credential found for userId=${userId}`);
}
// T-03-13: decrypt only here; result never logged
const appPassword = decryptPassword(cred.encryptedPassword)
return createFastmailClient(cred.fastmailEmail, appPassword)
const appPassword = decryptPassword(cred.encryptedPassword);
return createFastmailClient(cred.fastmailEmail, appPassword);
}
// ── Targeted re-sync (D-06) ─────────────────────────────────────────────────
@@ -209,25 +212,24 @@ async function triggerTargetedResync(
): Promise<void> {
try {
// loadClientForUser may throw in test environments — caught below
let client = clientCache?.get(userId)
let client = clientCache?.get(userId);
if (!client) {
client = await loadClientForUser(userId)
clientCache?.set(userId, client)
client = await loadClientForUser(userId);
clientCache?.set(userId, client);
}
const davCalendars = await client.fetchCalendars()
const davCalendars = await client.fetchCalendars();
// Pitfall 7: find the DAVCalendar by URL match (normalize trailing slash differences)
const davCal = davCalendars.find(
(cal) =>
cal.url === calendarUrl ||
cal.url.replace(/\/$/, '') === calendarUrl.replace(/\/$/, ''),
)
cal.url === calendarUrl || cal.url.replace(/\/$/, '') === calendarUrl.replace(/\/$/, ''),
);
if (!davCal) {
console.error(
`[outboxWorker] DAVCalendar not found for url=${calendarUrl} — skipping re-sync`,
)
return
);
return;
}
// NOTIF-03: pass onChanges so this-member writes push to the other member.
@@ -238,38 +240,38 @@ async function triggerTargetedResync(
console.error(
'[outboxWorker] dispatchEventChange error:',
err instanceof Error ? err.message : String(err),
)
})
);
});
}
})
});
} catch (err) {
// Re-sync failure is non-fatal — log and continue (T-03-13)
console.error(
'[outboxWorker] triggerTargetedResync error:',
err instanceof Error ? err.message : String(err),
)
);
}
}
// ── Row dispatch ─────────────────────────────────────────────────────────────
type OutboxRow = typeof calendarOutbox.$inferSelect
type OutboxRow = typeof calendarOutbox.$inferSelect;
interface DispatchResult {
success: boolean
conflict: boolean
hardFail: boolean
transient: boolean
error?: string
success: boolean;
conflict: boolean;
hardFail: boolean;
transient: boolean;
error?: string;
}
async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// CR-03: fail closed on credential errors — let loadClientForUser throw.
// The outer per-row catch in runOutboxDrain logs and leaves the row pending (correct transient behavior).
// Do NOT add an empty-credential fallback — that would silently PUT with no authentication.
const client = await loadClientForUser(row.userId)
const client = await loadClientForUser(row.userId);
let response: Response
let response: Response;
if (row.operation === 'delete') {
if (!row.calendarObjectUrl) {
@@ -279,9 +281,9 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: true,
transient: false,
error: 'delete operation missing calendarObjectUrl',
}
};
}
response = await deleteCalendarEvent(client, row.calendarObjectUrl, row.etag ?? null)
response = await deleteCalendarEvent(client, row.calendarObjectUrl, row.etag ?? null);
} else if (row.operation === 'update') {
if (!row.payload || !row.calendarObjectUrl) {
return {
@@ -290,22 +292,34 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: true,
transient: false,
error: 'update operation missing payload or calendarObjectUrl',
}
};
}
// CR-02: parse the stored form JSON and build a real VCALENDAR string
let rawFields: Record<string, unknown>
let rawFields: Record<string, unknown>;
try {
rawFields = JSON.parse(row.payload) as Record<string, unknown>
rawFields = JSON.parse(row.payload) as Record<string, unknown>;
} catch {
return { success: false, conflict: false, hardFail: true, transient: false, error: 'payload parse failed' }
return {
success: false,
conflict: false,
hardFail: true,
transient: false,
error: 'payload parse failed',
};
}
// IN-03: re-validate the parsed payload. A schema-invalid row can never succeed —
// hard-fail it (no retry) rather than feeding undefined/Invalid Date into the VEVENT.
const parsedFields = outboxPayloadSchema.safeParse(rawFields)
const parsedFields = outboxPayloadSchema.safeParse(rawFields);
if (!parsedFields.success) {
return { success: false, conflict: false, hardFail: true, transient: false, error: `payload validation failed: ${parsedFields.error.message}` }
return {
success: false,
conflict: false,
hardFail: true,
transient: false,
error: `payload validation failed: ${parsedFields.error.message}`,
};
}
const fields: OutboxPayloadFields = parsedFields.data
const fields: OutboxPayloadFields = parsedFields.data;
// WR-01: recurrence preservation. The PWA omits `recurrence` from an edit payload
// (it cannot read the existing RRULE — not in the occurrence contract, D-03), so on
// update we must NOT rebuild the VEVENT with no RRULE — that would silently convert a
@@ -313,12 +327,12 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// recurrence, fall back to the RRULE already stored in calendarEvents.rawVevent.
// An explicit recurrence value (including 'none') still overrides — that is a
// deliberate user change. Read rawVevent in the same scoped query as the fresh etag.
let preservedRrule: string | undefined
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence')
let preservedRrule: string | undefined;
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
const rruleFromPayload =
fields.recurrence && fields.recurrence !== 'none'
? RRULE_PRESETS[fields.recurrence as string]
: undefined
: undefined;
// WR-02: re-read the freshest etag from calendarEvents just before PUT.
// Rapid successive edits to the same uid enqueue multiple update rows, each
// carrying the etag at enqueue time. If a prior edit succeeded and triggered
@@ -334,7 +348,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// D-08) or coincidentally match and overwrite. Scope the re-read to THIS row's
// own calendar by joining through calendars on the outbox row's userId +
// calendarUrl so the freshest etag belongs to the writing member.
let etagForPut: string | null = row.etag ?? null
let etagForPut: string | null = row.etag ?? null;
const freshEtagRows = (await db
.select({ etag: calendarEvents.etag, rawVevent: calendarEvents.rawVevent })
.from(calendarEvents)
@@ -346,15 +360,15 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
eq(calendars.url, row.calendarUrl),
),
)
.limit(1)) as Array<{ etag: string | null; rawVevent: string | null }>
.limit(1)) as Array<{ etag: string | null; rawVevent: string | null }>;
if (freshEtagRows.length > 0 && freshEtagRows[0].etag != null) {
etagForPut = freshEtagRows[0].etag
etagForPut = freshEtagRows[0].etag;
}
// WR-01: when the edit payload carries no explicit recurrence, preserve the RRULE
// already on the stored event so an edit does not strip a recurring series.
if (!hasExplicitRecurrence && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) {
preservedRrule = extractRruleString(freshEtagRows[0].rawVevent)
preservedRrule = extractRruleString(freshEtagRows[0].rawVevent);
}
// D-06: assemble the final RRULE string, combining the preset or preserved RRULE
@@ -364,7 +378,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// concatenate onto `FREQ=WEEKLY;BYDAY=...` which would produce double-UNTIL.
// WR-01 note: preservedRrule is only set when !hasExplicitRecurrence (see above),
// so the hasExplicitRecurrence branch always takes precedence over preserved RRULE.
let finalRruleString: string | undefined
let finalRruleString: string | undefined;
if (hasExplicitRecurrence) {
// Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted)
finalRruleString = rruleFromPayload
@@ -374,22 +388,22 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
fields.recurrenceCount,
fields.allDay,
)
: undefined
: undefined;
} else if (preservedRrule) {
if (fields.recurrenceUntil || fields.recurrenceCount !== undefined) {
// Series edit with bound change only: strip existing UNTIL/COUNT, then re-apply
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '')
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '');
finalRruleString = assembleRruleString(
strippedPreset,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay,
)
);
} else {
finalRruleString = preservedRrule
finalRruleString = preservedRrule;
}
} else {
finalRruleString = rruleFromPayload
finalRruleString = rruleFromPayload;
}
const { icsString } = buildVeventString({
@@ -401,14 +415,9 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
location: fields.location,
description: fields.description,
rruleString: finalRruleString,
})
});
response = await updateCalendarEvent(
client,
row.calendarObjectUrl,
icsString,
etagForPut,
)
response = await updateCalendarEvent(client, row.calendarObjectUrl, icsString, etagForPut);
} else {
// create
if (!row.payload) {
@@ -418,21 +427,33 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: true,
transient: false,
error: 'create operation missing payload',
}
};
}
// CR-02: parse the stored form JSON and build a real VCALENDAR string
let rawFields: Record<string, unknown>
let rawFields: Record<string, unknown>;
try {
rawFields = JSON.parse(row.payload) as Record<string, unknown>
rawFields = JSON.parse(row.payload) as Record<string, unknown>;
} catch {
return { success: false, conflict: false, hardFail: true, transient: false, error: 'payload parse failed' }
return {
success: false,
conflict: false,
hardFail: true,
transient: false,
error: 'payload parse failed',
};
}
// IN-03: re-validate the parsed payload — hard-fail a schema-invalid create row.
const parsedFields = outboxPayloadSchema.safeParse(rawFields)
const parsedFields = outboxPayloadSchema.safeParse(rawFields);
if (!parsedFields.success) {
return { success: false, conflict: false, hardFail: true, transient: false, error: `payload validation failed: ${parsedFields.error.message}` }
return {
success: false,
conflict: false,
hardFail: true,
transient: false,
error: `payload validation failed: ${parsedFields.error.message}`,
};
}
const fields: OutboxPayloadFields = parsedFields.data
const fields: OutboxPayloadFields = parsedFields.data;
// CR-01: edit-as-move RRULE preservation. The same-calendar `update` branch
// preserves a recurring series' RRULE by reading rawVevent; the `create` branch
// (used for the create half of an edit-as-move, D-04) has no source for the
@@ -441,21 +462,21 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// the worker can re-apply it here. An explicit `recurrence` on the payload still
// wins (deliberate user change); the preserved RRULE only fills the gap when the
// edit omitted recurrence — matching the update-branch semantics and the WR-01 fix.
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence')
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
const rruleFromPayload =
fields.recurrence && fields.recurrence !== 'none'
? RRULE_PRESETS[fields.recurrence as string]
: undefined
: undefined;
const preservedRrule =
typeof fields._preservedRrule === 'string' && fields._preservedRrule.length > 0
? fields._preservedRrule
: undefined
: undefined;
// D-06: assemble the final RRULE string with optional UNTIL/COUNT bound.
// CR-01: an explicit recurrence preset wins over _preservedRrule (deliberate user choice).
// recurrence:'none' explicitly clears any RRULE — including when _preservedRrule is present.
// If no explicit recurrence, fall back to _preservedRrule (edit-as-move RRULE carry-through).
let finalRruleString: string | undefined
let finalRruleString: string | undefined;
if (hasExplicitRecurrence) {
// Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted)
finalRruleString = rruleFromPayload
@@ -465,22 +486,22 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
fields.recurrenceCount,
fields.allDay,
)
: undefined
: undefined;
} else if (preservedRrule) {
if (fields.recurrenceUntil || fields.recurrenceCount !== undefined) {
// Bound change on preserved RRULE: strip existing UNTIL/COUNT first (Pitfall 3)
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '')
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '');
finalRruleString = assembleRruleString(
strippedPreset,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay,
)
);
} else {
finalRruleString = preservedRrule
finalRruleString = preservedRrule;
}
} else {
finalRruleString = rruleFromPayload
finalRruleString = rruleFromPayload;
}
const { icsString } = buildVeventString({
@@ -492,13 +513,13 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
location: fields.location,
description: fields.description,
rruleString: finalRruleString,
})
});
// Build a minimal DAVCalendar for the write wrapper (only url is needed)
const davCalendar = { url: row.calendarUrl } as Parameters<typeof createCalendarEvent>[1]
response = await createCalendarEvent(client, davCalendar, row.uid, icsString)
const davCalendar = { url: row.calendarUrl } as Parameters<typeof createCalendarEvent>[1];
response = await createCalendarEvent(client, davCalendar, row.uid, icsString);
}
const status = response.status
const status = response.status;
if (status === CONFLICT_STATUS) {
return {
@@ -507,7 +528,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: false,
transient: false,
error: `412 conflict: etag mismatch for uid=${row.uid}`,
}
};
}
if (HARD_FAIL_STATUSES.has(status)) {
@@ -517,7 +538,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: true,
transient: false,
error: `Hard fail: HTTP ${status} for uid=${row.uid}`,
}
};
}
if (TRANSIENT_STATUSES.has(status)) {
@@ -527,11 +548,11 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: false,
transient: true,
error: `Transient error: HTTP ${status} for uid=${row.uid}`,
}
};
}
if (response.ok) {
return { success: true, conflict: false, hardFail: false, transient: false }
return { success: true, conflict: false, hardFail: false, transient: false };
}
// IN-02: an unmapped 4xx (e.g. 405, 409, 422) is a permanent client error — retrying it
@@ -547,7 +568,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: true,
transient: false,
error: `Hard fail: HTTP ${status} for uid=${row.uid}`,
}
};
}
// Unknown / 5xx status — treat as transient to avoid silent data loss
@@ -557,7 +578,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: false,
transient: true,
error: `Unknown HTTP ${status} for uid=${row.uid}`,
}
};
}
// ── Main drain loop ──────────────────────────────────────────────────────────
@@ -580,8 +601,8 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
*/
export async function runOutboxDrain(): Promise<void> {
// CR-05: single-process concurrency guard (see isDraining declaration for limitations)
if (isDraining) return
isDraining = true
if (isDraining) return;
isDraining = true;
try {
// Fetch pending rows: WHERE status='pending' AND next_attempt_at <= NOW()
@@ -589,40 +610,37 @@ export async function runOutboxDrain(): Promise<void> {
.select()
.from(calendarOutbox)
.where(
and(
eq(calendarOutbox.status, 'pending'),
lte(calendarOutbox.nextAttemptAt, new Date()),
),
)) as OutboxRow[]
and(eq(calendarOutbox.status, 'pending'), lte(calendarOutbox.nextAttemptAt, new Date())),
)) as OutboxRow[];
if (pending.length === 0) return
if (pending.length === 0) return;
// D-04 edit-as-move ordering: sort so create rows come before delete rows within the same groupId.
// Rows without a groupId are unaffected (stable relative order preserved).
// This is a fast path; the authoritative gate is the durable DB sibling-status check below.
const sorted = [...pending].sort((a, b) => {
if (a.groupId && b.groupId && a.groupId === b.groupId) {
if (a.operation === 'create' && b.operation === 'delete') return -1
if (a.operation === 'delete' && b.operation === 'create') return 1
if (a.operation === 'create' && b.operation === 'delete') return -1;
if (a.operation === 'delete' && b.operation === 'create') return 1;
}
return 0
})
return 0;
});
// Track groupIds where the create failed within this batch (fast path for same-batch pairs).
// Cross-batch ordering is enforced durably by the DB sibling-status check inside the loop.
const failedCreateGroups = new Set<string>()
const failedCreateGroups = new Set<string>();
// IN-01: per-drain-cycle client cache so triggerTargetedResync decrypts each member's
// credential at most once per cycle. Discarded when the drain returns — never persisted.
const clientCache = new Map<number, FastmailClient>()
const clientCache = new Map<number, FastmailClient>();
for (const row of sorted) {
// D-04 fast path: if the create for this group already failed in this batch, skip the delete
if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) {
console.warn(
`[outboxWorker] Skipping delete row.id=${row.id} — create for groupId=${row.groupId} failed this batch (D-04)`,
)
continue
);
continue;
}
// CR-04: Durable create-before-delete gate — query DB for sibling create status.
@@ -632,40 +650,37 @@ export async function runOutboxDrain(): Promise<void> {
.select({ status: calendarOutbox.status })
.from(calendarOutbox)
.where(
and(
eq(calendarOutbox.groupId, row.groupId),
eq(calendarOutbox.operation, 'create'),
),
)) as Array<{ status: string }>
and(eq(calendarOutbox.groupId, row.groupId), eq(calendarOutbox.operation, 'create')),
)) as Array<{ status: string }>;
const siblingStatus = siblingRows[0]?.status
const siblingStatus = siblingRows[0]?.status;
if (siblingStatus !== 'done') {
if (siblingStatus === 'failed' || siblingStatus === 'dead') {
// Sibling create failed permanently — skip this delete forever (D-04: original preserved)
console.warn(
`[outboxWorker] Paired create for groupId=${row.groupId} is ${siblingStatus} — marking delete row.id=${row.id} failed (original event preserved, D-04)`,
)
);
await db
.update(calendarOutbox)
.set({
status: 'failed',
lastError: 'paired create did not succeed — original preserved',
})
.where(eq(calendarOutbox.id, row.id))
.where(eq(calendarOutbox.id, row.id));
} else {
// Sibling create is still pending/processing — defer this delete to a later cycle
console.warn(
`[outboxWorker] Deferring delete row.id=${row.id} — sibling create (groupId=${row.groupId}) is not yet done (status=${siblingStatus ?? 'not found'})`,
)
);
// Leave the delete row pending; do NOT update its status
}
continue
continue;
}
}
try {
const result = await dispatchRow(row)
const result = await dispatchRow(row);
if (result.conflict) {
// WR-06: distinguish an edit-as-move create-412 from a same-calendar conflict.
@@ -677,19 +692,19 @@ export async function runOutboxDrain(): Promise<void> {
// and has no cue to retry. Emit a move-specific lastError that does NOT contain
// '412' so the toast routes it to the dedicated move-failed copy instead of the
// generic etag-conflict copy.
const isMoveCreate = !!row.groupId && row.operation === 'create'
const isMoveCreate = !!row.groupId && row.operation === 'create';
const conflictError = isMoveCreate
? 'move-failed: the event could not be moved — re-open it and save again'
: (result.error ?? '412 conflict')
: (result.error ?? '412 conflict');
// 412 — mark failed (no retry), re-sync calendar so UI sees authoritative state (D-08)
await db
.update(calendarOutbox)
.set({ status: 'failed', lastError: conflictError })
.where(eq(calendarOutbox.id, row.id))
await triggerTargetedResync(row.calendarUrl, row.userId, clientCache)
.where(eq(calendarOutbox.id, row.id));
await triggerTargetedResync(row.calendarUrl, row.userId, clientCache);
if (row.groupId && row.operation === 'create') {
failedCreateGroups.add(row.groupId)
failedCreateGroups.add(row.groupId);
}
} else if (result.success) {
// Success — refresh the local cache BEFORE marking done. The PWA's
@@ -711,24 +726,24 @@ export async function runOutboxDrain(): Promise<void> {
await Promise.race([
triggerTargetedResync(row.calendarUrl, row.userId, clientCache),
new Promise<void>((resolve) => setTimeout(resolve, RESYNC_TIMEOUT_MS)),
])
]);
await db
.update(calendarOutbox)
.set({ status: 'done' })
.where(eq(calendarOutbox.id, row.id))
.where(eq(calendarOutbox.id, row.id));
} else if (result.hardFail) {
// Hard fail — mark failed immediately, no retry (D-07)
await db
.update(calendarOutbox)
.set({ status: 'failed', lastError: result.error ?? 'Hard fail' })
.where(eq(calendarOutbox.id, row.id))
.where(eq(calendarOutbox.id, row.id));
if (row.groupId && row.operation === 'create') {
failedCreateGroups.add(row.groupId)
failedCreateGroups.add(row.groupId);
}
} else {
// Transient — exponential backoff or dead-letter (D-07 / T-03-12)
const nextAttemptCount = row.attemptCount + 1
const nextAttemptCount = row.attemptCount + 1;
if (nextAttemptCount >= MAX_ATTEMPTS) {
// Dead-letter: max attempts reached (T-03-12)
await db
@@ -738,15 +753,15 @@ export async function runOutboxDrain(): Promise<void> {
attemptCount: nextAttemptCount,
lastError: result.error ?? 'Max attempts exceeded',
})
.where(eq(calendarOutbox.id, row.id))
.where(eq(calendarOutbox.id, row.id));
if (row.groupId && row.operation === 'create') {
failedCreateGroups.add(row.groupId)
failedCreateGroups.add(row.groupId);
}
} else {
// WR-01: use row.attemptCount (the attempt that just failed, 0-based) as the backoff index.
// This makes the first retry wait BACKOFF_SECONDS[0]=15s, not BACKOFF_SECONDS[1]=60s.
const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000
const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000;
await db
.update(calendarOutbox)
.set({
@@ -754,7 +769,7 @@ export async function runOutboxDrain(): Promise<void> {
nextAttemptAt: new Date(Date.now() + backoffMs),
lastError: result.error,
})
.where(eq(calendarOutbox.id, row.id))
.where(eq(calendarOutbox.id, row.id));
}
}
} catch (err) {
@@ -762,11 +777,11 @@ export async function runOutboxDrain(): Promise<void> {
console.error(
`[outboxWorker] Error dispatching row.id=${row.id} uid=${row.uid}:`,
err instanceof Error ? err.message : String(err),
)
);
}
}
} finally {
isDraining = false
isDraining = false;
}
}
@@ -781,7 +796,7 @@ export async function runOutboxDrain(): Promise<void> {
export function startOutboxWorker(): void {
setInterval(() => {
runOutboxDrain().catch((err: unknown) => {
console.error('[outboxWorker] Unhandled runOutboxDrain error:', err)
})
}, 15 * 1000)
console.error('[outboxWorker] Unhandled runOutboxDrain error:', err);
});
}, 15 * 1000);
}