@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.d.mts CHANGED
@@ -636,6 +636,8 @@ declare class ObjectStackProtocolImplementation implements ObjectStackProtocol {
636
636
  variant?: "link" | "primary" | "secondary" | "danger" | "ghost" | undefined;
637
637
  confirmText?: string | undefined;
638
638
  successMessage?: string | undefined;
639
+ errorMessage?: string | undefined;
640
+ undoable?: boolean | undefined;
639
641
  resultDialog?: {
640
642
  title?: string | undefined;
641
643
  description?: string | undefined;
@@ -1161,49 +1163,6 @@ declare class ObjectStackProtocolImplementation implements ObjectStackProtocol {
1161
1163
  totalHits: number;
1162
1164
  truncated: boolean;
1163
1165
  }>;
1164
- /**
1165
- * Convert a qualified Lead into an Account + Contact (+ optional
1166
- * Opportunity) and mark the Lead as converted. Mirrors the Salesforce
1167
- * lead-conversion model:
1168
- *
1169
- * - If `accountId` is provided, the lead's company info is NOT used
1170
- * to create a new account; the new contact and opportunity link to
1171
- * the existing account instead.
1172
- * - If `contactId` is provided, no new contact is created either —
1173
- * useful when the lead is a new contact at an existing account.
1174
- * - `createOpportunity` defaults to true; pass `false` to convert
1175
- * without producing an opportunity (some teams convert "logos
1176
- * only" first).
1177
- * - Lead is updated atomically: `is_converted=true`,
1178
- * `converted_account`/`converted_contact`/`converted_opportunity`
1179
- * pointers, `converted_date`, and `status='converted'`.
1180
- *
1181
- * Atomicity is enforced via the default driver's transaction support
1182
- * when available; otherwise a best-effort compensation (delete
1183
- * already-created child records on failure) is attempted. Permission
1184
- * checks on each child object are inherited from the caller's
1185
- * execution context so SecurityPlugin still gates account/contact/
1186
- * opportunity creates.
1187
- */
1188
- convertLead(request: {
1189
- leadId: string;
1190
- accountId?: string;
1191
- contactId?: string;
1192
- createOpportunity?: boolean;
1193
- opportunity?: {
1194
- name?: string;
1195
- amount?: number;
1196
- close_date?: string;
1197
- stage?: string;
1198
- };
1199
- convertedStatus?: string;
1200
- context?: any;
1201
- }): Promise<{
1202
- lead: any;
1203
- account: any;
1204
- contact: any;
1205
- opportunity: any | null;
1206
- }>;
1207
1166
  getMetaItemCached(request: {
1208
1167
  type: string;
1209
1168
  name: string;
@@ -2472,7 +2431,10 @@ declare class ObjectQL implements IDataEngine {
2472
2431
  * - `set_null` → clear the foreign key,
2473
2432
  * - `restrict` → refuse the delete when dependents exist.
2474
2433
  * `master_detail` defaults to `cascade` (the parent owns the child
2475
- * lifecycle); `lookup` defaults to `set_null`. Only runs for single-id
2434
+ * lifecycle); `lookup` defaults to `set_null` except a `set_null` default
2435
+ * on a REQUIRED lookup escalates to `restrict` (you can't null a NOT NULL
2436
+ * FK; restricting with a clear dependent-count message beats a misleading
2437
+ * "<field> is required" 400 from the child). Only runs for single-id
2476
2438
  * deletes — multi/predicate deletes skip cascade (logged).
2477
2439
  */
2478
2440
  private cascadeDeleteRelations;
@@ -2715,13 +2677,25 @@ declare class ScopedContext {
2715
2677
  /**
2716
2678
  * Group + aggregate raw rows according to the AST's `groupBy` /
2717
2679
  * `aggregations`. When neither is present, returns the rows unchanged.
2680
+ *
2681
+ * `timezone` (ADR-0053 Phase 2) shifts date bucketing to a reference timezone
2682
+ * so a row near a tz day-boundary lands in the right day/week/month/quarter.
2683
+ * It is only consulted by `groupBy` items carrying a `dateGranularity`; an
2684
+ * unset or `'UTC'` value keeps the historical UTC bucketing.
2718
2685
  */
2719
- declare function applyInMemoryAggregation(rows: any[], ast: Pick<QueryAST, 'groupBy' | 'aggregations'>): any[];
2686
+ declare function applyInMemoryAggregation(rows: any[], ast: Pick<QueryAST, 'groupBy' | 'aggregations'>, timezone?: string): any[];
2720
2687
  /**
2721
2688
  * Bucket a date-like value into an ISO-formatted period label. Weeks start
2722
2689
  * Monday and use ISO week numbering.
2690
+ *
2691
+ * `timezone` (ADR-0053 Phase 2) resolves the calendar day in a reference zone
2692
+ * so an instant near a tz day-boundary buckets where a user in that zone would
2693
+ * expect. An unset / `'UTC'` / invalid zone keeps the historical UTC bucketing.
2694
+ * The y/m/d are taken in the reference zone and the ISO-week math then runs on
2695
+ * a UTC date built from those parts — the parts already carry the zone shift,
2696
+ * so the week boundary lands correctly without re-applying any offset.
2723
2697
  */
2724
- declare function bucketDateValue(value: unknown, granularity: DateGranularityValue): string;
2698
+ declare function bucketDateValue(value: unknown, granularity: DateGranularityValue, timezone?: string): string;
2725
2699
 
2726
2700
  /**
2727
2701
  * Hook Execution Metrics
@@ -2917,7 +2891,7 @@ declare function wrapDeclarativeHook(meta: Hook, handler: HookHandler, opts?: Wr
2917
2891
 
2918
2892
  interface FieldValidationError {
2919
2893
  field: string;
2920
- code: 'required' | 'min_length' | 'max_length' | 'min_value' | 'max_value' | 'invalid_email' | 'invalid_url' | 'invalid_phone' | 'invalid_number' | 'invalid_boolean' | 'invalid_date' | 'invalid_option' | 'invalid_transition' | 'rule_violation' | 'invalid_format' | 'invalid_json' | 'json_schema_violation';
2894
+ code: 'required' | 'min_length' | 'max_length' | 'min_value' | 'max_value' | 'invalid_email' | 'invalid_url' | 'invalid_phone' | 'invalid_number' | 'invalid_boolean' | 'invalid_date' | 'invalid_time' | 'invalid_option' | 'invalid_transition' | 'rule_violation' | 'invalid_format' | 'invalid_json' | 'json_schema_violation';
2921
2895
  message: string;
2922
2896
  /** Allowed values for select/multiselect, when applicable. */
2923
2897
  options?: string[];
package/dist/index.d.ts CHANGED
@@ -636,6 +636,8 @@ declare class ObjectStackProtocolImplementation implements ObjectStackProtocol {
636
636
  variant?: "link" | "primary" | "secondary" | "danger" | "ghost" | undefined;
637
637
  confirmText?: string | undefined;
638
638
  successMessage?: string | undefined;
639
+ errorMessage?: string | undefined;
640
+ undoable?: boolean | undefined;
639
641
  resultDialog?: {
640
642
  title?: string | undefined;
641
643
  description?: string | undefined;
@@ -1161,49 +1163,6 @@ declare class ObjectStackProtocolImplementation implements ObjectStackProtocol {
1161
1163
  totalHits: number;
1162
1164
  truncated: boolean;
1163
1165
  }>;
1164
- /**
1165
- * Convert a qualified Lead into an Account + Contact (+ optional
1166
- * Opportunity) and mark the Lead as converted. Mirrors the Salesforce
1167
- * lead-conversion model:
1168
- *
1169
- * - If `accountId` is provided, the lead's company info is NOT used
1170
- * to create a new account; the new contact and opportunity link to
1171
- * the existing account instead.
1172
- * - If `contactId` is provided, no new contact is created either —
1173
- * useful when the lead is a new contact at an existing account.
1174
- * - `createOpportunity` defaults to true; pass `false` to convert
1175
- * without producing an opportunity (some teams convert "logos
1176
- * only" first).
1177
- * - Lead is updated atomically: `is_converted=true`,
1178
- * `converted_account`/`converted_contact`/`converted_opportunity`
1179
- * pointers, `converted_date`, and `status='converted'`.
1180
- *
1181
- * Atomicity is enforced via the default driver's transaction support
1182
- * when available; otherwise a best-effort compensation (delete
1183
- * already-created child records on failure) is attempted. Permission
1184
- * checks on each child object are inherited from the caller's
1185
- * execution context so SecurityPlugin still gates account/contact/
1186
- * opportunity creates.
1187
- */
1188
- convertLead(request: {
1189
- leadId: string;
1190
- accountId?: string;
1191
- contactId?: string;
1192
- createOpportunity?: boolean;
1193
- opportunity?: {
1194
- name?: string;
1195
- amount?: number;
1196
- close_date?: string;
1197
- stage?: string;
1198
- };
1199
- convertedStatus?: string;
1200
- context?: any;
1201
- }): Promise<{
1202
- lead: any;
1203
- account: any;
1204
- contact: any;
1205
- opportunity: any | null;
1206
- }>;
1207
1166
  getMetaItemCached(request: {
1208
1167
  type: string;
1209
1168
  name: string;
@@ -2472,7 +2431,10 @@ declare class ObjectQL implements IDataEngine {
2472
2431
  * - `set_null` → clear the foreign key,
2473
2432
  * - `restrict` → refuse the delete when dependents exist.
2474
2433
  * `master_detail` defaults to `cascade` (the parent owns the child
2475
- * lifecycle); `lookup` defaults to `set_null`. Only runs for single-id
2434
+ * lifecycle); `lookup` defaults to `set_null` except a `set_null` default
2435
+ * on a REQUIRED lookup escalates to `restrict` (you can't null a NOT NULL
2436
+ * FK; restricting with a clear dependent-count message beats a misleading
2437
+ * "<field> is required" 400 from the child). Only runs for single-id
2476
2438
  * deletes — multi/predicate deletes skip cascade (logged).
2477
2439
  */
2478
2440
  private cascadeDeleteRelations;
@@ -2715,13 +2677,25 @@ declare class ScopedContext {
2715
2677
  /**
2716
2678
  * Group + aggregate raw rows according to the AST's `groupBy` /
2717
2679
  * `aggregations`. When neither is present, returns the rows unchanged.
2680
+ *
2681
+ * `timezone` (ADR-0053 Phase 2) shifts date bucketing to a reference timezone
2682
+ * so a row near a tz day-boundary lands in the right day/week/month/quarter.
2683
+ * It is only consulted by `groupBy` items carrying a `dateGranularity`; an
2684
+ * unset or `'UTC'` value keeps the historical UTC bucketing.
2718
2685
  */
2719
- declare function applyInMemoryAggregation(rows: any[], ast: Pick<QueryAST, 'groupBy' | 'aggregations'>): any[];
2686
+ declare function applyInMemoryAggregation(rows: any[], ast: Pick<QueryAST, 'groupBy' | 'aggregations'>, timezone?: string): any[];
2720
2687
  /**
2721
2688
  * Bucket a date-like value into an ISO-formatted period label. Weeks start
2722
2689
  * Monday and use ISO week numbering.
2690
+ *
2691
+ * `timezone` (ADR-0053 Phase 2) resolves the calendar day in a reference zone
2692
+ * so an instant near a tz day-boundary buckets where a user in that zone would
2693
+ * expect. An unset / `'UTC'` / invalid zone keeps the historical UTC bucketing.
2694
+ * The y/m/d are taken in the reference zone and the ISO-week math then runs on
2695
+ * a UTC date built from those parts — the parts already carry the zone shift,
2696
+ * so the week boundary lands correctly without re-applying any offset.
2723
2697
  */
2724
- declare function bucketDateValue(value: unknown, granularity: DateGranularityValue): string;
2698
+ declare function bucketDateValue(value: unknown, granularity: DateGranularityValue, timezone?: string): string;
2725
2699
 
2726
2700
  /**
2727
2701
  * Hook Execution Metrics
@@ -2917,7 +2891,7 @@ declare function wrapDeclarativeHook(meta: Hook, handler: HookHandler, opts?: Wr
2917
2891
 
2918
2892
  interface FieldValidationError {
2919
2893
  field: string;
2920
- code: 'required' | 'min_length' | 'max_length' | 'min_value' | 'max_value' | 'invalid_email' | 'invalid_url' | 'invalid_phone' | 'invalid_number' | 'invalid_boolean' | 'invalid_date' | 'invalid_option' | 'invalid_transition' | 'rule_violation' | 'invalid_format' | 'invalid_json' | 'json_schema_violation';
2894
+ code: 'required' | 'min_length' | 'max_length' | 'min_value' | 'max_value' | 'invalid_email' | 'invalid_url' | 'invalid_phone' | 'invalid_number' | 'invalid_boolean' | 'invalid_date' | 'invalid_time' | 'invalid_option' | 'invalid_transition' | 'rule_violation' | 'invalid_format' | 'invalid_json' | 'json_schema_violation';
2921
2895
  message: string;
2922
2896
  /** Allowed values for select/multiselect, when applicable. */
2923
2897
  options?: string[];
package/dist/index.js CHANGED
@@ -4345,145 +4345,6 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
4345
4345
  };
4346
4346
  }
4347
4347
  // ==========================================
4348
- // Lead Convert (M10.6)
4349
- // ==========================================
4350
- /**
4351
- * Convert a qualified Lead into an Account + Contact (+ optional
4352
- * Opportunity) and mark the Lead as converted. Mirrors the Salesforce
4353
- * lead-conversion model:
4354
- *
4355
- * - If `accountId` is provided, the lead's company info is NOT used
4356
- * to create a new account; the new contact and opportunity link to
4357
- * the existing account instead.
4358
- * - If `contactId` is provided, no new contact is created either —
4359
- * useful when the lead is a new contact at an existing account.
4360
- * - `createOpportunity` defaults to true; pass `false` to convert
4361
- * without producing an opportunity (some teams convert "logos
4362
- * only" first).
4363
- * - Lead is updated atomically: `is_converted=true`,
4364
- * `converted_account`/`converted_contact`/`converted_opportunity`
4365
- * pointers, `converted_date`, and `status='converted'`.
4366
- *
4367
- * Atomicity is enforced via the default driver's transaction support
4368
- * when available; otherwise a best-effort compensation (delete
4369
- * already-created child records on failure) is attempted. Permission
4370
- * checks on each child object are inherited from the caller's
4371
- * execution context so SecurityPlugin still gates account/contact/
4372
- * opportunity creates.
4373
- */
4374
- async convertLead(request) {
4375
- const leadId = String(request.leadId || "").trim();
4376
- if (!leadId) {
4377
- const err = new Error("leadId is required");
4378
- err.status = 400;
4379
- err.code = "INVALID_REQUEST";
4380
- throw err;
4381
- }
4382
- const ctx = request.context;
4383
- const ctxOpt = ctx !== void 0 ? { context: ctx } : void 0;
4384
- const lead = await this.engine.findOne("lead", { where: { id: leadId }, ...ctxOpt });
4385
- if (!lead) {
4386
- const err = new Error(`Lead '${leadId}' not found`);
4387
- err.status = 404;
4388
- err.code = "LEAD_NOT_FOUND";
4389
- throw err;
4390
- }
4391
- if (lead.is_converted) {
4392
- const err = new Error(`Lead '${leadId}' is already converted`);
4393
- err.status = 409;
4394
- err.code = "LEAD_ALREADY_CONVERTED";
4395
- throw err;
4396
- }
4397
- const runConversion = async (trxCtx) => {
4398
- const opCtx = trxCtx ?? ctx;
4399
- const trxCtxOpt = opCtx !== void 0 ? { context: opCtx } : void 0;
4400
- let account;
4401
- if (request.accountId) {
4402
- account = await this.engine.findOne("account", { where: { id: request.accountId }, ...trxCtxOpt });
4403
- if (!account) {
4404
- const err = new Error(`Account '${request.accountId}' not found`);
4405
- err.status = 404;
4406
- err.code = "ACCOUNT_NOT_FOUND";
4407
- throw err;
4408
- }
4409
- } else {
4410
- const accountPayload = {
4411
- name: lead.company || `${lead.first_name ?? ""} ${lead.last_name ?? ""}`.trim() || "Untitled Account"
4412
- };
4413
- if (lead.industry) accountPayload.industry = lead.industry;
4414
- if (lead.annual_revenue) accountPayload.annual_revenue = lead.annual_revenue;
4415
- if (lead.number_of_employees) accountPayload.employees = lead.number_of_employees;
4416
- if (lead.website) accountPayload.website = lead.website;
4417
- if (lead.phone) accountPayload.phone = lead.phone;
4418
- if (lead.address) accountPayload.billing_address = lead.address;
4419
- if (lead.owner) accountPayload.owner = lead.owner;
4420
- account = await this.engine.insert("account", accountPayload, trxCtxOpt);
4421
- }
4422
- let contact;
4423
- if (request.contactId) {
4424
- contact = await this.engine.findOne("contact", { where: { id: request.contactId }, ...trxCtxOpt });
4425
- if (!contact) {
4426
- const err = new Error(`Contact '${request.contactId}' not found`);
4427
- err.status = 404;
4428
- err.code = "CONTACT_NOT_FOUND";
4429
- throw err;
4430
- }
4431
- } else {
4432
- const contactPayload = {
4433
- first_name: lead.first_name ?? "",
4434
- last_name: lead.last_name ?? lead.company ?? "Unknown"
4435
- };
4436
- if (lead.salutation) contactPayload.salutation = lead.salutation;
4437
- if (lead.email) contactPayload.email = lead.email;
4438
- if (lead.phone) contactPayload.phone = lead.phone;
4439
- if (lead.mobile) contactPayload.mobile = lead.mobile;
4440
- if (lead.title) contactPayload.title = lead.title;
4441
- if (lead.address) contactPayload.mailing_address = lead.address;
4442
- if (lead.owner) contactPayload.owner = lead.owner;
4443
- if (account?.id) contactPayload.account = account.id;
4444
- contact = await this.engine.insert("contact", contactPayload, trxCtxOpt);
4445
- }
4446
- let opportunity = null;
4447
- const shouldCreateOpp = request.createOpportunity !== false;
4448
- if (shouldCreateOpp) {
4449
- const oppOverrides = request.opportunity ?? {};
4450
- const defaultName = oppOverrides.name || `${account?.name ?? lead.company ?? "Lead"} - New Opportunity`;
4451
- const defaultClose = oppOverrides.close_date || new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3).toISOString().slice(0, 10);
4452
- const oppPayload = {
4453
- name: defaultName,
4454
- stage: oppOverrides.stage ?? "qualification",
4455
- close_date: defaultClose
4456
- };
4457
- if (oppOverrides.amount !== void 0) oppPayload.amount = oppOverrides.amount;
4458
- else if (lead.annual_revenue) oppPayload.amount = lead.annual_revenue;
4459
- if (account?.id) oppPayload.account = account.id;
4460
- if (contact?.id) oppPayload.primary_contact = contact.id;
4461
- if (lead.owner) oppPayload.owner = lead.owner;
4462
- if (lead.lead_source) oppPayload.lead_source = lead.lead_source;
4463
- opportunity = await this.engine.insert("opportunity", oppPayload, trxCtxOpt);
4464
- }
4465
- const leadUpdate = {
4466
- is_converted: true,
4467
- status: request.convertedStatus ?? "converted",
4468
- converted_account: account?.id ?? null,
4469
- converted_contact: contact?.id ?? null,
4470
- converted_opportunity: opportunity?.id ?? null,
4471
- converted_date: (/* @__PURE__ */ new Date()).toISOString()
4472
- };
4473
- const updatedLead = await this.engine.update("lead", leadUpdate, {
4474
- where: { id: leadId },
4475
- ...trxCtxOpt
4476
- });
4477
- return {
4478
- lead: updatedLead ?? { ...lead, ...leadUpdate },
4479
- account,
4480
- contact,
4481
- opportunity
4482
- };
4483
- };
4484
- return this.engine.transaction(runConversion, ctx);
4485
- }
4486
- // ==========================================
4487
4348
  // Metadata Caching
4488
4349
  // ==========================================
4489
4350
  async getMetaItemCached(request) {
@@ -6316,7 +6177,7 @@ var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
6316
6177
  // src/engine.ts
6317
6178
  var import_node_async_hooks = require("async_hooks");
6318
6179
  var import_kernel6 = require("@objectstack/spec/kernel");
6319
- var import_core = require("@objectstack/core");
6180
+ var import_core2 = require("@objectstack/core");
6320
6181
  var import_system2 = require("@objectstack/spec/system");
6321
6182
 
6322
6183
  // src/secret-fields.ts
@@ -6847,11 +6708,20 @@ function validateOne(name, def, value) {
6847
6708
  if (value === 0 || value === 1 || value === "0" || value === "1" || value === "true" || value === "false") return null;
6848
6709
  return { field: name, code: "invalid_boolean", message: `${name} must be true or false` };
6849
6710
  }
6850
- if (t === "date" || t === "datetime" || t === "time") {
6711
+ if (t === "date" || t === "datetime") {
6851
6712
  if (value instanceof Date) return null;
6852
6713
  if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return null;
6853
6714
  return { field: name, code: "invalid_date", message: `${name} must be a valid ${t} (ISO-8601)` };
6854
6715
  }
6716
+ if (t === "time") {
6717
+ if (value instanceof Date) return null;
6718
+ if (typeof value === "string") {
6719
+ const timeOfDay = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]([01]\d|2[0-3]):?[0-5]\d)?$/;
6720
+ const hasDate = /\d{4}-\d{2}-\d{2}/.test(value);
6721
+ if (timeOfDay.test(value.trim()) || hasDate && !Number.isNaN(Date.parse(value))) return null;
6722
+ }
6723
+ return { field: name, code: "invalid_time", message: `${name} must be a valid time (HH:MM or HH:MM:SS)` };
6724
+ }
6855
6725
  if (t === "select" || t === "radio") {
6856
6726
  const allowed = optionValues(def.options);
6857
6727
  if (allowed.length > 0 && !allowed.includes(String(value))) {
@@ -7167,7 +7037,8 @@ function legalNextStates(objectSchema, field, currentState) {
7167
7037
  }
7168
7038
 
7169
7039
  // src/in-memory-aggregation.ts
7170
- function applyInMemoryAggregation(rows, ast) {
7040
+ var import_core = require("@objectstack/core");
7041
+ function applyInMemoryAggregation(rows, ast, timezone) {
7171
7042
  const groupBy = ast.groupBy ?? [];
7172
7043
  const aggregations = ast.aggregations ?? [];
7173
7044
  if (groupBy.length === 0 && aggregations.length === 0) return rows;
@@ -7180,7 +7051,7 @@ function applyInMemoryAggregation(rows, ast) {
7180
7051
  const parts = [];
7181
7052
  for (const g of groupBy) {
7182
7053
  const fieldName = typeof g === "string" ? g : g.alias ?? g.field;
7183
- const value = projectGroupValue(row, g);
7054
+ const value = projectGroupValue(row, g, timezone);
7184
7055
  key[fieldName] = value;
7185
7056
  parts.push(`${fieldName}=${value}`);
7186
7057
  }
@@ -7199,11 +7070,11 @@ function applyInMemoryAggregation(rows, ast) {
7199
7070
  }
7200
7071
  return out;
7201
7072
  }
7202
- function projectGroupValue(row, g) {
7073
+ function projectGroupValue(row, g, timezone) {
7203
7074
  const field = typeof g === "string" ? g : g.field;
7204
7075
  const v = row?.[field];
7205
7076
  if (typeof g !== "string" && g.dateGranularity) {
7206
- return bucketDateValue(v, g.dateGranularity);
7077
+ return bucketDateValue(v, g.dateGranularity, timezone);
7207
7078
  }
7208
7079
  return v == null ? "(null)" : String(v);
7209
7080
  }
@@ -7281,12 +7152,11 @@ function toNumber(v) {
7281
7152
  const n = Number(v);
7282
7153
  return Number.isFinite(n) ? n : 0;
7283
7154
  }
7284
- function bucketDateValue(value, granularity) {
7155
+ function bucketDateValue(value, granularity, timezone) {
7285
7156
  if (value == null) return "(null)";
7286
7157
  const d = value instanceof Date ? value : new Date(String(value));
7287
7158
  if (Number.isNaN(d.getTime())) return "(null)";
7288
- const y = d.getUTCFullYear();
7289
- const m = d.getUTCMonth() + 1;
7159
+ const { year: y, month: m, day } = (0, import_core.calendarPartsInTzOrUtc)(d, timezone);
7290
7160
  switch (granularity) {
7291
7161
  case "year":
7292
7162
  return String(y);
@@ -7295,9 +7165,9 @@ function bucketDateValue(value, granularity) {
7295
7165
  case "month":
7296
7166
  return `${y}-${String(m).padStart(2, "0")}`;
7297
7167
  case "day":
7298
- return `${y}-${String(m).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
7168
+ return `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
7299
7169
  case "week": {
7300
- const target = new Date(Date.UTC(y, d.getUTCMonth(), d.getUTCDate()));
7170
+ const target = new Date(Date.UTC(y, m - 1, day));
7301
7171
  const dayNum = (target.getUTCDay() + 6) % 7;
7302
7172
  target.setUTCDate(target.getUTCDate() - dayNum + 3);
7303
7173
  const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
@@ -7340,12 +7210,16 @@ function planFormulaProjection(schema, requestedFields) {
7340
7210
  }
7341
7211
  return { plan };
7342
7212
  }
7343
- function applyFormulaPlan(plan, records) {
7213
+ function applyFormulaPlan(plan, records, execCtx, nowSnapshot) {
7344
7214
  if (!plan.length) return;
7215
+ const now = nowSnapshot ?? /* @__PURE__ */ new Date();
7216
+ const timezone = execCtx?.timezone;
7217
+ const user = execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : void 0;
7218
+ const org = execCtx?.tenantId ? { id: String(execCtx.tenantId) } : void 0;
7345
7219
  for (const rec of records) {
7346
7220
  if (rec == null) continue;
7347
7221
  for (const fp of plan) {
7348
- const r = import_formula4.ExpressionEngine.evaluate(fp.expression, { record: rec });
7222
+ const r = import_formula4.ExpressionEngine.evaluate(fp.expression, { now, timezone, user, org, record: rec });
7349
7223
  rec[fp.name] = r.ok ? r.value : null;
7350
7224
  }
7351
7225
  }
@@ -7423,7 +7297,7 @@ var _ObjectQL = class _ObjectQL {
7423
7297
  * parent objects that aggregate it. Invalidated when packages register. */
7424
7298
  this.summaryIndex = null;
7425
7299
  this.hostContext = hostContext;
7426
- this.logger = hostContext.logger || (0, import_core.createLogger)({ level: "info", format: "pretty" });
7300
+ this.logger = hostContext.logger || (0, import_core2.createLogger)({ level: "info", format: "pretty" });
7427
7301
  if (process?.env?.OBJECTQL_STRICT_HOOKS === "1") {
7428
7302
  this._strictHookBinding = true;
7429
7303
  }
@@ -7777,6 +7651,7 @@ var _ObjectQL = class _ObjectQL {
7777
7651
  if (typeof dv === "object" && dv !== null && dv.dialect && typeof dv.source === "string") {
7778
7652
  const result = import_formula4.ExpressionEngine.evaluate(dv, {
7779
7653
  now,
7654
+ timezone: execCtx?.timezone,
7780
7655
  user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : void 0,
7781
7656
  org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : void 0,
7782
7657
  record: out,
@@ -8713,7 +8588,7 @@ var _ObjectQL = class _ObjectQL {
8713
8588
  hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
8714
8589
  try {
8715
8590
  let result = await driver.find(object, hookContext.input.ast, hookContext.input.options);
8716
- if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result);
8591
+ if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result, opCtx.context);
8717
8592
  if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) {
8718
8593
  result = await this.expandRelatedRecords(object, result, ast.expand, 0, opCtx.context);
8719
8594
  }
@@ -8757,7 +8632,7 @@ var _ObjectQL = class _ObjectQL {
8757
8632
  await this.executeWithMiddleware(opCtx, async () => {
8758
8633
  const findOneOpts = this.buildDriverOptions(opCtx.context);
8759
8634
  let result = await driver.findOne(objectName, opCtx.ast, findOneOpts);
8760
- if (result != null) applyFormulaPlan(_findOneFormula.plan, [result]);
8635
+ if (result != null) applyFormulaPlan(_findOneFormula.plan, [result], opCtx.context);
8761
8636
  if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {
8762
8637
  const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context);
8763
8638
  result = expanded[0];
@@ -8972,7 +8847,10 @@ var _ObjectQL = class _ObjectQL {
8972
8847
  * - `set_null` → clear the foreign key,
8973
8848
  * - `restrict` → refuse the delete when dependents exist.
8974
8849
  * `master_detail` defaults to `cascade` (the parent owns the child
8975
- * lifecycle); `lookup` defaults to `set_null`. Only runs for single-id
8850
+ * lifecycle); `lookup` defaults to `set_null` except a `set_null` default
8851
+ * on a REQUIRED lookup escalates to `restrict` (you can't null a NOT NULL
8852
+ * FK; restricting with a clear dependent-count message beats a misleading
8853
+ * "<field> is required" 400 from the child). Only runs for single-id
8976
8854
  * deletes — multi/predicate deletes skip cascade (logged).
8977
8855
  */
8978
8856
  async cascadeDeleteRelations(object, id, context, depth = 0) {
@@ -8998,7 +8876,10 @@ var _ObjectQL = class _ObjectQL {
8998
8876
  resolvedRef = void 0;
8999
8877
  }
9000
8878
  if (ref !== object && resolvedRef !== object) continue;
9001
- const behavior = fdef.type === "master_detail" ? fdef.deleteBehavior === "restrict" ? "restrict" : "cascade" : fdef.deleteBehavior || "set_null";
8879
+ let behavior = fdef.type === "master_detail" ? fdef.deleteBehavior === "restrict" ? "restrict" : "cascade" : fdef.deleteBehavior || "set_null";
8880
+ if (behavior === "set_null" && fdef.required === true) {
8881
+ behavior = "restrict";
8882
+ }
9002
8883
  let dependents;
9003
8884
  try {
9004
8885
  dependents = await this.find(childName, { where: { [fieldName]: id }, context });
@@ -9007,9 +8888,16 @@ var _ObjectQL = class _ObjectQL {
9007
8888
  }
9008
8889
  if (!dependents || dependents.length === 0) continue;
9009
8890
  if (behavior === "restrict") {
9010
- throw new Error(
9011
- `Cannot delete ${object} (${id}): ${dependents.length} dependent ${childName} record(s) via ${fieldName}`
8891
+ const reason = fdef.deleteBehavior !== "restrict" && fdef.required === true ? ` (${fieldName} is required, so it cannot be cleared)` : "";
8892
+ const err = new Error(
8893
+ `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}.`
9012
8894
  );
8895
+ err.code = "DELETE_RESTRICTED";
8896
+ err.status = 409;
8897
+ err.object = object;
8898
+ err.dependentObject = childName;
8899
+ err.dependentCount = dependents.length;
8900
+ throw err;
9013
8901
  }
9014
8902
  for (const dep of dependents) {
9015
8903
  const depId = dep?.id;
@@ -9143,11 +9031,14 @@ var _ObjectQL = class _ObjectQL {
9143
9031
  if (!g?.dateGranularity) return true;
9144
9032
  return granularityCaps?.[g.dateGranularity] === true;
9145
9033
  });
9146
- if (typeof drv.aggregate === "function" && allStructuredSupported) {
9034
+ const tz = query.timezone;
9035
+ const hasDateBucket = structuredItems.some((g) => !!g?.dateGranularity);
9036
+ const tzRequiresInMemory = !!tz && tz !== "UTC" && hasDateBucket;
9037
+ if (typeof drv.aggregate === "function" && allStructuredSupported && !tzRequiresInMemory) {
9147
9038
  return drv.aggregate(object, ast, this.buildDriverOptions(opCtx.context));
9148
9039
  }
9149
9040
  const raw = await driver.find(object, ast, this.buildDriverOptions(opCtx.context));
9150
- return applyInMemoryAggregation(raw, ast);
9041
+ return applyInMemoryAggregation(raw, ast, tz);
9151
9042
  });
9152
9043
  return opCtx.result;
9153
9044
  }
@@ -10354,9 +10245,9 @@ var ObjectQLPlugin = class {
10354
10245
  };
10355
10246
 
10356
10247
  // src/kernel-factory.ts
10357
- var import_core2 = require("@objectstack/core");
10248
+ var import_core3 = require("@objectstack/core");
10358
10249
  async function createObjectQLKernel(options = {}) {
10359
- const kernel = new import_core2.ObjectKernel();
10250
+ const kernel = new import_core3.ObjectKernel();
10360
10251
  await kernel.use(new ObjectQLPlugin());
10361
10252
  if (options.plugins) {
10362
10253
  for (const plugin of options.plugins) {