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
+36 -42
View File
@@ -15,14 +15,14 @@
* Fire-and-forget: sync correctness does not depend on push success.
*/
import { eq, ne } from 'drizzle-orm'
import { db } from '../db/client.js'
import { users, pushSubscriptions } from '../db/schema.js'
import { dispatchPush } from './pushDispatcher.js'
import { eq, ne } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users, pushSubscriptions } from '../db/schema.js';
import { dispatchPush } from './pushDispatcher.js';
// ── Types ────────────────────────────────────────────────────────────────────
export type EventChangeOperation = 'create' | 'update' | 'delete'
export type EventChangeOperation = 'create' | 'update' | 'delete';
/**
* Meaningful fields whose change triggers a push notification (D-04).
@@ -34,21 +34,21 @@ export const MEANINGFUL_FIELDS = new Set([
'allDay',
'title',
'location',
])
]);
/**
* Payload describing a detected calendar event change.
* Produced by syncCalendar and consumed by poller + outboxWorker.
*/
export interface EventChange {
uid: string
title: string | null
operation: EventChangeOperation
uid: string;
title: string | null;
operation: EventChangeOperation;
/** For 'update': which fields changed. Omit for 'create' and 'delete'. */
changedFields?: string[]
changedFields?: string[];
/** UTC timestamp of the event start (for notification copy). */
dtstartUtc?: Date | null
allDay?: boolean
dtstartUtc?: Date | null;
allDay?: boolean;
}
// ── Core logic ───────────────────────────────────────────────────────────────
@@ -63,11 +63,11 @@ export interface EventChange {
*/
export function isMeaningfulChange(change: EventChange): boolean {
if (change.operation === 'create' || change.operation === 'delete') {
return true
return true;
}
// update: require at least one meaningful field
const fields = change.changedFields ?? []
return fields.some((f) => MEANINGFUL_FIELDS.has(f))
const fields = change.changedFields ?? [];
return fields.some((f) => MEANINGFUL_FIELDS.has(f));
}
/**
@@ -81,32 +81,32 @@ function buildCopy(
change: EventChange,
actorName: string,
): { notifTitle: string; notifBody: string; navigate: string } {
const eventTitle = change.title ?? change.uid
const eventTitle = change.title ?? change.uid;
let notifTitle: string
let notifBody: string
let notifTitle: string;
let notifBody: string;
// D-02: event notifications show specifics — actor + title.
// D-03: name the actor in every change notification.
if (change.operation === 'create') {
notifTitle = `${actorName} added an event`
notifBody = eventTitle
notifTitle = `${actorName} added an event`;
notifBody = eventTitle;
} else if (change.operation === 'delete') {
notifTitle = `${actorName} removed an event`
notifBody = eventTitle
notifTitle = `${actorName} removed an event`;
notifBody = eventTitle;
} else {
// update
notifTitle = `${actorName} updated an event`
notifBody = eventTitle
notifTitle = `${actorName} updated an event`;
notifBody = eventTitle;
}
// Navigate: /calendar?event=uid for create/update; /calendar for delete
const navigate =
change.operation === 'delete'
? '/calendar'
: `/calendar?event=${encodeURIComponent(change.uid)}`
: `/calendar?event=${encodeURIComponent(change.uid)}`;
return { notifTitle, notifBody, navigate }
return { notifTitle, notifBody, navigate };
}
/**
@@ -119,13 +119,10 @@ function buildCopy(
*
* Fire-and-forget: resolves after dispatching without awaiting push ACKs.
*/
export async function dispatchEventChange(
change: EventChange,
actorUserId: number,
): Promise<void> {
export async function dispatchEventChange(change: EventChange, actorUserId: number): Promise<void> {
// D-04: skip description-only edits
if (!isMeaningfulChange(change)) {
return
return;
}
// IN-01: resolve actor display name for D-02/D-03 notification copy.
@@ -139,22 +136,19 @@ export async function dispatchEventChange(
// D-13: query push_subscriptions from MariaDB only
// D-03: ne() filter excludes the actor at DB level; application-level filter
// below provides defence-in-depth (also makes the mock-based tests deterministic).
db
.select()
.from(pushSubscriptions)
.where(ne(pushSubscriptions.userId, actorUserId)),
])
db.select().from(pushSubscriptions).where(ne(pushSubscriptions.userId, actorUserId)),
]);
const actorName: string = actorRows[0]?.displayName ?? 'A family member'
const actorName: string = actorRows[0]?.displayName ?? 'A family member';
// D-03: additional application-level actor exclusion (defence-in-depth)
const subs = allSubs.filter((s) => s.userId !== actorUserId)
const subs = allSubs.filter((s) => s.userId !== actorUserId);
if (subs.length === 0) {
return
return;
}
const { notifTitle, notifBody, navigate } = buildCopy(change, actorName)
const { notifTitle, notifBody, navigate } = buildCopy(change, actorName);
// Fan out to all non-actor subscriptions (D-03 already enforced by ne() filter)
for (const sub of subs) {
@@ -164,12 +158,12 @@ export async function dispatchEventChange(
body: notifBody,
tag: `event-change-${change.uid}`,
navigate,
})
});
} catch (err) {
console.error(
`[eventChangeDispatcher] Error dispatching for uid=${change.uid} sub.id=${sub.id}:`,
err instanceof Error ? err.message : String(err),
)
);
}
}
}
+7 -10
View File
@@ -17,27 +17,24 @@
* Source: RESEARCH.md Finding 3 verbatim pattern.
*/
import { eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { lists, listShares } from '../db/schema.js'
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { lists, listShares } from '../db/schema.js';
/**
* Returns all list IDs accessible to userId:
* owned lists UNION lists shared to this user, deduplicated.
*/
export async function getAccessibleListIds(userId: number): Promise<number[]> {
const owned = await db.select({ id: lists.id }).from(lists).where(eq(lists.ownerId, userId))
const owned = await db.select({ id: lists.id }).from(lists).where(eq(lists.ownerId, userId));
const shared = await db
.select({ listId: listShares.listId })
.from(listShares)
.where(eq(listShares.userId, userId))
.where(eq(listShares.userId, userId));
const all = [
...owned.map((r) => r.id),
...shared.map((r) => r.listId),
]
const all = [...owned.map((r) => r.id), ...shared.map((r) => r.listId)];
// Deduplicate (handles the degenerate case where a list is both owned and shared)
return [...new Set(all)]
return [...new Set(all)];
}
+37 -38
View File
@@ -17,11 +17,11 @@
* - coalesceListPush handles burst collapsing; this module owns audience + copy.
*/
import { eq, inArray } from 'drizzle-orm'
import { db } from '../db/client.js'
import { users, lists, listShares, pushSubscriptions } from '../db/schema.js'
import { coalesceListPush } from './pushCoalescer.js'
import { dispatchPush } from './pushDispatcher.js'
import { eq, inArray } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users, lists, listShares, pushSubscriptions } from '../db/schema.js';
import { coalesceListPush } from './pushCoalescer.js';
import { dispatchPush } from './pushDispatcher.js';
/**
* Notify all accessible, non-actor subscribers that a list changed.
@@ -36,83 +36,82 @@ import { dispatchPush } from './pushDispatcher.js'
* for fast fake-timer or real-timer test execution.
*/
export function notifyListChange(listId: number, actorId: number, windowMs?: number): void {
coalesceListPush(listId, actorId, async (coalListId, coalActorId, count) => {
try {
await sendListChangePush(coalListId, coalActorId, count)
} catch (err: unknown) {
console.error(
`[listChangeDispatcher] unhandled error for list ${coalListId}:`,
err instanceof Error ? err.message : String(err),
)
}
}, windowMs)
coalesceListPush(
listId,
actorId,
async (coalListId, coalActorId, count) => {
try {
await sendListChangePush(coalListId, coalActorId, count);
} catch (err: unknown) {
console.error(
`[listChangeDispatcher] unhandled error for list ${coalListId}:`,
err instanceof Error ? err.message : String(err),
);
}
},
windowMs,
);
}
/**
* Inner dispatch: resolves actor name + list name, builds the audience,
* and sends a push to every accessible non-actor subscriber.
*/
async function sendListChangePush(
listId: number,
actorId: number,
count: number,
): Promise<void> {
async function sendListChangePush(listId: number, actorId: number, count: number): Promise<void> {
// Resolve actor display name and list name in parallel
const [actorRow, listRow] = await Promise.all([
db.select({ displayName: users.displayName }).from(users).where(eq(users.id, actorId)).limit(1),
db.select({ name: lists.name }).from(lists).where(eq(lists.id, listId)).limit(1),
])
]);
if (!listRow[0]) {
// List deleted between mutation and coalesce fire — no-op
return
return;
}
const actorName: string = actorRow[0]?.displayName ?? 'Someone'
const listName: string = listRow[0].name
const actorName: string = actorRow[0]?.displayName ?? 'Someone';
const listName: string = listRow[0].name;
// Build audience: list owner list_shares members, MINUS the actor (D-03)
const [ownerRows, shareRows] = await Promise.all([
db.select({ ownerId: lists.ownerId }).from(lists).where(eq(lists.id, listId)).limit(1),
db.select({ userId: listShares.userId }).from(listShares).where(eq(listShares.listId, listId)),
])
]);
if (!ownerRows[0]) {
// List gone — no-op
return
return;
}
const ownerId = ownerRows[0].ownerId
const shareUserIds = shareRows.map((r) => r.userId)
const ownerId = ownerRows[0].ownerId;
const shareUserIds = shareRows.map((r) => r.userId);
// Union of owner + sharees; deduplicate; exclude actor (D-03)
const audienceIds = [
...new Set([ownerId, ...shareUserIds]),
].filter((uid) => uid !== actorId)
const audienceIds = [...new Set([ownerId, ...shareUserIds])].filter((uid) => uid !== actorId);
if (audienceIds.length === 0) {
return
return;
}
// Load push subscriptions for all audience members
const subs = await db
.select()
.from(pushSubscriptions)
.where(inArray(pushSubscriptions.userId, audienceIds))
.where(inArray(pushSubscriptions.userId, audienceIds));
if (subs.length === 0) {
return
return;
}
// D-02 generic copy: "{Actor} made {N} changes to {ListName}"
// No item text — keeps the lock screen clean.
const body = `${actorName} made ${count} ${count === 1 ? 'change' : 'changes'} to ${listName}`
const body = `${actorName} made ${count} ${count === 1 ? 'change' : 'changes'} to ${listName}`;
const notification = {
title: listName,
body,
tag: `list-change:${listId}`,
navigate: `/lists/${listId}`,
}
};
// Fan out to each subscription; one failure must not abort the rest (T-05-04)
for (const sub of subs) {
@@ -120,7 +119,7 @@ async function sendListChangePush(
console.error(
`[listChangeDispatcher] dispatchPush failed for sub ${sub.id}:`,
err instanceof Error ? err.message : String(err),
)
})
);
});
}
}
+11 -11
View File
@@ -15,29 +15,29 @@
* Source: RESEARCH.md Finding 1 verbatim pattern.
*/
import { EventEmitter } from 'node:events'
import { EventEmitter } from 'node:events';
// Module-level singleton — one emitter shared across all route handlers
// in this Node.js process.
const emitter = new EventEmitter()
emitter.setMaxListeners(200) // 100 members × 2 devices, generous headroom (T-04-04)
const emitter = new EventEmitter();
emitter.setMaxListeners(200); // 100 members × 2 devices, generous headroom (T-04-04)
/**
* Event type union for list change notifications.
* All events carry the originating listId and an opaque payload.
*/
export type ListEvent = {
type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted'
listId: number
payload: unknown
}
type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted';
listId: number;
payload: unknown;
};
/**
* Broadcast an event to all SSE subscribers watching this list.
* Channel is keyed by listId — events for list A never reach subscribers of list B.
*/
export function publishListEvent(listId: number, event: ListEvent): void {
emitter.emit(`list:${listId}`, event)
emitter.emit(`list:${listId}`, event);
}
/**
@@ -48,7 +48,7 @@ export function subscribeListEvents(
listId: number,
handler: (event: ListEvent) => void,
): () => void {
const channel = `list:${listId}`
emitter.on(channel, handler)
return () => emitter.off(channel, handler)
const channel = `list:${listId}`;
emitter.on(channel, handler);
return () => emitter.off(channel, handler);
}
+19 -24
View File
@@ -8,16 +8,16 @@
* Exports: coalesceListPush
*/
type DispatchFn = (listId: number, actorId: number, count: number) => Promise<void>
type DispatchFn = (listId: number, actorId: number, count: number) => Promise<void>;
type PendingEntry = {
count: number
timer: ReturnType<typeof setTimeout>
}
count: number;
timer: ReturnType<typeof setTimeout>;
};
// Module-level singleton — keyed by `${listId}:${actorId}`.
// Entries are self-deleting: deleted when the timer fires.
const pending = new Map<string, PendingEntry>()
const pending = new Map<string, PendingEntry>();
/**
* Coalesce list-change push notifications for a single (list, actor) pair.
@@ -36,35 +36,30 @@ export function coalesceListPush(
dispatch: DispatchFn,
windowMs = 45_000,
): void {
const key = `${listId}:${actorId}`
const existing = pending.get(key)
const key = `${listId}:${actorId}`;
const existing = pending.get(key);
if (existing) {
// Extend the window on every new call within the burst (sliding debounce).
clearTimeout(existing.timer)
existing.count++
existing.timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs)
clearTimeout(existing.timer);
existing.count++;
existing.timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs);
} else {
// First call in a new burst — start a fresh entry.
const timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs)
pending.set(key, { count: 1, timer })
const timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs);
pending.set(key, { count: 1, timer });
}
}
function fire(
key: string,
listId: number,
actorId: number,
dispatch: DispatchFn,
): void {
const entry = pending.get(key)
if (!entry) return
const count = entry.count
pending.delete(key)
function fire(key: string, listId: number, actorId: number, dispatch: DispatchFn): void {
const entry = pending.get(key);
if (!entry) return;
const count = entry.count;
pending.delete(key);
dispatch(listId, actorId, count).catch((err: unknown) => {
console.error(
`[pushCoalescer] dispatch failed for list ${listId}:`,
err instanceof Error ? err.message : String(err),
)
})
);
});
}
+25 -25
View File
@@ -16,31 +16,31 @@
* - dispatchPush resolves (never throws) so fan-out loops continue after failures.
*/
import webpush from 'web-push'
import { eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { pushSubscriptions } from '../db/schema.js'
import webpush from 'web-push';
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { pushSubscriptions } from '../db/schema.js';
/**
* Push subscription row shape (subset of schema.pushSubscriptions used by dispatcher).
*/
export type PushSubscription = {
id: number
userId: number
endpoint: string
p256dh: string
auth: string
}
id: number;
userId: number;
endpoint: string;
p256dh: string;
auth: string;
};
/**
* Notification content passed to dispatchPush and used to build the push body.
*/
export type NotificationPayload = {
title: string
body?: string
tag?: string
navigate?: string
}
title: string;
body?: string;
tag?: string;
navigate?: string;
};
/**
* Build the dual-format push payload body string.
@@ -52,7 +52,7 @@ export type NotificationPayload = {
* The service worker reads whichever format the browser understands.
*/
export function buildPushBody(notification: NotificationPayload): string {
const { title, body = '', tag, navigate } = notification
const { title, body = '', tag, navigate } = notification;
return JSON.stringify({
// iOS 18.4+ declarative web push format (WebKit blog 2025-04-14)
@@ -69,7 +69,7 @@ export function buildPushBody(notification: NotificationPayload): string {
data: {
url: navigate,
},
})
});
}
/**
@@ -96,26 +96,26 @@ export async function dispatchPush(
p256dh: sub.p256dh,
auth: sub.auth,
},
}
};
const body = buildPushBody(notification)
const body = buildPushBody(notification);
try {
await webpush.sendNotification(webPushSub, body, {
TTL: 300,
urgency: 'normal',
})
});
} catch (err: unknown) {
const statusCode = (err as { statusCode?: number }).statusCode
const statusCode = (err as { statusCode?: number }).statusCode;
if (statusCode === 410 || statusCode === 404) {
// Dead subscription — prune from DB (D-11)
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id))
return
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id));
return;
}
// Transient error — log and continue; do NOT delete subscription
const message = err instanceof Error ? err.message : String(err)
console.error('[pushDispatcher] sendNotification failed:', statusCode, message)
const message = err instanceof Error ? err.message : String(err);
console.error('[pushDispatcher] sendNotification failed:', statusCode, message);
}
}
+3 -3
View File
@@ -12,7 +12,7 @@
* Both are pure functions — no DB access, no side effects.
*/
import { generateKeyBetween } from 'fractional-indexing'
import { generateKeyBetween } from 'fractional-indexing';
/**
* Generate a rank suitable for appending an item AFTER the last active item.
@@ -23,7 +23,7 @@ import { generateKeyBetween } from 'fractional-indexing'
* An empty list gets "a0" (generateKeyBetween(null, null)).
*/
export function rankForAppend(lastRank: string | null): string {
return generateKeyBetween(lastRank, null)
return generateKeyBetween(lastRank, null);
}
/**
@@ -38,5 +38,5 @@ export function rankForAppend(lastRank: string | null): string {
* @returns a rank string that sorts between `prev` and `next` when ordered ASC.
*/
export function rankBetween(prev: string | null, next: string | null): string {
return generateKeyBetween(prev, next)
return generateKeyBetween(prev, next);
}