Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
Showing only changes of commit 95f9d8c097 - Show all commits
+21 -4
View File
@@ -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' })