From 7f04db3c89f359134bcb036f21438b7d25d7bf56 Mon Sep 17 00:00:00 2001 From: Alec Mocatta Date: Wed, 1 Jul 2026 15:41:45 -0700 Subject: [PATCH 1/2] harden editor test --- e2e/markdown-editor-fuzz.spec.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/e2e/markdown-editor-fuzz.spec.ts b/e2e/markdown-editor-fuzz.spec.ts index d02f1eb..e43d8e4 100644 --- a/e2e/markdown-editor-fuzz.spec.ts +++ b/e2e/markdown-editor-fuzz.spec.ts @@ -97,12 +97,9 @@ test("fuzzes markdown editor plain text in a real browser", async ({ test("fuzzes markdown editor plain text through the Flashtype UI", async ({ browserName: _browserName, }, testInfo) => { - const seed = - process.env.FLASHTYPE_MARKDOWN_UI_FUZZ_SEED ?? - process.env.FLASHTYPE_MARKDOWN_FUZZ_SEED ?? - MARKDOWN_EDITOR_FUZZ_DEFAULT_SEED; const operationCount = markdownUiFuzzOperationCount(); - const rng = seedrandom(seed); + const seed = testInfo.repeatEachIndex; + const rng = seedrandom(seed.toString()); const state = createSimplifiedState(); const workspaceDir = testInfo.outputPath("workspace-ui-fuzz"); const fuzzFile = path.join(workspaceDir, "fuzz.md"); @@ -134,11 +131,13 @@ test("fuzzes markdown editor plain text through the Flashtype UI", async ({ try { await applyOperationToUiPage(page, operation); applyOperationToSimplifiedState(state, operation); + const delayMs = Math.floor(rng() * (1000 + 1)); + await page.waitForTimeout(delayMs); } catch (error) { const snapshot = await safeUiSnapshot(page); throw new Error( buildUiOperationFailureMessage({ - seed, + seed: seed, index, operation, state, @@ -154,7 +153,7 @@ test("fuzzes markdown editor plain text through the Flashtype UI", async ({ if (snapshot.plainText !== expected) { throw new Error( buildUiPlainTextMismatchMessage({ - seed, + seed: seed, index, operation, state, @@ -187,7 +186,7 @@ test("fuzzes markdown editor plain text through the Flashtype UI", async ({ function assertSnapshotSelectionMatches( snapshot: MarkdownFuzzSnapshot, state: SimplifiedState, - seed: string, + seed: number, index: number, operation: FuzzOperation, ): void { @@ -375,7 +374,7 @@ function markdownUiFuzzOperationCount(): number { } function buildUiOperationFailureMessage(args: { - seed: string; + seed: number; index: number; operation: FuzzOperation; state: SimplifiedState; @@ -399,7 +398,7 @@ function buildUiOperationFailureMessage(args: { } function buildUiPlainTextMismatchMessage(args: { - seed: string; + seed: number; index: number; operation: FuzzOperation; state: SimplifiedState; @@ -422,7 +421,7 @@ function buildUiPlainTextMismatchMessage(args: { function assertUiSnapshotSelectionMatches( snapshot: UiMarkdownFuzzSnapshot, state: SimplifiedState, - seed: string, + seed: number, index: number, operation: FuzzOperation, ): void { @@ -446,7 +445,7 @@ function assertUiSnapshotSelectionMatches( } function buildUiSelectionMismatchMessage(args: { - seed: string; + seed: number; index: number; operation: FuzzOperation; state: SimplifiedState; From d43426db3aa06233955e97dbdbb81a82fbe5e46e Mon Sep 17 00:00:00 2001 From: Alec Mocatta Date: Wed, 1 Jul 2026 17:28:51 -0700 Subject: [PATCH 2/2] Handle origin-aware markdown autosave echoes --- e2e/markdown-editor-fuzz.spec.ts | 2 +- electron/ipc-lix.mjs | 16 +- electron/lix.mjs | 10 +- electron/types.d.ts | 2 + .../editor/build-markdown-from-editor.ts | 39 +++- .../markdown/editor/create-editor.ts | 59 ++---- .../markdown/editor/tip-tap-editor.test.tsx | 181 ++++++++++++++++++ .../markdown/editor/tip-tap-editor.tsx | 63 ++++-- .../markdown/editor/upsert-markdown-file.ts | 46 +++-- src/lib/lix-client.ts | 6 +- src/lib/lix-types.ts | 14 +- src/test-utils/node-lix-sdk.ts | 17 +- submodule/lix | 2 +- 13 files changed, 363 insertions(+), 94 deletions(-) diff --git a/e2e/markdown-editor-fuzz.spec.ts b/e2e/markdown-editor-fuzz.spec.ts index e43d8e4..24cfdc5 100644 --- a/e2e/markdown-editor-fuzz.spec.ts +++ b/e2e/markdown-editor-fuzz.spec.ts @@ -97,7 +97,7 @@ test("fuzzes markdown editor plain text in a real browser", async ({ test("fuzzes markdown editor plain text through the Flashtype UI", async ({ browserName: _browserName, }, testInfo) => { - const operationCount = markdownUiFuzzOperationCount(); + const operationCount = 300; const seed = testInfo.repeatEachIndex; const rng = seedrandom(seed.toString()); const state = createSimplifiedState(); diff --git a/electron/ipc-lix.mjs b/electron/ipc-lix.mjs index ea34005..517a4ec 100644 --- a/electron/ipc-lix.mjs +++ b/electron/ipc-lix.mjs @@ -48,9 +48,10 @@ export function registerLixIpc(resolveWindowForEvent, options = {}) { const lix = await ensureLixOpenForEvent(event); const sql = String(payload?.sql ?? ""); const params = normalizeParams(payload?.params); + const options = normalizeExecuteOptions(payload?.options); const started = performance.now(); try { - const result = await lix.execute(sql, params); + const result = await lix.execute(sql, params, options); const serialized = serializeExecuteResult(result, "lix.execute"); logSlowOperation("execute", started, { sqlHash: hashString(sql), @@ -130,9 +131,10 @@ export function registerLixIpc(resolveWindowForEvent, options = {}) { ); const sql = String(payload?.sql ?? ""); const params = normalizeParams(payload?.params); + const options = normalizeExecuteOptions(payload?.options); const started = performance.now(); try { - const result = await transaction.execute(sql, params); + const result = await transaction.execute(sql, params, options); const serialized = serializeExecuteResult(result, "transaction.execute"); logSlowOperation("transaction:execute", started, { transactionId: String(payload?.transactionId ?? ""), @@ -449,6 +451,16 @@ function normalizeParams(params) { return params.map((param, index) => normalizeSqlParam(param, index)); } +function normalizeExecuteOptions(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + return undefined; + } + if (typeof options.originKey !== "string") { + return undefined; + } + return { originKey: options.originKey }; +} + function normalizeSqlParam(value, index = 0) { if ( value === null || diff --git a/electron/lix.mjs b/electron/lix.mjs index 0ab21b1..565258f 100644 --- a/electron/lix.mjs +++ b/electron/lix.mjs @@ -315,8 +315,10 @@ function createDesktopLixHandle(nativeLix, workspaceDir, storageDir) { storageDir() { return storageDir; }, - async execute(sql, params = []) { - return await runQueued(() => nativeLix.execute(sql, [...params])); + async execute(sql, params = [], options) { + return await runQueued(() => + nativeLix.execute(sql, [...params], options), + ); }, async beginTransaction() { const releaseSlot = await acquireOperationSlot(); @@ -329,8 +331,8 @@ function createDesktopLixHandle(nativeLix, workspaceDir, storageDir) { throw error; } return { - async execute(sql, params = []) { - return await transaction.execute(sql, [...params]); + async execute(sql, params = [], options) { + return await transaction.execute(sql, [...params], options); }, async commit() { if (transactionClosed) { diff --git a/electron/types.d.ts b/electron/types.d.ts index f0e61d2..8d6ae77 100644 --- a/electron/types.d.ts +++ b/electron/types.d.ts @@ -52,6 +52,7 @@ export type DesktopLixApi = { execute(payload: { sql: string; params?: ReadonlyArray; + options?: { originKey?: string }; }): Promise; executeTransaction(payload: { statements: ReadonlyArray<{ @@ -64,6 +65,7 @@ export type DesktopLixApi = { transactionId: string; sql: string; params?: ReadonlyArray; + options?: { originKey?: string }; }): Promise; transactionCommit(payload: { transactionId: string }): Promise; transactionRollback(payload: { transactionId: string }): Promise; diff --git a/src/extensions/markdown/editor/build-markdown-from-editor.ts b/src/extensions/markdown/editor/build-markdown-from-editor.ts index 0540871..5df6816 100644 --- a/src/extensions/markdown/editor/build-markdown-from-editor.ts +++ b/src/extensions/markdown/editor/build-markdown-from-editor.ts @@ -1,11 +1,48 @@ import { serializeAst } from "./markdown"; import { tiptapDocToAst } from "./tiptap-markdown-bridge"; +const createNodeId = (): string => { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID().replaceAll("-", "").slice(0, 10); + } + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`.slice( + 0, + 10, + ); +}; + +function ensureTopLevelIds(children: any[]): void { + const seen = new Set(); + for (const node of children) { + node.data = node.data || {}; + let id = (node.data.id ?? "") as string; + if (!id || seen.has(id)) { + do { + id = createNodeId(); + } while (seen.has(id)); + node.data.id = id; + } + seen.add(id); + } +} + +export const normalizePersistedMarkdown = (markdown: string): string => + markdown.endsWith("\n") ? markdown : `${markdown}\n`; + export function buildMarkdownFromEditor(editor: any): string { const ast = tiptapDocToAst(editor.getJSON() as any) as any; + const children = (ast?.children ?? []) as any[]; + ensureTopLevelIds(children); const root = { type: "root", - children: (ast?.children ?? []) as any[], + children, } as any; return serializeAst(root); } + +export function buildNormalizedMarkdownFromEditor(editor: any): string { + return normalizePersistedMarkdown(buildMarkdownFromEditor(editor)); +} diff --git a/src/extensions/markdown/editor/create-editor.ts b/src/extensions/markdown/editor/create-editor.ts index 23e1113..ac84e52 100644 --- a/src/extensions/markdown/editor/create-editor.ts +++ b/src/extensions/markdown/editor/create-editor.ts @@ -2,17 +2,17 @@ import { Editor } from "@tiptap/core"; import History from "@tiptap/extension-history"; import Placeholder from "@tiptap/extension-placeholder"; import type { Lix } from "@/lib/lix-types"; -import { - MarkdownWc, - astToTiptapDoc, - tiptapDocToAst, -} from "./tiptap-markdown-bridge"; +import { MarkdownWc, astToTiptapDoc } from "./tiptap-markdown-bridge"; import type { EmptyMarkdownDefaultBlock } from "./tiptap-markdown-bridge"; import { parseMarkdown, serializeAst } from "./markdown"; import { handlePaste as defaultHandlePaste } from "./handle-paste"; import { SlashCommandsExtension } from "./extensions/slash-commands"; import { TableNavigationExtension } from "./extensions/table-navigation"; import { upsertMarkdownFile } from "./upsert-markdown-file"; +import { + buildNormalizedMarkdownFromEditor, + normalizePersistedMarkdown, +} from "./build-markdown-from-editor"; type CreateEditorArgs = { lix: Lix; @@ -27,24 +27,21 @@ type CreateEditorArgs = { persistDebounceMs?: number; persistState?: boolean; resolveImageSrc?: (src: string) => string; + originKey?: string; }; -const createNodeId = (): string => { +export const createMarkdownEditorOriginKey = (): string => { if ( typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ) { - return crypto.randomUUID().replaceAll("-", "").slice(0, 10); + return `flashtype.markdown-editor:${crypto.randomUUID()}`; } - return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`.slice( - 0, - 10, - ); + return `flashtype.markdown-editor:${Date.now().toString(36)}${Math.random() + .toString(36) + .slice(2)}`; }; -const normalizePersistedMarkdown = (markdown: string): string => - markdown.endsWith("\n") ? markdown : `${markdown}\n`; - function flushEditorViewDomObserver(view: any): void { view?.domObserver?.flush?.(); } @@ -67,9 +64,7 @@ function externalLinkUrlFromClick(event: MouseEvent): string | null { return null; } const target = - event.target instanceof Element - ? event.target.closest("a[href]") - : null; + event.target instanceof Element ? event.target.closest("a[href]") : null; if (!(target instanceof HTMLAnchorElement)) { return null; } @@ -105,28 +100,6 @@ function handleExternalLinkClick(event: MouseEvent): void { openExternalLink(url); } -function ensureTopLevelIds(children: any[]): void { - const seen = new Set(); - for (const node of children) { - node.data = node.data || {}; - let id = (node.data.id ?? "") as string; - if (!id || seen.has(id)) { - do { - id = createNodeId(); - } while (seen.has(id)); - node.data.id = id; - } - seen.add(id); - } -} - -function markdownFromEditorAst(editor: Editor): string { - const ast = tiptapDocToAst(editor.getJSON() as any) as any; - const children: any[] = Array.isArray(ast?.children) ? ast.children : []; - ensureTopLevelIds(children); - return serializeAst({ type: "root", children } as any); -} - // Plain TipTap Editor factory (no React). Useful for unit/integration tests. export function createEditor(args: CreateEditorArgs): Editor { const { @@ -142,6 +115,7 @@ export function createEditor(args: CreateEditorArgs): Editor { persistDebounceMs, persistState = true, resolveImageSrc, + originKey = createMarkdownEditorOriginKey(), } = args; const ast = contentAst ?? (parseMarkdown(initialMarkdown ?? "") as any); @@ -159,13 +133,14 @@ export function createEditor(args: CreateEditorArgs): Editor { ); const persistDebounceMsResolved = persistDebounceMs ?? 0; const persistOnce = async (editor: Editor) => { - const markdown = normalizePersistedMarkdown(markdownFromEditorAst(editor)); + const markdown = buildNormalizedMarkdownFromEditor(editor); if (markdown === lastPersistedMarkdown) return; await upsertMarkdownFile({ lix, fileId: fileId!, markdown, createIfMissing: false, + originKey, }); lastPersistedMarkdown = markdown; }; @@ -226,9 +201,7 @@ export function createEditor(args: CreateEditorArgs): Editor { content: astToTiptapDoc(ast, { defaultBlock }) as any, onCreate: ({ editor }) => { currentEditor = editor as Editor; - lastPersistedMarkdown = normalizePersistedMarkdown( - markdownFromEditorAst(editor), - ); + lastPersistedMarkdown = buildNormalizedMarkdownFromEditor(editor); onCreate?.({ editor }); }, onUpdate: ({ editor }) => { diff --git a/src/extensions/markdown/editor/tip-tap-editor.test.tsx b/src/extensions/markdown/editor/tip-tap-editor.test.tsx index 97c75fd..21c5994 100644 --- a/src/extensions/markdown/editor/tip-tap-editor.test.tsx +++ b/src/extensions/markdown/editor/tip-tap-editor.test.tsx @@ -34,6 +34,96 @@ function Providers({ ); } +async function renderEditorForMarkdownFile({ + fileId, + markdown, + originKey = "flashtype.markdown-editor:test-origin", +}: { + fileId: string; + markdown: string; + originKey?: string; +}): Promise<{ lix: Lix; editor: Editor }> { + const lix = await openLix({ + keyValues: [ + { + key: "lix_deterministic_mode", + value: { enabled: true }, + lixcol_branch_id: "global", + lixcol_global: true, + }, + { + key: "flashtype_active_file_id", + value: fileId, + lixcol_branch_id: "global", + lixcol_global: true, + lixcol_untracked: true, + }, + ], + }); + await qb(lix) + .insertInto("lix_file") + .values({ + id: fileId, + path: `/${fileId}.md`, + data: new TextEncoder().encode(markdown), + }) + .execute(); + + let editorRef: Editor | null = null; + await act(async () => { + render( + + + (editorRef = editor)} + originKey={originKey} + persistDebounceMs={60_000} + /> + + , + ); + }); + await screen.findByTestId("tiptap-editor"); + await waitFor(() => expect(editorRef).not.toBeNull()); + return { lix, editor: editorRef! }; +} + +async function setEditorText(editor: Editor, text: string): Promise { + await act(async () => { + editor.commands.setContent({ + type: "doc", + content: [ + { + type: "paragraph", + content: text ? [{ type: "text", text }] : undefined, + }, + ], + }); + }); + await waitFor(() => + expect(screen.getByTestId("tiptap-editor")).toHaveTextContent(text), + ); +} + +async function writeMarkdownFileWithOrigin( + lix: Lix, + fileId: string, + markdown: string, + originKey?: string, +): Promise { + await lix.execute( + "UPDATE lix_file SET data = $1 WHERE id = $2", + [new TextEncoder().encode(markdown), fileId], + originKey ? { originKey } : undefined, + ); +} + +async function settleMarkdownObserver(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 75)); + }); +} + // Removed CaptureEditor and editor ref helpers; interact via DOM instead test("renders initial document content", async () => { @@ -517,6 +607,97 @@ test("updates editor when file.data is updated externally (simulate updateFile w }); }); +test("ignores same-origin stale markdown autosave echoes", async () => { + const originKey = "flashtype.markdown-editor:same-origin-stale"; + const fileId = "file_same_origin_stale"; + const { lix, editor } = await renderEditorForMarkdownFile({ + fileId, + markdown: "Initial\n", + originKey, + }); + + await setEditorText(editor, "Local newer"); + await writeMarkdownFileWithOrigin( + lix, + fileId, + "Stale saved copy\n", + originKey, + ); + await settleMarkdownObserver(); + + const editorNode = screen.getByTestId("tiptap-editor"); + expect(editorNode).toHaveTextContent("Local newer"); + expect(editorNode).not.toHaveTextContent("Stale saved copy"); +}); + +test("same-origin echo matching current markdown marks editor clean", async () => { + const originKey = "flashtype.markdown-editor:same-origin-clean"; + const fileId = "file_same_origin_clean"; + const { lix, editor } = await renderEditorForMarkdownFile({ + fileId, + markdown: "Initial\n", + originKey, + }); + + await setEditorText(editor, "Local current"); + await writeMarkdownFileWithOrigin(lix, fileId, "Local current\n", originKey); + await settleMarkdownObserver(); + await writeMarkdownFileWithOrigin( + lix, + fileId, + "External after clean\n", + "external-origin", + ); + + await waitFor(() => { + expect(screen.getByTestId("tiptap-editor")).toHaveTextContent( + "External after clean", + ); + }); +}); + +test("applies different-origin markdown update when editor is clean", async () => { + const fileId = "file_external_clean"; + const { lix } = await renderEditorForMarkdownFile({ + fileId, + markdown: "Initial\n", + }); + + await writeMarkdownFileWithOrigin( + lix, + fileId, + "External clean update\n", + "external-origin", + ); + + await waitFor(() => { + expect(screen.getByTestId("tiptap-editor")).toHaveTextContent( + "External clean update", + ); + }); +}); + +test("does not clobber dirty editor content with different-origin markdown update", async () => { + const fileId = "file_external_dirty"; + const { lix, editor } = await renderEditorForMarkdownFile({ + fileId, + markdown: "Initial\n", + }); + + await setEditorText(editor, "Unsaved local edit"); + await writeMarkdownFileWithOrigin( + lix, + fileId, + "External dirty update\n", + "external-origin", + ); + await settleMarkdownObserver(); + + const editorNode = screen.getByTestId("tiptap-editor"); + expect(editorNode).toHaveTextContent("Unsaved local edit"); + expect(editorNode).not.toHaveTextContent("External dirty update"); +}); + test("preserves main content when switching to a new branch and back", async () => { const lix = await openLix({ keyValues: [ diff --git a/src/extensions/markdown/editor/tip-tap-editor.tsx b/src/extensions/markdown/editor/tip-tap-editor.tsx index a5539a5..06ace7b 100644 --- a/src/extensions/markdown/editor/tip-tap-editor.tsx +++ b/src/extensions/markdown/editor/tip-tap-editor.tsx @@ -5,13 +5,16 @@ import { qb, sql } from "@/lib/lix-kysely"; import { useEditorCtx } from "./editor-context"; import { useLix, useQueryTakeFirst } from "@/lib/lix-react"; import { useKeyValue } from "@/hooks/key-value/use-key-value"; -import { createEditor } from "./create-editor"; +import { createEditor, createMarkdownEditorOriginKey } from "./create-editor"; import { assembleMdAst } from "./assemble-md-ast"; import { astToTiptapDoc } from "./tiptap-markdown-bridge"; import type { EmptyMarkdownDefaultBlock } from "./tiptap-markdown-bridge"; -import { parseMarkdown, serializeAst } from "./markdown"; -import { tiptapDocToAst } from "./tiptap-markdown-bridge"; +import { parseMarkdown } from "./markdown"; import { decodeMarkdownData } from "./decode-markdown-data"; +import { + buildNormalizedMarkdownFromEditor, + normalizePersistedMarkdown, +} from "./build-markdown-from-editor"; import { desktopWorkspaceApi, useDesktopWorkspaceDir, @@ -26,6 +29,7 @@ type TipTapEditorProps = { focusOnLoad?: boolean; defaultBlock?: EmptyMarkdownDefaultBlock; isActiveView?: boolean; + originKey?: string; }; /** @@ -51,6 +55,7 @@ export function TipTapEditor({ focusOnLoad, defaultBlock, isActiveView = true, + originKey, }: TipTapEditorProps) { if (fileId) { return ( @@ -63,6 +68,7 @@ export function TipTapEditor({ focusOnLoad={focusOnLoad} defaultBlock={defaultBlock} isActiveView={isActiveView} + originKey={originKey} /> ); } @@ -75,6 +81,7 @@ export function TipTapEditor({ focusOnLoad={focusOnLoad} defaultBlock={defaultBlock} isActiveView={isActiveView} + originKey={originKey} /> ); } @@ -162,6 +169,7 @@ function TipTapEditorLoadedContent({ focusOnLoad, defaultBlock, isActiveView = true, + originKey, hasInitialFile, initialMarkdown, sourceFilePath, @@ -175,8 +183,10 @@ function TipTapEditorLoadedContent({ const { setEditor } = useEditorCtx(); const workspaceDirState = useDesktopWorkspaceDir(); const PERSIST_DEBOUNCE_MS = persistDebounceMs ?? 500; - const normalizePersistedMarkdown = (markdown: string): string => - markdown.endsWith("\n") ? markdown : `${markdown}\n`; + const editorOriginKey = useMemo( + () => originKey ?? createMarkdownEditorOriginKey(), + [originKey], + ); const resolveImageSrc = useMemo(() => { const workspaceApi = desktopWorkspaceApi(); const workspacePath = workspaceDirState.workspaceDir; @@ -200,6 +210,7 @@ function TipTapEditorLoadedContent({ const lastInitialAstRef = useRef(null); const hasAutoFocusedRef = useRef(false); const mountedEditorRef = useRef(null); + const pendingExternalMarkdownRef = useRef(null); const editor = useMemo(() => { if ( @@ -221,6 +232,7 @@ function TipTapEditorLoadedContent({ defaultBlock, persistDebounceMs: PERSIST_DEBOUNCE_MS, resolveImageSrc, + originKey: editorOriginKey, }); }, [ lix, @@ -232,6 +244,7 @@ function TipTapEditorLoadedContent({ initialMarkdown, defaultBlock, resolveImageSrc, + editorOriginKey, workspaceDirState.loaded, ]); @@ -405,16 +418,21 @@ function TipTapEditorLoadedContent({ if (!activeFileId || !editor || !isActiveView) return; const events = lix.observe( ` - SELECT - data - FROM lix_file - WHERE id = ? - `, + SELECT + f.data, + f.lixcol_change_id, + c.origin_key + FROM lix_file AS f + LEFT JOIN lix_change AS c ON c.id = f.lixcol_change_id + WHERE f.id = ? + `, [activeFileId], ); let closed = false; let sawInitialSnapshot = false; const initialObservedMarkdown = normalizePersistedMarkdown(initialMarkdown); + let lastCleanPersistedMarkdown = buildNormalizedMarkdownFromEditor(editor); + pendingExternalMarkdownRef.current = null; void (async () => { while (!closed) { @@ -429,30 +447,32 @@ function TipTapEditorLoadedContent({ const nextMarkdown = normalizePersistedMarkdown( decodeMarkdownData(firstRow.get("data")), ); + const observedOriginKey = firstRow.get("origin_key"); + const currentMarkdown = buildNormalizedMarkdownFromEditor(editor); if (!sawInitialSnapshot) { sawInitialSnapshot = true; if (nextMarkdown === initialObservedMarkdown) { continue; } } - const currentMarkdownAst = tiptapDocToAst( - editor.getJSON() as any, - ) as any; - const currentMarkdown = normalizePersistedMarkdown( - serializeAst({ - type: "root", - children: Array.isArray(currentMarkdownAst?.children) - ? currentMarkdownAst.children - : [], - }), - ); if (currentMarkdown === nextMarkdown) { + lastCleanPersistedMarkdown = nextMarkdown; + pendingExternalMarkdownRef.current = null; + continue; + } + if (observedOriginKey === editorOriginKey) { + continue; + } + if (currentMarkdown !== lastCleanPersistedMarkdown) { + pendingExternalMarkdownRef.current = nextMarkdown; continue; } const ast = parseMarkdown(nextMarkdown) as any; editor.commands.setContent(astToTiptapDoc(ast, { defaultBlock }), { emitUpdate: false, }); + lastCleanPersistedMarkdown = nextMarkdown; + pendingExternalMarkdownRef.current = null; } })(); @@ -466,6 +486,7 @@ function TipTapEditorLoadedContent({ activeFileId, activeBranchId, isActiveView, + editorOriginKey, initialMarkdown, defaultBlock, ]); diff --git a/src/extensions/markdown/editor/upsert-markdown-file.ts b/src/extensions/markdown/editor/upsert-markdown-file.ts index ba441ed..ac13b42 100644 --- a/src/extensions/markdown/editor/upsert-markdown-file.ts +++ b/src/extensions/markdown/editor/upsert-markdown-file.ts @@ -14,6 +14,7 @@ export async function upsertMarkdownFile(args: { path?: string; metadata?: any; createIfMissing?: boolean; + originKey?: string; }): Promise { const { lix, @@ -22,6 +23,7 @@ export async function upsertMarkdownFile(args: { path, metadata, createIfMissing = true, + originKey, } = args; const data = new TextEncoder().encode(markdown); const db = qb(lix); @@ -46,24 +48,28 @@ export async function upsertMarkdownFile(args: { if (metadata !== undefined && metadata !== existing.lixcol_metadata) { updateValues.lixcol_metadata = resolvedMetadata; } - await db - .updateTable("lix_file") - .set(updateValues) - .where("id", "=", fileId) - .execute(); + await executeMarkdownFileWrite( + lix, + { + sql: `UPDATE lix_file SET ${Object.keys(updateValues) + .map((column) => `${column} = ?`) + .join(", ")} WHERE id = ?`, + params: [...Object.values(updateValues), fileId], + }, + originKey, + ); captureDocumentModifiedTelemetry({ lix, fileId, filePath: resolvedPath }); } else { if (!createIfMissing) return; // Insert requires a path; use provided or fallback to /.md - await db - .insertInto("lix_file") - .values({ - id: fileId, - path: path ?? `/${fileId}.md`, - data, - lixcol_metadata: metadata ?? null, - }) - .execute(); + await executeMarkdownFileWrite( + lix, + { + sql: "INSERT INTO lix_file (id, path, data, lixcol_metadata) VALUES (?, ?, ?, ?)", + params: [fileId, path ?? `/${fileId}.md`, data, metadata ?? null], + }, + originKey, + ); captureDocumentModifiedTelemetry({ lix, fileId, @@ -72,6 +78,18 @@ export async function upsertMarkdownFile(args: { } } +async function executeMarkdownFileWrite( + lix: Lix, + statement: { sql: string; params: ReadonlyArray }, + originKey: string | undefined, +): Promise { + if (originKey) { + await lix.execute(statement.sql, statement.params, { originKey }); + return; + } + await lix.execute(statement.sql, statement.params); +} + function captureDocumentModifiedTelemetry({ lix, fileId, diff --git a/src/lib/lix-client.ts b/src/lib/lix-client.ts index 9310d7f..e2640e4 100644 --- a/src/lib/lix-client.ts +++ b/src/lib/lix-client.ts @@ -8,6 +8,7 @@ import type { Lix, LixRow, LixRuntimeQueryResult, + LixExecuteOptions, ObserveEvent, ObserveEvents, SqlTransaction, @@ -57,10 +58,11 @@ export async function openDesktopLix(): Promise { const execute = async ( sql: string, params: ReadonlyArray = [], + options?: LixExecuteOptions, ): Promise => { ensureOpen("execute"); return toRuntimeQueryResult( - await runQueued(() => desktop.lix.execute({ sql, params })), + await runQueued(() => desktop.lix.execute({ sql, params, options })), ); }; @@ -81,6 +83,7 @@ export async function openDesktopLix(): Promise { execute: async ( sql: string, params: ReadonlyArray = [], + options?: LixExecuteOptions, ): Promise => { if (transactionClosed) { throw new Error("transaction is closed; execute() is unavailable"); @@ -91,6 +94,7 @@ export async function openDesktopLix(): Promise { transactionId, sql, params, + options, }), ); }, diff --git a/src/lib/lix-types.ts b/src/lib/lix-types.ts index 5eb4299..c207282 100644 --- a/src/lib/lix-types.ts +++ b/src/lib/lix-types.ts @@ -1,4 +1,5 @@ import type { + ExecuteOptions, ExecuteResult, Lix as SdkLix, LixTransaction as SdkLixTransaction, @@ -6,6 +7,7 @@ import type { } from "@lix-js/sdk"; export type { ExecuteResult as LixRuntimeQueryResult } from "@lix-js/sdk"; +export type { ExecuteOptions as LixExecuteOptions } from "@lix-js/sdk"; export type LixRow = ExecuteResult["rows"][number]; @@ -15,7 +17,11 @@ export type TransactionStatement = { }; export type SqlTransaction = Pick & { - execute(sql: string, params?: ReadonlyArray): Promise; + execute( + sql: string, + params?: ReadonlyArray, + options?: ExecuteOptions, + ): Promise; }; export type ObserveEvent = { @@ -55,7 +61,11 @@ type SdkLixBase = Pick< >; export interface FlashtypeLix extends SdkLixBase { - execute(sql: string, params?: ReadonlyArray): Promise; + execute( + sql: string, + params?: ReadonlyArray, + options?: ExecuteOptions, + ): Promise; beginTransaction(): Promise; transaction(callback: (tx: SqlTransaction) => Promise): Promise; executeTransaction( diff --git a/src/test-utils/node-lix-sdk.ts b/src/test-utils/node-lix-sdk.ts index 03a3644..87a76e9 100644 --- a/src/test-utils/node-lix-sdk.ts +++ b/src/test-utils/node-lix-sdk.ts @@ -2,6 +2,7 @@ import { createRequire } from "node:module"; import { resolve } from "node:path"; import type { BundledPluginArchive, + ExecuteOptions, ExecuteResult, Lix as SdkLix, OpenLixOptions as SdkOpenLixOptions, @@ -92,14 +93,22 @@ async function seedKeyValues( function createTestLixAdapter(sdkLix: SdkLix): Lix { return { - async execute(sql: string, params: ReadonlyArray = []) { - return await sdkLix.execute(sql, toSqlParams(params)); + async execute( + sql: string, + params: ReadonlyArray = [], + options?: ExecuteOptions, + ) { + return await sdkLix.execute(sql, toSqlParams(params), options); }, async beginTransaction() { const transaction = await sdkLix.beginTransaction(); return { - async execute(sql: string, params: ReadonlyArray = []) { - return await transaction.execute(sql, toSqlParams(params)); + async execute( + sql: string, + params: ReadonlyArray = [], + options?: ExecuteOptions, + ) { + return await transaction.execute(sql, toSqlParams(params), options); }, async commit() { await transaction.commit(); diff --git a/submodule/lix b/submodule/lix index 8166512..f693fcc 160000 --- a/submodule/lix +++ b/submodule/lix @@ -1 +1 @@ -Subproject commit 8166512b598acda861556ecbc46da3faf48dd680 +Subproject commit f693fcc032857d6caf68a23fbff95a4250215953