@opennextjs/cloudflare 1.18.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,43 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
+ export type TagData = {
3
+ revalidatedAt: number;
4
+ stale: number | null;
5
+ expire: number | null;
6
+ };
2
7
  export declare class DOShardedTagCache extends DurableObject<CloudflareEnv> {
3
8
  sql: SqlStorage;
4
9
  constructor(state: DurableObjectState, env: CloudflareEnv);
10
+ getTagData(tags: string[]): Promise<Record<string, TagData>>;
11
+ /**
12
+ * @deprecated since v1.19.
13
+ *
14
+ * Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
15
+ * for a given tag using a single key.
16
+ *
17
+ * Kept for backward compatibility during rolling deploys.
18
+ */
5
19
  getLastRevalidated(tags: string[]): Promise<number>;
20
+ /**
21
+ * @deprecated since v1.19.
22
+ *
23
+ * Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
24
+ * for a given tag using a single key.
25
+ *
26
+ * Kept for backward compatibility during rolling deploys.
27
+ */
6
28
  hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean>;
7
- writeTags(tags: string[], lastModified: number): Promise<void>;
29
+ /**
30
+ * @deprecated since v1.19.
31
+ *
32
+ * Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
33
+ * for a given tag using a single key.
34
+ *
35
+ * Kept for backward compatibility during rolling deploys.
36
+ */
8
37
  getRevalidationTimes(tags: string[]): Promise<Record<string, number>>;
38
+ writeTags(tags: Array<string | {
39
+ tag: string;
40
+ stale?: number;
41
+ expire?: number | null;
42
+ }>, lastModified?: number): Promise<void>;
9
43
  }
@@ -6,41 +6,105 @@ export class DOShardedTagCache extends DurableObject {
6
6
  super(state, env);
7
7
  this.sql = state.storage.sql;
8
8
  state.blockConcurrencyWhile(async () => {
9
- this.sql.exec(`CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER)`);
9
+ // Columns:
10
+ // tag - The cache tag.
11
+ // revalidatedAt - Timestamp (ms) when the tag was last revalidated.
12
+ // stale - Timestamp (ms) when the cached entry becomes stale. Added in v1.19.
13
+ // expire - Timestamp (ms) when the cached entry expires. NULL means no expire. Added in v1.19.
14
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER, stale INTEGER, expire INTEGER DEFAULT NULL)`);
15
+ // Schema migration: Add `stale` and `expire` columns for existing DO - those have been introduced to support SWR in v1.19
16
+ try {
17
+ // SQLite does not support adding multiple columns in a single ALTER TABLE statement.
18
+ this.sql.exec(`ALTER TABLE revalidations ADD COLUMN stale INTEGER; ALTER TABLE revalidations ADD COLUMN expire INTEGER DEFAULT NULL`);
19
+ }
20
+ catch {
21
+ // The ALTER TABLE statement fails if the columns already exist.
22
+ // It only means the DO has already been migrated.
23
+ }
10
24
  });
11
25
  }
12
- async getLastRevalidated(tags) {
26
+ async getTagData(tags) {
27
+ if (tags.length === 0)
28
+ return {};
13
29
  try {
14
30
  const result = this.sql
15
- .exec(`SELECT MAX(revalidatedAt) AS time FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`, ...tags)
31
+ .exec(`SELECT tag, revalidatedAt, stale, expire FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`, ...tags)
16
32
  .toArray();
17
- const timeMs = (result[0]?.time ?? 0);
18
- debugCache("DOShardedTagCache", `getLastRevalidated tags=${tags} -> time=${timeMs}`);
19
- return timeMs;
33
+ debugCache("DOShardedTagCache", `getTagData tags=${tags} -> ${result.length} results`);
34
+ return Object.fromEntries(result.map((row) => [
35
+ row.tag,
36
+ {
37
+ revalidatedAt: (row.revalidatedAt ?? 0),
38
+ stale: (row.stale ?? null),
39
+ expire: (row.expire ?? null),
40
+ },
41
+ ]));
20
42
  }
21
43
  catch (e) {
22
44
  console.error(e);
23
- // By default we don't want to crash here, so we return 0
24
- return 0;
45
+ return {};
25
46
  }
26
47
  }
48
+ /**
49
+ * @deprecated since v1.19.
50
+ *
51
+ * Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
52
+ * for a given tag using a single key.
53
+ *
54
+ * Kept for backward compatibility during rolling deploys.
55
+ */
56
+ async getLastRevalidated(tags) {
57
+ const data = await this.getTagData(tags);
58
+ const values = Object.values(data);
59
+ const timeMs = values.length === 0 ? 0 : Math.max(...values.map(({ revalidatedAt }) => revalidatedAt));
60
+ debugCache("DOShardedTagCache", `getLastRevalidated tags=${tags} -> time=${timeMs}`);
61
+ return timeMs;
62
+ }
63
+ /**
64
+ * @deprecated since v1.19.
65
+ *
66
+ * Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
67
+ * for a given tag using a single key.
68
+ *
69
+ * Kept for backward compatibility during rolling deploys.
70
+ */
27
71
  async hasBeenRevalidated(tags, lastModified) {
28
- const revalidated = this.sql
29
- .exec(`SELECT 1 FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")}) AND revalidatedAt > ? LIMIT 1`, ...tags, lastModified ?? Date.now())
30
- .toArray().length > 0;
72
+ const data = await this.getTagData(tags);
73
+ const lastModifiedOrNowMs = lastModified ?? Date.now();
74
+ const revalidated = Object.values(data).some(({ revalidatedAt }) => revalidatedAt > lastModifiedOrNowMs);
31
75
  debugCache("DOShardedTagCache", `hasBeenRevalidated tags=${tags} -> revalidated=${revalidated}`);
32
76
  return revalidated;
33
77
  }
34
- async writeTags(tags, lastModified) {
35
- debugCache("DOShardedTagCache", `writeTags tags=${tags} time=${lastModified}`);
36
- tags.forEach((tag) => {
37
- this.sql.exec(`INSERT OR REPLACE INTO revalidations (tag, revalidatedAt) VALUES (?, ?)`, tag, lastModified);
38
- });
39
- }
78
+ /**
79
+ * @deprecated since v1.19.
80
+ *
81
+ * Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
82
+ * for a given tag using a single key.
83
+ *
84
+ * Kept for backward compatibility during rolling deploys.
85
+ */
40
86
  async getRevalidationTimes(tags) {
41
- const result = this.sql
42
- .exec(`SELECT tag, revalidatedAt FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`, ...tags)
43
- .toArray();
44
- return Object.fromEntries(result.map((row) => [row.tag, row.revalidatedAt]));
87
+ const data = await this.getTagData(tags);
88
+ return Object.fromEntries(Object.entries(data).map(([tag, { revalidatedAt }]) => [tag, revalidatedAt]));
89
+ }
90
+ async writeTags(tags, lastModified) {
91
+ if (tags.length === 0)
92
+ return;
93
+ const nowMs = lastModified ?? Date.now();
94
+ debugCache("DOShardedTagCache", `writeTags tags=${JSON.stringify(tags)} time=${nowMs}`);
95
+ if (typeof tags[0] === "string") {
96
+ // Old call format: writeTags(tags: string[], lastModified: number)
97
+ for (const tag of tags) {
98
+ // `expire` defaults to `NULL`
99
+ this.sql.exec(`INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale) VALUES (?, ?, ?)`, tag, nowMs, nowMs);
100
+ }
101
+ }
102
+ else {
103
+ // New call format: writeTags(tags: Array<{ tag, stale?, expire? }>)
104
+ for (const entry of tags) {
105
+ const staleValue = entry.stale ?? nowMs;
106
+ this.sql.exec(`INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale, expire) VALUES (?, ?, ?, ?)`, entry.tag, staleValue, staleValue, entry.expire ?? null);
107
+ }
108
+ }
45
109
  }
46
110
  }
