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
49 changes: 49 additions & 0 deletions .changeset/deleteview-contract-per-home-outcomes-4564.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
'@object-ui/types': minor
'@object-ui/data-objectstack': minor
---

The `DataSource` contract carries `deleteView`'s per-home outcomes (#4564)

#4479 / PR #4562 widened the ObjectStack adapter's `deleteView` to return
`DeleteViewResult { deleted, draft?, published? }`, so a caller could finally tell a
partial delete ("draft gone, published overlay left") from a complete one. The shared
interface did not follow: `DataSource.deleteView?` still declared the narrow
`Promise<{ deleted: boolean }>`.

Nothing failed to compile, and that is exactly what made the gap invisible — a wider
return is assignable to a narrower declaration, so the adapter satisfied the interface
while every consumer reaching it **through** `DataSource` was handed a type with the
per-home outcomes already discarded. The one real call site today (app-shell's
`ObjectView` delete handler) awaits the call and reads nothing off the receipt, so the
loss was latent rather than broken.

`DeleteViewResult` and `ViewHomeDeleteOutcome` now live in `@object-ui/types`, beside
the `DataSource` interface that returns them, and `deleteView?`'s declared return is
`Promise<DeleteViewResult>`. The direction was forced: the dependency runs
`@object-ui/data-objectstack` to `@object-ui/types` and never the other way, so the
shapes could not be imported downward — moving them was the alternative to re-declaring
a structural twin in `types`, which the one-resolver rule rejects because a copy is
mutually assignable with the original for exactly as long as it takes to drift.

`@object-ui/data-objectstack` re-exports both names unchanged, so every importer PR
#4562 left pointing at it keeps compiling — and now resolves to the same declaration the
shared contract speaks rather than a look-alike. A repo-wide census before the move
found zero importers of either name outside the declaring file itself, PR #4562's own
suite included, so the re-export is insurance rather than a load-bearing shim.

`deleteView` stays **optional** on the interface and keeps both parameters; the growth is
to the return type only, and `deleted` is untouched, so a consumer reading only `deleted`
needs no edit.

Grading, per this repository's version-alignment convention (the major tracks
`@objectstack`, never an API-break count):

- `@object-ui/types` — **minor**: entry-reachable growth. Two new exported interfaces
plus a widened method return on `DataSource`, all reachable from the package entry.
- `@object-ui/data-objectstack` — **minor**, measured rather than assumed. Its emitted
`dist/index.d.ts` is **not** byte-identical after the swap: the two `interface` blocks
leave the file and are replaced by a re-export from `@object-ui/types` (121.61 KB to
120.25 KB). Both names remain in the public export list, so no importer breaks, but the
declaration genuinely moved and the emitted types now depend on `@object-ui/types` for
it — that is a minor, not a patch.
246 changes: 246 additions & 0 deletions packages/data-objectstack/src/deleteViewContract.types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import { describe, it, expect, vi } from 'vitest';
import type { DataSource, DeleteViewResult, ViewHomeDeleteOutcome } from '@object-ui/types';
import {
ObjectStackAdapter,
type DeleteViewResult as AdapterDeleteViewResult,
type ViewHomeDeleteOutcome as AdapterViewHomeDeleteOutcome,
} from './index';

