bun-shared-memory 0.2.0 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,81 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.2] - 2026-11-05
11
+
12
+ ### Added
13
+
14
+ - **`null` is now a supported shared value**. A new `TYPE_NULL` slot type
15
+ allows `null` to be stored and read anywhere — top-level, object property,
16
+ array element, nested object/array. `undefined` remains intentionally
17
+ unsupported.
18
+ - **Nested objects and live `Shared` references**: an object property (or array
19
+ element) may now be another plain object, read lazily and mutated in place
20
+ (e.g. `doc.profile.meta.level++` writes directly to shared memory). Two new
21
+ slot types back this: `TYPE_OBJECT_REF` (a nested object block in the
22
+ DataTable) and `TYPE_KEY_REF` (a live reference to another shared key).
23
+ - **Nested `Shared` values**: storing a `Shared` object inside another object
24
+ or array keeps a live reference, so both wrappers stay in sync (no copying).
25
+ - **Nested arrays**: arrays may contain other arrays (stored recursively as
26
+ array-refs in the ArrayTable). `arr[0][1] = x`, `arr[0].push(...)`, etc.
27
+ - **Defragmentation** remaps nested object references and rewrites their
28
+ property slots; the per-worker object-layout cache is re-validated against
29
+ the global layout version so it stays correct across a defrag.
30
+
31
+ ### Performance
32
+
33
+ - Cache the key's **version-cell index in the read cache**, so cache-hit reads
34
+ skip the `versionCellIndex` arithmetic and go straight to
35
+ `Atomics.load(view, cached.cell)`.
36
+ - Hoist **`getLayoutVersion`** inside lock-free writes: within a write (counter
37
+ > 0) the layout version cannot change, so read it once and reuse it for all
38
+ cached-offset and ArrayRef checks, saving 2–3 `Atomics.load` ops per write.
39
+ Array writes improved from ~37× to ~33× slower than native.
40
+
41
+ ### Fixed
42
+
43
+ - Fix `copyBytes` in the defrag compactor so overlapping source/destination
44
+ ranges (a small string-value block compacting forward over the source region
45
+ of a later array block) are copied overlap-safely. This previously could
46
+ corrupt array contents after a defragmentation that involved interleaved
47
+ string-value and array blocks.
48
+
49
+ ## [0.2.1] - 2026-11-04
50
+
51
+ ### Performance
52
+
53
+ - Inline the read-cache fast path in `readProp`/`readValue` instead of calling
54
+ `readCachedValue`, which returned a polymorphic `{hit,value}` union and
55
+ allocated an object on every cache hit. Keeping the read path monomorphic
56
+ lets the JIT optimize it: number reads dropped from ~12× to ~6× slower than
57
+ native, boolean reads from ~8.4× to ~5.5×, and all array index reads
58
+ improved (array-read summary ~9.7× → ~8.7×).
59
+ - Add `updateBigIntSlot`/`updateArrayBigIntSlot` fast paths that write int64
60
+ bigints directly into the slot, avoiding the `ValueSlot` object allocation
61
+ of the generic encode path. `bigint[]` index writes dropped from ~24.8× to
62
+ ~18× slower than native.
63
+ - Extract the array-proxy set handler body into a named `writeArrayProp`
64
+ function and inline the `writeLock`, so no closure is allocated per write.
65
+ - Cache the key's version-cell index in the array proxy so writes bump the
66
+ version without a per-write Map lookup. Object array-prop index writes
67
+ dropped from ~33.9× to ~29× slower than native.
68
+ - Real-world benchmark: the shared-memory "get user" endpoint improved to
69
+ ~5.15M req/s, widening the gap over the comparison
70
+ backends (sqlite 9.9×, redis 76.5×, mysql 82.0× slower than
71
+ shared memory).
72
+
73
+ ### Benchmark
74
+
75
+ - Increase `SAMPLES_PER_WORKER` from 5 to 15 for more stable per-run statistics.
76
+ - Increase `RUNS_PER_METHOD` from 3 to 5 for better CPU-contention filtering.
77
+ - Increase warmup from 1,000 to 5,000 iterations so the JIT and DB caches
78
+ are fully warmed before measurement.
79
+ - Add `?pool=8` to MySQL connection strings to avoid per-request connect overhead.
80
+ - Add priming queries (`SELECT LIMIT 1` for MySQL, `PING` for Redis) so the
81
+ first measured request does not hit cold-connect latency.
82
+
8
83
  ## [0.2.0] - 2026-11-04
