@object-ui/data-objectstack 17.5.0 → 17.6.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/dist/index.cjs +262 -357
  3. package/dist/index.d.cts +385 -448
  4. package/dist/index.d.ts +385 -448
  5. package/dist/index.js +258 -350
  6. package/package.json +5 -6
  7. package/dist/tsconfig.tsbuildinfo +0 -1
  8. package/src/aggregate-capability.test.ts +0 -177
  9. package/src/apiAccessDenied.test.ts +0 -233
  10. package/src/appAccessProbe.test.ts +0 -147
  11. package/src/bulkDelete.test.ts +0 -75
  12. package/src/bulkUpdate.test.ts +0 -93
  13. package/src/cache/MetadataCache.test.ts +0 -477
  14. package/src/cache/MetadataCache.ts +0 -289
  15. package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
  16. package/src/connection.test.ts +0 -188
  17. package/src/contracts.ts +0 -115
  18. package/src/deleteView.homes.test.ts +0 -389
  19. package/src/deleteViewContract.types.test.ts +0 -246
  20. package/src/errors.test.ts +0 -426
  21. package/src/errors.ts +0 -293
  22. package/src/exportDownload.test.ts +0 -141
  23. package/src/filter-entry-translation.test.ts +0 -360
  24. package/src/filter-operator-ast-parity.test.ts +0 -135
  25. package/src/getObjectSchema.test.ts +0 -145
  26. package/src/httpFailureLogging.test.ts +0 -109
  27. package/src/importJob.test.ts +0 -71
  28. package/src/index.ts +0 -4442
  29. package/src/integration.ts +0 -200
  30. package/src/listViewOverrides.test.ts +0 -241
  31. package/src/listViews.test.ts +0 -175
  32. package/src/metadata-client.get-envelope.test.ts +0 -171
  33. package/src/metadata-client.saveAdvisories.test.ts +0 -226
  34. package/src/metadata-client.test.ts +0 -303
  35. package/src/metadata-client.ts +0 -1046
  36. package/src/occ.test.ts +0 -101
  37. package/src/onMutation.test.ts +0 -589
  38. package/src/onSaveAdvisory.test.ts +0 -311
  39. package/src/onWriteWarning.test.ts +0 -273
  40. package/src/orderby-serialization.test.ts +0 -115
  41. package/src/queryDataset.test.ts +0 -543
  42. package/src/searchAll.test.ts +0 -120
  43. package/src/security.ts +0 -243
  44. package/src/spec-symbol-batch6.test.ts +0 -326
  45. package/src/studio.ts +0 -152
  46. package/src/updateView.draft.test.ts +0 -242
  47. package/src/userState.test.ts +0 -315
  48. package/src/userState.ts +0 -238
  49. package/src/v3-compat.test.ts +0 -229
  50. package/src/validation-error.test.ts +0 -94
  51. package/src/viewCacheInvalidation.pin.test.ts +0 -260
  52. 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
- });
@@ -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
- });