docs(intel): refresh codebase intelligence at 99f59c3 — capture Phase 4 lists + live-sync
Stale intel reflected commit 01f7456 (pre-Phase-4). Refresh adds the shared-lists
+ SSE subsystem: 21 API endpoints (was 10), lists/sse routes, listEmitter/listAccess/rank
libs, PWA lists surface, 7 new arch decisions, and Phase 4 deps (react-router, @dnd-kit,
fractional-indexing). Canonical filenames preserved; API-SURFACE.md + .last-refresh.json regenerated.
This commit is contained in:
@@ -102,4 +102,95 @@
|
||||
- **auth:** oidcAuthMiddleware
|
||||
- **file:** apps/api/src/routes/sse.ts
|
||||
- **response:** text/event-stream — event: heartbeat, data: { ts, id } every 10s
|
||||
- **description:** SSE smoke-test endpoint for Pangolin tunnel validation. Phase 4 list-sync events not yet wired.
|
||||
- **description:** SSE smoke-test endpoint for Pangolin tunnel validation.
|
||||
|
||||
## `GET /api/sse/lists`
|
||||
|
||||
- **method:** GET
|
||||
- **path:** /api/sse/lists
|
||||
- **auth:** oidcAuthMiddleware
|
||||
- **file:** apps/api/src/routes/sse.ts
|
||||
- **response:** text/event-stream — events: item:added | item:updated | item:deleted | list:updated | list:deleted, plus heartbeat every 30s
|
||||
- **description:** Scoped live-list fan-out stream (LIST-04, D-04). Subscribes only to list channels accessible to the caller (owner + shares). Event payload triggers client-side query invalidation (D-10). 30s keepalive heartbeat for Pangolin tunnel.
|
||||
|
||||
## `GET /api/lists`
|
||||
|
||||
- **method:** GET
|
||||
- **path:** /api/lists
|
||||
- **auth:** oidcAuthMiddleware
|
||||
- **file:** apps/api/src/routes/lists.ts
|
||||
- **response:** { lists: [{ id, name, isShared, ownerId, activeCount, doneCount, createdAt, updatedAt }] }
|
||||
- **description:** Scoped list index. Returns only lists the caller owns or has a list_shares row for (T-04-02, D-04). Includes per-list item counts.
|
||||
|
||||
## `POST /api/lists`
|
||||
|
||||
- **method:** POST
|
||||
- **path:** /api/lists
|
||||
- **auth:** oidcAuthMiddleware
|
||||
- **body:** { name: string, isShared?: boolean (default true) }
|
||||
- **file:** apps/api/src/routes/lists.ts
|
||||
- **response:** 201 { id, name, isShared, ownerId, activeCount, doneCount, createdAt, updatedAt }
|
||||
- **description:** Create a named list. isShared=true (default) auto-inserts list_shares for all other members (D-01/D-02). Shares are server-managed only — no client shares endpoint (T-04-08).
|
||||
|
||||
## `PATCH /api/lists/:id`
|
||||
|
||||
- **method:** PATCH
|
||||
- **path:** /api/lists/:id
|
||||
- **auth:** oidcAuthMiddleware
|
||||
- **params:** id (path)
|
||||
- **body:** { name?: string, isShared?: boolean } — at least one field required
|
||||
- **file:** apps/api/src/routes/lists.ts
|
||||
- **response:** { id, name, isShared, ownerId, createdAt, updatedAt }
|
||||
- **description:** Update name and/or isShared. Caller must be owner or sharee. isShared mutations owner-only (T-04-07/T-04-08). Visibility change reconciles list_shares (false→true inserts; true→false deletes all non-owner shares).
|
||||
|
||||
## `DELETE /api/lists/:id`
|
||||
|
||||
- **method:** DELETE
|
||||
- **path:** /api/lists/:id
|
||||
- **auth:** oidcAuthMiddleware
|
||||
- **params:** id (path)
|
||||
- **file:** apps/api/src/routes/lists.ts
|
||||
- **response:** { id }
|
||||
- **description:** Owner-only delete. Cascade via FK onDelete:cascade removes items and shares. Non-owner sharees receive 403.
|
||||
|
||||
## `GET /api/lists/:id/items`
|
||||
|
||||
- **method:** GET
|
||||
- **path:** /api/lists/:id/items
|
||||
- **auth:** oidcAuthMiddleware
|
||||
- **params:** id (path)
|
||||
- **file:** apps/api/src/routes/lists.ts
|
||||
- **response:** { items: [{ id, listId, text, checked, rank, createdAt, updatedAt }] }
|
||||
- **description:** Returns all items for the list ordered by rank ASC. Access-gated: owner or sharee only (T-04-05).
|
||||
|
||||
## `POST /api/lists/:id/items`
|
||||
|
||||
- **method:** POST
|
||||
- **path:** /api/lists/:id/items
|
||||
- **auth:** oidcAuthMiddleware
|
||||
- **params:** id (path)
|
||||
- **body:** { text: string (1..500) }
|
||||
- **file:** apps/api/src/routes/lists.ts
|
||||
- **response:** 201 { id, listId, text, checked, rank, createdAt, updatedAt }
|
||||
- **description:** Add item to list. Rank assigned via rankForAppend(lastActiveRank) — appends after last unchecked item. Access-gated (T-04-05). publishListEvent item:added fan-out.
|
||||
|
||||
## `PATCH /api/list-items/:itemId`
|
||||
|
||||
- **method:** PATCH
|
||||
- **path:** /api/list-items/:itemId
|
||||
- **auth:** oidcAuthMiddleware
|
||||
- **params:** itemId (path)
|
||||
- **body:** exactly one of: { checked: boolean } | { text: string } | { position: string }
|
||||
- **file:** apps/api/src/routes/lists.ts
|
||||
- **response:** { id, listId, text, checked, rank, createdAt, updatedAt }
|
||||
- **description:** Per-field last-write-wins item update (D-08). Exactly one field enforced by Zod (T-04-07). uncheck (checked:false) recomputes rank to active-bottom. 404 if item missing (T-04-09, no upsert). publishListEvent item:updated fan-out.
|
||||
|
||||
## `DELETE /api/list-items/:itemId`
|
||||
|
||||
- **method:** DELETE
|
||||
- **path:** /api/list-items/:itemId
|
||||
- **auth:** oidcAuthMiddleware
|
||||
- **params:** itemId (path)
|
||||
- **file:** apps/api/src/routes/lists.ts
|
||||
- **response:** { id }
|
||||
- **description:** Delete item instantly (D-06). Delete-wins semantics (D-09): no rollback path. Access-gated: owner or sharee. publishListEvent item:deleted fan-out.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"_meta": {
|
||||
"updated_at": "2026-06-09T00:00:00Z",
|
||||
"commit": "01f7456b81dd55d477d0bd7530df818f61a873c2",
|
||||
"version": 1
|
||||
"updated_at": "2026-06-09T18:56:37.459Z",
|
||||
"commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f",
|
||||
"version": 3
|
||||
},
|
||||
"entries": {
|
||||
"GET /health": {
|
||||
@@ -38,7 +38,10 @@
|
||||
"method": "GET",
|
||||
"path": "/api/events",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"params": ["start (YYYY-MM-DD, required)", "end (YYYY-MM-DD, required)"],
|
||||
"params": [
|
||||
"start (YYYY-MM-DD, required)",
|
||||
"end (YYYY-MM-DD, required)"
|
||||
],
|
||||
"file": "apps/api/src/routes/events.ts",
|
||||
"response": "{ occurrences: CalendarOccurrence[] }",
|
||||
"description": "Windowed calendar events. Max 90-day window. Reads only from MariaDB cache; never hits Fastmail. Expands RRULEs server-side."
|
||||
@@ -56,7 +59,9 @@
|
||||
"method": "PATCH",
|
||||
"path": "/api/events/:uid/edit",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"params": ["uid (path)"],
|
||||
"params": [
|
||||
"uid (path)"
|
||||
],
|
||||
"body": "CreateEventPayload",
|
||||
"file": "apps/api/src/routes/events.ts",
|
||||
"response": "202 { uid: string }",
|
||||
@@ -66,7 +71,9 @@
|
||||
"method": "DELETE",
|
||||
"path": "/api/events/:uid",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"params": ["uid (path)"],
|
||||
"params": [
|
||||
"uid (path)"
|
||||
],
|
||||
"file": "apps/api/src/routes/events.ts",
|
||||
"response": "202 { uid: string }",
|
||||
"description": "Enqueues delete to calendarOutbox with cached etag (If-Match). Async write-back."
|
||||
@@ -75,7 +82,9 @@
|
||||
"method": "GET",
|
||||
"path": "/api/events/sync-status",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"params": ["uid (query, required)"],
|
||||
"params": [
|
||||
"uid (query, required)"
|
||||
],
|
||||
"file": "apps/api/src/routes/events.ts",
|
||||
"response": "{ uid: string, status: 'pending'|'done'|'failed'|'dead', error?: string }",
|
||||
"description": "Outbox status poll for a given event UID, scoped to current member. Used by SyncStateToast."
|
||||
@@ -94,7 +103,101 @@
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"file": "apps/api/src/routes/sse.ts",
|
||||
"response": "text/event-stream — event: heartbeat, data: { ts, id } every 10s",
|
||||
"description": "SSE smoke-test endpoint for Pangolin tunnel validation. Phase 4 list-sync events not yet wired."
|
||||
"description": "SSE smoke-test endpoint for Pangolin tunnel validation."
|
||||
},
|
||||
"GET /api/sse/lists": {
|
||||
"method": "GET",
|
||||
"path": "/api/sse/lists",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"file": "apps/api/src/routes/sse.ts",
|
||||
"response": "text/event-stream — events: item:added | item:updated | item:deleted | list:updated | list:deleted, plus heartbeat every 30s",
|
||||
"description": "Scoped live-list fan-out stream (LIST-04, D-04). Subscribes only to list channels accessible to the caller (owner + shares). Event payload triggers client-side query invalidation (D-10). 30s keepalive heartbeat for Pangolin tunnel."
|
||||
},
|
||||
"GET /api/lists": {
|
||||
"method": "GET",
|
||||
"path": "/api/lists",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"file": "apps/api/src/routes/lists.ts",
|
||||
"response": "{ lists: [{ id, name, isShared, ownerId, activeCount, doneCount, createdAt, updatedAt }] }",
|
||||
"description": "Scoped list index. Returns only lists the caller owns or has a list_shares row for (T-04-02, D-04). Includes per-list item counts."
|
||||
},
|
||||
"POST /api/lists": {
|
||||
"method": "POST",
|
||||
"path": "/api/lists",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"body": "{ name: string, isShared?: boolean (default true) }",
|
||||
"file": "apps/api/src/routes/lists.ts",
|
||||
"response": "201 { id, name, isShared, ownerId, activeCount, doneCount, createdAt, updatedAt }",
|
||||
"description": "Create a named list. isShared=true (default) auto-inserts list_shares for all other members (D-01/D-02). Shares are server-managed only — no client shares endpoint (T-04-08)."
|
||||
},
|
||||
"PATCH /api/lists/:id": {
|
||||
"method": "PATCH",
|
||||
"path": "/api/lists/:id",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"params": [
|
||||
"id (path)"
|
||||
],
|
||||
"body": "{ name?: string, isShared?: boolean } — at least one field required",
|
||||
"file": "apps/api/src/routes/lists.ts",
|
||||
"response": "{ id, name, isShared, ownerId, createdAt, updatedAt }",
|
||||
"description": "Update name and/or isShared. Caller must be owner or sharee. isShared mutations owner-only (T-04-07/T-04-08). Visibility change reconciles list_shares (false→true inserts; true→false deletes all non-owner shares)."
|
||||
},
|
||||
"DELETE /api/lists/:id": {
|
||||
"method": "DELETE",
|
||||
"path": "/api/lists/:id",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"params": [
|
||||
"id (path)"
|
||||
],
|
||||
"file": "apps/api/src/routes/lists.ts",
|
||||
"response": "{ id }",
|
||||
"description": "Owner-only delete. Cascade via FK onDelete:cascade removes items and shares. Non-owner sharees receive 403."
|
||||
},
|
||||
"GET /api/lists/:id/items": {
|
||||
"method": "GET",
|
||||
"path": "/api/lists/:id/items",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"params": [
|
||||
"id (path)"
|
||||
],
|
||||
"file": "apps/api/src/routes/lists.ts",
|
||||
"response": "{ items: [{ id, listId, text, checked, rank, createdAt, updatedAt }] }",
|
||||
"description": "Returns all items for the list ordered by rank ASC. Access-gated: owner or sharee only (T-04-05)."
|
||||
},
|
||||
"POST /api/lists/:id/items": {
|
||||
"method": "POST",
|
||||
"path": "/api/lists/:id/items",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"params": [
|
||||
"id (path)"
|
||||
],
|
||||
"body": "{ text: string (1..500) }",
|
||||
"file": "apps/api/src/routes/lists.ts",
|
||||
"response": "201 { id, listId, text, checked, rank, createdAt, updatedAt }",
|
||||
"description": "Add item to list. Rank assigned via rankForAppend(lastActiveRank) — appends after last unchecked item. Access-gated (T-04-05). publishListEvent item:added fan-out."
|
||||
},
|
||||
"PATCH /api/list-items/:itemId": {
|
||||
"method": "PATCH",
|
||||
"path": "/api/list-items/:itemId",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"params": [
|
||||
"itemId (path)"
|
||||
],
|
||||
"body": "exactly one of: { checked: boolean } | { text: string } | { position: string }",
|
||||
"file": "apps/api/src/routes/lists.ts",
|
||||
"response": "{ id, listId, text, checked, rank, createdAt, updatedAt }",
|
||||
"description": "Per-field last-write-wins item update (D-08). Exactly one field enforced by Zod (T-04-07). uncheck (checked:false) recomputes rank to active-bottom. 404 if item missing (T-04-09, no upsert). publishListEvent item:updated fan-out."
|
||||
},
|
||||
"DELETE /api/list-items/:itemId": {
|
||||
"method": "DELETE",
|
||||
"path": "/api/list-items/:itemId",
|
||||
"auth": "oidcAuthMiddleware",
|
||||
"params": [
|
||||
"itemId (path)"
|
||||
],
|
||||
"file": "apps/api/src/routes/lists.ts",
|
||||
"response": "{ id }",
|
||||
"description": "Delete item instantly (D-06). Delete-wins semantics (D-09): no rollback path. Access-gated: owner or sharee. publishListEvent item:deleted fan-out."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,160 @@
|
||||
{
|
||||
"_meta": {
|
||||
"updated_at": "2026-06-09T00:00:00Z",
|
||||
"commit": "01f7456b81dd55d477d0bd7530df818f61a873c2",
|
||||
"version": 1
|
||||
"updated_at": "2026-06-09T18:56:37.788Z",
|
||||
"commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f",
|
||||
"version": 3
|
||||
},
|
||||
"entries": {
|
||||
"broker-cache-api-pattern": {
|
||||
"title": "Broker-Cache-API pattern (two planes never cross)",
|
||||
"decision": "Backend split into a broker plane (apps/api/src/broker/) that owns all Fastmail I/O and an API plane (apps/api/src/routes/) that reads only from MariaDB. Broker crons are not reachable from the HTTP layer.",
|
||||
"files": ["apps/api/src/broker/poller.ts", "apps/api/src/broker/outboxWorker.ts", "apps/api/src/routes/"]
|
||||
"files": [
|
||||
"apps/api/src/broker/poller.ts",
|
||||
"apps/api/src/broker/outboxWorker.ts",
|
||||
"apps/api/src/routes/"
|
||||
]
|
||||
},
|
||||
"write-broker-boundary": {
|
||||
"title": "Write-broker boundary invariant",
|
||||
"decision": "No route file imports tsdav or createFastmailClient; no broker file handles HTTP requests. Routes enqueue calendar_outbox rows and return 202 (optimistic-accept); the outbox worker performs the Fastmail write asynchronously.",
|
||||
"files": ["apps/api/src/routes/events.ts", "apps/api/src/broker/write.ts"]
|
||||
"files": [
|
||||
"apps/api/src/routes/events.ts",
|
||||
"apps/api/src/broker/write.ts"
|
||||
]
|
||||
},
|
||||
"identity-keying": {
|
||||
"title": "Identity keyed on oidc_iss + oidc_sub",
|
||||
"decision": "Users are keyed on oidc_iss + oidc_sub (never email). A hex color from the palette is auto-assigned on first login.",
|
||||
"files": ["apps/api/src/routes/me.ts", "apps/api/src/auth/middleware.ts"]
|
||||
"files": [
|
||||
"apps/api/src/routes/me.ts",
|
||||
"apps/api/src/auth/middleware.ts"
|
||||
]
|
||||
},
|
||||
"D-03-writable-set": {
|
||||
"title": "D-03 calendar ownership / writable-set predicate",
|
||||
"decision": "Every writable-set query uses WHERE userId = currentUser.id OR isShared = true. Another member's personal calendar is a read-only overlay.",
|
||||
"files": ["apps/api/src/routes/events.ts"]
|
||||
"files": [
|
||||
"apps/api/src/routes/events.ts"
|
||||
]
|
||||
},
|
||||
"D-13-dual-field-dtstart": {
|
||||
"title": "D-13 all-day vs timed events (dual dtstart fields)",
|
||||
"decision": "dtstart_utc is NULL for all-day events; dtstart_date is NULL for timed events. Never coerce DATE to DATETIME.",
|
||||
"files": ["apps/api/src/db/schema.ts"]
|
||||
"files": [
|
||||
"apps/api/src/db/schema.ts"
|
||||
]
|
||||
},
|
||||
"D-16-shared-fastmail-account": {
|
||||
"title": "D-16 shared Fastmail account, per-member credentials",
|
||||
"decision": "Both members share one Fastmail account. Calendar identity in DB is (userId, url) — the same collection URL appears once per member credential. CalDAV credential per member is stored AES-256-GCM encrypted in member_credentials.",
|
||||
"files": ["apps/api/src/db/schema.ts", "apps/api/src/broker/poller.ts"]
|
||||
"files": [
|
||||
"apps/api/src/db/schema.ts",
|
||||
"apps/api/src/broker/poller.ts"
|
||||
]
|
||||
},
|
||||
"outbox-status-machine": {
|
||||
"title": "Outbox status machine",
|
||||
"decision": "calendar_outbox rows transition pending -> done | failed | dead. failed rows retry up to a limit; dead is terminal. The sync-status endpoint surfaces worst-status-first per uid.",
|
||||
"files": ["apps/api/src/broker/outboxWorker.ts", "apps/api/src/routes/events.ts"]
|
||||
"files": [
|
||||
"apps/api/src/broker/outboxWorker.ts",
|
||||
"apps/api/src/routes/events.ts"
|
||||
]
|
||||
},
|
||||
"oidc-behind-pangolin": {
|
||||
"title": "OIDC behind Pangolin requires OIDC_AUTH_EXTERNAL_URL",
|
||||
"decision": "OIDC_AUTH_EXTERNAL_URL must be set to the public HTTPS URL to construct a correct redirect_uri; without it the callback resolves to the internal container address.",
|
||||
"files": ["apps/api/src/auth/middleware.ts", "apps/api/src/index.ts"]
|
||||
"files": [
|
||||
"apps/api/src/auth/middleware.ts",
|
||||
"apps/api/src/index.ts"
|
||||
]
|
||||
},
|
||||
"dev-auth-bypass": {
|
||||
"title": "Dev auth bypass",
|
||||
"decision": "DEV_AUTH_BYPASS=true with NODE_ENV!=production injects DEV_USER via Hono context; OIDC middleware is never mounted in this mode.",
|
||||
"files": ["apps/api/src/auth/devBypass.js", "apps/api/src/index.ts"]
|
||||
"files": [
|
||||
"apps/api/src/auth/devBypass.js",
|
||||
"apps/api/src/index.ts"
|
||||
]
|
||||
},
|
||||
"pwa-static-serving": {
|
||||
"title": "PWA static serving + SPA fallback",
|
||||
"decision": "Hono serveStatic serves ./public (Vite build output); SPA routes fall through to an index.html catch-all registered after /health, /api/*, and /callback so those win.",
|
||||
"files": ["apps/api/src/index.ts"]
|
||||
"files": [
|
||||
"apps/api/src/index.ts"
|
||||
]
|
||||
},
|
||||
"schedule-x-routing": {
|
||||
"title": "Schedule-X calendar routing",
|
||||
"decision": "Events are routed to Schedule-X calendars by isShared ? 'shared' : String(ownerUserId) — never by calendarId. hydrateEvents.ts enforces this.",
|
||||
"files": ["apps/pwa/src/hydrateEvents.ts", "apps/pwa/src/components/CalendarShell.tsx"]
|
||||
"files": [
|
||||
"apps/pwa/src/lib/hydrateEvents.ts",
|
||||
"apps/pwa/src/components/CalendarShell.tsx"
|
||||
]
|
||||
},
|
||||
"state-ownership": {
|
||||
"title": "Client state ownership split",
|
||||
"decision": "Server state is owned by TanStack Query; UI-only state (selected range, color map, drawer) by Zustand. Schedule-X renders the calendar UI.",
|
||||
"files": ["apps/pwa/src/store/calendarStore.ts", "apps/pwa/src/components/CalendarShell.tsx"]
|
||||
"files": [
|
||||
"apps/pwa/src/store/calendarStore.ts",
|
||||
"apps/pwa/src/components/CalendarShell.tsx"
|
||||
]
|
||||
},
|
||||
"lists-storage-mariadb-not-caldav": {
|
||||
"title": "Lists stored in MariaDB, not CalDAV (Phase 4)",
|
||||
"decision": "Named lists and items are app-owned data in MariaDB (lists, list_items, list_shares tables), not pushed to Fastmail. CalDAV is exclusively for calendar events.",
|
||||
"files": [
|
||||
"apps/api/src/db/schema.ts",
|
||||
"apps/api/src/routes/lists.ts"
|
||||
]
|
||||
},
|
||||
"D-01-D-02-list-sharing": {
|
||||
"title": "D-01/D-02 list sharing via join table (member-count-agnostic)",
|
||||
"decision": "isShared=true (default) triggers auto-insert of list_shares rows for all other users at create/patch time. Shares are server-managed only — no client-writable shares endpoint (T-04-08). list_shares join table is member-count-agnostic for future N-member expansion.",
|
||||
"files": [
|
||||
"apps/api/src/routes/lists.ts",
|
||||
"apps/api/src/db/schema.ts"
|
||||
]
|
||||
},
|
||||
"D-04-scoped-sse-fan-out": {
|
||||
"title": "D-04 scoped SSE fan-out — per-list channels, not global",
|
||||
"decision": "GET /api/sse/lists resolves the caller's accessible list IDs via getAccessibleListIds, then subscribes one listEmitter channel per ID. Private lists of other members are never delivered. In-memory EventEmitter singleton (D-18) — no Redis; single-process, no replicas.",
|
||||
"files": [
|
||||
"apps/api/src/routes/sse.ts",
|
||||
"apps/api/src/lib/listEmitter.ts",
|
||||
"apps/api/src/lib/listAccess.ts"
|
||||
]
|
||||
},
|
||||
"D-08-per-field-lww-patch": {
|
||||
"title": "D-08 per-field last-write-wins PATCH for list items",
|
||||
"decision": "PATCH /api/list-items/:itemId accepts exactly one field (checked | text | position). Zod enforces single-field constraint. Prevents one client's stale read overwriting concurrent updates to other fields.",
|
||||
"files": [
|
||||
"apps/api/src/routes/lists.ts"
|
||||
]
|
||||
},
|
||||
"D-13-fractional-rank": {
|
||||
"title": "D-13 fractional-indexing rank for list item ordering",
|
||||
"decision": "list_items.rank is a varchar(255) COLLATE utf8mb4_bin using fractional-indexing strings. A single drag-reorder writes only the moved item's rank (one-row write). utf8mb4_bin collation required so uppercase-prefixed ranks (e.g. 'Zz') sort before lowercase (e.g. 'a0'), matching JS string order.",
|
||||
"files": [
|
||||
"apps/api/src/db/schema.ts",
|
||||
"apps/api/src/lib/rank.ts",
|
||||
"apps/pwa/src/routes/ListDetail.tsx"
|
||||
]
|
||||
},
|
||||
"D-10-D-11-D-12-sse-resilience": {
|
||||
"title": "D-10/D-11/D-12 SSE resilience: invalidate-not-patch, bounded backoff, polling fallback",
|
||||
"decision": "D-10: SSE events carry minimal { type, listId } payload; client full-refetches via TanStack Query invalidation rather than patching cache from event payload. D-11: useListSSE implements bounded backoff (250ms→8s cap, MAX_ATTEMPTS then give-up). D-12: 30s polling fallback always active in ListDetail as safety net.",
|
||||
"files": [
|
||||
"apps/pwa/src/hooks/useListSSE.ts",
|
||||
"apps/pwa/src/routes/ListDetail.tsx"
|
||||
]
|
||||
},
|
||||
"react-router-spa-shell": {
|
||||
"title": "react-router BrowserRouter SPA shell with BottomTabBar",
|
||||
"decision": "App.tsx wraps routes in BrowserRouter with declarative Routes. BottomTabBar is a sibling of Routes (not inside) so it persists across navigation. SW navigateFallback covers /lists/* deep-links.",
|
||||
"files": [
|
||||
"apps/pwa/src/App.tsx",
|
||||
"apps/pwa/src/components/BottomTabBar.tsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"_meta": {
|
||||
"updated_at": "2026-06-09T00:00:00Z",
|
||||
"commit": "01f7456b81dd55d477d0bd7530df818f61a873c2",
|
||||
"version": 1
|
||||
"updated_at": "2026-06-09T18:56:37.618Z",
|
||||
"commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f",
|
||||
"version": 3
|
||||
},
|
||||
"entries": {
|
||||
"hono": {
|
||||
@@ -10,147 +10,249 @@
|
||||
"type": "production",
|
||||
"workspace": "@familysync/api",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/api/src/index.ts", "apps/api/src/routes/"]
|
||||
"used_by": [
|
||||
"apps/api/src/index.ts",
|
||||
"apps/api/src/routes/"
|
||||
]
|
||||
},
|
||||
"@hono/node-server": {
|
||||
"version": "2.0.4",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/api",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/api/src/index.ts"]
|
||||
"used_by": [
|
||||
"apps/api/src/index.ts"
|
||||
]
|
||||
},
|
||||
"@hono/oidc-auth": {
|
||||
"version": "1.8.3",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/api",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/api/src/auth/middleware.ts"]
|
||||
"used_by": [
|
||||
"apps/api/src/auth/middleware.ts"
|
||||
]
|
||||
},
|
||||
"@hono/zod-validator": {
|
||||
"version": "0.8.0",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/api",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/api/src/routes/events.ts"]
|
||||
"used_by": [
|
||||
"apps/api/src/routes/events.ts",
|
||||
"apps/api/src/routes/lists.ts"
|
||||
]
|
||||
},
|
||||
"drizzle-orm": {
|
||||
"version": "0.45.2",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/api",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/api/src/db/client.ts", "apps/api/src/db/schema.ts", "apps/api/src/routes/"]
|
||||
"used_by": [
|
||||
"apps/api/src/db/client.ts",
|
||||
"apps/api/src/db/schema.ts",
|
||||
"apps/api/src/routes/",
|
||||
"apps/api/src/lib/listAccess.ts"
|
||||
]
|
||||
},
|
||||
"mysql2": {
|
||||
"version": "3.22.4",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/api",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/api/src/db/client.ts"]
|
||||
"used_by": [
|
||||
"apps/api/src/db/client.ts"
|
||||
]
|
||||
},
|
||||
"tsdav": {
|
||||
"version": "2.2.2",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/api",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/api/src/broker/client.ts", "apps/api/src/broker/write.ts"]
|
||||
"used_by": [
|
||||
"apps/api/src/broker/client.ts",
|
||||
"apps/api/src/broker/write.ts"
|
||||
]
|
||||
},
|
||||
"ical.js": {
|
||||
"version": "2.2.1",
|
||||
"type": "production",
|
||||
"workspace": "both (@familysync/api + @familysync/pwa)",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/api/src/broker/expand.ts", "apps/api/src/broker/vevent.ts", "apps/api/src/broker/sync.ts"]
|
||||
"used_by": [
|
||||
"apps/api/src/broker/expand.ts",
|
||||
"apps/api/src/broker/vevent.ts",
|
||||
"apps/api/src/broker/sync.ts"
|
||||
]
|
||||
},
|
||||
"zod": {
|
||||
"version": "^3.25.0",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/api",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/api/src/routes/events.ts"]
|
||||
"used_by": [
|
||||
"apps/api/src/routes/events.ts",
|
||||
"apps/api/src/routes/lists.ts"
|
||||
]
|
||||
},
|
||||
"fractional-indexing": {
|
||||
"version": "^3.2.0",
|
||||
"type": "production",
|
||||
"workspace": "both (@familysync/api + @familysync/pwa)",
|
||||
"invocation": "require",
|
||||
"used_by": [
|
||||
"apps/api/src/lib/rank.ts",
|
||||
"apps/pwa/src/routes/ListDetail.tsx"
|
||||
]
|
||||
},
|
||||
"node-cron": {
|
||||
"version": "^4.2.1",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/api",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/api/src/broker/poller.ts", "apps/api/src/broker/outboxWorker.ts"]
|
||||
"used_by": [
|
||||
"apps/api/src/broker/poller.ts",
|
||||
"apps/api/src/broker/outboxWorker.ts"
|
||||
]
|
||||
},
|
||||
"drizzle-kit": {
|
||||
"version": "0.31.10",
|
||||
"type": "development",
|
||||
"workspace": "@familysync/api",
|
||||
"invocation": "npm run db:generate / npm run db:migrate",
|
||||
"used_by": ["npm run db:generate", "npm run db:migrate", "npm run db:push"]
|
||||
"used_by": [
|
||||
"npm run db:generate",
|
||||
"npm run db:migrate",
|
||||
"npm run db:push"
|
||||
]
|
||||
},
|
||||
"temporal-polyfill": {
|
||||
"version": "0.3.2",
|
||||
"type": "production",
|
||||
"workspace": "both",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/api/src/broker/expand.ts", "apps/pwa/src/lib/eventDateTime.ts"]
|
||||
"used_by": [
|
||||
"apps/api/src/broker/expand.ts",
|
||||
"apps/pwa/src/lib/eventDateTime.ts"
|
||||
]
|
||||
},
|
||||
"react": {
|
||||
"version": "^19.0.0",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/pwa",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/pwa/src/"]
|
||||
"used_by": [
|
||||
"apps/pwa/src/"
|
||||
]
|
||||
},
|
||||
"react-router": {
|
||||
"version": "^7.17.0",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/pwa",
|
||||
"invocation": "require",
|
||||
"used_by": [
|
||||
"apps/pwa/src/App.tsx",
|
||||
"apps/pwa/src/routes/",
|
||||
"apps/pwa/src/components/BottomTabBar.tsx"
|
||||
]
|
||||
},
|
||||
"@dnd-kit/core": {
|
||||
"version": "^6.3.1",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/pwa",
|
||||
"invocation": "require",
|
||||
"used_by": [
|
||||
"apps/pwa/src/routes/ListDetail.tsx"
|
||||
]
|
||||
},
|
||||
"@dnd-kit/sortable": {
|
||||
"version": "^10.0.0",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/pwa",
|
||||
"invocation": "require",
|
||||
"used_by": [
|
||||
"apps/pwa/src/routes/ListDetail.tsx"
|
||||
]
|
||||
},
|
||||
"vite": {
|
||||
"version": "8.0.16",
|
||||
"type": "development",
|
||||
"workspace": "@familysync/pwa",
|
||||
"invocation": "npm run dev / npm run build",
|
||||
"used_by": ["npm run dev", "npm run build"]
|
||||
"used_by": [
|
||||
"npm run dev",
|
||||
"npm run build"
|
||||
]
|
||||
},
|
||||
"vite-plugin-pwa": {
|
||||
"version": "^1.3.0",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/pwa",
|
||||
"invocation": "implicit",
|
||||
"used_by": ["apps/pwa/vite.config.ts"]
|
||||
"used_by": [
|
||||
"apps/pwa/vite.config.ts"
|
||||
]
|
||||
},
|
||||
"@tanstack/react-query": {
|
||||
"version": "5.101.0",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/pwa",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/pwa/src/App.tsx", "apps/pwa/src/components/"]
|
||||
"used_by": [
|
||||
"apps/pwa/src/App.tsx",
|
||||
"apps/pwa/src/components/",
|
||||
"apps/pwa/src/routes/ListsIndex.tsx",
|
||||
"apps/pwa/src/routes/ListDetail.tsx",
|
||||
"apps/pwa/src/hooks/useListSSE.ts"
|
||||
]
|
||||
},
|
||||
"zustand": {
|
||||
"version": "5.0.14",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/pwa",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/pwa/src/store/calendarStore.ts"]
|
||||
"used_by": [
|
||||
"apps/pwa/src/store/calendarStore.ts",
|
||||
"apps/pwa/src/store/listsStore.ts"
|
||||
]
|
||||
},
|
||||
"@schedule-x/calendar": {
|
||||
"version": "4.6.0",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/pwa",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/pwa/src/components/CalendarShell.tsx", "apps/pwa/src/lib/calendarConfig.ts"]
|
||||
"used_by": [
|
||||
"apps/pwa/src/components/CalendarShell.tsx",
|
||||
"apps/pwa/src/lib/calendarConfig.ts"
|
||||
]
|
||||
},
|
||||
"@schedule-x/react": {
|
||||
"version": "4.1.0",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/pwa",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/pwa/src/components/CalendarShell.tsx"]
|
||||
"used_by": [
|
||||
"apps/pwa/src/components/CalendarShell.tsx"
|
||||
]
|
||||
},
|
||||
"lucide-react": {
|
||||
"version": "1.17.0",
|
||||
"type": "production",
|
||||
"workspace": "@familysync/pwa",
|
||||
"invocation": "require",
|
||||
"used_by": ["apps/pwa/src/components/"]
|
||||
"used_by": [
|
||||
"apps/pwa/src/components/"
|
||||
]
|
||||
},
|
||||
"vitest": {
|
||||
"version": "^4.1.8",
|
||||
"type": "development",
|
||||
"workspace": "both",
|
||||
"invocation": "npm test",
|
||||
"used_by": ["npm test", "npm run test:watch"]
|
||||
"used_by": [
|
||||
"npm test",
|
||||
"npm run test:watch"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+328
-78
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"_meta": {
|
||||
"updated_at": "2026-06-09T00:00:00Z",
|
||||
"commit": "01f7456b81dd55d477d0bd7530df818f61a873c2",
|
||||
"version": 1
|
||||
"updated_at": "2026-06-09T18:56:37.326Z",
|
||||
"commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f",
|
||||
"version": 3
|
||||
},
|
||||
"entries": {
|
||||
"apps/api/src/index.ts": {
|
||||
"exports": ["app"],
|
||||
"exports": [
|
||||
"app"
|
||||
],
|
||||
"imports": [
|
||||
"@hono/node-server",
|
||||
"@hono/node-server/serve-static",
|
||||
@@ -14,6 +16,7 @@
|
||||
"./routes/health.js",
|
||||
"./routes/me.js",
|
||||
"./routes/events.js",
|
||||
"./routes/lists.js",
|
||||
"./routes/sse.js",
|
||||
"./auth/middleware.js",
|
||||
"./auth/devBypass.js",
|
||||
@@ -24,7 +27,9 @@
|
||||
"notes": "Hono app factory + HTTP server; mounts routes, OIDC guard, static PWA assets. Broker workers started only when isMainModule()."
|
||||
},
|
||||
"apps/api/src/routes/events.ts": {
|
||||
"exports": ["eventsRouter"],
|
||||
"exports": [
|
||||
"eventsRouter"
|
||||
],
|
||||
"imports": [
|
||||
"node:crypto",
|
||||
"hono",
|
||||
@@ -42,8 +47,47 @@
|
||||
"type": "module",
|
||||
"notes": "GET /api/events (windowed), POST /api/events/create, PATCH /api/events/:uid/edit, DELETE /api/events/:uid, GET /api/events/sync-status, GET /api/events/writable-calendars. Writes enqueue to calendarOutbox only — never calls Fastmail directly."
|
||||
},
|
||||
"apps/api/src/routes/lists.ts": {
|
||||
"exports": [
|
||||
"listsRouter",
|
||||
"listItemsRouter"
|
||||
],
|
||||
"imports": [
|
||||
"hono",
|
||||
"@hono/zod-validator",
|
||||
"zod",
|
||||
"drizzle-orm",
|
||||
"../db/client.js",
|
||||
"../db/schema.js",
|
||||
"../auth/middleware.js",
|
||||
"../auth/user.js",
|
||||
"../auth/devBypass.js",
|
||||
"../lib/rank.js",
|
||||
"../lib/listEmitter.js"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "listsRouter: GET/POST /api/lists, PATCH/DELETE /api/lists/:id, POST/GET /api/lists/:id/items. listItemsRouter: PATCH/DELETE /api/list-items/:itemId. Owner-guard on isShared mutations (T-04-07/T-04-08). Auto-populates list_shares on isShared=true creation (D-01/D-02). publishListEvent fan-out after every mutation."
|
||||
},
|
||||
"apps/api/src/routes/sse.ts": {
|
||||
"exports": [
|
||||
"sseRouter"
|
||||
],
|
||||
"imports": [
|
||||
"hono",
|
||||
"hono/streaming",
|
||||
"../auth/middleware.js",
|
||||
"../auth/user.js",
|
||||
"../auth/devBypass.js",
|
||||
"../lib/listEmitter.js",
|
||||
"../lib/listAccess.js"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "GET /api/sse/heartbeat — 10s interval smoke-test. GET /api/sse/lists — scoped live-list fan-out (LIST-04, D-04); subscribes per-accessible-list via subscribeListEvents; 30s keepalive heartbeat."
|
||||
},
|
||||
"apps/api/src/routes/me.ts": {
|
||||
"exports": ["meRouter"],
|
||||
"exports": [
|
||||
"meRouter"
|
||||
],
|
||||
"imports": [
|
||||
"hono",
|
||||
"../auth/middleware.js",
|
||||
@@ -53,112 +97,288 @@
|
||||
"type": "module",
|
||||
"notes": "GET /api/me — returns { user: { id, displayName, color } }. Upserts user on first login."
|
||||
},
|
||||
"apps/api/src/routes/sse.ts": {
|
||||
"exports": ["sseRouter"],
|
||||
"imports": ["hono", "hono/streaming"],
|
||||
"type": "module",
|
||||
"notes": "GET /api/sse/heartbeat — server-sent events smoke-test; 10s interval heartbeat. Phase 4 SSE fan-out to be added."
|
||||
},
|
||||
"apps/api/src/routes/health.ts": {
|
||||
"exports": ["healthRouter"],
|
||||
"imports": ["hono", "../db/client.js", "drizzle-orm"],
|
||||
"exports": [
|
||||
"healthRouter"
|
||||
],
|
||||
"imports": [
|
||||
"hono",
|
||||
"../db/client.js",
|
||||
"drizzle-orm"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "GET /health — unauthenticated. Runs SELECT 1 against DB; returns { ok, db }."
|
||||
},
|
||||
"apps/api/src/db/schema.ts": {
|
||||
"exports": ["users", "memberCredentials", "calendars", "calendarEvents", "calendarOutbox"],
|
||||
"imports": ["drizzle-orm/mysql-core"],
|
||||
"exports": [
|
||||
"users",
|
||||
"memberCredentials",
|
||||
"calendars",
|
||||
"calendarEvents",
|
||||
"calendarOutbox",
|
||||
"lists",
|
||||
"listShares",
|
||||
"listItems"
|
||||
],
|
||||
"imports": [
|
||||
"drizzle-orm/mysql-core"
|
||||
],
|
||||
"type": "config",
|
||||
"notes": "Drizzle schema for all 5 MariaDB tables. calendarOutbox status enum: pending|done|failed|dead. calendarEvents dual-field dtstart (dtstartUtc / dtstartDate) for timed vs all-day."
|
||||
"notes": "Drizzle schema for all 8 MariaDB tables. Phase 4 adds lists, list_shares, list_items. list_items.rank uses varcharBin (COLLATE utf8mb4_bin) for fractional-indexing sort correctness. calendarOutbox status enum: pending|done|failed|dead."
|
||||
},
|
||||
"apps/api/src/db/client.ts": {
|
||||
"exports": ["db"],
|
||||
"imports": ["drizzle-orm/mysql2", "mysql2/promise"],
|
||||
"exports": [
|
||||
"db"
|
||||
],
|
||||
"imports": [
|
||||
"drizzle-orm/mysql2",
|
||||
"mysql2/promise"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Drizzle client bound to mysql2 pool. Reads DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME from env."
|
||||
},
|
||||
"apps/api/src/lib/listEmitter.ts": {
|
||||
"exports": [
|
||||
"publishListEvent",
|
||||
"subscribeListEvents",
|
||||
"ListEvent"
|
||||
],
|
||||
"imports": [
|
||||
"node:events"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "In-process singleton EventEmitter for list change fan-out (D-18). Per-list channels keyed as list:${listId}. publishListEvent broadcasts; subscribeListEvents returns an unsubscribe fn. Max 200 listeners (T-04-04). Redis swap seam: abstraction boundary is inside this module."
|
||||
},
|
||||
"apps/api/src/lib/listAccess.ts": {
|
||||
"exports": [
|
||||
"getAccessibleListIds"
|
||||
],
|
||||
"imports": [
|
||||
"drizzle-orm",
|
||||
"../db/client.js",
|
||||
"../db/schema.js"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "getAccessibleListIds(userId): returns deduped list IDs the user owns OR has a list_shares row for. Gate used by SSE endpoint to scope subscriptions (D-04, T-04-02, T-04-03)."
|
||||
},
|
||||
"apps/api/src/lib/rank.ts": {
|
||||
"exports": [
|
||||
"rankForAppend",
|
||||
"rankBetween"
|
||||
],
|
||||
"imports": [
|
||||
"fractional-indexing"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Pure helpers wrapping fractional-indexing generateKeyBetween. rankForAppend(lastRank) → rank after last active item. rankBetween(prev, next) → rank between two items. No DB access."
|
||||
},
|
||||
"apps/api/src/auth/middleware.ts": {
|
||||
"exports": ["oidcAuthMiddleware", "processOAuthCallback", "getAuth"],
|
||||
"imports": ["@hono/oidc-auth", "hono"],
|
||||
"exports": [
|
||||
"oidcAuthMiddleware",
|
||||
"processOAuthCallback",
|
||||
"getAuth"
|
||||
],
|
||||
"imports": [
|
||||
"@hono/oidc-auth",
|
||||
"hono"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "OIDC middleware for Hono. Reads OIDC_AUTH_EXTERNAL_URL (mandatory behind Pangolin), OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_ISSUER from env."
|
||||
},
|
||||
"apps/api/src/auth/devBypass.ts": {
|
||||
"exports": ["devAuthBypass", "DEV_USER"],
|
||||
"imports": ["hono"],
|
||||
"exports": [
|
||||
"devAuthBypass",
|
||||
"DEV_USER"
|
||||
],
|
||||
"imports": [
|
||||
"hono"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Dev-only auth bypass middleware. Active only when DEV_AUTH_BYPASS=true AND NODE_ENV!=production. Augments Hono ContextVariableMap with 'user' key."
|
||||
},
|
||||
"apps/api/src/auth/user.ts": {
|
||||
"exports": ["upsertUser", "deriveDisplayName"],
|
||||
"imports": ["../db/client.js", "../db/schema.js", "drizzle-orm"],
|
||||
"exports": [
|
||||
"upsertUser",
|
||||
"deriveDisplayName"
|
||||
],
|
||||
"imports": [
|
||||
"../db/client.js",
|
||||
"../db/schema.js",
|
||||
"drizzle-orm"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "User upsert keyed on oidc_iss + oidc_sub. deriveDisplayName: name → preferred_username → email → sub."
|
||||
},
|
||||
"apps/api/src/broker/poller.ts": {
|
||||
"exports": ["startBrokerPoller"],
|
||||
"imports": ["node-cron", "./sync.js", "../db/client.js", "../db/schema.js"],
|
||||
"exports": [
|
||||
"startBrokerPoller"
|
||||
],
|
||||
"imports": [
|
||||
"node-cron",
|
||||
"./sync.js",
|
||||
"../db/client.js",
|
||||
"../db/schema.js"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "5-minute cron that polls Fastmail CalDAV for each member credential. ctag change-detection (D-13)."
|
||||
},
|
||||
"apps/api/src/broker/outboxWorker.ts": {
|
||||
"exports": ["startOutboxWorker"],
|
||||
"imports": ["node-cron", "./write.js", "../db/client.js", "../db/schema.js"],
|
||||
"exports": [
|
||||
"startOutboxWorker"
|
||||
],
|
||||
"imports": [
|
||||
"node-cron",
|
||||
"./write.js",
|
||||
"../db/client.js",
|
||||
"../db/schema.js"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "15-second cron that drains pending calendarOutbox rows. Dispatches create/update/delete to Fastmail. Status machine: pending → done|failed|dead."
|
||||
},
|
||||
"apps/api/src/broker/sync.ts": {
|
||||
"exports": ["syncCalendarsForCredential"],
|
||||
"imports": ["./client.js", "./expand.js", "../db/client.js", "../db/schema.js", "ical.js"],
|
||||
"exports": [
|
||||
"syncCalendarsForCredential"
|
||||
],
|
||||
"imports": [
|
||||
"./client.js",
|
||||
"./expand.js",
|
||||
"../db/client.js",
|
||||
"../db/schema.js",
|
||||
"ical.js"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "CalDAV PROPFIND + REPORT → upserts calendars and calendarEvents rows."
|
||||
},
|
||||
"apps/api/src/broker/write.ts": {
|
||||
"exports": ["executeOutboxRow"],
|
||||
"imports": ["./client.js", "./vevent.js", "../db/client.js", "../db/schema.js"],
|
||||
"exports": [
|
||||
"executeOutboxRow"
|
||||
],
|
||||
"imports": [
|
||||
"./client.js",
|
||||
"./vevent.js",
|
||||
"../db/client.js",
|
||||
"../db/schema.js"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Executes a single outbox row: builds VEVENT, calls tsdav PUT/DELETE with If-Match etag."
|
||||
},
|
||||
"apps/api/src/broker/client.ts": {
|
||||
"exports": ["createFastmailClient"],
|
||||
"imports": ["tsdav", "./crypto.js", "../db/client.js", "../db/schema.js"],
|
||||
"exports": [
|
||||
"createFastmailClient"
|
||||
],
|
||||
"imports": [
|
||||
"tsdav",
|
||||
"./crypto.js",
|
||||
"../db/client.js",
|
||||
"../db/schema.js"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Creates a tsdav DAVClient per member credential (decrypted AES-256-GCM)."
|
||||
},
|
||||
"apps/api/src/broker/crypto.ts": {
|
||||
"exports": ["encrypt", "decrypt"],
|
||||
"imports": ["node:crypto"],
|
||||
"exports": [
|
||||
"encrypt",
|
||||
"decrypt"
|
||||
],
|
||||
"imports": [
|
||||
"node:crypto"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "AES-256-GCM encrypt/decrypt for Fastmail app passwords stored in memberCredentials."
|
||||
},
|
||||
"apps/api/src/broker/expand.ts": {
|
||||
"exports": ["expandOccurrences"],
|
||||
"imports": ["ical.js", "temporal-polyfill"],
|
||||
"exports": [
|
||||
"expandOccurrences"
|
||||
],
|
||||
"imports": [
|
||||
"ical.js",
|
||||
"temporal-polyfill"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Expands raw VCALENDAR string into CalendarOccurrence[] for a [start, end) window. Handles RRULE, EXDATE, DST via ical.js + Temporal."
|
||||
},
|
||||
"apps/api/src/broker/vevent.ts": {
|
||||
"exports": ["buildVevent", "extractRruleString"],
|
||||
"imports": ["ical.js"],
|
||||
"exports": [
|
||||
"buildVevent",
|
||||
"extractRruleString"
|
||||
],
|
||||
"imports": [
|
||||
"ical.js"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Builds VCALENDAR/VEVENT strings from CreateEventPayload. extractRruleString preserves RRULE on calendar-move edits."
|
||||
},
|
||||
"apps/pwa/src/main.tsx": {
|
||||
"exports": [],
|
||||
"imports": ["react-dom/client", "./App.tsx"],
|
||||
"imports": [
|
||||
"react-dom/client",
|
||||
"./App.tsx"
|
||||
],
|
||||
"type": "entry-point",
|
||||
"notes": "React root mount."
|
||||
},
|
||||
"apps/pwa/src/App.tsx": {
|
||||
"exports": ["default"],
|
||||
"exports": [
|
||||
"default"
|
||||
],
|
||||
"imports": [
|
||||
"react-router",
|
||||
"./components/CalendarShell.js",
|
||||
"./routes/ListsIndex.js",
|
||||
"./routes/ListDetail.js",
|
||||
"./components/BottomTabBar.js"
|
||||
],
|
||||
"type": "entry-point",
|
||||
"notes": "BrowserRouter shell. Routes: / → /calendar redirect, /calendar → CalendarShell, /lists → ListsIndex, /lists/:listId → ListDetail. BottomTabBar rendered as persistent sibling of Routes."
|
||||
},
|
||||
"apps/pwa/src/routes/ListsIndex.tsx": {
|
||||
"exports": [
|
||||
"ListsIndex"
|
||||
],
|
||||
"imports": [
|
||||
"react",
|
||||
"@tanstack/react-query",
|
||||
"./components/CalendarShell.tsx",
|
||||
"./components/InstallPrompt.tsx"
|
||||
"../api/listsClient.js",
|
||||
"../components/"
|
||||
],
|
||||
"type": "entry-point",
|
||||
"notes": "Root component. Sets up QueryClient, renders CalendarShell + InstallPrompt."
|
||||
"type": "module",
|
||||
"notes": "Lists overview route (/lists). TanStack Query ['lists'] → fetchLists. Renders ListCard per list, ListsEmptyState when empty, CreateListSheet for new list, ListDeleteDialog for delete confirmation. Optimistic delete with rollback."
|
||||
},
|
||||
"apps/pwa/src/routes/ListDetail.tsx": {
|
||||
"exports": [
|
||||
"ListDetail"
|
||||
],
|
||||
"imports": [
|
||||
"react",
|
||||
"@tanstack/react-query",
|
||||
"fractional-indexing",
|
||||
"@dnd-kit/core",
|
||||
"@dnd-kit/sortable",
|
||||
"../api/listsClient.js",
|
||||
"../hooks/useListSSE.js",
|
||||
"../components/"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Single list view (/lists/:listId). Splits items into active (!checked, rank ASC) and completed sections. dnd-kit drag-to-reorder with PATCH { position }. useListSSE for live sync (D-10/D-11). 30s polling fallback (D-12). Optimistic check/uncheck + add + delete."
|
||||
},
|
||||
"apps/pwa/src/api/listsClient.ts": {
|
||||
"exports": [
|
||||
"fetchLists",
|
||||
"createList",
|
||||
"patchList",
|
||||
"deleteList",
|
||||
"fetchListItems",
|
||||
"addItem",
|
||||
"patchListItem",
|
||||
"deleteItem",
|
||||
"List",
|
||||
"ListItem",
|
||||
"ListsResponse",
|
||||
"ListItemsResponse"
|
||||
],
|
||||
"imports": [],
|
||||
"type": "module",
|
||||
"notes": "Typed fetch wrappers for all lists API endpoints. credentials: 'include' for OIDC session cookie. Same opaqueredirect pattern as client.ts."
|
||||
},
|
||||
"apps/pwa/src/api/client.ts": {
|
||||
"exports": [
|
||||
@@ -172,10 +392,23 @@
|
||||
],
|
||||
"imports": [],
|
||||
"type": "module",
|
||||
"notes": "Typed fetch wrappers for all API endpoints. Uses credentials: 'include' + redirect: 'manual' for OIDC opaqueredirect detection."
|
||||
"notes": "Typed fetch wrappers for all calendar API endpoints. Uses credentials: 'include' + redirect: 'manual' for OIDC opaqueredirect detection."
|
||||
},
|
||||
"apps/pwa/src/hooks/useListSSE.ts": {
|
||||
"exports": [
|
||||
"useListSSE"
|
||||
],
|
||||
"imports": [
|
||||
"react",
|
||||
"@tanstack/react-query"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Bounded-backoff EventSource hook for /api/sse/lists (D-11). Backoff: 250ms→500ms→1s→2s→4s→cap 8s; stops after MAX_ATTEMPTS. withCredentials: true (T-04-01). On open: invalidates ['list', listId] for full refetch (D-10). On event: invalidates relevant query. Polling fallback (D-12) lives in ListDetail."
|
||||
},
|
||||
"apps/pwa/src/components/CalendarShell.tsx": {
|
||||
"exports": ["CalendarShell"],
|
||||
"exports": [
|
||||
"CalendarShell"
|
||||
],
|
||||
"imports": [
|
||||
"react",
|
||||
"@tanstack/react-query",
|
||||
@@ -195,62 +428,79 @@
|
||||
"type": "module",
|
||||
"notes": "Top-level calendar view. Orchestrates TanStack Query fetches, Schedule-X calendar, event create/edit/delete flows, sync toasts."
|
||||
},
|
||||
"apps/pwa/src/components/EventForm.tsx": {
|
||||
"exports": ["EventForm"],
|
||||
"imports": ["react", "../api/client.ts"],
|
||||
"apps/pwa/src/components/BottomTabBar.tsx": {
|
||||
"exports": [
|
||||
"BottomTabBar"
|
||||
],
|
||||
"imports": [
|
||||
"react",
|
||||
"react-router",
|
||||
"../store/listsStore.js"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Create/edit event form. Posts to createEvent/updateEvent. Supports recurrence presets, allDay toggle, calendar picker."
|
||||
"notes": "Phone-only bottom navigation tab bar. Tabs: Calendar (/calendar) and Lists (/lists). Persistent across route changes (rendered outside <Routes>). Visibility controlled by CSS at ≥768px."
|
||||
},
|
||||
"apps/pwa/src/components/EventDetailPopover.tsx": {
|
||||
"exports": ["EventDetailPopover"],
|
||||
"imports": ["react", "../api/client.ts"],
|
||||
"apps/pwa/src/store/listsStore.ts": {
|
||||
"exports": [
|
||||
"useListsStore"
|
||||
],
|
||||
"imports": [
|
||||
"zustand"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Popover shown on event click. Shows title/time/location/description, edit/delete actions."
|
||||
},
|
||||
"apps/pwa/src/components/SyncStateToast.tsx": {
|
||||
"exports": ["SyncStateToast"],
|
||||
"imports": ["react", "@tanstack/react-query", "../api/client.ts"],
|
||||
"type": "module",
|
||||
"notes": "Polls /api/events/sync-status to show pending → done | failed toast for async CalDAV writes."
|
||||
},
|
||||
"apps/pwa/src/components/ColorLegend.tsx": {
|
||||
"exports": ["ColorLegend"],
|
||||
"imports": ["react", "../store/calendarStore.ts"],
|
||||
"type": "module",
|
||||
"notes": "Displays per-member color swatches from calendarStore."
|
||||
"notes": "Zustand UI-only state for lists surface: activeTab, createListSheetOpen. No server data. Follows calendarStore.ts pattern — no persist, no immer."
|
||||
},
|
||||
"apps/pwa/src/store/calendarStore.ts": {
|
||||
"exports": ["useCalendarStore"],
|
||||
"imports": ["zustand"],
|
||||
"exports": [
|
||||
"useCalendarStore"
|
||||
],
|
||||
"imports": [
|
||||
"zustand"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Zustand store for UI-only state: selectedDateRange, calendarId→color map, drawer open/closed. No server state."
|
||||
},
|
||||
"apps/pwa/src/lib/calendarConfig.ts": {
|
||||
"exports": ["buildCalendarConfig"],
|
||||
"exports": [
|
||||
"buildCalendarConfig"
|
||||
],
|
||||
"imports": [],
|
||||
"type": "module",
|
||||
"notes": "Builds Schedule-X calendar config from member color map and MeUser."
|
||||
},
|
||||
"apps/pwa/src/lib/hydrateEvents.ts": {
|
||||
"exports": ["hydrateEvents"],
|
||||
"imports": ["../api/client.ts"],
|
||||
"exports": [
|
||||
"hydrateEvents"
|
||||
],
|
||||
"imports": [
|
||||
"../api/client.ts"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Maps CalendarOccurrence[] → Schedule-X event objects. Routes by isShared/ownerUserId (never calendarId)."
|
||||
},
|
||||
"apps/pwa/src/lib/eventDateTime.ts": {
|
||||
"exports": ["formatEventDateTime", "toScheduleXDateTime"],
|
||||
"imports": ["temporal-polyfill"],
|
||||
"exports": [
|
||||
"formatEventDateTime",
|
||||
"toScheduleXDateTime"
|
||||
],
|
||||
"imports": [
|
||||
"temporal-polyfill"
|
||||
],
|
||||
"type": "module",
|
||||
"notes": "Date/time formatting helpers for Schedule-X event start/end fields."
|
||||
},
|
||||
"apps/pwa/src/lib/loginRedirect.ts": {
|
||||
"exports": ["maybeRedirectToLogin"],
|
||||
"exports": [
|
||||
"maybeRedirectToLogin"
|
||||
],
|
||||
"imports": [],
|
||||
"type": "module",
|
||||
"notes": "Top-level navigation to /api/login when OIDC 302/opaqueredirect detected. CORS-bypass strategy."
|
||||
},
|
||||
"apps/pwa/src/lib/colorUtils.ts": {
|
||||
"exports": ["assignMemberColors"],
|
||||
"exports": [
|
||||
"assignMemberColors"
|
||||
],
|
||||
"imports": [],
|
||||
"type": "module",
|
||||
"notes": "Assigns hex colors from palette to members deterministically."
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
{
|
||||
"_meta": {
|
||||
"updated_at": "2026-06-09T00:00:00Z",
|
||||
"commit": "01f7456b81dd55d477d0bd7530df818f61a873c2",
|
||||
"version": 1
|
||||
"updated_at": "2026-06-09T18:56:37.176Z",
|
||||
"commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f",
|
||||
"version": 3
|
||||
},
|
||||
"languages": ["TypeScript", "SQL"],
|
||||
"frameworks": ["Hono 4.12.23", "React 19", "Drizzle ORM 0.45.2"],
|
||||
"languages": [
|
||||
"TypeScript",
|
||||
"SQL"
|
||||
],
|
||||
"frameworks": [
|
||||
"Hono 4.12.23",
|
||||
"React 19",
|
||||
"Drizzle ORM 0.45.2"
|
||||
],
|
||||
"tools": [
|
||||
"Vite 8.0.16",
|
||||
"vite-plugin-pwa 1.3.0",
|
||||
@@ -19,12 +26,15 @@
|
||||
"package_manager": "pnpm 11.5.1",
|
||||
"runtime": "Node.js 22 LTS",
|
||||
"database": "MariaDB via mysql2 3.22.4",
|
||||
"cache": "Redis (ioredis — planned for Phase 4 list sync; not yet wired)",
|
||||
"cache": "Redis (ioredis — available in infra; not yet wired; in-memory EventEmitter used for Phase 4 list SSE fan-out)",
|
||||
"auth": "Authelia OIDC — authorization_code + PKCE via @hono/oidc-auth 1.8.3",
|
||||
"calendar_backend": "Fastmail CalDAV via tsdav 2.2.2 + ical.js 2.2.1",
|
||||
"calendar_ui": "@schedule-x/calendar 4.6.0",
|
||||
"server_state": "@tanstack/react-query 5.101.0",
|
||||
"client_state": "zustand 5.0.14",
|
||||
"routing": "react-router 7.17.0 (BrowserRouter, /calendar + /lists + /lists/:listId)",
|
||||
"drag_and_drop": "@dnd-kit/core 6.3.1 + @dnd-kit/sortable 10.0.0 (list item reorder)",
|
||||
"fractional_rank": "fractional-indexing 3.2.0 (list item ordering — utf8mb4_bin collation in DB)",
|
||||
"content_formats": [
|
||||
"TypeScript (source)",
|
||||
"SQL (Drizzle migrations)",
|
||||
|
||||
Reference in New Issue
Block a user