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
40 changes: 40 additions & 0 deletions .changeset/25917-react-adapter-datasource.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@object-ui/react': patch
'@object-ui/core': patch
'@object-ui/data-objectstack': patch
'@object-ui/app-shell': patch
'@object-ui/plugin-designer': patch
---

refactor(react): `AdapterCtx` / `useAdapter` are typed by `DataSource`, and `@object-ui/react` no longer depends on `@object-ui/data-objectstack`

Clause-②: yes (narrowing)
Metadata-protocol: none

<!-- 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. -->

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.

- **`@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).
- **`@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.
- **`@object-ui/data-objectstack`**: re-exports both names from `@object-ui/core`; its published face is unchanged.
- **`@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.
- **`@object-ui/plugin-designer`**: declares the same adapter for its own pages, which read the ObjectStack client.

**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.

- **Before.** `useAdapter()` returned `ObjectStackAdapter | null` whatever the host installed.
- **Now.** It returns `DataSource | null` unless the host declares its adapter, and reading `getClient` off it is `error TS2339`.
- **Fix.** Either declare the adapter your provider installs, once, in your own package:

```ts
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';

declare module '@object-ui/react' {
interface AdapterContextRegister {
adapter: ObjectStackAdapter;
}
}
```

or narrow at the call site that needs the ObjectStack-only member, and keep reading `DataSource` members everywhere else.
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/core.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/core","sources":111,"sha256":"c2cf28d9a3a53535b8a328f099857f84b3ac90402fedc8b898a9266cb261f55c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/core","sources":112,"sha256":"e586f7573193068eefdbf8c22651072941bbd3843d60612cc2bc5d7e16003fca","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1 change: 0 additions & 1 deletion scripts/sdui-manifest.sources/data-objectstack.json

This file was deleted.

2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/react.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/react","sources":70,"sha256":"d89393cc7203be3bc88ea59d1a5e0ad6cbda588b8b8ad44baefed0a44da5d9d2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/react","sources":70,"sha256":"0a4e839e9459da5ce7a6eb9b112d527bf8afecd3f7e8c6f63b936e5eb9593c68","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
23 changes: 23 additions & 0 deletions ui/packages/app-shell/src/adapter-context-register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* The app shell's declaration of the adapter it installs into `AdapterCtx`.
*
* `@object-ui/react` types `AdapterCtx` / `useAdapter` by the protocol-level
* `DataSource`, so the runtime does not depend on any one adapter
* (objectstack#25917). This shell's `AdapterProvider` installs an
* `ObjectStackAdapter`, and shell code reads its members beyond `DataSource`
* (`getClient`, the access probes, the connection state), so the shell
* declares that adapter here, in the package that already depends on it.
*
* Imported from this package's entry so the declaration rides its published
* types: a host that mounts `AdapterProvider` reads the same adapter type.
*/

import type { ObjectStackAdapter } from '@object-ui/data-objectstack';

declare module '@object-ui/react' {
interface AdapterContextRegister {
adapter: ObjectStackAdapter;
}
}

export {};
4 changes: 4 additions & 0 deletions ui/packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
// Components
export { AppShell } from './components/AppShell.js';

// The adapter this shell installs into `AdapterCtx`, declared for every
// reader of `useAdapter` (see the module).
import './adapter-context-register.js';

// Providers
export { AdapterProvider, useAdapter } from './providers/AdapterProvider.js';
export { MetadataProvider, useMetadata, useMetadataItem } from './providers/MetadataProvider.js';
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export * from './utils/non-grid-row-ceiling.js';
// the floor itself never grows past the four.
export * from './utils/emptiness.js';
export * from './utils/expand-fields.js';
// The `enable`-block API denial predicate, read by `@object-ui/react` and
// re-exported by `@object-ui/data-objectstack` (objectstack#25917).
export * from './utils/api-access-denied.js';
// The RETIREMENT gate (objectui#4914, maintainer ruling B). Homed here rather
// than in `@object-ui/fields` because `@object-ui/components` is one of its six
// consumers and `fields` depends on `components` — see the module's docblock
Expand Down
57 changes: 57 additions & 0 deletions ui/packages/core/src/utils/api-access-denied.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* ObjectUI — the `enable`-block API denial predicate
* 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 { errorCodeIsAnyOf } from '@object-ui/types';

// Homed in core rather than in `@object-ui/data-objectstack`: the predicate is
// a pure read of the ADR-0112 `code`, and `@object-ui/react` asks it
// (`classifyLoadError`) without depending on any one adapter. The adapter
// re-exports both names unchanged.

/**
* The two denials the server derives from an object's `enable` block —
* `apiAccessDenialFromEnable` (objectstack `packages/rest/src/rest-server.ts`).
*
* - `OBJECT_API_DISABLED` (404) — `enable.apiEnabled: false`; the object is
* not exposed over the data API at all.
* - `OBJECT_API_METHOD_NOT_ALLOWED` (405) — the operation is absent from the
* `enable.apiMethods` whitelist.
*
* Both are **pure functions of the object's metadata**: no user, no permission,
* no context, no request body. So neither is transient and neither is
* per-user — when one happens it is a permanent property of that object, and
* every retry of every persona gets the identical answer.
*
* That is exactly why they must not be degraded into "no data". A 404 from a
* missing collection means *this backend doesn't have that table*, which the
* optional-collection probes below legitimately read as "feature unavailable";
* a 404 from `OBJECT_API_DISABLED` means *this page can never work*. Answering
* the second with an empty result set renders "you have no records" over a
* surface that is not allowed to have any (objectui#4408 — it also hid the
* upstream defect objectstack#7544 for its entire life, because a merely
* unpopulated page invites nobody to click through).
*/
export const API_ACCESS_DENIED_CODES = [
'OBJECT_API_DISABLED',
'OBJECT_API_METHOD_NOT_ALLOWED',
] as const;

/**
* True when `error` is an `enable`-block API denial (see
* {@link API_ACCESS_DENIED_CODES}).
*
* Discriminates on the ADR-0112 `code`, never on the status: 404 alone cannot
* separate a disabled object from a missing collection, and 405 alone cannot
* separate a withheld method from any other method rejection. The code survives
* the transport — `@objectstack/client`'s fetch wrapper stamps `error.code`
* from the response envelope, and both spellings are declared members of the
* spec's `StandardErrorCode` — so no heuristic on status is needed or wanted.
*/
export function isApiAccessDeniedError(error: unknown): boolean {
return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
}
48 changes: 6 additions & 42 deletions ui/packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
convertFiltersToAST,
emulateBatchTransaction,
normalizeSchemaReferenceKeys,
isApiAccessDeniedError,
type DatasetDrillRange,
} from '@object-ui/core';
import { MetadataCache } from './cache/MetadataCache';
Expand Down Expand Up @@ -1017,48 +1018,11 @@ export function is404Error(error: unknown): boolean {
return errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND']);
}

/**
* The two denials the server derives from an object's `enable` block —
* `apiAccessDenialFromEnable` (objectstack `packages/rest/src/rest-server.ts`).
*
* - `OBJECT_API_DISABLED` (404) — `enable.apiEnabled: false`; the object is
* not exposed over the data API at all.
* - `OBJECT_API_METHOD_NOT_ALLOWED` (405) — the operation is absent from the
* `enable.apiMethods` whitelist.
*
* Both are **pure functions of the object's metadata**: no user, no permission,
* no context, no request body. So neither is transient and neither is
* per-user — when one happens it is a permanent property of that object, and
* every retry of every persona gets the identical answer.
*
* That is exactly why they must not be degraded into "no data". A 404 from a
* missing collection means *this backend doesn't have that table*, which the
* optional-collection probes below legitimately read as "feature unavailable";
* a 404 from `OBJECT_API_DISABLED` means *this page can never work*. Answering
* the second with an empty result set renders "you have no records" over a
* surface that is not allowed to have any (objectui#4408 — it also hid the
* upstream defect objectstack#7544 for its entire life, because a merely
* unpopulated page invites nobody to click through).
*/
export const API_ACCESS_DENIED_CODES = [
'OBJECT_API_DISABLED',
'OBJECT_API_METHOD_NOT_ALLOWED',
] as const;

/**
* True when `error` is an `enable`-block API denial (see
* {@link API_ACCESS_DENIED_CODES}).
*
* Discriminates on the ADR-0112 `code`, never on the status: 404 alone cannot
* separate a disabled object from a missing collection, and 405 alone cannot
* separate a withheld method from any other method rejection. The code survives
* the transport — `@objectstack/client`'s fetch wrapper stamps `error.code`
* from the response envelope, and both spellings are declared members of the
* spec's `StandardErrorCode` — so no heuristic on status is needed or wanted.
*/
export function isApiAccessDeniedError(error: unknown): boolean {
return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
}
// `API_ACCESS_DENIED_CODES` / `isApiAccessDeniedError` live in
// `@object-ui/core` (`utils/api-access-denied.ts`) so that `@object-ui/react`
// can classify the denial without depending on this adapter. Re-exported here
// unchanged: this package's published face keeps both names.
export { API_ACCESS_DENIED_CODES, isApiAccessDeniedError } from '@object-ui/core';

/**
* What the by-name meta app route said about THIS session's access to an app
Expand Down
24 changes: 24 additions & 0 deletions ui/packages/plugin-designer/src/adapter-context-register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* The designer's declaration of the adapter its host installs into
* `AdapterCtx`.
*
* `@object-ui/react` types `AdapterCtx` / `useAdapter` by the protocol-level
* `DataSource`, so the runtime does not depend on any one adapter
* (objectstack#25917). The designer's app pages read the ObjectStack client
* (`getClient`), a member beyond `DataSource`, and run inside the ObjectStack
* Console, whose shell installs an `ObjectStackAdapter`; so the designer
* declares that adapter here, in the package that already depends on it.
*
* Imported by each page that reads such a member, so every program that
* compiles the page (the build and the test type-check) sees the declaration.
*/

import type { ObjectStackAdapter } from '@object-ui/data-objectstack';

declare module '@object-ui/react' {
interface AdapterContextRegister {
adapter: ObjectStackAdapter;
}
}

export {};
2 changes: 2 additions & 0 deletions ui/packages/plugin-designer/src/pages/CreateAppPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { wizardDraftToAppSchema } from '@object-ui/types';
import type { AppWizardDraft, ObjectSelection } from '@object-ui/types';
import { useMetadata } from '@object-ui/react';
import { useAdapter } from '@object-ui/react';
// Declares the ObjectStackAdapter this page reads `getClient` off.
import '../adapter-context-register';
import { toast } from 'sonner';

const DRAFT_STORAGE_KEY = 'objectui-app-wizard-draft';
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/plugin-designer/src/pages/EditAppPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type { AppWizardDraft, ObjectSelection } from '@object-ui/types';
import { AppSchema as SpecAppSchema, AppBrandingSchema as SpecAppBrandingSchema } from '@objectstack/spec/ui';
import { useMetadata } from '@object-ui/react';
import { useAdapter } from '@object-ui/react';
// Declares the ObjectStackAdapter this page reads `getClient` off.
import '../adapter-context-register';
import { toast } from 'sonner';

/**
Expand Down
24 changes: 24 additions & 0 deletions ui/packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,30 @@ function MyComponent() {
}
```

