@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.js CHANGED
@@ -31,14 +31,18 @@ __export(index_exports, {
31
31
  RESERVED_NAMESPACES: () => RESERVED_NAMESPACES,
32
32
  SchemaRegistry: () => SchemaRegistry,
33
33
  ScopedContext: () => ScopedContext,
34
+ ValidationError: () => ValidationError,
35
+ applyInMemoryAggregation: () => applyInMemoryAggregation,
34
36
  applySystemFields: () => applySystemFields,
35
37
  bindHooksToEngine: () => bindHooksToEngine,
38
+ bucketDateValue: () => bucketDateValue,
36
39
  computeFQN: () => computeFQN,
37
40
  convertIntrospectedSchemaToObjects: () => convertIntrospectedSchemaToObjects,
38
41
  createObjectQLKernel: () => createObjectQLKernel,
39
42
  noopHookMetricsRecorder: () => noopHookMetricsRecorder,
40
43
  parseFQN: () => parseFQN,
41
44
  toTitleCase: () => toTitleCase,
45
+ validateRecord: () => validateRecord,
42
46
  wrapDeclarativeHook: () => wrapDeclarativeHook
43
47
  });
44
48
  module.exports = __toCommonJS(index_exports);
@@ -81,9 +85,11 @@ function mergeObjectDefinitions(base, extension) {
81
85
  }
