// Legacy lifeos_states is retained as a read-only source for per-user migration. const TABLES = { memories: "lifeos_memories", goals: "lifeos_goals", tasks: "lifeos_tasks", museum: "lifeos_favorites" }; const META = new Set(["profile", "settings", "system"]); const empty = () => ({ profile: {}, memories: [], museum: [], settings: {}, system: { schemaVersion: 2, tasksBoard: { goals: [], tasks: [] } } }); const json = value => JSON.stringify(value); const object = value => value && typeof value === "object" && !Array.isArray(value); const array = value => Array.isArray(value) ? value : []; const parse = (value, fallback) => { try { return JSON.parse(value); } catch { return fallback; } }; const date = value => typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null; const label = value => typeof value === "string" && value.length <= 200 ? value : null; const cleanSystem = value => { const { tasksBoard, ...system } = object(value) ? value : {}; return { ...system, schemaVersion: 2 }; }; const response = (value, status = 200) => Response.json(value, { status, headers: { "cache-control": "no-store" } }); function entries(state) { const system = object(state.system) ? state.system : {}; return { profile: object(state.profile) ? state.profile : {}, settings: object(state.settings) ? state.settings : {}, system: cleanSystem(system), memories: array(state.memories), museum: array(state.museum), goals: array(system.tasksBoard?.goals), tasks: array(system.tasksBoard?.tasks) }; } function insert(db, userId, kind, value, now) { if (META.has(kind)) return db.prepare( "INSERT INTO lifeos_meta (user_id, kind, value_json, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING" ).bind(userId, kind, json(value), now); const id = label(value?.id); if (!id) return null; if (kind === "memories") return db.prepare( "INSERT INTO lifeos_memories (user_id, id, memory_date, value_json, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" ).bind(userId, id, date(value.date), json(value), now); if (kind === "goals") return db.prepare( "INSERT INTO lifeos_goals (user_id, id, target_date, done, value_json, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" ).bind(userId, id, date(value.targetDate), Number(!!value.done), json(value), now); if (kind === "tasks") return db.prepare( "INSERT INTO lifeos_tasks (user_id, id, goal_id, due_date, done, value_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" ).bind(userId, id, label(value.goalId), date(value.dueDate), Number(!!value.done), json(value), now); return db.prepare( "INSERT INTO lifeos_favorites (user_id, id, memory_id, value_json, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" ).bind(userId, id, label(value.memoryId), json(value), now); } async function seed(db, userId, state, now) { const data = entries(state), statements = []; for (const kind of ["profile", "settings", "system", "memories", "goals", "tasks", "museum"]) { for (const [index, original] of (META.has(kind) ? [data[kind]] : data[kind]).entries()) { const value = !META.has(kind) && !label(original?.id) ? { ...original, id: `legacy_${kind}_${index}` } : original; const statement = insert(db, userId, kind, value, now); if (statement) statements.push(statement); } } // D1 batch executes a group atomically. Keep batches bounded for larger accounts. for (let index = 0; index < statements.length; index += 50) await db.batch(statements.slice(index, index + 50)); await db.prepare("INSERT INTO lifeos_migrations (user_id, migrated_at) VALUES (?, ?) ON CONFLICT DO NOTHING").bind(userId, now).run(); } async function migrateLegacy(db, userId) { const marker = await db.prepare("SELECT user_id FROM lifeos_migrations WHERE user_id = ?").bind(userId).first(); if (marker) return true; const row = await db.prepare("SELECT profile_json, memories_json, museum_json, settings_json, system_json FROM lifeos_states WHERE user_id = ?").bind(userId).first(); if (!row) return false; const state = Object.fromEntries(["profile", "memories", "museum", "settings", "system"].map(kind => [kind, parse(row[`${kind}_json`], empty()[kind])])); await seed(db, userId, state, new Date().toISOString()); return true; } async function read(db, userId) { if (!await migrateLegacy(db, userId)) return { state: null, versions: null }; const [meta, memories, goals, tasks, museum] = await Promise.all([ db.prepare("SELECT kind, value_json, revision FROM lifeos_meta WHERE user_id = ?").bind(userId).all(), ...Object.values(TABLES).slice(0, 3).map(table => db.prepare(`SELECT id, value_json, revision FROM ${table} WHERE user_id = ? AND deleted_at IS NULL`).bind(userId).all()), db.prepare("SELECT id, value_json, revision FROM lifeos_favorites WHERE user_id = ? AND deleted_at IS NULL").bind(userId).all() ]); const state = empty(), versions = { profile: {}, settings: {}, system: {}, memories: {}, goals: {}, tasks: {}, museum: {} }; for (const row of meta.results) { state[row.kind] = parse(row.value_json, state[row.kind]); versions[row.kind].value = row.revision; } for (const [kind, rows] of Object.entries({ memories, goals, tasks, museum })) { state[kind] = rows.results.map(row => parse(row.value_json, {})); for (const row of rows.results) versions[kind][row.id] = row.revision; } state.system = { ...cleanSystem(state.system), tasksBoard: { goals: state.goals, tasks: state.tasks } }; delete state.goals; delete state.tasks; return { state, versions }; } async function change(db, userId, op) { const { kind, id, value, baseVersion } = op || {}; if (!(META.has(kind) || Object.hasOwn(TABLES, kind)) || !Number.isInteger(baseVersion) || baseVersion < 0) return response({ error: "Invalid change" }, 400); const key = META.has(kind) ? kind : label(id); if (!key || (value !== null && (!object(value) || (!META.has(kind) && value.id !== key)))) return response({ error: "Invalid record" }, 400); if (value !== null && json(value).length > 1_000_000) return response({ error: "Record is too large" }, 413); if (META.has(kind) && (value === null || !object(value))) return response({ error: "Invalid metadata" }, 400); const now = new Date().toISOString(); if (baseVersion === 0) { if (value === null) return response({ error: "Invalid deletion" }, 400); const result = await insert(db, userId, kind, kind === "system" ? cleanSystem(value) : value, now).run(); if (!result.meta.changes) return response({ error: "Conflict" }, 409); return response({ ok: true, version: 1 }); } let query, bindings; if (META.has(kind)) { query = "UPDATE lifeos_meta SET value_json = ?, revision = revision + 1, updated_at = ? WHERE user_id = ? AND kind = ? AND revision = ? RETURNING revision"; bindings = [json(kind === "system" ? cleanSystem(value) : value), now, userId, kind, baseVersion]; } else { const table = TABLES[kind]; const fields = kind === "memories" ? ["memory_date"] : kind === "goals" ? ["target_date", "done"] : kind === "tasks" ? ["goal_id", "due_date", "done"] : ["memory_id"]; const columns = value === null ? [] : fields; const extra = value === null ? [] : kind === "memories" ? [date(value.date)] : kind === "goals" ? [date(value.targetDate), Number(!!value.done)] : kind === "tasks" ? [label(value.goalId), date(value.dueDate), Number(!!value.done)] : [label(value.memoryId)]; query = value === null ? `UPDATE ${table} SET deleted_at = ?, updated_at = ?, revision = revision + 1 WHERE user_id = ? AND id = ? AND revision = ? AND deleted_at IS NULL RETURNING revision` : `UPDATE ${table} SET ${columns.map(column => `${column} = ?`).join(", ")}, value_json = ?, updated_at = ?, revision = revision + 1 WHERE user_id = ? AND id = ? AND revision = ? AND deleted_at IS NULL RETURNING revision`; bindings = value === null ? [now, now, userId, key, baseVersion] : [...extra, json(value), now, userId, key, baseVersion]; } const updated = await db.prepare(query).bind(...bindings).first(); if (!updated) return response({ error: "Conflict" }, 409); return response({ ok: true, version: updated.revision }); } export async function cloudApi(request, env, userId, pathname) { if (!userId) return response({ error: "Authentication required" }, 401); if (!env.DB) return response({ error: "Cloud storage is unavailable" }, 503); try { if (pathname === "/api/lifeos-state" && request.method === "GET") return response(await read(env.DB, userId)); if (pathname === "/api/lifeos-state" && request.method === "PUT") return response({ error: "Refresh Life OS to use record-based sync" }, 426); if (pathname === "/api/lifeos-initialize" && request.method === "POST") { if (await migrateLegacy(env.DB, userId)) return response({ error: "Cloud account already exists" }, 409); const { state } = await request.json(); if (!object(state) || !array(state.memories) || !array(state.museum) || !object(state.system)) return response({ error: "Invalid state" }, 400); await seed(env.DB, userId, state, new Date().toISOString()); return response(await read(env.DB, userId)); } if (pathname === "/api/lifeos-sync" && request.method === "POST") { if (!await migrateLegacy(env.DB, userId)) return response({ error: "Initialize cloud account first" }, 409); return await change(env.DB, userId, await request.json()); } return response({ error: "Method not allowed" }, 405); } catch (error) { console.error("Life OS cloud operation failed", error); return response({ error: "Cloud storage request failed" }, 500); } }