perf: reduce sync polling and index memo tags
This commit is contained in:
1 parent
91e04abbf9
commit
944980cf67
14 files changed
+322
-63
No files matched your search
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 <> '[]'");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
+34
-21
@@ -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<MobileSyncChangeRow>(),
|
||||
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<string, MobileSyncChangeRow>();
|
||||
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<MobileSyncChangeRow>();
|
||||
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<string, (typeof page)[number]>();
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<MemoTagUpdateRow>();
|
||||
|
||||
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<TagSummary[]> => {
|
||||
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<TagSummaryRow>();
|
||||
|
||||
@@ -1282,6 +1282,7 @@ export const WorkspaceApp = ({
|
||||
|
||||
useWorkspaceSyncLifecycle({
|
||||
pendingSyncCount: syncSummary.total,
|
||||
backgroundRefreshKey: localDataScope,
|
||||
refreshWorkspace: refreshWorkspaceFromServer,
|
||||
runQueuedSync,
|
||||
setOnline: setIsOnline,
|
||||
|
||||
@@ -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<unknown>;
|
||||
|
||||
export const useWorkspaceSyncLifecycle = ({
|
||||
pendingSyncCount,
|
||||
backgroundRefreshKey,
|
||||
refreshWorkspace,
|
||||
runQueuedSync,
|
||||
setOnline,
|
||||
syncIntervalMs,
|
||||
}: {
|
||||
pendingSyncCount: number;
|
||||
backgroundRefreshKey: string;
|
||||
refreshWorkspace: RefreshWorkspace;
|
||||
runQueuedSync: () => Promise<void>;
|
||||
setOnline: (online: boolean) => void;
|
||||
@@ -21,6 +29,12 @@ export const useWorkspaceSyncLifecycle = ({
|
||||
const deferredSyncTimerRef = useRef<number | null>(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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<RefreshLease>;
|
||||
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<Storage, "getItem" | "setItem">;
|
||||
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<Storage, "getItem" | "removeItem">;
|
||||
key: string;
|
||||
ownerId: string;
|
||||
}) => {
|
||||
if (parseRefreshLease(storage.getItem(key))?.ownerId === ownerId) storage.removeItem(key);
|
||||
};
|
||||
|
||||
export const createRefreshSingleFlight = <T>({
|
||||
refresh,
|
||||
now = Date.now,
|
||||
coalesceMs = BACKGROUND_WORKSPACE_REFRESH_COALESCE_MS,
|
||||
}: {
|
||||
refresh: () => Promise<T>;
|
||||
now?: () => number;
|
||||
coalesceMs?: number;
|
||||
}) => {
|
||||
let active: Promise<T> | 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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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;");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user