@peerbit/blocks 4.2.16 → 4.3.1

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.
@@ -0,0 +1,1101 @@
1
+ import type {
2
+ AnyStore,
3
+ CrashSafeAtomicReplaceDurability,
4
+ CrashSafeDurability,
5
+ } from "@peerbit/any-store-interface";
6
+ import {
7
+ type ScopedBlockReclamationFaultCode,
8
+ type ScopedBlockReclamationHealth,
9
+ type ScopedBlockReclamationLimits,
10
+ type ScopedBlockReclamationScopeV1,
11
+ type ScopedBlockReclamationV1,
12
+ type ScopedBlockReleaseResult,
13
+ calculateRawCid,
14
+ cidifyString,
15
+ stringifyCid,
16
+ verifyBlockBytes,
17
+ } from "@peerbit/blocks-interface";
18
+ import { createSHA256 } from "@peerbit/crypto";
19
+ import { AnyBlockStore } from "./any-blockstore.js";
20
+
21
+ const textEncoder = new TextEncoder();
22
+ const NAMESPACE = "peerbit-scoped-reclamation-v1";
23
+ const ROOT_SUBLEVEL_PREFIX = `!${NAMESPACE}!`;
24
+ const DATA_LEVEL = "data";
25
+ const REFERENCE_LEVEL = "references";
26
+ const RECORD_MAGIC = new Uint8Array([
27
+ 0x50, 0x42, 0x53, 0x52, 0x45, 0x46, 0x31, 0,
28
+ ]);
29
+ const RECORD_VERSION = 1;
30
+ const GENERATION_OFFSET = 12;
31
+ const COUNT_OFFSET = 20;
32
+ const RECORD_PREFIX_BYTES = 24;
33
+ const DIGEST_BYTES = 32;
34
+ const CHECKSUM_BYTES = 32;
35
+ const MAX_GENERATION = (1n << 64n) - 1n;
36
+ // Raw Blocks accepted arbitrary valid CID encodings before this opt-in wrapper
37
+ // existed. Keep pathological-but-valid CIDs usable while placing a generous,
38
+ // private ceiling on parser work. Managed CIDs retain their tighter public cap.
39
+ const MAX_RAW_BLOCK_CID_BYTES = 64 * 1024;
40
+ const SCOPE_DOMAIN = textEncoder.encode("peerbit:blocks:scope:v1\0");
41
+ const RECORD_DOMAIN = textEncoder.encode("peerbit:blocks:references:v1\0");
42
+ const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype);
43
+ const typedArrayByteLength = Object.getOwnPropertyDescriptor(
44
+ typedArrayPrototype,
45
+ "byteLength",
46
+ )!.get!;
47
+ const typedArrayBuffer = Object.getOwnPropertyDescriptor(
48
+ typedArrayPrototype,
49
+ "buffer",
50
+ )!.get!;
51
+ const typedArrayTag = Object.getOwnPropertyDescriptor(
52
+ typedArrayPrototype,
53
+ Symbol.toStringTag,
54
+ )!.get!;
55
+ const typedArraySet = Uint8Array.prototype.set;
56
+ const SCOPED_BLOCK_RECLAMATION_LIMITS: ScopedBlockReclamationLimits =
57
+ Object.freeze({
58
+ maxBlockBytes: 16 * 1024 * 1024,
59
+ maxCidBytes: 512,
60
+ scopeKeyBytes: 32,
61
+ maxReferencesPerBlock: 1_024,
62
+ maxPendingOperations: 1_024,
63
+ maxPendingBytes: 64 * 1024 * 1024,
64
+ });
65
+
66
+ const OPENING_HEALTH: ScopedBlockReclamationHealth = Object.freeze({
67
+ status: "opening",
68
+ });
69
+ const READY_HEALTH: ScopedBlockReclamationHealth = Object.freeze({
70
+ status: "ready",
71
+ });
72
+ const CLOSED_HEALTH: ScopedBlockReclamationHealth = Object.freeze({
73
+ status: "closed",
74
+ });
75
+
76
+ const isAtomicDurability = (
77
+ value: CrashSafeDurability | undefined,
78
+ ): value is CrashSafeAtomicReplaceDurability =>
79
+ value?.crashSafe === true &&
80
+ typeof value.barrier === "function" &&
81
+ typeof value.atomicReplace === "function";
82
+
83
+ const equalBytes = (left: Uint8Array, right: Uint8Array): boolean => {
84
+ if (left.byteLength !== right.byteLength) return false;
85
+ let difference = 0;
86
+ for (let i = 0; i < left.byteLength; i++) difference |= left[i] ^ right[i];
87
+ return difference === 0;
88
+ };
89
+
90
+ const compareBytes = (left: Uint8Array, right: Uint8Array): number => {
91
+ for (let i = 0; i < left.byteLength; i++) {
92
+ if (left[i] !== right[i]) return left[i]! - right[i]!;
93
+ }
94
+ return left.byteLength - right.byteLength;
95
+ };
96
+
97
+ const hash = (...values: Uint8Array[]): Uint8Array => {
98
+ const hasher = createSHA256();
99
+ for (const value of values) hasher.update(value);
100
+ return new Uint8Array(hasher.digest());
101
+ };
102
+
103
+ const exactByteLength = (value: Uint8Array, name: string): number => {
104
+ let byteLength: number;
105
+ let tag: string | undefined;
106
+ try {
107
+ byteLength = typedArrayByteLength.call(value);
108
+ tag = typedArrayTag.call(value);
109
+ } catch {
110
+ throw new TypeError(`${name} must be a genuine Uint8Array`);
111
+ }
112
+ if (tag !== "Uint8Array") throw new TypeError(`${name} must be a Uint8Array`);
113
+ if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
114
+ throw new RangeError(`${name} has an invalid byte length`);
115
+ }
116
+ return byteLength;
117
+ };
118
+
119
+ const exactCopy = (
120
+ value: Uint8Array,
121
+ name: string,
122
+ maximumBytes: number,
123
+ allowEmpty: boolean,
124
+ ): Uint8Array => {
125
+ const byteLength = exactByteLength(value, name);
126
+ let buffer: ArrayBufferLike;
127
+ try {
128
+ buffer = typedArrayBuffer.call(value);
129
+ } catch {
130
+ throw new TypeError(`${name} must be an attached Uint8Array`);
131
+ }
132
+ if (
133
+ typeof SharedArrayBuffer !== "undefined" &&
134
+ buffer instanceof SharedArrayBuffer
135
+ ) {
136
+ throw new TypeError(`${name} may not use shared memory`);
137
+ }
138
+ if ((!allowEmpty && byteLength === 0) || byteLength > maximumBytes) {
139
+ throw new RangeError(
140
+ allowEmpty
141
+ ? `${name} must contain at most ${maximumBytes} bytes`
142
+ : `${name} must contain exactly ${maximumBytes} bytes`,
143
+ );
144
+ }
145
+ const copy = new Uint8Array(byteLength);
146
+ try {
147
+ typedArraySet.call(copy, value);
148
+ return copy;
149
+ } catch {
150
+ throw new TypeError(`${name} must be an attached Uint8Array`);
151
+ }
152
+ };
153
+
154
+ const copyScopeKey = (value: Uint8Array): Uint8Array => {
155
+ const copy = exactCopy(
156
+ value,
157
+ "Scoped block reclamation scope key",
158
+ SCOPED_BLOCK_RECLAMATION_LIMITS.scopeKeyBytes,
159
+ false,
160
+ );
161
+ if (copy.byteLength !== SCOPED_BLOCK_RECLAMATION_LIMITS.scopeKeyBytes) {
162
+ throw new RangeError(
163
+ `Scoped block reclamation scope key must contain exactly ${SCOPED_BLOCK_RECLAMATION_LIMITS.scopeKeyBytes} bytes`,
164
+ );
165
+ }
166
+ return copy;
167
+ };
168
+
169
+ const encodeBoundedCidText = (
170
+ value: string,
171
+ name: "Raw block CID" | "Scoped block CID",
172
+ maximumBytes: number,
173
+ ): Uint8Array => {
174
+ if (typeof value !== "string" || value.length === 0) {
175
+ throw new TypeError(`${name} must be a non-empty string`);
176
+ }
177
+ // A CID is ASCII, so the code-unit check rejects oversized valid inputs
178
+ // without asking the parser or TextEncoder to process attacker-sized text.
179
+ if (value.length > maximumBytes) {
180
+ throw new RangeError(`${name} exceeds ${maximumBytes} bytes`);
181
+ }
182
+ const bytes = textEncoder.encode(value);
183
+ if (bytes.byteLength > maximumBytes) {
184
+ throw new RangeError(`${name} exceeds ${maximumBytes} bytes`);
185
+ }
186
+ return bytes;
187
+ };
188
+
189
+ const validateCidInput = (value: string): void => {
190
+ encodeBoundedCidText(
191
+ value,
192
+ "Scoped block CID",
193
+ SCOPED_BLOCK_RECLAMATION_LIMITS.maxCidBytes,
194
+ );
195
+ };
196
+
197
+ // ClassicLevel's root view can address the encoded keys of its sublevels. The
198
+ // raw Blocks surface is therefore a CID-only authority boundary, not merely a
199
+ // convenience validation: invalid keys must never reach the root store.
200
+ const isRawBlockCid = (value: unknown): value is string => {
201
+ if (typeof value !== "string" || value.length === 0) return false;
202
+ if (value.startsWith(ROOT_SUBLEVEL_PREFIX)) return false;
203
+ try {
204
+ encodeBoundedCidText(value, "Raw block CID", MAX_RAW_BLOCK_CID_BYTES);
205
+ const parsed = cidifyString(value);
206
+ encodeBoundedCidText(
207
+ stringifyCid(parsed),
208
+ "Raw block CID",
209
+ MAX_RAW_BLOCK_CID_BYTES,
210
+ );
211
+ return true;
212
+ } catch {
213
+ return false;
214
+ }
215
+ };
216
+
217
+ const requireRawBlockCid: (value: unknown) => asserts value is string = (
218
+ value,
219
+ ) => {
220
+ if (typeof value !== "string" || value.length === 0) {
221
+ throw new TypeError("Raw block key must be a valid CID string");
222
+ }
223
+ if (value.startsWith(ROOT_SUBLEVEL_PREFIX)) {
224
+ throw new TypeError("Raw block key must be a valid CID string");
225
+ }
226
+ encodeBoundedCidText(value, "Raw block CID", MAX_RAW_BLOCK_CID_BYTES);
227
+ let parsed: ReturnType<typeof cidifyString>;
228
+ try {
229
+ parsed = cidifyString(value);
230
+ } catch {
231
+ throw new TypeError("Raw block key must be a valid CID string");
232
+ }
233
+ encodeBoundedCidText(
234
+ stringifyCid(parsed),
235
+ "Raw block CID",
236
+ MAX_RAW_BLOCK_CID_BYTES,
237
+ );
238
+ };
239
+
240
+ const canonicalCid = (value: string): { cid: string; bytes: Uint8Array } => {
241
+ validateCidInput(value);
242
+ const parsed = cidifyString(value);
243
+ const cid = stringifyCid(parsed);
244
+ const bytes = encodeBoundedCidText(
245
+ cid,
246
+ "Scoped block CID",
247
+ SCOPED_BLOCK_RECLAMATION_LIMITS.maxCidBytes,
248
+ );
249
+ return { cid, bytes };
250
+ };
251
+
252
+ type ReferenceState = Readonly<{
253
+ generation: bigint;
254
+ references: readonly Uint8Array[];
255
+ }>;
256
+
257
+ type ManagedIteratorState = {
258
+ readonly iterator: AsyncIterator<[string, Uint8Array], void, void>;
259
+ closing?: Promise<void>;
260
+ };
261
+
262
+ const encodeReferenceState = (
263
+ cidBytes: Uint8Array,
264
+ state: ReferenceState,
265
+ ): Uint8Array => {
266
+ const bodyLength =
267
+ RECORD_PREFIX_BYTES + state.references.length * DIGEST_BYTES;
268
+ const bytes = new Uint8Array(bodyLength + CHECKSUM_BYTES);
269
+ const view = new DataView(bytes.buffer);
270
+ bytes.set(RECORD_MAGIC, 0);
271
+ bytes[8] = RECORD_VERSION;
272
+ view.setBigUint64(GENERATION_OFFSET, state.generation, true);
273
+ view.setUint32(COUNT_OFFSET, state.references.length, true);
274
+ for (let i = 0; i < state.references.length; i++) {
275
+ bytes.set(state.references[i]!, RECORD_PREFIX_BYTES + i * DIGEST_BYTES);
276
+ }
277
+ bytes.set(
278
+ hash(RECORD_DOMAIN, cidBytes, bytes.subarray(0, bodyLength)),
279
+ bodyLength,
280
+ );
281
+ return bytes;
282
+ };
283
+
284
+ const decodeReferenceState = (
285
+ cidBytes: Uint8Array,
286
+ input: Uint8Array,
287
+ ): ReferenceState => {
288
+ const corrupt = (detail: string): never => {
289
+ throw new ScopedBlockReclamationCorruptionError(
290
+ `Scoped block reference state ${detail}`,
291
+ );
292
+ };
293
+ const maxBytes =
294
+ RECORD_PREFIX_BYTES +
295
+ SCOPED_BLOCK_RECLAMATION_LIMITS.maxReferencesPerBlock * DIGEST_BYTES +
296
+ CHECKSUM_BYTES;
297
+ let bytes: Uint8Array;
298
+ try {
299
+ bytes = exactCopy(input, "Scoped block reference state", maxBytes, true);
300
+ } catch (error) {
301
+ corrupt(
302
+ `is invalid: ${error instanceof Error ? error.message : String(error)}`,
303
+ );
304
+ }
305
+ if (bytes.byteLength < RECORD_PREFIX_BYTES + CHECKSUM_BYTES) {
306
+ corrupt("is truncated");
307
+ }
308
+ if (!equalBytes(bytes.subarray(0, RECORD_MAGIC.byteLength), RECORD_MAGIC)) {
309
+ corrupt("has an invalid format marker");
310
+ }
311
+ if (bytes[8] !== RECORD_VERSION) corrupt("has an unsupported version");
312
+ if (bytes[9] !== 0 || bytes[10] !== 0 || bytes[11] !== 0) {
313
+ corrupt("has non-zero reserved bytes");
314
+ }
315
+ const view = new DataView(bytes.buffer);
316
+ const generation = view.getBigUint64(GENERATION_OFFSET, true);
317
+ if (generation === 0n) corrupt("has generation zero");
318
+ const count = view.getUint32(COUNT_OFFSET, true);
319
+ if (count > SCOPED_BLOCK_RECLAMATION_LIMITS.maxReferencesPerBlock) {
320
+ corrupt("contains too many references");
321
+ }
322
+ const bodyLength = RECORD_PREFIX_BYTES + count * DIGEST_BYTES;
323
+ if (bytes.byteLength !== bodyLength + CHECKSUM_BYTES) {
324
+ corrupt("has an invalid encoded length");
325
+ }
326
+ const expectedChecksum = hash(
327
+ RECORD_DOMAIN,
328
+ cidBytes,
329
+ bytes.subarray(0, bodyLength),
330
+ );
331
+ if (!equalBytes(bytes.subarray(bodyLength), expectedChecksum)) {
332
+ corrupt("checksum does not match");
333
+ }
334
+ const references: Uint8Array[] = [];
335
+ for (let i = 0; i < count; i++) {
336
+ const reference = bytes.slice(
337
+ RECORD_PREFIX_BYTES + i * DIGEST_BYTES,
338
+ RECORD_PREFIX_BYTES + (i + 1) * DIGEST_BYTES,
339
+ );
340
+ if (
341
+ references.length > 0 &&
342
+ compareBytes(references[references.length - 1]!, reference) >= 0
343
+ ) {
344
+ corrupt("references are not strictly ordered");
345
+ }
346
+ references.push(reference);
347
+ }
348
+ return { generation, references };
349
+ };
350
+
351
+ class ScopedBlockReclamationCorruptionError extends Error {
352
+ constructor(message: string) {
353
+ super(message);
354
+ this.name = "ScopedBlockReclamationCorruptionError";
355
+ }
356
+ }
357
+
358
+ class ScopedBlockReclamationFaultError extends Error {
359
+ readonly cause: unknown;
360
+ readonly reason: ScopedBlockReclamationFaultCode;
361
+
362
+ constructor(reason: ScopedBlockReclamationFaultCode, cause: unknown) {
363
+ super(`Scoped block reclamation faulted: ${reason}`);
364
+ this.name = "ScopedBlockReclamationFaultError";
365
+ this.reason = reason;
366
+ this.cause = cause;
367
+ }
368
+ }
369
+
370
+ class ScopedBlockReclamationLifecycleError extends Error {
371
+ constructor() {
372
+ super(
373
+ "Scoped block reclamation handle is not valid for this service lifecycle",
374
+ );
375
+ this.name = "ScopedBlockReclamationLifecycleError";
376
+ }
377
+ }
378
+
379
+ class ScopedBlockReclamationController implements ScopedBlockReclamationV1 {
380
+ declare readonly kind: "scoped-references-v1";
381
+ declare readonly limits: ScopedBlockReclamationLimits;
382
+ private currentHealth: ScopedBlockReclamationHealth = OPENING_HEALTH;
383
+ private dataStore?: AnyStore;
384
+ private referenceStore?: AnyStore;
385
+ private dataDurability?: CrashSafeDurability;
386
+ private referenceDurability?: CrashSafeAtomicReplaceDurability;
387
+ private lifecycleGeneration = 0;
388
+ private tail: Promise<void> = Promise.resolve();
389
+ private pendingOperations = 0;
390
+ private pendingBytes = 0;
391
+ private activeReadLeases = 0;
392
+ private readonly readDrainWaiters = new Set<() => void>();
393
+ private readonly managedIterators = new Set<ManagedIteratorState>();
394
+
395
+ constructor(private readonly rootStore: AnyStore) {
396
+ Object.defineProperties(this, {
397
+ kind: {
398
+ value: "scoped-references-v1",
399
+ enumerable: true,
400
+ writable: false,
401
+ configurable: false,
402
+ },
403
+ limits: {
404
+ value: SCOPED_BLOCK_RECLAMATION_LIMITS,
405
+ enumerable: true,
406
+ writable: false,
407
+ configurable: false,
408
+ },
409
+ });
410
+ }
411
+
412
+ health(): ScopedBlockReclamationHealth {
413
+ return this.currentHealth;
414
+ }
415
+
416
+ async start(): Promise<void> {
417
+ this.currentHealth = OPENING_HEALTH;
418
+ try {
419
+ if ((await this.rootStore.persisted()) !== true) {
420
+ throw new Error("Backing store is not persistent");
421
+ }
422
+ const namespace = await this.rootStore.sublevel(NAMESPACE);
423
+ const dataStore = await namespace.sublevel(DATA_LEVEL);
424
+ const referenceStore = await namespace.sublevel(REFERENCE_LEVEL);
425
+ const dataDurability = dataStore.crashSafeDurability;
426
+ const referenceDurability = referenceStore.crashSafeDurability;
427
+ if (
428
+ dataDurability?.crashSafe !== true ||
429
+ typeof dataDurability.barrier !== "function" ||
430
+ !isAtomicDurability(referenceDurability)
431
+ ) {
432
+ throw new Error("Backing sublevels lack crash-safe durability");
433
+ }
434
+ await dataDurability.barrier();
435
+ await referenceDurability.barrier();
436
+ this.dataStore = dataStore;
437
+ this.referenceStore = referenceStore;
438
+ this.dataDurability = dataDurability;
439
+ this.referenceDurability = referenceDurability;
440
+ this.lifecycleGeneration += 1;
441
+ this.currentHealth = READY_HEALTH;
442
+ } catch (error) {
443
+ this.fault("storage-failure", error);
444
+ }
445
+ }
446
+
447
+ async stop(): Promise<void> {
448
+ this.currentHealth = CLOSED_HEALTH;
449
+ await this.tail;
450
+ await this.waitForReadLeases();
451
+ await Promise.all(
452
+ [...this.managedIterators].map((state) =>
453
+ this.closeManagedIterator(state),
454
+ ),
455
+ );
456
+ this.dataStore = undefined;
457
+ this.referenceStore = undefined;
458
+ this.dataDurability = undefined;
459
+ this.referenceDurability = undefined;
460
+ }
461
+
462
+ failStartup(cause: unknown): never {
463
+ this.fault("storage-failure", cause);
464
+ }
465
+
466
+ openScope(scopeKey: Uint8Array): ScopedBlockReclamationScopeV1 {
467
+ this.requireReady(this.lifecycleGeneration);
468
+ const key = copyScopeKey(scopeKey);
469
+ const digest = hash(SCOPE_DOMAIN, key);
470
+ const generation = this.lifecycleGeneration;
471
+ return Object.freeze({
472
+ put: (bytes: Uint8Array) => this.put(generation, digest, bytes),
473
+ retain: (cid: string, bytes: Uint8Array) =>
474
+ this.retain(generation, digest, cid, bytes),
475
+ release: (cid: string) => this.release(generation, digest, cid),
476
+ });
477
+ }
478
+
479
+ private put(
480
+ generation: number,
481
+ scopeDigest: Uint8Array,
482
+ input: Uint8Array,
483
+ ): Promise<string> {
484
+ return this.enqueueWithBytes(generation, input, async (bytes) => {
485
+ const cid = (await calculateRawCid(bytes)).cid;
486
+ return this.retainPrepared(scopeDigest, cid, bytes);
487
+ });
488
+ }
489
+
490
+ private retain(
491
+ generation: number,
492
+ scopeDigest: Uint8Array,
493
+ inputCid: string,
494
+ input: Uint8Array,
495
+ ): Promise<string> {
496
+ let cid: string;
497
+ try {
498
+ cid = canonicalCid(inputCid).cid;
499
+ } catch (error) {
500
+ return Promise.reject(error);
501
+ }
502
+ return this.enqueueWithBytes(generation, input, async (bytes) => {
503
+ await verifyBlockBytes(cid, bytes);
504
+ return this.retainPrepared(scopeDigest, cid, bytes);
505
+ });
506
+ }
507
+
508
+ private async retainPrepared(
509
+ scopeDigest: Uint8Array,
510
+ cid: string,
511
+ bytes: Uint8Array,
512
+ ): Promise<string> {
513
+ const cidBytes = textEncoder.encode(cid);
514
+ const state = await this.readReferenceState(cid, cidBytes);
515
+ const stored = await this.readManagedBytes(cid);
516
+ if (state && state.references.length > 0 && stored === undefined) {
517
+ this.fault(
518
+ "corrupt-state",
519
+ new ScopedBlockReclamationCorruptionError(
520
+ "Scoped block reference state points to missing bytes",
521
+ ),
522
+ );
523
+ }
524
+ if (stored !== undefined) {
525
+ await this.verifyManagedBytes(cid, stored);
526
+ if (!equalBytes(stored, bytes)) {
527
+ this.fault(
528
+ "corrupt-state",
529
+ new ScopedBlockReclamationCorruptionError(
530
+ "Scoped managed block bytes disagree with retained bytes",
531
+ ),
532
+ );
533
+ }
534
+ } else {
535
+ await this.mutate(async () => {
536
+ await this.dataStore!.put(cid, bytes);
537
+ await this.dataDurability!.barrier();
538
+ });
539
+ }
540
+
541
+ const references = [...(state?.references ?? [])];
542
+ if (references.some((reference) => equalBytes(reference, scopeDigest))) {
543
+ return cid;
544
+ }
545
+ if (
546
+ references.length >= SCOPED_BLOCK_RECLAMATION_LIMITS.maxReferencesPerBlock
547
+ ) {
548
+ throw new RangeError(
549
+ `Scoped block reference count exceeds ${SCOPED_BLOCK_RECLAMATION_LIMITS.maxReferencesPerBlock}`,
550
+ );
551
+ }
552
+ references.push(scopeDigest);
553
+ references.sort(compareBytes);
554
+ await this.writeReferenceState(cid, cidBytes, {
555
+ generation: this.nextGeneration(state),
556
+ references,
557
+ });
558
+ return cid;
559
+ }
560
+
561
+ private async release(
562
+ generation: number,
563
+ scopeDigest: Uint8Array,
564
+ inputCid: string,
565
+ ): Promise<ScopedBlockReleaseResult> {
566
+ try {
567
+ validateCidInput(inputCid);
568
+ } catch (error) {
569
+ return Promise.reject(error);
570
+ }
571
+ return this.enqueue(generation, async () => {
572
+ const { cid, bytes: cidBytes } = canonicalCid(inputCid);
573
+ const state = await this.readReferenceState(cid, cidBytes);
574
+ if (!state || state.references.length === 0) {
575
+ await this.cleanupUnreferenced(cid, state !== undefined);
576
+ return "not-retained";
577
+ }
578
+ const stored = await this.readManagedBytes(cid);
579
+ if (stored === undefined) {
580
+ this.fault(
581
+ "corrupt-state",
582
+ new ScopedBlockReclamationCorruptionError(
583
+ "Scoped block reference state points to missing bytes",
584
+ ),
585
+ );
586
+ }
587
+ await this.verifyManagedBytes(cid, stored);
588
+ const index = state.references.findIndex((reference) =>
589
+ equalBytes(reference, scopeDigest),
590
+ );
591
+ if (index === -1) return "not-retained";
592
+ const references = state.references.filter((_, i) => i !== index);
593
+ await this.writeReferenceState(cid, cidBytes, {
594
+ generation: this.nextGeneration(state),
595
+ references,
596
+ });
597
+ if (references.length > 0) return "retained";
598
+ await this.cleanupUnreferenced(cid, true);
599
+ return "reclaimed";
600
+ });
601
+ }
602
+
603
+ private nextGeneration(state: ReferenceState | undefined): bigint {
604
+ const generation = (state?.generation ?? 0n) + 1n;
605
+ if (generation > MAX_GENERATION) {
606
+ throw new RangeError("Scoped block reference generation exceeds u64");
607
+ }
608
+ return generation;
609
+ }
610
+
611
+ private async readReferenceState(
612
+ cid: string,
613
+ cidBytes: Uint8Array,
614
+ ): Promise<ReferenceState | undefined> {
615
+ let bytes: Uint8Array | undefined;
616
+ try {
617
+ bytes = await this.referenceStore!.get(cid);
618
+ } catch (error) {
619
+ this.fault("storage-failure", error);
620
+ }
621
+ if (bytes === undefined) return undefined;
622
+ try {
623
+ return decodeReferenceState(cidBytes, bytes);
624
+ } catch (error) {
625
+ this.fault("corrupt-state", error);
626
+ }
627
+ }
628
+
629
+ private async writeReferenceState(
630
+ cid: string,
631
+ cidBytes: Uint8Array,
632
+ state: ReferenceState,
633
+ ): Promise<void> {
634
+ const bytes = encodeReferenceState(cidBytes, state);
635
+ try {
636
+ await this.referenceDurability!.atomicReplace(cid, bytes);
637
+ } catch (error) {
638
+ this.fault("ambiguous-mutation", error);
639
+ }
640
+ }
641
+
642
+ private async readManagedBytes(cid: string): Promise<Uint8Array | undefined> {
643
+ let value: Uint8Array | undefined;
644
+ try {
645
+ value = await this.dataStore!.get(cid);
646
+ } catch (error) {
647
+ this.fault("storage-failure", error);
648
+ }
649
+ if (value === undefined) return undefined;
650
+ return this.copyManagedBytes(value);
651
+ }
652
+
653
+ private copyManagedBytes(value: Uint8Array): Uint8Array {
654
+ try {
655
+ return exactCopy(
656
+ value,
657
+ "Scoped managed block bytes",
658
+ SCOPED_BLOCK_RECLAMATION_LIMITS.maxBlockBytes,
659
+ true,
660
+ );
661
+ } catch (error) {
662
+ this.fault(
663
+ "corrupt-state",
664
+ new ScopedBlockReclamationCorruptionError(
665
+ `Scoped managed block bytes are invalid: ${error instanceof Error ? error.message : String(error)}`,
666
+ ),
667
+ );
668
+ }
669
+ }
670
+
671
+ private async verifyManagedBytes(
672
+ cid: string,
673
+ bytes: Uint8Array,
674
+ ): Promise<void> {
675
+ try {
676
+ await verifyBlockBytes(cid, bytes);
677
+ } catch (error) {
678
+ this.fault("corrupt-state", error);
679
+ }
680
+ }
681
+
682
+ private async cleanupUnreferenced(
683
+ cid: string,
684
+ hasZeroReferenceRecord: boolean,
685
+ ): Promise<void> {
686
+ const stored = await this.readManagedBytes(cid);
687
+ if (stored !== undefined) {
688
+ await this.mutate(async () => {
689
+ await this.dataStore!.del(cid);
690
+ await this.dataDurability!.barrier();
691
+ });
692
+ }
693
+ if (hasZeroReferenceRecord) {
694
+ await this.mutate(async () => {
695
+ await this.referenceStore!.del(cid);
696
+ await this.referenceDurability!.barrier();
697
+ });
698
+ }
699
+ }
700
+
701
+ private async mutate(operation: () => Promise<void>): Promise<void> {
702
+ try {
703
+ await operation();
704
+ } catch (error) {
705
+ this.fault("ambiguous-mutation", error);
706
+ }
707
+ }
708
+
709
+ private enqueue<T>(
710
+ generation: number,
711
+ operation: () => Promise<T>,
712
+ reservedBytes = 0,
713
+ ): Promise<T> {
714
+ if (
715
+ this.pendingOperations >=
716
+ SCOPED_BLOCK_RECLAMATION_LIMITS.maxPendingOperations ||
717
+ this.pendingBytes + reservedBytes >
718
+ SCOPED_BLOCK_RECLAMATION_LIMITS.maxPendingBytes
719
+ ) {
720
+ return Promise.reject(
721
+ new RangeError("Scoped block reclamation pending-work limit exceeded"),
722
+ );
723
+ }
724
+ this.pendingOperations += 1;
725
+ this.pendingBytes += reservedBytes;
726
+ const result = this.tail.then(async () => {
727
+ this.requireReady(generation);
728
+ return operation();
729
+ });
730
+ this.tail = result.then(
731
+ (): void => undefined,
732
+ (): void => undefined,
733
+ );
734
+ return result.finally(() => {
735
+ this.pendingOperations -= 1;
736
+ this.pendingBytes -= reservedBytes;
737
+ });
738
+ }
739
+
740
+ private enqueueWithBytes<T>(
741
+ generation: number,
742
+ input: Uint8Array,
743
+ operation: (bytes: Uint8Array) => Promise<T>,
744
+ ): Promise<T> {
745
+ let byteLength: number;
746
+ try {
747
+ byteLength = exactByteLength(input, "Scoped block bytes");
748
+ } catch (error) {
749
+ return Promise.reject(error);
750
+ }
751
+ if (byteLength > SCOPED_BLOCK_RECLAMATION_LIMITS.maxBlockBytes) {
752
+ return Promise.reject(
753
+ new RangeError(
754
+ `Scoped block bytes must contain at most ${SCOPED_BLOCK_RECLAMATION_LIMITS.maxBlockBytes} bytes`,
755
+ ),
756
+ );
757
+ }
758
+ if (
759
+ this.pendingOperations >=
760
+ SCOPED_BLOCK_RECLAMATION_LIMITS.maxPendingOperations ||
761
+ this.pendingBytes + byteLength >
762
+ SCOPED_BLOCK_RECLAMATION_LIMITS.maxPendingBytes
763
+ ) {
764
+ return Promise.reject(
765
+ new RangeError("Scoped block reclamation pending-work limit exceeded"),
766
+ );
767
+ }
768
+ let bytes: Uint8Array;
769
+ try {
770
+ bytes = exactCopy(
771
+ input,
772
+ "Scoped block bytes",
773
+ SCOPED_BLOCK_RECLAMATION_LIMITS.maxBlockBytes,
774
+ true,
775
+ );
776
+ } catch (error) {
777
+ return Promise.reject(error);
778
+ }
779
+ return this.enqueue(generation, () => operation(bytes), byteLength);
780
+ }
781
+
782
+ private requireReady(generation: number): void {
783
+ if (
784
+ this.currentHealth.status !== "ready" ||
785
+ generation !== this.lifecycleGeneration
786
+ ) {
787
+ throw new ScopedBlockReclamationLifecycleError();
788
+ }
789
+ }
790
+
791
+ private fault(
792
+ reason: ScopedBlockReclamationFaultCode,
793
+ cause: unknown,
794
+ ): never {
795
+ // Stop wins the lifecycle race. A read already admitted before close may
796
+ // still reject, but it must not resurrect a closed controller as faulted.
797
+ if (this.currentHealth.status !== "closed") {
798
+ this.currentHealth = Object.freeze({ status: "faulted", reason });
799
+ }
800
+ if (cause instanceof ScopedBlockReclamationFaultError) throw cause;
801
+ throw new ScopedBlockReclamationFaultError(reason, cause);
802
+ }
803
+
804
+ private acquireReadLease(): (() => void) | undefined {
805
+ if (this.currentHealth.status === "closed" || !this.dataStore) {
806
+ return undefined;
807
+ }
808
+ this.activeReadLeases += 1;
809
+ let released = false;
810
+ return () => {
811
+ if (released) return;
812
+ released = true;
813
+ this.activeReadLeases -= 1;
814
+ if (this.activeReadLeases !== 0) return;
815
+ for (const resolve of this.readDrainWaiters) resolve();
816
+ this.readDrainWaiters.clear();
817
+ };
818
+ }
819
+
820
+ private waitForReadLeases(): Promise<void> {
821
+ if (this.activeReadLeases === 0) return Promise.resolve();
822
+ return new Promise((resolve) => this.readDrainWaiters.add(resolve));
823
+ }
824
+
825
+ private closeManagedIterator(state: ManagedIteratorState): Promise<void> {
826
+ if (!state.closing) {
827
+ state.closing = (async () => {
828
+ try {
829
+ await state.iterator.return?.();
830
+ } catch (error) {
831
+ if (this.currentHealth.status !== "closed") {
832
+ this.fault("storage-failure", error);
833
+ }
834
+ } finally {
835
+ this.managedIterators.delete(state);
836
+ }
837
+ })();
838
+ }
839
+ return state.closing;
840
+ }
841
+
842
+ async readForBlocks(inputCid: string): Promise<Uint8Array | undefined> {
843
+ let cid: string;
844
+ try {
845
+ cid = canonicalCid(inputCid).cid;
846
+ } catch {
847
+ return undefined;
848
+ }
849
+ const release = this.acquireReadLease();
850
+ if (!release) return undefined;
851
+ try {
852
+ return await this.readManagedBytes(cid);
853
+ } finally {
854
+ release();
855
+ }
856
+ }
857
+
858
+ async *iterateManaged(): AsyncGenerator<[string, Uint8Array], void, void> {
859
+ const dataStore = this.dataStore;
860
+ if (!dataStore || this.currentHealth.status === "closed") return;
861
+ const iterator = dataStore.iterator()[Symbol.asyncIterator]();
862
+ const state: ManagedIteratorState = { iterator };
863
+ this.managedIterators.add(state);
864
+ try {
865
+ while (true) {
866
+ const release = this.acquireReadLease();
867
+ if (!release) return;
868
+ let entry: [string, Uint8Array] | undefined;
869
+ try {
870
+ const next = await iterator.next();
871
+ if (next.done) return;
872
+ const [inputCid, inputBytes] = next.value as [string, Uint8Array];
873
+ let cid: string;
874
+ try {
875
+ cid = canonicalCid(inputCid).cid;
876
+ if (cid !== inputCid) {
877
+ throw new Error("Managed block key is not canonical");
878
+ }
879
+ } catch (error) {
880
+ this.fault("corrupt-state", error);
881
+ }
882
+ const bytes = this.copyManagedBytes(inputBytes);
883
+ // Resolve raw aliases while the lease is held. The outer block-store
884
+ // iterator must not perform an untracked root read after close begins.
885
+ if ((await this.rootStore.get(cid)) === undefined) {
886
+ entry = [cid, bytes];
887
+ }
888
+ } catch (error) {
889
+ if (error instanceof ScopedBlockReclamationFaultError) throw error;
890
+ this.fault("storage-failure", error);
891
+ } finally {
892
+ release();
893
+ }
894
+ if (entry) yield entry;
895
+ }
896
+ } finally {
897
+ await this.closeManagedIterator(state);
898
+ }
899
+ }
900
+ }
901
+
902
+ class ScopedReclamationBlockStore extends AnyBlockStore {
903
+ declare readonly localReclamation: ScopedBlockReclamationV1;
904
+
905
+ constructor(
906
+ store: AnyStore,
907
+ private readonly reclamation: ScopedBlockReclamationController,
908
+ ) {
909
+ super(store);
910
+ Object.defineProperty(this, "localReclamation", {
911
+ value: reclamation,
912
+ enumerable: true,
913
+ writable: false,
914
+ configurable: false,
915
+ });
916
+ }
917
+
918
+ override async start(): Promise<void> {
919
+ try {
920
+ await super.start();
921
+ await this.reclamation.start();
922
+ } catch (error) {
923
+ try {
924
+ await super.stop();
925
+ } catch {
926
+ // Preserve the startup fault; the service was never made ready.
927
+ }
928
+ this.reclamation.failStartup(error);
929
+ }
930
+ }
931
+
932
+ override async stop(): Promise<void> {
933
+ await this.reclamation.stop();
934
+ await super.stop();
935
+ }
936
+
937
+ override async get(
938
+ cid: string,
939
+ options?: Parameters<AnyBlockStore["get"]>[1],
940
+ ): Promise<Uint8Array | undefined> {
941
+ if (!isRawBlockCid(cid)) return undefined;
942
+ const raw = await super.get(cid, options);
943
+ if (raw !== undefined) return raw;
944
+ const managed = await this.reclamation.readForBlocks(cid);
945
+ return managed === undefined
946
+ ? undefined
947
+ : this.decodeStoredBytes(cid, managed, options);
948
+ }
949
+
950
+ override async getMany(
951
+ cids: string[],
952
+ options?: Parameters<AnyBlockStore["getMany"]>[1],
953
+ ): Promise<Array<Uint8Array | undefined>> {
954
+ const length = cids.length;
955
+ const values = new Array<Uint8Array | undefined>(length).fill(undefined);
956
+ const validIndexes: number[] = [];
957
+ const validCids: string[] = [];
958
+ for (let index = 0; index < length; index++) {
959
+ const cid = cids[index];
960
+ if (!isRawBlockCid(cid)) continue;
961
+ validIndexes.push(index);
962
+ validCids.push(cid);
963
+ }
964
+ const rawValues = await super.getMany(validCids, options);
965
+ for (let index = 0; index < validIndexes.length; index++) {
966
+ values[validIndexes[index]!] = rawValues[index];
967
+ }
968
+ await Promise.all(
969
+ validIndexes.map(async (index, validIndex) => {
970
+ const value = values[index];
971
+ if (value !== undefined) return;
972
+ const cid = validCids[validIndex]!;
973
+ const managed = await this.reclamation.readForBlocks(cid);
974
+ if (managed !== undefined) {
975
+ values[index] = await this.decodeStoredBytes(cid, managed, options);
976
+ }
977
+ }),
978
+ );
979
+ return values;
980
+ }
981
+
982
+ override async has(cid: string): Promise<boolean> {
983
+ if (!isRawBlockCid(cid)) return false;
984
+ return (
985
+ (await super.has(cid)) ||
986
+ (await this.reclamation.readForBlocks(cid)) !== undefined
987
+ );
988
+ }
989
+
990
+ override async hasMany(cids: string[]): Promise<boolean[]> {
991
+ const length = cids.length;
992
+ const values = new Array<boolean>(length).fill(false);
993
+ const validIndexes: number[] = [];
994
+ const validCids: string[] = [];
995
+ for (let index = 0; index < length; index++) {
996
+ const cid = cids[index];
997
+ if (!isRawBlockCid(cid)) continue;
998
+ validIndexes.push(index);
999
+ validCids.push(cid);
1000
+ }
1001
+ const rawValues = await super.hasMany(validCids);
1002
+ for (let index = 0; index < validIndexes.length; index++) {
1003
+ values[validIndexes[index]!] = rawValues[index]!;
1004
+ }
1005
+ await Promise.all(
1006
+ validIndexes.map(async (index, validIndex) => {
1007
+ const value = values[index];
1008
+ if (!value) {
1009
+ values[index] =
1010
+ (await this.reclamation.readForBlocks(validCids[validIndex]!)) !==
1011
+ undefined;
1012
+ }
1013
+ }),
1014
+ );
1015
+ return values;
1016
+ }
1017
+
1018
+ override async put(
1019
+ input: Parameters<AnyBlockStore["put"]>[0],
1020
+ ): Promise<string> {
1021
+ if (input instanceof Uint8Array) return super.put(input);
1022
+ const cid = input?.cid;
1023
+ requireRawBlockCid(cid);
1024
+ const block = input.block;
1025
+ return super.put({ cid, block });
1026
+ }
1027
+
1028
+ override async putMany(
1029
+ inputs: Parameters<AnyBlockStore["putMany"]>[0],
1030
+ ): Promise<string[]> {
1031
+ const stableInputs: Parameters<AnyBlockStore["putMany"]>[0] = [];
1032
+ const length = inputs.length;
1033
+ for (let index = 0; index < length; index++) {
1034
+ const input = inputs[index]!;
1035
+ if (input instanceof Uint8Array) {
1036
+ stableInputs.push(input);
1037
+ continue;
1038
+ }
1039
+ const cid = input?.cid;
1040
+ requireRawBlockCid(cid);
1041
+ const block = input.block;
1042
+ stableInputs.push({ cid, block });
1043
+ }
1044
+ return super.putMany(stableInputs);
1045
+ }
1046
+
1047
+ override putKnown(cid: string, bytes: Uint8Array): Promise<string> | string {
1048
+ requireRawBlockCid(cid);
1049
+ return super.putKnown(cid, bytes);
1050
+ }
1051
+
1052
+ override putKnownMany(
1053
+ blocks: Array<readonly [cid: string, bytes: Uint8Array]>,
1054
+ ): Promise<string[]> | string[] {
1055
+ const stableBlocks: Array<readonly [string, Uint8Array]> = [];
1056
+ const length = blocks.length;
1057
+ for (let index = 0; index < length; index++) {
1058
+ const block = blocks[index]!;
1059
+ const cid = block[0];
1060
+ requireRawBlockCid(cid);
1061
+ stableBlocks.push([cid, block[1]]);
1062
+ }
1063
+ return super.putKnownMany(stableBlocks);
1064
+ }
1065
+
1066
+ override async rm(cid: string): Promise<void> {
1067
+ requireRawBlockCid(cid);
1068
+ return super.rm(cid);
1069
+ }
1070
+
1071
+ override async rmMany(cids: string[]): Promise<number> {
1072
+ const stableCids: string[] = [];
1073
+ const length = cids.length;
1074
+ for (let index = 0; index < length; index++) {
1075
+ const cid = cids[index];
1076
+ requireRawBlockCid(cid);
1077
+ stableCids.push(cid);
1078
+ }
1079
+ return super.rmMany(stableCids);
1080
+ }
1081
+
1082
+ override async *iterator(): AsyncGenerator<[string, Uint8Array], void, void> {
1083
+ for await (const entry of super.iterator()) {
1084
+ if (isRawBlockCid(entry[0])) yield entry;
1085
+ }
1086
+ for await (const [cid, bytes] of this.reclamation.iterateManaged()) {
1087
+ yield [cid, bytes];
1088
+ }
1089
+ }
1090
+ }
1091
+
1092
+ /** @internal Used only after DirectBlock has proved this is its built-in store. */
1093
+ export const createBuiltInScopedReclamationBlockStore = (
1094
+ store: AnyStore,
1095
+ ): AnyBlockStore => {
1096
+ if (!isAtomicDurability(store.crashSafeDurability)) {
1097
+ return new AnyBlockStore(store);
1098
+ }
1099
+ const reclamation = new ScopedBlockReclamationController(store);
1100
+ return new ScopedReclamationBlockStore(store, reclamation);
1101
+ };