@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.mjs CHANGED
@@ -128,6 +128,14 @@ var SchemaRegistry = class {
128
128
  // ==========================================
129
129
  /** Type → Name/ID → MetadataItem */
130
130
  this.metadata = /* @__PURE__ */ new Map();
131
+ /**
132
+ * App name → navigation contributions (ADR-0029 D7).
133
+ *
134
+ * Lets packages inject nav items into apps they do not own (the UI analog
135
+ * of object extenders). Merged into the owning app's `navigation` tree on
136
+ * read in {@link getApp} / {@link getAllApps} by group id + priority.
137
+ */
138
+ this.appNavContributions = /* @__PURE__ */ new Map();
131
139
  /**
132
140
  * Package ids that must be installed in a DISABLED state. Seeded once at
133
141
  * boot (from persisted state) BEFORE any package registration so that every
@@ -355,6 +363,47 @@ var SchemaRegistry = class {
355
363
  const contributors = this.objectContributors.get(fqn);
356
364
  return contributors?.find((c) => c.ownership === "own");
357
365
  }
366
+ /**
367
+ * ADR-0029 K0 — assert every registered object resolves to exactly one
368
+ * owner.
369
+ *
370
+ * A second `own` from a different package is already rejected eagerly in
371
+ * {@link registerObject} (it throws). This is the install-time backstop
372
+ * called once all packages are registered (kernel bootstrap complete),
373
+ * and it additionally catches the case `registerObject` cannot: an object
374
+ * that has only `extend` contributions and **no owner** — which would
375
+ * otherwise resolve to nothing. Surfacing it here turns a silent
376
+ * "extend a non-existent object" into a clear bootstrap error.
377
+ *
378
+ * This is the invariant the kernel-decomposition (ADR-0029) relies on:
379
+ * the `sys` namespace is shared across many first-party plugins, but each
380
+ * object name has exactly one owner.
381
+ *
382
+ * @throws Error listing every object whose owner count is not exactly 1.
383
+ */
384
+ assertSingleOwnerPerObject() {
385
+ const violations = [];
386
+ for (const [fqn, contributors] of this.objectContributors.entries()) {
387
+ const owners = contributors.filter((c) => c.ownership === "own");
388
+ if (owners.length === 0) {
389
+ const extenders = contributors.map((c) => c.packageId).join(", ") || "(none)";
390
+ violations.push(
391
+ `Object "${fqn}" has no owner \u2014 only extend contributions from [${extenders}]. Exactly one package must register it with ownership 'own'.`
392
+ );
393
+ } else if (owners.length > 1) {
394
+ const names = owners.map((c) => c.packageId).join(", ");
395
+ violations.push(
396
+ `Object "${fqn}" has ${owners.length} owners [${names}] \u2014 exactly one is allowed.`
397
+ );
398
+ }
399
+ }
400
+ if (violations.length > 0) {
401
+ throw new Error(
402
+ `[Registry] single-owner-per-object check failed (ADR-0029):
403
+ ` + violations.join("\n ")
404
+ );
405
+ }
406
+ }
358
407
  /**
359
408
  * Unregister all objects contributed by a package.
360
409
  *
@@ -596,10 +645,88 @@ var SchemaRegistry = class {
596
645
  this.registerItem("app", app, "name", packageId);
597
646
  }
598
647
  getApp(name) {
599
- return this.getItem("app", name);
648
+ const app = this.getItem("app", name);
649
+ if (!app) return app;
650
+ return this.applyNavContributions(app);
600
651
  }
601
652
  getAllApps() {
602
- return this.listItems("app");
653
+ return this.listItems("app").map((app) => this.applyNavContributions(app));
654
+ }
655
+ // ==========================================
656
+ // App navigation contributions (ADR-0029 D7)
657
+ // ==========================================
658
+ /**
659
+ * Register a navigation contribution — a package injecting nav items into
660
+ * an app it does not own (the UI-layer analog of object `extend`).
661
+ *
662
+ * Contributions are merged into the target app's `navigation` tree lazily
663
+ * on read ({@link getApp} / {@link getAllApps}) by group id + priority, so
664
+ * registration order does not matter and the owning app can be registered
665
+ * before or after its contributors.
666
+ */
667
+ registerAppNavContribution(contribution, packageId) {
668
+ if (!contribution || !contribution.app) return;
669
+ const list = this.appNavContributions.get(contribution.app) ?? [];
670
+ list.push({
671
+ packageId,
672
+ group: contribution.group,
673
+ priority: contribution.priority ?? 200,
674
+ items: Array.isArray(contribution.items) ? contribution.items : []
675
+ });
676
+ this.appNavContributions.set(contribution.app, list);
677
+ this.log(
678
+ `[Registry] Navigation contribution: ${packageId ?? "(unknown)"} -> ${contribution.app}` + (contribution.group ? `/${contribution.group}` : "") + ` (${list[list.length - 1].items.length} items)`
679
+ );
680
+ }
681
+ /** Contributions registered for an app (empty array when none). */
682
+ getAppNavContributions(appName) {
683
+ return this.appNavContributions.get(appName) ?? [];
684
+ }
685
+ /**
686
+ * Return a copy of `app` with all registered navigation contributions
687
+ * merged into its `navigation` tree. The stored app is never mutated, so
688
+ * repeated reads stay idempotent.
689
+ *
690
+ * Public so the protocol serving path (`getMetaItems` / `getMetaItem` for
691
+ * `app`) can merge contributions the same way `getApp` / `getAllApps` do —
692
+ * the REST app endpoints read through the protocol, not these helpers, so
693
+ * the merge must be reachable from there too (ADR-0029 D7).
694
+ */
695
+ applyNavContributions(app) {
696
+ const contributions = this.appNavContributions.get(app?.name);
697
+ if (!contributions || contributions.length === 0) return app;
698
+ const cloned = structuredClone(app);
699
+ const nav = Array.isArray(cloned.navigation) ? cloned.navigation : cloned.navigation = [];
700
+ const sorted = [...contributions].sort((a, b) => a.priority - b.priority);
701
+ for (const c of sorted) {
702
+ if (!c.items.length) continue;
703
+ if (c.group) {
704
+ const group = this.findNavGroup(nav, c.group);
705
+ if (group) {
706
+ if (!Array.isArray(group.children)) group.children = [];
707
+ group.children.push(...c.items);
708
+ } else {
709
+ this.log(
710
+ `[Registry] Navigation contribution from "${c.packageId ?? "(unknown)"}" targets missing group "${c.group}" in app "${app.name}" \u2014 appending at top level.`
711
+ );
712
+ nav.push(...c.items);
713
+ }
714
+ } else {
715
+ nav.push(...c.items);
716
+ }
717
+ }
718
+ return cloned;
719
+ }
720
+ /** Depth-first search for a `type: 'group'` nav item by id. */
721
+ findNavGroup(items, groupId) {
722
+ for (const item of items) {
723
+ if (item && item.id === groupId && item.type === "group") return item;
724
+ if (item && Array.isArray(item.children)) {
725
+ const found = this.findNavGroup(item.children, groupId);
726
+ if (found) return found;
727
+ }
728
+ }
729
+ return void 0;
603
730
  }
604
731
  // ==========================================
605
732
  // Plugin Helpers
@@ -658,6 +785,7 @@ var SchemaRegistry = class {
658
785
  this.mergedObjectCache.clear();
659
786
  this.namespaceRegistry.clear();
660
787
  this.metadata.clear();
788
+ this.appNavContributions.clear();
661
789
  this.log("[Registry] Reset complete");
662
790
  }
663
791
  };
