|
| 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