Skip to content

Commit ef025fe

Browse files
refactor(react): AdapterCtx and useAdapter are typed by DataSource; react drops its data-objectstack dependency (#26292)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9CXkMCRg8Z8RTFcd3HfPqhC)_ Fixes #25917 `@object-ui/react` no longer depends on `@object-ui/data-objectstack`. `AdapterCtx` and `useAdapter()` are typed by the protocol-level `DataSource` from `@object-ui/types`, and a host that installs a concrete adapter declares it once on a new extension point, `AdapterContextRegister` (module augmentation, the same shape as TanStack Router's `Register`). The `enable`-block denial predicate moves to `@object-ui/core`, and the adapter re-exports it unchanged. Clause-②: yes (narrowing) Metadata-protocol: none ## Premise, measured on origin/main 4f7e041 - The card counted 28 `components` files reaching the concrete adapter. That count is stale: today there are 62 non-test readers of `useAdapter` / `AdapterCtx` (app-shell 41, console 14, components 4, plugin-designer 3). - 18 of them read `ObjectStackAdapter` members that `DataSource` does not declare: `getClient` (13 files), `probeAppAccess`, `probeMetaItemAccess`, `getConnectionState` / `onConnectionStateChange`, `clearCache`, `queryDataset`. They all sit in app-shell, console and plugin-designer, which already depend on the adapter. - `isApiAccessDeniedError` is also called inside `data-objectstack`, so `core` is its only single-source home: both `react` and `data-objectstack` depend on `core`. - The seat chose the route (register augmentation over 18 call-site guards) and amended the claim surface to cover core, data-objectstack and the host declarations. ## What changed - **`@object-ui/react`**: `AdapterCtx` / `useAdapter()` carry `ContextAdapter`, which resolves to the adapter a host declares on `AdapterContextRegister` and otherwise to `DataSource`. `@object-ui/data-objectstack` moves to `devDependencies`, because one boundary test (`error-message.normalisation-boundary.test.ts`) still reads it. `error-message.ts` imports the predicate from `@object-ui/core`. The README documents the extension point. - **`@object-ui/core`**: new `utils/api-access-denied.ts` holds `API_ACCESS_DENIED_CODES` and `isApiAccessDeniedError`, moved verbatim with their docblock. - **`@object-ui/data-objectstack`**: re-exports both names from `@object-ui/core`, so its published face is unchanged, and its internal caller imports the predicate from core. - **`@object-ui/app-shell`**: `adapter-context-register.ts` declares `ObjectStackAdapter`, and the package entry imports it, so the declaration ships in `dist/index.d.ts`. - **`@object-ui/plugin-designer`**: the same declaration, imported by the two pages that call `getClient`, so the test type-check program sees it too. - **sdui**: `core` and `react` re-recorded. `data-objectstack.json` is removed by the generator ("no longer reached"), because the registry walk reached it only through react. No call site of the 18 changed. Runtime behaviour is unchanged: the context holds the same object. ## Verification (merged main 2409c9c, head 29b4eed, all heavy runs under `os-verify-lock`) - Build: `turbo run build --filter=@object-ui/console`, 71/71 tasks, which includes the Console build. - `type-check`: core, react, data-objectstack, components, plugin-designer, app-shell and console all exit 0. Console resolves `@object-ui/react` and `@object-ui/app-shell` from `dist`, and its seven files that call `getClient` through app-shell's `useAdapter` compile, so the published app-shell types carry the declaration. - Published-types probe (one-off, deleted afterwards): a file under `ui/apps/console` that imports type-only from `@object-ui/app-shell` and reads `useAdapter()?.getClient()` exits 0. The same file without the app-shell import exits 2 with TS2339, `Property 'getClient' does not exist on type 'DataSource'` (the compiler prints the type with its `any` argument). - New pin `AppShellContext.adapterType.pin.test.ts` (13 cases, same tsc harness as `SchemaRendererContext.dataSourceType.pin.test.ts`). Without a declaration, `getClient` and `probeAppAccess` are refused and any `DataSource` may be provided. With a declaration, the host adapter's own member is readable and a bare `DataSource` provider value is refused. CONTROL rows guard against a harness that has degraded to `any`. - Mutations through `scripts/ablation-replace.mjs`, each restored to the HEAD blob with an empty `git diff HEAD`: - Typing the context as `any` turns 2 cases red: the provider check and a CONTROL row. - Widening the `DataSource` fallback with `getClient` / `probeAppAccess` turns 3 cases red: both NARROWED rows and the provider row. - vitest over `packages/react/`, `packages/core/`, `packages/data-objectstack/` and `packages/plugin-designer/`: 480 files and 7238 tests passed, 27 skipped. app-shell's suite is left to CI `Test Core` / Test UI because its runtime is untouched (type declaration only). - Gates: the 64 families `dispatch-gates --commands` derived were run and reconciled with `--ran`. 63 exit 0. `check:dual-build-cjs-loads` exits 3 (PREREQUISITE NOT MET: it reads the whole repo's built output), so it is NOT MEASURED locally and left to CI. `check-adr-0087-registration` verifies `not-required (runtime-interface-only ...#ContextAdapter)`. `check:new-line-citations`: 0 new citations. eslint on the changed files: 0 errors. - Dogfood: owed once on the final head (Clause-② narrowing). It runs after the next main merge, and the os-dev report carries its reading. ## Acceptance notes - The changeset carries a FROM → TO fix block, as the seat asked: declare the adapter, or narrow at the call site. `runtime-interface-only` inherits the prescription refusal, and the detector does not read this block as a prescription. The disposition stands on the compiler channel: a reader that needs the declaration gets `TS2339` at its own call site, and no metadata surface moves. - `plugin-designer` needed the declaration imported by its pages, not only placed under `src/`. Its `tsconfig.test.json` compiles the pages through the test files, and that program never includes a file that nothing imports. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017sGTFTS9edirCTVBgQysK7 --- _Generated by [Claude Code](https://claude.ai/code/session_017sGTFTS9edirCTVBgQysK7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e14d9ce commit ef025fe

18 files changed

Lines changed: 478 additions & 61 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@object-ui/react': patch
3+
'@object-ui/core': patch
4+
'@object-ui/data-objectstack': patch
5+
'@object-ui/app-shell': patch
6+
'@object-ui/plugin-designer': patch
7+
---
8+
9+
refactor(react): `AdapterCtx` / `useAdapter` are typed by `DataSource`, and `@object-ui/react` no longer depends on `@object-ui/data-objectstack`
10+
11+
Clause-②: yes (narrowing)
12+
Metadata-protocol: none
13+
14+
<!-- adr-0087: not-required (runtime-interface-only ui/packages/react/src/context/AppShellContext.tsx#ContextAdapter) No metadata moves: the narrowed type is the one `AdapterCtx` and `useAdapter()` carry, a runtime TypeScript type of a React context, with no Zod schema, spec declaration or stored row behind it, so there is nothing for objectstack migrate meta to rewrite; the compiler names the affected call site. -->
15+
16+
The runtime no longer depends on one adapter, so a bring-your-own backend installs `@object-ui/react` without the ObjectStack adapter, and learns at the type level which `DataSource` members it lacks.
17+
18+
- **`@object-ui/react`**: `AdapterCtx` and `useAdapter()` are typed by the protocol-level `DataSource` from `@object-ui/types`, not by `ObjectStackAdapter`. A new exported extension point, `AdapterContextRegister`, lets a host declare the concrete adapter its provider installs; `useAdapter()` then returns that adapter and an `AdapterCtx.Provider` value is checked against it. `ContextAdapter` names the resolved type. `@object-ui/data-objectstack` moves from `dependencies` to `devDependencies` (one boundary test still reads it).
19+
- **`@object-ui/core`**: exports `API_ACCESS_DENIED_CODES` and `isApiAccessDeniedError`, the `enable`-block API denial predicate, moved here unchanged so `@object-ui/react` can classify the denial without the adapter.
20+
- **`@object-ui/data-objectstack`**: re-exports both names from `@object-ui/core`; its published face is unchanged.
21+
- **`@object-ui/app-shell`**: declares `ObjectStackAdapter` on `AdapterContextRegister`, and the declaration ships in its published types, so a host that mounts `AdapterProvider` keeps today's `useAdapter()` type.
22+
- **`@object-ui/plugin-designer`**: declares the same adapter for its own pages, which read the ObjectStack client.
23+
24+
**BREAKING** (a type narrowing) for a TypeScript reader of `useAdapter()` / `AdapterCtx` from `@object-ui/react` that reads an `ObjectStackAdapter` member `DataSource` does not declare (`getClient`, `probeAppAccess`, `probeMetaItemAccess`, `getConnectionState`, `onConnectionStateChange`, `clearCache`, `queryDataset`, …) and does not import `@object-ui/app-shell`. Runtime behaviour is unchanged: the context holds the same adapter object as before.
25+
26+
- **Before.** `useAdapter()` returned `ObjectStackAdapter | null` whatever the host installed.
27+
- **Now.** It returns `DataSource | null` unless the host declares its adapter, and reading `getClient` off it is `error TS2339`.
28+
- **Fix.** Either declare the adapter your provider installs, once, in your own package:
29+
30+
```ts
31+
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
32+
33+
declare module '@object-ui/react' {
34+
interface AdapterContextRegister {
35+
adapter: ObjectStackAdapter;
36+
}
37+
}
38+
```
39+
40+
or narrow at the call site that needs the ObjectStack-only member, and keep reading `DataSource` members everywhere else.

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/core","sources":111,"sha256":"c2cf28d9a3a53535b8a328f099857f84b3ac90402fedc8b898a9266cb261f55c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/core","sources":112,"sha256":"e586f7573193068eefdbf8c22651072941bbd3843d60612cc2bc5d7e16003fca","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}

‎scripts/sdui-manifest.sources/data-objectstack.json‎

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/react","sources":70,"sha256":"d89393cc7203be3bc88ea59d1a5e0ad6cbda588b8b8ad44baefed0a44da5d9d2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/react","sources":70,"sha256":"0a4e839e9459da5ce7a6eb9b112d527bf8afecd3f7e8c6f63b936e5eb9593c68","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* The app shell's declaration of the adapter it installs into `AdapterCtx`.
3+
*
4+
* `@object-ui/react` types `AdapterCtx` / `useAdapter` by the protocol-level
5+
* `DataSource`, so the runtime does not depend on any one adapter
6+
* (objectstack#25917). This shell's `AdapterProvider` installs an
7+
* `ObjectStackAdapter`, and shell code reads its members beyond `DataSource`
8+
* (`getClient`, the access probes, the connection state), so the shell
9+
* declares that adapter here, in the package that already depends on it.
10+
*
11+
* Imported from this package's entry so the declaration rides its published
12+
* types: a host that mounts `AdapterProvider` reads the same adapter type.
13+
*/
14+
15+
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
16+
17+
declare module '@object-ui/react' {
18+
interface AdapterContextRegister {
19+
adapter: ObjectStackAdapter;
20+
}
21+
}
22+
23+
export {};

‎ui/packages/app-shell/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
// Components
99
export { AppShell } from './components/AppShell.js';
1010

11+
// The adapter this shell installs into `AdapterCtx`, declared for every
12+
// reader of `useAdapter` (see the module).
13+
import './adapter-context-register.js';
14+
1115
// Providers
1216
export { AdapterProvider, useAdapter } from './providers/AdapterProvider.js';
1317
export { MetadataProvider, useMetadata, useMetadataItem } from './providers/MetadataProvider.js';

‎ui/packages/core/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export * from './utils/non-grid-row-ceiling.js';
4040
// the floor itself never grows past the four.
4141
export * from './utils/emptiness.js';
4242
export * from './utils/expand-fields.js';
43+
// The `enable`-block API denial predicate, read by `@object-ui/react` and
44+
// re-exported by `@object-ui/data-objectstack` (objectstack#25917).
45+
export * from './utils/api-access-denied.js';
4346
// The RETIREMENT gate (objectui#4914, maintainer ruling B). Homed here rather
4447
// than in `@object-ui/fields` because `@object-ui/components` is one of its six
4548
// consumers and `fields` depends on `components` — see the module's docblock
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* ObjectUI — the `enable`-block API denial predicate
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 { errorCodeIsAnyOf } from '@object-ui/types';
10+
11+
// Homed in core rather than in `@object-ui/data-objectstack`: the predicate is
12+
// a pure read of the ADR-0112 `code`, and `@object-ui/react` asks it
13+
// (`classifyLoadError`) without depending on any one adapter. The adapter
14+
// re-exports both names unchanged.
15+
16+
/**
17+
* The two denials the server derives from an object's `enable` block —
18+
* `apiAccessDenialFromEnable` (objectstack `packages/rest/src/rest-server.ts`).
19+
*
20+
* - `OBJECT_API_DISABLED` (404) — `enable.apiEnabled: false`; the object is
21+
* not exposed over the data API at all.
22+
* - `OBJECT_API_METHOD_NOT_ALLOWED` (405) — the operation is absent from the
23+
* `enable.apiMethods` whitelist.
24+
*
25+
* Both are **pure functions of the object's metadata**: no user, no permission,
26+
* no context, no request body. So neither is transient and neither is
27+
* per-user — when one happens it is a permanent property of that object, and
28+
* every retry of every persona gets the identical answer.
29+
*
30+
* That is exactly why they must not be degraded into "no data". A 404 from a
31+
* missing collection means *this backend doesn't have that table*, which the
32+
* optional-collection probes below legitimately read as "feature unavailable";
33+
* a 404 from `OBJECT_API_DISABLED` means *this page can never work*. Answering
34+
* the second with an empty result set renders "you have no records" over a
35+
* surface that is not allowed to have any (objectui#4408 — it also hid the
36+
* upstream defect objectstack#7544 for its entire life, because a merely
37+
* unpopulated page invites nobody to click through).
38+
*/
39+
export const API_ACCESS_DENIED_CODES = [
40+
'OBJECT_API_DISABLED',
41+
'OBJECT_API_METHOD_NOT_ALLOWED',
42+
] as const;
43+
44+
/**
45+
* True when `error` is an `enable`-block API denial (see
46+
* {@link API_ACCESS_DENIED_CODES}).
47+
*
48+
* Discriminates on the ADR-0112 `code`, never on the status: 404 alone cannot
49+
* separate a disabled object from a missing collection, and 405 alone cannot
50+
* separate a withheld method from any other method rejection. The code survives
51+
* the transport — `@objectstack/client`'s fetch wrapper stamps `error.code`
52+
* from the response envelope, and both spellings are declared members of the
53+
* spec's `StandardErrorCode` — so no heuristic on status is needed or wanted.
54+
*/
55+
export function isApiAccessDeniedError(error: unknown): boolean {
56+
return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
57+
}

‎ui/packages/data-objectstack/src/index.ts‎

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {
7979
convertFiltersToAST,
8080
emulateBatchTransaction,
8181
normalizeSchemaReferenceKeys,
82+
isApiAccessDeniedError,
8283
type DatasetDrillRange,
8384
} from '@object-ui/core';
8485
import { MetadataCache } from './cache/MetadataCache';
@@ -1017,48 +1018,11 @@ export function is404Error(error: unknown): boolean {
10171018
return errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND']);
10181019
}
10191020

1020-
/**
1021-
* The two denials the server derives from an object's `enable` block —
1022-
* `apiAccessDenialFromEnable` (objectstack `packages/rest/src/rest-server.ts`).
1023-
*
1024-
* - `OBJECT_API_DISABLED` (404) — `enable.apiEnabled: false`; the object is
1025-
* not exposed over the data API at all.
1026-
* - `OBJECT_API_METHOD_NOT_ALLOWED` (405) — the operation is absent from the
1027-
* `enable.apiMethods` whitelist.
1028-
*
1029-
* Both are **pure functions of the object's metadata**: no user, no permission,
1030-
* no context, no request body. So neither is transient and neither is
1031-
* per-user — when one happens it is a permanent property of that object, and
1032-
* every retry of every persona gets the identical answer.
1033-
*
1034-
* That is exactly why they must not be degraded into "no data". A 404 from a
1035-
* missing collection means *this backend doesn't have that table*, which the
1036-
* optional-collection probes below legitimately read as "feature unavailable";
1037-
* a 404 from `OBJECT_API_DISABLED` means *this page can never work*. Answering
1038-
* the second with an empty result set renders "you have no records" over a
1039-
* surface that is not allowed to have any (objectui#4408 — it also hid the
1040-
* upstream defect objectstack#7544 for its entire life, because a merely
1041-
* unpopulated page invites nobody to click through).
1042-
*/
1043-
export const API_ACCESS_DENIED_CODES = [
1044-
'OBJECT_API_DISABLED',
1045-
'OBJECT_API_METHOD_NOT_ALLOWED',
1046-
] as const;
1047-
1048-
/**
1049-
* True when `error` is an `enable`-block API denial (see
1050-
* {@link API_ACCESS_DENIED_CODES}).
1051-
*
1052-
* Discriminates on the ADR-0112 `code`, never on the status: 404 alone cannot
1053-
* separate a disabled object from a missing collection, and 405 alone cannot
1054-
* separate a withheld method from any other method rejection. The code survives
1055-
* the transport — `@objectstack/client`'s fetch wrapper stamps `error.code`
1056-
* from the response envelope, and both spellings are declared members of the
1057-
* spec's `StandardErrorCode` — so no heuristic on status is needed or wanted.
1058-
*/
1059-
export function isApiAccessDeniedError(error: unknown): boolean {
1060-
return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
1061-
}
1021+
// `API_ACCESS_DENIED_CODES` / `isApiAccessDeniedError` live in
1022+
// `@object-ui/core` (`utils/api-access-denied.ts`) so that `@object-ui/react`
1023+
// can classify the denial without depending on this adapter. Re-exported here
1024+
// unchanged: this package's published face keeps both names.
1025+
export { API_ACCESS_DENIED_CODES, isApiAccessDeniedError } from '@object-ui/core';
10621026

10631027
/**
10641028
* What the by-name meta app route said about THIS session's access to an app

0 commit comments

Comments
 (0)