Add TypeScript domain and contract bindings

This commit is contained in:
ms2k 2026-07-15 21:00:44 +09:00
commit fecb9a1a8d
8 changed files with 1553 additions and 1 deletions

465
web/src/api/contracts.ts Normal file
View file

@ -0,0 +1,465 @@
import type { JobPhase, RuntimeFailureClass } from '../domain/vocabulary'
export type OpaqueIdDto = string
export interface TargetDto {
store: string
bucket: string
prefix: string
}
export interface TargetOverrideDto {
bucket?: string
prefix?: string
}
export type SourceSubmissionDto =
| HttpSourceSubmissionDto
| ObjectStoreSourceSubmissionDto
| HuggingFaceSourceSubmissionDto
export interface HttpSourceSubmissionDto {
kind: 'http'
url: string
result_path: string
credential_id?: OpaqueIdDto
}
export interface ObjectStoreSourceSubmissionDto {
kind: 'object_store'
store: string
bucket: string
key: string
version_id?: string
result_path: string
credential_id?: OpaqueIdDto
}
export interface HuggingFaceSelectionDto {
paths?: string[]
prefixes?: string[]
}
export interface HuggingFaceSourceSubmissionDto {
kind: 'hugging_face'
repository_type: 'model' | 'dataset' | 'space'
repository_id: string
revision: string
selection?: HuggingFaceSelectionDto
credential_id?: OpaqueIdDto
}
export interface JobSubmissionDto {
source: SourceSubmissionDto
target?: TargetOverrideDto
label?: string
}
export interface AttemptAuthorityDto {
attempt_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
}
export interface ProgressDto {
attempt_id: OpaqueIdDto
fence: number
completed_bytes: number
total_bytes?: number | null
completed_members: number
total_members?: number | null
observed_at: string
}
export type JobFailureDto =
| {
class:
| 'validation'
| 'authorization'
| 'source_not_found'
| 'source_changed'
| 'cancelled'
message: string
retryable: false
safe_details?: Record<string, string | number | boolean | null>
}
| {
class:
| 'transient_source'
| 'transient_target'
| 'integrity'
| 'capacity'
| 'internal'
| 'lost_attempt'
message: string
retryable: true
safe_details?: Record<string, string | number | boolean | null>
}
export interface AttemptOutcomeRequestDto {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
outcome:
| { kind: 'success'; result: ResultDescriptorDto }
| { kind: 'failure'; failure: AttemptRuntimeFailureDto }
}
export interface AttemptRuntimeFailureDto {
class: RuntimeFailureClass
message: string
safe_details?: Record<string, string | number | boolean | null>
}
export interface ResultDescriptorDto {
kind: 'manifest'
manifest: ObjectIdentityDto
member_count: number
total_bytes: number
}
export interface ObjectIdentityDto {
store: string
bucket: string
key: string
version_id?: string
checksum: string
}
export interface JobPhaseProjectionDto {
phase: JobPhase
}
interface EventBaseDto {
schema_version: 1
event_id: OpaqueIdDto
sequence: number
cursor: string
occurred_at: string
}
export type EventFrameDto =
JobChangedDto | DeletionChangedDto | ResyncRequiredDto | HeartbeatDto
export interface JobChangedDto extends EventBaseDto {
kind: 'job_changed'
job_id: OpaqueIdDto
job_revision: number
phase: JobPhase
lifecycle: 'visible' | 'soft_deleted' | 'hard_deleting' | 'hard_deleted'
authoritative_attempt: { attempt_id: OpaqueIdDto; fence: number } | null
progress: EventProgressDto | null
}
export type EventProgressDto = Omit<ProgressDto, 'observed_at'>
export interface DeletionChangedDto extends EventBaseDto {
kind: 'deletion_changed'
job_id: OpaqueIdDto
operation_id: OpaqueIdDto
state: 'queued' | 'running' | 'retry_wait' | 'blocked' | 'completed'
}
export interface ResyncRequiredDto extends EventBaseDto {
kind: 'resync_required'
reason:
| 'cursor_absent'
| 'cursor_invalid'
| 'cursor_expired'
| 'authorization_changed'
| 'history_unavailable'
}
export interface HeartbeatDto extends EventBaseDto {
kind: 'heartbeat'
}
export type SourceCredentialKindDto =
| 'basic_auth'
| 'bearer_token'
| 'http_headers'
| 'object_store'
| 'hugging_face'
export interface BasicAuthMaterialDto {
username: string
password: string
}
export interface TokenMaterialDto {
token: string
}
export interface HttpHeadersMaterialDto {
headers: Record<string, string>
}
export interface ObjectStoreCredentialMaterialDto {
access_key_id: string
secret_access_key: string
session_token?: string
}
export type CredentialMaterialDto =
| BasicAuthMaterialDto
| TokenMaterialDto
| HttpHeadersMaterialDto
| ObjectStoreCredentialMaterialDto
export type SourceCredentialCreateDto =
| { kind: 'basic_auth'; label: string; material: BasicAuthMaterialDto }
| { kind: 'bearer_token'; label: string; material: TokenMaterialDto }
| { kind: 'http_headers'; label: string; material: HttpHeadersMaterialDto }
| {
kind: 'object_store'
label: string
material: ObjectStoreCredentialMaterialDto
}
| { kind: 'hugging_face'; label: string; material: TokenMaterialDto }
export interface SourceCredentialRotateDto {
material: CredentialMaterialDto
}
export interface SourceCredentialVersionDto {
id: OpaqueIdDto
created_at: string
}
export interface CredentialGrantVersionDto {
credential_id: OpaqueIdDto
version_id: OpaqueIdDto
}
export interface SourceCredentialDto {
id: OpaqueIdDto
owner: OpaqueIdDto
label: string
kind: SourceCredentialKindDto
status: 'active' | 'revoked'
active_version: SourceCredentialVersionDto | null
created_at: string
updated_at: string
}
export interface SourceCredentialPageDto {
items: SourceCredentialDto[]
next_cursor: string | null
}
export type SourceProjectionDto =
| HttpSourceProjectionDto
| ObjectStoreSourceProjectionDto
| HuggingFaceSourceProjectionDto
export interface HttpSourceProjectionDto {
kind: 'http'
display_url: string
result_path: string
credential_id?: OpaqueIdDto
}
export interface ObjectStoreSourceProjectionDto {
kind: 'object_store'
store: string
bucket: string
key: string
version_id?: string
result_path: string
credential_id?: OpaqueIdDto
}
export interface HuggingFaceSourceProjectionDto {
kind: 'hugging_face'
repository_type: 'model' | 'dataset' | 'space'
repository_id: string
revision: string
selection?: HuggingFaceSelectionDto
credential_id?: OpaqueIdDto
}
export interface LeaseProjectionDto {
expires_at: string
renew_after: string
}
export interface ProgressValueDto {
completed_bytes: number
total_bytes?: number | null
completed_members: number
total_members?: number | null
}
export interface CheckpointProjectionDto {
reusable: boolean
validated_bytes: number
validated_members: number
}
export interface DurableDescriptorRefDto {
id: OpaqueIdDto
kind: 'resolved_source' | 'checkpoint'
schema_version: number
byte_length: number
item_count?: number
checksum: string
}
export interface ResultMemberDto {
id: OpaqueIdDto
path: string
size: number
object: ObjectIdentityDto
}
export interface ResultMemberPageDto {
items: ResultMemberDto[]
next_cursor: string | null
}
export interface JobDto {
resource_kind: 'job'
id: OpaqueIdDto
owner: OpaqueIdDto
label: string | null
revision: number
phase: JobPhase
lifecycle: 'visible' | 'soft_deleted' | 'hard_deleting' | 'hard_deleted'
source: SourceProjectionDto
target: TargetDto
authoritative_attempt: AttemptAuthorityDto | null
progress: ProgressDto | null
checkpoint: CheckpointProjectionDto | null
result: ResultDescriptorDto | null
failure: JobFailureDto | null
created_at: string
updated_at: string
event_cursor: string
}
export interface JobTombstoneDto {
resource_kind: 'tombstone'
id: OpaqueIdDto
owner: OpaqueIdDto
target: TargetDto
terminal_phase: 'Succeeded' | 'Failed' | 'Cancelled'
deletion_operation_id: OpaqueIdDto
created_at: string
hard_delete_started_at: string
updated_at: string
event_cursor: string
}
export type JobResourceDto = JobDto | JobTombstoneDto
export interface JobPageDto {
items: JobResourceDto[]
next_cursor: string | null
event_cursor: string
}
export interface AttemptRecordDto {
id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
state:
| 'AttemptAuthorized'
| 'AttemptExecuting'
| 'AttemptStopped'
| 'AttemptCommitted'
progress: ProgressDto | null
result: ResultDescriptorDto | null
failure: JobFailureDto | null
created_at: string
updated_at: string
}
export interface AttemptPageDto {
items: AttemptRecordDto[]
next_cursor: string | null
}
export interface DeletionProblemDto {
class: 'authorization' | 'target_unavailable' | 'configuration' | 'internal'
message: string
}
export interface DeletionOperationDto {
id: OpaqueIdDto
job_id: OpaqueIdDto
state: 'queued' | 'running' | 'retry_wait' | 'blocked' | 'completed'
problem: DeletionProblemDto | null
created_at: string
updated_at: string
}
export interface ApiErrorDto {
code:
| 'invalid_request'
| 'unauthenticated'
| 'forbidden'
| 'not_found'
| 'idempotency_conflict'
| 'precondition_failed'
| 'cursor_invalid'
| 'cursor_expired'
| 'rate_limited'
| 'unavailable'
| 'internal'
message: string
request_id: OpaqueIdDto
fields?: { pointer: string; code: string }[]
}
export type StagingTargetDto = TargetDto
export interface ClaimRequestDto {
operation_id: OpaqueIdDto
worker_incarnation: OpaqueIdDto
}
export interface ExecutionEnvelopeDto {
id: OpaqueIdDto
job_id: OpaqueIdDto
attempt_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
source: SourceProjectionDto
sensitive_source_ref: OpaqueIdDto
credential_version: CredentialGrantVersionDto | null
resolved_source_ref: DurableDescriptorRefDto | null
target: TargetDto
staging_target: StagingTargetDto
execution_grant_ref: OpaqueIdDto
checkpoint_ref: DurableDescriptorRefDto | null
}
export interface ClaimResponseDto {
authority: AttemptAuthorityDto
execution: ExecutionEnvelopeDto
}
export interface ActivationResponseDto extends ClaimResponseDto {
lease: LeaseProjectionDto
}
export interface AttemptMutationDto {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
}
export type AttemptRenewRequestDto = AttemptMutationDto
export type AttemptActivateRequestDto = AttemptMutationDto
export interface AttemptResolvedSourceRequestDto extends AttemptMutationDto {
resolved_source_ref: DurableDescriptorRefDto & { kind: 'resolved_source' }
}
export interface AttemptCheckpointRequestDto extends AttemptMutationDto {
checkpoint_ref: DurableDescriptorRefDto & { kind: 'checkpoint' }
}
export interface AttemptProgressRequestDto extends AttemptMutationDto {
progress: ProgressValueDto
}

