Skip to content

Commit 72d8d5b

Browse files
authored
Fix missing SQL parameter bindings in D1NextModeTagCache.getLastRevalidated() (#804)
1 parent 54e65c5 commit 72d8d5b

File tree

3 files changed

+349
-0
lines changed

3 files changed

+349
-0
lines changed

.changeset/empty-garlics-lead.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
Fix missing SQL parameter bindings in D1NextModeTagCache.getLastRevalidated()
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
/**
2+
* Author: Copilot (Claude Sonnet 4)
3+
*/
4+
import { error } from "@opennextjs/aws/adapters/logger.js";
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
import { getCloudflareContext } from "../../cloudflare-context.js";
8+
import { debugCache, FALLBACK_BUILD_ID, purgeCacheByTags } from "../internal.js";
9+
import { BINDING_NAME, D1NextModeTagCache, NAME } from "./d1-next-tag-cache.js";
10+
11+
// Mock dependencies
12+
vi.mock("@opennextjs/aws/adapters/logger.js", () => ({
13+
error: vi.fn(),
14+
}));
15+
16+
vi.mock("../../cloudflare-context.js", () => ({
17+
getCloudflareContext: vi.fn(),
18+
}));
19+
20+
vi.mock("../internal.js", () => ({
21+
debugCache: vi.fn(),
22+
FALLBACK_BUILD_ID: "fallback-build-id",
23+
purgeCacheByTags: vi.fn(),
24+
}));
25+
26+
describe("D1NextModeTagCache", () => {
27+
let tagCache: D1NextModeTagCache;
28+
let mockDb: {
29+
prepare: ReturnType<typeof vi.fn>;
30+
batch: ReturnType<typeof vi.fn>;
31+
};
32+
let mockPrepare: ReturnType<typeof vi.fn>;
33+
let mockBind: ReturnType<typeof vi.fn>;
34+
let mockRun: ReturnType<typeof vi.fn>;
35+
let mockRaw: ReturnType<typeof vi.fn>;
36+
let mockBatch: ReturnType<typeof vi.fn>;
37+
38+
beforeEach(() => {
39+
vi.clearAllMocks();
40+
41+
// Setup mock database
42+
mockRun = vi.fn();
43+
mockRaw = vi.fn();
44+
mockBind = vi.fn().mockReturnThis();
45+
mockPrepare = vi.fn().mockReturnValue({
46+
bind: mockBind,
47+
run: mockRun,
48+
raw: mockRaw,
49+
});
50+
mockBatch = vi.fn();
51+
52+
mockDb = {
53+
prepare: mockPrepare,
54+
batch: mockBatch,
55+
};
56+
57+
// Setup cloudflare context mock
58+
vi.mocked(getCloudflareContext).mockReturnValue({
59+
env: {
60+
[BINDING_NAME]: mockDb,
61+
},
62+
} as ReturnType<typeof getCloudflareContext>);
63+
64+
// Reset global config
65+
(globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } }).openNextConfig = {
66+
dangerous: {
67+
disableTagCache: false,
68+
},
69+
};
70+
71+
// Reset environment variables
72+
vi.unstubAllEnvs();
73+
74+
tagCache = new D1NextModeTagCache();
75+
});
76+
77+
describe("constructor and properties", () => {
78+
it("should have correct mode and name", () => {
79+
expect(tagCache.mode).toBe("nextMode");
80+
expect(tagCache.name).toBe(NAME);
81+
});
82+
});
83+
84+
describe("getLastRevalidated", () => {
85+
it("should return 0 when cache is disabled", async () => {
86+
(
87+
globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } }
88+
).openNextConfig!.dangerous!.disableTagCache = true;
89+
90+
const result = await tagCache.getLastRevalidated(["tag1", "tag2"]);
91+
92+
expect(result).toBe(0);
93+
expect(mockPrepare).not.toHaveBeenCalled();
94+
});
95+
96+
it("should return 0 when no database is available", async () => {
97+
vi.mocked(getCloudflareContext).mockReturnValue({
98+
env: {},
99+
} as ReturnType<typeof getCloudflareContext>);
100+
101+
const result = await tagCache.getLastRevalidated(["tag1", "tag2"]);
102+
103+
expect(result).toBe(0);
104+
expect(debugCache).toHaveBeenCalledWith("No D1 database found");
105+
});
106+
107+
it("should return the maximum revalidation time for given tags", async () => {
108+
const mockTime = 1234567890;
109+
mockRun.mockResolvedValue({
110+
results: [{ time: mockTime }],
111+
});
112+
113+
const tags = ["tag1", "tag2"];
114+
const result = await tagCache.getLastRevalidated(tags);
115+
116+
expect(result).toBe(mockTime);
117+
expect(mockPrepare).toHaveBeenCalledWith(
118+
"SELECT MAX(revalidatedAt) AS time FROM revalidations WHERE tag IN (?, ?)"
119+
);
120+
expect(mockBind).toHaveBeenCalledWith(`${FALLBACK_BUILD_ID}/tag1`, `${FALLBACK_BUILD_ID}/tag2`);
121+
});
122+
123+
it("should return 0 when no results are found", async () => {
124+
mockRun.mockResolvedValue({
125+
results: [],
126+
});
127+
128+
const result = await tagCache.getLastRevalidated(["tag1"]);
129+
130+
expect(result).toBe(0);
131+
});
132+
133+
it("should return 0 when database query throws an error", async () => {
134+
const mockError = new Error("Database error");
135+
mockRun.mockRejectedValue(mockError);
136+
137+
const result = await tagCache.getLastRevalidated(["tag1"]);
138+
139+
expect(result).toBe(0);
140+
expect(error).toHaveBeenCalledWith(mockError);
141+
});
142+
143+
it("should use custom build ID when NEXT_BUILD_ID is set", async () => {
144+
const customBuildId = "custom-build-id";
145+
vi.stubEnv("NEXT_BUILD_ID", customBuildId);
146+
147+
mockRun.mockResolvedValue({
148+
results: [{ time: 123 }],
149+
});
150+
151+
await tagCache.getLastRevalidated(["tag1"]);
152+
153+
expect(mockBind).toHaveBeenCalledWith(`${customBuildId}/tag1`);
154+
});
155+
});
156+
157+
describe("hasBeenRevalidated", () => {
158+
it("should return false when cache is disabled", async () => {
159+
(
160+
globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } }
161+
).openNextConfig!.dangerous!.disableTagCache = true;
162+
163+
const result = await tagCache.hasBeenRevalidated(["tag1"], 1000);
164+
165+
expect(result).toBe(false);
166+
expect(mockPrepare).not.toHaveBeenCalled();
167+
});
168+
169+
it("should return false when no database is available", async () => {
170+
vi.mocked(getCloudflareContext).mockReturnValue({
171+
env: {},
172+
} as ReturnType<typeof getCloudflareContext>);
173+
174+
const result = await tagCache.hasBeenRevalidated(["tag1"], 1000);
175+
176+
expect(result).toBe(false);
177+
});
178+
179+
it("should return true when tags have been revalidated after lastModified", async () => {
180+
mockRaw.mockResolvedValue([{ "1": 1 }]); // Non-empty result
181+
182+
const tags = ["tag1", "tag2"];
183+
const lastModified = 1000;
184+
const result = await tagCache.hasBeenRevalidated(tags, lastModified);
185+
186+
expect(result).toBe(true);
187+
expect(mockPrepare).toHaveBeenCalledWith(
188+
"SELECT 1 FROM revalidations WHERE tag IN (?, ?) AND revalidatedAt > ? LIMIT 1"
189+
);
190+
expect(mockBind).toHaveBeenCalledWith(
191+
`${FALLBACK_BUILD_ID}/tag1`,
192+
`${FALLBACK_BUILD_ID}/tag2`,
193+
lastModified
194+
);
195+
});
196+
197+
it("should return false when no tags have been revalidated", async () => {
198+
mockRaw.mockResolvedValue([]); // Empty result
199+
200+
const result = await tagCache.hasBeenRevalidated(["tag1"], 1000);
201+
202+
expect(result).toBe(false);
203+
});
204+
205+
it("should use current time as default when lastModified is not provided", async () => {
206+
const currentTime = Date.now();
207+
vi.spyOn(Date, "now").mockReturnValue(currentTime);
208+
mockRaw.mockResolvedValue([]);
209+
210+
await tagCache.hasBeenRevalidated(["tag1"]);
211+
212+
expect(mockBind).toHaveBeenCalledWith(`${FALLBACK_BUILD_ID}/tag1`, currentTime);
213+
});
214+
215+
it("should return false when database query throws an error", async () => {
216+
const mockError = new Error("Database error");
217+
mockRaw.mockRejectedValue(mockError);
218+
219+
const result = await tagCache.hasBeenRevalidated(["tag1"], 1000);
220+
221+
expect(result).toBe(false);
222+
expect(error).toHaveBeenCalledWith(mockError);
223+
});
224+
});
225+
226+
describe("writeTags", () => {
227+
it("should do nothing when cache is disabled", async () => {
228+
(
229+
globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } }
230+
).openNextConfig!.dangerous!.disableTagCache = true;
231+
232+
await tagCache.writeTags(["tag1", "tag2"]);
233+
234+
expect(mockBatch).not.toHaveBeenCalled();
235+
expect(purgeCacheByTags).not.toHaveBeenCalled();
236+
});
237+
238+
it("should do nothing when no database is available", async () => {
239+
vi.mocked(getCloudflareContext).mockReturnValue({
240+
env: {},
241+
} as ReturnType<typeof getCloudflareContext>);
242+
243+
await tagCache.writeTags(["tag1", "tag2"]);
244+
245+
expect(mockBatch).not.toHaveBeenCalled();
246+
expect(purgeCacheByTags).not.toHaveBeenCalled();
247+
});
248+
249+
it("should do nothing when tags array is empty", async () => {
250+
await tagCache.writeTags([]);
251+
252+
expect(mockBatch).not.toHaveBeenCalled();
253+
expect(purgeCacheByTags).not.toHaveBeenCalled();
254+
});
255+
256+
it("should write tags to database and purge cache", async () => {
257+
const currentTime = Date.now();
258+
vi.spyOn(Date, "now").mockReturnValue(currentTime);
259+
260+
const tags = ["tag1", "tag2"];
261+
await tagCache.writeTags(tags);
262+
263+
expect(mockBatch).toHaveBeenCalledWith([
264+
expect.objectContaining({
265+
bind: expect.any(Function),
266+
}),
267+
expect.objectContaining({
268+
bind: expect.any(Function),
269+
}),
270+
]);
271+
272+
// Verify the prepared statements were created correctly
273+
expect(mockPrepare).toHaveBeenCalledTimes(2);
274+
expect(mockPrepare).toHaveBeenCalledWith(
275+
"INSERT INTO revalidations (tag, revalidatedAt) VALUES (?, ?)"
276+
);
277+
278+
expect(purgeCacheByTags).toHaveBeenCalledWith(tags);
279+
});
280+
281+
it("should handle single tag", async () => {
282+
const currentTime = Date.now();
283+
vi.spyOn(Date, "now").mockReturnValue(currentTime);
284+
285+
await tagCache.writeTags(["single-tag"]);
286+
287+
expect(mockBatch).toHaveBeenCalledWith([
288+
expect.objectContaining({
289+
bind: expect.any(Function),
290+
}),
291+
]);
292+
293+
expect(purgeCacheByTags).toHaveBeenCalledWith(["single-tag"]);
294+
});
295+
});
296+
297+
describe("getCacheKey", () => {
298+
it("should generate cache key with build ID and tag", () => {
299+
const key = "test-tag";
300+
const cacheKey = (tagCache as unknown as { getCacheKey: (key: string) => string }).getCacheKey(key);
301+
302+
expect(cacheKey).toBe(`${FALLBACK_BUILD_ID}/${key}`);
303+
});
304+
305+
it("should use custom build ID when NEXT_BUILD_ID is set", () => {
306+
const customBuildId = "custom-build-id";
307+
vi.stubEnv("NEXT_BUILD_ID", customBuildId);
308+
309+
const key = "test-tag";
310+
const cacheKey = (tagCache as unknown as { getCacheKey: (key: string) => string }).getCacheKey(key);
311+
312+
expect(cacheKey).toBe(`${customBuildId}/${key}`);
313+
});
314+
315+
it("should handle double slashes by replacing them with single slash", () => {
316+
vi.stubEnv("NEXT_BUILD_ID", "build//id");
317+
318+
const key = "test-tag";
319+
const cacheKey = (tagCache as unknown as { getCacheKey: (key: string) => string }).getCacheKey(key);
320+
321+
expect(cacheKey).toBe("build/id/test-tag");
322+
});
323+
});
324+
325+
describe("getBuildId", () => {
326+
it("should return NEXT_BUILD_ID when set", () => {
327+
const customBuildId = "custom-build-id";
328+
vi.stubEnv("NEXT_BUILD_ID", customBuildId);
329+
330+
const buildId = (tagCache as unknown as { getBuildId: () => string }).getBuildId();
331+
332+
expect(buildId).toBe(customBuildId);
333+
});
334+
335+
it("should return fallback build ID when NEXT_BUILD_ID is not set", () => {
336+
// Environment variables are cleared by vi.unstubAllEnvs() in beforeEach
337+
338+
const buildId = (tagCache as unknown as { getBuildId: () => string }).getBuildId();
339+
340+
expect(buildId).toBe(FALLBACK_BUILD_ID);
341+
});
342+
});
343+
});

packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export class D1NextModeTagCache implements NextModeTagCache {
2121
.prepare(
2222
`SELECT MAX(revalidatedAt) AS time FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`
2323
)
24+
.bind(...tags.map((tag) => this.getCacheKey(tag)))
2425
.run();
2526

2627
if (result.results.length === 0) return 0;

0 commit comments

Comments
 (0)