@warlock.js/cascade 4.5.0 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/cjs/index.cjs +1012 -104
  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 +5 -4
  18. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  19. package/esm/drivers/mongodb/mongodb-id-generator.d.mts +98 -48
  20. package/esm/drivers/mongodb/mongodb-id-generator.d.mts.map +1 -1
  21. package/esm/drivers/mongodb/mongodb-id-generator.mjs +153 -59
  22. package/esm/drivers/mongodb/mongodb-id-generator.mjs.map +1 -1
  23. package/esm/drivers/mongodb/mongodb-query-builder.d.mts +25 -0
  24. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  25. package/esm/drivers/mongodb/mongodb-query-builder.mjs +32 -0
  26. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  27. package/esm/drivers/mongodb/mongodb-query-parser.d.mts +36 -0
  28. package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
  29. package/esm/drivers/mongodb/mongodb-query-parser.mjs +80 -1
  30. package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
  31. package/esm/drivers/postgres/postgres-dialect.d.mts +32 -4
  32. package/esm/drivers/postgres/postgres-dialect.d.mts.map +1 -1
  33. package/esm/drivers/postgres/postgres-dialect.mjs +57 -4
  34. package/esm/drivers/postgres/postgres-dialect.mjs.map +1 -1
  35. package/esm/drivers/postgres/postgres-driver.d.mts +83 -1
  36. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  37. package/esm/drivers/postgres/postgres-driver.mjs +129 -16
  38. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  39. package/esm/drivers/postgres/postgres-query-builder.d.mts +32 -0
  40. package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
  41. package/esm/drivers/postgres/postgres-query-builder.mjs +47 -1
  42. package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
  43. package/esm/drivers/postgres/postgres-query-parser.d.mts +13 -2
  44. package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
  45. package/esm/drivers/postgres/postgres-query-parser.mjs +21 -4
  46. package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
  47. package/esm/drivers/postgres/types.d.mts +15 -0
  48. package/esm/drivers/postgres/types.d.mts.map +1 -1
  49. package/esm/drivers/sql/sql-dialect.contract.d.mts +20 -0
  50. package/esm/drivers/sql/sql-dialect.contract.d.mts.map +1 -1
  51. package/esm/expressions/aggregate-expressions.d.mts +71 -34
  52. package/esm/expressions/aggregate-expressions.d.mts.map +1 -1
  53. package/esm/expressions/aggregate-expressions.mjs +80 -7
  54. package/esm/expressions/aggregate-expressions.mjs.map +1 -1
  55. package/esm/expressions/column-expressions.d.mts +193 -0
  56. package/esm/expressions/column-expressions.d.mts.map +1 -0
  57. package/esm/expressions/column-expressions.mjs +152 -0
  58. package/esm/expressions/column-expressions.mjs.map +1 -0
  59. package/esm/index.d.mts +3 -2
  60. package/esm/index.mjs +2 -1
  61. package/esm/model/methods/write-methods.d.mts +29 -0
  62. package/esm/model/methods/write-methods.d.mts.map +1 -0
  63. package/esm/model/methods/write-methods.mjs +164 -2
  64. package/esm/model/methods/write-methods.mjs.map +1 -1
  65. package/esm/model/model.d.mts +64 -3
  66. package/esm/model/model.d.mts.map +1 -1
  67. package/esm/model/model.mjs +65 -3
  68. package/esm/model/model.mjs.map +1 -1
  69. package/esm/writer/database-writer.d.mts.map +1 -1
  70. package/esm/writer/database-writer.mjs +4 -3
  71. package/esm/writer/database-writer.mjs.map +1 -1
  72. package/llms-full.txt +111 -8
  73. package/llms.txt +3 -3
  74. package/package.json +4 -4
  75. package/skills/README.md +3 -3
  76. package/skills/aggregate-data/SKILL.md +43 -3
  77. package/skills/manage-transactions/SKILL.md +45 -2
  78. 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.
6950
+ *
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.
6703
6977
  *
