Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2605,3 +2605,23 @@ words — then the story steps and the direct pins in
`test/integration/routes/unsubscribe.test.ts` can assert them through `t()`
instead of pinning copied wording. Out of scope for the migration, which does
not touch `src/`.

## Pin the mirror pair with a table check when processed_payments is next rebuilt

_Origin: the PR #2084 write-site sweep._

`processed_payments` should promise
`(protected_state = '' OR failure_data !=
'')` — a live-work mirror can never
exist without the stored state it mirrors. Deeper checks are impossible because
`failure_data` is encrypted; this weak form is the whole expressible invariant.
Every current writer already keeps it by construction (`paymentRowStateValues`
in `src/shared/db/payment-claim.ts` writes the pair together, and
`reserveSession` derives its blank pair from the same builder), so today the
check would only guard against a future rogue writer. Adding it to an existing
table needs a full table-rebuild migration of a money-adjacent table across
every site — the riskiest change class we ship — which is not worth it for a
state nothing can currently produce. If a real schema change ever rebuilds
`processed_payments` anyway, add this check in the same rebuild: the DDL belongs
on the last column via the `alsoAbout` pattern in
`src/shared/db/migrations/schema/payments/columns.ts`.
3 changes: 2 additions & 1 deletion src/features/admin/schema-atlas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

import { ownerPage } from "#routes/auth.ts";
import { defineRoutes } from "#routes/router.ts";
import { scanJointAnomalies } from "#shared/db/joint-state-scan.ts";
import { settings } from "#shared/db/settings.ts";
import { adminSchemaAtlasPage } from "#templates/admin/schema-atlas.tsx";

const handleSchemaAtlasGet = ownerPage(async (session) =>
adminSchemaAtlasPage(session, settings.theme),
adminSchemaAtlasPage(session, settings.theme, await scanJointAnomalies()),
);

