orez 0.1.7 → 0.1.9

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.
Files changed (41) hide show
  1. package/README.md +2 -1
  2. package/dist/cli.js +7 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +1 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +1 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +54 -6
  10. package/dist/index.js.map +1 -1
  11. package/dist/integration/test-permissions.d.ts +2 -0
  12. package/dist/integration/test-permissions.d.ts.map +1 -1
  13. package/dist/integration/test-permissions.js +28 -0
  14. package/dist/integration/test-permissions.js.map +1 -1
  15. package/dist/pg-proxy.d.ts.map +1 -1
  16. package/dist/pg-proxy.js +76 -1
  17. package/dist/pg-proxy.js.map +1 -1
  18. package/dist/sqlite-mode/native-binary.js +1 -1
  19. package/dist/sqlite-mode/native-binary.js.map +1 -1
  20. package/dist/sqlite-mode/package-resolve.d.ts +6 -0
  21. package/dist/sqlite-mode/package-resolve.d.ts.map +1 -0
  22. package/dist/sqlite-mode/package-resolve.js +20 -0
  23. package/dist/sqlite-mode/package-resolve.js.map +1 -0
  24. package/dist/sqlite-mode/resolve-mode.d.ts +12 -7
  25. package/dist/sqlite-mode/resolve-mode.d.ts.map +1 -1
  26. package/dist/sqlite-mode/resolve-mode.js +27 -23
  27. package/dist/sqlite-mode/resolve-mode.js.map +1 -1
  28. package/package.json +2 -2
  29. package/src/cli.ts +7 -1
  30. package/src/config.ts +2 -0
  31. package/src/index.ts +69 -6
  32. package/src/integration/integration.test.ts +49 -42
  33. package/src/integration/native-startup.test.ts +1 -1
  34. package/src/integration/restore-live-stress.test.ts +61 -65
  35. package/src/integration/restore-reset.test.ts +63 -66
  36. package/src/integration/test-permissions.ts +36 -0
  37. package/src/pg-proxy.ts +84 -1
  38. package/src/sqlite-mode/native-binary.ts +1 -1
  39. package/src/sqlite-mode/package-resolve.ts +17 -0
  40. package/src/sqlite-mode/resolve-mode.ts +31 -21
  41. package/src/sqlite-mode/sqlite-mode.test.ts +11 -5
@@ -18,11 +18,26 @@ import WebSocket from 'ws'
18
18
  import { execDumpFile } from '../cli.js'
19
19
  import { startZeroLite } from '../index.js'
20
20
  import { installChangeTracking } from '../replication/change-tracker.js'
21
- import { installAllowAllPermissions } from './test-permissions.js'
21
+ import {
22
+ ensureTablesInPublications,
23
+ hasNonNullPermissions,
24
+ installAllowAllPermissions,
25
+ } from './test-permissions.js'
22
26
 
23
27
  import type { PGlite } from '@electric-sql/pglite'
24
28
 
25
29
  const SYNC_PROTOCOL_VERSION = 45
30
+ const LIVE_CLIENT_SCHEMA = {
31
+ tables: {
32
+ restore_live_probe: {
33
+ columns: {
34
+ id: { type: 'string' },
35
+ value: { type: 'string' },
36
+ },
37
+ primaryKey: ['id'],
38
+ },
39
+ },
40
+ }
26
41
 