/**
* The shared `DataSource` contract carries `deleteView`'s per-home outcomes
* (#4564).
*
* #4479 / PR #4562 widened THIS adapter's `deleteView` to return
* `DeleteViewResult { deleted, draft?, published? }`, but the shared interface
* in `@object-ui/types` still declared the narrow `{ deleted: boolean }`. The
* adapter's wider return is assignable to the narrower declaration, so nothing
* failed to compile — which is exactly what made the gap invisible: a consumer
* reaching the adapter THROUGH `DataSource` was handed a type that had already
* thrown the per-home outcomes away. The one real call site today (app-shell's
* `ObjectView` delete handler) reads nothing off the receipt, so the loss was
* latent rather than broken.
*
* #4564's ruling moved the canonical shapes DOWN to `@object-ui/types` beside
* the interface (the dependency runs data-objectstack -> types, so the shapes
* could not be imported upward), widened `DataSource.deleteView?`'s declared
* return to `DeleteViewResult`, and left this package re-exporting both names
* so every existing importer keeps compiling.
*
* ## Where these pins get their colour
*
* Most of this file is COMPILE-time. That is not a weaker pin, it is the only
* pin that can observe this defect: the bug was never a wrong value at runtime,
* it was a declaration that could not describe the value. `vitest` transpiles
* with esbuild and erases types, so the runtime case below passed just as
* happily against the narrow declaration — its `receipt.draft` read was a
* `tsc` error and a green test at the same time. The colour therefore comes
* from `pnpm --filter @object-ui/data-objectstack type-check`, and this
* package's `tsconfig.json` includes its whole `src/**` (tests included), so
* these assertions are checked there and by CI's Type Check job with no
* separate `tsconfig.typetests.json` — the same property `queryDataset.test.ts`
* and `spec-symbol-batch6.test.ts` document and rely on.
*
* ## The discrimination control
*
* `NarrowLegacyResult` below is `deleteView`'s declared return BEFORE #4564,
* kept as a live control rather than described in prose. Every "the contract
* carries the per-home outcomes" assertion is paired with the control failing
* to carry them. Undo the move and the paired assertions go red together,
* which is what stops this file from degrading into a restatement of whatever
* the interface happens to say.
*/

const VIEW = 'account.my_pipeline';

/** Framework receipt: a draft row existed and was discarded. */
const DRAFT_DISCARDED = {
success: true,
reset: true,
seq: 41,
message: `Draft discarded — view/${VIEW}. [seq=41]`,
};

/** Framework receipt: no published row. A 200 carrying `reset:false`, not a 404. */
const NO_PUBLISHED = {
success: true,
reset: false,
message: `No view '${VIEW}' found — nothing to delete.`,
};

/**
* The smallest adapter that can answer both view-delete addresses.
*
* Deliberately NOT a second copy of `deleteView.homes.test.ts`'s harness: that
* file owns the behaviour (15 pins over both homes, the ordering, the failure
* paths) and stays untouched. This one exists only to drive one call through a
* `DataSource`-typed handle, so the per-home read below is served by the shared
* declaration and by nothing else.
*/
function makeAdapter(): ObjectStackAdapter {
const baseUrl = 'http://test.local';
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});

const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/meta/view/') && (init?.method ?? 'GET') === 'DELETE') {
return json(url.includes('state=draft') ? DRAFT_DISCARDED : NO_PUBLISHED);
}
return json({ success: true, data: { capabilities: {}, routes: {} } });
});

const adapter = new ObjectStackAdapter({ baseUrl, fetch: fetchImpl });
const poke = adapter as any;
poke.connected = true;
poke.connectionState = 'connected';
poke.metadataCache = {
get: async (_key: string, loader: () => Promise<any>) => loader(),
invalidate: () => undefined,
getCachedSync: () => undefined,
getStats: () => ({}),
};
return adapter;
}

/* -------------------------------------------------------------------------- */
/* Compile-time vocabulary — the repo's idiom (queryDataset.test.ts). */
/* -------------------------------------------------------------------------- */

type Assert<T extends true> = T;
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 shared contract's declared return, read off the interface itself. */
type ContractDeleteView = NonNullable<DataSource['deleteView']>;
type ContractReturn = Awaited<ReturnType<ContractDeleteView>>;

/**
* `DataSource.deleteView`'s declared return BEFORE #4564 — the control.
* Kept as a type, not a comment, so "the contract used to lose the per-home
* outcomes" is an assertion the compiler re-checks on every run.
*/
interface NarrowLegacyResult {
deleted: boolean;
}

