@octane-xplat/sqlite 0.0.1 → 0.10.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,61 @@
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
+ ```sh
4
+ pnpm add @octane-xplat/sqlite
5
+ ```
6
+
7
+ Cross-target SQLite persistence for Octane xplat apps:
8
+ `@nativescript-community/sqlite` on iOS/Android, a `sqlite-wasm` worker
9
+ with OPFS persistence on web, system `libsqlite3` via C-function interop
10
+ on the macOS AppKit host, and an explicit `supported: false` on Windows.
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
+ ```ts
32
+ import { deleteDatabase } from '@octane-xplat/sqlite'
33
+
34
+ if (supported) {
35
+ const database = await openDatabase('preview.db')
36
+ try {
37
+ console.log(database.isOpen, database.persistent)
38
+ await database.execute('CREATE TABLE IF NOT EXISTS items(name TEXT)')
39
+ const first = await database.getArray('SELECT name FROM items LIMIT 1')
40
+ await database.each(
41
+ 'SELECT name FROM items',
42
+ [],
43
+ (error, row) => {
44
+ if (!error) console.log(row.name)
45
+ },
46
+ (error, count) => {
47
+ if (!error) console.log(count)
48
+ },
49
+ )
50
+ if ((await database.getUserVersion()) === 0) await database.setUserVersion(1)
51
+ } finally {
52
+ await database.close()
53
+ }
54
+ await deleteDatabase('preview.db') // This example database is disposable.
55
+ }
56
+ ```
57
+
58
+ Design notes: [`docs/notes/sqlite-notes.md`](../../docs/notes/sqlite-notes.md);
59
+ limits: [known limits](../../docs/verify/known-limits.md). Exercised by the
60
+ harness `Services` screen
61
+ ([`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.10.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,199 @@
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
+ const openDatabases = new Map<number, WebSqliteDb>()
8
+ let nextId = 0
9
+ let worker: Worker | null = null
10
+ let workerGeneration = 0
11
+
12
+ const toError = (reason: unknown) => (reason instanceof Error ? reason : new Error(String(reason)))
13
+
14
+ const failWorker = (instance: Worker, error: Error) => {
15
+ if (worker !== instance) {
16
+ return
17
+ }
18
+
19
+ worker = null
20
+ workerGeneration++
21
+ instance.terminate()
22
+ openDatabases.clear()
23
+ for (const [id, entry] of pending) {
24
+ pending.delete(id)
25
+ entry.reject(error)
26
+ }
27
+ }
28
+
29
+ const ensureWorker = (): Worker => {
30
+ if (!worker) {
31
+ // Bundled by the consuming app's vite build (lib-mode workers are not
32
+ // emitted here — the leaf ships source). The wasm asset rides along via
33
+ // the ?url import inside the worker module.
34
+ const instance = new Worker(new URL('./worker.web.ts', import.meta.url), { type: 'module' })
35
+ worker = instance
36
+ instance.onmessage = (
37
+ event: MessageEvent<{ id: number; result?: unknown; error?: string }>,
38
+ ) => {
39
+ if (worker !== instance) {
40
+ return
41
+ }
42
+
43
+ const { id, result, error } = event.data
44
+ const entry = pending.get(id)
45
+
46
+ if (!entry) {
47
+ return
48
+ }
49
+
50
+ pending.delete(id)
51
+ if (error != null) {
52
+ entry.reject(new Error(error))
53
+ } else {
54
+ entry.resolve(result)
55
+ }
56
+ }
57
+
58
+ instance.onerror = (event) => {
59
+ event.preventDefault()
60
+ failWorker(
61
+ instance,
62
+ new Error(`sqlite Web worker failed: ${event.message || 'startup or runtime error'}`),
63
+ )
64
+ }
65
+
66
+ instance.onmessageerror = () => {
67
+ failWorker(instance, new Error('sqlite Web worker sent an unreadable response'))
68
+ }
69
+
70
+ return instance
71
+ }
72
+
73
+ return worker
74
+ }
75
+
76
+ const call = <T>(op: string, ...args: unknown[]): Promise<T> =>
77
+ new Promise((resolve, reject) => {
78
+ const id = ++nextId
79
+ pending.set(id, { resolve, reject })
80
+ try {
81
+ ensureWorker().postMessage({ id, op, args })
82
+ } catch (error) {
83
+ pending.delete(id)
84
+ reject(toError(error))
85
+ }
86
+ })
87
+
88
+ class WebSqliteDb implements SqliteDb {
89
+ private closed = false
90
+ constructor(
91
+ private readonly databaseId: number,
92
+ readonly persistent: boolean,
93
+ private readonly generation: number,
94
+ ) {}
95
+ get isOpen() {
96
+ return !this.closed && this.generation === workerGeneration
97
+ }
98
+ private ensureOpen() {
99
+ if (!this.isOpen) {
100
+ throw new Error('sqlite db is closed')
101
+ }
102
+ }
103
+ invalidate() {
104
+ this.closed = true
105
+ }
106
+ execute = (sql: string, params?: SqliteParams) => {
107
+ this.ensureOpen()
108
+ return call<void>('execute', this.databaseId, sql, params ?? null)
109
+ }
110
+ select = <T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T[]> => {
111
+ this.ensureOpen()
112
+ return call<T[]>('select', this.databaseId, sql, params ?? null)
113
+ }
114
+ selectArray = (sql: string, params?: SqliteParams) => {
115
+ this.ensureOpen()
116
+ return call<any[]>('selectArray', this.databaseId, sql, params ?? null)
117
+ }
118
+ get = <T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T | null> => {
119
+ this.ensureOpen()
120
+ return call<T | null>('get', this.databaseId, sql, params ?? null)
121
+ }
122
+ getArray = (sql: string, params?: SqliteParams) => {
123
+ this.ensureOpen()
124
+ return call<any[] | null>('getArray', this.databaseId, sql, params ?? null)
125
+ }
126
+ // Messages are serialized on the single worker-side connection, so the
127
+ // BEGIN…COMMIT window can span several RPC calls safely.
128
+ transaction = async <T>(action: (db: SqliteDb) => Promise<T>): Promise<T> => {
129
+ this.ensureOpen()
130
+ await call('begin', this.databaseId)
131
+ try {
132
+ const result = await action(this)
133
+ await call('commit', this.databaseId)
134
+ return result
135
+ } catch (error) {
136
+ await call('rollback', this.databaseId).catch(() => {})
137
+ throw error
138
+ }
139
+ }
140
+ each = async (
141
+ sql: string,
142
+ params: SqliteParams,
143
+ onRow: (error: Error | null, row: SqliteRow) => void,
144
+ onDone: (error: Error | null, count: number) => void,
145
+ ) => {
146
+ // Row callbacks can't stream back over postMessage cheaply for v1 —
147
+ // select then iterate. The contract is identical, the memory isn't.
148
+ const rows = await this.select(sql, params)
149
+
150
+ for (const row of rows) {
151
+ onRow(null, row)
152
+ }
153
+
154
+ onDone(null, rows.length)
155
+ return rows.length
156
+ }
157
+ getUserVersion = () => {
158
+ this.ensureOpen()
159
+ return call<number>('getUserVersion', this.databaseId)
160
+ }
161
+ setUserVersion = (version: number) => {
162
+ this.ensureOpen()
163
+ return call<void>('setUserVersion', this.databaseId, version | 0)
164
+ }
165
+ close = async () => {
166
+ if (!this.isOpen) {
167
+ return
168
+ }
169
+
170
+ this.closed = true
171
+ openDatabases.delete(this.databaseId)
172
+ await call('close', this.databaseId).catch(() => {})
173
+ }
174
+ }
175
+
176
+ export const openDatabase = async (
177
+ name: string,
178
+ _options?: OpenDatabaseOptions,
179
+ ): Promise<SqliteDb> => {
180
+ const generation = workerGeneration
181
+ const result = await call<{ databaseId: number; persistent: boolean }>('open', name)
182
+ if (generation !== workerGeneration) {
183
+ throw new Error('sqlite Web worker failed while opening the database')
184
+ }
185
+
186
+ const db = new WebSqliteDb(result.databaseId, result.persistent, generation)
187
+ openDatabases.set(result.databaseId, db)
188
+ return db
189
+ }
190
+
191
+ export const deleteDatabase = async (name: string) => {
192
+ const result = await call<{ deleted: boolean; databaseIds: number[] }>('delete', name)
193
+ for (const databaseId of result.databaseIds) {
194
+ openDatabases.get(databaseId)?.invalidate()
195
+ openDatabases.delete(databaseId)
196
+ }
197
+
198
+ return result.deleted
199
+ }
@@ -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,143 @@
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
+ type DatabaseHandle = { db: any; name: string; persistent: boolean }
19
+ const databases = new Map<number, DatabaseHandle>()
20
+ let nextDatabaseId = 0
21
+ let sahPoolPromise: Promise<any> | null = null
22
+
23
+ const getSahPool = (sqlite3: Sqlite3) => {
24
+ sahPoolPromise ??= Promise.resolve()
25
+ .then(() =>
26
+ typeof (sqlite3 as any).installOpfsSAHPoolVfs === 'function'
27
+ ? (sqlite3 as any).installOpfsSAHPoolVfs()
28
+ : null,
29
+ )
30
+ .catch(() => null)
31
+
32
+ return sahPoolPromise
33
+ }
34
+
35
+ const database = (id: number) => {
36
+ const entry = databases.get(id)
37
+ if (!entry) {
38
+ throw new Error(`sqlite db handle ${id} is not open`)
39
+ }
40
+
41
+ return entry
42
+ }
43
+
44
+ const normalizeParams = (params: any) =>
45
+ params == null ? undefined : Array.isArray(params) ? params : [params]
46
+
47
+ const open = async (name: string) => {
48
+ const sqlite3: Sqlite3 = await sqlite3Promise
49
+ const file = name.startsWith('/') ? name : `/${name}`
50
+ const sahPool = await getSahPool(sqlite3)
51
+
52
+ let db: any
53
+ let persistent = false
54
+ if (sahPool) {
55
+ db = new sqlite3.oo1.DB(file, 'c', sahPool.vfsName)
56
+ persistent = true
57
+ } else if ('opfs' in sqlite3 && (sqlite3.oo1 as any).OpfsDb) {
58
+ db = new (sqlite3.oo1 as any).OpfsDb(file)
59
+ persistent = true
60
+ } else {
61
+ db = new sqlite3.oo1.DB(file, 'ct')
62
+ persistent = false
63
+ }
64
+
65
+ const databaseId = ++nextDatabaseId
66
+ databases.set(databaseId, { db, name: file, persistent })
67
+ return { databaseId, persistent, version: sqlite3.version.libVersion }
68
+ }
69
+
70
+ const deleteDb = async (name: string) => {
71
+ const file = name.startsWith('/') ? name : `/${name}`
72
+ const databaseIds: number[] = []
73
+
74
+ for (const [id, entry] of databases) {
75
+ if (entry.name === file) {
76
+ entry.db.close()
77
+ databases.delete(id)
78
+ databaseIds.push(id)
79
+ }
80
+ }
81
+
82
+ const sahPool = sahPoolPromise ? await sahPoolPromise : null
83
+ if (sahPool) {
84
+ return { deleted: Boolean(sahPool.unlink(file)), databaseIds }
85
+ }
86
+
87
+ // OpfsDb path: best-effort removal of the OPFS entry.
88
+ try {
89
+ const root = await navigator.storage.getDirectory()
90
+ await root.removeEntry(file.slice(1))
91
+ return { deleted: true, databaseIds }
92
+ } catch {
93
+ return { deleted: false, databaseIds }
94
+ }
95
+ }
96
+
97
+ const handlers: Record<string, (...args: any[]) => unknown> = {
98
+ open,
99
+ delete: deleteDb,
100
+ execute: (id: number, sql: string, params?: any) =>
101
+ database(id).db.exec({ sql, bind: normalizeParams(params) }),
102
+ select: (id: number, sql: string, params?: any) =>
103
+ database(id).db.selectObjects(sql, normalizeParams(params)),
104
+ selectArray: (id: number, sql: string, params?: any) =>
105
+ database(id).db.selectArrays(sql, normalizeParams(params)),
106
+ get: (id: number, sql: string, params?: any) =>
107
+ database(id).db.selectObjects(sql, normalizeParams(params))[0] ?? null,
108
+ getArray: (id: number, sql: string, params?: any) =>
109
+ database(id).db.selectArrays(sql, normalizeParams(params))[0] ?? null,
110
+ begin: (id: number) => database(id).db.exec('BEGIN'),
111
+ commit: (id: number) => database(id).db.exec('COMMIT'),
112
+ rollback: (id: number) => database(id).db.exec('ROLLBACK'),
113
+ getUserVersion: (id: number) => database(id).db.selectValue('PRAGMA user_version'),
114
+ setUserVersion: (id: number, version: number) =>
115
+ database(id).db.exec(`PRAGMA user_version = ${version | 0}`),
116
+ close: (id: number) => {
117
+ const entry = databases.get(id)
118
+ if (!entry) {
119
+ return
120
+ }
121
+
122
+ entry.db.close()
123
+ databases.delete(id)
124
+ },
125
+ }
126
+
127
+ self.onmessage = async (event: MessageEvent<{ id: number; op: string; args: any[] }>) => {
128
+ const { id, op, args } = event.data
129
+
130
+ try {
131
+ const handler = handlers[op]
132
+
133
+ if (!handler) {
134
+ throw new Error(`unknown sqlite op: ${op}`)
135
+ }
136
+
137
+ const result = await handler(...args)
138
+
139
+ ;(self as any).postMessage({ id, result })
140
+ } catch (error: any) {
141
+ ;(self as any).postMessage({ id, error: error?.message ?? String(error) })
142
+ }
143
+ }
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 = {}