diff --git a/src/lib/ledger.ts b/src/lib/ledger.ts index f6a1130..00e34fd 100644 --- a/src/lib/ledger.ts +++ b/src/lib/ledger.ts @@ -17,7 +17,6 @@ import { LOCK_DEFAULT_INITIAL_RETRY_INTERVAL_MS, LOCK_DEFAULT_MAX_RETRIES, LOCK_STALE_TIMEOUT_MS, - LOCKED_BYTES, LOCKED_BYTES_LENGTH, SUPPORTED_LEDGER_VERSIONS, UNLOCKED_BYTES, @@ -489,7 +488,7 @@ export class KVLedger { currentOffset += result.length + result.errorCorrectionOffset; // Update the header after each read, to make sure we catch any new transactions - this.readHeader(); + await this.readHeader(); } else if (!ignoreReadErrors) { throw new Error("Unexpected end of file"); } @@ -637,7 +636,7 @@ export class KVLedger { } // 2. Prepare lock data - const lockBytes = LOCKED_BYTES; + const lockBytes = new Uint8Array(LOCKED_BYTES_LENGTH); const lockView = new DataView(lockBytes.buffer); const lockId = pseudoRandomTimestamp(BigInt(Date.now()), 11); // A lock id is a regular timestamp with the last 11 bits scrambled lockView.setBigUint64(0, lockId, false); diff --git a/src/lib/transaction.ts b/src/lib/transaction.ts index 6c6904f..e4486f3 100644 --- a/src/lib/transaction.ts +++ b/src/lib/transaction.ts @@ -138,7 +138,7 @@ export class KVTransaction { algorithm?: KVHashAlgorithm, ) { // Validate - if (this.operation === KVOperation.SET && value === undefined) { + if (operation === KVOperation.SET && value === undefined) { throw new Error("Set operation needs data"); } this.key = key; diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..2449d9c --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,232 @@ +import { assertEquals } from "@std/assert"; +import { test } from "@cross/test"; +import { KVIndex } from "../src/lib/index.ts"; +import { KVKeyInstance } from "../src/lib/key.ts"; + +test("KVIndex: add and get single entry", () => { + const index = new KVIndex(); + const key = new KVKeyInstance(["users", "alice"]); + index.add(key, 100); + + const results = index.get(new KVKeyInstance(["users", "alice"], true)); + assertEquals(results, [100]); +}); + +test("KVIndex: get returns empty array for missing key", () => { + const index = new KVIndex(); + const results = index.get(new KVKeyInstance(["nonexistent"], true)); + assertEquals(results, []); +}); + +test("KVIndex: add overwrites existing entry at same key", () => { + const index = new KVIndex(); + const key1 = new KVKeyInstance(["users", "alice"]); + const key2 = new KVKeyInstance(["users", "alice"]); + index.add(key1, 100); + index.add(key2, 200); + + const results = index.get(new KVKeyInstance(["users", "alice"], true)); + assertEquals(results, [200]); +}); + +test("KVIndex: get multiple entries under common prefix", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["users", "alice"]), 100); + index.add(new KVKeyInstance(["users", "bob"]), 200); + index.add(new KVKeyInstance(["users", "carol"]), 300); + + const results = index.get(new KVKeyInstance(["users"], true)); + assertEquals(results.sort((a, b) => a - b), [100, 200, 300]); +}); + +test("KVIndex: get returns results sorted by offset (ascending)", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["data", "c"]), 300); + index.add(new KVKeyInstance(["data", "a"]), 100); + index.add(new KVKeyInstance(["data", "b"]), 200); + + const results = index.get(new KVKeyInstance(["data"], true)); + assertEquals(results, [100, 200, 300]); +}); + +test("KVIndex: get returns results sorted by offset (descending) when reverse=true", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["data", "c"]), 300); + index.add(new KVKeyInstance(["data", "a"]), 100); + index.add(new KVKeyInstance(["data", "b"]), 200); + + const results = index.get(new KVKeyInstance(["data"], true), undefined, true); + assertEquals(results, [300, 200, 100]); +}); + +test("KVIndex: get respects limit", () => { + const index = new KVIndex(); + for (let i = 1; i <= 5; i++) { + index.add(new KVKeyInstance(["data", i]), i * 10); + } + + const results = index.get(new KVKeyInstance(["data"], true), 3); + assertEquals(results.length, 3); + assertEquals(results, [10, 20, 30]); +}); + +test("KVIndex: get with numeric range", () => { + const index = new KVIndex(); + for (let i = 1; i <= 10; i++) { + index.add(new KVKeyInstance(["scores", i]), i * 100); + } + + const results = index.get( + new KVKeyInstance(["scores", { from: 3, to: 6 }], true), + ); + assertEquals(results.sort((a, b) => a - b), [300, 400, 500, 600]); +}); + +test("KVIndex: get with string range", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["files", "doc-a"]), 100); + index.add(new KVKeyInstance(["files", "doc-b"]), 200); + index.add(new KVKeyInstance(["files", "img-1"]), 300); + + const results = index.get( + new KVKeyInstance(["files", { from: "doc-a", to: "doc-z" }], true), + ); + assertEquals(results.sort((a, b) => a - b), [100, 200]); +}); + +test("KVIndex: get with empty range matches all children", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["items", 1]), 10); + index.add(new KVKeyInstance(["items", 2]), 20); + index.add(new KVKeyInstance(["items", 3]), 30); + + const results = index.get(new KVKeyInstance(["items", {}], true)); + assertEquals(results.sort((a, b) => a - b), [10, 20, 30]); +}); + +test("KVIndex: delete removes entry", () => { + const index = new KVIndex(); + const key = new KVKeyInstance(["users", "alice"]); + index.add(key, 100); + + const removed = index.delete(new KVKeyInstance(["users", "alice"])); + assertEquals(removed, 100); + + const results = index.get(new KVKeyInstance(["users", "alice"], true)); + assertEquals(results, []); +}); + +test("KVIndex: delete returns undefined for missing key", () => { + const index = new KVIndex(); + const removed = index.delete(new KVKeyInstance(["nonexistent"])); + assertEquals(removed, undefined); +}); + +test("KVIndex: delete cleans up empty parent nodes", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["users", "alice", "profile"]), 100); + index.delete(new KVKeyInstance(["users", "alice", "profile"])); + + // Parent nodes should be gone + const results = index.get(new KVKeyInstance(["users"], true)); + assertEquals(results, []); + assertEquals(index.getChildKeys(null), []); +}); + +test("KVIndex: delete keeps sibling nodes intact", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["users", "alice"]), 100); + index.add(new KVKeyInstance(["users", "bob"]), 200); + + index.delete(new KVKeyInstance(["users", "alice"])); + + const remaining = index.get(new KVKeyInstance(["users"], true)); + assertEquals(remaining, [200]); +}); + +test("KVIndex: clear removes all entries", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["a"]), 1); + index.add(new KVKeyInstance(["b"]), 2); + + index.clear(); + + assertEquals(index.get(new KVKeyInstance(["a"], true)), []); + assertEquals(index.get(new KVKeyInstance(["b"], true)), []); + assertEquals(index.getChildKeys(null), []); +}); + +test("KVIndex: setIndex replaces entire index", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["old"]), 999); + + const newIndex = new KVIndex(); + newIndex.add(new KVKeyInstance(["new"]), 42); + index.setIndex(newIndex.index); + + assertEquals(index.get(new KVKeyInstance(["old"], true)), []); + assertEquals(index.get(new KVKeyInstance(["new"], true)), [42]); +}); + +test("KVIndex: getChildKeys returns root-level keys when null is passed", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["users", "alice"]), 100); + index.add(new KVKeyInstance(["products", 1]), 200); + index.add(new KVKeyInstance(["settings"]), 300); + + const keys = index.getChildKeys(null).sort(); + assertEquals(keys, ["products", "settings", "users"]); +}); + +test("KVIndex: getChildKeys returns child keys for a given key", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["user", "name"]), 100); + index.add(new KVKeyInstance(["user", "age"]), 200); + index.add(new KVKeyInstance(["user", "email"]), 300); + + const keys = index.getChildKeys(new KVKeyInstance(["user"])).sort(); + assertEquals(keys, ["age", "email", "name"]); +}); + +test("KVIndex: getChildKeys returns empty array for nonexistent key", () => { + const index = new KVIndex(); + const keys = index.getChildKeys(new KVKeyInstance(["nonexistent"])); + assertEquals(keys, []); +}); + +test("KVIndex: getChildKeys converts numeric keys to strings", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["items", 1]), 10); + index.add(new KVKeyInstance(["items", 2]), 20); + + const keys = index.getChildKeys(new KVKeyInstance(["items"])).sort(); + assertEquals(keys, ["1", "2"]); +}); + +test("KVIndex: handles multi-level keys with numbers", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["data", "user", 4]), 100); + index.add(new KVKeyInstance(["data", "system", 4]), 200); + + assertEquals( + index.get(new KVKeyInstance(["data", "user", 4], true)), + [100], + ); + assertEquals( + index.get(new KVKeyInstance(["data", "system", 4], true)), + [200], + ); + assertEquals( + index.get(new KVKeyInstance(["data", "system", 5], true)), + [], + ); +}); + +test("KVIndex: get with limit 0 returns empty array", () => { + const index = new KVIndex(); + index.add(new KVKeyInstance(["data", 1]), 10); + index.add(new KVKeyInstance(["data", 2]), 20); + + const results = index.get(new KVKeyInstance(["data"], true), 0); + assertEquals(results, []); +}); diff --git a/test/randomts.test.ts b/test/randomts.test.ts new file mode 100644 index 0000000..81c078c --- /dev/null +++ b/test/randomts.test.ts @@ -0,0 +1,58 @@ +import { assertEquals } from "@std/assert"; +import { test } from "@cross/test"; +import { pseudoRandomTimestamp } from "../src/lib/utils/randomts.ts"; + +test("pseudoRandomTimestamp: returns a bigint", () => { + const ts = pseudoRandomTimestamp(BigInt(Date.now())); + assertEquals(typeof ts, "bigint"); +}); + +test("pseudoRandomTimestamp: result is close to original timestamp", () => { + const original = BigInt(Date.now()); + const result = pseudoRandomTimestamp(original); + + // The upper bits should be preserved (only bottom numBits are randomized) + // With numBits=11, the mask clears the lower 11 bits (~2047), so the + // result should be within 2048 of the original + const diff = result > original ? result - original : original - result; + assertEquals(diff < BigInt(2048), true); +}); + +test("pseudoRandomTimestamp: produces different values on repeated calls", () => { + const ts = BigInt(Date.now()); + const results = new Set(); + + // Generate multiple values; they should not all be the same + for (let i = 0; i < 20; i++) { + results.add(pseudoRandomTimestamp(ts)); + } + + // With 11 bits of randomness (2048 possible values), 20 calls should + // almost certainly produce more than one unique value + assertEquals(results.size > 1, true); +}); + +test("pseudoRandomTimestamp: custom numBits works", () => { + const original = BigInt(1000000); + const result = pseudoRandomTimestamp(original, 4); + + // With numBits=4, only the bottom 4 bits (0–15) are randomized + const diff = result > original ? result - original : original - result; + assertEquals(diff < BigInt(16), true); +}); + +test("pseudoRandomTimestamp: zero timestamp produces a bigint", () => { + const result = pseudoRandomTimestamp(BigInt(0)); + assertEquals(typeof result, "bigint"); + // With 0 input and 11-bit randomness, result should be < 2048 + assertEquals(result < BigInt(2048), true); +}); + +test("pseudoRandomTimestamp: preserves upper bits of large timestamp", () => { + const original = BigInt("0x7FFFFFFFFFFFFFFF"); // Large 63-bit value + const result = pseudoRandomTimestamp(original, 11); + const mask = BigInt(~((1 << 11) - 1)); + + // Upper bits should match + assertEquals((result & mask) === (original & mask), true); +}); diff --git a/test/transaction.test.ts b/test/transaction.test.ts index 032b348..038d5c8 100644 --- a/test/transaction.test.ts +++ b/test/transaction.test.ts @@ -422,3 +422,25 @@ test("KVTransaction: incorrect hash algorithm throws error", () => { "Incorrect hash algorithm requested", ); }); + +test("KVTransaction: SET with undefined value throws error", () => { + // Arrange + const key = new KVKeyInstance(["test"]); + const timestamp = Date.now(); + + // Act & Assert: SET without a value should throw + const transaction = new KVTransaction(); + assertThrows( + () => { + transaction.create( + key, + KVOperation.SET, + timestamp, + undefined, + KVHashAlgorithm.MURMURHASH3, + ); + }, + Error, + "Set operation needs data", + ); +});