Add TypeScript domain and contract bindings
This commit is contained in:
parent
4da5a980ed
commit
fecb9a1a8d
8 changed files with 1553 additions and 1 deletions
465
web/src/api/contracts.ts
Normal file
465
web/src/api/contracts.ts
Normal 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
525
web/src/api/decode.ts
Normal 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
|
||||
}
|
||||
217
web/src/domain/domain.unit.test.ts
Normal file
217
web/src/domain/domain.unit.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
77
web/src/domain/fingerprint.ts
Normal file
77
web/src/domain/fingerprint.ts
Normal 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
46
web/src/domain/ports.ts
Normal 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
|
||||
}
|
||||
139
web/src/domain/validation.ts
Normal file
139
web/src/domain/validation.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
82
web/src/domain/vocabulary.ts
Normal file
82
web/src/domain/vocabulary.ts
Normal 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
|
||||
}
|
||||
|
|
@ -8,11 +8,12 @@
|
|||
"moduleDetection": "force",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2024",
|
||||
"tsBuildInfoFile": "./.cache/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte"]
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte", "../contracts/**/*.json"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue