@objectstack/objectql 9.7.0 → 9.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -2773,6 +2773,13 @@ function normaliseVersionToken(v) {
2773
2773
  }
2774
2774
  return s;
2775
2775
  }
2776
+ var CLONE_STRIP_FIELDS = [
2777
+ "id",
2778
+ "created_at",
2779
+ "created_by",
2780
+ "updated_at",
2781
+ "updated_by"
2782
+ ];
2776
2783
  var SERVICE_CONFIG = {
2777
2784
  auth: { route: "/api/v1/auth", plugin: "plugin-auth" },
2778
2785
  automation: { route: "/api/v1/automation", plugin: "plugin-automation" },
@@ -4028,6 +4035,68 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
4028
4035
  record: result
4029
4036
  };
4030
4037
  }
4038
+ /**
4039
+ * Clone a record — read the source, drop engine-owned columns, and
4040
+ * insert a fresh copy. Gated by the object's `enable.clone` capability
4041
+ * (default `true`; only an explicit `enable.clone === false` disables it).
4042
+ *
4043
+ * Shallow by design: it duplicates the record's own scalar/business field
4044
+ * values, not its related child records. The insert path re-stamps audit
4045
+ * columns, regenerates `autonumber` fields, and recomputes derived
4046
+ * (`formula`/`summary`) fields, so the copy is a valid new row rather than
4047
+ * a byte-identical twin. Caller-supplied `overrides` are applied last and
4048
+ * win over the copied values — the natural place to set a new `name`,
4049
+ * clear a unique field, or reset status before insert.
4050
+ */
4051
+ async cloneData(request) {
4052
+ const schema = this.engine.registry.getObject(request.object);
4053
+ if (!schema) {
4054
+ const err = new Error(`Object '${request.object}' not found`);
4055
+ err.code = "OBJECT_NOT_FOUND";
4056
+ err.status = 404;
4057
+ err.object = request.object;
4058
+ throw err;
4059
+ }
4060
+ if (schema.enable?.clone === false) {
4061
+ const err = new Error(`Cloning is disabled for object '${request.object}'`);
4062
+ err.code = "CLONE_DISABLED";
4063
+ err.status = 403;
4064
+ err.object = request.object;
4065
+ throw err;
4066
+ }
4067
+ const ctx = request.context;
4068
+ const ctxOpt = ctx !== void 0 ? { context: ctx } : void 0;
4069
+ const source = await this.engine.findOne(
4070
+ request.object,
4071
+ { where: { id: request.id }, ...ctxOpt }
4072
+ );
4073
+ if (!source) {
4074
+ const err = new Error(`Record ${request.id} not found in ${request.object}`);
4075
+ err.code = "RECORD_NOT_FOUND";
4076
+ err.status = 404;
4077
+ err.object = request.object;
4078
+ throw err;
4079
+ }
4080
+ const data = { ...source };
4081
+ for (const f of CLONE_STRIP_FIELDS) delete data[f];
4082
+ const fields = schema.fields || {};
4083
+ for (const [name, def] of Object.entries(fields)) {
4084
+ if (!def) continue;
4085
+ if (def.system === true || def.type === "autonumber" || def.type === "formula" || def.type === "summary") {
4086
+ delete data[name];
4087
+ }
4088
+ }
4089
+ if (request.overrides && typeof request.overrides === "object") {
4090
+ Object.assign(data, request.overrides);
4091
+ }
4092
+ const result = await this.engine.insert(request.object, data, ctxOpt);
4093
+ return {
4094
+ object: request.object,
4095
+ id: result.id,
4096
+ sourceId: request.id,
4097
+ record: result
4098
+ };
4099
+ }
4031
4100
  async updateData(request) {
4032
4101
  await this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context);
4033
4102
  const opts = { where: { id: request.id } };
@@ -4217,145 +4286,6 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
4217
4286
  };
4218
4287
  }
4219
4288
  // ==========================================
