@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.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(namespace, shortName) {
49
- if (!namespace || RESERVED_NAMESPACES.has(namespace)) {
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
- static get logLevel() {
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
- static set logLevel(level) {
187
+ set logLevel(level) {
85
188
  this._logLevel = level;
86
189
  }
87
- static log(msg) {
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
- static registerNamespace(namespace, packageId) {
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
- static unregisterNamespace(namespace, packageId) {
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
- static getNamespaceOwner(namespace) {
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
- static getNamespaceOwners(namespace) {
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
- static registerObject(schema, packageId, namespace, ownership = "own", priority = ownership === "own" ? DEFAULT_OWNER_PRIORITY : DEFAULT_EXTENDER_PRIORITY) {
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
- static resolveObject(fqn) {
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 (FQN, short name, or physical table 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 FQN match (e.g., 'crm__account')
223
- * 2. Short name fallback (e.g., 'account' → 'crm__account')
224
- * 3. Physical table name match (e.g., 'sys_user' 'sys__user')
225
- * ObjectSchema.create() auto-derives tableName as {namespace}_{name},
226
- * which uses a single underscore — different from the FQN double underscore.
227
- */
228
- static getObject(name) {
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
- return this.resolveObject(fqn);
340
+ matches.push(fqn);
235
341
  }
236
342
  }
237
- for (const fqn of this.objectContributors.keys()) {
238
- const resolved = this.resolveObject(fqn);
239
- if (resolved?.tableName === name) {
240
- return resolved;
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 void 0;
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
- static getAllObjects(packageId) {
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
- static getObjectContributors(fqn) {
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
- static getObjectOwner(fqn) {
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
- static unregisterObjectsByPackage(packageId, force = false) {
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
- static registerItem(type, item, keyField = "name", packageId) {
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
- console.warn(`[Registry] Overwriting ${type}: ${storageKey}`);
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
- static validate(type, item) {
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
- static unregisterItem(type, name) {
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
- static getItem(type, name) {
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
- static listItems(type, packageId) {
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
- static getRegisteredTypes() {
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
- static installPackage(manifest, settings) {
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
- static uninstallPackage(id) {
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
- static getPackage(id) {
570
+ getPackage(id) {
463
571
  return this.metadata.get("package")?.get(id);
464
572
  }
465
- static getAllPackages() {
573
+ getAllPackages() {
466
574
  return this.listItems("package");
467
575
  }
468
- static enablePackage(id) {
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
- static disablePackage(id) {
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
- static registerApp(app, packageId) {
601
+ registerApp(app, packageId) {
494
602
  this.registerItem("app", app, "name", packageId);
495
603
  }
496
- static getApp(name) {
604
+ getApp(name) {
497
605
  return this.getItem("app", name);
498
606
  }
499
- static getAllApps() {
607
+ getAllApps() {
500
608
  return this.listItems("app");
501
609
  }
502
610
  // ==========================================
503
611
  // Plugin Helpers
504
612
  // ==========================================
505
- static registerPlugin(manifest) {
613
+ registerPlugin(manifest) {
506
614
  this.registerItem("plugin", manifest, "id");
507
615
  }
508
- static getAllPlugins() {
616
+ getAllPlugins() {
509
617
  return this.listItems("plugin");
510
618
  }
511
619
  // ==========================================
512
620
  // Kind Helpers
513
621
  // ==========================================
514
- static registerKind(kind) {
622
+ registerKind(kind) {
515
623
  this.registerItem("kind", kind, "id");
516
624
  }
517
- static getAllKinds() {
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
- static reset() {
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 ObjectStackProtocolImplementation = class {
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 = SchemaRegistry.getRegisteredTypes();
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 = SchemaRegistry.listItems(request.type, packageId);
700
- if (items.length === 0) {
701
- const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
702
- if (alt) items = SchemaRegistry.listItems(alt, packageId);
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
- if (items.length === 0) {
705
- try {
706
- const whereClause = { type: request.type, state: "active" };
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
- const allRecords = await this.engine.find("sys_metadata", {
709
- where: whereClause
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 altRecords = await this.engine.find("sys_metadata", {
721
- where: { type: alt, state: "active" }
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
- } catch {
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.set(entry.name, entry);
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 = SchemaRegistry.getItem(request.type, request.name);
769
- if (item === void 0) {
770
- const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
771
- if (alt) item = SchemaRegistry.getItem(alt, request.name);
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
- try {
775
- const record = await this.engine.findOne("sys_metadata", {
776
- where: { type: request.type, name: request.name, state: "active" }
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 = SchemaRegistry.getObject(request.object);
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
- throw new Error(`Record ${request.id} not found in ${request.object}`);
1245
+ const err = new Error(`Record ${request.id} not found in ${request.object}`);
1246
+ err.code = "RECORD_NOT_FOUND";
1247
+ err.status = 404;
1248
+ err.object = request.object;
1249
+ throw err;
1006
1250
  }
1007
1251
  async createData(request) {
1008
- const result = await this.engine.insert(request.object, request.data);
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 result = await this.engine.update(request.object, request.data, { where: { id: request.id } });
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
- await this.engine.delete(request.object, { where: { id: request.id } });
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
- // Metadata Caching
1284
+ // Global Search (M10.5)
1033
1285
  // ==========================================
1034
- async getMetaItemCached(request) {
1035
- try {
1036
- let item = SchemaRegistry.getItem(request.type, request.name);
1037
- if (!item) {
1038
- const alt = import_shared.PLURAL_TO_SINGULAR[request.type] ?? import_shared.SINGULAR_TO_PLURAL[request.type];
1039
- if (alt) item = SchemaRegistry.getItem(alt, request.name);
1286
+ /**
1287
+ * Cross-object substring search across all registered objects that opt in
1288
+ * via `enable.searchable !== false` and `enable.apiEnabled !== false`.
1289
+ * Searches text-like fields (text/textarea/email/url/phone/markdown/html/string)
1290
+ * whose `searchable: true` flag is set, falling back to the object's
1291
+ * `displayNameField` (or `name`) when no fields are explicitly searchable.
1292
+ *
1293
+ * The query is split into whitespace-separated terms; each term must match
1294
+ * (case-insensitive LIKE) at least one searchable field. RBAC/RLS is
1295
+ * enforced by forwarding the caller's `context` to `engine.find` so users
1296
+ * only see records they are entitled to read.
1297
+ */
1298
+ async searchAll(request) {
1299
+ const q = (request.q ?? "").trim();
1300
+ if (!q) {
1301
+ return { query: "", hits: [], totalObjects: 0, totalHits: 0, truncated: false };
1302
+ }
1303
+ const overallLimit = Math.max(1, Math.min(100, Number(request.limit ?? 20)));
1304
+ const perObject = Math.max(1, Math.min(25, Number(request.perObject ?? 5)));
1305
+ const objectsFilter = request.objects && request.objects.length ? new Set(request.objects) : null;
1306
+ const terms = q.split(/\s+/).filter(Boolean).slice(0, 8);
1307
+ const allObjects = this.engine.registry?.getAllObjects?.() ?? [];
1308
+ const hits = [];
1309
+ let objectsScanned = 0;
1310
+ for (const obj of allObjects) {
1311
+ if (hits.length >= overallLimit) break;
1312
+ if (!obj?.name) continue;
1313
+ if (objectsFilter && !objectsFilter.has(obj.name)) continue;
1314
+ const enable = obj.enable ?? {};
1315
+ if (enable.searchable === false) continue;
1316
+ if (enable.apiEnabled === false) continue;
1317
+ if (obj.name.startsWith("sys_audit_log") || obj.name.startsWith("sys_activity") || obj.name.startsWith("sys_session") || obj.name.startsWith("sys_presence") || obj.name.startsWith("sys_metadata") || obj.name.startsWith("sys_account")) {
1318
+ continue;
1040
1319
  }
1041
- if (!item) {
1042
- try {
1043
- const services = this.getServicesRegistry?.();
1044
- const metadataService = services?.get("metadata");
1045
- if (metadataService && typeof metadataService.get === "function") {
1046
- item = await metadataService.get(request.type, request.name);
1320
+ const fieldsRaw = obj.fields;
1321
+ const fields = Array.isArray(fieldsRaw) ? fieldsRaw : fieldsRaw && typeof fieldsRaw === "object" ? Object.entries(fieldsRaw).map(([name, f]) => ({ name, ...f || {} })) : [];
1322
+ const TEXT_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "string", "email", "url", "phone", "markdown", "html"]);
1323
+ const fieldByName = new Map(fields.map((f) => [f.name, f]));
1324
+ const hasField = (n) => fieldByName.has(n);
1325
+ const titleFormatSource = obj.titleFormat && (obj.titleFormat.source || obj.titleFormat) || void 0;
1326
+ const renderTitle = (row) => {
1327
+ if (typeof titleFormatSource === "string") {
1328
+ let allResolved = true;
1329
+ const rendered = titleFormatSource.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key) => {
1330
+ const v = row[key];
1331
+ if (v == null || v === "") {
1332
+ allResolved = false;
1333
+ return "";
1334
+ }
1335
+ return String(v);
1336
+ }).trim();
1337
+ if (rendered && allResolved) return rendered;
1338
+ if (rendered) return rendered.replace(/\s+-\s+$/, "").replace(/^\s+-\s+/, "").trim() || row.id;
1339
+ }
1340
+ const candidates = [
1341
+ obj.displayNameField,
1342
+ "name",
1343
+ "full_name",
1344
+ "title",
1345
+ "subject",
1346
+ "label",
1347
+ "company"
1348
+ ].filter((c) => typeof c === "string" && hasField(c));
1349
+ for (const c of candidates) {
1350
+ const v = row[c];
1351
+ if (v != null && String(v).trim()) return String(v);
1352
+ }
1353
+ const fn = row.first_name, ln = row.last_name;
1354
+ if (fn || ln) return `${fn ?? ""} ${ln ?? ""}`.trim();
1355
+ return String(row.id);
1356
+ };
1357
+ const titleFieldName = obj.displayNameField || (hasField("name") ? "name" : void 0) || (hasField("title") ? "title" : void 0) || fields.find((f) => TEXT_TYPES.has(f.type))?.name;
1358
+ let searchableFields = fields.filter((f) => f && TEXT_TYPES.has(f.type) && f.searchable === true).map((f) => f.name);
1359
+ if (searchableFields.length === 0 && titleFieldName) {
1360
+ searchableFields = [titleFieldName];
1361
+ }
1362
+ if (searchableFields.length === 0) continue;
1363
+ objectsScanned++;
1364
+ const andClauses = terms.map((term) => ({
1365
+ $or: searchableFields.map((f) => ({ [f]: { $contains: term } }))
1366
+ }));
1367
+ const where = andClauses.length === 1 ? andClauses[0] : { $and: andClauses };
1368
+ try {
1369
+ const opts = {
1370
+ where,
1371
+ limit: perObject,
1372
+ orderBy: [{ field: "updated_at", direction: "desc" }]
1373
+ };
1374
+ if (request.context !== void 0) opts.context = request.context;
1375
+ const rows = await this.engine.find(obj.name, opts);
1376
+ for (const row of rows || []) {
1377
+ if (hits.length >= overallLimit) break;
1378
+ const title = renderTitle(row);
1379
+ let snippet;
1380
+ for (const f of searchableFields) {
1381
+ const v = row[f];
1382
+ if (typeof v === "string" && v) {
1383
+ const lc = v.toLowerCase();
1384
+ const idx = terms.map((t) => lc.indexOf(t.toLowerCase())).find((i) => i >= 0);
1385
+ if (idx != null && idx >= 0) {
1386
+ const start = Math.max(0, idx - 30);
1387
+ const end = Math.min(v.length, idx + 90);
1388
+ snippet = (start > 0 ? "\u2026" : "") + v.slice(start, end) + (end < v.length ? "\u2026" : "");
1389
+ break;
1390
+ }
1391
+ }
1047
1392
  }
1048
- } catch {
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 = SchemaRegistry.listItems("object");
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
- SchemaRegistry.registerItem(request.type, request.item, "name");
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: { type: request.type, name: request.name }
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
- await this.engine.insert("sys_metadata", {
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: "Saved to database and registry"
1942
+ message: orgId ? `Saved customization overlay (org=${orgId}) \u2014 type=${request.type}, name=${request.name}` : `Saved customization overlay (env-wide) \u2014 type=${request.type}, name=${request.name}`
1377
1943
  };
1378
1944
  } catch (dbError) {
1379
- console.warn(`[Protocol] DB persistence failed for ${request.type}/${request.name}: ${dbError.message}`);
1945
+ console.error(
1946
+ `[Protocol] sys_metadata persistence failed for ${request.type}/${request.name}: ${dbError.message}`
1947
+ );
1948
+ const err = new Error(
1949
+ `Failed to persist customization overlay to sys_metadata: ${dbError.message}. In-memory registry was updated but will be lost on restart.`
1950
+ );
1951
+ err.code = "overlay_persistence_failed";
1952
+ err.status = 500;
1953
+ throw err;
1954
+ }
1955
+ }
1956
+ /**
1957
+ * Remove a customization overlay row for the given metadata item, so the
1958
+ * next read falls through to the artifact-loaded default. Implements the
1959
+ * "Reset to factory default" semantic from ADR-0005. Whitelist is shared
1960
+ * with {@link saveMetaItem}.
1961
+ */
1962
+ async deleteMetaItem(request) {
1963
+ if (this.projectId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
1964
+ const err = new Error(
1965
+ `[not_overridable] Metadata type '${request.type}' has not opted into per-org overlay writes. See docs/adr/0005-metadata-customization-overlay.md.`
1966
+ );
1967
+ err.code = "not_overridable";
1968
+ err.status = 403;
1969
+ throw err;
1970
+ }
1971
+ const scopedWhere = {
1972
+ type: request.type,
1973
+ name: request.name,
1974
+ organization_id: request.organizationId ?? null
1975
+ };
1976
+ try {
1977
+ const existing = await this.engine.findOne("sys_metadata", { where: scopedWhere });
1978
+ if (!existing) {
1979
+ return {
1980
+ success: true,
1981
+ reset: false,
1982
+ message: `No customization overlay found for ${request.type}/${request.name} \u2014 already at artifact default.`
1983
+ };
1984
+ }
1985
+ await this.engine.delete("sys_metadata", { where: { id: existing.id } });
1986
+ if (this.projectId === void 0) {
1987
+ try {
1988
+ const services = this.getServicesRegistry?.();
1989
+ const metadataService = services?.get("metadata");
1990
+ if (metadataService && typeof metadataService.get === "function") {
1991
+ const artifactItem = await metadataService.get(request.type, request.name);
1992
+ if (artifactItem !== void 0) {
1993
+ this.engine.registry.registerItem(request.type, artifactItem, "name");
1994
+ }
1995
+ }
1996
+ } catch {
1997
+ }
1998
+ }
1380
1999
  return {
1381
2000
  success: true,
1382
- message: "Saved to memory registry (DB persistence unavailable)",
1383
- warning: dbError.message
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 records = await this.engine.find("sys_metadata", {
1397
- where: { state: "active" }
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
- SchemaRegistry.registerObject(data, record.packageId || "sys_metadata");
2033
+ this.engine.registry.registerObject(data, record.packageId || "sys_metadata");
1405
2034
  } else {
1406
- SchemaRegistry.registerItem(normalizedType, data, "name");
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
- console.warn(`[Protocol] DB hydration skipped: ${e.message}`);
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
- async unstarFeedItem(request) {
1493
- const svc = this.requireFeedService();
1494
- const item = await svc.getFeedItem(request.feedId);
1495
- if (!item) throw new Error(`Feed item ${request.feedId} not found`);
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
- async searchFeed(request) {
1500
- const svc = this.requireFeedService();
1501
- const result = await svc.listFeed({
1502
- object: request.object,
1503
- recordId: request.recordId,
1504
- filter: request.type,
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
- async getChangelog(request) {
1515
- const svc = this.requireFeedService();
1516
- const result = await svc.listFeed({
1517
- object: request.object,
1518
- recordId: request.recordId,
1519
- filter: "changes_only",
1520
- limit: request.limit,
1521
- cursor: request.cursor
1522
- });
1523
- const entries = result.items.map((item) => ({
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
- async feedSubscribe(request) {
1535
- const svc = this.requireFeedService();
1536
- const subscription = await svc.subscribe({
1537
- object: request.object,
1538
- recordId: request.recordId,
1539
- userId: "current_user",
1540
- events: request.events,
1541
- channels: request.channels
1542
- });
1543
- return { success: true, data: subscription };
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
- async feedUnsubscribe(request) {
1546
- const svc = this.requireFeedService();
1547
- const unsubscribed = await svc.unsubscribe(request.object, request.recordId, "current_user");
1548
- return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } };
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
- var import_kernel2 = require("@objectstack/spec/kernel");
1554
- var import_core = require("@objectstack/core");
1555
- var import_system = require("@objectstack/spec/system");
1556
- var import_shared2 = require("@objectstack/spec/shared");
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 SchemaRegistry;
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
- SchemaRegistry.installPackage(manifest);
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 = SchemaRegistry.registerObject(objDef, id, namespace, "own");
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 = SchemaRegistry.registerObject(objDef, id, namespace, "own");
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
- SchemaRegistry.registerObject(extDef, id, void 0, "extend", priority);
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
- SchemaRegistry.registerApp(app, id);
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
- SchemaRegistry.registerApp(manifest, id);
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 = item.name || item.id;
3475
+ const itemName = resolveMetadataItemName(key, item);
1853
3476
  if (itemName) {
1854
- SchemaRegistry.registerItem((0, import_shared2.pluralToSingular)(key), item, "name", id);
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
- SchemaRegistry.registerItem("data", dataset, "object", id);
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
- SchemaRegistry.registerKind(kind);
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 = SchemaRegistry.registerObject(objDef, ownerId, pluginNamespace, "own");
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 = SchemaRegistry.registerObject(objDef, ownerId, pluginNamespace, "own");
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
- SchemaRegistry.registerApp(plugin, ownerId);
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 = item.name || item.id;
3604
+ const itemName = resolveMetadataItemName(key, item);
1959
3605
  if (itemName) {
1960
- SchemaRegistry.registerItem((0, import_shared2.pluralToSingular)(key), item, "name", ownerId);
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 SchemaRegistry.getObject(objectName);
3645
+ return this._registry.getObject(objectName);
1999
3646
  }
2000
3647
  /**
2001
- * Resolve an object name to its Fully Qualified Name (FQN).
2002
- *
2003
- * Short names like 'task' are resolved to FQN like 'todo__task'
2004
- * via SchemaRegistry lookup. If no match is found, the name is
2005
- * returned as-is (for ad-hoc / unregistered objects).
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 = SchemaRegistry.getObject(name);
3655
+ const schema = this._registry.getObject(name);
2012
3656
  if (schema) {
2013
- return schema.tableName || schema.name;
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 = SchemaRegistry.getObject(objectName);
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 = SchemaRegistry.getObjectOwner(fqn);
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 = SchemaRegistry.getObject(objectName);
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 relatedRecords = await driver.find(referenceObject, relatedQuery) ?? [];
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
- let result = await driver.findOne(objectName, opCtx.ast);
3967
+ const findOneOpts = this.buildDriverOptions(opCtx.context);
3968
+ let result = await driver.findOne(objectName, opCtx.ast, findOneOpts);
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, hookContext.input.data, hookContext.input.options);
4011
+ result = await driver.bulkCreate(object, rows, hookContext.input.options);
2328
4012
  } else {
2329
- result = await Promise.all(hookContext.input.data.map((item) => driver.create(object, item, hookContext.input.options)));
4013
+ result = await Promise.all(rows.map((item) => driver.create(object, item, hookContext.input.options)));
2330
4014
  }
2331
4015
  } else {
2332
- result = await driver.create(object, hookContext.input.data, hookContext.input.options);
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
- return driver.find(object, ast);
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
- const driver = this.getDriver(options.object);
2550
- if (driver.execute) {
2551
- return driver.execute(command, void 0, options);
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 SchemaRegistry.registerObject(schema, packageId, namespace);
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
- SchemaRegistry.unregisterObjectsByPackage(packageId);
4366
+ this._registry.unregisterObjectsByPackage(packageId);
2583
4367
  } else {
2584
- SchemaRegistry.unregisterItem("object", name);
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 = SchemaRegistry.getAllObjects();
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
- SchemaRegistry.unregisterObjectsByPackage(packageId, true);
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
- import_kernel2.ExecutionContextSchema.parse(ctx),
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
- SchemaRegistry.registerItem(type, definition, "name");
4699
+ this.registry.registerItem(type, definition, "name");
2891
4700
  } else {
2892
- SchemaRegistry.registerItem(type, definition, definition.id ? "id" : "name");
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 = SchemaRegistry.getItem(type, name);
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 SchemaRegistry.getItem(type, name);
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 = SchemaRegistry.listItems(type);
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
- SchemaRegistry.unregisterItem(type, name);
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 = SchemaRegistry.getItem(type, name);
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 = SchemaRegistry.listItems(type);
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
- SchemaRegistry.unregisterObjectsByPackage(packageName);
4748
+ this.registry.unregisterObjectsByPackage(packageName);
2940
4749
  }
2941
4750
  /**
2942
4751
  * Convenience: get object definition
2943
4752
  */
2944
4753
  async getObject(name) {
2945
- return SchemaRegistry.getObject(name);
4754
+ return this.registry.getObject(name);
2946
4755
  }
2947
4756
  /**
2948
4757
  * Convenience: list all objects
2949
4758
  */
2950
4759
  async listObjects() {
2951
- return SchemaRegistry.getAllObjects();
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(ql, hostContext) {
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
- await this.restoreMetadataFromDb(ctx);
3029
- await this.syncRegisteredSchemas(ctx);
3030
- await this.bridgeObjectsToMetadataService(ctx);
4858
+ if (this.skipSchemaSync) {
4859
+ ctx.logger.info("Skipping schema sync (OS_SKIP_SCHEMA_SYNC=1) \u2014 assuming DDL is managed out-of-band");
4860
+ } else {
4861
+ await this.syncRegisteredSchemas(ctx);
4862
+ }
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 (ql) {
3039
- this.ql = 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
- this.ql.registerHook("beforeInsert", async (hookCtx) => {
3051
- if (hookCtx.session?.userId && hookCtx.input?.data) {
3052
- const data = hookCtx.input.data;
3053
- if (typeof data === "object" && data !== null) {
3054
- data.created_by = data.created_by ?? hookCtx.session.userId;
3055
- data.updated_by = hookCtx.session.userId;
3056
- data.created_at = data.created_at ?? (/* @__PURE__ */ new Date()).toISOString();
3057
- data.updated_at = (/* @__PURE__ */ new Date()).toISOString();
3058
- if (hookCtx.session.tenantId) {
3059
- data.tenant_id = data.tenant_id ?? hookCtx.session.tenantId;
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
- }, { object: "*", priority: 10 });
3064
- this.ql.registerHook("beforeUpdate", async (hookCtx) => {
3065
- if (hookCtx.session?.userId && hookCtx.input?.data) {
3066
- const data = hookCtx.input.data;
3067
- if (typeof data === "object" && data !== null) {
3068
- data.updated_by = hookCtx.session.userId;
3069
- data.updated_at = (/* @__PURE__ */ new Date()).toISOString();
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
- }, { object: "*", priority: 10 });
3073
- this.ql.registerHook("beforeUpdate", async (hookCtx) => {
3074
- if (hookCtx.input?.id && !hookCtx.previous) {
3075
- try {
3076
- const existing = await this.ql.findOne(hookCtx.object, {
3077
- where: { id: hookCtx.input.id }
3078
- });
3079
- if (existing) {
3080
- hookCtx.previous = existing;
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
- }, { object: "*", priority: 5 });
3086
- this.ql.registerHook("beforeDelete", async (hookCtx) => {
3087
- if (hookCtx.input?.id && !hookCtx.previous) {
3088
- try {
3089
- const existing = await this.ql.findOne(hookCtx.object, {
3090
- where: { id: hookCtx.input.id }
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
- }, { object: "*", priority: 5 });
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
- * Register tenant isolation middleware that auto-injects tenant_id filter
3103
- * for multi-tenant operations.
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 = obj.tableName || obj.name;
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