@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 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.1.0",
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": "^5.2.0",
26
- "@object-ui/core": "6.1.0",
27
- "@object-ui/types": "6.1.0"
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
+ }