feat: 기능 추가 과정중

This commit is contained in:
donghyeon-ka
2026-07-30 15:58:20 +09:00
parent d3ef801fe6
commit 6c52cdb916
648 changed files with 126325 additions and 6680 deletions
@@ -0,0 +1,139 @@
import type { Result } from "../../application/result.ts";
import type {
CursorPage,
CursorPaginationProfile,
CursorPaginationRuntime,
} from "../../contracts/cursor-pagination.ts";
import { createFailure } from "../../contracts/errors.ts";
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");
}
const result = await dependencies.loadPage(cursor, context);
if (!result.ok) return result;
const page = 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 = 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;
}
}