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
2 changed files with 11 additions and 16 deletions
Showing only changes of commit c21b040b36 - Show all commits
+7 -13
View File
@@ -130,18 +130,10 @@ interface DispatchResult {
} }
async function dispatchRow(row: OutboxRow): Promise<DispatchResult> { async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// Load the authenticated client for this row's owner. // CR-03: fail closed on credential errors — let loadClientForUser throw.
// In test environments loadClientForUser may fail (db mock mismatch) — fall back // The outer per-row catch in runOutboxDrain logs and leaves the row pending (correct transient behavior).
// to createFastmailClient with empty credentials (mocked in tests to return fake client). // Do NOT add an empty-credential fallback — that would silently PUT with no authentication.
let client: FastmailClient const client = await loadClientForUser(row.userId)
try {
client = await loadClientForUser(row.userId)
} catch {
// Unit-test path: db mock returns outbox rows for any select → decryptPassword throws.
// createFastmailClient is mocked and ignores credentials, so this still works.
// Production path: this branch is never taken (real Drizzle query succeeds).
client = await createFastmailClient('', '')
}
let response: Response let response: Response
@@ -364,7 +356,9 @@ export async function runOutboxDrain(): Promise<void> {
failedCreateGroups.add(row.groupId) failedCreateGroups.add(row.groupId)
} }
} else { } else {
const backoffMs = (BACKOFF_SECONDS[nextAttemptCount] ?? 1800) * 1000 // 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
await db await db
.update(calendarOutbox) .update(calendarOutbox)
.set({ .set({
+4 -3
View File
@@ -21,6 +21,7 @@
* Mounted under /api/* in index.ts — behind oidcAuthMiddleware. * Mounted under /api/* in index.ts — behind oidcAuthMiddleware.
*/ */
import { randomUUID } from 'node:crypto'
import { Hono } from 'hono' import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator' import { zValidator } from '@hono/zod-validator'
import { z } from 'zod' import { z } from 'zod'
@@ -253,7 +254,7 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
} }
// Generate a UID for the new event (Node.js 22 built-in) // Generate a UID for the new event (Node.js 22 built-in)
const uid = `${crypto.randomUUID()}@familysync` const uid = `${randomUUID()}@familysync`
// Enqueue the outbox row (pending) — the worker builds the VEVENT and calls Fastmail. // Enqueue the outbox row (pending) — the worker builds the VEVENT and calls Fastmail.
await db.insert(calendarOutbox).values({ await db.insert(calendarOutbox).values({
@@ -325,8 +326,8 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
if (isCalendarMove) { if (isCalendarMove) {
// D-04: edit-as-move — insert delete+create pair in one transaction (D-04 / Pitfall 5) // D-04: edit-as-move — insert delete+create pair in one transaction (D-04 / Pitfall 5)
const newUid = `${crypto.randomUUID()}@familysync` const newUid = `${randomUUID()}@familysync`
const groupId = crypto.randomUUID() const groupId = randomUUID()
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
// Delete from old calendar // Delete from old calendar