From 2961264052a57d91eb2af33aadeb1d4c5080106d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 3 Jul 2026 18:30:54 +0800 Subject: [PATCH] idb: add compound keys --- src/browser/webapi/storage/idb/Engine.zig | 70 +++++---- src/browser/webapi/storage/idb/IDBCursor.zig | 2 +- .../webapi/storage/idb/IDBDatabase.zig | 29 +++- src/browser/webapi/storage/idb/IDBIndex.zig | 10 +- .../webapi/storage/idb/IDBObjectStore.zig | 42 +++-- src/browser/webapi/storage/idb/Key.zig | 143 +++++++++++++++++- src/browser/webapi/storage/idb/idb.zig | 19 +++ 7 files changed, 261 insertions(+), 54 deletions(-) diff --git a/src/browser/webapi/storage/idb/Engine.zig b/src/browser/webapi/storage/idb/Engine.zig index 7b5c0b36e..7f612d991 100644 --- a/src/browser/webapi/storage/idb/Engine.zig +++ b/src/browser/webapi/storage/idb/Engine.zig @@ -19,6 +19,7 @@ const std = @import("std"); const lp = @import("lightpanda"); +const Key = @import("Key.zig"); const Sqlite = @import("../../../../storage/sqlite/Sqlite.zig"); const log = lp.log; @@ -133,6 +134,7 @@ pub fn open(path: [:0]const u8) !Engine { \\ database_id integer not null references idb_databases(id) on delete cascade, \\ name text not null, \\ key_path text, + \\ key_path_component_length integer not null default 0, \\ auto_increment integer not null default 0, \\ key_generator integer not null default 1, \\ unique(database_id, name) @@ -150,6 +152,7 @@ pub fn open(path: [:0]const u8) !Engine { \\ object_store_id integer not null references idb_object_stores(id) on delete cascade, \\ name text not null, \\ key_path text not null, + \\ key_path_component_length integer not null default 0, \\ is_unique integer not null default 0, \\ multi_entry integer not null default 0, \\ unique(object_store_id, name) @@ -220,13 +223,13 @@ pub fn objectStoreId(self: *const Engine, database_id: i64, name: []const u8) !? pub const StoreInfo = struct { id: i64, - key_path: ?[]const u8, + key_path: ?Key.KeyPath, 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", + "select id, key_path, key_path_component_length, auto_increment from idb_object_stores where database_id = ?1 and name = ?2", .{ database_id, name }, )) orelse return null; defer row.deinit(); @@ -234,8 +237,8 @@ pub fn objectStoreInfo(self: *const Engine, arena: Allocator, database_id: i64, 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_path = if (key_path) |kp| try Key.decodeKeyPathColumn(arena, kp, @intCast(row.get(i64, 2))) else null, + .auto_increment = row.get(bool, 3), }; } @@ -267,15 +270,23 @@ pub fn maybeBumpGenerator(self: *Engine, store_id: i64, key: f64) !void { pub fn createObjectStore( self: *Engine, + arena: Allocator, database_id: i64, name: []const u8, - key_path: ?[]const u8, + key_path: ?Key.KeyPath, auto_increment: bool, ) !i64 { + const column = if (key_path) |kp| try Key.encodeKeyPathColumn(arena, kp) else null; try self.conn.exec( - \\ insert into idb_object_stores (database_id, name, key_path, auto_increment) - \\ values (?1, ?2, ?3, ?4) - , .{ database_id, name, key_path, auto_increment }); + \\ insert into idb_object_stores (database_id, name, key_path, key_path_component_length, auto_increment) + \\ values (?1, ?2, ?3, ?4, ?5) + , .{ + database_id, + name, + if (column) |c| c.text else null, + if (column) |c| c.component_length else @as(usize, 0), + auto_increment, + }); return (try self.objectStoreId(database_id, name)).?; } @@ -320,18 +331,19 @@ pub fn clear(self: *Engine, object_store_id: i64) !void { pub const IndexInfo = struct { id: i64, - key_path: []const u8, + key_path: Key.KeyPath, unique: bool, multi_entry: bool, }; -pub fn createIndexRow(self: *Engine, object_store_id: i64, name: []const u8, key_path: []const u8, unique: bool, multi_entry: bool) !i64 { +pub fn createIndexRow(self: *Engine, arena: Allocator, object_store_id: i64, name: []const u8, key_path: Key.KeyPath, unique: bool, multi_entry: bool) !i64 { + const column = try Key.encodeKeyPathColumn(arena, key_path); return (try self.conn.scalar( i64, - \\ insert into idb_indexes (object_store_id, name, key_path, is_unique, multi_entry) - \\ values (?1, ?2, ?3, ?4, ?5) returning id + \\ insert into idb_indexes (object_store_id, name, key_path, key_path_component_length, is_unique, multi_entry) + \\ values (?1, ?2, ?3, ?4, ?5, ?6) returning id , - .{ object_store_id, name, key_path, unique, multi_entry }, + .{ object_store_id, name, column.text, column.component_length, unique, multi_entry }, )) orelse error.UnknownError; } @@ -348,22 +360,22 @@ pub fn deleteIndexRow(self: *Engine, object_store_id: i64, name: []const u8) !vo pub fn indexInfo(self: *const Engine, arena: Allocator, object_store_id: i64, name: []const u8) !?IndexInfo { var row = (try self.conn.row( - "select id, key_path, is_unique, multi_entry from idb_indexes where object_store_id = ?1 and name = ?2", + "select id, key_path, key_path_component_length, is_unique, multi_entry from idb_indexes where object_store_id = ?1 and name = ?2", .{ object_store_id, name }, )) orelse return null; defer row.deinit(); return .{ .id = row.get(i64, 0), - .key_path = try arena.dupe(u8, row.get([]const u8, 1)), - .unique = row.get(bool, 2), - .multi_entry = row.get(bool, 3), + .key_path = try Key.decodeKeyPathColumn(arena, row.get([]const u8, 1), @intCast(row.get(i64, 2))), + .unique = row.get(bool, 3), + .multi_entry = row.get(bool, 4), }; } pub fn indexesForStore(self: *const Engine, arena: Allocator, object_store_id: i64) ![]IndexInfo { var rows = try self.conn.rows( - "select id, key_path, is_unique, multi_entry from idb_indexes where object_store_id = ?1", + "select id, key_path, key_path_component_length, is_unique, multi_entry from idb_indexes where object_store_id = ?1", .{object_store_id}, ); defer rows.deinit(); @@ -372,9 +384,9 @@ pub fn indexesForStore(self: *const Engine, arena: Allocator, object_store_id: i while (try rows.next()) |row| { try list.append(arena, .{ .id = row.get(i64, 0), - .key_path = try arena.dupe(u8, row.get([]const u8, 1)), - .unique = row.get(bool, 2), - .multi_entry = row.get(bool, 3), + .key_path = try Key.decodeKeyPathColumn(arena, row.get([]const u8, 1), @intCast(row.get(i64, 2))), + .unique = row.get(bool, 3), + .multi_entry = row.get(bool, 4), }); } return list.items; @@ -769,7 +781,7 @@ test "IDB - Engine: ranged getAll/count honour open and closed bounds" { 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); + const store_id = try engine.createObjectStore(testing.allocator, db_id, "s", null, false); try seedNumbers(&engine, store_id, arena, &.{ 3, 1, 5, 2, 4 }); const k2 = try numKey(arena, 2); @@ -821,7 +833,7 @@ test "IDB - Engine: getRange/getKeyRange first-in-range and deleteRange" { 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); + const store_id = try engine.createObjectStore(testing.allocator, db_id, "s", null, false); try seedNumbers(&engine, store_id, arena, &.{ 1, 2, 3, 4, 5 }); const k2 = try numKey(arena, 2); @@ -909,7 +921,7 @@ test "IDB - Engine: database + object store + add/get round-trip" { const db_id = try engine.upsertDatabase("app", 1); try testing.expectEqual(1, (try engine.databaseVersion("app")).?); - const store_id = try engine.createObjectStore(db_id, "books", null, false); + const store_id = try engine.createObjectStore(testing.allocator, db_id, "books", null, false); // Value bytes deliberately include an embedded NUL and high bytes to prove // the BLOB path is binary-safe (serialized values are arbitrary bytes). @@ -928,7 +940,7 @@ test "IDB - Engine: add rejects duplicate key, put overwrites" { defer engine.close(); const db_id = try engine.upsertDatabase("app", 1); - const store_id = try engine.createObjectStore(db_id, "s", null, false); + const store_id = try engine.createObjectStore(testing.allocator, db_id, "s", null, false); try engine.add(store_id, "k", "v1"); try testing.expectError(error.Constraint, engine.add(store_id, "k", "v2")); @@ -944,8 +956,8 @@ test "IDB - Engine: createObjectStore rejects duplicate name" { defer engine.close(); const db_id = try engine.upsertDatabase("app", 1); - _ = try engine.createObjectStore(db_id, "s", null, false); - try testing.expectError(error.Constraint, engine.createObjectStore(db_id, "s", null, false)); + _ = try engine.createObjectStore(testing.allocator, db_id, "s", null, false); + try testing.expectError(error.Constraint, engine.createObjectStore(testing.allocator, db_id, "s", null, false)); } test "IDB - Engine: rollback discards uncommitted writes" { @@ -953,7 +965,7 @@ test "IDB - Engine: rollback discards uncommitted writes" { defer engine.close(); const db_id = try engine.upsertDatabase("app", 1); - const store_id = try engine.createObjectStore(db_id, "s", null, false); + const store_id = try engine.createObjectStore(testing.allocator, db_id, "s", null, false); try engine.begin(); try engine.add(store_id, "k", "v"); @@ -962,8 +974,6 @@ 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", 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 { diff --git a/src/browser/webapi/storage/idb/IDBCursor.zig b/src/browser/webapi/storage/idb/IDBCursor.zig index 2254f8296..e347f0557 100644 --- a/src/browser/webapi/storage/idb/IDBCursor.zig +++ b/src/browser/webapi/storage/idb/IDBCursor.zig @@ -245,7 +245,7 @@ pub fn update(self: *IDBCursor, value: js.Value, exec: *Execution) !*IDBRequest // For an in-line store, the value's own key must match the record's key. if (self._store._key_path) |kp| { - const extracted = Key.evaluatePath(value, kp) orelse return error.DataError; + const extracted = (try Key.extractKeyPath(exec.js.local.?, value, kp)) orelse return error.DataError; const encoded = try Key.encodeValue(exec.call_arena, extracted); if (!std.mem.eql(u8, encoded, current_key)) { return error.DataError; diff --git a/src/browser/webapi/storage/idb/IDBDatabase.zig b/src/browser/webapi/storage/idb/IDBDatabase.zig index 5df778060..0ce342f45 100644 --- a/src/browser/webapi/storage/idb/IDBDatabase.zig +++ b/src/browser/webapi/storage/idb/IDBDatabase.zig @@ -24,6 +24,7 @@ const js = @import("../../../js/js.zig"); const EventTarget = @import("../../EventTarget.zig"); const idb = @import("idb.zig"); +const Key = @import("Key.zig"); const Engine = @import("Engine.zig"); const IDBTransaction = @import("IDBTransaction.zig"); const IDBObjectStore = @import("IDBObjectStore.zig"); @@ -61,7 +62,7 @@ pub fn asEventTarget(self: *IDBDatabase) *EventTarget { } const CreateObjectStoreOptions = struct { - keyPath: ?[]const u8 = null, + keyPath: ?Key.KeyPath = null, autoIncrement: bool = false, }; @@ -78,10 +79,24 @@ pub fn createObjectStore( try txn.assertActive(); const opts = options orelse CreateObjectStoreOptions{}; + + // Validate + copy the key path onto the transaction arena so it outlives the + // call. autoIncrement is incompatible with an empty or compound key path. + const key_path: ?Key.KeyPath = if (opts.keyPath) |kp| blk: { + if (Key.isValidKeyPathSpec(kp) == false) { + return error.SyntaxError; + } + if (opts.autoIncrement and keyPathBlocksAutoIncrement(kp)) { + return error.InvalidAccessError; + } + break :blk try Key.dupeKeyPath(txn._arena, kp); + } else null; + const store_id = self._engine.createObjectStore( + txn._arena, self._database_id, name, - opts.keyPath, + key_path, opts.autoIncrement, ) catch |err| switch (err) { error.Constraint => return error.ConstraintError, @@ -89,12 +104,20 @@ pub fn createObjectStore( }; const owned_name = try txn.dupe(name); - const key_path = if (opts.keyPath) |kp| try txn.dupe(kp) else null; const store = try IDBObjectStore.init(txn, store_id, owned_name, key_path, opts.autoIncrement); try txn.cacheStore(store); return store; } +// autoIncrement requires an out-of-line or single-property in-line key; an empty +// or compound key path can't carry a generated key. +fn keyPathBlocksAutoIncrement(kp: Key.KeyPath) bool { + return switch (kp) { + .string => |s| s.len == 0, + .list => true, + }; +} + // Only callable while the upgrade transaction is live and active, hence the checks pub fn deleteObjectStore(self: *IDBDatabase, name: []const u8, _: *Execution) !void { const txn = self._txn orelse return error.InvalidStateError; diff --git a/src/browser/webapi/storage/idb/IDBIndex.zig b/src/browser/webapi/storage/idb/IDBIndex.zig index be83891b1..85d7fa35c 100644 --- a/src/browser/webapi/storage/idb/IDBIndex.zig +++ b/src/browser/webapi/storage/idb/IDBIndex.zig @@ -22,6 +22,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); +const idb = @import("idb.zig"); const Key = @import("Key.zig"); const Engine = @import("Engine.zig"); const IDBCursor = @import("IDBCursor.zig"); @@ -40,9 +41,12 @@ _store: *IDBObjectStore, _engine: *Engine, _index_id: i64, _name: []const u8, -_key_path: []const u8, +_key_path: Key.KeyPath, _unique: bool, _multi_entry: bool, +// not just for efficiency, we must return the same v8::Array every time the +// compound key is accessed. +_key_path_js: ?*js.Value.BareGlobal = null, pub fn init(obj_store: *IDBObjectStore, info: Engine.IndexInfo, name: []const u8) !*IDBIndex { const self = try obj_store._txn._arena.create(IDBIndex); @@ -197,8 +201,8 @@ pub fn getName(self: *const IDBIndex) []const u8 { return self._name; } -pub fn getKeyPath(self: *const IDBIndex) []const u8 { - return self._key_path; +pub fn getKeyPath(self: *IDBIndex, exec: *Execution) !js.Value { + return idb.cachedKeyPathJs(&self._key_path_js, self._store._txn, self._key_path, exec); } pub fn getUnique(self: *const IDBIndex) bool { diff --git a/src/browser/webapi/storage/idb/IDBObjectStore.zig b/src/browser/webapi/storage/idb/IDBObjectStore.zig index e3311c7d8..63755d2c2 100644 --- a/src/browser/webapi/storage/idb/IDBObjectStore.zig +++ b/src/browser/webapi/storage/idb/IDBObjectStore.zig @@ -22,6 +22,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); +const idb = @import("idb.zig"); const Key = @import("Key.zig"); const Engine = @import("Engine.zig"); const IDBIndex = @import("IDBIndex.zig"); @@ -41,18 +42,21 @@ const IDBObjectStore = @This(); _engine: *Engine, _store_id: i64, _name: []const u8, -_key_path: ?[]const u8, +_key_path: ?Key.KeyPath, _auto_increment: bool, _txn: *IDBTransaction, _deleted: bool = false, // identity map, store.indexes('a') === store.index('a') _indexes: std.ArrayList(*IDBIndex) = .empty, +// not just for efficiency, we must return the same v8::Array every time the +// compound key is accessed. +_key_path_js: ?*js.Value.BareGlobal = null, pub fn init( txn: *IDBTransaction, store_id: i64, name: []const u8, - key_path: ?[]const u8, + key_path: ?Key.KeyPath, auto_increment: bool, ) !*IDBObjectStore { const self = try txn._arena.create(IDBObjectStore); @@ -267,8 +271,8 @@ pub fn getName(self: *const IDBObjectStore) []const u8 { return self._name; } -pub fn getKeyPath(self: *const IDBObjectStore) ?[]const u8 { - return self._key_path; +pub fn getKeyPath(self: *IDBObjectStore, exec: *Execution) !js.Value { + return idb.cachedKeyPathJs(&self._key_path_js, self._txn, self._key_path, exec); } pub fn getAutoIncrement(self: *const IDBObjectStore) bool { @@ -311,7 +315,7 @@ fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: Write // can't have an explicit key if we're configured for in-line keys return error.DataError; } - if (Key.evaluatePath(value, kp)) |extracted| { + if (try Key.extractKeyPath(exec.js.local.?, value, kp)) |extracted| { break :blk .{ .explicit = .{ .encoded = try Key.encodeValue(txn._arena, extracted), .bump = if (self._auto_increment and extracted.isNumber()) try extracted.toF64() else null, @@ -321,7 +325,9 @@ fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: Write if (self._auto_increment == false) { return error.DataError; } - if (Key.canInjectKey(value, kp) == false) { + // A compound or empty key path can't carry a generated key, so + // auto_increment implies a non-empty single-property path here. + if (Key.canInjectKey(value, kp.string) == false) { return error.DataError; } break :blk .generate_in_line; @@ -382,7 +388,7 @@ fn writeInner(self: *IDBObjectStore, request: *IDBRequest, kind: WriteKind, valu .generate_in_line => blk: { const n = try self._engine.nextGeneratedKey(self._store_id); const k = try local.newNumber(@floatFromInt(n)); - try Key.injectKey(local, value, self._key_path.?, k); + try Key.injectKey(local, value, self._key_path.?.string, k); break :blk try Key.encodeValue(exec.call_arena, k); }, }; @@ -439,12 +445,12 @@ fn reindex(self: *IDBObjectStore, value: js.Value, primary_key: []const u8, exec var seen: std.ArrayList([]const u8) = .empty; try self._engine.deleteIndexRecordsForKey(self._store_id, primary_key); for (indexes) |idx| { - try self.addIndexEntries(arena, &seen, idx.id, idx.unique, idx.multi_entry, idx.key_path, value, primary_key); + try self.addIndexEntries(exec.js.local.?, arena, &seen, idx.id, idx.unique, idx.multi_entry, idx.key_path, value, primary_key); } } -fn addIndexEntries(self: *IDBObjectStore, arena: Allocator, seen: *std.ArrayList([]const u8), index_id: i64, unique: bool, multi_entry: bool, key_path: []const u8, value: js.Value, primary_key: []const u8) !void { - const extracted = Key.evaluatePath(value, key_path) orelse return; +fn addIndexEntries(self: *IDBObjectStore, local: *const js.Local, arena: Allocator, seen: *std.ArrayList([]const u8), index_id: i64, unique: bool, multi_entry: bool, key_path: Key.KeyPath, value: js.Value, primary_key: []const u8) !void { + const extracted = (try Key.extractKeyPath(local, value, key_path)) orelse return; if (multi_entry and extracted.isArray()) { seen.clearRetainingCapacity(); @@ -473,7 +479,7 @@ const CreateIndexOptions = struct { }; // Only callable during an upgrade (versionchange transaction). -pub fn createIndex(self: *IDBObjectStore, name: []const u8, key_path: []const u8, options: ?CreateIndexOptions, exec: *Execution) !*IDBIndex { +pub fn createIndex(self: *IDBObjectStore, name: []const u8, key_path: Key.KeyPath, options: ?CreateIndexOptions, exec: *Execution) !*IDBIndex { try self.assertLive(); const txn = self._txn; if (txn._mode != .versionchange) { @@ -481,12 +487,21 @@ pub fn createIndex(self: *IDBObjectStore, name: []const u8, key_path: []const u8 } // Spec order: the transaction-state check precedes the index-name check. try txn.assertActive(); + if (Key.isValidKeyPathSpec(key_path) == false) { + return error.SyntaxError; + } const opts = options orelse CreateIndexOptions{}; + // multiEntry is meaningless for a compound key path. + if (opts.multiEntry and std.meta.activeTag(key_path) == .list) { + return error.InvalidAccessError; + } + + const owned_key_path = try Key.dupeKeyPath(txn._arena, key_path); try self._engine.savepoint(); errdefer self._engine.rollbackSavepoint(); - const index_id = self._engine.createIndexRow(self._store_id, name, key_path, opts.unique, opts.multiEntry) catch |err| switch (err) { + const index_id = self._engine.createIndexRow(txn._arena, self._store_id, name, owned_key_path, opts.unique, opts.multiEntry) catch |err| switch (err) { error.Constraint => return error.ConstraintError, // duplicate index name else => return err, }; @@ -503,12 +518,11 @@ pub fn createIndex(self: *IDBObjectStore, name: []const u8, key_path: []const u8 var seen: std.ArrayList([]const u8) = .empty; while (try rows.next()) |row| { const value = try js.Value.deserialize(local, row.get([]const u8, 1)); - try self.addIndexEntries(arena, &seen, index_id, opts.unique, opts.multiEntry, key_path, value, row.get([]const u8, 0)); + try self.addIndexEntries(local, arena, &seen, index_id, opts.unique, opts.multiEntry, owned_key_path, value, row.get([]const u8, 0)); } } const owned_name = try txn.dupe(name); - const owned_key_path = try txn.dupe(key_path); const idb_index = try IDBIndex.init(self, .{ .id = index_id, .key_path = owned_key_path, diff --git a/src/browser/webapi/storage/idb/Key.zig b/src/browser/webapi/storage/idb/Key.zig index 4e4e492a5..1bca8ccb6 100644 --- a/src/browser/webapi/storage/idb/Key.zig +++ b/src/browser/webapi/storage/idb/Key.zig @@ -44,6 +44,14 @@ pub const Value = union(enum) { array: []const Value, }; +// A key path is either a single string path or, for a compound key, a list of +// string paths. An out-of-line store has no key path at all (represented as an +// optional KeyPath by its holders). +pub const KeyPath = union(enum) { + string: []const u8, + list: []const []const u8, +}; + pub fn number(n: f64) Key { return .{ .value = .{ .number = n } }; } @@ -159,8 +167,8 @@ fn decodeBytes(allocator: Allocator, bytes: []const u8, pos: *usize) ![]u8 { } // 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. +// IDB key. Invalid keys (booleans, null/undefined, plain objects, NaN, and +// invalid Dates) produce error.DataError. pub fn fromJs(value: js.Value, allocator: Allocator) !Value { return fromJsDepth(value, allocator, 0); } @@ -173,7 +181,8 @@ fn fromJsDepth(value: js.Value, allocator: Allocator, depth: usize) !Value { } if (value.isNumber()) { var n = try value.toF64(); - if (std.math.isNan(n) or std.math.isInf(n)) return error.DataError; + // NaN is not a valid key; ±Infinity is (it sorts at the numeric extremes). + if (std.math.isNan(n)) return error.DataError; if (n == 0) n = 0; // normalize -0 to +0 return .{ .number = n }; } @@ -219,6 +228,134 @@ pub fn toJs(value: Value, local: *const Local) !js.Value { } } +pub fn isValidKeyPath(path: []const u8) bool { + if (path.len == 0) { + // empty is valid + return true; + } + + // if not empty, must be one or more "identifiers" split by '.' + var it = std.mem.splitScalar(u8, path, '.'); + while (it.next()) |component| { + if (isIdentifier(component) == false) { + return false; + } + } + return true; +} + +fn isIdentifier(s: []const u8) bool { + if (s.len == 0) { + return false; + } + + // leading 0-9 not allowed + const first_ok = switch (s[0]) { + 'a'...'z', 'A'...'Z', '_', '$' => true, + else => |c| c >= 0x80, + }; + if (first_ok == false) { + return false; + } + + for (s[1..]) |c| { + const ok = switch (c) { + 'a'...'z', 'A'...'Z', '_', '$', '0' ... '9' => true, + else => c >= 0x80, + }; + if (ok == false) { + return false; + } + } + return true; +} + +// Validate the spec's (DOMString or sequence) key path form. A +// compound path must be a non-empty list of valid, non-empty string paths. +pub fn isValidKeyPathSpec(kp: KeyPath) bool { + switch (kp) { + .string => |s| return isValidKeyPath(s), + .list => |items| { + if (items.len == 0) { + return false; + } + for (items) |item| { + if (item.len == 0 or isValidKeyPath(item) == false) { + return false; + } + } + return true; + }, + } +} + +pub fn dupeKeyPath(arena: Allocator, kp: KeyPath) !KeyPath { + switch (kp) { + .string => |s| return .{ .string = try arena.dupe(u8, s) }, + .list => |items| { + const copy = try arena.alloc([]const u8, items.len); + for (items, 0..) |item, i| { + copy[i] = try arena.dupe(u8, item); + } + return .{ .list = copy }; + }, + } +} + +pub fn extractKeyPath(local: *const Local, value: js.Value, kp: KeyPath) !?js.Value { + switch (kp) { + .string => |s| return evaluatePath(value, s), + .list => |items| { + const arr = local.newArray(@intCast(items.len)); + for (items, 0..) |item, i| { + const component = evaluatePath(value, item) orelse return null; + _ = try arr.set(@intCast(i), component, .{}); + } + return arr.toValue(); + }, + } +} + +pub fn keyPathToJs(local: *const Local, kp: ?KeyPath) !js.Value { + const path = kp orelse return .{ .local = local, .handle = local.isolate.initNull() }; + switch (path) { + .string => |s| return local.newString(s).toValue(), + .list => |items| { + const arr = local.newArray(@intCast(items.len)); + for (items, 0..) |item, i| { + _ = try arr.set(@intCast(i), local.newString(item).toValue(), .{}); + } + return arr.toValue(); + }, + } +} + +// Storage form of a key path: the text column value and its component count. A +// count of 0 denotes a single string path; a positive count is a compound path +// of that many ','-joined components (a valid component can't contain a ','). +// The count both disambiguates a one-element list from a string and lets decode +// allocate the exact slice. +pub const ColumnKeyPath = struct { text: []const u8, component_length: usize }; + +pub fn encodeKeyPathColumn(arena: Allocator, kp: KeyPath) !ColumnKeyPath { + return switch (kp) { + .string => |s| .{ .text = s, .component_length = 0 }, + .list => |items| .{ .text = try std.mem.join(arena, ",", items), .component_length = items.len }, + }; +} + +pub fn decodeKeyPathColumn(arena: Allocator, text: []const u8, component_length: usize) !KeyPath { + if (component_length == 0) { + return .{ .string = try arena.dupe(u8, text) }; + } + const items = try arena.alloc([]const u8, component_length); + var it = std.mem.splitScalar(u8, text, ','); + for (items) |*item| { + item.* = try arena.dupe(u8, it.next() orelse return error.InvalidKeyEncoding); + } + return .{ .list = items }; +} + // 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) { diff --git a/src/browser/webapi/storage/idb/idb.zig b/src/browser/webapi/storage/idb/idb.zig index 65071aa6d..8b196c996 100644 --- a/src/browser/webapi/storage/idb/idb.zig +++ b/src/browser/webapi/storage/idb/idb.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const js = @import("../../../js/js.zig"); pub const Key = @import("Key.zig"); @@ -50,6 +51,24 @@ pub fn registerTypes() []const type { }; } +// the keyPath attribute need identity equality. For a compound key (an array) +// we have to return the same v8::rray on every reach. So users of KeyPath will +// cache it locally, and store it in the Transaction's globals list for cleanup. +pub fn cachedKeyPathJs(cache: *?*js.Value.BareGlobal, txn: *IDBTransaction, kp: ?Key.KeyPath, exec: *js.Execution) !js.Value { + const local = exec.js.local.?; + if (kp) |path| { + if (std.meta.activeTag(path) == .list) { + if (cache.*) |slot| { + return slot.local(local); + } + const value = try Key.keyPathToJs(local, path); + cache.* = try txn.persist(value); + return value; + } + } + return Key.keyPathToJs(local, kp); +} + // An on* event-handler attribute setter. The bridge can hand the setter either // a function (store it) or any other value (clears it). pub const FunctionSetter = union(enum) {