@happyvertical/sql 0.89.2 → 0.89.4

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 (36) hide show
  1. package/AGENT.md +1 -0
  2. package/README.md +143 -9
  3. package/dist/chunks/{connection-cache-BU3GBQxu.js → connection-cache-Da5UcY5u.js} +116 -4
  4. package/dist/chunks/{connection-cache-BU3GBQxu.js.map → connection-cache-Da5UcY5u.js.map} +1 -1
  5. package/dist/chunks/{duckdb-hbOqNSuA.js → duckdb-DfikVgov.js} +3 -3
  6. package/dist/chunks/{duckdb-hbOqNSuA.js.map → duckdb-DfikVgov.js.map} +1 -1
  7. package/dist/chunks/sqlite-Cz2NMiDs.js +2619 -0
  8. package/dist/chunks/sqlite-Cz2NMiDs.js.map +1 -0
  9. package/dist/chunks/{sqlite-connection-cache-Cnsr2tuN.js → sqlite-connection-cache-BAakj2HO.js} +9 -4
  10. package/dist/chunks/sqlite-connection-cache-BAakj2HO.js.map +1 -0
  11. package/dist/chunks/{sqlite-native-CdNLXZ60.js → sqlite-native-ByGwGEbS.js} +4 -4
  12. package/dist/chunks/{sqlite-native-CdNLXZ60.js.map → sqlite-native-ByGwGEbS.js.map} +1 -1
  13. package/dist/chunks/{types-uVFHgaSU.js → types-CwWJ-yju.js} +8 -2
  14. package/dist/chunks/types-CwWJ-yju.js.map +1 -0
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +185 -15
  18. package/dist/index.js.map +1 -1
  19. package/dist/json.d.ts.map +1 -1
  20. package/dist/secure-sqlite-client.d.ts +81 -0
  21. package/dist/secure-sqlite-client.d.ts.map +1 -0
  22. package/dist/shared/connection-cache.d.ts +13 -1
  23. package/dist/shared/connection-cache.d.ts.map +1 -1
  24. package/dist/shared/sqlite-connection-cache.d.ts +1 -1
  25. package/dist/shared/sqlite-connection-cache.d.ts.map +1 -1
  26. package/dist/shared/transaction-lock.d.ts +2 -0
  27. package/dist/shared/transaction-lock.d.ts.map +1 -1
  28. package/dist/shared/types.d.ts +6 -2
  29. package/dist/shared/types.d.ts.map +1 -1
  30. package/dist/sqlite.d.ts +35 -0
  31. package/dist/sqlite.d.ts.map +1 -1
  32. package/package.json +4 -3
  33. package/dist/chunks/sqlite-D3sUjQ5Q.js +0 -1136
  34. package/dist/chunks/sqlite-D3sUjQ5Q.js.map +0 -1
  35. package/dist/chunks/sqlite-connection-cache-Cnsr2tuN.js.map +0 -1
  36. package/dist/chunks/types-uVFHgaSU.js.map +0 -1
package/AGENT.md CHANGED
@@ -16,6 +16,7 @@ Database interface with support for SQLite, PostgreSQL, and DuckDB
16
16
  ```bash
17
17
  pnpm --filter @happyvertical/sql build
18
18
  pnpm --filter @happyvertical/sql test
19
+ pnpm --filter @happyvertical/sql typecheck
19
20
  pnpm --filter @happyvertical/sql clean
20
21
  ```
21
22
 
package/README.md CHANGED
@@ -21,6 +21,17 @@ const db = await getDatabase({ type: 'sqlite', url: ':memory:' });
21
21
  // SQLite (file)
22
22
  const fileDb = await getDatabase({ type: 'sqlite', url: 'file:./app.db' });
23
23
 