525
web/src/api/decode.ts Normal file
View file

@ -0,0 +1,525 @@
import { runtimeFailureClass, type JobPhase } from '../domain/vocabulary'
import { opaqueId } from '../domain/validation'
import type {
AttemptOutcomeRequestDto,
EventFrameDto,
EventProgressDto,
HuggingFaceSelectionDto,
JobFailureDto,
JobSubmissionDto,
ObjectIdentityDto,
ResultDescriptorDto,
SourceSubmissionDto,
TargetOverrideDto,
} from './contracts'
export function decodeJobFailure(value: unknown): JobFailureDto {
const object = record(value)
exactKeys(object, ['class', 'message', 'retryable', 'safe_details'])
const shared = {
message: string(object.message),
...(object.safe_details === undefined
? {}
: { safe_details: safeDetails(object.safe_details) }),
}
if (object.retryable === false) {
return {
class: permanentFailureClass(object.class),
retryable: false,
...shared,
}
}
if (object.retryable === true) {
return {
class: retryableFailureClass(object.class),
retryable: true,
...shared,
}
}
throw new Error('invalid failure retryability')
}
export function decodeJobSubmission(value: unknown): JobSubmissionDto {
const object = record(value)
exactKeys(object, ['source', 'target', 'label'])
const decoded: JobSubmissionDto = {
source: decodeSourceSubmission(object.source),
}
if (object.target !== undefined)
decoded.target = decodeTargetOverride(object.target)
if (object.label !== undefined) decoded.label = string(object.label)
return decoded
}
export function decodeEventFrame(value: unknown): EventFrameDto {
const object = record(value)
const base = {
schema_version: schemaVersion(object.schema_version),
event_id: opaqueId(string(object.event_id)),
sequence: positiveInteger(object.sequence),
cursor: cursor(object.cursor),
occurred_at: dateTime(object.occurred_at),
}
switch (object.kind) {
case 'job_changed':
exactKeys(object, [
...Object.keys(base),
'kind',
'job_id',
'job_revision',
'phase',
'lifecycle',
'authoritative_attempt',
'progress',
])
return {
...base,
kind: 'job_changed',
job_id: opaqueId(string(object.job_id)),
job_revision: positiveInteger(object.job_revision),
phase: jobPhase(object.phase),
lifecycle: lifecycle(object.lifecycle),
authoritative_attempt:
object.authoritative_attempt === null
? null
: decodeEventAttempt(object.authoritative_attempt),
progress:
object.progress === null
? null
: decodeEventProgress(object.progress),
}
case 'deletion_changed':
exactKeys(object, [
...Object.keys(base),
'kind',
'job_id',
'operation_id',
'state',
])
return {
...base,
kind: 'deletion_changed',
job_id: opaqueId(string(object.job_id)),
operation_id: opaqueId(string(object.operation_id)),
state: deletionState(object.state),
}
case 'resync_required':
exactKeys(object, [...Object.keys(base), 'kind', 'reason'])
return {
...base,
kind: 'resync_required',
reason: resyncReason(object.reason),
}
case 'heartbeat':
exactKeys(object, [...Object.keys(base), 'kind'])
return { ...base, kind: 'heartbeat' }
default:
throw new Error('unknown event frame kind')
}
}
export function decodeAttemptOutcome(value: unknown): AttemptOutcomeRequestDto {
const object = record(value)
exactKeys(object, ['operation_id', 'fence', 'worker_incarnation', 'outcome'])
const outcome = record(object.outcome)
const mutation = {
operation_id: opaqueId(string(object.operation_id)),
fence: positiveInteger(object.fence),
worker_incarnation: opaqueId(string(object.worker_incarnation)),
}
if (outcome.kind === 'success') {
exactKeys(outcome, ['kind', 'result'])
return {
...mutation,
outcome: { kind: 'success', result: decodeResult(outcome.result) },
}
}
if (outcome.kind === 'failure') {
exactKeys(outcome, ['kind', 'failure'])
const failure = record(outcome.failure)
exactKeys(failure, ['class', 'message', 'safe_details'])
return {
...mutation,
outcome: {
kind: 'failure',
failure: {
class: runtimeFailureClass(string(failure.class)),
message: string(failure.message),
...(failure.safe_details === undefined
? {}
: { safe_details: safeDetails(failure.safe_details) }),
},
},
}
}
throw new Error('unknown attempt outcome kind')
}
function decodeSourceSubmission(value: unknown): SourceSubmissionDto {
const object = record(value)
const credential =
object.credential_id === undefined
? {}
: { credential_id: opaqueId(string(object.credential_id)) }
switch (object.kind) {
case 'http':
exactKeys(object, ['kind', 'url', 'result_path', 'credential_id'])
return {
kind: 'http',
url: string(object.url),
result_path: string(object.result_path),
...credential,
}
case 'object_store':
exactKeys(object, [
'kind',
'store',
'bucket',
'key',
'version_id',
'result_path',
'credential_id',
])
return {
kind: 'object_store',
store: string(object.store),
bucket: string(object.bucket),
key: string(object.key),
...(object.version_id === undefined
? {}
: { version_id: string(object.version_id) }),
result_path: string(object.result_path),
...credential,
}
case 'hugging_face':
exactKeys(object, [
'kind',
'repository_type',
'repository_id',
'revision',
'selection',
'credential_id',
])
return {
kind: 'hugging_face',
repository_type: repositoryType(object.repository_type),
repository_id: string(object.repository_id),
revision: string(object.revision),
...(object.selection === undefined
? {}
: { selection: decodeSelection(object.selection) }),
...credential,
}
default:
throw new Error('unknown source submission kind')
}
}
function decodeTargetOverride(value: unknown): TargetOverrideDto {
const object = record(value)
exactKeys(object, ['bucket', 'prefix'])
if (object.bucket === undefined && object.prefix === undefined)
throw new Error('target override must not be empty')
return {
...(object.bucket === undefined ? {} : { bucket: string(object.bucket) }),
...(object.prefix === undefined ? {} : { prefix: string(object.prefix) }),
}
}
function decodeSelection(value: unknown): HuggingFaceSelectionDto {
const object = record(value)
exactKeys(object, ['paths', 'prefixes'])
if (object.paths === undefined && object.prefixes === undefined)
throw new Error('selection must not be empty')
return {
...(object.paths === undefined ? {} : { paths: stringArray(object.paths) }),
...(object.prefixes === undefined
? {}
: { prefixes: stringArray(object.prefixes) }),
}
}
function decodeEventAttempt(value: unknown): {
attempt_id: string
fence: number
} {
const object = record(value)
exactKeys(object, ['attempt_id', 'fence'])
return {
attempt_id: opaqueId(string(object.attempt_id)),
fence: positiveInteger(object.fence),
}
}
function decodeEventProgress(value: unknown): EventProgressDto {
const object = record(value)
exactKeys(object, [
'attempt_id',
'fence',
'completed_bytes',
'total_bytes',
'completed_members',
'total_members',
])
return {
attempt_id: opaqueId(string(object.attempt_id)),
fence: positiveInteger(object.fence),
completed_bytes: nonnegativeInteger(object.completed_bytes),
...(object.total_bytes === undefined
? {}
: { total_bytes: nullableNonnegativeInteger(object.total_bytes) }),
completed_members: nonnegativeInteger(object.completed_members),
...(object.total_members === undefined
? {}
: { total_members: nullableNonnegativeInteger(object.total_members) }),
}
}
function decodeResult(value: unknown): ResultDescriptorDto {
const object = record(value)
exactKeys(object, ['kind', 'manifest', 'member_count', 'total_bytes'])
if (object.kind !== 'manifest') throw new Error('unknown result kind')
return {
kind: 'manifest',
manifest: decodeObjectIdentity(object.manifest),
member_count: positiveInteger(object.member_count),
total_bytes: nonnegativeInteger(object.total_bytes),
}
}
function decodeObjectIdentity(value: unknown): ObjectIdentityDto {
const object = record(value)
exactKeys(object, ['store', 'bucket', 'key', 'version_id', 'checksum'])
return {
store: string(object.store),
bucket: string(object.bucket),
key: string(object.key),
...(object.version_id === undefined
? {}
: { version_id: string(object.version_id) }),
checksum: string(object.checksum),
}
}
function safeDetails(
value: unknown,
): Record<string, string | number | boolean | null> {
const object = record(value)
const output: Record<string, string | number | boolean | null> = {}
for (const [key, member] of Object.entries(object)) {
if (
member !== null &&
typeof member !== 'string' &&
typeof member !== 'boolean' &&
!(typeof member === 'number' && Number.isSafeInteger(member))
) {
throw new Error('invalid safe detail value')
}
output[key] = member
}
return output
}
function exactKeys(object: Record<string, unknown>, allowed: string[]): void {
const allowedKeys = new Set(allowed)
if (Object.keys(object).some((key) => !allowedKeys.has(key)))
throw new Error('unknown DTO field')
}
function record(value: unknown): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value))
throw new Error('expected object')
return Object.fromEntries(Object.entries(value))
}
function string(value: unknown): string {
if (typeof value !== 'string') throw new Error('expected string')
return value
}
function cursor(value: unknown): string {
const decoded = string(value)
const length = Array.from(decoded).length
if (length < 16 || length > 2048) throw new Error('invalid cursor length')
return decoded
}
function dateTime(value: unknown): string {
const decoded = string(value)
const pattern =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/
const match = pattern.exec(decoded)
if (match === null) throw new Error('invalid RFC 3339 date-time')
const [
,
yearText,
monthText,
dayText,
hourText,
minuteText,
secondText,
offsetHourText,
offsetMinuteText,
] = match
const year = Number(yearText)
const month = Number(monthText)
const day = Number(dayText)
const hour = Number(hourText)
const minute = Number(minuteText)
const second = Number(secondText)
const offsetHour = offsetHourText === undefined ? 0 : Number(offsetHourText)
const offsetMinute =
offsetMinuteText === undefined ? 0 : Number(offsetMinuteText)
if (
month < 1 ||
month > 12 ||
day < 1 ||
day > daysInMonth(year, month) ||
hour > 23 ||
minute > 59 ||
second > 59 ||
offsetHour > 23 ||
offsetMinute > 59 ||
Number.isNaN(Date.parse(decoded))
)
throw new Error('invalid RFC 3339 date-time')
return decoded
}
function daysInMonth(year: number, month: number): number {
if (month === 2)
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28
return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31
}
function stringArray(value: unknown): string[] {
if (!Array.isArray(value)) throw new Error('expected string array')
return value.map(string)
}
function positiveInteger(value: unknown): number {
const integer = nonnegativeInteger(value)
if (integer === 0) throw new Error('expected positive integer')
return integer
}
function nonnegativeInteger(value: unknown): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
throw new Error('expected nonnegative integer')
return value
}
function nullableNonnegativeInteger(value: unknown): number | null {
return value === null ? null : nonnegativeInteger(value)
}
function schemaVersion(value: unknown): 1 {
if (value !== 1) throw new Error('unsupported schema version')
return value
}
function jobPhase(value: unknown): JobPhase {
if (
value !== 'Queued' &&
value !== 'Running' &&
value !== 'RetryWait' &&
value !== 'Cancelling' &&
value !== 'Succeeded' &&
value !== 'Failed' &&
value !== 'Cancelled'
)
throw new Error('unknown job phase')
return value
}
function lifecycle(
value: unknown,
): 'visible' | 'soft_deleted' | 'hard_deleting' | 'hard_deleted' {
if (
value !== 'visible' &&
value !== 'soft_deleted' &&
value !== 'hard_deleting' &&
value !== 'hard_deleted'
)
throw new Error('unknown job lifecycle')
return value
}
function repositoryType(value: unknown): 'model' | 'dataset' | 'space' {
if (value !== 'model' && value !== 'dataset' && value !== 'space')
throw new Error('unknown repository type')
return value
}
function deletionState(
value: unknown,
): 'queued' | 'running' | 'retry_wait' | 'blocked' | 'completed' {
if (
value !== 'queued' &&
value !== 'running' &&
value !== 'retry_wait' &&
value !== 'blocked' &&
value !== 'completed'
)
throw new Error('unknown deletion state')
return value
}
function resyncReason(
value: unknown,
):
| 'cursor_absent'
| 'cursor_invalid'
| 'cursor_expired'
| 'authorization_changed'
| 'history_unavailable' {
if (
value !== 'cursor_absent' &&
value !== 'cursor_invalid' &&
value !== 'cursor_expired' &&
value !== 'authorization_changed' &&
value !== 'history_unavailable'
)
throw new Error('unknown resync reason')
return value
}
function permanentFailureClass(
value: unknown,
):
| 'validation'
| 'authorization'
| 'source_not_found'
| 'source_changed'
| 'cancelled' {
if (
value !== 'validation' &&
value !== 'authorization' &&
value !== 'source_not_found' &&
value !== 'source_changed' &&
value !== 'cancelled'
)
throw new Error('invalid permanent failure class')
return value
}
function retryableFailureClass(
value: unknown,
):
| 'transient_source'
| 'transient_target'
| 'integrity'
| 'capacity'
| 'internal'
| 'lost_attempt' {
if (
value !== 'transient_source' &&
value !== 'transient_target' &&
value !== 'integrity' &&
value !== 'capacity' &&
value !== 'internal' &&
value !== 'lost_attempt'
)
throw new Error('invalid retryable failure class')
return value
}

