@warlock.js/cascade 4.4.0 → 4.6.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 (77) hide show
  1. package/CHANGELOG.md +29 -3
  2. package/cjs/index.cjs +933 -90
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/contracts/database-driver.contract.d.mts +27 -4
  5. package/esm/contracts/database-driver.contract.d.mts.map +1 -1
  6. package/esm/contracts/database-id-generator.contract.d.mts +38 -0
  7. package/esm/contracts/database-id-generator.contract.d.mts.map +1 -1
  8. package/esm/contracts/index.d.mts +1 -1
  9. package/esm/contracts/query-builder.contract.d.mts +27 -0
  10. package/esm/contracts/query-builder.contract.d.mts.map +1 -1
  11. package/esm/data-source/data-source.d.mts +21 -1
  12. package/esm/data-source/data-source.d.mts.map +1 -1
  13. package/esm/data-source/data-source.mjs +22 -0
  14. package/esm/data-source/data-source.mjs.map +1 -1
  15. package/esm/drivers/mongodb/mongodb-driver.d.mts +2 -2
  16. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  17. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  18. package/esm/drivers/mongodb/mongodb-id-generator.d.mts +98 -48
  19. package/esm/drivers/mongodb/mongodb-id-generator.d.mts.map +1 -1
  20. package/esm/drivers/mongodb/mongodb-id-generator.mjs +153 -59
  21. package/esm/drivers/mongodb/mongodb-id-generator.mjs.map +1 -1
  22. package/esm/drivers/mongodb/mongodb-query-builder.d.mts +25 -0
  23. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  24. package/esm/drivers/mongodb/mongodb-query-builder.mjs +32 -0
  25. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  26. package/esm/drivers/mongodb/mongodb-query-parser.d.mts +36 -0
  27. package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
  28. package/esm/drivers/mongodb/mongodb-query-parser.mjs +80 -1
  29. package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
  30. package/esm/drivers/postgres/postgres-dialect.d.mts +32 -4
  31. package/esm/drivers/postgres/postgres-dialect.d.mts.map +1 -1
  32. package/esm/drivers/postgres/postgres-dialect.mjs +57 -4
  33. package/esm/drivers/postgres/postgres-dialect.mjs.map +1 -1
  34. package/esm/drivers/postgres/postgres-driver.d.mts +42 -0
  35. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  36. package/esm/drivers/postgres/postgres-driver.mjs +55 -6
  37. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  38. package/esm/drivers/postgres/postgres-query-builder.d.mts +32 -0
  39. package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
  40. package/esm/drivers/postgres/postgres-query-builder.mjs +47 -1
  41. package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
  42. package/esm/drivers/postgres/postgres-query-parser.d.mts +13 -2
  43. package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
  44. package/esm/drivers/postgres/postgres-query-parser.mjs +21 -4
  45. package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
  46. package/esm/drivers/postgres/types.d.mts +15 -0
  47. package/esm/drivers/postgres/types.d.mts.map +1 -1
  48. package/esm/drivers/sql/sql-dialect.contract.d.mts +20 -0
  49. package/esm/drivers/sql/sql-dialect.contract.d.mts.map +1 -1
  50. package/esm/expressions/aggregate-expressions.d.mts +71 -34
  51. package/esm/expressions/aggregate-expressions.d.mts.map +1 -1
  52. package/esm/expressions/aggregate-expressions.mjs +80 -7
  53. package/esm/expressions/aggregate-expressions.mjs.map +1 -1
  54. package/esm/expressions/column-expressions.d.mts +193 -0
  55. package/esm/expressions/column-expressions.d.mts.map +1 -0
  56. package/esm/expressions/column-expressions.mjs +152 -0
  57. package/esm/expressions/column-expressions.mjs.map +1 -0
  58. package/esm/index.d.mts +3 -2
  59. package/esm/index.mjs +2 -1
  60. package/esm/model/methods/write-methods.d.mts +29 -0
  61. package/esm/model/methods/write-methods.d.mts.map +1 -0
  62. package/esm/model/methods/write-methods.mjs +164 -2
  63. package/esm/model/methods/write-methods.mjs.map +1 -1
  64. package/esm/model/model.d.mts +64 -3
  65. package/esm/model/model.d.mts.map +1 -1
  66. package/esm/model/model.mjs +65 -3
  67. package/esm/model/model.mjs.map +1 -1
  68. package/esm/writer/database-writer.d.mts.map +1 -1
  69. package/esm/writer/database-writer.mjs +4 -3
  70. package/esm/writer/database-writer.mjs.map +1 -1
  71. package/llms-full.txt +106 -7
  72. package/llms.txt +3 -3
  73. package/package.json +4 -4
  74. package/skills/README.md +3 -3
  75. package/skills/aggregate-data/SKILL.md +43 -3
  76. package/skills/manage-transactions/SKILL.md +40 -1
  77. package/skills/perform-atomic-ops/SKILL.md +23 -3
package/cjs/index.cjs CHANGED
@@ -196,6 +196,28 @@ var DataSource = class {
196
196
  const driver = this.driver;
197
197
  if (typeof driver.getIdGenerator === "function") return driver.getIdGenerator();
198
198
  }
199
+ /**
200
+ * Execute a raw query against this data source's driver.
201
+ *
202
+ * Thin passthrough to `driver.query`, so it is transaction-aware: inside a
203
+ * `transaction()` scope the query auto-joins the active transaction
204
+ * client/session.
205
+ *
206
+ * @typeParam T - The expected row shape.
207
+ * @param sql - Raw SQL string (driver dialect).
208
+ * @param params - Optional positional query parameters.
209
+ * @returns The driver's raw query result (`rows` + `rowCount`).
210
+ *
211
+ * @example
212
+ * ```typescript
213
+ * const { rows } = await dataSource.raw<{ count: number }>(
214
+ * "SELECT COUNT(*)::int AS count FROM users",
215
+ * );
216
+ * ```
217
+ */
218
+ raw(sql, params) {
219
+ return this.driver.query(sql, params);
220
+ }
199
221
  };
200
222
 
201
223
  //#endregion
@@ -3862,13 +3884,14 @@ var DatabaseWriter = class {
3862
3884
  mode: isInsert ? "insert" : "update"
3863
3885
  });
3864
3886
  if (options.skipValidation || !this.schema) return;
3865
- const validationSchema = isInsert ? this.schema.clone() : this.schema.clone(Object.keys(this.model.data)).extend({
3887
+ const systemColumns = {
3866
3888
  id: _warlock_js_seal.v.scalar().optional(),
3867
3889
  _id: _warlock_js_seal.v.any().optional(),
3868
3890
  ...(0, _mongez_reinforcements.when)(this.ctor.createdAtColumn, () => ({ [this.ctor.createdAtColumn]: _warlock_js_seal.v.date().optional() })),
3869
3891
  ...(0, _mongez_reinforcements.when)(this.ctor.updatedAtColumn, () => ({ [this.ctor.updatedAtColumn]: _warlock_js_seal.v.date().optional() })),
3870
3892
  ...(0, _mongez_reinforcements.when)(this.ctor.deletedAtColumn, () => ({ [this.ctor.deletedAtColumn]: _warlock_js_seal.v.date().optional() }))
3871
- });
3893
+ };
3894
+ const validationSchema = isInsert ? this.schema.clone().extend(systemColumns) : this.schema.clone(Object.keys(this.model.data)).extend(systemColumns);
3872
3895
  if (this.strictMode === "strip") validationSchema.stripUnknown();
3873
3896
  else if (this.strictMode === "fail") validationSchema.allowUnknown(false);
3874
3897
  else if (this.strictMode === "allow") validationSchema.allowUnknown(true);
