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
@@ -15,28 +15,28 @@ autonomous: true
requirements: [AUTH-ENTRY-01]
must_haves:
truths:
- "An unauthenticated top-level browser nav to /api/login completes the OIDC login and lands back on the app, logged in"
- 'An unauthenticated top-level browser nav to /api/login completes the OIDC login and lands back on the app, logged in'
- "An unauthenticated PWA load redirects the browser to /api/login instead of showing a dead-end 'Sign-in required'"
- "A genuine /api/me failure does NOT cause an infinite login redirect loop (one-shot guard)"
- 'A genuine /api/me failure does NOT cause an infinite login redirect loop (one-shot guard)'
artifacts:
- path: apps/api/src/index.ts
provides: "Guarded GET /api/login route that redirects to /"
contains: "/api/login"
provides: 'Guarded GET /api/login route that redirects to /'
contains: '/api/login'
- path: apps/pwa/src/lib/loginRedirect.ts
provides: "One-shot sessionStorage-guarded login-redirect helper"
provides: 'One-shot sessionStorage-guarded login-redirect helper'
- path: apps/api/tests/routes/login.test.ts
provides: "Backend test: /api/login redirects to /"
provides: 'Backend test: /api/login redirects to /'
- path: apps/pwa/src/lib/loginRedirect.test.ts
provides: "Frontend test: unauth redirect + one-shot loop guard"
provides: 'Frontend test: unauth redirect + one-shot loop guard'
key_links:
- from: apps/pwa/src/components/CalendarShell.tsx
to: apps/pwa/src/lib/loginRedirect.ts
via: "useEffect on meQuery.isError calls maybeRedirectToLogin()"
pattern: "maybeRedirectToLogin"
via: 'useEffect on meQuery.isError calls maybeRedirectToLogin()'
pattern: 'maybeRedirectToLogin'
- from: apps/pwa/src/components/CalendarShell.tsx
to: apps/pwa/src/lib/loginRedirect.ts
via: "meQuery.isSuccess clears the one-shot flag"
pattern: "clearLoginRedirect"
via: 'meQuery.isSuccess clears the one-shot flag'
pattern: 'clearLoginRedirect'
---
<objective>
@@ -45,6 +45,7 @@ dead-end "Sign-in required" message with no way to log in, because the SPA reach
only via `fetch()`, and the OIDC guard's 302 to Authelia is CORS-blocked for XHR.
Two coordinated changes:
1. Backend: add a guarded `GET /api/login` route that redirects to `/`. A TOP-LEVEL browser
navigation (not fetch) to this guarded route triggers the full OIDC login flow and returns
to the app cleanly — no CORS problem (Authelia 302 is followed at the document level).
@@ -102,6 +103,7 @@ Output: Backend login route + tests; frontend redirect helper + wiring + tests.
reaches the handler and returns 302 → '/' (passthrough lets it through, mirroring me.test's
OIDC-path block).
Use `res.headers.get('location')` to assert the redirect target.
</action>
<verify>
<automated>cd apps/api && pnpm exec vitest run tests/routes/login.test.ts && pnpm exec tsc --noEmit</automated>
@@ -143,6 +145,7 @@ Output: Backend login route + tests; frontend redirect helper + wiring + tests.
- maybeRedirectToLogin() sets href to '/api/login', sets the flag, returns true on first call.
- A second maybeRedirectToLogin() call does NOT change href again and returns false (one-shot).
- clearLoginRedirect() removes the flag; a subsequent maybeRedirectToLogin() redirects again.
</action>
<verify>
<automated>cd apps/pwa && pnpm exec vitest run src/lib/loginRedirect.test.ts && pnpm exec tsc --noEmit</automated>
@@ -173,6 +176,7 @@ Output: Backend login route + tests; frontend redirect helper + wiring + tests.
success path now calls clearLoginRedirect, ensure the helper's guards keep it a no-op (handled in
Task 2). If the existing test mocks meQuery.isError anywhere, confirm it still renders without an
unhandled navigation (the helper redirect is guarded and href assignment is inert under jsdom).
</action>
<verify>
<automated>cd apps/pwa && pnpm exec vitest run src/components/CalendarShell.test.tsx && pnpm exec tsc --noEmit</automated>
@@ -189,12 +193,13 @@ Output: Backend login route + tests; frontend redirect helper + wiring + tests.
</verification>
<success_criteria>
- `GET /api/login` is mounted behind the OIDC guard and redirects authenticated requests to '/'.
- Unauthenticated PWA load triggers a single full-page navigation to '/api/login' (no CORS-blocked XHR, no infinite loop).
- A genuine backend error after one redirect falls through to "Sign-in required" instead of looping.
- The stale comment in client.ts no longer claims fetch follows the Authelia 302 automatically.
- All unit tests pass; both apps type-check clean.
</success_criteria>
</success_criteria>
<output>
Create `.planning/quick/260606-tv8-fix-missing-sign-in-redirect-in-the-pwa-/260606-tv8-SUMMARY.md` when done.
@@ -1,12 +1,17 @@
---
phase: quick-260606-tv8
plan: "01"
plan: '01'
subsystem: auth-entry
tags: [auth, pwa, oidc, redirect, one-shot-guard]
dependency_graph:
requires: []
provides: [AUTH-ENTRY-01]
affects: [apps/api/src/index.ts, apps/pwa/src/lib/loginRedirect.ts, apps/pwa/src/components/CalendarShell.tsx]
affects:
[
apps/api/src/index.ts,
apps/pwa/src/lib/loginRedirect.ts,
apps/pwa/src/components/CalendarShell.tsx,
]
tech_stack:
added: []
patterns:
@@ -22,12 +27,12 @@ key_files:
- apps/pwa/src/api/client.ts
- apps/pwa/src/components/CalendarShell.tsx
decisions:
- "GET /api/login placed after OIDC guard with bare app.get (not app.route) — single redirect, no router needed"
- "sessionStorage chosen over localStorage for the one-shot flag — flag is per-tab and clears on tab close, preventing stale block across sessions"
- "useEffect dependencies are meQuery.isError and meQuery.isSuccess (not the query object) — stable boolean flags, no unnecessary re-invocations"
- 'GET /api/login placed after OIDC guard with bare app.get (not app.route) — single redirect, no router needed'
- 'sessionStorage chosen over localStorage for the one-shot flag — flag is per-tab and clears on tab close, preventing stale block across sessions'
- 'useEffect dependencies are meQuery.isError and meQuery.isSuccess (not the query object) — stable boolean flags, no unnecessary re-invocations'
metrics:
duration: "5 minutes"
completed_date: "2026-06-07"
duration: '5 minutes'
completed_date: '2026-06-07'
tasks_completed: 3
files_changed: 6
---
@@ -41,6 +46,7 @@ metrics:
### Task 1: GET /api/login backend route + tests (commit 237ec49)
Added `app.get('/api/login', (c) => c.redirect('/'))` in `apps/api/src/index.ts`, placed after the OIDC guard block. When an unauthenticated user navigates to `/api/login` as a top-level browser request:
1. The guard intercepts and 302s to Authelia.
2. After login, Authelia POSTs to `/callback`; the middleware sets a `continue` cookie pointing to `/api/login`.
3. The browser follows the cookie back to `/api/login` (now authenticated); the handler 302s to `/` and the SPA boots.
@@ -52,6 +58,7 @@ Created `apps/api/tests/routes/login.test.ts` (3 tests, mirrors `me.test.ts` pat
### Task 2: One-shot login-redirect helper + tests (commit 6dc9ccd)
Created `apps/pwa/src/lib/loginRedirect.ts` exporting:
- `maybeRedirectToLogin()` — sets `sessionStorage['familysync.loginRedirectAttempted']` and assigns `window.location.href = '/api/login'` on first call; returns `false` on subsequent calls (loop guard). Guarded against `window`/`sessionStorage` unavailability.
- `clearLoginRedirect()` — removes the flag, allowing future re-auth redirects.
@@ -62,6 +69,7 @@ Updated `apps/pwa/src/api/client.ts` to remove the false claim that "the browser
### Task 3: CalendarShell meQuery wiring (commit c2e0ab1)
Added two `useEffect` hooks in `CalendarShell.tsx`:
- On `meQuery.isError`: calls `maybeRedirectToLogin()`. If first attempt, page navigates away. If already attempted, falls through to the existing "Sign-in required" branch.
- On `meQuery.isSuccess`: calls `clearLoginRedirect()` so a later session expiry can redirect again.
@@ -88,6 +96,7 @@ None — no new network endpoints (the `/api/login` route is behind the existing
## Self-Check: PASSED
Files exist:
- apps/api/src/index.ts (modified)
- apps/api/tests/routes/login.test.ts (created)
- apps/pwa/src/lib/loginRedirect.ts (created)
@@ -96,6 +105,7 @@ Files exist:
- apps/pwa/src/components/CalendarShell.tsx (modified)
Commits:
- 237ec49: feat(260606-tv8-01): add guarded GET /api/login route + tests
- 6dc9ccd: feat(260606-tv8-01): add one-shot login-redirect helper + tests; fix client.ts comment
- c2e0ab1: feat(260606-tv8-01): wire login redirect into CalendarShell meQuery handling
@@ -12,26 +12,26 @@ autonomous: true
requirements: []
must_haves:
truths:
- "DELETE /api/events/:uid returns 202 (not 503) for an owned event — delete dialog closes."
- "PATCH /api/events/:uid/edit returns 202 (not 503) for an owned event."
- "A regression test exercises the REAL Drizzle query builder for the edit + delete lookups and fails when the calendars join is absent."
- "GET /api/me derives a non-blank displayName from OIDC name/preferred_username/email claims, falling back sensibly."
- "GET /api/events returns only events whose calendar is owned by the current user OR is shared."
- 'DELETE /api/events/:uid returns 202 (not 503) for an owned event — delete dialog closes.'
- 'PATCH /api/events/:uid/edit returns 202 (not 503) for an owned event.'
- 'A regression test exercises the REAL Drizzle query builder for the edit + delete lookups and fails when the calendars join is absent.'
- 'GET /api/me derives a non-blank displayName from OIDC name/preferred_username/email claims, falling back sensibly.'
- 'GET /api/events returns only events whose calendar is owned by the current user OR is shared.'
artifacts:
- path: "apps/api/src/routes/events.ts"
provides: "Joined edit/delete lookups + user/shared-scoped GET filter"
- path: "apps/api/src/routes/me.ts"
provides: "Robust displayName claim derivation"
- path: "apps/api/tests/routes/events.test.ts"
provides: "Real-query-builder regression test for the missing-join class of bug"
- path: 'apps/api/src/routes/events.ts'
provides: 'Joined edit/delete lookups + user/shared-scoped GET filter'
- path: 'apps/api/src/routes/me.ts'
provides: 'Robust displayName claim derivation'
- path: 'apps/api/tests/routes/events.test.ts'
provides: 'Real-query-builder regression test for the missing-join class of bug'
key_links:
- from: "events.ts PATCH /:uid/edit + DELETE /:uid lookups"
to: "calendars table"
via: "innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))"
- from: 'events.ts PATCH /:uid/edit + DELETE /:uid lookups'
to: 'calendars table'
via: 'innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))'
pattern: "innerJoin\\(calendars"
- from: "events.ts GET /"
to: "current user ownership"
via: "WHERE calendars.userId = currentUserId OR calendars.isShared"
- from: 'events.ts GET /'
to: 'current user ownership'
via: 'WHERE calendars.userId = currentUserId OR calendars.isShared'
pattern: "calendars\\.(userId|isShared)"
---
@@ -59,10 +59,15 @@ Output: Corrected `events.ts`, `me.ts`, and a real-query-builder regression test
@.planning/phases/03-event-write-back-pwa-install/.continue-here.md
# Reference idioms already in events.ts:
# - Working join: GET / at ~line 142-183 uses
# .from(calendarEvents).innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
# - Working ownership predicate: /writable-calendars at ~line 501-509 uses
# .where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)))
# - Working join: GET / at ~line 142-183 uses
# .from(calendarEvents).innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
# - Working ownership predicate: /writable-calendars at ~line 501-509 uses
# .where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)))
</context>
<constraints>
@@ -102,6 +107,7 @@ Output: Corrected `events.ts`, `me.ts`, and a real-query-builder regression test
Then update the PATCH `/:uid/edit` and DELETE `/:uid` describe-block `beforeEach` mocks so the mocked select chain routes `.from(calendarEvents).innerJoin(...).where(...)` to the seeded `mockDbRows` (reuse the GET-suite `mockInnerJoin1Fn``mockWhereFn` wiring already defined at the top of the file), and keep the secondary `.from(calendars).where(...)` isShared-fallback chain working.
Prefer the lightest approach that catches the class of bug: a `.toSQL()` string assertion on the real query builder. Do NOT introduce a new full DB test harness (no SQLite container, no live MariaDB) — investigate the existing mock structure first and reuse it.
</action>
<verify>
<automated>cd apps/api && npm test -- routes/events.test.ts</automated>
@@ -120,6 +126,7 @@ Output: Corrected `events.ts`, `me.ts`, and a real-query-builder regression test
Derive a display name robustly from the OIDC claims object returned by `getAuth(c)`, preferring in order: `name`, then `preferred_username`, then `email`, then a sensible fallback derived from `sub` (e.g. `'Member ' + sub` or the local-part if email exists). Each candidate must be read defensively (`typeof claim === 'string' && claim.trim() !== ''`) since claims may be missing or empty. Pass the resolved display name as the third argument to `upsertUser(iss, sub, displayName)`.
Keep `iss`/`sub` extraction unchanged (identity stays keyed on iss+sub per D-10). Do not change the dev-bypass branch. Do not alter `upsertUser`'s signature or `user.ts`. Add a brief comment noting the claim-preference order and that Authelia-side claim emission is an operator concern (out of scope here).
</action>
<verify>
<automated>cd apps/api && npm run typecheck && npm test -- routes/me.test.ts</automated>
@@ -136,6 +143,7 @@ Output: Corrected `events.ts`, `me.ts`, and a real-query-builder regression test
In `apps/api/src/routes/events.ts`, the `GET /` handler (~line 118) currently returns ALL users' events with no ownership predicate. Resolve the current user id at the top of the handler using the existing `resolveUserId(c)` helper (already defined ~line 59 and used by the write endpoints); return `c.json({ error: 'Unauthorized' }, 401)` if it is null — match the write-endpoint pattern exactly.
Add an ownership predicate to the existing `.where(...)` so only events on calendars owned by the current user OR shared calendars are returned. Combine the new ownership filter with the existing date-window `or(...)` block using `and(...)`, i.e. effectively `and(<existing window or-block>, or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)))`. Mirror the authoritative ownership idiom already used by `/writable-calendars` (~line 509). Do not change the window-span guard, the join chain, or the expansion logic.
</action>
<verify>
<automated>cd apps/api && npm run typecheck && npm test -- routes/events.test.ts</automated>
@@ -154,18 +162,20 @@ Output: Corrected `events.ts`, `me.ts`, and a real-query-builder regression test
</verification>
<success_criteria>
- BUG 1: Both edit and delete lookups innerJoin calendars; a real-query-builder regression test guards against the missing-join regression.
- BUG 2: me.ts derives a non-blank displayName from OIDC claims (name → preferred_username → email → fallback).
- BUG 3: GET /api/events filters to current-user-owned OR shared calendars.
- Full test suite + typecheck green.
- Each fix landed as a separate atomic commit.
</success_criteria>
</success_criteria>
<manual_follow_up>
NOT a plan task — operator action after merge:
- Re-test in a real browser through the tunnel: delete an event (dialog should close, event disappears), edit an event, and confirm the calendar legend shows the member's name (BUG 2 may additionally require Authelia to emit the `name`/`email` claim — operator's call; the code now reads whatever claims are present).
- playwright-cli is intentionally NOT used (broken in this WSL2 env).
</manual_follow_up>
</manual_follow_up>
<output>
Create `.planning/quick/260607-l6l-fix-phase-03-write-path-correctness-bugs/260607-l6l-SUMMARY.md` when done.
@@ -6,9 +6,9 @@ tags: [bug-fix, events, auth, displayName, calendar-ownership, drizzle, tdd]
dependency_graph:
requires: []
provides:
- "PATCH /:uid/edit and DELETE /:uid event lookups work (no 503)"
- "Robust displayName derivation from OIDC claims in me.ts + resolveUserId"
- "GET /api/events scoped to current user + shared calendars"
- 'PATCH /:uid/edit and DELETE /:uid event lookups work (no 503)'
- 'Robust displayName derivation from OIDC claims in me.ts + resolveUserId'
- 'GET /api/events scoped to current user + shared calendars'
affects:
- apps/api/src/routes/events.ts
- apps/api/src/routes/me.ts
@@ -17,9 +17,9 @@ dependency_graph:
tech_stack:
added: []
patterns:
- "Drizzle innerJoin for cross-table selects (events → calendars)"
- "toSQL() on real drizzle instance (no DB) as regression guard"
- "OIDC claim preference chain: name → preferred_username → email → sub fallback"
- 'Drizzle innerJoin for cross-table selects (events → calendars)'
- 'toSQL() on real drizzle instance (no DB) as regression guard'
- 'OIDC claim preference chain: name → preferred_username → email → sub fallback'
key_files:
created: []
modified:
@@ -28,12 +28,12 @@ key_files:
- apps/api/src/auth/user.ts
- apps/api/tests/routes/events.test.ts
decisions:
- "Updated user.ts to fix blank displayName for existing rows (update on re-upsert when displayName was null)"
- "Used and(ownership_predicate, date_window_or) structure for GET / WHERE clause"
- "Symlinked worktree node_modules to main repo for test execution (runtime-only)"
- 'Updated user.ts to fix blank displayName for existing rows (update on re-upsert when displayName was null)'
- 'Used and(ownership_predicate, date_window_or) structure for GET / WHERE clause'
- 'Symlinked worktree node_modules to main repo for test execution (runtime-only)'
metrics:
duration: "~25 minutes"
completed: "2026-06-07"
duration: '~25 minutes'
completed: '2026-06-07'
tasks_completed: 3
files_modified: 4
---
@@ -44,11 +44,11 @@ metrics:
## Tasks Completed
| # | Name | Commit | Files |
|---|------|--------|-------|
| 1 | Add missing innerJoin to PATCH+DELETE event lookups | 2870413 | events.ts, events.test.ts |
| 2 | Derive displayName from OIDC claims in me.ts + resolveUserId | 23c8bb3 | events.ts, me.ts, user.ts |
| 3 | Scope GET /api/events to current user + shared calendars | 00a0454 | events.ts |
| # | Name | Commit | Files |
| --- | ------------------------------------------------------------ | ------- | ------------------------- |
| 1 | Add missing innerJoin to PATCH+DELETE event lookups | 2870413 | events.ts, events.test.ts |
| 2 | Derive displayName from OIDC claims in me.ts + resolveUserId | 23c8bb3 | events.ts, me.ts, user.ts |
| 3 | Scope GET /api/events to current user + shared calendars | 00a0454 | events.ts |
## Bug Details
@@ -69,6 +69,7 @@ metrics:
Additionally, `upsertUser` returned existing rows unchanged — meaning an already-blank `displayName` would never be corrected even after the claim-derivation fix.
**Fix (me.ts + events.ts):** Both call sites now derive `displayName` using the preference chain:
1. `name` — full name set by the IdP (most human-friendly)
2. `preferred_username` — login handle; still readable
3. `email` — reveals contact info but acceptable fallback
@@ -87,6 +88,7 @@ Each candidate is tested defensively: `typeof v === 'string' && v.trim() !== ''`
**Root cause:** The GET / handler had no ownership predicate — it returned events from all calendars in the DB, regardless of who owns them. The second household member would see the first member's private events.
**Fix:** Added `resolveUserId(c)` call at the top of the GET handler (returns 401 if unauthenticated). Added ownership predicate wrapped with `and()` around the existing date-window `or()` block:
```
and(
or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
@@ -101,6 +103,7 @@ Mirrors the authoritative `/writable-calendars` ownership idiom (D-03).
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fixed upsertUser returning stale null displayName for existing rows**
- **Found during:** Task 2
- **Issue:** The plan said "do not alter upsertUser signature or user.ts" but the scope amendment required that existing blank rows be corrected. `upsertUser` returned existing rows without updating displayName, making the me.ts fix useless for already-created users.
- **Fix:** Modified user.ts to issue an UPDATE when existing row has `displayName = null` and caller provides a non-null value. Signature unchanged.
@@ -108,6 +111,7 @@ Mirrors the authoritative `/writable-calendars` ownership idiom (D-03).
- **Commit:** 23c8bb3
**2. [Rule 3 - Blocking] Node_modules symlink for worktree test execution**
- **Found during:** Task 1 (TDD verification)
- **Issue:** Worktree has no `node_modules` — vitest could not find dependencies. Main repo tests run against main repo source files, not worktree files.
- **Fix:** Created symlink `apps/api/node_modules -> /home/luc/Projects/familysync/apps/api/node_modules`. Symlink is not tracked by git (node_modules is gitignored) — this is a runtime-only convenience for test execution in the worktree context.
@@ -151,7 +155,7 @@ The orchestrator verified all three fixes and made two follow-up commits:
**1. `509f4b2` — test: make BUG 1 join regression test couple to the handler.**
The executor's original `toSQL()` regression test was **tautological**: it
hand-built the joined query *inside the test body* and asserted the SQL
hand-built the joined query _inside the test body_ and asserted the SQL
contained a join — it never exercised the handler, so removing `.innerJoin`
from `events.ts` left it green. Replaced with two tests that issue real
PATCH/DELETE requests against the mocked select-chain (`from → innerJoin →
@@ -12,16 +12,16 @@ requirements: [D-14]
must_haves:
truths:
- "01-HUMAN-UAT.md item 4 (SSE smoke) shows a PASS result note dated 2026-06-08"
- "01-HUMAN-UAT.md Summary shows passed:1 pending:3 (no longer passed:0)"
- "03-GATE2-RESULTS.md Part C row C1 shows a PASS marker dated 2026-06-08 with evidence"
- "03-GATE2-RESULTS.md Summary Part-C row shows PASS/CLEARED (no longer DEFERRED)"
- "No PENDING/DEFERRED/passed: 0 markers remain on any SSE row in either file"
- '01-HUMAN-UAT.md item 4 (SSE smoke) shows a PASS result note dated 2026-06-08'
- '01-HUMAN-UAT.md Summary shows passed:1 pending:3 (no longer passed:0)'
- '03-GATE2-RESULTS.md Part C row C1 shows a PASS marker dated 2026-06-08 with evidence'
- '03-GATE2-RESULTS.md Summary Part-C row shows PASS/CLEARED (no longer DEFERRED)'
- 'No PENDING/DEFERRED/passed: 0 markers remain on any SSE row in either file'
artifacts:
- path: ".planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md"
provides: "Phase 1 UAT item 4 marked PASS"
- path: ".planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md"
provides: "Gate 2 Part C SSE row + summary marked PASS"
- path: '.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md'
provides: 'Phase 1 UAT item 4 marked PASS'
- path: '.planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md'
provides: 'Gate 2 Part C SSE row + summary marked PASS'
key_links: []
---
@@ -52,7 +52,7 @@ Record these facts (do not embellish, do not overstate):
- Result: no early cut by the proxy. PASS. Phase 4 entry gate (D-14 / issue #1034) is CLEARED.
- Date executed: 2026-06-08.
- Caveat: proves no idle-timeout cutoff and no buffering over ~6 min; does NOT prove the absence of a max total connection-duration cap. That residual risk is covered by the already-decided Phase 4 design (D-10 refetch-on-reconnect + D-11 capped-backoff reconnect + D-12 polling fallback in 04-CONTEXT.md) — no further infra work required to clear the gate.
</verbatim_evidence>
</verbatim_evidence>
<tasks>
@@ -69,6 +69,7 @@ Record these facts (do not embellish, do not overstate):
2. Update the `## Summary` block: change `passed: 0``passed: 1` and `pending: 4``pending: 3`. Leave `total: 4`, `issues: 0`, `skipped: 0`, `blocked: 0` unchanged.
Do NOT touch items 13 (AUTH-01/02/03) or their `result: [pending]` lines. Preserve all existing Markdown formatting and front-matter.
</action>
<verify>
<automated>grep -n "PASS (2026-06-08)" /home/luc/Projects/familysync/.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md && grep -qx "passed: 1" /home/luc/Projects/familysync/.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md && grep -qx "pending: 3" /home/luc/Projects/familysync/.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md && ! grep -qx "passed: 0" /home/luc/Projects/familysync/.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md</automated>
@@ -89,6 +90,7 @@ Record these facts (do not embellish, do not overstate):
2. In the `## Summary` table at the bottom, change the row `| C — SSE smoke test | ⏳ DEFERRED — Phase 4 ENTRY gate (D-14), not a Phase 3 deliverable |` to `| C — SSE smoke test | ✅ PASS (2026-06-08) — Phase 4 ENTRY gate (D-14 / issue #1034) CLEARED; held ~6 min, 35 heartbeats, incremental delivery, no proxy cut |`.
Do NOT modify any other rows (A/B/D parts), the Header, the Gate 2 outcome paragraph, or any code fences. Preserve all Markdown formatting.
</action>
<verify>
<automated>grep -n "✅ PASS (2026-06-08)" /home/luc/Projects/familysync/.planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md | grep -E "C1|C — SSE" ; grep -c "✅ PASS (2026-06-08)" /home/luc/Projects/familysync/.planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md && ! grep -q "PENDING — operator" /home/luc/Projects/familysync/.planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md && ! grep -q "⏳ DEFERRED" /home/luc/Projects/familysync/.planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md</automated>
@@ -115,11 +117,12 @@ Confirm NO changes were made to: 04-CONTEXT.md, ROADMAP.md, or any source code.
</verification>
<success_criteria>
- 01-HUMAN-UAT.md item 4 marked PASS with 2026-06-08 evidence; Summary passed:1 / pending:3.
- 03-GATE2-RESULTS.md Part C row C1 and Summary Part-C row marked PASS (2026-06-08) with evidence.
- No PENDING/DEFERRED/passed:0 markers remain on any SSE row.
- No other files touched; table structures and Markdown formatting preserved.
</success_criteria>
</success_criteria>
<output>
Create `.planning/quick/260607-u8o-record-sse-over-pangolin-smoke-test-pass/260607-u8o-SUMMARY.md` when done.
@@ -5,8 +5,8 @@ subsystem: planning-docs
tags: [gate, sse, pangolin, phase-4-entry, documentation]
requires: []
provides:
- "Phase 1 UAT item 4 (SSE smoke) marked PASS"
- "Gate 2 Part C SSE row + summary marked PASS — Phase 4 entry gate (D-14) cleared"
- 'Phase 1 UAT item 4 (SSE smoke) marked PASS'
- 'Gate 2 Part C SSE row + summary marked PASS — Phase 4 entry gate (D-14) cleared'
affects:
- .planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md
- .planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md
@@ -11,22 +11,22 @@ autonomous: true
requirements: []
must_haves:
truths:
- "Running `pnpm --filter @familysync/api run db:push` fails — the script no longer exists"
- "docs/deployment.md instructs operators to apply schema via `drizzle-kit migrate`, not `drizzle-kit push`"
- "docs/deployment.md warns operators NOT to use `drizzle-kit push` on MariaDB and explains why (false destructive diff)"
- 'Running `pnpm --filter @familysync/api run db:push` fails — the script no longer exists'
- 'docs/deployment.md instructs operators to apply schema via `drizzle-kit migrate`, not `drizzle-kit push`'
- 'docs/deployment.md warns operators NOT to use `drizzle-kit push` on MariaDB and explains why (false destructive diff)'
- "`drizzle-kit generate` against the current schema produces no spurious destructive diff (reports 'No schema changes' or only an intended additive delta — never a truncate/drop)"
artifacts:
- path: "apps/api/package.json"
provides: "API scripts with db:push removed; db:generate + db:migrate retained as canonical workflow"
contains: "db:migrate"
- path: "docs/deployment.md"
provides: "Schema-apply step + prod step repointed to generate/migrate with anti-push warning"
contains: "drizzle-kit migrate"
- path: 'apps/api/package.json'
provides: 'API scripts with db:push removed; db:generate + db:migrate retained as canonical workflow'
contains: 'db:migrate'
- path: 'docs/deployment.md'
provides: 'Schema-apply step + prod step repointed to generate/migrate with anti-push warning'
contains: 'drizzle-kit migrate'
key_links:
- from: "docs/deployment.md Step 3"
to: "apps/api/src/db/migrations"
via: "drizzle-kit migrate applies committed migration SQL"
pattern: "drizzle-kit migrate"
- from: 'docs/deployment.md Step 3'
to: 'apps/api/src/db/migrations'
via: 'drizzle-kit migrate applies committed migration SQL'
pattern: 'drizzle-kit migrate'
---
<objective>
@@ -51,11 +51,17 @@ Output: `apps/api/package.json` with `db:push` removed; `docs/deployment.md` Ste
@.planning/todos/pending/adopt-drizzle-migrations-workflow.md
# Hard constraints (do NOT violate):
# - Do NOT renumber, delete, or regenerate any existing migration SQL file or snapshot in
# apps/api/src/db/migrations/ (including the orphan 0001_calendars_user_url_unique.sql).
# apps/api/src/db/migrations/ (including the orphan 0001_calendars_user_url_unique.sql).
# - Do NOT run `db:migrate` or `db:push` against the live/dev DB — it holds real data.
# - `drizzle-kit generate` is safe: it diffs schema.ts against the JSON snapshots in meta/,
# never the live DB. It needs no DB connection.
# never the live DB. It needs no DB connection.
</context>
<tasks>
@@ -103,10 +109,11 @@ Output: `apps/api/package.json` with `db:push` removed; `docs/deployment.md` Ste
</verification>
<success_criteria>
- `db:push` script removed from apps/api/package.json; JSON valid; generate+migrate scripts intact.
- docs/deployment.md Steps 3 and 6 apply schema via `drizzle-kit migrate`; an explicit anti-push warning with data-loss rationale is present; no remaining push references.
- Dry `drizzle-kit generate` confirmed to emit no spurious destructive diff, with migration history left byte-identical and the live DB never touched.
</success_criteria>
</success_criteria>
<output>
Create `.planning/quick/260610-cr8-adopt-drizzle-generate-migrate-workflow-/260610-cr8-SUMMARY.md` when done
@@ -15,8 +15,8 @@ affects: [deployment, schema-changes, onboarding]
tech-stack:
added: []
patterns:
- "Schema authoring: db:generate diffs schema.ts against meta/ snapshots (no DB); db:migrate applies committed SQL"
- "drizzle-kit push is banned on this MariaDB — mysql dialect misreads MariaDB 11.x metadata and schedules false truncate/recreate"
- 'Schema authoring: db:generate diffs schema.ts against meta/ snapshots (no DB); db:migrate applies committed SQL'
- 'drizzle-kit push is banned on this MariaDB — mysql dialect misreads MariaDB 11.x metadata and schedules false truncate/recreate'
key-files:
created: []
@@ -25,7 +25,7 @@ key-files:
- docs/deployment.md
key-decisions:
- "D-Task5-DDL confirmed: drizzle-kit push banned on MariaDB; generate+migrate is the only schema workflow"
- 'D-Task5-DDL confirmed: drizzle-kit push banned on MariaDB; generate+migrate is the only schema workflow'
requirements-completed: []
@@ -73,6 +73,7 @@ completed: 2026-06-10
### Auto-fixed Issues
**1. [Rule 1 - Bug] Warning text matched the plan's verify exclusion regex**
- **Found during:** Task 2 (verify step)
- **Issue:** The anti-push warning callout contained the literal string `drizzle-kit push`, which the plan's verify regex `! grep -Eq 'drizzle-kit push|db:push'` flagged as a remaining push reference.
- **Fix:** Rephrased warning to "the `push` subcommand of drizzle-kit" — semantically equivalent, avoids the exact pattern, verify passes.
@@ -88,16 +89,19 @@ completed: 2026-06-10
## Verify Output
**Task 1:**
```
ok: db:push removed, generate+migrate intact
```
**Task 2:**
```
ok: migrate path + warning present, no push references remain
```
**Task 3:**
```
No config path provided, using default 'drizzle.config.ts'
Reading config file '/home/luc/Projects/familysync/apps/api/drizzle.config.ts'
@@ -141,5 +145,6 @@ None.
## Self-Check: PASSED
---
*Phase: quick-260610-cr8*
*Completed: 2026-06-10*
_Phase: quick-260610-cr8_
_Completed: 2026-06-10_
@@ -19,12 +19,12 @@ overrides_applied: 0
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | `pnpm --filter @familysync/api run db:push` fails — script no longer exists | VERIFIED | `apps/api/package.json` scripts block contains only `db:generate` and `db:migrate`; `db:push` key is absent. Node programmatic check: "ok: db:push removed, generate+migrate intact" |
| 2 | `docs/deployment.md` instructs operators to apply schema via `drizzle-kit migrate`, not `drizzle-kit push` | VERIFIED | Step 3 command is `pnpm --filter @familysync/api exec drizzle-kit migrate`; Step 6 reads "then `drizzle-kit migrate` once (Step 3)". Zero occurrences of `drizzle-kit push` or `db:push` in the file. |
| 3 | `docs/deployment.md` warns operators NOT to use `drizzle-kit push` on MariaDB and explains why | VERIFIED | Lines 145-149: `> **WARNING — do NOT use the `push` subcommand of drizzle-kit on this MariaDB.**` with explicit data-loss rationale ("misreads MariaDB 11.x metadata and schedules a false truncate/recreate that **wipes data**"). Warning regex `grep -iEq 'do not.*push'` matches. |
| 4 | `drizzle-kit generate` against the current schema produces no spurious destructive diff | VERIFIED | SUMMARY Task 3 output: "No schema changes, nothing to migrate". Migration directory has 6 SQL files (00000004 plus orphan 0001_calendars_user_url_unique.sql), last touched by commit `44fbb2b` (pre-task). `git status --porcelain apps/api/src/db/migrations/` is clean. No task commit touched the migrations path. |
| # | Truth | Status | Evidence |
| --- | ---------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `pnpm --filter @familysync/api run db:push` fails — script no longer exists | VERIFIED | `apps/api/package.json` scripts block contains only `db:generate` and `db:migrate`; `db:push` key is absent. Node programmatic check: "ok: db:push removed, generate+migrate intact" |
| 2 | `docs/deployment.md` instructs operators to apply schema via `drizzle-kit migrate`, not `drizzle-kit push` | VERIFIED | Step 3 command is `pnpm --filter @familysync/api exec drizzle-kit migrate`; Step 6 reads "then `drizzle-kit migrate` once (Step 3)". Zero occurrences of `drizzle-kit push` or `db:push` in the file. |
| 3 | `docs/deployment.md` warns operators NOT to use `drizzle-kit push` on MariaDB and explains why | VERIFIED | Lines 145-149: `> **WARNING — do NOT use the `push` subcommand of drizzle-kit on this MariaDB.**` with explicit data-loss rationale ("misreads MariaDB 11.x metadata and schedules a false truncate/recreate that **wipes data**"). Warning regex `grep -iEq 'do not.*push'` matches. |
| 4 | `drizzle-kit generate` against the current schema produces no spurious destructive diff | VERIFIED | SUMMARY Task 3 output: "No schema changes, nothing to migrate". Migration directory has 6 SQL files (00000004 plus orphan 0001_calendars_user_url_unique.sql), last touched by commit `44fbb2b` (pre-task). `git status --porcelain apps/api/src/db/migrations/` is clean. No task commit touched the migrations path. |
**Score:** 4/4 truths verified
@@ -32,27 +32,27 @@ overrides_applied: 0
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/package.json` | db:push removed; db:generate + db:migrate retained | VERIFIED | Scripts block has exactly `db:generate` and `db:migrate`; no `db:push` key present. Valid JSON confirmed by node require. |
| `docs/deployment.md` | Schema-apply step repointed to generate/migrate with anti-push warning | VERIFIED | Step 3 and Step 6 both reference `drizzle-kit migrate`. Warning callout at lines 145-149 is present with data-loss rationale. |
| Artifact | Expected | Status | Details |
| ----------------------- | ---------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `apps/api/package.json` | db:push removed; db:generate + db:migrate retained | VERIFIED | Scripts block has exactly `db:generate` and `db:migrate`; no `db:push` key present. Valid JSON confirmed by node require. |
| `docs/deployment.md` | Schema-apply step repointed to generate/migrate with anti-push warning | VERIFIED | Step 3 and Step 6 both reference `drizzle-kit migrate`. Warning callout at lines 145-149 is present with data-loss rationale. |
---
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| From | To | Via | Status | Details |
| --------------------------- | ---------------------------- | ------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `docs/deployment.md` Step 3 | `apps/api/src/db/migrations` | `drizzle-kit migrate` applies committed SQL | VERIFIED | Command on line 156 is `pnpm --filter @familysync/api exec drizzle-kit migrate`. Authoring workflow note at lines 160-162 explains `db:generate` diffs schema.ts against committed snapshots. |
---
### Scope Constraint: files_modified matches actual git diff
| Constraint | Status | Evidence |
|------------|--------|----------|
| `git diff f452400~1..1a95d81 --name-only` returns only `apps/api/package.json` and `docs/deployment.md` | VERIFIED | Command output: exactly those two files; no other files touched. |
| No file under `apps/api/src/db/migrations/` added, deleted, renumbered, or modified | VERIFIED | `git status --porcelain apps/api/src/db/migrations/` is empty. Most recent commit touching migrations is `44fbb2b`, which predates both task commits `f452400` and `1a95d81`. |
| Constraint | Status | Evidence |
| ------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `git diff f452400~1..1a95d81 --name-only` returns only `apps/api/package.json` and `docs/deployment.md` | VERIFIED | Command output: exactly those two files; no other files touched. |
| No file under `apps/api/src/db/migrations/` added, deleted, renumbered, or modified | VERIFIED | `git status --porcelain apps/api/src/db/migrations/` is empty. Most recent commit touching migrations is `44fbb2b`, which predates both task commits `f452400` and `1a95d81`. |
---
@@ -64,12 +64,12 @@ None. No TBD, FIXME, XXX, or placeholder patterns in either modified file. The d
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| `db:push` absent from package.json | `node -e "..."` (programmatic JSON check) | "ok: db:push removed, generate+migrate intact" | PASS |
| No push references in deployment.md | `grep -n "drizzle-kit push\|db:push" docs/deployment.md` | (no output) | PASS |
| Anti-push warning present | `grep -iEq 'do not.*push' docs/deployment.md` | MATCH | PASS |
| Migration directory untouched | `git status --porcelain apps/api/src/db/migrations/` | (clean) | PASS |
| Behavior | Command | Result | Status |
| ----------------------------------- | -------------------------------------------------------- | ---------------------------------------------- | ------ |
| `db:push` absent from package.json | `node -e "..."` (programmatic JSON check) | "ok: db:push removed, generate+migrate intact" | PASS |
| No push references in deployment.md | `grep -n "drizzle-kit push\|db:push" docs/deployment.md` | (no output) | PASS |
| Anti-push warning present | `grep -iEq 'do not.*push' docs/deployment.md` | MATCH | PASS |
| Migration directory untouched | `git status --porcelain apps/api/src/db/migrations/` | (clean) | PASS |
---
@@ -9,13 +9,13 @@ autonomous: true
requirements: [DOCS-FIX]
must_haves:
truths:
- "docs/deployment.md tells a Phase 2+ developer the exact command to run the API + PWA host-side (no Docker)"
- "The doc explains DB_HOST must be overridden to localhost because root .env sets DB_HOST=mariadb for the Docker network"
- "The doc states the dev script does not auto-load .env"
- 'docs/deployment.md tells a Phase 2+ developer the exact command to run the API + PWA host-side (no Docker)'
- 'The doc explains DB_HOST must be overridden to localhost because root .env sets DB_HOST=mariadb for the Docker network'
- 'The doc states the dev script does not auto-load .env'
artifacts:
- path: "docs/deployment.md"
provides: "Host-side (no-Docker) local-dev run instructions for Phase 2+"
contains: "Running locally"
- path: 'docs/deployment.md'
provides: 'Host-side (no-Docker) local-dev run instructions for Phase 2+'
contains: 'Running locally'
key_links: []
---
@@ -82,6 +82,7 @@ Output: Updated docs/deployment.md.
Use fenced ```bash blocks for the commands. Match the surrounding doc's tone and heading depth
(the parent section is `##`, so use `###` for this subsection).
</action>
<verify>
<automated>grep -q "Running locally (host-side, no Docker)" docs/deployment.md && grep -q "DB_HOST=localhost pnpm --filter @familysync/api dev" docs/deployment.md && grep -q "pnpm --filter @familysync/pwa dev" docs/deployment.md && grep -q "set -a; source .env; set +a" docs/deployment.md</automated>
@@ -1,6 +1,6 @@
---
phase: quick-260610-czd
plan: "01"
plan: '01'
subsystem: docs
tags: [docs, local-dev, deployment]
dependency_graph:
@@ -15,10 +15,10 @@ key_files:
modified:
- docs/deployment.md
decisions:
- "D: Sourcing .env manually with DB_HOST override is intentional — baking --env-file into the dev script would load DB_HOST=mariadb and break host-side dev."
- 'D: Sourcing .env manually with DB_HOST override is intentional — baking --env-file into the dev script would load DB_HOST=mariadb and break host-side dev.'
metrics:
duration: "~5 min"
completed: "2026-06-10"
duration: '~5 min'
completed: '2026-06-10'
---
# Phase quick-260610-czd Plan 01: Fix docs/deployment.md local-dev command — Summary
@@ -27,9 +27,9 @@ metrics:
## Tasks Completed
| # | Task | Commit | Files |
|---|------|--------|-------|
| 1 | Add "Running locally (host-side, no Docker)" subsection | 39e2ee0 | docs/deployment.md |
| # | Task | Commit | Files |
| --- | ------------------------------------------------------- | ------- | ------------------ |
| 1 | Add "Running locally (host-side, no Docker)" subsection | 39e2ee0 | docs/deployment.md |
## Verification Output
@@ -11,27 +11,27 @@ autonomous: true
requirements: [PUSH-REMIND-RESILIENCE]
must_haves:
truths:
- "A shared timed event whose start is in (now, now+16min] triggers a reminder push on the next scan, even if the cron tick at the ideal 15-min mark was missed/late (catch-up)."
- "A given shared timed event fires EXACTLY ONCE across all scans while it sits in the catch-up window — no cross-tick double-fire."
- "An event whose dtstart has already passed (dtstart <= now) does NOT trigger a reminder."
- 'A shared timed event whose start is in (now, now+16min] triggers a reminder push on the next scan, even if the cron tick at the ideal 15-min mark was missed/late (catch-up).'
- 'A given shared timed event fires EXACTLY ONCE across all scans while it sits in the catch-up window — no cross-tick double-fire.'
- 'An event whose dtstart has already passed (dtstart <= now) does NOT trigger a reminder.'
- "The notification body reflects the actual lead time (e.g. 'Starts in 8 min'), guarded to a minimum of 1."
- "Existing Phase-5 behaviours stay green: isShared-only (D-05), allDay excluded (D-07), always-visible notification (D-11), empty push_subscriptions -> zero sends/no crash (D-16), per-event and per-sub error isolation (T-05-17/18/19)."
- 'Existing Phase-5 behaviours stay green: isShared-only (D-05), allDay excluded (D-07), always-visible notification (D-11), empty push_subscriptions -> zero sends/no crash (D-16), per-event and per-sub error isolation (T-05-17/18/19).'
artifacts:
- path: "apps/api/src/broker/reminderScheduler.ts"
provides: "Resilient catch-up reminder scan with per-uid exactly-once dedup, in-memory (D-12)."
contains: "runReminderCheck"
- path: "apps/api/tests/broker/reminderScheduler.test.ts"
provides: "Updated tests covering single-fire across consecutive ticks, missed-tick recovery, already-started exclusion, plus retained Phase-5 behaviours."
contains: "missed"
- path: 'apps/api/src/broker/reminderScheduler.ts'
provides: 'Resilient catch-up reminder scan with per-uid exactly-once dedup, in-memory (D-12).'
contains: 'runReminderCheck'
- path: 'apps/api/tests/broker/reminderScheduler.test.ts'
provides: 'Updated tests covering single-fire across consecutive ticks, missed-tick recovery, already-started exclusion, plus retained Phase-5 behaviours.'
contains: 'missed'
key_links:
- from: "apps/api/src/broker/reminderScheduler.ts"
to: "calendarEvents.dtstartUtc"
via: "WHERE gt(now) AND lte(now+16min)"
- from: 'apps/api/src/broker/reminderScheduler.ts'
to: 'calendarEvents.dtstartUtc'
via: 'WHERE gt(now) AND lte(now+16min)'
pattern: "gt\\(.*dtstartUtc"
- from: "apps/api/src/broker/reminderScheduler.ts"
to: "dispatchPush"
via: "fan-out per subscription, then mark uid sent"
pattern: "dispatchPush"
- from: 'apps/api/src/broker/reminderScheduler.ts'
to: 'dispatchPush'
via: 'fan-out per subscription, then mark uid sent'
pattern: 'dispatchPush'
---
<objective>
@@ -87,6 +87,7 @@ Output: Updated `reminderScheduler.ts` (in-memory only, no schema/deps/Redis per
Comments / decisions to preserve and update: D-05, D-07, D-11, D-12, D-16, T-05-17, T-05-18, T-05-19, WR-01, CR-01 must remain referenced in the header/inline comments with their meanings updated for per-uid dedup and the catch-up window. Add a short inline CAVEAT comment near the dedup: if an event's dtstart is RESCHEDULED earlier after a reminder already fired, it will not re-fire — acceptable for v1.
Do NOT: add schema/migration changes, new dependencies, Redis, or DB persistence. Do NOT change pushDispatcher or its call signature. Do NOT place fenced code blocks anywhere — this is directive prose.
</action>
<verify>
<automated>pnpm --filter @familysync/api typecheck</automated>
@@ -117,6 +118,7 @@ Output: Updated `reminderScheduler.ts` (in-memory only, no schema/deps/Redis per
- Keep the all-day (D-07), non-shared (D-05) empty-result tests as-is. Update the file's top docblock to describe the catch-up `(now, now+16min]` window and per-uid exactly-once dedup instead of the old `[now+14,now+16]` / minuteBucket description.
Do NOT place fenced code blocks in this action prose. Do NOT introduce real DB or real push; keep everything mocked.
</action>
<verify>
<automated>pnpm --filter @familysync/api test</automated>
@@ -133,13 +135,14 @@ Output: Updated `reminderScheduler.ts` (in-memory only, no schema/deps/Redis per
</verification>
<success_criteria>
- A shared timed event in `(now, now+16min]` fires a reminder on the next scan after a missed/late tick (catch-up) — Test 1 closed.
- Each such event fires EXACTLY ONCE across consecutive ticks (cross-tick double-fire bug fixed).
- Events with `dtstart <= now` never fire.
- Notification body reflects actual lead time, guarded to >= 1 min.
- All prior Phase-5 decisions/threat mitigations preserved (D-05/D-07/D-11/D-12/D-16, T-05-17/18/19, WR-01, CR-01).
- In-memory only: no schema change, no new deps, no Redis/DB persistence. Only `reminderScheduler.ts` and its test touched.
</success_criteria>
</success_criteria>
<output>
Create `.planning/quick/260610-hbu-make-phase-5-reminder-scheduler-resilien/260610-hbu-SUMMARY.md` when done.
@@ -1,6 +1,6 @@
---
phase: quick-260610-hbu
plan: "01"
plan: '01'
subsystem: api/broker
tags: [push-notifications, reminder-scheduler, resilience, dedup]
dependency_graph:
@@ -16,13 +16,13 @@ key_files:
- apps/api/src/broker/reminderScheduler.ts
- apps/api/tests/broker/reminderScheduler.test.ts
decisions:
- "Window changed from [now+14min, now+16min] to (now, now+16min] for missed-tick catch-up"
- "Dedup key changed from uid:minuteBucket (Set) to bare uid (Map<uid, dtstartMs>) for cross-tick exactly-once"
- 'Window changed from [now+14min, now+16min] to (now, now+16min] for missed-tick catch-up'
- 'Dedup key changed from uid:minuteBucket (Set) to bare uid (Map<uid, dtstartMs>) for cross-tick exactly-once'
- "Body changed from hardcoded 'Starts in 15 min' to lead-accurate 'Starts in N min'"
- "CR-01 pruning changed from minuteBucket-age to started-event (dtstartMs <= now)"
- 'CR-01 pruning changed from minuteBucket-age to started-event (dtstartMs <= now)'
metrics:
duration: "~15min"
completed: "2026-06-10"
duration: '~15min'
completed: '2026-06-10'
tasks_completed: 2
files_modified: 2
---
@@ -33,10 +33,10 @@ metrics:
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Catch-up window + per-uid dedup in reminderScheduler.ts | 3fdb242 | apps/api/src/broker/reminderScheduler.ts |
| 2 | Update tests for catch-up + single-fire + missed-tick | 93bb2c1 | apps/api/tests/broker/reminderScheduler.test.ts |
| Task | Name | Commit | Files |
| ---- | ------------------------------------------------------- | ------- | ----------------------------------------------- |
| 1 | Catch-up window + per-uid dedup in reminderScheduler.ts | 3fdb242 | apps/api/src/broker/reminderScheduler.ts |
| 2 | Update tests for catch-up + single-fire + missed-tick | 93bb2c1 | apps/api/tests/broker/reminderScheduler.test.ts |
## Verification Output
@@ -90,13 +90,14 @@ grep -n "minuteBucket" apps/api/src/broker/reminderScheduler.ts
**Dispatch loop check:** `sentReminders.has(uid:minuteBucket)` replaced with `sentReminders.has(uid)`. WR-01 mark-after-dispatch preserved: `sentReminders.set(uid, dtstartMs)` after fan-out loop.
**Notification body:** Hardcoded `'Starts in 15 min'` replaced with `\`Starts in ${minutes} min\`` where `minutes = Math.max(1, Math.round((dtstartMs - now) / 60000))`.
**Notification body:** Hardcoded `'Starts in 15 min'` replaced with `\`Starts in ${minutes} min\``where`minutes = Math.max(1, Math.round((dtstartMs - now) / 60000))`.
**CR-01 pruning:** Stale minuteBucket iteration replaced with started-event pruning: delete entries whose stored `dtstartMs <= now.getTime()`.
### Task 2 — reminderScheduler.test.ts
New tests added:
- **SINGLE-FIRE across 3 consecutive ticks:** 3 calls to `runReminderCheck` with event still in window; asserts `dispatchPush` called exactly once.
- **MISSED-TICK-RECOVERY:** No scan at the ideal 15-min mark; call at 8 min before start; asserts dispatch fires.
- **ALREADY-STARTED:** dtstart <= now excluded by SQL `gt`; mocked as empty rows; 0 dispatches.
@@ -105,10 +106,12 @@ New tests added:
- **Fan-out:** 2 subscriber rows for one event uid; 2 dispatches.
Old minuteBucket tests removed:
- `(eventUid, minuteBucket) twice within the same run` — replaced by per-uid SINGLE-FIRE test.
- `CR-01: re-dispatch in next minute bucket` — replaced by CR-01 started-event pruning test.
Retained:
- D-07 all-day exclusion test.
- D-05 non-shared exclusion test.
- WR-01 mark-after-dispatch test (updated to per-uid dedup language).
@@ -19,13 +19,13 @@ overrides_applied: 0
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|---------|
| 1 | A shared timed event in (now, now+16min] fires on the next scan even after a missed/late tick (catch-up). | VERIFIED | `gt(calendarEvents.dtstartUtc, now)` at line 103; lower bound is `now`, not `now+14min`. Any scan while event is still future will find it. MISSED-TICK-RECOVERY test fires at 8-min lead. |
| 2 | A given event fires EXACTLY ONCE across all scans while in the catch-up window — no cross-tick double-fire. | VERIFIED | `sentReminders` is `Map<string, number>` keyed on bare uid (line 47); `if (sentReminders.has(uid)) continue` at line 145. SINGLE-FIRE test asserts `dispatchPush` called exactly once across 3 consecutive ticks. |
| 3 | An event whose dtstart has already passed (dtstart <= now) does NOT trigger a reminder. | VERIFIED | `gt(calendarEvents.dtstartUtc, now)` at line 103 excludes already-started events at the DB level. "already-started" test mocks empty rows, asserts 0 dispatches. |
| 4 | Notification body reflects actual lead time (e.g. "Starts in 8 min"), guarded to minimum 1. | VERIFIED | Line 150: `const minutes = Math.max(1, Math.round((event.dtstartUtc.getTime() - now.getTime()) / 60000))` then `body: \`Starts in ${minutes} min\`` at line 156. Not hardcoded. |
| 5 | Existing Phase-5 behaviours stay green: isShared-only (D-05), allDay excluded (D-07), always-visible notification (D-11), empty push_subscriptions -> zero sends/no crash (D-16), per-event and per-sub error isolation (T-05-18/T-05-19). | VERIFIED | All present in source (lines 101-102 for D-05/D-07; line 98 for D-11 via dispatchPush; lines 141-186 for T-05-18/19 try/catch isolation). Tests for D-05, D-07, D-16, T-05-19 all pass (10/10). |
| # | Truth | Status | Evidence |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | A shared timed event in (now, now+16min] fires on the next scan even after a missed/late tick (catch-up). | VERIFIED | `gt(calendarEvents.dtstartUtc, now)` at line 103; lower bound is `now`, not `now+14min`. Any scan while event is still future will find it. MISSED-TICK-RECOVERY test fires at 8-min lead. |
| 2 | A given event fires EXACTLY ONCE across all scans while in the catch-up window — no cross-tick double-fire. | VERIFIED | `sentReminders` is `Map<string, number>` keyed on bare uid (line 47); `if (sentReminders.has(uid)) continue` at line 145. SINGLE-FIRE test asserts `dispatchPush` called exactly once across 3 consecutive ticks. |
| 3 | An event whose dtstart has already passed (dtstart <= now) does NOT trigger a reminder. | VERIFIED | `gt(calendarEvents.dtstartUtc, now)` at line 103 excludes already-started events at the DB level. "already-started" test mocks empty rows, asserts 0 dispatches. |
| 4 | Notification body reflects actual lead time (e.g. "Starts in 8 min"), guarded to minimum 1. | VERIFIED | Line 150: `const minutes = Math.max(1, Math.round((event.dtstartUtc.getTime() - now.getTime()) / 60000))` then `body: \`Starts in ${minutes} min\`` at line 156. Not hardcoded. |
| 5 | Existing Phase-5 behaviours stay green: isShared-only (D-05), allDay excluded (D-07), always-visible notification (D-11), empty push_subscriptions -> zero sends/no crash (D-16), per-event and per-sub error isolation (T-05-18/T-05-19). | VERIFIED | All present in source (lines 101-102 for D-05/D-07; line 98 for D-11 via dispatchPush; lines 141-186 for T-05-18/19 try/catch isolation). Tests for D-05, D-07, D-16, T-05-19 all pass (10/10). |
**Score:** 5/5 truths verified
@@ -33,36 +33,36 @@ overrides_applied: 0
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/src/broker/reminderScheduler.ts` | Resilient catch-up scan with per-uid exactly-once dedup, `runReminderCheck` exported | VERIFIED | 213 lines; contains `runReminderCheck`, `startReminderScheduler`; `Map<string, number>` dedup; `gt` lower bound; lead-accurate body |
| `apps/api/tests/broker/reminderScheduler.test.ts` | Tests covering single-fire, missed-tick recovery, already-started exclusion, plus retained Phase-5 behaviours | VERIFIED | 409 lines; contains "SINGLE-FIRE", "MISSED-TICK-RECOVERY", "already-started", D-05/D-07/D-16/WR-01/CR-01/T-05-19 tests |
| Artifact | Expected | Status | Details |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `apps/api/src/broker/reminderScheduler.ts` | Resilient catch-up scan with per-uid exactly-once dedup, `runReminderCheck` exported | VERIFIED | 213 lines; contains `runReminderCheck`, `startReminderScheduler`; `Map<string, number>` dedup; `gt` lower bound; lead-accurate body |
| `apps/api/tests/broker/reminderScheduler.test.ts` | Tests covering single-fire, missed-tick recovery, already-started exclusion, plus retained Phase-5 behaviours | VERIFIED | 409 lines; contains "SINGLE-FIRE", "MISSED-TICK-RECOVERY", "already-started", D-05/D-07/D-16/WR-01/CR-01/T-05-19 tests |
---
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `reminderScheduler.ts` | `calendarEvents.dtstartUtc` | `WHERE gt(now) AND lte(now+16min)` | VERIFIED | Line 103: `gt(calendarEvents.dtstartUtc, now)`. Line 104: `lte(calendarEvents.dtstartUtc, windowEnd)`. |
| `reminderScheduler.ts` | `dispatchPush` | fan-out per subscription, then mark uid sent | VERIFIED | Lines 163-174: per-sub loop calling `await dispatchPush(sub, notification)`. Line 179: `sentReminders.set(uid, ...)` after the fan-out loop (WR-01 preserved). |
| From | To | Via | Status | Details |
| ---------------------- | --------------------------- | -------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reminderScheduler.ts` | `calendarEvents.dtstartUtc` | `WHERE gt(now) AND lte(now+16min)` | VERIFIED | Line 103: `gt(calendarEvents.dtstartUtc, now)`. Line 104: `lte(calendarEvents.dtstartUtc, windowEnd)`. |
| `reminderScheduler.ts` | `dispatchPush` | fan-out per subscription, then mark uid sent | VERIFIED | Lines 163-174: per-sub loop calling `await dispatchPush(sub, notification)`. Line 179: `sentReminders.set(uid, ...)` after the fan-out loop (WR-01 preserved). |
---
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| TypeScript compilation (strict) | `pnpm --filter @familysync/api typecheck` | exit 0, no output | PASS |
| All 10 reminderScheduler tests pass | `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts` | 1 file passed, 10/10 tests passed | PASS |
| `gt(dtstartUtc, now)` lower bound present | `grep -n "gt(" reminderScheduler.ts` | line 103: `gt(calendarEvents.dtstartUtc, now)` | PASS |
| `gte` import/usage removed | `grep -n "gte" reminderScheduler.ts` | no matches | PASS |
| `minuteBucket` variable fully removed | `grep -n "minuteBucket" reminderScheduler.ts` | only in comments, no variable | PASS |
| Dedup store is `Map<string, number>` keyed by uid | `grep -n "Map" reminderScheduler.ts` | line 47: `new Map<string, number>()` | PASS |
| Lead-accurate body with Math.max guard | `grep -n "Math.max" reminderScheduler.ts` | line 150: `Math.max(1, Math.round(...))` | PASS |
| WR-01 mark-after-dispatch preserved | `grep -n "sentReminders.set" reminderScheduler.ts` | line 179: after fan-out loop | PASS |
| CR-01 started-event pruning | `grep -n "dtstartMs" reminderScheduler.ts` | lines 192-194: prune on `dtstartMs <= now.getTime()` | PASS |
| Required test names present | grep for SINGLE-FIRE, MISSED, already-started in test file | all found at lines 178, 214, 119 | PASS |
| Behavior | Command | Result | Status |
| ------------------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------ |
| TypeScript compilation (strict) | `pnpm --filter @familysync/api typecheck` | exit 0, no output | PASS |
| All 10 reminderScheduler tests pass | `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts` | 1 file passed, 10/10 tests passed | PASS |
| `gt(dtstartUtc, now)` lower bound present | `grep -n "gt(" reminderScheduler.ts` | line 103: `gt(calendarEvents.dtstartUtc, now)` | PASS |
| `gte` import/usage removed | `grep -n "gte" reminderScheduler.ts` | no matches | PASS |
| `minuteBucket` variable fully removed | `grep -n "minuteBucket" reminderScheduler.ts` | only in comments, no variable | PASS |
| Dedup store is `Map<string, number>` keyed by uid | `grep -n "Map" reminderScheduler.ts` | line 47: `new Map<string, number>()` | PASS |
| Lead-accurate body with Math.max guard | `grep -n "Math.max" reminderScheduler.ts` | line 150: `Math.max(1, Math.round(...))` | PASS |
| WR-01 mark-after-dispatch preserved | `grep -n "sentReminders.set" reminderScheduler.ts` | line 179: after fan-out loop | PASS |
| CR-01 started-event pruning | `grep -n "dtstartMs" reminderScheduler.ts` | lines 192-194: prune on `dtstartMs <= now.getTime()` | PASS |
| Required test names present | grep for SINGLE-FIRE, MISSED, already-started in test file | all found at lines 178, 214, 119 | PASS |
---
@@ -13,33 +13,33 @@ requirements:
- QUICK-i4x
must_haves:
truths:
- "The three background workers schedule their callbacks with setInterval, not node-cron schedule()"
- "No worker file imports node-cron"
- "runPoll / runOutboxDrain / runReminderCheck callback bodies are unchanged (still .catch-wrapped)"
- "Interval timings are preserved: poller 5 min, outbox 15 s, reminder 1 min"
- "apps/api typechecks clean and the broker unit tests still pass"
- 'The three background workers schedule their callbacks with setInterval, not node-cron schedule()'
- 'No worker file imports node-cron'
- 'runPoll / runOutboxDrain / runReminderCheck callback bodies are unchanged (still .catch-wrapped)'
- 'Interval timings are preserved: poller 5 min, outbox 15 s, reminder 1 min'
- 'apps/api typechecks clean and the broker unit tests still pass'
artifacts:
- path: "apps/api/src/broker/poller.ts"
provides: "startBrokerPoller scheduling runPoll via setInterval(5min)"
contains: "setInterval"
- path: "apps/api/src/broker/outboxWorker.ts"
provides: "startOutboxWorker scheduling runOutboxDrain via setInterval(15s)"
contains: "setInterval"
- path: "apps/api/src/broker/reminderScheduler.ts"
provides: "startReminderScheduler scheduling runReminderCheck via setInterval(1min)"
contains: "setInterval"
- path: 'apps/api/src/broker/poller.ts'
provides: 'startBrokerPoller scheduling runPoll via setInterval(5min)'
contains: 'setInterval'
- path: 'apps/api/src/broker/outboxWorker.ts'
provides: 'startOutboxWorker scheduling runOutboxDrain via setInterval(15s)'
contains: 'setInterval'
- path: 'apps/api/src/broker/reminderScheduler.ts'
provides: 'startReminderScheduler scheduling runReminderCheck via setInterval(1min)'
contains: 'setInterval'
key_links:
- from: "apps/api/src/broker/poller.ts"
to: "runPoll"
via: "setInterval(cb, 5 * 60 * 1000)"
- from: 'apps/api/src/broker/poller.ts'
to: 'runPoll'
via: 'setInterval(cb, 5 * 60 * 1000)'
pattern: "setInterval\\("
- from: "apps/api/src/broker/outboxWorker.ts"
to: "runOutboxDrain"
via: "setInterval(cb, 15 * 1000)"
- from: 'apps/api/src/broker/outboxWorker.ts'
to: 'runOutboxDrain'
via: 'setInterval(cb, 15 * 1000)'
pattern: "setInterval\\("
- from: "apps/api/src/broker/reminderScheduler.ts"
to: "runReminderCheck"
via: "setInterval(cb, 60 * 1000)"
- from: 'apps/api/src/broker/reminderScheduler.ts'
to: 'runReminderCheck'
via: 'setInterval(cb, 60 * 1000)'
pattern: "setInterval\\("
---
@@ -68,8 +68,10 @@ intervals, with the now-unused `node-cron` import removed and stale doc comments
<context>
@./CLAUDE.md
# The three worker files — only the start* functions and their doc comments change.
# The three worker files — only the start\* functions and their doc comments change.
# Do NOT modify runPoll / runOutboxDrain / runReminderCheck logic.
@apps/api/src/broker/poller.ts
@apps/api/src/broker/outboxWorker.ts
@apps/api/src/broker/reminderScheduler.ts
@@ -108,35 +110,36 @@ current pattern (the workers run for the process lifetime).
Update the now-stale doc comments that reference node-cron so they describe setInterval, keeping
the WHY. Specifically:
- poller.ts: the file-header line "runs every 5 minutes via node-cron", the
"startBrokerPoller wraps it in node-cron's 5-minute schedule." line, the
"Source: https://github.com/node-cron/node-cron ..." source line, and the
"Starts the 5-minute background polling schedule." block.
- outboxWorker.ts: "startOutboxWorker wraps it in a 15-second node-cron schedule." and the
"Source: https://github.com/node-cron/node-cron (v4 stable)" line, plus the
"Starts the 15-second background outbox drain schedule." block.
- reminderScheduler.ts: the file-header "Fires every minute via node-cron." line and the
"Start the 1-minute reminder scan schedule." block comment ("keeps the cron out of the test
process" → setInterval phrasing).
Each updated comment must briefly state the WHY (node-cron 4.2.1 skipped scheduled executions
in the long-running server process, so scheduling uses setInterval instead). Keep edits brief —
do not rewrite the surrounding decision/threat-mitigation prose. Leave the `node-cron` dependency
in package.json (now unused, harmless); it is removable later but removing it now risks lockfile
drift and is out of scope for this tight change.
- poller.ts: the file-header line "runs every 5 minutes via node-cron", the
"startBrokerPoller wraps it in node-cron's 5-minute schedule." line, the
"Source: https://github.com/node-cron/node-cron ..." source line, and the
"Starts the 5-minute background polling schedule." block.
- outboxWorker.ts: "startOutboxWorker wraps it in a 15-second node-cron schedule." and the
"Source: https://github.com/node-cron/node-cron (v4 stable)" line, plus the
"Starts the 15-second background outbox drain schedule." block.
- reminderScheduler.ts: the file-header "Fires every minute via node-cron." line and the
"Start the 1-minute reminder scan schedule." block comment ("keeps the cron out of the test
process" → setInterval phrasing).
Each updated comment must briefly state the WHY (node-cron 4.2.1 skipped scheduled executions
in the long-running server process, so scheduling uses setInterval instead). Keep edits brief —
do not rewrite the surrounding decision/threat-mitigation prose. Leave the `node-cron` dependency
in package.json (now unused, harmless); it is removable later but removing it now risks lockfile
drift and is out of scope for this tight change.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && ! grep -rn "node-cron" apps/api/src/broker/poller.ts apps/api/src/broker/outboxWorker.ts apps/api/src/broker/reminderScheduler.ts | grep -v '^\s*[0-9]*:.*//' ; grep -c "setInterval" apps/api/src/broker/poller.ts apps/api/src/broker/outboxWorker.ts apps/api/src/broker/reminderScheduler.ts</automated>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api typecheck</automated>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api exec vitest run tests/broker/</automated>
<automated>cd /home/luc/Projects/familysync && ! grep -rn "node-cron" apps/api/src/broker/poller.ts apps/api/src/broker/outboxWorker.ts apps/api/src/broker/reminderScheduler.ts | grep -v '^\s*[0-9]*:._//' ; grep -c "setInterval" apps/api/src/broker/poller.ts apps/api/src/broker/outboxWorker.ts apps/api/src/broker/reminderScheduler.ts</automated>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api typecheck</automated>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api exec vitest run tests/broker/</automated>
</verify>
<done>
All three files import setInterval-based scheduling with no remaining `import { schedule } from 'node-cron'`;
each start* function calls setInterval at the correct interval (poller 300000 ms, outbox 15000 ms,
reminder 60000 ms) with its original callback body intact; `pnpm --filter @familysync/api typecheck`
exits 0; and `pnpm --filter @familysync/api exec vitest run tests/broker/` reports all broker tests
green (runPoll / runOutboxDrain / runReminderCheck behavior unchanged).
All three files import setInterval-based scheduling with no remaining `import { schedule } from 'node-cron'`;
each start_ function calls setInterval at the correct interval (poller 300000 ms, outbox 15000 ms,
reminder 60000 ms) with its original callback body intact; `pnpm --filter @familysync/api typecheck`
exits 0; and `pnpm --filter @familysync/api exec vitest run tests/broker/` reports all broker tests
green (runPoll / runOutboxDrain / runReminderCheck behavior unchanged).
</done>
</task>
</task>
</tasks>
@@ -1,6 +1,6 @@
---
phase: quick-260610-i4x
plan: "01"
plan: '01'
subsystem: broker
tags: [scheduler, setInterval, node-cron, fix]
dependency_graph:
@@ -17,9 +17,9 @@ key_files:
- apps/api/src/broker/outboxWorker.ts
- apps/api/src/broker/reminderScheduler.ts
decisions:
- "Use setInterval for broker worker scheduling: node-cron 4.2.1 silently skipped executions in the long-running API process; setInterval is reliable in the same process"
- 'Use setInterval for broker worker scheduling: node-cron 4.2.1 silently skipped executions in the long-running API process; setInterval is reliable in the same process'
metrics:
completed: "2026-06-10"
completed: '2026-06-10'
---
# Quick Task 260610-i4x: Replace node-cron with setInterval in Broker Workers Summary
@@ -28,23 +28,26 @@ metrics:
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Swap node-cron schedule() for setInterval in the three worker start* functions | d9efbc1 | poller.ts, outboxWorker.ts, reminderScheduler.ts |
| Task | Name | Commit | Files |
| ---- | ------------------------------------------------------------------------------- | ------- | ------------------------------------------------ |
| 1 | Swap node-cron schedule() for setInterval in the three worker start\* functions | d9efbc1 | poller.ts, outboxWorker.ts, reminderScheduler.ts |
## Changes Made
### apps/api/src/broker/poller.ts
- Removed `import { schedule } from 'node-cron'`
- `startBrokerPoller`: `schedule('*/5 * * * *', cb)``setInterval(cb, 5 * 60 * 1000)`
- Updated file-header and JSDoc comments to reference setInterval and document the WHY
### apps/api/src/broker/outboxWorker.ts
- Removed `import { schedule } from 'node-cron'`
- `startOutboxWorker`: `schedule('*/15 * * * * *', cb)``setInterval(cb, 15 * 1000)`
- Updated header comment and scheduler JSDoc
### apps/api/src/broker/reminderScheduler.ts
- Removed `import { schedule } from 'node-cron'`
- `startReminderScheduler`: `schedule('* * * * *', cb)``setInterval(cb, 60 * 1000)`
- Updated file-header "Fires every minute" line and scheduler JSDoc
@@ -16,48 +16,48 @@ score: 5/5 must-haves verified
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | The three background workers schedule their callbacks with setInterval, not node-cron schedule() | VERIFIED | Each start* function contains `setInterval(()=>{…}, N)` at lines poller.ts:96, outboxWorker.ts:758, reminderScheduler.ts:210. Zero `schedule(` calls remain in any of the three files. |
| 2 | No worker file imports node-cron | VERIFIED | All remaining `node-cron` text is inside JSDoc block comments (WHY context). `grep -n "import.*node-cron"` returns no matches. Commit d9efbc1 stat confirms only 3 files changed. |
| 3 | runPoll / runOutboxDrain / runReminderCheck callback bodies are unchanged (still .catch-wrapped) | VERIFIED | poller.ts:97 `runPoll().catch(...)`, outboxWorker.ts:759 `runOutboxDrain().catch(...)`, reminderScheduler.ts:211 `runReminderCheck().catch(...)` — identical catch wrappers present. |
| 4 | Interval timings are preserved: poller 5 min, outbox 15 s, reminder 1 min | VERIFIED | poller.ts:100 `5 * 60 * 1000` (300000 ms), outboxWorker.ts:762 `15 * 1000` (15000 ms), reminderScheduler.ts:214 `60 * 1000` (60000 ms). |
| 5 | apps/api typechecks clean and the broker unit tests still pass | VERIFIED | `pnpm --filter @familysync/api typecheck` exited 0 (no output). `pnpm --filter @familysync/api exec vitest run tests/broker/` reported 8 test files passed, 91 tests passed. |
| # | Truth | Status | Evidence |
| --- | ------------------------------------------------------------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | The three background workers schedule their callbacks with setInterval, not node-cron schedule() | VERIFIED | Each start\* function contains `setInterval(()=>{…}, N)` at lines poller.ts:96, outboxWorker.ts:758, reminderScheduler.ts:210. Zero `schedule(` calls remain in any of the three files. |
| 2 | No worker file imports node-cron | VERIFIED | All remaining `node-cron` text is inside JSDoc block comments (WHY context). `grep -n "import.*node-cron"` returns no matches. Commit d9efbc1 stat confirms only 3 files changed. |
| 3 | runPoll / runOutboxDrain / runReminderCheck callback bodies are unchanged (still .catch-wrapped) | VERIFIED | poller.ts:97 `runPoll().catch(...)`, outboxWorker.ts:759 `runOutboxDrain().catch(...)`, reminderScheduler.ts:211 `runReminderCheck().catch(...)` — identical catch wrappers present. |
| 4 | Interval timings are preserved: poller 5 min, outbox 15 s, reminder 1 min | VERIFIED | poller.ts:100 `5 * 60 * 1000` (300000 ms), outboxWorker.ts:762 `15 * 1000` (15000 ms), reminderScheduler.ts:214 `60 * 1000` (60000 ms). |
| 5 | apps/api typechecks clean and the broker unit tests still pass | VERIFIED | `pnpm --filter @familysync/api typecheck` exited 0 (no output). `pnpm --filter @familysync/api exec vitest run tests/broker/` reported 8 test files passed, 91 tests passed. |
**Score:** 5/5 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/src/broker/poller.ts` | startBrokerPoller scheduling runPoll via setInterval(5min) | VERIFIED | setInterval at line 96, `5 * 60 * 1000` at line 100, `runPoll().catch(...)` callback |
| `apps/api/src/broker/outboxWorker.ts` | startOutboxWorker scheduling runOutboxDrain via setInterval(15s) | VERIFIED | setInterval at line 758, `15 * 1000` at line 762, `runOutboxDrain().catch(...)` callback |
| Artifact | Expected | Status | Details |
| ------------------------------------------ | ------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------ |
| `apps/api/src/broker/poller.ts` | startBrokerPoller scheduling runPoll via setInterval(5min) | VERIFIED | setInterval at line 96, `5 * 60 * 1000` at line 100, `runPoll().catch(...)` callback |
| `apps/api/src/broker/outboxWorker.ts` | startOutboxWorker scheduling runOutboxDrain via setInterval(15s) | VERIFIED | setInterval at line 758, `15 * 1000` at line 762, `runOutboxDrain().catch(...)` callback |
| `apps/api/src/broker/reminderScheduler.ts` | startReminderScheduler scheduling runReminderCheck via setInterval(1min) | VERIFIED | setInterval at line 210, `60 * 1000` at line 214, `runReminderCheck().catch(...)` callback |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `apps/api/src/broker/poller.ts` | runPoll | `setInterval(cb, 5 * 60 * 1000)` | VERIFIED | Line 96-100 in startBrokerPoller |
| `apps/api/src/broker/outboxWorker.ts` | runOutboxDrain | `setInterval(cb, 15 * 1000)` | VERIFIED | Line 758-762 in startOutboxWorker |
| `apps/api/src/broker/reminderScheduler.ts` | runReminderCheck | `setInterval(cb, 60 * 1000)` | VERIFIED | Line 210-214 in startReminderScheduler |
| From | To | Via | Status | Details |
| ------------------------------------------ | ---------------- | -------------------------------- | -------- | -------------------------------------- |
| `apps/api/src/broker/poller.ts` | runPoll | `setInterval(cb, 5 * 60 * 1000)` | VERIFIED | Line 96-100 in startBrokerPoller |
| `apps/api/src/broker/outboxWorker.ts` | runOutboxDrain | `setInterval(cb, 15 * 1000)` | VERIFIED | Line 758-762 in startOutboxWorker |
| `apps/api/src/broker/reminderScheduler.ts` | runReminderCheck | `setInterval(cb, 60 * 1000)` | VERIFIED | Line 210-214 in startReminderScheduler |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| TypeScript compiles clean | `pnpm --filter @familysync/api typecheck` | exit 0, no output | PASS |
| Broker unit tests pass | `pnpm --filter @familysync/api exec vitest run tests/broker/` | 8 files / 91 tests passed | PASS |
| Behavior | Command | Result | Status |
| ------------------------- | ------------------------------------------------------------- | ------------------------- | ------ |
| TypeScript compiles clean | `pnpm --filter @familysync/api typecheck` | exit 0, no output | PASS |
| Broker unit tests pass | `pnpm --filter @familysync/api exec vitest run tests/broker/` | 8 files / 91 tests passed | PASS |
### Scope Containment
| Check | Result |
|-------|--------|
| Check | Result |
| ----------------------------------- | ------------------------------------------------------------------------------- |
| Commit d9efbc1 touches only 3 files | VERIFIED — git show stat: outboxWorker.ts, poller.ts, reminderScheduler.ts only |
| index.ts not modified | VERIFIED — not in commit stat |
| package.json not modified | VERIFIED — not in commit stat |
| pnpm-lock.yaml not modified | VERIFIED — not in commit stat |
| No `.unref()` added | VERIFIED — grep returns no matches in any of the three files |
| index.ts not modified | VERIFIED — not in commit stat |
| package.json not modified | VERIFIED — not in commit stat |
| pnpm-lock.yaml not modified | VERIFIED — not in commit stat |
| No `.unref()` added | VERIFIED — grep returns no matches in any of the three files |
### Anti-Patterns Found
@@ -14,29 +14,29 @@ requirements: [UAT-05-T4]
must_haves:
truths:
- "Tapping 'How to enable' in SettingsSheet's permission-denied hint opens the OS-specific instruction dialog (role=\"dialog\") instead of closing the sheet"
- "PermissionDeniedBanner still opens its instruction sheet identically after the extraction (behaviour-preserving)"
- "The instruction sheet copy/markup/styles are unchanged from the original local InstructionSheet"
- 'Tapping ''How to enable'' in SettingsSheet''s permission-denied hint opens the OS-specific instruction dialog (role="dialog") instead of closing the sheet'
- 'PermissionDeniedBanner still opens its instruction sheet identically after the extraction (behaviour-preserving)'
- 'The instruction sheet copy/markup/styles are unchanged from the original local InstructionSheet'
artifacts:
- path: "apps/pwa/src/components/InstructionSheet.tsx"
provides: "Shared InstructionSheet bottom-sheet dialog + isIOS/IOS_STEPS/ANDROID_STEPS, named export"
contains: "export function InstructionSheet"
- path: "apps/pwa/src/components/SettingsSheet.tsx"
provides: "Permission-denied hint wired to open InstructionSheet"
contains: "setInstructionsOpen"
- path: "apps/pwa/src/components/PermissionDeniedBanner.tsx"
provides: "Banner importing the shared InstructionSheet (local copy removed)"
contains: "import { InstructionSheet }"
- path: "apps/pwa/src/components/InstructionSheet.test.tsx"
- path: 'apps/pwa/src/components/InstructionSheet.tsx'
provides: 'Shared InstructionSheet bottom-sheet dialog + isIOS/IOS_STEPS/ANDROID_STEPS, named export'
contains: 'export function InstructionSheet'
- path: 'apps/pwa/src/components/SettingsSheet.tsx'
provides: 'Permission-denied hint wired to open InstructionSheet'
contains: 'setInstructionsOpen'
- path: 'apps/pwa/src/components/PermissionDeniedBanner.tsx'
provides: 'Banner importing the shared InstructionSheet (local copy removed)'
contains: 'import { InstructionSheet }'
- path: 'apps/pwa/src/components/InstructionSheet.test.tsx'
provides: "Test asserting SettingsSheet 'How to enable' opens role=dialog rather than calling onClose"
key_links:
- from: "apps/pwa/src/components/SettingsSheet.tsx"
to: "apps/pwa/src/components/InstructionSheet.tsx"
via: "named import + instructionsOpen state render"
- from: 'apps/pwa/src/components/SettingsSheet.tsx'
to: 'apps/pwa/src/components/InstructionSheet.tsx'
via: 'named import + instructionsOpen state render'
pattern: "import \\{ InstructionSheet \\}.*InstructionSheet\\.js"
- from: "apps/pwa/src/components/PermissionDeniedBanner.tsx"
to: "apps/pwa/src/components/InstructionSheet.tsx"
via: "named import"
- from: 'apps/pwa/src/components/PermissionDeniedBanner.tsx'
to: 'apps/pwa/src/components/InstructionSheet.tsx'
via: 'named import'
pattern: "import \\{ InstructionSheet \\}.*InstructionSheet\\.js"
---
@@ -69,6 +69,7 @@ Output: New shared `InstructionSheet.tsx`; PermissionDeniedBanner refactored to
Create `apps/pwa/src/components/InstructionSheet.tsx`. Move VERBATIM from PermissionDeniedBanner.tsx (lines ~25-202): the `isIOS()` function, the `IOS_STEPS` and `ANDROID_STEPS` constants, the `InstructionSheetProps` interface, and the `InstructionSheet` component. Keep markup, inline styles, CSS-var design tokens, role="dialog"/aria-modal/aria-label, the X close button, the numbered steps, and the Done button BYTE-FOR-BYTE identical — this is a behaviour-preserving extraction, NOT a redesign (UI-SPEC §Surface; T-05-24: copy stays plain-text JSX children, no dangerouslySetInnerHTML). Change `function InstructionSheet` to `export function InstructionSheet` (named export). Carry the `import { X } from 'lucide-react'` into the new file (only X is needed there). `isIOS()` MUST move with the component since it selects the step list + platform label.
Edit `apps/pwa/src/components/PermissionDeniedBanner.tsx`: delete the now-moved `isIOS()`, `IOS_STEPS`, `ANDROID_STEPS`, `InstructionSheetProps`, and local `InstructionSheet` (the "OS detection", "Instruction steps", and "Instruction sheet" sections). Add `import { InstructionSheet } from './InstructionSheet.js'` (match the existing `.js`-specifier convention used by `usePushSubscription.js`). The remaining import from `lucide-react` keeps `AlertCircle`; drop `X` from that import ONLY if no longer used in this file (grep confirms — X was only used inside the moved InstructionSheet). Leave the `PermissionDeniedBanner` function body unchanged — it already references `InstructionSheet` and `setInstructionsOpen`, now satisfied by the import. Do NOT touch the banner's render/visibility logic.
</action>
<verify>
<automated>cd apps/pwa && grep -q "export function InstructionSheet" src/components/InstructionSheet.tsx && grep -q "import { InstructionSheet } from './InstructionSheet.js'" src/components/PermissionDeniedBanner.tsx && ! grep -q "function InstructionSheet" src/components/PermissionDeniedBanner.tsx && pnpm --filter @familysync/pwa typecheck</automated>
@@ -87,6 +88,7 @@ Output: New shared `InstructionSheet.tsx`; PermissionDeniedBanner refactored to
Edit `apps/pwa/src/components/SettingsSheet.tsx`: `useState` is already imported (line 24) — no import change needed there. Add `import { InstructionSheet } from './InstructionSheet.js'` alongside the other component imports. Inside the `SettingsSheet` function, add `const [instructionsOpen, setInstructionsOpen] = useState(false)` near the other useState hooks. Change the broken "How to enable" button (currently `onClick={onClose}`, ~line 373) to `onClick={() => setInstructionsOpen(true)}`. At the end of the returned fragment (after the closing `</div>` of the Sheet, before the fragment's closing `</>`), render `{instructionsOpen && (<InstructionSheet onClose={() => setInstructionsOpen(false)} />)}`. The InstructionSheet is `position: fixed; zIndex: 1000` so it correctly layers above the settings sheet (zIndex 301). Do NOT change the disabled-toggle behaviour or any other logic — only the guidance link was broken; staying unable to re-enable in-app while browser-blocked is correct.
Create `apps/pwa/src/components/InstructionSheet.test.tsx` following the existing harness style (vitest + @testing-library/react + `.js` import specifiers, see InstallPrompt.test.tsx). Write the test FIRST and watch it fail against the un-wired SettingsSheet (RED), then make it pass (GREEN). Mock the `usePushSubscription` hook (`vi.mock('../hooks/usePushSubscription.js', ...)`) to return `{ permission: 'denied', isSubscribed: false, subscribe: vi.fn(), setEnabled: vi.fn() }` so the permission-denied hint renders. Test: render `<SettingsSheet isOpen={true} onClose={onCloseSpy} />`, assert no `role="dialog"` named "How to enable notifications" exists yet, click the "How to enable" button (`screen.getByText('How to enable')`), then assert (a) a dialog with the "How to enable notifications" heading is now visible and (b) `onCloseSpy` was NOT called. Keep it pragmatic — if jsdom lacks `Notification`, define a minimal `globalThis.Notification = { permission: 'denied' }` stub in the test before render. Do not over-engineer; one wiring assertion is sufficient.
</action>
<verify>
<automated>cd apps/pwa && grep -q "onClick={() => setInstructionsOpen(true)}" src/components/SettingsSheet.tsx && grep -q "instructionsOpen && " src/components/SettingsSheet.tsx && ! grep -q "onClick={onClose}\s*$" <(grep -A1 "How to enable" src/components/SettingsSheet.tsx) ; pnpm --filter @familysync/pwa test -- src/components/InstructionSheet.test.tsx</automated>
@@ -117,10 +119,11 @@ Output: New shared `InstructionSheet.tsx`; PermissionDeniedBanner refactored to
</verification>
<success_criteria>
- A browser-blocked user opening Settings and tapping "How to enable" sees the OS-specific instruction dialog (the original UAT-05-T4 failure is fixed).
- PermissionDeniedBanner behaves identically to before (behaviour-preserving extraction).
- InstructionSheet markup/copy/styles unchanged; no new dependencies; no dangerouslySetInnerHTML.
</success_criteria>
</success_criteria>
<output>
Create `.planning/quick/260610-jlp-fix-broken-how-to-enable-link-in-notific/260610-jlp-SUMMARY.md` when done
@@ -18,11 +18,11 @@ key_files:
- apps/pwa/src/components/PermissionDeniedBanner.tsx
- apps/pwa/src/components/SettingsSheet.tsx
decisions:
- "Extracted InstructionSheet verbatim from PermissionDeniedBanner; zero markup/copy/style changes (behaviour-preserving extraction)"
- "SettingsSheet uses local instructionsOpen state to gate rendering InstructionSheet, not onClose"
- 'Extracted InstructionSheet verbatim from PermissionDeniedBanner; zero markup/copy/style changes (behaviour-preserving extraction)'
- 'SettingsSheet uses local instructionsOpen state to gate rendering InstructionSheet, not onClose'
metrics:
duration: ~8 minutes
completed: "2026-06-10"
completed: '2026-06-10'
requirements: [UAT-05-T4]
---
@@ -32,11 +32,11 @@ requirements: [UAT-05-T4]
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Extract InstructionSheet into shared component + rewire PermissionDeniedBanner | 74b5d44 | InstructionSheet.tsx (new), PermissionDeniedBanner.tsx |
| 2 | Wire SettingsSheet "How to enable" + write test | 874c030 | SettingsSheet.tsx, InstructionSheet.test.tsx (new) |
| 3 | Full typecheck + build + test gate | (no code change) | — |
| Task | Name | Commit | Files |
| ---- | ------------------------------------------------------------------------------ | ---------------- | ------------------------------------------------------ |
| 1 | Extract InstructionSheet into shared component + rewire PermissionDeniedBanner | 74b5d44 | InstructionSheet.tsx (new), PermissionDeniedBanner.tsx |
| 2 | Wire SettingsSheet "How to enable" + write test | 874c030 | SettingsSheet.tsx, InstructionSheet.test.tsx (new) |
| 3 | Full typecheck + build + test gate | (no code change) | — |
## Quality Gate Results
@@ -16,27 +16,27 @@ must_haves:
- "When @hono/oidc-auth produces a valid session this request (c.get('oidcAuthJwt') is truthy), the response re-sets the oidc-auth cookie with a persistent Max-Age so the browser keeps it across PWA/tab close"
- "When no valid session JWT is on context (logged-out / deleted / never-set request, c.get('oidcAuthJwt') is falsy), NO oidc-auth Set-Cookie is emitted — the middleware never resurrects a deleted or absent cookie (the critical correctness/security guard)"
- "The re-issued cookie carries httpOnly:true, secure:true, sameSite:'Lax', and the domain attribute only when OIDC_COOKIE_DOMAIN is set, mirroring the library's conditional-domain logic"
- "pnpm --filter @familysync/api typecheck exits 0"
- 'pnpm --filter @familysync/api typecheck exits 0'
artifacts:
- path: "apps/api/src/auth/persistSessionCookie.ts"
provides: "persistSessionCookie() Hono MiddlewareHandler that upgrades the session-scoped oidc-auth cookie to a persistent one"
exports: ["persistSessionCookie"]
- path: 'apps/api/src/auth/persistSessionCookie.ts'
provides: 'persistSessionCookie() Hono MiddlewareHandler that upgrades the session-scoped oidc-auth cookie to a persistent one'
exports: ['persistSessionCookie']
min_lines: 25
- path: "apps/api/tests/auth/persistSessionCookie.test.ts"
provides: "Vitest unit tests for the persist + guard behaviors"
contains: "persistSessionCookie"
- path: "apps/api/src/index.ts"
provides: "Mounting of persistSessionCookie immediately after oidcAuthMiddleware inside the !devBypassActive block"
contains: "persistSessionCookie"
- path: 'apps/api/tests/auth/persistSessionCookie.test.ts'
provides: 'Vitest unit tests for the persist + guard behaviors'
contains: 'persistSessionCookie'
- path: 'apps/api/src/index.ts'
provides: 'Mounting of persistSessionCookie immediately after oidcAuthMiddleware inside the !devBypassActive block'
contains: 'persistSessionCookie'
key_links:
- from: "apps/api/src/index.ts"
to: "apps/api/src/auth/persistSessionCookie.ts"
- from: 'apps/api/src/index.ts'
to: 'apps/api/src/auth/persistSessionCookie.ts'
via: "import + app.use('/api/*', persistSessionCookie()) inside if (!devBypassActive)"
pattern: "persistSessionCookie\\(\\)"
- from: "apps/api/src/auth/persistSessionCookie.ts"
to: "oidcAuthJwt context var (set by @hono/oidc-auth)"
- from: 'apps/api/src/auth/persistSessionCookie.ts'
to: 'oidcAuthJwt context var (set by @hono/oidc-auth)'
via: "c.get('oidcAuthJwt') read; cookie re-issued only when truthy"
pattern: "oidcAuthJwt"
pattern: 'oidcAuthJwt'
---
<objective>
@@ -63,11 +63,17 @@ Output: New middleware file + its tests, wired into index.ts.
@apps/api/tests/auth/devBypass.test.ts
# Ground truth on the library's cookie + context behavior (do NOT edit the library):
# - @hono/oidc-auth/dist/index.js sets a session-scoped cookie via
# setCookie(c, OIDC_COOKIE_NAME, session_jwt, { path, httpOnly:true, secure:true [, domain if OIDC_COOKIE_DOMAIN] })
# with NO maxAge/expires, then immediately calls c.set('oidcAuthJwt', session_jwt).
# setCookie(c, OIDC_COOKIE_NAME, session_jwt, { path, httpOnly:true, secure:true [, domain if OIDC_COOKIE_DOMAIN] })
# with NO maxAge/expires, then immediately calls c.set('oidcAuthJwt', session_jwt).
# - oidcAuthJwt is set ONLY on requests where a valid session is created/refreshed.
# Logged-out / no-session requests do NOT set it. This is the guard signal.
# Logged-out / no-session requests do NOT set it. This is the guard signal.
</context>
<tasks>
@@ -99,6 +105,7 @@ Output: New middleware file + its tests, wired into index.ts.
4. Then `await next()`.
Keep it tiny. Do NOT import or call into @hono/oidc-auth. Do NOT add new dependencies (hono/cookie ships with hono, already a dependency). Do NOT place fenced code blocks in production comments.
</action>
<verify>
<automated>pnpm --filter @familysync/api typecheck</automated>
@@ -123,6 +130,7 @@ Output: New middleware file + its tests, wired into index.ts.
- Test B (guard path — the security property): build a Hono app that does NOT set oidcAuthJwt, run `persistSessionCookie()`, hit a GET /api/test. Assert `res.headers.get('set-cookie')` is null OR does not contain the oidc-auth cookie name — i.e. no resurrection when there is no session JWT.
- Read the cookie NAME the same way the implementation does (`process.env.OIDC_COOKIE_NAME ?? 'oidc-auth'`) so the test stays correct if the env override is set; default 'oidc-auth' otherwise.
Do NOT call the real @hono/oidc-auth in the test — set the context var directly. Do NOT add DB/integration setup (keep it a pure unit test so it runs under the tests/auth/ filter without MariaDB).
</action>
<verify>
<automated>pnpm --filter @familysync/api typecheck && pnpm --filter @familysync/api exec vitest run tests/auth/</automated>
@@ -140,10 +148,11 @@ Output: New middleware file + its tests, wired into index.ts.
</verification>
<success_criteria>
- A logged-in member who closes the PWA and reopens it later (within OIDC_AUTH_EXPIRES) is NOT bounced to Authelia — the oidc-auth cookie now has a Max-Age and persists.
- A logged-out / no-session request never receives an oidc-auth Set-Cookie from this middleware (no resurrection).
- No changes to @hono/oidc-auth, package.json, or .env.
</success_criteria>
</success_criteria>
<notes>
- Tuning the session lifetime is config-only, no code change: raising `OIDC_AUTH_EXPIRES` in .env (e.g. `2592000` for 30 days) extends both the server-side JWT lifetime AND the persisted cookie's Max-Age — the new middleware reads `OIDC_AUTH_EXPIRES` for maxAge automatically. The effective ceiling is still bounded by Authelia's refresh_token_lifespan.
@@ -37,14 +37,15 @@ metrics:
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Create persistSessionCookie() middleware | aabcb5d | apps/api/src/auth/persistSessionCookie.ts |
| 2 | Wire into index.ts + unit tests | 8343fad | apps/api/src/index.ts, apps/api/tests/auth/persistSessionCookie.test.ts |
| Task | Name | Commit | Files |
| ---- | ---------------------------------------- | ------- | ----------------------------------------------------------------------- |
| 1 | Create persistSessionCookie() middleware | aabcb5d | apps/api/src/auth/persistSessionCookie.ts |
| 2 | Wire into index.ts + unit tests | 8343fad | apps/api/src/index.ts, apps/api/tests/auth/persistSessionCookie.test.ts |
## Verification Results
### typecheck
```
$ pnpm --filter @familysync/api typecheck
$ tsc --noEmit
@@ -52,6 +53,7 @@ $ tsc --noEmit
```
### vitest run tests/auth/
```
RUN v4.1.8 /home/luc/Projects/familysync/apps/api
@@ -62,6 +64,7 @@ $ tsc --noEmit
```
All 3 auth test files pass (devBypass, user, persistSessionCookie). 14/14 tests green including:
- Test A (persist path): truthy oidcAuthJwt → Set-Cookie with Max-Age, SameSite=Lax, HttpOnly, Secure
- Test B (guard path): absent oidcAuthJwt → no oidc-auth Set-Cookie emitted
- Guard variant: empty string oidcAuthJwt → no resurrection
@@ -17,39 +17,39 @@ overrides_applied: 0
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Truthy oidcAuthJwt → response re-sets oidc-auth cookie with Max-Age, SameSite=Lax, HttpOnly, Secure | VERIFIED | `persistSessionCookie.ts` lines 4776: guard passes, `setCookie(c, name, jwt, { httpOnly:true, secure:true, sameSite:'Lax', maxAge, ... })` called before `await next()` |
| 2 | Falsy/absent oidcAuthJwt → NO oidc-auth Set-Cookie emitted (no resurrection guard) | VERIFIED | Lines 4750: `if (!jwt) { await next(); return }` — hard early exit before any `setCookie` call; covered by Test B (absent) and guard variant (empty string), both passing |
| 3 | Cookie carries httpOnly:true, secure:true, sameSite:'Lax'; domain included only when OIDC_COOKIE_DOMAIN set | VERIFIED | Options object built lines 6369 (always includes httpOnly/secure/sameSite); `if (process.env.OIDC_COOKIE_DOMAIN)` guard at line 71 adds domain key only when env var is set — key absent (not undefined) when unset |
| 4 | `pnpm --filter @familysync/api typecheck` exits 0 | VERIFIED | Run output: `$ tsc --noEmit` — exit 0, no diagnostic output |
| # | Truth | Status | Evidence |
| --- | ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Truthy oidcAuthJwt → response re-sets oidc-auth cookie with Max-Age, SameSite=Lax, HttpOnly, Secure | VERIFIED | `persistSessionCookie.ts` lines 4776: guard passes, `setCookie(c, name, jwt, { httpOnly:true, secure:true, sameSite:'Lax', maxAge, ... })` called before `await next()` |
| 2 | Falsy/absent oidcAuthJwt → NO oidc-auth Set-Cookie emitted (no resurrection guard) | VERIFIED | Lines 4750: `if (!jwt) { await next(); return }` — hard early exit before any `setCookie` call; covered by Test B (absent) and guard variant (empty string), both passing |
| 3 | Cookie carries httpOnly:true, secure:true, sameSite:'Lax'; domain included only when OIDC_COOKIE_DOMAIN set | VERIFIED | Options object built lines 6369 (always includes httpOnly/secure/sameSite); `if (process.env.OIDC_COOKIE_DOMAIN)` guard at line 71 adds domain key only when env var is set — key absent (not undefined) when unset |
| 4 | `pnpm --filter @familysync/api typecheck` exits 0 | VERIFIED | Run output: `$ tsc --noEmit` — exit 0, no diagnostic output |
**Score:** 4/4 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/src/auth/persistSessionCookie.ts` | persistSessionCookie() MiddlewareHandler; min 25 lines | VERIFIED | 81 lines; exports `persistSessionCookie(): MiddlewareHandler`; imports only `hono` and `hono/cookie` — no new dependencies |
| `apps/api/tests/auth/persistSessionCookie.test.ts` | Vitest unit tests; contains "persistSessionCookie" | VERIFIED | 123 lines; 4 tests across 2 describe groups; pure unit test (no MariaDB, no @hono/oidc-auth import) |
| `apps/api/src/index.ts` | Mounts persistSessionCookie immediately after oidcAuthMiddleware inside !devBypassActive | VERIFIED | Line 14: import present; lines 5254: `app.use('/api/*', oidcAuthMiddleware())` followed immediately by `app.use('/api/*', persistSessionCookie())` — both inside `if (!devBypassActive)` block |
| Artifact | Expected | Status | Details |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/api/src/auth/persistSessionCookie.ts` | persistSessionCookie() MiddlewareHandler; min 25 lines | VERIFIED | 81 lines; exports `persistSessionCookie(): MiddlewareHandler`; imports only `hono` and `hono/cookie` — no new dependencies |
| `apps/api/tests/auth/persistSessionCookie.test.ts` | Vitest unit tests; contains "persistSessionCookie" | VERIFIED | 123 lines; 4 tests across 2 describe groups; pure unit test (no MariaDB, no @hono/oidc-auth import) |
| `apps/api/src/index.ts` | Mounts persistSessionCookie immediately after oidcAuthMiddleware inside !devBypassActive | VERIFIED | Line 14: import present; lines 5254: `app.use('/api/*', oidcAuthMiddleware())` followed immediately by `app.use('/api/*', persistSessionCookie())` — both inside `if (!devBypassActive)` block |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `apps/api/src/index.ts` | `apps/api/src/auth/persistSessionCookie.ts` | `import { persistSessionCookie } from './auth/persistSessionCookie.js'` + `app.use('/api/*', persistSessionCookie())` inside `if (!devBypassActive)` | VERIFIED | Import at line 14; usage at line 54; ordering correct — line 53 oidcAuthMiddleware, line 54 persistSessionCookie |
| `apps/api/src/auth/persistSessionCookie.ts` | oidcAuthJwt context var (@hono/oidc-auth) | `c.get('oidcAuthJwt' as never)` read; cookie re-issued only when truthy | VERIFIED | Line 40: `const jwt = c.get('oidcAuthJwt' as never) as string | undefined`; line 47: falsy guard; no import of @hono/oidc-auth |
| From | To | Via | Status | Details |
| ------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `apps/api/src/index.ts` | `apps/api/src/auth/persistSessionCookie.ts` | `import { persistSessionCookie } from './auth/persistSessionCookie.js'` + `app.use('/api/*', persistSessionCookie())` inside `if (!devBypassActive)` | VERIFIED | Import at line 14; usage at line 54; ordering correct — line 53 oidcAuthMiddleware, line 54 persistSessionCookie |
| `apps/api/src/auth/persistSessionCookie.ts` | oidcAuthJwt context var (@hono/oidc-auth) | `c.get('oidcAuthJwt' as never)` read; cookie re-issued only when truthy | VERIFIED | Line 40: `const jwt = c.get('oidcAuthJwt' as never) as string | undefined`; line 47: falsy guard; no import of @hono/oidc-auth |
### Behavioral Spot-Checks (Vitest)
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Full tests/auth/ suite | `pnpm --filter @familysync/api exec vitest run tests/auth/` | 3 test files, 14 tests, all passed | PASS |
| Test A — persist path (Max-Age + SameSite=Lax + HttpOnly + Secure present) | included in suite above | passes | PASS |
| Test A variant — same JWT value re-issued unchanged | included in suite above | passes | PASS |
| Test B — guard path (absent oidcAuthJwt → no Set-Cookie) | included in suite above | passes | PASS |
| Test B variant — empty string oidcAuthJwt → no resurrection | included in suite above | passes | PASS |
| Behavior | Command | Result | Status |
| -------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------- | ------ |
| Full tests/auth/ suite | `pnpm --filter @familysync/api exec vitest run tests/auth/` | 3 test files, 14 tests, all passed | PASS |
| Test A — persist path (Max-Age + SameSite=Lax + HttpOnly + Secure present) | included in suite above | passes | PASS |
| Test A variant — same JWT value re-issued unchanged | included in suite above | passes | PASS |
| Test B — guard path (absent oidcAuthJwt → no Set-Cookie) | included in suite above | passes | PASS |
| Test B variant — empty string oidcAuthJwt → no resurrection | included in suite above | passes | PASS |
### Anti-Patterns Found
@@ -57,12 +57,12 @@ None. No TBD/FIXME/XXX markers, no placeholder returns, no empty handlers, no ha
### Scope Constraint Verification
| Constraint | Status | Evidence |
|------------|--------|----------|
| No changes to @hono/oidc-auth | VERIFIED | Only files modified: persistSessionCookie.ts (new), index.ts (import + 2 lines), persistSessionCookie.test.ts (new) |
| No changes to package.json | VERIFIED | No package.json in modified files list; only `hono/cookie` used, which ships with the existing `hono` dependency |
| No changes to .env | VERIFIED | Not in modified files list |
| Cookie set BEFORE await next() | VERIFIED | `setCookie(c, name, jwt, options)` at line 76; `await next()` at line 78 |
| Constraint | Status | Evidence |
| --------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| No changes to @hono/oidc-auth | VERIFIED | Only files modified: persistSessionCookie.ts (new), index.ts (import + 2 lines), persistSessionCookie.test.ts (new) |
| No changes to package.json | VERIFIED | No package.json in modified files list; only `hono/cookie` used, which ships with the existing `hono` dependency |
| No changes to .env | VERIFIED | Not in modified files list |
| Cookie set BEFORE await next() | VERIFIED | `setCookie(c, name, jwt, options)` at line 76; `await next()` at line 78 |
| Middleware NOT mounted under dev bypass | VERIFIED | Both `oidcAuthMiddleware()` and `persistSessionCookie()` registrations are inside `if (!devBypassActive)` block (index.ts lines 5155) |
### Human Verification Required
@@ -12,21 +12,21 @@ requirements: [NOTIF-FIX-01, NOTIF-FIX-02]
must_haves:
truths:
- "Android push notifications alert audibly/visibly (heads-up + sound/vibration), not silently, even when the notification tag is reused."
- "iOS push notifications continue to display correctly (added options are ignored by iOS; payload parsing and declarative/legacy logic unchanged)."
- "pwa typecheck and build both succeed, emitting dist/sw.js."
- "Re-enable instructions name a browser-agnostic Chromium browser (Chrome or Edge), not Chrome only."
- 'Android push notifications alert audibly/visibly (heads-up + sound/vibration), not silently, even when the notification tag is reused.'
- 'iOS push notifications continue to display correctly (added options are ignored by iOS; payload parsing and declarative/legacy logic unchanged).'
- 'pwa typecheck and build both succeed, emitting dist/sw.js.'
- 'Re-enable instructions name a browser-agnostic Chromium browser (Chrome or Edge), not Chrome only.'
artifacts:
- path: "apps/pwa/src/sw.ts"
provides: "Push handler showNotification call enriched with icon, badge, renotify, vibrate"
contains: "renotify: true"
- path: "apps/pwa/src/components/InstructionSheet.tsx"
provides: "Browser-agnostic ANDROID_STEPS first step"
contains: "Chrome or Edge"
- path: 'apps/pwa/src/sw.ts'
provides: 'Push handler showNotification call enriched with icon, badge, renotify, vibrate'
contains: 'renotify: true'
- path: 'apps/pwa/src/components/InstructionSheet.tsx'
provides: 'Browser-agnostic ANDROID_STEPS first step'
contains: 'Chrome or Edge'
key_links:
- from: "apps/pwa/src/sw.ts push handler"
to: "self.registration.showNotification options object"
via: "NotificationOptions with renotify+vibrate+icon+badge"
- from: 'apps/pwa/src/sw.ts push handler'
to: 'self.registration.showNotification options object'
via: 'NotificationOptions with renotify+vibrate+icon+badge'
pattern: "showNotification\\(title"
---
@@ -63,11 +63,11 @@ In the `push` event handler, modify ONLY the `self.registration.showNotification
Do NOT set `silent` (it must stay falsy/absent). Do NOT touch the payload parsing, the iOS-18.4+ declarative-vs-legacy branch, the try/catch fallback, `event.waitUntil`, or the `notificationclick` handler. iOS ignores these added options, so its path is unchanged.
TS caveat: `renotify` and `vibrate` are valid NotificationOptions members but some `lib.dom` TS versions omit/deprecate them on `ServiceWorkerRegistration.showNotification`. If typecheck errors on these keys, resolve it MINIMALLY: declare a `const options: NotificationOptions = { ... }` and pass it (with a narrow `as NotificationOptions` cast only if still required). Do NOT broaden suppression or silence unrelated errors. Build success is the load-bearing gate.
</action>
<verify>
<automated>pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa build && test -f apps/pwa/dist/sw.js && grep -q "renotify" apps/pwa/dist/sw.js</automated>
</verify>
<done>showNotification options include icon, badge, renotify:true, and vibrate; body/tag/data unchanged; `silent` absent; typecheck passes; vite build succeeds and emits apps/pwa/dist/sw.js containing the renotify option.</done>
</action>
<verify>
<automated>pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa build && test -f apps/pwa/dist/sw.js && grep -q "renotify" apps/pwa/dist/sw.js</automated>
</verify>
<done>showNotification options include icon, badge, renotify:true, and vibrate; body/tag/data unchanged; `silent` absent; typecheck passes; vite build succeeds and emits apps/pwa/dist/sw.js containing the renotify option.</done>
</task>
<task type="auto">
@@ -93,10 +93,11 @@ In the `ANDROID_STEPS` array, change the first step from `'Open Chrome on your p
</verification>
<success_criteria>
- sw.ts showNotification call includes icon, badge, renotify:true, vibrate; body/tag/data and all parsing/waitUntil logic unchanged; no `silent` key.
- InstructionSheet ANDROID_STEPS is browser-agnostic (Chrome or Edge).
- typecheck + build + test all pass; dist/sw.js emitted with the fix.
</success_criteria>
</success_criteria>
<output>
Create `.planning/quick/260610-ka9-fix-silent-android-push-notifications-en/260610-ka9-SUMMARY.md` when done.
@@ -15,7 +15,7 @@ key_files:
- apps/pwa/src/sw.ts
- apps/pwa/src/components/InstructionSheet.tsx
decisions:
- "Used `as NotificationOptions` narrow cast (not type suppression) to handle renotify/vibrate absent from this lib.dom; cast is scoped to the single options object"
- 'Used `as NotificationOptions` narrow cast (not type suppression) to handle renotify/vibrate absent from this lib.dom; cast is scoped to the single options object'
metrics:
duration: ~5 min
completed: 2026-06-10
@@ -27,10 +27,10 @@ metrics:
## Tasks Completed
| # | Task | Commit | Files |
|---|------|--------|-------|
| 1 | Enrich showNotification options to fix silent Android push | c864fc4 | apps/pwa/src/sw.ts |
| 2 | Make Android re-enable instructions browser-agnostic | c864fc4 | apps/pwa/src/components/InstructionSheet.tsx |
| # | Task | Commit | Files |
| --- | ---------------------------------------------------------- | ------- | -------------------------------------------- |
| 1 | Enrich showNotification options to fix silent Android push | c864fc4 | apps/pwa/src/sw.ts |
| 2 | Make Android re-enable instructions browser-agnostic | c864fc4 | apps/pwa/src/components/InstructionSheet.tsx |
## Changes Made
@@ -73,6 +73,7 @@ pnpm --filter @familysync/pwa test
### Auto-fixed Issues
**1. [Rule 1 - Bug] TypeScript error: renotify absent from NotificationOptions in this lib.dom**
- **Found during:** Task 1 typecheck
- **Issue:** `tsc --noEmit` reported `error TS2353: Object literal may only specify known properties, and 'renotify' does not exist in type 'NotificationOptions'` (same for `vibrate`).
- **Fix:** Changed `const options: NotificationOptions = { ... }` to `const options = { ... } as NotificationOptions`. The cast is minimal and scoped to the single options object; no other errors suppressed.
@@ -13,28 +13,28 @@ requirements: [WR-01]
must_haves:
truths:
- "On a pull_request to main, Gitea no longer creates a CI / publish (pull_request) commit status (no orphan pending status)."
- "On push to main (PR merge), the Publish workflow builds and pushes git.bergerhouse.net/luckberg/familysync-api with :latest and :<MILESTONE>-<shortsha> tags."
- "The three required PR status contexts (CI / fast-checks, CI / api, CI / harness) are unchanged in name and behavior."
- "A maintainer reading the README and publish.yml header can determine how, when, and under what safety gate publishing happens, plus how to bump MILESTONE."
- 'On a pull_request to main, Gitea no longer creates a CI / publish (pull_request) commit status (no orphan pending status).'
- 'On push to main (PR merge), the Publish workflow builds and pushes git.bergerhouse.net/luckberg/familysync-api with :latest and :<MILESTONE>-<shortsha> tags.'
- 'The three required PR status contexts (CI / fast-checks, CI / api, CI / harness) are unchanged in name and behavior.'
- 'A maintainer reading the README and publish.yml header can determine how, when, and under what safety gate publishing happens, plus how to bump MILESTONE.'
artifacts:
- path: ".gitea/workflows/publish.yml"
provides: "Standalone push-to-main image publish workflow with documented release model"
contains: "name: Publish"
- path: ".gitea/workflows/ci.yml"
provides: "PR-only CI workflow (fast-checks, api, harness); no publish job, no push trigger, no MILESTONE env"
contains: "name: CI"
- path: "README.md"
provides: "Release / image-publishing documentation section"
contains: "Publishing"
- path: '.gitea/workflows/publish.yml'
provides: 'Standalone push-to-main image publish workflow with documented release model'
contains: 'name: Publish'
- path: '.gitea/workflows/ci.yml'
provides: 'PR-only CI workflow (fast-checks, api, harness); no publish job, no push trigger, no MILESTONE env'
contains: 'name: CI'
- path: 'README.md'
provides: 'Release / image-publishing documentation section'
contains: 'Publishing'
key_links:
- from: ".gitea/workflows/publish.yml"
to: "secrets.REGISTRY_PAT"
via: "docker login --password-stdin"
- from: '.gitea/workflows/publish.yml'
to: 'secrets.REGISTRY_PAT'
via: 'docker login --password-stdin'
pattern: "secrets\\.REGISTRY_PAT"
- from: ".gitea/workflows/publish.yml"
to: "env.MILESTONE"
via: "Compute image tags step reads workflow-level MILESTONE"
- from: '.gitea/workflows/publish.yml'
to: 'env.MILESTONE'
via: 'Compute image tags step reads workflow-level MILESTONE'
pattern: "env\\.MILESTONE"
---
@@ -72,16 +72,17 @@ Create `.gitea/workflows/publish.yml` as a standalone push-only workflow:
- Add a header comment block (see Task 2 — same content as the README section, condensed) at the very top of publish.yml above `name: Publish`.
Then edit `.gitea/workflows/ci.yml`:
- Remove the entire `publish:` job (current lines 314-362).
- Remove the `push:` trigger key from `on:` (lines 6-7), leaving only `pull_request: branches: [main]`.
- Remove the workflow-level `env: MILESTONE: v1.1` block (lines 9-10) — it was referenced ONLY by the publish job (confirmed: grep ci.yml for MILESTONE returns only the publish Compute-image-tags step). Do not leave an empty `env:` key.
- Do NOT rename `name: CI` or the job ids `fast-checks` / `api` / `harness`, and do NOT remove their `if: github.event_name == 'pull_request'` guards — renaming or removing would change/break the required status contexts (`CI / fast-checks (pull_request)`, `CI / api (pull_request)`, `CI / harness (pull_request)`). The guards are harmless now that `push:` is gone; leave them.
</action>
<verify>
<automated>test -f .gitea/workflows/publish.yml && grep -q 'name: Publish' .gitea/workflows/publish.yml && grep -q 'secrets.REGISTRY_PAT' .gitea/workflows/publish.yml && grep -q '--password-stdin' .gitea/workflows/publish.yml && grep -q 'MILESTONE: v1.1' .gitea/workflows/publish.yml && grep -Eq '^\s*push:' .gitea/workflows/publish.yml && ! grep -q "github.event_name == 'push'" .gitea/workflows/publish.yml && ! grep -q 'publish:' .gitea/workflows/ci.yml && ! grep -q 'MILESTONE' .gitea/workflows/ci.yml && ! grep -Eq '^\s*push:' .gitea/workflows/ci.yml && grep -q 'pull_request:' .gitea/workflows/ci.yml && grep -q 'name: CI' .gitea/workflows/ci.yml && grep -c 'if:' .gitea/workflows/ci.yml | grep -qE '^[3-9]'</automated>
<automated>test -f .gitea/workflows/publish.yml && grep -q 'name: Publish' .gitea/workflows/publish.yml && grep -q 'secrets.REGISTRY_PAT' .gitea/workflows/publish.yml && grep -q '--password-stdin' .gitea/workflows/publish.yml && grep -q 'MILESTONE: v1.1' .gitea/workflows/publish.yml && grep -Eq '^\s*push:' .gitea/workflows/publish.yml && ! grep -q "github.event_name == 'push'" .gitea/workflows/publish.yml && ! grep -q 'publish:' .gitea/workflows/ci.yml && ! grep -q 'MILESTONE' .gitea/workflows/ci.yml && ! grep -Eq '^\s*push:' .gitea/workflows/ci.yml && grep -q 'pull_request:' .gitea/workflows/ci.yml && grep -q 'name: CI' .gitea/workflows/ci.yml && grep -c 'if:' .gitea/workflows/ci.yml | grep -qE '^[3-9]'</automated>
</verify>
<done>publish.yml exists with name=Publish, push-to-main-only trigger, no redundant if-guard, workflow-level MILESTONE, the four publish steps with all inline comments intact, and a header doc block. ci.yml has no publish job, no push trigger, no MILESTONE env, retains name=CI and all three PR jobs with their guards.</done>
</task>
</task>
<task type="auto">
<name>Task 2: Document the release model and verify YAML well-formedness</name>
@@ -98,11 +99,11 @@ Then write the SAME information condensed into the header comment block at the t
Finally verify both workflow YAML files are well-formed. No `yamllint`/`act`/`js-yaml`/`pyyaml` is available locally (confirmed during planning: no YAML parser in any node_modules, no pyyaml, no ruby yaml). Docker IS available, so parse both files strictly with the purpose-built yq image (no network beyond the image pull, no repo deps):
`docker run --rm -i mikefarah/yq:4 e '.' - < .gitea/workflows/publish.yml` and likewise for ci.yml — a malformed file makes yq exit non-zero. If the yq image cannot be pulled (offline), fall back to structural inspection: re-read both files end-to-end, confirm consistent 2-space indentation, that every `run: |` block body is indented under its key, that the moved publish steps parse as a list under `jobs.publish.steps`, and explicitly NOTE in the SUMMARY that YAML was verified by inspection only (no parser available).
</action>
<verify>
<automated>grep -qi 'Publishing\|Releases' README.md && grep -q 'REGISTRY_PAT' README.md && grep -q 'familysync-api' README.md && grep -q 'MILESTONE' README.md && grep -q 'branch protection' README.md && grep -qi 'REGISTRY_PAT' .gitea/workflows/publish.yml && (docker run --rm -i mikefarah/yq:4 e '.' - < .gitea/workflows/publish.yml >/dev/null 2>&1 && docker run --rm -i mikefarah/yq:4 e '.' - < .gitea/workflows/ci.yml >/dev/null 2>&1 || echo 'YAML-PARSER-UNAVAILABLE-INSPECTED-MANUALLY')</automated>
</verify>
<done>README has a Publishing/Releases section covering all six points (auto-on-push-to-main, image+two-tags, REGISTRY_PAT secret + naming, branch-protection safety gate, no test needs:, MILESTONE bump). publish.yml header block carries the condensed same. Both YAML files parse cleanly under yq (or are noted as inspected-only if no parser pulled).</done>
</action>
<verify>
<automated>grep -qi 'Publishing\|Releases' README.md && grep -q 'REGISTRY_PAT' README.md && grep -q 'familysync-api' README.md && grep -q 'MILESTONE' README.md && grep -q 'branch protection' README.md && grep -qi 'REGISTRY_PAT' .gitea/workflows/publish.yml && (docker run --rm -i mikefarah/yq:4 e '.' - < .gitea/workflows/publish.yml >/dev/null 2>&1 && docker run --rm -i mikefarah/yq:4 e '.' - < .gitea/workflows/ci.yml >/dev/null 2>&1 || echo 'YAML-PARSER-UNAVAILABLE-INSPECTED-MANUALLY')</automated>
</verify>
<done>README has a Publishing/Releases section covering all six points (auto-on-push-to-main, image+two-tags, REGISTRY_PAT secret + naming, branch-protection safety gate, no test needs:, MILESTONE bump). publish.yml header block carries the condensed same. Both YAML files parse cleanly under yq (or are noted as inspected-only if no parser pulled).</done>
</task>
</tasks>
@@ -115,11 +116,12 @@ Finally verify both workflow YAML files are well-formed. No `yamllint`/`act`/`js
</verification>
<success_criteria>
- A PR to `main` produces only `CI / fast-checks`, `CI / api`, `CI / harness` statuses — no `CI / publish` orphan.
- A merge to `main` triggers the `Publish` workflow, building/pushing `familysync-api:latest` + `familysync-api:v1.1-<shortsha>`.
- No behavior change to the three PR jobs; required checks still satisfiable.
- Release process is discoverable in README and in the publish.yml header.
</success_criteria>
</success_criteria>
<output>
Create `.planning/quick/260611-ozt-split-publish-job-into-standalone-gitea-/260611-ozt-SUMMARY.md` when done.
@@ -17,12 +17,12 @@ key_files:
- .gitea/workflows/ci.yml
- README.md
decisions:
- "D-OZT-01: Safety gate is branch protection on main (not needs:) — publish.yml runs in a separate workflow invocation from ci.yml PR jobs"
- 'D-OZT-01: Safety gate is branch protection on main (not needs:) — publish.yml runs in a separate workflow invocation from ci.yml PR jobs'
- "D-OZT-02: README is the documentation home for the release model (not a separate docs/RELEASE.md) — consistent with this project's single-maintainer pattern"
- "D-OZT-03: Dropped the redundant if: github.event_name == 'push' guard — push-to-main trigger in publish.yml fully replaces it"
metrics:
duration: ~5 minutes
completed: "2026-06-11"
completed: '2026-06-11'
tasks_completed: 2
tasks_total: 2
files_changed: 3
@@ -37,6 +37,7 @@ Split the `publish` job out of `.gitea/workflows/ci.yml` into a new standalone `
### .gitea/workflows/publish.yml (created)
New standalone push-only workflow:
- `name: Publish`, `on: push: branches: [main]` only
- Workflow-level `MILESTONE: v1.1` env (moved from ci.yml)
- Single `publish` job with all four steps verbatim from ci.yml: checkout, compute image tags, docker login, build+push, docker logout
@@ -56,6 +57,7 @@ New standalone push-only workflow:
### README.md (modified)
Added "Publishing / Releases" section between "Deployment" and "License" covering:
- Auto-trigger on push to main (PR merge)
- Image name and two-tag scheme (:latest + :<MILESTONE>-<shortsha>)
- REGISTRY_PAT secret requirement and naming rationale
@@ -68,14 +70,15 @@ Added "Publishing / Releases" section between "Deployment" and "License" coverin
## Commits
| Hash | Message |
|------|---------|
| 6efc062 | chore(260611-ozt): split publish job into standalone publish.yml |
| Hash | Message |
| ------- | ------------------------------------------------------------------------------ |
| 6efc062 | chore(260611-ozt): split publish job into standalone publish.yml |
| 0c9139b | docs(260611-ozt): document release model in README Publishing/Releases section |
## YAML Verification
Both workflow files validated with `docker run --rm -i mikefarah/yq:4 e '.' -`:
- `.gitea/workflows/publish.yml`: **VALID**
- `.gitea/workflows/ci.yml`: **VALID**