@objectstack/objectql 7.2.1 → 7.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +343 -9
- package/dist/index.d.ts +343 -9
- package/dist/index.js +606 -23
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +599 -25
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
package/dist/index.mjs
CHANGED
|
@@ -128,6 +128,23 @@ 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();
|
|
139
|
+
/**
|
|
140
|
+
* Package ids that must be installed in a DISABLED state. Seeded once at
|
|
141
|
+
* boot (from persisted state) BEFORE any package registration so that every
|
|
142
|
+
* registration path — boot artifact, marketplace rehydrate, local import —
|
|
143
|
+
* honors persisted disable state uniformly without a fragile post-boot
|
|
144
|
+
* re-application hook. See {@link setInitialDisabledPackageIds} and
|
|
145
|
+
* {@link installPackage}.
|
|
146
|
+
*/
|
|
147
|
+
this.initialDisabledPackageIds = /* @__PURE__ */ new Set();
|
|
131
148
|
if (options.multiTenant !== void 0) {
|
|
132
149
|
this.multiTenant = options.multiTenant;
|
|
133
150
|
} else {
|
|
@@ -144,6 +161,14 @@ var SchemaRegistry = class {
|
|
|
144
161
|
if (this._logLevel === "silent" || this._logLevel === "error" || this._logLevel === "warn") return;
|
|
145
162
|
console.log(msg);
|
|
146
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Seed the set of package ids that should be installed disabled. Call this
|
|
166
|
+
* before package registration begins; later `installPackage` calls for these
|
|
167
|
+
* ids land in the `disabled` state. Replaces any previously seeded set.
|
|
168
|
+
*/
|
|
169
|
+
setInitialDisabledPackageIds(ids) {
|
|
170
|
+
this.initialDisabledPackageIds = new Set(ids);
|
|
171
|
+
}
|
|
147
172
|
// ==========================================
|
|
148
173
|
// Namespace Management
|
|
149
174
|
// ==========================================
|
|
@@ -338,6 +363,47 @@ var SchemaRegistry = class {
|
|
|
338
363
|
const contributors = this.objectContributors.get(fqn);
|
|
339
364
|
return contributors?.find((c) => c.ownership === "own");
|
|
340
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
|
+
}
|
|
341
407
|
/**
|
|
342
408
|
* Unregister all objects contributed by a package.
|
|
343
409
|
*
|
|
@@ -469,10 +535,23 @@ var SchemaRegistry = class {
|
|
|
469
535
|
return this.getAllObjects(packageId);
|
|
470
536
|
}
|
|
471
537
|
const items = Array.from(this.metadata.get(type)?.values() || []);
|
|
538
|
+
let result = items;
|
|
472
539
|
if (packageId) {
|
|
473
|
-
|
|
540
|
+
result = result.filter((item) => item._packageId === packageId);
|
|
474
541
|
}
|
|
475
|
-
|
|
542
|
+
if (type !== "package") {
|
|
543
|
+
result = result.filter((item) => !this.isPackageDisabled(item?._packageId));
|
|
544
|
+
}
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Whether a package has been explicitly disabled. Unknown packages and
|
|
549
|
+
* items with no owning package are treated as enabled.
|
|
550
|
+
*/
|
|
551
|
+
isPackageDisabled(packageId) {
|
|
552
|
+
if (!packageId) return false;
|
|
553
|
+
const pkg = this.getPackage(packageId);
|
|
554
|
+
return pkg?.enabled === false || pkg?.status === "disabled";
|
|
476
555
|
}
|
|
477
556
|
/**
|
|
478
557
|
* Get all registered metadata types (Kinds)
|
|
@@ -489,12 +568,14 @@ var SchemaRegistry = class {
|
|
|
489
568
|
// ==========================================
|
|
490
569
|
installPackage(manifest, settings) {
|
|
491
570
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
571
|
+
const disabled = this.initialDisabledPackageIds.has(manifest.id);
|
|
492
572
|
const pkg = {
|
|
493
573
|
manifest,
|
|
494
|
-
status: "installed",
|
|
495
|
-
enabled:
|
|
574
|
+
status: disabled ? "disabled" : "installed",
|
|
575
|
+
enabled: !disabled,
|
|
496
576
|
installedAt: now,
|
|
497
577
|
updatedAt: now,
|
|
578
|
+
...disabled ? { statusChangedAt: now } : {},
|
|
498
579
|
settings
|
|
499
580
|
};
|
|
500
581
|
if (manifest.namespace) {
|
|
@@ -564,10 +645,88 @@ var SchemaRegistry = class {
|
|
|
564
645
|
this.registerItem("app", app, "name", packageId);
|
|
565
646
|
}
|
|
566
647
|
getApp(name) {
|
|
567
|
-
|
|
648
|
+
const app = this.getItem("app", name);
|
|
649
|
+
if (!app) return app;
|
|
650
|
+
return this.applyNavContributions(app);
|
|
568
651
|
}
|
|
569
652
|
getAllApps() {
|
|
570
|
-
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;
|
|
571
730
|
}
|
|
572
731
|
// ==========================================
|
|
573
732
|
// Plugin Helpers
|
|
@@ -626,6 +785,7 @@ var SchemaRegistry = class {
|
|
|
626
785
|
this.mergedObjectCache.clear();
|
|
627
786
|
this.namespaceRegistry.clear();
|
|
628
787
|
this.metadata.clear();
|
|
788
|
+
this.appNavContributions.clear();
|
|
629
789
|
this.log("[Registry] Reset complete");
|
|
630
790
|
}
|
|
631
791
|
};
|
|
@@ -780,6 +940,12 @@ var SysMetadataRepository = class {
|
|
|
780
940
|
version,
|
|
781
941
|
updated_at: now
|
|
782
942
|
};
|
|
943
|
+
if (existing) {
|
|
944
|
+
const existingPkg = existing.package_id ?? null;
|
|
945
|
+
parentRowData.package_id = existingPkg ?? opts.packageId ?? null;
|
|
946
|
+
} else {
|
|
947
|
+
parentRowData.package_id = opts.packageId ?? null;
|
|
948
|
+
}
|
|
783
949
|
if (existing) {
|
|
784
950
|
const existingId = existing.id;
|
|
785
951
|
if (existingId === void 0) {
|
|
@@ -1311,8 +1477,9 @@ var SysMetadataRepository = class {
|
|
|
1311
1477
|
import { ConflictError as ConflictError2 } from "@objectstack/metadata-core";
|
|
1312
1478
|
import { parseFilterAST, isFilterAST } from "@objectstack/spec/data";
|
|
1313
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";
|
|
1314
1481
|
import { METADATA_FORM_REGISTRY } from "@objectstack/spec/system";
|
|
1315
|
-
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";
|
|
1316
1483
|
import {
|
|
1317
1484
|
extractProtection,
|
|
1318
1485
|
evaluateLockForWrite,
|
|
@@ -2010,6 +2177,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
2010
2177
|
const zodSchema = getMetadataTypeSchema2(singular);
|
|
2011
2178
|
const schema = (zodSchema ? toJsonSchemaSafe(zodSchema) : void 0) ?? HAND_CRAFTED_SCHEMAS[singular];
|
|
2012
2179
|
const form = TYPE_TO_FORM[singular];
|
|
2180
|
+
const typeActions = getMetadataTypeActions(singular);
|
|
2013
2181
|
const base = registryByType.get(singular);
|
|
2014
2182
|
if (base) {
|
|
2015
2183
|
const isEnvOverridden = writableOverrides.has(singular);
|
|
@@ -2021,7 +2189,11 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
2021
2189
|
allowOrgOverride: base.allowOrgOverride || isEnvOverridden,
|
|
2022
2190
|
overrideSource: isEnvOverridden && !base.allowOrgOverride ? "env" : "registry",
|
|
2023
2191
|
schema,
|
|
2024
|
-
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 } : {}
|
|
2025
2197
|
};
|
|
2026
2198
|
}
|
|
2027
2199
|
return {
|
|
@@ -2040,7 +2212,9 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
2040
2212
|
domain: "system",
|
|
2041
2213
|
overrideSource: writableOverrides.has(singular) ? "env" : "registry",
|
|
2042
2214
|
schema,
|
|
2043
|
-
form
|
|
2215
|
+
form,
|
|
2216
|
+
// Plugin-registered actions on a type with no registry entry.
|
|
2217
|
+
...typeActions.length ? { actions: typeActions } : {}
|
|
2044
2218
|
};
|
|
2045
2219
|
}).sort((a, b) => {
|
|
2046
2220
|
if (a.domain !== b.domain) return a.domain.localeCompare(b.domain);
|
|
@@ -2138,13 +2312,13 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
2138
2312
|
state: "active",
|
|
2139
2313
|
organization_id: oid
|
|
2140
2314
|
};
|
|
2141
|
-
if (packageId) whereClause.
|
|
2315
|
+
if (packageId) whereClause.package_id = packageId;
|
|
2142
2316
|
let rs = await this.engine.find("sys_metadata", { where: whereClause });
|
|
2143
2317
|
if (!rs || rs.length === 0) {
|
|
2144
2318
|
const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
|
|
2145
2319
|
if (alt) {
|
|
2146
2320
|
const altWhere = { type: alt, state: "active", organization_id: oid };
|
|
2147
|
-
if (packageId) altWhere.
|
|
2321
|
+
if (packageId) altWhere.package_id = packageId;
|
|
2148
2322
|
rs = await this.engine.find("sys_metadata", { where: altWhere });
|
|
2149
2323
|
}
|
|
2150
2324
|
}
|
|
@@ -2167,6 +2341,10 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
2167
2341
|
for (const record of records) {
|
|
2168
2342
|
const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
2169
2343
|
if (data && typeof data === "object" && "name" in data) {
|
|
2344
|
+
const recPkg = record.package_id ?? void 0;
|
|
2345
|
+
if (recPkg && data._packageId === void 0) {
|
|
2346
|
+
data._packageId = recPkg;
|
|
2347
|
+
}
|
|
2170
2348
|
byName.set(data.name, data);
|
|
2171
2349
|
}
|
|
2172
2350
|
if (this.environmentId === void 0) {
|
|
@@ -2206,6 +2384,17 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
2206
2384
|
}
|
|
2207
2385
|
} catch {
|
|
2208
2386
|
}
|
|
2387
|
+
if (request.type !== "package" && request.type !== "object" && request.type !== "objects") {
|
|
2388
|
+
items = items.filter(
|
|
2389
|
+
(it) => !this.engine.registry.isPackageDisabled(it?._packageId)
|
|
2390
|
+
);
|
|
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
|
+
}
|
|
2209
2398
|
return {
|
|
2210
2399
|
type: request.type,
|
|
2211
2400
|
items: decorateMetadataItems(
|
|
@@ -2249,6 +2438,10 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
2249
2438
|
const record = (orgId ? await findOverlay(orgId) : void 0) ?? await findOverlay(null);
|
|
2250
2439
|
if (record) {
|
|
2251
2440
|
item = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
|
|
2441
|
+
const recPkg = record.package_id ?? void 0;
|
|
2442
|
+
if (recPkg && item && typeof item === "object" && item._packageId === void 0) {
|
|
2443
|
+
item._packageId = recPkg;
|
|
2444
|
+
}
|
|
2252
2445
|
}
|
|
2253
2446
|
} catch {
|
|
2254
2447
|
}
|
|
@@ -2291,6 +2484,9 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
2291
2484
|
if (alt) item = this.engine.registry.getItem(alt, request.name);
|
|
2292
2485
|
}
|
|
2293
2486
|
}
|
|
2487
|
+
if ((request.type === "app" || request.type === "apps") && item) {
|
|
2488
|
+
item = this.engine.registry.applyNavContributions(item);
|
|
2489
|
+
}
|
|
2294
2490
|
const artifactItem = this.lookupArtifactItem(request.type, request.name);
|
|
2295
2491
|
const decorated = decorateMetadataItem(
|
|
2296
2492
|
request.type,
|
|
@@ -3049,7 +3245,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
3049
3245
|
throw new Error(`Metadata item ${request.type}/${request.name} not found`);
|
|
3050
3246
|
}
|
|
3051
3247
|
const content = JSON.stringify(item);
|
|
3052
|
-
const hash = simpleHash(content);
|
|
3248
|
+
const hash = simpleHash(request.locale ? `${request.locale}\0${content}` : content);
|
|
3053
3249
|
const etag = { value: hash, weak: false };
|
|
3054
3250
|
if (request.cacheRequest?.ifNoneMatch) {
|
|
3055
3251
|
const clientEtag = request.cacheRequest.ifNoneMatch.replace(/^"(.*)"$/, "$1").replace(/^W\/"(.*)"$/, "$1");
|
|
@@ -3695,7 +3891,8 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
3695
3891
|
actor: request.actor ?? "system",
|
|
3696
3892
|
source: "protocol.saveMetaItem",
|
|
3697
3893
|
intent,
|
|
3698
|
-
state: mode === "draft" ? "draft" : "active"
|
|
3894
|
+
state: mode === "draft" ? "draft" : "active",
|
|
3895
|
+
...request.packageId !== void 0 ? { packageId: request.packageId } : {}
|
|
3699
3896
|
});
|
|
3700
3897
|
if (mode === "publish") {
|
|
3701
3898
|
this.applyObjectRegistryMutation(request);
|
|
@@ -3746,12 +3943,16 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
3746
3943
|
where: scopedWhere
|
|
3747
3944
|
});
|
|
3748
3945
|
if (existing) {
|
|
3749
|
-
|
|
3946
|
+
const updateRow = {
|
|
3750
3947
|
metadata: JSON.stringify(request.item),
|
|
3751
3948
|
updated_at: now,
|
|
3752
3949
|
version: (existing.version || 0) + 1,
|
|
3753
3950
|
state: "active"
|
|
3754
|
-
}
|
|
3951
|
+
};
|
|
3952
|
+
const existingPkg = existing.package_id ?? null;
|
|
3953
|
+
const nextPkg = existingPkg ?? request.packageId ?? null;
|
|
3954
|
+
if (nextPkg !== null) updateRow.package_id = nextPkg;
|
|
3955
|
+
await this.engine.update("sys_metadata", updateRow, {
|
|
3755
3956
|
where: { id: existing.id }
|
|
3756
3957
|
});
|
|
3757
3958
|
} else {
|
|
@@ -3771,6 +3972,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
|
|
|
3771
3972
|
updated_at: now,
|
|
3772
3973
|
organization_id: orgId
|
|
3773
3974
|
};
|
|
3975
|
+
if (request.packageId) row.package_id = request.packageId;
|
|
3774
3976
|
await this.engine.insert("sys_metadata", row);
|
|
3775
3977
|
}
|
|
3776
3978
|
return {
|
|
@@ -4495,8 +4697,33 @@ var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
|
|
|
4495
4697
|
import { ExecutionContextSchema } from "@objectstack/spec/kernel";
|
|
4496
4698
|
import { createLogger } from "@objectstack/core";
|
|
4497
4699
|
import { CoreServiceName, StorageNameMapping } from "@objectstack/spec/system";
|
|
4498
|
-
|
|
4499
|
-
|
|
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";
|
|
4500
4727
|
|
|
4501
4728
|
// src/hook-wrappers.ts
|
|
4502
4729
|
import { ExpressionEngine } from "@objectstack/formula";
|
|
@@ -5050,6 +5277,101 @@ function validateRecord(objectSchema, data, mode) {
|
|
|
5050
5277
|
if (errors.length > 0) throw new ValidationError(errors);
|
|
5051
5278
|
}
|
|
5052
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
|
+
|
|
5053
5375
|
// src/in-memory-aggregation.ts
|
|
5054
5376
|
function applyInMemoryAggregation(rows, ast) {
|
|
5055
5377
|
const groupBy = ast.groupBy ?? [];
|
|
@@ -5207,7 +5529,7 @@ function planFormulaProjection(schema, requestedFields) {
|
|
|
5207
5529
|
if (def?.type === "formula" && def.expression) {
|
|
5208
5530
|
const expr = typeof def.expression === "string" ? { dialect: "cel", source: def.expression } : def.expression;
|
|
5209
5531
|
plan.push({ name: f, expression: expr });
|
|
5210
|
-
|
|
5532
|
+
ExpressionEngine3.compile(expr);
|
|
5211
5533
|
} else if (Array.isArray(requestedFields) && requestedFields.length > 0) {
|
|
5212
5534
|
projected.add(f);
|
|
5213
5535
|
}
|
|
@@ -5229,7 +5551,7 @@ function applyFormulaPlan(plan, records) {
|
|
|
5229
5551
|
for (const rec of records) {
|
|
5230
5552
|
if (rec == null) continue;
|
|
5231
5553
|
for (const fp of plan) {
|
|
5232
|
-
const r =
|
|
5554
|
+
const r = ExpressionEngine3.evaluate(fp.expression, { record: rec });
|
|
5233
5555
|
rec[fp.name] = r.ok ? r.value : null;
|
|
5234
5556
|
}
|
|
5235
5557
|
}
|
|
@@ -5239,7 +5561,7 @@ function resolveMetadataItemName(key, item) {
|
|
|
5239
5561
|
if (item.name) return item.name;
|
|
5240
5562
|
if (item.id) return item.id;
|
|
5241
5563
|
if (key === "views") {
|
|
5242
|
-
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;
|
|
5243
5565
|
}
|
|
5244
5566
|
return void 0;
|
|
5245
5567
|
}
|
|
@@ -5251,6 +5573,11 @@ var _ObjectQL = class _ObjectQL {
|
|
|
5251
5573
|
this.datasourceMapping = [];
|
|
5252
5574
|
// Package manifests registry (for defaultDatasource lookup)
|
|
5253
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();
|
|
5254
5581
|
// Per-object hooks with priority support
|
|
5255
5582
|
this.hooks = /* @__PURE__ */ new Map([
|
|
5256
5583
|
["beforeFind", []],
|
|
@@ -5633,7 +5960,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
5633
5960
|
if (f.defaultValue == null) continue;
|
|
5634
5961
|
const dv = f.defaultValue;
|
|
5635
5962
|
if (typeof dv === "object" && dv !== null && dv.dialect && typeof dv.source === "string") {
|
|
5636
|
-
const result =
|
|
5963
|
+
const result = ExpressionEngine3.evaluate(dv, {
|
|
5637
5964
|
now,
|
|
5638
5965
|
user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : void 0,
|
|
5639
5966
|
org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : void 0,
|
|
@@ -5673,6 +6000,12 @@ var _ObjectQL = class _ObjectQL {
|
|
|
5673
6000
|
if (id) {
|
|
5674
6001
|
this.manifests.set(id, manifest);
|
|
5675
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
|
+
}
|
|
5676
6009
|
this._registry.installPackage(manifest);
|
|
5677
6010
|
this.logger.debug("Installed Package", { id: manifest.id, name: manifest.name, namespace });
|
|
5678
6011
|
if (manifest.objects) {
|
|
@@ -5726,6 +6059,15 @@ var _ObjectQL = class _ObjectQL {
|
|
|
5726
6059
|
this._registry.registerApp(resolved, id);
|
|
5727
6060
|
this.logger.debug("Registered manifest-as-app", { app: manifest.name, from: id });
|
|
5728
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
|
+
}
|
|
5729
6071
|
const metadataArrayKeys = [
|
|
5730
6072
|
// UI Protocol
|
|
5731
6073
|
"actions",
|
|
@@ -5748,6 +6090,8 @@ var _ObjectQL = class _ObjectQL {
|
|
|
5748
6090
|
"policies",
|
|
5749
6091
|
// AI Protocol
|
|
5750
6092
|
"agents",
|
|
6093
|
+
"tools",
|
|
6094
|
+
"skills",
|
|
5751
6095
|
"ragPipelines",
|
|
5752
6096
|
// API Protocol
|
|
5753
6097
|
"apis",
|
|
@@ -5767,6 +6111,11 @@ var _ObjectQL = class _ObjectQL {
|
|
|
5767
6111
|
if (itemName) {
|
|
5768
6112
|
const toRegister = item.name === itemName ? item : { ...item, name: itemName };
|
|
5769
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
|
+
}
|
|
5770
6119
|
} else {
|
|
5771
6120
|
this.logger.warn(`Skipping ${pluralToSingular(key)} without a derivable name`, { id });
|
|
5772
6121
|
}
|
|
@@ -5919,6 +6268,41 @@ var _ObjectQL = class _ObjectQL {
|
|
|
5919
6268
|
this.logger.info("Set default driver", { driverName: driver.name });
|
|
5920
6269
|
}
|
|
5921
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
|
+
}
|
|
5922
6306
|
/**
|
|
5923
6307
|
* Set the realtime service for publishing data change events.
|
|
5924
6308
|
* Should be called after kernel resolves the realtime service.
|
|
@@ -5929,6 +6313,141 @@ var _ObjectQL = class _ObjectQL {
|
|
|
5929
6313
|
this.realtimeService = service;
|
|
5930
6314
|
this.logger.info("RealtimeService configured for data events");
|
|
5931
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
|
+
}
|
|
5932
6451
|
/**
|
|
5933
6452
|
* Helper to get object definition
|
|
5934
6453
|
*/
|
|
@@ -6221,6 +6740,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6221
6740
|
hookContext.event = "afterFind";
|
|
6222
6741
|
hookContext.result = result;
|
|
6223
6742
|
await this.triggerHooks("afterFind", hookContext);
|
|
6743
|
+
this.maskSecretFields(object, hookContext.result);
|
|
6224
6744
|
return hookContext.result;
|
|
6225
6745
|
} catch (e) {
|
|
6226
6746
|
this.logger.error("Find operation failed", e, { object });
|
|
@@ -6262,6 +6782,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6262
6782
|
const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context);
|
|
6263
6783
|
result = expanded[0];
|
|
6264
6784
|
}
|
|
6785
|
+
this.maskSecretFields(objectName, result);
|
|
6265
6786
|
return result;
|
|
6266
6787
|
});
|
|
6267
6788
|
return opCtx.result;
|
|
@@ -6269,6 +6790,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6269
6790
|
async insert(object, data, options) {
|
|
6270
6791
|
object = this.resolveObjectName(object);
|
|
6271
6792
|
this.logger.debug("Insert operation starting", { object, isBatch: Array.isArray(data) });
|
|
6793
|
+
this.assertWriteAllowed(object, "insert");
|
|
6272
6794
|
const driver = this.getDriver(object);
|
|
6273
6795
|
const opCtx = {
|
|
6274
6796
|
object,
|
|
@@ -6297,7 +6819,13 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6297
6819
|
const rows = hookContext.input.data.map(
|
|
6298
6820
|
(row) => this.applyFieldDefaults(object, row, opCtx.context, nowSnap)
|
|
6299
6821
|
);
|
|
6300
|
-
for (const r of rows)
|
|
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
|
+
}
|
|
6301
6829
|
if (driver.bulkCreate) {
|
|
6302
6830
|
result = await driver.bulkCreate(object, rows, hookContext.input.options);
|
|
6303
6831
|
} else {
|
|
@@ -6310,7 +6838,9 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6310
6838
|
opCtx.context,
|
|
6311
6839
|
nowSnap
|
|
6312
6840
|
);
|
|
6841
|
+
await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options);
|
|
6313
6842
|
validateRecord(schemaForValidation, row, "insert");
|
|
6843
|
+
evaluateValidationRules(schemaForValidation, row, "insert", { logger: this.logger });
|
|
6314
6844
|
result = await driver.create(object, row, hookContext.input.options);
|
|
6315
6845
|
}
|
|
6316
6846
|
hookContext.event = "afterInsert";
|
|
@@ -6360,6 +6890,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6360
6890
|
async update(object, data, options) {
|
|
6361
6891
|
object = this.resolveObjectName(object);
|
|
6362
6892
|
this.logger.debug("Update operation starting", { object });
|
|
6893
|
+
this.assertWriteAllowed(object, "update");
|
|
6363
6894
|
const driver = this.getDriver(object);
|
|
6364
6895
|
let id = data.id;
|
|
6365
6896
|
if (!id && options?.where && typeof options.where === "object" && "id" in options.where) {
|
|
@@ -6386,11 +6917,23 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6386
6917
|
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options);
|
|
6387
6918
|
try {
|
|
6388
6919
|
let result;
|
|
6920
|
+
let priorRecord = null;
|
|
6921
|
+
const updateSchema = this._registry.getObject(object);
|
|
6389
6922
|
if (hookContext.input.id) {
|
|
6390
|
-
|
|
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 });
|
|
6391
6930
|
result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
|
|
6392
6931
|
} else if (options?.multi && driver.updateMany) {
|
|
6393
|
-
|
|
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
|
+
}
|
|
6394
6937
|
const ast = { object, where: options.where };
|
|
6395
6938
|
result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options);
|
|
6396
6939
|
} else {
|
|
@@ -6398,6 +6941,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6398
6941
|
}
|
|
6399
6942
|
hookContext.event = "afterUpdate";
|
|
6400
6943
|
hookContext.result = result;
|
|
6944
|
+
if (priorRecord) hookContext.previous = priorRecord;
|
|
6401
6945
|
await this.triggerHooks("afterUpdate", hookContext);
|
|
6402
6946
|
if (this.realtimeService) {
|
|
6403
6947
|
try {
|
|
@@ -6430,6 +6974,7 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6430
6974
|
async delete(object, options) {
|
|
6431
6975
|
object = this.resolveObjectName(object);
|
|
6432
6976
|
this.logger.debug("Delete operation starting", { object });
|
|
6977
|
+
this.assertWriteAllowed(object, "delete");
|
|
6433
6978
|
const driver = this.getDriver(object);
|
|
6434
6979
|
let id = void 0;
|
|
6435
6980
|
if (options?.where && typeof options.where === "object" && "id" in options.where) {
|
|
@@ -6689,6 +7234,26 @@ var _ObjectQL = class _ObjectQL {
|
|
|
6689
7234
|
getDriverByName(name) {
|
|
6690
7235
|
return this.drivers.get(name);
|
|
6691
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
|
+
}
|
|
6692
7257
|
/**
|
|
6693
7258
|
* Get the driver responsible for the given object.
|
|
6694
7259
|
*
|
|
@@ -7622,7 +8187,7 @@ var ObjectQLPlugin = class {
|
|
|
7622
8187
|
*/
|
|
7623
8188
|
async loadMetadataFromService(metadataService, ctx) {
|
|
7624
8189
|
ctx.logger.info("Syncing metadata from external service into ObjectQL registry...");
|
|
7625
|
-
const metadataTypes = ["object", "view", "app", "flow", "
|
|
8190
|
+
const metadataTypes = ["object", "view", "app", "flow", "function", "hook"];
|
|
7626
8191
|
let totalLoaded = 0;
|
|
7627
8192
|
for (const type of metadataTypes) {
|
|
7628
8193
|
try {
|
|
@@ -7772,6 +8337,8 @@ export {
|
|
|
7772
8337
|
ObjectRepository,
|
|
7773
8338
|
ObjectStackProtocolImplementation,
|
|
7774
8339
|
RESERVED_NAMESPACES,
|
|
8340
|
+
SECRET_MASK,
|
|
8341
|
+
SECRET_REF_PREFIX,
|
|
7775
8342
|
SchemaRegistry,
|
|
7776
8343
|
ScopedContext,
|
|
7777
8344
|
SysMetadataRepository,
|
|
@@ -7780,11 +8347,18 @@ export {
|
|
|
7780
8347
|
applySystemFields,
|
|
7781
8348
|
bindHooksToEngine,
|
|
7782
8349
|
bucketDateValue,
|
|
8350
|
+
collectSecretFields,
|
|
7783
8351
|
computeFQN,
|
|
7784
8352
|
convertIntrospectedSchemaToObjects,
|
|
7785
8353
|
createObjectQLKernel,
|
|
8354
|
+
evaluateValidationRules,
|
|
8355
|
+
isSecretRef,
|
|
8356
|
+
legalNextStates,
|
|
8357
|
+
makeSecretRef,
|
|
8358
|
+
needsPriorRecord,
|
|
7786
8359
|
noopHookMetricsRecorder,
|
|
7787
8360
|
parseFQN,
|
|
8361
|
+
parseSecretRef,
|
|
7788
8362
|
toTitleCase,
|
|
7789
8363
|
validateRecord,
|
|
7790
8364
|
wrapDeclarativeHook
|