@object-ui/data-objectstack 6.2.3 → 7.1.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,17 +1290,22 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1233
1290
  await this.connect();
1234
1291
 
1235
1292
  try {
1236
- // Use cache with automatic fetching
1237
- const schema = await this.metadataCache.get(objectName, async () => {
1238
- const result: any = await this.client.meta.getItem('object', objectName);
1239
-
1240
- // Unwrap 'item' property if present (common API response wrapper)
1241
- if (result && result.item) {
1242
- return result.item;
1243
- }
1244
-
1245
- return result;
1246
- });
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.
1299
+ // Read through a cache-revalidating fetch (see fetchObjectSchemaFresh):
1300
+ // the server marks single-object metadata `public, max-age=3600`, so a
1301
+ // plain fetch would keep serving the pre-publish schema from the browser
1302
+ // HTTP cache for up to an hour — and the create/edit form (which reads
1303
+ // getObjectSchema) would never show a field added + published in this
1304
+ // session. The list endpoint is uncached, which is why list views already
1305
+ // refresh on publish.
1306
+ const schema = await this.metadataCache.get(objectName, () =>
1307
+ this.fetchObjectSchemaFresh(objectName),
1308
+ );
1247
1309
 
1248
1310
  return schema;
1249
1311
  } catch (error: unknown) {
@@ -1262,6 +1324,59 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1262
1324
  }
1263
1325
  }
1264
1326
 
1327
+ /**
1328
+ * Fetch a single object's schema while always revalidating the browser cache.
1329
+ *
1330
+ * The server serves `GET /api/v1/meta/object/:name` with
1331
+ * `Cache-Control: public, max-age=3600`, so the default `fetch` the SDK uses
1332
+ * keeps returning the same response from the browser HTTP cache for up to an
1333
+ * hour without contacting the origin. Because the create/edit form reads the
1334
+ * object schema through {@link getObjectSchema}, a field added + published in
1335
+ * the same session never appears in the form even though it is live (the LIST
1336
+ * endpoint, `/meta/object`, is uncached — which is why list views update).
1337
+ *
1338
+ * Issuing the read with `cache: 'no-cache'` forces a conditional revalidation
1339
+ * (`If-None-Match`): a changed ETag returns the fresh schema, an unchanged one
1340
+ * still gets a cheap `304`. We go through `fetchImpl` (the adapter's
1341
+ * authenticated fetch) rather than `client.meta.getItem` because the SDK does
1342
+ * not expose the request cache mode.
1343
+ */
1344
+ private async fetchObjectSchemaFresh(objectName: string): Promise<unknown> {
1345
+ const baseUrl = (this.baseUrl || '').replace(/\/$/, '');
1346
+ // Avoid doubling /api/v1 when baseUrl already carries the version suffix
1347
+ // (mirrors rawFindWithPopulate).
1348
+ const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
1349
+ const metaPath = hasApiVersionSuffix ? '/meta' : '/api/v1/meta';
1350
+ const url = `${baseUrl}${metaPath}/object/${encodeURIComponent(objectName)}`;
1351
+
1352
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
1353
+ // Bearer (server-to-server) callers configure `this.token`; cookie/console
1354
+ // auth is injected by `fetchImpl` (the authenticated fetch wrapper).
1355
+ if (this.token) {
1356
+ headers['Authorization'] = `Bearer ${this.token}`;
1357
+ }
1358
+
1359
+ const res = await this.fetchImpl(url, {
1360
+ method: 'GET',
1361
+ headers,
1362
+ // Revalidate instead of serving the stale `max-age` body (see doc above).
1363
+ cache: 'no-cache',
1364
+ });
1365
+
1366
+ if (!res.ok) {
1367
+ const errBody: any = await res.json().catch(() => ({ message: res.statusText }));
1368
+ const err: any = new Error(errBody?.error?.message || errBody?.message || res.statusText);
1369
+ err.status = res.status;
1370
+ throw err;
1371
+ }
1372
+
1373
+ const body: any = await res.json();
1374
+ // Unwrap defensively across server/SDK response shapes: the standard
1375
+ // `{ success, data }` envelope, an `{ item }` wrapper, or the bare item.
1376
+ const data = body && typeof body === 'object' && 'success' in body && 'data' in body ? body.data : body;
1377
+ return data && typeof data === 'object' && 'item' in data ? data.item : data;
1378
+ }
1379
+
1265
1380
  /**
1266
1381
  * Get access to the underlying ObjectStack client for advanced operations.
1267
1382
  */
@@ -1348,8 +1463,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1348
1463
  try {
1349
1464
  const cacheKey = `view:${objectName}:${viewId}`;
1350
1465
  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);
1466
+ // Views are an independent metadata type (ADR-0017) — the first
1467
+ // getItem argument is the metadata TYPE, not the object name.
1468
+ // (Passing objectName here hit /meta/<object>/<view> and always 404ed.)
1469
+ const result: any = await this.client.meta.getItem('view', viewId);
1353
1470
  if (result && result.item) return result.item;
