snapback4-expo 0.4.11 → 0.4.12

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
@@ -31,6 +31,13 @@ SQLite and the native-query Wasm host. The earlier module measured 1.7 MB
31
31
  linked and about a millisecond per call; those measurements predate the
32
32
  native-query host.
33
33
 
34
+ The pinned Expo 57.0.22 apps list `snapback4-expo/plugin` in app.json's
35
+ `plugins`. Keep that entry when generating iOS: it supplies the scene lifecycle
36
+ required by Xcode 27, shared by the reference chat app and both starters.
37
+ After adding it, run `npx expo prebuild --platform ios`, rebuild Release and
38
+ reinstall. Preserve custom native changes when regenerating; the plugin handles
39
+ the standard Swift starter and refuses custom delegates/manifests.
40
+
34
41
  Earlier iOS simulator evidence from the repository's witness in a development
35
42
  build (before the published driver, recorded in
36
43
  `metrics/witness.jsonl` as `dev-client-native`): a cold sync of 303 rows
@@ -59,13 +66,13 @@ runner, commands and artifact contract are in `snapback4 guide witness`
59
66
  “Proving it on an iOS simulator”. No Expo Go or Metro process supplies a
60
67
  Release witness.
61
68
 
62
- At every milestone, run `npm run export:ios` for Hermes; on a Mac with Xcode,
63
- `npm run native:smoke` (about 3 minutes warm). The Expo starters include both scripts.
64
- The smoke builds and installs your Release app, chooses its own simulator and free
69
+ At every milestone, `npm run export:ios` keeps Hermes in `dist/` and also proves the app runs on iOS when Xcode/runtime are present (warm runs take about 18 s for the plain starter and 27 s for the social one, cold runs 175–230 s, on this mini).
70
+ `npm run native:smoke` always runs the full Release build; `SNAPBACK4_NATIVE_SMOKE=off` skips the automatic smoke. Unavailable hosts skip, app/SDK failures fail.
71
+ The smoke prepares and installs your Release app, chooses its own simulator and free
65
72
  port, and keeps receipts in `artifacts/native-smoke/<timestamp>`. It proves local
66
73
  store acquisition, cold offline reopen and reconnect. Pending write replay, media
67
74
  and application assertions need the deeper scenario in `snapback4 guide witness`.
68
- An empty starter proves lifecycle only; export alone proves no native execution.
75
+ An empty starter proves lifecycle only; a skipped smoke proves no native execution.
69
76
 
70
77
  Own-build preparation (`witness ios --build . --smoke`) uses the witness helper
71
78
  in matching current `snapback4` and `snapback4-expo` packages. Its synchronous native observation hashes
@@ -84,9 +91,9 @@ E_OFFLINE is recorded as expected unavailability. Server-only reads
84
91
  must answer online in prime and restart. Conditional native-module queries
85
92
  require an application witness; generic smoke refuses that unsupported shape. Client semantics are unchanged.
86
93
 
