diff --git a/apps/api/src/template-seed-migration.test.mjs b/apps/api/src/template-seed-migration.test.mjs index 4248ab0d..ca368f5a 100644 --- a/apps/api/src/template-seed-migration.test.mjs +++ b/apps/api/src/template-seed-migration.test.mjs @@ -22,13 +22,36 @@ describe("editable template migration", () => { `).run(); sqlite.exec(migration("0030_unify_editable_note_templates.sql")); + sqlite.exec(migration("0031_limit_default_memo_templates.sql")); const clean = sqlite.query(`SELECT id, name FROM memo_templates WHERE workspace_id = ? ORDER BY id`).all("ws_clean"); const edited = sqlite.query(`SELECT id, name FROM memo_templates WHERE workspace_id = ? ORDER BY id`).all("ws_edited"); - expect(clean).toHaveLength(6); + expect(clean).toHaveLength(5); expect(clean.some((row) => row.id === "ws_clean_template_project_weekly")).toBe(false); - expect(edited).toHaveLength(7); + expect(edited).toHaveLength(6); expect(edited.some((row) => row.name === "我的项目周报")).toBe(true); expect(clean.every((row) => !row.name.includes("内置"))).toBe(true); }); + + test("preserves a user-edited post-mortem template", () => { + const sqlite = new Database(":memory:"); + sqlite.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE workspaces (id TEXT PRIMARY KEY); + INSERT INTO workspaces (id) VALUES ('ws_edited'); + `); + sqlite.exec(migration("0016_templates.sql")); + sqlite.exec(migration("0030_unify_editable_note_templates.sql")); + sqlite.query(` + UPDATE memo_templates + SET name = '我的事故复盘', updated_at = '2099-01-01T00:00:00.000Z' + WHERE id = 'ws_edited_template_post-mortem' + `).run(); + + sqlite.exec(migration("0031_limit_default_memo_templates.sql")); + + const templates = sqlite.query(`SELECT id, name FROM memo_templates WHERE workspace_id = ? ORDER BY id`).all("ws_edited"); + expect(templates).toHaveLength(6); + expect(templates.some((row) => row.name === "我的事故复盘")).toBe(true); + }); }); diff --git a/apps/api/src/workspace-provisioning.test.mjs b/apps/api/src/workspace-provisioning.test.mjs index 8b7fc3c8..695561ba 100644 --- a/apps/api/src/workspace-provisioning.test.mjs +++ b/apps/api/src/workspace-provisioning.test.mjs @@ -57,7 +57,7 @@ describe("workspace provisioning", () => { expect(calls[0].sql).toContain("INSERT OR IGNORE INTO memo_templates"); expect(calls[0].values).toContain("ws_new_template_quick-note"); expect(calls[0].values).toContain("Quick Spark"); - expect(calls.filter((call) => call.sql.includes("INSERT OR IGNORE INTO memo_templates"))).toHaveLength(6); + expect(calls.filter((call) => call.sql.includes("INSERT OR IGNORE INTO memo_templates"))).toHaveLength(5); expect(calls.some((call) => call.sql.includes("INSERT OR IGNORE INTO ai_prompt_templates"))).toBe(true); }); }); diff --git a/apps/web/src/lib/repository.test.mjs b/apps/web/src/lib/repository.test.mjs index 8f7c8f3d..be2f14dc 100644 --- a/apps/web/src/lib/repository.test.mjs +++ b/apps/web/src/lib/repository.test.mjs @@ -35,6 +35,56 @@ afterEach(async () => { }); describe("web repository offline boundaries", () => { + test("reconciles stale template cache entries while preserving queued local edits", async () => { + const previousOnline = globalThis.navigator?.onLine; + if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true }); + const restoreWindow = installTestWindow(); + const scope = "https://demo.edgeever.org|user-1"; + const template = (id, name, updatedAt) => ({ + id, + name, + description: null, + title: name, + contentJson: { type: "doc", content: [] }, + contentMarkdown: "", + tags: [], + createdAt: updatedAt, + updatedAt, + }); + const stale = template("template-stale", "Stale", "2026-01-01T00:00:00.000Z"); + const edited = template("template-edited", "Local edit", "2026-01-03T00:00:00.000Z"); + const remoteEdited = template("template-edited", "Remote old", "2026-01-02T00:00:00.000Z"); + const remote = template("template-remote", "Remote", "2026-01-04T00:00:00.000Z"); + await localDb.templates.bulkPut([{ ...stale, scope }, { ...edited, scope }]); + await localDb.syncQueue.put({ + id: `action:${scope}:template.update:${edited.id}`, + kind: "template.update", + scope, + memoId: edited.id, + status: "pending", + payload: { templateId: edited.id, name: edited.name }, + attemptCount: 0, + lastError: null, + nextAttemptAt: null, + claimId: null, + createdAt: edited.updatedAt, + updatedAt: edited.updatedAt, + }); + const originalListTemplates = api.listTemplates; + api.listTemplates = async () => ({ templates: [remote, remoteEdited] }); + + try { + const result = await createWebRepository(scope).listTemplates(); + expect(result.templates.map((item) => item.id)).toEqual([remote.id, edited.id]); + expect(result.templates.find((item) => item.id === edited.id)?.name).toBe("Local edit"); + expect(await localDb.templates.get([scope, stale.id])).toBeUndefined(); + } finally { + api.listTemplates = originalListTemplates; + restoreWindow(); + if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline }); + } + }); + test("saves memo edits locally while deferring remote synchronization", async () => { const restoreWindow = installTestWindow(); let immediateEvents = 0; diff --git a/apps/web/src/lib/repository.ts b/apps/web/src/lib/repository.ts index b9680a65..86287384 100644 --- a/apps/web/src/lib/repository.ts +++ b/apps/web/src/lib/repository.ts @@ -106,6 +106,44 @@ const hasActiveLocalMemoUpdate = async (memoId: string) => { return isActiveLocalMemoUpdateStatus(item?.status); }; +const reconcileLocalTemplates = async ( + scope: string, + remoteTemplates: MemoTemplate[], +): Promise => { + const [local, queuedItems] = await Promise.all([ + listLocalTemplates(scope), + localDb.syncQueue.filter((item) => item.scope === scope && item.kind.startsWith("template.")).toArray(), + ]); + const localById = new Map(local.templates.map((template) => [template.id, template])); + const pendingUpsertIds = new Set( + queuedItems + .filter((item) => item.kind === "template.create" || item.kind === "template.update") + .map((item) => item.memoId), + ); + const pendingDeleteIds = new Set( + queuedItems.filter((item) => item.kind === "template.delete").map((item) => item.memoId), + ); + const reconciled = new Map(); + + for (const remote of remoteTemplates) { + if (pendingDeleteIds.has(remote.id)) continue; + reconciled.set(remote.id, pendingUpsertIds.has(remote.id) ? localById.get(remote.id) ?? remote : remote); + } + for (const templateId of pendingUpsertIds) { + const localTemplate = localById.get(templateId); + if (localTemplate) reconciled.set(templateId, localTemplate); + } + + const templates = [...reconciled.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + await localDb.transaction("rw", localDb.templates, async () => { + await localDb.templates.where("scope").equals(scope).delete(); + if (templates.length > 0) { + await localDb.templates.bulkPut(templates.map((template) => ({ ...template, scope }))); + } + }); + return templates; +}; + /** * Persist and surface a remote detail only when it is strictly fresher than the * local mirror (and no memo.update is still waiting to push). Returns whether @@ -187,13 +225,13 @@ export const createWebRepository = (scope: string): EdgeEverRepository => { async listTemplates() { const local = await listLocalTemplates(scope); - if (local.templates.length > 0 || isOffline()) { - if (!isOffline()) void api.listTemplates().then((remote) => Promise.all(remote.templates.map((template) => putLocalTemplate(scope, template)))).catch(() => {}); + if (isOffline()) return local; + try { + const remote = await api.listTemplates(); + return { templates: await reconcileLocalTemplates(scope, remote.templates) }; + } catch { return local; } - const remote = await api.listTemplates(); - await Promise.all(remote.templates.map((template) => putLocalTemplate(scope, template))); - return remote; }, createTemplate: async (input) => { const template = await createLocalTemplate(scope, input); diff --git a/migrations/0031_limit_default_memo_templates.sql b/migrations/0031_limit_default_memo_templates.sql new file mode 100644 index 00000000..61cda086 --- /dev/null +++ b/migrations/0031_limit_default_memo_templates.sql @@ -0,0 +1,10 @@ +PRAGMA foreign_keys = ON; + +-- Five starter templates cover the most common workflows. Remove only the +-- untouched post-mortem preset; edited copies remain ordinary user templates. +DELETE FROM memo_templates +WHERE id = workspace_id || '_template_post-mortem' + AND name = '问题排查与复盘' + AND description = '记录故障现象、根因分析 (5 Whys) 与防范机制。' + AND title = '问题排查与复盘' + AND updated_at = created_at; diff --git a/packages/shared/src/memo-template-seeds.test.mjs b/packages/shared/src/memo-template-seeds.test.mjs index 4ebc3f71..67bccae7 100644 --- a/packages/shared/src/memo-template-seeds.test.mjs +++ b/packages/shared/src/memo-template-seeds.test.mjs @@ -6,9 +6,9 @@ import { } from "./memo-template-seeds.ts"; describe("memo template seeds", () => { - test("provides six unique editable starter templates in both supported languages", () => { - expect(DEFAULT_MEMO_TEMPLATE_SEEDS).toHaveLength(6); - expect(new Set(DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => seed.key)).size).toBe(6); + test("provides five unique editable starter templates in both supported languages", () => { + expect(DEFAULT_MEMO_TEMPLATE_SEEDS).toHaveLength(5); + expect(new Set(DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => seed.key)).size).toBe(5); expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "zh-CN").title).toBe("灵感速记"); expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "en-GB").title).toBe("Quick Spark"); }); diff --git a/packages/shared/src/memo-template-seeds.ts b/packages/shared/src/memo-template-seeds.ts index 3a24c9f8..3d015254 100644 --- a/packages/shared/src/memo-template-seeds.ts +++ b/packages/shared/src/memo-template-seeds.ts @@ -7,8 +7,8 @@ export type MemoTemplateSeedTranslation = { }; export type MemoTemplateSeed = { - key: "quick-note" | "meeting" | "weekly-review" | "reading" | "okr" | "post-mortem"; - translationKey: "quickNote" | "meeting" | "weeklyReview" | "reading" | "okr" | "postMortem"; + key: "quick-note" | "meeting" | "weekly-review" | "reading" | "okr"; + translationKey: "quickNote" | "meeting" | "weeklyReview" | "reading" | "okr"; tag: string; translations: Record; }; @@ -88,19 +88,6 @@ export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [ contentMarkdown: "# 🎯 Goal Breakdown\n\n- **Period**:\n- **Owner**:\n\n---\n\n## 📌 Objective\n\n> \n\n## 📈 Key Results\n\n- **KR 1**: Target metric -> Current progress\n- **KR 2**: Target metric -> Current progress\n\n## 🗓️ Milestones\n\n- [ ] **Phase 1 (Date)**: Target\n- [ ] **Phase 2 (Date)**: Target\n\n## 📋 Execution Checklist\n\n- [ ] \n- [ ] \n", }, ), - seed( - { key: "post-mortem", translationKey: "postMortem", tag: "post-mortem" }, - { - title: "问题排查与复盘", - description: "记录故障现象、根因分析 (5 Whys) 与防范机制。", - contentMarkdown: "# 🔍 问题排查与复盘 (Post-mortem)\n\n- **发生时间**:\n- **影响范围**:\n- **处理状态**:已解决 / 处理中\n\n---\n\n## 🚨 故障现象与影响\n\n\n\n## 🛠️ 排查过程与解决方案\n\n1. \n2. \n\n## 🔬 根因分析 (Root Cause / 5 Whys)\n\n- **根本原因**:\n\n## 🛡️ 预防措施 (Action Items)\n\n- [ ] **[短期规避]** \n- [ ] **[长期优化]** \n", - }, - { - title: "Problem & Post-mortem", - description: "Document incident symptoms, 5-Whys root cause, and preventive measures.", - contentMarkdown: "# 🔍 Post-mortem & Problem Investigation\n\n- **Occurred At**:\n- **Impact Scope**:\n- **Status**: Resolved / In-Progress\n\n---\n\n## 🚨 Symptoms & Impact\n\n\n\n## 🛠️ Investigation & Fix Steps\n\n1. \n2. \n\n## 🔬 Root Cause Analysis (5 Whys)\n\n- **Root Cause**:\n\n## 🛡️ Action Items & Prevention\n\n- [ ] **[Short-term]** \n- [ ] **[Long-term]** \n", - }, - ), ]; export const normalizeMemoTemplateSeedLocale = (