perf: reduce sync polling and index memo tags

This commit is contained in:
tianma-if committed 2026-08-20 14:38:51 +08:00
1 parent 91e04abbf9
commit 944980cf67
14 files changed
+322 -63

No files matched your search

+6 -5
View File
@@ -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
+3 -2
View File
@@ -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");
}
+2 -2
View File
@@ -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 <> '[]'");
+40
View File
@@ -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();
});
});
+22 -14
View File
@@ -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
View File
@@ -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,
});
});
+9 -9
View File
@@ -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>();
+1
View File
@@ -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);
+75
View File
@@ -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";
+24
View File
@@ -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;
+1
View File
@@ -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",
+5 -4
View File
@@ -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;");
}