@octane-xplat/sqlite 0.9.0 → 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/README.md +33 -6
- package/package.json +1 -1
- package/src/db.web.ts +91 -20
- package/src/worker.web.ts +64 -40
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# `@octane-xplat/sqlite`
|
|
2
2
|
|
|
3
|
+
```sh
|
|
4
|
+
pnpm add @octane-xplat/sqlite
|
|
5
|
+
```
|
|
6
|
+
|
|
3
7
|
Cross-target SQLite persistence for Octane xplat apps:
|
|
4
8
|
`@nativescript-community/sqlite` on iOS/Android, a `sqlite-wasm` worker
|
|
5
9
|
with OPFS persistence on web, system `libsqlite3` via C-function interop
|
|
6
10
|
on the macOS AppKit host, and an explicit `supported: false` on Windows.
|
|
7
11
|
|
|
8
|
-
```sh
|
|
9
|
-
pnpm add @octane-xplat/sqlite
|
|
10
|
-
```
|
|
11
|
-
|
|
12
12
|
```ts
|
|
13
13
|
import { openDatabase, supported } from '@octane-xplat/sqlite'
|
|
14
14
|
|
|
@@ -28,7 +28,34 @@ and on some browsers cross-origin isolation, so check the flag before
|
|
|
28
28
|
treating web data as durable. `deleteDatabase` removes a named database.
|
|
29
29
|
Branch on `supported` where a target has no backend.
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
33
60
|
harness `Services` screen
|
|
34
61
|
([`packages/app/src/Services.tsrx`](../app/src/Services.tsrx)).
|
package/package.json
CHANGED
package/src/db.web.ts
CHANGED
|
@@ -4,16 +4,42 @@ export const supported = true
|
|
|
4
4
|
|
|
5
5
|
type Pending = { resolve: (value: any) => void; reject: (error: Error) => void }
|
|
6
6
|
const pending = new Map<number, Pending>()
|
|
7
|
+
const openDatabases = new Map<number, WebSqliteDb>()
|
|
7
8
|
let nextId = 0
|
|
8
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
|
+
}
|
|
9
28
|
|
|
10
29
|
const ensureWorker = (): Worker => {
|
|
11
30
|
if (!worker) {
|
|
12
31
|
// Bundled by the consuming app's vite build (lib-mode workers are not
|
|
13
32
|
// emitted here — the leaf ships source). The wasm asset rides along via
|
|
14
33
|
// the ?url import inside the worker module.
|
|
15
|
-
|
|
16
|
-
worker
|
|
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
|
+
|
|
17
43
|
const { id, result, error } = event.data
|
|
18
44
|
const entry = pending.get(id)
|
|
19
45
|
|
|
@@ -28,6 +54,20 @@ const ensureWorker = (): Worker => {
|
|
|
28
54
|
entry.resolve(result)
|
|
29
55
|
}
|
|
30
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
|
|
31
71
|
}
|
|
32
72
|
|
|
33
73
|
return worker
|
|
@@ -37,48 +77,63 @@ const call = <T>(op: string, ...args: unknown[]): Promise<T> =>
|
|
|
37
77
|
new Promise((resolve, reject) => {
|
|
38
78
|
const id = ++nextId
|
|
39
79
|
pending.set(id, { resolve, reject })
|
|
40
|
-
|
|
80
|
+
try {
|
|
81
|
+
ensureWorker().postMessage({ id, op, args })
|
|
82
|
+
} catch (error) {
|
|
83
|
+
pending.delete(id)
|
|
84
|
+
reject(toError(error))
|
|
85
|
+
}
|
|
41
86
|
})
|
|
42
87
|
|
|
43
88
|
class WebSqliteDb implements SqliteDb {
|
|
44
|
-
|
|
45
|
-
constructor(
|
|
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
|
+
}
|
|
46
98
|
private ensureOpen() {
|
|
47
99
|
if (!this.isOpen) {
|
|
48
100
|
throw new Error('sqlite db is closed')
|
|
49
101
|
}
|
|
50
102
|
}
|
|
103
|
+
invalidate() {
|
|
104
|
+
this.closed = true
|
|
105
|
+
}
|
|
51
106
|
execute = (sql: string, params?: SqliteParams) => {
|
|
52
107
|
this.ensureOpen()
|
|
53
|
-
return call<void>('execute', sql, params ?? null)
|
|
108
|
+
return call<void>('execute', this.databaseId, sql, params ?? null)
|
|
54
109
|
}
|
|
55
110
|
select = <T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T[]> => {
|
|
56
111
|
this.ensureOpen()
|
|
57
|
-
return call<T[]>('select', sql, params ?? null)
|
|
112
|
+
return call<T[]>('select', this.databaseId, sql, params ?? null)
|
|
58
113
|
}
|
|
59
114
|
selectArray = (sql: string, params?: SqliteParams) => {
|
|
60
115
|
this.ensureOpen()
|
|
61
|
-
return call<any[]>('selectArray', sql, params ?? null)
|
|
116
|
+
return call<any[]>('selectArray', this.databaseId, sql, params ?? null)
|
|
62
117
|
}
|
|
63
118
|
get = <T = SqliteRow>(sql: string, params?: SqliteParams): Promise<T | null> => {
|
|
64
119
|
this.ensureOpen()
|
|
65
|
-
return call<T | null>('get', sql, params ?? null)
|
|
120
|
+
return call<T | null>('get', this.databaseId, sql, params ?? null)
|
|
66
121
|
}
|
|
67
122
|
getArray = (sql: string, params?: SqliteParams) => {
|
|
68
123
|
this.ensureOpen()
|
|
69
|
-
return call<any[] | null>('getArray', sql, params ?? null)
|
|
124
|
+
return call<any[] | null>('getArray', this.databaseId, sql, params ?? null)
|
|
70
125
|
}
|
|
71
126
|
// Messages are serialized on the single worker-side connection, so the
|
|
72
127
|
// BEGIN…COMMIT window can span several RPC calls safely.
|
|
73
128
|
transaction = async <T>(action: (db: SqliteDb) => Promise<T>): Promise<T> => {
|
|
74
129
|
this.ensureOpen()
|
|
75
|
-
await call('begin')
|
|
130
|
+
await call('begin', this.databaseId)
|
|
76
131
|
try {
|
|
77
132
|
const result = await action(this)
|
|
78
|
-
await call('commit')
|
|
133
|
+
await call('commit', this.databaseId)
|
|
79
134
|
return result
|
|
80
135
|
} catch (error) {
|
|
81
|
-
await call('rollback').catch(() => {})
|
|
136
|
+
await call('rollback', this.databaseId).catch(() => {})
|
|
82
137
|
throw error
|
|
83
138
|
}
|
|
84
139
|
}
|
|
@@ -101,19 +156,20 @@ class WebSqliteDb implements SqliteDb {
|
|
|
101
156
|
}
|
|
102
157
|
getUserVersion = () => {
|
|
103
158
|
this.ensureOpen()
|
|
104
|
-
return call<number>('getUserVersion')
|
|
159
|
+
return call<number>('getUserVersion', this.databaseId)
|
|
105
160
|
}
|
|
106
161
|
setUserVersion = (version: number) => {
|
|
107
162
|
this.ensureOpen()
|
|
108
|
-
return call<void>('setUserVersion', version | 0)
|
|
163
|
+
return call<void>('setUserVersion', this.databaseId, version | 0)
|
|
109
164
|
}
|
|
110
165
|
close = async () => {
|
|
111
166
|
if (!this.isOpen) {
|
|
112
167
|
return
|
|
113
168
|
}
|
|
114
169
|
|
|
115
|
-
this.
|
|
116
|
-
|
|
170
|
+
this.closed = true
|
|
171
|
+
openDatabases.delete(this.databaseId)
|
|
172
|
+
await call('close', this.databaseId).catch(() => {})
|
|
117
173
|
}
|
|
118
174
|
}
|
|
119
175
|
|
|
@@ -121,8 +177,23 @@ export const openDatabase = async (
|
|
|
121
177
|
name: string,
|
|
122
178
|
_options?: OpenDatabaseOptions,
|
|
123
179
|
): Promise<SqliteDb> => {
|
|
124
|
-
const
|
|
125
|
-
|
|
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
|
|
126
189
|
}
|
|
127
190
|
|
|
128
|
-
export const deleteDatabase = async (name: string) =>
|
|
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
|
+
}
|
package/src/worker.web.ts
CHANGED
|
@@ -15,10 +15,31 @@ import wasmUrl from '@sqlite.org/sqlite-wasm/sqlite3.wasm?url'
|
|
|
15
15
|
type Sqlite3 = Awaited<ReturnType<typeof sqlite3InitModule>>
|
|
16
16
|
const sqlite3Promise = sqlite3InitModule({ locateFile: () => wasmUrl })
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
let
|
|
21
|
-
let
|
|
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
|
+
}
|
|
22
43
|
|
|
23
44
|
const normalizeParams = (params: any) =>
|
|
24
45
|
params == null ? undefined : Array.isArray(params) ? params : [params]
|
|
@@ -26,17 +47,10 @@ const normalizeParams = (params: any) =>
|
|
|
26
47
|
const open = async (name: string) => {
|
|
27
48
|
const sqlite3: Sqlite3 = await sqlite3Promise
|
|
28
49
|
const file = name.startsWith('/') ? name : `/${name}`
|
|
50
|
+
const sahPool = await getSahPool(sqlite3)
|
|
29
51
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
52
|
+
let db: any
|
|
53
|
+
let persistent = false
|
|
40
54
|
if (sahPool) {
|
|
41
55
|
db = new sqlite3.oo1.DB(file, 'c', sahPool.vfsName)
|
|
42
56
|
persistent = true
|
|
@@ -48,51 +62,65 @@ const open = async (name: string) => {
|
|
|
48
62
|
persistent = false
|
|
49
63
|
}
|
|
50
64
|
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
const databaseId = ++nextDatabaseId
|
|
66
|
+
databases.set(databaseId, { db, name: file, persistent })
|
|
67
|
+
return { databaseId, persistent, version: sqlite3.version.libVersion }
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
const deleteDb = async (name: string) => {
|
|
56
71
|
const file = name.startsWith('/') ? name : `/${name}`
|
|
72
|
+
const databaseIds: number[] = []
|
|
57
73
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}
|
|
61
80
|
}
|
|
62
81
|
|
|
82
|
+
const sahPool = sahPoolPromise ? await sahPoolPromise : null
|
|
63
83
|
if (sahPool) {
|
|
64
|
-
return Boolean(sahPool.unlink(file))
|
|
84
|
+
return { deleted: Boolean(sahPool.unlink(file)), databaseIds }
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
// OpfsDb path: best-effort removal of the OPFS entry.
|
|
68
88
|
try {
|
|
69
89
|
const root = await navigator.storage.getDirectory()
|
|
70
90
|
await root.removeEntry(file.slice(1))
|
|
71
|
-
return true
|
|
91
|
+
return { deleted: true, databaseIds }
|
|
72
92
|
} catch {
|
|
73
|
-
return false
|
|
93
|
+
return { deleted: false, databaseIds }
|
|
74
94
|
}
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
const handlers: Record<string, (...args: any[]) => unknown> = {
|
|
78
98
|
open,
|
|
79
99
|
delete: deleteDb,
|
|
80
|
-
execute: (sql: string, params?: any) =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
93
120
|
}
|
|
94
121
|
|
|
95
|
-
db
|
|
122
|
+
entry.db.close()
|
|
123
|
+
databases.delete(id)
|
|
96
124
|
},
|
|
97
125
|
}
|
|
98
126
|
|
|
@@ -106,10 +134,6 @@ self.onmessage = async (event: MessageEvent<{ id: number; op: string; args: any[
|
|
|
106
134
|
throw new Error(`unknown sqlite op: ${op}`)
|
|
107
135
|
}
|
|
108
136
|
|
|
109
|
-
if (!db && op !== 'open' && op !== 'delete') {
|
|
110
|
-
throw new Error('sqlite db is not open')
|
|
111
|
-
}
|
|
112
|
-
|
|
113
137
|
const result = await handler(...args)
|
|
114
138
|
|
|
115
139
|
;(self as any).postMessage({ id, result })
|