@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.js
CHANGED
|
@@ -22,6 +22,7 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
DEFAULT_EXTENDER_PRIORITY: () => DEFAULT_EXTENDER_PRIORITY,
|
|
24
24
|
DEFAULT_OWNER_PRIORITY: () => DEFAULT_OWNER_PRIORITY,
|
|
25
|
+
InMemoryHookMetricsRecorder: () => InMemoryHookMetricsRecorder,
|
|
25
26
|
MetadataFacade: () => MetadataFacade,
|
|
26
27
|
ObjectQL: () => ObjectQL,
|
|
27
28
|
ObjectQLPlugin: () => ObjectQLPlugin,
|
|
@@ -30,11 +31,19 @@ __export(index_exports, {
|
|
|
30
31
|
RESERVED_NAMESPACES: () => RESERVED_NAMESPACES,
|
|
31
32
|
SchemaRegistry: () => SchemaRegistry,
|
|
32
33
|
ScopedContext: () => ScopedContext,
|
|
34
|
+
ValidationError: () => ValidationError,
|
|
35
|
+
applyInMemoryAggregation: () => applyInMemoryAggregation,
|
|
36
|
+
applySystemFields: () => applySystemFields,
|
|
37
|
+
bindHooksToEngine: () => bindHooksToEngine,
|
|
38
|
+
bucketDateValue: () => bucketDateValue,
|
|
33
39
|
computeFQN: () => computeFQN,
|
|
34
40
|
convertIntrospectedSchemaToObjects: () => convertIntrospectedSchemaToObjects,
|
|
35
41
|
createObjectQLKernel: () => createObjectQLKernel,
|
|
42
|
+
noopHookMetricsRecorder: () => noopHookMetricsRecorder,
|
|
36
43
|
parseFQN: () => parseFQN,
|
|
37
|
-
toTitleCase: () => toTitleCase
|
|
44
|
+
toTitleCase: () => toTitleCase,
|
|
45
|
+
validateRecord: () => validateRecord,
|
|
46
|
+
wrapDeclarativeHook: () => wrapDeclarativeHook
|
|
38
47
|
});
|
|
39
48
|
module.exports = __toCommonJS(index_exports);
|
|
40
49
|
|
|
@@ -45,11 +54,8 @@ var import_ui = require("@objectstack/spec/ui");
|
|
|
45
54
|
var RESERVED_NAMESPACES = /* @__PURE__ */ new Set(["base", "system"]);
|
|
46
55
|
var DEFAULT_OWNER_PRIORITY = 100;
|
|
47
56
|
var DEFAULT_EXTENDER_PRIORITY = 200;
|
|
48
|
-
function computeFQN(
|
|
49
|
-
|
|
50
|
-
return shortName;
|
|
51
|
-
}
|
|
52
|
-
return `${namespace}__${shortName}`;
|
|
57
|
+
function computeFQN(_namespace, shortName) {
|
|
58
|
+
return shortName;
|
|
53
59
|
}
|
|
54
60
|
function parseFQN(fqn) {
|
|
55
61
|
const idx = fqn.indexOf("__");
|
|
@@ -77,14 +83,111 @@ function mergeObjectDefinitions(base, extension) {
|
|
|
77
83
|
if (extension.description !== void 0) merged.description = extension.description;
|
|
78
84
|
return merged;
|
|
79
85
|
}
|
|
86
|
+
function applySystemFields(schema, opts) {
|
|
87
|
+
if (schema.systemFields === false) return schema;
|
|
88
|
+
if (schema.managedBy === "better-auth") return schema;
|
|
89
|
+
const sf = typeof schema.systemFields === "object" && schema.systemFields !== null ? schema.systemFields : void 0;
|
|
90
|
+
const tenancyDisabled = schema.tenancy?.enabled === false;
|
|
91
|
+
const wantTenant = opts.multiTenant && sf?.tenant !== false && !tenancyDisabled;
|
|
92
|
+
const wantAudit = sf?.audit !== false;
|
|
93
|
+
const additions = {};
|
|
94
|
+
if (wantTenant && !schema.fields?.organization_id) {
|
|
95
|
+
additions.organization_id = {
|
|
96
|
+
type: "lookup",
|
|
97
|
+
reference: "sys_organization",
|
|
98
|
+
label: "Organization",
|
|
99
|
+
required: false,
|
|
100
|
+
indexed: true,
|
|
101
|
+
hidden: true,
|
|
102
|
+
readonly: true,
|
|
103
|
+
system: true,
|
|
104
|
+
description: "Tenant scope (auto-populated by SecurityPlugin on insert)."
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (wantAudit) {
|
|
108
|
+
if (!schema.fields?.created_at) {
|
|
109
|
+
additions.created_at = {
|
|
110
|
+
type: "datetime",
|
|
111
|
+
label: "Created At",
|
|
112
|
+
required: false,
|
|
113
|
+
readonly: true,
|
|
114
|
+
system: true,
|
|
115
|
+
description: "Timestamp when the record was created (auto-populated by the driver)."
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (!schema.fields?.created_by) {
|
|
119
|
+
additions.created_by = {
|
|
120
|
+
type: "lookup",
|
|
121
|
+
reference: "sys_user",
|
|
122
|
+
label: "Created By",
|
|
123
|
+
required: false,
|
|
124
|
+
readonly: true,
|
|
125
|
+
system: true,
|
|
126
|
+
description: "User who created the record (populated when an authenticated session is present)."
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (!schema.fields?.updated_at) {
|
|
130
|
+
additions.updated_at = {
|
|
131
|
+
type: "datetime",
|
|
132
|
+
label: "Last Modified At",
|
|
133
|
+
required: false,
|
|
134
|
+
readonly: true,
|
|
135
|
+
system: true,
|
|
136
|
+
description: "Timestamp of the most recent modification (auto-populated by the driver)."
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (!schema.fields?.updated_by) {
|
|
140
|
+
additions.updated_by = {
|
|
141
|
+
type: "lookup",
|
|
142
|
+
reference: "sys_user",
|
|
143
|
+
label: "Last Modified By",
|
|
144
|
+
required: false,
|
|
145
|
+
readonly: true,
|
|
146
|
+
system: true,
|
|
147
|
+
description: "User who last modified the record (populated when an authenticated session is present)."
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (Object.keys(additions).length === 0) return schema;
|
|
152
|
+
return {
|
|
153
|
+
...schema,
|
|
154
|
+
fields: { ...additions, ...schema.fields ?? {} }
|
|
155
|
+
};
|
|
156
|
+
}
|
|
80
157
|
var SchemaRegistry = class {
|
|
81
|
-
|
|
158
|
+
constructor(options = {}) {
|
|
159
|
+
// ==========================================
|
|
160
|
+
// Logging control
|
|
161
|
+
// ==========================================
|
|
162
|
+
/** Controls verbosity of registry console messages. Default: 'info'. */
|
|
163
|
+
this._logLevel = "info";
|
|
164
|
+
// ==========================================
|
|
165
|
+
// Object-specific storage (Ownership Model)
|
|
166
|
+
// ==========================================
|
|
167
|
+
/** FQN → Contributor[] (all packages that own/extend this object) */
|
|
168
|
+
this.objectContributors = /* @__PURE__ */ new Map();
|
|
169
|
+
/** FQN → Merged ServiceObject (cached, invalidated on changes) */
|
|
170
|
+
this.mergedObjectCache = /* @__PURE__ */ new Map();
|
|
171
|
+
/** Namespace → Set<PackageId> (multiple packages can share a namespace) */
|
|
172
|
+
this.namespaceRegistry = /* @__PURE__ */ new Map();
|
|
173
|
+
// ==========================================
|
|
174
|
+
// Generic metadata storage (non-object types)
|
|
175
|
+
// ==========================================
|
|
176
|
+
/** Type → Name/ID → MetadataItem */
|
|
177
|
+
this.metadata = /* @__PURE__ */ new Map();
|
|
178
|
+
if (options.multiTenant !== void 0) {
|
|
179
|
+
this.multiTenant = options.multiTenant;
|
|
180
|
+
} else {
|
|
181
|
+
this.multiTenant = String(process.env.OS_MULTI_TENANT ?? "true").toLowerCase() !== "false";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
get logLevel() {
|
|
82
185
|
return this._logLevel;
|
|
83
186
|
}
|
|
84
|
-
|
|
187
|
+
set logLevel(level) {
|
|
85
188
|
this._logLevel = level;
|
|
86
189
|
}
|
|
87
|
-
|
|
190
|
+
log(msg) {
|
|
88
191
|
if (this._logLevel === "silent" || this._logLevel === "error" || this._logLevel === "warn") return;
|
|
89
192
|
console.log(msg);
|
|
90
193
|
}
|
|
@@ -95,7 +198,7 @@ var SchemaRegistry = class {
|
|
|
95
198
|
* Register a namespace for a package.
|
|
96
199
|
* Multiple packages can share the same namespace (e.g. 'sys').
|
|
97
200
|
*/
|
|
98
|
-
|
|
201
|
+
registerNamespace(namespace, packageId) {
|
|
99
202
|
if (!namespace) return;
|
|
100
203
|
let owners = this.namespaceRegistry.get(namespace);
|
|
101
204
|
if (!owners) {
|
|
@@ -108,7 +211,7 @@ var SchemaRegistry = class {
|
|
|
108
211
|
/**
|
|
109
212
|
* Unregister a namespace when a package is uninstalled.
|
|
110
213
|
*/
|
|
111
|
-
|
|
214
|
+
unregisterNamespace(namespace, packageId) {
|
|
112
215
|
const owners = this.namespaceRegistry.get(namespace);
|
|
113
216
|
if (owners) {
|
|
114
217
|
owners.delete(packageId);
|
|
@@ -121,7 +224,7 @@ var SchemaRegistry = class {
|
|
|
121
224
|
/**
|
|
122
225
|
* Get the packages that use a namespace.
|
|
123
226
|
*/
|
|
124
|
-
|
|
227
|
+
getNamespaceOwner(namespace) {
|
|
125
228
|
const owners = this.namespaceRegistry.get(namespace);
|
|
126
229
|
if (!owners || owners.size === 0) return void 0;
|
|
127
230
|
return owners.values().next().value;
|
|
@@ -129,7 +232,7 @@ var SchemaRegistry = class {
|
|
|
129
232
|
/**
|
|
130
233
|
* Get all packages that share a namespace.
|
|
131
234
|
*/
|
|
132
|
-
|
|
235
|
+
getNamespaceOwners(namespace) {
|
|
133
236
|
const owners = this.namespaceRegistry.get(namespace);
|
|
134
237
|
return owners ? Array.from(owners) : [];
|
|
135
238
|
}
|
|
@@ -147,7 +250,8 @@ var SchemaRegistry = class {
|
|
|
147
250
|
*
|
|
148
251
|
* @throws Error if trying to 'own' an object that already has an owner
|
|
149
252
|
*/
|
|
150
|
-
|
|
253
|
+
registerObject(schema, packageId, namespace, ownership = "own", priority = ownership === "own" ? DEFAULT_OWNER_PRIORITY : DEFAULT_EXTENDER_PRIORITY) {
|
|
254
|
+
schema = applySystemFields(schema, { multiTenant: this.multiTenant });
|
|
151
255
|
const shortName = schema.name;
|
|
152
256
|
const fqn = computeFQN(namespace, shortName);
|
|
153
257
|
if (namespace) {
|
|
@@ -194,7 +298,7 @@ var SchemaRegistry = class {
|
|
|
194
298
|
* Resolve an object by FQN, merging all contributions.
|
|
195
299
|
* Returns the merged object or undefined if not found.
|
|
196
300
|
*/
|
|
197
|
-
|
|
301
|
+
resolveObject(fqn) {
|
|
198
302
|
const cached = this.mergedObjectCache.get(fqn);
|
|
199
303
|
if (cached) return cached;
|
|
200
304
|
const contributors = this.objectContributors.get(fqn);
|
|
@@ -216,38 +320,42 @@ var SchemaRegistry = class {
|
|
|
216
320
|
return merged;
|
|
217
321
|
}
|
|
218
322
|
/**
|
|
219
|
-
* Get object by name (
|
|
323
|
+
* Get object by name (short name canonical, FQN supported for disambiguation).
|
|
324
|
+
*
|
|
325
|
+
* Short names are canonical for user code, AI generation, and most lookups.
|
|
326
|
+
* FQN is accepted as an explicit fallback for cross-package disambiguation
|
|
327
|
+
* when two packages contribute objects with the same short name.
|
|
220
328
|
*
|
|
221
329
|
* Resolution order:
|
|
222
|
-
* 1. Exact
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const direct = this.resolveObject(name);
|
|
230
|
-
if (direct) return direct;
|
|
330
|
+
* 1. Exact name match — the name IS the canonical key.
|
|
331
|
+
* If multiple packages contribute the same short name, a warning is logged
|
|
332
|
+
* and the first match wins — disambiguate by passing the FQN explicitly.
|
|
333
|
+
* 2. Legacy FQN match (e.g., 'crm__account') — backward compat.
|
|
334
|
+
*/
|
|
335
|
+
getObject(name) {
|
|
336
|
+
const matches = [];
|
|
231
337
|
for (const fqn of this.objectContributors.keys()) {
|
|
232
338
|
const { shortName } = parseFQN(fqn);
|
|
233
339
|
if (shortName === name) {
|
|
234
|
-
|
|
340
|
+
matches.push(fqn);
|
|
235
341
|
}
|
|
236
342
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
343
|
+
if (matches.length > 0) {
|
|
344
|
+
if (matches.length > 1) {
|
|
345
|
+
console.warn(
|
|
346
|
+
`[SchemaRegistry] Ambiguous short name "${name}" matches: ${matches.join(", ")}. Returning first match. Use FQN to disambiguate.`
|
|
347
|
+
);
|
|
241
348
|
}
|
|
349
|
+
return this.resolveObject(matches[0]);
|
|
242
350
|
}
|
|
243
|
-
return
|
|
351
|
+
return this.resolveObject(name);
|
|
244
352
|
}
|
|
245
353
|
/**
|
|
246
354
|
* Get all registered objects (merged).
|
|
247
355
|
*
|
|
248
356
|
* @param packageId - Optional filter: only objects contributed by this package
|
|
249
357
|
*/
|
|
250
|
-
|
|
358
|
+
getAllObjects(packageId) {
|
|
251
359
|
const results = [];
|
|
252
360
|
for (const fqn of this.objectContributors.keys()) {
|
|
253
361
|
if (packageId) {
|
|
@@ -266,13 +374,13 @@ var SchemaRegistry = class {
|
|
|
266
374
|
/**
|
|
267
375
|
* Get all contributors for an object.
|
|
268
376
|
*/
|
|
269
|
-
|
|
377
|
+
getObjectContributors(fqn) {
|
|
270
378
|
return this.objectContributors.get(fqn) || [];
|
|
271
379
|
}
|
|
272
380
|
/**
|
|
273
381
|
* Get the owner contributor for an object.
|
|
274
382
|
*/
|
|
275
|
-
|
|
383
|
+
getObjectOwner(fqn) {
|
|
276
384
|
const contributors = this.objectContributors.get(fqn);
|
|
277
385
|
return contributors?.find((c) => c.ownership === "own");
|
|
278
386
|
}
|
|
@@ -281,7 +389,7 @@ var SchemaRegistry = class {
|
|
|
281
389
|
*
|
|
282
390
|
* @throws Error if trying to uninstall an owner that has extenders
|
|
283
391
|
*/
|
|
284
|
-
|
|
392
|
+
unregisterObjectsByPackage(packageId, force = false) {
|
|
285
393
|
for (const [fqn, contributors] of this.objectContributors.entries()) {
|
|
286
394
|
const packageContribs = contributors.filter((c) => c.packageId === packageId);
|
|
287
395
|
for (const contrib of packageContribs) {
|
|
@@ -313,7 +421,7 @@ var SchemaRegistry = class {
|
|
|
313
421
|
/**
|
|
314
422
|
* Universal Register Method for non-object metadata.
|
|
315
423
|
*/
|
|
316
|
-
|
|
424
|
+
registerItem(type, item, keyField = "name", packageId) {
|
|
317
425
|
if (!this.metadata.has(type)) {
|
|
318
426
|
this.metadata.set(type, /* @__PURE__ */ new Map());
|
|
319
427
|
}
|
|
@@ -329,7 +437,7 @@ var SchemaRegistry = class {
|
|
|
329
437
|
}
|
|
330
438
|
const storageKey = packageId ? `${packageId}:${baseName}` : baseName;
|
|
331
439
|
if (collection.has(storageKey)) {
|
|
332
|
-
|
|
440
|
+
this.log(`[Registry] Overwriting ${type}: ${storageKey}`);
|
|
333
441
|
}
|
|
334
442
|
collection.set(storageKey, item);
|
|
335
443
|
this.log(`[Registry] Registered ${type}: ${storageKey}`);
|
|
@@ -337,7 +445,7 @@ var SchemaRegistry = class {
|
|
|
337
445
|
/**
|
|
338
446
|
* Validate Metadata against Spec Zod Schemas
|
|
339
447
|
*/
|
|
340
|
-
|
|
448
|
+
validate(type, item) {
|
|
341
449
|
if (type === "object") {
|
|
342
450
|
return import_data.ObjectSchema.parse(item);
|
|
343
451
|
}
|
|
@@ -355,7 +463,7 @@ var SchemaRegistry = class {
|
|
|
355
463
|
/**
|
|
356
464
|
* Universal Unregister Method
|
|
357
465
|
*/
|
|
358
|
-
|
|
466
|
+
unregisterItem(type, name) {
|
|
359
467
|
const collection = this.metadata.get(type);
|
|
360
468
|
if (!collection) {
|
|
361
469
|
console.warn(`[Registry] Attempted to unregister non-existent ${type}: ${name}`);
|
|
@@ -378,7 +486,7 @@ var SchemaRegistry = class {
|
|
|
378
486
|
/**
|
|
379
487
|
* Universal Get Method
|
|
380
488
|
*/
|
|
381
|
-
|
|
489
|
+
getItem(type, name) {
|
|
382
490
|
if (type === "object" || type === "objects") {
|
|
383
491
|
return this.getObject(name);
|
|
384
492
|
}
|
|
@@ -394,7 +502,7 @@ var SchemaRegistry = class {
|
|
|
394
502
|
/**
|
|
395
503
|
* Universal List Method
|
|
396
504
|
*/
|
|
397
|
-
|
|
505
|
+
listItems(type, packageId) {
|
|
398
506
|
if (type === "object" || type === "objects") {
|
|
399
507
|
return this.getAllObjects(packageId);
|
|
400
508
|
}
|
|
@@ -407,7 +515,7 @@ var SchemaRegistry = class {
|
|
|
407
515
|
/**
|
|
408
516
|
* Get all registered metadata types (Kinds)
|
|
409
517
|
*/
|
|
410
|
-
|
|
518
|
+
getRegisteredTypes() {
|
|
411
519
|
const types = Array.from(this.metadata.keys());
|
|
412
520
|
if (!types.includes("object") && this.objectContributors.size > 0) {
|
|
413
521
|
types.push("object");
|
|
@@ -417,7 +525,7 @@ var SchemaRegistry = class {
|
|
|
417
525
|
// ==========================================
|
|
418
526
|
// Package Management
|
|
419
527
|
// ==========================================
|
|
420
|
-
|
|
528
|
+
installPackage(manifest, settings) {
|
|
421
529
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
422
530
|
const pkg = {
|
|
423
531
|
manifest,
|
|
@@ -441,7 +549,7 @@ var SchemaRegistry = class {
|
|
|
441
549
|
this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`);
|
|
442
550
|
return pkg;
|
|
443
551
|
}
|
|
444
|
-
|
|
552
|
+
uninstallPackage(id) {
|
|
445
553
|
const pkg = this.getPackage(id);
|
|
446
554
|
if (!pkg) {
|
|
447
555
|
console.warn(`[Registry] Package not found for uninstall: ${id}`);
|
|
@@ -459,13 +567,13 @@ var SchemaRegistry = class {
|
|
|
459
567
|
}
|
|
460
568
|
return false;
|
|
461
569
|
}
|
|
462
|
-
|
|
570
|
+
getPackage(id) {
|
|
463
571
|
return this.metadata.get("package")?.get(id);
|
|
464
572
|
}
|
|
465
|
-
|
|
573
|
+
getAllPackages() {
|
|
466
574
|
return this.listItems("package");
|
|
467
575
|
}
|
|
468
|
-
|
|
576
|
+
enablePackage(id) {
|
|
469
577
|
const pkg = this.getPackage(id);
|
|
470
578
|
if (pkg) {
|
|
471
579
|
pkg.enabled = true;
|
|
@@ -476,7 +584,7 @@ var SchemaRegistry = class {
|
|
|
476
584
|
}
|
|
477
585
|
return pkg;
|
|
478
586
|
}
|
|
479
|
-
|
|
587
|
+
disablePackage(id) {
|
|
480
588
|
const pkg = this.getPackage(id);
|
|
481
589
|
if (pkg) {
|
|
482
590
|
pkg.enabled = false;
|
|
@@ -490,31 +598,31 @@ var SchemaRegistry = class {
|
|
|
490
598
|
// ==========================================
|
|
491
599
|
// App Helpers
|
|
492
600
|
// ==========================================
|
|
493
|
-
|
|
601
|
+
registerApp(app, packageId) {
|
|
494
602
|
this.registerItem("app", app, "name", packageId);
|
|
495
603
|
}
|
|
496
|
-
|
|
604
|
+
getApp(name) {
|
|
497
605
|
return this.getItem("app", name);
|
|
498
606
|
}
|
|
499
|
-
|
|
607
|
+
getAllApps() {
|
|
500
608
|
return this.listItems("app");
|
|
501
609
|
}
|
|
502
610
|
// ==========================================
|
|
503
611
|
// Plugin Helpers
|
|
504
612
|
// ==========================================
|
|
505
|
-
|
|
613
|
+
registerPlugin(manifest) {
|
|
506
614
|
this.registerItem("plugin", manifest, "id");
|
|
507
615
|
}
|
|
508
|
-
|
|
616
|
+
getAllPlugins() {
|
|
509
617
|
return this.listItems("plugin");
|
|
510
618
|
}
|
|
511
619
|
// ==========================================
|
|
512
620
|
// Kind Helpers
|
|
513
621
|
// ==========================================
|
|
514
|
-
|
|
622
|
+
registerKind(kind) {
|
|
515
623
|
this.registerItem("kind", kind, "id");
|
|
516
624
|
}
|
|
517
|
-
|
|
625
|
+
getAllKinds() {
|
|
518
626
|
return this.listItems("kind");
|
|
519
627
|
}
|
|
520
628
|
// ==========================================
|
|
@@ -523,7 +631,7 @@ var SchemaRegistry = class {
|
|
|
523
631
|
/**
|
|
524
632
|
* Clear all registry state. Use only for testing.
|
|
525
633
|
*/
|
|
526
|
-
|
|
634
|
+
reset() {
|
|
527
635
|
this.objectContributors.clear();
|
|
528
636
|
this.mergedObjectCache.clear();
|
|
529
637
|
this.namespaceRegistry.clear();
|
|
@@ -531,29 +639,26 @@ var SchemaRegistry = class {
|
|
|
531
639
|
this.log("[Registry] Reset complete");
|
|
532
640
|
}
|
|
533
641
|
};
|
|
534
|
-
// ==========================================
|
|
535
|
-
// Logging control
|
|
536
|
-
// ==========================================
|
|
537
|
-
/** Controls verbosity of registry console messages. Default: 'info'. */
|
|
538
|
-
SchemaRegistry._logLevel = "info";
|
|
539
|
-
// ==========================================
|
|
540
|
-
// Object-specific storage (Ownership Model)
|
|
541
|
-
// ==========================================
|
|
542
|
-
/** FQN → Contributor[] (all packages that own/extend this object) */
|
|
543
|
-
SchemaRegistry.objectContributors = /* @__PURE__ */ new Map();
|
|
544
|
-
/** FQN → Merged ServiceObject (cached, invalidated on changes) */
|
|
545
|
-
SchemaRegistry.mergedObjectCache = /* @__PURE__ */ new Map();
|
|
546
|
-
/** Namespace → Set<PackageId> (multiple packages can share a namespace) */
|
|
547
|
-
SchemaRegistry.namespaceRegistry = /* @__PURE__ */ new Map();
|
|
548
|
-
// ==========================================
|
|
549
|
-
// Generic metadata storage (non-object types)
|
|
550
|
-
// ==========================================
|
|
551
|
-
/** Type → Name/ID → MetadataItem */
|
|
552
|
-
SchemaRegistry.metadata = /* @__PURE__ */ new Map();
|
|
553
642
|
|
|
554
643
|
// src/protocol.ts
|
|
555
644
|
var import_data2 = require("@objectstack/spec/data");
|
|
556
645
|
var import_shared = require("@objectstack/spec/shared");
|
|
646
|
+
var import_ui2 = require("@objectstack/spec/ui");
|
|
647
|
+
var import_kernel2 = require("@objectstack/spec/kernel");
|
|
648
|
+
var FORM_VIEW_TYPES = /* @__PURE__ */ new Set(["simple", "tabbed", "wizard", "split", "drawer", "modal"]);
|
|
649
|
+
function resolveOverlaySchema(type, item) {
|
|
650
|
+
const singular = import_shared.PLURAL_TO_SINGULAR[type] ?? type;
|
|
651
|
+
switch (singular) {
|
|
652
|
+
case "view": {
|
|
653
|
+
const t = item && typeof item === "object" && "type" in item ? String(item.type) : void 0;
|
|
654
|
+
return t && FORM_VIEW_TYPES.has(t) ? import_ui2.FormViewSchema : import_ui2.ListViewSchema;
|
|
655
|
+
}
|
|
656
|
+
case "dashboard":
|
|
657
|
+
return import_ui2.DashboardSchema;
|
|
658
|
+
default:
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
557
662
|
function simpleHash(str) {
|
|
558
663
|
let hash = 0;
|
|
559
664
|
for (let i = 0; i < str.length; i++) {
|
|
@@ -580,11 +685,75 @@ var SERVICE_CONFIG = {
|
|
|
580
685
|
"file-storage": { route: "/api/v1/storage", plugin: "plugin-storage" },
|
|
581
686
|
search: { route: "/api/v1/search", plugin: "plugin-search" }
|
|
582
687
|
};
|
|
583
|
-
var
|
|
584
|
-
constructor(engine, getServicesRegistry, getFeedService) {
|
|
688
|
+
var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementation {
|
|
689
|
+
constructor(engine, getServicesRegistry, getFeedService, projectId) {
|
|
690
|
+
/**
|
|
691
|
+
* One-time guard for ensuring the overlay-uniqueness UNIQUE INDEX exists
|
|
692
|
+
* on `sys_metadata`. ADR-0005: scopes overlays by
|
|
693
|
+
* `(type, name, organization_id, project_id, scope)` for active rows only.
|
|
694
|
+
* Idempotent SQL — safe to attempt on every protocol instance.
|
|
695
|
+
*
|
|
696
|
+
* Inlined here (rather than importing from @objectstack/metadata/migrations)
|
|
697
|
+
* to avoid a circular dependency: metadata already depends on objectql.
|
|
698
|
+
*/
|
|
699
|
+
this.overlayIndexEnsured = false;
|
|
585
700
|
this.engine = engine;
|
|
586
701
|
this.getServicesRegistry = getServicesRegistry;
|
|
587
702
|
this.getFeedService = getFeedService;
|
|
703
|
+
this.projectId = projectId;
|
|
704
|
+
}
|
|
705
|
+
async ensureOverlayIndex() {
|
|
706
|
+
if (this.overlayIndexEnsured) return;
|
|
707
|
+
this.overlayIndexEnsured = true;
|
|
708
|
+
try {
|
|
709
|
+
const engineAny = this.engine;
|
|
710
|
+
let driver = engineAny?.driver ?? engineAny?.getDriver?.();
|
|
711
|
+
if (!driver && engineAny?.drivers instanceof Map) {
|
|
712
|
+
for (const candidate of engineAny.drivers.values()) {
|
|
713
|
+
if (candidate && (typeof candidate.raw === "function" || typeof candidate.execute === "function")) {
|
|
714
|
+
driver = candidate;
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (!driver) return;
|
|
720
|
+
const exec = async (sql) => {
|
|
721
|
+
if (typeof driver.raw === "function") {
|
|
722
|
+
await driver.raw(sql);
|
|
723
|
+
} else if (typeof driver.execute === "function") {
|
|
724
|
+
await driver.execute(sql);
|
|
725
|
+
} else {
|
|
726
|
+
throw new Error("driver has neither raw nor execute");
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
try {
|
|
730
|
+
await exec("DROP INDEX IF EXISTS idx_sys_metadata_overlay_active");
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
733
|
+
const partialSql = "CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ON sys_metadata (type, name, organization_id) WHERE state = 'active'";
|
|
734
|
+
const fallbackSql = "CREATE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ON sys_metadata (type, name, organization_id)";
|
|
735
|
+
try {
|
|
736
|
+
await exec(partialSql);
|
|
737
|
+
} catch (err) {
|
|
738
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
739
|
+
if (/partial|where clause|syntax/i.test(msg)) {
|
|
740
|
+
try {
|
|
741
|
+
await exec(fallbackSql);
|
|
742
|
+
} catch {
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
} catch {
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Exposes the project scope the protocol is bound to. Consumers like
|
|
751
|
+
* the HTTP dispatcher use this to decide whether to trust the process-
|
|
752
|
+
* wide SchemaRegistry or whether they must route a read through the
|
|
753
|
+
* protocol's project_id-filtered lookup.
|
|
754
|
+
*/
|
|
755
|
+
getProjectId() {
|
|
756
|
+
return this.projectId;
|
|
588
757
|
}
|
|
589
758
|
requireFeedService() {
|
|
590
759
|
const svc = this.getFeedService?.();
|
|
@@ -681,7 +850,7 @@ var ObjectStackProtocolImplementation = class {
|
|
|
681
850
|
};
|
|
682
851
|
}
|
|
683
852
|
async getMetaTypes() {
|
|
684
|
-
const schemaTypes =
|
|
853
|
+
const schemaTypes = this.engine.registry.getRegisteredTypes();
|
|
685
854
|
let runtimeTypes = [];
|
|
686
855
|
try {
|
|
687
856
|
const services = this.getServicesRegistry?.();
|
|
@@ -696,41 +865,66 @@ var ObjectStackProtocolImplementation = class {
|
|
|
696
865
|
}
|
|
697
866
|
async getMetaItems(request) {
|
|
698
867
|
const { packageId } = request;
|
|
699
|
-
let items =
|
|
700
|
-
if (
|
|
701
|
-
|
|
702
|
-
if (
|
|
868
|
+
let items = [];
|
|
869
|
+
if (this.projectId === void 0) {
|
|
870
|
+
items = [...this.engine.registry.listItems(request.type, packageId)];
|
|
871
|
+
if (items.length === 0) {
|
|
872
|
+
const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
|
|
873
|
+
if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
items = [...this.engine.registry.listItems(request.type, packageId)];
|
|
877
|
+
if (items.length === 0) {
|
|
878
|
+
const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
|
|
879
|
+
if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
|
|
880
|
+
}
|
|
703
881
|
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
882
|
+
try {
|
|
883
|
+
const orgId = request.organizationId;
|
|
884
|
+
const queryByOrg = async (oid) => {
|
|
885
|
+
const whereClause = {
|
|
886
|
+
type: request.type,
|
|
887
|
+
state: "active",
|
|
888
|
+
organization_id: oid
|
|
889
|
+
};
|
|
707
890
|
if (packageId) whereClause._packageId = packageId;
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
});
|
|
711
|
-
if (allRecords && allRecords.length > 0) {
|
|
712
|
-
items = allRecords.map((record) => {
|
|
713
|
-
const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
714
|
-
SchemaRegistry.registerItem(request.type, data, "name");
|
|
715
|
-
return data;
|
|
716
|
-
});
|
|
717
|
-
} else {
|
|
891
|
+
let rs = await this.engine.find("sys_metadata", { where: whereClause });
|
|
892
|
+
if (!rs || rs.length === 0) {
|
|
718
893
|
const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
|
|
719
894
|
if (alt) {
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
});
|
|
723
|
-
if (altRecords && altRecords.length > 0) {
|
|
724
|
-
items = altRecords.map((record) => {
|
|
725
|
-
const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
726
|
-
SchemaRegistry.registerItem(request.type, data, "name");
|
|
727
|
-
return data;
|
|
728
|
-
});
|
|
729
|
-
}
|
|
895
|
+
const altWhere = { type: alt, state: "active", organization_id: oid };
|
|
896
|
+
if (packageId) altWhere._packageId = packageId;
|
|
897
|
+
rs = await this.engine.find("sys_metadata", { where: altWhere });
|
|
730
898
|
}
|
|
731
899
|
}
|
|
732
|
-
|
|
900
|
+
return rs ?? [];
|
|
901
|
+
};
|
|
902
|
+
const envWideRecords = await queryByOrg(null);
|
|
903
|
+
const orgRecords = orgId ? await queryByOrg(orgId) : [];
|
|
904
|
+
const mergedMap = /* @__PURE__ */ new Map();
|
|
905
|
+
for (const r of envWideRecords) mergedMap.set(r.name, r);
|
|
906
|
+
for (const r of orgRecords) mergedMap.set(r.name, r);
|
|
907
|
+
const records = Array.from(mergedMap.values());
|
|
908
|
+
if (records && records.length > 0) {
|
|
909
|
+
const byName = /* @__PURE__ */ new Map();
|
|
910
|
+
for (const existing of items) {
|
|
911
|
+
const entry = existing;
|
|
912
|
+
if (entry && typeof entry === "object" && "name" in entry) {
|
|
913
|
+
byName.set(entry.name, entry);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
for (const record of records) {
|
|
917
|
+
const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
918
|
+
if (data && typeof data === "object" && "name" in data) {
|
|
919
|
+
byName.set(data.name, data);
|
|
920
|
+
}
|
|
921
|
+
if (this.projectId === void 0) {
|
|
922
|
+
this.engine.registry.registerItem(request.type, data, "name");
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
items = Array.from(byName.values());
|
|
733
926
|
}
|
|
927
|
+
} catch {
|
|
734
928
|
}
|
|
735
929
|
try {
|
|
736
930
|
const services = this.getServicesRegistry?.();
|
|
@@ -751,7 +945,9 @@ var ObjectStackProtocolImplementation = class {
|
|
|
751
945
|
for (const item of runtimeItems) {
|
|
752
946
|
const entry = item;
|
|
753
947
|
if (entry && typeof entry === "object" && "name" in entry) {
|
|
754
|
-
itemMap.
|
|
948
|
+
if (!itemMap.has(entry.name)) {
|
|
949
|
+
itemMap.set(entry.name, entry);
|
|
950
|
+
}
|
|
755
951
|
}
|
|
756
952
|
}
|
|
757
953
|
items = Array.from(itemMap.values());
|
|
@@ -765,32 +961,41 @@ var ObjectStackProtocolImplementation = class {
|
|
|
765
961
|
};
|
|
766
962
|
}
|
|
767
963
|
async getMetaItem(request) {
|
|
768
|
-
let item
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
964
|
+
let item;
|
|
965
|
+
const orgId = request.organizationId;
|
|
966
|
+
try {
|
|
967
|
+
const findOverlay = async (oid) => {
|
|
968
|
+
const where = {
|
|
969
|
+
type: request.type,
|
|
970
|
+
name: request.name,
|
|
971
|
+
state: "active",
|
|
972
|
+
organization_id: oid
|
|
973
|
+
};
|
|
974
|
+
const rec = await this.engine.findOne("sys_metadata", { where });
|
|
975
|
+
if (rec) return rec;
|
|
976
|
+
const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
|
|
977
|
+
if (alt) {
|
|
978
|
+
const altWhere = {
|
|
979
|
+
type: alt,
|
|
980
|
+
name: request.name,
|
|
981
|
+
state: "active",
|
|
982
|
+
organization_id: oid
|
|
983
|
+
};
|
|
984
|
+
return await this.engine.findOne("sys_metadata", { where: altWhere });
|
|
985
|
+
}
|
|
986
|
+
return void 0;
|
|
987
|
+
};
|
|
988
|
+
const record = (orgId ? await findOverlay(orgId) : void 0) ?? await findOverlay(null);
|
|
989
|
+
if (record) {
|
|
990
|
+
item = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
991
|
+
}
|
|
992
|
+
} catch {
|
|
772
993
|
}
|
|
773
994
|
if (item === void 0) {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
if (record) {
|
|
779
|
-
item = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
780
|
-
SchemaRegistry.registerItem(request.type, item, "name");
|
|
781
|
-
} else {
|
|
782
|
-
const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
|
|
783
|
-
if (alt) {
|
|
784
|
-
const altRecord = await this.engine.findOne("sys_metadata", {
|
|
785
|
-
where: { type: alt, name: request.name, state: "active" }
|
|
786
|
-
});
|
|
787
|
-
if (altRecord) {
|
|
788
|
-
item = typeof altRecord.metadata === "string" ? JSON.parse(altRecord.metadata) : altRecord.metadata;
|
|
789
|
-
SchemaRegistry.registerItem(request.type, item, "name");
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
} catch {
|
|
995
|
+
item = this.engine.registry.getItem(request.type, request.name);
|
|
996
|
+
if (item === void 0) {
|
|
997
|
+
const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
|
|
998
|
+
if (alt) item = this.engine.registry.getItem(alt, request.name);
|
|
794
999
|
}
|
|
795
1000
|
}
|
|
796
1001
|
if (item === void 0) {
|
|
@@ -810,7 +1015,7 @@ var ObjectStackProtocolImplementation = class {
|
|
|
810
1015
|
};
|
|
811
1016
|
}
|
|
812
1017
|
async getUiView(request) {
|
|
813
|
-
const schema =
|
|
1018
|
+
const schema = this.engine.registry.getObject(request.object);
|
|
814
1019
|
if (!schema) throw new Error(`Object ${request.object} not found`);
|
|
815
1020
|
const fields = schema.fields || {};
|
|
816
1021
|
const fieldKeys = Object.keys(fields);
|
|
@@ -866,6 +1071,21 @@ var ObjectStackProtocolImplementation = class {
|
|
|
866
1071
|
}
|
|
867
1072
|
async findData(request) {
|
|
868
1073
|
const options = { ...request.query };
|
|
1074
|
+
if (request.context !== void 0) {
|
|
1075
|
+
options.context = request.context;
|
|
1076
|
+
}
|
|
1077
|
+
for (const [dollar, bare] of [
|
|
1078
|
+
["$top", "top"],
|
|
1079
|
+
["$skip", "skip"],
|
|
1080
|
+
["$orderby", "orderBy"],
|
|
1081
|
+
["$select", "select"],
|
|
1082
|
+
["$count", "count"]
|
|
1083
|
+
]) {
|
|
1084
|
+
if (options[dollar] != null && options[bare] == null) {
|
|
1085
|
+
options[bare] = options[dollar];
|
|
1086
|
+
}
|
|
1087
|
+
delete options[dollar];
|
|
1088
|
+
}
|
|
869
1089
|
if (options.top != null) {
|
|
870
1090
|
options.limit = Number(options.top);
|
|
871
1091
|
delete options.top;
|
|
@@ -972,6 +1192,23 @@ var ObjectStackProtocolImplementation = class {
|
|
|
972
1192
|
options.where = implicitFilters;
|
|
973
1193
|
}
|
|
974
1194
|
}
|
|
1195
|
+
const hasGroupBy = Array.isArray(options.groupBy) && options.groupBy.length > 0;
|
|
1196
|
+
const hasAggregations = Array.isArray(options.aggregations) && options.aggregations.length > 0;
|
|
1197
|
+
if (hasGroupBy || hasAggregations) {
|
|
1198
|
+
const records2 = await this.engine.aggregate(request.object, {
|
|
1199
|
+
where: options.where,
|
|
1200
|
+
groupBy: options.groupBy,
|
|
1201
|
+
aggregations: options.aggregations,
|
|
1202
|
+
context: options.context
|
|
1203
|
+
});
|
|
1204
|
+
const limited = typeof options.limit === "number" && options.limit > 0 ? records2.slice(0, options.limit) : records2;
|
|
1205
|
+
return {
|
|
1206
|
+
object: request.object,
|
|
1207
|
+
records: limited,
|
|
1208
|
+
total: limited.length,
|
|
1209
|
+
hasMore: false
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
975
1212
|
const records = await this.engine.find(request.object, options);
|
|
976
1213
|
return {
|
|
977
1214
|
object: request.object,
|
|
@@ -984,6 +1221,9 @@ var ObjectStackProtocolImplementation = class {
|
|
|
984
1221
|
const queryOptions = {
|
|
985
1222
|
where: { id: request.id }
|
|
986
1223
|
};
|
|
1224
|
+
if (request.context !== void 0) {
|
|
1225
|
+
queryOptions.context = request.context;
|
|
1226
|
+
}
|
|
987
1227
|
if (request.select) {
|
|
988
1228
|
queryOptions.fields = typeof request.select === "string" ? request.select.split(",").map((s) => s.trim()).filter(Boolean) : request.select;
|
|
989
1229
|
}
|
|
@@ -1002,10 +1242,18 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1002
1242
|
record: result
|
|
1003
1243
|
};
|
|
1004
1244
|
}
|
|
1005
|
-
|
|
1245
|
+
const err = new Error(`Record ${request.id} not found in ${request.object}`);
|
|
1246
|
+
err.code = "RECORD_NOT_FOUND";
|
|
1247
|
+
err.status = 404;
|
|
1248
|
+
err.object = request.object;
|
|
1249
|
+
throw err;
|
|
1006
1250
|
}
|
|
1007
1251
|
async createData(request) {
|
|
1008
|
-
const result = await this.engine.insert(
|
|
1252
|
+
const result = await this.engine.insert(
|
|
1253
|
+
request.object,
|
|
1254
|
+
request.data,
|
|
1255
|
+
request.context !== void 0 ? { context: request.context } : void 0
|
|
1256
|
+
);
|
|
1009
1257
|
return {
|
|
1010
1258
|
object: request.object,
|
|
1011
1259
|
id: result.id,
|
|
@@ -1013,7 +1261,9 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1013
1261
|
};
|
|
1014
1262
|
}
|
|
1015
1263
|
async updateData(request) {
|
|
1016
|
-
const
|
|
1264
|
+
const opts = { where: { id: request.id } };
|
|
1265
|
+
if (request.context !== void 0) opts.context = request.context;
|
|
1266
|
+
const result = await this.engine.update(request.object, request.data, opts);
|
|
1017
1267
|
return {
|
|
1018
1268
|
object: request.object,
|
|
1019
1269
|
id: request.id,
|
|
@@ -1021,7 +1271,9 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1021
1271
|
};
|
|
1022
1272
|
}
|
|
1023
1273
|
async deleteData(request) {
|
|
1024
|
-
|
|
1274
|
+
const opts = { where: { id: request.id } };
|
|
1275
|
+
if (request.context !== void 0) opts.context = request.context;
|
|
1276
|
+
await this.engine.delete(request.object, opts);
|
|
1025
1277
|
return {
|
|
1026
1278
|
object: request.object,
|
|
1027
1279
|
id: request.id,
|
|
@@ -1029,25 +1281,281 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1029
1281
|
};
|
|
1030
1282
|
}
|
|
1031
1283
|
// ==========================================
|
|
1032
|
-
//
|
|
1284
|
+
// Global Search (M10.5)
|
|
1033
1285
|
// ==========================================
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1286
|
+
/**
|
|
1287
|
+
* Cross-object substring search across all registered objects that opt in
|
|
1288
|
+
* via `enable.searchable !== false` and `enable.apiEnabled !== false`.
|
|
1289
|
+
* Searches text-like fields (text/textarea/email/url/phone/markdown/html/string)
|
|
1290
|
+
* whose `searchable: true` flag is set, falling back to the object's
|
|
1291
|
+
* `displayNameField` (or `name`) when no fields are explicitly searchable.
|
|
1292
|
+
*
|
|
1293
|
+
* The query is split into whitespace-separated terms; each term must match
|
|
1294
|
+
* (case-insensitive LIKE) at least one searchable field. RBAC/RLS is
|
|
1295
|
+
* enforced by forwarding the caller's `context` to `engine.find` so users
|
|
1296
|
+
* only see records they are entitled to read.
|
|
1297
|
+
*/
|
|
1298
|
+
async searchAll(request) {
|
|
1299
|
+
const q = (request.q ?? "").trim();
|
|
1300
|
+
if (!q) {
|
|
1301
|
+
return { query: "", hits: [], totalObjects: 0, totalHits: 0, truncated: false };
|
|
1302
|
+
}
|
|
1303
|
+
const overallLimit = Math.max(1, Math.min(100, Number(request.limit ?? 20)));
|
|
1304
|
+
const perObject = Math.max(1, Math.min(25, Number(request.perObject ?? 5)));
|
|
1305
|
+
const objectsFilter = request.objects && request.objects.length ? new Set(request.objects) : null;
|
|
1306
|
+
const terms = q.split(/\s+/).filter(Boolean).slice(0, 8);
|
|
1307
|
+
const allObjects = this.engine.registry?.getAllObjects?.() ?? [];
|
|
1308
|
+
const hits = [];
|
|
1309
|
+
let objectsScanned = 0;
|
|
1310
|
+
for (const obj of allObjects) {
|
|
1311
|
+
if (hits.length >= overallLimit) break;
|
|
1312
|
+
if (!obj?.name) continue;
|
|
1313
|
+
if (objectsFilter && !objectsFilter.has(obj.name)) continue;
|
|
1314
|
+
const enable = obj.enable ?? {};
|
|
1315
|
+
if (enable.searchable === false) continue;
|
|
1316
|
+
if (enable.apiEnabled === false) continue;
|
|
1317
|
+
if (obj.name.startsWith("sys_audit_log") || obj.name.startsWith("sys_activity") || obj.name.startsWith("sys_session") || obj.name.startsWith("sys_presence") || obj.name.startsWith("sys_metadata") || obj.name.startsWith("sys_account")) {
|
|
1318
|
+
continue;
|
|
1040
1319
|
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1320
|
+
const fieldsRaw = obj.fields;
|
|
1321
|
+
const fields = Array.isArray(fieldsRaw) ? fieldsRaw : fieldsRaw && typeof fieldsRaw === "object" ? Object.entries(fieldsRaw).map(([name, f]) => ({ name, ...f || {} })) : [];
|
|
1322
|
+
const TEXT_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "string", "email", "url", "phone", "markdown", "html"]);
|
|
1323
|
+
const fieldByName = new Map(fields.map((f) => [f.name, f]));
|
|
1324
|
+
const hasField = (n) => fieldByName.has(n);
|
|
1325
|
+
const titleFormatSource = obj.titleFormat && (obj.titleFormat.source || obj.titleFormat) || void 0;
|
|
1326
|
+
const renderTitle = (row) => {
|
|
1327
|
+
if (typeof titleFormatSource === "string") {
|
|
1328
|
+
let allResolved = true;
|
|
1329
|
+
const rendered = titleFormatSource.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key) => {
|
|
1330
|
+
const v = row[key];
|
|
1331
|
+
if (v == null || v === "") {
|
|
1332
|
+
allResolved = false;
|
|
1333
|
+
return "";
|
|
1334
|
+
}
|
|
1335
|
+
return String(v);
|
|
1336
|
+
}).trim();
|
|
1337
|
+
if (rendered && allResolved) return rendered;
|
|
1338
|
+
if (rendered) return rendered.replace(/\s+-\s+$/, "").replace(/^\s+-\s+/, "").trim() || row.id;
|
|
1339
|
+
}
|
|
1340
|
+
const candidates = [
|
|
1341
|
+
obj.displayNameField,
|
|
1342
|
+
"name",
|
|
1343
|
+
"full_name",
|
|
1344
|
+
"title",
|
|
1345
|
+
"subject",
|
|
1346
|
+
"label",
|
|
1347
|
+
"company"
|
|
1348
|
+
].filter((c) => typeof c === "string" && hasField(c));
|
|
1349
|
+
for (const c of candidates) {
|
|
1350
|
+
const v = row[c];
|
|
1351
|
+
if (v != null && String(v).trim()) return String(v);
|
|
1352
|
+
}
|
|
1353
|
+
const fn = row.first_name, ln = row.last_name;
|
|
1354
|
+
if (fn || ln) return `${fn ?? ""} ${ln ?? ""}`.trim();
|
|
1355
|
+
return String(row.id);
|
|
1356
|
+
};
|
|
1357
|
+
const titleFieldName = obj.displayNameField || (hasField("name") ? "name" : void 0) || (hasField("title") ? "title" : void 0) || fields.find((f) => TEXT_TYPES.has(f.type))?.name;
|
|
1358
|
+
let searchableFields = fields.filter((f) => f && TEXT_TYPES.has(f.type) && f.searchable === true).map((f) => f.name);
|
|
1359
|
+
if (searchableFields.length === 0 && titleFieldName) {
|
|
1360
|
+
searchableFields = [titleFieldName];
|
|
1361
|
+
}
|
|
1362
|
+
if (searchableFields.length === 0) continue;
|
|
1363
|
+
objectsScanned++;
|
|
1364
|
+
const andClauses = terms.map((term) => ({
|
|
1365
|
+
$or: searchableFields.map((f) => ({ [f]: { $contains: term } }))
|
|
1366
|
+
}));
|
|
1367
|
+
const where = andClauses.length === 1 ? andClauses[0] : { $and: andClauses };
|
|
1368
|
+
try {
|
|
1369
|
+
const opts = {
|
|
1370
|
+
where,
|
|
1371
|
+
limit: perObject,
|
|
1372
|
+
orderBy: [{ field: "updated_at", direction: "desc" }]
|
|
1373
|
+
};
|
|
1374
|
+
if (request.context !== void 0) opts.context = request.context;
|
|
1375
|
+
const rows = await this.engine.find(obj.name, opts);
|
|
1376
|
+
for (const row of rows || []) {
|
|
1377
|
+
if (hits.length >= overallLimit) break;
|
|
1378
|
+
const title = renderTitle(row);
|
|
1379
|
+
let snippet;
|
|
1380
|
+
for (const f of searchableFields) {
|
|
1381
|
+
const v = row[f];
|
|
1382
|
+
if (typeof v === "string" && v) {
|
|
1383
|
+
const lc = v.toLowerCase();
|
|
1384
|
+
const idx = terms.map((t) => lc.indexOf(t.toLowerCase())).find((i) => i >= 0);
|
|
1385
|
+
if (idx != null && idx >= 0) {
|
|
1386
|
+
const start = Math.max(0, idx - 30);
|
|
1387
|
+
const end = Math.min(v.length, idx + 90);
|
|
1388
|
+
snippet = (start > 0 ? "\u2026" : "") + v.slice(start, end) + (end < v.length ? "\u2026" : "");
|
|
1389
|
+
break;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1047
1392
|
}
|
|
1048
|
-
|
|
1393
|
+
hits.push({
|
|
1394
|
+
object: obj.name,
|
|
1395
|
+
id: row.id,
|
|
1396
|
+
title,
|
|
1397
|
+
snippet,
|
|
1398
|
+
record: row
|
|
1399
|
+
});
|
|
1049
1400
|
}
|
|
1401
|
+
} catch {
|
|
1402
|
+
continue;
|
|
1050
1403
|
}
|
|
1404
|
+
}
|
|
1405
|
+
return {
|
|
1406
|
+
query: q,
|
|
1407
|
+
hits,
|
|
1408
|
+
totalObjects: objectsScanned,
|
|
1409
|
+
totalHits: hits.length,
|
|
1410
|
+
truncated: hits.length >= overallLimit
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
// ==========================================
|
|
1414
|
+
// Lead Convert (M10.6)
|
|
1415
|
+
// ==========================================
|
|
1416
|
+
/**
|
|
1417
|
+
* Convert a qualified Lead into an Account + Contact (+ optional
|
|
1418
|
+
* Opportunity) and mark the Lead as converted. Mirrors the Salesforce
|
|
1419
|
+
* lead-conversion model:
|
|
1420
|
+
*
|
|
1421
|
+
* - If `accountId` is provided, the lead's company info is NOT used
|
|
1422
|
+
* to create a new account; the new contact and opportunity link to
|
|
1423
|
+
* the existing account instead.
|
|
1424
|
+
* - If `contactId` is provided, no new contact is created either —
|
|
1425
|
+
* useful when the lead is a new contact at an existing account.
|
|
1426
|
+
* - `createOpportunity` defaults to true; pass `false` to convert
|
|
1427
|
+
* without producing an opportunity (some teams convert "logos
|
|
1428
|
+
* only" first).
|
|
1429
|
+
* - Lead is updated atomically: `is_converted=true`,
|
|
1430
|
+
* `converted_account`/`converted_contact`/`converted_opportunity`
|
|
1431
|
+
* pointers, `converted_date`, and `status='converted'`.
|
|
1432
|
+
*
|
|
1433
|
+
* Atomicity is enforced via the default driver's transaction support
|
|
1434
|
+
* when available; otherwise a best-effort compensation (delete
|
|
1435
|
+
* already-created child records on failure) is attempted. Permission
|
|
1436
|
+
* checks on each child object are inherited from the caller's
|
|
1437
|
+
* execution context so SecurityPlugin still gates account/contact/
|
|
1438
|
+
* opportunity creates.
|
|
1439
|
+
*/
|
|
1440
|
+
async convertLead(request) {
|
|
1441
|
+
const leadId = String(request.leadId || "").trim();
|
|
1442
|
+
if (!leadId) {
|
|
1443
|
+
const err = new Error("leadId is required");
|
|
1444
|
+
err.status = 400;
|
|
1445
|
+
err.code = "INVALID_REQUEST";
|
|
1446
|
+
throw err;
|
|
1447
|
+
}
|
|
1448
|
+
const ctx = request.context;
|
|
1449
|
+
const ctxOpt = ctx !== void 0 ? { context: ctx } : void 0;
|
|
1450
|
+
const lead = await this.engine.findOne("lead", { where: { id: leadId }, ...ctxOpt });
|
|
1451
|
+
if (!lead) {
|
|
1452
|
+
const err = new Error(`Lead '${leadId}' not found`);
|
|
1453
|
+
err.status = 404;
|
|
1454
|
+
err.code = "LEAD_NOT_FOUND";
|
|
1455
|
+
throw err;
|
|
1456
|
+
}
|
|
1457
|
+
if (lead.is_converted) {
|
|
1458
|
+
const err = new Error(`Lead '${leadId}' is already converted`);
|
|
1459
|
+
err.status = 409;
|
|
1460
|
+
err.code = "LEAD_ALREADY_CONVERTED";
|
|
1461
|
+
throw err;
|
|
1462
|
+
}
|
|
1463
|
+
const runConversion = async (trxCtx) => {
|
|
1464
|
+
const opCtx = trxCtx ?? ctx;
|
|
1465
|
+
const trxCtxOpt = opCtx !== void 0 ? { context: opCtx } : void 0;
|
|
1466
|
+
let account;
|
|
1467
|
+
if (request.accountId) {
|
|
1468
|
+
account = await this.engine.findOne("account", { where: { id: request.accountId }, ...trxCtxOpt });
|
|
1469
|
+
if (!account) {
|
|
1470
|
+
const err = new Error(`Account '${request.accountId}' not found`);
|
|
1471
|
+
err.status = 404;
|
|
1472
|
+
err.code = "ACCOUNT_NOT_FOUND";
|
|
1473
|
+
throw err;
|
|
1474
|
+
}
|
|
1475
|
+
} else {
|
|
1476
|
+
const accountPayload = {
|
|
1477
|
+
name: lead.company || `${lead.first_name ?? ""} ${lead.last_name ?? ""}`.trim() || "Untitled Account"
|
|
1478
|
+
};
|
|
1479
|
+
if (lead.industry) accountPayload.industry = lead.industry;
|
|
1480
|
+
if (lead.annual_revenue) accountPayload.annual_revenue = lead.annual_revenue;
|
|
1481
|
+
if (lead.number_of_employees) accountPayload.employees = lead.number_of_employees;
|
|
1482
|
+
if (lead.website) accountPayload.website = lead.website;
|
|
1483
|
+
if (lead.phone) accountPayload.phone = lead.phone;
|
|
1484
|
+
if (lead.address) accountPayload.billing_address = lead.address;
|
|
1485
|
+
if (lead.owner) accountPayload.owner = lead.owner;
|
|
1486
|
+
account = await this.engine.insert("account", accountPayload, trxCtxOpt);
|
|
1487
|
+
}
|
|
1488
|
+
let contact;
|
|
1489
|
+
if (request.contactId) {
|
|
1490
|
+
contact = await this.engine.findOne("contact", { where: { id: request.contactId }, ...trxCtxOpt });
|
|
1491
|
+
if (!contact) {
|
|
1492
|
+
const err = new Error(`Contact '${request.contactId}' not found`);
|
|
1493
|
+
err.status = 404;
|
|
1494
|
+
err.code = "CONTACT_NOT_FOUND";
|
|
1495
|
+
throw err;
|
|
1496
|
+
}
|
|
1497
|
+
} else {
|
|
1498
|
+
const contactPayload = {
|
|
1499
|
+
first_name: lead.first_name ?? "",
|
|
1500
|
+
last_name: lead.last_name ?? lead.company ?? "Unknown"
|
|
1501
|
+
};
|
|
1502
|
+
if (lead.salutation) contactPayload.salutation = lead.salutation;
|
|
1503
|
+
if (lead.email) contactPayload.email = lead.email;
|
|
1504
|
+
if (lead.phone) contactPayload.phone = lead.phone;
|
|
1505
|
+
if (lead.mobile) contactPayload.mobile = lead.mobile;
|
|
1506
|
+
if (lead.title) contactPayload.title = lead.title;
|
|
1507
|
+
if (lead.address) contactPayload.mailing_address = lead.address;
|
|
1508
|
+
if (lead.owner) contactPayload.owner = lead.owner;
|
|
1509
|
+
if (account?.id) contactPayload.account = account.id;
|
|
1510
|
+
contact = await this.engine.insert("contact", contactPayload, trxCtxOpt);
|
|
1511
|
+
}
|
|
1512
|
+
let opportunity = null;
|
|
1513
|
+
const shouldCreateOpp = request.createOpportunity !== false;
|
|
1514
|
+
if (shouldCreateOpp) {
|
|
1515
|
+
const oppOverrides = request.opportunity ?? {};
|
|
1516
|
+
const defaultName = oppOverrides.name || `${account?.name ?? lead.company ?? "Lead"} - New Opportunity`;
|
|
1517
|
+
const defaultClose = oppOverrides.close_date || new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3).toISOString().slice(0, 10);
|
|
1518
|
+
const oppPayload = {
|
|
1519
|
+
name: defaultName,
|
|
1520
|
+
stage: oppOverrides.stage ?? "qualification",
|
|
1521
|
+
close_date: defaultClose
|
|
1522
|
+
};
|
|
1523
|
+
if (oppOverrides.amount !== void 0) oppPayload.amount = oppOverrides.amount;
|
|
1524
|
+
else if (lead.annual_revenue) oppPayload.amount = lead.annual_revenue;
|
|
1525
|
+
if (account?.id) oppPayload.account = account.id;
|
|
1526
|
+
if (contact?.id) oppPayload.primary_contact = contact.id;
|
|
1527
|
+
if (lead.owner) oppPayload.owner = lead.owner;
|
|
1528
|
+
if (lead.lead_source) oppPayload.lead_source = lead.lead_source;
|
|
1529
|
+
opportunity = await this.engine.insert("opportunity", oppPayload, trxCtxOpt);
|
|
1530
|
+
}
|
|
1531
|
+
const leadUpdate = {
|
|
1532
|
+
is_converted: true,
|
|
1533
|
+
status: request.convertedStatus ?? "converted",
|
|
1534
|
+
converted_account: account?.id ?? null,
|
|
1535
|
+
converted_contact: contact?.id ?? null,
|
|
1536
|
+
converted_opportunity: opportunity?.id ?? null,
|
|
1537
|
+
converted_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
1538
|
+
};
|
|
1539
|
+
const updatedLead = await this.engine.update("lead", leadUpdate, {
|
|
1540
|
+
where: { id: leadId },
|
|
1541
|
+
...trxCtxOpt
|
|
1542
|
+
});
|
|
1543
|
+
return {
|
|
1544
|
+
lead: updatedLead ?? { ...lead, ...leadUpdate },
|
|
1545
|
+
account,
|
|
1546
|
+
contact,
|
|
1547
|
+
opportunity
|
|
1548
|
+
};
|
|
1549
|
+
};
|
|
1550
|
+
return this.engine.transaction(runConversion, ctx);
|
|
1551
|
+
}
|
|
1552
|
+
// ==========================================
|
|
1553
|
+
// Metadata Caching
|
|
1554
|
+
// ==========================================
|
|
1555
|
+
async getMetaItemCached(request) {
|
|
1556
|
+
try {
|
|
1557
|
+
const result = await this.getMetaItem({ type: request.type, name: request.name });
|
|
1558
|
+
const item = result?.item;
|
|
1051
1559
|
if (!item) {
|
|
1052
1560
|
throw new Error(`Metadata item ${request.type}/${request.name} not found`);
|
|
1053
1561
|
}
|
|
@@ -1239,7 +1747,7 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1239
1747
|
};
|
|
1240
1748
|
}
|
|
1241
1749
|
async getAnalyticsMeta(request) {
|
|
1242
|
-
const objects =
|
|
1750
|
+
const objects = this.engine.registry.listItems("object");
|
|
1243
1751
|
const cubeFilter = request?.cube;
|
|
1244
1752
|
const cubes = [];
|
|
1245
1753
|
for (const obj of objects) {
|
|
@@ -1339,71 +1847,192 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1339
1847
|
...request.options
|
|
1340
1848
|
});
|
|
1341
1849
|
}
|
|
1850
|
+
/** Normalize plural→singular before consulting the allow-list. */
|
|
1851
|
+
static isOverlayAllowed(type) {
|
|
1852
|
+
const singular = import_shared.PLURAL_TO_SINGULAR[type] ?? type;
|
|
1853
|
+
return _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(singular) || _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(type);
|
|
1854
|
+
}
|
|
1342
1855
|
async saveMetaItem(request) {
|
|
1343
1856
|
if (!request.item) {
|
|
1344
1857
|
throw new Error("Item data is required");
|
|
1345
1858
|
}
|
|
1346
|
-
|
|
1859
|
+
if (this.projectId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
|
|
1860
|
+
const allowed = Array.from(_ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES).join(", ");
|
|
1861
|
+
const err = new Error(
|
|
1862
|
+
`[not_overridable] Metadata type '${request.type}' has not opted into per-org overlay writes. Set allowOrgOverride: true on its DEFAULT_METADATA_TYPE_REGISTRY entry to enable. Currently allowed: ${allowed}. See docs/adr/0005-metadata-customization-overlay.md.`
|
|
1863
|
+
);
|
|
1864
|
+
err.code = "not_overridable";
|
|
1865
|
+
err.status = 403;
|
|
1866
|
+
throw err;
|
|
1867
|
+
}
|
|
1868
|
+
{
|
|
1869
|
+
const schema = resolveOverlaySchema(request.type, request.item);
|
|
1870
|
+
if (schema) {
|
|
1871
|
+
const parsed = schema.safeParse(request.item);
|
|
1872
|
+
if (!parsed.success) {
|
|
1873
|
+
const issues = parsed.error.issues.map((i) => ({
|
|
1874
|
+
path: i.path.join("."),
|
|
1875
|
+
message: i.message,
|
|
1876
|
+
code: i.code
|
|
1877
|
+
}));
|
|
1878
|
+
const summary = issues.slice(0, 3).map((i) => `${i.path || "<root>"}: ${i.message}`).join("; ");
|
|
1879
|
+
const err = new Error(
|
|
1880
|
+
`[invalid_metadata] ${request.type}/${request.name} failed spec validation: ${summary}` + (issues.length > 3 ? ` (+${issues.length - 3} more)` : "")
|
|
1881
|
+
);
|
|
1882
|
+
err.code = "invalid_metadata";
|
|
1883
|
+
err.status = 422;
|
|
1884
|
+
err.issues = issues;
|
|
1885
|
+
throw err;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
if (request.type === "object" || request.type === "objects") {
|
|
1890
|
+
this.engine.registry.registerItem(request.type, request.item, "name");
|
|
1891
|
+
try {
|
|
1892
|
+
this.engine.registry.registerObject(request.item, "sys_metadata");
|
|
1893
|
+
} catch (err) {
|
|
1894
|
+
console.warn(
|
|
1895
|
+
`[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
|
|
1896
|
+
);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
await this.ensureOverlayIndex();
|
|
1347
1900
|
try {
|
|
1348
1901
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1902
|
+
const orgId = request.organizationId ?? null;
|
|
1903
|
+
const scopedWhere = {
|
|
1904
|
+
type: request.type,
|
|
1905
|
+
name: request.name,
|
|
1906
|
+
organization_id: orgId,
|
|
1907
|
+
state: "active"
|
|
1908
|
+
};
|
|
1349
1909
|
const existing = await this.engine.findOne("sys_metadata", {
|
|
1350
|
-
where:
|
|
1910
|
+
where: scopedWhere
|
|
1351
1911
|
});
|
|
1352
1912
|
if (existing) {
|
|
1353
1913
|
await this.engine.update("sys_metadata", {
|
|
1354
1914
|
metadata: JSON.stringify(request.item),
|
|
1355
1915
|
updated_at: now,
|
|
1356
|
-
version: (existing.version || 0) + 1
|
|
1916
|
+
version: (existing.version || 0) + 1,
|
|
1917
|
+
state: "active"
|
|
1357
1918
|
}, {
|
|
1358
1919
|
where: { id: existing.id }
|
|
1359
1920
|
});
|
|
1360
1921
|
} else {
|
|
1361
1922
|
const id = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
1362
|
-
|
|
1923
|
+
const row = {
|
|
1363
1924
|
id,
|
|
1364
1925
|
name: request.name,
|
|
1365
1926
|
type: request.type,
|
|
1927
|
+
// `scope` enum is ['system','platform','user']; per-org
|
|
1928
|
+
// overlays use 'platform' as the informational tag. The
|
|
1929
|
+
// authoritative isolation key is `organization_id`.
|
|
1366
1930
|
scope: "platform",
|
|
1367
1931
|
metadata: JSON.stringify(request.item),
|
|
1368
1932
|
state: "active",
|
|
1369
1933
|
version: 1,
|
|
1370
1934
|
created_at: now,
|
|
1371
|
-
updated_at: now
|
|
1372
|
-
|
|
1935
|
+
updated_at: now,
|
|
1936
|
+
organization_id: orgId
|
|
1937
|
+
};
|
|
1938
|
+
await this.engine.insert("sys_metadata", row);
|
|
1373
1939
|
}
|
|
1374
1940
|
return {
|
|
1375
1941
|
success: true,
|
|
1376
|
-
message:
|
|
1942
|
+
message: orgId ? `Saved customization overlay (org=${orgId}) \u2014 type=${request.type}, name=${request.name}` : `Saved customization overlay (env-wide) \u2014 type=${request.type}, name=${request.name}`
|
|
1377
1943
|
};
|
|
1378
1944
|
} catch (dbError) {
|
|
1379
|
-
console.
|
|
1945
|
+
console.error(
|
|
1946
|
+
`[Protocol] sys_metadata persistence failed for ${request.type}/${request.name}: ${dbError.message}`
|
|
1947
|
+
);
|
|
1948
|
+
const err = new Error(
|
|
1949
|
+
`Failed to persist customization overlay to sys_metadata: ${dbError.message}. In-memory registry was updated but will be lost on restart.`
|
|
1950
|
+
);
|
|
1951
|
+
err.code = "overlay_persistence_failed";
|
|
1952
|
+
err.status = 500;
|
|
1953
|
+
throw err;
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Remove a customization overlay row for the given metadata item, so the
|
|
1958
|
+
* next read falls through to the artifact-loaded default. Implements the
|
|
1959
|
+
* "Reset to factory default" semantic from ADR-0005. Whitelist is shared
|
|
1960
|
+
* with {@link saveMetaItem}.
|
|
1961
|
+
*/
|
|
1962
|
+
async deleteMetaItem(request) {
|
|
1963
|
+
if (this.projectId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
|
|
1964
|
+
const err = new Error(
|
|
1965
|
+
`[not_overridable] Metadata type '${request.type}' has not opted into per-org overlay writes. See docs/adr/0005-metadata-customization-overlay.md.`
|
|
1966
|
+
);
|
|
1967
|
+
err.code = "not_overridable";
|
|
1968
|
+
err.status = 403;
|
|
1969
|
+
throw err;
|
|
1970
|
+
}
|
|
1971
|
+
const scopedWhere = {
|
|
1972
|
+
type: request.type,
|
|
1973
|
+
name: request.name,
|
|
1974
|
+
organization_id: request.organizationId ?? null
|
|
1975
|
+
};
|
|
1976
|
+
try {
|
|
1977
|
+
const existing = await this.engine.findOne("sys_metadata", { where: scopedWhere });
|
|
1978
|
+
if (!existing) {
|
|
1979
|
+
return {
|
|
1980
|
+
success: true,
|
|
1981
|
+
reset: false,
|
|
1982
|
+
message: `No customization overlay found for ${request.type}/${request.name} \u2014 already at artifact default.`
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
await this.engine.delete("sys_metadata", { where: { id: existing.id } });
|
|
1986
|
+
if (this.projectId === void 0) {
|
|
1987
|
+
try {
|
|
1988
|
+
const services = this.getServicesRegistry?.();
|
|
1989
|
+
const metadataService = services?.get("metadata");
|
|
1990
|
+
if (metadataService && typeof metadataService.get === "function") {
|
|
1991
|
+
const artifactItem = await metadataService.get(request.type, request.name);
|
|
1992
|
+
if (artifactItem !== void 0) {
|
|
1993
|
+
this.engine.registry.registerItem(request.type, artifactItem, "name");
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
} catch {
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1380
1999
|
return {
|
|
1381
2000
|
success: true,
|
|
1382
|
-
|
|
1383
|
-
|
|
2001
|
+
reset: true,
|
|
2002
|
+
message: `Customization overlay deleted \u2014 ${request.type}/${request.name} reset to artifact default.`
|
|
1384
2003
|
};
|
|
2004
|
+
} catch (err) {
|
|
2005
|
+
const e = new Error(`Failed to delete customization overlay: ${err.message}`);
|
|
2006
|
+
e.status = 500;
|
|
2007
|
+
throw e;
|
|
1385
2008
|
}
|
|
1386
2009
|
}
|
|
1387
2010
|
/**
|
|
1388
2011
|
* Hydrate SchemaRegistry from the database on startup.
|
|
1389
2012
|
* Loads all active metadata records and registers them in the in-memory registry.
|
|
1390
2013
|
* Safe to call repeatedly — idempotent (latest DB record wins).
|
|
2014
|
+
*
|
|
2015
|
+
* Per ADR-0005, project-kernel mode ALSO hydrates from sys_metadata —
|
|
2016
|
+
* customization overlay rows must survive restart. Scope filter
|
|
2017
|
+
* (`project_id = this.projectId ?? null`) keeps tenants isolated.
|
|
1391
2018
|
*/
|
|
1392
2019
|
async loadMetaFromDb() {
|
|
1393
2020
|
let loaded = 0;
|
|
1394
2021
|
let errors = 0;
|
|
1395
2022
|
try {
|
|
1396
|
-
const
|
|
1397
|
-
|
|
1398
|
-
|
|
2023
|
+
const where = {
|
|
2024
|
+
state: "active",
|
|
2025
|
+
organization_id: null
|
|
2026
|
+
};
|
|
2027
|
+
const records = await this.engine.find("sys_metadata", { where });
|
|
1399
2028
|
for (const record of records) {
|
|
1400
2029
|
try {
|
|
1401
2030
|
const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
1402
2031
|
const normalizedType = import_shared.PLURAL_TO_SINGULAR[record.type] ?? record.type;
|
|
1403
2032
|
if (normalizedType === "object") {
|
|
1404
|
-
|
|
2033
|
+
this.engine.registry.registerObject(data, record.packageId || "sys_metadata");
|
|
1405
2034
|
} else {
|
|
1406
|
-
|
|
2035
|
+
this.engine.registry.registerItem(normalizedType, data, "name");
|
|
1407
2036
|
}
|
|
1408
2037
|
loaded++;
|
|
1409
2038
|
} catch (e) {
|
|
@@ -1412,7 +2041,9 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1412
2041
|
}
|
|
1413
2042
|
}
|
|
1414
2043
|
} catch (e) {
|
|
1415
|
-
|
|
2044
|
+
if (!/no such table/i.test(e.message ?? "")) {
|
|
2045
|
+
console.warn(`[Protocol] DB hydration skipped: ${e.message}`);
|
|
2046
|
+
}
|
|
1416
2047
|
}
|
|
1417
2048
|
return { loaded, errors };
|
|
1418
2049
|
}
|
|
@@ -1482,78 +2113,846 @@ var ObjectStackProtocolImplementation = class {
|
|
|
1482
2113
|
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
1483
2114
|
return { success: true, data: { feedId: request.feedId, pinned: false } };
|
|
1484
2115
|
}
|
|
1485
|
-
async starFeedItem(request) {
|
|
1486
|
-
const svc = this.requireFeedService();
|
|
1487
|
-
const item = await svc.getFeedItem(request.feedId);
|
|
1488
|
-
if (!item) throw new Error(`Feed item ${request.feedId} not found`);
|
|
1489
|
-
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
1490
|
-
return { success: true, data: { feedId: request.feedId, starred: true, starredAt: (/* @__PURE__ */ new Date()).toISOString() } };
|
|
2116
|
+
async starFeedItem(request) {
|
|
2117
|
+
const svc = this.requireFeedService();
|
|
2118
|
+
const item = await svc.getFeedItem(request.feedId);
|
|
2119
|
+
if (!item) throw new Error(`Feed item ${request.feedId} not found`);
|
|
2120
|
+
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
2121
|
+
return { success: true, data: { feedId: request.feedId, starred: true, starredAt: (/* @__PURE__ */ new Date()).toISOString() } };
|
|
2122
|
+
}
|
|
2123
|
+
async unstarFeedItem(request) {
|
|
2124
|
+
const svc = this.requireFeedService();
|
|
2125
|
+
const item = await svc.getFeedItem(request.feedId);
|
|
2126
|
+
if (!item) throw new Error(`Feed item ${request.feedId} not found`);
|
|
2127
|
+
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
2128
|
+
return { success: true, data: { feedId: request.feedId, starred: false } };
|
|
2129
|
+
}
|
|
2130
|
+
async searchFeed(request) {
|
|
2131
|
+
const svc = this.requireFeedService();
|
|
2132
|
+
const result = await svc.listFeed({
|
|
2133
|
+
object: request.object,
|
|
2134
|
+
recordId: request.recordId,
|
|
2135
|
+
filter: request.type,
|
|
2136
|
+
limit: request.limit,
|
|
2137
|
+
cursor: request.cursor
|
|
2138
|
+
});
|
|
2139
|
+
const queryLower = (request.query || "").toLowerCase();
|
|
2140
|
+
const filtered = result.items.filter(
|
|
2141
|
+
(item) => item.body?.toLowerCase().includes(queryLower)
|
|
2142
|
+
);
|
|
2143
|
+
return { success: true, data: { items: filtered, total: filtered.length, hasMore: false } };
|
|
2144
|
+
}
|
|
2145
|
+
async getChangelog(request) {
|
|
2146
|
+
const svc = this.requireFeedService();
|
|
2147
|
+
const result = await svc.listFeed({
|
|
2148
|
+
object: request.object,
|
|
2149
|
+
recordId: request.recordId,
|
|
2150
|
+
filter: "changes_only",
|
|
2151
|
+
limit: request.limit,
|
|
2152
|
+
cursor: request.cursor
|
|
2153
|
+
});
|
|
2154
|
+
const entries = result.items.map((item) => ({
|
|
2155
|
+
id: item.id,
|
|
2156
|
+
object: item.object,
|
|
2157
|
+
recordId: item.recordId,
|
|
2158
|
+
actor: item.actor,
|
|
2159
|
+
changes: item.changes || [],
|
|
2160
|
+
timestamp: item.createdAt,
|
|
2161
|
+
source: item.source
|
|
2162
|
+
}));
|
|
2163
|
+
return { success: true, data: { entries, total: result.total, nextCursor: result.nextCursor, hasMore: result.hasMore } };
|
|
2164
|
+
}
|
|
2165
|
+
async feedSubscribe(request) {
|
|
2166
|
+
const svc = this.requireFeedService();
|
|
2167
|
+
const subscription = await svc.subscribe({
|
|
2168
|
+
object: request.object,
|
|
2169
|
+
recordId: request.recordId,
|
|
2170
|
+
userId: "current_user",
|
|
2171
|
+
events: request.events,
|
|
2172
|
+
channels: request.channels
|
|
2173
|
+
});
|
|
2174
|
+
return { success: true, data: subscription };
|
|
2175
|
+
}
|
|
2176
|
+
async feedUnsubscribe(request) {
|
|
2177
|
+
const svc = this.requireFeedService();
|
|
2178
|
+
const unsubscribed = await svc.unsubscribe(request.object, request.recordId, "current_user");
|
|
2179
|
+
return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } };
|
|
2180
|
+
}
|
|
2181
|
+
};
|
|
2182
|
+
/**
|
|
2183
|
+
* Metadata types that are customer-overridable via {@link saveMetaItem}/
|
|
2184
|
+
* {@link deleteMetaItem} in project-kernel mode. Derived from the canonical
|
|
2185
|
+
* registry in {@link DEFAULT_METADATA_TYPE_REGISTRY}: a type opts in by
|
|
2186
|
+
* setting `allowOrgOverride: true` on its registry entry. The set is
|
|
2187
|
+
* augmented with the plural form of every singular so callers using REST
|
|
2188
|
+
* conventions (`/api/v1/meta/views/...`) get the same gate. See ADR-0005
|
|
2189
|
+
* §"Whitelist enforcement" for the rationale and the per-type rollout
|
|
2190
|
+
* checklist.
|
|
2191
|
+
*/
|
|
2192
|
+
_ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES = (() => {
|
|
2193
|
+
const out = /* @__PURE__ */ new Set();
|
|
2194
|
+
for (const entry of import_kernel2.DEFAULT_METADATA_TYPE_REGISTRY) {
|
|
2195
|
+
if (!entry.allowOrgOverride) continue;
|
|
2196
|
+
out.add(entry.type);
|
|
2197
|
+
const plural = import_shared.SINGULAR_TO_PLURAL[entry.type];
|
|
2198
|
+
if (plural) out.add(plural);
|
|
2199
|
+
}
|
|
2200
|
+
return out;
|
|
2201
|
+
})();
|
|
2202
|
+
var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
|
|
2203
|
+
|
|
2204
|
+
// src/engine.ts
|
|
2205
|
+
var import_kernel3 = require("@objectstack/spec/kernel");
|
|
2206
|
+
var import_core = require("@objectstack/core");
|
|
2207
|
+
var import_system = require("@objectstack/spec/system");
|
|
2208
|
+
var import_shared2 = require("@objectstack/spec/shared");
|
|
2209
|
+
var import_formula2 = require("@objectstack/formula");
|
|
2210
|
+
|
|
2211
|
+
// src/hook-wrappers.ts
|
|
2212
|
+
var import_formula = require("@objectstack/formula");
|
|
2213
|
+
|
|
2214
|
+
// src/hook-metrics.ts
|
|
2215
|
+
var noopHookMetricsRecorder = {
|
|
2216
|
+
recordExecution: () => {
|
|
2217
|
+
},
|
|
2218
|
+
recordSkip: () => {
|
|
2219
|
+
},
|
|
2220
|
+
recordRetry: () => {
|
|
2221
|
+
}
|
|
2222
|
+
};
|
|
2223
|
+
var InMemoryHookMetricsRecorder = class {
|
|
2224
|
+
constructor() {
|
|
2225
|
+
this.executions = /* @__PURE__ */ new Map();
|
|
2226
|
+
this.skips = /* @__PURE__ */ new Map();
|
|
2227
|
+
this.retries = /* @__PURE__ */ new Map();
|
|
2228
|
+
}
|
|
2229
|
+
recordExecution(label, outcome, durationMs) {
|
|
2230
|
+
const key = `${label.hook}|${outcome}`;
|
|
2231
|
+
const cur = this.executions.get(key) ?? { count: 0, totalMs: 0 };
|
|
2232
|
+
cur.count += 1;
|
|
2233
|
+
cur.totalMs += Math.max(0, durationMs);
|
|
2234
|
+
this.executions.set(key, cur);
|
|
2235
|
+
}
|
|
2236
|
+
recordSkip(label, reason) {
|
|
2237
|
+
const key = `${label.hook}|${reason}`;
|
|
2238
|
+
this.skips.set(key, (this.skips.get(key) ?? 0) + 1);
|
|
2239
|
+
}
|
|
2240
|
+
recordRetry(label, _attempt) {
|
|
2241
|
+
this.retries.set(label.hook, (this.retries.get(label.hook) ?? 0) + 1);
|
|
2242
|
+
}
|
|
2243
|
+
snapshot() {
|
|
2244
|
+
return {
|
|
2245
|
+
executions: Array.from(this.executions, ([key, v]) => {
|
|
2246
|
+
const [hook, outcome] = key.split("|");
|
|
2247
|
+
return { hook, outcome, count: v.count, totalMs: v.totalMs };
|
|
2248
|
+
}),
|
|
2249
|
+
skips: Array.from(this.skips, ([key, count]) => {
|
|
2250
|
+
const [hook, reason] = key.split("|");
|
|
2251
|
+
return { hook, reason, count };
|
|
2252
|
+
}),
|
|
2253
|
+
retries: Array.from(this.retries, ([hook, count]) => ({ hook, count }))
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
reset() {
|
|
2257
|
+
this.executions.clear();
|
|
2258
|
+
this.skips.clear();
|
|
2259
|
+
this.retries.clear();
|
|
2260
|
+
}
|
|
2261
|
+
};
|
|
2262
|
+
|
|
2263
|
+
// src/hook-wrappers.ts
|
|
2264
|
+
var noopLogger = {
|
|
2265
|
+
debug: () => {
|
|
2266
|
+
},
|
|
2267
|
+
info: () => {
|
|
2268
|
+
},
|
|
2269
|
+
warn: () => {
|
|
2270
|
+
},
|
|
2271
|
+
error: () => {
|
|
2272
|
+
}
|
|
2273
|
+
};
|
|
2274
|
+
function wrapDeclarativeHook(meta, handler, opts = {}) {
|
|
2275
|
+
const logger = opts.logger ?? noopLogger;
|
|
2276
|
+
const metrics = opts.metrics ?? noopHookMetricsRecorder;
|
|
2277
|
+
const isAfterEvent = meta.events?.some((e) => typeof e === "string" && e.startsWith("after")) ?? false;
|
|
2278
|
+
const hasBody = Boolean(meta.body);
|
|
2279
|
+
const labelFor = (ctx) => ({
|
|
2280
|
+
hook: meta.name,
|
|
2281
|
+
object: ctx.object ?? (typeof meta.object === "string" ? meta.object : void 0),
|
|
2282
|
+
event: ctx.event,
|
|
2283
|
+
body: hasBody
|
|
2284
|
+
});
|
|
2285
|
+
let conditionFn;
|
|
2286
|
+
if (meta.condition) {
|
|
2287
|
+
const expr = typeof meta.condition === "string" ? { dialect: "cel", source: meta.condition } : meta.condition;
|
|
2288
|
+
if (expr.source && expr.source.trim()) {
|
|
2289
|
+
const check = import_formula.ExpressionEngine.compile(expr);
|
|
2290
|
+
if (check.ok) {
|
|
2291
|
+
conditionFn = (record) => {
|
|
2292
|
+
const r = import_formula.ExpressionEngine.evaluate(expr, { record: record ?? {} });
|
|
2293
|
+
if (!r.ok) {
|
|
2294
|
+
logger.warn("[hook] condition evaluation failed; treating as false", {
|
|
2295
|
+
hook: meta.name,
|
|
2296
|
+
condition: expr.source,
|
|
2297
|
+
error: r.error.message
|
|
2298
|
+
});
|
|
2299
|
+
return false;
|
|
2300
|
+
}
|
|
2301
|
+
return Boolean(r.value);
|
|
2302
|
+
};
|
|
2303
|
+
} else {
|
|
2304
|
+
logger.warn("[hook] condition formula failed to compile; condition ignored", {
|
|
2305
|
+
hook: meta.name,
|
|
2306
|
+
condition: expr.source,
|
|
2307
|
+
error: check.error.message
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
const retryMax = Math.max(0, Number(meta.retryPolicy?.maxRetries ?? 0));
|
|
2313
|
+
const retryBackoffMs = Math.max(0, Number(meta.retryPolicy?.backoffMs ?? 0));
|
|
2314
|
+
const timeoutMs = typeof meta.timeout === "number" && meta.timeout > 0 ? meta.timeout : void 0;
|
|
2315
|
+
const onError = meta.onError ?? "abort";
|
|
2316
|
+
const fireAndForget = Boolean(meta.async) && isAfterEvent;
|
|
2317
|
+
const runWithTimeout = async (ctx) => {
|
|
2318
|
+
if (!timeoutMs) {
|
|
2319
|
+
await handler(ctx);
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
let timer;
|
|
2323
|
+
try {
|
|
2324
|
+
await Promise.race([
|
|
2325
|
+
Promise.resolve().then(() => handler(ctx)),
|
|
2326
|
+
new Promise((_, reject) => {
|
|
2327
|
+
timer = setTimeout(() => {
|
|
2328
|
+
reject(new Error(`Hook '${meta.name}' timed out after ${timeoutMs}ms`));
|
|
2329
|
+
}, timeoutMs);
|
|
2330
|
+
})
|
|
2331
|
+
]);
|
|
2332
|
+
} finally {
|
|
2333
|
+
if (timer) clearTimeout(timer);
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2336
|
+
const runWithRetry = async (ctx) => {
|
|
2337
|
+
let attempt = 0;
|
|
2338
|
+
let lastErr;
|
|
2339
|
+
while (attempt <= retryMax) {
|
|
2340
|
+
try {
|
|
2341
|
+
await runWithTimeout(ctx);
|
|
2342
|
+
return;
|
|
2343
|
+
} catch (err) {
|
|
2344
|
+
lastErr = err;
|
|
2345
|
+
attempt += 1;
|
|
2346
|
+
if (attempt > retryMax) break;
|
|
2347
|
+
if (retryBackoffMs > 0) {
|
|
2348
|
+
await new Promise((r) => setTimeout(r, retryBackoffMs * attempt));
|
|
2349
|
+
}
|
|
2350
|
+
try {
|
|
2351
|
+
metrics.recordRetry(labelFor(ctx), attempt);
|
|
2352
|
+
} catch {
|
|
2353
|
+
}
|
|
2354
|
+
logger.warn("[hook] retrying after failure", {
|
|
2355
|
+
hook: meta.name,
|
|
2356
|
+
attempt,
|
|
2357
|
+
maxRetries: retryMax,
|
|
2358
|
+
error: err?.message
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
throw lastErr;
|
|
2363
|
+
};
|
|
2364
|
+
const runWithErrorPolicy = async (ctx) => {
|
|
2365
|
+
try {
|
|
2366
|
+
await runWithRetry(ctx);
|
|
2367
|
+
} catch (err) {
|
|
2368
|
+
if (onError === "log") {
|
|
2369
|
+
logger.error("[hook] handler failed (onError=log; suppressing)", {
|
|
2370
|
+
hook: meta.name,
|
|
2371
|
+
object: ctx.object,
|
|
2372
|
+
event: ctx.event,
|
|
2373
|
+
error: err?.message
|
|
2374
|
+
});
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
throw err;
|
|
2378
|
+
}
|
|
2379
|
+
};
|
|
2380
|
+
return async (ctx) => {
|
|
2381
|
+
if (conditionFn) {
|
|
2382
|
+
const record = pickRecordPayload(ctx);
|
|
2383
|
+
if (!conditionFn(record)) {
|
|
2384
|
+
logger.debug("[hook] skipped by condition", {
|
|
2385
|
+
hook: meta.name,
|
|
2386
|
+
object: ctx.object,
|
|
2387
|
+
event: ctx.event
|
|
2388
|
+
});
|
|
2389
|
+
try {
|
|
2390
|
+
metrics.recordSkip(labelFor(ctx), "condition");
|
|
2391
|
+
} catch {
|
|
2392
|
+
}
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
const restore = installFlatInput(ctx);
|
|
2397
|
+
const startedAt = Date.now();
|
|
2398
|
+
const recordOutcome = (err) => {
|
|
2399
|
+
const elapsed = Date.now() - startedAt;
|
|
2400
|
+
let outcome = "success";
|
|
2401
|
+
if (err) {
|
|
2402
|
+
const msg = String(err?.message ?? err ?? "");
|
|
2403
|
+
if (/timed out after/i.test(msg)) outcome = "timeout";
|
|
2404
|
+
else if (/capability|cap-rejection|capability_rejected/i.test(msg)) outcome = "capability_rejected";
|
|
2405
|
+
else outcome = "error";
|
|
2406
|
+
}
|
|
2407
|
+
try {
|
|
2408
|
+
metrics.recordExecution(labelFor(ctx), outcome, elapsed);
|
|
2409
|
+
} catch {
|
|
2410
|
+
}
|
|
2411
|
+
};
|
|
2412
|
+
try {
|
|
2413
|
+
if (fireAndForget) {
|
|
2414
|
+
try {
|
|
2415
|
+
metrics.recordSkip(labelFor(ctx), "fire_and_forget");
|
|
2416
|
+
} catch {
|
|
2417
|
+
}
|
|
2418
|
+
void runWithErrorPolicy(ctx).then(() => recordOutcome()).catch((err) => {
|
|
2419
|
+
recordOutcome(err);
|
|
2420
|
+
logger.error("[hook] async handler error (fire-and-forget)", {
|
|
2421
|
+
hook: meta.name,
|
|
2422
|
+
error: err?.message
|
|
2423
|
+
});
|
|
2424
|
+
});
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
try {
|
|
2428
|
+
await runWithErrorPolicy(ctx);
|
|
2429
|
+
recordOutcome();
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
recordOutcome(err);
|
|
2432
|
+
throw err;
|
|
2433
|
+
}
|
|
2434
|
+
} finally {
|
|
2435
|
+
restore();
|
|
2436
|
+
}
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
function installFlatInput(ctx) {
|
|
2440
|
+
const raw = ctx.input ?? {};
|
|
2441
|
+
const looksWrapped = raw && typeof raw === "object" && ("data" in raw || "options" in raw || "id" in raw || "ast" in raw);
|
|
2442
|
+
if (!looksWrapped) return () => {
|
|
2443
|
+
};
|
|
2444
|
+
const ensureData = () => {
|
|
2445
|
+
if (!raw.data || typeof raw.data !== "object") {
|
|
2446
|
+
raw.data = {};
|
|
2447
|
+
}
|
|
2448
|
+
return raw.data;
|
|
2449
|
+
};
|
|
2450
|
+
const proxy = new Proxy(raw, {
|
|
2451
|
+
get(target, prop, receiver) {
|
|
2452
|
+
if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
|
|
2453
|
+
return Reflect.get(target, prop, receiver);
|
|
2454
|
+
}
|
|
2455
|
+
const data = target.data;
|
|
2456
|
+
if (data && typeof data === "object" && prop in data) {
|
|
2457
|
+
return data[prop];
|
|
2458
|
+
}
|
|
2459
|
+
return Reflect.get(target, prop, receiver);
|
|
2460
|
+
},
|
|
2461
|
+
set(target, prop, value) {
|
|
2462
|
+
if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
|
|
2463
|
+
target[prop] = value;
|
|
2464
|
+
return true;
|
|
2465
|
+
}
|
|
2466
|
+
ensureData()[prop] = value;
|
|
2467
|
+
return true;
|
|
2468
|
+
},
|
|
2469
|
+
has(target, prop) {
|
|
2470
|
+
if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
|
|
2471
|
+
return prop in target;
|
|
2472
|
+
}
|
|
2473
|
+
const data = target.data;
|
|
2474
|
+
if (data && typeof data === "object" && prop in data) return true;
|
|
2475
|
+
return prop in target;
|
|
2476
|
+
},
|
|
2477
|
+
ownKeys(target) {
|
|
2478
|
+
const dataKeys = target.data && typeof target.data === "object" ? Object.keys(target.data) : [];
|
|
2479
|
+
return Array.from(new Set(dataKeys));
|
|
2480
|
+
},
|
|
2481
|
+
getOwnPropertyDescriptor(target, prop) {
|
|
2482
|
+
const data = target.data;
|
|
2483
|
+
if (data && typeof data === "object" && prop in data) {
|
|
2484
|
+
return { configurable: true, enumerable: true, writable: true, value: data[prop] };
|
|
2485
|
+
}
|
|
2486
|
+
if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
|
|
2487
|
+
const desc = Object.getOwnPropertyDescriptor(target, prop);
|
|
2488
|
+
return desc ? { ...desc, enumerable: false } : void 0;
|
|
2489
|
+
}
|
|
2490
|
+
return Object.getOwnPropertyDescriptor(target, prop);
|
|
2491
|
+
}
|
|
2492
|
+
});
|
|
2493
|
+
ctx.input = proxy;
|
|
2494
|
+
return () => {
|
|
2495
|
+
ctx.input = raw;
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
function pickRecordPayload(ctx) {
|
|
2499
|
+
const input = ctx.input ?? {};
|
|
2500
|
+
if (input && typeof input === "object" && input.data && typeof input.data === "object") {
|
|
2501
|
+
return input.data;
|
|
2502
|
+
}
|
|
2503
|
+
if (ctx.previous && typeof ctx.previous === "object") {
|
|
2504
|
+
return ctx.previous;
|
|
2505
|
+
}
|
|
2506
|
+
return input;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
// src/hook-binder.ts
|
|
2510
|
+
var noopLogger2 = {
|
|
2511
|
+
debug: () => {
|
|
2512
|
+
},
|
|
2513
|
+
info: () => {
|
|
2514
|
+
},
|
|
2515
|
+
warn: () => {
|
|
2516
|
+
},
|
|
2517
|
+
error: () => {
|
|
2518
|
+
}
|
|
2519
|
+
};
|
|
2520
|
+
function bindHooksToEngine(engine, hooks, opts = {}) {
|
|
2521
|
+
const logger = opts.logger ?? noopLogger2;
|
|
2522
|
+
const result = { registered: 0, skipped: 0, errors: [] };
|
|
2523
|
+
if (!Array.isArray(hooks) || hooks.length === 0) {
|
|
2524
|
+
return result;
|
|
2525
|
+
}
|
|
2526
|
+
if (opts.packageId && typeof engine.unregisterHooksByPackage === "function") {
|
|
2527
|
+
try {
|
|
2528
|
+
engine.unregisterHooksByPackage(opts.packageId);
|
|
2529
|
+
} catch (err) {
|
|
2530
|
+
logger.warn("[hook-binder] unregister-by-package failed; continuing", {
|
|
2531
|
+
packageId: opts.packageId,
|
|
2532
|
+
error: err?.message
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
if (opts.functions && typeof engine.registerFunction === "function") {
|
|
2537
|
+
for (const [name, fn] of Object.entries(opts.functions)) {
|
|
2538
|
+
try {
|
|
2539
|
+
engine.registerFunction(name, fn, opts.packageId);
|
|
2540
|
+
} catch (err) {
|
|
2541
|
+
logger.warn("[hook-binder] failed to register function", {
|
|
2542
|
+
name,
|
|
2543
|
+
error: err?.message
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
for (const hook of hooks) {
|
|
2549
|
+
try {
|
|
2550
|
+
const resolved = resolveHandler(engine, hook, opts);
|
|
2551
|
+
if (!resolved) {
|
|
2552
|
+
result.skipped += 1;
|
|
2553
|
+
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";
|
|
2554
|
+
result.errors.push({ hook: hook.name, reason });
|
|
2555
|
+
if (opts.strict) {
|
|
2556
|
+
throw new Error(`[hook-binder] strict: cannot bind hook '${hook.name}': ${reason}`);
|
|
2557
|
+
}
|
|
2558
|
+
logger.warn("[hook-binder] skipping hook with unresolved handler", {
|
|
2559
|
+
hook: hook.name,
|
|
2560
|
+
handler: hook.handler,
|
|
2561
|
+
hasBody: Boolean(hook.body)
|
|
2562
|
+
});
|
|
2563
|
+
continue;
|
|
2564
|
+
}
|
|
2565
|
+
if (opts.warnLegacyHandler && !hook.body && typeof hook.handler === "string") {
|
|
2566
|
+
logger.warn("[hook-binder] DEPRECATED: hook uses legacy handler ref without body", {
|
|
2567
|
+
hook: hook.name,
|
|
2568
|
+
handler: hook.handler,
|
|
2569
|
+
hint: "Move the handler source into Hook.body so the artifact stays metadata-only and the .mjs runtime bundle can be dropped."
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
const wrapped = wrapDeclarativeHook(hook, resolved, { logger, metrics: opts.metrics });
|
|
2573
|
+
const objects = normalizeObjects(hook.object);
|
|
2574
|
+
const events = Array.isArray(hook.events) ? hook.events : [];
|
|
2575
|
+
for (const event of events) {
|
|
2576
|
+
for (const object of objects) {
|
|
2577
|
+
engine.registerHook(event, wrapped, {
|
|
2578
|
+
object,
|
|
2579
|
+
priority: typeof hook.priority === "number" ? hook.priority : 100,
|
|
2580
|
+
packageId: opts.packageId,
|
|
2581
|
+
// Reflect metadata so future tooling can introspect / unregister
|
|
2582
|
+
// and so we can detect duplicate name collisions.
|
|
2583
|
+
// The engine ignores unknown options today; this is forward-only.
|
|
2584
|
+
...{ meta: hook, hookName: hook.name }
|
|
2585
|
+
});
|
|
2586
|
+
result.registered += 1;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
} catch (err) {
|
|
2590
|
+
result.errors.push({ hook: hook.name, reason: err?.message ?? String(err) });
|
|
2591
|
+
logger.error("[hook-binder] failed to bind hook", {
|
|
2592
|
+
hook: hook.name,
|
|
2593
|
+
error: err?.message
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
if (result.registered > 0) {
|
|
2598
|
+
logger.debug("[hook-binder] hooks bound", {
|
|
2599
|
+
packageId: opts.packageId,
|
|
2600
|
+
registered: result.registered,
|
|
2601
|
+
skipped: result.skipped
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
return result;
|
|
2605
|
+
}
|
|
2606
|
+
function normalizeObjects(target) {
|
|
2607
|
+
if (Array.isArray(target)) return target.length > 0 ? target : ["*"];
|
|
2608
|
+
if (typeof target === "string" && target.length > 0) return [target];
|
|
2609
|
+
return ["*"];
|
|
2610
|
+
}
|
|
2611
|
+
function resolveHandler(engine, hook, opts) {
|
|
2612
|
+
const body = hook.body;
|
|
2613
|
+
if (body && typeof body === "object") {
|
|
2614
|
+
let runner = opts.bodyRunner;
|
|
2615
|
+
if (typeof runner !== "function") {
|
|
2616
|
+
const fallback = engine?._defaultBodyRunner;
|
|
2617
|
+
if (typeof fallback === "function") runner = fallback;
|
|
2618
|
+
}
|
|
2619
|
+
if (typeof runner !== "function") {
|
|
2620
|
+
return void 0;
|
|
2621
|
+
}
|
|
2622
|
+
const fn = runner(hook);
|
|
2623
|
+
if (typeof fn === "function") return fn;
|
|
2624
|
+
return void 0;
|
|
2625
|
+
}
|
|
2626
|
+
const h = hook.handler;
|
|
2627
|
+
if (typeof h === "function") return h;
|
|
2628
|
+
if (typeof h === "string" && h.length > 0) {
|
|
2629
|
+
const fromBundle = opts.functions?.[h];
|
|
2630
|
+
if (typeof fromBundle === "function") return fromBundle;
|
|
2631
|
+
if (typeof engine.resolveFunction === "function") {
|
|
2632
|
+
const fn = engine.resolveFunction(h);
|
|
2633
|
+
if (typeof fn === "function") return fn;
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
return void 0;
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
// src/validation/record-validator.ts
|
|
2640
|
+
var SKIP_FIELDS = /* @__PURE__ */ new Set([
|
|
2641
|
+
"id",
|
|
2642
|
+
"created_at",
|
|
2643
|
+
"created_by",
|
|
2644
|
+
"updated_at",
|
|
2645
|
+
"updated_by",
|
|
2646
|
+
"organization_id",
|
|
2647
|
+
"tenant_id"
|
|
2648
|
+
]);
|
|
2649
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
2650
|
+
var URL_RE = /^[a-z][a-z0-9+.\-]*:\/\/[^\s]+$/i;
|
|
2651
|
+
var PHONE_RE = /^[+()\-\s\d.]{5,}$/;
|
|
2652
|
+
var ValidationError = class extends Error {
|
|
2653
|
+
constructor(fields) {
|
|
2654
|
+
super(
|
|
2655
|
+
`Validation failed for ${fields.length} field(s): ` + fields.map((f) => `${f.field} (${f.code})`).join(", ")
|
|
2656
|
+
);
|
|
2657
|
+
this.code = "VALIDATION_FAILED";
|
|
2658
|
+
this.name = "ValidationError";
|
|
2659
|
+
this.fields = fields;
|
|
2660
|
+
}
|
|
2661
|
+
};
|
|
2662
|
+
function isMissing(v) {
|
|
2663
|
+
return v === void 0 || v === null || typeof v === "string" && v.trim() === "";
|
|
2664
|
+
}
|
|
2665
|
+
function optionValues(options) {
|
|
2666
|
+
if (!Array.isArray(options)) return [];
|
|
2667
|
+
return options.map(
|
|
2668
|
+
(o) => typeof o === "object" && o !== null ? String(o.value) : String(o)
|
|
2669
|
+
);
|
|
2670
|
+
}
|
|
2671
|
+
function validateOne(name, def, value) {
|
|
2672
|
+
if (def.required && isMissing(value)) {
|
|
2673
|
+
return { field: name, code: "required", message: `${name} is required` };
|
|
2674
|
+
}
|
|
2675
|
+
if (isMissing(value)) return null;
|
|
2676
|
+
const t = def.type;
|
|
2677
|
+
if (t === "text" || t === "textarea" || t === "email" || t === "url" || t === "phone" || t === "password" || t === "markdown" || t === "html" || t === "richtext" || t === "code") {
|
|
2678
|
+
const s = typeof value === "string" ? value : String(value);
|
|
2679
|
+
if (def.maxLength !== void 0 && s.length > def.maxLength) {
|
|
2680
|
+
return { field: name, code: "max_length", message: `${name} must be \u2264 ${def.maxLength} characters (got ${s.length})` };
|
|
2681
|
+
}
|
|
2682
|
+
if (def.minLength !== void 0 && s.length < def.minLength) {
|
|
2683
|
+
return { field: name, code: "min_length", message: `${name} must be \u2265 ${def.minLength} characters (got ${s.length})` };
|
|
2684
|
+
}
|
|
2685
|
+
if (t === "email" && !EMAIL_RE.test(s)) {
|
|
2686
|
+
return { field: name, code: "invalid_email", message: `${name} must be a valid email address` };
|
|
2687
|
+
}
|
|
2688
|
+
if (t === "url" && !URL_RE.test(s)) {
|
|
2689
|
+
return { field: name, code: "invalid_url", message: `${name} must be a valid URL (scheme://...)` };
|
|
2690
|
+
}
|
|
2691
|
+
if (t === "phone" && !PHONE_RE.test(s)) {
|
|
2692
|
+
return { field: name, code: "invalid_phone", message: `${name} must be a valid phone number` };
|
|
2693
|
+
}
|
|
2694
|
+
return null;
|
|
2695
|
+
}
|
|
2696
|
+
if (t === "number" || t === "currency" || t === "percent" || t === "rating" || t === "slider") {
|
|
2697
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
2698
|
+
if (!Number.isFinite(n)) {
|
|
2699
|
+
return { field: name, code: "invalid_number", message: `${name} must be a number` };
|
|
2700
|
+
}
|
|
2701
|
+
if (def.min !== void 0 && n < def.min) {
|
|
2702
|
+
return { field: name, code: "min_value", message: `${name} must be \u2265 ${def.min}` };
|
|
2703
|
+
}
|
|
2704
|
+
if (def.max !== void 0 && n > def.max) {
|
|
2705
|
+
return { field: name, code: "max_value", message: `${name} must be \u2264 ${def.max}` };
|
|
2706
|
+
}
|
|
2707
|
+
return null;
|
|
2708
|
+
}
|
|
2709
|
+
if (t === "boolean" || t === "toggle") {
|
|
2710
|
+
if (typeof value === "boolean") return null;
|
|
2711
|
+
if (value === 0 || value === 1 || value === "0" || value === "1" || value === "true" || value === "false") return null;
|
|
2712
|
+
return { field: name, code: "invalid_boolean", message: `${name} must be true or false` };
|
|
1491
2713
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
1497
|
-
return { success: true, data: { feedId: request.feedId, starred: false } };
|
|
2714
|
+
if (t === "date" || t === "datetime" || t === "time") {
|
|
2715
|
+
if (value instanceof Date) return null;
|
|
2716
|
+
if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return null;
|
|
2717
|
+
return { field: name, code: "invalid_date", message: `${name} must be a valid ${t} (ISO-8601)` };
|
|
1498
2718
|
}
|
|
1499
|
-
|
|
1500
|
-
const
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
limit: request.limit,
|
|
1506
|
-
cursor: request.cursor
|
|
1507
|
-
});
|
|
1508
|
-
const queryLower = (request.query || "").toLowerCase();
|
|
1509
|
-
const filtered = result.items.filter(
|
|
1510
|
-
(item) => item.body?.toLowerCase().includes(queryLower)
|
|
1511
|
-
);
|
|
1512
|
-
return { success: true, data: { items: filtered, total: filtered.length, hasMore: false } };
|
|
2719
|
+
if (t === "select" || t === "radio") {
|
|
2720
|
+
const allowed = optionValues(def.options);
|
|
2721
|
+
if (allowed.length > 0 && !allowed.includes(String(value))) {
|
|
2722
|
+
return { field: name, code: "invalid_option", message: `${name} must be one of: ${allowed.join(", ")}`, options: allowed };
|
|
2723
|
+
}
|
|
2724
|
+
return null;
|
|
1513
2725
|
}
|
|
1514
|
-
|
|
1515
|
-
const
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
id: item.id,
|
|
1525
|
-
object: item.object,
|
|
1526
|
-
recordId: item.recordId,
|
|
1527
|
-
actor: item.actor,
|
|
1528
|
-
changes: item.changes || [],
|
|
1529
|
-
timestamp: item.createdAt,
|
|
1530
|
-
source: item.source
|
|
1531
|
-
}));
|
|
1532
|
-
return { success: true, data: { entries, total: result.total, nextCursor: result.nextCursor, hasMore: result.hasMore } };
|
|
2726
|
+
if (t === "multiselect" || t === "checkboxes" || t === "tags") {
|
|
2727
|
+
const allowed = optionValues(def.options);
|
|
2728
|
+
if (allowed.length === 0) return null;
|
|
2729
|
+
const arr = Array.isArray(value) ? value : [value];
|
|
2730
|
+
for (const v of arr) {
|
|
2731
|
+
if (!allowed.includes(String(v))) {
|
|
2732
|
+
return { field: name, code: "invalid_option", message: `${name}: "${v}" is not one of: ${allowed.join(", ")}`, options: allowed };
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
return null;
|
|
1533
2736
|
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
2737
|
+
return null;
|
|
2738
|
+
}
|
|
2739
|
+
function validateRecord(objectSchema, data, mode) {
|
|
2740
|
+
if (!objectSchema?.fields || !data) return;
|
|
2741
|
+
const errors = [];
|
|
2742
|
+
const fields = objectSchema.fields;
|
|
2743
|
+
if (mode === "insert") {
|
|
2744
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
2745
|
+
if (SKIP_FIELDS.has(name)) continue;
|
|
2746
|
+
if (def.system || def.readonly) continue;
|
|
2747
|
+
const err = validateOne(name, def, data[name]);
|
|
2748
|
+
if (err) errors.push(err);
|
|
2749
|
+
}
|
|
2750
|
+
} else {
|
|
2751
|
+
for (const [name, value] of Object.entries(data)) {
|
|
2752
|
+
if (SKIP_FIELDS.has(name)) continue;
|
|
2753
|
+
const def = fields[name];
|
|
2754
|
+
if (!def) continue;
|
|
2755
|
+
if (def.system || def.readonly) continue;
|
|
2756
|
+
const err = validateOne(name, { ...def, required: false }, value);
|
|
2757
|
+
if (err) errors.push(err);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
if (errors.length > 0) throw new ValidationError(errors);
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
// src/in-memory-aggregation.ts
|
|
2764
|
+
function applyInMemoryAggregation(rows, ast) {
|
|
2765
|
+
const groupBy = ast.groupBy ?? [];
|
|
2766
|
+
const aggregations = ast.aggregations ?? [];
|
|
2767
|
+
if (groupBy.length === 0 && aggregations.length === 0) return rows;
|
|
2768
|
+
if (groupBy.length === 0) {
|
|
2769
|
+
return [aggregateBucket(rows, aggregations)];
|
|
2770
|
+
}
|
|
2771
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
2772
|
+
for (const row of rows) {
|
|
2773
|
+
const key = {};
|
|
2774
|
+
const parts = [];
|
|
2775
|
+
for (const g of groupBy) {
|
|
2776
|
+
const fieldName = typeof g === "string" ? g : g.alias ?? g.field;
|
|
2777
|
+
const value = projectGroupValue(row, g);
|
|
2778
|
+
key[fieldName] = value;
|
|
2779
|
+
parts.push(`${fieldName}=${value}`);
|
|
2780
|
+
}
|
|
2781
|
+
const id = parts.join("");
|
|
2782
|
+
let bucket = buckets.get(id);
|
|
2783
|
+
if (!bucket) {
|
|
2784
|
+
bucket = { key, rows: [] };
|
|
2785
|
+
buckets.set(id, bucket);
|
|
2786
|
+
}
|
|
2787
|
+
bucket.rows.push(row);
|
|
2788
|
+
}
|
|
2789
|
+
const out = [];
|
|
2790
|
+
for (const { key, rows: bucketRows } of buckets.values()) {
|
|
2791
|
+
const aggValues = aggregateBucket(bucketRows, aggregations);
|
|
2792
|
+
out.push({ ...key, ...aggValues });
|
|
2793
|
+
}
|
|
2794
|
+
return out;
|
|
2795
|
+
}
|
|
2796
|
+
function projectGroupValue(row, g) {
|
|
2797
|
+
const field = typeof g === "string" ? g : g.field;
|
|
2798
|
+
const v = row?.[field];
|
|
2799
|
+
if (typeof g !== "string" && g.dateGranularity) {
|
|
2800
|
+
return bucketDateValue(v, g.dateGranularity);
|
|
1544
2801
|
}
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
2802
|
+
return v == null ? "(null)" : String(v);
|
|
2803
|
+
}
|
|
2804
|
+
function aggregateBucket(rows, aggregations) {
|
|
2805
|
+
const out = {};
|
|
2806
|
+
for (const agg of aggregations) {
|
|
2807
|
+
const alias = agg.alias;
|
|
2808
|
+
const fn = agg.function;
|
|
2809
|
+
if (fn === "count") {
|
|
2810
|
+
if (!agg.field) {
|
|
2811
|
+
out[alias] = rows.length;
|
|
2812
|
+
} else {
|
|
2813
|
+
out[alias] = rows.reduce(
|
|
2814
|
+
(acc, r) => r[agg.field] != null ? acc + 1 : acc,
|
|
2815
|
+
0
|
|
2816
|
+
);
|
|
2817
|
+
}
|
|
2818
|
+
continue;
|
|
2819
|
+
}
|
|
2820
|
+
const field = agg.field;
|
|
2821
|
+
if (!field) {
|
|
2822
|
+
out[alias] = null;
|
|
2823
|
+
continue;
|
|
2824
|
+
}
|
|
2825
|
+
const values = collectValues(rows, field, !!agg.distinct);
|
|
2826
|
+
switch (fn) {
|
|
2827
|
+
case "count_distinct":
|
|
2828
|
+
out[alias] = new Set(values.filter((v) => v != null)).size;
|
|
2829
|
+
break;
|
|
2830
|
+
case "sum":
|
|
2831
|
+
out[alias] = values.reduce((a, b) => a + toNumber(b), 0);
|
|
2832
|
+
break;
|
|
2833
|
+
case "avg": {
|
|
2834
|
+
const nums = values.filter((v) => v != null).map(toNumber);
|
|
2835
|
+
out[alias] = nums.length === 0 ? null : nums.reduce((a, b) => a + b, 0) / nums.length;
|
|
2836
|
+
break;
|
|
2837
|
+
}
|
|
2838
|
+
case "min": {
|
|
2839
|
+
const defined = values.filter((v) => v != null);
|
|
2840
|
+
out[alias] = defined.length === 0 ? null : defined.reduce((a, b) => a < b ? a : b);
|
|
2841
|
+
break;
|
|
2842
|
+
}
|
|
2843
|
+
case "max": {
|
|
2844
|
+
const defined = values.filter((v) => v != null);
|
|
2845
|
+
out[alias] = defined.length === 0 ? null : defined.reduce((a, b) => a > b ? a : b);
|
|
2846
|
+
break;
|
|
2847
|
+
}
|
|
2848
|
+
case "array_agg":
|
|
2849
|
+
out[alias] = values.slice();
|
|
2850
|
+
break;
|
|
2851
|
+
case "string_agg":
|
|
2852
|
+
out[alias] = values.filter((v) => v != null).map(String).join(",");
|
|
2853
|
+
break;
|
|
2854
|
+
default:
|
|
2855
|
+
out[alias] = null;
|
|
2856
|
+
}
|
|
1549
2857
|
}
|
|
1550
|
-
|
|
2858
|
+
return out;
|
|
2859
|
+
}
|
|
2860
|
+
function collectValues(rows, field, distinct) {
|
|
2861
|
+
if (!distinct) return rows.map((r) => r?.[field]);
|
|
2862
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2863
|
+
const out = [];
|
|
2864
|
+
for (const r of rows) {
|
|
2865
|
+
const v = r?.[field];
|
|
2866
|
+
if (seen.has(v)) continue;
|
|
2867
|
+
seen.add(v);
|
|
2868
|
+
out.push(v);
|
|
2869
|
+
}
|
|
2870
|
+
return out;
|
|
2871
|
+
}
|
|
2872
|
+
function toNumber(v) {
|
|
2873
|
+
if (typeof v === "number") return v;
|
|
2874
|
+
if (v == null) return 0;
|
|
2875
|
+
const n = Number(v);
|
|
2876
|
+
return Number.isFinite(n) ? n : 0;
|
|
2877
|
+
}
|
|
2878
|
+
function bucketDateValue(value, granularity) {
|
|
2879
|
+
if (value == null) return "(null)";
|
|
2880
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
2881
|
+
if (Number.isNaN(d.getTime())) return "(null)";
|
|
2882
|
+
const y = d.getUTCFullYear();
|
|
2883
|
+
const m = d.getUTCMonth() + 1;
|
|
2884
|
+
switch (granularity) {
|
|
2885
|
+
case "year":
|
|
2886
|
+
return String(y);
|
|
2887
|
+
case "quarter":
|
|
2888
|
+
return `${y}-Q${Math.floor((m - 1) / 3) + 1}`;
|
|
2889
|
+
case "month":
|
|
2890
|
+
return `${y}-${String(m).padStart(2, "0")}`;
|
|
2891
|
+
case "day":
|
|
2892
|
+
return `${y}-${String(m).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
|
2893
|
+
case "week": {
|
|
2894
|
+
const target = new Date(Date.UTC(y, d.getUTCMonth(), d.getUTCDate()));
|
|
2895
|
+
const dayNum = (target.getUTCDay() + 6) % 7;
|
|
2896
|
+
target.setUTCDate(target.getUTCDate() - dayNum + 3);
|
|
2897
|
+
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
|
|
2898
|
+
const weekNo = 1 + Math.round(
|
|
2899
|
+
((target.getTime() - firstThursday.getTime()) / 864e5 - 3 + (firstThursday.getUTCDay() + 6) % 7) / 7
|
|
2900
|
+
);
|
|
2901
|
+
return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
|
|
2902
|
+
}
|
|
2903
|
+
default:
|
|
2904
|
+
return String(value);
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
1551
2907
|
|
|
1552
2908
|
// src/engine.ts
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
2909
|
+
function planFormulaProjection(schema, requestedFields) {
|
|
2910
|
+
if (!schema?.fields) return { plan: [] };
|
|
2911
|
+
const allFieldNames = Object.keys(schema.fields);
|
|
2912
|
+
const targets = Array.isArray(requestedFields) && requestedFields.length > 0 ? requestedFields : allFieldNames;
|
|
2913
|
+
const plan = [];
|
|
2914
|
+
const projected = /* @__PURE__ */ new Set();
|
|
2915
|
+
for (const f of targets) {
|
|
2916
|
+
const def = schema.fields[f];
|
|
2917
|
+
if (def?.type === "formula" && def.expression) {
|
|
2918
|
+
const expr = typeof def.expression === "string" ? { dialect: "cel", source: def.expression } : def.expression;
|
|
2919
|
+
plan.push({ name: f, expression: expr });
|
|
2920
|
+
import_formula2.ExpressionEngine.compile(expr);
|
|
2921
|
+
} else if (Array.isArray(requestedFields) && requestedFields.length > 0) {
|
|
2922
|
+
projected.add(f);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
if (plan.length === 0) return { plan: [] };
|
|
2926
|
+
if (Array.isArray(requestedFields) && requestedFields.length > 0) {
|
|
2927
|
+
if (!projected.has("id")) projected.add("id");
|
|
2928
|
+
for (const fname of allFieldNames) {
|
|
2929
|
+
const fdef = schema.fields[fname];
|
|
2930
|
+
if (fdef?.type === "formula") continue;
|
|
2931
|
+
projected.add(fname);
|
|
2932
|
+
}
|
|
2933
|
+
return { plan, projected: Array.from(projected) };
|
|
2934
|
+
}
|
|
2935
|
+
return { plan };
|
|
2936
|
+
}
|
|
2937
|
+
function applyFormulaPlan(plan, records) {
|
|
2938
|
+
if (!plan.length) return;
|
|
2939
|
+
for (const rec of records) {
|
|
2940
|
+
if (rec == null) continue;
|
|
2941
|
+
for (const fp of plan) {
|
|
2942
|
+
const r = import_formula2.ExpressionEngine.evaluate(fp.expression, { record: rec });
|
|
2943
|
+
rec[fp.name] = r.ok ? r.value : null;
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
function resolveMetadataItemName(key, item) {
|
|
2948
|
+
if (!item) return void 0;
|
|
2949
|
+
if (item.name) return item.name;
|
|
2950
|
+
if (item.id) return item.id;
|
|
2951
|
+
if (key === "views") {
|
|
2952
|
+
return item?.list?.data?.object || item?.form?.data?.object || void 0;
|
|
2953
|
+
}
|
|
2954
|
+
return void 0;
|
|
2955
|
+
}
|
|
1557
2956
|
var _ObjectQL = class _ObjectQL {
|
|
1558
2957
|
constructor(hostContext = {}) {
|
|
1559
2958
|
this.drivers = /* @__PURE__ */ new Map();
|
|
@@ -1577,10 +2976,29 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1577
2976
|
this.middlewares = [];
|
|
1578
2977
|
// Action registry: key = "objectName:actionName"
|
|
1579
2978
|
this.actions = /* @__PURE__ */ new Map();
|
|
2979
|
+
// Function registry: name → handler. Used by `bindHooksToEngine` to
|
|
2980
|
+
// resolve string-named hook handlers (the JSON-safe form). Populated by
|
|
2981
|
+
// `defineStack({ functions })` via `AppPlugin`, or directly via
|
|
2982
|
+
// `engine.registerFunction(...)`.
|
|
2983
|
+
this.functions = /* @__PURE__ */ new Map();
|
|
1580
2984
|
// Host provided context additions (e.g. Server router)
|
|
1581
2985
|
this.hostContext = {};
|
|
2986
|
+
// Per-engine SchemaRegistry instance.
|
|
2987
|
+
//
|
|
2988
|
+
// Historically SchemaRegistry was a process-wide singleton of static state,
|
|
2989
|
+
// which broke multi-project servers: a project kernel would inherit every
|
|
2990
|
+
// object registered by the control plane (e.g. sys_metadata), and
|
|
2991
|
+
// getDriver()'s owner lookup would route CRUD to the wrong database. Each
|
|
2992
|
+
// engine now owns its registry so kernels are fully isolated.
|
|
2993
|
+
this._registry = new SchemaRegistry();
|
|
1582
2994
|
this.hostContext = hostContext;
|
|
1583
2995
|
this.logger = hostContext.logger || (0, import_core.createLogger)({ level: "info", format: "pretty" });
|
|
2996
|
+
if (process?.env?.OBJECTQL_STRICT_HOOKS === "1") {
|
|
2997
|
+
this._strictHookBinding = true;
|
|
2998
|
+
}
|
|
2999
|
+
if (process?.env?.OBJECTQL_WARN_LEGACY_HANDLER === "1") {
|
|
3000
|
+
this._warnLegacyHandler = true;
|
|
3001
|
+
}
|
|
1584
3002
|
this.logger.info("ObjectQL Engine Instance Created");
|
|
1585
3003
|
}
|
|
1586
3004
|
/**
|
|
@@ -1596,10 +3014,13 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1596
3014
|
};
|
|
1597
3015
|
}
|
|
1598
3016
|
/**
|
|
1599
|
-
* Expose the SchemaRegistry for plugins to register metadata
|
|
3017
|
+
* Expose the SchemaRegistry for plugins to register metadata.
|
|
3018
|
+
*
|
|
3019
|
+
* Returns the per-engine instance, NOT the class. Each ObjectQL engine
|
|
3020
|
+
* owns its registry so multi-project kernels remain isolated.
|
|
1600
3021
|
*/
|
|
1601
3022
|
get registry() {
|
|
1602
|
-
return
|
|
3023
|
+
return this._registry;
|
|
1603
3024
|
}
|
|
1604
3025
|
/**
|
|
1605
3026
|
* Load and Register a Plugin
|
|
@@ -1645,11 +3066,121 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1645
3066
|
handler,
|
|
1646
3067
|
object: options?.object,
|
|
1647
3068
|
priority: options?.priority ?? 100,
|
|
1648
|
-
packageId: options?.packageId
|
|
3069
|
+
packageId: options?.packageId,
|
|
3070
|
+
meta: options?.meta,
|
|
3071
|
+
hookName: options?.hookName
|
|
1649
3072
|
});
|
|
1650
3073
|
entries.sort((a, b) => a.priority - b.priority);
|
|
1651
3074
|
this.logger.debug("Registered hook", { event, object: options?.object, priority: options?.priority ?? 100, totalHandlers: entries.length });
|
|
1652
3075
|
}
|
|
3076
|
+
/**
|
|
3077
|
+
* Remove all hooks registered under a given `packageId`. Used by
|
|
3078
|
+
* `bindHooksToEngine` to make re-binding (hot reload, app reinstall)
|
|
3079
|
+
* idempotent, and by app uninstall flows.
|
|
3080
|
+
*/
|
|
3081
|
+
unregisterHooksByPackage(packageId) {
|
|
3082
|
+
if (!packageId) return 0;
|
|
3083
|
+
let removed = 0;
|
|
3084
|
+
for (const [event, entries] of this.hooks.entries()) {
|
|
3085
|
+
const before = entries.length;
|
|
3086
|
+
const kept = entries.filter((e) => e.packageId !== packageId);
|
|
3087
|
+
if (kept.length !== before) {
|
|
3088
|
+
this.hooks.set(event, kept);
|
|
3089
|
+
removed += before - kept.length;
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
if (removed > 0) {
|
|
3093
|
+
this.logger.debug("Unregistered hooks by package", { packageId, removed });
|
|
3094
|
+
}
|
|
3095
|
+
return removed;
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* Register a named function handler that can later be referenced by
|
|
3099
|
+
* string from a `Hook.handler` field. This is the JSON-safe form of
|
|
3100
|
+
* handler binding — declarative metadata persisted to disk or shipped
|
|
3101
|
+
* over the wire only carries the name.
|
|
3102
|
+
*/
|
|
3103
|
+
registerFunction(name, handler, packageId) {
|
|
3104
|
+
if (!name || typeof handler !== "function") return;
|
|
3105
|
+
this.functions.set(name, { handler, packageId });
|
|
3106
|
+
this.logger.debug("Registered function", { name, packageId });
|
|
3107
|
+
}
|
|
3108
|
+
/** Look up a registered function by name. */
|
|
3109
|
+
resolveFunction(name) {
|
|
3110
|
+
return this.functions.get(name)?.handler;
|
|
3111
|
+
}
|
|
3112
|
+
/** Remove all functions registered under a given `packageId`. */
|
|
3113
|
+
unregisterFunctionsByPackage(packageId) {
|
|
3114
|
+
if (!packageId) return 0;
|
|
3115
|
+
let removed = 0;
|
|
3116
|
+
for (const [name, entry] of this.functions.entries()) {
|
|
3117
|
+
if (entry.packageId === packageId) {
|
|
3118
|
+
this.functions.delete(name);
|
|
3119
|
+
removed += 1;
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
if (removed > 0) {
|
|
3123
|
+
this.logger.debug("Unregistered functions by package", { packageId, removed });
|
|
3124
|
+
}
|
|
3125
|
+
return removed;
|
|
3126
|
+
}
|
|
3127
|
+
/**
|
|
3128
|
+
* Bind a list of declarative `Hook` metadata definitions to this engine.
|
|
3129
|
+
*
|
|
3130
|
+
* Convenience proxy to the canonical `bindHooksToEngine` so callers do
|
|
3131
|
+
* not need a separate import. Use `import { bindHooksToEngine } from
|
|
3132
|
+
* '@objectstack/objectql'` directly when you want the result object.
|
|
3133
|
+
*/
|
|
3134
|
+
bindHooks(hooks, opts) {
|
|
3135
|
+
const merged = { ...opts ?? {}, logger: this.logger };
|
|
3136
|
+
if (!merged.bodyRunner && this._defaultBodyRunner) {
|
|
3137
|
+
merged.bodyRunner = this._defaultBodyRunner;
|
|
3138
|
+
}
|
|
3139
|
+
if (merged.strict === void 0 && this._strictHookBinding) {
|
|
3140
|
+
merged.strict = true;
|
|
3141
|
+
}
|
|
3142
|
+
if (merged.warnLegacyHandler === void 0 && this._warnLegacyHandler) {
|
|
3143
|
+
merged.warnLegacyHandler = true;
|
|
3144
|
+
}
|
|
3145
|
+
if (!merged.metrics && this._hookMetricsRecorder) {
|
|
3146
|
+
merged.metrics = this._hookMetricsRecorder;
|
|
3147
|
+
}
|
|
3148
|
+
bindHooksToEngine(this, hooks, merged);
|
|
3149
|
+
}
|
|
3150
|
+
/**
|
|
3151
|
+
* Install a default body-runner used when `bindHooks` is called without
|
|
3152
|
+
* an explicit one. The runtime layer sets this once on each per-project
|
|
3153
|
+
* engine so every binding path (template seed, metadata sync, AppPlugin)
|
|
3154
|
+
* can execute hook `body.source` consistently.
|
|
3155
|
+
*/
|
|
3156
|
+
setDefaultBodyRunner(runner) {
|
|
3157
|
+
this._defaultBodyRunner = runner;
|
|
3158
|
+
}
|
|
3159
|
+
/**
|
|
3160
|
+
* Toggle strict hook-binding mode for this engine. When enabled, every
|
|
3161
|
+
* subsequent `bindHooks` call rejects on the first unresolved hook
|
|
3162
|
+
* instead of silently warning. Production runtimes should enable this.
|
|
3163
|
+
*/
|
|
3164
|
+
setStrictHookBinding(strict) {
|
|
3165
|
+
this._strictHookBinding = strict;
|
|
3166
|
+
}
|
|
3167
|
+
/** Toggle deprecation warnings for hooks still using legacy `handler` ref. */
|
|
3168
|
+
setWarnLegacyHandler(warn) {
|
|
3169
|
+
this._warnLegacyHandler = warn;
|
|
3170
|
+
}
|
|
3171
|
+
/**
|
|
3172
|
+
* Install a metrics recorder used by every subsequent `bindHooks` call.
|
|
3173
|
+
* The recorder's methods are invoked per-execution to count outcomes
|
|
3174
|
+
* (success / error / timeout / capability_rejected), skips, and retries.
|
|
3175
|
+
* Defaults to no-op so the engine pays zero cost when nobody is observing.
|
|
3176
|
+
*/
|
|
3177
|
+
setHookMetricsRecorder(recorder) {
|
|
3178
|
+
this._hookMetricsRecorder = recorder;
|
|
3179
|
+
}
|
|
3180
|
+
/** Read the engine's installed metrics recorder, if any. */
|
|
3181
|
+
getHookMetricsRecorder() {
|
|
3182
|
+
return this._hookMetricsRecorder;
|
|
3183
|
+
}
|
|
1653
3184
|
async triggerHooks(event, context) {
|
|
1654
3185
|
const entries = this.hooks.get(event) || [];
|
|
1655
3186
|
if (entries.length === 0) {
|
|
@@ -1742,9 +3273,98 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1742
3273
|
userId: execCtx.userId,
|
|
1743
3274
|
tenantId: execCtx.tenantId,
|
|
1744
3275
|
roles: execCtx.roles,
|
|
1745
|
-
accessToken: execCtx.accessToken
|
|
3276
|
+
accessToken: execCtx.accessToken,
|
|
3277
|
+
// Propagate system-elevated flag so hooks can distinguish engine
|
|
3278
|
+
// self-writes (e.g. approval status mirror) from genuine user writes.
|
|
3279
|
+
...execCtx.isSystem ? { isSystem: true } : {}
|
|
1746
3280
|
};
|
|
1747
3281
|
}
|
|
3282
|
+
/**
|
|
3283
|
+
* Build the DriverOptions blob passed to every IDataDriver call.
|
|
3284
|
+
*
|
|
3285
|
+
* Always carries `tenantId` from the active ExecutionContext so the
|
|
3286
|
+
* driver can enforce per-tenant isolation (SQL driver auto-scopes reads
|
|
3287
|
+
* and auto-injects the tenant column on writes). Existing user-supplied
|
|
3288
|
+
* shapes (transactions, AST extras) are preserved by spreading them
|
|
3289
|
+
* first.
|
|
3290
|
+
*
|
|
3291
|
+
* System / isSystem callers may still cross tenants by clearing
|
|
3292
|
+
* `tenantId` themselves on the resulting object; this helper does not
|
|
3293
|
+
* mask the system path.
|
|
3294
|
+
*/
|
|
3295
|
+
buildDriverOptions(execCtx, base) {
|
|
3296
|
+
const hasTx = execCtx?.transaction !== void 0;
|
|
3297
|
+
const hasTenant = execCtx?.tenantId !== void 0;
|
|
3298
|
+
const isSystem = execCtx?.isSystem === true;
|
|
3299
|
+
if (!hasTx && !hasTenant && !isSystem) return base;
|
|
3300
|
+
const opts = base && typeof base === "object" ? { ...base } : {};
|
|
3301
|
+
if (hasTx && opts.transaction === void 0) {
|
|
3302
|
+
opts.transaction = execCtx.transaction;
|
|
3303
|
+
}
|
|
3304
|
+
if (hasTenant && opts.tenantId === void 0) {
|
|
3305
|
+
opts.tenantId = execCtx.tenantId;
|
|
3306
|
+
}
|
|
3307
|
+
if (isSystem && opts.bypassTenantAudit === void 0) {
|
|
3308
|
+
opts.bypassTenantAudit = true;
|
|
3309
|
+
}
|
|
3310
|
+
return opts;
|
|
3311
|
+
}
|
|
3312
|
+
/**
|
|
3313
|
+
* Build a HookContext.api: a ScopedContext that hooks can use to
|
|
3314
|
+
* read/write other objects within the same execution context.
|
|
3315
|
+
* Falls back to a system-elevated empty context when no execCtx
|
|
3316
|
+
* is supplied (e.g. system-triggered hooks).
|
|
3317
|
+
*/
|
|
3318
|
+
buildHookApi(execCtx) {
|
|
3319
|
+
const safeCtx = execCtx ?? { isSystem: true };
|
|
3320
|
+
return new ScopedContext(safeCtx, this);
|
|
3321
|
+
}
|
|
3322
|
+
/**
|
|
3323
|
+
* Apply field defaults to an incoming insert payload. Defaults that are
|
|
3324
|
+
* Expression envelopes (e.g. `{ dialect: 'cel', source: 'today()' }`,
|
|
3325
|
+
* `{ dialect: 'cel', source: 'os.user.id' }`) are evaluated via
|
|
3326
|
+
* `ExpressionEngine` against the calling user/org/now snapshot. Static
|
|
3327
|
+
* defaults are applied verbatim. Records that already supplied a value for a
|
|
3328
|
+
* field are left untouched.
|
|
3329
|
+
*
|
|
3330
|
+
* Implements ROADMAP §M9.9b — `defaultValue` accepts Expression so authors
|
|
3331
|
+
* can replace "write a hook to default to today/current-user" with a
|
|
3332
|
+
* declarative `defaultValue: cel\`today()\``.
|
|
3333
|
+
*/
|
|
3334
|
+
applyFieldDefaults(object, record, execCtx, nowSnapshot) {
|
|
3335
|
+
const schema = this.getSchema(object);
|
|
3336
|
+
const fieldsRaw = schema?.fields;
|
|
3337
|
+
if (!fieldsRaw || typeof fieldsRaw !== "object") return record;
|
|
3338
|
+
const fieldEntries = Array.isArray(fieldsRaw) ? fieldsRaw : Object.entries(fieldsRaw).map(([name, def]) => ({ name, ...def }));
|
|
3339
|
+
const out = { ...record };
|
|
3340
|
+
const now = nowSnapshot ?? /* @__PURE__ */ new Date();
|
|
3341
|
+
for (const f of fieldEntries) {
|
|
3342
|
+
if (out[f.name] !== void 0) continue;
|
|
3343
|
+
if (f.defaultValue == null) continue;
|
|
3344
|
+
const dv = f.defaultValue;
|
|
3345
|
+
if (typeof dv === "object" && dv !== null && dv.dialect && typeof dv.source === "string") {
|
|
3346
|
+
const result = import_formula2.ExpressionEngine.evaluate(dv, {
|
|
3347
|
+
now,
|
|
3348
|
+
user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : void 0,
|
|
3349
|
+
org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : void 0,
|
|
3350
|
+
record: out,
|
|
3351
|
+
extra: { object }
|
|
3352
|
+
});
|
|
3353
|
+
if (result.ok) {
|
|
3354
|
+
out[f.name] = result.value;
|
|
3355
|
+
} else {
|
|
3356
|
+
this.logger.warn("Failed to evaluate default expression", {
|
|
3357
|
+
object,
|
|
3358
|
+
field: f.name,
|
|
3359
|
+
error: result.error
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
3362
|
+
} else {
|
|
3363
|
+
out[f.name] = dv;
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
return out;
|
|
3367
|
+
}
|
|
1748
3368
|
/**
|
|
1749
3369
|
* Register contribution (Manifest)
|
|
1750
3370
|
*
|
|
@@ -1759,23 +3379,24 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1759
3379
|
const id = manifest.id || manifest.name;
|
|
1760
3380
|
const namespace = manifest.namespace;
|
|
1761
3381
|
this.logger.debug("Registering package manifest", { id, namespace });
|
|
3382
|
+
console.warn(`[ObjectQL:registerApp] id=${id} flows=${Array.isArray(manifest.flows) ? manifest.flows.length : typeof manifest.flows} keys=${Object.keys(manifest).join(",")}`);
|
|
1762
3383
|
if (id) {
|
|
1763
3384
|
this.manifests.set(id, manifest);
|
|
1764
3385
|
}
|
|
1765
|
-
|
|
3386
|
+
this._registry.installPackage(manifest);
|
|
1766
3387
|
this.logger.debug("Installed Package", { id: manifest.id, name: manifest.name, namespace });
|
|
1767
3388
|
if (manifest.objects) {
|
|
1768
3389
|
if (Array.isArray(manifest.objects)) {
|
|
1769
3390
|
this.logger.debug("Registering objects from manifest (Array)", { id, objectCount: manifest.objects.length });
|
|
1770
3391
|
for (const objDef of manifest.objects) {
|
|
1771
|
-
const fqn =
|
|
3392
|
+
const fqn = this._registry.registerObject(objDef, id, namespace, "own");
|
|
1772
3393
|
this.logger.debug("Registered Object", { fqn, from: id });
|
|
1773
3394
|
}
|
|
1774
3395
|
} else {
|
|
1775
3396
|
this.logger.debug("Registering objects from manifest (Map)", { id, objectCount: Object.keys(manifest.objects).length });
|
|
1776
3397
|
for (const [name, objDef] of Object.entries(manifest.objects)) {
|
|
1777
3398
|
objDef.name = name;
|
|
1778
|
-
const fqn =
|
|
3399
|
+
const fqn = this._registry.registerObject(objDef, id, namespace, "own");
|
|
1779
3400
|
this.logger.debug("Registered Object", { fqn, from: id });
|
|
1780
3401
|
}
|
|
1781
3402
|
}
|
|
@@ -1795,7 +3416,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1795
3416
|
validations: ext.validations,
|
|
1796
3417
|
indexes: ext.indexes
|
|
1797
3418
|
};
|
|
1798
|
-
|
|
3419
|
+
this._registry.registerObject(extDef, id, void 0, "extend", priority);
|
|
1799
3420
|
this.logger.debug("Registered Object Extension", { target: targetFqn, priority, from: id });
|
|
1800
3421
|
}
|
|
1801
3422
|
}
|
|
@@ -1804,13 +3425,15 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1804
3425
|
for (const app of manifest.apps) {
|
|
1805
3426
|
const appName = app.name || app.id;
|
|
1806
3427
|
if (appName) {
|
|
1807
|
-
|
|
3428
|
+
const resolved = namespace ? this.resolveNavObjectNames(app, namespace) : app;
|
|
3429
|
+
this._registry.registerApp(resolved, id);
|
|
1808
3430
|
this.logger.debug("Registered App", { app: appName, from: id });
|
|
1809
3431
|
}
|
|
1810
3432
|
}
|
|
1811
3433
|
}
|
|
1812
3434
|
if (manifest.name && manifest.navigation && !manifest.apps?.length) {
|
|
1813
|
-
|
|
3435
|
+
const resolved = namespace ? this.resolveNavObjectNames(manifest, namespace) : manifest;
|
|
3436
|
+
this._registry.registerApp(resolved, id);
|
|
1814
3437
|
this.logger.debug("Registered manifest-as-app", { app: manifest.name, from: id });
|
|
1815
3438
|
}
|
|
1816
3439
|
const metadataArrayKeys = [
|
|
@@ -1849,9 +3472,12 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1849
3472
|
if (Array.isArray(items) && items.length > 0) {
|
|
1850
3473
|
this.logger.debug(`Registering ${key} from manifest`, { id, count: items.length });
|
|
1851
3474
|
for (const item of items) {
|
|
1852
|
-
const itemName =
|
|
3475
|
+
const itemName = resolveMetadataItemName(key, item);
|
|
1853
3476
|
if (itemName) {
|
|
1854
|
-
|
|
3477
|
+
const toRegister = item.name === itemName ? item : { ...item, name: itemName };
|
|
3478
|
+
this._registry.registerItem((0, import_shared2.pluralToSingular)(key), toRegister, "name", id);
|
|
3479
|
+
} else {
|
|
3480
|
+
this.logger.warn(`Skipping ${(0, import_shared2.pluralToSingular)(key)} without a derivable name`, { id });
|
|
1855
3481
|
}
|
|
1856
3482
|
}
|
|
1857
3483
|
}
|
|
@@ -1861,14 +3487,14 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1861
3487
|
this.logger.debug("Registering seed data datasets", { id, count: seedData.length });
|
|
1862
3488
|
for (const dataset of seedData) {
|
|
1863
3489
|
if (dataset.object) {
|
|
1864
|
-
|
|
3490
|
+
this._registry.registerItem("data", dataset, "object", id);
|
|
1865
3491
|
}
|
|
1866
3492
|
}
|
|
1867
3493
|
}
|
|
1868
3494
|
if (manifest.contributes?.kinds) {
|
|
1869
3495
|
this.logger.debug("Registering kinds from manifest", { id, kindCount: manifest.contributes.kinds.length });
|
|
1870
3496
|
for (const kind of manifest.contributes.kinds) {
|
|
1871
|
-
|
|
3497
|
+
this._registry.registerKind(kind);
|
|
1872
3498
|
this.logger.debug("Registered Kind", { kind: kind.name || kind.type, from: id });
|
|
1873
3499
|
}
|
|
1874
3500
|
}
|
|
@@ -1883,6 +3509,25 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1883
3509
|
}
|
|
1884
3510
|
}
|
|
1885
3511
|
}
|
|
3512
|
+
/**
|
|
3513
|
+
* Deep-clone an app definition, resolving objectName references in navigation
|
|
3514
|
+
* items via the registry. Object names are canonical identifiers — no FQN
|
|
3515
|
+
* expansion is applied.
|
|
3516
|
+
*/
|
|
3517
|
+
resolveNavObjectNames(app, namespace) {
|
|
3518
|
+
if (!app.navigation) return app;
|
|
3519
|
+
const resolveItems = (items) => items.map((item) => {
|
|
3520
|
+
const resolved = { ...item };
|
|
3521
|
+
if (resolved.objectName && !resolved.objectName.includes("__")) {
|
|
3522
|
+
resolved.objectName = computeFQN(namespace, resolved.objectName);
|
|
3523
|
+
}
|
|
3524
|
+
if (Array.isArray(resolved.children)) {
|
|
3525
|
+
resolved.children = resolveItems(resolved.children);
|
|
3526
|
+
}
|
|
3527
|
+
return resolved;
|
|
3528
|
+
});
|
|
3529
|
+
return { ...app, navigation: resolveItems(app.navigation) };
|
|
3530
|
+
}
|
|
1886
3531
|
/**
|
|
1887
3532
|
* Register a nested plugin's metadata (objects, actions, views, etc.)
|
|
1888
3533
|
*
|
|
@@ -1903,7 +3548,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1903
3548
|
if (Array.isArray(plugin.objects)) {
|
|
1904
3549
|
this.logger.debug("Registering plugin objects (Array)", { pluginName, count: plugin.objects.length });
|
|
1905
3550
|
for (const objDef of plugin.objects) {
|
|
1906
|
-
const fqn =
|
|
3551
|
+
const fqn = this._registry.registerObject(objDef, ownerId, pluginNamespace, "own");
|
|
1907
3552
|
this.logger.debug("Registered Object", { fqn, from: pluginName });
|
|
1908
3553
|
}
|
|
1909
3554
|
} else {
|
|
@@ -1911,7 +3556,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1911
3556
|
this.logger.debug("Registering plugin objects (Map)", { pluginName, count: entries.length });
|
|
1912
3557
|
for (const [name, objDef] of entries) {
|
|
1913
3558
|
objDef.name = name;
|
|
1914
|
-
const fqn =
|
|
3559
|
+
const fqn = this._registry.registerObject(objDef, ownerId, pluginNamespace, "own");
|
|
1915
3560
|
this.logger.debug("Registered Object", { fqn, from: pluginName });
|
|
1916
3561
|
}
|
|
1917
3562
|
}
|
|
@@ -1921,7 +3566,8 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1921
3566
|
}
|
|
1922
3567
|
if (plugin.name && plugin.navigation) {
|
|
1923
3568
|
try {
|
|
1924
|
-
|
|
3569
|
+
const resolved = pluginNamespace ? this.resolveNavObjectNames(plugin, pluginNamespace) : plugin;
|
|
3570
|
+
this._registry.registerApp(resolved, ownerId);
|
|
1925
3571
|
this.logger.debug("Registered plugin-as-app", { app: plugin.name, from: pluginName });
|
|
1926
3572
|
} catch (err) {
|
|
1927
3573
|
this.logger.warn("Failed to register plugin as app", { pluginName, error: err.message });
|
|
@@ -1955,9 +3601,10 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1955
3601
|
const items = plugin[key];
|
|
1956
3602
|
if (Array.isArray(items) && items.length > 0) {
|
|
1957
3603
|
for (const item of items) {
|
|
1958
|
-
const itemName =
|
|
3604
|
+
const itemName = resolveMetadataItemName(key, item);
|
|
1959
3605
|
if (itemName) {
|
|
1960
|
-
|
|
3606
|
+
const toRegister = item.name === itemName ? item : { ...item, name: itemName };
|
|
3607
|
+
this._registry.registerItem((0, import_shared2.pluralToSingular)(key), toRegister, "name", ownerId);
|
|
1961
3608
|
}
|
|
1962
3609
|
}
|
|
1963
3610
|
}
|
|
@@ -1995,24 +3642,21 @@ var _ObjectQL = class _ObjectQL {
|
|
|
1995
3642
|
* Helper to get object definition
|
|
1996
3643
|
*/
|
|
1997
3644
|
getSchema(objectName) {
|
|
1998
|
-
return
|
|
3645
|
+
return this._registry.getObject(objectName);
|
|
1999
3646
|
}
|
|
2000
3647
|
/**
|
|
2001
|
-
* Resolve
|
|
2002
|
-
*
|
|
2003
|
-
*
|
|
2004
|
-
*
|
|
2005
|
-
*
|
|
2006
|
-
*
|
|
2007
|
-
* This ensures that all driver operations use a consistent key
|
|
2008
|
-
* regardless of whether the caller uses the short name or FQN.
|
|
3648
|
+
* Resolve any object identifier to the physical storage name used by drivers.
|
|
3649
|
+
*
|
|
3650
|
+
* Accepts the canonical short name (e.g., 'account') or, for explicit
|
|
3651
|
+
* cross-package disambiguation, the canonical object name (e.g., 'account'). The result is
|
|
3652
|
+
* the physical table name derived via `StorageNameMapping.resolveTableName`.
|
|
2009
3653
|
*/
|
|
2010
3654
|
resolveObjectName(name) {
|
|
2011
|
-
const schema =
|
|
3655
|
+
const schema = this._registry.getObject(name);
|
|
2012
3656
|
if (schema) {
|
|
2013
|
-
return
|
|
3657
|
+
return import_system.StorageNameMapping.resolveTableName(schema);
|
|
2014
3658
|
}
|
|
2015
|
-
return name;
|
|
3659
|
+
return import_system.StorageNameMapping.resolveTableName({ name });
|
|
2016
3660
|
}
|
|
2017
3661
|
/**
|
|
2018
3662
|
* Helper to get the target driver
|
|
@@ -2024,7 +3668,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2024
3668
|
* 4. Global default driver
|
|
2025
3669
|
*/
|
|
2026
3670
|
getDriver(objectName) {
|
|
2027
|
-
const object =
|
|
3671
|
+
const object = this._registry.getObject(objectName);
|
|
2028
3672
|
if (object?.datasource && object.datasource !== "default") {
|
|
2029
3673
|
if (this.drivers.has(object.datasource)) {
|
|
2030
3674
|
return this.drivers.get(object.datasource);
|
|
@@ -2040,7 +3684,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2040
3684
|
return this.drivers.get(mappedDatasource);
|
|
2041
3685
|
}
|
|
2042
3686
|
const fqn = object?.name || objectName;
|
|
2043
|
-
const owner =
|
|
3687
|
+
const owner = this._registry.getObjectOwner(fqn);
|
|
2044
3688
|
if (owner?.packageId) {
|
|
2045
3689
|
const manifest = this.manifests.get(owner.packageId);
|
|
2046
3690
|
if (manifest?.defaultDatasource && manifest.defaultDatasource !== "default") {
|
|
@@ -2158,10 +3802,10 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2158
3802
|
* @param depth - Current recursion depth (0-based)
|
|
2159
3803
|
* @returns Records with expanded lookup fields (IDs replaced by full objects)
|
|
2160
3804
|
*/
|
|
2161
|
-
async expandRelatedRecords(objectName, records, expand, depth = 0) {
|
|
3805
|
+
async expandRelatedRecords(objectName, records, expand, depth = 0, execCtx) {
|
|
2162
3806
|
if (!records || records.length === 0) return records;
|
|
2163
3807
|
if (depth >= _ObjectQL.MAX_EXPAND_DEPTH) return records;
|
|
2164
|
-
const objectSchema =
|
|
3808
|
+
const objectSchema = this._registry.getObject(objectName);
|
|
2165
3809
|
if (!objectSchema || !objectSchema.fields) return records;
|
|
2166
3810
|
for (const [fieldName, nestedAST] of Object.entries(expand)) {
|
|
2167
3811
|
const fieldDef = objectSchema.fields[fieldName];
|
|
@@ -2190,7 +3834,8 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2190
3834
|
...nestedAST.orderBy ? { orderBy: nestedAST.orderBy } : {}
|
|
2191
3835
|
};
|
|
2192
3836
|
const driver = this.getDriver(referenceObject);
|
|
2193
|
-
const
|
|
3837
|
+
const expandOpts = this.buildDriverOptions(execCtx);
|
|
3838
|
+
const relatedRecords = await driver.find(referenceObject, relatedQuery, expandOpts) ?? [];
|
|
2194
3839
|
const recordMap = /* @__PURE__ */ new Map();
|
|
2195
3840
|
for (const rec of relatedRecords) {
|
|
2196
3841
|
const id = rec.id;
|
|
@@ -2201,7 +3846,8 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2201
3846
|
referenceObject,
|
|
2202
3847
|
relatedRecords,
|
|
2203
3848
|
nestedAST.expand,
|
|
2204
|
-
depth + 1
|
|
3849
|
+
depth + 1,
|
|
3850
|
+
execCtx
|
|
2205
3851
|
);
|
|
2206
3852
|
recordMap.clear();
|
|
2207
3853
|
for (const rec of expandedRelated) {
|
|
@@ -2242,6 +3888,20 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2242
3888
|
ast.limit = ast.top;
|
|
2243
3889
|
}
|
|
2244
3890
|
delete ast.top;
|
|
3891
|
+
const _findSchema = this._registry.getObject(object);
|
|
3892
|
+
const _findFormula = planFormulaProjection(_findSchema, ast.fields);
|
|
3893
|
+
if (_findFormula.projected) ast.fields = _findFormula.projected;
|
|
3894
|
+
if (_findSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) {
|
|
3895
|
+
const known = new Set(Object.keys(_findSchema.fields));
|
|
3896
|
+
known.add("id");
|
|
3897
|
+
known.add("created_at");
|
|
3898
|
+
known.add("updated_at");
|
|
3899
|
+
const filtered = ast.fields.filter((f) => {
|
|
3900
|
+
const head = String(f).split(".")[0];
|
|
3901
|
+
return known.has(head);
|
|
3902
|
+
});
|
|
3903
|
+
ast.fields = filtered.length > 0 ? filtered : void 0;
|
|
3904
|
+
}
|
|
2245
3905
|
const opCtx = {
|
|
2246
3906
|
object,
|
|
2247
3907
|
operation: "find",
|
|
@@ -2255,14 +3915,17 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2255
3915
|
event: "beforeFind",
|
|
2256
3916
|
input: { ast: opCtx.ast, options: opCtx.options },
|
|
2257
3917
|
session: this.buildSession(opCtx.context),
|
|
3918
|
+
api: this.buildHookApi(opCtx.context),
|
|
2258
3919
|
transaction: opCtx.context?.transaction,
|
|
2259
3920
|
ql: this
|
|
2260
3921
|
};
|
|
2261
3922
|
await this.triggerHooks("beforeFind", hookContext);
|
|
3923
|
+
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
|
|
2262
3924
|
try {
|
|
2263
3925
|
let result = await driver.find(object, hookContext.input.ast, hookContext.input.options);
|
|
3926
|
+
if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result);
|
|
2264
3927
|
if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) {
|
|
2265
|
-
result = await this.expandRelatedRecords(object, result, ast.expand, 0);
|
|
3928
|
+
result = await this.expandRelatedRecords(object, result, ast.expand, 0, opCtx.context);
|
|
2266
3929
|
}
|
|
2267
3930
|
hookContext.event = "afterFind";
|
|
2268
3931
|
hookContext.result = result;
|
|
@@ -2282,6 +3945,17 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2282
3945
|
const ast = { object: objectName, ...query, limit: 1 };
|
|
2283
3946
|
delete ast.context;
|
|
2284
3947
|
delete ast.top;
|
|
3948
|
+
const _findOneSchema = this._registry.getObject(objectName);
|
|
3949
|
+
const _findOneFormula = planFormulaProjection(_findOneSchema, ast.fields);
|
|
3950
|
+
if (_findOneFormula.projected) ast.fields = _findOneFormula.projected;
|
|
3951
|
+
if (_findOneSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) {
|
|
3952
|
+
const known = new Set(Object.keys(_findOneSchema.fields));
|
|
3953
|
+
known.add("id");
|
|
3954
|
+
known.add("created_at");
|
|
3955
|
+
known.add("updated_at");
|
|
3956
|
+
const filtered = ast.fields.filter((f) => known.has(String(f).split(".")[0]));
|
|
3957
|
+
ast.fields = filtered.length > 0 ? filtered : void 0;
|
|
3958
|
+
}
|
|
2285
3959
|
const opCtx = {
|
|
2286
3960
|
object: objectName,
|
|
2287
3961
|
operation: "findOne",
|
|
@@ -2290,9 +3964,11 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2290
3964
|
context: query?.context
|
|
2291
3965
|
};
|
|
2292
3966
|
await this.executeWithMiddleware(opCtx, async () => {
|
|
2293
|
-
|
|
3967
|
+
const findOneOpts = this.buildDriverOptions(opCtx.context);
|
|
3968
|
+
let result = await driver.findOne(objectName, opCtx.ast, findOneOpts);
|
|
3969
|
+
if (result != null) applyFormulaPlan(_findOneFormula.plan, [result]);
|
|
2294
3970
|
if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {
|
|
2295
|
-
const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0);
|
|
3971
|
+
const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context);
|
|
2296
3972
|
result = expanded[0];
|
|
2297
3973
|
}
|
|
2298
3974
|
return result;
|
|
@@ -2316,20 +3992,35 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2316
3992
|
event: "beforeInsert",
|
|
2317
3993
|
input: { data: opCtx.data, options: opCtx.options },
|
|
2318
3994
|
session: this.buildSession(opCtx.context),
|
|
3995
|
+
api: this.buildHookApi(opCtx.context),
|
|
2319
3996
|
transaction: opCtx.context?.transaction,
|
|
2320
3997
|
ql: this
|
|
2321
3998
|
};
|
|
2322
3999
|
await this.triggerHooks("beforeInsert", hookContext);
|
|
4000
|
+
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
|
|
2323
4001
|
try {
|
|
2324
4002
|
let result;
|
|
4003
|
+
const nowSnap = /* @__PURE__ */ new Date();
|
|
4004
|
+
const schemaForValidation = this._registry.getObject(object);
|
|
2325
4005
|
if (Array.isArray(hookContext.input.data)) {
|
|
4006
|
+
const rows = hookContext.input.data.map(
|
|
4007
|
+
(row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
|
|
4008
|
+
);
|
|
4009
|
+
for (const r of rows) validateRecord(schemaForValidation, r, "insert");
|
|
2326
4010
|
if (driver.bulkCreate) {
|
|
2327
|
-
result = await driver.bulkCreate(object,
|
|
4011
|
+
result = await driver.bulkCreate(object, rows, hookContext.input.options);
|
|
2328
4012
|
} else {
|
|
2329
|
-
result = await Promise.all(
|
|
4013
|
+
result = await Promise.all(rows.map((item) => driver.create(object, item, hookContext.input.options)));
|
|
2330
4014
|
}
|
|
2331
4015
|
} else {
|
|
2332
|
-
|
|
4016
|
+
const row = this.applyFieldDefaults(
|
|
4017
|
+
object,
|
|
4018
|
+
hookContext.input.data,
|
|
4019
|
+
opCtx.context,
|
|
4020
|
+
nowSnap
|
|
4021
|
+
);
|
|
4022
|
+
validateRecord(schemaForValidation, row, "insert");
|
|
4023
|
+
result = await driver.create(object, row, hookContext.input.options);
|
|
2333
4024
|
}
|
|
2334
4025
|
hookContext.event = "afterInsert";
|
|
2335
4026
|
hookContext.result = result;
|
|
@@ -2396,15 +4087,19 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2396
4087
|
event: "beforeUpdate",
|
|
2397
4088
|
input: { id, data: opCtx.data, options: opCtx.options },
|
|
2398
4089
|
session: this.buildSession(opCtx.context),
|
|
4090
|
+
api: this.buildHookApi(opCtx.context),
|
|
2399
4091
|
transaction: opCtx.context?.transaction,
|
|
2400
4092
|
ql: this
|
|
2401
4093
|
};
|
|
2402
4094
|
await this.triggerHooks("beforeUpdate", hookContext);
|
|
4095
|
+
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
|
|
2403
4096
|
try {
|
|
2404
4097
|
let result;
|
|
2405
4098
|
if (hookContext.input.id) {
|
|
4099
|
+
validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
|
|
2406
4100
|
result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
|
|
2407
4101
|
} else if (options?.multi && driver.updateMany) {
|
|
4102
|
+
validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
|
|
2408
4103
|
const ast = { object, where: options.where };
|
|
2409
4104
|
result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options);
|
|
2410
4105
|
} else {
|
|
@@ -2461,10 +4156,12 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2461
4156
|
event: "beforeDelete",
|
|
2462
4157
|
input: { id, options: opCtx.options },
|
|
2463
4158
|
session: this.buildSession(opCtx.context),
|
|
4159
|
+
api: this.buildHookApi(opCtx.context),
|
|
2464
4160
|
transaction: opCtx.context?.transaction,
|
|
2465
4161
|
ql: this
|
|
2466
4162
|
};
|
|
2467
4163
|
await this.triggerHooks("beforeDelete", hookContext);
|
|
4164
|
+
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
|
|
2468
4165
|
try {
|
|
2469
4166
|
let result;
|
|
2470
4167
|
if (hookContext.input.id) {
|
|
@@ -2514,11 +4211,12 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2514
4211
|
context: query?.context
|
|
2515
4212
|
};
|
|
2516
4213
|
await this.executeWithMiddleware(opCtx, async () => {
|
|
4214
|
+
const countOpts = this.buildDriverOptions(opCtx.context);
|
|
2517
4215
|
if (driver.count) {
|
|
2518
4216
|
const ast = { object, where: query?.where };
|
|
2519
|
-
return driver.count(object, ast);
|
|
4217
|
+
return driver.count(object, ast, countOpts);
|
|
2520
4218
|
}
|
|
2521
|
-
const res = await this.find(object, { where: query?.where, fields: ["id"] });
|
|
4219
|
+
const res = await this.find(object, { where: query?.where, fields: ["id"], context: opCtx.context });
|
|
2522
4220
|
return res.length;
|
|
2523
4221
|
});
|
|
2524
4222
|
return opCtx.result;
|
|
@@ -2540,18 +4238,104 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2540
4238
|
groupBy: query.groupBy,
|
|
2541
4239
|
aggregations: query.aggregations
|
|
2542
4240
|
};
|
|
2543
|
-
|
|
4241
|
+
const drv = driver;
|
|
4242
|
+
const groupByItems = Array.isArray(query.groupBy) ? query.groupBy : [];
|
|
4243
|
+
const granularityCaps = drv?.supports?.queryDateGranularity;
|
|
4244
|
+
const structuredItems = groupByItems.filter((g) => typeof g !== "string");
|
|
4245
|
+
const allStructuredSupported = structuredItems.every((g) => {
|
|
4246
|
+
if (!g?.dateGranularity) return true;
|
|
4247
|
+
return granularityCaps?.[g.dateGranularity] === true;
|
|
4248
|
+
});
|
|
4249
|
+
if (typeof drv.aggregate === "function" && allStructuredSupported) {
|
|
4250
|
+
return drv.aggregate(object, ast, this.buildDriverOptions(opCtx.context));
|
|
4251
|
+
}
|
|
4252
|
+
const raw = await driver.find(object, ast, this.buildDriverOptions(opCtx.context));
|
|
4253
|
+
return applyInMemoryAggregation(raw, ast);
|
|
2544
4254
|
});
|
|
2545
4255
|
return opCtx.result;
|
|
2546
4256
|
}
|
|
4257
|
+
/**
|
|
4258
|
+
* Run raw driver-specific commands (SQL for SqlDriver, REST for RestDriver, …).
|
|
4259
|
+
*
|
|
4260
|
+
* ⚠️ **Tenant isolation bypass.** Raw `execute()` does NOT thread the
|
|
4261
|
+
* caller's `ExecutionContext.tenantId` into a `WHERE organization_id`
|
|
4262
|
+
* predicate — drivers see the command verbatim. Callers MUST inline the
|
|
4263
|
+
* tenant filter themselves, or restrict raw execution to genuinely global
|
|
4264
|
+
* statements (schema migrations, sys_* / control-plane tables).
|
|
4265
|
+
*
|
|
4266
|
+
* Prefer the typed entry points (`find`, `update`, `delete`, `count`, …)
|
|
4267
|
+
* whenever feasible — they auto-apply tenancy + soft-delete + audit warnings.
|
|
4268
|
+
*/
|
|
2547
4269
|
async execute(command, options) {
|
|
4270
|
+
let driver;
|
|
2548
4271
|
if (options?.object) {
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
4272
|
+
driver = this.getDriver(options.object);
|
|
4273
|
+
} else if (options?.datasource && this.drivers.has(options.datasource)) {
|
|
4274
|
+
driver = this.drivers.get(options.datasource);
|
|
4275
|
+
} else if (this.defaultDriver && this.drivers.has(this.defaultDriver)) {
|
|
4276
|
+
driver = this.drivers.get(this.defaultDriver);
|
|
4277
|
+
} else if (this.drivers.size === 1) {
|
|
4278
|
+
driver = this.drivers.values().next().value;
|
|
4279
|
+
}
|
|
4280
|
+
if (!driver) {
|
|
4281
|
+
throw new Error(
|
|
4282
|
+
"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."
|
|
4283
|
+
);
|
|
4284
|
+
}
|
|
4285
|
+
if (!driver.execute) {
|
|
4286
|
+
throw new Error("Selected driver does not implement execute()");
|
|
4287
|
+
}
|
|
4288
|
+
let rawCommand = command;
|
|
4289
|
+
let params = options?.args ?? options?.params;
|
|
4290
|
+
if (command && typeof command === "object" && !Array.isArray(command) && "sql" in command) {
|
|
4291
|
+
rawCommand = command.sql;
|
|
4292
|
+
if (params === void 0) {
|
|
4293
|
+
params = command.args ?? command.params;
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
return driver.execute(rawCommand, params, options);
|
|
4297
|
+
}
|
|
4298
|
+
/**
|
|
4299
|
+
* Execute a callback inside a database transaction.
|
|
4300
|
+
*
|
|
4301
|
+
* The callback receives a context object that should be passed to all
|
|
4302
|
+
* downstream `engine.insert/update/delete/find/findOne` calls (as
|
|
4303
|
+
* `{ context: trxCtx }`). The transaction handle threads through
|
|
4304
|
+
* `OperationContext.context.transaction` and the SQL driver's per-builder
|
|
4305
|
+
* `.transacting(trx)` call.
|
|
4306
|
+
*
|
|
4307
|
+
* - If the default driver does not support `beginTransaction`, the callback
|
|
4308
|
+
* runs directly with the supplied base context (no rollback). This keeps
|
|
4309
|
+
* the API safe to call on drivers without ACID support (e.g. the
|
|
4310
|
+
* in-memory driver in tests).
|
|
4311
|
+
* - On callback success the transaction is committed; on any thrown error
|
|
4312
|
+
* it is rolled back and the original error is re-thrown.
|
|
4313
|
+
*
|
|
4314
|
+
* Use case: multi-step operations that must be atomic (e.g. CRM
|
|
4315
|
+
* `convertLead`, which creates an account + contact + opportunity + flips
|
|
4316
|
+
* the lead in a single unit of work).
|
|
4317
|
+
*/
|
|
4318
|
+
async transaction(callback, baseContext) {
|
|
4319
|
+
const driver = this.defaultDriver ? this.drivers.get(this.defaultDriver) : void 0;
|
|
4320
|
+
const drv = driver;
|
|
4321
|
+
if (!drv?.beginTransaction) {
|
|
4322
|
+
return callback(baseContext);
|
|
4323
|
+
}
|
|
4324
|
+
const trx = await drv.beginTransaction();
|
|
4325
|
+
const trxCtx = { ...baseContext ?? {}, transaction: trx };
|
|
4326
|
+
try {
|
|
4327
|
+
const result = await callback(trxCtx);
|
|
4328
|
+
if (drv.commit) await drv.commit(trx);
|
|
4329
|
+
else if (drv.commitTransaction) await drv.commitTransaction(trx);
|
|
4330
|
+
return result;
|
|
4331
|
+
} catch (err) {
|
|
4332
|
+
try {
|
|
4333
|
+
if (drv.rollback) await drv.rollback(trx);
|
|
4334
|
+
else if (drv.rollbackTransaction) await drv.rollbackTransaction(trx);
|
|
4335
|
+
} catch {
|
|
2552
4336
|
}
|
|
4337
|
+
throw err;
|
|
2553
4338
|
}
|
|
2554
|
-
throw new Error("Execute requires options.object to select driver");
|
|
2555
4339
|
}
|
|
2556
4340
|
// ============================================
|
|
2557
4341
|
// Compatibility / Convenience API
|
|
@@ -2572,16 +4356,16 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2572
4356
|
}
|
|
2573
4357
|
}
|
|
2574
4358
|
}
|
|
2575
|
-
return
|
|
4359
|
+
return this._registry.registerObject(schema, packageId, namespace);
|
|
2576
4360
|
}
|
|
2577
4361
|
/**
|
|
2578
4362
|
* Unregister a single object by name.
|
|
2579
4363
|
*/
|
|
2580
4364
|
unregisterObject(name, packageId) {
|
|
2581
4365
|
if (packageId) {
|
|
2582
|
-
|
|
4366
|
+
this._registry.unregisterObjectsByPackage(packageId);
|
|
2583
4367
|
} else {
|
|
2584
|
-
|
|
4368
|
+
this._registry.unregisterItem("object", name);
|
|
2585
4369
|
}
|
|
2586
4370
|
}
|
|
2587
4371
|
/**
|
|
@@ -2597,7 +4381,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2597
4381
|
*/
|
|
2598
4382
|
getConfigs() {
|
|
2599
4383
|
const result = {};
|
|
2600
|
-
const objects =
|
|
4384
|
+
const objects = this._registry.getAllObjects();
|
|
2601
4385
|
for (const obj of objects) {
|
|
2602
4386
|
if (obj.name) {
|
|
2603
4387
|
result[obj.name] = obj;
|
|
@@ -2631,10 +4415,32 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2631
4415
|
return void 0;
|
|
2632
4416
|
}
|
|
2633
4417
|
}
|
|
4418
|
+
/**
|
|
4419
|
+
* Sync all registered object schemas to their respective drivers.
|
|
4420
|
+
* Call this after dynamically registering new objects at runtime
|
|
4421
|
+
* (e.g. after template seeding) to ensure tables/collections exist
|
|
4422
|
+
* before inserting seed data.
|
|
4423
|
+
*/
|
|
4424
|
+
async syncSchemas() {
|
|
4425
|
+
const allObjects = this._registry.getAllObjects();
|
|
4426
|
+
for (const obj of allObjects) {
|
|
4427
|
+
const driver = this.getDriverForObject(obj.name);
|
|
4428
|
+
if (!driver) continue;
|
|
4429
|
+
const tableName = import_system.StorageNameMapping.resolveTableName(obj);
|
|
4430
|
+
if (typeof driver.syncSchemasBatch === "function" && driver.supports?.batchSchemaSync) {
|
|
4431
|
+
}
|
|
4432
|
+
if (typeof driver.syncSchema === "function") {
|
|
4433
|
+
try {
|
|
4434
|
+
await driver.syncSchema(tableName, obj);
|
|
4435
|
+
} catch {
|
|
4436
|
+
}
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
2634
4440
|
/**
|
|
2635
4441
|
* Get a registered driver by datasource name.
|
|
2636
4442
|
* Alias matching @objectql/core datasource() API.
|
|
2637
|
-
*
|
|
4443
|
+
*
|
|
2638
4444
|
* @throws Error if the datasource is not found
|
|
2639
4445
|
*/
|
|
2640
4446
|
datasource(name) {
|
|
@@ -2665,7 +4471,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2665
4471
|
}
|
|
2666
4472
|
}
|
|
2667
4473
|
this.removeActionsByPackage(packageId);
|
|
2668
|
-
|
|
4474
|
+
this._registry.unregisterObjectsByPackage(packageId, true);
|
|
2669
4475
|
}
|
|
2670
4476
|
/**
|
|
2671
4477
|
* Gracefully shut down the engine, disconnecting all drivers.
|
|
@@ -2684,7 +4490,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
2684
4490
|
*/
|
|
2685
4491
|
createContext(ctx) {
|
|
2686
4492
|
return new ScopedContext(
|
|
2687
|
-
|
|
4493
|
+
import_kernel3.ExecutionContextSchema.parse(ctx),
|
|
2688
4494
|
this
|
|
2689
4495
|
);
|
|
2690
4496
|
}
|
|
@@ -2881,86 +4687,97 @@ var ScopedContext = class _ScopedContext {
|
|
|
2881
4687
|
|
|
2882
4688
|
// src/metadata-facade.ts
|
|
2883
4689
|
var MetadataFacade = class {
|
|
4690
|
+
constructor(registry) {
|
|
4691
|
+
this.registry = registry;
|
|
4692
|
+
}
|
|
2884
4693
|
/**
|
|
2885
4694
|
* Register a metadata item
|
|
2886
4695
|
*/
|
|
2887
4696
|
async register(type, name, data) {
|
|
2888
4697
|
const definition = typeof data === "object" && data !== null ? { ...data, name: data.name ?? name } : data;
|
|
2889
4698
|
if (type === "object") {
|
|
2890
|
-
|
|
4699
|
+
this.registry.registerItem(type, definition, "name");
|
|
2891
4700
|
} else {
|
|
2892
|
-
|
|
4701
|
+
this.registry.registerItem(type, definition, definition.id ? "id" : "name");
|
|
2893
4702
|
}
|
|
2894
4703
|
}
|
|
2895
4704
|
/**
|
|
2896
4705
|
* Get a metadata item by type and name
|
|
2897
4706
|
*/
|
|
2898
4707
|
async get(type, name) {
|
|
2899
|
-
const item =
|
|
4708
|
+
const item = this.registry.getItem(type, name);
|
|
2900
4709
|
return item?.content ?? item;
|
|
2901
4710
|
}
|
|
2902
4711
|
/**
|
|
2903
4712
|
* Get the raw entry (with metadata wrapper)
|
|
2904
4713
|
*/
|
|
2905
4714
|
getEntry(type, name) {
|
|
2906
|
-
return
|
|
4715
|
+
return this.registry.getItem(type, name);
|
|
2907
4716
|
}
|
|
2908
4717
|
/**
|
|
2909
4718
|
* List all items of a type
|
|
2910
4719
|
*/
|
|
2911
4720
|
async list(type) {
|
|
2912
|
-
const items =
|
|
4721
|
+
const items = this.registry.listItems(type);
|
|
2913
4722
|
return items.map((item) => item?.content ?? item);
|
|
2914
4723
|
}
|
|
2915
4724
|
/**
|
|
2916
4725
|
* Unregister a metadata item
|
|
2917
4726
|
*/
|
|
2918
4727
|
async unregister(type, name) {
|
|
2919
|
-
|
|
4728
|
+
this.registry.unregisterItem(type, name);
|
|
2920
4729
|
}
|
|
2921
4730
|
/**
|
|
2922
4731
|
* Check if a metadata item exists
|
|
2923
4732
|
*/
|
|
2924
4733
|
async exists(type, name) {
|
|
2925
|
-
const item =
|
|
4734
|
+
const item = this.registry.getItem(type, name);
|
|
2926
4735
|
return item !== void 0 && item !== null;
|
|
2927
4736
|
}
|
|
2928
4737
|
/**
|
|
2929
4738
|
* List all names of metadata items of a given type
|
|
2930
4739
|
*/
|
|
2931
4740
|
async listNames(type) {
|
|
2932
|
-
const items =
|
|
4741
|
+
const items = this.registry.listItems(type);
|
|
2933
4742
|
return items.map((item) => item?.name ?? item?.content?.name ?? "").filter(Boolean);
|
|
2934
4743
|
}
|
|
2935
4744
|
/**
|
|
2936
4745
|
* Unregister all metadata from a package
|
|
2937
4746
|
*/
|
|
2938
4747
|
async unregisterPackage(packageName) {
|
|
2939
|
-
|
|
4748
|
+
this.registry.unregisterObjectsByPackage(packageName);
|
|
2940
4749
|
}
|
|
2941
4750
|
/**
|
|
2942
4751
|
* Convenience: get object definition
|
|
2943
4752
|
*/
|
|
2944
4753
|
async getObject(name) {
|
|
2945
|
-
return
|
|
4754
|
+
return this.registry.getObject(name);
|
|
2946
4755
|
}
|
|
2947
4756
|
/**
|
|
2948
4757
|
* Convenience: list all objects
|
|
2949
4758
|
*/
|
|
2950
4759
|
async listObjects() {
|
|
2951
|
-
return
|
|
4760
|
+
return this.registry.getAllObjects();
|
|
2952
4761
|
}
|
|
2953
4762
|
};
|
|
2954
4763
|
|
|
2955
4764
|
// src/plugin.ts
|
|
4765
|
+
var import_system2 = require("@objectstack/spec/system");
|
|
2956
4766
|
function hasLoadMetaFromDb(service) {
|
|
2957
4767
|
return typeof service === "object" && service !== null && typeof service["loadMetaFromDb"] === "function";
|
|
2958
4768
|
}
|
|
2959
4769
|
var ObjectQLPlugin = class {
|
|
2960
|
-
constructor(
|
|
4770
|
+
constructor(qlOrOptions, hostContext) {
|
|
2961
4771
|
this.name = "com.objectstack.engine.objectql";
|
|
2962
4772
|
this.type = "objectql";
|
|
2963
4773
|
this.version = "1.0.0";
|
|
4774
|
+
/**
|
|
4775
|
+
* Schema sync to remote SQL DBs is latency-bound (one round-trip per
|
|
4776
|
+
* table × 2 phases). Default to 120s instead of the kernel's 30s so
|
|
4777
|
+
* cold Neon/Turso starts don't get killed mid-sync.
|
|
4778
|
+
*/
|
|
4779
|
+
this.startupTimeout = 12e4;
|
|
4780
|
+
this.skipSchemaSync = false;
|
|
2964
4781
|
this.init = async (ctx) => {
|
|
2965
4782
|
if (!this.ql) {
|
|
2966
4783
|
const hostCtx = { ...this.hostContext, logger: ctx.logger };
|
|
@@ -2982,10 +4799,23 @@ var ObjectQLPlugin = class {
|
|
|
2982
4799
|
});
|
|
2983
4800
|
const protocolShim = new ObjectStackProtocolImplementation(
|
|
2984
4801
|
this.ql,
|
|
2985
|
-
() => ctx.getServices ? ctx.getServices() : /* @__PURE__ */ new Map()
|
|
4802
|
+
() => ctx.getServices ? ctx.getServices() : /* @__PURE__ */ new Map(),
|
|
4803
|
+
void 0,
|
|
4804
|
+
this.projectId
|
|
2986
4805
|
);
|
|
2987
4806
|
ctx.registerService("protocol", protocolShim);
|
|
2988
4807
|
ctx.logger.info("Protocol service registered");
|
|
4808
|
+
ctx.registerService("analytics", {
|
|
4809
|
+
query: (body) => protocolShim.analyticsQuery(body),
|
|
4810
|
+
getMeta: async () => ({
|
|
4811
|
+
cubes: [],
|
|
4812
|
+
message: "Analytics meta endpoint not implemented by ObjectQL adapter"
|
|
4813
|
+
}),
|
|
4814
|
+
generateSql: async (_body) => ({
|
|
4815
|
+
sql: null,
|
|
4816
|
+
message: "Analytics SQL generation not implemented by ObjectQL adapter"
|
|
4817
|
+
})
|
|
4818
|
+
});
|
|
2989
4819
|
};
|
|
2990
4820
|
this.start = async (ctx) => {
|
|
2991
4821
|
ctx.logger.info("ObjectQL engine starting...");
|
|
@@ -3025,103 +4855,194 @@ var ObjectQLPlugin = class {
|
|
|
3025
4855
|
}
|
|
3026
4856
|
}
|
|
3027
4857
|
await this.ql?.init();
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
4858
|
+
if (this.skipSchemaSync) {
|
|
4859
|
+
ctx.logger.info("Skipping schema sync (OS_SKIP_SCHEMA_SYNC=1) \u2014 assuming DDL is managed out-of-band");
|
|
4860
|
+
} else {
|
|
4861
|
+
await this.syncRegisteredSchemas(ctx);
|
|
4862
|
+
}
|
|
4863
|
+
if (this.projectId === void 0) {
|
|
4864
|
+
await this.restoreMetadataFromDb(ctx);
|
|
4865
|
+
} else {
|
|
4866
|
+
ctx.logger.info("Project kernel \u2014 skipping sys_metadata hydration (metadata sourced from artifact)");
|
|
4867
|
+
}
|
|
4868
|
+
if (!this.skipSchemaSync) {
|
|
4869
|
+
await this.syncRegisteredSchemas(ctx);
|
|
4870
|
+
}
|
|
4871
|
+
if (this.projectId === void 0) {
|
|
4872
|
+
await this.bridgeObjectsToMetadataService(ctx);
|
|
4873
|
+
}
|
|
3031
4874
|
this.registerAuditHooks(ctx);
|
|
3032
|
-
this.registerTenantMiddleware(ctx);
|
|
3033
4875
|
ctx.logger.info("ObjectQL engine started", {
|
|
3034
4876
|
driversRegistered: this.ql?.["drivers"]?.size || 0,
|
|
3035
4877
|
objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0
|
|
3036
4878
|
});
|
|
3037
4879
|
};
|
|
3038
|
-
if (
|
|
3039
|
-
this.ql =
|
|
3040
|
-
} else {
|
|
4880
|
+
if (qlOrOptions instanceof ObjectQL) {
|
|
4881
|
+
this.ql = qlOrOptions;
|
|
3041
4882
|
this.hostContext = hostContext;
|
|
4883
|
+
return;
|
|
4884
|
+
}
|
|
4885
|
+
const opts = qlOrOptions ?? {};
|
|
4886
|
+
if (opts.ql) {
|
|
4887
|
+
this.ql = opts.ql;
|
|
4888
|
+
}
|
|
4889
|
+
this.hostContext = opts.hostContext ?? hostContext;
|
|
4890
|
+
this.projectId = opts.projectId;
|
|
4891
|
+
if (typeof opts.startupTimeout === "number" && opts.startupTimeout > 0) {
|
|
4892
|
+
this.startupTimeout = opts.startupTimeout;
|
|
3042
4893
|
}
|
|
4894
|
+
this.skipSchemaSync = typeof opts.skipSchemaSync === "boolean" ? opts.skipSchemaSync : process.env.OS_SKIP_SCHEMA_SYNC === "1";
|
|
3043
4895
|
}
|
|
3044
4896
|
/**
|
|
3045
4897
|
* Register built-in audit hooks for auto-stamping created_by/updated_by
|
|
3046
|
-
* and fetching previousData for update/delete operations.
|
|
4898
|
+
* and fetching previousData for update/delete operations. These are
|
|
4899
|
+
* declared as canonical `Hook` metadata and bound through the same
|
|
4900
|
+
* `bindHooksToEngine` path used by `defineStack({ hooks })`, so the
|
|
4901
|
+
* engine's built-ins flow through the same rails as user code
|
|
4902
|
+
* (dogfooding the protocol).
|
|
3047
4903
|
*/
|
|
3048
4904
|
registerAuditHooks(ctx) {
|
|
3049
4905
|
if (!this.ql) return;
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
4906
|
+
const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
4907
|
+
const hasField = (objectName, field) => {
|
|
4908
|
+
try {
|
|
4909
|
+
const schema = this.ql?.getSchema?.(objectName);
|
|
4910
|
+
if (!schema || typeof schema !== "object") return false;
|
|
4911
|
+
const fields = schema.fields;
|
|
4912
|
+
if (!fields || typeof fields !== "object") return false;
|
|
4913
|
+
return Object.prototype.hasOwnProperty.call(fields, field);
|
|
4914
|
+
} catch {
|
|
4915
|
+
return false;
|
|
4916
|
+
}
|
|
4917
|
+
};
|
|
4918
|
+
const applyToRecord = (record, objectName, session, isInsert) => {
|
|
4919
|
+
const now = stamp();
|
|
4920
|
+
if (isInsert) {
|
|
4921
|
+
record.created_at = record.created_at ?? now;
|
|
4922
|
+
}
|
|
4923
|
+
record.updated_at = now;
|
|
4924
|
+
if (session?.userId) {
|
|
4925
|
+
if (isInsert && hasField(objectName, "created_by")) {
|
|
4926
|
+
record.created_by = record.created_by ?? session.userId;
|
|
3061
4927
|
}
|
|
4928
|
+
if (hasField(objectName, "updated_by")) {
|
|
4929
|
+
record.updated_by = session.userId;
|
|
4930
|
+
}
|
|
4931
|
+
}
|
|
4932
|
+
if (isInsert && session?.tenantId && hasField(objectName, "tenant_id")) {
|
|
4933
|
+
record.tenant_id = record.tenant_id ?? session.tenantId;
|
|
3062
4934
|
}
|
|
3063
|
-
}
|
|
3064
|
-
|
|
3065
|
-
if (
|
|
3066
|
-
const
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
4935
|
+
};
|
|
4936
|
+
const stampData = (data, objectName, session, isInsert) => {
|
|
4937
|
+
if (Array.isArray(data)) {
|
|
4938
|
+
for (const row of data) {
|
|
4939
|
+
if (row && typeof row === "object") {
|
|
4940
|
+
applyToRecord(row, objectName, session, isInsert);
|
|
4941
|
+
}
|
|
3070
4942
|
}
|
|
4943
|
+
} else if (data && typeof data === "object") {
|
|
4944
|
+
applyToRecord(data, objectName, session, isInsert);
|
|
3071
4945
|
}
|
|
3072
|
-
}
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
4946
|
+
};
|
|
4947
|
+
const builtinHooks = [
|
|
4948
|
+
{
|
|
4949
|
+
name: "sys_stamp_audit_insert",
|
|
4950
|
+
object: "*",
|
|
4951
|
+
events: ["beforeInsert"],
|
|
4952
|
+
priority: 10,
|
|
4953
|
+
description: "Auto-stamp created_by / updated_by / created_at / updated_at / tenant_id on insert (only when the field exists on the object schema)",
|
|
4954
|
+
handler: async (hookCtx) => {
|
|
4955
|
+
if (hookCtx.input?.data) {
|
|
4956
|
+
stampData(hookCtx.input.data, hookCtx.object, hookCtx.session, true);
|
|
4957
|
+
}
|
|
4958
|
+
}
|
|
4959
|
+
},
|
|
4960
|
+
{
|
|
4961
|
+
name: "sys_stamp_audit_update",
|
|
4962
|
+
object: "*",
|
|
4963
|
+
events: ["beforeUpdate"],
|
|
4964
|
+
priority: 10,
|
|
4965
|
+
description: "Auto-stamp updated_by / updated_at on update (only when the field exists on the object schema)",
|
|
4966
|
+
handler: async (hookCtx) => {
|
|
4967
|
+
if (hookCtx.input?.data) {
|
|
4968
|
+
stampData(hookCtx.input.data, hookCtx.object, hookCtx.session, false);
|
|
4969
|
+
}
|
|
4970
|
+
}
|
|
4971
|
+
},
|
|
4972
|
+
{
|
|
4973
|
+
name: "sys_fetch_previous_update",
|
|
4974
|
+
object: "*",
|
|
4975
|
+
events: ["beforeUpdate"],
|
|
4976
|
+
priority: 5,
|
|
4977
|
+
description: "Auto-fetch the previous record for update hooks",
|
|
4978
|
+
handler: async (hookCtx) => {
|
|
4979
|
+
if (hookCtx.input?.id && !hookCtx.previous) {
|
|
4980
|
+
try {
|
|
4981
|
+
const existing = await this.ql.findOne(hookCtx.object, {
|
|
4982
|
+
where: { id: hookCtx.input.id },
|
|
4983
|
+
context: {
|
|
4984
|
+
roles: [],
|
|
4985
|
+
permissions: [],
|
|
4986
|
+
isSystem: true,
|
|
4987
|
+
...hookCtx.transaction ? { transaction: hookCtx.transaction } : {}
|
|
4988
|
+
}
|
|
4989
|
+
});
|
|
4990
|
+
if (existing) hookCtx.previous = existing;
|
|
4991
|
+
} catch (_e) {
|
|
4992
|
+
}
|
|
4993
|
+
}
|
|
4994
|
+
}
|
|
4995
|
+
},
|
|
4996
|
+
{
|
|
4997
|
+
name: "sys_fetch_previous_delete",
|
|
4998
|
+
object: "*",
|
|
4999
|
+
events: ["beforeDelete"],
|
|
5000
|
+
priority: 5,
|
|
5001
|
+
description: "Auto-fetch the previous record for delete hooks",
|
|
5002
|
+
handler: async (hookCtx) => {
|
|
5003
|
+
if (hookCtx.input?.id && !hookCtx.previous) {
|
|
5004
|
+
try {
|
|
5005
|
+
const existing = await this.ql.findOne(hookCtx.object, {
|
|
5006
|
+
where: { id: hookCtx.input.id },
|
|
5007
|
+
context: {
|
|
5008
|
+
roles: [],
|
|
5009
|
+
permissions: [],
|
|
5010
|
+
isSystem: true,
|
|
5011
|
+
...hookCtx.transaction ? { transaction: hookCtx.transaction } : {}
|
|
5012
|
+
}
|
|
5013
|
+
});
|
|
5014
|
+
if (existing) hookCtx.previous = existing;
|
|
5015
|
+
} catch (_e) {
|
|
5016
|
+
}
|
|
3081
5017
|
}
|
|
3082
|
-
} catch (_e) {
|
|
3083
5018
|
}
|
|
3084
5019
|
}
|
|
3085
|
-
|
|
3086
|
-
this.ql.
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
5020
|
+
];
|
|
5021
|
+
if (typeof this.ql.bindHooks === "function") {
|
|
5022
|
+
this.ql.bindHooks(builtinHooks, { packageId: "sys:audit" });
|
|
5023
|
+
} else {
|
|
5024
|
+
for (const h of builtinHooks) {
|
|
5025
|
+
for (const event of h.events) {
|
|
5026
|
+
this.ql.registerHook(event, h.handler, {
|
|
5027
|
+
object: h.object,
|
|
5028
|
+
priority: h.priority,
|
|
5029
|
+
packageId: "sys:audit"
|
|
3091
5030
|
});
|
|
3092
|
-
if (existing) {
|
|
3093
|
-
hookCtx.previous = existing;
|
|
3094
|
-
}
|
|
3095
|
-
} catch (_e) {
|
|
3096
5031
|
}
|
|
3097
5032
|
}
|
|
3098
|
-
}
|
|
3099
|
-
ctx.logger.debug("Audit hooks registered (created_by/updated_by, previousData)");
|
|
5033
|
+
}
|
|
5034
|
+
ctx.logger.debug("Audit hooks registered via binder (created_by/updated_by, previousData)");
|
|
3100
5035
|
}
|
|
3101
5036
|
/**
|
|
3102
|
-
*
|
|
3103
|
-
*
|
|
5037
|
+
* Tenant isolation moved to `@objectstack/plugin-security`'s
|
|
5038
|
+
* `member_default` permission set RLS
|
|
5039
|
+
* (`organization_id = current_user.organization_id`, with
|
|
5040
|
+
* field-existence guards). The legacy `registerTenantMiddleware`
|
|
5041
|
+
* method was removed because it (a) collided with SecurityPlugin's
|
|
5042
|
+
* RLS pipeline and (b) blindly filtered tables that don't have a
|
|
5043
|
+
* `tenant_id` column (e.g. `sys_organization`), returning 0 rows
|
|
5044
|
+
* instead of all rows.
|
|
3104
5045
|
*/
|
|
3105
|
-
registerTenantMiddleware(ctx) {
|
|
3106
|
-
if (!this.ql) return;
|
|
3107
|
-
this.ql.registerMiddleware(async (opCtx, next) => {
|
|
3108
|
-
if (!opCtx.context?.tenantId || opCtx.context?.isSystem) {
|
|
3109
|
-
return next();
|
|
3110
|
-
}
|
|
3111
|
-
if (["find", "findOne", "count", "aggregate"].includes(opCtx.operation)) {
|
|
3112
|
-
if (opCtx.ast) {
|
|
3113
|
-
const tenantFilter = { tenant_id: opCtx.context.tenantId };
|
|
3114
|
-
if (opCtx.ast.where) {
|
|
3115
|
-
opCtx.ast.where = { $and: [opCtx.ast.where, tenantFilter] };
|
|
3116
|
-
} else {
|
|
3117
|
-
opCtx.ast.where = tenantFilter;
|
|
3118
|
-
}
|
|
3119
|
-
}
|
|
3120
|
-
}
|
|
3121
|
-
await next();
|
|
3122
|
-
});
|
|
3123
|
-
ctx.logger.debug("Tenant isolation middleware registered");
|
|
3124
|
-
}
|
|
3125
5046
|
/**
|
|
3126
5047
|
* Synchronize all registered object schemas to the database.
|
|
3127
5048
|
*
|
|
@@ -3160,7 +5081,7 @@ var ObjectQLPlugin = class {
|
|
|
3160
5081
|
skipped++;
|
|
3161
5082
|
continue;
|
|
3162
5083
|
}
|
|
3163
|
-
const tableName =
|
|
5084
|
+
const tableName = import_system2.StorageNameMapping.resolveTableName(obj);
|
|
3164
5085
|
let group = driverGroups.get(driver);
|
|
3165
5086
|
if (!group) {
|
|
3166
5087
|
group = [];
|
|
@@ -3317,13 +5238,20 @@ var ObjectQLPlugin = class {
|
|
|
3317
5238
|
*/
|
|
3318
5239
|
async loadMetadataFromService(metadataService, ctx) {
|
|
3319
5240
|
ctx.logger.info("Syncing metadata from external service into ObjectQL registry...");
|
|
3320
|
-
const metadataTypes = ["object", "view", "app", "flow", "workflow", "function"];
|
|
5241
|
+
const metadataTypes = ["object", "view", "app", "flow", "workflow", "function", "hook"];
|
|
3321
5242
|
let totalLoaded = 0;
|
|
3322
5243
|
for (const type of metadataTypes) {
|
|
3323
5244
|
try {
|
|
3324
5245
|
if (typeof metadataService.loadMany === "function") {
|
|
3325
5246
|
const items = await metadataService.loadMany(type);
|
|
3326
5247
|
if (items && items.length > 0) {
|
|
5248
|
+
if (type === "function" && this.ql && typeof this.ql.registerFunction === "function") {
|
|
5249
|
+
for (const item of items) {
|
|
5250
|
+
if (item?.name && typeof item.handler === "function") {
|
|
5251
|
+
this.ql.registerFunction(item.name, item.handler, "metadata-service");
|
|
5252
|
+
}
|
|
5253
|
+
}
|
|
5254
|
+
}
|
|
3327
5255
|
items.forEach((item) => {
|
|
3328
5256
|
const keyField = item.id ? "id" : "name";
|
|
3329
5257
|
if (type === "object" && this.ql) {
|
|
@@ -3333,6 +5261,11 @@ var ObjectQLPlugin = class {
|
|
|
3333
5261
|
this.ql.registry.registerItem(type, item, keyField);
|
|
3334
5262
|
}
|
|
3335
5263
|
});
|
|
5264
|
+
if (type === "hook" && this.ql && typeof this.ql.bindHooks === "function") {
|
|
5265
|
+
this.ql.bindHooks(items, {
|
|
5266
|
+
packageId: "metadata-service"
|
|
5267
|
+
});
|
|
5268
|
+
}
|
|
3336
5269
|
totalLoaded += items.length;
|
|
3337
5270
|
ctx.logger.info(`Synced ${items.length} ${type}(s) from metadata service`);
|
|
3338
5271
|
}
|
|
@@ -3449,6 +5382,7 @@ function convertIntrospectedSchemaToObjects(introspectedSchema, options) {
|
|
|
3449
5382
|
0 && (module.exports = {
|
|
3450
5383
|
DEFAULT_EXTENDER_PRIORITY,
|
|
3451
5384
|
DEFAULT_OWNER_PRIORITY,
|
|
5385
|
+
InMemoryHookMetricsRecorder,
|
|
3452
5386
|
MetadataFacade,
|
|
3453
5387
|
ObjectQL,
|
|
3454
5388
|
ObjectQLPlugin,
|
|
@@ -3457,10 +5391,18 @@ function convertIntrospectedSchemaToObjects(introspectedSchema, options) {
|
|
|
3457
5391
|
RESERVED_NAMESPACES,
|
|
3458
5392
|
SchemaRegistry,
|
|
3459
5393
|
ScopedContext,
|
|
5394
|
+
ValidationError,
|
|
5395
|
+
applyInMemoryAggregation,
|
|
5396
|
+
applySystemFields,
|
|
5397
|
+
bindHooksToEngine,
|
|
5398
|
+
bucketDateValue,
|
|
3460
5399
|
computeFQN,
|
|
3461
5400
|
convertIntrospectedSchemaToObjects,
|
|
3462
5401
|
createObjectQLKernel,
|
|
5402
|
+
noopHookMetricsRecorder,
|
|
3463
5403
|
parseFQN,
|
|
3464
|
-
toTitleCase
|
|
5404
|
+
toTitleCase,
|
|
5405
|
+
validateRecord,
|
|
5406
|
+
wrapDeclarativeHook
|
|
3465
5407
|
});
|
|
3466
5408
|
//# sourceMappingURL=index.js.map
|