@object-ui/data-objectstack 6.1.0 → 6.2.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/CHANGELOG.md +87 -0
- package/dist/index.cjs +177 -0
- package/dist/index.d.cts +157 -1
- package/dist/index.d.ts +157 -1
- package/dist/index.js +176 -0
- package/package.json +4 -4
- package/src/index.ts +15 -0
- package/src/metadata-client.test.ts +124 -0
- package/src/metadata-client.ts +338 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# @object-ui/data-objectstack
|
|
2
2
|
|
|
3
|
+
## 6.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ec8dcde: Add visual editing for object & field metadata in the Setup app.
|
|
8
|
+
|
|
9
|
+
**`@object-ui/data-objectstack`** — new `MetadataClient` class. A thin,
|
|
10
|
+
auth-friendly wrapper over the framework's `/api/v1/meta/*` REST
|
|
11
|
+
endpoints (list / get / save / reset / history), with first-class
|
|
12
|
+
support for `If-Match` (optimistic concurrency), `X-Actor` (audit
|
|
13
|
+
attribution), environment-scoped paths
|
|
14
|
+
(`/environments/:id/meta/*`), and 404-as-null semantics. Use
|
|
15
|
+
`new MetadataClient({ baseUrl })` or `client.withEnvironment(id)` to
|
|
16
|
+
target a specific environment.
|
|
17
|
+
|
|
18
|
+
**`@object-ui/plugin-designer`** — two new route-ready pages that
|
|
19
|
+
together close the "Data Model" management loop in the Setup app:
|
|
20
|
+
- `MetadataObjectsPage` — lists every object schema (via
|
|
21
|
+
`MetadataClient.list('object')`), renders the existing
|
|
22
|
+
`ObjectManager`, and persists edits/deletes through PUT/DELETE on
|
|
23
|
+
the metadata REST surface. Honours `allowRuntimeCreate` and
|
|
24
|
+
surfaces server errors verbatim.
|
|
25
|
+
- `MetadataFieldsPage` — for a single object, loads the parent
|
|
26
|
+
schema, projects `fields` into the existing `FieldDesigner`, and
|
|
27
|
+
on save merges the edited field map back into the object before
|
|
28
|
+
issuing a single PUT. Preserves unknown per-field attributes so
|
|
29
|
+
nothing the designer doesn't render is dropped.
|
|
30
|
+
|
|
31
|
+
Both pages take either a pre-built `MetadataClient` or a
|
|
32
|
+
`MetadataClientConfig`; neither imposes a routing convention on the
|
|
33
|
+
host app — they can be mounted anywhere (e.g.
|
|
34
|
+
`/apps/setup/_meta/object` and `/apps/setup/_meta/object/:name/fields`).
|
|
35
|
+
|
|
36
|
+
These additions do not modify the underlying `ObjectManager` /
|
|
37
|
+
`FieldDesigner` components, which remain pure controlled-input
|
|
38
|
+
components usable in non-REST contexts.
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- fe3c1d3: Metadata Admin engine — unified UI for all 27 metadata types.
|
|
43
|
+
|
|
44
|
+
A generic, schema-driven admin shell that replaces the old per-type
|
|
45
|
+
bespoke pages with a single registry-driven engine. Admins can now browse,
|
|
46
|
+
create, override, diff, and roll back every registered metadata type from
|
|
47
|
+
the Setup app → _All Metadata Types_.
|
|
48
|
+
|
|
49
|
+
### New: `@object-ui/app-shell` views/metadata-admin
|
|
50
|
+
- **`MetadataDirectoryPage`** — auto-grouped tile directory by domain, with
|
|
51
|
+
free-text search, domain chips, and a _Writable only_ filter.
|
|
52
|
+
- **`MetadataResourceListPage` / `MetadataResourceEditPage` / `…CreatePage` / `…HistoryPage`** —
|
|
53
|
+
generic CRUD shell. Uses the new `/meta/types` schema field to render
|
|
54
|
+
SchemaForm; uses `?layers=code,overlay,effective` to power a 3-state diff
|
|
55
|
+
tab; uses `/references` to warn before destructive deletes.
|
|
56
|
+
- **`MetadataQuickFind`** — Cmd+Shift+M palette searching across types and
|
|
57
|
+
items.
|
|
58
|
+
- **`PermissionMatrixEditor`** — Salesforce-style matrix custom editor for
|
|
59
|
+
`type=permission`. Objects × CRUD/VAMA/lifecycle columns with cascade
|
|
60
|
+
rules (viewAllRecords ⟹ allowRead, etc.), expandable per-object field
|
|
61
|
+
R/W subtable, bulk-set (R / CRUD / All / None), filter, _only granted_
|
|
62
|
+
toggle, destructive-change confirmation, profile switch.
|
|
63
|
+
- **`DesignerEditorWrapper`** — generic load–edit–save shell that hosts any
|
|
64
|
+
bespoke designer (`ObjectViewConfigurator`, `DashboardEditor`,
|
|
65
|
+
`PageCanvasEditor`, …). Handles dirty tracking, Save / Reset / Refresh /
|
|
66
|
+
History buttons, and the read-only fallback when `allowOrgOverride` is
|
|
67
|
+
false.
|
|
68
|
+
- **`i18n.ts`** — bilingual (`en-US`, `zh-CN`) bundle for built-in type
|
|
69
|
+
labels, domain labels, and engine UI strings, with `detectLocale()` and a
|
|
70
|
+
`t(key)` helper.
|
|
71
|
+
|
|
72
|
+
### New routing variant
|
|
73
|
+
- App nav now supports `{ type: 'component', componentRef, params? }` items.
|
|
74
|
+
`AppContent` resolves them through the existing `ComponentRegistry`.
|
|
75
|
+
- Built-in components registered: `metadata:directory`, `metadata:resource`,
|
|
76
|
+
`metadata:object/edit` (FieldsPage), `metadata:permission/edit`
|
|
77
|
+
(PermissionMatrixEditor), and lazy designer wrappers for view / dashboard
|
|
78
|
+
/ page.
|
|
79
|
+
|
|
80
|
+
### Plugin-designer
|
|
81
|
+
- Lazy-exported `ObjectManager`, `FieldDesigner`, `ObjectViewConfigurator`,
|
|
82
|
+
`DashboardEditor`, `PageCanvasEditor`, `MetadataObjectsPage`, and
|
|
83
|
+
`MetadataFieldsPage` so the engine can mount them on demand.
|
|
84
|
+
|
|
85
|
+
The temporary `/dev/meta` route is removed. Setup app navigation flows
|
|
86
|
+
through the new component routes.
|
|
87
|
+
- @object-ui/types@6.2.0
|
|
88
|
+
- @object-ui/core@6.2.0
|
|
89
|
+
|
|
3
90
|
## 6.1.0
|
|
4
91
|
|
|
5
92
|
### Patch Changes
|
package/dist/index.cjs
CHANGED
|
@@ -28,6 +28,7 @@ __export(index_exports, {
|
|
|
28
28
|
ConcurrentUpdateError: () => ConcurrentUpdateError,
|
|
29
29
|
ConnectionError: () => ConnectionError,
|
|
30
30
|
IntegrationManager: () => IntegrationManager,
|
|
31
|
+
MetadataClient: () => MetadataClient,
|
|
31
32
|
MetadataNotFoundError: () => MetadataNotFoundError,
|
|
32
33
|
ObjectStackAdapter: () => ObjectStackAdapter,
|
|
33
34
|
ObjectStackError: () => ObjectStackError,
|
|
@@ -779,6 +780,181 @@ var IntegrationManager = class {
|
|
|
779
780
|
}
|
|
780
781
|
};
|
|
781
782
|
|
|
783
|
+
// src/metadata-client.ts
|
|
784
|
+
var META_PREFIX = "/meta";
|
|
785
|
+
var API_PREFIX = "/api/v1";
|
|
786
|
+
function buildBase(config) {
|
|
787
|
+
const trimmed = config.baseUrl.replace(/\/+$/, "");
|
|
788
|
+
const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
789
|
+
if (/\/api\/v\d+$/i.test(trimmed)) {
|
|
790
|
+
const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
|
|
791
|
+
const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
792
|
+
return `${stripped}${scopedNoApi}`;
|
|
793
|
+
}
|
|
794
|
+
return `${trimmed}${scoped}`;
|
|
795
|
+
}
|
|
796
|
+
async function parseError(res) {
|
|
797
|
+
let body;
|
|
798
|
+
try {
|
|
799
|
+
body = await res.json();
|
|
800
|
+
} catch {
|
|
801
|
+
body = await res.text().catch(() => void 0);
|
|
802
|
+
}
|
|
803
|
+
const message = (body && typeof body === "object" && "error" in body ? String(body.error) : void 0) ?? `Metadata request failed: ${res.status} ${res.statusText}`;
|
|
804
|
+
const err = new Error(message);
|
|
805
|
+
err.status = res.status;
|
|
806
|
+
err.body = body;
|
|
807
|
+
if (body && typeof body === "object" && "code" in body) {
|
|
808
|
+
err.code = String(body.code);
|
|
809
|
+
}
|
|
810
|
+
return err;
|
|
811
|
+
}
|
|
812
|
+
var MetadataClient = class _MetadataClient {
|
|
813
|
+
constructor(config) {
|
|
814
|
+
__publicField(this, "base");
|
|
815
|
+
__publicField(this, "fetchImpl");
|
|
816
|
+
__publicField(this, "headers");
|
|
817
|
+
this.base = buildBase(config);
|
|
818
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
819
|
+
this.headers = { Accept: "application/json", ...config.headers ?? {} };
|
|
820
|
+
}
|
|
821
|
+
/** Update the client's environment scope at runtime. */
|
|
822
|
+
withEnvironment(environmentId) {
|
|
823
|
+
return new _MetadataClient({
|
|
824
|
+
baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
|
|
825
|
+
environmentId,
|
|
826
|
+
fetch: this.fetchImpl,
|
|
827
|
+
headers: this.headers
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
/** List all registered metadata types (returns the registry rows). */
|
|
831
|
+
async listTypes() {
|
|
832
|
+
const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
833
|
+
if (!res.ok) throw await parseError(res);
|
|
834
|
+
const data = await res.json();
|
|
835
|
+
if (Array.isArray(data)) return data;
|
|
836
|
+
if (data && Array.isArray(data.entries)) return data.entries;
|
|
837
|
+
if (data && Array.isArray(data.items)) return data.items;
|
|
838
|
+
if (data && Array.isArray(data.types)) {
|
|
839
|
+
return data.types.map((t) => ({ type: t }));
|
|
840
|
+
}
|
|
841
|
+
return [];
|
|
842
|
+
}
|
|
843
|
+
/** List items of a metadata type (e.g. `object`, `field`, `view`). */
|
|
844
|
+
async list(type, options = {}) {
|
|
845
|
+
const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : "";
|
|
846
|
+
const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
|
|
847
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
848
|
+
if (!res.ok) throw await parseError(res);
|
|
849
|
+
const data = await res.json();
|
|
850
|
+
if (Array.isArray(data)) return data;
|
|
851
|
+
if (data && Array.isArray(data.items)) {
|
|
852
|
+
return data.items;
|
|
853
|
+
}
|
|
854
|
+
return [];
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Get a single metadata item. Returns the unwrapped item content
|
|
858
|
+
* (matching the framework REST handler which calls `res.json(item)`).
|
|
859
|
+
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
860
|
+
*/
|
|
861
|
+
async get(type, name) {
|
|
862
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}`;
|
|
863
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
864
|
+
if (res.status === 404) return null;
|
|
865
|
+
if (!res.ok) throw await parseError(res);
|
|
866
|
+
return await res.json();
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
870
|
+
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
871
|
+
* envelopes; we send bare for consistency.
|
|
872
|
+
*/
|
|
873
|
+
async save(type, name, item, options = {}) {
|
|
874
|
+
const qs = options.force ? "?force=true" : "";
|
|
875
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
876
|
+
const headers = {
|
|
877
|
+
...this.headers,
|
|
878
|
+
"Content-Type": "application/json"
|
|
879
|
+
};
|
|
880
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
881
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
882
|
+
const res = await this.fetchImpl(url, {
|
|
883
|
+
method: "PUT",
|
|
884
|
+
headers,
|
|
885
|
+
body: JSON.stringify(item)
|
|
886
|
+
});
|
|
887
|
+
if (!res.ok) throw await parseError(res);
|
|
888
|
+
return await res.json();
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
|
|
892
|
+
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
893
|
+
* customisation, if any), and `effective` (what the runtime sees).
|
|
894
|
+
*/
|
|
895
|
+
async layered(type, name) {
|
|
896
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true`;
|
|
897
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
898
|
+
if (res.status === 404) {
|
|
899
|
+
return { code: null, overlay: null, overlayScope: null, effective: null };
|
|
900
|
+
}
|
|
901
|
+
if (!res.ok) throw await parseError(res);
|
|
902
|
+
const body = await res.json();
|
|
903
|
+
const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
|
|
904
|
+
if (!hasEnvelope) {
|
|
905
|
+
return {
|
|
906
|
+
code: null,
|
|
907
|
+
overlay: null,
|
|
908
|
+
overlayScope: null,
|
|
909
|
+
effective: body
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
return {
|
|
913
|
+
code: body.code ?? null,
|
|
914
|
+
overlay: body.overlay ?? null,
|
|
915
|
+
overlayScope: body.overlayScope ?? null,
|
|
916
|
+
effective: body.effective ?? null
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Find every metadata item that references this one (Phase 3a). Useful
|
|
921
|
+
* for pre-delete impact analysis: "Are any views pointing at this
|
|
922
|
+
* object before I drop it?".
|
|
923
|
+
*/
|
|
924
|
+
async references(type, name) {
|
|
925
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
|
|
926
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
927
|
+
if (res.status === 404) return [];
|
|
928
|
+
if (!res.ok) throw await parseError(res);
|
|
929
|
+
const data = await res.json();
|
|
930
|
+
return Array.isArray(data) ? data : data?.items ?? [];
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Reset a metadata customization overlay back to the artifact default.
|
|
934
|
+
* Idempotent: returns the result even when no overlay row existed.
|
|
935
|
+
*/
|
|
936
|
+
async reset(type, name, options = {}) {
|
|
937
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}`;
|
|
938
|
+
const headers = { ...this.headers };
|
|
939
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
940
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
941
|
+
const res = await this.fetchImpl(url, { method: "DELETE", headers });
|
|
942
|
+
if (!res.ok) throw await parseError(res);
|
|
943
|
+
return await res.json();
|
|
944
|
+
}
|
|
945
|
+
/** Fetch the durable history (change log) for one metadata item. */
|
|
946
|
+
async history(type, name, options = {}) {
|
|
947
|
+
const params = new URLSearchParams();
|
|
948
|
+
if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
|
|
949
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
950
|
+
const qs = params.toString();
|
|
951
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
|
|
952
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
953
|
+
if (!res.ok) throw await parseError(res);
|
|
954
|
+
return await res.json();
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
|
|
782
958
|
// src/security.ts
|
|
783
959
|
var SecurityManager = class {
|
|
784
960
|
constructor(policy = {}) {
|
|
@@ -2348,6 +2524,7 @@ function createObjectStackAdapter(config) {
|
|
|
2348
2524
|
ConcurrentUpdateError,
|
|
2349
2525
|
ConnectionError,
|
|
2350
2526
|
IntegrationManager,
|
|
2527
|
+
MetadataClient,
|
|
2351
2528
|
MetadataNotFoundError,
|
|
2352
2529
|
ObjectStackAdapter,
|
|
2353
2530
|
ObjectStackError,
|
package/dist/index.d.cts
CHANGED
|
@@ -491,6 +491,162 @@ declare class IntegrationManager {
|
|
|
491
491
|
private send;
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
/**
|
|
495
|
+
* ObjectUI
|
|
496
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
497
|
+
*
|
|
498
|
+
* This source code is licensed under the MIT license found in the
|
|
499
|
+
* LICENSE file in the root directory of this source tree.
|
|
500
|
+
*/
|
|
501
|
+
/**
|
|
502
|
+
* MetadataClient
|
|
503
|
+
*
|
|
504
|
+
* Thin, framework-agnostic HTTP client for the ObjectStack metadata
|
|
505
|
+
* API (`/api/v1/meta/*`). Used by the platform Setup app surfaces in
|
|
506
|
+
* `@object-ui/plugin-designer` (Object Manager, Field Designer, etc.)
|
|
507
|
+
* so they can read and write protocol metadata without depending on
|
|
508
|
+
* `ObjectStackAdapter` (which is a generic data-source binding and
|
|
509
|
+
* carries a lot of view/dashboard-specific behaviour we don't need
|
|
510
|
+
* here).
|
|
511
|
+
*
|
|
512
|
+
* Endpoints (see `packages/rest/src/rest-server.ts`):
|
|
513
|
+
* GET /api/v1/meta — list all metadata types
|
|
514
|
+
* GET /api/v1/meta/:type — list items of a type
|
|
515
|
+
* GET /api/v1/meta/:type/:name — get one item (returns the
|
|
516
|
+
* unwrapped item content)
|
|
517
|
+
* PUT /api/v1/meta/:type/:name — save (overlay) one item
|
|
518
|
+
* honours `If-Match` for OCC
|
|
519
|
+
* DELETE /api/v1/meta/:type/:name — reset overlay to artifact
|
|
520
|
+
* GET /api/v1/meta/:type/:name/history — durable change log
|
|
521
|
+
*
|
|
522
|
+
* The client deliberately keeps the response shape opaque (typed as
|
|
523
|
+
* `unknown`/generic `T`) so callers explicitly narrow per-metadata-
|
|
524
|
+
* type, mirroring the framework's "single Zod source per type" rule.
|
|
525
|
+
*/
|
|
526
|
+
interface MetadataClientConfig {
|
|
527
|
+
/** Base URL of the ObjectStack server (no trailing slash needed). */
|
|
528
|
+
baseUrl: string;
|
|
529
|
+
/**
|
|
530
|
+
* Optional environment ID to scope reads/writes to a tenant. When
|
|
531
|
+
* provided, requests use the scoped path
|
|
532
|
+
* `/api/v1/environments/:environmentId/meta/...` and overlays are
|
|
533
|
+
* persisted in the environment's own metadata store.
|
|
534
|
+
*/
|
|
535
|
+
environmentId?: string;
|
|
536
|
+
/** Optional custom fetch implementation (defaults to `globalThis.fetch`). */
|
|
537
|
+
fetch?: typeof fetch;
|
|
538
|
+
/** Additional headers (e.g. auth) included on every request. */
|
|
539
|
+
headers?: Record<string, string>;
|
|
540
|
+
}
|
|
541
|
+
interface MetadataListOptions {
|
|
542
|
+
/** Filter by source package id (matches the `package` query param). */
|
|
543
|
+
packageId?: string;
|
|
544
|
+
}
|
|
545
|
+
interface MetadataSaveOptions {
|
|
546
|
+
/**
|
|
547
|
+
* Optimistic concurrency token (the `checksum` returned by the last
|
|
548
|
+
* read). When present, sent as the `If-Match` header so concurrent
|
|
549
|
+
* edits get a 409 instead of overwriting each other.
|
|
550
|
+
*/
|
|
551
|
+
ifMatch?: string;
|
|
552
|
+
/** Optional actor id, sent as `X-Actor` for history attribution. */
|
|
553
|
+
actor?: string;
|
|
554
|
+
/**
|
|
555
|
+
* Bypass destructive-change protection (Phase 3a). The server returns
|
|
556
|
+
* `409 destructive_change` with `issues[]` if a write would drop or
|
|
557
|
+
* narrow data; setting `force: true` adds `?force=true` to the request
|
|
558
|
+
* so the operator can confirm and proceed.
|
|
559
|
+
*/
|
|
560
|
+
force?: boolean;
|
|
561
|
+
}
|
|
562
|
+
/** Layered view of a metadata item — Phase 3a `?layers=true`. */
|
|
563
|
+
interface MetadataLayered<T = unknown> {
|
|
564
|
+
/** Code-level (artifact) item; null if the item only exists as an overlay. */
|
|
565
|
+
code: T | null;
|
|
566
|
+
/** Org/environment overlay (just the saved delta or full overlay row). */
|
|
567
|
+
overlay: T | null;
|
|
568
|
+
/** Overlay scope (`organization` | `environment` | `package` | null). */
|
|
569
|
+
overlayScope: string | null;
|
|
570
|
+
/** Merged effective view — what the runtime actually sees. */
|
|
571
|
+
effective: T | null;
|
|
572
|
+
}
|
|
573
|
+
/** Reference back-pointer — Phase 3a `/references`. */
|
|
574
|
+
interface MetadataReference {
|
|
575
|
+
/** Referencing item's metadata type. */
|
|
576
|
+
fromType: string;
|
|
577
|
+
/** Referencing item's name. */
|
|
578
|
+
fromName: string;
|
|
579
|
+
/** JSON path within the referencing item that holds the reference. */
|
|
580
|
+
path: string;
|
|
581
|
+
/** The actual value seen at `path` (the referenced name). */
|
|
582
|
+
value: string;
|
|
583
|
+
}
|
|
584
|
+
interface MetadataHistoryOptions {
|
|
585
|
+
/** Only return events after this sequence number. */
|
|
586
|
+
sinceSeq?: number;
|
|
587
|
+
/** Limit the number of events returned. */
|
|
588
|
+
limit?: number;
|
|
589
|
+
}
|
|
590
|
+
interface MetadataError extends Error {
|
|
591
|
+
status: number;
|
|
592
|
+
code?: string;
|
|
593
|
+
body?: unknown;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* MetadataClient — read/write protocol metadata via the framework REST API.
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* ```ts
|
|
600
|
+
* const client = new MetadataClient({ baseUrl: 'http://localhost:3000' });
|
|
601
|
+
* const objects = await client.list<{ name: string; label?: string }>('object');
|
|
602
|
+
* const account = await client.get<{ fields: Record<string, unknown> }>('object', 'account');
|
|
603
|
+
* await client.save('object', 'account', { ...account, label: 'Customer' });
|
|
604
|
+
* ```
|
|
605
|
+
*/
|
|
606
|
+
declare class MetadataClient {
|
|
607
|
+
private readonly base;
|
|
608
|
+
private readonly fetchImpl;
|
|
609
|
+
private readonly headers;
|
|
610
|
+
constructor(config: MetadataClientConfig);
|
|
611
|
+
/** Update the client's environment scope at runtime. */
|
|
612
|
+
withEnvironment(environmentId: string | undefined): MetadataClient;
|
|
613
|
+
/** List all registered metadata types (returns the registry rows). */
|
|
614
|
+
listTypes(): Promise<unknown[]>;
|
|
615
|
+
/** List items of a metadata type (e.g. `object`, `field`, `view`). */
|
|
616
|
+
list<T = unknown>(type: string, options?: MetadataListOptions): Promise<T[]>;
|
|
617
|
+
/**
|
|
618
|
+
* Get a single metadata item. Returns the unwrapped item content
|
|
619
|
+
* (matching the framework REST handler which calls `res.json(item)`).
|
|
620
|
+
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
621
|
+
*/
|
|
622
|
+
get<T = unknown>(type: string, name: string): Promise<T | null>;
|
|
623
|
+
/**
|
|
624
|
+
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
625
|
+
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
626
|
+
* envelopes; we send bare for consistency.
|
|
627
|
+
*/
|
|
628
|
+
save<T = unknown>(type: string, name: string, item: unknown, options?: MetadataSaveOptions): Promise<T>;
|
|
629
|
+
/**
|
|
630
|
+
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
|
|
631
|
+
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
632
|
+
* customisation, if any), and `effective` (what the runtime sees).
|
|
633
|
+
*/
|
|
634
|
+
layered<T = unknown>(type: string, name: string): Promise<MetadataLayered<T>>;
|
|
635
|
+
/**
|
|
636
|
+
* Find every metadata item that references this one (Phase 3a). Useful
|
|
637
|
+
* for pre-delete impact analysis: "Are any views pointing at this
|
|
638
|
+
* object before I drop it?".
|
|
639
|
+
*/
|
|
640
|
+
references(type: string, name: string): Promise<MetadataReference[]>;
|
|
641
|
+
/**
|
|
642
|
+
* Reset a metadata customization overlay back to the artifact default.
|
|
643
|
+
* Idempotent: returns the result even when no overlay row existed.
|
|
644
|
+
*/
|
|
645
|
+
reset<T = unknown>(type: string, name: string, options?: MetadataSaveOptions): Promise<T>;
|
|
646
|
+
/** Fetch the durable history (change log) for one metadata item. */
|
|
647
|
+
history<T = unknown>(type: string, name: string, options?: MetadataHistoryOptions): Promise<T>;
|
|
648
|
+
}
|
|
649
|
+
|
|
494
650
|
/**
|
|
495
651
|
* ObjectUI
|
|
496
652
|
* Copyright (c) 2024-present ObjectStack Inc.
|
|
@@ -1233,4 +1389,4 @@ declare function createObjectStackAdapter<T = unknown>(config: {
|
|
|
1233
1389
|
reconnectDelay?: number;
|
|
1234
1390
|
}): DataSource<T>;
|
|
1235
1391
|
|
|
1236
|
-
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConcurrentUpdateError, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type ObjectStackUserStateAdapterOptions, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, type UserDataAdapter, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, createObjectStackUserStateAdapter, generateContractManifest, getSharedDiscovery, is404Error, isConcurrentUpdateError, isErrorType, isObjectStackError, normaliseClientError, snapToGrid, validatePluginContract };
|
|
1392
|
+
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConcurrentUpdateError, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataClient, type MetadataClientConfig, type MetadataError, type MetadataHistoryOptions, type MetadataLayered, type MetadataListOptions, MetadataNotFoundError, type MetadataReference, type MetadataSaveOptions, ObjectStackAdapter, ObjectStackError, type ObjectStackUserStateAdapterOptions, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, type UserDataAdapter, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, createObjectStackUserStateAdapter, generateContractManifest, getSharedDiscovery, is404Error, isConcurrentUpdateError, isErrorType, isObjectStackError, normaliseClientError, snapToGrid, validatePluginContract };
|
package/dist/index.d.ts
CHANGED
|
@@ -491,6 +491,162 @@ declare class IntegrationManager {
|
|
|
491
491
|
private send;
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
/**
|
|
495
|
+
* ObjectUI
|
|
496
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
497
|
+
*
|
|
498
|
+
* This source code is licensed under the MIT license found in the
|
|
499
|
+
* LICENSE file in the root directory of this source tree.
|
|
500
|
+
*/
|
|
501
|
+
/**
|
|
502
|
+
* MetadataClient
|
|
503
|
+
*
|
|
504
|
+
* Thin, framework-agnostic HTTP client for the ObjectStack metadata
|
|
505
|
+
* API (`/api/v1/meta/*`). Used by the platform Setup app surfaces in
|
|
506
|
+
* `@object-ui/plugin-designer` (Object Manager, Field Designer, etc.)
|
|
507
|
+
* so they can read and write protocol metadata without depending on
|
|
508
|
+
* `ObjectStackAdapter` (which is a generic data-source binding and
|
|
509
|
+
* carries a lot of view/dashboard-specific behaviour we don't need
|
|
510
|
+
* here).
|
|
511
|
+
*
|
|
512
|
+
* Endpoints (see `packages/rest/src/rest-server.ts`):
|
|
513
|
+
* GET /api/v1/meta — list all metadata types
|
|
514
|
+
* GET /api/v1/meta/:type — list items of a type
|
|
515
|
+
* GET /api/v1/meta/:type/:name — get one item (returns the
|
|
516
|
+
* unwrapped item content)
|
|
517
|
+
* PUT /api/v1/meta/:type/:name — save (overlay) one item
|
|
518
|
+
* honours `If-Match` for OCC
|
|
519
|
+
* DELETE /api/v1/meta/:type/:name — reset overlay to artifact
|
|
520
|
+
* GET /api/v1/meta/:type/:name/history — durable change log
|
|
521
|
+
*
|
|
522
|
+
* The client deliberately keeps the response shape opaque (typed as
|
|
523
|
+
* `unknown`/generic `T`) so callers explicitly narrow per-metadata-
|
|
524
|
+
* type, mirroring the framework's "single Zod source per type" rule.
|
|
525
|
+
*/
|
|
526
|
+
interface MetadataClientConfig {
|
|
527
|
+
/** Base URL of the ObjectStack server (no trailing slash needed). */
|
|
528
|
+
baseUrl: string;
|
|
529
|
+
/**
|
|
530
|
+
* Optional environment ID to scope reads/writes to a tenant. When
|
|
531
|
+
* provided, requests use the scoped path
|
|
532
|
+
* `/api/v1/environments/:environmentId/meta/...` and overlays are
|
|
533
|
+
* persisted in the environment's own metadata store.
|
|
534
|
+
*/
|
|
535
|
+
environmentId?: string;
|
|
536
|
+
/** Optional custom fetch implementation (defaults to `globalThis.fetch`). */
|
|
537
|
+
fetch?: typeof fetch;
|
|
538
|
+
/** Additional headers (e.g. auth) included on every request. */
|
|
539
|
+
headers?: Record<string, string>;
|
|
540
|
+
}
|
|
541
|
+
interface MetadataListOptions {
|
|
542
|
+
/** Filter by source package id (matches the `package` query param). */
|
|
543
|
+
packageId?: string;
|
|
544
|
+
}
|
|
545
|
+
interface MetadataSaveOptions {
|
|
546
|
+
/**
|
|
547
|
+
* Optimistic concurrency token (the `checksum` returned by the last
|
|
548
|
+
* read). When present, sent as the `If-Match` header so concurrent
|
|
549
|
+
* edits get a 409 instead of overwriting each other.
|
|
550
|
+
*/
|
|
551
|
+
ifMatch?: string;
|
|
552
|
+
/** Optional actor id, sent as `X-Actor` for history attribution. */
|
|
553
|
+
actor?: string;
|
|
554
|
+
/**
|
|
555
|
+
* Bypass destructive-change protection (Phase 3a). The server returns
|
|
556
|
+
* `409 destructive_change` with `issues[]` if a write would drop or
|
|
557
|
+
* narrow data; setting `force: true` adds `?force=true` to the request
|
|
558
|
+
* so the operator can confirm and proceed.
|
|
559
|
+
*/
|
|
560
|
+
force?: boolean;
|
|
561
|
+
}
|
|
562
|
+
/** Layered view of a metadata item — Phase 3a `?layers=true`. */
|
|
563
|
+
interface MetadataLayered<T = unknown> {
|
|
564
|
+
/** Code-level (artifact) item; null if the item only exists as an overlay. */
|
|
565
|
+
code: T | null;
|
|
566
|
+
/** Org/environment overlay (just the saved delta or full overlay row). */
|
|
567
|
+
overlay: T | null;
|
|
568
|
+
/** Overlay scope (`organization` | `environment` | `package` | null). */
|
|
569
|
+
overlayScope: string | null;
|
|
570
|
+
/** Merged effective view — what the runtime actually sees. */
|
|
571
|
+
effective: T | null;
|
|
572
|
+
}
|
|
573
|
+
/** Reference back-pointer — Phase 3a `/references`. */
|
|
574
|
+
interface MetadataReference {
|
|
575
|
+
/** Referencing item's metadata type. */
|
|
576
|
+
fromType: string;
|
|
577
|
+
/** Referencing item's name. */
|
|
578
|
+
fromName: string;
|
|
579
|
+
/** JSON path within the referencing item that holds the reference. */
|
|
580
|
+
path: string;
|
|
581
|
+
/** The actual value seen at `path` (the referenced name). */
|
|
582
|
+
value: string;
|
|
583
|
+
}
|
|
584
|
+
interface MetadataHistoryOptions {
|
|
585
|
+
/** Only return events after this sequence number. */
|
|
586
|
+
sinceSeq?: number;
|
|
587
|
+
/** Limit the number of events returned. */
|
|
588
|
+
limit?: number;
|
|
589
|
+
}
|
|
590
|
+
interface MetadataError extends Error {
|
|
591
|
+
status: number;
|
|
592
|
+
code?: string;
|
|
593
|
+
body?: unknown;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* MetadataClient — read/write protocol metadata via the framework REST API.
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* ```ts
|
|
600
|
+
* const client = new MetadataClient({ baseUrl: 'http://localhost:3000' });
|
|
601
|
+
* const objects = await client.list<{ name: string; label?: string }>('object');
|
|
602
|
+
* const account = await client.get<{ fields: Record<string, unknown> }>('object', 'account');
|
|
603
|
+
* await client.save('object', 'account', { ...account, label: 'Customer' });
|
|
604
|
+
* ```
|
|
605
|
+
*/
|
|
606
|
+
declare class MetadataClient {
|
|
607
|
+
private readonly base;
|
|
608
|
+
private readonly fetchImpl;
|
|
609
|
+
private readonly headers;
|
|
610
|
+
constructor(config: MetadataClientConfig);
|
|
611
|
+
/** Update the client's environment scope at runtime. */
|
|
612
|
+
withEnvironment(environmentId: string | undefined): MetadataClient;
|
|
613
|
+
/** List all registered metadata types (returns the registry rows). */
|
|
614
|
+
listTypes(): Promise<unknown[]>;
|
|
615
|
+
/** List items of a metadata type (e.g. `object`, `field`, `view`). */
|
|
616
|
+
list<T = unknown>(type: string, options?: MetadataListOptions): Promise<T[]>;
|
|
617
|
+
/**
|
|
618
|
+
* Get a single metadata item. Returns the unwrapped item content
|
|
619
|
+
* (matching the framework REST handler which calls `res.json(item)`).
|
|
620
|
+
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
621
|
+
*/
|
|
622
|
+
get<T = unknown>(type: string, name: string): Promise<T | null>;
|
|
623
|
+
/**
|
|
624
|
+
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
625
|
+
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
626
|
+
* envelopes; we send bare for consistency.
|
|
627
|
+
*/
|
|
628
|
+
save<T = unknown>(type: string, name: string, item: unknown, options?: MetadataSaveOptions): Promise<T>;
|
|
629
|
+
/**
|
|
630
|
+
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
|
|
631
|
+
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
632
|
+
* customisation, if any), and `effective` (what the runtime sees).
|
|
633
|
+
*/
|
|
634
|
+
layered<T = unknown>(type: string, name: string): Promise<MetadataLayered<T>>;
|
|
635
|
+
/**
|
|
636
|
+
* Find every metadata item that references this one (Phase 3a). Useful
|
|
637
|
+
* for pre-delete impact analysis: "Are any views pointing at this
|
|
638
|
+
* object before I drop it?".
|
|
639
|
+
*/
|
|
640
|
+
references(type: string, name: string): Promise<MetadataReference[]>;
|
|
641
|
+
/**
|
|
642
|
+
* Reset a metadata customization overlay back to the artifact default.
|
|
643
|
+
* Idempotent: returns the result even when no overlay row existed.
|
|
644
|
+
*/
|
|
645
|
+
reset<T = unknown>(type: string, name: string, options?: MetadataSaveOptions): Promise<T>;
|
|
646
|
+
/** Fetch the durable history (change log) for one metadata item. */
|
|
647
|
+
history<T = unknown>(type: string, name: string, options?: MetadataHistoryOptions): Promise<T>;
|
|
648
|
+
}
|
|
649
|
+
|
|
494
650
|
/**
|
|
495
651
|
* ObjectUI
|
|
496
652
|
* Copyright (c) 2024-present ObjectStack Inc.
|
|
@@ -1233,4 +1389,4 @@ declare function createObjectStackAdapter<T = unknown>(config: {
|
|
|
1233
1389
|
reconnectDelay?: number;
|
|
1234
1390
|
}): DataSource<T>;
|
|
1235
1391
|
|
|
1236
|
-
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConcurrentUpdateError, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type ObjectStackUserStateAdapterOptions, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, type UserDataAdapter, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, createObjectStackUserStateAdapter, generateContractManifest, getSharedDiscovery, is404Error, isConcurrentUpdateError, isErrorType, isObjectStackError, normaliseClientError, snapToGrid, validatePluginContract };
|
|
1392
|
+
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConcurrentUpdateError, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataClient, type MetadataClientConfig, type MetadataError, type MetadataHistoryOptions, type MetadataLayered, type MetadataListOptions, MetadataNotFoundError, type MetadataReference, type MetadataSaveOptions, ObjectStackAdapter, ObjectStackError, type ObjectStackUserStateAdapterOptions, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, type UserDataAdapter, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, createObjectStackUserStateAdapter, generateContractManifest, getSharedDiscovery, is404Error, isConcurrentUpdateError, isErrorType, isObjectStackError, normaliseClientError, snapToGrid, validatePluginContract };
|
package/dist/index.js
CHANGED
|
@@ -732,6 +732,181 @@ var IntegrationManager = class {
|
|
|
732
732
|
}
|
|
733
733
|
};
|
|
734
734
|
|
|
735
|
+
// src/metadata-client.ts
|
|
736
|
+
var META_PREFIX = "/meta";
|
|
737
|
+
var API_PREFIX = "/api/v1";
|
|
738
|
+
function buildBase(config) {
|
|
739
|
+
const trimmed = config.baseUrl.replace(/\/+$/, "");
|
|
740
|
+
const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
741
|
+
if (/\/api\/v\d+$/i.test(trimmed)) {
|
|
742
|
+
const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
|
|
743
|
+
const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
744
|
+
return `${stripped}${scopedNoApi}`;
|
|
745
|
+
}
|
|
746
|
+
return `${trimmed}${scoped}`;
|
|
747
|
+
}
|
|
748
|
+
async function parseError(res) {
|
|
749
|
+
let body;
|
|
750
|
+
try {
|
|
751
|
+
body = await res.json();
|
|
752
|
+
} catch {
|
|
753
|
+
body = await res.text().catch(() => void 0);
|
|
754
|
+
}
|
|
755
|
+
const message = (body && typeof body === "object" && "error" in body ? String(body.error) : void 0) ?? `Metadata request failed: ${res.status} ${res.statusText}`;
|
|
756
|
+
const err = new Error(message);
|
|
757
|
+
err.status = res.status;
|
|
758
|
+
err.body = body;
|
|
759
|
+
if (body && typeof body === "object" && "code" in body) {
|
|
760
|
+
err.code = String(body.code);
|
|
761
|
+
}
|
|
762
|
+
return err;
|
|
763
|
+
}
|
|
764
|
+
var MetadataClient = class _MetadataClient {
|
|
765
|
+
constructor(config) {
|
|
766
|
+
__publicField(this, "base");
|
|
767
|
+
__publicField(this, "fetchImpl");
|
|
768
|
+
__publicField(this, "headers");
|
|
769
|
+
this.base = buildBase(config);
|
|
770
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
771
|
+
this.headers = { Accept: "application/json", ...config.headers ?? {} };
|
|
772
|
+
}
|
|
773
|
+
/** Update the client's environment scope at runtime. */
|
|
774
|
+
withEnvironment(environmentId) {
|
|
775
|
+
return new _MetadataClient({
|
|
776
|
+
baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
|
|
777
|
+
environmentId,
|
|
778
|
+
fetch: this.fetchImpl,
|
|
779
|
+
headers: this.headers
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
/** List all registered metadata types (returns the registry rows). */
|
|
783
|
+
async listTypes() {
|
|
784
|
+
const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
785
|
+
if (!res.ok) throw await parseError(res);
|
|
786
|
+
const data = await res.json();
|
|
787
|
+
if (Array.isArray(data)) return data;
|
|
788
|
+
if (data && Array.isArray(data.entries)) return data.entries;
|
|
789
|
+
if (data && Array.isArray(data.items)) return data.items;
|
|
790
|
+
if (data && Array.isArray(data.types)) {
|
|
791
|
+
return data.types.map((t) => ({ type: t }));
|
|
792
|
+
}
|
|
793
|
+
return [];
|
|
794
|
+
}
|
|
795
|
+
/** List items of a metadata type (e.g. `object`, `field`, `view`). */
|
|
796
|
+
async list(type, options = {}) {
|
|
797
|
+
const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : "";
|
|
798
|
+
const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
|
|
799
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
800
|
+
if (!res.ok) throw await parseError(res);
|
|
801
|
+
const data = await res.json();
|
|
802
|
+
if (Array.isArray(data)) return data;
|
|
803
|
+
if (data && Array.isArray(data.items)) {
|
|
804
|
+
return data.items;
|
|
805
|
+
}
|
|
806
|
+
return [];
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Get a single metadata item. Returns the unwrapped item content
|
|
810
|
+
* (matching the framework REST handler which calls `res.json(item)`).
|
|
811
|
+
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
812
|
+
*/
|
|
813
|
+
async get(type, name) {
|
|
814
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}`;
|
|
815
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
816
|
+
if (res.status === 404) return null;
|
|
817
|
+
if (!res.ok) throw await parseError(res);
|
|
818
|
+
return await res.json();
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
822
|
+
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
823
|
+
* envelopes; we send bare for consistency.
|
|
824
|
+
*/
|
|
825
|
+
async save(type, name, item, options = {}) {
|
|
826
|
+
const qs = options.force ? "?force=true" : "";
|
|
827
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
828
|
+
const headers = {
|
|
829
|
+
...this.headers,
|
|
830
|
+
"Content-Type": "application/json"
|
|
831
|
+
};
|
|
832
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
833
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
834
|
+
const res = await this.fetchImpl(url, {
|
|
835
|
+
method: "PUT",
|
|
836
|
+
headers,
|
|
837
|
+
body: JSON.stringify(item)
|
|
838
|
+
});
|
|
839
|
+
if (!res.ok) throw await parseError(res);
|
|
840
|
+
return await res.json();
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
|
|
844
|
+
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
845
|
+
* customisation, if any), and `effective` (what the runtime sees).
|
|
846
|
+
*/
|
|
847
|
+
async layered(type, name) {
|
|
848
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true`;
|
|
849
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
850
|
+
if (res.status === 404) {
|
|
851
|
+
return { code: null, overlay: null, overlayScope: null, effective: null };
|
|
852
|
+
}
|
|
853
|
+
if (!res.ok) throw await parseError(res);
|
|
854
|
+
const body = await res.json();
|
|
855
|
+
const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
|
|
856
|
+
if (!hasEnvelope) {
|
|
857
|
+
return {
|
|
858
|
+
code: null,
|
|
859
|
+
overlay: null,
|
|
860
|
+
overlayScope: null,
|
|
861
|
+
effective: body
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
return {
|
|
865
|
+
code: body.code ?? null,
|
|
866
|
+
overlay: body.overlay ?? null,
|
|
867
|
+
overlayScope: body.overlayScope ?? null,
|
|
868
|
+
effective: body.effective ?? null
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Find every metadata item that references this one (Phase 3a). Useful
|
|
873
|
+
* for pre-delete impact analysis: "Are any views pointing at this
|
|
874
|
+
* object before I drop it?".
|
|
875
|
+
*/
|
|
876
|
+
async references(type, name) {
|
|
877
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
|
|
878
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
879
|
+
if (res.status === 404) return [];
|
|
880
|
+
if (!res.ok) throw await parseError(res);
|
|
881
|
+
const data = await res.json();
|
|
882
|
+
return Array.isArray(data) ? data : data?.items ?? [];
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Reset a metadata customization overlay back to the artifact default.
|
|
886
|
+
* Idempotent: returns the result even when no overlay row existed.
|
|
887
|
+
*/
|
|
888
|
+
async reset(type, name, options = {}) {
|
|
889
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}`;
|
|
890
|
+
const headers = { ...this.headers };
|
|
891
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
892
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
893
|
+
const res = await this.fetchImpl(url, { method: "DELETE", headers });
|
|
894
|
+
if (!res.ok) throw await parseError(res);
|
|
895
|
+
return await res.json();
|
|
896
|
+
}
|
|
897
|
+
/** Fetch the durable history (change log) for one metadata item. */
|
|
898
|
+
async history(type, name, options = {}) {
|
|
899
|
+
const params = new URLSearchParams();
|
|
900
|
+
if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
|
|
901
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
902
|
+
const qs = params.toString();
|
|
903
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
|
|
904
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
905
|
+
if (!res.ok) throw await parseError(res);
|
|
906
|
+
return await res.json();
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
|
|
735
910
|
// src/security.ts
|
|
736
911
|
var SecurityManager = class {
|
|
737
912
|
constructor(policy = {}) {
|
|
@@ -2300,6 +2475,7 @@ export {
|
|
|
2300
2475
|
ConcurrentUpdateError,
|
|
2301
2476
|
ConnectionError,
|
|
2302
2477
|
IntegrationManager,
|
|
2478
|
+
MetadataClient,
|
|
2303
2479
|
MetadataNotFoundError,
|
|
2304
2480
|
ObjectStackAdapter,
|
|
2305
2481
|
ObjectStackError,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/data-objectstack",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.2.0",
|
|
4
4
|
"description": "ObjectStack Data Adapter for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@objectstack/client": "^
|
|
26
|
-
"@object-ui/core": "6.
|
|
27
|
-
"@object-ui/types": "6.
|
|
25
|
+
"@objectstack/client": "^6.7.1",
|
|
26
|
+
"@object-ui/core": "6.2.0",
|
|
27
|
+
"@object-ui/types": "6.2.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"tsup": "^8.5.1",
|
package/src/index.ts
CHANGED
|
@@ -2068,6 +2068,21 @@ export type { PluginContract, PluginExport, PluginAPIContract, ContractValidatio
|
|
|
2068
2068
|
export { IntegrationManager } from './integration';
|
|
2069
2069
|
export type { IntegrationConfig, IntegrationTrigger, IntegrationProvider, SlackIntegrationConfig, EmailIntegrationConfig, WebhookIntegrationConfig } from './integration';
|
|
2070
2070
|
|
|
2071
|
+
// Metadata API client — read/write protocol metadata via /api/v1/meta/*.
|
|
2072
|
+
// Used by plugin-designer to back the Setup-app Object Manager and Field
|
|
2073
|
+
// Designer surfaces; kept separate from ObjectStackAdapter so callers
|
|
2074
|
+
// can use it without the full data-source surface.
|
|
2075
|
+
export { MetadataClient } from './metadata-client';
|
|
2076
|
+
export type {
|
|
2077
|
+
MetadataClientConfig,
|
|
2078
|
+
MetadataListOptions,
|
|
2079
|
+
MetadataSaveOptions,
|
|
2080
|
+
MetadataHistoryOptions,
|
|
2081
|
+
MetadataError,
|
|
2082
|
+
MetadataLayered,
|
|
2083
|
+
MetadataReference,
|
|
2084
|
+
} from './metadata-client';
|
|
2085
|
+
|
|
2071
2086
|
export { SecurityManager } from './security';
|
|
2072
2087
|
export type { SecurityPolicy, CSPConfig, AuditLogConfig, AuditEventType, DataMaskingConfig, DataMaskingRule, AuditLogEntry } from './security';
|
|
2073
2088
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
10
|
+
import { MetadataClient } from './metadata-client';
|
|
11
|
+
|
|
12
|
+
function mockFetch(handler: (url: string, init?: RequestInit) => Promise<Response>): typeof fetch {
|
|
13
|
+
return vi.fn(handler) as unknown as typeof fetch;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
|
17
|
+
return new Response(JSON.stringify(body), {
|
|
18
|
+
status: 200,
|
|
19
|
+
headers: { 'content-type': 'application/json' },
|
|
20
|
+
...init,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('MetadataClient', () => {
|
|
25
|
+
let urls: string[];
|
|
26
|
+
let inits: (RequestInit | undefined)[];
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
urls = [];
|
|
30
|
+
inits = [];
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function client(baseUrl: string, environmentId?: string) {
|
|
34
|
+
return new MetadataClient({
|
|
35
|
+
baseUrl,
|
|
36
|
+
environmentId,
|
|
37
|
+
fetch: mockFetch(async (url, init) => {
|
|
38
|
+
urls.push(url);
|
|
39
|
+
inits.push(init);
|
|
40
|
+
if (init?.method === 'PUT' || init?.method === 'DELETE') {
|
|
41
|
+
return jsonResponse({ ok: true });
|
|
42
|
+
}
|
|
43
|
+
if (url.endsWith('/meta')) return jsonResponse([{ type: 'object' }]);
|
|
44
|
+
if (url.match(/\/meta\/object$/)) return jsonResponse([{ name: 'account' }]);
|
|
45
|
+
if (url.match(/\/meta\/object\/account$/)) {
|
|
46
|
+
return jsonResponse({ name: 'account', label: 'Account' });
|
|
47
|
+
}
|
|
48
|
+
if (url.match(/\/meta\/object\/missing$/)) {
|
|
49
|
+
return new Response('not found', { status: 404 });
|
|
50
|
+
}
|
|
51
|
+
if (url.match(/history$/)) return jsonResponse({ events: [] });
|
|
52
|
+
return jsonResponse({ items: [{ name: 'fallback' }] });
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it('builds unscoped URLs against the bare baseUrl', async () => {
|
|
58
|
+
await client('http://localhost:3000').list('object');
|
|
59
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('builds scoped URLs when environmentId is set', async () => {
|
|
63
|
+
await client('http://localhost:3000', 'env_42').list('object');
|
|
64
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/environments/env_42/meta/object');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('collapses /api/v1 suffix on baseUrl to avoid duplication', async () => {
|
|
68
|
+
await client('http://localhost:3000/api/v1').list('object');
|
|
69
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns null on 404 from get()', async () => {
|
|
73
|
+
const result = await client('http://localhost:3000').get('object', 'missing');
|
|
74
|
+
expect(result).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('unwraps `items` envelope on list when the server returns one', async () => {
|
|
78
|
+
const c = new MetadataClient({
|
|
79
|
+
baseUrl: 'http://localhost:3000',
|
|
80
|
+
fetch: mockFetch(async () => jsonResponse({ items: [{ name: 'wrapped' }] })),
|
|
81
|
+
});
|
|
82
|
+
const items = await c.list<{ name: string }>('field');
|
|
83
|
+
expect(items).toEqual([{ name: 'wrapped' }]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('sends If-Match and X-Actor headers on save when provided', async () => {
|
|
87
|
+
await client('http://localhost:3000').save('object', 'account', { foo: 1 }, {
|
|
88
|
+
ifMatch: 'sha256:abc',
|
|
89
|
+
actor: 'user_1',
|
|
90
|
+
});
|
|
91
|
+
expect(inits[0]?.method).toBe('PUT');
|
|
92
|
+
const headers = inits[0]?.headers as Record<string, string>;
|
|
93
|
+
expect(headers['If-Match']).toBe('sha256:abc');
|
|
94
|
+
expect(headers['X-Actor']).toBe('user_1');
|
|
95
|
+
expect(headers['Content-Type']).toBe('application/json');
|
|
96
|
+
expect(inits[0]?.body).toBe(JSON.stringify({ foo: 1 }));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('encodes type and name path segments', async () => {
|
|
100
|
+
await client('http://localhost:3000').get('object', 'has spaces');
|
|
101
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/has%20spaces');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('builds history URL with query params', async () => {
|
|
105
|
+
await client('http://localhost:3000').history('view', 'all', { sinceSeq: 10, limit: 5 });
|
|
106
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/view/all/history?sinceSeq=10&limit=5');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('throws MetadataError with status and body on failure', async () => {
|
|
110
|
+
const c = new MetadataClient({
|
|
111
|
+
baseUrl: 'http://localhost:3000',
|
|
112
|
+
fetch: mockFetch(async () =>
|
|
113
|
+
new Response(JSON.stringify({ error: 'metadata_conflict', code: 'metadata_conflict' }), {
|
|
114
|
+
status: 409,
|
|
115
|
+
headers: { 'content-type': 'application/json' },
|
|
116
|
+
})),
|
|
117
|
+
});
|
|
118
|
+
await expect(c.save('object', 'account', {})).rejects.toMatchObject({
|
|
119
|
+
status: 409,
|
|
120
|
+
code: 'metadata_conflict',
|
|
121
|
+
message: 'metadata_conflict',
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* MetadataClient
|
|
11
|
+
*
|
|
12
|
+
* Thin, framework-agnostic HTTP client for the ObjectStack metadata
|
|
13
|
+
* API (`/api/v1/meta/*`). Used by the platform Setup app surfaces in
|
|
14
|
+
* `@object-ui/plugin-designer` (Object Manager, Field Designer, etc.)
|
|
15
|
+
* so they can read and write protocol metadata without depending on
|
|
16
|
+
* `ObjectStackAdapter` (which is a generic data-source binding and
|
|
17
|
+
* carries a lot of view/dashboard-specific behaviour we don't need
|
|
18
|
+
* here).
|
|
19
|
+
*
|
|
20
|
+
* Endpoints (see `packages/rest/src/rest-server.ts`):
|
|
21
|
+
* GET /api/v1/meta — list all metadata types
|
|
22
|
+
* GET /api/v1/meta/:type — list items of a type
|
|
23
|
+
* GET /api/v1/meta/:type/:name — get one item (returns the
|
|
24
|
+
* unwrapped item content)
|
|
25
|
+
* PUT /api/v1/meta/:type/:name — save (overlay) one item
|
|
26
|
+
* honours `If-Match` for OCC
|
|
27
|
+
* DELETE /api/v1/meta/:type/:name — reset overlay to artifact
|
|
28
|
+
* GET /api/v1/meta/:type/:name/history — durable change log
|
|
29
|
+
*
|
|
30
|
+
* The client deliberately keeps the response shape opaque (typed as
|
|
31
|
+
* `unknown`/generic `T`) so callers explicitly narrow per-metadata-
|
|
32
|
+
* type, mirroring the framework's "single Zod source per type" rule.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export interface MetadataClientConfig {
|
|
36
|
+
/** Base URL of the ObjectStack server (no trailing slash needed). */
|
|
37
|
+
baseUrl: string;
|
|
38
|
+
/**
|
|
39
|
+
* Optional environment ID to scope reads/writes to a tenant. When
|
|
40
|
+
* provided, requests use the scoped path
|
|
41
|
+
* `/api/v1/environments/:environmentId/meta/...` and overlays are
|
|
42
|
+
* persisted in the environment's own metadata store.
|
|
43
|
+
*/
|
|
44
|
+
environmentId?: string;
|
|
45
|
+
/** Optional custom fetch implementation (defaults to `globalThis.fetch`). */
|
|
46
|
+
fetch?: typeof fetch;
|
|
47
|
+
/** Additional headers (e.g. auth) included on every request. */
|
|
48
|
+
headers?: Record<string, string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MetadataListOptions {
|
|
52
|
+
/** Filter by source package id (matches the `package` query param). */
|
|
53
|
+
packageId?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MetadataSaveOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Optimistic concurrency token (the `checksum` returned by the last
|
|
59
|
+
* read). When present, sent as the `If-Match` header so concurrent
|
|
60
|
+
* edits get a 409 instead of overwriting each other.
|
|
61
|
+
*/
|
|
62
|
+
ifMatch?: string;
|
|
63
|
+
/** Optional actor id, sent as `X-Actor` for history attribution. */
|
|
64
|
+
actor?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Bypass destructive-change protection (Phase 3a). The server returns
|
|
67
|
+
* `409 destructive_change` with `issues[]` if a write would drop or
|
|
68
|
+
* narrow data; setting `force: true` adds `?force=true` to the request
|
|
69
|
+
* so the operator can confirm and proceed.
|
|
70
|
+
*/
|
|
71
|
+
force?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Layered view of a metadata item — Phase 3a `?layers=true`. */
|
|
75
|
+
export interface MetadataLayered<T = unknown> {
|
|
76
|
+
/** Code-level (artifact) item; null if the item only exists as an overlay. */
|
|
77
|
+
code: T | null;
|
|
78
|
+
/** Org/environment overlay (just the saved delta or full overlay row). */
|
|
79
|
+
overlay: T | null;
|
|
80
|
+
/** Overlay scope (`organization` | `environment` | `package` | null). */
|
|
81
|
+
overlayScope: string | null;
|
|
82
|
+
/** Merged effective view — what the runtime actually sees. */
|
|
83
|
+
effective: T | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Reference back-pointer — Phase 3a `/references`. */
|
|
87
|
+
export interface MetadataReference {
|
|
88
|
+
/** Referencing item's metadata type. */
|
|
89
|
+
fromType: string;
|
|
90
|
+
/** Referencing item's name. */
|
|
91
|
+
fromName: string;
|
|
92
|
+
/** JSON path within the referencing item that holds the reference. */
|
|
93
|
+
path: string;
|
|
94
|
+
/** The actual value seen at `path` (the referenced name). */
|
|
95
|
+
value: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface MetadataHistoryOptions {
|
|
99
|
+
/** Only return events after this sequence number. */
|
|
100
|
+
sinceSeq?: number;
|
|
101
|
+
/** Limit the number of events returned. */
|
|
102
|
+
limit?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface MetadataError extends Error {
|
|
106
|
+
status: number;
|
|
107
|
+
code?: string;
|
|
108
|
+
body?: unknown;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const META_PREFIX = '/meta';
|
|
112
|
+
const API_PREFIX = '/api/v1';
|
|
113
|
+
|
|
114
|
+
function buildBase(config: MetadataClientConfig): string {
|
|
115
|
+
const trimmed = config.baseUrl.replace(/\/+$/, '');
|
|
116
|
+
const scoped = config.environmentId
|
|
117
|
+
? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}`
|
|
118
|
+
: `${API_PREFIX}${META_PREFIX}`;
|
|
119
|
+
// Allow baseUrl to already include `/api/v1`; collapse the duplicate.
|
|
120
|
+
if (/\/api\/v\d+$/i.test(trimmed)) {
|
|
121
|
+
const stripped = trimmed.replace(/\/api\/v\d+$/i, '');
|
|
122
|
+
const scopedNoApi = config.environmentId
|
|
123
|
+
? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}`
|
|
124
|
+
: `${API_PREFIX}${META_PREFIX}`;
|
|
125
|
+
return `${stripped}${scopedNoApi}`;
|
|
126
|
+
}
|
|
127
|
+
return `${trimmed}${scoped}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function parseError(res: Response): Promise<MetadataError> {
|
|
131
|
+
let body: unknown;
|
|
132
|
+
try {
|
|
133
|
+
body = await res.json();
|
|
134
|
+
} catch {
|
|
135
|
+
body = await res.text().catch(() => undefined);
|
|
136
|
+
}
|
|
137
|
+
const message =
|
|
138
|
+
(body && typeof body === 'object' && 'error' in (body as Record<string, unknown>)
|
|
139
|
+
? String((body as Record<string, unknown>).error)
|
|
140
|
+
: undefined) ?? `Metadata request failed: ${res.status} ${res.statusText}`;
|
|
141
|
+
const err = new Error(message) as MetadataError;
|
|
142
|
+
err.status = res.status;
|
|
143
|
+
err.body = body;
|
|
144
|
+
if (body && typeof body === 'object' && 'code' in (body as Record<string, unknown>)) {
|
|
145
|
+
err.code = String((body as Record<string, unknown>).code);
|
|
146
|
+
}
|
|
147
|
+
return err;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* MetadataClient — read/write protocol metadata via the framework REST API.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```ts
|
|
155
|
+
* const client = new MetadataClient({ baseUrl: 'http://localhost:3000' });
|
|
156
|
+
* const objects = await client.list<{ name: string; label?: string }>('object');
|
|
157
|
+
* const account = await client.get<{ fields: Record<string, unknown> }>('object', 'account');
|
|
158
|
+
* await client.save('object', 'account', { ...account, label: 'Customer' });
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
export class MetadataClient {
|
|
162
|
+
private readonly base: string;
|
|
163
|
+
private readonly fetchImpl: typeof fetch;
|
|
164
|
+
private readonly headers: Record<string, string>;
|
|
165
|
+
|
|
166
|
+
constructor(config: MetadataClientConfig) {
|
|
167
|
+
this.base = buildBase(config);
|
|
168
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
169
|
+
this.headers = { Accept: 'application/json', ...(config.headers ?? {}) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Update the client's environment scope at runtime. */
|
|
173
|
+
withEnvironment(environmentId: string | undefined): MetadataClient {
|
|
174
|
+
return new MetadataClient({
|
|
175
|
+
baseUrl: this.base
|
|
176
|
+
.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, '')
|
|
177
|
+
.replace(/\/+$/, ''),
|
|
178
|
+
environmentId,
|
|
179
|
+
fetch: this.fetchImpl,
|
|
180
|
+
headers: this.headers,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** List all registered metadata types (returns the registry rows). */
|
|
185
|
+
async listTypes(): Promise<unknown[]> {
|
|
186
|
+
const res = await this.fetchImpl(this.base, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
187
|
+
if (!res.ok) throw await parseError(res);
|
|
188
|
+
const data = await res.json();
|
|
189
|
+
if (Array.isArray(data)) return data;
|
|
190
|
+
// Framework REST returns `{ types: string[], entries: TypeEntry[] }`
|
|
191
|
+
// where `entries` is the rich per-type registry row. Older / scoped
|
|
192
|
+
// shapes may use `items`. Prefer `entries` (rich), fall back to
|
|
193
|
+
// `items` (legacy), then synthesize stub entries from `types[]`
|
|
194
|
+
// if neither is present.
|
|
195
|
+
if (data && Array.isArray((data as any).entries)) return (data as any).entries;
|
|
196
|
+
if (data && Array.isArray((data as any).items)) return (data as any).items;
|
|
197
|
+
if (data && Array.isArray((data as any).types)) {
|
|
198
|
+
return (data as any).types.map((t: string) => ({ type: t }));
|
|
199
|
+
}
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** List items of a metadata type (e.g. `object`, `field`, `view`). */
|
|
204
|
+
async list<T = unknown>(type: string, options: MetadataListOptions = {}): Promise<T[]> {
|
|
205
|
+
const qs = options.packageId
|
|
206
|
+
? `?package=${encodeURIComponent(options.packageId)}`
|
|
207
|
+
: '';
|
|
208
|
+
const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
|
|
209
|
+
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
210
|
+
if (!res.ok) throw await parseError(res);
|
|
211
|
+
const data = await res.json();
|
|
212
|
+
if (Array.isArray(data)) return data as T[];
|
|
213
|
+
if (data && Array.isArray((data as { items?: unknown[] }).items)) {
|
|
214
|
+
return (data as { items: T[] }).items;
|
|
215
|
+
}
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get a single metadata item. Returns the unwrapped item content
|
|
221
|
+
* (matching the framework REST handler which calls `res.json(item)`).
|
|
222
|
+
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
223
|
+
*/
|
|
224
|
+
async get<T = unknown>(type: string, name: string): Promise<T | null> {
|
|
225
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}`;
|
|
226
|
+
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
227
|
+
if (res.status === 404) return null;
|
|
228
|
+
if (!res.ok) throw await parseError(res);
|
|
229
|
+
return (await res.json()) as T;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
234
|
+
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
235
|
+
* envelopes; we send bare for consistency.
|
|
236
|
+
*/
|
|
237
|
+
async save<T = unknown>(
|
|
238
|
+
type: string,
|
|
239
|
+
name: string,
|
|
240
|
+
item: unknown,
|
|
241
|
+
options: MetadataSaveOptions = {},
|
|
242
|
+
): Promise<T> {
|
|
243
|
+
const qs = options.force ? '?force=true' : '';
|
|
244
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
245
|
+
const headers: Record<string, string> = {
|
|
246
|
+
...this.headers,
|
|
247
|
+
'Content-Type': 'application/json',
|
|
248
|
+
};
|
|
249
|
+
if (options.ifMatch) headers['If-Match'] = options.ifMatch;
|
|
250
|
+
if (options.actor) headers['X-Actor'] = options.actor;
|
|
251
|
+
const res = await this.fetchImpl(url, {
|
|
252
|
+
method: 'PUT',
|
|
253
|
+
headers,
|
|
254
|
+
body: JSON.stringify(item),
|
|
255
|
+
});
|
|
256
|
+
if (!res.ok) throw await parseError(res);
|
|
257
|
+
return (await res.json()) as T;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
|
|
262
|
+
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
263
|
+
* customisation, if any), and `effective` (what the runtime sees).
|
|
264
|
+
*/
|
|
265
|
+
async layered<T = unknown>(type: string, name: string): Promise<MetadataLayered<T>> {
|
|
266
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true`;
|
|
267
|
+
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
268
|
+
if (res.status === 404) {
|
|
269
|
+
return { code: null, overlay: null, overlayScope: null, effective: null };
|
|
270
|
+
}
|
|
271
|
+
if (!res.ok) throw await parseError(res);
|
|
272
|
+
const body = (await res.json()) as MetadataLayered<T> & Record<string, unknown>;
|
|
273
|
+
const hasEnvelope =
|
|
274
|
+
body && (('code' in body) || ('overlay' in body) || ('effective' in body));
|
|
275
|
+
if (!hasEnvelope) {
|
|
276
|
+
return {
|
|
277
|
+
code: null,
|
|
278
|
+
overlay: null,
|
|
279
|
+
overlayScope: null,
|
|
280
|
+
effective: body as unknown as T,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
code: body.code ?? null,
|
|
285
|
+
overlay: body.overlay ?? null,
|
|
286
|
+
overlayScope: body.overlayScope ?? null,
|
|
287
|
+
effective: body.effective ?? null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Find every metadata item that references this one (Phase 3a). Useful
|
|
293
|
+
* for pre-delete impact analysis: "Are any views pointing at this
|
|
294
|
+
* object before I drop it?".
|
|
295
|
+
*/
|
|
296
|
+
async references(type: string, name: string): Promise<MetadataReference[]> {
|
|
297
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
|
|
298
|
+
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
299
|
+
if (res.status === 404) return [];
|
|
300
|
+
if (!res.ok) throw await parseError(res);
|
|
301
|
+
const data = await res.json();
|
|
302
|
+
return Array.isArray(data) ? (data as MetadataReference[]) : (data?.items ?? []);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Reset a metadata customization overlay back to the artifact default.
|
|
307
|
+
* Idempotent: returns the result even when no overlay row existed.
|
|
308
|
+
*/
|
|
309
|
+
async reset<T = unknown>(
|
|
310
|
+
type: string,
|
|
311
|
+
name: string,
|
|
312
|
+
options: MetadataSaveOptions = {},
|
|
313
|
+
): Promise<T> {
|
|
314
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}`;
|
|
315
|
+
const headers: Record<string, string> = { ...this.headers };
|
|
316
|
+
if (options.ifMatch) headers['If-Match'] = options.ifMatch;
|
|
317
|
+
if (options.actor) headers['X-Actor'] = options.actor;
|
|
318
|
+
const res = await this.fetchImpl(url, { method: 'DELETE', headers });
|
|
319
|
+
if (!res.ok) throw await parseError(res);
|
|
320
|
+
return (await res.json()) as T;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Fetch the durable history (change log) for one metadata item. */
|
|
324
|
+
async history<T = unknown>(
|
|
325
|
+
type: string,
|
|
326
|
+
name: string,
|
|
327
|
+
options: MetadataHistoryOptions = {},
|
|
328
|
+
): Promise<T> {
|
|
329
|
+
const params = new URLSearchParams();
|
|
330
|
+
if (options.sinceSeq !== undefined) params.set('sinceSeq', String(options.sinceSeq));
|
|
331
|
+
if (options.limit !== undefined) params.set('limit', String(options.limit));
|
|
332
|
+
const qs = params.toString();
|
|
333
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ''}`;
|
|
334
|
+
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
|
|
335
|
+
if (!res.ok) throw await parseError(res);
|
|
336
|
+
return (await res.json()) as T;
|
|
337
|
+
}
|
|
338
|
+
}
|