webapi, idb: add IDBKeyRange

Including support for IDBKeyRange based operations
This commit is contained in:
Karl Seguin committed 2026-07-03 07:10:01 +08:00
1 parent 90a5a5fc51
commit 09e5e5df12
11 files changed
+1206 -80

No files matched your search

+12
View File
@@ -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,
+4
View File
@@ -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);
}
+325
View File
@@ -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>
{
+239 -17
View File
@@ -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 });
};
+129 -20
View File
@@ -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 {
+333 -38
View File
@@ -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));
}
}
+2
View File
@@ -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,