mirror of
https://github.com/smogon/sprites.git
synced 2026-08-26 19:56:45 -05:00
Convert declarations to the house shapes
Interfaces become type aliases with comma members (extends becomes intersection), and class privates use #names instead of the private keyword. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ export type Sprite = Artifact | SrcFile;
|
||||
// The unhashed -> hashed name mapping published beside a stamped set.
|
||||
export class Manifest {
|
||||
readonly ctx: DeployCtx;
|
||||
private entries = new Map<string, string>();
|
||||
#entries = new Map<string, string>();
|
||||
|
||||
constructor(ctx: DeployCtx) {
|
||||
this.ctx = ctx;
|
||||
@@ -20,25 +20,25 @@ export class Manifest {
|
||||
set(key: string, value: string): void {
|
||||
// ActionQueue only dedups final dsts; hashed dsts differ even when
|
||||
// unhashed names collide, so check the key explicitly.
|
||||
if (this.entries.has(key)) {
|
||||
if (this.#entries.has(key)) {
|
||||
throw new Error(`duplicate sprite name ${key}`);
|
||||
}
|
||||
this.entries.set(key, value);
|
||||
this.#entries.set(key, value);
|
||||
}
|
||||
|
||||
write(dst: string): void {
|
||||
let sorted: Record<string, string> = {};
|
||||
for (let k of [...this.entries.keys()].sort()) {
|
||||
sorted[k] = this.entries.get(k)!;
|
||||
for (let k of [...this.#entries.keys()].sort()) {
|
||||
sorted[k] = this.#entries.get(k)!;
|
||||
}
|
||||
this.ctx.write(dst, JSON.stringify(sorted, null, 4) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
export interface Dest {
|
||||
dir: string;
|
||||
ext?: string;
|
||||
}
|
||||
export type Dest = {
|
||||
dir: string,
|
||||
ext?: string,
|
||||
};
|
||||
|
||||
function extOf(f: Sprite, ext?: string): string {
|
||||
let result = ext ?? f.ext;
|
||||
|
||||
@@ -15,7 +15,7 @@ export class Artifact {
|
||||
readonly ext: string; // "png", "gif", ... (no dot)
|
||||
readonly decl: RuleDecl;
|
||||
readonly index: number; // position among decl.outputs
|
||||
private digest: string | null = null;
|
||||
#digest: string | null = null;
|
||||
|
||||
constructor(name: string, ext: string, decl: RuleDecl, index: number) {
|
||||
this.name = name;
|
||||
@@ -34,46 +34,46 @@ export class Artifact {
|
||||
}
|
||||
|
||||
get hash(): string {
|
||||
if (this.digest === null) {
|
||||
if (this.#digest === null) {
|
||||
throw new Error(`Artifact ${this.filename} has not been built yet`);
|
||||
}
|
||||
return this.digest;
|
||||
return this.#digest;
|
||||
}
|
||||
|
||||
resolve(digest: string): void {
|
||||
if (this.digest !== null && this.digest !== digest) {
|
||||
if (this.#digest !== null && this.#digest !== digest) {
|
||||
throw new Error(`Artifact ${this.filename} resolved twice with different digests`);
|
||||
}
|
||||
this.digest = digest;
|
||||
this.#digest = digest;
|
||||
}
|
||||
}
|
||||
|
||||
export type Input = string | Artifact; // string = source path relative to repo root
|
||||
|
||||
export interface CmdSpec {
|
||||
display?: string;
|
||||
export type CmdSpec = {
|
||||
display?: string,
|
||||
// Tracked-but-not-substituted inputs: part of rule identity, but never
|
||||
// expanded into %f. Use for files a tool reads on its own (e.g.
|
||||
// tools/sheet readdirs the minisprite directories).
|
||||
deps?: Input | Input[];
|
||||
deps?: Input | Input[],
|
||||
// IMPORTANT: set this on any rule whose output BYTES depend on input
|
||||
// NAMES (a tool that readdirs, or parses ids out of %f filenames, e.g.
|
||||
// the spritesheet builders). Identity is normally content-only, so
|
||||
// without this flag a same-bytes rename would leave the output stale.
|
||||
nameSensitive?: boolean;
|
||||
cmds: subst.Cmd[];
|
||||
}
|
||||
nameSensitive?: boolean,
|
||||
cmds: subst.Cmd[],
|
||||
};
|
||||
|
||||
export interface RuleDecl {
|
||||
export type RuleDecl = {
|
||||
id: number; // registration order; identity for artifact inputs
|
||||
inputs: Input[]; // ordered (%f order; some rules are order-sensitive)
|
||||
deps: Input[];
|
||||
outputs: Artifact[];
|
||||
deps: Input[],
|
||||
outputs: Artifact[],
|
||||
cmds: string[]; // flattened PRE-substitution templates
|
||||
display: string | null; // nominally substituted; cosmetic
|
||||
displayTemplate: string | null; // pre-substitution; groups forEach rules
|
||||
nameSensitive: boolean;
|
||||
}
|
||||
nameSensitive: boolean,
|
||||
};
|
||||
|
||||
let decls: RuleDecl[] = [];
|
||||
// Declaring an identical rule twice returns the existing artifacts, so
|
||||
|
||||
@@ -31,10 +31,10 @@ export function casStat(casDir: string, digest: string, ext: string): bigint | n
|
||||
}
|
||||
}
|
||||
|
||||
export interface CasObject {
|
||||
export type CasObject = {
|
||||
digest: string; // sha256 hex of the bytes
|
||||
size: bigint;
|
||||
}
|
||||
size: bigint,
|
||||
};
|
||||
|
||||
// Move tmpPath into the store, returning the content digest and size. An
|
||||
// existing object is trusted only if its bytes actually hash to the digest;
|
||||
|
||||
@@ -6,28 +6,28 @@ import * as executor from './executor.ts';
|
||||
import {reconcileHashes} from './hash.ts';
|
||||
import {type Store} from './store.ts';
|
||||
|
||||
export interface BuildOpts {
|
||||
root: string;
|
||||
store: Store;
|
||||
export type BuildOpts = {
|
||||
root: string,
|
||||
store: Store,
|
||||
casDir: string; // relative to root; substituted into commands
|
||||
tmpDir: string;
|
||||
jobs: number;
|
||||
dryRun: boolean;
|
||||
failFast: boolean;
|
||||
verbose: boolean;
|
||||
tmpDir: string,
|
||||
jobs: number,
|
||||
dryRun: boolean,
|
||||
failFast: boolean,
|
||||
verbose: boolean,
|
||||
// GC after a fully successful build: drop rules whose key is no longer
|
||||
// declared, sweep unreferenced CAS objects, prune the file cache. Only
|
||||
// safe when `decls` is the full rule universe (a partial build would GC
|
||||
// the other deploys' state), so the CLI sets it for union builds only.
|
||||
gc: boolean;
|
||||
signal: AbortSignal;
|
||||
log?: (line: string) => void;
|
||||
logError?: (line: string) => void;
|
||||
}
|
||||
gc: boolean,
|
||||
signal: AbortSignal,
|
||||
log?: (line: string) => void,
|
||||
logError?: (line: string) => void,
|
||||
};
|
||||
|
||||
export interface DriveResult extends executor.BuildResult {
|
||||
interrupted: boolean;
|
||||
}
|
||||
export type DriveResult = executor.BuildResult & {
|
||||
interrupted: boolean,
|
||||
};
|
||||
|
||||
export async function build(decls: readonly RuleDecl[], opts: BuildOpts): Promise<DriveResult> {
|
||||
let log = opts.log ?? console.log;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
|
||||
import {spawn} from 'node:child_process';
|
||||
|
||||
export interface ExecResult {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
output: string;
|
||||
durationMs: number;
|
||||
}
|
||||
export type ExecResult = {
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
output: string,
|
||||
durationMs: number,
|
||||
};
|
||||
|
||||
let livePids = new Set<number>();
|
||||
|
||||
|
||||
@@ -18,26 +18,26 @@ export type RuleOutcome =
|
||||
| {status: 'failed', message: string}
|
||||
| {status: 'blocked'}; // a producer failed
|
||||
|
||||
export interface BuildResult {
|
||||
export type BuildResult = {
|
||||
outcomes: Map<artifact.RuleDecl, RuleOutcome>; // no entry = not attempted (aborted)
|
||||
keys: Map<artifact.RuleDecl, string>; // only decls whose key resolved
|
||||
ok: boolean; // every decl clean or ran
|
||||
}
|
||||
};
|
||||
|
||||
export interface ExecutorOpts {
|
||||
export type ExecutorOpts = {
|
||||
root: string; // cwd for commands
|
||||
store: Store;
|
||||
store: Store,
|
||||
casDir: string; // relative to root (substituted into commands)
|
||||
tmpDir: string;
|
||||
jobs: number;
|
||||
dryRun: boolean;
|
||||
failFast: boolean;
|
||||
tmpDir: string,
|
||||
jobs: number,
|
||||
dryRun: boolean,
|
||||
failFast: boolean,
|
||||
verbose?: boolean; // annotate run lines with the dirty reason
|
||||
sourceHashes: Map<string, Buffer>; // every source input/dep, pre-reconciled
|
||||
signal: AbortSignal;
|
||||
log?: (line: string) => void;
|
||||
logError?: (line: string) => void;
|
||||
}
|
||||
signal: AbortSignal,
|
||||
log?: (line: string) => void,
|
||||
logError?: (line: string) => void,
|
||||
};
|
||||
|
||||
export function label(decl: artifact.RuleDecl): string {
|
||||
return decl.display ?? decl.cmds[0]!;
|
||||
@@ -54,27 +54,27 @@ class DryDirty extends Error {}
|
||||
class Aborted extends Error {}
|
||||
|
||||
class Semaphore {
|
||||
private available: number;
|
||||
private waiters: (() => void)[] = [];
|
||||
#available: number;
|
||||
#waiters: (() => void)[] = [];
|
||||
|
||||
constructor(n: number) {
|
||||
this.available = n;
|
||||
this.#available = n;
|
||||
}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
if (this.available > 0) {
|
||||
this.available--;
|
||||
if (this.#available > 0) {
|
||||
this.#available--;
|
||||
return;
|
||||
}
|
||||
await new Promise<void>(resolve => this.waiters.push(resolve));
|
||||
await new Promise<void>(resolve => this.#waiters.push(resolve));
|
||||
}
|
||||
|
||||
release(): void {
|
||||
let waiter = this.waiters.shift();
|
||||
let waiter = this.#waiters.shift();
|
||||
if (waiter !== undefined) {
|
||||
waiter();
|
||||
} else {
|
||||
this.available++;
|
||||
this.#available++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,144 +85,144 @@ class Semaphore {
|
||||
// The artifact graph is a DAG by construction (a rule can only reference
|
||||
// artifacts that already exist as values), so there is no cycle check.
|
||||
export class Executor {
|
||||
private opts: ExecutorOpts;
|
||||
private memo = new Map<artifact.RuleDecl, Promise<string[]>>();
|
||||
private inflightByKey = new Map<string, Promise<string[]>>();
|
||||
private outcomes = new Map<artifact.RuleDecl, RuleOutcome>();
|
||||
private keys = new Map<artifact.RuleDecl, string>();
|
||||
private semaphore: Semaphore;
|
||||
private failAc = new AbortController();
|
||||
private runSignal: AbortSignal;
|
||||
private counter = 0;
|
||||
private tmpSeq = 0;
|
||||
private log: (line: string) => void;
|
||||
private logError: (line: string) => void;
|
||||
#opts: ExecutorOpts;
|
||||
#memo = new Map<artifact.RuleDecl, Promise<string[]>>();
|
||||
#inflightByKey = new Map<string, Promise<string[]>>();
|
||||
#outcomes = new Map<artifact.RuleDecl, RuleOutcome>();
|
||||
#keys = new Map<artifact.RuleDecl, string>();
|
||||
#semaphore: Semaphore;
|
||||
#failAc = new AbortController();
|
||||
#runSignal: AbortSignal;
|
||||
#counter = 0;
|
||||
#tmpSeq = 0;
|
||||
#log: (line: string) => void;
|
||||
#logError: (line: string) => void;
|
||||
|
||||
constructor(opts: ExecutorOpts) {
|
||||
this.opts = opts;
|
||||
this.semaphore = new Semaphore(opts.jobs);
|
||||
this.runSignal = AbortSignal.any([opts.signal, this.failAc.signal]);
|
||||
this.log = opts.log ?? console.log;
|
||||
this.logError = opts.logError ?? console.error;
|
||||
this.#opts = opts;
|
||||
this.#semaphore = new Semaphore(opts.jobs);
|
||||
this.#runSignal = AbortSignal.any([opts.signal, this.#failAc.signal]);
|
||||
this.#log = opts.log ?? console.log;
|
||||
this.#logError = opts.logError ?? console.error;
|
||||
}
|
||||
|
||||
async build(decls: readonly artifact.RuleDecl[]): Promise<BuildResult> {
|
||||
await Promise.allSettled(decls.map(d => this.demand(d)));
|
||||
await Promise.allSettled(decls.map(d => this.#demand(d)));
|
||||
let ok = decls.every(d => {
|
||||
let status = this.outcomes.get(d)?.status;
|
||||
let status = this.#outcomes.get(d)?.status;
|
||||
return status === 'clean' || status === 'ran';
|
||||
});
|
||||
return {outcomes: this.outcomes, keys: this.keys, ok};
|
||||
return {outcomes: this.#outcomes, keys: this.#keys, ok};
|
||||
}
|
||||
|
||||
private demand(decl: artifact.RuleDecl): Promise<string[]> {
|
||||
let p = this.memo.get(decl);
|
||||
#demand(decl: artifact.RuleDecl): Promise<string[]> {
|
||||
let p = this.#memo.get(decl);
|
||||
if (p === undefined) {
|
||||
// Any non-sentinel escape (a store error, a resolve conflict, a
|
||||
// bug) must surface as a reported failure, not vanish into the
|
||||
// allSettled in build().
|
||||
p = this.demandInner(decl).catch(err => {
|
||||
p = this.#demandInner(decl).catch(err => {
|
||||
if (err instanceof RuleFailed || err instanceof DryDirty || err instanceof Aborted) {
|
||||
throw err;
|
||||
}
|
||||
this.outcomes.set(decl, {status: 'failed', message: 'internal error'});
|
||||
this.logError(`FAILED (internal error): ${label(decl)}`);
|
||||
this.logError(indent(err instanceof Error ? err.stack ?? err.message : String(err)));
|
||||
this.failAc.abort();
|
||||
this.#outcomes.set(decl, {status: 'failed', message: 'internal error'});
|
||||
this.#logError(`FAILED (internal error): ${label(decl)}`);
|
||||
this.#logError(indent(err instanceof Error ? err.stack ?? err.message : String(err)));
|
||||
this.#failAc.abort();
|
||||
throw new RuleFailed();
|
||||
});
|
||||
this.memo.set(decl, p);
|
||||
this.#memo.set(decl, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private async digestOf(i: artifact.Input): Promise<string> {
|
||||
async #digestOf(i: artifact.Input): Promise<string> {
|
||||
if (typeof i !== 'string') {
|
||||
await this.demand(i.decl);
|
||||
await this.#demand(i.decl);
|
||||
return i.hash;
|
||||
}
|
||||
let hash = this.opts.sourceHashes.get(i);
|
||||
let hash = this.#opts.sourceHashes.get(i);
|
||||
if (hash === undefined) {
|
||||
throw new BuildError(`No hash for source ${i}`);
|
||||
}
|
||||
return hash.toString('hex');
|
||||
}
|
||||
|
||||
private async demandInner(decl: artifact.RuleDecl): Promise<string[]> {
|
||||
async #demandInner(decl: artifact.RuleDecl): Promise<string[]> {
|
||||
let digests: Map<artifact.Input, string>;
|
||||
try {
|
||||
let inputs = [...decl.inputs, ...decl.deps];
|
||||
let resolved = await Promise.all(inputs.map(i => this.digestOf(i)));
|
||||
let resolved = await Promise.all(inputs.map(i => this.#digestOf(i)));
|
||||
digests = new Map(inputs.map((i, n) => [i, resolved[n]!]));
|
||||
} catch (err) {
|
||||
if (err instanceof RuleFailed) {
|
||||
this.outcomes.set(decl, {status: 'blocked'});
|
||||
this.#outcomes.set(decl, {status: 'blocked'});
|
||||
} else if (err instanceof DryDirty) {
|
||||
this.outcomes.set(decl, {status: 'would-run', reason: 'blocked'});
|
||||
this.log(`would run (blocked by dirty producer): ${label(decl)}`);
|
||||
this.#outcomes.set(decl, {status: 'would-run', reason: 'blocked'});
|
||||
this.#log(`would run (blocked by dirty producer): ${label(decl)}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let key = artifact.computeKey(decl, i => digests.get(i)!);
|
||||
this.keys.set(decl, key);
|
||||
this.#keys.set(decl, key);
|
||||
|
||||
// Byte-identical duplicate declarations share one execution.
|
||||
let existing = this.inflightByKey.get(key);
|
||||
let existing = this.#inflightByKey.get(key);
|
||||
if (existing !== undefined) {
|
||||
try {
|
||||
let shared = await existing;
|
||||
decl.outputs.forEach((o, n) => o.resolve(shared[n]!));
|
||||
this.outcomes.set(decl, {status: 'clean'});
|
||||
this.#outcomes.set(decl, {status: 'clean'});
|
||||
return shared;
|
||||
} catch (err) {
|
||||
if (err instanceof RuleFailed) {
|
||||
this.outcomes.set(decl, {status: 'blocked'});
|
||||
this.#outcomes.set(decl, {status: 'blocked'});
|
||||
} else if (err instanceof DryDirty) {
|
||||
this.outcomes.set(decl, {status: 'would-run', reason: 'blocked'});
|
||||
this.#outcomes.set(decl, {status: 'would-run', reason: 'blocked'});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
let work = this.perform(decl, key);
|
||||
this.inflightByKey.set(key, work);
|
||||
let work = this.#perform(decl, key);
|
||||
this.#inflightByKey.set(key, work);
|
||||
let result = await work;
|
||||
decl.outputs.forEach((o, n) => o.resolve(result[n]!));
|
||||
return result;
|
||||
}
|
||||
|
||||
private async perform(decl: artifact.RuleDecl, key: string): Promise<string[]> {
|
||||
let {store, casDir} = this.opts;
|
||||
async #perform(decl: artifact.RuleDecl, key: string): Promise<string[]> {
|
||||
let {store, casDir} = this.#opts;
|
||||
let stored = store.lookupRule(key);
|
||||
if (stored !== null
|
||||
&& stored.length === decl.outputs.length
|
||||
&& stored.every((o, n) => o.ext === decl.outputs[n]!.ext)
|
||||
&& stored.every(o => cas.casStat(casDir, o.digest, o.ext) === o.size)) {
|
||||
this.outcomes.set(decl, {status: 'clean'});
|
||||
this.#outcomes.set(decl, {status: 'clean'});
|
||||
return stored.map(o => o.digest);
|
||||
}
|
||||
let reason: DirtyReason = stored === null ? 'new' : 'cas-missing';
|
||||
|
||||
if (this.opts.dryRun) {
|
||||
this.outcomes.set(decl, {status: 'would-run', reason});
|
||||
this.log(`would run (${reason}): ${label(decl)}`);
|
||||
if (this.#opts.dryRun) {
|
||||
this.#outcomes.set(decl, {status: 'would-run', reason});
|
||||
this.#log(`would run (${reason}): ${label(decl)}`);
|
||||
throw new DryDirty();
|
||||
}
|
||||
|
||||
await this.semaphore.acquire();
|
||||
await this.#semaphore.acquire();
|
||||
try {
|
||||
if (this.runSignal.aborted) {
|
||||
if (this.#runSignal.aborted) {
|
||||
throw new Aborted();
|
||||
}
|
||||
return await this.execute(decl, key, reason);
|
||||
return await this.#execute(decl, key, reason);
|
||||
} finally {
|
||||
this.semaphore.release();
|
||||
this.#semaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async execute(decl: artifact.RuleDecl, key: string, reason: DirtyReason): Promise<string[]> {
|
||||
let {store, casDir, tmpDir, root} = this.opts;
|
||||
let ruleTmp = pathlib.join(tmpDir, String(this.tmpSeq++));
|
||||
async #execute(decl: artifact.RuleDecl, key: string, reason: DirtyReason): Promise<string[]> {
|
||||
let {store, casDir, tmpDir, root} = this.#opts;
|
||||
let ruleTmp = pathlib.join(tmpDir, String(this.#tmpSeq++));
|
||||
fs.mkdirSync(ruleTmp, {recursive: true});
|
||||
let tempOutputs = decl.outputs.map(o => pathlib.join(ruleTmp, o.filename));
|
||||
let concreteInputs = decl.inputs.map(
|
||||
@@ -230,8 +230,8 @@ export class Executor {
|
||||
let command = decl.cmds.map(c => substitute(c, concreteInputs, tempOutputs)).join(' && ');
|
||||
|
||||
try {
|
||||
let result = await runShell(command, {cwd: root, signal: this.runSignal});
|
||||
if (this.runSignal.aborted && result.code !== 0) {
|
||||
let result = await runShell(command, {cwd: root, signal: this.#runSignal});
|
||||
if (this.#runSignal.aborted && result.code !== 0) {
|
||||
throw new Aborted(); // killed by the abort, not a real failure; stays dirty
|
||||
}
|
||||
if (result.code === 0) {
|
||||
@@ -242,18 +242,18 @@ export class Executor {
|
||||
return {digest: object.digest, ext: o.ext, size: object.size};
|
||||
});
|
||||
store.recordRule(key, decl.cmds, outputs);
|
||||
this.outcomes.set(decl, {status: 'ran', reason});
|
||||
this.log(`[${++this.counter}] ${label(decl)}`
|
||||
+ (this.opts.verbose ? ` (${reason})` : ''));
|
||||
this.#outcomes.set(decl, {status: 'ran', reason});
|
||||
this.#log(`[${++this.#counter}] ${label(decl)}`
|
||||
+ (this.#opts.verbose ? ` (${reason})` : ''));
|
||||
if (result.output !== '') {
|
||||
this.log(indent(result.output));
|
||||
this.#log(indent(result.output));
|
||||
}
|
||||
return outputs.map(o => o.digest);
|
||||
}
|
||||
return this.fail(decl, command, result.output,
|
||||
return this.#fail(decl, command, result.output,
|
||||
`command succeeded but did not produce: ${missing.map(p => pathlib.basename(p)).join(' ')}`);
|
||||
}
|
||||
return this.fail(decl, command, result.output, `exit status ${result.code ?? result.signal}`);
|
||||
return this.#fail(decl, command, result.output, `exit status ${result.code ?? result.signal}`);
|
||||
} finally {
|
||||
fs.rmSync(ruleTmp, {recursive: true, force: true});
|
||||
}
|
||||
@@ -261,15 +261,15 @@ export class Executor {
|
||||
|
||||
// Nothing is recorded for a failure: failed and never-ran are the same
|
||||
// state, so the rule stays dirty.
|
||||
private fail(decl: artifact.RuleDecl, command: string, output: string, message: string): never {
|
||||
this.outcomes.set(decl, {status: 'failed', message});
|
||||
this.logError(`FAILED: ${label(decl)} (${message})`);
|
||||
this.logError(` command: ${command}`);
|
||||
#fail(decl: artifact.RuleDecl, command: string, output: string, message: string): never {
|
||||
this.#outcomes.set(decl, {status: 'failed', message});
|
||||
this.#logError(`FAILED: ${label(decl)} (${message})`);
|
||||
this.#logError(` command: ${command}`);
|
||||
if (output !== '') {
|
||||
this.logError(indent(output));
|
||||
this.#logError(indent(output));
|
||||
}
|
||||
if (this.opts.failFast) {
|
||||
this.failAc.abort();
|
||||
if (this.#opts.failFast) {
|
||||
this.#failAc.abort();
|
||||
}
|
||||
throw new RuleFailed();
|
||||
}
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
import * as fs from 'node:fs';
|
||||
import {createHash} from 'node:crypto';
|
||||
|
||||
export interface FileStat {
|
||||
size: bigint;
|
||||
mtimeNs: bigint;
|
||||
hash: Buffer;
|
||||
}
|
||||
export type FileStat = {
|
||||
size: bigint,
|
||||
mtimeNs: bigint,
|
||||
hash: Buffer,
|
||||
};
|
||||
|
||||
export function hashFileSync(path: string): Buffer {
|
||||
return createHash('sha256').update(fs.readFileSync(path)).digest();
|
||||
}
|
||||
|
||||
export interface ReconcileResult {
|
||||
export type ReconcileResult = {
|
||||
hashes: Map<string, Buffer>; // current content hash for every extant path
|
||||
updated: Map<string, FileStat>; // cache entries that changed (to persist)
|
||||
missing: string[]; // paths that don't exist or aren't files
|
||||
}
|
||||
};
|
||||
|
||||
// Content hashes with a stat cache: only files whose (size, mtime_ns) changed
|
||||
// since the recorded cache entry are rehashed. mtime_ns exceeds 2^53, hence
|
||||
|
||||
@@ -65,10 +65,10 @@ export function base(x: string | Artifact): string {
|
||||
return typeof x === 'string' ? basenameNoExt(x) : x.name;
|
||||
}
|
||||
|
||||
export interface SpriteData {
|
||||
id: string;
|
||||
data: Record<string, string | true>;
|
||||
}
|
||||
export type SpriteData = {
|
||||
id: string,
|
||||
data: Record<string, string | true>,
|
||||
};
|
||||
|
||||
// Port of util/sprites.lua spritedata. Lua used gmatch("[^-]+"), which skips
|
||||
// empty segments, hence the filter.
|
||||
@@ -111,11 +111,11 @@ export function trimimg(opts: {input?: string, output?: string} = {}): string {
|
||||
return `magick convert ${opts.input ?? '%f'} ${PNG_DETERMINISTIC} -trim ${opts.output ?? '%o'}`;
|
||||
}
|
||||
|
||||
interface CompressOpts {
|
||||
pngquant?: string;
|
||||
optipng?: string;
|
||||
advpng?: string;
|
||||
}
|
||||
type CompressOpts = {
|
||||
pngquant?: string,
|
||||
optipng?: string,
|
||||
advpng?: string,
|
||||
};
|
||||
|
||||
function compressopts(program: string, copts: CompressOpts): void {
|
||||
copts.pngquant = getconfig(`${program}_PNGQUANT`) ?? copts.pngquant;
|
||||
|
||||
@@ -6,11 +6,11 @@ import {DatabaseSync} from 'node:sqlite';
|
||||
import {BuildError} from './errors.ts';
|
||||
import type {FileStat} from './hash.ts';
|
||||
|
||||
export interface StoredOutput {
|
||||
export type StoredOutput = {
|
||||
digest: string; // sha256 hex of the bytes; the CAS object is <digest>.<ext>
|
||||
ext: string;
|
||||
ext: string,
|
||||
size: bigint; // verified against the object on every clean check
|
||||
}
|
||||
};
|
||||
|
||||
let DDL = `
|
||||
CREATE TABLE IF NOT EXISTS file_cache (
|
||||
@@ -45,38 +45,38 @@ function userVersion(db: DatabaseSync): number {
|
||||
}
|
||||
|
||||
export class Store {
|
||||
private db: DatabaseSync;
|
||||
#db: DatabaseSync;
|
||||
|
||||
constructor(dbPath: string) {
|
||||
fs.mkdirSync(pathlib.dirname(dbPath), {recursive: true});
|
||||
this.db = new DatabaseSync(dbPath, {readBigInts: true});
|
||||
this.db.exec('PRAGMA journal_mode = WAL');
|
||||
this.db.exec('PRAGMA foreign_keys = ON');
|
||||
this.db.exec('PRAGMA synchronous = NORMAL');
|
||||
this.migrate();
|
||||
this.#db = new DatabaseSync(dbPath, {readBigInts: true});
|
||||
this.#db.exec('PRAGMA journal_mode = WAL');
|
||||
this.#db.exec('PRAGMA foreign_keys = ON');
|
||||
this.#db.exec('PRAGMA synchronous = NORMAL');
|
||||
this.#migrate();
|
||||
}
|
||||
|
||||
private transaction<T>(fn: () => T): T {
|
||||
this.db.exec('BEGIN');
|
||||
#transaction<T>(fn: () => T): T {
|
||||
this.#db.exec('BEGIN');
|
||||
try {
|
||||
let result = fn();
|
||||
this.db.exec('COMMIT');
|
||||
this.#db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.db.exec('ROLLBACK');
|
||||
this.#db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private migrate(): void {
|
||||
let version = userVersion(this.db);
|
||||
#migrate(): void {
|
||||
let version = userVersion(this.#db);
|
||||
if (version === 0) {
|
||||
this.db.exec('BEGIN;' + DDL + 'PRAGMA user_version = 2; COMMIT;');
|
||||
this.#db.exec('BEGIN;' + DDL + 'PRAGMA user_version = 2; COMMIT;');
|
||||
} else if (version === 1) {
|
||||
// v1 stored fixed-name outputs and per-input hashes; none of it
|
||||
// maps to the content-addressed model. Keep the file_cache (same
|
||||
// schema, saves rehashing every source) and drop the rest.
|
||||
this.db.exec(`BEGIN;
|
||||
this.#db.exec(`BEGIN;
|
||||
DROP TABLE rule_inputs;
|
||||
DROP TABLE rule_outputs;
|
||||
DROP TABLE rules;
|
||||
@@ -90,7 +90,7 @@ export class Store {
|
||||
|
||||
loadFileCache(): Map<string, FileStat> {
|
||||
let result = new Map<string, FileStat>();
|
||||
let rows = this.db.prepare('SELECT path, size, mtime_ns, hash FROM file_cache').all() as
|
||||
let rows = this.#db.prepare('SELECT path, size, mtime_ns, hash FROM file_cache').all() as
|
||||
unknown as {path: string, size: bigint, mtime_ns: bigint, hash: Uint8Array}[];
|
||||
for (let row of rows) {
|
||||
let h = row.hash;
|
||||
@@ -101,11 +101,11 @@ export class Store {
|
||||
}
|
||||
|
||||
saveFileCache(entries: Map<string, FileStat>): void {
|
||||
let upsert = this.db.prepare(`
|
||||
let upsert = this.#db.prepare(`
|
||||
INSERT INTO file_cache (path, size, mtime_ns, hash) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
size = excluded.size, mtime_ns = excluded.mtime_ns, hash = excluded.hash`);
|
||||
this.transaction(() => {
|
||||
this.#transaction(() => {
|
||||
for (let [path, stat] of entries) {
|
||||
upsert.run(path, stat.size, stat.mtimeNs, stat.hash);
|
||||
}
|
||||
@@ -113,10 +113,10 @@ export class Store {
|
||||
}
|
||||
|
||||
pruneFileCache(live: Set<string>): void {
|
||||
let paths = this.db.prepare('SELECT path FROM file_cache').all() as
|
||||
let paths = this.#db.prepare('SELECT path FROM file_cache').all() as
|
||||
unknown as {path: string}[];
|
||||
let del = this.db.prepare('DELETE FROM file_cache WHERE path = ?');
|
||||
this.transaction(() => {
|
||||
let del = this.#db.prepare('DELETE FROM file_cache WHERE path = ?');
|
||||
this.#transaction(() => {
|
||||
for (let {path} of paths) {
|
||||
if (!live.has(path)) {
|
||||
del.run(path);
|
||||
@@ -126,13 +126,13 @@ export class Store {
|
||||
}
|
||||
|
||||
lookupRule(key: string): StoredOutput[] | null {
|
||||
return this.transaction(() => {
|
||||
let rule = this.db.prepare('SELECT id FROM rules WHERE key = ?').get(key) as
|
||||
return this.#transaction(() => {
|
||||
let rule = this.#db.prepare('SELECT id FROM rules WHERE key = ?').get(key) as
|
||||
{id: bigint} | undefined;
|
||||
if (rule === undefined) {
|
||||
return null;
|
||||
}
|
||||
let rows = this.db.prepare(
|
||||
let rows = this.#db.prepare(
|
||||
'SELECT digest, ext, size FROM rule_outputs WHERE rule_id = ? ORDER BY ord')
|
||||
.all(rule.id) as unknown as StoredOutput[];
|
||||
// node:sqlite rows have a null prototype; return plain objects.
|
||||
@@ -143,14 +143,14 @@ export class Store {
|
||||
// One transaction per completed rule: an interrupted build only ever
|
||||
// contains fully-recorded rules.
|
||||
recordRule(key: string, cmds: string[], outputs: StoredOutput[]): void {
|
||||
let upsert = this.db.prepare(`
|
||||
let upsert = this.#db.prepare(`
|
||||
INSERT INTO rules (key, cmds) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET cmds = excluded.cmds
|
||||
RETURNING id`);
|
||||
let delOutputs = this.db.prepare('DELETE FROM rule_outputs WHERE rule_id = ?');
|
||||
let insOutput = this.db.prepare(
|
||||
let delOutputs = this.#db.prepare('DELETE FROM rule_outputs WHERE rule_id = ?');
|
||||
let insOutput = this.#db.prepare(
|
||||
'INSERT INTO rule_outputs (rule_id, ord, digest, ext, size) VALUES (?, ?, ?, ?, ?)');
|
||||
this.transaction(() => {
|
||||
this.#transaction(() => {
|
||||
let {id} = upsert.get(key, cmds.join('\n')) as {id: bigint};
|
||||
delOutputs.run(id);
|
||||
outputs.forEach((o, i) => insOutput.run(id, i, o.digest, o.ext, o.size));
|
||||
@@ -159,11 +159,11 @@ export class Store {
|
||||
|
||||
// GC: drop every rule whose key is not live. Returns the removal count.
|
||||
deleteKeysNotIn(live: Set<string>): number {
|
||||
let keys = this.db.prepare('SELECT id, key FROM rules').all() as
|
||||
let keys = this.#db.prepare('SELECT id, key FROM rules').all() as
|
||||
unknown as {id: bigint, key: string}[];
|
||||
let del = this.db.prepare('DELETE FROM rules WHERE id = ?');
|
||||
let del = this.#db.prepare('DELETE FROM rules WHERE id = ?');
|
||||
let removed = 0;
|
||||
this.transaction(() => {
|
||||
this.#transaction(() => {
|
||||
for (let {id, key} of keys) {
|
||||
if (!live.has(key)) {
|
||||
del.run(id);
|
||||
@@ -176,14 +176,14 @@ export class Store {
|
||||
|
||||
// Every CAS object referenced by some rule, as "<digest>.<ext>" basenames.
|
||||
liveObjects(): Set<string> {
|
||||
let rows = this.db.prepare('SELECT digest, ext FROM rule_outputs').all() as
|
||||
let rows = this.#db.prepare('SELECT digest, ext FROM rule_outputs').all() as
|
||||
unknown as {digest: string, ext: string}[];
|
||||
return new Set(rows.map(r => `${r.digest}.${r.ext}`));
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
this.db.close();
|
||||
this.#db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
this.#db.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ import {Store} from '../store.ts';
|
||||
|
||||
beforeEach(resetDecls);
|
||||
|
||||
interface Env {
|
||||
root: string;
|
||||
dbPath: string;
|
||||
casDir: string;
|
||||
tmpDir: string;
|
||||
execLog: string;
|
||||
}
|
||||
type Env = {
|
||||
root: string,
|
||||
dbPath: string,
|
||||
casDir: string,
|
||||
tmpDir: string,
|
||||
execLog: string,
|
||||
};
|
||||
|
||||
function setup(): Env {
|
||||
let root = fs.mkdtempSync(pathlib.join(os.tmpdir(), 'executor-test-'));
|
||||
|
||||
@@ -11,24 +11,24 @@ import * as pathlib from './path.ts';
|
||||
|
||||
// A source-tree file from ctx.list(); shaped like a deploy Path plus the
|
||||
// repo-relative path.
|
||||
export interface SrcFile extends pathlib.Path {
|
||||
path: string;
|
||||
}
|
||||
export type SrcFile = pathlib.Path & {
|
||||
path: string,
|
||||
};
|
||||
|
||||
export type CopySource = Artifact | SrcFile | string; // string = repo-relative path
|
||||
|
||||
// The finish API: nice naming over built artifacts and raw sources. Ops are
|
||||
// queued in call order, which is the tar entry order.
|
||||
export interface DeployCtx {
|
||||
copy(src: CopySource, dst: string): void;
|
||||
write(dst: string, data: string): void;
|
||||
read(src: CopySource): string;
|
||||
list(dir: string): SrcFile[];
|
||||
export type DeployCtx = {
|
||||
copy(src: CopySource, dst: string): void,
|
||||
write(dst: string, data: string): void,
|
||||
read(src: CopySource): string,
|
||||
list(dir: string): SrcFile[],
|
||||
// 8-char base32 content stamp. One source: the digest of its bytes,
|
||||
// byte-compatible with artifact hashes. Several: a digest of the sorted
|
||||
// per-source digests (order-insensitive).
|
||||
hash(...srcs: CopySource[]): string;
|
||||
}
|
||||
hash(...srcs: CopySource[]): string,
|
||||
};
|
||||
|
||||
export type DeployFn = (ctx: DeployCtx) => void | Promise<void>;
|
||||
|
||||
|
||||
@@ -6,18 +6,18 @@ import JSON5 from 'json5';
|
||||
|
||||
import {BuildError} from '../build/errors.ts';
|
||||
|
||||
export interface DeployEntry {
|
||||
subset: string[];
|
||||
cmd: string;
|
||||
export type DeployEntry = {
|
||||
subset: string[],
|
||||
cmd: string,
|
||||
// dir entries get their subset materialized into a temp directory whose
|
||||
// path replaces %d in cmd; tar entries get the subset tarred on stdin.
|
||||
dir?: boolean;
|
||||
}
|
||||
dir?: boolean,
|
||||
};
|
||||
|
||||
export interface DeployTarget {
|
||||
buildFile: string;
|
||||
deploy: DeployEntry[];
|
||||
}
|
||||
export type DeployTarget = {
|
||||
buildFile: string,
|
||||
deploy: DeployEntry[],
|
||||
};
|
||||
|
||||
export type DeployConfig = Map<string, DeployTarget>;
|
||||
|
||||
|
||||
@@ -27,19 +27,19 @@ let LOCK_PATH = '.build/lock.sqlite';
|
||||
let CAS_DIR = '.build/cas';
|
||||
let TMP_DIR = '.build/tmp';
|
||||
|
||||
interface CommonOpts {
|
||||
jobs: string;
|
||||
dryRun?: boolean;
|
||||
failFast?: boolean;
|
||||
config: string;
|
||||
verbose?: boolean;
|
||||
}
|
||||
type CommonOpts = {
|
||||
jobs: string,
|
||||
dryRun?: boolean,
|
||||
failFast?: boolean,
|
||||
config: string,
|
||||
verbose?: boolean,
|
||||
};
|
||||
|
||||
interface VerbOpts extends CommonOpts {
|
||||
output?: string;
|
||||
link?: boolean;
|
||||
tar?: boolean;
|
||||
}
|
||||
type VerbOpts = CommonOpts & {
|
||||
output?: string,
|
||||
link?: boolean,
|
||||
tar?: boolean,
|
||||
};
|
||||
|
||||
let USAGE = `usage: node tools/deploy/index.ts <command> [options]
|
||||
|
||||
|
||||
@@ -28,17 +28,17 @@ type DebugEntry = {
|
||||
export type LogEntry = OpEntry | DebugEntry;
|
||||
|
||||
export class ActionQueue {
|
||||
private seen: Map<string, OpEntry | 'MoreThan1'>;
|
||||
#seen: Map<string, OpEntry | 'MoreThan1'>;
|
||||
// Have an accessor for this in the future? idk
|
||||
public log: LogEntry[];
|
||||
public valid: boolean;
|
||||
private debugBuffer: unknown[];
|
||||
#debugBuffer: unknown[];
|
||||
|
||||
constructor() {
|
||||
this.seen = new Map;
|
||||
this.#seen = new Map;
|
||||
this.log = [];
|
||||
this.valid = true;
|
||||
this.debugBuffer = [];
|
||||
this.#debugBuffer = [];
|
||||
}
|
||||
|
||||
throw(obj: unknown) {
|
||||
@@ -47,31 +47,31 @@ export class ActionQueue {
|
||||
}
|
||||
|
||||
debug(obj: unknown) {
|
||||
this.debugBuffer.push(obj);
|
||||
this.#debugBuffer.push(obj);
|
||||
}
|
||||
|
||||
gdebug(obj: unknown, stray: boolean) {
|
||||
this.log.push({type: 'Debug', obj, stray});
|
||||
}
|
||||
|
||||
private pushOp(op: Op, dst: string) {
|
||||
#pushOp(op: Op, dst: string) {
|
||||
dst = nodePath.normalize(dst);
|
||||
let entry: OpEntry = {
|
||||
type: 'Op',
|
||||
op,
|
||||
dst,
|
||||
valid: 'Success',
|
||||
debugObjs: this.debugBuffer
|
||||
debugObjs: this.#debugBuffer
|
||||
};
|
||||
this.log.push(entry);
|
||||
this.debugBuffer = [];
|
||||
this.#debugBuffer = [];
|
||||
if (nodePath.isAbsolute(dst)) {
|
||||
this.valid = false;
|
||||
entry.valid = 'Absolute';
|
||||
} else {
|
||||
let lastEntry = this.seen.get(dst);
|
||||
let lastEntry = this.#seen.get(dst);
|
||||
if (lastEntry === undefined) {
|
||||
this.seen.set(dst, entry);
|
||||
this.#seen.set(dst, entry);
|
||||
} else {
|
||||
this.valid = false;
|
||||
entry.valid = 'Multiple';
|
||||
@@ -83,18 +83,18 @@ export class ActionQueue {
|
||||
}
|
||||
|
||||
copy(src: string, dst: string) {
|
||||
this.pushOp({type: 'Copy', src}, dst);
|
||||
this.#pushOp({type: 'Copy', src}, dst);
|
||||
}
|
||||
|
||||
write(data: string, dst: string) {
|
||||
this.pushOp({type: 'Write', data}, dst);
|
||||
this.#pushOp({type: 'Write', data}, dst);
|
||||
}
|
||||
|
||||
skip() {
|
||||
for (let obj of this.debugBuffer) {
|
||||
for (let obj of this.#debugBuffer) {
|
||||
this.gdebug(obj, true);
|
||||
}
|
||||
this.debugBuffer = [];
|
||||
this.#debugBuffer = [];
|
||||
}
|
||||
|
||||
print(level: 'errors' | 'all') {
|
||||
|
||||
Reference in New Issue
Block a user