@peerbit/shared-log 13.2.3 → 13.2.5

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/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  getPublicKeyFromPeerId,
18
18
  sha256Base64Sync,
19
19
  sha256Sync,
20
+ toHexString,
20
21
  } from "@peerbit/crypto";
21
22
  import {
22
23
  And,
@@ -368,6 +369,339 @@ const canUseOptionalNativeModuleImports = (): boolean => {
368
369
  );
369
370
  };
370
371
 
372
+ /**
373
+ * Build the per-program coordinate persistence directory under a node's
374
+ * durable storage root: `<nodeDirectory>/coordinates/<fsSafeLogId>`. Uses
375
+ * forward-slash joining (accepted by Node's `fs` on every platform) so
376
+ * shared-log does not need to statically import `node:path`, which is not
377
+ * available in browser bundles. Trailing separators on the root are trimmed
378
+ * to avoid doubled slashes.
379
+ */
380
+ const joinNativeCoordinateDirectory = (
381
+ nodeDirectory: string,
382
+ fsSafeLogId: string,
383
+ ): string => `${nodeDirectory.replace(/[/\\]+$/, "")}/coordinates/${fsSafeLogId}`;
384
+
385
+ /** The native backbone's in-wasm-memory block store. */
386
+ type NativeBackboneBlocks = NativePeerbitBackbone["blocks"];
387
+
388
+ /**
389
+ * Write-through block store bridging the native backbone's in-wasm-memory block
390
+ * store to a durable per-program {@link AnyBlockStore}.
391
+ *
392
+ * WHY: when the native backbone is active the log's entry blocks live only in
393
+ * the native wasm block map (`NativeBackboneBlockStore.persisted() === false`).
394
+ * On a restart that map is empty, so the native graph cannot reload heads the
395
+ * durable heads index still lists ("Failed to load entry from head"). This
396
+ * wrapper mirrors every write into the same durable `blocks` sublevel the
397
+ * non-native path uses. On a native miss, reads fall through to durable storage
398
+ * and lazily repopulate the wasm map so the native graph can walk the DAG again.
399
+ *
400
+ * The native store stays the authoritative hot store the native graph reads
401
+ * from: reads hit native first and only fall back to durable (repopulating
402
+ * native on a hit so subsequent native-graph reads succeed).
403
+ *
404
+ * METHOD SURFACE (see #1006): `RemoteBlocks` and the log feature-detect the
405
+ * optional batch methods (`putMany`/`putKnown`/`putKnownMany`/
406
+ * `putKnownManyColumns`/`rmMany`). To keep the receive-fusion / columnar fast
407
+ * paths engaged this wrapper exposes EXACTLY the methods the native store
408
+ * exposes — including `putKnownManyColumns`, which `AnyBlockStore` does not have
409
+ * — and delegates each. It deliberately does not add methods the native store
410
+ * lacks (`getBlockResponsePayload`/`getNativeLogBlockStoreHandle` stay absent so
411
+ * the optional-chained probes in `RemoteBlocks` behave exactly as today).
412
+ */
413
+ class NativeBackboneWriteThroughBlockStore {
414
+ constructor(
415
+ private readonly native: NativeBackboneBlocks,
416
+ private readonly durable: AnyBlockStore,
417
+ ) {}
418
+
419
+ // A durable mirror write that cannot be awaited at its call site (the
420
+ // columnar putKnownManyColumns fast path must return a synchronous string[]
421
+ // because RemoteBlocks.putKnownManyColumns treats the result as sync) is
422
+ // tracked here instead of being silently `void`ed. Its rejection is stored
423
+ // and re-thrown on the next awaited wrapper method (and on stop()), so a
424
+ // failed durable write (IO/disk-full) surfaces as an error rather than
425
+ // vanishing and leaving the block out of durable while native/log report
426
+ // success. `.catch` also prevents unhandled-rejection noise.
427
+ private readonly pendingDurableWrites = new Set<Promise<unknown>>();
428
+ private durableWriteError: unknown;
429
+
430
+ private trackDurable(result: unknown): void {
431
+ // The durable store may answer synchronously (an in-memory or already
432
+ // resolved store); only a real pending promise needs tracking. A sync
433
+ // success is already durable; a sync throw would have propagated already.
434
+ if (!isPromiseLike(result)) {
435
+ return;
436
+ }
437
+ const tracked = Promise.resolve(result).then(
438
+ () => {
439
+ this.pendingDurableWrites.delete(tracked);
440
+ },
441
+ (error) => {
442
+ this.pendingDurableWrites.delete(tracked);
443
+ if (this.durableWriteError === undefined) {
444
+ this.durableWriteError = error;
445
+ }
446
+ },
447
+ );
448
+ this.pendingDurableWrites.add(tracked);
449
+ }
450
+
451
+ // Wait for every tracked (sync-path) durable write to settle, then surface
452
+ // the first failure. Awaited methods call this so a prior columnar durable
453
+ // failure propagates as back-pressure to the next caller.
454
+ private async drainDurable(): Promise<void> {
455
+ while (this.pendingDurableWrites.size > 0) {
456
+ await Promise.allSettled([...this.pendingDurableWrites]);
457
+ }
458
+ if (this.durableWriteError !== undefined) {
459
+ const error = this.durableWriteError;
460
+ this.durableWriteError = undefined;
461
+ throw error;
462
+ }
463
+ }
464
+
465
+ // --- lifecycle -------------------------------------------------------
466
+ // The native store's lifecycle hooks are no-ops; only the durable store
467
+ // needs starting/stopping. The wasm map is NOT eagerly rehydrated from disk:
468
+ // entry blocks are pulled back lazily on demand through the read fallback in
469
+ // getMany()/get() (durable hit -> repopulate the wasm map), which is what the
470
+ // log's DAG walk (EntryIndex.resolveMany -> store.getMany) exercises. Keeping
471
+ // the wasm map cold on open is required by the strict-native resident
472
+ // coordinate-state optimization: a reopened non-replicating native node must
473
+ // report hasBlock(head) === false and answer a same-signer append from the
474
+ // persisted coordinate + signer facts without resolving the entry block.
475
+ async start(): Promise<void> {
476
+ await this.durable.start();
477
+ }
478
+
479
+ async stop(): Promise<void> {
480
+ // Surface any tracked (sync-path) durable write failure and ensure all
481
+ // mirror writes have settled before the durable store is torn down.
482
+ await this.drainDurable();
483
+ await this.durable.stop();
484
+ }
485
+
486
+ status() {
487
+ return this.durable.status();
488
+ }
489
+
490
+ waitFor(): Promise<string[]> {
491
+ return Promise.resolve([]);
492
+ }
493
+
494
+ // Mirror a single already-committed block (present in the wasm map) to the
495
+ // durable store ONLY. Used by the native commit-only append fast path: the
496
+ // native prepare commits the entry block into the wasm map and returns no raw
497
+ // bytes, and the strict-native resident-coordinate path deliberately does NOT
498
+ // route the block through the log's finishBlocks/putKnown* (that would disturb
499
+ // the commit-only append path the RCS optimization depends on). Instead the
500
+ // caller reads the committed bytes back and calls this so the block lands in
501
+ // durable directly. Like putKnownManyColumns' durable mirror, the write is
502
+ // tracked (not fire-and-forget) so an IO/disk-full failure surfaces on the
503
+ // next awaited wrapper method / stop() rather than being swallowed.
504
+ mirrorToDurable(cid: string, bytes: Uint8Array): void {
505
+ this.trackDurable(this.durable.putKnown(cid, bytes));
506
+ }
507
+
508
+ // --- writes (apply to BOTH: native first for the hot path, then durable) ---
509
+ async put(
510
+ data: Uint8Array | { block: { bytes: Uint8Array }; cid: string },
511
+ ): Promise<string> {
512
+ await this.drainDurable();
513
+ const cid = await this.native.put(data as any);
514
+ // The native store computes a raw-codec CID for a `Uint8Array`, storing
515
+ // the bytes verbatim (raw codec is identity), and stores `block.bytes`
516
+ // for the pre-CIDed object form. Either way the input bytes match what
517
+ // native stored, so feed durable the known cid+bytes without recomputing.
518
+ const value =
519
+ data instanceof Uint8Array
520
+ ? data
521
+ : (data as { block: { bytes: Uint8Array } }).block.bytes;
522
+ await this.durable.putKnown(cid, value);
523
+ return cid;
524
+ }
525
+
526
+ async putMany(
527
+ blocks: Array<Uint8Array | { block: { bytes: Uint8Array }; cid: string }>,
528
+ ): Promise<string[]> {
529
+ await this.drainDurable();
530
+ const cids = await this.native.putMany(blocks as any);
531
+ const durableBlocks: Array<readonly [string, Uint8Array]> = cids.map(
532
+ (cid, index) => {
533
+ const block = blocks[index]!;
534
+ const value =
535
+ block instanceof Uint8Array
536
+ ? block
537
+ : (block as { block: { bytes: Uint8Array } }).block.bytes;
538
+ return [cid, value] as const;
539
+ },
540
+ );
541
+ await this.durable.putKnownMany(durableBlocks);
542
+ return cids;
543
+ }
544
+
545
+ // Native put is synchronous (the authoritative hot store); the durable mirror
546
+ // is awaited so the returned promise resolves only after BOTH native and
547
+ // durable succeed and a durable IO/disk-full failure rejects here instead of
548
+ // being swallowed. RemoteBlocks.putKnown and the log's putKnownEntryBytesBatch
549
+ // both await this method, so returning a promise is compatible.
550
+ async putKnown(cid: string, bytes: Uint8Array): Promise<string> {
551
+ await this.drainDurable();
552
+ const stored = this.native.putKnown(cid, bytes);
553
+ await this.durable.putKnown(cid, bytes);
554
+ return stored;
555
+ }
556
+
557
+ async putKnownMany(
558
+ blocks: Array<readonly [cid: string, bytes: Uint8Array]>,
559
+ ): Promise<string[]> {
560
+ await this.drainDurable();
561
+ const cids = this.native.putKnownMany(blocks);
562
+ await this.durable.putKnownMany(blocks);
563
+ return cids;
564
+ }
565
+
566
+ putKnownManyColumns(cids: string[], bytes: Uint8Array[]): string[] {
567
+ const stored = this.native.putKnownManyColumns(cids, bytes);
568
+ // AnyBlockStore has no columnar method; mirror via putKnownMany, which
569
+ // takes [cid, bytes] tuples and hits the same batched store path.
570
+ // This method must return a synchronous string[] (RemoteBlocks.putKnownManyColumns
571
+ // consumes the result synchronously), so the durable write cannot be awaited
572
+ // inline. Track it instead of `void`ing it so a durable rejection surfaces
573
+ // on the next awaited wrapper method / stop() rather than being swallowed.
574
+ const durableBlocks = cids.map(
575
+ (cid, index) => [cid, bytes[index]!] as const,
576
+ );
577
+ this.trackDurable(this.durable.putKnownMany(durableBlocks));
578
+ return stored;
579
+ }
580
+
581
+ // --- reads (native/wasm first; on miss, durable fallback + repopulate) ---
582
+ // RemoteBlocks.get awaits this single-get, so on a native miss consult the
583
+ // durable store (like getMany does) and repopulate the native map, rather
584
+ // than returning undefined and only scheduling a background repopulate. That
585
+ // avoids a spurious miss (which would otherwise fall through to a remote
586
+ // read) for a block that is present on disk.
587
+ async get(cid: string, _options?: unknown): Promise<Uint8Array | undefined> {
588
+ const local = this.native.get(cid);
589
+ if (local != null) {
590
+ return local;
591
+ }
592
+ const durableValue = await this.durable.get(cid);
593
+ if (durableValue != null) {
594
+ // Repopulate the native map so the native graph reads it next time.
595
+ this.native.putKnownManyColumns([cid], [durableValue]);
596
+ return durableValue;
597
+ }
598
+ return undefined;
599
+ }
600
+
601
+ async getMany(cids: string[]): Promise<Array<Uint8Array | undefined>> {
602
+ const results = await this.native.getMany(cids);
603
+ const missing: string[] = [];
604
+ for (let i = 0; i < results.length; i++) {
605
+ if (results[i] == null) {
606
+ missing.push(cids[i]!);
607
+ }
608
+ }
609
+ if (missing.length === 0) {
610
+ return results;
611
+ }
612
+ const durableValues = await this.durable.getMany(missing);
613
+ const repopulateCids: string[] = [];
614
+ const repopulateBytes: Uint8Array[] = [];
615
+ let missingIndex = 0;
616
+ for (let i = 0; i < results.length; i++) {
617
+ if (results[i] != null) {
618
+ continue;
619
+ }
620
+ const value = durableValues[missingIndex++];
621
+ if (value != null) {
622
+ results[i] = value;
623
+ repopulateCids.push(cids[i]!);
624
+ repopulateBytes.push(value);
625
+ }
626
+ }
627
+ if (repopulateCids.length > 0) {
628
+ // Repopulate the native map so the native graph sees these blocks.
629
+ this.native.putKnownManyColumns(repopulateCids, repopulateBytes);
630
+ }
631
+ return results;
632
+ }
633
+
634
+ async has(cid: string): Promise<boolean> {
635
+ if (this.native.has(cid)) {
636
+ return true;
637
+ }
638
+ // Mirror getMany/hasMany: a block absent from the native wasm map may still
639
+ // be present in the durable store (e.g. persisted on disk but not yet
640
+ // repopulated into wasm). Consult durable on a native miss so presence
641
+ // checks agree with the resolves that getMany/hasMany already durable-fall
642
+ // back on. `Blocks.has` is declared `MaybePromise<boolean>`, so returning a
643
+ // promise here is contract-compatible.
644
+ return this.durable.has(cid);
645
+ }
646
+
647
+ async hasMany(cids: string[]): Promise<boolean[]> {
648
+ const nativeHas = await this.native.hasMany(cids);
649
+ const missing: string[] = [];
650
+ for (let i = 0; i < nativeHas.length; i++) {
651
+ if (!nativeHas[i]) {
652
+ missing.push(cids[i]!);
653
+ }
654
+ }
655
+ if (missing.length === 0) {
656
+ return nativeHas;
657
+ }
658
+ const durableHas = await this.durable.hasMany(missing);
659
+ let missingIndex = 0;
660
+ for (let i = 0; i < nativeHas.length; i++) {
661
+ if (!nativeHas[i]) {
662
+ nativeHas[i] = durableHas[missingIndex++]!;
663
+ }
664
+ }
665
+ return nativeHas;
666
+ }
667
+
668
+ // --- removes (apply to BOTH) ----------------------------------------
669
+ // Native rm is synchronous; the durable rm is awaited so the returned promise
670
+ // resolves only after both succeed and a durable failure rejects here rather
671
+ // than being swallowed. All rm callers (RemoteBlocks.rm, the log) await it.
672
+ async rm(cid: string): Promise<void> {
673
+ await this.drainDurable();
674
+ this.native.rm(cid);
675
+ await this.durable.rm(cid);
676
+ }
677
+
678
+ del(cid: string): void {
679
+ void this.rm(cid);
680
+ }
681
+
682
+ async rmMany(cids: string[]): Promise<number> {
683
+ await this.drainDurable();
684
+ const removed = await this.native.rmMany(cids);
685
+ await this.durable.rmMany(cids);
686
+ return removed;
687
+ }
688
+
689
+ // --- misc ------------------------------------------------------------
690
+ async *iterator(): AsyncGenerator<[string, Uint8Array], void, void> {
691
+ yield* this.native.iterator();
692
+ }
693
+
694
+ size(): number {
695
+ return this.native.size();
696
+ }
697
+
698
+ persisted(): boolean {
699
+ // The blocks are now mirrored to a durable store, so report persisted so
700
+ // callers that gate durable-only behavior on this flag behave correctly.
701
+ return true;
702
+ }
703
+ }
704
+
371
705
  type LeaderMap = Map<string, { intersecting: boolean }>;
