@amalgm/shell 0.1.134 → 0.1.135
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/content-direct.d.ts +21 -13
- package/dist/content-direct.js +70 -68
- package/dist/content-direct.js.map +1 -1
- package/dist/user-ground-host.d.ts +12 -7
- package/dist/user-ground-host.js +181 -73
- package/dist/user-ground-host.js.map +1 -1
- package/dist/wire-client.js +1 -1
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
package/dist/user-ground-host.js
CHANGED
|
@@ -38,8 +38,8 @@ const CARGO_SEGMENT_LOAD_CONCURRENCY = 8;
|
|
|
38
38
|
/** Storage-object lanes for one download set. Tiny objects use the wide
|
|
39
39
|
* request window, while the separate byte budget narrows large chunks so
|
|
40
40
|
* request concurrency can never silently become an unbounded memory claim. */
|
|
41
|
-
/** Admission is sized to the refs batch law so one
|
|
42
|
-
*
|
|
41
|
+
/** Admission is sized to the refs batch law so one planned wave resolves in
|
|
42
|
+
* one refs frame and the span planner merges each pack's members into a
|
|
43
43
|
* few broad ranged reads. Memory is bounded by the byte laws below, never by
|
|
44
44
|
* this count. */
|
|
45
45
|
const DIRECT_OBJECT_DOWNLOAD_CONCURRENCY = CONTENT_REFS_BATCH_OBJECTS;
|
|
@@ -685,7 +685,14 @@ export class UserGroundHost {
|
|
|
685
685
|
const fusionResourceId = this.cloudState.resourceId;
|
|
686
686
|
const fusionLegs = new TransferLegs();
|
|
687
687
|
const fusion = new RegisterUploadFusion({
|
|
688
|
-
|
|
688
|
+
// Fusion ships whole small artifacts into packs; skipping one
|
|
689
|
+
// requires it to be both sealed and packed, so sealed-but-
|
|
690
|
+
// standalone smalls re-ship here and placement converges on
|
|
691
|
+
// packs without a separate migration.
|
|
692
|
+
sealedContentHashes: async (hashes) => {
|
|
693
|
+
const inventory = await this.sealInventory(fusionResourceId, hashes);
|
|
694
|
+
return new Set([...inventory.sealed].filter((hash) => inventory.packed.has(hash)));
|
|
695
|
+
},
|
|
689
696
|
sendCargo: async (artifacts, payload) => {
|
|
690
697
|
const { sealed } = await this.putCargo(fusionResourceId, artifacts, payload, fusionLegs);
|
|
691
698
|
return sealed;
|
|
@@ -923,10 +930,11 @@ export class UserGroundHost {
|
|
|
923
930
|
throw new Error("entity Record rail already started");
|
|
924
931
|
await this.wire.open();
|
|
925
932
|
// One floor, no per-feature gates: this shell and its gateway release
|
|
926
|
-
// together, and the gateway ships first.
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
|
|
933
|
+
// together, and the gateway ships first. 16 = placement answers on the
|
|
934
|
+
// seal inventory; an older gateway cannot say which sealed artifacts are
|
|
935
|
+
// packed, and this shell plans pack-span downloads and repacks against
|
|
936
|
+
// exactly that answer.
|
|
937
|
+
if (this.wire.protocolVersion < 16) {
|
|
930
938
|
throw new Error("gateway speaks an older wire protocol than this shell");
|
|
931
939
|
}
|
|
932
940
|
this.adoptCloudDeliveryBaseline(identity, this.cloudState.deliveryWatermark);
|
|
@@ -1538,11 +1546,16 @@ export class UserGroundHost {
|
|
|
1538
1546
|
// The authority's seal inventory is the only permission to skip an
|
|
1539
1547
|
// upload — asked before any source is touched, so sealed artifacts cost
|
|
1540
1548
|
// zero local reads and a retried publication whose upload already sealed
|
|
1541
|
-
// completely never needs its captured ground to still exist.
|
|
1542
|
-
|
|
1549
|
+
// completely never needs its captured ground to still exist. Placement
|
|
1550
|
+
// rides the same answer: a sealed-but-unpacked small artifact is
|
|
1551
|
+
// correctness-complete but stored inefficiently, so this guarantor also
|
|
1552
|
+
// re-ships it into a pack when its source is still at hand (axiom 44).
|
|
1553
|
+
const inventory = await legs.time("planMs", () => this.sealInventory(resourceId, [...byHash.keys()]));
|
|
1543
1554
|
const unsealed = [...byHash.values()]
|
|
1544
|
-
.filter(({ artifact }) => !
|
|
1545
|
-
const
|
|
1555
|
+
.filter(({ artifact }) => !inventory.sealed.has(artifact.contentHash));
|
|
1556
|
+
const unpacked = [...byHash.values()].filter(({ artifact }) => inventory.sealed.has(artifact.contentHash)
|
|
1557
|
+
&& !inventory.packed.has(artifact.contentHash));
|
|
1558
|
+
const rows = legs.timeSync("planMs", () => readGroundRowsByUuid(this.databasePath(identity), [...unsealed, ...unpacked].map(({ uuid }) => uuid)));
|
|
1546
1559
|
// Each artifact uploads from the strongest evidence available: a cache
|
|
1547
1560
|
// copy when another journey pinned one, otherwise the ground entity
|
|
1548
1561
|
// itself. Register stages nothing, so the ground path is the common
|
|
@@ -1589,6 +1602,42 @@ export class UserGroundHost {
|
|
|
1589
1602
|
bytes,
|
|
1590
1603
|
};
|
|
1591
1604
|
}));
|
|
1605
|
+
// Repack candidates are sealed already, so their sources are optional:
|
|
1606
|
+
// anything missing, oversized, or unreadable skips silently — a frozen
|
|
1607
|
+
// workspace simply keeps standalone placement until its next actual
|
|
1608
|
+
// publication. What readers see never changes either way.
|
|
1609
|
+
const repackSources = legs.timeSync("planMs", () => unpacked.flatMap(({ artifact, uuid }) => {
|
|
1610
|
+
const cached = contentCacheFile(cache, artifact.contentHash);
|
|
1611
|
+
const fromCache = existsSync(cached);
|
|
1612
|
+
const row = rows.get(uuid);
|
|
1613
|
+
if (!fromCache && (artifact.kind === "git" || !row))
|
|
1614
|
+
return [];
|
|
1615
|
+
const file = fromCache ? cached : row.absolutePath;
|
|
1616
|
+
let linkTarget;
|
|
1617
|
+
let bytes;
|
|
1618
|
+
try {
|
|
1619
|
+
if (!fromCache && artifact.kind === "link") {
|
|
1620
|
+
linkTarget = Buffer.from(readlinkSync(file), "utf8");
|
|
1621
|
+
bytes = linkTarget.byteLength;
|
|
1622
|
+
}
|
|
1623
|
+
else {
|
|
1624
|
+
bytes = statSync(file).size;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
catch {
|
|
1628
|
+
return [];
|
|
1629
|
+
}
|
|
1630
|
+
if (bytes > CHUNK_BYTES)
|
|
1631
|
+
return [];
|
|
1632
|
+
return [{
|
|
1633
|
+
artifact,
|
|
1634
|
+
file,
|
|
1635
|
+
fromCache,
|
|
1636
|
+
...(linkTarget ? { linkTarget } : {}),
|
|
1637
|
+
bytes,
|
|
1638
|
+
repack: true,
|
|
1639
|
+
}];
|
|
1640
|
+
}));
|
|
1592
1641
|
const stale = (source, error) => {
|
|
1593
1642
|
if (source.fromCache) {
|
|
1594
1643
|
// A cache copy that no longer proves its hash is torn evidence:
|
|
@@ -1608,8 +1657,11 @@ export class UserGroundHost {
|
|
|
1608
1657
|
const small = sources.filter((source) => source.bytes <= CHUNK_BYTES);
|
|
1609
1658
|
const large = sources.filter((source) => source.bytes > CHUNK_BYTES);
|
|
1610
1659
|
let cargoSegments = 0;
|
|
1660
|
+
let repackedArtifacts = 0;
|
|
1661
|
+
let repackedBytes = 0;
|
|
1611
1662
|
const sendCargoSegment = async (segment) => {
|
|
1612
|
-
const loaded = new Array(segment.items.length);
|
|
1663
|
+
const loaded = new Array(segment.items.length).fill(null);
|
|
1664
|
+
const dropped = [];
|
|
1613
1665
|
await boundedForEach(segment.items.map((item, index) => ({ source: item.source, index })), CARGO_SEGMENT_LOAD_CONCURRENCY, async ({ source, index }) => {
|
|
1614
1666
|
try {
|
|
1615
1667
|
const bytes = source.linkTarget ?? await legs.time("loadMs", () => readFile(source.file));
|
|
@@ -1620,21 +1672,39 @@ export class UserGroundHost {
|
|
|
1620
1672
|
};
|
|
1621
1673
|
}
|
|
1622
1674
|
catch (error) {
|
|
1675
|
+
// A repack source that churned or vanished under this read is
|
|
1676
|
+
// dropped, never stale: the artifact stays sealed standalone,
|
|
1677
|
+
// and it is answered as sealed because it truthfully is.
|
|
1678
|
+
if (source.repack) {
|
|
1679
|
+
dropped.push(source.artifact.contentHash);
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1623
1682
|
if (sourceMismatch(error))
|
|
1624
1683
|
stale(source, error);
|
|
1625
1684
|
throw error;
|
|
1626
1685
|
}
|
|
1627
1686
|
});
|
|
1628
|
-
const
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1687
|
+
const shipping = segment.items.flatMap((item, index) => {
|
|
1688
|
+
const load = loaded[index];
|
|
1689
|
+
return load === null ? [] : [{ item, load }];
|
|
1690
|
+
});
|
|
1691
|
+
if (shipping.length === 0)
|
|
1692
|
+
return dropped;
|
|
1693
|
+
const { sealed, wireBytes } = await this.putCargo(resourceId, shipping.map(({ item, load }) => ({
|
|
1694
|
+
content_hash: item.contentHash,
|
|
1695
|
+
bytes: load.manifest.bytes,
|
|
1696
|
+
chunks: load.manifest.chunks.map((chunk) => ({ sha256: chunk.sha256, bytes: chunk.bytes })),
|
|
1697
|
+
})), Buffer.concat(shipping.map(({ load }) => load.bytes)), legs);
|
|
1698
|
+
for (const { item, load } of shipping) {
|
|
1699
|
+
uploadedChunks += load.manifest.chunks.length;
|
|
1700
|
+
if (item.source.repack) {
|
|
1701
|
+
repackedArtifacts += 1;
|
|
1702
|
+
repackedBytes += load.manifest.bytes;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1635
1705
|
uploadedBytes += wireBytes;
|
|
1636
1706
|
cargoSegments += 1;
|
|
1637
|
-
return sealed;
|
|
1707
|
+
return [...sealed, ...dropped];
|
|
1638
1708
|
};
|
|
1639
1709
|
const transfer = async (source) => {
|
|
1640
1710
|
const { artifact, file, manifest: knownManifest, bytes: fileBytes } = source;
|
|
@@ -1701,7 +1771,7 @@ export class UserGroundHost {
|
|
|
1701
1771
|
}
|
|
1702
1772
|
};
|
|
1703
1773
|
try {
|
|
1704
|
-
await streamCargo(small.map((source) => ({
|
|
1774
|
+
await streamCargo([...small, ...repackSources].map((source) => ({
|
|
1705
1775
|
contentHash: source.artifact.contentHash,
|
|
1706
1776
|
bytes: source.bytes,
|
|
1707
1777
|
source,
|
|
@@ -1728,6 +1798,12 @@ export class UserGroundHost {
|
|
|
1728
1798
|
cargoBytes: small.reduce((total, source) => total + source.bytes, 0),
|
|
1729
1799
|
largeArtifacts: large.length,
|
|
1730
1800
|
largeBytes: large.reduce((total, source) => total + source.bytes, 0),
|
|
1801
|
+
// Placement work: sealed-but-standalone smalls re-shipped into
|
|
1802
|
+
// packs by this guarantor. Candidates counts what the inventory
|
|
1803
|
+
// named; repacked counts what actually shipped this pass.
|
|
1804
|
+
repackCandidates: unpacked.length,
|
|
1805
|
+
repackedArtifacts,
|
|
1806
|
+
repackedBytes,
|
|
1731
1807
|
uploadedBytes,
|
|
1732
1808
|
loadedBytes,
|
|
1733
1809
|
uploadedChunks,
|
|
@@ -1757,10 +1833,11 @@ export class UserGroundHost {
|
|
|
1757
1833
|
}
|
|
1758
1834
|
}
|
|
1759
1835
|
/** Download a set of immutable artifacts straight from content storage.
|
|
1760
|
-
* The set is the primitive
|
|
1761
|
-
*
|
|
1762
|
-
*
|
|
1763
|
-
*
|
|
1836
|
+
* The set is the primitive and the plan is physical: the engine resolves
|
|
1837
|
+
* the whole want-list in two planned waves — manifests grouped by the
|
|
1838
|
+
* pack that holds them, then blocks the same way — so all members of one
|
|
1839
|
+
* pack collapse into a few broad ranged reads while standalone objects
|
|
1840
|
+
* stay one read each. A lone artifact is a set of one. */
|
|
1764
1841
|
async downloadContentSet(resourceId, cacheDir, wants, retryAttempts = 10, trace, staged) {
|
|
1765
1842
|
const unique = new Map(wants.map((want) => [want.artifact.contentHash, want]));
|
|
1766
1843
|
const sole = unique.size === 1 ? [...unique.values()][0]?.artifact : undefined;
|
|
@@ -1801,56 +1878,75 @@ export class UserGroundHost {
|
|
|
1801
1878
|
throw new Error("download cache has no content manifest");
|
|
1802
1879
|
return found;
|
|
1803
1880
|
};
|
|
1804
|
-
//
|
|
1805
|
-
//
|
|
1806
|
-
//
|
|
1807
|
-
//
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1881
|
+
// An Add stages ordinary files directly at their hidden final
|
|
1882
|
+
// destination — except content this machine already holds sealed,
|
|
1883
|
+
// which stays on the cache rail so a warm re-Add remains a local
|
|
1884
|
+
// clone instead of a fresh download.
|
|
1885
|
+
const route = (artifact, manifest) => {
|
|
1886
|
+
const stagedPaths = stagedSet && staged?.destinations.get(artifact.contentHash);
|
|
1887
|
+
if (stagedPaths && !hasSealedContent(cacheDir, artifact.contentHash, manifest.bytes)) {
|
|
1888
|
+
stagedArtifacts += 1;
|
|
1889
|
+
targets.set(artifact.contentHash, stagedSet.target(artifact, manifest, stagedPaths));
|
|
1890
|
+
}
|
|
1891
|
+
else {
|
|
1892
|
+
targets.set(artifact.contentHash, downloads.target(artifact, manifest));
|
|
1893
|
+
}
|
|
1814
1894
|
};
|
|
1815
1895
|
try {
|
|
1816
|
-
|
|
1896
|
+
// Every storage object — manifests and blocks alike — is fetched
|
|
1897
|
+
// straight from content storage over a presigned URL and opened
|
|
1898
|
+
// locally; the engine plans whole waves against these primitives, so
|
|
1899
|
+
// the wire carries only the key handoff and batched refs requests. A
|
|
1900
|
+
// retried read refreshes its URL but never its geometry: placement is
|
|
1901
|
+
// immutable, and a moved answer fails loudly instead of retrying.
|
|
1902
|
+
const { receipts, shape } = await downloadAllArtifacts([...unique.values()].map((want) => want.artifact), {
|
|
1817
1903
|
sha256Hex: timedSha256Hex,
|
|
1818
|
-
|
|
1904
|
+
localManifest: (artifact) => legs.timeSync("planMs", () => {
|
|
1819
1905
|
// The record's plan or this machine's own manifest cache already
|
|
1820
1906
|
// proves chunk identity; downloaded bytes are still hash-verified,
|
|
1821
1907
|
// so a stale local manifest can only fail loudly, never corrupt.
|
|
1822
|
-
const local =
|
|
1823
|
-
?? readCachedManifest(cacheDir, artifact.contentHash)
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1908
|
+
const local = unique.get(artifact.contentHash)?.manifest
|
|
1909
|
+
?? readCachedManifest(cacheDir, artifact.contentHash);
|
|
1910
|
+
if (local)
|
|
1911
|
+
route(artifact, local);
|
|
1912
|
+
return local ?? null;
|
|
1913
|
+
}),
|
|
1914
|
+
requestRefs: async (objects) => legs.time("fetchMs", () => retryContent(() => this.direct.requestRefs(resourceId, objects), true, retryAttempts)),
|
|
1915
|
+
fetchSpan: async (url, span, refresh) => legs.time("fetchMs", async () => {
|
|
1916
|
+
let attempt = 0;
|
|
1917
|
+
return retryContent(async () => {
|
|
1918
|
+
let signed = url;
|
|
1919
|
+
if (attempt > 0) {
|
|
1920
|
+
try {
|
|
1921
|
+
signed = await refresh();
|
|
1922
|
+
}
|
|
1923
|
+
catch (error) {
|
|
1924
|
+
// A moved placement (or a refused re-resolution) is a
|
|
1925
|
+
// planning fact, not a transient fetch fault — surface it
|
|
1926
|
+
// without burning the remaining attempts.
|
|
1927
|
+
throw new WireRequestError({
|
|
1928
|
+
type: "shared.error",
|
|
1929
|
+
code: "entity_content_moved",
|
|
1930
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1931
|
+
status: 409,
|
|
1932
|
+
retryable: false,
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1843
1935
|
}
|
|
1844
|
-
|
|
1845
|
-
|
|
1936
|
+
attempt += 1;
|
|
1937
|
+
return this.direct.fetchSpan(signed, span);
|
|
1938
|
+
}, true, retryAttempts);
|
|
1939
|
+
}),
|
|
1940
|
+
openEnvelope: async (_object, sealed) => {
|
|
1941
|
+
requests += 1;
|
|
1942
|
+
const plain = await legs.time("fetchMs", () => this.direct.openEnvelope(resourceId, sealed));
|
|
1943
|
+
downloadedBytes += plain.byteLength;
|
|
1944
|
+
return plain;
|
|
1846
1945
|
},
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
const chunk = manifest.chunks[index];
|
|
1850
|
-
if (!chunk)
|
|
1851
|
-
throw new Error(`content manifest has no chunk ${index}`);
|
|
1852
|
-
return fetchObject({ kind: "block", sha256: chunk.sha256 });
|
|
1946
|
+
manifestProven: async (artifact, manifest) => {
|
|
1947
|
+
legs.timeSync("planMs", () => route(artifact, manifest));
|
|
1853
1948
|
},
|
|
1949
|
+
hasChunk: async (artifact, _manifest, index) => legs.time("reuseMs", () => target(artifact).hasChunk(index)),
|
|
1854
1950
|
putChunk: async (artifact, _manifest, index, bytes) => legs.time("writeMs", () => target(artifact).putChunk(index, bytes)),
|
|
1855
1951
|
sealArtifact: async (artifact) => {
|
|
1856
1952
|
const sealed = await legs.time("sealMs", () => target(artifact).seal());
|
|
@@ -1891,6 +1987,10 @@ export class UserGroundHost {
|
|
|
1891
1987
|
// nothing in flight. The staged rail decomposes its own write and
|
|
1892
1988
|
// seal internals below; `stagedArtifacts` counts its placements.
|
|
1893
1989
|
requests,
|
|
1990
|
+
// The engine's own geometry: how manifests were known (local vs
|
|
1991
|
+
// fetched, packed vs standalone) and how many planned pack spans
|
|
1992
|
+
// carried the packed objects.
|
|
1993
|
+
...shape,
|
|
1894
1994
|
...physicalPlaneDelta(planeBefore, this.direct.metrics()),
|
|
1895
1995
|
...legs.report(),
|
|
1896
1996
|
...(stagedSet ? stagedSet.report() : {}),
|
|
@@ -2471,27 +2571,35 @@ export class UserGroundHost {
|
|
|
2471
2571
|
});
|
|
2472
2572
|
}
|
|
2473
2573
|
/** Which of these heads does the authority hold sealed under the current
|
|
2474
|
-
* content contract
|
|
2475
|
-
*
|
|
2476
|
-
|
|
2574
|
+
* content contract — and which of the sealed ones are packed, their whole
|
|
2575
|
+
* closure inside immutable packs? Batched so a whole workspace costs a
|
|
2576
|
+
* handful of frames. `sealed` is the only permission to skip an upload
|
|
2577
|
+
* for correctness; `packed` is placement knowledge only. The packed
|
|
2578
|
+
* answer is a protocol 16 capability and is required: a missing field
|
|
2579
|
+
* means an older gateway, and this machine refuses to guess placement. */
|
|
2580
|
+
async sealInventory(resourceId, hashes) {
|
|
2477
2581
|
const batches = [];
|
|
2478
2582
|
for (let start = 0; start < hashes.length; start += CONTENT_REFS_BATCH_OBJECTS) {
|
|
2479
2583
|
batches.push(hashes.slice(start, start + CONTENT_REFS_BATCH_OBJECTS));
|
|
2480
2584
|
}
|
|
2481
2585
|
const sealed = new Set();
|
|
2586
|
+
const packed = new Set();
|
|
2482
2587
|
const answers = await Promise.all(batches.map((batch) => retryContent(() => this.wire.request({
|
|
2483
2588
|
type: "private.entity-content.sealed",
|
|
2484
2589
|
resource_id: resourceId,
|
|
2485
2590
|
content_hashes: batch,
|
|
2486
2591
|
}, ["private.entity-content.sealed-result"]))));
|
|
2487
2592
|
for (const frame of answers) {
|
|
2488
|
-
if (!Array.isArray(frame.sealed)) {
|
|
2489
|
-
throw new Error("content authority answered
|
|
2593
|
+
if (!Array.isArray(frame.sealed) || !Array.isArray(frame.packed)) {
|
|
2594
|
+
throw new Error("content authority answered a seal inventory without "
|
|
2595
|
+
+ "placement; protocol 16 requires the gateway to deploy before this machine");
|
|
2490
2596
|
}
|
|
2491
2597
|
for (const hash of frame.sealed)
|
|
2492
2598
|
sealed.add(String(hash));
|
|
2599
|
+
for (const hash of frame.packed)
|
|
2600
|
+
packed.add(String(hash));
|
|
2493
2601
|
}
|
|
2494
|
-
return sealed;
|
|
2602
|
+
return { sealed, packed };
|
|
2495
2603
|
}
|
|
2496
2604
|
async publishPendingSnapshots(identity, trace, manifestHints) {
|
|
2497
2605
|
for (const pending of readSnapshotPublications(this.databasePath(identity))) {
|
|
@@ -2512,7 +2620,7 @@ export class UserGroundHost {
|
|
|
2512
2620
|
return;
|
|
2513
2621
|
const started = performance.now();
|
|
2514
2622
|
const legs = new TransferLegs();
|
|
2515
|
-
const sealed = await legs.time("planMs", () => this.
|
|
2623
|
+
const { sealed } = await legs.time("planMs", () => this.sealInventory(resourceId, pages.map((page) => page.sha256)));
|
|
2516
2624
|
const unsealed = pages
|
|
2517
2625
|
.filter((page) => !sealed.has(page.sha256))
|
|
2518
2626
|
.map((page) => ({ contentHash: page.sha256, bytes: page.bytes.byteLength, page }));
|