@concavejs/docstore-expo-sqlite 0.0.1-alpha.8

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.
@@ -0,0 +1,51 @@
1
+ import { type PreparedStatement, type RowResult, type SqlParam, type SqliteAdapter, type TransactionFn } from "@concavejs/docstore-sqlite-base";
2
+ export type ExpoSqlBindValues = unknown[] | Record<string, unknown>;
3
+ export interface BusyRetryOptions {
4
+ maxAttempts?: number;
5
+ initialDelayMs?: number;
6
+ maxDelayMs?: number;
7
+ backoffFactor?: number;
8
+ }
9
+ export interface ExpoSqlAdapterOptions {
10
+ applyPragmas?: boolean;
11
+ schemaVersion?: number;
12
+ metadataTableName?: string;
13
+ busyRetry?: BusyRetryOptions;
14
+ }
15
+ /**
16
+ * Minimal shape required from expo-sqlite database instances.
17
+ *
18
+ * This uses structural typing on purpose so the adapter doesn't need to import
19
+ * from expo-sqlite directly to build and test in non-Expo environments.
20
+ */
21
+ export interface ExpoSqlDatabaseLike {
22
+ execAsync(source: string): Promise<unknown>;
23
+ runAsync(source: string, ...params: unknown[]): Promise<unknown>;
24
+ getAllAsync<T = RowResult>(source: string, ...params: unknown[]): Promise<T[]>;
25
+ }
26
+ export type ExpoSqlDatabase = ExpoSqlDatabaseLike;
27
+ export declare function createExpoSqlDatabase(database: ExpoSqlDatabaseLike): ExpoSqlDatabase;
28
+ export declare class ExpoSqlAdapter implements SqliteAdapter {
29
+ private readonly database;
30
+ private readonly runSerializedTransaction;
31
+ private readonly schemaVersion;
32
+ private readonly metadataTableName;
33
+ private readonly applyPragmas;
34
+ private readonly busyRetry;
35
+ private initPromise?;
36
+ constructor(database: ExpoSqlDatabase, options?: ExpoSqlAdapterOptions);
37
+ exec(sql: string): Promise<void>;
38
+ prepare(sql: string): PreparedStatement;
39
+ transaction<T>(fn: TransactionFn<T>): Promise<T>;
40
+ hexToBuffer(hex: string): SqlParam;
41
+ bufferToHex(buffer: unknown): string;
42
+ executeWithRetry(sql: string, bindValues?: ExpoSqlBindValues): Promise<void>;
43
+ selectWithRetry<T = RowResult>(sql: string, bindValues?: ExpoSqlBindValues): Promise<T[]>;
44
+ private ensureInitialized;
45
+ private initialize;
46
+ private migrate;
47
+ private withBusyRetry;
48
+ private executeRaw;
49
+ private selectRaw;
50
+ private tryExecRaw;
51
+ }
@@ -0,0 +1,264 @@
1
+ import { arrayBufferToHex, hexToArrayBuffer } from "@concavejs/core/utils";
2
+ import { createSerializedTransactionRunner, } from "@concavejs/docstore-sqlite-base";
3
+ const DEFAULT_SCHEMA_VERSION = 1;
4
+ const SCHEMA_VERSION_KEY = "docstore_schema_version";
5
+ const DEFAULT_METADATA_TABLE = "concave_meta";
6
+ const NATIVE_TIMERS_SYMBOL = Symbol.for("@concavejs/core/native-timers");
7
+ export function createExpoSqlDatabase(database) {
8
+ return {
9
+ execAsync: (source) => database.execAsync(source),
10
+ runAsync: (source, ...params) => database.runAsync(source, ...params),
11
+ getAllAsync: (source, ...params) => database.getAllAsync(source, ...params),
12
+ };
13
+ }
14
+ class ExpoPreparedStatement {
15
+ constructor(adapter, sql) {
16
+ this.adapter = adapter;
17
+ this.sql = sql;
18
+ }
19
+ async get(...params) {
20
+ const rows = await this.adapter.selectWithRetry(this.sql, normalizeSqlParams(params));
21
+ return rows[0] ?? null;
22
+ }
23
+ async all(...params) {
24
+ return this.adapter.selectWithRetry(this.sql, normalizeSqlParams(params));
25
+ }
26
+ async run(...params) {
27
+ await this.adapter.executeWithRetry(this.sql, normalizeSqlParams(params));
28
+ }
29
+ }
30
+ export class ExpoSqlAdapter {
31
+ constructor(database, options = {}) {
32
+ this.database = database;
33
+ this.schemaVersion = normalizeSchemaVersion(options.schemaVersion ?? DEFAULT_SCHEMA_VERSION);
34
+ this.metadataTableName = sanitizeIdentifier(options.metadataTableName ?? DEFAULT_METADATA_TABLE);
35
+ this.applyPragmas = options.applyPragmas !== false;
36
+ this.busyRetry = normalizeBusyRetryOptions(options.busyRetry);
37
+ this.runSerializedTransaction = createSerializedTransactionRunner({
38
+ begin: async () => {
39
+ await this.executeWithRetry("BEGIN IMMEDIATE");
40
+ },
41
+ commit: async () => {
42
+ await this.executeWithRetry("COMMIT");
43
+ },
44
+ rollback: async () => {
45
+ await this.executeWithRetry("ROLLBACK");
46
+ },
47
+ });
48
+ }
49
+ async exec(sql) {
50
+ for (const statement of splitSqlStatements(sql)) {
51
+ await this.executeWithRetry(statement);
52
+ }
53
+ }
54
+ prepare(sql) {
55
+ return new ExpoPreparedStatement(this, sql);
56
+ }
57
+ async transaction(fn) {
58
+ return this.runSerializedTransaction(fn);
59
+ }
60
+ hexToBuffer(hex) {
61
+ return new Uint8Array(hexToArrayBuffer(hex));
62
+ }
63
+ bufferToHex(buffer) {
64
+ return arrayBufferToHex(coerceSqlBlob(buffer));
65
+ }
66
+ async executeWithRetry(sql, bindValues) {
67
+ await this.ensureInitialized();
68
+ await this.withBusyRetry(async () => {
69
+ await this.executeRaw(sql, bindValues);
70
+ return undefined;
71
+ });
72
+ }
73
+ async selectWithRetry(sql, bindValues) {
74
+ await this.ensureInitialized();
75
+ return this.withBusyRetry(() => this.selectRaw(sql, bindValues));
76
+ }
77
+ async ensureInitialized() {
78
+ if (!this.initPromise) {
79
+ this.initPromise = this.initialize();
80
+ }
81
+ await this.initPromise;
82
+ }
83
+ async initialize() {
84
+ await this.withBusyRetry(() => this.executeRaw(`CREATE TABLE IF NOT EXISTS ${this.metadataTableName} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`));
85
+ const rows = await this.withBusyRetry(() => this.selectRaw(`SELECT value FROM ${this.metadataTableName} WHERE key = ?`, [SCHEMA_VERSION_KEY]));
86
+ const existingVersion = rows.length > 0 ? Number(rows[0]?.value) : undefined;
87
+ if (existingVersion === undefined || !Number.isFinite(existingVersion)) {
88
+ await this.withBusyRetry(() => this.executeRaw(`INSERT OR REPLACE INTO ${this.metadataTableName} (key, value) VALUES (?, ?)`, [SCHEMA_VERSION_KEY, String(this.schemaVersion)]));
89
+ }
90
+ else if (!Number.isInteger(existingVersion) || existingVersion <= 0) {
91
+ throw new Error(`Invalid persisted docstore schema version: ${rows[0]?.value}`);
92
+ }
93
+ else if (existingVersion > this.schemaVersion) {
94
+ throw new Error(`Persisted docstore schema version ${existingVersion} is newer than supported version ${this.schemaVersion}`);
95
+ }
96
+ else if (existingVersion < this.schemaVersion) {
97
+ await this.migrate(existingVersion, this.schemaVersion);
98
+ await this.withBusyRetry(() => this.executeRaw(`UPDATE ${this.metadataTableName} SET value = ? WHERE key = ?`, [String(this.schemaVersion), SCHEMA_VERSION_KEY]));
99
+ }
100
+ if (this.applyPragmas) {
101
+ await this.tryExecRaw("PRAGMA journal_mode = WAL");
102
+ await this.tryExecRaw("PRAGMA synchronous = NORMAL");
103
+ }
104
+ }
105
+ async migrate(fromVersion, toVersion) {
106
+ if (fromVersion === toVersion) {
107
+ return;
108
+ }
109
+ // Placeholder for future adapter-level metadata migrations.
110
+ // BaseSqliteDocStore still owns runtime schema tables and indexes.
111
+ }
112
+ async withBusyRetry(operation) {
113
+ let attempt = 0;
114
+ let delayMs = this.busyRetry.initialDelayMs;
115
+ while (true) {
116
+ try {
117
+ return await operation();
118
+ }
119
+ catch (error) {
120
+ attempt += 1;
121
+ if (!isBusyError(error) || attempt >= this.busyRetry.maxAttempts) {
122
+ throw error;
123
+ }
124
+ await sleep(delayMs);
125
+ delayMs = Math.min(this.busyRetry.maxDelayMs, Math.ceil(delayMs * this.busyRetry.backoffFactor));
126
+ }
127
+ }
128
+ }
129
+ async executeRaw(sql, bindValues) {
130
+ await callExpoStatement(this.database.runAsync.bind(this.database), sql, bindValues);
131
+ }
132
+ async selectRaw(sql, bindValues) {
133
+ return callExpoSelect(this.database.getAllAsync.bind(this.database), sql, bindValues);
134
+ }
135
+ async tryExecRaw(sql) {
136
+ try {
137
+ await this.database.execAsync(sql);
138
+ }
139
+ catch {
140
+ // Some SQLite builds may not support all pragmas.
141
+ }
142
+ }
143
+ }
144
+ function callExpoStatement(statement, source, bindValues) {
145
+ if (bindValues === undefined) {
146
+ return statement(source);
147
+ }
148
+ if (Array.isArray(bindValues)) {
149
+ return statement(source, ...bindValues);
150
+ }
151
+ return statement(source, bindValues);
152
+ }
153
+ function callExpoSelect(statement, source, bindValues) {
154
+ if (bindValues === undefined) {
155
+ return statement(source);
156
+ }
157
+ if (Array.isArray(bindValues)) {
158
+ return statement(source, ...bindValues);
159
+ }
160
+ return statement(source, bindValues);
161
+ }
162
+ function splitSqlStatements(sql) {
163
+ return sql
164
+ .split(";")
165
+ .map((statement) => statement.trim())
166
+ .filter((statement) => statement.length > 0);
167
+ }
168
+ function normalizeSqlParams(params) {
169
+ return params.map((param) => {
170
+ if (typeof param === "boolean") {
171
+ return param ? 1 : 0;
172
+ }
173
+ if (typeof param === "bigint") {
174
+ return Number(param);
175
+ }
176
+ if (param instanceof ArrayBuffer) {
177
+ return new Uint8Array(param);
178
+ }
179
+ if (ArrayBuffer.isView(param)) {
180
+ return new Uint8Array(param.buffer, param.byteOffset, param.byteLength).slice();
181
+ }
182
+ return param;
183
+ });
184
+ }
185
+ function coerceSqlBlob(value) {
186
+ if (value instanceof ArrayBuffer) {
187
+ return value;
188
+ }
189
+ if (ArrayBuffer.isView(value)) {
190
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice().buffer;
191
+ }
192
+ if (Array.isArray(value)) {
193
+ return new Uint8Array(value).buffer;
194
+ }
195
+ if (isSerializedBuffer(value)) {
196
+ return new Uint8Array(value.data).buffer;
197
+ }
198
+ throw new Error("Unexpected SQL blob value shape");
199
+ }
200
+ function isSerializedBuffer(value) {
201
+ return (typeof value === "object" &&
202
+ value !== null &&
203
+ "data" in value &&
204
+ Array.isArray(value.data));
205
+ }
206
+ function isBusyError(error) {
207
+ if (!(error instanceof Error)) {
208
+ return false;
209
+ }
210
+ const message = error.message.toLowerCase();
211
+ return message.includes("database is locked") || message.includes("database is busy") || message.includes("busy");
212
+ }
213
+ function normalizeSchemaVersion(value) {
214
+ if (!Number.isInteger(value) || value <= 0) {
215
+ throw new Error(`Invalid schemaVersion: ${value}`);
216
+ }
217
+ return value;
218
+ }
219
+ function sanitizeIdentifier(value) {
220
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
221
+ throw new Error(`Invalid SQLite identifier: ${value}`);
222
+ }
223
+ return value;
224
+ }
225
+ function normalizeBusyRetryOptions(options) {
226
+ return {
227
+ maxAttempts: normalizePositiveInt(options?.maxAttempts ?? 5, "busyRetry.maxAttempts"),
228
+ initialDelayMs: normalizePositiveInt(options?.initialDelayMs ?? 4, "busyRetry.initialDelayMs"),
229
+ maxDelayMs: normalizePositiveInt(options?.maxDelayMs ?? 80, "busyRetry.maxDelayMs"),
230
+ backoffFactor: normalizePositiveNumber(options?.backoffFactor ?? 2, "busyRetry.backoffFactor"),
231
+ };
232
+ }
233
+ function normalizePositiveInt(value, field) {
234
+ if (!Number.isInteger(value) || value <= 0) {
235
+ throw new Error(`${field} must be a positive integer`);
236
+ }
237
+ return value;
238
+ }
239
+ function normalizePositiveNumber(value, field) {
240
+ if (!Number.isFinite(value) || value <= 0) {
241
+ throw new Error(`${field} must be a positive number`);
242
+ }
243
+ return value;
244
+ }
245
+ function resolveNativeSetTimeout() {
246
+ const globalWithTimers = globalThis;
247
+ const nativeTimers = globalWithTimers[NATIVE_TIMERS_SYMBOL];
248
+ if (nativeTimers && typeof nativeTimers.setTimeout === "function") {
249
+ return nativeTimers.setTimeout;
250
+ }
251
+ return null;
252
+ }
253
+ const nativeSetTimeout = resolveNativeSetTimeout();
254
+ function sleep(ms) {
255
+ if (!Number.isFinite(ms) || ms <= 0) {
256
+ return Promise.resolve();
257
+ }
258
+ if (!nativeSetTimeout) {
259
+ return Promise.resolve();
260
+ }
261
+ return new Promise((resolve) => {
262
+ nativeSetTimeout(() => resolve(), ms);
263
+ });
264
+ }
@@ -0,0 +1,2 @@
1
+ export { SqliteDocStore, type SqliteDocStoreOptions } from "./sqlite-docstore";
2
+ export { ExpoSqlAdapter, type BusyRetryOptions, type ExpoSqlAdapterOptions, type ExpoSqlBindValues, type ExpoSqlDatabase, type ExpoSqlDatabaseLike, createExpoSqlDatabase, } from "./expo-sql-adapter";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { SqliteDocStore } from "./sqlite-docstore";
2
+ export { ExpoSqlAdapter, createExpoSqlDatabase, } from "./expo-sql-adapter";
@@ -0,0 +1,12 @@
1
+ import { BaseSqliteDocStore } from "@concavejs/docstore-sqlite-base";
2
+ import { type BusyRetryOptions, type ExpoSqlAdapterOptions, type ExpoSqlDatabase } from "./expo-sql-adapter";
3
+ export interface SqliteDocStoreOptions extends Omit<ExpoSqlAdapterOptions, "busyRetry"> {
4
+ busyRetry?: BusyRetryOptions;
5
+ }
6
+ /**
7
+ * SQLite DocStore backed by an Expo SQLite-compatible database.
8
+ */
9
+ export declare class SqliteDocStore extends BaseSqliteDocStore {
10
+ constructor(database: ExpoSqlDatabase, options?: SqliteDocStoreOptions);
11
+ close(): Promise<void>;
12
+ }
@@ -0,0 +1,14 @@
1
+ import { BaseSqliteDocStore } from "@concavejs/docstore-sqlite-base";
2
+ import { ExpoSqlAdapter, } from "./expo-sql-adapter";
3
+ /**
4
+ * SQLite DocStore backed by an Expo SQLite-compatible database.
5
+ */
6
+ export class SqliteDocStore extends BaseSqliteDocStore {
7
+ constructor(database, options = {}) {
8
+ const adapter = new ExpoSqlAdapter(database, options);
9
+ super(adapter);
10
+ }
11
+ async close() {
12
+ // Expo SQLite does not require explicit close for common usage.
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@concavejs/docstore-expo-sqlite",
3
+ "version": "0.0.1-alpha.8",
4
+ "license": "FSL-1.1-Apache-2.0",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "rm -rf dist && bunx tsc -p tsconfig.json --declaration --declarationMap false --outDir dist",
21
+ "test": "bun test --run --passWithNoTests"
22
+ },
23
+ "dependencies": {
24
+ "@concavejs/core": "0.0.1-alpha.8",
25
+ "@concavejs/docstore-sqlite-base": "0.0.1-alpha.8",
26
+ "convex": "^1.27.3"
27
+ },
28
+ "devDependencies": {
29
+ "typescript": "^5.9.3"
30
+ }
31
+ }