Repository navigation
feat: events with idempotency and fan-out, delivery reads, and CORS (steps 3.4–3.6) - #33
devinder-dev wants to merge 2 commits into
Conversation
…steps 3.4–3.6) - POST /v1/events: the event and one delivery per matching live endpoint in one transaction; the same Idempotency-Key returns the first answer (ON CONFLICT DO NOTHING, then a separate SELECT) with Idempotent-Replayed: true. NUL and lone surrogates in the payload are 422. - GET /v1/deliveries: keyset pages (limit + 1, cursor = last row sent), filters by status and endpoint, every join also on owner_id. - GET /v1/deliveries/:id: event, endpoint (also when deleted) and the numbered attempt log; malformed, missing and foreign ids are the same 404. - CORS for the dashboard origins, registered before the auth hook.
|
Read all 23 files. Approving. Three findings, and the first one lands on my side of the project so I 1. 2. CORS allows an origin our own docs forbid. 3. The idempotency correctness depends on READ COMMITTED, and that only lives in a comment. Also worth stating in the contract rather than only in a comment: a repeated What's good, in my own words. One Merging in order 31 -> 32 -> 33 since they're stacked. |
AbdikhaniAbdirashid
left a comment
There was a problem hiding this comment.
Approved. Need an answer on the replay route before I wire the dashboard to the real API, but it doesn't block this merge.
What
Steps 3.4–3.6 of Phase 3, the last part of the core API:
POST /v1/events, readingdeliveries (
GET /v1/deliveries,GET /v1/deliveries/:id), and CORS for the dashboard.With this, an event goes in and its pending deliveries can be seen in the API and the
dashboard. Sending them is Phase 4 (the worker).
Why it works this way
the fan-out insert a delivery and then fail: neither the event nor the delivery is saved.
UNIQUE (owner_id, idempotency_key)with
ON CONFLICT DO NOTHING: a second request with the same key waits at the unique indexuntil the first commits, then inserts nothing. The original is read by a separate
statement, because under READ COMMITTED each statement gets a fresh view and so sees the
first request's commit. Five requests with one key at the same moment give 1 event, 1
creator and 4 replays (held at a real lock, no sleeps).
nothing new. Refusing it would need a new error code in the contract.
with the same key and gets the original answer, or the event is created now. A lost answer
after COMMIT returns the original
deliveriescount, even if an endpoint was added since.docs/api.mdnow says this; it never promises that an error means nothing was saved.\u0000injsonb(22P05) and alone surrogate like
"\ud800"(22P02). Only the event insert maps exactly these two codesto
validation_failedwithpath: "payload"; anywhere else they stay a 500 (our own bug).WHERE d.id < $cursor ORDER BY d.id DESC LIMIT limit + 1. UUIDv7 idsare ordered by time, so "older" is "smaller id"; as fast on page 100 as on page 1, and new
rows don't shift the next page. The extra row tells us there is a next page without a count
query;
next_cursoris the id of the last row sent. It's stable paging, not a frozensnapshot: a row committed late can land behind a page already read, and the dashboard
refreshes.
limitonly accepts digits.""," 5"and1e2are refused instead of quietly turnedinto numbers.
owner_id. Our foreign keys are single-column, so the databasewould accept a delivery of owner B pointing at owner A's event. The joins are the guard: a
test plants exactly such rows and they are never shown.
next_attempt_atlives in one SQL fragment. The column is never NULL (while a row isleased it holds the lease's expiry); the API shows it only for
pending, in the list andthe detail.
which ids exist (and a malformed id never reaches Postgres as a 500).
CORS. It only decides which websites' JavaScript may read our answers:
cdn.myrelay.techand
localhost:4173(both in the contract).@fastify/corsis registered at the root,before the
/v1auth hook, because the browser's preflight carries no key; a 401 stillcarries the CORS headers, so the dashboard can read the error.
strictPreflight: false, soan
OPTIONSwithout preflight headers gets 204 instead of the plugin's plain-text 400.Changes
schemas/events.ts,repositories/events.ts,services/events.ts,routes/events.ts;schemas/deliveries.ts,repositories/deliveries.ts(fan-out, list,detail, attempts),
services/deliveries.ts,routes/deliveries.ts;routes/v1.tsregisters them;
app.tsregisters CORS.@fastify/corspinned to exactly11.3.1(released 8 Oct; 11.4.0 is from2 Nov, too new for us).
another owner's; no match → 0; repeated key; another owner's key; no key; the 5-request race;
lost answer; changed body; failed fan-out rolls back), event validation (every field, the
key, NUL in a value, a key and nested, both surrogates, an emoji as a positive control, 413,
415, 401), deliveries (pages of 2, 2, 1 with no gaps, filters, strict query validation),
isolation (B can't list, filter for or read A's deliveries; planted inconsistent rows stay
hidden), detail (the example row in
docs/api.mdfield by field, deleted endpoint, attemptnumbering with a tie,
next_attempt_atin list and detail), CORS (preflight from bothorigins, a foreign origin, no
Origin, a 401 with CORS headers, health unchanged).docs/api.md(key rules, the key wins, retry advice,limitdigits, malformed id→ 404).
No migration: the tables and indexes are from migration 001.
Tested
bun run typecheck,bun run test:db: 288 pass, 0 fail.ON CONFLICT, the NUL/surrogate mapping,owner_idon theevent join,
next_attempt_at's rule, or using the extra row as the cursor each turns thematching test red.
bun audit: no vulnerabilities (64 packages).After merge, checked from outside
POST /v1/eventstwice with the sameIdempotency-Keyandtype: "live.check"→ 202 twice,the same id and count, the replay header only on the second.
GET /v1/deliveries?limit=2→ the delivery,pending; numbers are numbers.OPTIONS /v1/eventswithOrigin: https://cdn.myrelay.techand the preflight headers → 204,allow-origin cdn; the same with
Origin: https://evil.example→ no allow-origin; with noheaders → 204, no allow-origin.
Careful: deliveries created in production now will be sent once the worker runs
(Phase 4). An event reaches every endpoint that lists its type or
*, so in production weonly create endpoints that point at receivers we own, and send
live.checkevents: then theworker only ever sends to us.