mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 04:01:58 +00:00
add comments to everything potentially consumer facing (#3127)
* add comments to everything potentially consumer facing * rework smtp --------- Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>
This commit is contained in:
@@ -9,7 +9,12 @@ import {
|
||||
import { ServiceInterfaceType, Effects } from '../../base/lib/types'
|
||||
import * as patterns from '../../base/lib/util/patterns'
|
||||
import { Backups } from './backup/Backups'
|
||||
import { smtpInputSpec } from '../../base/lib/actions/input/inputSpecConstants'
|
||||
import {
|
||||
smtpInputSpec,
|
||||
systemSmtpSpec,
|
||||
customSmtp,
|
||||
smtpProviderVariants,
|
||||
} from '../../base/lib/actions/input/inputSpecConstants'
|
||||
import { Daemon, Daemons } from './mainFn/Daemons'
|
||||
import { checkPortListening } from './health/checkFns/checkPortListening'
|
||||
import { checkWebUrl, runHealthScript } from './health/checkFns'
|
||||
@@ -62,6 +67,7 @@ import {
|
||||
import { getOwnServiceInterfaces } from '../../base/lib/util/getServiceInterfaces'
|
||||
import { Volumes, createVolumes } from './util/Volume'
|
||||
|
||||
/** The minimum StartOS version required by this SDK release */
|
||||
export const OSVersion = testTypeVersion('0.4.0-alpha.20')
|
||||
|
||||
// prettier-ignore
|
||||
@@ -71,11 +77,29 @@ type AnyNeverCond<T extends any[], Then, Else> =
|
||||
T extends [any, ...infer U] ? AnyNeverCond<U,Then, Else> :
|
||||
never
|
||||
|
||||
/**
|
||||
* The top-level SDK facade for building StartOS service packages.
|
||||
*
|
||||
* Use `StartSdk.of()` to create an uninitialized instance, then call `.withManifest()`
|
||||
* to bind it to a manifest, and finally `.build()` to obtain the full toolkit of helpers
|
||||
* for actions, daemons, backups, interfaces, health checks, and more.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type; starts as `never` until `.withManifest()` is called.
|
||||
*/
|
||||
export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
private constructor(readonly manifest: Manifest) {}
|
||||
/**
|
||||
* Create an uninitialized StartSdk instance. Call `.withManifest()` next.
|
||||
* @returns A new StartSdk with no manifest bound.
|
||||
*/
|
||||
static of() {
|
||||
return new StartSdk<never>(null as never)
|
||||
}
|
||||
/**
|
||||
* Bind a manifest to the SDK, producing a typed SDK instance.
|
||||
* @param manifest - The service manifest definition
|
||||
* @returns A new StartSdk instance parameterized by the given manifest type
|
||||
*/
|
||||
withManifest<Manifest extends T.SDKManifest = never>(manifest: Manifest) {
|
||||
return new StartSdk<Manifest>(manifest)
|
||||
}
|
||||
@@ -88,6 +112,14 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
return null as any
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize the SDK and return the full set of helpers for building a StartOS service.
|
||||
*
|
||||
* This method is only callable after `.withManifest()` has been called (enforced at the type level).
|
||||
*
|
||||
* @param isReady - Type-level gate; resolves to `true` only when a manifest is bound.
|
||||
* @returns An object containing all SDK utilities: actions, daemons, backups, interfaces, health checks, volumes, triggers, and more.
|
||||
*/
|
||||
build(isReady: AnyNeverCond<[Manifest], 'Build not ready', true>) {
|
||||
type NestedEffects = 'subcontainer' | 'store' | 'action' | 'plugin'
|
||||
type InterfaceEffects =
|
||||
@@ -137,13 +169,19 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
}
|
||||
|
||||
return {
|
||||
/** The bound service manifest */
|
||||
manifest: this.manifest,
|
||||
/** Volume path helpers derived from the manifest volume definitions */
|
||||
volumes: createVolumes(this.manifest),
|
||||
...startSdkEffectWrapper,
|
||||
/** Persist the current data version to the StartOS effect system */
|
||||
setDataVersion,
|
||||
/** Retrieve the current data version from the StartOS effect system */
|
||||
getDataVersion,
|
||||
action: {
|
||||
/** Execute an action by its ID, optionally providing input */
|
||||
run: actions.runAction,
|
||||
/** Create a task notification for a specific package's action */
|
||||
createTask: <T extends ActionInfo<T.ActionId, any>>(
|
||||
effects: T.Effects,
|
||||
packageId: T.PackageId,
|
||||
@@ -158,6 +196,7 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
severity,
|
||||
options: options,
|
||||
}),
|
||||
/** Create a task notification for this service's own action (uses manifest.id automatically) */
|
||||
createOwnTask: <T extends ActionInfo<T.ActionId, any>>(
|
||||
effects: T.Effects,
|
||||
action: T,
|
||||
@@ -171,9 +210,20 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
severity,
|
||||
options: options,
|
||||
}),
|
||||
/**
|
||||
* Clear one or more task notifications by their replay IDs
|
||||
* @param effects - The effects context
|
||||
* @param replayIds - One or more replay IDs of the tasks to clear
|
||||
*/
|
||||
clearTask: (effects: T.Effects, ...replayIds: string[]) =>
|
||||
effects.action.clearTasks({ only: replayIds }),
|
||||
},
|
||||
/**
|
||||
* Check whether the specified (or all) dependencies are satisfied.
|
||||
* @param effects - The effects context
|
||||
* @param packageIds - Optional subset of dependency IDs to check; defaults to all
|
||||
* @returns An object describing which dependencies are satisfied and which are not
|
||||
*/
|
||||
checkDependencies: checkDependencies as <
|
||||
DependencyId extends keyof Manifest['dependencies'] &
|
||||
T.PackageId = keyof Manifest['dependencies'] & T.PackageId,
|
||||
@@ -182,11 +232,25 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
packageIds?: DependencyId[],
|
||||
) => Promise<CheckDependencies<DependencyId>>,
|
||||
serviceInterface: {
|
||||
/** Retrieve a single service interface belonging to this package by its ID */
|
||||
getOwn: getOwnServiceInterface,
|
||||
/** Retrieve a single service interface from any package */
|
||||
get: getServiceInterface,
|
||||
/** Retrieve all service interfaces belonging to this package */
|
||||
getAllOwn: getOwnServiceInterfaces,
|
||||
/** Retrieve all service interfaces, optionally filtering by package */
|
||||
getAll: getServiceInterfaces,
|
||||
},
|
||||
/**
|
||||
* Get the container IP address with reactive subscription support.
|
||||
*
|
||||
* Returns an object with multiple read strategies: `const()` for a value
|
||||
* that retries on change, `once()` for a single read, `watch()` for an async
|
||||
* generator, `onChange()` for a callback, and `waitFor()` to block until a predicate is met.
|
||||
*
|
||||
* @param effects - The effects context
|
||||
* @param options - Optional filtering options (e.g. `containerId`)
|
||||
*/
|
||||
getContainerIp: (
|
||||
effects: T.Effects,
|
||||
options: Omit<
|
||||
@@ -279,9 +343,22 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
},
|
||||
|
||||
MultiHost: {
|
||||
/**
|
||||
* Create a new MultiHost instance for binding ports and exporting interfaces.
|
||||
* @param effects - The effects context
|
||||
* @param id - A unique identifier for this multi-host group
|
||||
*/
|
||||
of: (effects: Effects, id: string) => new MultiHost({ id, effects }),
|
||||
},
|
||||
/**
|
||||
* Return `null` if the given string is empty, otherwise return the string unchanged.
|
||||
* Useful for converting empty user input into explicit null values.
|
||||
*/
|
||||
nullIfEmpty,
|
||||
/**
|
||||
* Indicate that a daemon should use the container image's configured entrypoint.
|
||||
* @param overrideCmd - Optional command arguments to append after the entrypoint
|
||||
*/
|
||||
useEntrypoint: (overrideCmd?: string[]) =>
|
||||
new T.UseEntrypoint(overrideCmd),
|
||||
/**
|
||||
@@ -396,7 +473,12 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
run: Run<{}>,
|
||||
) => Action.withoutInput(id, metadata, run),
|
||||
},
|
||||
inputSpecConstants: { smtpInputSpec },
|
||||
inputSpecConstants: {
|
||||
smtpInputSpec,
|
||||
systemSmtpSpec,
|
||||
customSmtp,
|
||||
smtpProviderVariants,
|
||||
},
|
||||
/**
|
||||
* @description Use this function to create a service interface.
|
||||
* @param effects
|
||||
@@ -444,21 +526,37 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
masked: boolean
|
||||
},
|
||||
) => new ServiceInterfaceBuilder({ ...options, effects }),
|
||||
/**
|
||||
* Get the system SMTP configuration with reactive subscription support.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
getSystemSmtp: <E extends Effects>(effects: E) =>
|
||||
new GetSystemSmtp(effects),
|
||||
/**
|
||||
* Get the outbound network gateway address with reactive subscription support.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
getOutboundGateway: <E extends Effects>(effects: E) =>
|
||||
new GetOutboundGateway(effects),
|
||||
/**
|
||||
* Get an SSL certificate for the given hostnames with reactive subscription support.
|
||||
* @param effects - The effects context
|
||||
* @param hostnames - The hostnames to obtain a certificate for
|
||||
* @param algorithm - Optional algorithm preference (e.g. Ed25519)
|
||||
*/
|
||||
getSslCertificate: <E extends Effects>(
|
||||
effects: E,
|
||||
hostnames: string[],
|
||||
algorithm?: T.Algorithm,
|
||||
) => new GetSslCertificate(effects, hostnames, algorithm),
|
||||
/** Retrieve the manifest of any installed service package by its ID */
|
||||
getServiceManifest,
|
||||
healthCheck: {
|
||||
checkPortListening,
|
||||
checkWebUrl,
|
||||
runHealthScript,
|
||||
},
|
||||
/** Common utility patterns (e.g. hostname regex, port validators) */
|
||||
patterns,
|
||||
/**
|
||||
* @description Use this function to list every Action offered by the service. Actions will be displayed in the provided order.
|
||||
@@ -638,21 +736,47 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
* ```
|
||||
*/
|
||||
setupInterfaces: setupServiceInterfaces,
|
||||
/**
|
||||
* Define the main entrypoint for the service. The provided function should
|
||||
* configure and return a `Daemons` instance describing all long-running processes.
|
||||
* @param fn - Async function that receives `effects` and returns a `Daemons` instance
|
||||
*/
|
||||
setupMain: (
|
||||
fn: (o: { effects: Effects }) => Promise<Daemons<Manifest, any>>,
|
||||
) => setupMain<Manifest>(fn),
|
||||
/** Built-in trigger strategies for controlling health-check polling intervals */
|
||||
trigger: {
|
||||
/** Default trigger: polls at a fixed interval */
|
||||
defaultTrigger,
|
||||
/** Trigger with a cooldown period between checks */
|
||||
cooldownTrigger,
|
||||
/** Switches to a different interval after the first successful check */
|
||||
changeOnFirstSuccess,
|
||||
/** Uses different intervals based on success vs failure results */
|
||||
successFailure,
|
||||
},
|
||||
Mounts: {
|
||||
/**
|
||||
* Create an empty Mounts builder for declaring volume, asset, dependency, and backup mounts.
|
||||
* @returns A new Mounts instance with no mounts configured
|
||||
*/
|
||||
of: Mounts.of<Manifest>,
|
||||
},
|
||||
Backups: {
|
||||
/**
|
||||
* Create a Backups configuration that backs up entire volumes by name.
|
||||
* @param volumeNames - Volume IDs from the manifest to include in backups
|
||||
*/
|
||||
ofVolumes: Backups.ofVolumes<Manifest>,
|
||||
/**
|
||||
* Create a Backups configuration from explicit sync path pairs.
|
||||
* @param syncs - Array of `{ dataPath, backupPath }` objects
|
||||
*/
|
||||
ofSyncs: Backups.ofSyncs<Manifest>,
|
||||
/**
|
||||
* Create a Backups configuration with custom rsync options (e.g. exclude patterns).
|
||||
* @param options - Partial sync options to override defaults
|
||||
*/
|
||||
withOptions: Backups.withOptions<Manifest>,
|
||||
},
|
||||
InputSpec: {
|
||||
@@ -687,11 +811,20 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
InputSpec.of<Spec>(spec),
|
||||
},
|
||||
Daemon: {
|
||||
/**
|
||||
* Create a single Daemon that wraps a long-running process with automatic restart logic.
|
||||
* Returns a curried function: call with `(effects, subcontainer, exec)`.
|
||||
*/
|
||||
get of() {
|
||||
return Daemon.of<Manifest>()
|
||||
},
|
||||
},
|
||||
Daemons: {
|
||||
/**
|
||||
* Create a new Daemons builder for defining the service's daemon topology.
|
||||
* Chain `.addDaemon()` calls to register each long-running process.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
of(effects: Effects) {
|
||||
return Daemons.of<Manifest>({ effects })
|
||||
},
|
||||
@@ -798,6 +931,19 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a one-shot command inside a temporary subcontainer.
|
||||
*
|
||||
* Creates a subcontainer, executes the command, and destroys the subcontainer when finished.
|
||||
* Throws an {@link ExitError} if the command exits with a non-zero code or signal.
|
||||
*
|
||||
* @param effects - The effects context
|
||||
* @param image - The container image to use
|
||||
* @param command - The command to execute (string array or UseEntrypoint)
|
||||
* @param options - Mount and command options
|
||||
* @param name - Optional human-readable name for debugging
|
||||
* @returns The stdout and stderr output of the command
|
||||
*/
|
||||
export async function runCommand<Manifest extends T.SDKManifest>(
|
||||
effects: Effects,
|
||||
image: { imageId: keyof Manifest['images'] & T.ImageId; sharedRun?: boolean },
|
||||
|
||||
@@ -5,10 +5,12 @@ import { Affine, asError } from '../util'
|
||||
import { ExtendedVersion, VersionRange } from '../../../base/lib'
|
||||
import { InitKind, InitScript } from '../../../base/lib/inits'
|
||||
|
||||
/** Default rsync options used for backup and restore operations */
|
||||
export const DEFAULT_OPTIONS: T.SyncOptions = {
|
||||
delete: true,
|
||||
exclude: [],
|
||||
}
|
||||
/** A single source-to-destination sync pair for backup and restore */
|
||||
export type BackupSync<Volumes extends string> = {
|
||||
dataPath: `/media/startos/volumes/${Volumes}/${string}`
|
||||
backupPath: `/media/startos/backup/${string}`
|
||||
@@ -17,8 +19,18 @@ export type BackupSync<Volumes extends string> = {
|
||||
restoreOptions?: Partial<T.SyncOptions>
|
||||
}
|
||||
|
||||
/** Effects type narrowed for backup/restore contexts, preventing reuse outside that scope */
|
||||
export type BackupEffects = T.Effects & Affine<'Backups'>
|
||||
|
||||
/**
|
||||
* Configures backup and restore operations using rsync.
|
||||
*
|
||||
* Supports syncing entire volumes or custom path pairs, with optional pre/post hooks
|
||||
* for both backup and restore phases. Implements {@link InitScript} so it can be used
|
||||
* as a restore-init step in `setupInit`.
|
||||
*
|
||||
* @typeParam M - The service manifest type
|
||||
*/
|
||||
export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
private constructor(
|
||||
private options = DEFAULT_OPTIONS,
|
||||
@@ -31,6 +43,11 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
private postRestore = async (effects: BackupEffects) => {},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a Backups configuration that backs up entire volumes by name.
|
||||
* Each volume is synced to a corresponding directory under `/media/startos/backup/volumes/`.
|
||||
* @param volumeNames - One or more volume IDs from the manifest
|
||||
*/
|
||||
static ofVolumes<M extends T.SDKManifest = never>(
|
||||
...volumeNames: Array<M['volumes'][number]>
|
||||
): Backups<M> {
|
||||
@@ -42,18 +59,31 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Backups configuration from explicit source/destination sync pairs.
|
||||
* @param syncs - Array of `{ dataPath, backupPath }` objects with optional per-sync options
|
||||
*/
|
||||
static ofSyncs<M extends T.SDKManifest = never>(
|
||||
...syncs: BackupSync<M['volumes'][number]>[]
|
||||
) {
|
||||
return syncs.reduce((acc, x) => acc.addSync(x), new Backups<M>())
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty Backups configuration with custom default rsync options.
|
||||
* Chain `.addVolume()` or `.addSync()` to add sync targets.
|
||||
* @param options - Partial rsync options to override defaults (e.g. `{ exclude: ['cache'] }`)
|
||||
*/
|
||||
static withOptions<M extends T.SDKManifest = never>(
|
||||
options?: Partial<T.SyncOptions>,
|
||||
) {
|
||||
return new Backups<M>({ ...DEFAULT_OPTIONS, ...options })
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the default rsync options for both backup and restore.
|
||||
* @param options - Partial rsync options to merge with current defaults
|
||||
*/
|
||||
setOptions(options?: Partial<T.SyncOptions>) {
|
||||
this.options = {
|
||||
...this.options,
|
||||
@@ -62,6 +92,10 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Override rsync options used only during backup (not restore).
|
||||
* @param options - Partial rsync options for the backup phase
|
||||
*/
|
||||
setBackupOptions(options?: Partial<T.SyncOptions>) {
|
||||
this.backupOptions = {
|
||||
...this.backupOptions,
|
||||
@@ -70,6 +104,10 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Override rsync options used only during restore (not backup).
|
||||
* @param options - Partial rsync options for the restore phase
|
||||
*/
|
||||
setRestoreOptions(options?: Partial<T.SyncOptions>) {
|
||||
this.restoreOptions = {
|
||||
...this.restoreOptions,
|
||||
@@ -78,26 +116,47 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a hook to run before backup rsync begins (e.g. dump a database).
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPreBackup(fn: (effects: BackupEffects) => Promise<void>) {
|
||||
this.preBackup = fn
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a hook to run after backup rsync completes.
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPostBackup(fn: (effects: BackupEffects) => Promise<void>) {
|
||||
this.postBackup = fn
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a hook to run before restore rsync begins.
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPreRestore(fn: (effects: BackupEffects) => Promise<void>) {
|
||||
this.preRestore = fn
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a hook to run after restore rsync completes.
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPostRestore(fn: (effects: BackupEffects) => Promise<void>) {
|
||||
this.postRestore = fn
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a volume to the backup set by its ID.
|
||||
* @param volume - The volume ID from the manifest
|
||||
* @param options - Optional per-volume rsync overrides
|
||||
*/
|
||||
addVolume(
|
||||
volume: M['volumes'][number],
|
||||
options?: Partial<{
|
||||
@@ -113,11 +172,19 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom sync pair to the backup set.
|
||||
* @param sync - A `{ dataPath, backupPath }` object with optional per-sync rsync options
|
||||
*/
|
||||
addSync(sync: BackupSync<M['volumes'][0]>) {
|
||||
this.backupSet.push(sync)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the backup: runs pre-hook, rsyncs all configured paths, saves the data version, then runs post-hook.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
async createBackup(effects: T.Effects) {
|
||||
await this.preBackup(effects as BackupEffects)
|
||||
for (const item of this.backupSet) {
|
||||
@@ -149,6 +216,10 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the restore: runs pre-hook, rsyncs all configured paths from backup to data, restores the data version, then runs post-hook.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
async restoreBackup(effects: T.Effects) {
|
||||
this.preRestore(effects as BackupEffects)
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import * as T from '../../../base/lib/types'
|
||||
import { _ } from '../util'
|
||||
import { InitScript } from '../../../base/lib/inits'
|
||||
|
||||
/**
|
||||
* Parameters for `setupBackups`. Either:
|
||||
* - An array of volume IDs to back up entirely, or
|
||||
* - An async factory function that returns a fully configured {@link Backups} instance
|
||||
*/
|
||||
export type SetupBackupsParams<M extends T.SDKManifest> =
|
||||
| M['volumes'][number][]
|
||||
| ((_: { effects: T.Effects }) => Promise<Backups<M>>)
|
||||
@@ -12,6 +17,15 @@ type SetupBackupsRes = {
|
||||
restoreInit: InitScript
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up backup and restore exports for the service.
|
||||
*
|
||||
* Returns `{ createBackup, restoreInit }` which should be exported and wired into
|
||||
* the service's init and backup entry points.
|
||||
*
|
||||
* @param options - Either an array of volume IDs or an async factory returning a Backups instance
|
||||
* @returns An object with `createBackup` (the backup export) and `restoreInit` (an InitScript for restore)
|
||||
*/
|
||||
export function setupBackups<M extends T.SDKManifest>(
|
||||
options: SetupBackupsParams<M>,
|
||||
) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TriggerInput } from '../trigger/TriggerInput'
|
||||
import { defaultTrigger } from '../trigger/defaultTrigger'
|
||||
import { once, asError, Drop } from '../util'
|
||||
|
||||
/** Parameters for creating a health check */
|
||||
export type HealthCheckParams = {
|
||||
id: HealthCheckId
|
||||
name: string
|
||||
@@ -13,6 +14,13 @@ export type HealthCheckParams = {
|
||||
fn(): Promise<HealthCheckResult> | HealthCheckResult
|
||||
}
|
||||
|
||||
/**
|
||||
* A periodic health check that reports daemon readiness to the StartOS UI.
|
||||
*
|
||||
* Polls at an interval controlled by a {@link Trigger}, reporting results as
|
||||
* "starting" (during the grace period), "success", or "failure". Automatically
|
||||
* pauses when the daemon is stopped and resumes when restarted.
|
||||
*/
|
||||
export class HealthCheck extends Drop {
|
||||
private started: number | null = null
|
||||
private setStarted = (started: number | null) => {
|
||||
@@ -91,13 +99,21 @@ export class HealthCheck extends Drop {
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Create a new HealthCheck instance and begin its polling loop.
|
||||
* @param effects - The effects context for reporting health status
|
||||
* @param options - Health check configuration (ID, name, check function, trigger, grace period)
|
||||
* @returns A new HealthCheck instance
|
||||
*/
|
||||
static of(effects: Effects, options: HealthCheckParams): HealthCheck {
|
||||
return new HealthCheck(effects, options)
|
||||
}
|
||||
/** Signal that the daemon is running, enabling health check polling */
|
||||
start() {
|
||||
if (this.started) return
|
||||
this.setStarted(performance.now())
|
||||
}
|
||||
/** Signal that the daemon has stopped, pausing health check polling */
|
||||
stop() {
|
||||
if (!this.started) return
|
||||
this.setStarted(null)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { T } from '../../../../base/lib'
|
||||
|
||||
/**
|
||||
* The result of a single health check invocation.
|
||||
*
|
||||
* Contains a `result` field ("success", "failure", or "starting") and an optional `message`.
|
||||
* This is the unnamed variant -- the health check name is added by the framework.
|
||||
*/
|
||||
export type HealthCheckResult = Omit<T.NamedHealthCheckResult, 'name'>
|
||||
|
||||
@@ -3,6 +3,14 @@ export { checkPortListening } from './checkPortListening'
|
||||
export { HealthCheckResult } from './HealthCheckResult'
|
||||
export { checkWebUrl } from './checkWebUrl'
|
||||
|
||||
/**
|
||||
* Create a promise that rejects after the specified timeout.
|
||||
* Useful for racing against long-running health checks.
|
||||
*
|
||||
* @param ms - Timeout duration in milliseconds
|
||||
* @param options.message - Custom error message (defaults to "Timed out")
|
||||
* @returns A promise that never resolves, only rejects after the timeout
|
||||
*/
|
||||
export function timeoutPromise(ms: number, { message = 'Timed out' } = {}) {
|
||||
return new Promise<never>((resolve, reject) =>
|
||||
setTimeout(() => reject(new Error(message)), ms),
|
||||
|
||||
@@ -8,6 +8,15 @@ import * as cp from 'child_process'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import { DaemonCommandType, ExecCommandOptions, ExecFnOptions } from './Daemons'
|
||||
|
||||
/**
|
||||
* Low-level controller for a single running process inside a subcontainer (or as a JS function).
|
||||
*
|
||||
* Manages the child process lifecycle: spawning, waiting, and signal-based termination.
|
||||
* Used internally by {@link Daemon} to manage individual command executions.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
* @typeParam C - The subcontainer type, or `null` for JS-only commands
|
||||
*/
|
||||
export class CommandController<
|
||||
Manifest extends T.SDKManifest,
|
||||
C extends SubContainer<Manifest> | null,
|
||||
@@ -21,6 +30,13 @@ export class CommandController<
|
||||
) {
|
||||
super()
|
||||
}
|
||||
/**
|
||||
* Factory method to create a new CommandController.
|
||||
*
|
||||
* Returns a curried async function: `(effects, subcontainer, exec) => CommandController`.
|
||||
* If the exec spec has an `fn` property, runs the function; otherwise spawns a shell command
|
||||
* in the subcontainer.
|
||||
*/
|
||||
static of<
|
||||
Manifest extends T.SDKManifest,
|
||||
C extends SubContainer<Manifest> | null,
|
||||
@@ -130,6 +146,10 @@ export class CommandController<
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Wait for the command to finish. Optionally terminate after a timeout.
|
||||
* @param options.timeout - Milliseconds to wait before terminating. Defaults to no timeout.
|
||||
*/
|
||||
async wait({ timeout = NO_TIMEOUT } = {}) {
|
||||
if (timeout > 0)
|
||||
setTimeout(() => {
|
||||
@@ -156,6 +176,15 @@ export class CommandController<
|
||||
await this.subcontainer?.destroy()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Terminate the running command by sending a signal.
|
||||
*
|
||||
* Sends the specified signal (default: SIGTERM), then escalates to SIGKILL
|
||||
* after the timeout expires. Destroys the subcontainer after the process exits.
|
||||
*
|
||||
* @param options.signal - The signal to send (default: SIGTERM)
|
||||
* @param options.timeout - Milliseconds before escalating to SIGKILL
|
||||
*/
|
||||
async term({ signal = SIGTERM, timeout = this.sigtermTimeout } = {}) {
|
||||
try {
|
||||
if (!this.state.exited) {
|
||||
|
||||
@@ -13,10 +13,15 @@ import { Oneshot } from './Oneshot'
|
||||
const TIMEOUT_INCREMENT_MS = 1000
|
||||
const MAX_TIMEOUT_MS = 30000
|
||||
/**
|
||||
* This is a wrapper around CommandController that has a state of off, where the command shouldn't be running
|
||||
* and the others state of running, where it will keep a living running command
|
||||
* A managed long-running process wrapper around {@link CommandController}.
|
||||
*
|
||||
* When started, the daemon automatically restarts its underlying command on failure
|
||||
* with exponential backoff (up to 30 seconds). When stopped, the command is terminated
|
||||
* gracefully. Implements {@link Drop} for automatic cleanup when the context is left.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
* @typeParam C - The subcontainer type, or `null` for JS-only daemons
|
||||
*/
|
||||
|
||||
export class Daemon<
|
||||
Manifest extends T.SDKManifest,
|
||||
C extends SubContainer<Manifest> | null = SubContainer<Manifest> | null,
|
||||
@@ -33,9 +38,16 @@ export class Daemon<
|
||||
) {
|
||||
super()
|
||||
}
|
||||
/** Returns true if this daemon is a one-shot process (exits after success) */
|
||||
isOneshot(): this is Oneshot<Manifest> {
|
||||
return this.oneshot
|
||||
}
|
||||
/**
|
||||
* Factory method to create a new Daemon.
|
||||
*
|
||||
* Returns a curried function: `(effects, subcontainer, exec) => Daemon`.
|
||||
* The daemon auto-terminates when the effects context is left.
|
||||
*/
|
||||
static of<Manifest extends T.SDKManifest>() {
|
||||
return <C extends SubContainer<Manifest> | null>(
|
||||
effects: T.Effects,
|
||||
@@ -57,6 +69,12 @@ export class Daemon<
|
||||
return res
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Start the daemon. If it is already running, this is a no-op.
|
||||
*
|
||||
* The daemon will automatically restart on failure with increasing backoff
|
||||
* until {@link term} is called.
|
||||
*/
|
||||
async start() {
|
||||
if (this.commandController) {
|
||||
return
|
||||
@@ -105,6 +123,17 @@ export class Daemon<
|
||||
console.error(asError(err))
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Terminate the daemon, stopping its underlying command.
|
||||
*
|
||||
* Sends the configured signal (default SIGTERM) and waits for the process to exit.
|
||||
* Optionally destroys the subcontainer after termination.
|
||||
*
|
||||
* @param termOptions - Optional termination settings
|
||||
* @param termOptions.signal - The signal to send (default: SIGTERM)
|
||||
* @param termOptions.timeout - Milliseconds to wait before SIGKILL
|
||||
* @param termOptions.destroySubcontainer - Whether to destroy the subcontainer after exit
|
||||
*/
|
||||
async term(termOptions?: {
|
||||
signal?: NodeJS.Signals | undefined
|
||||
timeout?: number | undefined
|
||||
@@ -125,14 +154,20 @@ export class Daemon<
|
||||
this.exiting = null
|
||||
}
|
||||
}
|
||||
/** Get a reference-counted handle to the daemon's subcontainer, or null if there is none */
|
||||
subcontainerRc(): SubContainerRc<Manifest> | null {
|
||||
return this.subcontainer?.rc() ?? null
|
||||
}
|
||||
/** Check whether this daemon shares the same subcontainer as another daemon */
|
||||
sharesSubcontainerWith(
|
||||
other: Daemon<Manifest, SubContainer<Manifest> | null>,
|
||||
): boolean {
|
||||
return this.subcontainer?.guid === other.subcontainer?.guid
|
||||
}
|
||||
/**
|
||||
* Register a callback to be invoked each time the daemon's process exits.
|
||||
* @param fn - Callback receiving `true` on clean exit, `false` on error
|
||||
*/
|
||||
onExit(fn: (success: boolean) => void) {
|
||||
this.onExitFns.push(fn)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,15 @@ import { Daemon } from './Daemon'
|
||||
import { CommandController } from './CommandController'
|
||||
import { Oneshot } from './Oneshot'
|
||||
|
||||
/** Promisified version of `child_process.exec` */
|
||||
export const cpExec = promisify(CP.exec)
|
||||
/** Promisified version of `child_process.execFile` */
|
||||
export const cpExecFile = promisify(CP.execFile)
|
||||
/**
|
||||
* Configuration for a daemon's health-check readiness probe.
|
||||
*
|
||||
* Determines how the system knows when a daemon is healthy and ready to serve.
|
||||
*/
|
||||
export type Ready = {
|
||||
/** A human-readable display name for the health check. If null, the health check itself will be from the UI */
|
||||
display: string | null
|
||||
@@ -45,6 +52,10 @@ export type Ready = {
|
||||
trigger?: Trigger
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for running a daemon as a shell command inside a subcontainer.
|
||||
* Includes the command to run, optional signal/timeout, environment, user, and stdio callbacks.
|
||||
*/
|
||||
export type ExecCommandOptions = {
|
||||
command: T.CommandType
|
||||
// Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms
|
||||
@@ -61,6 +72,11 @@ export type ExecCommandOptions = {
|
||||
onStderr?: (chunk: Buffer | string | any) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for running a daemon via an async function that may optionally return
|
||||
* a command to execute in the subcontainer. The function receives an `AbortSignal`
|
||||
* for cooperative cancellation.
|
||||
*/
|
||||
export type ExecFnOptions<
|
||||
Manifest extends T.SDKManifest,
|
||||
C extends SubContainer<Manifest> | null,
|
||||
@@ -73,6 +89,10 @@ export type ExecFnOptions<
|
||||
sigtermTimeout?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The execution specification for a daemon: either an {@link ExecFnOptions} (async function)
|
||||
* or an {@link ExecCommandOptions} (shell command, only valid when a subcontainer is provided).
|
||||
*/
|
||||
export type DaemonCommandType<
|
||||
Manifest extends T.SDKManifest,
|
||||
C extends SubContainer<Manifest> | null,
|
||||
@@ -385,6 +405,13 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully terminate all daemons in reverse dependency order.
|
||||
*
|
||||
* Daemons with no remaining dependents are shut down first, proceeding
|
||||
* until all daemons have been terminated. Falls back to a bulk shutdown
|
||||
* if a dependency cycle is detected.
|
||||
*/
|
||||
async term() {
|
||||
const remaining = new Set(this.healthDaemons)
|
||||
|
||||
@@ -427,6 +454,10 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start all registered daemons and their health checks.
|
||||
* @returns This `Daemons` instance, now running
|
||||
*/
|
||||
async build() {
|
||||
for (const daemon of this.healthDaemons) {
|
||||
await daemon.updateStatus()
|
||||
|
||||
@@ -49,6 +49,15 @@ type DependencyOpts<Manifest extends T.SDKManifest> = {
|
||||
readonly: boolean
|
||||
} & SharedOptions
|
||||
|
||||
/**
|
||||
* Immutable builder for declaring filesystem mounts into a subcontainer.
|
||||
*
|
||||
* Supports mounting volumes, static assets, dependency volumes, and backup directories.
|
||||
* Each `mount*` method returns a new `Mounts` instance (immutable builder pattern).
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
* @typeParam Backups - Tracks whether backup mounts have been added (type-level flag)
|
||||
*/
|
||||
export class Mounts<
|
||||
Manifest extends T.SDKManifest,
|
||||
Backups extends SharedOptions = never,
|
||||
@@ -60,10 +69,19 @@ export class Mounts<
|
||||
readonly backups: Backups[],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create an empty Mounts builder with no mounts configured.
|
||||
* @returns A new Mounts instance ready for chaining mount declarations
|
||||
*/
|
||||
static of<Manifest extends T.SDKManifest>() {
|
||||
return new Mounts<Manifest>([], [], [], [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a volume mount from the service's own volumes.
|
||||
* @param options - Volume ID, mountpoint, readonly flag, and optional subpath
|
||||
* @returns A new Mounts instance with this volume added
|
||||
*/
|
||||
mountVolume(options: VolumeOpts<Manifest>) {
|
||||
return new Mounts<Manifest, Backups>(
|
||||
[...this.volumes, options],
|
||||
@@ -73,6 +91,11 @@ export class Mounts<
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a read-only mount of the service's packaged static assets.
|
||||
* @param options - Mountpoint and optional subpath within the assets directory
|
||||
* @returns A new Mounts instance with this asset mount added
|
||||
*/
|
||||
mountAssets(options: SharedOptions) {
|
||||
return new Mounts<Manifest, Backups>(
|
||||
[...this.volumes],
|
||||
@@ -82,6 +105,11 @@ export class Mounts<
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a mount from a dependency package's volume.
|
||||
* @param options - Dependency ID, volume ID, mountpoint, readonly flag, and optional subpath
|
||||
* @returns A new Mounts instance with this dependency mount added
|
||||
*/
|
||||
mountDependency<DependencyManifest extends T.SDKManifest>(
|
||||
options: DependencyOpts<DependencyManifest>,
|
||||
) {
|
||||
@@ -93,6 +121,11 @@ export class Mounts<
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a mount of the backup directory. Only valid during backup/restore operations.
|
||||
* @param options - Mountpoint and optional subpath within the backup directory
|
||||
* @returns A new Mounts instance with this backup mount added
|
||||
*/
|
||||
mountBackups(options: SharedOptions) {
|
||||
return new Mounts<
|
||||
Manifest,
|
||||
@@ -108,6 +141,11 @@ export class Mounts<
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile all declared mounts into the low-level mount array consumed by the subcontainer runtime.
|
||||
* @throws If any two mounts share the same mountpoint
|
||||
* @returns An array of `{ mountpoint, options }` objects
|
||||
*/
|
||||
build(): MountArray {
|
||||
const mountpoints = new Set()
|
||||
for (let mountpoint of this.volumes
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Daemons } from './Daemons'
|
||||
import '../../../base/lib/interfaces/ServiceInterfaceBuilder'
|
||||
import '../../../base/lib/interfaces/Origin'
|
||||
|
||||
/** Default time in milliseconds to wait for a process to exit after SIGTERM before escalating to SIGKILL */
|
||||
export const DEFAULT_SIGTERM_TIMEOUT = 60_000
|
||||
/**
|
||||
* Used to ensure that the main function is running with the valid proofs.
|
||||
|
||||
@@ -24,6 +24,15 @@ export function setupManifest<
|
||||
return manifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final publishable manifest by combining the SDK manifest definition
|
||||
* with version graph metadata, OS version, SDK version, and computed fields
|
||||
* (migration ranges, hardware requirements, alerts, etc.).
|
||||
*
|
||||
* @param versions - The service's VersionGraph, used to extract the current version, release notes, and migration ranges
|
||||
* @param manifest - The SDK manifest definition (from `setupManifest`)
|
||||
* @returns A fully resolved Manifest ready for packaging
|
||||
*/
|
||||
export function buildManifest<
|
||||
Id extends string,
|
||||
Version extends string,
|
||||
|
||||
@@ -69,6 +69,14 @@ async function bind(
|
||||
await execFile('mount', [...args, from, to])
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface representing an isolated container environment for running service processes.
|
||||
*
|
||||
* Provides methods for executing commands, spawning processes, mounting filesystems,
|
||||
* and writing files within the container's rootfs. Comes in two flavors:
|
||||
* {@link SubContainerOwned} (owns the underlying filesystem) and
|
||||
* {@link SubContainerRc} (reference-counted handle to a shared container).
|
||||
*/
|
||||
export interface SubContainer<
|
||||
Manifest extends T.SDKManifest,
|
||||
Effects extends T.Effects = T.Effects,
|
||||
@@ -84,6 +92,11 @@ export interface SubContainer<
|
||||
*/
|
||||
subpath(path: string): string
|
||||
|
||||
/**
|
||||
* Apply filesystem mounts (volumes, assets, dependencies, backups) to this subcontainer.
|
||||
* @param mounts - The Mounts configuration to apply
|
||||
* @returns This subcontainer instance for chaining
|
||||
*/
|
||||
mount(
|
||||
mounts: Effects extends BackupEffects
|
||||
? Mounts<
|
||||
@@ -96,6 +109,7 @@ export interface SubContainer<
|
||||
: Mounts<Manifest, never>,
|
||||
): Promise<this>
|
||||
|
||||
/** Destroy this subcontainer and clean up its filesystem */
|
||||
destroy: () => Promise<null>
|
||||
|
||||
/**
|
||||
@@ -136,11 +150,22 @@ export interface SubContainer<
|
||||
stderr: string | Buffer
|
||||
}>
|
||||
|
||||
/**
|
||||
* Launch a command as the init (PID 1) process of the subcontainer.
|
||||
* Replaces the current leader process.
|
||||
* @param command - The command and arguments to execute
|
||||
* @param options - Optional environment, working directory, and user overrides
|
||||
*/
|
||||
launch(
|
||||
command: string[],
|
||||
options?: CommandOptions,
|
||||
): Promise<cp.ChildProcessWithoutNullStreams>
|
||||
|
||||
/**
|
||||
* Spawn a command inside the subcontainer as a non-init process.
|
||||
* @param command - The command and arguments to execute
|
||||
* @param options - Optional environment, working directory, user, and stdio overrides
|
||||
*/
|
||||
spawn(
|
||||
command: string[],
|
||||
options?: CommandOptions & StdioOptions,
|
||||
@@ -162,8 +187,13 @@ export interface SubContainer<
|
||||
options?: Parameters<typeof fs.writeFile>[2],
|
||||
): Promise<void>
|
||||
|
||||
/**
|
||||
* Create a reference-counted handle to this subcontainer.
|
||||
* The underlying container is only destroyed when all handles are released.
|
||||
*/
|
||||
rc(): SubContainerRc<Manifest, Effects>
|
||||
|
||||
/** Returns true if this is an owned subcontainer (not a reference-counted handle) */
|
||||
isOwned(): this is SubContainerOwned<Manifest, Effects>
|
||||
}
|
||||
|
||||
@@ -679,6 +709,12 @@ export class SubContainerOwned<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference-counted handle to a {@link SubContainerOwned}.
|
||||
*
|
||||
* Multiple `SubContainerRc` instances can share one underlying subcontainer.
|
||||
* The subcontainer is destroyed only when the last reference is released via `destroy()`.
|
||||
*/
|
||||
export class SubContainerRc<
|
||||
Manifest extends T.SDKManifest,
|
||||
Effects extends T.Effects = T.Effects,
|
||||
@@ -901,14 +937,17 @@ export type StdioOptions = {
|
||||
stdio?: cp.IOType
|
||||
}
|
||||
|
||||
/** UID/GID mapping for mount id-remapping (see kernel idmappings docs) */
|
||||
export type IdMap = { fromId: number; toId: number; range: number }
|
||||
|
||||
/** Union of all mount option types supported by the subcontainer runtime */
|
||||
export type MountOptions =
|
||||
| MountOptionsVolume
|
||||
| MountOptionsAssets
|
||||
| MountOptionsPointer
|
||||
| MountOptionsBackup
|
||||
|
||||
/** Mount options for binding a service volume into a subcontainer */
|
||||
export type MountOptionsVolume = {
|
||||
type: 'volume'
|
||||
volumeId: string
|
||||
@@ -918,6 +957,7 @@ export type MountOptionsVolume = {
|
||||
idmap: IdMap[]
|
||||
}
|
||||
|
||||
/** Mount options for binding packaged static assets into a subcontainer */
|
||||
export type MountOptionsAssets = {
|
||||
type: 'assets'
|
||||
subpath: string | null
|
||||
@@ -925,6 +965,7 @@ export type MountOptionsAssets = {
|
||||
idmap: { fromId: number; toId: number; range: number }[]
|
||||
}
|
||||
|
||||
/** Mount options for binding a dependency package's volume into a subcontainer */
|
||||
export type MountOptionsPointer = {
|
||||
type: 'pointer'
|
||||
packageId: string
|
||||
@@ -934,6 +975,7 @@ export type MountOptionsPointer = {
|
||||
idmap: { fromId: number; toId: number; range: number }[]
|
||||
}
|
||||
|
||||
/** Mount options for binding the backup directory into a subcontainer */
|
||||
export type MountOptionsBackup = {
|
||||
type: 'backup'
|
||||
subpath: string | null
|
||||
@@ -944,6 +986,10 @@ function wait(time: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, time))
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a subcontainer command exits with a non-zero code or signal.
|
||||
* Contains the full result including stdout, stderr, exit code, and exit signal.
|
||||
*/
|
||||
export class ExitError extends Error {
|
||||
constructor(
|
||||
readonly command: string,
|
||||
|
||||
@@ -84,8 +84,17 @@ function filterUndefined<A>(a: A): A {
|
||||
return a
|
||||
}
|
||||
|
||||
/**
|
||||
* Bidirectional transformers for converting between the raw file format and
|
||||
* the application-level data type. Used with FileHelper factory methods.
|
||||
*
|
||||
* @typeParam Raw - The native type the file format parses to (e.g. `Record<string, unknown>` for JSON)
|
||||
* @typeParam Transformed - The application-level type after transformation
|
||||
*/
|
||||
export type Transformers<Raw = unknown, Transformed = unknown> = {
|
||||
/** Transform raw parsed data into the application type */
|
||||
onRead: (value: Raw) => Transformed
|
||||
/** Transform application data back into the raw format for writing */
|
||||
onWrite: (value: Transformed) => Raw
|
||||
}
|
||||
|
||||
@@ -343,6 +352,19 @@ export class FileHelper<A> {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive reader for this file.
|
||||
*
|
||||
* Returns an object with multiple read strategies:
|
||||
* - `once()` - Read the file once and return the parsed value
|
||||
* - `const(effects)` - Read once but re-read when the file changes (for use with constRetry)
|
||||
* - `watch(effects)` - Async generator yielding new values on each file change
|
||||
* - `onChange(effects, callback)` - Fire a callback on each file change
|
||||
* - `waitFor(effects, predicate)` - Block until the file value satisfies a predicate
|
||||
*
|
||||
* @param map - Optional transform function applied after validation
|
||||
* @param eq - Optional equality function to deduplicate watch emissions
|
||||
*/
|
||||
read(): ReadType<A>
|
||||
read<B>(
|
||||
map: (value: A) => B,
|
||||
@@ -575,6 +597,11 @@ export class FileHelper<A> {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a File Helper for a .ini file.
|
||||
*
|
||||
* Supports optional encode/decode options and custom transformers.
|
||||
*/
|
||||
static ini<A extends Record<string, unknown>>(
|
||||
path: ToPath,
|
||||
shape: Validator<Record<string, unknown>, A>,
|
||||
@@ -601,6 +628,11 @@ export class FileHelper<A> {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a File Helper for a .env file (KEY=VALUE format, one per line).
|
||||
*
|
||||
* Lines starting with `#` are treated as comments and ignored on read.
|
||||
*/
|
||||
static env<A extends Record<string, string>>(
|
||||
path: ToPath,
|
||||
shape: Validator<Record<string, string>, A>,
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
import { Graph, Vertex, once } from '../util'
|
||||
import { IMPOSSIBLE, VersionInfo } from './VersionInfo'
|
||||
|
||||
/**
|
||||
* Read the current data version from the effects system.
|
||||
* @param effects - The effects context
|
||||
* @returns The parsed ExtendedVersion or VersionRange, or null if no version is set
|
||||
*/
|
||||
export async function getDataVersion(effects: T.Effects) {
|
||||
const versionStr = await effects.getDataVersion()
|
||||
if (!versionStr) return null
|
||||
@@ -22,6 +27,11 @@ export async function getDataVersion(effects: T.Effects) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a data version to the effects system.
|
||||
* @param effects - The effects context
|
||||
* @param version - The version to set, or null to clear it
|
||||
*/
|
||||
export async function setDataVersion(
|
||||
effects: T.Effects,
|
||||
version: ExtendedVersion | VersionRange | null,
|
||||
@@ -37,6 +47,14 @@ function isRange(v: ExtendedVersion | VersionRange): v is VersionRange {
|
||||
return 'satisfiedBy' in v
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether two version specifiers overlap (i.e. share at least one common version).
|
||||
* Works with any combination of ExtendedVersion and VersionRange.
|
||||
*
|
||||
* @param a - First version or range
|
||||
* @param b - Second version or range
|
||||
* @returns True if the two specifiers overlap
|
||||
*/
|
||||
export function overlaps(
|
||||
a: ExtendedVersion | VersionRange,
|
||||
b: ExtendedVersion | VersionRange,
|
||||
@@ -49,6 +67,16 @@ export function overlaps(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A directed graph of service versions and their migration paths.
|
||||
*
|
||||
* Builds a graph from {@link VersionInfo} definitions, then uses shortest-path
|
||||
* search to find and execute migration sequences between any two versions.
|
||||
* Implements both {@link InitScript} (for install/update migrations) and
|
||||
* {@link UninitScript} (for uninstall/downgrade migrations).
|
||||
*
|
||||
* @typeParam CurrentVersion - The string literal type of the current service version
|
||||
*/
|
||||
export class VersionGraph<CurrentVersion extends string>
|
||||
implements InitScript, UninitScript
|
||||
{
|
||||
@@ -58,6 +86,7 @@ export class VersionGraph<CurrentVersion extends string>
|
||||
ExtendedVersion | VersionRange,
|
||||
((opts: { effects: T.Effects }) => Promise<void>) | undefined
|
||||
>
|
||||
/** Dump the version graph as a human-readable string for debugging */
|
||||
dump(): string {
|
||||
return this.graph().dump((metadata) => metadata?.toString())
|
||||
}
|
||||
@@ -168,6 +197,18 @@ export class VersionGraph<CurrentVersion extends string>
|
||||
>(options: { current: VersionInfo<CurrentVersion>; other: OtherVersions }) {
|
||||
return new VersionGraph(options.current, options.other)
|
||||
}
|
||||
/**
|
||||
* Execute the shortest migration path between two versions.
|
||||
*
|
||||
* Finds the shortest path in the version graph from `from` to `to`,
|
||||
* executes each migration step in order, and updates the data version after each step.
|
||||
*
|
||||
* @param options.effects - The effects context
|
||||
* @param options.from - The source version or range
|
||||
* @param options.to - The target version or range
|
||||
* @returns The final data version after migration
|
||||
* @throws If no migration path exists between the two versions
|
||||
*/
|
||||
async migrate({
|
||||
effects,
|
||||
from,
|
||||
@@ -217,6 +258,10 @@ export class VersionGraph<CurrentVersion extends string>
|
||||
`cannot migrate from ${from.toString()} to ${to.toString()}`,
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Compute the version range from which the current version can be reached via migration.
|
||||
* Uses reverse breadth-first search from the current version vertex.
|
||||
*/
|
||||
canMigrateFrom = once(() =>
|
||||
Array.from(
|
||||
this.graph().reverseBreadthFirstSearch((v) =>
|
||||
@@ -234,6 +279,10 @@ export class VersionGraph<CurrentVersion extends string>
|
||||
)
|
||||
.normalize(),
|
||||
)
|
||||
/**
|
||||
* Compute the version range that the current version can migrate to.
|
||||
* Uses forward breadth-first search from the current version vertex.
|
||||
*/
|
||||
canMigrateTo = once(() =>
|
||||
Array.from(
|
||||
this.graph().breadthFirstSearch((v) =>
|
||||
@@ -252,6 +301,11 @@ export class VersionGraph<CurrentVersion extends string>
|
||||
.normalize(),
|
||||
)
|
||||
|
||||
/**
|
||||
* InitScript implementation: migrate from the stored data version to the current version.
|
||||
* If no data version exists (fresh install), sets it to the current version.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
async init(effects: T.Effects): Promise<void> {
|
||||
const from = await getDataVersion(effects)
|
||||
if (from) {
|
||||
@@ -265,6 +319,13 @@ export class VersionGraph<CurrentVersion extends string>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UninitScript implementation: migrate from the current data version to the target version.
|
||||
* Used during uninstall or downgrade to prepare data for the target version.
|
||||
*
|
||||
* @param effects - The effects context
|
||||
* @param target - The target version to migrate to, or null to clear the data version
|
||||
*/
|
||||
async uninit(
|
||||
effects: T.Effects,
|
||||
target: VersionRange | ExtendedVersion | null,
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { ValidateExVer } from '../../../base/lib/exver'
|
||||
import * as T from '../../../base/lib/types'
|
||||
|
||||
/**
|
||||
* Sentinel value indicating that a migration in a given direction is not possible.
|
||||
* Use this for `migrations.up` or `migrations.down` to prevent migration.
|
||||
*/
|
||||
export const IMPOSSIBLE: unique symbol = Symbol('IMPOSSIBLE')
|
||||
|
||||
/**
|
||||
* Configuration options for a single service version definition.
|
||||
*
|
||||
* @typeParam Version - The string literal exver version number
|
||||
*/
|
||||
export type VersionOptions<Version extends string> = {
|
||||
/** The exver-compliant version number */
|
||||
version: Version & ValidateExVer<Version>
|
||||
@@ -33,6 +42,14 @@ export type VersionOptions<Version extends string> = {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single version of the service, including its release notes,
|
||||
* migration scripts, and backwards-compatibility declarations.
|
||||
*
|
||||
* By convention, each version gets its own file (e.g. `versions/v1_0_0.ts`).
|
||||
*
|
||||
* @typeParam Version - The string literal exver version number
|
||||
*/
|
||||
export class VersionInfo<Version extends string> {
|
||||
private _version: null | Version = null
|
||||
private constructor(
|
||||
|
||||
4
sdk/package/package-lock.json
generated
4
sdk/package/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.51",
|
||||
"version": "0.4.0-beta.52",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.51",
|
||||
"version": "0.4.0-beta.52",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^3.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.51",
|
||||
"version": "0.4.0-beta.52",
|
||||
"description": "Software development kit to facilitate packaging services for StartOS",
|
||||
"main": "./package/lib/index.js",
|
||||
"types": "./package/lib/index.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user