24
+ // Local file under an application-custodied data directory (macOS and Linux)
25
+ const secureFileDb = await getDatabase({
26
+ type: 'sqlite',
27
+ url: './data/app.db',
28
+ secureFile: {
29
+ driver: 'node:sqlite',
30
+ custody: 'trusted-parent',
31
+ root: './data',
32
+ },
33
+ });
34
+
24
35
  // LibSQL/Turso (remote)
25
36
  const tursoDb = await getDatabase({
26
37
  type: 'sqlite',
@@ -83,6 +94,20 @@ cached adapter. Combine it with `cache: false` to evict first and return an
83
94
  uncached replacement. A concurrent initializer caught by eviction is closed
84
95
  and cannot repopulate the cache.
85
96
 
97
+ With automatic file registration enabled (the default), cached JSON adapters
98
+ also detect external changes to JSON data and companion `.schema.sql` files when
99
+ a later `getDatabase()` call acquires the same cache identity. A changed, added,
100
+ or removed source file causes the old DuckDB adapter to close before one fresh
101
+ shared adapter is created. Existing references point to the closed adapter
102
+ after replacement; acquire the database again before continuing work after an
103
+ external file change. Writes made through the adapter refresh its snapshot and
104
+ do not invalidate the adapter itself when the tracked sources are otherwise
105
+ unchanged. If an external change is already visible before an adapter export,
106
+ the export fails closed so the caller can reacquire the database and retry.
107
+ Freshness checks are not a cross-process file lock: an external process writing
108
+ the same table's JSON or companion schema file at the same instant as an adapter
109
+ export remains last-writer-wins and must be coordinated by the application.
110
+
86
111
  The JSON adapter's exported `clearConnectionCache()` helper is asynchronous:
87
112
  always `await clearConnectionCache()` before opening a replacement connection.
88
113
 
@@ -102,6 +127,91 @@ behavior. Call `close()` on uncached and DuckDB adapters when finished.
102
127
 
103
128
  Configuration is also loaded from `HAVE_SQL_*` environment variables (e.g. `HAVE_SQL_TYPE`, `HAVE_SQL_URL`). User-provided options take precedence.
104
129
 
130
+ ### Secure local SQLite acquisition
131
+
132
+ Secure mode requires an explicit custody contract:
133
+
134
+ ```typescript
135
+ secureFile: {
136
+ driver: 'node:sqlite',
137
+ custody: 'trusted-parent',
138
+ root: './data', // optional; defaults to the database's direct parent
139
+ }
140
+ ```
141
+
142
+ Boolean `true` fails closed. Before loading the driver, the adapter verifies
143
+ that the database is beneath the custody root, that static path components are
144
+ real directories rather than symlinks, and that the root plus database-parent
145
+ chain is owned by the current uid with no group/world write permission. An
146
+ existing leaf must likewise be a current-user-owned regular file with no
147
+ group/world write permission. A missing leaf is created exclusively with mode
148
+ `0600` before driver acquisition, independent of a permissive process umask, and
149
+ is removed if the driver cannot acquire it and its device/inode identity is
150
+ still unchanged. If identity inspection itself fails, acquisition fails closed
151
+ and leaves the restrictive empty leaf in place rather than risking deletion of
152
+ a replacement. On macOS, every inspected component and existing leaf must also
153
+ have no ACL entry that grants authority; restrictive deny-only entries
154
+ (including the standard home-directory `group:everyone deny delete` entry) are
155
+ accepted. ACL inspection errors or unrecognized/ambiguous ACL entries fail
156
+ closed.
157
+ The adapter invokes `/bin/ls -lde -- <path>` directly with an argument vector,
158
+ never through a shell, and parses every numbered ACL entry even when macOS's
159
+ extended-attribute `@` marker takes display precedence over the ACL `+` marker.
160
+ Ancestors above the custody root may not allow replacement by another principal
161
+ and must be owned either by the current uid or privileged uid `0` (the explicit
162
+ system-root exception). A sticky root-owned shared parent such as `/tmp` is
163
+ accepted. The application must create and retain custody of this directory;
164
+ mode `0700` with no permissive ACL is the conventional choice.
165
+
166
+ After custody validation, the adapter opens the path with Node's built-in
167
+ `node:sqlite` driver. Static ancestor and leaf symlinks are rejected. Under the
168
+ contract, other principals cannot replace entries beneath the current-user-owned
169
+ custody root, so there is no cross-principal pathname race between validation
170
+ and open. This is not an atomic path boundary against a hostile process
171
+ running as the same account: that process can already read, rewrite, unlink, or
172
+ replace an unencrypted user-owned database and its directory. Separating
173
+ same-account processes requires OS sandboxing plus a descriptor-relative/custom
174
+ SQLite VFS. Keep the custody contract in force for the full connection lifetime.
175
+
176
+ The secure path is supported on macOS and Linux with Node.js 24.18.0 or newer.
177
+ The runtime version is checked before `node:sqlite` is imported or the database
178
+ is opened. Secure mode fails closed on older or malformed runtime versions,
179
+ other platforms, and when combined with remote LibSQL URLs, `:memory:`, LibSQL
180
+ authentication or encryption, or optional native capabilities. Omit
181
+ `secureFile` to retain the existing LibSQL behavior on older consumers.
182
+
183
+ Every secure prepared statement enables exact BigInt reads. Safe SQLite integer
184
+ columns retain legacy JavaScript `number` results; integers outside the safe
185
+ range are returned as `bigint`, and `bigint` parameters bind exactly. Boolean
186
+ parameters are normalized to SQLite integers (`1`/`0`) at the driver boundary;
187
+ objects and arrays continue through the adapter's JSON serialization unchanged.
188
+ The public row-count contract remains `number`, so an exact `changes` metric
189
+ above `Number.MAX_SAFE_INTEGER` fails explicitly instead of rounding.
190
+
191
+ Secure connections also guard the public `client.execute()` seam with the same
192
+ invocation and transaction-scope lifetime rules as the database helpers. A
193
+ client call accepted before `close()` drains first; calls made after close or
194
+ after a transaction scope ends reject without reaching SQLite. Use
195
+ `database.transaction()` or `database.beginTransaction()` for transaction
196
+ control—direct `client.transaction()` and transaction-scoped `client.close()`
197
+ fail closed. Raw `BEGIN`, `COMMIT`, `END`, `ROLLBACK`, `SAVEPOINT`, and
198
+ `RELEASE` statements are likewise rejected through root and transaction-scoped
199
+ database/client routes so they cannot create an untracked transaction, bypass
200
+ callback/manual rollback, or invalidate an owned savepoint. A parent transaction
201
+ handle also rejects when invoked from inside a nested callback; use the nested
202
+ callback's handle or wait for the child to settle before reusing its parent.
203
+ If SQLite itself ends a transaction through a statement policy
204
+ such as `ON CONFLICT ROLLBACK`, already-accepted later work rejects before
205
+ execution and commit reports the automatic rollback. If an explicit rollback
206
+ fails, the secure client is invalidated and rejects later work rather than
207
+ returning a connection with uncertain transaction state to service.
208
+
209
+ Every static component must be a real path component. For example, macOS exposes
210
+ `/var` as a symlink, so use the resolved `/private/var/...` path when secure
211
+ acquisition is intentional. Secure mode requires the package's supported Node
212
+ runtime with built-in `node:sqlite`; it installs no additional native peer.
213
+ Default LibSQL use remains unchanged.
214
+
105
215
  ### Template Literal Queries
106
216
 
107
217
  ```typescript
@@ -229,14 +339,38 @@ Two consequences worth knowing:
229
339
  back. End it in a `finally` — a handle that is never ended holds the
230
340
  connection for the life of the process, and every later transaction on it
231
341
  fails with the queue timeout.
232
- - Inside a `transaction()` callback, use the `tx` you were handed. Calling a
233
- top-level `db.*` method that opens its own transaction makes it wait on the
234
- connection its own caller is holding.
235
-
236
- PostgreSQL pools its connections, so transactions there run concurrently and
237
- never queue. Nested scopes on one PostgreSQL transaction use savepoints; if
238
- sibling nested scopes are started concurrently, they serialize so PostgreSQL's
239
- stack-ordered savepoint lifecycle remains intact.
342
+ - Inside a `transaction()` callback, use the `tx` you were handed. With secure
343
+ SQLite, calling a top-level `db.*` or `db.client.execute()` method is rejected
344
+ immediately so detached work cannot escape into autocommit after the callback
345
+ ends. While a manual transaction handle is open, top-level calls wait on its
346
+ connection and reject at `transactionQueueTimeout` rather than hang forever.
347
+
348
+ Nested SQLite and PostgreSQL scopes use savepoints. If sibling nested scopes are
349
+ started concurrently on one transaction, they serialize so the stack-ordered
350
+ savepoint lifecycle remains intact. While a secure SQLite child savepoint is
351
+ open, operations invoked through its parent scope queue behind that child; a
352
+ child rollback therefore cannot silently remove a successful parent operation.
353
+ The enclosing commit or rollback drains all
354
+ accepted child scopes before ending the transaction. PostgreSQL pools separate
355
+ connections, so top-level transactions there run concurrently and never queue.
356
+ Every SQLite transaction-scoped operation is registered when its public method
357
+ is called. Ending a callback or invoking a manual handle's commit/rollback
358
+ seals the scope synchronously, drains operations already accepted, and rejects
359
+ later operations so work cannot escape into autocommit after the transaction.
360
+ If a SQLite statement failure is intentionally recoverable, attach an explicit
361
+ `.catch(...)` or `.then(..., onRejected)` to the transaction-scoped operation.
362
+ Passing it through `Promise.resolve`, `Promise.all`, or an async helper only
363
+ counts as recovery when the derived rejection is itself awaited or caught. A
364
+ detached rejected adoption fails closed and rolls back. `Promise.allSettled`
365
+ intentionally consumes each rejection, so using it permits the transaction to
366
+ commit after the caller inspects those results.
367
+
368
+ While transaction-scoped Promise observations are active, SQLite temporarily
369
+ enables a process-wide Node Promise lifecycle hook and an
370
+ `unhandledRejection` observer. Both are removed when the accepted work drains;
371
+ another reason to always end a manual `beginTransaction()` handle in `finally`
372
+ is that an abandoned handle can retain this bookkeeping as well as its database
373
+ connection.
240
374
 
241
375
  ### Identifiers
242
376
 
@@ -471,7 +605,7 @@ shipping it beyond development or test environments.
471
605
 
472
606
  | Adapter | `type` | Backend | Notes |
473
607
  |---------|--------|---------|-------|
474
- | SQLite | `'sqlite'` | LibSQL (`@libsql/client`) by default; native `node:sqlite` when capabilities are enabled | Supports `:memory:`, file, and remote Turso URLs by default. Native capabilities are local-only |
608
+ | SQLite | `'sqlite'` | LibSQL (`@libsql/client`) by default; built-in `node:sqlite` for capabilities and `secureFile` | Supports `:memory:`, file, and remote Turso URLs by default. Native capabilities are local-only; trusted-parent secure files are macOS/Linux-only |
475
609
  | PostgreSQL | `'postgres'` | `pg` Pool | Connection pooling, pgvector support |
476
610
  | DuckDB | `'duckdb'` | `@duckdb/node-api` | JSON file auto-registration, write-back strategies |
477
611
  | JSON | `'json'` | DuckDB in-memory | Queries JSON files as tables, connection caching |
@@ -995,15 +995,17 @@ function validateIndexName(indexName) {
995
995
  }
996
996
  //#endregion
997
997
  //#region src/shared/connection-cache.ts
998
+ var CACHED_VALIDATION_CAPABILITY = Symbol.for("@happyvertical/sql/connection-cache-cached-validation/v1");
998
999
  /** Race-safe async resource cache used by database adapters. */
999
1000
  var ConnectionCache = class {
1001
+ [CACHED_VALIDATION_CAPABILITY] = true;
1000
1002
  #connections = /* @__PURE__ */ new Map();
1001
1003
  #pending = /* @__PURE__ */ new Map();
1002
1004
  #evictions = /* @__PURE__ */ new Map();
1003
1005
  #requests = /* @__PURE__ */ new Map();
1004
1006
  #clearing = /* @__PURE__ */ new Map();
1005
1007
  #failedClosures = /* @__PURE__ */ new Map();
1006
- async getOrCreate(key, options, create, close) {
1008
+ async getOrCreate(key, options, create, close, validateCached) {
1007
1009
  validateDatabaseCacheOptions(options);
1008
1010
  const request = key && options.cache !== false ? this.#registerRequest(key) : void 0;
1009
1011
  try {
@@ -1021,7 +1023,17 @@ var ConnectionCache = class {
1021
1023
  }
1022
1024
  this.#throwIfFailedClosure(key);
1023
1025
  const cached = this.#connections.get(key);
1024
- if (cached) return cached;
1026
+ if (cached !== void 0) {
1027
+ if (!validateCached) return cached;
1028
+ const isValid = await validateCached(cached);
1029
+ this.#throwIfCancelled(request);
1030
+ if (isValid && this.#connections.get(key) === cached && !this.#evictions.has(key)) return cached;
1031
+ if (!isValid && this.#connections.get(key) === cached && !this.#evictions.has(key)) {
1032
+ await this.evict(key, close);
1033
+ this.#throwIfCancelled(request);
1034
+ }
1035
+ continue;
1036
+ }
1025
1037
  let pending = this.#pending.get(key);
1026
1038
  if (!pending) {
1027
1039
  this.#throwIfCancelled(request);
@@ -1184,10 +1196,110 @@ var ConnectionCache = class {
1184
1196
  }
1185
1197
  }
1186
1198
  };
1199
+ /**
1200
+ * Adds cached-resource validation to a shared cache created by an older module.
1201
+ *
1202
+ * JSON connection caches live on `globalThis`, so a compatible older SDK copy
1203
+ * can create the shared object before this module loads. Mutating that object
1204
+ * preserves one cache for both module copies while upgrading its four-argument
1205
+ * `getOrCreate` method to the current validation contract.
1206
+ */
1207
+ function enableCachedValidation(cache) {
1208
+ if (cache[CACHED_VALIDATION_CAPABILITY]) return cache;
1209
+ const legacyGetOrCreate = cache.getOrCreate.bind(cache);
1210
+ const legacyClear = cache.clear.bind(cache);
1211
+ const locks = /* @__PURE__ */ new Map();
1212
+ let clearGeneration = 0;
1213
+ let activeClear;
1214
+ const runLocked = async (key, work) => {
1215
+ const previous = locks.get(key) ?? Promise.resolve();
1216
+ let release;
1217
+ const current = new Promise((resolve) => {
1218
+ release = resolve;
1219
+ });
1220
+ const chain = previous.then(() => current, () => current);
1221
+ locks.set(key, chain);
1222
+ await previous.catch(() => {});
1223
+ try {
1224
+ return await work();
1225
+ } finally {
1226
+ release();
1227
+ if (locks.get(key) === chain) locks.delete(key);
1228
+ }
1229
+ };
1230
+ Object.defineProperty(cache, "getOrCreate", {
1231
+ configurable: true,
1232
+ value: async (key, options, create, close, validateCached) => {
1233
+ if (!key || options.cache === false) return legacyGetOrCreate(key, options, create, close);
1234
+ const requestGeneration = clearGeneration;
1235
+ const throwIfCleared = () => {
1236
+ if (activeClear || requestGeneration !== clearGeneration) throw new Error("Database connection initialization was cancelled by cache clear");
1237
+ };
1238
+ return runLocked(key, async () => {
1239
+ throwIfCleared();
1240
+ let requestOptions = options;
1241
+ while (true) {
1242
+ throwIfCleared();
1243
+ const candidate = await legacyGetOrCreate(key, requestOptions, create, close);
1244
+ throwIfCleared();
1245
+ if (!validateCached) return candidate;
1246
+ const isValid = await validateCached(candidate);
1247
+ throwIfCleared();
1248
+ if (isValid) {
1249
+ const confirmed = await legacyGetOrCreate(key, {
1250
+ ...requestOptions,
1251
+ clearCache: false
1252
+ }, create, close);
1253
+ throwIfCleared();
1254
+ if (confirmed === candidate) return candidate;
1255
+ } else await cache.evict(key, close);
1256
+ requestOptions = {
1257
+ ...options,
1258
+ clearCache: false
1259
+ };
1260
+ }
1261
+ });
1262
+ },
1263
+ writable: true
1264
+ });
1265
+ Object.defineProperty(cache, "clear", {
1266
+ configurable: true,
1267
+ value: async (close) => {
1268
+ if (activeClear) return activeClear;
1269
+ clearGeneration += 1;
1270
+ const clearing = (async () => {
1271
+ let clearError;
1272
+ let clearFailed = false;
1273
+ try {
1274
+ await legacyClear(close);
1275
+ } catch (error) {
1276
+ clearFailed = true;
1277
+ clearError = error;
1278
+ }
1279
+ while (locks.size > 0) await Promise.allSettled([...locks.values()]);
1280
+ if (clearFailed) throw clearError;
1281
+ })();
1282
+ activeClear = clearing;
1283
+ try {
1284
+ await clearing;
1285
+ } finally {
1286
+ if (activeClear === clearing) activeClear = void 0;
1287
+ }
1288
+ },
1289
+ writable: true
1290
+ });
1291
+ Object.defineProperty(cache, CACHED_VALIDATION_CAPABILITY, {
1292
+ configurable: false,
1293
+ enumerable: false,
1294
+ value: true,
1295
+ writable: false
1296
+ });
1297
+ return cache;
1298
+ }
1187
1299
  function validateDatabaseCacheOptions(options) {
1188
1300
  if (options.dbid === "") throw new Error("Database cache dbid must be a non-empty string");
1189
1301
  }
1190
1302
  //#endregion
1191
- export { resolveInsertColumns as _, generateAddColumnStatement as a, validateColumnName as c, validateTableName as d, DatabaseSchemaManager as f, raw as g, parseConditionKey as h, escapeStringLiteral as i, validateColumnNames as l, formatDbError as m, validateDatabaseCacheOptions as n, generateCreateIndexStatement as o, buildWhere as p, escapeQuotedIdentifier as r, quotePostgresColumnName as s, ConnectionCache as t, validateIndexName as u, unwrapRawKey as v, wrapDatabaseError as y };
1303
+ export { raw as _, escapeStringLiteral as a, wrapDatabaseError as b, quotePostgresColumnName as c, validateIndexName as d, validateTableName as f, parseConditionKey as g, formatDbError as h, escapeQuotedIdentifier as i, validateColumnName as l, buildWhere as m, enableCachedValidation as n, generateAddColumnStatement as o, DatabaseSchemaManager as p, validateDatabaseCacheOptions as r, generateCreateIndexStatement as s, ConnectionCache as t, validateColumnNames as u, resolveInsertColumns as v, unwrapRawKey as y };
1192
1304
 
1193
- //# sourceMappingURL=connection-cache-BU3GBQxu.js.map
1305
+ //# sourceMappingURL=connection-cache-Da5UcY5u.js.map