refactor: 리펙토링
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* §7.9–§7.10. Common bounded body reader.
|
||||
*
|
||||
* `bounded-json.ts` stays the V2 reader for already-migrated callers; this
|
||||
* module adds the byte-level primitives execution V3 needs: a raw bounded read,
|
||||
* a strict media-type check and the `NONE` one-byte probe.
|
||||
*/
|
||||
|
||||
export type BoundedBytesOutcome =
|
||||
| Readonly<{ ok: true; bytes: Uint8Array }>
|
||||
| Readonly<{ ok: false; code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE" }>;
|
||||
|
||||
export type BodyProbeOutcome =
|
||||
| Readonly<{ ok: true; present: boolean }>
|
||||
| Readonly<{ ok: false; code: "RESPONSE_STREAM_FAILURE" }>;
|
||||
|
||||
/** Essence match: `application/json` or any `*+json` subtype. */
|
||||
export function isJsonMediaType(headerValue: string | null): boolean {
|
||||
if (!headerValue) return false;
|
||||
const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
||||
return essence === "application/json" || essence.endsWith("+json");
|
||||
}
|
||||
|
||||
export function declaredContentLength(response: Response): number | null {
|
||||
const raw = response.headers.get("content-length");
|
||||
if (raw === null) return null;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
export async function readBoundedBytes(
|
||||
response: Response,
|
||||
maximumBytes: number,
|
||||
): Promise<BoundedBytesOutcome> {
|
||||
const declared = declaredContentLength(response);
|
||||
if (declared !== null && declared > maximumBytes) {
|
||||
await cancelBody(response);
|
||||
return failure("RESPONSE_TOO_LARGE");
|
||||
}
|
||||
if (!response.body) {
|
||||
return Object.freeze({ ok: true as const, bytes: new Uint8Array(0) });
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
if (!next.value) continue;
|
||||
total += next.value.byteLength;
|
||||
if (total > maximumBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
return failure("RESPONSE_TOO_LARGE");
|
||||
}
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} catch {
|
||||
await reader.cancel().catch(() => {});
|
||||
return failure("RESPONSE_STREAM_FAILURE");
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// A cancelled reader has already released its lock.
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return Object.freeze({ ok: true as const, bytes });
|
||||
}
|
||||
|
||||
/**
|
||||
* §7.10 `NONE`. A declared positive length is an immediate violation. Otherwise
|
||||
* at most one byte is probed: the descriptor does not permit an unexpected
|
||||
* body, so the runtime never drains an arbitrary amount to find out.
|
||||
*/
|
||||
export async function probeForbiddenBody(
|
||||
response: Response,
|
||||
): Promise<BodyProbeOutcome> {
|
||||
const declared = declaredContentLength(response);
|
||||
if (declared !== null && declared > 0) {
|
||||
await cancelBody(response);
|
||||
return Object.freeze({ ok: true as const, present: true });
|
||||
}
|
||||
if (!response.body) {
|
||||
return Object.freeze({ ok: true as const, present: false });
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
const next = await reader.read();
|
||||
if (next.done || !next.value || next.value.byteLength === 0) {
|
||||
return Object.freeze({ ok: true as const, present: false });
|
||||
}
|
||||
await reader.cancel().catch(() => {});
|
||||
return Object.freeze({ ok: true as const, present: true });
|
||||
} catch {
|
||||
await reader.cancel().catch(() => {});
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
code: "RESPONSE_STREAM_FAILURE" as const,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Already released by cancel().
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type DecodedJson =
|
||||
| Readonly<{ ok: true; value: unknown }>
|
||||
| Readonly<{ ok: false; code: "UTF8_INVALID" | "JSON_INVALID" }>;
|
||||
|
||||
export function decodeJsonBytes(bytes: Uint8Array): DecodedJson {
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return Object.freeze({ ok: false as const, code: "UTF8_INVALID" as const });
|
||||
}
|
||||
try {
|
||||
return Object.freeze({ ok: true as const, value: JSON.parse(text) });
|
||||
} catch {
|
||||
return Object.freeze({ ok: false as const, code: "JSON_INVALID" as const });
|
||||
}
|
||||
}
|
||||
|
||||
export function isEffectivelyEmpty(bytes: Uint8Array): boolean {
|
||||
if (bytes.byteLength === 0) return true;
|
||||
for (const byte of bytes) {
|
||||
// Space, tab, LF, CR are the only permitted "empty" filler.
|
||||
if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function cancelBody(response: Response): Promise<void> {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// Cancelling an already-settled body is not itself a failure.
|
||||
}
|
||||
}
|
||||
|
||||
function failure(
|
||||
code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE",
|
||||
): BoundedBytesOutcome {
|
||||
return Object.freeze({ ok: false as const, code });
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
HTTP_EXECUTION_CEILINGS,
|
||||
type InstalledHttpContract,
|
||||
} from "../../contracts/external-contract-runtime.ts";
|
||||
|
||||
/**
|
||||
* §7.4–§7.7. Descriptor-driven request projection.
|
||||
*
|
||||
* Nothing here re-derives operation semantics. The package descriptor supplies
|
||||
* path values, query entry order and the body value; this module only encodes,
|
||||
* bounds and re-verifies them.
|
||||
*/
|
||||
|
||||
export type CredentialPatchOutcome =
|
||||
| Readonly<{
|
||||
kind: "READY";
|
||||
headers: Readonly<Record<string, string>>;
|
||||
credentials: RequestCredentials;
|
||||
}>
|
||||
| Readonly<{ kind: "UNAUTHENTICATED" }>
|
||||
| Readonly<{ kind: "UNAVAILABLE" }>
|
||||
| Readonly<{ kind: "SCOPE_FENCED" }>;
|
||||
|
||||
/** §7.7. The complete set of headers a credential bridge may contribute. */
|
||||
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([
|
||||
"authorization",
|
||||
"x-csrf-token",
|
||||
"x-tenant-context",
|
||||
]);
|
||||
|
||||
const FORBIDDEN_REQUEST_HEADERS: ReadonlySet<string> = new Set([
|
||||
"host",
|
||||
"origin",
|
||||
"referer",
|
||||
"cookie",
|
||||
"content-length",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
export type RequestProjectionFailure =
|
||||
| "PROJECTION_RUNTIME_FAILURE"
|
||||
| "PROJECTION_INVALID"
|
||||
| "PATH_PLACEHOLDER_MISSING"
|
||||
| "PATH_VALUE_INVALID"
|
||||
| "QUERY_TOO_LARGE"
|
||||
| "URL_TOO_LARGE"
|
||||
| "URL_ORIGIN_ESCAPED"
|
||||
| "REQUEST_BODY_TOO_LARGE"
|
||||
| "REQUEST_BODY_UNENCODABLE"
|
||||
| "REQUEST_BODY_UNEXPECTED";
|
||||
|
||||
export type ProjectedRequest = Readonly<{
|
||||
url: string;
|
||||
method: string;
|
||||
bodyBytes: Uint8Array | null;
|
||||
}>;
|
||||
|
||||
export type RequestProjectionOutcome =
|
||||
| Readonly<{ ok: true; request: ProjectedRequest }>
|
||||
| Readonly<{ ok: false; failure: RequestProjectionFailure }>;
|
||||
|
||||
const PLACEHOLDER = /\{([A-Za-z][A-Za-z0-9_]*)\}|:([A-Za-z][A-Za-z0-9_]*)/g;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* §7.5. Built from the validated base URL and the descriptor's named values.
|
||||
* Segments are encoded exactly once; a raw slash inside a value is rejected
|
||||
* rather than silently creating a new path segment.
|
||||
*/
|
||||
export function projectRequest<Input, WireOutput, Problem>(
|
||||
installed: InstalledHttpContract<Input, WireOutput, Problem>,
|
||||
input: Input,
|
||||
baseUrl: string,
|
||||
): RequestProjectionOutcome {
|
||||
try {
|
||||
return projectRequestChecked(installed, input, baseUrl);
|
||||
} catch {
|
||||
return failure("PROJECTION_RUNTIME_FAILURE");
|
||||
}
|
||||
}
|
||||
|
||||
function projectRequestChecked<Input, WireOutput, Problem>(
|
||||
installed: InstalledHttpContract<Input, WireOutput, Problem>,
|
||||
input: Input,
|
||||
baseUrl: string,
|
||||
): RequestProjectionOutcome {
|
||||
const contract = installed.contract;
|
||||
const projection = contract.projectRequest(input);
|
||||
if (!isValidProjection(projection)) return failure("PROJECTION_INVALID");
|
||||
|
||||
let missing = false;
|
||||
let invalid = false;
|
||||
const path = contract.pathTemplate.replace(
|
||||
PLACEHOLDER,
|
||||
(_match, braced?: string, colon?: string) => {
|
||||
const name = braced ?? colon ?? "";
|
||||
const value = projection.pathValues[name];
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
missing = true;
|
||||
return "";
|
||||
}
|
||||
if (value.includes("/") || value.includes("\\")) {
|
||||
invalid = true;
|
||||
return "";
|
||||
}
|
||||
return encodeURIComponent(value);
|
||||
},
|
||||
);
|
||||
if (missing) return failure("PATH_PLACEHOLDER_MISSING");
|
||||
if (invalid) return failure("PATH_VALUE_INVALID");
|
||||
|
||||
const base = new URL(baseUrl);
|
||||
const url = new URL(path.replace(/^\/+/, ""), base);
|
||||
if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
|
||||
return failure("URL_ORIGIN_ESCAPED");
|
||||
}
|
||||
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of projection.queryEntries) {
|
||||
search.append(key, value);
|
||||
}
|
||||
const encodedQuery = search.toString();
|
||||
if (
|
||||
encoder.encode(encodedQuery).byteLength >
|
||||
HTTP_EXECUTION_CEILINGS.encodedQueryBytes
|
||||
) {
|
||||
return failure("QUERY_TOO_LARGE");
|
||||
}
|
||||
url.search = encodedQuery;
|
||||
|
||||
if (encoder.encode(url.href).byteLength > HTTP_EXECUTION_CEILINGS.finalUrlBytes) {
|
||||
return failure("URL_TOO_LARGE");
|
||||
}
|
||||
|
||||
let bodyBytes: Uint8Array | null = null;
|
||||
if (contract.requestBody === "JSON") {
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = JSON.stringify(projection.body);
|
||||
} catch {
|
||||
return failure("REQUEST_BODY_UNENCODABLE");
|
||||
}
|
||||
if (typeof encoded !== "string") {
|
||||
return failure("REQUEST_BODY_UNENCODABLE");
|
||||
}
|
||||
bodyBytes = encoder.encode(encoded);
|
||||
if (bodyBytes.byteLength > installed.frontend.requestByteLimit) {
|
||||
return failure("REQUEST_BODY_TOO_LARGE");
|
||||
}
|
||||
} else if (projection.body !== null && projection.body !== undefined) {
|
||||
return failure("REQUEST_BODY_UNEXPECTED");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
ok: true as const,
|
||||
request: Object.freeze({
|
||||
url: url.href,
|
||||
method: contract.method,
|
||||
bodyBytes,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function isValidProjection(
|
||||
value: unknown,
|
||||
): value is ReturnType<
|
||||
InstalledHttpContract<unknown, unknown, unknown>["contract"]["projectRequest"]
|
||||
> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const keys = Object.keys(candidate).sort();
|
||||
if (keys.join("|") !== "body|pathValues|queryEntries") return false;
|
||||
|
||||
const pathValues = candidate.pathValues;
|
||||
if (!pathValues || typeof pathValues !== "object" || Array.isArray(pathValues)) {
|
||||
return false;
|
||||
}
|
||||
const pathPrototype = Object.getPrototypeOf(pathValues);
|
||||
if (pathPrototype !== Object.prototype && pathPrototype !== null) return false;
|
||||
const pathEntries = Object.entries(pathValues as Record<string, unknown>);
|
||||
if (pathEntries.length > 32) return false;
|
||||
for (const [key, pathValue] of pathEntries) {
|
||||
if (
|
||||
!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) ||
|
||||
typeof pathValue !== "string" ||
|
||||
pathValue.length === 0 ||
|
||||
encoder.encode(pathValue).byteLength > HTTP_EXECUTION_CEILINGS.pathTemplateBytes
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const queryEntries = candidate.queryEntries;
|
||||
if (!Array.isArray(queryEntries) || queryEntries.length > 256) return false;
|
||||
for (const entry of queryEntries) {
|
||||
if (
|
||||
!Array.isArray(entry) ||
|
||||
entry.length !== 2 ||
|
||||
typeof entry[0] !== "string" ||
|
||||
typeof entry[1] !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export type FinalInvariantInput = Readonly<{
|
||||
request: ProjectedRequest;
|
||||
expectedMethod: string;
|
||||
baseUrl: string;
|
||||
init: RequestInit;
|
||||
headers: Readonly<Record<string, string>>;
|
||||
requestByteLimit: number;
|
||||
deadlineRemainingMs: number;
|
||||
scopeIsCurrent: boolean;
|
||||
}>;
|
||||
|
||||
export type FinalInvariantFailure =
|
||||
| "METHOD_CHANGED"
|
||||
| "URL_NOT_ALLOWED"
|
||||
| "REDIRECT_MODE_INVALID"
|
||||
| "CREDENTIALS_MODE_INVALID"
|
||||
| "HEADER_NOT_ALLOWED"
|
||||
| "FORBIDDEN_HEADER"
|
||||
| "REQUEST_BODY_TOO_LARGE"
|
||||
| "DEADLINE_EXPIRED"
|
||||
| "SCOPE_FENCED";
|
||||
|
||||
/**
|
||||
* §7.4. Runs after the credential patch and immediately before dispatch. A
|
||||
* failure here means `fetch()` is called zero times, so a defective auth
|
||||
* adapter can never alter the method, target or transport policy.
|
||||
*/
|
||||
export function checkFinalInvariants(
|
||||
input: FinalInvariantInput,
|
||||
): FinalInvariantFailure | null {
|
||||
if (input.request.method !== input.expectedMethod) return "METHOD_CHANGED";
|
||||
|
||||
const base = new URL(input.baseUrl);
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(input.request.url);
|
||||
} catch {
|
||||
return "URL_NOT_ALLOWED";
|
||||
}
|
||||
if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname)) {
|
||||
return "URL_NOT_ALLOWED";
|
||||
}
|
||||
if (input.init.redirect !== "error") return "REDIRECT_MODE_INVALID";
|
||||
if (
|
||||
input.init.credentials !== "omit" &&
|
||||
input.init.credentials !== "same-origin" &&
|
||||
input.init.credentials !== "include"
|
||||
) {
|
||||
return "CREDENTIALS_MODE_INVALID";
|
||||
}
|
||||
|
||||
for (const name of Object.keys(input.headers)) {
|
||||
const lower = name.toLowerCase();
|
||||
if (FORBIDDEN_REQUEST_HEADERS.has(lower)) return "FORBIDDEN_HEADER";
|
||||
if (
|
||||
lower !== "accept" &&
|
||||
lower !== "content-type" &&
|
||||
lower !== "idempotency-key" &&
|
||||
!ALLOWED_CREDENTIAL_HEADERS.has(lower)
|
||||
) {
|
||||
return "HEADER_NOT_ALLOWED";
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.request.bodyBytes &&
|
||||
input.request.bodyBytes.byteLength > input.requestByteLimit
|
||||
) {
|
||||
return "REQUEST_BODY_TOO_LARGE";
|
||||
}
|
||||
if (input.deadlineRemainingMs <= 0) return "DEADLINE_EXPIRED";
|
||||
if (!input.scopeIsCurrent) return "SCOPE_FENCED";
|
||||
return null;
|
||||
}
|
||||
|
||||
function failure(failureKind: RequestProjectionFailure): RequestProjectionOutcome {
|
||||
return Object.freeze({ ok: false as const, failure: failureKind });
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type {
|
||||
CommandEffectDescriptor,
|
||||
CommandEffectClassification,
|
||||
} from "../../contracts/external-contract-runtime.ts";
|
||||
|
||||
/**
|
||||
* §8.7–§8.9. Mutation effect certainty.
|
||||
*
|
||||
* The frontend never infers "not applied" from an HTTP status alone. Anything
|
||||
* observed after the request was dispatched but before a classified terminal
|
||||
* response is `MAYBE_APPLIED`, which forbids automatic resend.
|
||||
*/
|
||||
|
||||
export type MutationEffectCertainty =
|
||||
| "NOT_STARTED"
|
||||
| "NOT_APPLIED"
|
||||
| "MAYBE_APPLIED"
|
||||
| "APPLIED_CONFIRMED";
|
||||
|
||||
export type PhysicalAttemptState =
|
||||
| "NOT_STARTED"
|
||||
| "PREPARING"
|
||||
| "READY_TO_SEND"
|
||||
| "DISPATCHED"
|
||||
| "RESPONSE_HEADERS"
|
||||
| "READING_BODY"
|
||||
| "VALIDATING"
|
||||
| "MAPPING_READY"
|
||||
| "SETTLED";
|
||||
|
||||
/**
|
||||
* `READY_TO_SEND` is recorded immediately before entering the `fetch()`
|
||||
* invocation expression and `DISPATCHED` immediately after the promise is
|
||||
* returned. A synchronous throw therefore leaves the attempt `NOT_STARTED`.
|
||||
*/
|
||||
export function certaintyForAbandonedAttempt(
|
||||
state: PhysicalAttemptState,
|
||||
isCommand: boolean,
|
||||
): MutationEffectCertainty {
|
||||
if (!isCommand) return "NOT_STARTED";
|
||||
switch (state) {
|
||||
case "NOT_STARTED":
|
||||
case "PREPARING":
|
||||
case "READY_TO_SEND":
|
||||
return "NOT_STARTED";
|
||||
default:
|
||||
return "MAYBE_APPLIED";
|
||||
}
|
||||
}
|
||||
|
||||
export type ProblemEffectInput<Problem> = Readonly<{
|
||||
status: number;
|
||||
problem: Problem;
|
||||
descriptor: CommandEffectDescriptor<Problem> | null;
|
||||
}>;
|
||||
|
||||
export type ProblemEffectOutcome = Readonly<{
|
||||
effect: MutationEffectCertainty;
|
||||
contractRuntimeFailure: boolean;
|
||||
}>;
|
||||
|
||||
const CLASSIFICATIONS: ReadonlySet<CommandEffectClassification> = new Set([
|
||||
"NOT_APPLIED",
|
||||
"APPLIED_CONFIRMED",
|
||||
"MAYBE_APPLIED",
|
||||
]);
|
||||
|
||||
/**
|
||||
* §4.4. The classifier is package-owned and pure. A throw or an unrecognised
|
||||
* return value fails safe to `MAYBE_APPLIED` and is recorded as a contract
|
||||
* runtime failure rather than being silently treated as "not applied".
|
||||
*/
|
||||
export function classifyProblemEffect<Problem>(
|
||||
input: ProblemEffectInput<Problem>,
|
||||
): ProblemEffectOutcome {
|
||||
if (!input.descriptor) {
|
||||
// A read operation carries no command effect; there is nothing to apply.
|
||||
return Object.freeze({
|
||||
effect: "NOT_STARTED" as const,
|
||||
contractRuntimeFailure: false,
|
||||
});
|
||||
}
|
||||
let classification: CommandEffectClassification;
|
||||
try {
|
||||
classification = input.descriptor.classifyProblem({
|
||||
status: input.status,
|
||||
problem: input.problem,
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
effect: "MAYBE_APPLIED" as const,
|
||||
contractRuntimeFailure: true,
|
||||
});
|
||||
}
|
||||
if (!CLASSIFICATIONS.has(classification)) {
|
||||
return Object.freeze({
|
||||
effect: "MAYBE_APPLIED" as const,
|
||||
contractRuntimeFailure: true,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
effect: classification,
|
||||
contractRuntimeFailure: false,
|
||||
});
|
||||
}
|
||||
|
||||
export type MutationIntent = Readonly<{
|
||||
intentId: string;
|
||||
operationId: string;
|
||||
canonicalInputIdentity: string;
|
||||
idempotencyKey?: string;
|
||||
createdAtMonotonicMs: number;
|
||||
}>;
|
||||
|
||||
export type MutationIntentContext = Readonly<{
|
||||
intentId: string;
|
||||
idempotencyKey?: string;
|
||||
startedBy: "USER" | "FOREGROUND_RETRY" | "OUTBOX_REPLAY";
|
||||
}>;
|
||||
|
||||
export function createMutationIntent(
|
||||
input: Readonly<{
|
||||
operationId: string;
|
||||
canonicalInputIdentity: string;
|
||||
idempotencyKey?: string;
|
||||
monotonicNow?: () => number;
|
||||
}>,
|
||||
): MutationIntent {
|
||||
const now = input.monotonicNow ?? (() => performance.now());
|
||||
return Object.freeze({
|
||||
intentId: crypto.randomUUID(),
|
||||
operationId: input.operationId,
|
||||
canonicalInputIdentity: input.canonicalInputIdentity,
|
||||
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
||||
createdAtMonotonicMs: now(),
|
||||
});
|
||||
}
|
||||
|
||||
/** §8.10. Certainty to UI intent. The copy itself is owned by the i18n catalog. */
|
||||
export function projectCertaintyToUi(
|
||||
certainty: MutationEffectCertainty,
|
||||
): "RETRYABLE" | "CHECK_STATUS" | "SUCCESS" {
|
||||
switch (certainty) {
|
||||
case "APPLIED_CONFIRMED":
|
||||
return "SUCCESS";
|
||||
case "MAYBE_APPLIED":
|
||||
return "CHECK_STATUS";
|
||||
default:
|
||||
return "RETRYABLE";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user