4220
- // Lead Convert (M10.6)
4221
- // ==========================================
4222
- /**
4223
- * Convert a qualified Lead into an Account + Contact (+ optional
4224
- * Opportunity) and mark the Lead as converted. Mirrors the Salesforce
4225
- * lead-conversion model:
4226
- *
4227
- * - If `accountId` is provided, the lead's company info is NOT used
4228
- * to create a new account; the new contact and opportunity link to
4229
- * the existing account instead.
4230
- * - If `contactId` is provided, no new contact is created either —
4231
- * useful when the lead is a new contact at an existing account.
4232
- * - `createOpportunity` defaults to true; pass `false` to convert
4233
- * without producing an opportunity (some teams convert "logos
4234
- * only" first).
4235
- * - Lead is updated atomically: `is_converted=true`,
4236
- * `converted_account`/`converted_contact`/`converted_opportunity`
4237
- * pointers, `converted_date`, and `status='converted'`.
4238
- *
4239
- * Atomicity is enforced via the default driver's transaction support
4240
- * when available; otherwise a best-effort compensation (delete
4241
- * already-created child records on failure) is attempted. Permission
4242
- * checks on each child object are inherited from the caller's
4243
- * execution context so SecurityPlugin still gates account/contact/
4244
- * opportunity creates.
4245
- */
4246
- async convertLead(request) {
4247
- const leadId = String(request.leadId || "").trim();
4248
- if (!leadId) {
4249
- const err = new Error("leadId is required");
4250
- err.status = 400;
4251
- err.code = "INVALID_REQUEST";
4252
- throw err;
4253
- }
4254
- const ctx = request.context;
4255
- const ctxOpt = ctx !== void 0 ? { context: ctx } : void 0;
4256
- const lead = await this.engine.findOne("lead", { where: { id: leadId }, ...ctxOpt });
4257
- if (!lead) {
4258
- const err = new Error(`Lead '${leadId}' not found`);
4259
- err.status = 404;
4260
- err.code = "LEAD_NOT_FOUND";
4261
- throw err;
4262
- }
4263
- if (lead.is_converted) {
4264
- const err = new Error(`Lead '${leadId}' is already converted`);
4265
- err.status = 409;
4266
- err.code = "LEAD_ALREADY_CONVERTED";
4267
- throw err;
4268
- }
4269
- const runConversion = async (trxCtx) => {
4270
- const opCtx = trxCtx ?? ctx;
4271
- const trxCtxOpt = opCtx !== void 0 ? { context: opCtx } : void 0;
4272
- let account;
4273
- if (request.accountId) {
4274
- account = await this.engine.findOne("account", { where: { id: request.accountId }, ...trxCtxOpt });
4275
- if (!account) {
4276
- const err = new Error(`Account '${request.accountId}' not found`);
4277
- err.status = 404;
4278
- err.code = "ACCOUNT_NOT_FOUND";
4279
- throw err;
4280
- }
4281
- } else {
4282
- const accountPayload = {
4283
- name: lead.company || `${lead.first_name ?? ""} ${lead.last_name ?? ""}`.trim() || "Untitled Account"
4284
- };
4285
- if (lead.industry) accountPayload.industry = lead.industry;
4286
- if (lead.annual_revenue) accountPayload.annual_revenue = lead.annual_revenue;
4287
- if (lead.number_of_employees) accountPayload.employees = lead.number_of_employees;
4288
- if (lead.website) accountPayload.website = lead.website;
4289
- if (lead.phone) accountPayload.phone = lead.phone;
4290
- if (lead.address) accountPayload.billing_address = lead.address;
4291
- if (lead.owner) accountPayload.owner = lead.owner;
4292
- account = await this.engine.insert("account", accountPayload, trxCtxOpt);
4293
- }
4294
- let contact;
4295
- if (request.contactId) {
4296
- contact = await this.engine.findOne("contact", { where: { id: request.contactId }, ...trxCtxOpt });
4297
- if (!contact) {
4298
- const err = new Error(`Contact '${request.contactId}' not found`);
4299
- err.status = 404;
4300
- err.code = "CONTACT_NOT_FOUND";
4301
- throw err;
4302
- }
4303
- } else {
4304
- const contactPayload = {
4305
- first_name: lead.first_name ?? "",
4306
- last_name: lead.last_name ?? lead.company ?? "Unknown"
4307
- };
4308
- if (lead.salutation) contactPayload.salutation = lead.salutation;
4309
- if (lead.email) contactPayload.email = lead.email;
4310
- if (lead.phone) contactPayload.phone = lead.phone;
4311
- if (lead.mobile) contactPayload.mobile = lead.mobile;
4312
- if (lead.title) contactPayload.title = lead.title;
4313
- if (lead.address) contactPayload.mailing_address = lead.address;
4314
- if (lead.owner) contactPayload.owner = lead.owner;
4315
- if (account?.id) contactPayload.account = account.id;
4316
- contact = await this.engine.insert("contact", contactPayload, trxCtxOpt);
4317
- }
4318
- let opportunity = null;
4319
- const shouldCreateOpp = request.createOpportunity !== false;
4320
- if (shouldCreateOpp) {
4321
- const oppOverrides = request.opportunity ?? {};
4322
- const defaultName = oppOverrides.name || `${account?.name ?? lead.company ?? "Lead"} - New Opportunity`;
4323
- const defaultClose = oppOverrides.close_date || new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3).toISOString().slice(0, 10);
4324
- const oppPayload = {
4325
- name: defaultName,
4326
- stage: oppOverrides.stage ?? "qualification",
4327
- close_date: defaultClose
4328
- };
4329
- if (oppOverrides.amount !== void 0) oppPayload.amount = oppOverrides.amount;
4330
- else if (lead.annual_revenue) oppPayload.amount = lead.annual_revenue;
4331
- if (account?.id) oppPayload.account = account.id;
4332
- if (contact?.id) oppPayload.primary_contact = contact.id;
4333
- if (lead.owner) oppPayload.owner = lead.owner;
4334
- if (lead.lead_source) oppPayload.lead_source = lead.lead_source;
4335
- opportunity = await this.engine.insert("opportunity", oppPayload, trxCtxOpt);
4336
- }
4337
- const leadUpdate = {
4338
- is_converted: true,
4339
- status: request.convertedStatus ?? "converted",
4340
- converted_account: account?.id ?? null,
4341
- converted_contact: contact?.id ?? null,
4342
- converted_opportunity: opportunity?.id ?? null,
4343
- converted_date: (/* @__PURE__ */ new Date()).toISOString()
4344
- };
4345
- const updatedLead = await this.engine.update("lead", leadUpdate, {
4346
- where: { id: leadId },
4347
- ...trxCtxOpt
4348
- });
4349
- return {
4350
- lead: updatedLead ?? { ...lead, ...leadUpdate },
4351
- account,
4352
- contact,
4353
- opportunity
4354
- };
4355
- };
4356
- return this.engine.transaction(runConversion, ctx);
4357
- }
4358
- // ==========================================
4359
4289
  // Metadata Caching
