@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/CHANGELOG.md +95 -0
- package/dist/index.cjs +186 -3
- package/dist/index.d.cts +157 -1
- package/dist/index.d.ts +157 -1
- package/dist/index.js +185 -3
- package/package.json +5 -5
- package/src/index.ts +15 -0
- package/src/metadata-client.test.ts +124 -0
- package/src/metadata-client.ts +338 -0
- package/src/userState.test.ts +2 -2
- package/src/userState.ts +18 -3
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
|
-
|
|
546
|
+
$top: 1
|
|
547
547
|
};
|
|
548
548
|
const result = await dataSource.find(resource, params);
|
|
549
549
|
const rows = result?.data ?? [];
|
|
550
|
-
|
|
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
|
|
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": "^
|
|
26
|
-
"@object-ui/core": "6.0
|
|
27
|
-
"@object-ui/types": "6.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",
|
|
31
31
|
"typescript": "^6.0.3",
|
|
32
|
-
"vitest": "^4.1.
|
|
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
|
+
});
|