@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.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
|
|
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
|
|
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
|
-
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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.
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
const
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1284
|
+
// Global Search (M10.5)
|
|
1125
1285
|
// ==========================================
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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]
|
|
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`
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
scope:
|
|
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:
|
|
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.
|
|
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
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|