Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/dataset-selection-spec-type-3613.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@object-ui/data-objectstack': patch
---

data-objectstack: type `queryDataset(selection)` as the spec's `DatasetSelection` instead of a hand-written copy

The adapter restated the selection contract inline, field by field, and the copy
had drifted three ways from the pinned `@objectstack/spec@17.0.0-rc.5`:

- **`compareTo.dimension` was required.** It has been optional since
objectstack#5011, *because the executor resolves it*: exactly one time
dimension carrying a `dateRange` is the one shifted, and zero or several
raises a loud error naming the candidates. Requiring it made the compiler
demand from every typed caller precisely the consumer-side dimension guess
that change forbids — trading a loud executor error for a silently wrong
comparison window. No runtime path hit this yet (the dashboard's
`DatasetWidget` passes `selection` as `unknown`), but a declaration is a live
instruction to anyone calling this client from TypeScript.
- **`timeDimensions` was widened to `unknown[]`**, erasing the very entry shape
the executor's resolution reads (`{ dimension, granularity?, dateRange? }`),
and **`runtimeFilter` to `Record<string, unknown>`**, erasing the
`$and`/`$or`/`$not` vocabulary the server parses.
- **`dateGranularity` was missing entirely** — the copy had simply stopped at
whatever the contract looked like the day it was written, so a typed caller
could not bucket a trend by month at all.

