@vzn/vx 0.0.476 → 0.0.478

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.476",
3
+ "version": "0.0.478",
4
4
  "description": "The fastest 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.476",
25
- "@vzn/vx-linux-arm64": "0.0.476",
26
- "@vzn/vx-darwin-x64": "0.0.476",
27
- "@vzn/vx-darwin-arm64": "0.0.476"
24
+ "@vzn/vx-linux-x64": "0.0.478",
25
+ "@vzn/vx-linux-arm64": "0.0.478",
26
+ "@vzn/vx-darwin-x64": "0.0.478",
27
+ "@vzn/vx-darwin-arm64": "0.0.478"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -228,7 +228,10 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
228
228
  // hit's stored stdout (up to 16 MB) with its overflow pages: 200
229
229
  // hits of 1 MB cost the close 125-150 ms. The cache KEY is
230
230
  // unchanged.
231
- export const SCHEMA_VERSION = 'v29'
231
+ // v30: blob_verdicts — the blob-size check's verdict by a hash of the
232
+ // index file (A-60), so a warm run reads one row. The cache KEY is
233
+ // unchanged.
234
+ export const SCHEMA_VERSION = 'v30'
232
235
 
233
236
  /** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
234
237
  const SELECT_ENTRY =
@@ -685,7 +688,7 @@ export class Cache implements CacheLayer {
685
688
  }
686
689
  if (found === SCHEMA_VERSION) return null
687
690
  this.db.exec(
688
- '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;',
691
+ '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 blob_verdicts; 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;',
689
692
  )
690
693
  this.db
691
694
  .prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
@@ -831,6 +834,14 @@ export class Cache implements CacheLayer {
831
834
  rememberBlobSizes(sizes: ReadonlyMap<string, number>): void {
832
835
  this.guard(() => this.files.rememberBlobSizes(sizes))
833
836
  }
837
+ /** `BlobSizeMemo`: the paths an index with this digest distrusts, when known. */
838
+ blobVerdict(digest: string): string[] | undefined {
839
+ return this.guard(() => this.files.blobVerdict(digest))
840
+ }
841
+ /** `BlobSizeMemo`: remember a verdict, honouring the local WRITE axis. */
842
+ rememberBlobVerdict(digest: string, paths: readonly string[]): void {
843
+ this.guard(() => this.files.rememberBlobVerdict(digest, paths))
844
+ }
834
845
  /**
835
846
  * `relPosix` against the run's workspace root, memoized: the same three
836
847
  * thousand files are re-relativized for every task of the project, which
@@ -74,6 +74,7 @@ export class FileHashStore {
74
74
  this.db.transaction(() => {
75
75
  this.db.prepare('DELETE FROM file_hashes WHERE seen_at < ?').run(cutoff)
76
76
  this.db.prepare('DELETE FROM blob_sizes WHERE seen_at < ?').run(cutoff)
77
+ this.db.prepare('DELETE FROM blob_verdicts WHERE seen_at < ?').run(cutoff)
77
78
  this.db
78
79
  .prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
79
80
  .run(FILE_HASHES_SWEPT_AT, String(now))
@@ -109,6 +110,26 @@ export class FileHashStore {
109
110
  }
110
111
  }
111
112
 
113
+ /** The paths a stored verdict distrusts, or undefined when none is stored for `digest`. */
114
+ blobVerdict(digest: string): string[] | undefined {
115
+ const row = this.db.query('SELECT paths FROM blob_verdicts WHERE digest = ?').get(digest) as {
116
+ paths: string
117
+ } | null
118
+ return row === null ? undefined : (JSON.parse(row.paths) as string[])
119
+ }
120
+
121
+ /** Store a verdict; a memo, so a full disk skips it. */
122
+ rememberBlobVerdict(digest: string, paths: readonly string[]): void {
123
+ if (!this.write) return
124
+ try {
125
+ this.db
126
+ .query('INSERT OR REPLACE INTO blob_verdicts(digest, paths, seen_at) VALUES (?, ?, ?)')
127
+ .run(digest, JSON.stringify(paths), Date.now())
128
+ } catch (err) {
129
+ if (!isIndexFull(err)) throw err
130
+ }
131
+ }
132
+
112
133
  /**
113
134
  * Content-hash a file (as a git blob OID, v20) with an mtime+size
114
135
  * fast path. If the `file_hashes` table has a row for `path` whose
@@ -7,7 +7,8 @@
7
7
 
8
8
  import path from 'node:path'
9
9
  import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
10
- import { UserError, executablePath, gitSpawnRefusal } from '../util/index.js'
10
+ import { UserError, executablePath, gitSpawnRefusal, xxh3hex } from '../util/index.js'
11
+ import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
11
12
 
12
13
  /** Three facts of the repository a directory is in: read off the disk, or one `git rev-parse`. */
