Skip to content

feat(services): data-only services boundary + import-free owned API surface - W-22419571#7569

Draft
peternhale wants to merge 66 commits into
developfrom
phale/W-22419571-services-data-only
Draft

feat(services): data-only services boundary + import-free owned API surface - W-22419571#7569
peternhale wants to merge 66 commits into
developfrom
phale/W-22419571-services-data-only

Conversation

@peternhale

@peternhale peternhale commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Reshapes the salesforcedx-vscode-services public API into a data-only boundary: it no longer loans live third-party-SDK instances (@salesforce/core Connection/SfProject, source-deploy-retrieve ComponentSet/DeployResult/RetrieveResult, jsforce DescribeMetadataObject, @salesforce/templates CreateOutput) across the extension boundary. Instead it returns hand-authored owned data DTOs and lends a services-owned ServicesOrg facade (withDefaultOrg(org => …)), so consumers get strong types without installing the Salesforce SDK or effect and without version coupling.

Why: the old shape hit the type-sharing trilemma — a consumer could only type a loaned Connection by (a) owning the type (impossible, it's foreign), (b) re-declaring it (never nominally assignable — TS compares private members by declaring class), or (c) importing the real SDK (the dep burden we're avoiding). No 4th option. Data-only dissolves it: the surface no longer references foreign types, so an import-free published entry falls out for free.

