@object-ui/data-objectstack 17.5.0 → 17.7.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 +2311 -0
- package/README.md +557 -11
- package/dist/index.cjs +1663 -463
- package/dist/index.d.cts +1780 -518
- package/dist/index.d.ts +1780 -518
- package/dist/index.js +1648 -457
- package/package.json +9 -9
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/aggregate-capability.test.ts +0 -177
- package/src/apiAccessDenied.test.ts +0 -233
- package/src/appAccessProbe.test.ts +0 -147
- package/src/bulkDelete.test.ts +0 -75
- package/src/bulkUpdate.test.ts +0 -93
- package/src/cache/MetadataCache.test.ts +0 -477
- package/src/cache/MetadataCache.ts +0 -289
- package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
- package/src/connection.test.ts +0 -188
- package/src/contracts.ts +0 -115
- package/src/deleteView.homes.test.ts +0 -389
- package/src/deleteViewContract.types.test.ts +0 -246
- package/src/errors.test.ts +0 -426
- package/src/errors.ts +0 -293
- package/src/exportDownload.test.ts +0 -141
- package/src/filter-entry-translation.test.ts +0 -360
- package/src/filter-operator-ast-parity.test.ts +0 -135
- package/src/getObjectSchema.test.ts +0 -145
- package/src/httpFailureLogging.test.ts +0 -109
- package/src/importJob.test.ts +0 -71
- package/src/index.ts +0 -4442
- package/src/integration.ts +0 -200
- package/src/listViewOverrides.test.ts +0 -241
- package/src/listViews.test.ts +0 -175
- package/src/metadata-client.get-envelope.test.ts +0 -171
- package/src/metadata-client.saveAdvisories.test.ts +0 -226
- package/src/metadata-client.test.ts +0 -303
- package/src/metadata-client.ts +0 -1046
- package/src/occ.test.ts +0 -101
- package/src/onMutation.test.ts +0 -589
- package/src/onSaveAdvisory.test.ts +0 -311
- package/src/onWriteWarning.test.ts +0 -273
- package/src/orderby-serialization.test.ts +0 -115
- package/src/queryDataset.test.ts +0 -543
- package/src/searchAll.test.ts +0 -120
- package/src/security.ts +0 -243
- package/src/spec-symbol-batch6.test.ts +0 -326
- package/src/studio.ts +0 -152
- package/src/updateView.draft.test.ts +0 -242
- package/src/userState.test.ts +0 -315
- package/src/userState.ts +0 -238
- package/src/v3-compat.test.ts +0 -229
- package/src/validation-error.test.ts +0 -94
- package/src/viewCacheInvalidation.pin.test.ts +0 -260
- package/src/viewInvalidationSeam.pin.test.ts +0 -162
|
@@ -1,115 +0,0 @@
|
|
|
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
|
-
* Every shape `QueryParams['$orderby']` declares reaches the wire as a sort.
|
|
11
|
-
*
|
|
12
|
-
* `find()` has TWO routes to the server — a plain read goes through
|
|
13
|
-
* `convertQueryParams` and the client SDK, a read with `$expand`/`$search`
|
|
14
|
-
* through `rawFindWithPopulate` — and each used to carry its own copy of the
|
|
15
|
-
* "which `$orderby` shape is this?" fold. Both copies handled three of the four
|
|
16
|
-
* declared shapes; both missed the bare string, and missed it the same
|
|
17
|
-
* spectacular way: the string fell into the `Record<field, direction>` branch,
|
|
18
|
-
* where `Object.entries('name asc')` enumerates character indices and the
|
|
19
|
-
* request went out as `sort=0,1,2,3,4,5,6,7`.
|
|
20
|
-
*
|
|
21
|
-
* That is not a cosmetic mistranslation. Since objectstack#4226 the server
|
|
22
|
-
* refuses a sort it cannot read (`400 INVALID_SORT`) rather than dropping it,
|
|
23
|
-
* so the whole list fails to load — and `"${field} ${order}"` is exactly what a
|
|
24
|
-
* standalone `ObjectGrid` builds from its view metadata's `sort`.
|
|
25
|
-
*
|
|
26
|
-
* Both routes are asserted for every shape, because "the two routes agree" is
|
|
27
|
-
* the property that was actually missing; one serializer now backs both.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
31
|
-
import { ObjectStackAdapter, clearSharedDiscoveryCache, serializeOrderBy } from './index';
|
|
32
|
-
|
|
33
|
-
function makeAdapter() {
|
|
34
|
-
const calls: string[] = [];
|
|
35
|
-
const fetchImpl = vi.fn(async (url: any) => {
|
|
36
|
-
const u = String(url);
|
|
37
|
-
calls.push(u);
|
|
38
|
-
if (u.includes('/api/v1/discovery')) {
|
|
39
|
-
return {
|
|
40
|
-
ok: true, status: 200, statusText: 'OK',
|
|
41
|
-
json: async () => ({ success: true, data: { version: 'v1', routes: {} } }),
|
|
42
|
-
} as any;
|
|
43
|
-
}
|
|
44
|
-
return {
|
|
45
|
-
ok: true, status: 200, statusText: 'OK',
|
|
46
|
-
json: async () => ({ success: true, data: { object: 'account', records: [], total: 0 } }),
|
|
47
|
-
} as any;
|
|
48
|
-
});
|
|
49
|
-
const adapter = new ObjectStackAdapter({
|
|
50
|
-
baseUrl: 'http://localhost:3000', token: 't', autoReconnect: false, fetch: fetchImpl as any,
|
|
51
|
-
});
|
|
52
|
-
return { adapter, calls };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** The `sort=` this `$orderby` produced on the wire, or `undefined` if none was sent. */
|
|
56
|
-
async function sortOnWire($orderby: unknown, route: 'plain' | 'expand'): Promise<string | undefined> {
|
|
57
|
-
const { adapter, calls } = makeAdapter();
|
|
58
|
-
await adapter.find('account', {
|
|
59
|
-
$orderby,
|
|
60
|
-
...(route === 'expand' ? { $expand: ['owner'] } : {}),
|
|
61
|
-
} as any);
|
|
62
|
-
const dataCall = calls.filter((u) => u.includes('/data/account')).pop();
|
|
63
|
-
const raw = dataCall ? new URL(dataCall).searchParams.get('sort') : null;
|
|
64
|
-
return raw === null ? undefined : raw;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Run one input down both `find()` routes and assert they agree. */
|
|
68
|
-
function bothRoutes(name: string, $orderby: unknown, expected: string | undefined) {
|
|
69
|
-
for (const route of ['plain', 'expand'] as const) {
|
|
70
|
-
it(`${name} (${route} route)`, async () => {
|
|
71
|
-
expect(await sortOnWire($orderby, route)).toBe(expected);
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
describe('$orderby reaches the wire for every declared shape', () => {
|
|
77
|
-
beforeEach(() => clearSharedDiscoveryCache());
|
|
78
|
-
|
|
79
|
-
// The regression: ObjectGrid's own `"${field} ${order}"`.
|
|
80
|
-
bothRoutes('a bare "field order" string', 'name asc', 'name asc');
|
|
81
|
-
bothRoutes('a bare string with a descending key', 'created_at desc', 'created_at desc');
|
|
82
|
-
bothRoutes('a bare string already in `-field` shorthand', '-created_at', '-created_at');
|
|
83
|
-
bothRoutes('a bare multi-key string', 'status asc,-created_at', 'status asc,-created_at');
|
|
84
|
-
|
|
85
|
-
bothRoutes('a SortNode[]', [{ field: 'name', order: 'asc' }], 'name');
|
|
86
|
-
bothRoutes(
|
|
87
|
-
'a SortNode[] with a descending key',
|
|
88
|
-
[{ field: 'status', order: 'asc' }, { field: 'created_at', order: 'desc' }],
|
|
89
|
-
'status,-created_at',
|
|
90
|
-
);
|
|
91
|
-
bothRoutes('a string[]', ['name', '-age'], 'name,-age');
|
|
92
|
-
bothRoutes('a Record<field, direction>', { name: 'asc', age: 'desc' }, 'name,-age');
|
|
93
|
-
|
|
94
|
-
bothRoutes('no sort at all', undefined, undefined);
|
|
95
|
-
bothRoutes('an empty array', [], undefined);
|
|
96
|
-
bothRoutes('an empty object', {}, undefined);
|
|
97
|
-
bothRoutes('a whitespace-only string', ' ', undefined);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe('serializeOrderBy', () => {
|
|
101
|
-
it('never enumerates a string as an object — the defect this replaced', () => {
|
|
102
|
-
// `Object.entries('name asc')` → [['0','n'],['1','a'],…]. The old fold
|
|
103
|
-
// reached that branch for every string input.
|
|
104
|
-
expect(serializeOrderBy('name asc')).toBe('name asc');
|
|
105
|
-
expect(serializeOrderBy('name asc')).not.toMatch(/^0,1,2/);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('reads `order` case-insensitively', () => {
|
|
109
|
-
expect(serializeOrderBy([{ field: 'name', order: 'DESC' as any }])).toBe('-name');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('defaults a missing direction to ascending', () => {
|
|
113
|
-
expect(serializeOrderBy([{ field: 'name' }])).toBe('name');
|
|
114
|
-
});
|
|
115
|
-
});
|
package/src/queryDataset.test.ts
DELETED
|
@@ -1,543 +0,0 @@
|
|
|
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, beforeEach, vi } from 'vitest';
|
|
10
|
-
import type {
|
|
11
|
-
AnalyticsQuery as SpecAnalyticsQuery,
|
|
12
|
-
AnalyticsResult as SpecAnalyticsResult,
|
|
13
|
-
DatasetCompareTo as SpecDatasetCompareTo,
|
|
14
|
-
DatasetSelection as SpecDatasetSelection,
|
|
15
|
-
} from '@objectstack/spec/contracts';
|
|
16
|
-
import type { PercentScale as SpecPercentScale } from '@objectstack/spec/data';
|
|
17
|
-
// The shared drill-filter builder + its range type (objectui#3813): the date
|
|
18
|
-
// sidecar's only consumer, imported rather than reproduced so the assertions
|
|
19
|
-
// below are about the SAME code the dashboard widget and report renderer run.
|
|
20
|
-
import { buildDatasetDrillFilter, type DatasetDrillRange } from '@object-ui/core';
|
|
21
|
-
import {
|
|
22
|
-
ObjectStackAdapter,
|
|
23
|
-
clearSharedDiscoveryCache,
|
|
24
|
-
isAnalyticsNotInstalledError,
|
|
25
|
-
} from './index';
|
|
26
|
-
|
|
27
|
-
/** A fetch mock that answers discovery + records the dataset-query POST. */
|
|
28
|
-
function makeFetch(datasetResponse: { ok: boolean; status?: number; body: unknown }) {
|
|
29
|
-
const calls: Array<{ url: string; init?: any }> = [];
|
|
30
|
-
const fetchImpl = vi.fn(async (url: any, init?: any) => {
|
|
31
|
-
const u = String(url);
|
|
32
|
-
calls.push({ url: u, init });
|
|
33
|
-
if (u.includes('/api/v1/discovery')) {
|
|
34
|
-
return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) } as any;
|
|
35
|
-
}
|
|
36
|
-
if (u.includes('/api/v1/analytics/dataset/query')) {
|
|
37
|
-
return {
|
|
38
|
-
ok: datasetResponse.ok,
|
|
39
|
-
status: datasetResponse.status ?? (datasetResponse.ok ? 200 : 400),
|
|
40
|
-
statusText: datasetResponse.ok ? 'OK' : 'Bad Request',
|
|
41
|
-
json: async () => datasetResponse.body,
|
|
42
|
-
} as any;
|
|
43
|
-
}
|
|
44
|
-
return { ok: true, status: 200, statusText: 'OK', json: async () => ({}) } as any;
|
|
45
|
-
});
|
|
46
|
-
return { fetchImpl, calls };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const inlineDataset = {
|
|
50
|
-
name: 'sales', label: 'Sales', object: 'opportunity', include: ['account'],
|
|
51
|
-
dimensions: [{ name: 'region', field: 'account.region', type: 'string' }],
|
|
52
|
-
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
|
|
53
|
-
};
|
|
54
|
-
const selection = { dimensions: ['region'], measures: ['revenue'] };
|
|
55
|
-
|
|
56
|
-
describe('ObjectStackAdapter.queryDataset', () => {
|
|
57
|
-
beforeEach(() => clearSharedDiscoveryCache());
|
|
58
|
-
|
|
59
|
-
it('POSTs the inline dataset + selection and returns rows/fields', async () => {
|
|
60
|
-
const { fetchImpl, calls } = makeFetch({
|
|
61
|
-
ok: true,
|
|
62
|
-
body: { rows: [{ region: 'NA', revenue: 100 }], fields: [{ name: 'revenue', type: 'number' }] },
|
|
63
|
-
});
|
|
64
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', token: 'tok_123', autoReconnect: false, fetch: fetchImpl as any });
|
|
65
|
-
|
|
66
|
-
const result = await adapter.queryDataset(inlineDataset as any, selection);
|
|
67
|
-
|
|
68
|
-
expect(result.rows).toEqual([{ region: 'NA', revenue: 100 }]);
|
|
69
|
-
expect(result.fields).toEqual([{ name: 'revenue', type: 'number' }]);
|
|
70
|
-
|
|
71
|
-
const post = calls.find((c) => c.url.includes('/analytics/dataset/query'))!;
|
|
72
|
-
expect(post.url).toBe('http://localhost:3000/api/v1/analytics/dataset/query');
|
|
73
|
-
expect(post.init.method).toBe('POST');
|
|
74
|
-
expect(post.init.headers.Authorization).toBe('Bearer tok_123');
|
|
75
|
-
expect(JSON.parse(post.init.body)).toEqual({ dataset: inlineDataset, selection });
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('sends datasetName when given a string', async () => {
|
|
79
|
-
const { fetchImpl, calls } = makeFetch({ ok: true, body: { rows: [], fields: [] } });
|
|
80
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
81
|
-
await adapter.queryDataset('sales', selection);
|
|
82
|
-
const post = calls.find((c) => c.url.includes('/analytics/dataset/query'))!;
|
|
83
|
-
expect(JSON.parse(post.init.body)).toEqual({ datasetName: 'sales', selection });
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('unwraps a { success, data } envelope', async () => {
|
|
87
|
-
const { fetchImpl } = makeFetch({ ok: true, body: { success: true, data: { rows: [{ x: 1 }], fields: [] } } });
|
|
88
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
89
|
-
const result = await adapter.queryDataset(inlineDataset as any, selection);
|
|
90
|
-
expect(result.rows).toEqual([{ x: 1 }]);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('throws (no silent fallback) with the server message on a 4xx', async () => {
|
|
94
|
-
const { fetchImpl } = makeFetch({ ok: false, status: 400, body: { code: 'DATASET_INVALID', message: 'relationship not declared' } });
|
|
95
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
96
|
-
await expect(adapter.queryDataset(inlineDataset as any, selection)).rejects.toThrow(/relationship not declared/);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// framework#3891 retired the degraded in-kernel analytics fallback, so a
|
|
100
|
-
// deployment without @objectstack/service-analytics answers 501 (REST route
|
|
101
|
-
// present, no service) or 404 (framework#4019 stopped mounting the routes).
|
|
102
|
-
// Neither is an authoring mistake — the caller gets a typed, readable error
|
|
103
|
-
// instead of `Dataset query failed: 501 Not Implemented — …`.
|
|
104
|
-
describe('analytics capability absent', () => {
|
|
105
|
-
it('maps 501 NOT_IMPLEMENTED to a readable ANALYTICS_NOT_INSTALLED error', async () => {
|
|
106
|
-
const { fetchImpl } = makeFetch({
|
|
107
|
-
ok: false,
|
|
108
|
-
status: 501,
|
|
109
|
-
body: {
|
|
110
|
-
code: 'NOT_IMPLEMENTED',
|
|
111
|
-
message: 'Analytics dataset query is not available on this deployment (no analytics service with queryDataset).',
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
115
|
-
|
|
116
|
-
const err = await adapter.queryDataset(inlineDataset as any, selection).catch((e) => e);
|
|
117
|
-
|
|
118
|
-
expect(isAnalyticsNotInstalledError(err)).toBe(true);
|
|
119
|
-
expect(err.code).toBe('ANALYTICS_NOT_INSTALLED');
|
|
120
|
-
expect(err.message).toContain('Analytics capability is not installed');
|
|
121
|
-
expect(err.message).toContain('@objectstack/service-analytics');
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('maps a 404 (routes not mounted) the same way', async () => {
|
|
125
|
-
const { fetchImpl } = makeFetch({ ok: false, status: 404, body: { error: 'Not found' } });
|
|
126
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
127
|
-
|
|
128
|
-
const err = await adapter.queryDataset(inlineDataset as any, selection).catch((e) => e);
|
|
129
|
-
|
|
130
|
-
expect(isAnalyticsNotInstalledError(err)).toBe(true);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('leaves a real compile error alone (it is NOT a missing capability)', async () => {
|
|
134
|
-
const { fetchImpl } = makeFetch({ ok: false, status: 400, body: { message: 'relationship not declared in include' } });
|
|
135
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
136
|
-
|
|
137
|
-
const err = await adapter.queryDataset(inlineDataset as any, selection).catch((e) => e);
|
|
138
|
-
|
|
139
|
-
expect(isAnalyticsNotInstalledError(err)).toBe(false);
|
|
140
|
-
expect(String(err.message)).toContain('relationship not declared');
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
/* -------------------------------------------------------------------------- */
|
|
146
|
-
/* objectui#3613 — the `selection` parameter IS the spec type, not a copy. */
|
|
147
|
-
/* */
|
|
148
|
-
/* Compile-time pins. A violation is a `tsc --noEmit` error, not a runtime */
|
|
149
|
-
/* failure: this package's tsconfig.json includes its whole `src/**` (tests */
|
|
150
|
-
/* included), so these are checked by `pnpm --filter @object-ui/data-objectstack*/
|
|
151
|
-
/* type-check` with no separate tsconfig.typetests.json — the same property */
|
|
152
|
-
/* spec-symbol-batch6.test.ts documents and relies on. */
|
|
153
|
-
/* -------------------------------------------------------------------------- */
|
|
154
|
-
|
|
155
|
-
type Assert<T extends true> = T;
|
|
156
|
-
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
157
|
-
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
158
|
-
? true
|
|
159
|
-
: false;
|
|
160
|
-
type HasKey<T, K extends PropertyKey> = K extends keyof T ? true : false;
|
|
161
|
-
|
|
162
|
-
/** The declared type of `queryDataset`'s second parameter, read off the method. */
|
|
163
|
-
type SelectionParam = Parameters<ObjectStackAdapter['queryDataset']>[1];
|
|
164
|
-
|
|
165
|
-
describe('queryDataset(selection) is @objectstack/spec DatasetSelection itself', () => {
|
|
166
|
-
it('is pinned at compile time', () => {
|
|
167
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecDatasetSelection>, false>>;
|
|
168
|
-
|
|
169
|
-
// THE pin. `Equal` rather than a two-way `extends` on purpose: a
|
|
170
|
-
// restatement that merely *overlaps* the contract satisfies mutual
|
|
171
|
-
// assignability in the drifted direction too (a required `dimension` is
|
|
172
|
-
// still assignable TO an optional one). Structural identity is the only
|
|
173
|
-
// assertion a hand copy cannot pass while drifting.
|
|
174
|
-
type _IsTheSpecType = Assert<Equal<SelectionParam, SpecDatasetSelection>>;
|
|
175
|
-
|
|
176
|
-
expect(true).toBe(true);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// The three drifts the deleted copy had accumulated, named individually so a
|
|
180
|
-
// future reader learns what a restatement costs — not just that one existed.
|
|
181
|
-
it('pins compareTo as the spec DatasetCompareTo, with dimension OPTIONAL', () => {
|
|
182
|
-
type CompareTo = NonNullable<SelectionParam['compareTo']>;
|
|
183
|
-
type _IsSpecCompareTo = Assert<Equal<CompareTo, SpecDatasetCompareTo>>;
|
|
184
|
-
|
|
185
|
-
// objectstack#5011 made `dimension` optional BECAUSE the executor resolves
|
|
186
|
-
// it (exactly one dated time dimension → that one; zero or several → a loud
|
|
187
|
-
// error listing the candidates). The copy declared it required, so the
|
|
188
|
-
// compiler demanded from every typed caller precisely the consumer-side
|
|
189
|
-
// dimension guess that change forbids — turning a loud executor error into
|
|
190
|
-
// a silently wrong comparison window.
|
|
191
|
-
type _DimensionOptional = Assert<Equal<CompareTo['dimension'], string | undefined>>;
|
|
192
|
-
type _DimensionWasNotRequired = Assert<Equal<Equal<CompareTo['dimension'], string>, false>>;
|
|
193
|
-
// …and the shape with no `dimension` at all is a legal authoring surface.
|
|
194
|
-
type _KindOnlyIsValid = Assert<
|
|
195
|
-
{ kind: 'previousPeriod' } extends CompareTo ? true : false
|
|
196
|
-
>;
|
|
197
|
-
|
|
198
|
-
expect(true).toBe(true);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('pins timeDimensions at the spec shape, not `unknown[]`', () => {
|
|
202
|
-
type _IsSpecTimeDimensions = Assert<
|
|
203
|
-
Equal<SelectionParam['timeDimensions'], SpecAnalyticsQuery['timeDimensions']>
|
|
204
|
-
>;
|
|
205
|
-
type _NotUnknownArray = Assert<
|
|
206
|
-
Equal<Equal<SelectionParam['timeDimensions'], unknown[] | undefined>, false>
|
|
207
|
-
>;
|
|
208
|
-
// The entry shape the executor's compareTo resolution reads (`dateRange`
|
|
209
|
-
// present ⇒ shiftable) — invisible through `unknown[]`.
|
|
210
|
-
type Entry = NonNullable<SelectionParam['timeDimensions']>[number];
|
|
211
|
-
type _EntryHasDimension = Assert<Equal<Entry['dimension'], string>>;
|
|
212
|
-
type _EntryHasDateRange = Assert<HasKey<Entry, 'dateRange'>>;
|
|
213
|
-
|
|
214
|
-
expect(true).toBe(true);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it('pins runtimeFilter as a FilterCondition and carries dateGranularity', () => {
|
|
218
|
-
// The copy widened `runtimeFilter` to `Record<string, unknown>`, which
|
|
219
|
-
// erases the `$and`/`$or`/`$not` vocabulary the server parses.
|
|
220
|
-
type _RuntimeFilterNarrowed = Assert<
|
|
221
|
-
Equal<Equal<SelectionParam['runtimeFilter'], Record<string, unknown> | undefined>, false>
|
|
222
|
-
>;
|
|
223
|
-
type _RuntimeFilterKnowsLogicalOps = Assert<
|
|
224
|
-
HasKey<NonNullable<SelectionParam['runtimeFilter']>, '$and'>
|
|
225
|
-
>;
|
|
226
|
-
|
|
227
|
-
// `dateGranularity` (framework#3588) is the drift nobody had noticed: the
|
|
228
|
-
// copy never grew the key, so a typed caller could not bucket a trend by
|
|
229
|
-
// month at all. A restatement does not only drift on the fields it has — it
|
|
230
|
-
// also stops at whatever the contract looked like the day it was written.
|
|
231
|
-
type _HasDateGranularity = Assert<HasKey<SelectionParam, 'dateGranularity'>>;
|
|
232
|
-
type _DateGranularityEnum = Assert<
|
|
233
|
-
Equal<
|
|
234
|
-
SelectionParam['dateGranularity'],
|
|
235
|
-
'day' | 'week' | 'month' | 'quarter' | 'year' | undefined
|
|
236
|
-
>
|
|
237
|
-
>;
|
|
238
|
-
|
|
239
|
-
expect(true).toBe(true);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
// The runtime half of the same fact: the adapter must forward `compareTo`
|
|
243
|
-
// VERBATIM. If it ever "helpfully" filled a `dimension` in to satisfy its own
|
|
244
|
-
// old declaration, the executor would stop resolving and every caller's
|
|
245
|
-
// comparison window would depend on the adapter's guess.
|
|
246
|
-
it('forwards a dimension-less compareTo to the server untouched', async () => {
|
|
247
|
-
const { fetchImpl, calls } = makeFetch({ ok: true, body: { rows: [], fields: [] } });
|
|
248
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
249
|
-
|
|
250
|
-
await adapter.queryDataset('sales', {
|
|
251
|
-
dimensions: ['region'],
|
|
252
|
-
measures: ['revenue'],
|
|
253
|
-
timeDimensions: [{ dimension: 'closedAt', granularity: 'month', dateRange: ['2026-01-01', '2026-03-31'] }],
|
|
254
|
-
compareTo: { kind: 'previousPeriod' },
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
const post = calls.find((c) => c.url.includes('/analytics/dataset/query'))!;
|
|
258
|
-
const sent = JSON.parse(post.init.body);
|
|
259
|
-
expect(sent.selection.compareTo).toEqual({ kind: 'previousPeriod' });
|
|
260
|
-
expect(sent.selection.compareTo).not.toHaveProperty('dimension');
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
/* -------------------------------------------------------------------------- */
|
|
265
|
-
/* objectui#3752 — the RESULT's `fields` element is the spec type, not a copy. */
|
|
266
|
-
/* */
|
|
267
|
-
/* Same drift family as #3613 above, on the other side of the call: the return */
|
|
268
|
-
/* type hand-listed five keys and had already stopped at the contract of the */
|
|
269
|
-
/* day it was written — no `percentScale`. Compile-time pins, checked by */
|
|
270
|
-
/* `pnpm --filter @object-ui/data-objectstack type-check` for the reason that */
|
|
271
|
-
/* section documents (this package's tsconfig.json compiles its own tests). */
|
|
272
|
-
/* -------------------------------------------------------------------------- */
|
|
273
|
-
|
|
274
|
-
/** What `queryDataset` RESOLVES to, read off the method. */
|
|
275
|
-
type DatasetEnvelope = Awaited<ReturnType<ObjectStackAdapter['queryDataset']>>;
|
|
276
|
-
/** …and the declared shape of one result column. */
|
|
277
|
-
type ResultField = DatasetEnvelope['fields'][number];
|
|
278
|
-
|
|
279
|
-
describe('queryDataset result fields ARE @objectstack/spec AnalyticsResult fields', () => {
|
|
280
|
-
it('is pinned at compile time', () => {
|
|
281
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecAnalyticsResult>, false>>;
|
|
282
|
-
|
|
283
|
-
// THE pin: structural identity with the spec's element, so the declaration
|
|
284
|
-
// cannot lag the contract again. `Equal` rather than `extends` for the
|
|
285
|
-
// #3613 reason — a narrower copy stays assignable while drifting.
|
|
286
|
-
type _IsTheSpecField = Assert<Equal<ResultField, SpecAnalyticsResult['fields'][number]>>;
|
|
287
|
-
|
|
288
|
-
// The single key the deleted copy was missing, named so a reader learns
|
|
289
|
-
// WHICH omission cost something rather than merely that one existed.
|
|
290
|
-
type _HasPercentScale = Assert<HasKey<ResultField, 'percentScale'>>;
|
|
291
|
-
type _PercentScaleIsTheSpecUnion = Assert<
|
|
292
|
-
Equal<ResultField['percentScale'], SpecPercentScale | undefined>
|
|
293
|
-
>;
|
|
294
|
-
// …and not a widened `string`: 'fraction' | 'whole' is the whole point —
|
|
295
|
-
// a renderer branches on it instead of guessing from the value (#3136).
|
|
296
|
-
type _PercentScaleIsNotString = Assert<
|
|
297
|
-
Equal<Equal<ResultField['percentScale'], string | undefined>, false>
|
|
298
|
-
>;
|
|
299
|
-
|
|
300
|
-
// The exact shape this replaced, kept as a NEGATIVE pin: restoring the
|
|
301
|
-
// hand-written five keys turns this red, so the sabotage direction is
|
|
302
|
-
// recorded in the test rather than in a commit message.
|
|
303
|
-
type _NotTheFiveKeyCopy = Assert<
|
|
304
|
-
Equal<
|
|
305
|
-
Equal<ResultField, { name: string; type: string; label?: string; format?: string; currency?: string }>,
|
|
306
|
-
false
|
|
307
|
-
>
|
|
308
|
-
>;
|
|
309
|
-
|
|
310
|
-
expect(true).toBe(true);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
// Why only `fields` is spec-owned and the envelope is not (objectui#3752
|
|
314
|
-
// direction B). The REST route answers `AnalyticsResult` PLUS ADR-0021 D2
|
|
315
|
-
// drill sidecars, but this method rebuilds its own object from the payload
|
|
316
|
-
// and never copies `sql` — so declaring the envelope as `AnalyticsResult &
|
|
317
|
-
// {…}` would advertise a key the adapter structurally cannot return.
|
|
318
|
-
it('pins the envelope as NOT an AnalyticsResult (no `sql`)', () => {
|
|
319
|
-
type _NoSql = Assert<Equal<HasKey<DatasetEnvelope, 'sql'>, false>>;
|
|
320
|
-
// The drill sidecars the spec type does not carry stay locally declared.
|
|
321
|
-
type _HasDrillObject = Assert<HasKey<DatasetEnvelope, 'object'>>;
|
|
322
|
-
type _HasDimensionFields = Assert<HasKey<DatasetEnvelope, 'dimensionFields'>>;
|
|
323
|
-
type _HasDrillRawRows = Assert<HasKey<DatasetEnvelope, 'drillRawRows'>>;
|
|
324
|
-
type _HasDrillRanges = Assert<HasKey<DatasetEnvelope, 'drillRanges'>>;
|
|
325
|
-
|
|
326
|
-
expect(true).toBe(true);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
// The runtime half: a column's `percentScale` must reach the caller verbatim.
|
|
330
|
-
// The adapter passes `fields` through untouched, so this guards against a
|
|
331
|
-
// future "normalisation" quietly dropping the key the declaration now shows.
|
|
332
|
-
it('returns a percentage column with its percentScale untouched', async () => {
|
|
333
|
-
const { fetchImpl } = makeFetch({
|
|
334
|
-
ok: true,
|
|
335
|
-
body: {
|
|
336
|
-
rows: [{ region: 'NA', winRate: 1 }],
|
|
337
|
-
fields: [
|
|
338
|
-
{ name: 'region', type: 'string', label: 'Region' },
|
|
339
|
-
{ name: 'winRate', type: 'number', label: 'Win rate', format: '0.0%', percentScale: 'fraction' },
|
|
340
|
-
],
|
|
341
|
-
},
|
|
342
|
-
});
|
|
343
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
344
|
-
|
|
345
|
-
const result = await adapter.queryDataset(inlineDataset as any, selection);
|
|
346
|
-
|
|
347
|
-
// Read through the DECLARED type, not `as any`: this line is the reason the
|
|
348
|
-
// issue was filed — before the fix it did not compile.
|
|
349
|
-
const winRate = result.fields.find((f) => f.name === 'winRate')!;
|
|
350
|
-
expect(winRate.percentScale).toBe('fraction');
|
|
351
|
-
const region = result.fields.find((f) => f.name === 'region')!;
|
|
352
|
-
expect(region.percentScale).toBeUndefined();
|
|
353
|
-
});
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
/* -------------------------------------------------------------------------- */
|
|
357
|
-
/* objectui#3813 — the date-range drill sidecar reaches the caller. */
|
|
358
|
-
/* */
|
|
359
|
-
/* Third instance of the same family as #3613/#3752, this time on the RUNTIME */
|
|
360
|
-
/* face: `queryDataset` rebuilds its result by hand-picking keys off the */
|
|
361
|
-
/* payload, and `drillRanges` was never in the list — so the server's #1752 */
|
|
362
|
-
/* date-bucket drill scope was silently dropped by the only real adapter, and */
|
|
363
|
-
/* a date-only dashboard/report lost its entire drill entry point (`canDrill` */
|
|
364
|
-
/* can only be true via these ranges when no equality drill dim exists). */
|
|
365
|
-
/* */
|
|
366
|
-
/* The envelopes below are the SHAPES THE SERVER ACTUALLY SENDS, not idealised */
|
|
367
|
-
/* ones — that distinction is why the existing consumer tests could not see the */
|
|
368
|
-
/* bug (they mock a data source and feed `drillRanges` in directly): */
|
|
369
|
-
/* - `POST /analytics/dataset/query` answers `res.json(result)` — a BARE */
|
|
370
|
-
/* result, no `{ success, data }` wrapper (objectstack `rest-server.ts`); */
|
|
371
|
-
/* - it carries `sql`, which this adapter deliberately does not return; */
|
|
372
|
-
/* - for a DATE-ONLY grouping the server sets `object` + `drillRanges` and */
|
|
373
|
-
/* NOTHING else drill-related: `service-analytics` excludes date dims from */
|
|
374
|
-
/* `drillDims`, so `dimensionFields` / `drillRawRows` are absent entirely. */
|
|
375
|
-
/* -------------------------------------------------------------------------- */
|
|
376
|
-
|
|
377
|
-
/** Sales bucketed by month only — no non-date dimension, so no equality drill. */
|
|
378
|
-
const monthlyDataset = {
|
|
379
|
-
name: 'sales_by_month', label: 'Sales by month', object: 'opportunity',
|
|
380
|
-
dimensions: [{ name: 'closed_month', field: 'close_date', type: 'date', dateGranularity: 'month' }],
|
|
381
|
-
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
|
|
382
|
-
};
|
|
383
|
-
const monthlySelection = { dimensions: ['closed_month'], measures: ['revenue'] };
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* `DatasetWidget`'s drill predicate, reproduced from the widget rather than
|
|
387
|
-
* restated: `drillDims` is `dimensions.filter((d) => d in dimensionFields)` and
|
|
388
|
-
* `canDrill = !!drillObject && (drillDims.length > 0 || !!drillRanges?.length)`
|
|
389
|
-
* (`packages/plugin-dashboard/src/DatasetWidget.tsx:590-593`; the report
|
|
390
|
-
* renderer's per-row `hasRange` gate at `DatasetReportRenderer.tsx:431` and its
|
|
391
|
-
* pivot twin at `:855` are the same fact per row). Fed straight from the
|
|
392
|
-
* adapter's return so the assertion is about what the ADAPTER produced.
|
|
393
|
-
*/
|
|
394
|
-
function consumerDrillView(
|
|
395
|
-
result: Awaited<ReturnType<ObjectStackAdapter['queryDataset']>>,
|
|
396
|
-
dimensions: string[],
|
|
397
|
-
) {
|
|
398
|
-
const drillDims = result.dimensionFields ? dimensions.filter((d) => d in result.dimensionFields!) : [];
|
|
399
|
-
return {
|
|
400
|
-
drillDims,
|
|
401
|
-
canDrill: !!result.object && (drillDims.length > 0 || !!result.drillRanges?.length),
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
describe('queryDataset passes the server drillRanges sidecar through (#1752)', () => {
|
|
406
|
-
beforeEach(() => clearSharedDiscoveryCache());
|
|
407
|
-
|
|
408
|
-
it('keeps drillRanges verbatim for a DATE-ONLY grouping, so canDrill is true', async () => {
|
|
409
|
-
const { fetchImpl } = makeFetch({
|
|
410
|
-
ok: true,
|
|
411
|
-
// The real envelope: bare (no success/data wrapper), with `sql`, with
|
|
412
|
-
// `object` re-set by the range block, and WITHOUT dimensionFields /
|
|
413
|
-
// drillRawRows — the server emits none of those for a date-only grouping.
|
|
414
|
-
body: {
|
|
415
|
-
rows: [
|
|
416
|
-
{ closed_month: '2026-05', revenue: 100 },
|
|
417
|
-
{ closed_month: '2026-06', revenue: 250 },
|
|
418
|
-
],
|
|
419
|
-
fields: [
|
|
420
|
-
{ name: 'closed_month', type: 'date', label: 'Closed month' },
|
|
421
|
-
{ name: 'revenue', type: 'number', label: 'Revenue', format: '$0,0' },
|
|
422
|
-
],
|
|
423
|
-
sql: 'SELECT date_trunc(...) AS closed_month, SUM(amount) AS revenue FROM opportunity GROUP BY 1',
|
|
424
|
-
object: 'opportunity',
|
|
425
|
-
drillRanges: [
|
|
426
|
-
{ closed_month: { field: 'close_date', gte: '2026-05-01', lt: '2026-06-01' } },
|
|
427
|
-
{ closed_month: { field: 'close_date', gte: '2026-06-01', lt: '2026-07-01' } },
|
|
428
|
-
],
|
|
429
|
-
},
|
|
430
|
-
});
|
|
431
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
432
|
-
|
|
433
|
-
const result = await adapter.queryDataset(monthlyDataset as any, monthlySelection);
|
|
434
|
-
|
|
435
|
-
// (1) The key arrives at the caller, untouched and row-aligned. Read through
|
|
436
|
-
// the DECLARED type, not `as any` — before the fix this line did not compile.
|
|
437
|
-
expect(result.drillRanges).toEqual([
|
|
438
|
-
{ closed_month: { field: 'close_date', gte: '2026-05-01', lt: '2026-06-01' } },
|
|
439
|
-
{ closed_month: { field: 'close_date', gte: '2026-06-01', lt: '2026-07-01' } },
|
|
440
|
-
]);
|
|
441
|
-
expect(result.drillRanges).toHaveLength(result.rows.length);
|
|
442
|
-
|
|
443
|
-
// (2) The mock really is the server's date-only shape, not a convenient one:
|
|
444
|
-
// if these ever become defined, the fixture stopped exercising the case
|
|
445
|
-
// where `drillRanges` is the ONLY thing that can make a widget drillable.
|
|
446
|
-
expect(result.dimensionFields).toBeUndefined();
|
|
447
|
-
expect(result.drillRawRows).toBeUndefined();
|
|
448
|
-
|
|
449
|
-
// (3) The issue's acceptance anchor, evaluated with the consumers' own
|
|
450
|
-
// predicate: a date-only chart is drillable again.
|
|
451
|
-
const { drillDims, canDrill } = consumerDrillView(result, monthlySelection.dimensions);
|
|
452
|
-
expect(drillDims).toEqual([]);
|
|
453
|
-
expect(canDrill).toBe(true);
|
|
454
|
-
|
|
455
|
-
// (4) …and the drilled filter really scopes to the CLICKED bucket, built by
|
|
456
|
-
// the shared `buildDatasetDrillFilter` the widget and the report both call.
|
|
457
|
-
expect(buildDatasetDrillFilter(result.drillRawRows?.[1], drillDims, result.dimensionFields ?? {}, undefined, result.drillRanges?.[1]))
|
|
458
|
-
.toEqual({ close_date: { $gte: '2026-06-01', $lt: '2026-07-01' } });
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
it('keeps BOTH sidecars for a mixed date + non-date grouping (no superset drill)', async () => {
|
|
462
|
-
const { fetchImpl } = makeFetch({
|
|
463
|
-
ok: true,
|
|
464
|
-
body: {
|
|
465
|
-
rows: [{ region: 'North America', closed_month: '2026-06', revenue: 250 }],
|
|
466
|
-
fields: [
|
|
467
|
-
{ name: 'region', type: 'string', label: 'Region' },
|
|
468
|
-
{ name: 'closed_month', type: 'date', label: 'Closed month' },
|
|
469
|
-
{ name: 'revenue', type: 'number', label: 'Revenue' },
|
|
470
|
-
],
|
|
471
|
-
object: 'opportunity',
|
|
472
|
-
// Equality sidecars cover the non-date dim only (and carry the RAW value
|
|
473
|
-
// `NA`, not the row's resolved label "North America"); the date dim gets
|
|
474
|
-
// a range instead.
|
|
475
|
-
dimensionFields: { region: 'account.region' },
|
|
476
|
-
drillRawRows: [{ region: 'NA' }],
|
|
477
|
-
drillRanges: [{ closed_month: { field: 'close_date', gte: '2026-06-01', lt: '2026-07-01' } }],
|
|
478
|
-
},
|
|
479
|
-
});
|
|
480
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
481
|
-
|
|
482
|
-
const result = await adapter.queryDataset(monthlyDataset as any, { dimensions: ['region', 'closed_month'], measures: ['revenue'] });
|
|
483
|
-
|
|
484
|
-
const { drillDims, canDrill } = consumerDrillView(result, ['region', 'closed_month']);
|
|
485
|
-
expect(canDrill).toBe(true);
|
|
486
|
-
expect(drillDims).toEqual(['region']);
|
|
487
|
-
|
|
488
|
-
// Both halves in one filter. Without the range the drill was a SUPERSET —
|
|
489
|
-
// clicking June's bar opened every month for that region.
|
|
490
|
-
expect(buildDatasetDrillFilter(result.drillRawRows?.[0], drillDims, result.dimensionFields ?? {}, { stage: 'won' }, result.drillRanges?.[0]))
|
|
491
|
-
.toEqual({
|
|
492
|
-
stage: 'won',
|
|
493
|
-
'account.region': 'NA',
|
|
494
|
-
close_date: { $gte: '2026-06-01', $lt: '2026-07-01' },
|
|
495
|
-
});
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
it('leaves drillRanges undefined when the server sends none (non-date grouping)', async () => {
|
|
499
|
-
const { fetchImpl } = makeFetch({
|
|
500
|
-
ok: true,
|
|
501
|
-
body: {
|
|
502
|
-
rows: [{ region: 'NA', revenue: 100 }],
|
|
503
|
-
fields: [{ name: 'revenue', type: 'number' }],
|
|
504
|
-
object: 'opportunity',
|
|
505
|
-
dimensionFields: { region: 'account.region' },
|
|
506
|
-
drillRawRows: [{ region: 'NA' }],
|
|
507
|
-
},
|
|
508
|
-
});
|
|
509
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
510
|
-
|
|
511
|
-
const result = await adapter.queryDataset(inlineDataset as any, selection);
|
|
512
|
-
|
|
513
|
-
// Absent stays absent — the passthrough must not invent an empty array,
|
|
514
|
-
// which would flip `canDrill`'s `!!drillRanges?.length` reasoning nowhere
|
|
515
|
-
// but would make the two sidecars' index alignment meaningless.
|
|
516
|
-
expect(result.drillRanges).toBeUndefined();
|
|
517
|
-
expect(consumerDrillView(result, ['region']).canDrill).toBe(true);
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
it('is pinned at compile time as the SHARED DatasetDrillRange, not a restatement', () => {
|
|
521
|
-
type DrillRanges = NonNullable<DatasetEnvelope['drillRanges']>;
|
|
522
|
-
type RangeEntry = DrillRanges[number][string];
|
|
523
|
-
|
|
524
|
-
// THE pin. `@object-ui/core`'s `DatasetDrillRange` is the single in-repo
|
|
525
|
-
// declaration of this shape — it is what `buildDatasetDrillFilter` accepts
|
|
526
|
-
// and what `DatasetWidget` / `DatasetReportRenderer` type their state with.
|
|
527
|
-
// Nothing in `@objectstack/spec` owns it yet (the producer's own
|
|
528
|
-
// `AnalyticsResultWithDrill` is local to `service-analytics`), so identity
|
|
529
|
-
// with the shared interface is the strongest available contract pin: a local
|
|
530
|
-
// `{ field: string; gte: string; lt: string }` copy — the shape a reader is
|
|
531
|
-
// most tempted to write here — fails this line, per #3613's lesson that a
|
|
532
|
-
// restatement stays assignable while it drifts.
|
|
533
|
-
type _IsTheSharedType = Assert<Equal<RangeEntry, DatasetDrillRange>>;
|
|
534
|
-
type _SharedTypeNotAny = Assert<Equal<IsAny<DatasetDrillRange>, false>>;
|
|
535
|
-
|
|
536
|
-
// Row-aligned array of dimension NAME → range, mirroring `drillRawRows`.
|
|
537
|
-
type _IsRowAlignedArray = Assert<Equal<DrillRanges, Array<Record<string, DatasetDrillRange>>>>;
|
|
538
|
-
// Optional, because the server omits it whenever no date dim was bucketed.
|
|
539
|
-
type _IsOptional = Assert<Equal<DatasetEnvelope['drillRanges'], DrillRanges | undefined>>;
|
|
540
|
-
|
|
541
|
-
expect(true).toBe(true);
|
|
542
|
-
});
|
|
543
|
-
});
|