The parameter is now the spec type by reference, so there is nothing left to
re-sync. The fix is the removal of the dialect rather than a correction to it:
restating a contract owned elsewhere creates a second de-facto dialect of it, and
drift is then only a matter of time (AGENTS.md #0/#0.1). `queryDataset.test.ts`
pins structural identity with `DatasetSelection` plus each of the three drifts
individually, checked by this package's `tsc --noEmit`; a runtime test pins that
a dimension-less `compareTo` reaches the server untouched, so the adapter can
never start guessing on the executor's behalf.

The response type is deliberately left alone — it is the REST envelope
(`object` / `dimensionFields` / `drillRawRows`), not a restatement of
`AnalyticsResult`.
29 changes: 13 additions & 16 deletions packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
import type { DatasetSelection } from '@objectstack/spec/contracts';
import type {
DataSource,
BatchTransactionOperation,
Expand Down Expand Up @@ -3190,25 +3191,21 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
* silently returning wrong numbers.
*
* @param dataset - An inline dataset definition (draft) OR a saved dataset name.
* @param selection - Dimension/measure names to project + runtime directives.
* @param selection - The spec's {@link DatasetSelection} — dimension/measure
* names to project plus runtime directives. This parameter IS the spec type
* by reference, never a local restatement of it (objectui#3613): a hand
* copy of a contract is a second dialect of it, and the copy this replaced
* had already drifted three ways from `@objectstack/spec` — it required
* `compareTo.dimension` (optional since objectstack#5011, and resolved by
* the EXECUTOR, so requiring it pushed callers into exactly the
* consumer-side dimension guess AGENTS.md #0.1 forbids), it widened
* `timeDimensions` to `unknown[]` and `runtimeFilter` to
* `Record<string, unknown>`, and it had never grown `dateGranularity` at
* all. Pinned in `queryDataset.test.ts`.
*/
async queryDataset(
dataset: Record<string, unknown> | string,
selection: {
dimensions?: string[];
measures: string[];
runtimeFilter?: Record<string, unknown>;
timeDimensions?: unknown[];
compareTo?: { kind: 'previousPeriod' | 'previousYear'; dimension: string };
order?: Record<string, 'asc' | 'desc'>;
limit?: number;
offset?: number;
timezone?: string;
/** Marginal-aggregate groupings (e.g. `[rows, [colDim], []]`) — server
* computes each subtotal with the measure's TRUE aggregate (never client
* re-derived). `[]` is the grand total. */
totals?: { groupings: string[][] };
},
selection: DatasetSelection,
): Promise<{
rows: Array<Record<string, unknown>>;
/** Column metadata: a display `label` (dimensions and measures), and a
Expand Down
124 changes: 124 additions & 0 deletions packages/data-objectstack/src/queryDataset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import type {
AnalyticsQuery as SpecAnalyticsQuery,
DatasetCompareTo as SpecDatasetCompareTo,
DatasetSelection as SpecDatasetSelection,
} from '@objectstack/spec/contracts';
import {
ObjectStackAdapter,
clearSharedDiscoveryCache,
Expand Down Expand Up @@ -130,3 +135,122 @@ describe('ObjectStackAdapter.queryDataset', () => {
});
});
});

/* -------------------------------------------------------------------------- */
/* objectui#3613 — the `selection` parameter IS the spec type, not a copy. */
/* */
/* Compile-time pins. A violation is a `tsc --noEmit` error, not a runtime */
/* failure: this package's tsconfig.json includes its whole `src/**` (tests */
/* included), so these are checked by `pnpm --filter @object-ui/data-objectstack*/
/* type-check` with no separate tsconfig.typetests.json — the same property */
/* spec-symbol-batch6.test.ts documents and relies on. */
/* -------------------------------------------------------------------------- */

type Assert<T extends true> = T;
type IsAny<T> = 0 extends 1 & T ? true : false;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
type HasKey<T, K extends PropertyKey> = K extends keyof T ? true : false;

/** The declared type of `queryDataset`'s second parameter, read off the method. */
type SelectionParam = Parameters<ObjectStackAdapter['queryDataset']>[1];

describe('queryDataset(selection) is @objectstack/spec DatasetSelection itself', () => {
it('is pinned at compile time', () => {
type _SpecNotAny = Assert<Equal<IsAny<SpecDatasetSelection>, false>>;

// THE pin. `Equal` rather than a two-way `extends` on purpose: a
// restatement that merely *overlaps* the contract satisfies mutual
// assignability in the drifted direction too (a required `dimension` is
// still assignable TO an optional one). Structural identity is the only
// assertion a hand copy cannot pass while drifting.
type _IsTheSpecType = Assert<Equal<SelectionParam, SpecDatasetSelection>>;

expect(true).toBe(true);
});

// The three drifts the deleted copy had accumulated, named individually so a
// future reader learns what a restatement costs — not just that one existed.
it('pins compareTo as the spec DatasetCompareTo, with dimension OPTIONAL', () => {
type CompareTo = NonNullable<SelectionParam['compareTo']>;
type _IsSpecCompareTo = Assert<Equal<CompareTo, SpecDatasetCompareTo>>;

// objectstack#5011 made `dimension` optional BECAUSE the executor resolves
// it (exactly one dated time dimension → that one; zero or several → a loud
// error listing the candidates). The copy declared it required, so the
// compiler demanded from every typed caller precisely the consumer-side
// dimension guess that change forbids — turning a loud executor error into
// a silently wrong comparison window.
type _DimensionOptional = Assert<Equal<CompareTo['dimension'], string | undefined>>;
type _DimensionWasNotRequired = Assert<Equal<Equal<CompareTo['dimension'], string>, false>>;
// …and the shape with no `dimension` at all is a legal authoring surface.
type _KindOnlyIsValid = Assert<
{ kind: 'previousPeriod' } extends CompareTo ? true : false
>;

expect(true).toBe(true);
});

it('pins timeDimensions at the spec shape, not `unknown[]`', () => {
type _IsSpecTimeDimensions = Assert<
Equal<SelectionParam['timeDimensions'], SpecAnalyticsQuery['timeDimensions']>
>;
type _NotUnknownArray = Assert<
Equal<Equal<SelectionParam['timeDimensions'], unknown[] | undefined>, false>
>;
// The entry shape the executor's compareTo resolution reads (`dateRange`
// present ⇒ shiftable) — invisible through `unknown[]`.
type Entry = NonNullable<SelectionParam['timeDimensions']>[number];
type _EntryHasDimension = Assert<Equal<Entry['dimension'], string>>;
type _EntryHasDateRange = Assert<HasKey<Entry, 'dateRange'>>;

expect(true).toBe(true);
});

it('pins runtimeFilter as a FilterCondition and carries dateGranularity', () => {
// The copy widened `runtimeFilter` to `Record<string, unknown>`, which
// erases the `$and`/`$or`/`$not` vocabulary the server parses.
type _RuntimeFilterNarrowed = Assert<
Equal<Equal<SelectionParam['runtimeFilter'], Record<string, unknown> | undefined>, false>
>;
type _RuntimeFilterKnowsLogicalOps = Assert<
HasKey<NonNullable<SelectionParam['runtimeFilter']>, '$and'>
>;

// `dateGranularity` (framework#3588) is the drift nobody had noticed: the
// copy never grew the key, so a typed caller could not bucket a trend by
// month at all. A restatement does not only drift on the fields it has — it
// also stops at whatever the contract looked like the day it was written.
type _HasDateGranularity = Assert<HasKey<SelectionParam, 'dateGranularity'>>;
type _DateGranularityEnum = Assert<
Equal<
SelectionParam['dateGranularity'],
'day' | 'week' | 'month' | 'quarter' | 'year' | undefined
>
>;

expect(true).toBe(true);
});

// The runtime half of the same fact: the adapter must forward `compareTo`
// VERBATIM. If it ever "helpfully" filled a `dimension` in to satisfy its own
// old declaration, the executor would stop resolving and every caller's
// comparison window would depend on the adapter's guess.
it('forwards a dimension-less compareTo to the server untouched', async () => {
const { fetchImpl, calls } = makeFetch({ ok: true, body: { rows: [], fields: [] } });
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });

await adapter.queryDataset('sales', {
dimensions: ['region'],
measures: ['revenue'],
timeDimensions: [{ dimension: 'closedAt', granularity: 'month', dateRange: ['2026-01-01', '2026-03-31'] }],
compareTo: { kind: 'previousPeriod' },
});

const post = calls.find((c) => c.url.includes('/analytics/dataset/query'))!;
const sent = JSON.parse(post.init.body);
expect(sent.selection.compareTo).toEqual({ kind: 'previousPeriod' });
expect(sent.selection.compareTo).not.toHaveProperty('dimension');
});
});
Loading