Highlights:

  • Owned DTOs in src/owned/*.ts (import nothing; guarded by ownedTypes test). 3pp→owned mapping lives inside the service impls (*Mapper.ts).
  • Loan pattern + orchestration shifted into services: withDefaultOrg lends an owned ServicesOrg (query/dml/request/identity, {tooling?} flag) — never a raw Connection. Build→deploy/retrieve moved behind spec-based methods (deployFromSource(spec), retrieveToSource(spec), retrieveRemoteChanges, retrieveMembers, describeProjectComponents(spec)); the ComponentSet never leaves services.
  • Escape hatch for the rare raw-instance need (e.g. apex-testing's new TestService(connection) from @salesforce/apex-node): getConnectionData() returns the data to rebuild a Connection in the consumer's own dep space.
  • Migrated consumers off the loaned-instance getters: apex-testing, metadata (deploy/retrieve/delete), org-browser, lwc, lightning, visualforce, apex-log, apex-oas, soql, apex-replay-debugger, core (getUserId).
  • Import-free published entry: new salesforcedx-vscode-services-types/owned subpath (OwnedServicesApi + DTOs + helpers + ICONS), proven by a consumer fixture that type-checks with no SDK/effect installed, plus a real-tsc conformance guard so the hand-authored type can't drift.
  • Removed 3 consumer-free deprecated getters. The rest stay deprecated and are removed only at 0 consumers (incremental, non-breaking).

Deliberately retained (tracked, gated on their consumers migrating; see ADR-0021): core WorkspaceContext.getConnection() + getAuthFields() (published 2PP backward-compat); conflict detection + diffHelpers (need SDR path-resolution owned data can't express); deployOnSaveService + delete-marked-ComponentSet deploy.

Docs:

What issues does this PR fix or reference?

@W-22419571@

Functionality Before

Consumers received live 3pp SDK instances (e.g. getConnection(): Connection, deploy(componentSet): DeployResult) and therefore had to install + version-match the Salesforce SDK (and transitively effect) to type them.

Functionality After

Consumers receive owned data DTOs / a ServicesOrg loan facade / spec-based action methods returning owned outcomes. No SDK or effect install needed for the data-only surface; the new salesforcedx-vscode-services-types/owned entry type-checks with neither installed (enforced by a consumer-gate test). No user-visible behavior change — channel output, diagnostics, conflict handling, and result storage are preserved.

Implements Task B.2 — data-only ProjectInfo mapper and getProjectInfo method.

Changes:
- Add projectInfoMapper.ts: toProjectInfo(SfProject, paths) → ProjectInfo
  Maps live SfProject to owned ProjectInfo DTO (boundary code)
- Add getProjectInfo() to ProjectService: acquires SfProject, computes
  conventional SOQL/faux/typings paths, calls toProjectInfo
- Wire getProjectInfo into ServicesContract and PlainServicesApi
- Add projectInfoMapper.test.ts: TDD test covering field mapping

The mapper is extension-internal boundary code (MAY import @salesforce/core),
but produces services-owned ProjectInfo with no external imports.

Path derivation: mirrors existing getSoqlMetadataPath/getFauxStandardObjectsPath/
getTypingsPath patterns (Global.SFDX_STATE_FOLDER + tools/sobjects segments).

Verified: mapper test passes, compile clean, lint clean.
…mes - W-22419571

- Add deployMapper to map SDR DeployResult/RetrieveResult to owned DeployOutcome/RetrieveOutcome
- Add buildComponentSet to ComponentSetService to dispatch SourceSpec to existing getters
- Add deployFromSource(spec, opts) to MetadataDeployService
- Add retrieveToSource(spec, opts) to MetadataRetrieveService
- Wire new methods into contract and plainApi
- Mapper test verifies owned outcome shape
…9571

Consumer trap fix:
- deployFromSource() now takes NO options (both ignoreConflicts and checkOnly
  were silently ignored)
- retrieveToSource() keeps only ignoreConflicts option (removed outputDir
  which was silently ignored)

Removed:
- DeployOptions type (deleted entirely - both fields were unwired)
- RetrieveOptions.outputDir (only retrieveComponentSetToDirectory honors
  custom output path)

Why: Exposing options that the implementation silently ignores creates a
consumer trap. Better to expose nothing than expose broken options. These
can be wired later when needed.
…anges - W-22419571

FIX 1: Prevent garbage OrgChange records from nameless ChangeResults.
- Add hasNameAndType type guard to filter changes before mapping
- Apply filter in getConflictChanges, getLocalChanges, getRemoteChanges
- Update toOrgChange signature to require name+type (removes ?? '' fallbacks)
- Add test verifying narrowed type requirement

FIX 2: Add applyIgnore option to getLocalChanges for symmetry with getRemoteChanges.
- Update contract, service implementation to accept opts?: { applyIgnore?: boolean }
- Filters ignored === true changes when applyIgnore is set
…nal - W-22419571

Make rawOutput non-optional to match @salesforce/templates CreateOutput contract
where it is always provided. Also make created array readonly for consistency
with other owned DTOs.
…rg - W-22419571

Migrated 2 of 4 apex-testing files from deprecated ConnectionService.getConnection()
to the owned withDefaultOrg loan facade:
- orgApexClassProvider.ts: tooling query for ApexClass body
- testDiscovery.ts: Tooling API test discovery with pagination

Both files now use api.withDefaultOrg(org => ...) via the plain services API.
The callback receives a services-owned ServicesOrg with query/request operations.

Type workaround: PromisifiedContract mapping loses the generic parameter from
withDefaultOrg<R>, causing it to return Promise<unknown>. Used Effect.map with
type assertion (eslint-disabled) to restore type safety until the contract type
is improved.

NOT migrated in this chunk:
- apexTestRunUtils.ts: BLOCKED - requires raw Connection for TestService from
  @salesforce/apex-node (external package). This is a capability gap; TestService
  needs the full Connection object which ServicesOrg doesn't expose.
- packageResolution.ts: Not called by any files in this chunk; will be migrated
  when its callers (testController.ts) are migrated in a future chunk.

Verified: compile clean, lint clean (0 errors), no getConnection() usage in migrated files.
…W-22419571

Tasks 4-7 cluster: owned types re-export + deployManifest data-only deploy

- Re-export DeployOutcome, FileResponseInfo, ComponentFailureInfo, SourceSpec,
  DeployFromSourceOptions from services/src/index.ts for metadata consumption
- Create deployFromOutcome.ts as self-contained outcome presenter:
  inlines formatting, diagnostics, storage for owned DeployOutcome
  (keeps old helpers intact for unmigrated deployComponentSet)
- Rewrite deployManifest command: conflict detection stays on existing
  ComponentSet path; deploy switches to data-only deployFromSource(spec)
- Conflict retry restructured: performDeploy Effect.gen closes over spec/resolved
  so retry can re-invoke with same manifest URI
- Channel output "Starting..." prints in command before deployFromSource
- Live DeployResult no longer crosses boundary for this flow
Collapse duplicated deploy formatting to one owned-type
implementation. Fix duplicate diagnostics collection. Add missing
tests.
…-only - W-22419571

Switch deploySourcePath.ts and projectDeployStart.ts to deploy data-only via
MetadataDeployService.deployFromSource(spec, {ignoreConflicts:true}) +
deployFromOutcome(outcome), exactly mirroring the already-migrated
deployManifest.ts.

- deploySourcePath: spec = {kind:'paths', uris: resolvedUris.map(u => u.toString())}
- projectDeployStart: spec = {kind:'projectDirectories'}

Conflict detection STAYS on the existing ComponentSet path (deferred).
ConflictsDetectedError retry now re-issues the spec via the performDeploy
closure (closing over spec, NOT err.componentSet). The live DeployResult no
longer crosses the boundary for these two commands.
branches - W-22419571

BUG: deployFromOutcome.test.ts only called formatDeployOutput /
getMergedDeployFailures directly — never invoked deployFromOutcome,
so its 3 real behaviors (append formatted text to channel;
applyDeployDiagnostics on failures-with-paths; raise
DeployCompletedWithErrorsError on failures) were UNTESTED.

FIX: Add real Effect tests that invoke deployFromOutcome(outcome)
against mocked ChannelService + deployDiagnostics. Assert success
path (formatted text appended, no diagnostics, no error) and failure
paths (applyDeployDiagnostics called with filePath; no
applyDeployDiagnostics without filePath; both fail with
DeployCompletedWithErrorsError).

Keep existing helper-level assertions; add unit-level coverage.
W-22419571

MINOR fixes:
- deployMapper.test: add assertion for errorMessage (mapper sets
  response.errorMessage but no test asserted it)
- README.md: remove stale createFromTemplate() mention (removed from
  public API; only createFromTemplateOwned() remains)
…22419571

Align 11 consumption/architecture docs with the final owned API + ADR-0021: correct owned-type
field lists (DeployOutcome/RetrieveOutcome/ProjectInfo/ComponentSetInfo), mark removed public
getters (describe/getLocalChangesAsComponentSet/createFromTemplate) as removed, document the
bare-entry value-import rule, and clarify hand-authored owned types are the final approach.
…9571

- deployFromOutcome.test: widen runWithMocks requirement channel (deployFromOutcome statically
  requires FsService/ChannelService, both neutralized by jest.mock + the mocked getServicesApi).
- org-browser index.test: add withDefaultOrg + getConnectionData to the ConnectionService mock
  (Phase-B members the service interface now requires).
…e - W-22419571

Phase E.2. Hand-authored import-free OwnedServicesApi (the Promise-returning data-only
subset) published via a new `salesforcedx-vscode-services-types/owned` subpath — only
owned DTOs + ServicesOrg + helpers + ICONS, zero @salesforce/jsforce/effect imports.

Guards:
- test-consumer fixture type-checks the owned surface with types:[] + skipLibCheck:false
  (no SDK/effect resolvable); wired as services-types check:consumer + test, added to the
  root test fan-out so it runs in the full-repo gate.
- ownedServicesApiConformance.ts (src, enforced by real tsc since ts-jest isolatedModules
  cannot reliably evaluate type-level assertions) proves PlainServicesApi is a structural
  superset of OwnedServicesApi on the guarded members, so the type cannot silently drift.

Two members are intentionally excluded from strict conformance (documented divergences):
withDefaultOrg (PromisifiedContract generic-loss; owned keeps Promise<R>) and
createFromTemplateOwned (owned templateType is string, deliberately wider to stay import-free).

Also remove the orphaned `unable_to_retrieve_org_info` i18n key (both locales) left unused
after the apex-replay-debugger getConnection migration; it blocked the whole-workspace lint.
…e - W-22419571

Completes Phase E.2 wiring (the OwnedServicesApi type + fixture landed in df410ec):
- generateEntry.ts emits src/owned.ts (the import-free re-export bundle).
- services-types package.json: ./owned subpath export, check:consumer + test wireit targets.
- root package.json: add salesforcedx-vscode-services-types:test to the test fan-out.
…ervices-data-only

# Conflicts:
#	packages/salesforcedx-vscode-services/src/terminal/terminalService.ts
@peternhale
peternhale requested a review from a team as a code owner June 24, 2026 12:28
@peternhale
peternhale marked this pull request as draft June 24, 2026 12:29
@peternhale
peternhale removed the request for review from RitamAgrawal June 24, 2026 12:29
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
// Hand-authored, services-owned. NO imports from @salesforce/*, jsforce, or effect.
export type OrgChange = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather have these expressed as Effect schema transformations than as TS types.

so that shapes and transforms are expressed together.

[and then export the TS type derived from that]

export type ComponentInfo = {
readonly fullName: string;
readonly type: string;
readonly state?: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"string" ? could probably be more specific than that

*
* @param obj - DescribeMetadataObject from jsforce metadata.describe()
*/
export const toMetadataTypeInfo = (obj: DescribeMetadataObject): MetadataTypeInfo => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, definitely leaning Effect schema after seeing these.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect schema forces consumer to use Effect

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These need to be plain.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, like export the type derived from the effect schema.

