@warlock.js/cascade 4.6.0 → 4.6.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ All notable changes to `@warlock.js/cascade` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 4.6.1
8
+
9
+ ### Fixed
10
+
11
+ - Native Postgres array columns (`TEXT[]` / `JSONB[]`, from `arrayText()` / `arrayJson()`) are now auto-detected by introspecting the schema on connect and bound as raw arrays — no more "malformed array literal" on insert and no need to hand-list `nativeArrayColumns` (which stays as an optional per-connection override, now consulted per-table)
12
+ - `transaction()` now flat-nests: a nested `transaction()` joins the active one (same session, sees its uncommitted writes) instead of opening a second, independent transaction — fixes phantom foreign-key violations when a service that opens its own transaction is called inside an outer one (e.g. a seeder creating a row, then a service inserting a child that references it). MongoDB joins too, replacing its "nested not supported" throw
13
+
7
14
  ## 4.6.0
8
15
 
9
16
  ### Added
package/cjs/index.cjs CHANGED
@@ -13035,7 +13035,10 @@ var MongoDbDriver = class {
13035
13035
  * @throws {Error} If transaction fails, is explicitly rolled back, or replica set not configured
13036
13036
  */
13037
13037
  async transaction(fn, options) {
13038
- if (databaseTransactionContext.hasActiveTransaction()) throw new Error("Nested transaction() calls are not supported. Use beginTransaction() with savepoints for advanced transaction patterns.");
13038
+ const ctx = { rollback(reason) {
13039
+ throw new TransactionRollbackError(reason);
13040
+ } };
13041
+ if (databaseTransactionContext.hasActiveTransaction()) return fn(ctx);
13039
13042
  await this.ensureReplicaSetAvailable();
13040
13043
  const session = this.getClientInstance().startSession();
13041
13044
  try {
@@ -13045,9 +13048,7 @@ var MongoDbDriver = class {
13045
13048
  });
13046
13049
  databaseTransactionContext.enter({ session });
13047
13050
  try {
13048
- const result = await fn({ rollback(reason) {
13049
- throw new TransactionRollbackError(reason);
13050
- } });
13051
+ const result = await fn(ctx);
13051
13052
  await session.commitTransaction();
13052
13053
  return result;
13053
13054
  } catch (error) {
@@ -16925,13 +16926,22 @@ var PostgresDriver = class {
16925
16926
  */
16926
16927
  _syncAdapter;
16927
16928
  /**
16928
- * Lookup set of column names that hold native PostgreSQL arrays
16929
- * (`JSONB[]`, `TEXT[]`, …) and must NOT be JSON-text encoded.
16929
+ * Explicit, table-agnostic override list of column names that hold native
16930
+ * PostgreSQL arrays (`JSONB[]`, `TEXT[]`, …) and must NOT be JSON-text
16931
+ * encoded. Merged with (and superseded per-table by) the schema
16932
+ * introspection below; kept as a manual escape hatch.
16930
16933
  *
16931
16934
  * @see PostgresPoolConfig.nativeArrayColumns
16932
16935
  */
16933
16936
  _nativeArrayColumns;
16934
16937
  /**
16938
+ * Native-array columns discovered by introspecting the live schema on
16939
+ * connect, keyed `table → { column, … }`. Authoritative and table-scoped, so
16940
+ * a column that is `TEXT[]` in one table and `jsonb` in another is encoded
16941
+ * correctly for each — no app configuration required.
16942
+ */
16943
+ _introspectedArrayColumns = /* @__PURE__ */ new Map();
16944
+ /**
16935
16945
  * Create a new PostgreSQL driver instance.
16936
16946
  *
16937
16947
  * @param config - PostgreSQL connection configuration
@@ -16997,6 +17007,7 @@ var PostgresDriver = class {
16997
17007
  (await this._pool.connect()).release();
16998
17008
  _warlock_js_logger.log.success("database.postgres", "connection", `Connected to database ${_mongez_copper.colors.bold(_mongez_copper.colors.yellowBright(this.config.database))}`);
16999
17009
  this._isConnected = true;
17010
+ await this.loadNativeArrayColumns();
17000
17011
  this.emit("connected");
17001
17012
  } catch (error) {
17002
17013
  _warlock_js_logger.log.fatal("database.postgres", "connection", "Failed to connect to database");
@@ -17033,13 +17044,15 @@ var PostgresDriver = class {
17033
17044
  * that need special handling for PostgreSQL storage.
17034
17045
  *
17035
17046
  * @param data - The data object to serialize
17047
+ * @param table - Optional table name; when given, columns introspected as
17048
+ * native arrays on that table are bound raw (see {@link serializeValue}).
17036
17049
  * @returns Serialized data ready for PostgreSQL
17037
17050
  */
17038
- serialize(data) {
17051
+ serialize(data, table) {
17039
17052
  const serialized = {};
17040
17053
  for (const [key, value] of Object.entries(data)) {
17041
17054
  if (value === void 0) continue;
17042
- serialized[key] = this.serializeValue(key, value);
17055
+ serialized[key] = this.serializeValue(key, value, table);
17043
17056
  }
17044
17057
  return serialized;
17045
17058
  }
@@ -17059,36 +17072,87 @@ var PostgresDriver = class {
17059
17072
  * `JSON.stringify`. node-pg renders a raw JS array as a PostgreSQL array
17060
17073
  * literal `{...}` (and `[]` as `{}`), which a `json` / `jsonb` column
17061
17074
  * rejects — so we bind the value as JSON text instead, the form those
17062
- * columns accept. Columns listed in `nativeArrayColumns` are exempt:
17063
- * their raw array is passed through so node-pg emits the `{...}` literal
17064
- * a genuine `JSONB[]` / `TEXT[]` column needs.
17075
+ * columns accept. Columns known to be native arrays — via schema
17076
+ * introspection or the `nativeArrayColumns` config are exempt: their raw
17077
+ * array is passed through so node-pg emits the `{...}` literal a genuine
17078
+ * `JSONB[]` / `TEXT[]` column needs.
17065
17079
  * - plain object → `JSON.stringify`. Equivalent to node-pg's own object
17066
17080
  * handling, made explicit so both write paths agree.
17067
17081
  * - everything else (scalars: string, number, boolean, null) → untouched.
17068
17082
  *
17069
- * Boundary note: the serializer has no access to the table schema, so it
17070
- * cannot tell a `json` / `jsonb` column from a native-array column purely
17071
- * from the value. `nativeArrayColumns` is the explicit, opt-in escape hatch
17072
- * for the latter. No `::jsonb` placeholder cast is added: a JSON-text string
17073
- * binds correctly to `json` / `jsonb` without one, and a blind cast would
17074
- * misfire on columns we cannot positively identify as jsonb.
17083
+ * Distinguishing native-array from `json` / `jsonb` columns: a value alone
17084
+ * can't tell them apart, so the driver introspects the live schema on connect
17085
+ * (see {@link loadNativeArrayColumns}) and consults that per-table map here
17086
+ * via {@link isNativeArrayColumn}. The explicit `nativeArrayColumns` config
17087
+ * still works as a table-agnostic override. No `::jsonb` placeholder cast is
17088
+ * added: a JSON-text string binds correctly to `json` / `jsonb` without one,
17089
+ * and a blind cast would misfire on columns we cannot positively identify as
17090
+ * jsonb.
17075
17091
  *
17076
- * @param key - Column name (used to honour `nativeArrayColumns`)
17092
+ * @param key - Column name (used to resolve native-array columns)
17077
17093
  * @param value - The raw value to serialize (never `undefined`)
17094
+ * @param table - Optional table name; enables the per-table native-array lookup
17078
17095
  * @returns The value ready to bind as a query parameter
17079
17096
  */
17080
- serializeValue(key, value) {
17097
+ serializeValue(key, value, table) {
17081
17098
  if (value instanceof Date) return value.toISOString();
17082
17099
  if (typeof value === "bigint") return value.toString();
17083
17100
  if (Array.isArray(value)) {
17084
17101
  if (value.length > 0 && value.every((v) => typeof v === "number")) return `[${value.join(",")}]`;
17085
- if (this._nativeArrayColumns.has(key)) return value;
17102
+ if (this.isNativeArrayColumn(table, key)) return value;
17086
17103
  return JSON.stringify(value);
17087
17104
  }
17088
17105
  if (typeof value === "object" && value !== null) return JSON.stringify(value);
17089
17106
  return value;
17090
17107
  }
17091
17108
  /**
17109
+ * Whether `column` on `table` is a native PostgreSQL array. True when the
17110
+ * connect-time schema introspection saw it as `data_type = 'ARRAY'` for that
17111
+ * table (authoritative, per-table), or when it's listed in the table-agnostic
17112
+ * `nativeArrayColumns` config override.
17113
+ */
17114
+ isNativeArrayColumn(table, column) {
17115
+ if (table && this._introspectedArrayColumns.get(table)?.has(column)) return true;
17116
+ return this._nativeArrayColumns.has(column);
17117
+ }
17118
+ /**
17119
+ * Introspect the live schema for native-array columns so array values bind
17120
+ * correctly with zero app configuration.
17121
+ *
17122
+ * A JS array must be bound two opposite ways depending on the column: as JSON
17123
+ * text for a `json` / `jsonb` column, but as a raw array (which node-pg
17124
+ * renders `{...}`) for a native `TEXT[]` / `JSONB[]` / `INTEGER[]` column. The
17125
+ * serializer sees values, not types, so without this it JSON-stringifies
17126
+ * every array — which a native-array column rejects with "malformed array
17127
+ * literal". One `information_schema` query at connect, cached for the
17128
+ * connection lifetime, removes the need to hand-list `nativeArrayColumns`.
17129
+ *
17130
+ * Best-effort: any failure (e.g. restricted catalog access) is logged and
17131
+ * leaves the map empty so the config override still applies — it never blocks
17132
+ * connect. A schema change made within a live connection isn't reflected
17133
+ * until the next connect.
17134
+ */
17135
+ async loadNativeArrayColumns() {
17136
+ try {
17137
+ const result = await this.query(`SELECT table_name, column_name
17138
+ FROM information_schema.columns
17139
+ WHERE table_schema = ANY (current_schemas(false))
17140
+ AND data_type = 'ARRAY'`);
17141
+ const map = /* @__PURE__ */ new Map();
17142
+ for (const { table_name, column_name } of result.rows) {
17143
+ let columns = map.get(table_name);
17144
+ if (!columns) {
17145
+ columns = /* @__PURE__ */ new Set();
17146
+ map.set(table_name, columns);
17147
+ }
17148
+ columns.add(column_name);
17149
+ }
17150
+ this._introspectedArrayColumns = map;
17151
+ } catch {
17152
+ _warlock_js_logger.log.warn("database.postgres", "introspection", "Could not introspect native-array columns; using the nativeArrayColumns config only");
17153
+ }
17154
+ }
17155
+ /**
17092
17156
  * Get the dirty tracker for this driver.
17093
17157
  */
17094
17158
  getDirtyTracker(data) {
@@ -17137,7 +17201,7 @@ var PostgresDriver = class {
17137
17201
  * @returns The inserted document
17138
17202
  */
17139
17203
  async insert(table, document, _options) {
17140
- const serialized = this.serialize(document);
17204
+ const serialized = this.serialize(document, table);
17141
17205
  const filteredData = Object.fromEntries(Object.entries(serialized).filter(([key, value]) => {
17142
17206
  if (key === "id" && (value === null || value === void 0)) return false;
17143
17207
  return true;
@@ -17164,7 +17228,7 @@ var PostgresDriver = class {
17164
17228
  if (documents.length === 0) return [];
17165
17229
  const allColumns = /* @__PURE__ */ new Set();
17166
17230
  for (const doc of documents) {
17167
- const serialized = this.serialize(doc);
17231
+ const serialized = this.serialize(doc, table);
17168
17232
  Object.keys(serialized).forEach((key) => allColumns.add(key));
17169
17233
  }
17170
17234
  const columns = Array.from(allColumns);
@@ -17174,7 +17238,7 @@ var PostgresDriver = class {
17174
17238
  const params = [];
17175
17239
  let paramIndex = 1;
17176
17240
  for (const doc of documents) {
17177
- const serialized = this.serialize(doc);
17241
+ const serialized = this.serialize(doc, table);
17178
17242
  const rowPlaceholders = [];
17179
17243
  for (const col of columns) if (col in serialized) {
17180
17244
  rowPlaceholders.push(this.dialect.placeholder(paramIndex++));
@@ -17241,7 +17305,7 @@ var PostgresDriver = class {
17241
17305
  * @returns The replaced document or null
17242
17306
  */
17243
17307
  async replace(table, filter, document, _options) {
17244
- const serialized = this.serialize(document);
17308
+ const serialized = this.serialize(document, table);
17245
17309
  const columns = Object.keys(serialized);
17246
17310
  const values = Object.values(serialized);
17247
17311
  const quotedTable = this.dialect.quoteIdentifier(table);
@@ -17263,7 +17327,7 @@ var PostgresDriver = class {
17263
17327
  * @returns The upserted row
17264
17328
  */
17265
17329
  async upsert(table, filter, document, options) {
17266
- const serialized = this.serialize(document);
17330
+ const serialized = this.serialize(document, table);
17267
17331
  const columns = Object.keys(serialized);
17268
17332
  const values = Object.values(serialized);
17269
17333
  if (columns.length === 0) throw new Error("Cannot upsert empty document");
@@ -17389,13 +17453,14 @@ var PostgresDriver = class {
17389
17453
  * @throws {Error} If transaction fails or is explicitly rolled back
17390
17454
  */
17391
17455
  async transaction(fn, options) {
17392
- if (databaseTransactionContext.hasActiveTransaction()) {}
17456
+ const ctx = { rollback(reason) {
17457
+ throw new TransactionRollbackError(reason);
17458
+ } };
17459
+ if (databaseTransactionContext.hasActiveTransaction()) return fn(ctx);
17393
17460
  const tx = await this.beginTransaction(options);
17394
17461
  databaseTransactionContext.enter({ session: tx.context });
17395
17462
  try {
17396
- const result = await fn({ rollback(reason) {
17397
- throw new TransactionRollbackError(reason);
17398
- } });
17463
+ const result = await fn(ctx);
17399
17464
  await tx.commit();
17400
17465
  return result;
17401
17466
  } catch (error) {
@@ -17557,7 +17622,7 @@ var PostgresDriver = class {
17557
17622
  let paramIndex = 1;
17558
17623
  if (update.$set) for (const [key, value] of Object.entries(update.$set)) {
17559
17624
  setClauses.push(`${this.dialect.quoteIdentifier(key)} = ${this.dialect.placeholder(paramIndex++)}`);
17560
- params.push(value === void 0 ? value : this.serializeValue(key, value));
17625
+ params.push(value === void 0 ? value : this.serializeValue(key, value, table));
17561
17626
  }
17562
17627
  if (update.$unset) for (const key of Object.keys(update.$unset)) setClauses.push(`${this.dialect.quoteIdentifier(key)} = NULL`);
17563
17628
  if (update.$inc) for (const [key, amount] of Object.entries(update.$inc)) {