From 90a5a5fc51314430dcc0280751fd827656dd2958 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 29 Jun 2026 18:25:01 +0800 Subject: [PATCH] webapi: IndexedDB base Initial WIP on IndexedDB WebAPI. Uses sqlite and a new `--indexdb_dir` config. Defaults to :memory:. The main things missing are the IDBCursor, IDBIndex and IDBKeyRange, along with a bunch of smaller apis. Also, every object is currently tied to the Page arena / factory. It's possible that's how it will have to be, but, once more of the API lands, I will check if we can scope these better. --- src/Config.zig | 8 + src/browser/Session.zig | 9 +- src/browser/js/String.zig | 9 + src/browser/js/Value.zig | 3 + src/browser/js/bridge.zig | 2 + src/browser/tests/indexeddb.html | 330 ++++++++++++++++++ src/browser/webapi/DOMException.zig | 28 +- src/browser/webapi/Event.zig | 2 + src/browser/webapi/EventTarget.zig | 9 + src/browser/webapi/Window.zig | 12 + src/browser/webapi/WorkerGlobalScope.zig | 12 + src/browser/webapi/storage/idb/Engine.zig | 260 ++++++++++++++ .../webapi/storage/idb/IDBDatabase.zig | 153 ++++++++ src/browser/webapi/storage/idb/IDBFactory.zig | 225 ++++++++++++ .../webapi/storage/idb/IDBObjectStore.zig | 215 ++++++++++++ src/browser/webapi/storage/idb/IDBRequest.zig | 183 ++++++++++ .../webapi/storage/idb/IDBTransaction.zig | 289 +++++++++++++++ .../storage/idb/IDBVersionChangeEvent.zig | 93 +++++ src/browser/webapi/storage/idb/Key.zig | 131 +++++++ src/browser/webapi/storage/idb/Manager.zig | 129 +++++++ src/browser/webapi/storage/idb/idb.zig | 53 +++ src/help.zon | 3 + 22 files changed, 2154 insertions(+), 4 deletions(-) create mode 100644 src/browser/tests/indexeddb.html create mode 100644 src/browser/webapi/storage/idb/Engine.zig create mode 100644 src/browser/webapi/storage/idb/IDBDatabase.zig create mode 100644 src/browser/webapi/storage/idb/IDBFactory.zig create mode 100644 src/browser/webapi/storage/idb/IDBObjectStore.zig create mode 100644 src/browser/webapi/storage/idb/IDBRequest.zig create mode 100644 src/browser/webapi/storage/idb/IDBTransaction.zig create mode 100644 src/browser/webapi/storage/idb/IDBVersionChangeEvent.zig create mode 100644 src/browser/webapi/storage/idb/Key.zig create mode 100644 src/browser/webapi/storage/idb/Manager.zig create mode 100644 src/browser/webapi/storage/idb/idb.zig diff --git a/src/Config.zig b/src/Config.zig index 68db591aa..9fa8d6f7d 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -114,6 +114,7 @@ const CommonOptions = .{ .{ .name = "disable_subframes", .type = bool }, .{ .name = "disable_workers", .type = bool }, .{ .name = "enable_external_stylesheets", .type = bool }, + .{ .name = "indexdb_dir", .type = ?[:0]const u8 }, }; fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat { @@ -567,6 +568,13 @@ pub fn storageSqlitePath(self: *const Config) ?[:0]const u8 { }; } +pub fn indexDBDir(self: *const Config) ?[:0]const u8 { + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.indexdb_dir, + else => unreachable, + }; +} + pub const DumpFormat = enum { html, markdown, diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 4d113012f..33f041104 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -22,9 +22,10 @@ const builtin = @import("builtin"); const App = @import("../App.zig"); -const storage = @import("webapi/storage/storage.zig"); -const Navigation = @import("webapi/navigation/Navigation.zig"); const History = @import("webapi/History.zig"); +const storage = @import("webapi/storage/storage.zig"); +const IdbManager = @import("webapi/storage/idb/idb.zig").Manager; +const Navigation = @import("webapi/navigation/Navigation.zig"); const Page = @import("Page.zig"); const Frame = @import("Frame.zig"); @@ -50,6 +51,8 @@ arena: Allocator, history: History, navigation: Navigation, storage_shed: storage.Shed, +// Per-origin IndexedDB engines +idb: IdbManager, // Backs `globalThis.lp.*`; values pre-stringified so the prelude splices // them in without re-encoding. bridge_store: std.StringHashMapUnmanaged([]const u8) = .empty, @@ -156,6 +159,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi // The prototype (EventTarget) for Navigation is created when a Frame is created. .navigation = .{ ._proto = undefined }, .storage_shed = .{}, + .idb = IdbManager.init(allocator, browser.app.config.indexDBDir()), .browser = browser, .notification = notification, .cookie_jar = storage.Cookie.Jar.init(allocator, notification), @@ -180,6 +184,7 @@ pub fn deinit(self: *Session) void { self.browser.env.memoryPressureNotification(.critical); self.storage_shed.deinit(self.browser.app.allocator); + self.idb.deinit(); { const allocator = self.browser.app.allocator; var it = self.bridge_store.iterator(); diff --git a/src/browser/js/String.zig b/src/browser/js/String.zig index c405b0e1c..b6dff8158 100644 --- a/src/browser/js/String.zig +++ b/src/browser/js/String.zig @@ -50,12 +50,21 @@ pub fn toValue(self: String) js.Value { pub fn toSlice(self: String) ![]u8 { return self._toSlice(false, self.local.call_arena); } + pub fn toSliceZ(self: String) ![:0]u8 { return self._toSlice(true, self.local.call_arena); } + pub fn toSliceWithAlloc(self: String, allocator: Allocator) ![]u8 { return self._toSlice(false, allocator); } + +// e.g. after a header byte — instead of allocating a temporary and copying. +pub fn toSliceWithBuf(self: String, buf: []u8) []u8 { + const n = v8.v8__String__WriteUtf8(self.handle, self.local.isolate.handle, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8); + return buf[0..@intCast(n)]; +} + fn _toSlice(self: String, comptime null_terminate: bool, allocator: Allocator) !(if (null_terminate) [:0]u8 else []u8) { const local = self.local; const handle = self.handle; diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index c9d4fb5e3..3669448f6 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -370,6 +370,7 @@ pub fn serialize(self: Value) !Serialized { .throw_data_clone_error = CloneDelegate.throwDataCloneError, }) orelse return error.JsException; defer v8.v8__ValueSerializer__DELETE(serializer); + // the delegate callbacks only fire during WriteValue, after this is set delegate_ctx.serializer = serializer; @@ -409,6 +410,7 @@ pub fn deserialize(local: *const js.Local, bytes: []const u8) !Value { return .{ .local = local, .handle = handle }; } +<<<<<<< HEAD // Host object types that support structured cloning via structuredSerialize / // structuredDeserialize hooks. The serialized payload tags each host object // with its position in this list; buffers never outlive the process, so the @@ -614,6 +616,7 @@ const CloneDelegate = struct { } }; + pub fn persist(self: Value) !Global { return self._persist(true); } diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 6131f19b2..9559d9a94 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -966,6 +966,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/Node.zig"), @import("../webapi/storage/storage.zig"), @import("../webapi/storage/CookieStore.zig"), + @import("../webapi/storage/idb/idb.zig"), @import("../webapi/event/CookieChangeEvent.zig"), @import("../webapi/URL.zig"), @import("../webapi/Window.zig"), @@ -1057,6 +1058,7 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/Performance.zig"), @import("../webapi/PerformanceObserver.zig"), @import("../webapi/storage/CookieStore.zig"), + @import("../webapi/storage/idb/idb.zig"), @import("../webapi/event/CookieChangeEvent.zig"), @import("../webapi/BroadcastChannel.zig"), }); diff --git a/src/browser/tests/indexeddb.html b/src/browser/tests/indexeddb.html new file mode 100644 index 000000000..1c818a6eb --- /dev/null +++ b/src/browser/tests/indexeddb.html @@ -0,0 +1,330 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index 296d2965a..ab18fa05c 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -61,14 +61,18 @@ pub fn fromError(err: anyerror) ?DOMException { error.InvalidAccessError => .{ ._code = .invalid_access_error }, error.OperationError => .{ ._code = .operation_error }, error.DataError => .{ ._code = .data_error }, + error.ConstraintError => .{ ._code = .constraint_error }, + error.VersionError => .{ ._code = .version_error }, + error.TransactionInactiveError => .{ ._code = .transaction_inactive_error }, + error.ReadOnlyError => .{ ._code = .read_only_error }, else => null, }; } pub fn getCode(self: *const DOMException) u8 { return switch (self._code) { - // WebCrypto-only errors: no legacy numeric code. - .operation_error, .data_error => 0, + // no legacy numeric code + .operation_error, .data_error, .constraint_error, .version_error, .transaction_inactive_error, .read_only_error => 0, else => @intFromEnum(self._code), }; } @@ -103,6 +107,10 @@ pub fn getName(self: *const DOMException) []const u8 { .data_clone_error => "DataCloneError", .operation_error => "OperationError", .data_error => "DataError", + .constraint_error => "ConstraintError", + .version_error => "VersionError", + .transaction_inactive_error => "TransactionInactiveError", + .read_only_error => "ReadOnlyError", }; } @@ -135,6 +143,10 @@ pub fn getMessage(self: *const DOMException) []const u8 { .data_clone_error => "The object can not be cloned", .operation_error => "The operation failed for an operation-specific reason", .data_error => "Data provided to an operation does not meet requirements", + .constraint_error => "A mutation operation in the transaction failed because a constraint was not satisfied", + .version_error => "An attempt was made to open a database using a lower version than the existing version", + .transaction_inactive_error => "A request was placed against a transaction which is currently not active, or which is finished", + .read_only_error => "A mutation operation was attempted in a read-only transaction", }; } @@ -178,6 +190,14 @@ const Code = enum(u8) { data_error = 0xFE, /// Defined by WebCrypto; no legacy code, exposed via name only. operation_error = 0xFF, + /// Defined by IndexedDB; no legacy code, exposed via name only. + constraint_error = 0xFD, + /// Defined by IndexedDB; no legacy code, exposed via name only. + version_error = 0xFC, + /// Defined by IndexedDB; no legacy code, exposed via name only. + transaction_inactive_error = 0xFB, + /// Defined by IndexedDB; no legacy code, exposed via name only. + read_only_error = 0xFA, /// Maps a standard error name to its legacy code /// Returns .none (code 0) for non-legacy error names @@ -206,6 +226,10 @@ const Code = enum(u8) { .{ "DataCloneError", .data_clone_error }, .{ "OperationError", .operation_error }, .{ "DataError", .data_error }, + .{ "ConstraintError", .constraint_error }, + .{ "VersionError", .version_error }, + .{ "TransactionInactiveError", .transaction_inactive_error }, + .{ "ReadOnlyError", .read_only_error }, }); return lookup.get(name) orelse .none; } diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index d47a54b48..d3aa63fa2 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -82,6 +82,7 @@ pub const Type = union(enum) { form_data_event: *@import("event/FormDataEvent.zig"), close_event: *@import("event/CloseEvent.zig"), cookie_change_event: *@import("event/CookieChangeEvent.zig"), + idb_version_change_event: *@import("storage/idb/IDBVersionChangeEvent.zig"), toggle_event: *@import("event/ToggleEvent.zig"), }; @@ -175,6 +176,7 @@ pub fn is(self: *Event, comptime T: type) ?*T { .form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null, .close_event => |e| return if (T == @import("event/CloseEvent.zig")) e else null, .cookie_change_event => |e| return if (T == @import("event/CookieChangeEvent.zig")) e else null, + .idb_version_change_event => |e| return if (T == @import("storage/idb/IDBVersionChangeEvent.zig")) e else null, .toggle_event => |e| return if (T == @import("event/ToggleEvent.zig")) e else null, .ui_event => |e| { if (T == @import("event/UIEvent.zig")) { diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index e3aca0cf1..89246b8a8 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -51,6 +51,9 @@ pub const Type = union(enum) { font_face_set: *@import("css/FontFaceSet.zig"), websocket: *@import("net/WebSocket.zig"), cookie_store: *@import("storage/CookieStore.zig"), + idb_request: *@import("storage/idb/IDBRequest.zig"), + idb_database: *@import("storage/idb/IDBDatabase.zig"), + idb_transaction: *@import("storage/idb/IDBTransaction.zig"), notification: *@import("Notification.zig"), }; @@ -164,6 +167,9 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { .font_face_set => writer.writeAll(""), .websocket => writer.writeAll(""), .cookie_store => writer.writeAll(""), + .idb_request => writer.writeAll(""), + .idb_database => writer.writeAll(""), + .idb_transaction => writer.writeAll(""), .notification => writer.writeAll(""), }; } @@ -189,6 +195,9 @@ pub fn toString(self: *EventTarget) []const u8 { .font_face_set => return "[object FontFaceSet]", .websocket => return "[object WebSocket]", .cookie_store => return "[object CookieStore]", + .idb_request => return "[object IDBRequest]", + .idb_database => return "[object IDBDatabase]", + .idb_transaction => return "[object IDBTransaction]", .notification => return "[object Notification]", }; } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 31f79c0c7..10a5b4869 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -43,6 +43,7 @@ const MessageEvent = @import("event/MessageEvent.zig"); const MessagePort = @import("MessagePort.zig"); const MediaQueryList = @import("css/MediaQueryList.zig"); const storage = @import("storage/storage.zig"); +const idb = @import("storage/idb/idb.zig"); const CookieStore = @import("storage/CookieStore.zig"); const Element = @import("Element.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); @@ -75,6 +76,7 @@ _screen: *Screen, _visual_viewport: *VisualViewport, _performance: Performance, _cookie_store: ?*CookieStore = null, +_idb_factory: ?*idb.IDBFactory = null, _on_load: ?js.Function.Global = null, _on_pageshow: ?js.Function.Global = null, _on_popstate: ?js.Function.Global = null, @@ -270,6 +272,15 @@ pub fn getCookieStore(self: *Window, exec: *Execution) !*CookieStore { return cs; } +pub fn getIndexedDB(self: *Window, exec: *Execution) !*idb.IDBFactory { + if (self._idb_factory) |f| { + return f; + } + const f = try exec._factory.create(idb.IDBFactory{}); + self._idb_factory = f; + return f; +} + pub fn getOrigin(self: *const Window) []const u8 { return self._frame.origin orelse "null"; } @@ -1009,6 +1020,7 @@ pub const JsApi = struct { pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{}); pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{}); pub const cookieStore = bridge.accessor(Window.getCookieStore, null, .{}); + pub const indexedDB = bridge.accessor(Window.getIndexedDB, null, .{}); pub const origin = bridge.accessor(Window.getOrigin, null, .{}); pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{ .deletable = false }); pub const history = bridge.accessor(Window.getHistory, null, .{}); diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index ab965648e..030d56853 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -44,6 +44,7 @@ const Performance = @import("Performance.zig"); const WorkerLocation = @import("WorkerLocation.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); const Fetch = @import("net/Fetch.zig"); +const idb = @import("storage/idb/idb.zig"); const CookieStore = @import("storage/CookieStore.zig"); const DedicatedWorkerGlobalScope = @import("DedicatedWorkerGlobalScope.zig"); @@ -103,6 +104,7 @@ _console: Console = .init, _crypto: Crypto = .init, _navigator: Navigator = .init, _performance: Performance, +_idb_factory: ?*idb.IDBFactory = null, _on_error: ?JS.Function.Global = null, _on_rejection_handled: ?JS.Function.Global = null, _on_unhandled_rejection: ?JS.Function.Global = null, @@ -503,6 +505,15 @@ pub fn clearInterval(self: *WorkerGlobalScope, id: u32) void { self._timers.clear(id); } +pub fn getIndexedDB(self: *WorkerGlobalScope, exec: *JS.Execution) !*idb.IDBFactory { + if (self._idb_factory) |f| { + return f; + } + const f = try exec._factory.create(idb.IDBFactory{}); + self._idb_factory = f; + return f; +} + // `console` and `self` are [Replaceable]: assignment redefines them as own data // properties on the global rather than throwing through the getter-only accessor // in strict mode. Used here (console) and by DedicatedWorkerGlobalScope (self). @@ -548,6 +559,7 @@ pub const JsApi = struct { pub const self = bridge.accessor(WorkerGlobalScope.getSelf, WorkerGlobalScope.setSelf, .{}); pub const location = bridge.accessor(WorkerGlobalScope.getLocation, null, .{}); pub const cookieStore = bridge.accessor(WorkerGlobalScope.getCookieStore, null, .{}); + pub const indexedDB = bridge.accessor(WorkerGlobalScope.getIndexedDB, null, .{}); pub const onerror = bridge.accessor(WorkerGlobalScope.getOnError, WorkerGlobalScope.setOnError, .{}); pub const onrejectionhandled = bridge.accessor(WorkerGlobalScope.getOnRejectionHandled, WorkerGlobalScope.setOnRejectionHandled, .{}); diff --git a/src/browser/webapi/storage/idb/Engine.zig b/src/browser/webapi/storage/idb/Engine.zig new file mode 100644 index 000000000..ddd688e9d --- /dev/null +++ b/src/browser/webapi/storage/idb/Engine.zig @@ -0,0 +1,260 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const Sqlite = @import("../../../../storage/sqlite/Sqlite.zig"); + +const log = lp.log; +const Allocator = std.mem.Allocator; + +const Engine = @This(); + +conn: Sqlite.Conn, + +pub fn open(path: [:0]const u8) !Engine { + const conn = try Sqlite.Conn.open(path); + errdefer conn.close(); + + try conn.busyTimeout(1000); + try conn.exec("pragma journal_mode=wal", .{}); + try conn.exec( + \\ create table if not exists idb_databases ( + \\ id integer primary key, + \\ name text not null unique, + \\ version integer not null + \\ ); + \\ create table if not exists idb_object_stores ( + \\ id integer primary key, + \\ database_id integer not null references idb_databases(id), + \\ name text not null, + \\ key_path text, + \\ auto_increment integer not null default 0, + \\ unique(database_id, name) + \\ ); + \\ create table if not exists idb_records ( + \\ object_store_id integer not null references idb_object_stores(id), + \\ key blob not null, + \\ value blob not null, + \\ primary key (object_store_id, key) + \\ ) without rowid; + , .{}); + return .{ .conn = conn }; +} + +pub fn close(self: *Engine) void { + self.conn.close(); +} + +pub fn begin(self: *Engine) !void { + return self.conn.exec("begin immediate", .{}); +} + +pub fn commit(self: *Engine) !void { + return self.conn.exec("commit", .{}); +} + +pub fn rollback(self: *Engine) void { + self.conn.exec("rollback", .{}) catch |err| { + log.warn(.storage, "idb rollback", .{ .err = err, .sqlite = self.conn.lastError() }); + }; +} +pub fn databaseId(self: *const Engine, name: []const u8) !?i64 { + return self.conn.scalar(i64, "select id from idb_databases where name = ?1", .{name}); +} + +pub fn databaseVersion(self: *const Engine, name: []const u8) !?i64 { + return self.conn.scalar(i64, "select version from idb_databases where name = ?1", .{name}); +} + +pub fn upsertDatabase(self: *Engine, name: []const u8, version: i64) !i64 { + try self.conn.exec( + \\ insert into idb_databases (name, version) values (?1, ?2) + \\ on conflict(name) do update set version = ?2 + , .{ name, version }); + + return (try self.databaseId(name)).?; +} + +pub fn deleteDatabase(self: *Engine, name: []const u8) !void { + const database_id = (try self.databaseId(name)) orelse return; + + try self.begin(); + errdefer self.rollback(); + try self.conn.exec( + "delete from idb_records where object_store_id in (select id from idb_object_stores where database_id = ?1)", + .{database_id}, + ); + try self.conn.exec("delete from idb_object_stores where database_id = ?1", .{database_id}); + try self.conn.exec("delete from idb_databases where id = ?1", .{database_id}); + try self.commit(); +} + +pub fn objectStoreId(self: *const Engine, database_id: i64, name: []const u8) !?i64 { + return self.conn.scalar( + i64, + "select id from idb_object_stores where database_id = ?1 and name = ?2", + .{ database_id, name }, + ); +} + +pub fn createObjectStore( + self: *Engine, + database_id: i64, + name: []const u8, + key_path: ?[]const u8, + auto_increment: bool, +) !i64 { + 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 }); + return (try self.objectStoreId(database_id, name)).?; +} + +pub fn deleteObjectStore(self: *Engine, database_id: i64, name: []const u8) !void { + const store_id = (try self.objectStoreId(database_id, name)) orelse return error.NotFound; + // caller has a transaction open + try self.conn.exec("delete from idb_records where object_store_id = ?1", .{store_id}); + try self.conn.exec("delete from idb_object_stores where id = ?1", .{store_id}); +} + +pub fn add(self: *Engine, object_store_id: i64, key: []const u8, value: []const u8) !void { + return self.conn.exec( + "insert into idb_records (object_store_id, key, value) values (?1, ?2, ?3)", + .{ object_store_id, key, value }, + ); +} + +pub fn put(self: *Engine, object_store_id: i64, key: []const u8, value: []const u8) !void { + return self.conn.exec( + \\ insert into idb_records (object_store_id, key, value) values (?1, ?2, ?3) + \\ on conflict(object_store_id, key) do update set value = ?3 + , .{ object_store_id, key, value }); +} + +pub fn get(self: *const Engine, allocator: Allocator, object_store_id: i64, key: []const u8) !?[]u8 { + var row = (try self.conn.row( + "select value from idb_records where object_store_id = ?1 and key = ?2", + .{ object_store_id, key }, + )) orelse return null; + defer row.deinit(); + 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 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}, + ); + defer rows.deinit(); + + var list: std.ArrayList([]u8) = .empty; + while (try rows.next()) |row| { + try list.append(arena, try arena.dupe(u8, row.get([]const u8, 0))); + } + return list.items; +} + +const testing = @import("../../../../testing.zig"); + +test "IDB - Engine: open creates schema" { + var engine = try Engine.open(":memory:"); + defer engine.close(); + try testing.expectEqual(null, try engine.databaseVersion("missing")); +} + +test "IDB - Engine: database + object store + add/get round-trip" { + var engine = try Engine.open(":memory:"); + defer engine.close(); + + 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); + + // Value bytes deliberately include an embedded NUL and high bytes to prove + // the BLOB path is binary-safe (serialized values are arbitrary bytes). + const value = "a\x00b\xffc"; + try engine.add(store_id, "key1", value); + + const got = (try engine.get(testing.allocator, store_id, "key1")).?; + defer testing.allocator.free(got); + try testing.expectEqualSlices(u8, value, got); + + try testing.expectEqual(null, try engine.get(testing.allocator, store_id, "absent")); +} + +test "IDB - Engine: add rejects duplicate key, put overwrites" { + 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 engine.add(store_id, "k", "v1"); + try testing.expectError(error.Constraint, engine.add(store_id, "k", "v2")); + + try engine.put(store_id, "k", "v2"); + const got = (try engine.get(testing.allocator, store_id, "k")).?; + defer testing.allocator.free(got); + try testing.expectEqualSlices(u8, "v2", got); +} + +test "IDB - Engine: createObjectStore rejects duplicate name" { + var engine = try Engine.open(":memory:"); + 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)); +} + +test "IDB - Engine: rollback discards uncommitted writes" { + 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 engine.begin(); + try engine.add(store_id, "k", "v"); + engine.rollback(); + + try testing.expectEqual(null, try engine.get(testing.allocator, store_id, "k")); +} diff --git a/src/browser/webapi/storage/idb/IDBDatabase.zig b/src/browser/webapi/storage/idb/IDBDatabase.zig new file mode 100644 index 000000000..a2ba43c09 --- /dev/null +++ b/src/browser/webapi/storage/idb/IDBDatabase.zig @@ -0,0 +1,153 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../../js/js.zig"); + +const EventTarget = @import("../../EventTarget.zig"); + +const Engine = @import("Engine.zig"); +const IDBTransaction = @import("IDBTransaction.zig"); +const IDBObjectStore = @import("IDBObjectStore.zig"); + +const Execution = js.Execution; + +const IDBDatabase = @This(); + +_proto: *EventTarget, +_exec: *Execution, +_engine: *Engine, +_database_id: i64, +_name: []const u8, +_version: i64, +_txn: ?*IDBTransaction = null, // only set during upgradeneeded + +pub fn init(exec: *Execution, engine: *Engine, database_id: i64, name: []const u8, version: i64) !*IDBDatabase { + return exec._factory.eventTarget(IDBDatabase{ + ._proto = undefined, + ._exec = exec, + ._engine = engine, + ._database_id = database_id, + ._name = name, + ._version = version, + }); +} + +pub fn asEventTarget(self: *IDBDatabase) *EventTarget { + return self._proto; +} + +const CreateObjectStoreOptions = struct { + keyPath: ?[]const u8 = null, + autoIncrement: bool = false, +}; + +// Only callable during upgradeneeded, hence the _txn check +pub fn createObjectStore( + self: *IDBDatabase, + name: []const u8, + options: ?CreateObjectStoreOptions, + exec: *Execution, +) !*IDBObjectStore { + const txn = self._txn orelse return error.InvalidStateError; + + const opts = options orelse CreateObjectStoreOptions{}; + const store_id = self._engine.createObjectStore( + self._database_id, + name, + opts.keyPath, + opts.autoIncrement, + ) catch |err| switch (err) { + error.Constraint => return error.ConstraintError, + else => return err, + }; + + 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); +} + +// Only callable during upgradeneeded, hence the _txn check +pub fn deleteObjectStore(self: *IDBDatabase, name: []const u8, _: *Execution) !void { + if (self._txn == null) { + return error.InvalidStateError; + } + return self._engine.deleteObjectStore(self._database_id, name); +} + +const TransactionMode = enum { + readonly, + readwrite, + pub const js_enum_from_string = true; +}; + +const StoreNames = union(enum) { + name: []const u8, + names: []const []const u8, +}; + +const TransactionOptions = struct { + durability: IDBTransaction.Durability = .default, +}; + +pub fn transaction( + self: *IDBDatabase, + store_names: StoreNames, + mode: ?TransactionMode, + options: ?TransactionOptions, + exec: *Execution, +) !*IDBTransaction { + _ = store_names; // TODO + const opts = options orelse TransactionOptions{}; + return IDBTransaction.init(exec, self, switch (mode orelse .readonly) { + .readonly => .readonly, + .readwrite => .readwrite, + }, opts.durability); +} + +pub fn close(_: *IDBDatabase) void { + // Connections are pooled on the Manager and shared across handles, so a + // single handle's close() is a no-op for the bare slice. +} + +pub fn getName(self: *const IDBDatabase) []const u8 { + return self._name; +} + +pub fn getVersion(self: *const IDBDatabase) i64 { + return self._version; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(IDBDatabase); + + pub const Meta = struct { + pub const name = "IDBDatabase"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const name = bridge.accessor(IDBDatabase.getName, null, .{}); + pub const version = bridge.accessor(IDBDatabase.getVersion, null, .{}); + pub const createObjectStore = bridge.function(IDBDatabase.createObjectStore, .{ .dom_exception = true }); + pub const deleteObjectStore = bridge.function(IDBDatabase.deleteObjectStore, .{ .dom_exception = true }); + pub const transaction = bridge.function(IDBDatabase.transaction, .{ .dom_exception = true }); + pub const close = bridge.function(IDBDatabase.close, .{}); +}; diff --git a/src/browser/webapi/storage/idb/IDBFactory.zig b/src/browser/webapi/storage/idb/IDBFactory.zig new file mode 100644 index 000000000..bded61047 --- /dev/null +++ b/src/browser/webapi/storage/idb/IDBFactory.zig @@ -0,0 +1,225 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../../js/js.zig"); + +const Key = @import("Key.zig"); +const Engine = @import("Engine.zig"); +const IDBRequest = @import("IDBRequest.zig"); +const IDBDatabase = @import("IDBDatabase.zig"); +const IDBTransaction = @import("IDBTransaction.zig"); + +const log = lp.log; +const Execution = js.Execution; + +const IDBFactory = @This(); + +_pad: bool = false, + +pub fn open(_: *IDBFactory, name: []const u8, version: ?u64, exec: *Execution) !*IDBRequest { + if (exec.origin() == null) { + // unavailable for opaque origins, e.g. about:blank + return error.SecurityError; + } + + // A supplied version must be >= 1; open(name, 0) is a TypeError per spec. + if (version) |v| { + if (v == 0) return error.TypeError; + } + + const request = try IDBRequest.init(exec); + + const ctx = try exec._factory.create(OpenContext{ + .request = request, + .name = try exec.dupeString(name), + .version = version, + .exec = exec, + }); + + try exec.js.scheduler.add(ctx, OpenContext.run, 0, .{ + .name = "IDBFactory.open", + .finalizer = OpenContext.cancelled, + }); + return request; +} + +const OpenContext = struct { + request: *IDBRequest, + name: []const u8, + version: ?u64, + exec: *Execution, + + fn cancelled(ctx: *anyopaque) void { + const self: *OpenContext = @ptrCast(@alignCast(ctx)); + self.exec._factory.destroy(self); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *OpenContext = @ptrCast(@alignCast(ctx)); + defer self.exec._factory.destroy(self); + + self.runOpen() catch |err| { + log.warn(.storage, "idb open", .{ .err = err, .name = self.name }); + self.request.setError(err); + self.request.deliver(self.exec) catch {}; + }; + return null; + } + + fn runOpen(self: *OpenContext) !void { + const exec = self.exec; + + // origin being null was already guarded against, so this should be + // unreachable, but this is safer. + const origin = exec.origin() orelse return error.SecurityError; + + const engine = try exec.session.idb.engineForOrigin(origin); + const existing = try engine.databaseVersion(self.name); + + // No explicit version means "open at the current version" (or 1 for a + // brand-new database). + const requested: i64 = if (self.version) |v| @intCast(v) else existing orelse 1; + + if (existing) |current| { + if (requested < current) { + self.request.setError(error.VersionError); + self.request.deliver(exec) catch {}; + return; + } + + if (requested == current) { + const database_id = (try engine.databaseId(self.name)).?; + const db = try IDBDatabase.init(exec, engine, database_id, self.name, current); + self.request.setDatabaseResult(db); + return self.request.fireSuccess(exec); + } + } + + // New database or an upgrade to a higher version. Run a versionchange + // transaction so user JS can evolve the schema during `upgradeneeded`; + // it's exposed as `request.transaction` and committed here once the + // handler returns. + try engine.begin(); + errdefer engine.rollback(); + + const database_id = try engine.upsertDatabase(self.name, requested); + const db = try IDBDatabase.init(exec, engine, database_id, self.name, requested); + self.request.setDatabaseResult(db); + + const txn = try IDBTransaction.initVersionChange(exec, db); + self.request._txn = txn; + + { + db._txn = txn; + defer db._txn = null; + const old_version: u64 = @intCast(existing orelse 0); + try self.request.fireUpgradeNeeded(exec, old_version, @intCast(requested)); + } + + if (txn.aborted()) { + // updateneeded handler called abort() (what a jerk!) + self.request._txn = null; + self.request.setError(error.AbortError); + return self.request.deliver(exec); + } + + txn.settle(exec); + self.request._txn = null; + return self.request.fireSuccess(exec); + } +}; + +pub fn deleteDatabase(_: *IDBFactory, name: []const u8, exec: *Execution) !*IDBRequest { + if (exec.origin() == null) { + // unavailable for opaque origins, e.g. about:blank + return error.SecurityError; + } + + const request = try IDBRequest.init(exec); + + const ctx = try exec._factory.create(DeleteContext{ + .request = request, + .name = try exec.dupeString(name), + .exec = exec, + }); + + try exec.js.scheduler.add(ctx, DeleteContext.run, 0, .{ + .name = "IDBFactory.deleteDatabase", + .finalizer = DeleteContext.cancelled, + }); + return request; +} + +const DeleteContext = struct { + request: *IDBRequest, + name: []const u8, + exec: *Execution, + + fn cancelled(ctx: *anyopaque) void { + const self: *DeleteContext = @ptrCast(@alignCast(ctx)); + self.exec._factory.destroy(self); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *DeleteContext = @ptrCast(@alignCast(ctx)); + defer self.exec._factory.destroy(self); + + self.runDelete() catch |err| { + log.warn(.storage, "idb deleteDatabase", .{ .err = err, .name = self.name }); + self.request.setError(err); + self.request.deliver(self.exec) catch {}; + }; + return null; + } + + fn runDelete(self: *DeleteContext) !void { + const exec = self.exec; + const origin = exec.origin() orelse return error.SecurityError; + const engine = try exec.session.idb.engineForOrigin(origin); + try engine.deleteDatabase(self.name); + return self.request.fireSuccess(exec); + } +}; + +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); + return switch (std.mem.order(u8, a, b)) { + .lt => -1, + .eq => 0, + .gt => 1, + }; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(IDBFactory); + + pub const Meta = struct { + pub const name = "IDBFactory"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + pub const open = bridge.function(IDBFactory.open, .{ .dom_exception = true }); + pub const deleteDatabase = bridge.function(IDBFactory.deleteDatabase, .{ .dom_exception = true }); + pub const cmp = bridge.function(IDBFactory.cmp, .{ .dom_exception = true }); +}; diff --git a/src/browser/webapi/storage/idb/IDBObjectStore.zig b/src/browser/webapi/storage/idb/IDBObjectStore.zig new file mode 100644 index 000000000..1007217f1 --- /dev/null +++ b/src/browser/webapi/storage/idb/IDBObjectStore.zig @@ -0,0 +1,215 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const lp = @import("lightpanda"); + +const js = @import("../../../js/js.zig"); + +const Key = @import("Key.zig"); +const Engine = @import("Engine.zig"); +const IDBRequest = @import("IDBRequest.zig"); +const IDBTransaction = @import("IDBTransaction.zig"); + +const log = lp.log; +const Execution = js.Execution; + +const IDBObjectStore = @This(); + +_exec: *Execution, +_engine: *Engine, +_store_id: i64, +_name: []const u8, +_key_path: ?[]const u8, +// only null during an upgradeneeded +_txn: ?*IDBTransaction, + +pub fn init( + engine: *Engine, + txn: ?*IDBTransaction, + store_id: i64, + name: []const u8, + key_path: ?[]const u8, + exec: *Execution, +) !*IDBObjectStore { + return exec._factory.create(IDBObjectStore{ + ._exec = exec, + ._engine = engine, + ._txn = txn, + ._store_id = store_id, + ._name = name, + ._key_path = key_path, + }); +} + +pub fn add(self: *IDBObjectStore, value: js.Value, key: ?js.Value) !*IDBRequest { + return self.write(value, key, .add); +} + +pub fn put(self: *IDBObjectStore, value: js.Value, key: ?js.Value) !*IDBRequest { + return self.write(value, key, .put); +} + +pub fn get(self: *IDBObjectStore, key: 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 request = try txn.newRequest(); + + const bytes = self._engine.get(arena, self._store_id, encoded) catch |err| { + log.warn(.storage, "idb get", .{ .err = err }); + request.setError(err); + return request; + }; + + const b = bytes orelse return request; + + const value = try js.Value.deserialize(exec.js.local.?, b); + try request.setValueResult(value); + return request; +} + +pub fn delete(self: *IDBObjectStore, key: js.Value, _: *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 request = try txn.newRequest(); + + self._engine.delete(self._store_id, encoded) catch |err| { + log.warn(.storage, "idb delete", .{ .err = err }); + request.setError(err); + }; + return request; +} + +pub fn clear(self: *IDBObjectStore, _: *Execution) !*IDBRequest { + const txn = self._txn orelse return error.TransactionInactiveError; + if (txn._mode == .readonly) { + return error.ReadOnlyError; + } + try txn.ensureBegun(); + + const request = try txn.newRequest(); + self._engine.clear(self._store_id) catch |err| { + log.warn(.storage, "idb clear", .{ .err = err }); + request.setError(err); + }; + return request; +} + +pub fn count(self: *IDBObjectStore, exec: *Execution) !*IDBRequest { + const txn = self._txn orelse return error.TransactionInactiveError; + try txn.ensureBegun(); + + const request = try txn.newRequest(); + const n = self._engine.count(self._store_id) catch |err| { + log.warn(.storage, "idb count", .{ .err = err }); + request.setError(err); + return request; + }; + try request.setValueResult(try exec.js.local.?.zigValueToJs(n, .{})); + return request; +} + +pub fn getAll(self: *IDBObjectStore, exec: *Execution) !*IDBRequest { + const txn = self._txn orelse return error.TransactionInactiveError; + try txn.ensureBegun(); + + const local = exec.js.local.?; + const request = try txn.newRequest(); + + const values = self._engine.getAll(exec.call_arena, self._store_id) catch |err| { + log.warn(.storage, "idb getAll", .{ .err = err }); + request.setError(err); + return request; + }; + + const arr = local.newArray(@intCast(values.len)); + for (values, 0..) |bytes, i| { + const value = try js.Value.deserialize(local, bytes); + _ = try arr.set(@intCast(i), value, .{}); + } + try request.setValueResult(arr.toValue()); + return request; +} + +pub fn getName(self: *const IDBObjectStore) []const u8 { + return self._name; +} + +pub fn getKeyPath(self: *const IDBObjectStore) ?[]const u8 { + return self._key_path; +} + +const WriteKind = enum { add, put }; + +fn write(self: *IDBObjectStore, value: js.Value, key_: ?js.Value, kind: WriteKind) !*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 serialized = try value.serialize(); + defer serialized.deinit(); + + const request = try txn.newRequest(); + + const result = switch (kind) { + .add => self._engine.add(self._store_id, encoded, serialized.bytes()), + .put => self._engine.put(self._store_id, encoded, serialized.bytes()), + }; + result catch |err| { + log.warn(.storage, "idb write", .{ .err = err, .kind = kind }); + request.setError(err); + return request; + }; + + try request.setValueResult(key); + return request; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(IDBObjectStore); + + pub const Meta = struct { + pub const name = "IDBObjectStore"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const name = bridge.accessor(IDBObjectStore.getName, null, .{}); + pub const keyPath = bridge.accessor(IDBObjectStore.getKeyPath, null, .{}); + 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 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 }); +}; diff --git a/src/browser/webapi/storage/idb/IDBRequest.zig b/src/browser/webapi/storage/idb/IDBRequest.zig new file mode 100644 index 000000000..f4d1f653c --- /dev/null +++ b/src/browser/webapi/storage/idb/IDBRequest.zig @@ -0,0 +1,183 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../../js/js.zig"); + +const Event = @import("../../Event.zig"); +const EventTarget = @import("../../EventTarget.zig"); +const DOMException = @import("../../DOMException.zig"); + +const idb = @import("idb.zig"); +const IDBDatabase = @import("IDBDatabase.zig"); +const IDBTransaction = @import("IDBTransaction.zig"); +const IDBVersionChangeEvent = @import("IDBVersionChangeEvent.zig"); + +const Execution = js.Execution; +const FunctionSetter = idb.FunctionSetter; + +const IDBRequest = @This(); + +_proto: *EventTarget, +_result: Result = .none, +_error: ?anyerror = null, +_txn: ?*IDBTransaction = null, +_ready_state: ReadyState = .pending, + +_on_success: ?js.Function.Global = null, +_on_error: ?js.Function.Global = null, +_on_upgrade_needed: ?js.Function.Global = null, + +const ReadyState = enum { + pending, + done, + + pub fn toString(self: ReadyState) []const u8 { + return @tagName(self); + } +}; + +const Result = union(enum) { + none, + value: js.Value.Global, // the result of a get/add/put + database: *IDBDatabase, // the result of an open +}; + +pub fn init(exec: *Execution) !*IDBRequest { + return exec._factory.eventTarget(IDBRequest{ ._proto = undefined }); +} + +pub fn asEventTarget(self: *IDBRequest) *EventTarget { + return self._proto; +} + +pub fn setValueResult(self: *IDBRequest, value: js.Value) !void { + self._result = .{ .value = try value.persist() }; +} + +pub fn setDatabaseResult(self: *IDBRequest, database: *IDBDatabase) void { + self._result = .{ .database = database }; +} + +pub fn setError(self: *IDBRequest, err: anyerror) void { + self._error = err; +} + +pub fn failed(self: *const IDBRequest) bool { + return self._error != null; +} + +pub fn deliver(self: *IDBRequest, exec: *Execution) !void { + self._ready_state = .done; + if (self._error != null) { + return self.fire(exec, comptime .wrap("error"), self._on_error); + } + return self.fire(exec, comptime .wrap("success"), self._on_success); +} + +pub fn fireUpgradeNeeded(self: *IDBRequest, exec: *Execution, old_version: u64, new_version: u64) !void { + self._ready_state = .done; + const event = try IDBVersionChangeEvent.initTrusted(.wrap("upgradeneeded"), old_version, new_version, exec); + try exec.dispatch(self.asEventTarget(), event.asEvent(), self._on_upgrade_needed, .{ .context = "IDBRequest.upgradeneeded" }); +} + +pub fn fireSuccess(self: *IDBRequest, exec: *Execution) !void { + self._ready_state = .done; + return self.fire(exec, comptime .wrap("success"), self._on_success); +} + +fn fire(self: *IDBRequest, exec: *Execution, typ: lp.String, handler: ?js.Function.Global) !void { + const event = try Event.initTrusted(typ, null, exec.page); + try exec.dispatch(self.asEventTarget(), event, handler, .{ .context = "IDBRequest" }); +} + +pub fn getReadyState(self: *const IDBRequest) ReadyState { + return self._ready_state; +} + +pub fn getResult(self: *const IDBRequest) !Result { + return self._result; +} + +pub fn getTransaction(self: *const IDBRequest) ?*IDBTransaction { + return self._txn; +} + +// Return this as a DOMException directly. If we return an error, the bridge +// *will* convert it to a DOMException, but it'll throw it, not return it. +pub fn getError(self: *const IDBRequest) ?DOMException { + const err = self._error orelse return null; + const mapped: anyerror = switch (err) { + // sqlite's generic constraint failure is IDB's ConstraintError. + error.Constraint => error.ConstraintError, + else => err, + }; + return DOMException.fromError(mapped) orelse DOMException.init(null, "UnknownError"); +} + +pub fn getOnSuccess(self: *const IDBRequest) ?js.Function.Global { + return self._on_success; +} + +pub fn setOnSuccess(self: *IDBRequest, setter: ?FunctionSetter) void { + self._on_success = getFunctionFromSetter(setter); +} + +pub fn getOnError(self: *const IDBRequest) ?js.Function.Global { + return self._on_error; +} + +pub fn setOnError(self: *IDBRequest, setter: ?FunctionSetter) void { + self._on_error = getFunctionFromSetter(setter); +} + +pub fn getOnUpgradeNeeded(self: *const IDBRequest) ?js.Function.Global { + return self._on_upgrade_needed; +} + +pub fn setOnUpgradeNeeded(self: *IDBRequest, setter: ?FunctionSetter) void { + self._on_upgrade_needed = getFunctionFromSetter(setter); +} + +fn getFunctionFromSetter(setter: ?FunctionSetter) ?js.Function.Global { + const s = setter orelse return null; + return switch (s) { + .func => |f| f, + .anything => null, + }; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(IDBRequest); + + pub const Meta = struct { + pub const name = "IDBRequest"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const readyState = bridge.accessor(IDBRequest.getReadyState, null, .{}); + pub const result = bridge.accessor(IDBRequest.getResult, null, .{ .null_as_undefined = true }); + pub const transaction = bridge.accessor(IDBRequest.getTransaction, null, .{ .null_as_undefined = true }); + pub const @"error" = bridge.accessor(IDBRequest.getError, null, .{ .null_as_undefined = true }); + pub const onsuccess = bridge.accessor(IDBRequest.getOnSuccess, IDBRequest.setOnSuccess, .{}); + pub const onerror = bridge.accessor(IDBRequest.getOnError, IDBRequest.setOnError, .{}); + pub const onupgradeneeded = bridge.accessor(IDBRequest.getOnUpgradeNeeded, IDBRequest.setOnUpgradeNeeded, .{}); +}; diff --git a/src/browser/webapi/storage/idb/IDBTransaction.zig b/src/browser/webapi/storage/idb/IDBTransaction.zig new file mode 100644 index 000000000..5d5927053 --- /dev/null +++ b/src/browser/webapi/storage/idb/IDBTransaction.zig @@ -0,0 +1,289 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../../js/js.zig"); + +const Event = @import("../../Event.zig"); +const EventTarget = @import("../../EventTarget.zig"); + +const idb = @import("idb.zig"); +const Engine = @import("Engine.zig"); +const IDBDatabase = @import("IDBDatabase.zig"); +const IDBRequest = @import("IDBRequest.zig"); +const IDBObjectStore = @import("IDBObjectStore.zig"); + +const log = lp.log; +const Execution = js.Execution; +const FunctionSetter = idb.FunctionSetter; + +const IDBTransaction = @This(); + +_proto: *EventTarget, +_exec: *Execution, +_db: *IDBDatabase, +_engine: *Engine, +_mode: Mode, +// Advisory only: we always commit through sqlite. Stored to expose the property. +_durability: Durability = .default, +_requests: std.ArrayList(*IDBRequest) = .empty, +_begun: bool = false, +_settled: bool = false, +_aborted: bool = false, + +_on_complete: ?js.Function.Global = null, +_on_error: ?js.Function.Global = null, +_on_abort: ?js.Function.Global = null, + +pub const Mode = enum { + readonly, + readwrite, + versionchange, + + pub fn toString(self: Mode) []const u8 { + return @tagName(self); + } +}; + +pub const Durability = enum { + default, + strict, + relaxed, + + pub const js_enum_from_string = true; + + pub fn toString(self: Durability) []const u8 { + return @tagName(self); + } +}; + +pub fn init(exec: *Execution, db: *IDBDatabase, mode: Mode, durability: Durability) !*IDBTransaction { + const self = try exec._factory.eventTarget(IDBTransaction{ + ._proto = undefined, + ._exec = exec, + ._db = db, + ._engine = db._engine, + ._mode = mode, + ._durability = durability, + }); + + // Schedule the drain even for an empty transaction so it still `complete`s. + try exec.js.scheduler.add(self, drain, 0, .{ + .name = "IDBTransaction.drain", + .finalizer = finalize, + }); + return self; +} + +// We need a "special" transaction for upgradeneeded +pub fn initVersionChange(exec: *Execution, db: *IDBDatabase) !*IDBTransaction { + return exec._factory.eventTarget(IDBTransaction{ + ._proto = undefined, + ._exec = exec, + ._db = db, + ._engine = db._engine, + ._mode = .versionchange, + ._begun = true, + }); +} + +pub fn asEventTarget(self: *IDBTransaction) *EventTarget { + return self._proto; +} + +pub fn aborted(self: *const IDBTransaction) bool { + return self._aborted; +} + +pub fn commit(self: *IDBTransaction, exec: *Execution) !void { + if (self._settled) { + return error.InvalidStateError; + } + self.settle(exec); +} + +pub fn abort(self: *IDBTransaction, exec: *Execution) !void { + if (self._settled) { + return error.InvalidStateError; + } + + self._aborted = true; + self._settled = true; + + if (self._begun) { + self._engine.rollback(); + } + + for (self._requests.items) |request| { + request.setError(error.AbortError); + request.deliver(exec) catch |err| { + log.warn(.storage, "idb abort deliver", .{ .err = err }); + }; + } + self.fire(exec, comptime .wrap("abort"), self._on_abort); +} + +// Deliver pending requests, commit, then fire `complete` (or `abort` if the +// commit fails). Drives a transaction to a successful close — shared by the +// drain task and the open path's synchronous versionchange transaction. +pub fn settle(self: *IDBTransaction, exec: *Execution) void { + self.deliverPending(exec); + if (self._begun) { + self._engine.commit() catch |err| { + log.warn(.storage, "idb commit", .{ .err = err, .sqlite = self._engine.conn.lastError() }); + self._engine.rollback(); + self.fire(exec, comptime .wrap("abort"), self._on_abort); + return; + }; + } + self.fire(exec, comptime .wrap("complete"), self._on_complete); +} + +pub fn ensureBegun(self: *IDBTransaction) !void { + if (self._settled) { + return error.TransactionInactiveError; + } + + if (self._begun) { + return; + } + try self._engine.begin(); + self._begun = true; +} + +pub fn newRequest(self: *IDBTransaction) !*IDBRequest { + const request = try IDBRequest.init(self._exec); + request._txn = self; + try self._requests.append(self._exec.arena, request); + return request; +} + +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 { + return error.NotFound; + }; + + const owned_name = try exec.dupeString(name); + return IDBObjectStore.init(self._engine, self, store_id, owned_name, null, exec); +} + +pub fn getMode(self: *const IDBTransaction) Mode { + return self._mode; +} + +pub fn getDurability(self: *const IDBTransaction) Durability { + return self._durability; +} + +pub fn getDb(self: *IDBTransaction) *IDBDatabase { + return self._db; +} + +pub fn getOnComplete(self: *const IDBTransaction) ?js.Function.Global { + return self._on_complete; +} + +pub fn setOnComplete(self: *IDBTransaction, setter: ?FunctionSetter) void { + self._on_complete = getFunctionFromSetter(setter); +} + +pub fn getOnError(self: *const IDBTransaction) ?js.Function.Global { + return self._on_error; +} + +pub fn setOnError(self: *IDBTransaction, setter: ?FunctionSetter) void { + self._on_error = getFunctionFromSetter(setter); +} + +pub fn getOnAbort(self: *const IDBTransaction) ?js.Function.Global { + return self._on_abort; +} + +pub fn setOnAbort(self: *IDBTransaction, setter: ?FunctionSetter) void { + self._on_abort = getFunctionFromSetter(setter); +} + +fn getFunctionFromSetter(setter: ?FunctionSetter) ?js.Function.Global { + const s = setter orelse return null; + return switch (s) { + .func => |f| f, + .anything => null, + }; +} + +fn fire(self: *IDBTransaction, exec: *Execution, typ: lp.String, handler: ?js.Function.Global) void { + self._settled = true; + const event = Event.initTrusted(typ, null, exec.page) catch |err| { + log.warn(.storage, "idb transaction event", .{ .err = err }); + return; + }; + exec.dispatch(self.asEventTarget(), event, handler, .{ .context = "IDBTransaction" }) catch |err| { + log.warn(.storage, "idb transaction dispatch", .{ .err = err }); + }; +} + +fn drain(ctx: *anyopaque) !?u32 { + const self: *IDBTransaction = @ptrCast(@alignCast(ctx)); + if (self._settled) { + // could have already been settled, e.g. via an abort) + return null; + } + self.settle(self._exec); + return null; +} + +fn finalize(ctx: *anyopaque) void { + const self: *IDBTransaction = @ptrCast(@alignCast(ctx)); + if (self._begun and !self._settled) { + self._engine.rollback(); + } +} + +// Deliver every queued request in order. Re-reads len each iteration so a +// `success` handler can enqueue a follow-up request on this transaction. +fn deliverPending(self: *IDBTransaction, exec: *Execution) void { + var i: usize = 0; + while (i < self._requests.items.len) : (i += 1) { + self._requests.items[i].deliver(exec) catch |err| { + log.warn(.storage, "idb request deliver", .{ .err = err }); + }; + } +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(IDBTransaction); + + pub const Meta = struct { + pub const name = "IDBTransaction"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const mode = bridge.accessor(IDBTransaction.getMode, null, .{}); + pub const durability = bridge.accessor(IDBTransaction.getDurability, null, .{}); + pub const db = bridge.accessor(IDBTransaction.getDb, null, .{}); + pub const objectStore = bridge.function(IDBTransaction.objectStore, .{ .dom_exception = true }); + pub const abort = bridge.function(IDBTransaction.abort, .{ .dom_exception = true }); + pub const commit = bridge.function(IDBTransaction.commit, .{ .dom_exception = true }); + pub const oncomplete = bridge.accessor(IDBTransaction.getOnComplete, IDBTransaction.setOnComplete, .{}); + pub const onerror = bridge.accessor(IDBTransaction.getOnError, IDBTransaction.setOnError, .{}); + pub const onabort = bridge.accessor(IDBTransaction.getOnAbort, IDBTransaction.setOnAbort, .{}); +}; diff --git a/src/browser/webapi/storage/idb/IDBVersionChangeEvent.zig b/src/browser/webapi/storage/idb/IDBVersionChangeEvent.zig new file mode 100644 index 000000000..119dfc9de --- /dev/null +++ b/src/browser/webapi/storage/idb/IDBVersionChangeEvent.zig @@ -0,0 +1,93 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../../js/js.zig"); + +const Event = @import("../../Event.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; +const Execution = js.Execution; + +const IDBVersionChangeEvent = @This(); + +_proto: *Event, +_old_version: u64, +_new_version: ?u64, + +const IDBVersionChangeEventOptions = struct { + oldVersion: u64 = 0, + newVersion: ?u64 = null, +}; + +const Options = Event.inheritOptions(IDBVersionChangeEvent, IDBVersionChangeEventOptions); + +pub fn init(typ: []const u8, opts_: ?Options, exec: *const Execution) !*IDBVersionChangeEvent { + const arena = try exec.getArena(.tiny, "IDBVersionChangeEvent"); + errdefer exec.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + return initWithTrusted(arena, type_string, opts_, false, exec); +} + +pub fn initTrusted(typ: String, old_version: u64, new_version: ?u64, exec: *const Execution) !*IDBVersionChangeEvent { + const arena = try exec.getArena(.tiny, "IDBVersionChangeEvent.trusted"); + errdefer exec.releaseArena(arena); + return initWithTrusted(arena, typ, .{ .oldVersion = old_version, .newVersion = new_version }, true, exec); +} + +fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, exec: *const Execution) !*IDBVersionChangeEvent { + const opts = opts_ orelse Options{}; + + const event = try exec._factory.event(arena, typ, IDBVersionChangeEvent{ + ._proto = undefined, + ._old_version = opts.oldVersion, + ._new_version = opts.newVersion, + }); + + Event.populatePrototypes(event, opts, trusted); + return event; +} + +pub fn asEvent(self: *IDBVersionChangeEvent) *Event { + return self._proto; +} + +pub fn getOldVersion(self: *const IDBVersionChangeEvent) u64 { + return self._old_version; +} + +pub fn getNewVersion(self: *const IDBVersionChangeEvent) ?u64 { + return self._new_version; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(IDBVersionChangeEvent); + + pub const Meta = struct { + pub const name = "IDBVersionChangeEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(IDBVersionChangeEvent.init, .{}); + pub const oldVersion = bridge.accessor(IDBVersionChangeEvent.getOldVersion, null, .{}); + pub const newVersion = bridge.accessor(IDBVersionChangeEvent.getNewVersion, null, .{ .null_as_undefined = true }); +}; diff --git a/src/browser/webapi/storage/idb/Key.zig b/src/browser/webapi/storage/idb/Key.zig new file mode 100644 index 000000000..c7fa1d1ef --- /dev/null +++ b/src/browser/webapi/storage/idb/Key.zig @@ -0,0 +1,131 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); + +const js = @import("../../../js/js.zig"); + +const Allocator = std.mem.Allocator; + +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, +}; + +value: Value, + +pub const Value = union(enum) { + number: f64, + string: []const u8, +}; + +pub fn number(n: f64) Key { + return .{ .value = .{ .number = n } }; +} + +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 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; +} + +// 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; + }, + } +} + +// 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. +fn writeOrderedF64(out: *[8]u8, n: f64) void { + var bits: u64 = @bitCast(n); + if (bits & (1 << 63) != 0) { + bits = ~bits; + } else { + bits |= (1 << 63); + } + std.mem.writeInt(u64, out, bits, .big); +} + +const testing = @import("../../../../testing.zig"); + +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; + defer if (prev) |p| testing.allocator.free(p); + + for (cases) |n| { + const enc = try Key.number(n).encode(testing.allocator); + if (prev) |p| { + try testing.expect(std.mem.order(u8, p, enc) == .lt); + testing.allocator.free(p); + } + prev = enc; + } +} + +test "IDB - Key: numbers sort before strings" { + const num = try Key.number(1e308).encode(testing.allocator); + defer testing.allocator.free(num); + const str = try Key.string("").encode(testing.allocator); + defer testing.allocator.free(str); + try testing.expect(std.mem.order(u8, num, str) == .lt); +} + +test "IDB - Key: string encoding preserves byte order" { + 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); +} diff --git a/src/browser/webapi/storage/idb/Manager.zig b/src/browser/webapi/storage/idb/Manager.zig new file mode 100644 index 000000000..c69a33037 --- /dev/null +++ b/src/browser/webapi/storage/idb/Manager.zig @@ -0,0 +1,129 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const Engine = @import("Engine.zig"); + +const log = lp.log; +const Allocator = std.mem.Allocator; + +const Manager = @This(); + +allocator: Allocator, +base_dir: []const u8, +engines: std.StringHashMapUnmanaged(*Engine) = .empty, + +pub fn init(allocator: Allocator, base_dir: ?[]const u8) Manager { + return .{ .allocator = allocator, .base_dir = base_dir orelse ":memory:" }; +} + +pub fn deinit(self: *Manager) void { + var it = self.engines.iterator(); + while (it.next()) |kv| { + kv.value_ptr.*.close(); + self.allocator.destroy(kv.value_ptr.*); + self.allocator.free(kv.key_ptr.*); + } + self.engines.deinit(self.allocator); +} + +// Gets or creates the engine for the given origin. +pub fn engineForOrigin(self: *Manager, origin: []const u8) !*Engine { + const gop = try self.engines.getOrPut(self.allocator, origin); + if (gop.found_existing) { + return gop.value_ptr.*; + } + errdefer _ = self.engines.remove(origin); + + const engine = try self.allocator.create(Engine); + errdefer self.allocator.destroy(engine); + + engine.* = try self.open(origin); + errdefer engine.close(); + + gop.key_ptr.* = try self.allocator.dupe(u8, origin); + gop.value_ptr.* = engine; + return engine; +} + +fn open(self: *Manager, origin: []const u8) !Engine { + if (std.mem.eql(u8, self.base_dir, ":memory:")) { + return Engine.open(":memory:"); + } + + // we hash the origin for two reason: + // 1 - it fixes the length, so we don't have to worry about illegally long filename + // 2 - It removes any unsafe characters + var digest: [16]u8 = undefined; + std.crypto.hash.Blake3.hash(origin, &digest, .{}); + var name_buf: [64]u8 = undefined; + const file_name = std.fmt.bufPrint(&name_buf, "idb-{x}.sqlite", .{&digest}) catch unreachable; + + const path = try std.fs.path.joinZ(self.allocator, &.{ self.base_dir, file_name }); + defer self.allocator.free(path); + + log.debug(.storage, "idb open", .{ .origin = origin, .path = path }); + return Engine.open(path); +} + +const testing = @import("../../../../testing.zig"); +test "IDB - Manager: same origin returns same engine, distinct origins differ" { + var mgr = Manager.init(testing.allocator, null); + defer mgr.deinit(); + + const a1 = try mgr.engineForOrigin("https://a.com"); + const a2 = try mgr.engineForOrigin("https://a.com"); + const b1 = try mgr.engineForOrigin("https://b.com"); + + try testing.expect(a1 == a2); + try testing.expect(a1 != b1); +} + +test "IDB - Manager: in-memory engines are origin-isolated" { + var mgr = Manager.init(testing.allocator, null); + defer mgr.deinit(); + + const a = try mgr.engineForOrigin("https://a.com"); + const b = try mgr.engineForOrigin("https://b.com"); + + _ = try a.upsertDatabase("db", 1); + try testing.expectEqual(null, try b.databaseVersion("db")); +} + +test "IDB - Manager: on-disk engines hash to per-origin files, isolated" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(dir); + + var mgr = Manager.init(testing.allocator, dir); + defer mgr.deinit(); + + // A long origin (hostname near the 253-byte limit) must still open: the + // hashed file name stays well under NAME_MAX where a transcribed one would + // not. + const long_host = "https://" ++ ("a" ** 250) ++ ".com"; + const a = try mgr.engineForOrigin(long_host); + _ = try a.upsertDatabase("db", 7); + + const b = try mgr.engineForOrigin("https://b.com"); + try testing.expectEqual(null, try b.databaseVersion("db")); + try testing.expectEqual(7, (try a.databaseVersion("db")).?); +} diff --git a/src/browser/webapi/storage/idb/idb.zig b/src/browser/webapi/storage/idb/idb.zig new file mode 100644 index 000000000..eef0c94ee --- /dev/null +++ b/src/browser/webapi/storage/idb/idb.zig @@ -0,0 +1,53 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const js = @import("../../../js/js.zig"); + +pub const Key = @import("Key.zig"); +pub const Engine = @import("Engine.zig"); +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 IDBTransaction = @import("IDBTransaction.zig"); +pub const IDBObjectStore = @import("IDBObjectStore.zig"); +pub const IDBVersionChangeEvent = @import("IDBVersionChangeEvent.zig"); + +pub fn registerTypes() []const type { + return &.{ + IDBFactory, + IDBRequest, + IDBDatabase, + IDBTransaction, + IDBObjectStore, + IDBVersionChangeEvent, + }; +} + +// 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) { + func: js.Function.Global, + anything: js.Value, +}; + +const testing = @import("../../../../testing.zig"); +test "WebApi: IndexedDB" { + try testing.htmlRunner("indexeddb.html", .{}); +} diff --git a/src/help.zon b/src/help.zon index eb9d11c2d..6fd17dc9a 100644 --- a/src/help.zon +++ b/src/help.zon @@ -367,5 +367,8 @@ \\ --storage-sqlite-path \\ Path to the SQLite database file for persistent storage. \\ Use ":memory:" for in-memory storage. + \\ --indexdb_dir + \\ Directory to store an SQLite database for IndexDB. + \\ Defaults to ":memory:" , }