Files

143 lines
5.8 KiB
JavaScript

import { open, readFile, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
import { exactKeys, isoTimestamp, plainObject, TrustError } from '../../../../platform/lib/validation.mjs'
const PHASES = new Set([
'planning', 'candidate-ready', 'suspended', 'snapshot-created', 'switched', 'probation',
'committed', 'restoring-data', 'rolled-back', 'failed',
])
const TRANSITIONS = new Map([
['planning', new Set(['candidate-ready', 'failed'])],
['candidate-ready', new Set(['suspended', 'failed'])],
['suspended', new Set(['snapshot-created', 'failed'])],
['snapshot-created', new Set(['switched', 'restoring-data', 'failed'])],
['switched', new Set(['probation', 'restoring-data'])],
['probation', new Set(['committed', 'restoring-data'])],
['restoring-data', new Set(['rolled-back', 'failed'])],
['committed', new Set(['restoring-data'])],
['rolled-back', new Set()],
['failed', new Set()],
])
function deployment(value, label, snapshot) {
const object = plainObject(value, label)
exactKeys(object, ['dsh', 'environment', 'runtime', ...(snapshot ? ['dataSnapshot', 'receiptTokens'] : [])], label)
for (const name of ['dsh', 'environment', 'runtime']) {
if (typeof object[name] !== 'string' || object[name] === '') throw new TrustError(`${label}.${name} is invalid`)
}
if (snapshot && object.dataSnapshot !== null && typeof object.dataSnapshot !== 'string') {
throw new TrustError(`${label}.dataSnapshot is invalid`)
}
if (snapshot && (!Array.isArray(object.receiptTokens) || object.receiptTokens.some(token => typeof token !== 'string'))) {
throw new TrustError(`${label}.receiptTokens is invalid`)
}
return Object.freeze({ ...object, ...(snapshot ? { receiptTokens: Object.freeze([...object.receiptTokens]) } : {}) })
}
function parseJournal(value) {
const object = plainObject(value, 'update journal')
exactKeys(object, [
'error', 'from', 'mode', 'phase', 'probationUntil', 'receiptTokens', 'schema',
'snapshotId', 'to', 'transactionId', 'updatedAt',
], 'update journal')
if (object.schema !== 1) throw new TrustError('update journal schema must be 1')
if (typeof object.transactionId !== 'string' || !/^[A-Za-z0-9-]{1,128}$/.test(object.transactionId)) {
throw new TrustError('update journal transaction ID is invalid')
}
if (!['stable', 'experimental'].includes(object.mode)) throw new TrustError('update journal mode is invalid')
if (!PHASES.has(object.phase)) throw new TrustError('update journal phase is invalid')
if (!Array.isArray(object.receiptTokens) || object.receiptTokens.some(token => typeof token !== 'string')) {
throw new TrustError('update journal receipt tokens are invalid')
}
for (const name of ['snapshotId', 'probationUntil', 'error']) {
if (object[name] !== null && typeof object[name] !== 'string') throw new TrustError(`update journal ${name} is invalid`)
}
if (object.probationUntil !== null) isoTimestamp(object.probationUntil, 'update journal probation deadline')
isoTimestamp(object.updatedAt, 'update journal update time')
return Object.freeze({
...object,
from: deployment(object.from, 'update journal from', true),
to: deployment(object.to, 'update journal to', false),
receiptTokens: Object.freeze([...object.receiptTokens]),
})
}
async function syncDirectory(path) {
const handle = await open(path, 'r')
try { await handle.sync() } finally { await handle.close() }
}
export class UpdateJournal {
constructor(path, now = () => new Date()) {
this.path = path
this.now = now
this.queue = Promise.resolve()
}
exclusive(operation) {
const result = this.queue.then(operation, operation)
this.queue = result.then(() => undefined, () => undefined)
return result
}
async read() {
try { return parseJournal(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) {
if (error?.code === 'ENOENT') return undefined
throw error
}
}
begin(value) {
return this.exclusive(async () => {
const previous = await this.read()
if (previous !== undefined && !['committed', 'rolled-back', 'failed'].includes(previous.phase)) {
throw new TrustError('an update transaction journal already exists')
}
const journal = parseJournal({
schema: 1,
transactionId: value.transactionId,
mode: value.mode,
phase: 'planning',
from: value.from,
to: value.to,
snapshotId: null,
receiptTokens: [],
probationUntil: null,
error: null,
updatedAt: this.now().toISOString(),
})
await durableReplace(this.path, `${JSON.stringify(journal)}\n`)
return journal
})
}
transition(phase, fields = {}) {
return this.exclusive(async () => {
const current = await this.read()
if (current === undefined) throw new TrustError('update transaction journal does not exist')
if (!TRANSITIONS.get(current.phase)?.has(phase)) {
throw new TrustError(`update journal cannot transition from ${current.phase} to ${phase}`)
}
const next = parseJournal({ ...current, ...fields, phase, updatedAt: this.now().toISOString() })
if (next.transactionId !== current.transactionId || next.mode !== current.mode) {
throw new TrustError('update journal identity cannot change')
}
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
return next
})
}
clear() {
return this.exclusive(async () => {
const current = await this.read()
if (current !== undefined && !['committed', 'rolled-back', 'failed'].includes(current.phase)) {
throw new TrustError('cannot clear a nonterminal update journal')
}
await rm(this.path, { force: true })
await syncDirectory(dirname(this.path)).catch(error => { if (error?.code !== 'ENOENT') throw error })
})
}
}