fix: repair welcome note Markdown seed
Repair untouched welcome notes with real line breaks, a consistent content hash and revision, and sync notification. Keep first-sign-in localization compatible with both seed versions and cover persisted database upgrades and user-content preservation. Fixes #622
This commit is contained in:
1 parent
65e946effb
commit
dba620d8e5
6 files changed
+197
-12
No files matched your search
@@ -5,6 +5,7 @@ import {
|
||||
import {
|
||||
DEFAULT_NOTEBOOK_SEEDS,
|
||||
WELCOME_MEMO_ID,
|
||||
WELCOME_MEMO_REPAIRED_CONTENT_HASH,
|
||||
WELCOME_MEMO_SEED_CONTENT_HASH,
|
||||
WELCOME_MEMO_SEED_TITLE,
|
||||
buildWelcomeMemoContent,
|
||||
@@ -148,7 +149,7 @@ export const createWorkspaceSeedLocalizationStatements = async (
|
||||
// The memo row must still carry the seeded title and the system actor, and
|
||||
// the content must still be the seeded revision. The content is replaced
|
||||
// before the memo row, so the memo row's guard looks for that replacement.
|
||||
const welcomeGuard = (contentHash: string, revision: number) => ({
|
||||
const welcomeGuard = (states: [string, number][]) => ({
|
||||
sql: `${SEED_LOCALIZATION_PENDING}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM memos
|
||||
@@ -157,7 +158,7 @@ export const createWorkspaceSeedLocalizationStatements = async (
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM memo_contents
|
||||
WHERE memo_id = ? AND content_hash = ? AND revision = ?
|
||||
WHERE memo_id = ? AND (${states.map(() => "(content_hash = ? AND revision = ?)").join(" OR ")})
|
||||
)`,
|
||||
bindings: [
|
||||
workspaceId,
|
||||
@@ -165,12 +166,14 @@ export const createWorkspaceSeedLocalizationStatements = async (
|
||||
workspaceId,
|
||||
WELCOME_MEMO_SEED_TITLE,
|
||||
WELCOME_MEMO_ID,
|
||||
contentHash,
|
||||
revision,
|
||||
...states.flat(),
|
||||
],
|
||||
});
|
||||
const welcomeIsUntouched = welcomeGuard(WELCOME_MEMO_SEED_CONTENT_HASH, 0);
|
||||
const welcomeContentWasLocalized = welcomeGuard(welcomeContentHash, 1);
|
||||
const welcomeIsUntouched = welcomeGuard([
|
||||
[WELCOME_MEMO_SEED_CONTENT_HASH, 0],
|
||||
[WELCOME_MEMO_REPAIRED_CONTENT_HASH, 1],
|
||||
]);
|
||||
const welcomeContentWasLocalized = welcomeGuard([[welcomeContentHash, 1], [welcomeContentHash, 2]]);
|
||||
|
||||
return [
|
||||
...createWorkspaceDefaultSeedStatements(db, workspaceId, now, locale, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { globSync, readFileSync } from "node:fs";
|
||||
import { WELCOME_MEMO_SEED_CONTENT_HASH } from "@edgeever/shared/default-workspace-seeds";
|
||||
import { WELCOME_MEMO_REPAIRED_CONTENT_HASH, WELCOME_MEMO_SEED_CONTENT_HASH } from "@edgeever/shared/default-workspace-seeds";
|
||||
import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts";
|
||||
import { updateMemoRecord } from "./memo-service.ts";
|
||||
import {
|
||||
@@ -35,9 +35,9 @@ const freshInstallation = () => {
|
||||
// An installation that ran every migration before this change, then upgraded.
|
||||
const upgradedInstallation = (prepare) => {
|
||||
const database = openDatabase();
|
||||
migrate(database.sqlite, (file) => !file.endsWith(LOCALIZATION_MIGRATION));
|
||||
migrate(database.sqlite, (file) => file < `migrations/${LOCALIZATION_MIGRATION}`);
|
||||
prepare(database.sqlite);
|
||||
database.sqlite.exec(readFileSync(`migrations/${LOCALIZATION_MIGRATION}`, "utf8"));
|
||||
migrate(database.sqlite, (file) => file >= `migrations/${LOCALIZATION_MIGRATION}`);
|
||||
return database;
|
||||
};
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("fresh installation", () => {
|
||||
content: [{ type: "text", text: "Witaj w EdgeEver" }],
|
||||
});
|
||||
expect(note.content_hash).not.toBe(WELCOME_MEMO_SEED_CONTENT_HASH);
|
||||
expect(note.revision).toBe(1);
|
||||
expect(note.revision).toBe(2);
|
||||
expect(searchMatches(sqlite, "Witaj")).toEqual(["memo_welcome"]);
|
||||
expect(templateNames(sqlite, DEFAULT_WORKSPACE_ID)).not.toContain("灵感速记");
|
||||
expect(searchMatches(sqlite, "Skrzynka")).toEqual([]);
|
||||
@@ -240,7 +240,7 @@ describe("fresh installation", () => {
|
||||
);
|
||||
expect(edited.error).toBeUndefined();
|
||||
const before = welcome(sqlite);
|
||||
expect(before.content_hash).toBe(WELCOME_MEMO_SEED_CONTENT_HASH);
|
||||
expect(before.content_hash).toBe(WELCOME_MEMO_REPAIRED_CONTENT_HASH);
|
||||
expect(pendingWorkspaces(sqlite)).toEqual([DEFAULT_WORKSPACE_ID]);
|
||||
|
||||
await ensureUserWorkspace(db, "usr_owner", "admin", "en-US");
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Repair only the exact, untouched installation seed. SQL does not interpret \n.
|
||||
-- Update the memo first so existing sync triggers announce the content change.
|
||||
-- Both statements re-check the old content; replaying this migration is a no-op.
|
||||
UPDATE memos
|
||||
SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = 'memo_welcome'
|
||||
AND id IN (SELECT memo_id FROM memo_contents WHERE memo_id = 'memo_welcome'
|
||||
AND revision = 0
|
||||
AND content_hash = '4edcf7092f86c561210d4eb788e96f28ec5c045f93952c82f5f57e1902f548bd'
|
||||
AND content_json = '{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"欢迎来到 EdgeEver"}]},{"type":"paragraph","content":[{"type":"text","text":"这是第一条 EdgeEver 笔记,三栏、边缘、Agent-ready。"}]},{"type":"paragraph","content":[{"type":"text","text":"接下来可以创建笔记本、写笔记、搜索内容,并把多条笔记合并成一条新的长期笔记。"}]}]}'
|
||||
AND content_markdown = '## 欢迎来到 EdgeEver\n\n这是第一条 EdgeEver 笔记,三栏、边缘、Agent-ready。\n\n接下来可以创建笔记本、写笔记、搜索内容,并把多条笔记合并成一条新的长期笔记。'
|
||||
AND content_text = '欢迎来到 EdgeEver 这是第一条 EdgeEver 笔记,三栏、边缘、Agent-ready。 接下来可以创建笔记本、写笔记、搜索内容,并把多条笔记合并成一条新的长期笔记。'
|
||||
AND NOT EXISTS (SELECT 1 FROM memo_revisions WHERE memo_id = 'memo_welcome')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM memos
|
||||
WHERE id = 'memo_welcome' AND workspace_id = 'ws_default'
|
||||
AND title = '欢迎来到 EdgeEver'
|
||||
AND created_by = 'system' AND updated_by = 'system' AND is_deleted = 0
|
||||
));
|
||||
|
||||
UPDATE memo_contents
|
||||
SET content_markdown = replace(content_markdown, '\n', char(10)),
|
||||
content_hash = '30fae5a337f8b9709505c5ab00ee921410b78632c14f4b7577c88aea0f9fc390',
|
||||
revision = revision + 1,
|
||||
updated_at = (SELECT updated_at FROM memos WHERE id = 'memo_welcome')
|
||||
WHERE memo_id = 'memo_welcome'
|
||||
AND revision = 0
|
||||
AND content_hash = '4edcf7092f86c561210d4eb788e96f28ec5c045f93952c82f5f57e1902f548bd'
|
||||
AND content_json = '{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"欢迎来到 EdgeEver"}]},{"type":"paragraph","content":[{"type":"text","text":"这是第一条 EdgeEver 笔记,三栏、边缘、Agent-ready。"}]},{"type":"paragraph","content":[{"type":"text","text":"接下来可以创建笔记本、写笔记、搜索内容,并把多条笔记合并成一条新的长期笔记。"}]}]}'
|
||||
AND content_markdown = '## 欢迎来到 EdgeEver\n\n这是第一条 EdgeEver 笔记,三栏、边缘、Agent-ready。\n\n接下来可以创建笔记本、写笔记、搜索内容,并把多条笔记合并成一条新的长期笔记。'
|
||||
AND content_text = '欢迎来到 EdgeEver 这是第一条 EdgeEver 笔记,三栏、边缘、Agent-ready。 接下来可以创建笔记本、写笔记、搜索内容,并把多条笔记合并成一条新的长期笔记。'
|
||||
AND NOT EXISTS (SELECT 1 FROM memo_revisions WHERE memo_id = 'memo_welcome')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM memos
|
||||
WHERE id = 'memo_welcome' AND workspace_id = 'ws_default'
|
||||
AND title = '欢迎来到 EdgeEver'
|
||||
AND created_by = 'system' AND updated_by = 'system' AND is_deleted = 0
|
||||
);
|
||||
@@ -64,6 +64,8 @@ export const WELCOME_MEMO_ID = "memo_welcome";
|
||||
*/
|
||||
export const WELCOME_MEMO_SEED_TITLE = "欢迎来到 EdgeEver";
|
||||
export const WELCOME_MEMO_SEED_CONTENT_HASH = "4edcf7092f86c561210d4eb788e96f28ec5c045f93952c82f5f57e1902f548bd";
|
||||
// Migration 0071 repairs the original seed's literal backslash-n and advances it to revision 1.
|
||||
export const WELCOME_MEMO_REPAIRED_CONTENT_HASH = "30fae5a337f8b9709505c5ab00ee921410b78632c14f4b7577c88aea0f9fc390";
|
||||
|
||||
export type WelcomeMemoTranslation = {
|
||||
title: string;
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("seed memo migrations", () => {
|
||||
const db = new Database(":memory:");
|
||||
const migrations = globSync("migrations/*.sql").sort();
|
||||
|
||||
for (const migration of migrations.filter((path) => !path.endsWith("0014_repair_seed_memo_content_hash.sql"))) {
|
||||
for (const migration of migrations.filter((path) => path < "migrations/0014_repair_seed_memo_content_hash.sql")) {
|
||||
db.exec(readFileSync(migration, "utf8"));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { globSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { buildWelcomeMemoContent, WELCOME_MEMO_REPAIRED_CONTENT_HASH } from "../packages/shared/src/default-workspace-seeds";
|
||||
import { createSelfHostedStorageAdapter } from "../apps/api/src/self-hosted-storage-adapter";
|
||||
import { ensureUserWorkspace } from "../apps/api/src/workspace-provisioning";
|
||||
import { createLocalDevelopmentSession } from "../scripts/local-dev-auth.mjs";
|
||||
|
||||
const repairPath = "migrations/0071_fix_welcome_memo_markdown.sql";
|
||||
const repair = readFileSync(repairPath, "utf8");
|
||||
const migrations = globSync("migrations/*.sql").sort();
|
||||
const databases: Database[] = [];
|
||||
const directories: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const db of databases.splice(0)) db.close();
|
||||
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const open = (path = ":memory:") => {
|
||||
const db = new Database(path);
|
||||
databases.push(db);
|
||||
return db;
|
||||
};
|
||||
const legacy = () => {
|
||||
const db = open();
|
||||
for (const path of migrations.filter((path) => path < repairPath)) db.exec(readFileSync(path, "utf8"));
|
||||
return db;
|
||||
};
|
||||
const content = (db: Database) => db.query("SELECT * FROM memo_contents WHERE memo_id = 'memo_welcome'").get() as {
|
||||
content_markdown: string; content_json: string; content_hash: string; revision: number; updated_at: string;
|
||||
};
|
||||
const memo = (db: Database) => db.query("SELECT * FROM memos WHERE id = 'memo_welcome'").get();
|
||||
const changes = (db: Database) => db.query("SELECT * FROM mobile_sync_changes ORDER BY id").all();
|
||||
const assertRepaired = (db: Database) => {
|
||||
const row = content(db);
|
||||
expect(row.content_markdown).toBe(buildWelcomeMemoContent("zh-CN").contentMarkdown);
|
||||
expect(row.content_markdown).not.toContain("\\n");
|
||||
expect(row.content_markdown.split("\n\n")).toHaveLength(3);
|
||||
expect(row.content_hash).toBe(createHash("sha256").update(row.content_markdown + row.content_json).digest("hex"));
|
||||
expect(row.content_hash).toBe(WELCOME_MEMO_REPAIRED_CONTENT_HASH);
|
||||
expect(row.revision).toBe(1);
|
||||
};
|
||||
|
||||
describe("welcome Markdown repair", () => {
|
||||
test("initializes correctly through local development auto-login without normal sign-in", async () => {
|
||||
const db = open();
|
||||
for (const path of migrations) db.exec(readFileSync(path, "utf8"));
|
||||
await createLocalDevelopmentSession(db);
|
||||
assertRepaired(db);
|
||||
});
|
||||
|
||||
test("upgrades a persisted old database and announces the change exactly once", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "edgeever-welcome-upgrade-"));
|
||||
directories.push(directory);
|
||||
const path = join(directory, "old.sqlite");
|
||||
const old = new Database(path);
|
||||
try {
|
||||
for (const migration of migrations.filter((file) => file < repairPath)) old.exec(readFileSync(migration, "utf8"));
|
||||
old.exec("UPDATE memos SET updated_at = '2020-01-01T00:00:00.000Z' WHERE id = 'memo_welcome'");
|
||||
expect(content(old).content_markdown).toContain("\\n\\n");
|
||||
} finally {
|
||||
old.close();
|
||||
}
|
||||
const db = open(path);
|
||||
const before = changes(db);
|
||||
db.transaction(() => db.exec(repair))();
|
||||
assertRepaired(db);
|
||||
expect(changes(db)).toHaveLength(before.length + 1);
|
||||
expect(changes(db).at(-1)).toMatchObject({ workspace_id: "ws_default", entity_type: "memo", entity_id: "memo_welcome", operation: "upsert" });
|
||||
expect(db.query("SELECT updated_at FROM memos WHERE id = 'memo_welcome'").get()).toEqual({ updated_at: content(db).updated_at });
|
||||
expect(content(db).updated_at).not.toBe("2020-01-01T00:00:00.000Z");
|
||||
const repaired = { memo: memo(db), content: content(db), changes: changes(db) };
|
||||
db.exec(repair);
|
||||
expect({ memo: memo(db), content: content(db), changes: changes(db) }).toEqual(repaired);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["title", "UPDATE memos SET title = 'Custom title'"],
|
||||
["actor", "UPDATE memos SET updated_by = 'user'"],
|
||||
["creator", "UPDATE memos SET created_by = 'user'"],
|
||||
["deleted", "UPDATE memos SET is_deleted = 1"],
|
||||
["revision", "UPDATE memo_contents SET revision = 4"],
|
||||
["hash", "UPDATE memo_contents SET content_hash = 'custom'"],
|
||||
["Markdown with literal escapes", "UPDATE memo_contents SET content_markdown = 'user\\ntext'"],
|
||||
["JSON", "UPDATE memo_contents SET content_json = '{}'"],
|
||||
["plain text", "UPDATE memo_contents SET content_text = 'Custom text'"],
|
||||
])("preserves a seed with a changed %s", (_name, edit) => {
|
||||
const db = legacy();
|
||||
db.exec(edit);
|
||||
const before = { memo: memo(db), content: content(db), changes: changes(db) };
|
||||
db.exec(repair);
|
||||
expect({ memo: memo(db), content: content(db), changes: changes(db) }).toEqual(before);
|
||||
});
|
||||
|
||||
test.each([false, true])("localizes at first sign-in with repair applied: %s", async (applyRepair) => {
|
||||
const sqlite = legacy();
|
||||
if (applyRepair) sqlite.exec(repair);
|
||||
sqlite.exec("INSERT INTO users (id, username, password_hash, display_name) VALUES ('owner', 'owner', 'unused', 'Owner')");
|
||||
const db = createSelfHostedStorageAdapter(sqlite, "/tmp/edgeever-welcome-unused").db;
|
||||
await ensureUserWorkspace(db, "owner", "owner", "en-US");
|
||||
expect(content(sqlite).content_markdown).toBe(buildWelcomeMemoContent("en-US").contentMarkdown);
|
||||
expect(content(sqlite).revision).toBe(applyRepair ? 2 : 1);
|
||||
expect(memo(sqlite)).toMatchObject({ title: "Welcome to EdgeEver" });
|
||||
const before = { memo: memo(sqlite), content: content(sqlite), changes: changes(sqlite) };
|
||||
sqlite.exec(repair);
|
||||
expect({ memo: memo(sqlite), content: content(sqlite), changes: changes(sqlite) }).toEqual(before);
|
||||
});
|
||||
|
||||
test("preserves a note with revision history even if its current content matches the seed", () => {
|
||||
const db = legacy();
|
||||
db.exec(`INSERT INTO memo_revisions (id, memo_id, revision, title, content_json, content_markdown, content_hash, created_by)
|
||||
SELECT 'rev_old', memo_id, 0, 'Previous title', content_json, content_markdown, content_hash, 'user'
|
||||
FROM memo_contents WHERE memo_id = 'memo_welcome'`);
|
||||
const before = { memo: memo(db), content: content(db), changes: changes(db) };
|
||||
db.exec(repair);
|
||||
expect({ memo: memo(db), content: content(db), changes: changes(db) }).toEqual(before);
|
||||
});
|
||||
|
||||
test("preserves other notes containing the exact same content", () => {
|
||||
const db = legacy();
|
||||
db.exec(`INSERT INTO memos (id, notebook_id, title, created_by, updated_by)
|
||||
VALUES ('memo_user', 'nb_inbox', '欢迎来到 EdgeEver', 'system', 'system');
|
||||
INSERT INTO memo_contents (memo_id, content_json, content_markdown, content_text, content_hash)
|
||||
SELECT 'memo_user', content_json, content_markdown, content_text, content_hash
|
||||
FROM memo_contents WHERE memo_id = 'memo_welcome'`);
|
||||
const before = db.query("SELECT * FROM memo_contents WHERE memo_id = 'memo_user'").get();
|
||||
db.exec(repair);
|
||||
expect(db.query("SELECT * FROM memo_contents WHERE memo_id = 'memo_user'").get()).toEqual(before);
|
||||
assertRepaired(db);
|
||||
});
|
||||
|
||||
test("does not announce or recreate a missing welcome note", () => {
|
||||
const db = legacy();
|
||||
db.exec("DELETE FROM memo_contents WHERE memo_id = 'memo_welcome'; DELETE FROM memos WHERE id = 'memo_welcome'");
|
||||
const before = changes(db);
|
||||
db.exec(repair);
|
||||
expect(memo(db)).toBeNull();
|
||||
expect(changes(db)).toEqual(before);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user