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:"
,
}