diff --git a/.changeset/prefer-uncached-hyperdrive-after-write.md b/.changeset/prefer-uncached-hyperdrive-after-write.md
new file mode 100644
index 0000000000..84e47ed6e4
--- /dev/null
+++ b/.changeset/prefer-uncached-hyperdrive-after-write.md
@@ -0,0 +1,6 @@
+---
+"emdash": patch
+"@emdash-cms/cloudflare": patch
+---
+
+Fixes anonymous public pages reseeding edge/object caches with stale Hyperdrive query results right after content publishes. When `cachedBinding` is set, public reads prefer the uncached Hyperdrive binding for a short window after content writes (default 60s, overridable via `preferUncachedAfterWriteMs` to match your Hyperdrive max_age).
diff --git a/docs/src/content/docs/deployment/database.mdx b/docs/src/content/docs/deployment/database.mdx
index 0c6ff69052..eca8e89341 100644
--- a/docs/src/content/docs/deployment/database.mdx
+++ b/docs/src/content/docs/deployment/database.mdx
@@ -307,11 +307,14 @@ wrangler hyperdrive create emdash-db \
### Configuration
-| Option | Type | Default | Description |
-| --------------- | -------- | ------------- | -------------------------------------------------------------------- |
-| `binding` | `string` | `"HYPERDRIVE"` | Primary (caching-disabled) Hyperdrive binding name |
-| `cachedBinding` | `string` | — | Optional caching-enabled binding for anonymous reads (see below) |
-| `max` | `number` | `5` | Max size of the in-Worker connection pool to Hyperdrive |
+| Option | Type | Default | Description |
+| ------------------------------ | -------- | -------------- | ---------------------------------------------------------------------------------------------------------------- |
+| `binding` | `string` | `"HYPERDRIVE"` | Primary (caching-disabled) Hyperdrive binding name |
+| `cachedBinding` | `string` | — | Optional caching-enabled binding for anonymous reads (see below) |
+| `preferUncachedAfterWriteMs` | `number` | `60000`\* | After a content publish, prefer `binding` for this many ms on anonymous public reads (match Hyperdrive `max_age`) |
+| `max` | `number` | `5` | Max size of the in-Worker connection pool to Hyperdrive |
+
+\*Default `60000` applies only when `cachedBinding` is set; ignored otherwise.
### Serving anonymous reads from cache
@@ -343,7 +346,7 @@ database: hyperdrive({ binding: "HYPERDRIVE", cachedBinding: "HYPERDRIVE_CACHED"
This is the [two-configuration pattern](https://developers.cloudflare.com/hyperdrive/configuration/query-caching/#disable-caching) Cloudflare documents for caching. EmDash decides which binding to use per request:
-- **Anonymous reads of public-site paths** (`GET`/`HEAD`, no session, not under `/_emdash`) → cache-enabled `cachedBinding`.
+- **Anonymous reads of public-site paths** (`GET`/`HEAD`, no session, not under `/_emdash`) → cache-enabled `cachedBinding`, **except** for a short window after a content publish (default 60s; set `preferUncachedAfterWriteMs` to your Hyperdrive `max_age`) when EmDash prefers the uncached `binding` so a rebuild cannot reseed edge/object caches from still-stale Hyperdrive results.
- **Authenticated requests** (editors, authors) → uncached `binding`.
- **Writes** (`POST`, `PUT`, `DELETE`, including anonymous ones) → uncached `binding`.
- **Any request under `/_emdash`** (admin, setup, auth, internal APIs), even an anonymous `GET` → uncached `binding`.
@@ -357,7 +360,7 @@ This is the [two-configuration pattern](https://developers.cloudflare.com/hyperd
- Both configurations must point at the **same** database, or anonymous visitors see a different dataset than editors. Anonymous reads of just-published content can be up to the cache's `max_age` stale (Hyperdrive default 60s, max 1h), and this cache is independent of EmDash's own cache invalidation. Only opt in if a short public-read staleness window is acceptable; otherwise omit `cachedBinding` and keep caching disabled.
+ Both configurations must point at the **same** database, or anonymous visitors see a different dataset than editors. Set `preferUncachedAfterWriteMs` to your cached config's `max_age` (default 60s) so post-publish public rebuilds read fresh SQL. Outside that window, anonymous public reads can still be up to `max_age` stale relative to non-content writes. Only opt in if that trade-off is acceptable; otherwise omit `cachedBinding` and keep caching disabled.
diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts
index 6f8c768c52..cefd932a2f 100644
--- a/packages/cloudflare/src/db/hyperdrive.ts
+++ b/packages/cloudflare/src/db/hyperdrive.ts
@@ -81,8 +81,18 @@ interface HyperdriveConfig {
* authenticated requests and all writes stay on `binding`.
*/
cachedBinding?: string;
+ /**
+ * After a content publish, prefer the primary uncached binding for this
+ * many ms on anonymous public reads. Defaults to 60_000 when
+ * `cachedBinding` is set (Hyperdrive's default `max_age`); 0 otherwise.
+ * Set equal to your cached Hyperdrive config's `max_age`.
+ */
+ preferUncachedAfterWriteMs?: number;
}
+/** Hyperdrive default query-cache max_age when caching is enabled. */
+const DEFAULT_PREFER_UNCACHED_AFTER_WRITE_MS = 60_000;
+
/**
* Minimal shape of a Hyperdrive binding. Workers inject `connectionString`
* (and the discrete parts) at runtime; we only need the string for pg.
@@ -147,6 +157,8 @@ export interface RequestScopedDbOpts {
isWrite: boolean;
cookies: CookieJar;
url: URL;
+ /** ms-epoch of last content-namespace invalidation; from core object-cache. */
+ lastContentWriteAt?: number;
}
export interface RequestScopedDb {
@@ -182,6 +194,7 @@ export function createRequestScopedDb(opts: RequestScopedDbOpts): RequestScopedD
isAuthenticated: opts.isAuthenticated,
isWrite: opts.isWrite,
url: opts.url,
+ lastContentWriteAt: opts.lastContentWriteAt,
});
let binding = getBinding(bindingName);
// If the cached binding was selected but isn't present at runtime (e.g.
@@ -241,6 +254,17 @@ function isEmDashInternalPath(url: URL): boolean {
return url.pathname === EMDASH_BASE_PATH || url.pathname.startsWith(`${EMDASH_BASE_PATH}/`);
}
+/**
+ * Effective window (ms) to prefer the primary binding after a content write.
+ * Defaults to Hyperdrive's 60s `max_age` when `cachedBinding` is set.
+ */
+function preferUncachedDurationMs(config: HyperdriveConfig): number {
+ if (config.preferUncachedAfterWriteMs !== undefined) {
+ return config.preferUncachedAfterWriteMs;
+ }
+ return config.cachedBinding ? DEFAULT_PREFER_UNCACHED_AFTER_WRITE_MS : 0;
+}
+
/**
* Decide which binding a given request should use.
*
@@ -255,12 +279,20 @@ function isEmDashInternalPath(url: URL): boolean {
* even an anonymous GET → primary. The setup-status and login-state reads
* are anonymous GETs that must see writes made moments earlier; routing them
* to the cache would loop the setup wizard and show stale auth state.
+ * - anonymous public reads within `preferUncachedAfterWriteMs` of the last
+ * content-namespace invalidation → primary, so a post-publish rebuild does
+ * not reseed edge/object caches from Hyperdrive's still-stale query cache.
*
* Pure (no I/O) so the routing rule can be unit-tested directly.
*/
export function selectBindingName(
config: HyperdriveConfig,
- opts: { isAuthenticated: boolean; isWrite: boolean; url: URL },
+ opts: {
+ isAuthenticated: boolean;
+ isWrite: boolean;
+ url: URL;
+ lastContentWriteAt?: number;
+ },
): string {
if (
config.cachedBinding &&
@@ -268,6 +300,11 @@ export function selectBindingName(
!opts.isWrite &&
!isEmDashInternalPath(opts.url)
) {
+ const duration = preferUncachedDurationMs(config);
+ const lastWrite = opts.lastContentWriteAt ?? 0;
+ if (duration > 0 && lastWrite > 0 && Date.now() - lastWrite < duration) {
+ return config.binding;
+ }
return config.cachedBinding;
}
return config.binding;
diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts
index 92354de349..406704f3b3 100644
--- a/packages/cloudflare/src/index.ts
+++ b/packages/cloudflare/src/index.ts
@@ -139,11 +139,11 @@ export interface HyperdriveConfig {
* check, which must observe a write made moments earlier. Migrations and the
* cold-start singleton always use `binding`.
*
- * Anonymous reads of just-published content can be up to the cache's
- * `max_age` stale (Hyperdrive default 60s, max 1h), and this cache is
- * independent of EmDash's own cache invalidation — only opt in if a short
- * public-read staleness window is acceptable. Omit it and the adapter uses
- * the single primary binding as before.
+ * After a content publish, EmDash prefers the primary uncached binding for
+ * anonymous public reads for a short window (see
+ * {@link preferUncachedAfterWriteMs}) so edge/object caches are not reseeded
+ * from Hyperdrive's still-stale query results. Outside that window,
+ * anonymous public reads use this cache-enabled binding.
*
* Bind both configs in wrangler:
* ```jsonc
@@ -157,6 +157,17 @@ export interface HyperdriveConfig {
*/
cachedBinding?: string;
+ /**
+ * How long (ms) after a content write anonymous public reads should use the
+ * primary uncached `binding` instead of `cachedBinding`. Set this equal to
+ * your cached Hyperdrive configuration's `max_age` so the prefer-uncached
+ * window covers the query-cache TTL.
+ *
+ * Only applies when `cachedBinding` is set. Default: `60_000` (Hyperdrive's
+ * default `max_age`) when `cachedBinding` is set; ignored otherwise.
+ */
+ preferUncachedAfterWriteMs?: number;
+
/**
* Maximum size of the in-Worker node-postgres connection pool.
*
@@ -299,12 +310,18 @@ export function d1(config: D1Config): DatabaseDescriptor {
*
* **Optional: serve anonymous reads from cache.** If a short public-read
* staleness window is acceptable, pass a second `cachedBinding` pointing at a
- * caching-enabled Hyperdrive config over the same database. Anonymous read
- * requests then route through the cache-enabled binding while authenticated
- * requests and writes stay on the uncached `binding`, keeping read-after-write
- * consistency intact:
+ * caching-enabled Hyperdrive config over the same database. Anonymous public
+ * reads then route through the cache-enabled binding while authenticated
+ * requests and writes stay on the uncached `binding`. For a short window after
+ * each content publish (default 60s; set `preferUncachedAfterWriteMs` to match
+ * your Hyperdrive `max_age`), anonymous public reads also use the primary so a
+ * rebuild cannot reseed edge caches from stale Hyperdrive results:
* ```ts
- * database: hyperdrive({ binding: "HYPERDRIVE", cachedBinding: "HYPERDRIVE_CACHED" })
+ * database: hyperdrive({
+ * binding: "HYPERDRIVE",
+ * cachedBinding: "HYPERDRIVE_CACHED",
+ * // preferUncachedAfterWriteMs: 60_000, // default when cachedBinding is set
+ * })
* ```
*
* For best latency, pair this with a Smart Placement hint so the Worker runs in
@@ -338,6 +355,9 @@ export function hyperdrive(config: HyperdriveConfig = {}): DatabaseDescriptor {
binding: config.binding ?? "HYPERDRIVE",
max: config.max,
...(config.cachedBinding !== undefined ? { cachedBinding: config.cachedBinding } : {}),
+ ...(config.preferUncachedAfterWriteMs !== undefined
+ ? { preferUncachedAfterWriteMs: config.preferUncachedAfterWriteMs }
+ : {}),
},
type: "postgres",
// Each request gets a fresh pg connection that is closed afterwards —
diff --git a/packages/cloudflare/tests/db/hyperdrive-routing.test.ts b/packages/cloudflare/tests/db/hyperdrive-routing.test.ts
index d28ddc352d..e56c3beef2 100644
--- a/packages/cloudflare/tests/db/hyperdrive-routing.test.ts
+++ b/packages/cloudflare/tests/db/hyperdrive-routing.test.ts
@@ -110,6 +110,82 @@ describe("selectBindingName", () => {
),
).toBe("HYPERDRIVE");
});
+
+ it("uses the primary binding for anonymous public reads soon after a content write", () => {
+ const name = selectBindingName(cfg, {
+ isAuthenticated: false,
+ isWrite: false,
+ url: publicUrl,
+ lastContentWriteAt: Date.now() - 1_000,
+ });
+ expect(name).toBe("HYPERDRIVE");
+ });
+
+ it("uses the cached binding once the prefer-uncached window has elapsed", () => {
+ const name = selectBindingName(cfg, {
+ isAuthenticated: false,
+ isWrite: false,
+ url: publicUrl,
+ lastContentWriteAt: Date.now() - 61_000,
+ });
+ expect(name).toBe("HYPERDRIVE_CACHED");
+ });
+
+ it("uses the cached binding when lastContentWriteAt is zero or missing", () => {
+ expect(
+ selectBindingName(cfg, {
+ isAuthenticated: false,
+ isWrite: false,
+ url: publicUrl,
+ lastContentWriteAt: 0,
+ }),
+ ).toBe("HYPERDRIVE_CACHED");
+ expect(
+ selectBindingName(cfg, {
+ isAuthenticated: false,
+ isWrite: false,
+ url: publicUrl,
+ }),
+ ).toBe("HYPERDRIVE_CACHED");
+ });
+
+ it("respects a custom preferUncachedAfterWriteMs", () => {
+ const custom = {
+ binding: "HYPERDRIVE",
+ cachedBinding: "HYPERDRIVE_CACHED",
+ preferUncachedAfterWriteMs: 5_000,
+ };
+ expect(
+ selectBindingName(custom, {
+ isAuthenticated: false,
+ isWrite: false,
+ url: publicUrl,
+ lastContentWriteAt: Date.now() - 2_000,
+ }),
+ ).toBe("HYPERDRIVE");
+ expect(
+ selectBindingName(custom, {
+ isAuthenticated: false,
+ isWrite: false,
+ url: publicUrl,
+ lastContentWriteAt: Date.now() - 6_000,
+ }),
+ ).toBe("HYPERDRIVE_CACHED");
+ });
+
+ it("ignores lastContentWriteAt when no cachedBinding is set", () => {
+ expect(
+ selectBindingName(
+ { binding: "HYPERDRIVE" },
+ {
+ isAuthenticated: false,
+ isWrite: false,
+ url: publicUrl,
+ lastContentWriteAt: Date.now(),
+ },
+ ),
+ ).toBe("HYPERDRIVE");
+ });
});
describe("createRequestScopedDb binding routing", () => {
@@ -127,6 +203,19 @@ describe("createRequestScopedDb binding routing", () => {
expect(poolCalls[0]!.connectionString).toBe("postgres://replica/cached");
});
+ it("builds the pool from the primary binding when lastContentWriteAt is recent", () => {
+ poolCalls.length = 0;
+ createRequestScopedDb({
+ config: { binding: "HYPERDRIVE", cachedBinding: "HYPERDRIVE_CACHED" },
+ isAuthenticated: false,
+ isWrite: false,
+ cookies,
+ url,
+ lastContentWriteAt: Date.now() - 500,
+ });
+ expect(poolCalls[0]!.connectionString).toBe("postgres://primary/uncached");
+ });
+
it("builds the pool from the primary binding for authenticated reads", () => {
poolCalls.length = 0;
createRequestScopedDb({
diff --git a/packages/cloudflare/tests/hyperdrive-config.test.ts b/packages/cloudflare/tests/hyperdrive-config.test.ts
index 6079fc384c..6bf2c9c1df 100644
--- a/packages/cloudflare/tests/hyperdrive-config.test.ts
+++ b/packages/cloudflare/tests/hyperdrive-config.test.ts
@@ -42,4 +42,18 @@ describe("hyperdrive()", () => {
cachedBinding: "HYPERDRIVE_CACHED",
});
});
+
+ it("passes through preferUncachedAfterWriteMs", () => {
+ const result = hyperdrive({
+ binding: "HYPERDRIVE",
+ cachedBinding: "HYPERDRIVE_CACHED",
+ preferUncachedAfterWriteMs: 120_000,
+ });
+ expect(result.config).toEqual({
+ binding: "HYPERDRIVE",
+ max: undefined,
+ cachedBinding: "HYPERDRIVE_CACHED",
+ preferUncachedAfterWriteMs: 120_000,
+ });
+ });
});
diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts
index 07337359c4..790eea0d58 100644
--- a/packages/core/src/astro/middleware.ts
+++ b/packages/core/src/astro/middleware.ts
@@ -47,6 +47,7 @@ import {
import { setI18nConfig } from "../i18n/config.js";
import type { Database, Storage } from "../index.js";
import { createPublicMediaUrlResolver } from "../media/url.js";
+import { getLastContentWriteAt } from "../object-cache/index.js";
import type { SandboxRunnerFactory } from "../plugins/sandbox/types.js";
import type { ResolvedPlugin } from "../plugins/types.js";
import { invalidateUrlPatternCache } from "../query.js";
@@ -346,6 +347,7 @@ async function runOutsideRequest(
): Promise {
const runtime = await getRuntime(config);
+ const lastContentWriteAt = await getLastContentWriteAt();
const scoped = createRequestScopedDb({
config: config.database?.config,
isAuthenticated: false,
@@ -354,6 +356,7 @@ async function runOutsideRequest(
isWrite: true,
cookies: NOOP_COOKIE_JAR,
url: CRON_EVENT_URL,
+ lastContentWriteAt,
});
if (!scoped?.close) {
// Stateless adapter (or no per-request scoping): the singleton is safe
@@ -647,12 +650,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
// Even on the anonymous fast path we ask the adapter for a per-request
// scoped db. For D1 with read replication this routes anonymous reads
// to the nearest replica; for other adapters it's a no-op.
+ const lastContentWriteAt = await getLastContentWriteAt();
const anonScoped = createRequestScopedDb({
config: config?.database?.config,
isAuthenticated: false,
isWrite: request.method !== "GET" && request.method !== "HEAD",
cookies,
url,
+ lastContentWriteAt,
});
const runAnon = async () => {
const t0 = performance.now();
@@ -855,12 +860,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
// it in ALS so the runtime's db getter and loader's getDb() pick it up,
// then call commit() after next() so the adapter can persist any
// per-request state (e.g. a D1 bookmark cookie for read-your-writes).
+ const lastContentWriteAt = await getLastContentWriteAt();
const scoped = createRequestScopedDb({
config: config?.database?.config,
isAuthenticated: !!sessionUser || hasBearerAuth,
isWrite: request.method !== "GET" && request.method !== "HEAD",
cookies: context.cookies,
url,
+ lastContentWriteAt,
});
const renderAndFinalize = async () => {
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 405b3d3483..d14ccd5d3d 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -199,6 +199,7 @@ export { EmDashStorageError } from "./storage/types.js";
// Object cache (distributed read-through query cache)
export {
cachedQuery,
+ getLastContentWriteAt,
invalidateObjectCache,
invalidateCollectionCache,
invalidateTaxonomyObjectCache,
diff --git a/packages/core/src/object-cache/index.ts b/packages/core/src/object-cache/index.ts
index 3c66cd7d23..b40d05d440 100644
--- a/packages/core/src/object-cache/index.ts
+++ b/packages/core/src/object-cache/index.ts
@@ -131,6 +131,8 @@ function raceInFlightEpochRead(
const BACKEND_KEY = Symbol.for("emdash:object-cache:backend");
const EPOCH_KEY = Symbol.for("emdash:object-cache:epochs");
const PENDING_KEY = Symbol.for("emdash:object-cache:pending-bumps");
+const LAST_CONTENT_WRITE_KEY = Symbol.for("emdash:object-cache:last-content-write");
+const PENDING_CONTENT_WRITE_KEY = Symbol.for("emdash:object-cache:pending-content-write");
const g = globalThis as Record;
const holder: BackendHolder =
@@ -171,6 +173,39 @@ const pendingBumps: Set =
return s;
})();
+/**
+ * Isolate-local ms-epoch of the last content-namespace invalidation, plus a
+ * cached backend read (same revalidate window as epochs). Adapters that need
+ * to prefer fresh SQL after a publish (e.g. Hyperdrive `cachedBinding`) read
+ * this via {@link getLastContentWriteAt}.
+ */
+interface LastContentWriteState {
+ /** Local / merged stamp; 0 if never set in this isolate. */
+ value: number;
+ /** `Date.now()` when `value` was last confirmed from the backend (or local stamp). */
+ at: number;
+ promise?: Promise;
+ promiseAt?: number;
+}
+
+const lastContentWrite: LastContentWriteState =
+ // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see request-context.ts)
+ (g[LAST_CONTENT_WRITE_KEY] as LastContentWriteState | undefined) ??
+ (() => {
+ const s: LastContentWriteState = { value: 0, at: 0 };
+ g[LAST_CONTENT_WRITE_KEY] = s;
+ return s;
+ })();
+
+/** Whether a backend persist of the content-write stamp is already scheduled. */
+const contentWritePersist: { pending: boolean } =
+ // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see request-context.ts)
+ (g[PENDING_CONTENT_WRITE_KEY] as { pending: boolean } | undefined) ??
+ (() => {
+ const s = { pending: false };
+ g[PENDING_CONTENT_WRITE_KEY] = s;
+ return s;
+ })();
/**
* Resolve (once per isolate) the configured object-cache backend.
*
@@ -246,6 +281,19 @@ export function __setObjectCacheBackendForTests(
holder.backend = backend;
holder.config = { ...holder.config, ...config };
epochCache.clear();
+ lastContentWrite.value = 0;
+ lastContentWrite.at = 0;
+ lastContentWrite.promise = undefined;
+ lastContentWrite.promiseAt = undefined;
+ contentWritePersist.pending = false;
+}
+
+/**
+ * Test-only local stamp of the last content-namespace invalidation.
+ * @internal
+ */
+export function __getLastContentWriteAtForTests(): number {
+ return lastContentWrite.value;
}
/** Build the backend key for a namespace's epoch anchor. */
@@ -253,6 +301,14 @@ function epochKey(namespace: string): string {
return `${holder.config.keyPrefix}:epoch:${namespace}`;
}
+/** Backend key for the shared "last content write" stamp (no short TTL). */
+function lastContentWriteKey(): string {
+ return `${holder.config.keyPrefix}:last-content-write-at`;
+}
+
+function isContentNamespace(namespace: string): boolean {
+ return namespace.startsWith("content:");
+}
/**
* Build the (epoch-independent) backend key for a cached value.
*
@@ -474,6 +530,95 @@ export async function isObjectCacheActive(): Promise {
return (await isObjectCacheConfigured()) && !shouldBypass();
}
+/**
+ * Stamp the isolate-local + backend "last content write" marker when a
+ * content collection namespace is invalidated. Used by DB adapters (Hyperdrive)
+ * to briefly prefer uncached SQL after a publish so edge/object caches are not
+ * reseeded from a stale query-cache hit.
+ */
+function stampLastContentWrite(): void {
+ const stamp = Math.max(lastContentWrite.value + 1, Date.now());
+ lastContentWrite.value = stamp;
+ lastContentWrite.at = stamp;
+ // Drop any in-flight backend read so it cannot lower a fresher local stamp.
+ lastContentWrite.promise = undefined;
+ lastContentWrite.promiseAt = undefined;
+
+ if (contentWritePersist.pending) return;
+ contentWritePersist.pending = true;
+ after(async () => {
+ contentWritePersist.pending = false;
+ try {
+ const backend = await getBackend();
+ if (!backend) return;
+ const latest = lastContentWrite.value;
+ // Persistent (no TTL) — same contract as epoch anchors.
+ await backend.set(lastContentWriteKey(), String(latest));
+ } catch (error) {
+ console.error("[object-cache] last-content-write stamp failed:", error);
+ }
+ });
+}
+
+/**
+ * ms-epoch of the last content-namespace invalidation (`content:*`), or `0`
+ * if unknown. Returns `max(local, backend)` so a warm isolate that just
+ * published is immediately correct, and cold isolates learn within their
+ * `revalidate` window after another isolate stamped the backend.
+ */
+export async function getLastContentWriteAt(): Promise {
+ const local = lastContentWrite.value;
+ const now = Date.now();
+ // Cache a confirmed miss (`0`) the same way as a positive stamp — otherwise
+ // every logged-out request re-reads the backend until the first content write.
+ if (now - lastContentWrite.at < holder.config.revalidate) {
+ return local;
+ }
+
+ const backend = await getBackend();
+ if (!backend) return local;
+
+ if (lastContentWrite.promise) {
+ const age = now - (lastContentWrite.promiseAt ?? 0);
+ const deadline = epochReadDeadline();
+ if (age < deadline) {
+ return raceInFlightEpochRead(lastContentWrite.promise, deadline - age, local);
+ }
+ }
+
+ const promise = (async () => {
+ let value: number;
+ try {
+ const raw = await withTimeout(
+ backend.get(lastContentWriteKey()),
+ holder.config.timeout,
+ "last-content-write read",
+ );
+ const parsed = raw === null ? 0 : Number(raw);
+ value = Number.isFinite(parsed) ? parsed : 0;
+ } catch {
+ value = lastContentWrite.value;
+ }
+ const merged = Math.max(value, lastContentWrite.value);
+ lastContentWrite.value = merged;
+ lastContentWrite.at = Date.now();
+ lastContentWrite.promise = undefined;
+ lastContentWrite.promiseAt = undefined;
+ return merged;
+ })();
+
+ after(() =>
+ promise.then(
+ () => undefined,
+ () => undefined,
+ ),
+ );
+
+ lastContentWrite.promise = promise;
+ lastContentWrite.promiseAt = now;
+ return promise;
+}
+
/**
* Invalidate every cached value in `namespace` by bumping its epoch.
*
@@ -481,6 +626,8 @@ export async function isObjectCacheActive(): Promise {
* writing isolate is instantly consistent) and the backend write is deferred
* via `after`. Other isolates pick up the new epoch within their `revalidate`
* window. No-ops when the cache is disabled.
+ *
+ * Content namespaces (`content:*`) also stamp {@link getLastContentWriteAt}.
*/
export function invalidateObjectCache(namespace: string): void {
// Monotonic so two writes in the same millisecond still produce distinct
@@ -491,6 +638,10 @@ export function invalidateObjectCache(namespace: string): void {
// Optimistic local bump: keep this isolate consistent without a round-trip.
epochCache.set(namespace, { value: stamp, at: stamp });
+ if (isContentNamespace(namespace)) {
+ stampLastContentWrite();
+ }
+
// Coalesce repeated bumps of the same namespace within a tick (e.g. a bulk
// publish loop) into a single backend write that persists the latest epoch.
if (pendingBumps.has(namespace)) return;
@@ -509,7 +660,6 @@ export function invalidateObjectCache(namespace: string): void {
}
});
}
-
/**
* Fixed namespaces for data shared across collections. Content reads fold the
* `BYLINES` and `TAXONOMIES` epochs into their keys (via {@link cachedQuery})
diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts
index b853d4125c..a2bdaad580 100644
--- a/packages/core/src/virtual-modules.d.ts
+++ b/packages/core/src/virtual-modules.d.ts
@@ -59,6 +59,12 @@ declare module "virtual:emdash/dialect" {
set(name: string, value: string, options: Record): void;
};
url: URL;
+ /**
+ * ms-epoch of the last content-namespace object-cache invalidation.
+ * Hyperdrive uses this (with `preferUncachedAfterWriteMs`) to briefly
+ * prefer the primary uncached binding after a publish.
+ */
+ lastContentWriteAt?: number;
}
export interface RequestScopedDb {
db: Kysely;
diff --git a/packages/core/tests/unit/object-cache.test.ts b/packages/core/tests/unit/object-cache.test.ts
index e7f64cede0..860e37d51c 100644
--- a/packages/core/tests/unit/object-cache.test.ts
+++ b/packages/core/tests/unit/object-cache.test.ts
@@ -4,8 +4,11 @@ vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual
import { decode, encode } from "../../src/object-cache/codec.js";
import {
+ __getLastContentWriteAtForTests,
__setObjectCacheBackendForTests,
cachedQuery,
+ getLastContentWriteAt,
+ invalidateCollectionCache,
invalidateObjectCache,
type ObjectCacheBackend,
} from "../../src/object-cache/index.js";
@@ -440,3 +443,78 @@ describe("cachedQuery", () => {
expect(load.mock.calls.length).toBe(calls);
});
});
+
+describe("last content write stamp", () => {
+ beforeEach(() => {
+ __setObjectCacheBackendForTests(spyBackend(), { revalidate: 1000, defaultTtl: 3600 });
+ });
+ afterEach(() => {
+ __setObjectCacheBackendForTests(null);
+ });
+
+ it("stamps lastContentWriteAt when invalidating a content: namespace", async () => {
+ expect(__getLastContentWriteAtForTests()).toBe(0);
+ const before = Date.now();
+ invalidateObjectCache("content:posts");
+ const local = __getLastContentWriteAtForTests();
+ expect(local).toBeGreaterThanOrEqual(before);
+ expect(await getLastContentWriteAt()).toBe(local);
+ await flush();
+ });
+
+ it("stamps via invalidateCollectionCache", async () => {
+ invalidateCollectionCache("posts");
+ expect(__getLastContentWriteAtForTests()).toBeGreaterThan(0);
+ });
+
+ it("does not stamp on non-content namespaces", () => {
+ invalidateObjectCache("settings");
+ invalidateObjectCache("menus");
+ invalidateObjectCache("bylines");
+ invalidateObjectCache("taxonomies");
+ invalidateObjectCache("schema");
+ invalidateObjectCache("comments");
+ expect(__getLastContentWriteAtForTests()).toBe(0);
+ });
+
+ it("persists the stamp to the backend under a stable key", async () => {
+ const backend = spyBackend();
+ __setObjectCacheBackendForTests(backend, { revalidate: 1000, defaultTtl: 3600 });
+ invalidateObjectCache("content:posts");
+ const local = __getLastContentWriteAtForTests();
+ await flush();
+ const setKeys = vi.mocked(backend.set).mock.calls.map((c) => c[0]);
+ expect(setKeys).toContain("em:last-content-write-at");
+ expect(backend.store.get("em:last-content-write-at")).toBe(String(local));
+ });
+
+ it("caches a confirmed zero marker within the revalidate window", async () => {
+ const backend = spyBackend();
+ __setObjectCacheBackendForTests(backend, { revalidate: 60_000, defaultTtl: 3600 });
+ expect(await getLastContentWriteAt()).toBe(0);
+ expect(backend.get).toHaveBeenCalledTimes(1);
+ expect(await getLastContentWriteAt()).toBe(0);
+ expect(backend.get).toHaveBeenCalledTimes(1);
+ });
+
+ it("merges a fresher backend stamp on cold isolate read", async () => {
+ const backend = spyBackend();
+ backend.store.set("em:last-content-write-at", "1700000000000");
+ __setObjectCacheBackendForTests(backend, { revalidate: 0, defaultTtl: 3600 });
+ expect(__getLastContentWriteAtForTests()).toBe(0);
+ const got = await getLastContentWriteAt();
+ expect(got).toBe(1_700_000_000_000);
+ expect(__getLastContentWriteAtForTests()).toBe(1_700_000_000_000);
+ });
+
+ it("does not let a stale backend read lower a fresher local stamp", async () => {
+ const backend = spyBackend();
+ backend.store.set("em:last-content-write-at", "100");
+ __setObjectCacheBackendForTests(backend, { revalidate: 0, defaultTtl: 3600 });
+ invalidateObjectCache("content:posts");
+ const local = __getLastContentWriteAtForTests();
+ expect(local).toBeGreaterThan(100);
+ const got = await getLastContentWriteAt();
+ expect(got).toBe(local);
+ });
+});