Milestone v1.0: FamilySync MVP #1
@@ -104,11 +104,24 @@ async function loadClientForUser(userId: number): Promise<FastmailClient> {
|
||||
* Triggers a targeted single-calendar re-sync after a successful write or 412 conflict.
|
||||
* Fetches fresh DAVCalendars so ctag/etag are authoritative (Pitfall 7 — no stale objects).
|
||||
* All errors are caught and logged — re-sync failure is non-fatal.
|
||||
*
|
||||
* IN-01: accepts an optional per-drain-cycle client cache. Without it, every settled or
|
||||
* conflicted row independently reloaded + AES-GCM-decrypted the member credential,
|
||||
* widening the window the decrypted app password lives in memory (T-03-13). When a cache
|
||||
* is supplied, the decrypted client is built at most once per userId per drain cycle.
|
||||
*/
|
||||
async function triggerTargetedResync(calendarUrl: string, userId: number): Promise<void> {
|
||||
async function triggerTargetedResync(
|
||||
calendarUrl: string,
|
||||
userId: number,
|
||||
clientCache?: Map<number, FastmailClient>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// loadClientForUser may throw in test environments — caught below
|
||||
const client = await loadClientForUser(userId)
|
||||
let client = clientCache?.get(userId)
|
||||
if (!client) {
|
||||
client = await loadClientForUser(userId)
|
||||
clientCache?.set(userId, client)
|
||||
}
|
||||
const davCalendars = await client.fetchCalendars()
|
||||
|
||||
// Pitfall 7: find the DAVCalendar by URL match (normalize trailing slash differences)
|
||||
@@ -382,6 +395,10 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
// Cross-batch ordering is enforced durably by the DB sibling-status check inside the loop.
|
||||
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>()
|
||||
|
||||
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)) {
|
||||
@@ -452,7 +469,7 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
.update(calendarOutbox)
|
||||
.set({ status: 'failed', lastError: conflictError })
|
||||
.where(eq(calendarOutbox.id, row.id))
|
||||
await triggerTargetedResync(row.calendarUrl, row.userId)
|
||||
await triggerTargetedResync(row.calendarUrl, row.userId, clientCache)
|
||||
|
||||
if (row.groupId && row.operation === 'create') {
|
||||
failedCreateGroups.add(row.groupId)
|
||||
@@ -464,7 +481,7 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
// raced the re-sync and returned stale cache (deleted event still
|
||||
// present, edit not yet applied) — forcing a manual refresh. Re-syncing
|
||||
// first means 'done' guarantees the cache already reflects the write.
|
||||
await triggerTargetedResync(row.calendarUrl, row.userId)
|
||||
await triggerTargetedResync(row.calendarUrl, row.userId, clientCache)
|
||||
await db
|
||||
.update(calendarOutbox)
|
||||
.set({ status: 'done' })
|
||||
|
||||
Reference in New Issue
Block a user