@vzn/vx 0.0.370 → 0.0.371

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.370",
3
+ "version": "0.0.371",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.370",
25
- "@vzn/vx-linux-arm64": "0.0.370",
26
- "@vzn/vx-darwin-x64": "0.0.370",
27
- "@vzn/vx-darwin-arm64": "0.0.370"
24
+ "@vzn/vx-linux-x64": "0.0.371",
25
+ "@vzn/vx-linux-arm64": "0.0.371",
26
+ "@vzn/vx-darwin-x64": "0.0.371",
27
+ "@vzn/vx-darwin-arm64": "0.0.371"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -223,7 +223,16 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
223
223
  // the last entry's bytes on disk under a green run (item 886). A row
224
224
  // is current only with the inode and ctime recorded after the save
225
225
  // or restore that wrote it. The cache KEY is unchanged.
226
- export const SCHEMA_VERSION = 'v28'
226
+ // v29: entries.stdout moved to entry_stdout. SQLite rewrites a whole
227
+ // record on UPDATE, so the run-end `accessed_at` bump rewrote each
228
+ // hit's stored stdout (up to 16 MB) with its overflow pages: 200
229
+ // hits of 1 MB cost the close 125-150 ms. The cache KEY is
230
+ // unchanged.
231
+ export const SCHEMA_VERSION = 'v29'
232
+
233
+ /** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
234
+ const SELECT_ENTRY =
235
+ "SELECT e.*, COALESCE(s.stdout, '') AS stdout FROM entries e LEFT JOIN entry_stdout s ON s.hash = e.hash"
227
236
 
228
237
  /** A schema version's number (`v28` → 28); one that is not `v<n>` is older than any. */
