@objectstack/objectql 4.0.5 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +260 -6
- package/dist/index.d.ts +260 -6
- package/dist/index.js +1063 -124
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1057 -122
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -6
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
|
|
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
|
|
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
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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.
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
const
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1235
|
+
// Global Search (M10.5)
|
|
1080
1236
|
// ==========================================
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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]
|
|
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`
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1448
|
-
scope:
|
|
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:
|
|
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.
|
|
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
|
-
|
|
1469
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|