Add TypeScript domain and contract bindings
This commit is contained in:
parent
4da5a980ed
commit
fecb9a1a8d
5 changed files with 1553 additions and 1 deletions
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
|
||||
}
|
||||
Loading…
Reference in a new issue