4360
4290
  // ==========================================
4361
4291
  async getMetaItemCached(request) {
@@ -6719,11 +6649,20 @@ function validateOne(name, def, value) {
6719
6649
  if (value === 0 || value === 1 || value === "0" || value === "1" || value === "true" || value === "false") return null;
6720
6650
  return { field: name, code: "invalid_boolean", message: `${name} must be true or false` };
6721
6651
  }
6722
- if (t === "date" || t === "datetime" || t === "time") {
6652
+ if (t === "date" || t === "datetime") {
6723
6653
  if (value instanceof Date) return null;
6724
6654
  if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return null;
6725
6655
  return { field: name, code: "invalid_date", message: `${name} must be a valid ${t} (ISO-8601)` };
6726
6656
  }
6657
+ if (t === "time") {
6658
+ if (value instanceof Date) return null;
6659
+ if (typeof value === "string") {
6660
+ const timeOfDay = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]([01]\d|2[0-3]):?[0-5]\d)?$/;
6661
+ const hasDate = /\d{4}-\d{2}-\d{2}/.test(value);
6662
+ if (timeOfDay.test(value.trim()) || hasDate && !Number.isNaN(Date.parse(value))) return null;
6663
+ }
6664
+ return { field: name, code: "invalid_time", message: `${name} must be a valid time (HH:MM or HH:MM:SS)` };
6665
+ }
6727
6666
  if (t === "select" || t === "radio") {
6728
6667
  const allowed = optionValues(def.options);
6729
6668
  if (allowed.length > 0 && !allowed.includes(String(value))) {
@@ -7039,7 +6978,8 @@ function legalNextStates(objectSchema, field, currentState) {
7039
6978
  }
7040
6979
 
7041
6980
  // src/in-memory-aggregation.ts
7042
- function applyInMemoryAggregation(rows, ast) {
6981
+ import { calendarPartsInTzOrUtc } from "@objectstack/core";
6982
+ function applyInMemoryAggregation(rows, ast, timezone) {
7043
6983
  const groupBy = ast.groupBy ?? [];
7044
6984
  const aggregations = ast.aggregations ?? [];
7045
6985
  if (groupBy.length === 0 && aggregations.length === 0) return rows;
@@ -7052,7 +6992,7 @@ function applyInMemoryAggregation(rows, ast) {
7052
6992
  const parts = [];
7053
6993
  for (const g of groupBy) {
7054
6994
  const fieldName = typeof g === "string" ? g : g.alias ?? g.field;
7055
- const value = projectGroupValue(row, g);
6995
+ const value = projectGroupValue(row, g, timezone);
7056
6996
  key[fieldName] = value;
7057
6997
  parts.push(`${fieldName}=${value}`);
7058
6998
  }
@@ -7071,11 +7011,11 @@ function applyInMemoryAggregation(rows, ast) {
7071
7011
  }
7072
7012
  return out;
7073
7013
  }
7074
- function projectGroupValue(row, g) {
7014
+ function projectGroupValue(row, g, timezone) {
7075
7015
  const field = typeof g === "string" ? g : g.field;
7076
7016
  const v = row?.[field];
7077
7017
  if (typeof g !== "string" && g.dateGranularity) {
7078
- return bucketDateValue(v, g.dateGranularity);
7018
+ return bucketDateValue(v, g.dateGranularity, timezone);
7079
7019
  }
7080
7020
  return v == null ? "(null)" : String(v);
7081
7021
  }
@@ -7153,12 +7093,11 @@ function toNumber(v) {
7153
7093
  const n = Number(v);
7154
7094
  return Number.isFinite(n) ? n : 0;
7155
7095
  }
7156
- function bucketDateValue(value, granularity) {
7096
+ function bucketDateValue(value, granularity, timezone) {
7157
7097
  if (value == null) return "(null)";
7158
7098
  const d = value instanceof Date ? value : new Date(String(value));
7159
7099
  if (Number.isNaN(d.getTime())) return "(null)";
7160
- const y = d.getUTCFullYear();
7161
- const m = d.getUTCMonth() + 1;
7100
+ const { year: y, month: m, day } = calendarPartsInTzOrUtc(d, timezone);
7162
7101
  switch (granularity) {
7163
7102
  case "year":
7164
7103
  return String(y);
@@ -7167,9 +7106,9 @@ function bucketDateValue(value, granularity) {
7167
7106
  case "month":
7168
7107
  return `${y}-${String(m).padStart(2, "0")}`;
7169
7108
  case "day":
7170
- return `${y}-${String(m).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
7109
+ return `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
7171
7110
  case "week": {
7172
- const target = new Date(Date.UTC(y, d.getUTCMonth(), d.getUTCDate()));
7111
+ const target = new Date(Date.UTC(y, m - 1, day));
7173
7112
  const dayNum = (target.getUTCDay() + 6) % 7;
7174
7113
  target.setUTCDate(target.getUTCDate() - dayNum + 3);
7175
7114
  const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
@@ -7212,16 +7151,25 @@ function planFormulaProjection(schema, requestedFields) {
7212
7151
  }
7213
7152
  return { plan };
7214
7153
  }
7215
- function applyFormulaPlan(plan, records) {
7154
+ function applyFormulaPlan(plan, records, execCtx, nowSnapshot) {
7216
7155
  if (!plan.length) return;
7156
+ const now = nowSnapshot ?? /* @__PURE__ */ new Date();
7157
+ const timezone = execCtx?.timezone;
7158
+ const user = execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : void 0;
7159
+ const org = execCtx?.tenantId ? { id: String(execCtx.tenantId) } : void 0;
7217
7160
  for (const rec of records) {
7218
7161
  if (rec == null) continue;
7219
7162
  for (const fp of plan) {
7220
- const r = ExpressionEngine3.evaluate(fp.expression, { record: rec });
7163
+ const r = ExpressionEngine3.evaluate(fp.expression, { now, timezone, user, org, record: rec });
7221
7164
  rec[fp.name] = r.ok ? r.value : null;
7222
7165
  }
7223
7166
  }
7224
7167
  }
7168
+ function mergeReadContext(fromQuery, fromOptions) {
7169
+ if (fromOptions == null) return fromQuery;
7170
+ if (fromQuery == null) return fromOptions;
7171
+ return { ...fromQuery, ...fromOptions };
7172
+ }
7225
7173
  function resolveMetadataItemName(key, item) {
7226
7174
  if (!item) return void 0;
7227
7175
  if (item.name) return item.name;
@@ -7644,6 +7592,7 @@ var _ObjectQL = class _ObjectQL {
7644
7592
  if (typeof dv === "object" && dv !== null && dv.dialect && typeof dv.source === "string") {
7645
7593
  const result = ExpressionEngine3.evaluate(dv, {
7646
7594
  now,
7595
+ timezone: execCtx?.timezone,
7647
7596
  user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : void 0,
7648
7597
  org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : void 0,
7649
7598
  record: out,
@@ -8535,7 +8484,7 @@ var _ObjectQL = class _ObjectQL {
8535
8484
  // ============================================
8536
8485
  // Data Access Methods (IDataEngine Interface)
8537
8486
  // ============================================
8538
- async find(object, query) {
8487
+ async find(object, query, options) {
8539
8488
  object = this.resolveObjectName(object);
8540
8489
  this.logger.debug("Find operation starting", { object, query });
8541
8490
  const driver = this.getDriver(object);
@@ -8564,7 +8513,7 @@ var _ObjectQL = class _ObjectQL {
8564
8513
  operation: "find",
8565
8514
  ast,
8566
8515
  options: query,
8567
- context: query?.context
8516
+ context: mergeReadContext(query?.context, options?.context)
8568
8517
  };
8569
8518
  await this.executeWithMiddleware(opCtx, async () => {
8570
8519
  const hookContext = {
@@ -8580,7 +8529,7 @@ var _ObjectQL = class _ObjectQL {
8580
8529
  hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
8581
8530
  try {
8582
8531
  let result = await driver.find(object, hookContext.input.ast, hookContext.input.options);
8583
- if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result);
8532
+ if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result, opCtx.context);
8584
8533
  if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) {
8585
8534
  result = await this.expandRelatedRecords(object, result, ast.expand, 0, opCtx.context);
8586
8535
  }
@@ -8596,7 +8545,7 @@ var _ObjectQL = class _ObjectQL {
8596
8545
  });
8597
8546
  return opCtx.result;
8598
8547
  }
8599
- async findOne(objectName, query) {
8548
+ async findOne(objectName, query, options) {
8600
8549
  objectName = this.resolveObjectName(objectName);
8601
8550
  this.logger.debug("FindOne operation", { objectName });
8602
8551
  const driver = this.getDriver(objectName);
@@ -8619,12 +8568,12 @@ var _ObjectQL = class _ObjectQL {
8619
8568
  operation: "findOne",
8620
8569
  ast,
8621
8570
  options: query,
8622
- context: query?.context
8571
+ context: mergeReadContext(query?.context, options?.context)
8623
8572
  };
8624
8573
  await this.executeWithMiddleware(opCtx, async () => {
8625
8574
  const findOneOpts = this.buildDriverOptions(opCtx.context);
8626
8575
  let result = await driver.findOne(objectName, opCtx.ast, findOneOpts);
8627
- if (result != null) applyFormulaPlan(_findOneFormula.plan, [result]);
8576
+ if (result != null) applyFormulaPlan(_findOneFormula.plan, [result], opCtx.context);
8628
8577
  if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {
8629
8578
  const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context);
8630
8579
  result = expanded[0];
@@ -8839,7 +8788,10 @@ var _ObjectQL = class _ObjectQL {
8839
8788
  * - `set_null` → clear the foreign key,
8840
8789
  * - `restrict` → refuse the delete when dependents exist.
8841
8790
  * `master_detail` defaults to `cascade` (the parent owns the child
8842
- * lifecycle); `lookup` defaults to `set_null`. Only runs for single-id
8791
+ * lifecycle); `lookup` defaults to `set_null` except a `set_null` default
8792
+ * on a REQUIRED lookup escalates to `restrict` (you can't null a NOT NULL
8793
+ * FK; restricting with a clear dependent-count message beats a misleading
8794
+ * "<field> is required" 400 from the child). Only runs for single-id
8843
8795
  * deletes — multi/predicate deletes skip cascade (logged).
8844
8796
  */
8845
8797
  async cascadeDeleteRelations(object, id, context, depth = 0) {
@@ -8865,7 +8817,10 @@ var _ObjectQL = class _ObjectQL {
8865
8817
  resolvedRef = void 0;
8866
8818
  }
8867
8819
  if (ref !== object && resolvedRef !== object) continue;
8868
- const behavior = fdef.type === "master_detail" ? fdef.deleteBehavior === "restrict" ? "restrict" : "cascade" : fdef.deleteBehavior || "set_null";
8820
+ let behavior = fdef.type === "master_detail" ? fdef.deleteBehavior === "restrict" ? "restrict" : "cascade" : fdef.deleteBehavior || "set_null";
8821
+ if (behavior === "set_null" && fdef.required === true) {
8822
+ behavior = "restrict";
8823
+ }
8869
8824
  let dependents;
8870
8825
  try {
8871
8826
  dependents = await this.find(childName, { where: { [fieldName]: id }, context });
@@ -8874,9 +8829,16 @@ var _ObjectQL = class _ObjectQL {
8874
8829
  }
8875
8830
  if (!dependents || dependents.length === 0) continue;
8876
8831
  if (behavior === "restrict") {
8877
- throw new Error(
8878
- `Cannot delete ${object} (${id}): ${dependents.length} dependent ${childName} record(s) via ${fieldName}`
8832
+ const reason = fdef.deleteBehavior !== "restrict" && fdef.required === true ? ` (${fieldName} is required, so it cannot be cleared)` : "";
8833
+ const err = new Error(
8834
+ `Cannot delete ${object} (${id}): ${dependents.length} dependent ${childName} record(s) reference it via ${fieldName}${reason}. Delete or reassign them first, or set deleteBehavior:'cascade' on ${childName}.${fieldName}.`
8879
8835
  );
8836
+ err.code = "DELETE_RESTRICTED";
8837
+ err.status = 409;
8838
+ err.object = object;
8839
+ err.dependentObject = childName;
8840
+ err.dependentCount = dependents.length;
8841
+ throw err;
8880
8842
  }
8881
8843
  for (const dep of dependents) {
8882
8844
  const depId = dep?.id;
@@ -8965,14 +8927,14 @@ var _ObjectQL = class _ObjectQL {
8965
8927
  });
8966
8928
  return opCtx.result;
8967
8929
  }
8968
- async count(object, query) {
8930
+ async count(object, query, options) {
8969
8931
  object = this.resolveObjectName(object);
8970
8932
  const driver = this.getDriver(object);
8971
8933
  const opCtx = {
8972
8934
  object,
8973
8935
  operation: "count",
8974
8936
  options: query,
8975
- context: query?.context
8937
+ context: mergeReadContext(query?.context, options?.context)
8976
8938
  };
8977
8939
  await this.executeWithMiddleware(opCtx, async () => {
8978
8940
  const countOpts = this.buildDriverOptions(opCtx.context);
@@ -8985,7 +8947,7 @@ var _ObjectQL = class _ObjectQL {
8985
8947
  });
8986
8948
  return opCtx.result;
8987
8949
  }
8988
- async aggregate(object, query) {
8950
+ async aggregate(object, query, options) {
8989
8951
  object = this.resolveObjectName(object);
8990
8952
  const driver = this.getDriver(object);
8991
8953
  this.logger.debug(`Aggregate on ${object} using ${driver.name}`, query);
@@ -8993,7 +8955,7 @@ var _ObjectQL = class _ObjectQL {
8993
8955
  object,
8994
8956
  operation: "aggregate",
8995
8957
  options: query,
8996
- context: query?.context
8958
+ context: mergeReadContext(query?.context, options?.context)
8997
8959
  };
8998
8960
  await this.executeWithMiddleware(opCtx, async () => {
8999
8961
  const ast = {
@@ -9010,11 +8972,14 @@ var _ObjectQL = class _ObjectQL {
9010
8972
  if (!g?.dateGranularity) return true;
9011
8973
  return granularityCaps?.[g.dateGranularity] === true;
9012
8974
  });
9013
- if (typeof drv.aggregate === "function" && allStructuredSupported) {
8975
+ const tz = query.timezone;
8976
+ const hasDateBucket = structuredItems.some((g) => !!g?.dateGranularity);
8977
+ const tzRequiresInMemory = !!tz && tz !== "UTC" && hasDateBucket;
8978
+ if (typeof drv.aggregate === "function" && allStructuredSupported && !tzRequiresInMemory) {
9014
8979
  return drv.aggregate(object, ast, this.buildDriverOptions(opCtx.context));
9015
8980
  }
9016
8981
  const raw = await driver.find(object, ast, this.buildDriverOptions(opCtx.context));
9017
- return applyInMemoryAggregation(raw, ast);
8982
+ return applyInMemoryAggregation(raw, ast, tz);
9018
8983
  });
9019
8984
  return opCtx.result;
9020
8985
  }