feat(storage): migrate Life OS cloud state to versioned records
This commit is contained in:
1 parent
6972a00445
commit
1606c41f43
17 files changed
+502
-72
No files matched your search
@@ -0,0 +1,148 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
+2
-29
@@ -1,7 +1,7 @@
|
||||
import { addonsJs, bootGuardJs, i18nJs, localeEn, localeZh, localeEnJs, localeZhJs, tasksJs, calendarJs, i18nRenderersJs, browserAppJs, dataActionsJs, terminalAppJs, indexHtml, manifest, serviceWorker, icon } from "./assets.js";
|
||||
import { cloudApi } from "./cloud-state.js";
|
||||
|
||||
const BING_ARCHIVE = "https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US";
|
||||
const STATE_FIELDS = ["profile", "memories", "museum", "settings", "system"];
|
||||
|
||||
async function bingMetadata() {
|
||||
const response = await fetch(BING_ARCHIVE, { headers: { accept: "application/json" } });
|
||||
@@ -21,38 +21,11 @@ function loginPage(request) {
|
||||
return `<!doctype html><html lang="${language === localeZh ? "zh-CN" : "en"}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Life OS</title><style>html,body{margin:0;min-height:100%;font-family:ui-sans-serif,system-ui,sans-serif;background:#121513;color:#f4f6f1}body{display:grid;place-items:center;padding:24px;box-sizing:border-box}.panel{width:min(430px,100%);border:1px solid #414942;border-radius:22px;padding:34px;background:#1a201b;box-shadow:0 28px 70px #0008}.mark{font:600 11px ui-monospace,monospace;letter-spacing:.2em;color:#b8ff3d}.title{font-size:36px;letter-spacing:-.06em;margin:18px 0 10px}.copy{color:#afb9b1;line-height:1.65;margin:0 0 26px}.sign{display:flex;align-items:center;justify-content:center;text-decoration:none;background:#b8ff3d;color:#11170d;padding:14px 18px;border-radius:12px;font-size:14px;font-weight:700}.fine{margin:18px 0 0;color:#7e8a81;font:11px ui-monospace,monospace;letter-spacing:.04em;text-align:center}</style></head><body><main class="panel"><div class="mark">LIFE OS</div><h1 class="title">${text.title}</h1><p class="copy">${escape(text.description)}</p><a class="sign" href="/signin-with-chatgpt?return_to=/" target="_top">${escape(text.continue)}</a><p class="fine">${escape(text.footnote)}</p></main></body></html>`;
|
||||
}
|
||||
|
||||
function validState(value) { return value && typeof value === "object" && !Array.isArray(value) && STATE_FIELDS.every((field) => Object.hasOwn(value, field)); }
|
||||
function stateFromRow(row) { return row ? Object.fromEntries(STATE_FIELDS.map((field) => [field, JSON.parse(row[`${field}_json`])])) : null; }
|
||||
|
||||
async function stateApi(request, env, id) {
|
||||
if (!id) return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||
if (!env.DB) return Response.json({ error: "Cloud storage is unavailable" }, { status: 503 });
|
||||
try {
|
||||
if (request.method === "GET") {
|
||||
const row = await env.DB.prepare("SELECT profile_json, memories_json, museum_json, settings_json, system_json, updated_at FROM lifeos_states WHERE user_id = ?").bind(id).first();
|
||||
return Response.json({ state: stateFromRow(row), updatedAt: row?.updated_at ?? null }, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
const payload = await request.json();
|
||||
if (!validState(payload?.state)) return Response.json({ error: "Invalid Life OS state" }, { status: 400 });
|
||||
const state = payload.state;
|
||||
const serialized = STATE_FIELDS.map((field) => JSON.stringify(state[field]));
|
||||
if (serialized.some((value) => value.length > 1_000_000)) return Response.json({ error: "Life OS state is too large" }, { status: 413 });
|
||||
const updatedAt = new Date().toISOString();
|
||||
await env.DB.prepare("INSERT INTO lifeos_states (user_id, profile_json, memories_json, museum_json, settings_json, system_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET profile_json = excluded.profile_json, memories_json = excluded.memories_json, museum_json = excluded.museum_json, settings_json = excluded.settings_json, system_json = excluded.system_json, updated_at = excluded.updated_at").bind(id, ...serialized, updatedAt).run();
|
||||
return Response.json({ ok: true, updatedAt }, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
return Response.json({ error: "Method not allowed" }, { status: 405, headers: { allow: "GET, PUT" } });
|
||||
} catch (error) {
|
||||
return Response.json({ error: "Cloud storage request failed", detail: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
const id = userId(request);
|
||||
if (url.pathname === "/api/lifeos-state") return stateApi(request, env, id);
|
||||
if (["/api/lifeos-state", "/api/lifeos-initialize", "/api/lifeos-sync"].includes(url.pathname)) return cloudApi(request, env, id, url.pathname);
|
||||
try {
|
||||
if (url.pathname === "/api/bing-wallpaper") return Response.json(await bingMetadata(), { headers: { "cache-control": "public, max-age=3600" } });
|
||||
if (url.pathname === "/api/bing-wallpaper-image") {
|
||||
|
||||
Reference in new issue
Block a user