@objectstack/objectql 4.0.4 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +726 -1117
- package/dist/index.d.ts +726 -1117
- package/dist/index.js +2316 -374
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2305 -372
- package/dist/index.mjs.map +1 -1
- package/package.json +33 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -720
- package/src/datasource-mapping.test.ts +0 -181
- package/src/engine.test.ts +0 -613
- package/src/engine.ts +0 -1668
- package/src/index.ts +0 -41
- package/src/kernel-factory.ts +0 -48
- package/src/metadata-facade.ts +0 -96
- package/src/plugin.integration.test.ts +0 -995
- package/src/plugin.ts +0 -534
- package/src/protocol-data.test.ts +0 -245
- package/src/protocol-discovery.test.ts +0 -213
- package/src/protocol-feed.test.ts +0 -303
- package/src/protocol-meta.test.ts +0 -440
- package/src/protocol.ts +0 -1242
- package/src/registry.test.ts +0 -494
- package/src/registry.ts +0 -716
- package/src/util.test.ts +0 -226
- package/src/util.ts +0 -219
- package/tsconfig.json +0 -10
package/dist/index.mjs
CHANGED
|
@@ -5,11 +5,8 @@ import { AppSchema } from "@objectstack/spec/ui";
|
|
|
5
5
|
var RESERVED_NAMESPACES = /* @__PURE__ */ new Set(["base", "system"]);
|
|
6
6
|
var DEFAULT_OWNER_PRIORITY = 100;
|
|
7
7
|
var DEFAULT_EXTENDER_PRIORITY = 200;
|
|
8
|
-
function computeFQN(
|
|
9
|
-
|
|
10
|
-
return shortName;
|
|
11
|
-
}
|
|
12
|
-
return `${namespace}__${shortName}`;
|
|
8
|
+
function computeFQN(_namespace, shortName) {
|
|
9
|
+
return shortName;
|
|
13
10
|
}
|
|
14
11
|
function parseFQN(fqn) {
|
|
15
12
|
const idx = fqn.indexOf("__");
|
|
@@ -37,14 +34,111 @@ function mergeObjectDefinitions(base, extension) {
|
|
|
37
34
|
if (extension.description !== void 0) merged.description = extension.description;
|
|
38
35
|
return merged;
|
|
39
36
|
}
|
|
37
|
+
function applySystemFields(schema, opts) {
|
|
38
|
+
if (schema.systemFields === false) return schema;
|
|
39
|
+
if (schema.managedBy === "better-auth") return schema;
|
|
40
|
+
const sf = typeof schema.systemFields === "object" && schema.systemFields !== null ? schema.systemFields : void 0;
|
|
41
|
+
const tenancyDisabled = schema.tenancy?.enabled === false;
|
|
42
|
+
const wantTenant = opts.multiTenant && sf?.tenant !== false && !tenancyDisabled;
|
|
43
|
+
const wantAudit = sf?.audit !== false;
|
|
44
|
+
const additions = {};
|
|
45
|
+
if (wantTenant && !schema.fields?.organization_id) {
|
|
46
|
+
additions.organization_id = {
|
|
47
|
+
type: "lookup",
|
|
48
|
+
reference: "sys_organization",
|
|
49
|
+
label: "Organization",
|
|
50
|
+
required: false,
|
|
51
|
+
indexed: true,
|
|
52
|
+
hidden: true,
|
|
53
|
+
readonly: true,
|
|
54
|
+
system: true,
|
|
55
|
+
description: "Tenant scope (auto-populated by SecurityPlugin on insert)."
|
|
56
|
+
};
|
|
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
|
+
}
|
|
102
|
+
if (Object.keys(additions).length === 0) return schema;
|
|
103
|
+
return {
|
|
104
|
+
...schema,
|
|
105
|
+
fields: { ...additions, ...schema.fields ?? {} }
|
|
106
|
+
};
|
|
107
|
+
}
|
|
40
108
|
var SchemaRegistry = class {
|
|
41
|
-
|
|
109
|
+
constructor(options = {}) {
|
|
110
|
+
// ==========================================
|
|
111
|
+
// Logging control
|
|
112
|
+
// ==========================================
|
|
113
|
+
/** Controls verbosity of registry console messages. Default: 'info'. */
|
|
114
|
+
this._logLevel = "info";
|
|
115
|
+
// ==========================================
|
|
116
|
+
// Object-specific storage (Ownership Model)
|
|
117
|
+
// ==========================================
|
|
118
|
+
/** FQN → Contributor[] (all packages that own/extend this object) */
|
|
119
|
+
this.objectContributors = /* @__PURE__ */ new Map();
|
|
120
|
+
/** FQN → Merged ServiceObject (cached, invalidated on changes) */
|
|
121
|
+
this.mergedObjectCache = /* @__PURE__ */ new Map();
|
|
122
|
+
/** Namespace → Set<PackageId> (multiple packages can share a namespace) */
|
|
123
|
+
this.namespaceRegistry = /* @__PURE__ */ new Map();
|
|
124
|
+
// ==========================================
|
|
125
|
+
// Generic metadata storage (non-object types)
|
|
126
|
+
// ==========================================
|
|
127
|
+
/** Type → Name/ID → MetadataItem */
|
|
128
|
+
this.metadata = /* @__PURE__ */ new Map();
|
|
129
|
+
if (options.multiTenant !== void 0) {
|
|
130
|
+
this.multiTenant = options.multiTenant;
|
|
131
|
+
} else {
|
|
132
|
+
this.multiTenant = String(process.env.OS_MULTI_TENANT ?? "true").toLowerCase() !== "false";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
get logLevel() {
|
|
42
136
|
return this._logLevel;
|
|
43
137
|
}
|
|
44
|
-
|
|
138
|
+
set logLevel(level) {
|
|
45
139
|
this._logLevel = level;
|
|
46
140
|
}
|
|
47
|
-
|
|
141
|
+
log(msg) {
|
|
48
142
|
if (this._logLevel === "silent" || this._logLevel === "error" || this._logLevel === "warn") return;
|
|
49
143
|
console.log(msg);
|
|
50
144
|
}
|
|
@@ -55,7 +149,7 @@ var SchemaRegistry = class {
|
|
|
55
149
|
* Register a namespace for a package.
|
|
56
150
|
* Multiple packages can share the same namespace (e.g. 'sys').
|
|
57
151
|
*/
|
|
58
|
-
|
|
152
|
+
registerNamespace(namespace, packageId) {
|
|
59
153
|
if (!namespace) return;
|
|
60
154
|
let owners = this.namespaceRegistry.get(namespace);
|
|
61
155
|
if (!owners) {
|
|
@@ -68,7 +162,7 @@ var SchemaRegistry = class {
|
|
|
68
162
|
/**
|
|
69
163
|
* Unregister a namespace when a package is uninstalled.
|
|
70
164
|
*/
|
|
71
|
-
|
|
165
|
+
unregisterNamespace(namespace, packageId) {
|
|
72
166
|
const owners = this.namespaceRegistry.get(namespace);
|
|
73
167
|
if (owners) {
|
|
74
168
|
owners.delete(packageId);
|
|
@@ -81,7 +175,7 @@ var SchemaRegistry = class {
|
|
|
81
175
|
/**
|
|
82
176
|
* Get the packages that use a namespace.
|
|
83
177
|
*/
|
|
84
|
-
|
|
178
|
+
getNamespaceOwner(namespace) {
|
|
85
179
|
const owners = this.namespaceRegistry.get(namespace);
|
|
86
180
|
if (!owners || owners.size === 0) return void 0;
|
|
87
181
|
return owners.values().next().value;
|
|
@@ -89,7 +183,7 @@ var SchemaRegistry = class {
|
|
|
89
183
|
/**
|
|
90
184
|
* Get all packages that share a namespace.
|
|
91
185
|
*/
|
|
92
|
-
|
|
186
|
+
getNamespaceOwners(namespace) {
|
|
93
187
|
const owners = this.namespaceRegistry.get(namespace);
|
|
94
188
|
return owners ? Array.from(owners) : [];
|
|
95
189
|
}
|
|
@@ -107,7 +201,8 @@ var SchemaRegistry = class {
|
|
|
107
201
|
*
|
|
108
202
|
* @throws Error if trying to 'own' an object that already has an owner
|
|
109
203
|
*/
|
|
110
|
-
|
|
204
|
+
registerObject(schema, packageId, namespace, ownership = "own", priority = ownership === "own" ? DEFAULT_OWNER_PRIORITY : DEFAULT_EXTENDER_PRIORITY) {
|
|
205
|
+
schema = applySystemFields(schema, { multiTenant: this.multiTenant });
|
|
111
206
|
const shortName = schema.name;
|
|
112
207
|
const fqn = computeFQN(namespace, shortName);
|
|
113
208
|
if (namespace) {
|
|
@@ -154,7 +249,7 @@ var SchemaRegistry = class {
|
|
|
154
249
|
* Resolve an object by FQN, merging all contributions.
|
|
155
250
|
* Returns the merged object or undefined if not found.
|
|
156
251
|
*/
|
|
157
|
-
|
|
252
|
+
resolveObject(fqn) {
|
|
158
253
|
const cached = this.mergedObjectCache.get(fqn);
|
|
159
254
|
if (cached) return cached;
|
|
160
255
|
const contributors = this.objectContributors.get(fqn);
|
|
@@ -176,38 +271,42 @@ var SchemaRegistry = class {
|
|
|
176
271
|
return merged;
|
|
177
272
|
}
|
|
178
273
|
/**
|
|
179
|
-
* Get object by name (
|
|
274
|
+
* Get object by name (short name canonical, FQN supported for disambiguation).
|
|
275
|
+
*
|
|
276
|
+
* Short names are canonical for user code, AI generation, and most lookups.
|
|
277
|
+
* FQN is accepted as an explicit fallback for cross-package disambiguation
|
|
278
|
+
* when two packages contribute objects with the same short name.
|
|
180
279
|
*
|
|
181
280
|
* Resolution order:
|
|
182
|
-
* 1. Exact
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const direct = this.resolveObject(name);
|
|
190
|
-
if (direct) return direct;
|
|
281
|
+
* 1. Exact name match — the name IS the canonical key.
|
|
282
|
+
* If multiple packages contribute the same short name, a warning is logged
|
|
283
|
+
* and the first match wins — disambiguate by passing the FQN explicitly.
|
|
284
|
+
* 2. Legacy FQN match (e.g., 'crm__account') — backward compat.
|
|
285
|
+
*/
|
|
286
|
+
getObject(name) {
|
|
287
|
+
const matches = [];
|
|
191
288
|
for (const fqn of this.objectContributors.keys()) {
|
|
192
289
|
const { shortName } = parseFQN(fqn);
|
|
193
290
|
if (shortName === name) {
|
|
194
|
-
|
|
291
|
+
matches.push(fqn);
|
|
195
292
|
}
|
|
196
293
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
294
|
+
if (matches.length > 0) {
|
|
295
|
+
if (matches.length > 1) {
|
|
296
|
+
console.warn(
|
|
297
|
+
`[SchemaRegistry] Ambiguous short name "${name}" matches: ${matches.join(", ")}. Returning first match. Use FQN to disambiguate.`
|
|
298
|
+
);
|
|
201
299
|
}
|
|
300
|
+
return this.resolveObject(matches[0]);
|
|
202
301
|
}
|
|
203
|
-
return
|
|
302
|
+
return this.resolveObject(name);
|
|
204
303
|
}
|
|
205
304
|
/**
|
|
206
305
|
* Get all registered objects (merged).
|
|
207
306
|
*
|
|
208
307
|
* @param packageId - Optional filter: only objects contributed by this package
|
|
209
308
|
*/
|
|
210
|
-
|
|
309
|
+
getAllObjects(packageId) {
|
|
211
310
|
const results = [];
|
|
212
311
|
for (const fqn of this.objectContributors.keys()) {
|
|
213
312
|
if (packageId) {
|
|
@@ -226,13 +325,13 @@ var SchemaRegistry = class {
|
|
|
226
325
|
/**
|
|
227
326
|
* Get all contributors for an object.
|
|
228
327
|
*/
|
|
229
|
-
|
|
328
|
+
getObjectContributors(fqn) {
|
|
230
329
|
return this.objectContributors.get(fqn) || [];
|
|
231
330
|
}
|
|
232
331
|
/**
|
|
233
332
|
* Get the owner contributor for an object.
|
|
234
333
|
*/
|
|
235
|
-
|
|
334
|
+
getObjectOwner(fqn) {
|
|
236
335
|
const contributors = this.objectContributors.get(fqn);
|
|
237
336
|
return contributors?.find((c) => c.ownership === "own");
|
|
238
337
|
}
|
|
@@ -241,7 +340,7 @@ var SchemaRegistry = class {
|
|
|
241
340
|
*
|
|
242
341
|
* @throws Error if trying to uninstall an owner that has extenders
|
|
243
342
|
*/
|
|
244
|
-
|
|
343
|
+
unregisterObjectsByPackage(packageId, force = false) {
|
|
245
344
|
for (const [fqn, contributors] of this.objectContributors.entries()) {
|
|
246
345
|
const packageContribs = contributors.filter((c) => c.packageId === packageId);
|
|
247
346
|
for (const contrib of packageContribs) {
|
|
@@ -273,7 +372,7 @@ var SchemaRegistry = class {
|
|
|
273
372
|
/**
|
|
274
373
|
* Universal Register Method for non-object metadata.
|
|
275
374
|
*/
|
|
276
|
-
|
|
375
|
+
registerItem(type, item, keyField = "name", packageId) {
|
|
277
376
|
if (!this.metadata.has(type)) {
|
|
278
377
|
this.metadata.set(type, /* @__PURE__ */ new Map());
|
|
279
378
|
}
|
|
@@ -289,7 +388,7 @@ var SchemaRegistry = class {
|
|
|
289
388
|
}
|
|
290
389
|
const storageKey = packageId ? `${packageId}:${baseName}` : baseName;
|
|
291
390
|
if (collection.has(storageKey)) {
|
|
292
|
-
|
|
391
|
+
this.log(`[Registry] Overwriting ${type}: ${storageKey}`);
|
|
293
392
|
}
|
|
294
393
|
collection.set(storageKey, item);
|
|
295
394
|
this.log(`[Registry] Registered ${type}: ${storageKey}`);
|
|
@@ -297,7 +396,7 @@ var SchemaRegistry = class {
|
|
|
297
396
|
/**
|
|
298
397
|
* Validate Metadata against Spec Zod Schemas
|
|
299
398
|
*/
|
|
300
|
-
|
|
399
|
+
validate(type, item) {
|
|
301
400
|
if (type === "object") {
|
|
302
401
|
return ObjectSchema.parse(item);
|
|
303
402
|
}
|
|
@@ -315,7 +414,7 @@ var SchemaRegistry = class {
|
|
|
315
414
|
/**
|
|
316
415
|
* Universal Unregister Method
|
|
317
416
|
*/
|
|
318
|
-
|
|
417
|
+
unregisterItem(type, name) {
|
|
319
418
|
const collection = this.metadata.get(type);
|
|
320
419
|
if (!collection) {
|
|
321
420
|
console.warn(`[Registry] Attempted to unregister non-existent ${type}: ${name}`);
|
|
@@ -338,7 +437,7 @@ var SchemaRegistry = class {
|
|
|
338
437
|
/**
|
|
339
438
|
* Universal Get Method
|
|
340
439
|
*/
|
|
341
|
-
|
|
440
|
+
getItem(type, name) {
|
|
342
441
|
if (type === "object" || type === "objects") {
|
|
343
442
|
return this.getObject(name);
|
|
344
443
|
}
|
|
@@ -354,7 +453,7 @@ var SchemaRegistry = class {
|
|
|
354
453
|
/**
|
|
355
454
|
* Universal List Method
|
|
356
455
|
*/
|
|
357
|
-
|
|
456
|
+
listItems(type, packageId) {
|
|
358
457
|
if (type === "object" || type === "objects") {
|
|
359
458
|
return this.getAllObjects(packageId);
|
|
360
459
|
}
|
|
@@ -367,7 +466,7 @@ var SchemaRegistry = class {
|
|
|
367
466
|
/**
|
|
368
467
|
* Get all registered metadata types (Kinds)
|
|
369
468
|
*/
|
|
370
|
-
|
|
469
|
+
getRegisteredTypes() {
|
|
371
470
|
const types = Array.from(this.metadata.keys());
|
|
372
471
|
if (!types.includes("object") && this.objectContributors.size > 0) {
|
|
373
472
|
types.push("object");
|
|
@@ -377,7 +476,7 @@ var SchemaRegistry = class {
|
|
|
377
476
|
// ==========================================
|
|
378
477
|
// Package Management
|
|
379
478
|
// ==========================================
|
|
380
|
-
|
|
479
|
+
installPackage(manifest, settings) {
|
|
381
480
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
382
481
|
const pkg = {
|
|
383
482
|
manifest,
|
|
@@ -401,7 +500,7 @@ var SchemaRegistry = class {
|
|
|
401
500
|
this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`);
|
|
402
501
|
return pkg;
|
|
403
502
|
}
|
|
404
|
-
|
|
503
|
+
uninstallPackage(id) {
|
|
405
504
|
const pkg = this.getPackage(id);
|
|
406
505
|
if (!pkg) {
|
|
407
506
|
console.warn(`[Registry] Package not found for uninstall: ${id}`);
|
|
@@ -419,13 +518,13 @@ var SchemaRegistry = class {
|
|
|
419
518
|
}
|
|
420
519
|
return false;
|
|
421
520
|
}
|
|
422
|
-
|
|
521
|
+
getPackage(id) {
|
|
423
522
|
return this.metadata.get("package")?.get(id);
|
|
424
523
|
}
|
|
425
|
-
|
|
524
|
+
getAllPackages() {
|
|
426
525
|
return this.listItems("package");
|
|
427
526
|
}
|
|
428
|
-
|
|
527
|
+
enablePackage(id) {
|
|
429
528
|
const pkg = this.getPackage(id);
|
|
430
529
|
if (pkg) {
|
|
431
530
|
pkg.enabled = true;
|
|
@@ -436,7 +535,7 @@ var SchemaRegistry = class {
|
|
|
436
535
|
}
|
|
437
536
|
return pkg;
|
|
438
537
|
}
|
|
439
|
-
|
|
538
|
+
disablePackage(id) {
|
|
440
539
|
const pkg = this.getPackage(id);
|
|
441
540
|
if (pkg) {
|
|
442
541
|
pkg.enabled = false;
|
|
@@ -450,31 +549,31 @@ var SchemaRegistry = class {
|
|
|
450
549
|
// ==========================================
|
|
451
550
|
// App Helpers
|
|
452
551
|
// ==========================================
|
|
453
|
-
|
|
552
|
+
registerApp(app, packageId) {
|
|
454
553
|
this.registerItem("app", app, "name", packageId);
|
|
455
554
|
}
|
|
456
|
-
|
|
555
|
+
getApp(name) {
|
|
457
556
|
return this.getItem("app", name);
|
|
458
557
|
}
|
|
459
|
-
|
|
558
|
+
getAllApps() {
|
|
460
559
|
return this.listItems("app");
|
|
461
560
|
}
|
|
462
561
|
// ==========================================
|
|
463
562
|
// Plugin Helpers
|
|
464
563
|
// ==========================================
|
|
465
|
-
|
|
564
|
+
registerPlugin(manifest) {
|
|
466
565
|
this.registerItem("plugin", manifest, "id");
|
|
467
566
|
}
|
|
468
|
-
|
|
567
|
+
getAllPlugins() {
|
|
469
568
|
return this.listItems("plugin");
|
|
470
569
|
}
|
|
471
570
|
// ==========================================
|
|
472
571
|
// Kind Helpers
|
|
473
572
|
// ==========================================
|
|
474
|
-
|
|
573
|
+
registerKind(kind) {
|
|
475
574
|
this.registerItem("kind", kind, "id");
|
|
476
575
|
}
|
|
477
|
-
|
|
576
|
+
getAllKinds() {
|
|
478
577
|
return this.listItems("kind");
|
|
479
578
|
}
|
|
480
579
|
// ==========================================
|
|
@@ -483,7 +582,7 @@ var SchemaRegistry = class {
|
|
|
483
582
|
/**
|
|
484
583
|
* Clear all registry state. Use only for testing.
|
|
485
584
|
*/
|
|
486
|
-
|
|
585
|
+
reset() {
|
|
487
586
|
this.objectContributors.clear();
|
|
488
587
|
this.mergedObjectCache.clear();
|
|
489
588
|
this.namespaceRegistry.clear();
|
|
@@ -491,29 +590,26 @@ var SchemaRegistry = class {
|
|
|
491
590
|
this.log("[Registry] Reset complete");
|
|
492
591
|
}
|
|
493
592
|
};
|
|
494
|
-
// ==========================================
|
|
495
|
-
// Logging control
|
|
496
|
-
// ==========================================
|
|
497
|
-
/** Controls verbosity of registry console messages. Default: 'info'. */
|
|
498
|
-
SchemaRegistry._logLevel = "info";
|
|
499
|
-
// ==========================================
|
|
500
|
-
// Object-specific storage (Ownership Model)
|
|
501
|
-
// ==========================================
|
|
502
|
-
/** FQN → Contributor[] (all packages that own/extend this object) */
|
|
503
|
-
SchemaRegistry.objectContributors = /* @__PURE__ */ new Map();
|
|
504
|
-
/** FQN → Merged ServiceObject (cached, invalidated on changes) */
|
|
505
|
-
SchemaRegistry.mergedObjectCache = /* @__PURE__ */ new Map();
|
|
506
|
-
/** Namespace → Set<PackageId> (multiple packages can share a namespace) */
|
|
507
|
-
SchemaRegistry.namespaceRegistry = /* @__PURE__ */ new Map();
|
|
508
|
-
// ==========================================
|
|
509
|
-
// Generic metadata storage (non-object types)
|
|
510
|
-
// ==========================================
|
|
511
|
-
/** Type → Name/ID → MetadataItem */
|
|
512
|
-
SchemaRegistry.metadata = /* @__PURE__ */ new Map();
|
|
513
593
|
|
|
514
594
|
// src/protocol.ts
|
|
515
595
|
import { parseFilterAST, isFilterAST } from "@objectstack/spec/data";
|
|
516
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
|
+
}
|
|
517
613
|
function simpleHash(str) {
|
|
518
614
|
let hash = 0;
|
|
519
615
|
for (let i = 0; i < str.length; i++) {
|
|
@@ -540,11 +636,75 @@ var SERVICE_CONFIG = {
|
|
|
540
636
|
"file-storage": { route: "/api/v1/storage", plugin: "plugin-storage" },
|
|
541
637
|
search: { route: "/api/v1/search", plugin: "plugin-search" }
|
|
542
638
|
};
|
|
543
|
-
var
|
|
544
|
-
constructor(engine, getServicesRegistry, getFeedService) {
|
|
639
|
+
var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementation {
|
|
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;
|
|
545
651
|
this.engine = engine;
|
|
546
652
|
this.getServicesRegistry = getServicesRegistry;
|
|
547
653
|
this.getFeedService = getFeedService;
|
|
654
|
+
this.projectId = projectId;
|
|
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
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Exposes the project scope the protocol is bound to. Consumers like
|
|
702
|
+
* the HTTP dispatcher use this to decide whether to trust the process-
|
|
703
|
+
* wide SchemaRegistry or whether they must route a read through the
|
|
704
|
+
* protocol's project_id-filtered lookup.
|
|
705
|
+
*/
|
|
706
|
+
getProjectId() {
|
|
707
|
+
return this.projectId;
|
|
548
708
|
}
|
|
549
709
|
requireFeedService() {
|
|
550
710
|
const svc = this.getFeedService?.();
|
|
@@ -641,7 +801,7 @@ var ObjectStackProtocolImplementation = class {
|
|
|
641
801
|
};
|
|
642
802
|
}
|
|
643
803
|
async getMetaTypes() {
|
|
644
|
-
const schemaTypes =
|
|
804
|
+
const schemaTypes = this.engine.registry.getRegisteredTypes();
|
|
645
805
|
let runtimeTypes = [];
|
|
646
806
|
try {
|
|
647
807
|
const services = this.getServicesRegistry?.();
|
|
@@ -656,41 +816,66 @@ var ObjectStackProtocolImplementation = class {
|
|
|
656
816
|
}
|
|
657
817
|
async getMetaItems(request) {
|
|
658
818
|
const { packageId } = request;
|
|
659
|
-
let items =
|
|
660
|
-
if (
|
|
661
|
-
|
|
662
|
-
if (
|
|
819
|
+
let items = [];
|
|
820
|
+
if (this.projectId === void 0) {
|
|
821
|
+
items = [...this.engine.registry.listItems(request.type, packageId)];
|
|
822
|
+
if (items.length === 0) {
|
|
823
|
+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
824
|
+
if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
|
|
825
|
+
}
|
|
826
|
+
} else {
|
|
827
|
+
items = [...this.engine.registry.listItems(request.type, packageId)];
|
|
828
|
+
if (items.length === 0) {
|
|
829
|
+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
830
|
+
if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
|
|
831
|
+
}
|
|
663
832
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
+
};
|
|
667
841
|
if (packageId) whereClause._packageId = packageId;
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
});
|
|
671
|
-
if (allRecords && allRecords.length > 0) {
|
|
672
|
-
items = allRecords.map((record) => {
|
|
673
|
-
const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
674
|
-
SchemaRegistry.registerItem(request.type, data, "name");
|
|
675
|
-
return data;
|
|
676
|
-
});
|
|
677
|
-
} else {
|
|
842
|
+
let rs = await this.engine.find("sys_metadata", { where: whereClause });
|
|
843
|
+
if (!rs || rs.length === 0) {
|
|
678
844
|
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
679
845
|
if (alt) {
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
});
|
|
683
|
-
if (altRecords && altRecords.length > 0) {
|
|
684
|
-
items = altRecords.map((record) => {
|
|
685
|
-
const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
686
|
-
SchemaRegistry.registerItem(request.type, data, "name");
|
|
687
|
-
return data;
|
|
688
|
-
});
|
|
689
|
-
}
|
|
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 });
|
|
690
849
|
}
|
|
691
850
|
}
|
|
692
|
-
|
|
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());
|
|
859
|
+
if (records && records.length > 0) {
|
|
860
|
+
const byName = /* @__PURE__ */ new Map();
|
|
861
|
+
for (const existing of items) {
|
|
862
|
+
const entry = existing;
|
|
863
|
+
if (entry && typeof entry === "object" && "name" in entry) {
|
|
864
|
+
byName.set(entry.name, entry);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
for (const record of records) {
|
|
868
|
+
const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
869
|
+
if (data && typeof data === "object" && "name" in data) {
|
|
870
|
+
byName.set(data.name, data);
|
|
871
|
+
}
|
|
872
|
+
if (this.projectId === void 0) {
|
|
873
|
+
this.engine.registry.registerItem(request.type, data, "name");
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
items = Array.from(byName.values());
|
|
693
877
|
}
|
|
878
|
+
} catch {
|
|
694
879
|
}
|
|
695
880
|
try {
|
|
696
881
|
const services = this.getServicesRegistry?.();
|
|
@@ -711,7 +896,9 @@ var ObjectStackProtocolImplementation = class {
|
|
|
711
896
|
for (const item of runtimeItems) {
|
|
712
897
|
const entry = item;
|
|
713
898
|
if (entry && typeof entry === "object" && "name" in entry) {
|
|
714
|
-
itemMap.
|
|
899
|
+
if (!itemMap.has(entry.name)) {
|
|
900
|
+
itemMap.set(entry.name, entry);
|
|
901
|
+
}
|
|
715
902
|
}
|
|
716
903
|
}
|
|
717
904
|
items = Array.from(itemMap.values());
|
|
@@ -725,32 +912,41 @@ var ObjectStackProtocolImplementation = class {
|
|
|
725
912
|
};
|
|
726
913
|
}
|
|
727
914
|
async getMetaItem(request) {
|
|
728
|
-
let item
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
915
|
+
let item;
|
|
916
|
+
const orgId = request.organizationId;
|
|
917
|
+
try {
|
|
918
|
+
const findOverlay = async (oid) => {
|
|
919
|
+
const where = {
|
|
920
|
+
type: request.type,
|
|
921
|
+
name: request.name,
|
|
922
|
+
state: "active",
|
|
923
|
+
organization_id: oid
|
|
924
|
+
};
|
|
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 });
|
|
936
|
+
}
|
|
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 {
|
|
732
944
|
}
|
|
733
945
|
if (item === void 0) {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
if (record) {
|
|
739
|
-
item = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
740
|
-
SchemaRegistry.registerItem(request.type, item, "name");
|
|
741
|
-
} else {
|
|
742
|
-
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
|
|
743
|
-
if (alt) {
|
|
744
|
-
const altRecord = await this.engine.findOne("sys_metadata", {
|
|
745
|
-
where: { type: alt, name: request.name, state: "active" }
|
|
746
|
-
});
|
|
747
|
-
if (altRecord) {
|
|
748
|
-
item = typeof altRecord.metadata === "string" ? JSON.parse(altRecord.metadata) : altRecord.metadata;
|
|
749
|
-
SchemaRegistry.registerItem(request.type, item, "name");
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
} catch {
|
|
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);
|
|
754
950
|
}
|
|
755
951
|
}
|
|
756
952
|
if (item === void 0) {
|
|
@@ -770,7 +966,7 @@ var ObjectStackProtocolImplementation = class {
|
|
|
770
966
|
};
|
|
771
967
|
}
|
|
772
968
|
async getUiView(request) {
|
|
773
|
-
const schema =
|
|
969
|
+
const schema = this.engine.registry.getObject(request.object);
|
|
774
970
|
if (!schema) throw new Error(`Object ${request.object} not found`);
|
|
775
971
|
const fields = schema.fields || {};
|
|
776
972
|
const fieldKeys = Object.keys(fields);
|
|
@@ -826,6 +1022,21 @@ var ObjectStackProtocolImplementation = class {
|
|
|
826
1022
|
}
|
|
827
1023
|
async findData(request) {
|
|
828
1024
|
const options = { ...request.query };
|
|
1025
|
+
if (request.context !== void 0) {
|
|
1026
|
+
options.context = request.context;
|
|
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
|
+
}
|
|
829
1040
|
if (options.top != null) {
|
|
830
1041
|
options.limit = Number(options.top);
|
|
831
1042
|
delete options.top;
|
|
@@ -932,6 +1143,23 @@ var ObjectStackProtocolImplementation = class {
|
|
|
932
1143
|
options.where = implicitFilters;
|
|
933
1144
|
}
|
|
934
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
|
+
}
|
|
935
1163
|
const records = await this.engine.find(request.object, options);
|
|
936
1164
|
return {
|
|
937
1165
|
object: request.object,
|
|
@@ -944,6 +1172,9 @@ var ObjectStackProtocolImplementation = class {
|
|
|
944
1172
|
const queryOptions = {
|
|
945
1173
|
where: { id: request.id }
|
|
946
1174
|
};
|
|
1175
|
+
if (request.context !== void 0) {
|
|
1176
|
+
queryOptions.context = request.context;
|
|
1177
|
+
}
|
|
947
1178
|
if (request.select) {
|
|
948
1179
|
queryOptions.fields = typeof request.select === "string" ? request.select.split(",").map((s) => s.trim()).filter(Boolean) : request.select;
|
|
949
1180
|
}
|
|
@@ -962,10 +1193,18 @@ var ObjectStackProtocolImplementation = class {
|
|
|
962
1193
|
record: result
|
|
963
1194
|
};
|
|
964
1195
|
}
|
|
965
|
-
|
|
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;
|
|
966
1201
|
}
|
|
967
1202
|
async createData(request) {
|
|
968
|
-
const result = await this.engine.insert(
|
|
1203
|
+
const result = await this.engine.insert(
|
|
1204
|
+
request.object,
|
|
1205
|
+
request.data,
|
|
1206
|
+
request.context !== void 0 ? { context: request.context } : void 0
|
|
1207
|
+
);
|
|
969
1208
|
return {
|
|
970
1209
|
object: request.object,
|
|
971
1210
|
id: result.id,
|
|
@@ -973,7 +1212,9 @@ var ObjectStackProtocolImplementation = class {
|
|
|
973
1212
|
};
|
|
974
1213
|
}
|
|
975
1214
|
async updateData(request) {
|
|
976
|
-
const
|
|
1215
|
+
const opts = { where: { id: request.id } };
|
|
1216
|
+
if (request.context !== void 0) opts.context = request.context;
|
|
1217
|
+
const result = await this.engine.update(request.object, request.data, opts);
|
|
977
1218
|
return {
|
|
978
1219
|
object: request.object,
|
|
979
1220
|
id: request.id,
|
|
@@ -981,7 +1222,9 @@ var ObjectStackProtocolImplementation = class {
|
|
|
981
1222
|
};
|
|
982
1223
|
}
|
|
983
1224
|
async deleteData(request) {
|
|
984
|
-
|
|
1225
|
+
const opts = { where: { id: request.id } };
|
|
1226
|
+
if (request.context !== void 0) opts.context = request.context;
|
|
1227
|
+
await this.engine.delete(request.object, opts);
|
|
985
1228
|
return {
|
|
986
1229
|
object: request.object,
|
|
987
1230
|
id: request.id,
|
|
@@ -989,25 +1232,281 @@ var ObjectStackProtocolImplementation = class {
|
|
|
989
1232
|
};
|
|
990
1233
|
}
|
|
991
1234
|
// ==========================================
|
|
992
|
-
//
|
|
1235
|
+
// Global Search (M10.5)
|
|
993
1236
|
// ==========================================
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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;
|
|
1000
1270
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
+
}
|
|
1007
1343
|
}
|
|
1008
|
-
|
|
1344
|
+
hits.push({
|
|
1345
|
+
object: obj.name,
|
|
1346
|
+
id: row.id,
|
|
1347
|
+
title,
|
|
1348
|
+
snippet,
|
|
1349
|
+
record: row
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
} catch {
|
|
1353
|
+
continue;
|
|
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;
|
|
1009
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);
|
|
1010
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;
|
|
1011
1510
|
if (!item) {
|
|
1012
1511
|
throw new Error(`Metadata item ${request.type}/${request.name} not found`);
|
|
1013
1512
|
}
|
|
@@ -1199,7 +1698,7 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1199
1698
|
};
|
|
1200
1699
|
}
|
|
1201
1700
|
async getAnalyticsMeta(request) {
|
|
1202
|
-
const objects =
|
|
1701
|
+
const objects = this.engine.registry.listItems("object");
|
|
1203
1702
|
const cubeFilter = request?.cube;
|
|
1204
1703
|
const cubes = [];
|
|
1205
1704
|
for (const obj of objects) {
|
|
@@ -1299,71 +1798,192 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1299
1798
|
...request.options
|
|
1300
1799
|
});
|
|
1301
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
|
+
}
|
|
1302
1806
|
async saveMetaItem(request) {
|
|
1303
1807
|
if (!request.item) {
|
|
1304
1808
|
throw new Error("Item data is required");
|
|
1305
1809
|
}
|
|
1306
|
-
|
|
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)` : "")
|
|
1832
|
+
);
|
|
1833
|
+
err.code = "invalid_metadata";
|
|
1834
|
+
err.status = 422;
|
|
1835
|
+
err.issues = issues;
|
|
1836
|
+
throw err;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
if (request.type === "object" || request.type === "objects") {
|
|
1841
|
+
this.engine.registry.registerItem(request.type, request.item, "name");
|
|
1842
|
+
try {
|
|
1843
|
+
this.engine.registry.registerObject(request.item, "sys_metadata");
|
|
1844
|
+
} catch (err) {
|
|
1845
|
+
console.warn(
|
|
1846
|
+
`[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
await this.ensureOverlayIndex();
|
|
1307
1851
|
try {
|
|
1308
1852
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1853
|
+
const orgId = request.organizationId ?? null;
|
|
1854
|
+
const scopedWhere = {
|
|
1855
|
+
type: request.type,
|
|
1856
|
+
name: request.name,
|
|
1857
|
+
organization_id: orgId,
|
|
1858
|
+
state: "active"
|
|
1859
|
+
};
|
|
1309
1860
|
const existing = await this.engine.findOne("sys_metadata", {
|
|
1310
|
-
where:
|
|
1861
|
+
where: scopedWhere
|
|
1311
1862
|
});
|
|
1312
1863
|
if (existing) {
|
|
1313
1864
|
await this.engine.update("sys_metadata", {
|
|
1314
1865
|
metadata: JSON.stringify(request.item),
|
|
1315
1866
|
updated_at: now,
|
|
1316
|
-
version: (existing.version || 0) + 1
|
|
1867
|
+
version: (existing.version || 0) + 1,
|
|
1868
|
+
state: "active"
|
|
1317
1869
|
}, {
|
|
1318
1870
|
where: { id: existing.id }
|
|
1319
1871
|
});
|
|
1320
1872
|
} else {
|
|
1321
1873
|
const id = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
1322
|
-
|
|
1874
|
+
const row = {
|
|
1323
1875
|
id,
|
|
1324
1876
|
name: request.name,
|
|
1325
1877
|
type: request.type,
|
|
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`.
|
|
1326
1881
|
scope: "platform",
|
|
1327
1882
|
metadata: JSON.stringify(request.item),
|
|
1328
1883
|
state: "active",
|
|
1329
1884
|
version: 1,
|
|
1330
1885
|
created_at: now,
|
|
1331
|
-
updated_at: now
|
|
1332
|
-
|
|
1886
|
+
updated_at: now,
|
|
1887
|
+
organization_id: orgId
|
|
1888
|
+
};
|
|
1889
|
+
await this.engine.insert("sys_metadata", row);
|
|
1333
1890
|
}
|
|
1334
1891
|
return {
|
|
1335
1892
|
success: true,
|
|
1336
|
-
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}`
|
|
1337
1894
|
};
|
|
1338
1895
|
} catch (dbError) {
|
|
1339
|
-
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
|
+
}
|
|
1340
1950
|
return {
|
|
1341
1951
|
success: true,
|
|
1342
|
-
|
|
1343
|
-
|
|
1952
|
+
reset: true,
|
|
1953
|
+
message: `Customization overlay deleted \u2014 ${request.type}/${request.name} reset to artifact default.`
|
|
1344
1954
|
};
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
const e = new Error(`Failed to delete customization overlay: ${err.message}`);
|
|
1957
|
+
e.status = 500;
|
|
1958
|
+
throw e;
|
|
1345
1959
|
}
|
|
1346
1960
|
}
|
|
1347
1961
|
/**
|
|
1348
1962
|
* Hydrate SchemaRegistry from the database on startup.
|
|
1349
1963
|
* Loads all active metadata records and registers them in the in-memory registry.
|
|
1350
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.
|
|
1351
1969
|
*/
|
|
1352
1970
|
async loadMetaFromDb() {
|
|
1353
1971
|
let loaded = 0;
|
|
1354
1972
|
let errors = 0;
|
|
1355
1973
|
try {
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1974
|
+
const where = {
|
|
1975
|
+
state: "active",
|
|
1976
|
+
organization_id: null
|
|
1977
|
+
};
|
|
1978
|
+
const records = await this.engine.find("sys_metadata", { where });
|
|
1359
1979
|
for (const record of records) {
|
|
1360
1980
|
try {
|
|
1361
1981
|
const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
1362
1982
|
const normalizedType = PLURAL_TO_SINGULAR[record.type] ?? record.type;
|
|
1363
1983
|
if (normalizedType === "object") {
|
|
1364
|
-
|
|
1984
|
+
this.engine.registry.registerObject(data, record.packageId || "sys_metadata");
|
|
1365
1985
|
} else {
|
|
1366
|
-
|
|
1986
|
+
this.engine.registry.registerItem(normalizedType, data, "name");
|
|
1367
1987
|
}
|
|
1368
1988
|
loaded++;
|
|
1369
1989
|
} catch (e) {
|
|
@@ -1372,7 +1992,9 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1372
1992
|
}
|
|
1373
1993
|
}
|
|
1374
1994
|
} catch (e) {
|
|
1375
|
-
|
|
1995
|
+
if (!/no such table/i.test(e.message ?? "")) {
|
|
1996
|
+
console.warn(`[Protocol] DB hydration skipped: ${e.message}`);
|
|
1997
|
+
}
|
|
1376
1998
|
}
|
|
1377
1999
|
return { loaded, errors };
|
|
1378
2000
|
}
|
|
@@ -1442,78 +2064,846 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1442
2064
|
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
1443
2065
|
return { success: true, data: { feedId: request.feedId, pinned: false } };
|
|
1444
2066
|
}
|
|
1445
|
-
async starFeedItem(request) {
|
|
1446
|
-
const svc = this.requireFeedService();
|
|
1447
|
-
const item = await svc.getFeedItem(request.feedId);
|
|
1448
|
-
if (!item) throw new Error(`Feed item ${request.feedId} not found`);
|
|
1449
|
-
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
1450
|
-
return { success: true, data: { feedId: request.feedId, starred: true, starredAt: (/* @__PURE__ */ new Date()).toISOString() } };
|
|
2067
|
+
async starFeedItem(request) {
|
|
2068
|
+
const svc = this.requireFeedService();
|
|
2069
|
+
const item = await svc.getFeedItem(request.feedId);
|
|
2070
|
+
if (!item) throw new Error(`Feed item ${request.feedId} not found`);
|
|
2071
|
+
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
2072
|
+
return { success: true, data: { feedId: request.feedId, starred: true, starredAt: (/* @__PURE__ */ new Date()).toISOString() } };
|
|
2073
|
+
}
|
|
2074
|
+
async unstarFeedItem(request) {
|
|
2075
|
+
const svc = this.requireFeedService();
|
|
2076
|
+
const item = await svc.getFeedItem(request.feedId);
|
|
2077
|
+
if (!item) throw new Error(`Feed item ${request.feedId} not found`);
|
|
2078
|
+
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
2079
|
+
return { success: true, data: { feedId: request.feedId, starred: false } };
|
|
2080
|
+
}
|
|
2081
|
+
async searchFeed(request) {
|
|
2082
|
+
const svc = this.requireFeedService();
|
|
2083
|
+
const result = await svc.listFeed({
|
|
2084
|
+
object: request.object,
|
|
2085
|
+
recordId: request.recordId,
|
|
2086
|
+
filter: request.type,
|
|
2087
|
+
limit: request.limit,
|
|
2088
|
+
cursor: request.cursor
|
|
2089
|
+
});
|
|
2090
|
+
const queryLower = (request.query || "").toLowerCase();
|
|
2091
|
+
const filtered = result.items.filter(
|
|
2092
|
+
(item) => item.body?.toLowerCase().includes(queryLower)
|
|
2093
|
+
);
|
|
2094
|
+
return { success: true, data: { items: filtered, total: filtered.length, hasMore: false } };
|
|
2095
|
+
}
|
|
2096
|
+
async getChangelog(request) {
|
|
2097
|
+
const svc = this.requireFeedService();
|
|
2098
|
+
const result = await svc.listFeed({
|
|
2099
|
+
object: request.object,
|
|
2100
|
+
recordId: request.recordId,
|
|
2101
|
+
filter: "changes_only",
|
|
2102
|
+
limit: request.limit,
|
|
2103
|
+
cursor: request.cursor
|
|
2104
|
+
});
|
|
2105
|
+
const entries = result.items.map((item) => ({
|
|
2106
|
+
id: item.id,
|
|
2107
|
+
object: item.object,
|
|
2108
|
+
recordId: item.recordId,
|
|
2109
|
+
actor: item.actor,
|
|
2110
|
+
changes: item.changes || [],
|
|
2111
|
+
timestamp: item.createdAt,
|
|
2112
|
+
source: item.source
|
|
2113
|
+
}));
|
|
2114
|
+
return { success: true, data: { entries, total: result.total, nextCursor: result.nextCursor, hasMore: result.hasMore } };
|
|
2115
|
+
}
|
|
2116
|
+
async feedSubscribe(request) {
|
|
2117
|
+
const svc = this.requireFeedService();
|
|
2118
|
+
const subscription = await svc.subscribe({
|
|
2119
|
+
object: request.object,
|
|
2120
|
+
recordId: request.recordId,
|
|
2121
|
+
userId: "current_user",
|
|
2122
|
+
events: request.events,
|
|
2123
|
+
channels: request.channels
|
|
2124
|
+
});
|
|
2125
|
+
return { success: true, data: subscription };
|
|
2126
|
+
}
|
|
2127
|
+
async feedUnsubscribe(request) {
|
|
2128
|
+
const svc = this.requireFeedService();
|
|
2129
|
+
const unsubscribed = await svc.unsubscribe(request.object, request.recordId, "current_user");
|
|
2130
|
+
return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } };
|
|
2131
|
+
}
|
|
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;
|
|
2154
|
+
|
|
2155
|
+
// src/engine.ts
|
|
2156
|
+
import { ExecutionContextSchema } from "@objectstack/spec/kernel";
|
|
2157
|
+
import { createLogger } from "@objectstack/core";
|
|
2158
|
+
import { CoreServiceName, StorageNameMapping } from "@objectstack/spec/system";
|
|
2159
|
+
import { pluralToSingular } from "@objectstack/spec/shared";
|
|
2160
|
+
import { ExpressionEngine as ExpressionEngine2 } from "@objectstack/formula";
|
|
2161
|
+
|
|
2162
|
+
// src/hook-wrappers.ts
|
|
2163
|
+
import { ExpressionEngine } from "@objectstack/formula";
|
|
2164
|
+
|
|
2165
|
+
// src/hook-metrics.ts
|
|
2166
|
+
var noopHookMetricsRecorder = {
|
|
2167
|
+
recordExecution: () => {
|
|
2168
|
+
},
|
|
2169
|
+
recordSkip: () => {
|
|
2170
|
+
},
|
|
2171
|
+
recordRetry: () => {
|
|
2172
|
+
}
|
|
2173
|
+
};
|
|
2174
|
+
var InMemoryHookMetricsRecorder = class {
|
|
2175
|
+
constructor() {
|
|
2176
|
+
this.executions = /* @__PURE__ */ new Map();
|
|
2177
|
+
this.skips = /* @__PURE__ */ new Map();
|
|
2178
|
+
this.retries = /* @__PURE__ */ new Map();
|
|
2179
|
+
}
|
|
2180
|
+
recordExecution(label, outcome, durationMs) {
|
|
2181
|
+
const key = `${label.hook}|${outcome}`;
|
|
2182
|
+
const cur = this.executions.get(key) ?? { count: 0, totalMs: 0 };
|
|
2183
|
+
cur.count += 1;
|
|
2184
|
+
cur.totalMs += Math.max(0, durationMs);
|
|
2185
|
+
this.executions.set(key, cur);
|
|
2186
|
+
}
|
|
2187
|
+
recordSkip(label, reason) {
|
|
2188
|
+
const key = `${label.hook}|${reason}`;
|
|
2189
|
+
this.skips.set(key, (this.skips.get(key) ?? 0) + 1);
|
|
2190
|
+
}
|
|
2191
|
+
recordRetry(label, _attempt) {
|
|
2192
|
+
this.retries.set(label.hook, (this.retries.get(label.hook) ?? 0) + 1);
|
|
2193
|
+
}
|
|
2194
|
+
snapshot() {
|
|
2195
|
+
return {
|
|
2196
|
+
executions: Array.from(this.executions, ([key, v]) => {
|
|
2197
|
+
const [hook, outcome] = key.split("|");
|
|
2198
|
+
return { hook, outcome, count: v.count, totalMs: v.totalMs };
|
|
2199
|
+
}),
|
|
2200
|
+
skips: Array.from(this.skips, ([key, count]) => {
|
|
2201
|
+
const [hook, reason] = key.split("|");
|
|
2202
|
+
return { hook, reason, count };
|
|
2203
|
+
}),
|
|
2204
|
+
retries: Array.from(this.retries, ([hook, count]) => ({ hook, count }))
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
reset() {
|
|
2208
|
+
this.executions.clear();
|
|
2209
|
+
this.skips.clear();
|
|
2210
|
+
this.retries.clear();
|
|
2211
|
+
}
|
|
2212
|
+
};
|
|
2213
|
+
|
|
2214
|
+
// src/hook-wrappers.ts
|
|
2215
|
+
var noopLogger = {
|
|
2216
|
+
debug: () => {
|
|
2217
|
+
},
|
|
2218
|
+
info: () => {
|
|
2219
|
+
},
|
|
2220
|
+
warn: () => {
|
|
2221
|
+
},
|
|
2222
|
+
error: () => {
|
|
2223
|
+
}
|
|
2224
|
+
};
|
|
2225
|
+
function wrapDeclarativeHook(meta, handler, opts = {}) {
|
|
2226
|
+
const logger = opts.logger ?? noopLogger;
|
|
2227
|
+
const metrics = opts.metrics ?? noopHookMetricsRecorder;
|
|
2228
|
+
const isAfterEvent = meta.events?.some((e) => typeof e === "string" && e.startsWith("after")) ?? false;
|
|
2229
|
+
const hasBody = Boolean(meta.body);
|
|
2230
|
+
const labelFor = (ctx) => ({
|
|
2231
|
+
hook: meta.name,
|
|
2232
|
+
object: ctx.object ?? (typeof meta.object === "string" ? meta.object : void 0),
|
|
2233
|
+
event: ctx.event,
|
|
2234
|
+
body: hasBody
|
|
2235
|
+
});
|
|
2236
|
+
let conditionFn;
|
|
2237
|
+
if (meta.condition) {
|
|
2238
|
+
const expr = typeof meta.condition === "string" ? { dialect: "cel", source: meta.condition } : meta.condition;
|
|
2239
|
+
if (expr.source && expr.source.trim()) {
|
|
2240
|
+
const check = ExpressionEngine.compile(expr);
|
|
2241
|
+
if (check.ok) {
|
|
2242
|
+
conditionFn = (record) => {
|
|
2243
|
+
const r = ExpressionEngine.evaluate(expr, { record: record ?? {} });
|
|
2244
|
+
if (!r.ok) {
|
|
2245
|
+
logger.warn("[hook] condition evaluation failed; treating as false", {
|
|
2246
|
+
hook: meta.name,
|
|
2247
|
+
condition: expr.source,
|
|
2248
|
+
error: r.error.message
|
|
2249
|
+
});
|
|
2250
|
+
return false;
|
|
2251
|
+
}
|
|
2252
|
+
return Boolean(r.value);
|
|
2253
|
+
};
|
|
2254
|
+
} else {
|
|
2255
|
+
logger.warn("[hook] condition formula failed to compile; condition ignored", {
|
|
2256
|
+
hook: meta.name,
|
|
2257
|
+
condition: expr.source,
|
|
2258
|
+
error: check.error.message
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
const retryMax = Math.max(0, Number(meta.retryPolicy?.maxRetries ?? 0));
|
|
2264
|
+
const retryBackoffMs = Math.max(0, Number(meta.retryPolicy?.backoffMs ?? 0));
|
|
2265
|
+
const timeoutMs = typeof meta.timeout === "number" && meta.timeout > 0 ? meta.timeout : void 0;
|
|
2266
|
+
const onError = meta.onError ?? "abort";
|
|
2267
|
+
const fireAndForget = Boolean(meta.async) && isAfterEvent;
|
|
2268
|
+
const runWithTimeout = async (ctx) => {
|
|
2269
|
+
if (!timeoutMs) {
|
|
2270
|
+
await handler(ctx);
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
let timer;
|
|
2274
|
+
try {
|
|
2275
|
+
await Promise.race([
|
|
2276
|
+
Promise.resolve().then(() => handler(ctx)),
|
|
2277
|
+
new Promise((_, reject) => {
|
|
2278
|
+
timer = setTimeout(() => {
|
|
2279
|
+
reject(new Error(`Hook '${meta.name}' timed out after ${timeoutMs}ms`));
|
|
2280
|
+
}, timeoutMs);
|
|
2281
|
+
})
|
|
2282
|
+
]);
|
|
2283
|
+
} finally {
|
|
2284
|
+
if (timer) clearTimeout(timer);
|
|
2285
|
+
}
|
|
2286
|
+
};
|
|
2287
|
+
const runWithRetry = async (ctx) => {
|
|
2288
|
+
let attempt = 0;
|
|
2289
|
+
let lastErr;
|
|
2290
|
+
while (attempt <= retryMax) {
|
|
2291
|
+
try {
|
|
2292
|
+
await runWithTimeout(ctx);
|
|
2293
|
+
return;
|
|
2294
|
+
} catch (err) {
|
|
2295
|
+
lastErr = err;
|
|
2296
|
+
attempt += 1;
|
|
2297
|
+
if (attempt > retryMax) break;
|
|
2298
|
+
if (retryBackoffMs > 0) {
|
|
2299
|
+
await new Promise((r) => setTimeout(r, retryBackoffMs * attempt));
|
|
2300
|
+
}
|
|
2301
|
+
try {
|
|
2302
|
+
metrics.recordRetry(labelFor(ctx), attempt);
|
|
2303
|
+
} catch {
|
|
2304
|
+
}
|
|
2305
|
+
logger.warn("[hook] retrying after failure", {
|
|
2306
|
+
hook: meta.name,
|
|
2307
|
+
attempt,
|
|
2308
|
+
maxRetries: retryMax,
|
|
2309
|
+
error: err?.message
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
throw lastErr;
|
|
2314
|
+
};
|
|
2315
|
+
const runWithErrorPolicy = async (ctx) => {
|
|
2316
|
+
try {
|
|
2317
|
+
await runWithRetry(ctx);
|
|
2318
|
+
} catch (err) {
|
|
2319
|
+
if (onError === "log") {
|
|
2320
|
+
logger.error("[hook] handler failed (onError=log; suppressing)", {
|
|
2321
|
+
hook: meta.name,
|
|
2322
|
+
object: ctx.object,
|
|
2323
|
+
event: ctx.event,
|
|
2324
|
+
error: err?.message
|
|
2325
|
+
});
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
throw err;
|
|
2329
|
+
}
|
|
2330
|
+
};
|
|
2331
|
+
return async (ctx) => {
|
|
2332
|
+
if (conditionFn) {
|
|
2333
|
+
const record = pickRecordPayload(ctx);
|
|
2334
|
+
if (!conditionFn(record)) {
|
|
2335
|
+
logger.debug("[hook] skipped by condition", {
|
|
2336
|
+
hook: meta.name,
|
|
2337
|
+
object: ctx.object,
|
|
2338
|
+
event: ctx.event
|
|
2339
|
+
});
|
|
2340
|
+
try {
|
|
2341
|
+
metrics.recordSkip(labelFor(ctx), "condition");
|
|
2342
|
+
} catch {
|
|
2343
|
+
}
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
const restore = installFlatInput(ctx);
|
|
2348
|
+
const startedAt = Date.now();
|
|
2349
|
+
const recordOutcome = (err) => {
|
|
2350
|
+
const elapsed = Date.now() - startedAt;
|
|
2351
|
+
let outcome = "success";
|
|
2352
|
+
if (err) {
|
|
2353
|
+
const msg = String(err?.message ?? err ?? "");
|
|
2354
|
+
if (/timed out after/i.test(msg)) outcome = "timeout";
|
|
2355
|
+
else if (/capability|cap-rejection|capability_rejected/i.test(msg)) outcome = "capability_rejected";
|
|
2356
|
+
else outcome = "error";
|
|
2357
|
+
}
|
|
2358
|
+
try {
|
|
2359
|
+
metrics.recordExecution(labelFor(ctx), outcome, elapsed);
|
|
2360
|
+
} catch {
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
try {
|
|
2364
|
+
if (fireAndForget) {
|
|
2365
|
+
try {
|
|
2366
|
+
metrics.recordSkip(labelFor(ctx), "fire_and_forget");
|
|
2367
|
+
} catch {
|
|
2368
|
+
}
|
|
2369
|
+
void runWithErrorPolicy(ctx).then(() => recordOutcome()).catch((err) => {
|
|
2370
|
+
recordOutcome(err);
|
|
2371
|
+
logger.error("[hook] async handler error (fire-and-forget)", {
|
|
2372
|
+
hook: meta.name,
|
|
2373
|
+
error: err?.message
|
|
2374
|
+
});
|
|
2375
|
+
});
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
try {
|
|
2379
|
+
await runWithErrorPolicy(ctx);
|
|
2380
|
+
recordOutcome();
|
|
2381
|
+
} catch (err) {
|
|
2382
|
+
recordOutcome(err);
|
|
2383
|
+
throw err;
|
|
2384
|
+
}
|
|
2385
|
+
} finally {
|
|
2386
|
+
restore();
|
|
2387
|
+
}
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
function installFlatInput(ctx) {
|
|
2391
|
+
const raw = ctx.input ?? {};
|
|
2392
|
+
const looksWrapped = raw && typeof raw === "object" && ("data" in raw || "options" in raw || "id" in raw || "ast" in raw);
|
|
2393
|
+
if (!looksWrapped) return () => {
|
|
2394
|
+
};
|
|
2395
|
+
const ensureData = () => {
|
|
2396
|
+
if (!raw.data || typeof raw.data !== "object") {
|
|
2397
|
+
raw.data = {};
|
|
2398
|
+
}
|
|
2399
|
+
return raw.data;
|
|
2400
|
+
};
|
|
2401
|
+
const proxy = new Proxy(raw, {
|
|
2402
|
+
get(target, prop, receiver) {
|
|
2403
|
+
if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
|
|
2404
|
+
return Reflect.get(target, prop, receiver);
|
|
2405
|
+
}
|
|
2406
|
+
const data = target.data;
|
|
2407
|
+
if (data && typeof data === "object" && prop in data) {
|
|
2408
|
+
return data[prop];
|
|
2409
|
+
}
|
|
2410
|
+
return Reflect.get(target, prop, receiver);
|
|
2411
|
+
},
|
|
2412
|
+
set(target, prop, value) {
|
|
2413
|
+
if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
|
|
2414
|
+
target[prop] = value;
|
|
2415
|
+
return true;
|
|
2416
|
+
}
|
|
2417
|
+
ensureData()[prop] = value;
|
|
2418
|
+
return true;
|
|
2419
|
+
},
|
|
2420
|
+
has(target, prop) {
|
|
2421
|
+
if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
|
|
2422
|
+
return prop in target;
|
|
2423
|
+
}
|
|
2424
|
+
const data = target.data;
|
|
2425
|
+
if (data && typeof data === "object" && prop in data) return true;
|
|
2426
|
+
return prop in target;
|
|
2427
|
+
},
|
|
2428
|
+
ownKeys(target) {
|
|
2429
|
+
const dataKeys = target.data && typeof target.data === "object" ? Object.keys(target.data) : [];
|
|
2430
|
+
return Array.from(new Set(dataKeys));
|
|
2431
|
+
},
|
|
2432
|
+
getOwnPropertyDescriptor(target, prop) {
|
|
2433
|
+
const data = target.data;
|
|
2434
|
+
if (data && typeof data === "object" && prop in data) {
|
|
2435
|
+
return { configurable: true, enumerable: true, writable: true, value: data[prop] };
|
|
2436
|
+
}
|
|
2437
|
+
if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
|
|
2438
|
+
const desc = Object.getOwnPropertyDescriptor(target, prop);
|
|
2439
|
+
return desc ? { ...desc, enumerable: false } : void 0;
|
|
2440
|
+
}
|
|
2441
|
+
return Object.getOwnPropertyDescriptor(target, prop);
|
|
2442
|
+
}
|
|
2443
|
+
});
|
|
2444
|
+
ctx.input = proxy;
|
|
2445
|
+
return () => {
|
|
2446
|
+
ctx.input = raw;
|
|
2447
|
+
};
|
|
2448
|
+
}
|
|
2449
|
+
function pickRecordPayload(ctx) {
|
|
2450
|
+
const input = ctx.input ?? {};
|
|
2451
|
+
if (input && typeof input === "object" && input.data && typeof input.data === "object") {
|
|
2452
|
+
return input.data;
|
|
2453
|
+
}
|
|
2454
|
+
if (ctx.previous && typeof ctx.previous === "object") {
|
|
2455
|
+
return ctx.previous;
|
|
2456
|
+
}
|
|
2457
|
+
return input;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
// src/hook-binder.ts
|
|
2461
|
+
var noopLogger2 = {
|
|
2462
|
+
debug: () => {
|
|
2463
|
+
},
|
|
2464
|
+
info: () => {
|
|
2465
|
+
},
|
|
2466
|
+
warn: () => {
|
|
2467
|
+
},
|
|
2468
|
+
error: () => {
|
|
2469
|
+
}
|
|
2470
|
+
};
|
|
2471
|
+
function bindHooksToEngine(engine, hooks, opts = {}) {
|
|
2472
|
+
const logger = opts.logger ?? noopLogger2;
|
|
2473
|
+
const result = { registered: 0, skipped: 0, errors: [] };
|
|
2474
|
+
if (!Array.isArray(hooks) || hooks.length === 0) {
|
|
2475
|
+
return result;
|
|
2476
|
+
}
|
|
2477
|
+
if (opts.packageId && typeof engine.unregisterHooksByPackage === "function") {
|
|
2478
|
+
try {
|
|
2479
|
+
engine.unregisterHooksByPackage(opts.packageId);
|
|
2480
|
+
} catch (err) {
|
|
2481
|
+
logger.warn("[hook-binder] unregister-by-package failed; continuing", {
|
|
2482
|
+
packageId: opts.packageId,
|
|
2483
|
+
error: err?.message
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
if (opts.functions && typeof engine.registerFunction === "function") {
|
|
2488
|
+
for (const [name, fn] of Object.entries(opts.functions)) {
|
|
2489
|
+
try {
|
|
2490
|
+
engine.registerFunction(name, fn, opts.packageId);
|
|
2491
|
+
} catch (err) {
|
|
2492
|
+
logger.warn("[hook-binder] failed to register function", {
|
|
2493
|
+
name,
|
|
2494
|
+
error: err?.message
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
for (const hook of hooks) {
|
|
2500
|
+
try {
|
|
2501
|
+
const resolved = resolveHandler(engine, hook, opts);
|
|
2502
|
+
if (!resolved) {
|
|
2503
|
+
result.skipped += 1;
|
|
2504
|
+
const reason = hook.body ? `hook body present but no bodyRunner supplied to bindHooksToEngine (runtime must wire QuickJSScriptRunner)` : typeof hook.handler === "string" ? `unknown function '${hook.handler}'` : "no handler";
|
|
2505
|
+
result.errors.push({ hook: hook.name, reason });
|
|
2506
|
+
if (opts.strict) {
|
|
2507
|
+
throw new Error(`[hook-binder] strict: cannot bind hook '${hook.name}': ${reason}`);
|
|
2508
|
+
}
|
|
2509
|
+
logger.warn("[hook-binder] skipping hook with unresolved handler", {
|
|
2510
|
+
hook: hook.name,
|
|
2511
|
+
handler: hook.handler,
|
|
2512
|
+
hasBody: Boolean(hook.body)
|
|
2513
|
+
});
|
|
2514
|
+
continue;
|
|
2515
|
+
}
|
|
2516
|
+
if (opts.warnLegacyHandler && !hook.body && typeof hook.handler === "string") {
|
|
2517
|
+
logger.warn("[hook-binder] DEPRECATED: hook uses legacy handler ref without body", {
|
|
2518
|
+
hook: hook.name,
|
|
2519
|
+
handler: hook.handler,
|
|
2520
|
+
hint: "Move the handler source into Hook.body so the artifact stays metadata-only and the .mjs runtime bundle can be dropped."
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
const wrapped = wrapDeclarativeHook(hook, resolved, { logger, metrics: opts.metrics });
|
|
2524
|
+
const objects = normalizeObjects(hook.object);
|
|
2525
|
+
const events = Array.isArray(hook.events) ? hook.events : [];
|
|
2526
|
+
for (const event of events) {
|
|
2527
|
+
for (const object of objects) {
|
|
2528
|
+
engine.registerHook(event, wrapped, {
|
|
2529
|
+
object,
|
|
2530
|
+
priority: typeof hook.priority === "number" ? hook.priority : 100,
|
|
2531
|
+
packageId: opts.packageId,
|
|
2532
|
+
// Reflect metadata so future tooling can introspect / unregister
|
|
2533
|
+
// and so we can detect duplicate name collisions.
|
|
2534
|
+
// The engine ignores unknown options today; this is forward-only.
|
|
2535
|
+
...{ meta: hook, hookName: hook.name }
|
|
2536
|
+
});
|
|
2537
|
+
result.registered += 1;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
} catch (err) {
|
|
2541
|
+
result.errors.push({ hook: hook.name, reason: err?.message ?? String(err) });
|
|
2542
|
+
logger.error("[hook-binder] failed to bind hook", {
|
|
2543
|
+
hook: hook.name,
|
|
2544
|
+
error: err?.message
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
if (result.registered > 0) {
|
|
2549
|
+
logger.debug("[hook-binder] hooks bound", {
|
|
2550
|
+
packageId: opts.packageId,
|
|
2551
|
+
registered: result.registered,
|
|
2552
|
+
skipped: result.skipped
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
return result;
|
|
2556
|
+
}
|
|
2557
|
+
function normalizeObjects(target) {
|
|
2558
|
+
if (Array.isArray(target)) return target.length > 0 ? target : ["*"];
|
|
2559
|
+
if (typeof target === "string" && target.length > 0) return [target];
|
|
2560
|
+
return ["*"];
|
|
2561
|
+
}
|
|
2562
|
+
function resolveHandler(engine, hook, opts) {
|
|
2563
|
+
const body = hook.body;
|
|
2564
|
+
if (body && typeof body === "object") {
|
|
2565
|
+
let runner = opts.bodyRunner;
|
|
2566
|
+
if (typeof runner !== "function") {
|
|
2567
|
+
const fallback = engine?._defaultBodyRunner;
|
|
2568
|
+
if (typeof fallback === "function") runner = fallback;
|
|
2569
|
+
}
|
|
2570
|
+
if (typeof runner !== "function") {
|
|
2571
|
+
return void 0;
|
|
2572
|
+
}
|
|
2573
|
+
const fn = runner(hook);
|
|
2574
|
+
if (typeof fn === "function") return fn;
|
|
2575
|
+
return void 0;
|
|
2576
|
+
}
|
|
2577
|
+
const h = hook.handler;
|
|
2578
|
+
if (typeof h === "function") return h;
|
|
2579
|
+
if (typeof h === "string" && h.length > 0) {
|
|
2580
|
+
const fromBundle = opts.functions?.[h];
|
|
2581
|
+
if (typeof fromBundle === "function") return fromBundle;
|
|
2582
|
+
if (typeof engine.resolveFunction === "function") {
|
|
2583
|
+
const fn = engine.resolveFunction(h);
|
|
2584
|
+
if (typeof fn === "function") return fn;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
return void 0;
|
|
2588
|
+
}
|
|
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` };
|
|
1451
2664
|
}
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
1457
|
-
return { success: true, data: { feedId: request.feedId, starred: false } };
|
|
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)` };
|
|
1458
2669
|
}
|
|
1459
|
-
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
limit: request.limit,
|
|
1466
|
-
cursor: request.cursor
|
|
1467
|
-
});
|
|
1468
|
-
const queryLower = (request.query || "").toLowerCase();
|
|
1469
|
-
const filtered = result.items.filter(
|
|
1470
|
-
(item) => item.body?.toLowerCase().includes(queryLower)
|
|
1471
|
-
);
|
|
1472
|
-
return { success: true, data: { items: filtered, total: filtered.length, hasMore: false } };
|
|
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;
|
|
1473
2676
|
}
|
|
1474
|
-
|
|
1475
|
-
const
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
id: item.id,
|
|
1485
|
-
object: item.object,
|
|
1486
|
-
recordId: item.recordId,
|
|
1487
|
-
actor: item.actor,
|
|
1488
|
-
changes: item.changes || [],
|
|
1489
|
-
timestamp: item.createdAt,
|
|
1490
|
-
source: item.source
|
|
1491
|
-
}));
|
|
1492
|
-
return { success: true, data: { entries, total: result.total, nextCursor: result.nextCursor, hasMore: result.hasMore } };
|
|
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;
|
|
1493
2687
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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);
|
|
1504
2752
|
}
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
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
|
+
}
|
|
1509
2808
|
}
|
|
1510
|
-
|
|
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
|
+
}
|
|
1511
2858
|
|
|
1512
2859
|
// src/engine.ts
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
2860
|
+
function planFormulaProjection(schema, requestedFields) {
|
|
2861
|
+
if (!schema?.fields) return { plan: [] };
|
|
2862
|
+
const allFieldNames = Object.keys(schema.fields);
|
|
2863
|
+
const targets = Array.isArray(requestedFields) && requestedFields.length > 0 ? requestedFields : allFieldNames;
|
|
2864
|
+
const plan = [];
|
|
2865
|
+
const projected = /* @__PURE__ */ new Set();
|
|
2866
|
+
for (const f of targets) {
|
|
2867
|
+
const def = schema.fields[f];
|
|
2868
|
+
if (def?.type === "formula" && def.expression) {
|
|
2869
|
+
const expr = typeof def.expression === "string" ? { dialect: "cel", source: def.expression } : def.expression;
|
|
2870
|
+
plan.push({ name: f, expression: expr });
|
|
2871
|
+
ExpressionEngine2.compile(expr);
|
|
2872
|
+
} else if (Array.isArray(requestedFields) && requestedFields.length > 0) {
|
|
2873
|
+
projected.add(f);
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
if (plan.length === 0) return { plan: [] };
|
|
2877
|
+
if (Array.isArray(requestedFields) && requestedFields.length > 0) {
|
|
2878
|
+
if (!projected.has("id")) projected.add("id");
|
|
2879
|
+
for (const fname of allFieldNames) {
|
|
2880
|
+
const fdef = schema.fields[fname];
|
|
2881
|
+
if (fdef?.type === "formula") continue;
|
|
2882
|
+
projected.add(fname);
|
|
2883
|
+
}
|
|
2884
|
+
return { plan, projected: Array.from(projected) };
|
|
2885
|
+
}
|
|
2886
|
+
return { plan };
|
|
2887
|
+
}
|
|
2888
|
+
function applyFormulaPlan(plan, records) {
|
|
2889
|
+
if (!plan.length) return;
|
|
2890
|
+
for (const rec of records) {
|
|
2891
|
+
if (rec == null) continue;
|
|
2892
|
+
for (const fp of plan) {
|
|
2893
|
+
const r = ExpressionEngine2.evaluate(fp.expression, { record: rec });
|
|
2894
|
+
rec[fp.name] = r.ok ? r.value : null;
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
function resolveMetadataItemName(key, item) {
|
|
2899
|
+
if (!item) return void 0;
|
|
2900
|
+
if (item.name) return item.name;
|
|
2901
|
+
if (item.id) return item.id;
|
|
2902
|
+
if (key === "views") {
|
|
2903
|
+
return item?.list?.data?.object || item?.form?.data?.object || void 0;
|
|
2904
|
+
}
|
|
2905
|
+
return void 0;
|
|
2906
|
+
}
|
|
1517
2907
|
var _ObjectQL = class _ObjectQL {
|
|
1518
2908
|
constructor(hostContext = {}) {
|
|
1519
2909
|
this.drivers = /* @__PURE__ */ new Map();
|
|
@@ -1537,10 +2927,29 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1537
2927
|
this.middlewares = [];
|
|
1538
2928
|
// Action registry: key = "objectName:actionName"
|
|
1539
2929
|
this.actions = /* @__PURE__ */ new Map();
|
|
2930
|
+
// Function registry: name → handler. Used by `bindHooksToEngine` to
|
|
2931
|
+
// resolve string-named hook handlers (the JSON-safe form). Populated by
|
|
2932
|
+
// `defineStack({ functions })` via `AppPlugin`, or directly via
|
|
2933
|
+
// `engine.registerFunction(...)`.
|
|
2934
|
+
this.functions = /* @__PURE__ */ new Map();
|
|
1540
2935
|
// Host provided context additions (e.g. Server router)
|
|
1541
2936
|
this.hostContext = {};
|
|
2937
|
+
// Per-engine SchemaRegistry instance.
|
|
2938
|
+
//
|
|
2939
|
+
// Historically SchemaRegistry was a process-wide singleton of static state,
|
|
2940
|
+
// which broke multi-project servers: a project kernel would inherit every
|
|
2941
|
+
// object registered by the control plane (e.g. sys_metadata), and
|
|
2942
|
+
// getDriver()'s owner lookup would route CRUD to the wrong database. Each
|
|
2943
|
+
// engine now owns its registry so kernels are fully isolated.
|
|
2944
|
+
this._registry = new SchemaRegistry();
|
|
1542
2945
|
this.hostContext = hostContext;
|
|
1543
2946
|
this.logger = hostContext.logger || createLogger({ level: "info", format: "pretty" });
|
|
2947
|
+
if (process?.env?.OBJECTQL_STRICT_HOOKS === "1") {
|
|
2948
|
+
this._strictHookBinding = true;
|
|
2949
|
+
}
|
|
2950
|
+
if (process?.env?.OBJECTQL_WARN_LEGACY_HANDLER === "1") {
|
|
2951
|
+
this._warnLegacyHandler = true;
|
|
2952
|
+
}
|
|
1544
2953
|
this.logger.info("ObjectQL Engine Instance Created");
|
|
1545
2954
|
}
|
|
1546
2955
|
/**
|
|
@@ -1556,10 +2965,13 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1556
2965
|
};
|
|
1557
2966
|
}
|
|
1558
2967
|
/**
|
|
1559
|
-
* Expose the SchemaRegistry for plugins to register metadata
|
|
2968
|
+
* Expose the SchemaRegistry for plugins to register metadata.
|
|
2969
|
+
*
|
|
2970
|
+
* Returns the per-engine instance, NOT the class. Each ObjectQL engine
|
|
2971
|
+
* owns its registry so multi-project kernels remain isolated.
|
|
1560
2972
|
*/
|
|
1561
2973
|
get registry() {
|
|
1562
|
-
return
|
|
2974
|
+
return this._registry;
|
|
1563
2975
|
}
|
|
1564
2976
|
/**
|
|
1565
2977
|
* Load and Register a Plugin
|
|
@@ -1605,11 +3017,121 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1605
3017
|
handler,
|
|
1606
3018
|
object: options?.object,
|
|
1607
3019
|
priority: options?.priority ?? 100,
|
|
1608
|
-
packageId: options?.packageId
|
|
3020
|
+
packageId: options?.packageId,
|
|
3021
|
+
meta: options?.meta,
|
|
3022
|
+
hookName: options?.hookName
|
|
1609
3023
|
});
|
|
1610
3024
|
entries.sort((a, b) => a.priority - b.priority);
|
|
1611
3025
|
this.logger.debug("Registered hook", { event, object: options?.object, priority: options?.priority ?? 100, totalHandlers: entries.length });
|
|
1612
3026
|
}
|
|
3027
|
+
/**
|
|
3028
|
+
* Remove all hooks registered under a given `packageId`. Used by
|
|
3029
|
+
* `bindHooksToEngine` to make re-binding (hot reload, app reinstall)
|
|
3030
|
+
* idempotent, and by app uninstall flows.
|
|
3031
|
+
*/
|
|
3032
|
+
unregisterHooksByPackage(packageId) {
|
|
3033
|
+
if (!packageId) return 0;
|
|
3034
|
+
let removed = 0;
|
|
3035
|
+
for (const [event, entries] of this.hooks.entries()) {
|
|
3036
|
+
const before = entries.length;
|
|
3037
|
+
const kept = entries.filter((e) => e.packageId !== packageId);
|
|
3038
|
+
if (kept.length !== before) {
|
|
3039
|
+
this.hooks.set(event, kept);
|
|
3040
|
+
removed += before - kept.length;
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
if (removed > 0) {
|
|
3044
|
+
this.logger.debug("Unregistered hooks by package", { packageId, removed });
|
|
3045
|
+
}
|
|
3046
|
+
return removed;
|
|
3047
|
+
}
|
|
3048
|
+
/**
|
|
3049
|
+
* Register a named function handler that can later be referenced by
|
|
3050
|
+
* string from a `Hook.handler` field. This is the JSON-safe form of
|
|
3051
|
+
* handler binding — declarative metadata persisted to disk or shipped
|
|
3052
|
+
* over the wire only carries the name.
|
|
3053
|
+
*/
|
|
3054
|
+
registerFunction(name, handler, packageId) {
|
|
3055
|
+
if (!name || typeof handler !== "function") return;
|
|
3056
|
+
this.functions.set(name, { handler, packageId });
|
|
3057
|
+
this.logger.debug("Registered function", { name, packageId });
|
|
3058
|
+
}
|
|
3059
|
+
/** Look up a registered function by name. */
|
|
3060
|
+
resolveFunction(name) {
|
|
3061
|
+
return this.functions.get(name)?.handler;
|
|
3062
|
+
}
|
|
3063
|
+
/** Remove all functions registered under a given `packageId`. */
|
|
3064
|
+
unregisterFunctionsByPackage(packageId) {
|
|
3065
|
+
if (!packageId) return 0;
|
|
3066
|
+
let removed = 0;
|
|
3067
|
+
for (const [name, entry] of this.functions.entries()) {
|
|
3068
|
+
if (entry.packageId === packageId) {
|
|
3069
|
+
this.functions.delete(name);
|
|
3070
|
+
removed += 1;
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
if (removed > 0) {
|
|
3074
|
+
this.logger.debug("Unregistered functions by package", { packageId, removed });
|
|
3075
|
+
}
|
|
3076
|
+
return removed;
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Bind a list of declarative `Hook` metadata definitions to this engine.
|
|
3080
|
+
*
|
|
3081
|
+
* Convenience proxy to the canonical `bindHooksToEngine` so callers do
|
|
3082
|
+
* not need a separate import. Use `import { bindHooksToEngine } from
|
|
3083
|
+
* '@objectstack/objectql'` directly when you want the result object.
|
|
3084
|
+
*/
|
|
3085
|
+
bindHooks(hooks, opts) {
|
|
3086
|
+
const merged = { ...opts ?? {}, logger: this.logger };
|
|
3087
|
+
if (!merged.bodyRunner && this._defaultBodyRunner) {
|
|
3088
|
+
merged.bodyRunner = this._defaultBodyRunner;
|
|
3089
|
+
}
|
|
3090
|
+
if (merged.strict === void 0 && this._strictHookBinding) {
|
|
3091
|
+
merged.strict = true;
|
|
3092
|
+
}
|
|
3093
|
+
if (merged.warnLegacyHandler === void 0 && this._warnLegacyHandler) {
|
|
3094
|
+
merged.warnLegacyHandler = true;
|
|
3095
|
+
}
|
|
3096
|
+
if (!merged.metrics && this._hookMetricsRecorder) {
|
|
3097
|
+
merged.metrics = this._hookMetricsRecorder;
|
|
3098
|
+
}
|
|
3099
|
+
bindHooksToEngine(this, hooks, merged);
|
|
3100
|
+
}
|
|
3101
|
+
/**
|
|
3102
|
+
* Install a default body-runner used when `bindHooks` is called without
|
|
3103
|
+
* an explicit one. The runtime layer sets this once on each per-project
|
|
3104
|
+
* engine so every binding path (template seed, metadata sync, AppPlugin)
|
|
3105
|
+
* can execute hook `body.source` consistently.
|
|
3106
|
+
*/
|
|
3107
|
+
setDefaultBodyRunner(runner) {
|
|
3108
|
+
this._defaultBodyRunner = runner;
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* Toggle strict hook-binding mode for this engine. When enabled, every
|
|
3112
|
+
* subsequent `bindHooks` call rejects on the first unresolved hook
|
|
3113
|
+
* instead of silently warning. Production runtimes should enable this.
|
|
3114
|
+
*/
|
|
3115
|
+
setStrictHookBinding(strict) {
|
|
3116
|
+
this._strictHookBinding = strict;
|
|
3117
|
+
}
|
|
3118
|
+
/** Toggle deprecation warnings for hooks still using legacy `handler` ref. */
|
|
3119
|
+
setWarnLegacyHandler(warn) {
|
|
3120
|
+
this._warnLegacyHandler = warn;
|
|
3121
|
+
}
|
|
3122
|
+
/**
|
|
3123
|
+
* Install a metrics recorder used by every subsequent `bindHooks` call.
|
|
3124
|
+
* The recorder's methods are invoked per-execution to count outcomes
|
|
3125
|
+
* (success / error / timeout / capability_rejected), skips, and retries.
|
|
3126
|
+
* Defaults to no-op so the engine pays zero cost when nobody is observing.
|
|
3127
|
+
*/
|
|
3128
|
+
setHookMetricsRecorder(recorder) {
|
|
3129
|
+
this._hookMetricsRecorder = recorder;
|
|
3130
|
+
}
|
|
3131
|
+
/** Read the engine's installed metrics recorder, if any. */
|
|
3132
|
+
getHookMetricsRecorder() {
|
|
3133
|
+
return this._hookMetricsRecorder;
|
|
3134
|
+
}
|
|
1613
3135
|
async triggerHooks(event, context) {
|
|
1614
3136
|
const entries = this.hooks.get(event) || [];
|
|
1615
3137
|
if (entries.length === 0) {
|
|
@@ -1702,9 +3224,98 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1702
3224
|
userId: execCtx.userId,
|
|
1703
3225
|
tenantId: execCtx.tenantId,
|
|
1704
3226
|
roles: execCtx.roles,
|
|
1705
|
-
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 } : {}
|
|
1706
3231
|
};
|
|
1707
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
|
+
}
|
|
3263
|
+
/**
|
|
3264
|
+
* Build a HookContext.api: a ScopedContext that hooks can use to
|
|
3265
|
+
* read/write other objects within the same execution context.
|
|
3266
|
+
* Falls back to a system-elevated empty context when no execCtx
|
|
3267
|
+
* is supplied (e.g. system-triggered hooks).
|
|
3268
|
+
*/
|
|
3269
|
+
buildHookApi(execCtx) {
|
|
3270
|
+
const safeCtx = execCtx ?? { isSystem: true };
|
|
3271
|
+
return new ScopedContext(safeCtx, this);
|
|
3272
|
+
}
|
|
3273
|
+
/**
|
|
3274
|
+
* Apply field defaults to an incoming insert payload. Defaults that are
|
|
3275
|
+
* Expression envelopes (e.g. `{ dialect: 'cel', source: 'today()' }`,
|
|
3276
|
+
* `{ dialect: 'cel', source: 'os.user.id' }`) are evaluated via
|
|
3277
|
+
* `ExpressionEngine` against the calling user/org/now snapshot. Static
|
|
3278
|
+
* defaults are applied verbatim. Records that already supplied a value for a
|
|
3279
|
+
* field are left untouched.
|
|
3280
|
+
*
|
|
3281
|
+
* Implements ROADMAP §M9.9b — `defaultValue` accepts Expression so authors
|
|
3282
|
+
* can replace "write a hook to default to today/current-user" with a
|
|
3283
|
+
* declarative `defaultValue: cel\`today()\``.
|
|
3284
|
+
*/
|
|
3285
|
+
applyFieldDefaults(object, record, execCtx, nowSnapshot) {
|
|
3286
|
+
const schema = this.getSchema(object);
|
|
3287
|
+
const fieldsRaw = schema?.fields;
|
|
3288
|
+
if (!fieldsRaw || typeof fieldsRaw !== "object") return record;
|
|
3289
|
+
const fieldEntries = Array.isArray(fieldsRaw) ? fieldsRaw : Object.entries(fieldsRaw).map(([name, def]) => ({ name, ...def }));
|
|
3290
|
+
const out = { ...record };
|
|
3291
|
+
const now = nowSnapshot ?? /* @__PURE__ */ new Date();
|
|
3292
|
+
for (const f of fieldEntries) {
|
|
3293
|
+
if (out[f.name] !== void 0) continue;
|
|
3294
|
+
if (f.defaultValue == null) continue;
|
|
3295
|
+
const dv = f.defaultValue;
|
|
3296
|
+
if (typeof dv === "object" && dv !== null && dv.dialect && typeof dv.source === "string") {
|
|
3297
|
+
const result = ExpressionEngine2.evaluate(dv, {
|
|
3298
|
+
now,
|
|
3299
|
+
user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : void 0,
|
|
3300
|
+
org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : void 0,
|
|
3301
|
+
record: out,
|
|
3302
|
+
extra: { object }
|
|
3303
|
+
});
|
|
3304
|
+
if (result.ok) {
|
|
3305
|
+
out[f.name] = result.value;
|
|
3306
|
+
} else {
|
|
3307
|
+
this.logger.warn("Failed to evaluate default expression", {
|
|
3308
|
+
object,
|
|
3309
|
+
field: f.name,
|
|
3310
|
+
error: result.error
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
} else {
|
|
3314
|
+
out[f.name] = dv;
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
return out;
|
|
3318
|
+
}
|
|
1708
3319
|
/**
|
|
1709
3320
|
* Register contribution (Manifest)
|
|
1710
3321
|
*
|
|
@@ -1719,23 +3330,24 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1719
3330
|
const id = manifest.id || manifest.name;
|
|
1720
3331
|
const namespace = manifest.namespace;
|
|
1721
3332
|
this.logger.debug("Registering package manifest", { id, namespace });
|
|
3333
|
+
console.warn(`[ObjectQL:registerApp] id=${id} flows=${Array.isArray(manifest.flows) ? manifest.flows.length : typeof manifest.flows} keys=${Object.keys(manifest).join(",")}`);
|
|
1722
3334
|
if (id) {
|
|
1723
3335
|
this.manifests.set(id, manifest);
|
|
1724
3336
|
}
|
|
1725
|
-
|
|
3337
|
+
this._registry.installPackage(manifest);
|
|
1726
3338
|
this.logger.debug("Installed Package", { id: manifest.id, name: manifest.name, namespace });
|
|
1727
3339
|
if (manifest.objects) {
|
|
1728
3340
|
if (Array.isArray(manifest.objects)) {
|
|
1729
3341
|
this.logger.debug("Registering objects from manifest (Array)", { id, objectCount: manifest.objects.length });
|
|
1730
3342
|
for (const objDef of manifest.objects) {
|
|
1731
|
-
const fqn =
|
|
3343
|
+
const fqn = this._registry.registerObject(objDef, id, namespace, "own");
|
|
1732
3344
|
this.logger.debug("Registered Object", { fqn, from: id });
|
|
1733
3345
|
}
|
|
1734
3346
|
} else {
|
|
1735
3347
|
this.logger.debug("Registering objects from manifest (Map)", { id, objectCount: Object.keys(manifest.objects).length });
|
|
1736
3348
|
for (const [name, objDef] of Object.entries(manifest.objects)) {
|
|
1737
3349
|
objDef.name = name;
|
|
1738
|
-
const fqn =
|
|
3350
|
+
const fqn = this._registry.registerObject(objDef, id, namespace, "own");
|
|
1739
3351
|
this.logger.debug("Registered Object", { fqn, from: id });
|
|
1740
3352
|
}
|
|
1741
3353
|
}
|
|
@@ -1755,7 +3367,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1755
3367
|
validations: ext.validations,
|
|
1756
3368
|
indexes: ext.indexes
|
|
1757
3369
|
};
|
|
1758
|
-
|
|
3370
|
+
this._registry.registerObject(extDef, id, void 0, "extend", priority);
|
|
1759
3371
|
this.logger.debug("Registered Object Extension", { target: targetFqn, priority, from: id });
|
|
1760
3372
|
}
|
|
1761
3373
|
}
|
|
@@ -1764,13 +3376,15 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1764
3376
|
for (const app of manifest.apps) {
|
|
1765
3377
|
const appName = app.name || app.id;
|
|
1766
3378
|
if (appName) {
|
|
1767
|
-
|
|
3379
|
+
const resolved = namespace ? this.resolveNavObjectNames(app, namespace) : app;
|
|
3380
|
+
this._registry.registerApp(resolved, id);
|
|
1768
3381
|
this.logger.debug("Registered App", { app: appName, from: id });
|
|
1769
3382
|
}
|
|
1770
3383
|
}
|
|
1771
3384
|
}
|
|
1772
3385
|
if (manifest.name && manifest.navigation && !manifest.apps?.length) {
|
|
1773
|
-
|
|
3386
|
+
const resolved = namespace ? this.resolveNavObjectNames(manifest, namespace) : manifest;
|
|
3387
|
+
this._registry.registerApp(resolved, id);
|
|
1774
3388
|
this.logger.debug("Registered manifest-as-app", { app: manifest.name, from: id });
|
|
1775
3389
|
}
|
|
1776
3390
|
const metadataArrayKeys = [
|
|
@@ -1809,9 +3423,12 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1809
3423
|
if (Array.isArray(items) && items.length > 0) {
|
|
1810
3424
|
this.logger.debug(`Registering ${key} from manifest`, { id, count: items.length });
|
|
1811
3425
|
for (const item of items) {
|
|
1812
|
-
const itemName =
|
|
3426
|
+
const itemName = resolveMetadataItemName(key, item);
|
|
1813
3427
|
if (itemName) {
|
|
1814
|
-
|
|
3428
|
+
const toRegister = item.name === itemName ? item : { ...item, name: itemName };
|
|
3429
|
+
this._registry.registerItem(pluralToSingular(key), toRegister, "name", id);
|
|
3430
|
+
} else {
|
|
3431
|
+
this.logger.warn(`Skipping ${pluralToSingular(key)} without a derivable name`, { id });
|
|
1815
3432
|
}
|
|
1816
3433
|
}
|
|
1817
3434
|
}
|
|
@@ -1821,14 +3438,14 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1821
3438
|
this.logger.debug("Registering seed data datasets", { id, count: seedData.length });
|
|
1822
3439
|
for (const dataset of seedData) {
|
|
1823
3440
|
if (dataset.object) {
|
|
1824
|
-
|
|
3441
|
+
this._registry.registerItem("data", dataset, "object", id);
|
|
1825
3442
|
}
|
|
1826
3443
|
}
|
|
1827
3444
|
}
|
|
1828
3445
|
if (manifest.contributes?.kinds) {
|
|
1829
3446
|
this.logger.debug("Registering kinds from manifest", { id, kindCount: manifest.contributes.kinds.length });
|
|
1830
3447
|
for (const kind of manifest.contributes.kinds) {
|
|
1831
|
-
|
|
3448
|
+
this._registry.registerKind(kind);
|
|
1832
3449
|
this.logger.debug("Registered Kind", { kind: kind.name || kind.type, from: id });
|
|
1833
3450
|
}
|
|
1834
3451
|
}
|
|
@@ -1843,6 +3460,25 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1843
3460
|
}
|
|
1844
3461
|
}
|
|
1845
3462
|
}
|
|
3463
|
+
/**
|
|
3464
|
+
* Deep-clone an app definition, resolving objectName references in navigation
|
|
3465
|
+
* items via the registry. Object names are canonical identifiers — no FQN
|
|
3466
|
+
* expansion is applied.
|
|
3467
|
+
*/
|
|
3468
|
+
resolveNavObjectNames(app, namespace) {
|
|
3469
|
+
if (!app.navigation) return app;
|
|
3470
|
+
const resolveItems = (items) => items.map((item) => {
|
|
3471
|
+
const resolved = { ...item };
|
|
3472
|
+
if (resolved.objectName && !resolved.objectName.includes("__")) {
|
|
3473
|
+
resolved.objectName = computeFQN(namespace, resolved.objectName);
|
|
3474
|
+
}
|
|
3475
|
+
if (Array.isArray(resolved.children)) {
|
|
3476
|
+
resolved.children = resolveItems(resolved.children);
|
|
3477
|
+
}
|
|
3478
|
+
return resolved;
|
|
3479
|
+
});
|
|
3480
|
+
return { ...app, navigation: resolveItems(app.navigation) };
|
|
3481
|
+
}
|
|
1846
3482
|
/**
|
|
1847
3483
|
* Register a nested plugin's metadata (objects, actions, views, etc.)
|
|
1848
3484
|
*
|
|
@@ -1863,7 +3499,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1863
3499
|
if (Array.isArray(plugin.objects)) {
|
|
1864
3500
|
this.logger.debug("Registering plugin objects (Array)", { pluginName, count: plugin.objects.length });
|
|
1865
3501
|
for (const objDef of plugin.objects) {
|
|
1866
|
-
const fqn =
|
|
3502
|
+
const fqn = this._registry.registerObject(objDef, ownerId, pluginNamespace, "own");
|
|
1867
3503
|
this.logger.debug("Registered Object", { fqn, from: pluginName });
|
|
1868
3504
|
}
|
|
1869
3505
|
} else {
|
|
@@ -1871,7 +3507,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1871
3507
|
this.logger.debug("Registering plugin objects (Map)", { pluginName, count: entries.length });
|
|
1872
3508
|
for (const [name, objDef] of entries) {
|
|
1873
3509
|
objDef.name = name;
|
|
1874
|
-
const fqn =
|
|
3510
|
+
const fqn = this._registry.registerObject(objDef, ownerId, pluginNamespace, "own");
|
|
1875
3511
|
this.logger.debug("Registered Object", { fqn, from: pluginName });
|
|
1876
3512
|
}
|
|
1877
3513
|
}
|
|
@@ -1881,7 +3517,8 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1881
3517
|
}
|
|
1882
3518
|
if (plugin.name && plugin.navigation) {
|
|
1883
3519
|
try {
|
|
1884
|
-
|
|
3520
|
+
const resolved = pluginNamespace ? this.resolveNavObjectNames(plugin, pluginNamespace) : plugin;
|
|
3521
|
+
this._registry.registerApp(resolved, ownerId);
|
|
1885
3522
|
this.logger.debug("Registered plugin-as-app", { app: plugin.name, from: pluginName });
|
|
1886
3523
|
} catch (err) {
|
|
1887
3524
|
this.logger.warn("Failed to register plugin as app", { pluginName, error: err.message });
|
|
@@ -1915,9 +3552,10 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1915
3552
|
const items = plugin[key];
|
|
1916
3553
|
if (Array.isArray(items) && items.length > 0) {
|
|
1917
3554
|
for (const item of items) {
|
|
1918
|
-
const itemName =
|
|
3555
|
+
const itemName = resolveMetadataItemName(key, item);
|
|
1919
3556
|
if (itemName) {
|
|
1920
|
-
|
|
3557
|
+
const toRegister = item.name === itemName ? item : { ...item, name: itemName };
|
|
3558
|
+
this._registry.registerItem(pluralToSingular(key), toRegister, "name", ownerId);
|
|
1921
3559
|
}
|
|
1922
3560
|
}
|
|
1923
3561
|
}
|
|
@@ -1955,24 +3593,21 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1955
3593
|
* Helper to get object definition
|
|
1956
3594
|
*/
|
|
1957
3595
|
getSchema(objectName) {
|
|
1958
|
-
return
|
|
3596
|
+
return this._registry.getObject(objectName);
|
|
1959
3597
|
}
|
|
1960
3598
|
/**
|
|
1961
|
-
* Resolve
|
|
1962
|
-
*
|
|
1963
|
-
*
|
|
1964
|
-
*
|
|
1965
|
-
*
|
|
1966
|
-
*
|
|
1967
|
-
* This ensures that all driver operations use a consistent key
|
|
1968
|
-
* regardless of whether the caller uses the short name or FQN.
|
|
3599
|
+
* Resolve any object identifier to the physical storage name used by drivers.
|
|
3600
|
+
*
|
|
3601
|
+
* Accepts the canonical short name (e.g., 'account') or, for explicit
|
|
3602
|
+
* cross-package disambiguation, the canonical object name (e.g., 'account'). The result is
|
|
3603
|
+
* the physical table name derived via `StorageNameMapping.resolveTableName`.
|
|
1969
3604
|
*/
|
|
1970
3605
|
resolveObjectName(name) {
|
|
1971
|
-
const schema =
|
|
3606
|
+
const schema = this._registry.getObject(name);
|
|
1972
3607
|
if (schema) {
|
|
1973
|
-
return
|
|
3608
|
+
return StorageNameMapping.resolveTableName(schema);
|
|
1974
3609
|
}
|
|
1975
|
-
return name;
|
|
3610
|
+
return StorageNameMapping.resolveTableName({ name });
|
|
1976
3611
|
}
|
|
1977
3612
|
/**
|
|
1978
3613
|
* Helper to get the target driver
|
|
@@ -1984,7 +3619,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1984
3619
|
* 4. Global default driver
|
|
1985
3620
|
*/
|
|
1986
3621
|
getDriver(objectName) {
|
|
1987
|
-
const object =
|
|
3622
|
+
const object = this._registry.getObject(objectName);
|
|
1988
3623
|
if (object?.datasource && object.datasource !== "default") {
|
|
1989
3624
|
if (this.drivers.has(object.datasource)) {
|
|
1990
3625
|
return this.drivers.get(object.datasource);
|
|
@@ -2000,7 +3635,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2000
3635
|
return this.drivers.get(mappedDatasource);
|
|
2001
3636
|
}
|
|
2002
3637
|
const fqn = object?.name || objectName;
|
|
2003
|
-
const owner =
|
|
3638
|
+
const owner = this._registry.getObjectOwner(fqn);
|
|
2004
3639
|
if (owner?.packageId) {
|
|
2005
3640
|
const manifest = this.manifests.get(owner.packageId);
|
|
2006
3641
|
if (manifest?.defaultDatasource && manifest.defaultDatasource !== "default") {
|
|
@@ -2118,10 +3753,10 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2118
3753
|
* @param depth - Current recursion depth (0-based)
|
|
2119
3754
|
* @returns Records with expanded lookup fields (IDs replaced by full objects)
|
|
2120
3755
|
*/
|
|
2121
|
-
async expandRelatedRecords(objectName, records, expand, depth = 0) {
|
|
3756
|
+
async expandRelatedRecords(objectName, records, expand, depth = 0, execCtx) {
|
|
2122
3757
|
if (!records || records.length === 0) return records;
|
|
2123
3758
|
if (depth >= _ObjectQL.MAX_EXPAND_DEPTH) return records;
|
|
2124
|
-
const objectSchema =
|
|
3759
|
+
const objectSchema = this._registry.getObject(objectName);
|
|
2125
3760
|
if (!objectSchema || !objectSchema.fields) return records;
|
|
2126
3761
|
for (const [fieldName, nestedAST] of Object.entries(expand)) {
|
|
2127
3762
|
const fieldDef = objectSchema.fields[fieldName];
|
|
@@ -2150,7 +3785,8 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2150
3785
|
...nestedAST.orderBy ? { orderBy: nestedAST.orderBy } : {}
|
|
2151
3786
|
};
|
|
2152
3787
|
const driver = this.getDriver(referenceObject);
|
|
2153
|
-
const
|
|
3788
|
+
const expandOpts = this.buildDriverOptions(execCtx);
|
|
3789
|
+
const relatedRecords = await driver.find(referenceObject, relatedQuery, expandOpts) ?? [];
|
|
2154
3790
|
const recordMap = /* @__PURE__ */ new Map();
|
|
2155
3791
|
for (const rec of relatedRecords) {
|
|
2156
3792
|
const id = rec.id;
|
|
@@ -2161,7 +3797,8 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2161
3797
|
referenceObject,
|
|
2162
3798
|
relatedRecords,
|
|
2163
3799
|
nestedAST.expand,
|
|
2164
|
-
depth + 1
|
|
3800
|
+
depth + 1,
|
|
3801
|
+
execCtx
|
|
2165
3802
|
);
|
|
2166
3803
|
recordMap.clear();
|
|
2167
3804
|
for (const rec of expandedRelated) {
|
|
@@ -2202,6 +3839,20 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2202
3839
|
ast.limit = ast.top;
|
|
2203
3840
|
}
|
|
2204
3841
|
delete ast.top;
|
|
3842
|
+
const _findSchema = this._registry.getObject(object);
|
|
3843
|
+
const _findFormula = planFormulaProjection(_findSchema, ast.fields);
|
|
3844
|
+
if (_findFormula.projected) ast.fields = _findFormula.projected;
|
|
3845
|
+
if (_findSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) {
|
|
3846
|
+
const known = new Set(Object.keys(_findSchema.fields));
|
|
3847
|
+
known.add("id");
|
|
3848
|
+
known.add("created_at");
|
|
3849
|
+
known.add("updated_at");
|
|
3850
|
+
const filtered = ast.fields.filter((f) => {
|
|
3851
|
+
const head = String(f).split(".")[0];
|
|
3852
|
+
return known.has(head);
|
|
3853
|
+
});
|
|
3854
|
+
ast.fields = filtered.length > 0 ? filtered : void 0;
|
|
3855
|
+
}
|
|
2205
3856
|
const opCtx = {
|
|
2206
3857
|
object,
|
|
2207
3858
|
operation: "find",
|
|
@@ -2215,14 +3866,17 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2215
3866
|
event: "beforeFind",
|
|
2216
3867
|
input: { ast: opCtx.ast, options: opCtx.options },
|
|
2217
3868
|
session: this.buildSession(opCtx.context),
|
|
3869
|
+
api: this.buildHookApi(opCtx.context),
|
|
2218
3870
|
transaction: opCtx.context?.transaction,
|
|
2219
3871
|
ql: this
|
|
2220
3872
|
};
|
|
2221
3873
|
await this.triggerHooks("beforeFind", hookContext);
|
|
3874
|
+
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
|
|
2222
3875
|
try {
|
|
2223
3876
|
let result = await driver.find(object, hookContext.input.ast, hookContext.input.options);
|
|
3877
|
+
if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result);
|
|
2224
3878
|
if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) {
|
|
2225
|
-
result = await this.expandRelatedRecords(object, result, ast.expand, 0);
|
|
3879
|
+
result = await this.expandRelatedRecords(object, result, ast.expand, 0, opCtx.context);
|
|
2226
3880
|
}
|
|
2227
3881
|
hookContext.event = "afterFind";
|
|
2228
3882
|
hookContext.result = result;
|
|
@@ -2242,6 +3896,17 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2242
3896
|
const ast = { object: objectName, ...query, limit: 1 };
|
|
2243
3897
|
delete ast.context;
|
|
2244
3898
|
delete ast.top;
|
|
3899
|
+
const _findOneSchema = this._registry.getObject(objectName);
|
|
3900
|
+
const _findOneFormula = planFormulaProjection(_findOneSchema, ast.fields);
|
|
3901
|
+
if (_findOneFormula.projected) ast.fields = _findOneFormula.projected;
|
|
3902
|
+
if (_findOneSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) {
|
|
3903
|
+
const known = new Set(Object.keys(_findOneSchema.fields));
|
|
3904
|
+
known.add("id");
|
|
3905
|
+
known.add("created_at");
|
|
3906
|
+
known.add("updated_at");
|
|
3907
|
+
const filtered = ast.fields.filter((f) => known.has(String(f).split(".")[0]));
|
|
3908
|
+
ast.fields = filtered.length > 0 ? filtered : void 0;
|
|
3909
|
+
}
|
|
2245
3910
|
const opCtx = {
|
|
2246
3911
|
object: objectName,
|
|
2247
3912
|
operation: "findOne",
|
|
@@ -2250,9 +3915,11 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2250
3915
|
context: query?.context
|
|
2251
3916
|
};
|
|
2252
3917
|
await this.executeWithMiddleware(opCtx, async () => {
|
|
2253
|
-
|
|
3918
|
+
const findOneOpts = this.buildDriverOptions(opCtx.context);
|
|
3919
|
+
let result = await driver.findOne(objectName, opCtx.ast, findOneOpts);
|
|
3920
|
+
if (result != null) applyFormulaPlan(_findOneFormula.plan, [result]);
|
|
2254
3921
|
if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {
|
|
2255
|
-
const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0);
|
|
3922
|
+
const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context);
|
|
2256
3923
|
result = expanded[0];
|
|
2257
3924
|
}
|
|
2258
3925
|
return result;
|
|
@@ -2276,20 +3943,35 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2276
3943
|
event: "beforeInsert",
|
|
2277
3944
|
input: { data: opCtx.data, options: opCtx.options },
|
|
2278
3945
|
session: this.buildSession(opCtx.context),
|
|
3946
|
+
api: this.buildHookApi(opCtx.context),
|
|
2279
3947
|
transaction: opCtx.context?.transaction,
|
|
2280
3948
|
ql: this
|
|
2281
3949
|
};
|
|
2282
3950
|
await this.triggerHooks("beforeInsert", hookContext);
|
|
3951
|
+
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
|
|
2283
3952
|
try {
|
|
2284
3953
|
let result;
|
|
3954
|
+
const nowSnap = /* @__PURE__ */ new Date();
|
|
3955
|
+
const schemaForValidation = this._registry.getObject(object);
|
|
2285
3956
|
if (Array.isArray(hookContext.input.data)) {
|
|
3957
|
+
const rows = hookContext.input.data.map(
|
|
3958
|
+
(row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
|
|
3959
|
+
);
|
|
3960
|
+
for (const r of rows) validateRecord(schemaForValidation, r, "insert");
|
|
2286
3961
|
if (driver.bulkCreate) {
|
|
2287
|
-
result = await driver.bulkCreate(object,
|
|
3962
|
+
result = await driver.bulkCreate(object, rows, hookContext.input.options);
|
|
2288
3963
|
} else {
|
|
2289
|
-
result = await Promise.all(
|
|
3964
|
+
result = await Promise.all(rows.map((item) => driver.create(object, item, hookContext.input.options)));
|
|
2290
3965
|
}
|
|
2291
3966
|
} else {
|
|
2292
|
-
|
|
3967
|
+
const row = this.applyFieldDefaults(
|
|
3968
|
+
object,
|
|
3969
|
+
hookContext.input.data,
|
|
3970
|
+
opCtx.context,
|
|
3971
|
+
nowSnap
|
|
3972
|
+
);
|
|
3973
|
+
validateRecord(schemaForValidation, row, "insert");
|
|
3974
|
+
result = await driver.create(object, row, hookContext.input.options);
|
|
2293
3975
|
}
|
|
2294
3976
|
hookContext.event = "afterInsert";
|
|
2295
3977
|
hookContext.result = result;
|
|
@@ -2356,15 +4038,19 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2356
4038
|
event: "beforeUpdate",
|
|
2357
4039
|
input: { id, data: opCtx.data, options: opCtx.options },
|
|
2358
4040
|
session: this.buildSession(opCtx.context),
|
|
4041
|
+
api: this.buildHookApi(opCtx.context),
|
|
2359
4042
|
transaction: opCtx.context?.transaction,
|
|
2360
4043
|
ql: this
|
|
2361
4044
|
};
|
|
2362
4045
|
await this.triggerHooks("beforeUpdate", hookContext);
|
|
4046
|
+
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
|
|
2363
4047
|
try {
|
|
2364
4048
|
let result;
|
|
2365
4049
|
if (hookContext.input.id) {
|
|
4050
|
+
validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
|
|
2366
4051
|
result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
|
|
2367
4052
|
} else if (options?.multi && driver.updateMany) {
|
|
4053
|
+
validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
|
|
2368
4054
|
const ast = { object, where: options.where };
|
|
2369
4055
|
result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options);
|
|
2370
4056
|
} else {
|
|
@@ -2421,10 +4107,12 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2421
4107
|
event: "beforeDelete",
|
|
2422
4108
|
input: { id, options: opCtx.options },
|
|
2423
4109
|
session: this.buildSession(opCtx.context),
|
|
4110
|
+
api: this.buildHookApi(opCtx.context),
|
|
2424
4111
|
transaction: opCtx.context?.transaction,
|
|
2425
4112
|
ql: this
|
|
2426
4113
|
};
|
|
2427
4114
|
await this.triggerHooks("beforeDelete", hookContext);
|
|
4115
|
+
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
|
|
2428
4116
|
try {
|
|
2429
4117
|
let result;
|
|
2430
4118
|
if (hookContext.input.id) {
|
|
@@ -2474,11 +4162,12 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2474
4162
|
context: query?.context
|
|
2475
4163
|
};
|
|
2476
4164
|
await this.executeWithMiddleware(opCtx, async () => {
|
|
4165
|
+
const countOpts = this.buildDriverOptions(opCtx.context);
|
|
2477
4166
|
if (driver.count) {
|
|
2478
4167
|
const ast = { object, where: query?.where };
|
|
2479
|
-
return driver.count(object, ast);
|
|
4168
|
+
return driver.count(object, ast, countOpts);
|
|
2480
4169
|
}
|
|
2481
|
-
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 });
|
|
2482
4171
|
return res.length;
|
|
2483
4172
|
});
|
|
2484
4173
|
return opCtx.result;
|
|
@@ -2500,18 +4189,104 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2500
4189
|
groupBy: query.groupBy,
|
|
2501
4190
|
aggregations: query.aggregations
|
|
2502
4191
|
};
|
|
2503
|
-
|
|
4192
|
+
const drv = driver;
|
|
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));
|
|
4202
|
+
}
|
|
4203
|
+
const raw = await driver.find(object, ast, this.buildDriverOptions(opCtx.context));
|
|
4204
|
+
return applyInMemoryAggregation(raw, ast);
|
|
2504
4205
|
});
|
|
2505
4206
|
return opCtx.result;
|
|
2506
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
|
+
*/
|
|
2507
4220
|
async execute(command, options) {
|
|
4221
|
+
let driver;
|
|
2508
4222
|
if (options?.object) {
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
4223
|
+
driver = this.getDriver(options.object);
|
|
4224
|
+
} else if (options?.datasource && this.drivers.has(options.datasource)) {
|
|
4225
|
+
driver = this.drivers.get(options.datasource);
|
|
4226
|
+
} else if (this.defaultDriver && this.drivers.has(this.defaultDriver)) {
|
|
4227
|
+
driver = this.drivers.get(this.defaultDriver);
|
|
4228
|
+
} else if (this.drivers.size === 1) {
|
|
4229
|
+
driver = this.drivers.values().next().value;
|
|
4230
|
+
}
|
|
4231
|
+
if (!driver) {
|
|
4232
|
+
throw new Error(
|
|
4233
|
+
"Execute requires options.object to select a driver, or a default driver to be configured. Configure datasourceMapping with `default: true` or pass `{ object }` / `{ datasource }` in options."
|
|
4234
|
+
);
|
|
4235
|
+
}
|
|
4236
|
+
if (!driver.execute) {
|
|
4237
|
+
throw new Error("Selected driver does not implement execute()");
|
|
4238
|
+
}
|
|
4239
|
+
let rawCommand = command;
|
|
4240
|
+
let params = options?.args ?? options?.params;
|
|
4241
|
+
if (command && typeof command === "object" && !Array.isArray(command) && "sql" in command) {
|
|
4242
|
+
rawCommand = command.sql;
|
|
4243
|
+
if (params === void 0) {
|
|
4244
|
+
params = command.args ?? command.params;
|
|
4245
|
+
}
|
|
4246
|
+
}
|
|
4247
|
+
return driver.execute(rawCommand, params, options);
|
|
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 {
|
|
2512
4287
|
}
|
|
4288
|
+
throw err;
|
|
2513
4289
|
}
|
|
2514
|
-
throw new Error("Execute requires options.object to select driver");
|
|
2515
4290
|
}
|
|
2516
4291
|
// ============================================
|
|
2517
4292
|
// Compatibility / Convenience API
|
|
@@ -2532,16 +4307,16 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2532
4307
|
}
|
|
2533
4308
|
}
|
|
2534
4309
|
}
|
|
2535
|
-
return
|
|
4310
|
+
return this._registry.registerObject(schema, packageId, namespace);
|
|
2536
4311
|
}
|
|
2537
4312
|
/**
|
|
2538
4313
|
* Unregister a single object by name.
|
|
2539
4314
|
*/
|
|
2540
4315
|
unregisterObject(name, packageId) {
|
|
2541
4316
|
if (packageId) {
|
|
2542
|
-
|
|
4317
|
+
this._registry.unregisterObjectsByPackage(packageId);
|
|
2543
4318
|
} else {
|
|
2544
|
-
|
|
4319
|
+
this._registry.unregisterItem("object", name);
|
|
2545
4320
|
}
|
|
2546
4321
|
}
|
|
2547
4322
|
/**
|
|
@@ -2557,7 +4332,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2557
4332
|
*/
|
|
2558
4333
|
getConfigs() {
|
|
2559
4334
|
const result = {};
|
|
2560
|
-
const objects =
|
|
4335
|
+
const objects = this._registry.getAllObjects();
|
|
2561
4336
|
for (const obj of objects) {
|
|
2562
4337
|
if (obj.name) {
|
|
2563
4338
|
result[obj.name] = obj;
|
|
@@ -2591,10 +4366,32 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2591
4366
|
return void 0;
|
|
2592
4367
|
}
|
|
2593
4368
|
}
|
|
4369
|
+
/**
|
|
4370
|
+
* Sync all registered object schemas to their respective drivers.
|
|
4371
|
+
* Call this after dynamically registering new objects at runtime
|
|
4372
|
+
* (e.g. after template seeding) to ensure tables/collections exist
|
|
4373
|
+
* before inserting seed data.
|
|
4374
|
+
*/
|
|
4375
|
+
async syncSchemas() {
|
|
4376
|
+
const allObjects = this._registry.getAllObjects();
|
|
4377
|
+
for (const obj of allObjects) {
|
|
4378
|
+
const driver = this.getDriverForObject(obj.name);
|
|
4379
|
+
if (!driver) continue;
|
|
4380
|
+
const tableName = StorageNameMapping.resolveTableName(obj);
|
|
4381
|
+
if (typeof driver.syncSchemasBatch === "function" && driver.supports?.batchSchemaSync) {
|
|
4382
|
+
}
|
|
4383
|
+
if (typeof driver.syncSchema === "function") {
|
|
4384
|
+
try {
|
|
4385
|
+
await driver.syncSchema(tableName, obj);
|
|
4386
|
+
} catch {
|
|
4387
|
+
}
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
2594
4391
|
/**
|
|
2595
4392
|
* Get a registered driver by datasource name.
|
|
2596
4393
|
* Alias matching @objectql/core datasource() API.
|
|
2597
|
-
*
|
|
4394
|
+
*
|
|
2598
4395
|
* @throws Error if the datasource is not found
|
|
2599
4396
|
*/
|
|
2600
4397
|
datasource(name) {
|
|
@@ -2625,7 +4422,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2625
4422
|
}
|
|
2626
4423
|
}
|
|
2627
4424
|
this.removeActionsByPackage(packageId);
|
|
2628
|
-
|
|
4425
|
+
this._registry.unregisterObjectsByPackage(packageId, true);
|
|
2629
4426
|
}
|
|
2630
4427
|
/**
|
|
2631
4428
|
* Gracefully shut down the engine, disconnecting all drivers.
|
|
@@ -2841,86 +4638,97 @@ var ScopedContext = class _ScopedContext {
|
|
|
2841
4638
|
|
|
2842
4639
|
// src/metadata-facade.ts
|
|
2843
4640
|
var MetadataFacade = class {
|
|
4641
|
+
constructor(registry) {
|
|
4642
|
+
this.registry = registry;
|
|
4643
|
+
}
|
|
2844
4644
|
/**
|
|
2845
4645
|
* Register a metadata item
|
|
2846
4646
|
*/
|
|
2847
4647
|
async register(type, name, data) {
|
|
2848
4648
|
const definition = typeof data === "object" && data !== null ? { ...data, name: data.name ?? name } : data;
|
|
2849
4649
|
if (type === "object") {
|
|
2850
|
-
|
|
4650
|
+
this.registry.registerItem(type, definition, "name");
|
|
2851
4651
|
} else {
|
|
2852
|
-
|
|
4652
|
+
this.registry.registerItem(type, definition, definition.id ? "id" : "name");
|
|
2853
4653
|
}
|
|
2854
4654
|
}
|
|
2855
4655
|
/**
|
|
2856
4656
|
* Get a metadata item by type and name
|
|
2857
4657
|
*/
|
|
2858
4658
|
async get(type, name) {
|
|
2859
|
-
const item =
|
|
4659
|
+
const item = this.registry.getItem(type, name);
|
|
2860
4660
|
return item?.content ?? item;
|
|
2861
4661
|
}
|
|
2862
4662
|
/**
|
|
2863
4663
|
* Get the raw entry (with metadata wrapper)
|
|
2864
4664
|
*/
|
|
2865
4665
|
getEntry(type, name) {
|
|
2866
|
-
return
|
|
4666
|
+
return this.registry.getItem(type, name);
|
|
2867
4667
|
}
|
|
2868
4668
|
/**
|
|
2869
4669
|
* List all items of a type
|
|
2870
4670
|
*/
|
|
2871
4671
|
async list(type) {
|
|
2872
|
-
const items =
|
|
4672
|
+
const items = this.registry.listItems(type);
|
|
2873
4673
|
return items.map((item) => item?.content ?? item);
|
|
2874
4674
|
}
|
|
2875
4675
|
/**
|
|
2876
4676
|
* Unregister a metadata item
|
|
2877
4677
|
*/
|
|
2878
4678
|
async unregister(type, name) {
|
|
2879
|
-
|
|
4679
|
+
this.registry.unregisterItem(type, name);
|
|
2880
4680
|
}
|
|
2881
4681
|
/**
|
|
2882
4682
|
* Check if a metadata item exists
|
|
2883
4683
|
*/
|
|
2884
4684
|
async exists(type, name) {
|
|
2885
|
-
const item =
|
|
4685
|
+
const item = this.registry.getItem(type, name);
|
|
2886
4686
|
return item !== void 0 && item !== null;
|
|
2887
4687
|
}
|
|
2888
4688
|
/**
|
|
2889
4689
|
* List all names of metadata items of a given type
|
|
2890
4690
|
*/
|
|
2891
4691
|
async listNames(type) {
|
|
2892
|
-
const items =
|
|
4692
|
+
const items = this.registry.listItems(type);
|
|
2893
4693
|
return items.map((item) => item?.name ?? item?.content?.name ?? "").filter(Boolean);
|
|
2894
4694
|
}
|
|
2895
4695
|
/**
|
|
2896
4696
|
* Unregister all metadata from a package
|
|
2897
4697
|
*/
|
|
2898
4698
|
async unregisterPackage(packageName) {
|
|
2899
|
-
|
|
4699
|
+
this.registry.unregisterObjectsByPackage(packageName);
|
|
2900
4700
|
}
|
|
2901
4701
|
/**
|
|
2902
4702
|
* Convenience: get object definition
|
|
2903
4703
|
*/
|
|
2904
4704
|
async getObject(name) {
|
|
2905
|
-
return
|
|
4705
|
+
return this.registry.getObject(name);
|
|
2906
4706
|
}
|
|
2907
4707
|
/**
|
|
2908
4708
|
* Convenience: list all objects
|
|
2909
4709
|
*/
|
|
2910
4710
|
async listObjects() {
|
|
2911
|
-
return
|
|
4711
|
+
return this.registry.getAllObjects();
|
|
2912
4712
|
}
|
|
2913
4713
|
};
|
|
2914
4714
|
|
|
2915
4715
|
// src/plugin.ts
|
|
4716
|
+
import { StorageNameMapping as StorageNameMapping2 } from "@objectstack/spec/system";
|
|
2916
4717
|
function hasLoadMetaFromDb(service) {
|
|
2917
4718
|
return typeof service === "object" && service !== null && typeof service["loadMetaFromDb"] === "function";
|
|
2918
4719
|
}
|
|
2919
4720
|
var ObjectQLPlugin = class {
|
|
2920
|
-
constructor(
|
|
4721
|
+
constructor(qlOrOptions, hostContext) {
|
|
2921
4722
|
this.name = "com.objectstack.engine.objectql";
|
|
2922
4723
|
this.type = "objectql";
|
|
2923
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;
|
|
2924
4732
|
this.init = async (ctx) => {
|
|
2925
4733
|
if (!this.ql) {
|
|
2926
4734
|
const hostCtx = { ...this.hostContext, logger: ctx.logger };
|
|
@@ -2942,10 +4750,23 @@ var ObjectQLPlugin = class {
|
|
|
2942
4750
|
});
|
|
2943
4751
|
const protocolShim = new ObjectStackProtocolImplementation(
|
|
2944
4752
|
this.ql,
|
|
2945
|
-
() => ctx.getServices ? ctx.getServices() : /* @__PURE__ */ new Map()
|
|
4753
|
+
() => ctx.getServices ? ctx.getServices() : /* @__PURE__ */ new Map(),
|
|
4754
|
+
void 0,
|
|
4755
|
+
this.projectId
|
|
2946
4756
|
);
|
|
2947
4757
|
ctx.registerService("protocol", protocolShim);
|
|
2948
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
|
+
});
|
|
2949
4770
|
};
|
|
2950
4771
|
this.start = async (ctx) => {
|
|
2951
4772
|
ctx.logger.info("ObjectQL engine starting...");
|
|
@@ -2985,103 +4806,194 @@ var ObjectQLPlugin = class {
|
|
|
2985
4806
|
}
|
|
2986
4807
|
}
|
|
2987
4808
|
await this.ql?.init();
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
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
|
+
}
|
|
4814
|
+
if (this.projectId === void 0) {
|
|
4815
|
+
await this.restoreMetadataFromDb(ctx);
|
|
4816
|
+
} else {
|
|
4817
|
+
ctx.logger.info("Project kernel \u2014 skipping sys_metadata hydration (metadata sourced from artifact)");
|
|
4818
|
+
}
|
|
4819
|
+
if (!this.skipSchemaSync) {
|
|
4820
|
+
await this.syncRegisteredSchemas(ctx);
|
|
4821
|
+
}
|
|
4822
|
+
if (this.projectId === void 0) {
|
|
4823
|
+
await this.bridgeObjectsToMetadataService(ctx);
|
|
4824
|
+
}
|
|
2991
4825
|
this.registerAuditHooks(ctx);
|
|
2992
|
-
this.registerTenantMiddleware(ctx);
|
|
2993
4826
|
ctx.logger.info("ObjectQL engine started", {
|
|
2994
4827
|
driversRegistered: this.ql?.["drivers"]?.size || 0,
|
|
2995
4828
|
objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0
|
|
2996
4829
|
});
|
|
2997
4830
|
};
|
|
2998
|
-
if (
|
|
2999
|
-
this.ql =
|
|
3000
|
-
} else {
|
|
4831
|
+
if (qlOrOptions instanceof ObjectQL) {
|
|
4832
|
+
this.ql = qlOrOptions;
|
|
3001
4833
|
this.hostContext = hostContext;
|
|
4834
|
+
return;
|
|
4835
|
+
}
|
|
4836
|
+
const opts = qlOrOptions ?? {};
|
|
4837
|
+
if (opts.ql) {
|
|
4838
|
+
this.ql = opts.ql;
|
|
4839
|
+
}
|
|
4840
|
+
this.hostContext = opts.hostContext ?? hostContext;
|
|
4841
|
+
this.projectId = opts.projectId;
|
|
4842
|
+
if (typeof opts.startupTimeout === "number" && opts.startupTimeout > 0) {
|
|
4843
|
+
this.startupTimeout = opts.startupTimeout;
|
|
3002
4844
|
}
|
|
4845
|
+
this.skipSchemaSync = typeof opts.skipSchemaSync === "boolean" ? opts.skipSchemaSync : process.env.OS_SKIP_SCHEMA_SYNC === "1";
|
|
3003
4846
|
}
|
|
3004
4847
|
/**
|
|
3005
4848
|
* Register built-in audit hooks for auto-stamping created_by/updated_by
|
|
3006
|
-
* and fetching previousData for update/delete operations.
|
|
4849
|
+
* and fetching previousData for update/delete operations. These are
|
|
4850
|
+
* declared as canonical `Hook` metadata and bound through the same
|
|
4851
|
+
* `bindHooksToEngine` path used by `defineStack({ hooks })`, so the
|
|
4852
|
+
* engine's built-ins flow through the same rails as user code
|
|
4853
|
+
* (dogfooding the protocol).
|
|
3007
4854
|
*/
|
|
3008
4855
|
registerAuditHooks(ctx) {
|
|
3009
4856
|
if (!this.ql) return;
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
4857
|
+
const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
4858
|
+
const hasField = (objectName, field) => {
|
|
4859
|
+
try {
|
|
4860
|
+
const schema = this.ql?.getSchema?.(objectName);
|
|
4861
|
+
if (!schema || typeof schema !== "object") return false;
|
|
4862
|
+
const fields = schema.fields;
|
|
4863
|
+
if (!fields || typeof fields !== "object") return false;
|
|
4864
|
+
return Object.prototype.hasOwnProperty.call(fields, field);
|
|
4865
|
+
} catch {
|
|
4866
|
+
return false;
|
|
4867
|
+
}
|
|
4868
|
+
};
|
|
4869
|
+
const applyToRecord = (record, objectName, session, isInsert) => {
|
|
4870
|
+
const now = stamp();
|
|
4871
|
+
if (isInsert) {
|
|
4872
|
+
record.created_at = record.created_at ?? now;
|
|
4873
|
+
}
|
|
4874
|
+
record.updated_at = now;
|
|
4875
|
+
if (session?.userId) {
|
|
4876
|
+
if (isInsert && hasField(objectName, "created_by")) {
|
|
4877
|
+
record.created_by = record.created_by ?? session.userId;
|
|
3021
4878
|
}
|
|
4879
|
+
if (hasField(objectName, "updated_by")) {
|
|
4880
|
+
record.updated_by = session.userId;
|
|
4881
|
+
}
|
|
4882
|
+
}
|
|
4883
|
+
if (isInsert && session?.tenantId && hasField(objectName, "tenant_id")) {
|
|
4884
|
+
record.tenant_id = record.tenant_id ?? session.tenantId;
|
|
3022
4885
|
}
|
|
3023
|
-
}
|
|
3024
|
-
|
|
3025
|
-
if (
|
|
3026
|
-
const
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
4886
|
+
};
|
|
4887
|
+
const stampData = (data, objectName, session, isInsert) => {
|
|
4888
|
+
if (Array.isArray(data)) {
|
|
4889
|
+
for (const row of data) {
|
|
4890
|
+
if (row && typeof row === "object") {
|
|
4891
|
+
applyToRecord(row, objectName, session, isInsert);
|
|
4892
|
+
}
|
|
3030
4893
|
}
|
|
4894
|
+
} else if (data && typeof data === "object") {
|
|
4895
|
+
applyToRecord(data, objectName, session, isInsert);
|
|
3031
4896
|
}
|
|
3032
|
-
}
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
4897
|
+
};
|
|
4898
|
+
const builtinHooks = [
|
|
4899
|
+
{
|
|
4900
|
+
name: "sys_stamp_audit_insert",
|
|
4901
|
+
object: "*",
|
|
4902
|
+
events: ["beforeInsert"],
|
|
4903
|
+
priority: 10,
|
|
4904
|
+
description: "Auto-stamp created_by / updated_by / created_at / updated_at / tenant_id on insert (only when the field exists on the object schema)",
|
|
4905
|
+
handler: async (hookCtx) => {
|
|
4906
|
+
if (hookCtx.input?.data) {
|
|
4907
|
+
stampData(hookCtx.input.data, hookCtx.object, hookCtx.session, true);
|
|
4908
|
+
}
|
|
4909
|
+
}
|
|
4910
|
+
},
|
|
4911
|
+
{
|
|
4912
|
+
name: "sys_stamp_audit_update",
|
|
4913
|
+
object: "*",
|
|
4914
|
+
events: ["beforeUpdate"],
|
|
4915
|
+
priority: 10,
|
|
4916
|
+
description: "Auto-stamp updated_by / updated_at on update (only when the field exists on the object schema)",
|
|
4917
|
+
handler: async (hookCtx) => {
|
|
4918
|
+
if (hookCtx.input?.data) {
|
|
4919
|
+
stampData(hookCtx.input.data, hookCtx.object, hookCtx.session, false);
|
|
4920
|
+
}
|
|
4921
|
+
}
|
|
4922
|
+
},
|
|
4923
|
+
{
|
|
4924
|
+
name: "sys_fetch_previous_update",
|
|
4925
|
+
object: "*",
|
|
4926
|
+
events: ["beforeUpdate"],
|
|
4927
|
+
priority: 5,
|
|
4928
|
+
description: "Auto-fetch the previous record for update hooks",
|
|
4929
|
+
handler: async (hookCtx) => {
|
|
4930
|
+
if (hookCtx.input?.id && !hookCtx.previous) {
|
|
4931
|
+
try {
|
|
4932
|
+
const existing = await this.ql.findOne(hookCtx.object, {
|
|
4933
|
+
where: { id: hookCtx.input.id },
|
|
4934
|
+
context: {
|
|
4935
|
+
roles: [],
|
|
4936
|
+
permissions: [],
|
|
4937
|
+
isSystem: true,
|
|
4938
|
+
...hookCtx.transaction ? { transaction: hookCtx.transaction } : {}
|
|
4939
|
+
}
|
|
4940
|
+
});
|
|
4941
|
+
if (existing) hookCtx.previous = existing;
|
|
4942
|
+
} catch (_e) {
|
|
4943
|
+
}
|
|
4944
|
+
}
|
|
4945
|
+
}
|
|
4946
|
+
},
|
|
4947
|
+
{
|
|
4948
|
+
name: "sys_fetch_previous_delete",
|
|
4949
|
+
object: "*",
|
|
4950
|
+
events: ["beforeDelete"],
|
|
4951
|
+
priority: 5,
|
|
4952
|
+
description: "Auto-fetch the previous record for delete hooks",
|
|
4953
|
+
handler: async (hookCtx) => {
|
|
4954
|
+
if (hookCtx.input?.id && !hookCtx.previous) {
|
|
4955
|
+
try {
|
|
4956
|
+
const existing = await this.ql.findOne(hookCtx.object, {
|
|
4957
|
+
where: { id: hookCtx.input.id },
|
|
4958
|
+
context: {
|
|
4959
|
+
roles: [],
|
|
4960
|
+
permissions: [],
|
|
4961
|
+
isSystem: true,
|
|
4962
|
+
...hookCtx.transaction ? { transaction: hookCtx.transaction } : {}
|
|
4963
|
+
}
|
|
4964
|
+
});
|
|
4965
|
+
if (existing) hookCtx.previous = existing;
|
|
4966
|
+
} catch (_e) {
|
|
4967
|
+
}
|
|
3041
4968
|
}
|
|
3042
|
-
} catch (_e) {
|
|
3043
4969
|
}
|
|
3044
4970
|
}
|
|
3045
|
-
|
|
3046
|
-
this.ql.
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
4971
|
+
];
|
|
4972
|
+
if (typeof this.ql.bindHooks === "function") {
|
|
4973
|
+
this.ql.bindHooks(builtinHooks, { packageId: "sys:audit" });
|
|
4974
|
+
} else {
|
|
4975
|
+
for (const h of builtinHooks) {
|
|
4976
|
+
for (const event of h.events) {
|
|
4977
|
+
this.ql.registerHook(event, h.handler, {
|
|
4978
|
+
object: h.object,
|
|
4979
|
+
priority: h.priority,
|
|
4980
|
+
packageId: "sys:audit"
|
|
3051
4981
|
});
|
|
3052
|
-
if (existing) {
|
|
3053
|
-
hookCtx.previous = existing;
|
|
3054
|
-
}
|
|
3055
|
-
} catch (_e) {
|
|
3056
4982
|
}
|
|
3057
4983
|
}
|
|
3058
|
-
}
|
|
3059
|
-
ctx.logger.debug("Audit hooks registered (created_by/updated_by, previousData)");
|
|
4984
|
+
}
|
|
4985
|
+
ctx.logger.debug("Audit hooks registered via binder (created_by/updated_by, previousData)");
|
|
3060
4986
|
}
|
|
3061
4987
|
/**
|
|
3062
|
-
*
|
|
3063
|
-
*
|
|
4988
|
+
* Tenant isolation moved to `@objectstack/plugin-security`'s
|
|
4989
|
+
* `member_default` permission set RLS
|
|
4990
|
+
* (`organization_id = current_user.organization_id`, with
|
|
4991
|
+
* field-existence guards). The legacy `registerTenantMiddleware`
|
|
4992
|
+
* method was removed because it (a) collided with SecurityPlugin's
|
|
4993
|
+
* RLS pipeline and (b) blindly filtered tables that don't have a
|
|
4994
|
+
* `tenant_id` column (e.g. `sys_organization`), returning 0 rows
|
|
4995
|
+
* instead of all rows.
|
|
3064
4996
|
*/
|
|
3065
|
-
registerTenantMiddleware(ctx) {
|
|
3066
|
-
if (!this.ql) return;
|
|
3067
|
-
this.ql.registerMiddleware(async (opCtx, next) => {
|
|
3068
|
-
if (!opCtx.context?.tenantId || opCtx.context?.isSystem) {
|
|
3069
|
-
return next();
|
|
3070
|
-
}
|
|
3071
|
-
if (["find", "findOne", "count", "aggregate"].includes(opCtx.operation)) {
|
|
3072
|
-
if (opCtx.ast) {
|
|
3073
|
-
const tenantFilter = { tenant_id: opCtx.context.tenantId };
|
|
3074
|
-
if (opCtx.ast.where) {
|
|
3075
|
-
opCtx.ast.where = { $and: [opCtx.ast.where, tenantFilter] };
|
|
3076
|
-
} else {
|
|
3077
|
-
opCtx.ast.where = tenantFilter;
|
|
3078
|
-
}
|
|
3079
|
-
}
|
|
3080
|
-
}
|
|
3081
|
-
await next();
|
|
3082
|
-
});
|
|
3083
|
-
ctx.logger.debug("Tenant isolation middleware registered");
|
|
3084
|
-
}
|
|
3085
4997
|
/**
|
|
3086
4998
|
* Synchronize all registered object schemas to the database.
|
|
3087
4999
|
*
|
|
@@ -3120,7 +5032,7 @@ var ObjectQLPlugin = class {
|
|
|
3120
5032
|
skipped++;
|
|
3121
5033
|
continue;
|
|
3122
5034
|
}
|
|
3123
|
-
const tableName =
|
|
5035
|
+
const tableName = StorageNameMapping2.resolveTableName(obj);
|
|
3124
5036
|
let group = driverGroups.get(driver);
|
|
3125
5037
|
if (!group) {
|
|
3126
5038
|
group = [];
|
|
@@ -3277,13 +5189,20 @@ var ObjectQLPlugin = class {
|
|
|
3277
5189
|
*/
|
|
3278
5190
|
async loadMetadataFromService(metadataService, ctx) {
|
|
3279
5191
|
ctx.logger.info("Syncing metadata from external service into ObjectQL registry...");
|
|
3280
|
-
const metadataTypes = ["object", "view", "app", "flow", "workflow", "function"];
|
|
5192
|
+
const metadataTypes = ["object", "view", "app", "flow", "workflow", "function", "hook"];
|
|
3281
5193
|
let totalLoaded = 0;
|
|
3282
5194
|
for (const type of metadataTypes) {
|
|
3283
5195
|
try {
|
|
3284
5196
|
if (typeof metadataService.loadMany === "function") {
|
|
3285
5197
|
const items = await metadataService.loadMany(type);
|
|
3286
5198
|
if (items && items.length > 0) {
|
|
5199
|
+
if (type === "function" && this.ql && typeof this.ql.registerFunction === "function") {
|
|
5200
|
+
for (const item of items) {
|
|
5201
|
+
if (item?.name && typeof item.handler === "function") {
|
|
5202
|
+
this.ql.registerFunction(item.name, item.handler, "metadata-service");
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
5205
|
+
}
|
|
3287
5206
|
items.forEach((item) => {
|
|
3288
5207
|
const keyField = item.id ? "id" : "name";
|
|
3289
5208
|
if (type === "object" && this.ql) {
|
|
@@ -3293,6 +5212,11 @@ var ObjectQLPlugin = class {
|
|
|
3293
5212
|
this.ql.registry.registerItem(type, item, keyField);
|
|
3294
5213
|
}
|
|
3295
5214
|
});
|
|
5215
|
+
if (type === "hook" && this.ql && typeof this.ql.bindHooks === "function") {
|
|
5216
|
+
this.ql.bindHooks(items, {
|
|
5217
|
+
packageId: "metadata-service"
|
|
5218
|
+
});
|
|
5219
|
+
}
|
|
3296
5220
|
totalLoaded += items.length;
|
|
3297
5221
|
ctx.logger.info(`Synced ${items.length} ${type}(s) from metadata service`);
|
|
3298
5222
|
}
|
|
@@ -3408,6 +5332,7 @@ function convertIntrospectedSchemaToObjects(introspectedSchema, options) {
|
|
|
3408
5332
|
export {
|
|
3409
5333
|
DEFAULT_EXTENDER_PRIORITY,
|
|
3410
5334
|
DEFAULT_OWNER_PRIORITY,
|
|
5335
|
+
InMemoryHookMetricsRecorder,
|
|
3411
5336
|
MetadataFacade,
|
|
3412
5337
|
ObjectQL,
|
|
3413
5338
|
ObjectQLPlugin,
|
|
@@ -3416,10 +5341,18 @@ export {
|
|
|
3416
5341
|
RESERVED_NAMESPACES,
|
|
3417
5342
|
SchemaRegistry,
|
|
3418
5343
|
ScopedContext,
|
|
5344
|
+
ValidationError,
|
|
5345
|
+
applyInMemoryAggregation,
|
|
5346
|
+
applySystemFields,
|
|
5347
|
+
bindHooksToEngine,
|
|
5348
|
+
bucketDateValue,
|
|
3419
5349
|
computeFQN,
|
|
3420
5350
|
convertIntrospectedSchemaToObjects,
|
|
3421
5351
|
createObjectQLKernel,
|
|
5352
|
+
noopHookMetricsRecorder,
|
|
3422
5353
|
parseFQN,
|
|
3423
|
-
toTitleCase
|
|
5354
|
+
toTitleCase,
|
|
5355
|
+
validateRecord,
|
|
5356
|
+
wrapDeclarativeHook
|
|
3424
5357
|
};
|
|
3425
5358
|
//# sourceMappingURL=index.mjs.map
|