@wavehouse/chtypes 0.4.0 → 0.5.0

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/dist/fetch.js CHANGED
@@ -34,7 +34,7 @@ import { Readable, Transform } from 'node:stream';
34
34
  import { pipeline } from 'node:stream/promises';
35
35
  import { setTimeout as sleep } from 'node:timers/promises';
36
36
  import { fileURLToPath } from 'node:url';
37
- import { ABI_REVISION, ArtifactCorruptError, ArtifactPinnedError, ArtifactUnpublishedError, ArtifactUntrustedError, ChtypesError, SourceUnreachableError, } from './errors.js';
37
+ import { ABI_REVISION, ArtifactCorruptError, ArtifactPinnedError, ArtifactUnpublishedError, ArtifactUntrustedError, ChtypesError, FETCH_COMMAND, SourceUnreachableError, } from './errors.js';
38
38
  import { fetchDestination, hostPlatform, isPlatformKey } from './paths.js';
39
39
  import { extractTarGz } from './tar.js';
40
40
  // ------------------------------------------------------------- constants
@@ -50,8 +50,17 @@ export const DEFAULT_RELEASE_TAG = 'artifacts';
50
50
  export const RELEASE_PUBLIC_KEYS = [
51
51
  'fdb5f06a8d4c9918d049a5f1748fa2e3b3238c3f2000986d5bb9e31beff778fc',
52
52
  ];
53
- /** The lock-file schema this SDK writes and reads (docs/guides/fetch.md §5). */
54
- export const LOCK_SCHEMA = 1;
53
+ /**
54
+ * The lock-file schema this SDK writes, and the newest it reads
55
+ * (docs/guides/fetch.md §5). Schema 1 (`<os>-<arch>/<minor>`) is still read
56
+ * and is converted to schema 2 (`<os>-<arch>/<clickhouse_version>`) the first
57
+ * time this SDK writes the file — schema 2 is what a two-patches-per-line
58
+ * cache needs to key on, since two patches of one line share a schema-1 key.
59
+ * SDKs through 0.4.x cannot read a schema-2 lock and refuse it (issue #284).
60
+ */
61
+ export const LOCK_SCHEMA = 2;
62
+ /** The newest lock schema this SDK STILL READS besides its own (docs/guides/fetch.md §5). */
63
+ const LOCK_SCHEMA_LEGACY = 1;
55
64
  /** The lock file `frozen` reads when no `lock` names one (docs/guides/fetch.md, Decisions); relative, so the working directory's. */
56
65
  export const DEFAULT_LOCK_FILE = 'chtypes.lock';
57
66
  const INDEX_SCHEMA = 1;
@@ -73,6 +82,21 @@ export function parseVersionSpelling(spelling) {
73
82
  const line = `${parts[0]}.${parts[1]}`;
74
83
  return parts.length >= 4 ? { spelling, line, exact: s, bare } : { spelling, line, exact: null, bare: null };
75
84
  }
85
+ /**
86
+ * Decision 7 / R2 (issue #284): does `candidate` — a served or installed
87
+ * `clickhouse_version` — match `req`? A spelled channel (`-lts` etc.) matches
88
+ * only itself; an unspelled one matches that patch on any channel. This is
89
+ * the ONE matching rule for every place a patch is matched: the registry, the
90
+ * release, the lock and `scripts/fetch.sh`. `false` for a line request — a
91
+ * line never "matches" a patch under this rule, it CONTAINS one.
92
+ */
93
+ export function patchMatches(candidate, req) {
94
+ if (req.exact === null)
95
+ return false;
96
+ if (CHANNEL.test(req.exact))
97
+ return candidate === req.exact;
98
+ return candidate === req.exact || candidate.replace(CHANNEL, '') === req.bare;
99
+ }
76
100
  /** Numeric order over dotted versions, channel suffix ignored: 25.10 > 25.8, 25.8.30.16 > 25.8.28.1. */
