Thnkrr: Plug-and-Play Check and Server Load
10 October 2026. An investigation of the code on the dev/pilot branch (API at merge
commit 225d7f9, web at 67e8c47). Nothing is built or changed here. This is findings plus a
plan that waits for approval.
It answers three questions:
- Can every outside supplier be swapped by changing settings and copying data, without rewriting the app and without rewriting the rows already in the database?
- Is the code modular, and are the database tables kept apart cleanly enough that one part can be replaced without dragging the others along?
- What does each student action cost the server, what happens at 3,000 to 5,000 daily active users, and where can the load be cut?
The companion files are PROVIDER-SWAP-RESEARCH-2026-10-09.md (which supplier could replace
which, with prices) and PRODUCTION-PLAN-2026-10-09.md. This document looks at the code
itself, which those two did not do in detail.
The one-minute version
- The suppliers that touch student data are all behind connectors, and swapping any of them never needs a data migration. Files, email, the AI writing check, the guardian adult check and the job queue each sit behind one small interface, and the database stores only neutral values (file names, not Amazon addresses; the model's name, not Amazon's format). Tests prove each connector daily with a fake. This is the good news and it is most of the picture.
- Three gaps on the supplier side: the picture check the documents describe does not exist in the code at all; video conversion calls ffmpeg directly with no connector; and the delivery-network purge has only an Amazon version. None is urgent for the pilot. The first two are one to two days each.
- The database tables are well separated. Ten files, one per area, and the areas point at each other through neutral "what kind of thing, which one" pairs rather than hard links. Four small pieces of debt are listed in section 5.
- The code's modules are reasonably separate but not independent. The profile module reaches into nine others, and there are a few two-way dependencies. This does not block any supplier swap. It would make splitting the app into separate services slow, which nobody is asking for.
- Server load is the real finding. At 5,000 daily active users the database would see roughly 900 to 1,000 questions a second at peak with today's code, and about three quarters of that is waste: every request asks the database three times just to learn who is signed in, every open browser tab asks for notifications and messages every 30 seconds, and a reviewer's queue page asks the database several hundred questions every 5 seconds. Six targeted changes bring the peak to roughly 200 a second, which the smallest database size handles with room to spare.
- The AI check is cheap at this scale: about 7,500 checks a day, roughly $25 a month. Photos and videos are the part that would hurt a 2-processor server: video conversion and speech-to-text could use 4 to 7 hours of one processor a day, on the same machine that answers students. The fix is to run that work in a second process, which the code is already shaped for.
- Six things assume one server. Rate limits, the posting brake, the search gate, the AI throttle, the busy-cache and the term cache all live in the memory of one process. A second API server would double every limit silently. Fine for the pilot, must be fixed before a second server.
Words used in this document
| Word | What it means here |
|---|---|
| Supplier (provider) | A company whose service we rent: Amazon for servers, files, email and the AI check |
| Connector (adapter) | A small piece of our code that is the only part that talks to one supplier. Swapping suppliers means writing or choosing one connector, not changing the app |
| Plug and play | A supplier can be changed by a settings change plus a data copy, never by rewriting the app |
| Settings | Values the app reads when it starts (addresses, names, keys). A swap that is "a settings change" needs no new code |
| Seam | The line in the code where a connector meets the rest of the app. A clean seam means nothing on the app side knows which supplier is behind it |
| Leak | A place where supplier-specific knowledge has crept past the seam into the app |
| Table | Where one kind of record lives in the database: students, posts, reviews. Our database is Postgres, an open standard |
| Data migration | Rewriting the rows already in the database into a new shape. The thing we want never to need when swapping a supplier |
| Schema migration | Adding a table or a column. Normal, cheap, and not what this document worries about |
| Module | One folder of code for one job (feed, search, moderation). The question is whether a module can be changed without touching the others |
| Request | One call from a student's browser to the server (open the feed, like a post) |
| Query | One question the server asks the database. At our size, the server's cost is mostly counted in queries |
| N+1 | The server asks the database once for a list, then once more per item in the list. Fine for 10 items, a problem for 100 |
| Index | A lookup table the database keeps so it can find rows without reading the whole table. A missing index means a full read every time |
| Poll | The browser asking the server "anything new?" on a timer, even when the student does nothing |
| Daily active users (DAU) | People who open the app at least once in a day |
| Background job | Work the server does on its own clock: the AI check, photo resizing, nightly clean-ups |
| Sweep | A background job that runs on a timer and looks for things to do |
| In memory | State kept inside the running server program: lost on restart, invisible to a second server |
| Connection pool | The fixed number of database connections one server keeps open (ours: 10). Every query waits for a free one |
1. What we checked and how
Every source file of the API (118 files, about 30,000 lines) and the web app on dev/pilot
was read, by hand or by three parallel read-only audits. For each outside supplier we asked:
is there one interface, how many implementations, is a fake used in tests, and what does
the database store about it. For each route a student or a reviewer hits we counted the
database queries, looked for loops that query per row, for missing indexes, and for timers
in the browser. For each background job we counted what it does every tick when there is
nothing to do.
Numbers come from reading the code, not from running it under load. The projections in section 8 use stated assumptions and are estimates to within a factor of two.
2. Outside services, one by one
"Swap" says what a change of supplier takes with the code as it is today. "Rows" says whether the database would need rewriting.
| Service | Connector in code | Implementations | Swap today | Rows to rewrite | Verdict |
|---|---|---|---|---|---|
| Photo and video files | Yes, Storage interface (src/lib/storage.ts) | One, speaking the S3 language (Amazon S3, MinIO, R2, B2, Google all fit) | A settings change for any S3-speaking company | None. Tables store file names only; the columns are called s3_key_… but hold names, not addresses | Good |
| Delivery-network purge (CloudFront) | Yes, CdnPurger interface (src/lib/cdn.ts) | One: CloudFront. A do-nothing version when unset | A new one-file adapter per network (Cloudflare, Bunny) | None | Good seam, one adapter |
Yes, Mailer interface (src/lib/mailer.ts) | One, plain SMTP (works with SES, Postmark, Mailgun, Zoho). Logs instead of sending when unset | A settings change plus the new sender's DNS setup | None | Good | |
| AI writing check | Yes, Classifier interface (src/modules/moderation/classifier.ts) | Three: Amazon Bedrock (production), Anthropic (laptop only, refused in production), dev stub. One chooser (select.ts), one throttle wrapper | Model: one setting. Another Bedrock-like host: a new adapter (the research proposes a generic "OpenAI-compatible" one) | None. Each verdict is stored with the model's name beside it | Best seam in the codebase |
| Speech-to-text | Yes, Transcriber interface (src/lib/transcriber.ts) | One: whisper.cpp on our own server. None when no model file is set | A new adapter for Amazon Transcribe or similar | None | Good, with one wrinkle: it is created inside the video code from settings, not handed in like the others, so tests cannot swap it |
| Video conversion | No connector. src/modules/media/video.ts runs ffmpeg and ffprobe directly | One (ffmpeg, open software) | Moving to a cloud converter means rewriting that one 380-line file | None | Acceptable for now; it is open software, not a supplier |
| Picture check | Does not exist. Docs 04, 07, 14, 18 and 19 describe an imageScreen connector | None. Every photo waits for a person | Build the seam (1 day) and one adapter (1 day each) | None | Documents promise something the code lacks |
| Guardian adult check | Yes, AdultCheckProvider interface (src/modules/consent/adult-check.ts) | One dev fake; "none" in production | A DigiLocker adapter when needed | None. The row stores "provider:reference" | Good |
| Background job queue | Yes, Jobs interface (src/jobs/boss.ts) | One: pg-boss, tables inside our own Postgres. An inline mode for tests | Another queue = a new adapter; pg-boss moves with the database | None | Good |
| Database | Drizzle over plain Postgres | Any hosted Postgres 16 | A settings change plus a copy. Needs the pg_trgm and citext add-ons, generated columns and advisory locks, all standard Postgres | Not applicable | Good. Not swappable for a non-Postgres database, by design |
| Picture hashing | Our own code (src/lib/phash.ts, dHash via sharp) | One | Not a supplier | Yes, if the algorithm ever changes: the media.phash column would need recomputing from the originals | Note only |
| Rate limits and brakes | No seam. In the memory of one process | One | A second server silently doubles every limit | None | Must change before a second server (section 6) |
| Secrets and settings | Environment variables, checked at start (src/config.ts) | One | Any host that sets environment variables | None | Good |
| Deploy, website hosting, logs | Shell script, systemd, Caddy; Amazon Session Manager per docs/27 | One | Covered in the provider-swap research: not yet portable | None | Out of scope here |
Leak check. No Amazon, Bedrock, S3, CloudFront, whisper or SMTP library is imported
outside the connector files, with one exception: the sharp image library is used directly
in the media and evidence modules and the seed scripts. That is fine; it is open software
doing image work, not a supplier. The web app contains no supplier name at all. Its only
assumption is that a video upload returns an ETag header, which every S3-speaking service
does.
Where connectors are created. The app hands four connectors to every module through one shared context object: database, jobs, mailer, classifier, storage. Three others are built on the spot from settings inside the module that uses them: the transcriber, the purge, the adult check. It works, but it is inconsistent, and the on-the-spot ones cannot be faked in a test. Cheap to unify.
3. The database: are the tables well separated?
Yes. The 55 tables live in ten files, one per area: identity, profiles, social, moderation, mentorship, safety, care, consent, handles, school pages.
What keeps them apart:
- Neutral pointers. Notifications, reports, the audit log, content reviews, case actions, evidence files and deadline alerts each say "what kind of thing, which one" as two plain columns. They do not hard-link to the posts or messages tables. So the moderation area can review a new kind of content without the posts area changing, and vice versa.
- Staging in the review row. Profile text, school descriptions and mentor experience descriptions are held in the review row until approved. The live table only ever carries approved text. One pattern, used everywhere.
- No supplier shapes. The AI verdict is stored as our own small JSON (decision, categories, confidence, reason) plus the model's name, not Amazon's response format. File columns hold names. The adult-check column holds "provider:reference".
- Current state in one place, history elsewhere. School membership is "current rows only", with a separate history table. Consent is append-only. Author holds are never deleted.
A supplier swap never requires a data migration. Checked for files, email, the AI check, the delivery network, speech, the adult check and the queue.
Debt worth clearing, none urgent:
| Item | Where | Why it matters |
|---|---|---|
| A deprecated column still present | schools.approved_domains (replaced by the school_domains table in migration 0023) | Nothing reads it; a future reader may think it is live |
| Five unused tables from the first profile design | activities, honors_awards, projects, courses, volunteering | Clutter in every backup and schema diagram |
| Old values kept in three lists | profile visibility (network, mentors_included), commitment status (enrolled), verification | Postgres cannot drop a list value; harmless, but they must never be written |
| Column names say "s3" | media.s3_key_original, media.s3_key_public | A name only. Renaming is cosmetic and costs a migration; leave it |
4. Is the code modular?
Reasonably, with a hub. Nineteen modules under src/modules, each with its own routes
and service files, all talking to the outside world through the shared context.
| Measure | Finding |
|---|---|
| Modules that import no other module | identity only |
| The widest module | profiles imports nine others (graph, identity, mentorship, moderation, notifications, onboarding, people, schools, search) |
| Two-way dependencies | profiles and mentorship; profiles and schools; profiles and onboarding; moderation and notifications |
| Intended shared pieces | people (who may see whom, name cards), notifications (every feature asks it, it decides), moderation (every piece of writing passes through it) |
| The biggest file | moderation/service.ts, 2,317 lines, 15 raw SQL statements. Everything that is written by a student goes through it |
| Shared types | The @thnkrr/contracts package defines every request and response shape. The API uses it. The web app does not: it keeps a hand-copied 1,750-line mirror and relies on the server rejecting drift |
| Tests of the seams | Integration tests run with an in-memory file store, a capturing mailer, a scripted AI and an inline job queue. The seams are proven, not just drawn |
What this means in practice: any supplier can be replaced without touching modules; any module can be changed without touching suppliers; but pulling one module out into its own service (for example running moderation on its own machine) would take real work because of the two-way links. Nobody has asked for that, and the pilot does not need it.
5. Server load today: what each action costs
Every signed-in request first pays a fixed cost: three database queries to turn the cookie into a person (session, then user, then roles). The cookie itself is not checked by signature, only by lookup. Counts below include those three.
What a student does
| Action | Requests | Database queries | AI calls | Other work |
|---|---|---|---|---|
| Open the feed page | 7 (feed, own profile, mentorships, suggestions, notifications, messages, who am I) | About 60 in total; the feed alone is 8 to 10 plus one per video post | 0 | None |
| Load more feed | 1 | 8 to 10 | 0 | None |
| Open a post | 1 | 12 to 16 | 0 | None |
| Open someone's profile | 1 | 16 to 20, plus 4 to 5 more on the first view each day. This GET writes a view row and possibly a notification | 0 | None |
| Open own profile | 2 | 12 to 19, strictly one after another, including one lookup per profile field for appeals | 0 | None |
| Search people, per keystroke | 1 | 11 to 21 | 0 | Gated: at most 6 searches at once per server, 2-second cap per query |
| Open messages list | 1 | 6 plus one per peer thread, and one query that reads every message the person has ever exchanged to pick one preview per thread | 0 | None |
| Open a thread | 1 | 6 to 9 | 0 | None |
| Write a post | 1 | 15 now, 13 more in the background | 1 | Stage-1 rules: about 13 fixed patterns plus two per editable term, run twice |
| Write a comment | 1 | 13 now, 21 to 22 in the background (the post, the last 50 comments read, 5 sent to the AI, then 7 queries to notify) | 1 | As above |
| Send a peer message | 1 | 14 now, 27 to 28 in the background. The same message row is re-read four times and the conversation four times | 1 | As above |
| Like a post | 1 | 11 | 0 | None |
| Upload a photo | 3 plus a direct upload to storage | 5 to 6 in the background | 0 | Two full image decodes (resize, then hash), one resize, three storage calls, and a read of every row in the picture blocklist for a nearest-match loop in code |
| Upload a video | 2 plus chunked direct upload | 4 in the background | 0 (the transcript joins the post's text check) | Up to 8 child processes: probe, one conversion pass making both sizes, poster, storyboard, audio extract, speech-to-text. The picture is decoded 3 to 4 times. All on 2 processor threads at low priority |
The AI prompt is about 1,040 words-worth of policy sent in full on every call (no caching on the Bedrock path), plus the text and up to 10 prior messages for a thread. One call per item, not per field: a post's body, link, photo captions and transcript are joined and checked once.
What the browser does on its own
| What is polled | How often | Who | Queries per poll |
|---|---|---|---|
| Notifications list and unread count | Every 30 s, every page, while the tab is in front | Everyone signed in | 7 to 10 |
| Messages list | Every 30 s, every page | Everyone signed in | 9 plus the unbounded preview read |
| Who am I | On every return to the tab, for any list older than its cache | Everyone | 10 to 15 |
| Own post, comment, request or profile while it is "being checked" | Every 2 to 2.5 s | The author | 8 to 19 |
| "Checking is busy" banner | Every 15 s while something waits | The author | 3 (plus a 5-second cache) |
| Review queue | Every 5 s | Each reviewer tab | 3 plus 2 to 7 per queued item and 1 to 4 storage presigns per item, all at once: several hundred for a full queue |
| Review stats | Every 10 s | Each reviewer tab, on every page | A count over the whole reviews table with no filter |
| Reports, cases, grievances, orders, takedowns, care, activity | Every 5 to 10 s, all mounted at once whatever tab is open | Each reviewer tab | 1 plus 3 to 4 per report; activity reads 600 audit rows with no index |
Polls pause when the tab is hidden, with three exceptions that keep running: the photo editor's refresh, the consent waiting screen (every 5 s, unbounded) and the upload status loop.
What the server does with nobody around
| Job | How often | Queries when idle | Notes |
|---|---|---|---|
| Job queue polling (pg-boss) | Continuous | About 6 a second, 500,000 a day, per server | Seven plain queues poll every 2 s, two pooled queues every 1 s, plus pg-boss's own housekeeping |
| Moderation sweep (stale reviews, care reminders, deadline alerts, suspensions) | Every minute | 8 | The suspension check reads the whole users table every minute: no index on status or end date. Each open safety item also gets 1 to 3 writes per minute for as long as it stays open |
| Consent sweep | Hourly | 4 | Three of the four read whole tables (no index on consent status or user status) |
| Nightly sweep (expire requests, delete old profile views) | Daily | 3 | The delete has no index on the day column and returns every deleted id into memory |
| AI keep-alive | Daily | 1 | One Bedrock call per model, outside the throttle |
Missing indexes that the hot paths want
| Table | Lookup with no index | Who hits it |
|---|---|---|
content_reviews | by surface and subject id (9 places in code) | Post page, thread page, requests page, school admin page, reports |
media | no index at all beyond the key | School admin page (two full reads per poll), photo processing |
audit_log | by time and action | Review activity panel, every 10 s |
users | by status, by suspension end, by role | The every-minute suspension sweep, the mentor directory, the school overview |
profile_views | by day | The nightly delete |
posts and messages | the sort expression "published or created time" | Feed and threads sort every candidate row before cutting to 20 |
parental_consents | by status | The hourly consent sweep |
Numbers in settings that do not fit a 2-processor, 4 GB server
| Setting | Value | Problem |
|---|---|---|
| AI checks in flight at once | 300 | Each holds one of only 10 database connections for its two transactions. 300 cannot fit in 10; the rest queue, and any request that waits more than 5 s is answered "busy" |
| AI checks started per second | 150 | Sized for 10,000 students posting in one minute. At that rate the follow-up database work is 1,800 to 4,200 queries a second through 10 connections |
| Bedrock sockets per server | 310 | Harmless, oversized |
| Photo jobs at once | 2 | Right for 2 processors, but video, speech-to-text and the web server share the same two |
6. Things that assume one server
All of these live in the memory of the process. A second API server would not know about the first.
| What | Effect with two servers |
|---|---|
| Request rate limit (300 a minute per person) | Becomes 600 |
| Posting brake (10 a minute per person) | Becomes 20 |
| Search gate (6 at once) | Becomes 12 against the same database |
| AI throttle (150 a second) | Becomes 300 against the same Amazon quota |
| "Checking is busy" cache, editable-term cache, website index cache | Briefly out of step between servers; harmless |
| Job wake-up on send | A job sent on server A starts on server B only at B's next 1 to 2 s poll; harmless |
Already safe for many servers: item locks (Postgres advisory locks), deadline alerts (unique rows), care reminders (conditional updates), scheduled jobs (pg-boss runs each once).
7. At 3,000 to 5,000 daily active users
Assumptions (change any of them and the numbers scale in proportion): 5,000 people a day; a school day of about 8 hours; 10 to 15 percent of them with the app open at the same moment at peak, call it 600 tabs; each person opens about 15 pages, writes 1 to 2 things, opens 1 profile and uploads a photo one day in ten; 50 videos a day across all schools; 5 reviewers working with a queue of about 50 items.
Database questions per second at peak, with today's code
| Source | Per second | Share |
|---|---|---|
| Browser polling (notifications and messages, 600 tabs, every 30 s, about 20 queries a pair) | ~400 | 40% |
| Reviewer tabs (5 tabs, queue every 5 s at ~250 queries, stats, lists) | ~300 | 30% |
| Page opens (about 5 a second, ~40 queries each) | ~200 | 20% |
| Job queue polling and sweeps | ~6 | 1% |
| Writing posts, comments, messages (7,500 a day, ~35 queries each) | ~9 | 1% |
| Profile views, likes, searches | ~30 | 3% |
| Total | ~950 |
Three quarters of that is the three sign-in queries on every request plus the two polls and the reviewer fan-out. A small Postgres can answer 950 simple questions a second, so this does not fall over on day one. It gets worse with time, not with users:
- The reviews table grows by about 7,500 rows a day (2.7 million a year). The stats call counts the whole table every 10 s per reviewer. The nine lookups by subject id read the whole table each. Within months these become seconds each.
- The messages preview reads every message a person has ever exchanged. It grows with history forever.
- The users-table read every minute for suspensions grows with sign-ups.
What breaks first, in order: the reviewer pages (they slow down as the reviews table grows, and they are polled every 5 s); then the 10-connection pool during a lunchtime posting burst, when 300 AI checks each want a connection and students start seeing "busy"; then the database's burst credits on the smallest instance size.
Processor time on the API server
| Work | Per day | Note |
|---|---|---|
| Answering requests (about 50 to 80 a second at peak) | Comfortable on 2 processors; Fastify handles thousands a second | The JSON and regex work of 300 concurrent AI checks runs on the same event loop |
| Photos (500 a day, about 1 s of processor each) | About 10 minutes | Fine |
| Videos (50 a day, 1 to 3 minutes long on average) | 4 to 7 hours of one processor: conversion at low priority plus speech-to-text, each about real time or slower on 2 threads | Shares the two processors with everything above during school hours |
Outside services and money
| Service | Per day | Per month, list price |
|---|---|---|
| AI checks on Bedrock (gpt-oss-safeguard-20b) | 7,500 calls, about 9 million input tokens, under 1 million output | About $25 |
| Storage reads and writes | A few thousand | Under $1 |
| A few hundred | Under $1 |
The AI is not the cost driver at this scale. The database and the one server's processor are where the ceiling is.
8. How to reduce the load, ranked by payoff
Each row says what changes for a student, roughly how much it saves, and how big the change is. None is built. None changes any supplier.
| Rank | Change | What it saves | Size |
|---|---|---|---|
| 1 | Sign-in check: one query instead of three, and a short memory cache. Keep the session, user and roles in one query; remember the answer for 30 to 60 s per server so polls do not pay it at all. Revocations and suspensions still take effect within a minute, which the sweep already assumes | Two queries off every request: roughly a third of all peak load | 1 day |
| 2 | Polling: one small "badges" call instead of two full lists. The 30-second tick fetches an unread count and an unread-messages count (2 queries) instead of the full notifications list and the full messages list. Raise the tick to 60 s. Later, a push channel (server-sent events) replaces the tick entirely | About 350 of the 400 polling queries a second | 1 to 2 days for the badges call; 3 to 4 days for push |
| 3 | Reviewer pages: no fan-out, cheap stats. Fetch the queue list only; load an item's context, previews and presigned links when the reviewer opens it. Keep a running count of pending and escalated items instead of counting the whole table. Mount a tab's list only when the tab is open. Add the audit-log time index | Nearly all of the 300 a second from reviewer tabs, and the part that grows with the table | 2 to 3 days |
| 4 | Messages preview: one row per thread. Ask the database for "the latest message in each of these threads" (a lateral join with limit 1) instead of every message ever | A query that grows with history becomes a fixed small one | Half a day |
| 5 | Seven indexes from the table in section 5 (reviews by surface and subject; media by owner and by school, kind, status; audit log by time; users by status and suspension end; profile views by day; posts and messages by their sort expression; consents by status) | Every lookup listed stops reading whole tables. Removes the "worse with time" cliff | Half a day; one additive migration |
| 6 | Run the media and AI workers in a second process. A ROLE=api or ROLE=worker setting: the API process serves requests, the worker process runs jobs. Both already share one database and one job queue; the registration is a single call today | Video conversion and speech-to-text stop competing with students for the two processors. On AWS this is the same box with two services, or a second small box | 1 day, plus the deploy change |
| 7 | Fit the AI numbers to the pool. In-flight checks 20 to 30 and 10 to 20 a second for the pilot, raised with evidence; or a pool of 20 and an in-flight cap tied to it. Same throughput for any real burst, no "busy" errors | Removes the lunchtime "busy" failure mode | An hour; settings only |
| 8 | Feed and thread trims. Fetch all video rows on a page in one query; drop the "every active user" sub-query from the comment counts; stop re-reading the same message and conversation four times in the publish path | 10 to 20 queries per feed open and per message | 1 day |
| 9 | Slow the idle job polls. Cron-only queues (sweeps, keep-alive) can poll every 10 to 30 s instead of 2 s | About 3 of the 6 idle queries a second; also lets the database sleep at night on a burstable instance | An hour |
| 10 | Photo blocklist match in the database. Store the hash as a 64-bit integer and let Postgres compute the bit distance, instead of reading every blocklist row into code per upload | Grows with the blocklist today; fixed after | Half a day |
After ranks 1 to 5, the same peak is roughly 200 database questions a second, dominated by real page opens. That fits the smallest RDS size (db.t4g.micro) with headroom; db.t4g.small is the safe choice if the reviewers' tables keep their full history online.
9. The plan
Nothing here is built. Order is by payoff and by what unblocks the next step. Effort is agent days.
Phase A: load, before more schools (about 5 days)
- Sign-in check to one query plus a short cache (rank 1).
- The seven indexes (rank 5).
- Messages preview as one row per thread (rank 4).
- Badges call and 60-second tick (rank 2, first half).
- AI concurrency and pool numbers fitted to the pilot (rank 7).
- Idle job polls slowed (rank 9).
Gate: a load replay (the existing moderation:load script extended with feed, notifications
and messages traffic at 600 simulated tabs) shows under 300 database queries a second.
Phase B: reviewers and workers (about 4 days)
- Reviewer queue without fan-out, running counters for stats, lists mounted per tab (rank 3).
- Worker role split (rank 6), with the deploy script starting two services.
- Feed and publish-path trims (rank 8).
Gate: a full queue of 100 items opens in under half a second; a 15-minute video converts without any student request waiting on the database.
Phase C: plug-and-play gaps (about 5 days)
- Picture-check seam with a "send to a person" default and one adapter (the research recommends starting with Google Vision SafeSearch or Azure Content Safety on data terms).
- Transcriber, purge and adult check handed in through the shared context like the others, so each can be faked in tests.
- A second purge adapter (Cloudflare) beside CloudFront.
- A video-conversion interface around the ffmpeg calls, with ffmpeg as the one implementation, so a cloud converter is an adapter, not a rewrite.
- The web app consumes
@thnkrr/contractsdirectly instead of a hand-copied mirror.
Gate: every connector has a fake, a conformance test and at least one real implementation.
Phase D: before a second server (about 3 days)
- Rate limit, posting brake, search gate and AI throttle moved to a shared store. Postgres can hold them (a small counters table with the advisory-lock pattern already in use); no Redis needed at this scale.
- Push channel for notifications and messages (rank 2, second half).
Gate: two API processes behind one address pass the integration suite with the same limits as one.
Schema clean-up (any time, half a day)
Drop the deprecated domains column and the five unused tables in one additive-then-drop migration pair, after a backup.
10. The founder's answers
- Badge refresh: stays at 30 seconds. The new badge call is cheap enough that slowing it down isn't needed.
- Archive: not built. Keeping every review in the main tables is fine at pilot size now that the slow lookups have indexes. If an archive is ever added, archived reviews must still open on screen on demand.
- A small server for video: yes, but after the current version is deployed. The switch is built (section 11); the second server is not.
11. What was built (10 October 2026)
Everything in phases A, B and C plus the schema clean-up, on branch feat/load-and-seams in
both repos. Phase D (a second API server) was left out by decision. Pull requests into
dev/pilot: thnkrr-api #18, thnkrr-web #16.
Measured results
Database questions per call, warm, on a busy test account. "Before" was measured on dev/pilot before any change.
| What | Before | After |
|---|---|---|
| A tab's 30-second refresh of the red dots | 31 | 1 |
| Messages list | 24 | 7 |
| Review queue, 40 items | 116 | 10 |
| Who am I | 13 | 6 |
| Feed | 11 | 8 |
| A post's page | 15 | 12 |
| Review stats | 5 | 2 |
| Burst | Before | After |
|---|---|---|
| Five reviewers refreshing a 40-item queue ten times | 5,800 questions | 500 questions |
| An idle feed tab over five minutes, in a real browser | 20 requests, both full lists | 10 small badge requests |
In the browser, pages open in 130 to 290 milliseconds and taps answer in 100 to 150 milliseconds on the busy account. Nothing froze for more than 70 milliseconds.
One audit item turned out wrong
The audit said 300 AI checks in flight would starve the 10 database connections. A real burst of 2,000 posts said otherwise: student reads stayed at 1 to 2 milliseconds and nobody was told "busy", because a check doesn't hold a connection while it waits for the AI. Lower settings only made the burst five times slower, so the settings were left alone.
A second item was withdrawn: the review page loads every staff list because its main tab is one merged to-do list built from all of them. The real cost was in the queue's own lookups, which are fixed.
What changed, in plain words
- Sign-in check: one database question instead of three, remembered for 30 seconds. Suspensions, bans, sign-outs and password changes still take effect on the next request. Roles changed by the staff-role script take up to 30 seconds (D-82).
- Red dots: a small "badges" call instead of downloading both lists every 30 seconds.
- Messages: one preview per thread instead of every message ever. Long threads and comment lists now show their newest lines; before, they showed the oldest and hid the newest.
- Review queue: built in a fixed handful of questions instead of several per item. A test proves it shows exactly what it showed before, for every kind of item.
- Indexes: ten added, so the slow lookups stop reading whole tables as they grow.
- Video server switch: one setting moves video work to a small server of its own; off by default (D-84, deploy/README.md "A small server for video").
- Picture check: the connector the documents promised now exists, with an Amazon Rekognition version, switched off. When on, it never decides; it tells the reviewer what it saw and puts flagged pictures first (D-83). Turn on only after the AWS AI-services opt-out is set.
- Other connectors: speech-to-text, video conversion and the guardian adult check are handed in at startup like storage and email, so each can be swapped or faked. The delivery-network purge now has a Cloudflare version beside CloudFront.
- Shared types: the web app now uses the API's contracts directly through a synced copy, and CI fails if the copy drifts. The switch found three fields the API sent but never declared; they are declared now.
- Clean-up: five never-used tables and one dead column removed (D-85). The migration refuses to run if any of those tables holds data.
Tests added
load-budget: the exact database cost of every hot endpoint, with a budget each, plus concurrent bursts (600 tabs, 200 feed opens, 120 posts during reads).phase-a-regressions,phase-b-regressions,phase-c-seams,seams: that nothing a person sees changed, and that each connector works with a fake.e2e/performance.spec.ts(Playwright): page times, tap times, freezes, what an idle tab asks for, and that a flagged picture's finding is on screen without scrolling.
Final runs: API 700 of 700, browser 145 of 145.
Before deploying
- Migrations 0031 to 0034 run on deploy. 0031 to 0033 only add things. 0034 removes the unused tables and column; take the RDS snapshot first, as the deploy script already asks.
- Nothing needs turning on. The picture check, Cloudflare purge and video server are all off until their settings are set.
Sources
Files examined on dev/pilot:
- API:
src/config.ts,src/context.ts,src/index.ts,src/server.ts,src/db/client.ts, all tensrc/db/schema/*.ts,drizzle/meta/0030_snapshot.json,src/lib/{storage,cdn,mailer,transcriber,locks,posting-limit,phash,auth-plugin,authorize,gate}.ts,src/jobs/{boss,workers}.ts, every file undersrc/modules/,test/helpers/mem-storage.ts, the integration test context setups,scripts/deploy.sh,packages/contracts/src/. - Web:
src/lib/api.ts,src/lib/query-client.ts, everysrc/lib/*.tshook module,src/api/schemas.ts, the layout, moderation, messaging and composer components,router.tsx,vite.config.ts,playwright.config.ts. - Documents:
docs/04-thnkrr-architecture.md§11,docs/07-decision-log.md,docs/18-aws-services-and-costs.md,docs/19-aws-why-and-alternatives.md,docs/25-public-profiles-and-search.md§6,docs/27-aws-deployment-decisions.md,notes/PROVIDER-SWAP-RESEARCH-2026-10-09.md,notes/PROVIDER-SWAP-PLAN-2026-10-09.md,notes/PRODUCTION-PLAN-2026-10-09.md,notes/MODERATION-PLAN-2026-10-05.md. - Bedrock price for gpt-oss-safeguard-20b ($0.07 per million input tokens, $0.20 output) from the provider-swap research of 9 October 2026.