@@ -1,15 +1,33 @@
1
- import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
1
+ import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js";
2
2
  export declare const NAME = "d1-next-mode-tag-cache";
3
3
  export declare const BINDING_NAME = "NEXT_TAG_CACHE_D1";
4
+ /**
5
+ * Stored value shape for D1 tag entries.
6
+ *
7
+ * - revalidatedAt: timestamp in ms of the last revalidation
8
+ * - stale: timestamp in ms when the tag becomes stale
9
+ * - expire: timestamp in ms when the tag expires (null means no expiry)
10
+ */
11
+ type D1TagValue = {
12
+ revalidatedAt: number;
13
+ stale: number | null;
14
+ expire: number | null;
15
+ };
4
16
  export declare class D1NextModeTagCache implements NextModeTagCache {
17
+ #private;
5
18
  readonly mode: "nextMode";
6
19
  readonly name = "d1-next-mode-tag-cache";
7
20
  getLastRevalidated(tags: string[]): Promise<number>;
8
21
  hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean>;
9
- writeTags(tags: string[]): Promise<void>;
22
+ writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void>;
23
+ isStale(tags: string[], lastModified?: number): Promise<boolean>;
10
24
  private getConfig;
11
25
  protected getCacheKey(key: string): string;
12
26
  protected getBuildId(): string;
27
+ /**
28
+ * @returns request scoped in-memory cache for tag values, or undefined if ALS is not available.
29
+ */
30
+ protected getItemsCache(): Map<string, D1TagValue | null> | undefined;
13
31
  }
14
32
  declare const _default: D1NextModeTagCache;
15
33
  export default _default;
@@ -12,17 +12,15 @@ export class D1NextModeTagCache {
12
12
  return 0;
13
13
  }
14
14
  try {
15
- const result = await db
16
- .prepare(`SELECT MAX(revalidatedAt) AS time FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`)
17
- .bind(...tags.map((tag) => this.getCacheKey(tag)))
18
- .run();
19
- const timeMs = (result.results[0]?.time ?? 0);
15
+ const result = await this.#resolveTagValues(tags, db);
16
+ const revalidations = [...result.values()]
17
+ .filter((v) => v != null)
18
+ .map((v) => v.revalidatedAt);
19
+ const timeMs = revalidations.length === 0 ? 0 : Math.max(...revalidations);
20
20
  debugCache("D1NextModeTagCache", `getLastRevalidated tags=${tags} -> ${timeMs}`);
21
21
  return timeMs;
22
22
  }
23
23
  catch (e) {
24
- // By default we don't want to crash here, so we return false
25
- // We still log the error though so we can debug it
26
24
  error(e);
27
25
  return 0;
28
26
  }
@@ -33,18 +31,21 @@ export class D1NextModeTagCache {
33
31
  return false;
34
32
  }
35
33
  try {
36
- const result = await db
37
- .prepare(`SELECT 1 FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")}) AND revalidatedAt > ? LIMIT 1`)
38
- .bind(...tags.map((tag) => this.getCacheKey(tag)), lastModified ?? Date.now())
39
- .raw();
40
- const revalidated = result.length > 0;
34
+ const now = Date.now();
35
+ const result = await this.#resolveTagValues(tags, db);
36
+ const revalidated = [...result.values()].some((v) => {
37
+ if (v == null)
38
+ return false;
39
+ const { revalidatedAt, expire } = v;
40
+ if (expire != null)
41
+ return expire <= now && expire > (lastModified ?? 0);
42
+ return revalidatedAt > (lastModified ?? now);
43
+ });
41
44
  debugCache("D1NextModeTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> ${revalidated}`);
42
45
  return revalidated;
43
46
  }
44
47
  catch (e) {
45
48
  error(e);
46
- // By default we don't want to crash here, so we return false
47
- // We still log the error though so we can debug it
48
49
  return false;
49
50
  }
50
51
  }
