@objectstack/objectql 4.0.5 → 4.1.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
@@ -36,9 +36,11 @@ function mergeObjectDefinitions(base, extension) {
36
36
  }
37
37
  function applySystemFields(schema, opts) {
38
38
  if (schema.systemFields === false) return schema;
39
- if (schema.managedBy) return schema;
39
+ if (schema.managedBy === "better-auth") return schema;
40
40
  const sf = typeof schema.systemFields === "object" && schema.systemFields !== null ? schema.systemFields : void 0;
41
- const wantTenant = opts.multiTenant && sf?.tenant !== false;
41
+ const tenancyDisabled = schema.tenancy?.enabled === false;
42
+ const wantTenant = opts.multiTenant && sf?.tenant !== false && !tenancyDisabled;
43
+ const wantAudit = sf?.audit !== false;
42
44
  const additions = {};
43
45
  if (wantTenant && !schema.fields?.organization_id) {
44
46
  additions.organization_id = {
@@ -49,9 +51,54 @@ function applySystemFields(schema, opts) {
49
51
  indexed: true,
50
52
  hidden: true,
51
53
  readonly: true,
54
+ system: true,
52
55
  description: "Tenant scope (auto-populated by SecurityPlugin on insert)."
53
56
  };
54
57
  }
58
+ if (wantAudit) {
59
+ if (!schema.fields?.created_at) {
60
+ additions.created_at = {
61
+ type: "datetime",
62
+ label: "Created At",
63
+ required: false,
64
+ readonly: true,
65
+ system: true,
66
+ description: "Timestamp when the record was created (auto-populated by the driver)."
67
+ };
68
+ }
69
+ if (!schema.fields?.created_by) {
70
+ additions.created_by = {
71
+ type: "lookup",
72
+ reference: "sys_user",
73
+ label: "Created By",
74
+ required: false,
75
+ readonly: true,
76
+ system: true,
77
+ description: "User who created the record (populated when an authenticated session is present)."
78
+ };
79
+ }
80
+ if (!schema.fields?.updated_at) {
81
+ additions.updated_at = {
82
+ type: "datetime",
83
+ label: "Last Modified At",
84
+ required: false,
85
+ readonly: true,
86
+ system: true,
87
+ description: "Timestamp of the most recent modification (auto-populated by the driver)."
88
+ };
89
+ }
90
+ if (!schema.fields?.updated_by) {
91
+ additions.updated_by = {
92
+ type: "lookup",
93
+ reference: "sys_user",
94
+ label: "Last Modified By",
95
+ required: false,
96
+ readonly: true,
97
+ system: true,
98
+ description: "User who last modified the record (populated when an authenticated session is present)."
99
+ };
100
+ }
101
+ }
55
102
  if (Object.keys(additions).length === 0) return schema;
56
103
  return {
57
104
  ...schema,
@@ -547,6 +594,22 @@ var SchemaRegistry = class {
547
594
  // src/protocol.ts
548
595
  import { parseFilterAST, isFilterAST } from "@objectstack/spec/data";
549
596
  import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from "@objectstack/spec/shared";
597
+ import { ListViewSchema, FormViewSchema, DashboardSchema } from "@objectstack/spec/ui";
598
+ import { DEFAULT_METADATA_TYPE_REGISTRY } from "@objectstack/spec/kernel";
599
+ var FORM_VIEW_TYPES = /* @__PURE__ */ new Set(["simple", "tabbed", "wizard", "split", "drawer", "modal"]);
600
+ function resolveOverlaySchema(type, item) {
601
+ const singular = PLURAL_TO_SINGULAR[type] ?? type;
602
+ switch (singular) {
603
+ case "view": {
604
+ const t = item && typeof item === "object" && "type" in item ? String(item.type) : void 0;
605
+ return t && FORM_VIEW_TYPES.has(t) ? FormViewSchema : ListViewSchema;
606
+ }
607
+ case "dashboard":
608
+ return DashboardSchema;
609
+ default:
610
+ return null;
611
+ }
612
+ }
550
613
  function simpleHash(str) {
551
614
  let hash = 0;
552
615
  for (let i = 0; i < str.length; i++) {
@@ -573,13 +636,67 @@ var SERVICE_CONFIG = {
573
636
  "file-storage": { route: "/api/v1/storage", plugin: "plugin-storage" },
574
637
  search: { route: "/api/v1/search", plugin: "plugin-search" }
575
638
  };
576
- var ObjectStackProtocolImplementation = class {
639
+ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementation {
577
640
  constructor(engine, getServicesRegistry, getFeedService, projectId) {
641
+ /**
642
+ * One-time guard for ensuring the overlay-uniqueness UNIQUE INDEX exists
643
+ * on `sys_metadata`. ADR-0005: scopes overlays by
644
+ * `(type, name, organization_id, project_id, scope)` for active rows only.
645
+ * Idempotent SQL — safe to attempt on every protocol instance.
646
+ *
647
+ * Inlined here (rather than importing from @objectstack/metadata/migrations)
648
+ * to avoid a circular dependency: metadata already depends on objectql.
649
+ */
650
+ this.overlayIndexEnsured = false;
578
651
  this.engine = engine;
579
652
  this.getServicesRegistry = getServicesRegistry;
580
653
  this.getFeedService = getFeedService;
581
654
  this.projectId = projectId;
582
655
  }
656
+ async ensureOverlayIndex() {
657
+ if (this.overlayIndexEnsured) return;
658
+ this.overlayIndexEnsured = true;
659
+ try {
660
+ const engineAny = this.engine;
661
+ let driver = engineAny?.driver ?? engineAny?.getDriver?.();
662
+ if (!driver && engineAny?.drivers instanceof Map) {
663
+ for (const candidate of engineAny.drivers.values()) {
664
+ if (candidate && (typeof candidate.raw === "function" || typeof candidate.execute === "function")) {
665
+ driver = candidate;
666
+ break;
667
+ }
668
+ }
669
+ }
670
+ if (!driver) return;
671
+ const exec = async (sql) => {
672
+ if (typeof driver.raw === "function") {
673
+ await driver.raw(sql);
674
+ } else if (typeof driver.execute === "function") {
675
+ await driver.execute(sql);
676
+ } else {
677
+ throw new Error("driver has neither raw nor execute");
678
+ }
679
+ };
680
+ try {
681
+ await exec("DROP INDEX IF EXISTS idx_sys_metadata_overlay_active");
682
+ } catch {
683
+ }
684
+ const partialSql = "CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ON sys_metadata (type, name, organization_id) WHERE state = 'active'";
685
+ const fallbackSql = "CREATE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ON sys_metadata (type, name, organization_id)";
686
+ try {
687
+ await exec(partialSql);
688
+ } catch (err) {
689
+ const msg = err instanceof Error ? err.message : String(err);
690
+ if (/partial|where clause|syntax/i.test(msg)) {
691
+ try {
692
+ await exec(fallbackSql);
693
+ } catch {
694
+ }
695
+ }
696
+ }
697
+ } catch {
698
+ }
699
+ }
583
700
  /**
584
701
  * Exposes the project scope the protocol is bound to. Consumers like
585
702
  * the HTTP dispatcher use this to decide whether to trust the process-
@@ -713,24 +830,32 @@ var ObjectStackProtocolImplementation = class {
713
830
  if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
714
831
  }
715
832
  }
716
- if (this.projectId === void 0) try {
717
- const whereClause = {
718
- type: request.type,
719
- state: "active",
720
- // Always filter by project_id: project kernels use their projectId,
721
- // control-plane kernels use NULL (global scope only).
722
- project_id: this.projectId ?? null
723
- };
724
- if (packageId) whereClause._packageId = packageId;
725
- let records = await this.engine.find("sys_metadata", { where: whereClause });
726
- if (!records || records.length === 0) {
727
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
728
- if (alt) {
729
- const altWhere = { type: alt, state: "active", project_id: this.projectId ?? null };
730
- if (packageId) altWhere._packageId = packageId;
731
- records = await this.engine.find("sys_metadata", { where: altWhere });
833
+ try {
834
+ const orgId = request.organizationId;
835
+ const queryByOrg = async (oid) => {
836
+ const whereClause = {
837
+ type: request.type,
838
+ state: "active",
839
+ organization_id: oid
840
+ };
841
+ if (packageId) whereClause._packageId = packageId;
842
+ let rs = await this.engine.find("sys_metadata", { where: whereClause });
843
+ if (!rs || rs.length === 0) {
844
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
845
+ if (alt) {
846
+ const altWhere = { type: alt, state: "active", organization_id: oid };
847
+ if (packageId) altWhere._packageId = packageId;
848
+ rs = await this.engine.find("sys_metadata", { where: altWhere });
849
+ }
732
850
  }
733
- }
851
+ return rs ?? [];
852
+ };
853
+ const envWideRecords = await queryByOrg(null);
854
+ const orgRecords = orgId ? await queryByOrg(orgId) : [];
855
+ const mergedMap = /* @__PURE__ */ new Map();
856
+ for (const r of envWideRecords) mergedMap.set(r.name, r);
857
+ for (const r of orgRecords) mergedMap.set(r.name, r);
858
+ const records = Array.from(mergedMap.values());
734
859
  if (records && records.length > 0) {
735
860
  const byName = /* @__PURE__ */ new Map();
736
861
  for (const existing of items) {
@@ -771,7 +896,9 @@ var ObjectStackProtocolImplementation = class {
771
896
  for (const item of runtimeItems) {
772
897
  const entry = item;
773
898
  if (entry && typeof entry === "object" && "name" in entry) {
774
- itemMap.set(entry.name, entry);
899
+ if (!itemMap.has(entry.name)) {
900
+ itemMap.set(entry.name, entry);
901
+ }
775
902
  }
776
903
  }
777
904
  items = Array.from(itemMap.values());
@@ -786,44 +913,40 @@ var ObjectStackProtocolImplementation = class {
786
913
  }
787
914
  async getMetaItem(request) {
788
915
  let item;
789
- if (this.projectId === void 0) {
790
- item = this.engine.registry.getItem(request.type, request.name);
791
- if (item === void 0) {
792
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
793
- if (alt) item = this.engine.registry.getItem(alt, request.name);
794
- }
795
- }
796
- if (item === void 0 && this.projectId === void 0) {
797
- try {
798
- const scopedWhere = {
916
+ const orgId = request.organizationId;
917
+ try {
918
+ const findOverlay = async (oid) => {
919
+ const where = {
799
920
  type: request.type,
800
921
  name: request.name,
801
- state: "active"
922
+ state: "active",
923
+ organization_id: oid
802
924
  };
803
- scopedWhere.project_id = this.projectId ?? null;
804
- const record = await this.engine.findOne("sys_metadata", {
805
- where: scopedWhere
806
- });
807
- if (record) {
808
- item = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
809
- if (this.projectId === void 0) {
810
- this.engine.registry.registerItem(request.type, item, "name");
811
- }
812
- } else {
813
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
814
- if (alt) {
815
- const altWhere = { type: alt, name: request.name, state: "active" };
816
- altWhere.project_id = this.projectId ?? null;
817
- const altRecord = await this.engine.findOne("sys_metadata", { where: altWhere });
818
- if (altRecord) {
819
- item = typeof altRecord.metadata === "string" ? JSON.parse(altRecord.metadata) : altRecord.metadata;
820
- if (this.projectId === void 0) {
821
- this.engine.registry.registerItem(request.type, item, "name");
822
- }
823
- }
824
- }
925
+ const rec = await this.engine.findOne("sys_metadata", { where });
926
+ if (rec) return rec;
927
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
928
+ if (alt) {
929
+ const altWhere = {
930
+ type: alt,
931
+ name: request.name,
932
+ state: "active",
933
+ organization_id: oid
934
+ };
935
+ return await this.engine.findOne("sys_metadata", { where: altWhere });
825
936
  }
826
- } catch {
937
+ return void 0;
938
+ };
939
+ const record = (orgId ? await findOverlay(orgId) : void 0) ?? await findOverlay(null);
940
+ if (record) {
941
+ item = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
942
+ }
943
+ } catch {
944
+ }
945
+ if (item === void 0) {
946
+ item = this.engine.registry.getItem(request.type, request.name);
947
+ if (item === void 0) {
948
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
949
+ if (alt) item = this.engine.registry.getItem(alt, request.name);
827
950
  }
828
951
  }
829
952
  if (item === void 0) {
@@ -902,6 +1025,18 @@ var ObjectStackProtocolImplementation = class {
902
1025
  if (request.context !== void 0) {
903
1026
  options.context = request.context;
904
1027
  }
1028
+ for (const [dollar, bare] of [
1029
+ ["$top", "top"],
1030
+ ["$skip", "skip"],
1031
+ ["$orderby", "orderBy"],
1032
+ ["$select", "select"],
1033
+ ["$count", "count"]
1034
+ ]) {
1035
+ if (options[dollar] != null && options[bare] == null) {
1036
+ options[bare] = options[dollar];
1037
+ }
1038
+ delete options[dollar];
1039
+ }
905
1040
  if (options.top != null) {
906
1041
  options.limit = Number(options.top);
907
1042
  delete options.top;
@@ -1008,6 +1143,23 @@ var ObjectStackProtocolImplementation = class {
1008
1143
  options.where = implicitFilters;
1009
1144
  }
1010
1145
  }
1146
+ const hasGroupBy = Array.isArray(options.groupBy) && options.groupBy.length > 0;
1147
+ const hasAggregations = Array.isArray(options.aggregations) && options.aggregations.length > 0;
1148
+ if (hasGroupBy || hasAggregations) {
1149
+ const records2 = await this.engine.aggregate(request.object, {
1150
+ where: options.where,
1151
+ groupBy: options.groupBy,
1152
+ aggregations: options.aggregations,
1153
+ context: options.context
1154
+ });
1155
+ const limited = typeof options.limit === "number" && options.limit > 0 ? records2.slice(0, options.limit) : records2;
1156
+ return {
1157
+ object: request.object,
1158
+ records: limited,
1159
+ total: limited.length,
1160
+ hasMore: false
1161
+ };
1162
+ }
1011
1163
  const records = await this.engine.find(request.object, options);
1012
1164
  return {
1013
1165
  object: request.object,
@@ -1041,7 +1193,11 @@ var ObjectStackProtocolImplementation = class {
1041
1193
  record: result
1042
1194
  };
1043
1195
  }
1044
- throw new Error(`Record ${request.id} not found in ${request.object}`);
1196
+ const err = new Error(`Record ${request.id} not found in ${request.object}`);
1197
+ err.code = "RECORD_NOT_FOUND";
1198
+ err.status = 404;
1199
+ err.object = request.object;
1200
+ throw err;
1045
1201
  }
1046
1202
  async createData(request) {
1047
1203
  const result = await this.engine.insert(
@@ -1076,25 +1232,281 @@ var ObjectStackProtocolImplementation = class {
1076
1232
  };
1077
1233
  }
1078
1234
  // ==========================================
1079
- // Metadata Caching
1235
+ // Global Search (M10.5)
1080
1236
  // ==========================================
1081
- async getMetaItemCached(request) {
1082
- try {
1083
- let item = this.engine.registry.getItem(request.type, request.name);
1084
- if (!item) {
1085
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
1086
- if (alt) item = this.engine.registry.getItem(alt, request.name);
1237
+ /**
1238
+ * Cross-object substring search across all registered objects that opt in
1239
+ * via `enable.searchable !== false` and `enable.apiEnabled !== false`.
1240
+ * Searches text-like fields (text/textarea/email/url/phone/markdown/html/string)
1241
+ * whose `searchable: true` flag is set, falling back to the object's
1242
+ * `displayNameField` (or `name`) when no fields are explicitly searchable.
1243
+ *
1244
+ * The query is split into whitespace-separated terms; each term must match
1245
+ * (case-insensitive LIKE) at least one searchable field. RBAC/RLS is
1246
+ * enforced by forwarding the caller's `context` to `engine.find` so users
1247
+ * only see records they are entitled to read.
1248
+ */
1249
+ async searchAll(request) {
1250
+ const q = (request.q ?? "").trim();
1251
+ if (!q) {
1252
+ return { query: "", hits: [], totalObjects: 0, totalHits: 0, truncated: false };
1253
+ }
1254
+ const overallLimit = Math.max(1, Math.min(100, Number(request.limit ?? 20)));
1255
+ const perObject = Math.max(1, Math.min(25, Number(request.perObject ?? 5)));
1256
+ const objectsFilter = request.objects && request.objects.length ? new Set(request.objects) : null;
1257
+ const terms = q.split(/\s+/).filter(Boolean).slice(0, 8);
1258
+ const allObjects = this.engine.registry?.getAllObjects?.() ?? [];
1259
+ const hits = [];
1260
+ let objectsScanned = 0;
1261
+ for (const obj of allObjects) {
1262
+ if (hits.length >= overallLimit) break;
1263
+ if (!obj?.name) continue;
1264
+ if (objectsFilter && !objectsFilter.has(obj.name)) continue;
1265
+ const enable = obj.enable ?? {};
1266
+ if (enable.searchable === false) continue;
1267
+ if (enable.apiEnabled === false) continue;
1268
+ if (obj.name.startsWith("sys_audit_log") || obj.name.startsWith("sys_activity") || obj.name.startsWith("sys_session") || obj.name.startsWith("sys_presence") || obj.name.startsWith("sys_metadata") || obj.name.startsWith("sys_account")) {
1269
+ continue;
1087
1270
  }
1088
- if (!item) {
1089
- try {
1090
- const services = this.getServicesRegistry?.();
1091
- const metadataService = services?.get("metadata");
1092
- if (metadataService && typeof metadataService.get === "function") {
1093
- item = await metadataService.get(request.type, request.name);
1271
+ const fieldsRaw = obj.fields;
1272
+ const fields = Array.isArray(fieldsRaw) ? fieldsRaw : fieldsRaw && typeof fieldsRaw === "object" ? Object.entries(fieldsRaw).map(([name, f]) => ({ name, ...f || {} })) : [];
1273
+ const TEXT_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "string", "email", "url", "phone", "markdown", "html"]);
1274
+ const fieldByName = new Map(fields.map((f) => [f.name, f]));
1275
+ const hasField = (n) => fieldByName.has(n);
1276
+ const titleFormatSource = obj.titleFormat && (obj.titleFormat.source || obj.titleFormat) || void 0;
1277
+ const renderTitle = (row) => {
1278
+ if (typeof titleFormatSource === "string") {
1279
+ let allResolved = true;
1280
+ const rendered = titleFormatSource.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key) => {
1281
+ const v = row[key];
1282
+ if (v == null || v === "") {
1283
+ allResolved = false;
1284
+ return "";
1285
+ }
1286
+ return String(v);
1287
+ }).trim();
1288
+ if (rendered && allResolved) return rendered;
1289
+ if (rendered) return rendered.replace(/\s+-\s+$/, "").replace(/^\s+-\s+/, "").trim() || row.id;
1290
+ }
1291
+ const candidates = [
1292
+ obj.displayNameField,
1293
+ "name",
1294
+ "full_name",
1295
+ "title",
1296
+ "subject",
1297
+ "label",
1298
+ "company"
1299
+ ].filter((c) => typeof c === "string" && hasField(c));
1300
+ for (const c of candidates) {
1301
+ const v = row[c];
1302
+ if (v != null && String(v).trim()) return String(v);
1303
+ }
1304
+ const fn = row.first_name, ln = row.last_name;
1305
+ if (fn || ln) return `${fn ?? ""} ${ln ?? ""}`.trim();
1306
+ return String(row.id);
1307
+ };
1308
+ const titleFieldName = obj.displayNameField || (hasField("name") ? "name" : void 0) || (hasField("title") ? "title" : void 0) || fields.find((f) => TEXT_TYPES.has(f.type))?.name;
1309
+ let searchableFields = fields.filter((f) => f && TEXT_TYPES.has(f.type) && f.searchable === true).map((f) => f.name);
1310
+ if (searchableFields.length === 0 && titleFieldName) {
1311
+ searchableFields = [titleFieldName];
1312
+ }
1313
+ if (searchableFields.length === 0) continue;
1314
+ objectsScanned++;
1315
+ const andClauses = terms.map((term) => ({
1316
+ $or: searchableFields.map((f) => ({ [f]: { $contains: term } }))
1317
+ }));
1318
+ const where = andClauses.length === 1 ? andClauses[0] : { $and: andClauses };
1319
+ try {
1320
+ const opts = {
1321
+ where,
1322
+ limit: perObject,
1323
+ orderBy: [{ field: "updated_at", direction: "desc" }]
1324
+ };
1325
+ if (request.context !== void 0) opts.context = request.context;
1326
+ const rows = await this.engine.find(obj.name, opts);
1327
+ for (const row of rows || []) {
1328
+ if (hits.length >= overallLimit) break;
1329
+ const title = renderTitle(row);
1330
+ let snippet;
1331
+ for (const f of searchableFields) {
1332
+ const v = row[f];
1333
+ if (typeof v === "string" && v) {
1334
+ const lc = v.toLowerCase();
1335
+ const idx = terms.map((t) => lc.indexOf(t.toLowerCase())).find((i) => i >= 0);
1336
+ if (idx != null && idx >= 0) {
1337
+ const start = Math.max(0, idx - 30);
1338
+ const end = Math.min(v.length, idx + 90);
1339
+ snippet = (start > 0 ? "\u2026" : "") + v.slice(start, end) + (end < v.length ? "\u2026" : "");
1340
+ break;
1341
+ }
1342
+ }
1094
1343
  }
1095
- } catch {
1344
+ hits.push({
1345
+ object: obj.name,
1346
+ id: row.id,
1347
+ title,
1348
+ snippet,
1349
+ record: row
1350
+ });
1096
1351
  }
1352
+ } catch {
1353
+ continue;
1097
1354
  }
1355
+ }
1356
+ return {
1357
+ query: q,
1358
+ hits,
1359
+ totalObjects: objectsScanned,
1360
+ totalHits: hits.length,
1361
+ truncated: hits.length >= overallLimit
1362
+ };
1363
+ }
1364
+ // ==========================================
1365
+ // Lead Convert (M10.6)
1366
+ // ==========================================
1367
+ /**
1368
+ * Convert a qualified Lead into an Account + Contact (+ optional
1369
+ * Opportunity) and mark the Lead as converted. Mirrors the Salesforce
1370
+ * lead-conversion model:
1371
+ *
1372
+ * - If `accountId` is provided, the lead's company info is NOT used
1373
+ * to create a new account; the new contact and opportunity link to
1374
+ * the existing account instead.
1375
+ * - If `contactId` is provided, no new contact is created either —
1376
+ * useful when the lead is a new contact at an existing account.
1377
+ * - `createOpportunity` defaults to true; pass `false` to convert
1378
+ * without producing an opportunity (some teams convert "logos
1379
+ * only" first).
1380
+ * - Lead is updated atomically: `is_converted=true`,
1381
+ * `converted_account`/`converted_contact`/`converted_opportunity`
1382
+ * pointers, `converted_date`, and `status='converted'`.
1383
+ *
1384
+ * Atomicity is enforced via the default driver's transaction support
1385
+ * when available; otherwise a best-effort compensation (delete
1386
+ * already-created child records on failure) is attempted. Permission
1387
+ * checks on each child object are inherited from the caller's
1388
+ * execution context so SecurityPlugin still gates account/contact/
1389
+ * opportunity creates.
1390
+ */
1391
+ async convertLead(request) {
1392
+ const leadId = String(request.leadId || "").trim();
1393
+ if (!leadId) {
1394
+ const err = new Error("leadId is required");
1395
+ err.status = 400;
1396
+ err.code = "INVALID_REQUEST";
1397
+ throw err;
1398
+ }
1399
+ const ctx = request.context;
1400
+ const ctxOpt = ctx !== void 0 ? { context: ctx } : void 0;
1401
+ const lead = await this.engine.findOne("lead", { where: { id: leadId }, ...ctxOpt });
1402
+ if (!lead) {
1403
+ const err = new Error(`Lead '${leadId}' not found`);
1404
+ err.status = 404;
1405
+ err.code = "LEAD_NOT_FOUND";
1406
+ throw err;
1407
+ }
1408
+ if (lead.is_converted) {
1409
+ const err = new Error(`Lead '${leadId}' is already converted`);
1410
+ err.status = 409;
1411
+ err.code = "LEAD_ALREADY_CONVERTED";
1412
+ throw err;
1413
+ }
1414
+ const runConversion = async (trxCtx) => {
1415
+ const opCtx = trxCtx ?? ctx;
1416
+ const trxCtxOpt = opCtx !== void 0 ? { context: opCtx } : void 0;
1417
+ let account;
1418
+ if (request.accountId) {
1419
+ account = await this.engine.findOne("account", { where: { id: request.accountId }, ...trxCtxOpt });
1420
+ if (!account) {
1421
+ const err = new Error(`Account '${request.accountId}' not found`);
1422
+ err.status = 404;
1423
+ err.code = "ACCOUNT_NOT_FOUND";
1424
+ throw err;
1425
+ }
1426
+ } else {
1427
+ const accountPayload = {
1428
+ name: lead.company || `${lead.first_name ?? ""} ${lead.last_name ?? ""}`.trim() || "Untitled Account"
1429
+ };
1430
+ if (lead.industry) accountPayload.industry = lead.industry;
1431
+ if (lead.annual_revenue) accountPayload.annual_revenue = lead.annual_revenue;
1432
+ if (lead.number_of_employees) accountPayload.employees = lead.number_of_employees;
1433
+ if (lead.website) accountPayload.website = lead.website;
1434
+ if (lead.phone) accountPayload.phone = lead.phone;
1435
+ if (lead.address) accountPayload.billing_address = lead.address;
1436
+ if (lead.owner) accountPayload.owner = lead.owner;
1437
+ account = await this.engine.insert("account", accountPayload, trxCtxOpt);
1438
+ }
1439
+ let contact;
1440
+ if (request.contactId) {
1441
+ contact = await this.engine.findOne("contact", { where: { id: request.contactId }, ...trxCtxOpt });
1442
+ if (!contact) {
1443
+ const err = new Error(`Contact '${request.contactId}' not found`);
1444
+ err.status = 404;
1445
+ err.code = "CONTACT_NOT_FOUND";
1446
+ throw err;
1447
+ }
1448
+ } else {
1449
+ const contactPayload = {
1450
+ first_name: lead.first_name ?? "",
1451
+ last_name: lead.last_name ?? lead.company ?? "Unknown"
1452
+ };
1453
+ if (lead.salutation) contactPayload.salutation = lead.salutation;
1454
+ if (lead.email) contactPayload.email = lead.email;
1455
+ if (lead.phone) contactPayload.phone = lead.phone;
1456
+ if (lead.mobile) contactPayload.mobile = lead.mobile;
1457
+ if (lead.title) contactPayload.title = lead.title;
1458
+ if (lead.address) contactPayload.mailing_address = lead.address;
1459
+ if (lead.owner) contactPayload.owner = lead.owner;
1460
+ if (account?.id) contactPayload.account = account.id;
1461
+ contact = await this.engine.insert("contact", contactPayload, trxCtxOpt);
1462
+ }
1463
+ let opportunity = null;
1464
+ const shouldCreateOpp = request.createOpportunity !== false;
1465
+ if (shouldCreateOpp) {
1466
+ const oppOverrides = request.opportunity ?? {};
1467
+ const defaultName = oppOverrides.name || `${account?.name ?? lead.company ?? "Lead"} - New Opportunity`;
1468
+ const defaultClose = oppOverrides.close_date || new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3).toISOString().slice(0, 10);
1469
+ const oppPayload = {
1470
+ name: defaultName,
1471
+ stage: oppOverrides.stage ?? "qualification",
1472
+ close_date: defaultClose
1473
+ };
1474
+ if (oppOverrides.amount !== void 0) oppPayload.amount = oppOverrides.amount;
1475
+ else if (lead.annual_revenue) oppPayload.amount = lead.annual_revenue;
1476
+ if (account?.id) oppPayload.account = account.id;
1477
+ if (contact?.id) oppPayload.primary_contact = contact.id;
1478
+ if (lead.owner) oppPayload.owner = lead.owner;
1479
+ if (lead.lead_source) oppPayload.lead_source = lead.lead_source;
1480
+ opportunity = await this.engine.insert("opportunity", oppPayload, trxCtxOpt);
1481
+ }
1482
+ const leadUpdate = {
1483
+ is_converted: true,
1484
+ status: request.convertedStatus ?? "converted",
1485
+ converted_account: account?.id ?? null,
1486
+ converted_contact: contact?.id ?? null,
1487
+ converted_opportunity: opportunity?.id ?? null,
1488
+ converted_date: (/* @__PURE__ */ new Date()).toISOString()
1489
+ };
1490
+ const updatedLead = await this.engine.update("lead", leadUpdate, {
1491
+ where: { id: leadId },
1492
+ ...trxCtxOpt
1493
+ });
1494
+ return {
1495
+ lead: updatedLead ?? { ...lead, ...leadUpdate },
1496
+ account,
1497
+ contact,
1498
+ opportunity
1499
+ };
1500
+ };
1501
+ return this.engine.transaction(runConversion, ctx);
1502
+ }
1503
+ // ==========================================
1504
+ // Metadata Caching
1505
+ // ==========================================
1506
+ async getMetaItemCached(request) {
1507
+ try {
1508
+ const result = await this.getMetaItem({ type: request.type, name: request.name });
1509
+ const item = result?.item;
1098
1510
  if (!item) {
1099
1511
  throw new Error(`Metadata item ${request.type}/${request.name} not found`);
1100
1512
  }
@@ -1386,45 +1798,65 @@ var ObjectStackProtocolImplementation = class {
1386
1798
  ...request.options
1387
1799
  });
1388
1800
  }
1801
+ /** Normalize plural→singular before consulting the allow-list. */
1802
+ static isOverlayAllowed(type) {
1803
+ const singular = PLURAL_TO_SINGULAR[type] ?? type;
1804
+ return _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(singular) || _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(type);
1805
+ }
1389
1806
  async saveMetaItem(request) {
1390
1807
  if (!request.item) {
1391
1808
  throw new Error("Item data is required");
1392
1809
  }
1393
- if (this.projectId !== void 0) {
1394
- this.engine.registry.registerItem(request.type, request.item, "name");
1395
- if (request.type === "object" || request.type === "objects") {
1396
- try {
1397
- this.engine.registry.registerObject(request.item, "sys_metadata");
1398
- } catch (err) {
1399
- console.warn(
1400
- `[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
1810
+ if (this.projectId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
1811
+ const allowed = Array.from(_ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES).join(", ");
1812
+ const err = new Error(
1813
+ `[not_overridable] Metadata type '${request.type}' has not opted into per-org overlay writes. Set allowOrgOverride: true on its DEFAULT_METADATA_TYPE_REGISTRY entry to enable. Currently allowed: ${allowed}. See docs/adr/0005-metadata-customization-overlay.md.`
1814
+ );
1815
+ err.code = "not_overridable";
1816
+ err.status = 403;
1817
+ throw err;
1818
+ }
1819
+ {
1820
+ const schema = resolveOverlaySchema(request.type, request.item);
1821
+ if (schema) {
1822
+ const parsed = schema.safeParse(request.item);
1823
+ if (!parsed.success) {
1824
+ const issues = parsed.error.issues.map((i) => ({
1825
+ path: i.path.join("."),
1826
+ message: i.message,
1827
+ code: i.code
1828
+ }));
1829
+ const summary = issues.slice(0, 3).map((i) => `${i.path || "<root>"}: ${i.message}`).join("; ");
1830
+ const err = new Error(
1831
+ `[invalid_metadata] ${request.type}/${request.name} failed spec validation: ${summary}` + (issues.length > 3 ? ` (+${issues.length - 3} more)` : "")
1401
1832
  );
1833
+ err.code = "invalid_metadata";
1834
+ err.status = 422;
1835
+ err.issues = issues;
1836
+ throw err;
1402
1837
  }
1403
1838
  }
1404
- return {
1405
- success: true,
1406
- message: "Saved to memory registry (project kernel \u2014 sys_metadata is control-plane only)"
1407
- };
1408
1839
  }
1409
- this.engine.registry.registerItem(request.type, request.item, "name");
1410
1840
  if (request.type === "object" || request.type === "objects") {
1841
+ this.engine.registry.registerItem(request.type, request.item, "name");
1411
1842
  try {
1412
1843
  this.engine.registry.registerObject(request.item, "sys_metadata");
1413
1844
  } catch (err) {
1414
1845
  console.warn(
1415
- `[Protocol] this.engine.registry.registerObject failed for ${request.name}: ${err?.message ?? err}`
1846
+ `[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
1416
1847
  );
1417
1848
  }
1418
1849
  }
1850
+ await this.ensureOverlayIndex();
1419
1851
  try {
1420
1852
  const now = (/* @__PURE__ */ new Date()).toISOString();
1853
+ const orgId = request.organizationId ?? null;
1421
1854
  const scopedWhere = {
1422
1855
  type: request.type,
1423
- name: request.name
1856
+ name: request.name,
1857
+ organization_id: orgId,
1858
+ state: "active"
1424
1859
  };
1425
- if (this.projectId !== void 0) {
1426
- scopedWhere.project_id = this.projectId;
1427
- }
1428
1860
  const existing = await this.engine.findOne("sys_metadata", {
1429
1861
  where: scopedWhere
1430
1862
  });
@@ -1432,7 +1864,8 @@ var ObjectStackProtocolImplementation = class {
1432
1864
  await this.engine.update("sys_metadata", {
1433
1865
  metadata: JSON.stringify(request.item),
1434
1866
  updated_at: now,
1435
- version: (existing.version || 0) + 1
1867
+ version: (existing.version || 0) + 1,
1868
+ state: "active"
1436
1869
  }, {
1437
1870
  where: { id: existing.id }
1438
1871
  });
@@ -1442,49 +1875,105 @@ var ObjectStackProtocolImplementation = class {
1442
1875
  id,
1443
1876
  name: request.name,
1444
1877
  type: request.type,
1445
- // `scope` tracks platform vs project authorship. With
1446
- // project_id carries the project id, 'project' is the
1447
- // honest label whenever we know we're inside one.
1448
- scope: this.projectId !== void 0 ? "project" : "platform",
1878
+ // `scope` enum is ['system','platform','user']; per-org
1879
+ // overlays use 'platform' as the informational tag. The
1880
+ // authoritative isolation key is `organization_id`.
1881
+ scope: "platform",
1449
1882
  metadata: JSON.stringify(request.item),
1450
1883
  state: "active",
1451
1884
  version: 1,
1452
1885
  created_at: now,
1453
- updated_at: now
1886
+ updated_at: now,
1887
+ organization_id: orgId
1454
1888
  };
1455
- if (this.projectId !== void 0) {
1456
- row.project_id = this.projectId;
1457
- }
1458
1889
  await this.engine.insert("sys_metadata", row);
1459
1890
  }
1460
1891
  return {
1461
1892
  success: true,
1462
- message: "Saved to database and registry"
1893
+ message: orgId ? `Saved customization overlay (org=${orgId}) \u2014 type=${request.type}, name=${request.name}` : `Saved customization overlay (env-wide) \u2014 type=${request.type}, name=${request.name}`
1463
1894
  };
1464
1895
  } catch (dbError) {
1465
- console.warn(`[Protocol] DB persistence failed for ${request.type}/${request.name}: ${dbError.message}`);
1896
+ console.error(
1897
+ `[Protocol] sys_metadata persistence failed for ${request.type}/${request.name}: ${dbError.message}`
1898
+ );
1899
+ const err = new Error(
1900
+ `Failed to persist customization overlay to sys_metadata: ${dbError.message}. In-memory registry was updated but will be lost on restart.`
1901
+ );
1902
+ err.code = "overlay_persistence_failed";
1903
+ err.status = 500;
1904
+ throw err;
1905
+ }
1906
+ }
1907
+ /**
1908
+ * Remove a customization overlay row for the given metadata item, so the
1909
+ * next read falls through to the artifact-loaded default. Implements the
1910
+ * "Reset to factory default" semantic from ADR-0005. Whitelist is shared
1911
+ * with {@link saveMetaItem}.
1912
+ */
1913
+ async deleteMetaItem(request) {
1914
+ if (this.projectId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
1915
+ const err = new Error(
1916
+ `[not_overridable] Metadata type '${request.type}' has not opted into per-org overlay writes. See docs/adr/0005-metadata-customization-overlay.md.`
1917
+ );
1918
+ err.code = "not_overridable";
1919
+ err.status = 403;
1920
+ throw err;
1921
+ }
1922
+ const scopedWhere = {
1923
+ type: request.type,
1924
+ name: request.name,
1925
+ organization_id: request.organizationId ?? null
1926
+ };
1927
+ try {
1928
+ const existing = await this.engine.findOne("sys_metadata", { where: scopedWhere });
1929
+ if (!existing) {
1930
+ return {
1931
+ success: true,
1932
+ reset: false,
1933
+ message: `No customization overlay found for ${request.type}/${request.name} \u2014 already at artifact default.`
1934
+ };
1935
+ }
1936
+ await this.engine.delete("sys_metadata", { where: { id: existing.id } });
1937
+ if (this.projectId === void 0) {
1938
+ try {
1939
+ const services = this.getServicesRegistry?.();
1940
+ const metadataService = services?.get("metadata");
1941
+ if (metadataService && typeof metadataService.get === "function") {
1942
+ const artifactItem = await metadataService.get(request.type, request.name);
1943
+ if (artifactItem !== void 0) {
1944
+ this.engine.registry.registerItem(request.type, artifactItem, "name");
1945
+ }
1946
+ }
1947
+ } catch {
1948
+ }
1949
+ }
1466
1950
  return {
1467
1951
  success: true,
1468
- message: "Saved to memory registry (DB persistence unavailable)",
1469
- warning: dbError.message
1952
+ reset: true,
1953
+ message: `Customization overlay deleted \u2014 ${request.type}/${request.name} reset to artifact default.`
1470
1954
  };
1955
+ } catch (err) {
1956
+ const e = new Error(`Failed to delete customization overlay: ${err.message}`);
1957
+ e.status = 500;
1958
+ throw e;
1471
1959
  }
1472
1960
  }
1473
1961
  /**
1474
1962
  * Hydrate SchemaRegistry from the database on startup.
1475
1963
  * Loads all active metadata records and registers them in the in-memory registry.
1476
1964
  * Safe to call repeatedly — idempotent (latest DB record wins).
1965
+ *
1966
+ * Per ADR-0005, project-kernel mode ALSO hydrates from sys_metadata —
1967
+ * customization overlay rows must survive restart. Scope filter
1968
+ * (`project_id = this.projectId ?? null`) keeps tenants isolated.
1477
1969
  */
1478
1970
  async loadMetaFromDb() {
1479
- if (this.projectId !== void 0) {
1480
- return { loaded: 0, errors: 0 };
1481
- }
1482
1971
  let loaded = 0;
1483
1972
  let errors = 0;
1484
1973
  try {
1485
1974
  const where = {
1486
1975
  state: "active",
1487
- project_id: this.projectId ?? null
1976
+ organization_id: null
1488
1977
  };
1489
1978
  const records = await this.engine.find("sys_metadata", { where });
1490
1979
  for (const record of records) {
@@ -1641,6 +2130,27 @@ var ObjectStackProtocolImplementation = class {
1641
2130
  return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } };
1642
2131
  }
1643
2132
  };
2133
+ /**
2134
+ * Metadata types that are customer-overridable via {@link saveMetaItem}/
2135
+ * {@link deleteMetaItem} in project-kernel mode. Derived from the canonical
2136
+ * registry in {@link DEFAULT_METADATA_TYPE_REGISTRY}: a type opts in by
2137
+ * setting `allowOrgOverride: true` on its registry entry. The set is
2138
+ * augmented with the plural form of every singular so callers using REST
2139
+ * conventions (`/api/v1/meta/views/...`) get the same gate. See ADR-0005
2140
+ * §"Whitelist enforcement" for the rationale and the per-type rollout
2141
+ * checklist.
2142
+ */
2143
+ _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES = (() => {
2144
+ const out = /* @__PURE__ */ new Set();
2145
+ for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) {
2146
+ if (!entry.allowOrgOverride) continue;
2147
+ out.add(entry.type);
2148
+ const plural = SINGULAR_TO_PLURAL[entry.type];
2149
+ if (plural) out.add(plural);
2150
+ }
2151
+ return out;
2152
+ })();
2153
+ var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
1644
2154
 
1645
2155
  // src/engine.ts
1646
2156
  import { ExecutionContextSchema } from "@objectstack/spec/kernel";
@@ -2077,6 +2587,275 @@ function resolveHandler(engine, hook, opts) {
2077
2587
  return void 0;
2078
2588
  }
2079
2589
 
2590
+ // src/validation/record-validator.ts
2591
+ var SKIP_FIELDS = /* @__PURE__ */ new Set([
2592
+ "id",
2593
+ "created_at",
2594
+ "created_by",
2595
+ "updated_at",
2596
+ "updated_by",
2597
+ "organization_id",
2598
+ "tenant_id"
2599
+ ]);
2600
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2601
+ var URL_RE = /^[a-z][a-z0-9+.\-]*:\/\/[^\s]+$/i;
2602
+ var PHONE_RE = /^[+()\-\s\d.]{5,}$/;
2603
+ var ValidationError = class extends Error {
2604
+ constructor(fields) {
2605
+ super(
2606
+ `Validation failed for ${fields.length} field(s): ` + fields.map((f) => `${f.field} (${f.code})`).join(", ")
2607
+ );
2608
+ this.code = "VALIDATION_FAILED";
2609
+ this.name = "ValidationError";
2610
+ this.fields = fields;
2611
+ }
2612
+ };
2613
+ function isMissing(v) {
2614
+ return v === void 0 || v === null || typeof v === "string" && v.trim() === "";
2615
+ }
2616
+ function optionValues(options) {
2617
+ if (!Array.isArray(options)) return [];
2618
+ return options.map(
2619
+ (o) => typeof o === "object" && o !== null ? String(o.value) : String(o)
2620
+ );
2621
+ }
2622
+ function validateOne(name, def, value) {
2623
+ if (def.required && isMissing(value)) {
2624
+ return { field: name, code: "required", message: `${name} is required` };
2625
+ }
2626
+ if (isMissing(value)) return null;
2627
+ const t = def.type;
2628
+ if (t === "text" || t === "textarea" || t === "email" || t === "url" || t === "phone" || t === "password" || t === "markdown" || t === "html" || t === "richtext" || t === "code") {
2629
+ const s = typeof value === "string" ? value : String(value);
2630
+ if (def.maxLength !== void 0 && s.length > def.maxLength) {
2631
+ return { field: name, code: "max_length", message: `${name} must be \u2264 ${def.maxLength} characters (got ${s.length})` };
2632
+ }
2633
+ if (def.minLength !== void 0 && s.length < def.minLength) {
2634
+ return { field: name, code: "min_length", message: `${name} must be \u2265 ${def.minLength} characters (got ${s.length})` };
2635
+ }
2636
+ if (t === "email" && !EMAIL_RE.test(s)) {
2637
+ return { field: name, code: "invalid_email", message: `${name} must be a valid email address` };
2638
+ }
2639
+ if (t === "url" && !URL_RE.test(s)) {
2640
+ return { field: name, code: "invalid_url", message: `${name} must be a valid URL (scheme://...)` };
2641
+ }
2642
+ if (t === "phone" && !PHONE_RE.test(s)) {
2643
+ return { field: name, code: "invalid_phone", message: `${name} must be a valid phone number` };
2644
+ }
2645
+ return null;
2646
+ }
2647
+ if (t === "number" || t === "currency" || t === "percent" || t === "rating" || t === "slider") {
2648
+ const n = typeof value === "number" ? value : Number(value);
2649
+ if (!Number.isFinite(n)) {
2650
+ return { field: name, code: "invalid_number", message: `${name} must be a number` };
2651
+ }
2652
+ if (def.min !== void 0 && n < def.min) {
2653
+ return { field: name, code: "min_value", message: `${name} must be \u2265 ${def.min}` };
2654
+ }
2655
+ if (def.max !== void 0 && n > def.max) {
2656
+ return { field: name, code: "max_value", message: `${name} must be \u2264 ${def.max}` };
2657
+ }
2658
+ return null;
2659
+ }
2660
+ if (t === "boolean" || t === "toggle") {
2661
+ if (typeof value === "boolean") return null;
2662
+ if (value === 0 || value === 1 || value === "0" || value === "1" || value === "true" || value === "false") return null;
2663
+ return { field: name, code: "invalid_boolean", message: `${name} must be true or false` };
2664
+ }
2665
+ if (t === "date" || t === "datetime" || t === "time") {
2666
+ if (value instanceof Date) return null;
2667
+ if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return null;
2668
+ return { field: name, code: "invalid_date", message: `${name} must be a valid ${t} (ISO-8601)` };
2669
+ }
2670
+ if (t === "select" || t === "radio") {
2671
+ const allowed = optionValues(def.options);
2672
+ if (allowed.length > 0 && !allowed.includes(String(value))) {
2673
+ return { field: name, code: "invalid_option", message: `${name} must be one of: ${allowed.join(", ")}`, options: allowed };
2674
+ }
2675
+ return null;
2676
+ }
2677
+ if (t === "multiselect" || t === "checkboxes" || t === "tags") {
2678
+ const allowed = optionValues(def.options);
2679
+ if (allowed.length === 0) return null;
2680
+ const arr = Array.isArray(value) ? value : [value];
2681
+ for (const v of arr) {
2682
+ if (!allowed.includes(String(v))) {
2683
+ return { field: name, code: "invalid_option", message: `${name}: "${v}" is not one of: ${allowed.join(", ")}`, options: allowed };
2684
+ }
2685
+ }
2686
+ return null;
2687
+ }
2688
+ return null;
2689
+ }
2690
+ function validateRecord(objectSchema, data, mode) {
2691
+ if (!objectSchema?.fields || !data) return;
2692
+ const errors = [];
2693
+ const fields = objectSchema.fields;
2694
+ if (mode === "insert") {
2695
+ for (const [name, def] of Object.entries(fields)) {
2696
+ if (SKIP_FIELDS.has(name)) continue;
2697
+ if (def.system || def.readonly) continue;
2698
+ const err = validateOne(name, def, data[name]);
2699
+ if (err) errors.push(err);
2700
+ }
2701
+ } else {
2702
+ for (const [name, value] of Object.entries(data)) {
2703
+ if (SKIP_FIELDS.has(name)) continue;
2704
+ const def = fields[name];
2705
+ if (!def) continue;
2706
+ if (def.system || def.readonly) continue;
2707
+ const err = validateOne(name, { ...def, required: false }, value);
2708
+ if (err) errors.push(err);
2709
+ }
2710
+ }
2711
+ if (errors.length > 0) throw new ValidationError(errors);
2712
+ }
2713
+
2714
+ // src/in-memory-aggregation.ts
2715
+ function applyInMemoryAggregation(rows, ast) {
2716
+ const groupBy = ast.groupBy ?? [];
2717
+ const aggregations = ast.aggregations ?? [];
2718
+ if (groupBy.length === 0 && aggregations.length === 0) return rows;
2719
+ if (groupBy.length === 0) {
2720
+ return [aggregateBucket(rows, aggregations)];
2721
+ }
2722
+ const buckets = /* @__PURE__ */ new Map();
2723
+ for (const row of rows) {
2724
+ const key = {};
2725
+ const parts = [];
2726
+ for (const g of groupBy) {
2727
+ const fieldName = typeof g === "string" ? g : g.alias ?? g.field;
2728
+ const value = projectGroupValue(row, g);
2729
+ key[fieldName] = value;
2730
+ parts.push(`${fieldName}=${value}`);
2731
+ }
2732
+ const id = parts.join("");
2733
+ let bucket = buckets.get(id);
2734
+ if (!bucket) {
2735
+ bucket = { key, rows: [] };
2736
+ buckets.set(id, bucket);
2737
+ }
2738
+ bucket.rows.push(row);
2739
+ }
2740
+ const out = [];
2741
+ for (const { key, rows: bucketRows } of buckets.values()) {
2742
+ const aggValues = aggregateBucket(bucketRows, aggregations);
2743
+ out.push({ ...key, ...aggValues });
2744
+ }
2745
+ return out;
2746
+ }
2747
+ function projectGroupValue(row, g) {
2748
+ const field = typeof g === "string" ? g : g.field;
2749
+ const v = row?.[field];
2750
+ if (typeof g !== "string" && g.dateGranularity) {
2751
+ return bucketDateValue(v, g.dateGranularity);
2752
+ }
2753
+ return v == null ? "(null)" : String(v);
2754
+ }
2755
+ function aggregateBucket(rows, aggregations) {
2756
+ const out = {};
2757
+ for (const agg of aggregations) {
2758
+ const alias = agg.alias;
2759
+ const fn = agg.function;
2760
+ if (fn === "count") {
2761
+ if (!agg.field) {
2762
+ out[alias] = rows.length;
2763
+ } else {
2764
+ out[alias] = rows.reduce(
2765
+ (acc, r) => r[agg.field] != null ? acc + 1 : acc,
2766
+ 0
2767
+ );
2768
+ }
2769
+ continue;
2770
+ }
2771
+ const field = agg.field;
2772
+ if (!field) {
2773
+ out[alias] = null;
2774
+ continue;
2775
+ }
2776
+ const values = collectValues(rows, field, !!agg.distinct);
2777
+ switch (fn) {
2778
+ case "count_distinct":
2779
+ out[alias] = new Set(values.filter((v) => v != null)).size;
2780
+ break;
2781
+ case "sum":
2782
+ out[alias] = values.reduce((a, b) => a + toNumber(b), 0);
2783
+ break;
2784
+ case "avg": {
2785
+ const nums = values.filter((v) => v != null).map(toNumber);
2786
+ out[alias] = nums.length === 0 ? null : nums.reduce((a, b) => a + b, 0) / nums.length;
2787
+ break;
2788
+ }
2789
+ case "min": {
2790
+ const defined = values.filter((v) => v != null);
2791
+ out[alias] = defined.length === 0 ? null : defined.reduce((a, b) => a < b ? a : b);
2792
+ break;
2793
+ }
2794
+ case "max": {
2795
+ const defined = values.filter((v) => v != null);
2796
+ out[alias] = defined.length === 0 ? null : defined.reduce((a, b) => a > b ? a : b);
2797
+ break;
2798
+ }
2799
+ case "array_agg":
2800
+ out[alias] = values.slice();
2801
+ break;
2802
+ case "string_agg":
2803
+ out[alias] = values.filter((v) => v != null).map(String).join(",");
2804
+ break;
2805
+ default:
2806
+ out[alias] = null;
2807
+ }
2808
+ }
2809
+ return out;
2810
+ }
2811
+ function collectValues(rows, field, distinct) {
2812
+ if (!distinct) return rows.map((r) => r?.[field]);
2813
+ const seen = /* @__PURE__ */ new Set();
2814
+ const out = [];
2815
+ for (const r of rows) {
2816
+ const v = r?.[field];
2817
+ if (seen.has(v)) continue;
2818
+ seen.add(v);
2819
+ out.push(v);
2820
+ }
2821
+ return out;
2822
+ }
2823
+ function toNumber(v) {
2824
+ if (typeof v === "number") return v;
2825
+ if (v == null) return 0;
2826
+ const n = Number(v);
2827
+ return Number.isFinite(n) ? n : 0;
2828
+ }
2829
+ function bucketDateValue(value, granularity) {
2830
+ if (value == null) return "(null)";
2831
+ const d = value instanceof Date ? value : new Date(String(value));
2832
+ if (Number.isNaN(d.getTime())) return "(null)";
2833
+ const y = d.getUTCFullYear();
2834
+ const m = d.getUTCMonth() + 1;
2835
+ switch (granularity) {
2836
+ case "year":
2837
+ return String(y);
2838
+ case "quarter":
2839
+ return `${y}-Q${Math.floor((m - 1) / 3) + 1}`;
2840
+ case "month":
2841
+ return `${y}-${String(m).padStart(2, "0")}`;
2842
+ case "day":
2843
+ return `${y}-${String(m).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
2844
+ case "week": {
2845
+ const target = new Date(Date.UTC(y, d.getUTCMonth(), d.getUTCDate()));
2846
+ const dayNum = (target.getUTCDay() + 6) % 7;
2847
+ target.setUTCDate(target.getUTCDate() - dayNum + 3);
2848
+ const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
2849
+ const weekNo = 1 + Math.round(
2850
+ ((target.getTime() - firstThursday.getTime()) / 864e5 - 3 + (firstThursday.getUTCDay() + 6) % 7) / 7
2851
+ );
2852
+ return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
2853
+ }
2854
+ default:
2855
+ return String(value);
2856
+ }
2857
+ }
2858
+
2080
2859
  // src/engine.ts
2081
2860
  function planFormulaProjection(schema, requestedFields) {
2082
2861
  if (!schema?.fields) return { plan: [] };
@@ -2097,7 +2876,11 @@ function planFormulaProjection(schema, requestedFields) {
2097
2876
  if (plan.length === 0) return { plan: [] };
2098
2877
  if (Array.isArray(requestedFields) && requestedFields.length > 0) {
2099
2878
  if (!projected.has("id")) projected.add("id");
2100
- for (const fname of allFieldNames) projected.add(fname);
2879
+ for (const fname of allFieldNames) {
2880
+ const fdef = schema.fields[fname];
2881
+ if (fdef?.type === "formula") continue;
2882
+ projected.add(fname);
2883
+ }
2101
2884
  return { plan, projected: Array.from(projected) };
2102
2885
  }
2103
2886
  return { plan };
@@ -2441,9 +3224,42 @@ var _ObjectQL = class _ObjectQL {
2441
3224
  userId: execCtx.userId,
2442
3225
  tenantId: execCtx.tenantId,
2443
3226
  roles: execCtx.roles,
2444
- accessToken: execCtx.accessToken
3227
+ accessToken: execCtx.accessToken,
3228
+ // Propagate system-elevated flag so hooks can distinguish engine
3229
+ // self-writes (e.g. approval status mirror) from genuine user writes.
3230
+ ...execCtx.isSystem ? { isSystem: true } : {}
2445
3231
  };
2446
3232
  }
3233
+ /**
3234
+ * Build the DriverOptions blob passed to every IDataDriver call.
3235
+ *
3236
+ * Always carries `tenantId` from the active ExecutionContext so the
3237
+ * driver can enforce per-tenant isolation (SQL driver auto-scopes reads
3238
+ * and auto-injects the tenant column on writes). Existing user-supplied
3239
+ * shapes (transactions, AST extras) are preserved by spreading them
3240
+ * first.
3241
+ *
3242
+ * System / isSystem callers may still cross tenants by clearing
3243
+ * `tenantId` themselves on the resulting object; this helper does not
3244
+ * mask the system path.
3245
+ */
3246
+ buildDriverOptions(execCtx, base) {
3247
+ const hasTx = execCtx?.transaction !== void 0;
3248
+ const hasTenant = execCtx?.tenantId !== void 0;
3249
+ const isSystem = execCtx?.isSystem === true;
3250
+ if (!hasTx && !hasTenant && !isSystem) return base;
3251
+ const opts = base && typeof base === "object" ? { ...base } : {};
3252
+ if (hasTx && opts.transaction === void 0) {
3253
+ opts.transaction = execCtx.transaction;
3254
+ }
3255
+ if (hasTenant && opts.tenantId === void 0) {
3256
+ opts.tenantId = execCtx.tenantId;
3257
+ }
3258
+ if (isSystem && opts.bypassTenantAudit === void 0) {
3259
+ opts.bypassTenantAudit = true;
3260
+ }
3261
+ return opts;
3262
+ }
2447
3263
  /**
2448
3264
  * Build a HookContext.api: a ScopedContext that hooks can use to
2449
3265
  * read/write other objects within the same execution context.
@@ -2937,7 +3753,7 @@ var _ObjectQL = class _ObjectQL {
2937
3753
  * @param depth - Current recursion depth (0-based)
2938
3754
  * @returns Records with expanded lookup fields (IDs replaced by full objects)
2939
3755
  */
2940
- async expandRelatedRecords(objectName, records, expand, depth = 0) {
3756
+ async expandRelatedRecords(objectName, records, expand, depth = 0, execCtx) {
2941
3757
  if (!records || records.length === 0) return records;
2942
3758
  if (depth >= _ObjectQL.MAX_EXPAND_DEPTH) return records;
2943
3759
  const objectSchema = this._registry.getObject(objectName);
@@ -2969,7 +3785,8 @@ var _ObjectQL = class _ObjectQL {
2969
3785
  ...nestedAST.orderBy ? { orderBy: nestedAST.orderBy } : {}
2970
3786
  };
2971
3787
  const driver = this.getDriver(referenceObject);
2972
- const relatedRecords = await driver.find(referenceObject, relatedQuery) ?? [];
3788
+ const expandOpts = this.buildDriverOptions(execCtx);
3789
+ const relatedRecords = await driver.find(referenceObject, relatedQuery, expandOpts) ?? [];
2973
3790
  const recordMap = /* @__PURE__ */ new Map();
2974
3791
  for (const rec of relatedRecords) {
2975
3792
  const id = rec.id;
@@ -2980,7 +3797,8 @@ var _ObjectQL = class _ObjectQL {
2980
3797
  referenceObject,
2981
3798
  relatedRecords,
2982
3799
  nestedAST.expand,
2983
- depth + 1
3800
+ depth + 1,
3801
+ execCtx
2984
3802
  );
2985
3803
  recordMap.clear();
2986
3804
  for (const rec of expandedRelated) {
@@ -3053,11 +3871,12 @@ var _ObjectQL = class _ObjectQL {
3053
3871
  ql: this
3054
3872
  };
3055
3873
  await this.triggerHooks("beforeFind", hookContext);
3874
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
3056
3875
  try {
3057
3876
  let result = await driver.find(object, hookContext.input.ast, hookContext.input.options);
3058
3877
  if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result);
3059
3878
  if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) {
3060
- result = await this.expandRelatedRecords(object, result, ast.expand, 0);
3879
+ result = await this.expandRelatedRecords(object, result, ast.expand, 0, opCtx.context);
3061
3880
  }
3062
3881
  hookContext.event = "afterFind";
3063
3882
  hookContext.result = result;
@@ -3096,10 +3915,11 @@ var _ObjectQL = class _ObjectQL {
3096
3915
  context: query?.context
3097
3916
  };
3098
3917
  await this.executeWithMiddleware(opCtx, async () => {
3099
- let result = await driver.findOne(objectName, opCtx.ast);
3918
+ const findOneOpts = this.buildDriverOptions(opCtx.context);
3919
+ let result = await driver.findOne(objectName, opCtx.ast, findOneOpts);
3100
3920
  if (result != null) applyFormulaPlan(_findOneFormula.plan, [result]);
3101
3921
  if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {
3102
- const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0);
3922
+ const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context);
3103
3923
  result = expanded[0];
3104
3924
  }
3105
3925
  return result;
@@ -3128,13 +3948,16 @@ var _ObjectQL = class _ObjectQL {
3128
3948
  ql: this
3129
3949
  };
3130
3950
  await this.triggerHooks("beforeInsert", hookContext);
3951
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
3131
3952
  try {
3132
3953
  let result;
3133
3954
  const nowSnap = /* @__PURE__ */ new Date();
3955
+ const schemaForValidation = this._registry.getObject(object);
3134
3956
  if (Array.isArray(hookContext.input.data)) {
3135
3957
  const rows = hookContext.input.data.map(
3136
3958
  (row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
3137
3959
  );
3960
+ for (const r of rows) validateRecord(schemaForValidation, r, "insert");
3138
3961
  if (driver.bulkCreate) {
3139
3962
  result = await driver.bulkCreate(object, rows, hookContext.input.options);
3140
3963
  } else {
@@ -3147,6 +3970,7 @@ var _ObjectQL = class _ObjectQL {
3147
3970
  opCtx.context,
3148
3971
  nowSnap
3149
3972
  );
3973
+ validateRecord(schemaForValidation, row, "insert");
3150
3974
  result = await driver.create(object, row, hookContext.input.options);
3151
3975
  }
3152
3976
  hookContext.event = "afterInsert";
@@ -3219,11 +4043,14 @@ var _ObjectQL = class _ObjectQL {
3219
4043
  ql: this
3220
4044
  };
3221
4045
  await this.triggerHooks("beforeUpdate", hookContext);
4046
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
3222
4047
  try {
3223
4048
  let result;
3224
4049
  if (hookContext.input.id) {
4050
+ validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
3225
4051
  result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
3226
4052
  } else if (options?.multi && driver.updateMany) {
4053
+ validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
3227
4054
  const ast = { object, where: options.where };
3228
4055
  result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options);
3229
4056
  } else {
@@ -3285,6 +4112,7 @@ var _ObjectQL = class _ObjectQL {
3285
4112
  ql: this
3286
4113
  };
3287
4114
  await this.triggerHooks("beforeDelete", hookContext);
4115
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
3288
4116
  try {
3289
4117
  let result;
3290
4118
  if (hookContext.input.id) {
@@ -3334,11 +4162,12 @@ var _ObjectQL = class _ObjectQL {
3334
4162
  context: query?.context
3335
4163
  };
3336
4164
  await this.executeWithMiddleware(opCtx, async () => {
4165
+ const countOpts = this.buildDriverOptions(opCtx.context);
3337
4166
  if (driver.count) {
3338
4167
  const ast = { object, where: query?.where };
3339
- return driver.count(object, ast);
4168
+ return driver.count(object, ast, countOpts);
3340
4169
  }
3341
- const res = await this.find(object, { where: query?.where, fields: ["id"] });
4170
+ const res = await this.find(object, { where: query?.where, fields: ["id"], context: opCtx.context });
3342
4171
  return res.length;
3343
4172
  });
3344
4173
  return opCtx.result;
@@ -3361,13 +4190,33 @@ var _ObjectQL = class _ObjectQL {
3361
4190
  aggregations: query.aggregations
3362
4191
  };
3363
4192
  const drv = driver;
3364
- if (typeof drv.aggregate === "function") {
3365
- return drv.aggregate(object, ast);
4193
+ const groupByItems = Array.isArray(query.groupBy) ? query.groupBy : [];
4194
+ const granularityCaps = drv?.supports?.queryDateGranularity;
4195
+ const structuredItems = groupByItems.filter((g) => typeof g !== "string");
4196
+ const allStructuredSupported = structuredItems.every((g) => {
4197
+ if (!g?.dateGranularity) return true;
4198
+ return granularityCaps?.[g.dateGranularity] === true;
4199
+ });
4200
+ if (typeof drv.aggregate === "function" && allStructuredSupported) {
4201
+ return drv.aggregate(object, ast, this.buildDriverOptions(opCtx.context));
3366
4202
  }
3367
- return driver.find(object, ast);
4203
+ const raw = await driver.find(object, ast, this.buildDriverOptions(opCtx.context));
4204
+ return applyInMemoryAggregation(raw, ast);
3368
4205
  });
3369
4206
  return opCtx.result;
3370
4207
  }
4208
+ /**
4209
+ * Run raw driver-specific commands (SQL for SqlDriver, REST for RestDriver, …).
4210
+ *
4211
+ * ⚠️ **Tenant isolation bypass.** Raw `execute()` does NOT thread the
4212
+ * caller's `ExecutionContext.tenantId` into a `WHERE organization_id`
4213
+ * predicate — drivers see the command verbatim. Callers MUST inline the
4214
+ * tenant filter themselves, or restrict raw execution to genuinely global
4215
+ * statements (schema migrations, sys_* / control-plane tables).
4216
+ *
4217
+ * Prefer the typed entry points (`find`, `update`, `delete`, `count`, …)
4218
+ * whenever feasible — they auto-apply tenancy + soft-delete + audit warnings.
4219
+ */
3371
4220
  async execute(command, options) {
3372
4221
  let driver;
3373
4222
  if (options?.object) {
@@ -3397,6 +4246,48 @@ var _ObjectQL = class _ObjectQL {
3397
4246
  }
3398
4247
  return driver.execute(rawCommand, params, options);
3399
4248
  }
4249
+ /**
4250
+ * Execute a callback inside a database transaction.
4251
+ *
4252
+ * The callback receives a context object that should be passed to all
4253
+ * downstream `engine.insert/update/delete/find/findOne` calls (as
4254
+ * `{ context: trxCtx }`). The transaction handle threads through
4255
+ * `OperationContext.context.transaction` and the SQL driver's per-builder
4256
+ * `.transacting(trx)` call.
4257
+ *
4258
+ * - If the default driver does not support `beginTransaction`, the callback
4259
+ * runs directly with the supplied base context (no rollback). This keeps
4260
+ * the API safe to call on drivers without ACID support (e.g. the
4261
+ * in-memory driver in tests).
4262
+ * - On callback success the transaction is committed; on any thrown error
4263
+ * it is rolled back and the original error is re-thrown.
4264
+ *
4265
+ * Use case: multi-step operations that must be atomic (e.g. CRM
4266
+ * `convertLead`, which creates an account + contact + opportunity + flips
4267
+ * the lead in a single unit of work).
4268
+ */
4269
+ async transaction(callback, baseContext) {
4270
+ const driver = this.defaultDriver ? this.drivers.get(this.defaultDriver) : void 0;
4271
+ const drv = driver;
4272
+ if (!drv?.beginTransaction) {
4273
+ return callback(baseContext);
4274
+ }
4275
+ const trx = await drv.beginTransaction();
4276
+ const trxCtx = { ...baseContext ?? {}, transaction: trx };
4277
+ try {
4278
+ const result = await callback(trxCtx);
4279
+ if (drv.commit) await drv.commit(trx);
4280
+ else if (drv.commitTransaction) await drv.commitTransaction(trx);
4281
+ return result;
4282
+ } catch (err) {
4283
+ try {
4284
+ if (drv.rollback) await drv.rollback(trx);
4285
+ else if (drv.rollbackTransaction) await drv.rollbackTransaction(trx);
4286
+ } catch {
4287
+ }
4288
+ throw err;
4289
+ }
4290
+ }
3400
4291
  // ============================================
3401
4292
  // Compatibility / Convenience API
3402
4293
  // ============================================
@@ -3831,6 +4722,13 @@ var ObjectQLPlugin = class {
3831
4722
  this.name = "com.objectstack.engine.objectql";
3832
4723
  this.type = "objectql";
3833
4724
  this.version = "1.0.0";
4725
+ /**
4726
+ * Schema sync to remote SQL DBs is latency-bound (one round-trip per
4727
+ * table × 2 phases). Default to 120s instead of the kernel's 30s so
4728
+ * cold Neon/Turso starts don't get killed mid-sync.
4729
+ */
4730
+ this.startupTimeout = 12e4;
4731
+ this.skipSchemaSync = false;
3834
4732
  this.init = async (ctx) => {
3835
4733
  if (!this.ql) {
3836
4734
  const hostCtx = { ...this.hostContext, logger: ctx.logger };
@@ -3858,6 +4756,17 @@ var ObjectQLPlugin = class {
3858
4756
  );
3859
4757
  ctx.registerService("protocol", protocolShim);
3860
4758
  ctx.logger.info("Protocol service registered");
4759
+ ctx.registerService("analytics", {
4760
+ query: (body) => protocolShim.analyticsQuery(body),
4761
+ getMeta: async () => ({
4762
+ cubes: [],
4763
+ message: "Analytics meta endpoint not implemented by ObjectQL adapter"
4764
+ }),
4765
+ generateSql: async (_body) => ({
4766
+ sql: null,
4767
+ message: "Analytics SQL generation not implemented by ObjectQL adapter"
4768
+ })
4769
+ });
3861
4770
  };
3862
4771
  this.start = async (ctx) => {
3863
4772
  ctx.logger.info("ObjectQL engine starting...");
@@ -3897,13 +4806,19 @@ var ObjectQLPlugin = class {
3897
4806
  }
3898
4807
  }
3899
4808
  await this.ql?.init();
3900
- await this.syncRegisteredSchemas(ctx);
4809
+ if (this.skipSchemaSync) {
4810
+ ctx.logger.info("Skipping schema sync (OS_SKIP_SCHEMA_SYNC=1) \u2014 assuming DDL is managed out-of-band");
4811
+ } else {
4812
+ await this.syncRegisteredSchemas(ctx);
4813
+ }
3901
4814
  if (this.projectId === void 0) {
3902
4815
  await this.restoreMetadataFromDb(ctx);
3903
4816
  } else {
3904
4817
  ctx.logger.info("Project kernel \u2014 skipping sys_metadata hydration (metadata sourced from artifact)");
3905
4818
  }
3906
- await this.syncRegisteredSchemas(ctx);
4819
+ if (!this.skipSchemaSync) {
4820
+ await this.syncRegisteredSchemas(ctx);
4821
+ }
3907
4822
  if (this.projectId === void 0) {
3908
4823
  await this.bridgeObjectsToMetadataService(ctx);
3909
4824
  }
@@ -3924,6 +4839,10 @@ var ObjectQLPlugin = class {
3924
4839
  }
3925
4840
  this.hostContext = opts.hostContext ?? hostContext;
3926
4841
  this.projectId = opts.projectId;
4842
+ if (typeof opts.startupTimeout === "number" && opts.startupTimeout > 0) {
4843
+ this.startupTimeout = opts.startupTimeout;
4844
+ }
4845
+ this.skipSchemaSync = typeof opts.skipSchemaSync === "boolean" ? opts.skipSchemaSync : process.env.OS_SKIP_SCHEMA_SYNC === "1";
3927
4846
  }
3928
4847
  /**
3929
4848
  * Register built-in audit hooks for auto-stamping created_by/updated_by
@@ -4011,7 +4930,13 @@ var ObjectQLPlugin = class {
4011
4930
  if (hookCtx.input?.id && !hookCtx.previous) {
4012
4931
  try {
4013
4932
  const existing = await this.ql.findOne(hookCtx.object, {
4014
- where: { id: hookCtx.input.id }
4933
+ where: { id: hookCtx.input.id },
4934
+ context: {
4935
+ roles: [],
4936
+ permissions: [],
4937
+ isSystem: true,
4938
+ ...hookCtx.transaction ? { transaction: hookCtx.transaction } : {}
4939
+ }
4015
4940
  });
4016
4941
  if (existing) hookCtx.previous = existing;
4017
4942
  } catch (_e) {
@@ -4029,7 +4954,13 @@ var ObjectQLPlugin = class {
4029
4954
  if (hookCtx.input?.id && !hookCtx.previous) {
4030
4955
  try {
4031
4956
  const existing = await this.ql.findOne(hookCtx.object, {
4032
- where: { id: hookCtx.input.id }
4957
+ where: { id: hookCtx.input.id },
4958
+ context: {
4959
+ roles: [],
4960
+ permissions: [],
4961
+ isSystem: true,
4962
+ ...hookCtx.transaction ? { transaction: hookCtx.transaction } : {}
4963
+ }
4033
4964
  });
4034
4965
  if (existing) hookCtx.previous = existing;
4035
4966
  } catch (_e) {
@@ -4410,14 +5341,18 @@ export {
4410
5341
  RESERVED_NAMESPACES,
4411
5342
  SchemaRegistry,
4412
5343
  ScopedContext,
5344
+ ValidationError,
5345
+ applyInMemoryAggregation,
4413
5346
  applySystemFields,
4414
5347
  bindHooksToEngine,
5348
+ bucketDateValue,
4415
5349
  computeFQN,
4416
5350
  convertIntrospectedSchemaToObjects,
4417
5351
  createObjectQLKernel,
4418
5352
  noopHookMetricsRecorder,
4419
5353
  parseFQN,
4420
5354
  toTitleCase,
5355
+ validateRecord,
4421
5356
  wrapDeclarativeHook
4422
5357
  };
4423
5358
  //# sourceMappingURL=index.mjs.map