Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
55875b9
chore: add eslint flat config for typescript and react 19
eloraju Sep 15, 2026
00f56a7
test: specify Result, red
eloraju Sep 15, 2026
6bfe07c
feat: add Result for errors as values
eloraju Sep 15, 2026
a920d5f
chore: add signatures for the Phase 1 pure core
eloraju Sep 15, 2026
76e2ca5
test: specify can() and the REST schemas, red
eloraju Sep 15, 2026
66dd926
feat: implement can() over a constant Role map
eloraju Sep 15, 2026
6f37330
feat: implement the REST surface schemas
eloraju Sep 15, 2026
bccb9f4
chore: add better-auth, kysely and kysely-postgres-js
eloraju Sep 15, 2026
457079a
test: migration runner, signing secret and PUBLIC_URL
eloraju Sep 15, 2026
c5d7d25
feat: apply numbered SQL migrations on boot
eloraju Sep 15, 2026
e9048a7
feat: anonymous sessions on the shared Bun.sql pool
eloraju Sep 15, 2026
a3a9939
feat: docker compose deployment with app and postgres
eloraju Sep 15, 2026
ef005e7
test: specify that an empty note means no note, red
eloraju Sep 15, 2026
ebb1d99
test: overturn the cases the empty-means-nothing ruling contradicts
eloraju Sep 15, 2026
21be976
test: specify that an empty unit means no unit, red
eloraju Sep 15, 2026
4935db0
feat: add lists, items and memberships tables
eloraju Sep 15, 2026
39abc66
test: specify the REST surface against a real Postgres, red
eloraju Sep 15, 2026
7fe933d
test: specify the migration runner's failure boundaries, red
eloraju Sep 15, 2026
95d343c
feat: implement the Lists and Items endpoints
eloraju Sep 15, 2026
8264711
fix: keep an unreadable migration file inside the Result boundary
eloraju Sep 15, 2026
daedf7c
fix: stop a failed lock release masking the migration error
eloraju Sep 15, 2026
73d764b
fix: detect an applied migration whose file is gone
eloraju Sep 15, 2026
425f429
feat: add the React UI and mount the API routes
eloraju Sep 15, 2026
4c4345e
feat: normalise an emptied note or unit instead of rejecting it
eloraju Sep 15, 2026
097d2d5
test: specify the client session bootstrap, red
eloraju Sep 15, 2026
3817ae5
refactor: stop the server ordering Lists
eloraju Sep 15, 2026
8893840
fix: only a definitive no-session may sign in
eloraju Sep 15, 2026
ae4fc4b
feat: bootstrap the session in the client, not the page handler
eloraju Sep 15, 2026
e6c1067
fix: run the Lists index through can() like every other endpoint
eloraju Sep 15, 2026
08f80e1
refactor: decide the creator's Role in the domain, not in the insert
eloraju Sep 15, 2026
8822505
fix: name the field an over-long unit or note belongs to
eloraju Sep 15, 2026
ef35487
fix: wrap the session check at its boundary
eloraju Sep 15, 2026
14220b3
fix: sort the List picker in the client
eloraju Sep 15, 2026
d8a2d85
fix: reconcile with the server when an instant action is refused
eloraju Sep 15, 2026
fee0163
fix: report a quantity that is not a number instead of clearing it
eloraju Sep 15, 2026
476d926
docs: record Phase 0 and Phase 1 as delivered
eloraju Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
dist
.git
.env
*.log
docs
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copy to .env, then `docker compose up`.

# The origin browsers use to reach this app — the only origin source in the app (ADR-0008).
# It sets the session cookie's Secure flag, the WebSocket URL and Invite link origins.
# Examples: http://localhost:3000 · http://192.168.1.50:3000 · https://lists.example.com
PUBLIC_URL=http://localhost:3000

# Where the app finds Postgres. docker-compose.yml overrides this with the compose-network
# address; it is here for running the app outside compose (`bun run dev`).
DATABASE_URL=postgres://slist:slist@localhost:5432/slist

# Credentials for the bundled postgres service. Change the password for anything reachable
# beyond your own machine.
POSTGRES_USER=slist
POSTGRES_PASSWORD=slist
POSTGRES_DB=slist

# Host port to publish the app on.
APP_PORT=3000

# Optional. Left empty, a signing secret is generated on first boot and persisted in the
# database, so sessions survive a restart (ADR-0008).
# AUTH_SECRET=
19 changes: 19 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Bun 1.4 or later is required: pre-1.4 `bun:sql` could return one query's rows to another when a
# parameterless and a parameterised query shared a connection (oven-sh/bun#32772), which an auth
# adapter hits routinely (ADR-0007).
FROM oven/bun:1.4-alpine

WORKDIR /app

