@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 +21 -0
- package/README.md +33 -2
- package/package.json +38 -10
- package/src/db.macos.ts +343 -0
- package/src/db.ts +54 -0
- package/src/db.unsupported.ts +14 -0
- package/src/db.web.ts +128 -0
- package/src/db.windows.ts +1 -0
- package/src/index.macos.ts +2 -0
- package/src/index.ts +2 -0
- package/src/index.web.ts +2 -0
- package/src/tsconfig.json +8 -0
- package/src/types.ts +50 -0
- package/src/worker.web.ts +119 -0
- package/index.d.ts +0 -1
- package/index.js +0 -2
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
|
-
#
|
|
1
|
+
# `@octane-xplat/sqlite`
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
|
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
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
}
|
package/src/db.macos.ts
ADDED
|
@@ -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'
|
package/src/index.ts
ADDED
package/src/index.web.ts
ADDED
|
@@ -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