Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
9 changed files with 241 additions and 2 deletions
Showing only changes of commit 39d4ec84c0 - Show all commits
+1
View File
@@ -19,6 +19,7 @@
"@hono/oidc-auth": "1.8.3",
"@hono/zod-validator": "0.8.0",
"drizzle-orm": "0.45.2",
"fractional-indexing": "^3.2.0",
"hono": "4.12.23",
"ical.js": "2.2.1",
"mysql2": "3.22.4",
+19
View File
@@ -0,0 +1,19 @@
/**
* Wave-0 RED stubs for listEmitter (in-memory EventEmitter fan-out).
*
* Covers D-04: SSE fan-out MUST be scoped to who can see a list.
* These are pure-unit tests — no DB or HTTP server needed.
*
* Downstream plans implement the actual listEmitter.ts module that these stubs test.
*
* Run: pnpm --filter @familysync/api test
*/
describe('listEmitter — scoped fan-out correctness (D-04)', () => {
it.todo('publishListEvent emits only to subscribers for the matching listId')
it.todo('publishListEvent does NOT emit to subscribers for a different listId')
it.todo('subscribeListEvents returns an unsubscribe function that stops future events')
it.todo('unsubscribed handler is not called after unsubscribe()')
it.todo('multiple subscribers for the same listId all receive the event')
it.todo('emitter handles 100+ concurrent subscribers without error (D-18 scale check)')
})
+42
View File
@@ -0,0 +1,42 @@
/**
* Wave-0 RED stubs for the lists API router.
*
* These stubs cover LIST-01 through LIST-04 behavior.
* Downstream plans (02/03/06) replace the `it.todo` entries with real assertions
* once the routes and DB are wired.
*
* Security focus: T-04-02 — scoped fan-out (private list events must NOT reach non-owner).
*
* Run: pnpm --filter @familysync/api test
*/
describe('GET /api/lists — LIST-01: returns only accessible lists', () => {
it.todo('returns empty array when user has no lists')
it.todo('returns lists owned by the current user')
it.todo('returns lists shared with the current user via list_shares')
it.todo('does NOT return private lists owned by another user')
})
describe('POST /api/lists — LIST-01: create list', () => {
it.todo('creates a list and inserts a list_shares row when isShared=true (D-01)')
it.todo('creates a private list with no list_shares row when isShared=false')
it.todo('rejects a name longer than 255 characters with 422')
it.todo('returns 401 when called without a session')
})
describe('PATCH /api/list-items/:id — LIST-02: per-field update', () => {
it.todo('updates only the checked field when patch body is { checked: true } (D-08)')
it.todo('updates only the text field when patch body is { text: "..." } (D-08)')
it.todo('rejects a patch body with more than one field with 422')
it.todo('returns 403 when caller does not own or share the parent list (T-04-02)')
})
describe('PATCH /api/list-items/:id rank — LIST-03: fractional reorder', () => {
it.todo('updates the rank field to the new fractional-indexing string')
it.todo('rejects an empty rank string with 422')
})
describe('SSE scoped fan-out — LIST-04 / T-04-02: private-list event isolation', () => {
it.todo('publishListEvent on a private list does NOT emit to a subscriber for a different list')
it.todo('publishListEvent on a shared list emits to all subscribers for that list')
})
+37
View File
@@ -0,0 +1,37 @@
/**
* Vitest global test setup for apps/api.
*
* Establishes shared test infrastructure for API tests:
* - DB pool access via the existing client.ts (DB_HOST/DB_NAME from env)
* - Per-test cleanup for list tables (truncate between tests so state is isolated)
*
* Usage:
* This file is referenced in vitest.config.ts via test.setupFiles.
* Pure-logic tests (listEmitter, fractional rank) do NOT require DB — the
* cleanup function is a no-op when the tables are empty.
*
* Environment:
* Set DB_HOST, DB_USER, DB_PASSWORD, DB_NAME in the test environment.
* Tests run against a local MariaDB dev database; production data is never touched.
*/
import { afterEach } from 'vitest'
import { db } from '../src/db/client.js'
import { lists, listItems, listShares } from '../src/db/schema.js'
/**
* Truncate list tables in FK-safe order after each test.
* list_items and list_shares have FK to lists; delete children first.
* Called automatically via afterEach — no per-test setup needed.
*/
afterEach(async () => {
try {
// Delete child rows first to avoid FK constraint violations
await db.delete(listItems)
await db.delete(listShares)
await db.delete(lists)
} catch {
// DB may not be available in pure-unit test runs (no DB_HOST configured).
// Swallow the error — pure-logic tests do not need cleanup.
}
})
+1
View File
@@ -4,5 +4,6 @@ export default defineConfig({
test: {
environment: 'node',
globals: true,
setupFiles: ['./test/setup.ts'],
},
})
+4
View File
@@ -11,6 +11,8 @@
"test": "vitest run"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@schedule-x/calendar": "4.6.0",
"@schedule-x/calendar-controls": "4.6.0",
"@schedule-x/event-modal": "4.6.0",
@@ -18,10 +20,12 @@
"@schedule-x/react": "4.1.0",
"@schedule-x/theme-default": "4.6.0",
"@tanstack/react-query": "5.101.0",
"fractional-indexing": "^3.2.0",
"ical.js": "2.2.1",
"lucide-react": "1.17.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.17.0",
"temporal-polyfill": "0.3.2",
"vite-plugin-pwa": "^1.3.0",
"zustand": "5.0.14"
+20
View File
@@ -0,0 +1,20 @@
/**
* Wave-0 RED stubs for useListSSE hook.
*
* Covers D-11: bounded (capped exponential) backoff before showing the
* "disconnected / updates paused" indicator.
*
* Tests use a mock EventSource that simulates connect/disconnect scenarios.
* Downstream plans implement the actual useListSSE.ts hook.
*
* Run: pnpm --filter @familysync/pwa test
*/
describe('useListSSE — D-11 bounded backoff', () => {
it.todo('starts connected when EventSource fires the open event')
it.todo('attempts to reconnect with exponential backoff on error')
it.todo('stops retrying after the configured max attempts and sets status to disconnected')
it.todo('resets backoff counter on successful reconnect')
it.todo('calls onMessage callback with parsed event data when a message arrives')
it.todo('cleans up EventSource on unmount')
})
+19
View File
@@ -0,0 +1,19 @@
/**
* Wave-0 RED stubs for ListDetail component.
*
* Covers D-07: optimistic update — the editing member's change shows instantly,
* then reconciles against the server (rollback on rejection).
*
* Downstream plans implement the actual ListDetail.tsx component.
*
* Run: pnpm --filter @familysync/pwa test
*/
describe('ListDetail — D-07 optimistic update + rollback', () => {
it.todo('checking an item immediately updates the UI before server responds')
it.todo('unchecking an item immediately updates the UI before server responds')
it.todo('rolls back the checked state if the server PATCH returns an error')
it.todo('adding an item shows it in the list immediately (optimistic insert)')
it.todo('removes the optimistically-added item if the server POST returns an error')
it.todo('completed items sink to the "completed" section at the bottom (D-05)')
})
+98 -2
View File
@@ -22,6 +22,9 @@ importers:
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(mysql2@3.22.4(@types/node@22.19.19))
fractional-indexing:
specifier: ^3.2.0
version: 3.2.0
hono:
specifier: 4.12.23
version: 4.12.23
@@ -59,6 +62,12 @@ importers:
apps/pwa:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
'@schedule-x/calendar':
specifier: 4.6.0
version: 4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2)
@@ -80,6 +89,9 @@ importers:
'@tanstack/react-query':
specifier: 5.101.0
version: 5.101.0(react@19.2.7)
fractional-indexing:
specifier: ^3.2.0
version: 3.2.0
ical.js:
specifier: 2.2.1
version: 2.2.1
@@ -92,6 +104,9 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.7(react@19.2.7)
react-router:
specifier: ^7.17.0
version: 7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
temporal-polyfill:
specifier: 0.3.2
version: 0.3.2
@@ -685,6 +700,28 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
@@ -1773,6 +1810,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
core-js-compat@3.49.0:
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
@@ -2057,6 +2098,10 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
fractional-indexing@3.2.0:
resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==}
engines: {node: ^14.13.1 || >=16.0.0}
fs-extra@9.1.0:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
@@ -2589,6 +2634,16 @@ packages:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
react-router@7.17.0:
resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react@19.2.7:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
@@ -2682,6 +2737,9 @@ packages:
resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==}
engines: {node: '>=20.0.0'}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -3880,6 +3938,31 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.7)
'@dnd-kit/utilities': 3.2.2(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@dnd-kit/utilities': 3.2.2(react@19.2.7)
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.7)':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@drizzle-team/brocli@0.10.2': {}
'@emnapi/core@1.10.0':
@@ -4660,6 +4743,8 @@ snapshots:
convert-source-map@2.0.0: {}
cookie@1.1.1: {}
core-js-compat@3.49.0:
dependencies:
browserslist: 4.28.2
@@ -4959,6 +5044,8 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fractional-indexing@3.2.0: {}
fs-extra@9.1.0:
dependencies:
at-least-node: 1.0.0
@@ -5459,6 +5546,14 @@ snapshots:
react-refresh@0.17.0: {}
react-router@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
cookie: 1.1.1
react: 19.2.7
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 19.2.7(react@19.2.7)
react@19.2.7: {}
redent@3.0.0:
@@ -5605,6 +5700,8 @@ snapshots:
serialize-javascript@7.0.5: {}
set-cookie-parser@2.7.2: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -5805,8 +5902,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
tslib@2.8.1:
optional: true
tslib@2.8.1: {}
tsx@4.22.4:
dependencies: