diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..1d263c5 --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,4 @@ +import { Hono } from 'hono' + +export const health = new Hono() +health.get('/health', c => c.json({ ok: true })) diff --git a/src/routes/miniapp.ts b/src/routes/miniapp.ts new file mode 100644 index 0000000..d9e20ff --- /dev/null +++ b/src/routes/miniapp.ts @@ -0,0 +1,12 @@ +import { Hono } from 'hono' + +const HTML = ` +arcanesync + + +

managed by arcanegram client. you can close this window.

+ +` + +export const miniapp = new Hono() +miniapp.get('/miniapp', c => c.html(HTML)) diff --git a/src/routes/sync.ts b/src/routes/sync.ts new file mode 100644 index 0000000..e303d7f --- /dev/null +++ b/src/routes/sync.ts @@ -0,0 +1,84 @@ +import type { AuthEnv } from '@/middleware/auth.js' +import { Buffer } from 'node:buffer' +import { eq, sql } from 'drizzle-orm' +import { Hono } from 'hono' +import { userSettings } from '@/db/schema.js' +import { db } from '@/lib/db.js' +import { authMiddleware } from '@/middleware/auth.js' +import { config } from '@/shared/config.js' + +export const sync = new Hono() +sync.use('*', authMiddleware) + +sync.get('/sync', async (c) => { + const userId = c.get('userId') + const [row] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)) + if (!row) + return c.json({ version: 0, data: {} }) + return c.json({ version: row.version, data: row.data }) +}) + +interface PutBody { + baseVersion: number + patch: Record +} + +sync.put('/sync', async (c) => { + const raw = await c.req.text() + if (Buffer.byteLength(raw, 'utf8') > config.maxPayloadBytes) + return c.json({ error: 'payload-too-large' }, 413) + + let body: PutBody + try { + body = JSON.parse(raw) + } + catch { + return c.json({ error: 'bad-json' }, 400) + } + if (typeof body.baseVersion !== 'number' || typeof body.patch !== 'object' || body.patch === null || Array.isArray(body.patch)) + return c.json({ error: 'malformed' }, 400) + + const userId = c.get('userId') + + return db.transaction(async (tx) => { + const [row] = await tx + .select() + .from(userSettings) + .where(eq(userSettings.userId, userId)) + .for('update') + + if (!row) { + const merged = applyPatch({}, body.patch) + const [inserted] = await tx + .insert(userSettings) + .values({ userId, version: 1, data: merged }) + .returning() + return c.json({ version: inserted.version, data: inserted.data }) + } + + if (body.baseVersion !== row.version) + return c.json({ version: row.version, data: row.data }, 409) + + const merged = applyPatch(row.data, body.patch) + const [updated] = await tx + .update(userSettings) + .set({ version: row.version + 1, data: merged, updatedAt: sql`now()` }) + .where(eq(userSettings.userId, userId)) + .returning() + return c.json({ version: updated.version, data: updated.data }) + }) +}) + +function applyPatch( + base: Record, + patch: Record, +): Record { + const out: Record = { ...base } + for (const [k, v] of Object.entries(patch)) { + if (v === null) + delete out[k] + else + out[k] = v + } + return out +}