refactor(canvas): drop Upstash entirely after successful DO migration

Production migration ran cleanly (samples_checked: 9, mismatches: []),
canvas data preserved, drawing verified by user. Phase 4 cleanup:

Removed code:
- src/lib/canvas-storage.js (Upstash GETRANGE reader)
- src/lib/redis-client.js   (Upstash REST helpers)
- src/lib/rate-limiter.js   (orphan since Phase 2)
- src/admin/migrate-from-upstash.js (one-shot importer)
- src/durable-objects/canvas-room.js #handleImport route
- src/durable-objects/lib/chunk-storage.js importFullCanvas helper
- src/worker.js /admin/migrate-from-upstash mount + import

Removed tests:
- test/lib/canvas-storage.test.js
- test/lib/redis-client.test.js
- test/integration/redis-canvas-roundtrip.test.js
- test/durable-objects/canvas-room.test.js (was skipped pending rewrite)
- vitest.integration.config.js (only Redis testcontainers used it)

Removed deps:
- @upstash/redis, ioredis, testcontainers (-184 packages)

Removed constants:
- REDIS_KEY_PREFIX, REDIS_CANVAS_KEY (only used by deleted code)

Removed package.json scripts: test:integration, test:all
Removed CF Worker secrets in production:
  UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, MIGRATION_TOKEN