@@ -53,15 +54,89 @@ export class D1NextModeTagCache {
53
54
  if (isDisabled || tags.length === 0)
54
55
  return Promise.resolve();
55
56
  const nowMs = Date.now();
56
- await db.batch(tags.map((tag) => db
57
- .prepare(`INSERT INTO revalidations (tag, revalidatedAt) VALUES (?, ?)`)
58
- .bind(this.getCacheKey(tag), nowMs)));
59
- debugCache("D1NextModeTagCache", `writeTags tags=${tags} time=${nowMs}`);
57
+ await db.batch(tags.map((tag) => {
58
+ const tagStr = typeof tag === "string" ? tag : tag.tag;
59
+ const stale = typeof tag === "string" ? nowMs : (tag.stale ?? nowMs);
60
+ const expire = typeof tag === "string" ? null : (tag.expire ?? null);
61
+ return db
62
+ .prepare(`INSERT INTO revalidations (tag, revalidatedAt, stale, expire) VALUES (?, ?, ?, ?)`)
63
+ .bind(this.getCacheKey(tagStr), stale, stale, expire);
64
+ }));
65
+ const tagStrings = tags.map((t) => (typeof t === "string" ? t : t.tag));
66
+ debugCache("D1NextModeTagCache", `writeTags tags=${tagStrings} time=${nowMs}`);
60
67
  // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
61
68
  if (isPurgeCacheEnabled()) {
62
- await purgeCacheByTags(tags);
69
+ await purgeCacheByTags(tagStrings);
63
70
  }
64
71
  }
