Integrate Authority Blob storage

This commit is contained in:
Youzini-afk
2026-04-28 12:52:52 +08:00
parent 322804a12a
commit d7cbbb20c1
5 changed files with 1531 additions and 194 deletions

439
tests/authority-blob.mjs Normal file
View File

@@ -0,0 +1,439 @@
import assert from "node:assert/strict";
import {
createAuthorityBlobAdapter,
normalizeAuthorityBlobPath,
normalizeAuthorityBlobReadResult,
} from "../maintenance/authority-blob-adapter.js";
import {
backupToServer,
download,
listServerBackups,
restoreFromServer,
upload,
} from "../sync/bme-sync.js";
class MemoryStorage {
constructor() {
this.map = new Map();
}
getItem(key) {
return this.map.has(key) ? this.map.get(key) : null;
}
setItem(key, value) {
this.map.set(String(key), String(value));
}
}
class FakeDb {
constructor(chatId, snapshot = null) {
this.chatId = chatId;
this.snapshot = snapshot || {
meta: {
schemaVersion: 1,
chatId,
deviceId: "",
revision: 1,
lastModified: 10,
nodeCount: 1,
edgeCount: 0,
tombstoneCount: 0,
},
nodes: [{ id: `${chatId}-node`, updatedAt: 10 }],
edges: [],
tombstones: [],
state: {
lastProcessedFloor: 1,
extractionCount: 1,
},
};
this.meta = new Map([
["syncDirty", false],
["syncDirtyReason", ""],
["lastSyncedRevision", 0],
]);
this.lastImportPayload = null;
}
async exportSnapshot() {
return JSON.parse(JSON.stringify(this.snapshot));
}
async importSnapshot(snapshot) {
this.lastImportPayload = JSON.parse(JSON.stringify(snapshot));
this.snapshot = JSON.parse(JSON.stringify(snapshot));
}
async getMeta(key, fallback = null) {
return this.meta.has(key) ? this.meta.get(key) : fallback;
}
async patchMeta(record = {}) {
for (const [key, value] of Object.entries(record)) {
this.meta.set(key, value);
}
}
async setMeta(key, value) {
this.meta.set(key, value);
}
}
function createMockAuthorityBlobClient() {
const files = new Map();
const calls = [];
return {
files,
calls,
async writeJson(payload = {}) {
calls.push(["writeJson", { ...payload }]);
files.set(String(payload.path || ""), JSON.parse(JSON.stringify(payload.payload)));
return { ok: true, path: payload.path, size: JSON.stringify(payload.payload).length };
},
async writeText(payload = {}) {
calls.push(["writeText", { ...payload }]);
files.set(String(payload.path || ""), String(payload.text ?? payload.data ?? ""));
return { ok: true, path: payload.path, size: String(payload.text ?? "").length };
},
async readJson(payload = {}) {
calls.push(["readJson", { ...payload }]);
const path = String(payload.path || "");
if (!files.has(path)) return { exists: false, path };
return { exists: true, path, payload: JSON.parse(JSON.stringify(files.get(path))) };
},
async delete(payload = {}) {
calls.push(["delete", { ...payload }]);
const path = String(payload.path || "");
const existed = files.delete(path);
return { ok: true, deleted: existed, exists: existed, path };
},
};
}
function createMockFetch() {
const logs = {
getCalls: 0,
uploadCalls: 0,
deleteCalls: 0,
sanitizeCalls: 0,
};
const response = (status, body) => ({
ok: status >= 200 && status < 300,
status,
statusText: String(status),
async json() {
return JSON.parse(JSON.stringify(body));
},
async text() {
return typeof body === "string" ? body : JSON.stringify(body);
},
});
const fetch = async (url, options = {}) => {
const method = String(options.method || "GET").toUpperCase();
if (url === "/api/files/sanitize-filename" && method === "POST") {
logs.sanitizeCalls += 1;
const body = JSON.parse(String(options.body || "{}"));
return response(200, {
fileName: String(body.fileName || "").replace(/[^A-Za-z0-9._~-]+/g, "_"),
});
}
if (url === "/api/files/upload" && method === "POST") {
logs.uploadCalls += 1;
return response(500, "legacy upload should not be used");
}
if (url === "/api/files/delete" && method === "POST") {
logs.deleteCalls += 1;
return response(404, "not found");
}
if (String(url).startsWith("/user/files/") && method === "GET") {
logs.getCalls += 1;
return response(404, "not found");
}
return response(404, "unsupported route");
};
return { fetch, logs };
}
function createLegacyFileFetch() {
const files = new Map();
const logs = {
getCalls: 0,
uploadCalls: 0,
deleteCalls: 0,
sanitizeCalls: 0,
};
const response = (status, body) => ({
ok: status >= 200 && status < 300,
status,
statusText: String(status),
async json() {
return typeof body === "string" ? JSON.parse(body) : JSON.parse(JSON.stringify(body));
},
async text() {
return typeof body === "string" ? body : JSON.stringify(body);
},
});
const decodeUploadData = (value = "") =>
Buffer.from(String(value || ""), "base64").toString("utf8");
const fetch = async (url, options = {}) => {
const method = String(options.method || "GET").toUpperCase();
if (url === "/api/files/sanitize-filename" && method === "POST") {
logs.sanitizeCalls += 1;
const body = JSON.parse(String(options.body || "{}"));
return response(200, {
fileName: String(body.fileName || "").replace(/[^A-Za-z0-9._~-]+/g, "_"),
});
}
if (url === "/api/files/upload" && method === "POST") {
logs.uploadCalls += 1;
const body = JSON.parse(String(options.body || "{}"));
const name = String(body.name || "");
files.set(name, decodeUploadData(body.data));
return response(200, { path: `/user/files/${name}` });
}
if (url === "/api/files/delete" && method === "POST") {
logs.deleteCalls += 1;
const body = JSON.parse(String(options.body || "{}"));
const name = decodeURIComponent(String(body.path || "").split("/").pop() || "");
const deleted = files.delete(name);
return response(deleted ? 200 : 404, deleted ? { deleted: true } : "not found");
}
if (String(url).startsWith("/user/files/") && method === "GET") {
logs.getCalls += 1;
const path = String(url).split("?")[0];
const name = decodeURIComponent(path.slice("/user/files/".length));
if (!files.has(name)) return response(404, "not found");
return response(200, JSON.parse(files.get(name)));
}
return response(404, "unsupported route");
};
return { fetch, files, logs };
}
function buildRuntimeOptions({ dbByChatId, fetch, blobClient, onAuthorityBlobEvent = null }) {
return {
fetch,
blobClient,
authorityBlobEnabled: true,
authorityBlobFailOpen: true,
getDb: async (chatId) => {
const db = dbByChatId.get(chatId);
if (!db) throw new Error(`missing db: ${chatId}`);
return db;
},
getSafetyDb: async (chatId) => new FakeDb(`__restore_safety__${chatId}`),
getRequestHeaders: () => ({ "X-Test": "1" }),
onAuthorityBlobEvent,
};
}
function createFailingAuthorityBlobClient() {
const calls = [];
const fail = async (method, payload = {}) => {
calls.push([method, { ...payload }]);
throw new Error("blob unavailable");
};
return {
calls,
writeJson: (payload) => fail("writeJson", payload),
writeText: (payload) => fail("writeText", payload),
readJson: (payload) => fail("readJson", payload),
delete: (payload) => fail("delete", payload),
};
}
async function testAdapterBasics() {
const client = createMockAuthorityBlobClient();
const adapter = createAuthorityBlobAdapter({}, { blobClient: client });
assert.equal(normalizeAuthorityBlobPath("/user/files/demo.json"), "user/files/demo.json");
assert.equal(
normalizeAuthorityBlobReadResult({ data: JSON.stringify({ ok: true }) }, "a.json").payload.ok,
true,
);
const writeResult = await adapter.writeJson("/user/files/demo.json", { hello: "world" });
assert.equal(writeResult.ok, true);
const readResult = await adapter.readJson("user/files/demo.json");
assert.equal(readResult.exists, true);
assert.deepEqual(readResult.payload, { hello: "world" });
const deleteResult = await adapter.delete("user/files/demo.json");
assert.equal(deleteResult.deleted, true);
}
async function testAuthorityBlobFailOpenFallsBackToUserFiles() {
globalThis.localStorage = new MemoryStorage();
const blobClient = createFailingAuthorityBlobClient();
const { fetch, logs } = createLegacyFileFetch();
const dbByChatId = new Map();
const db = new FakeDb("blob-fallback", {
meta: {
schemaVersion: 1,
chatId: "blob-fallback",
deviceId: "",
revision: 9,
lastModified: 90,
nodeCount: 1,
edgeCount: 0,
tombstoneCount: 0,
},
nodes: [{ id: "fallback-node", updatedAt: 90 }],
edges: [],
tombstones: [],
state: { lastProcessedFloor: 6, extractionCount: 3 },
});
dbByChatId.set("blob-fallback", db);
const events = [];
const runtime = buildRuntimeOptions({
dbByChatId,
fetch,
blobClient,
onAuthorityBlobEvent: (event) => events.push(event),
});
const backupResult = await backupToServer("blob-fallback", runtime);
assert.equal(backupResult.backedUp, true);
assert.ok(logs.uploadCalls > 0);
assert.ok(blobClient.calls.some(([method]) => method === "writeJson"));
assert.ok(events.some((event) => event.reason === "authority-blob-error"));
db.snapshot = {
meta: {
schemaVersion: 1,
chatId: "blob-fallback",
deviceId: "",
revision: 1,
lastModified: 10,
nodeCount: 0,
edgeCount: 0,
tombstoneCount: 0,
},
nodes: [],
edges: [],
tombstones: [],
state: { lastProcessedFloor: -1, extractionCount: 0 },
};
const restoreResult = await restoreFromServer("blob-fallback", runtime);
assert.equal(restoreResult.restored, true);
assert.equal(db.snapshot.nodes[0].id, "fallback-node");
assert.ok(logs.getCalls > 0);
}
async function testBackupRestoreUsesAuthorityBlob() {
globalThis.localStorage = new MemoryStorage();
const blobClient = createMockAuthorityBlobClient();
const { fetch, logs } = createMockFetch();
const dbByChatId = new Map();
const db = new FakeDb("blob-backup", {
meta: {
schemaVersion: 1,
chatId: "blob-backup",
deviceId: "",
revision: 7,
lastModified: 70,
nodeCount: 1,
edgeCount: 0,
tombstoneCount: 0,
},
nodes: [{ id: "blob-node", updatedAt: 70 }],
edges: [],
tombstones: [],
state: { lastProcessedFloor: 3, extractionCount: 2 },
});
dbByChatId.set("blob-backup", db);
const events = [];
const runtime = buildRuntimeOptions({
dbByChatId,
fetch,
blobClient,
onAuthorityBlobEvent: (event) => events.push(event),
});
const backupResult = await backupToServer("blob-backup", runtime);
assert.equal(backupResult.backedUp, true);
assert.equal(logs.uploadCalls, 0);
assert.equal(blobClient.files.has("user/files/ST-BME_BackupManifest.json"), true);
assert.equal(blobClient.files.has(`user/files/${backupResult.filename}`), true);
const manifest = await listServerBackups(runtime);
assert.equal(manifest.entries.length, 1);
assert.equal(manifest.entries[0].chatId, "blob-backup");
db.snapshot = {
meta: {
schemaVersion: 1,
chatId: "blob-backup",
deviceId: "",
revision: 1,
lastModified: 10,
nodeCount: 0,
edgeCount: 0,
tombstoneCount: 0,
},
nodes: [],
edges: [],
tombstones: [],
state: { lastProcessedFloor: -1, extractionCount: 0 },
};
const restoreResult = await restoreFromServer("blob-backup", runtime);
assert.equal(restoreResult.restored, true);
assert.equal(db.snapshot.nodes[0].id, "blob-node");
assert.equal(events.some((event) => event.backend === "authority-blob"), true);
}
async function testSyncUploadDownloadUsesAuthorityBlob() {
globalThis.localStorage = new MemoryStorage();
const blobClient = createMockAuthorityBlobClient();
const { fetch, logs } = createMockFetch();
const dbByChatId = new Map();
const db = new FakeDb("blob-sync", {
meta: {
schemaVersion: 1,
chatId: "blob-sync",
deviceId: "",
revision: 5,
lastModified: 50,
nodeCount: 1,
edgeCount: 0,
tombstoneCount: 0,
},
nodes: [{ id: "sync-blob-node", updatedAt: 50 }],
edges: [],
tombstones: [],
state: { lastProcessedFloor: 4, extractionCount: 1 },
});
dbByChatId.set("blob-sync", db);
const runtime = buildRuntimeOptions({ dbByChatId, fetch, blobClient });
const uploadResult = await upload("blob-sync", runtime);
assert.equal(uploadResult.uploaded, true);
assert.equal(logs.uploadCalls, 0);
assert.equal(blobClient.files.has("user/files/ST-BME_sync_blob-sync.json"), true);
db.snapshot = {
meta: {
schemaVersion: 1,
chatId: "blob-sync",
deviceId: "",
revision: 1,
lastModified: 10,
nodeCount: 0,
edgeCount: 0,
tombstoneCount: 0,
},
nodes: [],
edges: [],
tombstones: [],
state: { lastProcessedFloor: -1, extractionCount: 0 },
};
const downloadResult = await download("blob-sync", runtime);
assert.equal(downloadResult.downloaded, true);
assert.equal(db.snapshot.nodes[0].id, "sync-blob-node");
}
await testAdapterBasics();
await testAuthorityBlobFailOpenFallsBackToUserFiles();
await testBackupRestoreUsesAuthorityBlob();
await testSyncUploadDownloadUsesAuthorityBlob();
console.log("authority-blob tests passed");