@warlock.js/cascade 5.11.0 → 5.13.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 (68) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/cjs/index.cjs +685 -128
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/contracts/database-driver.contract.d.mts +44 -7
  5. package/esm/contracts/database-driver.contract.d.mts.map +1 -1
  6. package/esm/contracts/index.d.mts +2 -2
  7. package/esm/contracts/query-builder.contract.d.mts +61 -1
  8. package/esm/contracts/query-builder.contract.d.mts.map +1 -1
  9. package/esm/drivers/mongodb/mongodb-driver.d.mts +8 -4
  10. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  11. package/esm/drivers/mongodb/mongodb-driver.mjs +16 -6
  12. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  13. package/esm/drivers/mongodb/mongodb-query-builder.d.mts +32 -7
  14. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  15. package/esm/drivers/mongodb/mongodb-query-builder.mjs +56 -8
  16. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  17. package/esm/drivers/mongodb/mongodb-query-parser.d.mts +39 -12
  18. package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
  19. package/esm/drivers/mongodb/mongodb-query-parser.mjs +143 -55
  20. package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
  21. package/esm/drivers/mongodb/mongodb-update-translator.mjs +25 -0
  22. package/esm/drivers/mongodb/mongodb-update-translator.mjs.map +1 -0
  23. package/esm/drivers/mongodb/pipeline-stage-object.mjs +24 -0
  24. package/esm/drivers/mongodb/pipeline-stage-object.mjs.map +1 -0
  25. package/esm/drivers/mongodb/types.d.mts +7 -1
  26. package/esm/drivers/mongodb/types.d.mts.map +1 -1
  27. package/esm/drivers/postgres/postgres-driver.d.mts +52 -9
  28. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  29. package/esm/drivers/postgres/postgres-driver.mjs +170 -38
  30. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  31. package/esm/drivers/postgres/postgres-query-builder.d.mts +17 -1
  32. package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
  33. package/esm/drivers/postgres/postgres-query-builder.mjs +31 -0
  34. package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
  35. package/esm/drivers/postgres/postgres-update-validator.mjs +36 -0
  36. package/esm/drivers/postgres/postgres-update-validator.mjs.map +1 -0
  37. package/esm/errors/unsupported-lean-operation.error.d.mts +25 -0
  38. package/esm/errors/unsupported-lean-operation.error.d.mts.map +1 -0
  39. package/esm/errors/unsupported-lean-operation.error.mjs +31 -0
  40. package/esm/errors/unsupported-lean-operation.error.mjs.map +1 -0
  41. package/esm/errors/unsupported-query-operation.error.d.mts +30 -0
  42. package/esm/errors/unsupported-query-operation.error.d.mts.map +1 -0
  43. package/esm/errors/unsupported-query-operation.error.mjs +37 -0
  44. package/esm/errors/unsupported-query-operation.error.mjs.map +1 -0
  45. package/esm/errors/unsupported-update-operation.error.d.mts +30 -0
  46. package/esm/errors/unsupported-update-operation.error.d.mts.map +1 -0
  47. package/esm/errors/unsupported-update-operation.error.mjs +37 -0
  48. package/esm/errors/unsupported-update-operation.error.mjs.map +1 -0
  49. package/esm/index.d.mts +6 -3
  50. package/esm/index.mjs +4 -1
  51. package/esm/model/methods/query-methods.mjs +22 -6
  52. package/esm/model/methods/query-methods.mjs.map +1 -1
  53. package/esm/model/model.d.mts +28 -11
  54. package/esm/model/model.d.mts.map +1 -1
  55. package/esm/model/model.mjs +30 -13
  56. package/esm/model/model.mjs.map +1 -1
  57. package/esm/query-builder/lean-records.mjs +34 -0
  58. package/esm/query-builder/lean-records.mjs.map +1 -0
  59. package/esm/query-builder/query-builder.d.mts +13 -1
  60. package/esm/query-builder/query-builder.d.mts.map +1 -1
  61. package/esm/query-builder/query-builder.mjs +16 -0
  62. package/esm/query-builder/query-builder.mjs.map +1 -1
  63. package/llms-full.txt +72 -5
  64. package/llms.txt +3 -3
  65. package/package.json +4 -4
  66. package/skills/aggregate-data/SKILL.md +24 -1
  67. package/skills/perform-atomic-ops/SKILL.md +38 -3
  68. package/skills/query-data/SKILL.md +10 -1
package/cjs/index.cjs CHANGED
@@ -475,6 +475,105 @@ var UnsafeRawExpressionError = class UnsafeRawExpressionError extends Error {
475
475
  }
476
476
  };
477
477
 
478
+ //#endregion
479
+ //#region ../cascade/src/errors/unsupported-lean-operation.error.ts
480
+ /**
481
+ * Error thrown when a query in lean read mode (`.lean()`) asks for something
482
+ * that needs hydrated Model instances — eager-loading relations with `with()`
483
+ * or `joinWith()`.
484
+ *
485
+ * Relations are attached to Model instances and shaped (including the related
486
+ * model's `hidden` fields) by the Model layer, so lean mode refuses them rather
487
+ * than returning a half-loaded or unfiltered result.
488
+ */
489
+ var UnsupportedLeanOperationError = class UnsupportedLeanOperationError extends Error {
490
+ /**
491
+ * The rejected builder operation (e.g. `"with"`, `"joinWith"`).
492
+ */
493
+ operation;
494
+ /**
495
+ * Creates a new UnsupportedLeanOperationError.
496
+ *
497
+ * @param operation - The rejected builder operation
498
+ */
499
+ constructor(operation) {
500
+ super(`${operation}() cannot be combined with lean(): eager-loaded relations need hydrated models. Drop lean() for this query, or load the related records with a second lean query.`);
501
+ this.name = "UnsupportedLeanOperationError";
502
+ this.operation = operation;
503
+ if (Error.captureStackTrace) Error.captureStackTrace(this, UnsupportedLeanOperationError);
504
+ }
505
+ };
506
+
507
+ //#endregion
508
+ //#region ../cascade/src/errors/unsupported-query-operation.error.ts
509
+ /**
510
+ * Error thrown when a query builder is asked for a read operation its driver
511
+ * cannot express — e.g. a MongoDB-only aggregation stage such as `unwind()` or
512
+ * `addFields()` on a SQL driver.
513
+ *
514
+ * Builders throw this instead of silently dropping the stage, so a caller
515
+ * never receives rows shaped differently from what the query asked for.
516
+ */
517
+ var UnsupportedQueryOperationError = class UnsupportedQueryOperationError extends Error {
518
+ /**
519
+ * The operation that was rejected (e.g. `"unwind"`, `"addFields"`).
520
+ */
521
+ operation;
522
+ /**
523
+ * The driver that rejected it (e.g. `"postgres"`).
524
+ */
525
+ driver;
526
+ /**
527
+ * Creates a new UnsupportedQueryOperationError.
528
+ *
529
+ * @param operation - The rejected operation
530
+ * @param driver - The driver name
531
+ * @param hint - Optional guidance appended to the message
532
+ */
533
+ constructor(operation, driver, hint) {
534
+ super(`The ${driver} driver does not support ${operation}() in queries.` + (hint ? ` ${hint}` : ""));
535
+ this.name = "UnsupportedQueryOperationError";
536
+ this.operation = operation;
537
+ this.driver = driver;
538
+ if (Error.captureStackTrace) Error.captureStackTrace(this, UnsupportedQueryOperationError);
539
+ }
540
+ };
541
+
542
+ //#endregion
543
+ //#region ../cascade/src/errors/unsupported-update-operation.error.ts
544
+ /**
545
+ * Error thrown when a driver is asked to perform an update it cannot express
546
+ * natively — e.g. an aggregation-pipeline update, `arrayFilters` or `$addToSet`
547
+ * on a SQL driver, or an upsert whose conflict target cannot be derived.
548
+ *
549
+ * Drivers throw this instead of silently dropping the operation, so a caller
550
+ * never believes a write happened that did not.
551
+ */
552
+ var UnsupportedUpdateOperationError = class UnsupportedUpdateOperationError extends Error {
553
+ /**
554
+ * The operation that was rejected (e.g. `"pipeline update"`, `"$addToSet"`).
555
+ */
556
+ operation;
557
+ /**
558
+ * The driver that rejected it (e.g. `"postgres"`).
559
+ */
560
+ driver;
561
+ /**
562
+ * Creates a new UnsupportedUpdateOperationError.
563
+ *
564
+ * @param operation - The rejected operation
565
+ * @param driver - The driver name
566
+ * @param hint - Optional guidance appended to the message
567
+ */
568
+ constructor(operation, driver, hint) {
569
+ super(`The ${driver} driver does not support ${operation} in atomic updates.` + (hint ? ` ${hint}` : ""));
570
+ this.name = "UnsupportedUpdateOperationError";
571
+ this.operation = operation;
572
+ this.driver = driver;
573
+ if (Error.captureStackTrace) Error.captureStackTrace(this, UnsupportedUpdateOperationError);
574
+ }
575
+ };
576
+
478
577
  //#endregion