82
86
  function applySystemFields(schema, opts) {
83
87
  if (schema.systemFields === false) return schema;
84
- if (schema.managedBy) return schema;
88
+ if (schema.managedBy === "better-auth") return schema;
85
89
  const sf = typeof schema.systemFields === "object" && schema.systemFields !== null ? schema.systemFields : void 0;
86
- const wantTenant = opts.multiTenant && sf?.tenant !== false;
90
+ const tenancyDisabled = schema.tenancy?.enabled === false;
91
+ const wantTenant = opts.multiTenant && sf?.tenant !== false && !tenancyDisabled;
92
+ const wantAudit = sf?.audit !== false;
87
93
  const additions = {};
88
94
  if (wantTenant && !schema.fields?.organization_id) {
89
95
  additions.organization_id = {
@@ -94,9 +100,54 @@ function applySystemFields(schema, opts) {
94
100
  indexed: true,
95
101
  hidden: true,
96
102
  readonly: true,
103
+ system: true,
97
104
  description: "Tenant scope (auto-populated by SecurityPlugin on insert)."
98
105
  };
99
106
  }
107
+ if (wantAudit) {
108
+ if (!schema.fields?.created_at) {
109
+ additions.created_at = {
110
+ type: "datetime",
111
+ label: "Created At",
112
+ required: false,
113
+ readonly: true,
114
+ system: true,
115
+ description: "Timestamp when the record was created (auto-populated by the driver)."
116
+ };
117
+ }
118
+ if (!schema.fields?.created_by) {
119
+ additions.created_by = {
120
+ type: "lookup",
121
+ reference: "sys_user",
122
+ label: "Created By",
123
+ required: false,
124
+ readonly: true,
125
+ system: true,
126
+ description: "User who created the record (populated when an authenticated session is present)."
127
+ };
128
+ }
129
+ if (!schema.fields?.updated_at) {
130
+ additions.updated_at = {
131
+ type: "datetime",
132
+ label: "Last Modified At",
133
+ required: false,
134
+ readonly: true,
135
+ system: true,
136
+ description: "Timestamp of the most recent modification (auto-populated by the driver)."
137
+ };
138
+ }
139
+ if (!schema.fields?.updated_by) {
140
+ additions.updated_by = {
141
+ type: "lookup",
142
+ reference: "sys_user",
143
+ label: "Last Modified By",
144
+ required: false,
145
+ readonly: true,
146
+ system: true,
147
+ description: "User who last modified the record (populated when an authenticated session is present)."
148
+ };
149
+ }
150
+ }
100
151
  if (Object.keys(additions).length === 0) return schema;
101
152
  return {
102
153
  ...schema,
@@ -592,6 +643,22 @@ var SchemaRegistry = class {
592
643
  // src/protocol.ts
593
644
  var import_data2 = require("@objectstack/spec/data");
594
645
  var import_shared = require("@objectstack/spec/shared");
646
+ var import_ui2 = require("@objectstack/spec/ui");
647
+ var import_kernel2 = require("@objectstack/spec/kernel");
648
+ var FORM_VIEW_TYPES = /* @__PURE__ */ new Set(["simple", "tabbed", "wizard", "split", "drawer", "modal"]);
649
+ function resolveOverlaySchema(type, item) {
650
+ const singular = import_shared.PLURAL_TO_SINGULAR[type] ?? type;
651
+ switch (singular) {
652
+ case "view": {
653
+ const t = item && typeof item === "object" && "type" in item ? String(item.type) : void 0;
654
+ return t && FORM_VIEW_TYPES.has(t) ? import_ui2.FormViewSchema : import_ui2.ListViewSchema;
655
+ }
656
+ case "dashboard":
657
+ return import_ui2.DashboardSchema;
658
+ default:
659
+ return null;
660
+ }
661
+ }
595
662
  function simpleHash(str) {
596
663
  let hash = 0;
597
664
  for (let i = 0; i < str.length; i++) {
@@ -618,13 +685,67 @@ var SERVICE_CONFIG = {
618
685
  "file-storage": { route: "/api/v1/storage", plugin: "plugin-storage" },
619
686
  search: { route: "/api/v1/search", plugin: "plugin-search" }
620
687
  };
621
- var ObjectStackProtocolImplementation = class {
688
+ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementation {
622
689
  constructor(engine, getServicesRegistry, getFeedService, projectId) {
690
+ /**
691
+ * One-time guard for ensuring the overlay-uniqueness UNIQUE INDEX exists
692
+ * on `sys_metadata`. ADR-0005: scopes overlays by
693
+ * `(type, name, organization_id, project_id, scope)` for active rows only.
694
+ * Idempotent SQL — safe to attempt on every protocol instance.
695
+ *
696
+ * Inlined here (rather than importing from @objectstack/metadata/migrations)
697
+ * to avoid a circular dependency: metadata already depends on objectql.
698
+ */
699
+ this.overlayIndexEnsured = false;
623
700
  this.engine = engine;
624
701
  this.getServicesRegistry = getServicesRegistry;
625
702
  this.getFeedService = getFeedService;
626
703
  this.projectId = projectId;
627
704
  }
705
+ async ensureOverlayIndex() {
706
+ if (this.overlayIndexEnsured) return;
707
+ this.overlayIndexEnsured = true;
708
+ try {
709
+ const engineAny = this.engine;
710
+ let driver = engineAny?.driver ?? engineAny?.getDriver?.();
711
+ if (!driver && engineAny?.drivers instanceof Map) {
712
+ for (const candidate of engineAny.drivers.values()) {
713
+ if (candidate && (typeof candidate.raw === "function" || typeof candidate.execute === "function")) {
714
+ driver = candidate;
715
+ break;
716
+ }
717
+ }
718
+ }
719
+ if (!driver) return;
720
+ const exec = async (sql) => {
721
+ if (typeof driver.raw === "function") {
722
+ await driver.raw(sql);
723
+ } else if (typeof driver.execute === "function") {
724
+ await driver.execute(sql);
725
+ } else {
726
+ throw new Error("driver has neither raw nor execute");
727
+ }
728
+ };
729
+ try {
730
+ await exec("DROP INDEX IF EXISTS idx_sys_metadata_overlay_active");
731
+ } catch {
732
+ }
733
+ const partialSql = "CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ON sys_metadata (type, name, organization_id) WHERE state = 'active'";
734
+ const fallbackSql = "CREATE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ON sys_metadata (type, name, organization_id)";
735
+ try {
736
+ await exec(partialSql);
737
+ } catch (err) {
738
+ const msg = err instanceof Error ? err.message : String(err);
739
+ if (/partial|where clause|syntax/i.test(msg)) {
740
+ try {
741
+ await exec(fallbackSql);
742
+ } catch {
743
+ }
744
+ }
745
+ }
746
+ } catch {
747
+ }
748
+ }
628
749
  /**
629
750
  * Exposes the project scope the protocol is bound to. Consumers like
630
751
  * the HTTP dispatcher use this to decide whether to trust the process-
@@ -758,24 +879,32 @@ var ObjectStackProtocolImplementation = class {
758
879
  if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
759
880
  }
760
881
  }
761
- if (this.projectId === void 0) try {
762
- const whereClause = {
763
- type: request.type,
764
- state: "active",
765
- // Always filter by project_id: project kernels use their projectId,
766
- // control-plane kernels use NULL (global scope only).
767
- project_id: this.projectId ?? null
768
- };
769
- if (packageId) whereClause._packageId = packageId;
770
- let records = await this.engine.find("sys_metadata", { where: whereClause });
771
- if (!records || records.length === 0) {
772
- const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
773
- if (alt) {
774
- const altWhere = { type: alt, state: "active", project_id: this.projectId ?? null };
775
- if (packageId) altWhere._packageId = packageId;
776
- records = await this.engine.find("sys_metadata", { where: altWhere });
882
+ try {
883
+ const orgId = request.organizationId;
884
+ const queryByOrg = async (oid) => {
885
+ const whereClause = {
886
+ type: request.type,
887
+ state: "active",
888
+ organization_id: oid
889
+ };
890
+ if (packageId) whereClause._packageId = packageId;
891
+ let rs = await this.engine.find("sys_metadata", { where: whereClause });
892
+ if (!rs || rs.length === 0) {
893
+ const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
894
+ if (alt) {
895
+ const altWhere = { type: alt, state: "active", organization_id: oid };
896
+ if (packageId) altWhere._packageId = packageId;
897
+ rs = await this.engine.find("sys_metadata", { where: altWhere });
898
+ }
777
899
  }
778
- }
900
+ return rs ?? [];
901
+ };
902
+ const envWideRecords = await queryByOrg(null);
903
+ const orgRecords = orgId ? await queryByOrg(orgId) : [];
904
+ const mergedMap = /* @__PURE__ */ new Map();
905
+ for (const r of envWideRecords) mergedMap.set(r.name, r);
906
+ for (const r of orgRecords) mergedMap.set(r.name, r);
907
+ const records = Array.from(mergedMap.values());
779
908
  if (records && records.length > 0) {
780
909
  const byName = /* @__PURE__ */ new Map();
781
910
  for (const existing of items) {
@@ -816,7 +945,9 @@ var ObjectStackProtocolImplementation = class {
816
945
  for (const item of runtimeItems) {
817
946
  const entry = item;
818
947
  if (entry && typeof entry === "object" && "name" in entry) {
819
- itemMap.set(entry.name, entry);
948
+ if (!itemMap.has(entry.name)) {
949
+ itemMap.set(entry.name, entry);
950
+ }
820
951
  }
821
952
  }
822
953
  items = Array.from(itemMap.values());
@@ -831,44 +962,40 @@ var ObjectStackProtocolImplementation = class {
831
962
  }
832
963
  async getMetaItem(request) {
833
964
  let item;
834
- if (this.projectId === void 0) {
835
- item = this.engine.registry.getItem(request.type, request.name);
836
- if (item === void 0) {
837
- const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
838
- if (alt) item = this.engine.registry.getItem(alt, request.name);
839
- }
840
- }
841
- if (item === void 0 && this.projectId === void 0) {
842
- try {
843
- const scopedWhere = {
965
+ const orgId = request.organizationId;
966
+ try {
967
+ const findOverlay = async (oid) => {
968
+ const where = {
844
969
  type: request.type,
845
970
  name: request.name,
846
- state: "active"
971
+ state: "active",
972
+ organization_id: oid
847
973
  };
848
- scopedWhere.project_id = this.projectId ?? null;
849
- const record = await this.engine.findOne("sys_metadata", {
850
- where: scopedWhere
851
- });
852
- if (record) {
853
- item = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
854
- if (this.projectId === void 0) {
855
- this.engine.registry.registerItem(request.type, item, "name");
856
- }
857
- } else {
858
- const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
859
- if (alt) {
860
- const altWhere = { type: alt, name: request.name, state: "active" };
861
- altWhere.project_id = this.projectId ?? null;
862
- const altRecord = await this.engine.findOne("sys_metadata", { where: altWhere });
863
- if (altRecord) {
864
- item = typeof altRecord.metadata === "string" ? JSON.parse(altRecord.metadata) : altRecord.metadata;
865
- if (this.projectId === void 0) {
866
- this.engine.registry.registerItem(request.type, item, "name");
867
- }
868
- }
869
- }
974
+ const rec = await this.engine.findOne("sys_metadata", { where });
975
+ if (rec) return rec;
976
+ const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
977
+ if (alt) {
978
+ const altWhere = {
979
+ type: alt,
980
+ name: request.name,
981
+ state: "active",
982
+ organization_id: oid
983
+ };
984
+ return await this.engine.findOne("sys_metadata", { where: altWhere });
870
985
  }
871
- } catch {
986
+ return void 0;
987
+ };
988
+ const record = (orgId ? await findOverlay(orgId) : void 0) ?? await findOverlay(null);
989
+ if (record) {
990
+ item = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
991
+ }
992
+ } catch {
993
+ }
994
+ if (item === void 0) {
995
+ item = this.engine.registry.getItem(request.type, request.name);
996
+ if (item === void 0) {
997
+ const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
998
+ if (alt) item = this.engine.registry.getItem(alt, request.name);
872
999
  }
873
1000
  }
874
1001
  if (item === void 0) {
@@ -947,6 +1074,18 @@ var ObjectStackProtocolImplementation = class {
947
1074
  if (request.context !== void 0) {
948
1075
  options.context = request.context;
949
1076
  }
1077
+ for (const [dollar, bare] of [
1078
+ ["$top", "top"],
1079
+ ["$skip", "skip"],
1080
+ ["$orderby", "orderBy"],
1081
+ ["$select", "select"],
1082
+ ["$count", "count"]
1083
+ ]) {
1084
+ if (options[dollar] != null && options[bare] == null) {
1085
+ options[bare] = options[dollar];
1086
+ }
1087
+ delete options[dollar];
1088
+ }
950
1089
  if (options.top != null) {
951
1090
  options.limit = Number(options.top);
952
1091
  delete options.top;
@@ -1053,6 +1192,23 @@ var ObjectStackProtocolImplementation = class {
1053
1192
  options.where = implicitFilters;
1054
1193
  }
1055
1194
  }
1195
+ const hasGroupBy = Array.isArray(options.groupBy) && options.groupBy.length > 0;
1196
+ const hasAggregations = Array.isArray(options.aggregations) && options.aggregations.length > 0;
1197
+ if (hasGroupBy || hasAggregations) {
1198
+ const records2 = await this.engine.aggregate(request.object, {
1199
+ where: options.where,
1200
+ groupBy: options.groupBy,
1201
+ aggregations: options.aggregations,
1202
+ context: options.context
1203
+ });
1204
+ const limited = typeof options.limit === "number" && options.limit > 0 ? records2.slice(0, options.limit) : records2;
1205
+ return {
1206
+ object: request.object,
1207
+ records: limited,
1208
+ total: limited.length,
1209
+ hasMore: false
1210
+ };
1211
+ }
1056
1212
  const records = await this.engine.find(request.object, options);
1057
1213
  return {
1058
1214
  object: request.object,
@@ -1086,7 +1242,11 @@ var ObjectStackProtocolImplementation = class {
1086
1242
  record: result
1087
1243
  };
1088
1244
  }
1089
- throw new Error(`Record ${request.id} not found in ${request.object}`);
1245
+ const err = new Error(`Record ${request.id} not found in ${request.object}`);
1246
+ err.code = "RECORD_NOT_FOUND";
1247
+ err.status = 404;
1248
+ err.object = request.object;
1249
+ throw err;
1090
1250
  }
1091
1251
  async createData(request) {
1092
1252
  const result = await this.engine.insert(
@@ -1121,25 +1281,281 @@ var ObjectStackProtocolImplementation = class {
1121
1281
  };
1122
1282
  }
1123
1283
  // ==========================================
1124
- // Metadata Caching
1284
+ // Global Search (M10.5)
1125
1285
  // ==========================================
1126
- async getMetaItemCached(request) {
1127
- try {
1128
- let item = this.engine.registry.getItem(request.type, request.name);
1129
- if (!item) {
1130
- const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
1131
- if (alt) item = this.engine.registry.getItem(alt, request.name);
1286
+ /**
1287
+ * Cross-object substring search across all registered objects that opt in
1288
+ * via `enable.searchable !== false` and `enable.apiEnabled !== false`.
1289
+ * Searches text-like fields (text/textarea/email/url/phone/markdown/html/string)
1290
+ * whose `searchable: true` flag is set, falling back to the object's
1291
+ * `displayNameField` (or `name`) when no fields are explicitly searchable.
1292
+ *
1293
+ * The query is split into whitespace-separated terms; each term must match
1294
+ * (case-insensitive LIKE) at least one searchable field. RBAC/RLS is
1295
+ * enforced by forwarding the caller's `context` to `engine.find` so users
1296
+ * only see records they are entitled to read.
1297
+ */
1298
+ async searchAll(request) {
1299
+ const q = (request.q ?? "").trim();
1300
+ if (!q) {
1301
+ return { query: "", hits: [], totalObjects: 0, totalHits: 0, truncated: false };
1302
+ }
1303
+ const overallLimit = Math.max(1, Math.min(100, Number(request.limit ?? 20)));
1304
+ const perObject = Math.max(1, Math.min(25, Number(request.perObject ?? 5)));
1305
+ const objectsFilter = request.objects && request.objects.length ? new Set(request.objects) : null;
1306
+ const terms = q.split(/\s+/).filter(Boolean).slice(0, 8);
1307
+ const allObjects = this.engine.registry?.getAllObjects?.() ?? [];
1308
+ const hits = [];
1309
+ let objectsScanned = 0;
1310
+ for (const obj of allObjects) {
1311
+ if (hits.length >= overallLimit) break;
1312
+ if (!obj?.name) continue;
1313
+ if (objectsFilter && !objectsFilter.has(obj.name)) continue;
1314
+ const enable = obj.enable ?? {};
1315
+ if (enable.searchable === false) continue;
1316
+ if (enable.apiEnabled === false) continue;
1317
+ 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")) {
1318
+ continue;
1132
1319
  }
1133
- if (!item) {
1134
- try {
1135
- const services = this.getServicesRegistry?.();
1136
- const metadataService = services?.get("metadata");
1137
- if (metadataService && typeof metadataService.get === "function") {
1138
- item = await metadataService.get(request.type, request.name);
1320
+ const fieldsRaw = obj.fields;
1321
+ const fields = Array.isArray(fieldsRaw) ? fieldsRaw : fieldsRaw && typeof fieldsRaw === "object" ? Object.entries(fieldsRaw).map(([name, f]) => ({ name, ...f || {} })) : [];
1322
+ const TEXT_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "string", "email", "url", "phone", "markdown", "html"]);
1323
+ const fieldByName = new Map(fields.map((f) => [f.name, f]));
1324
+ const hasField = (n) => fieldByName.has(n);
1325
+ const titleFormatSource = obj.titleFormat && (obj.titleFormat.source || obj.titleFormat) || void 0;
1326
+ const renderTitle = (row) => {
1327
+ if (typeof titleFormatSource === "string") {
1328
+ let allResolved = true;
1329
+ const rendered = titleFormatSource.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key) => {
1330
+ const v = row[key];
1331
+ if (v == null || v === "") {
1332
+ allResolved = false;
1333
+ return "";
1334
+ }
1335
+ return String(v);
1336
+ }).trim();
1337
+ if (rendered && allResolved) return rendered;
1338
+ if (rendered) return rendered.replace(/\s+-\s+$/, "").replace(/^\s+-\s+/, "").trim() || row.id;
1339
+ }
1340
+ const candidates = [
1341
+ obj.displayNameField,
1342
+ "name",
1343
+ "full_name",
1344
+ "title",
1345
+ "subject",
1346
+ "label",
1347
+ "company"
1348
+ ].filter((c) => typeof c === "string" && hasField(c));
1349
+ for (const c of candidates) {
1350
+ const v = row[c];
1351
+ if (v != null && String(v).trim()) return String(v);
1352
+ }
1353
+ const fn = row.first_name, ln = row.last_name;
1354
+ if (fn || ln) return `${fn ?? ""} ${ln ?? ""}`.trim();
1355
+ return String(row.id);
1356
+ };
1357
+ const titleFieldName = obj.displayNameField || (hasField("name") ? "name" : void 0) || (hasField("title") ? "title" : void 0) || fields.find((f) => TEXT_TYPES.has(f.type))?.name;
1358
+ let searchableFields = fields.filter((f) => f && TEXT_TYPES.has(f.type) && f.searchable === true).map((f) => f.name);
1359
+ if (searchableFields.length === 0 && titleFieldName) {
1360
+ searchableFields = [titleFieldName];
1361
+ }
1362
+ if (searchableFields.length === 0) continue;
1363
+ objectsScanned++;
1364
+ const andClauses = terms.map((term) => ({
1365
+ $or: searchableFields.map((f) => ({ [f]: { $contains: term } }))
1366
+ }));
1367
+ const where = andClauses.length === 1 ? andClauses[0] : { $and: andClauses };
1368
+ try {
1369
+ const opts = {
1370
+ where,
1371
+ limit: perObject,
1372
+ orderBy: [{ field: "updated_at", direction: "desc" }]
1373
+ };
1374
+ if (request.context !== void 0) opts.context = request.context;
1375
+ const rows = await this.engine.find(obj.name, opts);
1376
+ for (const row of rows || []) {
1377
+ if (hits.length >= overallLimit) break;
1378
+ const title = renderTitle(row);
1379
+ let snippet;
1380
+ for (const f of searchableFields) {
1381
+ const v = row[f];
1382
+ if (typeof v === "string" && v) {
1383
+ const lc = v.toLowerCase();
1384
+ const idx = terms.map((t) => lc.indexOf(t.toLowerCase())).find((i) => i >= 0);
1385
+ if (idx != null && idx >= 0) {
1386
+ const start = Math.max(0, idx - 30);
1387
+ const end = Math.min(v.length, idx + 90);
1388
+ snippet = (start > 0 ? "\u2026" : "") + v.slice(start, end) + (end < v.length ? "\u2026" : "");
1389
+ break;
1390
+ }
1391
+ }
1139
1392
  }
1140
- } catch {
1393
+ hits.push({
1394
+ object: obj.name,
1395
+ id: row.id,
1396
+ title,
1397
+ snippet,
1398
+ record: row
1399
+ });
1141
1400
  }
1401
+ } catch {
1402
+ continue;
1142
1403
  }
1404
+ }
1405
+ return {
1406
+ query: q,
1407
+ hits,
1408
+ totalObjects: objectsScanned,
1409
+ totalHits: hits.length,
1410
+ truncated: hits.length >= overallLimit
1411
+ };
1412
+ }
1413
+ // ==========================================
1414
+ // Lead Convert (M10.6)
1415
+ // ==========================================
1416
+ /**
1417
+ * Convert a qualified Lead into an Account + Contact (+ optional
1418
+ * Opportunity) and mark the Lead as converted. Mirrors the Salesforce
1419
+ * lead-conversion model:
1420
+ *
1421
+ * - If `accountId` is provided, the lead's company info is NOT used
1422
+ * to create a new account; the new contact and opportunity link to
1423
+ * the existing account instead.
1424
+ * - If `contactId` is provided, no new contact is created either —
1425
+ * useful when the lead is a new contact at an existing account.
1426
+ * - `createOpportunity` defaults to true; pass `false` to convert
1427
+ * without producing an opportunity (some teams convert "logos
1428
+ * only" first).
1429
+ * - Lead is updated atomically: `is_converted=true`,
1430
+ * `converted_account`/`converted_contact`/`converted_opportunity`
1431
+ * pointers, `converted_date`, and `status='converted'`.
1432
+ *
1433
+ * Atomicity is enforced via the default driver's transaction support
1434
+ * when available; otherwise a best-effort compensation (delete
1435
+ * already-created child records on failure) is attempted. Permission
1436
+ * checks on each child object are inherited from the caller's
1437
+ * execution context so SecurityPlugin still gates account/contact/
1438
+ * opportunity creates.
1439
+ */
1440
+ async convertLead(request) {
1441
+ const leadId = String(request.leadId || "").trim();
1442
+ if (!leadId) {
1443
+ const err = new Error("leadId is required");
1444
+ err.status = 400;
1445
+ err.code = "INVALID_REQUEST";
1446
+ throw err;
1447
+ }
1448
+ const ctx = request.context;
1449
+ const ctxOpt = ctx !== void 0 ? { context: ctx } : void 0;
1450
+ const lead = await this.engine.findOne("lead", { where: { id: leadId }, ...ctxOpt });
1451
+ if (!lead) {
1452
+ const err = new Error(`Lead '${leadId}' not found`);
1453
+ err.status = 404;
1454
+ err.code = "LEAD_NOT_FOUND";
1455
+ throw err;
1456
+ }
1457
+ if (lead.is_converted) {
1458
+ const err = new Error(`Lead '${leadId}' is already converted`);
1459
+ err.status = 409;
1460
+ err.code = "LEAD_ALREADY_CONVERTED";
1461
+ throw err;
1462
+ }
1463
+ const runConversion = async (trxCtx) => {
1464
+ const opCtx = trxCtx ?? ctx;
1465
+ const trxCtxOpt = opCtx !== void 0 ? { context: opCtx } : void 0;
1466
+ let account;
1467
+ if (request.accountId) {
1468
+ account = await this.engine.findOne("account", { where: { id: request.accountId }, ...trxCtxOpt });
1469
+ if (!account) {
1470
+ const err = new Error(`Account '${request.accountId}' not found`);
1471
+ err.status = 404;
1472
+ err.code = "ACCOUNT_NOT_FOUND";
1473
+ throw err;
1474
+ }
1475
+ } else {
1476
+ const accountPayload = {
1477
+ name: lead.company || `${lead.first_name ?? ""} ${lead.last_name ?? ""}`.trim() || "Untitled Account"
1478
+ };
1479
+ if (lead.industry) accountPayload.industry = lead.industry;
1480
+ if (lead.annual_revenue) accountPayload.annual_revenue = lead.annual_revenue;
1481
+ if (lead.number_of_employees) accountPayload.employees = lead.number_of_employees;
1482
+ if (lead.website) accountPayload.website = lead.website;
1483
+ if (lead.phone) accountPayload.phone = lead.phone;
1484
+ if (lead.address) accountPayload.billing_address = lead.address;
1485
+ if (lead.owner) accountPayload.owner = lead.owner;
1486
+ account = await this.engine.insert("account", accountPayload, trxCtxOpt);
1487
+ }
1488
+ let contact;
1489
+ if (request.contactId) {
1490
+ contact = await this.engine.findOne("contact", { where: { id: request.contactId }, ...trxCtxOpt });
1491
+ if (!contact) {
1492
+ const err = new Error(`Contact '${request.contactId}' not found`);
1493
+ err.status = 404;
1494
+ err.code = "CONTACT_NOT_FOUND";
1495
+ throw err;
1496
+ }
1497
+ } else {
1498
+ const contactPayload = {
1499
+ first_name: lead.first_name ?? "",
1500
+ last_name: lead.last_name ?? lead.company ?? "Unknown"
1501
+ };
1502
+ if (lead.salutation) contactPayload.salutation = lead.salutation;
1503
+ if (lead.email) contactPayload.email = lead.email;
1504
+ if (lead.phone) contactPayload.phone = lead.phone;
1505
+ if (lead.mobile) contactPayload.mobile = lead.mobile;
1506
+ if (lead.title) contactPayload.title = lead.title;
1507
+ if (lead.address) contactPayload.mailing_address = lead.address;
1508
+ if (lead.owner) contactPayload.owner = lead.owner;
1509
+ if (account?.id) contactPayload.account = account.id;
1510
+ contact = await this.engine.insert("contact", contactPayload, trxCtxOpt);
1511
+ }
1512
+ let opportunity = null;
1513
+ const shouldCreateOpp = request.createOpportunity !== false;
1514
+ if (shouldCreateOpp) {
1515
+ const oppOverrides = request.opportunity ?? {};
1516
+ const defaultName = oppOverrides.name || `${account?.name ?? lead.company ?? "Lead"} - New Opportunity`;
1517
+ const defaultClose = oppOverrides.close_date || new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3).toISOString().slice(0, 10);
1518
+ const oppPayload = {
1519
+ name: defaultName,
1520
+ stage: oppOverrides.stage ?? "qualification",
1521
+ close_date: defaultClose
1522
+ };
1523
+ if (oppOverrides.amount !== void 0) oppPayload.amount = oppOverrides.amount;
1524
+ else if (lead.annual_revenue) oppPayload.amount = lead.annual_revenue;
1525
+ if (account?.id) oppPayload.account = account.id;
1526
+ if (contact?.id) oppPayload.primary_contact = contact.id;
1527
+ if (lead.owner) oppPayload.owner = lead.owner;
1528
+ if (lead.lead_source) oppPayload.lead_source = lead.lead_source;
1529
+ opportunity = await this.engine.insert("opportunity", oppPayload, trxCtxOpt);
1530
+ }
1531
+ const leadUpdate = {
1532
+ is_converted: true,
1533
+ status: request.convertedStatus ?? "converted",
1534
+ converted_account: account?.id ?? null,
1535
+ converted_contact: contact?.id ?? null,
1536
+ converted_opportunity: opportunity?.id ?? null,
1537
+ converted_date: (/* @__PURE__ */ new Date()).toISOString()
1538
+ };
1539
+ const updatedLead = await this.engine.update("lead", leadUpdate, {
1540
+ where: { id: leadId },
1541
+ ...trxCtxOpt
1542
+ });
1543
+ return {
1544
+ lead: updatedLead ?? { ...lead, ...leadUpdate },
1545
+ account,
1546
+ contact,
1547
+ opportunity
1548
+ };
1549
+ };
1550
+ return this.engine.transaction(runConversion, ctx);
1551
+ }
1552
+ // ==========================================
1553
+ // Metadata Caching
1554
+ // ==========================================
1555
+ async getMetaItemCached(request) {
1556
+ try {
1557
+ const result = await this.getMetaItem({ type: request.type, name: request.name });
1558
+ const item = result?.item;
1143
1559
  if (!item) {
1144
1560
  throw new Error(`Metadata item ${request.type}/${request.name} not found`);
1145
1561
  }
@@ -1431,45 +1847,65 @@ var ObjectStackProtocolImplementation = class {
1431
1847
  ...request.options
1432
1848
  });
1433
1849
  }
1850
+ /** Normalize plural→singular before consulting the allow-list. */
1851
+ static isOverlayAllowed(type) {
1852
+ const singular = import_shared.PLURAL_TO_SINGULAR[type] ?? type;
1853
+ return _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(singular) || _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(type);
1854
+ }
1434
1855
  async saveMetaItem(request) {
1435
1856
  if (!request.item) {
1436
1857
  throw new Error("Item data is required");
1437
1858
  }
1438
- if (this.projectId !== void 0) {
1439
- this.engine.registry.registerItem(request.type, request.item, "name");
1440
- if (request.type === "object" || request.type === "objects") {
1441
- try {
1442
- this.engine.registry.registerObject(request.item, "sys_metadata");
1443
- } catch (err) {
1444
- console.warn(
1445
- `[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
1859
+ if (this.projectId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
1860
+ const allowed = Array.from(_ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES).join(", ");
1861
+ const err = new Error(
1862
+ `[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.`
1863
+ );
1864
+ err.code = "not_overridable";
1865
+ err.status = 403;
1866
+ throw err;
1867
+ }
1868
+ {
1869
+ const schema = resolveOverlaySchema(request.type, request.item);
1870
+ if (schema) {
1871
+ const parsed = schema.safeParse(request.item);
1872
+ if (!parsed.success) {
1873
+ const issues = parsed.error.issues.map((i) => ({
1874
+ path: i.path.join("."),
1875
+ message: i.message,
1876
+ code: i.code
1877
+ }));
1878
+ const summary = issues.slice(0, 3).map((i) => `${i.path || "<root>"}: ${i.message}`).join("; ");
1879
+ const err = new Error(
1880
+ `[invalid_metadata] ${request.type}/${request.name} failed spec validation: ${summary}` + (issues.length > 3 ? ` (+${issues.length - 3} more)` : "")
1446
1881
  );
1882
+ err.code = "invalid_metadata";
1883
+ err.status = 422;
1884
+ err.issues = issues;
1885
+ throw err;
1447
1886
  }
1448
1887
  }
1449
- return {
1450
- success: true,
1451
- message: "Saved to memory registry (project kernel \u2014 sys_metadata is control-plane only)"
1452
- };
1453
1888
  }
1454
- this.engine.registry.registerItem(request.type, request.item, "name");
1455
1889
  if (request.type === "object" || request.type === "objects") {
1890
+ this.engine.registry.registerItem(request.type, request.item, "name");
1456
1891
  try {
1457
1892
  this.engine.registry.registerObject(request.item, "sys_metadata");
1458
1893
  } catch (err) {
1459
1894
  console.warn(
1460
- `[Protocol] this.engine.registry.registerObject failed for ${request.name}: ${err?.message ?? err}`
1895
+ `[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
1461
1896
  );
1462
1897
  }
1463
1898
  }
1899
+ await this.ensureOverlayIndex();
1464
1900
  try {
1465
1901
  const now = (/* @__PURE__ */ new Date()).toISOString();
1902
+ const orgId = request.organizationId ?? null;
1466
1903
  const scopedWhere = {
1467
1904
  type: request.type,
1468
- name: request.name
1905
+ name: request.name,
1906
+ organization_id: orgId,
1907
+ state: "active"
1469
1908
  };
1470
- if (this.projectId !== void 0) {
1471
- scopedWhere.project_id = this.projectId;
1472
- }
1473
1909
  const existing = await this.engine.findOne("sys_metadata", {
1474
1910
  where: scopedWhere
1475
1911
  });
@@ -1477,7 +1913,8 @@ var ObjectStackProtocolImplementation = class {
1477
1913
  await this.engine.update("sys_metadata", {
1478
1914
  metadata: JSON.stringify(request.item),
1479
1915
  updated_at: now,
1480
- version: (existing.version || 0) + 1
1916
+ version: (existing.version || 0) + 1,
1917
+ state: "active"
1481
1918
  }, {
1482
1919
  where: { id: existing.id }
1483
1920
  });
@@ -1487,49 +1924,105 @@ var ObjectStackProtocolImplementation = class {
1487
1924
  id,
1488
1925
  name: request.name,
1489
1926
  type: request.type,
1490
- // `scope` tracks platform vs project authorship. With
1491
- // project_id carries the project id, 'project' is the
1492
- // honest label whenever we know we're inside one.
1493
- scope: this.projectId !== void 0 ? "project" : "platform",
1927
+ // `scope` enum is ['system','platform','user']; per-org
1928
+ // overlays use 'platform' as the informational tag. The
1929
+ // authoritative isolation key is `organization_id`.
1930
+ scope: "platform",
1494
1931
  metadata: JSON.stringify(request.item),
1495
1932
  state: "active",
1496
1933
  version: 1,
1497
1934
  created_at: now,
1498
- updated_at: now
1935
+ updated_at: now,
1936
+ organization_id: orgId
1499
1937
  };
1500
- if (this.projectId !== void 0) {
1501
- row.project_id = this.projectId;
1502
- }
1503
1938
  await this.engine.insert("sys_metadata", row);
1504
1939
  }
1505
1940
  return {
1506
1941
  success: true,
1507
- message: "Saved to database and registry"
1942
+ 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}`
1508
1943
  };
1509
1944
  } catch (dbError) {
1510
- console.warn(`[Protocol] DB persistence failed for ${request.type}/${request.name}: ${dbError.message}`);
1945
+ console.error(
1946
+ `[Protocol] sys_metadata persistence failed for ${request.type}/${request.name}: ${dbError.message}`
1947
+ );
1948
+ const err = new Error(
1949
+ `Failed to persist customization overlay to sys_metadata: ${dbError.message}. In-memory registry was updated but will be lost on restart.`
1950
+ );
1951
+ err.code = "overlay_persistence_failed";
1952
+ err.status = 500;
1953
+ throw err;
1954
+ }
1955
+ }
1956
+ /**
1957
+ * Remove a customization overlay row for the given metadata item, so the
1958
+ * next read falls through to the artifact-loaded default. Implements the
1959
+ * "Reset to factory default" semantic from ADR-0005. Whitelist is shared
1960
+ * with {@link saveMetaItem}.
1961
+ */
1962
+ async deleteMetaItem(request) {
1963
+ if (this.projectId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
1964
+ const err = new Error(
1965
+ `[not_overridable] Metadata type '${request.type}' has not opted into per-org overlay writes. See docs/adr/0005-metadata-customization-overlay.md.`
1966
+ );
1967
+ err.code = "not_overridable";
1968
+ err.status = 403;
1969
+ throw err;
1970
+ }
1971
+ const scopedWhere = {
1972
+ type: request.type,
1973
+ name: request.name,
1974
+ organization_id: request.organizationId ?? null
1975
+ };
1976
+ try {
1977
+ const existing = await this.engine.findOne("sys_metadata", { where: scopedWhere });
1978
+ if (!existing) {
1979
+ return {
1980
+ success: true,
1981
+ reset: false,
1982
+ message: `No customization overlay found for ${request.type}/${request.name} \u2014 already at artifact default.`
1983
+ };
1984
+ }
1985
+ await this.engine.delete("sys_metadata", { where: { id: existing.id } });
1986
+ if (this.projectId === void 0) {
1987
+ try {
1988
+ const services = this.getServicesRegistry?.();
1989
+ const metadataService = services?.get("metadata");
1990
+ if (metadataService && typeof metadataService.get === "function") {
1991
+ const artifactItem = await metadataService.get(request.type, request.name);
1992
+ if (artifactItem !== void 0) {
1993
+ this.engine.registry.registerItem(request.type, artifactItem, "name");
1994
+ }
1995
+ }
1996
+ } catch {
1997
+ }
1998
+ }
1511
1999
  return {
1512
2000
  success: true,
1513
- message: "Saved to memory registry (DB persistence unavailable)",
1514
- warning: dbError.message
2001
+ reset: true,
2002
+ message: `Customization overlay deleted \u2014 ${request.type}/${request.name} reset to artifact default.`
1515
2003
  };
2004
+ } catch (err) {
2005
+ const e = new Error(`Failed to delete customization overlay: ${err.message}`);
2006
+ e.status = 500;
2007
+ throw e;
1516
2008
  }
1517
2009
  }
1518
2010
  /**
1519
2011
  * Hydrate SchemaRegistry from the database on startup.
1520
2012
  * Loads all active metadata records and registers them in the in-memory registry.
1521
2013
  * Safe to call repeatedly — idempotent (latest DB record wins).
2014
+ *
2015
+ * Per ADR-0005, project-kernel mode ALSO hydrates from sys_metadata —
2016
+ * customization overlay rows must survive restart. Scope filter
2017
+ * (`project_id = this.projectId ?? null`) keeps tenants isolated.
1522
2018
  */
1523
2019
  async loadMetaFromDb() {
1524
- if (this.projectId !== void 0) {
1525
- return { loaded: 0, errors: 0 };
1526
- }
1527
2020
  let loaded = 0;
1528
2021
  let errors = 0;
1529
2022
  try {
1530
2023
  const where = {
1531
2024
  state: "active",
1532
- project_id: this.projectId ?? null
2025
+ organization_id: null
1533
2026
  };
1534
2027
  const records = await this.engine.find("sys_metadata", { where });
1535
2028
  for (const record of records) {
@@ -1686,9 +2179,30 @@ var ObjectStackProtocolImplementation = class {
1686
2179
  return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } };
1687
2180
  }
1688
2181
  };
2182
+ /**
2183
+ * Metadata types that are customer-overridable via {@link saveMetaItem}/
2184
+ * {@link deleteMetaItem} in project-kernel mode. Derived from the canonical
2185
+ * registry in {@link DEFAULT_METADATA_TYPE_REGISTRY}: a type opts in by
2186
+ * setting `allowOrgOverride: true` on its registry entry. The set is
2187
+ * augmented with the plural form of every singular so callers using REST
2188
+ * conventions (`/api/v1/meta/views/...`) get the same gate. See ADR-0005
2189
+ * §"Whitelist enforcement" for the rationale and the per-type rollout
2190
+ * checklist.
2191
+ */
2192
+ _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES = (() => {
2193
+ const out = /* @__PURE__ */ new Set();
2194
+ for (const entry of import_kernel2.DEFAULT_METADATA_TYPE_REGISTRY) {
2195
+ if (!entry.allowOrgOverride) continue;
2196
+ out.add(entry.type);
2197
+ const plural = import_shared.SINGULAR_TO_PLURAL[entry.type];
2198
+ if (plural) out.add(plural);
2199
+ }
2200
+ return out;
2201
+ })();
2202
+ var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
1689
2203
 
1690
2204
  // src/engine.ts
1691
- var import_kernel2 = require("@objectstack/spec/kernel");
2205
+ var import_kernel3 = require("@objectstack/spec/kernel");
1692
2206
  var import_core = require("@objectstack/core");
1693
2207
  var import_system = require("@objectstack/spec/system");
1694
2208
  var import_shared2 = require("@objectstack/spec/shared");
@@ -2122,6 +2636,275 @@ function resolveHandler(engine, hook, opts) {
2122
2636
  return void 0;
2123
2637
  }
2124
2638
 
2639
+ // src/validation/record-validator.ts
2640
+ var SKIP_FIELDS = /* @__PURE__ */ new Set([
2641
+ "id",
2642
+ "created_at",
2643
+ "created_by",
2644
+ "updated_at",
2645
+ "updated_by",
2646
+ "organization_id",
2647
+ "tenant_id"
2648
+ ]);
2649
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2650
+ var URL_RE = /^[a-z][a-z0-9+.\-]*:\/\/[^\s]+$/i;
2651
+ var PHONE_RE = /^[+()\-\s\d.]{5,}$/;
2652
+ var ValidationError = class extends Error {
2653
+ constructor(fields) {
2654
+ super(
2655
+ `Validation failed for ${fields.length} field(s): ` + fields.map((f) => `${f.field} (${f.code})`).join(", ")
2656
+ );
2657
+ this.code = "VALIDATION_FAILED";
2658
+ this.name = "ValidationError";
2659
+ this.fields = fields;
2660
+ }
2661
+ };
2662
+ function isMissing(v) {
2663
+ return v === void 0 || v === null || typeof v === "string" && v.trim() === "";
2664
+ }
2665
+ function optionValues(options) {
2666
+ if (!Array.isArray(options)) return [];
2667
+ return options.map(
2668
+ (o) => typeof o === "object" && o !== null ? String(o.value) : String(o)
2669
+ );
2670
+ }
2671
+ function validateOne(name, def, value) {
2672
+ if (def.required && isMissing(value)) {
2673
+ return { field: name, code: "required", message: `${name} is required` };
2674
+ }
2675
+ if (isMissing(value)) return null;
2676
+ const t = def.type;
2677
+ if (t === "text" || t === "textarea" || t === "email" || t === "url" || t === "phone" || t === "password" || t === "markdown" || t === "html" || t === "richtext" || t === "code") {
2678
+ const s = typeof value === "string" ? value : String(value);
2679
+ if (def.maxLength !== void 0 && s.length > def.maxLength) {
2680
+ return { field: name, code: "max_length", message: `${name} must be \u2264 ${def.maxLength} characters (got ${s.length})` };
2681
+ }
2682
+ if (def.minLength !== void 0 && s.length < def.minLength) {
2683
+ return { field: name, code: "min_length", message: `${name} must be \u2265 ${def.minLength} characters (got ${s.length})` };
2684
+ }
2685
+ if (t === "email" && !EMAIL_RE.test(s)) {
2686
+ return { field: name, code: "invalid_email", message: `${name} must be a valid email address` };
2687
+ }
2688
+ if (t === "url" && !URL_RE.test(s)) {
2689
+ return { field: name, code: "invalid_url", message: `${name} must be a valid URL (scheme://...)` };
2690
+ }
2691
+ if (t === "phone" && !PHONE_RE.test(s)) {
2692
+ return { field: name, code: "invalid_phone", message: `${name} must be a valid phone number` };
2693
+ }
2694
+ return null;
2695
+ }
2696
+ if (t === "number" || t === "currency" || t === "percent" || t === "rating" || t === "slider") {
2697
+ const n = typeof value === "number" ? value : Number(value);
2698
+ if (!Number.isFinite(n)) {
2699
+ return { field: name, code: "invalid_number", message: `${name} must be a number` };
2700
+ }
2701
+ if (def.min !== void 0 && n < def.min) {
2702
+ return { field: name, code: "min_value", message: `${name} must be \u2265 ${def.min}` };
2703
+ }
2704
+ if (def.max !== void 0 && n > def.max) {
2705
+ return { field: name, code: "max_value", message: `${name} must be \u2264 ${def.max}` };
2706
+ }
2707
+ return null;
2708
+ }
2709
+ if (t === "boolean" || t === "toggle") {
2710
+ if (typeof value === "boolean") return null;
2711
+ if (value === 0 || value === 1 || value === "0" || value === "1" || value === "true" || value === "false") return null;
2712
+ return { field: name, code: "invalid_boolean", message: `${name} must be true or false` };
2713
+ }
2714
+ if (t === "date" || t === "datetime" || t === "time") {
2715
+ if (value instanceof Date) return null;
2716
+ if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return null;
2717
+ return { field: name, code: "invalid_date", message: `${name} must be a valid ${t} (ISO-8601)` };
2718
+ }
2719
+ if (t === "select" || t === "radio") {
2720
+ const allowed = optionValues(def.options);
2721
+ if (allowed.length > 0 && !allowed.includes(String(value))) {
2722
+ return { field: name, code: "invalid_option", message: `${name} must be one of: ${allowed.join(", ")}`, options: allowed };
2723
+ }
2724
+ return null;
2725
+ }
2726
+ if (t === "multiselect" || t === "checkboxes" || t === "tags") {
2727
+ const allowed = optionValues(def.options);
2728
+ if (allowed.length === 0) return null;
2729
+ const arr = Array.isArray(value) ? value : [value];
2730
+ for (const v of arr) {
2731
+ if (!allowed.includes(String(v))) {
2732
+ return { field: name, code: "invalid_option", message: `${name}: "${v}" is not one of: ${allowed.join(", ")}`, options: allowed };
2733
+ }
2734
+ }
2735
+ return null;
2736
+ }
2737
+ return null;
2738
+ }
2739
+ function validateRecord(objectSchema, data, mode) {
2740
+ if (!objectSchema?.fields || !data) return;
2741
+ const errors = [];
2742
+ const fields = objectSchema.fields;
2743
+ if (mode === "insert") {
2744
+ for (const [name, def] of Object.entries(fields)) {
2745
+ if (SKIP_FIELDS.has(name)) continue;
2746
+ if (def.system || def.readonly) continue;
2747
+ const err = validateOne(name, def, data[name]);
2748
+ if (err) errors.push(err);
2749
+ }
2750
+ } else {
2751
+ for (const [name, value] of Object.entries(data)) {
2752
+ if (SKIP_FIELDS.has(name)) continue;
2753
+ const def = fields[name];
2754
+ if (!def) continue;
2755
+ if (def.system || def.readonly) continue;
2756
+ const err = validateOne(name, { ...def, required: false }, value);
2757
+ if (err) errors.push(err);
2758
+ }
2759
+ }
2760
+ if (errors.length > 0) throw new ValidationError(errors);
2761
+ }
2762
+
2763
+ // src/in-memory-aggregation.ts
2764
+ function applyInMemoryAggregation(rows, ast) {
2765
+ const groupBy = ast.groupBy ?? [];
2766
+ const aggregations = ast.aggregations ?? [];
2767
+ if (groupBy.length === 0 && aggregations.length === 0) return rows;
2768
+ if (groupBy.length === 0) {
2769
+ return [aggregateBucket(rows, aggregations)];
2770
+ }
2771
+ const buckets = /* @__PURE__ */ new Map();
2772
+ for (const row of rows) {
2773
+ const key = {};
2774
+ const parts = [];
2775
+ for (const g of groupBy) {
2776
+ const fieldName = typeof g === "string" ? g : g.alias ?? g.field;
2777
+ const value = projectGroupValue(row, g);
2778
+ key[fieldName] = value;
2779
+ parts.push(`${fieldName}=${value}`);
2780
+ }
2781
+ const id = parts.join("");
2782
+ let bucket = buckets.get(id);
2783
+ if (!bucket) {
2784
+ bucket = { key, rows: [] };
2785
+ buckets.set(id, bucket);
2786
+ }
2787
+ bucket.rows.push(row);
2788
+ }
2789
+ const out = [];
2790
+ for (const { key, rows: bucketRows } of buckets.values()) {
2791
+ const aggValues = aggregateBucket(bucketRows, aggregations);
2792
+ out.push({ ...key, ...aggValues });
2793
+ }
2794
+ return out;
2795
+ }
2796
+ function projectGroupValue(row, g) {
2797
+ const field = typeof g === "string" ? g : g.field;
2798
+ const v = row?.[field];
2799
+ if (typeof g !== "string" && g.dateGranularity) {
2800
+ return bucketDateValue(v, g.dateGranularity);
2801
+ }
2802
+ return v == null ? "(null)" : String(v);
2803
+ }
2804
+ function aggregateBucket(rows, aggregations) {
2805
+ const out = {};
2806
+ for (const agg of aggregations) {
2807
+ const alias = agg.alias;
2808
+ const fn = agg.function;
2809
+ if (fn === "count") {
2810
+ if (!agg.field) {
2811
+ out[alias] = rows.length;
2812
+ } else {
2813
+ out[alias] = rows.reduce(
2814
+ (acc, r) => r[agg.field] != null ? acc + 1 : acc,
2815
+ 0
2816
+ );
2817
+ }
2818
+ continue;
2819
+ }
2820
+ const field = agg.field;
2821
+ if (!field) {
2822
+ out[alias] = null;
2823
+ continue;
2824
+ }
2825
+ const values = collectValues(rows, field, !!agg.distinct);
2826
+ switch (fn) {
2827
+ case "count_distinct":
2828
+ out[alias] = new Set(values.filter((v) => v != null)).size;
2829
+ break;
2830
+ case "sum":
2831
+ out[alias] = values.reduce((a, b) => a + toNumber(b), 0);
2832
+ break;
2833
+ case "avg": {
2834
+ const nums = values.filter((v) => v != null).map(toNumber);
2835
+ out[alias] = nums.length === 0 ? null : nums.reduce((a, b) => a + b, 0) / nums.length;
2836
+ break;
2837
+ }
2838
+ case "min": {
2839
+ const defined = values.filter((v) => v != null);
2840
+ out[alias] = defined.length === 0 ? null : defined.reduce((a, b) => a < b ? a : b);
2841
+ break;
2842
+ }
2843
+ case "max": {
2844
+ const defined = values.filter((v) => v != null);
2845
+ out[alias] = defined.length === 0 ? null : defined.reduce((a, b) => a > b ? a : b);
2846
+ break;
2847
+ }
2848
+ case "array_agg":
2849
+ out[alias] = values.slice();
2850
+ break;
2851
+ case "string_agg":
2852
+ out[alias] = values.filter((v) => v != null).map(String).join(",");
2853
+ break;
2854
+ default:
2855
+ out[alias] = null;
2856
+ }
2857
+ }
2858
+ return out;
2859
+ }
2860
+ function collectValues(rows, field, distinct) {
2861
+ if (!distinct) return rows.map((r) => r?.[field]);
2862
+ const seen = /* @__PURE__ */ new Set();
2863
+ const out = [];
2864
+ for (const r of rows) {
2865
+ const v = r?.[field];
2866
+ if (seen.has(v)) continue;
2867
+ seen.add(v);
2868
+ out.push(v);
2869
+ }
2870
+ return out;
2871
+ }
2872
+ function toNumber(v) {
2873
+ if (typeof v === "number") return v;
2874
+ if (v == null) return 0;
2875
+ const n = Number(v);
2876
+ return Number.isFinite(n) ? n : 0;
2877
+ }
2878
+ function bucketDateValue(value, granularity) {
2879
+ if (value == null) return "(null)";
2880
+ const d = value instanceof Date ? value : new Date(String(value));
2881
+ if (Number.isNaN(d.getTime())) return "(null)";
2882
+ const y = d.getUTCFullYear();
2883
+ const m = d.getUTCMonth() + 1;
2884
+ switch (granularity) {
2885
+ case "year":
2886
+ return String(y);
2887
+ case "quarter":
2888
+ return `${y}-Q${Math.floor((m - 1) / 3) + 1}`;
2889
+ case "month":
2890
+ return `${y}-${String(m).padStart(2, "0")}`;
2891
+ case "day":
2892
+ return `${y}-${String(m).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
2893
+ case "week": {
2894
+ const target = new Date(Date.UTC(y, d.getUTCMonth(), d.getUTCDate()));
2895
+ const dayNum = (target.getUTCDay() + 6) % 7;
2896
+ target.setUTCDate(target.getUTCDate() - dayNum + 3);
2897
+ const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
2898
+ const weekNo = 1 + Math.round(
2899
+ ((target.getTime() - firstThursday.getTime()) / 864e5 - 3 + (firstThursday.getUTCDay() + 6) % 7) / 7
2900
+ );
2901
+ return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
2902
+ }
2903
+ default:
2904
+ return String(value);
2905
+ }
2906
+ }
2907
+
2125
2908
  // src/engine.ts
2126
2909
  function planFormulaProjection(schema, requestedFields) {
2127
2910
  if (!schema?.fields) return { plan: [] };
@@ -2142,7 +2925,11 @@ function planFormulaProjection(schema, requestedFields) {
2142
2925
  if (plan.length === 0) return { plan: [] };
2143
2926
  if (Array.isArray(requestedFields) && requestedFields.length > 0) {
2144
2927
  if (!projected.has("id")) projected.add("id");
2145
- for (const fname of allFieldNames) projected.add(fname);
2928
+ for (const fname of allFieldNames) {
2929
+ const fdef = schema.fields[fname];
2930
+ if (fdef?.type === "formula") continue;
2931
+ projected.add(fname);
2932
+ }
2146
2933
  return { plan, projected: Array.from(projected) };
2147
2934
  }
2148
2935
  return { plan };
@@ -2486,9 +3273,42 @@ var _ObjectQL = class _ObjectQL {
2486
3273
  userId: execCtx.userId,
2487
3274
  tenantId: execCtx.tenantId,
2488
3275
  roles: execCtx.roles,
2489
- accessToken: execCtx.accessToken
3276
+ accessToken: execCtx.accessToken,
3277
+ // Propagate system-elevated flag so hooks can distinguish engine
3278
+ // self-writes (e.g. approval status mirror) from genuine user writes.
3279
+ ...execCtx.isSystem ? { isSystem: true } : {}
2490
3280
  };
2491
3281
  }
3282
+ /**
3283
+ * Build the DriverOptions blob passed to every IDataDriver call.
3284
+ *
3285
+ * Always carries `tenantId` from the active ExecutionContext so the
3286
+ * driver can enforce per-tenant isolation (SQL driver auto-scopes reads
3287
+ * and auto-injects the tenant column on writes). Existing user-supplied
3288
+ * shapes (transactions, AST extras) are preserved by spreading them
3289
+ * first.
3290
+ *
3291
+ * System / isSystem callers may still cross tenants by clearing
3292
+ * `tenantId` themselves on the resulting object; this helper does not
3293
+ * mask the system path.
3294
+ */
3295
+ buildDriverOptions(execCtx, base) {
3296
+ const hasTx = execCtx?.transaction !== void 0;
3297
+ const hasTenant = execCtx?.tenantId !== void 0;
3298
+ const isSystem = execCtx?.isSystem === true;
3299
+ if (!hasTx && !hasTenant && !isSystem) return base;
3300
+ const opts = base && typeof base === "object" ? { ...base } : {};
3301
+ if (hasTx && opts.transaction === void 0) {
3302
+ opts.transaction = execCtx.transaction;
3303
+ }
3304
+ if (hasTenant && opts.tenantId === void 0) {
3305
+ opts.tenantId = execCtx.tenantId;
3306
+ }
3307
+ if (isSystem && opts.bypassTenantAudit === void 0) {
3308
+ opts.bypassTenantAudit = true;
3309
+ }
3310
+ return opts;
3311
+ }
2492
3312
  /**
2493
3313
  * Build a HookContext.api: a ScopedContext that hooks can use to
2494
3314
  * read/write other objects within the same execution context.
@@ -2982,7 +3802,7 @@ var _ObjectQL = class _ObjectQL {
2982
3802
  * @param depth - Current recursion depth (0-based)
2983
3803
  * @returns Records with expanded lookup fields (IDs replaced by full objects)
2984
3804
  */
2985
- async expandRelatedRecords(objectName, records, expand, depth = 0) {
3805
+ async expandRelatedRecords(objectName, records, expand, depth = 0, execCtx) {
2986
3806
  if (!records || records.length === 0) return records;
2987
3807
  if (depth >= _ObjectQL.MAX_EXPAND_DEPTH) return records;
2988
3808
  const objectSchema = this._registry.getObject(objectName);
@@ -3014,7 +3834,8 @@ var _ObjectQL = class _ObjectQL {
3014
3834
  ...nestedAST.orderBy ? { orderBy: nestedAST.orderBy } : {}
3015
3835
  };
3016
3836
  const driver = this.getDriver(referenceObject);
3017
- const relatedRecords = await driver.find(referenceObject, relatedQuery) ?? [];
3837
+ const expandOpts = this.buildDriverOptions(execCtx);
3838
+ const relatedRecords = await driver.find(referenceObject, relatedQuery, expandOpts) ?? [];
3018
3839
  const recordMap = /* @__PURE__ */ new Map();
3019
3840
  for (const rec of relatedRecords) {
3020
3841
  const id = rec.id;
@@ -3025,7 +3846,8 @@ var _ObjectQL = class _ObjectQL {
3025
3846
  referenceObject,
3026
3847
  relatedRecords,
3027
3848
  nestedAST.expand,
3028
- depth + 1
3849
+ depth + 1,
3850
+ execCtx
3029
3851
  );
3030
3852
  recordMap.clear();
3031
3853
  for (const rec of expandedRelated) {
@@ -3098,11 +3920,12 @@ var _ObjectQL = class _ObjectQL {
3098
3920
  ql: this
3099
3921
  };
3100
3922
  await this.triggerHooks("beforeFind", hookContext);
3923
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
3101
3924
  try {
3102
3925
  let result = await driver.find(object, hookContext.input.ast, hookContext.input.options);
3103
3926
  if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result);
3104
3927
  if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) {
3105
- result = await this.expandRelatedRecords(object, result, ast.expand, 0);
3928
+ result = await this.expandRelatedRecords(object, result, ast.expand, 0, opCtx.context);
3106
3929
  }
3107
3930
  hookContext.event = "afterFind";
3108
3931
  hookContext.result = result;
@@ -3141,10 +3964,11 @@ var _ObjectQL = class _ObjectQL {
3141
3964
  context: query?.context
3142
3965
  };
3143
3966
  await this.executeWithMiddleware(opCtx, async () => {
3144
- let result = await driver.findOne(objectName, opCtx.ast);
3967
+ const findOneOpts = this.buildDriverOptions(opCtx.context);
3968
+ let result = await driver.findOne(objectName, opCtx.ast, findOneOpts);
3145
3969
  if (result != null) applyFormulaPlan(_findOneFormula.plan, [result]);
3146
3970
  if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {
3147
- const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0);
3971
+ const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context);
3148
3972
  result = expanded[0];
3149
3973
  }
3150
3974
  return result;
@@ -3173,13 +3997,16 @@ var _ObjectQL = class _ObjectQL {
3173
3997
  ql: this
3174
3998
  };
3175
3999
  await this.triggerHooks("beforeInsert", hookContext);
4000
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
3176
4001
  try {
3177
4002
  let result;
3178
4003
  const nowSnap = /* @__PURE__ */ new Date();
4004
+ const schemaForValidation = this._registry.getObject(object);
3179
4005
  if (Array.isArray(hookContext.input.data)) {
3180
4006
  const rows = hookContext.input.data.map(
3181
4007
  (row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
3182
4008
  );
4009
+ for (const r of rows) validateRecord(schemaForValidation, r, "insert");
3183
4010
  if (driver.bulkCreate) {
3184
4011
  result = await driver.bulkCreate(object, rows, hookContext.input.options);
3185
4012
  } else {
@@ -3192,6 +4019,7 @@ var _ObjectQL = class _ObjectQL {
3192
4019
  opCtx.context,
3193
4020
  nowSnap
3194
4021
  );
4022
+ validateRecord(schemaForValidation, row, "insert");
3195
4023
  result = await driver.create(object, row, hookContext.input.options);
3196
4024
  }
3197
4025
  hookContext.event = "afterInsert";
@@ -3264,11 +4092,14 @@ var _ObjectQL = class _ObjectQL {
3264
4092
  ql: this
3265
4093
  };
3266
4094
  await this.triggerHooks("beforeUpdate", hookContext);
4095
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
3267
4096
  try {
3268
4097
  let result;
3269
4098
  if (hookContext.input.id) {
4099
+ validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
3270
4100
  result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
3271
4101
  } else if (options?.multi && driver.updateMany) {
4102
+ validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
3272
4103
  const ast = { object, where: options.where };
3273
4104
  result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options);
3274
4105
  } else {
@@ -3330,6 +4161,7 @@ var _ObjectQL = class _ObjectQL {
3330
4161
  ql: this
3331
4162
  };
3332
4163
  await this.triggerHooks("beforeDelete", hookContext);
4164
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
3333
4165
  try {
3334
4166
  let result;
3335
4167
  if (hookContext.input.id) {
@@ -3379,11 +4211,12 @@ var _ObjectQL = class _ObjectQL {
3379
4211
  context: query?.context
3380
4212
  };
3381
4213
  await this.executeWithMiddleware(opCtx, async () => {
4214
+ const countOpts = this.buildDriverOptions(opCtx.context);
3382
4215
  if (driver.count) {
3383
4216
  const ast = { object, where: query?.where };
3384
- return driver.count(object, ast);
4217
+ return driver.count(object, ast, countOpts);
3385
4218
  }
3386
- const res = await this.find(object, { where: query?.where, fields: ["id"] });
4219
+ const res = await this.find(object, { where: query?.where, fields: ["id"], context: opCtx.context });
3387
4220
  return res.length;
3388
4221
  });
3389
4222
  return opCtx.result;
@@ -3406,13 +4239,33 @@ var _ObjectQL = class _ObjectQL {
3406
4239
  aggregations: query.aggregations
3407
4240
  };
3408
4241
  const drv = driver;
3409
- if (typeof drv.aggregate === "function") {
3410
- return drv.aggregate(object, ast);
4242
+ const groupByItems = Array.isArray(query.groupBy) ? query.groupBy : [];
4243
+ const granularityCaps = drv?.supports?.queryDateGranularity;
4244
+ const structuredItems = groupByItems.filter((g) => typeof g !== "string");
4245
+ const allStructuredSupported = structuredItems.every((g) => {
4246
+ if (!g?.dateGranularity) return true;
4247
+ return granularityCaps?.[g.dateGranularity] === true;
4248
+ });
4249
+ if (typeof drv.aggregate === "function" && allStructuredSupported) {
4250
+ return drv.aggregate(object, ast, this.buildDriverOptions(opCtx.context));
3411
4251
  }
3412
- return driver.find(object, ast);
4252
+ const raw = await driver.find(object, ast, this.buildDriverOptions(opCtx.context));
4253
+ return applyInMemoryAggregation(raw, ast);
3413
4254
  });
3414
4255
  return opCtx.result;
3415
4256
  }
4257
+ /**
4258
+ * Run raw driver-specific commands (SQL for SqlDriver, REST for RestDriver, …).
4259
+ *
4260
+ * ⚠️ **Tenant isolation bypass.** Raw `execute()` does NOT thread the
4261
+ * caller's `ExecutionContext.tenantId` into a `WHERE organization_id`
4262
+ * predicate — drivers see the command verbatim. Callers MUST inline the
4263
+ * tenant filter themselves, or restrict raw execution to genuinely global
4264
+ * statements (schema migrations, sys_* / control-plane tables).
4265
+ *
4266
+ * Prefer the typed entry points (`find`, `update`, `delete`, `count`, …)
4267
+ * whenever feasible — they auto-apply tenancy + soft-delete + audit warnings.
4268
+ */
3416
4269
  async execute(command, options) {
3417
4270
  let driver;
3418
4271
  if (options?.object) {
@@ -3442,6 +4295,48 @@ var _ObjectQL = class _ObjectQL {
3442
4295
  }
3443
4296
  return driver.execute(rawCommand, params, options);
3444
4297
  }
4298
+ /**
4299
+ * Execute a callback inside a database transaction.
4300
+ *
4301
+ * The callback receives a context object that should be passed to all
4302
+ * downstream `engine.insert/update/delete/find/findOne` calls (as
4303
+ * `{ context: trxCtx }`). The transaction handle threads through
4304
+ * `OperationContext.context.transaction` and the SQL driver's per-builder
4305
+ * `.transacting(trx)` call.
4306
+ *
4307
+ * - If the default driver does not support `beginTransaction`, the callback
4308
+ * runs directly with the supplied base context (no rollback). This keeps
4309
+ * the API safe to call on drivers without ACID support (e.g. the
4310
+ * in-memory driver in tests).
4311
+ * - On callback success the transaction is committed; on any thrown error
4312
+ * it is rolled back and the original error is re-thrown.
4313
+ *
4314
+ * Use case: multi-step operations that must be atomic (e.g. CRM
4315
+ * `convertLead`, which creates an account + contact + opportunity + flips
4316
+ * the lead in a single unit of work).
4317
+ */
4318
+ async transaction(callback, baseContext) {
4319
+ const driver = this.defaultDriver ? this.drivers.get(this.defaultDriver) : void 0;
4320
+ const drv = driver;
4321
+ if (!drv?.beginTransaction) {
4322
+ return callback(baseContext);
4323
+ }
4324
+ const trx = await drv.beginTransaction();
4325
+ const trxCtx = { ...baseContext ?? {}, transaction: trx };
4326
+ try {
4327
+ const result = await callback(trxCtx);
4328
+ if (drv.commit) await drv.commit(trx);
4329
+ else if (drv.commitTransaction) await drv.commitTransaction(trx);
4330
+ return result;
4331
+ } catch (err) {
4332
+ try {
4333
+ if (drv.rollback) await drv.rollback(trx);
4334
+ else if (drv.rollbackTransaction) await drv.rollbackTransaction(trx);
4335
+ } catch {
4336
+ }
4337
+ throw err;
4338
+ }
4339
+ }
3445
4340
  // ============================================
3446
4341
  // Compatibility / Convenience API
3447
4342
  // ============================================
@@ -3595,7 +4490,7 @@ var _ObjectQL = class _ObjectQL {
3595
4490
  */
3596
4491
  createContext(ctx) {
3597
4492
  return new ScopedContext(
3598
- import_kernel2.ExecutionContextSchema.parse(ctx),
4493
+ import_kernel3.ExecutionContextSchema.parse(ctx),
3599
4494
  this
3600
4495
  );
3601
4496
  }
@@ -3876,6 +4771,13 @@ var ObjectQLPlugin = class {
3876
4771
  this.name = "com.objectstack.engine.objectql";
3877
4772
  this.type = "objectql";
3878
4773
  this.version = "1.0.0";
4774
+ /**
4775
+ * Schema sync to remote SQL DBs is latency-bound (one round-trip per
4776
+ * table × 2 phases). Default to 120s instead of the kernel's 30s so
4777
+ * cold Neon/Turso starts don't get killed mid-sync.
4778
+ */
4779
+ this.startupTimeout = 12e4;
4780
+ this.skipSchemaSync = false;
3879
4781
  this.init = async (ctx) => {
3880
4782
  if (!this.ql) {
3881
4783
  const hostCtx = { ...this.hostContext, logger: ctx.logger };
@@ -3903,6 +4805,17 @@ var ObjectQLPlugin = class {
3903
4805
  );
3904
4806
  ctx.registerService("protocol", protocolShim);
3905
4807
  ctx.logger.info("Protocol service registered");
4808
+ ctx.registerService("analytics", {
4809
+ query: (body) => protocolShim.analyticsQuery(body),
4810
+ getMeta: async () => ({
4811
+ cubes: [],
4812
+ message: "Analytics meta endpoint not implemented by ObjectQL adapter"
4813
+ }),
4814
+ generateSql: async (_body) => ({
4815
+ sql: null,
4816
+ message: "Analytics SQL generation not implemented by ObjectQL adapter"
4817
+ })
4818
+ });
3906
4819
  };
3907
4820
  this.start = async (ctx) => {
3908
4821
  ctx.logger.info("ObjectQL engine starting...");
@@ -3942,13 +4855,19 @@ var ObjectQLPlugin = class {
3942
4855
  }
3943
4856
  }
3944
4857
  await this.ql?.init();
3945
- await this.syncRegisteredSchemas(ctx);
4858
+ if (this.skipSchemaSync) {
4859
+ ctx.logger.info("Skipping schema sync (OS_SKIP_SCHEMA_SYNC=1) \u2014 assuming DDL is managed out-of-band");
4860
+ } else {
4861
+ await this.syncRegisteredSchemas(ctx);
4862
+ }
3946
4863
  if (this.projectId === void 0) {
3947
4864
  await this.restoreMetadataFromDb(ctx);
3948
4865
  } else {
3949
4866
  ctx.logger.info("Project kernel \u2014 skipping sys_metadata hydration (metadata sourced from artifact)");
3950
4867
  }
3951
- await this.syncRegisteredSchemas(ctx);
4868
+ if (!this.skipSchemaSync) {
4869
+ await this.syncRegisteredSchemas(ctx);
4870
+ }
3952
4871
  if (this.projectId === void 0) {
3953
4872
  await this.bridgeObjectsToMetadataService(ctx);
3954
4873
  }
@@ -3969,6 +4888,10 @@ var ObjectQLPlugin = class {
3969
4888
  }
3970
4889
  this.hostContext = opts.hostContext ?? hostContext;
3971
4890
  this.projectId = opts.projectId;
4891
+ if (typeof opts.startupTimeout === "number" && opts.startupTimeout > 0) {
4892
+ this.startupTimeout = opts.startupTimeout;
4893
+ }
4894
+ this.skipSchemaSync = typeof opts.skipSchemaSync === "boolean" ? opts.skipSchemaSync : process.env.OS_SKIP_SCHEMA_SYNC === "1";
3972
4895
  }
3973
4896
  /**
3974
4897
  * Register built-in audit hooks for auto-stamping created_by/updated_by
@@ -4056,7 +4979,13 @@ var ObjectQLPlugin = class {
4056
4979
  if (hookCtx.input?.id && !hookCtx.previous) {
4057
4980
  try {
4058
4981
  const existing = await this.ql.findOne(hookCtx.object, {
4059
- where: { id: hookCtx.input.id }
4982
+ where: { id: hookCtx.input.id },
4983
+ context: {
4984
+ roles: [],
4985
+ permissions: [],
4986
+ isSystem: true,
4987
+ ...hookCtx.transaction ? { transaction: hookCtx.transaction } : {}
4988
+ }
4060
4989
  });
4061
4990
  if (existing) hookCtx.previous = existing;
4062
4991
  } catch (_e) {
@@ -4074,7 +5003,13 @@ var ObjectQLPlugin = class {
4074
5003
  if (hookCtx.input?.id && !hookCtx.previous) {
4075
5004
  try {
4076
5005
  const existing = await this.ql.findOne(hookCtx.object, {
4077
- where: { id: hookCtx.input.id }
5006
+ where: { id: hookCtx.input.id },
5007
+ context: {
5008
+ roles: [],
5009
+ permissions: [],
5010
+ isSystem: true,
5011
+ ...hookCtx.transaction ? { transaction: hookCtx.transaction } : {}
5012
+ }
4078
5013
  });
4079
5014
  if (existing) hookCtx.previous = existing;
4080
5015
  } catch (_e) {
@@ -4456,14 +5391,18 @@ function convertIntrospectedSchemaToObjects(introspectedSchema, options) {
4456
5391
  RESERVED_NAMESPACES,
4457
5392
  SchemaRegistry,
4458
5393
  ScopedContext,
5394
+ ValidationError,
5395
+ applyInMemoryAggregation,
4459
5396
  applySystemFields,
4460
5397
  bindHooksToEngine,
5398
+ bucketDateValue,
4461
5399
  computeFQN,
4462
5400
  convertIntrospectedSchemaToObjects,
4463
5401
  createObjectQLKernel,
4464
5402
  noopHookMetricsRecorder,
4465
5403
  parseFQN,
4466
5404
  toTitleCase,
5405
+ validateRecord,
4467
5406
  wrapDeclarativeHook
4468
5407
  });
4469
5408
  //# sourceMappingURL=index.js.map