@warlock.js/cascade 5.25.0 → 5.27.0

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 (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/cjs/index.cjs +106 -17
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/contracts/query-builder.contract.d.mts +7 -0
  5. package/esm/contracts/query-builder.contract.d.mts.map +1 -1
  6. package/esm/drivers/mongodb/mongodb-query-builder.d.mts +5 -0
  7. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  8. package/esm/drivers/mongodb/mongodb-query-builder.mjs +9 -0
  9. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  10. package/esm/drivers/postgres/postgres-query-builder.d.mts +5 -0
  11. package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
  12. package/esm/drivers/postgres/postgres-query-builder.mjs +15 -0
  13. package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
  14. package/esm/index.d.mts +2 -2
  15. package/esm/migration/index.d.mts +1 -1
  16. package/esm/migration/migration-order.mjs +6 -3
  17. package/esm/migration/migration-order.mjs.map +1 -1
  18. package/esm/migration/migration-runner.d.mts +5 -3
  19. package/esm/migration/migration-runner.d.mts.map +1 -1
  20. package/esm/migration/migration-runner.mjs +3 -2
  21. package/esm/migration/migration-runner.mjs.map +1 -1
  22. package/esm/migration/migration.d.mts +11 -1
  23. package/esm/migration/migration.d.mts.map +1 -1
  24. package/esm/migration/migration.mjs +45 -2
  25. package/esm/migration/migration.mjs.map +1 -1
  26. package/esm/model/methods/query-methods.mjs +13 -9
  27. package/esm/model/methods/query-methods.mjs.map +1 -1
  28. package/esm/model/model.d.mts +2 -2
  29. package/esm/model/model.d.mts.map +1 -1
  30. package/esm/model/model.mjs +1 -1
  31. package/esm/model/model.mjs.map +1 -1
  32. package/esm/query-builder/query-builder.d.mts +5 -0
  33. package/esm/query-builder/query-builder.d.mts.map +1 -1
  34. package/esm/query-builder/query-builder.mjs +15 -0
  35. package/esm/query-builder/query-builder.mjs.map +1 -1
  36. package/llms-full.txt +11 -1
  37. package/package.json +6 -6
package/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ 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
+ ## 5.27.0 - 2026-10-01
8
+
9
+ ### Changed
10
+
11
+ - Dependency: @mongez/supportive-is 3.0 (type-predicate checkers).
12
+
13
+ ## 5.26.0 - 2026-09-30
14
+
15
+ ### Added
16
+
17
+ - `whereTrusted(conditions)` on the query builder contract applies a code-authored filter without the unsafe-operator check, so MongoDB operators such as `$in` and `$lt` pass through. Never pass request input to it. The PostgreSQL builder throws `UnsupportedQueryOperationError` for operator objects, because its `where()` parser would otherwise bind them as plain values. Custom query builders must implement the new method.
18
+ - Migrations can be registered with an origin (`"package"` or `"app"`). Package migrations run before every app migration while preserving the existing `order`, `createdAt`, and name sort within each partition. Direct `register()` calls default to `"app"`.
19
+
20
+ ### Fixed
21
+
22
+ - Migrations now reject colliding unnamed index definitions before sending DDL to MongoDB or PostgreSQL, with guidance to provide an explicit index name. Declarative alter migrations with a custom `up()` but no `down()` now emit one rollback warning.
23
+ - `trustedFilter: true` now works on `atomic()`, `findOneAndUpdate()` and `findAndUpdate()` with an operator filter such as `{ id: { $in: ids } }`. The scoped-write lookup re-ran the filter through `where()`, which rejected the operator, so these calls threw `UnsafeFilterError` even when the filter was trusted. Global scopes (tenant, soft delete) still apply to the write. `findAndUpdate()` now accepts `trustedFilter`.
24
+
7
25
  ## 5.25.0 - 2026-09-28
8
26
 
9
27
  ### Added
package/cjs/index.cjs CHANGED
@@ -5309,6 +5309,10 @@ function toDriverOptions(options) {
5309
5309
  const { trustedFilter: _trustedFilter, ...rest } = options;
5310
5310
  return rest;
5311
5311
  }
5312
+ function applyWriteFilter(query, filter, trustedFilter) {
5313
+ if (trustedFilter && typeof query.whereTrusted === "function") return query.whereTrusted(filter);
5314
+ return query.where(filter);
5315
+ }
5312
5316
  /**
5313
5317
  * Add the same generated `id` that normal Mongo inserts receive to an upsert's
5314
5318
  * insert branch. `$setOnInsert` keeps an existing document's id immutable.
@@ -5335,9 +5339,9 @@ async function withAutoGeneratedUpsertId(ModelClass, filter, operations, options
5335
5339
  * so the target ids are resolved through the scoped query builder and added to
5336
5340
  * the filter. `null` means no visible row matches: the write must be a no-op.
5337
5341
  */
5338
- async function scopeWriteFilter(ModelClass, filter, single) {
5342
+ async function scopeWriteFilter(ModelClass, filter, single, trustedFilter = false) {
5339
5343
  const primaryKey = ModelClass.primaryKey;
5340
- const ids = await ModelClass.query().where(filter).pluck(primaryKey);
5344
+ const ids = await applyWriteFilter(ModelClass.query(), filter, trustedFilter).pluck(primaryKey);
5341
5345
  if (ids.length === 0) return null;
5342
5346
  return {
5343
5347
  ...filter,
@@ -5349,10 +5353,10 @@ async function scopeWriteFilter(ModelClass, filter, single) {
5349
5353
  * the driver would insert or, worse, update a row a scope hides (another
5350
5354
  * tenant's, a soft-deleted one), so a hidden match is refused.
5351
5355
  */
5352
- async function scopeUpsertFilter(ModelClass, filter) {
5353
- const scoped = await scopeWriteFilter(ModelClass, filter, true);
5356
+ async function scopeUpsertFilter(ModelClass, filter, trustedFilter = false) {
5357
+ const scoped = await scopeWriteFilter(ModelClass, filter, true, trustedFilter);
5354
5358
  if (scoped) return scoped;
5355
- if (await ModelClass.query().withoutGlobalScopes().where(filter).exists()) throw new Error(`${ModelClass.name}: upsert matches a row hidden by a global scope (tenant or soft delete); refusing to touch it.`);
5359
+ if (await applyWriteFilter(ModelClass.query().withoutGlobalScopes(), filter, trustedFilter).exists()) throw new Error(`${ModelClass.name}: upsert matches a row hidden by a global scope (tenant or soft delete); refusing to touch it.`);
5356
5360
  return filter;
5357
5361
  }
5358
5362
  /**
@@ -5361,7 +5365,7 @@ async function scopeUpsertFilter(ModelClass, filter) {
5361
5365
  */
5362
5366
  async function performAtomic(ModelClass, filter, operations, options) {
5363
5367
  const resolved = resolveFilter(filter, options);
5364
- const scoped = options?.upsert ? await scopeUpsertFilter(ModelClass, resolved) : await scopeWriteFilter(ModelClass, resolved, false);
5368
+ const scoped = options?.upsert ? await scopeUpsertFilter(ModelClass, resolved, options?.trustedFilter) : await scopeWriteFilter(ModelClass, resolved, false, options?.trustedFilter);
5365
5369
  if (!scoped) return 0;
5366
5370
  const upsertOperations = await withAutoGeneratedUpsertId(ModelClass, scoped, operations, options);
5367
5371
  const result = await ModelClass.getDriver().atomic(ModelClass.table, scoped, upsertOperations, toDriverOptions(options));
@@ -5381,8 +5385,8 @@ async function updateById(ModelClass, id, data) {
5381
5385
  }
5382
5386
  async function findAndUpdateRecords(ModelClass, filter, update, options) {
5383
5387
  const primaryKey = ModelClass.primaryKey;
5384
- const safeFilter = sanitizeFilter(filter);
5385
- const ids = await ModelClass.query().where(safeFilter).pluck(primaryKey);
5388
+ const safeFilter = resolveFilter(filter, options);
5389
+ const ids = await applyWriteFilter(ModelClass.query(), safeFilter, options?.trustedFilter ?? false).pluck(primaryKey);
5386
5390
  if (ids.length === 0) {
5387
5391
  if (!options?.upsert) return [];
5388
5392
  await performAtomic(ModelClass, safeFilter, update, {
@@ -5399,7 +5403,7 @@ async function findAndUpdateRecords(ModelClass, filter, update, options) {
5399
5403
  }
5400
5404
  async function findOneAndUpdateRecord(ModelClass, filter, update, options) {
5401
5405
  const resolved = resolveFilter(filter, options);
5402
- const scoped = options?.upsert ? await scopeUpsertFilter(ModelClass, resolved) : await scopeWriteFilter(ModelClass, resolved, true);
5406
+ const scoped = options?.upsert ? await scopeUpsertFilter(ModelClass, resolved, options?.trustedFilter) : await scopeWriteFilter(ModelClass, resolved, true, options?.trustedFilter);
5403
5407
  if (!scoped) return null;
5404
5408
  const upsertUpdate = await withAutoGeneratedUpsertId(ModelClass, scoped, update, options);
5405
5409
  const result = await ModelClass.getDriver().findOneAndUpdate(ModelClass.table, scoped, upsertUpdate, toDriverOptions(options));
@@ -7296,7 +7300,7 @@ var Model = class Model {
7296
7300
  * Find one and update multiple records that matches the provided filter and return the updated record
7297
7301
  * @param filter - Filter conditions
7298
7302
  * @param update - Update operators, or an aggregation pipeline (MongoDB only)
7299
- * @param options - `upsert`, `arrayFilters` (MongoDB only)
7303
+ * @param options - `upsert`, `arrayFilters` (MongoDB only), `trustedFilter`
7300
7304
  * @returns The updated records
7301
7305
  */
7302
7306
  static async findAndUpdate(filter, update, options) {
@@ -9505,6 +9509,21 @@ var QueryBuilder = class QueryBuilder {
9505
9509
  }
9506
9510
  return this;
9507
9511
  }
9512
+ /**
9513
+ * Add a code-authored object filter without the `$`-operator safety guard.
9514
+ * Never use this with request input; callers must use where() for that.
9515
+ */
9516
+ whereTrusted(conditions) {
9517
+ for (const [field, value] of Object.entries(conditions)) {
9518
+ assertDefined(value, field);
9519
+ this.addOperation("where", {
9520
+ field,
9521
+ operator: "=",
9522
+ value
9523
+ });
9524
+ }
9525
+ return this;
9526
+ }
9508
9527
  orWhere(...args) {
9509
9528
  if (args.length === 1 && typeof args[0] === "function") {
9510
9529
  const sub = this.subQuery();
@@ -12375,6 +12394,15 @@ var MongoQueryBuilder = class MongoQueryBuilder extends QueryBuilder {
12375
12394
  this.addWhereClause("where", args);
12376
12395
  return this;
12377
12396
  }
12397
+ /**
12398
+ * Add a code-authored MongoDB filter without the `$`-operator safety guard.
12399
+ * Never use this with request input; callers must use where() for that.
12400
+ */
12401
+ whereTrusted(conditions) {
12402
+ for (const [field, value] of Object.entries(conditions)) assertDefined(value, field);
12403
+ this.operationsHelper.addMatchOperation("where:object", conditions);
12404
+ return this;
12405
+ }
12378
12406
  orWhere(...args) {
12379
12407
  this.addWhereClause("orWhere", args);
12380
12408
  return this;
@@ -17475,6 +17503,13 @@ function parseAggregateValue(value) {
17475
17503
  function toParserOps(ops) {
17476
17504
  return ops;
17477
17505
  }
17506
+ function containsOperatorKey(value) {
17507
+ if (Array.isArray(value)) return value.some(containsOperatorKey);
17508
+ if (value === null || typeof value !== "object") return false;
17509
+ const prototype = Object.getPrototypeOf(value);
17510
+ if (prototype !== Object.prototype && prototype !== null) return false;
17511
+ return Object.entries(value).some(([key, nested]) => key.startsWith("$") || containsOperatorKey(nested));
17512
+ }
17478
17513
  /**
17479
17514
  * PostgreSQL Query Builder.
17480
17515
  *
@@ -17495,6 +17530,14 @@ var PostgresQueryBuilder = class PostgresQueryBuilder extends QueryBuilder {
17495
17530
  table;
17496
17531
  /** Data source backing this builder. */
17497
17532
  dataSource;
17533
+ /**
17534
+ * PostgreSQL has no parser for MongoDB-style object operators such as
17535
+ * `{ id: { $in: [1, 2] } }`; accepting one would bind the object as `=`.
17536
+ */
17537
+ whereTrusted(conditions) {
17538
+ if (containsOperatorKey(conditions)) throw new UnsupportedQueryOperationError("whereTrusted with MongoDB-style operators", "postgres", "Use the portable whereIn()/where(field, operator, value) APIs instead.");
17539
+ return super.whereTrusted(conditions);
17540
+ }
17498
17541
  /** Hydration callback for transforming result rows into model instances. */
17499
17542
  hydrateCallback;
17500
17543
  /** Invoked before query execution. */
@@ -22239,6 +22282,7 @@ var Migration = class {
22239
22282
  * @internal
22240
22283
  */
22241
22284
  async execute() {
22285
+ this.assertNoGeneratedIndexNameCollisions();
22242
22286
  for (const op of this.pendingOperations) await this.executeOperation(op);
22243
22287
  this.pendingOperations.length = 0;
22244
22288
  }
@@ -22275,6 +22319,7 @@ var Migration = class {
22275
22319
  * @internal
22276
22320
  */
22277
22321
  toSteps() {
22322
+ this.assertNoGeneratedIndexNameCollisions();
22278
22323
  const serializer = this.driver.driver.getSQLSerializer();
22279
22324
  const steps = [];
22280
22325
  let schemaRun = [];
@@ -22422,6 +22467,40 @@ var Migration = class {
22422
22467
  }
22423
22468
  }
22424
22469
  /**
22470
+ * Reject two unnamed indexes that a driver would give the same name before
22471
+ * either DDL command is sent. MongoDB's default names include directions;
22472
+ * PostgreSQL's include the table and columns.
22473
+ */
22474
+ assertNoGeneratedIndexNameCollisions() {
22475
+ const engine = this.databaseEngine;
22476
+ if (engine !== "mongodb" && engine !== "postgres") return;
22477
+ const unnamedIndexes = this.pendingOperations.flatMap((operation) => {
22478
+ if (operation.type === "createIndex") {
22479
+ const index = operation.payload;
22480
+ return index.name ? [] : [index];
22481
+ }
22482
+ if (operation.type === "createUniqueIndex") {
22483
+ const { columns, name } = operation.payload;
22484
+ return name ? [] : [{
22485
+ columns,
22486
+ unique: true
22487
+ }];
22488
+ }
22489
+ return [];
22490
+ });
22491
+ const seen = /* @__PURE__ */ new Map();
22492
+ for (const index of unnamedIndexes) {
22493
+ const generatedName = engine === "mongodb" ? index.columns.map((column, i) => `${column}_${index.directions?.[i] === "desc" ? -1 : 1}`).join("_") : `idx_${this.table}_${index.columns.join("_")}`;
22494
+ const first = seen.get(generatedName);
22495
+ if (first) throw new Error(`Migration queued two ${engine} indexes with the generated name "${generatedName}": ${this.describeIndex(first)} and ${this.describeIndex(index)}. Give one index an explicit name, for example { name: "..." }.`);
22496
+ seen.set(generatedName, index);
22497
+ }
22498
+ }
22499
+ describeIndex(index) {
22500
+ const partial = index.where ? JSON.stringify(index.where) : "none";
22501
+ return `columns [${index.columns.join(", ")}] (unique: ${!!index.unique}, partial: ${partial})`;
22502
+ }
22503
+ /**
22425
22504
  * Check if a table exists.
22426
22505
  *
22427
22506
  * Useful for conditional migrations and idempotent operations.
@@ -24128,13 +24207,18 @@ Migration.create = function createMigration(model, columns, options = {}) {
24128
24207
  */
24129
24208
  Migration.alter = function alterMigration(model, schema, options = {}) {
24130
24209
  const { order = 0, createdAt, transactional } = options;
24131
- return class AlterMigration extends Migration {
24210
+ let missingDownWarningEmitted = false;
24211
+ class AlterMigration extends Migration {
24132
24212
  static order = order;
24133
24213
  static createdAt = createdAt;
24134
24214
  static transactional = transactional;
24135
24215
  table = model.table;
24136
24216
  dataSource = model.dataSource;
24137
24217
  async up() {
24218
+ if (options.up && !options.down && !missingDownWarningEmitted) {
24219
+ missingDownWarningEmitted = true;
24220
+ _warlock_js_logger.log.warn("database", "migration", `Migration "${AlterMigration.migrationName ?? this.table}" defines a custom up() without a down() rollback.`);
24221
+ }
24138
24222
  if (schema.add) wireColumns(this, schema.add);
24139
24223
  if (schema.drop) for (const col of schema.drop) this.dropColumn(col);
24140
24224
  if (schema.rename) for (const [from, to] of Object.entries(schema.rename)) this.renameColumn(from, to);
@@ -24187,7 +24271,8 @@ Migration.alter = function alterMigration(model, schema, options = {}) {
24187
24271
  async down() {
24188
24272
  if (options.down) await options.down.call(this);
24189
24273
  }
24190
- };
24274
+ }
24275
+ return AlterMigration;
24191
24276
  };
24192
24277
  Migration.__declarativeFactoriesAttached = true;
24193
24278
 
@@ -24263,11 +24348,14 @@ function compareCreatedAt(a, b) {
24263
24348
  * Comparator for applying migrations — oldest first.
24264
24349
  *
24265
24350
  * Priority:
24266
- * 1. `order` override (lower = earlier, default 0)
24267
- * 2. `createdAt` timestamp (older = earlier)
24268
- * 3. Alphabetical by migration name (last resort)
24351
+ * 1. package origin before app origin
24352
+ * 2. `order` override (lower = earlier, default 0)
24353
+ * 3. `createdAt` timestamp (older = earlier)
24354
+ * 4. Alphabetical by migration name (last resort)
24269
24355
  */
24270
24356
  function sortMigrations(a, b) {
24357
+ const byOrigin = Number(a.origin === "app") - Number(b.origin === "app");
24358
+ if (byOrigin !== 0) return byOrigin;
24271
24359
  const byOrder = (a.order ?? 0) - (b.order ?? 0);
24272
24360
  if (byOrder !== 0) return byOrder;
24273
24361
  const byCreatedAt = compareCreatedAt(a.createdAt, b.createdAt);
@@ -24643,7 +24731,7 @@ var MigrationRunner = class {
24643
24731
  * The migration name is read from `MigrationClass.migrationName`.
24644
24732
  *
24645
24733
  * @param MigrationClass - Migration class (must have static `name` set)
24646
- * @param createdAt - Optional timestamp for ordering
24734
+ * @param origin - Registration source; defaults to `"app"` for direct registrations.
24647
24735
  * @returns This runner for chaining
24648
24736
  *
24649
24737
  * @example
@@ -24652,7 +24740,7 @@ var MigrationRunner = class {
24652
24740
  * runner.register(CreateUsersTable);
24653
24741
  * ```
24654
24742
  */
24655
- register(MigrationClass) {
24743
+ register(MigrationClass, origin = "app") {
24656
24744
  const name = MigrationClass.migrationName;
24657
24745
  if (!name) throw new Error("Migration class must have a static 'migrationName' property set. Set it in CLI after importing: MigrationClass.migrationName = \"filename\";");
24658
24746
  const existing = this.migrations.find((m) => m.migrationName === name);
@@ -24660,6 +24748,7 @@ var MigrationRunner = class {
24660
24748
  if (existing !== MigrationClass) throw new Error(`Duplicate migration name "${name}": two different migration classes resolve to the same name. Rename one of the files (or set a unique static 'migrationName').`);
24661
24749
  return this;
24662
24750
  }
24751
+ MigrationClass.origin = origin;
24663
24752
  this.migrations.push(MigrationClass);
24664
24753
  return this;
24665
24754
  }