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:
Christopher Monsanto
2026-08-17 19:00:42 -04:00
parent 73f5954407
commit dc2bff9738
14 changed files with 245 additions and 245 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>();

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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-'));

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -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]

View File

@@ -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') {