372
706
 
373
707
  type LeaderSelectionOptions<R extends "u32" | "u64"> = {
@@ -6320,6 +6654,23 @@ export class SharedLog<
6320
6654
  return undefined;
6321
6655
  }
6322
6656
  const backbone = this._nativeBackbone;
6657
+ // When the durable write-through wrapper is active the log's block store
6658
+ // (this.remoteBlocks.localStore) is the wrapper, NOT the raw wasm block map
6659
+ // (backbone.blocks), so this comparison is false. In that case the block
6660
+ // must be mirrored to durable directly; see the guarded handling in the
6661
+ // prepare callback below.
6662
+ const durableWrapperActive =
6663
+ this.remoteBlocks?.localStore !== backbone.blocks;
6664
+ // The write-through wrapper instance, captured only when active, so the
6665
+ // commit-only block can be mirrored to durable WITHOUT routing it through
6666
+ // the log's finishBlocks/putKnown* (which would disturb the strict-native
6667
+ // resident-coordinate append path). `mirrorToDurable` writes to the durable
6668
+ // side only and tracks the write for error-surfacing.
6669
+ const durableWrapper = durableWrapperActive
6670
+ ? (this.remoteBlocks?.localStore as unknown as {
6671
+ mirrorToDurable?: (cid: string, bytes: Uint8Array) => void;
6672
+ })
6673
+ : undefined;
6323
6674
  let nativeBackboneDocumentIndexCommitted = false;
