@object-ui/data-objectstack 6.2.3 → 7.0.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/src/index.ts CHANGED
@@ -603,7 +603,11 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
603
603
  // which parses `populate` (comma-separated) into an array for lookup expansion.
604
604
  // We use a raw request because the client SDK's data.find() QueryOptions
605
605
  // interface does not include populate/expand fields.
606
- if (params?.$expand && params.$expand.length > 0) {
606
+ if ((params?.$expand && params.$expand.length > 0)
607
+ || (params?.$search != null && String(params.$search).trim() !== '')) {
608
+ // The client SDK's data.find() QueryOptions drops `$search`; route through
609
+ // the raw GET so the term reaches protocol.findData → the metadata-driven
610
+ // search executor (ADR-0061).
607
611
  const result = await this.rawFindWithPopulate(resource, params);
608
612
  return this.normalizeQueryResult(result, params);
609
613
  }
@@ -857,6 +861,37 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
857
861
  * @param data - Array of records to process
858
862
  * @returns Promise resolving to array of results
859
863
  */
864
+ /**
865
+ * Cross-object transactional batch (ObjectStack #1604). Runs the operations
866
+ * in ONE server transaction — commit all or roll back all. A field value of
867
+ * `{ $ref: <earlier op index> }` resolves to that op's created id, so a child
868
+ * can reference its parent created earlier in the same batch (master-detail).
869
+ */
870
+ async batchTransaction(
871
+ operations: Array<{ object: string; action?: 'create' | 'update' | 'delete'; data?: any; id?: string }>,
872
+ ): Promise<{ results: any[] }> {
873
+ const url = `${this.baseUrl}/api/v1/batch`;
874
+ const response = await fetch(url, {
875
+ method: 'POST',
876
+ headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
877
+ // Send the session cookie too: in the browser console auth is cookie-based
878
+ // (no bearer `token`), so without `credentials: 'include'` this raw fetch
879
+ // is unauthenticated — every master-detail batch save would 401. Bearer
880
+ // (server-to-server) and cookie (console) auth now both work.
881
+ credentials: 'include',
882
+ body: JSON.stringify({ operations }),
883
+ });
884
+ if (!response.ok) {
885
+ const error = await response.json().catch(() => ({ message: response.statusText }));
886
+ throw new ObjectStackError(
887
+ error.error || error.message || `Batch failed with status ${response.status}`,
888
+ 'BATCH_ERROR',
889
+ response.status,
890
+ );
891
+ }
892
+ return response.json();
893
+ }
894
+
860
895
  async bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]> {
861
896
  await this.connect();
862
897
 
@@ -1027,16 +1062,22 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1027
1062
  };
1028
1063
  }
1029
1064
 
1030
- const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number };
1065
+ const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number; hasMore?: boolean };
1031
1066
  const records = resultObj.records || resultObj.value || [];
1032
1067
  const total = resultObj.total ?? resultObj.count ?? records.length;
1068
+ // Prefer the server's `hasMore` (real server-side pagination, framework
1069
+ // issue #2212). Fall back to the page-local estimate (a full page implies
1070
+ // there may be more) only when the server doesn't report it.
1071
+ const hasMore = typeof resultObj.hasMore === 'boolean'
1072
+ ? resultObj.hasMore
1073
+ : (params?.$top ? records.length === params.$top : false);
1033
1074
  return {
1034
1075
  data: records,
1035
1076
  total,
1036
1077
  // Calculate page number safely
1037
1078
  page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
1038
1079
  pageSize: params?.$top,
1039
- hasMore: params?.$top ? records.length === params.$top : false,
1080
+ hasMore,
1040
1081
  };
1041
1082
  }
1042
1083
 
@@ -1062,6 +1103,15 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1062
1103
  queryParams.set('skip', String(params.$skip));
1063
1104
  }
1064
1105
 