1354
1471
  return result ?? null;
1355
1472
  });
@@ -1427,13 +1544,43 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1427
1544
  const items: any[] = Array.isArray(result?.items)
1428
1545
  ? result.items
1429
1546
  : Array.isArray(result) ? result : [];
1547
+ // This feeds the list-view switcher (ViewTabBar), so it must return
1548
+ // LIST-family views only. The backend now exposes each view as an
1549
+ // independent ViewItem carrying a `viewKind` discriminant (ADR-0017);
1550
+ // form-family views (`form`/`detail`) are record forms, not list tabs,
1551
+ // and must be excluded — otherwise e.g. `crm_activity.default` (a form)
1552
+ // leaks in as a spurious switcher tab. Bare specs without `viewKind`
1553
+ // (legacy artifacts / saved views) are kept as list views.
1554
+ const FORM_FAMILY = new Set(['form', 'detail']);
1430
1555
  return items.filter((v: any) => {
1431
1556
  if (!v) return false;
1432
1557
  // Handle both bare view spec and `{list: {...}}` artifact wrapper
1433
1558
  const spec = v.list ?? v;
1434
1559
  const obj = spec?.data?.object ?? spec?.object ?? spec?.objectName;
1435
- return obj === objectName;
1436
- }).map((v: any) => v.list ?? v);
1560
+ if (obj !== objectName) return false;
1561
+ const viewKind = v.viewKind ?? spec?.viewKind;
1562
+ return !(viewKind && FORM_FAMILY.has(viewKind));
1563
+ }).map((v: any) => {
1564
+ const spec = v.list ?? v;
1565
+ // Canonical ViewItem (ADR-0017) carries its body under `config`;
1566
+ // the display `type` (grid/kanban/gallery/…) lives at `config.type`,
1567
+ // and only the list/form *family* sits at the top level (`viewKind`).
1568
+ // Flatten `config` up to the legacy NamedListView shape the switcher +
1569
+ // ObjectView consume — mirroring MetadataProvider.mergeViewsIntoObjects
1570
+ // so the two paths don't drift. Without this an un-flattened item has
1571
+ // no top-level `type`, so ObjectView's saved-view normalization defaults
1572
+ // it to 'grid' and overrides the metadata entry — a kanban/gallery/
1573
+ // calendar view then silently renders as a plain table.
1574
+ if (spec && spec.config && typeof spec.config === 'object') {
1575
+ return {
1576
+ ...spec.config,
1577
+ name: spec.name ?? spec.config.name,
1578
+ label: spec.label ?? spec.config.label,
1579
+ isDefault: !!spec.isDefault,
1580
+ };
1581
+ }
1582
+ return spec;
1583
+ });
1437
1584
  } catch (err) {
1438
1585
  console.warn('[OBJECTSTACKDataSource] listViews failed:', err);
1439
1586
  return [];
@@ -1732,6 +1879,111 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1732
1879
  }
1733
1880
  }
1734
1881
 
