feat(storage): add SQLite-backed record store (#320)
* feat(storage): add SQLite-backed record store - add capability-shed SQLite storage with deterministic effects, replay, and atomic record operations - expose matching TypeScript and Zig APIs across desktop and mobile hosts - add hermetic coverage, devhost support, documentation, and a worked example Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com> * fix(storage): restore pristine SQLite amalgamation - restore the upstream byte removed during whitespace cleanup so the vendored source matches its documented checksum Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com> * fix(storage): harden store result delivery * fix(storage): align devhost store semantics * fix(storage): address review findings --------- Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com>
This commit is contained in:
@@ -67,6 +67,9 @@ jobs:
|
||||
# gpu-components is a TypeScript-core app, so its smoke build needs
|
||||
# the frontend compiler and exact-pinned TypeScript toolchain.
|
||||
- run: npm ci --prefix packages/core
|
||||
# The mobile aggregate runs on Linux for Android. Exercise the other
|
||||
# store-capable cross-target here against the real iPhone simulator SDK.
|
||||
- run: zig build test-example-mobile-canvas-lib-ios-store
|
||||
- run: zig build test-webview-system-link
|
||||
- run: zig build test-webview-smoke
|
||||
# Signed-package seal pin: an ad-hoc signed package must pass
|
||||
|
||||
@@ -141,9 +141,28 @@ pub fn build(b: *std.Build) void {
|
||||
desktop_mod.addImport("json", json_mod);
|
||||
desktop_mod.addImport("canvas", canvas_mod);
|
||||
desktop_mod.addImport("terminal_vt", terminalVtModule(b, target, optimize));
|
||||
desktop_mod.addIncludePath(b.path("third_party/sqlite"));
|
||||
desktop_mod.addCSourceFile(.{
|
||||
.file = b.path("third_party/sqlite/sqlite3.c"),
|
||||
.flags = sqliteCompileFlags(),
|
||||
});
|
||||
desktop_mod.link_libc = true;
|
||||
const desktop_tests = testArtifact(b, desktop_mod);
|
||||
const desktop_test_shards = desktopTestShardArtifacts(b, desktop_mod);
|
||||
|
||||
// SQLite is capability-shed from ordinary app artifacts. Its focused
|
||||
// engine/store suite gets a dedicated module that explicitly compiles the
|
||||
// vendored amalgamation, so framework tests cover it without making every
|
||||
// unrelated example carry the database object.
|
||||
const record_store_mod = module(b, target, optimize, "src/runtime/record_store.zig");
|
||||
record_store_mod.addIncludePath(b.path("third_party/sqlite"));
|
||||
record_store_mod.addCSourceFile(.{
|
||||
.file = b.path("third_party/sqlite/sqlite3.c"),
|
||||
.flags = sqliteCompileFlags(),
|
||||
});
|
||||
record_store_mod.link_libc = true;
|
||||
const record_store_tests = testArtifact(b, record_store_mod);
|
||||
|
||||
// The embeddable static library's root module carries only the C ABI
|
||||
// exports (fixed WebView shell host); user-app canvas libraries are
|
||||
// produced by `addMobileLib` from src/embed/app_exports.zig instead.
|
||||
@@ -474,6 +493,7 @@ pub fn build(b: *std.Build) void {
|
||||
test_step.dependOn(&b.addRunArtifact(json_tests).step);
|
||||
test_step.dependOn(&b.addRunArtifact(app_runner_assets_tests).step);
|
||||
test_step.dependOn(&b.addRunArtifact(canvas_tests).step);
|
||||
test_step.dependOn(&b.addRunArtifact(record_store_tests).step);
|
||||
for (desktop_test_shards) |shard_tests| {
|
||||
test_step.dependOn(&b.addRunArtifact(shard_tests).step);
|
||||
}
|
||||
@@ -1427,6 +1447,7 @@ pub fn build(b: *std.Build) void {
|
||||
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-gpu-surface", "Run GPU surface example tests", "examples/gpu-surface", .managed),
|
||||
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-gpu-dashboard", "Run GPU dashboard example tests", "examples/gpu-dashboard", .managed),
|
||||
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-gpu-components", "Run GPU components example tests", "examples/gpu-components", .managed),
|
||||
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-record-store", "Run record-store example tests", "examples/record-store", .managed),
|
||||
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-ui-inbox", "Run ui builder inbox example tests", "examples/ui-inbox", .owned),
|
||||
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-kanban", "Run ui builder kanban example tests", "examples/kanban", .managed),
|
||||
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-habits", "Run markup habits example tests", "examples/habits", .managed),
|
||||
@@ -1635,14 +1656,51 @@ pub fn build(b: *std.Build) void {
|
||||
mobile_canvas_lib_step.dependOn(&build_mobile_canvas_lib.step);
|
||||
mobile_examples_step.dependOn(&build_mobile_canvas_lib.step);
|
||||
|
||||
// Android cross-compile proof: pure Zig (no NDK sysroot — the static
|
||||
// lib links no libc), PIC so the objects can land in the shim's .so.
|
||||
const build_mobile_canvas_store_lib = b.addSystemCommand(&.{ "zig", "build", "lib", "-Dstore=true" });
|
||||
build_mobile_canvas_store_lib.setCwd(b.path("examples/mobile-canvas"));
|
||||
const mobile_canvas_store_lib_step = b.step("test-example-mobile-canvas-lib-store", "Build a record-store-capable mobile embed static library");
|
||||
mobile_canvas_store_lib_step.dependOn(&build_mobile_canvas_store_lib.step);
|
||||
mobile_examples_step.dependOn(&build_mobile_canvas_store_lib.step);
|
||||
|
||||
// Android cross-compile proofs: the capability-free archive remains pure
|
||||
// Zig, while the store variant must discover the NDK sysroot and compile
|
||||
// the vendored SQLite amalgamation for bionic. Keeping both catches a
|
||||
// capability branch that a host-only store build cannot exercise.
|
||||
const build_mobile_canvas_lib_android = b.addSystemCommand(&.{ "zig", "build", "lib", "-Dtarget=aarch64-linux-android" });
|
||||
build_mobile_canvas_lib_android.setCwd(b.path("examples/mobile-canvas"));
|
||||
const mobile_canvas_lib_android_step = b.step("test-example-mobile-canvas-lib-android", "Cross-compile the mobile-canvas embed static library for aarch64-linux-android");
|
||||
mobile_canvas_lib_android_step.dependOn(&build_mobile_canvas_lib_android.step);
|
||||
mobile_examples_step.dependOn(&build_mobile_canvas_lib_android.step);
|
||||
|
||||
const build_mobile_canvas_store_lib_android = b.addSystemCommand(&.{
|
||||
"zig", "build", "lib", "-Dstore=true", "-Dtarget=aarch64-linux-android",
|
||||
"--prefix", "zig-out/test-android-store",
|
||||
});
|
||||
build_mobile_canvas_store_lib_android.setCwd(b.path("examples/mobile-canvas"));
|
||||
const mobile_canvas_store_lib_android_step = b.step("test-example-mobile-canvas-lib-android-store", "Cross-compile SQLite-backed mobile storage for aarch64-linux-android");
|
||||
mobile_canvas_store_lib_android_step.dependOn(&build_mobile_canvas_store_lib_android.step);
|
||||
mobile_examples_step.dependOn(&build_mobile_canvas_store_lib_android.step);
|
||||
|
||||
// Apple SDK headers only exist on macOS. This is still part of the local
|
||||
// mobile aggregate there, and CI invokes the named step on its macOS tier.
|
||||
if (b.graph.host.result.os.tag == .macos) {
|
||||
const build_mobile_canvas_store_lib_ios = b.addSystemCommand(&.{
|
||||
"zig", "build", "lib", "-Dstore=true", "-Dtarget=aarch64-ios-simulator",
|
||||
"--prefix", "zig-out/test-ios-store",
|
||||
});
|
||||
build_mobile_canvas_store_lib_ios.setCwd(b.path("examples/mobile-canvas"));
|
||||
const build_mobile_canvas_store_lib_ios_device = b.addSystemCommand(&.{
|
||||
"zig", "build", "lib", "-Dstore=true", "-Dtarget=aarch64-ios",
|
||||
"--prefix", "zig-out/test-ios-device-store",
|
||||
});
|
||||
build_mobile_canvas_store_lib_ios_device.setCwd(b.path("examples/mobile-canvas"));
|
||||
const mobile_canvas_store_lib_ios_step = b.step("test-example-mobile-canvas-lib-ios-store", "Cross-compile SQLite-backed mobile storage for iOS simulator and device");
|
||||
mobile_canvas_store_lib_ios_step.dependOn(&build_mobile_canvas_store_lib_ios.step);
|
||||
mobile_canvas_store_lib_ios_step.dependOn(&build_mobile_canvas_store_lib_ios_device.step);
|
||||
mobile_examples_step.dependOn(&build_mobile_canvas_store_lib_ios.step);
|
||||
mobile_examples_step.dependOn(&build_mobile_canvas_store_lib_ios_device.step);
|
||||
}
|
||||
|
||||
const examples_step = b.step("test-examples", "Run all example tests and layout checks");
|
||||
examples_step.dependOn(frontend_examples_step);
|
||||
examples_step.dependOn(native_examples_step);
|
||||
@@ -2719,6 +2777,15 @@ pub fn build(b: *std.Build) void {
|
||||
cef_bundle_step.dependOn(&cef_bundle_script.step);
|
||||
}
|
||||
|
||||
fn sqliteCompileFlags() []const []const u8 {
|
||||
return &.{
|
||||
"-DSQLITE_THREADSAFE=1",
|
||||
"-DSQLITE_OMIT_LOAD_EXTENSION",
|
||||
"-DSQLITE_DQS=0",
|
||||
"-DSQLITE_DEFAULT_MEMSTATUS=0",
|
||||
};
|
||||
}
|
||||
|
||||
fn module(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, path: []const u8) *std.Build.Module {
|
||||
return b.createModule(.{
|
||||
.root_source_file = b.path(path),
|
||||
@@ -2857,6 +2924,7 @@ fn tsCoreE2eArtifact(
|
||||
.entry = "tests/ts-core/fixture.ts",
|
||||
.src_dir = host_src.getDirectory(),
|
||||
.name = "host_fixture_core",
|
||||
.store_capability = true,
|
||||
// The fixture drives pastBytes to the f64-exact boundary (2^53):
|
||||
// no honest i64 declaration exists there, so the compiled
|
||||
// projection carries the slot as f64.
|
||||
@@ -3263,6 +3331,8 @@ const ExternalCoreFixtureSpec = struct {
|
||||
name: []const u8,
|
||||
/// The fixture stands in for an app whose manifest declares Tier 1.
|
||||
persist_capability: bool = false,
|
||||
/// The fixture stands in for an app whose manifest declares Tier 2.
|
||||
store_capability: bool = false,
|
||||
/// Attested integer slots the compiled projection carries as f64
|
||||
/// (values that reach the f64-exact boundary have no honest i64
|
||||
/// declaration on that side) — corewire's --f64-slot demotions,
|
||||
@@ -3303,6 +3373,7 @@ fn externalCoreFixtureModule(
|
||||
break :services check.addOutputFileArg("services.contract.json");
|
||||
} else null;
|
||||
if (spec.persist_capability) check.addArgs(&.{ "--capability", "persist" });
|
||||
if (spec.store_capability) check.addArgs(&.{ "--capability", "store" });
|
||||
tsCoreAddDirInputs(b, check, "packages/core/sdk");
|
||||
tsCoreAddDirInputs(b, check, std.fs.path.dirname(spec.entry) orelse ".");
|
||||
const frontend_sources = [_][]const u8{
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"src",
|
||||
"templates",
|
||||
"tests",
|
||||
"third_party/sqlite",
|
||||
"tools",
|
||||
},
|
||||
}
|
||||
|
||||
+212
-1
@@ -4,6 +4,7 @@
|
||||
//! come from the native-sdk dependency.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
/// The shared web-layer inference contract: this build graph is one thin
|
||||
/// adapter over it (the CLI's manifest tooling and the app runner are the
|
||||
@@ -366,6 +367,7 @@ fn tsCoreStage(
|
||||
app_root: []const u8,
|
||||
app_name: []const u8,
|
||||
persist_capability: bool,
|
||||
store_capability: bool,
|
||||
persist_version: ?u64,
|
||||
) TsCoreStage {
|
||||
const node = tsCorePreflight(b, dep, app_root);
|
||||
@@ -399,6 +401,7 @@ fn tsCoreStage(
|
||||
break :services_contract check.addOutputFileArg("services.contract.json");
|
||||
} else null;
|
||||
if (persist_capability) check.addArgs(&.{ "--capability", "persist" });
|
||||
if (store_capability) check.addArgs(&.{ "--capability", "store" });
|
||||
if (persist_version) |version| {
|
||||
check.addArgs(&.{ "--persist-version", b.fmt("{d}", .{version}) });
|
||||
check.addArgs(&.{ "--persist-state", appPath(b, app_root, ".native/cache/persist-schema.json") });
|
||||
@@ -625,6 +628,7 @@ pub const mobile_export_symbol_names = [_][]const u8{
|
||||
"native_sdk_app_audio_event",
|
||||
"native_sdk_app_set_image_service",
|
||||
"native_sdk_app_set_automation_dir",
|
||||
"native_sdk_app_set_data_root",
|
||||
"native_sdk_app_touch",
|
||||
"native_sdk_app_scroll",
|
||||
"native_sdk_app_key",
|
||||
@@ -672,6 +676,10 @@ pub const MobileLibOptions = struct {
|
||||
/// `src/embed/ui_host.zig`. Ignored for `.scene = .webview`.
|
||||
main: []const u8 = "src/main.zig",
|
||||
scene: MobileSceneOption = .canvas,
|
||||
/// Link and install the engine-owned Tier-2 record store. Standard
|
||||
/// `addApp` builds infer this from app.zon; direct `addMobileLib`
|
||||
/// callers state it here because that lower-level API has no manifest.
|
||||
store_capability: bool = false,
|
||||
};
|
||||
|
||||
/// Mobile counterpart of `addApp`: produce the embed static library
|
||||
@@ -709,10 +717,21 @@ fn addMobileLibWithTarget(b: *std.Build, dep: *std.Build.Dependency, target: std
|
||||
});
|
||||
exports_mod.addImport("native_sdk", native_sdk_mod);
|
||||
if (options.scene == .canvas) {
|
||||
const mobile_options = b.addOptions();
|
||||
mobile_options.addOption(bool, "store_capability", options.store_capability);
|
||||
exports_mod.addImport("mobile_build_options", mobile_options.createModule());
|
||||
const app_mod = localModule(b, target, optimize, options.main);
|
||||
app_mod.addImport("native_sdk", native_sdk_mod);
|
||||
exports_mod.addImport("app", app_mod);
|
||||
}
|
||||
if (options.store_capability) {
|
||||
exports_mod.addIncludePath(dep.path("third_party/sqlite"));
|
||||
exports_mod.addCSourceFile(.{
|
||||
.file = dep.path("third_party/sqlite/sqlite3.c"),
|
||||
.flags = sqliteCFlags(b, target),
|
||||
});
|
||||
exports_mod.link_libc = true;
|
||||
}
|
||||
exports_mod.export_symbol_names = &mobile_export_symbol_names;
|
||||
|
||||
const lib = b.addLibrary(.{
|
||||
@@ -790,7 +809,16 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
|
||||
" `mobileOptions` app — Zig and markup cores are fully supported on mobile.\n");
|
||||
}
|
||||
const ts_stage: ?TsCoreStage = if (core_tree == .ts)
|
||||
tsCoreStage(b, dep, target, app_options.app_root, app_options.name, app_config.persist_capability, app_config.persist_version)
|
||||
tsCoreStage(
|
||||
b,
|
||||
dep,
|
||||
target,
|
||||
app_options.app_root,
|
||||
app_options.name,
|
||||
app_config.persist_capability,
|
||||
app_config.store_capability,
|
||||
app_config.persist_version,
|
||||
)
|
||||
else
|
||||
null;
|
||||
|
||||
@@ -803,6 +831,7 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
|
||||
addMobileLibWithTarget(b, dep, target, optimize, .{
|
||||
.name = app_options.name,
|
||||
.main = appPath(b, app_options.app_root, app_options.main),
|
||||
.store_capability = app_config.store_capability,
|
||||
});
|
||||
}
|
||||
const platform_option = b.option(PlatformOption, "platform", "Desktop backend: auto, null, macos, linux, windows") orelse .auto;
|
||||
@@ -1114,6 +1143,23 @@ fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Resolv
|
||||
app_mod.link_libc = true;
|
||||
app_mod.addObjectFile(stage.archive);
|
||||
}
|
||||
if (app_config.sqlite_capability) {
|
||||
// `store` and the relational `sqlite` tier share this exact object.
|
||||
// The source is absent from every artifact declaring neither
|
||||
// capability, which keeps capability inference a real binary-size
|
||||
// boundary rather than a runtime flag.
|
||||
app_mod.addIncludePath(dep.path("third_party/sqlite"));
|
||||
app_mod.addCSourceFile(.{
|
||||
.file = dep.path("third_party/sqlite/sqlite3.c"),
|
||||
.flags = &.{
|
||||
"-DSQLITE_THREADSAFE=1",
|
||||
"-DSQLITE_OMIT_LOAD_EXTENSION",
|
||||
"-DSQLITE_DQS=0",
|
||||
"-DSQLITE_DEFAULT_MEMSTATUS=0",
|
||||
},
|
||||
});
|
||||
app_mod.link_libc = true;
|
||||
}
|
||||
addMacosPrivacyInfoPlist(b, app_mod, target, app_config);
|
||||
return app_mod;
|
||||
}
|
||||
@@ -1160,6 +1206,163 @@ fn nativeSdkTarget(b: *std.Build) std.Build.ResolvedTarget {
|
||||
return b.resolveTargetQuery(query);
|
||||
}
|
||||
|
||||
const sqlite_c_defines = [_][]const u8{
|
||||
"-DSQLITE_THREADSAFE=1",
|
||||
"-DSQLITE_OMIT_LOAD_EXTENSION",
|
||||
"-DSQLITE_DQS=0",
|
||||
"-DSQLITE_DEFAULT_MEMSTATUS=0",
|
||||
};
|
||||
|
||||
/// Zig deliberately supplies no libc headers for Apple/Android cross targets.
|
||||
/// Store-capable mobile libraries therefore compile the vendored amalgamation
|
||||
/// against the same platform SDK the host tier will use to link the archive.
|
||||
/// Desktop targets keep Zig's ordinary libc discovery.
|
||||
fn sqliteCFlags(b: *std.Build, target: std.Build.ResolvedTarget) []const []const u8 {
|
||||
if (target.result.os.tag == .ios) {
|
||||
const sysroot = b.sysroot orelse iosSdkPath(b, target.result.abi == .simulator) orelse
|
||||
std.debug.panic("a store-capable iOS library needs the Apple SDK; install Xcode or pass --sysroot <iphone SDK path>", .{});
|
||||
return b.dupeStrings(&.{
|
||||
sqlite_c_defines[0],
|
||||
sqlite_c_defines[1],
|
||||
sqlite_c_defines[2],
|
||||
sqlite_c_defines[3],
|
||||
"-isysroot",
|
||||
sysroot,
|
||||
b.fmt("-isystem{s}/usr/include", .{sysroot}),
|
||||
});
|
||||
}
|
||||
if (target.result.abi.isAndroid()) {
|
||||
const sysroot = b.sysroot orelse androidNdkSysrootPath(b) orelse
|
||||
std.debug.panic("a store-capable Android library needs the NDK; set ANDROID_NDK_ROOT or ANDROID_HOME, or pass --sysroot <NDK sysroot>", .{});
|
||||
const triple = target.result.linuxTriple(b.allocator) catch @panic("out of memory");
|
||||
return b.dupeStrings(&.{
|
||||
sqlite_c_defines[0],
|
||||
sqlite_c_defines[1],
|
||||
sqlite_c_defines[2],
|
||||
sqlite_c_defines[3],
|
||||
b.fmt("-isystem{s}/usr/include/{s}", .{ sysroot, triple }),
|
||||
b.fmt("-isystem{s}/usr/include", .{sysroot}),
|
||||
});
|
||||
}
|
||||
return &sqlite_c_defines;
|
||||
}
|
||||
|
||||
fn iosSdkPath(b: *std.Build, simulator: bool) ?[]const u8 {
|
||||
const result = std.process.run(b.allocator, b.graph.io, .{
|
||||
.argv = &.{ "xcrun", "--sdk", if (simulator) "iphonesimulator" else "iphoneos", "--show-sdk-path" },
|
||||
.stdout_limit = .limited(4096),
|
||||
.stderr_limit = .limited(4096),
|
||||
}) catch return null;
|
||||
defer b.allocator.free(result.stderr);
|
||||
if (result.term != .exited or result.term.exited != 0) {
|
||||
b.allocator.free(result.stdout);
|
||||
return null;
|
||||
}
|
||||
return std.mem.trimEnd(u8, result.stdout, "\r\n");
|
||||
}
|
||||
|
||||
fn androidNdkSysrootPath(b: *std.Build) ?[]const u8 {
|
||||
for ([_][]const u8{ "ANDROID_NDK_ROOT", "ANDROID_NDK_HOME", "ANDROID_NDK_LATEST_HOME" }) |name| {
|
||||
if (b.graph.environ_map.get(name)) |root| {
|
||||
if (root.len > 0) {
|
||||
if (ndkSysrootUnder(b, root)) |sysroot| return sysroot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sdk_root = androidSdkRoot(b) orelse return null;
|
||||
const ndk_root = latestVersionSubdir(b, sdk_root, "ndk") orelse blk: {
|
||||
const legacy = b.pathJoin(&.{ sdk_root, "ndk-bundle" });
|
||||
break :blk if (buildDirExists(b, legacy)) legacy else return null;
|
||||
};
|
||||
return ndkSysrootUnder(b, ndk_root);
|
||||
}
|
||||
|
||||
fn androidSdkRoot(b: *std.Build) ?[]const u8 {
|
||||
for ([_][]const u8{ "ANDROID_HOME", "ANDROID_SDK_ROOT" }) |name| {
|
||||
if (b.graph.environ_map.get(name)) |root| {
|
||||
if (root.len > 0 and buildDirExists(b, root)) return root;
|
||||
}
|
||||
}
|
||||
return switch (builtin.os.tag) {
|
||||
.macos => if (b.graph.environ_map.get("HOME")) |home|
|
||||
b.pathJoin(&.{ home, "Library", "Android", "sdk" })
|
||||
else
|
||||
null,
|
||||
.windows => if (b.graph.environ_map.get("LOCALAPPDATA")) |local_app_data|
|
||||
b.pathJoin(&.{ local_app_data, "Android", "Sdk" })
|
||||
else
|
||||
null,
|
||||
else => if (b.graph.environ_map.get("HOME")) |home|
|
||||
b.pathJoin(&.{ home, "Android", "Sdk" })
|
||||
else
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
fn ndkSysrootUnder(b: *std.Build, ndk_root: []const u8) ?[]const u8 {
|
||||
const prebuilt_path = b.pathJoin(&.{ ndk_root, "toolchains", "llvm", "prebuilt" });
|
||||
var cwd = std.Io.Dir.cwd();
|
||||
var dir = cwd.openDir(b.graph.io, prebuilt_path, .{ .iterate = true }) catch return null;
|
||||
defer dir.close(b.graph.io);
|
||||
var iterator = dir.iterate();
|
||||
while (iterator.next(b.graph.io) catch return null) |entry| {
|
||||
if (entry.kind != .directory) continue;
|
||||
const sysroot = b.pathJoin(&.{ prebuilt_path, entry.name, "sysroot" });
|
||||
if (buildDirExists(b, b.pathJoin(&.{ sysroot, "usr", "include" }))) return sysroot;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn latestVersionSubdir(b: *std.Build, root: []const u8, parent: []const u8) ?[]const u8 {
|
||||
const parent_path = b.pathJoin(&.{ root, parent });
|
||||
var cwd = std.Io.Dir.cwd();
|
||||
var dir = cwd.openDir(b.graph.io, parent_path, .{ .iterate = true }) catch return null;
|
||||
defer dir.close(b.graph.io);
|
||||
var best: ?[]const u8 = null;
|
||||
defer if (best) |name| b.allocator.free(name);
|
||||
var iterator = dir.iterate();
|
||||
while (iterator.next(b.graph.io) catch return null) |entry| {
|
||||
if (entry.kind != .directory) continue;
|
||||
if (best) |current| {
|
||||
if (!versionLess(current, entry.name)) continue;
|
||||
b.allocator.free(current);
|
||||
best = null;
|
||||
}
|
||||
best = b.allocator.dupe(u8, entry.name) catch @panic("out of memory");
|
||||
}
|
||||
const name = best orelse return null;
|
||||
return b.pathJoin(&.{ parent_path, name });
|
||||
}
|
||||
|
||||
fn versionLess(a: []const u8, b: []const u8) bool {
|
||||
var a_parts = std.mem.splitScalar(u8, a, '.');
|
||||
var b_parts = std.mem.splitScalar(u8, b, '.');
|
||||
while (true) {
|
||||
const a_part = a_parts.next();
|
||||
const b_part = b_parts.next();
|
||||
if (a_part == null and b_part == null) return false;
|
||||
if (a_part == null) return true;
|
||||
if (b_part == null) return false;
|
||||
const a_num = std.fmt.parseUnsigned(u64, a_part.?, 10) catch null;
|
||||
const b_num = std.fmt.parseUnsigned(u64, b_part.?, 10) catch null;
|
||||
if (a_num != null and b_num != null) {
|
||||
if (a_num.? != b_num.?) return a_num.? < b_num.?;
|
||||
} else switch (std.mem.order(u8, a_part.?, b_part.?)) {
|
||||
.lt => return true,
|
||||
.gt => return false,
|
||||
.eq => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn buildDirExists(b: *std.Build, path: []const u8) bool {
|
||||
var cwd = std.Io.Dir.cwd();
|
||||
var dir = cwd.openDir(b.graph.io, path, .{}) catch return false;
|
||||
dir.close(b.graph.io);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn macosSdkPath(b: *std.Build) ?[]const u8 {
|
||||
if (b.graph.environ_map.get("SDKROOT")) |sdkroot| {
|
||||
if (sdkroot.len > 0) return sdkroot;
|
||||
@@ -1207,6 +1410,10 @@ fn nativeSdkModuleWithTerminal(b: *std.Build, dep: *std.Build.Dependency, target
|
||||
debug_mod.addImport("trace", trace_mod);
|
||||
|
||||
const native_sdk_mod = externalModule(b, dep, target, optimize, "src/root.zig");
|
||||
// The header makes the internal wrapper parsable even when lazy exports
|
||||
// are reflected; the amalgamation itself is attached below only for an
|
||||
// opted-in store/sqlite artifact.
|
||||
native_sdk_mod.addIncludePath(dep.path("third_party/sqlite"));
|
||||
native_sdk_mod.addImport("geometry", geometry_mod);
|
||||
native_sdk_mod.addImport("assets", assets_mod);
|
||||
native_sdk_mod.addImport("app_dirs", app_dirs_mod);
|
||||
@@ -1541,6 +1748,8 @@ const AppManifestBuildConfig = struct {
|
||||
system_audio_permission: bool = false,
|
||||
persist_capability: bool = false,
|
||||
persist_version: ?u64 = null,
|
||||
store_capability: bool = false,
|
||||
sqlite_capability: bool = false,
|
||||
/// The first web declaration found (for teaching messages), or null
|
||||
/// when app.zon declares no web use. `web_engine = "system"` alone is
|
||||
/// NOT web intent — it is the default in many canvas manifests.
|
||||
@@ -1615,6 +1824,8 @@ fn appManifestBuildConfig(b: *std.Build, app_root: []const u8) AppManifestBuildC
|
||||
.system_audio_permission = hasManifestPermission(raw.permissions, "system_audio"),
|
||||
.persist_capability = hasManifestCapability(raw.capabilities, "persist"),
|
||||
.persist_version = if (raw.persist) |persist| persist.version else null,
|
||||
.store_capability = hasManifestCapability(raw.capabilities, "store"),
|
||||
.sqlite_capability = hasManifestCapability(raw.capabilities, "store") or hasManifestCapability(raw.capabilities, "sqlite"),
|
||||
.web_declaration = web_layer_contract.manifestDeclaration(raw),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ A fuller manifest for an app that also [embeds web content](/docs/frontend) and
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>capabilities</code></td>
|
||||
<td>Feature declarations (see <a href="/docs/security">Security</a>)</td>
|
||||
<td>Feature declarations (see <a href="/docs/security">Security</a>). <code>"store"</code> links the engine-owned record store; <code>"store"</code> and <code>"sqlite"</code> share one capability-shed SQLite engine; see <a href="/docs/record-store">Record Store</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>persist</code></td>
|
||||
|
||||
@@ -38,6 +38,13 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
|
||||
<td>None. Gated by the <code>persist</code> build capability.</td>
|
||||
<td>Generated TypeScript app runners on every app-data platform; Zig-core hosts receive the same named <code>core.persist</code> effect seam</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Record store</td>
|
||||
<td><code>Cmd.store.set/get/delete/scan/setMany</code> / <code>fx.storeSet/storeGet/storeDelete/storeScan/storeSetMany</code></td>
|
||||
<td>None. Model-core effect only.</td>
|
||||
<td>None. Gated by the <code>store</code> build capability.</td>
|
||||
<td>SQLite-backed engine store in the per-app data directory; replay remains offline and the core devhost uses a process-local map</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Native dialogs</td>
|
||||
<td><code>runtime.showOpenDialog(...)</code> / <code>showSaveDialog(...)</code> / <code>showMessageDialog(...)</code></td>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
export const metadata = pageMetadata("record-store");
|
||||
|
||||
export default function RecordStoreLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# Record Store
|
||||
|
||||
The record store persists independent byte records without giving `update` a database handle or making the app own a file format. It is the right fit for caches, message history, and document-sized values that grow or change one record at a time. Declare the build capability in `app.zon`:
|
||||
|
||||
```zig:app.zon
|
||||
.capabilities = .{ "store" },
|
||||
```
|
||||
|
||||
`"store"` links the shared SQLite engine and opens one engine-owned `store.db` in the app-data directory. Apps name keys, never paths or SQL. The common desktop runner and the iOS and Android hosts install that data directory before the first app effect. Apps without either storage capability do not link SQLite, and `native check` warns when the `"store"` declaration and `Cmd.store.*` calls disagree. The relational tier's `"sqlite"` capability selects the same engine object, so an app declaring both still links SQLite once.
|
||||
|
||||
## A saved draft
|
||||
|
||||
Every operation is a command. The committed model changes first; the result returns later as an ordinary `Msg`.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { Cmd } from "@native-sdk/core";
|
||||
|
||||
export interface Model {
|
||||
readonly draft: Uint8Array;
|
||||
readonly loaded: boolean;
|
||||
}
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "load" }
|
||||
| { readonly kind: "loaded"; readonly result: Uint8Array }
|
||||
| { readonly kind: "edited"; readonly draft: Uint8Array }
|
||||
| { readonly kind: "saved" }
|
||||
| { readonly kind: "store_failed"; readonly reason: Uint8Array };
|
||||
|
||||
export const viewUnbound = ["loaded", "saved", "store_failed"] as const;
|
||||
|
||||
export function initialModel(): Model {
|
||||
return { draft: new Uint8Array(0), loaded: false };
|
||||
}
|
||||
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "load":
|
||||
return [model, Cmd.store.get("draft/current", {
|
||||
key: "load-draft", ok: "loaded", err: "store_failed",
|
||||
})];
|
||||
case "loaded":
|
||||
// A get result starts with 1 for a hit and 0 for a miss. The value
|
||||
// follows the hit byte, so an empty value is distinct from absence.
|
||||
return msg.result[0] === 1
|
||||
? { draft: msg.result.subarray(1), loaded: true }
|
||||
: { ...model, loaded: true };
|
||||
case "edited":
|
||||
return [{ ...model, draft: msg.draft }, Cmd.store.set(
|
||||
"draft/current",
|
||||
msg.draft,
|
||||
{ key: "save-draft", ok: "saved", err: "store_failed" },
|
||||
)];
|
||||
case "saved":
|
||||
case "store_failed":
|
||||
return model;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reissuing the same route `key` replaces the older in-flight operation, and `Cmd.cancel(key)` cancels it silently. Distinct commands issued in one commit are performed in command-stream order: a synchronous read waits for writes that precede it. A get in a later commit also observes an earlier successful set.
|
||||
|
||||
## Zig-core parity
|
||||
|
||||
Zig cores use the same runtime-owned database and result envelope. The common app runner installs the binding before the app's first effect; no Zig entry point resolves a path or opens SQLite.
|
||||
|
||||
```zig
|
||||
const Msg = union(enum) {
|
||||
store_result: native_sdk.EffectHostResult,
|
||||
};
|
||||
const Effects = native_sdk.Effects(Msg);
|
||||
|
||||
fn loadDraft(fx: *Effects) void {
|
||||
fx.storeGet(.{
|
||||
.key = 1,
|
||||
.record_key = "draft/current",
|
||||
.on_result = Effects.hostMsg(.store_result),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`Effects.storeSet`, `storeGet`, `storeDelete`, `storeScan`, and `storeSetMany` mirror the TypeScript operations. Their `EffectHostResult` carries `key`, `ok`, and `bytes`; get and scan use exactly the framing described below.
|
||||
|
||||
## Operations and bounds
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Command</th>
|
||||
<th>Behavior</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>Cmd.store.set(key, bytes, route)</code></td>
|
||||
<td>Insert or replace one value. Keys are non-empty UTF-8 up to 512 bytes; values are at most 1 MiB.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.store.get(key, route)</code></td>
|
||||
<td>Return <code>[1][value...]</code> for a hit or <code>[0]</code> for a miss through the ok arm.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.store.delete(key, route)</code></td>
|
||||
<td>Delete one value. A missing key succeeds.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.store.scan(prefix, options, route)</code></td>
|
||||
<td>Return a byte-lexicographic prefix page. <code>limit</code> defaults to 100 and is capped at 256; pass the returned next-key bytes as <code>after</code> (a known literal key may be passed as a string).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.store.setMany(entries, route)</code></td>
|
||||
<td>Insert or replace 1–64 records atomically, with an 8 MiB encoded batch bound.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
A scan page is little-endian framed bytes: `count u32`, then `count` repetitions of `key_length u32`, key bytes, `value_length u32`, value bytes, followed by `next_length u32` and the next-key bytes. An empty next key ends pagination. Pages stop at record boundaries; records are never truncated.
|
||||
|
||||
All error arms receive one closed reason as UTF-8 bytes: `io_failed`, `over_bound`, `bad_key`, `rejected`, or `busy`. Cache misses use the get ok arm because absence is an expected lookup result. `setMany` validates the entire batch before its transaction, so an invalid entry changes nothing.
|
||||
|
||||
## Replay and the virtual host
|
||||
|
||||
Store results use the ordinary effect journal. Session replay feeds the recorded result and never opens the live database. Zig full-loop tests opt into one hermetic SQLite database per harness with `TestHarness().createWithRecordStore(allocator, surface)`; it is bound before `harness.start(app)` and closed by `harness.destroy(allocator)`. `native dev --core` performs the same API against a process-local map that survives its simulated `{"restart": true}` command.
|
||||
|
||||
Use [Model Persistence](/docs/persistence) when the whole in-memory model is the unit you save. Use the record store when records grow independently. Use raw file effects only for user-visible files, exports, or blobs larger than the record bound; relational queries and secondary indexes belong in the SQL tier rather than this API.
|
||||
|
||||
The repository's [record-store example](https://github.com/vercel-labs/native/tree/main/examples/record-store) exercises all five commands from a TypeScript core and Native markup view.
|
||||
@@ -343,6 +343,10 @@ The runtime interprets the command after the model commits and dispatches any re
|
||||
<td><code>Cmd.persist()</code></td>
|
||||
<td>Snapshot the just-committed Model through the engine-owned, capability-gated atomic store; restore arrives through the manifest's configured boot Msg route — see <a href="/docs/persistence">Model Persistence</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.store.set/get/delete/scan/setMany</code></td>
|
||||
<td>Persist independent byte records in the engine-owned, capability-gated record store; every result returns through the declared Msg route — see <a href="/docs/record-store">Record Store</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.host(name, ...args)</code> / <code>Cmd.request(name, payload, { key?, ok, err })</code></td>
|
||||
<td>App-defined host commands by literal name: fire-and-forget, or routed with exactly one result Msg back</td>
|
||||
@@ -360,6 +364,8 @@ Platform state stays on the same effect boundary. `Cmd.openExternalUrl(url)` enf
|
||||
|
||||
Durable in-memory state uses `Cmd.persist()`: declare the `persist` capability, configure the boot routes and schema version, then return the command beside the committed model. The engine owns canonical serialization, trailing-edge coalescing, atomic app-data placement, backup recovery, migration, and journal/replay. See [Model Persistence](/docs/persistence) for the complete setup. `Cmd.readFile` and `Cmd.writeFile` remain for user-visible files, exports, and blobs; when using them, request the framework-provided app-data directory through `envMsgs` instead of depending on process cwd.
|
||||
|
||||
Independent byte records use `Cmd.store`: declare the `store` capability, then route set/get/delete/scan/setMany results back to Msg arms. The engine owns the app-data path, SQLite schema, atomic batches, pagination, and replay boundary. See [Record Store](/docs/record-store).
|
||||
|
||||
External sources — sockets, file watchers, native worker threads — reach `update` through a channel. `Cmd.channelOpen(key, { event })` opens a long-lived stream under an app-chosen numeric key, and every event dispatches the one `event` arm as a five-field record; `state` must be a named string-literal-union alias carrying exactly the three members — a narrower union would silently drop states the host emits, so the build refuses it. Posting is not a TS verb: compiled cores are single-threaded by design, so the posting handle lives on the native side (`Effects.channelHandle(key)`), where embedders and platform-services extensions post bytes from their own threads. Back-pressure is honest — posts the native handle refused count into `droppedPending`/`droppedTotal` on the next delivered event, never silence — and a duplicate open on a live key dispatches `rejected`. `Cmd.channelClose(key)` ends the stream: staged posts flush, exactly one `closed` event carries the final totals, and the key frees.
|
||||
|
||||
```ts:src/core.ts
|
||||
|
||||
@@ -31,6 +31,7 @@ const unprefixedNavSections: NavSection[] = [
|
||||
{ name: "Native UI", href: "/native-ui" },
|
||||
{ name: "Dynamic Images", href: "/dynamic-images" },
|
||||
{ name: "Model Persistence", href: "/persistence" },
|
||||
{ name: "Record Store", href: "/record-store" },
|
||||
{ name: "Terminal", href: "/terminal" },
|
||||
{ name: "State & Data Flow", href: "/state" },
|
||||
{ name: "Theming", href: "/theming" },
|
||||
|
||||
@@ -13,6 +13,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"native-ui": "Native UI",
|
||||
"dynamic-images": "Dynamic Images",
|
||||
persistence: "Model Persistence",
|
||||
"record-store": "Record Store",
|
||||
terminal: "Terminal",
|
||||
state: "State & Data Flow",
|
||||
theming: "Theming",
|
||||
|
||||
@@ -30,6 +30,11 @@ pub const Op = union(enum) {
|
||||
window_show: struct { label: []const u8 },
|
||||
window_hide: struct { label: []const u8 },
|
||||
dock_presence: struct { visible: bool },
|
||||
store_set: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8, bytes: []const u8 },
|
||||
store_get: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8 },
|
||||
store_delete: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8 },
|
||||
store_scan: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, prefix: []const u8, limit: u32, after: []const u8 },
|
||||
store_set_many: StoreSetMany,
|
||||
quit_app,
|
||||
image_load: struct { id: f64, event_tag: u8, path: []const u8, url: []const u8, cache_path: []const u8, expected_bytes: f64 },
|
||||
image_cancel: struct { id: f64 },
|
||||
@@ -137,6 +142,26 @@ pub const Op = union(enum) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const StoreSetMany = struct {
|
||||
key: []const u8,
|
||||
ok_tag: u8,
|
||||
err_tag: u8,
|
||||
scope: u32,
|
||||
count: u32,
|
||||
/// Raw entries: `[key_len u32][key][value_len u32][value]`.
|
||||
entry_bytes: []const u8,
|
||||
|
||||
pub fn entry(self: StoreSetMany, index: usize) struct { key: []const u8, bytes: []const u8 } {
|
||||
var off: usize = 0;
|
||||
var i: usize = 0;
|
||||
while (true) : (i += 1) {
|
||||
const key = longBytes(self.entry_bytes, &off);
|
||||
const bytes = longBytes(self.entry_bytes, &off);
|
||||
if (i == index) return .{ .key = key, .bytes = bytes };
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
pub const CmdIter = struct {
|
||||
@@ -449,6 +474,44 @@ pub const CmdIter = struct {
|
||||
off += 1;
|
||||
break :blk .{ .dock_presence = .{ .visible = visible } };
|
||||
},
|
||||
0x23 => blk: {
|
||||
const head = routedHead(b, &off);
|
||||
const scope = readU32(b, &off);
|
||||
const store_key = longBytes(b, &off);
|
||||
const bytes = longBytes(b, &off);
|
||||
break :blk .{ .store_set = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key, .bytes = bytes } };
|
||||
},
|
||||
0x24 => blk: {
|
||||
const head = routedHead(b, &off);
|
||||
const scope = readU32(b, &off);
|
||||
const store_key = longBytes(b, &off);
|
||||
break :blk .{ .store_get = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key } };
|
||||
},
|
||||
0x25 => blk: {
|
||||
const head = routedHead(b, &off);
|
||||
const scope = readU32(b, &off);
|
||||
const store_key = longBytes(b, &off);
|
||||
break :blk .{ .store_delete = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key } };
|
||||
},
|
||||
0x26 => blk: {
|
||||
const head = routedHead(b, &off);
|
||||
const scope = readU32(b, &off);
|
||||
const prefix = longBytes(b, &off);
|
||||
const limit = readU32(b, &off);
|
||||
const after = longBytes(b, &off);
|
||||
break :blk .{ .store_scan = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .prefix = prefix, .limit = limit, .after = after } };
|
||||
},
|
||||
0x27 => blk: {
|
||||
const head = routedHead(b, &off);
|
||||
const scope = readU32(b, &off);
|
||||
const count = readU32(b, &off);
|
||||
const entries_start = off;
|
||||
for (0..count) |_| {
|
||||
_ = longBytes(b, &off);
|
||||
_ = longBytes(b, &off);
|
||||
}
|
||||
break :blk .{ .store_set_many = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .count = count, .entry_bytes = b[entries_start..off] } };
|
||||
},
|
||||
else => std.debug.panic("cmdview: unknown op byte 0x{X:0>2} at offset {d}", .{ op, self.off }),
|
||||
};
|
||||
self.off = off;
|
||||
@@ -510,6 +573,12 @@ fn longBytes(b: []const u8, off: *usize) []const u8 {
|
||||
return out;
|
||||
}
|
||||
|
||||
fn readU32(b: []const u8, off: *usize) u32 {
|
||||
const value = std.mem.readInt(u32, b[off.*..][0..4], .little);
|
||||
off.* += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
/// First decoded op of the given kind in a cmd buffer, or null.
|
||||
@@ -584,6 +653,30 @@ test "window_hide and dock_presence decode, alone and inside a batch" {
|
||||
try std.testing.expectEqual(@as(?Op, null), iter.next());
|
||||
}
|
||||
|
||||
test "record store command records decode and advance exactly" {
|
||||
const batch = [_]u8{
|
||||
0x23, 1, 'r', 2, 3, 0, 0, 0, 0, 1, 0, 0, 0, 'k', 1, 0, 0, 0, 'v',
|
||||
0x26, 0, 4, 5, 0, 0, 0, 0, 2, 0, 0, 0, 'p', '/', 7, 0, 0, 0, 1,
|
||||
0, 0, 0, 'a', 0x27, 1, 'b', 6, 7, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0,
|
||||
0, 0, 'x', 2, 0, 0, 0, 8, 9, 0x02, 10,
|
||||
};
|
||||
var iter = CmdIter.init(&batch);
|
||||
const set = (iter.next() orelse return error.TestUnexpectedResult).store_set;
|
||||
try std.testing.expectEqualStrings("r", set.key);
|
||||
try std.testing.expectEqualStrings("k", set.store_key);
|
||||
try std.testing.expectEqualSlices(u8, "v", set.bytes);
|
||||
const scan = (iter.next() orelse return error.TestUnexpectedResult).store_scan;
|
||||
try std.testing.expectEqualStrings("p/", scan.prefix);
|
||||
try std.testing.expectEqual(@as(u32, 7), scan.limit);
|
||||
try std.testing.expectEqualStrings("a", scan.after);
|
||||
const many = (iter.next() orelse return error.TestUnexpectedResult).store_set_many;
|
||||
try std.testing.expectEqual(@as(u32, 1), many.count);
|
||||
try std.testing.expectEqualStrings("x", many.entry(0).key);
|
||||
try std.testing.expectEqualSlices(u8, &.{ 8, 9 }, many.entry(0).bytes);
|
||||
try std.testing.expectEqual(@as(u8, 10), (iter.next() orelse return error.TestUnexpectedResult).now.msg_tag);
|
||||
try std.testing.expectEqual(@as(?Op, null), iter.next());
|
||||
}
|
||||
|
||||
test "the image records decode, alone and inside a batch" {
|
||||
// image_load: [op 0x12][id f64 LE][event_tag][path][url][cache]
|
||||
// [expected f64 LE] — the bytes rt.zig's cmdImageLoad pins (the same
|
||||
|
||||
@@ -266,6 +266,7 @@ int native_sdk_app_text_input_state(void *app, native_sdk_text_input_state_t *ou
|
||||
typedef double (*native_sdk_text_measure_fn)(void *context, uint64_t font_id, double size, const char *text, uintptr_t text_len);
|
||||
int native_sdk_app_set_text_measure(void *app, native_sdk_text_measure_fn measure, void *context);
|
||||
int native_sdk_app_set_automation_dir(void *app, const char *path, uintptr_t len);
|
||||
int native_sdk_app_set_data_root(void *app, const char *path, uintptr_t len);
|
||||
int native_sdk_app_render_pixel_size(void *app, float scale, native_sdk_canvas_pixels_t *out);
|
||||
int native_sdk_app_render_pixels(void *app, float scale, uint8_t *pixels, uintptr_t pixels_len, native_sdk_canvas_pixels_t *out);
|
||||
// Incremental sibling of native_sdk_app_render_pixels for a host that
|
||||
|
||||
@@ -309,6 +309,7 @@ int native_sdk_app_text_input_state(void *app, native_sdk_text_input_state_t *ou
|
||||
typedef double (*native_sdk_text_measure_fn)(void *context, uint64_t font_id, double size, const char *text, uintptr_t text_len);
|
||||
int native_sdk_app_set_text_measure(void *app, native_sdk_text_measure_fn measure, void *context);
|
||||
int native_sdk_app_set_automation_dir(void *app, const char *path, uintptr_t len);
|
||||
int native_sdk_app_set_data_root(void *app, const char *path, uintptr_t len);
|
||||
int native_sdk_app_render_pixel_size(void *app, float scale, native_sdk_canvas_pixels_t *out);
|
||||
int native_sdk_app_render_pixels(void *app, float scale, uint8_t *pixels, uintptr_t pixels_len, native_sdk_canvas_pixels_t *out);
|
||||
// Incremental sibling of native_sdk_app_render_pixels for a host that
|
||||
|
||||
@@ -3,5 +3,11 @@ const std = @import("std");
|
||||
const native_sdk = @import("native_sdk");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
native_sdk.addMobileLib(b, b.dependency("native_sdk", .{}), .{ .name = "mobile-canvas" });
|
||||
native_sdk.addMobileLib(b, b.dependency("native_sdk", .{}), .{
|
||||
.name = "mobile-canvas",
|
||||
// The example does not use the store, but this option gives the SDK
|
||||
// gate a real mobile artifact that exercises capability-selected
|
||||
// SQLite linkage and the data-root host lifecycle.
|
||||
.store_capability = b.option(bool, "store", "Link the Tier-2 record store") orelse false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ int native_sdk_app_text_input_state(void *app, native_sdk_text_input_state_t *ou
|
||||
typedef double (*native_sdk_text_measure_fn)(void *context, uint64_t font_id, double size, const char *text, uintptr_t text_len);
|
||||
int native_sdk_app_set_text_measure(void *app, native_sdk_text_measure_fn measure, void *context);
|
||||
int native_sdk_app_set_automation_dir(void *app, const char *path, uintptr_t len);
|
||||
int native_sdk_app_set_data_root(void *app, const char *path, uintptr_t len);
|
||||
uintptr_t native_sdk_app_widget_semantics_count(void *app);
|
||||
int native_sdk_app_widget_semantics_at(void *app, uintptr_t index, native_sdk_widget_semantics_t *out);
|
||||
int native_sdk_app_widget_semantics_by_id(void *app, uint64_t id, native_sdk_widget_semantics_t *out);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Record Store
|
||||
|
||||
A default TypeScript + Native markup app demonstrating the Tier 2 record
|
||||
store. It loads a prefix page during `initialModel`, atomically seeds several
|
||||
records, reads and updates one record, and deletes it without handling a file
|
||||
path or a SQL statement.
|
||||
|
||||
```bash
|
||||
native test
|
||||
native run
|
||||
```
|
||||
|
||||
The app declares the `"store"` capability in `app.zon`; builds without that
|
||||
capability shed the SQLite engine.
|
||||
@@ -0,0 +1,34 @@
|
||||
.{
|
||||
.id = "dev.native_sdk.record_store",
|
||||
.name = "record-store",
|
||||
.display_name = "Record Store",
|
||||
.description = "A TypeScript and Native markup example of the engine-owned record store.",
|
||||
.version = "0.1.0",
|
||||
.platforms = .{"macos"},
|
||||
.permissions = .{ "view", "command" },
|
||||
.capabilities = .{ "native_views", "gpu_surfaces", "store" },
|
||||
.shell = .{
|
||||
.windows = .{
|
||||
.{
|
||||
.label = "main",
|
||||
.title = "Record Store",
|
||||
.width = 680,
|
||||
.height = 480,
|
||||
.min_width = 520,
|
||||
.min_height = 360,
|
||||
.restore_state = false,
|
||||
.restore_policy = "center_on_primary",
|
||||
.views = .{
|
||||
.{ .label = "record-store-canvas", .kind = "gpu_surface", .fill = true, .role = "Record store example", .accessibility_label = "Record store example", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
.security = .{
|
||||
.navigation = .{
|
||||
.allowed_origins = .{ "zero://app", "zero://inline" },
|
||||
.external_links = .{ .action = "deny" },
|
||||
},
|
||||
},
|
||||
.web_engine = "system",
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "record-store",
|
||||
"private": true,
|
||||
"description": "Editor surface for the TypeScript core; the native CLI builds without node_modules.",
|
||||
"dependencies": {
|
||||
"@native-sdk/core": "0.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!-- A compact tour of the five record-store commands. The core boots with a
|
||||
prefix scan, while these controls demonstrate an atomic batch, one-key
|
||||
get/set/delete, and another scan without exposing paths or SQL. -->
|
||||
<column background="background" padding="28" gap="20">
|
||||
<column gap="6">
|
||||
<text size="display">Record Store</text>
|
||||
<text foreground="text_muted" wrap="true">Independent byte records, routed back through Msg. Close and reopen the app to see that the values remain in the engine-owned app-data database.</text>
|
||||
</column>
|
||||
|
||||
<panel padding="18" background="surface" radius="lg">
|
||||
<column gap="12">
|
||||
<row cross="center">
|
||||
<text grow="1">Records under drafts/</text>
|
||||
<badge variant="secondary">{recordCount}</badge>
|
||||
</row>
|
||||
<separator />
|
||||
<if test="{present}">
|
||||
<column gap="6">
|
||||
<text size="sm" foreground="text_muted">drafts/current</text>
|
||||
<text wrap="true">{current}</text>
|
||||
</column>
|
||||
</if>
|
||||
<else>
|
||||
<text foreground="text_muted">Load drafts/current to inspect its value.</text>
|
||||
</else>
|
||||
</column>
|
||||
</panel>
|
||||
|
||||
<row gap="10">
|
||||
<button variant="primary" on-press="seed">Seed atomically</button>
|
||||
<button variant="outline" on-press="load">Load current</button>
|
||||
<button variant="outline" on-press="save">Save current</button>
|
||||
<button variant="destructive" on-press="remove">Delete current</button>
|
||||
<button variant="ghost" on-press="refresh">Refresh count</button>
|
||||
</row>
|
||||
|
||||
<spacer grow="1" />
|
||||
<status-bar>{status}</status-bar>
|
||||
</column>
|
||||
@@ -0,0 +1,144 @@
|
||||
// Record-store example: every database interaction is inert Cmd data and
|
||||
// every observation returns through an ordinary Msg after the model commits.
|
||||
|
||||
import { Cmd, utf8Bytes } from "@native-sdk/core";
|
||||
|
||||
const ACTIVE_KEY = "drafts/current";
|
||||
|
||||
export interface Model {
|
||||
readonly recordCount: number;
|
||||
readonly current: Uint8Array;
|
||||
readonly present: boolean;
|
||||
readonly status: Uint8Array;
|
||||
}
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "seed" }
|
||||
| { readonly kind: "seeded" }
|
||||
| { readonly kind: "load" }
|
||||
| { readonly kind: "loaded"; readonly result: Uint8Array }
|
||||
| { readonly kind: "save" }
|
||||
| { readonly kind: "saved" }
|
||||
| { readonly kind: "remove" }
|
||||
| { readonly kind: "removed" }
|
||||
| { readonly kind: "refresh" }
|
||||
| { readonly kind: "scanned"; readonly page: Uint8Array }
|
||||
| { readonly kind: "store_failed"; readonly reason: Uint8Array };
|
||||
|
||||
export const viewUnbound = ["seeded", "loaded", "saved", "removed", "scanned", "store_failed"] as const;
|
||||
|
||||
function pageCount(page: Uint8Array): number {
|
||||
if (page.length < 4) return 0;
|
||||
return page[0] + page[1] * 256 + page[2] * 65536 + page[3] * 16777216;
|
||||
}
|
||||
|
||||
export function initialModel(): [Model, Cmd<Msg>] {
|
||||
return [
|
||||
{
|
||||
recordCount: 0,
|
||||
current: new Uint8Array(0),
|
||||
present: false,
|
||||
status: utf8Bytes("Loading the drafts/ prefix…"),
|
||||
},
|
||||
Cmd.store.scan("drafts/", { limit: 16 }, {
|
||||
key: "scan-drafts",
|
||||
ok: "scanned",
|
||||
err: "store_failed",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "seed":
|
||||
return [
|
||||
{ ...model, status: utf8Bytes("Writing three records atomically…") },
|
||||
Cmd.store.setMany([
|
||||
[ACTIVE_KEY, utf8Bytes("A saved draft from the record store")],
|
||||
["drafts/archive/1", utf8Bytes("First archived draft")],
|
||||
["drafts/archive/2", utf8Bytes("Second archived draft")],
|
||||
], { key: "seed-drafts", ok: "seeded", err: "store_failed" }),
|
||||
];
|
||||
case "seeded":
|
||||
case "refresh":
|
||||
return [
|
||||
{ ...model, status: utf8Bytes("Scanning drafts/…") },
|
||||
Cmd.store.scan("drafts/", { limit: 16 }, {
|
||||
key: "scan-drafts",
|
||||
ok: "scanned",
|
||||
err: "store_failed",
|
||||
}),
|
||||
];
|
||||
case "scanned": {
|
||||
const decoded = pageCount(msg.page);
|
||||
const count = decoded >= 0 && decoded <= 256 ? Math.trunc(decoded) : 0;
|
||||
return { ...model, recordCount: count, status: utf8Bytes("Prefix page loaded") };
|
||||
}
|
||||
case "load":
|
||||
return [
|
||||
{ ...model, status: utf8Bytes("Loading drafts/current…") },
|
||||
Cmd.store.get(ACTIVE_KEY, {
|
||||
key: "load-current",
|
||||
ok: "loaded",
|
||||
err: "store_failed",
|
||||
}),
|
||||
];
|
||||
case "loaded":
|
||||
return msg.result[0] === 1
|
||||
? {
|
||||
...model,
|
||||
current: msg.result.subarray(1),
|
||||
present: true,
|
||||
status: utf8Bytes("Record loaded"),
|
||||
}
|
||||
: {
|
||||
...model,
|
||||
current: new Uint8Array(0),
|
||||
present: false,
|
||||
status: utf8Bytes("Record is absent"),
|
||||
};
|
||||
case "save":
|
||||
return [
|
||||
{ ...model, status: utf8Bytes("Saving drafts/current…") },
|
||||
Cmd.store.set(
|
||||
ACTIVE_KEY,
|
||||
utf8Bytes("Updated independently of every other record"),
|
||||
{ key: "save-current", ok: "saved", err: "store_failed" },
|
||||
),
|
||||
];
|
||||
case "saved":
|
||||
return [
|
||||
{ ...model, status: utf8Bytes("Record saved; loading it back…") },
|
||||
Cmd.store.get(ACTIVE_KEY, {
|
||||
key: "load-current",
|
||||
ok: "loaded",
|
||||
err: "store_failed",
|
||||
}),
|
||||
];
|
||||
case "remove":
|
||||
return [
|
||||
{ ...model, status: utf8Bytes("Deleting drafts/current…") },
|
||||
Cmd.store.delete(ACTIVE_KEY, {
|
||||
key: "delete-current",
|
||||
ok: "removed",
|
||||
err: "store_failed",
|
||||
}),
|
||||
];
|
||||
case "removed":
|
||||
return [
|
||||
{
|
||||
...model,
|
||||
current: new Uint8Array(0),
|
||||
present: false,
|
||||
status: utf8Bytes("Record deleted; refreshing the prefix…"),
|
||||
},
|
||||
Cmd.store.scan("drafts/", { limit: 16 }, {
|
||||
key: "scan-drafts",
|
||||
ok: "scanned",
|
||||
err: "store_failed",
|
||||
}),
|
||||
];
|
||||
case "store_failed":
|
||||
return { ...model, status: msg.reason };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["esnext"],
|
||||
"types": [],
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -126,6 +126,11 @@ export interface WriteRoute<M extends Msgish> {
|
||||
readonly err: M["kind"];
|
||||
}
|
||||
|
||||
export interface StoreScanOptions {
|
||||
readonly limit?: number;
|
||||
readonly after?: string | Uint8Array;
|
||||
}
|
||||
|
||||
export interface FetchRoute<M extends Msgish> {
|
||||
readonly key?: string;
|
||||
readonly ok: M["kind"];
|
||||
@@ -287,6 +292,37 @@ export type CmdData =
|
||||
readonly path: Uint8Array;
|
||||
readonly bytes: Uint8Array;
|
||||
}
|
||||
| {
|
||||
readonly op: "store_set";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly storeKey: string;
|
||||
readonly bytes: Uint8Array;
|
||||
}
|
||||
| {
|
||||
readonly op: "store_get" | "store_delete";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly storeKey: string;
|
||||
}
|
||||
| {
|
||||
readonly op: "store_scan";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly prefix: string;
|
||||
readonly limit: number;
|
||||
readonly after: string | Uint8Array;
|
||||
}
|
||||
| {
|
||||
readonly op: "store_set_many";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly entries: ReadonlyArray<readonly [string, Uint8Array]>;
|
||||
}
|
||||
| {
|
||||
readonly op: "fetch";
|
||||
readonly key: string;
|
||||
@@ -486,6 +522,24 @@ export const Cmd = {
|
||||
return { op: "write_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path, bytes };
|
||||
},
|
||||
|
||||
store: {
|
||||
set(storeKey: string, bytes: Uint8Array, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
|
||||
return { op: "store_set", key: route.key ?? "", okKind: route.ok, errKind: route.err, storeKey, bytes };
|
||||
},
|
||||
get(storeKey: string, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
|
||||
return { op: "store_get", key: route.key ?? "", okKind: route.ok, errKind: route.err, storeKey };
|
||||
},
|
||||
delete(storeKey: string, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
|
||||
return { op: "store_delete", key: route.key ?? "", okKind: route.ok, errKind: route.err, storeKey };
|
||||
},
|
||||
scan(prefix: string, options: StoreScanOptions, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
|
||||
return { op: "store_scan", key: route.key ?? "", okKind: route.ok, errKind: route.err, prefix, limit: options.limit ?? 0, after: options.after ?? "" };
|
||||
},
|
||||
setMany(entries: ReadonlyArray<readonly [string, Uint8Array]>, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
|
||||
return { op: "store_set_many", key: route.key ?? "", okKind: route.ok, errKind: route.err, entries };
|
||||
},
|
||||
},
|
||||
|
||||
fetch(
|
||||
spec: FetchStreamSpec,
|
||||
route: { readonly key?: string; readonly line?: string; readonly ok: string; readonly err: string },
|
||||
|
||||
Vendored
+38
@@ -117,6 +117,10 @@ export interface WriteRoute<M extends Msgish> {
|
||||
readonly ok: EmptyKind<M>;
|
||||
readonly err: BytesKind<M>;
|
||||
}
|
||||
export interface StoreScanOptions {
|
||||
readonly limit?: number;
|
||||
readonly after?: string | Uint8Array;
|
||||
}
|
||||
export interface FetchRoute<M extends Msgish> {
|
||||
readonly key?: string;
|
||||
readonly ok: FetchedKind<M>;
|
||||
@@ -228,6 +232,33 @@ export type Cmd<M extends Msgish> = {
|
||||
readonly errKind: string;
|
||||
readonly path: Uint8Array;
|
||||
readonly bytes: Uint8Array;
|
||||
} | {
|
||||
readonly op: "store_set";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly storeKey: string;
|
||||
readonly bytes: Uint8Array;
|
||||
} | {
|
||||
readonly op: "store_get" | "store_delete";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly storeKey: string;
|
||||
} | {
|
||||
readonly op: "store_scan";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly prefix: string;
|
||||
readonly limit: number;
|
||||
readonly after: string | Uint8Array;
|
||||
} | {
|
||||
readonly op: "store_set_many";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly entries: ReadonlyArray<readonly [string, Uint8Array]>;
|
||||
} | {
|
||||
readonly op: "fetch";
|
||||
readonly key: string;
|
||||
@@ -391,6 +422,13 @@ export declare const Cmd: {
|
||||
cancel(key: string): Cmd<never>;
|
||||
readFile<M extends Msgish>(path: Uint8Array, route: RequestRoute<M>): Cmd<M>;
|
||||
writeFile<M extends Msgish>(path: Uint8Array, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M>;
|
||||
store: {
|
||||
set<M extends Msgish>(storeKey: string, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M>;
|
||||
get<M extends Msgish>(storeKey: string, route: RequestRoute<M>): Cmd<M>;
|
||||
delete<M extends Msgish>(storeKey: string, route: WriteRoute<M>): Cmd<M>;
|
||||
scan<M extends Msgish>(prefix: string, options: StoreScanOptions, route: RequestRoute<M>): Cmd<M>;
|
||||
setMany<M extends Msgish>(entries: ReadonlyArray<readonly [string, Uint8Array]>, route: WriteRoute<M>): Cmd<M>;
|
||||
};
|
||||
fetch: typeof fetchCmd;
|
||||
clipboardWrite(bytes: Uint8Array): Cmd<never>;
|
||||
clipboardRead<M extends Msgish>(route: RequestRoute<M>): Cmd<M>;
|
||||
|
||||
@@ -809,6 +809,14 @@ export interface WriteRoute<M extends Msgish> {
|
||||
readonly err: BytesKind<M>;
|
||||
}
|
||||
|
||||
/// Pagination controls for `Cmd.store.scan`. `limit` defaults to 100 and is
|
||||
/// bounded at 256. `after` is the opaque key cursor returned by the previous
|
||||
/// page; omit it for the first page.
|
||||
export interface StoreScanOptions {
|
||||
readonly limit?: number;
|
||||
readonly after?: string | Uint8Array;
|
||||
}
|
||||
|
||||
/// `Cmd.fetch` routing: the ok arm carries `{ status, body }` (one number
|
||||
/// field, one bytes field — matched by type); the err arm the reason bytes.
|
||||
export interface FetchRoute<M extends Msgish> {
|
||||
@@ -989,6 +997,37 @@ export type Cmd<M extends Msgish> =
|
||||
readonly path: Uint8Array;
|
||||
readonly bytes: Uint8Array;
|
||||
}
|
||||
| {
|
||||
readonly op: "store_set";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly storeKey: string;
|
||||
readonly bytes: Uint8Array;
|
||||
}
|
||||
| {
|
||||
readonly op: "store_get" | "store_delete";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly storeKey: string;
|
||||
}
|
||||
| {
|
||||
readonly op: "store_scan";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly prefix: string;
|
||||
readonly limit: number;
|
||||
readonly after: string | Uint8Array;
|
||||
}
|
||||
| {
|
||||
readonly op: "store_set_many";
|
||||
readonly key: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly entries: ReadonlyArray<readonly [string, Uint8Array]>;
|
||||
}
|
||||
| {
|
||||
readonly op: "fetch";
|
||||
readonly key: string;
|
||||
@@ -1265,6 +1304,45 @@ export const Cmd = {
|
||||
return { op: "write_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path, bytes };
|
||||
},
|
||||
|
||||
/// Capability-gated, engine-owned per-record storage. Keys are UTF-8 text
|
||||
/// up to 512 bytes; values are bytes up to 1 MiB. Results remain effects:
|
||||
/// they arrive through the supplied Msg routes after update commits.
|
||||
store: {
|
||||
set<M extends Msgish>(storeKey: string, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M> {
|
||||
return { op: "store_set", key: route.key ?? "", okKind: route.ok, errKind: route.err, storeKey, bytes };
|
||||
},
|
||||
|
||||
/// The ok payload is `[1][value...]` for a hit or `[0]` for a miss, so
|
||||
/// an empty stored value remains distinguishable from absence.
|
||||
get<M extends Msgish>(storeKey: string, route: RequestRoute<M>): Cmd<M> {
|
||||
return { op: "store_get", key: route.key ?? "", okKind: route.ok, errKind: route.err, storeKey };
|
||||
},
|
||||
|
||||
/// Deleting an absent key succeeds.
|
||||
delete<M extends Msgish>(storeKey: string, route: WriteRoute<M>): Cmd<M> {
|
||||
return { op: "store_delete", key: route.key ?? "", okKind: route.ok, errKind: route.err, storeKey };
|
||||
},
|
||||
|
||||
/// The ok payload is a length-prefixed page of `(key,value)` pairs and
|
||||
/// a next-key cursor. Pages end at record boundaries; data is never cut.
|
||||
scan<M extends Msgish>(prefix: string, options: StoreScanOptions, route: RequestRoute<M>): Cmd<M> {
|
||||
return {
|
||||
op: "store_scan",
|
||||
key: route.key ?? "",
|
||||
okKind: route.ok,
|
||||
errKind: route.err,
|
||||
prefix,
|
||||
limit: options.limit ?? 0,
|
||||
after: options.after ?? "",
|
||||
};
|
||||
},
|
||||
|
||||
/// Atomically upsert all entries (at most 64 entries / 8 MiB encoded).
|
||||
setMany<M extends Msgish>(entries: ReadonlyArray<readonly [string, Uint8Array]>, route: WriteRoute<M>): Cmd<M> {
|
||||
return { op: "store_set_many", key: route.key ?? "", okKind: route.ok, errKind: route.err, entries };
|
||||
},
|
||||
},
|
||||
|
||||
fetch: fetchCmd,
|
||||
|
||||
/// Put bytes on the system clipboard, fire-and-forget (an over-bound or
|
||||
|
||||
@@ -460,6 +460,7 @@ export class SubsetChecker {
|
||||
private readonly capabilities: Set<string>;
|
||||
private readonly persistRoutes: PersistRoutes | undefined;
|
||||
private usesPersist = false;
|
||||
private usesStore = false;
|
||||
|
||||
constructor(
|
||||
tast: TypedAst,
|
||||
@@ -502,6 +503,9 @@ export class SubsetChecker {
|
||||
if (this.capabilities.has("persist") && !this.usesPersist) {
|
||||
this.warn("NS1028", "app.zon declares the `persist` capability, but this core has no `Cmd.persist()` call.", this.entry);
|
||||
}
|
||||
if (this.capabilities.has("store") && !this.usesStore) {
|
||||
this.warn("NS1069", "app.zon declares the `store` capability, but this core has no `Cmd.store.*` call.", this.entry);
|
||||
}
|
||||
this.checkExceptions();
|
||||
return {
|
||||
diagnostics: this.diagnostics,
|
||||
@@ -2491,6 +2495,24 @@ export class SubsetChecker {
|
||||
}
|
||||
}
|
||||
|
||||
// NS1069 — the nested record-store factories remain capability-bound;
|
||||
// recognizing the SDK Cmd symbol (rather than its spelling alone) keeps
|
||||
// local objects named Cmd out of this cross-file contract check.
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
ts.isPropertyAccessExpression(node.expression.expression) &&
|
||||
node.expression.expression.name.text === "store" &&
|
||||
ts.isIdentifier(node.expression.expression.expression) &&
|
||||
this.cmdNames.has(node.expression.expression.expression.text) &&
|
||||
this.isSdkReference(node.expression.expression.expression)
|
||||
) {
|
||||
this.usesStore = true;
|
||||
if (!this.capabilities.has("store")) {
|
||||
this.warn("NS1069", "`Cmd.store.*` requires the `store` capability in app.zon.", node);
|
||||
}
|
||||
}
|
||||
|
||||
// NS1001/NS1022/NS1051 — mutation stays inside local ownership:
|
||||
// mutating array methods are legal on arrays this function created and
|
||||
// still owns; shared data (parameters, model/msg trees, module tables)
|
||||
|
||||
+287
-13
@@ -36,8 +36,10 @@
|
||||
// - timer/now/delay arms carry exactly one number payload field (pinned
|
||||
// by tsc), so the harness constructs them shape-directed without
|
||||
// needing the field's name.
|
||||
// Cmd.persist snapshots the committed model in virtual-host memory; every
|
||||
// other effect (files, buffered/streaming fetch, clipboard,
|
||||
// Cmd.persist snapshots the committed model in virtual-host memory, and
|
||||
// capability-enabled Cmd.store performs against a process-local byte map.
|
||||
// Every other effect
|
||||
// (files, buffered/streaming fetch, clipboard,
|
||||
// notifications, spawn, audio, host commands)
|
||||
// is printed as `cmd ...` and NOT performed — feed its result back yourself
|
||||
// as an ordinary Msg line. That is the point: results are plain messages,
|
||||
@@ -57,7 +59,7 @@ interface Cmdish {
|
||||
}
|
||||
|
||||
function usage(): never {
|
||||
console.error("usage: devhost.ts <core.ts> [--script <msgs.ndjson>]");
|
||||
console.error("usage: devhost.ts <core.ts> [--script <msgs.ndjson>] [--capability <name>]...");
|
||||
console.error("core-logic loop only (update/effects under a virtual host) - not a renderer;");
|
||||
console.error("run the real app with `native dev`.");
|
||||
process.exit(2);
|
||||
@@ -69,8 +71,14 @@ let script: string | null = null;
|
||||
let persistOk: string | null = null;
|
||||
let persistNone: string | null = null;
|
||||
let persistErr: string | null = null;
|
||||
const capabilities = new Set<string>();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--script") script = args[++i] ?? null;
|
||||
else if (args[i] === "--capability") {
|
||||
const capability = args[++i] ?? null;
|
||||
if (capability === null) usage();
|
||||
capabilities.add(capability);
|
||||
}
|
||||
else if (args[i] === "--persist-ok") persistOk = args[++i] ?? null;
|
||||
else if (args[i] === "--persist-none") persistNone = args[++i] ?? null;
|
||||
else if (args[i] === "--persist-err") persistErr = args[++i] ?? null;
|
||||
@@ -86,7 +94,7 @@ if (persistOk !== null && persistNone !== null && persistErr !== null) {
|
||||
// Run the frontend inside the watched process so every node --watch restart
|
||||
// revalidates manifest-owned routes against the newly edited Msg union.
|
||||
const checked = checkFile(entry, {
|
||||
capabilities: ["persist"],
|
||||
capabilities: [...new Set([...capabilities, "persist"])],
|
||||
persistRoutes: { ok: persistOk, none: persistNone, err: persistErr },
|
||||
});
|
||||
for (const error of checked.typeErrors) console.error(error);
|
||||
@@ -112,6 +120,7 @@ if (typeof mod.initialModel !== "function" || typeof mod.update !== "function")
|
||||
// ---------------------------------------------------------- transcript i/o
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const strictDecoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function jsonable(value: unknown): unknown {
|
||||
@@ -153,6 +162,43 @@ const delays = new Map<string, { msgKind: string; at: number }>();
|
||||
/// Process-local Tier-1 store. `structuredClone` preserves Uint8Array and
|
||||
/// nested model data without making the app own a serialization format.
|
||||
let persistedModel: unknown | null = null;
|
||||
interface VirtualStoreRecord {
|
||||
readonly key: Uint8Array;
|
||||
readonly value: Uint8Array;
|
||||
}
|
||||
|
||||
/// Process-local Tier-2 store. Like SQLite, record identity is the encoded
|
||||
/// UTF-8 key rather than the source JavaScript string. That distinction is
|
||||
/// observable for ill-formed UTF-16: TextEncoder canonicalizes every lone
|
||||
/// surrogate to U+FFFD, so strings with the same encoded bytes must address
|
||||
/// one record here just as they do in the native host.
|
||||
const recordStore = new Map<string, VirtualStoreRecord>();
|
||||
|
||||
interface PendingStoreResult {
|
||||
readonly routeKey: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly ok: boolean;
|
||||
readonly okVoid: boolean;
|
||||
readonly bytes: Uint8Array;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/// Store operations are performed in command-stream order, but their routed
|
||||
/// results wait until the dispatch's complete command walk has returned. This
|
||||
/// is the native effect boundary: a later command in one Cmd.batch can replace
|
||||
/// or cancel an earlier result before either arm runs.
|
||||
const pendingStoreResults: PendingStoreResult[] = [];
|
||||
const pendingStoreByKey = new Map<string, PendingStoreResult>();
|
||||
let dispatchDepth = 0;
|
||||
let drainingStoreResults = false;
|
||||
|
||||
const maxStoreKeyBytes = 512;
|
||||
const maxStoreValueBytes = 1024 * 1024;
|
||||
const maxStoreBatchEntries = 64;
|
||||
const maxStoreBatchBytes = 8 * 1024 * 1024;
|
||||
const maxStoreScanLimit = 256;
|
||||
const maxStoreResultBytes = maxStoreValueBytes + (2 * maxStoreKeyBytes) + 32;
|
||||
|
||||
/// Timer/now/delay arms carry exactly one number payload field (tsc pins
|
||||
/// the shape), so a proxy that answers every non-kind read with the
|
||||
@@ -177,6 +223,213 @@ function bytesMsg(kind: string, bytes: Uint8Array): unknown {
|
||||
);
|
||||
}
|
||||
|
||||
function emptyMsg(kind: string): unknown {
|
||||
return { kind };
|
||||
}
|
||||
|
||||
function storeKeyBytes(key: string, allowEmpty = false): Uint8Array | null {
|
||||
const bytes = encoder.encode(key);
|
||||
if ((!allowEmpty && bytes.length === 0) || bytes.length > maxStoreKeyBytes) return null;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function storeKeyIdentity(bytes: Uint8Array): string {
|
||||
let identity = "";
|
||||
for (const byte of bytes) identity += String.fromCharCode(byte);
|
||||
return identity;
|
||||
}
|
||||
|
||||
function validStoreKeyBytes(bytes: Uint8Array, allowEmpty = false): Uint8Array | null {
|
||||
if ((!allowEmpty && bytes.length === 0) || bytes.length > maxStoreKeyBytes) return null;
|
||||
try {
|
||||
strictDecoder.decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function compareBytes(left: Uint8Array, right: Uint8Array): number {
|
||||
const count = Math.min(left.length, right.length);
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (left[i]! !== right[i]!) return left[i]! - right[i]!;
|
||||
}
|
||||
return left.length - right.length;
|
||||
}
|
||||
|
||||
function startsWithBytes(value: Uint8Array, prefix: Uint8Array): boolean {
|
||||
if (prefix.length > value.length) return false;
|
||||
for (let i = 0; i < prefix.length; i++) if (value[i] !== prefix[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function frameStoreScan(entries: ReadonlyArray<readonly [Uint8Array, Uint8Array]>, next: Uint8Array): Uint8Array {
|
||||
let length = 8 + next.length;
|
||||
for (const [key, value] of entries) length += 8 + key.length + value.length;
|
||||
const output = new Uint8Array(length);
|
||||
const view = new DataView(output.buffer);
|
||||
let at = 0;
|
||||
const writeU32 = (value: number): void => {
|
||||
view.setUint32(at, value, true);
|
||||
at += 4;
|
||||
};
|
||||
const writeBytes = (bytes: Uint8Array): void => {
|
||||
writeU32(bytes.length);
|
||||
output.set(bytes, at);
|
||||
at += bytes.length;
|
||||
};
|
||||
writeU32(entries.length);
|
||||
for (const [key, value] of entries) {
|
||||
writeBytes(key);
|
||||
writeBytes(value);
|
||||
}
|
||||
writeBytes(next);
|
||||
return output;
|
||||
}
|
||||
|
||||
function queueStoreResult(cmd: Cmdish, ok: boolean, bytes: Uint8Array, okVoid: boolean): void {
|
||||
const routeKey = cmd.key as string;
|
||||
if (routeKey.length > 0) {
|
||||
const replaced = pendingStoreByKey.get(routeKey);
|
||||
if (replaced) replaced.active = false;
|
||||
}
|
||||
const result: PendingStoreResult = {
|
||||
routeKey,
|
||||
okKind: cmd.okKind as string,
|
||||
errKind: cmd.errKind as string,
|
||||
ok,
|
||||
okVoid,
|
||||
bytes,
|
||||
active: true,
|
||||
};
|
||||
pendingStoreResults.push(result);
|
||||
if (routeKey.length > 0) pendingStoreByKey.set(routeKey, result);
|
||||
}
|
||||
|
||||
function cancelPendingStore(routeKey: string): boolean {
|
||||
const pending = pendingStoreByKey.get(routeKey);
|
||||
if (!pending) return false;
|
||||
pending.active = false;
|
||||
pendingStoreByKey.delete(routeKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function drainStoreResults(): void {
|
||||
if (dispatchDepth !== 0 || drainingStoreResults) return;
|
||||
drainingStoreResults = true;
|
||||
try {
|
||||
while (pendingStoreResults.length > 0) {
|
||||
const result = pendingStoreResults.shift()!;
|
||||
if (!result.active) continue;
|
||||
if (result.routeKey.length > 0) {
|
||||
if (pendingStoreByKey.get(result.routeKey) !== result) continue;
|
||||
pendingStoreByKey.delete(result.routeKey);
|
||||
}
|
||||
result.active = false;
|
||||
if (!result.ok) dispatch(bytesMsg(result.errKind, result.bytes));
|
||||
else if (result.okVoid) dispatch(emptyMsg(result.okKind));
|
||||
else dispatch(bytesMsg(result.okKind, result.bytes));
|
||||
}
|
||||
} finally {
|
||||
drainingStoreResults = false;
|
||||
}
|
||||
}
|
||||
|
||||
function rejectStore(cmd: Cmdish, reason: string): void {
|
||||
say(`cmd ${cmd.op} rejected ${reason}`);
|
||||
queueStoreResult(cmd, false, encoder.encode(reason), false);
|
||||
}
|
||||
|
||||
function performStoreCmd(cmd: Cmdish): void {
|
||||
const routeKey = cmd.key as string;
|
||||
if (!capabilities.has("store")) return rejectStore(cmd, "rejected");
|
||||
switch (cmd.op) {
|
||||
case "store_set": {
|
||||
const key = cmd.storeKey as string;
|
||||
const keyBytes = storeKeyBytes(key);
|
||||
const bytes = cmd.bytes;
|
||||
if (!keyBytes) return rejectStore(cmd, "bad_key");
|
||||
if (!(bytes instanceof Uint8Array) || bytes.length > maxStoreValueBytes) return rejectStore(cmd, "over_bound");
|
||||
recordStore.set(storeKeyIdentity(keyBytes), { key: keyBytes.slice(), value: bytes.slice() });
|
||||
say(`cmd store_set ${routeKey} ${key} (stored in virtual host memory)`);
|
||||
queueStoreResult(cmd, true, new Uint8Array(0), true);
|
||||
return;
|
||||
}
|
||||
case "store_get": {
|
||||
const key = cmd.storeKey as string;
|
||||
const keyBytes = storeKeyBytes(key);
|
||||
if (!keyBytes) return rejectStore(cmd, "bad_key");
|
||||
const value = recordStore.get(storeKeyIdentity(keyBytes))?.value;
|
||||
const result = new Uint8Array(value === undefined ? 1 : value.length + 1);
|
||||
if (value !== undefined) {
|
||||
result[0] = 1;
|
||||
result.set(value, 1);
|
||||
}
|
||||
say(`cmd store_get ${routeKey} ${key} (${value === undefined ? "miss" : "hit"})`);
|
||||
queueStoreResult(cmd, true, result, false);
|
||||
return;
|
||||
}
|
||||
case "store_delete": {
|
||||
const key = cmd.storeKey as string;
|
||||
const keyBytes = storeKeyBytes(key);
|
||||
if (!keyBytes) return rejectStore(cmd, "bad_key");
|
||||
recordStore.delete(storeKeyIdentity(keyBytes));
|
||||
say(`cmd store_delete ${routeKey} ${key} (deleted from virtual host memory)`);
|
||||
queueStoreResult(cmd, true, new Uint8Array(0), true);
|
||||
return;
|
||||
}
|
||||
case "store_scan": {
|
||||
const prefix = cmd.prefix as string;
|
||||
const after = cmd.after as string | Uint8Array;
|
||||
const prefixBytes = storeKeyBytes(prefix, true);
|
||||
const afterBytes = typeof after === "string"
|
||||
? (after === "" ? new Uint8Array(0) : storeKeyBytes(after))
|
||||
: validStoreKeyBytes(after, true);
|
||||
const requested = cmd.limit as number;
|
||||
const limit = requested === 0 ? 100 : requested;
|
||||
if (!prefixBytes || !afterBytes) return rejectStore(cmd, "bad_key");
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > maxStoreScanLimit) return rejectStore(cmd, "over_bound");
|
||||
const matches = [...recordStore.values()]
|
||||
.map(({ key, value }) => [key, value] as const)
|
||||
.filter(([key]) => startsWithBytes(key, prefixBytes) && (afterBytes.length === 0 || compareBytes(key, afterBytes) > 0))
|
||||
.sort(([left], [right]) => compareBytes(left, right));
|
||||
const page: Array<readonly [Uint8Array, Uint8Array]> = [];
|
||||
let framedLength = 8;
|
||||
for (const entry of matches) {
|
||||
if (page.length >= limit) break;
|
||||
const rowLength = 8 + entry[0].length + entry[1].length;
|
||||
if (framedLength + rowLength + entry[0].length > maxStoreResultBytes && page.length > 0) break;
|
||||
page.push(entry);
|
||||
framedLength += rowLength;
|
||||
}
|
||||
const hasMore = page.length < matches.length;
|
||||
const next = hasMore ? page.at(-1)![0] : new Uint8Array(0);
|
||||
say(`cmd store_scan ${routeKey} ${prefix} (${page.length} records${hasMore ? ", more" : ""})`);
|
||||
queueStoreResult(cmd, true, frameStoreScan(page, next), false);
|
||||
return;
|
||||
}
|
||||
case "store_set_many": {
|
||||
const entries = cmd.entries as ReadonlyArray<readonly [string, Uint8Array]>;
|
||||
if (!Array.isArray(entries) || entries.length === 0 || entries.length > maxStoreBatchEntries) return rejectStore(cmd, "over_bound");
|
||||
let encodedLength = 8;
|
||||
for (const [key, value] of entries) {
|
||||
const keyBytes = storeKeyBytes(key);
|
||||
if (!keyBytes) return rejectStore(cmd, "bad_key");
|
||||
if (!(value instanceof Uint8Array) || value.length > maxStoreValueBytes) return rejectStore(cmd, "over_bound");
|
||||
encodedLength += 8 + keyBytes.length + value.length;
|
||||
if (encodedLength > maxStoreBatchBytes) return rejectStore(cmd, "over_bound");
|
||||
}
|
||||
for (const [key, value] of entries) {
|
||||
const keyBytes = storeKeyBytes(key)!;
|
||||
recordStore.set(storeKeyIdentity(keyBytes), { key: keyBytes.slice(), value: value.slice() });
|
||||
}
|
||||
say(`cmd store_set_many ${routeKey} (${entries.length} records stored atomically)`);
|
||||
queueStoreResult(cmd, true, new Uint8Array(0), true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function performCmd(cmd: Cmdish): void {
|
||||
switch (cmd.op) {
|
||||
case "none":
|
||||
@@ -197,7 +450,9 @@ function performCmd(cmd: Cmdish): void {
|
||||
}
|
||||
case "cancel": {
|
||||
const key = cmd.key as string;
|
||||
if (delays.delete(key)) {
|
||||
if (cancelPendingStore(key)) {
|
||||
say(`cmd cancel ${key} (store result dropped)`);
|
||||
} else if (delays.delete(key)) {
|
||||
say(`cmd cancel ${key} (delay dropped)`);
|
||||
} else {
|
||||
say(`cmd cancel ${key} (not performed here - a live request or buffered named op drops silently; a live spawn or streaming fetch ends loudly, err arm "cancelled")`);
|
||||
@@ -213,6 +468,13 @@ function performCmd(cmd: Cmdish): void {
|
||||
if (persistErr) dispatch(bytesMsg(persistErr, encoder.encode("rejected")));
|
||||
}
|
||||
return;
|
||||
case "store_set":
|
||||
case "store_get":
|
||||
case "store_delete":
|
||||
case "store_scan":
|
||||
case "store_set_many":
|
||||
performStoreCmd(cmd);
|
||||
return;
|
||||
case "show_notification": {
|
||||
const details = Object.entries(cmd)
|
||||
.filter(([k]) => k !== "op")
|
||||
@@ -307,19 +569,31 @@ function boot(): void {
|
||||
const restored = persistedModel !== null;
|
||||
model = restored ? structuredClone(persistedModel) : first;
|
||||
say(`model ${JSON.stringify(jsonable(model))}`);
|
||||
if (cmd) performCmd(cmd as Cmdish);
|
||||
reconcileSubs();
|
||||
dispatchDepth += 1;
|
||||
try {
|
||||
if (cmd) performCmd(cmd as Cmdish);
|
||||
reconcileSubs();
|
||||
} finally {
|
||||
dispatchDepth -= 1;
|
||||
}
|
||||
drainStoreResults();
|
||||
const route = restored ? persistOk : persistNone;
|
||||
if (route) dispatch({ kind: route });
|
||||
}
|
||||
|
||||
function dispatch(msg: unknown): void {
|
||||
const result = mod.update(model, msg);
|
||||
const [next, cmd] = Array.isArray(result) ? result : [result, null];
|
||||
model = next;
|
||||
say(`model ${JSON.stringify(jsonable(model))}`);
|
||||
if (cmd) performCmd(cmd as Cmdish);
|
||||
reconcileSubs();
|
||||
dispatchDepth += 1;
|
||||
try {
|
||||
const result = mod.update(model, msg);
|
||||
const [next, cmd] = Array.isArray(result) ? result : [result, null];
|
||||
model = next;
|
||||
say(`model ${JSON.stringify(jsonable(model))}`);
|
||||
if (cmd) performCmd(cmd as Cmdish);
|
||||
reconcileSubs();
|
||||
} finally {
|
||||
dispatchDepth -= 1;
|
||||
}
|
||||
drainStoreResults();
|
||||
}
|
||||
|
||||
function handleLine(raw: string): void {
|
||||
|
||||
@@ -430,6 +430,12 @@ export const rules = {
|
||||
fix: "Increase app.zon's `.persist.version` when the `Model` shape changes, and never decrease or reuse a version number.",
|
||||
why: "The version selects the app's pure migration path while the model fingerprint rejects accidental shape drift; reusing a version would make old bytes ambiguous and could restore them into the wrong model layout.",
|
||||
},
|
||||
NS1069: {
|
||||
id: "NS1069",
|
||||
title: "Cmd.store and its capability must agree",
|
||||
fix: "Add `\"store\"` to app.zon's `capabilities`, or remove the unused capability/command.",
|
||||
why: "The store capability controls whether SQLite and the engine-owned record-store binding are linked into the app. Keeping the declaration and command in lockstep prevents a rejected effect and sheds the storage engine from apps that do not use it.",
|
||||
},
|
||||
} as const satisfies Record<string, RuleCopy>;
|
||||
|
||||
export type RuleId = keyof typeof rules;
|
||||
|
||||
@@ -19,6 +19,41 @@ test("clean core passes the checker", () => {
|
||||
assert.deepEqual(ruleIds(checkOnly(core)), []);
|
||||
});
|
||||
|
||||
test("NS1069 keeps every Cmd.store factory in capability lockstep", () => {
|
||||
const source = `
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
export interface Model { readonly bytes: Uint8Array; }
|
||||
export type Msg =
|
||||
| { readonly kind: "go" }
|
||||
| { readonly kind: "wrote" }
|
||||
| { readonly kind: "loaded"; readonly bytes: Uint8Array }
|
||||
| { readonly kind: "failed"; readonly reason: Uint8Array };
|
||||
export function initialModel(): Model { return { bytes: asciiBytes("v") }; }
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "go": return [model, Cmd.batch([
|
||||
Cmd.store.set("doc/1", model.bytes, { key: "put", ok: "wrote", err: "failed" }),
|
||||
Cmd.store.get("doc/1", { key: "get", ok: "loaded", err: "failed" }),
|
||||
Cmd.store.delete("doc/1", { key: "del", ok: "wrote", err: "failed" }),
|
||||
Cmd.store.scan("doc/", { limit: 10 }, { key: "scan", ok: "loaded", err: "failed" }),
|
||||
Cmd.store.setMany([["doc/2", model.bytes]], { key: "many", ok: "wrote", err: "failed" }),
|
||||
])];
|
||||
case "wrote":
|
||||
case "loaded":
|
||||
case "failed": return model;
|
||||
}
|
||||
}
|
||||
`;
|
||||
const enabled = check(source, { capabilities: ["store"] });
|
||||
assert.equal(enabled.ok, true, JSON.stringify(enabled));
|
||||
assert.equal(enabled.warnings.some((d) => d.id === "NS1069"), false);
|
||||
|
||||
const missing = check(source);
|
||||
assert.equal(missing.warnings.filter((d) => d.id === "NS1069").length, 5);
|
||||
const unused = check(core, { capabilities: ["store"] });
|
||||
assert.equal(unused.warnings.filter((d) => d.id === "NS1069").length, 1);
|
||||
});
|
||||
|
||||
test("NS1033 validates app.zon persistence restore routes against Msg", () => {
|
||||
const source = `
|
||||
import { Cmd } from "@native-sdk/core";
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
test("the devhost performs record-store batches, reads, and prefix scans", () => {
|
||||
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "native-store-devhost-"));
|
||||
try {
|
||||
const core = path.join(tmp, "core.ts");
|
||||
const script = path.join(tmp, "msgs.ndjson");
|
||||
fs.writeFileSync(core, `
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
export interface Model { readonly hits: number; readonly pageCount: number; }
|
||||
export type Msg =
|
||||
| { readonly kind: "go" }
|
||||
| { readonly kind: "again" }
|
||||
| { readonly kind: "wrote" }
|
||||
| { readonly kind: "loaded"; readonly bytes: Uint8Array }
|
||||
| { readonly kind: "scanned"; readonly bytes: Uint8Array }
|
||||
| { readonly kind: "failed"; readonly reason: Uint8Array };
|
||||
export function initialModel(): Model { return { hits: 0, pageCount: 0 }; }
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "go": return [model, Cmd.store.setMany([
|
||||
["chat/1", asciiBytes("one")], ["chat/2", asciiBytes("two")],
|
||||
], { key: "seed", ok: "wrote", err: "failed" })];
|
||||
case "wrote":
|
||||
case "again": return [model, Cmd.store.get("chat/1", { key: "read", ok: "loaded", err: "failed" })];
|
||||
case "loaded": return [{ ...model, hits: msg.bytes[0] }, Cmd.store.scan("chat/", { limit: 1 }, { key: "page", ok: "scanned", err: "failed" })];
|
||||
case "scanned": return { ...model, pageCount: msg.bytes[0] };
|
||||
case "failed": return model;
|
||||
}
|
||||
}
|
||||
`);
|
||||
fs.writeFileSync(script, '{"kind":"go"}\n{"restart":true}\n{"kind":"again"}\n');
|
||||
const run = spawnSync(process.execPath, [path.join(packageDir, "src", "devhost.ts"), core, "--script", script, "--capability", "store"], {
|
||||
cwd: tmp,
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.match(run.stdout, /cmd store_set_many seed \(2 records stored atomically\)/);
|
||||
assert.match(run.stdout, /cmd store_get read chat\/1 \(hit\)/);
|
||||
assert.match(run.stdout, /cmd store_scan page chat\/ \(1 records, more\)/);
|
||||
assert.match(run.stdout, /"hits":1,"pageCount":1/);
|
||||
assert.equal((run.stdout.match(/cmd store_get read chat\/1 \(hit\)/g) ?? []).length, 2);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the devhost defers store results so replacement and cancel match native batches", () => {
|
||||
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "native-store-devhost-order-"));
|
||||
try {
|
||||
const core = path.join(tmp, "core.ts");
|
||||
const script = path.join(tmp, "msgs.ndjson");
|
||||
fs.writeFileSync(core, `
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
export interface Model { readonly first: number; readonly second: number; readonly cancelled: number; readonly hit: number; }
|
||||
export type Msg =
|
||||
| { readonly kind: "go" }
|
||||
| { readonly kind: "first" }
|
||||
| { readonly kind: "second" }
|
||||
| { readonly kind: "cancelled" }
|
||||
| { readonly kind: "loaded"; readonly bytes: Uint8Array }
|
||||
| { readonly kind: "failed"; readonly reason: Uint8Array };
|
||||
export function initialModel(): Model { return { first: 0, second: 0, cancelled: 0, hit: 0 }; }
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "go": return [model, Cmd.batch([
|
||||
Cmd.store.set("doc/replaced", asciiBytes("one"), { key: "replace", ok: "first", err: "failed" }),
|
||||
Cmd.store.set("doc/replaced", asciiBytes("two"), { key: "replace", ok: "second", err: "failed" }),
|
||||
Cmd.store.set("doc/cancelled", asciiBytes("kept"), { key: "cancel", ok: "cancelled", err: "failed" }),
|
||||
Cmd.cancel("cancel"),
|
||||
])];
|
||||
case "first": return { ...model, first: model.first + 1 };
|
||||
case "second": return [{ ...model, second: model.second + 1 }, Cmd.store.get("doc/cancelled", { key: "read", ok: "loaded", err: "failed" })];
|
||||
case "cancelled": return { ...model, cancelled: model.cancelled + 1 };
|
||||
case "loaded": return { ...model, hit: msg.bytes[0] };
|
||||
case "failed": return model;
|
||||
}
|
||||
}
|
||||
`);
|
||||
fs.writeFileSync(script, '{"kind":"go"}\n');
|
||||
const run = spawnSync(process.execPath, [
|
||||
path.join(packageDir, "src", "devhost.ts"), core, "--script", script, "--capability", "store",
|
||||
], { cwd: tmp, encoding: "utf8" });
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.match(run.stdout, /cmd cancel cancel \(store result dropped\)/);
|
||||
assert.match(run.stdout, /"first":0,"second":1,"cancelled":0,"hit":1/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the devhost keeps initial-model store results behind the complete boot batch", () => {
|
||||
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "native-store-devhost-boot-order-"));
|
||||
try {
|
||||
const core = path.join(tmp, "core.ts");
|
||||
const script = path.join(tmp, "msgs.ndjson");
|
||||
fs.writeFileSync(core, `
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
export interface Model { readonly first: number; readonly second: number; readonly ticks: number; }
|
||||
export type Msg =
|
||||
| { readonly kind: "first" }
|
||||
| { readonly kind: "second" }
|
||||
| { readonly kind: "tick"; readonly atMs: number }
|
||||
| { readonly kind: "failed"; readonly reason: Uint8Array };
|
||||
export function initialModel(): [Model, Cmd<Msg>] {
|
||||
return [{ first: 0, second: 0, ticks: 0 }, Cmd.batch([
|
||||
Cmd.store.set("doc/boot", asciiBytes("one"), { key: "replace", ok: "first", err: "failed" }),
|
||||
Cmd.now("tick"),
|
||||
Cmd.store.set("doc/boot", asciiBytes("two"), { key: "replace", ok: "second", err: "failed" }),
|
||||
])];
|
||||
}
|
||||
export function update(model: Model, msg: Msg): Model {
|
||||
switch (msg.kind) {
|
||||
case "first": return { ...model, first: model.first + 1 };
|
||||
case "second": return { ...model, second: model.second + 1 };
|
||||
case "tick": return { ...model, ticks: model.ticks + 1 };
|
||||
case "failed": return model;
|
||||
}
|
||||
}
|
||||
`);
|
||||
fs.writeFileSync(script, "");
|
||||
const run = spawnSync(process.execPath, [
|
||||
path.join(packageDir, "src", "devhost.ts"), core, "--script", script, "--capability", "store",
|
||||
], { cwd: tmp, encoding: "utf8" });
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.match(run.stdout, /"first":0,"second":1,"ticks":1/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the devhost uses encoded key identity and rejects fractional scan limits", () => {
|
||||
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "native-store-devhost-key-bytes-"));
|
||||
try {
|
||||
const core = path.join(tmp, "core.ts");
|
||||
const script = path.join(tmp, "msgs.ndjson");
|
||||
fs.writeFileSync(core, `
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
export interface Model {
|
||||
readonly value: number;
|
||||
readonly records: number;
|
||||
readonly invalidLimit: number;
|
||||
readonly rejected: boolean;
|
||||
}
|
||||
export type Msg =
|
||||
| { readonly kind: "go" }
|
||||
| { readonly kind: "wrote" }
|
||||
| { readonly kind: "loaded"; readonly bytes: Uint8Array }
|
||||
| { readonly kind: "scanned"; readonly bytes: Uint8Array }
|
||||
| { readonly kind: "failed"; readonly reason: Uint8Array };
|
||||
export function initialModel(): Model {
|
||||
return { value: 0, records: 0, invalidLimit: 0.5, rejected: false };
|
||||
}
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "go": return [model, Cmd.batch([
|
||||
Cmd.store.set("\\uD800", asciiBytes("one"), { key: "first", ok: "wrote", err: "failed" }),
|
||||
Cmd.store.set("\\uD801", asciiBytes("two"), { key: "second", ok: "wrote", err: "failed" }),
|
||||
Cmd.store.get("\\uD800", { key: "read", ok: "loaded", err: "failed" }),
|
||||
])];
|
||||
case "wrote": return model;
|
||||
case "loaded": return [
|
||||
{ ...model, value: msg.bytes[1] },
|
||||
Cmd.store.scan("", { limit: 10 }, { key: "page", ok: "scanned", err: "failed" }),
|
||||
];
|
||||
case "scanned": return [
|
||||
{ ...model, records: msg.bytes[0] },
|
||||
Cmd.store.scan("", { limit: model.invalidLimit }, { key: "invalid", ok: "scanned", err: "failed" }),
|
||||
];
|
||||
case "failed": return { ...model, rejected: msg.reason[0] === 111 };
|
||||
}
|
||||
}
|
||||
`);
|
||||
fs.writeFileSync(script, '{"kind":"go"}\n');
|
||||
const run = spawnSync(process.execPath, [
|
||||
path.join(packageDir, "src", "devhost.ts"), core, "--script", script, "--capability", "store",
|
||||
], { cwd: tmp, encoding: "utf8" });
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.match(run.stdout, /cmd store_get read .+ \(hit\)/);
|
||||
assert.match(run.stdout, /cmd store_scan page \(1 records\)/);
|
||||
assert.match(run.stdout, /cmd store_scan rejected over_bound/);
|
||||
assert.match(run.stdout, /"value":116,"records":1,"invalidLimit":0.5,"rejected":true/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the devhost rejects store commands without the app.zon capability", () => {
|
||||
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "native-store-devhost-capability-"));
|
||||
try {
|
||||
const core = path.join(tmp, "core.ts");
|
||||
const script = path.join(tmp, "msgs.ndjson");
|
||||
fs.writeFileSync(core, `
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
export interface Model { readonly rejected: boolean; }
|
||||
export type Msg =
|
||||
| { readonly kind: "go" }
|
||||
| { readonly kind: "wrote" }
|
||||
| { readonly kind: "failed"; readonly reason: Uint8Array };
|
||||
export function initialModel(): Model { return { rejected: false }; }
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "go": return [model, Cmd.store.set("doc/one", asciiBytes("one"), { key: "write", ok: "wrote", err: "failed" })];
|
||||
case "wrote": return model;
|
||||
case "failed": return { rejected: msg.reason.length === 8 && msg.reason[0] === 114 };
|
||||
}
|
||||
}
|
||||
`);
|
||||
fs.writeFileSync(script, '{"kind":"go"}\n');
|
||||
const run = spawnSync(process.execPath, [path.join(packageDir, "src", "devhost.ts"), core, "--script", script], {
|
||||
cwd: tmp,
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.match(run.stdout, /cmd store_set rejected rejected/);
|
||||
assert.match(run.stdout, /"rejected":true/);
|
||||
assert.doesNotMatch(run.stdout, /stored in virtual host memory/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -106,7 +106,9 @@ test("NS1066 refuses phase-1 npm imports but permits compiler-shipped Node built
|
||||
"core.ts": serviceCore,
|
||||
"services/feeds.ts": `import thing from "left-pad"; export function parse(bytes: Uint8Array): Uint8Array { void thing; return bytes; }`,
|
||||
});
|
||||
assert.ok(npm.diagnostics.some((d) => d.id === "NS1066"), JSON.stringify(npm.diagnostics));
|
||||
const diagnostic = npm.diagnostics.find((d) => d.id === "NS1066");
|
||||
assert.ok(diagnostic, JSON.stringify(npm.diagnostics));
|
||||
assert.equal(diagnostic.title, "service dependencies are vendored in phase 1");
|
||||
|
||||
const builtin = checkFiles({
|
||||
"core.ts": serviceCore,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"packages/core/package-lock.json",
|
||||
"tools/corewire",
|
||||
"third_party/webview2",
|
||||
"third_party/sqlite",
|
||||
"native-sdk.d.ts",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
|
||||
@@ -19,6 +19,7 @@ const mirrors = [
|
||||
{ source: 'skills', target: 'skills' },
|
||||
{ source: 'skill-data', target: 'skill-data' },
|
||||
{ source: 'third_party/webview2', target: 'third_party/webview2' },
|
||||
{ source: 'third_party/sqlite', target: 'third_party/sqlite' },
|
||||
// corewire: the mirror/facade/profile generator every TypeScript-core
|
||||
// build compiles from the dependency.
|
||||
{ source: 'tools/corewire', target: 'tools/corewire' },
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
// build script that `addApp` lives in), app.zon (the SDK's own manifest,
|
||||
// which its build script reads at configure time), assets/ (files the
|
||||
// build graph resolves from the dependency, e.g. the Windows application
|
||||
// manifest build/app.zig wires via dep.path), third_party/webview2/ (the
|
||||
// vendored WebView2 SDK header and loader the Windows build resolves the
|
||||
// same way; the CEF runtimes stay out — they are large downloaded
|
||||
// artifacts, not repo files), and the agent skills. With the payload in
|
||||
// manifest build/app.zig wires via dep.path), third_party/webview2/ and
|
||||
// third_party/sqlite/ (the vendored platform/storage sources build/app.zig
|
||||
// resolves the same way; the CEF runtimes stay out — they are large
|
||||
// downloaded artifacts, not repo files), and the agent skills. With the payload in
|
||||
// the package, `native init && native dev` work offline right after
|
||||
// install.
|
||||
//
|
||||
@@ -44,11 +44,13 @@ for (const dir of ['src', 'build', 'assets', 'skills', 'skill-data']) {
|
||||
}
|
||||
|
||||
{
|
||||
const source = join(repoRoot, 'third_party', 'webview2');
|
||||
const target = join(projectRoot, 'third_party', 'webview2');
|
||||
rmSync(join(projectRoot, 'third_party'), { recursive: true, force: true });
|
||||
cpSync(source, target, { recursive: true });
|
||||
console.log(`✓ Copied third_party/webview2/ to ${target}`);
|
||||
for (const dir of ['webview2', 'sqlite']) {
|
||||
const source = join(repoRoot, 'third_party', dir);
|
||||
const target = join(projectRoot, 'third_party', dir);
|
||||
cpSync(source, target, { recursive: true });
|
||||
console.log(`✓ Copied third_party/${dir}/ to ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
// corewire (tools/corewire): the contract-sidecar mirror/facade/profile
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: ts-core
|
||||
description: Authoring guide for the primary Native SDK app-logic path: TypeScript app cores - Model, Msg, update, and the pure functions they call, written in the closed app-core subset, checked by the @native-sdk/core frontend, and compiled ahead-of-time to native code by the external core compiler. Use for new apps unless the user explicitly chose Zig, when writing or modifying a src/core.ts app core, fixing subset checker errors (NS1001-NS1067), or deciding how to express state, messages, text (bytes and the byte-text string methods), text input, continuous controls (sliders, scroll), effects (Cmd), subscriptions (Sub), the host-event wiring channels (frameMsg, keyMsg, pinchMsg, dropMsg, appearanceMsg, chromeMsg, envMsgs, app.zon assets), derived values, the view_unbound lint opt-out, local mutation of owned arrays, or how to split a core into modules under src/ (relative imports, namespace imports, @native-sdk/core/text, @native-sdk/core/events). Use ts-services alongside this guide for src/services work.
|
||||
description: Authoring guide for the primary Native SDK app-logic path: TypeScript app cores - Model, Msg, update, and the pure functions they call, written in the closed app-core subset, checked by the @native-sdk/core frontend, and compiled ahead-of-time to native code by the external core compiler. Use for new apps unless the user explicitly chose Zig, when writing or modifying a src/core.ts app core, fixing subset checker errors (NS1001-NS1069), or deciding how to express state, messages, text (bytes and the byte-text string methods), text input, continuous controls (sliders, scroll), effects (Cmd), subscriptions (Sub), the host-event wiring channels (frameMsg, keyMsg, pinchMsg, dropMsg, appearanceMsg, chromeMsg, envMsgs, app.zon assets), derived values, the view_unbound lint opt-out, local mutation of owned arrays, or how to split a core into modules under src/ (relative imports, namespace imports, @native-sdk/core/text, @native-sdk/core/events). Use ts-services alongside this guide for src/services work.
|
||||
---
|
||||
|
||||
# Author app cores in the TypeScript subset
|
||||
@@ -135,6 +135,7 @@ The command set (Cmd wire format v3):
|
||||
|
||||
These map directly onto the host's effect engine — files, HTTP, the clipboard, notifications, one-shot timers. Routed operations follow the `Cmd.request` rules (inline `{ key?, ok, err }`, string-literal arm names, arm shapes checked by tsc and taught by NS1027), with one difference from `request`: each op's `ok` arm has the op's OWN result shape. Keys follow the one keyed-effect rule everywhere: issuing a keyed op whose key is already in flight REPLACES the old one (the superseded op's result is dropped — no message), and `Cmd.cancel(key)` drops it silently. Every `err` arm carries exactly one `Uint8Array` field and receives a machine-readable reason; fire-and-forget writes and notifications have no routing arms. Paths, URLs, and bodies are bytes (`asciiBytes` for literals); dynamic values the engine refuses at runtime surface through the `err` arm, while compile-time-knowable bounds for routed operations stop the build (NS1030). Fire-and-forget operations fail closed under the host's validation instead.
|
||||
|
||||
- `Cmd.store.set(key, bytes, { key?, ok, err })` / `get` / `delete` / `scan` / `setMany` — capability-gated, engine-owned per-record storage. Declare `"store"` in `app.zon`; keys are UTF-8 strings up to 512 bytes and values are bytes up to 1 MiB. `setMany` applies 1–64 entries atomically. `get`'s ok bytes are `[1][value...]` for a hit and `[0]` for a miss, keeping an empty value distinct from absence. `scan(prefix, { limit?, after? }, route)` returns a little-endian length-prefixed page of key/value pairs plus an opaque next-key cursor; pass those next-key bytes back as `after` (a known literal cursor may be a string). The limit defaults to 100 and is capped at 256. Writes run off-loop in issue order, a synchronous read waits for earlier writes in the same command walk, every result journals, and failures route one of `io_failed`, `over_bound`, `bad_key`, `rejected`, or `busy`. The SQLite backing and app-data path are never part of the app-facing API.
|
||||
- `Cmd.readFile(path, { key?, ok, err })` — read a whole file. `ok` arm: one `Uint8Array` field with the content. `err` reasons: `not_found`, `io_failed`, `truncated` (the file exceeds the engine's 1 MiB read bound — a cut file never passes as whole), `rejected`. Paths are at most 1024 bytes.
|
||||
- `Cmd.writeFile(path, bytes, { key?, ok, err })` — write a whole file (parent directories created, an existing file replaced whole; at most 1 MiB). `ok` arm: NO payload fields (`{ kind: "wrote" }`) — a successful write has nothing to report. `err` reasons: `io_failed`, `rejected`.
|
||||
- `Cmd.fetch({ url, method?, headers?, body?, timeoutMs? }, { key?, ok, err })` — a buffered HTTP(S) exchange. `ok` arm: exactly two fields, one `number` and one `Uint8Array` (`{ kind: "fetched", status: number, body: Uint8Array }`) — matched by type, so the names are yours. The status is the real HTTP status: a 404 is still `ok` (an HTTP-level error is a delivered response). `err` reasons: `connect_failed`, `tls_failed`, `protocol_failed`, `timed_out`, `rejected`, and `truncated` (the body exceeded the engine's 256 KiB buffered bound — never delivered silently cut). The spec is an inline object: `url` bytes (≤ 2 KiB), `method` one of `"GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"` (default GET), `headers` an inline flat record — names are compile-time ASCII, values are string literals OR runtime bytes (`{ authorization: bearerToken(model.apiKey), "content-type": "application/json" }` — how a launch-supplied key rides an `Authorization` header; ≤ 8 headers, ≤ 1 KiB total, NS1029/NS1030), `body` bytes (≤ 64 KiB), `timeoutMs` a positive integer literal (engine default when omitted).
|
||||
@@ -397,6 +398,7 @@ Every diagnostic carries one of these IDs plus the fix and the why. Write to the
|
||||
- **NS1026 host payloads are bytes or a flat scalar record.** `Cmd.host`/`Cmd.request` carry exactly one payload: a `Uint8Array`, or an inline record of number/boolean/`Uint8Array` fields. Nested records, other field types, or a payload mixed with extra arguments have no wire encoding.
|
||||
- **NS1027 effect results route to Msg arms by name.** Routing (`{ key?, ok, err }`) and timer targets are string-literal arm names with the payload shape the effect produces — one `Uint8Array` field for host results/errors, one number field for timer and delay fires, no fields for `writeFile`'s ok, one number plus one `Uint8Array` field for `fetch`'s ok. Callbacks and computed names cannot work: the runtime builds the result Msg from the arm's declared shape at build time.
|
||||
- **NS1028 Cmd.persist and capability disagree (warning).** `Cmd.persist()` requires `"persist"` in `app.zon`; declaring the capability without issuing the command produces the inverse warning. The operation stays on the wire so the warning remains non-fatal, but only capability-enabled builds link the snapshot store.
|
||||
- **NS1069 Cmd.store and capability disagree (warning).** Any `Cmd.store.*` operation requires `"store"` in `app.zon`; declaring the capability without issuing a store command produces the inverse warning. The SQLite engine is linked only for `"store"` or the relational `"sqlite"` capability, while the record-store host binding is installed only for `"store"`.
|
||||
- **NS1029 effect op arguments have a fixed shape.** Paths/URLs/bodies are bytes, `Cmd.fetch`'s spec is an inline object with a closed verb literal, a number-literal timeout, and an inline flat record of headers whose NAMES are compile-time ASCII and whose VALUES are string literals or runtime bytes (`Uint8Array`). The record's shape encodes at build time; a runtime header value rides its length-prefixed wire field at dispatch time exactly like `url`/`body` — but a smuggled string (a ternary of literals, a template) has no encoding: make it bytes.
|
||||
- **NS1031 exported model helpers join the model's binding surface.** An exported single-Model-parameter helper becomes a Model declaration markup binds (`doneCount` → `{doneCount}`); two members with one binding name would be ambiguous — rename one.
|
||||
- **NS1032 viewUnbound names update-only model state.** `export const viewUnbound = [...] as const` entries must be string literals naming Model fields, exported model helpers, or Msg kinds — by their TypeScript spellings (`"nextId"`); anything else would silence nothing and hide a typo.
|
||||
|
||||
@@ -12,7 +12,7 @@ Two rules resolve most failures:
|
||||
- Operations on the outside world take a `std.Io` first (or right after the receiver). Get one from `init.io` in `main(init: std.process.Init)`, from `std.testing.io` in tests, or from `std.Io.Threaded` in code with no `Init` to thread through.
|
||||
- Containers are unmanaged: initialize with `.empty` and pass the allocator to every mutating call.
|
||||
|
||||
In a UiApp, `update` never sees an `Io` — persistence, subprocesses, HTTP, clocks, and timers go through the typed effects channel (`fx.readFile`, `fx.spawn`, `fx.fetch`, `fx.wallMs`, `fx.startTimer`; see `native skills get native-ui`). Raw `std.Io` belongs in `main`, tests, and standalone tools. And because Zig analyzes lazily, a 0.15-ism can hide in code only one build path references: run BOTH `zig build` and `zig build test` before calling a change done.
|
||||
In a UiApp, `update` never sees an `Io` — persistence, record storage, subprocesses, HTTP, clocks, and timers go through the typed effects channel (`fx.persist`, `fx.storeSet`/`storeGet`/`storeDelete`/`storeScan`/`storeSetMany`, `fx.readFile`, `fx.spawn`, `fx.fetch`, `fx.wallMs`, `fx.startTimer`; see `native skills get native-ui`). Raw `std.Io` belongs in `main`, tests, and standalone tools. Record-store apps declare the `"store"` capability; the engine owns the SQLite connection and app-data path, while every result still returns as a Msg. And because Zig analyzes lazily, a 0.15-ism can hide in code only one build path references: run BOTH `zig build` and `zig build test` before calling a change done.
|
||||
|
||||
## error: struct 'heap' has no member named 'GeneralPurposeAllocator' — allocators come from `main(init: std.process.Init)`
|
||||
|
||||
|
||||
+44
-4
@@ -54,6 +54,10 @@ pub const RunOptions = struct {
|
||||
security: native_sdk.SecurityPolicy = .{},
|
||||
menus: []const native_sdk.Menu = &.{},
|
||||
shortcuts: ?[]const native_sdk.Shortcut = null,
|
||||
/// Filled by `runWithOptions` from the manifest capability. App entry
|
||||
/// points do not set this themselves; the field only carries the owned
|
||||
/// binding uniformly into each platform's runtime options.
|
||||
record_store: ?native_sdk.RecordStoreBinding = null,
|
||||
|
||||
fn appInfo(self: RunOptions, buffers: *StateBuffers) native_sdk.AppInfo {
|
||||
var info: native_sdk.AppInfo = .{
|
||||
@@ -435,6 +439,17 @@ fn manifestDeclaresTrayCapability() bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether app.zon declares the Tier-2 record store. This is comptime so
|
||||
/// apps without the capability never analyze the SQLite open/deinit path.
|
||||
fn manifestDeclaresStore() bool {
|
||||
if (comptime !@hasField(@TypeOf(app_manifest), "capabilities")) return false;
|
||||
inline for (app_manifest.capabilities) |capability| {
|
||||
const name: []const u8 = capability;
|
||||
if (comptime std.mem.eql(u8, name, "store")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn shortcutModifiers(comptime shortcut: anytype) native_sdk.ShortcutModifiers {
|
||||
const values = if (@hasField(@TypeOf(shortcut), "modifiers")) shortcut.modifiers else .{};
|
||||
var modifiers: native_sdk.ShortcutModifiers = .{};
|
||||
@@ -466,14 +481,35 @@ pub fn runWithOptions(app: native_sdk.App, options: RunOptions, init: std.proces
|
||||
if (init.environ_map.get("NATIVE_SDK_SESSION_REPLAY")) |journal_path| {
|
||||
return runSessionReplay(app, options, init, journal_path);
|
||||
}
|
||||
const RecordStoreType = if (comptime manifestDeclaresStore()) native_sdk.RecordStore else void;
|
||||
var record_store_value: RecordStoreType = undefined;
|
||||
var record_store_open = false;
|
||||
var resolved_options = options;
|
||||
if (comptime manifestDeclaresStore()) {
|
||||
var data_dir_buffer: [512]u8 = undefined;
|
||||
const app_data_dir = native_sdk.app_dirs.resolveOne(
|
||||
.{ .name = options.bundle_id },
|
||||
native_sdk.app_dirs.currentPlatform(),
|
||||
native_sdk.debug.envFromMap(init.environ_map),
|
||||
.data,
|
||||
&data_dir_buffer,
|
||||
) catch return error.StoreDataDirUnavailable;
|
||||
try std.Io.Dir.cwd().createDirPath(init.io, app_data_dir);
|
||||
record_store_value = try native_sdk.RecordStore.open(std.heap.page_allocator, app_data_dir);
|
||||
record_store_open = true;
|
||||
resolved_options.record_store = record_store_value.binding();
|
||||
}
|
||||
defer if (comptime manifestDeclaresStore()) {
|
||||
if (record_store_open) record_store_value.deinit();
|
||||
};
|
||||
if (comptime std.mem.eql(u8, build_options.platform, "macos")) {
|
||||
try runMacos(app, options, init);
|
||||
try runMacos(app, resolved_options, init);
|
||||
} else if (comptime std.mem.eql(u8, build_options.platform, "linux")) {
|
||||
try runLinux(app, options, init);
|
||||
try runLinux(app, resolved_options, init);
|
||||
} else if (comptime std.mem.eql(u8, build_options.platform, "windows")) {
|
||||
try runWindows(app, options, init);
|
||||
try runWindows(app, resolved_options, init);
|
||||
} else {
|
||||
try runNull(app, options, init);
|
||||
try runNull(app, resolved_options, init);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,6 +565,7 @@ fn runNull(app: native_sdk.App, options: RunOptions, init: std.process.Init) !vo
|
||||
.shortcuts = shortcuts,
|
||||
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
.window_state_store = store,
|
||||
.record_store = options.record_store,
|
||||
.environ = init.minimal.environ,
|
||||
.session_recorder = session_recorder,
|
||||
});
|
||||
@@ -593,6 +630,7 @@ fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
|
||||
.shortcuts = shortcuts,
|
||||
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
.window_state_store = store,
|
||||
.record_store = options.record_store,
|
||||
.environ = init.minimal.environ,
|
||||
.session_recorder = session_recorder,
|
||||
});
|
||||
@@ -654,6 +692,7 @@ fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
|
||||
.shortcuts = shortcuts,
|
||||
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
.window_state_store = store,
|
||||
.record_store = options.record_store,
|
||||
.environ = init.minimal.environ,
|
||||
.session_recorder = session_recorder,
|
||||
});
|
||||
@@ -714,6 +753,7 @@ fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init)
|
||||
.shortcuts = shortcuts,
|
||||
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
.window_state_store = store,
|
||||
.record_store = options.record_store,
|
||||
.environ = init.minimal.environ,
|
||||
.session_recorder = session_recorder,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
//! (window 1, label "mobile-surface").
|
||||
|
||||
const native_sdk = @import("native_sdk");
|
||||
const mobile_build_options = @import("mobile_build_options");
|
||||
|
||||
comptime {
|
||||
native_sdk.embed.exportMobileCApi(native_sdk.embed.UiAppHost(@import("app")));
|
||||
native_sdk.embed.exportMobileCApi(native_sdk.embed.UiAppHostWithRecordStore(
|
||||
@import("app"),
|
||||
mobile_build_options.store_capability,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -242,6 +242,25 @@ pub fn MobileCApi(comptime Host: type) type {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// Supply the OS-owned app-data directory before start. Hosts call
|
||||
/// this for every app; non-store host types accept it as a no-op so
|
||||
/// the mobile ABI does not vary with capability shedding.
|
||||
pub fn native_sdk_app_set_data_root(app: ?*anyopaque, path: ?[*]const u8, len: usize) callconv(.c) c_int {
|
||||
const self = hostApp(Host, app) orelse return 0;
|
||||
const dir = inputSlice(path, len) catch |err| {
|
||||
recordError(self, err);
|
||||
return 0;
|
||||
};
|
||||
if (comptime @hasDecl(Host, "setDataRoot")) {
|
||||
self.setDataRoot(dir) catch |err| {
|
||||
recordError(self, err);
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
self.last_error = null;
|
||||
return 1;
|
||||
}
|
||||
|
||||
pub fn native_sdk_app_touch(app: ?*anyopaque, id: u64, phase: c_int, x: f32, y: f32, pressure: f32) callconv(.c) void {
|
||||
const self = hostApp(Host, app) orelse return;
|
||||
self.embedded.touch(id, phase, x, y, pressure) catch |err| {
|
||||
@@ -762,6 +781,7 @@ pub const native_sdk_app_set_audio_service = FixedShellApi.native_sdk_app_set_au
|
||||
pub const native_sdk_app_audio_event = FixedShellApi.native_sdk_app_audio_event;
|
||||
pub const native_sdk_app_set_image_service = FixedShellApi.native_sdk_app_set_image_service;
|
||||
pub const native_sdk_app_set_automation_dir = FixedShellApi.native_sdk_app_set_automation_dir;
|
||||
pub const native_sdk_app_set_data_root = FixedShellApi.native_sdk_app_set_data_root;
|
||||
pub const native_sdk_app_touch = FixedShellApi.native_sdk_app_touch;
|
||||
pub const native_sdk_app_scroll = FixedShellApi.native_sdk_app_scroll;
|
||||
pub const native_sdk_app_key = FixedShellApi.native_sdk_app_key;
|
||||
|
||||
@@ -26,6 +26,7 @@ pub const mobile_gpu_surface_label = types.mobile_gpu_surface_label;
|
||||
pub const EmbeddedApp = host.EmbeddedApp;
|
||||
pub const MobileHostApp = host.MobileHostApp;
|
||||
pub const UiAppHost = ui_host.UiAppHost;
|
||||
pub const UiAppHostWithRecordStore = ui_host.UiAppHostWithRecordStore;
|
||||
pub const mobile_shell_scene = ui_host.mobile_shell_scene;
|
||||
pub const MobileCApi = c_api.MobileCApi;
|
||||
pub const exportMobileCApi = c_api.exportMobileCApi;
|
||||
@@ -45,6 +46,7 @@ pub const native_sdk_app_set_audio_service = c_api.native_sdk_app_set_audio_serv
|
||||
pub const native_sdk_app_audio_event = c_api.native_sdk_app_audio_event;
|
||||
pub const native_sdk_app_set_image_service = c_api.native_sdk_app_set_image_service;
|
||||
pub const native_sdk_app_set_automation_dir = c_api.native_sdk_app_set_automation_dir;
|
||||
pub const native_sdk_app_set_data_root = c_api.native_sdk_app_set_data_root;
|
||||
pub const native_sdk_app_touch = c_api.native_sdk_app_touch;
|
||||
pub const native_sdk_app_scroll = c_api.native_sdk_app_scroll;
|
||||
pub const native_sdk_app_key = c_api.native_sdk_app_key;
|
||||
|
||||
@@ -1019,6 +1019,8 @@ const MobileCounterDef = struct {
|
||||
|
||||
const MobileCounterHost = ui_host.UiAppHost(MobileCounterDef);
|
||||
const MobileCounterApi = c_api.MobileCApi(MobileCounterHost);
|
||||
const MobileStoreHost = ui_host.UiAppHostWithRecordStore(MobileCounterDef, true);
|
||||
const MobileStoreApi = c_api.MobileCApi(MobileStoreHost);
|
||||
|
||||
fn expectNoUiHostError(app: ?*anyopaque) !void {
|
||||
try std.testing.expectEqualStrings("", std.mem.span(MobileCounterApi.native_sdk_app_last_error_name(app)));
|
||||
@@ -1052,6 +1054,33 @@ fn tapMobileWidget(app: ?*anyopaque, node: MobileWidgetSemantics) !void {
|
||||
try expectNoUiHostError(app);
|
||||
}
|
||||
|
||||
test "mobile store capability requires and binds the OS app-data root before start" {
|
||||
const app = MobileStoreApi.native_sdk_app_create() orelse return error.TestUnexpectedResult;
|
||||
defer MobileStoreApi.native_sdk_app_destroy(app);
|
||||
const self: *MobileStoreHost = @ptrCast(@alignCast(app));
|
||||
|
||||
MobileStoreApi.native_sdk_app_start(app);
|
||||
try std.testing.expectEqualStrings("StoreDataDirUnavailable", std.mem.span(MobileStoreApi.native_sdk_app_last_error_name(app)));
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buffer: [256]u8 = undefined;
|
||||
const path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/mobile-store", .{tmp.sub_path[0..]});
|
||||
try std.Io.Dir.cwd().createDirPath(std.testing.io, path);
|
||||
try std.testing.expectEqual(@as(c_int, 1), MobileStoreApi.native_sdk_app_set_data_root(app, path.ptr, path.len));
|
||||
try std.testing.expect(self.record_store_open);
|
||||
try std.testing.expect(self.embedded.runtime.options.record_store != null);
|
||||
|
||||
MobileStoreApi.native_sdk_app_start(app);
|
||||
try std.testing.expectEqualStrings("", std.mem.span(MobileStoreApi.native_sdk_app_last_error_name(app)));
|
||||
var surface_token: u8 = 0;
|
||||
MobileStoreApi.native_sdk_app_viewport(app, 390, 844, 1, &surface_token, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
MobileStoreApi.native_sdk_app_frame(app);
|
||||
try self.ui.dispatch(&self.embedded.runtime, 1, .increment);
|
||||
try std.testing.expect(self.ui.effects.record_store_binding != null);
|
||||
MobileStoreApi.native_sdk_app_stop(app);
|
||||
}
|
||||
|
||||
test "mobile C ABI drives a user UiApp canvas scene end to end" {
|
||||
const app = MobileCounterApi.native_sdk_app_create() orelse return error.TestUnexpectedResult;
|
||||
defer MobileCounterApi.native_sdk_app_destroy(app);
|
||||
|
||||
@@ -57,7 +57,15 @@ pub const mobile_shell_windows = [_]app_manifest.ShellWindow{.{
|
||||
pub const mobile_shell_scene: app_manifest.ShellConfig = .{ .windows = &mobile_shell_windows };
|
||||
|
||||
pub fn UiAppHost(comptime AppDef: type) type {
|
||||
return UiAppHostWithRecordStore(AppDef, false);
|
||||
}
|
||||
|
||||
/// Capability-specialized mobile host. The boolean is comptime so a mobile
|
||||
/// artifact without `store` never analyzes SQLite open/deinit and carries no
|
||||
/// database symbols; `build/app.zig` supplies the manifest-inferred value.
|
||||
pub fn UiAppHostWithRecordStore(comptime AppDef: type, comptime record_store_enabled: bool) type {
|
||||
const features: runtime.UiAppFeatures = if (@hasDecl(AppDef, "features")) AppDef.features else .{};
|
||||
const RecordStoreType = if (record_store_enabled) runtime.RecordStore else void;
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
@@ -69,6 +77,8 @@ pub fn UiAppHost(comptime AppDef: type) type {
|
||||
/// the host wraps it so ABI-facing counters observe every event.
|
||||
inner_app: runtime.App,
|
||||
embedded: EmbeddedApp,
|
||||
record_store: RecordStoreType = undefined,
|
||||
record_store_open: bool = false,
|
||||
started: bool = false,
|
||||
frame_index: u64 = 0,
|
||||
last_error: ?anyerror = null,
|
||||
@@ -130,6 +140,7 @@ pub fn UiAppHost(comptime AppDef: type) type {
|
||||
// produce real pixels (the buffer M2's surface blit consumes).
|
||||
self.null_platform.gpu_surface_packets = false;
|
||||
self.started = false;
|
||||
self.record_store_open = false;
|
||||
self.frame_index = 0;
|
||||
self.last_error = null;
|
||||
self.command_count = 0;
|
||||
@@ -180,6 +191,9 @@ pub fn UiAppHost(comptime AppDef: type) type {
|
||||
// its heap-owned registrations (registered canvas font
|
||||
// bytes) before the host storage goes.
|
||||
self.embedded.deinit();
|
||||
if (comptime record_store_enabled) {
|
||||
if (self.record_store_open) self.record_store.deinit();
|
||||
}
|
||||
// The embedded null platform lives inside `self`, so freeing
|
||||
// `self` IS this path's platform destruction — and an
|
||||
// abandoned channel wake call may still enter that platform
|
||||
@@ -197,10 +211,30 @@ pub fn UiAppHost(comptime AppDef: type) type {
|
||||
}
|
||||
|
||||
pub fn start(self: *Self) anyerror!void {
|
||||
if (comptime record_store_enabled) {
|
||||
if (!self.record_store_open) return error.StoreDataDirUnavailable;
|
||||
}
|
||||
self.started = true;
|
||||
try self.embedded.start();
|
||||
}
|
||||
|
||||
/// Install the OS-owned app-data directory before start. iOS passes
|
||||
/// Library/Application Support and Android passes files/, exactly the
|
||||
/// `.data` directories resolved by `app_dirs` on those platforms.
|
||||
pub fn setDataRoot(self: *Self, data_root: []const u8) !void {
|
||||
if (comptime !record_store_enabled) return;
|
||||
if (self.started) return error.AppAlreadyStarted;
|
||||
if (data_root.len == 0 or data_root.len > max_mobile_asset_root_bytes) return error.InvalidStoreDataDir;
|
||||
if (self.record_store_open) {
|
||||
self.record_store.deinit();
|
||||
self.record_store_open = false;
|
||||
self.embedded.runtime.options.record_store = null;
|
||||
}
|
||||
self.record_store = try runtime.RecordStore.open(std.heap.page_allocator, data_root);
|
||||
self.record_store_open = true;
|
||||
self.embedded.runtime.options.record_store = self.record_store.binding();
|
||||
}
|
||||
|
||||
/// Host-pumped frame step: the shim's display-link (or test) tick.
|
||||
/// Synthesizes the `gpu_surface_frame` event a platform loop would
|
||||
/// deliver for the mobile surface — first tick installs the widget
|
||||
|
||||
@@ -237,6 +237,11 @@ public final class NativeSdkActivity extends Activity implements SurfaceHolder.C
|
||||
// section below.
|
||||
nativeSetImageService(nativeApp);
|
||||
|
||||
// `getFilesDir()` is app_dirs `.data` on Android. Store-capability
|
||||
// builds open store.db here before start; other builds accept the
|
||||
// same stable host call as a capability-shed no-op.
|
||||
nativeSetDataRoot(nativeApp, getFilesDir().getAbsolutePath());
|
||||
|
||||
// Verification harness: `am start --ez native-sdk-automation true`
|
||||
// publishes snapshot.txt into the app's files dir, same protocol
|
||||
// as the desktop -Dautomation=true runners (readable over
|
||||
@@ -1361,6 +1366,7 @@ public final class NativeSdkActivity extends Activity implements SurfaceHolder.C
|
||||
private native boolean nativeTextInputState(long app, long[] widgetId, float[] frame);
|
||||
private native boolean nativeScrollableWidgetAt(long app, float x, float y);
|
||||
private native void nativeSetAssetRoot(long app, String path);
|
||||
private native void nativeSetDataRoot(long app, String path);
|
||||
private native void nativeSetAutomationDir(long app, String path);
|
||||
private native void nativeSetTextMeasure(long app);
|
||||
private native void nativeSetAudioService(long app);
|
||||
|
||||
@@ -403,6 +403,16 @@ JNIEXPORT jboolean JNICALL Java_dev_native_1sdk_host_NativeSdkActivity_nativeScr
|
||||
|
||||
// ------------------------------------------------------- assets/automation
|
||||
|
||||
JNIEXPORT void JNICALL Java_dev_native_1sdk_host_NativeSdkActivity_nativeSetDataRoot(JNIEnv *env, jobject self, jlong app, jstring path) {
|
||||
(void)self;
|
||||
if (!path) return;
|
||||
const char *chars = (*env)->GetStringUTFChars(env, path, NULL);
|
||||
if (!chars) return;
|
||||
native_sdk_app_set_data_root((void *)app, chars, strlen(chars));
|
||||
host_log_error((void *)app, "data_root");
|
||||
(*env)->ReleaseStringUTFChars(env, path, chars);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_dev_native_1sdk_host_NativeSdkActivity_nativeSetAssetRoot(JNIEnv *env, jobject self, jlong app, jstring path) {
|
||||
(void)self;
|
||||
if (!path) return;
|
||||
|
||||
@@ -227,6 +227,7 @@ typedef struct native_sdk_image_service {
|
||||
} native_sdk_image_service_t;
|
||||
int native_sdk_app_set_image_service(void *app, const native_sdk_image_service_t *service, void *context);
|
||||
int native_sdk_app_set_automation_dir(void *app, const char *path, uintptr_t len);
|
||||
int native_sdk_app_set_data_root(void *app, const char *path, uintptr_t len);
|
||||
void native_sdk_app_set_asset_root(void *app, const char *path, uintptr_t len);
|
||||
uintptr_t native_sdk_app_widget_semantics_count(void *app);
|
||||
int native_sdk_app_widget_semantics_at(void *app, uintptr_t index, native_sdk_widget_semantics_t *out);
|
||||
|
||||
@@ -245,6 +245,7 @@ typedef struct native_sdk_image_service {
|
||||
} native_sdk_image_service_t;
|
||||
int native_sdk_app_set_image_service(void *app, const native_sdk_image_service_t *service, void *context);
|
||||
int native_sdk_app_set_automation_dir(void *app, const char *path, uintptr_t len);
|
||||
int native_sdk_app_set_data_root(void *app, const char *path, uintptr_t len);
|
||||
void native_sdk_app_set_asset_root(void *app, const char *path, uintptr_t len);
|
||||
uintptr_t native_sdk_app_widget_semantics_count(void *app);
|
||||
int native_sdk_app_widget_semantics_at(void *app, uintptr_t index, native_sdk_widget_semantics_t *out);
|
||||
|
||||
@@ -1675,6 +1675,28 @@ static const CGFloat NativeSdkTouchSlop = 8.0;
|
||||
native_sdk_app_set_image_service(self.nativeApp, &imageService, NULL);
|
||||
[self logNativeErrorIfAny:@"image_service"];
|
||||
|
||||
// Engine-owned durable storage lives in the platform app-data
|
||||
// directory. The ABI accepts this for every app; capability-shed builds
|
||||
// treat it as a no-op, while `store` builds open store.db before start.
|
||||
NSString *dataRoot = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory,
|
||||
NSUserDomainMask,
|
||||
YES).firstObject;
|
||||
if (dataRoot) {
|
||||
NSError *dataRootError = nil;
|
||||
[NSFileManager.defaultManager createDirectoryAtPath:dataRoot
|
||||
withIntermediateDirectories:YES
|
||||
attributes:nil
|
||||
error:&dataRootError];
|
||||
if (dataRootError) {
|
||||
NSLog(@"native-sdk: app data directory unavailable: %@", dataRootError);
|
||||
} else {
|
||||
native_sdk_app_set_data_root(self.nativeApp,
|
||||
dataRoot.UTF8String,
|
||||
[dataRoot lengthOfBytesUsingEncoding:NSUTF8StringEncoding]);
|
||||
[self logNativeErrorIfAny:@"data_root"];
|
||||
}
|
||||
}
|
||||
|
||||
// Verification harness: with NATIVE_SDK_AUTOMATION set (simctl launch
|
||||
// exports SIMCTL_CHILD_* into the app) the embedded runtime publishes
|
||||
// snapshot.txt into the app's data container, same protocol as the
|
||||
|
||||
@@ -162,6 +162,8 @@ pub const CapabilityKind = enum {
|
||||
clipboard,
|
||||
credentials,
|
||||
persist,
|
||||
store,
|
||||
sqlite,
|
||||
open_url,
|
||||
reveal_path,
|
||||
recent_documents,
|
||||
@@ -188,6 +190,8 @@ pub const Capability = union(CapabilityKind) {
|
||||
clipboard: void,
|
||||
credentials: void,
|
||||
persist: void,
|
||||
store: void,
|
||||
sqlite: void,
|
||||
open_url: void,
|
||||
reveal_path: void,
|
||||
recent_documents: void,
|
||||
|
||||
@@ -49,6 +49,16 @@ pub const EffectFileResult = runtime.EffectFileResult;
|
||||
pub const EffectPersistOutcome = runtime.EffectPersistOutcome;
|
||||
pub const EffectPersistResult = runtime.EffectPersistResult;
|
||||
pub const max_effect_persist_snapshot_bytes = runtime.max_effect_persist_snapshot_bytes;
|
||||
pub const EffectStoreOutcome = runtime.EffectStoreOutcome;
|
||||
pub const EffectStoreOp = runtime.EffectStoreOp;
|
||||
pub const RecordStoreBinding = runtime.RecordStoreBinding;
|
||||
pub const max_effect_store_key_bytes = runtime.max_effect_store_key_bytes;
|
||||
pub const max_effect_store_value_bytes = runtime.max_effect_store_value_bytes;
|
||||
pub const max_effect_store_batch_entries = runtime.max_effect_store_batch_entries;
|
||||
pub const max_effect_store_batch_bytes = runtime.max_effect_store_batch_bytes;
|
||||
pub const max_effect_store_result_bytes = runtime.max_effect_store_result_bytes;
|
||||
pub const default_effect_store_scan_limit = runtime.default_effect_store_scan_limit;
|
||||
pub const max_effect_store_scan_limit = runtime.max_effect_store_scan_limit;
|
||||
pub const effect_error_exit_code = runtime.effect_error_exit_code;
|
||||
pub const max_effects = runtime.max_effects;
|
||||
pub const max_effect_argv = runtime.max_effect_argv;
|
||||
@@ -71,6 +81,8 @@ pub const persist_store = runtime.persist_store;
|
||||
pub const PersistStore = runtime.PersistStore;
|
||||
pub const PersistOutcome = runtime.PersistOutcome;
|
||||
pub const max_persist_snapshot_bytes = runtime.max_persist_snapshot_bytes;
|
||||
pub const record_store = runtime.record_store;
|
||||
pub const RecordStore = runtime.RecordStore;
|
||||
pub const EffectClipboardOp = runtime.EffectClipboardOp;
|
||||
pub const EffectClipboardOutcome = runtime.EffectClipboardOutcome;
|
||||
pub const EffectClipboardResult = runtime.EffectClipboardResult;
|
||||
|
||||
@@ -489,6 +489,10 @@ pub const Options = struct {
|
||||
shortcuts: []const platform.Shortcut = &.{},
|
||||
automation: ?automation.Server = null,
|
||||
window_state_store: ?window_state.Store = null,
|
||||
/// Engine-owned record-store service. The app runner sets this only
|
||||
/// when app.zon declares the `store` capability; replay leaves it null
|
||||
/// because journaled effect results are the whole external world.
|
||||
record_store: ?runtime_effects.RecordStoreBinding = null,
|
||||
js_window_api: bool = false,
|
||||
/// Whether this build ships the embedded web layer. The app runner
|
||||
/// sets it from the build graph's app.zon inference (declare-to-use:
|
||||
|
||||
+63
-1
@@ -40,6 +40,7 @@ const extensions = @import("../extensions/root.zig");
|
||||
const app_manifest = @import("app_manifest");
|
||||
const platform = @import("../platform/root.zig");
|
||||
const runtime_effects = @import("effects.zig");
|
||||
const runtime_record_store = @import("record_store.zig");
|
||||
const security = @import("../security/root.zig");
|
||||
|
||||
const max_async_bridge_responses = runtime_async_bridge.max_async_bridge_responses;
|
||||
@@ -1041,7 +1042,6 @@ pub fn TestHarness() type {
|
||||
trace_records: [64]trace.Record = undefined,
|
||||
trace_sink: trace.BufferSink = undefined,
|
||||
runtime: Runtime = undefined,
|
||||
|
||||
/// The harness embeds the multi-megabyte Runtime, so stack
|
||||
/// instances overflow test threads; create on the heap.
|
||||
pub fn create(gpa: std.mem.Allocator, surface: platform.Surface) !*Self {
|
||||
@@ -1050,6 +1050,13 @@ pub fn TestHarness() type {
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Store-capability counterpart of `create`. The SQLite database is
|
||||
/// private to this harness and is automatically bound to every UiApp
|
||||
/// effect channel installed into its runtime.
|
||||
pub fn createWithRecordStore(gpa: std.mem.Allocator, surface: platform.Surface) !*RecordStoreTestHarness() {
|
||||
return RecordStoreTestHarness().create(gpa, surface);
|
||||
}
|
||||
|
||||
pub fn destroy(self: *Self, gpa: std.mem.Allocator) void {
|
||||
// The harness embeds the runtime's platform: deinit both
|
||||
// returns the runtime's heap-owned storage (registered font
|
||||
@@ -1063,11 +1070,16 @@ pub fn TestHarness() type {
|
||||
}
|
||||
|
||||
pub fn init(self: *Self, surface: platform.Surface) void {
|
||||
self.initRuntime(surface, null);
|
||||
}
|
||||
|
||||
fn initRuntime(self: *Self, surface: platform.Surface, record_store_binding: ?runtime_effects.RecordStoreBinding) void {
|
||||
self.null_platform = platform.NullPlatform.init(surface);
|
||||
self.trace_sink = trace.BufferSink.init(&self.trace_records);
|
||||
Runtime.initAt(&self.runtime, .{
|
||||
.platform = self.null_platform.platform(),
|
||||
.trace_sink = self.trace_sink.sink(),
|
||||
.record_store = record_store_binding,
|
||||
// On-demand runtime storage (registered font bytes,
|
||||
// registered image slot buffers, adopted media-surface
|
||||
// texture buffers) routes through
|
||||
@@ -1098,6 +1110,56 @@ pub fn TestHarness() type {
|
||||
};
|
||||
}
|
||||
|
||||
/// Capability-opted harness returned by `TestHarness().createWithRecordStore`.
|
||||
/// It is a separate concrete type so ordinary app tests that use
|
||||
/// `TestHarness().create` never analyze or link SQLite merely because Zig's
|
||||
/// test reflection visits the public SDK surface.
|
||||
pub fn RecordStoreTestHarness() type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
null_platform: platform.NullPlatform = platform.NullPlatform.init(.{}),
|
||||
trace_records: [64]trace.Record = undefined,
|
||||
trace_sink: trace.BufferSink = undefined,
|
||||
runtime: Runtime = undefined,
|
||||
record_store: ?runtime_record_store.Store = null,
|
||||
|
||||
pub fn create(gpa: std.mem.Allocator, surface: platform.Surface) !*Self {
|
||||
const self = try gpa.create(Self);
|
||||
errdefer gpa.destroy(self);
|
||||
self.record_store = try runtime_record_store.Store.openMemory(gpa);
|
||||
errdefer if (self.record_store) |*store| store.deinit();
|
||||
self.null_platform = platform.NullPlatform.init(surface);
|
||||
self.trace_sink = trace.BufferSink.init(&self.trace_records);
|
||||
Runtime.initAt(&self.runtime, .{
|
||||
.platform = self.null_platform.platform(),
|
||||
.trace_sink = self.trace_sink.sink(),
|
||||
.record_store = self.record_store.?.binding(),
|
||||
.allocator = if (builtin.is_test) std.testing.allocator else std.heap.page_allocator,
|
||||
.environ = if (builtin.is_test) std.testing.environ else null,
|
||||
});
|
||||
self.runtime.dispatch_error_policy = .propagate;
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn destroy(self: *Self, gpa: std.mem.Allocator) void {
|
||||
self.runtime.deinit();
|
||||
if (self.record_store) |*store| store.deinit();
|
||||
gpa.destroy(self);
|
||||
}
|
||||
|
||||
pub fn start(self: *Self, app: App) anyerror!void {
|
||||
try self.runtime.dispatchPlatformEvent(app, .app_start);
|
||||
try self.runtime.dispatchPlatformEvent(app, .{ .surface_resized = self.null_platform.surface_value });
|
||||
try self.runtime.dispatchPlatformEvent(app, .frame_requested);
|
||||
}
|
||||
|
||||
pub fn stop(self: *Self, app: App) anyerror!void {
|
||||
try self.runtime.dispatchPlatformEvent(app, .app_shutdown);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const testingWriteViewJson = writeViewJson;
|
||||
const testingCopyInto = copyInto;
|
||||
const testingCanvasWidgetSemanticsById = canvasWidgetSemanticsById;
|
||||
|
||||
+469
-33
@@ -75,10 +75,15 @@ const platform = @import("../platform/root.zig");
|
||||
const validation = @import("validation.zig");
|
||||
const runtime_clock = @import("clock.zig");
|
||||
const persist_store = @import("persist_store.zig");
|
||||
const record_store = @import("record_store.zig");
|
||||
const pty_transport = @import("pty.zig");
|
||||
|
||||
/// Maximum in-flight effects (spawn slots / worker threads).
|
||||
pub const max_effects: usize = 16;
|
||||
/// Record-store effects have their own capacity: a large batch or a busy
|
||||
/// database cannot consume the spawn/fetch/file family's sixteen slots.
|
||||
pub const max_store_effects: usize = 16;
|
||||
const total_effect_slots: usize = max_effects + max_store_effects;
|
||||
/// Maximum argv entries per spawn.
|
||||
pub const max_effect_argv: usize = 16;
|
||||
/// Maximum total bytes across all argv entries of one spawn.
|
||||
@@ -170,6 +175,16 @@ pub const max_effect_file_bytes: usize = 1024 * 1024;
|
||||
/// consume a file-effect slot or inherit the raw-file cap.
|
||||
pub const max_effect_persist_snapshot_bytes: usize = persist_store.max_snapshot_bytes;
|
||||
pub const EffectPersistOutcome = persist_store.Outcome;
|
||||
pub const max_effect_store_key_bytes: usize = record_store.max_key_bytes;
|
||||
pub const max_effect_store_value_bytes: usize = record_store.max_value_bytes;
|
||||
pub const max_effect_store_batch_entries: usize = record_store.max_batch_entries;
|
||||
pub const max_effect_store_batch_bytes: usize = record_store.max_batch_bytes;
|
||||
pub const max_effect_store_result_bytes: usize = record_store.max_result_bytes;
|
||||
pub const default_effect_store_scan_limit: u32 = record_store.default_scan_limit;
|
||||
pub const max_effect_store_scan_limit: u32 = record_store.max_scan_limit;
|
||||
pub const EffectStoreOutcome = record_store.Outcome;
|
||||
pub const EffectStoreOp = record_store.Operation;
|
||||
pub const RecordStoreBinding = record_store.Binding;
|
||||
/// The one boot-time model-restore result delivered to a persistence-enabled
|
||||
/// Zig core. Successful bytes contain the generated snapshot body; every
|
||||
/// other outcome carries an empty slice. The bytes are instance-lived, so an
|
||||
@@ -2909,6 +2924,40 @@ pub fn Effects(comptime Msg: type) type {
|
||||
on_result: ?HostMsgFn = null,
|
||||
};
|
||||
|
||||
pub const StoreEntry = struct {
|
||||
key: []const u8,
|
||||
bytes: []const u8,
|
||||
};
|
||||
|
||||
pub const StoreSetOptions = struct {
|
||||
key: u64,
|
||||
record_key: []const u8,
|
||||
bytes: []const u8,
|
||||
on_result: ?HostMsgFn = null,
|
||||
};
|
||||
|
||||
pub const StoreGetOptions = struct {
|
||||
key: u64,
|
||||
record_key: []const u8,
|
||||
on_result: ?HostMsgFn = null,
|
||||
};
|
||||
|
||||
pub const StoreDeleteOptions = StoreGetOptions;
|
||||
|
||||
pub const StoreScanOptions = struct {
|
||||
key: u64,
|
||||
prefix: []const u8,
|
||||
limit: u32 = default_effect_store_scan_limit,
|
||||
after: []const u8 = "",
|
||||
on_result: ?HostMsgFn = null,
|
||||
};
|
||||
|
||||
pub const StoreSetManyOptions = struct {
|
||||
key: u64,
|
||||
entries: []const StoreEntry,
|
||||
on_result: ?HostMsgFn = null,
|
||||
};
|
||||
|
||||
/// A recorded host request, exposed by the fake executor for
|
||||
/// test assertions. Slices point into slot storage and stay
|
||||
/// valid until the request retires.
|
||||
@@ -3508,13 +3557,15 @@ pub fn Effects(comptime Msg: type) type {
|
||||
volume: f32,
|
||||
};
|
||||
|
||||
/// `draining`: the worker is done and the terminal entry is
|
||||
/// `draining`: the effect work is done and the terminal entry is
|
||||
/// queued, but the slot still owns a heap buffer (a fetch's body
|
||||
/// or a collect spawn's stdout) until the drain delivers (and
|
||||
/// thereby retires) it.
|
||||
/// thereby retires) it. A consumer that dequeues a worker-fed
|
||||
/// terminal may publish this state before the producer thread's
|
||||
/// post-enqueue epilogue finishes; reclaim joins that epilogue.
|
||||
const SlotState = enum(u8) { idle, running, done, draining };
|
||||
|
||||
const SlotKind = enum(u8) { spawn, fetch, file, clipboard, host, image };
|
||||
const SlotKind = enum(u8) { spawn, fetch, file, clipboard, host, store, image };
|
||||
|
||||
const EntryKind = enum(u8) { line, exit, response, file, clipboard, host, image, channel, pty };
|
||||
|
||||
@@ -4005,6 +4056,22 @@ pub fn Effects(comptime Msg: type) type {
|
||||
}
|
||||
};
|
||||
|
||||
/// Immutable input and bounded result storage for one record-store
|
||||
/// write. The worker owns this block until `joinWorker`; keeping the
|
||||
/// SQLite input out of the slot lets a replaced request become
|
||||
/// logically cancelled without racing the write that must still run
|
||||
/// before its replacement.
|
||||
const StoreWorkerContext = struct {
|
||||
binding: RecordStoreBinding,
|
||||
operation: EffectStoreOp,
|
||||
sequence: u64,
|
||||
sequence_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
|
||||
payload: []u8,
|
||||
output: [32]u8 = undefined,
|
||||
outcome: EffectStoreOutcome = .rejected,
|
||||
output_len: usize = 0,
|
||||
};
|
||||
|
||||
const Slot = struct {
|
||||
state: std.atomic.Value(SlotState) = std.atomic.Value(SlotState).init(.idle),
|
||||
generation: u32 = 0,
|
||||
@@ -4053,6 +4120,10 @@ pub fn Effects(comptime Msg: type) type {
|
||||
/// handshake — the loop thread only dereferences it while
|
||||
/// the channel is alive.
|
||||
spawn_ctx: ?*SpawnWorkerContext = null,
|
||||
/// The private input/result block of an off-loop record-store
|
||||
/// write. Store workers are always joined before teardown can
|
||||
/// release the host-owned database binding.
|
||||
store_ctx: ?*StoreWorkerContext = null,
|
||||
/// Producer-side drop accounting (worker in real mode, loop
|
||||
/// thread in fake mode; never both).
|
||||
dropped_pending: u32 = 0,
|
||||
@@ -4370,6 +4441,10 @@ pub fn Effects(comptime Msg: type) type {
|
||||
/// requests reject loudly in real mode, and the fake executor
|
||||
/// parks requests for `feedHostResult` regardless.
|
||||
host_calls: ?HostCallBinding = null,
|
||||
/// Capability-installed record-store service. Store commands are
|
||||
/// SDK-reserved routed requests, so their results reuse the request
|
||||
/// journal/replay path while the database handle stays host-owned.
|
||||
record_store_binding: ?RecordStoreBinding = null,
|
||||
/// Window-action mirror: counts and the last requested label,
|
||||
/// observable in tests (`windowActionState`).
|
||||
window_action_state: WindowActionState = .{},
|
||||
@@ -4463,7 +4538,7 @@ pub fn Effects(comptime Msg: type) type {
|
||||
/// process-lifetime posting handles. Seedable in tests to pin
|
||||
/// the non-wrapping guarantee without 2^32 opens.
|
||||
channel_generation: u64 = 0,
|
||||
slots: [max_effects]Slot = [_]Slot{.{}} ** max_effects,
|
||||
slots: [total_effect_slots]Slot = [_]Slot{.{}} ** total_effect_slots,
|
||||
/// Fixed fx timer table (see `max_effect_timers`): timers live
|
||||
/// beside the effect slots, never in them. Loop-thread only.
|
||||
timer_slots: [max_effect_timers]TimerSlot = [_]TimerSlot{.{}} ** max_effect_timers,
|
||||
@@ -4920,6 +4995,27 @@ pub fn Effects(comptime Msg: type) type {
|
||||
slot.generation = 0;
|
||||
}
|
||||
}
|
||||
// Store writes use std.Thread directly rather than the shared
|
||||
// threaded-I/O executor. Let the bounded SQLite operations finish
|
||||
// and join them before the runner may close their host-owned DB.
|
||||
// Their ordered-write condition means all workers must be allowed
|
||||
// to progress together; wait for the family as a set, then join.
|
||||
if (comptime io_threaded_supported) {
|
||||
while (true) {
|
||||
var store_running = false;
|
||||
for (&self.slots) |*slot| {
|
||||
if (slot.kind == .store and !slot.fake and slot.state.load(.acquire) == .running) {
|
||||
store_running = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!store_running) break;
|
||||
std.Thread.yield() catch {};
|
||||
}
|
||||
for (&self.slots) |*slot| {
|
||||
if (slot.kind == .store) joinWorker(slot);
|
||||
}
|
||||
}
|
||||
for (&self.slots) |*slot| {
|
||||
if (slot.state.load(.acquire) == .running and !slot.fake) {
|
||||
slot.cancel_requested.store(true, .release);
|
||||
@@ -5593,6 +5689,13 @@ pub fn Effects(comptime Msg: type) type {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind the engine-owned Tier-2 store. Loop-thread only; first bind
|
||||
/// sticks. Apps without the `store` capability never install it and
|
||||
/// receive a closed `rejected` outcome if a command is smuggled in.
|
||||
pub fn bindRecordStore(self: *Self, binding: RecordStoreBinding) void {
|
||||
if (self.record_store_binding == null) self.record_store_binding = binding;
|
||||
}
|
||||
|
||||
/// Switch this channel into session-replay mode: the fake
|
||||
/// executor (no processes, no network, no file or pasteboard
|
||||
/// I/O — requests park in their slots) with journaled results as
|
||||
@@ -7788,6 +7891,147 @@ pub fn Effects(comptime Msg: type) type {
|
||||
self.hostSend("core.persist", "");
|
||||
}
|
||||
|
||||
/// Upsert one record. Store keys are UTF-8 (1..512 bytes), values are
|
||||
/// bounded at 1 MiB, and the result uses the request shape: `ok=true`
|
||||
/// with empty bytes on success, otherwise `ok=false` with one closed
|
||||
/// StoreOutcome name (`bad_key`, `over_bound`, `io_failed`, `busy`, or
|
||||
/// `rejected`).
|
||||
pub fn storeSet(self: *Self, options: StoreSetOptions) void {
|
||||
if (!record_store.validKey(options.record_key)) return self.rejectStore(options.key, options.on_result, .bad_key);
|
||||
if (options.bytes.len > max_effect_store_value_bytes) return self.rejectStore(options.key, options.on_result, .over_bound);
|
||||
const payload = self.storeFieldsPayload(&.{ options.record_key, options.bytes }) orelse
|
||||
return self.rejectStore(options.key, options.on_result, .rejected);
|
||||
defer self.allocator.free(payload);
|
||||
self.startHostRequest(.{
|
||||
.key = options.key,
|
||||
.name = "core.store.set",
|
||||
.payload = payload,
|
||||
.on_result = options.on_result,
|
||||
}, max_effect_store_value_bytes + max_effect_store_key_bytes + 16, 32, .set);
|
||||
}
|
||||
|
||||
/// Read one record. The ok payload is a one-byte presence envelope:
|
||||
/// `[1][value...]` for a hit and `[0]` for a miss. The tag keeps an
|
||||
/// empty stored value distinct from absence while retaining the
|
||||
/// existing RequestRoute/EffectHostResult surface.
|
||||
pub fn storeGet(self: *Self, options: StoreGetOptions) void {
|
||||
if (!record_store.validKey(options.record_key)) return self.rejectStore(options.key, options.on_result, .bad_key);
|
||||
const payload = self.storeFieldsPayload(&.{options.record_key}) orelse
|
||||
return self.rejectStore(options.key, options.on_result, .rejected);
|
||||
defer self.allocator.free(payload);
|
||||
self.startHostRequest(.{
|
||||
.key = options.key,
|
||||
.name = "core.store.get",
|
||||
.payload = payload,
|
||||
.on_result = options.on_result,
|
||||
}, max_effect_store_key_bytes + 8, max_effect_store_value_bytes + 1, .get);
|
||||
}
|
||||
|
||||
/// Delete one record. Missing keys succeed.
|
||||
pub fn storeDelete(self: *Self, options: StoreDeleteOptions) void {
|
||||
if (!record_store.validKey(options.record_key)) return self.rejectStore(options.key, options.on_result, .bad_key);
|
||||
const payload = self.storeFieldsPayload(&.{options.record_key}) orelse
|
||||
return self.rejectStore(options.key, options.on_result, .rejected);
|
||||
defer self.allocator.free(payload);
|
||||
self.startHostRequest(.{
|
||||
.key = options.key,
|
||||
.name = "core.store.delete",
|
||||
.payload = payload,
|
||||
.on_result = options.on_result,
|
||||
}, max_effect_store_key_bytes + 8, 32, .delete);
|
||||
}
|
||||
|
||||
/// Scan a byte-lexicographic prefix page. The ok payload is
|
||||
/// `[count u32][count * (key_len u32,key,value_len u32,value)]`
|
||||
/// followed by `[next_len u32,next]`; an empty next ends iteration.
|
||||
pub fn storeScan(self: *Self, options: StoreScanOptions) void {
|
||||
if (!record_store.validPrefix(options.prefix) or
|
||||
(options.after.len > 0 and !record_store.validKey(options.after)))
|
||||
{
|
||||
return self.rejectStore(options.key, options.on_result, .bad_key);
|
||||
}
|
||||
if (options.limit > max_effect_store_scan_limit) return self.rejectStore(options.key, options.on_result, .over_bound);
|
||||
const len = std.math.add(usize, 16, options.prefix.len) catch
|
||||
return self.rejectStore(options.key, options.on_result, .over_bound);
|
||||
const total = std.math.add(usize, len, options.after.len) catch
|
||||
return self.rejectStore(options.key, options.on_result, .over_bound);
|
||||
const payload = self.allocator.alloc(u8, total) catch
|
||||
return self.rejectStore(options.key, options.on_result, .rejected);
|
||||
defer self.allocator.free(payload);
|
||||
var at: usize = 0;
|
||||
writeStoreU32(payload, &at, 0);
|
||||
writeStoreField(payload, &at, options.prefix);
|
||||
writeStoreU32(payload, &at, options.limit);
|
||||
writeStoreField(payload, &at, options.after);
|
||||
self.startHostRequest(.{
|
||||
.key = options.key,
|
||||
.name = "core.store.scan",
|
||||
.payload = payload,
|
||||
.on_result = options.on_result,
|
||||
}, max_effect_store_key_bytes * 2 + 16, max_effect_store_result_bytes, .scan);
|
||||
}
|
||||
|
||||
/// Atomically upsert a bounded batch. Validation happens before the
|
||||
/// request starts, and SQLite applies every entry in one transaction.
|
||||
pub fn storeSetMany(self: *Self, options: StoreSetManyOptions) void {
|
||||
if (options.entries.len == 0 or options.entries.len > max_effect_store_batch_entries) {
|
||||
return self.rejectStore(options.key, options.on_result, .over_bound);
|
||||
}
|
||||
var len: usize = 8;
|
||||
for (options.entries) |entry| {
|
||||
if (!record_store.validKey(entry.key)) return self.rejectStore(options.key, options.on_result, .bad_key);
|
||||
if (entry.bytes.len > max_effect_store_value_bytes) return self.rejectStore(options.key, options.on_result, .over_bound);
|
||||
len = std.math.add(usize, len, 8) catch return self.rejectStore(options.key, options.on_result, .over_bound);
|
||||
len = std.math.add(usize, len, entry.key.len) catch return self.rejectStore(options.key, options.on_result, .over_bound);
|
||||
len = std.math.add(usize, len, entry.bytes.len) catch return self.rejectStore(options.key, options.on_result, .over_bound);
|
||||
}
|
||||
if (len > max_effect_store_batch_bytes) return self.rejectStore(options.key, options.on_result, .over_bound);
|
||||
const payload = self.allocator.alloc(u8, len) catch
|
||||
return self.rejectStore(options.key, options.on_result, .rejected);
|
||||
defer self.allocator.free(payload);
|
||||
var at: usize = 0;
|
||||
writeStoreU32(payload, &at, 0);
|
||||
writeStoreU32(payload, &at, @intCast(options.entries.len));
|
||||
for (options.entries) |entry| {
|
||||
writeStoreField(payload, &at, entry.key);
|
||||
writeStoreField(payload, &at, entry.bytes);
|
||||
}
|
||||
self.startHostRequest(.{
|
||||
.key = options.key,
|
||||
.name = "core.store.setMany",
|
||||
.payload = payload,
|
||||
.on_result = options.on_result,
|
||||
}, max_effect_store_batch_bytes, 32, .set_many);
|
||||
}
|
||||
|
||||
fn storeFieldsPayload(self: *Self, fields: []const []const u8) ?[]u8 {
|
||||
var len: usize = 4;
|
||||
for (fields) |field| {
|
||||
len = std.math.add(usize, len, 4) catch return null;
|
||||
len = std.math.add(usize, len, field.len) catch return null;
|
||||
}
|
||||
const payload = self.allocator.alloc(u8, len) catch return null;
|
||||
var at: usize = 0;
|
||||
writeStoreU32(payload, &at, 0);
|
||||
for (fields) |field| writeStoreField(payload, &at, field);
|
||||
return payload;
|
||||
}
|
||||
|
||||
fn writeStoreU32(buffer: []u8, at: *usize, value: u32) void {
|
||||
std.mem.writeInt(u32, buffer[at.*..][0..4], value, .little);
|
||||
at.* += 4;
|
||||
}
|
||||
|
||||
fn writeStoreField(buffer: []u8, at: *usize, bytes: []const u8) void {
|
||||
writeStoreU32(buffer, at, @intCast(bytes.len));
|
||||
@memcpy(buffer[at.*..][0..bytes.len], bytes);
|
||||
at.* += bytes.len;
|
||||
}
|
||||
|
||||
fn rejectStore(self: *Self, key: u64, on_result: ?HostMsgFn, outcome: EffectStoreOutcome) void {
|
||||
self.deliverLoopHost(.{ .key = key, .ok = false, .bytes = record_store.outcomeName(outcome) }, on_result, true);
|
||||
}
|
||||
|
||||
/// A keyed, routed host command — the generic named host call
|
||||
/// behind a transpiled core's `request` wire records: the host
|
||||
/// performs `name` with `payload` and answers with exactly one
|
||||
@@ -7805,11 +8049,22 @@ pub fn Effects(comptime Msg: type) type {
|
||||
/// the request parks in its slot (inspect with `pendingHostAt`,
|
||||
/// answer with `feedHostResult`).
|
||||
pub fn hostRequest(self: *Self, options: HostRequestOptions) void {
|
||||
self.startHostRequest(options, max_effect_host_payload_bytes, max_effect_host_result_bytes, null);
|
||||
}
|
||||
|
||||
fn startHostRequest(
|
||||
self: *Self,
|
||||
options: HostRequestOptions,
|
||||
payload_limit: usize,
|
||||
result_limit: usize,
|
||||
store_op: ?EffectStoreOp,
|
||||
) void {
|
||||
self.reclaimSlots();
|
||||
const fake = self.executor == .fake;
|
||||
const native_request = isNativeHostRequestName(options.name);
|
||||
const store_request = store_op != null;
|
||||
if (options.name.len == 0 or options.name.len > max_effect_host_name_bytes or
|
||||
options.payload.len > max_effect_host_payload_bytes)
|
||||
options.payload.len > payload_limit)
|
||||
{
|
||||
return self.rejectHost(options.key, options.on_result);
|
||||
}
|
||||
@@ -7854,36 +8109,51 @@ pub fn Effects(comptime Msg: type) type {
|
||||
const state = slot.state.load(.acquire);
|
||||
if (state != .running and state != .draining) continue;
|
||||
if (slot.key != options.key) continue;
|
||||
if (slot.kind != .host) {
|
||||
const expected_kind: SlotKind = if (store_request) .store else .host;
|
||||
if (slot.kind != expected_kind) {
|
||||
if (state == .running or slotTerminalUndelivered(slot)) {
|
||||
return self.rejectHost(options.key, options.on_result);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A write already handed to SQLite is only logically
|
||||
// cancelled: it still runs in issue order, and its
|
||||
// terminal is swallowed. Its replacement needs a fresh
|
||||
// store slot so the worker's storage cannot be reused.
|
||||
if (slot.kind == .store and state == .running and !slot.fake and slot.worker_thread != null) {
|
||||
slot.cancelled_generation = slot.generation;
|
||||
slot.cancel_requested.store(true, .release);
|
||||
continue;
|
||||
}
|
||||
if (!native_request and self.host_calls != null and self.host_calls.?.reject_duplicate_keys) {
|
||||
return self.rejectHost(options.key, options.on_result);
|
||||
}
|
||||
// Tell the host first: a late answer for the old
|
||||
// occupancy must find nothing.
|
||||
if (state == .running and !slot.fake) self.notifyHostCancel(options.key);
|
||||
if (slot.kind == .host and state == .running and !slot.fake) self.notifyHostCancel(options.key);
|
||||
if (slot.kind == .store and state == .draining) joinWorker(slot);
|
||||
self.releaseFetchSlot(slot);
|
||||
slot.generation = 0;
|
||||
replaced = index;
|
||||
}
|
||||
break :blk replaced orelse
|
||||
(self.findIdleSlot() orelse return self.rejectHost(options.key, options.on_result));
|
||||
((if (store_request) self.findIdleStoreSlot() else self.findIdleSlot()) orelse
|
||||
return self.rejectHost(options.key, options.on_result));
|
||||
};
|
||||
|
||||
const slot = &self.slots[slot_index];
|
||||
// The buffer holds the payload copy, then the result space.
|
||||
const buffer = self.allocator.alloc(u8, options.payload.len + max_effect_host_result_bytes) catch {
|
||||
const total_buffer_len = std.math.add(usize, options.payload.len, result_limit) catch {
|
||||
return self.rejectHost(options.key, options.on_result);
|
||||
};
|
||||
const buffer = self.allocator.alloc(u8, total_buffer_len) catch {
|
||||
return self.rejectHost(options.key, options.on_result);
|
||||
};
|
||||
slot.generation = self.next_generation;
|
||||
self.next_generation +%= 1;
|
||||
if (self.next_generation == 0) self.next_generation = 1;
|
||||
slot.key = options.key;
|
||||
slot.kind = .host;
|
||||
slot.kind = if (store_request) .store else .host;
|
||||
slot.on_line = null;
|
||||
slot.on_exit = null;
|
||||
slot.on_response = null;
|
||||
@@ -7911,6 +8181,14 @@ pub fn Effects(comptime Msg: type) type {
|
||||
// Fake mode (tests and session replay) parks here: the feed
|
||||
// is the only terminal source.
|
||||
if (fake) return;
|
||||
if (store_op) |operation| {
|
||||
if (operation == .get or operation == .scan) {
|
||||
self.performBoundStoreRequest(slot.hostName(), options.key, slot.fetchPayload());
|
||||
} else {
|
||||
self.startStoreWorker(slot_index, operation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (native_request) {
|
||||
self.performNativeHostRequest(slot.hostName(), options.key, slot.fetchPayload());
|
||||
return;
|
||||
@@ -7920,7 +8198,8 @@ pub fn Effects(comptime Msg: type) type {
|
||||
}
|
||||
|
||||
fn isNativeHostRequestName(name: []const u8) bool {
|
||||
return std.mem.eql(u8, name, "native-sdk.launch-at-login.status") or
|
||||
return std.mem.startsWith(u8, name, "core.store.") or
|
||||
std.mem.eql(u8, name, "native-sdk.launch-at-login.status") or
|
||||
std.mem.eql(u8, name, "native-sdk.launch-at-login.set") or
|
||||
std.mem.eql(u8, name, "native-sdk.credentials.set") or
|
||||
std.mem.eql(u8, name, "native-sdk.credentials.get") or
|
||||
@@ -7943,6 +8222,10 @@ pub fn Effects(comptime Msg: type) type {
|
||||
}
|
||||
|
||||
fn performNativeHostRequest(self: *Self, name: []const u8, key: u64, payload: []const u8) void {
|
||||
if (std.mem.startsWith(u8, name, "core.store.")) {
|
||||
self.performBoundStoreRequest(name, key, payload);
|
||||
return;
|
||||
}
|
||||
if (std.mem.startsWith(u8, name, "native-sdk.credentials.") or
|
||||
std.mem.eql(u8, name, "native-sdk.time.formatLocal"))
|
||||
{
|
||||
@@ -7976,6 +8259,129 @@ pub fn Effects(comptime Msg: type) type {
|
||||
self.feedHostResult(key, true, launchAtLoginStatusName(status)) catch {};
|
||||
}
|
||||
|
||||
fn performBoundStoreRequest(self: *Self, name: []const u8, key: u64, payload: []const u8) void {
|
||||
const binding = self.record_store_binding orelse {
|
||||
self.feedHostResult(key, false, "rejected") catch {};
|
||||
return;
|
||||
};
|
||||
const op: EffectStoreOp = if (std.mem.eql(u8, name, "core.store.set"))
|
||||
.set
|
||||
else if (std.mem.eql(u8, name, "core.store.get"))
|
||||
.get
|
||||
else if (std.mem.eql(u8, name, "core.store.delete"))
|
||||
.delete
|
||||
else if (std.mem.eql(u8, name, "core.store.scan"))
|
||||
.scan
|
||||
else if (std.mem.eql(u8, name, "core.store.setMany"))
|
||||
.set_many
|
||||
else {
|
||||
self.feedHostResult(key, false, "rejected") catch {};
|
||||
return;
|
||||
};
|
||||
const output = self.allocator.alloc(u8, max_effect_store_result_bytes) catch {
|
||||
self.feedHostResult(key, false, "rejected") catch {};
|
||||
return;
|
||||
};
|
||||
defer self.allocator.free(output);
|
||||
const execution = binding.execute_fn(binding.context, op, payload, output);
|
||||
if (execution.len > output.len) {
|
||||
self.feedHostResult(key, false, "over_bound") catch {};
|
||||
return;
|
||||
}
|
||||
switch (execution.outcome) {
|
||||
.ok, .miss => self.feedHostResult(key, true, output[0..execution.len]) catch {},
|
||||
else => self.feedHostResult(key, false, record_store.outcomeName(execution.outcome)) catch {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Start one store write on the store-reserved worker family. The
|
||||
/// binding reserves a monotonic SQLite position on this loop thread;
|
||||
/// workers therefore commit in command-stream order even if the OS
|
||||
/// schedules their threads differently.
|
||||
fn startStoreWorker(self: *Self, slot_index: usize, operation: EffectStoreOp) void {
|
||||
const slot = &self.slots[slot_index];
|
||||
if (comptime !io_threaded_supported) {
|
||||
self.feedHostResult(slot.key, false, "rejected") catch {};
|
||||
return;
|
||||
}
|
||||
const binding = self.record_store_binding orelse {
|
||||
self.feedHostResult(slot.key, false, "rejected") catch {};
|
||||
return;
|
||||
};
|
||||
const payload = process_allocator.dupe(u8, slot.fetchPayload()) catch {
|
||||
self.feedHostResult(slot.key, false, "rejected") catch {};
|
||||
return;
|
||||
};
|
||||
const ctx = process_allocator.create(StoreWorkerContext) catch {
|
||||
process_allocator.free(payload);
|
||||
self.feedHostResult(slot.key, false, "rejected") catch {};
|
||||
return;
|
||||
};
|
||||
ctx.* = .{
|
||||
.binding = binding,
|
||||
.operation = operation,
|
||||
.sequence = 0,
|
||||
.payload = payload,
|
||||
};
|
||||
slot.store_ctx = ctx;
|
||||
const thread = std.Thread.spawn(.{}, storeWorkerMain, .{ self, slot_index, slot.generation, ctx }) catch {
|
||||
slot.store_ctx = null;
|
||||
process_allocator.free(payload);
|
||||
process_allocator.destroy(ctx);
|
||||
self.feedHostResult(slot.key, false, "rejected") catch {};
|
||||
return;
|
||||
};
|
||||
slot.worker_thread = thread;
|
||||
ctx.sequence = binding.reserve_write_fn(binding.context);
|
||||
ctx.sequence_ready.store(true, .release);
|
||||
}
|
||||
|
||||
fn storeWorkerMain(self: *Self, slot_index: usize, generation: u32, ctx: *StoreWorkerContext) void {
|
||||
while (!ctx.sequence_ready.load(.acquire)) std.atomic.spinLoopHint();
|
||||
const execution = ctx.binding.execute_write_fn(
|
||||
ctx.binding.context,
|
||||
ctx.sequence,
|
||||
ctx.operation,
|
||||
ctx.payload,
|
||||
&ctx.output,
|
||||
);
|
||||
ctx.outcome = execution.outcome;
|
||||
ctx.output_len = @min(execution.len, ctx.output.len);
|
||||
|
||||
const slot = &self.slots[slot_index];
|
||||
const buffer = slot.fetch_buffer orelse {
|
||||
slot.state.store(.done, .release);
|
||||
return;
|
||||
};
|
||||
const result: []const u8 = switch (execution.outcome) {
|
||||
.ok, .miss => ctx.output[0..ctx.output_len],
|
||||
else => record_store.outcomeName(execution.outcome),
|
||||
};
|
||||
const capacity = buffer.len - slot.payload_len;
|
||||
const within_bound = execution.len <= ctx.output.len and result.len <= capacity;
|
||||
const delivered = if (within_bound) result else "over_bound";
|
||||
@memcpy(buffer[slot.payload_len..][0..delivered.len], delivered);
|
||||
slot.body_len = delivered.len;
|
||||
var entry: Entry = .{
|
||||
.kind = .host,
|
||||
.slot_index = @intCast(slot_index),
|
||||
.generation = generation,
|
||||
.key = slot.key,
|
||||
.line_len = @intCast(delivered.len),
|
||||
.host_ok = within_bound and (execution.outcome == .ok or execution.outcome == .miss),
|
||||
.host_fn = slot.on_host,
|
||||
};
|
||||
while (!self.enqueue(&entry)) {
|
||||
if (self.shutdown.load(.acquire)) {
|
||||
slot.state.store(.done, .release);
|
||||
return;
|
||||
}
|
||||
std.atomic.spinLoopHint();
|
||||
}
|
||||
slot.state.store(.draining, .release);
|
||||
self.wakeHost();
|
||||
}
|
||||
|
||||
fn performBoundSystemRequest(self: *Self, name: []const u8, key: u64, payload: []const u8) void {
|
||||
const binding = self.system_services orelse {
|
||||
self.feedHostResult(key, false, "unsupported") catch {};
|
||||
@@ -8095,8 +8501,14 @@ pub fn Effects(comptime Msg: type) type {
|
||||
for (&self.slots) |*slot| {
|
||||
const state = slot.state.load(.acquire);
|
||||
if (state != .running and state != .draining) continue;
|
||||
if (slot.kind != .host or slot.key != key) continue;
|
||||
if (state == .running and !slot.fake) self.notifyHostCancel(key);
|
||||
if ((slot.kind != .host and slot.kind != .store) or slot.key != key) continue;
|
||||
if (slot.kind == .store and state == .running and !slot.fake and slot.worker_thread != null) {
|
||||
slot.cancelled_generation = slot.generation;
|
||||
slot.cancel_requested.store(true, .release);
|
||||
continue;
|
||||
}
|
||||
if (slot.kind == .host and state == .running and !slot.fake) self.notifyHostCancel(key);
|
||||
if (slot.kind == .store and state == .draining) joinWorker(slot);
|
||||
self.releaseFetchSlot(slot);
|
||||
// A queued result entry (fed, undrained) dies by
|
||||
// generation mismatch; zero marks "no occupancy".
|
||||
@@ -8145,7 +8557,7 @@ pub fn Effects(comptime Msg: type) type {
|
||||
return;
|
||||
};
|
||||
const slot = &self.slots[slot_index];
|
||||
if (slot.kind == .host) return self.cancelHostRequest(key);
|
||||
if (slot.kind == .host or slot.kind == .store) return self.cancelHostRequest(key);
|
||||
slot.cancelled_generation = slot.generation;
|
||||
slot.cancel_requested.store(true, .release);
|
||||
if (slot.fake) {
|
||||
@@ -10117,15 +10529,23 @@ pub fn Effects(comptime Msg: type) type {
|
||||
// `.response`: a mismatched generation means the
|
||||
// occupant was already retired (replaced or
|
||||
// cancelled — its result drops silently, per the
|
||||
// request contract). No consumer-side
|
||||
// `.draining` store is needed here (unlike the
|
||||
// worker-fed arms): host answers are fed on the
|
||||
// loop thread with the store sequenced before
|
||||
// the enqueue — and a same-key host request
|
||||
// REPLACES an in-flight one rather than
|
||||
// rejecting, so no handler retry hinges on the
|
||||
// state either way.
|
||||
// request contract).
|
||||
if (entry.generation != slot.generation) continue;
|
||||
// Ordinary host answers are fed on the loop
|
||||
// thread with `.draining` sequenced before the
|
||||
// enqueue. Store WRITES reuse this entry family,
|
||||
// but their workers post first and store
|
||||
// `.draining` immediately afterward so a full
|
||||
// queue cannot make a joinable slot block its own
|
||||
// producer. The drain can therefore race ahead of
|
||||
// that store. Retire a store occupancy here before
|
||||
// its terminal reaches update, matching the file
|
||||
// and image worker-fed arms: a handler that issues
|
||||
// another store command under the same key must be
|
||||
// able to reuse this slot instead of consuming a
|
||||
// second store slot (or rejecting at capacity).
|
||||
// The worker's later re-store is idempotent.
|
||||
if (slot.kind == .store) slot.state.store(.draining, .release);
|
||||
// Take buffer ownership so the slot can be
|
||||
// reused while `update` still reads the bytes.
|
||||
if (self.drain_fetch_body) |old| self.allocator.free(old);
|
||||
@@ -11208,7 +11628,7 @@ pub fn Effects(comptime Msg: type) type {
|
||||
pub fn pendingHostCount(self: *Self) usize {
|
||||
var count: usize = 0;
|
||||
for (&self.slots) |*slot| {
|
||||
if (slot.fake and slot.kind == .host and slot.state.load(.acquire) == .running) count += 1;
|
||||
if (slot.fake and (slot.kind == .host or slot.kind == .store) and slot.state.load(.acquire) == .running) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -11217,7 +11637,7 @@ pub fn Effects(comptime Msg: type) type {
|
||||
pub fn pendingHostAt(self: *Self, index: usize) ?HostRequest {
|
||||
var seen: usize = 0;
|
||||
for (&self.slots) |*slot| {
|
||||
if (!(slot.fake and slot.kind == .host and slot.state.load(.acquire) == .running)) continue;
|
||||
if (!(slot.fake and (slot.kind == .host or slot.kind == .store) and slot.state.load(.acquire) == .running)) continue;
|
||||
if (seen == index) {
|
||||
return .{
|
||||
.key = slot.key,
|
||||
@@ -11244,7 +11664,7 @@ pub fn Effects(comptime Msg: type) type {
|
||||
pub fn feedHostResult(self: *Self, key: u64, ok: bool, bytes: []const u8) error{EffectNotFound}!void {
|
||||
const slot_index = blk: {
|
||||
const index = self.findActiveSlot(key) orelse return error.EffectNotFound;
|
||||
if (self.slots[index].kind != .host) return error.EffectNotFound;
|
||||
if (self.slots[index].kind != .host and self.slots[index].kind != .store) return error.EffectNotFound;
|
||||
break :blk index;
|
||||
};
|
||||
const slot = &self.slots[slot_index];
|
||||
@@ -12817,7 +13237,14 @@ pub fn Effects(comptime Msg: type) type {
|
||||
}
|
||||
|
||||
fn findIdleSlot(self: *Self) ?usize {
|
||||
for (&self.slots, 0..) |*slot, index| {
|
||||
for (self.slots[0..max_effects], 0..) |*slot, index| {
|
||||
if (slot.state.load(.acquire) == .idle) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn findIdleStoreSlot(self: *Self) ?usize {
|
||||
for (self.slots[max_effects..], max_effects..) |*slot, index| {
|
||||
if (slot.state.load(.acquire) == .idle) return index;
|
||||
}
|
||||
return null;
|
||||
@@ -12915,7 +13342,7 @@ pub fn Effects(comptime Msg: type) type {
|
||||
fn slotTerminalUndelivered(slot: *const Slot) bool {
|
||||
return switch (slot.kind) {
|
||||
.spawn => slot.collect_buffer != null or slot.exit_undelivered,
|
||||
.fetch, .file, .clipboard, .host, .image => slot.fetch_buffer != null,
|
||||
.fetch, .file, .clipboard, .host, .store, .image => slot.fetch_buffer != null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12940,10 +13367,13 @@ pub fn Effects(comptime Msg: type) type {
|
||||
}
|
||||
|
||||
/// Join a finished worker's thread and clear its handle.
|
||||
/// Loop-thread only. The reclaim path reaches this only after
|
||||
/// the worker published a non-running slot state — its last
|
||||
/// slot access — so the join blocks for the thread's epilogue
|
||||
/// (a wake nudge and the OS exit), never on child I/O. The
|
||||
/// Loop-thread only. The reclaim path reaches this after either
|
||||
/// the worker published a non-running slot state or the consumer
|
||||
/// dequeued its terminal and idempotently published `.draining`.
|
||||
/// In the latter case the successful enqueue proves the effect
|
||||
/// work and result production are complete, so the join waits
|
||||
/// only for the producer's post-enqueue epilogue (a state store,
|
||||
/// wake nudge, and OS exit), never on child I/O. The
|
||||
/// teardown path (`deinit`) may join a still-running worker;
|
||||
/// convergence there is the kill's and the shutdown flag's
|
||||
/// doing, as documented at that call site.
|
||||
@@ -12966,6 +13396,11 @@ pub fn Effects(comptime Msg: type) type {
|
||||
destroySpawnContext(ctx);
|
||||
slot.spawn_ctx = null;
|
||||
}
|
||||
if (slot.store_ctx) |ctx| {
|
||||
process_allocator.free(ctx.payload);
|
||||
process_allocator.destroy(ctx);
|
||||
slot.store_ctx = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12988,8 +13423,9 @@ pub fn Effects(comptime Msg: type) type {
|
||||
// A draining slot is reusable once the drain
|
||||
// delivered its terminal (took the fetch body or
|
||||
// collected stdout, or cleared a `.lines` exit's
|
||||
// marker). Its worker is already finished either
|
||||
// way: retire the thread now.
|
||||
// marker). Its effect work is already finished either
|
||||
// way; retire the thread now (the join may wait for a
|
||||
// producer's short post-enqueue epilogue).
|
||||
.draining => {
|
||||
joinWorker(slot);
|
||||
if (slot.fetch_buffer == null and slot.collect_buffer == null and !slot.exit_undelivered) {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Tier-2 record-store effect coverage: the public Zig surface remains inert
|
||||
//! command data until Effects performs it, and every SQLite answer returns as
|
||||
//! the same routed/journaled host-result message family used by TS cores.
|
||||
|
||||
const std = @import("std");
|
||||
const effects_mod = @import("effects.zig");
|
||||
const record_store = @import("record_store.zig");
|
||||
|
||||
const Msg = union(enum) { result: effects_mod.EffectHostResult };
|
||||
const Fx = effects_mod.Effects(Msg);
|
||||
|
||||
fn takeResult(fx: *Fx) !effects_mod.EffectHostResult {
|
||||
for (0..100_000) |_| {
|
||||
if (fx.takeMsg()) |msg| return msg.result;
|
||||
std.Thread.yield() catch {};
|
||||
}
|
||||
return error.TestExpectedMsg;
|
||||
}
|
||||
|
||||
test "record-store effects round-trip CRUD and preserve empty versus missing" {
|
||||
var store = try record_store.Store.openMemory(std.testing.allocator);
|
||||
defer store.deinit();
|
||||
var fx = Fx.init(std.testing.allocator);
|
||||
defer fx.deinit();
|
||||
fx.bindRecordStore(store.binding());
|
||||
|
||||
fx.storeSet(.{ .key = 1, .record_key = "doc/1", .bytes = "", .on_result = Fx.hostMsg(.result) });
|
||||
const set = try takeResult(&fx);
|
||||
try std.testing.expect(set.ok);
|
||||
try std.testing.expectEqual(@as(usize, 0), set.bytes.len);
|
||||
|
||||
fx.storeGet(.{ .key = 2, .record_key = "doc/1", .on_result = Fx.hostMsg(.result) });
|
||||
const hit = try takeResult(&fx);
|
||||
try std.testing.expect(hit.ok);
|
||||
try std.testing.expectEqualSlices(u8, &.{1}, hit.bytes);
|
||||
|
||||
fx.storeDelete(.{ .key = 3, .record_key = "doc/1", .on_result = Fx.hostMsg(.result) });
|
||||
try std.testing.expect((try takeResult(&fx)).ok);
|
||||
fx.storeGet(.{ .key = 4, .record_key = "doc/1", .on_result = Fx.hostMsg(.result) });
|
||||
const miss = try takeResult(&fx);
|
||||
try std.testing.expect(miss.ok);
|
||||
try std.testing.expectEqualSlices(u8, &.{0}, miss.bytes);
|
||||
}
|
||||
|
||||
test "record-store effects apply setMany atomically and page prefix scans" {
|
||||
var store = try record_store.Store.openMemory(std.testing.allocator);
|
||||
defer store.deinit();
|
||||
var fx = Fx.init(std.testing.allocator);
|
||||
defer fx.deinit();
|
||||
fx.bindRecordStore(store.binding());
|
||||
|
||||
const entries = [_]Fx.StoreEntry{
|
||||
.{ .key = "chat/1", .bytes = "one" },
|
||||
.{ .key = "chat/2", .bytes = "two" },
|
||||
.{ .key = "other/1", .bytes = "skip" },
|
||||
};
|
||||
fx.storeSetMany(.{ .key = 10, .entries = &entries, .on_result = Fx.hostMsg(.result) });
|
||||
try std.testing.expect((try takeResult(&fx)).ok);
|
||||
|
||||
fx.storeScan(.{ .key = 11, .prefix = "chat/", .limit = 1, .on_result = Fx.hostMsg(.result) });
|
||||
const page = try takeResult(&fx);
|
||||
try std.testing.expect(page.ok);
|
||||
var cursor: usize = 0;
|
||||
try std.testing.expectEqual(@as(u32, 1), readU32(page.bytes, &cursor));
|
||||
try std.testing.expectEqualStrings("chat/1", readField(page.bytes, &cursor));
|
||||
try std.testing.expectEqualStrings("one", readField(page.bytes, &cursor));
|
||||
try std.testing.expectEqualStrings("chat/1", readField(page.bytes, &cursor));
|
||||
try std.testing.expectEqual(page.bytes.len, cursor);
|
||||
}
|
||||
|
||||
test "a synchronous get in the same command walk observes the preceding write" {
|
||||
var store = try record_store.Store.openMemory(std.testing.allocator);
|
||||
defer store.deinit();
|
||||
var fx = Fx.init(std.testing.allocator);
|
||||
defer fx.deinit();
|
||||
fx.bindRecordStore(store.binding());
|
||||
|
||||
fx.storeSet(.{ .key = 12, .record_key = "ordered/key", .bytes = "visible", .on_result = Fx.hostMsg(.result) });
|
||||
fx.storeGet(.{ .key = 13, .record_key = "ordered/key", .on_result = Fx.hostMsg(.result) });
|
||||
|
||||
const first = try takeResult(&fx);
|
||||
const second = try takeResult(&fx);
|
||||
try std.testing.expect(first.ok);
|
||||
try std.testing.expect(second.ok);
|
||||
const read = if (first.key == 13) first else second;
|
||||
try std.testing.expect(read.ok);
|
||||
try std.testing.expectEqualSlices(u8, "visible", read.bytes[1..]);
|
||||
}
|
||||
|
||||
test "record-store effects reject bounds and absent capability loudly" {
|
||||
var fx = Fx.init(std.testing.allocator);
|
||||
defer fx.deinit();
|
||||
fx.storeGet(.{ .key = 20, .record_key = "missing", .on_result = Fx.hostMsg(.result) });
|
||||
const absent = try takeResult(&fx);
|
||||
try std.testing.expect(!absent.ok);
|
||||
try std.testing.expectEqualStrings("rejected", absent.bytes);
|
||||
|
||||
var oversized_key: [record_store.max_key_bytes + 1]u8 = undefined;
|
||||
@memset(&oversized_key, 'k');
|
||||
fx.storeGet(.{ .key = 21, .record_key = &oversized_key, .on_result = Fx.hostMsg(.result) });
|
||||
const bounded = try takeResult(&fx);
|
||||
try std.testing.expect(!bounded.ok);
|
||||
try std.testing.expectEqualStrings("bad_key", bounded.bytes);
|
||||
}
|
||||
|
||||
test "record-store requests have capacity independent of generic effects" {
|
||||
var fx = Fx.init(std.testing.allocator);
|
||||
defer fx.deinit();
|
||||
fx.executor = .fake;
|
||||
|
||||
for (0..effects_mod.max_effects) |index| {
|
||||
fx.hostRequest(.{
|
||||
.key = 100 + index,
|
||||
.name = "fixture.request",
|
||||
.on_result = Fx.hostMsg(.result),
|
||||
});
|
||||
}
|
||||
for (0..effects_mod.max_store_effects) |index| {
|
||||
fx.storeGet(.{
|
||||
.key = 1_000 + index,
|
||||
.record_key = "fixture/key",
|
||||
.on_result = Fx.hostMsg(.result),
|
||||
});
|
||||
}
|
||||
try std.testing.expectEqual(
|
||||
effects_mod.max_effects + effects_mod.max_store_effects,
|
||||
fx.pendingHostCount(),
|
||||
);
|
||||
|
||||
fx.storeGet(.{
|
||||
.key = 2_000,
|
||||
.record_key = "fixture/overflow",
|
||||
.on_result = Fx.hostMsg(.result),
|
||||
});
|
||||
const refused = try takeResult(&fx);
|
||||
try std.testing.expect(!refused.ok);
|
||||
try std.testing.expectEqualStrings("rejected", refused.bytes);
|
||||
}
|
||||
|
||||
test "record-store keys replace on reissue and cancel silently" {
|
||||
var fx = Fx.init(std.testing.allocator);
|
||||
defer fx.deinit();
|
||||
fx.executor = .fake;
|
||||
|
||||
fx.storeGet(.{ .key = 30, .record_key = "old/key", .on_result = Fx.hostMsg(.result) });
|
||||
fx.storeGet(.{ .key = 30, .record_key = "new/key", .on_result = Fx.hostMsg(.result) });
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingHostCount());
|
||||
const replacement = fx.pendingHostAt(0).?;
|
||||
try std.testing.expectEqualStrings("core.store.get", replacement.name);
|
||||
var payload_at: usize = 4;
|
||||
try std.testing.expectEqualStrings("new/key", readField(replacement.payload, &payload_at));
|
||||
try std.testing.expectEqual(replacement.payload.len, payload_at);
|
||||
try fx.feedHostResult(30, true, &.{ 1, 'n', 'e', 'w' });
|
||||
const result = try takeResult(&fx);
|
||||
try std.testing.expectEqual(@as(u64, 30), result.key);
|
||||
try std.testing.expectEqualSlices(u8, &.{ 1, 'n', 'e', 'w' }, result.bytes);
|
||||
|
||||
fx.storeScan(.{ .key = 31, .prefix = "chat/", .on_result = Fx.hostMsg(.result) });
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingHostCount());
|
||||
fx.cancel(31);
|
||||
try std.testing.expectEqual(@as(usize, 0), fx.pendingHostCount());
|
||||
try std.testing.expectError(error.EffectNotFound, fx.feedHostResult(31, true, &.{ 0, 0, 0, 0, 0, 0, 0, 0 }));
|
||||
try std.testing.expect(fx.takeMsg() == null);
|
||||
}
|
||||
|
||||
test "draining a store terminal retires a producer preempted after enqueue" {
|
||||
var fx = Fx.init(std.testing.allocator);
|
||||
defer fx.deinit();
|
||||
fx.executor = .fake;
|
||||
|
||||
const key = 32;
|
||||
fx.storeGet(.{ .key = key, .record_key = "doc/32", .on_result = Fx.hostMsg(.result) });
|
||||
try fx.feedHostResult(key, true, &.{0});
|
||||
|
||||
// Reconstruct the real write worker's preemption window exactly:
|
||||
// `storeWorkerMain` publishes the terminal entry, then stores
|
||||
// `.draining`. A consumer can acquire the queue between those two
|
||||
// operations and observe the queued result while the slot still says
|
||||
// `.running`. The fake feed stores first, so rewind just that state.
|
||||
for (&fx.slots) |*slot| {
|
||||
if (slot.kind == .store and slot.key == key) slot.state.store(.running, .release);
|
||||
}
|
||||
|
||||
const result = try takeResult(&fx);
|
||||
try std.testing.expect(result.ok);
|
||||
try std.testing.expectEqualSlices(u8, &.{0}, result.bytes);
|
||||
|
||||
// Delivery is the key-freeing instant. The drain must publish the
|
||||
// terminal state itself rather than depending on the preempted worker
|
||||
// to resume before update handles the result.
|
||||
for (&fx.slots) |*slot| {
|
||||
if (slot.kind != .store or slot.key != key) continue;
|
||||
try std.testing.expectEqual(.draining, slot.state.load(.acquire));
|
||||
try std.testing.expect(slot.fetch_buffer == null);
|
||||
return;
|
||||
}
|
||||
return error.TestExpectedStoreSlot;
|
||||
}
|
||||
|
||||
fn readU32(bytes: []const u8, cursor: *usize) u32 {
|
||||
const value = std.mem.readInt(u32, bytes[cursor.*..][0..4], .little);
|
||||
cursor.* += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
fn readField(bytes: []const u8, cursor: *usize) []const u8 {
|
||||
const len: usize = @intCast(readU32(bytes, cursor));
|
||||
const field = bytes[cursor.*..][0..len];
|
||||
cursor.* += len;
|
||||
return field;
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
//! SQLite-backed, engine-owned record store. The public effect layer passes
|
||||
//! bounded wire payloads through `execute`; this module owns schema, SQL,
|
||||
//! transactions, byte ordering, and page framing.
|
||||
|
||||
const std = @import("std");
|
||||
const sqlite = @import("sqlite_engine.zig");
|
||||
|
||||
pub const max_key_bytes: usize = 512;
|
||||
pub const max_value_bytes: usize = 1024 * 1024;
|
||||
pub const max_batch_entries: usize = 64;
|
||||
pub const max_batch_bytes: usize = 8 * 1024 * 1024;
|
||||
pub const default_scan_limit: u32 = 100;
|
||||
pub const max_scan_limit: u32 = 256;
|
||||
/// Enough for one maximum-sized record plus its key and page framing. Larger
|
||||
/// scans end at a record boundary and return that record's predecessor as the
|
||||
/// next cursor; no key or value is ever cut.
|
||||
pub const max_result_bytes: usize = max_value_bytes + (2 * max_key_bytes) + 32;
|
||||
pub const schema_version: u32 = 1;
|
||||
|
||||
pub const Outcome = enum(u8) {
|
||||
ok,
|
||||
miss,
|
||||
io_failed,
|
||||
over_bound,
|
||||
bad_key,
|
||||
rejected,
|
||||
busy,
|
||||
};
|
||||
|
||||
pub const Operation = enum(u8) { set, get, delete, scan, set_many };
|
||||
|
||||
pub const Execution = struct {
|
||||
outcome: Outcome,
|
||||
len: usize = 0,
|
||||
};
|
||||
|
||||
pub const Binding = struct {
|
||||
context: *anyopaque,
|
||||
execute_fn: *const fn (context: *anyopaque, op: Operation, payload: []const u8, output: []u8) Execution,
|
||||
reserve_write_fn: *const fn (context: *anyopaque) u64,
|
||||
execute_write_fn: *const fn (context: *anyopaque, sequence: u64, op: Operation, payload: []const u8, output: []u8) Execution,
|
||||
};
|
||||
|
||||
const schema =
|
||||
"CREATE TABLE IF NOT EXISTS kv (" ++
|
||||
"scope INTEGER NOT NULL DEFAULT 0," ++
|
||||
"k BLOB NOT NULL," ++
|
||||
"v BLOB NOT NULL," ++
|
||||
"PRIMARY KEY(scope,k)) WITHOUT ROWID;";
|
||||
|
||||
pub const Store = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
write_db: sqlite.Connection,
|
||||
/// Durable stores keep reads on their own query-only connection so a
|
||||
/// cached get/scan never borrows the writer. In-memory tests intentionally
|
||||
/// leave this null: SQLite's `:memory:` database belongs to one connection.
|
||||
read_db: ?sqlite.Connection = null,
|
||||
path: [:0]u8,
|
||||
write_mutex: std.atomic.Mutex = .unlocked,
|
||||
read_mutex: std.atomic.Mutex = .unlocked,
|
||||
next_write_sequence: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
|
||||
reserved_write_sequence: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
|
||||
|
||||
pub fn open(allocator: std.mem.Allocator, data_dir: []const u8) !Store {
|
||||
const path_plain = try std.fs.path.join(allocator, &.{ data_dir, "store.db" });
|
||||
defer allocator.free(path_plain);
|
||||
const path = try allocator.dupeZ(u8, path_plain);
|
||||
errdefer allocator.free(path);
|
||||
var write_db = try sqlite.Connection.open(path);
|
||||
errdefer write_db.close();
|
||||
try initialize(&write_db, true);
|
||||
var read_db = try sqlite.Connection.open(path);
|
||||
errdefer read_db.close();
|
||||
try read_db.exec("PRAGMA busy_timeout=250;");
|
||||
try read_db.exec("PRAGMA query_only=ON;");
|
||||
return .{ .allocator = allocator, .write_db = write_db, .read_db = read_db, .path = path };
|
||||
}
|
||||
|
||||
pub fn openMemory(allocator: std.mem.Allocator) !Store {
|
||||
const path = try allocator.dupeZ(u8, ":memory:");
|
||||
errdefer allocator.free(path);
|
||||
var write_db = try sqlite.Connection.open(path);
|
||||
errdefer write_db.close();
|
||||
try initialize(&write_db, false);
|
||||
return .{ .allocator = allocator, .write_db = write_db, .path = path };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Store) void {
|
||||
self.lockWrite();
|
||||
if (self.read_db) |*db| {
|
||||
self.lockRead();
|
||||
db.close();
|
||||
self.unlockRead();
|
||||
}
|
||||
self.write_db.close();
|
||||
self.unlockWrite();
|
||||
self.allocator.free(self.path);
|
||||
}
|
||||
|
||||
pub fn binding(self: *Store) Binding {
|
||||
return .{
|
||||
.context = self,
|
||||
.execute_fn = executeBound,
|
||||
.reserve_write_fn = reserveWriteBound,
|
||||
.execute_write_fn = executeWriteBound,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn execute(self: *Store, op: Operation, payload: []const u8, output: []u8) Execution {
|
||||
// Reads stay on the issuing thread, but they may not pass a write
|
||||
// already reserved by an earlier command in the same walk. No later
|
||||
// write can be reserved while that walk is waiting here, so this
|
||||
// snapshot is the exact command-stream barrier the read owes.
|
||||
const write_barrier = self.reserved_write_sequence.load(.acquire);
|
||||
while (self.next_write_sequence.load(.acquire) < write_barrier) std.Thread.yield() catch {};
|
||||
return self.executeRaw(op, payload, output);
|
||||
}
|
||||
|
||||
fn executeRaw(self: *Store, op: Operation, payload: []const u8, output: []u8) Execution {
|
||||
return switch (op) {
|
||||
.get => blk: {
|
||||
self.lockRead();
|
||||
defer self.unlockRead();
|
||||
break :blk self.executeGet(payload, output);
|
||||
},
|
||||
.scan => blk: {
|
||||
self.lockRead();
|
||||
defer self.unlockRead();
|
||||
break :blk self.executeScan(payload, output);
|
||||
},
|
||||
.set => blk: {
|
||||
self.lockWrite();
|
||||
defer self.unlockWrite();
|
||||
break :blk self.executeSet(payload);
|
||||
},
|
||||
.delete => blk: {
|
||||
self.lockWrite();
|
||||
defer self.unlockWrite();
|
||||
break :blk self.executeDelete(payload);
|
||||
},
|
||||
.set_many => blk: {
|
||||
self.lockWrite();
|
||||
defer self.unlockWrite();
|
||||
break :blk self.executeSetMany(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn executeBound(context: *anyopaque, op: Operation, payload: []const u8, output: []u8) Execution {
|
||||
const self: *Store = @ptrCast(@alignCast(context));
|
||||
return self.execute(op, payload, output);
|
||||
}
|
||||
|
||||
/// Reserve a writer position on the loop thread. Workers may start in
|
||||
/// any OS scheduling order, but `executeWrite` admits them to SQLite in
|
||||
/// the exact order their effects were issued.
|
||||
pub fn reserveWrite(self: *Store) u64 {
|
||||
return self.reserved_write_sequence.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
pub fn executeWrite(self: *Store, sequence: u64, op: Operation, payload: []const u8, output: []u8) Execution {
|
||||
while (sequence != self.next_write_sequence.load(.acquire)) std.Thread.yield() catch {};
|
||||
defer _ = self.next_write_sequence.fetchAdd(1, .release);
|
||||
return self.executeRaw(op, payload, output);
|
||||
}
|
||||
|
||||
fn reserveWriteBound(context: *anyopaque) u64 {
|
||||
const self: *Store = @ptrCast(@alignCast(context));
|
||||
return self.reserveWrite();
|
||||
}
|
||||
|
||||
fn executeWriteBound(context: *anyopaque, sequence: u64, op: Operation, payload: []const u8, output: []u8) Execution {
|
||||
const self: *Store = @ptrCast(@alignCast(context));
|
||||
return self.executeWrite(sequence, op, payload, output);
|
||||
}
|
||||
|
||||
fn executeSet(self: *Store, payload: []const u8) Execution {
|
||||
var cursor: Cursor = .{ .bytes = payload };
|
||||
const scope = cursor.int(u32) orelse return .{ .outcome = .rejected };
|
||||
const key = cursor.bytesField() orelse return .{ .outcome = .rejected };
|
||||
const value = cursor.bytesField() orelse return .{ .outcome = .rejected };
|
||||
if (!cursor.done()) return .{ .outcome = .rejected };
|
||||
if (!validKey(key)) return .{ .outcome = .bad_key };
|
||||
if (value.len > max_value_bytes) return .{ .outcome = .over_bound };
|
||||
|
||||
var statement = self.write_db.prepare("INSERT INTO kv(scope,k,v) VALUES(?1,?2,?3) ON CONFLICT(scope,k) DO UPDATE SET v=excluded.v;") catch |err| return failure(err);
|
||||
defer statement.finalize();
|
||||
statement.bindInt(1, scope) catch |err| return failure(err);
|
||||
statement.bindBlob(2, key) catch |err| return failure(err);
|
||||
statement.bindBlob(3, value) catch |err| return failure(err);
|
||||
_ = statement.step() catch |err| return failure(err);
|
||||
return .{ .outcome = .ok };
|
||||
}
|
||||
|
||||
fn executeGet(self: *Store, payload: []const u8, output: []u8) Execution {
|
||||
var cursor: Cursor = .{ .bytes = payload };
|
||||
const scope = cursor.int(u32) orelse return .{ .outcome = .rejected };
|
||||
const key = cursor.bytesField() orelse return .{ .outcome = .rejected };
|
||||
if (!cursor.done()) return .{ .outcome = .rejected };
|
||||
if (!validKey(key)) return .{ .outcome = .bad_key };
|
||||
|
||||
var statement = self.readConnection().prepare("SELECT v FROM kv WHERE scope=?1 AND k=?2;") catch |err| return failure(err);
|
||||
defer statement.finalize();
|
||||
statement.bindInt(1, scope) catch |err| return failure(err);
|
||||
statement.bindBlob(2, key) catch |err| return failure(err);
|
||||
switch (statement.step() catch |err| return failure(err)) {
|
||||
.done => {
|
||||
if (output.len < 1) return .{ .outcome = .over_bound };
|
||||
output[0] = 0;
|
||||
return .{ .outcome = .miss, .len = 1 };
|
||||
},
|
||||
.row => {
|
||||
const value = statement.columnBlob(0);
|
||||
if (value.len > max_value_bytes or output.len < value.len + 1) return .{ .outcome = .over_bound };
|
||||
output[0] = 1;
|
||||
@memcpy(output[1 .. value.len + 1], value);
|
||||
return .{ .outcome = .ok, .len = value.len + 1 };
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn executeDelete(self: *Store, payload: []const u8) Execution {
|
||||
var cursor: Cursor = .{ .bytes = payload };
|
||||
const scope = cursor.int(u32) orelse return .{ .outcome = .rejected };
|
||||
const key = cursor.bytesField() orelse return .{ .outcome = .rejected };
|
||||
if (!cursor.done()) return .{ .outcome = .rejected };
|
||||
if (!validKey(key)) return .{ .outcome = .bad_key };
|
||||
var statement = self.write_db.prepare("DELETE FROM kv WHERE scope=?1 AND k=?2;") catch |err| return failure(err);
|
||||
defer statement.finalize();
|
||||
statement.bindInt(1, scope) catch |err| return failure(err);
|
||||
statement.bindBlob(2, key) catch |err| return failure(err);
|
||||
_ = statement.step() catch |err| return failure(err);
|
||||
return .{ .outcome = .ok };
|
||||
}
|
||||
|
||||
fn executeScan(self: *Store, payload: []const u8, output: []u8) Execution {
|
||||
var cursor: Cursor = .{ .bytes = payload };
|
||||
const scope = cursor.int(u32) orelse return .{ .outcome = .rejected };
|
||||
const prefix = cursor.bytesField() orelse return .{ .outcome = .rejected };
|
||||
const requested_limit = cursor.int(u32) orelse return .{ .outcome = .rejected };
|
||||
const after = cursor.bytesField() orelse return .{ .outcome = .rejected };
|
||||
if (!cursor.done()) return .{ .outcome = .rejected };
|
||||
if (!validPrefix(prefix) or (after.len > 0 and !validKey(after))) return .{ .outcome = .bad_key };
|
||||
const limit = if (requested_limit == 0) default_scan_limit else requested_limit;
|
||||
if (limit > max_scan_limit) return .{ .outcome = .over_bound };
|
||||
if (output.len < 8) return .{ .outcome = .over_bound };
|
||||
|
||||
var statement = self.readConnection().prepare(
|
||||
"SELECT k,v FROM kv WHERE scope=?1 " ++
|
||||
"AND (?2=X'' OR substr(k,1,length(?2))=?2) " ++
|
||||
"AND (?3=X'' OR k>?3) ORDER BY k LIMIT ?4;",
|
||||
) catch |err| return failure(err);
|
||||
defer statement.finalize();
|
||||
statement.bindInt(1, scope) catch |err| return failure(err);
|
||||
statement.bindBlob(2, prefix) catch |err| return failure(err);
|
||||
statement.bindBlob(3, after) catch |err| return failure(err);
|
||||
statement.bindInt(4, @as(i64, limit) + 1) catch |err| return failure(err);
|
||||
|
||||
var writer = PageWriter.init(output);
|
||||
writer.writeInt(u32, 0) catch return .{ .outcome = .over_bound };
|
||||
var count: u32 = 0;
|
||||
var last_key_buffer: [max_key_bytes]u8 = undefined;
|
||||
var last_key_len: usize = 0;
|
||||
var has_more = false;
|
||||
while (true) {
|
||||
switch (statement.step() catch |err| return failure(err)) {
|
||||
.done => break,
|
||||
.row => {
|
||||
const key = statement.columnBlob(0);
|
||||
const value = statement.columnBlob(1);
|
||||
if (count >= limit) {
|
||||
has_more = true;
|
||||
break;
|
||||
}
|
||||
const needed = 4 + key.len + 4 + value.len + 4 + key.len;
|
||||
if (writer.remaining() < needed) {
|
||||
// The row is left whole for the next page. A first row
|
||||
// always fits by max_result_bytes' definition.
|
||||
if (count == 0) return .{ .outcome = .over_bound };
|
||||
has_more = true;
|
||||
break;
|
||||
}
|
||||
writer.writeBytes(key) catch unreachable;
|
||||
writer.writeBytes(value) catch unreachable;
|
||||
count += 1;
|
||||
@memcpy(last_key_buffer[0..key.len], key);
|
||||
last_key_len = key.len;
|
||||
},
|
||||
}
|
||||
}
|
||||
std.mem.writeInt(u32, output[0..4], count, .little);
|
||||
writer.writeBytes(if (has_more) last_key_buffer[0..last_key_len] else "") catch return .{ .outcome = .over_bound };
|
||||
return .{ .outcome = .ok, .len = writer.at };
|
||||
}
|
||||
|
||||
fn executeSetMany(self: *Store, payload: []const u8) Execution {
|
||||
if (payload.len > max_batch_bytes) return .{ .outcome = .over_bound };
|
||||
var cursor: Cursor = .{ .bytes = payload };
|
||||
const scope = cursor.int(u32) orelse return .{ .outcome = .rejected };
|
||||
const count = cursor.int(u32) orelse return .{ .outcome = .rejected };
|
||||
if (count == 0 or count > max_batch_entries) return .{ .outcome = .over_bound };
|
||||
|
||||
// Validate the complete envelope before opening a transaction. This
|
||||
// keeps malformed batches out of SQLite altogether.
|
||||
var validation = cursor;
|
||||
for (0..count) |_| {
|
||||
const key = validation.bytesField() orelse return .{ .outcome = .rejected };
|
||||
const value = validation.bytesField() orelse return .{ .outcome = .rejected };
|
||||
if (!validKey(key)) return .{ .outcome = .bad_key };
|
||||
if (value.len > max_value_bytes) return .{ .outcome = .over_bound };
|
||||
}
|
||||
if (!validation.done()) return .{ .outcome = .rejected };
|
||||
|
||||
self.write_db.exec("BEGIN IMMEDIATE;") catch |err| return failure(err);
|
||||
var committed = false;
|
||||
defer if (!committed) self.write_db.exec("ROLLBACK;") catch {};
|
||||
var statement = self.write_db.prepare("INSERT INTO kv(scope,k,v) VALUES(?1,?2,?3) ON CONFLICT(scope,k) DO UPDATE SET v=excluded.v;") catch |err| return failure(err);
|
||||
defer statement.finalize();
|
||||
for (0..count) |_| {
|
||||
const key = cursor.bytesField().?;
|
||||
const value = cursor.bytesField().?;
|
||||
statement.bindInt(1, scope) catch |err| return failure(err);
|
||||
statement.bindBlob(2, key) catch |err| return failure(err);
|
||||
statement.bindBlob(3, value) catch |err| return failure(err);
|
||||
_ = statement.step() catch |err| return failure(err);
|
||||
statement.reset() catch |err| return failure(err);
|
||||
}
|
||||
std.debug.assert(cursor.done());
|
||||
self.write_db.exec("COMMIT;") catch |err| return failure(err);
|
||||
committed = true;
|
||||
return .{ .outcome = .ok };
|
||||
}
|
||||
|
||||
fn readConnection(self: *Store) *sqlite.Connection {
|
||||
if (self.read_db) |*db| return db;
|
||||
return &self.write_db;
|
||||
}
|
||||
|
||||
fn lockWrite(self: *Store) void {
|
||||
while (!self.write_mutex.tryLock()) std.atomic.spinLoopHint();
|
||||
}
|
||||
|
||||
fn unlockWrite(self: *Store) void {
|
||||
self.write_mutex.unlock();
|
||||
}
|
||||
|
||||
fn lockRead(self: *Store) void {
|
||||
// An in-memory store has no second connection, so its reads share the
|
||||
// writer mutex as well as the writer handle.
|
||||
const mutex = if (self.read_db != null) &self.read_mutex else &self.write_mutex;
|
||||
while (!mutex.tryLock()) std.atomic.spinLoopHint();
|
||||
}
|
||||
|
||||
fn unlockRead(self: *Store) void {
|
||||
const mutex = if (self.read_db != null) &self.read_mutex else &self.write_mutex;
|
||||
mutex.unlock();
|
||||
}
|
||||
};
|
||||
|
||||
fn initialize(db: *sqlite.Connection, durable: bool) !void {
|
||||
const version = blk: {
|
||||
var version_query = try db.prepare("PRAGMA user_version;");
|
||||
defer version_query.finalize();
|
||||
if (try version_query.step() != .row) return error.InvalidSchemaVersion;
|
||||
break :blk version_query.columnInt(0);
|
||||
};
|
||||
if (version < 0 or version > schema_version) return error.VersionUnknown;
|
||||
if (durable) {
|
||||
try db.exec("PRAGMA journal_mode=WAL;");
|
||||
try db.exec("PRAGMA synchronous=NORMAL;");
|
||||
try db.exec("PRAGMA busy_timeout=250;");
|
||||
}
|
||||
try db.exec(schema);
|
||||
if (version == 0) try db.exec("PRAGMA user_version=1;");
|
||||
}
|
||||
|
||||
pub fn outcomeName(outcome: Outcome) []const u8 {
|
||||
return @tagName(outcome);
|
||||
}
|
||||
|
||||
fn failure(err: sqlite.Error) Execution {
|
||||
return .{ .outcome = if (err == error.Busy) .busy else .io_failed };
|
||||
}
|
||||
|
||||
pub fn validKey(key: []const u8) bool {
|
||||
return key.len > 0 and key.len <= max_key_bytes and std.unicode.utf8ValidateSlice(key);
|
||||
}
|
||||
|
||||
pub fn validPrefix(prefix: []const u8) bool {
|
||||
return prefix.len <= max_key_bytes and std.unicode.utf8ValidateSlice(prefix);
|
||||
}
|
||||
|
||||
const Cursor = struct {
|
||||
bytes: []const u8,
|
||||
at: usize = 0,
|
||||
|
||||
fn int(self: *Cursor, comptime T: type) ?T {
|
||||
if (self.at > self.bytes.len or self.bytes.len - self.at < @sizeOf(T)) return null;
|
||||
const value = std.mem.readInt(T, self.bytes[self.at..][0..@sizeOf(T)], .little);
|
||||
self.at += @sizeOf(T);
|
||||
return value;
|
||||
}
|
||||
|
||||
fn bytesField(self: *Cursor) ?[]const u8 {
|
||||
const len: usize = self.int(u32) orelse return null;
|
||||
if (len > self.bytes.len - self.at) return null;
|
||||
const value = self.bytes[self.at..][0..len];
|
||||
self.at += len;
|
||||
return value;
|
||||
}
|
||||
|
||||
fn done(self: *const Cursor) bool {
|
||||
return self.at == self.bytes.len;
|
||||
}
|
||||
};
|
||||
|
||||
const PageWriter = struct {
|
||||
buffer: []u8,
|
||||
at: usize = 0,
|
||||
|
||||
fn init(buffer: []u8) PageWriter {
|
||||
return .{ .buffer = buffer };
|
||||
}
|
||||
|
||||
fn remaining(self: *const PageWriter) usize {
|
||||
return self.buffer.len - self.at;
|
||||
}
|
||||
|
||||
fn writeInt(self: *PageWriter, comptime T: type, value: T) error{NoSpace}!void {
|
||||
if (self.remaining() < @sizeOf(T)) return error.NoSpace;
|
||||
std.mem.writeInt(T, self.buffer[self.at..][0..@sizeOf(T)], value, .little);
|
||||
self.at += @sizeOf(T);
|
||||
}
|
||||
|
||||
fn writeBytes(self: *PageWriter, bytes: []const u8) error{NoSpace}!void {
|
||||
if (bytes.len > std.math.maxInt(u32) or self.remaining() < 4 + bytes.len) return error.NoSpace;
|
||||
try self.writeInt(u32, @intCast(bytes.len));
|
||||
@memcpy(self.buffer[self.at..][0..bytes.len], bytes);
|
||||
self.at += bytes.len;
|
||||
}
|
||||
};
|
||||
|
||||
fn request(allocator: std.mem.Allocator, scope: u32, fields: []const []const u8) ![]u8 {
|
||||
var len: usize = 4;
|
||||
for (fields) |field| len += 4 + field.len;
|
||||
const bytes = try allocator.alloc(u8, len);
|
||||
std.mem.writeInt(u32, bytes[0..4], scope, .little);
|
||||
var writer = PageWriter{ .buffer = bytes, .at = 4 };
|
||||
for (fields) |field| try writer.writeBytes(field);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
fn scanRequest(allocator: std.mem.Allocator, scope: u32, prefix: []const u8, limit: u32, after: []const u8) ![]u8 {
|
||||
const bytes = try allocator.alloc(u8, 16 + prefix.len + after.len);
|
||||
var writer = PageWriter.init(bytes);
|
||||
try writer.writeInt(u32, scope);
|
||||
try writer.writeBytes(prefix);
|
||||
try writer.writeInt(u32, limit);
|
||||
try writer.writeBytes(after);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const TestEntry = struct { key: []const u8, value: []const u8 };
|
||||
|
||||
fn setManyRequest(allocator: std.mem.Allocator, scope: u32, entries: []const TestEntry) ![]u8 {
|
||||
var len: usize = 8;
|
||||
for (entries) |entry| len += 8 + entry.key.len + entry.value.len;
|
||||
const bytes = try allocator.alloc(u8, len);
|
||||
var writer = PageWriter.init(bytes);
|
||||
try writer.writeInt(u32, scope);
|
||||
try writer.writeInt(u32, @intCast(entries.len));
|
||||
for (entries) |entry| {
|
||||
try writer.writeBytes(entry.key);
|
||||
try writer.writeBytes(entry.value);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
test "durable stores read through a dedicated WAL connection" {
|
||||
const allocator = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buffer: [256]u8 = undefined;
|
||||
const path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/record-store", .{tmp.sub_path[0..]});
|
||||
try std.Io.Dir.cwd().createDirPath(std.testing.io, path);
|
||||
var store = try Store.open(allocator, path);
|
||||
defer store.deinit();
|
||||
try std.testing.expect(store.read_db != null);
|
||||
|
||||
var output: [max_result_bytes]u8 = undefined;
|
||||
const set_payload = try request(allocator, 0, &.{ "durable/key", "value" });
|
||||
defer allocator.free(set_payload);
|
||||
try std.testing.expectEqual(Outcome.ok, store.execute(.set, set_payload, &output).outcome);
|
||||
const get_payload = try request(allocator, 0, &.{"durable/key"});
|
||||
defer allocator.free(get_payload);
|
||||
const found = store.execute(.get, get_payload, &output);
|
||||
try std.testing.expectEqual(Outcome.ok, found.outcome);
|
||||
try std.testing.expectEqualSlices(u8, "value", output[1..found.len]);
|
||||
}
|
||||
|
||||
test "record store CRUD distinguishes empty values from misses" {
|
||||
const allocator = std.testing.allocator;
|
||||
var store = try Store.openMemory(allocator);
|
||||
defer store.deinit();
|
||||
var output: [max_result_bytes]u8 = undefined;
|
||||
|
||||
const set_payload = try request(allocator, 0, &.{ "doc/a", "" });
|
||||
defer allocator.free(set_payload);
|
||||
try std.testing.expectEqual(Outcome.ok, store.execute(.set, set_payload, &output).outcome);
|
||||
|
||||
const get_payload = try request(allocator, 0, &.{"doc/a"});
|
||||
defer allocator.free(get_payload);
|
||||
const found = store.execute(.get, get_payload, &output);
|
||||
try std.testing.expectEqual(Outcome.ok, found.outcome);
|
||||
try std.testing.expectEqualSlices(u8, &.{1}, output[0..found.len]);
|
||||
|
||||
const missing_payload = try request(allocator, 0, &.{"doc/missing"});
|
||||
defer allocator.free(missing_payload);
|
||||
const missing = store.execute(.get, missing_payload, &output);
|
||||
try std.testing.expectEqual(Outcome.miss, missing.outcome);
|
||||
try std.testing.expectEqualSlices(u8, &.{0}, output[0..missing.len]);
|
||||
|
||||
try std.testing.expectEqual(Outcome.ok, store.execute(.delete, get_payload, &output).outcome);
|
||||
try std.testing.expectEqual(Outcome.miss, store.execute(.get, get_payload, &output).outcome);
|
||||
}
|
||||
|
||||
test "record store scan is byte ordered and paginated" {
|
||||
const allocator = std.testing.allocator;
|
||||
var store = try Store.openMemory(allocator);
|
||||
defer store.deinit();
|
||||
var output: [max_result_bytes]u8 = undefined;
|
||||
const keys = [_][]const u8{ "chat/1", "chat/2", "chat/3", "other/1" };
|
||||
for (&keys) |key| {
|
||||
const payload = try request(allocator, 0, &.{ key, key });
|
||||
defer allocator.free(payload);
|
||||
try std.testing.expectEqual(Outcome.ok, store.execute(.set, payload, &output).outcome);
|
||||
}
|
||||
|
||||
const first_request = try scanRequest(allocator, 0, "chat/", 2, "");
|
||||
defer allocator.free(first_request);
|
||||
const first_page = store.execute(.scan, first_request, &output);
|
||||
try std.testing.expectEqual(Outcome.ok, first_page.outcome);
|
||||
var first = Cursor{ .bytes = output[0..first_page.len] };
|
||||
try std.testing.expectEqual(@as(u32, 2), first.int(u32).?);
|
||||
try std.testing.expectEqualStrings("chat/1", first.bytesField().?);
|
||||
try std.testing.expectEqualStrings("chat/1", first.bytesField().?);
|
||||
try std.testing.expectEqualStrings("chat/2", first.bytesField().?);
|
||||
try std.testing.expectEqualStrings("chat/2", first.bytesField().?);
|
||||
const next = first.bytesField().?;
|
||||
var next_copy: [max_key_bytes]u8 = undefined;
|
||||
@memcpy(next_copy[0..next.len], next);
|
||||
try std.testing.expectEqualStrings("chat/2", next);
|
||||
try std.testing.expect(first.done());
|
||||
|
||||
const second_request = try scanRequest(allocator, 0, "chat/", 2, next_copy[0..next.len]);
|
||||
defer allocator.free(second_request);
|
||||
const second_page = store.execute(.scan, second_request, &output);
|
||||
try std.testing.expectEqual(Outcome.ok, second_page.outcome);
|
||||
var second = Cursor{ .bytes = output[0..second_page.len] };
|
||||
try std.testing.expectEqual(@as(u32, 1), second.int(u32).?);
|
||||
try std.testing.expectEqualStrings("chat/3", second.bytesField().?);
|
||||
try std.testing.expectEqualStrings("chat/3", second.bytesField().?);
|
||||
try std.testing.expectEqual(@as(usize, 0), second.bytesField().?.len);
|
||||
try std.testing.expect(second.done());
|
||||
}
|
||||
|
||||
test "record store setMany rolls back the whole batch on a bad key" {
|
||||
const allocator = std.testing.allocator;
|
||||
var store = try Store.openMemory(allocator);
|
||||
defer store.deinit();
|
||||
var output: [max_result_bytes]u8 = undefined;
|
||||
var bytes: [4 + 4 + 4 + 2 + 4 + 1 + 4 + 0 + 4 + 1]u8 = undefined;
|
||||
var writer = PageWriter.init(&bytes);
|
||||
try writer.writeInt(u32, 0);
|
||||
try writer.writeInt(u32, 2);
|
||||
try writer.writeBytes("ok");
|
||||
try writer.writeBytes("1");
|
||||
try writer.writeBytes("");
|
||||
try writer.writeBytes("2");
|
||||
try std.testing.expectEqual(Outcome.bad_key, store.execute(.set_many, &bytes, &output).outcome);
|
||||
const get_payload = try request(allocator, 0, &.{"ok"});
|
||||
defer allocator.free(get_payload);
|
||||
try std.testing.expectEqual(Outcome.miss, store.execute(.get, get_payload, &output).outcome);
|
||||
}
|
||||
|
||||
test "record store setMany rolls back writes when SQLite rejects a later row" {
|
||||
const allocator = std.testing.allocator;
|
||||
var store = try Store.openMemory(allocator);
|
||||
defer store.deinit();
|
||||
try store.write_db.exec(
|
||||
"CREATE TRIGGER reject_second BEFORE INSERT ON kv " ++
|
||||
"WHEN new.k=X'626164' BEGIN SELECT RAISE(ABORT,'fixture'); END;",
|
||||
);
|
||||
const entries = [_]TestEntry{
|
||||
.{ .key = "kept-only-on-commit", .value = "one" },
|
||||
.{ .key = "bad", .value = "two" },
|
||||
};
|
||||
const payload = try setManyRequest(allocator, 0, &entries);
|
||||
defer allocator.free(payload);
|
||||
var output: [max_result_bytes]u8 = undefined;
|
||||
try std.testing.expectEqual(Outcome.io_failed, store.execute(.set_many, payload, &output).outcome);
|
||||
|
||||
const get_payload = try request(allocator, 0, &.{"kept-only-on-commit"});
|
||||
defer allocator.free(get_payload);
|
||||
try std.testing.expectEqual(Outcome.miss, store.execute(.get, get_payload, &output).outcome);
|
||||
}
|
||||
|
||||
test "record store enforces exact key value scan and batch bounds" {
|
||||
const allocator = std.testing.allocator;
|
||||
var store = try Store.openMemory(allocator);
|
||||
defer store.deinit();
|
||||
const output = try allocator.alloc(u8, max_result_bytes);
|
||||
defer allocator.free(output);
|
||||
|
||||
const max_key = try allocator.alloc(u8, max_key_bytes);
|
||||
defer allocator.free(max_key);
|
||||
@memset(max_key, 'k');
|
||||
const max_value = try allocator.alloc(u8, max_value_bytes);
|
||||
defer allocator.free(max_value);
|
||||
@memset(max_value, 0xa5);
|
||||
const exact = try request(allocator, 0, &.{ max_key, max_value });
|
||||
defer allocator.free(exact);
|
||||
try std.testing.expectEqual(Outcome.ok, store.execute(.set, exact, output).outcome);
|
||||
|
||||
const exact_get = try request(allocator, 0, &.{max_key});
|
||||
defer allocator.free(exact_get);
|
||||
const found = store.execute(.get, exact_get, output);
|
||||
try std.testing.expectEqual(Outcome.ok, found.outcome);
|
||||
try std.testing.expectEqual(max_value_bytes + 1, found.len);
|
||||
try std.testing.expectEqualSlices(u8, max_value, output[1..found.len]);
|
||||
|
||||
const long_key = try allocator.alloc(u8, max_key_bytes + 1);
|
||||
defer allocator.free(long_key);
|
||||
@memset(long_key, 'x');
|
||||
const long_key_request = try request(allocator, 0, &.{long_key});
|
||||
defer allocator.free(long_key_request);
|
||||
try std.testing.expectEqual(Outcome.bad_key, store.execute(.get, long_key_request, output).outcome);
|
||||
const empty_key_request = try request(allocator, 0, &.{""});
|
||||
defer allocator.free(empty_key_request);
|
||||
try std.testing.expectEqual(Outcome.bad_key, store.execute(.get, empty_key_request, output).outcome);
|
||||
const invalid_utf8_request = try request(allocator, 0, &.{&.{0xff}});
|
||||
defer allocator.free(invalid_utf8_request);
|
||||
try std.testing.expectEqual(Outcome.bad_key, store.execute(.get, invalid_utf8_request, output).outcome);
|
||||
|
||||
const long_value = try allocator.alloc(u8, max_value_bytes + 1);
|
||||
defer allocator.free(long_value);
|
||||
@memset(long_value, 0x5a);
|
||||
const long_value_request = try request(allocator, 0, &.{ "value/too-large", long_value });
|
||||
defer allocator.free(long_value_request);
|
||||
try std.testing.expectEqual(Outcome.over_bound, store.execute(.set, long_value_request, output).outcome);
|
||||
|
||||
const long_prefix_request = try scanRequest(allocator, 0, long_key, 1, "");
|
||||
defer allocator.free(long_prefix_request);
|
||||
try std.testing.expectEqual(Outcome.bad_key, store.execute(.scan, long_prefix_request, output).outcome);
|
||||
const long_limit_request = try scanRequest(allocator, 0, "", max_scan_limit + 1, "");
|
||||
defer allocator.free(long_limit_request);
|
||||
try std.testing.expectEqual(Outcome.over_bound, store.execute(.scan, long_limit_request, output).outcome);
|
||||
|
||||
var too_many_header: [8]u8 = undefined;
|
||||
std.mem.writeInt(u32, too_many_header[0..4], 0, .little);
|
||||
std.mem.writeInt(u32, too_many_header[4..8], max_batch_entries + 1, .little);
|
||||
try std.testing.expectEqual(Outcome.over_bound, store.execute(.set_many, &too_many_header, output).outcome);
|
||||
const oversized_batch = try allocator.alloc(u8, max_batch_bytes + 1);
|
||||
defer allocator.free(oversized_batch);
|
||||
@memset(oversized_batch, 0);
|
||||
try std.testing.expectEqual(Outcome.over_bound, store.execute(.set_many, oversized_batch, output).outcome);
|
||||
}
|
||||
|
||||
test "record store scopes remain isolated on the extensible wire" {
|
||||
const allocator = std.testing.allocator;
|
||||
var store = try Store.openMemory(allocator);
|
||||
defer store.deinit();
|
||||
var output: [max_result_bytes]u8 = undefined;
|
||||
const scoped_set = try request(allocator, 7, &.{ "shared/key", "scoped" });
|
||||
defer allocator.free(scoped_set);
|
||||
try std.testing.expectEqual(Outcome.ok, store.execute(.set, scoped_set, &output).outcome);
|
||||
const default_get = try request(allocator, 0, &.{"shared/key"});
|
||||
defer allocator.free(default_get);
|
||||
try std.testing.expectEqual(Outcome.miss, store.execute(.get, default_get, &output).outcome);
|
||||
const scoped_get = try request(allocator, 7, &.{"shared/key"});
|
||||
defer allocator.free(scoped_get);
|
||||
const found = store.execute(.get, scoped_get, &output);
|
||||
try std.testing.expectEqual(Outcome.ok, found.outcome);
|
||||
try std.testing.expectEqualStrings("scoped", output[1..found.len]);
|
||||
}
|
||||
|
||||
test "record store reports busy and io failures through the closed outcome enum" {
|
||||
const allocator = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buffer: [256]u8 = undefined;
|
||||
const path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/record-store-failures", .{tmp.sub_path[0..]});
|
||||
try std.Io.Dir.cwd().createDirPath(std.testing.io, path);
|
||||
var locking = try Store.open(allocator, path);
|
||||
defer locking.deinit();
|
||||
var blocked = try Store.open(allocator, path);
|
||||
defer blocked.deinit();
|
||||
try locking.write_db.exec("BEGIN IMMEDIATE;");
|
||||
defer locking.write_db.exec("ROLLBACK;") catch {};
|
||||
const set_payload = try request(allocator, 0, &.{ "locked", "value" });
|
||||
defer allocator.free(set_payload);
|
||||
var output: [max_result_bytes]u8 = undefined;
|
||||
try std.testing.expectEqual(Outcome.busy, blocked.execute(.set, set_payload, &output).outcome);
|
||||
|
||||
var broken = try Store.openMemory(allocator);
|
||||
defer broken.deinit();
|
||||
try broken.write_db.exec("DROP TABLE kv;");
|
||||
const get_payload = try request(allocator, 0, &.{"missing-table"});
|
||||
defer allocator.free(get_payload);
|
||||
try std.testing.expectEqual(Outcome.io_failed, broken.execute(.get, get_payload, &output).outcome);
|
||||
}
|
||||
|
||||
test "record store refuses schema versions newer than the engine" {
|
||||
const allocator = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var dir_buffer: [256]u8 = undefined;
|
||||
const dir = try std.fmt.bufPrint(&dir_buffer, ".zig-cache/tmp/{s}/record-store-version", .{tmp.sub_path[0..]});
|
||||
try std.Io.Dir.cwd().createDirPath(std.testing.io, dir);
|
||||
const path_plain = try std.fs.path.join(allocator, &.{ dir, "store.db" });
|
||||
defer allocator.free(path_plain);
|
||||
const path = try allocator.dupeZ(u8, path_plain);
|
||||
defer allocator.free(path);
|
||||
var db = try sqlite.Connection.open(path);
|
||||
try db.exec("PRAGMA user_version=2;");
|
||||
db.close();
|
||||
try std.testing.expectError(error.VersionUnknown, Store.open(allocator, dir));
|
||||
}
|
||||
|
||||
test "record store workers commit in reserved issue order" {
|
||||
const allocator = std.testing.allocator;
|
||||
var store = try Store.openMemory(allocator);
|
||||
defer store.deinit();
|
||||
const first = try request(allocator, 0, &.{ "ordered/key", "first" });
|
||||
defer allocator.free(first);
|
||||
const second = try request(allocator, 0, &.{ "ordered/key", "second" });
|
||||
defer allocator.free(second);
|
||||
|
||||
const first_sequence = store.reserveWrite();
|
||||
const second_sequence = store.reserveWrite();
|
||||
var second_output: [32]u8 = undefined;
|
||||
const Worker = struct {
|
||||
fn run(target: *Store, sequence: u64, payload: []const u8, output: []u8) void {
|
||||
const result = target.executeWrite(sequence, .set, payload, output);
|
||||
std.debug.assert(result.outcome == .ok);
|
||||
}
|
||||
};
|
||||
const second_thread = try std.Thread.spawn(.{}, Worker.run, .{ &store, second_sequence, second, &second_output });
|
||||
var first_output: [32]u8 = undefined;
|
||||
try std.testing.expectEqual(Outcome.ok, store.executeWrite(first_sequence, .set, first, &first_output).outcome);
|
||||
second_thread.join();
|
||||
|
||||
const get_payload = try request(allocator, 0, &.{"ordered/key"});
|
||||
defer allocator.free(get_payload);
|
||||
var output: [max_result_bytes]u8 = undefined;
|
||||
const found = store.execute(.get, get_payload, &output);
|
||||
try std.testing.expectEqual(Outcome.ok, found.outcome);
|
||||
try std.testing.expectEqualSlices(u8, "second", output[1..found.len]);
|
||||
}
|
||||
|
||||
test "synchronous reads wait for earlier reserved writes" {
|
||||
const allocator = std.testing.allocator;
|
||||
var store = try Store.openMemory(allocator);
|
||||
defer store.deinit();
|
||||
const set_payload = try request(allocator, 0, &.{ "ordered/read", "visible" });
|
||||
defer allocator.free(set_payload);
|
||||
const get_payload = try request(allocator, 0, &.{"ordered/read"});
|
||||
defer allocator.free(get_payload);
|
||||
|
||||
const sequence = store.reserveWrite();
|
||||
var write_output: [32]u8 = undefined;
|
||||
const Worker = struct {
|
||||
fn run(target: *Store, write_sequence: u64, payload: []const u8, output: []u8) void {
|
||||
// Make it plausible for the loop-thread read to reach its barrier
|
||||
// before this worker enters SQLite.
|
||||
std.Thread.yield() catch {};
|
||||
const result = target.executeWrite(write_sequence, .set, payload, output);
|
||||
std.debug.assert(result.outcome == .ok);
|
||||
}
|
||||
};
|
||||
const thread = try std.Thread.spawn(.{}, Worker.run, .{ &store, sequence, set_payload, &write_output });
|
||||
|
||||
var output: [max_result_bytes]u8 = undefined;
|
||||
const found = store.execute(.get, get_payload, &output);
|
||||
thread.join();
|
||||
try std.testing.expectEqual(Outcome.ok, found.outcome);
|
||||
try std.testing.expectEqualSlices(u8, "visible", output[1..found.len]);
|
||||
}
|
||||
@@ -85,6 +85,16 @@ pub const EffectFileResult = runtime_effects.EffectFileResult;
|
||||
pub const EffectPersistOutcome = runtime_effects.EffectPersistOutcome;
|
||||
pub const EffectPersistResult = runtime_effects.EffectPersistResult;
|
||||
pub const max_effect_persist_snapshot_bytes = runtime_effects.max_effect_persist_snapshot_bytes;
|
||||
pub const EffectStoreOutcome = runtime_effects.EffectStoreOutcome;
|
||||
pub const EffectStoreOp = runtime_effects.EffectStoreOp;
|
||||
pub const RecordStoreBinding = runtime_effects.RecordStoreBinding;
|
||||
pub const max_effect_store_key_bytes = runtime_effects.max_effect_store_key_bytes;
|
||||
pub const max_effect_store_value_bytes = runtime_effects.max_effect_store_value_bytes;
|
||||
pub const max_effect_store_batch_entries = runtime_effects.max_effect_store_batch_entries;
|
||||
pub const max_effect_store_batch_bytes = runtime_effects.max_effect_store_batch_bytes;
|
||||
pub const max_effect_store_result_bytes = runtime_effects.max_effect_store_result_bytes;
|
||||
pub const default_effect_store_scan_limit = runtime_effects.default_effect_store_scan_limit;
|
||||
pub const max_effect_store_scan_limit = runtime_effects.max_effect_store_scan_limit;
|
||||
pub const max_effect_file_path_bytes = runtime_effects.max_effect_file_path_bytes;
|
||||
pub const max_effect_file_bytes = runtime_effects.max_effect_file_bytes;
|
||||
pub const EffectClipboardOp = runtime_effects.EffectClipboardOp;
|
||||
@@ -162,12 +172,15 @@ const runtime_session_record = @import("session_record.zig");
|
||||
const runtime_session_replay = @import("session_replay.zig");
|
||||
const runtime_session_blobs = @import("session_blobs.zig");
|
||||
const runtime_persist_store = @import("persist_store.zig");
|
||||
const runtime_record_store = @import("record_store.zig");
|
||||
pub const session_journal = runtime_session_journal;
|
||||
pub const session_blobs = runtime_session_blobs;
|
||||
pub const persist_store = runtime_persist_store;
|
||||
pub const PersistStore = runtime_persist_store.Store;
|
||||
pub const PersistOutcome = runtime_persist_store.Outcome;
|
||||
pub const max_persist_snapshot_bytes = runtime_persist_store.max_snapshot_bytes;
|
||||
pub const record_store = runtime_record_store;
|
||||
pub const RecordStore = runtime_record_store.Store;
|
||||
pub const SessionRecorder = runtime_session_record.SessionRecorder;
|
||||
pub const SessionRecorderSink = runtime_session_record.RecorderSink;
|
||||
pub const SessionBlobSink = runtime_session_blobs.SessionBlobSink;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Small internal SQLite seam shared by the record store and the relational
|
||||
//! storage tier. SQLite remains an implementation detail: apps receive inert
|
||||
//! effects, never a synchronous database handle.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// The tiny stable SQLite C ABI this engine uses. Keeping the declarations
|
||||
/// here instead of translating sqlite3.h is load-bearing for capability
|
||||
/// shedding: every app may import the SDK's effect types, while only apps
|
||||
/// declaring `store` or `sqlite` compile and link the amalgamation itself.
|
||||
/// SQLite's public ABI and these numeric constants are explicitly stable.
|
||||
const c = struct {
|
||||
const sqlite3 = opaque {};
|
||||
const sqlite3_stmt = opaque {};
|
||||
|
||||
const SQLITE_OK: c_int = 0;
|
||||
const SQLITE_BUSY: c_int = 5;
|
||||
const SQLITE_LOCKED: c_int = 6;
|
||||
const SQLITE_ROW: c_int = 100;
|
||||
const SQLITE_DONE: c_int = 101;
|
||||
|
||||
const SQLITE_OPEN_READWRITE: c_int = 0x00000002;
|
||||
const SQLITE_OPEN_CREATE: c_int = 0x00000004;
|
||||
const SQLITE_OPEN_FULLMUTEX: c_int = 0x00010000;
|
||||
|
||||
// Keep the references strong: a store-capable build that forgets to link
|
||||
// the engine must fail at link time. The ordinary TestHarness is a
|
||||
// separate type from RecordStoreTestHarness, so no-store app tests never
|
||||
// analyze these calls merely through SDK reflection.
|
||||
const linkage: std.builtin.GlobalLinkage = .strong;
|
||||
const sqlite3_open_v2 = @extern(*allowzero const fn ([*:0]const u8, *?*sqlite3, c_int, ?[*:0]const u8) callconv(.c) c_int, .{ .name = "sqlite3_open_v2", .linkage = linkage });
|
||||
const sqlite3_close_v2 = @extern(*allowzero const fn (*sqlite3) callconv(.c) c_int, .{ .name = "sqlite3_close_v2", .linkage = linkage });
|
||||
const sqlite3_exec = @extern(*allowzero const fn (*sqlite3, [*:0]const u8, ?*const anyopaque, ?*anyopaque, ?*?[*:0]u8) callconv(.c) c_int, .{ .name = "sqlite3_exec", .linkage = linkage });
|
||||
const sqlite3_prepare_v2 = @extern(*allowzero const fn (*sqlite3, [*:0]const u8, c_int, *?*sqlite3_stmt, ?*?[*:0]const u8) callconv(.c) c_int, .{ .name = "sqlite3_prepare_v2", .linkage = linkage });
|
||||
const sqlite3_finalize = @extern(*allowzero const fn (*sqlite3_stmt) callconv(.c) c_int, .{ .name = "sqlite3_finalize", .linkage = linkage });
|
||||
const sqlite3_reset = @extern(*allowzero const fn (*sqlite3_stmt) callconv(.c) c_int, .{ .name = "sqlite3_reset", .linkage = linkage });
|
||||
const sqlite3_clear_bindings = @extern(*allowzero const fn (*sqlite3_stmt) callconv(.c) c_int, .{ .name = "sqlite3_clear_bindings", .linkage = linkage });
|
||||
const sqlite3_bind_int64 = @extern(*allowzero const fn (*sqlite3_stmt, c_int, i64) callconv(.c) c_int, .{ .name = "sqlite3_bind_int64", .linkage = linkage });
|
||||
const sqlite3_bind_zeroblob64 = @extern(*allowzero const fn (*sqlite3_stmt, c_int, u64) callconv(.c) c_int, .{ .name = "sqlite3_bind_zeroblob64", .linkage = linkage });
|
||||
const sqlite3_bind_blob64 = @extern(*allowzero const fn (*sqlite3_stmt, c_int, ?*const anyopaque, u64, ?*const anyopaque) callconv(.c) c_int, .{ .name = "sqlite3_bind_blob64", .linkage = linkage });
|
||||
const sqlite3_step = @extern(*allowzero const fn (*sqlite3_stmt) callconv(.c) c_int, .{ .name = "sqlite3_step", .linkage = linkage });
|
||||
const sqlite3_column_bytes = @extern(*allowzero const fn (*sqlite3_stmt, c_int) callconv(.c) c_int, .{ .name = "sqlite3_column_bytes", .linkage = linkage });
|
||||
const sqlite3_column_blob = @extern(*allowzero const fn (*sqlite3_stmt, c_int) callconv(.c) ?*const anyopaque, .{ .name = "sqlite3_column_blob", .linkage = linkage });
|
||||
const sqlite3_column_int64 = @extern(*allowzero const fn (*sqlite3_stmt, c_int) callconv(.c) i64, .{ .name = "sqlite3_column_int64", .linkage = linkage });
|
||||
};
|
||||
|
||||
pub const Error = error{
|
||||
Busy,
|
||||
OpenFailed,
|
||||
PrepareFailed,
|
||||
BindFailed,
|
||||
StepFailed,
|
||||
ExecFailed,
|
||||
};
|
||||
|
||||
pub const Step = enum { row, done };
|
||||
|
||||
pub const Connection = struct {
|
||||
handle: *c.sqlite3,
|
||||
|
||||
pub fn open(path: [:0]const u8) Error!Connection {
|
||||
var handle: ?*c.sqlite3 = null;
|
||||
const flags = c.SQLITE_OPEN_READWRITE | c.SQLITE_OPEN_CREATE | c.SQLITE_OPEN_FULLMUTEX;
|
||||
if (c.sqlite3_open_v2(path.ptr, &handle, flags, null) != c.SQLITE_OK) {
|
||||
if (handle) |opened| _ = c.sqlite3_close_v2(opened);
|
||||
return error.OpenFailed;
|
||||
}
|
||||
return .{ .handle = handle orelse return error.OpenFailed };
|
||||
}
|
||||
|
||||
pub fn close(self: *Connection) void {
|
||||
_ = c.sqlite3_close_v2(self.handle);
|
||||
}
|
||||
|
||||
pub fn exec(self: *Connection, sql: [:0]const u8) Error!void {
|
||||
const result = c.sqlite3_exec(self.handle, sql.ptr, null, null, null);
|
||||
if (result != c.SQLITE_OK) return classify(result, error.ExecFailed);
|
||||
}
|
||||
|
||||
pub fn prepare(self: *Connection, sql: [:0]const u8) Error!Statement {
|
||||
var statement: ?*c.sqlite3_stmt = null;
|
||||
const result = c.sqlite3_prepare_v2(self.handle, sql.ptr, @intCast(sql.len), &statement, null);
|
||||
if (result != c.SQLITE_OK) return classify(result, error.PrepareFailed);
|
||||
return .{ .handle = statement orelse return error.PrepareFailed };
|
||||
}
|
||||
};
|
||||
|
||||
pub const Statement = struct {
|
||||
handle: *c.sqlite3_stmt,
|
||||
|
||||
pub fn finalize(self: *Statement) void {
|
||||
_ = c.sqlite3_finalize(self.handle);
|
||||
}
|
||||
|
||||
pub fn reset(self: *Statement) Error!void {
|
||||
const reset_result = c.sqlite3_reset(self.handle);
|
||||
if (reset_result != c.SQLITE_OK) return classify(reset_result, error.StepFailed);
|
||||
const clear_result = c.sqlite3_clear_bindings(self.handle);
|
||||
if (clear_result != c.SQLITE_OK) return classify(clear_result, error.BindFailed);
|
||||
}
|
||||
|
||||
pub fn bindInt(self: *Statement, index: c_int, value: i64) Error!void {
|
||||
const result = c.sqlite3_bind_int64(self.handle, index, value);
|
||||
if (result != c.SQLITE_OK) return classify(result, error.BindFailed);
|
||||
}
|
||||
|
||||
pub fn bindBlob(self: *Statement, index: c_int, bytes: []const u8) Error!void {
|
||||
if (bytes.len == 0) {
|
||||
const result = c.sqlite3_bind_zeroblob64(self.handle, index, 0);
|
||||
if (result != c.SQLITE_OK) return classify(result, error.BindFailed);
|
||||
return;
|
||||
}
|
||||
const pointer: ?*const anyopaque = if (bytes.len == 0) null else bytes.ptr;
|
||||
// Callers keep every bound slice alive through step/reset/finalize, so
|
||||
// SQLITE_STATIC (a null destructor) is both correct and avoids the C
|
||||
// macro's deliberately invalid -1 function pointer, which Zig 0.16
|
||||
// refuses to materialize at comptime.
|
||||
const result = c.sqlite3_bind_blob64(self.handle, index, pointer, bytes.len, null);
|
||||
if (result != c.SQLITE_OK) return classify(result, error.BindFailed);
|
||||
}
|
||||
|
||||
pub fn step(self: *Statement) Error!Step {
|
||||
const result = c.sqlite3_step(self.handle);
|
||||
return switch (result) {
|
||||
c.SQLITE_ROW => .row,
|
||||
c.SQLITE_DONE => .done,
|
||||
else => classify(result, error.StepFailed),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn columnBlob(self: *const Statement, index: c_int) []const u8 {
|
||||
const len: usize = @intCast(c.sqlite3_column_bytes(self.handle, index));
|
||||
if (len == 0) return "";
|
||||
const raw = c.sqlite3_column_blob(self.handle, index) orelse return "";
|
||||
const bytes: [*]const u8 = @ptrCast(raw);
|
||||
return bytes[0..len];
|
||||
}
|
||||
|
||||
pub fn columnInt(self: *const Statement, index: c_int) i64 {
|
||||
return c.sqlite3_column_int64(self.handle, index);
|
||||
}
|
||||
};
|
||||
|
||||
fn classify(result: c_int, fallback: Error) Error {
|
||||
// Extended result codes retain the primary code in the low byte.
|
||||
return switch (result & 0xff) {
|
||||
c.SQLITE_BUSY, c.SQLITE_LOCKED => error.Busy,
|
||||
else => fallback,
|
||||
};
|
||||
}
|
||||
|
||||
test "sqlite engine opens memory databases and executes statements" {
|
||||
var db = try Connection.open(":memory:");
|
||||
defer db.close();
|
||||
try db.exec("CREATE TABLE t (k BLOB PRIMARY KEY, v BLOB NOT NULL);");
|
||||
var insert = try db.prepare("INSERT INTO t(k,v) VALUES(?1,?2);");
|
||||
defer insert.finalize();
|
||||
try insert.bindBlob(1, "a");
|
||||
try insert.bindBlob(2, "b");
|
||||
try std.testing.expectEqual(Step.done, try insert.step());
|
||||
}
|
||||
@@ -27,6 +27,7 @@ test {
|
||||
_ = @import("effects_image_tests.zig");
|
||||
_ = @import("effects_channel_tests.zig");
|
||||
_ = @import("effects_host_tests.zig");
|
||||
_ = @import("effects_store_tests.zig");
|
||||
_ = @import("effects_pty_tests.zig");
|
||||
_ = @import("terminal_session_tests.zig");
|
||||
_ = @import("pty.zig");
|
||||
|
||||
@@ -472,6 +472,7 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
key: [max_wire_key_bytes]u8 = undefined,
|
||||
ok_tag: u8 = 0,
|
||||
err_tag: u8 = 0,
|
||||
ok_void: bool = false,
|
||||
|
||||
fn wireKey(entry: *const RequestEntry) []const u8 {
|
||||
return entry.key[0..entry.key_len];
|
||||
@@ -657,7 +658,7 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
var audio_cache_dir_len: usize = 0;
|
||||
var audio_cache_dir_buf: [runtime_effects.max_effect_audio_path_bytes]u8 = undefined;
|
||||
var audio_cache_path_buf: [runtime_effects.max_effect_audio_path_bytes]u8 = undefined;
|
||||
var requests: [runtime_effects.max_effects]RequestEntry = @splat(.{});
|
||||
var requests: [runtime_effects.max_effects + runtime_effects.max_store_effects]RequestEntry = @splat(.{});
|
||||
var timers: [runtime_effects.max_effect_timers]TimerEntry = @splat(.{});
|
||||
var effects_table: [runtime_effects.max_effects]EffectEntry = @splat(.{});
|
||||
var delays: [runtime_effects.max_effect_timers]DelayEntry = @splat(.{});
|
||||
@@ -1374,6 +1375,89 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
},
|
||||
// dock_presence [op][visible u8]
|
||||
0x22 => fx.setDockPresence(takeByte(cmd, &at) != 0),
|
||||
// store_set [op][route][scope u32][key bytes][value bytes]
|
||||
0x23 => {
|
||||
const head = takeRoutedHead(cmd, &at);
|
||||
const scope = takeU32(cmd, &at);
|
||||
if (scope != 0) @panic("ts core host: record-store scope is reserved and must be zero in cmd format v3");
|
||||
const record_key = takeLongBytes(cmd, &at);
|
||||
const bytes = takeLongBytes(cmd, &at);
|
||||
const request_key = allocStoreRequestEntry(fx, head, true) orelse continue;
|
||||
fx.storeSet(.{
|
||||
.key = request_key,
|
||||
.record_key = record_key,
|
||||
.bytes = bytes,
|
||||
.on_result = hostResultMsg,
|
||||
});
|
||||
},
|
||||
// store_get [op][route][scope u32][key bytes]
|
||||
0x24 => {
|
||||
const head = takeRoutedHead(cmd, &at);
|
||||
const scope = takeU32(cmd, &at);
|
||||
if (scope != 0) @panic("ts core host: record-store scope is reserved and must be zero in cmd format v3");
|
||||
const record_key = takeLongBytes(cmd, &at);
|
||||
const request_key = allocStoreRequestEntry(fx, head, false) orelse continue;
|
||||
fx.storeGet(.{
|
||||
.key = request_key,
|
||||
.record_key = record_key,
|
||||
.on_result = hostResultMsg,
|
||||
});
|
||||
},
|
||||
// store_delete [op][route][scope u32][key bytes]
|
||||
0x25 => {
|
||||
const head = takeRoutedHead(cmd, &at);
|
||||
const scope = takeU32(cmd, &at);
|
||||
if (scope != 0) @panic("ts core host: record-store scope is reserved and must be zero in cmd format v3");
|
||||
const record_key = takeLongBytes(cmd, &at);
|
||||
const request_key = allocStoreRequestEntry(fx, head, true) orelse continue;
|
||||
fx.storeDelete(.{
|
||||
.key = request_key,
|
||||
.record_key = record_key,
|
||||
.on_result = hostResultMsg,
|
||||
});
|
||||
},
|
||||
// store_scan [op][route][scope u32][prefix bytes]
|
||||
// [limit u32][after bytes]
|
||||
0x26 => {
|
||||
const head = takeRoutedHead(cmd, &at);
|
||||
const scope = takeU32(cmd, &at);
|
||||
if (scope != 0) @panic("ts core host: record-store scope is reserved and must be zero in cmd format v3");
|
||||
const prefix = takeLongBytes(cmd, &at);
|
||||
const limit = takeU32(cmd, &at);
|
||||
const after = takeLongBytes(cmd, &at);
|
||||
const request_key = allocStoreRequestEntry(fx, head, false) orelse continue;
|
||||
fx.storeScan(.{
|
||||
.key = request_key,
|
||||
.prefix = prefix,
|
||||
.limit = limit,
|
||||
.after = after,
|
||||
.on_result = hostResultMsg,
|
||||
});
|
||||
},
|
||||
// store_set_many [op][route][scope u32][count u32]
|
||||
// [count * (key bytes,value bytes)]
|
||||
0x27 => {
|
||||
const head = takeRoutedHead(cmd, &at);
|
||||
const scope = takeU32(cmd, &at);
|
||||
if (scope != 0) @panic("ts core host: record-store scope is reserved and must be zero in cmd format v3");
|
||||
const count: usize = @intCast(takeU32(cmd, &at));
|
||||
var entries: [runtime_effects.max_effect_store_batch_entries]Fx.StoreEntry = undefined;
|
||||
var kept: usize = 0;
|
||||
for (0..count) |_| {
|
||||
const record_key = takeLongBytes(cmd, &at);
|
||||
const bytes = takeLongBytes(cmd, &at);
|
||||
if (kept < entries.len) {
|
||||
entries[kept] = .{ .key = record_key, .bytes = bytes };
|
||||
kept += 1;
|
||||
}
|
||||
}
|
||||
const request_key = allocStoreRequestEntry(fx, head, true) orelse continue;
|
||||
fx.storeSetMany(.{
|
||||
.key = request_key,
|
||||
.entries = if (count <= entries.len) entries[0..kept] else &.{},
|
||||
.on_result = hostResultMsg,
|
||||
});
|
||||
},
|
||||
else => @panic("ts core host: unknown command wire record - the core and this runtime disagree on cmd_format_version"),
|
||||
}
|
||||
}
|
||||
@@ -2303,13 +2387,24 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
/// the engine replaces the in-flight call under the same engine
|
||||
/// key. Unkeyed requests (`key.len == 0`) each take a fresh
|
||||
/// entry: nothing can replace or cancel them.
|
||||
fn issueRequest(fx: *Fx, name: []const u8, key: []const u8, ok_tag: u8, err_tag: u8, payload: []const u8) void {
|
||||
fn allocRequestEntry(
|
||||
fx: *Fx,
|
||||
key: []const u8,
|
||||
ok_tag: u8,
|
||||
err_tag: u8,
|
||||
ok_void: bool,
|
||||
store_request: bool,
|
||||
) ?u64 {
|
||||
const index = blk: {
|
||||
if (key.len > 0) {
|
||||
if (findRequest(key)) |existing| break :blk existing;
|
||||
if (findRequest(key)) |existing| {
|
||||
const existing_is_store = existing >= runtime_effects.max_effects;
|
||||
if (existing_is_store == store_request) break :blk existing;
|
||||
fx.cancelHostRequest(request_key_base + existing);
|
||||
requests[existing].used = false;
|
||||
}
|
||||
}
|
||||
break :blk freeRequestIndex() orelse
|
||||
@panic("ts core host: more than 16 host requests in flight - the request table mirrors the engine's max_effects slots");
|
||||
break :blk (if (store_request) freeStoreRequestIndex() else freeRequestIndex()) orelse return null;
|
||||
};
|
||||
const entry = &requests[index];
|
||||
entry.used = true;
|
||||
@@ -2317,6 +2412,23 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
@memcpy(entry.key[0..key.len], key);
|
||||
entry.ok_tag = ok_tag;
|
||||
entry.err_tag = err_tag;
|
||||
entry.ok_void = ok_void;
|
||||
return index;
|
||||
}
|
||||
|
||||
fn allocStoreRequestEntry(fx: *Fx, head: RoutedHead, ok_void: bool) ?u64 {
|
||||
const index = allocRequestEntry(fx, head.key, head.ok_tag, head.err_tag, ok_void, true) orelse {
|
||||
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
|
||||
return null;
|
||||
};
|
||||
return request_key_base + index;
|
||||
}
|
||||
|
||||
fn issueRequest(fx: *Fx, name: []const u8, key: []const u8, ok_tag: u8, err_tag: u8, payload: []const u8) void {
|
||||
const index = allocRequestEntry(fx, key, ok_tag, err_tag, false, false) orelse {
|
||||
fx.stageLoopMsg(msgFromTagStaticBytes(err_tag, "rejected"));
|
||||
return;
|
||||
};
|
||||
fx.hostRequest(.{
|
||||
.key = request_key_base + index,
|
||||
.name = name,
|
||||
@@ -2333,7 +2445,14 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
}
|
||||
|
||||
fn freeRequestIndex() ?usize {
|
||||
for (&requests, 0..) |*entry, index| {
|
||||
for (requests[0..runtime_effects.max_effects], 0..) |*entry, index| {
|
||||
if (!entry.used) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn freeStoreRequestIndex() ?usize {
|
||||
for (requests[runtime_effects.max_effects..], runtime_effects.max_effects..) |*entry, index| {
|
||||
if (!entry.used) return index;
|
||||
}
|
||||
return null;
|
||||
@@ -2354,6 +2473,7 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
}
|
||||
const entry = &requests[index];
|
||||
entry.used = false;
|
||||
if (result.ok and entry.ok_void) return msgFromTagVoid(entry.ok_tag);
|
||||
return msgFromTagBytes(if (result.ok) entry.ok_tag else entry.err_tag, result.bytes);
|
||||
}
|
||||
|
||||
@@ -3186,6 +3306,12 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
return slice;
|
||||
}
|
||||
|
||||
/// A fixed-width little-endian integer.
|
||||
fn takeU32(bytes: []const u8, at: *usize) u32 {
|
||||
const raw = takeBytes(bytes, at, 4);
|
||||
return std.mem.readInt(u32, raw[0..4], .little);
|
||||
}
|
||||
|
||||
/// A one-byte-length-prefixed field (names and keys).
|
||||
fn takeShortBytes(bytes: []const u8, at: *usize) []const u8 {
|
||||
const len: usize = takeByte(bytes, at);
|
||||
|
||||
@@ -1404,6 +1404,9 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
fn bindEffectsChannel(self: *Self, runtime: *Runtime) void {
|
||||
self.effects.bindServices(&runtime.options.platform.services);
|
||||
self.effects.bindEnviron(runtime.options.environ);
|
||||
if (runtime.options.record_store) |binding| {
|
||||
self.effects.bindRecordStore(binding);
|
||||
}
|
||||
self.effects.bindImages(runtime.canvasImageRegistryBinding());
|
||||
self.effects.bindMediaSurfaces(runtime.mediaSurfaceBinding());
|
||||
self.effects.bindWindowActions(.{
|
||||
|
||||
@@ -109,6 +109,32 @@ fn retainedTextExists(runtime: *core.Runtime, text: []const u8) !bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
test "record-store test harness binds one hermetic in-memory database" {
|
||||
const harness = try core.TestHarness().createWithRecordStore(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
try std.testing.expect(harness.record_store != null);
|
||||
try std.testing.expect(harness.runtime.options.record_store != null);
|
||||
harness.null_platform.gpu_surfaces = true;
|
||||
|
||||
const app_state = try std.testing.allocator.create(CounterApp);
|
||||
defer std.testing.allocator.destroy(app_state);
|
||||
app_state.* = CounterApp.init(std.heap.page_allocator, .{}, counterOptions());
|
||||
defer app_state.deinit();
|
||||
const app = app_state.app();
|
||||
try harness.start(app);
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
|
||||
.label = canvas_label,
|
||||
.size = geometry.SizeF.init(400, 300),
|
||||
.scale_factor = 1,
|
||||
.frame_index = 1,
|
||||
.timestamp_ns = 1_000_000,
|
||||
.nonblank = true,
|
||||
} });
|
||||
try app_state.dispatch(&harness.runtime, 1, .increment);
|
||||
try std.testing.expect(app_state.effects.record_store_binding != null);
|
||||
try harness.stop(app);
|
||||
}
|
||||
|
||||
test "ui app owns install, dispatch, and rebuild end to end" {
|
||||
// The runtime and the app are both large structs; keep them off the
|
||||
// test thread's stack.
|
||||
|
||||
@@ -1296,6 +1296,8 @@ fn parseCapability(value: []const u8) !app_manifest.Capability {
|
||||
if (std.mem.eql(u8, value, "clipboard")) return .clipboard;
|
||||
if (std.mem.eql(u8, value, "credentials")) return .credentials;
|
||||
if (std.mem.eql(u8, value, "persist")) return .persist;
|
||||
if (std.mem.eql(u8, value, "store")) return .store;
|
||||
if (std.mem.eql(u8, value, "sqlite")) return .sqlite;
|
||||
if (std.mem.eql(u8, value, "open_url")) return .open_url;
|
||||
if (std.mem.eql(u8, value, "reveal_path")) return .reveal_path;
|
||||
if (std.mem.eql(u8, value, "recent_documents")) return .recent_documents;
|
||||
@@ -2446,6 +2448,14 @@ test "manifest metadata parser carries model persistence configuration" {
|
||||
try std.testing.expectError(error.MissingRequiredField, app_manifest.validatePersist(convertPersist(metadata.persist), &.{}));
|
||||
}
|
||||
|
||||
test "manifest capability parser recognizes both shared SQLite tiers" {
|
||||
const values = [_][]const u8{ "store", "sqlite" };
|
||||
const capabilities = try parseCapabilities(std.testing.allocator, &values);
|
||||
defer std.testing.allocator.free(capabilities);
|
||||
try std.testing.expectEqual(app_manifest.CapabilityKind.store, capabilities[0].kind());
|
||||
try std.testing.expectEqual(app_manifest.CapabilityKind.sqlite, capabilities[1].kind());
|
||||
}
|
||||
|
||||
test "manifest metadata parser reads structured security policy" {
|
||||
const metadata = try parseText(std.testing.allocator,
|
||||
\\.{
|
||||
|
||||
@@ -2910,6 +2910,7 @@ test "mobile package templates ship the toolkit hosts" {
|
||||
try std.testing.expect(std.mem.indexOf(u8, ios_host, "native_sdk_app_text_input_state") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, ios_host, "native_sdk_app_set_text_measure") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, ios_host, "native_sdk_app_set_asset_root") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, ios_host, "native_sdk_app_set_data_root") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, ios_host, "native_sdk_app_widget_semantics_by_id") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, ios_host, "view.safeAreaInsets") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, ios_host, "_dyld_get_image_header_containing_address") != null);
|
||||
@@ -2935,6 +2936,7 @@ test "mobile package templates ship the toolkit hosts" {
|
||||
try std.testing.expect(std.mem.indexOf(u8, android_bridge, "native_sdk_app_text_input_state") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, android_bridge, "native_sdk_app_set_text_measure") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, android_bridge, "native_sdk_app_set_asset_root") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, android_bridge, "native_sdk_app_set_data_root") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, android_bridge, "ANativeWindow_fromSurface") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, android_bridge, "WINDOW_FORMAT_RGBA_8888") != null);
|
||||
}
|
||||
|
||||
@@ -1468,6 +1468,7 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
|
||||
\\ const app_mod = localModule(b, target, optimize, "src/main.zig");
|
||||
\\ app_mod.addImport("native_sdk", native_sdk_mod);
|
||||
\\ app_mod.addImport("runner", runner_mod);
|
||||
\\ if (app_config.sqlite_capability) addSqliteEngine(b, app_mod, native_sdk_path);
|
||||
\\ addMacosPrivacyInfoPlist(b, app_mod, target, app_config);
|
||||
\\ const exe = b.addExecutable(.{
|
||||
\\ .name = app_exe_name,
|
||||
@@ -1530,6 +1531,7 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
|
||||
\\ const package_app_mod = localModule(b, target, package_optimize, "src/main.zig");
|
||||
\\ package_app_mod.addImport("native_sdk", package_sdk_mod);
|
||||
\\ package_app_mod.addImport("runner", package_runner_mod);
|
||||
\\ if (app_config.sqlite_capability) addSqliteEngine(b, package_app_mod, native_sdk_path);
|
||||
\\ addMacosPrivacyInfoPlist(b, package_app_mod, target, app_config);
|
||||
\\ const built = b.addExecutable(.{
|
||||
\\ .name = app_exe_name,
|
||||
@@ -1705,6 +1707,7 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
|
||||
\\ debug_mod.addImport("trace", trace_mod);
|
||||
\\
|
||||
\\ const native_sdk_mod = externalModule(b, target, optimize, native_sdk_path, "src/root.zig");
|
||||
\\ native_sdk_mod.addIncludePath(nativeSdkPath(b, native_sdk_path, "third_party/sqlite"));
|
||||
\\ native_sdk_mod.addImport("geometry", geometry_mod);
|
||||
\\ native_sdk_mod.addImport("assets", assets_mod);
|
||||
\\ native_sdk_mod.addImport("app_dirs", app_dirs_mod);
|
||||
@@ -1717,6 +1720,15 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
|
||||
\\ return native_sdk_mod;
|
||||
\\}
|
||||
\\
|
||||
\\fn addSqliteEngine(b: *std.Build, app_mod: *std.Build.Module, native_sdk_path: []const u8) void {
|
||||
\\ app_mod.addIncludePath(nativeSdkPath(b, native_sdk_path, "third_party/sqlite"));
|
||||
\\ app_mod.addCSourceFile(.{
|
||||
\\ .file = nativeSdkPath(b, native_sdk_path, "third_party/sqlite/sqlite3.c"),
|
||||
\\ .flags = &.{ "-DSQLITE_THREADSAFE=1", "-DSQLITE_OMIT_LOAD_EXTENSION", "-DSQLITE_DQS=0", "-DSQLITE_DEFAULT_MEMSTATUS=0" },
|
||||
\\ });
|
||||
\\ app_mod.link_libc = true;
|
||||
\\}
|
||||
\\
|
||||
\\fn externalModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, native_sdk_path: []const u8, path: []const u8) *std.Build.Module {
|
||||
\\ return b.createModule(.{
|
||||
\\ .root_source_file = nativeSdkPath(b, native_sdk_path, path),
|
||||
@@ -1992,6 +2004,7 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
|
||||
\\ webview_layer: WebLayerOption = .auto,
|
||||
\\ microphone_permission: bool = false,
|
||||
\\ system_audio_permission: bool = false,
|
||||
\\ sqlite_capability: bool = false,
|
||||
\\ /// The first web declaration found (for teaching messages), or
|
||||
\\ /// null when app.zon declares no web use. `web_engine = "system"`
|
||||
\\ /// alone is NOT web intent — it is the default in many canvas
|
||||
@@ -2045,6 +2058,7 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
|
||||
\\ .webview_layer = parseWebLayer(raw.webview_layer) orelse @panic("app.zon .webview_layer must be \"auto\", \"include\", or \"exclude\""),
|
||||
\\ .microphone_permission = hasManifestPermission(raw.permissions, "microphone"),
|
||||
\\ .system_audio_permission = hasManifestPermission(raw.permissions, "system_audio"),
|
||||
\\ .sqlite_capability = hasManifestCapability(raw.capabilities, "store") or hasManifestCapability(raw.capabilities, "sqlite"),
|
||||
\\ };
|
||||
\\ config.web_declaration = blk: {
|
||||
\\ if (raw.frontend != null) break :blk "a .frontend block";
|
||||
@@ -2068,6 +2082,13 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
|
||||
\\ return false;
|
||||
\\}
|
||||
\\
|
||||
\\fn hasManifestCapability(capabilities: []const []const u8, name: []const u8) bool {
|
||||
\\ for (capabilities) |capability| {
|
||||
\\ if (std.mem.eql(u8, capability, name)) return true;
|
||||
\\ }
|
||||
\\ return false;
|
||||
\\}
|
||||
\\
|
||||
\\/// The web-layer decision for this build — the same declare-to-use
|
||||
\\/// contract the Native SDK's standard build graph, CLI, and runner
|
||||
\\/// apply: an app is WEB when app.zon declares web use (a .frontend
|
||||
@@ -2272,6 +2293,7 @@ fn runnerZig() []const u8 {
|
||||
\\ commands: ?[]const native_sdk.Command = null,
|
||||
\\ menus: ?[]const native_sdk.Menu = null,
|
||||
\\ shortcuts: ?[]const native_sdk.Shortcut = null,
|
||||
\\ record_store: ?native_sdk.RecordStoreBinding = null,
|
||||
\\
|
||||
\\ fn appInfo(self: RunOptions, buffers: *StateBuffers) native_sdk.AppInfo {
|
||||
\\ var info: native_sdk.AppInfo = .{
|
||||
@@ -2606,6 +2628,15 @@ fn runnerZig() []const u8 {
|
||||
\\ return false;
|
||||
\\}
|
||||
\\
|
||||
\\fn manifestDeclaresStore() bool {
|
||||
\\ if (comptime !@hasField(@TypeOf(app_manifest), "capabilities")) return false;
|
||||
\\ inline for (app_manifest.capabilities) |capability| {
|
||||
\\ const name: []const u8 = capability;
|
||||
\\ if (comptime std.mem.eql(u8, name, "store")) return true;
|
||||
\\ }
|
||||
\\ return false;
|
||||
\\}
|
||||
\\
|
||||
\\fn menuItem(comptime item: anytype) native_sdk.MenuItem {
|
||||
\\ return .{
|
||||
\\ .label = if (@hasField(@TypeOf(item), "label")) item.label else "",
|
||||
@@ -2644,14 +2675,35 @@ fn runnerZig() []const u8 {
|
||||
\\ if (build_options.debug_overlay) {
|
||||
\\ std.debug.print("debug-overlay=true backend={s} web-engine={s} trace={s}\n", .{ build_options.platform, build_options.web_engine, build_options.trace });
|
||||
\\ }
|
||||
\\ const RecordStoreType = if (comptime manifestDeclaresStore()) native_sdk.RecordStore else void;
|
||||
\\ var record_store_value: RecordStoreType = undefined;
|
||||
\\ var record_store_open = false;
|
||||
\\ var resolved_options = options;
|
||||
\\ if (comptime manifestDeclaresStore()) {
|
||||
\\ var data_dir_buffer: [512]u8 = undefined;
|
||||
\\ const app_data_dir = native_sdk.app_dirs.resolveOne(
|
||||
\\ .{ .name = options.bundle_id },
|
||||
\\ native_sdk.app_dirs.currentPlatform(),
|
||||
\\ native_sdk.debug.envFromMap(init.environ_map),
|
||||
\\ .data,
|
||||
\\ &data_dir_buffer,
|
||||
\\ ) catch return error.StoreDataDirUnavailable;
|
||||
\\ try std.Io.Dir.cwd().createDirPath(init.io, app_data_dir);
|
||||
\\ record_store_value = try native_sdk.RecordStore.open(std.heap.page_allocator, app_data_dir);
|
||||
\\ record_store_open = true;
|
||||
\\ resolved_options.record_store = record_store_value.binding();
|
||||
\\ }
|
||||
\\ defer if (comptime manifestDeclaresStore()) {
|
||||
\\ if (record_store_open) record_store_value.deinit();
|
||||
\\ };
|
||||
\\ if (comptime std.mem.eql(u8, build_options.platform, "macos")) {
|
||||
\\ try runMacos(app, options, init);
|
||||
\\ try runMacos(app, resolved_options, init);
|
||||
\\ } else if (comptime std.mem.eql(u8, build_options.platform, "linux")) {
|
||||
\\ try runLinux(app, options, init);
|
||||
\\ try runLinux(app, resolved_options, init);
|
||||
\\ } else if (comptime std.mem.eql(u8, build_options.platform, "windows")) {
|
||||
\\ try runWindows(app, options, init);
|
||||
\\ try runWindows(app, resolved_options, init);
|
||||
\\ } else {
|
||||
\\ try runNull(app, options, init);
|
||||
\\ try runNull(app, resolved_options, init);
|
||||
\\ }
|
||||
\\}
|
||||
\\
|
||||
@@ -2703,6 +2755,7 @@ fn runnerZig() []const u8 {
|
||||
\\ .shortcuts = shortcuts,
|
||||
\\ .automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
\\ .window_state_store = store,
|
||||
\\ .record_store = options.record_store,
|
||||
\\ .environ = init.minimal.environ,
|
||||
\\ });
|
||||
\\
|
||||
@@ -2757,6 +2810,7 @@ fn runnerZig() []const u8 {
|
||||
\\ .shortcuts = shortcuts,
|
||||
\\ .automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
\\ .window_state_store = store,
|
||||
\\ .record_store = options.record_store,
|
||||
\\ .environ = init.minimal.environ,
|
||||
\\ });
|
||||
\\
|
||||
@@ -2811,6 +2865,7 @@ fn runnerZig() []const u8 {
|
||||
\\ .shortcuts = shortcuts,
|
||||
\\ .automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
\\ .window_state_store = store,
|
||||
\\ .record_store = options.record_store,
|
||||
\\ .environ = init.minimal.environ,
|
||||
\\ });
|
||||
\\
|
||||
@@ -2865,6 +2920,7 @@ fn runnerZig() []const u8 {
|
||||
\\ .shortcuts = shortcuts,
|
||||
\\ .automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
\\ .window_state_store = store,
|
||||
\\ .record_store = options.record_store,
|
||||
\\ .environ = init.minimal.environ,
|
||||
\\ });
|
||||
\\
|
||||
@@ -3876,6 +3932,8 @@ test "writeDefaultApp emits Vite project files" {
|
||||
// as the managed graph (for both dev and the separately optimized exe).
|
||||
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "permissions: []const []const u8") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "hasManifestPermission(raw.permissions, \"microphone\")") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "hasManifestCapability(raw.capabilities, \"store\")") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "addSqliteEngine(b, app_mod, native_sdk_path)") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "NSMicrophoneUsageDescription") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "NSAudioCaptureUsageDescription") != null);
|
||||
try std.testing.expect(std.mem.count(u8, build_zig_text, "addMacosPrivacyInfoPlist(b, ") == 2);
|
||||
@@ -3929,6 +3987,9 @@ test "writeDefaultApp emits Vite project files" {
|
||||
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "menus: ?[]const native_sdk.Menu = null") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "resolvedMenus") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "shortcuts: ?[]const native_sdk.Shortcut = null") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "record_store: ?native_sdk.RecordStoreBinding = null") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "fn manifestDeclaresStore()") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, ".record_store = options.record_store") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "resolvedShortcuts") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "const manifest_windows") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "fn appInfo(self: RunOptions, buffers: *StateBuffers)") != null);
|
||||
|
||||
@@ -502,6 +502,9 @@ pub fn compilerTypecheckCore(allocator: std.mem.Allocator, io: std.Io, base_env:
|
||||
|
||||
pub const DevHostOptions = struct {
|
||||
base_env: *std.process.Environ.Map,
|
||||
/// app.zon capabilities installed by the shipping host. The virtual host
|
||||
/// receives the same set so capability-shed effects reject identically.
|
||||
capabilities: []const []const u8 = &.{},
|
||||
/// NDJSON message script; null = interactive stdin.
|
||||
script: ?[]const u8 = null,
|
||||
/// Re-run the harness whenever any module of the core changes.
|
||||
@@ -542,6 +545,9 @@ pub fn runDevHost(allocator: std.mem.Allocator, io: std.Io, framework_root: []co
|
||||
if (options.script) |script| {
|
||||
try argv.appendSlice(allocator, &.{ "--script", script });
|
||||
}
|
||||
for (options.capabilities) |capability| {
|
||||
try argv.appendSlice(allocator, &.{ "--capability", capability });
|
||||
}
|
||||
if (options.persist_routes) |routes| {
|
||||
try argv.appendSlice(allocator, &.{
|
||||
"--persist-ok",
|
||||
|
||||
@@ -75,6 +75,9 @@ export interface Model {
|
||||
// it must carry through exactly.
|
||||
readonly fracBytes: number;
|
||||
readonly wholeBytes: number;
|
||||
// Dynamic invalid store limit: the facade must keep it on the host's
|
||||
// rejection path instead of truncating it into the default limit.
|
||||
readonly fracStoreLimit: number;
|
||||
// The expectedBytes wire boundary, model-owned like topId: 2^53 - 1
|
||||
// is the last exactly-carried count, and 2^53 (which 2^53 + 1
|
||||
// aliases on the f64 wire) must map to "unknown size" — there is no
|
||||
@@ -152,8 +155,14 @@ export type Msg =
|
||||
| { readonly kind: "mix_reject_flip" }
|
||||
| { readonly kind: "chan_evt"; readonly key: number; readonly state: ChannelState; readonly bytes: Uint8Array; readonly droppedPending: number; readonly droppedTotal: number }
|
||||
| { readonly kind: "notify" }
|
||||
| { readonly kind: "store_put" }
|
||||
| { readonly kind: "store_get" }
|
||||
| { readonly kind: "store_delete" }
|
||||
| { readonly kind: "store_scan" }
|
||||
| { readonly kind: "store_many" }
|
||||
| { readonly kind: "open_pty" }
|
||||
| { readonly kind: "pty_evt"; readonly key: Uint8Array; readonly state: PtyState; readonly bytes: Uint8Array; readonly code: number; readonly reason: PtyExitReason; readonly signal: number; readonly droppedWrites: number };
|
||||
| { readonly kind: "pty_evt"; readonly key: Uint8Array; readonly state: PtyState; readonly bytes: Uint8Array; readonly code: number; readonly reason: PtyExitReason; readonly signal: number; readonly droppedWrites: number }
|
||||
| { readonly kind: "store_scan_invalid" };
|
||||
|
||||
export function initialModel(): [Model, Cmd<Msg>] {
|
||||
return [
|
||||
@@ -195,6 +204,7 @@ export function initialModel(): [Model, Cmd<Msg>] {
|
||||
topId: 9007199254740991, // 2^53 - 1, the last exactly-carried id
|
||||
fracBytes: 1.5,
|
||||
wholeBytes: 4096,
|
||||
fracStoreLimit: 0.5,
|
||||
topBytes: 9007199254740991, // 2^53 - 1, the last exactly-carried count
|
||||
pastBytes: 9007199254740992, // 2^53 — 2^53 + 1 is this same wire value
|
||||
chanState: "closed",
|
||||
@@ -451,6 +461,24 @@ export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
|
||||
subtitle: asciiBytes("native-sdk"),
|
||||
body: asciiBytes("TS core notification"),
|
||||
})];
|
||||
case "store_put":
|
||||
return [model, Cmd.store.set("fixture/one", model.status, { key: "store", ok: "wrote", err: "failed" })];
|
||||
case "store_get":
|
||||
return [model, Cmd.store.get("fixture/one", { key: "store", ok: "loaded", err: "failed" })];
|
||||
case "store_delete":
|
||||
return [model, Cmd.store.delete("fixture/one", { key: "store", ok: "wrote", err: "failed" })];
|
||||
case "store_scan":
|
||||
return [model, Cmd.store.scan("fixture/café/", { limit: 7, after: utf8Bytes("fixture/café/🚀") }, { key: "store", ok: "loaded", err: "failed" })];
|
||||
case "store_scan_invalid":
|
||||
// Keep this value model-derived so the facade must preserve a dynamic
|
||||
// invalid number for the host's over_bound rejection path.
|
||||
return [model, Cmd.store.scan("", { limit: model.fracStoreLimit }, { key: "store", ok: "loaded", err: "failed" })];
|
||||
case "store_many":
|
||||
return [model, Cmd.store.setMany([
|
||||
["fixture/one", asciiBytes("one")],
|
||||
["fixture/two", model.status],
|
||||
["fixture/café/🚀/next", asciiBytes("page")],
|
||||
], { key: "store", ok: "wrote", err: "failed" })];
|
||||
case "open_pty":
|
||||
return [model, Cmd.ptySpawn([asciiBytes("/bin/sh")], { key: "fixture-pty", event: "pty_evt" })];
|
||||
case "pty_evt":
|
||||
|
||||
@@ -101,6 +101,12 @@ fn e2eCommand(name: []const u8) ?fixture.Msg {
|
||||
if (std.mem.eql(u8, name, "core.mixreject")) return .mix_reject;
|
||||
if (std.mem.eql(u8, name, "core.mixrejectflip")) return .mix_reject_flip;
|
||||
if (std.mem.eql(u8, name, "core.notify")) return .notify;
|
||||
if (std.mem.eql(u8, name, "core.storeput")) return .store_put;
|
||||
if (std.mem.eql(u8, name, "core.storeget")) return .store_get;
|
||||
if (std.mem.eql(u8, name, "core.storedelete")) return .store_delete;
|
||||
if (std.mem.eql(u8, name, "core.storescan")) return .store_scan;
|
||||
if (std.mem.eql(u8, name, "core.storescaninvalid")) return .store_scan_invalid;
|
||||
if (std.mem.eql(u8, name, "core.storemany")) return .store_many;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -136,6 +142,19 @@ const tick_platform_id: u64 = runtime_ns.effect_timer_platform_id_base + 0;
|
||||
/// takes bridge op slot 0, deterministically in issue order.
|
||||
const first_effect_key: u64 = runtime_ns.ts_core_effect_key_base + 0;
|
||||
|
||||
fn storePayloadU32(bytes: []const u8, at: *usize) u32 {
|
||||
const value = std.mem.readInt(u32, bytes[at.*..][0..4], .little);
|
||||
at.* += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
fn storePayloadField(bytes: []const u8, at: *usize) []const u8 {
|
||||
const len: usize = @intCast(storePayloadU32(bytes, at));
|
||||
const field = bytes[at.*..][0..len];
|
||||
at.* += len;
|
||||
return field;
|
||||
}
|
||||
|
||||
/// The delay's platform id: with the subscription tick occupying
|
||||
/// engine timer slot 0 from boot, the first armed delay lands in
|
||||
/// engine timer slot 1.
|
||||
@@ -542,6 +561,74 @@ test "writeFile and readFile round-trip real disk through the compiled core" {
|
||||
try std.testing.expectEqualStrings("ready", Bridge.model().status);
|
||||
}
|
||||
|
||||
test "every Cmd.store factory emits its bounded v3 record through the external core" {
|
||||
HostStub.reset();
|
||||
const h = try Harness.createFake();
|
||||
defer h.destroy();
|
||||
const fx = &h.app_state.effects;
|
||||
|
||||
try fx.feedHostResult(status_request_key, true, "ready");
|
||||
try h.wake();
|
||||
|
||||
try h.menu("core.storeput");
|
||||
var request = fx.pendingHostAt(0).?;
|
||||
try std.testing.expectEqualStrings("core.store.set", request.name);
|
||||
var at: usize = 0;
|
||||
try std.testing.expectEqual(@as(u32, 0), storePayloadU32(request.payload, &at));
|
||||
try std.testing.expectEqualStrings("fixture/one", storePayloadField(request.payload, &at));
|
||||
try std.testing.expectEqualStrings("ready", storePayloadField(request.payload, &at));
|
||||
try fx.feedHostResult(request.key, true, "");
|
||||
try h.wake();
|
||||
|
||||
try h.menu("core.storeget");
|
||||
request = fx.pendingHostAt(0).?;
|
||||
try std.testing.expectEqualStrings("core.store.get", request.name);
|
||||
at = 4;
|
||||
try std.testing.expectEqualStrings("fixture/one", storePayloadField(request.payload, &at));
|
||||
try fx.feedHostResult(request.key, true, &.{ 1, 'o', 'n', 'e' });
|
||||
try h.wake();
|
||||
try std.testing.expectEqualSlices(u8, &.{ 1, 'o', 'n', 'e' }, Bridge.model().status);
|
||||
|
||||
try h.menu("core.storescan");
|
||||
request = fx.pendingHostAt(0).?;
|
||||
try std.testing.expectEqualStrings("core.store.scan", request.name);
|
||||
at = 4;
|
||||
try std.testing.expectEqualStrings("fixture/café/", storePayloadField(request.payload, &at));
|
||||
try std.testing.expectEqual(@as(u32, 7), storePayloadU32(request.payload, &at));
|
||||
try std.testing.expectEqualStrings("fixture/café/🚀", storePayloadField(request.payload, &at));
|
||||
try fx.feedHostResult(request.key, true, "page");
|
||||
try h.wake();
|
||||
|
||||
// A dynamic fractional limit must not truncate to zero (the default page
|
||||
// size) in the facade. It reaches the host as the over-bound sentinel and
|
||||
// takes the declared error route without issuing a storage request.
|
||||
try h.menu("core.storescaninvalid");
|
||||
try h.wake();
|
||||
try std.testing.expectEqual(@as(usize, 0), fx.pendingHostCount());
|
||||
try std.testing.expectEqual(@as(i64, 1), Bridge.model().failures);
|
||||
try std.testing.expectEqualStrings("over_bound", Bridge.model().lastErr);
|
||||
|
||||
try h.menu("core.storemany");
|
||||
request = fx.pendingHostAt(0).?;
|
||||
try std.testing.expectEqualStrings("core.store.setMany", request.name);
|
||||
at = 4;
|
||||
try std.testing.expectEqual(@as(u32, 3), storePayloadU32(request.payload, &at));
|
||||
try std.testing.expectEqualStrings("fixture/one", storePayloadField(request.payload, &at));
|
||||
try std.testing.expectEqualStrings("one", storePayloadField(request.payload, &at));
|
||||
try std.testing.expectEqualStrings("fixture/two", storePayloadField(request.payload, &at));
|
||||
try std.testing.expectEqualStrings("page", storePayloadField(request.payload, &at));
|
||||
try std.testing.expectEqualStrings("fixture/café/🚀/next", storePayloadField(request.payload, &at));
|
||||
try std.testing.expectEqualStrings("page", storePayloadField(request.payload, &at));
|
||||
try fx.feedHostResult(request.key, true, "");
|
||||
try h.wake();
|
||||
|
||||
try h.menu("core.storedelete");
|
||||
request = fx.pendingHostAt(0).?;
|
||||
try std.testing.expectEqualStrings("core.store.delete", request.name);
|
||||
try fx.feedHostResult(request.key, true, "");
|
||||
try h.wake();
|
||||
}
|
||||
|
||||
test "clipboardWrite and clipboardRead ride the platform pasteboard" {
|
||||
HostStub.reset();
|
||||
const h = try Harness.create();
|
||||
@@ -1458,9 +1545,12 @@ fn recordSession(buffer: *JournalBuffer) !CoreSnapshot {
|
||||
|
||||
HostStub.reset();
|
||||
removeStore();
|
||||
var record_store = try native_sdk.RecordStore.openMemory(std.testing.allocator);
|
||||
defer record_store.deinit();
|
||||
const h = try Harness.createRecorded(recorder);
|
||||
defer h.destroy();
|
||||
const fx = &h.app_state.effects;
|
||||
fx.bindRecordStore(record_store.binding());
|
||||
|
||||
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
||||
|
||||
@@ -1489,6 +1579,26 @@ fn recordSession(buffer: *JournalBuffer) !CoreSnapshot {
|
||||
try h.menu("core.load");
|
||||
try h.waitPending();
|
||||
try h.wake();
|
||||
// Real SQLite-backed writes, a hit, and a non-empty scan page all enter
|
||||
// the ordinary journaled host-result stream. Replay below binds no
|
||||
// database at all, so those recorded terminals are the sole result source.
|
||||
try h.menu("core.storeput");
|
||||
try h.waitPending();
|
||||
try h.wake();
|
||||
try h.menu("core.storemany");
|
||||
try h.waitPending();
|
||||
try h.wake();
|
||||
try h.menu("core.storeget");
|
||||
try h.wake();
|
||||
try std.testing.expectEqualSlices(u8, &.{ 1, 'o', 'n', 'e' }, Bridge.model().status);
|
||||
try h.menu("core.storescan");
|
||||
try h.wake();
|
||||
try std.testing.expectEqual(@as(u32, 1), std.mem.readInt(u32, Bridge.model().status[0..4], .little));
|
||||
// Restore a human-readable final model value after the scan's framed
|
||||
// binary page so the snapshot remains easy to diagnose.
|
||||
try h.menu("core.load");
|
||||
try h.waitPending();
|
||||
try h.wake();
|
||||
try h.menu("core.share");
|
||||
try h.menu("core.paste");
|
||||
try h.wake();
|
||||
@@ -1511,7 +1621,7 @@ test "a recorded compiled-core session replays byte-identically with no host cal
|
||||
try std.testing.expectEqual(@as(f64, 50_000), recorded.stampMs);
|
||||
try std.testing.expectEqual(@as(i64, 1), recorded.failures);
|
||||
try std.testing.expectEqualStrings("ready", recorded.status[0..recorded.statusLen]);
|
||||
try std.testing.expectEqual(@as(i64, 1), recorded.saved);
|
||||
try std.testing.expectEqual(@as(i64, 3), recorded.saved);
|
||||
try std.testing.expectEqual(@as(f64, 450), recorded.firedAt);
|
||||
|
||||
// Determinism pin: the same driven session records byte-identical
|
||||
@@ -1524,7 +1634,7 @@ test "a recorded compiled-core session replays byte-identically with no host cal
|
||||
try std.testing.expectEqualSlices(u8, buffer.journalBytes(), second.journalBytes());
|
||||
|
||||
// Replay into a fresh app: journaled `.host`/`.file`/`.clipboard`
|
||||
// results and the journaled clock feed the stub executor; the
|
||||
// results (including SQLite writes, get hit, and scan page) and the journaled clock feed the stub executor; the
|
||||
// platform timer events (subscription ticks AND the delay fire)
|
||||
// replay from the event log; the host binding is NEVER called.
|
||||
// Deleting the store first proves the replayed file ops touch no
|
||||
@@ -1556,7 +1666,7 @@ test "a recorded compiled-core session replays byte-identically with no host cal
|
||||
// of running. (Timer fires ride the event log.) Nothing touched
|
||||
// the stub host — and the deleted store proves nothing touched
|
||||
// the disk.
|
||||
try std.testing.expectEqual(@as(u64, 7), report.effects_fed);
|
||||
try std.testing.expectEqual(@as(u64, 12), report.effects_fed);
|
||||
try std.testing.expectEqual(@as(usize, 0), HostStub.request_count);
|
||||
try std.testing.expectEqual(@as(usize, 0), HostStub.send_count);
|
||||
try std.testing.expectEqualDeep(recorded, CoreSnapshot.take());
|
||||
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# SQLite amalgamation
|
||||
|
||||
This directory vendors the SQLite 3.53.4 amalgamation (`sqlite3.c` and
|
||||
`sqlite3.h`) downloaded from the official SQLite distribution:
|
||||
|
||||
- archive: `sqlite-amalgamation-3530400.zip`
|
||||
- archive SHA3-256: `628a44cfe82c66aed1ccbbe85a562d2e33ebe64b3288981ed76285612227934e`
|
||||
- `sqlite3.c` SHA3-256: `67f423e9ebbbdc473cbc4772c872ee6b89f31fde4ed0279a5c25d5f65c043a16`
|
||||
|
||||
SQLite is in the public domain. See <https://sqlite.org/copyright.html>.
|
||||
|
||||
Only apps declaring the `store` or `sqlite` capability compile this source
|
||||
into their artifact.
|
||||
Vendored
+269649
File diff suppressed because it is too large
Load Diff
Vendored
+14349
File diff suppressed because it is too large
Load Diff
@@ -147,6 +147,7 @@ const Codec = enum {
|
||||
w_bool,
|
||||
w_bytes,
|
||||
short_text,
|
||||
utf8_text,
|
||||
trunc_toward_zero,
|
||||
enum_index,
|
||||
ascii_string,
|
||||
@@ -203,8 +204,9 @@ const FacadeEmitter = struct {
|
||||
.w_u64 => &.{ .w_u32, .trunc_toward_zero, .trap },
|
||||
.w_bytes => &.{.w_u32},
|
||||
.short_text => &.{ .sink, .trap },
|
||||
.utf8_text => &.{},
|
||||
.enum_index => &.{.trap},
|
||||
.cmd_encoder => &.{ .w_u8, .w_f64, .w_bytes, .short_text, .enum_index, .trap },
|
||||
.cmd_encoder => &.{ .w_u8, .w_f64, .w_bytes, .short_text, .utf8_text, .enum_index, .trap },
|
||||
.sub_encoder => &.{ .w_u8, .w_f64, .short_text },
|
||||
else => &.{},
|
||||
};
|
||||
@@ -2565,6 +2567,62 @@ const FacadeEmitter = struct {
|
||||
\\
|
||||
);
|
||||
}
|
||||
if (self.used_codec.contains(.utf8_text)) {
|
||||
try self.raw(
|
||||
\\
|
||||
\\// Store keys are ordinary UTF-8 text, not the ASCII-only
|
||||
\\// short names used by command routing. This mirrors the SDK's
|
||||
\\// utf8Bytes intrinsic, including U+FFFD for lone surrogates.
|
||||
\\function nscfUtf8TextBytes(text: string): Uint8Array {
|
||||
\\ let byteLength = 0;
|
||||
\\ for (let i = 0; i < text.length; i++) {
|
||||
\\ const code = text.charCodeAt(i);
|
||||
\\ if (code <= 0x7f) byteLength += 1;
|
||||
\\ else if (code <= 0x7ff) byteLength += 2;
|
||||
\\ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
|
||||
\\ const next = text.charCodeAt(i + 1);
|
||||
\\ if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
\\ byteLength += 4;
|
||||
\\ i += 1;
|
||||
\\ } else byteLength += 3;
|
||||
\\ } else byteLength += 3;
|
||||
\\ }
|
||||
\\ const out = new Uint8Array(byteLength);
|
||||
\\ let at = 0;
|
||||
\\ for (let i = 0; i < text.length; i++) {
|
||||
\\ let code = text.charCodeAt(i);
|
||||
\\ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
|
||||
\\ const next = text.charCodeAt(i + 1);
|
||||
\\ if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
\\ code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
|
||||
\\ i += 1;
|
||||
\\ } else code = 0xfffd;
|
||||
\\ } else if (code >= 0xd800 && code <= 0xdfff) code = 0xfffd;
|
||||
\\ if (code <= 0x7f) {
|
||||
\\ out[at] = code;
|
||||
\\ at += 1;
|
||||
\\ } else if (code <= 0x7ff) {
|
||||
\\ out[at] = 0xc0 | (code >> 6);
|
||||
\\ out[at + 1] = 0x80 | (code & 0x3f);
|
||||
\\ at += 2;
|
||||
\\ } else if (code <= 0xffff) {
|
||||
\\ out[at] = 0xe0 | (code >> 12);
|
||||
\\ out[at + 1] = 0x80 | ((code >> 6) & 0x3f);
|
||||
\\ out[at + 2] = 0x80 | (code & 0x3f);
|
||||
\\ at += 3;
|
||||
\\ } else {
|
||||
\\ out[at] = 0xf0 | (code >> 18);
|
||||
\\ out[at + 1] = 0x80 | ((code >> 12) & 0x3f);
|
||||
\\ out[at + 2] = 0x80 | ((code >> 6) & 0x3f);
|
||||
\\ out[at + 3] = 0x80 | (code & 0x3f);
|
||||
\\ at += 4;
|
||||
\\ }
|
||||
\\ }
|
||||
\\ return out;
|
||||
\\}
|
||||
\\
|
||||
);
|
||||
}
|
||||
if (self.used_codec.contains(.ascii_string)) {
|
||||
try self.raw(
|
||||
\\
|
||||
@@ -2613,6 +2671,14 @@ const FacadeEmitter = struct {
|
||||
\\const nscfAudioCaptureSources = ["microphone", "system"];
|
||||
\\const nscfVideoVerbs = ["play", "pause", "stop", "seek", "volume", "muted", "loop"];
|
||||
\\
|
||||
\\// Preserve the store err route for dynamic invalid limits. Writing a
|
||||
\\// fractional or out-of-u32 number directly into the byte sink would
|
||||
\\// truncate/wrap it into a different, potentially valid request. 257
|
||||
\\// is the host's stable over-bound sentinel (the public maximum is 256).
|
||||
\\function nscfStoreScanLimit(value: number): number {{
|
||||
\\ return Number.isInteger(value) && value >= 0 && value <= 256 ? value : 257;
|
||||
\\}}
|
||||
\\
|
||||
\\function nscfEncodeCmd(sink: nscfSink, cmd: nscfCmd<{s}>): void {{
|
||||
\\
|
||||
, .{msg});
|
||||
@@ -2752,6 +2818,46 @@ const FacadeEmitter = struct {
|
||||
\\ nscfWU8(sink, 0x22);
|
||||
\\ nscfWU8(sink, cmd.visible ? 1 : 0);
|
||||
\\ return;
|
||||
\\ case "store_set":
|
||||
\\ nscfWU8(sink, 0x23);
|
||||
\\ nscfWShortText(sink, cmd.key);
|
||||
\\ nscfWU8(sink, nscfTagOf(cmd.okKind));
|
||||
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
|
||||
\\ nscfWU32(sink, 0);
|
||||
\\ nscfWBytes(sink, nscfUtf8TextBytes(cmd.storeKey));
|
||||
\\ nscfWBytes(sink, cmd.bytes);
|
||||
\\ return;
|
||||
\\ case "store_get":
|
||||
\\ case "store_delete":
|
||||
\\ nscfWU8(sink, cmd.op === "store_get" ? 0x24 : 0x25);
|
||||
\\ nscfWShortText(sink, cmd.key);
|
||||
\\ nscfWU8(sink, nscfTagOf(cmd.okKind));
|
||||
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
|
||||
\\ nscfWU32(sink, 0);
|
||||
\\ nscfWBytes(sink, nscfUtf8TextBytes(cmd.storeKey));
|
||||
\\ return;
|
||||
\\ case "store_scan":
|
||||
\\ nscfWU8(sink, 0x26);
|
||||
\\ nscfWShortText(sink, cmd.key);
|
||||
\\ nscfWU8(sink, nscfTagOf(cmd.okKind));
|
||||
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
|
||||
\\ nscfWU32(sink, 0);
|
||||
\\ nscfWBytes(sink, nscfUtf8TextBytes(cmd.prefix));
|
||||
\\ nscfWU32(sink, nscfStoreScanLimit(cmd.limit));
|
||||
\\ nscfWBytes(sink, typeof cmd.after === "string" ? nscfUtf8TextBytes(cmd.after) : cmd.after);
|
||||
\\ return;
|
||||
\\ case "store_set_many":
|
||||
\\ nscfWU8(sink, 0x27);
|
||||
\\ nscfWShortText(sink, cmd.key);
|
||||
\\ nscfWU8(sink, nscfTagOf(cmd.okKind));
|
||||
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
|
||||
\\ nscfWU32(sink, 0);
|
||||
\\ nscfWU32(sink, cmd.entries.length);
|
||||
\\ for (let i = 0; i < cmd.entries.length; i++) {
|
||||
\\ nscfWBytes(sink, nscfUtf8TextBytes(cmd.entries[i]![0]));
|
||||
\\ nscfWBytes(sink, cmd.entries[i]![1]);
|
||||
\\ }
|
||||
\\ return;
|
||||
\\ case "quit_app":
|
||||
\\ nscfWU8(sink, 0x11);
|
||||
\\ return;
|
||||
@@ -3585,6 +3691,8 @@ test "facade emission is deterministic and carries the adapter surface" {
|
||||
// collide with them.
|
||||
try testing.expect(std.mem.indexOf(u8, first, "type nscfSink = number[];") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, first, "import type { Cmd as nscfCmd }") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, first, "Number.isInteger(value) && value >= 0 && value <= 256 ? value : 257") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, first, "nscfWU32(sink, nscfStoreScanLimit(cmd.limit));") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, first, "NscfSink") == null);
|
||||
try testing.expect(std.mem.indexOf(u8, first, "NSCF_TAG_") == null);
|
||||
}
|
||||
|
||||
@@ -260,6 +260,7 @@ pub fn main(init: std.process.Init) !void {
|
||||
const dev_metadata = try tooling.manifest.readMetadata(allocator, init.io, "app.zon");
|
||||
tooling.ts_core.runDevHost(allocator, init.io, framework_root, .{
|
||||
.base_env = init.environ_map,
|
||||
.capabilities = dev_metadata.capabilities,
|
||||
.script = try flagValue(args, "--script"),
|
||||
.watch = flagBool(args, "--watch"),
|
||||
.persist_routes = if (dev_metadata.persist) |persist| .{
|
||||
|
||||
Reference in New Issue
Block a user