@objectstack/objectql 7.3.0 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -29,6 +29,8 @@ __export(index_exports, {
29
29
  ObjectRepository: () => ObjectRepository,
30
30
  ObjectStackProtocolImplementation: () => ObjectStackProtocolImplementation,
31
31
  RESERVED_NAMESPACES: () => RESERVED_NAMESPACES,
32
+ SECRET_MASK: () => SECRET_MASK,
33
+ SECRET_REF_PREFIX: () => SECRET_REF_PREFIX,
32
34
  SchemaRegistry: () => SchemaRegistry,
33
35
  ScopedContext: () => ScopedContext,
34
36
  SysMetadataRepository: () => SysMetadataRepository,
@@ -37,11 +39,18 @@ __export(index_exports, {
37
39
  applySystemFields: () => applySystemFields,
38
40
  bindHooksToEngine: () => bindHooksToEngine,
39
41
  bucketDateValue: () => bucketDateValue,
42
+ collectSecretFields: () => collectSecretFields,
40
43
  computeFQN: () => computeFQN,
41
44
  convertIntrospectedSchemaToObjects: () => convertIntrospectedSchemaToObjects,
42
45
  createObjectQLKernel: () => createObjectQLKernel,
46
+ evaluateValidationRules: () => evaluateValidationRules,
47
+ isSecretRef: () => isSecretRef,
48
+ legalNextStates: () => legalNextStates,
49
+ makeSecretRef: () => makeSecretRef,
50
+ needsPriorRecord: () => needsPriorRecord,
43
51
  noopHookMetricsRecorder: () => noopHookMetricsRecorder,
44
52
  parseFQN: () => parseFQN,
53
+ parseSecretRef: () => parseSecretRef,
45
54
  toTitleCase: () => toTitleCase,
46
55
  validateRecord: () => validateRecord,
47
56
  wrapDeclarativeHook: () => wrapDeclarativeHook
@@ -178,6 +187,14 @@ var SchemaRegistry = class {
178
187
  // ==========================================
179
188
  /** Type → Name/ID → MetadataItem */
180
189
  this.metadata = /* @__PURE__ */ new Map();
190
+ /**
191
+ * App name → navigation contributions (ADR-0029 D7).
192
+ *
193
+ * Lets packages inject nav items into apps they do not own (the UI analog
194
+ * of object extenders). Merged into the owning app's `navigation` tree on
195
+ * read in {@link getApp} / {@link getAllApps} by group id + priority.
196
+ */
197
+ this.appNavContributions = /* @__PURE__ */ new Map();
181
198
  /**
182
199
  * Package ids that must be installed in a DISABLED state. Seeded once at
183
200
  * boot (from persisted state) BEFORE any package registration so that every
@@ -405,6 +422,47 @@ var SchemaRegistry = class {
405
422
  const contributors = this.objectContributors.get(fqn);
406
423
  return contributors?.find((c) => c.ownership === "own");
407
424
  }
425
+ /**
426
+ * ADR-0029 K0 — assert every registered object resolves to exactly one
427
+ * owner.
428
+ *
429
+ * A second `own` from a different package is already rejected eagerly in
430
+ * {@link registerObject} (it throws). This is the install-time backstop
431
+ * called once all packages are registered (kernel bootstrap complete),
432
+ * and it additionally catches the case `registerObject` cannot: an object
433
+ * that has only `extend` contributions and **no owner** — which would
434
+ * otherwise resolve to nothing. Surfacing it here turns a silent
435
+ * "extend a non-existent object" into a clear bootstrap error.
436
+ *
437
+ * This is the invariant the kernel-decomposition (ADR-0029) relies on:
438
+ * the `sys` namespace is shared across many first-party plugins, but each
439
+ * object name has exactly one owner.
440
+ *
441
+ * @throws Error listing every object whose owner count is not exactly 1.
442
+ */
443
+ assertSingleOwnerPerObject() {
444
+ const violations = [];
445
+ for (const [fqn, contributors] of this.objectContributors.entries()) {
446
+ const owners = contributors.filter((c) => c.ownership === "own");
447
+ if (owners.length === 0) {
448
+ const extenders = contributors.map((c) => c.packageId).join(", ") || "(none)";
449
+ violations.push(
450
+ `Object "${fqn}" has no owner \u2014 only extend contributions from [${extenders}]. Exactly one package must register it with ownership 'own'.`
451
+ );
452
+ } else if (owners.length > 1) {
453
+ const names = owners.map((c) => c.packageId).join(", ");
454
+ violations.push(
455
+ `Object "${fqn}" has ${owners.length} owners [${names}] \u2014 exactly one is allowed.`
456
+ );
457
+ }
458
+ }
459
+ if (violations.length > 0) {
460
+ throw new Error(
461
+ `[Registry] single-owner-per-object check failed (ADR-0029):
462
+ ` + violations.join("\n ")
463
+ );
464
+ }
465
+ }
408
466
  /**
409
467
  * Unregister all objects contributed by a package.
410
468
  *
@@ -646,10 +704,88 @@ var SchemaRegistry = class {
646
704
  this.registerItem("app", app, "name", packageId);
647
705
  }
648
706
  getApp(name) {
649
- return this.getItem("app", name);
707
+ const app = this.getItem("app", name);
708
+ if (!app) return app;
709
+ return this.applyNavContributions(app);
650
710
  }
651
711
  getAllApps() {
652
- return this.listItems("app");
712
+ return this.listItems("app").map((app) => this.applyNavContributions(app));
713
+ }
714
+ // ==========================================
715
+ // App navigation contributions (ADR-0029 D7)
716
+ // ==========================================
717
+ /**
718
+ * Register a navigation contribution — a package injecting nav items into
719
+ * an app it does not own (the UI-layer analog of object `extend`).
720
+ *
721
+ * Contributions are merged into the target app's `navigation` tree lazily
722
+ * on read ({@link getApp} / {@link getAllApps}) by group id + priority, so
723
+ * registration order does not matter and the owning app can be registered
724
+ * before or after its contributors.
725
+ */
726
+ registerAppNavContribution(contribution, packageId) {
727
+ if (!contribution || !contribution.app) return;
728
+ const list = this.appNavContributions.get(contribution.app) ?? [];
729
+ list.push({
730
+ packageId,
731
+ group: contribution.group,
732
+ priority: contribution.priority ?? 200,
733
+ items: Array.isArray(contribution.items) ? contribution.items : []
734
+ });
735
+ this.appNavContributions.set(contribution.app, list);
736
+ this.log(
737
+ `[Registry] Navigation contribution: ${packageId ?? "(unknown)"} -> ${contribution.app}` + (contribution.group ? `/${contribution.group}` : "") + ` (${list[list.length - 1].items.length} items)`
738
+ );
739
+ }
740
+ /** Contributions registered for an app (empty array when none). */
741
+ getAppNavContributions(appName) {
742
+ return this.appNavContributions.get(appName) ?? [];
743
+ }
744
+ /**
745
+ * Return a copy of `app` with all registered navigation contributions
746
+ * merged into its `navigation` tree. The stored app is never mutated, so
747
+ * repeated reads stay idempotent.
748
+ *
749
+ * Public so the protocol serving path (`getMetaItems` / `getMetaItem` for
750
+ * `app`) can merge contributions the same way `getApp` / `getAllApps` do —
751
+ * the REST app endpoints read through the protocol, not these helpers, so
752
+ * the merge must be reachable from there too (ADR-0029 D7).
753
+ */
754
+ applyNavContributions(app) {
755
+ const contributions = this.appNavContributions.get(app?.name);
756
+ if (!contributions || contributions.length === 0) return app;
757
+ const cloned = structuredClone(app);
758
+ const nav = Array.isArray(cloned.navigation) ? cloned.navigation : cloned.navigation = [];
759
+ const sorted = [...contributions].sort((a, b) => a.priority - b.priority);
760
+ for (const c of sorted) {
761
+ if (!c.items.length) continue;
762
+ if (c.group) {
763
+ const group = this.findNavGroup(nav, c.group);
764
+ if (group) {
765
+ if (!Array.isArray(group.children)) group.children = [];
766
+ group.children.push(...c.items);
767
+ } else {
768
+ this.log(
769
+ `[Registry] Navigation contribution from "${c.packageId ?? "(unknown)"}" targets missing group "${c.group}" in app "${app.name}" \u2014 appending at top level.`
770
+ );
771
+ nav.push(...c.items);
772
+ }
773
+ } else {
774
+ nav.push(...c.items);
775
+ }
776
+ }
777
+ return cloned;
778
+ }
779
+ /** Depth-first search for a `type: 'group'` nav item by id. */
780
+ findNavGroup(items, groupId) {
781
+ for (const item of items) {
782
+ if (item && item.id === groupId && item.type === "group") return item;
783
+ if (item && Array.isArray(item.children)) {
784
+ const found = this.findNavGroup(item.children, groupId);
785
+ if (found) return found;
786
+ }
787
+ }
788
+ return void 0;
653
789
  }
654
790
  // ==========================================
655
791
  // Plugin Helpers
@@ -708,6 +844,7 @@ var SchemaRegistry = class {
708
844
  this.mergedObjectCache.clear();
709
845
  this.namespaceRegistry.clear();
710
846
  this.metadata.clear();
847
+ this.appNavContributions.clear();
711
848
  this.log("[Registry] Reset complete");
712
849
  }
713
850
  };
@@ -1399,6 +1536,7 @@ var SysMetadataRepository = class {
1399
1536
  var import_metadata_core2 = require("@objectstack/metadata-core");
1400
1537
  var import_data2 = require("@objectstack/spec/data");
1401
1538
  var import_shared4 = require("@objectstack/spec/shared");
1539
+ var import_ui2 = require("@objectstack/spec/ui");
1402
1540
  var import_system = require("@objectstack/spec/system");
1403
1541
  var import_kernel4 = require("@objectstack/spec/kernel");
1404
1542
  var import_kernel5 = require("@objectstack/spec/kernel");
@@ -2093,6 +2231,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2093
2231
  const zodSchema = (0, import_kernel4.getMetadataTypeSchema)(singular);
2094
2232
  const schema = (zodSchema ? toJsonSchemaSafe(zodSchema) : void 0) ?? HAND_CRAFTED_SCHEMAS[singular];
2095
2233
  const form = TYPE_TO_FORM[singular];
2234
+ const typeActions = (0, import_kernel4.getMetadataTypeActions)(singular);
2096
2235
  const base = registryByType.get(singular);
2097
2236
  if (base) {
2098
2237
  const isEnvOverridden = writableOverrides.has(singular);
@@ -2104,7 +2243,11 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2104
2243
  allowOrgOverride: base.allowOrgOverride || isEnvOverridden,
2105
2244
  overrideSource: isEnvOverridden && !base.allowOrgOverride ? "env" : "registry",
2106
2245
  schema,
2107
- form
2246
+ form,
2247
+ // Override the spread `base.actions` with the merged view
2248
+ // (declarative + plugin-registered). Omit when empty to
2249
+ // preserve the prior "no actions key" response shape.
2250
+ ...typeActions.length ? { actions: typeActions } : {}
2108
2251
  };
2109
2252
  }
2110
2253
  return {
@@ -2123,7 +2266,9 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2123
2266
  domain: "system",
2124
2267
  overrideSource: writableOverrides.has(singular) ? "env" : "registry",
2125
2268
  schema,
2126
- form
2269
+ form,
2270
+ // Plugin-registered actions on a type with no registry entry.
2271
+ ...typeActions.length ? { actions: typeActions } : {}
2127
2272
  };
2128
2273
  }).sort((a, b) => {
2129
2274
  if (a.domain !== b.domain) return a.domain.localeCompare(b.domain);
@@ -2298,6 +2443,12 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2298
2443
  (it) => !this.engine.registry.isPackageDisabled(it?._packageId)
2299
2444
  );
2300
2445
  }
2446
+ if (request.type === "view" || request.type === "views") {
2447
+ items = items.filter((it) => !(0, import_ui2.isAggregatedViewContainer)(it));
2448
+ }
2449
+ if (request.type === "app" || request.type === "apps") {
2450
+ items = items.map((app) => this.engine.registry.applyNavContributions(app));
2451
+ }
2301
2452
  return {
2302
2453
  type: request.type,
2303
2454
  items: decorateMetadataItems(
@@ -2387,6 +2538,9 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2387
2538
  if (alt) item = this.engine.registry.getItem(alt, request.name);
2388
2539
  }
2389
2540
  }
2541
+ if ((request.type === "app" || request.type === "apps") && item) {
2542
+ item = this.engine.registry.applyNavContributions(item);
2543
+ }
2390
2544
  const artifactItem = this.lookupArtifactItem(request.type, request.name);
2391
2545
  const decorated = decorateMetadataItem(
2392
2546
  request.type,
@@ -3145,7 +3299,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3145
3299
  throw new Error(`Metadata item ${request.type}/${request.name} not found`);
3146
3300
  }
3147
3301
  const content = JSON.stringify(item);
3148
- const hash = simpleHash(content);
3302
+ const hash = simpleHash(request.locale ? `${request.locale}\0${content}` : content);
3149
3303
  const etag = { value: hash, weak: false };
3150
3304
  if (request.cacheRequest?.ifNoneMatch) {
3151
3305
  const clientEtag = request.cacheRequest.ifNoneMatch.replace(/^"(.*)"$/, "$1").replace(/^W\/"(.*)"$/, "$1");
@@ -4597,8 +4751,33 @@ var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
4597
4751
  var import_kernel6 = require("@objectstack/spec/kernel");
4598
4752
  var import_core = require("@objectstack/core");
4599
4753
  var import_system2 = require("@objectstack/spec/system");
4754
+
4755
+ // src/secret-fields.ts
4756
+ var SECRET_REF_PREFIX = "secret:";
4757
+ var SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
4758
+ function makeSecretRef(handleId) {
4759
+ return `${SECRET_REF_PREFIX}${handleId}`;
4760
+ }
4761
+ function isSecretRef(value) {
4762
+ return typeof value === "string" && value.startsWith(SECRET_REF_PREFIX);
4763
+ }
4764
+ function parseSecretRef(value) {
4765
+ return isSecretRef(value) ? value.slice(SECRET_REF_PREFIX.length) : null;
4766
+ }
4767
+ function collectSecretFields(schema) {
4768
+ const fields = schema?.fields;
4769
+ if (!fields) return [];
4770
+ const out = [];
4771
+ for (const [name, def] of Object.entries(fields)) {
4772
+ if (def && def.type === "secret") out.push(name);
4773
+ }
4774
+ return out;
4775
+ }
4776
+
4777
+ // src/engine.ts
4600
4778
  var import_shared5 = require("@objectstack/spec/shared");
4601
- var import_formula2 = require("@objectstack/formula");
4779
+ var import_formula3 = require("@objectstack/formula");
4780
+ var import_spec = require("@objectstack/spec");
4602
4781
 
4603
4782
  // src/hook-wrappers.ts
4604
4783
  var import_formula = require("@objectstack/formula");
@@ -5152,6 +5331,101 @@ function validateRecord(objectSchema, data, mode) {
5152
5331
  if (errors.length > 0) throw new ValidationError(errors);
5153
5332
  }
5154
5333
 
5334
+ // src/validation/rule-validator.ts
5335
+ var import_formula2 = require("@objectstack/formula");
5336
+ function needsPriorRecord(objectSchema) {
5337
+ const rules = objectSchema?.validations;
5338
+ if (!Array.isArray(rules)) return false;
5339
+ return rules.some(
5340
+ (r) => r != null && typeof r === "object" && (r.type === "state_machine" || r.type === "cross_field" || r.type === "script")
5341
+ );
5342
+ }
5343
+ function toExpression(cond) {
5344
+ return typeof cond === "string" ? { dialect: "cel", source: cond } : cond;
5345
+ }
5346
+ function evaluateValidationRules(objectSchema, data, mode, opts = {}) {
5347
+ const rules = objectSchema?.validations;
5348
+ if (!Array.isArray(rules) || rules.length === 0 || !data) return;
5349
+ const previous = opts.previous ?? void 0;
5350
+ const merged = { ...previous ?? {}, ...data };
5351
+ const errors = [];
5352
+ const ordered = rules.filter((r) => r != null && typeof r === "object").filter((r) => r.active !== false).filter((r) => {
5353
+ const events = r.events ?? ["insert", "update"];
5354
+ return events.includes(mode);
5355
+ }).sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
5356
+ for (const rule of ordered) {
5357
+ let violation = null;
5358
+ try {
5359
+ if (rule.type === "state_machine") {
5360
+ violation = checkStateMachine(rule, mode, data, previous);
5361
+ } else if (rule.type === "script" || rule.type === "cross_field") {
5362
+ violation = checkPredicate(rule, merged, previous, opts.logger);
5363
+ }
5364
+ } catch (err) {
5365
+ opts.logger?.warn?.(`Validation rule '${rule.name}' threw \u2014 skipped`, err);
5366
+ continue;
5367
+ }
5368
+ if (!violation) continue;
5369
+ const severity = rule.severity ?? "error";
5370
+ if (severity === "error") {
5371
+ errors.push(violation);
5372
+ } else {
5373
+ opts.logger?.warn?.(
5374
+ `Validation rule '${rule.name}' (${severity}): ${violation.message}`
5375
+ );
5376
+ }
5377
+ }
5378
+ if (errors.length > 0) throw new ValidationError(errors);
5379
+ }
5380
+ function checkStateMachine(rule, mode, data, previous) {
5381
+ if (mode === "insert" || !previous) return null;
5382
+ if (!(rule.field in data)) return null;
5383
+ const from = previous[rule.field];
5384
+ const to = data[rule.field];
5385
+ if (from === to || to === void 0 || to === null) return null;
5386
+ const fromKey = String(from);
5387
+ const allowed = rule.transitions[fromKey];
5388
+ if (!Array.isArray(allowed)) return null;
5389
+ if (!allowed.includes(String(to))) {
5390
+ return {
5391
+ field: rule.field,
5392
+ code: "invalid_transition",
5393
+ message: rule.message || `Invalid transition for ${rule.field}: ${fromKey} \u2192 ${String(to)}`
5394
+ };
5395
+ }
5396
+ return null;
5397
+ }
5398
+ function checkPredicate(rule, record, previous, logger) {
5399
+ const expr = toExpression(rule.condition);
5400
+ const result = import_formula2.ExpressionEngine.evaluate(expr, {
5401
+ record,
5402
+ previous: previous ?? void 0
5403
+ });
5404
+ if (!result.ok) {
5405
+ logger?.warn?.(
5406
+ `Validation rule '${rule.name}' predicate failed to evaluate (${result.error.kind}: ${result.error.message}) \u2014 skipped`
5407
+ );
5408
+ return null;
5409
+ }
5410
+ if (result.value === true) {
5411
+ return {
5412
+ field: rule.fields?.[0] ?? "_record",
5413
+ code: "rule_violation",
5414
+ message: rule.message
5415
+ };
5416
+ }
5417
+ return null;
5418
+ }
5419
+ function legalNextStates(objectSchema, field, currentState) {
5420
+ const rules = objectSchema?.validations;
5421
+ if (!Array.isArray(rules)) return null;
5422
+ const rule = rules.find(
5423
+ (r) => r != null && typeof r === "object" && r.type === "state_machine" && r.field === field
5424
+ );
5425
+ if (!rule) return null;
5426
+ return rule.transitions[currentState] ?? [];
5427
+ }
5428
+
5155
5429
  // src/in-memory-aggregation.ts
5156
5430
  function applyInMemoryAggregation(rows, ast) {
5157
5431
  const groupBy = ast.groupBy ?? [];
@@ -5309,7 +5583,7 @@ function planFormulaProjection(schema, requestedFields) {
5309
5583
  if (def?.type === "formula" && def.expression) {
5310
5584
  const expr = typeof def.expression === "string" ? { dialect: "cel", source: def.expression } : def.expression;
5311
5585
  plan.push({ name: f, expression: expr });
5312
- import_formula2.ExpressionEngine.compile(expr);
5586
+ import_formula3.ExpressionEngine.compile(expr);
5313
5587
  } else if (Array.isArray(requestedFields) && requestedFields.length > 0) {
5314
5588
  projected.add(f);
5315
5589
  }
@@ -5331,7 +5605,7 @@ function applyFormulaPlan(plan, records) {
5331
5605
  for (const rec of records) {
5332
5606
  if (rec == null) continue;
5333
5607
  for (const fp of plan) {
5334
- const r = import_formula2.ExpressionEngine.evaluate(fp.expression, { record: rec });
5608
+ const r = import_formula3.ExpressionEngine.evaluate(fp.expression, { record: rec });
5335
5609
  rec[fp.name] = r.ok ? r.value : null;
5336
5610
  }
5337
5611
  }
@@ -5341,7 +5615,7 @@ function resolveMetadataItemName(key, item) {
5341
5615
  if (item.name) return item.name;
5342
5616
  if (item.id) return item.id;
5343
5617
  if (key === "views") {
5344
- return item?.list?.data?.object || item?.form?.data?.object || void 0;
5618
+ return item?.object || item?.list?.data?.object || item?.form?.data?.object || void 0;
5345
5619
  }
5346
5620
  return void 0;
5347
5621
  }
@@ -5353,6 +5627,11 @@ var _ObjectQL = class _ObjectQL {
5353
5627
  this.datasourceMapping = [];
5354
5628
  // Package manifests registry (for defaultDatasource lookup)
5355
5629
  this.manifests = /* @__PURE__ */ new Map();
5630
+ // Datasource definitions by name (ADR-0015): carries schemaMode +
5631
+ // external.allowWrites so the write gate (Gate 3) can enforce federation
5632
+ // ownership. Populated from manifests in registerApp and via
5633
+ // registerDatasourceDef. Absent entry ⇒ treated as managed (default DB).
5634
+ this.datasourceDefs = /* @__PURE__ */ new Map();
5356
5635
  // Per-object hooks with priority support
5357
5636
  this.hooks = /* @__PURE__ */ new Map([
5358
5637
  ["beforeFind", []],
@@ -5735,7 +6014,7 @@ var _ObjectQL = class _ObjectQL {
5735
6014
  if (f.defaultValue == null) continue;
5736
6015
  const dv = f.defaultValue;
5737
6016
  if (typeof dv === "object" && dv !== null && dv.dialect && typeof dv.source === "string") {
5738
- const result = import_formula2.ExpressionEngine.evaluate(dv, {
6017
+ const result = import_formula3.ExpressionEngine.evaluate(dv, {
5739
6018
  now,
5740
6019
  user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : void 0,
5741
6020
  org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : void 0,
@@ -5775,6 +6054,12 @@ var _ObjectQL = class _ObjectQL {
5775
6054
  if (id) {
5776
6055
  this.manifests.set(id, manifest);
5777
6056
  }
6057
+ if (manifest.datasources) {
6058
+ const dsList = Array.isArray(manifest.datasources) ? manifest.datasources : Object.entries(manifest.datasources).map(([name, def]) => ({ name, ...def }));
6059
+ for (const ds of dsList) {
6060
+ if (ds?.name) this.registerDatasourceDef(ds);
6061
+ }
6062
+ }
5778
6063
  this._registry.installPackage(manifest);
5779
6064
  this.logger.debug("Installed Package", { id: manifest.id, name: manifest.name, namespace });
5780
6065
  if (manifest.objects) {
@@ -5828,6 +6113,15 @@ var _ObjectQL = class _ObjectQL {
5828
6113
  this._registry.registerApp(resolved, id);
5829
6114
  this.logger.debug("Registered manifest-as-app", { app: manifest.name, from: id });
5830
6115
  }
6116
+ if (Array.isArray(manifest.navigationContributions) && manifest.navigationContributions.length > 0) {
6117
+ for (const contribution of manifest.navigationContributions) {
6118
+ this._registry.registerAppNavContribution(contribution, id);
6119
+ }
6120
+ this.logger.debug("Registered navigation contributions", {
6121
+ from: id,
6122
+ count: manifest.navigationContributions.length
6123
+ });
6124
+ }
5831
6125
  const metadataArrayKeys = [
5832
6126
  // UI Protocol
5833
6127
  "actions",
@@ -5871,6 +6165,11 @@ var _ObjectQL = class _ObjectQL {
5871
6165
  if (itemName) {
5872
6166
  const toRegister = item.name === itemName ? item : { ...item, name: itemName };
5873
6167
  this._registry.registerItem((0, import_shared5.pluralToSingular)(key), toRegister, "name", id);
6168
+ if (key === "views" && (0, import_spec.isAggregatedViewContainer)(toRegister)) {
6169
+ for (const vi of (0, import_spec.expandViewContainer)(itemName, toRegister)) {
6170
+ this._registry.registerItem("view", vi, "name", id);
6171
+ }
6172
+ }
5874
6173
  } else {
5875
6174
  this.logger.warn(`Skipping ${(0, import_shared5.pluralToSingular)(key)} without a derivable name`, { id });
5876
6175
  }
@@ -6023,6 +6322,41 @@ var _ObjectQL = class _ObjectQL {
6023
6322
  this.logger.info("Set default driver", { driverName: driver.name });
6024
6323
  }
6025
6324
  }
6325
+ /**
6326
+ * Register a Datasource *definition* (ADR-0015).
6327
+ *
6328
+ * Distinct from {@link registerDriver}, which registers a live connection.
6329
+ * This captures the declarative `schemaMode` + `external.allowWrites` so the
6330
+ * write gate ({@link assertWriteAllowed}) can enforce external-datasource
6331
+ * ownership. Safe to call repeatedly; last write wins.
6332
+ */
6333
+ registerDatasourceDef(def) {
6334
+ if (!def?.name) return;
6335
+ this.datasourceDefs.set(def.name, { schemaMode: def.schemaMode, external: def.external });
6336
+ }
6337
+ /**
6338
+ * Write gate — Gate 3 of ADR-0015 §5.3.
6339
+ *
6340
+ * Blocks insert/update/delete against a federated datasource
6341
+ * (`schemaMode !== 'managed'`) unless BOTH the datasource opts in
6342
+ * (`external.allowWrites`) AND the object opts in (`external.writable`).
6343
+ * Managed datasources (the common case, including the absence of any
6344
+ * definition) are unaffected.
6345
+ */
6346
+ assertWriteAllowed(objectName, operation) {
6347
+ const object = this._registry.getObject(objectName);
6348
+ const dsName = object?.datasource;
6349
+ if (!dsName || dsName === "default") return;
6350
+ const ds = this.datasourceDefs.get(dsName);
6351
+ if (!ds || !ds.schemaMode || ds.schemaMode === "managed") return;
6352
+ const dsAllows = ds.external?.allowWrites ?? false;
6353
+ const objAllows = object?.external?.writable ?? false;
6354
+ if (!(dsAllows && objAllows)) {
6355
+ throw new import_shared5.ExternalWriteForbiddenError(
6356
+ `Write '${operation}' blocked on object '${objectName}': datasource '${dsName}' is external (schemaMode=${ds.schemaMode}). Requires datasource.external.allowWrites=true (got ${dsAllows}) AND object.external.writable=true (got ${objAllows}).`
6357
+ );
6358
+ }
6359
+ }
6026
6360
  /**
6027
6361
  * Set the realtime service for publishing data change events.
6028
6362
  * Should be called after kernel resolves the realtime service.
@@ -6033,6 +6367,141 @@ var _ObjectQL = class _ObjectQL {
6033
6367
  this.realtimeService = service;
6034
6368
  this.logger.info("RealtimeService configured for data events");
6035
6369
  }
6370
+ /**
6371
+ * Register the crypto provider that backs `secret`-typed fields.
6372
+ *
6373
+ * When set, the engine encrypts secret fields on write (storing ciphertext in
6374
+ * `sys_secret` and only an opaque ref on the business row) and masks them on
6375
+ * read. When NOT set, writing to an object that declares a secret field is
6376
+ * **fail-closed** — the write throws rather than persist cleartext.
6377
+ *
6378
+ * Mirrors the Settings subsystem's ICryptoProvider wiring; the host (e.g.
6379
+ * `serve`) injects `InMemoryCryptoProvider` in dev and a KMS/Vault-backed
6380
+ * provider in production.
6381
+ */
6382
+ setCryptoProvider(provider) {
6383
+ this.cryptoProvider = provider;
6384
+ this.logger.info("CryptoProvider configured for secret fields");
6385
+ }
6386
+ /**
6387
+ * Encrypt any `secret`-typed fields on `row` in place before it reaches the
6388
+ * driver. Each plaintext is wrapped by the ICryptoProvider, persisted as a
6389
+ * `sys_secret` row, and replaced on `row` by an opaque ref. Cleartext never
6390
+ * reaches the business table.
6391
+ *
6392
+ * Rules:
6393
+ * - No secret fields on the object ⇒ no-op (fast path, no crypto cost).
6394
+ * - `null`/`undefined` value ⇒ left as-is (clears the secret).
6395
+ * - Value already a ref (re-save of an unchanged ref) ⇒ left as-is.
6396
+ * - Value equal to the read mask ⇒ dropped, so a form round-trip that
6397
+ * echoes the mask does not overwrite the stored secret.
6398
+ * - **Fail-closed:** any other value with no CryptoProvider registered, or
6399
+ * no reachable `sys_secret` store, THROWS — never persists cleartext.
6400
+ */
6401
+ async encryptSecretFields(object, row, context, driverOptions) {
6402
+ if (!row || typeof row !== "object") return;
6403
+ const schema = this._registry.getObject(object);
6404
+ const secretFields = collectSecretFields(schema);
6405
+ if (secretFields.length === 0) return;
6406
+ for (const field of secretFields) {
6407
+ if (!(field in row)) continue;
6408
+ const value = row[field];
6409
+ if (value === null || typeof value === "undefined") continue;
6410
+ if (isSecretRef(value)) continue;
6411
+ if (value === SECRET_MASK) {
6412
+ delete row[field];
6413
+ continue;
6414
+ }
6415
+ if (!this.cryptoProvider) {
6416
+ throw new Error(
6417
+ `Cannot persist secret field "${object}.${field}": no CryptoProvider is registered. Wire one via engine.setCryptoProvider(...) (e.g. InMemoryCryptoProvider in dev, a KMS/Vault provider in production). Refusing to store cleartext (fail-closed).`
6418
+ );
6419
+ }
6420
+ const plain = typeof value === "string" ? value : JSON.stringify(value);
6421
+ const handle = await this.cryptoProvider.encrypt(plain, {
6422
+ namespace: object,
6423
+ key: field,
6424
+ tenantId: context?.tenantId
6425
+ });
6426
+ let secretDriver;
6427
+ try {
6428
+ secretDriver = this.getDriver("sys_secret");
6429
+ } catch {
6430
+ throw new Error(
6431
+ `Cannot persist secret field "${object}.${field}": the sys_secret store is not available. Ensure the platform-objects (sys_secret) are registered before writing secret fields (fail-closed).`
6432
+ );
6433
+ }
6434
+ await secretDriver.create(
6435
+ "sys_secret",
6436
+ {
6437
+ id: handle.id,
6438
+ namespace: object,
6439
+ key: field,
6440
+ kms_key_id: handle.kmsKeyId,
6441
+ alg: handle.alg,
6442
+ version: handle.version,
6443
+ ciphertext: handle.ciphertext,
6444
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
6445
+ },
6446
+ driverOptions
6447
+ );
6448
+ row[field] = makeSecretRef(handle.id);
6449
+ }
6450
+ }
6451
+ /**
6452
+ * Mask `secret`-typed fields on read so plaintext never leaves the engine
6453
+ * through the normal query path. A set secret becomes {@link SECRET_MASK};
6454
+ * an unset one stays `null`. Privileged callers that genuinely need the
6455
+ * plaintext use {@link resolveSecret} against the stored ref.
6456
+ */
6457
+ maskSecretFields(object, rows) {
6458
+ if (!rows) return;
6459
+ const schema = this._registry.getObject(object);
6460
+ const secretFields = collectSecretFields(schema);
6461
+ if (secretFields.length === 0) return;
6462
+ const list = Array.isArray(rows) ? rows : [rows];
6463
+ for (const row of list) {
6464
+ if (!row || typeof row !== "object") continue;
6465
+ for (const field of secretFields) {
6466
+ if (!(field in row)) continue;
6467
+ row[field] = row[field] == null ? null : SECRET_MASK;
6468
+ }
6469
+ }
6470
+ }
6471
+ /**
6472
+ * Dereference a stored secret ref back to its plaintext. Intended for
6473
+ * privileged, server-side consumers (e.g. a datasource connection-pool
6474
+ * binder) — NOT exposed through the generic read path, which only ever
6475
+ * returns the mask.
6476
+ *
6477
+ * Fail-closed: throws when no CryptoProvider is registered or the
6478
+ * `sys_secret` row is missing. Returns `null` when `ref` is not a secret ref.
6479
+ */
6480
+ async resolveSecret(ref, opts) {
6481
+ const id = parseSecretRef(ref);
6482
+ if (!id) return null;
6483
+ if (!this.cryptoProvider) {
6484
+ throw new Error("Cannot resolve secret: no CryptoProvider is registered (fail-closed).");
6485
+ }
6486
+ const secretDriver = this.getDriver("sys_secret");
6487
+ const found = await secretDriver.find("sys_secret", { object: "sys_secret", where: { id } });
6488
+ const secret = Array.isArray(found) ? found[0] : found;
6489
+ if (!secret) {
6490
+ throw new Error(`Cannot resolve secret: sys_secret row "${id}" not found (fail-closed).`);
6491
+ }
6492
+ const handle = {
6493
+ id: secret.id,
6494
+ kmsKeyId: secret.kms_key_id,
6495
+ alg: secret.alg,
6496
+ version: secret.version,
6497
+ ciphertext: secret.ciphertext
6498
+ };
6499
+ return this.cryptoProvider.decrypt(handle, {
6500
+ namespace: secret.namespace,
6501
+ key: secret.key,
6502
+ tenantId: opts?.tenantId
6503
+ });
6504
+ }
6036
6505
  /**
6037
6506
  * Helper to get object definition
6038
6507
  */
@@ -6325,6 +6794,7 @@ var _ObjectQL = class _ObjectQL {
6325
6794
  hookContext.event = "afterFind";
6326
6795
  hookContext.result = result;
6327
6796
  await this.triggerHooks("afterFind", hookContext);
6797
+ this.maskSecretFields(object, hookContext.result);
6328
6798
  return hookContext.result;
6329
6799
  } catch (e) {
6330
6800
  this.logger.error("Find operation failed", e, { object });
@@ -6366,6 +6836,7 @@ var _ObjectQL = class _ObjectQL {
6366
6836
  const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context);
6367
6837
  result = expanded[0];
6368
6838
  }
6839
+ this.maskSecretFields(objectName, result);
6369
6840
  return result;
6370
6841
  });
6371
6842
  return opCtx.result;
@@ -6373,6 +6844,7 @@ var _ObjectQL = class _ObjectQL {
6373
6844
  async insert(object, data, options) {
6374
6845
  object = this.resolveObjectName(object);
6375
6846
  this.logger.debug("Insert operation starting", { object, isBatch: Array.isArray(data) });
6847
+ this.assertWriteAllowed(object, "insert");
6376
6848
  const driver = this.getDriver(object);
6377
6849
  const opCtx = {
6378
6850
  object,
@@ -6401,7 +6873,13 @@ var _ObjectQL = class _ObjectQL {
6401
6873
  const rows = hookContext.input.data.map(
6402
6874
  (row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
6403
6875
  );
6404
- for (const r of rows) validateRecord(schemaForValidation, r, "insert");
6876
+ for (const r of rows) {
6877
+ await this.encryptSecretFields(object, r, opCtx.context, hookContext.input.options);
6878
+ }
6879
+ for (const r of rows) {
6880
+ validateRecord(schemaForValidation, r, "insert");
6881
+ evaluateValidationRules(schemaForValidation, r, "insert", { logger: this.logger });
6882
+ }
6405
6883
  if (driver.bulkCreate) {
6406
6884
  result = await driver.bulkCreate(object, rows, hookContext.input.options);
6407
6885
  } else {
@@ -6414,7 +6892,9 @@ var _ObjectQL = class _ObjectQL {
6414
6892
  opCtx.context,
6415
6893
  nowSnap
6416
6894
  );
6895
+ await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options);
6417
6896
  validateRecord(schemaForValidation, row, "insert");
6897
+ evaluateValidationRules(schemaForValidation, row, "insert", { logger: this.logger });
6418
6898
  result = await driver.create(object, row, hookContext.input.options);
6419
6899
  }
6420
6900
  hookContext.event = "afterInsert";
@@ -6464,6 +6944,7 @@ var _ObjectQL = class _ObjectQL {
6464
6944
  async update(object, data, options) {
6465
6945
  object = this.resolveObjectName(object);
6466
6946
  this.logger.debug("Update operation starting", { object });
6947
+ this.assertWriteAllowed(object, "update");
6467
6948
  const driver = this.getDriver(object);
6468
6949
  let id = data.id;
6469
6950
  if (!id && options?.where && typeof options.where === "object" && "id" in options.where) {
@@ -6490,11 +6971,23 @@ var _ObjectQL = class _ObjectQL {
6490
6971
  hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
6491
6972
  try {
6492
6973
  let result;
6974
+ let priorRecord = null;
6975
+ const updateSchema = this._registry.getObject(object);
6493
6976
  if (hookContext.input.id) {
6494
- validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
6977
+ await this.encryptSecretFields(object, hookContext.input.data, opCtx.context, hookContext.input.options);
6978
+ validateRecord(updateSchema, hookContext.input.data, "update");
6979
+ if (needsPriorRecord(updateSchema) || (this.hooks.get("afterUpdate")?.length ?? 0) > 0) {
6980
+ const priorAst = { object, where: { id: hookContext.input.id }, limit: 1 };
6981
+ priorRecord = await driver.findOne(object, priorAst, hookContext.input.options);
6982
+ }
6983
+ evaluateValidationRules(updateSchema, hookContext.input.data, "update", { previous: priorRecord, logger: this.logger });
6495
6984
  result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
6496
6985
  } else if (options?.multi && driver.updateMany) {
6497
- validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
6986
+ await this.encryptSecretFields(object, hookContext.input.data, opCtx.context, hookContext.input.options);
6987
+ validateRecord(updateSchema, hookContext.input.data, "update");
6988
+ if (needsPriorRecord(updateSchema)) {
6989
+ this.logger.warn("Object-level validation rules (state_machine/cross_field/script) are not enforced on multi-row updates", { object });
6990
+ }
6498
6991
  const ast = { object, where: options.where };
6499
6992
  result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options);
6500
6993
  } else {
@@ -6502,6 +6995,7 @@ var _ObjectQL = class _ObjectQL {
6502
6995
  }
6503
6996
  hookContext.event = "afterUpdate";
6504
6997
  hookContext.result = result;
6998
+ if (priorRecord) hookContext.previous = priorRecord;
6505
6999
  await this.triggerHooks("afterUpdate", hookContext);
6506
7000
  if (this.realtimeService) {
6507
7001
  try {
@@ -6534,6 +7028,7 @@ var _ObjectQL = class _ObjectQL {
6534
7028
  async delete(object, options) {
6535
7029
  object = this.resolveObjectName(object);
6536
7030
  this.logger.debug("Delete operation starting", { object });
7031
+ this.assertWriteAllowed(object, "delete");
6537
7032
  const driver = this.getDriver(object);
6538
7033
  let id = void 0;
6539
7034
  if (options?.where && typeof options.where === "object" && "id" in options.where) {
@@ -6793,6 +7288,26 @@ var _ObjectQL = class _ObjectQL {
6793
7288
  getDriverByName(name) {
6794
7289
  return this.drivers.get(name);
6795
7290
  }
7291
+ /**
7292
+ * Introspect a datasource's live remote schema (ADR-0015).
7293
+ *
7294
+ * Resolves the driver registered under `datasource` and delegates to its
7295
+ * `introspectSchema()` capability. Used by the external-datasource service
7296
+ * (and CLI/REST) to list remote tables and validate federated objects.
7297
+ *
7298
+ * @throws if the datasource has no registered driver, or the driver does
7299
+ * not support introspection.
7300
+ */
7301
+ async introspectDatasource(datasource) {
7302
+ const driver = this.drivers.get(datasource);
7303
+ if (!driver) {
7304
+ throw new Error(`[ObjectQL] Datasource '${datasource}' has no registered driver to introspect.`);
7305
+ }
7306
+ if (typeof driver.introspectSchema !== "function") {
7307
+ throw new Error(`[ObjectQL] Driver for datasource '${datasource}' does not support introspectSchema().`);
7308
+ }
7309
+ return driver.introspectSchema();
7310
+ }
6796
7311
  /**
6797
7312
  * Get the driver responsible for the given object.
6798
7313
  *
@@ -7726,7 +8241,7 @@ var ObjectQLPlugin = class {
7726
8241
  */
7727
8242
  async loadMetadataFromService(metadataService, ctx) {
7728
8243
  ctx.logger.info("Syncing metadata from external service into ObjectQL registry...");
7729
- const metadataTypes = ["object", "view", "app", "flow", "workflow", "function", "hook"];
8244
+ const metadataTypes = ["object", "view", "app", "flow", "function", "hook"];
7730
8245
  let totalLoaded = 0;
7731
8246
  for (const type of metadataTypes) {
7732
8247
  try {
@@ -7877,6 +8392,8 @@ function convertIntrospectedSchemaToObjects(introspectedSchema, options) {
7877
8392
  ObjectRepository,
7878
8393
  ObjectStackProtocolImplementation,
7879
8394
  RESERVED_NAMESPACES,
8395
+ SECRET_MASK,
8396
+ SECRET_REF_PREFIX,
7880
8397
  SchemaRegistry,
7881
8398
  ScopedContext,
7882
8399
  SysMetadataRepository,
@@ -7885,11 +8402,18 @@ function convertIntrospectedSchemaToObjects(introspectedSchema, options) {
7885
8402
  applySystemFields,
7886
8403
  bindHooksToEngine,
7887
8404
  bucketDateValue,
8405
+ collectSecretFields,
7888
8406
  computeFQN,
7889
8407
  convertIntrospectedSchemaToObjects,
7890
8408
  createObjectQLKernel,
8409
+ evaluateValidationRules,
8410
+ isSecretRef,
8411
+ legalNextStates,
8412
+ makeSecretRef,
8413
+ needsPriorRecord,
7891
8414
  noopHookMetricsRecorder,
7892
8415
  parseFQN,
8416
+ parseSecretRef,
7893
8417
  toTitleCase,
7894
8418
  validateRecord,
7895
8419
  wrapDeclarativeHook