@object-ui/data-objectstack 6.0.4 → 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/dist/index.js CHANGED
@@ -539,15 +539,21 @@ function createObjectStackUserStateAdapter(options) {
539
539
  let cachedRowId = null;
540
540
  const findExisting = async () => {
541
541
  const params = {
542
- filter: {
542
+ $filter: {
543
543
  user_id: userId,
544
544
  key
545
545
  },
546
- limit: 1
546
+ $top: 1
547
547
  };
548
548
  const result = await dataSource.find(resource, params);
549
549
  const rows = result?.data ?? [];
550
- return rows.length > 0 ? rows[0] : null;
550
+ const match = rows.find((r) => {
551
+ if (!r) return false;
552
+ const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
553
+ const keyMismatch = r.key !== void 0 && r.key !== key;
554
+ return !userMismatch && !keyMismatch;
555
+ });
556
+ return match ?? null;
551
557
  };
552
558
  const decodeValue = (value) => {
553
559
  if (Array.isArray(value)) return value;
@@ -726,6 +732,181 @@ var IntegrationManager = class {
726
732
  }
727
733
  };
728
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
+
729
910
  // src/security.ts
730
911
  var SecurityManager = class {
731
912
  constructor(policy = {}) {
@@ -2294,6 +2475,7 @@ export {
2294
2475
  ConcurrentUpdateError,
2295
2476
  ConnectionError,
2296
2477
  IntegrationManager,
2478
+ MetadataClient,
2297
2479
  MetadataNotFoundError,
2298
2480
  ObjectStackAdapter,
2299
2481
  ObjectStackError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/data-objectstack",
3
- "version": "6.0.4",
3
+ "version": "6.2.0",
4
4
  "description": "ObjectStack Data Adapter for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,14 +22,14 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@objectstack/client": "^5.2.0",
26
- "@object-ui/core": "6.0.4",
27
- "@object-ui/types": "6.0.4"
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",
31
31
  "typescript": "^6.0.3",
32
- "vitest": "^4.1.6"
32
+ "vitest": "^4.1.7"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
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
+ });