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.
This commit is contained in:
Karl Seguin committed 2026-07-03 07:09:59 +08:00
1 parent 6708d5b961
commit 90a5a5fc51
22 files changed
+2154 -4

No files matched your search

+8
View File
@@ -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,
+7 -2
View File
@@ -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();
+9
View File
@@ -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;
+3
View File
@@ -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);
}
+2
View File
@@ -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"),
});
+330
View File
@@ -0,0 +1,330 @@
<!DOCTYPE html>
<body>
<script src="testing.js"></script>
<!-- The bare-slice milestone: open a database, create an object store during
the upgrade, then add and read back a structured-cloned value. Exercises
the async request model (upgradeneeded -> success), transaction autocommit
(the writes commit when the drain task runs), and the value-serialization
seam. -->
<script id="open_add_get" type=module>
{
const state = await testing.async();
const req = indexedDB.open("books-db", 1);
let upgraded = false;
req.onupgradeneeded = (e) => {
const db = e.target.result;
db.createObjectStore("books");
upgraded = true;
};
req.onsuccess = (e) => {
const db = e.target.result;
testing.expectEqual("books-db", db.name);
testing.expectEqual(1, db.version);
const tx = db.transaction("books", "readwrite");
const store = tx.objectStore("books");
store.add({ title: "Dune", pages: 412 }, "b1");
const getReq = store.get("b1");
getReq.onsuccess = () => {
state.resolve({ upgraded, value: getReq.result, readyState: getReq.readyState });
};
};
await state.done((got) => {
testing.expectEqual(true, got.upgraded);
testing.expectEqual("done", got.readyState);
testing.expectEqual("Dune", got.value.title);
testing.expectEqual(412, got.value.pages);
});
}
</script>
<!-- A missing key resolves the request with `undefined` (not an error). -->
<script id="get_missing" type=module>
{
const state = await testing.async();
const req = indexedDB.open("misc-db", 1);
req.onupgradeneeded = (e) => e.target.result.createObjectStore("kv");
req.onsuccess = (e) => {
const store = e.target.result.transaction("kv", "readonly").objectStore("kv");
const getReq = store.get("absent");
getReq.onsuccess = () => state.resolve(getReq.result);
};
await state.done((result) => {
testing.expectEqual(undefined, result);
});
}
</script>
<!-- A numeric key and a second connection see data written earlier in the
session: the engine persists across open() calls within the origin. -->
<script id="numeric_key_persists" type=module>
{
const state = await testing.async();
const open1 = indexedDB.open("nums-db", 1);
open1.onupgradeneeded = (e) => e.target.result.createObjectStore("s");
open1.onsuccess = (e) => {
const store = e.target.result.transaction("s", "readwrite").objectStore("s");
store.add("forty-two", 42);
// Re-open and read back via a fresh connection/transaction.
const open2 = indexedDB.open("nums-db");
open2.onsuccess = (e2) => {
const getReq = e2.target.result.transaction("s", "readonly").objectStore("s").get(42);
getReq.onsuccess = () => state.resolve(getReq.result);
};
};
await state.done((value) => {
testing.expectEqual("forty-two", value);
});
}
</script>
<!-- Version rules: version 0 is a synchronous TypeError; reopening at the same
version does not re-run upgradeneeded; a downgrade fails via onerror rather
than onsuccess. -->
<script id="version_rules" type=module>
{
let threw = null;
try { indexedDB.open("ver-db", 0); } catch (e) { threw = e; }
testing.expectEqual(true, threw instanceof TypeError);
const state = await testing.async();
const open1 = indexedDB.open("ver-db", 2);
let firstUpgrade = false;
open1.onupgradeneeded = () => { firstUpgrade = true; };
open1.onsuccess = () => {
testing.expectEqual(true, firstUpgrade);
// Same version again: opens successfully, no second upgrade.
const open2 = indexedDB.open("ver-db", 2);
let secondUpgrade = false;
open2.onupgradeneeded = () => { secondUpgrade = true; };
open2.onsuccess = () => {
// Downgrade to a lower version: must reject via onerror.
const open3 = indexedDB.open("ver-db", 1);
open3.onsuccess = () => state.resolve({ downgradeRejected: false, secondUpgrade });
open3.onerror = () => state.resolve({ downgradeRejected: true, secondUpgrade });
};
};
await state.done((got) => {
testing.expectEqual(false, got.secondUpgrade);
testing.expectEqual(true, got.downgradeRejected);
});
}
</script>
<!-- A store handle retained past its transaction's completion is inactive:
operating on it throws TransactionInactiveError rather than touching the
transaction's released arena. -->
<script id="store_after_complete" type=module>
{
const state = await testing.async();
const open = indexedDB.open("inactive-db", 1);
open.onupgradeneeded = (e) => e.target.result.createObjectStore("s");
open.onsuccess = (e) => {
const tx = e.target.result.transaction("s", "readwrite");
const store = tx.objectStore("s");
store.put("v", "k");
tx.oncomplete = () => {
// The transaction has finished; the retained store is now inactive.
let threw = null;
try { store.get("k"); } catch (err) { threw = err; }
state.resolve(threw);
};
};
await state.done((threw) => {
testing.expectEqual(true, threw !== null);
testing.expectEqual("TransactionInactiveError", threw.name);
});
}
</script>
<!-- deleteDatabase removes the database: reopening recreates it (upgradeneeded
fires again, version back to 1). -->
<script id="delete_database" type=module>
{
const state = await testing.async();
const open1 = indexedDB.open("del-db", 3);
open1.onupgradeneeded = (e) => e.target.result.createObjectStore("s");
open1.onsuccess = (e) => {
e.target.result.close();
const del = indexedDB.deleteDatabase("del-db");
del.onsuccess = () => {
const open2 = indexedDB.open("del-db");
let reUpgraded = false;
open2.onupgradeneeded = () => { reUpgraded = true; };
open2.onsuccess = (e2) => state.resolve({ reUpgraded, version: e2.target.result.version });
};
};
await state.done((got) => {
// Reopening triggered an upgrade and reset the version, proving the
// database (which had been at version 3) was actually deleted.
testing.expectEqual(true, got.reUpgraded);
testing.expectEqual(1, got.version);
});
}
</script>
<!-- indexedDB.cmp orders keys (-1/0/1); numbers sort before strings; an
invalid key is a DataError. -->
<script id="cmp" type=module>
{
testing.expectEqual(-1, indexedDB.cmp(1, 2));
testing.expectEqual(0, indexedDB.cmp(2, 2));
testing.expectEqual(1, indexedDB.cmp(2, 1));
testing.expectEqual(-1, indexedDB.cmp("apple", "banana"));
testing.expectEqual(-1, indexedDB.cmp(999, "0")); // number < string
let threw = null;
try { indexedDB.cmp(1, {}); } catch (e) { threw = e; }
testing.expectEqual("DataError", threw && threw.name);
}
</script>
<!-- During upgradeneeded, request.transaction is the versionchange transaction;
a store created in the upgrade belongs to it and seeded data persists. -->
<script id="request_transaction" type=module>
{
const state = await testing.async();
const open = indexedDB.open("rt-db", 1);
let mode = null, present = false;
open.onupgradeneeded = (e) => {
const tx = e.target.transaction;
present = tx != null;
mode = tx && tx.mode;
e.target.result.createObjectStore("s").add("hello", "k"); // store bound to the txn
};
open.onsuccess = (e) => {
const g = e.target.result.transaction("s", "readonly").objectStore("s").get("k");
g.onsuccess = () => state.resolve({ present, mode, value: g.result });
};
await state.done((got) => {
testing.expectEqual(true, got.present);
testing.expectEqual("versionchange", got.mode);
testing.expectEqual("hello", got.value);
});
}
</script>
<!-- transaction.abort() in upgradeneeded rolls the upgrade back: the open
request fails with AbortError and the database is not created. -->
<script id="abort_upgrade" type=module>
{
const state = await testing.async();
const open = indexedDB.open("abrt-db", 1);
open.onupgradeneeded = (e) => {
e.target.result.createObjectStore("s");
e.target.transaction.abort();
};
open.onerror = () => {
const open2 = indexedDB.open("abrt-db");
let reUpgraded = false;
open2.onupgradeneeded = () => { reUpgraded = true; };
open2.onsuccess = () => state.resolve({ errName: open.error && open.error.name, reUpgraded });
};
await state.done((got) => {
testing.expectEqual("AbortError", got.errName);
testing.expectEqual(true, got.reUpgraded); // rollback undid the database creation
});
}
</script>
<!-- deleteObjectStore removes a store during the upgrade. -->
<script id="delete_object_store" type=module>
{
const state = await testing.async();
const open = indexedDB.open("dos-db", 1);
open.onupgradeneeded = (e) => {
const db = e.target.result;
db.createObjectStore("temp");
db.deleteObjectStore("temp");
};
open.onsuccess = (e) => {
const tx = e.target.result.transaction("temp", "readonly");
let threw = null;
try { tx.objectStore("temp"); } catch (err) { threw = err; }
state.resolve(threw && threw.name);
};
await state.done((name) => {
testing.expectEqual("NotFoundError", name);
});
}
</script>
<!-- upgradeneeded is an IDBVersionChangeEvent (oldVersion/newVersion), and the
store query methods (count, getAll) + durability work. -->
<script id="objectstore_methods" type=module>
{
const state = await testing.async();
const open = indexedDB.open("osm-db", 2);
let oldV = null, newV = null;
open.onupgradeneeded = (e) => {
oldV = e.oldVersion;
newV = e.newVersion;
const s = e.target.result.createObjectStore("s");
s.add("a", 1);
s.add("b", 2);
s.add("c", 3);
};
open.onsuccess = (e) => {
const tx = e.target.result.transaction("s", "readonly");
testing.expectEqual("default", tx.durability);
const store = tx.objectStore("s");
const cReq = store.count();
const gaReq = store.getAll();
gaReq.onsuccess = () => state.resolve({ oldV, newV, count: cReq.result, all: gaReq.result });
};
await state.done((got) => {
testing.expectEqual(0, got.oldV);
testing.expectEqual(2, got.newV);
testing.expectEqual(3, got.count);
testing.expectEqual(3, got.all.length);
testing.expectEqual("a", got.all[0]); // key order
testing.expectEqual("c", got.all[2]);
});
}
</script>
<!-- A write in a readonly transaction is a ReadOnlyError. -->
<script id="readonly_enforced" type=module>
{
const state = await testing.async();
const open = indexedDB.open("ro-db", 1);
open.onupgradeneeded = (e) => e.target.result.createObjectStore("s");
open.onsuccess = (e) => {
const store = e.target.result.transaction("s", "readonly").objectStore("s");
let threw = null;
try { store.add("v", "k"); } catch (err) { threw = err; }
state.resolve(threw && threw.name);
};
await state.done((name) => testing.expectEqual("ReadOnlyError", name));
}
</script>
<!-- clear() empties the store; commit() settles the transaction explicitly. -->
<script id="clear_and_commit" type=module>
{
const state = await testing.async();
const open = indexedDB.open("cc-db", 1);
open.onupgradeneeded = (e) => e.target.result.createObjectStore("s").add("v", "k");
open.onsuccess = (e) => {
const db = e.target.result;
const tx = db.transaction("s", "readwrite");
tx.objectStore("s").clear();
// oncomplete must be set before commit(): commit settles synchronously.
tx.oncomplete = () => {
const c = db.transaction("s", "readonly").objectStore("s").count();
c.onsuccess = () => state.resolve(c.result);
};
tx.commit();
};
await state.done((count) => testing.expectEqual(0, count));
}
</script>
</body>
+26 -2
View File
@@ -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;
}
+2
View File
@@ -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")) {
+9
View File
@@ -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("<FontFaceSet>"),
.websocket => writer.writeAll("<WebSocket>"),
.cookie_store => writer.writeAll("<CookieStore>"),
.idb_request => writer.writeAll("<IDBRequest>"),
.idb_database => writer.writeAll("<IDBDatabase>"),
.idb_transaction => writer.writeAll("<IDBTransaction>"),
.notification => writer.writeAll("<Notification>"),
};
}
@@ -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]",
};
}
+12
View File
@@ -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, .{});
+12
View File
@@ -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, .{});
+260
View File
@@ -0,0 +1,260 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const 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"));
}
@@ -0,0 +1,153 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const 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, .{});
};
@@ -0,0 +1,225 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const 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 });
};
@@ -0,0 +1,215 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const 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 });
};
@@ -0,0 +1,183 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const 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, .{});
};
@@ -0,0 +1,289 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const 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, .{});
};
@@ -0,0 +1,93 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const 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 });
};
+131
View File
@@ -0,0 +1,131 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("../../../js/js.zig");
const 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);
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const 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")).?);
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const 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", .{});
}
+3
View File
@@ -367,5 +367,8 @@
\\ --storage-sqlite-path <PATH>
\\ Path to the SQLite database file for persistent storage.
\\ Use ":memory:" for in-memory storage.
\\ --indexdb_dir <PATH>
\\ Directory to store an SQLite database for IndexDB.
\\ Defaults to ":memory:"
,
}