1106
+ // Full-text search (ADR-0061). The server resolves which fields to match
1107
+ // from object metadata; the client only sends the term (+ optional override).
1108
+ if (params.$search != null && String(params.$search).trim() !== '') {
1109
+ queryParams.set('search', String(params.$search).trim());
1110
+ }
1111
+ if (params.$searchFields && params.$searchFields.length > 0) {
1112
+ queryParams.set('searchFields', params.$searchFields.join(','));
1113
+ }
1114
+
1065
1115
  // Selection — always include `id` to ensure records can be identified
1066
1116
  // for navigation/selection even when callers omit it from $select.
1067
1117
  if (params.$select && params.$select.length > 0) {
@@ -1219,6 +1269,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1219
1269
  options.top = params.$top;
1220
1270
  }
1221
1271
 
1272
+ if (params.$search != null && String(params.$search).trim() !== '') {
1273
+ (options as Record<string, unknown>).search = String(params.$search).trim();
1274
+ }
1275
+ if (params.$searchFields && params.$searchFields.length > 0) {
1276
+ (options as Record<string, unknown>).searchFields = params.$searchFields;
1277
+ }
1278
+
1222
1279
  return options;
1223
1280
  }
1224
1281
 
@@ -1233,7 +1290,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1233
1290
  await this.connect();
1234
1291
 
1235
1292
  try {
1236
- // Use cache with automatic fetching
1293
+ // Use cache with automatic fetching. The cache is keyed by object name
1294
+ // only (locale-independent); a language switch wipes it wholesale via
1295
+ // `clearCache()` so the next read re-fetches in the new locale — see the
1296
+ // shell's locale remount (issue #1319). Keeping the key locale-free here
1297
+ // means a metadata *write* still invalidates the single entry it knows
1298
+ // about, without having to fan out across every cached locale.
1237
1299
  const schema = await this.metadataCache.get(objectName, async () => {
1238
1300
  const result: any = await this.client.meta.getItem('object', objectName);
1239
1301
 
@@ -1348,8 +1410,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1348
1410
  try {
1349
1411
  const cacheKey = `view:${objectName}:${viewId}`;
1350
1412
  return await this.metadataCache.get(cacheKey, async () => {
1351
- // Try meta.getItem for view metadata
1352
- const result: any = await this.client.meta.getItem(objectName, viewId);
1413
+ // Views are an independent metadata type (ADR-0017) — the first
1414
+ // getItem argument is the metadata TYPE, not the object name.
1415
+ // (Passing objectName here hit /meta/<object>/<view> and always 404ed.)
1416
+ const result: any = await this.client.meta.getItem('view', viewId);
1353
1417
  if (result && result.item) return result.item;
1354
1418
  return result ?? null;
1355
1419
  });
@@ -1427,13 +1491,43 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1427
1491
  const items: any[] = Array.isArray(result?.items)
1428
1492
  ? result.items
1429
1493
  : Array.isArray(result) ? result : [];
1494
+ // This feeds the list-view switcher (ViewTabBar), so it must return
1495
+ // LIST-family views only. The backend now exposes each view as an
1496
+ // independent ViewItem carrying a `viewKind` discriminant (ADR-0017);
1497
+ // form-family views (`form`/`detail`) are record forms, not list tabs,
1498
+ // and must be excluded — otherwise e.g. `crm_activity.default` (a form)
1499
+ // leaks in as a spurious switcher tab. Bare specs without `viewKind`
1500
+ // (legacy artifacts / saved views) are kept as list views.
1501
+ const FORM_FAMILY = new Set(['form', 'detail']);
1430
1502
  return items.filter((v: any) => {
1431
1503
  if (!v) return false;
1432
1504
  // Handle both bare view spec and `{list: {...}}` artifact wrapper
1433
1505
  const spec = v.list ?? v;
1434
1506
  const obj = spec?.data?.object ?? spec?.object ?? spec?.objectName;
1435
- return obj === objectName;
1436
- }).map((v: any) => v.list ?? v);
1507
+ if (obj !== objectName) return false;
1508
+ const viewKind = v.viewKind ?? spec?.viewKind;
1509
+ return !(viewKind && FORM_FAMILY.has(viewKind));
1510
+ }).map((v: any) => {
1511
+ const spec = v.list ?? v;
1512
+ // Canonical ViewItem (ADR-0017) carries its body under `config`;
1513
+ // the display `type` (grid/kanban/gallery/…) lives at `config.type`,
1514
+ // and only the list/form *family* sits at the top level (`viewKind`).
1515
+ // Flatten `config` up to the legacy NamedListView shape the switcher +
1516
+ // ObjectView consume — mirroring MetadataProvider.mergeViewsIntoObjects
1517
+ // so the two paths don't drift. Without this an un-flattened item has
1518
+ // no top-level `type`, so ObjectView's saved-view normalization defaults
1519
+ // it to 'grid' and overrides the metadata entry — a kanban/gallery/
1520
+ // calendar view then silently renders as a plain table.
1521
+ if (spec && spec.config && typeof spec.config === 'object') {
1522
+ return {
1523
+ ...spec.config,
1524
+ name: spec.name ?? spec.config.name,
1525
+ label: spec.label ?? spec.config.label,
1526
+ isDefault: !!spec.isDefault,
1527
+ };
1528
+ }
1529
+ return spec;
1530
+ });
1437
1531
  } catch (err) {
1438
1532
  console.warn('[OBJECTSTACKDataSource] listViews failed:', err);
1439
1533
  return [];
@@ -1732,6 +1826,111 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1732
1826
  }
1733
1827
  }
