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:
tianma-if committed 2026-10-19 19:43:33 +08:00
1 parent 65e946effb
commit dba620d8e5
6 files changed
+197 -12

No files matched your search

+9 -6
View File
@@ -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;
+1 -1
View File
@@ -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);
});
});