diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 28582aa1..c76eee89 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1949,8 +1949,8 @@ const searchMemoSummaries = async ( } for (const tag of tags) { - filters.push("EXISTS (SELECT 1 FROM json_each(m.tags_json) WHERE json_each.value = ?)"); - binds.push(tag); + filters.push("EXISTS (SELECT 1 FROM memo_tags mt WHERE mt.memo_id = m.id AND mt.workspace_id = ? AND mt.name = ?)"); + binds.push(options.workspaceId, tag); } if (options.createdAfter) { @@ -3789,9 +3789,10 @@ const getWorkspaceStats = async (db: D1Database, workspaceId: string) => { db.prepare(`SELECT COUNT(*) AS count FROM notebooks WHERE workspace_id = ? AND is_deleted = 0`).bind(workspaceId).first<{ count: number }>(), db .prepare( - `SELECT COUNT(DISTINCT json_each.value) AS count - FROM memos m, json_each(m.tags_json) - WHERE m.workspace_id = ? AND m.is_deleted = 0 AND trim(json_each.value) <> ''` + `SELECT COUNT(DISTINCT mt.name) AS count + FROM memo_tags mt + INNER JOIN memos m ON m.id = mt.memo_id AND m.workspace_id = mt.workspace_id + WHERE mt.workspace_id = ? AND m.is_deleted = 0` ) .bind(workspaceId).first<{ count: number }>(), db diff --git a/apps/api/src/memo-list-service.test.mjs b/apps/api/src/memo-list-service.test.mjs index d768f856..6c092a91 100644 --- a/apps/api/src/memo-list-service.test.mjs +++ b/apps/api/src/memo-list-service.test.mjs @@ -92,8 +92,9 @@ describe("memo list service", () => { expect(database.calls).toHaveLength(2); for (const call of database.calls) { - expect(call.sql).toContain("json_each(m.tags_json)"); - expect(call.sql).toContain("LOWER(CAST(memo_tag.value AS TEXT)) = LOWER(?)"); + expect(call.sql).toContain("FROM memo_tags mt"); + expect(call.sql).toContain("mt.normalized_name = LOWER(?)"); + expect(call.parameters.filter((value) => value === "ws_1").length).toBeGreaterThanOrEqual(2); expect(call.parameters).toContain("Demo"); expect(call.sql).not.toContain("m.tags_json LIKE"); } diff --git a/apps/api/src/memo-list-service.ts b/apps/api/src/memo-list-service.ts index 0d9a7830..c790591b 100644 --- a/apps/api/src/memo-list-service.ts +++ b/apps/api/src/memo-list-service.ts @@ -190,9 +190,9 @@ export const listMemos = async ( if (tag) { baseConditions.push( - "EXISTS (SELECT 1 FROM json_each(m.tags_json) AS memo_tag WHERE LOWER(CAST(memo_tag.value AS TEXT)) = LOWER(?))", + "EXISTS (SELECT 1 FROM memo_tags mt WHERE mt.memo_id = m.id AND mt.workspace_id = ? AND mt.normalized_name = LOWER(?))", ); - baseBinds.push(tag); + baseBinds.push(input.workspaceId, tag); } if (filter === "tagged") baseConditions.push("m.tags_json <> '[]'"); diff --git a/apps/api/src/memo-tags-migration.test.mjs b/apps/api/src/memo-tags-migration.test.mjs new file mode 100644 index 00000000..6bfb5634 --- /dev/null +++ b/apps/api/src/memo-tags-migration.test.mjs @@ -0,0 +1,40 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { globSync, readFileSync } from "node:fs"; + +describe("normalized memo tag migration", () => { + test("backfills tags and keeps the indexed relation synchronized", () => { + const sqlite = new Database(":memory:"); + sqlite.exec("PRAGMA foreign_keys = ON"); + for (const path of globSync("migrations/*.sql").sort().filter((path) => path < "migrations/0035")) { + sqlite.exec(readFileSync(path, "utf8")); + } + + sqlite.query( + `UPDATE memos SET tags_json = '["Demo","数据库"]' WHERE id = 'memo_welcome'`, + ).run(); + sqlite.exec(readFileSync("migrations/0035_normalized_memo_tags.sql", "utf8")); + + expect(sqlite.query( + `SELECT name, normalized_name FROM memo_tags WHERE memo_id = 'memo_welcome' ORDER BY name`, + ).all()).toEqual([ + { name: "Demo", normalized_name: "demo" }, + { name: "数据库", normalized_name: "数据库" }, + ]); + + sqlite.query(`UPDATE memos SET tags_json = '["Updated"]' WHERE id = 'memo_welcome'`).run(); + expect(sqlite.query( + `SELECT name, normalized_name FROM memo_tags WHERE memo_id = 'memo_welcome'`, + ).all()).toEqual([{ name: "Updated", normalized_name: "updated" }]); + + expect(sqlite.query( + `EXPLAIN QUERY PLAN SELECT memo_id FROM memo_tags WHERE workspace_id = ? AND normalized_name = ?`, + ).all().some((row) => String(row.detail).includes("idx_memo_tags_workspace_name"))).toBe(true); + + sqlite.query(`UPDATE memos SET tags_json = '[]' WHERE id = 'memo_welcome'`).run(); + expect(sqlite.query( + `SELECT COUNT(*) AS count FROM memo_tags WHERE memo_id = 'memo_welcome'`, + ).get().count).toBe(0); + sqlite.close(); + }); +}); diff --git a/apps/api/src/sync-routes.test.mjs b/apps/api/src/sync-routes.test.mjs index feafd000..78e335f9 100644 --- a/apps/api/src/sync-routes.test.mjs +++ b/apps/api/src/sync-routes.test.mjs @@ -80,15 +80,22 @@ describe("sync route contracts", () => { }); }); - test("uses the workspace cursor index without joining the whole change log", async () => { + test("loads changes and workspace cursor state in one indexed query", async () => { const sqlStatements = []; const database = { prepare: (sql) => { sqlStatements.push(sql); return { bind: () => ({ - all: async () => ({ results: [] }), - first: async () => ({ cursor: 0, sync_identity: "workspace-created-at" }), + all: async () => ({ results: [{ + id: null, + entity_type: null, + entity_id: null, + operation: null, + server_cursor: 0, + sync_identity: "workspace-created-at", + }] }), + first: async () => null, }), }; }, @@ -100,9 +107,10 @@ describe("sync route contracts", () => { ); expect(response.status).toBe(200); - const cursorSql = sqlStatements.find((sql) => sql.includes("SELECT MAX(c.id)")); + expect(sqlStatements).toHaveLength(1); + const cursorSql = sqlStatements[0]; expect(cursorSql).toContain("WHERE c.workspace_id = w.id"); - expect(cursorSql).not.toContain("LEFT JOIN mobile_sync_changes"); + expect(cursorSql).toContain("WHERE workspace_id = ? AND id > ?"); }); test("folds repeated entities within a page while preserving the raw page cursor", async () => { @@ -110,19 +118,19 @@ describe("sync route contracts", () => { prepare: (sql) => ({ bind: () => ({ all: async () => { - if (sql.includes("FROM mobile_sync_changes")) { + if (sql.includes("WITH workspace_state")) { return { results: [ - { id: 11, entity_type: "memo", entity_id: "memo_1", operation: "upsert" }, - { id: 12, entity_type: "memo", entity_id: "memo_1", operation: "upsert" }, - { id: 13, entity_type: "notebook", entity_id: "notebook_1", operation: "delete" }, + { id: 11, entity_type: "memo", entity_id: "memo_1", operation: "upsert", server_cursor: 13, sync_identity: "workspace-created-at" }, + { id: 12, entity_type: "memo", entity_id: "memo_1", operation: "upsert", server_cursor: 13, sync_identity: "workspace-created-at" }, + { id: 13, entity_type: "notebook", entity_id: "notebook_1", operation: "delete", server_cursor: 13, sync_identity: "workspace-created-at" }, ], }; } if (sql.includes("FROM memos m")) return { results: [{ id: "memo_1", title: "Latest" }] }; return { results: [] }; }, - first: async () => ({ cursor: 13, sync_identity: "workspace-created-at" }), + first: async () => null, }), }), }; @@ -164,17 +172,17 @@ describe("sync route contracts", () => { prepare: (sql) => ({ bind: () => ({ all: async () => { - if (sql.includes("FROM mobile_sync_changes")) { + if (sql.includes("WITH workspace_state")) { return { results: [ - { id: 21, entity_type: "memo", entity_id: "memo_1", operation: "delete" }, - { id: 22, entity_type: "memo", entity_id: "memo_1", operation: "upsert" }, + { id: 21, entity_type: "memo", entity_id: "memo_1", operation: "delete", server_cursor: 22, sync_identity: "workspace-created-at" }, + { id: 22, entity_type: "memo", entity_id: "memo_1", operation: "upsert", server_cursor: 22, sync_identity: "workspace-created-at" }, ], }; } return { results: [] }; }, - first: async () => ({ cursor: 22, sync_identity: "workspace-created-at" }), + first: async () => null, }), }), }; diff --git a/apps/api/src/sync-routes.ts b/apps/api/src/sync-routes.ts index f3df65c5..ab17d509 100644 --- a/apps/api/src/sync-routes.ts +++ b/apps/api/src/sync-routes.ts @@ -5,10 +5,12 @@ import { mapNotebook, type NotebookRow } from "./notebook-service"; import { getWorkspaceId, requireScopes } from "./request-auth"; type MobileSyncChangeRow = { - id: number; - entity_type: "notebook" | "memo"; - entity_id: string; - operation: "upsert" | "delete"; + id: number | null; + entity_type: "notebook" | "memo" | null; + entity_id: string | null; + operation: "upsert" | "delete" | null; + server_cursor: number; + sync_identity: string; }; export type SyncMemoDetailRow = { @@ -106,27 +108,38 @@ export const registerSyncRoutes = ( Number.MAX_SAFE_INTEGER, ); const limit = dependencies.clampNumber(Number(context.req.query("limit") ?? 100), 1, 200); - const [rows, cursorRow] = await Promise.all([ - context.env.storage.db.prepare( - `SELECT id, entity_type, entity_id, operation - FROM mobile_sync_changes - WHERE workspace_id = ? AND id > ? - ORDER BY id ASC - LIMIT ?`, - ).bind(workspaceId, cursor, limit + 1).all(), - context.env.storage.db.prepare( - `SELECT w.created_at AS sync_identity, + const rows = await context.env.storage.db.prepare( + `WITH workspace_state AS ( + SELECT w.created_at AS sync_identity, COALESCE(( SELECT MAX(c.id) FROM mobile_sync_changes c WHERE c.workspace_id = w.id - ), 0) AS cursor + ), 0) AS server_cursor FROM workspaces w - WHERE w.id = ?`, - ).bind(workspaceId).first<{ cursor: number; sync_identity: string }>(), - ]); - const page = rows.results.slice(0, limit); - const latestPageChanges = new Map(); + WHERE w.id = ? + ), page_changes AS ( + SELECT id, entity_type, entity_id, operation + FROM mobile_sync_changes + WHERE workspace_id = ? AND id > ? + ORDER BY id ASC + LIMIT ? + ) + SELECT c.id, c.entity_type, c.entity_id, c.operation, + s.server_cursor, s.sync_identity + FROM workspace_state s + LEFT JOIN page_changes c ON 1 = 1 + ORDER BY c.id ASC`, + ).bind(workspaceId, workspaceId, cursor, limit + 1).all(); + const cursorRow = rows.results[0]; + const rawChanges = rows.results.filter((row): row is MobileSyncChangeRow & { + id: number; + entity_type: "notebook" | "memo"; + entity_id: string; + operation: "upsert" | "delete"; + } => row.id !== null && row.entity_type !== null && row.entity_id !== null && row.operation !== null); + const page = rawChanges.slice(0, limit); + const latestPageChanges = new Map(); for (const change of page) { latestPageChanges.set(`${change.entity_type}:${change.entity_id}`, change); } @@ -193,7 +206,7 @@ export const registerSyncRoutes = ( changes, cursor: page.at(-1)?.id ?? cursor, hasMore: rows.results.length > limit, - serverCursor: cursorRow?.cursor ?? 0, + serverCursor: cursorRow?.server_cursor ?? 0, syncIdentity: cursorRow?.sync_identity, }); }); diff --git a/apps/api/src/tag-service.ts b/apps/api/src/tag-service.ts index 69cc825f..24624bb0 100644 --- a/apps/api/src/tag-service.ts +++ b/apps/api/src/tag-service.ts @@ -34,11 +34,11 @@ const getMemoRowsByTag = async (db: DatabaseAdapter, workspaceId: string, tag: s WHERE m.workspace_id = ? AND m.is_deleted = 0 AND EXISTS ( SELECT 1 - FROM json_each(m.tags_json) - WHERE json_each.value = ? + FROM memo_tags mt + WHERE mt.memo_id = m.id AND mt.workspace_id = ? AND mt.name = ? )` ) - .bind(workspaceId, tag) + .bind(workspaceId, workspaceId, tag) .all(); return rows.results; @@ -55,14 +55,14 @@ const replaceTag = (currentTags: string[], oldTag: string, nextTag: string | nul export const listTagSummaries = async (db: DatabaseAdapter, workspaceId: string): Promise => { const rows = await db .prepare( - `SELECT json_each.value AS name, + `SELECT mt.name AS name, COUNT(DISTINCT m.id) AS memo_count, MAX(m.updated_at) AS updated_at - FROM memos m, json_each(m.tags_json) - WHERE m.workspace_id = ? AND m.is_deleted = 0 - AND trim(json_each.value) <> '' - GROUP BY json_each.value - ORDER BY lower(json_each.value) ASC` + FROM memo_tags mt + INNER JOIN memos m ON m.id = mt.memo_id AND m.workspace_id = mt.workspace_id + WHERE mt.workspace_id = ? AND m.is_deleted = 0 + GROUP BY mt.name + ORDER BY mt.normalized_name ASC, mt.name ASC` ) .bind(workspaceId) .all(); diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 56aa997a..ed8495c1 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -1282,6 +1282,7 @@ export const WorkspaceApp = ({ useWorkspaceSyncLifecycle({ pendingSyncCount: syncSummary.total, + backgroundRefreshKey: localDataScope, refreshWorkspace: refreshWorkspaceFromServer, runQueuedSync, setOnline: setIsOnline, diff --git a/apps/web/src/hooks/useWorkspaceSyncLifecycle.ts b/apps/web/src/hooks/useWorkspaceSyncLifecycle.ts index 138e8afc..c8cfe3b0 100644 --- a/apps/web/src/hooks/useWorkspaceSyncLifecycle.ts +++ b/apps/web/src/hooks/useWorkspaceSyncLifecycle.ts @@ -1,18 +1,26 @@ import { useEffect, useRef } from "react"; import { isBrowserOffline, verifyBrowserConnectivity } from "@/lib/network-status"; import { SYNC_QUEUE_DEFERRED_EVENT } from "@/lib/sync-events"; -import { BACKGROUND_WORKSPACE_REFRESH_INTERVAL_MS, type WorkspaceRefreshMode } from "@/lib/workspace-refresh"; +import { + BACKGROUND_WORKSPACE_REFRESH_INTERVAL_MS, + claimBackgroundRefreshLease, + createRefreshSingleFlight, + releaseBackgroundRefreshLease, + type WorkspaceRefreshMode, +} from "@/lib/workspace-refresh"; type RefreshWorkspace = (mode: WorkspaceRefreshMode) => Promise; export const useWorkspaceSyncLifecycle = ({ pendingSyncCount, + backgroundRefreshKey, refreshWorkspace, runQueuedSync, setOnline, syncIntervalMs, }: { pendingSyncCount: number; + backgroundRefreshKey: string; refreshWorkspace: RefreshWorkspace; runQueuedSync: () => Promise; setOnline: (online: boolean) => void; @@ -21,6 +29,12 @@ export const useWorkspaceSyncLifecycle = ({ const deferredSyncTimerRef = useRef(null); const deferredSyncPendingRef = useRef(false); const runQueuedSyncRef = useRef(runQueuedSync); + const refreshWorkspaceRef = useRef(refreshWorkspace); + const backgroundRefreshOwnerRef = useRef(crypto.randomUUID()); + + useEffect(() => { + refreshWorkspaceRef.current = refreshWorkspace; + }, [refreshWorkspace]); useEffect(() => { runQueuedSyncRef.current = runQueuedSync; @@ -131,24 +145,59 @@ export const useWorkspaceSyncLifecycle = ({ }, [syncIntervalMs]); useEffect(() => { + const leaseKey = `edgeever.background-refresh:${backgroundRefreshKey}`; + const ownerId = backgroundRefreshOwnerRef.current; + const runRefresh = createRefreshSingleFlight({ + refresh: () => refreshWorkspaceRef.current("background"), + }); const refreshVisibleWorkspace = () => { if (document.visibilityState === "hidden" || isBrowserOffline()) return; - void refreshWorkspace("background").catch(() => { + // Focus and visibility events remain immediate. Periodic refreshes use + // a short cross-tab lease so multiple visible EdgeEver tabs do not all + // poll D1 every thirty seconds. + void runRefresh().catch(() => { // A later focus, visibility, or interval refresh will retry. }); }; - const intervalId = window.setInterval(refreshVisibleWorkspace, BACKGROUND_WORKSPACE_REFRESH_INTERVAL_MS); + const refreshLeaseOwner = () => { + if (document.visibilityState === "hidden" || isBrowserOffline()) return; + try { + if (!claimBackgroundRefreshLease({ storage: window.localStorage, key: leaseKey, ownerId })) return; + } catch { + // Restricted storage environments still get normal single-tab sync. + } + refreshVisibleWorkspace(); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + try { + releaseBackgroundRefreshLease({ storage: window.localStorage, key: leaseKey, ownerId }); + } catch { + // Ignore unavailable local storage. + } + return; + } + refreshVisibleWorkspace(); + }; + + const intervalId = window.setInterval(refreshLeaseOwner, BACKGROUND_WORKSPACE_REFRESH_INTERVAL_MS); window.addEventListener("focus", refreshVisibleWorkspace); window.addEventListener("pageshow", refreshVisibleWorkspace); - document.addEventListener("visibilitychange", refreshVisibleWorkspace); + document.addEventListener("visibilitychange", handleVisibilityChange); return () => { window.clearInterval(intervalId); window.removeEventListener("focus", refreshVisibleWorkspace); window.removeEventListener("pageshow", refreshVisibleWorkspace); - document.removeEventListener("visibilitychange", refreshVisibleWorkspace); + document.removeEventListener("visibilitychange", handleVisibilityChange); + try { + releaseBackgroundRefreshLease({ storage: window.localStorage, key: leaseKey, ownerId }); + } catch { + // Ignore unavailable local storage. + } }; - }, [refreshWorkspace]); + }, [backgroundRefreshKey]); useEffect(() => { if (pendingSyncCount === 0) return; diff --git a/apps/web/src/lib/workspace-refresh.test.mjs b/apps/web/src/lib/workspace-refresh.test.mjs index b653bf54..d7412faa 100644 --- a/apps/web/src/lib/workspace-refresh.test.mjs +++ b/apps/web/src/lib/workspace-refresh.test.mjs @@ -2,6 +2,9 @@ import { strict as assert } from "node:assert"; import { describe, it } from "node:test"; import { BACKGROUND_WORKSPACE_REFRESH_INTERVAL_MS, + claimBackgroundRefreshLease, + createRefreshSingleFlight, + releaseBackgroundRefreshLease, refreshWorkspaceData, resolveCreatedMemoSelection, resolveSyncedMemoId, @@ -13,6 +16,48 @@ describe("refreshWorkspaceData", () => { assert.equal(BACKGROUND_WORKSPACE_REFRESH_INTERVAL_MS, 30_000); }); + it("allows only one tab to own a live background refresh lease", () => { + const values = new Map(); + const storage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + }; + + assert.equal(claimBackgroundRefreshLease({ storage, key: "sync", ownerId: "tab-a", now: 1_000, leaseMs: 500 }), true); + assert.equal(claimBackgroundRefreshLease({ storage, key: "sync", ownerId: "tab-b", now: 1_100, leaseMs: 500 }), false); + releaseBackgroundRefreshLease({ storage, key: "sync", ownerId: "tab-b" }); + assert.equal(claimBackgroundRefreshLease({ storage, key: "sync", ownerId: "tab-b", now: 1_501, leaseMs: 500 }), true); + }); + + it("coalesces concurrent and immediately repeated background refreshes", async () => { + let now = 1_000; + let calls = 0; + let finish; + const run = createRefreshSingleFlight({ + now: () => now, + coalesceMs: 100, + refresh: () => new Promise((resolve) => { + calls += 1; + finish = resolve; + }), + }); + + const first = run(); + const concurrent = run(); + assert.equal(first, concurrent); + assert.equal(calls, 1); + finish("done"); + assert.equal(await first, "done"); + assert.equal(await run(), null); + now += 101; + finish = undefined; + const next = run(); + assert.equal(calls, 2); + finish("again"); + assert.equal(await next, "again"); + }); + it("keeps the trash route when opening a deleted memo", () => { assert.equal(shouldNavigateHomeWhenOpeningMemo("trash"), false); assert.equal(shouldNavigateHomeWhenOpeningMemo("notebook"), true); diff --git a/apps/web/src/lib/workspace-refresh.ts b/apps/web/src/lib/workspace-refresh.ts index 992f7139..405292b7 100644 --- a/apps/web/src/lib/workspace-refresh.ts +++ b/apps/web/src/lib/workspace-refresh.ts @@ -2,6 +2,81 @@ export type WorkspaceRefreshMode = "background" | "manual"; export type WorkspaceMemoView = "notebook" | "trash"; export const BACKGROUND_WORKSPACE_REFRESH_INTERVAL_MS = 30_000; +export const BACKGROUND_WORKSPACE_REFRESH_LEASE_MS = BACKGROUND_WORKSPACE_REFRESH_INTERVAL_MS * 2; +export const BACKGROUND_WORKSPACE_REFRESH_COALESCE_MS = 1_000; + +type RefreshLease = { + ownerId: string; + expiresAt: number; +}; + +const parseRefreshLease = (value: string | null): RefreshLease | null => { + if (!value) return null; + try { + const lease = JSON.parse(value) as Partial; + return typeof lease.ownerId === "string" && Number.isFinite(lease.expiresAt) + ? { ownerId: lease.ownerId, expiresAt: Number(lease.expiresAt) } + : null; + } catch { + return null; + } +}; + +export const claimBackgroundRefreshLease = ({ + storage, + key, + ownerId, + now = Date.now(), + leaseMs = BACKGROUND_WORKSPACE_REFRESH_LEASE_MS, +}: { + storage: Pick; + key: string; + ownerId: string; + now?: number; + leaseMs?: number; +}) => { + const current = parseRefreshLease(storage.getItem(key)); + if (current && current.ownerId !== ownerId && current.expiresAt > now) return false; + + storage.setItem(key, JSON.stringify({ ownerId, expiresAt: now + leaseMs })); + return parseRefreshLease(storage.getItem(key))?.ownerId === ownerId; +}; + +export const releaseBackgroundRefreshLease = ({ + storage, + key, + ownerId, +}: { + storage: Pick; + key: string; + ownerId: string; +}) => { + if (parseRefreshLease(storage.getItem(key))?.ownerId === ownerId) storage.removeItem(key); +}; + +export const createRefreshSingleFlight = ({ + refresh, + now = Date.now, + coalesceMs = BACKGROUND_WORKSPACE_REFRESH_COALESCE_MS, +}: { + refresh: () => Promise; + now?: () => number; + coalesceMs?: number; +}) => { + let active: Promise | null = null; + let lastStartedAt = Number.NEGATIVE_INFINITY; + + return () => { + if (active) return active; + const startedAt = now(); + if (startedAt - lastStartedAt < coalesceMs) return Promise.resolve(null); + lastStartedAt = startedAt; + active = refresh().finally(() => { + active = null; + }); + return active; + }; +}; export const shouldNavigateHomeWhenOpeningMemo = (view: WorkspaceMemoView) => view !== "trash"; diff --git a/migrations/0035_normalized_memo_tags.sql b/migrations/0035_normalized_memo_tags.sql new file mode 100644 index 00000000..4ea962fc --- /dev/null +++ b/migrations/0035_normalized_memo_tags.sql @@ -0,0 +1,24 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE memo_tags ( + memo_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + PRIMARY KEY (memo_id, name), + FOREIGN KEY (memo_id) REFERENCES memos(id) + ON UPDATE CASCADE + ON DELETE CASCADE +); + +CREATE INDEX idx_memo_tags_workspace_name + ON memo_tags(workspace_id, normalized_name, memo_id); + +INSERT INTO memo_tags (memo_id, workspace_id, name, normalized_name) +SELECT m.id, m.workspace_id, trim(CAST(tag.value AS TEXT)), lower(trim(CAST(tag.value AS TEXT))) +FROM memos m, json_each(m.tags_json) AS tag +WHERE trim(CAST(tag.value AS TEXT)) <> ''; + +-- Keep triggers on one physical line for remote D1 compatibility. +CREATE TRIGGER trg_memo_tags_insert AFTER INSERT ON memos WHEN json_array_length(NEW.tags_json) > 0 BEGIN INSERT OR IGNORE INTO memo_tags (memo_id, workspace_id, name, normalized_name) SELECT NEW.id, NEW.workspace_id, trim(CAST(value AS TEXT)), lower(trim(CAST(value AS TEXT))) FROM json_each(NEW.tags_json) WHERE trim(CAST(value AS TEXT)) <> ''; END; +CREATE TRIGGER trg_memo_tags_update AFTER UPDATE OF tags_json, workspace_id ON memos WHEN OLD.tags_json <> NEW.tags_json OR OLD.workspace_id <> NEW.workspace_id BEGIN DELETE FROM memo_tags WHERE memo_id = NEW.id; INSERT OR IGNORE INTO memo_tags (memo_id, workspace_id, name, normalized_name) SELECT NEW.id, NEW.workspace_id, trim(CAST(value AS TEXT)), lower(trim(CAST(value AS TEXT))) FROM json_each(NEW.tags_json) WHERE trim(CAST(value AS TEXT)) <> ''; END; diff --git a/scripts/verify-deployment.mjs b/scripts/verify-deployment.mjs index dbb31e90..0e263ccc 100644 --- a/scripts/verify-deployment.mjs +++ b/scripts/verify-deployment.mjs @@ -15,6 +15,7 @@ export const REQUIRED_TABLES = [ "memo_import_sources", "mobile_sync_changes", "memo_search_documents", + "memo_tags", "maintenance_leases", "ai_provider_configs", "ai_models", diff --git a/tests/wrangler-runner.test.ts b/tests/wrangler-runner.test.ts index c3c236fd..871d08fe 100644 --- a/tests/wrangler-runner.test.ts +++ b/tests/wrangler-runner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { globSync, readFileSync } from "node:fs"; import { resolve, sep } from "node:path"; import { buildLocalDevEnvironmentFile, @@ -126,14 +126,15 @@ describe("cross-platform Wrangler runner", () => { }); test("keeps every D1 trigger on one physical line for remote compatibility", () => { - const migrationSql = ["0001_initial.sql", "0013_mobile_sync_changes.sql"] - .map((file) => readFileSync(resolve("migrations", file), "utf8")) + const migrationSql = globSync(resolve("migrations", "*.sql")) + .sort() + .map((file) => readFileSync(file, "utf8")) .join("\n"); const triggerLines = migrationSql .split("\n") .filter((line) => line.startsWith("CREATE TRIGGER ")); - expect(triggerLines).toHaveLength(7); + expect(triggerLines).toHaveLength(12); for (const triggerLine of triggerLines) { expect(triggerLine).toEndWith(" END;"); }