diff --git a/.cursor/rules/branch.mdc b/.agent/rules/branch.mdc similarity index 100% rename from .cursor/rules/branch.mdc rename to .agent/rules/branch.mdc diff --git a/.cursor/rules/branch/104-build-command-error.mdc b/.agent/rules/branch/104-build-command-error.mdc similarity index 100% rename from .cursor/rules/branch/104-build-command-error.mdc rename to .agent/rules/branch/104-build-command-error.mdc diff --git a/.cursor/rules/branch/110-env-sample.mdc b/.agent/rules/branch/110-env-sample.mdc similarity index 100% rename from .cursor/rules/branch/110-env-sample.mdc rename to .agent/rules/branch/110-env-sample.mdc diff --git a/.cursor/rules/branch/118-icstablebtree.mdc b/.agent/rules/branch/118-icstablebtree.mdc similarity index 100% rename from .cursor/rules/branch/118-icstablebtree.mdc rename to .agent/rules/branch/118-icstablebtree.mdc diff --git "a/.agent/rules/branch/122-nicp_cdk-icstabletable-btree-\345\210\235\346\234\237\345\214\226\344\270\215\345\205\267\345\220\210\343\201\256\344\277\256\346\255\243.mdc" "b/.agent/rules/branch/122-nicp_cdk-icstabletable-btree-\345\210\235\346\234\237\345\214\226\344\270\215\345\205\267\345\220\210\343\201\256\344\277\256\346\255\243.mdc" new file mode 100644 index 0000000..e2c45b7 --- /dev/null +++ "b/.agent/rules/branch/122-nicp_cdk-icstabletable-btree-\345\210\235\346\234\237\345\214\226\344\270\215\345\205\267\345\220\210\343\201\256\344\277\256\346\255\243.mdc" @@ -0,0 +1,106 @@ +# nicp_cdk `IcStableTable` B+Tree 初期化不具合の修正設計 + +## 背景 + +`IcStableTable` を B+Tree 実装へ更新した backend canister で、最初の +`create_wallet` が `alignment must be a power of two` で reject される。 + +canister log により、入力検証・alias/address index 照会の後、 +`ensureOwnerProfile` が `ownerProfileDb` へ最初のレコードを保存する段階で +失敗することを確認した。未使用の stable-memory 領域でも再現するため、既存 +stable-memory の破損や application input は原因ではない。 + +## 原因 + +`IcStableTable` の空 B+Tree に最初の値を代入する経路は、`newNode` から +`StableAllocator.allocate` を呼ぶ。 + +```text +IcStableTable.[]=() + -> newNode() + -> alloc(size = nodeSize, alignment = nodeSize) + -> StableAllocator.allocate() +``` + +実行時には `alignment = 0` となる。従って、B+Tree instance の `nodeSize` が +初回 insert 前に `DefaultNodeSize`(1024)へ初期化・復元されていない。 + +## 修正方針 + +### 1. superblock の node size を必ず検証する + +`initIcStableTable` は新規 table と既存 table のどちらでも、return 前に +`nodeSize` が 256 以上かつ 2 のべき乗であることを検証する。 + +- 新規 table: `nodeSize = DefaultNodeSize` を設定してから superblock を書き込む。 +- 既存 SBT2 table: superblock の node size を読み、同じ条件を検証する。 +- `0`、256 未満、2 のべき乗でない値: `ValueError("invalid SBT2 node size")` として + 明示的に拒否する。allocator まで不正な値を渡さない。 + +### 2. node 確保時にも防御的に検証する + +`newNode` の直前に node size の検証を置く。これは破損した header や将来の +初期化回帰が発生しても、`alignment must be a power of two` という原因不明な +エラーではなく、SBT2 layout の不正として失敗させるためである。 + +```nim +proc ensureValidNodeSize(nodeSize: uint32) = + if nodeSize < 256 or (nodeSize and (nodeSize - 1)) != 0: + raise newException(ValueError, "invalid SBT2 node size") +``` + +`initIcStableTable` の新規作成時、`readHeader` の既存読み込み時、`newNode` の +3 箇所でこの検証を利用する。 + +### 3. 永続フォーマットの扱い + +node size が 0 の SBT2 header は正常な table として扱わない。空であることを +推測して自動 `clear` すると、破損した non-empty table を消去する危険がある。 + +- 既存 SBT2 header が不正: init を失敗させ、運用者に restore または明示 migration + を要求する。 +- 新規 table: 正しい `DefaultNodeSize` を含む header を必ず書き込む。 +- STBL v1: 現行方針どおり明示 migration を要求し、暗黙変換しない。 + +## 実装対象 + +- `src/nicp_cdk/storage/libs/stable_btree.nim` + - node size validation helper の追加 + - `initIcStableTable` の新規・既存 header 経路を修正 + - `newNode` 前の防御検証を追加 +- `tests/storage/test_stable_btree.nim` + - 新規 table の初回 insert が成功するテスト + - reopen 後の初回/継続 insert が成功するテスト + - node size が 0 の superblock を注入した場合に `invalid SBT2 node size` で + 失敗するテスト +- stable-memory canister example + - `table_set` による空 table への初回書き込みを local IC で検証する。 + +## 進捗 + +- [x] SBT2 の node size を共通ヘルパーで検証し、新規作成・既存 header 読み込み・node 確保前に適用 +- [x] 新規 table の初回 insert、reopen 後の継続 insert、node size が 0 の superblock 拒否をユニットテスト化 +- [x] local IC で空 table に対する `table_set` の初回書き込みを確認 +- [x] 独自 object 型の value について、更新後も reopen をまたいで復元できることをテスト化 + +## 検証結果 + +- `nim c -r -d:nicpMemoryViewOnly --skipUserCfg tests/storage/test_stable_btree.nim` +- `nim c -r -d:nicpMemoryViewOnly --skipUserCfg tests/storage/test_stable_btree_api.nim` +- local IC: `table_reset` 後の `table_set("initial", "value")` が成功し、`table_get` は `("value")`、`table_len` は `(1 : nat)` を返却 + +## 受け入れ条件 + +1. 空の `IcStableTable` への最初の `[]=` が成功する。 +2. `nodeSize` が常に 256 以上の 2 のべき乗である。 +3. 不正な SBT2 superblock は allocator 呼び出し前に明示エラーになる。 +4. backend の `create_wallet` が `ensureOwnerProfile` を通過し、wallet と各 index を + 保存できる。 +5. table 初期化は header と bounded cache のみを読み、全 key/value を heap に復元 + しない。 + +## 非対象 + +- STBL v1 から SBT2 へのデータ migration 実装 +- B+Tree node size 以外の allocator/free-list 設計変更 +- application 側での空 table 自動 `clear` による回避 diff --git a/.cursor/rules/branch/18-t-ecdsa-arg.mdc b/.agent/rules/branch/18-t-ecdsa-arg.mdc similarity index 100% rename from .cursor/rules/branch/18-t-ecdsa-arg.mdc rename to .agent/rules/branch/18-t-ecdsa-arg.mdc diff --git a/.cursor/rules/branch/19-record.mdc b/.agent/rules/branch/19-record.mdc similarity index 100% rename from .cursor/rules/branch/19-record.mdc rename to .agent/rules/branch/19-record.mdc diff --git a/.cursor/rules/branch/28-async.mdc b/.agent/rules/branch/28-async.mdc similarity index 100% rename from .cursor/rules/branch/28-async.mdc rename to .agent/rules/branch/28-async.mdc diff --git a/.cursor/rules/branch/33-sign-verify.mdc b/.agent/rules/branch/33-sign-verify.mdc similarity index 100% rename from .cursor/rules/branch/33-sign-verify.mdc rename to .agent/rules/branch/33-sign-verify.mdc diff --git a/.cursor/rules/branch/37-http-outcall.mdc b/.agent/rules/branch/37-http-outcall.mdc similarity index 100% rename from .cursor/rules/branch/37-http-outcall.mdc rename to .agent/rules/branch/37-http-outcall.mdc diff --git a/.cursor/rules/branch/38-ethereum-send-transaction.mdc b/.agent/rules/branch/38-ethereum-send-transaction.mdc similarity index 100% rename from .cursor/rules/branch/38-ethereum-send-transaction.mdc rename to .agent/rules/branch/38-ethereum-send-transaction.mdc diff --git a/.cursor/rules/branch/43-fix-ic-function.mdc b/.agent/rules/branch/43-fix-ic-function.mdc similarity index 100% rename from .cursor/rules/branch/43-fix-ic-function.mdc rename to .agent/rules/branch/43-fix-ic-function.mdc diff --git a/.cursor/rules/branch/50-fix-project-create-command.mdc b/.agent/rules/branch/50-fix-project-create-command.mdc similarity index 100% rename from .cursor/rules/branch/50-fix-project-create-command.mdc rename to .agent/rules/branch/50-fix-project-create-command.mdc diff --git a/.cursor/rules/branch/52-sign-with-nonce.mdc b/.agent/rules/branch/52-sign-with-nonce.mdc similarity index 100% rename from .cursor/rules/branch/52-sign-with-nonce.mdc rename to .agent/rules/branch/52-sign-with-nonce.mdc diff --git a/.cursor/rules/branch/55-estimate-cycle.mdc b/.agent/rules/branch/55-estimate-cycle.mdc similarity index 100% rename from .cursor/rules/branch/55-estimate-cycle.mdc rename to .agent/rules/branch/55-estimate-cycle.mdc diff --git a/.cursor/rules/branch/59-test-sign-message.mdc b/.agent/rules/branch/59-test-sign-message.mdc similarity index 100% rename from .cursor/rules/branch/59-test-sign-message.mdc rename to .agent/rules/branch/59-test-sign-message.mdc diff --git a/.cursor/rules/branch/63-estimate-gas-cost.mdc b/.agent/rules/branch/63-estimate-gas-cost.mdc similarity index 100% rename from .cursor/rules/branch/63-estimate-gas-cost.mdc rename to .agent/rules/branch/63-estimate-gas-cost.mdc diff --git a/.cursor/rules/branch/64-upgrade-storage.mdc b/.agent/rules/branch/64-upgrade-storage.mdc similarity index 100% rename from .cursor/rules/branch/64-upgrade-storage.mdc rename to .agent/rules/branch/64-upgrade-storage.mdc diff --git a/.cursor/rules/branch/72-update-httpoutcall.mdc b/.agent/rules/branch/72-update-httpoutcall.mdc similarity index 100% rename from .cursor/rules/branch/72-update-httpoutcall.mdc rename to .agent/rules/branch/72-update-httpoutcall.mdc diff --git a/.cursor/rules/branch/83-iterators-for-icrecord.mdc b/.agent/rules/branch/83-iterators-for-icrecord.mdc similarity index 100% rename from .cursor/rules/branch/83-iterators-for-icrecord.mdc rename to .agent/rules/branch/83-iterators-for-icrecord.mdc diff --git a/.cursor/rules/branch/86-vetkey.mdc b/.agent/rules/branch/86-vetkey.mdc similarity index 100% rename from .cursor/rules/branch/86-vetkey.mdc rename to .agent/rules/branch/86-vetkey.mdc diff --git a/.cursor/rules/branch/87-dfx-to-icp-cli.mdc b/.agent/rules/branch/87-dfx-to-icp-cli.mdc similarity index 100% rename from .cursor/rules/branch/87-dfx-to-icp-cli.mdc rename to .agent/rules/branch/87-dfx-to-icp-cli.mdc diff --git a/.cursor/rules/branch/encode-http-request-arg.mdc b/.agent/rules/branch/encode-http-request-arg.mdc similarity index 100% rename from .cursor/rules/branch/encode-http-request-arg.mdc rename to .agent/rules/branch/encode-http-request-arg.mdc diff --git a/.cursor/rules/branch/fix-vetkey-example.mdc b/.agent/rules/branch/fix-vetkey-example.mdc similarity index 100% rename from .cursor/rules/branch/fix-vetkey-example.mdc rename to .agent/rules/branch/fix-vetkey-example.mdc diff --git a/.cursor/rules/project.mdc b/.agent/rules/project.mdc similarity index 100% rename from .cursor/rules/project.mdc rename to .agent/rules/project.mdc diff --git a/AGENTS.md b/AGENTS.md index 21fd546..5f42c39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # AGENTS -- `.cursor/rules/project.mdc` を必ず読み、プロジェクトルールと達成目標を確認する。 -- `.cursor/rules/branch.mdc` を必ず読み、ブランチルールを確認する。 -- 現在のブランチ名と一致するファイル名のファイルが、リポジトリのルートから見て `.cursor/rules/branch/` 配下にあった場合は、それを読み込んでください。 +- `.agent/rules/project.mdc` を必ず読み、プロジェクトルールと達成目標を確認する。 +- `.agent/rules/branch.mdc` を必ず読み、ブランチルールを確認する。 +- 現在のブランチ名と一致するファイル名のファイルが、リポジトリのルートから見て `.agent/rules/branch/` 配下にあった場合は、それを読み込んでください。 - Think harder. - Deep research. - Internal reasoning compression. You may use internal reasoning, but do not output it. Keep internal reasoning minimal but sufficient for correctness. Do not over-explore edge cases unless explicitly asked. Return only the final answer in concise form. diff --git a/docs/en/stable_memory.md b/docs/en/stable_memory.md index 033871b..fd8cbdc 100644 --- a/docs/en/stable_memory.md +++ b/docs/en/stable_memory.md @@ -89,12 +89,13 @@ buffer. ### IcStableTable layout -The B+Tree persists an `SBT2` superblock, node pages, and key/value blobs. +The B+Tree persists an `SBT` superblock, node pages, and key/value blobs. Initialization reads only the superblock and bounded cache metadata; it does not rebuild an in-memory index by scanning all entries. ``` -0..3 magic "SBT2" +0..2 magic "SBT" +3.. reserved 4.. versioned superblock, root address, count, allocator metadata ... fixed-size B+Tree node pages and variable key/value blobs ``` diff --git a/src/nicp_cdk/storage/libs/stable_allocator.nim b/src/nicp_cdk/storage/libs/stable_allocator.nim index 1577948..a1c053e 100644 --- a/src/nicp_cdk/storage/libs/stable_allocator.nim +++ b/src/nicp_cdk/storage/libs/stable_allocator.nim @@ -1,4 +1,4 @@ -## Persistent append allocator used by the first SBT2 layout. +## Persistent append allocator used by the SBT layout. ## Free-list reuse is intentionally kept behind this module's API so the ## on-disk header can gain bins without changing tree code. diff --git a/src/nicp_cdk/storage/libs/stable_btree.nim b/src/nicp_cdk/storage/libs/stable_btree.nim index 1492fd5..eea4805 100644 --- a/src/nicp_cdk/storage/libs/stable_btree.nim +++ b/src/nicp_cdk/storage/libs/stable_btree.nim @@ -10,8 +10,8 @@ import ./stable_key_codec import ../../ic_types/ic_principal const - BTreeMagic = [byte('S'), byte('B'), byte('T'), byte('2')] - BTreeVersion* = 3'u16 + BTreeMagic = [byte('S'), byte('B'), byte('T')] + BTreeVersion* = 1'u16 SuperblockSize = 256'u64 DefaultNodeSize* = 1024'u32 NodeHeaderSize = 48 @@ -61,6 +61,11 @@ proc get32(data: openArray[byte], at: int): uint32 = littleEndian32(addr result, unsafeAddr data[at]) proc get64(data: openArray[byte], at: int): uint64 = littleEndian64(addr result, unsafeAddr data[at]) + +proc ensureValidNodeSize(nodeSize: uint32) = + if nodeSize < 256 or (nodeSize and (nodeSize - 1)) != 0: + raise newException(ValueError, "invalid SBT node size") + proc capacity(t: IcStableTable): int = (int(t.nodeSize) - NodeHeaderSize) div SlotSize proc encodeKey[K, V](t: IcStableTable[K, V], key: K): seq[byte] = @@ -82,7 +87,7 @@ proc decodeKey[K, V](t: IcStableTable[K, V], data: openArray[byte]): K = proc writeHeader[K, V](t: IcStableTable[K, V]) = var b = newSeq[byte](int(SuperblockSize)) - for i in 0 .. 3: b[i] = BTreeMagic[i] + for i in 0 .. 2: b[i] = BTreeMagic[i] b.put32(4, uint32(BTreeVersion)); b.put32(8, t.nodeSize) b.put32(12, t.keyCodecId); b.put32(16, t.valueCodecId) b.put64(24, t.header.count); b.put64(32, t.header.rootAddr) @@ -93,23 +98,22 @@ proc writeHeader[K, V](t: IcStableTable[K, V]) = proc readHeader[K, V](t: var IcStableTable[K, V]): bool = if t.memory.size < SuperblockSize: return false let b = t.memory.read(0, SuperblockSize) - for i in 0 .. 3: + for i in 0 .. 2: if b[i] != BTreeMagic[i]: return false if b.get32(4) != uint32(BTreeVersion): - raise newException(ValueError, "unsupported SBT2 layout version") + raise newException(ValueError, "unsupported SBT layout version") t.nodeSize = b.get32(8) - if t.nodeSize < 256 or (t.nodeSize and (t.nodeSize - 1)) != 0: - raise newException(ValueError, "invalid SBT2 node size") + ensureValidNodeSize(t.nodeSize) let storedKeyCodecId = b.get32(12) - if storedKeyCodecId != t.keyCodecId: raise newException(ValueError, "SBT2 key codec mismatch") + if storedKeyCodecId != t.keyCodecId: raise newException(ValueError, "SBT key codec mismatch") let storedValueCodecId = b.get32(16) - if storedValueCodecId != t.valueCodecId: raise newException(ValueError, "SBT2 value codec mismatch") + if storedValueCodecId != t.valueCodecId: raise newException(ValueError, "SBT value codec mismatch") t.keyCodecId = storedKeyCodecId; t.valueCodecId = storedValueCodecId t.header.count = b.get64(24); t.header.rootAddr = b.get64(32) t.header.height = b.get32(40); t.header.firstLeaf = b.get64(48); t.header.lastLeaf = b.get64(56); t.header.blobFreeHead = b.get64(64) t.header.arenaEnd = b.get64(72) if t.header.arenaEnd < SuperblockSize or t.header.arenaEnd > t.memory.size: - raise newException(ValueError, "invalid SBT2 allocator metadata") + raise newException(ValueError, "invalid SBT allocator metadata") result = true proc readNode[K, V](t: IcStableTable[K, V], address: uint64): Node = @@ -118,12 +122,12 @@ proc readNode[K, V](t: IcStableTable[K, V], address: uint64): Node = let cached = t.cache.entries[slot] if cached.valid and cached.address == address: return cached.node if address < SuperblockSize or address > t.memory.size - uint64(t.nodeSize): - raise newException(ValueError, "SBT2 node address out of bounds") + raise newException(ValueError, "SBT node address out of bounds") let b = t.memory.read(address, uint64(t.nodeSize)) result.kind = b[0] - if result.kind != LeafNode and result.kind != InternalNode: raise newException(ValueError, "invalid SBT2 node type") + if result.kind != LeafNode and result.kind != InternalNode: raise newException(ValueError, "invalid SBT node type") let n = int(b.get32(4)) - if n > t.capacity: raise newException(ValueError, "invalid SBT2 node slot count") + if n > t.capacity: raise newException(ValueError, "invalid SBT node slot count") result.prev = b.get64(8); result.next = b.get64(16); result.firstChild = b.get64(24) result.slots = newSeq[Slot](n) for i in 0 ..< n: @@ -134,7 +138,7 @@ proc readNode[K, V](t: IcStableTable[K, V], address: uint64): Node = t.cache.entries[slot] = NodeCacheEntry(address: address, node: result, valid: true) proc writeNode[K, V](t: IcStableTable[K, V], address: uint64, node: Node) = - if node.slots.len > t.capacity: raise newException(ValueError, "SBT2 node overflow") + if node.slots.len > t.capacity: raise newException(ValueError, "SBT node overflow") var b = newSeq[byte](int(t.nodeSize)); b[0] = node.kind; b.put32(4, uint32(node.slots.len)) b.put64(8, node.prev); b.put64(16, node.next); b.put64(24, node.firstChild) for i, s in node.slots: @@ -150,7 +154,7 @@ proc alloc[K, V](t: var IcStableTable[K, V], size, alignment: uint64): uint64 = result = a.allocate(t.memory, size, alignment); t.header.arenaEnd = a.arenaEnd proc readBlobHeader[K, V](t: IcStableTable[K, V], address: uint64): (uint64, uint64) = if address < SuperblockSize or address > t.memory.size - 16'u64: - raise newException(ValueError, "SBT2 blob header out of bounds") + raise newException(ValueError, "SBT blob header out of bounds") let data = t.memory.read(address, 16) (data.get64(0), data.get64(8)) # payload capacity, next free header @@ -180,13 +184,13 @@ proc writeBlob[K, V](t: var IcStableTable[K, V], data: openArray[byte]): uint64 proc freeBlob[K, V](t: var IcStableTable[K, V], payloadAddress: uint64) = if payloadAddress < SuperblockSize + 16'u64: - raise newException(ValueError, "invalid SBT2 blob address") + raise newException(ValueError, "invalid SBT blob address") let headerAddress = payloadAddress - 16'u64 let (capacity, _) = t.readBlobHeader(headerAddress) t.writeBlobHeader(headerAddress, capacity, t.header.blobFreeHead) t.header.blobFreeHead = headerAddress proc readKey[K, V](t: IcStableTable[K, V], s: Slot): seq[byte] = - if s.keyOff > t.memory.size or uint64(s.keyLen) > t.memory.size - s.keyOff: raise newException(ValueError, "SBT2 key blob out of bounds") + if s.keyOff > t.memory.size or uint64(s.keyLen) > t.memory.size - s.keyOff: raise newException(ValueError, "SBT key blob out of bounds") t.memory.read(s.keyOff, uint64(s.keyLen)) proc bytesCompare(a, b: openArray[byte]): int = for i in 0 ..< min(a.len, b.len): @@ -207,6 +211,7 @@ proc childIndex[K, V](t: IcStableTable[K, V], n: Node, key: openArray[byte]): in if bytesCompare(t.readKey(n.slots[mid]), key) <= 0: lo = mid + 1 else: hi = mid lo proc newNode[K, V](t: var IcStableTable[K, V], kind: uint8): uint64 = + ensureValidNodeSize(t.nodeSize) result = t.alloc(uint64(t.nodeSize), uint64(t.nodeSize)); t.writeNode(result, Node(kind: kind)) proc initIcStableTable*[K, V](memory: StableMemoryView, codec: StableKeyCodec[K], cacheSlots: int = 16, @@ -215,8 +220,7 @@ proc initIcStableTable*[K, V](memory: StableMemoryView, codec: StableKeyCodec[K] if codec.id == 0 or codec.encode.isNil or codec.decode.isNil: raise newException(ValueError, "invalid StableKeyCodec") result.memory = memory; result.nodeSize = DefaultNodeSize; result.keyCodecId = codec.id; result.valueCodecId = valueCodecId; result.codec = codec if not result.readHeader: - if memory.size >= 4 and memory.read(0, 4) == @[byte('S'), byte('T'), byte('B'), byte('L')]: - raise newException(ValueError, "STBL v1 detected: explicit migration is required") + ensureValidNodeSize(result.nodeSize) result.header.arenaEnd = SuperblockSize result.writeHeader if cacheSlots > 0: diff --git a/tests/storage/test_stable_btree.nim b/tests/storage/test_stable_btree.nim index 62ec22e..cbb2d44 100644 --- a/tests/storage/test_stable_btree.nim +++ b/tests/storage/test_stable_btree.nim @@ -14,6 +14,14 @@ type InMemoryStable = ref object type CompositeKey = object group: uint16 id: uint16 +type UserProfile = object + id: uint64 + displayName: string + active: bool +type StoredProfile = object + profile: UserProfile + labels: seq[string] + revision: uint32 proc compositeCodec(): StableKeyCodec[CompositeKey] = StableKeyCodec[CompositeKey](id: 1001'u32, @@ -36,6 +44,51 @@ proc memoryView(memory: InMemoryStable): StableMemoryView = ) suite "stable B+Tree": + test "first insert succeeds for a new table and after reopen": + let backing = InMemoryStable(data: @[]) + var tree = initIcStableTable[uint32, string](backing.memoryView()) + tree[1'u32] = "first" + check tree.len == 1 + check tree[1'u32] == "first" + + var reopened = initIcStableTable[uint32, string](backing.memoryView()) + reopened[2'u32] = "second" + check reopened.len == 2 + check reopened[1'u32] == "first" + check reopened[2'u32] == "second" + + test "custom object values persist across updates and reopen": + let backing = InMemoryStable(data: @[]) + let original = StoredProfile( + profile: UserProfile(id: 42'u64, displayName: "Alice", active: true), + labels: @["owner", "verified"], revision: 1'u32 + ) + var tree = initIcStableTable[uint32, StoredProfile](backing.memoryView()) + tree[42'u32] = original + check tree[42'u32] == original + + let updated = StoredProfile( + profile: UserProfile(id: 42'u64, displayName: "Alice Smith", active: false), + labels: @["owner"], revision: 2'u32 + ) + tree[42'u32] = updated + check tree.len == 1 + + var reopened = initIcStableTable[uint32, StoredProfile](backing.memoryView()) + check reopened[42'u32] == updated + + test "zero node size in an SBT superblock is rejected": + let backing = InMemoryStable(data: @[]) + discard initIcStableTable[uint32, string](backing.memoryView()) + for index in 8 .. 11: + backing.data[index] = 0 + + try: + discard initIcStableTable[uint32, string](backing.memoryView()) + check false + except ValueError as error: + check error.msg == "invalid SBT node size" + test "split, ordered iteration, update, and reopen": let backing = InMemoryStable(data: @[]) var tree = initIcStableTable[uint32, string](backing.memoryView(), cacheSlots = 0)