@coderook/cli 0.46.1 → 0.46.3

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.
@@ -30,6 +30,8 @@ const node_crypto_1 = require("node:crypto");
30
30
  const promises_1 = require("node:fs/promises");
31
31
  const node_path_1 = __importDefault(require("node:path"));
32
32
  const cbx_js_1 = require("../core/cbx.js");
33
+ const delta_js_1 = require("../shared/delta.js");
34
+ const store_v2_js_1 = require("./store_v2.js");
33
35
  exports.DIGEST = /^[0-9a-f]{64}$/;
34
36
  /** Objects at most this big are packed rather than written on their own. */
35
37
  exports.PACKED_OBJECT_LIMIT = 256 * 1024;
@@ -38,6 +40,58 @@ const PACK_BYTES = 32 * 1024 * 1024;
38
40
  function digestOf(bytes) {
39
41
  return (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex");
40
42
  }
43
+ /*
44
+ Every v2 index, searched in place. A lookup is a binary search per pack,
45
+ newest first, which is nothing for the few a status makes; a save or a
46
+ restore makes thousands, and once the searches would cost more than
47
+ decoding everything once, everything is decoded into one map.
48
+ */
49
+ class V2Packs {
50
+ files = [];
51
+ map = null;
52
+ searches = 0;
53
+ add(file) {
54
+ this.files.push(file);
55
+ if (this.map)
56
+ for (const name of file.names())
57
+ this.map.set(name, { ...file.find(name), pack: file.pack });
58
+ }
59
+ get(digest) {
60
+ if (this.map)
61
+ return this.map.get(digest);
62
+ this.searches += this.files.length;
63
+ if (this.searches > 200_000) {
64
+ this.map = new Map();
65
+ for (const file of this.files) {
66
+ for (const name of file.names())
67
+ this.map.set(name, { ...file.find(name), pack: file.pack });
68
+ }
69
+ return this.map.get(digest);
70
+ }
71
+ for (let index = this.files.length - 1; index >= 0; index -= 1) {
72
+ const found = this.files[index].find(digest);
73
+ if (found)
74
+ return { ...found, pack: this.files[index].pack };
75
+ }
76
+ return undefined;
77
+ }
78
+ has(digest) {
79
+ return this.get(digest) !== undefined;
80
+ }
81
+ *keys() {
82
+ for (const file of this.files)
83
+ yield* file.names();
84
+ }
85
+ }
86
+ /*
87
+ The largest object kept as a delta: the biggest piece any chunk profile
88
+ cuts (32 MiB). The transport stops at 8 MiB, and using its limit here kept
89
+ every edited piece of a large file whole again: JDK's 130 MB module image
90
+ is cut into pieces of up to 16 MiB.
91
+ */
92
+ const LOCAL_DELTA_MAX_BYTES = 32 * 1024 * 1024;
93
+ /* Solid blocks kept decompressed, so restoring the files of one block reads it once. */
94
+ const BLOCKS_KEPT_BYTES = 160 * 1024 * 1024;
41
95
  class ObjectStore {
42
96
  directory;
43
97
  /* Fan-out folders already known to exist, so each is made once, not per object. */
@@ -57,8 +111,20 @@ class ObjectStore {
57
111
  because Windows will not delete a folder holding an open file.
58
112
  */
59
113
  handles = new Map();
60
- constructor(directory) {
114
+ /*
115
+ A cbx-local 2 history (store_v2.ts): the same objects, kept as solid
116
+ blocks and deltas in v2 packs. A v1 history never writes them, but every
117
+ store reads both, so upgrading changes nothing already written.
118
+ */
119
+ v2;
120
+ v2Located = null;
121
+ pendingV2 = new Map();
122
+ writingV2 = new Map();
123
+ pendingV2Bytes = 0;
124
+ blocks = new Map();
125
+ constructor(directory, options = {}) {
61
126
  this.directory = directory;
127
+ this.v2 = options.v2 === true;
62
128
  }
63
129
  get packDirectory() {
64
130
  return node_path_1.default.join(this.directory, "packs");
@@ -68,9 +134,21 @@ class ObjectStore {
68
134
  throw new Error(`Not an object name: ${digest}`);
69
135
  return node_path_1.default.join(this.directory, digest.slice(0, 2), digest.slice(2));
70
136
  }
137
+ /*
138
+ Read once, by one caller: the lanes of a save ask at the same moment, and
139
+ a second caller that found the v1 list set while the v2 list was still
140
+ loading went on with no v2 list at all.
141
+ */
142
+ loadingPacks = null;
71
143
  async packs() {
72
- if (this.packed)
144
+ if (this.packed && this.v2Located)
73
145
  return this.packed;
146
+ this.loadingPacks ??= this.loadPacks().finally(() => {
147
+ this.loadingPacks = null;
148
+ });
149
+ return this.loadingPacks;
150
+ }
151
+ async loadPacks() {
74
152
  const found = new Map();
75
153
  let names = [];
76
154
  try {
@@ -86,9 +164,33 @@ class ObjectStore {
86
164
  found.set(digest, { pack, offset, length });
87
165
  }
88
166
  }
167
+ const v2 = await this.readV2Indexes();
89
168
  this.packed = found;
169
+ this.v2Located = v2;
170
+ return found;
171
+ }
172
+ get v2Directory() {
173
+ return node_path_1.default.join(this.directory, "v2");
174
+ }
175
+ async readV2Indexes() {
176
+ const found = new V2Packs();
177
+ let names = [];
178
+ try {
179
+ names = await (0, promises_1.readdir)(this.v2Directory);
180
+ }
181
+ catch {
182
+ /* No v2 packs. */
183
+ }
184
+ for (const name of names.filter((one) => one.endsWith(".idx")).sort()) {
185
+ const pack = node_path_1.default.join(this.v2Directory, name.replace(/\.idx$/, ".pack"));
186
+ found.add(new store_v2_js_1.V2IndexFile(pack, await (0, promises_1.readFile)(node_path_1.default.join(this.v2Directory, name))));
187
+ }
90
188
  return found;
91
189
  }
190
+ async v2Index() {
191
+ await this.packs();
192
+ return this.v2Located;
193
+ }
92
194
  async loose(digest) {
93
195
  try {
94
196
  await (0, promises_1.stat)(this.where(digest));
@@ -101,10 +203,59 @@ class ObjectStore {
101
203
  async has(digest) {
102
204
  if (this.pending.has(digest) || this.writing.has(digest))
103
205
  return true;
206
+ if (this.pendingV2.has(digest) || this.writingV2.has(digest))
207
+ return true;
104
208
  if ((await this.packs()).has(digest))
105
209
  return true;
210
+ if (this.v2Located.has(digest))
211
+ return true;
106
212
  return this.loose(digest);
107
213
  }
214
+ /**
215
+ * How many deltas stand under an object, or null when it is not held.
216
+ * Anything kept whole, in a v1 store or loose, stands on nothing.
217
+ */
218
+ async chainOf(digest) {
219
+ const waiting = this.pendingV2.get(digest) ?? this.writingV2.get(digest);
220
+ if (waiting)
221
+ return waiting.solid ? 0 : waiting.chain;
222
+ const entry = (await this.v2Index()).get(digest);
223
+ if (entry)
224
+ return entry.chain;
225
+ return (await this.has(digest)) ? 0 : null;
226
+ }
227
+ /** An object's own length without rebuilding it, or null when it is not held. */
228
+ async rawSize(digest) {
229
+ const waiting = this.pendingV2.get(digest) ?? this.writingV2.get(digest);
230
+ if (waiting)
231
+ return waiting.raw.length;
232
+ const entry = (await this.v2Index()).get(digest);
233
+ if (entry)
234
+ return entry.raw;
235
+ const frame = this.pending.get(digest) ?? this.writing.get(digest);
236
+ if (frame)
237
+ return Number(frame.readBigUInt64LE(8));
238
+ const located = this.packed.get(digest);
239
+ try {
240
+ const head = Buffer.alloc(16);
241
+ if (located) {
242
+ await (await this.handleFor(located.pack)).read(head, 0, 16, located.offset);
243
+ }
244
+ else {
245
+ const handle = await (0, promises_1.open)(this.where(digest), "r");
246
+ try {
247
+ await handle.read(head, 0, 16, 0);
248
+ }
249
+ finally {
250
+ await handle.close();
251
+ }
252
+ }
253
+ return Number(head.readBigUInt64LE(8));
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ }
108
259
  /**
109
260
  * Keep `raw`, returning its name.
110
261
  *
@@ -118,6 +269,10 @@ class ObjectStore {
118
269
  const digest = digestOf(raw);
119
270
  if (await this.has(digest))
120
271
  return digest;
272
+ if (this.v2 && !options.loose) {
273
+ await this.putV2(raw, digest, options.base, options.path, options.maxChain ?? store_v2_js_1.MAX_CHAIN);
274
+ return digest;
275
+ }
121
276
  const { frame } = (0, cbx_js_1.chunkFrame)(raw);
122
277
  if (!options.loose && raw.length <= exports.PACKED_OBJECT_LIMIT) {
123
278
  this.pending.set(digest, frame);
@@ -150,6 +305,17 @@ class ObjectStore {
150
305
  /* One pack at a time; a second caller waits for the first and goes again. */
151
306
  while (this.flushing)
152
307
  await this.flushing;
308
+ if (this.pendingV2.size) {
309
+ const taken = this.pendingV2;
310
+ this.pendingV2 = new Map();
311
+ this.pendingV2Bytes = 0;
312
+ this.writingV2 = taken;
313
+ this.flushing = this.writeV2Pack(taken).finally(() => {
314
+ this.writingV2 = new Map();
315
+ this.flushing = null;
316
+ });
317
+ await this.flushing;
318
+ }
153
319
  if (!this.pending.size)
154
320
  return;
155
321
  const taken = this.pending;
@@ -162,6 +328,190 @@ class ObjectStore {
162
328
  });
163
329
  await this.flushing;
164
330
  }
331
+ /**
332
+ * Keep one object in a v2 store: as a delta against `base` when that is
333
+ * clearly smaller than the object compressed on its own and the base does
334
+ * not already stand on too many others; otherwise joined into a solid
335
+ * block when small, or whole.
336
+ */
337
+ async putV2(raw, digest, base, filePath, maxChain) {
338
+ if (base && base !== digest && raw.length >= 64 && raw.length <= LOCAL_DELTA_MAX_BYTES) {
339
+ const depth = await this.chainOf(base);
340
+ if (depth !== null && depth + 1 < Math.min(maxChain, store_v2_js_1.MAX_CHAIN) + 1) {
341
+ let from = null;
342
+ try {
343
+ from = await this.get(base);
344
+ }
345
+ catch {
346
+ from = null;
347
+ }
348
+ if (from && from.length <= LOCAL_DELTA_MAX_BYTES) {
349
+ const patch = Buffer.from((0, delta_js_1.createExactDeltaPatch)(from, raw, LOCAL_DELTA_MAX_BYTES));
350
+ const squeezed = patch.length > 64 ? (0, store_v2_js_1.zstdAt)(patch, 3) : patch;
351
+ const packed = squeezed.length < patch.length;
352
+ const record = packed ? squeezed : patch;
353
+ /* Against what the object costs compressed on its own, not its raw size. */
354
+ const alone = Math.min(raw.length, (0, store_v2_js_1.zstdAt)(raw, 1).length);
355
+ if (record.length < alone / 2 && alone - record.length >= 96) {
356
+ await this.holdV2(digest, {
357
+ solid: false,
358
+ raw,
359
+ record,
360
+ kind: packed ? store_v2_js_1.KIND_DELTA_ZSTD : store_v2_js_1.KIND_DELTA,
361
+ chain: depth + 1,
362
+ base,
363
+ });
364
+ return;
365
+ }
366
+ }
367
+ }
368
+ }
369
+ if (raw.length <= store_v2_js_1.SOLID_OBJECT_LIMIT) {
370
+ await this.holdV2(digest, { solid: true, raw, key: (0, store_v2_js_1.solidKey)(filePath), large: false });
371
+ return;
372
+ }
373
+ /* Larger and compressible: joined with others of its kind. Otherwise kept as it is. */
374
+ if ((0, store_v2_js_1.compresses)(raw)) {
375
+ await this.holdV2(digest, { solid: true, raw, key: (0, store_v2_js_1.solidKey)(filePath), large: true });
376
+ return;
377
+ }
378
+ await this.holdV2(digest, { solid: false, raw, record: raw, kind: store_v2_js_1.KIND_WHOLE, chain: 0 });
379
+ }
380
+ async holdV2(digest, held) {
381
+ this.pendingV2.set(digest, held);
382
+ this.pendingV2Bytes += held.raw.length;
383
+ /* Written when there is a pack's worth; callers flush at the end of a save either way. */
384
+ if (this.pendingV2Bytes >= store_v2_js_1.V2_PACK_BYTES)
385
+ await this.flush();
386
+ }
387
+ async writeV2Pack(objects) {
388
+ await (0, promises_1.mkdir)(this.v2Directory, { recursive: true });
389
+ const entries = new Map();
390
+ const records = [];
391
+ let offset = 0;
392
+ const solid = [...objects]
393
+ .filter((pair) => pair[1].solid)
394
+ .sort(([leftName, left], [rightName, right]) => left.key < right.key ? -1 : left.key > right.key ? 1 : leftName < rightName ? -1 : 1);
395
+ let block = [];
396
+ let blockBytes = 0;
397
+ let blockLarge = false;
398
+ const closeBlock = () => {
399
+ if (!block.length)
400
+ return;
401
+ const joined = Buffer.concat(block.map(([, raw]) => raw));
402
+ const stored = (0, store_v2_js_1.zstdAt)(joined, 9, true, blockLarge ? store_v2_js_1.LARGE_WINDOW_LOG : undefined, true);
403
+ let inner = 0;
404
+ for (const [digest, raw] of block) {
405
+ entries.set(digest, { kind: store_v2_js_1.KIND_SOLID, chain: 0, offset, length: stored.length, inner, raw: raw.length });
406
+ inner += raw.length;
407
+ }
408
+ records.push(stored);
409
+ offset += stored.length;
410
+ block = [];
411
+ blockBytes = 0;
412
+ };
413
+ /* Small objects first, then large: the two tiers never share a block. */
414
+ for (const large of [false, true]) {
415
+ const limit = large ? store_v2_js_1.LARGE_BLOCK_BYTES : store_v2_js_1.SOLID_BLOCK_BYTES;
416
+ blockLarge = large;
417
+ for (const [digest, held] of solid) {
418
+ if (held.large !== large)
419
+ continue;
420
+ if (blockBytes && blockBytes + held.raw.length > limit)
421
+ closeBlock();
422
+ block.push([digest, held.raw]);
423
+ blockBytes += held.raw.length;
424
+ }
425
+ closeBlock();
426
+ }
427
+ for (const [digest, held] of objects) {
428
+ if (held.solid)
429
+ continue;
430
+ entries.set(digest, {
431
+ kind: held.kind,
432
+ chain: held.chain,
433
+ offset,
434
+ length: held.record.length,
435
+ inner: 0,
436
+ raw: held.raw.length,
437
+ ...(held.base ? { base: held.base } : {}),
438
+ });
439
+ records.push(held.record);
440
+ offset += held.record.length;
441
+ }
442
+ const body = Buffer.concat(records);
443
+ const id = digestOf(body);
444
+ const pack = node_path_1.default.join(this.v2Directory, `${id}.pack`);
445
+ const idx = node_path_1.default.join(this.v2Directory, `${id}.idx`);
446
+ await (0, promises_1.writeFile)(`${pack}.partial`, body);
447
+ await (0, promises_1.rename)(`${pack}.partial`, pack);
448
+ const encoded = (0, store_v2_js_1.encodeIndex)(entries);
449
+ await (0, promises_1.writeFile)(`${idx}.partial`, encoded);
450
+ await (0, promises_1.rename)(`${idx}.partial`, idx);
451
+ (await this.v2Index()).add(new store_v2_js_1.V2IndexFile(pack, encoded));
452
+ }
453
+ handleFor(pack) {
454
+ let opening = this.handles.get(pack);
455
+ if (!opening) {
456
+ opening = (0, promises_1.open)(pack, "r");
457
+ this.handles.set(pack, opening);
458
+ }
459
+ return opening;
460
+ }
461
+ async readRecord(entry) {
462
+ const record = Buffer.alloc(entry.length);
463
+ const { bytesRead } = await (await this.handleFor(entry.pack)).read(record, 0, entry.length, entry.offset);
464
+ if (bytesRead !== entry.length)
465
+ throw new Error("A v2 pack ends before one of its records does");
466
+ return record;
467
+ }
468
+ /** Rebuild one object from a v2 pack and check it against its name. */
469
+ async getV2(digest, entry) {
470
+ let raw;
471
+ try {
472
+ if (entry.kind === store_v2_js_1.KIND_SOLID) {
473
+ const key = `${entry.pack}:${entry.offset}`;
474
+ let block = this.blocks.get(key);
475
+ if (block) {
476
+ this.blocks.delete(key);
477
+ }
478
+ else {
479
+ block = (0, store_v2_js_1.unzstd)(await this.readRecord(entry));
480
+ }
481
+ this.blocks.set(key, block);
482
+ let kept = 0;
483
+ for (const one of this.blocks.values())
484
+ kept += one.length;
485
+ while (kept > BLOCKS_KEPT_BYTES && this.blocks.size > 1) {
486
+ const oldest = this.blocks.keys().next().value;
487
+ kept -= this.blocks.get(oldest).length;
488
+ this.blocks.delete(oldest);
489
+ }
490
+ if (entry.inner + entry.raw > block.length)
491
+ throw new Error("it reaches outside its block");
492
+ raw = Buffer.from(block.subarray(entry.inner, entry.inner + entry.raw));
493
+ }
494
+ else if (entry.kind === store_v2_js_1.KIND_WHOLE || entry.kind === store_v2_js_1.KIND_WHOLE_ZSTD) {
495
+ const record = await this.readRecord(entry);
496
+ raw = entry.kind === store_v2_js_1.KIND_WHOLE ? record : (0, store_v2_js_1.unzstd)(record);
497
+ }
498
+ else if (entry.kind === store_v2_js_1.KIND_DELTA || entry.kind === store_v2_js_1.KIND_DELTA_ZSTD) {
499
+ const record = await this.readRecord(entry);
500
+ const patch = entry.kind === store_v2_js_1.KIND_DELTA ? record : (0, store_v2_js_1.unzstd)(record);
501
+ raw = Buffer.from((0, delta_js_1.applyDeltaPatch)(await this.get(entry.base), patch, LOCAL_DELTA_MAX_BYTES));
502
+ }
503
+ else {
504
+ throw new Error("it is of a kind this cbx does not read");
505
+ }
506
+ }
507
+ catch (error) {
508
+ throw new Error(`Object ${digest.slice(0, 12)} could not be rebuilt: ${error instanceof Error ? error.message : String(error)}`);
509
+ }
510
+ if (digestOf(raw) !== digest) {
511
+ throw new Error(`Object ${digest.slice(0, 12)} is damaged: its bytes do not match its name`);
512
+ }
513
+ return raw;
514
+ }
165
515
  async writePack(objects) {
166
516
  await (0, promises_1.mkdir)(this.packDirectory, { recursive: true });
167
517
  const index = {};
@@ -189,14 +539,15 @@ class ObjectStore {
189
539
  const waiting = this.pending.get(digest) ?? this.writing.get(digest);
190
540
  if (waiting)
191
541
  return (0, cbx_js_1.openChunkFrame)(waiting, digest);
542
+ const held = this.pendingV2.get(digest) ?? this.writingV2.get(digest);
543
+ if (held)
544
+ return Buffer.from(held.raw);
192
545
  const located = (await this.packs()).get(digest);
546
+ const inV2 = !located ? this.v2Located.get(digest) : undefined;
547
+ if (inV2)
548
+ return this.getV2(digest, inV2);
193
549
  if (located) {
194
- let opening = this.handles.get(located.pack);
195
- if (!opening) {
196
- opening = (0, promises_1.open)(located.pack, "r");
197
- this.handles.set(located.pack, opening);
198
- }
199
- const handle = await opening;
550
+ const handle = await this.handleFor(located.pack);
200
551
  const frame = Buffer.alloc(located.length);
201
552
  const { bytesRead } = await handle.read(frame, 0, located.length, located.offset);
202
553
  if (bytesRead !== located.length) {
@@ -221,6 +572,7 @@ class ObjectStore {
221
572
  */
222
573
  rescan() {
223
574
  this.packed = null;
575
+ this.v2Located = null;
224
576
  }
225
577
  /** Close any packs held open for reading. Safe to call at any time. */
226
578
  async release() {
@@ -246,7 +598,7 @@ class ObjectStore {
246
598
  find them, which grew with the history: the same object either way,
247
599
  checked the same way.
248
600
  */
249
- if (!this.pending.has(digest) && !this.writing.has(digest)) {
601
+ if (!this.pending.has(digest) && !this.writing.has(digest) && !this.pendingV2.has(digest) && !this.writingV2.has(digest)) {
250
602
  let frame = null;
251
603
  try {
252
604
  frame = await (0, promises_1.readFile)(this.where(digest));
@@ -278,6 +630,10 @@ class ObjectStore {
278
630
  if (digest.startsWith(prefix))
279
631
  found.add(digest);
280
632
  }
633
+ for (const digest of this.v2Located.keys()) {
634
+ if (digest.startsWith(prefix))
635
+ found.add(digest);
636
+ }
281
637
  return [...found];
282
638
  }
283
639
  }