Merge main — the test-infrastructure fixes from the shared suite

Brings in the truncate retry, the socket fix for the record-fence spec that
failed this branch's second e2e shard, and the corrected absence assertions.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 13:22:43 +02:00
commit 7436a0fcba
18 files changed
+3439 -3081

No files matched your search

+3128 -3012
View File
File diff suppressed because it is too large. Load diff
+21 -21
View File
@@ -9329,14 +9329,14 @@ paths:
tags:
- Insights
summary: List the caller's Coach goal / if-then plans
description: v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each
decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional
target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass
`?status=` to filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active +
review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted
returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled
surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body.
Undecryptable rows are omitted rather than failing the read.
description: v1.21.3 — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on
the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach
extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to
filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past =
met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted returns the
non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s.
Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are
omitted rather than failing the read.
parameters:
- name: status
in: query
@@ -9384,11 +9384,11 @@ paths:
tags:
- Insights
summary: Confirm or update a Coach plan's lifecycle
description: v1.21.3 (B1) — confirm a proposed plan (status proposed → active) or mark it met / abandoned, and
optionally set / clear a review date. The body carries ONLY lifecycle fields — never the metric or the encrypted
free text — so a client can change a plan's status but never inject or overwrite its prose. A foreign / unknown
/ already-deleted id maps to 404 (never 403) so the existence channel does not leak across accounts.
Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.
description: v1.21.3 — confirm a proposed plan (status proposed → active) or mark it met / abandoned, and optionally set
/ clear a review date. The body carries ONLY lifecycle fields — never the metric or the encrypted free text — so
a client can change a plan's status but never inject or overwrite its prose. A foreign / unknown /
already-deleted id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated.
Auth via cookie or Bearer; the owner is narrowed from the session.
parameters:
- name: id
in: path
@@ -9428,9 +9428,9 @@ paths:
tags:
- Insights
summary: Soft-delete one Coach plan
description: "v1.21.3 (B1) — soft-deletes a single plan owned by the caller. An unknown / cross-user / already-deleted
id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another
account. Coach-gated. Auth via cookie or Bearer."
description: "v1.21.3 — soft-deletes a single plan owned by the caller. An unknown / cross-user / already-deleted id is
an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account.
Coach-gated. Auth via cookie or Bearer."
parameters:
- name: id
in: path
@@ -9543,7 +9543,7 @@ paths:
tags:
- Insights
summary: Create a Coach reminder manually
description: v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed
description: v1.22 — create a reminder from the ledger. `note` is the only writable content; `when` is the closed
grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The
per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is
narrowed from the session.
@@ -9580,8 +9580,8 @@ paths:
tags:
- Insights
summary: Confirm or update a Coach reminder's lifecycle
description: v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the
closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown /
description: v1.22 — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the closed
`when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown /
already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie
or Bearer.
parameters:
@@ -9623,8 +9623,8 @@ paths:
tags:
- Insights
summary: Soft-delete one Coach reminder
description: "v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted
id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer."
description: "v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id
is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer."
parameters:
- name: id
in: path
+7 -3
View File
@@ -491,12 +491,16 @@ test.describe("account sharing", () => {
// than on a wall of refusals: the revoke cleared their acting session in
// the same transaction that stamped the row.
await page.goto("/measurements");
// Their own record, back. This goes first: the protected shell holds
// every child — nav, banner, page body — behind its hydration gate until
// `/api/auth/me` resolves, so "no banner" straight after a navigation is
// satisfied by the page still loading and would stay green if the revoke
// had left the acting session standing. The delegate's own marker only
// exists once the record really resolved to theirs.
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toHaveCount(0);
// Their own record, back — which is also the proof that the page did not
// simply fail to load.
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
});
test("the ended grant stays on the record as history", async () => {
+15
View File
@@ -193,6 +193,21 @@ test.describe("delegated writes", () => {
// And the row it created is not theirs to change. Absent, not disabled:
// a `toBeDisabled()` assertion here would pass against the exact design
// this release exists to avoid.
//
// The list has to have painted the reading first. Both layouts stamp
// `measurement-row` and only the mounted one exists, so this holds at
// either viewport and says the rows really arrived — without it every
// count below is a statement about an empty or still-loading list.
const rows = page.getByTestId("measurement-row");
await expect(rows.first()).toBeVisible({ timeout: 30_000 });
// The selection column is the gate. `canManage` is false below MANAGE, so
// no row carries a checkbox — which is what makes the bulk bar
// unreachable rather than merely unrendered. The bar on its own proved
// nothing here: it renders on `count > 0`, and with nothing selected an
// owner meets no bar either, so that assertion could not fail for the
// reason this comment gives.
await expect(rows.getByRole("checkbox")).toHaveCount(0);
await expect(
page.locator('[data-slot="selection-action-bar"]'),
).toHaveCount(0);
+16
View File
@@ -112,10 +112,26 @@ async function enterRecord(page: Page, username: string): Promise<void> {
});
}
/**
* Leave the record and prove the own-record shell is back.
*
* Leaving replaces the document, and the protected shell renders its
* hydration gate — no nav, no banner, no page body — until `/api/auth/me`
* resolves. "The banner is gone" is true of that gate as well, so on its own
* it is satisfied by the document on its way out and is not a wait at all.
* The top bar only exists past the gate, so it is the anchor the absence
* hangs on.
*/
async function leaveRecord(page: Page): Promise<void> {
await withDocumentReplacement(page, () =>
page.locator('[data-slot="shared-record-banner-exit"]').click(),
);
await expect(
page.locator('[data-slot="record-scope-hydration-gate"]'),
).toHaveCount(0, { timeout: 30_000 });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
+9
View File
@@ -373,6 +373,15 @@ test.describe("document vault — AI assist + content search", () => {
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
// The sheet shell paints skeletons while the detail query is pending, so
// its own visibility says nothing about the body having rendered. The
// footer download action belongs to the resolved document, so waiting on
// it is what keeps the two absences below from passing against a
// skeleton that never had the line to begin with.
await expect(
sheet.locator('[data-slot="document-download"]'),
).toBeVisible();
await expect(
sheet.locator('[data-slot="document-detail-ai-read"]'),
).toHaveCount(0);
+6 -1
View File
@@ -311,10 +311,15 @@ test.describe("document vault", () => {
await expect(detail).not.toBeVisible();
await page.reload();
// A reload replaces the document, and the protected shell holds every
// child until `/api/auth/me` resolves, so "the sheet is not visible"
// right here would be a statement about the hold, not about the handoff
// having been consumed. The list row is the first thing that exists only
// once the page really rendered, so it goes first.
await expect(openButton(page, "MRT Knie")).toBeVisible();
await expect(detail).not.toBeVisible();
expect(new URL(page.url()).searchParams.get("doc")).toBeNull();
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
await expect(detail).toBeVisible();
await detail.locator('[data-slot="document-chat-open"]').click();
+12 -14
View File
@@ -114,25 +114,23 @@ test.describe("add measurement flow", () => {
);
await page.goto("/", { waitUntil: "domcontentloaded" });
// Past the route-level skeleton first. `networkidle` says nothing about
// whether the suspended segment resolved, and every locator below lives
// inside the content that replaces it.
await expect(page.locator('[data-slot="dashboard-loading"]')).toHaveCount(
0,
{ timeout: 60_000 },
);
await page.waitForLoadState("networkidle");
// Open the "Add" dropdown — the dashboard's quick-entry trigger sits
// at the top-right of `<main>`. Scope the locator there so we don't
// accidentally match an "Add" button on the sidebar.
const main = page.locator("main");
await openMenu(
page,
main
.getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i })
.first(),
);
const addTrigger = main
.getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i })
.first();
// Wait for the trigger itself rather than for the route-level skeleton to
// go. The skeleton is also absent while the protected shell holds the
// whole page behind its hydration gate, so its disappearance is not a
// statement about the suspended segment having resolved; the control the
// next line clicks is. `networkidle` says nothing about either.
await expect(addTrigger).toBeVisible({ timeout: 60_000 });
await page.waitForLoadState("networkidle");
await openMenu(page, addTrigger);
// v1.5 phase-5: the menu items now have distinct labels — the
// measurement entry says "Measurement" / "Messung" instead of "Add",
+8
View File
@@ -51,6 +51,14 @@ test.describe("medication wizard — one-shot", () => {
(url) => url.pathname === "/medications" && !url.searchParams.has("new"),
);
// The replay this guards against can only happen once the client shell is
// live and the URL effect has run, and until `/api/auth/me` resolves the
// protected shell paints nothing at all — so the absence has to hang on
// the hydrated page. The hero title is the medications page's own
// heading, and it renders only in the settled branch.
await expect(page.locator('[data-tour-id="medications-hero"]')).toBeVisible(
{ timeout: 30_000 },
);
await expect(
page.locator('[data-slot="medication-wizard-dialog"]'),
).toHaveCount(0);
+4 -3
View File
@@ -204,9 +204,10 @@ test("a fresh arrival flips the day in place", async ({ page }) => {
// mean something: a note that was never rendered is also absent at the
// end, and a day that was already final also reads final at the end.
await page.goto("/");
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
0,
);
// No wait on the skeleton being gone: it is also gone while the protected
// shell holds the whole page behind its hydration gate, so it resolves
// before the digest has rendered anything. The hero count below is the
// wait, and it is a wait on the content itself.
const hero = page.locator('[data-slot="today-hero"]');
// Counts rather than visibility, matching the AFTER block: the two are
// then the same assertion with the expected number flipped, and neither
+10 -2
View File
@@ -433,8 +433,16 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
return res.status;
});
expect(left).toBe(200);
const me = await context.request.get("/api/auth/me");
const now = (await me.json()).data.recordSession as {
// Read back through the page for the same reason the switch above goes
// that way. This was the last call in the file still using the shared
// API context, and it duly failed with `read ECONNRESET` on all three
// attempts once the pooled socket had gone stale. The browser opens its
// own connection, and a GET for the caller's own session is outside the
// app either way.
const now = (await page.evaluate(async () => {
const res = await fetch("/api/auth/me");
return (await res.json()).data.recordSession;
})) as {
epoch: number;
scope: string | null;
};
+43 -1
View File
@@ -200,10 +200,52 @@ async function openProfileRecord(page: Page) {
await expect(page).toHaveURL(/\/profile$/);
}
/**
* Run an act that swaps the document out, and wait until it has.
*
* Exiting a record is a full navigation. A token stamped on the window before
* the click is gone the moment the new document exists, which is the only
* signal that the old one is not still the page under test.
*/
async function withDocumentReplacement(
page: Page,
act: () => Promise<void>,
): Promise<void> {
await page.evaluate(() => {
(window as Window & { __hlNavToken?: true }).__hlNavToken = true;
});
await act();
await page.waitForFunction(
() =>
(window as Window & { __hlNavToken?: true }).__hlNavToken === undefined,
undefined,
{ timeout: 30_000 },
);
}
/**
* Leave the record and prove the own-record shell is back.
*
* Leaving replaces the document, and the protected shell renders its
* hydration gate — no nav, no banner, no page body — until `/api/auth/me`
* resolves. "The banner is gone" is true of that gate as well, so on its own
* it is satisfied by the document on its way out and is not a wait at all.
* The top bar only exists past the gate, so it is the anchor the absence
* hangs on.
*/
async function leaveRecord(page: Page) {
await page.locator('[data-slot="shared-record-banner-exit"]').click();
await withDocumentReplacement(page, () =>
page.locator('[data-slot="shared-record-banner-exit"]').click(),
);
await expect(
page.locator('[data-slot="record-scope-hydration-gate"]'),
).toHaveCount(0, { timeout: 30_000 });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
);
}
+35 -4
View File
@@ -145,13 +145,26 @@ async function openRecord(page: Page, username: string) {
return { accountId, accessLevel, recordKind };
}
/**
* Leave the record and prove the own-record shell is back.
*
* Leaving replaces the document, and the protected shell renders its
* hydration gate — no nav, no banner, no page body — until `/api/auth/me`
* resolves. "The banner is gone" is true of that gate as well, so on its own
* it is satisfied by the document on its way out and is not a wait at all.
* The top bar only exists past the gate, so it is the anchor the absence
* hangs on.
*/
async function leaveRecord(page: Page) {
// Leaving is the same act pointed the other way, and it has the same gap.
// The banner being gone is satisfied by the document that is on its way out,
// so on its own it is not a wait at all.
await withDocumentReplacement(page, () =>
page.locator('[data-slot="shared-record-banner-exit"]').click(),
);
await expect(
page.locator('[data-slot="record-scope-hydration-gate"]'),
).toHaveCount(0, { timeout: 30_000 });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
@@ -432,6 +445,19 @@ test.describe.serial("scoped sharing browser journeys", () => {
await expect(banner).toContainText(record.username);
await page.goto("/measurements");
// Two gates stand between the navigation and the page: the shell's own
// hold on `/api/auth/me`, and the route's own `PageAuthGate`. Both paint
// a page with no header action on it, so the absence has to hang on the
// header the button would have sat in. The hero title renders in the same
// `PageHeader` whose `actions` slot the button fills, and only in the
// settled branch, so it says the header really rendered without the
// button rather than not having rendered yet.
await expect(banner).toBeVisible({ timeout: 30_000 });
await expect(
page.locator('[data-tour-id="measurements-hero"]'),
).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-slot="measurement-add"]')).toHaveCount(0);
await page.goto("/settings/account");
@@ -569,8 +595,13 @@ test.describe.serial("scoped sharing browser journeys", () => {
data?: { recordId?: string };
};
expect(integrationPayload.data?.recordId).toBe(accountId);
// The loading branch of this card emits the same family attribute with
// `aria-busy="true"` on it, and a skeleton holds zero controls — so the
// read-only claim below passes against a card that never arrived. The
// settled card is the one that labels itself by its own title id, so the
// locator names that instead of the family alone.
const integrations = page.locator(
'[data-record-settings-family="integrations"]',
'[data-record-settings-family="integrations"][aria-labelledby="managed-integration-status-title"]',
);
await expect(integrations).toBeVisible();
await expect(integrations).toHaveAttribute("data-record-id", accountId);
@@ -68,10 +68,26 @@ async function openRecord(page: Page, username: string) {
});
}
/**
* Leave the record and prove the own-record shell is back.
*
* Leaving replaces the document, and the protected shell renders its
* hydration gate — no nav, no banner, no page body — until `/api/auth/me`
* resolves. "The banner is gone" is true of that gate as well, so on its own
* it is satisfied by the document on its way out and is not a wait at all.
* The top bar only exists past the gate, so it is the anchor the absence
* hangs on.
*/
async function leaveRecord(page: Page) {
await withDocumentReplacement(page, () =>
page.locator('[data-slot="shared-record-banner-exit"]').click(),
);
await expect(
page.locator('[data-slot="record-scope-hydration-gate"]'),
).toHaveCount(0, { timeout: 30_000 });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
+17 -11
View File
@@ -136,19 +136,22 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
// CCH-04 — the per-metric inline launch icon is retired; no header
// launcher renders on the sub-page.
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
0,
);
// The FAB is the permanent launcher; with no unread nudge it
// The FAB is the permanent launcher, and it is also the proof that the
// shell got past its hydration gate: nothing — header, FAB, page body —
// exists until `/api/auth/me` resolves, so an absence asserted above
// this line is a statement about the gate. With no unread nudge the FAB
// renders without the unread dot.
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
0,
);
// The per-metric inline launch icon is retired; no header launcher
// renders on the sub-page.
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
0,
);
});
test(`/insights/${slug} mounts the dot-free Coach FAB on Desktop Chrome`, async ({
@@ -161,15 +164,18 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
0,
);
// Same order as the mobile branch, for the same reason: the FAB is the
// marker that the shell finished hydrating, so the retired header
// launcher is claimed absent only after something has rendered.
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
0,
);
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
0,
);
});
}
@@ -0,0 +1,63 @@
/**
* The integration suite wipes the database between files with one
* `TRUNCATE`, which needs the strictest lock on every table at once. The
* server writes in thirty-nine places without awaiting the result, so one of
* those can land after the test that caused it has returned and take the
* truncate down with a deadlock. Postgres picks a victim; ours was an
* unrelated file, twice in two days, green on the rerun both times.
*
* The helper retries that one error now. These cases hold it to exactly that:
* it gives up on anything else immediately, and it does not retry forever.
*/
import { describe, expect, it, vi } from "vitest";
import { truncateAllTables } from "../../tests/integration/setup";
function deadlock(): Error & { code: string } {
const error = new Error("deadlock detected") as Error & { code: string };
error.code = "40P01";
return error;
}
function fakeClient(executeRaw: ReturnType<typeof vi.fn>) {
const empty = vi.fn().mockResolvedValue([]);
return {
$executeRawUnsafe: executeRaw,
moodTag: { findMany: empty, createMany: vi.fn() },
moodTagCategory: { findMany: empty, createMany: vi.fn() },
} as unknown as Parameters<typeof truncateAllTables>[0];
}
describe("truncateAllTables", () => {
it("retries a deadlocked truncate and succeeds once the lock clears", async () => {
const executeRaw = vi
.fn()
.mockRejectedValueOnce(deadlock())
.mockRejectedValueOnce(deadlock())
.mockResolvedValue(0);
await truncateAllTables(fakeClient(executeRaw));
expect(executeRaw).toHaveBeenCalledTimes(3);
});
it("gives up on an error that is not a deadlock, on the first attempt", async () => {
const other = new Error("permission denied") as Error & { code: string };
other.code = "42501";
const executeRaw = vi.fn().mockRejectedValue(other);
await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow(
"permission denied",
);
expect(executeRaw).toHaveBeenCalledTimes(1);
});
it("stops retrying a deadlock that never clears", async () => {
const executeRaw = vi.fn().mockRejectedValue(deadlock());
await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow(
"deadlock detected",
);
expect(executeRaw).toHaveBeenCalledTimes(5);
});
});
+6 -6
View File
@@ -1604,7 +1604,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "List the caller's Coach goal / if-then plans",
description:
'v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.',
'v1.21.3 — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.',
parameters: [
{
name: "status",
@@ -1645,7 +1645,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Confirm or update a Coach plan's lifecycle",
description:
"v1.21.3 (B1) — confirm a proposed plan (status proposed → active) or mark it met / abandoned, and optionally set / clear a review date. The body carries ONLY lifecycle fields — never the metric or the encrypted free text — so a client can change a plan's status but never inject or overwrite its prose. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
"v1.21.3 — confirm a proposed plan (status proposed → active) or mark it met / abandoned, and optionally set / clear a review date. The body carries ONLY lifecycle fields — never the metric or the encrypted free text — so a client can change a plan's status but never inject or overwrite its prose. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
parameters: [
{
name: "id",
@@ -1688,7 +1688,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Soft-delete one Coach plan",
description:
"v1.21.3 (B1) — soft-deletes a single plan owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.",
"v1.21.3 — soft-deletes a single plan owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.",
parameters: [
{
name: "id",
@@ -1760,7 +1760,7 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Create a Coach reminder manually",
description:
"v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
"v1.22 — create a reminder from the ledger. `note` is the only writable content; `when` is the closed grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
requestBody: {
required: true,
content: {
@@ -1796,7 +1796,7 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Confirm or update a Coach reminder's lifecycle",
description:
"v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie or Bearer.",
"v1.22 — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie or Bearer.",
parameters: [
{
name: "id",
@@ -1839,7 +1839,7 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Soft-delete one Coach reminder",
description:
"v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer.",
"v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer.",
parameters: [
{
name: "id",
+23 -3
View File
@@ -101,9 +101,29 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
}
const quoted = tables.map((t) => `"${t}"`).join(", ");
await client.$executeRawUnsafe(
`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`,
);
// A truncate takes the strictest lock there is on every table at once, so it
// loses to anything still holding a row lock. Thirty-nine places in the
// server write without awaiting the result (a last-used stamp, a delivery
// record, an audit row), and one of those can land after the test that
// triggered it has returned. Postgres then reports a deadlock and kills the
// truncate, which fails an unrelated file and passes on the next run.
// Retrying is the honest answer: a deadlock means somebody else got there
// first, and by the next attempt the stray write has finished. Only 40P01 is
// retried, so a lock held for good still fails as it should.
for (let attempt = 0; ; attempt++) {
try {
await client.$executeRawUnsafe(
`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`,
);
break;
} catch (error) {
const code = (error as { code?: string }).code;
const deadlock =
code === "40P01" || /deadlock detected/i.test(String(error));
if (!deadlock || attempt >= 4) throw error;
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
}
}
// Categories first — the tag rows FK into them.
if (categorySnapshot.length > 0) {