6704
- * @param field - The field name to sum
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
@@ -12360,7 +13035,10 @@ var MongoDbDriver = class {
12360
13035
  * @throws {Error} If transaction fails, is explicitly rolled back, or replica set not configured
12361
13036
  */
12362
13037
  async transaction(fn, options) {
12363
- if (databaseTransactionContext.hasActiveTransaction()) throw new Error("Nested transaction() calls are not supported. Use beginTransaction() with savepoints for advanced transaction patterns.");
13038
+ const ctx = { rollback(reason) {
13039
+ throw new TransactionRollbackError(reason);
13040
+ } };
13041
+ if (databaseTransactionContext.hasActiveTransaction()) return fn(ctx);
12364
13042
  await this.ensureReplicaSetAvailable();
12365
13043
  const session = this.getClientInstance().startSession();
12366
13044
  try {
@@ -12370,9 +13048,7 @@ var MongoDbDriver = class {
12370
13048
  });
12371
13049
  databaseTransactionContext.enter({ session });
12372
13050
  try {
12373
- const result = await fn({ rollback(reason) {
12374
- throw new TransactionRollbackError(reason);
12375
- } });
13051
+ const result = await fn(ctx);
12376
13052
  await session.commitTransaction();
12377
13053
  return result;
12378
13054
  } catch (error) {
@@ -12897,18 +13573,24 @@ var PostgresDialect = class {
12897
13573
  /**
12898
13574
  * Translate a database-agnostic aggregate expression to PostgreSQL SQL.
12899
13575
  *
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).
13576
+ * The scalar aggregates map to their ANSI SQL function, and `countDistinct`
13577
+ * maps to `COUNT(DISTINCT …)`. `distinct`, `floor`, `first` and `last` are
13578
+ * MongoDB-only for v1 — none has a single-scalar `GROUP BY` equivalent on
13579
+ * PostgreSQL, so they throw instead of emitting a silently-different semantic
13580
+ * (the footgun this guards).
12904
13581
  *
12905
13582
  * @param expression - The abstract aggregate (`$agg.*`) to translate
12906
13583
  * @returns The SQL fragment (e.g. `SUM("amount")`, `COUNT(*)`)
12907
13584
  */
12908
13585
  aggregateToSql(expression) {
13586
+ if (expression.__expr) {
13587
+ 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.`);
13588
+ return `SUM(${this.columnExpressionToSql(expression.__expr)})`;
13589
+ }
12909
13590
  const column = expression.__field === null ? "*" : this.quoteIdentifier(expression.__field);
12910
13591
  switch (expression.__agg) {
12911
13592
  case "count": return "COUNT(*)";
13593
+ case "countDistinct": return `COUNT(DISTINCT ${column})`;
12912
13594
  case "sum": return `SUM(${column})`;
12913
13595
  case "avg": return `AVG(${column})`;
12914
13596
  case "min": return `MIN(${column})`;
@@ -12916,6 +13598,53 @@ var PostgresDialect = class {
12916
13598
  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
13599
  }
12918
13600
  }
13601
+ /**
13602
+ * Compile a typed {@link ColumnExpression} tree into a SQL fragment.
13603
+ *
13604
+ * Column references flow through {@link quoteIdentifier} (so user-supplied
13605
+ * names are quoted/escaped, never raw-interpolated); literals are emitted as
13606
+ * numeric/boolean SQL constants; arithmetic ops are parenthesised. The `raw`
13607
+ * node is the only path that emits a string verbatim — it is opt-in by name.
13608
+ *
13609
+ * @param expression - The expression tree to compile
13610
+ * @returns A SQL fragment (e.g. `("price" * "quantity")`)
13611
+ */
13612
+ columnExpressionToSql(expression) {
13613
+ switch (expression.__expr) {
13614
+ case "column": return this.quoteIdentifier(expression.column);
13615
+ case "literal": return typeof expression.value === "boolean" ? this.booleanLiteral(expression.value) : String(expression.value);
13616
+ case "raw": return expression.expression;
13617
+ case "add":
13618
+ case "subtract":
13619
+ case "multiply":
13620
+ case "divide": {
13621
+ const operator = {
13622
+ add: "+",
13623
+ subtract: "-",
13624
+ multiply: "*",
13625
+ divide: "/"
13626
+ }[expression.__expr];
13627
+ return `(${expression.operands.map((operand) => this.columnExpressionToSql(operand)).join(` ${operator} `)})`;
13628
+ }
13629
+ default: throw new Error(`Unsupported column expression node: ${JSON.stringify(expression)}`);
13630
+ }
13631
+ }
13632
+ /**
13633
+ * Build a `date_trunc` bucket expression for a portable `groupByDate`.
13634
+ *
13635
+ * @param column - The date/timestamp column to truncate
13636
+ * @param unit - The bucket granularity
13637
+ * @returns The SQL fragment (e.g. `date_trunc('month', "created_at")`)
13638
+ *
13639
+ * @example
13640
+ * ```typescript
13641
+ * dialect.dateTruncSql("created_at", "month");
13642
+ * // date_trunc('month', "created_at")
13643
+ * ```
13644
+ */
13645
+ dateTruncSql(column, unit) {
13646
+ return `date_trunc('${unit}', ${this.quoteIdentifier(column)})`;
13647
+ }
12919
13648
  };
12920
13649
 
12921
13650
  //#endregion
@@ -13764,10 +14493,16 @@ var PostgresQueryParser = class PostgresQueryParser {
13764
14493
  */
13765
14494
  orderClauses = [];
13766
14495
  /**
13767
- * GROUP BY columns.
14496
+ * GROUP BY columns (bare identifiers — get quoted).
13768
14497
  */
13769
14498
  groupColumns = [];
13770
14499
  /**
14500
+ * Raw GROUP BY expressions (emitted verbatim — e.g. a `date_trunc(...)`
14501
+ * bucket). Kept separate from `groupColumns` so they bypass identifier
14502
+ * quoting while still landing in the same `GROUP BY` clause.
14503
+ */
14504
+ groupRawExpressions = [];
14505
+ /**
13771
14506
  * HAVING clauses.
13772
14507
  */
13773
14508
  havingClauses = [];
@@ -13938,6 +14673,9 @@ var PostgresQueryParser = class PostgresQueryParser {
13938
14673
  case "groupBy":
13939
14674
  this.processGroupBy(data);
13940
14675
  break;
14676
+ case "groupByRaw":
14677
+ this.processGroupByRaw(data);
14678
+ break;
13941
14679
  case "having":
13942
14680
  this.processHaving(data);
13943
14681
  break;
@@ -13972,9 +14710,9 @@ var PostgresQueryParser = class PostgresQueryParser {
13972
14710
  parts.push(fromClause);
13973
14711
  if (this.joinClauses.length > 0) parts.push(this.joinClauses.join(" "));
13974
14712
  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(", ")}`);
14713
+ if (this.groupColumns.length > 0 || this.groupRawExpressions.length > 0) {
14714
+ const allGroupTerms = [...this.groupColumns.map((c) => this.dialect.quoteIdentifier(c)), ...this.groupRawExpressions];
14715
+ parts.push(`GROUP BY ${allGroupTerms.join(", ")}`);
13978
14716
  }
13979
14717
  if (this.havingClauses.length > 0) parts.push(`HAVING ${this.havingClauses.join(" AND ")}`);
13980
14718
  if (this.orderClauses.length > 0) parts.push(`ORDER BY ${this.orderClauses.join(", ")}`);
@@ -14432,6 +15170,14 @@ var PostgresQueryParser = class PostgresQueryParser {
14432
15170
  this.groupColumns.push(...columns);
14433
15171
  }
14434
15172
  /**
15173
+ * Process a raw GROUP BY expression (emitted verbatim, no quoting). Used by
15174
+ * `groupByDate` to add a `date_trunc(...)` bucket to the GROUP BY clause.
15175
+ */
15176
+ processGroupByRaw(data) {
15177
+ const expression = data.expression;
15178
+ this.groupRawExpressions.push(expression);
15179
+ }
15180
+ /**
14435
15181
  * Process HAVING operation.
14436
15182
  */
14437
15183
  processHaving(data) {
@@ -14612,6 +15358,53 @@ var PostgresQueryBuilder = class PostgresQueryBuilder extends QueryBuilder {
14612
15358
  if (!aggregates) return super.groupBy(fields);
14613
15359
  const fieldList = Array.isArray(fields) ? fields : [fields];
14614
15360
  this.addOperation("select", { fields: fieldList });
15361
+ this.projectGroupAggregates(aggregates);
15362
+ return super.groupBy(fields);
15363
+ }
15364
+ /**
15365
+ * Portable date-bucketed GROUP BY.
15366
+ *
15367
+ * Truncates `column` to the given bucket (`day` / `week` / `month` / `year`)
15368
+ * via PostgreSQL's `date_trunc`, projects the bucket under the column's own
15369
+ * name, and groups by the truncated expression. Optional `aggregates` follow
15370
+ * the same rules as the two-arg `groupBy` (`$agg.*` helpers or raw SQL
15371
+ * strings; MongoDB operator objects and MongoDB-only aggregates throw).
15372
+ *
15373
+ * Emits (for `groupByDate("created_at", "month", { revenue: $agg.sum(...) })`):
15374
+ * `SELECT date_trunc('month', "created_at") AS "created_at", SUM(...) AS "revenue"
15375
+ * FROM ... GROUP BY date_trunc('month', "created_at")`
15376
+ *
15377
+ * @param column - The date/timestamp column to bucket
15378
+ * @param unit - The bucket granularity
15379
+ * @param aggregates - Optional aggregate projections keyed by output alias
15380
+ *
15381
+ * @example
15382
+ * Order.query().groupByDate("created_at", "month", {
15383
+ * revenue: $agg.sum($expr.mul("price", "quantity")),
15384
+ * });
15385
+ */
15386
+ groupByDate(column, unit, aggregates) {
15387
+ const bucketSql = this.driver.dialect.dateTruncSql(column, unit);
15388
+ this.addOperation("selectRaw", {
15389
+ expression: `${bucketSql} AS ${this.driver.dialect.quoteIdentifier(column)}`,
15390
+ bindings: []
15391
+ });
15392
+ if (aggregates) this.projectGroupAggregates(aggregates);
15393
+ this.addOperation("groupByRaw", {
15394
+ expression: bucketSql,
15395
+ bindings: []
15396
+ });
15397
+ return this;
15398
+ }
15399
+ /**
15400
+ * Translate a `{ alias: aggregate }` map into `selectRaw` projections and
15401
+ * record each alias → SQL so `applyGroupByAggregates` can later rewrite a
15402
+ * `having()` on the alias. Shared by `groupBy` and `groupByDate`.
15403
+ *
15404
+ * `$agg.distinct/floor/first/last` throw (MongoDB-only on Postgres v1);
15405
+ * MongoDB operator objects throw (not portable to SQL).
15406
+ */
15407
+ projectGroupAggregates(aggregates) {
14615
15408
  for (const [alias, expression] of Object.entries(aggregates)) {
14616
15409
  let sql;
14617
15410
  if (isAggregateExpression(expression)) sql = this.driver.dialect.aggregateToSql(expression);
@@ -14623,7 +15416,6 @@ var PostgresQueryBuilder = class PostgresQueryBuilder extends QueryBuilder {
14623
15416
  bindings: []
14624
15417
  });
14625
15418
  }
14626
- return super.groupBy(fields);
14627
15419
  }
14628
15420
  /**
14629
15421
  * Nearest-neighbour vector similarity search via pgvector cosine distance.
@@ -16134,12 +16926,29 @@ var PostgresDriver = class {
16134
16926
  */
16135
16927
  _syncAdapter;
16136
16928
  /**
16929
+ * Explicit, table-agnostic override list of column names that hold native
16930
+ * PostgreSQL arrays (`JSONB[]`, `TEXT[]`, …) and must NOT be JSON-text
16931
+ * encoded. Merged with (and superseded per-table by) the schema
16932
+ * introspection below; kept as a manual escape hatch.
16933
+ *
16934
+ * @see PostgresPoolConfig.nativeArrayColumns
16935
+ */
16936
+ _nativeArrayColumns;
16937
+ /**
16938
+ * Native-array columns discovered by introspecting the live schema on
16939
+ * connect, keyed `table → { column, … }`. Authoritative and table-scoped, so
16940
+ * a column that is `TEXT[]` in one table and `jsonb` in another is encoded
16941
+ * correctly for each — no app configuration required.
16942
+ */
16943
+ _introspectedArrayColumns = /* @__PURE__ */ new Map();
16944
+ /**
16137
16945
  * Create a new PostgreSQL driver instance.
16138
16946
  *
16139
16947
  * @param config - PostgreSQL connection configuration
16140
16948
  */
16141
16949
  constructor(config) {
16142
16950
  this.config = config;
16951
+ this._nativeArrayColumns = new Set(config.nativeArrayColumns ?? []);
16143
16952
  }
16144
16953
  /**
16145
16954
  * Get the connection pool instance.
@@ -16198,6 +17007,7 @@ var PostgresDriver = class {
16198
17007
  (await this._pool.connect()).release();
16199
17008
  _warlock_js_logger.log.success("database.postgres", "connection", `Connected to database ${_mongez_copper.colors.bold(_mongez_copper.colors.yellowBright(this.config.database))}`);
16200
17009
  this._isConnected = true;
17010
+ await this.loadNativeArrayColumns();
16201
17011
  this.emit("connected");
16202
17012
  } catch (error) {
16203
17013
  _warlock_js_logger.log.fatal("database.postgres", "connection", "Failed to connect to database");
@@ -16234,21 +17044,115 @@ var PostgresDriver = class {
16234
17044
  * that need special handling for PostgreSQL storage.
16235
17045
  *
16236
17046
  * @param data - The data object to serialize
17047
+ * @param table - Optional table name; when given, columns introspected as
17048
+ * native arrays on that table are bound raw (see {@link serializeValue}).
16237
17049
  * @returns Serialized data ready for PostgreSQL
16238
17050
  */
16239
- serialize(data) {
17051
+ serialize(data, table) {
16240
17052
  const serialized = {};
16241
17053
  for (const [key, value] of Object.entries(data)) {
16242
17054
  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;
17055
+ serialized[key] = this.serializeValue(key, value, table);
16248
17056
  }
16249
17057
  return serialized;
16250
17058
  }
16251
17059
  /**
17060
+ * Serialize a single column value into a node-pg bindable parameter.
17061
+ *
17062
+ * Shared by {@link serialize} (INSERT path) and {@link buildUpdateQuery}
17063
+ * `$set` (UPDATE path) so both encode `json` / `jsonb` columns identically.
17064
+ *
17065
+ * Encoding rules (in order):
17066
+ * - `Date` → ISO string.
17067
+ * - `bigint` → decimal string (node-pg has no native bigint binding).
17068
+ * - all-number array → pgvector literal `'[n1,n2,...]'`. node-pg would
17069
+ * otherwise emit a `{n1,n2,...}` array literal, which the `vector` type
17070
+ * rejects. This branch is preserved exactly.
17071
+ * - any other array (object-array, string-array, mixed, empty `[]`) →
17072
+ * `JSON.stringify`. node-pg renders a raw JS array as a PostgreSQL array
17073
+ * literal `{...}` (and `[]` as `{}`), which a `json` / `jsonb` column
17074
+ * rejects — so we bind the value as JSON text instead, the form those
17075
+ * columns accept. Columns known to be native arrays — via schema
17076
+ * introspection or the `nativeArrayColumns` config — are exempt: their raw
17077
+ * array is passed through so node-pg emits the `{...}` literal a genuine
17078
+ * `JSONB[]` / `TEXT[]` column needs.
17079
+ * - plain object → `JSON.stringify`. Equivalent to node-pg's own object
17080
+ * handling, made explicit so both write paths agree.
17081
+ * - everything else (scalars: string, number, boolean, null) → untouched.
17082
+ *
17083
+ * Distinguishing native-array from `json` / `jsonb` columns: a value alone
17084
+ * can't tell them apart, so the driver introspects the live schema on connect
17085
+ * (see {@link loadNativeArrayColumns}) and consults that per-table map here
17086
+ * via {@link isNativeArrayColumn}. The explicit `nativeArrayColumns` config
17087
+ * still works as a table-agnostic override. No `::jsonb` placeholder cast is
17088
+ * added: a JSON-text string binds correctly to `json` / `jsonb` without one,
17089
+ * and a blind cast would misfire on columns we cannot positively identify as
17090
+ * jsonb.
17091
+ *
17092
+ * @param key - Column name (used to resolve native-array columns)
17093
+ * @param value - The raw value to serialize (never `undefined`)
17094
+ * @param table - Optional table name; enables the per-table native-array lookup
17095
+ * @returns The value ready to bind as a query parameter
17096
+ */
17097
+ serializeValue(key, value, table) {
17098
+ if (value instanceof Date) return value.toISOString();
17099
+ if (typeof value === "bigint") return value.toString();
17100
+ if (Array.isArray(value)) {
17101
+ if (value.length > 0 && value.every((v) => typeof v === "number")) return `[${value.join(",")}]`;
17102
+ if (this.isNativeArrayColumn(table, key)) return value;
17103
+ return JSON.stringify(value);
17104
+ }
17105
+ if (typeof value === "object" && value !== null) return JSON.stringify(value);
17106
+ return value;
17107
+ }
17108
+ /**
17109
+ * Whether `column` on `table` is a native PostgreSQL array. True when the
17110
+ * connect-time schema introspection saw it as `data_type = 'ARRAY'` for that
17111
+ * table (authoritative, per-table), or when it's listed in the table-agnostic
17112
+ * `nativeArrayColumns` config override.
17113
+ */
17114
+ isNativeArrayColumn(table, column) {
17115
+ if (table && this._introspectedArrayColumns.get(table)?.has(column)) return true;
17116
+ return this._nativeArrayColumns.has(column);
17117
+ }
17118
+ /**
17119
+ * Introspect the live schema for native-array columns so array values bind
17120
+ * correctly with zero app configuration.
17121
+ *
17122
+ * A JS array must be bound two opposite ways depending on the column: as JSON
17123
+ * text for a `json` / `jsonb` column, but as a raw array (which node-pg
17124
+ * renders `{...}`) for a native `TEXT[]` / `JSONB[]` / `INTEGER[]` column. The
17125
+ * serializer sees values, not types, so without this it JSON-stringifies
17126
+ * every array — which a native-array column rejects with "malformed array
17127
+ * literal". One `information_schema` query at connect, cached for the
17128
+ * connection lifetime, removes the need to hand-list `nativeArrayColumns`.
17129
+ *
17130
+ * Best-effort: any failure (e.g. restricted catalog access) is logged and
17131
+ * leaves the map empty so the config override still applies — it never blocks
17132
+ * connect. A schema change made within a live connection isn't reflected
17133
+ * until the next connect.
17134
+ */
17135
+ async loadNativeArrayColumns() {
17136
+ try {
17137
+ const result = await this.query(`SELECT table_name, column_name
17138
+ FROM information_schema.columns
17139
+ WHERE table_schema = ANY (current_schemas(false))
17140
+ AND data_type = 'ARRAY'`);
17141
+ const map = /* @__PURE__ */ new Map();
17142
+ for (const { table_name, column_name } of result.rows) {
17143
+ let columns = map.get(table_name);
17144
+ if (!columns) {
17145
+ columns = /* @__PURE__ */ new Set();
17146
+ map.set(table_name, columns);
17147
+ }
17148
+ columns.add(column_name);
17149
+ }
17150
+ this._introspectedArrayColumns = map;
17151
+ } catch {
17152
+ _warlock_js_logger.log.warn("database.postgres", "introspection", "Could not introspect native-array columns; using the nativeArrayColumns config only");
17153
+ }
17154
+ }
17155
+ /**
16252
17156
  * Get the dirty tracker for this driver.
16253
17157
  */
16254
17158
  getDirtyTracker(data) {
@@ -16297,7 +17201,7 @@ var PostgresDriver = class {
16297
17201
  * @returns The inserted document
16298
17202
  */
16299
17203
  async insert(table, document, _options) {
16300
- const serialized = this.serialize(document);
17204
+ const serialized = this.serialize(document, table);
16301
17205
  const filteredData = Object.fromEntries(Object.entries(serialized).filter(([key, value]) => {
16302
17206
  if (key === "id" && (value === null || value === void 0)) return false;
16303
17207
  return true;
@@ -16324,7 +17228,7 @@ var PostgresDriver = class {
16324
17228
  if (documents.length === 0) return [];
16325
17229
  const allColumns = /* @__PURE__ */ new Set();
16326
17230
  for (const doc of documents) {
16327
- const serialized = this.serialize(doc);
17231
+ const serialized = this.serialize(doc, table);
16328
17232
  Object.keys(serialized).forEach((key) => allColumns.add(key));
16329
17233
  }
16330
17234
  const columns = Array.from(allColumns);
@@ -16334,7 +17238,7 @@ var PostgresDriver = class {
16334
17238
  const params = [];
16335
17239
  let paramIndex = 1;
16336
17240
  for (const doc of documents) {
16337
- const serialized = this.serialize(doc);
17241
+ const serialized = this.serialize(doc, table);
16338
17242
  const rowPlaceholders = [];
16339
17243
  for (const col of columns) if (col in serialized) {
16340
17244
  rowPlaceholders.push(this.dialect.placeholder(paramIndex++));
@@ -16401,7 +17305,7 @@ var PostgresDriver = class {
16401
17305
  * @returns The replaced document or null
16402
17306
  */
16403
17307
  async replace(table, filter, document, _options) {
16404
- const serialized = this.serialize(document);
17308
+ const serialized = this.serialize(document, table);
16405
17309
  const columns = Object.keys(serialized);
16406
17310
  const values = Object.values(serialized);
16407
17311
  const quotedTable = this.dialect.quoteIdentifier(table);
@@ -16423,7 +17327,7 @@ var PostgresDriver = class {
16423
17327
  * @returns The upserted row
16424
17328
  */
16425
17329
  async upsert(table, filter, document, options) {
16426
- const serialized = this.serialize(document);
17330
+ const serialized = this.serialize(document, table);
16427
17331
  const columns = Object.keys(serialized);
16428
17332
  const values = Object.values(serialized);
16429
17333
  if (columns.length === 0) throw new Error("Cannot upsert empty document");
@@ -16549,13 +17453,14 @@ var PostgresDriver = class {
16549
17453
  * @throws {Error} If transaction fails or is explicitly rolled back
16550
17454
  */
16551
17455
  async transaction(fn, options) {
16552
- if (databaseTransactionContext.hasActiveTransaction()) {}
17456
+ const ctx = { rollback(reason) {
17457
+ throw new TransactionRollbackError(reason);
17458
+ } };
17459
+ if (databaseTransactionContext.hasActiveTransaction()) return fn(ctx);
16553
17460
  const tx = await this.beginTransaction(options);
16554
17461
  databaseTransactionContext.enter({ session: tx.context });
16555
17462
  try {
16556
- const result = await fn({ rollback(reason) {
16557
- throw new TransactionRollbackError(reason);
16558
- } });
17463
+ const result = await fn(ctx);
16559
17464
  await tx.commit();
16560
17465
  return result;
16561
17466
  } catch (error) {
@@ -16717,7 +17622,7 @@ var PostgresDriver = class {
16717
17622
  let paramIndex = 1;
16718
17623
  if (update.$set) for (const [key, value] of Object.entries(update.$set)) {
16719
17624
  setClauses.push(`${this.dialect.quoteIdentifier(key)} = ${this.dialect.placeholder(paramIndex++)}`);
16720
- params.push(value);
17625
+ params.push(value === void 0 ? value : this.serializeValue(key, value, table));
16721
17626
  }
16722
17627
  if (update.$unset) for (const key of Object.keys(update.$unset)) setClauses.push(`${this.dialect.quoteIdentifier(key)} = NULL`);
16723
17628
  if (update.$inc) for (const [key, amount] of Object.entries(update.$inc)) {
@@ -21301,6 +22206,7 @@ async function listExecutedMigrations() {
21301
22206
 
21302
22207
  //#endregion
21303
22208
  exports.$agg = $agg;
22209
+ exports.$expr = $expr;
21304
22210
  exports.BelongsTo = BelongsTo;
21305
22211
  exports.BelongsToMany = BelongsToMany;
21306
22212
  exports.ColumnBuilder = ColumnBuilder;
@@ -21384,6 +22290,7 @@ exports.int = int;
21384
22290
  exports.integer = integer;
21385
22291
  exports.ipAddress = ipAddress;
21386
22292
  exports.isAggregateExpression = isAggregateExpression;
22293
+ exports.isColumnExpression = isColumnExpression;
21387
22294
  exports.isMongoDBDriverLoaded = isMongoDBDriverLoaded;
21388
22295
  exports.json = json;
21389
22296
  exports.lineString = lineString;
@@ -21414,6 +22321,7 @@ exports.time = time;
21414
22321
  exports.timestamp = timestamp;
21415
22322
  exports.tinyInt = tinyInt;
21416
22323
  exports.tinyInteger = tinyInteger;
22324
+ exports.toColumnExpression = toColumnExpression;
21417
22325
  exports.transaction = transaction;
21418
22326
  exports.tryResolveModelClass = tryResolveModelClass;
21419
22327
  exports.ulid = ulid;