snapback4-expo 0.2.0 → 0.2.2

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 CHANGED
@@ -11,8 +11,6 @@ import { createLocalClient } from "snapback4/local";
11
11
  import { createClient } from "snapback4/client";
12
12
  import { nativeAvailable, nativeDevice } from "snapback4-expo";
13
13
 
14
- const safe = (name: string) => name.replace(/[^A-Za-z0-9._-]/g, "_"); // store and session names carry the URL and the viewer
15
- const sqliteStore = { open: async (name: string, schema: unknown) => openSqlite(expoSqliteDriver(await SQLite.openDatabaseAsync(`${safe(name)}.db`)), schema) };
16
14
  const client = nativeAvailable()
17
15
  ? await createLocalClient({ url, guest: true, sessionStore, device: nativeDevice })
18
16
  : createClient({ url, session }); // Expo Go: the online tier, with a session the app keeps
@@ -27,14 +25,24 @@ Expo Go loads no custom native code, so the module is present only in a
27
25
  development or production build: add `expo-dev-client`, then
28
26
  `npx expo run:ios` (the module autolinks; nothing to configure). The
29
27
  Rust library ships in the package as `ios/Snapback4Device.xcframework`
30
- (9.1 MB, a 4.8 MB archive a slice, built by `scripts/build-xcframework.sh`
31
- from `crates/snapback4-device`); it costs an app 1.7 MB linked, SQLite
32
- included, and a module call takes about a millisecond.
28
+ (about 13.2 MB in 0.2.1, a 6.6 MB archive per slice, built by
29
+ `scripts/build-xcframework.sh` from `crates/snapback4-device`). It includes
30
+ SQLite and the native-query Wasm host. The earlier module measured 1.7 MB
31
+ linked and about a millisecond per call; those measurements predate the
32
+ native-query host.
33
33
 
34
- Proved on the iOS simulator by the repository's witness in a development
34
+ Earlier iOS simulator evidence from the repository's witness in a development
35
35
  build (`WITNESS_DEV_CLIENT=1 node witness/expo.mjs`, recorded in
36
36
  `metrics/witness.jsonl` as `dev-client-native`): a cold sync of 303 rows
37
37
  in 31 ms, a reopen in 6 ms, a cold start with no network answering the
38
38
  thread from the store in 2 ms, and writes composed offline surviving a
39
39
  restart and landing in order. `EXPO_PUBLIC_SNAPBACK_TRACE=1` logs every
40
40
  call's op, time and answer size. Android follows.
41
+
42
+ Ordinary media can be retained with `createLocalClient({ ..., device: nativeDevice,
43
+ assets: { keep: "viewed", maxBytes: 67108864 } })`. The opener supplies private
44
+ file storage beside each device store in Documents. Offline `client.asset` and
45
+ `useAsset` return a file URI; online calls revalidate. The client purges bytes
46
+ on home-row revocation, deletion, clearing or expiry and on sign-out; `once`
47
+ bytes never enter file storage. Rebuild the native app to include this storage
48
+ capability. `keep: "none"` (default) retains no bytes.
@@ -9,9 +9,13 @@
9
9
  * Ops: state, adopt {backend}, apply {page, first}, query {name, viewer, args, now},
10
10
  * predict {name, viewer, args, now, newIds, entropy}, withdraw {predicted},
11
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.
12
+ * set_meta {key, value}, clear_rows. Nothing unwinds across this boundary:
13
+ * unwind builds translate an internal panic to E_STORE; production uses
14
+ * panic=abort and terminates before unwinding. Every answer is JSON the
15
+ * caller frees. A device is not thread-safe: one caller at a time, as the
16
+ * store is one transaction at a time.
17
+ * `apply.first` must be true on the first page of a snapshot. It defaults
18
+ * to false so an omitted marker cannot discard a snapshot already staged.
15
19
  */
16
20
  #ifndef SNAPBACK4_H
17
21
  #define SNAPBACK4_H
@@ -26,7 +30,10 @@ typedef struct sb4_device sb4_device;
26
30
  * (ask `sb4_open_reason` why). */
27
31
  sb4_device *sb4_open(const char *path, const char *backend_json);
28
32
 
29
- /* Why `sb4_open` would refuse, as JSON to free: {"ok":true} when it would open. */
33
+ /* Take the refusal recorded by the most recent failed `sb4_open` on this
34
+ * thread, as JSON to free. This diagnoses that attempt without opening or
35
+ * mutating the store a second time. The arguments are retained for ABI
36
+ * compatibility. */
30
37
  char *sb4_open_reason(const char *path, const char *backend_json);
31
38
 
32
39
  /* One call; the answer is JSON to free with `sb4_free`. */
@@ -9,9 +9,13 @@
9
9
  * Ops: state, adopt {backend}, apply {page, first}, query {name, viewer, args, now},
10
10
  * predict {name, viewer, args, now, newIds, entropy}, withdraw {predicted},
11
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.
12
+ * set_meta {key, value}, clear_rows. Nothing unwinds across this boundary:
13
+ * unwind builds translate an internal panic to E_STORE; production uses
14
+ * panic=abort and terminates before unwinding. Every answer is JSON the
15
+ * caller frees. A device is not thread-safe: one caller at a time, as the
16
+ * store is one transaction at a time.
17
+ * `apply.first` must be true on the first page of a snapshot. It defaults
18
+ * to false so an omitted marker cannot discard a snapshot already staged.
15
19
  */
16
20
  #ifndef SNAPBACK4_H
17
21
  #define SNAPBACK4_H
@@ -26,7 +30,10 @@ typedef struct sb4_device sb4_device;
26
30
  * (ask `sb4_open_reason` why). */
27
31
  sb4_device *sb4_open(const char *path, const char *backend_json);