/** sfdc-project.json `sfdcLoginUrl`, when set (used by auth flows). */
readonly sfdcLoginUrl?: string;
/** sfdc-project.json `defaultLwcLanguage`, when set (e.g. 'typescript' | 'javascript'). */
readonly defaultLwcLanguage?: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not make the types stricter when the values are known? is there a benefit of "string" ?


const mapSfLogLevel = (level: string): LogLevel.LogLevel => {
switch (level.toLowerCase()) {
// Defensive: config/env sources can yield a non-string at runtime despite the typed contract.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

effect schema!

query: async <T = Record<string, unknown>>(soql: string, opts?: QueryOpts): Promise<OwnedQueryResult<T>> => {
const api = opts?.tooling ? conn.tooling : conn;
const r = await api.query<JSForceRecord>(soql, { autoFetch: opts?.autoFetch, maxFetch: opts?.maxFetch });
// jsforce's `Record[]` is structurally unrelated to the owned generic `T[]` plus differs in mutability, so a double-cast is required at this boundary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

challenge to make it less hacky about types.

import type { Connection } from '@salesforce/core';

/** Wraps a live Connection in the services-owned ServicesOrg facade. The Connection never escapes. */
export const makeServicesOrg = (conn: Connection): ServicesOrg => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's an odd name. this is the queryCrudService?

const workspaceInfo = yield* workspaceService.getWorkspaceInfoOrThrow();

// Compute the conventional paths
const soqlMetadataPath = (yield* getSoqlMetadataPath()).fsPath;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these should be vscode-uri, not string.

otherwise you shove all "safe on web" to the consumer. fsPath and string is bad news

"@salesforce/effect-ext-utils": "*",
"effect": "^3.21.2",
"@salesforce/vscode-i18n": "*",
"salesforcedx-vscode-services": "*",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you do that, all the dependencies of services (sfdx-core, jsforce, stl, sdr) are now in this extension, too.

absolutely not.

);
// OwnedQueryResult is structurally compatible with QueryResult (both have done, totalSize, records)
const executeQuery = async (): Promise<QueryResult<JsonMap>> => {
const result = await api.withDefaultOrg(org =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a good pattern if the result comes back as unknown and then you have to assert that it is something.

@mshanemc mshanemc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to exhaust other options before doing something like this.

a few of these might be desirable

  • crudQuery service (to reduce some of the getConnection use cases)
  • projectInfo (to avoid handing back the full project)

moving all the "foo as bar" into here instead of letting external consumers do it ( as Connection from their jsforce version) doesn't feel any better.

alternative to consider

  • tsconfig tweaks to get rid of the relative paths
  • typeof to derive the types from ex: Connection and re-export
  • going into some of the libraries and exporting new duck-typeable non-class things (ex: I've long wanted to convert SDR's Deploy|Retrieve result to just be an object) for simpler types reimport/export or a ProjectInfo object from sfdx-core that just [zod | EffectSchema]-parses the file into an object

@mshanemc mshanemc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another example would be a ComponentSet that's not a class but just an object you can read.

Some of the new "owned" objects would be better in the actual libraries.

…ervices-data-only

# Conflicts:
#	package-lock.json
#	packages/salesforcedx-vscode-apex-testing/package.json
#	packages/salesforcedx-vscode-metadata/src/shared/retrieve/retrieveOutcome.ts
#	packages/salesforcedx-vscode-services/src/terminal/terminalService.ts
#	packages/salesforcedx-vscode-soql/src/commands/dataQuery.ts
… types

Consumer extensions were incorrectly depending on the full
salesforcedx-vscode-services extension package. They should only depend
on the @salesforce/vscode-services package which provides the API surface
types without pulling in the full extension implementation.

Also added @salesforce/core dependency to apex-testing package which
directly imports from it.

Changes:
- Changed devDependencies from salesforcedx-vscode-services to @salesforce/vscode-services in:
  - salesforcedx-vscode-soql
  - salesforcedx-vscode-apex-oas
  - salesforcedx-vscode-visualforce
  - salesforcedx-vscode-lightning
  - salesforcedx-vscode-apex-replay-debugger
  - salesforcedx-vscode-apex
  - salesforcedx-vscode-apex-log
  - salesforcedx-vscode-org
  - salesforcedx-vscode-lwc

- Changed dependencies from salesforcedx-vscode-services to @salesforce/vscode-services in:
  - salesforcedx-vscode-metadata
  - salesforcedx-vscode-org-browser

- Added @salesforce/core@^8.31.0 to salesforcedx-vscode-apex-testing dependencies
…meout

- Updated retrieveOutcome.test.ts to work with Effect-based retrieveHasErrors
  that now requires RetrieveResult instead of RetrieveOutcome
- Fixed terminalService.test.ts timeout expectation from 120s to 30s to match
  the new default timeout value from the merge
- Added proper Effect layer mocking with ComponentSetService
Eliminate unsafe type assertions in servicesOrgAdapter.ts:

- Add generic constraint `T extends Record<string, unknown>` to query/singleRecordQuery
  to prevent unconstrained generics and pass type parameters through to jsforce
- Remove double-cast `as unknown as T` - now direct assignment with proper generic flow
- Replace `as never` cast with type guard `isHttpMethod` and runtime validation
- Add `Mutable<T>` helper for readonly→mutable conversions at serialization boundaries
- Simplify arrow functions to single-line expressions (arrow-body-style)
- Document remaining type casts as necessary boundary conversions with eslint-disable

Related work item: W-22419571
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants