Skip to content

Commit c2593ef

Browse files
committed
test(testcontainers): add a lagging-replica client for read-your-writes coverage
Wraps a Prisma client to serve stale reads for chosen models (missing rows or frozen snapshots) while writes pass through, so tests can reproduce the replica lag the single-database harness never exhibits.
1 parent 5721351 commit c2593ef

2 files changed

Lines changed: 81 additions & 0 deletions

File tree

internal-packages/testcontainers/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
} from "./utils";
3030

3131
export { assertNonNullable, createPostgresContainer } from "./utils";
32+
export { laggingReplica, type LaggingModel } from "./laggingReplica";
3233
export { logCleanup };
3334
export type { MinIOConnectionConfig };
3435

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// A read-replica that has NOT caught up to its primary, for reproducing read-your-writes hazards the
2+
// zero-lag single-DB test harness can't surface. Wraps a real Prisma client; for the configured
3+
// models it serves STALE reads instead of live ones. Other models and all writes forward untouched.
4+
// Modes: "missing" (row not replicated yet -> null/[]/0, *OrThrow throws); "frozen" (row out of date
5+
// -> returns the provided pre-write snapshot rows, matched on top-level scalar `where` equality).
6+
// `wasHit` asserts the stale replica was actually consulted.
7+
8+
const READ_METHODS = new Set([
9+
"findFirst",
10+
"findUnique",
11+
"findFirstOrThrow",
12+
"findUniqueOrThrow",
13+
"findMany",
14+
"count",
15+
]);
16+
17+
export type LaggingModel =
18+
| { model: string; mode: "missing" }
19+
| { model: string; mode: "frozen"; rows: readonly Record<string, unknown>[] };
20+
21+
// Match a frozen row against a Prisma `where` using top-level scalar equality only (enough for the
22+
// friendlyId / id / environmentId lookups these reads use); nested filters are treated as "matches".
23+
function whereMatches(row: Record<string, unknown>, where: unknown): boolean {
24+
if (where == null || typeof where !== "object") return true;
25+
return Object.entries(where as Record<string, unknown>).every(([key, val]) => {
26+
if (val !== null && typeof val === "object") return true;
27+
return row[key] === val;
28+
});
29+
}
30+
31+
export function laggingReplica<C extends object>(
32+
real: C,
33+
configs: readonly LaggingModel[]
34+
): { client: C; wasHit: (model?: string) => boolean } {
35+
const byModel = new Map(configs.map((c) => [c.model, c]));
36+
const hits = new Set<string>();
37+
38+
const makeModelProxy = (modelName: string, realModel: object, cfg: LaggingModel) =>
39+
new Proxy(realModel, {
40+
get(target, prop) {
41+
if (typeof prop === "string" && READ_METHODS.has(prop)) {
42+
return async (args?: { where?: unknown }) => {
43+
hits.add(modelName);
44+
if (cfg.mode === "missing") {
45+
if (prop === "findMany") return [];
46+
if (prop === "count") return 0;
47+
if (prop === "findFirstOrThrow" || prop === "findUniqueOrThrow") {
48+
throw new Error(`laggingReplica: ${modelName}.${prop} - row not visible on replica yet`);
49+
}
50+
return null;
51+
}
52+
const matched = cfg.rows.filter((r) => whereMatches(r, args?.where));
53+
if (prop === "findMany") return matched;
54+
if (prop === "count") return matched.length;
55+
const first = matched[0] ?? null;
56+
if ((prop === "findFirstOrThrow" || prop === "findUniqueOrThrow") && !first) {
57+
throw new Error(`laggingReplica: ${modelName}.${prop} - no frozen row matched`);
58+
}
59+
return first;
60+
};
61+
}
62+
const value = (target as Record<string | symbol, unknown>)[prop];
63+
return typeof value === "function" ? (value as (...a: unknown[]) => unknown).bind(target) : value;
64+
},
65+
});
66+
67+
const client = new Proxy(real, {
68+
get(target, prop) {
69+
const cfg = typeof prop === "string" ? byModel.get(prop) : undefined;
70+
const delegate = cfg ? (target as Record<string, unknown>)[prop as string] : undefined;
71+
if (cfg && delegate && typeof delegate === "object") {
72+
return makeModelProxy(prop as string, delegate, cfg);
73+
}
74+
const value = (target as Record<string | symbol, unknown>)[prop];
75+
return typeof value === "function" ? (value as (...a: unknown[]) => unknown).bind(target) : value;
76+
},
77+
}) as C;
78+
79+
return { client, wasHit: (model) => (model ? hits.has(model) : hits.size > 0) };
80+
}

0 commit comments

Comments
 (0)