13
14
  export interface RepoFacts {
@@ -17,6 +18,8 @@ export interface RepoFacts {
17
18
  commonDir: string
18
19
  /** `--show-object-format`: the hash the index's blob OIDs are in. */
19
20
  objectFormat: 'sha1' | 'sha256'
21
+ /** `--git-path index` (it honours `GIT_INDEX_FILE`), relative to the directory asked. */
22
+ indexFile: string
20
23
  }
21
24
 
22
25
  const repoFactsMemo = new Map<string, RepoFacts>()
@@ -46,6 +49,8 @@ export function repoFacts(dir: string): RepoFacts | null {
46
49
  '--show-prefix',
47
50
  '--git-common-dir',
48
51
  '--show-object-format',
52
+ '--git-path',
53
+ 'index',
49
54
  ],
50
55
  cwd: dir,
51
56
  stdout: 'pipe',
@@ -58,7 +63,7 @@ export function repoFacts(dir: string): RepoFacts | null {
58
63
  // One line per flag, in order. A git that does not know
59
64
  // `--show-object-format` echoes it back, as it does any unknown flag,
60
65
  // which reads as sha1 — what the flag's own spawn answered there too.
61
- const [prefix = '', commonDir = '', format = ''] = new TextDecoder()
66
+ const [prefix = '', commonDir = '', format = '', indexFile = ''] = new TextDecoder()
62
67
  .decode(proc.stdout)
63
68
  .split('\n')
64
69
  .map((l) => l.trim())
@@ -66,13 +71,15 @@ export function repoFacts(dir: string): RepoFacts | null {
66
71
  prefix,
67
72
  commonDir,
68
73
  objectFormat: format === 'sha256' ? 'sha256' : 'sha1',
74
+ indexFile,
69
75
  }
70
76
  repoFactsMemo.set(dir, facts)
71
77
  return facts
72
78
  }
73
79
 
74
- /** Variables that move where git finds the repository: with any set, git is asked. */
80
+ /** Variables that move where git finds the repository or its index: with any set, git is asked. */
75
81
  const GIT_LOCATION_ENV = [
82
+ 'GIT_INDEX_FILE',
76
83
  'GIT_DIR',
77
84
  'GIT_WORK_TREE',
78
85
  'GIT_COMMON_DIR',
@@ -106,6 +113,7 @@ function repoFactsFromDisk(dir: string): RepoFacts | undefined {
106
113
  prefix: below === '' ? '' : `${below}/`,
107
114
  commonDir: path.relative(real, dotGit).split(path.sep).join('/'),
108
115
  objectFormat,
116
+ indexFile: path.relative(real, path.join(dotGit, 'index')).split(path.sep).join('/'),
109
117
  }
110
118
  }
111
119
  const parent = path.dirname(at)
@@ -613,10 +621,33 @@ function splitLsDebug(run: GitRun): {
613
621
  return { records, sizes, undecodable }
614
622
  }
615
623
 
616
- /** Blob sizes learned once and kept: a blob's size is fixed for its OID. `Cache` is one. */
624
+ /**
625
+ * What the blob-size check keeps between runs (`Cache` is one): each blob's
626
+ * size, fixed for its OID, and each verdict, the paths an index distrusts,
627
+ * fixed for the index file's bytes (`IndexBlobCheck.key`).
628
+ */
617
629
  export interface BlobSizeMemo {
618
630
  knownBlobSizes(oids: readonly string[]): Map<string, number>
619
631
  rememberBlobSizes(sizes: ReadonlyMap<string, number>): void
632
+ blobVerdict(key: string): string[] | undefined
633
+ rememberBlobVerdict(key: string, paths: readonly string[]): void
634
+ }
635
+
636
+ /** Each regular stage-0 index entry's path, raw OID and recorded worktree size, in index order. */
637
+ export interface IndexBlobs {
638
+ paths: string[]
639
+ oids: string[]
640
+ sizes: number[]
641
+ }
642
+
643
+ /** What the blob-size check reads, lazily: the verdict's key, and the listing a miss needs. */
644
+ export interface IndexBlobCheck {
645
+ /** xxh3 of the index file and the pathspecs; undefined when the file could not be read. */
646
+ key: string | undefined
647
+ /** The entries `ls-files --debug` lists, or null when it fails. */
648
+ blobs(): Promise<IndexBlobs | null>
649
+ /** The key as the index stands now: a verdict is stored only for the index it came from. */
650
+ rekey(): Promise<string | undefined>
620
651
  }
621
652
 
622
653
  /**
@@ -625,53 +656,58 @@ export interface BlobSizeMemo {
625
656
  * was added from, and git holds a stat-clean entry clean without re-reading
626
657
  * it once the filter is gone (`core.autocrlf` turned off, a `.gitattributes`
627
658
  * rule removed): `status` and the filter gate, which reads today's config,
628
- * both let the blob stand for bytes it does not hold. The recorded sizes
629
- * cost no read of the worktree; the blob sizes come from `memo`, and only
630
- * the ones it lacks from one `cat-file` (65 ms over 3,000 loose objects,
631
- * the whole of the cost, measured 2026-10-02). A smudged (racy) entry
632
- * records 0 and is hashed from disk. An answer that cannot be read trusts
633
- * nothing. A filter that keeps the size (`tr a-z A-Z`) is not caught: only
634
- * a read of every trusted file would be (caching.md, step 12).
659
+ * both let the blob stand for bytes it does not hold. Which paths an index
660
+ * distrusts is a function of the index alone, so `memo` keeps the verdict
661
+ * by a hash of the index file and a warm run reads one row: the `--debug`
662
+ * listing and a lookup per entry cost 550 ms at 100,000 files (2026-10-02).
663
+ * An index with no verdict spawns that listing (the recorded sizes), asks
664
+ * the per-OID sizes, and `cat-file` for the ones `memo` lacks; the verdict
665
+ * is stored only if the index is still the one it was read from. A smudged
666
+ * (racy) entry records 0 and is hashed from disk. An answer that cannot be
667
+ * read trusts nothing. A filter that keeps the size (`tr a-z A-Z`) is not
668
+ * caught: only a read of every trusted file would be (caching.md, step 12).
635
669
  */
636
670
  async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo): Promise<void> {
637
- const { trusted, indexed } = enumeration
638
- const wanted = new Set<string>()
639
- // Each trusted path's index entry, found once: a second lookup by path
640
- // in the check below was half of this pass on a 12,905-file tree.
641
- const rels: string[] = []
642
- const entries: Array<{ oid: string; size: number }> = []
643
- for (const rel of trusted.keys()) {
644
- const entry = indexed.get(rel)
645
- if (entry === undefined) continue // a symlink: its blob is its target string
646
- rels.push(rel)
647
- entries.push(entry)
648
- wanted.add(entry.oid)
649
- }
650
- if (wanted.size === 0) return
651
- const sizes = memo?.knownBlobSizes([...wanted]) ?? new Map<string, number>()
652
- const unknown = [...wanted].filter((oid) => !sizes.has(oid))
653
- if (unknown.length > 0) {
654
- const run = await enumeration.catFile(unknown.join('\n') + '\n')
655
- if (run === null || run.exitCode !== 0) {
671
+ const { trusted, blobCheck } = enumeration
672
+ if (trusted.size === 0) return
673
+ const key = blobCheck.key
674
+ let resized = key === undefined ? undefined : memo?.blobVerdict(key)
675
+ if (resized === undefined) {
676
+ const blobs = await blobCheck.blobs()
677
+ if (blobs === null) {
656
678
  trusted.clear()
657
679
  return
658
680
  }
659
- const learned = new Map<string, number>()
660
- for (const line of run.stdout.split('\n')) {
661
- const sp = line.indexOf(' ')
662
- const n = Number(line.slice(sp + 1))
663
- if (sp > 0 && line.slice(sp + 1) !== '' && Number.isInteger(n))
664
- learned.set(line.slice(0, sp), n)
681
+ const unique = [...new Set(blobs.oids)]
682
+ const sizes = memo?.knownBlobSizes(unique) ?? new Map<string, number>()
683
+ const unknown = unique.filter((oid) => !sizes.has(oid))
684
+ if (unknown.length > 0) {
685
+ const run = await enumeration.catFile(unknown.join('\n') + '\n')
686
+ if (run === null || run.exitCode !== 0) {
687
+ trusted.clear()
688
+ return
689
+ }
690
+ const learned = new Map<string, number>()
691
+ for (const line of run.stdout.split('\n')) {
692
+ const sp = line.indexOf(' ')
693
+ const n = Number(line.slice(sp + 1))
694
+ if (sp > 0 && line.slice(sp + 1) !== '' && Number.isInteger(n))
695
+ learned.set(line.slice(0, sp), n)
696
+ }
697
+ for (const [oid, n] of learned) sizes.set(oid, n)
698
+ memo?.rememberBlobSizes(learned)
699
+ }
700
+ resized = []
701
+ for (let i = 0; i < blobs.oids.length; i++) {
702
+ const size = sizes.get(blobs.oids[i]!)
703
+ // The index keeps the low 32 bits of a size.
704
+ if (size === undefined || size % 2 ** 32 !== blobs.sizes[i]) resized.push(blobs.paths[i]!)
705
+ }
706
+ if (key !== undefined && (await blobCheck.rekey()) === key) {
707
+ memo?.rememberBlobVerdict(key, resized)
665
708
  }
666
- for (const [oid, n] of learned) sizes.set(oid, n)
667
- memo?.rememberBlobSizes(learned)
668
- }
669
- for (let i = 0; i < rels.length; i++) {
670
- const entry = entries[i]!
671
- const size = sizes.get(entry.oid)
672
- // The index keeps the low 32 bits of a size.
673
- if (size === undefined || size % 2 ** 32 !== entry.size) trusted.delete(rels[i]!)
674
709
  }
710
+ for (const rel of resized) trusted.delete(rel)
675
711
  }
676
712
 
677
713
  /** One completed `git` invocation. */
@@ -1074,8 +1110,8 @@ export interface GitEnumeration {
1074
1110
  undecodable: readonly string[]
1075
1111
  /** `Date.now()` before the spawns: what `trusted` says is true as of no earlier. */
1076
1112
  startedAtMs: number
1077
- /** Each regular stage-0 entry's raw OID and the worktree size the index recorded. */
1078
- indexed: ReadonlyMap<string, { oid: string; size: number }>
1113
+ /** The blob-size check's input (`dropResizedOids`). */
1114
+ blobCheck: IndexBlobCheck
1079
1115
  /** `git cat-file --batch-check` over `stdin`'s OIDs; null when it could not spawn. */
1080
1116
  catFile(stdin: string): Promise<{ exitCode: number; stdout: string } | null>
1081
1117
  }
@@ -1156,12 +1192,7 @@ export async function startGitEnumeration(
1156
1192
  // tracked paths are dirty AND which files are untracked. Asking
1157
1193
  // `ls-files --others` for the untracked set walked the same tree a second
1158
1194
  // time (~50 ms of CPU, concurrent with status but contending with it).
1159
- // `--debug` adds each entry's recorded stat, for the blob-size check
1160
- // (`dropResizedOids`, A-60).
1161
- const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs]).then(
1162
- (run) =>
1163
- run === null || run.exitCode !== 0 ? { run, debug: null } : { run, debug: splitLsDebug(run) },
1164
- )
1195
+ const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs])
1165
1196
  const running = Promise.all([
1166
1197
  listing,
1167
1198
  // `--ignored=matching` names an ignored path without descending into an
@@ -1203,7 +1234,50 @@ export async function startGitEnumeration(
1203
1234
  // at all. `--git-dir` named the per-worktree directory, so the gate
1204
1235
  // looked where the rule can never be.
1205
1236
  const facts = repoFacts(workspaceRoot)
1206
- const [{ run: ls, debug }, status, vars] = await running
1237
+ // The blob-size check's verdict is a function of the index's entries, so
1238
+ // the index file's bytes key it: one read, where `ls-files --debug` and a
1239
+ // lookup per entry cost 550 ms at 100,000 files (A-60).
1240
+ const pathspecKey = xxh3hex(pathspecs.join('\0'))
1241
+ const indexFile =
1242
+ facts === null || facts.indexFile === ''
1243
+ ? undefined
1244
+ : path.resolve(workspaceRoot, facts.indexFile)
1245
+ const readIndexKey = async (): Promise<
1246
+ { key: string; stamp: string; ctimeMs: number } | undefined
1247
+ > => {
1248
+ if (indexFile === undefined) return undefined
1249
+ try {
1250
+ const st = lstatSync(indexFile)
1251
+ const bytes = await Bun.file(indexFile).bytes()
1252
+ return {
1253
+ key: `${xxh3hex(bytes)}:${pathspecKey}`,
1254
+ stamp: `${st.ino}:${st.ctimeMs}:${st.size}`,
1255
+ ctimeMs: st.ctimeMs,
1256
+ }
1257
+ } catch {
1258
+ return undefined
1259
+ }
1260
+ }
1261
+ const indexKeyRead = readIndexKey()
1262
+ const [ls, status, vars] = await running
1263
+ // The key stands for the index `ls-files` listed only when that index
1264
+ // was written before the enumeration began (git writes it by rename, so
1265
+ // the stamp moves with any write) and is still in place after it: a
1266
+ // `git add` between the listing and the read paired one index's verdict
1267
+ // with the other's entries. Otherwise this run asks for no verdict.
1268
+ const read = await indexKeyRead
1269
+ let indexKey: string | undefined
1270
+ if (
1271
+ read !== undefined &&
1272
+ read.ctimeMs < startedAtMs - racyWindowMs(read.ctimeMs, FILE_HASH_RACY_MS)
1273
+ ) {
1274
+ try {
1275
+ const st = lstatSync(indexFile!)
1276
+ if (`${st.ino}:${st.ctimeMs}:${st.size}` === read.stamp) indexKey = read.key
1277
+ } catch {
1278
+ // gone: no key
1279
+ }
1280
+ }
1207
1281
  if (ls === null) {
1208
1282
  throw gitSpawnRefusal(workspaceRoot)
1209
1283
  }
@@ -1214,14 +1288,13 @@ export async function startGitEnumeration(
1214
1288
  `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
1215
1289
  )
1216
1290
  }
1217
- const indexed = new Map<string, { oid: string; size: number }>()
1218
1291
  const {
1219
1292
  files: tracked,
1220
1293
  oids,
1221
1294
  flagged,
1222
1295
  gitlinks,
1223
1296
  undecodable,
1224
- } = parseLsFilesOutput(debug!.records, debug!.undecodable, { sizes: debug!.sizes, indexed })
1297
+ } = parseLsFilesOutput(ls.stdout.length === 0 ? [] : ls.stdout.split('\0'), ls.undecodable)
1225
1298
  // Normalize `status`'s repo-root-relative paths to workspace-relative (strip
1226
1299
  // the `--show-prefix`) so the dirty set is keyed identically to the trusted
1227
1300
  // OID map. Without this, when the workspace root is a git subdir, a modified
@@ -1299,7 +1372,24 @@ export async function startGitEnumeration(
1299
1372
  untracked: dirty === null ? null : untracked,
1300
1373
  undecodable,
1301
1374
  startedAtMs,
1302
- indexed,
1375
+ blobCheck: {
1376
+ key: indexKey,
1377
+ async blobs() {
1378
+ const run = await spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs])
1379
+ if (run === null || run.exitCode !== 0) return null
1380
+ const debug = splitLsDebug(run)
1381
+ const indexed = new Map<string, { oid: string; size: number }>()
1382
+ parseLsFilesOutput(debug.records, debug.undecodable, { sizes: debug.sizes, indexed })
1383
+ const blobs: IndexBlobs = { paths: [], oids: [], sizes: [] }
1384
+ for (const [rel, { oid, size }] of indexed) {
1385
+ blobs.paths.push(rel)
1386
+ blobs.oids.push(oid)
1387
+ blobs.sizes.push(size)
1388
+ }
1389
+ return blobs
1390
+ },
1391
+ rekey: async () => (await readIndexKey())?.key,
1392
+ },
1303
1393
  // A missing blob is an answer ("missing"), never a fetch from a partial
1304
1394
  // clone's promisor remote.
1305
1395
  catFile: (stdin) =>
@@ -56,6 +56,7 @@ export {
56
56
  lazyGitEnumeration,
57
57
  type LazyGitEnumeration,
58
58
  type GitEnumeration,
59
+ type BlobSizeMemo,
59
60
  } from './git-inputs.js'
60
61
  export { LayeredCache, type RemoteCacheLayer } from './layered-cache.js'
61
62
  export { ChainedCache } from './chained-cache.js'
@@ -144,6 +144,13 @@ export function createTables(db: Database): void {
144
144
  size INTEGER NOT NULL,
145
145
  seen_at INTEGER NOT NULL
146
146
  );
147
+ -- v30: the paths an index distrusts, by a hash of the index file and the
148
+ -- pathspecs (A-60): a warm run reads one row, not one per blob.
149
+ CREATE TABLE IF NOT EXISTS blob_verdicts (
150
+ digest TEXT PRIMARY KEY,
151
+ paths TEXT NOT NULL,
152
+ seen_at INTEGER NOT NULL
153
+ );
147
154
  -- v16: per-output-file fingerprints, scoped by the cache entry
148
155
  -- that produced them. Lets loadOutputFilesBatch(hashes) answer
149
156
  -- "for entry X, what are its outputs supposed to look like?"