9
84
 
10
85
  ### Added
package/README.md CHANGED
@@ -38,20 +38,20 @@ is benchmarked sequentially with 4 workers. See
38
38
 
39
39
  Measured on Linux (Bun 1.4.4, 100,000 requests per methodology, sorted by
40
40
  speed; MySQL/Redis benchmarked over both TCP and Unix socket; each methodology
41
- is the minimum over 3 runs × 5 samples to filter CPU-contention noise):
41
+ is the minimum over 5 runs × 15 samples to filter CPU-contention noise):
42
42
 
43
43
  | Methodology | Transport | Requests | Elapsed (ms) | req/s | vs. shared |
44
44
  | ----------- | --------- | -------- | ------------ | --------- | ------------- |
45
- | `shared` | — | 100,000 | 21 | 4,876,492 | — |
46
- | `sqlite` | — | 100,000 | 188 | 531,116 | 9.2× slower |
47
- | `redis` | socket | 100,000 | 1,262 | 79,229 | 61.5× slower |
48
- | `mysql` | socket | 100,000 | 1,605 | 62,322 | 78.2× slower |
49
- | `redis` | tcp | 100,000 | 2,817 | 35,497 | 137.4× slower |
50
- | `mysql` | tcp | 100,000 | 2,976 | 33,602 | 145.1× slower |
51
-
52
- Shared memory is the fastest by a wide margin: ~9.2× faster than a live SQLite
53
- read and ~61–145× faster than live MySQL/Redis reads. Unix sockets are
54
- significantly faster than TCP for MySQL/Redis (~1.8×), but both remain far
45
+ | `shared` | — | 100,000 | 19 | 5,154,977 | — |
46
+ | `sqlite` | — | 100,000 | 192 | 520,329 | 9.9× slower |
47
+ | `redis` | socket | 100,000 | 1,484 | 67,385 | 76.5× slower |
48
+ | `mysql` | socket | 100,000 | 1,591 | 62,865 | 82.0× slower |
49
+ | `redis` | tcp | 100,000 | 2,617 | 38,205 | 134.9× slower |
50
+ | `mysql` | tcp | 100,000 | 3,133 | 31,922 | 161.5× slower |
51
+
52
+ Shared memory is the fastest by a wide margin: ~9.9× faster than a live SQLite
53
+ read and ~61–161× faster than live MySQL/Redis reads. Unix sockets are
54
+ significantly faster than TCP for MySQL/Redis (~1.7–1.8×), but both remain far
55
55
  slower than shared memory.
56
56
 
57
57
  ## ✨ Features
@@ -90,7 +90,10 @@ See [SPEC.md](./SPEC.md) for the full specification.
90
90
  - **Allowed**: primitives (`number`, `string`, `boolean`), `bigint`, and arrays of these (mixed arrays allowed).
91
91
  - **BigInts** of any size are supported: values that fit in 64 bits are stored as a fast int64 slot; larger values are stored as their decimal string in the StringTable.
92
92
  - **Objects** have a **fixed shape**: once created, properties may not be added or removed — only their values can change.
93
- - **Not allowed**: nested objects, nested arrays, `null`, `undefined`.
93
+ - **Nested objects and arrays** are supported: an object or array can contain other plain objects and arrays (read lazily, mutated in place).
94
+ - **Nested `Shared` references**: a `Shared` object can be stored inside another object or array as a _live reference_ — both stay in sync (mutating either updates the same shared value). Self-references and mutual references are supported as long as the property exists in the object's initial fixed shape.
95
+ - **`null` is allowed** as a value (top-level, object property, or array element).
96
+ - **Not allowed**: `undefined`.
94
97
 
95
98
  ## Usage
96
99
 
@@ -123,6 +126,19 @@ const arr = Shared<number[]>("arr-1", [1, 2, 3]);
123
126
  arr.push(4);
124
127
  arr[0] = 99;
125
128
 
129
+ // Nested objects are read lazily and mutated in place
130
+ const doc = Shared("doc-1", {
131
+ profile: { name: "Alice", meta: { level: 3 } },
132
+ });
133
+ doc.profile.meta.level++;
134
+ doc.profile.meta.level; // 4
135
+
136
+ // Nested Shared values are live references that stay in sync
137
+ const settings = Shared("settings-1", { theme: "dark" });
138
+ const app = Shared("app-1", { settings });
139
+ app.settings.theme = "light";
140
+ settings.theme; // "light" (same shared value)
141
+
126
142
  // Primitives use read()/write()/update()
