@octane-xplat/sqlite 0.0.1 → 0.9.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alec Larson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # @octane-xplat/sqlite
1
+ # `@octane-xplat/sqlite`
2
2
 
3
- Version 0.0.1 is a reservation stub so the automated release can publish this package name. It has no supported API. Use a later release for the implementation.
3
+ Cross-target SQLite persistence for Octane xplat apps:
4
+ `@nativescript-community/sqlite` on iOS/Android, a `sqlite-wasm` worker
5
+ with OPFS persistence on web, system `libsqlite3` via C-function interop
6
+ on the macOS AppKit host, and an explicit `supported: false` on Windows.
7
+
8
+ ```sh
9
+ pnpm add @octane-xplat/sqlite
10
+ ```
11
+
12
+ ```ts
13
+ import { openDatabase, supported } from '@octane-xplat/sqlite'
14
+
15
+ const db = await openDatabase('trips.db') // { threading?: boolean } option, native only
16
+ await db.execute('CREATE TABLE IF NOT EXISTS items(name TEXT)')
17
+ await db.execute('INSERT INTO items VALUES (?)', ['Passport'])
18
+ const rows = await db.selectArray('SELECT name FROM items')
19
+ await db.close()
20
+ ```
21
+
22
+ The `SqliteDb` surface: `execute`, `selectArray`, `getArray`, `each`
23
+ (row callback), `getUserVersion`/`setUserVersion` for migrations, `close`.
24
+ Params are `null | number | bigint | string | ArrayBuffer | Uint8Array`.
25
+ `db.isOpen` reports liveness; `db.persistent` reports whether storage
26
+ survives reloads — web only persists through OPFS, which needs a Worker
27
+ and on some browsers cross-origin isolation, so check the flag before
28
+ treating web data as durable. `deleteDatabase` removes a named database.
29
+ Branch on `supported` where a target has no backend.
30
+
31
+ Design notes: [`docs/sqlite-notes.md`](../../docs/sqlite-notes.md);
32
+ limits: [known limits](../../docs/known-limits.md). Exercised by the
33
+ harness `Services` screen
34
+ ([`packages/app/src/Services.tsrx`](../app/src/Services.tsrx)).
package/package.json CHANGED
@@ -1,18 +1,46 @@
1
1
  {
2
2
  "name": "@octane-xplat/sqlite",
3
- "version": "0.0.1",
3
+ "version": "0.9.0",
4
4
  "description": "Cross-target SQLite persistence — sqlite-wasm worker on web, @nativescript-community/sqlite on iOS/Android",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "https://github.com/octane-xplat/octane-xplat.git",
7
+ "url": "https://github.com/octane-xplat/octane-xplat",
8
8
  "directory": "packages/sqlite"
9
9
  },