1734
1828
 
1829
+ /**
1830
+ * Run a semantic-layer `dataset` (ADR-0021) and return chart-ready rows.
1831
+ *
1832
+ * Posts to `POST /api/v1/analytics/dataset/query` (see `@objectstack/rest`
1833
+ * `registerAnalyticsEndpoints`). Accepts either a saved dataset name or an
1834
+ * inline draft definition — the inline form is what the Studio dataset
1835
+ * editor sends to preview an unsaved draft. The adapter's bearer token is
1836
+ * forwarded so tenant/RLS scoping (ADR-0021 D-C) is enforced server-side.
1837
+ *
1838
+ * Unlike {@link aggregate}, this does NOT fall back to client-side
1839
+ * aggregation: cross-object joins can only run on the server, so a failure
1840
+ * is surfaced to the caller (the preview panel shows the error) rather than
1841
+ * silently returning wrong numbers.
1842
+ *
1843
+ * @param dataset - An inline dataset definition (draft) OR a saved dataset name.
1844
+ * @param selection - Dimension/measure names to project + runtime directives.
1845
+ */
1846
+ async queryDataset(
1847
+ dataset: Record<string, unknown> | string,
1848
+ selection: {
1849
+ dimensions?: string[];
1850
+ measures: string[];
1851
+ runtimeFilter?: Record<string, unknown>;
1852
+ timeDimensions?: unknown[];
1853
+ compareTo?: { kind: 'previousPeriod' | 'previousYear'; dimension: string };
1854
+ order?: Record<string, 'asc' | 'desc'>;
1855
+ limit?: number;
1856
+ offset?: number;
1857
+ timezone?: string;
1858
+ /** Marginal-aggregate groupings (e.g. `[rows, [colDim], []]`) — server
1859
+ * computes each subtotal with the measure's TRUE aggregate (never client
1860
+ * re-derived). `[]` is the grand total. */
1861
+ totals?: { groupings: string[][] };
1862
+ },
1863
+ ): Promise<{
1864
+ rows: Array<Record<string, unknown>>;
1865
+ /** Column metadata: a display `label` (dimensions and measures), and a
1866
+ * measure's numeral `format` + declared `currency` for value formatting. */
1867
+ fields: Array<{ name: string; type: string; label?: string; format?: string; currency?: string }>;
1868
+ /** ADR-0021 D2 drill-through: the dataset's base object (records to drill into). */
1869
+ object?: string;
1870
+ /** Drillable dimension NAME → underlying object FIELD name. */
1871
+ dimensionFields?: Record<string, string>;
1872
+ /** Raw grouped values per row (aligned to `rows` by index) for drill filters. */
1873
+ drillRawRows?: Array<Record<string, unknown>>;
1874
+ /** Server-computed marginal aggregates, one entry per requested grouping. */
1875
+ totals?: Array<{ dimensions: string[]; rows: Array<Record<string, unknown>> }>;
1876
+ }> {
1877
+ await this.connect();
1878
+ const base = (this.baseUrl || '').replace(/\/$/, '');
1879
+ const url = `${base}/api/v1/analytics/dataset/query`;
1880
+ // ADR-0037 P3 — draft data preview. Preview mode is URL-keyed by design
1881
+ // (`?preview=draft` flips the whole document, incl. the Live Canvas
1882
+ // iframe), so the adapter reads it straight off the location rather than
1883
+ // threading a React context down through every widget package. When set,
1884
+ // the server overlays the pending seed draft's rows on the dataset query
1885
+ // and resolves draft-overlaid dataset definitions.
1886
+ const previewDrafts =
1887
+ typeof window !== 'undefined' &&
1888
+ new URLSearchParams(window.location.search).get('preview') === 'draft';
1889
+ const requestBody = typeof dataset === 'string'
1890
+ ? { datasetName: dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) }
1891
+ : { dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) };
1892
+
1893
+ const res = await this.fetchImpl(url, {
1894
+ method: 'POST',
1895
+ headers: {
1896
+ 'Content-Type': 'application/json',
1897
+ ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
1898
+ },
1899
+ body: JSON.stringify(requestBody),
1900
+ });
1901
+
1902
+ if (!res.ok) {
1903
+ let detail = '';
1904
+ try {
1905
+ const errBody = await res.json();
1906
+ detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
1907
+ } catch { /* non-JSON error body */ }
1908
+ throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
1909
+ }
1910
+
1911
+ const payload = await res.json();
1912
+ // Unwrap the standard `{ success, data }` envelope when present.
1913
+ const data = payload && typeof payload === 'object' && 'success' in payload && 'data' in payload
1914
+ ? (payload as { data: unknown }).data
1915
+ : payload;
1916
+ const rows = Array.isArray((data as any)?.rows)
1917
+ ? (data as any).rows
1918
+ : (Array.isArray(data) ? (data as any) : []);
1919
+ const fields = Array.isArray((data as any)?.fields) ? (data as any).fields : [];
1920
+ // Drill-through metadata (ADR-0021 D2): the server exposes the dataset's
1921
+ // base object + drillable dimension→field mapping, plus a parallel array of
1922
+ // RAW grouped values (the rows themselves carry display labels), so a host
1923
+ // can build an exact-match filter from a clicked bucket.
1924
+ const object = typeof (data as any)?.object === 'string' ? (data as any).object : undefined;
1925
+ const dimensionFields =
1926
+ (data as any)?.dimensionFields && typeof (data as any).dimensionFields === 'object'
1927
+ ? ((data as any).dimensionFields as Record<string, string>)
1928
+ : undefined;
1929
+ const drillRawRows = Array.isArray((data as any)?.drillRawRows) ? (data as any).drillRawRows : undefined;
1930
+ const totals = Array.isArray((data as any)?.totals) ? (data as any).totals : undefined;
1931
+ return { rows, fields, object, dimensionFields, drillRawRows, totals };
1932
+ }
1933
+
1735
1934
  /** Client-side aggregation fallback */
