@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.
- package/CHANGELOG.md +18 -0
- package/cjs/index.cjs +106 -17
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/query-builder.contract.d.mts +7 -0
- package/esm/contracts/query-builder.contract.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.d.mts +5 -0
- package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.mjs +9 -0
- package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-query-builder.d.mts +5 -0
- package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-query-builder.mjs +15 -0
- package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
- package/esm/index.d.mts +2 -2
- package/esm/migration/index.d.mts +1 -1
- package/esm/migration/migration-order.mjs +6 -3
- package/esm/migration/migration-order.mjs.map +1 -1
- package/esm/migration/migration-runner.d.mts +5 -3
- package/esm/migration/migration-runner.d.mts.map +1 -1
- package/esm/migration/migration-runner.mjs +3 -2
- package/esm/migration/migration-runner.mjs.map +1 -1
- package/esm/migration/migration.d.mts +11 -1
- package/esm/migration/migration.d.mts.map +1 -1
- package/esm/migration/migration.mjs +45 -2
- package/esm/migration/migration.mjs.map +1 -1
- package/esm/model/methods/query-methods.mjs +13 -9
- package/esm/model/methods/query-methods.mjs.map +1 -1
- package/esm/model/model.d.mts +2 -2
- package/esm/model/model.d.mts.map +1 -1
- package/esm/model/model.mjs +1 -1
- package/esm/model/model.mjs.map +1 -1
- package/esm/query-builder/query-builder.d.mts +5 -0
- package/esm/query-builder/query-builder.d.mts.map +1 -1
- package/esm/query-builder/query-builder.mjs +15 -0
- package/esm/query-builder/query-builder.mjs.map +1 -1
- package/llms-full.txt +11 -1
- 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()
|
|
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()
|
|
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 =
|
|
5385
|
-
const ids = await ModelClass.query()
|
|
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
|
-
|
|
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.
|
|
24267
|
-
* 2. `
|
|
24268
|
-
* 3.
|
|
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
|
|
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
|
}
|