View file

@ -0,0 +1,217 @@
import { describe, expect, it } from 'vitest'
import corpusJson from '../../../contracts/golden/domain.json'
import type {
AttemptOutcomeRequestDto,
EventFrameDto,
JobSubmissionDto,
} from '../api/contracts'
import {
decodeAttemptOutcome,
decodeEventFrame,
decodeJobFailure,
decodeJobSubmission,
} from '../api/decode'
import { canonicalJson, jsonValue, requestFingerprint } from './fingerprint'
import {
failureDisposition,
isJobPhase,
isTerminalPhase,
jobPhases,
runtimeFailureClass,
} from './vocabulary'
import {
bucketName,
memberPath,
storageScopeCovers,
targetPrefix,
} from './validation'
const corpus = corpusJson
const dtoCompileCases = [
{
source: {
kind: 'http',
url: 'https://example.invalid/file',
result_path: 'file.bin',
},
},
] as const satisfies readonly JobSubmissionDto[]
const eventCompileCase = {
schema_version: 1,
kind: 'heartbeat',
event_id: 'event_0001',
sequence: 1,
cursor: 'cursor-value-0001',
occurred_at: '2026-07-15T00:00:00Z',
} as const satisfies EventFrameDto
const outcomeCompileCase = {
operation_id: 'operation_01',
fence: 1,
worker_incarnation: 'worker_0001',
outcome: {
kind: 'failure',
failure: {
class: 'transient_source',
message: 'source temporarily unavailable',
},
},
} as const satisfies AttemptOutcomeRequestDto
describe('shared domain corpus', () => {
it('preserves protocol phase and failure vocabulary', () => {
expect(corpus.job_phases).toEqual(jobPhases)
expect(corpus.job_phases.every(isJobPhase)).toBe(true)
expect(
corpus.job_phases
.slice(0, 4)
.every((phase) => isJobPhase(phase) && !isTerminalPhase(phase)),
).toBe(true)
expect(
corpus.job_phases
.slice(4)
.every((phase) => isJobPhase(phase) && isTerminalPhase(phase)),
).toBe(true)
for (const failure of corpus.runtime_failures) {
expect(failureDisposition(runtimeFailureClass(failure.class))).toBe(
failure.disposition,
)
}
})
it('validates and normalizes shared domain values', () => {
for (const value of corpus.buckets.valid)
expect(bucketName(value)).toBe(value)
for (const value of corpus.buckets.invalid) {
expect(() => bucketName(value)).toThrow('invalid bucket name')
}
for (const value of corpus.target_prefixes.valid) {
expect(targetPrefix(value.input, [])).toBe(value.canonical)
}
for (const value of corpus.target_prefixes.invalid) {
expect(() => targetPrefix(value, [])).toThrow('invalid target prefix')
}
for (const value of corpus.reserved_target_prefixes) {
expect(() => targetPrefix(value.input, value.reserved)).toThrow(
'target prefix uses a reserved segment',
)
}
for (const value of corpus.member_paths.valid) {
expect(memberPath(value.input)).toBe(value.canonical)
}
for (const value of corpus.member_paths.invalid) {
expect(() => memberPath(value)).toThrow('invalid result member path')
}
})
it('matches RFC 8785 canonical request fingerprints', async () => {
for (const value of corpus.fingerprints) {
const request = jsonValue(value.value)
expect(canonicalJson(request)).toBe(value.canonical)
await expect(requestFingerprint(request)).resolves.toBe(value.sha256)
}
expect(() => jsonValue('\ud800')).toThrow(
'canonical JSON does not permit lone surrogates',
)
expect(() => jsonValue({ '\udc00': true })).toThrow(
'canonical JSON does not permit lone surrogates',
)
})
it('uses exact target tuples and path segments for scope coverage', () => {
for (const value of corpus.scope_coverage) {
const scope = {
store: value.scope[0],
bucket: bucketName(value.scope[1]),
prefix: targetPrefix(value.scope[2], []),
}
const target = {
store: value.target[0],
bucket: bucketName(value.target[1]),
prefix: targetPrefix(value.target[2], []),
}
expect(storageScopeCovers(scope, target)).toBe(value.covers)
}
})
it('round trips mutable job submission DTOs', () => {
for (const value of corpus.job_submissions) {
expect(decodeJobSubmission(value)).toEqual(value)
}
for (const value of corpus.event_frames) {
expect(decodeEventFrame(value)).toEqual(value)
}
for (const value of corpus.attempt_outcomes) {
expect(decodeAttemptOutcome(value)).toEqual(value)
}
for (const value of corpus.job_failures) {
expect(decodeJobFailure(value)).toEqual(value)
}
expect(dtoCompileCases).toHaveLength(1)
expect(eventCompileCase.kind).toBe('heartbeat')
expect(outcomeCompileCase.outcome.kind).toBe('failure')
})
it('rejects contradictory job failure dispositions', () => {
expect(() =>
decodeJobFailure({
class: 'source_not_found',
message: 'missing',
retryable: true,
}),
).toThrow('invalid retryable failure class')
expect(() =>
decodeJobFailure({
class: 'transient_target',
message: 'unavailable',
retryable: false,
}),
).toThrow('invalid permanent failure class')
})
it('rejects WebSocket values outside the schema', () => {
expect(() =>
decodeEventFrame({
schema_version: 2,
kind: 'heartbeat',
event_id: 'event_0001',
sequence: 1,
cursor: 'cursor-value-0001',
occurred_at: '2026-07-15T00:00:00Z',
}),
).toThrow('unsupported schema version')
expect(() =>
decodeEventFrame({
schema_version: 1,
kind: 'heartbeat',
event_id: 'event_0001',
sequence: 0,
cursor: 'short',
occurred_at: 'not-a-date',
}),
).toThrow('expected positive integer')
expect(() =>
decodeEventFrame({
schema_version: 1,
kind: 'heartbeat',
event_id: 'event_0001',
sequence: 1,
cursor: 'cursor-value-0001',
occurred_at: '2026-02-30T00:00:00Z',
}),
).toThrow('invalid RFC 3339 date-time')
expect(() =>
decodeEventFrame({
schema_version: 1,
kind: 'heartbeat',
event_id: 'event_0001',
sequence: 1,
cursor: 'cursor-value-0001',
occurred_at: '2026-01-01T24:00:00Z',
}),
).toThrow('invalid RFC 3339 date-time')
})
})

