diff --git a/.changeset/25917-react-adapter-datasource.md b/.changeset/25917-react-adapter-datasource.md new file mode 100644 index 00000000000..d4e54f9aef9 --- /dev/null +++ b/.changeset/25917-react-adapter-datasource.md @@ -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 + + + +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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 350bfb01bde..8b2b53b2ab0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6129,9 +6129,6 @@ importers: '@object-ui/core': specifier: workspace:* version: link:../core - '@object-ui/data-objectstack': - specifier: workspace:* - version: link:../data-objectstack '@object-ui/i18n': specifier: workspace:* version: link:../i18n @@ -6148,6 +6145,9 @@ importers: specifier: 19.3.0 version: 19.3.0(react@19.3.0) devDependencies: + '@object-ui/data-objectstack': + specifier: workspace:* + version: link:../data-objectstack '@object-ui/test-support': specifier: workspace:* version: link:../test-support diff --git a/scripts/sdui-manifest.sources/core.json b/scripts/sdui-manifest.sources/core.json index dea72002adf..8c1a7049cd0 100644 --- a/scripts/sdui-manifest.sources/core.json +++ b/scripts/sdui-manifest.sources/core.json @@ -1 +1 @@ -{"package":"ui/packages/core","sources":111,"sha256":"c2cf28d9a3a53535b8a328f099857f84b3ac90402fedc8b898a9266cb261f55c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/core","sources":112,"sha256":"e586f7573193068eefdbf8c22651072941bbd3843d60612cc2bc5d7e16003fca","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/data-objectstack.json b/scripts/sdui-manifest.sources/data-objectstack.json deleted file mode 100644 index f0a6be1d056..00000000000 --- a/scripts/sdui-manifest.sources/data-objectstack.json +++ /dev/null @@ -1 +0,0 @@ -{"package":"ui/packages/data-objectstack","sources":10,"sha256":"83f94a01a5e9704df262bda723b1dd58c619d66ec70f9dc22476cc7146e8d97a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/react.json b/scripts/sdui-manifest.sources/react.json index bfa31928e40..5946a845eba 100644 --- a/scripts/sdui-manifest.sources/react.json +++ b/scripts/sdui-manifest.sources/react.json @@ -1 +1 @@ -{"package":"ui/packages/react","sources":70,"sha256":"d89393cc7203be3bc88ea59d1a5e0ad6cbda588b8b8ad44baefed0a44da5d9d2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/react","sources":70,"sha256":"0a4e839e9459da5ce7a6eb9b112d527bf8afecd3f7e8c6f63b936e5eb9593c68","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/ui/packages/app-shell/src/adapter-context-register.ts b/ui/packages/app-shell/src/adapter-context-register.ts new file mode 100644 index 00000000000..252085c44cf --- /dev/null +++ b/ui/packages/app-shell/src/adapter-context-register.ts @@ -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 {}; diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts index 0ea5a7c538d..82ed62608c6 100644 --- a/ui/packages/app-shell/src/index.ts +++ b/ui/packages/app-shell/src/index.ts @@ -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'; diff --git a/ui/packages/core/src/index.ts b/ui/packages/core/src/index.ts index 4d6c1b5986b..5d25ab1d2bd 100644 --- a/ui/packages/core/src/index.ts +++ b/ui/packages/core/src/index.ts @@ -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 diff --git a/ui/packages/core/src/utils/api-access-denied.ts b/ui/packages/core/src/utils/api-access-denied.ts new file mode 100644 index 00000000000..3569c329867 --- /dev/null +++ b/ui/packages/core/src/utils/api-access-denied.ts @@ -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); +} diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index ce9fdce64d4..f5827b29f64 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -79,6 +79,7 @@ import { convertFiltersToAST, emulateBatchTransaction, normalizeSchemaReferenceKeys, + isApiAccessDeniedError, type DatasetDrillRange, } from '@object-ui/core'; import { MetadataCache } from './cache/MetadataCache'; @@ -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 diff --git a/ui/packages/plugin-designer/src/adapter-context-register.ts b/ui/packages/plugin-designer/src/adapter-context-register.ts new file mode 100644 index 00000000000..979925df08a --- /dev/null +++ b/ui/packages/plugin-designer/src/adapter-context-register.ts @@ -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 {}; diff --git a/ui/packages/plugin-designer/src/pages/CreateAppPage.tsx b/ui/packages/plugin-designer/src/pages/CreateAppPage.tsx index 4efcadae17b..2f93d46ea0d 100644 --- a/ui/packages/plugin-designer/src/pages/CreateAppPage.tsx +++ b/ui/packages/plugin-designer/src/pages/CreateAppPage.tsx @@ -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'; diff --git a/ui/packages/plugin-designer/src/pages/EditAppPage.tsx b/ui/packages/plugin-designer/src/pages/EditAppPage.tsx index 31e1ae7eb31..92da4936bbc 100644 --- a/ui/packages/plugin-designer/src/pages/EditAppPage.tsx +++ b/ui/packages/plugin-designer/src/pages/EditAppPage.tsx @@ -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'; /** diff --git a/ui/packages/react/README.md b/ui/packages/react/README.md index 88a064f8287..e89e49a1598 100644 --- a/ui/packages/react/README.md +++ b/ui/packages/react/README.md @@ -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 diff --git a/ui/packages/react/package.json b/ui/packages/react/package.json index 46bb01c8d09..5ef55b29a3b 100644 --- a/ui/packages/react/package.json +++ b/ui/packages/react/package.json @@ -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:^" @@ -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", diff --git a/ui/packages/react/src/context/AppShellContext.tsx b/ui/packages/react/src/context/AppShellContext.tsx index 66481757aef..6be831dc48b 100644 --- a/ui/packages/react/src/context/AppShellContext.tsx +++ b/ui/packages/react/src/context/AppShellContext.tsx @@ -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(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(null); -export function useAdapter(): ObjectStackAdapter | null { +export function useAdapter(): ContextAdapter | null { return useContext(AdapterCtx); } diff --git a/ui/packages/react/src/context/__tests__/AppShellContext.adapterType.pin.test.ts b/ui/packages/react/src/context/__tests__/AppShellContext.adapterType.pin.test.ts new file mode 100644 index 00000000000..a7d9dda016a --- /dev/null +++ b/ui/packages/react/src/context/__tests__/AppShellContext.adapterType.pin.test.ts @@ -0,0 +1,240 @@ +/** + * 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. + */ + +/** + * `AdapterCtx` / `useAdapter` are typed by the protocol-level `DataSource`, + * and a host widens them only by declaring its adapter on + * `AdapterContextRegister` (objectstack#25917). + * + * ## What this pins, and why only the compiler can see it + * + * Both used to be typed by `ObjectStackAdapter`, which made the runtime depend + * on `@object-ui/data-objectstack`, and gave every reader the ObjectStack-only + * members (`getClient`, the access probes) whether or not its host installed + * that adapter. Which reads the compiler refuses is erased before any runtime + * assertion could run, so the gauge is `tsc`, driven here with the same + * harness as `SchemaRendererContext.dataSourceType.pin.test.ts`. + * + * Two programs over the SAME cases-by-line source shape: + * + * - UNDECLARED: no augmentation. A reader reaches `DataSource` members and is + * refused the ObjectStack-only ones; a provider may install any + * `DataSource`. Reverting the context to `ObjectStackAdapter` turns the + * refused `getClient` / `probeAppAccess` rows green and this file red. + * - DECLARED: the virtual module augments `AdapterContextRegister` with a host + * adapter that extends `DataSource`. The reader now reaches that adapter's + * extra member, and the provider's value is checked against it — a bare + * `DataSource` is refused there. + * + * ## Resolution guard + * + * The context resolves from its SOURCE path and `@object-ui/types` through + * `paths`, not `dist`. The CONTROL rows are refusals unrelated to the adapter + * type (`DataSource` declares `getObjectSchema` as required; a context value + * cannot be a string), so a harness that degrades to `any` fires them. A + * diagnostic in the virtual module's import header throws instead of reading + * as a verdict. + * + * Cost note (ui/AGENTS.md 测试纪律): both programs are built at MODULE SCOPE, + * so the compiler work lands in the import phase, which no test or hook + * timeout bounds. + */ + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +// __tests__ → context → src → react → packages → repo root +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..', '..'); +/** The seam under test, by SOURCE path — no `dist`, no barrel. */ +const CONTEXT_IMPORT = join(HERE, '..', 'AppShellContext').replace(/\\/g, '/'); + +/** + * One assignment or read, and whether `tsc` must refuse it. + * `code` emits EXACTLY one line — the line index is how a diagnostic is + * attributed back to a case. + */ +interface Case { + readonly what: string; + readonly code: string; + readonly refused: boolean; +} + +/** The adapter as a READER reaches it. */ +const READ = `(undefined as unknown as ReturnType)!`; +/** The type a PROVIDER's `value` is checked against. */ +const PROVIDED = `React.ContextType`; +/** A complete protocol-level adapter. */ +const ADAPTER = `(undefined as unknown as DataSource)`; + +const UNDECLARED: readonly Case[] = [ + { + what: 'a reader reaches a DataSource member (`find`) with no cast', + code: `const c: DataSource['find'] = ${READ}.find;`, + refused: false, + }, + { + what: 'a reader reaches `getObjectSchema`, which DataSource declares', + code: `const c: DataSource['getObjectSchema'] = ${READ}.getObjectSchema;`, + refused: false, + }, + { + what: 'a provider may install any DataSource, with no cast', + code: `const c: ${PROVIDED} = ${ADAPTER};`, + refused: false, + }, + { + what: 'a provider with no adapter yet may say so with `null`', + code: `const c: ${PROVIDED} = null;`, + refused: false, + }, + { + what: 'NARROWED `getClient` is not a DataSource member: refused with no host declaration', + code: `const c = ${READ}.getClient;`, + refused: true, + }, + { + what: 'NARROWED `probeAppAccess` is not a DataSource member: refused with no host declaration', + code: `const c = ${READ}.probeAppAccess;`, + refused: true, + }, + { + what: 'CONTROL `DataSource` really declares `getObjectSchema` as REQUIRED', + code: `const c: DataSource = { find: (undefined as any), findOne: (undefined as any), create: (undefined as any), update: (undefined as any), delete: (undefined as any) };`, + refused: true, + }, + { + what: 'CONTROL a context value cannot be a string', + code: `const c: ${PROVIDED} = 'not-an-adapter';`, + refused: true, + }, +]; + +const DECLARED: readonly Case[] = [ + { + what: "a reader reaches the declared host adapter's own member", + code: `const c: () => string = ${READ}.hostOnly;`, + refused: false, + }, + { + what: 'a reader still reaches DataSource members', + code: `const c: DataSource['find'] = ${READ}.find;`, + refused: false, + }, + { + what: 'a provider may install the declared host adapter', + code: `const c: ${PROVIDED} = (undefined as unknown as HostAdapter);`, + refused: false, + }, + { + what: 'a provider value is checked against the declared adapter: a bare DataSource is refused', + code: `const c: ${PROVIDED} = ${ADAPTER};`, + refused: true, + }, + { + what: 'CONTROL a member neither type declares is still refused', + code: `const c = ${READ}.notAMember;`, + refused: true, + }, +]; + +function header(declared: boolean): string { + return [ + `import type React from 'react';`, + `import type { DataSource } from '@object-ui/types';`, + `import type { AdapterCtx, useAdapter } from '${CONTEXT_IMPORT}';`, + `type _Used = [DataSource, typeof AdapterCtx, typeof useAdapter, React.ReactNode];`, + `interface HostAdapter extends DataSource { hostOnly(): string }`, + ...(declared + ? [`declare module '${CONTEXT_IMPORT}' { interface AdapterContextRegister { adapter: HostAdapter } }`] + : []), + ].join('\n'); +} + +/** + * Compile one program and return the set of case indices that produced a + * diagnostic. `paths` mirrors the repo root `tsconfig.json` (see the header). + */ +function erroringCases(cases: readonly Case[], declared: boolean): Set { + const head = `${header(declared)}\n`; + // One BLOCK per case so the `const c` declarations do not collide. + const body = cases.map((c) => `{ ${c.code} }`).join('\n'); + const source = `${head}${body}\n`; + const headerLines = head.split('\n').length - 1; + + const VIRTUAL = join(HERE, `__adapterCtxPins.${declared ? 'declared' : 'undeclared'}.virtual.ts`).replace(/\\/g, '/'); + const options: ts.CompilerOptions = { + strict: true, + skipLibCheck: true, + noEmit: true, + jsx: ts.JsxEmit.ReactJSX, + moduleResolution: ts.ModuleResolutionKind.Bundler, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + baseUrl: REPO_ROOT, + paths: { + '@object-ui/types': ['packages/types/src'], + '@object-ui/types/*': ['packages/types/src/*'], + '@object-ui/core': ['packages/core/src'], + '@object-ui/core/*': ['packages/core/src/*'], + }, + }; + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersion, ...rest) => + fileName === VIRTUAL + ? ts.createSourceFile(fileName, source, languageVersion, true) + : getSourceFile(fileName, languageVersion, ...rest); + const fileExists = host.fileExists.bind(host); + host.fileExists = (fileName) => (fileName === VIRTUAL ? true : fileExists(fileName)); + const readFile = host.readFile.bind(host); + host.readFile = (fileName) => (fileName === VIRTUAL ? source : readFile(fileName)); + + const program = ts.createProgram([VIRTUAL], options, host); + const sf = program.getSourceFile(VIRTUAL); + if (!sf) throw new Error('virtual source file was not added to the program'); + + const hit = new Set(); + for (const d of [...program.getSemanticDiagnostics(sf), ...program.getSyntacticDiagnostics(sf)]) { + if (d.start == null) continue; + const index = sf.getLineAndCharacterOfPosition(d.start).line - headerLines; + if (index < 0) { + throw new Error( + 'the pin harness failed to resolve its own imports — this is a setup failure, not a ' + + `verdict about \`AdapterCtx\`: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`, + ); + } + hit.add(index); + } + return hit; +} + +// Module scope on purpose — see the file header. +const refusedUndeclared = erroringCases(UNDECLARED, false); +const refusedDeclared = erroringCases(DECLARED, true); + +describe('`AdapterCtx` is typed by DataSource unless a host declares its adapter (objectstack#25917)', () => { + describe('no host declaration', () => { + for (const [i, c] of UNDECLARED.entries()) { + it(c.what, () => { + expect({ case: c.what, refused: refusedUndeclared.has(i) }) + .toEqual({ case: c.what, refused: c.refused }); + }); + } + }); + describe('a host declares its adapter on `AdapterContextRegister`', () => { + for (const [i, c] of DECLARED.entries()) { + it(c.what, () => { + expect({ case: c.what, refused: refusedDeclared.has(i) }) + .toEqual({ case: c.what, refused: c.refused }); + }); + } + }); +}); diff --git a/ui/packages/react/src/utils/error-message.ts b/ui/packages/react/src/utils/error-message.ts index 1d199cad267..2d8013341ca 100644 --- a/ui/packages/react/src/utils/error-message.ts +++ b/ui/packages/react/src/utils/error-message.ts @@ -21,7 +21,7 @@ * real reason (e.g. a row-level-security denial) instead of swallowing it. */ -import { isApiAccessDeniedError } from '@object-ui/data-objectstack'; +import { isApiAccessDeniedError } from '@object-ui/core'; import type { ApiError } from '@objectstack/spec/api'; /** @@ -259,14 +259,14 @@ export function classifyLoadError(err: unknown): LoadErrorKind { } const code = typeof e?.code === 'string' ? e.code.toUpperCase() : ''; // The object's `enable` block withholds this operation — checked FIRST, on - // the CODE alone (delegated to the shared predicate so this file and - // `@object-ui/data-objectstack` cannot drift on which codes count). Status - // cannot carry this verdict: the denial is a 404, the same status a missing - // collection and a missing record answer with, and its 405 sibling is the - // same status any other method rejection uses. Unlike every kind below it - // this one is not about the request, the session or the network — it is a - // permanent property of the object, identical for every persona and every - // retry (objectui#4408). + // the CODE alone (delegated to the shared `@object-ui/core` predicate, which + // `@object-ui/data-objectstack` re-exports, so the two cannot drift on which + // codes count). Status cannot carry this verdict: the denial is a 404, the + // same status a missing collection and a missing record answer with, and its + // 405 sibling is the same status any other method rejection uses. Unlike + // every kind below it this one is not about the request, the session or the + // network — it is a permanent property of the object, identical for every + // persona and every retry (objectui#4408). if (isApiAccessDeniedError(err)) return 'api-disabled'; if (status === 403 || code === 'PERMISSION_DENIED' || code === 'FORBIDDEN') return 'forbidden'; if (status === 401 || code === 'UNAUTHORIZED' || code === 'UNAUTHENTICATED') return 'unauthorized';