@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
|
@@ -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
|
+
}
|
package/src/userState.test.ts
CHANGED
|
@@ -39,8 +39,8 @@ describe('createObjectStackUserStateAdapter', () => {
|
|
|
39
39
|
|
|
40
40
|
expect(items).toEqual([]);
|
|
41
41
|
expect(ds.find).toHaveBeenCalledWith('sys_user_preference', {
|
|
42
|
-
filter: { user_id: 'u1', key: 'ui.favorites' },
|
|
43
|
-
|
|
42
|
+
$filter: { user_id: 'u1', key: 'ui.favorites' },
|
|
43
|
+
$top: 1,
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
package/src/userState.ts
CHANGED
|
@@ -105,16 +105,31 @@ export function createObjectStackUserStateAdapter<T = unknown>(
|
|
|
105
105
|
let cachedRowId: string | number | null = null;
|
|
106
106
|
|
|
107
107
|
const findExisting = async (): Promise<UserPreferenceRecord | null> => {
|
|
108
|
+
// NOTE: `QueryParams` uses OData-style `$`-prefixed keys. Using the bare
|
|
109
|
+
// names (`filter`, `limit`) silently drops the predicate at the
|
|
110
|
+
// `convertQueryParams` layer, which then returns *any* row in the
|
|
111
|
+
// `sys_user_preference` table — that caused the Favorites adapter to load
|
|
112
|
+
// the Recents row (and vice-versa), so clicked items leaked into Starred.
|
|
108
113
|
const params: QueryParams = {
|
|
109
|
-
filter: {
|
|
114
|
+
$filter: {
|
|
110
115
|
user_id: userId,
|
|
111
116
|
key,
|
|
112
117
|
},
|
|
113
|
-
|
|
118
|
+
$top: 1,
|
|
114
119
|
};
|
|
115
120
|
const result = await dataSource.find(resource, params);
|
|
116
121
|
const rows = (result?.data ?? []) as UserPreferenceRecord[];
|
|
117
|
-
|
|
122
|
+
// Defensive: if the backend ignored our predicate and returned mixed
|
|
123
|
+
// rows, only accept ones tagged for this (user, key) pair. Rows that
|
|
124
|
+
// omit those metadata fields entirely are trusted (covers mocks and
|
|
125
|
+
// backends that strip metadata from the projection).
|
|
126
|
+
const match = rows.find(r => {
|
|
127
|
+
if (!r) return false;
|
|
128
|
+
const userMismatch = r.user_id !== undefined && r.user_id !== userId;
|
|
129
|
+
const keyMismatch = r.key !== undefined && r.key !== key;
|
|
130
|
+
return !userMismatch && !keyMismatch;
|
|
131
|
+
});
|
|
132
|
+
return match ?? null;
|
|
118
133
|
};
|
|
119
134
|
|
|
120
135
|
const decodeValue = (value: unknown): T[] => {
|