LEG-01. AuthSessionPort.recover now takes the request's lifetime context, and the raw recovery helper returns data only. The sign-out notification moved to the site that adopts the result, so a recovery that answers after the deadline or a caller abort is observed and discarded instead of logging the user out of a request nobody is waiting on. LEG-02. The V2 client shares V3's credential admission validator instead of checking the allowed set alone. A bearer profile whose patch omits, empties, duplicates or corrupts Authorization now fails closed with zero fetches rather than dispatching an anonymous request under an authenticated profile. OPT-NET-01. A cursor loader rejection is re-thrown exactly as it is with no signal at all. Only a signal that has actually aborted classifies the outcome as PAGINATION_ABORTED, so a real upstream failure stops being filed as a user cancellation. OPT-NET-02. defineMutationIntent and the V3 admission site now share the single isValidIdempotencyKey authority, closing the drift that let a control character through intent definition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
191 lines
6.1 KiB
TypeScript
191 lines
6.1 KiB
TypeScript
import type { Result } from "../../application/result.ts";
|
|
import type {
|
|
CursorPage,
|
|
CursorPaginationProfile,
|
|
CursorPaginationRuntime,
|
|
} from "../../contracts/cursor-pagination.ts";
|
|
import { createFailure } from "../../contracts/errors.ts";
|
|
|
|
const ABORTED = Symbol("PAGINATION_ABORTED");
|
|
|
|
/**
|
|
* Resolves as soon as the operation settles or the signal aborts, whichever
|
|
* comes first. A late operation result is observed and discarded, never thrown
|
|
* as an unhandled rejection.
|
|
*
|
|
* OPT-NET-01. A loader rejection is *not* an abort. The presence of a signal
|
|
* says nothing about why the loader failed, so a rejection is re-thrown exactly
|
|
* as it would be with no signal at all; only a signal that has actually
|
|
* aborted classifies the outcome as cancellation.
|
|
*/
|
|
async function raceAbort<Value>(
|
|
operation: Promise<Value>,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Value | typeof ABORTED> {
|
|
operation.catch(() => {});
|
|
if (!signal) return await operation;
|
|
if (signal.aborted) return ABORTED;
|
|
return await new Promise<Value | typeof ABORTED>((resolve, reject) => {
|
|
const onAbort = () => resolve(ABORTED);
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
operation.then(
|
|
(value) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve(value);
|
|
},
|
|
(reason: unknown) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
if (signal.aborted) {
|
|
resolve(ABORTED);
|
|
return;
|
|
}
|
|
reject(reason);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
|
definitionId: string;
|
|
profile: CursorPaginationProfile;
|
|
loadPage(
|
|
cursor: string | null,
|
|
context: Readonly<{ signal?: AbortSignal }>,
|
|
): Promise<Result<CursorPage<Value>>>;
|
|
}>): CursorPaginationRuntime<Value> {
|
|
validateProfile(dependencies.profile);
|
|
return Object.freeze({
|
|
async loadAll(context) {
|
|
const items: Value[] = [];
|
|
const cursors = new Set<string>();
|
|
let cursor: string | null = null;
|
|
let snapshot: string | null | undefined;
|
|
for (
|
|
let pageIndex = 0;
|
|
pageIndex < dependencies.profile.maxPages;
|
|
pageIndex += 1
|
|
) {
|
|
if (context.signal?.aborted) {
|
|
return failure("REQUEST_ABORTED", "PAGINATION_ABORTED");
|
|
}
|
|
// N-10. A non-cooperative loader may never settle, or may settle after
|
|
// abort. Race the signal so `loadAll` is bounded, and re-check before
|
|
// observing the page so a late completion is ignored rather than
|
|
// accumulated into a successful result.
|
|
const raced: Result<CursorPage<Value>> | typeof ABORTED =
|
|
await raceAbort<Result<CursorPage<Value>>>(
|
|
dependencies.loadPage(cursor, context),
|
|
context.signal,
|
|
);
|
|
if (raced === ABORTED || context.signal?.aborted) {
|
|
return failure("REQUEST_ABORTED", "PAGINATION_ABORTED");
|
|
}
|
|
const result: Result<CursorPage<Value>> = raced;
|
|
if (!result.ok) return result;
|
|
const page: CursorPage<Value> = result.value;
|
|
if (!isValidPage(page, dependencies.profile)) {
|
|
return failure(
|
|
"PAGINATION_CONTRACT_VIOLATION",
|
|
"PAGINATION_PAGE_INVALID",
|
|
);
|
|
}
|
|
if (snapshot === undefined) {
|
|
snapshot = page.snapshotToken;
|
|
} else if (snapshot !== page.snapshotToken) {
|
|
return failure(
|
|
"PAGINATION_CONTRACT_VIOLATION",
|
|
"PAGINATION_SNAPSHOT_CHANGED",
|
|
);
|
|
}
|
|
items.push(...page.items);
|
|
if (
|
|
items.length > dependencies.profile.maxTotalItems ||
|
|
estimatedBytes(items) > dependencies.profile.maxEstimatedBytes
|
|
) {
|
|
return failure(
|
|
"RESULT_LIMIT_EXCEEDED",
|
|
"PAGINATION_RESULT_LIMIT",
|
|
);
|
|
}
|
|
if (!page.hasMore) return { ok: true, value: Object.freeze(items) };
|
|
const nextCursor: string | null = page.nextCursor;
|
|
if (!nextCursor || cursors.has(nextCursor)) {
|
|
return failure(
|
|
"PAGINATION_CONTRACT_VIOLATION",
|
|
"PAGINATION_CURSOR_LOOP",
|
|
);
|
|
}
|
|
cursors.add(nextCursor);
|
|
cursor = nextCursor;
|
|
}
|
|
return failure(
|
|
"RESULT_LIMIT_EXCEEDED",
|
|
"PAGINATION_PAGE_LIMIT",
|
|
);
|
|
},
|
|
});
|
|
|
|
function failure(
|
|
kind:
|
|
| "PAGINATION_CONTRACT_VIOLATION"
|
|
| "RESULT_LIMIT_EXCEEDED"
|
|
| "REQUEST_ABORTED",
|
|
code: string,
|
|
) {
|
|
return {
|
|
ok: false as const,
|
|
error: createFailure(kind, dependencies.definitionId, 0, { code }),
|
|
};
|
|
}
|
|
}
|
|
|
|
function validateProfile(profile: CursorPaginationProfile): void {
|
|
if (
|
|
!profile.profileId ||
|
|
!Number.isSafeInteger(profile.maxPages) ||
|
|
profile.maxPages < 1 ||
|
|
profile.maxPages > 100 ||
|
|
!Number.isSafeInteger(profile.maxTotalItems) ||
|
|
profile.maxTotalItems < 1 ||
|
|
!Number.isSafeInteger(profile.maxEstimatedBytes) ||
|
|
profile.maxEstimatedBytes < 1 ||
|
|
!Number.isSafeInteger(profile.maxCursorBytes) ||
|
|
profile.maxCursorBytes < 1 ||
|
|
profile.maxCursorBytes > 4_096
|
|
) {
|
|
throw new TypeError("Invalid cursor pagination profile.");
|
|
}
|
|
}
|
|
|
|
function isValidPage<Value>(
|
|
page: CursorPage<Value>,
|
|
profile: CursorPaginationProfile,
|
|
): boolean {
|
|
const encoder = new TextEncoder();
|
|
return (
|
|
Boolean(page) &&
|
|
Array.isArray(page.items) &&
|
|
typeof page.hasMore === "boolean" &&
|
|
page.hasMore === (page.nextCursor !== null) &&
|
|
(page.nextCursor === null ||
|
|
(typeof page.nextCursor === "string" &&
|
|
page.nextCursor.length > 0 &&
|
|
encoder.encode(page.nextCursor).byteLength <=
|
|
profile.maxCursorBytes)) &&
|
|
(page.snapshotToken === null ||
|
|
(typeof page.snapshotToken === "string" &&
|
|
page.snapshotToken.length > 0 &&
|
|
encoder.encode(page.snapshotToken).byteLength <=
|
|
profile.maxCursorBytes)) &&
|
|
(profile.allowSparsePage || !page.hasMore || page.items.length > 0)
|
|
);
|
|
}
|
|
|
|
function estimatedBytes(value: unknown): number {
|
|
try {
|
|
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
} catch {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
}
|