@@ -1349,8 +1477,9 @@ var SysMetadataRepository = class {
1349
1477
  import { ConflictError as ConflictError2 } from "@objectstack/metadata-core";
1350
1478
  import { parseFilterAST, isFilterAST } from "@objectstack/spec/data";
1351
1479
  import { PLURAL_TO_SINGULAR as PLURAL_TO_SINGULAR3, SINGULAR_TO_PLURAL as SINGULAR_TO_PLURAL2 } from "@objectstack/spec/shared";
1480
+ import { isAggregatedViewContainer } from "@objectstack/spec/ui";
1352
1481
  import { METADATA_FORM_REGISTRY } from "@objectstack/spec/system";
1353
- import { DEFAULT_METADATA_TYPE_REGISTRY as DEFAULT_METADATA_TYPE_REGISTRY2, getMetadataTypeSchema as getMetadataTypeSchema2 } from "@objectstack/spec/kernel";
1482
+ import { DEFAULT_METADATA_TYPE_REGISTRY as DEFAULT_METADATA_TYPE_REGISTRY2, getMetadataTypeSchema as getMetadataTypeSchema2, getMetadataTypeActions } from "@objectstack/spec/kernel";
1354
1483
  import {
1355
1484
  extractProtection,
1356
1485
  evaluateLockForWrite,
@@ -2048,6 +2177,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2048
2177
  const zodSchema = getMetadataTypeSchema2(singular);
2049
2178
  const schema = (zodSchema ? toJsonSchemaSafe(zodSchema) : void 0) ?? HAND_CRAFTED_SCHEMAS[singular];
2050
2179
  const form = TYPE_TO_FORM[singular];
2180
+ const typeActions = getMetadataTypeActions(singular);
2051
2181
  const base = registryByType.get(singular);
2052
2182
  if (base) {
2053
2183
  const isEnvOverridden = writableOverrides.has(singular);
@@ -2059,7 +2189,11 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2059
2189
  allowOrgOverride: base.allowOrgOverride || isEnvOverridden,
2060
2190
  overrideSource: isEnvOverridden && !base.allowOrgOverride ? "env" : "registry",
2061
2191
  schema,
2062
- form
2192
+ form,
2193
+ // Override the spread `base.actions` with the merged view
2194
+ // (declarative + plugin-registered). Omit when empty to
2195
+ // preserve the prior "no actions key" response shape.
2196
+ ...typeActions.length ? { actions: typeActions } : {}
2063
2197
  };
2064
2198
  }
2065
2199
  return {
@@ -2078,7 +2212,9 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2078
2212
  domain: "system",
2079
2213
  overrideSource: writableOverrides.has(singular) ? "env" : "registry",
2080
2214
  schema,
2081
- form
2215
+ form,
2216
+ // Plugin-registered actions on a type with no registry entry.
2217
+ ...typeActions.length ? { actions: typeActions } : {}
2082
2218
  };
2083
2219
  }).sort((a, b) => {
2084
2220
  if (a.domain !== b.domain) return a.domain.localeCompare(b.domain);
@@ -2253,6 +2389,12 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2253
2389
  (it) => !this.engine.registry.isPackageDisabled(it?._packageId)
2254
2390
  );
2255
2391
  }
