snapback4-expo 0.3.0 → 0.4.1
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.
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snapback4-expo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// online tier (`snapback4/client`) when it is not.
|
|
5
5
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
|
6
6
|
import type { DeviceCore, DeviceOpener } from "snapback4/local";
|
|
7
|
+
import type { WitnessContainer } from "./witness-core";
|
|
7
8
|
|
|
8
|
-
interface Snapback4Native {
|
|
9
|
+
interface Snapback4Native extends WitnessContainer {
|
|
9
10
|
readonly documentDirectory: string;
|
|
10
11
|
open(path: string, backend: string | null): Promise<number>;
|
|
11
12
|
call(handle: number, request: string): Promise<string>;
|
|
@@ -19,6 +20,9 @@ interface Snapback4Native {
|
|
|
19
20
|
const native = requireOptionalNativeModule<Snapback4Native>("Snapback4");
|
|
20
21
|
// EXPO_PUBLIC_SNAPBACK_TRACE=1 logs every call's op, time and answer size.
|
|
21
22
|
const trace = Boolean(process.env.EXPO_PUBLIC_SNAPBACK_TRACE);
|
|
23
|
+
// Metro includes the runner but does not evaluate it on an ordinary launch.
|
|
24
|
+
const smoke = native?.witnessLaunch && JSON.parse(native.witnessLaunch).smoke === true
|
|
25
|
+
? (require("./smoke") as typeof import("./smoke")).createSmoke(native) : null;
|
|
22
26
|
|
|
23
27
|
export function nativeAvailable(): boolean {
|
|
24
28
|
return native !== null;
|
|
@@ -34,7 +38,8 @@ export const nativeDevice: DeviceOpener = {
|
|
|
34
38
|
async open(name, backend): Promise<DeviceCore> {
|
|
35
39
|
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)");
|
|
36
40
|
const handle = await native.open(`${native.documentDirectory.replace(/\/$/, "")}/${name}.sqlite`, backend ? JSON.stringify(backend) : null);
|
|
37
|
-
|
|
41
|
+
const core: DeviceCore = {
|
|
42
|
+
...(smoke ? { onClient: (client, url) => smoke.attach(client, core, url) } : {}),
|
|
38
43
|
media: {
|
|
39
44
|
read: (id) => native.readAsset(handle, id),
|
|
40
45
|
write: (id, hex) => native.writeAsset(handle, id, hex),
|
|
@@ -51,5 +56,6 @@ export const nativeDevice: DeviceOpener = {
|
|
|
51
56
|
native.close(handle);
|
|
52
57
|
},
|
|
53
58
|
};
|
|
59
|
+
return core;
|
|
54
60
|
},
|
|
55
61
|
};
|
package/src/smoke.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// @ref LLP 3000.011.005.000#2 — execute inside the installed app on its own client.
|
|
2
|
+
import type { Client, Query } from "snapback4/contract";
|
|
3
|
+
import type { DeviceCore } from "snapback4/local";
|
|
4
|
+
import { createWitness, type WitnessContainer } from "./witness-core.ts";
|
|
5
|
+
|
|
6
|
+
type State = {
|
|
7
|
+
rows: Record<string, number>; watermark: number; generation: number;
|
|
8
|
+
storeId: string; acquired: boolean; backendRequired: boolean;
|
|
9
|
+
};
|
|
10
|
+
type Backend = {
|
|
11
|
+
programs: { name: string; kind: string; args: Record<string, unknown> }[];
|
|
12
|
+
schema: { operations?: Record<string, { runs?: "device" | "server"; held?: boolean }> };
|
|
13
|
+
};
|
|
14
|
+
type Baseline = State & { origin: string; viewer: string };
|
|
15
|
+
type ErrorUtils = {
|
|
16
|
+
getGlobalHandler(): (error: Error, fatal?: boolean) => void;
|
|
17
|
+
setGlobalHandler(handler: (error: Error, fatal?: boolean) => void): void;
|
|
18
|
+
};
|
|
19
|
+
type Environment = {
|
|
20
|
+
errors?: ErrorUtils;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
pause?: () => Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
function assert(ok: unknown, message: string): asserts ok {
|
|
25
|
+
if (!ok) throw new Error(`E_WITNESS_STEP: ${message}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Kept separately from Expo imports so the segment logic runs against mock devices. */
|
|
29
|
+
export function createSmoke(container: WitnessContainer, environment: Environment = {}) {
|
|
30
|
+
if (!container.witnessLaunch) return null;
|
|
31
|
+
const config = JSON.parse(container.witnessLaunch);
|
|
32
|
+
if (config.smoke !== true) return null;
|
|
33
|
+
assert(["prime", "offline", "restart"].includes(config.segment), "unknown smoke segment");
|
|
34
|
+
const w = createWitness(container)!;
|
|
35
|
+
const now = environment.now ?? Date.now;
|
|
36
|
+
const pause = environment.pause ?? (() => new Promise<void>(resolve => setTimeout(resolve, 100)));
|
|
37
|
+
const startedAt = now();
|
|
38
|
+
const errors: { message: string; fatal: boolean }[] = [];
|
|
39
|
+
const errorUtils = environment.errors ?? (globalThis as typeof globalThis & { ErrorUtils?: ErrorUtils }).ErrorUtils;
|
|
40
|
+
const previous = errorUtils?.getGlobalHandler();
|
|
41
|
+
const handler = (error: Error, fatal?: boolean) => {
|
|
42
|
+
errors.push({ message: String(error?.message ?? error), fatal: fatal === true });
|
|
43
|
+
// Synchronous native append survives the previous handler terminating JS.
|
|
44
|
+
try {
|
|
45
|
+
container.witnessAppend(JSON.stringify({ smoke: { segment: w.segment, pass: false, errors: [...errors] },
|
|
46
|
+
witness: { at: new Date().toISOString(), step: "smoke:failed" } }) + "\n");
|
|
47
|
+
} finally { previous?.(error, fatal); }
|
|
48
|
+
};
|
|
49
|
+
errorUtils?.setGlobalHandler(handler);
|
|
50
|
+
type Attachment = { client: Client; device: DeviceCore; origin: string };
|
|
51
|
+
const superseded = Symbol("superseded smoke client");
|
|
52
|
+
let latest: Attachment | undefined;
|
|
53
|
+
let running: Promise<void> | undefined;
|
|
54
|
+
const checkTime = () => {
|
|
55
|
+
assert(errors.length === 0, "uncaught JavaScript error");
|
|
56
|
+
assert(now() - startedAt < config.timeoutMs, "timed out waiting for smoke client or sync watermark");
|
|
57
|
+
};
|
|
58
|
+
const start = () => running ??= w.step({ prime: "primed", offline: "offline-done", restart: "done" }[w.segment]!, async () => {
|
|
59
|
+
for (;;) {
|
|
60
|
+
checkTime();
|
|
61
|
+
if (!latest) { await pause(); continue; }
|
|
62
|
+
const attached = latest;
|
|
63
|
+
try { await sample(attached); return; }
|
|
64
|
+
catch (error) { if (error !== superseded) throw error; }
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const attach = (client: Client, device: DeviceCore, origin: string): (() => void) => {
|
|
68
|
+
const attached = latest = { client, device, origin };
|
|
69
|
+
void start().catch(() => { /* step already durably recorded the failure */ });
|
|
70
|
+
return () => { if (latest === attached) latest = undefined; };
|
|
71
|
+
};
|
|
72
|
+
async function sample(attached: Attachment) {
|
|
73
|
+
const { client, device, origin } = attached;
|
|
74
|
+
const current = () => { if (latest !== attached) throw superseded; };
|
|
75
|
+
let state: State | undefined;
|
|
76
|
+
const queries: Record<string, unknown>[] = [];
|
|
77
|
+
const read = async <T>(op: string): Promise<T> => {
|
|
78
|
+
current();
|
|
79
|
+
let result: Record<string, unknown>;
|
|
80
|
+
try { result = await device.call({ op }); } finally { current(); }
|
|
81
|
+
assert(!result.denied && result.ok, `${op} refused: ${JSON.stringify(result.denied)}`);
|
|
82
|
+
return result.ok as T;
|
|
83
|
+
};
|
|
84
|
+
const wait = async (condition: () => Promise<boolean>) => {
|
|
85
|
+
while (!(await condition())) { checkTime(); await pause(); current(); }
|
|
86
|
+
};
|
|
87
|
+
const snapshot = async () => {
|
|
88
|
+
state = await read<State>("witness_state");
|
|
89
|
+
assert(state && typeof state.storeId === "string" && state.storeId.length > 0
|
|
90
|
+
&& Number.isSafeInteger(state.watermark) && state.watermark >= 0
|
|
91
|
+
&& state.rows && Object.values(state.rows).every(n => Number.isSafeInteger(n) && n >= 0), "invalid device smoke state");
|
|
92
|
+
};
|
|
93
|
+
const ready = () => client.viewer() !== null && client.link() === (w.segment === "offline" ? "offline" : "online")
|
|
94
|
+
&& client.acquired && state!.acquired && !state!.backendRequired;
|
|
95
|
+
const identity = () => {
|
|
96
|
+
if (w.segment === "prime") return;
|
|
97
|
+
const baseline = config.baseline as Baseline | undefined;
|
|
98
|
+
assert(baseline && baseline.origin === origin && baseline.viewer === client.viewer()
|
|
99
|
+
&& baseline.storeId === state!.storeId && baseline.generation === state!.generation,
|
|
100
|
+
"prime's backend/session identity does not match this launch");
|
|
101
|
+
if (w.segment === "offline") {
|
|
102
|
+
assert(JSON.stringify(Object.entries(state!.rows).sort()) === JSON.stringify(Object.entries(baseline.rows).sort()), "held row counts differ from prime");
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const barrier = () => {
|
|
106
|
+
assert(Number.isSafeInteger(config.barrierSeq) && config.barrierSeq > config.baseline?.watermark, "invalid restart sync barrier");
|
|
107
|
+
return state!.watermark >= config.barrierSeq;
|
|
108
|
+
};
|
|
109
|
+
const receipt = (pass: boolean, error?: unknown) => w.receipt({ smoke: {
|
|
110
|
+
segment: w.segment, pass, startedAt, endedAt: now(), durationMs: now() - startedAt,
|
|
111
|
+
origin, viewer: client.viewer(), link: client.link(), ...state, queries, errors: [...errors],
|
|
112
|
+
...(w.segment === "restart" ? { barrierSeq: config.barrierSeq } : {}),
|
|
113
|
+
...(error ? { error: error instanceof Error ? error.message : String(error) } : {}),
|
|
114
|
+
} });
|
|
115
|
+
try {
|
|
116
|
+
assert(errorUtils, "the native JavaScript error handler is unavailable");
|
|
117
|
+
await wait(async () => { await snapshot(); return ready(); });
|
|
118
|
+
identity();
|
|
119
|
+
if (w.segment === "offline") {
|
|
120
|
+
const backend = await read<Backend>("backend");
|
|
121
|
+
for (const program of backend.programs) {
|
|
122
|
+
if (program.kind !== "Query" || !Object.values(program.args).every(type => type !== null && typeof type === "object" && "Optional" in type)) continue;
|
|
123
|
+
const fact = backend.schema.operations?.[program.name] ?? {};
|
|
124
|
+
const op = { kind: "query", name: program.name, ...(fact.runs ? { runs: fact.runs } : {}), ...(fact.held ? { held: true } : {}) } as Query<{}, unknown>;
|
|
125
|
+
const result = await client.query(op, {});
|
|
126
|
+
current();
|
|
127
|
+
if ("data" in result) {
|
|
128
|
+
queries.push({ name: program.name, state: "ready", count: Array.isArray(result.data) ? result.data.length : result.data == null ? 0 : 1,
|
|
129
|
+
complete: result.complete, fresh: result.fresh, cut: result.cut });
|
|
130
|
+
} else if ("denied" in result && result.denied.code && result.denied.message) {
|
|
131
|
+
queries.push({ name: program.name, state: "refused", code: result.denied.code, message: result.denied.message });
|
|
132
|
+
} else { throw new Error(`query ${program.name} returned neither ready nor an explained refusal`); }
|
|
133
|
+
}
|
|
134
|
+
} else if (w.segment === "restart") {
|
|
135
|
+
await wait(async () => { await snapshot(); identity(); return ready() && barrier(); });
|
|
136
|
+
}
|
|
137
|
+
// Give the mounted app a turn, then attest the state that exists now.
|
|
138
|
+
await pause();
|
|
139
|
+
await snapshot();
|
|
140
|
+
assert(ready(), "session, link or acquisition changed before receipt");
|
|
141
|
+
identity();
|
|
142
|
+
if (w.segment === "restart") assert(barrier(), "sync watermark is below the restart barrier");
|
|
143
|
+
assert(errors.length === 0, "uncaught JavaScript error");
|
|
144
|
+
current();
|
|
145
|
+
await receipt(true);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
current(); // A closed or replaced candidate cannot emit failure evidence.
|
|
148
|
+
await receipt(false, error); throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { attach, run: (client: Client, device: DeviceCore, origin: string) => {
|
|
152
|
+
attach(client, device, origin); return running!;
|
|
153
|
+
}, dispose: () => { latest = undefined; if (previous) errorUtils?.setGlobalHandler(previous); } };
|
|
154
|
+
}
|
package/src/witness.ts
CHANGED
|
@@ -5,6 +5,9 @@ export type { Witness } from "./witness-core";
|
|
|
5
5
|
let instance: Witness | null | undefined;
|
|
6
6
|
/** Null on ordinary launches and outside an iOS simulator driven by `snapback4 witness ios`. */
|
|
7
7
|
export function witness(): Witness | null {
|
|
8
|
-
if (instance === undefined)
|
|
8
|
+
if (instance === undefined) {
|
|
9
|
+
const container = requireOptionalNativeModule<WitnessContainer>("Snapback4");
|
|
10
|
+
instance = container?.witnessLaunch && JSON.parse(container.witnessLaunch).smoke === true ? null : createWitness(container);
|
|
11
|
+
}
|
|
9
12
|
return instance;
|
|
10
13
|
}
|