describe('DataSource.deleteView declares the per-home outcomes (#4564)', () => {
it('the shared contract returns the canonical DeleteViewResult, not a restatement', () => {
// `Equal` rather than a two-way `extends`: a structurally overlapping
// re-declaration in `types` would satisfy mutual assignability while
// drifting, and a hand copy is precisely what the one-resolver rule and
// this card's ruling rejected in favour of moving the declaration.
type _IsTheCanonicalShape = Assert<Equal<ContractReturn, DeleteViewResult>>;

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

it('carries `draft` and `published`, where the pre-#4564 declaration carried neither', () => {
type _CarriesDraft = Assert<Equal<HasKey<ContractReturn, 'draft'>, true>>;
type _CarriesPublished = Assert<Equal<HasKey<ContractReturn, 'published'>, true>>;

// The control, and the whole reason this file is a pin rather than an echo:
// the shape the interface used to declare cannot answer either read. If the
// move is reverted, the two assertions above go red and these two stay
// green — the difference between them IS #4564's delta.
type _ControlHasNoDraft = Assert<Equal<HasKey<NarrowLegacyResult, 'draft'>, false>>;
type _ControlHasNoPublished = Assert<Equal<HasKey<NarrowLegacyResult, 'published'>, false>>;
type _ContractIsNotTheNarrowShape = Assert<
Equal<Equal<ContractReturn, NarrowLegacyResult>, false>
>;

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

it('types the per-home outcomes as ViewHomeDeleteOutcome, optional on both homes', () => {
type _DraftIsTheOutcome = Assert<Equal<ContractReturn['draft'], ViewHomeDeleteOutcome | undefined>>;
type _PublishedIsTheOutcome = Assert<
Equal<ContractReturn['published'], ViewHomeDeleteOutcome | undefined>
>;
// `deleted` is unchanged — the growth is additive, so a consumer reading
// only `deleted` is untouched by the widening.
type _DeletedUnchanged = Assert<Equal<ContractReturn['deleted'], boolean>>;

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

it('MUST NOT CHANGE: deleteView stays OPTIONAL, with the same two parameters', () => {
// The widening is to the RETURN only. An adapter that never implemented
// `deleteView` must stay a legal `DataSource`.
type _StillOptional = Assert<undefined extends DataSource['deleteView'] ? true : false>;
type _SameParameters = Assert<Equal<Parameters<ContractDeleteView>, [string, string]>>;

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

describe('the data-objectstack spellings survive the move (#4564)', () => {
it('re-exports both names as the SAME declaration, not a structural twin', () => {
// The re-export contract: every importer that PR #4562 left pointing at
// `@object-ui/data-objectstack` keeps compiling, and keeps getting the type
// the shared interface now speaks. `Equal` is the assertion that matters —
// two identical-looking declarations are mutually assignable, so only
// identity can tell a re-export from a copy that will drift.
type _ResultIsTheTypesSpelling = Assert<Equal<AdapterDeleteViewResult, DeleteViewResult>>;
type _OutcomeIsTheTypesSpelling = Assert<
Equal<AdapterViewHomeDeleteOutcome, ViewHomeDeleteOutcome>
>;

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

it('the adapter method still satisfies the widened contract', () => {
const adapter = makeAdapter();

// Explicit, because `class ObjectStackAdapter implements DataSource` would
// have gone on compiling with the NARROW declaration too — a wider return
// is assignable to a narrower one, which is how the gap survived #4562's
// review in the first place. This states the direction the card cares
// about instead of inheriting it from the class heritage clause.
const deleteView = adapter.deleteView.bind(adapter) satisfies ContractDeleteView;
expect(typeof deleteView).toBe('function');

type _AdapterAssignableToContract = Assert<
ObjectStackAdapter['deleteView'] extends ContractDeleteView ? true : false
>;
// ...and no longer merely assignable: the two returns are now one type, so
// the adapter cannot widen further without the contract following it.
type _ReturnsAreOneType = Assert<
Equal<Awaited<ReturnType<ObjectStackAdapter['deleteView']>>, ContractReturn>
>;

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

describe('a consumer holding only the shared DataSource (#4564)', () => {
it('reads the per-home outcomes the adapter actually returns', async () => {
const adapter = makeAdapter();

// The narrowing that makes this a #4564 pin rather than a duplicate of
// `deleteView.homes.test.ts`: the receipt below is typed by `DataSource`,
// not by `ObjectStackAdapter`. Before the move this assignment compiled
// just as well and threw the per-home outcomes away on the way through.
const ds: DataSource = adapter;

const receipt = await ds.deleteView!('account', VIEW);

// Draft-only view: the draft home held the row, the published one did not.
expect(receipt.deleted).toBe(true);
expect(receipt.draft?.removed).toBe(true);
expect(receipt.published?.removed).toBe(false);
expect(receipt.draft?.message).toContain('Draft discarded');
});
});
57 changes: 24 additions & 33 deletions packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import {
import type { AnalyticsResult, DatasetSelection } from '@objectstack/spec/contracts';
import type {
DataSource,
// #4564 — the canonical `deleteView` receipt shapes, declared beside the
// `DataSource` interface they belong to. Imported for the local uses below
// and re-exported under the same names further down, so this package's
// existing importers are untouched by the move.
DeleteViewResult,
ViewHomeDeleteOutcome,
BatchTransactionOperation,
DataSourceMutationEvent,
QueryParams,
Expand Down Expand Up @@ -1054,41 +1060,26 @@ function unwrapViewDraft(resp: unknown): Record<string, any> | null {
}

/**
* Outcome of {@link ObjectStackAdapter.deleteView}'s delete against ONE of a
* view's two homes — the pending draft, or the published overlay (#4479).
*/
export interface ViewHomeDeleteOutcome {
/**
* True when this home held a row and the server removed it. False is the
* "there was nothing here" answer, which is a success, not a failure: the
* framework reports a missing home as a 200 carrying `reset:false`.
*/
removed: boolean;
/** The server receipt's `reset` flag, when it sent one. */
reset?: boolean;
/** The server receipt's human-readable message, when it sent one. */
message?: string;
}

/**
* Receipt for {@link ObjectStackAdapter.deleteView} (#4479).
* `deleteView`'s receipt shapes, RE-EXPORTED from `@object-ui/types` (#4564).
*
* #4479 first declared both here, because this adapter is where the widened
* receipt is produced. That left the shared `DataSource.deleteView?` unable to
* describe it: the dependency runs this package -> `@object-ui/types` and never
* the other way, so the interface kept the narrow `{ deleted: boolean }` and a
* consumer reaching this adapter THROUGH `DataSource` was handed a type with
* the per-home outcomes already discarded. Nothing failed to compile — a wider
* return is assignable to a narrower declaration — which is precisely why the
* gap was silent until #4564 measured it.
*
* The per-home fields are ADDITIVE over the original `{ deleted: boolean }`:
* a caller that only reads `deleted` is unaffected, and one that needs to tell
* "draft gone, overlay left" from "both gone" now can.
* So the canonical declarations now live beside the interface they belong to,
* in `packages/types/src/data.ts`, and this package re-exports them under the
* SAME names: every importer PR #4562 left pointing at
* `@object-ui/data-objectstack` keeps compiling, and now gets the very type the
* shared contract speaks rather than a structural twin of it. Pinned by
* `deleteViewContract.types.test.ts`, which asserts type IDENTITY — mutual
* assignability would have been satisfied by a copy that was already drifting.
*/
export interface DeleteViewResult {
/**
* True only when no home is left serving the view AND at least one home
* actually held a row. A view that existed in neither home answers `false`
* — the same answer that shape has always given.
*/
deleted: boolean;
/** Outcome against the pending draft (`?state=draft`). */
draft?: ViewHomeDeleteOutcome;
/** Outcome against the published overlay. */
published?: ViewHomeDeleteOutcome;
}
export type { DeleteViewResult, ViewHomeDeleteOutcome } from '@object-ui/types';

/**
* Read one delete receipt into a {@link ViewHomeDeleteOutcome}.
Expand Down
Loading
Loading