View file

@ -0,0 +1,77 @@
export type JsonValue =
null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
export function jsonValue(value: unknown): JsonValue {
if (value === null || typeof value === 'boolean') return value
if (typeof value === 'string') {
assertValidUnicode(value)
return value
}
if (typeof value === 'number') {
if (!Number.isFinite(value))
throw new Error('canonical JSON does not permit non-finite numbers')
return value
}
if (Array.isArray(value)) return value.map(jsonValue)
if (typeof value === 'object') {
const prototype = Object.getPrototypeOf(value)
if (prototype !== Object.prototype && prototype !== null)
throw new Error('value is not JSON')
return Object.fromEntries(
Object.entries(value).map(([key, member]) => {
assertValidUnicode(key)
return [key, jsonValue(member)]
}),
)
}
throw new Error('value is not JSON')
}
export function canonicalJson(value: JsonValue): string {
if (
value === null ||
typeof value === 'boolean' ||
typeof value === 'string'
) {
if (typeof value === 'string') assertValidUnicode(value)
return JSON.stringify(value)
}
if (typeof value === 'number') {
if (!Number.isFinite(value))
throw new Error('canonical JSON does not permit non-finite numbers')
return JSON.stringify(value)
}
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
return `{${Object.keys(value)
.map((key) => {
assertValidUnicode(key)
return key
})
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
.join(',')}}`
}
function assertValidUnicode(value: string): void {
for (let index = 0; index < value.length; index += 1) {
const codeUnit = value.charCodeAt(index)
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
const next = value.charCodeAt(index + 1)
if (index + 1 >= value.length || next < 0xdc00 || next > 0xdfff)
throw new Error('canonical JSON does not permit lone surrogates')
index += 1
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
throw new Error('canonical JSON does not permit lone surrogates')
}
}
}
export async function requestFingerprint(value: JsonValue): Promise<string> {
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(canonicalJson(value)),
)
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
}