10
- "homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/sqlite",
11
- "main": "./index.js",
12
- "types": "./index.d.ts",
13
10
  "files": [
14
- "README.md",
15
- "index.js",
16
- "index.d.ts"
17
- ]
18
- }
11
+ "src"
12
+ ],
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "macos": "./src/index.macos.ts",
17
+ "web": "./src/index.web.ts",
18
+ "native": "./src/index.ts",
19
+ "default": "./src/index.ts"
20
+ }
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "@nativescript-community/sqlite": "3.5.9",
27
+ "@sqlite.org/sqlite-wasm": "3.53.4-build1"
28
+ },
29
+ "peerDependencies": {
30
+ "@nativescript/core": ">=9.1.0 <10"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@nativescript/core": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "nativescript": {
38
+ "platforms": {
39
+ "android": "9.1.0",
40
+ "ios": "9.1.0"
41
+ }
42
+ },
43
+ "scripts": {
44
+ "test:packed": "node tests/packed-consumer.mjs"
45
+ }
46
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * macOS (AppKit/JSC host) backend: system libsqlite3 via C-function interop.
3
+ *
4
+ * The host runtime resolves declared C functions from the metadata bundle
5
+ * (metadata.macos.arm64.nsmd) — sqlite3.h and dlfcn.h are swept in during
6
+ * generation, so `dlopen` loads /usr/lib/libsqlite3.dylib and the sqlite3_*
7
+ * entry points are ordinary JS calls. This gives real file-backed sqlite with
8
+ * per-statement durability, identical semantics to the iOS/Android plugin.
9
+ *
10
+ * Depends on host metadata generated with the sqlite3/dlfcn umbrella imports
11
+ * (prebuilt metadata.nsmd >= the build that carries them); without it
12
+ * `supported` reports false and openDatabase rejects.
13
+ */
14
+ import type { OpenDatabaseOptions, SqliteDb, SqliteParam, SqliteParams, SqliteRow } from './types'
15
+
16
+ const host = globalThis as any
17
+ const fs = host.require?.('node:fs')
18
+
19
+ // SQLITE_TRANSIENT (-1): sqlite copies bound text/blob bytes. The marshaller
20
+ // hands the C side a temporary buffer, so STATIC (0) reads freed memory.
21
+ // Allocate the sentinel once — the runtime's Pointer→fn-pointer marshal is not
22
+ // re-entrant; a fresh Pointer per call crashes on the second bind.
23
+ let transientPtr: any = null
24
+ const SQLITE_TRANSIENT = () => (transientPtr ??= new host.interop.Pointer(-1))
25
+ const SQLITE_ROW = 100
26
+ const SQLITE_DONE = 101
27
+ const SQLITE_INTEGER = 1
28
+ const SQLITE_FLOAT = 2
29
+ const SQLITE_TEXT = 3
30
+ const SQLITE_BLOB = 4
31
+
32
+ let dlopened = false
33
+
34
+ const ensureLib = () => {
35
+ if (dlopened) {
36
+ return
37
+ }
38
+
39
+ if (typeof host.dlopen !== 'function' || typeof host.sqlite3_open !== 'function') {
40
+ throw new Error('sqlite3 interop unavailable — host metadata predates the sqlite3/dlfcn sweep')
41
+ }
42
+
43
+ host.dlopen('/usr/lib/libsqlite3.dylib', 2)
44
+ dlopened = true
45
+ }
46
+
47
+ export const supported =
48
+ typeof host.dlopen === 'function' && typeof host.sqlite3_open === 'function'
49
+
50
+ // NSApplicationSupportDirectory (14) / NSUserDomainMask (1).
51
+ const databaseDir = (): string | null => {
52
+ try {
53
+ const urls = host.NSFileManager.defaultManager.URLsForDirectoryInDomains(14, 1)
54
+ if (!urls?.count) {
55
+ return null
56
+ }
57
+
58
+ return `${urls.objectAtIndex(0).path}/octane-sqlite`
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ const databasePath = (name: string): string | null => {
65
+ if (name === ':memory:') {
66
+ return null
67
+ }
68
+
69
+ const dir = databaseDir()
70
+ if (!dir || !fs) {
71
+ return null
72
+ }
73
+
74
+ try {
75
+ fs.mkdirSync(dir)
76
+ } catch {}
77
+
78
+ return `${dir}/${name.replace(/[^\w.-]/g, '_')}.sqlite3`
79
+ }
80
+
81
+ const toArray = (params?: SqliteParams): SqliteParam[] =>
82
+ params == null ? [] : Array.isArray(params) ? params : [params]
83
+
84
+ const bind = (stmt: any, params: SqliteParam[]) => {
85
+ for (let i = 0; i < params.length; i++) {
86
+ const p = params[i]
87
+ const index = i + 1
88
+
89
+ if (p == null) {
90
+ host.sqlite3_bind_null(stmt, index)
91
+ } else if (typeof p === 'string') {
92
+ host.sqlite3_bind_text(stmt, index, p, -1, SQLITE_TRANSIENT())
93
+ } else if (typeof p === 'bigint') {
94
+ host.sqlite3_bind_int64(stmt, index, p)
95
+ } else if (typeof p === 'number') {
96
+ if (Number.isSafeInteger(p)) {
97
+ host.sqlite3_bind_int64(stmt, index, p)
98
+ } else {
99
+ host.sqlite3_bind_double(stmt, index, p)
100
+ }
101
+ } else {
102
+ const bytes = p instanceof ArrayBuffer ? new Uint8Array(p) : p
103
+ host.sqlite3_bind_blob(stmt, index, bytes, bytes.byteLength, SQLITE_TRANSIENT())
104
+ }
105
+ }
106
+ }
107
+
108
+ const columnValue = (stmt: any, index: number): any => {
109
+ switch (host.sqlite3_column_type(stmt, index)) {
110
+ case SQLITE_INTEGER: {
111
+ const v = host.sqlite3_column_int64(stmt, index)
112
+ return typeof v === 'bigint' && v >= -9007199254740991n && v <= 9007199254740991n
113
+ ? Number(v)
114
+ : v
115
+ }
116
+ case SQLITE_FLOAT:
117
+ return host.sqlite3_column_double(stmt, index)
118
+ case SQLITE_TEXT:
119
+ return host.interop.stringFromCString(host.sqlite3_column_text(stmt, index))
120
+ case SQLITE_BLOB: {
121
+ const ptr = host.sqlite3_column_blob(stmt, index)
122
+ const len = host.sqlite3_column_bytes(stmt, index)
123
+
124
+ if (!ptr || !len) {
125
+ return new Uint8Array(0)
126
+ }
127
+
128
+ const data = host.NSData.alloc().initWithBytesLength(ptr, len)
129
+ return new Uint8Array(host.interop.bufferFromData(data))
130
+ }
131
+ default:
132
+ return null
133
+ }
134
+ }
135
+
136
+ class MacosSqliteDb implements SqliteDb {
137
+ isOpen = true
138
+ constructor(
139
+ private readonly db: any,
140
+ readonly persistent: boolean,
141
+ ) {}
142
+
143
+ private ensureOpen() {
144
+ if (!this.isOpen) {
145
+ throw new Error('sqlite db is closed')
146
+ }
147
+ }
148
+
149
+ private err(): Error {
150
+ return new Error(host.interop.stringFromCString(host.sqlite3_errmsg(this.db)) || 'sqlite error')
151
+ }
152
+
153
+ // prepare → bind → step to completion → finalize. Rows are yielded through
154
+ // `onRow` (object or array shape chosen by the caller).
155
+ private run(
156
+ sql: string,
157
+ params: SqliteParam[],
158
+ onRow?: (stmt: any, cols: number) => void,
159
+ ): number {
160
+ const ref = new host.interop.Reference()
161
+ const rc = host.sqlite3_prepare_v2(this.db, sql, -1, ref, null)
162
+
163
+ if (rc !== 0) {
164
+ throw this.err()
165
+ }
166
+
167
+ const stmt = ref.value
168
+ try {
169
+ bind(stmt, params)
170
+ const cols = host.sqlite3_column_count(stmt)
171
+ let rows = 0
172
+
173
+ while (true) {
174
+ const step = host.sqlite3_step(stmt)
175
+
176
+ if (step === SQLITE_ROW) {
177
+ rows++
178
+ onRow?.(stmt, cols)
179
+ } else if (step === SQLITE_DONE) {
180
+ return rows
181
+ } else {
182
+ throw this.err()
183
+ }
184
+ }
185
+ } finally {
186
+ host.sqlite3_finalize(stmt)
187
+ }
188
+ }
189
+
190
+ execute = async (sql: string, params?: SqliteParams): Promise<void> => {
191
+ this.ensureOpen()
192
+ this.run(sql, toArray(params))
193
+ }
194
+ select = async <T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T[]> => {
195
+ this.ensureOpen()
196
+ const rows: T[] = []
197
+
198
+ this.run(sql, toArray(params), (stmt, cols) => {
199
+ const row: Record<string, unknown> = {}
200
+ for (let i = 0; i < cols; i++) {
201
+ row[host.interop.stringFromCString(host.sqlite3_column_name(stmt, i))] = columnValue(
202
+ stmt,
203
+ i,
204
+ )
205
+ }
206
+
207
+ rows.push(row as T)
208
+ })
209
+
210
+ return rows
211
+ }
212
+ selectArray = async (sql: string, params?: SqliteParams) => {
213
+ this.ensureOpen()
214
+ const rows: any[][] = []
215
+
216
+ this.run(sql, toArray(params), (stmt, cols) => {
217
+ const row: any[] = []
218
+ for (let i = 0; i < cols; i++) {
219
+ row.push(columnValue(stmt, i))
220
+ }
221
+
222
+ rows.push(row)
223
+ })
224
+
225
+ return rows
226
+ }
227
+ get = async <T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T | null> => {
228
+ const rows = await this.select<T>(sql, params)
229
+ return rows[0] ?? null
230
+ }
231
+ getArray = async (sql: string, params?: SqliteParams) => {
232
+ const rows = await this.selectArray(sql, params)
233
+ return rows[0] ?? null
234
+ }
235
+ transaction = async <T>(action: (db: SqliteDb) => Promise<T>): Promise<T> => {
236
+ this.ensureOpen()
237
+ this.run('BEGIN', [])
238
+ try {
239
+ const result = await action(this)
240
+ this.run('COMMIT', [])
241
+ return result
242
+ } catch (error) {
243
+ try {
244
+ this.run('ROLLBACK', [])
245
+ } catch {}
246
+
247
+ throw error
248
+ }
249
+ }
250
+ each = async (
251
+ sql: string,
252
+ params: SqliteParams,
253
+ onRow: (error: Error | null, row: SqliteRow) => void,
254
+ onDone: (error: Error | null, count: number) => void,
255
+ ) => {
256
+ this.ensureOpen()
257
+ let count = 0
258
+
259
+ try {
260
+ this.run(sql, toArray(params), (stmt, cols) => {
261
+ const row: SqliteRow = {}
262
+ for (let i = 0; i < cols; i++) {
263
+ row[host.interop.stringFromCString(host.sqlite3_column_name(stmt, i))] = columnValue(
264
+ stmt,
265
+ i,
266
+ )
267
+ }
268
+
269
+ onRow(null, row)
270
+ count++
271
+ })
272
+ } catch (error) {
273
+ onDone(error as Error, count)
274
+ throw error
275
+ }
276
+
277
+ onDone(null, count)
278
+ return count
279
+ }
280
+ getUserVersion = async (): Promise<number> => {
281
+ this.ensureOpen()
282
+ return Number((await this.getArray('PRAGMA user_version'))?.[0] ?? 0)
283
+ }
284
+ setUserVersion = async (version: number): Promise<void> => {
285
+ this.ensureOpen()
286
+ this.run(`PRAGMA user_version = ${version | 0}`, [])
287
+ }
288
+ close = async () => {
289
+ if (!this.isOpen) {
290
+ return
291
+ }
292
+
293
+ this.isOpen = false
294
+ host.sqlite3_close_v2(this.db)
295
+ }
296
+ }
297
+
298
+ export const openDatabase = async (
299
+ name: string,
300
+ _options?: OpenDatabaseOptions,
301
+ ): Promise<SqliteDb> => {
302
+ ensureLib()
303
+
304
+ const path = name === ':memory:' ? ':memory:' : databasePath(name)
305
+ const ref = new host.interop.Reference()
306
+ const rc = host.sqlite3_open(path ?? ':memory:', ref)
307
+
308
+ if (rc !== 0) {
309
+ const db = ref.value
310
+ const message = db
311
+ ? host.interop.stringFromCString(host.sqlite3_errmsg(db))
312
+ : `open failed (rc=${rc})`
313
+
314
+ if (db) {
315
+ host.sqlite3_close_v2(db)
316
+ }
317
+
318
+ throw new Error(`sqlite open failed: ${message}`)
319
+ }
320
+
321
+ const db = ref.value
322
+ host.sqlite3_busy_timeout(db, 5000)
323
+ return new MacosSqliteDb(db, path != null)
324
+ }
325
+
326
+ export const deleteDatabase = async (name: string): Promise<boolean> => {
327
+ const path = databasePath(name)
328
+ if (!path) {
329
+ return false
330
+ }
331
+
332
+ let removed = false
333
+ const fm = host.NSFileManager.defaultManager
334
+
335
+ for (const suffix of ['', '-wal', '-shm', '-journal']) {
336
+ const target = `${path}${suffix}`
337
+ if (fs?.existsSync(target)) {
338
+ removed = !!fm.removeItemAtPathError(target, null) || removed
339
+ }
340
+ }
341
+
342
+ return removed
343
+ }
package/src/db.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { knownFolders } from '@nativescript/core'
2
+ import {
3
+ deleteDatabase as deleteNativeDatabase,
4
+ openOrCreate,
5
+ type SQLiteDatabase,
6
+ } from '@nativescript-community/sqlite'
7
+
8
+ import type { OpenDatabaseOptions, SqliteDb, SqliteParams, SqliteRow } from './types'
9
+
10
+ export const supported = true
11
+
12
+ const resolvePath = (name: string) =>
13
+ name.includes('/') ? name : knownFolders.documents().getFile(name).path
14
+
15
+ class NativeSqliteDb implements SqliteDb {
16
+ readonly persistent = true
17
+ constructor(private readonly db: SQLiteDatabase) {}
18
+ get isOpen() {
19
+ return this.db.isOpen
20
+ }
21
+ execute = (sql: string, params?: SqliteParams) => this.db.execute(sql, params)
22
+ select = <T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T[]> =>
23
+ this.db.select(sql, params) as Promise<T[]>
24
+ selectArray = (sql: string, params?: SqliteParams) => this.db.selectArray(sql, params)
25
+ // The plugin's getRaw reads resultDictionary before s.next() — get/getArray
26
+ // crash on iOS. select(selectN)[0] is the same contract, working code.
27
+ get = async <T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T | null> =>
28
+ ((await this.db.select(sql, params)) as T[])[0] ?? null
29
+ getArray = async (sql: string, params?: SqliteParams) =>
30
+ (await this.db.selectArray(sql, params))[0] ?? null
31
+ transaction = <T>(action: (db: SqliteDb) => Promise<T>): Promise<T> =>
32
+ this.db.transaction(() => action(this))
33
+ each = (
34
+ sql: string,
35
+ params: SqliteParams,
36
+ onRow: (error: Error | null, row: SqliteRow) => void,
37
+ onDone: (error: Error | null, count: number) => void,
38
+ ): Promise<number> => this.db.each(sql, params, onRow, onDone)
39
+ getUserVersion = async () => this.db.getVersion() as number
40
+ setUserVersion = async (version: number) => {
41
+ this.db.setVersion(version)
42
+ }
43
+ close = async () => {
44
+ this.db.close()
45
+ }
46
+ }
47
+
48
+ export const openDatabase = async (
49
+ name: string,
50
+ options?: OpenDatabaseOptions,
51
+ ): Promise<SqliteDb> =>
52
+ new NativeSqliteDb(openOrCreate(resolvePath(name), { threading: options?.threading ?? true }))
53
+
54
+ export const deleteDatabase = async (name: string) => deleteNativeDatabase(resolvePath(name))
@@ -0,0 +1,14 @@
1
+ import type { OpenDatabaseOptions, SqliteDb } from './types'
2
+
3
+ export const supported = false
4
+
5
+ const unsupported = (name: string): never => {
6
+ throw new Error(
7
+ `@octane-xplat/sqlite is not supported on this platform yet (${name} not opened).`,
8
+ )
9
+ }
10
+
11
+ export const openDatabase = (_name: string, _options?: OpenDatabaseOptions): Promise<SqliteDb> =>
12
+ unsupported(_name)
13
+
14
+ export const deleteDatabase = async (_name: string) => false
package/src/db.web.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { OpenDatabaseOptions, SqliteDb, SqliteParams, SqliteRow } from './types'
2
+
3
+ export const supported = true
4
+
5
+ type Pending = { resolve: (value: any) => void; reject: (error: Error) => void }
6
+ const pending = new Map<number, Pending>()
7
+ let nextId = 0
8
+ let worker: Worker | null = null
9
+
10
+ const ensureWorker = (): Worker => {
11
+ if (!worker) {
12
+ // Bundled by the consuming app's vite build (lib-mode workers are not
13
+ // emitted here — the leaf ships source). The wasm asset rides along via
14
+ // the ?url import inside the worker module.
15
+ worker = new Worker(new URL('./worker.web.ts', import.meta.url), { type: 'module' })
16
+ worker.onmessage = (event: MessageEvent<{ id: number; result?: unknown; error?: string }>) => {
17
+ const { id, result, error } = event.data
18
+ const entry = pending.get(id)
19
+
20
+ if (!entry) {
21
+ return
22
+ }
23
+
24
+ pending.delete(id)
25
+ if (error != null) {
26
+ entry.reject(new Error(error))
27
+ } else {
28
+ entry.resolve(result)
29
+ }
30
+ }
31
+ }
32
+
33
+ return worker
34
+ }
35
+
36
+ const call = <T>(op: string, ...args: unknown[]): Promise<T> =>
37
+ new Promise((resolve, reject) => {
38
+ const id = ++nextId
39
+ pending.set(id, { resolve, reject })
40
+ ensureWorker().postMessage({ id, op, args })
41
+ })
42
+
43
+ class WebSqliteDb implements SqliteDb {
44
+ isOpen = true
45
+ constructor(readonly persistent: boolean) {}
46
+ private ensureOpen() {
47
+ if (!this.isOpen) {
48
+ throw new Error('sqlite db is closed')
49
+ }
50
+ }
51
+ execute = (sql: string, params?: SqliteParams) => {
52
+ this.ensureOpen()
53
+ return call<void>('execute', sql, params ?? null)
54
+ }
55
+ select = <T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T[]> => {
56
+ this.ensureOpen()
57
+ return call<T[]>('select', sql, params ?? null)
58
+ }
59
+ selectArray = (sql: string, params?: SqliteParams) => {
60
+ this.ensureOpen()
61
+ return call<any[]>('selectArray', sql, params ?? null)
62
+ }
63
+ get = <T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T | null> => {
64
+ this.ensureOpen()
65
+ return call<T | null>('get', sql, params ?? null)
66
+ }
67
+ getArray = (sql: string, params?: SqliteParams) => {
68
+ this.ensureOpen()
69
+ return call<any[] | null>('getArray', sql, params ?? null)
70
+ }
71
+ // Messages are serialized on the single worker-side connection, so the
72
+ // BEGIN…COMMIT window can span several RPC calls safely.
73
+ transaction = async <T>(action: (db: SqliteDb) => Promise<T>): Promise<T> => {
74
+ this.ensureOpen()
75
+ await call('begin')
76
+ try {
77
+ const result = await action(this)
78
+ await call('commit')
79
+ return result
80
+ } catch (error) {
81
+ await call('rollback').catch(() => {})
82
+ throw error
83
+ }
84
+ }
85
+ each = async (
86
+ sql: string,
87
+ params: SqliteParams,
88
+ onRow: (error: Error | null, row: SqliteRow) => void,
89
+ onDone: (error: Error | null, count: number) => void,
90
+ ) => {
91
+ // Row callbacks can't stream back over postMessage cheaply for v1 —
92
+ // select then iterate. The contract is identical, the memory isn't.
93
+ const rows = await this.select(sql, params)
94
+
95
+ for (const row of rows) {
96
+ onRow(null, row)
97
+ }
98
+
99
+ onDone(null, rows.length)
100
+ return rows.length
101
+ }
102
+ getUserVersion = () => {
103
+ this.ensureOpen()
104
+ return call<number>('getUserVersion')
105
+ }
106
+ setUserVersion = (version: number) => {
107
+ this.ensureOpen()
108
+ return call<void>('setUserVersion', version | 0)
109
+ }
110
+ close = async () => {
111
+ if (!this.isOpen) {
112
+ return
113
+ }
114
+
115
+ this.isOpen = false
116
+ await call('close').catch(() => {})
117
+ }
118
+ }
119
+
120
+ export const openDatabase = async (
121
+ name: string,
122
+ _options?: OpenDatabaseOptions,
123
+ ): Promise<SqliteDb> => {
124
+ const result = await call<{ persistent: boolean }>('open', name)
125
+ return new WebSqliteDb(result.persistent)
126
+ }
127
+
128
+ export const deleteDatabase = async (name: string) => call<boolean>('delete', name)
@@ -0,0 +1 @@
1
+ export * from './db.unsupported'
@@ -0,0 +1,2 @@
1
+ export * from './db'
2
+ export type * from './types'
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './db'
2
+ export type * from './types'
@@ -0,0 +1,2 @@
1
+ export * from './db'
2
+ export type * from './types'
@@ -0,0 +1,8 @@
1
+ {
2
+ // Package source is consumed as raw .ts (exports map → ./src/*), so
3
+ // consumer dev transforms resolve THIS directory's nearest tsconfig.json.
4
+ // @NativeClass()/decorator files need experimentalDecorators emit; without
5
+ // a discoverable config the transform emits TC39 `export @dec class`,
6
+ // which the device V8 cannot parse — SyntaxError at boot.
7
+ "extends": "../../../tsconfig.base.json"
8
+ }
package/src/types.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared contract for @octane-xplat/sqlite — every platform leaf exports the
3
+ * same names (the identical-exports invariant; the .web/.macos/.windows leaves
4
+ * are implementation swaps, not API changes).
5
+ */
6
+
7
+ export type SqliteParam = null | number | bigint | string | ArrayBuffer | Uint8Array
8
+ export type SqliteParams = SqliteParam | SqliteParam[]
9
+
10
+ export interface SqliteRow {
11
+ [name: string]: SqliteParam
12
+ }
13
+
14
+ export interface OpenDatabaseOptions {
15
+ /**
16
+ * Native only: route calls through the plugin's worker thread instead of
17
+ * the JS thread. Defaults to true. Ignored on other platforms (the web
18
+ * leaf always runs in a Worker).
19
+ */
20
+ threading?: boolean
21
+ }
22
+
23
+ export interface SqliteDb {
24
+ readonly isOpen: boolean
25
+ /**
26
+ * False when the backend opened a transient database — the web leaf only
27
+ * persists through OPFS, which needs a Worker and, on some browsers,
28
+ * cross-origin isolation. Always true on native.
29
+ */
30
+ readonly persistent: boolean
31
+ execute(sql: string, params?: SqliteParams): Promise<void>
32
+ select<T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T[]>
33
+ selectArray(sql: string, params?: SqliteParams): Promise<SqliteParam[][]>
34
+ /** First row, or null. */
35
+ get<T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T | null>
36
+ /** First row as an array, or null. */
37
+ getArray(sql: string, params?: SqliteParams): Promise<SqliteParam[] | null>
38
+ /** Runs `action` inside a transaction; a throw rolls everything back. */
39
+ transaction<T>(action: (db: SqliteDb) => Promise<T>): Promise<T>
40
+ each(
41
+ sql: string,
42
+ params: SqliteParams,
43
+ onRow: (error: Error | null, row: SqliteRow) => void,
44
+ onDone: (error: Error | null, count: number) => void,
45
+ ): Promise<number>
46
+ /** PRAGMA user_version — shared migration hook across platforms. */
47
+ getUserVersion(): Promise<number>
48
+ setUserVersion(version: number): Promise<void>
49
+ close(): Promise<void>
50
+ }
@@ -0,0 +1,119 @@
1
+ /// <reference lib="webworker" />
2
+ /**
3
+ * sqlite-wasm host. Runs inside a module Worker spawned by db.web.ts; the
4
+ * client is a thin RPC layer so every API call is async on the JS side.
5
+ *
6
+ * Persistence preference order:
7
+ * 1. installOpfsSAHPoolVfs — OPFS via sync access handles, needs a Worker
8
+ * but NOT cross-origin isolation (no COOP/COEP deployment constraint).
9
+ * 2. OpfsDb — the older OPFS backend; requires crossOriginIsolated.
10
+ * 3. oo1.DB('ct') — transient in-memory database.
11
+ */
12
+ import sqlite3InitModule from '@sqlite.org/sqlite-wasm'
13
+ import wasmUrl from '@sqlite.org/sqlite-wasm/sqlite3.wasm?url'
14
+
15
+ type Sqlite3 = Awaited<ReturnType<typeof sqlite3InitModule>>
16
+ const sqlite3Promise = sqlite3InitModule({ locateFile: () => wasmUrl })
17
+
18
+ let db: any = null
19
+ let dbName = ''
20
+ let sahPool: any = null
21
+ let persistent = false
22
+
23
+ const normalizeParams = (params: any) =>
24
+ params == null ? undefined : Array.isArray(params) ? params : [params]
25
+
26
+ const open = async (name: string) => {
27
+ const sqlite3: Sqlite3 = await sqlite3Promise
28
+ const file = name.startsWith('/') ? name : `/${name}`
29
+
30
+ if (db) {
31
+ db.close()
32
+ }
33
+
34
+ db = null
35
+ sahPool ??=
36
+ typeof (sqlite3 as any).installOpfsSAHPoolVfs === 'function'
37
+ ? await (sqlite3 as any).installOpfsSAHPoolVfs().catch(() => null)
38
+ : null
39
+
40
+ if (sahPool) {
41
+ db = new sqlite3.oo1.DB(file, 'c', sahPool.vfsName)
42
+ persistent = true
43
+ } else if ('opfs' in sqlite3 && (sqlite3.oo1 as any).OpfsDb) {
44
+ db = new (sqlite3.oo1 as any).OpfsDb(file)
45
+ persistent = true
46
+ } else {
47
+ db = new sqlite3.oo1.DB(file, 'ct')
48
+ persistent = false
49
+ }
50
+
51
+ dbName = file
52
+ return { persistent, version: sqlite3.version.libVersion }
53
+ }
54
+
55
+ const deleteDb = async (name: string) => {
56
+ const file = name.startsWith('/') ? name : `/${name}`
57
+
58
+ if (db && dbName === file) {
59
+ db.close()
60
+ db = null
61
+ }
62
+
63
+ if (sahPool) {
64
+ return Boolean(sahPool.unlink(file))
65
+ }
66
+
67
+ // OpfsDb path: best-effort removal of the OPFS entry.
68
+ try {
69
+ const root = await navigator.storage.getDirectory()
70
+ await root.removeEntry(file.slice(1))
71
+ return true
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ const handlers: Record<string, (...args: any[]) => unknown> = {
78
+ open,
79
+ delete: deleteDb,
80
+ execute: (sql: string, params?: any) => db.exec({ sql, bind: normalizeParams(params) }),
81
+ select: (sql: string, params?: any) => db.selectObjects(sql, normalizeParams(params)),
82
+ selectArray: (sql: string, params?: any) => db.selectArrays(sql, normalizeParams(params)),
83
+ get: (sql: string, params?: any) => db.selectObjects(sql, normalizeParams(params))[0] ?? null,
84
+ getArray: (sql: string, params?: any) => db.selectArrays(sql, normalizeParams(params))[0] ?? null,
85
+ begin: () => db.exec('BEGIN'),
86
+ commit: () => db.exec('COMMIT'),
87
+ rollback: () => db.exec('ROLLBACK'),
88
+ getUserVersion: () => db.selectValue('PRAGMA user_version'),
89
+ setUserVersion: (version: number) => db.exec(`PRAGMA user_version = ${version | 0}`),
90
+ close: () => {
91
+ if (db) {
92
+ db.close()
93
+ }
94
+
95
+ db = null
96
+ },
97
+ }
98
+
99
+ self.onmessage = async (event: MessageEvent<{ id: number; op: string; args: any[] }>) => {
100
+ const { id, op, args } = event.data
101
+
102
+ try {
103
+ const handler = handlers[op]
104
+
105
+ if (!handler) {
106
+ throw new Error(`unknown sqlite op: ${op}`)
107
+ }
108
+
109
+ if (!db && op !== 'open' && op !== 'delete') {
110
+ throw new Error('sqlite db is not open')
111
+ }
112
+
113
+ const result = await handler(...args)
114
+
115
+ ;(self as any).postMessage({ id, result })
116
+ } catch (error: any) {
117
+ ;(self as any).postMessage({ id, error: error?.message ?? String(error) })
118
+ }
119
+ }
package/index.d.ts DELETED
@@ -1 +0,0 @@
1
- export {}
package/index.js DELETED
@@ -1,2 +0,0 @@
1
- // Reservation stub; implementation will arrive in a later release.
2
- module.exports = {}