diff --git a/apps/api/src/workspace-provisioning.ts b/apps/api/src/workspace-provisioning.ts index ddb0c458..5a4878be 100644 --- a/apps/api/src/workspace-provisioning.ts +++ b/apps/api/src/workspace-provisioning.ts @@ -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, { diff --git a/apps/api/src/workspace-seed-localization.test.mjs b/apps/api/src/workspace-seed-localization.test.mjs index bc934e8c..3ff36464 100644 --- a/apps/api/src/workspace-seed-localization.test.mjs +++ b/apps/api/src/workspace-seed-localization.test.mjs @@ -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"); diff --git a/migrations/0071_fix_welcome_memo_markdown.sql b/migrations/0071_fix_welcome_memo_markdown.sql new file mode 100644 index 00000000..f46e4b1f --- /dev/null +++ b/migrations/0071_fix_welcome_memo_markdown.sql @@ -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 + ); diff --git a/packages/shared/src/default-workspace-seeds.ts b/packages/shared/src/default-workspace-seeds.ts index 04e8a852..0e687600 100644 --- a/packages/shared/src/default-workspace-seeds.ts +++ b/packages/shared/src/default-workspace-seeds.ts @@ -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; diff --git a/tests/seed-memo-migration.test.ts b/tests/seed-memo-migration.test.ts index 9c0ff5c3..f525bc1d 100644 --- a/tests/seed-memo-migration.test.ts +++ b/tests/seed-memo-migration.test.ts @@ -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")); } diff --git a/tests/welcome-memo-markdown-migration.test.ts b/tests/welcome-memo-markdown-migration.test.ts new file mode 100644 index 00000000..ea5961b7 --- /dev/null +++ b/tests/welcome-memo-markdown-migration.test.ts @@ -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); + }); +});