46
web/src/domain/ports.ts Normal file
View file

@ -0,0 +1,46 @@
import type { OpaqueId } from './validation'
export interface Clock<Instant> {
now(): Instant
}
export interface DurableMutationStore<Request, Response> {
execute(
scope: MutationScope,
request: Request,
requestFingerprint: string,
): Promise<Response>
}
export interface CoordinationStore<Claim> {
claim(
workerIncarnation: OpaqueId,
operationId: OpaqueId,
): Promise<Claim | undefined>
}
export interface IdentityProvider<Principal> {
authenticate(bearerToken: string): Promise<Principal>
}
export interface CredentialResolver<Secret> {
resolve(credentialId: OpaqueId, versionId: OpaqueId): Promise<Secret>
}
export interface SourceAdapter<Request, ResolvedSource> {
resolve(request: Request): Promise<ResolvedSource>
}
export interface StorageAdapter<Request, StoredObject> {
create(request: Request): Promise<StoredObject>
}
export interface EventSink<Event> {
publish(event: Event): Promise<void>
}
export interface MutationScope {
subject: OpaqueId
operation: string
key: string
}

View file

@ -0,0 +1,139 @@
declare const domainValue: unique symbol
type DomainValue<Name extends string> = string & {
readonly [domainValue]: Name
}
export type OpaqueId = DomainValue<'OpaqueId'>
export type BucketName = DomainValue<'BucketName'>
export type TargetPrefix = DomainValue<'TargetPrefix'>
export type MemberPath = DomainValue<'MemberPath'>
export interface Target {
store: string
bucket: BucketName
prefix: TargetPrefix
}
export interface StorageScope extends Target {}
const opaqueIdPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
const bucketPattern = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/
const categoryCPattern = /\p{C}/u
export function opaqueId(value: string): OpaqueId {
assertOpaqueId(value)
return value
}
export function bucketName(value: string): BucketName {
assertBucketName(value)
return value
}
function assertOpaqueId(value: string): asserts value is OpaqueId {
if (!opaqueIdPattern.test(value)) throw new Error('invalid opaque identifier')
}
function assertBucketName(value: string): asserts value is BucketName {
if (!bucketPattern.test(value) || value.includes('..'))
throw new Error('invalid bucket name')
}
export function targetPrefix(
value: string,
reservedSegments: readonly string[],
): TargetPrefix {
if (
value === '/' ||
value.startsWith('/') ||
value.includes('//') ||
value.includes('\\')
)
throw new Error('invalid target prefix')
const withoutTrailingSeparator = value.endsWith('/')
? value.slice(0, -1)
: value
if (withoutTrailingSeparator === '') {
const empty = ''
assertTargetPrefix(empty)
return empty
}
const prefix = normalizePath(withoutTrailingSeparator, false)
const reserved = new Set(
reservedSegments.map((segment) => segment.normalize('NFC')),
)
if (prefix.split('/').some((segment) => reserved.has(segment)))
throw new Error('target prefix uses a reserved segment')
assertTargetPrefix(prefix)
return prefix
}
export function memberPath(value: string): MemberPath {
if (value === '' || value.endsWith('/'))
throw new Error('invalid result member path')
const path = normalizePath(value, true)
assertMemberPath(path)
return path
}
function assertTargetPrefix(value: string): asserts value is TargetPrefix {
if (
value.startsWith('/') ||
value.endsWith('/') ||
value.includes('//') ||
value.includes('\\') ||
hasInvalidSegment(value)
)
throw new Error('invalid target prefix')
}
function assertMemberPath(value: string): asserts value is MemberPath {
if (
value === '' ||
value.startsWith('/') ||
value.endsWith('/') ||
value.includes('//') ||
value.includes('\\') ||
hasInvalidSegment(value)
)
throw new Error('invalid result member path')
}
export function storageScopeCovers(
scope: StorageScope,
target: Target,
): boolean {
if (scope.store !== target.store || scope.bucket !== target.bucket)
return false
const scopeSegments = scope.prefix === '' ? [] : scope.prefix.split('/')
const targetSegments = target.prefix === '' ? [] : target.prefix.split('/')
return scopeSegments.every(
(segment, index) => targetSegments[index] === segment,
)
}
function normalizePath(value: string, member: boolean): string {
const invalid =
value.startsWith('/') ||
value.includes('//') ||
value.includes('\\') ||
hasInvalidSegment(value)
if (invalid)
throw new Error(
member ? 'invalid result member path' : 'invalid target prefix',
)
return value
.split('/')
.map((segment) => segment.normalize('NFC'))
.join('/')
}
function hasInvalidSegment(value: string): boolean {
return value
.split('/')
.some(
(segment) =>
segment === '.' || segment === '..' || categoryCPattern.test(segment),
)
}

