@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 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
- Design notes: [`docs/sqlite-notes.md`](../../docs/sqlite-notes.md);
32
- limits: [known limits](../../docs/known-limits.md). Exercised by the
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octane-xplat/sqlite",
3
- "version": "0.9.0",
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",
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
- worker = new Worker(new URL('./worker.web.ts', import.meta.url), { type: 'module' })
16
- worker.onmessage = (event: MessageEvent<{ id: number; result?: unknown; error?: string }>) => {
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
- ensureWorker().postMessage({ id, op, args })
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
- isOpen = true
45
- constructor(readonly persistent: boolean) {}
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.isOpen = false
116
- await call('close').catch(() => {})
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 result = await call<{ persistent: boolean }>('open', name)
125
- return new WebSqliteDb(result.persistent)
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) => call<boolean>('delete', name)
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
- let db: any = null
19
- let dbName = ''
20
- let sahPool: any = null
21
- let persistent = false
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
- 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
-
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
- dbName = file
52
- return { persistent, version: sqlite3.version.libVersion }
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
- if (db && dbName === file) {
59
- db.close()
60
- db = null
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) => 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()
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 = null
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 })