fix(canvas): code-review fixes + sync user-facing docs to DO storage

Fixes from code review of canvas-on-do migration (commit c3f7c02):

- worker.js /api/ws: rewrite request URL to '/ws' so the DO pathname
  switch dispatches correctly. The original c.req.raw kept '/api/ws'
  which the DO never matched → 404 on every WS upgrade.
- migrate-from-upstash.js pickSampleOffsets: use TOTAL_PIXELS - 1 for the
  last byte instead of CANVAS_WIDTH * CANVAS_WIDTH (only correct when
  the canvas is square; constants explicitly invite non-square).
- chunk-storage.js writePixels: clarify atomicity comment — the loop is
  atomic *because it has no awaits*, not because of any implicit DO
  transaction. Added guidance for future maintainers.
- cooldown-store.js tryAcquire: GC sweep wrapped in try/catch so a
  transient failure can't drop the user's allowed: true response.

Docs:
- README.md: drop Upstash from tech stack, redraw architecture,
  document new project layout (durable-objects/lib, admin/), add
  CHUNK_BYTES to configuration table.
- docs/system-architecture.md: full rewrite for DO-storage data flow,
  document SQLite schema, race-safe rate-limit pattern, free-tier table.
- docs/deployment-guide.md: drop Upstash setup, add optional one-shot
  migration runbook, update free-tier table to actual May 2026 limits.

Tests: 112 pass, 6 skipped (pending Phase 4 rewrite via
@cloudflare/vitest-pool-workers). Bundle dry-run clean.

Local wrangler dev smoke test was attempted but the sandboxed env
hangs HTTP requests at the workerd layer (TCP connects, no response).
Routing fix verified by code inspection; user must verify in their
own dev or production.
This commit is contained in:
tiennm99 committed 2026-05-10 00:37:27 +07:00
1 parent cdf2295ef6
commit b890dfb3b7
7 files changed
+271 -137

No files matched your search

+2 -2
View File
@@ -80,8 +80,8 @@ export async function migrateFromUpstash(env, roomStub, { force = false } = {})
function pickSampleOffsets(srcBytes) {
const offsets = new Set([
0, // (0, 0)
CANVAS_WIDTH - 1, // top-right
(CANVAS_WIDTH * CANVAS_WIDTH) - 1, // bottom-right (square canvas)
CANVAS_WIDTH - 1, // first-row right edge
TOTAL_PIXELS - 1, // last byte
Math.floor(TOTAL_PIXELS / 2), // middle
Math.floor(TOTAL_PIXELS / 2) + CANVAS_WIDTH + 1, // off-middle
]);
+4 -2
View File
@@ -81,8 +81,10 @@ export function writePixels(sql, pixels) {
// For each touched chunk: read current bytes (or zero-fill), apply edits,
// write back. INSERT OR REPLACE upserts the row.
// Note: CF DO transactions are implicit per-fetch handler invocation —
// multiple sql.exec calls within the same handler are atomic.
// Atomicity: this loop is fully synchronous (no `await`) and the DO is
// single-threaded, so the chunk updates are atomic with respect to other
// requests. If anyone adds an `await` inside this loop, wrap the whole
// block in `state.storage.transactionSync(() => { ... })` to preserve it.
for (const [chunkId, edits] of groups) {
const buf = readChunk(sql, chunkId);
// readChunk returns a fresh Uint8Array (or wraps a buffer); writes must
+6 -2
View File
@@ -33,7 +33,9 @@ export function tryAcquire(sql, userId, now = Date.now()) {
// Drain the cursor so rowsWritten is finalized.
updateCursor.toArray();
if (updateCursor.rowsWritten > 0) {
if (Math.random() < GC_SAMPLE_RATE) gc(sql, now);
if (Math.random() < GC_SAMPLE_RATE) {
try { gc(sql, now); } catch { /* GC is best-effort */ }
}
return { allowed: true, retryAfter: 0 };
}
@@ -45,7 +47,9 @@ export function tryAcquire(sql, userId, now = Date.now()) {
userId,
expiresAt,
);
if (Math.random() < GC_SAMPLE_RATE) gc(sql, now);
if (Math.random() < GC_SAMPLE_RATE) {
try { gc(sql, now); } catch { /* GC is best-effort */ }
}
return { allowed: true, retryAfter: 0 };
} catch {
return { allowed: false, retryAfter: REQUEST_COOLDOWN_SEC };
+4 -2
View File
@@ -62,13 +62,15 @@ app.post('/api/place', async (c) => {
});
});
/** GET /api/ws — WebSocket upgrade routed to the DO. */
/** GET /api/ws — WebSocket upgrade routed to the DO.
* The DO routes by url.pathname, so we rewrite the URL to `/ws` while
* preserving the original headers (including Upgrade) via the request init. */
app.get('/api/ws', async (c) => {
const upgradeHeader = c.req.header('Upgrade');
if (upgradeHeader !== 'websocket') {
return c.text('Expected WebSocket', 426);
}
return room(c.env).fetch(c.req.raw);
return room(c.env).fetch('http://do/ws', c.req.raw);
});
/**