Sparse ordering, and checking CORS for real
Drag-to-reorder that writes one row by bisecting a sparse sort key, and a CORS check that stopped inferring from a policy it could not read and ran a real upload instead.
Two things in this week’s log, and they turned out to have the same shape: a question I could have answered by guessing, answered instead by something that cannot lie. One was arithmetic done before any code was written. One was an upload run after reading the evidence told me nothing.
A drag that writes one row
Tasks v2 in Atlas made every task a row that owns itself, and rows need drag-to-reorder. The obvious design stores position as 1, 2, 3 and rewrites every row after the drop point on every drag. That is O(n) writes per gesture, and most of them are pointless: nothing about the rows you did not touch has changed.
What shipped instead is a sparse REAL sort column. A new row carries null, meaning never dragged. A drop asks for a value strictly between its two neighbours and writes exactly one row:
export function sortBetween(before: number | null, after: number | null): number | undefined {
if (before === null && after === null) return 0;
if (before === null) return (after as number) - SORT_STEP;
if (after === null) return before + SORT_STEP;
// Handed over backwards means the caller's neighbour arithmetic is wrong, and
// averaging would hide that behind a row landing somewhere plausible.
if (after <= before) return undefined;
const between = before + (after - before) / 2;
return between > before && between < after ? between : undefined;
}The interesting part is why I chose it. The apprenticeship’s Data Structures and Algorithms module was, if I am honest, mostly material I already knew; what it left behind was not content but the reflex of asking what an operation costs before building it. This was the first time that reflex picked a design of mine rather than grading one: one write per drag instead of a renumber pass, chosen before the component existed, because the analysis said it was the right shape.
The scheme rests on a claim that is true of the reals and only mostly true of a float64: you can always name a number strictly between two others. Halve a gap often enough and you eventually land on two adjacent float64s, at which point “the middle” is one of the bounds, the row would be saved with its neighbour’s value, and it silently stops moving. Detection is exact rather than a threshold, because the gap where that happens depends on the magnitude of the numbers – any constant is either wrong somewhere or needlessly conservative everywhere. So you do the arithmetic and check it, which is the last line above, and the test pins both sides of the boundary:
expect(sortBetween(1, 1 + Number.EPSILON)).toBeUndefined();
// And a gap that only looks tiny is still perfectly usable.
expect(sortBetween(1, 1 + 1e-12)).toBeDefined();Adjacent float64s cannot be split. A gap that only looks tiny is fine.
The “writes one row” claim holds until one of two things happens. The first is not exotic: it is every group’s first drag. sort orders as sort IS NULL, sort, so a placed row sorts ahead of every unplaced one. While NULLs are present, any single write moves the dragged row to the front of the group no matter where it was dropped – dragging the top row to the bottom looked like nothing happening at all. So the drag handler checks first:
const unplaced = original.some((row) => effectiveSort(row) === null);
const sort = unplaced ? undefined : sortBetween(neighbours.before, neighbours.after);
if (sort !== undefined) {
sortOverrides = { ...sortOverrides, [taskId]: sort };A group holding any unplaced row renumbers the whole group with fresh, evenly spaced values instead of writing one – and because a renumber places every row at once, a given group only pays for that the first time. The second trigger is the gap exhaustion from the section above: a collapsed gap fails sortBetween for the identical reason, and falls back to the identical renumber. So the honest complexity claim is longer than “one write per drag”: it is one renumber for a group’s first drag, one write for every drag after that, and one more renumber on the rare occasion a gap actually runs dry. The exhaustion test above needs forty repeated drops into the same slot before that happens once – rare, not impossible, and worth stating plainly rather than rounding down to “O(1) forever”.
The arithmetic has since moved to $lib/sparse-order.ts, because a second list wanted it: pinned chats, which store conversation_pins.sort the same way. The file’s own comment on the move says the second caller “would otherwise have copied the exhaustion rule below, which is exactly the sort of thing that is copied once and then fixed in one place.”
An upload settles what reading could not
The other subject is a different repo. Trove is an R2 file browser: you connect a bucket with scoped API tokens and upload from the browser via presigned URLs. The connect flow used to read the bucket’s CORS policy with GetBucketCors and warn when it looked wrong. Two problems stacked up.
First, the token scoping Trove recommends – Object Read & Write – is denied GetBucketCors. Cloudflare’s own permission table scopes that tier to reading, writing and listing objects only; viewing a bucket’s own configuration, which is where a CORS policy lives and which the S3 API reference lists as a bucket-level rather than an object-level operation, is Admin-tier only. Trove hit exactly that wall against a live bucket during the Phase 0 spike: on the recommended setup the check simply could not read the policy, and “could not read” was being reported as “uploads will fail”. That told a real user their working setup was broken.
Second, when CORS really is wrong, the browser reports a TypeError and nothing else: Failed to fetch, no status, no reason, nothing naming CORS. The presign spike measured this directly – the same page loaded from 127.0.0.1:5173 instead of localhost:5173, an origin not in the policy, failed with exactly that string and nothing more.
The fix stopped inferring and ran the experiment. The server mints a throwaway presigned PUT for _trove/.cors-probe, valid for 120 seconds; the browser uploads five bytes from the real origin; a best-effort DELETE tidies up afterwards:
try {
const put = await fetch(putUrl, {
method: 'PUT',
body: 'trove',
headers: { 'content-type': contentType }
});
if (!put.ok) {
return { ok: false, reason: 'rejected', detail: `R2 refused the upload (HTTP ${put.status}).` };
}
} catch {
return {
ok: false,
reason: 'blocked',
detail:
'The browser blocked the request before it reached R2 – this is what a missing CORS rule looks like.'
};
}Three outcomes, read precisely. A thrown TypeError means the browser blocked the request before it reached R2, which is the CORS-block signature. A non-OK HTTP status means the request reached R2 and was refused for some other reason. A 200 is definitive: uploads work.
The asymmetry is worth naming: success is unambiguous, failure is not. A blocked probe could be a missing CORS rule or a network failure. But on failure the right action is showing the customer the policy to save either way, so the ambiguity costs nothing.
One of these questions was settled by arithmetic before the code existed, one by an experiment after the readable evidence ran out. Both cost more than guessing. Both are cheaper than being wrong.
Sources
Read on 25 August 2026, alongside the Atlas and Trove repositories at HEAD.
- Cloudflare R2 – Authentication (API tokens) – the permission table the CORS claim rests on: Object Read & Write is scoped to “read, write, and list objects in specific buckets”; only the Admin Read & Write / Admin Read only tiers can “view bucket configuration”. Cloudflare does not spell out “GetBucketCors is denied to Object Read & Write” in one sentence – this is the shape those two rows produce.
- Cloudflare R2 – S3 API compatibility – confirms
GetBucketCorsandPutBucketCorsare implemented, and lists both under bucket-level rather than object-level operations, which is the other half of that argument. - Cloudflare R2 – Configure CORS – confirms a presigned URL still goes through the browser’s own CORS enforcement, which is why only a real browser request can settle whether an upload will actually work.
The sparse-ordering claims are checked against atlas-web/src/lib/sparse-order.ts, atlas-web/src/lib/tasks/TaskList.svelte and test/task-ordering.test.ts; the CORS claims against src/lib/corsProbe.ts, src/lib/server/r2/client.ts, src/routes/(app)/connections/+page.server.ts and docs/phase-0-r2-browser.md in Trove, and spikes/presign-upload/README.md for the 127.0.0.1 vs localhost finding – all at HEAD.
Projects
what this writeup is about