@@ -3899,7 +3922,7 @@ var DatabaseWriter = class {
3899
3922
  await this.generateNextId();
3900
3923
  const dataToInsert = this.model.data;
3901
3924
  const createdAtColumn = this.ctor.createdAtColumn;
3902
- if (createdAtColumn) dataToInsert[createdAtColumn] = /* @__PURE__ */ new Date();
3925
+ if (createdAtColumn && dataToInsert[createdAtColumn] == null) dataToInsert[createdAtColumn] = /* @__PURE__ */ new Date();
3903
3926
  const updatedAtColumn = this.ctor.updatedAtColumn;
3904
3927
  if (updatedAtColumn) dataToInsert[updatedAtColumn] = /* @__PURE__ */ new Date();
3905
3928
  if (!options.skipEvents) await this.model.emitEvent("creating");
@@ -4857,6 +4880,16 @@ function getGlobalEvents() {
4857
4880
 
4858
4881
  //#endregion
4859
4882
  //#region ../@warlock.js/cascade/src/model/methods/write-methods.ts
4883
+ /**
4884
+ * Default number of rows processed per chunk by {@link createManyRecords}.
4885
+ *
4886
+ * Chunking keeps a single huge `createMany` call from flooding the driver:
4887
+ * the default path runs each chunk's `save()` calls under one `Promise.all`,
4888
+ * and the bulk path emits one multi-row `insertMany` per chunk so the
4889
+ * generated `INSERT ... VALUES (...),(...)` stays under the database's bind
4890
+ * parameter ceiling.
4891
+ */
4892
+ const DEFAULT_CREATE_MANY_BATCH_SIZE = 500;
4860
4893
  async function saveModel(model, options) {
4861
4894
  if (options?.merge) model.merge(options.merge);
4862
4895
  await new DatabaseWriter(model).save(options);
@@ -4867,8 +4900,160 @@ async function createRecord(ModelClass, data) {
4867
4900
  await model.save();
4868
4901
  return model;
4869
4902
  }
4870
- async function createManyRecords(ModelClass, data) {
4871
- return await Promise.all(data.map((item) => createRecord(ModelClass, item)));
4903
+ /**
4904
+ * Split an array into sequential chunks of at most `size` elements.
4905
+ *
4906
+ * @param items - The array to split.
4907
+ * @param size - Maximum chunk length (callers pass a value `>= 1`).
4908
+ * @returns An array of chunks preserving the original order.
4909
+ */
4910
+ function chunkArray(items, size) {
4911
+ const chunks = [];
4912
+ for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size));
4913
+ return chunks;
4914
+ }
4915
+ /**
4916
+ * Create many records, chunking the work so a huge array can't overwhelm the
4917
+ * driver.
4918
+ *
4919
+ * Two strategies are available via `options.bulk`:
4920
+ *
4921
+ * - **Default (`bulk` falsy):** each row is persisted through `save()`, so
4922
+ * model hooks, lifecycle events, casts and generated ids are all preserved.
4923
+ * Rows are processed in sequential chunks of `batchSize`, each chunk running
4924
+ * under a single `Promise.all`, so an array of millions of rows can't open
4925
+ * millions of concurrent inserts at once.
4926
+ *
4927
+ * - **Bulk (`bulk: true`):** each chunk is routed to the driver's native
4928
+ * multi-row insert (`insertMany`) for 10–100× throughput. This SKIPS the
4929
+ * per-row save lifecycle (no `saving` / `creating` / `created` / `saved`
4930
+ * events, hooks, or sync). Rows are still prepped through the writer
4931
+ * pipeline (validation, casting, timestamps, defaults, id-generation) so the
4932
+ * persisted columns match the default path, and the values returned by the
4933
+ * driver (generated `_id`, timestamps, SQL `RETURNING *`) are merged back
4934
+ * onto the returned models.
4935
+ *
4936
+ * Both strategies return the created model instances (an empty array is a
4937
+ * no-op that never touches the driver).
4938
+ *
4939
+ * @param ModelClass - The model class to create records for.
4940
+ * @param data - The rows to insert.
4941
+ * @param options - Chunking / bulk options.
4942
+ * @returns The created model instances.
4943
+ *
4944
+ * @example
4945
+ * ```typescript
4946
+ * // Default: per-row save(), chunked in batches of 500
4947
+ * const users = await User.createMany(rows);
4948
+ *
4949
+ * // Bulk: one multi-row INSERT per 1000-row chunk (skips per-row hooks)
4950
+ * const users = await User.createMany(rows, { bulk: true, batchSize: 1000 });
4951
+ * ```
4952
+ */
4953
+ async function createManyRecords(ModelClass, data, options = {}) {
4954
+ if (data.length === 0) return [];
4955
+ const chunks = chunkArray(data, options.batchSize && options.batchSize > 0 ? options.batchSize : 500);
4956
+ if (options.bulk === true) return await createManyBulk(ModelClass, chunks);
4957
+ const created = [];
4958
+ for (const chunk of chunks) {
4959
+ const models = chunk.map((item) => new ModelClass(item));
4960
+ await assignIdBlock(ModelClass, models);
4961
+ const chunkModels = await Promise.all(models.map(async (model) => {
4962
+ await model.save();
4963
+ return model;
4964
+ }));
4965
+ created.push(...chunkModels);
4966
+ }
4967
+ return created;
4968
+ }
4969
+ /**
4970
+ * Reserve a single contiguous id block for one chunk and pre-assign the ids to
4971
+ * the models that don't already carry one, so the per-row writer's id
4972
+ * generation no-ops (its `model.get("id")` guard short-circuits). This turns N
4973
+ * per-row counter round-trips into ONE per chunk.
4974
+ *
4975
+ * Falls back to per-row generation (a no-op here) when:
4976
+ * - the model disables auto-generation (`autoGenerateId === false`) — e.g. SQL,
4977
+ * which uses native `SERIAL`;
4978
+ * - the data source's id generator has no batch API (`generateNextIds`) — the
4979
+ * optional contract member is feature-detected;
4980
+ * - the model uses a random initial id / increment — a random stride can't form
4981
+ * a contiguous block, so those models keep per-row generation.
4982
+ *
4983
+ * Only models WITHOUT a caller-supplied id consume a block slot, so a chunk
4984
+ * mixing supplied and auto ids allocates exactly the right number (no gaps).
4985
+ *
4986
+ * @param ModelClass - The model class being inserted.
4987
+ * @param models - The freshly-constructed (unsaved) models for one chunk.
4988
+ */
4989
+ async function assignIdBlock(ModelClass, models) {
4990
+ if (!ModelClass.autoGenerateId || ModelClass.randomInitialId || ModelClass.randomIncrement) return;
4991
+ const idGenerator = ModelClass.getDataSource().idGenerator;
4992
+ if (!idGenerator || typeof idGenerator.generateNextIds !== "function") return;
4993
+ const idLessModels = models.filter((model) => !model.get("id"));
4994
+ if (idLessModels.length === 0) return;
4995
+ const ids = await idGenerator.generateNextIds({
4996
+ table: ModelClass.table,
4997
+ initialId: ModelClass.initialId || 1,
4998
+ incrementIdBy: ModelClass.incrementIdBy || 1,
4999
+ count: idLessModels.length
5000
+ });
5001
+ if (ids.length !== idLessModels.length) throw new Error(`generateNextIds returned ${ids.length} id(s) for ${idLessModels.length} row(s) on "${ModelClass.table}" — a batch id generator must return exactly the requested count.`);
5002
+ idLessModels.forEach((model, index) => {
5003
+ model.set("id", ids[index]);
5004
+ });
5005
+ }
5006
+ /**
5007
+ * Bulk variant of {@link createManyRecords}: prep each row through the writer
5008
+ * pipeline (validation, casting, timestamps, defaults, id-generation) without
5009
+ * touching the database, then flush each chunk with a single
5010
+ * `driver.insertMany` call.
5011
+ *
5012
+ * The writer reuse is achieved by swapping the driver's single-row `insert`
5013
+ * for a capturing stub for the lifetime of the bulk operation: running
5014
+ * `writer.save({ skipEvents, skipSync })` exercises the real prep but records
5015
+ * the prepared document instead of issuing N single-row inserts. The original
5016
+ * `insert` is always restored in `finally`.
5017
+ *
5018
+ * @param ModelClass - The model class to create records for.
5019
+ * @param chunks - Pre-chunked rows.
5020
+ * @returns The created model instances with driver-returned values merged in.
5021
+ */
5022
+ async function createManyBulk(ModelClass, chunks) {
5023
+ const driver = ModelClass.getDataSource().driver;
5024
+ const table = ModelClass.table;
5025
+ const originalInsert = driver.insert;
5026
+ driver.insert = (async (_table, document) => {
5027
+ return { document };
5028
+ });
5029
+ const created = [];
5030
+ try {
5031
+ for (const chunk of chunks) {
5032
+ const models = chunk.map((item) => new ModelClass(item));
5033
+ await assignIdBlock(ModelClass, models);
5034
+ const preparedDocuments = await Promise.all(models.map(async (model) => {
5035
+ await new DatabaseWriter(model).save({
5036
+ skipEvents: true,
5037
+ skipSync: true
5038
+ });
5039
+ return { ...model.data };
5040
+ }));
5041
+ const results = await driver.insertMany(table, preparedDocuments);
5042
+ models.forEach((model, index) => {
5043
+ const result = results[index];
5044
+ if (result) {
5045
+ const returnedDocument = "document" in result ? result.document : result;
5046
+ model.merge(returnedDocument);
5047
+ }
5048
+ model.dirtyTracker.reset();
5049
+ model.isNew = false;
5050
+ });
5051
+ created.push(...models);
5052
+ }
5053
+ } finally {
5054
+ driver.insert = originalInsert;
5055
+ }
5056
+ return created;
4872
5057
  }
4873
5058
  async function findOrCreateRecord(ModelClass, filter, data) {
4874
5059
  const existing = await ModelClass.first(filter);
@@ -5760,6 +5945,39 @@ var Model = class Model {
5760
5945
  return this.getDataSource().driver;
5761
5946
  }
5762
5947
  /**
5948
+ * Execute a raw query against this model's driver.
5949
+ *
5950
+ * Delegates to `getDriver().query`, so it inherits the driver's
5951
+ * transaction awareness: when called inside an active `transaction()`
5952
+ * scope the query auto-joins that transaction's client/session, otherwise
5953
+ * it runs on the pool. The return value is the driver's raw result
5954
+ * ({@link RawQueryResult}) — `rows` typed as `T` plus `rowCount` — not
5955
+ * hydrated model instances.
5956
+ *
5957
+ * Note: MongoDB drivers throw, as they do not support raw SQL.
5958
+ *
5959
+ * @typeParam T - The expected row shape.
5960
+ * @param sql - Raw SQL string (driver dialect).
5961
+ * @param params - Optional positional query parameters.
5962
+ * @returns The driver's raw query result.
5963
+ *
5964
+ * @example
5965
+ * ```typescript
5966
+ * const { rows } = await User.raw<{ id: number; total: number }>(
5967
+ * "SELECT id, COUNT(*) AS total FROM orders WHERE user_id = $1 GROUP BY id",
5968
+ * [userId],
5969
+ * );
5970
+ *
5971
+ * // Auto-joins the active transaction() scope:
5972
+ * await User.transaction(async () => {
5973
+ * await User.raw("UPDATE users SET active = true WHERE id = $1", [id]);
5974
+ * });
5975
+ * ```
5976
+ */
5977
+ static raw(sql, params) {
5978
+ return this.getDriver().query(sql, params);
5979
+ }
5980
+ /**
5763
5981
  * Generate next id and set it to current model's id
5764
5982
  */
5765
5983
  async generateNextId() {
@@ -6297,10 +6515,39 @@ var Model = class Model {
6297
6515
  return createRecord(this, data);
6298
6516
  }
6299
6517
  /**
6300
- * Create many documents and return an array of created models
6518
+ * Create many records and return an array of created models.
6519
+ *
6520
+ * The work is always chunked into batches of `options.batchSize`
6521
+ * (default 500) so a huge input array can't overwhelm the driver.
6522
+ *
6523
+ * **Default path** (no options, or `bulk` falsy): each row is persisted
6524
+ * through `save()`, preserving model hooks, lifecycle events, casts and
6525
+ * generated ids. Each chunk runs under a single `Promise.all`.
6526
+ *
6527
+ * **Bulk path** (`bulk: true`): each chunk is routed to the driver's native
6528
+ * multi-row insert (`insertMany`) for 10–100× throughput. This SKIPS the
6529
+ * per-row save lifecycle (no `saving` / `creating` / `created` / `saved`
6530
+ * events, hooks, or sync). Casts, timestamps, defaults and id-generation are
6531
+ * still applied (rows are prepped through the same writer pipeline) so the
6532
+ * persisted columns match the default path.
6533
+ *
6534
+ * An empty array is a no-op that never touches the driver.
6535
+ *
6536
+ * @param data - The rows to insert.
6537
+ * @param options - Chunking / bulk options.
6538
+ * @returns The created model instances.
6539
+ *
6540
+ * @example
6541
+ * ```typescript
6542
+ * // Default: per-row save(), chunked in batches of 500
6543
+ * const users = await User.createMany(rows);
6544
+ *
6545
+ * // Bulk: one multi-row INSERT per 1000-row chunk (skips per-row hooks)
6546
+ * const users = await User.createMany(rows, { bulk: true, batchSize: 1000 });
6547
+ * ```
6301
6548
  */
6302
- static async createMany(data) {
6303
- return createManyRecords(this, data);
6549
+ static async createMany(data, options) {
6550
+ return createManyRecords(this, data, options);
6304
6551
  }
6305
6552
  /**
6306
6553
  * Find a record or create it if not found.
@@ -6699,26 +6946,99 @@ const $agg = {
6699
6946
  };
6700
6947
  },
6701
6948
  /**
6702
- * Sum a numeric field across documents in each group.
6949
+ * Count the number of distinct values of a field in each group.
6703
6950
  *
6704
- * @param field - The field name to sum
6951
+ * @param field - The field name to count distinct values of
6952
+ * @returns Abstract count-distinct expression
6953
+ *
6954
+ * @example
6955
+ * ```typescript
6956
+ * query.groupBy("country", {
6957
+ * uniqueCities: $agg.countDistinct("city")
6958
+ * });
6959
+ * ```
6960
+ *
6961
+ * **Cross-driver.** Translates to:
6962
+ * - SQL: `COUNT(DISTINCT city)`
6963
+ * - MongoDB: `{ $addToSet: "$city" }` in the `$group` stage, finalized with
6964
+ * `{ $size: "$uniqueCities" }` in the renaming `$project` (the standard
6965
+ * distinct-count-per-group pattern, since `$size` is not a `$group`
6966
+ * accumulator).
6967
+ */
6968
+ countDistinct(field) {
6969
+ return {
6970
+ __agg: "countDistinct",
6971
+ __field: field
6972
+ };
6973
+ },
6974
+ /**
6975
+ * Sum a numeric field — or a composed arithmetic expression — across
6976
+ * documents in each group.
6977
+ *
6978
+ * Pass a bare column name to sum a single field (unchanged from v1), or a
6979
+ * typed `$expr` node (`$expr.mul` / `$expr.add` / `$expr.sub` / `$expr.div` /
6980
+ * `$expr.col` / `$expr.lit`) to sum a computed value such as `price * quantity`.
6981
+ *
6982
+ * @param input - A field name or a typed `ColumnExpression`
6705
6983
  * @returns Abstract sum expression
6706
6984
  *
6707
6985
  * @example
6708
6986
  * ```typescript
6987
+ * import { $agg, $expr } from "@warlock.js/cascade";
6988
+ *
6989
+ * // Bare column
6990
+ * query.groupBy("type", { totalDuration: $agg.sum("duration") });
6991
+ *
6992
+ * // Composed expression: SUM(price * quantity)
6993
+ * query.groupBy("type", { revenue: $agg.sum($expr.mul("price", "quantity")) });
6994
+ * ```
6995
+ *
6996
+ * Translates to:
6997
+ * - SQL: `SUM("duration")` / `SUM(("price" * "quantity"))`
6998
+ * - MongoDB: `{ $sum: "$duration" }` / `{ $sum: { $multiply: ["$price", "$quantity"] } }`
6999
+ */
7000
+ sum(input) {
7001
+ if (typeof input === "string") return {
7002
+ __agg: "sum",
7003
+ __field: input
7004
+ };
7005
+ return {
7006
+ __agg: "sum",
7007
+ __field: null,
7008
+ __expr: input
7009
+ };
7010
+ },
7011
+ /**
7012
+ * Sum a raw, driver-native expression escape hatch.
7013
+ *
7014
+ * Equivalent to `$agg.sum($expr.raw(expression))`. The raw string is emitted
7015
+ * verbatim into the generated query — never build it from untrusted input.
7016
+ * Reach for the typed {@link sum} form first; this exists only for fragments
7017
+ * the typed combinators can't express.
7018
+ *
7019
+ * @param expression - A raw expression fragment (e.g. `"price * quantity"`)
7020
+ * @returns Abstract sum expression wrapping the raw fragment
7021
+ *
7022
+ * @example
7023
+ * ```typescript
6709
7024
  * query.groupBy("type", {
6710
- * totalDuration: $agg.sum("duration")
7025
+ * net: $agg.sumRaw("price * quantity * (1 - discount)"),
6711
7026
  * });
6712
7027
  * ```
6713
7028
  *
6714
7029
  * Translates to:
6715
- * - MongoDB: `{ $sum: "$duration" }`
6716
- * - SQL: `SUM(duration)`
7030
+ * - SQL: `SUM(price * quantity * (1 - discount))`
7031
+ * - MongoDB: throws — raw SQL fragments are not portable to a pipeline; use
7032
+ * the typed {@link sum} form (or `groupByRaw`) on MongoDB instead.
6717
7033
  */
6718
- sum(field) {
7034
+ sumRaw(expression) {
6719
7035
  return {
6720
7036
  __agg: "sum",
6721
- __field: field
7037
+ __field: null,
7038
+ __expr: {
7039
+ __expr: "raw",
7040
+ expression
7041
+ }
6722
7042
  };
6723
7043
  },
6724
7044
  /**
@@ -6891,6 +7211,156 @@ const $agg = {
6891
7211
  }
6892
7212
  };
6893
7213
 
7214
+ //#endregion
7215
+ //#region ../@warlock.js/cascade/src/expressions/column-expressions.ts
7216
+ /**
7217
+ * Type guard: is `value` one of the typed column-expression nodes?
7218
+ *
7219
+ * @param value - The value to test
7220
+ * @returns `true` when `value` is a `ColumnExpression` node
7221
+ */
7222
+ function isColumnExpression(value) {
7223
+ return typeof value === "object" && value !== null && typeof value.__expr === "string";
7224
+ }
7225
+ /**
7226
+ * Reference a column/field by name.
7227
+ *
7228
+ * @param column - The column/field name
7229
+ * @returns A column-reference expression node
7230
+ */
7231
+ function col(column) {
7232
+ return {
7233
+ __expr: "column",
7234
+ column
7235
+ };
7236
+ }
7237
+ /**
7238
+ * A scalar literal (number or boolean) operand.
7239
+ *
7240
+ * @param value - The literal value
7241
+ * @returns A literal expression node
7242
+ */
7243
+ function lit(value) {
7244
+ return {
7245
+ __expr: "literal",
7246
+ value
7247
+ };
7248
+ }
7249
+ /**
7250
+ * Multiply two or more operands.
7251
+ *
7252
+ * @param operands - Column names, literals, or nested expression nodes
7253
+ * @returns A multiply expression node
7254
+ */
7255
+ function mul(...operands) {
7256
+ return {
7257
+ __expr: "multiply",
7258
+ operands: operands.map(toColumnExpression)
7259
+ };
7260
+ }
7261
+ /**
7262
+ * Add two or more operands.
7263
+ *
7264
+ * @param operands - Column names, literals, or nested expression nodes
7265
+ * @returns An add expression node
7266
+ */
7267
+ function add(...operands) {
7268
+ return {
7269
+ __expr: "add",
7270
+ operands: operands.map(toColumnExpression)
7271
+ };
7272
+ }
7273
+ /**
7274
+ * Subtract `right` from `left`.
7275
+ *
7276
+ * @param left - The minuend
7277
+ * @param right - The subtrahend
7278
+ * @returns A subtract expression node
7279
+ */
7280
+ function sub(left, right) {
7281
+ return {
7282
+ __expr: "subtract",
7283
+ operands: [toColumnExpression(left), toColumnExpression(right)]
7284
+ };
7285
+ }
7286
+ /**
7287
+ * Divide `left` by `right`.
7288
+ *
7289
+ * @param left - The dividend
7290
+ * @param right - The divisor
7291
+ * @returns A divide expression node
7292
+ */
7293
+ function div(left, right) {
7294
+ return {
7295
+ __expr: "divide",
7296
+ operands: [toColumnExpression(left), toColumnExpression(right)]
7297
+ };
7298
+ }
7299
+ /**
7300
+ * Wrap a raw expression string as an escape hatch.
7301
+ *
7302
+ * The string is emitted verbatim — never build it from untrusted input. Use
7303
+ * this only when the typed `$expr.mul` / `$expr.add` / `$expr.sub` /
7304
+ * `$expr.div` / `$expr.col` / `$expr.lit` combinators can't express what you
7305
+ * need.
7306
+ *
7307
+ * @param expression - A raw, driver-native expression fragment
7308
+ * @returns A raw expression node
7309
+ */
7310
+ function raw(expression) {
7311
+ return {
7312
+ __expr: "raw",
7313
+ expression
7314
+ };
7315
+ }
7316
+ /**
7317
+ * Normalize an expression input to a `ColumnExpression` node. A bare string
7318
+ * becomes a `$expr.col(...)` reference; an existing node passes through
7319
+ * unchanged.
7320
+ *
7321
+ * @param input - A typed expression node or a bare column-name string
7322
+ * @returns The corresponding `ColumnExpression` node
7323
+ */
7324
+ function toColumnExpression(input) {
7325
+ return typeof input === "string" ? col(input) : input;
7326
+ }
7327
+ /**
7328
+ * Typed, database-agnostic column-expression builders.
7329
+ *
7330
+ * Grouped under one object (like `$agg`) so the scalar arithmetic that feeds
7331
+ * an aggregate reads as a single, discoverable namespace: `$agg.sum($expr.mul(
7332
+ * "price", "quantity"))`. Each builder returns a plain, closed `ColumnExpression`
7333
+ * node — the driver translates it to native SQL / a Mongo pipeline.
7334
+ *
7335
+ * @example
7336
+ * ```typescript
7337
+ * import { $agg, $expr } from "@warlock.js/cascade";
7338
+ *
7339
+ * // SUM(price * quantity)
7340
+ * query.groupBy("product_id", { revenue: $agg.sum($expr.mul("price", "quantity")) });
7341
+ *
7342
+ * // SUM((1 - discount) * price)
7343
+ * query.groupBy("product_id", {
7344
+ * net: $agg.sum($expr.mul($expr.sub($expr.lit(1), "discount"), "price")),
7345
+ * });
7346
+ *
7347
+ * // price * 1.2 (e.g. tax)
7348
+ * $agg.sum($expr.mul("price", $expr.lit(1.2)));
7349
+ *
7350
+ * // escape hatch (Postgres-only, emitted verbatim — never from user input)
7351
+ * $agg.sum($expr.raw("price * quantity * (1 - discount)"));
7352
+ * ```
7353
+ */
7354
+ const $expr = {
7355
+ col,
7356
+ lit,
7357
+ mul,
7358
+ add,
7359
+ sub,
7360
+ div,
7361
+ raw
7362
+ };
7363
+
6894
7364
  //#endregion
6895
7365
  //#region ../@warlock.js/cascade/src/utils/is-valid-date-value.ts
6896
7366
  const isoRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/;
@@ -6963,43 +7433,54 @@ var MongoDBBlueprint = class {
6963
7433
 
6964
7434
  //#endregion
6965
7435
  //#region ../@warlock.js/cascade/src/drivers/mongodb/mongodb-id-generator.ts
7436
+ /** Number of times a reservation retries after a duplicate-key (E11000) error. */
7437
+ const MAX_RESERVE_ATTEMPTS = 3;
7438
+ /** MongoDB duplicate-key error code. */
7439
+ const DUPLICATE_KEY_ERROR_CODE = 11e3;
7440
+ /**
7441
+ * Is `error` a MongoDB duplicate-key (E11000) error?
7442
+ *
7443
+ * Raised when two concurrent first-time upserts race to create the same
7444
+ * counter document once the unique index on `{ collection: 1 }` exists.
7445
+ */
7446
+ function isDuplicateKeyError(error) {
7447
+ return typeof error === "object" && error !== null && error.code === DUPLICATE_KEY_ERROR_CODE;
7448
+ }
6966
7449
  /**
6967
7450
  * MongoDB-specific ID generator for auto-incrementing integer IDs.
6968
7451
  *
6969
- * Maintains a separate collection that tracks the last generated ID for each table.
6970
- * Generates auto-incrementing IDs similar to SQL's AUTO_INCREMENT feature.
7452
+ * Maintains a separate collection that tracks the last generated ID for each
7453
+ * table, mimicking SQL's `AUTO_INCREMENT` / `SERIAL`.
6971
7454
  *
6972
7455
  * **Collection Structure:**
6973
7456
  * ```json
6974
- * {
6975
- * "collection": "users",
6976
- * "id": 12345
6977
- * }
7457
+ * { "collection": "users", "id": 12345 }
6978
7458
  * ```
6979
7459
  *
6980
- * **Features:**
6981
- * - Atomic ID generation using findOneAndUpdate with aggregation pipeline
6982
- * - Automatic transaction support (driver handles session context)
6983
- * - Configurable initial ID and increment values
6984
- * - Thread-safe and concurrent-safe
7460
+ * **Atomicity & concurrency:**
7461
+ * - Each id (or block of ids) is reserved with a SINGLE atomic
7462
+ * `findOneAndUpdate` on one document; MongoDB serializes concurrent writes to
7463
+ * the same document, so concurrent callers receive distinct, non-overlapping
7464
+ * ids/blocks.
7465
+ * - A unique index on `{ collection: 1 }` is ensured lazily (once per instance)
7466
+ * so two concurrent first-time upserts for a brand-new table can't create
7467
+ * duplicate counter documents; the loser of that race gets an E11000 and is
7468
+ * retried (by then the document exists, so it takes the increment branch).
7469
+ *
7470
+ * **Transactions — IMPORTANT:** the counter write is its OWN standalone,
7471
+ * immediately-durable operation. It does NOT join an ambient
7472
+ * `transaction()` session (it calls `findOneAndUpdate` directly without a
7473
+ * session). This is intentional and matches SQL `SERIAL` semantics: if the
7474
+ * surrounding transaction rolls back, the inserted records are undone but the
7475
+ * consumed id(s) are NOT — they remain a gap in the sequence. Do not rely on
7476
+ * id reservation being rolled back with a transaction.
6985
7477
  *
6986
7478
  * @example
6987
7479
  * ```typescript
6988
- * const mongoDriver = new MongoDbDriver(config);
6989
7480
  * const idGenerator = new MongoIdGenerator(mongoDriver);
6990
7481
  *
6991
- * const dataSource = new DataSource({
6992
- * name: "primary",
6993
- * driver: mongoDriver,
6994
- * idGenerator,
6995
- * });
6996
- *
6997
- * // Generate IDs with custom configuration
6998
- * const id = await idGenerator.generateNextId({
6999
- * table: "users",
7000
- * initialId: 1000,
7001
- * incrementIdBy: 1
7002
- * });
7482
+ * const id = await idGenerator.generateNextId({ table: "users" }); // one id
7483
+ * const ids = await idGenerator.generateNextIds({ table: "users", count: 100 }); // a block
7003
7484
  * ```
7004
7485
  */
7005
7486
  var MongoIdGenerator = class {
@@ -7012,15 +7493,15 @@ var MongoIdGenerator = class {
7012
7493
  */
7013
7494
  counterCollection = "MasterMind";
7014
7495
  /**
7496
+ * Memoized "ensure unique index" promise — the index is created at most once
7497
+ * per generator instance, before the first reservation completes.
7498
+ */
7499
+ indexEnsured;
7500
+ /**
7015
7501
  * Create a new MongoDB ID generator instance.
7016
7502
  *
7017
7503
  * @param driver - The MongoDB driver instance
7018
7504
  * @param counterCollection - Name of the collection storing ID counters (default: "MasterMind")
7019
- *
7020
- * @example
7021
- * ```typescript
7022
- * const idGenerator = new MongoIdGenerator(mongoDriver, "id_counters");
7023
- * ```
7024
7505
  */
7025
7506
  constructor(driver, counterCollection) {
7026
7507
  this.driver = driver;
@@ -7029,66 +7510,149 @@ var MongoIdGenerator = class {
7029
7510
  /**
7030
7511
  * Generate the next ID for a table.
7031
7512
  *
7032
- * Uses atomic findOneAndUpdate with aggregation pipeline to ensure uniqueness
7033
- * even in concurrent scenarios. Automatically participates in active transactions.
7513
+ * Reserves a single id via one atomic `findOneAndUpdate` (see the class doc
7514
+ * for the atomicity / transaction contract). Equivalent to a block of size 1.
7034
7515
  *
7035
7516
  * @param options - Configuration for ID generation
7036
7517
  * @returns The generated ID
7037
7518
  *
7038
7519
  * @example
7039
7520
  * ```typescript
7040
- * // Simple usage
7041
- * const id = await idGenerator.generateNextId({ table: "users" });
7042
- *
7043
- * // With custom initial ID
7044
- * const id = await idGenerator.generateNextId({
7045
- * table: "products",
7046
- * initialId: 1000,
7047
- * incrementIdBy: 1
7048
- * });
7521
+ * const id = await idGenerator.generateNextId({ table: "users", initialId: 1000 });
7049
7522
  * ```
7050
7523
  */
7051
7524
  async generateNextId(options) {
7052
7525
  const { table, initialId = 1, incrementIdBy = 1 } = options;
7053
- return (await this.driver.getDatabase().collection(this.counterCollection).findOneAndUpdate({ collection: table }, [{ $set: {
7054
- id: { $cond: {
7055
- if: { $or: [{ $eq: ["$id", null] }, { $not: "$id" }] },
7056
- then: initialId,
7057
- else: { $add: ["$id", incrementIdBy] }
7058
- } },
7059
- collection: table
7060
- } }], {
7061
- upsert: true,
7062
- returnDocument: "after"
7063
- }))?.id ?? initialId;
7526
+ await this.ensureIndexes();
7527
+ return await this.reserveBlock(table, initialId, incrementIdBy, 1);
7064
7528
  }
7065
7529
  /**
7066
- * Get the last generated ID for a table.
7530
+ * Reserve a contiguous block of `count` ids in a single atomic operation.
7067
7531
  *
7068
- * @param table - The table/collection name
7069
- * @returns The last generated ID, or 0 if none exists
7532
+ * Advances the counter by `count * incrementIdBy` in one `findOneAndUpdate`
7533
+ * (so the stored last id becomes the block's last id) and returns the block
7534
+ * in ascending order. See the class doc for the non-transactional contract.
7535
+ *
7536
+ * @param options - `GenerateIdOptions` plus the block `count`
7537
+ * @returns The reserved ids in ascending order (length `count`)
7070
7538
  *
7071
7539
  * @example
7072
7540
  * ```typescript
7073
- * const lastId = await idGenerator.getLastId("users");
7074
- * console.log(`Last user ID: ${lastId}`);
7541
+ * const ids = await idGenerator.generateNextIds({ table: "users", count: 100 });
7542
+ * // ids[0] is the first id, ids[99] the last; getLastId("users") === ids[99]
7075
7543
  * ```
7076
7544
  */
7545
+ async generateNextIds(options) {
7546
+ const { table, initialId = 1, incrementIdBy = 1, count } = options;
7547
+ if (count <= 0) return [];
7548
+ await this.ensureIndexes();
7549
+ const firstId = await this.reserveBlock(table, initialId, incrementIdBy, count) - (count - 1) * incrementIdBy;
7550
+ return Array.from({ length: count }, (_, index) => firstId + index * incrementIdBy);
7551
+ }
7552
+ /**
7553
+ * Atomically advance the counter for `table` by a whole block and return the
7554
+ * block's LAST id.
7555
+ *
7556
+ * One `findOneAndUpdate` with an aggregation pipeline:
7557
+ * - **Cold start** (counter field missing or null): seed `initialId + (count - 1) * incrementIdBy`
7558
+ * so the FIRST id of the block equals `initialId` (the `count - 1` here vs
7559
+ * `count` in the steady-state branch is deliberate — the counter stores the
7560
+ * last-issued id and the very first id must be exactly `initialId`).
7561
+ * - **Steady state**: add `count * incrementIdBy` to the stored counter.
7562
+ *
7563
+ * Retries on a duplicate-key error from the cold-start upsert race (see
7564
+ * {@link isDuplicateKeyError}).
7565
+ *
7566
+ * @param table - The table/collection the block is for
7567
+ * @param initialId - The first id ever issued for this table
7568
+ * @param incrementIdBy - The fixed stride between ids
7569
+ * @param count - Block size (`>= 1`)
7570
+ * @returns The last id of the reserved block
7571
+ */
7572
+ async reserveBlock(table, initialId, incrementIdBy, count) {
7573
+ const run = async () => {
7574
+ const lastId = (await this.driver.getDatabase().collection(this.counterCollection).findOneAndUpdate({ collection: table }, [{ $set: {
7575
+ id: { $cond: {
7576
+ if: { $or: [{ $eq: [{ $type: "$id" }, "missing"] }, { $eq: ["$id", null] }] },
7577
+ then: initialId + (count - 1) * incrementIdBy,
7578
+ else: { $add: ["$id", count * incrementIdBy] }
7579
+ } },
7580
+ collection: table
7581
+ } }], {
7582
+ upsert: true,
7583
+ returnDocument: "after"
7584
+ }))?.id;
7585
+ if (typeof lastId !== "number") throw new Error(`Failed to reserve an id block for "${table}": counter returned no id.`);
7586
+ return lastId;
7587
+ };
7588
+ return await this.withDuplicateKeyRetry(run);
7589
+ }
7590
+ /**
7591
+ * Run `operation`, retrying on a duplicate-key (E11000) error up to
7592
+ * {@link MAX_RESERVE_ATTEMPTS} times. Only the cold-start upsert race throws
7593
+ * E11000 (once the unique index exists); on retry the counter document
7594
+ * already exists, so the increment branch runs and succeeds. Any other error
7595
+ * is rethrown immediately.
7596
+ *
7597
+ * @param operation - The reservation to run
7598
+ * @returns The operation's result
7599
+ */
7600
+ async withDuplicateKeyRetry(operation) {
7601
+ let lastError;
7602
+ for (let attempt = 1; attempt <= MAX_RESERVE_ATTEMPTS; attempt++) try {
7603
+ return await operation();
7604
+ } catch (error) {
7605
+ if (!isDuplicateKeyError(error)) throw error;
7606
+ lastError = error;
7607
+ }
7608
+ throw lastError;
7609
+ }
7610
+ /**
7611
+ * Ensure the unique index on `{ collection: 1 }` exists (once per instance).
7612
+ *
7613
+ * Best-effort: a pre-existing duplicate counter document (created before this
7614
+ * index existed) makes index creation fail with E11000. We swallow that
7615
+ * rather than block app boot — without the index the generator degrades to
7616
+ * its prior (un-indexed) behavior, and the retry path becomes a no-op since
7617
+ * E11000 can no longer be raised by the upsert.
7618
+ */
7619
+ async ensureIndexes() {
7620
+ if (!this.indexEnsured) this.indexEnsured = this.createCounterIndex();
7621
+ return await this.indexEnsured;
7622
+ }
7623
+ /**
7624
+ * Create the unique index on the counter collection's `collection` field.
7625
+ * Failures are swallowed (see {@link ensureIndexes}).
7626
+ */
7627
+ async createCounterIndex() {
7628
+ try {
7629
+ await this.driver.getDatabase().collection(this.counterCollection).createIndex({ collection: 1 }, {
7630
+ unique: true,
7631
+ name: "collection_unique"
7632
+ });
7633
+ } catch {}
7634
+ }
7635
+ /**
7636
+ * Get the last generated ID for a table.
7637
+ *
7638
+ * @param table - The table/collection name
7639
+ * @returns The last generated ID, or 0 if none exists
7640
+ */
7077
7641
  async getLastId(table) {
7078
7642
  return (await this.driver.queryBuilder(this.counterCollection).where("collection", table).first())?.id ?? 0;
7079
7643
  }
7080
7644
  /**
7081
7645
  * Set the last ID for a table.
7082
7646
  *
7083
- * Creates or updates the counter document for the specified table.
7084
- * Useful for seeding or resetting ID sequences.
7647
+ * Creates or updates the counter document for the specified table. Useful for
7648
+ * seeding or resetting ID sequences.
7085
7649
  *
7086
7650
  * @param table - The table/collection name
7087
7651
  * @param id - The ID to set as the last generated ID
7088
7652
  *
7089
7653
  * @example
7090
7654
  * ```typescript
7091
- * // Reset user IDs to start from 1000
7655
+ * // Reset user IDs to start from 1000 (next generated id is 1001)
7092
7656
  * await idGenerator.setLastId("users", 1000);
7093
7657
  * ```
7094
7658
  */
@@ -9024,6 +9588,13 @@ var MongoQueryParser = class {
9024
9588
  */
9025
9589
  groupFieldNames = /* @__PURE__ */ new Map();
9026
9590
  /**
9591
+ * Track `countDistinct` aggregate aliases per group stage. The renaming
9592
+ * `$project` finalizes these with `{ $size: "$alias" }` over the set built
9593
+ * by `$addToSet` in the `$group` stage.
9594
+ * Maps pipeline index to the set of aliases needing `$size` finalization.
9595
+ */
9596
+ countDistinctAliases = /* @__PURE__ */ new Map();
9597
+ /**
9027
9598
  * Create a new MongoDB query parser.
9028
9599
  *
9029
9600
  * @param options - Configuration options for the parser
@@ -9100,10 +9671,24 @@ var MongoQueryParser = class {
9100
9671
  if (op.type === "groupByWithAggregates" && op.data.fields) {
9101
9672
  const fieldNames = this.extractGroupFieldNames(op.data.fields);
9102
9673
  if (fieldNames) this.groupFieldNames.set(stageIndex, fieldNames);
9674
+ this.trackCountDistinctAliases(stageIndex, op.data.aggregates);
9675
+ } else if (op.type === "groupByDate") {
9676
+ this.groupFieldNames.set(stageIndex, op.data.column);
9677
+ this.trackCountDistinctAliases(stageIndex, op.data.aggregates ?? {});
9103
9678
  }
9104
9679
  }
9105
9680
  }
9106
9681
  /**
9682
+ * Record which aggregate aliases in a group stage are `countDistinct` so the
9683
+ * renaming `$project` can finalize them with `{ $size: "$alias" }` over the
9684
+ * set built by `$addToSet` (the standard distinct-count-per-group pattern).
9685
+ */
9686
+ trackCountDistinctAliases(stageIndex, aggregates) {
9687
+ const distinctAliases = /* @__PURE__ */ new Set();
9688
+ for (const [alias, expression] of Object.entries(aggregates)) if (isAggregateExpression(expression) && expression.__agg === "countDistinct") distinctAliases.add(alias);
9689
+ if (distinctAliases.size > 0) this.countDistinctAliases.set(stageIndex, distinctAliases);
9690
+ }
9691
+ /**
9107
9692
  * Post-process pipeline to rename _id fields after $group stages.
9108
9693
  *
9109
9694
  * This automatically renames MongoDB's `_id` field to the actual field name(s)
@@ -9122,8 +9707,10 @@ var MongoQueryParser = class {
9122
9707
  const projection = {};
9123
9708
  if (typeof fieldNames === "string") projection[fieldNames] = "$_id";
9124
9709
  else if (Array.isArray(fieldNames) && fieldNames.length > 0) for (const fieldName of fieldNames) projection[fieldName] = `$_id.${fieldName}`;
9710
+ const distinctAliases = this.countDistinctAliases.get(i);
9125
9711
  const aggregateFields = Object.keys(stage.$group).filter((key) => key !== "_id");
9126
- for (const field of aggregateFields) projection[field] = 1;
9712
+ for (const field of aggregateFields) if (distinctAliases?.has(field)) projection[field] = { $size: `$${field}` };
9713
+ else projection[field] = 1;
9127
9714
  if (Object.keys(projection).length > 0) {
9128
9715
  projection._id = 0;
9129
9716
  processed.push({ $project: projection });
@@ -9827,6 +10414,11 @@ var MongoQueryParser = class {
9827
10414
  if (stage) return stage;
9828
10415
  break;
9829
10416
  }
10417
+ case "groupByDate": {
10418
+ const stage = this.buildGroupByDateStage(op.data.column, op.data.unit, op.data.aggregates ?? {});
10419
+ if (stage) return stage;
10420
+ break;
10421
+ }
9830
10422
  case "groupByRaw": {
9831
10423
  const expression = op.data.expression;
9832
10424
  if (expression && typeof expression === "object") return { $group: expression };
@@ -9863,6 +10455,24 @@ var MongoQueryParser = class {
9863
10455
  return { $group: groupStage };
9864
10456
  }
9865
10457
  /**
10458
+ * Build a `$group` stage that buckets documents by a `$dateTrunc` of a date
10459
+ * field, optionally running aggregates over each bucket.
10460
+ *
10461
+ * @param column - The date field to bucket
10462
+ * @param unit - The bucket granularity
10463
+ * @param aggregates - Aggregate operations (abstract or raw)
10464
+ * @returns The `$group` stage
10465
+ */
10466
+ buildGroupByDateStage(column, unit, aggregates) {
10467
+ const groupStage = { _id: { $dateTrunc: {
10468
+ date: `$${column}`,
10469
+ unit
10470
+ } } };
10471
+ for (const [alias, expression] of Object.entries(aggregates)) if (isAggregateExpression(expression)) groupStage[alias] = this.translateAggregateExpression(expression);
10472
+ else groupStage[alias] = expression;
10473
+ return { $group: groupStage };
10474
+ }
10475
+ /**
9866
10476
  * Extract field names from GroupByInput for renaming _id.
9867
10477
  *
9868
10478
  * @param fields - The grouping fields
@@ -9886,7 +10496,11 @@ var MongoQueryParser = class {
9886
10496
  translateAggregateExpression(expr) {
9887
10497
  switch (expr.__agg) {
9888
10498
  case "count": return { $sum: 1 };
10499
+ case "countDistinct":
10500
+ if (!expr.__field) throw new Error("Count distinct aggregate requires a field name");
10501
+ return { $addToSet: `$${expr.__field}` };
9889
10502
  case "sum":
10503
+ if (expr.__expr) return { $sum: this.columnExpressionToMongo(expr.__expr) };
9890
10504
  if (!expr.__field) throw new Error("Sum aggregate requires a field name");
9891
10505
  return { $sum: `$${expr.__field}` };
9892
10506
  case "avg":
@@ -9913,6 +10527,35 @@ var MongoQueryParser = class {
9913
10527
  default: throw new Error(`Unknown aggregate function: ${expr.__agg}`);
9914
10528
  }
9915
10529
  }
10530
+ /**
10531
+ * Compile a typed {@link ColumnExpression} tree into a MongoDB aggregation
10532
+ * expression.
10533
+ *
10534
+ * Column references become `$field` paths; literals are emitted verbatim;
10535
+ * arithmetic ops map to `$add` / `$subtract` / `$multiply` / `$divide`. The
10536
+ * `raw` node is rejected — a raw SQL fragment is not portable to a MongoDB
10537
+ * pipeline, so callers must use the typed combinators (or `groupByRaw`) here.
10538
+ *
10539
+ * @param expression - The expression tree to compile
10540
+ * @returns A MongoDB aggregation expression (e.g. `{ $multiply: ["$price", "$quantity"] }`)
10541
+ */
10542
+ columnExpressionToMongo(expression) {
10543
+ switch (expression.__expr) {
10544
+ case "column": return `$${expression.column}`;
10545
+ case "literal": return expression.value;
10546
+ case "raw": throw new Error("$agg.sumRaw / $expr.raw is not portable to a MongoDB pipeline — a raw SQL fragment has no MongoDB equivalent. Use the typed $expr combinators ($expr.mul / $expr.add / $expr.sub / $expr.div / $expr.col / $expr.lit) or groupByRaw instead.");
10547
+ case "add":
10548
+ case "subtract":
10549
+ case "multiply":
10550
+ case "divide": return { [{
10551
+ add: "$add",
10552
+ subtract: "$subtract",
10553
+ multiply: "$multiply",
10554
+ divide: "$divide"
10555
+ }[expression.__expr]]: expression.operands.map((operand) => this.columnExpressionToMongo(operand)) };
10556
+ default: throw new Error(`Unsupported column expression node: ${JSON.stringify(expression)}`);
10557
+ }
10558
+ }
9916
10559
  buildGroupId(fields) {
9917
10560
  if (!fields) return null;
9918
10561
  if (typeof fields === "string") return `$${fields}`;
@@ -11055,6 +11698,38 @@ var MongoQueryBuilder = class MongoQueryBuilder extends QueryBuilder {
11055
11698
  return this;
11056
11699
  }
11057
11700
  /**
11701
+ * Portable date-bucketed grouping.
11702
+ *
11703
+ * Groups documents by `column` truncated to the given bucket
11704
+ * (`day` / `week` / `month` / `year`) via MongoDB's `$dateTrunc`, and runs
11705
+ * the optional aggregates over each bucket. The bucket is exposed under the
11706
+ * column's own name in the output (the `_id` is renamed in a follow-up
11707
+ * `$project`, the same convention as `groupBy`).
11708
+ *
11709
+ * @param column - The date field to bucket
11710
+ * @param unit - The bucket granularity
11711
+ * @param aggregates - Optional aggregate operations keyed by output alias
11712
+ *
11713
+ * @example
11714
+ * ```typescript
11715
+ * import { $agg, $expr } from "@warlock.js/cascade";
11716
+ *
11717
+ * query.groupByDate("created_at", "month", {
11718
+ * revenue: $agg.sum($expr.mul("price", "quantity")),
11719
+ * });
11720
+ * // $group: { _id: { $dateTrunc: { date: "$created_at", unit: "month" } },
11721
+ * // revenue: { $sum: { $multiply: ["$price", "$quantity"] } } }
11722
+ * ```
11723
+ */
11724
+ groupByDate(column, unit, aggregates) {
11725
+ this.operationsHelper.addGroupOperation("groupByDate", {
11726
+ column,
11727
+ unit,
11728
+ aggregates: aggregates ?? {}
11729
+ }, false);
11730
+ return this;
11731
+ }
11732
+ /**
11058
11733
  * Groups documents using a raw MongoDB expression.
11059
11734
  * @param expression - The raw grouping expression
11060
11735
  * @param bindings - Optional parameter bindings
@@ -12897,18 +13572,24 @@ var PostgresDialect = class {
12897
13572
  /**
12898
13573
  * Translate a database-agnostic aggregate expression to PostgreSQL SQL.
12899
13574
  *
12900
- * The five scalar aggregates map to their ANSI SQL function. `distinct`,
12901
- * `floor`, `first` and `last` are MongoDB-only for v1 — none has a
12902
- * single-scalar `GROUP BY` equivalent on PostgreSQL, so they throw instead
12903
- * of emitting a silently-different semantic (the footgun this guards).
13575
+ * The scalar aggregates map to their ANSI SQL function, and `countDistinct`
13576
+ * maps to `COUNT(DISTINCT …)`. `distinct`, `floor`, `first` and `last` are
13577
+ * MongoDB-only for v1 — none has a single-scalar `GROUP BY` equivalent on
13578
+ * PostgreSQL, so they throw instead of emitting a silently-different semantic
13579
+ * (the footgun this guards).
12904
13580
  *
12905
13581
  * @param expression - The abstract aggregate (`$agg.*`) to translate
12906
13582
  * @returns The SQL fragment (e.g. `SUM("amount")`, `COUNT(*)`)
12907
13583
  */
12908
13584
  aggregateToSql(expression) {
13585
+ if (expression.__expr) {
13586
+ if (expression.__agg !== "sum") throw new Error(`$agg.${expression.__agg} does not support a composed column expression yet — only $agg.sum(...) does. Use a bare column name here.`);
13587
+ return `SUM(${this.columnExpressionToSql(expression.__expr)})`;
13588
+ }
12909
13589
  const column = expression.__field === null ? "*" : this.quoteIdentifier(expression.__field);
12910
13590
  switch (expression.__agg) {
12911
13591
  case "count": return "COUNT(*)";
13592
+ case "countDistinct": return `COUNT(DISTINCT ${column})`;
12912
13593
  case "sum": return `SUM(${column})`;
12913
13594
  case "avg": return `AVG(${column})`;
12914
13595
  case "min": return `MIN(${column})`;
@@ -12916,6 +13597,53 @@ var PostgresDialect = class {
12916
13597
  default: throw new Error(`$agg.${expression.__agg} is MongoDB-only and not supported on a PostgreSQL groupBy. Use selectRaw / havingRaw with the equivalent SQL (window function / DISTINCT / FLOOR) if you need it here.`);
12917
13598
  }
12918
13599
  }
13600
+ /**
13601
+ * Compile a typed {@link ColumnExpression} tree into a SQL fragment.
13602
+ *
13603
+ * Column references flow through {@link quoteIdentifier} (so user-supplied
13604
+ * names are quoted/escaped, never raw-interpolated); literals are emitted as
13605
+ * numeric/boolean SQL constants; arithmetic ops are parenthesised. The `raw`
13606
+ * node is the only path that emits a string verbatim — it is opt-in by name.
13607
+ *
13608
+ * @param expression - The expression tree to compile
13609
+ * @returns A SQL fragment (e.g. `("price" * "quantity")`)
13610
+ */
13611
+ columnExpressionToSql(expression) {
13612
+ switch (expression.__expr) {
13613
+ case "column": return this.quoteIdentifier(expression.column);
13614
+ case "literal": return typeof expression.value === "boolean" ? this.booleanLiteral(expression.value) : String(expression.value);
13615
+ case "raw": return expression.expression;
13616
+ case "add":
13617
+ case "subtract":
13618
+ case "multiply":
13619
+ case "divide": {
13620
+ const operator = {
13621
+ add: "+",
13622
+ subtract: "-",
13623
+ multiply: "*",
13624
+ divide: "/"
13625
+ }[expression.__expr];
13626
+ return `(${expression.operands.map((operand) => this.columnExpressionToSql(operand)).join(` ${operator} `)})`;
13627
+ }
13628
+ default: throw new Error(`Unsupported column expression node: ${JSON.stringify(expression)}`);
13629
+ }
13630
+ }
13631
+ /**
13632
+ * Build a `date_trunc` bucket expression for a portable `groupByDate`.
13633
+ *
13634
+ * @param column - The date/timestamp column to truncate
13635
+ * @param unit - The bucket granularity
13636
+ * @returns The SQL fragment (e.g. `date_trunc('month', "created_at")`)
13637
+ *
13638
+ * @example
13639
+ * ```typescript
13640
+ * dialect.dateTruncSql("created_at", "month");
13641
+ * // date_trunc('month', "created_at")
13642
+ * ```
13643
+ */
13644
+ dateTruncSql(column, unit) {
13645
+ return `date_trunc('${unit}', ${this.quoteIdentifier(column)})`;
13646
+ }
12919
13647
  };
12920
13648
 
12921
13649
  //#endregion
@@ -13764,10 +14492,16 @@ var PostgresQueryParser = class PostgresQueryParser {
13764
14492
  */
13765
14493
  orderClauses = [];
13766
14494
  /**
13767
- * GROUP BY columns.
14495
+ * GROUP BY columns (bare identifiers — get quoted).
13768
14496
  */
13769
14497
  groupColumns = [];
13770
14498
  /**
14499
+ * Raw GROUP BY expressions (emitted verbatim — e.g. a `date_trunc(...)`
14500
+ * bucket). Kept separate from `groupColumns` so they bypass identifier
14501
+ * quoting while still landing in the same `GROUP BY` clause.
14502
+ */
14503
+ groupRawExpressions = [];
14504
+ /**
13771
14505
  * HAVING clauses.
13772
14506
  */
13773
14507
  havingClauses = [];
@@ -13938,6 +14672,9 @@ var PostgresQueryParser = class PostgresQueryParser {
13938
14672
  case "groupBy":
13939
14673
  this.processGroupBy(data);
13940
14674
  break;
14675
+ case "groupByRaw":
14676
+ this.processGroupByRaw(data);
14677
+ break;
13941
14678
  case "having":
13942
14679
  this.processHaving(data);
13943
14680
  break;
@@ -13972,9 +14709,9 @@ var PostgresQueryParser = class PostgresQueryParser {
13972
14709
  parts.push(fromClause);
13973
14710
  if (this.joinClauses.length > 0) parts.push(this.joinClauses.join(" "));
13974
14711
  if (this.whereClauses.length > 0) parts.push(`WHERE ${this.whereClauses.join(" ")}`);
13975
- if (this.groupColumns.length > 0) {
13976
- const quotedCols = this.groupColumns.map((c) => this.dialect.quoteIdentifier(c));
13977
- parts.push(`GROUP BY ${quotedCols.join(", ")}`);
14712
+ if (this.groupColumns.length > 0 || this.groupRawExpressions.length > 0) {
14713
+ const allGroupTerms = [...this.groupColumns.map((c) => this.dialect.quoteIdentifier(c)), ...this.groupRawExpressions];
14714
+ parts.push(`GROUP BY ${allGroupTerms.join(", ")}`);
13978
14715
  }
13979
14716
  if (this.havingClauses.length > 0) parts.push(`HAVING ${this.havingClauses.join(" AND ")}`);
13980
14717
  if (this.orderClauses.length > 0) parts.push(`ORDER BY ${this.orderClauses.join(", ")}`);
@@ -14432,6 +15169,14 @@ var PostgresQueryParser = class PostgresQueryParser {
14432
15169
  this.groupColumns.push(...columns);
14433
15170
  }
14434
15171
  /**
15172
+ * Process a raw GROUP BY expression (emitted verbatim, no quoting). Used by
15173
+ * `groupByDate` to add a `date_trunc(...)` bucket to the GROUP BY clause.
15174
+ */
15175
+ processGroupByRaw(data) {
15176
+ const expression = data.expression;
15177
+ this.groupRawExpressions.push(expression);
15178
+ }
15179
+ /**
14435
15180
  * Process HAVING operation.
14436
15181
  */
14437
15182
  processHaving(data) {
@@ -14612,6 +15357,53 @@ var PostgresQueryBuilder = class PostgresQueryBuilder extends QueryBuilder {
14612
15357
  if (!aggregates) return super.groupBy(fields);
14613
15358
  const fieldList = Array.isArray(fields) ? fields : [fields];
14614
15359
  this.addOperation("select", { fields: fieldList });
15360
+ this.projectGroupAggregates(aggregates);
15361
+ return super.groupBy(fields);
15362
+ }
15363
+ /**
15364
+ * Portable date-bucketed GROUP BY.
15365
+ *
15366
+ * Truncates `column` to the given bucket (`day` / `week` / `month` / `year`)
15367
+ * via PostgreSQL's `date_trunc`, projects the bucket under the column's own
15368
+ * name, and groups by the truncated expression. Optional `aggregates` follow
15369
+ * the same rules as the two-arg `groupBy` (`$agg.*` helpers or raw SQL
15370
+ * strings; MongoDB operator objects and MongoDB-only aggregates throw).
15371
+ *
15372
+ * Emits (for `groupByDate("created_at", "month", { revenue: $agg.sum(...) })`):
15373
+ * `SELECT date_trunc('month', "created_at") AS "created_at", SUM(...) AS "revenue"
15374
+ * FROM ... GROUP BY date_trunc('month', "created_at")`
15375
+ *
15376
+ * @param column - The date/timestamp column to bucket
15377
+ * @param unit - The bucket granularity
15378
+ * @param aggregates - Optional aggregate projections keyed by output alias
15379
+ *
15380
+ * @example
15381
+ * Order.query().groupByDate("created_at", "month", {
15382
+ * revenue: $agg.sum($expr.mul("price", "quantity")),
15383
+ * });
15384
+ */
15385
+ groupByDate(column, unit, aggregates) {
15386
+ const bucketSql = this.driver.dialect.dateTruncSql(column, unit);
15387
+ this.addOperation("selectRaw", {
15388
+ expression: `${bucketSql} AS ${this.driver.dialect.quoteIdentifier(column)}`,
15389
+ bindings: []
15390
+ });
15391
+ if (aggregates) this.projectGroupAggregates(aggregates);
15392
+ this.addOperation("groupByRaw", {
15393
+ expression: bucketSql,
15394
+ bindings: []
15395
+ });
15396
+ return this;
15397
+ }
15398
+ /**
15399
+ * Translate a `{ alias: aggregate }` map into `selectRaw` projections and
15400
+ * record each alias → SQL so `applyGroupByAggregates` can later rewrite a
15401
+ * `having()` on the alias. Shared by `groupBy` and `groupByDate`.
15402
+ *
15403
+ * `$agg.distinct/floor/first/last` throw (MongoDB-only on Postgres v1);
15404
+ * MongoDB operator objects throw (not portable to SQL).
15405
+ */
15406
+ projectGroupAggregates(aggregates) {
14615
15407
  for (const [alias, expression] of Object.entries(aggregates)) {
14616
15408
  let sql;
14617
15409
  if (isAggregateExpression(expression)) sql = this.driver.dialect.aggregateToSql(expression);
@@ -14623,7 +15415,6 @@ var PostgresQueryBuilder = class PostgresQueryBuilder extends QueryBuilder {
14623
15415
  bindings: []
14624
15416
  });
14625
15417
  }
14626
- return super.groupBy(fields);
14627
15418
  }
14628
15419
  /**
14629
15420
  * Nearest-neighbour vector similarity search via pgvector cosine distance.
@@ -16134,12 +16925,20 @@ var PostgresDriver = class {
16134
16925
  */
16135
16926
  _syncAdapter;
16136
16927
  /**
16928
+ * Lookup set of column names that hold native PostgreSQL arrays
16929
+ * (`JSONB[]`, `TEXT[]`, …) and must NOT be JSON-text encoded.
16930
+ *
16931
+ * @see PostgresPoolConfig.nativeArrayColumns
16932
+ */
16933
+ _nativeArrayColumns;
16934
+ /**
16137
16935
  * Create a new PostgreSQL driver instance.
16138
16936
  *
16139
16937
  * @param config - PostgreSQL connection configuration
16140
16938
  */
16141
16939
  constructor(config) {
16142
16940
  this.config = config;
16941
+ this._nativeArrayColumns = new Set(config.nativeArrayColumns ?? []);
16143
16942
  }
16144
16943
  /**
16145
16944
  * Get the connection pool instance.
@@ -16240,15 +17039,56 @@ var PostgresDriver = class {
16240
17039
  const serialized = {};
16241
17040
  for (const [key, value] of Object.entries(data)) {
16242
17041
  if (value === void 0) continue;
16243
- if (value instanceof Date) serialized[key] = value.toISOString();
16244
- else if (typeof value === "bigint") serialized[key] = value.toString();
16245
- else if (typeof value === "object" && value !== null && !Array.isArray(value)) serialized[key] = value;
16246
- else if (Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === "number")) serialized[key] = `[${value.join(",")}]`;
16247
- else serialized[key] = value;
17042
+ serialized[key] = this.serializeValue(key, value);
16248
17043
  }
16249
17044
  return serialized;
16250
17045
  }
16251
17046
  /**
17047
+ * Serialize a single column value into a node-pg bindable parameter.
17048
+ *
17049
+ * Shared by {@link serialize} (INSERT path) and {@link buildUpdateQuery}
17050
+ * `$set` (UPDATE path) so both encode `json` / `jsonb` columns identically.
17051
+ *
17052
+ * Encoding rules (in order):
17053
+ * - `Date` → ISO string.
17054
+ * - `bigint` → decimal string (node-pg has no native bigint binding).
17055
+ * - all-number array → pgvector literal `'[n1,n2,...]'`. node-pg would
17056
+ * otherwise emit a `{n1,n2,...}` array literal, which the `vector` type
17057
+ * rejects. This branch is preserved exactly.
17058
+ * - any other array (object-array, string-array, mixed, empty `[]`) →
17059
+ * `JSON.stringify`. node-pg renders a raw JS array as a PostgreSQL array
17060
+ * literal `{...}` (and `[]` as `{}`), which a `json` / `jsonb` column
17061
+ * rejects — so we bind the value as JSON text instead, the form those
17062
+ * columns accept. Columns listed in `nativeArrayColumns` are exempt:
17063
+ * their raw array is passed through so node-pg emits the `{...}` literal
17064
+ * a genuine `JSONB[]` / `TEXT[]` column needs.
17065
+ * - plain object → `JSON.stringify`. Equivalent to node-pg's own object
17066
+ * handling, made explicit so both write paths agree.
17067
+ * - everything else (scalars: string, number, boolean, null) → untouched.
17068
+ *
17069
+ * Boundary note: the serializer has no access to the table schema, so it
17070
+ * cannot tell a `json` / `jsonb` column from a native-array column purely
17071
+ * from the value. `nativeArrayColumns` is the explicit, opt-in escape hatch
17072
+ * for the latter. No `::jsonb` placeholder cast is added: a JSON-text string
17073
+ * binds correctly to `json` / `jsonb` without one, and a blind cast would
17074
+ * misfire on columns we cannot positively identify as jsonb.
17075
+ *
17076
+ * @param key - Column name (used to honour `nativeArrayColumns`)
17077
+ * @param value - The raw value to serialize (never `undefined`)
17078
+ * @returns The value ready to bind as a query parameter
17079
+ */
17080
+ serializeValue(key, value) {
17081
+ if (value instanceof Date) return value.toISOString();
17082
+ if (typeof value === "bigint") return value.toString();
17083
+ if (Array.isArray(value)) {
17084
+ if (value.length > 0 && value.every((v) => typeof v === "number")) return `[${value.join(",")}]`;
17085
+ if (this._nativeArrayColumns.has(key)) return value;
17086
+ return JSON.stringify(value);
17087
+ }
17088
+ if (typeof value === "object" && value !== null) return JSON.stringify(value);
17089
+ return value;
17090
+ }
17091
+ /**
16252
17092
  * Get the dirty tracker for this driver.
16253
17093
  */
16254
17094
  getDirtyTracker(data) {
@@ -16717,7 +17557,7 @@ var PostgresDriver = class {
16717
17557
  let paramIndex = 1;
16718
17558
  if (update.$set) for (const [key, value] of Object.entries(update.$set)) {
16719
17559
  setClauses.push(`${this.dialect.quoteIdentifier(key)} = ${this.dialect.placeholder(paramIndex++)}`);
16720
- params.push(value);
17560
+ params.push(value === void 0 ? value : this.serializeValue(key, value));
16721
17561
  }
16722
17562
  if (update.$unset) for (const key of Object.keys(update.$unset)) setClauses.push(`${this.dialect.quoteIdentifier(key)} = NULL`);
16723
17563
  if (update.$inc) for (const [key, amount] of Object.entries(update.$inc)) {
@@ -21301,6 +22141,7 @@ async function listExecutedMigrations() {
21301
22141
 
21302
22142
  //#endregion
21303
22143
  exports.$agg = $agg;
22144
+ exports.$expr = $expr;
21304
22145
  exports.BelongsTo = BelongsTo;
21305
22146
  exports.BelongsToMany = BelongsToMany;
21306
22147
  exports.ColumnBuilder = ColumnBuilder;
@@ -21384,6 +22225,7 @@ exports.int = int;
21384
22225
  exports.integer = integer;
21385
22226
  exports.ipAddress = ipAddress;
21386
22227
  exports.isAggregateExpression = isAggregateExpression;
22228
+ exports.isColumnExpression = isColumnExpression;
21387
22229
  exports.isMongoDBDriverLoaded = isMongoDBDriverLoaded;
21388
22230
  exports.json = json;
21389
22231
  exports.lineString = lineString;
@@ -21414,6 +22256,7 @@ exports.time = time;
21414
22256
  exports.timestamp = timestamp;
21415
22257
  exports.tinyInt = tinyInt;
21416
22258
  exports.tinyInteger = tinyInteger;
22259
+ exports.toColumnExpression = toColumnExpression;
21417
22260
  exports.transaction = transaction;
21418
22261
  exports.tryResolveModelClass = tryResolveModelClass;
21419
22262
  exports.ulid = ulid;