mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-05 02:45:48 -04:00
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:
22 files changed
+2154
-4
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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]",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")).?);
|
||||
}
|
||||
@@ -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", .{});
|
||||
}
|
||||
@@ -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:"
|
||||
,
|
||||
}
|
||||
Reference in new issue
Block a user