fix(search): probe one row past each cap so a source holding exactly the cap is not reported truncated

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:57:43 +02:00
1 parent 41049b3837
commit 5d7e0248b6
4 files changed
+298 -222

No files matched your search

@@ -80,4 +80,32 @@ describe("loadRecordCandidates under a budget", () => {
expect(out).toHaveLength(10);
expect(truncated).toEqual([]);
});
it("does not call a kind holding exactly its cap truncated", async () => {
prismaMock.encounter.findMany.mockResolvedValue(encounters(5));
const truncated: string[] = [];
const out = await loadRecordCandidates("u1", "visit", "", {
kinds: new Set(["visit"]),
rowsPerKind: 5,
onTruncated: (kind) => truncated.push(kind),
});
expect(out).toHaveLength(5);
expect(truncated).toEqual([]);
// One row past the cap is asked for, as the probe.
expect(prismaMock.encounter.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 6 }),
);
});
it("calls a kind with a row past its cap truncated, and keeps the cap", async () => {
prismaMock.encounter.findMany.mockResolvedValue(encounters(6));
const truncated: string[] = [];
const out = await loadRecordCandidates("u1", "visit", "", {
kinds: new Set(["visit"]),
rowsPerKind: 5,
onTruncated: (kind) => truncated.push(kind),
});
expect(out).toHaveLength(5);
expect(truncated).toEqual(["visit"]);
});
});
+237 -214
View File
@@ -361,66 +361,70 @@ async function visitCandidates(
...(empty ? { occurredAt: { lte: new Date() } } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "asc" }],
take: read.take,
take: probeTake(read),
include: { practitioner: PRACTITIONER_SELECT },
});
return mapUntilStopped(rows, read, (row): SearchCandidate => {
const practitioner = livePractitioner(row.practitioner);
const reason = decryptText(row.reasonEncrypted);
const outcome = decryptText(row.outcomeEncrypted);
const bodySite = decryptText(row.bodySiteEncrypted);
const day = userDayKey(row.occurredAt, owner.timezone);
const kindLabel = encounterKindLabel(row.kind, owner.display);
const title = reason?.replace(/\s+/g, " ").trim() || null;
return {
id: `visit:${row.id}`,
title: visitTitle({
kind: row.kind,
day,
bodySite: bodySite?.replace(/\s+/g, " ").trim() ?? null,
laterality: row.laterality,
practitioner: practitioner?.name ?? null,
status: row.status,
}),
url: recordUrl(origin, "visit", row.id),
kindOrder: KIND_ORDER.visit,
date: row.occurredAt.getTime(),
record: true,
hit: {
kind: "visit",
recordId: row.id,
title: title ?? kindLabel,
context: compact([
title ? kindLabel : null,
practitioner?.name,
sitePhrase(bodySite, row.laterality, owner.display),
]),
day,
path: recordPath("visit", row.id),
},
fields: [
{ text: bodySite ?? "", weight: WEIGHT.primary },
{ text: reason ?? "", weight: WEIGHT.primary, body: true },
{ text: practitioner?.name ?? "", weight: WEIGHT.primary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.primary },
{
text: [
"visit",
enumWords(row.kind),
encounterKindLabel(row.kind, "en"),
encounterKindLabel(row.kind, owner.locale),
ENCOUNTER_KIND_ALIASES[row.kind] ?? "",
].join(" "),
weight: WEIGHT.secondary,
return mapUntilStopped(
withinCap(rows, read),
read,
(row): SearchCandidate => {
const practitioner = livePractitioner(row.practitioner);
const reason = decryptText(row.reasonEncrypted);
const outcome = decryptText(row.outcomeEncrypted);
const bodySite = decryptText(row.bodySiteEncrypted);
const day = userDayKey(row.occurredAt, owner.timezone);
const kindLabel = encounterKindLabel(row.kind, owner.display);
const title = reason?.replace(/\s+/g, " ").trim() || null;
return {
id: `visit:${row.id}`,
title: visitTitle({
kind: row.kind,
day,
bodySite: bodySite?.replace(/\s+/g, " ").trim() ?? null,
laterality: row.laterality,
practitioner: practitioner?.name ?? null,
status: row.status,
}),
url: recordUrl(origin, "visit", row.id),
kindOrder: KIND_ORDER.visit,
date: row.occurredAt.getTime(),
record: true,
hit: {
kind: "visit",
recordId: row.id,
title: title ?? kindLabel,
context: compact([
title ? kindLabel : null,
practitioner?.name,
sitePhrase(bodySite, row.laterality, owner.display),
]),
day,
path: recordPath("visit", row.id),
},
{
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
{ text: outcome ?? "", weight: WEIGHT.secondary, body: true },
],
};
});
fields: [
{ text: bodySite ?? "", weight: WEIGHT.primary },
{ text: reason ?? "", weight: WEIGHT.primary, body: true },
{ text: practitioner?.name ?? "", weight: WEIGHT.primary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.primary },
{
text: [
"visit",
enumWords(row.kind),
encounterKindLabel(row.kind, "en"),
encounterKindLabel(row.kind, owner.locale),
ENCOUNTER_KIND_ALIASES[row.kind] ?? "",
].join(" "),
weight: WEIGHT.secondary,
},
{
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
{ text: outcome ?? "", weight: WEIGHT.secondary, body: true },
],
};
},
);
}
/**
@@ -481,53 +485,57 @@ async function conditionCandidates(
const rows = await prisma.illnessEpisode.findMany({
where: { userId, deletedAt: null },
orderBy: [{ onsetAt: "desc" }, { id: "asc" }],
take: read.take,
take: probeTake(read),
select: CONDITION_SELECT,
});
return mapUntilStopped(rows, read, (row): SearchCandidate => {
const bodySite = decryptText(row.bodySiteEncrypted);
const day = userDayKey(row.onsetAt, owner.timezone);
const site = bodySite?.replace(/\s+/g, " ").trim();
return {
id: `condition:${row.id}`,
title: scrubFenceMarkers(
`${row.label}${site ? ` · ${site}` : ""} (${illnessTypeLabel(row.type, "en")}, since ${day})`,
),
url: recordUrl(origin, "condition", row.id),
kindOrder: KIND_ORDER.condition,
date: row.onsetAt.getTime(),
record: true,
hit: {
kind: "condition",
recordId: row.id,
title: row.label,
context: compact([
illnessTypeLabel(row.type, owner.display),
sitePhrase(bodySite, row.laterality, owner.display),
]),
day,
path: recordPath("condition", row.id),
},
fields: [
{ text: row.label, weight: WEIGHT.primary },
{ text: bodySite ?? "", weight: WEIGHT.primary },
{
text: [
"condition illness",
enumWords(row.type),
illnessTypeLabel(row.type, "en"),
illnessTypeLabel(row.type, owner.locale),
enumWords(row.lifecycle),
].join(" "),
weight: WEIGHT.secondary,
return mapUntilStopped(
withinCap(rows, read),
read,
(row): SearchCandidate => {
const bodySite = decryptText(row.bodySiteEncrypted);
const day = userDayKey(row.onsetAt, owner.timezone);
const site = bodySite?.replace(/\s+/g, " ").trim();
return {
id: `condition:${row.id}`,
title: scrubFenceMarkers(
`${row.label}${site ? ` · ${site}` : ""} (${illnessTypeLabel(row.type, "en")}, since ${day})`,
),
url: recordUrl(origin, "condition", row.id),
kindOrder: KIND_ORDER.condition,
date: row.onsetAt.getTime(),
record: true,
hit: {
kind: "condition",
recordId: row.id,
title: row.label,
context: compact([
illnessTypeLabel(row.type, owner.display),
sitePhrase(bodySite, row.laterality, owner.display),
]),
day,
path: recordPath("condition", row.id),
},
{
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
],
};
});
fields: [
{ text: row.label, weight: WEIGHT.primary },
{ text: bodySite ?? "", weight: WEIGHT.primary },
{
text: [
"condition illness",
enumWords(row.type),
illnessTypeLabel(row.type, "en"),
illnessTypeLabel(row.type, owner.locale),
enumWords(row.lifecycle),
].join(" "),
weight: WEIGHT.secondary,
},
{
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
],
};
},
);
}
const DOCUMENT_SELECT = {
@@ -610,12 +618,12 @@ async function documentCandidates(
includeAiDeferred: boolean,
): Promise<SearchCandidate[]> {
const nameTerms = documentNameTerms(query, words);
const [rows, content, named] = await Promise.all([
const [probed, content, named] = await Promise.all([
prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
select: DOCUMENT_SELECT,
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
take: read.take,
take: probeTake(read),
}),
words.length > 0
? contentMatches(userId, words, includeAiDeferred)
@@ -638,6 +646,9 @@ async function documentCandidates(
})
: Promise.resolve([] as Array<{ id: string }>),
]);
// The cap applies to the newest read; the extra rows below are content and
// name hits beyond it, already bounded by their own read.
const rows = withinCap(probed, read);
// A content or name hit on a document older than the newest `take` still
// counts; the extra read is bounded like the others.
const seen = new Set(rows.map((r) => r.id));
@@ -654,58 +665,51 @@ async function documentCandidates(
})
: [];
// The cap applies to the newest read; the extra rows are content and name
// hits beyond it, already bounded by their own read.
if (rows.length >= read.take) read.onTruncated();
return mapUntilStopped(
[...rows, ...extra],
{ ...read, take: Infinity },
(row): SearchCandidate => {
const dated = row.documentDate ?? row.reportDate;
const kindLabel = documentKindLabel(row.kind, "en");
const name = row.title ?? row.filename ?? kindLabel;
return {
id: `document:${row.id}`,
title: scrubFenceMarkers(
`${name} (${kindLabel}${dated ? `, ${dateOnly(dated)}` : ""})`,
),
url: recordUrl(origin, "document", row.id),
kindOrder: KIND_ORDER.document,
date: (dated ?? row.createdAt).getTime(),
record: true,
hit: {
kind: "document",
recordId: row.id,
title:
row.title ??
row.filename ??
documentKindLabel(row.kind, owner.display),
context: compact([
documentKindLabel(row.kind, owner.display),
row.title ? row.filename : null,
]),
day: dated
? dateOnly(dated)
: userDayKey(row.createdAt, owner.timezone),
path: recordPath("document", row.id),
return mapUntilStopped([...rows, ...extra], read, (row): SearchCandidate => {
const dated = row.documentDate ?? row.reportDate;
const kindLabel = documentKindLabel(row.kind, "en");
const name = row.title ?? row.filename ?? kindLabel;
return {
id: `document:${row.id}`,
title: scrubFenceMarkers(
`${name} (${kindLabel}${dated ? `, ${dateOnly(dated)}` : ""})`,
),
url: recordUrl(origin, "document", row.id),
kindOrder: KIND_ORDER.document,
date: (dated ?? row.createdAt).getTime(),
record: true,
hit: {
kind: "document",
recordId: row.id,
title:
row.title ??
row.filename ??
documentKindLabel(row.kind, owner.display),
context: compact([
documentKindLabel(row.kind, owner.display),
row.title ? row.filename : null,
]),
day: dated
? dateOnly(dated)
: userDayKey(row.createdAt, owner.timezone),
path: recordPath("document", row.id),
},
contentWords: content.get(row.id),
fields: [
{ text: row.title ?? "", weight: WEIGHT.primary },
{ text: row.filename ?? "", weight: WEIGHT.primary },
{
text: [
"document",
enumWords(row.kind),
kindLabel,
documentKindLabel(row.kind, owner.locale),
].join(" "),
weight: WEIGHT.secondary,
},
contentWords: content.get(row.id),
fields: [
{ text: row.title ?? "", weight: WEIGHT.primary },
{ text: row.filename ?? "", weight: WEIGHT.primary },
{
text: [
"document",
enumWords(row.kind),
kindLabel,
documentKindLabel(row.kind, owner.locale),
].join(" "),
weight: WEIGHT.secondary,
},
],
};
},
);
],
};
});
}
async function vaccinationCandidates(
@@ -717,68 +721,72 @@ async function vaccinationCandidates(
const rows = await prisma.vaccinationRecord.findMany({
where: { userId, deletedAt: null },
orderBy: [{ occurredAt: "desc" }, { id: "asc" }],
take: read.take,
take: probeTake(read),
select: VACCINATION_SELECT,
});
return mapUntilStopped(rows, read, (row): SearchCandidate => {
const practitioner = livePractitioner(row.practitioner);
const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug });
const own = ownVaccine(row);
const catalogEn = vaccineCatalogName(row.antigenSlug, "en");
const name =
row.vaccineName ??
catalogEn ??
own?.name ??
row.antigenSlug ??
"Vaccination";
const dose = row.doseNumber ? `dose ${row.doseNumber}, ` : "";
const day = dateOnly(row.occurredAt);
return {
id: `vaccination:${row.id}`,
title: scrubFenceMarkers(`${name} (${dose}${day})`),
url: recordUrl(origin, "vaccination", row.id),
kindOrder: KIND_ORDER.vaccination,
date: row.occurredAt.getTime(),
record: true,
hit: {
kind: "vaccination",
recordId: row.id,
title:
row.vaccineName ??
vaccineCatalogName(row.antigenSlug, owner.display) ??
own?.name ??
row.antigenSlug ??
null,
context: compact([practitioner?.name]),
day,
path: recordPath("vaccination", row.id),
},
fields: [
{ text: row.vaccineName ?? "", weight: WEIGHT.primary },
{
text: [
catalogEn ?? "",
vaccineCatalogName(row.antigenSlug, owner.locale) ?? "",
row.antigenSlug
? enumWords(row.antigenSlug.replace(/-/g, "_"))
: "",
...(entry?.synonyms ?? []),
own?.name ?? "",
...(own?.components ?? []).map((antigen) =>
enumWords(antigen.replace(/-/g, "_")),
),
].join(" "),
weight: WEIGHT.primary,
return mapUntilStopped(
withinCap(rows, read),
read,
(row): SearchCandidate => {
const practitioner = livePractitioner(row.practitioner);
const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug });
const own = ownVaccine(row);
const catalogEn = vaccineCatalogName(row.antigenSlug, "en");
const name =
row.vaccineName ??
catalogEn ??
own?.name ??
row.antigenSlug ??
"Vaccination";
const dose = row.doseNumber ? `dose ${row.doseNumber}, ` : "";
const day = dateOnly(row.occurredAt);
return {
id: `vaccination:${row.id}`,
title: scrubFenceMarkers(`${name} (${dose}${day})`),
url: recordUrl(origin, "vaccination", row.id),
kindOrder: KIND_ORDER.vaccination,
date: row.occurredAt.getTime(),
record: true,
hit: {
kind: "vaccination",
recordId: row.id,
title:
row.vaccineName ??
vaccineCatalogName(row.antigenSlug, owner.display) ??
own?.name ??
row.antigenSlug ??
null,
context: compact([practitioner?.name]),
day,
path: recordPath("vaccination", row.id),
},
{
text: "vaccination vaccine immunization impfung",
weight: WEIGHT.secondary,
},
{ text: practitioner?.name ?? "", weight: WEIGHT.secondary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.secondary },
],
};
});
fields: [
{ text: row.vaccineName ?? "", weight: WEIGHT.primary },
{
text: [
catalogEn ?? "",
vaccineCatalogName(row.antigenSlug, owner.locale) ?? "",
row.antigenSlug
? enumWords(row.antigenSlug.replace(/-/g, "_"))
: "",
...(entry?.synonyms ?? []),
own?.name ?? "",
...(own?.components ?? []).map((antigen) =>
enumWords(antigen.replace(/-/g, "_")),
),
].join(" "),
weight: WEIGHT.primary,
},
{
text: "vaccination vaccine immunization impfung",
weight: WEIGHT.secondary,
},
{ text: practitioner?.name ?? "", weight: WEIGHT.secondary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.secondary },
],
};
},
);
}
export interface RecordSearchOptions {
@@ -818,6 +826,22 @@ interface KindRead {
onTruncated: () => void;
}
/**
* A kind's newest read asks for one row past its cap. The extra row only
* answers whether anything older is left: a kind holding exactly `take` rows
* was read whole and is not reported cut short.
*/
function probeTake(read: KindRead): number {
return read.take + 1;
}
/** The rows within the cap, telling `onTruncated` when the probe row came. */
function withinCap<Row>(rows: Row[], read: KindRead): Row[] {
if (rows.length <= read.take) return rows;
read.onTruncated();
return rows.slice(0, read.take);
}
/**
* Map a kind's rows newest first until `shouldStop` says stop. The rows are
* read already; what this bounds is the decrypting and the building, which is
@@ -828,7 +852,6 @@ function mapUntilStopped<Row, Out>(
read: KindRead,
build: (row: Row) => Out,
): Out[] {
if (rows.length >= read.take) read.onTruncated();
const out: Out[] = [];
for (const row of rows) {
if (read.shouldStop()) {
@@ -79,7 +79,24 @@ describe("scanNewest", () => {
const s = source(10_000, 1_000_000);
await scanNewest({ budget: b, wanted: 3, batch: 100, maxRows: 450, ...s });
expect(b.rows).toBe(450);
expect(s.reads).toEqual([100, 100, 100, 100, 50]);
// The last batch probes one row past the cap and does not keep it.
expect(s.reads).toEqual([100, 100, 100, 100, 51]);
expect(b.truncated).toBe(true);
});
it("does not call a source holding exactly the cap truncated", async () => {
const b = budget();
const s = source(450, 1_000_000);
await scanNewest({ budget: b, wanted: 3, batch: 100, maxRows: 450, ...s });
expect(b.rows).toBe(450);
expect(b.truncated).toBe(false);
});
it("calls a source one row past the cap truncated", async () => {
const b = budget();
const s = source(451, 1_000_000);
await scanNewest({ budget: b, wanted: 3, batch: 100, maxRows: 450, ...s });
expect(b.rows).toBe(450);
expect(b.truncated).toBe(true);
});
+15 -7
View File
@@ -143,22 +143,30 @@ export async function scanNewest<Row extends { id: string }>(args: {
budget.truncated = true;
break;
}
const take = Math.min(batch, maxRows - scanned);
const rows = await read(cursor, take);
const remaining = maxRows - scanned;
// The batch that reaches the cap asks for one row more than it keeps:
// that row only answers whether anything is left, so a source holding
// exactly `maxRows` rows is read whole and not reported cut short.
const last = remaining <= batch;
const take = last ? remaining : batch;
const fetched = await read(cursor, last ? take + 1 : take);
const more = last && fetched.length > take;
const rows = more ? fetched.slice(0, take) : fetched;
scanned += rows.length;
budget.rows += rows.length;
for (const row of rows) {
const candidate = match(row);
if (candidate) out.push(candidate);
}
// A short batch is the end of the source.
if (rows.length < take) break;
// One more than the limit: the caller learns there is more.
if (out.length > wanted) break;
if (scanned >= maxRows) {
if (more) {
budget.truncated = true;
break;
}
// A short batch, or the cap reached with nothing beyond it, is the end
// of the source.
if (rows.length < take || last) break;
// One more than the limit: the caller learns there is more.
if (out.length > wanted) break;
cursor = rows[rows.length - 1].id;
}
return out;