2392
+ if (request.type === "view" || request.type === "views") {
2393
+ items = items.filter((it) => !isAggregatedViewContainer(it));
2394
+ }
2395
+ if (request.type === "app" || request.type === "apps") {
2396
+ items = items.map((app) => this.engine.registry.applyNavContributions(app));
2397
+ }
2256
2398
  return {
2257
2399
  type: request.type,
2258
2400
  items: decorateMetadataItems(
@@ -2342,6 +2484,9 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2342
2484
  if (alt) item = this.engine.registry.getItem(alt, request.name);
2343
2485
  }
2344
2486
  }
2487
+ if ((request.type === "app" || request.type === "apps") && item) {
2488
+ item = this.engine.registry.applyNavContributions(item);
2489
+ }
2345
2490
  const artifactItem = this.lookupArtifactItem(request.type, request.name);
2346
2491
  const decorated = decorateMetadataItem(
2347
2492
  request.type,
@@ -3100,7 +3245,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3100
3245
  throw new Error(`Metadata item ${request.type}/${request.name} not found`);
3101
3246
  }
3102
3247
  const content = JSON.stringify(item);
3103
- const hash = simpleHash(content);
3248
+ const hash = simpleHash(request.locale ? `${request.locale}\0${content}` : content);
3104
3249
  const etag = { value: hash, weak: false };
