124 lines
3.7 KiB
TypeScript
124 lines
3.7 KiB
TypeScript
const Database = require('better-sqlite3');
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
|
|
const dbPath = path.join(__dirname, '../data/invicanvas.db');
|
|
const dbDir = path.dirname(dbPath);
|
|
|
|
if (!fs.existsSync(dbDir)) {
|
|
fs.mkdirSync(dbDir, { recursive: true });
|
|
}
|
|
|
|
export const db = new Database(dbPath);
|
|
|
|
db.pragma('journal_mode = WAL');
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
github_id TEXT UNIQUE,
|
|
username TEXT NOT NULL,
|
|
display_name TEXT,
|
|
avatar_url TEXT,
|
|
email TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS pixels (
|
|
x INTEGER NOT NULL,
|
|
y INTEGER NOT NULL,
|
|
color TEXT NOT NULL,
|
|
user_id INTEGER,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (x, y),
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_pixels_updated ON pixels(updated_at);
|
|
`);
|
|
|
|
const tableInfo = db.pragma('table_info(pixels)') as { name: string }[];
|
|
const hasUserId = tableInfo.some(col => col.name === 'user_id');
|
|
if (!hasUserId) {
|
|
db.exec('ALTER TABLE pixels ADD COLUMN user_id INTEGER REFERENCES users(id)');
|
|
}
|
|
|
|
const indexes = db.pragma('index_list(pixels)') as { name: string }[];
|
|
const hasIndex = indexes.some(idx => idx.name === 'idx_pixels_updated');
|
|
if (!hasIndex) {
|
|
db.exec('CREATE INDEX idx_pixels_updated ON pixels(updated_at)');
|
|
}
|
|
|
|
export function generateUserId(): string {
|
|
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
function isValidColor(color: string): boolean {
|
|
return /^#[0-9A-Fa-f]{6}$/.test(color);
|
|
}
|
|
|
|
export function validatePixel(pixel: { x: number; y: number; color: string }): string | null {
|
|
if (!Number.isInteger(pixel.x) || pixel.x < -1000000 || pixel.x > 1000000) {
|
|
return 'Invalid x coordinate';
|
|
}
|
|
if (!Number.isInteger(pixel.y) || pixel.y < -1000000 || pixel.y > 1000000) {
|
|
return 'Invalid y coordinate';
|
|
}
|
|
if (!isValidColor(pixel.color)) {
|
|
return 'Invalid color format';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function upsertPixelsWithConflictResolution(
|
|
pixels: { x: number; y: number; color: string; userId?: number }[],
|
|
clientTimestamp: number
|
|
): { saved: number; conflicts: number } {
|
|
const getStmt = db.prepare('SELECT updated_at FROM pixels WHERE x = ? AND y = ?');
|
|
const stmt = db.prepare(
|
|
'INSERT OR REPLACE INTO pixels (x, y, color, user_id, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)'
|
|
);
|
|
|
|
let saved = 0;
|
|
let conflicts = 0;
|
|
|
|
const transaction = db.transaction((pixels: any[]) => {
|
|
for (const pixel of pixels) {
|
|
try {
|
|
const existing = getStmt.get(pixel.x, pixel.y) as { updated_at: string } | undefined;
|
|
if (existing) {
|
|
const existingTime = new Date(existing.updated_at).getTime();
|
|
if (clientTimestamp && existingTime > clientTimestamp) {
|
|
conflicts++;
|
|
continue;
|
|
}
|
|
}
|
|
stmt.run(pixel.x, pixel.y, pixel.color, pixel.userId || null);
|
|
saved++;
|
|
} catch (err) {
|
|
console.error('Error upserting pixel:', err);
|
|
}
|
|
}
|
|
});
|
|
|
|
transaction(pixels);
|
|
return { saved, conflicts };
|
|
}
|
|
|
|
export function getPixelsInRange(
|
|
minX: number,
|
|
maxX: number,
|
|
minY: number,
|
|
maxY: number
|
|
): { x: number; y: number; color: string }[] {
|
|
const stmt = db.prepare(
|
|
'SELECT x, y, color FROM pixels WHERE x >= ? AND x <= ? AND y >= ? AND y <= ? ORDER BY x, y'
|
|
);
|
|
return stmt.all(minX, maxX, minY, maxY) as { x: number; y: number; color: string }[];
|
|
}
|
|
|
|
export function getAllPixels(): { x: number; y: number; color: string }[] {
|
|
const stmt = db.prepare('SELECT x, y, color FROM pixels ORDER BY x, y');
|
|
return stmt.all() as { x: number; y: number; color: string }[];
|
|
}
|