### useAdapter / AdapterCtx / AdapterContextRegister

`useAdapter()` reads the host's data adapter from `AdapterCtx`. Both are typed
by the protocol-level `DataSource` from `@object-ui/types`, so this package
depends on no adapter package, and a bring-your-own backend learns at the type
level which members it lacks.

A host whose provider installs a concrete adapter, and whose code reads members
beyond `DataSource`, declares that adapter once, in its own package:

```ts
import type { ObjectStackAdapter } from '@object-ui/data-objectstack'

declare module '@object-ui/react' {
interface AdapterContextRegister {
adapter: ObjectStackAdapter
}
}
```

`useAdapter()` then returns `ObjectStackAdapter | null`, and an
`AdapterCtx.Provider` value is checked against it. `@object-ui/app-shell` ships
this declaration, so a host that mounts its `AdapterProvider` already has it.

### useElementDataSourceSchema / ElementDataSourceGate

Consume `PageComponentSchema.dataSource` — the spec's per-element data binding
Expand Down
2 changes: 1 addition & 1 deletion ui/packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
},
"dependencies": {
"@object-ui/core": "workspace:*",
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "workspace:^"
Expand All @@ -42,6 +41,7 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/test-support": "workspace:*",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
Expand Down
41 changes: 38 additions & 3 deletions ui/packages/react/src/context/AppShellContext.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,48 @@
import { createContext, useContext, useEffect, useState } from 'react';
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
import type { DataSource } from '@object-ui/types';

// ---------------------------------------------------------------------------
// AdapterContext
// ---------------------------------------------------------------------------

export const AdapterCtx = createContext<ObjectStackAdapter | null>(null);
/**
* Where a host declares the concrete adapter it puts into {@link AdapterCtx}.
*
* The runtime types the context by the protocol-level `DataSource` interface
* from `@object-ui/types`, never by one concrete adapter (AGENTS #1: inject a
* `DataSource`, don't hard-code a backend). So `@object-ui/react` installs
* without any adapter package, and a bring-your-own backend learns at the type
* level which members it lacks instead of at runtime.
*
* A host whose provider installs a concrete adapter, and whose own code reads
* members beyond `DataSource`, declares that adapter here by module
* augmentation, in its own package, which already depends on the adapter:
*
* ```ts
* import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
* declare module '@object-ui/react' {
* interface AdapterContextRegister { adapter: ObjectStackAdapter }
* }
* ```
*
* The augmentation is the host's claim about what its provider installs; the
* `AdapterCtx.Provider` value is then checked against the declared type.
*/
// Deliberately empty: an EXTENSION POINT that hosts fill by augmentation.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- see above
export interface AdapterContextRegister {}

/**
* The adapter type {@link AdapterCtx} carries: the host's declared adapter
* when {@link AdapterContextRegister} names one, otherwise `DataSource`.
*/
export type ContextAdapter = AdapterContextRegister extends { adapter: infer A extends DataSource }
? A
: DataSource;

export const AdapterCtx = createContext<ContextAdapter | null>(null);

export function useAdapter(): ObjectStackAdapter | null {
export function useAdapter(): ContextAdapter | null {
return useContext(AdapterCtx);
}

Expand Down
Loading
Loading