# Dependencies first, so a source change does not reinstall them.
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY . .

ENV NODE_ENV=production
EXPOSE 3000

# One process serves the API, the WebSocket and the bundled frontend — same origin, no CORS
# (ADR-0008). Migrations run inside this process before it binds.
CMD ["bun", "src/index.ts"]
602 changes: 598 additions & 4 deletions bun.lock

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Throwaway Postgres for `bun test`. Tests run against a real database, never a
# mock (CONVENTIONS.md, "Tests"). Port 55433 so it cannot collide with the
# postgres service in docker-compose.yml.
name: slist-test

services:
postgres-test:
image: postgres:17-alpine
environment:
POSTGRES_USER: slist
POSTGRES_PASSWORD: slist
POSTGRES_DB: slist_test
ports:
- "55433:5432"
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U slist -d slist_test"]
interval: 2s
timeout: 3s
retries: 15
39 changes: 39 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# `docker compose up` with a copied .env must produce a working app (ADR-0008).
services:
app:
build: .
env_file: .env
environment:
# The app reaches Postgres over the compose network, whatever the operator's .env says about
# reaching it from their laptop.
DATABASE_URL: postgres://${POSTGRES_USER:-slist}:${POSTGRES_PASSWORD:-slist}@postgres:5432/${POSTGRES_DB:-slist}
PORT: 3000
ports:
- "${APP_PORT:-3000}:3000"
depends_on:
postgres:
# The app migrates on boot, so it must not start against a Postgres that is still
# initialising its data directory.
condition: service_healthy
restart: unless-stopped

postgres:
# Major version pinned: a Postgres major upgrade needs an explicit data migration, never a
# surprise on `docker compose pull`.
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-slist}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-slist}
POSTGRES_DB: ${POSTGRES_DB:-slist}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-slist} -d ${POSTGRES_DB:-slist}"]
interval: 2s
timeout: 3s
retries: 30
start_period: 10s
restart: unless-stopped

volumes:
postgres-data:
38 changes: 38 additions & 0 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ discovering at the end that nobody else can run it.
**Done when**: a stranger clones, copies `.env.example`, runs `docker compose
up`, opens `PUBLIC_URL`, and gets a session cookie for a new Anonymous Account.

**Delivered** (#1). The spike passed on every check, so Better Auth runs on one
shared `Bun.sql` pool via `PostgresJSDialect` and the `pg` fallback was not
needed.

One decision differs from the shape described above: **session bootstrap is
client-side**. `/*` serves the bundle and does no session work; the React app
calls the anonymous sign-in on boot when it finds no session, behind a gate. A
server-side shell handler only covered exact `/`, so deep links and refreshes
got no Account. A failed or errored session check must never trigger sign-in —
only a definitive "no session" may — because minting a new Anonymous Account
over a network blip strands that visitor's Lists with no recovery (ADR-0003).

## Phase 1 — Lists and Items, single user

A complete, usable single-user app.
Expand All @@ -59,6 +71,32 @@ redone without it — decide before starting Phase 2.

**Done when**: one person can keep a real shopping list on one device.

**Delivered** (#2). Three decisions taken during the work that the scope above
does not carry:

- **The server orders nothing, Lists included.** Said here of Items; it now
holds for Lists too, sorted client-side by `byName`.
- **Empty means nothing, for `note` and `unit`.** On create, absent, `""`,
whitespace and `null` all collapse to the key being absent; on update they
collapse to key-present-`null`. Exactly one representation of "no value"
reaches the database. `name` still rejects empty.
- The quantity/unit coupling is judged against the **payload**, not the
resulting Item — deliberate, so the check stays in the pure core.

**Process experiment: the split is judged worth keeping.** The separate test
author caught the note/unit asymmetry, the `{note: undefined}`/`toEqual` hole
and a create-side `null` inconsistency before any implementation existed to be
rewritten, and verified its coupling test was ordering-sensitive empirically
rather than assuming it. The review agent independently found that
`GET /api/lists` decided visibility in SQL without ever calling `can()` — a
capability decision rather than a Role read, which is why three agents' greps
missed it, and the one thing Phase 3 would have had to rewrite. The weakness to
carry forward: the most common failure was a first red of "cannot find module",
which proves absence rather than behaviour. Later phases require a behavioural
red.

Follow-up work from both reviews is filed as #8–#17.

## Phase 2 — Realtime

- `ws.subscribe("list:<id>")` on connect, authorised through `can()`.
Expand Down
50 changes: 50 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";

export default tseslint.config(
{ ignores: ["dist/**", "node_modules/**", "**/*.d.ts"] },

js.configs.recommended,
tseslint.configs.recommended,

{
files: ["**/*.{ts,tsx}"],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
rules: {
// A leading underscore is the opt-out: it is how a callback documents a parameter of the
// signature it is required to have but does not use.
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
],
},
},

{
files: ["**/*.{jsx,tsx}"],
...react.configs.flat.recommended,
languageOptions: {
...react.configs.flat.recommended.languageOptions,
globals: globals.browser,
},
// eslint-plugin-react-hooks v7 ships its config in ESLint 10's `plugins: [name]` form, which
// ESLint 9 rejects, so the plugin is registered by hand and only its rules are spread.
plugins: { ...react.configs.flat.recommended.plugins, "react-hooks": reactHooks },
settings: { react: { version: "detect" } },
rules: {
...react.configs.flat.recommended.rules,
// React 19's automatic JSX runtime: no React import, so no in-scope check.
...react.configs.flat["jsx-runtime"].rules,
...reactHooks.configs["recommended-latest"].rules,
},
},

// Last, so it wins: formatting lives in .prettierrc, not in lint rules.
prettier,
);
8 changes: 8 additions & 0 deletions migrations/0001_app_settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Server-side settings that must survive a restart but are not worth an env var.
-- Today that is only the auth signing secret, generated on first boot when the operator
-- did not supply one (ADR-0008).
create table app_settings (
key text primary key,
value text not null,
created_at timestamptz not null default now()
);
20 changes: 20 additions & 0 deletions migrations/0002_better_auth.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Better Auth's own tables. CLI-generated, not hand-written (ADR-0007):
-- DATABASE_URL=... bunx --bun @better-auth/cli generate \
-- --config src/auth/auth-cli.ts --output migrations/0002_better_auth.sql -y
-- Regenerate against an empty database when the auth config gains a plugin, and commit the
-- result as a new numbered file rather than editing this one — an applied migration that
-- changes on disk stops the boot.
-- "user".isAnonymous comes from the Anonymous plugin (ADR-0003).
create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" boolean not null, "image" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null, "isAnonymous" boolean);

create table "session" ("id" text not null primary key, "expiresAt" timestamptz not null, "token" text not null unique, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade);

create table "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" timestamptz, "refreshTokenExpiresAt" timestamptz, "scope" text, "password" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null);

