@objectstack/objectql 9.8.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
@@ -4286,145 +4286,6 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
4286
4286
  };
4287
4287
  }
4288
4288
  // ==========================================
4289
- // Lead Convert (M10.6)
4290
- // ==========================================
4291
- /**
4292
- * Convert a qualified Lead into an Account + Contact (+ optional
4293
- * Opportunity) and mark the Lead as converted. Mirrors the Salesforce
4294
- * lead-conversion model:
4295
- *
4296
- * - If `accountId` is provided, the lead's company info is NOT used
4297
- * to create a new account; the new contact and opportunity link to
4298
- * the existing account instead.
4299
- * - If `contactId` is provided, no new contact is created either —
4300
- * useful when the lead is a new contact at an existing account.
4301
- * - `createOpportunity` defaults to true; pass `false` to convert
4302
- * without producing an opportunity (some teams convert "logos
4303
- * only" first).
4304
- * - Lead is updated atomically: `is_converted=true`,
4305
- * `converted_account`/`converted_contact`/`converted_opportunity`
4306
- * pointers, `converted_date`, and `status='converted'`.
4307
- *
4308
- * Atomicity is enforced via the default driver's transaction support
4309
- * when available; otherwise a best-effort compensation (delete
4310
- * already-created child records on failure) is attempted. Permission
4311
- * checks on each child object are inherited from the caller's
4312
- * execution context so SecurityPlugin still gates account/contact/
4313
- * opportunity creates.
4314
- */
4315
- async convertLead(request) {
4316
- const leadId = String(request.leadId || "").trim();
4317
- if (!leadId) {
4318
- const err = new Error("leadId is required");
4319
- err.status = 400;
4320
- err.code = "INVALID_REQUEST";
4321
- throw err;
4322
- }
4323
- const ctx = request.context;
4324
- const ctxOpt = ctx !== void 0 ? { context: ctx } : void 0;
4325
- const lead = await this.engine.findOne("lead", { where: { id: leadId }, ...ctxOpt });
4326
- if (!lead) {
4327
- const err = new Error(`Lead '${leadId}' not found`);
4328
- err.status = 404;
4329
- err.code = "LEAD_NOT_FOUND";
4330
- throw err;
4331
- }
4332
- if (lead.is_converted) {
4333
- const err = new Error(`Lead '${leadId}' is already converted`);
4334
- err.status = 409;
4335
- err.code = "LEAD_ALREADY_CONVERTED";
4336
- throw err;
4337
- }
4338
- const runConversion = async (trxCtx) => {
4339
- const opCtx = trxCtx ?? ctx;
4340
- const trxCtxOpt = opCtx !== void 0 ? { context: opCtx } : void 0;
4341
- let account;
4342
- if (request.accountId) {
4343
- account = await this.engine.findOne("account", { where: { id: request.accountId }, ...trxCtxOpt });
4344
- if (!account) {
4345
- const err = new Error(`Account '${request.accountId}' not found`);
4346
- err.status = 404;
4347
- err.code = "ACCOUNT_NOT_FOUND";
4348
- throw err;
4349
- }
4350
- } else {
4351
- const accountPayload = {
4352
- name: lead.company || `${lead.first_name ?? ""} ${lead.last_name ?? ""}`.trim() || "Untitled Account"
4353
- };
4354
- if (lead.industry) accountPayload.industry = lead.industry;
4355
- if (lead.annual_revenue) accountPayload.annual_revenue = lead.annual_revenue;
4356
- if (lead.number_of_employees) accountPayload.employees = lead.number_of_employees;
4357
- if (lead.website) accountPayload.website = lead.website;
4358
- if (lead.phone) accountPayload.phone = lead.phone;
4359
- if (lead.address) accountPayload.billing_address = lead.address;
4360
- if (lead.owner) accountPayload.owner = lead.owner;
4361
- account = await this.engine.insert("account", accountPayload, trxCtxOpt);
4362
- }
4363
- let contact;
4364
- if (request.contactId) {
4365
- contact = await this.engine.findOne("contact", { where: { id: request.contactId }, ...trxCtxOpt });
4366
- if (!contact) {
4367
- const err = new Error(`Contact '${request.contactId}' not found`);
4368
- err.status = 404;
4369
- err.code = "CONTACT_NOT_FOUND";
4370
- throw err;
4371
- }
4372
- } else {
4373
- const contactPayload = {
4374
- first_name: lead.first_name ?? "",
4375
- last_name: lead.last_name ?? lead.company ?? "Unknown"
4376
- };
4377
- if (lead.salutation) contactPayload.salutation = lead.salutation;
4378
- if (lead.email) contactPayload.email = lead.email;
4379
- if (lead.phone) contactPayload.phone = lead.phone;
4380
- if (lead.mobile) contactPayload.mobile = lead.mobile;
4381
- if (lead.title) contactPayload.title = lead.title;
4382
- if (lead.address) contactPayload.mailing_address = lead.address;
4383
- if (lead.owner) contactPayload.owner = lead.owner;
4384
- if (account?.id) contactPayload.account = account.id;
4385
- contact = await this.engine.insert("contact", contactPayload, trxCtxOpt);
4386
- }
4387
- let opportunity = null;
4388
- const shouldCreateOpp = request.createOpportunity !== false;
4389
- if (shouldCreateOpp) {
4390
- const oppOverrides = request.opportunity ?? {};
4391
- const defaultName = oppOverrides.name || `${account?.name ?? lead.company ?? "Lead"} - New Opportunity`;
4392
- const defaultClose = oppOverrides.close_date || new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3).toISOString().slice(0, 10);
4393
- const oppPayload = {
4394
- name: defaultName,
4395
- stage: oppOverrides.stage ?? "qualification",
4396
- close_date: defaultClose
4397
- };
4398
- if (oppOverrides.amount !== void 0) oppPayload.amount = oppOverrides.amount;
4399
- else if (lead.annual_revenue) oppPayload.amount = lead.annual_revenue;
4400
- if (account?.id) oppPayload.account = account.id;
4401
- if (contact?.id) oppPayload.primary_contact = contact.id;
4402
- if (lead.owner) oppPayload.owner = lead.owner;
4403
- if (lead.lead_source) oppPayload.lead_source = lead.lead_source;
4404
- opportunity = await this.engine.insert("opportunity", oppPayload, trxCtxOpt);
4405
- }
4406
- const leadUpdate = {
4407
- is_converted: true,
4408
- status: request.convertedStatus ?? "converted",
4409
- converted_account: account?.id ?? null,
4410
- converted_contact: contact?.id ?? null,
4411
- converted_opportunity: opportunity?.id ?? null,
4412
- converted_date: (/* @__PURE__ */ new Date()).toISOString()
4413
- };
4414
- const updatedLead = await this.engine.update("lead", leadUpdate, {
4415
- where: { id: leadId },
4416
- ...trxCtxOpt
4417
- });
4418
- return {
4419
- lead: updatedLead ?? { ...lead, ...leadUpdate },
4420
- account,
4421
- contact,
4422
- opportunity
4423
- };
4424
- };
4425
- return this.engine.transaction(runConversion, ctx);
4426
- }
4427
- // ==========================================
4428
4289
  // Metadata Caching
