mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-30 08:24:51 -04:00
webapi, idb: add IDBKeyRange
Including support for IDBKeyRange based operations
This commit is contained in:
11 files changed
+1206
-80
No files matched your search
@@ -82,6 +82,18 @@ pub fn newObject(self: *const Local) js.Object {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn newDate(self: *const Local, time_ms: f64) !js.Value {
|
||||
const handle = v8.v8__Date__New(self.handle, time_ms) orelse return error.JsException;
|
||||
return .{ .local = self, .handle = handle };
|
||||
}
|
||||
|
||||
pub fn newNumber(self: *const Local, f: f64) !js.Value {
|
||||
return .{
|
||||
.local = self,
|
||||
.handle = self.isolate.initNumber(f).handle,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn newArray(self: *const Local, len: u32) js.Array {
|
||||
return .{
|
||||
.local = self,
|
||||
|
||||
@@ -126,6 +126,10 @@ pub fn isArrayBuffer(self: Value) bool {
|
||||
return v8.v8__Value__IsArrayBuffer(self.handle);
|
||||
}
|
||||
|
||||
pub fn isDate(self: Value) bool {
|
||||
return v8.v8__Value__IsDate(self.handle);
|
||||
}
|
||||
|
||||
pub fn isUint8Array(self: Value) bool {
|
||||
return v8.v8__Value__IsUint8Array(self.handle);
|
||||
}
|
||||
|
||||
@@ -187,6 +187,89 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- cmp across the full key-type ordering (number < string < binary < array)
|
||||
plus array element-wise comparison and prefix rules. -->
|
||||
<script id="cmp_key_types" type=module>
|
||||
{
|
||||
// Cross-type ordering.
|
||||
testing.expectEqual(-1, indexedDB.cmp(42, "42")); // number < string
|
||||
testing.expectEqual(-1, indexedDB.cmp("z", new Uint8Array([0]))); // string < binary
|
||||
testing.expectEqual(-1, indexedDB.cmp(new Uint8Array([255]), [0])); // binary < array
|
||||
|
||||
// Arrays compare element-wise; a shorter prefix sorts first.
|
||||
testing.expectEqual(-1, indexedDB.cmp([1], [1, 2]));
|
||||
testing.expectEqual(1, indexedDB.cmp([1, 2], [1, 1]));
|
||||
testing.expectEqual(0, indexedDB.cmp([1, "a"], [1, "a"]));
|
||||
testing.expectEqual(-1, indexedDB.cmp([1, [2]], [1, [3]])); // nested
|
||||
|
||||
// Binary keys compare by byte value.
|
||||
testing.expectEqual(-1, indexedDB.cmp(new Uint8Array([1, 2]), new Uint8Array([1, 3])));
|
||||
|
||||
// Invalid keys (boolean, null, NaN) are DataErrors.
|
||||
for (const bad of [true, null, NaN, {}]) {
|
||||
let threw = null;
|
||||
try { indexedDB.cmp(1, bad); } catch (e) { threw = e; }
|
||||
testing.expectEqual("DataError", threw && threw.name);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Array and binary values can be used as object-store keys and round-trip
|
||||
through get(). -->
|
||||
<script id="array_binary_keys" type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const open = indexedDB.open("akeys-db", 1);
|
||||
open.onupgradeneeded = (e) => e.target.result.createObjectStore("s");
|
||||
open.onsuccess = (e) => {
|
||||
const tx = e.target.result.transaction("s", "readwrite");
|
||||
const store = tx.objectStore("s");
|
||||
store.add("composite", ["a", 1]);
|
||||
store.add("blob", new Uint8Array([1, 2, 3]));
|
||||
|
||||
const g1 = store.get(["a", 1]);
|
||||
const g2 = store.get(new Uint8Array([1, 2, 3]));
|
||||
g2.onsuccess = () => state.resolve({ composite: g1.result, blob: g2.result });
|
||||
};
|
||||
await state.done((got) => {
|
||||
testing.expectEqual("composite", got.composite);
|
||||
testing.expectEqual("blob", got.blob);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Date values are keys (number < date < string), round-trip through get(),
|
||||
and getKey/cursor decode them back to Date objects. An invalid Date is a
|
||||
DataError. -->
|
||||
<script id="date_keys" type=module>
|
||||
{
|
||||
testing.expectEqual(-1, indexedDB.cmp(42, new Date(0))); // number < date
|
||||
testing.expectEqual(-1, indexedDB.cmp(new Date(1), "a")); // date < string
|
||||
testing.expectEqual(-1, indexedDB.cmp(new Date(1), new Date(2)));
|
||||
|
||||
let threw = null;
|
||||
try { indexedDB.cmp(1, new Date(NaN)); } catch (e) { threw = e; }
|
||||
testing.expectEqual("DataError", threw && threw.name);
|
||||
|
||||
const state = await testing.async();
|
||||
const open = indexedDB.open("date-db", 1);
|
||||
open.onupgradeneeded = (e) => e.target.result.createObjectStore("s");
|
||||
open.onsuccess = (e) => {
|
||||
const store = e.target.result.transaction("s", "readwrite").objectStore("s");
|
||||
const when = new Date(1700000000000);
|
||||
store.add("event", when);
|
||||
const g = store.get(when);
|
||||
const k = store.getKey(when);
|
||||
k.onsuccess = () => state.resolve({ value: g.result, key: k.result });
|
||||
};
|
||||
await state.done((got) => {
|
||||
testing.expectEqual("event", got.value);
|
||||
testing.expectEqual(true, got.key instanceof Date);
|
||||
testing.expectEqual(1700000000000, got.key.getTime());
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- During upgradeneeded, request.transaction is the versionchange transaction;
|
||||
a store created in the upgrade belongs to it and seeded data persists. -->
|
||||
<script id="request_transaction" type=module>
|
||||
@@ -307,6 +390,248 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- In-line keys: a store with a keyPath extracts the key from the value, and
|
||||
an explicit key argument is a DataError. -->
|
||||
<script id="inline_keys" type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const open = indexedDB.open("inline-db", 1);
|
||||
open.onupgradeneeded = (e) => e.target.result.createObjectStore("books", { keyPath: "isbn" });
|
||||
open.onsuccess = (e) => {
|
||||
const tx = e.target.result.transaction("books", "readwrite");
|
||||
const store = tx.objectStore("books");
|
||||
testing.expectEqual("isbn", store.keyPath);
|
||||
testing.expectEqual(false, store.autoIncrement);
|
||||
|
||||
store.add({ isbn: "111", title: "A" });
|
||||
|
||||
// Supplying an explicit key to an in-line store throws DataError.
|
||||
let threw = null;
|
||||
try { store.add({ isbn: "222" }, "extra"); } catch (err) { threw = err; }
|
||||
|
||||
const g = store.get("111");
|
||||
g.onsuccess = () => state.resolve({ title: g.result.title, threw: threw && threw.name });
|
||||
};
|
||||
await state.done((got) => {
|
||||
testing.expectEqual("A", got.title);
|
||||
testing.expectEqual("DataError", got.threw);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Nested key path "a.b" extracts a key from a nested property. -->
|
||||
<script id="nested_keypath" type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const open = indexedDB.open("nested-db", 1);
|
||||
open.onupgradeneeded = (e) => e.target.result.createObjectStore("s", { keyPath: "id.value" });
|
||||
open.onsuccess = (e) => {
|
||||
const store = e.target.result.transaction("s", "readwrite").objectStore("s");
|
||||
store.add({ id: { value: 7 }, name: "deep" });
|
||||
const g = store.get(7);
|
||||
g.onsuccess = () => state.resolve(g.result && g.result.name);
|
||||
};
|
||||
await state.done((name) => testing.expectEqual("deep", name));
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- autoIncrement generates keys for out-of-line stores, returns the key from
|
||||
add(), and the generator advances past an explicitly supplied higher key. -->
|
||||
<script id="auto_increment" type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const open = indexedDB.open("autoinc-db", 1);
|
||||
open.onupgradeneeded = (e) => e.target.result.createObjectStore("s", { autoIncrement: true });
|
||||
open.onsuccess = (e) => {
|
||||
const store = e.target.result.transaction("s", "readwrite").objectStore("s");
|
||||
const a = store.add("first"); // key 1
|
||||
const b = store.add("second"); // key 2
|
||||
store.add("tenth", 10); // explicit key bumps the generator
|
||||
const c = store.add("eleventh"); // key 11
|
||||
c.onsuccess = () => state.resolve({ a: a.result, b: b.result, c: c.result });
|
||||
};
|
||||
await state.done((got) => {
|
||||
testing.expectEqual(1, got.a);
|
||||
testing.expectEqual(2, got.b);
|
||||
testing.expectEqual(11, got.c);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- autoIncrement with a key path injects the generated key back into the
|
||||
stored value. -->
|
||||
<script id="auto_increment_inline" type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const open = indexedDB.open("autoinc-inline-db", 1);
|
||||
open.onupgradeneeded = (e) => e.target.result.createObjectStore("s", { keyPath: "id", autoIncrement: true });
|
||||
open.onsuccess = (e) => {
|
||||
const store = e.target.result.transaction("s", "readwrite").objectStore("s");
|
||||
const r = store.add({ name: "auto" });
|
||||
r.onsuccess = () => {
|
||||
const g = store.get(r.result);
|
||||
g.onsuccess = () => state.resolve({ key: r.result, stored: g.result });
|
||||
};
|
||||
};
|
||||
await state.done((got) => {
|
||||
testing.expectEqual(1, got.key);
|
||||
testing.expectEqual(1, got.stored.id); // injected
|
||||
testing.expectEqual("auto", got.stored.name);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- autoIncrement with a nested keyPath: a missing intermediate is created and
|
||||
the key injected; a non-object intermediate is a DataError (and must not
|
||||
consume a generated key). -->
|
||||
<script id="auto_increment_nested" type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const open = indexedDB.open("autoinc-nested-db", 1);
|
||||
open.onupgradeneeded = (e) => e.target.result.createObjectStore("s", { keyPath: "a.b", autoIncrement: true });
|
||||
open.onsuccess = (e) => {
|
||||
const store = e.target.result.transaction("s", "readwrite").objectStore("s");
|
||||
|
||||
const r1 = store.add({ name: "x" }); // a.b missing -> create + inject key 1
|
||||
|
||||
// a is a primitive: the key cannot be injected -> DataError, no key consumed.
|
||||
let threw = null;
|
||||
try { store.add({ a: 5 }); } catch (err) { threw = err; }
|
||||
|
||||
const r2 = store.add({ name: "y" }); // still key 2, generator not bumped
|
||||
r2.onsuccess = () => {
|
||||
const g = store.get(r1.result);
|
||||
g.onsuccess = () => state.resolve({
|
||||
k1: r1.result, k2: r2.result, threw: threw && threw.name, stored: g.result,
|
||||
});
|
||||
};
|
||||
};
|
||||
await state.done((got) => {
|
||||
testing.expectEqual(1, got.k1);
|
||||
testing.expectEqual("DataError", got.threw);
|
||||
testing.expectEqual(2, got.k2); // failed add did not consume key 2
|
||||
testing.expectEqual(1, got.stored.a.b); // injected into a created nested object
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- getKey returns the primary key (or undefined); getAllKeys returns every
|
||||
key in order. -->
|
||||
<script id="get_key_all_keys" type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const open = indexedDB.open("getkey-db", 1);
|
||||
open.onupgradeneeded = (e) => {
|
||||
const s = e.target.result.createObjectStore("s");
|
||||
s.add("a", 3);
|
||||
s.add("b", 1);
|
||||
s.add("c", 2);
|
||||
};
|
||||
open.onsuccess = (e) => {
|
||||
const store = e.target.result.transaction("s", "readonly").objectStore("s");
|
||||
const present = store.getKey(2);
|
||||
const absent = store.getKey(99);
|
||||
const all = store.getAllKeys();
|
||||
all.onsuccess = () => state.resolve({ present: present.result, absent: absent.result, all: all.result });
|
||||
};
|
||||
await state.done((got) => {
|
||||
testing.expectEqual(2, got.present);
|
||||
testing.expectEqual(undefined, got.absent);
|
||||
testing.expectEqual(3, got.all.length);
|
||||
testing.expectEqual(1, got.all[0]); // sorted key order
|
||||
testing.expectEqual(3, got.all[2]);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- IDBKeyRange factory methods, properties, and includes(). -->
|
||||
<script id="key_range" type=module>
|
||||
{
|
||||
const r = IDBKeyRange.bound(1, 10, false, true);
|
||||
testing.expectEqual(1, r.lower);
|
||||
testing.expectEqual(10, r.upper);
|
||||
testing.expectEqual(false, r.lowerOpen);
|
||||
testing.expectEqual(true, r.upperOpen);
|
||||
testing.expectEqual(true, r.includes(1));
|
||||
testing.expectEqual(true, r.includes(9));
|
||||
testing.expectEqual(false, r.includes(10)); // upper is open
|
||||
testing.expectEqual(false, r.includes(0));
|
||||
|
||||
const only = IDBKeyRange.only("k");
|
||||
testing.expectEqual("k", only.lower);
|
||||
testing.expectEqual("k", only.upper);
|
||||
testing.expectEqual(true, only.includes("k"));
|
||||
testing.expectEqual(false, only.includes("j"));
|
||||
|
||||
const lb = IDBKeyRange.lowerBound(5, true);
|
||||
testing.expectEqual(5, lb.lower);
|
||||
testing.expectEqual(undefined, lb.upper);
|
||||
testing.expectEqual(false, lb.includes(5)); // open
|
||||
testing.expectEqual(true, lb.includes(6));
|
||||
|
||||
const ub = IDBKeyRange.upperBound(5);
|
||||
testing.expectEqual(undefined, ub.lower);
|
||||
testing.expectEqual(true, ub.includes(5));
|
||||
|
||||
// bound() with lower > upper is a DataError.
|
||||
let threw = null;
|
||||
try { IDBKeyRange.bound(10, 1); } catch (e) { threw = e; }
|
||||
testing.expectEqual("DataError", threw && threw.name);
|
||||
|
||||
// IDBKeyRange is not constructable.
|
||||
let ctorThrew = false;
|
||||
try { new IDBKeyRange(); } catch (e) { ctorThrew = true; }
|
||||
testing.expectEqual(true, ctorThrew);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Ranged get/getAll/count/delete using a key range and a bare key. -->
|
||||
<script id="ranged_queries" type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const open = indexedDB.open("range-db", 1);
|
||||
open.onupgradeneeded = (e) => {
|
||||
const s = e.target.result.createObjectStore("s");
|
||||
for (let i = 1; i <= 5; i++) s.add("v" + i, i);
|
||||
};
|
||||
open.onsuccess = (e) => {
|
||||
const db = e.target.result;
|
||||
const store = db.transaction("s", "readwrite").objectStore("s");
|
||||
|
||||
const range = IDBKeyRange.bound(2, 4);
|
||||
const first = store.get(range); // first value in [2,4]
|
||||
const all = store.getAll(range); // values for keys 2,3,4
|
||||
const keys = store.getAllKeys(IDBKeyRange.lowerBound(3)); // keys 3,4,5
|
||||
const cnt = store.count(range); // 3
|
||||
const limited = store.getAll(null, 2); // first two of everything
|
||||
|
||||
store.delete(IDBKeyRange.bound(4, 5)); // remove keys 4,5
|
||||
const afterDelete = store.count();
|
||||
|
||||
afterDelete.onsuccess = () => state.resolve({
|
||||
first: first.result,
|
||||
all: all.result,
|
||||
keys: keys.result,
|
||||
cnt: cnt.result,
|
||||
limited: limited.result,
|
||||
remaining: afterDelete.result,
|
||||
});
|
||||
};
|
||||
await state.done((got) => {
|
||||
testing.expectEqual("v2", got.first);
|
||||
testing.expectEqual(3, got.all.length);
|
||||
testing.expectEqual("v2", got.all[0]);
|
||||
testing.expectEqual("v4", got.all[2]);
|
||||
testing.expectEqual(3, got.keys.length);
|
||||
testing.expectEqual(3, got.keys[0]);
|
||||
testing.expectEqual(5, got.keys[2]);
|
||||
testing.expectEqual(3, got.cnt);
|
||||
testing.expectEqual(2, got.limited.length);
|
||||
testing.expectEqual(3, got.remaining); // keys 1,2,3 remain
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- clear() empties the store; commit() settles the transaction explicitly. -->
|
||||
<script id="clear_and_commit" type=module>
|
||||
{
|
||||
|
||||
@@ -46,6 +46,7 @@ pub fn open(path: [:0]const u8) !Engine {
|
||||
\\ name text not null,
|
||||
\\ key_path text,
|
||||
\\ auto_increment integer not null default 0,
|
||||
\\ key_generator integer not null default 1,
|
||||
\\ unique(database_id, name)
|
||||
\\ );
|
||||
\\ create table if not exists idb_records (
|
||||
@@ -114,6 +115,53 @@ pub fn objectStoreId(self: *const Engine, database_id: i64, name: []const u8) !?
|
||||
);
|
||||
}
|
||||
|
||||
pub const StoreInfo = struct {
|
||||
id: i64,
|
||||
key_path: ?[]const u8,
|
||||
auto_increment: bool,
|
||||
};
|
||||
|
||||
pub fn objectStoreInfo(self: *const Engine, arena: Allocator, database_id: i64, name: []const u8) !?StoreInfo {
|
||||
var row = (try self.conn.row(
|
||||
"select id, key_path, auto_increment from idb_object_stores where database_id = ?1 and name = ?2",
|
||||
.{ database_id, name },
|
||||
)) orelse return null;
|
||||
defer row.deinit();
|
||||
|
||||
const key_path = row.get(?[]const u8, 1);
|
||||
return .{
|
||||
.id = row.get(i64, 0),
|
||||
.key_path = if (key_path) |kp| try arena.dupe(u8, kp) else null,
|
||||
.auto_increment = row.get(bool, 2),
|
||||
};
|
||||
}
|
||||
|
||||
// key_generator starts at 1, we increment it, but want to return the
|
||||
// previous value, hence the - 1.
|
||||
pub fn nextGeneratedKey(self: *Engine, store_id: i64) !i64 {
|
||||
return (try self.conn.scalar(
|
||||
i64,
|
||||
"update idb_object_stores set key_generator = key_generator + 1 where id = ?1 returning key_generator - 1",
|
||||
.{store_id},
|
||||
)) orelse error.NotFound;
|
||||
}
|
||||
|
||||
// If an explicit key was given, we need to bump the generator so that a future
|
||||
// generated key doesn't collide
|
||||
pub fn maybeBumpGenerator(self: *Engine, store_id: i64, key: f64) !void {
|
||||
if (key < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cap at 2^53, the largest integer the generator tracks per spec.
|
||||
const capped = @min(@floor(key), 9007199254740992);
|
||||
const want: i64 = @intFromFloat(capped + 1);
|
||||
try self.conn.exec(
|
||||
"update idb_object_stores set key_generator = ?2 where id = ?1 and key_generator < ?2",
|
||||
.{ store_id, want },
|
||||
);
|
||||
}
|
||||
|
||||
pub fn createObjectStore(
|
||||
self: *Engine,
|
||||
database_id: i64,
|
||||
@@ -158,30 +206,98 @@ pub fn get(self: *const Engine, allocator: Allocator, object_store_id: i64, key:
|
||||
return try allocator.dupe(u8, row.get([]const u8, 0));
|
||||
}
|
||||
|
||||
pub fn delete(self: *Engine, object_store_id: i64, key: []const u8) !void {
|
||||
return self.conn.exec(
|
||||
"delete from idb_records where object_store_id = ?1 and key = ?2",
|
||||
.{ object_store_id, key },
|
||||
);
|
||||
}
|
||||
|
||||
pub fn clear(self: *Engine, object_store_id: i64) !void {
|
||||
return self.conn.exec("delete from idb_records where object_store_id = ?1", .{object_store_id});
|
||||
}
|
||||
|
||||
pub fn count(self: *const Engine, object_store_id: i64) !i64 {
|
||||
return (try self.conn.scalar(
|
||||
i64,
|
||||
"select count(*) from idb_records where object_store_id = ?1",
|
||||
.{object_store_id},
|
||||
)) orelse 0;
|
||||
pub const Bounds = struct {
|
||||
lower: []const u8,
|
||||
upper: []const u8,
|
||||
|
||||
// These are built-time string literal operators, e.g. ">= ". Not worried
|
||||
// about some SQL injection.
|
||||
lower_op: []const u8,
|
||||
upper_op: []const u8,
|
||||
|
||||
// optimization flag for point queries.
|
||||
is_point: bool,
|
||||
|
||||
// Every encoded key begins with a type tag from 10 to 50]...
|
||||
// 0 sorts below, and...
|
||||
pub const min_sentinel: []const u8 = &.{0x00};
|
||||
// 255 sorts above
|
||||
pub const max_sentinel: []const u8 = &.{0xFF};
|
||||
|
||||
pub fn unbounded() Bounds {
|
||||
return .{ .lower = min_sentinel, .upper = max_sentinel, .lower_op = ">= ", .upper_op = "<= ", .is_point = false };
|
||||
}
|
||||
|
||||
pub fn point(encoded: []const u8) Bounds {
|
||||
return .{ .lower = encoded, .upper = encoded, .lower_op = "", .upper_op = "", .is_point = true };
|
||||
}
|
||||
};
|
||||
|
||||
pub fn getRange(self: *const Engine, allocator: Allocator, object_store_id: i64, b: Bounds) !?[]u8 {
|
||||
var buf: [256]u8 = undefined;
|
||||
const sql = try rangeSql(&buf, "select value", b, " order by key limit 1");
|
||||
var row = (try self.conn.row(sql, .{ object_store_id, b.lower, b.upper })) orelse return null;
|
||||
defer row.deinit();
|
||||
return try allocator.dupe(u8, row.get([]const u8, 0));
|
||||
}
|
||||
|
||||
pub fn getAll(self: *const Engine, arena: Allocator, object_store_id: i64) ![]const []u8 {
|
||||
var rows = try self.conn.rows(
|
||||
"select value from idb_records where object_store_id = ?1 order by key",
|
||||
.{object_store_id},
|
||||
pub fn getKeyRange(self: *const Engine, allocator: Allocator, object_store_id: i64, b: Bounds) !?[]u8 {
|
||||
var buf: [256]u8 = undefined;
|
||||
const sql = try rangeSql(&buf, "select key", b, " order by key limit 1");
|
||||
var row = (try self.conn.row(sql, .{ object_store_id, b.lower, b.upper })) orelse return null;
|
||||
defer row.deinit();
|
||||
return try allocator.dupe(u8, row.get([]const u8, 0));
|
||||
}
|
||||
|
||||
pub fn countRange(self: *const Engine, object_store_id: i64, b: Bounds) !i64 {
|
||||
var buf: [256]u8 = undefined;
|
||||
const sql = try rangeSql(&buf, "select count(*)", b, "");
|
||||
return (try self.conn.scalar(i64, sql, .{ object_store_id, b.lower, b.upper })) orelse 0;
|
||||
}
|
||||
|
||||
pub fn deleteRange(self: *Engine, object_store_id: i64, b: Bounds) !void {
|
||||
var buf: [256]u8 = undefined;
|
||||
const sql = try rangeSql(&buf, "delete", b, "");
|
||||
return self.conn.exec(sql, .{ object_store_id, b.lower, b.upper });
|
||||
}
|
||||
|
||||
// Every injected string is a compile-time known/safe value.
|
||||
fn rangeSql(buf: []u8, head: []const u8, b: Bounds, tail: []const u8) ![:0]u8 {
|
||||
if (b.is_point) {
|
||||
// optimized query for [common] point query. (key = ?2 or key = ?3)
|
||||
// to keep the param list the side for the caller.
|
||||
return std.fmt.bufPrintZ(
|
||||
buf,
|
||||
"{s} from idb_records where object_store_id = ?1 and (key = ?2 or key = ?3) {s}",
|
||||
.{ head, tail },
|
||||
);
|
||||
}
|
||||
|
||||
return std.fmt.bufPrintZ(
|
||||
buf,
|
||||
"{s} from idb_records where object_store_id = ?1 and key {s} ?2 and key {s} ?3{s}",
|
||||
.{ head, b.lower_op, b.upper_op, tail },
|
||||
);
|
||||
}
|
||||
|
||||
// What a ranged getAll/getAllKeys returns: the value or key column.
|
||||
pub const Column = enum {
|
||||
value,
|
||||
key,
|
||||
};
|
||||
|
||||
pub fn getAllRange(self: *const Engine, arena: Allocator, object_store_id: i64, b: Bounds, column: Column, limit_: ?u32) ![]const []u8 {
|
||||
var buf: [256]u8 = undefined;
|
||||
const head = if (column == .value) "select value" else "select key";
|
||||
const sql = try rangeSql(&buf, head, b, " order by key limit ?4");
|
||||
// SQLite treats a negative LIMIT as "no limit".
|
||||
const limit: i64 = if (limit_) |c| @intCast(c) else -1;
|
||||
|
||||
var rows = try self.conn.rows(sql, .{ object_store_id, b.lower, b.upper, limit });
|
||||
defer rows.deinit();
|
||||
|
||||
var list: std.ArrayList([]u8) = .empty;
|
||||
@@ -192,6 +308,86 @@ pub fn getAll(self: *const Engine, arena: Allocator, object_store_id: i64) ![]co
|
||||
}
|
||||
|
||||
const testing = @import("../../../../testing.zig");
|
||||
test "IDB - Engine: ranged getAll/count honour open and closed bounds" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var engine = try Engine.open(":memory:");
|
||||
defer engine.close();
|
||||
const db_id = try engine.upsertDatabase("app", 1);
|
||||
const store_id = try engine.createObjectStore(db_id, "s", null, false);
|
||||
try seedNumbers(&engine, store_id, arena, &.{ 3, 1, 5, 2, 4 });
|
||||
|
||||
const k2 = try numKey(arena, 2);
|
||||
const k4 = try numKey(arena, 4);
|
||||
|
||||
// Closed [2,4]: keys 2,3,4 in sorted order.
|
||||
{
|
||||
const b = boundsFor(k2, false, k4, false);
|
||||
const vals = try engine.getAllRange(arena, store_id, b, .value, null);
|
||||
try testing.expectEqual(3, vals.len);
|
||||
try testing.expectEqualSlices(u8, "v2", vals[0]);
|
||||
try testing.expectEqualSlices(u8, "v4", vals[2]);
|
||||
try testing.expectEqual(3, try engine.countRange(store_id, b));
|
||||
}
|
||||
|
||||
// Open lower (2,4]: keys 3,4.
|
||||
{
|
||||
const b = boundsFor(k2, true, k4, false);
|
||||
try testing.expectEqual(2, try engine.countRange(store_id, b));
|
||||
const vals = try engine.getAllRange(arena, store_id, b, .value, null);
|
||||
try testing.expectEqualSlices(u8, "v3", vals[0]);
|
||||
}
|
||||
|
||||
// Open both (2,4): only key 3.
|
||||
{
|
||||
const b = boundsFor(k2, true, k4, true);
|
||||
try testing.expectEqual(1, try engine.countRange(store_id, b));
|
||||
}
|
||||
|
||||
// Unbounded and point ranges.
|
||||
try testing.expectEqual(5, try engine.countRange(store_id, Engine.Bounds.unbounded()));
|
||||
try testing.expectEqual(1, try engine.countRange(store_id, Engine.Bounds.point(try numKey(arena, 3))));
|
||||
|
||||
// A limit caps the result count.
|
||||
const limited = try engine.getAllRange(arena, store_id, Engine.Bounds.unbounded(), .value, 2);
|
||||
try testing.expectEqual(2, limited.len);
|
||||
|
||||
// The .key column returns encoded keys, sorted.
|
||||
const keys = try engine.getAllRange(arena, store_id, Engine.Bounds.unbounded(), .key, null);
|
||||
try testing.expectEqualSlices(u8, try numKey(arena, 1), keys[0]);
|
||||
try testing.expectEqualSlices(u8, try numKey(arena, 5), keys[4]);
|
||||
}
|
||||
|
||||
test "IDB - Engine: getRange/getKeyRange first-in-range and deleteRange" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var engine = try Engine.open(":memory:");
|
||||
defer engine.close();
|
||||
const db_id = try engine.upsertDatabase("app", 1);
|
||||
const store_id = try engine.createObjectStore(db_id, "s", null, false);
|
||||
try seedNumbers(&engine, store_id, arena, &.{ 1, 2, 3, 4, 5 });
|
||||
|
||||
const k2 = try numKey(arena, 2);
|
||||
const k4 = try numKey(arena, 4);
|
||||
|
||||
// First value/key in [2,4] is for key 2.
|
||||
try testing.expectEqualSlices(u8, "v2", (try engine.getRange(arena, store_id, boundsFor(k2, false, k4, false))).?);
|
||||
try testing.expectEqualSlices(u8, k2, (try engine.getKeyRange(arena, store_id, boundsFor(k2, false, k4, false))).?);
|
||||
|
||||
// Open lower skips key 2.
|
||||
try testing.expectEqualSlices(u8, "v3", (try engine.getRange(arena, store_id, boundsFor(k2, true, k4, false))).?);
|
||||
|
||||
// Empty range yields null.
|
||||
try testing.expectEqual(null, try engine.getRange(arena, store_id, Engine.Bounds.point(try numKey(arena, 99))));
|
||||
|
||||
// deleteRange removes [4,5], leaving 1,2,3.
|
||||
try engine.deleteRange(store_id, boundsFor(k4, false, try numKey(arena, 5), false));
|
||||
try testing.expectEqual(3, try engine.countRange(store_id, Engine.Bounds.unbounded()));
|
||||
}
|
||||
|
||||
test "IDB - Engine: open creates schema" {
|
||||
var engine = try Engine.open(":memory:");
|
||||
@@ -258,3 +454,29 @@ test "IDB - Engine: rollback discards uncommitted writes" {
|
||||
|
||||
try testing.expectEqual(null, try engine.get(testing.allocator, store_id, "k"));
|
||||
}
|
||||
|
||||
const Key = @import("Key.zig");
|
||||
|
||||
// Seed a store with numeric keys n and values "v<n>", inserted out of order so
|
||||
// the range tests prove ORDER BY rather than insertion order.
|
||||
fn seedNumbers(engine: *Engine, store_id: i64, arena: Allocator, ns: []const u8) !void {
|
||||
for (ns) |n| {
|
||||
const enc = try Key.number(@floatFromInt(n)).encode(arena);
|
||||
var buf: [8]u8 = undefined;
|
||||
try engine.add(store_id, enc, try std.fmt.bufPrint(&buf, "v{d}", .{n}));
|
||||
}
|
||||
}
|
||||
|
||||
fn numKey(arena: Allocator, n: u8) ![]u8 {
|
||||
return Key.number(@floatFromInt(n)).encode(arena);
|
||||
}
|
||||
|
||||
fn boundsFor(lower: ?[]const u8, lower_open: bool, upper: ?[]const u8, upper_open: bool) Engine.Bounds {
|
||||
return .{
|
||||
.is_point = false,
|
||||
.lower = lower orelse Engine.Bounds.min_sentinel,
|
||||
.upper = upper orelse Engine.Bounds.max_sentinel,
|
||||
.lower_op = if (lower_open) "> " else ">= ",
|
||||
.upper_op = if (upper_open) "< " else "<= ",
|
||||
};
|
||||
}
|
||||
@@ -81,7 +81,7 @@ pub fn createObjectStore(
|
||||
|
||||
const owned_name = try exec.dupeString(name);
|
||||
const key_path = if (opts.keyPath) |kp| try exec.dupeString(kp) else null;
|
||||
return IDBObjectStore.init(self._engine, txn, store_id, owned_name, key_path, exec);
|
||||
return IDBObjectStore.init(self._engine, txn, store_id, owned_name, key_path, opts.autoIncrement, exec);
|
||||
}
|
||||
|
||||
// Only callable during upgradeneeded, hence the _txn check
|
||||
|
||||
@@ -200,8 +200,8 @@ const DeleteContext = struct {
|
||||
};
|
||||
|
||||
pub fn cmp(_: *IDBFactory, first: js.Value, second: js.Value, exec: *Execution) !i32 {
|
||||
const a = try Key.encodeValue(first, exec.call_arena);
|
||||
const b = try Key.encodeValue(second, exec.call_arena);
|
||||
const a = try Key.encodeValue(exec.call_arena, first);
|
||||
const b = try Key.encodeValue(exec.call_arena, second);
|
||||
return switch (std.mem.order(u8, a, b)) {
|
||||
.lt => -1,
|
||||
.eq => 0,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const js = @import("../../../js/js.zig");
|
||||
|
||||
const Key = @import("Key.zig");
|
||||
const Engine = @import("Engine.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Execution = js.Execution;
|
||||
|
||||
const IDBKeyRange = @This();
|
||||
|
||||
_exec: *Execution,
|
||||
// Encoded bound bytes (page-arena lived), null when that side is unbounded.
|
||||
_lower: ?[]const u8,
|
||||
_upper: ?[]const u8,
|
||||
_lower_open: bool,
|
||||
_upper_open: bool,
|
||||
|
||||
fn create(exec: *Execution, lower: ?[]const u8, upper: ?[]const u8, lower_open: bool, upper_open: bool) !*IDBKeyRange {
|
||||
return exec._factory.create(IDBKeyRange{
|
||||
._exec = exec,
|
||||
._lower = lower,
|
||||
._upper = upper,
|
||||
._lower_open = lower_open,
|
||||
._upper_open = upper_open,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn only(value: js.Value, exec: *Execution) !*IDBKeyRange {
|
||||
const encoded = try Key.encodeValue(exec.arena, value);
|
||||
return create(exec, encoded, encoded, false, false);
|
||||
}
|
||||
|
||||
pub fn lowerBound(value: js.Value, open: ?bool, exec: *Execution) !*IDBKeyRange {
|
||||
const encoded = try Key.encodeValue(exec.arena, value);
|
||||
return create(exec, encoded, null, open orelse false, false);
|
||||
}
|
||||
|
||||
pub fn upperBound(value: js.Value, open: ?bool, exec: *Execution) !*IDBKeyRange {
|
||||
const encoded = try Key.encodeValue(exec.arena, value);
|
||||
return create(exec, null, encoded, false, open orelse false);
|
||||
}
|
||||
|
||||
pub fn bound(lower: js.Value, upper: js.Value, lower_open: ?bool, upper_open: ?bool, exec: *Execution) !*IDBKeyRange {
|
||||
const lo = try Key.encodeValue(exec.arena, lower);
|
||||
const up = try Key.encodeValue(exec.arena, upper);
|
||||
if (std.mem.order(u8, lo, up) == .gt) {
|
||||
return error.DataError;
|
||||
}
|
||||
return create(exec, lo, up, lower_open orelse false, upper_open orelse false);
|
||||
}
|
||||
|
||||
pub fn getLower(self: *const IDBKeyRange, exec: *Execution) !?js.Value {
|
||||
const encoded = self._lower orelse return null;
|
||||
return try Key.decodeToJs(exec.call_arena, exec.js.local.?, encoded);
|
||||
}
|
||||
|
||||
pub fn getUpper(self: *const IDBKeyRange, exec: *Execution) !?js.Value {
|
||||
const encoded = self._upper orelse return null;
|
||||
return try Key.decodeToJs(exec.call_arena, exec.js.local.?, encoded);
|
||||
}
|
||||
|
||||
pub fn getLowerOpen(self: *const IDBKeyRange) bool {
|
||||
return self._lower_open;
|
||||
}
|
||||
|
||||
pub fn getUpperOpen(self: *const IDBKeyRange) bool {
|
||||
return self._upper_open;
|
||||
}
|
||||
|
||||
pub fn includes(self: *const IDBKeyRange, key: js.Value, exec: *Execution) !bool {
|
||||
const encoded = try Key.encodeValue(exec.call_arena, key);
|
||||
return self.containsEncoded(encoded);
|
||||
}
|
||||
|
||||
fn containsEncoded(self: *const IDBKeyRange, encoded: []const u8) bool {
|
||||
if (self._lower) |lo| {
|
||||
switch (std.mem.order(u8, encoded, lo)) {
|
||||
.lt => return false,
|
||||
.eq => if (self._lower_open) return false,
|
||||
.gt => {},
|
||||
}
|
||||
}
|
||||
if (self._upper) |up| {
|
||||
switch (std.mem.order(u8, encoded, up)) {
|
||||
.gt => return false,
|
||||
.eq => if (self._upper_open) return false,
|
||||
.lt => {},
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// SQL bounds for the engine's ranged queries.
|
||||
pub fn toBounds(self: *const IDBKeyRange) Engine.Bounds {
|
||||
return .{
|
||||
.is_point = false,
|
||||
.lower = self._lower orelse Engine.Bounds.min_sentinel,
|
||||
.upper = self._upper orelse Engine.Bounds.max_sentinel,
|
||||
.lower_op = if (self._lower_open) "> " else ">= ",
|
||||
.upper_op = if (self._upper_open) "< " else "<= ",
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve a get/getAll/count/delete query argument — a key, an IDBKeyRange, or
|
||||
// nothing (null/undefined) — into engine bounds. A bare key becomes a point
|
||||
// range; the encoded key is allocated in `arena`.
|
||||
pub fn resolveQuery(arena: Allocator, query: ?js.Value, exec: *Execution) !Engine.Bounds {
|
||||
const q = query orelse return Engine.Bounds.unbounded();
|
||||
if (q.isNullOrUndefined()) {
|
||||
return Engine.Bounds.unbounded();
|
||||
}
|
||||
if (exec.js.local.?.jsValueToZig(*IDBKeyRange, q)) |range| {
|
||||
return range.toBounds();
|
||||
} else |_| {}
|
||||
return Engine.Bounds.point(try Key.encodeValue(arena, q));
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(IDBKeyRange);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "IDBKeyRange";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const only = bridge.function(IDBKeyRange.only, .{ .static = true, .dom_exception = true });
|
||||
pub const lowerBound = bridge.function(IDBKeyRange.lowerBound, .{ .static = true, .dom_exception = true });
|
||||
pub const upperBound = bridge.function(IDBKeyRange.upperBound, .{ .static = true, .dom_exception = true });
|
||||
pub const bound = bridge.function(IDBKeyRange.bound, .{ .static = true, .dom_exception = true });
|
||||
|
||||
pub const lower = bridge.accessor(IDBKeyRange.getLower, null, .{ .null_as_undefined = true });
|
||||
pub const upper = bridge.accessor(IDBKeyRange.getUpper, null, .{ .null_as_undefined = true });
|
||||
pub const lowerOpen = bridge.accessor(IDBKeyRange.getLowerOpen, null, .{});
|
||||
pub const upperOpen = bridge.accessor(IDBKeyRange.getUpperOpen, null, .{});
|
||||
pub const includes = bridge.function(IDBKeyRange.includes, .{ .dom_exception = true });
|
||||
};
|
||||
@@ -23,6 +23,7 @@ const js = @import("../../../js/js.zig");
|
||||
const Key = @import("Key.zig");
|
||||
const Engine = @import("Engine.zig");
|
||||
const IDBRequest = @import("IDBRequest.zig");
|
||||
const IDBKeyRange = @import("IDBKeyRange.zig");
|
||||
const IDBTransaction = @import("IDBTransaction.zig");
|
||||
|
||||
const log = lp.log;
|
||||
@@ -30,11 +31,11 @@ const Execution = js.Execution;
|
||||
|
||||
const IDBObjectStore = @This();
|
||||
|
||||
_exec: *Execution,
|
||||
_engine: *Engine,
|
||||
_store_id: i64,
|
||||
_name: []const u8,
|
||||
_key_path: ?[]const u8,
|
||||
_auto_increment: bool,
|
||||
// only null during an upgradeneeded
|
||||
_txn: ?*IDBTransaction,
|
||||
|
||||
@@ -44,37 +45,38 @@ pub fn init(
|
||||
store_id: i64,
|
||||
name: []const u8,
|
||||
key_path: ?[]const u8,
|
||||
auto_increment: bool,
|
||||
exec: *Execution,
|
||||
) !*IDBObjectStore {
|
||||
return exec._factory.create(IDBObjectStore{
|
||||
._exec = exec,
|
||||
._engine = engine,
|
||||
._txn = txn,
|
||||
._store_id = store_id,
|
||||
._name = name,
|
||||
._key_path = key_path,
|
||||
._auto_increment = auto_increment,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add(self: *IDBObjectStore, value: js.Value, key: ?js.Value) !*IDBRequest {
|
||||
return self.write(value, key, .add);
|
||||
pub fn add(self: *IDBObjectStore, value: js.Value, key: ?js.Value, exec: *Execution) !*IDBRequest {
|
||||
return self.write(value, key, .add, exec);
|
||||
}
|
||||
|
||||
pub fn put(self: *IDBObjectStore, value: js.Value, key: ?js.Value) !*IDBRequest {
|
||||
return self.write(value, key, .put);
|
||||
pub fn put(self: *IDBObjectStore, value: js.Value, key: ?js.Value, exec: *Execution) !*IDBRequest {
|
||||
return self.write(value, key, .put, exec);
|
||||
}
|
||||
|
||||
pub fn get(self: *IDBObjectStore, key: js.Value, exec: *Execution) !*IDBRequest {
|
||||
pub fn get(self: *IDBObjectStore, query: js.Value, exec: *Execution) !*IDBRequest {
|
||||
const txn = self._txn orelse return error.TransactionInactiveError;
|
||||
try txn.ensureBegun();
|
||||
|
||||
// Both the encoded key and the fetched bytes are consumed within this call
|
||||
// (the bytes are deserialized below), so the per-call scratch arena suffices.
|
||||
const arena = exec.call_arena;
|
||||
const encoded = try Key.encodeValue(key, arena);
|
||||
const bounds = try IDBKeyRange.resolveQuery(arena, query, exec);
|
||||
const request = try txn.newRequest();
|
||||
|
||||
const bytes = self._engine.get(arena, self._store_id, encoded) catch |err| {
|
||||
const bytes = self._engine.getRange(arena, self._store_id, bounds) catch |err| {
|
||||
log.warn(.storage, "idb get", .{ .err = err });
|
||||
request.setError(err);
|
||||
return request;
|
||||
@@ -87,17 +89,17 @@ pub fn get(self: *IDBObjectStore, key: js.Value, exec: *Execution) !*IDBRequest
|
||||
return request;
|
||||
}
|
||||
|
||||
pub fn delete(self: *IDBObjectStore, key: js.Value, _: *Execution) !*IDBRequest {
|
||||
pub fn delete(self: *IDBObjectStore, query: js.Value, exec: *Execution) !*IDBRequest {
|
||||
const txn = self._txn orelse return error.TransactionInactiveError;
|
||||
if (txn._mode == .readonly) {
|
||||
return error.ReadOnlyError;
|
||||
}
|
||||
try txn.ensureBegun();
|
||||
|
||||
const encoded = try Key.encodeValue(key, self._exec.call_arena);
|
||||
const bounds = try IDBKeyRange.resolveQuery(exec.call_arena, query, exec);
|
||||
const request = try txn.newRequest();
|
||||
|
||||
self._engine.delete(self._store_id, encoded) catch |err| {
|
||||
self._engine.deleteRange(self._store_id, bounds) catch |err| {
|
||||
log.warn(.storage, "idb delete", .{ .err = err });
|
||||
request.setError(err);
|
||||
};
|
||||
@@ -119,12 +121,13 @@ pub fn clear(self: *IDBObjectStore, _: *Execution) !*IDBRequest {
|
||||
return request;
|
||||
}
|
||||
|
||||
pub fn count(self: *IDBObjectStore, exec: *Execution) !*IDBRequest {
|
||||
pub fn count(self: *IDBObjectStore, query: ?js.Value, exec: *Execution) !*IDBRequest {
|
||||
const txn = self._txn orelse return error.TransactionInactiveError;
|
||||
try txn.ensureBegun();
|
||||
|
||||
const bounds = try IDBKeyRange.resolveQuery(exec.call_arena, query, exec);
|
||||
const request = try txn.newRequest();
|
||||
const n = self._engine.count(self._store_id) catch |err| {
|
||||
const n = self._engine.countRange(self._store_id, bounds) catch |err| {
|
||||
log.warn(.storage, "idb count", .{ .err = err });
|
||||
request.setError(err);
|
||||
return request;
|
||||
@@ -133,14 +136,16 @@ pub fn count(self: *IDBObjectStore, exec: *Execution) !*IDBRequest {
|
||||
return request;
|
||||
}
|
||||
|
||||
pub fn getAll(self: *IDBObjectStore, exec: *Execution) !*IDBRequest {
|
||||
pub fn getAll(self: *IDBObjectStore, query: ?js.Value, count_: ?u32, exec: *Execution) !*IDBRequest {
|
||||
const txn = self._txn orelse return error.TransactionInactiveError;
|
||||
try txn.ensureBegun();
|
||||
|
||||
const local = exec.js.local.?;
|
||||
const arena = exec.call_arena;
|
||||
const bounds = try IDBKeyRange.resolveQuery(arena, query, exec);
|
||||
const request = try txn.newRequest();
|
||||
|
||||
const values = self._engine.getAll(exec.call_arena, self._store_id) catch |err| {
|
||||
const values = self._engine.getAllRange(arena, self._store_id, bounds, .value, count_) catch |err| {
|
||||
log.warn(.storage, "idb getAll", .{ .err = err });
|
||||
request.setError(err);
|
||||
return request;
|
||||
@@ -155,6 +160,48 @@ pub fn getAll(self: *IDBObjectStore, exec: *Execution) !*IDBRequest {
|
||||
return request;
|
||||
}
|
||||
|
||||
pub fn getKey(self: *IDBObjectStore, query: js.Value, exec: *Execution) !*IDBRequest {
|
||||
const txn = self._txn orelse return error.TransactionInactiveError;
|
||||
try txn.ensureBegun();
|
||||
|
||||
const arena = exec.call_arena;
|
||||
const bounds = try IDBKeyRange.resolveQuery(arena, query, exec);
|
||||
const request = try txn.newRequest();
|
||||
|
||||
const found = self._engine.getKeyRange(arena, self._store_id, bounds) catch |err| {
|
||||
log.warn(.storage, "idb getKey", .{ .err = err });
|
||||
request.setError(err);
|
||||
return request;
|
||||
};
|
||||
|
||||
const bytes = found orelse return request; // no record -> undefined
|
||||
try request.setValueResult(try Key.decodeToJs(arena, exec.js.local.?, bytes));
|
||||
return request;
|
||||
}
|
||||
|
||||
pub fn getAllKeys(self: *IDBObjectStore, query: ?js.Value, count_: ?u32, exec: *Execution) !*IDBRequest {
|
||||
const txn = self._txn orelse return error.TransactionInactiveError;
|
||||
try txn.ensureBegun();
|
||||
|
||||
const arena = exec.call_arena;
|
||||
const bounds = try IDBKeyRange.resolveQuery(arena, query, exec);
|
||||
const request = try txn.newRequest();
|
||||
|
||||
const keys = self._engine.getAllRange(arena, self._store_id, bounds, .key, count_) catch |err| {
|
||||
log.warn(.storage, "idb getAllKeys", .{ .err = err });
|
||||
request.setError(err);
|
||||
return request;
|
||||
};
|
||||
|
||||
const local = exec.js.local.?;
|
||||
const arr = local.newArray(@intCast(keys.len));
|
||||
for (keys, 0..) |bytes, i| {
|
||||
_ = try arr.set(@intCast(i), try Key.decodeToJs(arena, local, bytes), .{});
|
||||
}
|
||||
try request.setValueResult(arr.toValue());
|
||||
return request;
|
||||
}
|
||||
|
||||
pub fn getName(self: *const IDBObjectStore) []const u8 {
|
||||
return self._name;
|
||||
}
|
||||
@@ -163,17 +210,75 @@ pub fn getKeyPath(self: *const IDBObjectStore) ?[]const u8 {
|
||||
return self._key_path;
|
||||
}
|
||||
|
||||
pub fn getAutoIncrement(self: *const IDBObjectStore) bool {
|
||||
return self._auto_increment;
|
||||
}
|
||||
|
||||
pub fn getTransaction(self: *IDBObjectStore) ?*IDBTransaction {
|
||||
return self._txn;
|
||||
}
|
||||
|
||||
const WriteKind = enum { add, put };
|
||||
|
||||
fn write(self: *IDBObjectStore, value: js.Value, key_: ?js.Value, kind: WriteKind) !*IDBRequest {
|
||||
fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: WriteKind, exec: *Execution) !*IDBRequest {
|
||||
const txn = self._txn orelse return error.TransactionInactiveError;
|
||||
if (txn._mode == .readonly) {
|
||||
return error.ReadOnlyError;
|
||||
}
|
||||
try txn.ensureBegun();
|
||||
|
||||
const key = key_ orelse return error.DataError;
|
||||
const encoded = try Key.encodeValue(key, self._exec.call_arena);
|
||||
const local = exec.js.local.?;
|
||||
|
||||
var generated = false;
|
||||
const key_value: js.Value = blk: {
|
||||
if (self._key_path) |kp| {
|
||||
if (key_arg != null) {
|
||||
// can't have an explicit key if we're configured for in-line keys
|
||||
return error.DataError;
|
||||
}
|
||||
|
||||
if (Key.evaluatePath(value, kp)) |extracted| {
|
||||
break :blk extracted;
|
||||
}
|
||||
|
||||
// The keypath wasn't in the value...
|
||||
if (self._auto_increment == false) {
|
||||
// and auto-increment is disabled, no key, error.
|
||||
return error.DataError;
|
||||
}
|
||||
|
||||
if (Key.canInjectKey(value, kp) == false) {
|
||||
return error.DataError;
|
||||
}
|
||||
|
||||
generated = true;
|
||||
const n = try self._engine.nextGeneratedKey(self._store_id);
|
||||
const k = try local.newNumber(@floatFromInt(n));
|
||||
try Key.injectKey(local, value, kp, k);
|
||||
break :blk k;
|
||||
}
|
||||
|
||||
// Out-of-line keys.
|
||||
if (key_arg) |k| {
|
||||
break :blk k;
|
||||
}
|
||||
|
||||
if (self._auto_increment == false) {
|
||||
return error.DataError;
|
||||
}
|
||||
|
||||
generated = true;
|
||||
const n = try self._engine.nextGeneratedKey(self._store_id);
|
||||
break :blk try local.newNumber(@floatFromInt(n));
|
||||
};
|
||||
|
||||
const encoded = try Key.encodeValue(exec.call_arena, key_value);
|
||||
|
||||
if (self._auto_increment and !generated and key_value.isNumber()) {
|
||||
// auto-increment is enabled, but this was NOT a generated key, so we
|
||||
// need to bump the generator so that future generated keys don't collide
|
||||
try self._engine.maybeBumpGenerator(self._store_id, try key_value.toF64());
|
||||
}
|
||||
|
||||
const serialized = try value.serialize();
|
||||
defer serialized.deinit();
|
||||
@@ -190,7 +295,7 @@ fn write(self: *IDBObjectStore, value: js.Value, key_: ?js.Value, kind: WriteKin
|
||||
return request;
|
||||
};
|
||||
|
||||
try request.setValueResult(key);
|
||||
try request.setValueResult(key_value);
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -205,11 +310,15 @@ pub const JsApi = struct {
|
||||
|
||||
pub const name = bridge.accessor(IDBObjectStore.getName, null, .{});
|
||||
pub const keyPath = bridge.accessor(IDBObjectStore.getKeyPath, null, .{});
|
||||
pub const autoIncrement = bridge.accessor(IDBObjectStore.getAutoIncrement, null, .{});
|
||||
pub const transaction = bridge.accessor(IDBObjectStore.getTransaction, null, .{ .null_as_undefined = true });
|
||||
pub const add = bridge.function(IDBObjectStore.add, .{ .dom_exception = true });
|
||||
pub const put = bridge.function(IDBObjectStore.put, .{ .dom_exception = true });
|
||||
pub const get = bridge.function(IDBObjectStore.get, .{ .dom_exception = true });
|
||||
pub const getKey = bridge.function(IDBObjectStore.getKey, .{ .dom_exception = true });
|
||||
pub const delete = bridge.function(IDBObjectStore.delete, .{ .dom_exception = true });
|
||||
pub const clear = bridge.function(IDBObjectStore.clear, .{ .dom_exception = true });
|
||||
pub const count = bridge.function(IDBObjectStore.count, .{ .dom_exception = true });
|
||||
pub const getAll = bridge.function(IDBObjectStore.getAll, .{ .dom_exception = true });
|
||||
pub const getAllKeys = bridge.function(IDBObjectStore.getAllKeys, .{ .dom_exception = true });
|
||||
};
|
||||
@@ -177,12 +177,12 @@ pub fn newRequest(self: *IDBTransaction) !*IDBRequest {
|
||||
|
||||
pub fn objectStore(self: *IDBTransaction, name: []const u8, exec: *Execution) !*IDBObjectStore {
|
||||
const database_id = self._db._database_id;
|
||||
const store_id = (try self._engine.objectStoreId(database_id, name)) orelse {
|
||||
const info = (try self._engine.objectStoreInfo(exec.arena, database_id, name)) orelse {
|
||||
return error.NotFound;
|
||||
};
|
||||
|
||||
const owned_name = try exec.dupeString(name);
|
||||
return IDBObjectStore.init(self._engine, self, store_id, owned_name, null, exec);
|
||||
return IDBObjectStore.init(self._engine, self, info.id, owned_name, info.key_path, info.auto_increment, exec);
|
||||
}
|
||||
|
||||
pub fn getMode(self: *const IDBTransaction) Mode {
|
||||
|
||||
@@ -21,23 +21,27 @@ const std = @import("std");
|
||||
const js = @import("../../../js/js.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Local = js.Local;
|
||||
|
||||
const Key = @This();
|
||||
|
||||
// Type tags are the first encoded byte. The value is for correct sort order.
|
||||
const Tag = enum(u8) {
|
||||
number = 10,
|
||||
// date = 20,
|
||||
string = 30,
|
||||
// binary = 40,
|
||||
// array = 50,
|
||||
};
|
||||
const NUMBER_TAG: u8 = 10;
|
||||
const DATE_TAG: u8 = 20;
|
||||
const STRING_TAG: u8 = 30;
|
||||
const BINARY_TAG: u8 = 40;
|
||||
const ARRAY_TAG: u8 = 50;
|
||||
|
||||
// Guards against unbounded recursion / cyclic arrays when validating a JS value.
|
||||
const MAX_DEPTH = 32;
|
||||
|
||||
value: Value,
|
||||
|
||||
pub const Value = union(enum) {
|
||||
number: f64,
|
||||
date: f64,
|
||||
string: []const u8,
|
||||
binary: []const u8,
|
||||
array: []const Value,
|
||||
};
|
||||
|
||||
pub fn number(n: f64) Key {
|
||||
@@ -48,42 +52,252 @@ pub fn string(s: []const u8) Key {
|
||||
return .{ .value = .{ .string = s } };
|
||||
}
|
||||
|
||||
pub fn stringBuf(allocator: Allocator, payload_len: usize) ![]u8 {
|
||||
const buf = try allocator.alloc(u8, 1 + payload_len);
|
||||
buf[0] = @intFromEnum(Tag.string);
|
||||
return buf;
|
||||
pub fn binary(s: []const u8) Key {
|
||||
return .{ .value = .{ .binary = s } };
|
||||
}
|
||||
|
||||
pub fn encodeValue(value: js.Value, allocator: Allocator) ![]u8 {
|
||||
if (value.isString()) |s| {
|
||||
const buf = try stringBuf(allocator, s.len());
|
||||
_ = s.toSliceWithBuf(buf[1..]);
|
||||
return buf;
|
||||
}
|
||||
if (value.isNumber()) {
|
||||
return number(try value.toZig(f64)).encode(allocator);
|
||||
}
|
||||
return error.DataError;
|
||||
pub fn array(items: []const Value) Key {
|
||||
return .{ .value = .{ .array = items } };
|
||||
}
|
||||
|
||||
// Encode into an order-preserving byte slice, allocated by `allocator`.
|
||||
// Caller owns the returned memory.
|
||||
fn encode(self: Key, allocator: Allocator) ![]u8 {
|
||||
switch (self.value) {
|
||||
.number => |n| {
|
||||
var buf = try allocator.alloc(u8, 1 + 8);
|
||||
buf[0] = @intFromEnum(Tag.number);
|
||||
writeOrderedF64(buf[1..9], n);
|
||||
return buf;
|
||||
},
|
||||
.string => |s| {
|
||||
const buf = try stringBuf(allocator, s.len);
|
||||
@memcpy(buf[1..], s);
|
||||
return buf;
|
||||
pub fn encode(self: Key, allocator: Allocator) ![]u8 {
|
||||
var list: std.ArrayList(u8) = .empty;
|
||||
try encodeInto(allocator, self.value, &list);
|
||||
return list.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn encodeInto(allocator: Allocator, value: Value, list: *std.ArrayList(u8)) !void {
|
||||
switch (value) {
|
||||
.number => |n| try encodeF64(allocator, list, NUMBER_TAG, n),
|
||||
.date => |n| try encodeF64(allocator, list, DATE_TAG, n),
|
||||
.string => |s| try encodeBytes(allocator, list, STRING_TAG, s),
|
||||
.binary => |s| try encodeBytes(allocator, list, BINARY_TAG, s),
|
||||
.array => |items| {
|
||||
try list.append(allocator, ARRAY_TAG);
|
||||
for (items) |item| {
|
||||
try encodeInto(allocator, item, list);
|
||||
}
|
||||
try list.append(allocator, 0x00); // array terminator
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn encodeF64(allocator: Allocator, list: *std.ArrayList(u8), tag: u8, n: f64) !void {
|
||||
try list.append(allocator, tag);
|
||||
var buf: [8]u8 = undefined;
|
||||
writeOrderedF64(&buf, n);
|
||||
try list.appendSlice(allocator, &buf);
|
||||
}
|
||||
|
||||
fn encodeBytes(allocator: Allocator, list: *std.ArrayList(u8), tag: u8, data: []const u8) !void {
|
||||
try list.append(allocator, tag);
|
||||
for (data) |b| {
|
||||
if (b == 0x00) {
|
||||
try list.appendSlice(allocator, &.{ 0x00, 0xFF });
|
||||
} else {
|
||||
try list.append(allocator, b);
|
||||
}
|
||||
}
|
||||
try list.append(allocator, 0x00); // field terminator
|
||||
}
|
||||
|
||||
// Decode an encoded key back into a Key.Value. All sub-allocations (strings,
|
||||
// binary, array backing) come from `allocator` — use an arena and free as a unit.
|
||||
pub fn decode(allocator: Allocator, bytes: []const u8) !Value {
|
||||
var pos: usize = 0;
|
||||
const value = try decodeOne(allocator, bytes, &pos);
|
||||
return value;
|
||||
}
|
||||
|
||||
fn decodeOne(allocator: Allocator, bytes: []const u8, pos: *usize) !Value {
|
||||
if (pos.* >= bytes.len) return error.InvalidKeyEncoding;
|
||||
const tag = bytes[pos.*];
|
||||
pos.* += 1;
|
||||
switch (tag) {
|
||||
NUMBER_TAG, DATE_TAG => {
|
||||
if (pos.* + 8 > bytes.len) return error.InvalidKeyEncoding;
|
||||
const n = readOrderedF64(bytes[pos.*..][0..8]);
|
||||
pos.* += 8;
|
||||
return if (tag == NUMBER_TAG) .{ .number = n } else .{ .date = n };
|
||||
},
|
||||
STRING_TAG => return .{ .string = try decodeBytes(allocator, bytes, pos) },
|
||||
BINARY_TAG => return .{ .binary = try decodeBytes(allocator, bytes, pos) },
|
||||
ARRAY_TAG => {
|
||||
var items: std.ArrayList(Value) = .empty;
|
||||
while (pos.* < bytes.len and bytes[pos.*] != 0x00) {
|
||||
try items.append(allocator, try decodeOne(allocator, bytes, pos));
|
||||
}
|
||||
if (pos.* >= bytes.len) return error.InvalidKeyEncoding;
|
||||
pos.* += 1; // consume array terminator
|
||||
return .{ .array = try items.toOwnedSlice(allocator) };
|
||||
},
|
||||
else => return error.InvalidKeyEncoding,
|
||||
}
|
||||
}
|
||||
|
||||
fn decodeBytes(allocator: Allocator, bytes: []const u8, pos: *usize) ![]u8 {
|
||||
var out: std.ArrayList(u8) = .empty;
|
||||
while (true) {
|
||||
if (pos.* >= bytes.len) return error.InvalidKeyEncoding;
|
||||
const b = bytes[pos.*];
|
||||
pos.* += 1;
|
||||
if (b != 0x00) {
|
||||
try out.append(allocator, b);
|
||||
continue;
|
||||
}
|
||||
// 0x00: an escaped content byte (0x00 0xFF) or the field terminator.
|
||||
if (pos.* < bytes.len and bytes[pos.*] == 0xFF) {
|
||||
pos.* += 1;
|
||||
try out.append(allocator, 0x00);
|
||||
} else {
|
||||
break; // terminator
|
||||
}
|
||||
}
|
||||
return out.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
// Build a Key.Value from a JS value, validating that it is a structurally valid
|
||||
// IDB key. Invalid keys (booleans, null/undefined, objects, NaN, ±Inf, and —
|
||||
// until the binding supports it — Date) produce error.DataError.
|
||||
pub fn fromJs(value: js.Value, allocator: Allocator) !Value {
|
||||
return fromJsDepth(value, allocator, 0);
|
||||
}
|
||||
|
||||
fn fromJsDepth(value: js.Value, allocator: Allocator, depth: usize) !Value {
|
||||
if (depth > MAX_DEPTH) return error.DataError;
|
||||
|
||||
if (value.isString()) |s| {
|
||||
return .{ .string = try s.toSliceWithAlloc(allocator) };
|
||||
}
|
||||
if (value.isNumber()) {
|
||||
var n = try value.toF64();
|
||||
if (std.math.isNan(n) or std.math.isInf(n)) return error.DataError;
|
||||
if (n == 0) n = 0; // normalize -0 to +0
|
||||
return .{ .number = n };
|
||||
}
|
||||
if (value.isDate()) {
|
||||
// A Date coerces to its time value; an invalid Date (NaN) is not a key.
|
||||
const n = try value.toF64();
|
||||
if (std.math.isNan(n)) return error.DataError;
|
||||
return .{ .date = n };
|
||||
}
|
||||
if (value.isArrayBuffer() or value.isArrayBufferView()) {
|
||||
// toStringSmart returns the raw bytes for a confirmed binary value.
|
||||
const bytes = try value.toStringSmart();
|
||||
return .{ .binary = try allocator.dupe(u8, bytes) };
|
||||
}
|
||||
if (value.isArray()) {
|
||||
const arr = value.toArray();
|
||||
const len = arr.len();
|
||||
const items = try allocator.alloc(Value, len);
|
||||
for (0..len) |i| {
|
||||
const element = try arr.get(@intCast(i));
|
||||
items[i] = try fromJsDepth(element, allocator, depth + 1);
|
||||
}
|
||||
return .{ .array = items };
|
||||
}
|
||||
return error.DataError;
|
||||
}
|
||||
|
||||
// Build a JS value (in `local`) from a decoded Key.Value. Binary keys surface as
|
||||
// ArrayBuffer, arrays as Array, matching the spec's key-to-value conversion.
|
||||
pub fn toJs(value: Value, local: *const Local) !js.Value {
|
||||
switch (value) {
|
||||
.number => |n| return local.zigValueToJs(n, .{}),
|
||||
.date => |n| return local.newDate(n),
|
||||
.string => |s| return local.newString(s).toValue(),
|
||||
.binary => |s| return local.zigValueToJs(js.ArrayBuffer{ .values = s }, .{}),
|
||||
.array => |items| {
|
||||
const arr = local.newArray(@intCast(items.len));
|
||||
for (items, 0..) |item, i| {
|
||||
_ = try arr.set(@intCast(i), try toJs(item, local), .{});
|
||||
}
|
||||
return arr.toValue();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Given a key path , "manager.id", extract that from an object
|
||||
pub fn evaluatePath(value: js.Value, key_path: []const u8) ?js.Value {
|
||||
if (key_path.len == 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
var current = value;
|
||||
var it = std.mem.splitScalar(u8, key_path, '.');
|
||||
while (it.next()) |component| {
|
||||
if (!current.isObject()) {
|
||||
return null;
|
||||
}
|
||||
const next = current.toObject().get(component) catch return null;
|
||||
if (next.isUndefined()) {
|
||||
return null;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
// Whether a generated key can be injected at `key_path` (the spec's "check that
|
||||
// a key could be injected into a value"). Each existing path segment must be an
|
||||
// object; a missing segment is fine since injectKey creates it. Callers check
|
||||
// this before consuming a generated key so a doomed write doesn't advance the
|
||||
// key generator.
|
||||
pub fn canInjectKey(value: js.Value, key_path: []const u8) bool {
|
||||
const last_dot = std.mem.lastIndexOfScalar(u8, key_path, '.') orelse return value.isObject();
|
||||
|
||||
var current = value;
|
||||
var it = std.mem.splitScalar(u8, key_path[0..last_dot], '.');
|
||||
while (it.next()) |component| {
|
||||
if (!current.isObject()) {
|
||||
return false;
|
||||
}
|
||||
const next = current.toObject().get(component) catch return false;
|
||||
if (next.isUndefined()) {
|
||||
// injectKey will create the rest
|
||||
return true;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current.isObject();
|
||||
}
|
||||
|
||||
// Inject a key into a value at a (non-empty) key path, creating intermediate
|
||||
// objects as needed. Requires canInjectKey(value, key_path).
|
||||
pub fn injectKey(local: *const Local, value: js.Value, key_path: []const u8, key: js.Value) !void {
|
||||
const last_dot = std.mem.lastIndexOfScalar(u8, key_path, '.');
|
||||
const final = if (last_dot) |i| key_path[i + 1 ..] else key_path;
|
||||
|
||||
var current = value.toObject();
|
||||
if (last_dot) |i| {
|
||||
var it = std.mem.splitScalar(u8, key_path[0..i], '.');
|
||||
while (it.next()) |component| {
|
||||
const next = try current.get(component);
|
||||
if (next.isUndefined()) {
|
||||
const created = local.newObject();
|
||||
_ = try current.set(component, created.toValue(), .{});
|
||||
current = created;
|
||||
} else {
|
||||
current = next.toObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = try current.set(final, key, .{});
|
||||
}
|
||||
|
||||
// Convenience: validate+encode a JS value straight to its byte key.
|
||||
pub fn encodeValue(allocator: Allocator, value: js.Value) ![]u8 {
|
||||
const key: Key = .{ .value = try fromJs(value, allocator) };
|
||||
return key.encode(allocator);
|
||||
}
|
||||
|
||||
// Convenience: decode a byte key straight to a JS value.
|
||||
pub fn decodeToJs(allocator: Allocator, local: *const Local, bytes: []const u8) !js.Value {
|
||||
return toJs(try decode(allocator, bytes), local);
|
||||
}
|
||||
|
||||
// Map an f64 to 8 big-endian bytes whose unsigned ordering matches IEEE-754
|
||||
// numeric ordering. For positive numbers flip only the sign bit; for negative
|
||||
// numbers flip every bit. NaN is not a valid IDB key, so it is not handled.
|
||||
@@ -97,8 +311,36 @@ fn writeOrderedF64(out: *[8]u8, n: f64) void {
|
||||
std.mem.writeInt(u64, out, bits, .big);
|
||||
}
|
||||
|
||||
// Inverse of writeOrderedF64.
|
||||
fn readOrderedF64(in: *const [8]u8) f64 {
|
||||
var bits = std.mem.readInt(u64, in, .big);
|
||||
if (bits & (1 << 63) != 0) {
|
||||
bits &= ~@as(u64, 1 << 63); // was positive: we set the sign bit
|
||||
} else {
|
||||
bits = ~bits; // was negative: we flipped every bit
|
||||
}
|
||||
return @bitCast(bits);
|
||||
}
|
||||
|
||||
const testing = @import("../../../../testing.zig");
|
||||
|
||||
fn eql(a: Value, b: Value) bool {
|
||||
if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false;
|
||||
return switch (a) {
|
||||
.number => |n| n == b.number,
|
||||
.date => |n| n == b.date,
|
||||
.string => |s| std.mem.eql(u8, s, b.string),
|
||||
.binary => |s| std.mem.eql(u8, s, b.binary),
|
||||
.array => |items| blk: {
|
||||
if (items.len != b.array.len) break :blk false;
|
||||
for (items, b.array) |x, y| {
|
||||
if (!eql(x, y)) break :blk false;
|
||||
}
|
||||
break :blk true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test "IDB - Key: number encoding round-trips ordering" {
|
||||
const cases = [_]f64{ -1e308, -100.5, -1, -0.0001, 0, 0.0001, 1, 100.5, 1e308 };
|
||||
var prev: ?[]u8 = null;
|
||||
@@ -114,18 +356,71 @@ test "IDB - Key: number encoding round-trips ordering" {
|
||||
}
|
||||
}
|
||||
|
||||
test "IDB - Key: numbers sort before strings" {
|
||||
test "IDB - Key: cross-type ordering number < date < string < binary < array" {
|
||||
const num = try Key.number(1e308).encode(testing.allocator);
|
||||
defer testing.allocator.free(num);
|
||||
const str = try Key.string("").encode(testing.allocator);
|
||||
const date = try (Key{ .value = .{ .date = -1e308 } }).encode(testing.allocator);
|
||||
defer testing.allocator.free(date);
|
||||
const str = try Key.string("zzz").encode(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expect(std.mem.order(u8, num, str) == .lt);
|
||||
const bin = try Key.binary(&.{0xFF}).encode(testing.allocator);
|
||||
defer testing.allocator.free(bin);
|
||||
const arr = try Key.array(&.{.{ .number = 0 }}).encode(testing.allocator);
|
||||
defer testing.allocator.free(arr);
|
||||
|
||||
try testing.expect(std.mem.order(u8, num, date) == .lt);
|
||||
try testing.expect(std.mem.order(u8, date, str) == .lt);
|
||||
try testing.expect(std.mem.order(u8, str, bin) == .lt);
|
||||
try testing.expect(std.mem.order(u8, bin, arr) == .lt);
|
||||
}
|
||||
|
||||
test "IDB - Key: string encoding preserves byte order" {
|
||||
test "IDB - Key: string encoding preserves byte order and handles NUL" {
|
||||
const a = try Key.string("apple").encode(testing.allocator);
|
||||
defer testing.allocator.free(a);
|
||||
const b = try Key.string("banana").encode(testing.allocator);
|
||||
defer testing.allocator.free(b);
|
||||
try testing.expect(std.mem.order(u8, a, b) == .lt);
|
||||
|
||||
// A prefix sorts before a longer string, and an embedded NUL sorts after.
|
||||
const empty = try Key.string("").encode(testing.allocator);
|
||||
defer testing.allocator.free(empty);
|
||||
const x = try Key.string("x").encode(testing.allocator);
|
||||
defer testing.allocator.free(x);
|
||||
const x_nul = try Key.string("x\x00").encode(testing.allocator);
|
||||
defer testing.allocator.free(x_nul);
|
||||
try testing.expect(std.mem.order(u8, empty, x) == .lt);
|
||||
try testing.expect(std.mem.order(u8, x, x_nul) == .lt);
|
||||
}
|
||||
|
||||
test "IDB - Key: array ordering — shorter prefix sorts first, element order dominates" {
|
||||
const a1 = try Key.array(&.{.{ .number = 1 }}).encode(testing.allocator);
|
||||
defer testing.allocator.free(a1);
|
||||
const a12 = try Key.array(&.{ .{ .number = 1 }, .{ .number = 2 } }).encode(testing.allocator);
|
||||
defer testing.allocator.free(a12);
|
||||
const a2 = try Key.array(&.{.{ .number = 2 }}).encode(testing.allocator);
|
||||
defer testing.allocator.free(a2);
|
||||
|
||||
try testing.expect(std.mem.order(u8, a1, a12) == .lt); // [1] < [1,2]
|
||||
try testing.expect(std.mem.order(u8, a12, a2) == .lt); // [1,2] < [2]
|
||||
}
|
||||
|
||||
test "IDB - Key: encode/decode round-trip for every key type" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const cases = [_]Value{
|
||||
.{ .number = -42.5 },
|
||||
.{ .number = 0 },
|
||||
.{ .date = 1_700_000_000_000 },
|
||||
.{ .string = "héllo\x00world" },
|
||||
.{ .binary = &.{ 0x00, 0x01, 0xFF, 0x00 } },
|
||||
.{ .array = &.{ .{ .number = 1 }, .{ .string = "a" }, .{ .array = &.{.{ .number = 2 }} } } },
|
||||
};
|
||||
|
||||
for (cases) |case| {
|
||||
const enc = try (Key{ .value = case }).encode(arena);
|
||||
const dec = try Key.decode(arena, enc);
|
||||
try testing.expect(eql(case, dec));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub const Manager = @import("Manager.zig");
|
||||
pub const IDBFactory = @import("IDBFactory.zig");
|
||||
pub const IDBRequest = @import("IDBRequest.zig");
|
||||
pub const IDBDatabase = @import("IDBDatabase.zig");
|
||||
pub const IDBKeyRange = @import("IDBKeyRange.zig");
|
||||
pub const IDBTransaction = @import("IDBTransaction.zig");
|
||||
pub const IDBObjectStore = @import("IDBObjectStore.zig");
|
||||
pub const IDBVersionChangeEvent = @import("IDBVersionChangeEvent.zig");
|
||||
@@ -34,6 +35,7 @@ pub fn registerTypes() []const type {
|
||||
IDBFactory,
|
||||
IDBRequest,
|
||||
IDBDatabase,
|
||||
IDBKeyRange,
|
||||
IDBTransaction,
|
||||
IDBObjectStore,
|
||||
IDBVersionChangeEvent,
|
||||
|
||||
Reference in new issue
Block a user