create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" timestamptz not null, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null);

create index "session_userId_idx" on "session" ("userId");

create index "account_userId_idx" on "account" ("userId");

create index "verification_identifier_idx" on "verification" ("identifier");
45 changes: 45 additions & 0 deletions migrations/0003_lists_items_memberships.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-- Lists, their Memberships and their Items (CONTEXT.md).
--
-- There is deliberately no `position` column on items: Items have no inherent order, the server
-- never orders them, and sorting is the client's job. A column would be an invitation to start.
--
-- `memberships.role` is only ever read by `can()` (ADR-0005); the check constraint keeps the
-- column honest about the Roles that exist in code.

create table lists (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);

create table memberships (
list_id uuid not null references lists (id) on delete cascade,
account_id text not null references "user" (id) on delete cascade,
role text not null check (role in ('owner', 'editor')),
created_at timestamptz not null default now(),
primary key (list_id, account_id)
);

-- A List whose Owners have all gone keeps working (ADR-0004), so nothing here requires an Owner
-- to exist: an Ownerless List is a valid state, not a broken one.
create index memberships_account_id_idx on memberships (account_id);

create table items (
id uuid primary key default gen_random_uuid(),
list_id uuid not null references lists (id) on delete cascade,
name text not null,
-- Shopping quantities are approximate ("0.5 kg"), so a float is the right shape; nothing here
-- is money, where the rounding would matter.
quantity double precision check (quantity > 0),
unit text,
note text,
checked boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
-- A unit with nothing to measure is not a quantity; the Zod schemas say the same thing at the
-- edge, and the database refuses to hold the state they reject.
constraint items_unit_needs_quantity check (unit is null or quantity is not null)
);

create index items_list_id_idx on items (list_id);
28 changes: 22 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,31 @@
"scripts": {
"dev": "bun --hot src/index.ts",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"start": "NODE_ENV=production bun src/index.ts"
"start": "NODE_ENV=production bun src/index.ts",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "bun test",
"test:db": "docker compose -f docker-compose.test.yml up -d --wait"
},
"dependencies": {
"react": "^19",
"react-dom": "^19"
"better-auth": "^1.7.5",
"kysely": "^0.29.5",
"kysely-postgres-js": "^4.0.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest"
"@eslint/js": "^9",
"@types/bun": "latest",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"eslint": "^9",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"typescript": "^5",
"typescript-eslint": "^8.70.0"
}
}
39 changes: 0 additions & 39 deletions src/APITester.tsx

This file was deleted.

24 changes: 0 additions & 24 deletions src/App.tsx

This file was deleted.

Loading