479
578
  //#region ../cascade/src/database-dirty-tracker.ts
480
579
  function canBeFlatten(object) {
@@ -4707,18 +4806,34 @@ function increaseField(ModelClass, filter, field, amount) {
4707
4806
  function decreaseField(ModelClass, filter, field, amount) {
4708
4807
  return ModelClass.query().where(filter).decrement(field, amount);
4709
4808
  }
4710
- async function performAtomic(ModelClass, filter, operations) {
4711
- return (await ModelClass.getDriver().atomic(ModelClass.table, sanitizeFilter(filter), operations)).modifiedCount;
4809
+ function resolveFilter(filter, options) {
4810
+ return options?.trustedFilter ? filter : sanitizeFilter(filter);
4811
+ }
4812
+ function toDriverOptions(options) {
4813
+ if (!options) return;
4814
+ const { trustedFilter: _trustedFilter, ...rest } = options;
4815
+ return rest;
4816
+ }
4817
+ /**
4818
+ * Run an atomic update and return the number of documents modified plus the
4819
+ * number inserted by an `upsert`.
4820
+ */
4821
+ async function performAtomic(ModelClass, filter, operations, options) {
4822
+ const result = await ModelClass.getDriver().atomic(ModelClass.table, resolveFilter(filter, options), operations, toDriverOptions(options));
4823
+ return result.modifiedCount + (result.upsertedCount ?? 0);
4712
4824
  }
4713
4825
  async function updateById(ModelClass, id, data) {
4714
4826
  return (await ModelClass.getDriver().update(ModelClass.table, { [ModelClass.primaryKey]: id }, { $set: data })).modifiedCount;
4715
4827
  }
4716
- async function findAndUpdateRecords(ModelClass, filter, update) {
4717
- await performAtomic(ModelClass, filter, update);
4828
+ async function findAndUpdateRecords(ModelClass, filter, update, options) {
4829
+ await performAtomic(ModelClass, filter, update, {
4830
+ ...options,
4831
+ trustedFilter: false
4832
+ });
4718
4833
  return await ModelClass.query().where(filter).get();
4719
4834
  }
4720
- async function findOneAndUpdateRecord(ModelClass, filter, update) {
4721
- const result = await ModelClass.getDriver().findOneAndUpdate(ModelClass.table, sanitizeFilter(filter), update);
4835
+ async function findOneAndUpdateRecord(ModelClass, filter, update, options) {
4836
+ const result = await ModelClass.getDriver().findOneAndUpdate(ModelClass.table, resolveFilter(filter, options), update, toDriverOptions(options));
4722
4837
  if (!result) return null;
4723
4838
  return new ModelClass(result);
4724
4839
  }
@@ -6548,15 +6663,21 @@ var Model = class Model {
6548
6663
  return decreaseField(this, filter, field, amount);
6549
6664
  }
6550
6665
  /**
6551
- * Perform atomic operation
6552
- * Example
6666
+ * Perform an atomic update on every document/row matching the filter.
6667
+ *
6668
+ * @param filter - Filter conditions (operator keys rejected unless `trustedFilter`)
6669
+ * @param operations - Update operators, or an aggregation pipeline (MongoDB only)
6670
+ * @param options - `upsert`, `arrayFilters` (MongoDB only), `trustedFilter`
6671
+ * @returns Number of documents modified plus the number inserted by an upsert
6553
6672
  *
6673
+ * @example
6554
6674
  * ```typescript
6555
- * const user = await User.atomic({id: 1}, {$inc: {age: 1}})
6556
- * Returns user model with updated age
6675
+ * await User.atomic({ id: 1 }, { $inc: { age: 1 } });
6676
+ * await Counter.atomic({ key: "signups" }, { $inc: { count: 1 } }, { upsert: true });
6677
+ * ```
6557
6678
  */
6558
- static async atomic(filter, operations) {
6559
- return performAtomic(this, filter, operations);
6679
+ static async atomic(filter, operations, options) {
6680
+ return performAtomic(this, filter, operations, options);
6560
6681
  }
6561
6682
  /**
6562
6683
  * Perform an atomic update for the given id
@@ -6567,20 +6688,31 @@ var Model = class Model {
6567
6688
  /**
6568
6689
  * Find one and update multiple records that matches the provided filter and return the updated record
6569
6690
  * @param filter - Filter conditions
6570
- * @param update - Update operations ($set, $unset, $inc)
6691
+ * @param update - Update operators, or an aggregation pipeline (MongoDB only)
6692
+ * @param options - `upsert`, `arrayFilters` (MongoDB only)
6571
6693
  * @returns The updated records
6572
6694
  */
6573
- static async findAndUpdate(filter, update) {
6574
- return findAndUpdateRecords(this, filter, update);
6695
+ static async findAndUpdate(filter, update, options) {
6696
+ return findAndUpdateRecords(this, filter, update, options);
6575
6697
  }
6576
6698
  /**
6577
6699
  * Find one and update a single record that matches the provided filter and return the updated record
6578
6700
  * @param filter - Filter conditions
6579
- * @param update - Update operations ($set, $unset, $inc)
6580
- * @returns The updated record or null
6701
+ * @param update - Update operators, or an aggregation pipeline (MongoDB only)
6702
+ * @param options - `upsert`, `returnDocument` (default `"after"`), `arrayFilters`, `trustedFilter`
6703
+ * @returns The record after (or before) the update, or null when nothing matched
6704
+ *
6705
+ * @example
6706
+ * ```typescript
6707
+ * const counter = await Counter.findOneAndUpdate(
6708
+ * { key: "signups" },
6709
+ * { $inc: { count: 1 }, $setOnInsert: { startedAt: new Date() } },
6710
+ * { upsert: true },
6711
+ * );
6712
+ * ```
6581
6713
  */
6582
- static async findOneAndUpdate(filter, update) {
6583
- return findOneAndUpdateRecord(this, filter, update);
6714
+ static async findOneAndUpdate(filter, update, options) {
6715
+ return findOneAndUpdateRecord(this, filter, update, options);
6584
6716
  }
6585
6717
  /**
6586
6718
  * Find and replace the entire document that matches the provided filter and return the replaced document
@@ -8526,6 +8658,36 @@ var MongoMigrationDriver = class {
8526
8658
  }
8527
8659
  };
8528
8660
 
8661
+ //#endregion
8662
+ //#region ../cascade/src/query-builder/lean-records.ts
8663
+ /**
8664
+ * Throw when a lean query also asks for eager-loaded relations.
8665
+ *
8666
+ * Called before the query executes, so nothing is fetched for a request that
8667
+ * cannot be answered.
8668
+ *
8669
+ * @throws UnsupportedLeanOperationError
8670
+ */
8671
+ function assertLeanCompatible(state) {
8672
+ if (state.eagerLoadRelations.size > 0) throw new UnsupportedLeanOperationError("with");
8673
+ if (state.joinRelations && state.joinRelations.size > 0) throw new UnsupportedLeanOperationError("joinWith");
8674
+ }
8675
+ /**
8676
+ * Remove the model's `static hidden` top-level fields from lean rows, in place.
8677
+ *
8678
+ * The rows are fresh objects the driver just built, so mutating them is safe
8679
+ * and avoids a copy per row. Returns the same array.
8680
+ */
8681
+ function stripHiddenFromLeanRecords(records, modelClass) {
8682
+ const hidden = modelClass?.hidden;
8683
+ if (!hidden || hidden.length === 0) return records;
8684
+ for (const record of records) {
8685
+ if (record === null || typeof record !== "object") continue;
8686
+ for (const field of hidden) delete record[field];
8687
+ }
8688
+ return records;
8689
+ }
8690
+
8529
8691
  //#endregion
8530
8692
  //#region ../cascade/src/query-builder/query-builder.ts
8531
8693
  /**
@@ -8580,6 +8742,8 @@ var QueryBuilder = class QueryBuilder {
8580
8742
  relationDefinitions;
8581
8743
  /** The Model class reference, required for relation resolution. */
8582
8744
  modelClass;
8745
+ /** True once `lean()` was called — execution skips Model hydration. */
8746
+ isLean = false;
8583
8747
  /**
8584
8748
  * Append an operation to `operations[]` and update `opIndex`.
8585
8749
  * Every fluent method calls this.
@@ -8671,8 +8835,22 @@ var QueryBuilder = class QueryBuilder {
8671
8835
  cloned.countRelations = new Map(this.countRelations);
8672
8836
  cloned.relationDefinitions = this.relationDefinitions;
8673
8837
  cloned.modelClass = this.modelClass;
8838
+ cloned.isLean = this.isLean;
8674
8839
  return cloned;
8675
8840
  }
8841
+ /**
8842
+ * Switch to lean read mode: execution returns the plain driver rows — no
8843
+ * Model hydration, no driver deserialization, no hydrating/fetched
8844
+ * callbacks. The model's `static hidden` fields are still stripped.
8845
+ * Eager-loading relations in lean mode throws `UnsupportedLeanOperationError`.
8846
+ *
8847
+ * @example
8848
+ * const rows = await User.query().where("isActive", true).lean().get();
8849
+ */
8850
+ lean() {
8851
+ this.isLean = true;
8852
+ return this;
8853
+ }
8676
8854
  /** Disable one or more named global scopes for this query. */
8677
8855
  withoutGlobalScope(...scopeNames) {
8678
8856
  scopeNames.forEach((name) => this.disabledGlobalScopes.add(name));
@@ -10020,6 +10198,28 @@ function resolveLikePattern(pattern) {
10020
10198
  return pattern instanceof RegExp ? pattern.source : likePatternToRegexSource(pattern);
10021
10199
  }
10022
10200
 
10201
+ //#endregion
10202
+ //#region ../cascade/src/drivers/mongodb/pipeline-stage-object.ts
10203
+ /**
10204
+ * Whether a value looks like one MongoDB aggregation stage: a plain object
10205
+ * with exactly one key, and that key is a `$`-prefixed stage name
10206
+ * (`{ $lookup: { ... } }`, `{ $addFields: { ... } }`).
10207
+ *
10208
+ * Used to validate caller-built stages (`joinRaw()`, `raw()` results) before
10209
+ * they reach the pipeline, so a SQL string or a filter object is rejected with
10210
+ * a named error instead of failing at execution time or being ignored.
10211
+ *
10212
+ * @param value - The candidate stage
10213
+ * @returns `true` when the value is a single-stage object
10214
+ */
10215
+ function isPipelineStageObject(value) {
10216
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
10217
+ const prototype = Object.getPrototypeOf(value);
10218
+ if (prototype !== Object.prototype && prototype !== null) return false;
10219
+ const keys = Object.keys(value);
10220
+ return keys.length === 1 && keys[0].startsWith("$");
10221
+ }
10222
+
10023
10223
  //#endregion
10024
10224
  //#region ../cascade/src/drivers/mongodb/mongodb-query-parser.ts
10025
10225
  /**
@@ -10086,42 +10286,110 @@ var MongoQueryParser = class {
10086
10286
  * ```
10087
10287
  */
10088
10288
  parse() {
10289
+ this.groupFieldNames.clear();
10290
+ this.countDistinctAliases.clear();
10089
10291
  const pipeline = [];
10090
10292
  let currentStage = null;
10091
10293
  let currentBuffer = [];
10092
- for (const op of this.orderStages(this.operations)) if (op.mergeable && op.stage === currentStage) currentBuffer.push(op);
10093
- else {
10294
+ let trailingProjectIndex = -1;
10295
+ const emit = (stage, operations) => {
10296
+ if (stage === "$raw") {
10297
+ for (const op of operations) this.applyRawOperation(pipeline, op);
10298
+ trailingProjectIndex = -1;
10299
+ return;
10300
+ }
10301
+ const builtStage = this.buildStage(stage, operations);
10302
+ if (!builtStage) return;
10303
+ if (builtStage.$sort && trailingProjectIndex >= 0 && trailingProjectIndex === pipeline.length - 1) {
10304
+ const projectStage = pipeline.pop();
10305
+ pipeline.push(...this.placeSortAroundProjection(projectStage, builtStage));
10306
+ trailingProjectIndex = -1;
10307
+ return;
10308
+ }
10309
+ const stageIndex = pipeline.length;
10310
+ pipeline.push(builtStage);
10311
+ trailingProjectIndex = stage === "$project" ? stageIndex : -1;
10312
+ this.trackGroupFieldNames(stage, operations, stageIndex);
10313
+ const renameStage = this.buildGroupRenameStage(builtStage, stageIndex);
10314
+ if (renameStage) pipeline.push(renameStage);
10315
+ };
10316
+ for (const op of this.orderStages(this.operations)) {
10317
+ if (op.mergeable && op.stage === currentStage) {
10318
+ currentBuffer.push(op);
10319
+ continue;
10320
+ }
10094
10321
  if (currentBuffer.length > 0) {
10095
- const builtStage = this.buildStage(currentStage, currentBuffer);
10096
- if (builtStage) {
10097
- const stageIndex = pipeline.length;
10098
- pipeline.push(builtStage);
10099
- this.trackGroupFieldNames(currentStage, currentBuffer, stageIndex);
10100
- }
10322
+ emit(currentStage, currentBuffer);
10101
10323
  currentBuffer = [];
10102
10324
  }
10103
10325
  if (op.mergeable) {
10104
10326
  currentStage = op.stage;
10105
10327
  currentBuffer.push(op);
10106
10328
  } else {
10107
- const builtStage = this.buildStage(op.stage, [op]);
10108
- if (builtStage) {
10109
- const stageIndex = pipeline.length;
10110
- pipeline.push(builtStage);
10111
- this.trackGroupFieldNames(op.stage, [op], stageIndex);
10112
- }
10329
+ emit(op.stage, [op]);
10113
10330
  currentStage = null;
10114
10331
  }
10115
10332
  }
10116
- if (currentBuffer.length > 0) {
10117
- const builtStage = this.buildStage(currentStage, currentBuffer);
10118
- if (builtStage) {
10119
- const stageIndex = pipeline.length;
10120
- pipeline.push(builtStage);
10121
- this.trackGroupFieldNames(currentStage, currentBuffer, stageIndex);
10122
- }
10333
+ if (currentBuffer.length > 0) emit(currentStage, currentBuffer);
10334
+ return pipeline;
10335
+ }
10336
+ /**
10337
+ * Apply a raw escape-hatch operation to the pipeline built so far.
10338
+ *
10339
+ * - `joinRaw`: appends the caller-built stages verbatim.
10340
+ * - `raw`: calls the callback with the pipeline array. A returned array
10341
+ * replaces the pipeline; `undefined` keeps the (possibly mutated) array.
10342
+ *
10343
+ * @throws UnsupportedQueryOperationError when a `raw()` callback leaves
10344
+ * something other than an array of stage objects
10345
+ */
10346
+ applyRawOperation(pipeline, op) {
10347
+ if (op.type === "joinRaw") {
10348
+ pipeline.push(...op.data.stages);
10349
+ return;
10123
10350
  }
10124
- return this.postProcessGroupStages(pipeline);
10351
+ const result = op.data.builder(pipeline);
10352
+ const next = result === void 0 ? pipeline : result;
10353
+ if (!Array.isArray(next) || !next.every(isPipelineStageObject)) throw new UnsupportedQueryOperationError("raw", "mongodb", "The raw() callback receives the aggregation pipeline array and must return an array of stage objects, or mutate that array and return nothing.");
10354
+ if (next !== pipeline) pipeline.splice(0, pipeline.length, ...next);
10355
+ }
10356
+ /**
10357
+ * Order a `$sort` against the select `$project` it follows.
10358
+ *
10359
+ * A `$sort` after a `$project` cannot see fields the projection dropped, so
10360
+ * `select(["title"]).orderBy("likes")` would not sort. The sort moves before
10361
+ * the projection, unless it sorts by a field the projection computes
10362
+ * (`selectRaw({ score: ... }).orderBy("score")`), which only exists after it:
10363
+ *
10364
+ * - no computed sort key: `$sort`, `$project`
10365
+ * - only computed or selected sort keys: `$project`, `$sort` (unchanged)
10366
+ * - computed AND unselected sort keys: `$addFields` (the computed keys),
10367
+ * `$sort`, `$project` (those keys passed through)
10368
+ */
10369
+ placeSortAroundProjection(projectStage, sortStage) {
10370
+ const projection = projectStage.$project;
10371
+ const sortKeys = Object.keys(sortStage.$sort);
10372
+ const isInclusionFlag = (value) => value === 1 || value === true || value === 0 || value === false;
10373
+ const computedKeys = sortKeys.filter((key) => {
10374
+ const value = projection[key];
10375
+ return value !== void 0 && !isInclusionFlag(value) && value !== `$${key}`;
10376
+ });
10377
+ if (computedKeys.length === 0) return [sortStage, projectStage];
10378
+ if (!sortKeys.some((key) => {
10379
+ const value = projection[key];
10380
+ return value === void 0 || value === 0 || value === false;
10381
+ })) return [projectStage, sortStage];
10382
+ const addFields = {};
10383
+ const passThrough = { ...projection };
10384
+ for (const key of computedKeys) {
10385
+ addFields[key] = projection[key];
10386
+ passThrough[key] = 1;
10387
+ }
10388
+ return [
10389
+ { $addFields: addFields },
10390
+ sortStage,
10391
+ { $project: passThrough }
10392
+ ];
10125
10393
  }
10126
10394
  /**
10127
10395
  * Reorder operations so filters run before projections, mirroring SQL
@@ -10130,11 +10398,13 @@ var MongoQueryParser = class {
10130
10398
  * strips the filter column would run before the `$match` and silently drop
10131
10399
  * every document (`select(["a"]).where("b", x)` → `[]`).
10132
10400
  *
10133
- * Only *mergeable* `$match` operations are hoisted, and only within a
10134
- * segment of neighboring mergeable `$match` / `$project` / `$sort`
10135
- * operations. Any other operation — `$group`, `$lookup`, `$limit`, `$skip`,
10136
- * `$setWindowFields`, or a non-mergeable op (raw escapes, having-style
10137
- * post-group matches, `$sample`) is a barrier: nothing moves across it.
10401
+ * Only *mergeable* operations move, and only within a segment of neighboring
10402
+ * mergeable `$match` / `$project` / `$sort` operations. Inside a segment the
10403
+ * order is `$match`, then `$project`, then `$sort` (each keeping call order),
10404
+ * and {@link placeSortAroundProjection} then decides whether the sort runs
10405
+ * before the projection. Any other operation `$group`, `$lookup`,
10406
+ * `$limit`, `$skip`, `$setWindowFields`, raw escapes, having-style
10407
+ * post-group matches, `$sample` — is a barrier: nothing moves across it.
10138
10408
  * So `groupBy(...).where(...)` still filters AFTER the group, and
10139
10409
  * `limit(...)` / `random()` keep their call-order meaning.
10140
10410
  */
@@ -10144,7 +10414,8 @@ var MongoQueryParser = class {
10144
10414
  const flushSegment = () => {
10145
10415
  if (segment.length === 0) return;
10146
10416
  reordered.push(...segment.filter((op) => op.stage === "$match"));
10147
- reordered.push(...segment.filter((op) => op.stage !== "$match"));
10417
+ reordered.push(...segment.filter((op) => op.stage === "$project"));
10418
+ reordered.push(...segment.filter((op) => op.stage === "$sort"));
10148
10419
  segment = [];
10149
10420
  };
10150
10421
  for (const op of operations) if (op.mergeable && (op.stage === "$match" || op.stage === "$project" || op.stage === "$sort")) segment.push(op);
@@ -10183,35 +10454,26 @@ var MongoQueryParser = class {
10183
10454
  if (distinctAliases.size > 0) this.countDistinctAliases.set(stageIndex, distinctAliases);
10184
10455
  }
10185
10456
  /**
10186
- * Post-process pipeline to rename _id fields after $group stages.
10187
- *
10188
- * This automatically renames MongoDB's `_id` field to the actual field name(s)
10189
- * used for grouping, making the results more intuitive.
10190
- *
10191
- * @param pipeline - The aggregation pipeline
10192
- * @returns The processed pipeline
10193
- */
10194
- postProcessGroupStages(pipeline) {
10195
- const processed = [];
10196
- for (let i = 0; i < pipeline.length; i++) {
10197
- const stage = pipeline[i];
10198
- if (stage.$group && this.groupFieldNames.has(i)) {
10199
- const fieldNames = this.groupFieldNames.get(i);
10200
- processed.push(stage);
10201
- const projection = {};
10202
- if (typeof fieldNames === "string") projection[fieldNames] = "$_id";
10203
- else if (Array.isArray(fieldNames) && fieldNames.length > 0) for (const fieldName of fieldNames) projection[fieldName] = `$_id.${fieldName}`;
10204
- const distinctAliases = this.countDistinctAliases.get(i);
10205
- const aggregateFields = Object.keys(stage.$group).filter((key) => key !== "_id");
10206
- for (const field of aggregateFields) if (distinctAliases?.has(field)) projection[field] = { $size: `$${field}` };
10207
- else projection[field] = 1;
10208
- if (Object.keys(projection).length > 0) {
10209
- projection._id = 0;
10210
- processed.push({ $project: projection });
10211
- }
10212
- } else processed.push(stage);
10213
- }
10214
- return processed;
10457
+ * Build the `$project` that renames `_id` back to the grouped field name(s)
10458
+ * right after a `$group` stage with tracked field names, so results carry
10459
+ * the grouping columns instead of MongoDB's `_id`.
10460
+ *
10461
+ * @param stage - The stage just emitted
10462
+ * @param stageIndex - Its index in the pipeline
10463
+ * @returns The renaming `$project` stage, or null when none is needed
10464
+ */
10465
+ buildGroupRenameStage(stage, stageIndex) {
10466
+ if (!stage.$group || !this.groupFieldNames.has(stageIndex)) return null;
10467
+ const fieldNames = this.groupFieldNames.get(stageIndex);
10468
+ const projection = {};
10469
+ if (typeof fieldNames === "string") projection[fieldNames] = "$_id";
10470
+ else if (Array.isArray(fieldNames) && fieldNames.length > 0) for (const fieldName of fieldNames) projection[fieldName] = `$_id.${fieldName}`;
10471
+ const distinctAliases = this.countDistinctAliases.get(stageIndex);
10472
+ const aggregateFields = Object.keys(stage.$group).filter((key) => key !== "_id");
10473
+ for (const field of aggregateFields) projection[field] = distinctAliases?.has(field) ? { $size: `$${field}` } : 1;
10474
+ if (Object.keys(projection).length === 0) return null;
10475
+ projection._id = 0;
10476
+ return { $project: projection };
10215
10477
  }
10216
10478
  /**
10217
10479
  * Convert the parsed pipeline to a pretty-printed string for debugging.
@@ -10311,6 +10573,23 @@ var MongoQueryParser = class {
10311
10573
  const [first] = operations;
10312
10574
  return first === void 0 ? null : { $setWindowFields: first.data.spec };
10313
10575
  }
10576
+ case "$unwind": {
10577
+ const [first] = operations;
10578
+ if (first === void 0) return null;
10579
+ const { path, ...options } = first.data;
10580
+ return Object.keys(options).length === 0 ? { $unwind: path } : { $unwind: {
10581
+ path,
10582
+ ...options
10583
+ } };
10584
+ }
10585
+ case "$addFields": {
10586
+ const [first] = operations;
10587
+ return first === void 0 ? null : { $addFields: first.data };
10588
+ }
10589
+ case "$vectorSearch": {
10590
+ const [first] = operations;
10591
+ return first === void 0 ? null : { $vectorSearch: first.data };
10592
+ }
10314
10593
  default: return null;
10315
10594
  }
10316
10595
  }
@@ -11127,6 +11406,13 @@ var MongoQueryParser = class {
11127
11406
  const op = operations[0];
11128
11407
  if (op === void 0) return null;
11129
11408
  const options = op.data;
11409
+ if (Array.isArray(options.pipeline)) return { $lookup: {
11410
+ from: options.table,
11411
+ ...options.localField !== void 0 ? { localField: options.localField } : {},
11412
+ ...options.foreignField !== void 0 ? { foreignField: options.foreignField } : {},
11413
+ as: options.alias || options.table,
11414
+ pipeline: options.pipeline
11415
+ } };
11130
11416
  return { $lookup: {
11131
11417
  from: options.table,
11132
11418
  localField: options.localField,
@@ -12385,23 +12671,35 @@ var MongoQueryBuilder = class MongoQueryBuilder extends QueryBuilder {
12385
12671
  return this;
12386
12672
  }
12387
12673
  /**
12388
- * Performs a raw join using a custom aggregation pipeline.
12674
+ * Performs a raw join using caller-built aggregation stages.
12389
12675
  *
12390
- * This allows full control over the $lookup stage for complex join scenarios.
12676
+ * The expression is one pipeline stage (typically `{ $lookup: { ... } }`) or
12677
+ * an array of stages. They are emitted verbatim in call order, so a later
12678
+ * `where()` / `orderBy()` can use the joined fields.
12391
12679
  *
12392
- * @param expression - Raw expression (typically a $lookup stage or pipeline)
12393
- * @param _bindings - Optional bindings (not used in MongoDB but kept for API consistency)
12680
+ * @param expression - A pipeline stage object, or an array of stage objects
12681
+ * @param _bindings - Not used on MongoDB; kept for API consistency
12682
+ * @throws UnsupportedQueryOperationError when the expression is not a stage
12683
+ * object or an array of them (e.g. a SQL `JOIN` string)
12394
12684
  */
12395
12685
  joinRaw(expression, _bindings) {
12396
- this.operationsHelper.addMatchOperation("raw", { builder: () => expression }, false);
12686
+ const stages = Array.isArray(expression) ? expression : [expression];
12687
+ if (stages.length === 0 || !stages.every(isPipelineStageObject)) throw new UnsupportedQueryOperationError("joinRaw", "mongodb", "Pass a pipeline stage object such as { $lookup: { ... } }, or an array of stage objects.");
12688
+ this.operationsHelper.addOperation("$raw", "joinRaw", { stages });
12397
12689
  return this;
12398
12690
  }
12399
12691
  /**
12400
- * Allows direct manipulation of the native MongoDB query.
12401
- * @param builder - Function that receives and modifies the native query
12692
+ * Allows direct manipulation of the native MongoDB aggregation pipeline.
12693
+ *
12694
+ * The callback receives the pipeline array built from the operations
12695
+ * recorded before this call. Return a new array of stages to replace it, or
12696
+ * mutate the array in place and return nothing. Operations recorded after
12697
+ * `raw()` are appended to that result.
12698
+ *
12699
+ * @param builder - Receives the pipeline array; returns a replacement or nothing
12402
12700
  */
12403
12701
  raw(builder) {
12404
- this.operationsHelper.addMatchOperation("raw", { builder }, false);
12702
+ this.operationsHelper.addOperation("$raw", "raw", { builder });
12405
12703
  return this;
12406
12704
  }
12407
12705
  /**
@@ -12424,6 +12722,8 @@ var MongoQueryBuilder = class MongoQueryBuilder extends QueryBuilder {
12424
12722
  cloned.fetchingCallback = this.fetchingCallback?.bind(cloned);
12425
12723
  cloned.hydratingCallback = this.hydratingCallback?.bind(cloned);
12426
12724
  cloned.fetchedCallback = this.fetchedCallback?.bind(cloned);
12725
+ cloned.isLean = this.isLean;
12726
+ cloned.modelClass = this.modelClass;
12427
12727
  cloned.pendingGlobalScopes = this.pendingGlobalScopes;
12428
12728
  cloned.availableLocalScopes = this.availableLocalScopes;
12429
12729
  cloned.disabledGlobalScopes = new Set(this.disabledGlobalScopes);
@@ -12432,6 +12732,13 @@ var MongoQueryBuilder = class MongoQueryBuilder extends QueryBuilder {
12432
12732
  return cloned;
12433
12733
  }
12434
12734
  /**
12735
+ * Switch to lean read mode — see `QueryBuilderContract.lean()`.
12736
+ */
12737
+ lean() {
12738
+ super.lean();
12739
+ return this;
12740
+ }
12741
+ /**
12435
12742
  * Row-level locking is a SQL capability — MongoDB has no
12436
12743
  * `SELECT ... FOR UPDATE`. Throwing (instead of silently ignoring the call)
12437
12744
  * keeps a queue-claim pattern from silently running unlocked.
@@ -12468,6 +12775,10 @@ var MongoQueryBuilder = class MongoQueryBuilder extends QueryBuilder {
12468
12775
  async get() {
12469
12776
  const startTime = Date.now();
12470
12777
  if (this.fetchingCallback) await this.fetchingCallback(this);
12778
+ if (this.isLean) {
12779
+ assertLeanCompatible(this);
12780
+ return stripHiddenFromLeanRecords(await this.execute(), this.modelClass);
12781
+ }
12471
12782
  const rawRecords = await this.execute();
12472
12783
  if (this.hydratingCallback) await this.hydratingCallback(rawRecords, {
12473
12784
  query: this,
@@ -12967,6 +13278,26 @@ var MongoQueryBuilder = class MongoQueryBuilder extends QueryBuilder {
12967
13278
  return this;
12968
13279
  }
12969
13280
  /**
13281
+ * Deconstruct an array field into one document per element (`$unwind`).
13282
+ * The short form is emitted when no option is given. See
13283
+ * `QueryBuilderContract.unwind()`.
13284
+ */
13285
+ unwind(field, options) {
13286
+ const data = { path: field.startsWith("$") ? field : `$${field}` };
13287
+ if (options?.preserveNullAndEmptyArrays !== void 0) data.preserveNullAndEmptyArrays = options.preserveNullAndEmptyArrays;
13288
+ if (options?.includeArrayIndex !== void 0) data.includeArrayIndex = options.includeArrayIndex;
13289
+ this.operationsHelper.addOperation("$unwind", "unwind", data, false);
13290
+ return this;
13291
+ }
13292
+ /**
13293
+ * Add computed fields while keeping every existing field (`$addFields`).
13294
+ * See `QueryBuilderContract.addFields()`.
13295
+ */
13296
+ addFields(fields) {
13297
+ this.operationsHelper.addOperation("$addFields", "addFields", { ...fields }, false);
13298
+ return this;
13299
+ }
13300
+ /**
12970
13301
  * Nearest-neighbour vector similarity search via MongoDB Atlas $vectorSearch.
12971
13302
  *
12972
13303
  * Adds two pipeline stages:
@@ -13124,6 +13455,29 @@ var MongoSyncAdapter = class {
13124
13455
  }
13125
13456
  };
13126
13457
 
13458
+ //#endregion
13459
+ //#region ../cascade/src/drivers/mongodb/mongodb-update-translator.ts
13460
+ /**
13461
+ * Translate cascade's driver-agnostic update into MongoDB's native form.
13462
+ *
13463
+ * Every operator is native except `$dec`, which MongoDB does not have: it is
13464
+ * folded into `$inc` with negated amounts. A pipeline (array-form) update is
13465
+ * returned untouched.
13466
+ *
13467
+ * @param update - Operator object or aggregation pipeline
13468
+ * @returns The update MongoDB accepts
13469
+ */
13470
+ function toMongoUpdate(update) {
13471
+ if (Array.isArray(update) || !update.$dec) return update;
13472
+ const { $dec, ...rest } = update;
13473
+ const increments = { ...rest.$inc };
13474
+ for (const [field, amount] of Object.entries($dec)) increments[field] = (increments[field] ?? 0) - amount;
13475
+ return {
13476
+ ...rest,
13477
+ $inc: increments
13478
+ };
13479
+ }
13480
+
13127
13481
  //#endregion
13128
13482
  //#region ../cascade/src/drivers/mongodb/mongodb-driver.ts
13129
13483
  const DEFAULT_TRANSACTION_OPTIONS = {
@@ -13426,14 +13780,17 @@ var MongoDbDriver = class {
13426
13780
  return (await this.getDatabaseInstance().collection(table).findOneAndReplace(filter, document))?.value;
13427
13781
  }
13428
13782
  /**
13429
- * Find one and update a single document that matches the provided filter and return the updated document
13783
+ * Find one and update a single document that matches the provided filter.
13784
+ *
13785
+ * Native `upsert`, `arrayFilters`, pipeline updates and `returnDocument`
13786
+ * (default `"after"`).
13430
13787
  */
13431
13788
  async findOneAndUpdate(table, filter, update, options) {
13432
13789
  const collection = this.getDatabaseInstance().collection(table);
13433
13790
  const mongoOptions = this.withSession(options);
13434
- return await collection.findOneAndUpdate(filter, update, {
13435
- returnDocument: "after",
13436
- ...mongoOptions
13791
+ return await collection.findOneAndUpdate(filter, toMongoUpdate(update), {
13792
+ ...mongoOptions,
13793
+ returnDocument: options?.returnDocument ?? "after"
13437
13794
  });
13438
13795
  }
13439
13796
  /**
@@ -13624,11 +13981,17 @@ var MongoDbDriver = class {
13624
13981
  * Execute atomic operations (typically $inc/$set style updates) against documents.
13625
13982
  *
13626
13983
  * Uses `updateMany` so callers can atomically modify any set of documents.
13984
+ * `upsert`, `arrayFilters` and pipeline updates are native.
13627
13985
  */
13628
13986
  async atomic(table, filter, operations, options) {
13629
13987
  const collection = this.getDatabaseInstance().collection(table);
13630
- const mongoOptions = this.withSession(options);
13631
- return { modifiedCount: (await collection.updateMany(filter, operations, mongoOptions)).modifiedCount };
13988
+ const { returnDocument: _returnDocument, ...updateOptions } = options ?? {};
13989
+ const mongoOptions = this.withSession(updateOptions);
13990
+ const result = await collection.updateMany(filter, toMongoUpdate(operations), mongoOptions);
13991
+ return {
13992
+ modifiedCount: result.modifiedCount,
13993
+ upsertedCount: result.upsertedCount
13994
+ };
13632
13995
  }
13633
13996
  /**
13634
13997
  * Lazily create (and cache) the Mongo sync adapter.
@@ -15884,6 +16247,13 @@ var PostgresQueryBuilder = class PostgresQueryBuilder extends QueryBuilder {
15884
16247
  get driver() {
15885
16248
  return this.dataSource.driver;
15886
16249
  }
16250
+ /**
16251
+ * Switch to lean read mode — see `QueryBuilderContract.lean()`.
16252
+ */
16253
+ lean() {
16254
+ super.lean();
16255
+ return this;
16256
+ }
15887
16257
  clone() {
15888
16258
  const cloned = new PostgresQueryBuilder(this.table, this.dataSource);
15889
16259
  cloned.operations = [...this.operations];
@@ -15895,6 +16265,7 @@ var PostgresQueryBuilder = class PostgresQueryBuilder extends QueryBuilder {
15895
16265
  cloned.countRelations = new Map(this.countRelations);
15896
16266
  cloned.relationDefinitions = this.relationDefinitions;
15897
16267
  cloned.modelClass = this.modelClass;
16268
+ cloned.isLean = this.isLean;
15898
16269
  cloned.hydrateCallback = this.hydrateCallback;
15899
16270
  cloned.joinRelations = new Map(this.joinRelations);
15900
16271
  cloned.joinRelationsApplied = this.joinRelationsApplied;
@@ -16013,6 +16384,22 @@ var PostgresQueryBuilder = class PostgresQueryBuilder extends QueryBuilder {
16013
16384
  * .get<VectorRow & { score: number }>();
16014
16385
  * ```
16015
16386
  */
16387
+ /**
16388
+ * MongoDB-only stage. Always throws — see `QueryBuilderContract.unwind()`.
16389
+ *
16390
+ * @throws UnsupportedQueryOperationError
16391
+ */
16392
+ unwind(_field, _options) {
16393
+ throw new UnsupportedQueryOperationError("unwind", "postgres", "Query the array column with jsonb_array_elements()/unnest() through selectRaw/joinRaw, or model the elements as a related table.");
16394
+ }
16395
+ /**
16396
+ * MongoDB-only stage. Always throws — see `QueryBuilderContract.addFields()`.
16397
+ *
16398
+ * @throws UnsupportedQueryOperationError
16399
+ */
16400
+ addFields(_fields) {
16401
+ throw new UnsupportedQueryOperationError("addFields", "postgres", "Add computed columns with selectRaw() alongside the table columns, e.g. selectRaw('\"posts\".*').selectRaw(\"likes + shares AS score\").");
16402
+ }
16016
16403
  similarTo(column, embedding, alias = "score") {
16017
16404
  const literal = `[${embedding.join(",")}]`;
16018
16405
  const quotedCol = this.driver.dialect.quoteIdentifier(column);
@@ -16210,6 +16597,7 @@ var PostgresQueryBuilder = class PostgresQueryBuilder extends QueryBuilder {
16210
16597
  this.applyHasRelations();
16211
16598
  this.applyCountRelations();
16212
16599
  this.applyGroupByAggregates();
16600
+ if (this.isLean) assertLeanCompatible(this);
16213
16601
  if (this.fetchingCallback) await this.fetchingCallback(this);
16214
16602
  const { query = "", bindings = [] } = new PostgresQueryParser({
16215
16603
  table: this.table,
@@ -16217,6 +16605,10 @@ var PostgresQueryBuilder = class PostgresQueryBuilder extends QueryBuilder {
16217
16605
  }).parse();
16218
16606
  try {
16219
16607
  let records = (await this.driver.query(query, bindings)).rows;
16608
+ if (this.isLean) {
16609
+ this.operations = [];
16610
+ return stripHiddenFromLeanRecords(records, this.modelClass);
16611
+ }
16220
16612
  const joinedData = this.extractJoinedRelationData(records);
16221
16613
  if (this.hydratingCallback) await this.hydratingCallback(records, {});
16222
16614
  if (this.hydrateCallback) records = records.map((row, index) => this.hydrateCallback(row, index));
@@ -17434,6 +17826,38 @@ var PostgresSyncAdapter = class {
17434
17826
  }
17435
17827
  };
17436
17828
 
17829
+ //#endregion
17830
+ //#region ../cascade/src/drivers/postgres/postgres-update-validator.ts
17831
+ /**
17832
+ * Update operators the Postgres driver can express in SQL.
17833
+ *
17834
+ * `$setOnInsert` only contributes to the INSERT half of an upsert; on a plain
17835
+ * update it is a no-op, exactly as in MongoDB.
17836
+ */
17837
+ const SUPPORTED_OPERATORS = new Set([
17838
+ "$set",
17839
+ "$unset",
17840
+ "$inc",
17841
+ "$dec",
17842
+ "$setOnInsert"
17843
+ ]);
17844
+ /**
17845
+ * Reject every part of an update the Postgres driver cannot perform, so no
17846
+ * operator or option is ever silently dropped.
17847
+ *
17848
+ * @param update - The requested update
17849
+ * @param options - The requested update options
17850
+ * @returns The update, narrowed to an operator object
17851
+ * @throws UnsupportedUpdateOperationError for pipeline updates, `arrayFilters`,
17852
+ * and any operator outside {@link SUPPORTED_OPERATORS}
17853
+ */
17854
+ function assertPostgresUpdate(update, options) {
17855
+ if (Array.isArray(update)) throw new UnsupportedUpdateOperationError("pipeline update", "postgres", "Use an operator object ($set/$unset/$inc/$dec) or a raw query.");
17856
+ if (options?.arrayFilters !== void 0) throw new UnsupportedUpdateOperationError("arrayFilters", "postgres");
17857
+ for (const operator of Object.keys(update)) if (!SUPPORTED_OPERATORS.has(operator)) throw new UnsupportedUpdateOperationError(operator, "postgres", "Supported operators: $set, $unset, $inc, $dec, $setOnInsert.");
17858
+ return update;
17859
+ }
17860
+
17437
17861
  //#endregion
17438
17862
  //#region ../cascade/src/drivers/postgres/postgres-driver.ts
17439
17863
  /**
@@ -17589,6 +18013,8 @@ var PostgresDriver = class {
17589
18013
  * @see PostgresPoolConfig.nativeArrayColumns
17590
18014
  */
17591
18015
  _nativeArrayColumns;
18016
+ /** Unique keys per table, resolved from `pg_index` for upsert conflict targets. */
18017
+ _uniqueKeys = /* @__PURE__ */ new Map();
17592
18018
  /**
17593
18019
  * Native-array columns discovered by introspecting the live schema on
17594
18020
  * connect, keyed `table → { column, … }`. Authoritative and table-scoped, so
@@ -17910,17 +18336,120 @@ var PostgresDriver = class {
17910
18336
  }
17911
18337
  }
17912
18338
  /**
17913
- * Find one and update a single row matching the filter and return the updated row
17914
- * @param table - Target table name
17915
- * @param filter - Filter conditions
17916
- * @param update - Update operations ($set, $unset, $inc)
17917
- * @param options - Optional update options
17918
- * @returns The updated row or null
18339
+ * Find one row matching the filter, update it, and return it.
18340
+ *
18341
+ * - `upsert`: `INSERT ON CONFLICT (target) DO UPDATE … RETURNING *`, where
18342
+ * the target is the primary key or a unique index whose columns are all
18343
+ * equality keys of the filter (see {@link executeUpsert}).
18344
+ * - `returnDocument` (default `"after"`): `"before"` returns the row as it
18345
+ * was, and is not available together with `upsert`.
18346
+ *
18347
+ * The row is chosen with `SELECT … LIMIT 1 FOR UPDATE` and matched by primary
18348
+ * key with the filter repeated, so concurrent callers re-check the filter
18349
+ * against the latest committed row instead of overshooting it.
18350
+ *
18351
+ * @throws UnsupportedUpdateOperationError for pipeline updates, `arrayFilters`,
18352
+ * array operators, `before` + `upsert`, or an underivable conflict target
17919
18353
  */
17920
- async findOneAndUpdate(table, filter, update, _options) {
17921
- const { sql, params } = this.buildUpdateQuery(table, filter, update, 1);
17922
- const sqlWithReturning = `${sql} RETURNING *`;
17923
- return (await this.query(sqlWithReturning, params)).rows[0] ?? null;
18354
+ async findOneAndUpdate(table, filter, update, options) {
18355
+ const operations = assertPostgresUpdate(update, options);
18356
+ const returnBefore = options?.returnDocument === "before";
18357
+ if (options?.upsert) {
18358
+ if (returnBefore) throw new UnsupportedUpdateOperationError("returnDocument: before with upsert", "postgres", "Use returnDocument: \"after\", or read the row before upserting inside a transaction.");
18359
+ const { row } = await this.executeUpsert(table, filter, operations);
18360
+ return row ?? null;
18361
+ }
18362
+ const primaryKey = (await this.getUniqueKeys(table)).find((key) => key.primary)?.columns;
18363
+ if (!primaryKey) {
18364
+ if (returnBefore) throw new UnsupportedUpdateOperationError("returnDocument: before without a primary key", "postgres");
18365
+ const { sql, params } = this.buildUpdateQuery(table, filter, operations, 1);
18366
+ return (await this.query(`${sql} RETURNING *`, params)).rows[0] ?? null;
18367
+ }
18368
+ const quotedTable = this.dialect.quoteIdentifier(table);
18369
+ const keyList = (qualifier) => primaryKey.map((column) => (qualifier ? `${qualifier}.` : "") + this.dialect.quoteIdentifier(column)).join(", ");
18370
+ const set = this.buildSetClauses(table, operations, 1, returnBefore ? quotedTable : void 0);
18371
+ if (set.clauses.length === 0) throw new Error("No update operations specified");
18372
+ const inner = this.buildWhereClause(filter, set.nextIndex);
18373
+ const outer = this.buildWhereClause(filter, set.nextIndex + inner.whereParams.length, quotedTable);
18374
+ const params = [
18375
+ ...set.params,
18376
+ ...inner.whereParams,
18377
+ ...outer.whereParams
18378
+ ];
18379
+ const outerCondition = outer.condition ? ` AND ${outer.condition}` : "";
18380
+ if (returnBefore) {
18381
+ const before = this.dialect.quoteIdentifier("__cascade_before");
18382
+ const sql = `UPDATE ${quotedTable} SET ${set.clauses.join(", ")} FROM (SELECT * FROM ${quotedTable} ${inner.whereClause} LIMIT 1 FOR UPDATE) AS ${before} WHERE (${keyList(quotedTable)}) = (${keyList(before)})${outerCondition} RETURNING ${before}.*`;
18383
+ return (await this.query(sql, params)).rows[0] ?? null;
18384
+ }
18385
+ const sql = `UPDATE ${quotedTable} SET ${set.clauses.join(", ")} WHERE (${keyList(quotedTable)}) = (SELECT ${keyList()} FROM ${quotedTable} ${inner.whereClause} LIMIT 1 FOR UPDATE)${outerCondition} RETURNING *`;
18386
+ return (await this.query(sql, params)).rows[0] ?? null;
18387
+ }
18388
+ /**
18389
+ * Upsert through `INSERT … ON CONFLICT (target) DO UPDATE SET … [WHERE rest] RETURNING *`.
18390
+ *
18391
+ * Conflict target rule: the first unique index of the table (primary key
18392
+ * first; partial and expression indexes excluded) whose columns are ALL
18393
+ * equality keys of the filter — a non-null value that is not an operator
18394
+ * object. Any other filter predicate becomes the `DO UPDATE … WHERE`, so an
18395
+ * existing row that fails it is left untouched and no row is returned.
18396
+ *
18397
+ * Inserted row: filter equality values, then `$setOnInsert`, then `$set`,
18398
+ * then `$inc` (n) / `$dec` (-n); `$unset` columns are omitted.
18399
+ *
18400
+ * @throws UnsupportedUpdateOperationError when no conflict target can be derived
18401
+ */
18402
+ async executeUpsert(table, filter, operations) {
18403
+ const equalityKeys = new Set(Object.entries(filter).filter(([, value]) => value !== null && value !== void 0 && !this.isOperatorFilter(value)).map(([key]) => key));
18404
+ const target = (await this.getUniqueKeys(table)).find((key) => key.columns.every((column) => equalityKeys.has(column)));
18405
+ if (!target) throw new UnsupportedUpdateOperationError("upsert without a unique conflict target", "postgres", `Filter on every column of the primary key or of a unique index of "${table}".`);
18406
+ const insertRow = {};
18407
+ for (const key of equalityKeys) insertRow[key] = filter[key];
18408
+ Object.assign(insertRow, operations.$setOnInsert, operations.$set);
18409
+ for (const [key, amount] of Object.entries(operations.$inc ?? {})) insertRow[key] = amount;
18410
+ for (const [key, amount] of Object.entries(operations.$dec ?? {})) insertRow[key] = -amount;
18411
+ for (const key of Object.keys(operations.$unset ?? {})) delete insertRow[key];
18412
+ const columns = Object.keys(insertRow).filter((key) => insertRow[key] !== void 0);
18413
+ const params = columns.map((key) => this.serializeValue(key, insertRow[key], table));
18414
+ const quotedTable = this.dialect.quoteIdentifier(table);
18415
+ const quotedColumns = columns.map((column) => this.dialect.quoteIdentifier(column)).join(", ");
18416
+ const placeholders = columns.map((_, index) => this.dialect.placeholder(index + 1)).join(", ");
18417
+ const set = this.buildSetClauses(table, operations, columns.length + 1, quotedTable);
18418
+ params.push(...set.params);
18419
+ const firstTargetColumn = this.dialect.quoteIdentifier(target.columns[0]);
18420
+ const doUpdateSet = set.clauses.length > 0 ? set.clauses.join(", ") : `${firstTargetColumn} = ${quotedTable}.${firstTargetColumn}`;
18421
+ const remainingFilter = Object.fromEntries(Object.entries(filter).filter(([key]) => !target.columns.includes(key)));
18422
+ const where = this.buildWhereClause(remainingFilter, set.nextIndex, quotedTable);
18423
+ params.push(...where.whereParams);
18424
+ const conflictColumns = target.columns.map((column) => this.dialect.quoteIdentifier(column)).join(", ");
18425
+ const insertedFlag = "__cascade_inserted";
18426
+ const sql = `INSERT INTO ${quotedTable} (${quotedColumns}) VALUES (${placeholders}) ON CONFLICT (${conflictColumns}) DO UPDATE SET ${doUpdateSet} ${where.whereClause} RETURNING *, (xmax = 0) AS "${insertedFlag}"`;
18427
+ const returned = (await this.query(sql, params)).rows[0];
18428
+ if (!returned) return { inserted: false };
18429
+ const { [insertedFlag]: inserted, ...row } = returned;
18430
+ return {
18431
+ row,
18432
+ inserted: inserted === true
18433
+ };
18434
+ }
18435
+ /**
18436
+ * Unique keys of a table (primary key first), read from `pg_index`.
18437
+ *
18438
+ * Partial and expression indexes are excluded — they cannot be an
18439
+ * `ON CONFLICT (columns)` target. Non-empty results are cached per table.
18440
+ */
18441
+ async getUniqueKeys(table) {
18442
+ const cached = this._uniqueKeys.get(table);
18443
+ if (cached) return cached;
18444
+ const result = await this.query(`SELECT array_agg(a.attname::text ORDER BY k.ord) AS columns, i.indisprimary AS primary
18445
+ FROM pg_index i
18446
+ CROSS JOIN LATERAL unnest(i.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)
18447
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
18448
+ WHERE i.indrelid = to_regclass($1) AND i.indisunique AND i.indpred IS NULL AND i.indexprs IS NULL
18449
+ GROUP BY i.indexrelid, i.indisprimary
18450
+ ORDER BY i.indisprimary DESC, i.indexrelid`, [this.dialect.quoteIdentifier(table)]);
18451
+ if (result.rows.length > 0) this._uniqueKeys.set(table, result.rows);
18452
+ return result.rows;
17924
18453
  }
17925
18454
  /**
17926
18455
  * Update multiple rows matching the filter.
@@ -18127,8 +18656,16 @@ var PostgresDriver = class {
18127
18656
  * @param options - Optional options
18128
18657
  * @returns Update result
18129
18658
  */
18130
- async atomic(table, filter, operations, _options) {
18131
- const { sql, params } = this.buildUpdateQuery(table, filter, operations);
18659
+ async atomic(table, filter, operations, options) {
18660
+ const update = assertPostgresUpdate(operations, options);
18661
+ if (options?.upsert) {
18662
+ const { row, inserted } = await this.executeUpsert(table, filter, update);
18663
+ return {
18664
+ modifiedCount: row && !inserted ? 1 : 0,
18665
+ upsertedCount: row && inserted ? 1 : 0
18666
+ };
18667
+ }
18668
+ const { sql, params } = this.buildUpdateQuery(table, filter, update);
18132
18669
  return { modifiedCount: (await this.query(sql, params)).rowCount ?? 0 };
18133
18670
  }
18134
18671
  /**
@@ -18242,12 +18779,12 @@ var PostgresDriver = class {
18242
18779
  * @param startParamIndex - Starting parameter index
18243
18780
  * @returns Object with WHERE clause string and parameters
18244
18781
  */
18245
- buildWhereClause(filter, startParamIndex) {
18782
+ buildWhereClause(filter, startParamIndex, qualifier) {
18246
18783
  const conditions = [];
18247
18784
  const params = [];
18248
18785
  let paramIndex = startParamIndex;
18249
18786
  for (const [key, value] of Object.entries(filter)) {
18250
- const quotedKey = this.dialect.quoteIdentifier(key);
18787
+ const quotedKey = (qualifier ? `${qualifier}.` : "") + this.dialect.quoteIdentifier(key);
18251
18788
  if (value === null) conditions.push(`${quotedKey} IS NULL`);
18252
18789
  else if (this.isOperatorFilter(value)) for (const [operator, operand] of Object.entries(value)) switch (operator) {
18253
18790
  case "$in":
@@ -18297,9 +18834,11 @@ var PostgresDriver = class {
18297
18834
  params.push(value);
18298
18835
  }
18299
18836
  }
18837
+ const condition = conditions.join(" AND ");
18300
18838
  return {
18301
- whereClause: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
18302
- whereParams: params
18839
+ whereClause: condition ? `WHERE ${condition}` : "",
18840
+ whereParams: params,
18841
+ condition
18303
18842
  };
18304
18843
  }
18305
18844
  /**
@@ -18314,39 +18853,54 @@ var PostgresDriver = class {
18314
18853
  return keys.length > 0 && keys.every((key) => key.startsWith("$"));
18315
18854
  }
18316
18855
  /**
18317
- * Build an UPDATE query from update operations.
18856
+ * Build the SET clauses for an update.
18318
18857
  *
18319
- * @param table - Target table name
18320
- * @param filter - Filter conditions
18321
- * @param update - Update operations
18322
- * @param limit - Optional limit (for single row update)
18323
- * @returns Object with SQL and parameters
18858
+ * @param table - Target table name (for value serialization)
18859
+ * @param update - Update operations (`$setOnInsert` is not part of SET)
18860
+ * @param startIndex - First placeholder index
18861
+ * @param qualifier - Quoted table name to qualify right-hand column references
18862
+ * (needed where another relation with the same columns is in scope)
18324
18863
  */
18325
- buildUpdateQuery(table, filter, update, limit) {
18326
- const setClauses = [];
18864
+ buildSetClauses(table, update, startIndex, qualifier) {
18865
+ const clauses = [];
18327
18866
  const params = [];
18328
- let paramIndex = 1;
18867
+ let paramIndex = startIndex;
18868
+ const reference = (quotedKey) => qualifier ? `${qualifier}.${quotedKey}` : quotedKey;
18329
18869
  if (update.$set) for (const [key, value] of Object.entries(update.$set)) {
18330
- setClauses.push(`${this.dialect.quoteIdentifier(key)} = ${this.dialect.placeholder(paramIndex++)}`);
18870
+ clauses.push(`${this.dialect.quoteIdentifier(key)} = ${this.dialect.placeholder(paramIndex++)}`);
18331
18871
  params.push(value === void 0 ? value : this.serializeValue(key, value, table));
18332
18872
  }
18333
- if (update.$unset) for (const key of Object.keys(update.$unset)) setClauses.push(`${this.dialect.quoteIdentifier(key)} = NULL`);
18334
- if (update.$inc) for (const [key, amount] of Object.entries(update.$inc)) {
18335
- const quotedKey = this.dialect.quoteIdentifier(key);
18336
- setClauses.push(`${quotedKey} = COALESCE(${quotedKey}, 0) + ${this.dialect.placeholder(paramIndex++)}`);
18337
- params.push(amount);
18338
- }
18339
- if (update.$dec) for (const [key, amount] of Object.entries(update.$dec)) {
18873
+ if (update.$unset) for (const key of Object.keys(update.$unset)) clauses.push(`${this.dialect.quoteIdentifier(key)} = NULL`);
18874
+ for (const [operator, sign] of [["$inc", "+"], ["$dec", "-"]]) for (const [key, amount] of Object.entries(update[operator] ?? {})) {
18340
18875
  const quotedKey = this.dialect.quoteIdentifier(key);
18341
- setClauses.push(`${quotedKey} = COALESCE(${quotedKey}, 0) - ${this.dialect.placeholder(paramIndex++)}`);
18876
+ clauses.push(`${quotedKey} = COALESCE(${reference(quotedKey)}, 0) ${sign} ${this.dialect.placeholder(paramIndex++)}`);
18342
18877
  params.push(amount);
18343
18878
  }
18344
- if (setClauses.length === 0) throw new Error("No update operations specified");
18879
+ return {
18880
+ clauses,
18881
+ params,
18882
+ nextIndex: paramIndex
18883
+ };
18884
+ }
18885
+ /**
18886
+ * Build an UPDATE query from update operations.
18887
+ *
18888
+ * @param table - Target table name
18889
+ * @param filter - Filter conditions
18890
+ * @param update - Update operations
18891
+ * @param limit - Optional limit (for single row update)
18892
+ * @returns Object with SQL and parameters
18893
+ * @throws UnsupportedUpdateOperationError for operations SQL cannot express
18894
+ */
18895
+ buildUpdateQuery(table, filter, update, limit) {
18896
+ const set = this.buildSetClauses(table, assertPostgresUpdate(update), 1);
18897
+ if (set.clauses.length === 0) throw new Error("No update operations specified");
18898
+ const params = [...set.params];
18345
18899
  const quotedTable = this.dialect.quoteIdentifier(table);
18346
- const { whereClause, whereParams } = this.buildWhereClause(filter, paramIndex);
18900
+ const { whereClause, whereParams } = this.buildWhereClause(filter, set.nextIndex);
18347
18901
  params.push(...whereParams);
18348
- let sql = `UPDATE ${quotedTable} SET ${setClauses.join(", ")} ${whereClause}`;
18349
- if (limit === 1 && whereClause) sql = `UPDATE ${quotedTable} SET ${setClauses.join(", ")} WHERE ctid IN (SELECT ctid FROM ${quotedTable} ${whereClause} LIMIT 1)`;
18902
+ let sql = `UPDATE ${quotedTable} SET ${set.clauses.join(", ")} ${whereClause}`;
18903
+ if (limit === 1 && whereClause) sql = `UPDATE ${quotedTable} SET ${set.clauses.join(", ")} WHERE ctid IN (SELECT ctid FROM ${quotedTable} ${whereClause} LIMIT 1)`;
18350
18904
  return {
18351
18905
  sql,
18352
18906
  params
@@ -23101,6 +23655,9 @@ exports.SyncManager = SyncManager;
23101
23655
  exports.TransactionRollbackError = TransactionRollbackError;
23102
23656
  exports.UnsafeFilterError = UnsafeFilterError;
23103
23657
  exports.UnsafeRawExpressionError = UnsafeRawExpressionError;
23658
+ exports.UnsupportedLeanOperationError = UnsupportedLeanOperationError;
23659
+ exports.UnsupportedQueryOperationError = UnsupportedQueryOperationError;
23660
+ exports.UnsupportedUpdateOperationError = UnsupportedUpdateOperationError;
23104
23661
  exports.arrayBigInt = arrayBigInt;
23105
23662
  exports.arrayBoolean = arrayBoolean;
23106
23663
  exports.arrayDate = arrayDate;