Plan 00 Overview - SekiroKenjii/Quizzivy GitHub Wiki
Implementation plan for docs/quizzivy-spec-v0.3.md. This file records the
decisions the spec left open, the ones I made against it, and the mechanics of
local development. Phase task lists are in 10- β¦ 15-; the schema is in
20-data-model.md.
Spec sections are referenced by number and never restated.
Current programme: Phase R, the redesign β overview,
releases R1βR11 in 71-r1.md β¦ 81-r11.md, design gaps in
docs/design/gaps.md.
Previous milestone: Word exam import, with a separate teacher UX plan and the supplied production target specification. These are planning documents; implementation progress and remaining contract decisions are tracked in the milestone plan.
The spec commits to four PostgreSQL 18 constructs (Β§13.6). All four were checked against https://www.postgresql.org/docs/18/ before any DDL was designed. All four work, but three carry caveats Β§13 does not mention.
| Construct | Docs | Verdict |
|---|---|---|
uuidv7() |
functions-uuid | Works as Β§13.2 assumes. Built-in, no extension. Signature is uuidv7([shift interval]). |
| Virtual generated columns | ddl-generated-columns | VIRTUAL is the PG18 default β "A generated column is by default of the virtual kind." final_score works, and sum() over it works. Caveat: they cannot be indexed, cannot carry UNIQUE or PRIMARY KEY, and cannot have extended statistics; they are excluded from logical replication ("only supported for stored generated columns"). They can take NOT NULL and can be referenced by a CHECK. |
OLD / NEW in RETURNING
|
dml-returning | Works in INSERT/UPDATE/DELETE/MERGE; aliases renameable. Caveat: Β§13.4's "one statement instead of read-then-write" only holds inside a data-modifying CTE (Β§4.4 below). A bare UPDATE β¦ RETURNING followed by an INSERT is still two round trips. |
NOT NULL β¦ NOT VALID |
sql-altertable, release-18 | Real in PG18 β release note: "Allow ALTER TABLE to set the NOT VALID attribute of NOT NULL constraints." Caveat: only via the table-constraint form ADD CONSTRAINT c NOT NULL col NOT VALID. It is not in the CREATE TABLE grammar (which shows NOT NULL [ NO INHERIT ] only) and not available via SET NOT NULL. |
All four were additionally verified against a live postgres:18.6 container
during T-0.1, not only read. The exact error strings are recorded in
server/internal/db/pg18_test.go (T-0.16). Two claims in the first draft of this
plan were wrong and are corrected above: virtual generated columns do accept
NOT NULL and do accept a CHECK; only indexes, UNIQUE, PRIMARY KEY and
extended statistics are rejected.
Consequence for this project: NOT NULL NOT VALID is unreachable on a
greenfield schema. Every table in 20-data-model.md declares NOT NULL
inline at CREATE TABLE time, where it is free. The construct becomes relevant
only when a nullable column is tightened against existing production rows β
realistically Phase 5 or post-v1. T-0.16 pins the behaviour with a test so it is
proven rather than remembered.
Where the Neon postgres-best-practices skill conflicts with the PG18 docs, the
docs win (Β§13.1). The one live conflict: the skill teaches the pre-18
ADD CHECK NOT VALID β VALIDATE β SET NOT NULL dance. On PG18 use the native
not-null form above.
Decision: (a) β contract-first, both sides generated from one OpenAPI document.
The load-bearing risks in v1 are all server semantics: the authoritative
deadline_at, server-side maxPlays (Β§11.4), the publish snapshot (Β§13.3), the
shuffle seed, session takeover, and refresh rotation (Β§5.2). A frontend built
against hand-written MSW mocks would encode a plausible fiction of each, and
Phase 3 would be a rewrite rather than an integration. Writing the contract first
costs one Phase 0 task and makes the mocks derived rather than invented.
With one developer, "in parallel" in practice means vertical slices: a backend endpoint and the screen that consumes it as a PR pair within the same phase. The generated contract is what makes that safe.
api/openapi.yaml β hand-authored OpenAPI 3.1 β is the single source of truth.
It supersedes Β§15, which becomes documentation. Everything else is generated.
| Target | Tool | Output |
|---|---|---|
| Go server |
oapi-codegen (strict server mode) |
server/gen/openapi/ β interfaces, request/response types, param binding |
| TS types | openapi-typescript |
web/src/lib/api/schema.d.ts β types only, zero runtime |
| MSW fixtures |
ajv at test time |
fixtures validated against the OpenAPI schema so mocks cannot drift |
Zod stays hand-written. Β§2 makes zod the source of validation and TS types;
in practice its real job is react-hook-form input validation, which is a
narrower shape than the wire type (a password field has a min length the API
schema does not express). So each feature keeps its schemas.ts, and each
request schema carries a type-level assertion:
// web/src/features/auth/schemas.ts
type Expect<T extends true> = T;
type Equal<A, B> = (<G>() => G extends A ? 1 : 2) extends
(<G>() => G extends B ? 1 : 2) ? true : false;
export const loginSchema = z.object({ email: z.string().email(), password: z.string().min(8) });
type _check = Expect<Equal<
z.infer<typeof loginSchema>,
paths['/auth/login']['post']['requestBody']['content']['application/json']
>>;Drift fails tsc, not review. No third code generator.
CI enforcement: regenerate all outputs, then git diff --exit-code. A stale
generated file is a red build. Generated files are committed so the repo is
buildable without the toolchain.
New dependencies introduced here, with reasons for the PR description
(AGENTS.md rule): openapi-typescript (dev β type generation), ajv (dev β
fixture validation), oapi-codegen (Go tool β server generation).
app.quizzivy.com serves the SPA; api.quizzivy.com serves the Go API. Both are
subdomains of one registrable domain, which makes every request between them
same-site even though it is cross-origin.
This matters because Β§5.2 specifies SameSite=Lax, and Lax cookies are never
sent on cross-site fetch. On this topology they are sent, so Β§5.2 works
verbatim. On genuinely cross-site origins (a Vercel frontend against a Fly.io
backend, say) /auth/refresh would silently never authenticate. The topology is
load-bearing; it is not a deployment detail to revisit casually.
- Refresh cookie:
httpOnly; Secure; SameSite=Lax; Path=/auth, and host-only β noDomainattribute, soapp.quizzivy.comnever receives it. The API is at the root of its host, soPath=/authmatches/auth/refreshexactly. - CORS: exact-origin allowlist (never
*, which is illegal with credentials),Access-Control-Allow-Credentials: true,Vary: Origin, and preflight handling forPATCH/DELETEplus theAuthorizationand JSON content-type headers.
Β§5.4 says the app calls GET /auth/me on load and Β§5.2 says a 401 triggers one
refresh-and-retry. With TanStack Query mounting several queries at once, a cold
load fires N parallel requests, all 401, all call POST /auth/refresh. Rotation
plus Β§5.2's reuse detection then revokes the whole family and logs the user out β
on every page refresh.
src/lib/api/client.ts therefore holds one shared in-flight refresh promise.
Concurrent 401s await the same promise; only one rotation happens. This is
unit-tested (T-0.12) because it is invisible until it isn't.
Β§10.6 flushes integrity events on pagehide via navigator.sendBeacon.
sendBeacon cannot set request headers, so it cannot carry
Authorization: Bearer. And the access token is ~15 minutes (Β§5.2) while a test
runs 45β90, so at pagehide the token is usually expired anyway β exactly when
the flush matters most.
So POST /app/assignments/:id/attempts returns an opaque beacon token,
valid until deadline_at, stored as attempts.beacon_token_hash. The beacon
body carries it:
navigator.sendBeacon(
`${API}/app/attempts/${id}/events`,
new Blob([JSON.stringify({ beaconToken, sessionId, events })], { type: 'text/plain' })
);text/plain is a CORS-safelisted content type, so the beacon skips preflight β
which is necessary, because a preflight on unload is not reliably delivered. The
endpoint accepts only event appends; it grants no read access and cannot
mutate answers, so the weaker credential buys no additional authority.
Β§13.4 wants the audit diff captured in the same statement as the mutation. That
requires the RETURNING to feed an INSERT, which means a CTE:
WITH updated AS (
UPDATE app.attempts SET deadline_at = $2
WHERE id = $1
RETURNING id, old.deadline_at AS prev, new.deadline_at AS next
)
INSERT INTO app.audit_log (actor_user_id, action, entity, entity_id, ip, user_agent, diff)
SELECT $3, 'attempt.extend', 'attempt', id, $4, $5,
jsonb_build_object('deadline_at', jsonb_build_object('old', prev, 'new', next))
FROM updated;Written as two statements it is a read-then-write with a race. T-4.2 uses this
form and T-0.16 proves old/new survive inside a CTE.
Β§7 types Assignment.status as 'scheduled' | 'open' | 'closed'. Storing it
requires a scheduler to flip rows at opens_at and closes_at. Deriving it from
opens_at, closes_at and a nullable closed_at (manual early close) needs
nothing, cannot go stale, and produces the same frontend type. Derived.
In-process: golang.org/x/time/rate limiters in a bounded LRU, keyed
independently by client IP and by join-code hash, per Β§6.5's two limits. One API
instance at this scale, so no Redis and no shared state. The limiter interface is
narrow enough that swapping in a distributed backend later is one implementation,
not a refactor. 429 responses carry Retry-After.
Note on Β§6.5's stated rationale: an 8-character code from a 32-symbol alphabet is
2^40 β 1.1 Γ 10^12, and only a handful of codes are live at a time, so random
probing is not a realistic attack even unthrottled. The rate limit is still
correct β it bounds a cheap nuisance β but the threat that actually matters is a
forwarded code. The mitigations that address that are expiry, max_uses, and
the Β§6.4 member list. See 40-open-items.md on changing the max_uses default.
-
attempt_events.kindstaystext, not an enum. Β§13.2 prefers enums for closed sets, and Β§10.1's list looks closed β but an append-only telemetry log reliably gains kinds, and each one would be a migration plus a deploy ordering problem. Lookup tables and enums both cost more than they return here. -
Local object storage is MinIO, S3-compatible, so
aws-sdk-go-v2/s3targets it and R2 with only endpoint config differing. This decouples Phases 0β2 from the R2 credential blocker. -
Migrations are sequential and zero-padded (
00001_β¦), one concern each (Β§13.7). Sequential over timestamped because there is one developer and one branch line, so ordering conflicts cannot arise and the numbers stay readable. -
REVOKE CREATE ON SCHEMA public FROM PUBLIC(Β§13.2) is kept in migration 00001 as documentation, annotated as a no-op β it has been the default since PG15.
Both of these were raised in the pre-plan critique and decided by Thuong. They are recorded here so the reasoning is not relitigated in a later session.
Version snapshot shape. I proposed keeping test_version_questions as a real
table (it is the FK target for attempt_answers and holds ordinal/points/type)
while collapsing test_version_options, test_version_blanks and
test_version_blank_answers into one jsonb column, on the grounds that snapshot
leaves are written once, always read whole, never joined, and never filtered β
and that Β§13.3's justification for normalizing them ("per-question analytics
later") is explicitly deferred by Β§1.3 and Β§16. Thuong chose full
normalization, which preserves per-option SQL analytics as a plain query at the
cost of four extra tables and a longer publish routine. 20-data-model.md
implements the normalized form.
Audio duration probing. I proposed adding ffprobe to the backend image,
since VBR MP3 without a Xing header and unusual MP4 atom layouts are exactly the
cases hand-rolled parsers get wrong. Thuong chose pure-Go, avoiding a non-Go
binary in the container at the cost of roughly a day of work and residual
edge-case risk, tracked as R-08 in 30-risks.md and mitigated by the fixture
corpus in T-2.2.
Google sign-in library (approved deviation from Β§2). Β§2 fixes Google
Identity Services as the library and Β§5.3 requires Authorization Code + PKCE.
Those turned out to be incompatible: Google's authorization server supports
PKCE β its discovery document advertises
code_challenge_methods_supported: ["plain","S256"] β but the GIS JavaScript
wrapper google.accounts.oauth2.initCodeClient documents no way to pass a
code_challenge. Thuong approved building the authorization request
directly, which keeps Β§5.3's PKCE intact at the cost of not using the GIS SDK.
Concretely: generate a code_verifier, derive an S256 code_challenge, add a
state value, and open Google's authorization_endpoint in a popup. The
callback posts { code, codeVerifier, redirectUri } to POST /auth/google,
exactly as api/openapi.yaml already specifies. No GIS script tag, and no
dependency on undocumented wrapper behaviour. The "TiαΊΏp tα»₯c vα»i Google" button
is ours either way β Β§12 dictates charcoal, not Google's default styling.
Two further disagreements were accepted and are implemented as described:
30-risks.md R-05 (the clientSeq collision, Β§13.3 deviation D-01) and the
Β§16 exit-criteria corrections in 11-phase-1.md and 12-phase-2.md.
Monorepo. Β§3's frontend structure is preserved verbatim under web/.
Quizzivy/
ββ AGENTS.md standing rules for every session
ββ docs/
β ββ quizzivy-spec-v0.3.md source of truth (moved into the repo)
β ββ plan/ this plan
ββ api/
β ββ openapi.yaml THE API contract; Β§15 is documentation
ββ web/ quizzivy-web (Β§4)
β ββ src/ exactly Β§3 β app/ features/ components/
β layouts/ lib/ hooks/ stores/ styles/
ββ server/ Go module `quizzivy` (Β§4)
β ββ cmd/api/main.go
β ββ internal/
β β ββ auth/ join/ classes/ media/ questions/ tests/
β β ββ assignments/ attempts/ grading/ integrity/ audit/
β β ββ storage/ S3/R2 client
β β ββ media/probe/ pure-Go mp3 + mp4 duration (T-2.2)
β β ββ httpx/ error envelope, cursor codec, middleware
β β ββ ratelimit/
β ββ gen/openapi/ generated, committed, never hand-edited
β ββ go.mod
ββ migrations/ goose SQL, forward-only, 00001β¦
ββ seed/ never in migrations (Β§13.7)
ββ docker-compose.yml postgres:18 + minio
ββ .github/workflows/ci.yml
web/src/features/<name>/ follows Β§3's convention unchanged: api.ts,
schemas.ts, components/, pages/, optional store.ts, index.ts for public
exports only. Features import each other only through index.ts;
components/shared never imports from features/.
Backend packages mirror the feature folders deliberately, so a vertical slice is
one frontend folder plus one backend package plus one section of
api/openapi.yaml.
Β§15 implies one (403 ACCOUNT_NOT_PROVISIONED) but never defines it. Every
non-2xx response body is:
{
"error": {
"code": "ACCOUNT_NOT_PROVISIONED",
"message": "TΓ i khoαΊ£n chΖ°a Δược cαΊ₯p quyα»n.",
"details": { "field": "joinCode" },
"requestId": "01931f4e-..."
}
}-
codeβSCREAMING_SNAKE, stable, the only thing clients branch on. Enumerated inapi/openapi.yaml. -
messageβ already localized server-side viaAccept-Language,videfault. Clients display it; they do not construct copy fromcode, except for the two codes the contract'sErrorCodedescription names (#183). -
detailsβ optional, shape depends oncode. Field-level validation errors land here for react-hook-form. -
requestIdβ the copyable error ID Β§9's global error boundary shows.
429 additionally carries Retry-After (Β§6.5).
499 is the one status without an envelope. A request whose client cancelled it
(the browser aborting a fetch on navigation, or resetting the connection while
the answer is written) and that failed because of it is answered 499 with no
body and logged at status:499 with no error line: it is not a server error,
and docs/setup/operations.md ("Request logs") says what still is one. The contract does not list it, because nobody is there to read it.
Offset, uniform across list endpoints: page + limit in, { items, page, pageSize, total } out (O-20, which overrides Β§13.8's keyset rule for the
admin lists -- the teacher wants numbered pages, and at this scale a row
shifting under a page turn is the cheaper problem). Because PKs are
uuidv7() and therefore time-ordered, most lists sort by id DESC alone and
need no separate created_at index; 20-data-model.md records which tables
deviate.
docker compose up -d # postgres:18 on 5432, minio on 9000/9001
make migrate # goose up, as quizzivy_migrate
make seed # seed/ β admin user, one class, sample questions
make gen # oapi-codegen + openapi-typescript
cd server && go run ./cmd/api # :8080
cd web && pnpm dev # :5173, proxying /api to :8080
make dev runs the last two together.
Hosts. Local dev uses localhost:5173 and localhost:8080, which are
same-site (both localhost), so cookie behaviour matches production. Do not
introduce a 127.0.0.1 vs localhost split β that is cross-site and will
produce a bug that does not reproduce in staging.
Environment. .env.example is committed and stays current; .env is
ignored. VITE_GOOGLE_CLIENT_ID and VITE_API_BASE_URL are the only public
values (Β§5.3). The Google client secret, R2 credentials, and the JWT signing key
are backend-only.
Two database roles (Β§13.5): quizzivy_migrate owns the schema and runs
goose; quizzivy_app has DML plus USAGE and is what the API connects as.
Compose provisions both, so a missing grant fails locally rather than in
production.
Gitflow, as instructed.
| Branch | Purpose |
|---|---|
main |
Released only. Tagged v0.x. Never committed to directly. |
develop |
Integration. Every feature PR targets this. |
feature/<task-id>-<slug> |
One task from a phase file = one branch = one PR. |
release/phase-<n> |
Cut when a phase's tasks are all merged; stabilization only. |
hotfix/<slug> |
From main, merged to both main and develop. |
One task is one reviewable PR (Β§18), so feature/t-1-04-google-oauth maps to
T-1.4. Phase completion is a release branch merged to main and back to
develop, which is what makes Β§16's "each phase ends deployable" a branch state
rather than an assertion.
The PR template carries the Β§14 definition of done, plus the two conditional blocks (DDL review; public-endpoint rate limit and leak review) so they cannot be skipped silently.
One honest note: for a single developer deploying to one environment, develop
plus main doubles merge overhead for no isolation benefit, and release/*
branches are ceremony. It is being followed because it was specified, and it does
buy a clean phase boundary. If it becomes friction, see 40-open-items.md.
Generated from docs/plan/00-overview.md. Edit that file in the repository; changes made on this page are overwritten.