72
+ async isStale(tags, lastModified) {
73
+ const { isDisabled, db } = this.getConfig();
74
+ if (isDisabled || tags.length === 0) {
75
+ return false;
76
+ }
77
+ try {
78
+ const now = Date.now();
79
+ const result = await this.#resolveTagValues(tags, db);
80
+ const isStale = [...result.values()].some((v) => {
81
+ if (v == null)
82
+ return false;
83
+ const { stale, expire } = v;
84
+ if (stale == null || stale <= (lastModified ?? now))
85
+ return false;
86
+ return expire == null || expire > now;
87
+ });
88
+ debugCache("D1NextModeTagCache", `isStale tags=${tags} at=${lastModified} -> ${isStale}`);
89
+ return isStale;
90
+ }
91
+ catch (e) {
92
+ error(e);
93
+ return false;
94
+ }
95
+ }
96
+ /**
97
+ * Resolves tag values from the per-request in-memory cache, falling back to D1 for any misses.
98
+ *
99
+ * Results are stored back into the request cache so repeated calls within the same request
100
+ * avoid duplicate D1 queries.
101
+ *
102
+ * @param tags - The tag names to resolve.
103
+ * @param db - The D1 database binding.
104
+ * @returns A map of tag name to its D1TagValue (or null if the tag was not found).
105
+ */
106
+ async #resolveTagValues(tags, db) {
107
+ const result = new Map();
108
+ const uncachedTags = [];
109
+ const itemsCache = this.getItemsCache();
110
+ for (const tag of tags) {
111
+ if (itemsCache?.has(tag)) {
112
+ result.set(tag, itemsCache.get(tag) ?? null);
113
+ }
114
+ else {
115
+ uncachedTags.push(tag);
116
+ }
117
+ }
118
+ if (uncachedTags.length > 0) {
119
+ const rows = await db
120
+ .prepare(`SELECT tag, revalidatedAt, stale, expire FROM revalidations WHERE tag IN (${uncachedTags.map(() => "?").join(", ")})`)
121
+ .bind(...uncachedTags.map((tag) => this.getCacheKey(tag)))
122
+ .raw();
123
+ // Index rows by cache key for lookup.
124
+ const rowsByKey = new Map(rows.map((row) => [row[0], row]));
125
+ for (const tag of uncachedTags) {
126
+ const row = rowsByKey.get(this.getCacheKey(tag));
127
+ const value = row
128
+ ? {
129
+ revalidatedAt: row[1] ?? 0,
130
+ stale: row[2] ?? null,
131
+ expire: row[3] ?? null,
132
+ }
133
+ : null;
134
+ itemsCache?.set(tag, value);
135
+ result.set(tag, value);
136
+ }
137
+ }
138
+ return result;
139
+ }
65
140
  getConfig() {
66
141
  const db = getCloudflareContext().env[BINDING_NAME];
67
142
  if (!db)
@@ -80,5 +155,12 @@ export class D1NextModeTagCache {
80
155
  getBuildId() {
81
156
  return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
82
157
  }
158
+ /**
159
+ * @returns request scoped in-memory cache for tag values, or undefined if ALS is not available.
160
+ */
161
+ getItemsCache() {
162
+ const store = globalThis.__openNextAls?.getStore();
163
+ return store?.requestCache.getOrCreate("d1-nextMode:tagItems");
164
+ }
83
165
  }
84
166
  export default new D1NextModeTagCache();
@@ -1,5 +1,14 @@
1
- import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
1
+ import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js";
2
+ import type { TagData } from "../../durable-objects/sharded-tag-cache.js";
2
3
  import { DOShardedTagCache } from "../../durable-objects/sharded-tag-cache.js";
4
+ type NormalizedTagInput = {
5
+ tag: string;
6
+ stale?: number;
7
+ expire?: number | null;
8
+ };
9
+ type CachedTagValue = {
10
+ tag: string;
11
+ } & TagData;
3
12
  export declare const DEFAULT_WRITE_RETRIES = 3;
4
13
  export declare const DEFAULT_NUM_SHARDS = 4;
5
14
  export declare const NAME = "do-sharded-tag-cache";
@@ -88,6 +97,7 @@ interface DOIdOptions {
88
97
  region?: DurableObjectLocationHint;
89
98
  }
90
99
  declare class ShardedDOTagCache implements NextModeTagCache {
100
+ #private;
91
101
  private opts;
92
102
  readonly mode: "nextMode";
93
103
  readonly name = "do-sharded-tag-cache";
@@ -110,17 +120,18 @@ declare class ShardedDOTagCache implements NextModeTagCache {
110
120
  * @returns
111
121
  */
112
122
  hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean>;
123
+ isStale(tags: string[], lastModified?: number): Promise<boolean>;
113
124
  /**
114
125
  * This function writes the tags to the cache
115
126
  * Due to the way shards and regional cache are implemented, the regional cache may not be properly invalidated
116
127
  * @param tags
117
128
  * @returns
118
129
  */
119
- writeTags(tags: string[]): Promise<void>;
130
+ writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void>;
120
131
  /**
121
132
  * The following methods are public only because they are accessed from the tests
122
133
  */
123
- performWriteTagsWithRetry(doId: DOId, tags: string[], lastModified: number, retryNumber?: number): Promise<void>;
134
+ performWriteTagsWithRetry(doId: DOId, tags: NormalizedTagInput[], retryNumber?: number): Promise<void>;
124
135
  getCacheUrlKey(doId: DOId, tag: string): string;
125
136
  getCacheInstance(): Promise<Cache | undefined>;
126
137
  /**
@@ -128,10 +139,7 @@ declare class ShardedDOTagCache implements NextModeTagCache {
128
139
  * If the cache is not enabled, it will return an empty array
129
140
  * @returns An array of objects with the tag and the last revalidation time
130
141
  */
131
- getFromRegionalCache(opts: CacheTagKeyOptions): Promise<{
132
- tag: string;
133
- time: number;
134
- }[]>;
142
+ getFromRegionalCache(opts: CacheTagKeyOptions): Promise<CachedTagValue[]>;
135
143
  putToRegionalCache(optsKey: CacheTagKeyOptions, stub: DurableObjectStub<DOShardedTagCache>): Promise<void>;
136
144
  /**
137
145
  * Deletes the regional cache for the given tags
@@ -37,26 +37,10 @@ class ShardedDOTagCache {
37
37
  }
38
38
  const deduplicatedTags = Array.from(new Set(tags)); // We deduplicate the tags to avoid unnecessary requests
39
39
  try {
40
- const shardedTagGroups = this.groupTagsByDO({ tags: deduplicatedTags });
41
- const shardedTagRevalidationOutcomes = await Promise.all(shardedTagGroups.map(async ({ doId, tags }) => {
42
- const cachedValue = await this.getFromRegionalCache({ doId, tags });
43
- // If all the value were found in the regional cache, we can just return the max value
44
- if (cachedValue.length === tags.length) {
45
- const timeMs = Math.max(...cachedValue.map((item) => item.time));
46
- debugCache("ShardedDOTagCache", `getLastRevalidated tags=${tags} -> ${timeMs} (regional cache)`);
47
- return timeMs;
48
- }
49
- // Otherwise we need to check the durable object on the ones that were not found in the cache
50
- const filteredTags = deduplicatedTags.filter((tag) => !cachedValue.some((item) => item.tag === tag));
51
- const stub = this.getDurableObjectStub(doId);
52
- const lastRevalidated = await stub.getLastRevalidated(filteredTags);
53
- const timeMs = Math.max(...cachedValue.map((item) => item.time), lastRevalidated);
54
- // We then need to populate the regional cache with the missing tags
55
- getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags }, stub));
56
- debugCache("ShardedDOTagCache", `getLastRevalidated tags=${tags} -> ${timeMs}`);
57
- return timeMs;
58
- }));
59
- return Math.max(...shardedTagRevalidationOutcomes);
40
+ const tagData = await this.#resolveTagData(deduplicatedTags);
41
+ const timeMs = Math.max(0, ...[...tagData.values()].filter((d) => d != null).map((d) => d.revalidatedAt));
42
+ debugCache("ShardedDOTagCache", `getLastRevalidated tags=${tags} -> ${timeMs}`);
43
+ return timeMs;
60
44
  }
61
45
  catch (e) {
62
46
  error("Error while checking revalidation", e);
@@ -76,34 +60,48 @@ class ShardedDOTagCache {
76
60
  return false;
77
61
  }
78
62
  try {
79
- const shardedTagGroups = this.groupTagsByDO({ tags });
80
- const shardedTagRevalidationOutcomes = await Promise.all(shardedTagGroups.map(async ({ doId, tags }) => {
81
- const cachedValue = await this.getFromRegionalCache({ doId, tags });
82
- // If one of the cached values is newer than the lastModified, we can return true
83
- const cacheHasBeenRevalidated = cachedValue.some((cachedValue) => {
84
- return (cachedValue.time ?? 0) > (lastModified ?? Date.now());
85
- });
86
- if (cacheHasBeenRevalidated) {
87
- debugCache("ShardedDOTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> true (regional cache)`);
88
- return true;
89
- }
90
- const stub = this.getDurableObjectStub(doId);
91
- const _hasBeenRevalidated = await stub.hasBeenRevalidated(tags, lastModified);
92
- const remainingTags = tags.filter((tag) => !cachedValue.some((item) => item.tag === tag));
93
- if (remainingTags.length > 0) {
94
- // We need to put the missing tags in the regional cache
95
- getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags: remainingTags }, stub));
96
- }
97
- debugCache("ShardedDOTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> ${_hasBeenRevalidated}`);
98
- return _hasBeenRevalidated;
99
- }));
100
- return shardedTagRevalidationOutcomes.some((result) => result);
63
+ const now = Date.now();
64
+ const tagData = await this.#resolveTagData(tags);
65
+ const result = [...tagData.values()].some((data) => {
66
+ if (data == null)
67
+ return false;
68
+ const { revalidatedAt, expire } = data;
69
+ if (expire != null)
70
+ return expire <= now && expire > (lastModified ?? 0);
71
+ return revalidatedAt > (lastModified ?? now);
72
+ });
73
+ debugCache("ShardedDOTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> ${result}`);
74
+ return result;
101
75
  }
102
76
  catch (e) {
103
77
  error("Error while checking revalidation", e);
104
78
  return false;
105
79
  }
106
80
  }
81
+ async isStale(tags, lastModified) {
82
+ const { isDisabled } = this.getConfig();
83
+ if (isDisabled || tags.length === 0) {
84
+ return false;
85
+ }
86
+ try {
87
+ const now = Date.now();
88
+ const tagData = await this.#resolveTagData(tags);
89
+ const result = [...tagData.values()].some((data) => {
90
+ if (data == null)
91
+ return false;
92
+ const { stale, expire } = data;
93
+ if (stale == null || stale <= (lastModified ?? now))
94
+ return false;
95
+ return expire == null || expire > now;
96
+ });
97
+ debugCache("ShardedDOTagCache", `isStale tags=${tags} at=${lastModified} -> ${result}`);
98
+ return result;
99
+ }
100
+ catch (e) {
101
+ error("Error while checking stale", e);
102
+ return false;
103
+ }
104
+ }
107
105
  /**
108
106
  * This function writes the tags to the cache
109
107
  * Due to the way shards and regional cache are implemented, the regional cache may not be properly invalidated
@@ -114,28 +112,33 @@ class ShardedDOTagCache {
114
112
  const { isDisabled } = this.getConfig();
115
113
  if (isDisabled)
116
114
  return;
117
- // We want to use the same revalidation time for all tags
118
115
  const nowMs = Date.now();
119
- debugCache("ShardedDOTagCache", `writeTags tags=${tags} time=${nowMs}`);
120
- const shardedTagGroups = this.groupTagsByDO({ tags, generateAllReplicas: true });
121
- await Promise.all(shardedTagGroups.map(async ({ doId, tags }) => {
122
- await this.performWriteTagsWithRetry(doId, tags, nowMs);
116
+ const normalized = tags.map((tag) => typeof tag === "string"
117
+ ? { tag, stale: nowMs, expire: undefined }
118
+ : { tag: tag.tag, stale: tag.stale ?? nowMs, expire: tag.expire });
119
+ const tagStrings = normalized.map((t) => t.tag);
120
+ debugCache("ShardedDOTagCache", `writeTags tags=${tagStrings} time=${nowMs}`);
121
+ const tagMap = new Map(normalized.map((t) => [t.tag, t]));
122
+ const shardedTagGroups = this.groupTagsByDO({ tags: tagStrings, generateAllReplicas: true });
123
+ await Promise.all(shardedTagGroups.map(async ({ doId, tags: shardTags }) => {
124
+ const shardNormalized = shardTags.map((t) => tagMap.get(t));
125
+ await this.performWriteTagsWithRetry(doId, shardNormalized);
123
126
  }));
124
127
  // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
125
128
  if (isPurgeCacheEnabled()) {
126
- await purgeCacheByTags(tags);
129
+ await purgeCacheByTags(tagStrings);
127
130
  }
128
131
  }
129
132
  /**
130
133
  * The following methods are public only because they are accessed from the tests
131
134
  */
132
- async performWriteTagsWithRetry(doId, tags, lastModified, retryNumber = 0) {
135
+ async performWriteTagsWithRetry(doId, tags, retryNumber = 0) {
133
136
  try {
134
137
  const stub = this.getDurableObjectStub(doId);
135
- await stub.writeTags(tags, lastModified);
138
+ await stub.writeTags(tags);
136
139
  // Depending on the shards and the tags, deleting from the regional cache will not work for every tag
137
140
  // We also need to delete both cache
138
- await Promise.all([this.deleteRegionalCache({ doId, tags })]);
141
+ await Promise.all([this.deleteRegionalCache({ doId, tags: tags.map((t) => t.tag) })]);
139
142
  }
140
143
  catch (e) {
141
144
  error("Error while writing tags", e);
@@ -145,11 +148,10 @@ class ShardedDOTagCache {
145
148
  await getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED_DLQ?.send({
146
149
  failingShardId: doId.key,
147
150
  failingTags: tags,
148
- lastModified,
149
151
  });
150
152
  return;
151
153
  }
152
- await this.performWriteTagsWithRetry(doId, tags, lastModified, retryNumber + 1);
154
+ await this.performWriteTagsWithRetry(doId, tags, retryNumber + 1);
153
155
  }
154
156
  }
155
157
  getCacheUrlKey(doId, tag) {
@@ -179,7 +181,23 @@ class ShardedDOTagCache {
179
181
  return null;
180
182
  const cachedText = await cachedResponse.text();
181
183
  try {
182
- return { tag, time: parseInt(cachedText, 10) };
184
+ const parsed = JSON.parse(cachedText);
185
+ if (typeof parsed === "number") {
186
+ // Backward compat: old format stored a plain revalidatedAt number
187
+ return {
188
+ tag,
189
+ revalidatedAt: parsed,
190
+ stale: parsed,
191
+ expire: null,
192
+ };
193
+ }
194
+ const data = parsed;
195
+ return {
196
+ tag,
197
+ revalidatedAt: data.revalidatedAt ?? 0,
198
+ stale: data.stale ?? null,
199
+ expire: data.expire ?? null,
200
+ };
183
201
  }
184
202
  catch (e) {
185
203
  debugCache("Error while parsing cached value", e);
@@ -200,21 +218,22 @@ class ShardedDOTagCache {
200
218
  if (!cache)
201
219
  return;
202
220
  const tags = optsKey.tags;
203
- const tagsLastRevalidated = await stub.getRevalidationTimes(tags);
221
+ const tagData = await stub.getTagData(tags);
204
222
  await Promise.all(tags.map(async (tag) => {
205
- let lastRevalidated = tagsLastRevalidated[tag];
206
- if (lastRevalidated === undefined) {
223
+ let data = tagData[tag];
224
+ if (data === undefined) {
207
225
  if (this.opts.regionalCacheDangerouslyPersistMissingTags) {
208
- lastRevalidated = 0; // If the tag is not found, we set it to 0 as it means it has never been revalidated before.
226
+ // Tag not found: store a sentinel (never revalidated)
227
+ data = { revalidatedAt: 0, stale: null, expire: null };
209
228
  }
210
229
  else {
211
- debugCache("Tag not found in revalidation times", { tag, optsKey });
212
- return; // If the tag is not found, we skip it
230
+ debugCache("Tag not found in tag data", { tag, optsKey });
231
+ return;
213
232
  }
214
233
  }
215
234
  const cacheKey = this.getCacheUrlKey(optsKey.doId, tag);
216
- debugCache("Putting to regional cache", { cacheKey, lastRevalidated });
217
- await cache.put(cacheKey, new Response(lastRevalidated.toString(), {
235
+ debugCache("Putting to regional cache", { cacheKey, data });
236
+ await cache.put(cacheKey, new Response(JSON.stringify(data), {
218
237
  status: 200,
219
238
  headers: {
220
239
  "cache-control": `max-age=${this.opts.regionalCacheTtlSec ?? 5}`,
@@ -276,6 +295,59 @@ class ShardedDOTagCache {
276
295
  return result;
277
296
  }
278
297
  // Private methods
298
+ /**
299
+ * Fetches tag data for the given tags by consulting the regional cache first and falling back
300
+ * to Durable Object stubs for any misses. Returns a map of tag → TagData (null for tags not found).
301
+ */
302
+ async #fetchTagDataFromShards(tags) {
303
+ const result = new Map();
304
+ const shardedTagGroups = this.groupTagsByDO({ tags });
305
+ await Promise.all(shardedTagGroups.map(async ({ doId, tags: shardTags }) => {
306
+ const cachedValues = await this.getFromRegionalCache({ doId, tags: shardTags });
307
+ for (const { tag, revalidatedAt, stale, expire } of cachedValues) {
308
+ result.set(tag, { revalidatedAt, stale, expire });
309
+ }
310
+ const cachedTagNames = new Set(cachedValues.map(({ tag }) => tag));
311
+ const remainingTags = shardTags.filter((tag) => !cachedTagNames.has(tag));
312
+ if (remainingTags.length === 0)
313
+ return;
314
+ const stub = this.getDurableObjectStub(doId);
315
+ const tagData = await stub.getTagData(remainingTags);
316
+ for (const tag of remainingTags) {
317
+ result.set(tag, tagData[tag] ?? null);
318
+ }
319
+ getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags: remainingTags }, stub));
320
+ }));
321
+ return result;
322
+ }
323
+ /**
324
+ * Resolves tag data from the per-request in-memory cache, falling back to
325
+ * `#fetchTagDataFromShards` for any misses. Results are stored back so repeated
326
+ * calls within the same request avoid duplicate shard fetches.
327
+ */
328
+ async #resolveTagData(tags) {
329
+ const store = globalThis.__openNextAls?.getStore();
330
+ const itemsCache = store?.requestCache.getOrCreate("do-sharded:tagItems");
331
+ const result = new Map();
332
+ const uncachedTags = [];
333
+ for (const tag of tags) {
334
+ if (itemsCache?.has(tag)) {
335
+ result.set(tag, itemsCache.get(tag) ?? null);
336
+ }
337
+ else {
338
+ uncachedTags.push(tag);
339
+ }
340
+ }
341
+ if (uncachedTags.length > 0) {
342
+ const fetched = await this.#fetchTagDataFromShards(uncachedTags);
343
+ for (const tag of uncachedTags) {
344
+ const value = fetched.get(tag) ?? null;
345
+ itemsCache?.set(tag, value);
346
+ result.set(tag, value);
347
+ }
348
+ }
349
+ return result;
350
+ }
279
351
  getDurableObjectStub(doId) {
280
352
  const durableObject = getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED;
281
353
  if (!durableObject)
@@ -1,6 +1,14 @@
1
- import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
1
+ import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js";
2
2
  export declare const NAME = "kv-next-mode-tag-cache";
3
3
  export declare const BINDING_NAME = "NEXT_TAG_CACHE_KV";
4
+ /**
5
+ * Stored value shape for KV entries.
6
+ */
7
+ type KVTagValue = number | {
8
+ revalidatedAt: number;
9
+ stale?: number | null;
10
+ expire?: number | null;
11
+ };
4
12
  /**
5
13
  * Tag Cache based on a KV namespace
6
14
  *
@@ -19,7 +27,8 @@ export declare class KVNextModeTagCache implements NextModeTagCache {
19
27
  readonly name = "kv-next-mode-tag-cache";
20
28
  getLastRevalidated(tags: string[]): Promise<number>;
21
29
  hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean>;
22
- writeTags(tags: string[]): Promise<void>;
30
+ writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void>;
31
+ isStale(tags: string[], lastModified?: number): Promise<boolean>;
23
32
  /**
24
33
  * Returns the KV namespace when it exists and tag cache is not disabled.
25
34
  *
@@ -28,6 +37,10 @@ export declare class KVNextModeTagCache implements NextModeTagCache {
28
37
  private getKv;
29
38
  protected getCacheKey(key: string): string;
30
39
  protected getBuildId(): string;
40
+ /**
41
+ * @returns request scoped in-memory cache for tag values, or undefined if ALS is not available.
42
+ */
43
+ protected getItemsCache(): Map<string, KVTagValue | null> | undefined;
31
44
  }
32
45
  declare const _default: KVNextModeTagCache;
33
46
  export default _default;
@@ -3,6 +3,16 @@ import { getCloudflareContext } from "../../cloudflare-context.js";
3
3
  import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js";
4
4
  export const NAME = "kv-next-mode-tag-cache";
5
5
  export const BINDING_NAME = "NEXT_TAG_CACHE_KV";
6
+ function getRevalidatedAt(value) {
7
+ return typeof value === "number" ? value : (value.revalidatedAt ?? 0);
8
+ }
9
+ function getStale(value) {
10
+ // Backward compat: old format stored a plain number meaning revalidatedAt = stale
11
+ return typeof value === "number" ? value : (value.stale ?? null);
12
+ }
13
+ function getExpire(value) {
14
+ return typeof value === "number" ? null : (value.expire ?? null);
15
+ }
6
16
  /**
7
17
  * Tag Cache based on a KV namespace
8
18
  *
@@ -34,10 +44,10 @@ export class KVNextModeTagCache {
34
44
  return 0;
35
45
  }
36
46
  try {
37
- const keys = tags.map((tag) => this.getCacheKey(tag));
38
- // Use the `json` type to get back numbers/null
39
- const result = await kv.get(keys, { type: "json" });
40
- const revalidations = [...result.values()].filter((v) => v != null);
47
+ const result = await this.#resolveTagValues(tags, kv);
48
+ const revalidations = [...result.values()]
49
+ .filter((v) => v != null)
50
+ .map(getRevalidatedAt);
41
51
  return revalidations.length === 0 ? 0 : Math.max(...revalidations);
42
52
  }
43
53
  catch (e) {
@@ -47,10 +57,57 @@ export class KVNextModeTagCache {
47
57
  return 0;
48
58
  }
49
59
  }
60
+ /**
61
+ * Resolves tag values from the per-request in-memory cache, falling back to KV for any misses.
62
+ * Results are stored back into the request cache so repeated calls within the same request
63
+ * avoid duplicate KV fetches.
64
+ */
65
+ async #resolveTagValues(tags, kv) {
66
+ const result = new Map();
67
+ const uncachedTags = [];
68
+ const itemsCache = this.getItemsCache();
69
+ for (const tag of tags) {
70
+ if (itemsCache?.has(tag)) {
71
+ result.set(tag, itemsCache.get(tag) ?? null);
72
+ }
73
+ else {
74
+ uncachedTags.push(tag);
75
+ }
76
+ }
77
+ if (uncachedTags.length > 0) {
78
+ const kvKeys = uncachedTags.map((tag) => this.getCacheKey(tag));
79
+ const fetched = await kv.get(kvKeys, { type: "json" });
80
+ for (const tag of uncachedTags) {
81
+ const value = fetched.get(this.getCacheKey(tag)) ?? null;
82
+ itemsCache?.set(tag, value);
83
+ result.set(tag, value);
84
+ }
85
+ }
86
+ return result;
87
+ }
50
88
  async hasBeenRevalidated(tags, lastModified) {
51
- const revalidated = (await this.#getLastRevalidated(tags)) > (lastModified ?? Date.now());
52
- debugCache("KVNextModeTagCache", `hasBeenRevalidated tags=${tags} lastModified=${lastModified} -> ${revalidated}`);
53
- return revalidated;
89
+ const kv = this.getKv();
90
+ if (!kv || tags.length === 0) {
91
+ return false;
92
+ }
93
+ try {
94
+ const now = Date.now();
95
+ const result = await this.#resolveTagValues(tags, kv);
96
+ const revalidated = [...result.values()].some((v) => {
97
+ if (v == null)
98
+ return false;
99
+ const expire = getExpire(v);
100
+ if (expire != null)
101
+ return expire <= now && expire > (lastModified ?? 0);
102
+ return getRevalidatedAt(v) > (lastModified ?? now);
103
+ });
104
+ debugCache("KVNextModeTagCache", `hasBeenRevalidated tags=${tags} lastModified=${lastModified} -> ${revalidated}`);
105
+ return revalidated;
106
+ }
107
+ catch (e) {
108
+ error(e);
109
+ return false;
110
+ }
54
111
  }
55
112
  async writeTags(tags) {
56
113
  const kv = this.getKv();
@@ -59,12 +116,45 @@ export class KVNextModeTagCache {
59
116
  }
60
117
  const nowMs = Date.now();
61
118
  await Promise.all(tags.map(async (tag) => {
62
- await kv.put(this.getCacheKey(tag), String(nowMs));
119
+ if (typeof tag === "string") {
120
+ // Old format: store plain number string for backward compat
121
+ await kv.put(this.getCacheKey(tag), String(nowMs));
122
+ }
123
+ else {
124
+ const stale = tag.stale ?? nowMs;
125
+ const value = { revalidatedAt: stale, stale, expire: tag.expire ?? null };
126
+ await kv.put(this.getCacheKey(tag.tag), JSON.stringify(value));
127
+ }
63
128
  }));
64
- debugCache("KVNextModeTagCache", `writeTags tags=${tags} time=${nowMs}`);
129
+ const tagStrings = tags.map((t) => (typeof t === "string" ? t : t.tag));
130
+ debugCache("KVNextModeTagCache", `writeTags tags=${tagStrings} time=${nowMs}`);
65
131
  // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
66
132
  if (isPurgeCacheEnabled()) {
67
- await purgeCacheByTags(tags);
133
+ await purgeCacheByTags(tagStrings);
134
+ }
135
+ }
136
+ async isStale(tags, lastModified) {
137
+ const kv = this.getKv();
138
+ if (!kv || tags.length === 0)
139
+ return false;
140
+ try {
141
+ const now = Date.now();
142
+ const result = await this.#resolveTagValues(tags, kv);
143
+ const isStale = [...result.values()].some((v) => {
144
+ if (v == null)
145
+ return false;
146
+ const stale = getStale(v);
147
+ if (stale == null || stale <= (lastModified ?? now))
148
+ return false;
149
+ const expire = getExpire(v);
150
+ return expire == null || expire > now;
151
+ });
152
+ debugCache("KVNextModeTagCache", `isStale tags=${tags} lastModified=${lastModified} -> ${isStale}`);
153
+ return isStale;
154
+ }
155
+ catch (e) {
156
+ error(e);
157
+ return false;
68
158
  }
69
159
  }
70
160
  /**
@@ -87,5 +177,12 @@ export class KVNextModeTagCache {
87
177
  getBuildId() {
88
178
  return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
89
179
  }
180
+ /**
181
+ * @returns request scoped in-memory cache for tag values, or undefined if ALS is not available.
182
+ */
183
+ getItemsCache() {
184
+ const store = globalThis.__openNextAls?.getStore();
185
+ return store?.requestCache.getOrCreate("kv-nextMode:tagItems");
186
+ }
90
187
  }
91
188
  export default new KVNextModeTagCache();
@@ -1,4 +1,4 @@
1
- import { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
1
+ import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js";
2
2
  interface WithFilterOptions {
3
3
  /**
4
4
  * The original tag cache.
@@ -10,7 +10,7 @@ interface WithFilterOptions {
10
10
  * @param tag The tag to filter.
11
11
  * @returns true if the tag should be forwarded, false otherwise.
12
12
  */
13
- filterFn: (tag: string) => boolean;
13
+ filterFn: (tag: string | NextModeTagCacheWriteInput) => boolean;
14
14
  }
15
15
  /**
16
16
  * Creates a new tag cache that filters tags based on the provided filter function.
@@ -22,5 +22,5 @@ export declare function withFilter({ tagCache, filterFn }: WithFilterOptions): N
22
22
  * This is used to filter out internal soft tags.
23
23
  * Can be used if `revalidatePath` is not used.
24
24
  */
25
- export declare function softTagFilter(tag: string): boolean;
25
+ export declare function softTagFilter(tag: string | NextModeTagCacheWriteInput): boolean;
26
26
  export {};
@@ -44,5 +44,8 @@ export function withFilter({ tagCache, filterFn }) {
44
44
  * Can be used if `revalidatePath` is not used.
45
45
  */
46
46
  export function softTagFilter(tag) {
47
- return !tag.startsWith("_N_T_");
47
+ if (typeof tag === "string") {
48
+ return !tag.startsWith("_N_T_");
49
+ }
50
+ return !tag.tag.startsWith("_N_T_");
48
51
  }
@@ -102,6 +102,7 @@ fix: |-
102
102
  const handlersSetSymbol = Symbol.for('@next/cache-handlers-set');
103
103
  globalThis[handlersMapSymbol] = new Map();
104
104
  globalThis[handlersMapSymbol].set("default", require('${normalizePath(handlerPath)}').default);
105
+ globalThis[handlersMapSymbol].set("remote", require('${normalizePath(handlerPath)}').default);
105
106
  globalThis[handlersSetSymbol] = new Set(globalThis[handlersMapSymbol].values());
106
107
  `;
107
108
  }
@@ -10,6 +10,7 @@ import { getPackagePath } from "@opennextjs/aws/build/helper.js";
10
10
  import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
11
11
  import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
12
12
  import { normalizePath } from "../../../utils/normalize-path.js";
13
+ import { createComposableCacheHandlersRule } from "./next-server.js";
13
14
  export function patchRouteModules(updater, buildOpts) {
14
15
  return updater.updateContent("route-module", [
15
16
  {
@@ -17,13 +18,15 @@ export function patchRouteModules(updater, buildOpts) {
17
18
  escape: false,
18
19
  }),
19
20
  versions: ">=15.4.0",
20
- contentFilter: /getIncrementalCache\(/,
21
+ // app route doesn't have getIncrementalCache, but we still need to patch the composable cache handlers there
22
+ contentFilter: /(getIncrementalCache\(|loadCustomCacheHandlers\()/,
21
23
  callback: async ({ contents }) => {
22
24
  const { outputDir } = buildOpts;
23
25
  const outputPath = path.join(outputDir, "server-functions/default");
24
26
  const cacheHandler = path.join(outputPath, getPackagePath(buildOpts), "cache.cjs");
25
27
  contents = patchCode(contents, getIncrementalCacheRule(cacheHandler));
26
28
  contents = patchCode(contents, forceTrustHostHeader);
29
+ contents = patchCode(contents, createComposableCacheHandlersRule(path.join(outputPath, getPackagePath(buildOpts), "composable-cache.cjs")));
27
30
  return contents;
28
31
  },
29
32
  },
@@ -373,7 +373,12 @@ function populateD1TagCache(buildOpts, config, populateCacheOptions) {
373
373
  const result = runWrangler(buildOpts, [
374
374
  "d1 execute",
375
375
  D1_TAG_BINDING_NAME,
376
- `--command "CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, UNIQUE(tag) ON CONFLICT REPLACE);"`,
376
+ // Columns:
377
+ // tag - The cache tag.
378
+ // revalidatedAt - Timestamp (ms) when the tag was last revalidated.
379
+ // stale - Timestamp (ms) when the cached entry becomes stale. Added in v1.19.
380
+ // expire - Timestamp (ms) when the cached entry expires. NULL means no expire. Added in v1.19.
381
+ `--command "CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, stale INTEGER, expire INTEGER default NULL, UNIQUE(tag) ON CONFLICT REPLACE);"`,
377
382
  `--preview ${populateCacheOptions.shouldUsePreviewId}`,
378
383
  ], {
379
384
  target: populateCacheOptions.target,
@@ -384,6 +389,20 @@ function populateD1TagCache(buildOpts, config, populateCacheOptions) {
384
389
  if (!result.success) {
385
390
  throw new Error(`Wrangler d1 execute command failed${result.stderr ? `:\n${result.stderr}` : ""}`);
386
391
  }
392
+ // Schema migration: add `stale` and `expire` columns (idempotent, safe for existing deployments).
393
+ // The columns were added in v1.19 to support SWR.
394
+ // These commands are intentionally non-throwing — they fail harmlessly if the columns already exist.
395
+ runWrangler(buildOpts, [
396
+ "d1 execute",
397
+ D1_TAG_BINDING_NAME,
398
+ `--command "ALTER TABLE revalidations ADD COLUMN stale INTEGER; ALTER TABLE revalidations ADD COLUMN expire INTEGER default NULL"`,
399
+ `--preview ${populateCacheOptions.shouldUsePreviewId}`,
400
+ ], {
401
+ target: populateCacheOptions.target,
402
+ environment: populateCacheOptions.environment,
403
+ configPath: populateCacheOptions.wranglerConfigPath,
404
+ logging: "error",
405
+ });
387
406
  logger.info("\nSuccessfully created D1 table");
388
407
  }
389
408
  function populateStaticAssetsIncrementalCache(options) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@opennextjs/cloudflare",
3
3
  "description": "Cloudflare builder for next apps",
4
- "version": "1.18.1",
4
+ "version": "1.19.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opennextjs-cloudflare": "dist/cli/index.js"
@@ -44,11 +44,11 @@
44
44
  "dependencies": {
45
45
  "@ast-grep/napi": "^0.40.5",
46
46
  "@dotenvx/dotenvx": "1.31.0",
47
- "@opennextjs/aws": "3.9.16",
47
+ "@opennextjs/aws": "3.10.1",
48
48
  "cloudflare": "^4.4.1",
49
+ "comment-json": "^4.5.1",
49
50
  "enquirer": "^2.4.1",
50
51
  "glob": "^12.0.0",
51
- "comment-json": "^4.5.1",
52
52
  "ts-tqdm": "^0.8.6",
53
53
  "yargs": "^18.0.0"
54
54
  },
@@ -68,7 +68,7 @@
68
68
  "eslint-plugin-unicorn": "^55.0.0",
69
69
  "globals": "^15.9.0",
70
70
  "mock-fs": "^5.4.1",
71
- "next": "~15.5.9",
71
+ "next": "~15.5.15",
72
72
  "picomatch": "^4.0.2",
73
73
  "rimraf": "^6.0.1",
74
74
  "typescript": "^5.9.3",
@@ -76,8 +76,8 @@
76
76
  "vitest": "^2.1.1"
77
77
  },
78
78
  "peerDependencies": {
79
- "wrangler": "^4.65.0",
80
- "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5"
79
+ "next": ">=15.5.15 || >=16.2.3",
80
+ "wrangler": "^4.65.0"
81
81
  },
82
82
  "scripts": {
83
83
  "clean": "rimraf dist",