@happyvertical/sql 0.89.1 → 0.89.3

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
@@ -83,6 +83,20 @@ cached adapter. Combine it with `cache: false` to evict first and return an
83
83
  uncached replacement. A concurrent initializer caught by eviction is closed
84
84
  and cannot repopulate the cache.
85
85
 
86
+ With automatic file registration enabled (the default), cached JSON adapters
87
+ also detect external changes to JSON data and companion `.schema.sql` files when
88
+ a later `getDatabase()` call acquires the same cache identity. A changed, added,
89
+ or removed source file causes the old DuckDB adapter to close before one fresh
90
+ shared adapter is created. Existing references point to the closed adapter
91
+ after replacement; acquire the database again before continuing work after an
92
+ external file change. Writes made through the adapter refresh its snapshot and
93
+ do not invalidate the adapter itself when the tracked sources are otherwise
94
+ unchanged. If an external change is already visible before an adapter export,
95
+ the export fails closed so the caller can reacquire the database and retry.
96
+ Freshness checks are not a cross-process file lock: an external process writing
97
+ the same table's JSON or companion schema file at the same instant as an adapter
98
+ export remains last-writer-wins and must be coordinated by the application.
99
+
86
100
  The JSON adapter's exported `clearConnectionCache()` helper is asynchronous:
87
101
  always `await clearConnectionCache()` before opening a replacement connection.
88
102
 
@@ -248,6 +262,13 @@ object with a `toString` — is rejected outright rather than coerced, so the va
248
262
  validated is always the value interpolated. Values are always parameterized and
249
263
  are unaffected.
250
264
 
265
+ PostgreSQL `upsert()` additionally lowercases each validated column name before
266
+ double-quoting it. PostgreSQL has always folded the adapter's unquoted column
267
+ names to lowercase, so this preserves existing behavior (`mixedCase` still
268
+ addresses `mixedcase`) while allowing reserved words such as `end` in insert,
269
+ conflict and update positions. Delimited, case-sensitive physical names such as
270
+ `"MixedCase"` remain outside the CRUD identifier contract.
271
+
251
272
  `buildWhere` separates a supported operator suffix (`'price >'`,
252
273
  `'name contains'`) from the field and then validates the field as an identifier.
253
274
  Unsupported suffixes remain part of the field and fail validation rather than
@@ -911,11 +911,12 @@ function validateColumnName(columnName) {
911
911
  * Validates every identifier in a column list to prevent SQL injection
912
912
  *
913
913
  * Column names reaching the CRUD methods come from `Object.keys(data)` and the
914
- * caller's `conflictColumns`, both of which are interpolated into SQL. They are
915
- * checked rather than quoted deliberately: the accepted shape has no character
916
- * that can end an identifier, so quoting adds nothing to safety, and quoting a
917
- * name on PostgreSQL makes it case-sensitive, which would silently change which
918
- * column an existing caller addresses.
914
+ * caller's `conflictColumns`, both of which are interpolated into SQL. The
915
+ * accepted shape has no character that can end an identifier. Adapters may
916
+ * render a validated name bare, or quote it under an adapter-specific contract.
917
+ * PostgreSQL upsert uses {@link quotePostgresColumnName}, which canonicalizes
918
+ * the name before quoting so its established unquoted case-folding semantics
919
+ * do not change.
919
920
  *
920
921
  * @param columnNames - Column names to validate
921
922
  * @throws Error if any column name contains invalid characters
@@ -924,6 +925,24 @@ function validateColumnNames(columnNames) {
924
925
  for (const columnName of columnNames) validateColumnName(columnName);
925
926
  }
926
927
  /**
928
+ * Quotes a validated column name using PostgreSQL's historic case semantics.
929
+ *
930
+ * PostgreSQL folds every unquoted identifier to lowercase. The CRUD API has
931
+ * always accepted mixed-case inputs while rendering them unquoted, so quoting
932
+ * the caller's spelling directly would silently change which physical column
933
+ * is addressed. Validate the existing plain-identifier contract first, then
934
+ * lowercase before quoting: `mixedCase` still addresses `mixedcase`, while a
935
+ * reserved word such as `end` is no longer parsed as SQL syntax.
936
+ *
937
+ * @param columnName - Plain column name to render for PostgreSQL
938
+ * @returns A lowercase, double-quoted PostgreSQL identifier
939
+ * @throws Error if the column name is outside the CRUD identifier contract
940
+ */
941
+ function quotePostgresColumnName(columnName) {
942
+ validateColumnName(columnName);
943
+ return `"${columnName.toLowerCase()}"`;
944
+ }
945
+ /**
927
946
  * Escapes an identifier for interpolation inside double quotes
928
947
  *
929
948
  * For the positions that already quote the identifier — the DuckDB and JSON
@@ -976,15 +995,17 @@ function validateIndexName(indexName) {
976
995
  }
977
996
  //#endregion
978
997
  //#region src/shared/connection-cache.ts
998
+ var CACHED_VALIDATION_CAPABILITY = Symbol.for("@happyvertical/sql/connection-cache-cached-validation/v1");
979
999
  /** Race-safe async resource cache used by database adapters. */
980
1000
  var ConnectionCache = class {
1001
+ [CACHED_VALIDATION_CAPABILITY] = true;
981
1002
  #connections = /* @__PURE__ */ new Map();
982
1003
  #pending = /* @__PURE__ */ new Map();
983
1004
  #evictions = /* @__PURE__ */ new Map();
984
1005
  #requests = /* @__PURE__ */ new Map();
985
1006
  #clearing = /* @__PURE__ */ new Map();
986
1007
  #failedClosures = /* @__PURE__ */ new Map();
987
- async getOrCreate(key, options, create, close) {
1008
+ async getOrCreate(key, options, create, close, validateCached) {
988
1009
  validateDatabaseCacheOptions(options);
989
1010
  const request = key && options.cache !== false ? this.#registerRequest(key) : void 0;
990
1011
  try {
@@ -1002,7 +1023,17 @@ var ConnectionCache = class {
1002
1023
  }
1003
1024
  this.#throwIfFailedClosure(key);
1004
1025
  const cached = this.#connections.get(key);
1005
- 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
+ }
1006
1037
  let pending = this.#pending.get(key);
1007
1038
  if (!pending) {
1008
1039
  this.#throwIfCancelled(request);
@@ -1165,10 +1196,110 @@ var ConnectionCache = class {
1165
1196
  }
1166
1197
  }
1167
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
+ }
1168
1299
  function validateDatabaseCacheOptions(options) {
1169
1300
  if (options.dbid === "") throw new Error("Database cache dbid must be a non-empty string");
1170
1301
  }
1171
1302
  //#endregion
1172
- export { unwrapRawKey as _, generateAddColumnStatement as a, validateColumnNames as c, DatabaseSchemaManager as d, buildWhere as f, resolveInsertColumns as g, raw as h, escapeStringLiteral as i, validateIndexName as l, parseConditionKey as m, validateDatabaseCacheOptions as n, generateCreateIndexStatement as o, formatDbError as p, escapeQuotedIdentifier as r, validateColumnName as s, ConnectionCache as t, validateTableName as u, wrapDatabaseError as v };
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 };
1173
1304
 
1174
- //# sourceMappingURL=connection-cache-WEH2ulk5.js.map
1305
+ //# sourceMappingURL=connection-cache-Da5UcY5u.js.map