skip to content
all writeups
5 min read

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.

otjcollegealgorithmswebcloudflare

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:

atlas-web/src/lib/sparse-order.ts
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:

atlas-web/test/task-ordering.test.ts
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 has one exception, and it 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:

atlas-web/src/lib/tasks/TaskList.svelte
    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, or a gap that has collapsed, renumbers the whole group with fresh evenly spaced values instead. That first drag is the one write the scheme cannot save you; every drag after it is O(1).

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. 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:

src/lib/corsProbe.ts
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.

Projects

what this writeup is about