Skip to content

Commit e64e950

Browse files
committed
fix(run-store): reject findRuns take without orderBy across both run tables
An unordered take capped each run table independently and concatenated the two results, so a both-table read could silently drop one table rows once the other filled the cap. Reject it like the existing skip and cursor guards; callers that need a bounded cross-table read pass an orderBy for the keyset merge.
1 parent 3218843 commit e64e950

2 files changed

Lines changed: 46 additions & 10 deletions

File tree

internal-packages/run-store/src/PostgresRunStore.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2400,6 +2400,34 @@ describe("PostgresRunStore — table routing by id format", () => {
24002400
}
24012401
);
24022402

2403+
postgresTest(
2404+
"findRuns rejects `take` without `orderBy` across both tables (non-deterministic cap)",
2405+
async ({ prisma }) => {
2406+
const { environment } = await seedEnvironment(prisma);
2407+
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
2408+
2409+
// A both-table predicate (no id-list) with `take` but no `orderBy` would
2410+
// cap each table independently and silently drop one table's overflow.
2411+
await expect(
2412+
store.findRuns({
2413+
where: { runtimeEnvironmentId: environment.id },
2414+
select: { id: true },
2415+
take: 5,
2416+
})
2417+
).rejects.toThrow(/take.*orderBy/i);
2418+
2419+
// The same read WITH an `orderBy` is a valid bounded cross-table merge.
2420+
await expect(
2421+
store.findRuns({
2422+
where: { runtimeEnvironmentId: environment.id },
2423+
select: { id: true, createdAt: true },
2424+
orderBy: { createdAt: "desc" },
2425+
take: 5,
2426+
})
2427+
).resolves.toBeDefined();
2428+
}
2429+
);
2430+
24032431
postgresTest(
24042432
"findRuns with an id-list partitions by id format and skips the table with no candidate ids",
24052433
async ({ prisma }) => {

internal-packages/run-store/src/PostgresRunStore.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,20 @@ export class PostgresRunStore implements RunStore {
936936
"RunStore.findRuns: a negative `take` (Prisma 'last N') is not supported across both run tables."
937937
);
938938
}
939+
// `take` without `orderBy` across BOTH tables is non-deterministic: each
940+
// table is capped at `take` independently, then the two capped sets are
941+
// concatenated, so once one table fills `take` the other table's rows are
942+
// silently dropped. Reject it (like `skip`/`cursor` above) rather than
943+
// return a result that may omit one table. Add an `orderBy` for a bounded
944+
// cross-table merge, or scope the predicate to a single table.
945+
if (args.take !== undefined && ordered.length === 0) {
946+
throw new Error(
947+
"RunStore.findRuns: `take` without `orderBy` is not supported across both run tables " +
948+
"(each table is capped independently, so the cap is non-deterministic and may omit one " +
949+
"table's rows). Add an `orderBy` for a bounded cross-table merge, or scope the predicate " +
950+
"to a single table."
951+
);
952+
}
939953

940954
// ORDERED + LIMITED → bounded 2-way merge.
941955
if (ordered.length > 0 && args.take !== undefined) {
@@ -960,9 +974,10 @@ export class PostgresRunStore implements RunStore {
960974
return this.#stripAddedKeys(merged, addedKeys);
961975
}
962976

963-
// UNORDERED / NO-LIMIT (or `take` without `orderBy`) → run the SAME args
964-
// against both tables and concatenate. A run is in exactly one table, so
965-
// concatenation is complete and has no duplicates.
977+
// UNORDERED / NO-LIMIT → run the SAME args against both tables and
978+
// concatenate. A run is in exactly one table, so concatenation is complete
979+
// and has no duplicates. (`take` without `orderBy` was rejected above;
980+
// `orderBy` + `take` took the bounded-merge branch above.)
966981
//
967982
// `orderBy` without `take` still needs the order keys projected so the
968983
// whole-set re-sort below can read them.
@@ -985,13 +1000,6 @@ export class PostgresRunStore implements RunStore {
9851000
combined = combined.sort(comparator);
9861001
}
9871002

988-
// `take` without `orderBy`: an unordered cap. Each table was capped at
989-
// `take`, so the concatenation is at most `2*take`; trim to `take`. Order
990-
// among unordered rows is unspecified either way.
991-
if (args.take !== undefined) {
992-
combined = combined.slice(0, args.take);
993-
}
994-
9951003
return this.#stripAddedKeys(combined, addedKeys);
9961004
}
9971005

0 commit comments

Comments
 (0)