Add TypeScript domain and contract bindings

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

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
}