1736
1935
  private aggregateClientSide(records: any[], params: { field: string; function: string; groupBy: string }): any[] {
1737
1936
  const { field, function: aggFn, groupBy } = params;
@@ -2076,11 +2275,20 @@ export { MetadataClient } from './metadata-client';
2076
2275
  export type {
2077
2276
  MetadataClientConfig,
2078
2277
  MetadataListOptions,
2278
+ MetadataDraftHeader,
2079
2279
  MetadataSaveOptions,
2280
+ MetadataGetOptions,
2281
+ MetadataDeleteOptions,
2080
2282
  MetadataHistoryOptions,
2081
2283
  MetadataError,
2082
2284
  MetadataLayered,
2083
2285
  MetadataReference,
2286
+ MetadataDiagnostics,
2287
+ MetadataDiagnosticsOptions,
2288
+ MetadataDiagnosticsEntry,
2289
+ MetadataDiagnosticsSummary,
2290
+ MetadataAuditEntry,
2291
+ MetadataAuditResponse,
2084
2292
  } from './metadata-client';
2085
2293
 
2086
2294
  export { SecurityManager } from './security';
@@ -0,0 +1,78 @@
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 } from 'vitest';
10
+ import { ObjectStackAdapter } from './index';
11
+
12
+ /**
13
+ * `listViews(object)` feeds the list-view switcher (ObjectView → ViewTabBar),
14
+ * so it must return LIST-family views only. The backend exposes each view as
15
+ * an independent ViewItem carrying a `viewKind` discriminant (ADR-0017); a
16
+ * form-family view (e.g. `crm_activity.default`) must NOT leak in as a tab.
17
+ */
18
+ function makeDS(items: any[]) {
19
+ const ds: any = new ObjectStackAdapter({
20
+ baseUrl: 'http://test.local',
21
+ fetch: vi.fn(async () =>
22
+ new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
23
+ status: 200,
24
+ headers: { 'Content-Type': 'application/json' },
25
+ })),
26
+ });
27
+ ds.connected = true;
28
+ ds.connectionState = 'connected';
29
+ ds.client = { meta: { getItems: vi.fn(async () => ({ items })) } };
30
+ return ds;
31
+ }
32
+
33
+ describe('ObjectStackDataSource.listViews', () => {
34
+ // The crm_activity view set as the framework expands it.
35
+ const listAll = {
36
+ name: 'crm_activity.all', object: 'crm_activity', viewKind: 'list', isDefault: true,
37
+ label: 'All Activities', config: { type: 'grid', data: { object: 'crm_activity' } },
38
+ };
39
+ const listCalendar = {
40
+ name: 'crm_activity.calendar', object: 'crm_activity', viewKind: 'list',
41
+ label: 'Activity Calendar', config: { type: 'calendar', data: { object: 'crm_activity' } },
42
+ };
43
+ const formDefault = {
44
+ name: 'crm_activity.default', object: 'crm_activity', viewKind: 'form',
45
+ label: 'Activity', config: { type: 'simple' },
46
+ };
47
+
48
+ it('excludes form-family ViewItems from the list-view switcher', async () => {
49
+ const ds = makeDS([listAll, listCalendar, formDefault]);
50
+ const views = await ds.listViews('crm_activity');
51
+ const names = views.map((v: any) => v.name).sort();
52
+ expect(names).toEqual(['crm_activity.all', 'crm_activity.calendar']);
53
+ // The form view is the original leak — it must be gone.
54
+ expect(views.find((v: any) => v.name === 'crm_activity.default')).toBeUndefined();
55
+ });
56
+
57
+ it('excludes detail-family views too', async () => {
58
+ const detail = { name: 'crm_activity.detail', object: 'crm_activity', viewKind: 'detail', config: {} };
59
+ const ds = makeDS([listAll, detail]);
60
+ const views = await ds.listViews('crm_activity');
61
+ expect(views.map((v: any) => v.name)).toEqual(['crm_activity.all']);
62
+ });
63
+
64
+ it('filters by object binding', async () => {
65
+ const otherList = { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', config: {} };
66
+ const ds = makeDS([listAll, otherList]);
67
+ const views = await ds.listViews('crm_activity');
68
+ expect(views.map((v: any) => v.name)).toEqual(['crm_activity.all']);
69
+ });
70
+
71
+ it('keeps legacy bare specs without a viewKind (saved/list views)', async () => {
72
+ const legacy = { name: 'saved_grid', object: 'crm_activity', type: 'grid' };
73
+ const wrapped = { list: { name: 'wrapped_grid', object: 'crm_activity', type: 'grid' } };
74
+ const ds = makeDS([legacy, wrapped]);
75
+ const views = await ds.listViews('crm_activity');
76
+ expect(views.map((v: any) => v.name).sort()).toEqual(['saved_grid', 'wrapped_grid']);
77
+ });
78
+ });
@@ -121,4 +121,145 @@ describe('MetadataClient', () => {
121
121
  message: 'metadata_conflict',
122
122
  });
123
123
  });
