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
+2
-1
@@ -8,7 +8,7 @@ const meta = new URL("../dist/.openai/", import.meta.url);
|
||||
await rm(dist, { recursive: true, force: true });
|
||||
await Promise.all([mkdir(server, { recursive: true }), mkdir(meta, { recursive: true })]);
|
||||
const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, browserAppJs, dataActionsJs, terminalAppJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("browser-app.js"), text("data-actions.js"), text("terminal-app.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]);
|
||||
const release = "1.12.0";
|
||||
const release = "1.13.0";
|
||||
const localeEnJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`;
|
||||
const localeZhJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`;
|
||||
let indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${release}"`).replace('src="tasks.js"', `src="tasks.js?v=${release}"`).replace('src="calendar.js"', `src="calendar.js?v=${release}"`);
|
||||
@@ -23,5 +23,6 @@ for (const [src, code] of [
|
||||
}
|
||||
await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const dataActionsJs=${JSON.stringify(dataActionsJs)};\nexport const terminalAppJs=${JSON.stringify(terminalAppJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`);
|
||||
await copyFile(new URL("../worker/index.js", import.meta.url), new URL("index.js", server));
|
||||
await copyFile(new URL("../worker/cloud-state.js", import.meta.url), new URL("cloud-state.js", server));
|
||||
await copyFile(new URL("../.openai/hosting.json", import.meta.url), new URL("hosting.json", meta));
|
||||
console.log("Built Life OS Worker artifact");
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import vm from "node:vm";
|
||||
import worker from "../dist/server/index.js";
|
||||
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
sqlite.exec(readFileSync(new URL("../drizzle/0000_lifeos_cloud_state.sql", import.meta.url), "utf8"));
|
||||
sqlite.exec(readFileSync(new URL("../drizzle/0001_lifeos_records.sql", import.meta.url), "utf8"));
|
||||
const db = {
|
||||
prepare(sql) {
|
||||
return {
|
||||
bind(...args) {
|
||||
const statement = sqlite.prepare(sql);
|
||||
return {
|
||||
first: async () => statement.get(...args) ?? null,
|
||||
all: async () => ({ results: statement.all(...args) }),
|
||||
run: async () => ({ meta: { changes: statement.run(...args).changes } })
|
||||
};
|
||||
}
|
||||
};
|
||||
},
|
||||
async batch(statements) {
|
||||
sqlite.exec("BEGIN");
|
||||
try {
|
||||
const result = [];
|
||||
for (const statement of statements) result.push(await statement.run());
|
||||
sqlite.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) { sqlite.exec("ROLLBACK"); throw error; }
|
||||
}
|
||||
};
|
||||
const headers = { "oai-authenticated-user-id": "alice" };
|
||||
async function call(path, method = "GET", data, as = headers) {
|
||||
const response = await worker.fetch(new Request("https://life-os.test" + path, {
|
||||
method, headers: { ...as, "content-type": "application/json" },
|
||||
body: data === undefined ? undefined : JSON.stringify(data)
|
||||
}), { DB: db });
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
|
||||
const legacy = {
|
||||
profile: { name: "Alice" }, settings: { theme: "dark" },
|
||||
memories: [{ id: "m1", date: "2026-09-28", title: "Lab", tags: ["school", "lab"], text: "Notes" }],
|
||||
museum: [{ id: "f1", memoryId: "m1", preservedAt: "2026-09-28" }],
|
||||
system: { schemaVersion: 2, tasksBoard: {
|
||||
goals: [{ id: "g1", title: "Study", targetDate: "2026-12-12" }],
|
||||
tasks: [{ id: "t1", goalId: "g1", title: "Review", done: false }]
|
||||
} }
|
||||
};
|
||||
sqlite.prepare("INSERT INTO lifeos_states VALUES (?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"alice", JSON.stringify(legacy.profile), JSON.stringify(legacy.memories),
|
||||
JSON.stringify(legacy.museum), JSON.stringify(legacy.settings), JSON.stringify(legacy.system), "2026-09-28"
|
||||
);
|
||||
let result = await call("/api/lifeos-state");
|
||||
assert.equal(result.status, 200);
|
||||
assert.deepEqual(result.body.state.memories, legacy.memories);
|
||||
assert.deepEqual(result.body.state.system.tasksBoard, legacy.system.tasksBoard);
|
||||
assert.deepEqual(result.body.state.museum, legacy.museum);
|
||||
assert.equal(result.body.versions.memories.m1, 1);
|
||||
assert.deepEqual(sqlite.prepare("SELECT tag FROM lifeos_memory_tags ORDER BY tag").all().map(row => row.tag), ["lab", "school"]);
|
||||
assert.equal(sqlite.prepare("SELECT COUNT(*) AS count FROM lifeos_states").get().count, 1, "legacy backup remains intact");
|
||||
|
||||
result = await call("/api/lifeos-sync", "POST", { kind: "memories", id: "m1", baseVersion: 1, value: { ...legacy.memories[0], tags: ["new"] } });
|
||||
assert.equal(result.body.version, 2);
|
||||
assert.deepEqual(sqlite.prepare("SELECT tag FROM lifeos_memory_tags").all().map(row => row.tag), ["new"]);
|
||||
result = await call("/api/lifeos-sync", "POST", { kind: "memories", id: "m1", baseVersion: 1, value: legacy.memories[0] });
|
||||
assert.equal(result.status, 409, "stale client cannot overwrite a record");
|
||||
result = await call("/api/lifeos-sync", "POST", { kind: "tasks", id: "t2", baseVersion: 0, value: { id: "t2", title: "New task", done: false } });
|
||||
assert.equal(result.status, 200);
|
||||
result = await call("/api/lifeos-sync", "POST", { kind: "memories", id: "m1", baseVersion: 2, value: null });
|
||||
assert.equal(result.body.version, 3);
|
||||
assert.equal(sqlite.prepare("SELECT COUNT(*) AS count FROM lifeos_memory_tags").get().count, 0);
|
||||
assert.equal((await call("/api/lifeos-state")).body.state.memories.length, 0);
|
||||
assert.equal((await call("/api/lifeos-state", "GET", undefined, { "oai-authenticated-user-id": "bob" })).body.state, null);
|
||||
assert.equal((await call("/api/lifeos-state", "PUT", { state: legacy })).status, 426, "old whole-document writes are rejected");
|
||||
result = await call("/api/lifeos-initialize", "POST", { state: legacy }, { "oai-authenticated-user-id": "bob" });
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.body.state.system.tasksBoard.tasks.length, 1);
|
||||
assert.equal((await call("/api/lifeos-initialize", "POST", { state: legacy }, { "oai-authenticated-user-id": "bob" })).status, 409);
|
||||
const addons = readFileSync(new URL("../public/addons.js", import.meta.url), "utf8");
|
||||
const syncCode = addons.slice(addons.indexOf(" const CLOUD_KEYS ="), addons.indexOf(" const originalSetItem ="));
|
||||
assert.ok(syncCode.includes("function changes("));
|
||||
const values = new Map();
|
||||
const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, String(value)) };
|
||||
const client = vm.createContext({
|
||||
localStorage: storage, window: {}, fetch: async (path, options = {}) => worker.fetch(
|
||||
new Request("https://life-os.test" + path, { ...options, headers: { ...options.headers, ...headers } }), { DB: db }),
|
||||
clearTimeout, setTimeout, confirm: () => false, t: key => key,
|
||||
toast: () => {}, document: { createElement() {}, body: { append() {} } }
|
||||
});
|
||||
vm.runInContext(syncCode + "\nwindow.CloudTest = { ready(state, revisions) { baseline = clone(state); versions = revisions; cloudReady = true; }, changes, snapshot: cloudSnapshot, sync: uploadCloudState };", client);
|
||||
const snapshot = (await call("/api/lifeos-state")).body;
|
||||
for (const [kind, key] of Object.entries({ profile: "lifeos_profile", memories: "lifeos_memories", museum: "lifeos_museum", settings: "lifeos_settings", system: "lifeos_system" }))
|
||||
storage.setItem(key, JSON.stringify(snapshot.state[kind]));
|
||||
client.window.CloudTest.ready(snapshot.state, snapshot.versions);
|
||||
const system = JSON.parse(storage.getItem("lifeos_system"));
|
||||
system.tasksBoard.tasks[0].done = true;
|
||||
storage.setItem("lifeos_system", JSON.stringify(system));
|
||||
const pending = client.window.CloudTest.changes(snapshot.state, client.window.CloudTest.snapshot());
|
||||
assert.deepEqual(Array.from(pending, item => item.kind), ["tasks"], "editing a task only writes one record");
|
||||
await client.window.CloudTest.sync();
|
||||
assert.equal((await call("/api/lifeos-state")).body.state.system.tasksBoard.tasks[0].done, true);
|
||||
console.log("Life OS record migration, sync conflict, tags, and user isolation valid");
|
||||
@@ -13,7 +13,8 @@ assert.match(assets.addonsJs, /schemaVersion: 2/);
|
||||
assert.match(assets.addonsJs, /settings: settings\(\), system: sys\(\)/);
|
||||
assert.match(assets.addonsJs, /widgetLayout/);
|
||||
assert.match(assets.i18nRenderersJs, /lifeos_widget_layout_v1/);
|
||||
assert.match(assets.addonsJs, /if \(mergedLocalBoard\) await uploadCloudState\(\)/);
|
||||
assert.match(assets.addonsJs, /lifeos_cloud_baseline_v3/);
|
||||
assert.match(assets.addonsJs, /api\/lifeos-sync/);
|
||||
assert.match(assets.i18nRenderersJs, /dialog\.goals/);
|
||||
assert.match(assets.i18nRenderersJs, /shell\.wallpaperTitle/);
|
||||
assert.match(assets.i18nRenderersJs, /window\.museumApp/);
|
||||
@@ -172,7 +173,7 @@ assert.ok(command('date').message.length > 8);
|
||||
assert.ok(command('time').message.includes(':'));
|
||||
assert.ok(command('datetime').message.length > 12);
|
||||
assert.equal(command('uptime').message, 'terminal.uptime');
|
||||
assert.equal(command('version').message, 'Life OS v1.12.0');
|
||||
assert.equal(command('version').message, 'Life OS v1.13.0');
|
||||
assert.equal(command('status').message, 'terminal.status');
|
||||
assert.match(command('apps').message, /calendar/);
|
||||
assert.equal(command('echo "hello Life OS"').message, 'hello Life OS');
|
||||
|
||||
Reference in new issue
Block a user