127
143
  const counter = Shared<number>("counter-1", 0);
128
144
  counter.write(42);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-shared-memory",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Share data between Bun workers/threads using SharedArrayBuffer (zero-copy, 4-buffer architecture, Atomics-based synchronization).",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/bun": "latest",
50
- "cofusion": "^0.0.1",
50
+ "cofusion": "^0.0.2",
51
51
  "elysia": "^1.4.30"
52
52
  }
53
53
  }
@@ -425,6 +425,41 @@ export class GrowableBuffer {
425
425
  Atomics.add(this.cursor, 0, BigInt(n));
426
426
  }
427
427
 
428
+ /**
429
+ * Grow a block in place by `extra` bytes when the space immediately after it
430
+ * is free (i.e. the block is the last allocated one, so its end equals the
431
+ * current used length). This lets a caller extend a block without leaving
432
+ * the old prefix as dead space.
433
+ *
434
+ * The cursor is advanced with a compare-exchange loop, so concurrent workers
435
+ * cannot allocate into the space being claimed: if another worker has
436
+ * reserved bytes after `endOffset`, the cursor no longer equals `endOffset`
437
+ * and this returns false, letting the caller fall back to a fresh
438
+ * allocation.
439
+ *
440
+ * @returns true if the block was grown in place, false if the space after it
441
+ * is not free (caller must allocate a new block).
442
+ */
443
+ growInPlace(endOffset: number, extra: number): boolean {
444
+ if (extra <= 0) return true;
445
+ while (true) {
446
+ const current = Number(Atomics.load(this.cursor, 0));
447
+ if (current !== endOffset) return false;
448
+ const next = current + extra;
449
+ if (
450
+ Atomics.compareExchange(
451
+ this.cursor,
452
+ 0,
453
+ BigInt(current),
454
+ BigInt(next),
455
+ ) === BigInt(current)
456
+ ) {
457
+ this.ensureCapacity(next);
458
+ return true;
459
+ }
460
+ }
461
+ }
462
+
428
463
  /**
429
464
  * Atomically reserve `size` bytes at the end of the buffer and return the
430
465
  * start offset. This is the lock-free bump-allocation primitive: the shared
package/src/memory.ts CHANGED
@@ -56,6 +56,21 @@ export const TYPE_STRING_VALUE_REF = 7;
56
56
  * the a/b/c fields), avoiding a heap allocation and the string-table write.
57
57
  */
58
58
  export const TYPE_INLINE_STRING = 8;
59
+ /**
60
+ * A nested object block stored in the DataTable. `a` is the DataTable byte
61
+ * offset of the object block (same layout as a top-level object).
62
+ */
63
+ export const TYPE_OBJECT_REF = 9;
64
+ /**
65
+ * A live reference to another shared key (used for nested `Shared` objects so
66
+ * changes stay in sync). `a` is the referenced key's stable Index Table index.
67
+ */
68
+ export const TYPE_KEY_REF = 10;
69
+ /**
70
+ * The `null` value. No payload in a/b/c — the presence of the slot's type is
71
+ * the entire value. `undefined` is intentionally still not representable.
72
+ */
73
+ export const TYPE_NULL = 11;
59
74
 
60
75
  const SLOT_SIZE = 16;
61
76
  const OBJECT_HEADER = 16;
@@ -96,6 +111,12 @@ const slotView = new DataView(new ArrayBuffer(8));
96
111
  export interface StringRef {
97
112
  offset: number;
98
113
  length: number;
114
+ /**
115
+ * Reserved block size in the ArrayTable (>= ARRAY_HEADER + length). Only
116
+ * set for string values stored in the ArrayTable; used to grow a block in
117
+ * place when a string is overwritten with a longer one.
118
+ */
119
+ reserved?: number;
99
120
  }
100
121
 