87
- After JS/assets edits, repeat `npm run native:smoke`; simulator selection and
88
- fresh artifact directories are automatic. Warm Xcode intermediates
89
- are reused; each run builds the current app and seals a new receipt. Repack
90
- is withdrawn. NS round 0 cohort (2026-09-30), fresh starter: **221.7 s cold**, **83.5 s warm median (3 runs)**; social **186.2 s**.
91
- Shared Apartment M5 Pro mini, 18 cores/64 GiB, Xcode/iOS 27; one-minute load: cold 3.61–61.41, warm 3.21–7.22, social 14.62–47.52.
92
- Tools/dependencies installed first. Guidance, not a promise. See `guide witness` for scope.
94
+ After JS edits, repeat `npm run export:ios`; simulator selection and fresh
95
+ artifact directories are automatic. Matching native inputs reuse the Release
96
+ app and install the exact exported bundle/assets. Native changes rebuild;
97
+ `npm run native:smoke` always runs Xcode. Both routes retain the same loaded-file
98
+ hash and prime/offline/restart proof. Measured on the Apartment M5 Pro mini,
99
+ 18 cores/64 GiB, Xcode/iOS 27, with tools/dependencies installed first.
package/export-ios.mjs ADDED
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ // @ref LLP 3000.030#15 — exact export, explicit environment skips, bounded evidence.
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { createRequire } from 'node:module';
5
+ import path from 'node:path';
6
+ import fs from 'node:fs';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { createHash } from 'node:crypto';
9
+ import { isDeepStrictEqual } from 'node:util';
10
+
11
+ const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
12
+ function exportFiles(root, relative = '') {
13
+ return fs.readdirSync(path.join(root, relative)).sort().flatMap(name => {
14
+ const file = path.join(relative, name), full = path.join(root, file), stat = fs.lstatSync(full);
15
+ const row = { path: file, mode: stat.mode & 0o777 };
16
+ if (stat.isDirectory()) return [{ ...row, type: 'directory' }, ...exportFiles(root, file)];
17
+ if (!stat.isFile()) throw Error('export contains an unsupported file type');
18
+ const bytes = fs.readFileSync(full);
19
+ return [{ ...row, type: 'file', bytes: bytes.length, sha256: sha256(bytes) }];
20
+ });
21
+ }
22
+ function publicationExpectation(artifacts, source, skipped, smoke) {
23
+ if (skipped) return { proofSha256: null, files: exportFiles(source) };
24
+ const bytes = fs.readFileSync(path.join(artifacts, 'proof.json'));
25
+ const proofSha256 = sha256(bytes), files = JSON.parse(bytes).exported?.inventory?.files;
26
+ if (smoke?.exitStatus !== 0 || smoke?.proof?.sha256 !== proofSha256 || !Array.isArray(files)) throw Error('export proof does not match successful smoke receipt');
27
+ return { proofSha256, files };
28
+ }
29
+ export function verifyPublication(artifacts, source, target, expected) {
30
+ let receipt;
31
+ try { receipt = JSON.parse(fs.readFileSync(path.join(artifacts, 'publication.json'), 'utf8')); }
32
+ catch { throw Error('missing or unreadable verified publication.json'); }
33
+ if (receipt.source !== source || receipt.target !== target || !Number.isFinite(receipt.verifiedAt) ||
34
+ receipt.proofSha256 !== expected.proofSha256 || !isDeepStrictEqual(receipt.files, expected.files)) {
35
+ throw Error('publication.json does not match the export proved by this run');
36
+ }
37
+ }
38
+
39
+ export function skipReason(platform, env) {
40
+ if (env.SNAPBACK4_NATIVE_SMOKE === 'off') return 'SNAPBACK4_NATIVE_SMOKE=off';
41
+ if (platform !== 'darwin') return 'macOS is required';
42
+ return null;
43
+ }
44
+ const environmentConditions = new Set(['simulator-list', 'missing-runtime', 'missing-device-type', 'simulator-create', 'simulator-boot']);
45
+ export function smokeResult(status, receipt) {
46
+ if (status === 0) return { status: 0, message: 'native smoke passed' };
47
+ if (receipt?.environmentCondition === 'simulator-create' && /^E_WITNESS_TIMEOUT:/.test(receipt.error)) return { status: status || 1, message: 'native smoke failed' };
48
+ if (receipt?.phase === 'environment' && environmentConditions.has(receipt.environmentCondition) &&
49
+ /^(E_WITNESS_PLATFORM|E_WITNESS_SIMULATOR|E_WITNESS_TIMEOUT):/.test(receipt.error)) {
50
+ return { status: 0, message: `native smoke skipped: ${receipt.error}` };
51
+ }
52
+ return { status: status || 1, message: 'native smoke failed' };
53
+ }
54
+ // The Rust driver bounds its own cleanup and keeps its leases until it finishes.
55
+ // Wait for that exit even after a second signal; killing only its wrapper would
56
+ // orphan the driver's backend/app. This also forwards cancellation during export.
57
+ export function runChild(command, args, options = {}) {
58
+ return new Promise(resolve => {
59
+ const child = spawn(command, args, { stdio: 'inherit', env: process.env, ...options });
60
+ let received;
61
+ const handlers = Object.fromEntries(['SIGINT', 'SIGTERM'].map(signal => [signal, () => {
62
+ if (!received) { received = signal; child.kill(signal); }
63
+ }]));
64
+ for (const [signal, handler] of Object.entries(handlers)) process.on(signal, handler);
65
+ child.once('error', () => {}); // close also follows a failed spawn
66
+ child.once('close', (status, signal) => {
67
+ for (const [name, handler] of Object.entries(handlers)) process.off(name, handler);
68
+ const cancelled = received ?? signal;
69
+ resolve({ status: cancelled ? (cancelled === 'SIGINT' ? 130 : 143) : status ?? 1, cancelled });
70
+ });
71
+ });
72
+ }
73
+ // OS-locked pruning/recovery lives beside the driver's ownership records.
74
+ // Failure here is maintenance work, never a change to a completed smoke result.
75
+ export function retainEvidence(root, completed, binary = 'snapback4') {
76
+ const result = spawnSync(binary, ['witness', 'export-cleanup', root, ...(completed ? [completed] : [])], { encoding: 'utf8', timeout: 120_000 });
77
+ if (result.status !== 0) console.error(`native smoke evidence cleanup deferred: ${result.stderr || result.error?.message || result.status}`);
78
+ return result.status === 0;
79
+ }
80
+ // The helper locks a duplicate of this descriptor, so the kernel lease stays
81
+ // with this process after the helper exits. Children do not inherit it unless
82
+ // explicitly listed in stdio (the publisher must keep it if this wrapper dies).
83
+ export function acquireExportLease(artifacts, binary = 'snapback4') {
84
+ const fd = fs.openSync(path.join(artifacts, 'export.lock'), 'a+');
85
+ const result = spawnSync(binary, ['witness', 'export-lease', artifacts], { encoding: 'utf8', env: { ...process.env, SNAPBACK4_EXPORT_WRAPPER_PID: String(process.pid) }, stdio: ['ignore', 'pipe', 'pipe', fd] });
86
+ if (result.status !== 0) { fs.closeSync(fd); throw Error(result.stderr || result.error?.message || 'export lease failed'); }
87
+ return fd;
88
+ }
89
+ export async function main() {
90
+ const req = createRequire(path.resolve('package.json'));
91
+ const skip = skipReason(process.platform, process.env);
92
+ if (skip) {
93
+ const exported = await runChild(process.execPath, [req.resolve('expo/bin/cli'), 'export', '--platform', 'ios', '--dump-assetmap']);
94
+ if (exported.status !== 0) return exported.status;
95
+ console.log(`native smoke skipped: ${skip}`); return 0;
96
+ }
97
+ const root = path.resolve('artifacts/native-smoke');
98
+ const artifacts = path.join(root, `${Date.now()}-${process.pid}`);
99
+ const privateExport = path.join(artifacts, 'export');
100
+ const receiptPath = path.join(artifacts, 'smoke-ios-run.json');
101
+ fs.mkdirSync(artifacts, { recursive: true });
102
+ const lease = acquireExportLease(artifacts);
103
+ let publicationAttempted = false;
104
+ try {
105
+ const exported = await runChild(process.execPath, [req.resolve('expo/bin/cli'), 'export', '--platform', 'ios', '--dump-assetmap', '--output-dir', privateExport]);
106
+ if (exported.status !== 0) return exported.status;
107
+ const result = await runChild('snapback4', ['witness', 'ios', '--build', '.', '--smoke', '--export', privateExport, '--artifacts', artifacts], { env: { ...process.env, SNAPBACK4_EXPORT_WRAPPER_PID: String(process.pid) } });
108
+ let receipt;
109
+ try { receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')); } catch {}
110
+ const outcome = result.cancelled ? { status: result.status, message: 'native smoke interrupted' } : smokeResult(result.status, receipt);
111
+ if (outcome.status === 0) {
112
+ const expected = publicationExpectation(artifacts, privateExport, result.status !== 0, receipt);
113
+ publicationAttempted = true;
114
+ const published = await runChild('snapback4', ['witness', 'export-publish', privateExport, path.resolve('dist'), ...(result.status === 0 ? [] : ['--environment-skip'])], { stdio: ['inherit', 'inherit', 'inherit', lease] });
115
+ try {
116
+ verifyPublication(artifacts, privateExport, path.resolve('dist'), expected);
117
+ if (published.status !== 0) throw Error(`publisher exited with status ${published.status}`);
118
+ } catch (error) { outcome.status = published.status || 1; outcome.message = `export output publication failed: ${error.message}`; }
119
+ }
120
+ console.log(`${outcome.message}; receipt: ${receiptPath}`);
121
+ return outcome.status;
122
+ } finally {
123
+ // After a publication attempt this path may be the displaced last good
124
+ // export. Only the publisher, under its destination lock, may remove it.
125
+ try { if (!publicationAttempted) fs.rmSync(privateExport, { recursive: true, force: true }); }
126
+ finally { fs.closeSync(lease); retainEvidence(root, artifacts); }
127
+ }
128
+ }
129
+ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) {
130
+ try { process.exitCode = await main(); }
131
+ catch (error) { console.error(`export:ios failed: ${error.message}`); process.exitCode = 1; }
132
+ }
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "snapback4-expo",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
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",
7
7
  "types": "src/index.ts",
