@meshline/expo 0.1.0-alpha.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.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/android/build.gradle +20 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/org/meshline/expo/MeshlineRelaySocketModule.kt +82 -0
- package/compatibility/apply-android-fix.mjs +46 -0
- package/compatibility/expo+57.0.25.patch +13 -0
- package/compatibility/expo-modules-core+57.0.19.patch +27 -0
- package/dist/database-path.d.ts +7 -0
- package/dist/database-path.d.ts.map +1 -0
- package/dist/database-path.js +22 -0
- package/dist/database-path.js.map +1 -0
- package/dist/http.d.ts +9 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +64 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/socket.d.ts +23 -0
- package/dist/socket.d.ts.map +1 -0
- package/dist/socket.js +143 -0
- package/dist/socket.js.map +1 -0
- package/dist/sqlite-store.d.ts +27 -0
- package/dist/sqlite-store.d.ts.map +1 -0
- package/dist/sqlite-store.js +246 -0
- package/dist/sqlite-store.js.map +1 -0
- package/expo-module.config.json +5 -0
- package/ios/MeshlineExpo.podspec +19 -0
- package/ios/MeshlineRelaySocketModule.swift +160 -0
- package/package.json +56 -0
- package/src/database-path.ts +17 -0
- package/src/http.ts +44 -0
- package/src/index.ts +47 -0
- package/src/socket.ts +93 -0
- package/src/sqlite-store.ts +187 -0
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meshline/expo",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Expo native adapters for the Meshline TypeScript SDK",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"src",
|
|
11
|
+
"android",
|
|
12
|
+
"ios",
|
|
13
|
+
"compatibility",
|
|
14
|
+
"expo-module.config.json",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"bin": {
|
|
19
|
+
"meshline-expo-android-compat": "compatibility/apply-android-fix.mjs"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@meshline/sdk": "0.1.0-alpha.1",
|
|
29
|
+
"expo": "57.0.25",
|
|
30
|
+
"expo-crypto": "^57.0.3",
|
|
31
|
+
"expo-file-system": "^57.0.7",
|
|
32
|
+
"expo-modules-core": "57.0.19",
|
|
33
|
+
"expo-sqlite": "^57.0.3",
|
|
34
|
+
"react-native": ">=0.86.3 <0.87.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"expo": "57.0.25",
|
|
38
|
+
"expo-crypto": "57.0.3",
|
|
39
|
+
"expo-file-system": "57.0.7",
|
|
40
|
+
"expo-modules-core": "57.0.19",
|
|
41
|
+
"expo-sqlite": "57.0.3",
|
|
42
|
+
"react-native": "0.86.3",
|
|
43
|
+
"react": "19.2.3",
|
|
44
|
+
"@types/react-native__assets-registry": "0.84.0",
|
|
45
|
+
"@types/react": "^19.2.0"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/meshline-network/sdk.git",
|
|
50
|
+
"directory": "typescript/packages/expo"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public",
|
|
54
|
+
"registry": "https://registry.npmjs.org"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Both Expo SQLite and FileSystem consume file URIs on Android and iOS. */
|
|
2
|
+
export function databaseLocation(directory: string, name: string): { directoryUri: string; encodedName: string; fileUri: string } {
|
|
3
|
+
if (!name.trim() || name === '.' || name === '..' || /[\x00-\x1f\x7f/\\:?]/.test(name)) throw new TypeError('Expected a persistent SQLite database basename.');
|
|
4
|
+
if (typeof directory !== 'string' || directory.length === 0) throw new Error('A native SQLite database directory is required.');
|
|
5
|
+
let path = directory;
|
|
6
|
+
if (directory.startsWith('file:///')) {
|
|
7
|
+
if (/[?#]/.test(directory)) throw new TypeError('A database directory URI cannot contain a query or fragment.');
|
|
8
|
+
path = decodeURIComponent(directory.slice('file://'.length));
|
|
9
|
+
}
|
|
10
|
+
if (!path.startsWith('/') || /[\x00-\x1f\x7f\\]/.test(path)) throw new TypeError('Expected an absolute local database directory or file URI.');
|
|
11
|
+
// Android's defaultDatabaseDirectory is an absolute path without a URI scheme.
|
|
12
|
+
// Escape each component once so #, %, spaces and Unicode name the same file
|
|
13
|
+
// in the existence probe and in SQLite's native URI-to-path conversion.
|
|
14
|
+
const directoryUri = `file://${path.split('/').map(encodeURIComponent).join('/').replace(/\/+$/, '')}`;
|
|
15
|
+
const encodedName = encodeURIComponent(name);
|
|
16
|
+
return { directoryUri, encodedName, fileUri: `${directoryUri}/${encodedName}` };
|
|
17
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { throwIfAborted, type RelayFetch, type RelayFetchInit, type RelayFetchResponse } from '@meshline/sdk';
|
|
2
|
+
|
|
3
|
+
interface NativeResponse extends Omit<RelayFetchResponse, 'body'> { readonly body: ReadableStream<Uint8Array> | null }
|
|
4
|
+
|
|
5
|
+
/** Read the abort/error-aware stream, not Expo's native arrayBuffer fast path:
|
|
6
|
+
* that path can remain pending when native state becomes ERROR_RECEIVED. */
|
|
7
|
+
export function createExpoRelayFetch(fetch: (url: string, init: RelayFetchInit) => Promise<NativeResponse>): RelayFetch {
|
|
8
|
+
return async (url, init) => {
|
|
9
|
+
throwIfAborted(init.signal);
|
|
10
|
+
const response = await fetch(url, { ...init, headers: { ...init.headers, 'Cache-Control': 'no-store, no-cache', Pragma: 'no-cache' }, credentials: 'omit', redirect: 'error' });
|
|
11
|
+
const body = response.body; let consumed = false;
|
|
12
|
+
return {
|
|
13
|
+
status: response.status, headers: response.headers,
|
|
14
|
+
...(response.redirected === undefined ? {} : { redirected: response.redirected }), ...(response.type === undefined ? {} : { type: response.type }),
|
|
15
|
+
body: body ? { async cancel() { consumed = true; await body.cancel(); } } : null,
|
|
16
|
+
async arrayBuffer() {
|
|
17
|
+
throwIfAborted(init.signal);
|
|
18
|
+
if (consumed) throw new TypeError('Response body has already been consumed.'); consumed = true;
|
|
19
|
+
if (!body) return new ArrayBuffer(0);
|
|
20
|
+
const reader = body.getReader(); const chunks: Uint8Array[] = []; let length = 0;
|
|
21
|
+
let cancellation: Promise<void> | undefined; let cancellationFailure: { error: unknown } | undefined;
|
|
22
|
+
const abort = () => {
|
|
23
|
+
// Observe cancellation immediately, then propagate any failure
|
|
24
|
+
// in finally after the pending read settles and its lock releases.
|
|
25
|
+
cancellation = reader.cancel(init.signal.reason).then(() => undefined, error => { cancellationFailure = { error }; });
|
|
26
|
+
};
|
|
27
|
+
init.signal.addEventListener('abort', abort, { once: true });
|
|
28
|
+
try {
|
|
29
|
+
for (;;) {
|
|
30
|
+
const chunk = await reader.read(); throwIfAborted(init.signal);
|
|
31
|
+
if (chunk.done) break; chunks.push(chunk.value); length += chunk.value.byteLength;
|
|
32
|
+
}
|
|
33
|
+
const bytes = new Uint8Array(length); let offset = 0;
|
|
34
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
35
|
+
return bytes.buffer;
|
|
36
|
+
} finally {
|
|
37
|
+
init.signal.removeEventListener('abort', abort);
|
|
38
|
+
try { await cancellation; if (cancellationFailure) throw cancellationFailure.error; }
|
|
39
|
+
finally { reader.releaseLock(); }
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { fetch as nativeFetch } from 'expo/fetch';
|
|
2
|
+
import { getRandomValues } from 'expo-crypto';
|
|
3
|
+
import { File } from 'expo-file-system';
|
|
4
|
+
import { defaultDatabaseDirectory, openDatabaseAsync } from 'expo-sqlite';
|
|
5
|
+
import { type RandomSource } from '@meshline/sdk';
|
|
6
|
+
import { NativeSqliteStore } from './sqlite-store.js';
|
|
7
|
+
import { requireNativeModule } from 'expo';
|
|
8
|
+
import { nativeSocketFactory, type NativeSocketModule } from './socket.js';
|
|
9
|
+
import type { RelaySocketFactory } from '@meshline/sdk';
|
|
10
|
+
import { databaseLocation } from './database-path.js';
|
|
11
|
+
import { createExpoRelayFetch } from './http.js';
|
|
12
|
+
|
|
13
|
+
/** Requires a development/production build containing this package's native module. */
|
|
14
|
+
export function createExpoSocketFactory(): RelaySocketFactory {
|
|
15
|
+
return nativeSocketFactory(requireNativeModule<NativeSocketModule>('MeshlineRelaySocket'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ExpoSqliteStoreOptions {
|
|
19
|
+
/** Persistent database basename, not an absolute path or SQLite connection URI. */
|
|
20
|
+
readonly databaseName: string;
|
|
21
|
+
/** Absolute app-private directory path or local file URI. Omit to use expo-sqlite's default. */
|
|
22
|
+
readonly directory?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Native SQLite storage for Expo Modules applications; constructors never open or create files. */
|
|
26
|
+
export class ExpoSqliteStore extends NativeSqliteStore {
|
|
27
|
+
constructor(options: ExpoSqliteStoreOptions) {
|
|
28
|
+
const location = databaseLocation(options.directory ?? defaultDatabaseDirectory, options.databaseName);
|
|
29
|
+
super({
|
|
30
|
+
async exists() { return new File(location.fileUri).exists; },
|
|
31
|
+
open() { return openDatabaseAsync(location.encodedName, { useNewConnection: true }, location.directoryUri); },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** This API always uses the native cryptographic source, including development builds. */
|
|
37
|
+
export const expoRandom: RandomSource = {
|
|
38
|
+
bytes(length) {
|
|
39
|
+
if (!Number.isSafeInteger(length) || length < 0) throw new RangeError('Invalid random byte count.');
|
|
40
|
+
const bytes = new Uint8Array(length);
|
|
41
|
+
for (let offset = 0; offset < length; offset += 65536) getRandomValues(bytes.subarray(offset, offset + 65536));
|
|
42
|
+
return bytes;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Uses expo/fetch explicitly: React Native's legacy global fetch cannot enforce these transport policies. */
|
|
47
|
+
export const expoRelayFetch = createExpoRelayFetch((url, init) => nativeFetch(url, init));
|
package/src/socket.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { encodeUtf8, webEndpoint, type RelaySocket, type RelaySocketEvents, type RelaySocketFactory } from '@meshline/sdk';
|
|
2
|
+
|
|
3
|
+
/** Internal Expo Modules contract, separated from module loading for adapter tests. */
|
|
4
|
+
export interface NativeSocketEvent {
|
|
5
|
+
readonly id: number;
|
|
6
|
+
readonly type: 'open' | 'text' | 'binary' | 'error' | 'close';
|
|
7
|
+
readonly text?: string;
|
|
8
|
+
readonly message?: string;
|
|
9
|
+
readonly code?: number;
|
|
10
|
+
readonly reason?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface NativeSocketModule {
|
|
13
|
+
connect(id: number, endpoint: string): void;
|
|
14
|
+
send(id: number, text: string): void;
|
|
15
|
+
close(id: number, code: number, reason: string): void;
|
|
16
|
+
cancel(id: number): void;
|
|
17
|
+
bufferedAmount(id: number): number;
|
|
18
|
+
addListener(event: 'socket', listener: (event: NativeSocketEvent) => void): { remove(): void };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let nextId = 0;
|
|
22
|
+
|
|
23
|
+
/** The native implementation owns TLS, cookie/redirect policy and actual RFC close codes. */
|
|
24
|
+
export function nativeSocketFactory(module: NativeSocketModule): RelaySocketFactory {
|
|
25
|
+
return endpoint => {
|
|
26
|
+
const normalized = webEndpoint(endpoint, 'wss:').href;
|
|
27
|
+
if (nextId === Number.MAX_SAFE_INTEGER) throw new Error('Native socket identifiers are exhausted.');
|
|
28
|
+
const id = ++nextId;
|
|
29
|
+
let state = 0; let connected = false; let timer: ReturnType<typeof setTimeout> | undefined;
|
|
30
|
+
const listeners = new Map<keyof RelaySocketEvents, Set<(value: never) => void>>();
|
|
31
|
+
function emit<K extends keyof RelaySocketEvents>(event: K, value: RelaySocketEvents[K]): void {
|
|
32
|
+
for (const listener of [...listeners.get(event) ?? []]) listener(value as never);
|
|
33
|
+
}
|
|
34
|
+
function finish(code: number, reason: string): void {
|
|
35
|
+
if (state === 3) return;
|
|
36
|
+
state = 3; clearTimeout(timer); subscription.remove();
|
|
37
|
+
try { emit('close', { code, reason }); } finally { listeners.clear(); }
|
|
38
|
+
}
|
|
39
|
+
function fail(error: unknown): void {
|
|
40
|
+
if (state === 3) return;
|
|
41
|
+
try { emit('error', error instanceof Error ? error : new Error(String(error))); }
|
|
42
|
+
finally {
|
|
43
|
+
try { if (connected) module.cancel(id); }
|
|
44
|
+
finally { finish(1006, 'Native WebSocket failed.'); }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const subscription = module.addListener('socket', event => {
|
|
48
|
+
if (event.id !== id || state === 3) return;
|
|
49
|
+
switch (event.type) {
|
|
50
|
+
case 'open': if (state === 0) { state = 1; emit('open', undefined); } break;
|
|
51
|
+
case 'text': if (state === 1) emit('message', event.text); break;
|
|
52
|
+
case 'binary': if (state === 1) emit('message', new Uint8Array()); break;
|
|
53
|
+
case 'error': fail(new Error(event.message ?? 'Native WebSocket failed.')); break;
|
|
54
|
+
case 'close': finish(event.code ?? 1006, event.reason ?? ''); break;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
// RpcConnection installs all event handlers before native callbacks can fire.
|
|
58
|
+
queueMicrotask(() => {
|
|
59
|
+
if (state !== 0) return;
|
|
60
|
+
connected = true;
|
|
61
|
+
try { module.connect(id, normalized); } catch (error) { fail(error); }
|
|
62
|
+
});
|
|
63
|
+
const socket: RelaySocket = {
|
|
64
|
+
get readyState() { return state; },
|
|
65
|
+
get bufferedAmount() { return connected && state !== 3 ? module.bufferedAmount(id) : 0; },
|
|
66
|
+
send(text) {
|
|
67
|
+
if (state !== 1) throw new Error('Native WebSocket is not open.');
|
|
68
|
+
// Reject malformed UTF-16 before a native bridge can replace it.
|
|
69
|
+
encodeUtf8(text); module.send(id, text);
|
|
70
|
+
},
|
|
71
|
+
close(code, reason) {
|
|
72
|
+
if (!Number.isInteger(code) || !([1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014].includes(code) || code >= 3000 && code <= 4999)) throw new RangeError('Invalid WebSocket close code.');
|
|
73
|
+
if (encodeUtf8(reason).length > 123) throw new RangeError('WebSocket close reason exceeds 123 UTF-8 bytes.');
|
|
74
|
+
if (state >= 2) return code;
|
|
75
|
+
state = 2;
|
|
76
|
+
if (!connected) { queueMicrotask(() => finish(code, reason)); return code; }
|
|
77
|
+
try { module.close(id, code, reason); } catch (error) { fail(error); throw error; }
|
|
78
|
+
if (state !== 3) timer = setTimeout(() => {
|
|
79
|
+
try { module.cancel(id); } catch (error) { emit('error', error instanceof Error ? error : new Error(String(error))); }
|
|
80
|
+
finally { finish(1006, 'Native WebSocket close handshake timed out.'); }
|
|
81
|
+
}, 3000);
|
|
82
|
+
return code;
|
|
83
|
+
},
|
|
84
|
+
on(event, listener) {
|
|
85
|
+
if (state === 3) return () => {};
|
|
86
|
+
let set = listeners.get(event); if (!set) { set = new Set(); listeners.set(event, set); }
|
|
87
|
+
set.add(listener as (value: never) => void);
|
|
88
|
+
return () => { set.delete(listener as (value: never) => void); };
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
return socket;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AsyncGate, StateConflictError, bindingJson, canonicalJson, parseJson, prepareCommit, requireBatchCount,
|
|
3
|
+
requireObject, throwIfAborted, validateRecordQuery, type MeshlineStore, type QueryReader, type RecordQuery,
|
|
4
|
+
type StorageBinding, type StoredRecord, type StoreMutation, type StoreSnapshot,
|
|
5
|
+
} from '@meshline/sdk';
|
|
6
|
+
|
|
7
|
+
/** Private adapter boundary, also exercised against a real SQLite driver in contract tests. */
|
|
8
|
+
export interface SqliteDatabase {
|
|
9
|
+
execAsync(sql: string): Promise<void>;
|
|
10
|
+
runAsync(sql: string, ...parameters: (string | number | null)[]): Promise<unknown>;
|
|
11
|
+
getFirstAsync<T>(sql: string, ...parameters: (string | number | null)[]): Promise<T | null>;
|
|
12
|
+
getAllAsync<T>(sql: string, ...parameters: (string | number | null)[]): Promise<T[]>;
|
|
13
|
+
closeAsync(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export interface SqliteRuntime {
|
|
16
|
+
exists(): Promise<boolean>;
|
|
17
|
+
/** Every call MUST return a new, private native connection. Never use Expo's shared connection cache. */
|
|
18
|
+
open(): Promise<SqliteDatabase>;
|
|
19
|
+
}
|
|
20
|
+
interface Row { collection: string; key: string; revision: number; value: string }
|
|
21
|
+
function decodeRow(row: Row): StoredRecord { return { collection: row.collection, key: row.key, revision: row.revision, value: requireObject(parseJson(row.value)) }; }
|
|
22
|
+
function selection(query: RecordQuery) {
|
|
23
|
+
validateRecordQuery(query);
|
|
24
|
+
let where = 'collection = ?'; const parameters: (string | number | null)[] = [query.collection];
|
|
25
|
+
if (query.key !== undefined) { where += ' AND key = ?'; parameters.push(query.key); }
|
|
26
|
+
else if (query.prefix !== undefined) { where += ' AND key >= ? AND key < ?'; parameters.push(query.prefix, query.prefix + '\uffff'); }
|
|
27
|
+
return { where, parameters, order: query.reverse ? 'DESC' : 'ASC' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Serialized private writer and independent WAL snapshots, with no transaction spanning application callbacks. */
|
|
31
|
+
export class NativeSqliteStore implements MeshlineStore {
|
|
32
|
+
readonly #gate = new AsyncGate();
|
|
33
|
+
readonly #readers = new Set<NativeReader>();
|
|
34
|
+
#database: SqliteDatabase | undefined;
|
|
35
|
+
#disposed = false;
|
|
36
|
+
#disposal: Promise<void> | undefined;
|
|
37
|
+
constructor(readonly runtime: SqliteRuntime) {}
|
|
38
|
+
|
|
39
|
+
migrate(signal?: AbortSignal): Promise<void> {
|
|
40
|
+
this.#check();
|
|
41
|
+
return this.#gate.run(async () => {
|
|
42
|
+
this.#check(); throwIfAborted(signal);
|
|
43
|
+
if (this.#database) throw new Error('Migrate before initializing storage.');
|
|
44
|
+
const database = await this.runtime.open();
|
|
45
|
+
try {
|
|
46
|
+
await database.execAsync('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;');
|
|
47
|
+
await transaction(database, 'IMMEDIATE', async () => {
|
|
48
|
+
const version = (await database.getFirstAsync<{ user_version: number }>('PRAGMA user_version'))!.user_version;
|
|
49
|
+
if (version > 1) throw new Error(`Unsupported database schema ${version}.`);
|
|
50
|
+
if (version === 0) await database.execAsync(`
|
|
51
|
+
CREATE TABLE meshline_meta (id INTEGER PRIMARY KEY CHECK(id = 1), binding TEXT, version INTEGER NOT NULL CHECK(version >= 0));
|
|
52
|
+
INSERT INTO meshline_meta VALUES (1, NULL, 0);
|
|
53
|
+
CREATE TABLE meshline_records (collection TEXT NOT NULL, key TEXT NOT NULL, revision INTEGER NOT NULL, value TEXT NOT NULL,
|
|
54
|
+
PRIMARY KEY(collection, key)) WITHOUT ROWID;
|
|
55
|
+
PRAGMA user_version = 1;
|
|
56
|
+
`);
|
|
57
|
+
}, signal);
|
|
58
|
+
} finally { await database.closeAsync(); }
|
|
59
|
+
}, signal);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
initialize(binding: StorageBinding, signal?: AbortSignal): Promise<void> {
|
|
63
|
+
this.#check(); const encoded = bindingJson(binding);
|
|
64
|
+
return this.#gate.run(async () => {
|
|
65
|
+
this.#check(); throwIfAborted(signal);
|
|
66
|
+
if (this.#database) {
|
|
67
|
+
const existing = await this.#database.getFirstAsync<{ binding: string }>('SELECT binding FROM meshline_meta WHERE id = 1');
|
|
68
|
+
if (existing?.binding !== encoded) throw new Error('Database is bound to another network or account.');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!await this.runtime.exists()) throw new Error('Database must be explicitly migrated before initialization.');
|
|
72
|
+
const database = await this.runtime.open();
|
|
73
|
+
try {
|
|
74
|
+
const version = (await database.getFirstAsync<{ user_version: number }>('PRAGMA user_version'))!.user_version;
|
|
75
|
+
if (version !== 1) throw new Error('Database must be explicitly migrated before initialization.');
|
|
76
|
+
await database.execAsync('PRAGMA busy_timeout = 5000; PRAGMA synchronous = FULL;');
|
|
77
|
+
await transaction(database, 'IMMEDIATE', async () => {
|
|
78
|
+
const current = await database.getFirstAsync<{ binding: string | null }>('SELECT binding FROM meshline_meta WHERE id = 1');
|
|
79
|
+
if (!current) throw new Error('Missing Meshline database metadata.');
|
|
80
|
+
if (current.binding !== null && current.binding !== encoded) throw new Error('Database is bound to another network or account.');
|
|
81
|
+
if (current.binding === null) await database.runAsync('UPDATE meshline_meta SET binding = ? WHERE id = 1', encoded);
|
|
82
|
+
}, signal);
|
|
83
|
+
this.#database = database;
|
|
84
|
+
} catch (error) { await database.closeAsync(); throw error; }
|
|
85
|
+
}, signal);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
read(queries: readonly RecordQuery[], signal?: AbortSignal): Promise<StoreSnapshot> {
|
|
89
|
+
this.#check(); const selected = queries.map(selection);
|
|
90
|
+
return this.#gate.run(async () => {
|
|
91
|
+
const database = this.#connection();
|
|
92
|
+
return transaction(database, 'DEFERRED', async () => {
|
|
93
|
+
const version = (await database.getFirstAsync<{ version: number }>('SELECT version FROM meshline_meta WHERE id = 1'))!.version;
|
|
94
|
+
const sets: StoredRecord[][] = [];
|
|
95
|
+
for (const query of selected) sets.push((await database.getAllAsync<Row>(`SELECT * FROM meshline_records WHERE ${query.where} ORDER BY key ${query.order}`, ...query.parameters)).map(decodeRow));
|
|
96
|
+
return { version, sets };
|
|
97
|
+
}, signal);
|
|
98
|
+
}, signal);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
commit(expectedVersion: number, mutations: readonly StoreMutation[], signal?: AbortSignal): Promise<number> {
|
|
102
|
+
this.#check(); const changes = prepareCommit(expectedVersion, mutations);
|
|
103
|
+
return this.#gate.run(async () => {
|
|
104
|
+
const database = this.#connection();
|
|
105
|
+
return transaction(database, 'IMMEDIATE', async () => {
|
|
106
|
+
const version = (await database.getFirstAsync<{ version: number }>('SELECT version FROM meshline_meta WHERE id = 1'))!.version;
|
|
107
|
+
if (version !== expectedVersion) throw new StateConflictError('Database revision changed before commit.');
|
|
108
|
+
const next = expectedVersion + 1;
|
|
109
|
+
for (const change of changes) {
|
|
110
|
+
throwIfAborted(signal);
|
|
111
|
+
if (change.kind === 'put') await database.runAsync('INSERT INTO meshline_records VALUES (?, ?, ?, ?) ON CONFLICT(collection, key) DO UPDATE SET revision = excluded.revision, value = excluded.value', change.collection, change.key, next, canonicalJson(change.value));
|
|
112
|
+
else await database.runAsync('DELETE FROM meshline_records WHERE collection = ? AND key = ?', change.collection, change.key);
|
|
113
|
+
}
|
|
114
|
+
await database.runAsync('UPDATE meshline_meta SET version = ? WHERE id = 1', next);
|
|
115
|
+
return next;
|
|
116
|
+
}, signal);
|
|
117
|
+
}, signal);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
openQuery(query: RecordQuery, signal?: AbortSignal): Promise<QueryReader<StoredRecord>> {
|
|
121
|
+
this.#check(); const selected = selection(query);
|
|
122
|
+
return this.#gate.run(async () => {
|
|
123
|
+
this.#connection(); throwIfAborted(signal);
|
|
124
|
+
const database = await this.runtime.open();
|
|
125
|
+
try {
|
|
126
|
+
await database.execAsync('PRAGMA busy_timeout = 5000; PRAGMA query_only = ON; BEGIN DEFERRED;');
|
|
127
|
+
// Reading a real table pins the WAL snapshot before returning to application code.
|
|
128
|
+
await database.getFirstAsync('SELECT version FROM meshline_meta WHERE id = 1');
|
|
129
|
+
throwIfAborted(signal);
|
|
130
|
+
const reader = new NativeReader(database, selected, () => this.#readers.delete(reader));
|
|
131
|
+
this.#readers.add(reader); return reader;
|
|
132
|
+
} catch (error) { await database.closeAsync(); throw error; }
|
|
133
|
+
}, signal);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#check(): void { if (this.#disposed) throw new Error('Store is disposed.'); }
|
|
137
|
+
#connection(): SqliteDatabase { this.#check(); if (!this.#database) throw new Error('Store is not initialized.'); return this.#database; }
|
|
138
|
+
dispose(): Promise<void> {
|
|
139
|
+
this.#disposed = true;
|
|
140
|
+
return this.#disposal ??= this.#gate.run(async () => {
|
|
141
|
+
const failures: unknown[] = [];
|
|
142
|
+
for (const reader of this.#readers) { try { await reader.dispose(); } catch (error) { failures.push(error); } }
|
|
143
|
+
try { await this.#database?.closeAsync(); } catch (error) { failures.push(error); }
|
|
144
|
+
this.#database = undefined;
|
|
145
|
+
if (failures.length) throw new AggregateError(failures, 'Failed to dispose native SQLite storage.');
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function transaction<T>(database: SqliteDatabase, mode: 'IMMEDIATE' | 'DEFERRED', action: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
151
|
+
throwIfAborted(signal); await database.execAsync(`BEGIN ${mode}`);
|
|
152
|
+
try {
|
|
153
|
+
const result = await action(); throwIfAborted(signal);
|
|
154
|
+
await database.execAsync('COMMIT'); return result;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
try { await database.execAsync('ROLLBACK'); }
|
|
157
|
+
catch (rollbackError) { throw new AggregateError([error, rollbackError], 'SQLite operation and rollback both failed.'); }
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
class NativeReader implements QueryReader<StoredRecord> {
|
|
163
|
+
readonly #gate = new AsyncGate();
|
|
164
|
+
#offset = 0;
|
|
165
|
+
#completed = false;
|
|
166
|
+
#disposed = false;
|
|
167
|
+
#disposal: Promise<void> | undefined;
|
|
168
|
+
constructor(readonly database: SqliteDatabase, readonly query: ReturnType<typeof selection>, readonly released: () => void) {}
|
|
169
|
+
readNext(count: number, signal?: AbortSignal): Promise<readonly StoredRecord[]> {
|
|
170
|
+
requireBatchCount(count);
|
|
171
|
+
return this.#gate.run(async () => {
|
|
172
|
+
if (this.#disposed) throw new Error('Reader is disposed.');
|
|
173
|
+
throwIfAborted(signal); if (this.#completed) return [];
|
|
174
|
+
const rows = await this.database.getAllAsync<Row>(`SELECT * FROM meshline_records WHERE ${this.query.where} ORDER BY key ${this.query.order} LIMIT ? OFFSET ?`, ...this.query.parameters, count, this.#offset);
|
|
175
|
+
const records = rows.map(decodeRow); throwIfAborted(signal);
|
|
176
|
+
this.#offset += records.length; this.#completed = records.length < count;
|
|
177
|
+
return records;
|
|
178
|
+
}, signal);
|
|
179
|
+
}
|
|
180
|
+
dispose(): Promise<void> {
|
|
181
|
+
this.#disposed = true;
|
|
182
|
+
return this.#disposal ??= this.#gate.run(async () => {
|
|
183
|
+
try { await this.database.execAsync('ROLLBACK'); }
|
|
184
|
+
finally { try { await this.database.closeAsync(); } finally { this.released(); } }
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|