101
122
  export interface ArrayRef {
@@ -159,7 +180,12 @@ export function stringRefSlot(ref: StringRef): ValueSlot {
159
180
 
160
181
  /** Encode a string value (in the ArrayTable) into a ValueSlot. */
161
182
  export function stringValueRefSlot(ref: StringRef): ValueSlot {
162
- return { type: TYPE_STRING_VALUE_REF, a: ref.offset, b: ref.length, c: 0 };
183
+ return {
184
+ type: TYPE_STRING_VALUE_REF,
185
+ a: ref.offset,
186
+ b: ref.length,
187
+ c: ref.reserved ?? 0,
188
+ };
163
189
  }
164
190
 
165
191
  /** Encode a bigint stored as a decimal string into a ValueSlot. */
@@ -172,6 +198,21 @@ export function arrayRefSlot(ref: ArrayRef): ValueSlot {
172
198
  return { type: TYPE_ARRAY_REF, a: ref.offset, b: ref.count, c: ref.capacity };
173
199
  }
174
200
 
201
+ /** Encode a reference to a nested object block (DataTable offset). */
202
+ export function objectRefSlot(offset: number): ValueSlot {
203
+ return { type: TYPE_OBJECT_REF, a: offset, b: 0, c: 0 };
204
+ }
205
+
206
+ /** Encode a live reference to another shared key (by stable Index Table index). */
207
+ export function keyRefSlot(index: number): ValueSlot {
208
+ return { type: TYPE_KEY_REF, a: index, b: 0, c: 0 };
209
+ }
210
+
211
+ /** Encode the `null` value. */
212
+ export function nullSlot(): ValueSlot {
213
+ return { type: TYPE_NULL, a: 0, b: 0, c: 0 };
214
+ }
215
+
175
216
  /** Decode a ValueSlot into a JS value (primitives) or a ref object. */
176
217
  export function decodeSlot(slot: ValueSlot): unknown {
177
218
  switch (slot.type) {
@@ -191,8 +232,14 @@ export function decodeSlot(slot: ValueSlot): unknown {
191
232
  return { offset: slot.a, length: slot.b };
192
233
  case TYPE_STRING_VALUE_REF:
193
234
  return { offset: slot.a, length: slot.b };
235
+ case TYPE_NULL:
236
+ return null;
194
237
  case TYPE_ARRAY_REF:
195
238
  return { offset: slot.a, count: slot.b, capacity: slot.c };
239
+ case TYPE_OBJECT_REF:
240
+ return { offset: slot.a };
241
+ case TYPE_KEY_REF:
242
+ return { index: slot.a };
196
243
  default:
197
244
  throw new Error(`Unknown slot type ${slot.type}`);
198
245
  }
@@ -675,13 +722,34 @@ export class SharedMemory {
675
722
  // unbounded ArrayTable growth. The block's objectId header is preserved.
676
723
  if (oldRef !== undefined && size <= ARRAY_HEADER + oldRef.length) {
677
724
  this.arrayTable.writeBytes(oldRef.offset + ARRAY_HEADER, bytes);
678
- return { offset: oldRef.offset, length: bytes.byteLength };
725
+ return {
726
+ offset: oldRef.offset,
727
+ length: bytes.byteLength,
728
+ reserved: oldRef.reserved ?? size,
729
+ };
730
+ }
731
+ // The new string is longer than the old block. If the old block is the
732
+ // last allocated block (its reserved end is the current used length), grow
733
+ // it in place so repeated writes to the same property don't leak dead
734
+ // blocks. This also covers the shrink-then-grow case: a block that was
735
+ // reserved for a longer string but currently holds a shorter one can be
736
+ // grown back into its own reserved tail.
737
+ if (oldRef !== undefined && oldRef.reserved !== undefined) {
738
+ const reservedEnd = oldRef.offset + oldRef.reserved;
739
+ if (this.arrayTable.growInPlace(reservedEnd, size - oldRef.reserved)) {
740
+ this.arrayTable.writeBytes(oldRef.offset + ARRAY_HEADER, bytes);
741
+ return {
742
+ offset: oldRef.offset,
743
+ length: bytes.byteLength,
744
+ reserved: Math.max(oldRef.reserved, size),
745
+ };
746
+ }
679
747
  }
680
748
  const offset = this.arrayTable.reserve(size);
681
749
  const objectId = this.nextObjectId++;
682
750
  this.arrayTable.setInt32(offset, objectId);
683
751
  this.arrayTable.writeBytes(offset + ARRAY_HEADER, bytes);
684
- return { offset, length: bytes.byteLength };
752
+ return { offset, length: bytes.byteLength, reserved: size };
685
753
  }
686
754
 
687
755
  /** Read a string value from the ArrayTable by reference. */
@@ -812,6 +880,20 @@ export class SharedMemory {
812
880
  buf.setInt32(off + 12, 0);
813
881
  }
814
882
 
883
+ /**
884
+ * Fast-path: write an int64 bigint into an array slot directly, avoiding
885
+ * the ValueSlot object allocation and the 4 separate Int32 writes.
886
+ */
887
+ updateArrayBigIntSlot(ref: ArrayRef, index: number, value: bigint): void {
888
+ slotView.setBigInt64(0, value, true);
889
+ const buf = this.arrayTable;
890
+ const off = ref.offset + ARRAY_HEADER + index * SLOT_SIZE;
891
+ buf.setInt32(off, TYPE_BIGINT);
892
+ buf.setInt32(off + 4, slotView.getInt32(0, true));
893
+ buf.setInt32(off + 8, slotView.getInt32(4, true));
894
+ buf.setInt32(off + 12, 0);
895
+ }
896
+
815
897
  /**
816
898
  * Fast-path: write a short string inline into an array slot (up to 11
817
899
  * UTF-8 bytes), avoiding a heap allocation and the string-table write.
@@ -970,6 +1052,19 @@ export class SharedMemory {
970
1052
  buf.setInt32(offset + 12, 0);
971
1053
  }
972
1054
 
1055
+ /**
1056
+ * Fast-path: write an int64 bigint slot directly into the DataTable,
1057
+ * avoiding the ValueSlot object allocation of the generic path.
1058
+ */
1059
+ updateBigIntSlot(offset: number, value: bigint): void {
1060
+ slotView.setBigInt64(0, value, true);
1061
+ const buf = this.dataTable;
1062
+ buf.setInt32(offset, TYPE_BIGINT);
1063
+ buf.setInt32(offset + 4, slotView.getInt32(0, true));
1064
+ buf.setInt32(offset + 8, slotView.getInt32(4, true));
1065
+ buf.setInt32(offset + 12, 0);
1066
+ }
1067
+
973
1068
  /**
974
1069
  * Fast-path: write a short string inline into the slot itself (up to 11
975
1070
  * UTF-8 bytes), avoiding a heap allocation and the string-table write.
@@ -1090,6 +1185,14 @@ export class SharedMemory {
1090
1185
  getObjectLayout(key: string): ObjectLayout | null {
1091
1186
  const offset = this.lookup(key);
1092
1187
  if (offset === null) return null;
1188
+ return this.getObjectLayoutAtOffset(offset);
1189
+ }
1190
+
1191
+ /**
1192
+ * Get the stable layout of an object block at a DataTable offset (top-level
1193
+ * or nested). Returns null if the offset is not an object block.
1194
+ */
1195
+ getObjectLayoutAtOffset(offset: number): ObjectLayout | null {
1093
1196
  if (this.dataTable.getInt32(offset) !== TYPE_OBJECT) return null;
1094
1197
  const count = this.dataTable.getInt32(offset + 4);
1095
1198
  const properties = new Map<string, number>();
@@ -1162,6 +1265,20 @@ export class SharedMemory {
1162
1265
  return null;
1163
1266
  }
1164
1267
 
1268
+ /**
1269
+ * Read the key string for a known stable index. Used to resolve a live
1270
+ * shared-key reference (TYPE_KEY_REF) back to its key so a wrapper can be
1271
+ * reconstructed. Returns null if the index is out of range.
1272
+ */
1273
+ getKeyByIndex(index: number): string | null {
1274
+ const count = this.indexTable.getInt32(0);
1275
+ if (index >= count) return null;
1276
+ const entryOffset = INDEX_HEADER + index * INDEX_ENTRY_SIZE;
1277
+ const keyOffset = this.indexTable.getInt32(entryOffset);
1278
+ const keyLength = this.indexTable.getInt32(entryOffset + 4);
1279
+ return this.readString({ offset: keyOffset, length: keyLength });
1280
+ }
1281
+
1165
1282
  /**
1166
1283
  * Int32Array index of a key's version cell within the Index Table buffer.
1167
1284
  * Stable for the lifetime of the key (the Index Table is never reordered or
@@ -1312,12 +1429,21 @@ export class SharedMemory {
1312
1429
  stringValueBlocks.add(slot.a);
1313
1430
  arrayBlockSizes.set(
1314
1431
  slot.a,
1315
- Math.max(ARRAY_HEADER + slot.b, MIN_BLOCK_SIZE),
1432
+ Math.max(slot.c || ARRAY_HEADER + slot.b, MIN_BLOCK_SIZE),
1316
1433
  );
1317
1434
  break;
1318
1435
  case TYPE_ARRAY_REF:
1319
1436
  markArray(slot.a, slot.b, slot.c);
1320
1437
  break;
1438
+ case TYPE_OBJECT_REF:
1439
+ // Nested object block lives in the DataTable; mark it recursively.
1440
+ markObject(slot.a);
1441
+ break;
1442
+ case TYPE_KEY_REF:
1443
+ // A live reference to another shared key. The referenced key is
1444
+ // marked separately when the Index Table is walked (Phase 1), so
1445
+ // nothing to do here.
1446
+ break;
1321
1447
  }
1322
1448
  };
1323
1449
 
@@ -1433,14 +1559,14 @@ export class SharedMemory {
1433
1559
  const nameOffset = this.dataTable.getInt32(cursor);
1434
1560
  this.dataTable.setInt32(cursor, stringRemap.get(nameOffset)!);
1435
1561
  cursor += NAME_REF_SIZE;
1436
- remapSlot(this.dataTable, cursor, stringRemap, arrayRemap);
1562
+ remapSlot(this.dataTable, cursor, stringRemap, arrayRemap, objectRemap);
1437
1563
  cursor += SLOT_SIZE;
1438
1564
  }
1439
1565
  }
1440
1566
  for (const oldOffset of sortedData) {
1441
1567
  if (this.dataTable.getInt32(oldOffset) === TYPE_OBJECT) continue;
1442
1568
  const newOff = objectRemap.get(oldOffset)!;
1443
- remapSlot(this.dataTable, newOff, stringRemap, arrayRemap);
1569
+ remapSlot(this.dataTable, newOff, stringRemap, arrayRemap, objectRemap);
1444
1570
  }
1445
1571
  for (const oldOffset of sortedArrays) {
1446
1572
  if (stringValueBlocks.has(oldOffset)) continue;
@@ -1453,6 +1579,7 @@ export class SharedMemory {
1453
1579
  newOff + ARRAY_HEADER + i * SLOT_SIZE,
1454
1580
  stringRemap,
1455
1581
  arrayRemap,
1582
+ objectRemap,
1456
1583
  );
1457
1584
  }
1458
1585
  }
@@ -1487,15 +1614,25 @@ function readSlot(buf: GrowableBuffer, offset: number): ValueSlot {
1487
1614
  };
1488
1615
  }
1489
1616
 
1490
- /** Copy `len` bytes from `src` to `dst` in the same buffer (dst <= src). */
1617
+ /** Copy `len` bytes from `src` to `dst` in the same buffer, overlap-safe. */
1491
1618
  function copyBytes(
1492
1619
  buf: GrowableBuffer,
1493
1620
  dst: number,
1494
1621
  src: number,
1495
1622
  len: number,
1496
1623
  ): void {
1497
- for (let i = 0; i < len; i++) {
1498
- buf.setUint8(dst + i, buf.getUint8(src + i));
1624
+ // `dst` may be greater than `src` when a small block compacts forward over
1625
+ // the tail of a later block still holding its old data (e.g. string-value
1626
+ // blocks interleaved with array blocks). Copy backwards in that case, like
1627
+ // memmove, so overlapping source/destination ranges aren't corrupted.
1628
+ if (dst > src) {
1629
+ for (let i = len - 1; i >= 0; i--) {
1630
+ buf.setUint8(dst + i, buf.getUint8(src + i));
1631
+ }
1632
+ } else {
1633
+ for (let i = 0; i < len; i++) {
1634
+ buf.setUint8(dst + i, buf.getUint8(src + i));
1635
+ }
1499
1636
  }
1500
1637
  }
1501
1638
 
@@ -1509,11 +1646,16 @@ function remapSlot(
1509
1646
  offset: number,
1510
1647
  stringRemap: Map<number, number>,
1511
1648
  arrayRemap: Map<number, number>,
1649
+ objectRemap?: Map<number, number>,
1512
1650
  ): void {
1513
1651
  const type = buf.getInt32(offset);
1514
1652
  if (type === TYPE_STRING_REF || type === TYPE_BIGINT_STR) {
1515
1653
  buf.setInt32(offset + 4, stringRemap.get(buf.getInt32(offset + 4))!);
1516
1654
  } else if (type === TYPE_ARRAY_REF || type === TYPE_STRING_VALUE_REF) {
1517
1655
  buf.setInt32(offset + 4, arrayRemap.get(buf.getInt32(offset + 4))!);
1656
+ } else if (type === TYPE_OBJECT_REF && objectRemap !== undefined) {
1657
+ buf.setInt32(offset + 4, objectRemap.get(buf.getInt32(offset + 4))!);
1518
1658
  }
1659
+ // TYPE_KEY_REF (live shared-key reference) needs no remap: it stores the
1660
+ // key's stable Index Table index, which never changes.
1519
1661
  }