From 679b61da9ba979a93a143d9e9e1accba756a43ee Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 7 Oct 2026 16:55:38 -0500 Subject: [PATCH] fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419) --- src/backend/database/db/index.ts | 4 +- .../upgrade/snippets-schema-migration.test.ts | 52 +++++++++++++++++-- .../upgrade/snippets-schema-migration.ts | 45 +++++++++++++--- 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index c37592a18..681baf700 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1,4 +1,4 @@ -import { repairSnippetsNoteColumn } from "../../upgrade/snippets-schema-migration.js"; +import { repairSnippetsColumns } from "../../upgrade/snippets-schema-migration.js"; import { getErrorMessage } from "../../utils/error-message.js"; import { drizzle } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; @@ -928,7 +928,7 @@ const dropRetiredColumns = () => { }; const migrateSchema = () => { - repairSnippetsNoteColumn(sqlite); + repairSnippetsColumns(sqlite); addColumnIfNotExists("user_preferences", "theme", "TEXT"); addColumnIfNotExists("user_preferences", "font_size", "TEXT"); addColumnIfNotExists("user_preferences", "accent_color", "TEXT"); diff --git a/src/backend/tests/upgrade/snippets-schema-migration.test.ts b/src/backend/tests/upgrade/snippets-schema-migration.test.ts index cbdde082c..56232ec60 100644 --- a/src/backend/tests/upgrade/snippets-schema-migration.test.ts +++ b/src/backend/tests/upgrade/snippets-schema-migration.test.ts @@ -1,6 +1,6 @@ import Database from "better-sqlite3"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { repairSnippetsNoteColumn } from "../../upgrade/snippets-schema-migration.js"; +import { repairSnippetsColumns } from "../../upgrade/snippets-schema-migration.js"; let sqlite: Database.Database; beforeEach(() => { @@ -10,7 +10,7 @@ afterEach(() => { sqlite.close(); }); -describe("snippet note column upgrade", () => { +describe("snippet column upgrade", () => { it.each(["snippets", "p_snippets_snippets"])( "repairs %s without losing rows", (table) => { @@ -23,7 +23,7 @@ describe("snippet note column upgrade", () => { expect(() => sqlite.prepare(`SELECT "is_note" FROM "${table}"`)).toThrow( /is_note/, ); - repairSnippetsNoteColumn(sqlite); + repairSnippetsColumns(sqlite); if (table === "snippets") { // What the plugin migration does to the legacy table. sqlite.exec(`ALTER TABLE "snippets" RENAME TO "p_snippets_snippets"`); @@ -34,7 +34,7 @@ describe("snippet note column upgrade", () => { .get(), ).toEqual({ id: 7, content: "echo hello", is_note: 0 }); sqlite.exec("UPDATE p_snippets_snippets SET is_note = 1"); - repairSnippetsNoteColumn(sqlite); + repairSnippetsColumns(sqlite); expect( sqlite.prepare("SELECT is_note FROM p_snippets_snippets").get(), ).toEqual({ is_note: 1 }); @@ -42,11 +42,53 @@ describe("snippet note column upgrade", () => { ); it("does not create tables on a fresh install", () => { - repairSnippetsNoteColumn(sqlite); + repairSnippetsColumns(sqlite); expect( sqlite .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") .all(), ).toEqual([]); }); + + it("adds and fills sync ids on old snippet and folder tables", () => { + sqlite.exec( + `CREATE TABLE "p_snippets_snippets" (id INTEGER PRIMARY KEY, user_id TEXT, name TEXT, content TEXT)`, + ); + sqlite.exec( + `CREATE TABLE "snippet_folders" (id INTEGER PRIMARY KEY, user_id TEXT, name TEXT)`, + ); + sqlite.exec( + `INSERT INTO "p_snippets_snippets" VALUES (1, 'u', 'a', 'ls'), (2, 'u', 'b', 'pwd')`, + ); + sqlite.exec(`INSERT INTO "snippet_folders" VALUES (1, 'u', 'ops')`); + repairSnippetsColumns(sqlite); + + const snippets = sqlite + .prepare( + `SELECT "sync_id", "folder", "order", "host_filter" FROM "p_snippets_snippets"`, + ) + .all() as Array<{ sync_id: string; order: number }>; + expect(snippets).toHaveLength(2); + expect(snippets[0].sync_id).toMatch(/^[0-9a-f]{32}$/); + expect(snippets[0].sync_id).not.toBe(snippets[1].sync_id); + expect(snippets[0].order).toBe(0); + expect( + sqlite + .prepare(`SELECT "sync_id", "color", "icon" FROM "snippet_folders"`) + .get(), + ).toMatchObject({ color: null, icon: null }); + expect(() => + sqlite.exec(`UPDATE "p_snippets_snippets" SET "sync_id" = 'same'`), + ).toThrow(/UNIQUE/); + + const before = sqlite + .prepare(`SELECT "sync_id" FROM "p_snippets_snippets" ORDER BY id`) + .all(); + repairSnippetsColumns(sqlite); + expect( + sqlite + .prepare(`SELECT "sync_id" FROM "p_snippets_snippets" ORDER BY id`) + .all(), + ).toEqual(before); + }); }); diff --git a/src/backend/upgrade/snippets-schema-migration.ts b/src/backend/upgrade/snippets-schema-migration.ts index 0326c7d47..3a8b8088e 100644 --- a/src/backend/upgrade/snippets-schema-migration.ts +++ b/src/backend/upgrade/snippets-schema-migration.ts @@ -1,19 +1,48 @@ import type Database from "better-sqlite3"; +// Columns old SQLite snippet tables can be missing, for tables made before +// they were added, or adopted by the plugin before core added them. +const SNIPPET_COLUMNS: Array<[string, string]> = [ + ["folder", "TEXT"], + ["order", "INTEGER NOT NULL DEFAULT 0"], + ["host_filter", "TEXT"], + ["is_note", "INTEGER NOT NULL DEFAULT 0"], + ["sync_id", "TEXT"], +]; +const FOLDER_COLUMNS: Array<[string, string]> = [ + ["color", "TEXT"], + ["icon", "TEXT"], + ["sync_id", "TEXT"], +]; + +const TABLES: Array<[string, Array<[string, string]>]> = [ + ["snippets", SNIPPET_COLUMNS], + ["p_snippets_snippets", SNIPPET_COLUMNS], + ["snippet_folders", FOLDER_COLUMNS], + ["p_snippets_snippet_folders", FOLDER_COLUMNS], +]; + /** Repair older SQLite tables before adoption, or after an incomplete 2.9 upgrade. */ -export function repairSnippetsNoteColumn(sqlite: Database.Database): void { - for (const table of ["snippets", "p_snippets_snippets"]) { +export function repairSnippetsColumns(sqlite: Database.Database): void { + for (const [table, wanted] of TABLES) { const columns = sqlite.pragma(`table_info("${table}")`) as Array<{ name: string; }>; - if ( - columns.length === 0 || - columns.some(({ name }) => name === "is_note") - ) { - continue; + if (columns.length === 0) continue; + const have = new Set(columns.map(({ name }) => name)); + for (const [column, type] of wanted) { + if (!have.has(column)) { + sqlite.exec(`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`); + } } + // Sync matches rows by sync_id, so every row needs one. sqlite.exec( - `ALTER TABLE "${table}" ADD COLUMN "is_note" INTEGER NOT NULL DEFAULT 0`, + `UPDATE "${table}" SET "sync_id" = lower(hex(randomblob(16))) WHERE "sync_id" IS NULL`, ); + if (!have.has("sync_id")) { + sqlite.exec( + `CREATE UNIQUE INDEX IF NOT EXISTS "idx_${table}_sync_id" ON "${table}" ("sync_id")`, + ); + } } }