@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.
@@ -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
+ }
@@ -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
- limit: 1,
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
- limit: 1,
118
+ $top: 1,
114
119
  };
115
120
  const result = await dataSource.find(resource, params);
116
121
  const rows = (result?.data ?? []) as UserPreferenceRecord[];
117
- return rows.length > 0 ? rows[0] : null;
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[] => {