mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-14 18:19:15 +00:00
feat(canvas): migrate canvas + cooldown storage to DO SQLite
Move pixel state and rate-limit cooldowns out of Upstash Redis and into the existing CanvasRoom Durable Object's SQLite-backed storage. Worker becomes a thin validation/proxy; the DO does atomic cooldown check + pixel write + WS broadcast in one in-memory step. Why: eliminate external dependency, keep $0/month free-tier forever, exploit single-threaded actor for strong consistency without round-trips. Architecture: - canvas_chunks: 256 BLOB rows × 64 KB; CHUNK_COUNT derived from CANVAS_WIDTH × CANVAS_HEIGHT / CHUNK_BYTES so resize is config-only. - cooldowns: user_id → expires_at, 1% sample-rate lazy GC. - Worker forwards /api/canvas, /api/place, /api/ws to DO endpoints. - POST /admin/migrate-from-upstash: token-gated one-shot importer. Phases 1-3 code complete; Upstash dependency stays until prod migration runs and 7-day rollback window passes (Phase 4). Plan: plans/260509-2309-canvas-on-do-storage/
This commit is contained in:
1 parent
e042748b41
commit
c3f7c02f6d
19 files changed
+1688
-119
No files matched your search
@@ -1,20 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from '../src/lib/constants.js';
|
||||
|
||||
// Mock all external dependencies before importing worker
|
||||
vi.mock('../src/lib/canvas-storage.js', () => ({
|
||||
getFullCanvas: vi.fn(() => Promise.resolve(new Uint8Array(10))),
|
||||
setPixels: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
vi.mock('../src/lib/rate-limiter.js', () => ({
|
||||
checkRateLimit: vi.fn(() => Promise.resolve({ allowed: true, retryAfter: 0 })),
|
||||
}));
|
||||
// Worker validation runs at the edge before forwarding to the DO; we don't
|
||||
// need the real DO for these tests, just a stub that returns whatever response
|
||||
// the test wants to simulate.
|
||||
vi.mock('../src/durable-objects/canvas-room.js', () => ({
|
||||
CanvasRoom: class {},
|
||||
}));
|
||||
|
||||
import app from '../src/worker.js';
|
||||
import { checkRateLimit } from '../src/lib/rate-limiter.js';
|
||||
|
||||
/** Helper to create POST request */
|
||||
function postPlace(body) {
|
||||
@@ -25,13 +19,13 @@ function postPlace(body) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Minimal env mock with Durable Object stub */
|
||||
/** Configurable DO stub. Tests that exercise edge-validation never reach the
|
||||
* DO; tests that pass validation get this canned response. */
|
||||
let doResponse = () => Response.json({ ok: true });
|
||||
const env = {
|
||||
CANVAS_ROOM: {
|
||||
idFromName: () => 'room-id',
|
||||
get: () => ({
|
||||
fetch: () => Promise.resolve(new Response('ok')),
|
||||
}),
|
||||
get: () => ({ fetch: () => Promise.resolve(doResponse()) }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -124,8 +118,8 @@ describe('POST /api/place validation', () => {
|
||||
expect((await res.json()).error).toBe('invalid_pixel');
|
||||
});
|
||||
|
||||
it('returns 429 when rate limited', async () => {
|
||||
checkRateLimit.mockResolvedValue({ allowed: false, retryAfter: 1 });
|
||||
it('forwards rate-limit response from DO unchanged', async () => {
|
||||
doResponse = () => Response.json({ error: 'rate_limited', retryAfter: 1 }, { status: 429 });
|
||||
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: 0 }] }), env);
|
||||
expect(res.status).toBe(429);
|
||||
const data = await res.json();
|
||||
@@ -133,16 +127,15 @@ describe('POST /api/place validation', () => {
|
||||
expect(data.retryAfter).toBe(1);
|
||||
});
|
||||
|
||||
it('accepts valid pixel placement', async () => {
|
||||
checkRateLimit.mockResolvedValue({ allowed: true, retryAfter: 0 });
|
||||
it('forwards 200 OK from DO on valid placement', async () => {
|
||||
doResponse = () => Response.json({ ok: true });
|
||||
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: 0 }] }), env);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.ok).toBe(true);
|
||||
expect((await res.json()).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts boundary pixel values', async () => {
|
||||
checkRateLimit.mockResolvedValue({ allowed: true, retryAfter: 0 });
|
||||
doResponse = () => Response.json({ ok: true });
|
||||
const res = await app.fetch(postPlace({
|
||||
pixels: [{ x: CANVAS_WIDTH - 1, y: CANVAS_HEIGHT - 1, color: MAX_COLORS - 1 }],
|
||||
}), env);
|
||||
|
||||
Reference in new issue
Block a user