export const adminHandlers = defineRoutes({
Expand Down
19 changes: 19 additions & 0 deletions src/features/api/payment-processing/placeholder-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ import {
import { paymentReferenceIndex } from "#shared/db/payment-reference-store.ts";
import { advanceSessionFailure } from "#shared/db/processed-payments.ts";
import { ErrorCode, logError } from "#shared/logger.ts";
import {
assertJointStateLegal,
authorityFactOf,
jointRowFactOf,
} from "#shared/payment/joint-state.ts";
import {
type PlaceholderRefund,
placeholderRefund,
Expand Down Expand Up @@ -252,6 +257,20 @@ export const resumePlaceholderSession = async (
paidPaymentReferenceOf(session),
session.id,
);
// A resume navigates a combination of machines a crash left behind, so
// prove the combination is one a flow can produce before acting on it.
assertJointStateLegal(
jointRowFactOf(
search.held !== null
? { claim: search.held.claim, outcome: stored }
: { outcome: stored },
false,
),
// A row whose reference carries no charge answers with a null name and
// becomes "absent" here; no rows at all means nothing to check.
search.rows.map((row) => authorityFactOf(row.refundStateName)),
`resume of session ${session.id}`,
);
if (search.held !== null) {
const { claim, record } = search.held;
return await finishPlaceholderRefund(session, {
Expand Down
18 changes: 13 additions & 5 deletions src/features/api/payment-processing/store-refund.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@ export const storeClaimedPlaceholder = async (config: {
config.paymentReference,
config.unrecordedAt,
);
const anchorWritten = Promise.withResolvers<ClaimedPlaceholderAnchor>();
const anchorWritten = Promise.withResolvers<{
readonly attendeeId: number;
readonly claimedAnchor: ClaimedPlaceholderAnchor;
}>();
const stored = await attendeesApi.createAttendeeAtomic(
{ ...config.fields, allowOverbook: true, bookings: config.bookings },
async (tx, attendeeId) => {
Expand Down Expand Up @@ -241,12 +244,17 @@ export const storeClaimedPlaceholder = async (config: {
`Payment session lost its reservation before placeholder creation: ${config.sessionId}`,
);
}
anchorWritten.resolve(claimedAnchor);
anchorWritten.resolve({ attendeeId, claimedAnchor });
},
);
const attendeeId = (stored as Extract<typeof stored, { success: true }>)
.attendees[0]!.id;
return { attendeeId, claimedAnchor: await anchorWritten.promise };
// The overbook-tolerant store must not refuse; a refusal here means the
// atomic callback never ran, so waiting on it would hang forever.
if (!stored.success) {
throw new Error(
`Placeholder store was refused (${stored.reason}) for session ${config.sessionId}`,
);
}
return await anchorWritten.promise;
};

export const storeRefundedBooking = async (
Expand Down
5 changes: 5 additions & 0 deletions src/locales/en/schema-atlas.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"schema.actor.provider": "The payment provider",
"schema.actor.owner": "You",
"schema.widget.hint": "Choose a state to see its ways forward.",
"schema.check.heading": "Live check",
"schema.check.intro": "The machines above promise some combinations can never be stored. This check looks for them in this site's own records.",
"schema.check.none": "All stored payment records fit the rules.",
"schema.check.armed_without_claim": "A refund is set to send, but no job holds this row.",
"schema.check.claim_without_charge": "A job holds this row, but its payment has no charge record.",
"schema.refund.title": "A refund at the payment provider",
"schema.refund.intro": "Each refund the site owes a buyer lives in one record. The record says where the money is and who needs to act next. A refund always sits in exactly one of the states below.",
"schema.refund.state.ready": "Ready to send",
Expand Down
76 changes: 76 additions & 0 deletions src/shared/db/joint-state-scan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Find stored rows whose machines form a declared impossible combination.
* Each query is phrased over the plaintext mirror columns so the scan
* decrypts nothing: a row's live work shows through `protected_state` (every
* claim-holding node mirrors the one claim word), and a charge's state shows
* through `refund_state_name`. The scan is the operator's view of the seam —
* an impossible combination becomes a listed row instead of a debugging
* session.
*/

import { uniqueBy } from "#fp";
import { queryBatch, resultRows } from "#shared/db/client.ts";
import { CLAIM_MIRROR } from "#shared/payment/admit-move.ts";
import { ILLEGAL_JOINT_STATES } from "#shared/payment/joint-state.ts";

/** Which declared entry a found row breaks, named for the catalog. */
export type JointAnomalyKey = "armed_without_claim" | "claim_without_charge";

export interface JointAnomaly {
readonly key: JointAnomalyKey;
readonly sessionId: string;
}

/** Enough rows to show the problem without an unbounded read — a healthy
* site returns none at all. */
const SCAN_LIMIT = 25;

type DeclaredAuthority = (typeof ILLEGAL_JOINT_STATES)[number]["authority"];

interface DeclaredScan {
readonly key: JointAnomalyKey;
readonly sql: string;
}

/** One query per declared authority. The record is keyed by the declaration
* table's own literals, so adding an illegal combination is a compile error
* here until the scan learns how to look for it. */
const SCAN_OF: Record<DeclaredAuthority, DeclaredScan> = {
absent: {
key: "claim_without_charge",
sql: `SELECT payment.payment_session_id
FROM processed_payments AS payment
WHERE payment.protected_state = ?
AND NOT EXISTS (
SELECT 1 FROM payment_charges AS charge
WHERE charge.reference_index = payment.payment_reference_index
)
LIMIT ?`,
},
send_armed: {
key: "armed_without_claim",
sql: `SELECT payment.payment_session_id
FROM payment_charges AS charge
JOIN processed_payments AS payment
ON payment.payment_reference_index = charge.reference_index
WHERE charge.refund_state_name = 'send_armed'
AND payment.protected_state != ?
LIMIT ?`,
},
};

/** Scan the stored rows for every declared impossible combination. */
export const scanJointAnomalies = async (): Promise<JointAnomaly[]> => {
const scans = uniqueBy((scan: DeclaredScan) => scan.key)(
ILLEGAL_JOINT_STATES.map((entry) => SCAN_OF[entry.authority]),
);
const results = await queryBatch(
scans.map((scan) => ({ args: [CLAIM_MIRROR, SCAN_LIMIT], sql: scan.sql })),
);
return scans.flatMap((scan, index) =>
resultRows<{ payment_session_id: string }>(results[index]!).map((row) => ({
key: scan.key,
sessionId: row.payment_session_id,
})),
);
};
15 changes: 13 additions & 2 deletions src/shared/db/processed-payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type SqlStatement,
withTransaction,
} from "#shared/db/client.ts";
import { paymentRowStateValues } from "#shared/db/payment-claim.ts";
import {
storePaymentReference,
unclaimedPaymentReference,
Expand Down Expand Up @@ -131,16 +132,26 @@ export const reserveSession = async (
): Promise<ReserveSessionResult> => {
const claimedAt = nowIso();
const staleBefore = isoBefore(STALE_RESERVATION_MS);
// The blank pair comes from the row-state builder, so a reclaimed row is
// reset to exactly the state every other writer calls empty.
const blank = await paymentRowStateValues(EMPTY_ROW_STATE);
const [claimResult, lookupResult] = await executeBatchWithResults([
{
args: [sessionId, claimedAt, staleBefore],
args: [
sessionId,
claimedAt,
blank.failureData,
blank.protectedState,
staleBefore,
],
sql: `INSERT INTO processed_payments (payment_session_id, attendee_id, processed_at)
VALUES (?, NULL, ?)
ON CONFLICT(payment_session_id) DO UPDATE SET
attendee_id = NULL,
processed_at = excluded.processed_at,
ticket_tokens = '',
failure_data = '',
failure_data = ?,
protected_state = ?,
payment_reference = '',
payment_reference_index = ''
WHERE ${UNRESOLVED_RESERVATION}
Expand Down
Loading