4429
4290
  // ==========================================
4430
4291
  async getMetaItemCached(request) {
@@ -6788,11 +6649,20 @@ function validateOne(name, def, value) {
6788
6649
  if (value === 0 || value === 1 || value === "0" || value === "1" || value === "true" || value === "false") return null;
6789
6650
  return { field: name, code: "invalid_boolean", message: `${name} must be true or false` };
6790
6651
  }
6791
- if (t === "date" || t === "datetime" || t === "time") {
6652
+ if (t === "date" || t === "datetime") {
6792
6653
  if (value instanceof Date) return null;
6793
6654
  if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return null;
6794
6655
  return { field: name, code: "invalid_date", message: `${name} must be a valid ${t} (ISO-8601)` };
6795
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
+ }
6796
6666
  if (t === "select" || t === "radio") {
6797
6667
  const allowed = optionValues(def.options);
6798
6668
  if (allowed.length > 0 && !allowed.includes(String(value))) {
@@ -7108,7 +6978,8 @@ function legalNextStates(objectSchema, field, currentState) {
7108
6978
  }
7109
6979
 
7110
6980
  // src/in-memory-aggregation.ts
7111
- function applyInMemoryAggregation(rows, ast) {
6981
+ import { calendarPartsInTzOrUtc } from "@objectstack/core";
6982
+ function applyInMemoryAggregation(rows, ast, timezone) {
7112
6983
  const groupBy = ast.groupBy ?? [];
7113
6984
  const aggregations = ast.aggregations ?? [];
7114
6985
  if (groupBy.length === 0 && aggregations.length === 0) return rows;
@@ -7121,7 +6992,7 @@ function applyInMemoryAggregation(rows, ast) {
7121
6992
  const parts = [];
7122
6993
  for (const g of groupBy) {
7123
6994
  const fieldName = typeof g === "string" ? g : g.alias ?? g.field;
7124
- const value = projectGroupValue(row, g);
6995
+ const value = projectGroupValue(row, g, timezone);
7125
6996
  key[fieldName] = value;
7126
6997
  parts.push(`${fieldName}=${value}`);
7127
6998
  }
@@ -7140,11 +7011,11 @@ function applyInMemoryAggregation(rows, ast) {
7140
7011
  }
7141
7012
  return out;
7142
7013
  }
7143
- function projectGroupValue(row, g) {
7014
+ function projectGroupValue(row, g, timezone) {
7144
7015
  const field = typeof g === "string" ? g : g.field;
7145
7016
  const v = row?.[field];
7146
7017
  if (typeof g !== "string" && g.dateGranularity) {
7147
- return bucketDateValue(v, g.dateGranularity);
7018
+ return bucketDateValue(v, g.dateGranularity, timezone);
7148
7019
  }
7149
7020
  return v == null ? "(null)" : String(v);
7150
7021
  }
@@ -7222,12 +7093,11 @@ function toNumber(v) {
7222
7093
  const n = Number(v);
7223
7094
  return Number.isFinite(n) ? n : 0;
7224
7095
  }
7225
- function bucketDateValue(value, granularity) {
7096
+ function bucketDateValue(value, granularity, timezone) {
7226
7097
  if (value == null) return "(null)";
7227
7098
  const d = value instanceof Date ? value : new Date(String(value));
7228
7099
  if (Number.isNaN(d.getTime())) return "(null)";
7229
- const y = d.getUTCFullYear();
7230
- const m = d.getUTCMonth() + 1;
7100
+ const { year: y, month: m, day } = calendarPartsInTzOrUtc(d, timezone);
7231
7101
  switch (granularity) {
7232
7102
  case "year":
7233
7103
  return String(y);
@@ -7236,9 +7106,9 @@ function bucketDateValue(value, granularity) {
7236
7106
  case "month":
7237
7107
  return `${y}-${String(m).padStart(2, "0")}`;
7238
7108
  case "day":
7239
- 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")}`;
7240
7110
  case "week": {
7241
- const target = new Date(Date.UTC(y, d.getUTCMonth(), d.getUTCDate()));
7111
+ const target = new Date(Date.UTC(y, m - 1, day));
7242
7112
  const dayNum = (target.getUTCDay() + 6) % 7;
7243
7113
  target.setUTCDate(target.getUTCDate() - dayNum + 3);
7244
7114
  const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
@@ -7281,12 +7151,16 @@ function planFormulaProjection(schema, requestedFields) {
7281
7151
  }
7282
7152
  return { plan };
7283
7153
  }
7284
- function applyFormulaPlan(plan, records) {
7154
+ function applyFormulaPlan(plan, records, execCtx, nowSnapshot) {
7285
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;
7286
7160
  for (const rec of records) {
7287
7161
  if (rec == null) continue;
7288
7162
  for (const fp of plan) {
7289
- const r = ExpressionEngine3.evaluate(fp.expression, { record: rec });
7163
+ const r = ExpressionEngine3.evaluate(fp.expression, { now, timezone, user, org, record: rec });
7290
7164
  rec[fp.name] = r.ok ? r.value : null;
7291
7165
  }
7292
7166
  }
@@ -7718,6 +7592,7 @@ var _ObjectQL = class _ObjectQL {
7718
7592
  if (typeof dv === "object" && dv !== null && dv.dialect && typeof dv.source === "string") {
7719
7593
  const result = ExpressionEngine3.evaluate(dv, {
7720
7594
  now,
7595
+ timezone: execCtx?.timezone,
7721
7596
  user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : void 0,
7722
7597
  org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : void 0,
7723
7598
  record: out,
@@ -8654,7 +8529,7 @@ var _ObjectQL = class _ObjectQL {
8654
8529
  hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
8655
8530
  try {
8656
8531
  let result = await driver.find(object, hookContext.input.ast, hookContext.input.options);
8657
- if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result);
8532
+ if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result, opCtx.context);
8658
8533
  if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) {
8659
8534
  result = await this.expandRelatedRecords(object, result, ast.expand, 0, opCtx.context);
8660
8535
  }
@@ -8698,7 +8573,7 @@ var _ObjectQL = class _ObjectQL {
8698
8573
  await this.executeWithMiddleware(opCtx, async () => {
8699
8574
  const findOneOpts = this.buildDriverOptions(opCtx.context);
8700
8575
  let result = await driver.findOne(objectName, opCtx.ast, findOneOpts);
8701
- if (result != null) applyFormulaPlan(_findOneFormula.plan, [result]);
8576
+ if (result != null) applyFormulaPlan(_findOneFormula.plan, [result], opCtx.context);
8702
8577
  if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {
8703
8578
  const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context);
8704
8579
  result = expanded[0];
@@ -8913,7 +8788,10 @@ var _ObjectQL = class _ObjectQL {
8913
8788
  * - `set_null` → clear the foreign key,
8914
8789
  * - `restrict` → refuse the delete when dependents exist.
8915
8790
  * `master_detail` defaults to `cascade` (the parent owns the child
8916
- * 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
8917
8795
  * deletes — multi/predicate deletes skip cascade (logged).
8918
8796
  */
8919
8797
  async cascadeDeleteRelations(object, id, context, depth = 0) {
@@ -8939,7 +8817,10 @@ var _ObjectQL = class _ObjectQL {
8939
8817
  resolvedRef = void 0;
8940
8818
  }
8941
8819
  if (ref !== object && resolvedRef !== object) continue;
8942
- 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
+ }
8943
8824
  let dependents;
8944
8825
  try {
8945
8826
  dependents = await this.find(childName, { where: { [fieldName]: id }, context });
@@ -8948,9 +8829,16 @@ var _ObjectQL = class _ObjectQL {
8948
8829
  }
8949
8830
  if (!dependents || dependents.length === 0) continue;
8950
8831
  if (behavior === "restrict") {
8951
- throw new Error(
8952
- `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}.`
8953
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;
8954
8842
  }
8955
8843
  for (const dep of dependents) {
8956
8844
  const depId = dep?.id;
@@ -9084,11 +8972,14 @@ var _ObjectQL = class _ObjectQL {
9084
8972
  if (!g?.dateGranularity) return true;
9085
8973
  return granularityCaps?.[g.dateGranularity] === true;
9086
8974
  });
9087
- 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) {
9088
8979
  return drv.aggregate(object, ast, this.buildDriverOptions(opCtx.context));
9089
8980
  }
9090
8981
  const raw = await driver.find(object, ast, this.buildDriverOptions(opCtx.context));
9091
- return applyInMemoryAggregation(raw, ast);
8982
+ return applyInMemoryAggregation(raw, ast, tz);
9092
8983
  });
9093
8984
  return opCtx.result;
9094
8985
  }