229
238
  function schemaOrdinal(version: string): number {
@@ -439,6 +448,8 @@ export class Cache implements CacheLayer {
439
448
  private readonly insertEntry: ReturnType<Database['prepare']>
440
449
  private readonly deleteEntryRow: ReturnType<Database['prepare']>
441
450
  private readonly selectEntry: ReturnType<Database['prepare']>
451
+ private readonly upsertStdout: ReturnType<Database['prepare']>
452
+ private readonly deleteStdout: ReturnType<Database['prepare']>
442
453
  private readonly entryExists: ReturnType<Database['prepare']>
443
454
  private readonly bumpAccessed: ReturnType<Database['prepare']>
444
455
  private readonly touched = new Set<string>()
@@ -675,7 +686,7 @@ export class Cache implements CacheLayer {
675
686
  }
676
687
  if (found === SCHEMA_VERSION) return null
677
688
  this.db.exec(
678
- 'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs;',
689
+ 'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs; DROP TABLE IF EXISTS entry_stdout;',
679
690
  )
680
691
  this.db
681
692
  .prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
@@ -697,10 +708,9 @@ export class Cache implements CacheLayer {
697
708
 
698
709
  this.deleteEntryRow = this.db.prepare('DELETE FROM entries WHERE hash = ?')
699
710
  this.insertEntry = this.db.prepare(`
700
- INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, stdout, created_at, accessed_at, cpu_ms, peak_rss_bytes)
701
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
711
+ INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, created_at, accessed_at, cpu_ms, peak_rss_bytes)
712
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
702
713
  ON CONFLICT(hash) DO UPDATE SET
703
- stdout = excluded.stdout,
704
714
  project = excluded.project,
705
715
  task = excluded.task,
706
716
  command = excluded.command,
@@ -711,12 +721,15 @@ export class Cache implements CacheLayer {
711
721
  cpu_ms = excluded.cpu_ms,
712
722
  peak_rss_bytes = excluded.peak_rss_bytes
713
723
  `)
714
- this.selectEntry = this.db.prepare('SELECT * FROM entries WHERE hash = ?')
715
- // `has` asks only whether the row is there: the whole row, stdout
716
- // included, was built and dropped once per task of a `--dry` plan. A
724
+ this.upsertStdout = this.db.prepare(
725
+ 'INSERT INTO entry_stdout(hash, stdout) VALUES (?, ?) ON CONFLICT(hash) DO UPDATE SET stdout = excluded.stdout',
726
+ )
727
+ this.deleteStdout = this.db.prepare('DELETE FROM entry_stdout WHERE hash = ?')
728
+ this.selectEntry = this.db.prepare(`${SELECT_ENTRY} WHERE e.hash = ?`)
729
+ // `has` asks only whether the row is there, with no stdout joined. A
717
730
  // column off the index, so the table row is still read and a corrupt
718
731
  // table refuses here as it does on `get` (`SELECT 1` answers from the
719
- // index alone); stdout's overflow pages are not.
732
+ // index alone).
720
733
  this.entryExists = this.db.prepare('SELECT exit_code FROM entries WHERE hash = ?')
721
734
  this.bumpAccessed = this.db.prepare('UPDATE entries SET accessed_at = ? WHERE hash = ?')
722
735
  // INSERT OR IGNORE: re-saving the same hash (idempotent ingest /
@@ -961,7 +974,7 @@ export class Cache implements CacheLayer {
961
974
  const placeholders = chunk.map(() => '?').join(',')
962
975
  rows.push(
963
976
  ...(this.db
964
- .query(`SELECT * FROM entries WHERE hash IN (${placeholders})`)
977
+ .query(`${SELECT_ENTRY} WHERE e.hash IN (${placeholders})`)
965
978
  .all(...(chunk as readonly SQLQueryBindings[])) as EntryRow[]),
966
979
  )
967
980
  }
@@ -1580,6 +1593,8 @@ export class Cache implements CacheLayer {
1580
1593
  // (not the per-run path) so a warm all-cache-hit run — which never
1581
1594
  // saves — writes none of them.
1582
1595
  const insertEntry = this.insertEntry
1596
+ const upsertStdout = this.upsertStdout
1597
+ const deleteStdout = this.deleteStdout
1583
1598
  const outputs = this.outputs
1584
1599
  const insertEntryInput = this.insertEntryInput
1585
1600
  const inputComponents = meta.inputComponents
@@ -1629,7 +1644,6 @@ export class Cache implements CacheLayer {
1629
1644
  0,
1630
1645
  meta.durationMs,
1631
1646
  totalBytes,
1632
- stdoutText,
1633
1647
  now,
1634
1648
  now,
1635
1649
  // From the artifact, on both paths: a save indexes what it just
@@ -1637,6 +1651,8 @@ export class Cache implements CacheLayer {
1637
1651
  scanned.exec?.cpuMs ?? null,
1638
1652
  scanned.exec?.peakRssBytes ?? null,
1639
1653
  )
1654
+ if (stdoutText === '') deleteStdout.run(hash)
1655
+ else upsertStdout.run(hash, stdoutText)
1640
1656
  outputs.replaceFileRows(hash, outputFileRows)
1641
1657
  // INSERT OR IGNORE: identical inputs derive this same hash, so a
1642
1658
  // re-save's rows are identical — keep the first set, skip the rest.
@@ -18,10 +18,8 @@ export function createTables(db: Database): void {
18
18
  `)
19
19
 
20
20
  db.exec(`
21
- -- The queryable index: command, exit_code, duration, size,
22
- -- timestamps, and stdout, which the <hash>.tar.zst artifact also
23
- -- carries so it survives a remote round trip; a local hit
24
- -- replays it from here.
21
+ -- The queryable index: command, exit_code, duration, size and
22
+ -- timestamps; stdout in entry_stdout below.
25
23
  CREATE TABLE IF NOT EXISTS entries (
26
24
  hash TEXT PRIMARY KEY,
27
25
  project TEXT NOT NULL,
@@ -30,13 +28,21 @@ export function createTables(db: Database): void {
30
28
  exit_code INTEGER NOT NULL,
31
29
  duration_ms INTEGER NOT NULL,
32
30
  size_bytes INTEGER NOT NULL,
33
- stdout TEXT NOT NULL DEFAULT '',
34
31
  created_at INTEGER NOT NULL,
35
32
  accessed_at INTEGER NOT NULL,
36
33
  -- v26: the producing execution's usage, from the artifact's sidecar.
37
34
  cpu_ms INTEGER,
38
35
  peak_rss_bytes INTEGER
39
36
  );
37
+ -- v29: an entry's stdout, which the <hash>.tar.zst artifact also
38
+ -- carries so it survives a remote round trip; a local hit replays it
39
+ -- from here. Its own table: an UPDATE rewrites a whole record, and the
40
+ -- accessed_at bump rewrote up to 16 MB a hit. No row: empty stdout.
41
+ CREATE TABLE IF NOT EXISTS entry_stdout (
42
+ hash TEXT PRIMARY KEY,
43
+ stdout TEXT NOT NULL,
44
+ FOREIGN KEY (hash) REFERENCES entries(hash) ON DELETE CASCADE
45
+ );
40
46
  CREATE TABLE IF NOT EXISTS runs (
41
47
  id INTEGER PRIMARY KEY AUTOINCREMENT,
42
48
  -- '' when the outcome derived no cache key (skipped / persistent).
package/src/cli/select.ts CHANGED
@@ -26,7 +26,12 @@ import type { ProjectConfig } from '../config.js'
26
26
  import type { ProjectEntry } from '../workspace/index.js'
27
27
  import { parseDependencySpec } from '../graph/index.js'
28
28
  import { maskedLine, nearest, UserError } from '../util/index.js'
29
- import { claimedAffected, fingerprintClaims, gitOfDiscovery } from '../orchestrator/index.js'
29
+ import {
30
+ claimedAffected,
31
+ fingerprintClaims,
32
+ gitOfDiscovery,
33
+ keepDiscoveryGraph,
34
+ } from '../orchestrator/index.js'
30
35
  import {
31
36
  type CliLoadOptions,
32
37
  discoverCliProjects,
@@ -251,6 +256,9 @@ export async function resolveFilters(
251
256
  }
252
257
  }
253
258
  const graph = buildPackageGraph(projects, edges)
259
+ // The graph a run reusing this discovery builds is this one when no task
260
+ // edge went into it.
261
+ if (edges === undefined) keepDiscoveryGraph(projects, graph)
254
262
 
255
263
  // Resolve every `[<since>]` filter against git before the pure
256
264
  // applyFilters pass runs. One spawn per distinct ref — usually
@@ -6,6 +6,7 @@ export { prepareRun, type PreparedRun } from './prepare.js'
6
6
  export {
7
7
  discoverProjects,
8
8
  gitOfDiscovery,
9
+ keepDiscoveryGraph,
9
10
  loadProjects,
10
11
  loadResolvedProjects,
11
12
  loadWorkspacePlugins,
@@ -57,6 +57,7 @@ import {
57
57
  import {
58
58
  discoverProjects,
59
59
  gitOfDiscovery,
60
+ graphOfDiscovery,
60
61
  loadProjects,
61
62
  loadWorkspacePlugins,
62
63
  type LoadedProjects,
@@ -223,7 +224,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
223
224
  // those configs are evaluated. Side effect, deliberate and
224
225
  // Turbo-like: a broken config in an unrelated package no longer
225
226
  // fails a scoped run — it surfaces when that package enters scope.
226
- const packageGraph = buildPackageGraph(projectMetas)
227
+ const packageGraph =
228
+ (reused !== undefined ? graphOfDiscovery(reused.projects) : undefined) ??
229
+ buildPackageGraph(projectMetas)
227
230
  // Seeds: explicit scope, plus anchored pkg#task targets (which bypass
228
231
  // scope by design). With no explicit scope, bare task names fan out
229
232
  // across the whole workspace — but when EVERY spec is anchored, the
@@ -67,6 +67,21 @@ export function gitOfDiscovery(projects: readonly ProjectMeta[]): LazyGitEnumera
67
67
  return discoveredGit.get(projects)
68
68
  }
69
69
 
70
+ /**
71
+ * The package graph a selection pass built over a discovery, by that
72
+ * discovery's array, when it folded no task edge in: the graph a run that
73
+ * reuses the discovery (`RunOptions.discovered`) would build again.
74
+ */
75
+ const discoveredGraph = new WeakMap<readonly ProjectMeta[], PackageGraph>()
76
+
77
+ export function graphOfDiscovery(projects: readonly ProjectMeta[]): PackageGraph | undefined {
78
+ return discoveredGraph.get(projects)
79
+ }
80
+
81
+ export function keepDiscoveryGraph(projects: readonly ProjectMeta[], graph: PackageGraph): void {
82
+ discoveredGraph.set(projects, graph)
83
+ }
84
+
70
85
  /**
71
86
  * The workspace's projects: core's discovery, then the plugin `discover`
72
87
  * stage. With no plugin declaring it, `listProjects` alone.
@@ -544,7 +544,21 @@ async function runOnBus(
544
544
  // (`signals.ts`); the handlers are removed in the finally below so
545
545
  // repeated run() calls (test suites) never stack listeners.
546
546
  const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
547
- const persistentRegistry = new Map<string, ReturnType<typeof Bun.spawn>>()
547
+ // A server that ends on its own while the graph still runs is said at
548
+ // once: its dependants were failing against it, and the end of the run
549
+ // was the first word of it (C-69). Not once the graph is done (the end
550
+ // of the run and the keep-alive wait say it) nor under a stop.
551
+ let graphDone = false
552
+ const persistentRegistry = new (class extends Map<string, ReturnType<typeof Bun.spawn>> {
553
+ override set(id: string, child: ReturnType<typeof Bun.spawn>): this {
554
+ super.set(id, child)
555
+ void child.exited.then((code) => {
556
+ if (code !== 0 && !graphDone && !stopRun.signal.aborted)
557
+ log.status(`vx: ${id} exited with code ${code} while the run went on`)
558
+ })
559
+ return this
560
+ }
561
+ })()
548
562
  // One stop for the run: an embedder's `RunOptions.signal` and a process
549
563
  // signal both abort it. The scheduler stops dispatching (it reads the
550
564
  // signal), the children are torn down, and run() returns through its own
@@ -951,6 +965,7 @@ async function runOnBus(
951
965
  // Empty when the short-circuit didn't fire → byte-identical.
952
966
  restoreTier: shortCircuit.restoreTier,
953
967
  })
968
+ graphDone = true
954
969
 
955
970
  // Which persistent children outlive the graph, and the bounded SIGTERM
956
971
  // of the rest, before the summary prints. Scoped to the real CLI
@@ -125,8 +125,11 @@ export interface FingerprintClaims {
125
125
  export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
126
126
  // Turbo's CI spelling, `[origin/main...HEAD]`, is the base alone here:
127
127
  // vx already diffs from the merge base (three dots' meaning), and the
128
- // working tree it diffs to holds HEAD (D-117). Any other range is refused.
129
- const since = /^(.+)\.\.\.HEAD$/.exec(args.since)?.[1] ?? args.since
128
+ // working tree it diffs to holds HEAD (D-117). Its two-dot `[A..HEAD]`
129
+ // diffs from A itself (G-145). Any other range is refused.
130
+ const headRange = /^(.+?)(\.{2,3})HEAD$/s.exec(args.since)
131
+ const since = headRange?.[1] ?? args.since
132
+ const fromMergeBase = headRange?.[2] !== '..'
130
133
  // The base reaches git as an argument, never through a shell, so `$(…)`
131
134
  // is opaque — but an option-like value is not: `--output=<path>` is a
132
135
  // real `git diff` option and an arbitrary file write. This is a security
@@ -155,8 +158,9 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
155
158
  // CI `--affected`), and hides your own edit when the base later landed
156
159
  // byte-identical content. Turbo and Nx both diff from the merge base;
157
160
  // when there is none (unrelated histories, a detached probe) the ref
158
- // itself is the base, as before.
159
- const base = await mergeBase(args.workspaceRoot, since)
161
+ // itself is the base, as before. Turbo's two-dot range `A..HEAD` is the
162
+ // one form that diffs from A itself.
163
+ const base = fromMergeBase ? await mergeBase(args.workspaceRoot, since) : since
160
164
 
161
165
  const [diffed, untracked] = await Promise.all([
162
166
  // `--no-renames` is crucial for project-affected detection: with
@@ -139,6 +139,7 @@ self.onmessage = async (e) => {
139
139
  ok: true,
140
140
  nonJson,
141
141
  json: isObject && nonJson.length === 0 ? JSON.stringify(mod) : null,
142
+ fn: typeof mod === 'function',
142
143
  changed: changed(),
143
144
  })
144
145
  } catch (err) {
@@ -158,12 +159,16 @@ self.onmessage = async (e) => {
158
159
  }
159
160
  `
160
161
 
162
+ const FUNCTION_EXPORT = (): void => {}
163
+
161
164
  const WORKER_URL = `data:text/javascript,${encodeURIComponent(WORKER_SRC)}`
162
165
 
163
166
  interface WorkerReply {
164
167
  id: number
165
168
  ok: boolean
166
169
  json: string | null
170
+ /** The default export is a function, which JSON cannot carry back. */
171
+ fn?: boolean
167
172
  nonJson: NonJsonValue[]
168
173
  name: string
169
174
  message: string
@@ -327,7 +332,10 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
327
332
  })
328
333
  const [nonJson] = reply.nonJson
329
334
  if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
330
- return reply.json === null ? null : (JSON.parse(reply.json) as unknown)
335
+ // A function stands in for the one the config exported, so the caller's
336
+ // refusal names it as the in-process load's does.
337
+ if (reply.json === null) return reply.fn === true ? FUNCTION_EXPORT : null
338
+ return JSON.parse(reply.json) as unknown
331
339
  } finally {
332
340
  // In the `finally`, not after the await: a REJECTED evaluation — a config
333
341
  // with a typo, the common case while editing — would otherwise skip the
@@ -37,12 +37,21 @@ export const WORKSPACE_CONFIG_FILENAMES = [
37
37
  'vx.workspace.cjs',
38
38
  ]
39
39
 
40
+ /** What a function default export is told; the playground says the same. */
41
+ const EXPORTED_A_FUNCTION =
42
+ 'it exports a function, and vx reads the object itself — export what the function returns'
43
+
40
44
  /** Project configs already loaded in this process, by absolute path. */
41
45
  const loadedConfigs = new Set<string>()
42
46
 
43
47
  function assertDefaultObject(mod: unknown, kind: string, configPath: string): void {
44
48
  if (!mod || typeof mod !== 'object') {
45
- throw new UserError(`${kind} config at ${configPath} did not export a default object`)
49
+ // Vite's `defineConfig(() => ({ … }))` shape: vx reads the object, and
50
+ // the bare refusal did not say what was there instead (D-110).
51
+ throw new UserError(
52
+ `${kind} config at ${configPath} did not export a default object` +
53
+ (typeof mod === 'function' ? `: ${EXPORTED_A_FUNCTION}` : ''),
54
+ )
46
55
  }
47
56
  }
48
57