@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.mjs CHANGED
@@ -5,11 +5,8 @@ import { AppSchema } from "@objectstack/spec/ui";
5
5
  var RESERVED_NAMESPACES = /* @__PURE__ */ new Set(["base", "system"]);
6
6
  var DEFAULT_OWNER_PRIORITY = 100;
7
7
  var DEFAULT_EXTENDER_PRIORITY = 200;
8
- function computeFQN(namespace, shortName) {
9
- if (!namespace || RESERVED_NAMESPACES.has(namespace)) {
10
- return shortName;
11
- }
12
- return `${namespace}__${shortName}`;
8
+ function computeFQN(_namespace, shortName) {
9
+ return shortName;
13
10
  }
14
11
  function parseFQN(fqn) {
15
12
  const idx = fqn.indexOf("__");
@@ -37,14 +34,111 @@ function mergeObjectDefinitions(base, extension) {
37
34
  if (extension.description !== void 0) merged.description = extension.description;
38
35
  return merged;
39
36
  }
37
+ function applySystemFields(schema, opts) {
38
+ if (schema.systemFields === false) return schema;
39
+ if (schema.managedBy === "better-auth") return schema;
40
+ const sf = typeof schema.systemFields === "object" && schema.systemFields !== null ? schema.systemFields : void 0;
41
+ const tenancyDisabled = schema.tenancy?.enabled === false;
42
+ const wantTenant = opts.multiTenant && sf?.tenant !== false && !tenancyDisabled;
43
+ const wantAudit = sf?.audit !== false;
44
+ const additions = {};
45
+ if (wantTenant && !schema.fields?.organization_id) {
46
+ additions.organization_id = {
47
+ type: "lookup",
48
+ reference: "sys_organization",
49
+ label: "Organization",
50
+ required: false,
51
+ indexed: true,
52
+ hidden: true,
53
+ readonly: true,
54
+ system: true,
55
+ description: "Tenant scope (auto-populated by SecurityPlugin on insert)."
56
+ };
57
+ }
58
+ if (wantAudit) {
59
+ if (!schema.fields?.created_at) {
60
+ additions.created_at = {
61
+ type: "datetime",
62
+ label: "Created At",
63
+ required: false,
64
+ readonly: true,
65
+ system: true,
66
+ description: "Timestamp when the record was created (auto-populated by the driver)."
67
+ };
68
+ }
69
+ if (!schema.fields?.created_by) {
70
+ additions.created_by = {
71
+ type: "lookup",
72
+ reference: "sys_user",
73
+ label: "Created By",
74
+ required: false,
75
+ readonly: true,
76
+ system: true,
77
+ description: "User who created the record (populated when an authenticated session is present)."
78
+ };
79
+ }
80
+ if (!schema.fields?.updated_at) {
81
+ additions.updated_at = {
82
+ type: "datetime",
83
+ label: "Last Modified At",
84
+ required: false,
85
+ readonly: true,
86
+ system: true,
87
+ description: "Timestamp of the most recent modification (auto-populated by the driver)."
88
+ };
89
+ }
90
+ if (!schema.fields?.updated_by) {
91
+ additions.updated_by = {
92
+ type: "lookup",
93
+ reference: "sys_user",
94
+ label: "Last Modified By",
95
+ required: false,
96
+ readonly: true,
97
+ system: true,
98
+ description: "User who last modified the record (populated when an authenticated session is present)."
99
+ };
100
+ }
101
+ }
102
+ if (Object.keys(additions).length === 0) return schema;
103
+ return {
104
+ ...schema,
105
+ fields: { ...additions, ...schema.fields ?? {} }
106
+ };
107
+ }
40
108
  var SchemaRegistry = class {
41
- static get logLevel() {
109
+ constructor(options = {}) {
110
+ // ==========================================
111
+ // Logging control
112
+ // ==========================================
113
+ /** Controls verbosity of registry console messages. Default: 'info'. */
114
+ this._logLevel = "info";
115
+ // ==========================================
116
+ // Object-specific storage (Ownership Model)
117
+ // ==========================================
118
+ /** FQN → Contributor[] (all packages that own/extend this object) */
119
+ this.objectContributors = /* @__PURE__ */ new Map();
120
+ /** FQN → Merged ServiceObject (cached, invalidated on changes) */
121
+ this.mergedObjectCache = /* @__PURE__ */ new Map();
122
+ /** Namespace → Set<PackageId> (multiple packages can share a namespace) */
123
+ this.namespaceRegistry = /* @__PURE__ */ new Map();
124
+ // ==========================================
125
+ // Generic metadata storage (non-object types)
126
+ // ==========================================
127
+ /** Type → Name/ID → MetadataItem */
128
+ this.metadata = /* @__PURE__ */ new Map();
129
+ if (options.multiTenant !== void 0) {
130
+ this.multiTenant = options.multiTenant;
131
+ } else {
132
+ this.multiTenant = String(process.env.OS_MULTI_TENANT ?? "true").toLowerCase() !== "false";
133
+ }
134
+ }
135
+ get logLevel() {
42
136
  return this._logLevel;
43
137
  }
44
- static set logLevel(level) {
138
+ set logLevel(level) {
45
139
  this._logLevel = level;
46
140
  }
47
- static log(msg) {
141
+ log(msg) {
48
142
  if (this._logLevel === "silent" || this._logLevel === "error" || this._logLevel === "warn") return;
49
143
  console.log(msg);
50
144
  }
@@ -55,7 +149,7 @@ var SchemaRegistry = class {
55
149
  * Register a namespace for a package.
56
150
  * Multiple packages can share the same namespace (e.g. 'sys').
57
151
  */
58
- static registerNamespace(namespace, packageId) {
152
+ registerNamespace(namespace, packageId) {
59
153
  if (!namespace) return;
60
154
  let owners = this.namespaceRegistry.get(namespace);
61
155
  if (!owners) {
@@ -68,7 +162,7 @@ var SchemaRegistry = class {
68
162
  /**
69
163
  * Unregister a namespace when a package is uninstalled.
70
164
  */
71
- static unregisterNamespace(namespace, packageId) {
165
+ unregisterNamespace(namespace, packageId) {
72
166
  const owners = this.namespaceRegistry.get(namespace);
73
167
  if (owners) {
74
168
  owners.delete(packageId);
@@ -81,7 +175,7 @@ var SchemaRegistry = class {
81
175
  /**
82
176
  * Get the packages that use a namespace.
83
177
  */
84
- static getNamespaceOwner(namespace) {
178
+ getNamespaceOwner(namespace) {
85
179
  const owners = this.namespaceRegistry.get(namespace);
86
180
  if (!owners || owners.size === 0) return void 0;
87
181
  return owners.values().next().value;
@@ -89,7 +183,7 @@ var SchemaRegistry = class {
89
183
  /**
90
184
  * Get all packages that share a namespace.
91
185
  */
92
- static getNamespaceOwners(namespace) {
186
+ getNamespaceOwners(namespace) {
93
187
  const owners = this.namespaceRegistry.get(namespace);
94
188
  return owners ? Array.from(owners) : [];
95
189
  }
@@ -107,7 +201,8 @@ var SchemaRegistry = class {
107
201
  *
108
202
  * @throws Error if trying to 'own' an object that already has an owner
109
203
  */
110
- static registerObject(schema, packageId, namespace, ownership = "own", priority = ownership === "own" ? DEFAULT_OWNER_PRIORITY : DEFAULT_EXTENDER_PRIORITY) {
204
+ registerObject(schema, packageId, namespace, ownership = "own", priority = ownership === "own" ? DEFAULT_OWNER_PRIORITY : DEFAULT_EXTENDER_PRIORITY) {
205
+ schema = applySystemFields(schema, { multiTenant: this.multiTenant });
111
206
  const shortName = schema.name;
112
207
  const fqn = computeFQN(namespace, shortName);
113
208
  if (namespace) {
@@ -154,7 +249,7 @@ var SchemaRegistry = class {
154
249
  * Resolve an object by FQN, merging all contributions.
155
250
  * Returns the merged object or undefined if not found.
156
251
  */
157
- static resolveObject(fqn) {
252
+ resolveObject(fqn) {
158
253
  const cached = this.mergedObjectCache.get(fqn);
159
254
  if (cached) return cached;
160
255
  const contributors = this.objectContributors.get(fqn);
@@ -176,38 +271,42 @@ var SchemaRegistry = class {
176
271
  return merged;
177
272
  }
178
273
  /**
179
- * Get object by name (FQN, short name, or physical table name).
274
+ * Get object by name (short name canonical, FQN supported for disambiguation).
275
+ *
276
+ * Short names are canonical for user code, AI generation, and most lookups.
277
+ * FQN is accepted as an explicit fallback for cross-package disambiguation
278
+ * when two packages contribute objects with the same short name.
180
279
  *
181
280
  * Resolution order:
182
- * 1. Exact FQN match (e.g., 'crm__account')
183
- * 2. Short name fallback (e.g., 'account' → 'crm__account')
184
- * 3. Physical table name match (e.g., 'sys_user' 'sys__user')
185
- * ObjectSchema.create() auto-derives tableName as {namespace}_{name},
186
- * which uses a single underscore — different from the FQN double underscore.
187
- */
188
- static getObject(name) {
189
- const direct = this.resolveObject(name);
190
- if (direct) return direct;
281
+ * 1. Exact name match — the name IS the canonical key.
282
+ * If multiple packages contribute the same short name, a warning is logged
283
+ * and the first match wins disambiguate by passing the FQN explicitly.
284
+ * 2. Legacy FQN match (e.g., 'crm__account') backward compat.
285
+ */
286
+ getObject(name) {
287
+ const matches = [];
191
288
  for (const fqn of this.objectContributors.keys()) {
192
289
  const { shortName } = parseFQN(fqn);
193
290
  if (shortName === name) {
194
- return this.resolveObject(fqn);
291
+ matches.push(fqn);
195
292
  }
196
293
  }
197
- for (const fqn of this.objectContributors.keys()) {
198
- const resolved = this.resolveObject(fqn);
199
- if (resolved?.tableName === name) {
200
- return resolved;
294
+ if (matches.length > 0) {
295
+ if (matches.length > 1) {
296
+ console.warn(
297
+ `[SchemaRegistry] Ambiguous short name "${name}" matches: ${matches.join(", ")}. Returning first match. Use FQN to disambiguate.`
298
+ );
201
299
  }
300
+ return this.resolveObject(matches[0]);
202
301
  }
203
- return void 0;
302
+ return this.resolveObject(name);
204
303
  }
205
304
  /**
206
305
  * Get all registered objects (merged).
207
306
  *
208
307
  * @param packageId - Optional filter: only objects contributed by this package
209
308
  */
210
- static getAllObjects(packageId) {
309
+ getAllObjects(packageId) {
211
310
  const results = [];
212
311
  for (const fqn of this.objectContributors.keys()) {
213
312
  if (packageId) {
@@ -226,13 +325,13 @@ var SchemaRegistry = class {
226
325
  /**
227
326
  * Get all contributors for an object.
228
327
  */
229
- static getObjectContributors(fqn) {
328
+ getObjectContributors(fqn) {
230
329
  return this.objectContributors.get(fqn) || [];
231
330
  }
232
331
  /**
233
332
  * Get the owner contributor for an object.
234
333
  */
235
- static getObjectOwner(fqn) {
334
+ getObjectOwner(fqn) {
236
335
  const contributors = this.objectContributors.get(fqn);
237
336
  return contributors?.find((c) => c.ownership === "own");
238
337
  }
@@ -241,7 +340,7 @@ var SchemaRegistry = class {
241
340
  *
242
341
  * @throws Error if trying to uninstall an owner that has extenders
243
342
  */
244
- static unregisterObjectsByPackage(packageId, force = false) {
343
+ unregisterObjectsByPackage(packageId, force = false) {
245
344
  for (const [fqn, contributors] of this.objectContributors.entries()) {
246
345
  const packageContribs = contributors.filter((c) => c.packageId === packageId);
247
346
  for (const contrib of packageContribs) {
@@ -273,7 +372,7 @@ var SchemaRegistry = class {
273
372
  /**
274
373
  * Universal Register Method for non-object metadata.
275
374
  */
276
- static registerItem(type, item, keyField = "name", packageId) {
375
+ registerItem(type, item, keyField = "name", packageId) {
277
376
  if (!this.metadata.has(type)) {
278
377
  this.metadata.set(type, /* @__PURE__ */ new Map());
279
378
  }
@@ -289,7 +388,7 @@ var SchemaRegistry = class {
289
388
  }
290
389
  const storageKey = packageId ? `${packageId}:${baseName}` : baseName;
291
390
  if (collection.has(storageKey)) {
292
- console.warn(`[Registry] Overwriting ${type}: ${storageKey}`);
391
+ this.log(`[Registry] Overwriting ${type}: ${storageKey}`);
293
392
  }
294
393
  collection.set(storageKey, item);
295
394
  this.log(`[Registry] Registered ${type}: ${storageKey}`);
@@ -297,7 +396,7 @@ var SchemaRegistry = class {
297
396
  /**
298
397
  * Validate Metadata against Spec Zod Schemas
299
398
  */
300
- static validate(type, item) {
399
+ validate(type, item) {
301
400
  if (type === "object") {
302
401
  return ObjectSchema.parse(item);
303
402
  }
@@ -315,7 +414,7 @@ var SchemaRegistry = class {
315
414
  /**
316
415
  * Universal Unregister Method
317
416
  */
318
- static unregisterItem(type, name) {
417
+ unregisterItem(type, name) {
319
418
  const collection = this.metadata.get(type);
320
419
  if (!collection) {
321
420
  console.warn(`[Registry] Attempted to unregister non-existent ${type}: ${name}`);
@@ -338,7 +437,7 @@ var SchemaRegistry = class {
338
437
  /**
339
438
  * Universal Get Method
340
439
  */
341
- static getItem(type, name) {
440
+ getItem(type, name) {
342
441
  if (type === "object" || type === "objects") {
343
442
  return this.getObject(name);
344
443
  }
@@ -354,7 +453,7 @@ var SchemaRegistry = class {
354
453
  /**
355
454
  * Universal List Method
356
455
  */
357
- static listItems(type, packageId) {
456
+ listItems(type, packageId) {
358
457
  if (type === "object" || type === "objects") {
359
458
  return this.getAllObjects(packageId);
360
459
  }
@@ -367,7 +466,7 @@ var SchemaRegistry = class {
367
466
  /**
368
467
  * Get all registered metadata types (Kinds)
369
468
  */
370
- static getRegisteredTypes() {
469
+ getRegisteredTypes() {
371
470
  const types = Array.from(this.metadata.keys());
372
471
  if (!types.includes("object") && this.objectContributors.size > 0) {
373
472
  types.push("object");
@@ -377,7 +476,7 @@ var SchemaRegistry = class {
377
476
  // ==========================================
378
477
  // Package Management
379
478
  // ==========================================
380
- static installPackage(manifest, settings) {
479
+ installPackage(manifest, settings) {
381
480
  const now = (/* @__PURE__ */ new Date()).toISOString();
382
481
  const pkg = {
383
482
  manifest,
@@ -401,7 +500,7 @@ var SchemaRegistry = class {
401
500
  this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`);
402
501
  return pkg;
403
502
  }
404
- static uninstallPackage(id) {
503
+ uninstallPackage(id) {
405
504
  const pkg = this.getPackage(id);
406
505
  if (!pkg) {
407
506
  console.warn(`[Registry] Package not found for uninstall: ${id}`);
@@ -419,13 +518,13 @@ var SchemaRegistry = class {
419
518
  }
420
519
  return false;
421
520
  }
422
- static getPackage(id) {
521
+ getPackage(id) {
423
522
  return this.metadata.get("package")?.get(id);
424
523
  }
425
- static getAllPackages() {
524
+ getAllPackages() {
426
525
  return this.listItems("package");
427
526
  }
428
- static enablePackage(id) {
527
+ enablePackage(id) {
429
528
  const pkg = this.getPackage(id);
430
529
  if (pkg) {
431
530
  pkg.enabled = true;
@@ -436,7 +535,7 @@ var SchemaRegistry = class {
436
535
  }
437
536
  return pkg;
438
537
  }
439
- static disablePackage(id) {
538
+ disablePackage(id) {
440
539
  const pkg = this.getPackage(id);
441
540
  if (pkg) {
442
541
  pkg.enabled = false;
@@ -450,31 +549,31 @@ var SchemaRegistry = class {
450
549
  // ==========================================
451
550
  // App Helpers
452
551
  // ==========================================
453
- static registerApp(app, packageId) {
552
+ registerApp(app, packageId) {
454
553
  this.registerItem("app", app, "name", packageId);
455
554
  }
456
- static getApp(name) {
555
+ getApp(name) {
457
556
  return this.getItem("app", name);
458
557
  }
459
- static getAllApps() {
558
+ getAllApps() {
460
559
  return this.listItems("app");
461
560
  }
462
561
  // ==========================================
463
562
  // Plugin Helpers
464
563
  // ==========================================
465
- static registerPlugin(manifest) {
564
+ registerPlugin(manifest) {
466
565
  this.registerItem("plugin", manifest, "id");
467
566
  }
468
- static getAllPlugins() {
567
+ getAllPlugins() {
469
568
  return this.listItems("plugin");
470
569
  }
471
570
  // ==========================================
472
571
  // Kind Helpers
473
572
  // ==========================================
474
- static registerKind(kind) {
573
+ registerKind(kind) {
475
574
  this.registerItem("kind", kind, "id");
476
575
  }
477
- static getAllKinds() {
576
+ getAllKinds() {
478
577
  return this.listItems("kind");
479
578
  }
480
579
  // ==========================================
@@ -483,7 +582,7 @@ var SchemaRegistry = class {
483
582
  /**
484
583
  * Clear all registry state. Use only for testing.
485
584
  */
486
- static reset() {
585
+ reset() {
487
586
  this.objectContributors.clear();
488
587
  this.mergedObjectCache.clear();
489
588
  this.namespaceRegistry.clear();
@@ -491,29 +590,26 @@ var SchemaRegistry = class {
491
590
  this.log("[Registry] Reset complete");
492
591
  }
493
592
  };
494
- // ==========================================
495
- // Logging control
496
- // ==========================================
497
- /** Controls verbosity of registry console messages. Default: 'info'. */
498
- SchemaRegistry._logLevel = "info";
499
- // ==========================================
500
- // Object-specific storage (Ownership Model)
501
- // ==========================================
502
- /** FQN → Contributor[] (all packages that own/extend this object) */
503
- SchemaRegistry.objectContributors = /* @__PURE__ */ new Map();
504
- /** FQN → Merged ServiceObject (cached, invalidated on changes) */
505
- SchemaRegistry.mergedObjectCache = /* @__PURE__ */ new Map();
506
- /** Namespace → Set<PackageId> (multiple packages can share a namespace) */
507
- SchemaRegistry.namespaceRegistry = /* @__PURE__ */ new Map();
508
- // ==========================================
509
- // Generic metadata storage (non-object types)
510
- // ==========================================
511
- /** Type → Name/ID → MetadataItem */
512
- SchemaRegistry.metadata = /* @__PURE__ */ new Map();
513
593
 
514
594
  // src/protocol.ts
515
595
  import { parseFilterAST, isFilterAST } from "@objectstack/spec/data";
516
596
  import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from "@objectstack/spec/shared";
597
+ import { ListViewSchema, FormViewSchema, DashboardSchema } from "@objectstack/spec/ui";
598
+ import { DEFAULT_METADATA_TYPE_REGISTRY } from "@objectstack/spec/kernel";
599
+ var FORM_VIEW_TYPES = /* @__PURE__ */ new Set(["simple", "tabbed", "wizard", "split", "drawer", "modal"]);
600
+ function resolveOverlaySchema(type, item) {
601
+ const singular = PLURAL_TO_SINGULAR[type] ?? type;
602
+ switch (singular) {
603
+ case "view": {
604
+ const t = item && typeof item === "object" && "type" in item ? String(item.type) : void 0;
605
+ return t && FORM_VIEW_TYPES.has(t) ? FormViewSchema : ListViewSchema;
606
+ }
607
+ case "dashboard":
608
+ return DashboardSchema;
609
+ default:
610
+ return null;
611
+ }
612
+ }
517
613
  function simpleHash(str) {
518
614
  let hash = 0;
519
615
  for (let i = 0; i < str.length; i++) {
@@ -540,11 +636,75 @@ var SERVICE_CONFIG = {
540
636
  "file-storage": { route: "/api/v1/storage", plugin: "plugin-storage" },
541
637
  search: { route: "/api/v1/search", plugin: "plugin-search" }
542
638
  };
543
- var ObjectStackProtocolImplementation = class {
544
- constructor(engine, getServicesRegistry, getFeedService) {
639
+ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementation {
640
+ constructor(engine, getServicesRegistry, getFeedService, projectId) {
641
+ /**
642
+ * One-time guard for ensuring the overlay-uniqueness UNIQUE INDEX exists
643
+ * on `sys_metadata`. ADR-0005: scopes overlays by
644
+ * `(type, name, organization_id, project_id, scope)` for active rows only.
645
+ * Idempotent SQL — safe to attempt on every protocol instance.
646
+ *
647
+ * Inlined here (rather than importing from @objectstack/metadata/migrations)
648
+ * to avoid a circular dependency: metadata already depends on objectql.
649
+ */
650
+ this.overlayIndexEnsured = false;
545
651
  this.engine = engine;
546
652
  this.getServicesRegistry = getServicesRegistry;
547
653
  this.getFeedService = getFeedService;
654
+ this.projectId = projectId;
655
+ }
656
+ async ensureOverlayIndex() {
657
+ if (this.overlayIndexEnsured) return;
658
+ this.overlayIndexEnsured = true;
659
+ try {
660
+ const engineAny = this.engine;
661
+ let driver = engineAny?.driver ?? engineAny?.getDriver?.();
662
+ if (!driver && engineAny?.drivers instanceof Map) {
663
+ for (const candidate of engineAny.drivers.values()) {
664
+ if (candidate && (typeof candidate.raw === "function" || typeof candidate.execute === "function")) {
665
+ driver = candidate;
666
+ break;
667
+ }
668
+ }
669
+ }
670
+ if (!driver) return;
671
+ const exec = async (sql) => {
672
+ if (typeof driver.raw === "function") {
673
+ await driver.raw(sql);
674
+ } else if (typeof driver.execute === "function") {
675
+ await driver.execute(sql);
676
+ } else {
677
+ throw new Error("driver has neither raw nor execute");
678
+ }
679
+ };
680
+ try {
681
+ await exec("DROP INDEX IF EXISTS idx_sys_metadata_overlay_active");
682
+ } catch {
683
+ }
684
+ const partialSql = "CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ON sys_metadata (type, name, organization_id) WHERE state = 'active'";
685
+ const fallbackSql = "CREATE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ON sys_metadata (type, name, organization_id)";
686
+ try {
687
+ await exec(partialSql);
688
+ } catch (err) {
689
+ const msg = err instanceof Error ? err.message : String(err);
690
+ if (/partial|where clause|syntax/i.test(msg)) {
691
+ try {
692
+ await exec(fallbackSql);
693
+ } catch {
694
+ }
695
+ }
696
+ }
697
+ } catch {
698
+ }
699
+ }
700
+ /**
701
+ * Exposes the project scope the protocol is bound to. Consumers like
702
+ * the HTTP dispatcher use this to decide whether to trust the process-
703
+ * wide SchemaRegistry or whether they must route a read through the
704
+ * protocol's project_id-filtered lookup.
705
+ */
706
+ getProjectId() {
707
+ return this.projectId;
548
708
  }
549
709
  requireFeedService() {
550
710
  const svc = this.getFeedService?.();
@@ -641,7 +801,7 @@ var ObjectStackProtocolImplementation = class {
641
801
  };
642
802
  }
643
803
  async getMetaTypes() {
644
- const schemaTypes = SchemaRegistry.getRegisteredTypes();
804
+ const schemaTypes = this.engine.registry.getRegisteredTypes();
645
805
  let runtimeTypes = [];
646
806
  try {
647
807
  const services = this.getServicesRegistry?.();
@@ -656,41 +816,66 @@ var ObjectStackProtocolImplementation = class {
656
816
  }
657
817
  async getMetaItems(request) {
658
818
  const { packageId } = request;
659
- let items = SchemaRegistry.listItems(request.type, packageId);
660
- if (items.length === 0) {
661
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
662
- if (alt) items = SchemaRegistry.listItems(alt, packageId);
819
+ let items = [];
820
+ if (this.projectId === void 0) {
821
+ items = [...this.engine.registry.listItems(request.type, packageId)];
822
+ if (items.length === 0) {
823
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
824
+ if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
825
+ }
826
+ } else {
827
+ items = [...this.engine.registry.listItems(request.type, packageId)];
828
+ if (items.length === 0) {
829
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
830
+ if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
831
+ }
663
832
  }
664
- if (items.length === 0) {
665
- try {
666
- const whereClause = { type: request.type, state: "active" };
833
+ try {
834
+ const orgId = request.organizationId;
835
+ const queryByOrg = async (oid) => {
836
+ const whereClause = {
837
+ type: request.type,
838
+ state: "active",
839
+ organization_id: oid
840
+ };
667
841
  if (packageId) whereClause._packageId = packageId;
668
- const allRecords = await this.engine.find("sys_metadata", {
669
- where: whereClause
670
- });
671
- if (allRecords && allRecords.length > 0) {
672
- items = allRecords.map((record) => {
673
- const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
674
- SchemaRegistry.registerItem(request.type, data, "name");
675
- return data;
676
- });
677
- } else {
842
+ let rs = await this.engine.find("sys_metadata", { where: whereClause });
843
+ if (!rs || rs.length === 0) {
678
844
  const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
679
845
  if (alt) {
680
- const altRecords = await this.engine.find("sys_metadata", {
681
- where: { type: alt, state: "active" }
682
- });
683
- if (altRecords && altRecords.length > 0) {
684
- items = altRecords.map((record) => {
685
- const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
686
- SchemaRegistry.registerItem(request.type, data, "name");
687
- return data;
688
- });
689
- }
846
+ const altWhere = { type: alt, state: "active", organization_id: oid };
847
+ if (packageId) altWhere._packageId = packageId;
848
+ rs = await this.engine.find("sys_metadata", { where: altWhere });
690
849
  }
691
850
  }
692
- } catch {
851
+ return rs ?? [];
852
+ };
853
+ const envWideRecords = await queryByOrg(null);
854
+ const orgRecords = orgId ? await queryByOrg(orgId) : [];
855
+ const mergedMap = /* @__PURE__ */ new Map();
856
+ for (const r of envWideRecords) mergedMap.set(r.name, r);
857
+ for (const r of orgRecords) mergedMap.set(r.name, r);
858
+ const records = Array.from(mergedMap.values());
859
+ if (records && records.length > 0) {
860
+ const byName = /* @__PURE__ */ new Map();
861
+ for (const existing of items) {
862
+ const entry = existing;
863
+ if (entry && typeof entry === "object" && "name" in entry) {
864
+ byName.set(entry.name, entry);
865
+ }
866
+ }
867
+ for (const record of records) {
868
+ const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
869
+ if (data && typeof data === "object" && "name" in data) {
870
+ byName.set(data.name, data);
871
+ }
872
+ if (this.projectId === void 0) {
873
+ this.engine.registry.registerItem(request.type, data, "name");
874
+ }
875
+ }
876
+ items = Array.from(byName.values());
693
877
  }
878
+ } catch {
694
879
  }
695
880
  try {
696
881
  const services = this.getServicesRegistry?.();
@@ -711,7 +896,9 @@ var ObjectStackProtocolImplementation = class {
711
896
  for (const item of runtimeItems) {
712
897
  const entry = item;
713
898
  if (entry && typeof entry === "object" && "name" in entry) {
714
- itemMap.set(entry.name, entry);
899
+ if (!itemMap.has(entry.name)) {
900
+ itemMap.set(entry.name, entry);
901
+ }
715
902
  }
716
903
  }
717
904
  items = Array.from(itemMap.values());
@@ -725,32 +912,41 @@ var ObjectStackProtocolImplementation = class {
725
912
  };
726
913
  }
727
914
  async getMetaItem(request) {
728
- let item = SchemaRegistry.getItem(request.type, request.name);
729
- if (item === void 0) {
730
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
731
- if (alt) item = SchemaRegistry.getItem(alt, request.name);
915
+ let item;
916
+ const orgId = request.organizationId;
917
+ try {
918
+ const findOverlay = async (oid) => {
919
+ const where = {
920
+ type: request.type,
921
+ name: request.name,
922
+ state: "active",
923
+ organization_id: oid
924
+ };
925
+ const rec = await this.engine.findOne("sys_metadata", { where });
926
+ if (rec) return rec;
927
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
928
+ if (alt) {
929
+ const altWhere = {
930
+ type: alt,
931
+ name: request.name,
932
+ state: "active",
933
+ organization_id: oid
934
+ };
935
+ return await this.engine.findOne("sys_metadata", { where: altWhere });
936
+ }
937
+ return void 0;
938
+ };
939
+ const record = (orgId ? await findOverlay(orgId) : void 0) ?? await findOverlay(null);
940
+ if (record) {
941
+ item = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
942
+ }
943
+ } catch {
732
944
  }
733
945
  if (item === void 0) {
734
- try {
735
- const record = await this.engine.findOne("sys_metadata", {
736
- where: { type: request.type, name: request.name, state: "active" }
737
- });
738
- if (record) {
739
- item = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
740
- SchemaRegistry.registerItem(request.type, item, "name");
741
- } else {
742
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
743
- if (alt) {
744
- const altRecord = await this.engine.findOne("sys_metadata", {
745
- where: { type: alt, name: request.name, state: "active" }
746
- });
747
- if (altRecord) {
748
- item = typeof altRecord.metadata === "string" ? JSON.parse(altRecord.metadata) : altRecord.metadata;
749
- SchemaRegistry.registerItem(request.type, item, "name");
750
- }
751
- }
752
- }
753
- } catch {
946
+ item = this.engine.registry.getItem(request.type, request.name);
947
+ if (item === void 0) {
948
+ const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
949
+ if (alt) item = this.engine.registry.getItem(alt, request.name);
754
950
  }
755
951
  }
756
952
  if (item === void 0) {
@@ -770,7 +966,7 @@ var ObjectStackProtocolImplementation = class {
770
966
  };
771
967
  }
772
968
  async getUiView(request) {
773
- const schema = SchemaRegistry.getObject(request.object);
969
+ const schema = this.engine.registry.getObject(request.object);
774
970
  if (!schema) throw new Error(`Object ${request.object} not found`);
775
971
  const fields = schema.fields || {};
776
972
  const fieldKeys = Object.keys(fields);
@@ -826,6 +1022,21 @@ var ObjectStackProtocolImplementation = class {
826
1022
  }
827
1023
  async findData(request) {
828
1024
  const options = { ...request.query };
1025
+ if (request.context !== void 0) {
1026
+ options.context = request.context;
1027
+ }
1028
+ for (const [dollar, bare] of [
1029
+ ["$top", "top"],
1030
+ ["$skip", "skip"],
1031
+ ["$orderby", "orderBy"],
1032
+ ["$select", "select"],
1033
+ ["$count", "count"]
1034
+ ]) {
1035
+ if (options[dollar] != null && options[bare] == null) {
1036
+ options[bare] = options[dollar];
1037
+ }
1038
+ delete options[dollar];
1039
+ }
829
1040
  if (options.top != null) {
830
1041
  options.limit = Number(options.top);
831
1042
  delete options.top;
@@ -932,6 +1143,23 @@ var ObjectStackProtocolImplementation = class {
932
1143
  options.where = implicitFilters;
933
1144
  }
934
1145
  }
1146
+ const hasGroupBy = Array.isArray(options.groupBy) && options.groupBy.length > 0;
1147
+ const hasAggregations = Array.isArray(options.aggregations) && options.aggregations.length > 0;
1148
+ if (hasGroupBy || hasAggregations) {
1149
+ const records2 = await this.engine.aggregate(request.object, {
1150
+ where: options.where,
1151
+ groupBy: options.groupBy,
1152
+ aggregations: options.aggregations,
1153
+ context: options.context
1154
+ });
1155
+ const limited = typeof options.limit === "number" && options.limit > 0 ? records2.slice(0, options.limit) : records2;
1156
+ return {
1157
+ object: request.object,
1158
+ records: limited,
1159
+ total: limited.length,
1160
+ hasMore: false
1161
+ };
1162
+ }
935
1163
  const records = await this.engine.find(request.object, options);
936
1164
  return {
937
1165
  object: request.object,
@@ -944,6 +1172,9 @@ var ObjectStackProtocolImplementation = class {
944
1172
  const queryOptions = {
945
1173
  where: { id: request.id }
946
1174
  };
1175
+ if (request.context !== void 0) {
1176
+ queryOptions.context = request.context;
1177
+ }
947
1178
  if (request.select) {
948
1179
  queryOptions.fields = typeof request.select === "string" ? request.select.split(",").map((s) => s.trim()).filter(Boolean) : request.select;
949
1180
  }
@@ -962,10 +1193,18 @@ var ObjectStackProtocolImplementation = class {
962
1193
  record: result
963
1194
  };
964
1195
  }
965
- throw new Error(`Record ${request.id} not found in ${request.object}`);
1196
+ const err = new Error(`Record ${request.id} not found in ${request.object}`);
1197
+ err.code = "RECORD_NOT_FOUND";
1198
+ err.status = 404;
1199
+ err.object = request.object;
1200
+ throw err;
966
1201
  }
967
1202
  async createData(request) {
968
- const result = await this.engine.insert(request.object, request.data);
1203
+ const result = await this.engine.insert(
1204
+ request.object,
1205
+ request.data,
1206
+ request.context !== void 0 ? { context: request.context } : void 0
1207
+ );
969
1208
  return {
970
1209
  object: request.object,
971
1210
  id: result.id,
@@ -973,7 +1212,9 @@ var ObjectStackProtocolImplementation = class {
973
1212
  };
974
1213
  }
975
1214
  async updateData(request) {
976
- const result = await this.engine.update(request.object, request.data, { where: { id: request.id } });
1215
+ const opts = { where: { id: request.id } };
1216
+ if (request.context !== void 0) opts.context = request.context;
1217
+ const result = await this.engine.update(request.object, request.data, opts);
977
1218
  return {
978
1219
  object: request.object,
979
1220
  id: request.id,
@@ -981,7 +1222,9 @@ var ObjectStackProtocolImplementation = class {
981
1222
  };
982
1223
  }
983
1224
  async deleteData(request) {
984
- await this.engine.delete(request.object, { where: { id: request.id } });
1225
+ const opts = { where: { id: request.id } };
1226
+ if (request.context !== void 0) opts.context = request.context;
1227
+ await this.engine.delete(request.object, opts);
985
1228
  return {
986
1229
  object: request.object,
987
1230
  id: request.id,
@@ -989,25 +1232,281 @@ var ObjectStackProtocolImplementation = class {
989
1232
  };
990
1233
  }
991
1234
  // ==========================================
992
- // Metadata Caching
1235
+ // Global Search (M10.5)
993
1236
  // ==========================================
994
- async getMetaItemCached(request) {
995
- try {
996
- let item = SchemaRegistry.getItem(request.type, request.name);
997
- if (!item) {
998
- const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
999
- if (alt) item = SchemaRegistry.getItem(alt, request.name);
1237
+ /**
1238
+ * Cross-object substring search across all registered objects that opt in
1239
+ * via `enable.searchable !== false` and `enable.apiEnabled !== false`.
1240
+ * Searches text-like fields (text/textarea/email/url/phone/markdown/html/string)
1241
+ * whose `searchable: true` flag is set, falling back to the object's
1242
+ * `displayNameField` (or `name`) when no fields are explicitly searchable.
1243
+ *
1244
+ * The query is split into whitespace-separated terms; each term must match
1245
+ * (case-insensitive LIKE) at least one searchable field. RBAC/RLS is
1246
+ * enforced by forwarding the caller's `context` to `engine.find` so users
1247
+ * only see records they are entitled to read.
1248
+ */
1249
+ async searchAll(request) {
1250
+ const q = (request.q ?? "").trim();
1251
+ if (!q) {
1252
+ return { query: "", hits: [], totalObjects: 0, totalHits: 0, truncated: false };
1253
+ }
1254
+ const overallLimit = Math.max(1, Math.min(100, Number(request.limit ?? 20)));
1255
+ const perObject = Math.max(1, Math.min(25, Number(request.perObject ?? 5)));
1256
+ const objectsFilter = request.objects && request.objects.length ? new Set(request.objects) : null;
1257
+ const terms = q.split(/\s+/).filter(Boolean).slice(0, 8);
1258
+ const allObjects = this.engine.registry?.getAllObjects?.() ?? [];
1259
+ const hits = [];
1260
+ let objectsScanned = 0;
1261
+ for (const obj of allObjects) {
1262
+ if (hits.length >= overallLimit) break;
1263
+ if (!obj?.name) continue;
1264
+ if (objectsFilter && !objectsFilter.has(obj.name)) continue;
1265
+ const enable = obj.enable ?? {};
1266
+ if (enable.searchable === false) continue;
1267
+ if (enable.apiEnabled === false) continue;
1268
+ if (obj.name.startsWith("sys_audit_log") || obj.name.startsWith("sys_activity") || obj.name.startsWith("sys_session") || obj.name.startsWith("sys_presence") || obj.name.startsWith("sys_metadata") || obj.name.startsWith("sys_account")) {
1269
+ continue;
1000
1270
  }
1001
- if (!item) {
1002
- try {
1003
- const services = this.getServicesRegistry?.();
1004
- const metadataService = services?.get("metadata");
1005
- if (metadataService && typeof metadataService.get === "function") {
1006
- item = await metadataService.get(request.type, request.name);
1271
+ const fieldsRaw = obj.fields;
1272
+ const fields = Array.isArray(fieldsRaw) ? fieldsRaw : fieldsRaw && typeof fieldsRaw === "object" ? Object.entries(fieldsRaw).map(([name, f]) => ({ name, ...f || {} })) : [];
1273
+ const TEXT_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "string", "email", "url", "phone", "markdown", "html"]);
1274
+ const fieldByName = new Map(fields.map((f) => [f.name, f]));
1275
+ const hasField = (n) => fieldByName.has(n);
1276
+ const titleFormatSource = obj.titleFormat && (obj.titleFormat.source || obj.titleFormat) || void 0;
1277
+ const renderTitle = (row) => {
1278
+ if (typeof titleFormatSource === "string") {
1279
+ let allResolved = true;
1280
+ const rendered = titleFormatSource.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key) => {
1281
+ const v = row[key];
1282
+ if (v == null || v === "") {
1283
+ allResolved = false;
1284
+ return "";
1285
+ }
1286
+ return String(v);
1287
+ }).trim();
1288
+ if (rendered && allResolved) return rendered;
1289
+ if (rendered) return rendered.replace(/\s+-\s+$/, "").replace(/^\s+-\s+/, "").trim() || row.id;
1290
+ }
1291
+ const candidates = [
1292
+ obj.displayNameField,
1293
+ "name",
1294
+ "full_name",
1295
+ "title",
1296
+ "subject",
1297
+ "label",
1298
+ "company"
1299
+ ].filter((c) => typeof c === "string" && hasField(c));
1300
+ for (const c of candidates) {
1301
+ const v = row[c];
1302
+ if (v != null && String(v).trim()) return String(v);
1303
+ }
1304
+ const fn = row.first_name, ln = row.last_name;
1305
+ if (fn || ln) return `${fn ?? ""} ${ln ?? ""}`.trim();
1306
+ return String(row.id);
1307
+ };
1308
+ const titleFieldName = obj.displayNameField || (hasField("name") ? "name" : void 0) || (hasField("title") ? "title" : void 0) || fields.find((f) => TEXT_TYPES.has(f.type))?.name;
1309
+ let searchableFields = fields.filter((f) => f && TEXT_TYPES.has(f.type) && f.searchable === true).map((f) => f.name);
1310
+ if (searchableFields.length === 0 && titleFieldName) {
1311
+ searchableFields = [titleFieldName];
1312
+ }
1313
+ if (searchableFields.length === 0) continue;
1314
+ objectsScanned++;
1315
+ const andClauses = terms.map((term) => ({
1316
+ $or: searchableFields.map((f) => ({ [f]: { $contains: term } }))
1317
+ }));
1318
+ const where = andClauses.length === 1 ? andClauses[0] : { $and: andClauses };
1319
+ try {
1320
+ const opts = {
1321
+ where,
1322
+ limit: perObject,
1323
+ orderBy: [{ field: "updated_at", direction: "desc" }]
1324
+ };
1325
+ if (request.context !== void 0) opts.context = request.context;
1326
+ const rows = await this.engine.find(obj.name, opts);
1327
+ for (const row of rows || []) {
1328
+ if (hits.length >= overallLimit) break;
1329
+ const title = renderTitle(row);
1330
+ let snippet;
1331
+ for (const f of searchableFields) {
1332
+ const v = row[f];
1333
+ if (typeof v === "string" && v) {
1334
+ const lc = v.toLowerCase();
1335
+ const idx = terms.map((t) => lc.indexOf(t.toLowerCase())).find((i) => i >= 0);
1336
+ if (idx != null && idx >= 0) {
1337
+ const start = Math.max(0, idx - 30);
1338
+ const end = Math.min(v.length, idx + 90);
1339
+ snippet = (start > 0 ? "\u2026" : "") + v.slice(start, end) + (end < v.length ? "\u2026" : "");
1340
+ break;
1341
+ }
1342
+ }
1007
1343
  }
1008
- } catch {
1344
+ hits.push({
1345
+ object: obj.name,
1346
+ id: row.id,
1347
+ title,
1348
+ snippet,
1349
+ record: row
1350
+ });
1351
+ }
1352
+ } catch {
1353
+ continue;
1354
+ }
1355
+ }
1356
+ return {
1357
+ query: q,
1358
+ hits,
1359
+ totalObjects: objectsScanned,
1360
+ totalHits: hits.length,
1361
+ truncated: hits.length >= overallLimit
1362
+ };
1363
+ }
1364
+ // ==========================================
1365
+ // Lead Convert (M10.6)
1366
+ // ==========================================
1367
+ /**
1368
+ * Convert a qualified Lead into an Account + Contact (+ optional
1369
+ * Opportunity) and mark the Lead as converted. Mirrors the Salesforce
1370
+ * lead-conversion model:
1371
+ *
1372
+ * - If `accountId` is provided, the lead's company info is NOT used
1373
+ * to create a new account; the new contact and opportunity link to
1374
+ * the existing account instead.
1375
+ * - If `contactId` is provided, no new contact is created either —
1376
+ * useful when the lead is a new contact at an existing account.
1377
+ * - `createOpportunity` defaults to true; pass `false` to convert
1378
+ * without producing an opportunity (some teams convert "logos
1379
+ * only" first).
1380
+ * - Lead is updated atomically: `is_converted=true`,
1381
+ * `converted_account`/`converted_contact`/`converted_opportunity`
1382
+ * pointers, `converted_date`, and `status='converted'`.
1383
+ *
1384
+ * Atomicity is enforced via the default driver's transaction support
1385
+ * when available; otherwise a best-effort compensation (delete
1386
+ * already-created child records on failure) is attempted. Permission
1387
+ * checks on each child object are inherited from the caller's
1388
+ * execution context so SecurityPlugin still gates account/contact/
1389
+ * opportunity creates.
1390
+ */
1391
+ async convertLead(request) {
1392
+ const leadId = String(request.leadId || "").trim();
1393
+ if (!leadId) {
1394
+ const err = new Error("leadId is required");
1395
+ err.status = 400;
1396
+ err.code = "INVALID_REQUEST";
1397
+ throw err;
1398
+ }
1399
+ const ctx = request.context;
1400
+ const ctxOpt = ctx !== void 0 ? { context: ctx } : void 0;
1401
+ const lead = await this.engine.findOne("lead", { where: { id: leadId }, ...ctxOpt });
1402
+ if (!lead) {
1403
+ const err = new Error(`Lead '${leadId}' not found`);
1404
+ err.status = 404;
1405
+ err.code = "LEAD_NOT_FOUND";
1406
+ throw err;
1407
+ }
1408
+ if (lead.is_converted) {
1409
+ const err = new Error(`Lead '${leadId}' is already converted`);
1410
+ err.status = 409;
1411
+ err.code = "LEAD_ALREADY_CONVERTED";
1412
+ throw err;
1413
+ }
1414
+ const runConversion = async (trxCtx) => {
1415
+ const opCtx = trxCtx ?? ctx;
1416
+ const trxCtxOpt = opCtx !== void 0 ? { context: opCtx } : void 0;
1417
+ let account;
1418
+ if (request.accountId) {
1419
+ account = await this.engine.findOne("account", { where: { id: request.accountId }, ...trxCtxOpt });
1420
+ if (!account) {
1421
+ const err = new Error(`Account '${request.accountId}' not found`);
1422
+ err.status = 404;
1423
+ err.code = "ACCOUNT_NOT_FOUND";
1424
+ throw err;
1009
1425
  }
1426
+ } else {
1427
+ const accountPayload = {
1428
+ name: lead.company || `${lead.first_name ?? ""} ${lead.last_name ?? ""}`.trim() || "Untitled Account"
1429
+ };
1430
+ if (lead.industry) accountPayload.industry = lead.industry;
1431
+ if (lead.annual_revenue) accountPayload.annual_revenue = lead.annual_revenue;
1432
+ if (lead.number_of_employees) accountPayload.employees = lead.number_of_employees;
1433
+ if (lead.website) accountPayload.website = lead.website;
1434
+ if (lead.phone) accountPayload.phone = lead.phone;
1435
+ if (lead.address) accountPayload.billing_address = lead.address;
1436
+ if (lead.owner) accountPayload.owner = lead.owner;
1437
+ account = await this.engine.insert("account", accountPayload, trxCtxOpt);
1438
+ }
1439
+ let contact;
1440
+ if (request.contactId) {
1441
+ contact = await this.engine.findOne("contact", { where: { id: request.contactId }, ...trxCtxOpt });
1442
+ if (!contact) {
1443
+ const err = new Error(`Contact '${request.contactId}' not found`);
1444
+ err.status = 404;
1445
+ err.code = "CONTACT_NOT_FOUND";
1446
+ throw err;
1447
+ }
1448
+ } else {
1449
+ const contactPayload = {
1450
+ first_name: lead.first_name ?? "",
1451
+ last_name: lead.last_name ?? lead.company ?? "Unknown"
1452
+ };
1453
+ if (lead.salutation) contactPayload.salutation = lead.salutation;
1454
+ if (lead.email) contactPayload.email = lead.email;
1455
+ if (lead.phone) contactPayload.phone = lead.phone;
1456
+ if (lead.mobile) contactPayload.mobile = lead.mobile;
1457
+ if (lead.title) contactPayload.title = lead.title;
1458
+ if (lead.address) contactPayload.mailing_address = lead.address;
1459
+ if (lead.owner) contactPayload.owner = lead.owner;
1460
+ if (account?.id) contactPayload.account = account.id;
1461
+ contact = await this.engine.insert("contact", contactPayload, trxCtxOpt);
1462
+ }
1463
+ let opportunity = null;
1464
+ const shouldCreateOpp = request.createOpportunity !== false;
1465
+ if (shouldCreateOpp) {
1466
+ const oppOverrides = request.opportunity ?? {};
1467
+ const defaultName = oppOverrides.name || `${account?.name ?? lead.company ?? "Lead"} - New Opportunity`;
1468
+ const defaultClose = oppOverrides.close_date || new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3).toISOString().slice(0, 10);
1469
+ const oppPayload = {
1470
+ name: defaultName,
1471
+ stage: oppOverrides.stage ?? "qualification",
1472
+ close_date: defaultClose
1473
+ };
1474
+ if (oppOverrides.amount !== void 0) oppPayload.amount = oppOverrides.amount;
1475
+ else if (lead.annual_revenue) oppPayload.amount = lead.annual_revenue;
1476
+ if (account?.id) oppPayload.account = account.id;
1477
+ if (contact?.id) oppPayload.primary_contact = contact.id;
1478
+ if (lead.owner) oppPayload.owner = lead.owner;
1479
+ if (lead.lead_source) oppPayload.lead_source = lead.lead_source;
1480
+ opportunity = await this.engine.insert("opportunity", oppPayload, trxCtxOpt);
1010
1481
  }
1482
+ const leadUpdate = {
1483
+ is_converted: true,
1484
+ status: request.convertedStatus ?? "converted",
1485
+ converted_account: account?.id ?? null,
1486
+ converted_contact: contact?.id ?? null,
1487
+ converted_opportunity: opportunity?.id ?? null,
1488
+ converted_date: (/* @__PURE__ */ new Date()).toISOString()
1489
+ };
1490
+ const updatedLead = await this.engine.update("lead", leadUpdate, {
1491
+ where: { id: leadId },
1492
+ ...trxCtxOpt
1493
+ });
1494
+ return {
1495
+ lead: updatedLead ?? { ...lead, ...leadUpdate },
1496
+ account,
1497
+ contact,
1498
+ opportunity
1499
+ };
1500
+ };
1501
+ return this.engine.transaction(runConversion, ctx);
1502
+ }
1503
+ // ==========================================
1504
+ // Metadata Caching
1505
+ // ==========================================
1506
+ async getMetaItemCached(request) {
1507
+ try {
1508
+ const result = await this.getMetaItem({ type: request.type, name: request.name });
1509
+ const item = result?.item;
1011
1510
  if (!item) {
1012
1511
  throw new Error(`Metadata item ${request.type}/${request.name} not found`);
1013
1512
  }
@@ -1199,7 +1698,7 @@ var ObjectStackProtocolImplementation = class {
1199
1698
  };
1200
1699
  }
1201
1700
  async getAnalyticsMeta(request) {
1202
- const objects = SchemaRegistry.listItems("object");
1701
+ const objects = this.engine.registry.listItems("object");
1203
1702
  const cubeFilter = request?.cube;
1204
1703
  const cubes = [];
1205
1704
  for (const obj of objects) {
@@ -1299,71 +1798,192 @@ var ObjectStackProtocolImplementation = class {
1299
1798
  ...request.options
1300
1799
  });
1301
1800
  }
1801
+ /** Normalize plural→singular before consulting the allow-list. */
1802
+ static isOverlayAllowed(type) {
1803
+ const singular = PLURAL_TO_SINGULAR[type] ?? type;
1804
+ return _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(singular) || _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES.has(type);
1805
+ }
1302
1806
  async saveMetaItem(request) {
1303
1807
  if (!request.item) {
1304
1808
  throw new Error("Item data is required");
1305
1809
  }
1306
- SchemaRegistry.registerItem(request.type, request.item, "name");
1810
+ if (this.projectId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
1811
+ const allowed = Array.from(_ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES).join(", ");
1812
+ const err = new Error(
1813
+ `[not_overridable] Metadata type '${request.type}' has not opted into per-org overlay writes. Set allowOrgOverride: true on its DEFAULT_METADATA_TYPE_REGISTRY entry to enable. Currently allowed: ${allowed}. See docs/adr/0005-metadata-customization-overlay.md.`
1814
+ );
1815
+ err.code = "not_overridable";
1816
+ err.status = 403;
1817
+ throw err;
1818
+ }
1819
+ {
1820
+ const schema = resolveOverlaySchema(request.type, request.item);
1821
+ if (schema) {
1822
+ const parsed = schema.safeParse(request.item);
1823
+ if (!parsed.success) {
1824
+ const issues = parsed.error.issues.map((i) => ({
1825
+ path: i.path.join("."),
1826
+ message: i.message,
1827
+ code: i.code
1828
+ }));
1829
+ const summary = issues.slice(0, 3).map((i) => `${i.path || "<root>"}: ${i.message}`).join("; ");
1830
+ const err = new Error(
1831
+ `[invalid_metadata] ${request.type}/${request.name} failed spec validation: ${summary}` + (issues.length > 3 ? ` (+${issues.length - 3} more)` : "")
1832
+ );
1833
+ err.code = "invalid_metadata";
1834
+ err.status = 422;
1835
+ err.issues = issues;
1836
+ throw err;
1837
+ }
1838
+ }
1839
+ }
1840
+ if (request.type === "object" || request.type === "objects") {
1841
+ this.engine.registry.registerItem(request.type, request.item, "name");
1842
+ try {
1843
+ this.engine.registry.registerObject(request.item, "sys_metadata");
1844
+ } catch (err) {
1845
+ console.warn(
1846
+ `[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`
1847
+ );
1848
+ }
1849
+ }
1850
+ await this.ensureOverlayIndex();
1307
1851
  try {
1308
1852
  const now = (/* @__PURE__ */ new Date()).toISOString();
1853
+ const orgId = request.organizationId ?? null;
1854
+ const scopedWhere = {
1855
+ type: request.type,
1856
+ name: request.name,
1857
+ organization_id: orgId,
1858
+ state: "active"
1859
+ };
1309
1860
  const existing = await this.engine.findOne("sys_metadata", {
1310
- where: { type: request.type, name: request.name }
1861
+ where: scopedWhere
1311
1862
  });
1312
1863
  if (existing) {
1313
1864
  await this.engine.update("sys_metadata", {
1314
1865
  metadata: JSON.stringify(request.item),
1315
1866
  updated_at: now,
1316
- version: (existing.version || 0) + 1
1867
+ version: (existing.version || 0) + 1,
1868
+ state: "active"
1317
1869
  }, {
1318
1870
  where: { id: existing.id }
1319
1871
  });
1320
1872
  } else {
1321
1873
  const id = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;
1322
- await this.engine.insert("sys_metadata", {
1874
+ const row = {
1323
1875
  id,
1324
1876
  name: request.name,
1325
1877
  type: request.type,
1878
+ // `scope` enum is ['system','platform','user']; per-org
1879
+ // overlays use 'platform' as the informational tag. The
1880
+ // authoritative isolation key is `organization_id`.
1326
1881
  scope: "platform",
1327
1882
  metadata: JSON.stringify(request.item),
1328
1883
  state: "active",
1329
1884
  version: 1,
1330
1885
  created_at: now,
1331
- updated_at: now
1332
- });
1886
+ updated_at: now,
1887
+ organization_id: orgId
1888
+ };
1889
+ await this.engine.insert("sys_metadata", row);
1333
1890
  }
1334
1891
  return {
1335
1892
  success: true,
1336
- message: "Saved to database and registry"
1893
+ message: orgId ? `Saved customization overlay (org=${orgId}) \u2014 type=${request.type}, name=${request.name}` : `Saved customization overlay (env-wide) \u2014 type=${request.type}, name=${request.name}`
1337
1894
  };
1338
1895
  } catch (dbError) {
1339
- console.warn(`[Protocol] DB persistence failed for ${request.type}/${request.name}: ${dbError.message}`);
1896
+ console.error(
1897
+ `[Protocol] sys_metadata persistence failed for ${request.type}/${request.name}: ${dbError.message}`
1898
+ );
1899
+ const err = new Error(
1900
+ `Failed to persist customization overlay to sys_metadata: ${dbError.message}. In-memory registry was updated but will be lost on restart.`
1901
+ );
1902
+ err.code = "overlay_persistence_failed";
1903
+ err.status = 500;
1904
+ throw err;
1905
+ }
1906
+ }
1907
+ /**
1908
+ * Remove a customization overlay row for the given metadata item, so the
1909
+ * next read falls through to the artifact-loaded default. Implements the
1910
+ * "Reset to factory default" semantic from ADR-0005. Whitelist is shared
1911
+ * with {@link saveMetaItem}.
1912
+ */
1913
+ async deleteMetaItem(request) {
1914
+ if (this.projectId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
1915
+ const err = new Error(
1916
+ `[not_overridable] Metadata type '${request.type}' has not opted into per-org overlay writes. See docs/adr/0005-metadata-customization-overlay.md.`
1917
+ );
1918
+ err.code = "not_overridable";
1919
+ err.status = 403;
1920
+ throw err;
1921
+ }
1922
+ const scopedWhere = {
1923
+ type: request.type,
1924
+ name: request.name,
1925
+ organization_id: request.organizationId ?? null
1926
+ };
1927
+ try {
1928
+ const existing = await this.engine.findOne("sys_metadata", { where: scopedWhere });
1929
+ if (!existing) {
1930
+ return {
1931
+ success: true,
1932
+ reset: false,
1933
+ message: `No customization overlay found for ${request.type}/${request.name} \u2014 already at artifact default.`
1934
+ };
1935
+ }
1936
+ await this.engine.delete("sys_metadata", { where: { id: existing.id } });
1937
+ if (this.projectId === void 0) {
1938
+ try {
1939
+ const services = this.getServicesRegistry?.();
1940
+ const metadataService = services?.get("metadata");
1941
+ if (metadataService && typeof metadataService.get === "function") {
1942
+ const artifactItem = await metadataService.get(request.type, request.name);
1943
+ if (artifactItem !== void 0) {
1944
+ this.engine.registry.registerItem(request.type, artifactItem, "name");
1945
+ }
1946
+ }
1947
+ } catch {
1948
+ }
1949
+ }
1340
1950
  return {
1341
1951
  success: true,
1342
- message: "Saved to memory registry (DB persistence unavailable)",
1343
- warning: dbError.message
1952
+ reset: true,
1953
+ message: `Customization overlay deleted \u2014 ${request.type}/${request.name} reset to artifact default.`
1344
1954
  };
1955
+ } catch (err) {
1956
+ const e = new Error(`Failed to delete customization overlay: ${err.message}`);
1957
+ e.status = 500;
1958
+ throw e;
1345
1959
  }
1346
1960
  }
1347
1961
  /**
1348
1962
  * Hydrate SchemaRegistry from the database on startup.
1349
1963
  * Loads all active metadata records and registers them in the in-memory registry.
1350
1964
  * Safe to call repeatedly — idempotent (latest DB record wins).
1965
+ *
1966
+ * Per ADR-0005, project-kernel mode ALSO hydrates from sys_metadata —
1967
+ * customization overlay rows must survive restart. Scope filter
1968
+ * (`project_id = this.projectId ?? null`) keeps tenants isolated.
1351
1969
  */
1352
1970
  async loadMetaFromDb() {
1353
1971
  let loaded = 0;
1354
1972
  let errors = 0;
1355
1973
  try {
1356
- const records = await this.engine.find("sys_metadata", {
1357
- where: { state: "active" }
1358
- });
1974
+ const where = {
1975
+ state: "active",
1976
+ organization_id: null
1977
+ };
1978
+ const records = await this.engine.find("sys_metadata", { where });
1359
1979
  for (const record of records) {
1360
1980
  try {
1361
1981
  const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
1362
1982
  const normalizedType = PLURAL_TO_SINGULAR[record.type] ?? record.type;
1363
1983
  if (normalizedType === "object") {
1364
- SchemaRegistry.registerObject(data, record.packageId || "sys_metadata");
1984
+ this.engine.registry.registerObject(data, record.packageId || "sys_metadata");
1365
1985
  } else {
1366
- SchemaRegistry.registerItem(normalizedType, data, "name");
1986
+ this.engine.registry.registerItem(normalizedType, data, "name");
1367
1987
  }
1368
1988
  loaded++;
1369
1989
  } catch (e) {
@@ -1372,7 +1992,9 @@ var ObjectStackProtocolImplementation = class {
1372
1992
  }
1373
1993
  }
1374
1994
  } catch (e) {
1375
- console.warn(`[Protocol] DB hydration skipped: ${e.message}`);
1995
+ if (!/no such table/i.test(e.message ?? "")) {
1996
+ console.warn(`[Protocol] DB hydration skipped: ${e.message}`);
1997
+ }
1376
1998
  }
1377
1999
  return { loaded, errors };
1378
2000
  }
@@ -1442,78 +2064,846 @@ var ObjectStackProtocolImplementation = class {
1442
2064
  await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
1443
2065
  return { success: true, data: { feedId: request.feedId, pinned: false } };
1444
2066
  }
1445
- async starFeedItem(request) {
1446
- const svc = this.requireFeedService();
1447
- const item = await svc.getFeedItem(request.feedId);
1448
- if (!item) throw new Error(`Feed item ${request.feedId} not found`);
1449
- await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
1450
- return { success: true, data: { feedId: request.feedId, starred: true, starredAt: (/* @__PURE__ */ new Date()).toISOString() } };
2067
+ async starFeedItem(request) {
2068
+ const svc = this.requireFeedService();
2069
+ const item = await svc.getFeedItem(request.feedId);
2070
+ if (!item) throw new Error(`Feed item ${request.feedId} not found`);
2071
+ await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
2072
+ return { success: true, data: { feedId: request.feedId, starred: true, starredAt: (/* @__PURE__ */ new Date()).toISOString() } };
2073
+ }
2074
+ async unstarFeedItem(request) {
2075
+ const svc = this.requireFeedService();
2076
+ const item = await svc.getFeedItem(request.feedId);
2077
+ if (!item) throw new Error(`Feed item ${request.feedId} not found`);
2078
+ await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
2079
+ return { success: true, data: { feedId: request.feedId, starred: false } };
2080
+ }
2081
+ async searchFeed(request) {
2082
+ const svc = this.requireFeedService();
2083
+ const result = await svc.listFeed({
2084
+ object: request.object,
2085
+ recordId: request.recordId,
2086
+ filter: request.type,
2087
+ limit: request.limit,
2088
+ cursor: request.cursor
2089
+ });
2090
+ const queryLower = (request.query || "").toLowerCase();
2091
+ const filtered = result.items.filter(
2092
+ (item) => item.body?.toLowerCase().includes(queryLower)
2093
+ );
2094
+ return { success: true, data: { items: filtered, total: filtered.length, hasMore: false } };
2095
+ }
2096
+ async getChangelog(request) {
2097
+ const svc = this.requireFeedService();
2098
+ const result = await svc.listFeed({
2099
+ object: request.object,
2100
+ recordId: request.recordId,
2101
+ filter: "changes_only",
2102
+ limit: request.limit,
2103
+ cursor: request.cursor
2104
+ });
2105
+ const entries = result.items.map((item) => ({
2106
+ id: item.id,
2107
+ object: item.object,
2108
+ recordId: item.recordId,
2109
+ actor: item.actor,
2110
+ changes: item.changes || [],
2111
+ timestamp: item.createdAt,
2112
+ source: item.source
2113
+ }));
2114
+ return { success: true, data: { entries, total: result.total, nextCursor: result.nextCursor, hasMore: result.hasMore } };
2115
+ }
2116
+ async feedSubscribe(request) {
2117
+ const svc = this.requireFeedService();
2118
+ const subscription = await svc.subscribe({
2119
+ object: request.object,
2120
+ recordId: request.recordId,
2121
+ userId: "current_user",
2122
+ events: request.events,
2123
+ channels: request.channels
2124
+ });
2125
+ return { success: true, data: subscription };
2126
+ }
2127
+ async feedUnsubscribe(request) {
2128
+ const svc = this.requireFeedService();
2129
+ const unsubscribed = await svc.unsubscribe(request.object, request.recordId, "current_user");
2130
+ return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } };
2131
+ }
2132
+ };
2133
+ /**
2134
+ * Metadata types that are customer-overridable via {@link saveMetaItem}/
2135
+ * {@link deleteMetaItem} in project-kernel mode. Derived from the canonical
2136
+ * registry in {@link DEFAULT_METADATA_TYPE_REGISTRY}: a type opts in by
2137
+ * setting `allowOrgOverride: true` on its registry entry. The set is
2138
+ * augmented with the plural form of every singular so callers using REST
2139
+ * conventions (`/api/v1/meta/views/...`) get the same gate. See ADR-0005
2140
+ * §"Whitelist enforcement" for the rationale and the per-type rollout
2141
+ * checklist.
2142
+ */
2143
+ _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES = (() => {
2144
+ const out = /* @__PURE__ */ new Set();
2145
+ for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) {
2146
+ if (!entry.allowOrgOverride) continue;
2147
+ out.add(entry.type);
2148
+ const plural = SINGULAR_TO_PLURAL[entry.type];
2149
+ if (plural) out.add(plural);
2150
+ }
2151
+ return out;
2152
+ })();
2153
+ var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
2154
+
2155
+ // src/engine.ts
2156
+ import { ExecutionContextSchema } from "@objectstack/spec/kernel";
2157
+ import { createLogger } from "@objectstack/core";
2158
+ import { CoreServiceName, StorageNameMapping } from "@objectstack/spec/system";
2159
+ import { pluralToSingular } from "@objectstack/spec/shared";
2160
+ import { ExpressionEngine as ExpressionEngine2 } from "@objectstack/formula";
2161
+
2162
+ // src/hook-wrappers.ts
2163
+ import { ExpressionEngine } from "@objectstack/formula";
2164
+
2165
+ // src/hook-metrics.ts
2166
+ var noopHookMetricsRecorder = {
2167
+ recordExecution: () => {
2168
+ },
2169
+ recordSkip: () => {
2170
+ },
2171
+ recordRetry: () => {
2172
+ }
2173
+ };
2174
+ var InMemoryHookMetricsRecorder = class {
2175
+ constructor() {
2176
+ this.executions = /* @__PURE__ */ new Map();
2177
+ this.skips = /* @__PURE__ */ new Map();
2178
+ this.retries = /* @__PURE__ */ new Map();
2179
+ }
2180
+ recordExecution(label, outcome, durationMs) {
2181
+ const key = `${label.hook}|${outcome}`;
2182
+ const cur = this.executions.get(key) ?? { count: 0, totalMs: 0 };
2183
+ cur.count += 1;
2184
+ cur.totalMs += Math.max(0, durationMs);
2185
+ this.executions.set(key, cur);
2186
+ }
2187
+ recordSkip(label, reason) {
2188
+ const key = `${label.hook}|${reason}`;
2189
+ this.skips.set(key, (this.skips.get(key) ?? 0) + 1);
2190
+ }
2191
+ recordRetry(label, _attempt) {
2192
+ this.retries.set(label.hook, (this.retries.get(label.hook) ?? 0) + 1);
2193
+ }
2194
+ snapshot() {
2195
+ return {
2196
+ executions: Array.from(this.executions, ([key, v]) => {
2197
+ const [hook, outcome] = key.split("|");
2198
+ return { hook, outcome, count: v.count, totalMs: v.totalMs };
2199
+ }),
2200
+ skips: Array.from(this.skips, ([key, count]) => {
2201
+ const [hook, reason] = key.split("|");
2202
+ return { hook, reason, count };
2203
+ }),
2204
+ retries: Array.from(this.retries, ([hook, count]) => ({ hook, count }))
2205
+ };
2206
+ }
2207
+ reset() {
2208
+ this.executions.clear();
2209
+ this.skips.clear();
2210
+ this.retries.clear();
2211
+ }
2212
+ };
2213
+
2214
+ // src/hook-wrappers.ts
2215
+ var noopLogger = {
2216
+ debug: () => {
2217
+ },
2218
+ info: () => {
2219
+ },
2220
+ warn: () => {
2221
+ },
2222
+ error: () => {
2223
+ }
2224
+ };
2225
+ function wrapDeclarativeHook(meta, handler, opts = {}) {
2226
+ const logger = opts.logger ?? noopLogger;
2227
+ const metrics = opts.metrics ?? noopHookMetricsRecorder;
2228
+ const isAfterEvent = meta.events?.some((e) => typeof e === "string" && e.startsWith("after")) ?? false;
2229
+ const hasBody = Boolean(meta.body);
2230
+ const labelFor = (ctx) => ({
2231
+ hook: meta.name,
2232
+ object: ctx.object ?? (typeof meta.object === "string" ? meta.object : void 0),
2233
+ event: ctx.event,
2234
+ body: hasBody
2235
+ });
2236
+ let conditionFn;
2237
+ if (meta.condition) {
2238
+ const expr = typeof meta.condition === "string" ? { dialect: "cel", source: meta.condition } : meta.condition;
2239
+ if (expr.source && expr.source.trim()) {
2240
+ const check = ExpressionEngine.compile(expr);
2241
+ if (check.ok) {
2242
+ conditionFn = (record) => {
2243
+ const r = ExpressionEngine.evaluate(expr, { record: record ?? {} });
2244
+ if (!r.ok) {
2245
+ logger.warn("[hook] condition evaluation failed; treating as false", {
2246
+ hook: meta.name,
2247
+ condition: expr.source,
2248
+ error: r.error.message
2249
+ });
2250
+ return false;
2251
+ }
2252
+ return Boolean(r.value);
2253
+ };
2254
+ } else {
2255
+ logger.warn("[hook] condition formula failed to compile; condition ignored", {
2256
+ hook: meta.name,
2257
+ condition: expr.source,
2258
+ error: check.error.message
2259
+ });
2260
+ }
2261
+ }
2262
+ }
2263
+ const retryMax = Math.max(0, Number(meta.retryPolicy?.maxRetries ?? 0));
2264
+ const retryBackoffMs = Math.max(0, Number(meta.retryPolicy?.backoffMs ?? 0));
2265
+ const timeoutMs = typeof meta.timeout === "number" && meta.timeout > 0 ? meta.timeout : void 0;
2266
+ const onError = meta.onError ?? "abort";
2267
+ const fireAndForget = Boolean(meta.async) && isAfterEvent;
2268
+ const runWithTimeout = async (ctx) => {
2269
+ if (!timeoutMs) {
2270
+ await handler(ctx);
2271
+ return;
2272
+ }
2273
+ let timer;
2274
+ try {
2275
+ await Promise.race([
2276
+ Promise.resolve().then(() => handler(ctx)),
2277
+ new Promise((_, reject) => {
2278
+ timer = setTimeout(() => {
2279
+ reject(new Error(`Hook '${meta.name}' timed out after ${timeoutMs}ms`));
2280
+ }, timeoutMs);
2281
+ })
2282
+ ]);
2283
+ } finally {
2284
+ if (timer) clearTimeout(timer);
2285
+ }
2286
+ };
2287
+ const runWithRetry = async (ctx) => {
2288
+ let attempt = 0;
2289
+ let lastErr;
2290
+ while (attempt <= retryMax) {
2291
+ try {
2292
+ await runWithTimeout(ctx);
2293
+ return;
2294
+ } catch (err) {
2295
+ lastErr = err;
2296
+ attempt += 1;
2297
+ if (attempt > retryMax) break;
2298
+ if (retryBackoffMs > 0) {
2299
+ await new Promise((r) => setTimeout(r, retryBackoffMs * attempt));
2300
+ }
2301
+ try {
2302
+ metrics.recordRetry(labelFor(ctx), attempt);
2303
+ } catch {
2304
+ }
2305
+ logger.warn("[hook] retrying after failure", {
2306
+ hook: meta.name,
2307
+ attempt,
2308
+ maxRetries: retryMax,
2309
+ error: err?.message
2310
+ });
2311
+ }
2312
+ }
2313
+ throw lastErr;
2314
+ };
2315
+ const runWithErrorPolicy = async (ctx) => {
2316
+ try {
2317
+ await runWithRetry(ctx);
2318
+ } catch (err) {
2319
+ if (onError === "log") {
2320
+ logger.error("[hook] handler failed (onError=log; suppressing)", {
2321
+ hook: meta.name,
2322
+ object: ctx.object,
2323
+ event: ctx.event,
2324
+ error: err?.message
2325
+ });
2326
+ return;
2327
+ }
2328
+ throw err;
2329
+ }
2330
+ };
2331
+ return async (ctx) => {
2332
+ if (conditionFn) {
2333
+ const record = pickRecordPayload(ctx);
2334
+ if (!conditionFn(record)) {
2335
+ logger.debug("[hook] skipped by condition", {
2336
+ hook: meta.name,
2337
+ object: ctx.object,
2338
+ event: ctx.event
2339
+ });
2340
+ try {
2341
+ metrics.recordSkip(labelFor(ctx), "condition");
2342
+ } catch {
2343
+ }
2344
+ return;
2345
+ }
2346
+ }
2347
+ const restore = installFlatInput(ctx);
2348
+ const startedAt = Date.now();
2349
+ const recordOutcome = (err) => {
2350
+ const elapsed = Date.now() - startedAt;
2351
+ let outcome = "success";
2352
+ if (err) {
2353
+ const msg = String(err?.message ?? err ?? "");
2354
+ if (/timed out after/i.test(msg)) outcome = "timeout";
2355
+ else if (/capability|cap-rejection|capability_rejected/i.test(msg)) outcome = "capability_rejected";
2356
+ else outcome = "error";
2357
+ }
2358
+ try {
2359
+ metrics.recordExecution(labelFor(ctx), outcome, elapsed);
2360
+ } catch {
2361
+ }
2362
+ };
2363
+ try {
2364
+ if (fireAndForget) {
2365
+ try {
2366
+ metrics.recordSkip(labelFor(ctx), "fire_and_forget");
2367
+ } catch {
2368
+ }
2369
+ void runWithErrorPolicy(ctx).then(() => recordOutcome()).catch((err) => {
2370
+ recordOutcome(err);
2371
+ logger.error("[hook] async handler error (fire-and-forget)", {
2372
+ hook: meta.name,
2373
+ error: err?.message
2374
+ });
2375
+ });
2376
+ return;
2377
+ }
2378
+ try {
2379
+ await runWithErrorPolicy(ctx);
2380
+ recordOutcome();
2381
+ } catch (err) {
2382
+ recordOutcome(err);
2383
+ throw err;
2384
+ }
2385
+ } finally {
2386
+ restore();
2387
+ }
2388
+ };
2389
+ }
2390
+ function installFlatInput(ctx) {
2391
+ const raw = ctx.input ?? {};
2392
+ const looksWrapped = raw && typeof raw === "object" && ("data" in raw || "options" in raw || "id" in raw || "ast" in raw);
2393
+ if (!looksWrapped) return () => {
2394
+ };
2395
+ const ensureData = () => {
2396
+ if (!raw.data || typeof raw.data !== "object") {
2397
+ raw.data = {};
2398
+ }
2399
+ return raw.data;
2400
+ };
2401
+ const proxy = new Proxy(raw, {
2402
+ get(target, prop, receiver) {
2403
+ if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
2404
+ return Reflect.get(target, prop, receiver);
2405
+ }
2406
+ const data = target.data;
2407
+ if (data && typeof data === "object" && prop in data) {
2408
+ return data[prop];
2409
+ }
2410
+ return Reflect.get(target, prop, receiver);
2411
+ },
2412
+ set(target, prop, value) {
2413
+ if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
2414
+ target[prop] = value;
2415
+ return true;
2416
+ }
2417
+ ensureData()[prop] = value;
2418
+ return true;
2419
+ },
2420
+ has(target, prop) {
2421
+ if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
2422
+ return prop in target;
2423
+ }
2424
+ const data = target.data;
2425
+ if (data && typeof data === "object" && prop in data) return true;
2426
+ return prop in target;
2427
+ },
2428
+ ownKeys(target) {
2429
+ const dataKeys = target.data && typeof target.data === "object" ? Object.keys(target.data) : [];
2430
+ return Array.from(new Set(dataKeys));
2431
+ },
2432
+ getOwnPropertyDescriptor(target, prop) {
2433
+ const data = target.data;
2434
+ if (data && typeof data === "object" && prop in data) {
2435
+ return { configurable: true, enumerable: true, writable: true, value: data[prop] };
2436
+ }
2437
+ if (prop === "id" || prop === "options" || prop === "ast" || prop === "data") {
2438
+ const desc = Object.getOwnPropertyDescriptor(target, prop);
2439
+ return desc ? { ...desc, enumerable: false } : void 0;
2440
+ }
2441
+ return Object.getOwnPropertyDescriptor(target, prop);
2442
+ }
2443
+ });
2444
+ ctx.input = proxy;
2445
+ return () => {
2446
+ ctx.input = raw;
2447
+ };
2448
+ }
2449
+ function pickRecordPayload(ctx) {
2450
+ const input = ctx.input ?? {};
2451
+ if (input && typeof input === "object" && input.data && typeof input.data === "object") {
2452
+ return input.data;
2453
+ }
2454
+ if (ctx.previous && typeof ctx.previous === "object") {
2455
+ return ctx.previous;
2456
+ }
2457
+ return input;
2458
+ }
2459
+
2460
+ // src/hook-binder.ts
2461
+ var noopLogger2 = {
2462
+ debug: () => {
2463
+ },
2464
+ info: () => {
2465
+ },
2466
+ warn: () => {
2467
+ },
2468
+ error: () => {
2469
+ }
2470
+ };
2471
+ function bindHooksToEngine(engine, hooks, opts = {}) {
2472
+ const logger = opts.logger ?? noopLogger2;
2473
+ const result = { registered: 0, skipped: 0, errors: [] };
2474
+ if (!Array.isArray(hooks) || hooks.length === 0) {
2475
+ return result;
2476
+ }
2477
+ if (opts.packageId && typeof engine.unregisterHooksByPackage === "function") {
2478
+ try {
2479
+ engine.unregisterHooksByPackage(opts.packageId);
2480
+ } catch (err) {
2481
+ logger.warn("[hook-binder] unregister-by-package failed; continuing", {
2482
+ packageId: opts.packageId,
2483
+ error: err?.message
2484
+ });
2485
+ }
2486
+ }
2487
+ if (opts.functions && typeof engine.registerFunction === "function") {
2488
+ for (const [name, fn] of Object.entries(opts.functions)) {
2489
+ try {
2490
+ engine.registerFunction(name, fn, opts.packageId);
2491
+ } catch (err) {
2492
+ logger.warn("[hook-binder] failed to register function", {
2493
+ name,
2494
+ error: err?.message
2495
+ });
2496
+ }
2497
+ }
2498
+ }
2499
+ for (const hook of hooks) {
2500
+ try {
2501
+ const resolved = resolveHandler(engine, hook, opts);
2502
+ if (!resolved) {
2503
+ result.skipped += 1;
2504
+ const reason = hook.body ? `hook body present but no bodyRunner supplied to bindHooksToEngine (runtime must wire QuickJSScriptRunner)` : typeof hook.handler === "string" ? `unknown function '${hook.handler}'` : "no handler";
2505
+ result.errors.push({ hook: hook.name, reason });
2506
+ if (opts.strict) {
2507
+ throw new Error(`[hook-binder] strict: cannot bind hook '${hook.name}': ${reason}`);
2508
+ }
2509
+ logger.warn("[hook-binder] skipping hook with unresolved handler", {
2510
+ hook: hook.name,
2511
+ handler: hook.handler,
2512
+ hasBody: Boolean(hook.body)
2513
+ });
2514
+ continue;
2515
+ }
2516
+ if (opts.warnLegacyHandler && !hook.body && typeof hook.handler === "string") {
2517
+ logger.warn("[hook-binder] DEPRECATED: hook uses legacy handler ref without body", {
2518
+ hook: hook.name,
2519
+ handler: hook.handler,
2520
+ hint: "Move the handler source into Hook.body so the artifact stays metadata-only and the .mjs runtime bundle can be dropped."
2521
+ });
2522
+ }
2523
+ const wrapped = wrapDeclarativeHook(hook, resolved, { logger, metrics: opts.metrics });
2524
+ const objects = normalizeObjects(hook.object);
2525
+ const events = Array.isArray(hook.events) ? hook.events : [];
2526
+ for (const event of events) {
2527
+ for (const object of objects) {
2528
+ engine.registerHook(event, wrapped, {
2529
+ object,
2530
+ priority: typeof hook.priority === "number" ? hook.priority : 100,
2531
+ packageId: opts.packageId,
2532
+ // Reflect metadata so future tooling can introspect / unregister
2533
+ // and so we can detect duplicate name collisions.
2534
+ // The engine ignores unknown options today; this is forward-only.
2535
+ ...{ meta: hook, hookName: hook.name }
2536
+ });
2537
+ result.registered += 1;
2538
+ }
2539
+ }
2540
+ } catch (err) {
2541
+ result.errors.push({ hook: hook.name, reason: err?.message ?? String(err) });
2542
+ logger.error("[hook-binder] failed to bind hook", {
2543
+ hook: hook.name,
2544
+ error: err?.message
2545
+ });
2546
+ }
2547
+ }
2548
+ if (result.registered > 0) {
2549
+ logger.debug("[hook-binder] hooks bound", {
2550
+ packageId: opts.packageId,
2551
+ registered: result.registered,
2552
+ skipped: result.skipped
2553
+ });
2554
+ }
2555
+ return result;
2556
+ }
2557
+ function normalizeObjects(target) {
2558
+ if (Array.isArray(target)) return target.length > 0 ? target : ["*"];
2559
+ if (typeof target === "string" && target.length > 0) return [target];
2560
+ return ["*"];
2561
+ }
2562
+ function resolveHandler(engine, hook, opts) {
2563
+ const body = hook.body;
2564
+ if (body && typeof body === "object") {
2565
+ let runner = opts.bodyRunner;
2566
+ if (typeof runner !== "function") {
2567
+ const fallback = engine?._defaultBodyRunner;
2568
+ if (typeof fallback === "function") runner = fallback;
2569
+ }
2570
+ if (typeof runner !== "function") {
2571
+ return void 0;
2572
+ }
2573
+ const fn = runner(hook);
2574
+ if (typeof fn === "function") return fn;
2575
+ return void 0;
2576
+ }
2577
+ const h = hook.handler;
2578
+ if (typeof h === "function") return h;
2579
+ if (typeof h === "string" && h.length > 0) {
2580
+ const fromBundle = opts.functions?.[h];
2581
+ if (typeof fromBundle === "function") return fromBundle;
2582
+ if (typeof engine.resolveFunction === "function") {
2583
+ const fn = engine.resolveFunction(h);
2584
+ if (typeof fn === "function") return fn;
2585
+ }
2586
+ }
2587
+ return void 0;
2588
+ }
2589
+
2590
+ // src/validation/record-validator.ts
2591
+ var SKIP_FIELDS = /* @__PURE__ */ new Set([
2592
+ "id",
2593
+ "created_at",
2594
+ "created_by",
2595
+ "updated_at",
2596
+ "updated_by",
2597
+ "organization_id",
2598
+ "tenant_id"
2599
+ ]);
2600
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2601
+ var URL_RE = /^[a-z][a-z0-9+.\-]*:\/\/[^\s]+$/i;
2602
+ var PHONE_RE = /^[+()\-\s\d.]{5,}$/;
2603
+ var ValidationError = class extends Error {
2604
+ constructor(fields) {
2605
+ super(
2606
+ `Validation failed for ${fields.length} field(s): ` + fields.map((f) => `${f.field} (${f.code})`).join(", ")
2607
+ );
2608
+ this.code = "VALIDATION_FAILED";
2609
+ this.name = "ValidationError";
2610
+ this.fields = fields;
2611
+ }
2612
+ };
2613
+ function isMissing(v) {
2614
+ return v === void 0 || v === null || typeof v === "string" && v.trim() === "";
2615
+ }
2616
+ function optionValues(options) {
2617
+ if (!Array.isArray(options)) return [];
2618
+ return options.map(
2619
+ (o) => typeof o === "object" && o !== null ? String(o.value) : String(o)
2620
+ );
2621
+ }
2622
+ function validateOne(name, def, value) {
2623
+ if (def.required && isMissing(value)) {
2624
+ return { field: name, code: "required", message: `${name} is required` };
2625
+ }
2626
+ if (isMissing(value)) return null;
2627
+ const t = def.type;
2628
+ if (t === "text" || t === "textarea" || t === "email" || t === "url" || t === "phone" || t === "password" || t === "markdown" || t === "html" || t === "richtext" || t === "code") {
2629
+ const s = typeof value === "string" ? value : String(value);
2630
+ if (def.maxLength !== void 0 && s.length > def.maxLength) {
2631
+ return { field: name, code: "max_length", message: `${name} must be \u2264 ${def.maxLength} characters (got ${s.length})` };
2632
+ }
2633
+ if (def.minLength !== void 0 && s.length < def.minLength) {
2634
+ return { field: name, code: "min_length", message: `${name} must be \u2265 ${def.minLength} characters (got ${s.length})` };
2635
+ }
2636
+ if (t === "email" && !EMAIL_RE.test(s)) {
2637
+ return { field: name, code: "invalid_email", message: `${name} must be a valid email address` };
2638
+ }
2639
+ if (t === "url" && !URL_RE.test(s)) {
2640
+ return { field: name, code: "invalid_url", message: `${name} must be a valid URL (scheme://...)` };
2641
+ }
2642
+ if (t === "phone" && !PHONE_RE.test(s)) {
2643
+ return { field: name, code: "invalid_phone", message: `${name} must be a valid phone number` };
2644
+ }
2645
+ return null;
2646
+ }
2647
+ if (t === "number" || t === "currency" || t === "percent" || t === "rating" || t === "slider") {
2648
+ const n = typeof value === "number" ? value : Number(value);
2649
+ if (!Number.isFinite(n)) {
2650
+ return { field: name, code: "invalid_number", message: `${name} must be a number` };
2651
+ }
2652
+ if (def.min !== void 0 && n < def.min) {
2653
+ return { field: name, code: "min_value", message: `${name} must be \u2265 ${def.min}` };
2654
+ }
2655
+ if (def.max !== void 0 && n > def.max) {
2656
+ return { field: name, code: "max_value", message: `${name} must be \u2264 ${def.max}` };
2657
+ }
2658
+ return null;
2659
+ }
2660
+ if (t === "boolean" || t === "toggle") {
2661
+ if (typeof value === "boolean") return null;
2662
+ if (value === 0 || value === 1 || value === "0" || value === "1" || value === "true" || value === "false") return null;
2663
+ return { field: name, code: "invalid_boolean", message: `${name} must be true or false` };
1451
2664
  }
1452
- async unstarFeedItem(request) {
1453
- const svc = this.requireFeedService();
1454
- const item = await svc.getFeedItem(request.feedId);
1455
- if (!item) throw new Error(`Feed item ${request.feedId} not found`);
1456
- await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
1457
- return { success: true, data: { feedId: request.feedId, starred: false } };
2665
+ if (t === "date" || t === "datetime" || t === "time") {
2666
+ if (value instanceof Date) return null;
2667
+ if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return null;
2668
+ return { field: name, code: "invalid_date", message: `${name} must be a valid ${t} (ISO-8601)` };
1458
2669
  }
1459
- async searchFeed(request) {
1460
- const svc = this.requireFeedService();
1461
- const result = await svc.listFeed({
1462
- object: request.object,
1463
- recordId: request.recordId,
1464
- filter: request.type,
1465
- limit: request.limit,
1466
- cursor: request.cursor
1467
- });
1468
- const queryLower = (request.query || "").toLowerCase();
1469
- const filtered = result.items.filter(
1470
- (item) => item.body?.toLowerCase().includes(queryLower)
1471
- );
1472
- return { success: true, data: { items: filtered, total: filtered.length, hasMore: false } };
2670
+ if (t === "select" || t === "radio") {
2671
+ const allowed = optionValues(def.options);
2672
+ if (allowed.length > 0 && !allowed.includes(String(value))) {
2673
+ return { field: name, code: "invalid_option", message: `${name} must be one of: ${allowed.join(", ")}`, options: allowed };
2674
+ }
2675
+ return null;
1473
2676
  }
1474
- async getChangelog(request) {
1475
- const svc = this.requireFeedService();
1476
- const result = await svc.listFeed({
1477
- object: request.object,
1478
- recordId: request.recordId,
1479
- filter: "changes_only",
1480
- limit: request.limit,
1481
- cursor: request.cursor
1482
- });
1483
- const entries = result.items.map((item) => ({
1484
- id: item.id,
1485
- object: item.object,
1486
- recordId: item.recordId,
1487
- actor: item.actor,
1488
- changes: item.changes || [],
1489
- timestamp: item.createdAt,
1490
- source: item.source
1491
- }));
1492
- return { success: true, data: { entries, total: result.total, nextCursor: result.nextCursor, hasMore: result.hasMore } };
2677
+ if (t === "multiselect" || t === "checkboxes" || t === "tags") {
2678
+ const allowed = optionValues(def.options);
2679
+ if (allowed.length === 0) return null;
2680
+ const arr = Array.isArray(value) ? value : [value];
2681
+ for (const v of arr) {
2682
+ if (!allowed.includes(String(v))) {
2683
+ return { field: name, code: "invalid_option", message: `${name}: "${v}" is not one of: ${allowed.join(", ")}`, options: allowed };
2684
+ }
2685
+ }
2686
+ return null;
1493
2687
  }
1494
- async feedSubscribe(request) {
1495
- const svc = this.requireFeedService();
1496
- const subscription = await svc.subscribe({
1497
- object: request.object,
1498
- recordId: request.recordId,
1499
- userId: "current_user",
1500
- events: request.events,
1501
- channels: request.channels
1502
- });
1503
- return { success: true, data: subscription };
2688
+ return null;
2689
+ }
2690
+ function validateRecord(objectSchema, data, mode) {
2691
+ if (!objectSchema?.fields || !data) return;
2692
+ const errors = [];
2693
+ const fields = objectSchema.fields;
2694
+ if (mode === "insert") {
2695
+ for (const [name, def] of Object.entries(fields)) {
2696
+ if (SKIP_FIELDS.has(name)) continue;
2697
+ if (def.system || def.readonly) continue;
2698
+ const err = validateOne(name, def, data[name]);
2699
+ if (err) errors.push(err);
2700
+ }
2701
+ } else {
2702
+ for (const [name, value] of Object.entries(data)) {
2703
+ if (SKIP_FIELDS.has(name)) continue;
2704
+ const def = fields[name];
2705
+ if (!def) continue;
2706
+ if (def.system || def.readonly) continue;
2707
+ const err = validateOne(name, { ...def, required: false }, value);
2708
+ if (err) errors.push(err);
2709
+ }
2710
+ }
2711
+ if (errors.length > 0) throw new ValidationError(errors);
2712
+ }
2713
+
2714
+ // src/in-memory-aggregation.ts
2715
+ function applyInMemoryAggregation(rows, ast) {
2716
+ const groupBy = ast.groupBy ?? [];
2717
+ const aggregations = ast.aggregations ?? [];
2718
+ if (groupBy.length === 0 && aggregations.length === 0) return rows;
2719
+ if (groupBy.length === 0) {
2720
+ return [aggregateBucket(rows, aggregations)];
2721
+ }
2722
+ const buckets = /* @__PURE__ */ new Map();
2723
+ for (const row of rows) {
2724
+ const key = {};
2725
+ const parts = [];
2726
+ for (const g of groupBy) {
2727
+ const fieldName = typeof g === "string" ? g : g.alias ?? g.field;
2728
+ const value = projectGroupValue(row, g);
2729
+ key[fieldName] = value;
2730
+ parts.push(`${fieldName}=${value}`);
2731
+ }
2732
+ const id = parts.join("");
2733
+ let bucket = buckets.get(id);
2734
+ if (!bucket) {
2735
+ bucket = { key, rows: [] };
2736
+ buckets.set(id, bucket);
2737
+ }
2738
+ bucket.rows.push(row);
2739
+ }
2740
+ const out = [];
2741
+ for (const { key, rows: bucketRows } of buckets.values()) {
2742
+ const aggValues = aggregateBucket(bucketRows, aggregations);
2743
+ out.push({ ...key, ...aggValues });
2744
+ }
2745
+ return out;
2746
+ }
2747
+ function projectGroupValue(row, g) {
2748
+ const field = typeof g === "string" ? g : g.field;
2749
+ const v = row?.[field];
2750
+ if (typeof g !== "string" && g.dateGranularity) {
2751
+ return bucketDateValue(v, g.dateGranularity);
1504
2752
  }
1505
- async feedUnsubscribe(request) {
1506
- const svc = this.requireFeedService();
1507
- const unsubscribed = await svc.unsubscribe(request.object, request.recordId, "current_user");
1508
- return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } };
2753
+ return v == null ? "(null)" : String(v);
2754
+ }
2755
+ function aggregateBucket(rows, aggregations) {
2756
+ const out = {};
2757
+ for (const agg of aggregations) {
2758
+ const alias = agg.alias;
2759
+ const fn = agg.function;
2760
+ if (fn === "count") {
2761
+ if (!agg.field) {
2762
+ out[alias] = rows.length;
2763
+ } else {
2764
+ out[alias] = rows.reduce(
2765
+ (acc, r) => r[agg.field] != null ? acc + 1 : acc,
2766
+ 0
2767
+ );
2768
+ }
2769
+ continue;
2770
+ }
2771
+ const field = agg.field;
2772
+ if (!field) {
2773
+ out[alias] = null;
2774
+ continue;
2775
+ }
2776
+ const values = collectValues(rows, field, !!agg.distinct);
2777
+ switch (fn) {
2778
+ case "count_distinct":
2779
+ out[alias] = new Set(values.filter((v) => v != null)).size;
2780
+ break;
2781
+ case "sum":
2782
+ out[alias] = values.reduce((a, b) => a + toNumber(b), 0);
2783
+ break;
2784
+ case "avg": {
2785
+ const nums = values.filter((v) => v != null).map(toNumber);
2786
+ out[alias] = nums.length === 0 ? null : nums.reduce((a, b) => a + b, 0) / nums.length;
2787
+ break;
2788
+ }
2789
+ case "min": {
2790
+ const defined = values.filter((v) => v != null);
2791
+ out[alias] = defined.length === 0 ? null : defined.reduce((a, b) => a < b ? a : b);
2792
+ break;
2793
+ }
2794
+ case "max": {
2795
+ const defined = values.filter((v) => v != null);
2796
+ out[alias] = defined.length === 0 ? null : defined.reduce((a, b) => a > b ? a : b);
2797
+ break;
2798
+ }
2799
+ case "array_agg":
2800
+ out[alias] = values.slice();
2801
+ break;
2802
+ case "string_agg":
2803
+ out[alias] = values.filter((v) => v != null).map(String).join(",");
2804
+ break;
2805
+ default:
2806
+ out[alias] = null;
2807
+ }
1509
2808
  }
1510
- };
2809
+ return out;
2810
+ }
2811
+ function collectValues(rows, field, distinct) {
2812
+ if (!distinct) return rows.map((r) => r?.[field]);
2813
+ const seen = /* @__PURE__ */ new Set();
2814
+ const out = [];
2815
+ for (const r of rows) {
2816
+ const v = r?.[field];
2817
+ if (seen.has(v)) continue;
2818
+ seen.add(v);
2819
+ out.push(v);
2820
+ }
2821
+ return out;
2822
+ }
2823
+ function toNumber(v) {
2824
+ if (typeof v === "number") return v;
2825
+ if (v == null) return 0;
2826
+ const n = Number(v);
2827
+ return Number.isFinite(n) ? n : 0;
2828
+ }
2829
+ function bucketDateValue(value, granularity) {
2830
+ if (value == null) return "(null)";
2831
+ const d = value instanceof Date ? value : new Date(String(value));
2832
+ if (Number.isNaN(d.getTime())) return "(null)";
2833
+ const y = d.getUTCFullYear();
2834
+ const m = d.getUTCMonth() + 1;
2835
+ switch (granularity) {
2836
+ case "year":
2837
+ return String(y);
2838
+ case "quarter":
2839
+ return `${y}-Q${Math.floor((m - 1) / 3) + 1}`;
2840
+ case "month":
2841
+ return `${y}-${String(m).padStart(2, "0")}`;
2842
+ case "day":
2843
+ return `${y}-${String(m).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
2844
+ case "week": {
2845
+ const target = new Date(Date.UTC(y, d.getUTCMonth(), d.getUTCDate()));
2846
+ const dayNum = (target.getUTCDay() + 6) % 7;
2847
+ target.setUTCDate(target.getUTCDate() - dayNum + 3);
2848
+ const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
2849
+ const weekNo = 1 + Math.round(
2850
+ ((target.getTime() - firstThursday.getTime()) / 864e5 - 3 + (firstThursday.getUTCDay() + 6) % 7) / 7
2851
+ );
2852
+ return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
2853
+ }
2854
+ default:
2855
+ return String(value);
2856
+ }
2857
+ }
1511
2858
 
1512
2859
  // src/engine.ts
1513
- import { ExecutionContextSchema } from "@objectstack/spec/kernel";
1514
- import { createLogger } from "@objectstack/core";
1515
- import { CoreServiceName } from "@objectstack/spec/system";
1516
- import { pluralToSingular } from "@objectstack/spec/shared";
2860
+ function planFormulaProjection(schema, requestedFields) {
2861
+ if (!schema?.fields) return { plan: [] };
2862
+ const allFieldNames = Object.keys(schema.fields);
2863
+ const targets = Array.isArray(requestedFields) && requestedFields.length > 0 ? requestedFields : allFieldNames;
2864
+ const plan = [];
2865
+ const projected = /* @__PURE__ */ new Set();
2866
+ for (const f of targets) {
2867
+ const def = schema.fields[f];
2868
+ if (def?.type === "formula" && def.expression) {
2869
+ const expr = typeof def.expression === "string" ? { dialect: "cel", source: def.expression } : def.expression;
2870
+ plan.push({ name: f, expression: expr });
2871
+ ExpressionEngine2.compile(expr);
2872
+ } else if (Array.isArray(requestedFields) && requestedFields.length > 0) {
2873
+ projected.add(f);
2874
+ }
2875
+ }
2876
+ if (plan.length === 0) return { plan: [] };
2877
+ if (Array.isArray(requestedFields) && requestedFields.length > 0) {
2878
+ if (!projected.has("id")) projected.add("id");
2879
+ for (const fname of allFieldNames) {
2880
+ const fdef = schema.fields[fname];
2881
+ if (fdef?.type === "formula") continue;
2882
+ projected.add(fname);
2883
+ }
2884
+ return { plan, projected: Array.from(projected) };
2885
+ }
2886
+ return { plan };
2887
+ }
2888
+ function applyFormulaPlan(plan, records) {
2889
+ if (!plan.length) return;
2890
+ for (const rec of records) {
2891
+ if (rec == null) continue;
2892
+ for (const fp of plan) {
2893
+ const r = ExpressionEngine2.evaluate(fp.expression, { record: rec });
2894
+ rec[fp.name] = r.ok ? r.value : null;
2895
+ }
2896
+ }
2897
+ }
2898
+ function resolveMetadataItemName(key, item) {
2899
+ if (!item) return void 0;
2900
+ if (item.name) return item.name;
2901
+ if (item.id) return item.id;
2902
+ if (key === "views") {
2903
+ return item?.list?.data?.object || item?.form?.data?.object || void 0;
2904
+ }
2905
+ return void 0;
2906
+ }
1517
2907
  var _ObjectQL = class _ObjectQL {
1518
2908
  constructor(hostContext = {}) {
1519
2909
  this.drivers = /* @__PURE__ */ new Map();
@@ -1537,10 +2927,29 @@ var _ObjectQL = class _ObjectQL {
1537
2927
  this.middlewares = [];
1538
2928
  // Action registry: key = "objectName:actionName"
1539
2929
  this.actions = /* @__PURE__ */ new Map();
2930
+ // Function registry: name → handler. Used by `bindHooksToEngine` to
2931
+ // resolve string-named hook handlers (the JSON-safe form). Populated by
2932
+ // `defineStack({ functions })` via `AppPlugin`, or directly via
2933
+ // `engine.registerFunction(...)`.
2934
+ this.functions = /* @__PURE__ */ new Map();
1540
2935
  // Host provided context additions (e.g. Server router)
1541
2936
  this.hostContext = {};
2937
+ // Per-engine SchemaRegistry instance.
2938
+ //
2939
+ // Historically SchemaRegistry was a process-wide singleton of static state,
2940
+ // which broke multi-project servers: a project kernel would inherit every
2941
+ // object registered by the control plane (e.g. sys_metadata), and
2942
+ // getDriver()'s owner lookup would route CRUD to the wrong database. Each
2943
+ // engine now owns its registry so kernels are fully isolated.
2944
+ this._registry = new SchemaRegistry();
1542
2945
  this.hostContext = hostContext;
1543
2946
  this.logger = hostContext.logger || createLogger({ level: "info", format: "pretty" });
2947
+ if (process?.env?.OBJECTQL_STRICT_HOOKS === "1") {
2948
+ this._strictHookBinding = true;
2949
+ }
2950
+ if (process?.env?.OBJECTQL_WARN_LEGACY_HANDLER === "1") {
2951
+ this._warnLegacyHandler = true;
2952
+ }
1544
2953
  this.logger.info("ObjectQL Engine Instance Created");
1545
2954
  }
1546
2955
  /**
@@ -1556,10 +2965,13 @@ var _ObjectQL = class _ObjectQL {
1556
2965
  };
1557
2966
  }
1558
2967
  /**
1559
- * Expose the SchemaRegistry for plugins to register metadata
2968
+ * Expose the SchemaRegistry for plugins to register metadata.
2969
+ *
2970
+ * Returns the per-engine instance, NOT the class. Each ObjectQL engine
2971
+ * owns its registry so multi-project kernels remain isolated.
1560
2972
  */
1561
2973
  get registry() {
1562
- return SchemaRegistry;
2974
+ return this._registry;
1563
2975
  }
1564
2976
  /**
1565
2977
  * Load and Register a Plugin
@@ -1605,11 +3017,121 @@ var _ObjectQL = class _ObjectQL {
1605
3017
  handler,
1606
3018
  object: options?.object,
1607
3019
  priority: options?.priority ?? 100,
1608
- packageId: options?.packageId
3020
+ packageId: options?.packageId,
3021
+ meta: options?.meta,
3022
+ hookName: options?.hookName
1609
3023
  });
1610
3024
  entries.sort((a, b) => a.priority - b.priority);
1611
3025
  this.logger.debug("Registered hook", { event, object: options?.object, priority: options?.priority ?? 100, totalHandlers: entries.length });
1612
3026
  }
3027
+ /**
3028
+ * Remove all hooks registered under a given `packageId`. Used by
3029
+ * `bindHooksToEngine` to make re-binding (hot reload, app reinstall)
3030
+ * idempotent, and by app uninstall flows.
3031
+ */
3032
+ unregisterHooksByPackage(packageId) {
3033
+ if (!packageId) return 0;
3034
+ let removed = 0;
3035
+ for (const [event, entries] of this.hooks.entries()) {
3036
+ const before = entries.length;
3037
+ const kept = entries.filter((e) => e.packageId !== packageId);
3038
+ if (kept.length !== before) {
3039
+ this.hooks.set(event, kept);
3040
+ removed += before - kept.length;
3041
+ }
3042
+ }
3043
+ if (removed > 0) {
3044
+ this.logger.debug("Unregistered hooks by package", { packageId, removed });
3045
+ }
3046
+ return removed;
3047
+ }
3048
+ /**
3049
+ * Register a named function handler that can later be referenced by
3050
+ * string from a `Hook.handler` field. This is the JSON-safe form of
3051
+ * handler binding — declarative metadata persisted to disk or shipped
3052
+ * over the wire only carries the name.
3053
+ */
3054
+ registerFunction(name, handler, packageId) {
3055
+ if (!name || typeof handler !== "function") return;
3056
+ this.functions.set(name, { handler, packageId });
3057
+ this.logger.debug("Registered function", { name, packageId });
3058
+ }
3059
+ /** Look up a registered function by name. */
3060
+ resolveFunction(name) {
3061
+ return this.functions.get(name)?.handler;
3062
+ }
3063
+ /** Remove all functions registered under a given `packageId`. */
3064
+ unregisterFunctionsByPackage(packageId) {
3065
+ if (!packageId) return 0;
3066
+ let removed = 0;
3067
+ for (const [name, entry] of this.functions.entries()) {
3068
+ if (entry.packageId === packageId) {
3069
+ this.functions.delete(name);
3070
+ removed += 1;
3071
+ }
3072
+ }
3073
+ if (removed > 0) {
3074
+ this.logger.debug("Unregistered functions by package", { packageId, removed });
3075
+ }
3076
+ return removed;
3077
+ }
3078
+ /**
3079
+ * Bind a list of declarative `Hook` metadata definitions to this engine.
3080
+ *
3081
+ * Convenience proxy to the canonical `bindHooksToEngine` so callers do
3082
+ * not need a separate import. Use `import { bindHooksToEngine } from
3083
+ * '@objectstack/objectql'` directly when you want the result object.
3084
+ */
3085
+ bindHooks(hooks, opts) {
3086
+ const merged = { ...opts ?? {}, logger: this.logger };
3087
+ if (!merged.bodyRunner && this._defaultBodyRunner) {
3088
+ merged.bodyRunner = this._defaultBodyRunner;
3089
+ }
3090
+ if (merged.strict === void 0 && this._strictHookBinding) {
3091
+ merged.strict = true;
3092
+ }
3093
+ if (merged.warnLegacyHandler === void 0 && this._warnLegacyHandler) {
3094
+ merged.warnLegacyHandler = true;
3095
+ }
3096
+ if (!merged.metrics && this._hookMetricsRecorder) {
3097
+ merged.metrics = this._hookMetricsRecorder;
3098
+ }
3099
+ bindHooksToEngine(this, hooks, merged);
3100
+ }
3101
+ /**
3102
+ * Install a default body-runner used when `bindHooks` is called without
3103
+ * an explicit one. The runtime layer sets this once on each per-project
3104
+ * engine so every binding path (template seed, metadata sync, AppPlugin)
3105
+ * can execute hook `body.source` consistently.
3106
+ */
3107
+ setDefaultBodyRunner(runner) {
3108
+ this._defaultBodyRunner = runner;
3109
+ }
3110
+ /**
3111
+ * Toggle strict hook-binding mode for this engine. When enabled, every
3112
+ * subsequent `bindHooks` call rejects on the first unresolved hook
3113
+ * instead of silently warning. Production runtimes should enable this.
3114
+ */
3115
+ setStrictHookBinding(strict) {
3116
+ this._strictHookBinding = strict;
3117
+ }
3118
+ /** Toggle deprecation warnings for hooks still using legacy `handler` ref. */
3119
+ setWarnLegacyHandler(warn) {
3120
+ this._warnLegacyHandler = warn;
3121
+ }
3122
+ /**
3123
+ * Install a metrics recorder used by every subsequent `bindHooks` call.
3124
+ * The recorder's methods are invoked per-execution to count outcomes
3125
+ * (success / error / timeout / capability_rejected), skips, and retries.
3126
+ * Defaults to no-op so the engine pays zero cost when nobody is observing.
3127
+ */
3128
+ setHookMetricsRecorder(recorder) {
3129
+ this._hookMetricsRecorder = recorder;
3130
+ }
3131
+ /** Read the engine's installed metrics recorder, if any. */
3132
+ getHookMetricsRecorder() {
3133
+ return this._hookMetricsRecorder;
3134
+ }
1613
3135
  async triggerHooks(event, context) {
1614
3136
  const entries = this.hooks.get(event) || [];
1615
3137
  if (entries.length === 0) {
@@ -1702,9 +3224,98 @@ var _ObjectQL = class _ObjectQL {
1702
3224
  userId: execCtx.userId,
1703
3225
  tenantId: execCtx.tenantId,
1704
3226
  roles: execCtx.roles,
1705
- accessToken: execCtx.accessToken
3227
+ accessToken: execCtx.accessToken,
3228
+ // Propagate system-elevated flag so hooks can distinguish engine
3229
+ // self-writes (e.g. approval status mirror) from genuine user writes.
3230
+ ...execCtx.isSystem ? { isSystem: true } : {}
1706
3231
  };
1707
3232
  }
3233
+ /**
3234
+ * Build the DriverOptions blob passed to every IDataDriver call.
3235
+ *
3236
+ * Always carries `tenantId` from the active ExecutionContext so the
3237
+ * driver can enforce per-tenant isolation (SQL driver auto-scopes reads
3238
+ * and auto-injects the tenant column on writes). Existing user-supplied
3239
+ * shapes (transactions, AST extras) are preserved by spreading them
3240
+ * first.
3241
+ *
3242
+ * System / isSystem callers may still cross tenants by clearing
3243
+ * `tenantId` themselves on the resulting object; this helper does not
3244
+ * mask the system path.
3245
+ */
3246
+ buildDriverOptions(execCtx, base) {
3247
+ const hasTx = execCtx?.transaction !== void 0;
3248
+ const hasTenant = execCtx?.tenantId !== void 0;
3249
+ const isSystem = execCtx?.isSystem === true;
3250
+ if (!hasTx && !hasTenant && !isSystem) return base;
3251
+ const opts = base && typeof base === "object" ? { ...base } : {};
3252
+ if (hasTx && opts.transaction === void 0) {
3253
+ opts.transaction = execCtx.transaction;
3254
+ }
3255
+ if (hasTenant && opts.tenantId === void 0) {
3256
+ opts.tenantId = execCtx.tenantId;
3257
+ }
3258
+ if (isSystem && opts.bypassTenantAudit === void 0) {
3259
+ opts.bypassTenantAudit = true;
3260
+ }
3261
+ return opts;
3262
+ }
3263
+ /**
3264
+ * Build a HookContext.api: a ScopedContext that hooks can use to
3265
+ * read/write other objects within the same execution context.
3266
+ * Falls back to a system-elevated empty context when no execCtx
3267
+ * is supplied (e.g. system-triggered hooks).
3268
+ */
3269
+ buildHookApi(execCtx) {
3270
+ const safeCtx = execCtx ?? { isSystem: true };
3271
+ return new ScopedContext(safeCtx, this);
3272
+ }
3273
+ /**
3274
+ * Apply field defaults to an incoming insert payload. Defaults that are
3275
+ * Expression envelopes (e.g. `{ dialect: 'cel', source: 'today()' }`,
3276
+ * `{ dialect: 'cel', source: 'os.user.id' }`) are evaluated via
3277
+ * `ExpressionEngine` against the calling user/org/now snapshot. Static
3278
+ * defaults are applied verbatim. Records that already supplied a value for a
3279
+ * field are left untouched.
3280
+ *
3281
+ * Implements ROADMAP §M9.9b — `defaultValue` accepts Expression so authors
3282
+ * can replace "write a hook to default to today/current-user" with a
3283
+ * declarative `defaultValue: cel\`today()\``.
3284
+ */
3285
+ applyFieldDefaults(object, record, execCtx, nowSnapshot) {
3286
+ const schema = this.getSchema(object);
3287
+ const fieldsRaw = schema?.fields;
3288
+ if (!fieldsRaw || typeof fieldsRaw !== "object") return record;
3289
+ const fieldEntries = Array.isArray(fieldsRaw) ? fieldsRaw : Object.entries(fieldsRaw).map(([name, def]) => ({ name, ...def }));
3290
+ const out = { ...record };
3291
+ const now = nowSnapshot ?? /* @__PURE__ */ new Date();
3292
+ for (const f of fieldEntries) {
3293
+ if (out[f.name] !== void 0) continue;
3294
+ if (f.defaultValue == null) continue;
3295
+ const dv = f.defaultValue;
3296
+ if (typeof dv === "object" && dv !== null && dv.dialect && typeof dv.source === "string") {
3297
+ const result = ExpressionEngine2.evaluate(dv, {
3298
+ now,
3299
+ user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : void 0,
3300
+ org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : void 0,
3301
+ record: out,
3302
+ extra: { object }
3303
+ });
3304
+ if (result.ok) {
3305
+ out[f.name] = result.value;
3306
+ } else {
3307
+ this.logger.warn("Failed to evaluate default expression", {
3308
+ object,
3309
+ field: f.name,
3310
+ error: result.error
3311
+ });
3312
+ }
3313
+ } else {
3314
+ out[f.name] = dv;
3315
+ }
3316
+ }
3317
+ return out;
3318
+ }
1708
3319
  /**
1709
3320
  * Register contribution (Manifest)
1710
3321
  *
@@ -1719,23 +3330,24 @@ var _ObjectQL = class _ObjectQL {
1719
3330
  const id = manifest.id || manifest.name;
1720
3331
  const namespace = manifest.namespace;
1721
3332
  this.logger.debug("Registering package manifest", { id, namespace });
3333
+ console.warn(`[ObjectQL:registerApp] id=${id} flows=${Array.isArray(manifest.flows) ? manifest.flows.length : typeof manifest.flows} keys=${Object.keys(manifest).join(",")}`);
1722
3334
  if (id) {
1723
3335
  this.manifests.set(id, manifest);
1724
3336
  }
1725
- SchemaRegistry.installPackage(manifest);
3337
+ this._registry.installPackage(manifest);
1726
3338
  this.logger.debug("Installed Package", { id: manifest.id, name: manifest.name, namespace });
1727
3339
  if (manifest.objects) {
1728
3340
  if (Array.isArray(manifest.objects)) {
1729
3341
  this.logger.debug("Registering objects from manifest (Array)", { id, objectCount: manifest.objects.length });
1730
3342
  for (const objDef of manifest.objects) {
1731
- const fqn = SchemaRegistry.registerObject(objDef, id, namespace, "own");
3343
+ const fqn = this._registry.registerObject(objDef, id, namespace, "own");
1732
3344
  this.logger.debug("Registered Object", { fqn, from: id });
1733
3345
  }
1734
3346
  } else {
1735
3347
  this.logger.debug("Registering objects from manifest (Map)", { id, objectCount: Object.keys(manifest.objects).length });
1736
3348
  for (const [name, objDef] of Object.entries(manifest.objects)) {
1737
3349
  objDef.name = name;
1738
- const fqn = SchemaRegistry.registerObject(objDef, id, namespace, "own");
3350
+ const fqn = this._registry.registerObject(objDef, id, namespace, "own");
1739
3351
  this.logger.debug("Registered Object", { fqn, from: id });
1740
3352
  }
1741
3353
  }
@@ -1755,7 +3367,7 @@ var _ObjectQL = class _ObjectQL {
1755
3367
  validations: ext.validations,
1756
3368
  indexes: ext.indexes
1757
3369
  };
1758
- SchemaRegistry.registerObject(extDef, id, void 0, "extend", priority);
3370
+ this._registry.registerObject(extDef, id, void 0, "extend", priority);
1759
3371
  this.logger.debug("Registered Object Extension", { target: targetFqn, priority, from: id });
1760
3372
  }
1761
3373
  }
@@ -1764,13 +3376,15 @@ var _ObjectQL = class _ObjectQL {
1764
3376
  for (const app of manifest.apps) {
1765
3377
  const appName = app.name || app.id;
1766
3378
  if (appName) {
1767
- SchemaRegistry.registerApp(app, id);
3379
+ const resolved = namespace ? this.resolveNavObjectNames(app, namespace) : app;
3380
+ this._registry.registerApp(resolved, id);
1768
3381
  this.logger.debug("Registered App", { app: appName, from: id });
1769
3382
  }
1770
3383
  }
1771
3384
  }
1772
3385
  if (manifest.name && manifest.navigation && !manifest.apps?.length) {
1773
- SchemaRegistry.registerApp(manifest, id);
3386
+ const resolved = namespace ? this.resolveNavObjectNames(manifest, namespace) : manifest;
3387
+ this._registry.registerApp(resolved, id);
1774
3388
  this.logger.debug("Registered manifest-as-app", { app: manifest.name, from: id });
1775
3389
  }
1776
3390
  const metadataArrayKeys = [
@@ -1809,9 +3423,12 @@ var _ObjectQL = class _ObjectQL {
1809
3423
  if (Array.isArray(items) && items.length > 0) {
1810
3424
  this.logger.debug(`Registering ${key} from manifest`, { id, count: items.length });
1811
3425
  for (const item of items) {
1812
- const itemName = item.name || item.id;
3426
+ const itemName = resolveMetadataItemName(key, item);
1813
3427
  if (itemName) {
1814
- SchemaRegistry.registerItem(pluralToSingular(key), item, "name", id);
3428
+ const toRegister = item.name === itemName ? item : { ...item, name: itemName };
3429
+ this._registry.registerItem(pluralToSingular(key), toRegister, "name", id);
3430
+ } else {
3431
+ this.logger.warn(`Skipping ${pluralToSingular(key)} without a derivable name`, { id });
1815
3432
  }
1816
3433
  }
1817
3434
  }
@@ -1821,14 +3438,14 @@ var _ObjectQL = class _ObjectQL {
1821
3438
  this.logger.debug("Registering seed data datasets", { id, count: seedData.length });
1822
3439
  for (const dataset of seedData) {
1823
3440
  if (dataset.object) {
1824
- SchemaRegistry.registerItem("data", dataset, "object", id);
3441
+ this._registry.registerItem("data", dataset, "object", id);
1825
3442
  }
1826
3443
  }
1827
3444
  }
1828
3445
  if (manifest.contributes?.kinds) {
1829
3446
  this.logger.debug("Registering kinds from manifest", { id, kindCount: manifest.contributes.kinds.length });
1830
3447
  for (const kind of manifest.contributes.kinds) {
1831
- SchemaRegistry.registerKind(kind);
3448
+ this._registry.registerKind(kind);
1832
3449
  this.logger.debug("Registered Kind", { kind: kind.name || kind.type, from: id });
1833
3450
  }
1834
3451
  }
@@ -1843,6 +3460,25 @@ var _ObjectQL = class _ObjectQL {
1843
3460
  }
1844
3461
  }
1845
3462
  }
3463
+ /**
3464
+ * Deep-clone an app definition, resolving objectName references in navigation
3465
+ * items via the registry. Object names are canonical identifiers — no FQN
3466
+ * expansion is applied.
3467
+ */
3468
+ resolveNavObjectNames(app, namespace) {
3469
+ if (!app.navigation) return app;
3470
+ const resolveItems = (items) => items.map((item) => {
3471
+ const resolved = { ...item };
3472
+ if (resolved.objectName && !resolved.objectName.includes("__")) {
3473
+ resolved.objectName = computeFQN(namespace, resolved.objectName);
3474
+ }
3475
+ if (Array.isArray(resolved.children)) {
3476
+ resolved.children = resolveItems(resolved.children);
3477
+ }
3478
+ return resolved;
3479
+ });
3480
+ return { ...app, navigation: resolveItems(app.navigation) };
3481
+ }
1846
3482
  /**
1847
3483
  * Register a nested plugin's metadata (objects, actions, views, etc.)
1848
3484
  *
@@ -1863,7 +3499,7 @@ var _ObjectQL = class _ObjectQL {
1863
3499
  if (Array.isArray(plugin.objects)) {
1864
3500
  this.logger.debug("Registering plugin objects (Array)", { pluginName, count: plugin.objects.length });
1865
3501
  for (const objDef of plugin.objects) {
1866
- const fqn = SchemaRegistry.registerObject(objDef, ownerId, pluginNamespace, "own");
3502
+ const fqn = this._registry.registerObject(objDef, ownerId, pluginNamespace, "own");
1867
3503
  this.logger.debug("Registered Object", { fqn, from: pluginName });
1868
3504
  }
1869
3505
  } else {
@@ -1871,7 +3507,7 @@ var _ObjectQL = class _ObjectQL {
1871
3507
  this.logger.debug("Registering plugin objects (Map)", { pluginName, count: entries.length });
1872
3508
  for (const [name, objDef] of entries) {
1873
3509
  objDef.name = name;
1874
- const fqn = SchemaRegistry.registerObject(objDef, ownerId, pluginNamespace, "own");
3510
+ const fqn = this._registry.registerObject(objDef, ownerId, pluginNamespace, "own");
1875
3511
  this.logger.debug("Registered Object", { fqn, from: pluginName });
1876
3512
  }
1877
3513
  }
@@ -1881,7 +3517,8 @@ var _ObjectQL = class _ObjectQL {
1881
3517
  }
1882
3518
  if (plugin.name && plugin.navigation) {
1883
3519
  try {
1884
- SchemaRegistry.registerApp(plugin, ownerId);
3520
+ const resolved = pluginNamespace ? this.resolveNavObjectNames(plugin, pluginNamespace) : plugin;
3521
+ this._registry.registerApp(resolved, ownerId);
1885
3522
  this.logger.debug("Registered plugin-as-app", { app: plugin.name, from: pluginName });
1886
3523
  } catch (err) {
1887
3524
  this.logger.warn("Failed to register plugin as app", { pluginName, error: err.message });
@@ -1915,9 +3552,10 @@ var _ObjectQL = class _ObjectQL {
1915
3552
  const items = plugin[key];
1916
3553
  if (Array.isArray(items) && items.length > 0) {
1917
3554
  for (const item of items) {
1918
- const itemName = item.name || item.id;
3555
+ const itemName = resolveMetadataItemName(key, item);
1919
3556
  if (itemName) {
1920
- SchemaRegistry.registerItem(pluralToSingular(key), item, "name", ownerId);
3557
+ const toRegister = item.name === itemName ? item : { ...item, name: itemName };
3558
+ this._registry.registerItem(pluralToSingular(key), toRegister, "name", ownerId);
1921
3559
  }
1922
3560
  }
1923
3561
  }
@@ -1955,24 +3593,21 @@ var _ObjectQL = class _ObjectQL {
1955
3593
  * Helper to get object definition
1956
3594
  */
1957
3595
  getSchema(objectName) {
1958
- return SchemaRegistry.getObject(objectName);
3596
+ return this._registry.getObject(objectName);
1959
3597
  }
1960
3598
  /**
1961
- * Resolve an object name to its Fully Qualified Name (FQN).
1962
- *
1963
- * Short names like 'task' are resolved to FQN like 'todo__task'
1964
- * via SchemaRegistry lookup. If no match is found, the name is
1965
- * returned as-is (for ad-hoc / unregistered objects).
1966
- *
1967
- * This ensures that all driver operations use a consistent key
1968
- * regardless of whether the caller uses the short name or FQN.
3599
+ * Resolve any object identifier to the physical storage name used by drivers.
3600
+ *
3601
+ * Accepts the canonical short name (e.g., 'account') or, for explicit
3602
+ * cross-package disambiguation, the canonical object name (e.g., 'account'). The result is
3603
+ * the physical table name derived via `StorageNameMapping.resolveTableName`.
1969
3604
  */
1970
3605
  resolveObjectName(name) {
1971
- const schema = SchemaRegistry.getObject(name);
3606
+ const schema = this._registry.getObject(name);
1972
3607
  if (schema) {
1973
- return schema.tableName || schema.name;
3608
+ return StorageNameMapping.resolveTableName(schema);
1974
3609
  }
1975
- return name;
3610
+ return StorageNameMapping.resolveTableName({ name });
1976
3611
  }
1977
3612
  /**
1978
3613
  * Helper to get the target driver
@@ -1984,7 +3619,7 @@ var _ObjectQL = class _ObjectQL {
1984
3619
  * 4. Global default driver
1985
3620
  */
1986
3621
  getDriver(objectName) {
1987
- const object = SchemaRegistry.getObject(objectName);
3622
+ const object = this._registry.getObject(objectName);
1988
3623
  if (object?.datasource && object.datasource !== "default") {
1989
3624
  if (this.drivers.has(object.datasource)) {
1990
3625
  return this.drivers.get(object.datasource);
@@ -2000,7 +3635,7 @@ var _ObjectQL = class _ObjectQL {
2000
3635
  return this.drivers.get(mappedDatasource);
2001
3636
  }
2002
3637
  const fqn = object?.name || objectName;
2003
- const owner = SchemaRegistry.getObjectOwner(fqn);
3638
+ const owner = this._registry.getObjectOwner(fqn);
2004
3639
  if (owner?.packageId) {
2005
3640
  const manifest = this.manifests.get(owner.packageId);
2006
3641
  if (manifest?.defaultDatasource && manifest.defaultDatasource !== "default") {
@@ -2118,10 +3753,10 @@ var _ObjectQL = class _ObjectQL {
2118
3753
  * @param depth - Current recursion depth (0-based)
2119
3754
  * @returns Records with expanded lookup fields (IDs replaced by full objects)
2120
3755
  */
2121
- async expandRelatedRecords(objectName, records, expand, depth = 0) {
3756
+ async expandRelatedRecords(objectName, records, expand, depth = 0, execCtx) {
2122
3757
  if (!records || records.length === 0) return records;
2123
3758
  if (depth >= _ObjectQL.MAX_EXPAND_DEPTH) return records;
2124
- const objectSchema = SchemaRegistry.getObject(objectName);
3759
+ const objectSchema = this._registry.getObject(objectName);
2125
3760
  if (!objectSchema || !objectSchema.fields) return records;
2126
3761
  for (const [fieldName, nestedAST] of Object.entries(expand)) {
2127
3762
  const fieldDef = objectSchema.fields[fieldName];
@@ -2150,7 +3785,8 @@ var _ObjectQL = class _ObjectQL {
2150
3785
  ...nestedAST.orderBy ? { orderBy: nestedAST.orderBy } : {}
2151
3786
  };
2152
3787
  const driver = this.getDriver(referenceObject);
2153
- const relatedRecords = await driver.find(referenceObject, relatedQuery) ?? [];
3788
+ const expandOpts = this.buildDriverOptions(execCtx);
3789
+ const relatedRecords = await driver.find(referenceObject, relatedQuery, expandOpts) ?? [];
2154
3790
  const recordMap = /* @__PURE__ */ new Map();
2155
3791
  for (const rec of relatedRecords) {
2156
3792
  const id = rec.id;
@@ -2161,7 +3797,8 @@ var _ObjectQL = class _ObjectQL {
2161
3797
  referenceObject,
2162
3798
  relatedRecords,
2163
3799
  nestedAST.expand,
2164
- depth + 1
3800
+ depth + 1,
3801
+ execCtx
2165
3802
  );
2166
3803
  recordMap.clear();
2167
3804
  for (const rec of expandedRelated) {
@@ -2202,6 +3839,20 @@ var _ObjectQL = class _ObjectQL {
2202
3839
  ast.limit = ast.top;
2203
3840
  }
2204
3841
  delete ast.top;
3842
+ const _findSchema = this._registry.getObject(object);
3843
+ const _findFormula = planFormulaProjection(_findSchema, ast.fields);
3844
+ if (_findFormula.projected) ast.fields = _findFormula.projected;
3845
+ if (_findSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) {
3846
+ const known = new Set(Object.keys(_findSchema.fields));
3847
+ known.add("id");
3848
+ known.add("created_at");
3849
+ known.add("updated_at");
3850
+ const filtered = ast.fields.filter((f) => {
3851
+ const head = String(f).split(".")[0];
3852
+ return known.has(head);
3853
+ });
3854
+ ast.fields = filtered.length > 0 ? filtered : void 0;
3855
+ }
2205
3856
  const opCtx = {
2206
3857
  object,
2207
3858
  operation: "find",
@@ -2215,14 +3866,17 @@ var _ObjectQL = class _ObjectQL {
2215
3866
  event: "beforeFind",
2216
3867
  input: { ast: opCtx.ast, options: opCtx.options },
2217
3868
  session: this.buildSession(opCtx.context),
3869
+ api: this.buildHookApi(opCtx.context),
2218
3870
  transaction: opCtx.context?.transaction,
2219
3871
  ql: this
2220
3872
  };
2221
3873
  await this.triggerHooks("beforeFind", hookContext);
3874
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
2222
3875
  try {
2223
3876
  let result = await driver.find(object, hookContext.input.ast, hookContext.input.options);
3877
+ if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result);
2224
3878
  if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) {
2225
- result = await this.expandRelatedRecords(object, result, ast.expand, 0);
3879
+ result = await this.expandRelatedRecords(object, result, ast.expand, 0, opCtx.context);
2226
3880
  }
2227
3881
  hookContext.event = "afterFind";
2228
3882
  hookContext.result = result;
@@ -2242,6 +3896,17 @@ var _ObjectQL = class _ObjectQL {
2242
3896
  const ast = { object: objectName, ...query, limit: 1 };
2243
3897
  delete ast.context;
2244
3898
  delete ast.top;
3899
+ const _findOneSchema = this._registry.getObject(objectName);
3900
+ const _findOneFormula = planFormulaProjection(_findOneSchema, ast.fields);
3901
+ if (_findOneFormula.projected) ast.fields = _findOneFormula.projected;
3902
+ if (_findOneSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) {
3903
+ const known = new Set(Object.keys(_findOneSchema.fields));
3904
+ known.add("id");
3905
+ known.add("created_at");
3906
+ known.add("updated_at");
3907
+ const filtered = ast.fields.filter((f) => known.has(String(f).split(".")[0]));
3908
+ ast.fields = filtered.length > 0 ? filtered : void 0;
3909
+ }
2245
3910
  const opCtx = {
2246
3911
  object: objectName,
2247
3912
  operation: "findOne",
@@ -2250,9 +3915,11 @@ var _ObjectQL = class _ObjectQL {
2250
3915
  context: query?.context
2251
3916
  };
2252
3917
  await this.executeWithMiddleware(opCtx, async () => {
2253
- let result = await driver.findOne(objectName, opCtx.ast);
3918
+ const findOneOpts = this.buildDriverOptions(opCtx.context);
3919
+ let result = await driver.findOne(objectName, opCtx.ast, findOneOpts);
3920
+ if (result != null) applyFormulaPlan(_findOneFormula.plan, [result]);
2254
3921
  if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {
2255
- const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0);
3922
+ const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context);
2256
3923
  result = expanded[0];
2257
3924
  }
2258
3925
  return result;
@@ -2276,20 +3943,35 @@ var _ObjectQL = class _ObjectQL {
2276
3943
  event: "beforeInsert",
2277
3944
  input: { data: opCtx.data, options: opCtx.options },
2278
3945
  session: this.buildSession(opCtx.context),
3946
+ api: this.buildHookApi(opCtx.context),
2279
3947
  transaction: opCtx.context?.transaction,
2280
3948
  ql: this
2281
3949
  };
2282
3950
  await this.triggerHooks("beforeInsert", hookContext);
3951
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
2283
3952
  try {
2284
3953
  let result;
3954
+ const nowSnap = /* @__PURE__ */ new Date();
3955
+ const schemaForValidation = this._registry.getObject(object);
2285
3956
  if (Array.isArray(hookContext.input.data)) {
3957
+ const rows = hookContext.input.data.map(
3958
+ (row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
3959
+ );
3960
+ for (const r of rows) validateRecord(schemaForValidation, r, "insert");
2286
3961
  if (driver.bulkCreate) {
2287
- result = await driver.bulkCreate(object, hookContext.input.data, hookContext.input.options);
3962
+ result = await driver.bulkCreate(object, rows, hookContext.input.options);
2288
3963
  } else {
2289
- result = await Promise.all(hookContext.input.data.map((item) => driver.create(object, item, hookContext.input.options)));
3964
+ result = await Promise.all(rows.map((item) => driver.create(object, item, hookContext.input.options)));
2290
3965
  }
2291
3966
  } else {
2292
- result = await driver.create(object, hookContext.input.data, hookContext.input.options);
3967
+ const row = this.applyFieldDefaults(
3968
+ object,
3969
+ hookContext.input.data,
3970
+ opCtx.context,
3971
+ nowSnap
3972
+ );
3973
+ validateRecord(schemaForValidation, row, "insert");
3974
+ result = await driver.create(object, row, hookContext.input.options);
2293
3975
  }
2294
3976
  hookContext.event = "afterInsert";
2295
3977
  hookContext.result = result;
@@ -2356,15 +4038,19 @@ var _ObjectQL = class _ObjectQL {
2356
4038
  event: "beforeUpdate",
2357
4039
  input: { id, data: opCtx.data, options: opCtx.options },
2358
4040
  session: this.buildSession(opCtx.context),
4041
+ api: this.buildHookApi(opCtx.context),
2359
4042
  transaction: opCtx.context?.transaction,
2360
4043
  ql: this
2361
4044
  };
2362
4045
  await this.triggerHooks("beforeUpdate", hookContext);
4046
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
2363
4047
  try {
2364
4048
  let result;
2365
4049
  if (hookContext.input.id) {
4050
+ validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
2366
4051
  result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
2367
4052
  } else if (options?.multi && driver.updateMany) {
4053
+ validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
2368
4054
  const ast = { object, where: options.where };
2369
4055
  result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options);
2370
4056
  } else {
@@ -2421,10 +4107,12 @@ var _ObjectQL = class _ObjectQL {
2421
4107
  event: "beforeDelete",
2422
4108
  input: { id, options: opCtx.options },
2423
4109
  session: this.buildSession(opCtx.context),
4110
+ api: this.buildHookApi(opCtx.context),
2424
4111
  transaction: opCtx.context?.transaction,
2425
4112
  ql: this
2426
4113
  };
2427
4114
  await this.triggerHooks("beforeDelete", hookContext);
4115
+ hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
2428
4116
  try {
2429
4117
  let result;
2430
4118
  if (hookContext.input.id) {
@@ -2474,11 +4162,12 @@ var _ObjectQL = class _ObjectQL {
2474
4162
  context: query?.context
2475
4163
  };
2476
4164
  await this.executeWithMiddleware(opCtx, async () => {
4165
+ const countOpts = this.buildDriverOptions(opCtx.context);
2477
4166
  if (driver.count) {
2478
4167
  const ast = { object, where: query?.where };
2479
- return driver.count(object, ast);
4168
+ return driver.count(object, ast, countOpts);
2480
4169
  }
2481
- const res = await this.find(object, { where: query?.where, fields: ["id"] });
4170
+ const res = await this.find(object, { where: query?.where, fields: ["id"], context: opCtx.context });
2482
4171
  return res.length;
2483
4172
  });
2484
4173
  return opCtx.result;
@@ -2500,18 +4189,104 @@ var _ObjectQL = class _ObjectQL {
2500
4189
  groupBy: query.groupBy,
2501
4190
  aggregations: query.aggregations
2502
4191
  };
2503
- return driver.find(object, ast);
4192
+ const drv = driver;
4193
+ const groupByItems = Array.isArray(query.groupBy) ? query.groupBy : [];
4194
+ const granularityCaps = drv?.supports?.queryDateGranularity;
4195
+ const structuredItems = groupByItems.filter((g) => typeof g !== "string");
4196
+ const allStructuredSupported = structuredItems.every((g) => {
4197
+ if (!g?.dateGranularity) return true;
4198
+ return granularityCaps?.[g.dateGranularity] === true;
4199
+ });
4200
+ if (typeof drv.aggregate === "function" && allStructuredSupported) {
4201
+ return drv.aggregate(object, ast, this.buildDriverOptions(opCtx.context));
4202
+ }
4203
+ const raw = await driver.find(object, ast, this.buildDriverOptions(opCtx.context));
4204
+ return applyInMemoryAggregation(raw, ast);
2504
4205
  });
2505
4206
  return opCtx.result;
2506
4207
  }
4208
+ /**
4209
+ * Run raw driver-specific commands (SQL for SqlDriver, REST for RestDriver, …).
4210
+ *
4211
+ * ⚠️ **Tenant isolation bypass.** Raw `execute()` does NOT thread the
4212
+ * caller's `ExecutionContext.tenantId` into a `WHERE organization_id`
4213
+ * predicate — drivers see the command verbatim. Callers MUST inline the
4214
+ * tenant filter themselves, or restrict raw execution to genuinely global
4215
+ * statements (schema migrations, sys_* / control-plane tables).
4216
+ *
4217
+ * Prefer the typed entry points (`find`, `update`, `delete`, `count`, …)
4218
+ * whenever feasible — they auto-apply tenancy + soft-delete + audit warnings.
4219
+ */
2507
4220
  async execute(command, options) {
4221
+ let driver;
2508
4222
  if (options?.object) {
2509
- const driver = this.getDriver(options.object);
2510
- if (driver.execute) {
2511
- return driver.execute(command, void 0, options);
4223
+ driver = this.getDriver(options.object);
4224
+ } else if (options?.datasource && this.drivers.has(options.datasource)) {
4225
+ driver = this.drivers.get(options.datasource);
4226
+ } else if (this.defaultDriver && this.drivers.has(this.defaultDriver)) {
4227
+ driver = this.drivers.get(this.defaultDriver);
4228
+ } else if (this.drivers.size === 1) {
4229
+ driver = this.drivers.values().next().value;
4230
+ }
4231
+ if (!driver) {
4232
+ throw new Error(
4233
+ "Execute requires options.object to select a driver, or a default driver to be configured. Configure datasourceMapping with `default: true` or pass `{ object }` / `{ datasource }` in options."
4234
+ );
4235
+ }
4236
+ if (!driver.execute) {
4237
+ throw new Error("Selected driver does not implement execute()");
4238
+ }
4239
+ let rawCommand = command;
4240
+ let params = options?.args ?? options?.params;
4241
+ if (command && typeof command === "object" && !Array.isArray(command) && "sql" in command) {
4242
+ rawCommand = command.sql;
4243
+ if (params === void 0) {
4244
+ params = command.args ?? command.params;
4245
+ }
4246
+ }
4247
+ return driver.execute(rawCommand, params, options);
4248
+ }
4249
+ /**
4250
+ * Execute a callback inside a database transaction.
4251
+ *
4252
+ * The callback receives a context object that should be passed to all
4253
+ * downstream `engine.insert/update/delete/find/findOne` calls (as
4254
+ * `{ context: trxCtx }`). The transaction handle threads through
4255
+ * `OperationContext.context.transaction` and the SQL driver's per-builder
4256
+ * `.transacting(trx)` call.
4257
+ *
4258
+ * - If the default driver does not support `beginTransaction`, the callback
4259
+ * runs directly with the supplied base context (no rollback). This keeps
4260
+ * the API safe to call on drivers without ACID support (e.g. the
4261
+ * in-memory driver in tests).
4262
+ * - On callback success the transaction is committed; on any thrown error
4263
+ * it is rolled back and the original error is re-thrown.
4264
+ *
4265
+ * Use case: multi-step operations that must be atomic (e.g. CRM
4266
+ * `convertLead`, which creates an account + contact + opportunity + flips
4267
+ * the lead in a single unit of work).
4268
+ */
4269
+ async transaction(callback, baseContext) {
4270
+ const driver = this.defaultDriver ? this.drivers.get(this.defaultDriver) : void 0;
4271
+ const drv = driver;
4272
+ if (!drv?.beginTransaction) {
4273
+ return callback(baseContext);
4274
+ }
4275
+ const trx = await drv.beginTransaction();
4276
+ const trxCtx = { ...baseContext ?? {}, transaction: trx };
4277
+ try {
4278
+ const result = await callback(trxCtx);
4279
+ if (drv.commit) await drv.commit(trx);
4280
+ else if (drv.commitTransaction) await drv.commitTransaction(trx);
4281
+ return result;
4282
+ } catch (err) {
4283
+ try {
4284
+ if (drv.rollback) await drv.rollback(trx);
4285
+ else if (drv.rollbackTransaction) await drv.rollbackTransaction(trx);
4286
+ } catch {
2512
4287
  }
4288
+ throw err;
2513
4289
  }
2514
- throw new Error("Execute requires options.object to select driver");
2515
4290
  }
2516
4291
  // ============================================
2517
4292
  // Compatibility / Convenience API
@@ -2532,16 +4307,16 @@ var _ObjectQL = class _ObjectQL {
2532
4307
  }
2533
4308
  }
2534
4309
  }
2535
- return SchemaRegistry.registerObject(schema, packageId, namespace);
4310
+ return this._registry.registerObject(schema, packageId, namespace);
2536
4311
  }
2537
4312
  /**
2538
4313
  * Unregister a single object by name.
2539
4314
  */
2540
4315
  unregisterObject(name, packageId) {
2541
4316
  if (packageId) {
2542
- SchemaRegistry.unregisterObjectsByPackage(packageId);
4317
+ this._registry.unregisterObjectsByPackage(packageId);
2543
4318
  } else {
2544
- SchemaRegistry.unregisterItem("object", name);
4319
+ this._registry.unregisterItem("object", name);
2545
4320
  }
2546
4321
  }
2547
4322
  /**
@@ -2557,7 +4332,7 @@ var _ObjectQL = class _ObjectQL {
2557
4332
  */
2558
4333
  getConfigs() {
2559
4334
  const result = {};
2560
- const objects = SchemaRegistry.getAllObjects();
4335
+ const objects = this._registry.getAllObjects();
2561
4336
  for (const obj of objects) {
2562
4337
  if (obj.name) {
2563
4338
  result[obj.name] = obj;
@@ -2591,10 +4366,32 @@ var _ObjectQL = class _ObjectQL {
2591
4366
  return void 0;
2592
4367
  }
2593
4368
  }
4369
+ /**
4370
+ * Sync all registered object schemas to their respective drivers.
4371
+ * Call this after dynamically registering new objects at runtime
4372
+ * (e.g. after template seeding) to ensure tables/collections exist
4373
+ * before inserting seed data.
4374
+ */
4375
+ async syncSchemas() {
4376
+ const allObjects = this._registry.getAllObjects();
4377
+ for (const obj of allObjects) {
4378
+ const driver = this.getDriverForObject(obj.name);
4379
+ if (!driver) continue;
4380
+ const tableName = StorageNameMapping.resolveTableName(obj);
4381
+ if (typeof driver.syncSchemasBatch === "function" && driver.supports?.batchSchemaSync) {
4382
+ }
4383
+ if (typeof driver.syncSchema === "function") {
4384
+ try {
4385
+ await driver.syncSchema(tableName, obj);
4386
+ } catch {
4387
+ }
4388
+ }
4389
+ }
4390
+ }
2594
4391
  /**
2595
4392
  * Get a registered driver by datasource name.
2596
4393
  * Alias matching @objectql/core datasource() API.
2597
- *
4394
+ *
2598
4395
  * @throws Error if the datasource is not found
2599
4396
  */
2600
4397
  datasource(name) {
@@ -2625,7 +4422,7 @@ var _ObjectQL = class _ObjectQL {
2625
4422
  }
2626
4423
  }
2627
4424
  this.removeActionsByPackage(packageId);
2628
- SchemaRegistry.unregisterObjectsByPackage(packageId, true);
4425
+ this._registry.unregisterObjectsByPackage(packageId, true);
2629
4426
  }
2630
4427
  /**
2631
4428
  * Gracefully shut down the engine, disconnecting all drivers.
@@ -2841,86 +4638,97 @@ var ScopedContext = class _ScopedContext {
2841
4638
 
2842
4639
  // src/metadata-facade.ts
2843
4640
  var MetadataFacade = class {
4641
+ constructor(registry) {
4642
+ this.registry = registry;
4643
+ }
2844
4644
  /**
2845
4645
  * Register a metadata item
2846
4646
  */
2847
4647
  async register(type, name, data) {
2848
4648
  const definition = typeof data === "object" && data !== null ? { ...data, name: data.name ?? name } : data;
2849
4649
  if (type === "object") {
2850
- SchemaRegistry.registerItem(type, definition, "name");
4650
+ this.registry.registerItem(type, definition, "name");
2851
4651
  } else {
2852
- SchemaRegistry.registerItem(type, definition, definition.id ? "id" : "name");
4652
+ this.registry.registerItem(type, definition, definition.id ? "id" : "name");
2853
4653
  }
2854
4654
  }
2855
4655
  /**
2856
4656
  * Get a metadata item by type and name
2857
4657
  */
2858
4658
  async get(type, name) {
2859
- const item = SchemaRegistry.getItem(type, name);
4659
+ const item = this.registry.getItem(type, name);
2860
4660
  return item?.content ?? item;
2861
4661
  }
2862
4662
  /**
2863
4663
  * Get the raw entry (with metadata wrapper)
2864
4664
  */
2865
4665
  getEntry(type, name) {
2866
- return SchemaRegistry.getItem(type, name);
4666
+ return this.registry.getItem(type, name);
2867
4667
  }
2868
4668
  /**
2869
4669
  * List all items of a type
2870
4670
  */
2871
4671
  async list(type) {
2872
- const items = SchemaRegistry.listItems(type);
4672
+ const items = this.registry.listItems(type);
2873
4673
  return items.map((item) => item?.content ?? item);
2874
4674
  }
2875
4675
  /**
2876
4676
  * Unregister a metadata item
2877
4677
  */
2878
4678
  async unregister(type, name) {
2879
- SchemaRegistry.unregisterItem(type, name);
4679
+ this.registry.unregisterItem(type, name);
2880
4680
  }
2881
4681
  /**
2882
4682
  * Check if a metadata item exists
2883
4683
  */
2884
4684
  async exists(type, name) {
2885
- const item = SchemaRegistry.getItem(type, name);
4685
+ const item = this.registry.getItem(type, name);
2886
4686
  return item !== void 0 && item !== null;
2887
4687
  }
2888
4688
  /**
2889
4689
  * List all names of metadata items of a given type
2890
4690
  */
2891
4691
  async listNames(type) {
2892
- const items = SchemaRegistry.listItems(type);
4692
+ const items = this.registry.listItems(type);
2893
4693
  return items.map((item) => item?.name ?? item?.content?.name ?? "").filter(Boolean);
2894
4694
  }
2895
4695
  /**
2896
4696
  * Unregister all metadata from a package
2897
4697
  */
2898
4698
  async unregisterPackage(packageName) {
2899
- SchemaRegistry.unregisterObjectsByPackage(packageName);
4699
+ this.registry.unregisterObjectsByPackage(packageName);
2900
4700
  }
2901
4701
  /**
2902
4702
  * Convenience: get object definition
2903
4703
  */
2904
4704
  async getObject(name) {
2905
- return SchemaRegistry.getObject(name);
4705
+ return this.registry.getObject(name);
2906
4706
  }
2907
4707
  /**
2908
4708
  * Convenience: list all objects
2909
4709
  */
2910
4710
  async listObjects() {
2911
- return SchemaRegistry.getAllObjects();
4711
+ return this.registry.getAllObjects();
2912
4712
  }
2913
4713
  };
2914
4714
 
2915
4715
  // src/plugin.ts
4716
+ import { StorageNameMapping as StorageNameMapping2 } from "@objectstack/spec/system";
2916
4717
  function hasLoadMetaFromDb(service) {
2917
4718
  return typeof service === "object" && service !== null && typeof service["loadMetaFromDb"] === "function";
2918
4719
  }
2919
4720
  var ObjectQLPlugin = class {
2920
- constructor(ql, hostContext) {
4721
+ constructor(qlOrOptions, hostContext) {
2921
4722
  this.name = "com.objectstack.engine.objectql";
2922
4723
  this.type = "objectql";
2923
4724
  this.version = "1.0.0";
4725
+ /**
4726
+ * Schema sync to remote SQL DBs is latency-bound (one round-trip per
4727
+ * table × 2 phases). Default to 120s instead of the kernel's 30s so
4728
+ * cold Neon/Turso starts don't get killed mid-sync.
4729
+ */
4730
+ this.startupTimeout = 12e4;
4731
+ this.skipSchemaSync = false;
2924
4732
  this.init = async (ctx) => {
2925
4733
  if (!this.ql) {
2926
4734
  const hostCtx = { ...this.hostContext, logger: ctx.logger };
@@ -2942,10 +4750,23 @@ var ObjectQLPlugin = class {
2942
4750
  });
2943
4751
  const protocolShim = new ObjectStackProtocolImplementation(
2944
4752
  this.ql,
2945
- () => ctx.getServices ? ctx.getServices() : /* @__PURE__ */ new Map()
4753
+ () => ctx.getServices ? ctx.getServices() : /* @__PURE__ */ new Map(),
4754
+ void 0,
4755
+ this.projectId
2946
4756
  );
2947
4757
  ctx.registerService("protocol", protocolShim);
2948
4758
  ctx.logger.info("Protocol service registered");
4759
+ ctx.registerService("analytics", {
4760
+ query: (body) => protocolShim.analyticsQuery(body),
4761
+ getMeta: async () => ({
4762
+ cubes: [],
4763
+ message: "Analytics meta endpoint not implemented by ObjectQL adapter"
4764
+ }),
4765
+ generateSql: async (_body) => ({
4766
+ sql: null,
4767
+ message: "Analytics SQL generation not implemented by ObjectQL adapter"
4768
+ })
4769
+ });
2949
4770
  };
2950
4771
  this.start = async (ctx) => {
2951
4772
  ctx.logger.info("ObjectQL engine starting...");
@@ -2985,103 +4806,194 @@ var ObjectQLPlugin = class {
2985
4806
  }
2986
4807
  }
2987
4808
  await this.ql?.init();
2988
- await this.restoreMetadataFromDb(ctx);
2989
- await this.syncRegisteredSchemas(ctx);
2990
- await this.bridgeObjectsToMetadataService(ctx);
4809
+ if (this.skipSchemaSync) {
4810
+ ctx.logger.info("Skipping schema sync (OS_SKIP_SCHEMA_SYNC=1) \u2014 assuming DDL is managed out-of-band");
4811
+ } else {
4812
+ await this.syncRegisteredSchemas(ctx);
4813
+ }
4814
+ if (this.projectId === void 0) {
4815
+ await this.restoreMetadataFromDb(ctx);
4816
+ } else {
4817
+ ctx.logger.info("Project kernel \u2014 skipping sys_metadata hydration (metadata sourced from artifact)");
4818
+ }
4819
+ if (!this.skipSchemaSync) {
4820
+ await this.syncRegisteredSchemas(ctx);
4821
+ }
4822
+ if (this.projectId === void 0) {
4823
+ await this.bridgeObjectsToMetadataService(ctx);
4824
+ }
2991
4825
  this.registerAuditHooks(ctx);
2992
- this.registerTenantMiddleware(ctx);
2993
4826
  ctx.logger.info("ObjectQL engine started", {
2994
4827
  driversRegistered: this.ql?.["drivers"]?.size || 0,
2995
4828
  objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0
2996
4829
  });
2997
4830
  };
2998
- if (ql) {
2999
- this.ql = ql;
3000
- } else {
4831
+ if (qlOrOptions instanceof ObjectQL) {
4832
+ this.ql = qlOrOptions;
3001
4833
  this.hostContext = hostContext;
4834
+ return;
4835
+ }
4836
+ const opts = qlOrOptions ?? {};
4837
+ if (opts.ql) {
4838
+ this.ql = opts.ql;
4839
+ }
4840
+ this.hostContext = opts.hostContext ?? hostContext;
4841
+ this.projectId = opts.projectId;
4842
+ if (typeof opts.startupTimeout === "number" && opts.startupTimeout > 0) {
4843
+ this.startupTimeout = opts.startupTimeout;
3002
4844
  }
4845
+ this.skipSchemaSync = typeof opts.skipSchemaSync === "boolean" ? opts.skipSchemaSync : process.env.OS_SKIP_SCHEMA_SYNC === "1";
3003
4846
  }
3004
4847
  /**
3005
4848
  * Register built-in audit hooks for auto-stamping created_by/updated_by
3006
- * and fetching previousData for update/delete operations.
4849
+ * and fetching previousData for update/delete operations. These are
4850
+ * declared as canonical `Hook` metadata and bound through the same
4851
+ * `bindHooksToEngine` path used by `defineStack({ hooks })`, so the
4852
+ * engine's built-ins flow through the same rails as user code
4853
+ * (dogfooding the protocol).
3007
4854
  */
3008
4855
  registerAuditHooks(ctx) {
3009
4856
  if (!this.ql) return;
3010
- this.ql.registerHook("beforeInsert", async (hookCtx) => {
3011
- if (hookCtx.session?.userId && hookCtx.input?.data) {
3012
- const data = hookCtx.input.data;
3013
- if (typeof data === "object" && data !== null) {
3014
- data.created_by = data.created_by ?? hookCtx.session.userId;
3015
- data.updated_by = hookCtx.session.userId;
3016
- data.created_at = data.created_at ?? (/* @__PURE__ */ new Date()).toISOString();
3017
- data.updated_at = (/* @__PURE__ */ new Date()).toISOString();
3018
- if (hookCtx.session.tenantId) {
3019
- data.tenant_id = data.tenant_id ?? hookCtx.session.tenantId;
3020
- }
4857
+ const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
4858
+ const hasField = (objectName, field) => {
4859
+ try {
4860
+ const schema = this.ql?.getSchema?.(objectName);
4861
+ if (!schema || typeof schema !== "object") return false;
4862
+ const fields = schema.fields;
4863
+ if (!fields || typeof fields !== "object") return false;
4864
+ return Object.prototype.hasOwnProperty.call(fields, field);
4865
+ } catch {
4866
+ return false;
4867
+ }
4868
+ };
4869
+ const applyToRecord = (record, objectName, session, isInsert) => {
4870
+ const now = stamp();
4871
+ if (isInsert) {
4872
+ record.created_at = record.created_at ?? now;
4873
+ }
4874
+ record.updated_at = now;
4875
+ if (session?.userId) {
4876
+ if (isInsert && hasField(objectName, "created_by")) {
4877
+ record.created_by = record.created_by ?? session.userId;
3021
4878
  }
4879
+ if (hasField(objectName, "updated_by")) {
4880
+ record.updated_by = session.userId;
4881
+ }
4882
+ }
4883
+ if (isInsert && session?.tenantId && hasField(objectName, "tenant_id")) {
4884
+ record.tenant_id = record.tenant_id ?? session.tenantId;
3022
4885
  }
3023
- }, { object: "*", priority: 10 });
3024
- this.ql.registerHook("beforeUpdate", async (hookCtx) => {
3025
- if (hookCtx.session?.userId && hookCtx.input?.data) {
3026
- const data = hookCtx.input.data;
3027
- if (typeof data === "object" && data !== null) {
3028
- data.updated_by = hookCtx.session.userId;
3029
- data.updated_at = (/* @__PURE__ */ new Date()).toISOString();
4886
+ };
4887
+ const stampData = (data, objectName, session, isInsert) => {
4888
+ if (Array.isArray(data)) {
4889
+ for (const row of data) {
4890
+ if (row && typeof row === "object") {
4891
+ applyToRecord(row, objectName, session, isInsert);
4892
+ }
3030
4893
  }
4894
+ } else if (data && typeof data === "object") {
4895
+ applyToRecord(data, objectName, session, isInsert);
3031
4896
  }
3032
- }, { object: "*", priority: 10 });
3033
- this.ql.registerHook("beforeUpdate", async (hookCtx) => {
3034
- if (hookCtx.input?.id && !hookCtx.previous) {
3035
- try {
3036
- const existing = await this.ql.findOne(hookCtx.object, {
3037
- where: { id: hookCtx.input.id }
3038
- });
3039
- if (existing) {
3040
- hookCtx.previous = existing;
4897
+ };
4898
+ const builtinHooks = [
4899
+ {
4900
+ name: "sys_stamp_audit_insert",
4901
+ object: "*",
4902
+ events: ["beforeInsert"],
4903
+ priority: 10,
4904
+ description: "Auto-stamp created_by / updated_by / created_at / updated_at / tenant_id on insert (only when the field exists on the object schema)",
4905
+ handler: async (hookCtx) => {
4906
+ if (hookCtx.input?.data) {
4907
+ stampData(hookCtx.input.data, hookCtx.object, hookCtx.session, true);
4908
+ }
4909
+ }
4910
+ },
4911
+ {
4912
+ name: "sys_stamp_audit_update",
4913
+ object: "*",
4914
+ events: ["beforeUpdate"],
4915
+ priority: 10,
4916
+ description: "Auto-stamp updated_by / updated_at on update (only when the field exists on the object schema)",
4917
+ handler: async (hookCtx) => {
4918
+ if (hookCtx.input?.data) {
4919
+ stampData(hookCtx.input.data, hookCtx.object, hookCtx.session, false);
4920
+ }
4921
+ }
4922
+ },
4923
+ {
4924
+ name: "sys_fetch_previous_update",
4925
+ object: "*",
4926
+ events: ["beforeUpdate"],
4927
+ priority: 5,
4928
+ description: "Auto-fetch the previous record for update hooks",
4929
+ handler: async (hookCtx) => {
4930
+ if (hookCtx.input?.id && !hookCtx.previous) {
4931
+ try {
4932
+ const existing = await this.ql.findOne(hookCtx.object, {
4933
+ where: { id: hookCtx.input.id },
4934
+ context: {
4935
+ roles: [],
4936
+ permissions: [],
4937
+ isSystem: true,
4938
+ ...hookCtx.transaction ? { transaction: hookCtx.transaction } : {}
4939
+ }
4940
+ });
4941
+ if (existing) hookCtx.previous = existing;
4942
+ } catch (_e) {
4943
+ }
4944
+ }
4945
+ }
4946
+ },
4947
+ {
4948
+ name: "sys_fetch_previous_delete",
4949
+ object: "*",
4950
+ events: ["beforeDelete"],
4951
+ priority: 5,
4952
+ description: "Auto-fetch the previous record for delete hooks",
4953
+ handler: async (hookCtx) => {
4954
+ if (hookCtx.input?.id && !hookCtx.previous) {
4955
+ try {
4956
+ const existing = await this.ql.findOne(hookCtx.object, {
4957
+ where: { id: hookCtx.input.id },
4958
+ context: {
4959
+ roles: [],
4960
+ permissions: [],
4961
+ isSystem: true,
4962
+ ...hookCtx.transaction ? { transaction: hookCtx.transaction } : {}
4963
+ }
4964
+ });
4965
+ if (existing) hookCtx.previous = existing;
4966
+ } catch (_e) {
4967
+ }
3041
4968
  }
3042
- } catch (_e) {
3043
4969
  }
3044
4970
  }
3045
- }, { object: "*", priority: 5 });
3046
- this.ql.registerHook("beforeDelete", async (hookCtx) => {
3047
- if (hookCtx.input?.id && !hookCtx.previous) {
3048
- try {
3049
- const existing = await this.ql.findOne(hookCtx.object, {
3050
- where: { id: hookCtx.input.id }
4971
+ ];
4972
+ if (typeof this.ql.bindHooks === "function") {
4973
+ this.ql.bindHooks(builtinHooks, { packageId: "sys:audit" });
4974
+ } else {
4975
+ for (const h of builtinHooks) {
4976
+ for (const event of h.events) {
4977
+ this.ql.registerHook(event, h.handler, {
4978
+ object: h.object,
4979
+ priority: h.priority,
4980
+ packageId: "sys:audit"
3051
4981
  });
3052
- if (existing) {
3053
- hookCtx.previous = existing;
3054
- }
3055
- } catch (_e) {
3056
4982
  }
3057
4983
  }
3058
- }, { object: "*", priority: 5 });
3059
- ctx.logger.debug("Audit hooks registered (created_by/updated_by, previousData)");
4984
+ }
4985
+ ctx.logger.debug("Audit hooks registered via binder (created_by/updated_by, previousData)");
3060
4986
  }
3061
4987
  /**
3062
- * Register tenant isolation middleware that auto-injects tenant_id filter
3063
- * for multi-tenant operations.
4988
+ * Tenant isolation moved to `@objectstack/plugin-security`'s
4989
+ * `member_default` permission set RLS
4990
+ * (`organization_id = current_user.organization_id`, with
4991
+ * field-existence guards). The legacy `registerTenantMiddleware`
4992
+ * method was removed because it (a) collided with SecurityPlugin's
4993
+ * RLS pipeline and (b) blindly filtered tables that don't have a
4994
+ * `tenant_id` column (e.g. `sys_organization`), returning 0 rows
4995
+ * instead of all rows.
3064
4996
  */
3065
- registerTenantMiddleware(ctx) {
3066
- if (!this.ql) return;
3067
- this.ql.registerMiddleware(async (opCtx, next) => {
3068
- if (!opCtx.context?.tenantId || opCtx.context?.isSystem) {
3069
- return next();
3070
- }
3071
- if (["find", "findOne", "count", "aggregate"].includes(opCtx.operation)) {
3072
- if (opCtx.ast) {
3073
- const tenantFilter = { tenant_id: opCtx.context.tenantId };
3074
- if (opCtx.ast.where) {
3075
- opCtx.ast.where = { $and: [opCtx.ast.where, tenantFilter] };
3076
- } else {
3077
- opCtx.ast.where = tenantFilter;
3078
- }
3079
- }
3080
- }
3081
- await next();
3082
- });
3083
- ctx.logger.debug("Tenant isolation middleware registered");
3084
- }
3085
4997
  /**
3086
4998
  * Synchronize all registered object schemas to the database.
3087
4999
  *
@@ -3120,7 +5032,7 @@ var ObjectQLPlugin = class {
3120
5032
  skipped++;
3121
5033
  continue;
3122
5034
  }
3123
- const tableName = obj.tableName || obj.name;
5035
+ const tableName = StorageNameMapping2.resolveTableName(obj);
3124
5036
  let group = driverGroups.get(driver);
3125
5037
  if (!group) {
3126
5038
  group = [];
@@ -3277,13 +5189,20 @@ var ObjectQLPlugin = class {
3277
5189
  */
3278
5190
  async loadMetadataFromService(metadataService, ctx) {
3279
5191
  ctx.logger.info("Syncing metadata from external service into ObjectQL registry...");
3280
- const metadataTypes = ["object", "view", "app", "flow", "workflow", "function"];
5192
+ const metadataTypes = ["object", "view", "app", "flow", "workflow", "function", "hook"];
3281
5193
  let totalLoaded = 0;
3282
5194
  for (const type of metadataTypes) {
3283
5195
  try {
3284
5196
  if (typeof metadataService.loadMany === "function") {
3285
5197
  const items = await metadataService.loadMany(type);
3286
5198
  if (items && items.length > 0) {
5199
+ if (type === "function" && this.ql && typeof this.ql.registerFunction === "function") {
5200
+ for (const item of items) {
5201
+ if (item?.name && typeof item.handler === "function") {
5202
+ this.ql.registerFunction(item.name, item.handler, "metadata-service");
5203
+ }
5204
+ }
5205
+ }
3287
5206
  items.forEach((item) => {
3288
5207
  const keyField = item.id ? "id" : "name";
3289
5208
  if (type === "object" && this.ql) {
@@ -3293,6 +5212,11 @@ var ObjectQLPlugin = class {
3293
5212
  this.ql.registry.registerItem(type, item, keyField);
3294
5213
  }
3295
5214
  });
5215
+ if (type === "hook" && this.ql && typeof this.ql.bindHooks === "function") {
5216
+ this.ql.bindHooks(items, {
5217
+ packageId: "metadata-service"
5218
+ });
5219
+ }
3296
5220
  totalLoaded += items.length;
3297
5221
  ctx.logger.info(`Synced ${items.length} ${type}(s) from metadata service`);
3298
5222
  }
@@ -3408,6 +5332,7 @@ function convertIntrospectedSchemaToObjects(introspectedSchema, options) {
3408
5332
  export {
3409
5333
  DEFAULT_EXTENDER_PRIORITY,
3410
5334
  DEFAULT_OWNER_PRIORITY,
5335
+ InMemoryHookMetricsRecorder,
3411
5336
  MetadataFacade,
3412
5337
  ObjectQL,
3413
5338
  ObjectQLPlugin,
@@ -3416,10 +5341,18 @@ export {
3416
5341
  RESERVED_NAMESPACES,
3417
5342
  SchemaRegistry,
3418
5343
  ScopedContext,
5344
+ ValidationError,
5345
+ applyInMemoryAggregation,
5346
+ applySystemFields,
5347
+ bindHooksToEngine,
5348
+ bucketDateValue,
3419
5349
  computeFQN,
3420
5350
  convertIntrospectedSchemaToObjects,
3421
5351
  createObjectQLKernel,
5352
+ noopHookMetricsRecorder,
3422
5353
  parseFQN,
3423
- toTitleCase
5354
+ toTitleCase,
5355
+ validateRecord,
5356
+ wrapDeclarativeHook
3424
5357
  };
3425
5358
  //# sourceMappingURL=index.mjs.map