3105
3250
  if (request.cacheRequest?.ifNoneMatch) {
3106
3251
  const clientEtag = request.cacheRequest.ifNoneMatch.replace(/^"(.*)"$/, "$1").replace(/^W\/"(.*)"$/, "$1");
@@ -4552,8 +4697,33 @@ var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
4552
4697
  import { ExecutionContextSchema } from "@objectstack/spec/kernel";
4553
4698
  import { createLogger } from "@objectstack/core";
4554
4699
  import { CoreServiceName, StorageNameMapping } from "@objectstack/spec/system";
4555
- import { pluralToSingular } from "@objectstack/spec/shared";
4556
- import { ExpressionEngine as ExpressionEngine2 } from "@objectstack/formula";
4700
+
4701
+ // src/secret-fields.ts
4702
+ var SECRET_REF_PREFIX = "secret:";
4703
+ var SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
4704
+ function makeSecretRef(handleId) {
4705
+ return `${SECRET_REF_PREFIX}${handleId}`;
4706
+ }
4707
+ function isSecretRef(value) {
4708
+ return typeof value === "string" && value.startsWith(SECRET_REF_PREFIX);
4709
+ }
4710
+ function parseSecretRef(value) {
4711
+ return isSecretRef(value) ? value.slice(SECRET_REF_PREFIX.length) : null;
4712
+ }
4713
+ function collectSecretFields(schema) {
4714
+ const fields = schema?.fields;
4715
+ if (!fields) return [];
4716
+ const out = [];
4717
+ for (const [name, def] of Object.entries(fields)) {
4718
+ if (def && def.type === "secret") out.push(name);
4719
+ }
4720
+ return out;
4721
+ }
4722
+
4723
+ // src/engine.ts
4724
+ import { pluralToSingular, ExternalWriteForbiddenError } from "@objectstack/spec/shared";
4725
+ import { ExpressionEngine as ExpressionEngine3 } from "@objectstack/formula";
4726
+ import { isAggregatedViewContainer as isAggregatedViewContainer2, expandViewContainer } from "@objectstack/spec";
4557
4727
 
4558
4728
  // src/hook-wrappers.ts
4559
4729
  import { ExpressionEngine } from "@objectstack/formula";
@@ -5107,6 +5277,101 @@ function validateRecord(objectSchema, data, mode) {
5107
5277
  if (errors.length > 0) throw new ValidationError(errors);
5108
5278
  }
5109
5279
 
5280
+ // src/validation/rule-validator.ts
5281
+ import { ExpressionEngine as ExpressionEngine2 } from "@objectstack/formula";
5282
+ function needsPriorRecord(objectSchema) {
5283
+ const rules = objectSchema?.validations;
5284
+ if (!Array.isArray(rules)) return false;
5285
+ return rules.some(
5286
+ (r) => r != null && typeof r === "object" && (r.type === "state_machine" || r.type === "cross_field" || r.type === "script")
5287
+ );
5288
+ }
5289
+ function toExpression(cond) {
5290
+ return typeof cond === "string" ? { dialect: "cel", source: cond } : cond;
5291
+ }
5292
+ function evaluateValidationRules(objectSchema, data, mode, opts = {}) {
5293
+ const rules = objectSchema?.validations;
5294
+ if (!Array.isArray(rules) || rules.length === 0 || !data) return;
5295
+ const previous = opts.previous ?? void 0;
5296
+ const merged = { ...previous ?? {}, ...data };
5297
+ const errors = [];
5298
+ const ordered = rules.filter((r) => r != null && typeof r === "object").filter((r) => r.active !== false).filter((r) => {
5299
+ const events = r.events ?? ["insert", "update"];
5300
+ return events.includes(mode);
5301
+ }).sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
5302
+ for (const rule of ordered) {
5303
+ let violation = null;
5304
+ try {
5305
+ if (rule.type === "state_machine") {
5306
+ violation = checkStateMachine(rule, mode, data, previous);
5307
+ } else if (rule.type === "script" || rule.type === "cross_field") {
5308
+ violation = checkPredicate(rule, merged, previous, opts.logger);
5309
+ }
5310
+ } catch (err) {
5311
+ opts.logger?.warn?.(`Validation rule '${rule.name}' threw \u2014 skipped`, err);
5312
+ continue;
5313
+ }
5314
+ if (!violation) continue;
5315
+ const severity = rule.severity ?? "error";
5316
+ if (severity === "error") {
5317
+ errors.push(violation);
5318
+ } else {
5319
+ opts.logger?.warn?.(
5320
+ `Validation rule '${rule.name}' (${severity}): ${violation.message}`
5321
+ );
5322
+ }
5323
+ }
5324
+ if (errors.length > 0) throw new ValidationError(errors);
5325
+ }
5326
+ function checkStateMachine(rule, mode, data, previous) {
5327
+ if (mode === "insert" || !previous) return null;
5328
+ if (!(rule.field in data)) return null;
5329
+ const from = previous[rule.field];
5330
+ const to = data[rule.field];
5331
+ if (from === to || to === void 0 || to === null) return null;
5332
+ const fromKey = String(from);
5333
+ const allowed = rule.transitions[fromKey];
5334
+ if (!Array.isArray(allowed)) return null;
5335
+ if (!allowed.includes(String(to))) {
5336
+ return {
5337
+ field: rule.field,
5338
+ code: "invalid_transition",
5339
+ message: rule.message || `Invalid transition for ${rule.field}: ${fromKey} \u2192 ${String(to)}`
5340
+ };
5341
+ }
5342
+ return null;
5343
+ }
5344
+ function checkPredicate(rule, record, previous, logger) {
5345
+ const expr = toExpression(rule.condition);
5346
+ const result = ExpressionEngine2.evaluate(expr, {
5347
+ record,
5348
+ previous: previous ?? void 0
5349
+ });
5350
+ if (!result.ok) {
5351
+ logger?.warn?.(
5352
+ `Validation rule '${rule.name}' predicate failed to evaluate (${result.error.kind}: ${result.error.message}) \u2014 skipped`
5353
+ );
5354
+ return null;
5355
+ }
5356
+ if (result.value === true) {
5357
+ return {
5358
+ field: rule.fields?.[0] ?? "_record",
5359
+ code: "rule_violation",
5360
+ message: rule.message
5361
+ };
5362
+ }
5363
+ return null;
5364
+ }
5365
+ function legalNextStates(objectSchema, field, currentState) {
5366
+ const rules = objectSchema?.validations;
5367
+ if (!Array.isArray(rules)) return null;
5368
+ const rule = rules.find(
5369
+ (r) => r != null && typeof r === "object" && r.type === "state_machine" && r.field === field
5370
+ );
5371
+ if (!rule) return null;
5372
+ return rule.transitions[currentState] ?? [];
5373
+ }
5374
+
5110
5375
  // src/in-memory-aggregation.ts
5111
5376
  function applyInMemoryAggregation(rows, ast) {
5112
5377
  const groupBy = ast.groupBy ?? [];
@@ -5264,7 +5529,7 @@ function planFormulaProjection(schema, requestedFields) {
5264
5529
  if (def?.type === "formula" && def.expression) {
5265
5530
  const expr = typeof def.expression === "string" ? { dialect: "cel", source: def.expression } : def.expression;
5266
5531
  plan.push({ name: f, expression: expr });
5267
- ExpressionEngine2.compile(expr);
5532
+ ExpressionEngine3.compile(expr);
5268
5533
  } else if (Array.isArray(requestedFields) && requestedFields.length > 0) {
5269
5534
  projected.add(f);
5270
5535
  }
@@ -5286,7 +5551,7 @@ function applyFormulaPlan(plan, records) {
5286
5551
  for (const rec of records) {
5287
5552
  if (rec == null) continue;
5288
5553
  for (const fp of plan) {
5289
- const r = ExpressionEngine2.evaluate(fp.expression, { record: rec });
5554
+ const r = ExpressionEngine3.evaluate(fp.expression, { record: rec });
5290
5555
  rec[fp.name] = r.ok ? r.value : null;
5291
5556
  }
5292
5557
  }
@@ -5296,7 +5561,7 @@ function resolveMetadataItemName(key, item) {
5296
5561
  if (item.name) return item.name;
5297
5562
  if (item.id) return item.id;
5298
5563
  if (key === "views") {
5299
- return item?.list?.data?.object || item?.form?.data?.object || void 0;
5564
+ return item?.object || item?.list?.data?.object || item?.form?.data?.object || void 0;
5300
5565
  }
5301
5566
  return void 0;
5302
5567
  }
@@ -5308,6 +5573,11 @@ var _ObjectQL = class _ObjectQL {
5308
5573
  this.datasourceMapping = [];
5309
5574
  // Package manifests registry (for defaultDatasource lookup)
5310
5575
  this.manifests = /* @__PURE__ */ new Map();
5576
+ // Datasource definitions by name (ADR-0015): carries schemaMode +
5577
+ // external.allowWrites so the write gate (Gate 3) can enforce federation
5578
+ // ownership. Populated from manifests in registerApp and via
5579
+ // registerDatasourceDef. Absent entry ⇒ treated as managed (default DB).
5580
+ this.datasourceDefs = /* @__PURE__ */ new Map();
5311
5581
  // Per-object hooks with priority support
5312
5582
  this.hooks = /* @__PURE__ */ new Map([
5313
5583
  ["beforeFind", []],
@@ -5690,7 +5960,7 @@ var _ObjectQL = class _ObjectQL {
5690
5960
  if (f.defaultValue == null) continue;
5691
5961
  const dv = f.defaultValue;
5692
5962
  if (typeof dv === "object" && dv !== null && dv.dialect && typeof dv.source === "string") {
5693
- const result = ExpressionEngine2.evaluate(dv, {
5963
+ const result = ExpressionEngine3.evaluate(dv, {
5694
5964
  now,
5695
5965
  user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : void 0,
5696
5966
  org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : void 0,
@@ -5730,6 +6000,12 @@ var _ObjectQL = class _ObjectQL {
5730
6000
  if (id) {
5731
6001
  this.manifests.set(id, manifest);
5732
6002
  }
6003
+ if (manifest.datasources) {
6004
+ const dsList = Array.isArray(manifest.datasources) ? manifest.datasources : Object.entries(manifest.datasources).map(([name, def]) => ({ name, ...def }));
6005
+ for (const ds of dsList) {
6006
+ if (ds?.name) this.registerDatasourceDef(ds);
6007
+ }
6008
+ }
5733
6009
  this._registry.installPackage(manifest);
5734
6010
  this.logger.debug("Installed Package", { id: manifest.id, name: manifest.name, namespace });
5735
6011
  if (manifest.objects) {
@@ -5783,6 +6059,15 @@ var _ObjectQL = class _ObjectQL {
5783
6059
  this._registry.registerApp(resolved, id);
5784
6060
  this.logger.debug("Registered manifest-as-app", { app: manifest.name, from: id });
5785
6061
  }
6062
+ if (Array.isArray(manifest.navigationContributions) && manifest.navigationContributions.length > 0) {
6063
+ for (const contribution of manifest.navigationContributions) {
6064
+ this._registry.registerAppNavContribution(contribution, id);
6065
+ }
6066
+ this.logger.debug("Registered navigation contributions", {
6067
+ from: id,
6068
+ count: manifest.navigationContributions.length
6069
+ });
6070
+ }
5786
6071
  const metadataArrayKeys = [
5787
6072
  // UI Protocol
5788
6073
  "actions",
@@ -5826,6 +6111,11 @@ var _ObjectQL = class _ObjectQL {
5826
6111
  if (itemName) {
5827
6112
  const toRegister = item.name === itemName ? item : { ...item, name: itemName };
5828
6113
  this._registry.registerItem(pluralToSingular(key), toRegister, "name", id);
6114
+ if (key === "views" && isAggregatedViewContainer2(toRegister)) {
6115
+ for (const vi of expandViewContainer(itemName, toRegister)) {
6116
+ this._registry.registerItem("view", vi, "name", id);
6117
+ }
6118
+ }
5829
6119
  } else {
5830
6120
  this.logger.warn(`Skipping ${pluralToSingular(key)} without a derivable name`, { id });
5831
6121
  }
@@ -5978,6 +6268,41 @@ var _ObjectQL = class _ObjectQL {
5978
6268
  this.logger.info("Set default driver", { driverName: driver.name });
5979
6269
  }
5980
6270
  }
6271
+ /**
6272
+ * Register a Datasource *definition* (ADR-0015).
6273
+ *
6274
+ * Distinct from {@link registerDriver}, which registers a live connection.
6275
+ * This captures the declarative `schemaMode` + `external.allowWrites` so the
6276
+ * write gate ({@link assertWriteAllowed}) can enforce external-datasource
6277
+ * ownership. Safe to call repeatedly; last write wins.
6278
+ */
6279
+ registerDatasourceDef(def) {
6280
+ if (!def?.name) return;
6281
+ this.datasourceDefs.set(def.name, { schemaMode: def.schemaMode, external: def.external });
6282
+ }
6283
+ /**
6284
+ * Write gate — Gate 3 of ADR-0015 §5.3.
6285
+ *
6286
+ * Blocks insert/update/delete against a federated datasource
6287
+ * (`schemaMode !== 'managed'`) unless BOTH the datasource opts in
6288
+ * (`external.allowWrites`) AND the object opts in (`external.writable`).
6289
+ * Managed datasources (the common case, including the absence of any
6290
+ * definition) are unaffected.
6291
+ */
6292
+ assertWriteAllowed(objectName, operation) {
6293
+ const object = this._registry.getObject(objectName);
6294
+ const dsName = object?.datasource;
6295
+ if (!dsName || dsName === "default") return;
6296
+ const ds = this.datasourceDefs.get(dsName);
6297
+ if (!ds || !ds.schemaMode || ds.schemaMode === "managed") return;
6298
+ const dsAllows = ds.external?.allowWrites ?? false;
6299
+ const objAllows = object?.external?.writable ?? false;
6300
+ if (!(dsAllows && objAllows)) {
6301
+ throw new ExternalWriteForbiddenError(
6302
+ `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}).`
6303
+ );
6304
+ }
6305
+ }
5981
6306
  /**
5982
6307
  * Set the realtime service for publishing data change events.
5983
6308
  * Should be called after kernel resolves the realtime service.
@@ -5988,6 +6313,141 @@ var _ObjectQL = class _ObjectQL {
5988
6313
  this.realtimeService = service;
5989
6314
  this.logger.info("RealtimeService configured for data events");
5990
6315
  }
6316
+ /**
6317
+ * Register the crypto provider that backs `secret`-typed fields.
6318
+ *
6319
+ * When set, the engine encrypts secret fields on write (storing ciphertext in
6320
+ * `sys_secret` and only an opaque ref on the business row) and masks them on
6321
+ * read. When NOT set, writing to an object that declares a secret field is
6322
+ * **fail-closed** — the write throws rather than persist cleartext.
6323
+ *
6324
+ * Mirrors the Settings subsystem's ICryptoProvider wiring; the host (e.g.
6325
+ * `serve`) injects `InMemoryCryptoProvider` in dev and a KMS/Vault-backed
6326
+ * provider in production.
6327
+ */
6328
+ setCryptoProvider(provider) {
6329
+ this.cryptoProvider = provider;
6330
+ this.logger.info("CryptoProvider configured for secret fields");
6331
+ }
6332
+ /**
6333
+ * Encrypt any `secret`-typed fields on `row` in place before it reaches the
6334
+ * driver. Each plaintext is wrapped by the ICryptoProvider, persisted as a
6335
+ * `sys_secret` row, and replaced on `row` by an opaque ref. Cleartext never
6336
+ * reaches the business table.
6337
+ *
6338
+ * Rules:
6339
+ * - No secret fields on the object ⇒ no-op (fast path, no crypto cost).
6340
+ * - `null`/`undefined` value ⇒ left as-is (clears the secret).
6341
+ * - Value already a ref (re-save of an unchanged ref) ⇒ left as-is.
6342
+ * - Value equal to the read mask ⇒ dropped, so a form round-trip that
6343
+ * echoes the mask does not overwrite the stored secret.
6344
+ * - **Fail-closed:** any other value with no CryptoProvider registered, or
6345
+ * no reachable `sys_secret` store, THROWS — never persists cleartext.
6346
+ */
6347
+ async encryptSecretFields(object, row, context, driverOptions) {
6348
+ if (!row || typeof row !== "object") return;
6349
+ const schema = this._registry.getObject(object);
6350
+ const secretFields = collectSecretFields(schema);
6351
+ if (secretFields.length === 0) return;
6352
+ for (const field of secretFields) {
6353
+ if (!(field in row)) continue;
6354
+ const value = row[field];
6355
+ if (value === null || typeof value === "undefined") continue;
6356
+ if (isSecretRef(value)) continue;
6357
+ if (value === SECRET_MASK) {
6358
+ delete row[field];
6359
+ continue;
6360
+ }
6361
+ if (!this.cryptoProvider) {
6362
+ throw new Error(
6363
+ `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).`
6364
+ );
6365
+ }
6366
+ const plain = typeof value === "string" ? value : JSON.stringify(value);
6367
+ const handle = await this.cryptoProvider.encrypt(plain, {
6368
+ namespace: object,
6369
+ key: field,
6370
+ tenantId: context?.tenantId
6371
+ });
6372
+ let secretDriver;
6373
+ try {
6374
+ secretDriver = this.getDriver("sys_secret");
6375
+ } catch {
6376
+ throw new Error(
6377
+ `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).`
6378
+ );
6379
+ }
6380
+ await secretDriver.create(
6381
+ "sys_secret",
6382
+ {
6383
+ id: handle.id,
6384
+ namespace: object,
6385
+ key: field,
6386
+ kms_key_id: handle.kmsKeyId,
6387
+ alg: handle.alg,
6388
+ version: handle.version,
6389
+ ciphertext: handle.ciphertext,
6390
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
6391
+ },
6392
+ driverOptions
6393
+ );
6394
+ row[field] = makeSecretRef(handle.id);
6395
+ }
6396
+ }
6397
+ /**
6398
+ * Mask `secret`-typed fields on read so plaintext never leaves the engine
6399
+ * through the normal query path. A set secret becomes {@link SECRET_MASK};
6400
+ * an unset one stays `null`. Privileged callers that genuinely need the
6401
+ * plaintext use {@link resolveSecret} against the stored ref.
6402
+ */
6403
+ maskSecretFields(object, rows) {
6404
+ if (!rows) return;
6405
+ const schema = this._registry.getObject(object);
6406
+ const secretFields = collectSecretFields(schema);
6407
+ if (secretFields.length === 0) return;
6408
+ const list = Array.isArray(rows) ? rows : [rows];
6409
+ for (const row of list) {
6410
+ if (!row || typeof row !== "object") continue;
6411
+ for (const field of secretFields) {
6412
+ if (!(field in row)) continue;
6413
+ row[field] = row[field] == null ? null : SECRET_MASK;
6414
+ }
6415
+ }
6416
+ }
6417
+ /**
6418
+ * Dereference a stored secret ref back to its plaintext. Intended for
6419
+ * privileged, server-side consumers (e.g. a datasource connection-pool
6420
+ * binder) — NOT exposed through the generic read path, which only ever
6421
+ * returns the mask.
6422
+ *
6423
+ * Fail-closed: throws when no CryptoProvider is registered or the
6424
+ * `sys_secret` row is missing. Returns `null` when `ref` is not a secret ref.
6425
+ */
6426
+ async resolveSecret(ref, opts) {
6427
+ const id = parseSecretRef(ref);
6428
+ if (!id) return null;
6429
+ if (!this.cryptoProvider) {
6430
+ throw new Error("Cannot resolve secret: no CryptoProvider is registered (fail-closed).");
6431
+ }
6432
+ const secretDriver = this.getDriver("sys_secret");
6433
+ const found = await secretDriver.find("sys_secret", { object: "sys_secret", where: { id } });
6434
+ const secret = Array.isArray(found) ? found[0] : found;
6435
+ if (!secret) {
6436
+ throw new Error(`Cannot resolve secret: sys_secret row "${id}" not found (fail-closed).`);
6437
+ }
6438
+ const handle = {
6439
+ id: secret.id,
6440
+ kmsKeyId: secret.kms_key_id,
6441
+ alg: secret.alg,
6442
+ version: secret.version,
6443
+ ciphertext: secret.ciphertext
6444
+ };
6445
+ return this.cryptoProvider.decrypt(handle, {
6446
+ namespace: secret.namespace,
6447
+ key: secret.key,
6448
+ tenantId: opts?.tenantId
6449
+ });
6450
+ }
5991
6451
  /**
5992
6452
  * Helper to get object definition
5993
6453
  */
@@ -6280,6 +6740,7 @@ var _ObjectQL = class _ObjectQL {
6280
6740
  hookContext.event = "afterFind";
6281
6741
  hookContext.result = result;
6282
6742
  await this.triggerHooks("afterFind", hookContext);
6743
+ this.maskSecretFields(object, hookContext.result);
6283
6744
  return hookContext.result;
6284
6745
  } catch (e) {
6285
6746
  this.logger.error("Find operation failed", e, { object });
@@ -6321,6 +6782,7 @@ var _ObjectQL = class _ObjectQL {
6321
6782
  const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context);
6322
6783
  result = expanded[0];
6323
6784
  }
6785
+ this.maskSecretFields(objectName, result);
6324
6786
  return result;
6325
6787
  });
6326
6788
  return opCtx.result;
@@ -6328,6 +6790,7 @@ var _ObjectQL = class _ObjectQL {
6328
6790
  async insert(object, data, options) {
6329
6791
  object = this.resolveObjectName(object);
6330
6792
  this.logger.debug("Insert operation starting", { object, isBatch: Array.isArray(data) });
6793
+ this.assertWriteAllowed(object, "insert");
6331
6794
  const driver = this.getDriver(object);
6332
6795
  const opCtx = {
6333
6796
  object,
@@ -6356,7 +6819,13 @@ var _ObjectQL = class _ObjectQL {
6356
6819
  const rows = hookContext.input.data.map(
6357
6820
  (row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
6358
6821
  );
6359
- for (const r of rows) validateRecord(schemaForValidation, r, "insert");
6822
+ for (const r of rows) {
6823
+ await this.encryptSecretFields(object, r, opCtx.context, hookContext.input.options);
6824
+ }
6825
+ for (const r of rows) {
6826
+ validateRecord(schemaForValidation, r, "insert");
6827
+ evaluateValidationRules(schemaForValidation, r, "insert", { logger: this.logger });
6828
+ }
6360
6829
  if (driver.bulkCreate) {
6361
6830
  result = await driver.bulkCreate(object, rows, hookContext.input.options);
6362
6831
  } else {
@@ -6369,7 +6838,9 @@ var _ObjectQL = class _ObjectQL {
6369
6838
  opCtx.context,
6370
6839
  nowSnap
6371
6840
  );
6841
+ await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options);
6372
6842
  validateRecord(schemaForValidation, row, "insert");
6843
+ evaluateValidationRules(schemaForValidation, row, "insert", { logger: this.logger });
6373
6844
  result = await driver.create(object, row, hookContext.input.options);
6374
6845
  }
6375
6846
  hookContext.event = "afterInsert";
@@ -6419,6 +6890,7 @@ var _ObjectQL = class _ObjectQL {
6419
6890
  async update(object, data, options) {
6420
6891
  object = this.resolveObjectName(object);
6421
6892
  this.logger.debug("Update operation starting", { object });
6893
+ this.assertWriteAllowed(object, "update");
6422
6894
  const driver = this.getDriver(object);
6423
6895
  let id = data.id;
6424
6896
  if (!id && options?.where && typeof options.where === "object" && "id" in options.where) {
@@ -6445,11 +6917,23 @@ var _ObjectQL = class _ObjectQL {
6445
6917
  hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
6446
6918
  try {
6447
6919
  let result;
6920
+ let priorRecord = null;
6921
+ const updateSchema = this._registry.getObject(object);
6448
6922
  if (hookContext.input.id) {
6449
- validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
6923
+ await this.encryptSecretFields(object, hookContext.input.data, opCtx.context, hookContext.input.options);
6924
+ validateRecord(updateSchema, hookContext.input.data, "update");
6925
+ if (needsPriorRecord(updateSchema) || (this.hooks.get("afterUpdate")?.length ?? 0) > 0) {
6926
+ const priorAst = { object, where: { id: hookContext.input.id }, limit: 1 };
6927
+ priorRecord = await driver.findOne(object, priorAst, hookContext.input.options);
6928
+ }
6929
+ evaluateValidationRules(updateSchema, hookContext.input.data, "update", { previous: priorRecord, logger: this.logger });
6450
6930
  result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
6451
6931
  } else if (options?.multi && driver.updateMany) {
6452
- validateRecord(this._registry.getObject(object), hookContext.input.data, "update");
6932
+ await this.encryptSecretFields(object, hookContext.input.data, opCtx.context, hookContext.input.options);
6933
+ validateRecord(updateSchema, hookContext.input.data, "update");
6934
+ if (needsPriorRecord(updateSchema)) {
6935
+ this.logger.warn("Object-level validation rules (state_machine/cross_field/script) are not enforced on multi-row updates", { object });
6936
+ }
6453
6937
  const ast = { object, where: options.where };
6454
6938
  result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options);
6455
6939
  } else {
@@ -6457,6 +6941,7 @@ var _ObjectQL = class _ObjectQL {
6457
6941
  }
6458
6942
  hookContext.event = "afterUpdate";
6459
6943
  hookContext.result = result;
6944
+ if (priorRecord) hookContext.previous = priorRecord;
6460
6945
  await this.triggerHooks("afterUpdate", hookContext);
6461
6946
  if (this.realtimeService) {
6462
6947
  try {
@@ -6489,6 +6974,7 @@ var _ObjectQL = class _ObjectQL {
6489
6974
  async delete(object, options) {
6490
6975
  object = this.resolveObjectName(object);
6491
6976
  this.logger.debug("Delete operation starting", { object });
6977
+ this.assertWriteAllowed(object, "delete");
6492
6978
  const driver = this.getDriver(object);
6493
6979
  let id = void 0;
6494
6980
  if (options?.where && typeof options.where === "object" && "id" in options.where) {
@@ -6748,6 +7234,26 @@ var _ObjectQL = class _ObjectQL {
6748
7234
  getDriverByName(name) {
6749
7235
  return this.drivers.get(name);
6750
7236
  }
7237
+ /**
7238
+ * Introspect a datasource's live remote schema (ADR-0015).
7239
+ *
7240
+ * Resolves the driver registered under `datasource` and delegates to its
7241
+ * `introspectSchema()` capability. Used by the external-datasource service
7242
+ * (and CLI/REST) to list remote tables and validate federated objects.
7243
+ *
7244
+ * @throws if the datasource has no registered driver, or the driver does
7245
+ * not support introspection.
7246
+ */
7247
+ async introspectDatasource(datasource) {
7248
+ const driver = this.drivers.get(datasource);
7249
+ if (!driver) {
7250
+ throw new Error(`[ObjectQL] Datasource '${datasource}' has no registered driver to introspect.`);
7251
+ }
7252
+ if (typeof driver.introspectSchema !== "function") {
7253
+ throw new Error(`[ObjectQL] Driver for datasource '${datasource}' does not support introspectSchema().`);
7254
+ }
7255
+ return driver.introspectSchema();
7256
+ }
6751
7257
  /**
6752
7258
  * Get the driver responsible for the given object.
6753
7259
  *
@@ -7681,7 +8187,7 @@ var ObjectQLPlugin = class {
7681
8187
  */
7682
8188
  async loadMetadataFromService(metadataService, ctx) {
7683
8189
  ctx.logger.info("Syncing metadata from external service into ObjectQL registry...");
7684
- const metadataTypes = ["object", "view", "app", "flow", "workflow", "function", "hook"];
8190
+ const metadataTypes = ["object", "view", "app", "flow", "function", "hook"];
7685
8191
  let totalLoaded = 0;
7686
8192
  for (const type of metadataTypes) {
7687
8193
  try {
@@ -7831,6 +8337,8 @@ export {
7831
8337
  ObjectRepository,
7832
8338
  ObjectStackProtocolImplementation,
7833
8339
  RESERVED_NAMESPACES,
8340
+ SECRET_MASK,
8341
+ SECRET_REF_PREFIX,
7834
8342
  SchemaRegistry,
7835
8343
  ScopedContext,
7836
8344
  SysMetadataRepository,
@@ -7839,11 +8347,18 @@ export {
7839
8347
  applySystemFields,
7840
8348
  bindHooksToEngine,
7841
8349
  bucketDateValue,
8350
+ collectSecretFields,
7842
8351
  computeFQN,
7843
8352
  convertIntrospectedSchemaToObjects,
7844
8353
  createObjectQLKernel,
8354
+ evaluateValidationRules,
8355
+ isSecretRef,
8356
+ legalNextStates,
8357
+ makeSecretRef,
8358
+ needsPriorRecord,
7845
8359
  noopHookMetricsRecorder,
7846
8360
  parseFQN,
8361
+ parseSecretRef,
7847
8362
  toTitleCase,
7848
8363
  validateRecord,
7849
8364
  wrapDeclarativeHook