Tests: 94/94 pass (down from 112 — 18 deleted Upstash-specific).
Bundle: 71.90 KiB (down from 79.43 KiB).
Production verified: canvas data intact (38% non-zero bytes), migration
endpoint returns 404, secret list empty.
This commit is contained in:
2026-05-10 02:05:43 +07:00
parent b890dfb3b7
commit a977adc62d
18 changed files with 18 additions and 3082 deletions
-2208
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -9,20 +9,15 @@
"deploy": "vite build && wrangler deploy",
"preview": "vite build && wrangler dev",
"test": "vitest run --config vitest.config.js",
"test:integration": "vitest run --config vitest.integration.config.js",
"test:all": "vitest run --config vitest.config.js && vitest run --config vitest.integration.config.js",
"test:watch": "vitest --config vitest.config.js"
},
"dependencies": {
"@upstash/redis": "^1.34.3",
"hono": "^4.7.6",
"pixi.js": "^8.18.1"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"ioredis": "^5.10.1",
"svelte": "^5.28.2",
"testcontainers": "^11.14.0",
"vite": "^6.3.2",
"vitest": "^4.1.4",
"wrangler": "^4.14.1"
@@ -1,7 +1,7 @@
---
phase: 3
title: "One-Shot Upstash Migration"
status: code-complete
status: completed
priority: P2
effort: "2h"
dependencies: [2]
@@ -1,7 +1,7 @@
---
phase: 4
title: "Cleanup & Dependency Removal"
status: pending
status: completed
priority: P2
effort: "2h"
dependencies: [3]
@@ -9,6 +9,10 @@ brainstorm: ../reports/brainstorm-260509-2309-canvas-on-do-storage.md
researchReports:
- ../reports/researcher-260509-2255-forever-free-hosting.md
- ../reports/researcher-260509-2246-vercel-migration-feasibility.md
deployment:
productionUrl: https://rplace.miti99.workers.dev
migratedAt: 2026-05-10
postCleanupVersionId: 34cd1900-dd16-47f1-abae-8d195b076b0a
---
# Plan: Migrate canvas storage from Upstash → Durable Object SQLite
@@ -42,12 +46,13 @@ Constants drive resize: `CHUNK_COUNT = ceil(CANVAS_WIDTH * CANVAS_HEIGHT / CHUNK
|---|---|---|---|
| 1 | [Storage Foundation](phase-01-storage-foundation.md) | completed | ~3h |
| 2 | [DO Integration & Worker Proxy](phase-02-do-integration-worker-proxy.md) | completed | ~4h |
| 3 | [One-Shot Upstash Migration](phase-03-one-shot-upstash-migration.md) | code-complete (awaits production run) | ~2h |
| 4 | [Cleanup & Dependency Removal](phase-04-cleanup-dependency-removal.md) | blocked (waits for Phase 3 prod migration + 7d) | ~2h |
| 5 | [Deploy & Documentation](phase-05-deploy-documentation.md) | partial (resize doc done; deploy + README await Phase 4) | ~1h |
| 3 | [One-Shot Upstash Migration](phase-03-one-shot-upstash-migration.md) | completed | ~2h |
| 4 | [Cleanup & Dependency Removal](phase-04-cleanup-dependency-removal.md) | completed | ~2h |
| 5 | [Deploy & Documentation](phase-05-deploy-documentation.md) | partial (deploy done, READMEs done, 7-day observation pending) | ~1h |
**Total estimate:** ~12h (1.5 working days)
**Status:** Phases 1, 2, 3 (code) and 5 (resize doc) complete. Phase 3 prod run + Phase 4 cleanup are user-gated.
**Status:** Phases 14 complete. Phase 5 done except 7-day post-deploy observation window.
**Production:** https://rplace.miti99.workers.dev — version 34cd1900, Upstash fully removed.
## Dependencies
-100
View File
@@ -1,100 +0,0 @@
import { getFullCanvas } from '../lib/canvas-storage.js';
import { TOTAL_PIXELS, CANVAS_WIDTH } from '../lib/constants.js';
/**
* One-shot Upstash → DO migration. Reads the full canvas via the legacy
* Upstash REST path, ships the raw bytes to the DO `/import` endpoint,
* then verifies a few sample coordinates round-trip correctly.
*
* Removed entirely in Phase 4 of the canvas-on-do storage plan, along
* with the @upstash/redis dependency.
*
* @param {object} env - Worker env (Upstash creds + CANVAS_ROOM binding)
* @param {DurableObjectStub} roomStub
* @param {{force?: boolean}} opts
* @returns {Promise<Response>}
*/
export async function migrateFromUpstash(env, roomStub, { force = false } = {}) {
let upstashBytes;
try {
upstashBytes = await getFullCanvas(env);
} catch (err) {
return Response.json({ error: 'upstash_read_failed', message: String(err) }, { status: 500 });
}
if (upstashBytes.length !== TOTAL_PIXELS) {
return Response.json(
{
error: 'upstash_size_mismatch',
expected: TOTAL_PIXELS,
got: upstashBytes.length,
},
{ status: 500 },
);
}
const importUrl = force ? 'http://do/import?force=1' : 'http://do/import';
const importRes = await roomStub.fetch(importUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: upstashBytes,
});
if (!importRes.ok) {
const text = await importRes.text().catch(() => '');
return Response.json(
{ error: 'do_import_failed', status: importRes.status, body: text },
{ status: 502 },
);
}
// Round-trip verification: pull canvas back from the DO and compare a
// handful of sampled bytes. Catches subtle byte-order or chunking bugs.
const verifyRes = await roomStub.fetch('http://do/canvas');
if (!verifyRes.ok) {
return Response.json({ error: 'do_verify_read_failed' }, { status: 502 });
}
const doBytes = new Uint8Array(await verifyRes.arrayBuffer());
const samples = pickSampleOffsets(upstashBytes);
const mismatches = [];
for (const offset of samples) {
if (doBytes[offset] !== upstashBytes[offset]) {
mismatches.push({ offset, upstash: upstashBytes[offset], do: doBytes[offset] });
}
}
return Response.json({
ok: mismatches.length === 0,
bytes_imported: upstashBytes.length,
samples_checked: samples.length,
mismatches,
});
}
/**
* Pick byte offsets to verify post-migration. Includes corners, midpoints,
* and (preferentially) up to 5 offsets where the source has a non-zero
* value — those catch byte-order bugs that all-zero samples would miss.
*/
function pickSampleOffsets(srcBytes) {
const offsets = new Set([
0, // (0, 0)
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
]);
// Add up to 5 non-zero offsets so we don't only check empty pixels.
let found = 0;
const stride = Math.max(1, Math.floor(TOTAL_PIXELS / 1000));
for (let i = 0; i < TOTAL_PIXELS && found < 5; i += stride) {
if (srcBytes[i] !== 0) {
offsets.add(i);
found++;
}
}
return [...offsets];
}
+6 -34
View File
@@ -1,6 +1,6 @@
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE, TOTAL_PIXELS } from '../lib/constants.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from '../lib/constants.js';
import { init as initSchema } from './lib/schema.js';
import { readAllChunks, writePixels, importFullCanvas } from './lib/chunk-storage.js';
import { readAllChunks, writePixels } from './lib/chunk-storage.js';
import { tryAcquire } from './lib/cooldown-store.js';
/**
@@ -23,11 +23,10 @@ export class CanvasRoom {
async fetch(request) {
const url = new URL(request.url);
switch (url.pathname) {
case '/canvas': return this.#handleGetCanvas();
case '/place': return this.#handlePlace(request);
case '/import': return this.#handleImport(request);
case '/ws': return this.#handleWsUpgrade();
default: return new Response('not found', { status: 404 });
case '/canvas': return this.#handleGetCanvas();
case '/place': return this.#handlePlace(request);
case '/ws': return this.#handleWsUpgrade();
default: return new Response('not found', { status: 404 });
}
}
@@ -87,33 +86,6 @@ export class CanvasRoom {
return Response.json({ ok: true });
}
/**
* One-shot Upstash → DO migration target. Body is the full raw canvas
* (TOTAL_PIXELS bytes). Token-gated by the worker; this DO endpoint is
* not internet-reachable except via that gate.
*/
async #handleImport(request) {
const url = new URL(request.url);
const force = url.searchParams.get('force') === '1';
const buf = new Uint8Array(await request.arrayBuffer());
if (buf.length !== TOTAL_PIXELS) {
return Response.json(
{ error: 'size_mismatch', expected: TOTAL_PIXELS, got: buf.length },
{ status: 400 },
);
}
let result;
try {
result = importFullCanvas(this.sql, buf, force);
} catch (err) {
return Response.json({ error: 'import_failed', message: String(err) }, { status: 500 });
}
if (result.skipped) {
return Response.json({ error: 'already_populated', hint: 'pass ?force=1 to overwrite' }, { status: 409 });
}
return Response.json({ ok: true, chunks_written: result.imported });
}
#handleWsUpgrade() {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
+1 -33
View File
@@ -1,4 +1,4 @@
import { CANVAS_WIDTH, TOTAL_PIXELS, CHUNK_BYTES, CHUNK_COUNT } from '../../lib/constants.js';
import { CANVAS_WIDTH, TOTAL_PIXELS, CHUNK_BYTES } from '../../lib/constants.js';
/**
* Canvas pixel storage as fixed-size BLOB chunks in DO SQLite.
@@ -102,35 +102,3 @@ export function writePixels(sql, pixels) {
}
}
/**
* Bulk replace the entire canvas. Used by the one-shot Upstash migration.
* Refuses to run if the canvas already has data, unless `force` is true.
*
* @param {SqlStorage} sql
* @param {Uint8Array} fullCanvas - exactly TOTAL_PIXELS bytes
* @param {boolean} force - overwrite even if rows already exist
* @returns {{imported: number, skipped: boolean}}
*/
export function importFullCanvas(sql, fullCanvas, force = false) {
if (fullCanvas.length !== TOTAL_PIXELS) {
throw new Error(`expected ${TOTAL_PIXELS} bytes, got ${fullCanvas.length}`);
}
if (!force) {
const existing = sql.exec('SELECT COUNT(*) AS n FROM canvas_chunks').one().n;
if (existing > 0) {
return { imported: 0, skipped: true };
}
}
for (let chunkId = 0; chunkId < CHUNK_COUNT; chunkId++) {
const start = chunkId * CHUNK_BYTES;
const end = Math.min(start + CHUNK_BYTES, TOTAL_PIXELS);
const slice = fullCanvas.slice(start, end);
sql.exec(
'INSERT INTO canvas_chunks (chunk_id, bytes) VALUES (?, ?) ' +
'ON CONFLICT(chunk_id) DO UPDATE SET bytes = excluded.bytes',
chunkId,
slice,
);
}
return { imported: CHUNK_COUNT, skipped: false };
}
-67
View File
@@ -1,67 +0,0 @@
import { redisRaw, redisRawBinary } from './redis-client.js';
import { CANVAS_WIDTH, TOTAL_PIXELS, REDIS_CANVAS_KEY } from './constants.js';
const CANVAS_BYTES = TOTAL_PIXELS;
// Upstash REST returns binary as base64 (~4/3 overhead) and caps responses at
// 10 MB on the Free plan. 4 MiB raw → ~5.33 MB base64, safely under the limit,
// and divides a 16 MiB canvas into exactly 4 chunks.
const CHUNK_BYTES = 4 * 1024 * 1024;
/**
* Get the full canvas as a Uint8Array of raw bytes.
* Fetches in parallel chunks to stay under Upstash's per-request size cap.
* Returns a zero-filled buffer if canvas doesn't exist yet; truncated chunks
* are zero-padded (GETRANGE clamps past-end reads to "" by default).
* @param {object} env
* @returns {Promise<Uint8Array>}
*/
export async function getFullCanvas(env) {
const ranges = [];
for (let start = 0; start < CANVAS_BYTES; start += CHUNK_BYTES) {
const end = Math.min(start + CHUNK_BYTES, CANVAS_BYTES) - 1;
ranges.push([start, end]);
}
const results = await Promise.all(
ranges.map(([start, end]) =>
redisRawBinary(env, ['GETRANGE', REDIS_CANVAS_KEY, String(start), String(end)])
)
);
const out = new Uint8Array(CANVAS_BYTES);
let totalRead = 0;
for (let i = 0; i < ranges.length; i++) {
const base64 = results[i];
if (!base64) continue;
const raw = atob(base64);
const offset = ranges[i][0];
for (let j = 0; j < raw.length; j++) {
out[offset + j] = raw.charCodeAt(j);
}
totalRead += raw.length;
}
if (totalRead > 0 && totalRead < CANVAS_BYTES) {
console.warn(`Canvas read short: got ${totalRead} bytes, expected ${CANVAS_BYTES}; zero-padding tail`);
}
return out;
}
/**
* Set multiple pixels in a single atomic BITFIELD command.
* Uses raw REST API — SDK bitfield builder is broken in @upstash/redis 1.x.
* With u8, BITFIELD offsets are byte-aligned (`#N` = byte N).
* @param {object} env
* @param {Array<{x: number, y: number, color: number}>} pixels
*/
export async function setPixels(env, pixels) {
if (!pixels.length) return;
const command = ['BITFIELD', REDIS_CANVAS_KEY];
for (const { x, y, color } of pixels) {
const offset = y * CANVAS_WIDTH + x;
command.push('SET', 'u8', `#${offset}`, String(color));
}
await redisRaw(env, command);
}
-5
View File
@@ -18,11 +18,6 @@ export const MAX_BATCH_SIZE = 2048;
export const CHUNK_BYTES = 65536;
export const CHUNK_COUNT = Math.ceil(TOTAL_PIXELS / CHUNK_BYTES);
/** Legacy Upstash keys — used by the one-shot migration endpoint only.
* Removed after migration verification (see Phase 4 of the canvas-on-do
* storage plan). */
export const REDIS_KEY_PREFIX = 'rplace:';
export const REDIS_CANVAS_KEY = `${REDIS_KEY_PREFIX}canvas:v2`;
/**
* Build the 256-color palette deterministically:
-23
View File
@@ -1,23 +0,0 @@
import { getRedis } from './redis-client.js';
import { REQUEST_COOLDOWN_SEC, REDIS_KEY_PREFIX } from './constants.js';
/**
* One request per user per REQUEST_COOLDOWN_SEC.
* Batch size is independent of the cooldown — the caller validates it separately.
*
* Uses SET NX EX atomically: the first request in a window wins, subsequent
* requests return null until the key expires.
*
* @param {object} env
* @param {string} userId
* @returns {Promise<{allowed: boolean, retryAfter: number}>}
*/
export async function checkRateLimit(env, userId) {
const redis = getRedis(env);
const key = `${REDIS_KEY_PREFIX}cooldown:${userId}`;
const result = await redis.set(key, '1', { nx: true, ex: REQUEST_COOLDOWN_SEC });
if (result === 'OK') {
return { allowed: true, retryAfter: 0 };
}
return { allowed: false, retryAfter: REQUEST_COOLDOWN_SEC };
}
-67
View File
@@ -1,67 +0,0 @@
import { Redis } from '@upstash/redis/cloudflare';
/**
* Create an Upstash Redis client from CF Worker env bindings.
* @param {object} env - Cloudflare Worker env (UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN)
* @returns {Redis}
*/
export function getRedis(env) {
return new Redis({
url: env.UPSTASH_REDIS_REST_URL,
token: env.UPSTASH_REDIS_REST_TOKEN,
});
}
/**
* Execute a raw Redis command via Upstash REST API.
* Useful for commands where the SDK API is unreliable (e.g., BITFIELD).
* @param {object} env
* @param {string[]} command - Redis command as array, e.g. ['BITFIELD', 'key', 'SET', ...]
* @returns {Promise<*>} the `result` field from the Upstash response
*/
export async function redisRaw(env, command) {
const res = await fetch(env.UPSTASH_REDIS_REST_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(command),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Redis HTTP ${res.status}: ${text}`);
}
// Upstash returns 200 with {"error":"..."} for application errors.
const body = await res.json();
if (body && body.error) {
throw new Error(`Redis error: ${body.error}`);
}
return body.result;
}
/**
* Execute a Redis command via Upstash path-based REST API with base64 response.
* Uses Upstash-Encoding: base64 for binary-safe response transport.
* @param {object} env
* @param {string[]} command - Redis command as array, e.g. ['GETRANGE', 'key', '0', '100']
* @returns {Promise<string|null>} base64-encoded result string
*/
export async function redisRawBinary(env, command) {
const path = command.map((arg) => encodeURIComponent(String(arg))).join('/');
const res = await fetch(`${env.UPSTASH_REDIS_REST_URL}/${path}`, {
headers: {
Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`,
'Upstash-Encoding': 'base64',
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Redis HTTP ${res.status}: ${text}`);
}
const body = await res.json();
if (body && body.error) {
throw new Error(`Redis error: ${body.error}`);
}
return body.result; // base64-encoded string
}
-15
View File
@@ -1,7 +1,6 @@
import { Hono } from 'hono';
import { getUserId } from './lib/get-user-id.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from './lib/constants.js';
import { migrateFromUpstash } from './admin/migrate-from-upstash.js';
export { CanvasRoom } from './durable-objects/canvas-room.js';
@@ -73,18 +72,4 @@ app.get('/api/ws', async (c) => {
return room(c.env).fetch('http://do/ws', c.req.raw);
});
/**
* POST /admin/migrate-from-upstash — one-shot Upstash → DO canvas import.
* Token-gated; deleted in Phase 4 of the canvas-on-do storage plan.
*/
app.post('/admin/migrate-from-upstash', async (c) => {
const auth = c.req.header('Authorization') || '';
const expected = `Bearer ${c.env.MIGRATION_TOKEN || ''}`;
if (!c.env.MIGRATION_TOKEN || auth !== expected) {
return c.json({ error: 'forbidden' }, 403);
}
const force = c.req.query('force') === '1';
return migrateFromUpstash(c.env, room(c.env), { force });
});
export default app;
-105
View File
@@ -1,105 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CanvasRoom } from '../../src/durable-objects/canvas-room.js';
// TODO: Phase 4 of canvas-on-do storage plan rewrites these tests against the
// new SQLite-backed DO via @cloudflare/vitest-pool-workers. The pre-migration
// mocks here can't model state.storage.sql, so the suite is skipped until the
// rewrite. See plans/260509-2309-canvas-on-do-storage/phase-04-cleanup-dependency-removal.md.
const describeOrSkip = describe.skip;
/** Create a mock WebSocket */
function mockWebSocket() {
return { send: vi.fn(), close: vi.fn() };
}
/** Create a mock Durable Object state with Hibernation API */
function mockState() {
const sockets = new Set();
return {
acceptWebSocket: vi.fn((ws) => sockets.add(ws)),
getWebSockets: vi.fn(() => [...sockets]),
_sockets: sockets,
};
}
describeOrSkip('CanvasRoom', () => {
let state;
let room;
beforeEach(() => {
state = mockState();
room = new CanvasRoom(state);
});
describe('broadcast', () => {
it('sends pixel data to all connected WebSockets', async () => {
const ws1 = mockWebSocket();
const ws2 = mockWebSocket();
state._sockets.add(ws1);
state._sockets.add(ws2);
const pixels = [{ x: 10, y: 20, color: 5 }];
const req = new Request('http://internal/broadcast', {
method: 'POST',
body: JSON.stringify(pixels),
});
const res = await room.fetch(req);
expect(res.status).toBe(200);
const expected = JSON.stringify({ type: 'pixels', pixels });
expect(ws1.send).toHaveBeenCalledWith(expected);
expect(ws2.send).toHaveBeenCalledWith(expected);
});
it('closes WebSocket on send failure', async () => {
const ws = mockWebSocket();
ws.send.mockImplementation(() => { throw new Error('disconnected'); });
state._sockets.add(ws);
const req = new Request('http://internal/broadcast', {
method: 'POST',
body: JSON.stringify([{ x: 0, y: 0, color: 1 }]),
});
const res = await room.fetch(req);
expect(res.status).toBe(200);
expect(ws.close).toHaveBeenCalledWith(1011, 'send failed');
});
it('broadcasts to empty room without error', async () => {
const req = new Request('http://internal/broadcast', {
method: 'POST',
body: JSON.stringify([]),
});
const res = await room.fetch(req);
expect(res.status).toBe(200);
});
});
describe('webSocketClose', () => {
it('closes the WebSocket with given code and reason', () => {
const ws = mockWebSocket();
room.webSocketClose(ws, 1000, 'normal', true);
expect(ws.close).toHaveBeenCalledWith(1000, 'normal');
});
});
describe('webSocketError', () => {
it('closes the WebSocket with error code', () => {
const ws = mockWebSocket();
room.webSocketError(ws, new Error('test'));
expect(ws.close).toHaveBeenCalledWith(1011, 'error');
});
});
describe('webSocketMessage', () => {
it('ignores messages (no-op)', () => {
const ws = mockWebSocket();
// Should not throw
room.webSocketMessage(ws, 'hello');
expect(ws.send).not.toHaveBeenCalled();
});
});
});
@@ -1,174 +0,0 @@
/**
* Integration tests: BITFIELD write → GETRANGE read round-trip with real Redis.
* Uses Testcontainers to spin up a Redis Docker container.
* Verifies the exact command sequences our setPixels/getFullCanvas use.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { GenericContainer } from 'testcontainers';
import Redis from 'ioredis';
import { decodeCanvas } from '../../src/lib/canvas-decoder.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, REDIS_CANVAS_KEY } from '../../src/lib/constants.js';
const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
const CANVAS_BYTES = TOTAL_PIXELS; // 1 byte per pixel (u8)
let container;
let redis;
beforeAll(async () => {
container = await new GenericContainer('redis:7-alpine')
.withExposedPorts(6379)
.start();
redis = new Redis({
host: container.getHost(),
port: container.getMappedPort(6379),
});
}, 60000);
afterAll(async () => {
await redis?.quit();
await container?.stop();
});
/**
* Replicate setPixels: build the same BITFIELD command our worker sends.
*/
async function writePixels(pixels) {
const args = [];
for (const { x, y, color } of pixels) {
const offset = y * CANVAS_WIDTH + x;
args.push('SET', 'u8', `#${offset}`, String(color));
}
return redis.call('BITFIELD', REDIS_CANVAS_KEY, ...args);
}
/**
* Replicate getFullCanvas: GETRANGE → raw bytes → decode.
* Uses Buffer (binary-safe, unlike Upstash REST JSON transport).
*/
async function readCanvasBytes() {
const buf = await redis.getrangeBuffer(REDIS_CANVAS_KEY, 0, CANVAS_BYTES - 1);
if (!buf || buf.length === 0) {
return new Uint8Array(CANVAS_BYTES);
}
const bytes = new Uint8Array(buf.length);
for (let i = 0; i < buf.length; i++) {
bytes[i] = buf[i];
}
if (bytes.length < CANVAS_BYTES) {
const padded = new Uint8Array(CANVAS_BYTES);
padded.set(bytes);
return padded;
}
return bytes;
}
describe('Redis BITFIELD canvas round-trip', () => {
it('stores and reads back a single pixel', async () => {
await writePixels([{ x: 0, y: 0, color: 15 }]);
const bytes = await readCanvasBytes();
const indices = decodeCanvas(bytes.buffer);
expect(indices[0]).toBe(15);
});
it('round-trips 256 color values across the palette', async () => {
const pixels = [];
for (let i = 0; i < 256; i++) {
pixels.push({ x: i, y: 1, color: i });
}
await writePixels(pixels);
const bytes = await readCanvasBytes();
const indices = decodeCanvas(bytes.buffer);
for (let i = 0; i < 256; i++) {
expect(indices[1 * CANVAS_WIDTH + i]).toBe(i);
}
});
it('handles pixels at various canvas positions', async () => {
const testCases = [
{ x: 0, y: 0, color: 1 },
{ x: CANVAS_WIDTH - 1, y: 0, color: 255 },
{ x: 0, y: CANVAS_HEIGHT - 1, color: 128 },
{ x: CANVAS_WIDTH - 1, y: CANVAS_HEIGHT - 1, color: 42 },
{ x: CANVAS_WIDTH / 2, y: CANVAS_HEIGHT / 2, color: 200 },
];
await writePixels(testCases);
const bytes = await readCanvasBytes();
const indices = decodeCanvas(bytes.buffer);
for (const { x, y, color } of testCases) {
expect(indices[y * CANVAS_WIDTH + x]).toBe(color);
}
});
it('overwrites existing pixels correctly', async () => {
await writePixels([{ x: 500, y: 500, color: 10 }]);
let bytes = await readCanvasBytes();
let indices = decodeCanvas(bytes.buffer);
expect(indices[500 * CANVAS_WIDTH + 500]).toBe(10);
await writePixels([{ x: 500, y: 500, color: 250 }]);
bytes = await readCanvasBytes();
indices = decodeCanvas(bytes.buffer);
expect(indices[500 * CANVAS_WIDTH + 500]).toBe(250);
});
it('batch writes are atomic (all pixels in one BITFIELD)', async () => {
const batchSize = 500;
const pixels = [];
for (let i = 0; i < batchSize; i++) {
pixels.push({ x: i, y: 2, color: i % 256 });
}
await writePixels(pixels);
const bytes = await readCanvasBytes();
const indices = decodeCanvas(bytes.buffer);
for (let i = 0; i < batchSize; i++) {
expect(indices[2 * CANVAS_WIDTH + i]).toBe(i % 256);
}
});
it('adjacent pixels do not corrupt each other (byte boundary)', async () => {
// With u8 each pixel is its own byte; verify no bleed between neighbors.
const pixels = [];
for (let i = 0; i < 16; i++) pixels.push({ x: i, y: 3, color: 255 }); // all bits
for (let i = 16; i < 32; i++) pixels.push({ x: i, y: 3, color: 0 }); // zero
await writePixels(pixels);
const bytes = await readCanvasBytes();
const indices = decodeCanvas(bytes.buffer);
for (let i = 0; i < 16; i++) expect(indices[3 * CANVAS_WIDTH + i]).toBe(255);
for (let i = 16; i < 32; i++) expect(indices[3 * CANVAS_WIDTH + i]).toBe(0);
});
});
describe('Redis rate limiter cooldown (SET NX EX)', () => {
const COOLDOWN_SEC = 1;
const key = 'rplace:cooldown:test-user';
/** Mirrors checkRateLimit: SET key "1" NX EX <cooldown>. */
async function tryAcquire() {
const res = await redis.set(key, '1', 'EX', COOLDOWN_SEC, 'NX');
return res === 'OK';
}
it('allows first request for a fresh user', async () => {
await redis.del(key);
expect(await tryAcquire()).toBe(true);
});
it('rejects second request within cooldown window', async () => {
await redis.del(key);
expect(await tryAcquire()).toBe(true);
expect(await tryAcquire()).toBe(false);
});
it('allows again after cooldown expires', async () => {
await redis.del(key);
expect(await tryAcquire()).toBe(true);
// Wait slightly longer than cooldown for the EX key to expire.
await new Promise((r) => setTimeout(r, (COOLDOWN_SEC * 1000) + 100));
expect(await tryAcquire()).toBe(true);
}, 5000);
});
-113
View File
@@ -1,113 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CANVAS_WIDTH, REDIS_CANVAS_KEY } from '../../src/lib/constants.js';
// Mock redis-client module
vi.mock('../../src/lib/redis-client.js', () => ({
redisRaw: vi.fn(),
redisRawBinary: vi.fn(),
}));
import { getFullCanvas, setPixels } from '../../src/lib/canvas-storage.js';
import { redisRaw, redisRawBinary } from '../../src/lib/redis-client.js';
const CANVAS_BYTES = CANVAS_WIDTH * CANVAS_WIDTH; // u8 = 1 byte per pixel
describe('setPixels', () => {
beforeEach(() => vi.clearAllMocks());
it('does nothing for empty array', async () => {
await setPixels({}, []);
expect(redisRaw).not.toHaveBeenCalled();
});
it('builds correct BITFIELD command for single pixel', async () => {
redisRaw.mockResolvedValue({ result: [0] });
await setPixels({}, [{ x: 10, y: 20, color: 5 }]);
const call = redisRaw.mock.calls[0][1];
expect(call[0]).toBe('BITFIELD');
expect(call[1]).toBe(REDIS_CANVAS_KEY);
expect(call[2]).toBe('SET');
expect(call[3]).toBe('u8');
const expectedOffset = 20 * CANVAS_WIDTH + 10;
expect(call[4]).toBe(`#${expectedOffset}`);
expect(call[5]).toBe('5');
});
it('builds correct BITFIELD command for multiple pixels', async () => {
redisRaw.mockResolvedValue({ result: [0, 0] });
await setPixels({}, [
{ x: 0, y: 0, color: 1 },
{ x: 1, y: 0, color: 255 },
]);
const call = redisRaw.mock.calls[0][1];
// BITFIELD key SET u8 #0 1 SET u8 #1 255
expect(call).toEqual([
'BITFIELD', REDIS_CANVAS_KEY,
'SET', 'u8', '#0', '1',
'SET', 'u8', '#1', '255',
]);
});
it('computes offset correctly for various positions', async () => {
redisRaw.mockResolvedValue({ result: [0] });
// Last pixel of the canvas.
const lastX = CANVAS_WIDTH - 1;
const lastY = CANVAS_WIDTH - 1;
await setPixels({}, [{ x: lastX, y: lastY, color: 0 }]);
const offset = lastY * CANVAS_WIDTH + lastX;
expect(redisRaw.mock.calls[0][1][4]).toBe(`#${offset}`);
});
});
describe('getFullCanvas', () => {
beforeEach(() => vi.clearAllMocks());
it('returns zero-filled buffer when Redis returns null', async () => {
redisRawBinary.mockResolvedValue(null);
const buf = await getFullCanvas({});
expect(buf.length).toBe(CANVAS_BYTES);
expect(buf.every((b) => b === 0)).toBe(true);
});
it('returns zero-filled buffer when Redis returns empty string', async () => {
redisRawBinary.mockResolvedValue('');
const buf = await getFullCanvas({});
expect(buf.length).toBe(CANVAS_BYTES);
expect(buf.every((b) => b === 0)).toBe(true);
});
it('decodes base64 response correctly', async () => {
// 3 bytes: [0x78, 0xAB, 0xFF]
const base64 = btoa(String.fromCharCode(0x78, 0xAB, 0xFF));
redisRawBinary.mockResolvedValue(base64);
const buf = await getFullCanvas({});
expect(buf[0]).toBe(0x78);
expect(buf[1]).toBe(0xAB);
expect(buf[2]).toBe(0xFF);
});
it('pads short responses to full canvas size', async () => {
const base64 = btoa(String.fromCharCode(0xFF));
redisRawBinary.mockResolvedValue(base64);
const buf = await getFullCanvas({});
expect(buf.length).toBe(CANVAS_BYTES);
expect(buf[0]).toBe(0xFF);
expect(buf[1]).toBe(0);
});
it('handles all byte values (0x00-0xFF) without corruption', async () => {
// This is the test that would have caught the binary encoding bug
const allBytes = new Array(256);
for (let i = 0; i < 256; i++) allBytes[i] = String.fromCharCode(i);
const base64 = btoa(allBytes.join(''));
redisRawBinary.mockResolvedValue(base64);
const buf = await getFullCanvas({});
for (let i = 0; i < 256; i++) {
expect(buf[i]).toBe(i);
}
});
});
-117
View File
@@ -1,117 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock global fetch
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
import { redisRaw, redisRawBinary } from '../../src/lib/redis-client.js';
const env = {
UPSTASH_REDIS_REST_URL: 'https://redis.example.com',
UPSTASH_REDIS_REST_TOKEN: 'test-token',
};
describe('redisRaw', () => {
beforeEach(() => vi.clearAllMocks());
it('sends POST with JSON body and auth header', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ result: 'OK' }),
});
await redisRaw(env, ['SET', 'key', 'value']);
expect(mockFetch).toHaveBeenCalledWith(env.UPSTASH_REDIS_REST_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`,
'Content-Type': 'application/json',
},
body: '["SET","key","value"]',
});
});
it('returns the response result field', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ result: 'PONG' }),
});
const result = await redisRaw(env, ['PING']);
expect(result).toBe('PONG');
});
it('throws on non-ok HTTP response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 401,
text: () => Promise.resolve('Unauthorized'),
});
await expect(redisRaw(env, ['PING'])).rejects.toThrow('Redis HTTP 401');
});
it('throws on Upstash 200 with error envelope', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ error: 'ERR wrong number of arguments' }),
});
await expect(redisRaw(env, ['BITFIELD'])).rejects.toThrow(/Redis error.*wrong number of arguments/);
});
});
describe('redisRawBinary', () => {
beforeEach(() => vi.clearAllMocks());
it('uses path-based URL with Upstash-Encoding header', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ result: 'dGVzdA==' }),
});
await redisRawBinary(env, ['GETRANGE', 'mykey', '0', '100']);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe('https://redis.example.com/GETRANGE/mykey/0/100');
expect(opts.headers['Upstash-Encoding']).toBe('base64');
expect(opts.headers.Authorization).toBe(`Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`);
});
it('URL-encodes special characters in path segments', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ result: null }),
});
await redisRawBinary(env, ['GETRANGE', 'rplace:canvas', '0', '10']);
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('rplace%3Acanvas');
});
it('returns base64-encoded result string', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ result: 'AQID' }),
});
const result = await redisRawBinary(env, ['GET', 'key']);
expect(result).toBe('AQID');
});
it('throws on non-ok HTTP response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve('Internal Error'),
});
await expect(redisRawBinary(env, ['GET', 'key'])).rejects.toThrow('Redis HTTP 500');
});
it('throws on Upstash 200 with error envelope', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ error: 'ERR no such key' }),
});
await expect(redisRawBinary(env, ['GET', 'missing'])).rejects.toThrow(/Redis error.*no such key/);
});
});
-10
View File
@@ -1,10 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['test/integration/**/*.test.js'],
testTimeout: 60000,
hookTimeout: 60000,
},
});