28
32
 
29
- /* Why `sb4_open` would refuse, as JSON to free: {"ok":true} when it would open. */
33
+ /* Take the refusal recorded by the most recent failed `sb4_open` on this
34
+ * thread, as JSON to free. This diagnoses that attempt without opening or
35
+ * mutating the store a second time. The arguments are retained for ABI
36
+ * compatibility. */
30
37
  char *sb4_open_reason(const char *path, const char *backend_json);
31
38
 
32
39
  /* One call; the answer is JSON to free with `sb4_free`. */
@@ -6,6 +6,7 @@ import Snapback4Device
6
6
  // link and the transport. Handles are small integers the module hands out.
7
7
  public final class Snapback4Module: Module {
8
8
  private var devices: [Int: OpaquePointer] = [:]
9
+ private var mediaDirectories: [Int: URL] = [:]
9
10
  private var nextHandle = 1
10
11
  private let lock = NSLock()
11
12
 
@@ -32,6 +33,7 @@ public final class Snapback4Module: Module {
32
33
  let handle = self.nextHandle
33
34
  self.nextHandle += 1
34
35
  self.devices[handle] = device
36
+ self.mediaDirectories[handle] = URL(fileURLWithPath: path + ".media", isDirectory: true)
35
37
  return handle
36
38
  }
37
39
 
@@ -49,14 +51,55 @@ public final class Snapback4Module: Module {
49
51
  return String(cString: answer)
50
52
  }
51
53
 
54
+ // @ref LLP 3000.007#8 — the opener owns ordinary media in Documents
55
+ AsyncFunction("readAsset") { (handle: Int, id: String) -> String? in
56
+ self.lock.lock()
57
+ defer { self.lock.unlock() }
58
+ let file = try self.assetFile(handle, id)
59
+ guard FileManager.default.fileExists(atPath: file.path) else { return nil }
60
+ return try Data(contentsOf: file).map { String(format: "%02x", $0) }.joined()
61
+ }
62
+ AsyncFunction("writeAsset") { (handle: Int, id: String, hex: String) in
63
+ self.lock.lock()
64
+ defer { self.lock.unlock() }
65
+ let file = try self.assetFile(handle, id)
66
+ let chars = Array(hex.utf8)
67
+ guard chars.count % 2 == 0 else { throw Snapback4OpenException("invalid media bytes") }
68
+ var bytes = Data(capacity: chars.count / 2)
69
+ for i in stride(from: 0, to: chars.count, by: 2) {
70
+ guard let byte = UInt8(String(bytes: chars[i...i+1], encoding: .utf8) ?? "", radix: 16) else { throw Snapback4OpenException("invalid media bytes") }
71
+ bytes.append(byte)
72
+ }
73
+ try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
74
+ try bytes.write(to: file, options: .atomic)
75
+ }
76
+ AsyncFunction("removeAsset") { (handle: Int, id: String) in
77
+ self.lock.lock()
78
+ defer { self.lock.unlock() }
79
+ let file = try self.assetFile(handle, id)
80
+ if FileManager.default.fileExists(atPath: file.path) { try FileManager.default.removeItem(at: file) }
81
+ }
82
+ Function("assetUri") { (handle: Int, id: String) -> String in
83
+ self.lock.lock()
84
+ defer { self.lock.unlock() }
85
+ return try self.assetFile(handle, id).absoluteString
86
+ }
87
+
52
88
  Function("close") { (handle: Int) in
53
89
  self.lock.lock()
54
90
  defer { self.lock.unlock() }
91
+ self.mediaDirectories.removeValue(forKey: handle)
55
92
  if let device = self.devices.removeValue(forKey: handle) {
56
93
  sb4_close(device)
57
94
  }
58
95
  }
59
96
  }
97
+ private func assetFile(_ handle: Int, _ id: String) throws -> URL {
98
+ guard let directory = mediaDirectories[handle], !id.isEmpty,
99
+ id.utf8.allSatisfy({ ($0 >= 48 && $0 <= 57) || ($0 >= 65 && $0 <= 90) || ($0 >= 97 && $0 <= 122) })
100
+ else { throw Snapback4OpenException("invalid media id or closed device") }
101
+ return directory.appendingPathComponent(id)
102
+ }
60
103
  }
61
104
 
62
105
  internal final class Snapback4OpenException: GenericException<String> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snapback4-expo",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
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
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -10,6 +10,10 @@ interface Snapback4Native {
10
10
  open(path: string, backend: string | null): Promise<number>;
11
11
  call(handle: number, request: string): Promise<string>;
12
12
  close(handle: number): void;
13
+ readAsset(handle: number, id: string): Promise<string | null>;
14
+ writeAsset(handle: number, id: string, hex: string): Promise<void>;
15
+ removeAsset(handle: number, id: string): Promise<void>;
16
+ assetUri(handle: number, id: string): string;
13
17
  }
14
18
 
15
19
  const native = requireOptionalNativeModule<Snapback4Native>("Snapback4");
@@ -31,6 +35,12 @@ export const nativeDevice: DeviceOpener = {
31
35
  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
36
  const handle = await native.open(`${native.documentDirectory.replace(/\/$/, "")}/${name}.sqlite`, backend ? JSON.stringify(backend) : null);
33
37
  return {
38
+ media: {
39
+ read: (id) => native.readAsset(handle, id),
40
+ write: (id, hex) => native.writeAsset(handle, id, hex),
41
+ remove: (id) => native.removeAsset(handle, id),
42
+ uri: (id) => native.assetUri(handle, id),
43
+ },
34
44
  async call(request) {
35
45
  const t0 = trace ? Date.now() : 0;
36
46
  const answer = await native.call(handle, JSON.stringify(request));