@syncular/client 0.15.46 → 0.15.47

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
@@ -4,8 +4,9 @@ The TypeScript client protocol core (SPEC.md §§3–8, client side) plus its
4
4
  browser platform bindings.
5
5
 
6
6
  The normal `SyncClient` also runs in a CLI or background service.
7
- Use `openBunDatabase(path)` or `openNodeDatabase(path)` for a persistent local
8
- replica. See the [server-side sync client guide](https://syncular.dev/guide-server-clients/).
7
+ Use `openSqliteDatabase(path)` from `@syncular/client/sqlite` for a persistent
8
+ local replica on Node or Bun. See the
9
+ [server-side sync client guide](https://syncular.dev/guide-server-clients/).
9
10
 
10
11
  `SyncRemoteClient` is the database-less server client. It sends ordinary
11
12
  push-only commits through `/sync` and can call registered typed queries,
@@ -470,10 +471,10 @@ directory or IndexedDB store. This is the pinned decision (SPEC §5.9.7 B1):
470
471
  that pin them — a refcount adjust and a body insert/delete commit atomically,
471
472
  so a crash never strands a body against a stale count.
472
473
  - **Survives restarts for free.** The client DB already rides OPFS via the
473
- sahpool VFS in the browser (and a plain file under `rusqlite`/better-sqlite3
474
- on native/Node), so there is no second persistence surface and no second
475
- eviction policy to keep coherent. Close the app, reopen it: `fetchBlob` serves
476
- the cached body with no network.
474
+ sahpool VFS in the browser (and a plain file under `rusqlite`, `bun:sqlite`,
475
+ or `node:sqlite` on native runtimes), so there is no second persistence
476
+ surface and no second eviction policy to keep coherent. Close the app, reopen
477
+ it: `fetchBlob` serves the cached body with no network.
477
478
  - **SQLite handles multi-MB images fine.** A page-cached `BLOB` read is a memory
478
479
  copy, well within the image/document envelope this targets.
479
480
 
@@ -507,57 +508,38 @@ straight to a media element instead of pulling bytes through the cache — the
507
508
  image-app default (refcounted `BLOB` cache) and the large-media path (presigned
508
509
  URL, no byte cache) coexist per attachment.
509
510
 
510
- ## Node / Electron-main backend (`./node`)
511
+ ## Node and Bun SQLite backend (`./sqlite`)
511
512
 
512
- Hosts that run outside a browser an **Electron main process**, a plain
513
- **Node** service, a CLI — get a native SQLite backend through
514
- `openNodeDatabase`, a `ClientDatabase` over
515
- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3):
513
+ CLIs, background workers, Electron main processes, and services can use one
514
+ runtime-selected import:
516
515
 
517
516
  ```ts
518
- import { openNodeDatabase } from '@syncular/client/node';
517
+ import { openSqliteDatabase } from '@syncular/client/sqlite';
519
518
  import { SyncClient } from '@syncular/client';
520
519
 
521
- const database = openNodeDatabase('app.db'); // or ':memory:' (default)
520
+ const database = openSqliteDatabase('app.db'); // or ':memory:' (default)
522
521
  const client = new SyncClient({ database, schema, /* … */ });
523
522
  ```
524
523
 
525
- It mirrors the bun:sqlite adapter exactly: synchronous `exec` / `query` /
526
- `transaction` (nested calls are savepoints an inner failure rolls back only
527
- the inner scope), the same boolean→0/1 bind coercion, `null` round-trips, and
528
- BLOB columns handed back as plain `Uint8Array`s. The §5.3 `withSqliteImage`
529
- attach path is supported too, so a Node host can accept sqlite-image segments.
524
+ The export selects `bun:sqlite` on Bun and the built-in `node:sqlite` module on
525
+ Node 22.13 or newer. No SQLite package or native addon is required. Both
526
+ adapters support synchronous `exec`, `query`, nested transactions, boolean
527
+ bindings, `null`, `Uint8Array` BLOB values, and §5.3 SQLite-image attachment.
530
528
 
531
- **better-sqlite3 is an OPTIONAL peer dependency, not a hard one.** The package
532
- installs cleanly without it (browser-only apps never pay for a native build);
533
- `openNodeDatabase()` loads it lazily on first call and throws a clear,
534
- actionable error if the peer is missing. Add it in your app:
529
+ Runtime-specific imports remain available:
535
530
 
536
- ```sh
537
- npm install better-sqlite3 # or: bun add better-sqlite3
531
+ ```ts
532
+ import { openBunDatabase } from '@syncular/client/bun';
533
+ import { openNodeDatabase } from '@syncular/client/node';
538
534
  ```
539
535
 
540
- **Verifying the Node adapter and why not under bun.** bun **cannot** dlopen
541
- better-sqlite3 (`ERR_DLOPEN_FAILED`,
542
- [oven-sh/bun#4290](https://github.com/oven-sh/bun/issues/4290)); calling
543
- `openNodeDatabase()` under bun deliberately raises the same helpful error and
544
- points you at `./bun` instead. So the bun test suite
545
- (`test/node-database.test.ts`) proves what it can under bun — type/subpath
546
- conformance, the missing-peer error, and that the shared behavioral contract
547
- (`test/node-database/adapter-contract.ts`) passes on the reference bun:sqlite
548
- backend — while the better-sqlite3 adapter's real behavior is proven under
549
- **Node** against the actual native module by running that same contract:
536
+ The source and packed-package runtime contracts run under actual Node and Bun:
550
537
 
551
538
  ```sh
552
- cd packages/web-client
553
- bun run verify:node
539
+ bun run verify:runtimes
540
+ bun run build:packages && bun run verify:packages
554
541
  ```
555
542
 
556
- That bundles the verifier with bun (transpile + resolve only — bun never
557
- executes the native module) and runs the plain-JS bundle under Node, which
558
- exercises `openNodeDatabase` against real better-sqlite3 and exits non-zero on
559
- any divergence from the contract.
560
-
561
543
  ## RPC protocol (6 message types)
562
544
 
563
545
  `init`, `call`, `ready`, `result`, `error`, `event` — every API method
@@ -573,8 +555,9 @@ they own their buffer.
573
555
  | `.` | protocol core, transports, handle + RPC protocol (browser-safe, no SQLite) |
574
556
  | `./worker` | `startSyncWorker` — worker-side bootstrap (pulls sqlite-wasm) |
575
557
  | `./wasm` | sqlite-wasm bindings: `openPersistentWasmDatabase`, `openWasmDatabase` |
576
- | `./bun` | bun:sqlite binding for tests |
577
- | `./node` | better-sqlite3 binding: `openNodeDatabase` (Electron-main / plain Node) |
558
+ | `./sqlite` | Runtime-selected SQLite: `openSqliteDatabase` on Node or Bun |
559
+ | `./bun` | Explicit `bun:sqlite` binding: `openBunDatabase` |
560
+ | `./node` | Explicit built-in `node:sqlite` binding: `openNodeDatabase` |
578
561
 
579
562
  Tests drive the real worker entry in a bun `Worker` with bun:sqlite
580
563
  injected through the bootstrap's database-factory override
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * SPEC.md is normative.
4
4
  *
5
5
  * Browser-safe root: database backends live behind subpath exports
6
- * (`./bun` for bun:sqlite tests, `./wasm` for sqlite-wasm + OPFS); the
6
+ * (`./sqlite` for Node or Bun, `./wasm` for sqlite-wasm + OPFS); the
7
7
  * worker-side bootstrap lives behind `./worker`. The main-thread handle
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * SPEC.md is normative.
4
4
  *
5
5
  * Browser-safe root: database backends live behind subpath exports
6
- * (`./bun` for bun:sqlite tests, `./wasm` for sqlite-wasm + OPFS); the
6
+ * (`./sqlite` for Node or Bun, `./wasm` for sqlite-wasm + OPFS); the
7
7
  * worker-side bootstrap lives behind `./worker`. The main-thread handle
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
@@ -1,41 +1,18 @@
1
- import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
2
1
  /**
3
- * Structural view of the tiny better-sqlite3 surface this binding uses. We
4
- * type it locally (rather than importing `better-sqlite3`'s types) so the
5
- * package typechecks without the optional peer installed.
2
+ * `ClientDatabase` on Node's built-in `node:sqlite`. Semantics mirror the Bun
3
+ * adapter: synchronous queries, nested transactions, and SQLite image attach.
6
4
  */
7
- interface BetterSqliteStatement {
8
- run(...params: NodeParam[]): unknown;
9
- all(...params: NodeParam[]): unknown[];
10
- }
11
- interface BetterSqliteDatabase {
12
- readonly inTransaction: boolean;
13
- prepare(sql: string): BetterSqliteStatement;
14
- exec(sql: string): unknown;
15
- close(): void;
16
- }
17
- /**
18
- * better-sqlite3 accepts string / number / bigint / null / Buffer|Uint8Array
19
- * bind values, but NOT booleans (it throws "TypeError: can only bind …"). We
20
- * coerce booleans to 0/1 exactly like the bun adapter so callers see one
21
- * uniform bind contract across every backend.
22
- */
23
- type NodeParam = string | number | bigint | Uint8Array | null;
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
24
7
  export declare class NodeClientDatabase implements ClientDatabase {
25
8
  #private;
26
- readonly db: BetterSqliteDatabase;
9
+ readonly db: DatabaseSync;
27
10
  constructor(path?: string);
28
11
  exec(sql: string, params?: readonly SqlValue[]): void;
29
12
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
30
13
  transaction<T>(fn: () => T): T;
31
- /**
32
- * §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
33
- * buffers, so the image lands in a private temp file for the duration of
34
- * the ATTACH. Must be called outside any open transaction (SQLite cannot
35
- * ATTACH inside one).
36
- */
14
+ /** §5.3 image import through a private file attached for one callback. */
37
15
  withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T;
38
16
  close(): void;
39
17
  }
40
18
  export declare function openNodeDatabase(path?: string): ClientDatabase;
41
- export {};
@@ -1,23 +1,9 @@
1
1
  /**
2
- * `ClientDatabase` on better-sqlite3 the Electron-main / plain-Node
3
- * backend. Semantics mirror `./bun-database`
4
- * exactly (synchronous exec/query/transaction with the shared savepoint
5
- * helper, and the same §5.3 sqlite-image ATTACH path), so the core behaves
6
- * identically whether it runs on bun:sqlite (tests), sqlite-wasm (browser)
7
- * or better-sqlite3 (Node/Electron-main).
8
- *
9
- * better-sqlite3 is an OPTIONAL peer dependency, not a hard one: the package
10
- * installs cleanly without it and this module errors helpfully only when a
11
- * host actually calls `openNodeDatabase()` without having installed the peer.
12
- * Not exported from the package root, so browser/bun entries never resolve
13
- * the native module. Subpath export: `@syncular/client/node`.
14
- *
15
- * bun CANNOT dlopen better-sqlite3 (ERR_DLOPEN_FAILED, oven-sh/bun#4290), so
16
- * this adapter is verified under real Node — see the README "Electron-main /
17
- * plain-Node" section for the one-command recipe and `test/node-database`.
2
+ * `ClientDatabase` on Node's built-in `node:sqlite`. Semantics mirror the Bun
3
+ * adapter: synchronous queries, nested transactions, and SQLite image attach.
18
4
  */
5
+ import { DatabaseSync } from 'node:sqlite';
19
6
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
20
- import { createRequire } from 'node:module';
21
7
  import { tmpdir } from 'node:os';
22
8
  import { join } from 'node:path';
23
9
  import { assertImageAlias, runTransaction, } from './database.js';
@@ -28,19 +14,12 @@ function coerceParams(params) {
28
14
  return value;
29
15
  });
30
16
  }
31
- /**
32
- * better-sqlite3 returns BLOB columns as Node `Buffer`s. A Buffer IS a
33
- * Uint8Array subclass, but it can be a view onto a shared pool buffer, so we
34
- * normalize to a standalone Uint8Array — matching what bun:sqlite hands back
35
- * and keeping the buffer-ownership assumptions elsewhere (worker transfer,
36
- * structured clone) honest.
37
- */
38
17
  function normalizeRow(row) {
39
18
  const out = {};
40
19
  for (const key in row) {
41
20
  const value = row[key];
42
- if (Buffer.isBuffer(value)) {
43
- out[key] = new Uint8Array(value); // copies out of the pool
21
+ if (value instanceof Uint8Array) {
22
+ out[key] = new Uint8Array(value);
44
23
  }
45
24
  else {
46
25
  out[key] = value;
@@ -48,47 +27,11 @@ function normalizeRow(row) {
48
27
  }
49
28
  return out;
50
29
  }
51
- /**
52
- * Load the optional peer AND open the database in one guarded step, so BOTH
53
- * failure modes are turned into a clear, actionable error rather than a raw
54
- * one:
55
- *
56
- * - `require('better-sqlite3')` throwing MODULE_NOT_FOUND — the peer is not
57
- * installed (the common browser-only-host case), and
58
- * - `new Database()` throwing ERR_DLOPEN_FAILED — the module resolves but the
59
- * native addon cannot load, which is exactly what bun does for
60
- * better-sqlite3 (oven-sh/bun#4290); the addon only dlopens at construction.
61
- */
62
- function openBetterSqlite(path) {
63
- const require = createRequire(import.meta.url);
64
- try {
65
- const mod = require('better-sqlite3');
66
- const Database = mod.default ??
67
- mod;
68
- return new Database(path);
69
- }
70
- catch (error) {
71
- const code = error?.code;
72
- if (code === 'ERR_DLOPEN_FAILED') {
73
- throw new Error("openNodeDatabase() requires the 'better-sqlite3' native module, but " +
74
- 'it failed to load. This most commonly means you are running under ' +
75
- 'bun, which cannot dlopen better-sqlite3 (oven-sh/bun#4290) — use ' +
76
- "the bun:sqlite backend ('@syncular/client/bun') under bun, " +
77
- "and reserve '@syncular/client/node' for Node/Electron-main. " +
78
- `Underlying error: ${String(error)}`);
79
- }
80
- throw new Error('openNodeDatabase() requires the optional peer dependency ' +
81
- "'better-sqlite3', which is not installed. Add it to your app " +
82
- '(`npm install better-sqlite3` / `bun add better-sqlite3`) — it is ' +
83
- 'kept optional so @syncular/client installs without a native ' +
84
- `build for browser-only hosts. Underlying error: ${String(error)}`);
85
- }
86
- }
87
30
  export class NodeClientDatabase {
88
31
  db;
89
32
  #tx = { depth: 0 };
90
33
  constructor(path = ':memory:') {
91
- this.db = openBetterSqlite(path);
34
+ this.db = new DatabaseSync(path);
92
35
  }
93
36
  exec(sql, params = []) {
94
37
  this.db.prepare(sql).run(...coerceParams(params));
@@ -100,12 +43,7 @@ export class NodeClientDatabase {
100
43
  transaction(fn) {
101
44
  return runTransaction(this.#tx, (sql) => this.db.exec(sql), fn);
102
45
  }
103
- /**
104
- * §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
105
- * buffers, so the image lands in a private temp file for the duration of
106
- * the ATTACH. Must be called outside any open transaction (SQLite cannot
107
- * ATTACH inside one).
108
- */
46
+ /** §5.3 image import through a private file attached for one callback. */
109
47
  withSqliteImage(bytes, alias, fn) {
110
48
  assertImageAlias(alias);
111
49
  const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
@@ -10,8 +10,8 @@
10
10
  * bypasses the outbox (SPEC §7.1) and silently diverges from the
11
11
  * server — writes MUST go through `client.mutate([...])`.
12
12
  * 2. ONE STATEMENT. `sqlite-wasm`'s `exec` runs every statement in a
13
- * multi-statement string (`SELECT 1; DROP TABLE t`), while bun:sqlite /
14
- * better-sqlite3 prepare only the first. We unify on the strict
13
+ * multi-statement string (`SELECT 1; DROP TABLE t`), while the native
14
+ * SQLite adapters prepare only the first. We unify on the strict
15
15
  * behaviour: exactly one statement per `query()`.
16
16
  *
17
17
  * The guard only fronts the PUBLIC `client.query()` — engine-internal reads
@@ -10,8 +10,8 @@
10
10
  * bypasses the outbox (SPEC §7.1) and silently diverges from the
11
11
  * server — writes MUST go through `client.mutate([...])`.
12
12
  * 2. ONE STATEMENT. `sqlite-wasm`'s `exec` runs every statement in a
13
- * multi-statement string (`SELECT 1; DROP TABLE t`), while bun:sqlite /
14
- * better-sqlite3 prepare only the first. We unify on the strict
13
+ * multi-statement string (`SELECT 1; DROP TABLE t`), while the native
14
+ * SQLite adapters prepare only the first. We unify on the strict
15
15
  * behaviour: exactly one statement per `query()`.
16
16
  *
17
17
  * The guard only fronts the PUBLIC `client.query()` — engine-internal reads
@@ -0,0 +1,2 @@
1
+ import type { ClientDatabase } from './database.js';
2
+ export declare function openSqliteDatabase(path?: string): ClientDatabase;
@@ -0,0 +1,4 @@
1
+ import { openBunDatabase } from './bun-database.js';
2
+ export function openSqliteDatabase(path = ':memory:') {
3
+ return openBunDatabase(path);
4
+ }
@@ -0,0 +1,2 @@
1
+ import type { ClientDatabase } from './database.js';
2
+ export declare function openSqliteDatabase(path?: string): ClientDatabase;
@@ -0,0 +1,4 @@
1
+ import { openNodeDatabase } from './node-database.js';
2
+ export function openSqliteDatabase(path = ':memory:') {
3
+ return openNodeDatabase(path);
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.46",
3
+ "version": "0.15.47",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -58,6 +58,14 @@
58
58
  "default": "./dist/node-database.js"
59
59
  }
60
60
  },
61
+ "./sqlite": {
62
+ "bun": "./src/sqlite-bun.ts",
63
+ "node": {
64
+ "types": "./dist/sqlite-node.d.ts",
65
+ "default": "./dist/sqlite-node.js"
66
+ },
67
+ "types": "./dist/sqlite-node.d.ts"
68
+ },
61
69
  "./wasm": {
62
70
  "bun": "./src/wasm-database.ts",
63
71
  "browser": "./dist/wasm-database.js",
@@ -85,23 +93,13 @@
85
93
  "!dist/**/*.test.d.ts"
86
94
  ],
87
95
  "scripts": {
88
- "verify:node": "bun build ./test/node-database/verify-node.mjs --target=node --external better-sqlite3 --outfile=./.verify-node.built.mjs && node ./.verify-node.built.mjs"
96
+ "verify:node": "bun build ./test/node-database/verify-node.mjs --target=node --outfile=./.verify-node.built.mjs && node ./.verify-node.built.mjs"
89
97
  },
90
98
  "dependencies": {
91
99
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.15.46"
93
- },
94
- "peerDependencies": {
95
- "better-sqlite3": ">=11"
96
- },
97
- "peerDependenciesMeta": {
98
- "better-sqlite3": {
99
- "optional": true
100
- }
100
+ "@syncular/core": "0.15.47"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.15.46",
104
- "@types/better-sqlite3": "^7.6.13",
105
- "better-sqlite3": "^12.11.1"
103
+ "@syncular/server": "0.15.47"
106
104
  }
107
105
  }
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * SPEC.md is normative.
4
4
  *
5
5
  * Browser-safe root: database backends live behind subpath exports
6
- * (`./bun` for bun:sqlite tests, `./wasm` for sqlite-wasm + OPFS); the
6
+ * (`./sqlite` for Node or Bun, `./wasm` for sqlite-wasm + OPFS); the
7
7
  * worker-side bootstrap lives behind `./worker`. The main-thread handle
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
@@ -1,23 +1,9 @@
1
1
  /**
2
- * `ClientDatabase` on better-sqlite3 the Electron-main / plain-Node
3
- * backend. Semantics mirror `./bun-database`
4
- * exactly (synchronous exec/query/transaction with the shared savepoint
5
- * helper, and the same §5.3 sqlite-image ATTACH path), so the core behaves
6
- * identically whether it runs on bun:sqlite (tests), sqlite-wasm (browser)
7
- * or better-sqlite3 (Node/Electron-main).
8
- *
9
- * better-sqlite3 is an OPTIONAL peer dependency, not a hard one: the package
10
- * installs cleanly without it and this module errors helpfully only when a
11
- * host actually calls `openNodeDatabase()` without having installed the peer.
12
- * Not exported from the package root, so browser/bun entries never resolve
13
- * the native module. Subpath export: `@syncular/client/node`.
14
- *
15
- * bun CANNOT dlopen better-sqlite3 (ERR_DLOPEN_FAILED, oven-sh/bun#4290), so
16
- * this adapter is verified under real Node — see the README "Electron-main /
17
- * plain-Node" section for the one-command recipe and `test/node-database`.
2
+ * `ClientDatabase` on Node's built-in `node:sqlite`. Semantics mirror the Bun
3
+ * adapter: synchronous queries, nested transactions, and SQLite image attach.
18
4
  */
5
+ import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
19
6
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
20
- import { createRequire } from 'node:module';
21
7
  import { tmpdir } from 'node:os';
22
8
  import { join } from 'node:path';
23
9
  import {
@@ -28,54 +14,19 @@ import {
28
14
  type SqlValue,
29
15
  } from './database';
30
16
 
31
- /**
32
- * Structural view of the tiny better-sqlite3 surface this binding uses. We
33
- * type it locally (rather than importing `better-sqlite3`'s types) so the
34
- * package typechecks without the optional peer installed.
35
- */
36
- interface BetterSqliteStatement {
37
- run(...params: NodeParam[]): unknown;
38
- all(...params: NodeParam[]): unknown[];
39
- }
40
- interface BetterSqliteDatabase {
41
- readonly inTransaction: boolean;
42
- prepare(sql: string): BetterSqliteStatement;
43
- exec(sql: string): unknown;
44
- close(): void;
45
- }
46
- type BetterSqliteConstructor = new (
47
- path: string,
48
- options?: { readonly?: boolean; fileMustExist?: boolean },
49
- ) => BetterSqliteDatabase;
50
-
51
- /**
52
- * better-sqlite3 accepts string / number / bigint / null / Buffer|Uint8Array
53
- * bind values, but NOT booleans (it throws "TypeError: can only bind …"). We
54
- * coerce booleans to 0/1 exactly like the bun adapter so callers see one
55
- * uniform bind contract across every backend.
56
- */
57
- type NodeParam = string | number | bigint | Uint8Array | null;
58
-
59
- function coerceParams(params: readonly SqlValue[]): NodeParam[] {
60
- return params.map((value): NodeParam => {
17
+ function coerceParams(params: readonly SqlValue[]): SQLInputValue[] {
18
+ return params.map((value): SQLInputValue => {
61
19
  if (typeof value === 'boolean') return value ? 1 : 0;
62
20
  return value;
63
21
  });
64
22
  }
65
23
 
66
- /**
67
- * better-sqlite3 returns BLOB columns as Node `Buffer`s. A Buffer IS a
68
- * Uint8Array subclass, but it can be a view onto a shared pool buffer, so we
69
- * normalize to a standalone Uint8Array — matching what bun:sqlite hands back
70
- * and keeping the buffer-ownership assumptions elsewhere (worker transfer,
71
- * structured clone) honest.
72
- */
73
24
  function normalizeRow(row: Record<string, unknown>): SqlRow {
74
25
  const out: SqlRow = {};
75
26
  for (const key in row) {
76
27
  const value = row[key];
77
- if (Buffer.isBuffer(value)) {
78
- out[key] = new Uint8Array(value); // copies out of the pool
28
+ if (value instanceof Uint8Array) {
29
+ out[key] = new Uint8Array(value);
79
30
  } else {
80
31
  out[key] = value as SqlValue;
81
32
  }
@@ -83,55 +34,12 @@ function normalizeRow(row: Record<string, unknown>): SqlRow {
83
34
  return out;
84
35
  }
85
36
 
86
- /**
87
- * Load the optional peer AND open the database in one guarded step, so BOTH
88
- * failure modes are turned into a clear, actionable error rather than a raw
89
- * one:
90
- *
91
- * - `require('better-sqlite3')` throwing MODULE_NOT_FOUND — the peer is not
92
- * installed (the common browser-only-host case), and
93
- * - `new Database()` throwing ERR_DLOPEN_FAILED — the module resolves but the
94
- * native addon cannot load, which is exactly what bun does for
95
- * better-sqlite3 (oven-sh/bun#4290); the addon only dlopens at construction.
96
- */
97
- function openBetterSqlite(path: string): BetterSqliteDatabase {
98
- const require = createRequire(import.meta.url);
99
- try {
100
- const mod = require('better-sqlite3') as
101
- | BetterSqliteConstructor
102
- | { default: BetterSqliteConstructor };
103
- const Database =
104
- (mod as { default?: BetterSqliteConstructor }).default ??
105
- (mod as BetterSqliteConstructor);
106
- return new Database(path);
107
- } catch (error) {
108
- const code = (error as { code?: string })?.code;
109
- if (code === 'ERR_DLOPEN_FAILED') {
110
- throw new Error(
111
- "openNodeDatabase() requires the 'better-sqlite3' native module, but " +
112
- 'it failed to load. This most commonly means you are running under ' +
113
- 'bun, which cannot dlopen better-sqlite3 (oven-sh/bun#4290) — use ' +
114
- "the bun:sqlite backend ('@syncular/client/bun') under bun, " +
115
- "and reserve '@syncular/client/node' for Node/Electron-main. " +
116
- `Underlying error: ${String(error)}`,
117
- );
118
- }
119
- throw new Error(
120
- 'openNodeDatabase() requires the optional peer dependency ' +
121
- "'better-sqlite3', which is not installed. Add it to your app " +
122
- '(`npm install better-sqlite3` / `bun add better-sqlite3`) — it is ' +
123
- 'kept optional so @syncular/client installs without a native ' +
124
- `build for browser-only hosts. Underlying error: ${String(error)}`,
125
- );
126
- }
127
- }
128
-
129
37
  export class NodeClientDatabase implements ClientDatabase {
130
- readonly db: BetterSqliteDatabase;
38
+ readonly db: DatabaseSync;
131
39
  #tx = { depth: 0 };
132
40
 
133
41
  constructor(path = ':memory:') {
134
- this.db = openBetterSqlite(path);
42
+ this.db = new DatabaseSync(path);
135
43
  }
136
44
 
137
45
  exec(sql: string, params: readonly SqlValue[] = []): void {
@@ -140,19 +48,14 @@ export class NodeClientDatabase implements ClientDatabase {
140
48
 
141
49
  query(sql: string, params: readonly SqlValue[] = []): SqlRow[] {
142
50
  const rows = this.db.prepare(sql).all(...coerceParams(params));
143
- return (rows as Record<string, unknown>[]).map(normalizeRow);
51
+ return rows.map(normalizeRow);
144
52
  }
145
53
 
146
54
  transaction<T>(fn: () => T): T {
147
55
  return runTransaction(this.#tx, (sql) => this.db.exec(sql), fn);
148
56
  }
149
57
 
150
- /**
151
- * §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
152
- * buffers, so the image lands in a private temp file for the duration of
153
- * the ATTACH. Must be called outside any open transaction (SQLite cannot
154
- * ATTACH inside one).
155
- */
58
+ /** §5.3 image import through a private file attached for one callback. */
156
59
  withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T {
157
60
  assertImageAlias(alias);
158
61
  const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
@@ -10,8 +10,8 @@
10
10
  * bypasses the outbox (SPEC §7.1) and silently diverges from the
11
11
  * server — writes MUST go through `client.mutate([...])`.
12
12
  * 2. ONE STATEMENT. `sqlite-wasm`'s `exec` runs every statement in a
13
- * multi-statement string (`SELECT 1; DROP TABLE t`), while bun:sqlite /
14
- * better-sqlite3 prepare only the first. We unify on the strict
13
+ * multi-statement string (`SELECT 1; DROP TABLE t`), while the native
14
+ * SQLite adapters prepare only the first. We unify on the strict
15
15
  * behaviour: exactly one statement per `query()`.
16
16
  *
17
17
  * The guard only fronts the PUBLIC `client.query()` — engine-internal reads
@@ -0,0 +1,6 @@
1
+ import { openBunDatabase } from './bun-database';
2
+ import type { ClientDatabase } from './database';
3
+
4
+ export function openSqliteDatabase(path = ':memory:'): ClientDatabase {
5
+ return openBunDatabase(path);
6
+ }
@@ -0,0 +1,6 @@
1
+ import type { ClientDatabase } from './database';
2
+ import { openNodeDatabase } from './node-database';
3
+
4
+ export function openSqliteDatabase(path = ':memory:'): ClientDatabase {
5
+ return openNodeDatabase(path);
6
+ }