View file

@ -0,0 +1,82 @@
export const jobPhases = [
'Queued',
'Running',
'RetryWait',
'Cancelling',
'Succeeded',
'Failed',
'Cancelled',
] as const
export type JobPhase = (typeof jobPhases)[number]
export type JobLifecycle =
'visible' | 'soft_deleted' | 'hard_deleting' | 'hard_deleted'
export type AttemptState =
| 'AttemptUnused'
| 'AttemptAuthorized'
| 'AttemptExecuting'
| 'AttemptStopped'
| 'AttemptCommitted'
export type LeaseState = 'NoLease' | 'LeaseLive' | 'LeaseExpired'
export type StageState =
'StageNone' | 'StagePartial' | 'StageComplete' | 'StageCleaned'
export type OutcomeRequest =
| 'NoOutcome'
| 'WantsSuccess'
| 'WantsRetryableFailure'
| 'WantsPermanentFailure'
export const runtimeFailureClasses = [
'authorization',
'source_not_found',
'source_changed',
'transient_source',
'transient_target',
'integrity',
'capacity',
'internal',
] as const
export type RuntimeFailureClass = (typeof runtimeFailureClasses)[number]
export type FailureDisposition = 'permanent' | 'retryable'
export type FailureClass =
'validation' | RuntimeFailureClass | 'cancelled' | 'lost_attempt'
export function failureDisposition(
failure: RuntimeFailureClass,
): FailureDisposition {
switch (failure) {
case 'authorization':
case 'source_not_found':
case 'source_changed':
return 'permanent'
case 'transient_source':
case 'transient_target':
case 'integrity':
case 'capacity':
case 'internal':
return 'retryable'
}
failure satisfies never
throw new Error('unknown runtime failure class')
}
export function isTerminalPhase(phase: JobPhase): boolean {
return phase === 'Succeeded' || phase === 'Failed' || phase === 'Cancelled'
}
export function isJobPhase(value: string): value is JobPhase {
return jobPhases.some((phase) => phase === value)
}
export function isRuntimeFailureClass(
value: string,
): value is RuntimeFailureClass {
return runtimeFailureClasses.some((failure) => failure === value)
}
export function runtimeFailureClass(value: string): RuntimeFailureClass {
const failure = runtimeFailureClasses.find((candidate) => candidate === value)
if (failure === undefined) throw new Error('unknown runtime failure class')
return failure
}

View file

@ -8,11 +8,12 @@
"moduleDetection": "force", "moduleDetection": "force",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"noEmit": true, "noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"target": "ES2024", "target": "ES2024",
"tsBuildInfoFile": "./.cache/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./.cache/tsconfig.app.tsbuildinfo",
"types": ["vite/client"] "types": ["vite/client"]
}, },
"include": ["src/**/*.ts", "src/**/*.svelte"] "include": ["src/**/*.ts", "src/**/*.svelte", "../contracts/**/*.json"]
} }