6324
6675
  let committedNativeBackboneDocumentIndex:
6325
6676
  | NativeBackboneDocumentIndexCommitInput
@@ -6388,11 +6739,38 @@ export class SharedLog<
6388
6739
  },
6389
6740
  backbone.blocks,
6390
6741
  );
6391
- if (
6392
- prepared &&
6393
- !prepared.bytes &&
6394
- this.remoteBlocks?.localStore === backbone.blocks
6395
- ) {
6742
+ if (prepared && !prepared.bytes) {
6743
+ if (durableWrapper) {
6744
+ // The durable write-through wrapper is active, so the block store
6745
+ // the log writes through (this.remoteBlocks.localStore) is the
6746
+ // wrapper, NOT the raw wasm block map. prepareEntryV0PlainEntryCommit
6747
+ // committed the block into the wasm map ONLY and returned no raw
6748
+ // bytes, so on its own the block would never reach durable (log's
6749
+ // finishBlocks only calls putKnown* when prepared.bytes is set) and
6750
+ // a non-replicating native node would lose it on restart.
6751
+ //
6752
+ // Mirror the just-committed block (read back from the wasm store)
6753
+ // straight to the DURABLE side of the wrapper. Crucially we do NOT
6754
+ // attach prepared.bytes: doing so would make the log's finishBlocks
6755
+ // call putKnown*, which changes the commit-only append path and
6756
+ // breaks the strict-native resident-coordinate optimization (the
6757
+ // reopen tests assert the append stays native and resolves no entry
6758
+ // block). Instead the prepared result is returned exactly as the
6759
+ // memory-only branch below (getBytes only, no bytes), so the log's
6760
+ // finishBlocks path is UNCHANGED, and the block is made durable
6761
+ // out-of-band here. mirrorToDurable tracks the write so an IO/
6762
+ // disk-full failure surfaces on a later awaited wrapper method.
6763
+ const preparedHash = prepared.cid ?? prepared.hash;
6764
+ const committedBytes = preparedHash
6765
+ ? backbone.blocks.get(preparedHash)
6766
+ : undefined;
6767
+ if (committedBytes && durableWrapper.mirrorToDurable) {
6768
+ durableWrapper.mirrorToDurable(preparedHash!, committedBytes);
6769
+ }
6770
+ }
6771
+ // The block lives in the wasm map; expose getBytes for materialization
6772
+ // (identical to the memory-only native node path). When the durable
6773
+ // wrapper is active the durable copy was mirrored just above.
6396
6774
  return {
6397
6775
  ...prepared,
6398
6776
  getBytes: (hash: string) => backbone.blocks.get(hash),
@@ -6491,8 +6869,26 @@ export class SharedLog<
6491
6869
  NativePeerbitBackbone["preparePlainCommittedNoNextStorageAppendTransaction"]
6492
6870
  >
6493
6871
  | undefined;
6872
+ // The write-through durable wrapper, when active, is captured here so a
6873
+ // just-committed block can be mirrored to durable without disturbing the
6874
+ // strict-native commit path. When the wrapper is active the log's block
6875
+ // store is the wrapper (not the raw wasm map), so `localStore ===
6876
+ // backbone.blocks` is false — but the store is still native-backed, so we
6877
+ // must commit blocks in the backbone (the committed native prepare variant
6878
+ // is the one that emits the document-signer journal record; the
6879
+ // block-deferring variant does not, which would otherwise leave
6880
+ // document-signers.wal unwritten and break same-signer facts after
6881
+ // reopen). The block is then mirrored to durable out-of-band below.
6882
+ const durableWrapperActive =
6883
+ this.remoteBlocks?.localStore !== backbone.blocks;
6884
+ const durableWrapper = durableWrapperActive
6885
+ ? (this.remoteBlocks?.localStore as unknown as {
6886
+ mirrorToDurable?: (cid: string, bytes: Uint8Array) => void;
6887
+ })
6888
+ : undefined;
6494
6889
  const commitBlocksInBackbone =
6495
- this.remoteBlocks?.localStore === backbone.blocks;
6890
+ this.remoteBlocks?.localStore === backbone.blocks ||
6891
+ durableWrapperActive;
6496
6892
  let nativeBackboneDocumentIndexCommitted = false;
6497
6893
  let nativeBackboneDocumentDeleteCommitted = false;
6498
6894
  let committedNativeBackboneDocumentIndex:
@@ -6616,6 +7012,23 @@ export class SharedLog<
6616
7012
  !!nativeBackboneDocumentDeleteKey;
6617
7013
  const useTrimmedHashesOnly =
6618
7014
  properties?.resolveTrimmedEntries === false;
7015
+ if (durableWrapper?.mirrorToDurable) {
7016
+ // The block was committed into the wasm map (commitBlocksInBackbone is
7017
+ // true) but not into durable, because the log's finishBlocks path is
7018
+ // left UNCHANGED for strict-native mode (getBytes only, no bytes, so
7019
+ // no putKnown* through the wrapper). Mirror the just-committed block
7020
+ // straight to the durable side so a non-replicating native node keeps
7021
+ // it across a restart. mirrorToDurable tracks the write for
7022
+ // error-surfacing.
7023
+ const committedHash =
7024
+ backboneAppend.entry.cid ?? backboneAppend.entry.hash;
7025
+ const committedBytes = committedHash
7026
+ ? backbone.blocks.get(committedHash)
7027
+ : undefined;
7028
+ if (committedBytes) {
7029
+ durableWrapper.mirrorToDurable(committedHash!, committedBytes);
7030
+ }
7031
+ }
6619
7032
  return {
6620
7033
  ...backboneAppend.entry,
6621
7034
  gid: backboneAppend.coordinate.gid,
@@ -7178,12 +7591,25 @@ export class SharedLog<
7178
7591
  options?.target !== "none" ||
7179
7592
  options?.replicate === true ||
7180
7593
  (options?.delivery !== undefined && options.delivery !== false) ||
7181
- this.remoteBlocks?.localStore !== backbone.blocks ||
7182
7594
  !this.canUseNativeBackboneResidentCoordinateState() ||
7183
7595
  properties?.nexts?.some((nexts) => nexts.length > 0)
7184
7596
  ) {
7185
7597
  return undefined;
7186
7598
  }
7599
+ // When the durable write-through wrapper is active the log's block store is
7600
+ // the wrapper, not the raw wasm map, so `localStore === backbone.blocks` is
7601
+ // false. This batch path always commits blocks in the backbone (the
7602
+ // committed native batch prepare variants), so it is safe with the wrapper:
7603
+ // the blocks land in wasm and are mirrored to durable per-entry below. This
7604
+ // preserves the resident-coordinate fast batch path (and its meta.next
7605
+ // linking) after a reopen instead of bailing to a slow generic path.
7606
+ const durableWrapperActive =
7607
+ this.remoteBlocks?.localStore !== backbone.blocks;
7608
+ const durableWrapper = durableWrapperActive
7609
+ ? (this.remoteBlocks?.localStore as unknown as {
7610
+ mirrorToDurable?: (cid: string, bytes: Uint8Array) => void;
7611
+ })
7612
+ : undefined;
7187
7613
  const usesLatestDocumentContext = documentIndexes.every(
7188
7614
  (index) => index.useLatestContext === true,
7189
7615
  );
@@ -7310,6 +7736,21 @@ export class SharedLog<
7310
7736
  for (let i = 0; i < appended.appendFacts.length; i++) {
7311
7737
  const facts = appended.appendFacts[i]!;
7312
7738
  const backboneAppend = backboneAppends[i]!;
7739
+ if (durableWrapper?.mirrorToDurable) {
7740
+ // The batch prepare committed each block into the wasm map; the log's
7741
+ // finishBlocks path is left unchanged (getBytes only) to preserve
7742
+ // strict-native mode, so mirror the committed block straight to durable
7743
+ // so a non-replicating native node keeps it across a restart. Tracked
7744
+ // for error-surfacing by mirrorToDurable.
7745
+ const committedHash =
7746
+ backboneAppend.entry.cid ?? backboneAppend.entry.hash;
7747
+ const committedBytes = committedHash
7748
+ ? backbone.blocks.get(committedHash)
7749
+ : undefined;
7750
+ if (committedBytes) {
7751
+ durableWrapper.mirrorToDurable(committedHash!, committedBytes);
7752
+ }
7753
+ }
7313
7754
  const coordinateFields = this.createCoordinateFieldsFromNativePlanFacts({
7314
7755
  appendFacts: facts,
7315
7756
  plan: backboneAppend.coordinate,
@@ -8629,9 +9070,28 @@ export class SharedLog<
8629
9070
  this._wireSyncSession = wireSyncSession;
8630
9071
  wireSyncSession.registerTopic(this.topic);
8631
9072
  }
8632
- const localBlocks = this._nativeBackbone
8633
- ? this._nativeBackbone.blocks
8634
- : new AnyBlockStore(await storage.sublevel("blocks"));
9073
+ // Block store selection:
9074
+ // - No native backbone: durable per-program cache (unchanged default).
9075
+ // - Native backbone WITHOUT a durable directory (memory-only node): the
9076
+ // wasm-memory native store only (unchanged prior behavior).
9077
+ // - Native backbone WITH a durable directory: a write-through wrapper that
9078
+ // mirrors the native wasm store to the SAME durable `blocks` sublevel the
9079
+ // default path uses, and rehydrates the wasm map from disk on open. This
9080
+ // is what makes native entry blocks survive a restart so heads reload.
9081
+ let localBlocks: NonNullable<RemoteBlocks["localStore"]>;
9082
+ if (this._nativeBackbone) {
9083
+ if (this.node.directory != null) {
9084
+ const durable = new AnyBlockStore(await storage.sublevel("blocks"));
9085
+ localBlocks = new NativeBackboneWriteThroughBlockStore(
9086
+ this._nativeBackbone.blocks,
9087
+ durable,
9088
+ ) as unknown as NonNullable<RemoteBlocks["localStore"]>;
9089
+ } else {
9090
+ localBlocks = this._nativeBackbone.blocks;
9091
+ }
9092
+ } else {
9093
+ localBlocks = new AnyBlockStore(await storage.sublevel("blocks"));
9094
+ }
8635
9095
  this.remoteBlocks = new RemoteBlocks({
8636
9096
  local: localBlocks,
8637
9097
  publish: (message, options) =>
@@ -9318,21 +9778,36 @@ export class SharedLog<
9318
9778
  return undefined;
9319
9779
  }
9320
9780
  try {
9781
+ const nativeBackboneModule = await import(
9782
+ /* @vite-ignore */ "@peerbit/native-backbone"
9783
+ );
9321
9784
  const {
9322
9785
  createNativeBackboneCoordinatePersistence,
9323
9786
  createNativePeerbitBackbone,
9324
- } = await import(/* @vite-ignore */ "@peerbit/native-backbone");
9787
+ } = nativeBackboneModule;
9325
9788
  const backbone = await createNativePeerbitBackbone({
9326
9789
  resolution: this.domain.resolution,
9327
9790
  clockId: this.node.identity.publicKey.bytes,
9328
9791
  privateKey: this.node.identity.privateKey.privateKey,
9329
9792
  publicKey: this.node.identity.publicKey.publicKey,
9330
9793
  });
9331
- this._nativeBackboneCoordinatePersistence = options.coordinatePersistence
9332
- ? createNativeBackboneCoordinatePersistence(
9794
+ // Backward compatible: an explicitly supplied coordinate persistence
9795
+ // config always wins and is used unchanged. Otherwise, when the node
9796
+ // runs on durable on-disk storage, auto-derive a per-program store so
9797
+ // replication coordinates survive a clean stop -> restart without a
9798
+ // peer to re-derive from. Memory-only nodes (no directory) keep the
9799
+ // previous in-memory behavior.
9800
+ if (options.coordinatePersistence) {
9801
+ this._nativeBackboneCoordinatePersistence =
9802
+ createNativeBackboneCoordinatePersistence(
9333
9803
  options.coordinatePersistence as RuntimeNativeBackboneCoordinatePersistenceConfig,
9334
- )
9335
- : undefined;
9804
+ );
9805
+ } else {
9806
+ this._nativeBackboneCoordinatePersistence =
9807
+ await this.createAutoDerivedCoordinatePersistence(
9808
+ nativeBackboneModule,
9809
+ );
9810
+ }
9336
9811
  return backbone;
9337
9812
  } catch (error) {
9338
9813
  if (options.optional === false) {
@@ -9347,6 +9822,65 @@ export class SharedLog<
9347
9822
  }
9348
9823
  }
9349
9824
 
9825
+ /**
9826
+ * When the node has a durable on-disk storage directory, build a
9827
+ * per-program coordinate persistence store rooted under it so replication
9828
+ * coordinates auto-persist and survive a clean stop -> restart. Returns
9829
+ * `undefined` for memory-only nodes (no directory), preserving the prior
9830
+ * in-memory behavior.
9831
+ *
9832
+ * Namespacing: `<nodeDirectory>/coordinates/<fsSafe(log.id)>`. The log id
9833
+ * is the same identity used for `storage.sublevel`/`indexer.scope`
9834
+ * (see the `sha256Base64Sync(this.log.id)` above), but that base64 form is
9835
+ * not filesystem-path-safe, so the directory segment uses the hex encoding
9836
+ * of `this.log.id` (only `[0-9a-f]`, no `/`, `+`, or padding).
9837
+ *
9838
+ * Node vs OPFS is chosen with the same signal native-backbone uses to load
9839
+ * its wasm (`globalThis.process?.versions?.node`): Node gets the on-disk
9840
+ * store, browsers get the OPFS store.
9841
+ */
9842
+ private async createAutoDerivedCoordinatePersistence(
9843
+ nativeBackboneModule: typeof import("@peerbit/native-backbone"),
9844
+ ): Promise<NativeBackboneCoordinatePersistenceAdapter | undefined> {
9845
+ const directory = this.node.directory;
9846
+ if (directory == null) {
9847
+ // Memory-only node: keep prior in-memory behavior.
9848
+ return undefined;
9849
+ }
9850
+ const {
9851
+ createNativeBackboneCoordinatePersistence,
9852
+ NativeBackboneNodeCoordinatePersistenceStore,
9853
+ NativeBackboneOPFSCoordinatePersistenceStore,
9854
+ } = nativeBackboneModule;
9855
+ const namespace = toHexString(this.log.id);
9856
+ const isNode = !!(
9857
+ globalThis as { process?: { versions?: { node?: string } } }
9858
+ ).process?.versions?.node;
9859
+ let store: NativeBackboneCoordinatePersistenceStore;
9860
+ if (isNode) {
9861
+ const coordinateDirectory = joinNativeCoordinateDirectory(
9862
+ directory,
9863
+ namespace,
9864
+ );
9865
+ store = new NativeBackboneNodeCoordinatePersistenceStore(
9866
+ coordinateDirectory,
9867
+ );
9868
+ } else {
9869
+ // OPFS stores address by directory parts relative to the OPFS root,
9870
+ // not by an absolute filesystem path, so the node `directory` only
9871
+ // gates activation; the per-program namespace segments keep programs
9872
+ // isolated within the browser's origin-private file system.
9873
+ store = await NativeBackboneOPFSCoordinatePersistenceStore.create({
9874
+ directory: ["coordinates", namespace],
9875
+ });
9876
+ }
9877
+ return createNativeBackboneCoordinatePersistence({
9878
+ store,
9879
+ buffered: true,
9880
+ flushOnAppend: true,
9881
+ });
9882
+ }
9883
+
9350
9884
  private async updateTimestampOfOwnedReplicationRanges(
9351
9885
  timestamp: number = +new Date(),
9352
9886
  ) {