124
+
125
+ it('listDrafts requests /meta/_drafts with packageId + type and parses {drafts}', async () => {
126
+ const seen: string[] = [];
127
+ const c = new MetadataClient({
128
+ baseUrl: 'http://localhost:3000',
129
+ fetch: mockFetch(async (url) => {
130
+ seen.push(url);
131
+ return jsonResponse({
132
+ drafts: [{ type: 'object', name: 'course', packageId: 'app.edu', updatedAt: 't', updatedBy: 'ai' }],
133
+ });
134
+ }),
135
+ });
136
+ const out = await c.listDrafts({ packageId: 'app.edu', type: 'object' });
137
+ expect(seen[0]).toBe('http://localhost:3000/api/v1/meta/_drafts?packageId=app.edu&type=object');
138
+ expect(out).toEqual([
139
+ { type: 'object', name: 'course', packageId: 'app.edu', updatedAt: 't', updatedBy: 'ai' },
140
+ ]);
141
+ });
142
+
143
+ it('listDrafts tolerates the {data:{drafts}} envelope and a bare array', async () => {
144
+ const enveloped = new MetadataClient({
145
+ baseUrl: '',
146
+ fetch: mockFetch(async () =>
147
+ jsonResponse({ data: { drafts: [{ type: 'view', name: 'v', packageId: null, updatedAt: null, updatedBy: null }] } })),
148
+ });
149
+ expect((await enveloped.listDrafts()).map((d) => d.name)).toEqual(['v']);
150
+
151
+ const bare = new MetadataClient({
152
+ baseUrl: '',
153
+ fetch: mockFetch(async () =>
154
+ jsonResponse([{ type: 'object', name: 'b', packageId: null, updatedAt: null, updatedBy: null }])),
155
+ });
156
+ expect((await bare.listDrafts()).map((d) => d.name)).toEqual(['b']);
157
+ });
158
+
159
+ it('publishDraft POSTs /meta/:type/:name/publish (promotes a draft by ref, no package needed)', async () => {
160
+ const seen: { url: string; method?: string }[] = [];
161
+ const c = new MetadataClient({
162
+ baseUrl: 'http://localhost:3000',
163
+ fetch: mockFetch(async (url, init) => {
164
+ seen.push({ url, method: init?.method });
165
+ return jsonResponse({ success: true });
166
+ }),
167
+ });
168
+ await c.publishDraft('dashboard', 'sales_dashboard');
169
+ expect(seen[0]?.method).toBe('POST');
170
+ expect(seen[0]?.url).toBe('http://localhost:3000/api/v1/meta/dashboard/sales_dashboard/publish');
171
+ });
172
+
173
+ it('publishDraft throws on a non-ok response', async () => {
174
+ const c = new MetadataClient({
175
+ baseUrl: '',
176
+ fetch: mockFetch(async () => new Response('nope', { status: 500 })),
177
+ });
178
+ await expect(c.publishDraft('view', 'x')).rejects.toBeTruthy();
179
+ });
180
+
181
+ it('publishDraft returns the result (incl. seedApplied) and unwraps the {data} envelope', async () => {
182
+ // Flat shape (rest-server passes protocol result through).
183
+ const flat = new MetadataClient({
184
+ baseUrl: '',
185
+ fetch: mockFetch(async () =>
186
+ jsonResponse({ success: true, seedApplied: { success: false, error: 'no readable seed bodies' } })),
187
+ });
188
+ const r1 = await flat.publishDraft('seed', 'job_sample');
189
+ expect(r1.seedApplied).toEqual({ success: false, error: 'no readable seed bodies' });
190
+
191
+ // Dispatcher `{ success, data: {...} }` envelope.
192
+ const enveloped = new MetadataClient({
193
+ baseUrl: '',
194
+ fetch: mockFetch(async () =>
195
+ jsonResponse({ success: true, data: { success: true, seedApplied: { success: true, inserted: 6, updated: 0 } } })),
196
+ });
197
+ const r2 = await enveloped.publishDraft('seed', 'candidate_sample');
198
+ expect(r2.seedApplied).toEqual({ success: true, inserted: 6, updated: 0 });
199
+ });
200
+ });
201
+
202
+ describe('MetadataClient — ADR-0037 previewDrafts', () => {
203
+ function previewClient(opts: { previewDrafts?: boolean; environmentId?: string } = {}) {
204
+ const urls: string[] = [];
205
+ const c = new MetadataClient({
206
+ baseUrl: 'http://localhost:3000',
207
+ ...opts,
208
+ fetch: mockFetch(async (url) => {
209
+ urls.push(url);
210
+ return jsonResponse([{ name: 'account' }]);
211
+ }),
212
+ });
213
+ return { c, urls };
214
+ }
215
+
216
+ it('list() and get() append ?preview=draft when enabled', async () => {
217
+ const { c, urls } = previewClient({ previewDrafts: true });
218
+ await c.list('object');
219
+ await c.get('object', 'account');
220
+ expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object?preview=draft');
221
+ expect(urls[1]).toBe('http://localhost:3000/api/v1/meta/object/account?preview=draft');
222
+ });
223
+
224
+ it('combines preview with the package filter on list()', async () => {
225
+ const { c, urls } = previewClient({ previewDrafts: true });
226
+ await c.list('object', { packageId: 'app.crm' });
227
+ expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object?package=app.crm&preview=draft');
228
+ });
229
+
230
+ it('an explicit state=draft read wins over the overlay flag', async () => {
231
+ const { c, urls } = previewClient({ previewDrafts: true });
232
+ await c.get('object', 'account', { state: 'draft' });
233
+ expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/account?state=draft');
234
+ });
235
+
236
+ it('default client never sends the flag', async () => {
237
+ const { c, urls } = previewClient();
238
+ await c.list('object');
239
+ await c.get('object', 'account');
240
+ expect(urls[0]).not.toContain('preview');
241
+ expect(urls[1]).not.toContain('preview');
242
+ });
243
+
244
+ it('withPreviewDrafts derives a preview client and PRESERVES the environment scope', async () => {
245
+ const { c } = previewClient({ environmentId: 'env_1' });
246
+ const p = c.withPreviewDrafts(true);
247
+ expect(p.previewDrafts).toBe(true);
248
+ // Same instance back when the flag already matches.
249
+ expect(p.withPreviewDrafts(true)).toBe(p);
250
+ // Environment-scoped path survives the derivation (regression guard:
251
+ // a naive rebuild dropped /environments/:id from the base).
252
+ const urls: string[] = [];
253
+ const scoped = new MetadataClient({
254
+ baseUrl: 'http://localhost:3000',
255
+ environmentId: 'env_1',
256
+ previewDrafts: false,
257
+ fetch: mockFetch(async (url) => {
258
+ urls.push(url);
259
+ return jsonResponse([]);
260
+ }),
261
+ }).withPreviewDrafts(true);
262
+ await scoped.list('object');
263
+ expect(urls[0]).toBe('http://localhost:3000/api/v1/environments/env_1/meta/object?preview=draft');
264
+ });
124
265
  });