8
8
  "exports": {
9
- ".": { "types": "./src/index.ts", "default": "./src/index.ts" },
10
- "./witness": { "types": "./src/witness.ts", "default": "./src/witness.ts" },
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ },
13
+ "./witness": {
14
+ "types": "./src/witness.ts",
15
+ "default": "./src/witness.ts"
16
+ },
11
17
  "./plugin": "./plugin.cjs",
12
18
  "./package.json": "./package.json"
13
19
  },
@@ -16,7 +22,8 @@
16
22
  "src",
17
23
  "ios",
18
24
  "expo-module.config.json",
19
- "README.md"
25
+ "README.md",
26
+ "export-ios.mjs"
20
27
  ],
21
28
  "peerDependencies": {
22
29
  "expo": "*",
@@ -25,5 +32,8 @@
25
32
  "devDependencies": {
26
33
  "expo-modules-core": "*",
27
34
  "typescript": "5.9.3"
35
+ },
36
+ "bin": {
37
+ "snapback4-export-ios": "export-ios.mjs"
28
38
  }
29
39
  }
package/plugin.cjs CHANGED
@@ -1,4 +1,4 @@
1
- // @ref LLP 3000.030#13 — the pinned Expo starter must launch on iOS 27 without a manual migration.
1
+ // @ref LLP 3000.030#13 — one published lifecycle for reference and generated Expo 57.0.22 apps; preserve existing plugin configs.
2
2
  const { withAppDelegate, withInfoPlist } = require('expo/config-plugins');
3
3
  const marker = '// Snapback4 starter scene lifecycle';
4
4
  const legacy = `#if os(iOS) || os(tvOS)
package/src/witness.ts CHANGED
@@ -21,3 +21,11 @@ export function smokeSegment(): "prime" | "offline" | "restart" | null {
21
21
  const config = JSON.parse(container.witnessLaunch);
22
22
  return config.smoke === true && ["prime", "offline", "restart"].includes(config.segment) ? config.segment : null;
23
23
  }
24
+
25
+ /** Simulator smoke uses its owned loopback backend; ordinary launches keep the app URL. */
26
+ export function smokeOrigin(fallback: string): string {
27
+ const container = requireOptionalNativeModule<WitnessContainer>("Snapback4");
28
+ if (!container?.witnessLaunch) return fallback;
29
+ const config = JSON.parse(container.witnessLaunch);
30
+ return config.smoke === true && /^http:\/\/127\.0\.0\.1:[0-9]+$/.test(config.origin) ? config.origin : fallback;
31
+ }