77
101
  export function compareVersions(a, b) {
78
102
  const pa = a.replace(CHANNEL, '').split('.').map((p) => Number.parseInt(p, 10));
@@ -587,14 +611,7 @@ export function selectArtifact(index, platform, req) {
587
611
  const all = forPlatform(index, platform);
588
612
  const rev = fetchAbiRevision();
589
613
  const have = all.filter((a) => a.abi_revision === rev).map((a) => a.clickhouse_version).join(', ') || 'nothing';
590
- let anyRevision;
591
- if (req.exact !== null) {
592
- const typedChannel = CHANNEL.test(req.exact);
593
- anyRevision = all.filter((a) => a.clickhouse_version === req.exact || (!typedChannel && a.clickhouse_version.replace(CHANNEL, '') === req.bare));
594
- }
595
- else {
596
- anyRevision = all.filter((a) => a.clickhouse_minor === req.line);
597
- }
614
+ const anyRevision = req.exact !== null ? all.filter((a) => patchMatches(a.clickhouse_version, req)) : all.filter((a) => a.clickhouse_minor === req.line);
598
615
  const hit = anyRevision.filter((a) => a.abi_revision === rev);
599
616
  if (hit.length === 0) {
600
617
  throw new ArtifactUnpublishedError(req.exact !== null
@@ -602,7 +619,7 @@ export function selectArtifact(index, platform, req) {
602
619
  `release does not publish it at that revision: ${served(anyRevision, `that patch for ${platform}`)}` +
603
620
  `; at ABI revision ${rev} the release has: ${have}. Ask for the line (${req.line}) to take what was published.`
604
621
  : `chtypes: no artifact for ClickHouse line ${req.line} on ${platform} at ABI revision ${rev} (this SDK's): ` +
605
- `${served(anyRevision, `that line for ${platform}`)}; at ABI revision ${rev} the release has: ${have}`);
622
+ `${served(anyRevision, `that line for ${platform}`)}; at ABI revision ${rev} the release has: ${have}.`);
606
623
  }
607
624
  // A line can carry more than one row: two patches, or the same patch built
608
625
  // twice (a release keeps the two highest builds per version). Take the newest
@@ -647,7 +664,7 @@ function forPlatform(index, platform) {
647
664
  const rows = index.artifacts.filter((a) => a.os === os && a.arch === arch);
648
665
  if (rows.length === 0) {
649
666
  const have = [...new Set(index.artifacts.map((a) => `${a.os}-${a.arch}`))].sort();
650
- throw new ArtifactUnpublishedError(`chtypes: the release has nothing for ${platform} (it has: ${have.length > 0 ? have.join(', ') : 'nothing'})`);
667
+ throw new ArtifactUnpublishedError(`chtypes: the release has nothing for ${platform} (it has: ${have.length > 0 ? have.join(', ') : 'nothing'}).`);
651
668
  }
652
669
  return rows;
653
670
  }
@@ -683,11 +700,20 @@ export async function ensure(spelling, options = {}) {
683
700
  inFlight.set(key, p);
684
701
  return p;
685
702
  }
703
+ /** A synthetic line-shaped request, for the parts of `installOne` / the lock helpers that take one — `--all` never spells an exact patch. */
704
+ function lineRequest(line) {
705
+ return { spelling: line, line, exact: null, bare: null };
706
+ }
686
707
  /**
687
708
  * `fetch --all`: every line the release publishes for the platform at this
688
- * binding's ABI revision, each through `ensure`'s chain. A line it has only at
689
- * another revision is not installed and is named in one loud stderr line; the
690
- * rest go on.
709
+ * binding's ABI revision, each through `ensure`'s chain — installed flat, as
710
+ * a line request always is. A line it has only at another revision is not
711
+ * installed and is named in one loud stderr line; the rest go on.
712
+ *
713
+ * Under `--frozen` (F6): every line the lock pins for this platform, at its
714
+ * own newest pinned patch — never a line the release has that the lock does
715
+ * not pin (that is not drift in anything that was pinned). Every candidate's
716
+ * ABI revision is checked (F2) before the release is even read.
691
717
  */
692
718
  export async function ensureAll(options = {}) {
693
719
  const platform = resolvePlatform(options.platform);
@@ -696,10 +722,27 @@ export async function ensureAll(options = {}) {
696
722
  if (options.offline)
697
723
  throw new ChtypesError('chtypes: --all needs the release listing; it cannot run offline');
698
724
  const ctx = installContext(platform, dest, options);
725
+ let pinnedLines = [];
726
+ if (ctx.frozen) {
727
+ pinnedLines = linesPinnedForPlatform(ctx.lock, platform);
728
+ for (const line of pinnedLines)
729
+ checkLockRevisionEarly(ctx, lineRequest(line));
730
+ }
699
731
  const source = openSource(options);
700
732
  emit({ type: 'status', message: `every published ClickHouse line, ${platform} -> ${dest}` });
701
733
  emit({ type: 'status', message: `source ${source.description}` });
702
734
  const release = await loadRelease(source, options, emit);
735
+ if (ctx.frozen) {
736
+ const out = [];
737
+ for (const line of pinnedLines) {
738
+ const req = lineRequest(line);
739
+ const candidate = selectFromLock(ctx.lock, platform, req);
740
+ const art = verifyFrozenCandidate(release, ctx, req, candidate);
741
+ out.push(await installOne(release, art, ctx, req));
742
+ }
743
+ await installGoldens(release, ctx, options);
744
+ return out;
745
+ }
703
746
  const rows = selectAll(release.index, platform);
704
747
  // A line the release has only at another ABI revision is not installed —
705
748
  // and never silently: one loud line per such line, then the rest go on.
@@ -707,7 +750,7 @@ export async function ensureAll(options = {}) {
707
750
  warn(`chtypes: WARNING: ${message}`);
708
751
  const out = [];
709
752
  for (const art of rows)
710
- out.push(await installOne(release, art, ctx));
753
+ out.push(await installOne(release, art, ctx, lineRequest(art.clickhouse_minor)));
711
754
  await installGoldens(release, ctx, options);
712
755
  return out;
713
756
  }
@@ -809,7 +852,10 @@ function installContext(platform, dest, options) {
809
852
  // frozen without a lock path reads ./chtypes.lock (docs/guides/fetch.md, Decisions).
810
853
  const lockPath = options.lock !== undefined && options.lock !== '' ? path.resolve(options.lock) : frozen ? path.resolve(DEFAULT_LOCK_FILE) : undefined;
811
854
  const lock = lockPath !== undefined ? readLock(lockPath) : null;
812
- if (frozen && lock === null) {
855
+ // Decision 6: offline never reads the lock at all, so a missing lock file
856
+ // is not refused here — `ensureUncached`'s offline branch never consults
857
+ // `ctx.lock` and answers from the destination alone.
858
+ if (frozen && lock === null && options.offline !== true) {
813
859
  throw new ArtifactPinnedError(`chtypes: ${lockPath} does not exist, and frozen refuses anything it does not pin`);
814
860
  }
815
861
  if (platform !== hostPlatform()) {
@@ -818,38 +864,141 @@ function installContext(platform, dest, options) {
818
864
  }
819
865
  return { platform, dest, lockPath, lock, frozen, force: options.force ?? false, emit: options.onProgress ?? noop };
820
866
  }
867
+ /**
868
+ * Every lock entry (its full `<os>-<arch>/<clickhouse_version>` key) that
869
+ * `req` — a patch or a line spelling, for `platform` — matches under R2.
870
+ */
871
+ function candidatesFromLock(lock, platform, req) {
872
+ const prefix = `${platform}/`;
873
+ const rows = [];
874
+ for (const [key, entry] of Object.entries(lock.artifacts)) {
875
+ if (!key.startsWith(prefix))
876
+ continue;
877
+ const version = key.slice(prefix.length);
878
+ const matches = req.exact !== null ? patchMatches(version, req) : minorOfVersion(version) === req.line;
879
+ if (matches)
880
+ rows.push({ key, entry, version });
881
+ }
882
+ return rows;
883
+ }
884
+ /** F1: the newest-versioned candidate the lock pins for `req` — a patch spelling's own entry, or a line's newest pinned patch. */
885
+ function selectFromLock(lock, platform, req) {
886
+ const rows = candidatesFromLock(lock, platform, req);
887
+ if (rows.length === 0)
888
+ return undefined;
889
+ return rows.reduce((best, cur) => (compareVersions(cur.version, best.version) > 0 ? cur : best));
890
+ }
891
+ /** Every line the lock pins at least one patch of, for `platform` — what `--all --frozen` (F6) installs. */
892
+ function linesPinnedForPlatform(lock, platform) {
893
+ const prefix = `${platform}/`;
894
+ const lines = new Set();
895
+ for (const key of Object.keys(lock.artifacts)) {
896
+ if (key.startsWith(prefix))
897
+ lines.add(minorOfVersion(key.slice(prefix.length)));
898
+ }
899
+ return [...lines].sort(compareVersions);
900
+ }
901
+ /**
902
+ * Fails fast, before any network access, when `frozen`'s lock already names
903
+ * an ABI revision for one of `req`'s candidates that is not this binding's own
904
+ * (docs/guides/fetch.md §5, F2). A lock made for one ABI revision does not get
905
+ * a second chance disguised as a drifted pin or an unpublished line once the
906
+ * SDK moves to another: the fix is always the same re-lock, so the message
907
+ * says that directly instead of waiting to see which of the two symptoms
908
+ * selection would have produced.
909
+ */
910
+ function checkLockRevisionEarly(ctx, req) {
911
+ if (!ctx.frozen || ctx.lock === null)
912
+ return;
913
+ for (const { key, entry } of candidatesFromLock(ctx.lock, ctx.platform, req)) {
914
+ if (entry.abi_revision === undefined || entry.abi_revision === fetchAbiRevision())
915
+ continue;
916
+ throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins ${key} at ABI revision ${entry.abi_revision}; this SDK speaks ABI revision ` +
917
+ `${fetchAbiRevision()} — re-lock with: ${FETCH_COMMAND} ${req.spelling} --lock ${ctx.lockPath}`);
918
+ }
919
+ }
920
+ /**
921
+ * The one sentence appended to a PINNED or UNPUBLISHED message when, under
922
+ * `frozen`, one of the lock's own candidate entries for `req` exists but
923
+ * names no ABI revision at all — written by an SDK before this field
924
+ * existed. `''` when there is nothing to add.
925
+ */
926
+ function revisionNote(ctx, req) {
927
+ if (!ctx.frozen || ctx.lock === null)
928
+ return '';
929
+ const entry = candidatesFromLock(ctx.lock, ctx.platform, req).find((r) => r.entry.abi_revision === undefined);
930
+ if (entry === undefined)
931
+ return '';
932
+ return (` ${ctx.lockPath} records no ABI revision (written by an older SDK); this SDK speaks ABI revision ` +
933
+ `${fetchAbiRevision()} — re-lock with: ${FETCH_COMMAND} ${req.spelling} --lock ${ctx.lockPath}`);
934
+ }
935
+ /** F5: the release's own row for a lock candidate, verified against SHA256SUMS, the pin's own hash, and the SDK's ABI revision — in that order. */
936
+ function verifyFrozenCandidate(release, ctx, req, candidate) {
937
+ const { key, entry, version } = candidate;
938
+ const [os, arch] = ctx.platform.split('-');
939
+ const row = release.index.artifacts.find((a) => a.file === entry.file && a.os === os && a.arch === arch);
940
+ const listedSha = release.sums.get(entry.file);
941
+ if (row === undefined || listedSha === undefined) {
942
+ throw new ArtifactUnpublishedError(`chtypes: the release at ${release.source.description} does not list ${entry.file}, which ${ctx.lockPath} pins for ${key}.${revisionNote(ctx, req)}`);
943
+ }
944
+ if (row.sha256 !== listedSha) {
945
+ throw new ArtifactCorruptError(`chtypes: index.json says ${entry.file} is ${row.sha256} but SHA256SUMS says ${listedSha} — the release disagrees with itself; not installing it`);
946
+ }
947
+ if (row.abi_revision !== fetchAbiRevision()) {
948
+ throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins ${key} to ${entry.file}, which the release at ${release.source.description} serves at ABI revision ` +
949
+ `${row.abi_revision ?? 'none recorded'}; this SDK speaks ABI revision ${fetchAbiRevision()} — re-lock with: ` +
950
+ `${FETCH_COMMAND} ${version} --lock ${ctx.lockPath}${revisionNote(ctx, req)}`);
951
+ }
952
+ if (entry.sha256.toLowerCase() !== listedSha) {
953
+ throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins ${key} to ${entry.file} (${entry.sha256}) but the release lists it as ${listedSha}; ` +
954
+ `frozen refuses it.${revisionNote(ctx, req)}`);
955
+ }
956
+ return row;
957
+ }
958
+ /** F1-F5: install exactly what the lock pins for `req`, never a newer patch or build the release also serves. */
959
+ async function installFrozen(release, req, ctx, options) {
960
+ const lock = ctx.lock; // installContext refuses `frozen` with no lock before this is ever called.
961
+ const candidate = selectFromLock(lock, ctx.platform, req);
962
+ if (candidate === undefined) {
963
+ throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins nothing for ${ctx.platform}/${req.spelling}, and frozen refuses anything it does not pin`);
964
+ }
965
+ const art = verifyFrozenCandidate(release, ctx, req, candidate);
966
+ const installed = await installOne(release, art, ctx, req);
967
+ await installGoldens(release, ctx, options);
968
+ return installed;
969
+ }
821
970
  async function ensureUncached(req, platform, dest, options) {
822
971
  const ctx = installContext(platform, dest, options);
823
- const install = path.join(dest, req.line);
824
972
  if (options.offline) {
825
- // Never touch the source: installed and hashing what its own manifest says is the whole test.
826
- const have = await installedLine(install);
827
- const pin = ctx.lock?.artifacts[`${platform}/${req.line}`];
828
- if (have !== null && have.ok && (!ctx.frozen || pin !== undefined)) {
829
- ctx.emit({ type: 'status', message: `offline — already installed and verified: ${install}` });
973
+ // Never touch the source, and never the lock (Decision 6): installed and
974
+ // hashing what its own manifest says, at either slot, is the whole test.
975
+ const patches = await installedPatchesForLine(dest, req.line);
976
+ const match = req.exact !== null
977
+ ? (patches.find((p) => patchMatches(p.version, req)) ?? null)
978
+ : patches.reduce((best, cur) => (best === null || compareVersions(cur.version, best.version) > 0 ? cur : best), null);
979
+ if (match !== null && match.ok) {
980
+ ctx.emit({ type: 'status', message: `offline — already installed and verified: ${match.dir}` });
830
981
  return {
831
- dir: install,
982
+ dir: match.dir,
832
983
  registry: dest,
833
984
  line: req.line,
834
- version: have.version,
985
+ version: match.version,
835
986
  platform,
836
- file: pin?.file ?? '',
837
- sha256: pin?.sha256 ?? '',
838
- library: have.library,
839
- librarySha256: have.expected,
987
+ file: '',
988
+ sha256: '',
989
+ library: match.library,
990
+ librarySha256: match.expected,
840
991
  installed: false,
841
992
  source: 'offline',
842
993
  signed: false,
843
994
  keyId: null,
844
995
  };
845
996
  }
846
- if (have !== null && have.ok && ctx.frozen) {
847
- throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins nothing for ${platform}/${req.line}, and frozen refuses anything it does not pin`);
848
- }
849
- throw new SourceUnreachableError(`chtypes: offline — ClickHouse ${req.line} (${platform}) is not installed and verified in ${dest}` +
850
- (have !== null && !have.ok ? ` (${have.problem})` : '') +
997
+ throw new SourceUnreachableError(`chtypes: offline — ClickHouse ${req.spelling} (${platform}) is not installed and verified in ${dest}` +
998
+ (match !== null && !match.ok ? ` (${match.problem})` : '') +
851
999
  ', and offline forbids fetching it');
852
1000
  }
1001
+ checkLockRevisionEarly(ctx, req);
853
1002
  const source = openSource(options);
854
1003
  ctx.emit({
855
1004
  type: 'status',
@@ -857,17 +1006,58 @@ async function ensureUncached(req, platform, dest, options) {
857
1006
  });
858
1007
  ctx.emit({ type: 'status', message: `source ${source.description}` });
859
1008
  const release = await loadRelease(source, options, ctx.emit);
1009
+ if (ctx.frozen)
1010
+ return installFrozen(release, req, ctx, options);
860
1011
  const art = selectArtifact(release.index, platform, req);
861
- const installed = await installOne(release, art, ctx);
1012
+ const installed = await installOne(release, art, ctx, req);
862
1013
  await installGoldens(release, ctx, options);
863
1014
  return installed;
864
1015
  }
865
- /** One index row, from the release into `<dest>/<minor>/` — steps 2 through 4. */
866
- async function installOne(release, art, ctx) {
1016
+ /**
1017
+ * Same-filesystem, atomic: move the current flat `<dest>/<minor>/` occupant
1018
+ * aside to `<dest>/patches/<minor>/<its version>/` — NEVER deleted — before a
1019
+ * different patch takes the flat slot (issue #284, the layout rule). A
1020
+ * no-op when nothing is flat yet, or when the flat occupant already IS
1021
+ * `incomingVersion` (the ordinary re-verify/rebuild path handles that case).
1022
+ */
1023
+ async function demoteFlatIfDifferent(dest, minor, incomingVersion) {
1024
+ const flat = path.join(dest, minor);
1025
+ if (!existsSync(flat))
1026
+ return;
1027
+ const existing = readInstalledManifest(flat);
1028
+ if (existing === null || existing.version === incomingVersion)
1029
+ return;
1030
+ const patchesDir = path.join(dest, 'patches', minor);
1031
+ const target = path.join(patchesDir, existing.version);
1032
+ await mkdir(patchesDir, { recursive: true });
1033
+ if (existsSync(target))
1034
+ await rm(target, { recursive: true, force: true });
1035
+ await rename(flat, target);
1036
+ }
1037
+ /** Does `dir` already hold `art`, byte-verified — a manifest and a library hashing `art.library_sha256`? */
1038
+ async function verifiedInstall(dir, art) {
1039
+ const libPath = path.join(dir, art.library);
1040
+ if (!existsSync(path.join(dir, 'manifest.json')) || !existsSync(libPath))
1041
+ return false;
1042
+ return (await sha256File(libPath)) === art.library_sha256;
1043
+ }
1044
+ /**
1045
+ * One index row, from the release into its slot — steps 2 through 4. A LINE
1046
+ * request's pick installs flat, `<dest>/<minor>/`, demoting a different flat
1047
+ * occupant first (never deleting it). Any other exact patch request installs
1048
+ * at `<dest>/patches/<minor>/<clickhouse_version>/` — unless that exact
1049
+ * version already sits flat, byte-verified, in which case it counts as
1050
+ * installed there and no second copy is made (docs/guides/fetch.md §4).
1051
+ */
1052
+ async function installOne(release, art, ctx, req) {
867
1053
  const { dest, platform, emit } = ctx;
868
1054
  const minor = art.clickhouse_minor;
869
- const install = path.join(dest, minor);
870
- const lockKey = `${platform}/${minor}`;
1055
+ const version = art.clickhouse_version;
1056
+ const wantsFlat = req.exact === null;
1057
+ const flat = path.join(dest, minor);
1058
+ const nested = path.join(dest, 'patches', minor, version);
1059
+ const install = wantsFlat ? flat : nested;
1060
+ const lockKey = `${platform}/${version}`;
871
1061
  emit({ type: 'status', message: `${art.file} (${art.bytes} bytes, ClickHouse ${art.clickhouse_version}, library ${art.library})` });
872
1062
  // Step 2: the authentic SHA256SUMS must agree with index.json, byte for byte.
873
1063
  const listed = release.sums.get(art.file);
@@ -876,18 +1066,8 @@ async function installOne(release, art, ctx) {
876
1066
  if (listed !== art.sha256) {
877
1067
  throw new ArtifactCorruptError(`chtypes: index.json says ${art.file} is ${art.sha256} but SHA256SUMS says ${listed} — the release disagrees with itself; not installing it`);
878
1068
  }
879
- // §5: a frozen lock refuses anything but what it pins.
880
- if (ctx.frozen && ctx.lock !== null) {
881
- const pin = ctx.lock.artifacts[lockKey];
882
- if (pin === undefined) {
883
- throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins nothing for ${lockKey}, and frozen refuses anything it does not pin`);
884
- }
885
- if (pin.file !== art.file || pin.sha256.toLowerCase() !== art.sha256) {
886
- throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins ${lockKey} to ${pin.file} (${pin.sha256}) but the release offers ${art.file} (${art.sha256}); frozen refuses it`);
887
- }
888
- }
889
- const result = (installed) => ({
890
- dir: install,
1069
+ const result = (installed, dir) => ({
1070
+ dir,
891
1071
  registry: dest,
892
1072
  line: minor,
893
1073
  version: art.clickhouse_version,
@@ -901,22 +1081,33 @@ async function installOne(release, art, ctx) {
901
1081
  signed: release.signed,
902
1082
  keyId: release.keyId,
903
1083
  });
1084
+ const recordLock = () => {
1085
+ if (ctx.lockPath !== undefined && !ctx.frozen) {
1086
+ writeLockEntry(ctx.lockPath, lockKey, { file: art.file, sha256: art.sha256, abi_revision: fetchAbiRevision() });
1087
+ }
1088
+ };
904
1089
  // Already installed and intact? The same hash the install path ends with,
905
1090
  // so "already there" is a verified claim, not an inference from a directory.
906
1091
  if (!ctx.force) {
907
- const libPath = path.join(install, art.library);
908
- if (existsSync(path.join(install, 'manifest.json')) && existsSync(libPath)) {
909
- const have = await sha256File(libPath);
910
- if (have === art.library_sha256) {
911
- emit({ type: 'status', message: `already installed and verified: ${libPath}` });
912
- if (ctx.lockPath !== undefined)
913
- writeLockEntry(ctx.lockPath, lockKey, { file: art.file, sha256: art.sha256 });
914
- return result(false);
915
- }
916
- emit({ type: 'status', message: `${libPath} is present but hashes ${have} (want ${art.library_sha256}) — replacing` });
1092
+ if (await verifiedInstall(install, art)) {
1093
+ emit({ type: 'status', message: `already installed and verified: ${path.join(install, art.library)}` });
1094
+ recordLock();
1095
+ return result(false, install);
1096
+ }
1097
+ // §4: a patch request whose exact version already sits flat (put there by
1098
+ // an earlier line fetch) is already installed — no second, nested copy.
1099
+ if (!wantsFlat && (await verifiedInstall(flat, art))) {
1100
+ emit({ type: 'status', message: `already installed and verified (flat): ${path.join(flat, art.library)}` });
1101
+ recordLock();
1102
+ return result(false, flat);
917
1103
  }
918
1104
  }
1105
+ // A line request whose pick differs from the current flat occupant demotes
1106
+ // that occupant to patches/ BEFORE the incoming patch takes the flat slot.
1107
+ if (wantsFlat)
1108
+ await demoteFlatIfDifferent(dest, minor, version);
919
1109
  await mkdir(dest, { recursive: true });
1110
+ await mkdir(path.dirname(install), { recursive: true });
920
1111
  const tag = `${process.pid}.${randomBytes(4).toString('hex')}`;
921
1112
  // Hidden siblings: the loader skips dot-directories, so a half-written
922
1113
  // install is never scanned as a version.
@@ -955,7 +1146,7 @@ async function installOne(release, art, ctx) {
955
1146
  throw new ArtifactCorruptError(`chtypes: ${art.file} names library ${manifest.library} but does not contain it`);
956
1147
  }
957
1148
  // Into place through a sibling: an interrupted install never leaves a
958
- // half-populated <minor>/ for the loader to dlopen.
1149
+ // half-populated slot for the loader to dlopen.
959
1150
  const replaced = path.join(dest, `.${minor}.replaced.${tag}`);
960
1151
  if (existsSync(install))
961
1152
  await rename(install, replaced);
@@ -971,9 +1162,8 @@ async function installOne(release, art, ctx) {
971
1162
  throw new ArtifactCorruptError(`chtypes: installed ${installedLib} hashes ${finalSha}, manifest says ${manifest.librarySha256} — the install is bad; moved aside to ${aside}`);
972
1163
  }
973
1164
  emit({ type: 'status', message: `installed and verified: ${install} (${manifest.library} sha256 ${finalSha})` });
974
- if (ctx.lockPath !== undefined)
975
- writeLockEntry(ctx.lockPath, lockKey, { file: art.file, sha256: art.sha256 });
976
- return result(true);
1165
+ recordLock();
1166
+ return result(true, install);
977
1167
  }
978
1168
  finally {
979
1169
  await rm(tarball, { force: true });
@@ -982,33 +1172,34 @@ async function installOne(release, art, ctx) {
982
1172
  }
983
1173
  // ------------------------------------------------------ verify and list
984
1174
  /**
985
- * `chtypes verify`: re-hash every installed line in a registry directory
986
- * against its own manifest. A line whose manifest cannot be read is not a
987
- * line (a scratch directory) and is skipped, as the loader skips it.
1175
+ * `chtypes verify`: re-hash every installed PATCH in a registry directory —
1176
+ * the flat slot and every `patches/<minor>/<version>/` sibling (issue #284)
1177
+ * — against its own manifest. A directory whose manifest cannot be read is
1178
+ * not a patch (a scratch directory) and is skipped, as the loader skips it.
988
1179
  */
989
1180
  export async function verifyInstalled(dest, platform) {
990
1181
  const dir = fetchDestination(dest, resolvePlatform(platform));
991
1182
  const out = [];
992
- for (const line of installedLines(dir)) {
993
- const state = await installedLine(path.join(dir, line));
1183
+ for (const sub of allInstalledDirs(dir)) {
1184
+ const state = await installedPatch(sub);
994
1185
  if (state !== null)
995
1186
  out.push(state);
996
1187
  }
997
- return out;
1188
+ return out.sort((a, b) => compareVersions(a.version, b.version));
998
1189
  }
999
- /** `chtypes list`: what is installed in the registry directory, and what the release offers for the platform. */
1190
+ /** `chtypes list`: what is installed in the registry directory (one record per patch), and what the release offers for the platform. */
1000
1191
  export async function listArtifacts(options = {}) {
1001
1192
  const platform = resolvePlatform(options.platform);
1002
1193
  const dir = fetchDestination(options.dest, platform);
1003
1194
  const installed = [];
1004
- for (const line of installedLines(dir)) {
1005
- const m = readInstalledManifest(path.join(dir, line));
1195
+ for (const sub of allInstalledDirs(dir)) {
1196
+ const m = readInstalledManifest(sub);
1006
1197
  if (m === null)
1007
1198
  continue;
1008
- const present = existsSync(path.join(dir, line, m.library));
1199
+ const present = existsSync(path.join(sub, m.library));
1009
1200
  installed.push({
1010
- line,
1011
- dir: path.join(dir, line),
1201
+ line: m.minor,
1202
+ dir: sub,
1012
1203
  version: m.version,
1013
1204
  library: m.library,
1014
1205
  expected: m.librarySha256,
@@ -1017,6 +1208,7 @@ export async function listArtifacts(options = {}) {
1017
1208
  problem: present ? null : `${m.library} is missing`,
1018
1209
  });
1019
1210
  }
1211
+ installed.sort((a, b) => compareVersions(a.version, b.version));
1020
1212
  if (options.offline)
1021
1213
  return { registry: dir, platform, installed, offered: null, source: null };
1022
1214
  const source = openSource(options);
@@ -1034,7 +1226,37 @@ export async function listArtifacts(options = {}) {
1034
1226
  return { registry: dir, platform, installed, offered, source: source.description, ...(note !== undefined ? { notShown: note } : {}) };
1035
1227
  }
1036
1228
  // ------------------------------------------------------------ the lock
1037
- /** Read a lock file; null when it does not exist. */
1229
+ /** `chtypes-<version>-<os>-<arch>[-b<N>].tar.gz` (docs/guides/artifacts.md), version captured whole (it may itself contain dots and a channel). */
1230
+ const ASSET_FILE_NAME = /^chtypes-(.+)-(linux|darwin)-(arm64|amd64)(?:-b\d+)?\.tar\.gz$/;
1231
+ /** The ClickHouse version a served asset file name claims, or `null` when it does not parse. */
1232
+ function versionFromAssetFile(file) {
1233
+ const m = ASSET_FILE_NAME.exec(file);
1234
+ return m === null ? null : m[1];
1235
+ }
1236
+ /** True for a PATCH spelling (four or more numeric components) — what schema 2's keys must name, never a bare line. */
1237
+ function isPatchVersion(version) {
1238
+ try {
1239
+ return parseVersionSpelling(version).exact !== null;
1240
+ }
1241
+ catch {
1242
+ return false;
1243
+ }
1244
+ }
1245
+ /**
1246
+ * Read a lock file; `null` when it does not exist. Accepts schema 1 and
1247
+ * schema 2 (docs/guides/fetch.md §5); `.schema` reports the number that was
1248
+ * actually on disk (so a caller can tell which it read), while `.artifacts`
1249
+ * is ALWAYS schema-2-shaped: a schema-1 entry `<os>-<arch>/<minor>` is
1250
+ * converted to the schema-2 entry `<os>-<arch>/<v>`, `<v>` being the
1251
+ * ClickHouse version its own `file` names — the one schema-1 could not
1252
+ * record, because it never had two patches of a line to distinguish. Every
1253
+ * other reader in this module treats `.artifacts` as schema-2-keyed
1254
+ * unconditionally; only this doc comment and `.schema` itself ever mention
1255
+ * schema 1 again. Any other schema number is refused, naming both 1 and
1256
+ * `LOCK_SCHEMA`. An entry that does not parse, under either schema — including
1257
+ * a schema-2 key that names a bare line rather than an exact patch — makes the
1258
+ * whole lock unreadable and the error names it.
1259
+ */
1038
1260
  export function readLock(file) {
1039
1261
  let text;
1040
1262
  try {
@@ -1052,8 +1274,12 @@ export function readLock(file) {
1052
1274
  catch (err) {
1053
1275
  throw new ChtypesError(`chtypes: lock file ${file} is not JSON: ${errorText(err)}`, { cause: err });
1054
1276
  }
1055
- if (typeof doc !== 'object' || doc === null || doc['schema'] !== LOCK_SCHEMA) {
1056
- throw new ChtypesError(`chtypes: lock file ${file} is not schema ${LOCK_SCHEMA}`);
1277
+ if (typeof doc !== 'object' || doc === null) {
1278
+ throw new ChtypesError(`chtypes: lock file ${file} is not schema ${LOCK_SCHEMA_LEGACY} or ${LOCK_SCHEMA}`);
1279
+ }
1280
+ const schema = doc['schema'];
1281
+ if (schema !== LOCK_SCHEMA_LEGACY && schema !== LOCK_SCHEMA) {
1282
+ throw new ChtypesError(`chtypes: lock file ${file} is schema ${JSON.stringify(schema)}, not ${LOCK_SCHEMA_LEGACY} or ${LOCK_SCHEMA}`);
1057
1283
  }
1058
1284
  const raw = doc['artifacts'];
1059
1285
  const artifacts = {};
@@ -1062,11 +1288,40 @@ export function readLock(file) {
1062
1288
  if (typeof v !== 'object' || v === null)
1063
1289
  continue;
1064
1290
  const e = v;
1065
- if (typeof e['file'] === 'string' && typeof e['sha256'] === 'string')
1066
- artifacts[k] = { file: e['file'], sha256: e['sha256'] };
1291
+ if (typeof e['file'] !== 'string' || typeof e['sha256'] !== 'string')
1292
+ continue;
1293
+ // abi_revision is optional and additive (docs/guides/fetch.md §5): an
1294
+ // entry written before this SDK recorded it simply has none, read the
1295
+ // same way an index row that carries none is — never a typed number
1296
+ // (TS cannot tell 5.0 from 5, so anything not a safe integer is absent).
1297
+ const entry = { file: e['file'], sha256: e['sha256'], ...revisionOf(e['abi_revision']) };
1298
+ const slash = k.indexOf('/');
1299
+ if (slash < 0) {
1300
+ throw new ChtypesError(`chtypes: lock file ${file}: entry ${JSON.stringify(k)} is not <os>-<arch>/<version>`);
1301
+ }
1302
+ const platformKey = k.slice(0, slash);
1303
+ const versionOrMinor = k.slice(slash + 1);
1304
+ if (schema === LOCK_SCHEMA_LEGACY) {
1305
+ const version = versionFromAssetFile(entry.file);
1306
+ if (version === null) {
1307
+ throw new ChtypesError(`chtypes: lock file ${file}: entry ${JSON.stringify(k)}'s file ${JSON.stringify(entry.file)} does not name a ` +
1308
+ 'ClickHouse version — cannot convert this schema 1 lock to schema 2');
1309
+ }
1310
+ if (minorOfVersion(version) !== versionOrMinor) {
1311
+ throw new ChtypesError(`chtypes: lock file ${file}: entry ${JSON.stringify(k)} names line ${versionOrMinor} but its file ` +
1312
+ `${JSON.stringify(entry.file)} is ${version} — cannot convert this schema 1 lock to schema 2`);
1313
+ }
1314
+ artifacts[`${platformKey}/${version}`] = entry;
1315
+ }
1316
+ else {
1317
+ if (!isPatchVersion(versionOrMinor)) {
1318
+ throw new ChtypesError(`chtypes: lock file ${file}: entry ${JSON.stringify(k)} names a line, not an exact patch — schema ${LOCK_SCHEMA} keys are <os>-<arch>/<clickhouse_version>`);
1319
+ }
1320
+ artifacts[k] = entry;
1321
+ }
1067
1322
  }
1068
1323
  }
1069
- return { schema: LOCK_SCHEMA, artifacts };
1324
+ return { schema, artifacts };
1070
1325
  }
1071
1326
  function writeLockEntry(file, key, entry) {
1072
1327
  const current = readLock(file) ?? { schema: LOCK_SCHEMA, artifacts: {} };
@@ -1100,20 +1355,24 @@ function readInstalledManifest(dir) {
1100
1355
  return null;
1101
1356
  }
1102
1357
  }
1103
- /** The state of one installed line directory: null when it is not one (no usable manifest). */
1104
- async function installedLine(dir) {
1358
+ /**
1359
+ * The state of one installed patch directory (flat or `patches/<minor>/<version>/`):
1360
+ * null when it is not one (no usable manifest). `line` comes from the
1361
+ * manifest's own claim, never from the directory name — a nested directory is
1362
+ * named after the VERSION, not the line.
1363
+ */
1364
+ async function installedPatch(dir) {
1105
1365
  const m = readInstalledManifest(dir);
1106
1366
  if (m === null)
1107
1367
  return null;
1108
- const line = path.basename(dir);
1109
1368
  const libPath = path.join(dir, m.library);
1110
1369
  if (!existsSync(libPath)) {
1111
- return { line, dir, version: m.version, library: m.library, expected: m.librarySha256, actual: null, ok: false, problem: `${m.library} is missing` };
1370
+ return { line: m.minor, dir, version: m.version, library: m.library, expected: m.librarySha256, actual: null, ok: false, problem: `${m.library} is missing` };
1112
1371
  }
1113
1372
  const actual = await sha256File(libPath);
1114
1373
  const ok = actual === m.librarySha256;
1115
1374
  return {
1116
- line,
1375
+ line: m.minor,
1117
1376
  dir,
1118
1377
  version: m.version,
1119
1378
  library: m.library,
@@ -1123,17 +1382,82 @@ async function installedLine(dir) {
1123
1382
  problem: ok ? null : `${m.library} hashes ${actual}, manifest says ${m.librarySha256}`,
1124
1383
  };
1125
1384
  }
1126
- function installedLines(dir) {
1385
+ /** Every installed-patch directory in a registry root: the flat slot per line, plus every `patches/<minor>/<version>/`. */
1386
+ function allInstalledDirs(dir) {
1387
+ const out = [];
1127
1388
  let entries;
1128
1389
  try {
1129
1390
  entries = readdirSync(dir);
1130
1391
  }
1131
1392
  catch {
1132
- return [];
1393
+ return out;
1394
+ }
1395
+ for (const entry of entries.sort()) {
1396
+ if (entry.startsWith('.') || entry === 'patches')
1397
+ continue;
1398
+ if (existsSync(path.join(dir, entry, 'manifest.json')))
1399
+ out.push(path.join(dir, entry));
1400
+ }
1401
+ const patchesRoot = path.join(dir, 'patches');
1402
+ let minorEntries;
1403
+ try {
1404
+ minorEntries = readdirSync(patchesRoot);
1405
+ }
1406
+ catch {
1407
+ return out;
1408
+ }
1409
+ for (const minorEntry of minorEntries.sort()) {
1410
+ if (minorEntry.startsWith('.'))
1411
+ continue;
1412
+ const minorDir = path.join(patchesRoot, minorEntry);
1413
+ let versionEntries;
1414
+ try {
1415
+ versionEntries = readdirSync(minorDir);
1416
+ }
1417
+ catch {
1418
+ continue;
1419
+ }
1420
+ for (const versionEntry of versionEntries.sort()) {
1421
+ if (versionEntry.startsWith('.'))
1422
+ continue;
1423
+ const sub = path.join(minorDir, versionEntry);
1424
+ if (existsSync(path.join(sub, 'manifest.json')))
1425
+ out.push(sub);
1426
+ }
1427
+ }
1428
+ return out;
1429
+ }
1430
+ /**
1431
+ * Every installed patch of `minor` in `dest` — the flat slot and every
1432
+ * `patches/<minor>/<version>/` sibling — byte-verified against its own
1433
+ * manifest. Used offline, where the source and the lock are both off limits
1434
+ * (Decision 6): "installed" there means hashed and matching, not merely present.
1435
+ */
1436
+ async function installedPatchesForLine(dest, minor) {
1437
+ const flat = path.join(dest, minor);
1438
+ const dirs = existsSync(path.join(flat, 'manifest.json')) ? [flat] : [];
1439
+ const patchesDir = path.join(dest, 'patches', minor);
1440
+ let versionEntries = [];
1441
+ try {
1442
+ versionEntries = readdirSync(patchesDir);
1443
+ }
1444
+ catch {
1445
+ versionEntries = [];
1133
1446
  }
1134
- return entries
1135
- .filter((e) => !e.startsWith('.') && existsSync(path.join(dir, e, 'manifest.json')))
1136
- .sort(compareVersions);
1447
+ for (const versionEntry of versionEntries.sort()) {
1448
+ if (versionEntry.startsWith('.'))
1449
+ continue;
1450
+ const sub = path.join(patchesDir, versionEntry);
1451
+ if (existsSync(path.join(sub, 'manifest.json')))
1452
+ dirs.push(sub);
1453
+ }
1454
+ const out = [];
1455
+ for (const d of dirs) {
1456
+ const state = await installedPatch(d);
1457
+ if (state !== null)
1458
+ out.push(state);
1459
+ }
1460
+ return out;
1137
1461
  }
1138
1462
  /** sha256 of a file, streamed — a 300 MB library never sits in memory whole. */
1139
1463
  export async function sha256File(file) {