snapback4-expo 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # snapback4-expo
2
+
3
+ Snapback 4's device in Rust, as an Expo module: the partition in its own
4
+ SQLite, the stream applied, the outbox kept, queries and predictions
5
+ answered — the same interpreter the server runs — behind one JSON call.
6
+ `snapback4/native` is the client over it:
7
+
8
+ ```ts
9
+ import { createNativeClient } from "snapback4/native";
10
+ import { nativeAvailable, nativeDevice, documentDirectory } from "snapback4-expo";
11
+
12
+ const client = nativeAvailable()
13
+ ? await createNativeClient({ url, guest: true, sessionStore, native: nativeDevice, dataDir: documentDirectory() })
14
+ : await createLocalClient({ url, guest: true, sessionStore, store: sqliteStore }); // Expo Go: the TypeScript tier
15
+ ```
16
+
17
+ Expo Go loads no custom native code, so the module is present only in a
18
+ development or production build: add `expo-dev-client`, then
19
+ `npx expo run:ios` (the module autolinks; nothing to configure). The
20
+ Rust library ships in the package as `ios/Snapback4Device.xcframework`
21
+ (9.1 MB, a 4.8 MB archive a slice, built by `scripts/build-xcframework.sh`
22
+ from `crates/snapback4-device`); it costs an app 1.7 MB linked, SQLite
23
+ included, and a module call takes about a millisecond.
24
+
25
+ Proved on the iOS simulator by the repository's witness in a development
26
+ build (`WITNESS_DEV_CLIENT=1 node witness/expo.mjs`, recorded in
27
+ `metrics/witness.jsonl` as `dev-client-native`): a cold sync of 303 rows
28
+ in 31 ms, a reopen in 6 ms, a cold start with no network answering the
29
+ thread from the store in 2 ms, and writes composed offline surviving a
30
+ restart and landing in order. `EXPO_PUBLIC_SNAPBACK_TRACE=1` logs every
31
+ call's op, time and answer size. Android follows.
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["apple"],
3
+ "apple": {
4
+ "modules": ["Snapback4Module"]
5
+ }
6
+ }
@@ -0,0 +1,47 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>AvailableLibraries</key>
6
+ <array>
7
+ <dict>
8
+ <key>BinaryPath</key>
9
+ <string>libsnapback4_device.a</string>
10
+ <key>HeadersPath</key>
11
+ <string>Headers</string>
12
+ <key>LibraryIdentifier</key>
13
+ <string>ios-arm64-simulator</string>
14
+ <key>LibraryPath</key>
15
+ <string>libsnapback4_device.a</string>
16
+ <key>SupportedArchitectures</key>
17
+ <array>
18
+ <string>arm64</string>
19
+ </array>
20
+ <key>SupportedPlatform</key>
21
+ <string>ios</string>
22
+ <key>SupportedPlatformVariant</key>
23
+ <string>simulator</string>
24
+ </dict>
25
+ <dict>
26
+ <key>BinaryPath</key>
27
+ <string>libsnapback4_device.a</string>
28
+ <key>HeadersPath</key>
29
+ <string>Headers</string>
30
+ <key>LibraryIdentifier</key>
31
+ <string>ios-arm64</string>
32
+ <key>LibraryPath</key>
33
+ <string>libsnapback4_device.a</string>
34
+ <key>SupportedArchitectures</key>
35
+ <array>
36
+ <string>arm64</string>
37
+ </array>
38
+ <key>SupportedPlatform</key>
39
+ <string>ios</string>
40
+ </dict>
41
+ </array>
42
+ <key>CFBundlePackageType</key>
43
+ <string>XFWK</string>
44
+ <key>XCFrameworkFormatVersion</key>
45
+ <string>1.0</string>
46
+ </dict>
47
+ </plist>
@@ -0,0 +1,4 @@
1
+ module Snapback4Device {
2
+ header "snapback4.h"
3
+ export *
4
+ }
@@ -0,0 +1,42 @@
1
+ /* Snapback 4 on a device: the native module's C ABI. One JSON call.
2
+ *
3
+ * sb4_device *d = sb4_open("/data/bob.sqlite", backend_json); // GET /schema's JSON
4
+ * char *answer = sb4_call(d, "{\"op\":\"query\",\"name\":\"thread\",\"viewer\":\"dev:bob\",\"args\":{\"conversationId\":\"c1\"},\"now\":1789200000000}");
5
+ * ... answer is {"ok": ...} or {"denied": {"code","family","message",...}} ...
6
+ * sb4_free(answer);
7
+ * sb4_close(d);
8
+ *
9
+ * Ops: state, adopt {backend}, apply {page, first}, query {name, viewer, args, now},
10
+ * predict {name, viewer, args, now, newIds, entropy}, withdraw {predicted},
11
+ * enqueue {entry}, queued, next_submission, dequeue {id}, meta {key},
12
+ * set_meta {key, value}, clear_rows. Nothing panics across this boundary;
13
+ * every answer is JSON the caller frees. A device is not thread-safe: one
14
+ * caller at a time, as the store is one transaction at a time.
15
+ */
16
+ #ifndef SNAPBACK4_H
17
+ #define SNAPBACK4_H
18
+
19
+ #ifdef __cplusplus
20
+ extern "C" {
21
+ #endif
22
+
23
+ typedef struct sb4_device sb4_device;
24
+
25
+ /* Open the device's store at `path` for the backend; NULL when it refuses
26
+ * (ask `sb4_open_reason` why). */
27
+ sb4_device *sb4_open(const char *path, const char *backend_json);
28
+
29
+ /* Why `sb4_open` would refuse, as JSON to free: {"ok":true} when it would open. */
30
+ char *sb4_open_reason(const char *path, const char *backend_json);
31
+
32
+ /* One call; the answer is JSON to free with `sb4_free`. */
33
+ char *sb4_call(sb4_device *device, const char *request_json);
34
+
35
+ void sb4_free(char *text);
36
+ void sb4_close(sb4_device *device);
37
+
38
+ #ifdef __cplusplus
39
+ }
40
+ #endif
41
+
42
+ #endif
@@ -0,0 +1,4 @@
1
+ module Snapback4Device {
2
+ header "snapback4.h"
3
+ export *
4
+ }
@@ -0,0 +1,42 @@
1
+ /* Snapback 4 on a device: the native module's C ABI. One JSON call.
2
+ *
3
+ * sb4_device *d = sb4_open("/data/bob.sqlite", backend_json); // GET /schema's JSON
4
+ * char *answer = sb4_call(d, "{\"op\":\"query\",\"name\":\"thread\",\"viewer\":\"dev:bob\",\"args\":{\"conversationId\":\"c1\"},\"now\":1789200000000}");
5
+ * ... answer is {"ok": ...} or {"denied": {"code","family","message",...}} ...
6
+ * sb4_free(answer);
7
+ * sb4_close(d);
8
+ *
9
+ * Ops: state, adopt {backend}, apply {page, first}, query {name, viewer, args, now},
10
+ * predict {name, viewer, args, now, newIds, entropy}, withdraw {predicted},
11
+ * enqueue {entry}, queued, next_submission, dequeue {id}, meta {key},
12
+ * set_meta {key, value}, clear_rows. Nothing panics across this boundary;
13
+ * every answer is JSON the caller frees. A device is not thread-safe: one
14
+ * caller at a time, as the store is one transaction at a time.
15
+ */
16
+ #ifndef SNAPBACK4_H
17
+ #define SNAPBACK4_H
18
+
19
+ #ifdef __cplusplus
20
+ extern "C" {
21
+ #endif
22
+
23
+ typedef struct sb4_device sb4_device;
24
+
25
+ /* Open the device's store at `path` for the backend; NULL when it refuses
26
+ * (ask `sb4_open_reason` why). */
27
+ sb4_device *sb4_open(const char *path, const char *backend_json);
28
+
29
+ /* Why `sb4_open` would refuse, as JSON to free: {"ok":true} when it would open. */
30
+ char *sb4_open_reason(const char *path, const char *backend_json);
31
+
32
+ /* One call; the answer is JSON to free with `sb4_free`. */
33
+ char *sb4_call(sb4_device *device, const char *request_json);
34
+
35
+ void sb4_free(char *text);
36
+ void sb4_close(sb4_device *device);
37
+
38
+ #ifdef __cplusplus
39
+ }
40
+ #endif
41
+
42
+ #endif
@@ -0,0 +1,26 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'Snapback4Expo'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = 'MIT'
11
+ s.author = 'Snapback'
12
+ s.homepage = 'https://github.com/ccheever/snapback'
13
+ s.platforms = { :ios => '16.4' }
14
+ s.source = { git: '' }
15
+ s.static_framework = true
16
+ s.dependency 'ExpoModulesCore'
17
+
18
+ # The Rust module: both iOS targets, the C header and its module map,
19
+ # built by scripts/build-xcframework.sh.
20
+ s.vendored_frameworks = 'Snapback4Device.xcframework'
21
+ s.source_files = '*.swift'
22
+ s.pod_target_xcconfig = {
23
+ 'DEFINES_MODULE' => 'YES',
24
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
25
+ }
26
+ end
@@ -0,0 +1,66 @@
1
+ import ExpoModulesCore
2
+ import Snapback4Device
3
+
4
+ // The device in Rust, opened once per store and reached through one JSON
5
+ // call; the JavaScript tier (`snapback4/native`) keeps the session, the
6
+ // link and the transport. Handles are small integers the module hands out.
7
+ public final class Snapback4Module: Module {
8
+ private var devices: [Int: OpaquePointer] = [:]
9
+ private var nextHandle = 1
10
+ private let lock = NSLock()
11
+
12
+ public func definition() -> ModuleDefinition {
13
+ Name("Snapback4")
14
+
15
+ // Where an app keeps its stores: the documents directory.
16
+ Constant("documentDirectory") {
17
+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.path ?? NSTemporaryDirectory()
18
+ }
19
+
20
+ // Open the device's store at `path` for the backend (`GET /schema`'s
21
+ // JSON), or on the one it kept when `backend` is nil. Throws with the
22
+ // module's refusal when it cannot open.
23
+ AsyncFunction("open") { (path: String, backend: String?) -> Int in
24
+ guard let device = sb4_open(path, backend) else {
25
+ let reason = sb4_open_reason(path, backend)
26
+ let text = reason.map { String(cString: $0) } ?? "{\"denied\":{\"code\":\"E_STORE\",\"family\":\"store\",\"message\":\"the device did not open\"}}"
27
+ sb4_free(reason)
28
+ throw Snapback4OpenException(text)
29
+ }
30
+ self.lock.lock()
31
+ defer { self.lock.unlock() }
32
+ let handle = self.nextHandle
33
+ self.nextHandle += 1
34
+ self.devices[handle] = device
35
+ return handle
36
+ }
37
+
38
+ // One call: `request` is JSON with an `op`; the answer is JSON.
39
+ AsyncFunction("call") { (handle: Int, request: String) -> String in
40
+ self.lock.lock()
41
+ defer { self.lock.unlock() }
42
+ guard let device = self.devices[handle] else {
43
+ return "{\"denied\":{\"code\":\"E_INPUT\",\"family\":\"input\",\"message\":\"no device with that handle\"}}"
44
+ }
45
+ guard let answer = sb4_call(device, request) else {
46
+ return "{\"denied\":{\"code\":\"E_STORE\",\"family\":\"store\",\"message\":\"the module answered nothing\"}}"
47
+ }
48
+ defer { sb4_free(answer) }
49
+ return String(cString: answer)
50
+ }
51
+
52
+ Function("close") { (handle: Int) in
53
+ self.lock.lock()
54
+ defer { self.lock.unlock() }
55
+ if let device = self.devices.removeValue(forKey: handle) {
56
+ sb4_close(device)
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ internal final class Snapback4OpenException: GenericException<String> {
63
+ override var reason: String {
64
+ param
65
+ }
66
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "snapback4-expo",
3
+ "version": "0.1.14",
4
+ "type": "module",
5
+ "description": "Snapback 4's native module for Expo: the device in Rust (the partition in its own SQLite, the stream applied, the outbox kept, queries and predictions answered) behind one JSON call, and the opener `snapback4/native` takes.",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "files": ["src", "ios", "expo-module.config.json", "README.md"],
9
+ "peerDependencies": {
10
+ "expo": "*",
11
+ "snapback4": "*"
12
+ },
13
+ "devDependencies": {
14
+ "expo-modules-core": "*",
15
+ "typescript": "5.9.3"
16
+ }
17
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ // The opener `snapback4/native` takes, over the Expo module: absent in
2
+ // Expo Go (which loads no custom native code), present in a development
3
+ // or production build. An app asks `nativeAvailable()` and falls back to
4
+ // the TypeScript tier (`snapback4/local` over expo-sqlite) when it is not.
5
+ import { requireOptionalNativeModule } from "expo-modules-core";
6
+ import type { NativeCore, NativeOpener } from "snapback4/native";
7
+
8
+ interface Snapback4Native {
9
+ readonly documentDirectory: string;
10
+ open(path: string, backend: string | null): Promise<number>;
11
+ call(handle: number, request: string): Promise<string>;
12
+ close(handle: number): void;
13
+ }
14
+
15
+ const native = requireOptionalNativeModule<Snapback4Native>("Snapback4");
16
+ // EXPO_PUBLIC_SNAPBACK_TRACE=1 logs every call's op, time and answer size.
17
+ const trace = Boolean(process.env.EXPO_PUBLIC_SNAPBACK_TRACE);
18
+
19
+ export function nativeAvailable(): boolean {
20
+ return native !== null;
21
+ }
22
+
23
+ /** The directory the module suggests for an app's stores. */
24
+ export function documentDirectory(): string {
25
+ if (!native) throw new Error("snapback4-expo: the native module is not in this build");
26
+ return native.documentDirectory;
27
+ }
28
+
29
+ export const nativeDevice: NativeOpener = {
30
+ async open(path, backend): Promise<NativeCore> {
31
+ if (!native) throw new Error("snapback4-expo: the native module is not in this build (Expo Go loads no custom native code; make a development build)");
32
+ const handle = await native.open(path, backend ? JSON.stringify(backend) : null);
33
+ return {
34
+ async call(request) {
35
+ const t0 = trace ? Date.now() : 0;
36
+ const answer = await native.call(handle, JSON.stringify(request));
37
+ if (trace) console.log(`snapback4-expo ${String((request as { op?: string }).op)} ${Date.now() - t0} ms ${answer.length} B`);
38
+ return JSON.parse(answer) as Record<string, unknown>;
39
+ },
40
+ async close() {
41
+ native.close(handle);
42
+ },
43
+ };
44
+ },
45
+ };