1882
+ /**
1883
+ * Run a semantic-layer `dataset` (ADR-0021) and return chart-ready rows.
1884
+ *
1885
+ * Posts to `POST /api/v1/analytics/dataset/query` (see `@objectstack/rest`
1886
+ * `registerAnalyticsEndpoints`). Accepts either a saved dataset name or an
1887
+ * inline draft definition — the inline form is what the Studio dataset
1888
+ * editor sends to preview an unsaved draft. The adapter's bearer token is
1889
+ * forwarded so tenant/RLS scoping (ADR-0021 D-C) is enforced server-side.
1890
+ *
1891
+ * Unlike {@link aggregate}, this does NOT fall back to client-side
1892
+ * aggregation: cross-object joins can only run on the server, so a failure
1893
+ * is surfaced to the caller (the preview panel shows the error) rather than
1894
+ * silently returning wrong numbers.
1895
+ *
1896
+ * @param dataset - An inline dataset definition (draft) OR a saved dataset name.
1897
+ * @param selection - Dimension/measure names to project + runtime directives.
1898
+ */
1899
+ async queryDataset(
1900
+ dataset: Record<string, unknown> | string,
1901
+ selection: {
1902
+ dimensions?: string[];
1903
+ measures: string[];
1904
+ runtimeFilter?: Record<string, unknown>;
1905
+ timeDimensions?: unknown[];
1906
+ compareTo?: { kind: 'previousPeriod' | 'previousYear'; dimension: string };
1907
+ order?: Record<string, 'asc' | 'desc'>;
1908
+ limit?: number;
1909
+ offset?: number;
1910
+ timezone?: string;
1911
+ /** Marginal-aggregate groupings (e.g. `[rows, [colDim], []]`) — server
1912
+ * computes each subtotal with the measure's TRUE aggregate (never client
1913
+ * re-derived). `[]` is the grand total. */
1914
+ totals?: { groupings: string[][] };
1915
+ },
1916
+ ): Promise<{
1917
+ rows: Array<Record<string, unknown>>;
1918
+ /** Column metadata: a display `label` (dimensions and measures), and a
1919
+ * measure's numeral `format` + declared `currency` for value formatting. */
1920
+ fields: Array<{ name: string; type: string; label?: string; format?: string; currency?: string }>;
1921
+ /** ADR-0021 D2 drill-through: the dataset's base object (records to drill into). */
1922
+ object?: string;
1923
+ /** Drillable dimension NAME → underlying object FIELD name. */
1924
+ dimensionFields?: Record<string, string>;
1925
+ /** Raw grouped values per row (aligned to `rows` by index) for drill filters. */
1926
+ drillRawRows?: Array<Record<string, unknown>>;
1927
+ /** Server-computed marginal aggregates, one entry per requested grouping. */
1928
+ totals?: Array<{ dimensions: string[]; rows: Array<Record<string, unknown>> }>;
1929
+ }> {
1930
+ await this.connect();
1931
+ const base = (this.baseUrl || '').replace(/\/$/, '');
1932
+ const url = `${base}/api/v1/analytics/dataset/query`;
1933
+ // ADR-0037 P3 — draft data preview. Preview mode is URL-keyed by design
1934
+ // (`?preview=draft` flips the whole document, incl. the Live Canvas
1935
+ // iframe), so the adapter reads it straight off the location rather than
1936
+ // threading a React context down through every widget package. When set,
1937
+ // the server overlays the pending seed draft's rows on the dataset query
1938
+ // and resolves draft-overlaid dataset definitions.
1939
+ const previewDrafts =
1940
+ typeof window !== 'undefined' &&
1941
+ new URLSearchParams(window.location.search).get('preview') === 'draft';
1942
+ const requestBody = typeof dataset === 'string'
1943
+ ? { datasetName: dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) }
1944
+ : { dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) };
1945
+
1946
+ const res = await this.fetchImpl(url, {
1947
+ method: 'POST',
1948
+ headers: {
1949
+ 'Content-Type': 'application/json',
1950
+ ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
1951
+ },
1952
+ body: JSON.stringify(requestBody),
1953
+ });
1954
+
1955
+ if (!res.ok) {
1956
+ let detail = '';
1957
+ try {
1958
+ const errBody = await res.json();
1959
+ detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
1960
+ } catch { /* non-JSON error body */ }
1961
+ throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
1962
+ }
1963
+
1964
+ const payload = await res.json();
1965
+ // Unwrap the standard `{ success, data }` envelope when present.
1966
+ const data = payload && typeof payload === 'object' && 'success' in payload && 'data' in payload
1967
+ ? (payload as { data: unknown }).data
1968
+ : payload;
1969
+ const rows = Array.isArray((data as any)?.rows)
1970
+ ? (data as any).rows
1971
+ : (Array.isArray(data) ? (data as any) : []);
1972
+ const fields = Array.isArray((data as any)?.fields) ? (data as any).fields : [];
1973
+ // Drill-through metadata (ADR-0021 D2): the server exposes the dataset's
1974
+ // base object + drillable dimension→field mapping, plus a parallel array of
1975
+ // RAW grouped values (the rows themselves carry display labels), so a host
1976
+ // can build an exact-match filter from a clicked bucket.
1977
+ const object = typeof (data as any)?.object === 'string' ? (data as any).object : undefined;
1978
+ const dimensionFields =
1979
+ (data as any)?.dimensionFields && typeof (data as any).dimensionFields === 'object'
1980
+ ? ((data as any).dimensionFields as Record<string, string>)
1981
+ : undefined;
1982
+ const drillRawRows = Array.isArray((data as any)?.drillRawRows) ? (data as any).drillRawRows : undefined;
1983
+ const totals = Array.isArray((data as any)?.totals) ? (data as any).totals : undefined;
1984
+ return { rows, fields, object, dimensionFields, drillRawRows, totals };
1985
+ }
1986
+
1735
1987
  /** Client-side aggregation fallback */
1736
1988
  private aggregateClientSide(records: any[], params: { field: string; function: string; groupBy: string }): any[] {
1737
1989
  const { field, function: aggFn, groupBy } = params;
@@ -2076,11 +2328,20 @@ export { MetadataClient } from './metadata-client';
2076
2328
  export type {
2077
2329
  MetadataClientConfig,
2078
2330
  MetadataListOptions,
2331
+ MetadataDraftHeader,
2079
2332
  MetadataSaveOptions,
2333
+ MetadataGetOptions,
2334
+ MetadataDeleteOptions,
2080
2335
  MetadataHistoryOptions,
2081
2336
  MetadataError,
2082
2337
  MetadataLayered,
2083
2338
  MetadataReference,
2339
+ MetadataDiagnostics,
2340
+ MetadataDiagnosticsOptions,
2341
+ MetadataDiagnosticsEntry,
2342
+ MetadataDiagnosticsSummary,
2343
+ MetadataAuditEntry,
2344
+ MetadataAuditResponse,
2084
2345
  } from './metadata-client';
2085
2346
 
2086
2347
  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
  });