27
42
  function encodeSecProtocols(
28
43
  initConnectionMessage: unknown,
@@ -128,78 +143,52 @@ function connectAndSubscribe(
128
143
  query: Record<string, unknown>
129
144
  ): Promise<WebSocket> {
130
145
  return new Promise((resolve, reject) => {
131
- const ts = Date.now()
132
- const clientGroupID = `restore-live-cg-${ts}`
133
- const urlBase =
146
+ const initConnectionMessage: [string, Record<string, unknown>] = [
147
+ 'initConnection',
148
+ {
149
+ desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
150
+ clientSchema: LIVE_CLIENT_SCHEMA,
151
+ },
152
+ ]
153
+ const secProtocol = encodeSecProtocols(initConnectionMessage, undefined)
154
+ const ws = new WebSocket(
134
155
  `ws://127.0.0.1:${port}/sync/v${SYNC_PROTOCOL_VERSION}/connect` +
135
- `?clientGroupID=${clientGroupID}` +
136
- `&clientID=restore-live-client` +
137
- `&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`
138
-
139
- const bootstrapProtocol = encodeSecProtocols(
140
- ['initConnection', { desiredQueriesPatch: [] }],
141
- undefined
156
+ `?clientGroupID=restore-live-cg-${Date.now()}` +
157
+ `&clientID=restore-live-client` +
158
+ `&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
159
+ secProtocol
142
160
  )
143
- const bootstrapWs = new WebSocket(`${urlBase}&wsid=bootstrap`, bootstrapProtocol)
144
161
 
145
- const fail = (err: unknown) => {
146
- clearTimeout(bootstrapTimer)
162
+ let settled = false
163
+ const failTimer = setTimeout(() => {
164
+ if (settled) return
165
+ settled = true
147
166
  try {
148
- bootstrapWs.close()
167
+ ws.close()
149
168
  } catch {}
150
- reject(err)
151
- }
152
-
153
- const bootstrapTimer = setTimeout(() => {
154
- fail(new Error('bootstrap websocket timeout'))
169
+ reject(new Error('websocket connected but no downstream messages'))
155
170
  }, 7000)
156
- bootstrapWs.once('error', fail)
157
- bootstrapWs.once('message', () => {
158
- clearTimeout(bootstrapTimer)
159
- try {
160
- bootstrapWs.close()
161
- } catch {}
162
171
 
163
- const initConnectionMessage: [string, Record<string, unknown>] = [
164
- 'initConnection',
165
- {
166
- desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
167
- },
168
- ]
169
- const secProtocol = encodeSecProtocols(initConnectionMessage, undefined)
170
- const ws = new WebSocket(`${urlBase}&wsid=ws1`, secProtocol)
171
-
172
- let settled = false
173
- const failTimer = setTimeout(() => {
174
- if (settled) return
175
- settled = true
176
- try {
177
- ws.close()
178
- } catch {}
179
- reject(new Error('websocket connected but no downstream messages'))
180
- }, 7000)
181
-
182
- ws.on('message', (data) => {
183
- const msg = JSON.parse(data.toString())
184
- downstream.enqueue(msg)
185
- if (!settled) {
186
- settled = true
187
- clearTimeout(failTimer)
188
- resolve(ws)
189
- }
190
- })
191
- ws.once('error', (err) => {
192
- if (settled) return
172
+ ws.on('message', (data) => {
173
+ const msg = JSON.parse(data.toString())
174
+ downstream.enqueue(msg)
175
+ if (!settled) {
193
176
  settled = true
194
177
  clearTimeout(failTimer)
195
- reject(err)
196
- })
197
- ws.once('close', () => {
198
- if (settled) return
199
- settled = true
200
- clearTimeout(failTimer)
201
- reject(new Error('websocket closed before initial downstream message'))
202
- })
178
+ resolve(ws)
179
+ }
180
+ })
181
+ ws.once('error', (err) => {
182
+ if (settled) return
183
+ settled = true
184
+ clearTimeout(failTimer)
185
+ reject(err)
186
+ })
187
+ ws.once('close', () => {
188
+ if (settled) return
189
+ settled = true
190
+ clearTimeout(failTimer)
191
+ reject(new Error('websocket closed before initial downstream message'))
203
192
  })
204
193
  })
205
194
  }
@@ -299,6 +288,7 @@ describe('live restore stress with connected frontend', { timeout: 360_000 }, ()
299
288
  let zeroPort: number
300
289
  let shutdown: () => Promise<void>
301
290
  let restartZero: (() => Promise<void>) | undefined
291
+ let resetZeroFull: (() => Promise<void>) | undefined
302
292
  let dataDir: string
303
293
  let dumpFile: string
304
294
 
@@ -330,6 +320,7 @@ describe('live restore stress with connected frontend', { timeout: 360_000 }, ()
330
320
  zeroPort = started.zeroPort
331
321
  shutdown = started.stop
332
322
  restartZero = started.restartZero
323
+ resetZeroFull = started.resetZeroFull
333
324
  await waitForZero(zeroPort, 90_000)
334
325
  }, 180_000)
335
326
 
@@ -352,8 +343,13 @@ describe('live restore stress with connected frontend', { timeout: 360_000 }, ()
352
343
  value TEXT NOT NULL
353
344
  )
354
345
  `)
346
+ await ensureTablesInPublications(db, ['restore_live_probe'])
355
347
  await installAllowAllPermissions(db, ['restore_live_probe'])
356
- if (restartZero) {
348
+ expect(await hasNonNullPermissions(db)).toBe(true)
349
+ if (resetZeroFull) {
350
+ await resetZeroFull()
351
+ await waitForZero(zeroPort, 90_000)
352
+ } else if (restartZero) {
357
353
  await restartZero()
358
354
  await waitForZero(zeroPort, 60_000)
359
355
  }
@@ -18,10 +18,25 @@ import WebSocket from 'ws'
18
18
 
19
19
  import { execDumpFile } from '../cli.js'
20
20
  import { startZeroLite } from '../index.js'
21
- import { installAllowAllPermissions } from './test-permissions.js'
21
+ import {
22
+ ensureTablesInPublications,
23
+ hasNonNullPermissions,
24
+ installAllowAllPermissions,
25
+ } from './test-permissions.js'
22
26
 
23
27
  // zero-cache protocol version (from @rocicorp/zero/out/zero-protocol/src/protocol-version.js)
24
28
  const PROTOCOL_VERSION = 45
29
+ const RESET_CLIENT_SCHEMA = {
30
+ tables: {
31
+ reset_probe: {
32
+ columns: {
33
+ id: { type: 'string' },
34
+ value: { type: 'string' },
35
+ },
36
+ primaryKey: ['id'],
37
+ },
38
+ },
39
+ }
25
40
 
26
41
  // encode initConnection message for sec-websocket-protocol header
27
42
  // matches zero-protocol's encodeSecProtocols implementation
@@ -117,6 +132,7 @@ describe('restore/reset integration regression', { timeout: 150_000 }, () => {
117
132
  let zeroPort: number
118
133
  let shutdown: () => Promise<void>
119
134
  let restartZero: (() => Promise<void>) | undefined
135
+ let resetZeroFull: (() => Promise<void>) | undefined
120
136
  let dataDir: string
121
137
  let dumpFile: string
122
138
  let dumpFileIsTemp = false
@@ -143,6 +159,7 @@ describe('restore/reset integration regression', { timeout: 150_000 }, () => {
143
159
  zeroPort = started.zeroPort
144
160
  shutdown = started.stop
145
161
  restartZero = started.restartZero
162
+ resetZeroFull = started.resetZeroFull
146
163
 
147
164
  await waitForZero(zeroPort, 90_000)
148
165
  }, 120_000)
@@ -205,6 +222,7 @@ describe('restore/reset integration regression', { timeout: 150_000 }, () => {
205
222
  AFTER INSERT OR UPDATE OR DELETE ON public.reset_probe
206
223
  FOR EACH STATEMENT EXECUTE FUNCTION public._zero_notify_change();
207
224
  `)
225
+ await ensureTablesInPublications(db, ['reset_probe'])
208
226
  const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
209
227
  if (pubName) {
210
228
  const quotedPub = '"' + pubName.replace(/"/g, '""') + '"'
@@ -213,7 +231,11 @@ describe('restore/reset integration regression', { timeout: 150_000 }, () => {
213
231
  .catch(() => {})
214
232
  }
215
233
  await installAllowAllPermissions(db, ['reset_probe'])
216
- if (restartZero) {
234
+ expect(await hasNonNullPermissions(db)).toBe(true)
235
+ if (resetZeroFull) {
236
+ await resetZeroFull()
237
+ await waitForZero(zeroPort, 90_000)
238
+ } else if (restartZero) {
217
239
  await restartZero()
218
240
  await waitForZero(zeroPort, 60_000)
219
241
  }
@@ -259,79 +281,54 @@ function connectAndSubscribe(
259
281
  const ts = Date.now()
260
282
  const clientGroupID = `restore-reset-cg-${ts}`
261
283
  const clientID = 'restore-reset-client'
262
- const urlBase =
284
+ const initConnectionMessage: [string, Record<string, unknown>] = [
285
+ 'initConnection',
286
+ {
287
+ desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
288
+ clientSchema: RESET_CLIENT_SCHEMA,
289
+ },
290
+ ]
291
+ const secProtocol = encodeSecProtocols(initConnectionMessage, undefined)
292
+ const ws = new WebSocket(
263
293
  `ws://127.0.0.1:${port}/sync/v${PROTOCOL_VERSION}/connect` +
264
- `?clientGroupID=${clientGroupID}&clientID=${clientID}&schemaVersion=1&baseCookie=&ts=${ts}&lmid=0`
265
-
266
- // bootstrap the client group first so the query connection is not "new group"
267
- const bootstrapProtocol = encodeSecProtocols(
268
- ['initConnection', { desiredQueriesPatch: [] }],
269
- undefined
294
+ `?clientGroupID=${clientGroupID}&clientID=${clientID}&wsid=ws1&schemaVersion=1&baseCookie=&ts=${ts}&lmid=0`,
295
+ secProtocol
270
296
  )
271
- const bootstrapWs = new WebSocket(`${urlBase}&wsid=bootstrap`, bootstrapProtocol)
272
- const bootstrapTimer = setTimeout(() => {
273
- fail(new Error('bootstrap websocket timeout'))
274
- }, 7000)
275
-
276
- const fail = (err: unknown) => {
277
- clearTimeout(bootstrapTimer)
278
- try {
279
- bootstrapWs.close()
280
- } catch {}
281
- reject(err)
282
- }
283
297
 
284
- bootstrapWs.once('error', fail)
285
- bootstrapWs.once('message', () => {
286
- clearTimeout(bootstrapTimer)
298
+ let settled = false
299
+ let sawMessage = false
300
+ const failTimer = setTimeout(() => {
301
+ if (settled) return
302
+ settled = true
287
303
  try {
288
- bootstrapWs.close()
304
+ ws.close()
289
305
  } catch {}
306
+ reject(new Error('websocket connected but no downstream messages'))
307
+ }, 7000)
290
308
 
291
- const initConnectionMessage: [string, Record<string, unknown>] = [
292
- 'initConnection',
293
- {
294
- desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
295
- },
296
- ]
297
- const secProtocol = encodeSecProtocols(initConnectionMessage, undefined)
298
- const ws = new WebSocket(`${urlBase}&wsid=ws1`, secProtocol)
299
-
300
- let settled = false
301
- let sawMessage = false
302
- const failTimer = setTimeout(() => {
303
- if (settled) return
304
- settled = true
305
- try {
306
- ws.close()
307
- } catch {}
308
- reject(new Error('websocket connected but no downstream messages'))
309
- }, 7000)
310
-
311
- ws.on('message', (data) => {
312
- const msg = JSON.parse(data.toString())
313
- downstream.enqueue(msg)
314
- if (!sawMessage && !settled) {
315
- sawMessage = true
316
- settled = true
317
- clearTimeout(failTimer)
318
- resolve(ws)
319
- }
320
- })
321
-
322
- ws.once('error', (err) => {
323
- if (settled) return
309
+ ws.on('message', (data) => {
310
+ const msg = JSON.parse(data.toString())
311
+ downstream.enqueue(msg)
312
+ if (!sawMessage && !settled) {
313
+ sawMessage = true
324
314
  settled = true
325
315
  clearTimeout(failTimer)
326
- reject(err)
327
- })
316
+ resolve(ws)
317
+ }
318
+ })
328
319
 
329
- ws.once('close', () => {
330
- if (settled) return
331
- settled = true
332
- clearTimeout(failTimer)
333
- reject(new Error('websocket closed before initial downstream message'))
334
- })
320
+ ws.once('error', (err) => {
321
+ if (settled) return
322
+ settled = true
323
+ clearTimeout(failTimer)
324
+ reject(err)
325
+ })
326
+
327
+ ws.once('close', () => {
328
+ if (settled) return
329
+ settled = true
330
+ clearTimeout(failTimer)
331
+ reject(new Error('websocket closed before initial downstream message'))
335
332
  })
336
333
  })
337
334
  }
@@ -84,6 +84,42 @@ export async function installAllowAllPermissions(
84
84
  }
85
85
  }
86
86
 
87
+ export async function hasNonNullPermissions(db: DbLike): Promise<boolean> {
88
+ const schemas = await findPermissionsSchemas(db)
89
+ for (const schema of schemas) {
90
+ const quotedSchema = '"' + schema.replace(/"/g, '""') + '"'
91
+ const result = await db.query<{ has_permissions: boolean }>(
92
+ `SELECT (permissions IS NOT NULL) AS has_permissions
93
+ FROM ${quotedSchema}.permissions
94
+ WHERE lock = true
95
+ LIMIT 1`
96
+ )
97
+ if (result.rows[0]?.has_permissions) return true
98
+ }
99
+ return false
100
+ }
101
+
102
+ export async function ensureTablesInPublications(
103
+ db: DbLike,
104
+ tables: string[]
105
+ ): Promise<void> {
106
+ const pubs = await db.query<{ pubname: string }>(
107
+ `SELECT pubname
108
+ FROM pg_publication
109
+ WHERE pubname NOT LIKE '%metadata%'
110
+ ORDER BY pubname`
111
+ )
112
+ for (const { pubname } of pubs.rows) {
113
+ const quotedPub = '"' + pubname.replace(/"/g, '""') + '"'
114
+ for (const table of tables) {
115
+ const quotedTable = '"' + table.replace(/"/g, '""') + '"'
116
+ await db
117
+ .exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE "public".${quotedTable}`)
118
+ .catch(() => {})
119
+ }
120
+ }
121
+ }
122
+
87
123
  function parsePermissions(value: unknown): { tables?: Record<string, unknown> } {
88
124
  if (!value) return {}
89
125
  if (typeof value === 'string') {
package/src/pg-proxy.ts CHANGED
@@ -261,6 +261,43 @@ function readInt32BE(data: Uint8Array, offset: number): number {
261
261
  )
262
262
  }
263
263
 
264
+ // pglite transaction state warnings to suppress (benign, but noisy)
265
+ // 25001: "there is already a transaction in progress"
266
+ // 25P01: "there is no transaction in progress"
267
+ const SUPPRESS_NOTICE_CODES = new Set(['25001', '25P01'])
268
+
269
+ /**
270
+ * extract SQLSTATE code from a NoticeResponse message.
271
+ * returns null if not a NoticeResponse or code not found.
272
+ */
273
+ function extractNoticeCode(
274
+ data: Uint8Array,
275
+ offset: number,
276
+ totalLen: number
277
+ ): string | null {
278
+ if (data[offset] !== 0x4e) return null // not a NoticeResponse
279
+
280
+ let pos = offset + 5 // skip type byte + length
281
+ const end = offset + totalLen
282
+
283
+ while (pos < end) {
284
+ const fieldType = data[pos++]
285
+ if (fieldType === 0) break // terminator
286
+
287
+ // find null-terminated string
288
+ const strStart = pos
289
+ while (pos < end && data[pos] !== 0) pos++
290
+ if (pos >= end) break
291
+
292
+ if (fieldType === 0x43) {
293
+ // 'C' = SQLSTATE code
294
+ return new TextDecoder().decode(data.subarray(strStart, pos))
295
+ }
296
+ pos++ // skip null terminator
297
+ }
298
+ return null
299
+ }
300
+
264
301
  /**
265
302
  * strip ReadyForQuery messages from a response buffer.
266
303
  */
@@ -297,6 +334,48 @@ function stripReadyForQuery(data: Uint8Array): Uint8Array {
297
334
  return result
298
335
  }
299
336
 
337
+ /**
338
+ * strip NoticeResponse messages with specific SQLSTATE codes from a response buffer.
339
+ * pglite emits benign transaction state warnings that we suppress to reduce noise.
340
+ */
341
+ function stripTransactionWarnings(data: Uint8Array): Uint8Array {
342
+ if (data.length === 0) return data
343
+
344
+ const parts: Uint8Array[] = []
345
+ let offset = 0
346
+ let stripped = false
347
+
348
+ while (offset < data.length) {
349
+ if (offset + 5 > data.length) break
350
+ const msgLen = readInt32BE(data, offset + 1)
351
+ const totalLen = 1 + msgLen
352
+
353
+ if (totalLen <= 0 || offset + totalLen > data.length) break
354
+
355
+ const code = extractNoticeCode(data, offset, totalLen)
356
+ if (code && SUPPRESS_NOTICE_CODES.has(code)) {
357
+ stripped = true
358
+ } else {
359
+ parts.push(data.subarray(offset, offset + totalLen))
360
+ }
361
+
362
+ offset += totalLen
363
+ }
364
+
365
+ if (!stripped) return data
366
+ if (parts.length === 0) return new Uint8Array(0)
367
+ if (parts.length === 1) return parts[0]
368
+
369
+ const total = parts.reduce((sum, p) => sum + p.length, 0)
370
+ const result = new Uint8Array(total)
371
+ let pos = 0
372
+ for (const p of parts) {
373
+ result.set(p, pos)
374
+ pos += p.length
375
+ }
376
+ return result
377
+ }
378
+
300
379
  export async function startPgProxy(
301
380
  dbInput: PGlite | PGliteInstances,
302
381
  config: ZeroLiteConfig
@@ -436,6 +515,9 @@ export async function startPgProxy(
436
515
  throw err
437
516
  }
438
517
 
518
+ // strip benign transaction state warnings from pglite
519
+ result = stripTransactionWarnings(result)
520
+
439
521
  // strip ReadyForQuery from non-Sync/non-SimpleQuery responses
440
522
  if (data[0] !== 0x53 && data[0] !== 0x51) {
441
523
  result = stripReadyForQuery(result)
@@ -510,9 +592,10 @@ async function handleReplicationMessage(
510
592
  data = interceptQuery(data)
511
593
 
512
594
  // fall through to pglite for unrecognized queries
513
- return await db.execProtocolRaw(data, {
595
+ const result = await db.execProtocolRaw(data, {
514
596
  throwOnError: false,
515
597
  })
598
+ return stripTransactionWarnings(result)
516
599
  } finally {
517
600
  mutex.release()
518
601
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, resolve } from 'node:path'
3
3
 
4
- import { resolvePackage } from './resolve-mode.js'
4
+ import { resolvePackage } from './package-resolve.js'
5
5
 
6
6
  const NATIVE_BINARY_RELATIVE_PATHS = ['build/Release/better_sqlite3.node']
7
7
 
@@ -0,0 +1,17 @@
1
+ import { createRequire } from 'node:module'
2
+
3
+ /**
4
+ * resolve a package entry path
5
+ * import.meta.resolve doesn't work in vitest, so we fall back to require.resolve
6
+ */
7
+ export function resolvePackage(pkg: string): string {
8
+ try {
9
+ const resolved = import.meta.resolve(pkg)
10
+ if (resolved) return resolved.replace('file://', '')
11
+ } catch {}
12
+ try {
13
+ const require = createRequire(import.meta.url)
14
+ return require.resolve(pkg)
15
+ } catch {}
16
+ return ''
17
+ }
@@ -1,33 +1,42 @@
1
1
  /**
2
2
  * mode resolution - canonical place to determine sqlite mode from config/env
3
+ *
4
+ * priority:
5
+ * 1. explicit --disable-wasm-sqlite flag → native
6
+ * 2. explicit --force-wasm-sqlite flag → wasm
7
+ * 3. native binary available → native (auto-detect)
8
+ * 4. fallback → wasm
3
9
  */
4
10
 
5
- import { createRequire } from 'node:module'
11
+ import { inspectNativeSqliteBinary } from './native-binary.js'
12
+ import { resolvePackage } from './package-resolve.js'
6
13
 
7
14
  import type { SqliteMode, SqliteModeConfig } from './types.js'
8
-
9
- /**
10
- * resolve a package entry path
11
- * import.meta.resolve doesn't work in vitest, so we fall back to require.resolve
12
- */
13
- export function resolvePackage(pkg: string): string {
14
- try {
15
- const resolved = import.meta.resolve(pkg)
16
- if (resolved) return resolved.replace('file://', '')
17
- } catch {}
18
- try {
19
- const require = createRequire(import.meta.url)
20
- return require.resolve(pkg)
21
- } catch {}
22
- return ''
23
- }
15
+ export { resolvePackage } from './package-resolve.js'
24
16
 
25
17
  /**
26
18
  * resolve sqlite mode from config
27
19
  * single source of truth for mode selection
20
+ *
21
+ * @param disableWasmSqlite - explicit flag to force native mode
22
+ * @param forceWasmSqlite - explicit flag to force wasm mode (overrides auto-detect)
28
23
  */
29
- export function resolveSqliteMode(disableWasmSqlite: boolean): SqliteMode {
30
- return disableWasmSqlite ? 'native' : 'wasm'
24
+ export function resolveSqliteMode(
25
+ disableWasmSqlite: boolean,
26
+ forceWasmSqlite: boolean = false
27
+ ): SqliteMode {
28
+ // explicit native request
29
+ if (disableWasmSqlite) return 'native'
30
+
31
+ // explicit wasm request
32
+ if (forceWasmSqlite) return 'wasm'
33
+
34
+ // auto-detect: prefer native if binary is available
35
+ const nativeCheck = inspectNativeSqliteBinary()
36
+ if (nativeCheck.found) return 'native'
37
+
38
+ // fallback to wasm
39
+ return 'wasm'
31
40
  }
32
41
 
33
42
  /**
@@ -35,9 +44,10 @@ export function resolveSqliteMode(disableWasmSqlite: boolean): SqliteMode {
35
44
  * returns null if required packages aren't installed
36
45
  */
37
46
  export function resolveSqliteModeConfig(
38
- disableWasmSqlite: boolean
47
+ disableWasmSqlite: boolean,
48
+ forceWasmSqlite: boolean = false
39
49
  ): SqliteModeConfig | null {
40
- const mode = resolveSqliteMode(disableWasmSqlite)
50
+ const mode = resolveSqliteMode(disableWasmSqlite, forceWasmSqlite)
41
51
  const zeroSqlitePath = resolvePackage('@rocicorp/zero-sqlite3') || undefined
42
52
 
43
53
  // native mode may still need zero-sqlite3 path for restoring from a prior shim
@@ -28,16 +28,22 @@ describe('sqlite mode types', () => {
28
28
  })
29
29
 
30
30
  describe('sqlite mode resolution', () => {
31
- it('resolves wasm mode when disableWasmSqlite is false', () => {
32
- expect(resolveSqliteMode(false)).toBe('wasm')
31
+ it('resolves native mode when disableWasmSqlite is true', () => {
32
+ expect(resolveSqliteMode(true, false)).toBe('native')
33
33
  })
34
34
 
35
- it('resolves native mode when disableWasmSqlite is true', () => {
36
- expect(resolveSqliteMode(true)).toBe('native')
35
+ it('resolves wasm mode when forceWasmSqlite is true', () => {
36
+ expect(resolveSqliteMode(false, true)).toBe('wasm')
37
+ })
38
+
39
+ it('auto-detects mode based on native binary availability', () => {
40
+ // when neither flag is set, mode depends on whether native binary exists
41
+ const mode = resolveSqliteMode(false, false)
42
+ expect(['wasm', 'native']).toContain(mode)
37
43
  })
38
44
 
39
45
  it('returns config with mode for native', () => {
40
- const config = resolveSqliteModeConfig(true)
46
+ const config = resolveSqliteModeConfig(true, false)
41
47
  expect(config).not.toBeNull()
42
48
  expect(config?.mode).toBe('native')
43
49
  })