From 20afd019bc460eda876c46bf309a8cdfd0b93285 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 2 Nov 2026 09:04:20 -0600 Subject: [PATCH 01/10] feat(config): centralize Effect environment settings --- src/config.ts | 240 ++++++++++++++++++ src/git/integrityCheck.ts | 14 +- src/git/lock.ts | 24 +- src/shared/conflicts.ts | 4 +- src/shared/local/localShadowRepo.ts | 65 ++--- src/shared/remote/fileOperations.ts | 11 +- src/shared/remote/remoteSourceTracking.ts | 28 +- src/sourceTracking.ts | 21 +- test/unit/config.test.ts | 78 ++++++ test/unit/git/integrityCheck.test.ts | 24 +- test/unit/git/lock.test.ts | 41 +-- test/unit/git/lockWorker.ts | 8 +- test/unit/localDetectMovedFiles.test.ts | 80 +++--- test/unit/remote/fileOperations.test.ts | 16 +- test/unit/remote/remoteSourceTracking.test.ts | 119 +++++---- 15 files changed, 541 insertions(+), 232 deletions(-) create mode 100644 src/config.ts create mode 100644 test/unit/config.test.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 00000000..b14c67a6 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,240 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as Config from 'effect/Config'; +import * as ConfigProvider from 'effect/ConfigProvider'; +import * as Context from 'effect/Context'; +import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; +import * as Option from 'effect/Option'; +import * as Schema from 'effect/Schema'; + +const DEFAULT_FD_PERMITS = process.platform === 'win32' ? 64 : 256; +const DEFAULT_LOCK_TIMEOUT_SECONDS = 600; +const DEFAULT_LOCK_AUTOCLEAR_SECONDS = 300; + +const RawConfig = Config.all({ + fdPermits: Config.option(Config.String('SF_SOURCE_TRACKING_FD_PERMITS')), + batchSize: Config.option(Config.String('SF_SOURCE_TRACKING_BATCH_SIZE')), + legacyBatchSize: Config.option(Config.String('SFDX_SOURCE_TRACKING_BATCH_SIZE')), + disableSourceMobility: Config.option(Config.String('SF_DISABLE_SOURCE_MOBILITY')), + assumeSynced: Config.option(Config.String('SF_SOURCE_TRACKING_ASSUME_SYNCED')), + lockTimeoutSeconds: Config.option(Config.String('SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS')), + lockAutoclearSeconds: Config.option(Config.String('SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS')), + disableSourceMemberPolling: Config.option(Config.String('SF_DISABLE_SOURCE_MEMBER_POLLING')), + legacyDisableSourceMemberPolling: Config.option(Config.String('SFDX_DISABLE_SOURCE_MEMBER_POLLING')), + sourceMemberPollingTimeout: Config.option(Config.String('SF_SOURCE_MEMBER_POLLING_TIMEOUT')), + legacySourceMemberPollingTimeout: Config.option(Config.String('SFDX_SOURCE_MEMBER_POLLING_TIMEOUT')), + fileVersion: Config.option(Config.String('SF_SOURCE_TRACKING_FILE_VERSION')), + integrityCheck: Config.option(Config.String('SF_SOURCE_TRACKING_INTEGRITY_CHECK')), +}); + +export type SourceTrackingSettings = { + readonly fdPermits: number; + readonly disableSourceMobility: boolean; + readonly assumeSynced: boolean; + readonly lockTimeoutSeconds: number; + readonly lockAutoclearSeconds: number; + readonly disableSourceMemberPolling: boolean; + readonly sourceMemberPollingTimeout?: number; + readonly fileVersion: number; + readonly integrityCheck: boolean; +}; + +export const DEFAULT_SOURCE_TRACKING_SETTINGS: SourceTrackingSettings = { + fdPermits: DEFAULT_FD_PERMITS, + disableSourceMobility: false, + assumeSynced: false, + lockTimeoutSeconds: DEFAULT_LOCK_TIMEOUT_SECONDS, + lockAutoclearSeconds: DEFAULT_LOCK_AUTOCLEAR_SECONDS, + disableSourceMemberPolling: false, + fileVersion: 0, + integrityCheck: false, +}; + +export const SourceTrackingConfig = Context.Reference('@salesforce/source-tracking/config', { + defaultValue: () => DEFAULT_SOURCE_TRACKING_SETTINGS, +}); + +const positiveNumber = Schema.Number.check(Schema.isFinite(), Schema.isGreaterThan(0)); +const positiveInteger = Schema.Int.check(Schema.isGreaterThan(0)); +const nonNegativeInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +const kitNumber = (raw: string): number => + // Kit's toNumber accepts JavaScript numeric forms (including hex and Infinity). + // Empty strings are treated as missing by Env.getNumber. + Number(raw); +const readNumber = ( + name: string, + value: Option.Option, + schema: Schema.ConstraintCodec, + fallback: A +): Effect.Effect => + Option.match(value, { + onNone: () => Effect.succeed(fallback), + onSome: (raw) => { + const parsed = raw === '' ? Number.NaN : kitNumber(raw); + const decoded = Schema.decodeUnknownOption(schema)(parsed); + return Option.match(decoded, { + onSome: Effect.succeed, + onNone: () => + Effect.logWarning(`${name}=${JSON.stringify(raw)} is invalid; using ${String(fallback)}`).pipe( + Effect.as(fallback) + ), + }); + }, + }); + +const readOptionalNumber = (name: string, value: Option.Option): Effect.Effect => + Option.match(value, { + onNone: () => Effect.sync(() => undefined), + onSome: (raw) => { + const parsed = raw === '' ? Number.NaN : kitNumber(raw); + const decoded = Schema.decodeUnknownOption(positiveNumber)(parsed); + return Option.match(decoded, { + onSome: (seconds) => Effect.succeed(seconds), + onNone: () => + Effect.logWarning(`${name}=${JSON.stringify(raw)} is invalid; ignoring it`).pipe(Effect.as(undefined)), + }); + }, + }); + +const kitBoolean = (name: string, value: Option.Option): Effect.Effect => + Option.match(value, { + onNone: () => Effect.succeed(false), + onSome: (raw) => { + const normalized = raw.toLowerCase(); + if (normalized === 'true' || raw === '1') return Effect.succeed(true); + if (normalized === 'false' || raw === '0') return Effect.succeed(false); + return Effect.logWarning(`${name}=${JSON.stringify(raw)} is invalid; using false`).pipe(Effect.as(false)); + }, + }); + +export const loadSourceTrackingSettings = ( + provider: ConfigProvider.ConfigProvider = ConfigProvider.fromEnv() +): Effect.Effect => + Effect.gen(function* () { + const raw = yield* RawConfig.parse(provider); + const disablePollingRaw = Option.orElse(raw.disableSourceMemberPolling, () => raw.legacyDisableSourceMemberPolling); + const pollingTimeoutRaw = Option.orElse(raw.sourceMemberPollingTimeout, () => raw.legacySourceMemberPollingTimeout); + if (Option.isNone(raw.disableSourceMemberPolling) && Option.isSome(raw.legacyDisableSourceMemberPolling)) { + yield* Effect.logWarning( + 'SFDX_DISABLE_SOURCE_MEMBER_POLLING is deprecated; use SF_DISABLE_SOURCE_MEMBER_POLLING.' + ); + } + if (Option.isNone(raw.sourceMemberPollingTimeout) && Option.isSome(raw.legacySourceMemberPollingTimeout)) { + yield* Effect.logWarning( + 'SFDX_SOURCE_MEMBER_POLLING_TIMEOUT is deprecated; use SF_SOURCE_MEMBER_POLLING_TIMEOUT.' + ); + } + const legacyBatchSize = Option.orElse(raw.batchSize, () => raw.legacyBatchSize); + const legacyBatchName = Option.isSome(raw.batchSize) + ? 'SF_SOURCE_TRACKING_BATCH_SIZE' + : 'SFDX_SOURCE_TRACKING_BATCH_SIZE'; + const fdPermitsRaw = Option.isSome(raw.fdPermits) ? raw.fdPermits : legacyBatchSize; + if (Option.isSome(fdPermitsRaw) && Option.isNone(raw.fdPermits)) { + yield* Effect.logWarning(`${legacyBatchName} is deprecated; use SF_SOURCE_TRACKING_FD_PERMITS.`); + } + if (Option.isSome(raw.fdPermits) && Option.isSome(legacyBatchSize)) { + yield* Effect.logWarning(`${legacyBatchName} is ignored because SF_SOURCE_TRACKING_FD_PERMITS is set.`); + } + const [ + rawFdPermits, + lockTimeoutSeconds, + lockAutoclearSeconds, + sourceMemberPollingTimeout, + fileVersion, + disableSourceMobility, + assumeSynced, + disableSourceMemberPolling, + integrityCheck, + ] = yield* Effect.all([ + readNumber( + Option.isSome(raw.fdPermits) ? 'SF_SOURCE_TRACKING_FD_PERMITS' : legacyBatchName, + fdPermitsRaw, + positiveInteger, + DEFAULT_FD_PERMITS + ), + readNumber( + 'SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS', + raw.lockTimeoutSeconds, + positiveNumber, + DEFAULT_LOCK_TIMEOUT_SECONDS + ), + readNumber( + 'SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS', + raw.lockAutoclearSeconds, + positiveNumber, + DEFAULT_LOCK_AUTOCLEAR_SECONDS + ), + readOptionalNumber( + Option.isSome(raw.sourceMemberPollingTimeout) + ? 'SF_SOURCE_MEMBER_POLLING_TIMEOUT' + : 'SFDX_SOURCE_MEMBER_POLLING_TIMEOUT', + pollingTimeoutRaw + ), + readNumber('SF_SOURCE_TRACKING_FILE_VERSION', raw.fileVersion, nonNegativeInteger, 0), + kitBoolean('SF_DISABLE_SOURCE_MOBILITY', raw.disableSourceMobility), + kitBoolean('SF_SOURCE_TRACKING_ASSUME_SYNCED', raw.assumeSynced), + kitBoolean( + Option.isSome(raw.disableSourceMemberPolling) + ? 'SF_DISABLE_SOURCE_MEMBER_POLLING' + : 'SFDX_DISABLE_SOURCE_MEMBER_POLLING', + disablePollingRaw + ), + kitBoolean('SF_SOURCE_TRACKING_INTEGRITY_CHECK', raw.integrityCheck), + ]); + // Prior batch-size values can exceed safe concurrent file descriptors by + // orders of magnitude. Keep smaller explicit values as useful limits. + const fdPermits = Option.isSome(raw.fdPermits) + ? rawFdPermits + : Option.isSome(fdPermitsRaw) + ? Math.min(rawFdPermits, DEFAULT_FD_PERMITS) + : rawFdPermits; + + return { + fdPermits, + disableSourceMobility, + assumeSynced, + lockTimeoutSeconds, + lockAutoclearSeconds, + disableSourceMemberPolling, + ...(sourceMemberPollingTimeout === undefined ? {} : { sourceMemberPollingTimeout }), + fileVersion, + integrityCheck, + } satisfies SourceTrackingSettings; + }); + +let processSettings: Promise | undefined; + +/** Resolve one immutable environment snapshot for this Node process. */ +export const getProcessSourceTrackingSettings = (): Promise => + (processSettings ??= Effect.runPromise(loadSourceTrackingSettings().pipe(Effect.orDie))); + +export const sourceTrackingConfigLayer = (settings: SourceTrackingSettings): Layer.Layer => + Layer.succeed(SourceTrackingConfig, settings); + +export const sourceTrackingConfigLayerFromProvider = ( + provider: ConfigProvider.ConfigProvider +): Layer.Layer => + Layer.effect(SourceTrackingConfig, loadSourceTrackingSettings(provider)); + +/** Run Effect code with the process-wide settings snapshot; tests may provide a different service directly. */ +export const provideProcessSourceTrackingConfig = (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.provideServiceEffect(SourceTrackingConfig, Effect.promise(getProcessSourceTrackingSettings))); + +export const runWithProcessSourceTrackingConfig = (effect: Effect.Effect): Promise => + provideProcessSourceTrackingConfig(effect).pipe(Effect.orDie, Effect.runPromise); diff --git a/src/git/integrityCheck.ts b/src/git/integrityCheck.ts index 9f66dcd7..21f99d66 100644 --- a/src/git/integrityCheck.ts +++ b/src/git/integrityCheck.ts @@ -13,17 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import * as Config from 'effect/Config'; -import * as ConfigProvider from 'effect/ConfigProvider'; import * as Effect from 'effect/Effect'; import * as Schema from 'effect/Schema'; +import { SourceTrackingConfig } from '../config.js'; import { IndexCorruptError } from './errors.js'; import { readIndex } from './indexV2.js'; import { readDirectRef, readHead } from './refs.js'; import { RefName } from './schemas.js'; -const ENV_FLAG = 'SF_SOURCE_TRACKING_INTEGRITY_CHECK'; - /** * Optional startup integrity check, gated by SF_SOURCE_TRACKING_INTEGRITY_CHECK * during the dogfood window per phase 13. Verifies that .git/HEAD parses @@ -34,14 +31,7 @@ const ENV_FLAG = 'SF_SOURCE_TRACKING_INTEGRITY_CHECK'; * is set. Fails closed with IndexCorruptError so the caller can prompt the * user to delete + re-init the shadow. */ -export const isEnabled = () => - Config.String(ENV_FLAG) - .pipe( - Config.withDefault('false'), - Config.map((value) => value === 'true') - ) - .parse(ConfigProvider.fromEnv()) - .pipe(Effect.catchTag('ConfigError', () => Effect.succeed(false))); +export const isEnabled = Effect.map(SourceTrackingConfig, (config) => config.integrityCheck); const MAIN_REF: RefName = Schema.decodeUnknownSync(RefName)('refs/heads/main'); diff --git a/src/git/lock.ts b/src/git/lock.ts index 57f4970a..5a86f409 100644 --- a/src/git/lock.ts +++ b/src/git/lock.ts @@ -14,8 +14,6 @@ * limitations under the License. */ import * as Clock from 'effect/Clock'; -import * as Config from 'effect/Config'; -import * as ConfigProvider from 'effect/ConfigProvider'; import * as Duration from 'effect/Duration'; import * as Effect from 'effect/Effect'; import * as Exit from 'effect/Exit'; @@ -24,22 +22,9 @@ import * as Schedule from 'effect/Schedule'; import { FileSystem } from 'effect/FileSystem'; import { Path } from 'effect/Path'; import type { PlatformError } from 'effect/PlatformError'; +import { SourceTrackingConfig } from '../config.js'; import { RepoLockedError } from './errors.js'; -const DEFAULT_TIMEOUT = Duration.seconds(600); // 10 minutes -const DEFAULT_AUTOCLEAR = Duration.seconds(300); // 5 minutes -const ENV_TIMEOUT = 'SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS'; -const ENV_AUTOCLEAR = 'SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS'; - -const envDurationSeconds = (name: string, fallback: Duration.Duration) => - Config.Number(name) - .pipe(Config.withDefault(Duration.toSeconds(fallback))) - .parse(ConfigProvider.fromEnv()) - .pipe( - Effect.map((seconds) => (Number.isFinite(seconds) && seconds > 0 ? Duration.seconds(seconds) : fallback)), - Effect.catchTag('ConfigError', () => Effect.succeed(fallback)) - ); - const statSentinel = Symbol('stat-not-found'); const humanReadable = (duration: Duration.Duration): string => Duration.format(duration); @@ -66,12 +51,11 @@ const acquireLock = Effect.fn('acquireLock')(function* ( ) { const fs = yield* FileSystem; const path = yield* Path; + const config = yield* SourceTrackingConfig; const lockPath = path.join(gitdir, 'index.lock'); const indexPath = path.join(gitdir, 'index'); - const [timeout, autoclear] = yield* Effect.all([ - envDurationSeconds(ENV_TIMEOUT, DEFAULT_TIMEOUT), - envDurationSeconds(ENV_AUTOCLEAR, DEFAULT_AUTOCLEAR), - ]); + const timeout = Duration.seconds(config.lockTimeoutSeconds); + const autoclear = Duration.seconds(config.lockAutoclearSeconds); const timeoutMs = Duration.toMillis(timeout); const autoclearMs = Duration.toMillis(autoclear); const startMs = yield* Clock.currentTimeMillis; diff --git a/src/shared/conflicts.ts b/src/shared/conflicts.ts index 898b86e4..27052565 100644 --- a/src/shared/conflicts.ts +++ b/src/shared/conflicts.ts @@ -15,7 +15,7 @@ */ import { resolve } from 'node:path'; import { type ComponentSet, type ForceIgnore, type RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import * as Effect from 'effect/Effect'; +import { runWithProcessSourceTrackingConfig } from '../config.js'; import { type ConflictResponse, type ChangeResult, SourceConflictError } from './types.js'; import { getMetadataKey } from './functions.js'; import { populateTypesAndNames } from './populateTypesAndNames.js'; @@ -76,7 +76,7 @@ export const getDedupedConflictsFromChanges = async ({ ); return ( - await Effect.runPromise( + await runWithProcessSourceTrackingConfig( populateTypesAndNames({ excludeUnresolvable: true, projectPath, forceIgnore, registry })(localChanges) ) ) diff --git a/src/shared/local/localShadowRepo.ts b/src/shared/local/localShadowRepo.ts index 9a14420f..f4a8e0ca 100644 --- a/src/shared/local/localShadowRepo.ts +++ b/src/shared/local/localShadowRepo.ts @@ -23,8 +23,6 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem'; import * as NodePath from '@effect/platform-node/NodePath'; import * as Effect from 'effect/Effect'; import * as Chunk from 'effect/Chunk'; -import * as Config from 'effect/Config'; -import * as ConfigProvider from 'effect/ConfigProvider'; import * as HashSet from 'effect/HashSet'; import * as Layer from 'effect/Layer'; import * as ManagedRuntime from 'effect/ManagedRuntime'; @@ -38,6 +36,12 @@ import { Repo } from '../../git/repo.js'; import { repoErrorToSfError } from '../../git/sfCompatibility.js'; import { Author as AuthorSchema, RepoPath as RepoPathSchema, type RepoPath, RepoConfig } from '../../git/schemas.js'; import { excludeLwcLocalOnlyTest, folderContainsPath } from '../functions.js'; +import { + getProcessSourceTrackingSettings, + SourceTrackingConfig, + sourceTrackingConfigLayer, + type SourceTrackingSettings, +} from '../../config.js'; import { getMatches, ensurePosix, IS_WINDOWS } from './functions.js'; import { filenameMatchesToMap } from './moveDetection.js'; import type { LocalChanges, ShadowRepoOptions, SyncRequest } from './types.js'; @@ -53,24 +57,6 @@ const SHADOW_AUTHOR = Schema.decodeUnknownSync(AuthorSchema)({ const EMPTY_CHANGES: LocalChanges = { added: [], modified: [], deleted: [] }; -const FD_PERMITS_ENV = 'SF_SOURCE_TRACKING_FD_PERMITS'; -const FD_PERMITS_FALLBACK = IS_WINDOWS ? 64 : 256; - -const readNumberEnv = (name: string, fallback: number) => - Config.Number(name) - .pipe(Config.withDefault(fallback)) - .parse(ConfigProvider.fromEnv()) - .pipe(Effect.catchTag('ConfigError', () => Effect.succeed(fallback))); - -const readBooleanEnv = (name: string) => - Config.String(name) - .pipe( - Config.withDefault('false'), - Config.map((value) => value.toLowerCase() === 'true' || value === '1') - ) - .parse(ConfigProvider.fromEnv()) - .pipe(Effect.catchTag('ConfigError', () => Effect.succeed(false))); - const buildExclude = (packageDirs: readonly string[]): string => // .git/info/exclude is gitignore-syntax. Lite's status walk also applies the // package-root and Salesforce-specific exclusions in liteFileFilter below. @@ -115,19 +101,13 @@ type ApplyChangesArgs = { readonly message?: string; }; -const makeRepoConfig = (options: ShadowRepoOptions) => - readNumberEnv(FD_PERMITS_ENV, FD_PERMITS_FALLBACK).pipe( - Effect.map((fdPermits) => - Schema.decodeUnknownSync(RepoConfig)({ - dir: options.projectPath, - gitdir: getGitDir(options.orgId, options.projectPath), - roots: options.packageDirs.map((directory) => - ensurePosix(path.relative(options.projectPath, directory.fullPath)) - ), - fdPermits, - }) - ) - ); +const makeRepoConfig = (options: ShadowRepoOptions, settings: SourceTrackingSettings) => + Schema.decodeUnknownSync(RepoConfig)({ + dir: options.projectPath, + gitdir: getGitDir(options.orgId, options.projectPath), + roots: options.packageDirs.map((directory) => ensurePosix(path.relative(options.projectPath, directory.fullPath))), + fdPermits: settings.fdPermits, + }); /** Build one org's local-tracking operations. Git state and the status cache live in this closure. */ const makeShadowRepo = Effect.fn('ShadowRepo.make')(function* ( @@ -137,6 +117,7 @@ const makeShadowRepo = Effect.fn('ShadowRepo.make')(function* ( ) { const repo = yield* Repo; const fs = yield* FileSystem; + const settings = yield* SourceTrackingConfig; const { gitdir: gitDir, roots: packageDirs, fdPermits } = cfg; const fileFilter = liteFileFilter(packageDirs); const statusCache = yield* Ref.make>(Option.none()); @@ -215,7 +196,7 @@ const makeShadowRepo = Effect.fn('ShadowRepo.make')(function* ( // behavior: a later non-forced read does not repeat a failed detection. yield* Ref.set(statusCache, Option.some(changes)); - const movesDisabled = yield* readBooleanEnv('SF_DISABLE_SOURCE_MOBILITY'); + const movesDisabled = settings.disableSourceMobility; yield* Effect.promise(() => Lifecycle.getInstance().emitTelemetry({ eventName: movesDisabled ? 'moveFileDetectionDisabled' : 'moveFileDetectionEnabled', @@ -236,7 +217,7 @@ const makeShadowRepo = Effect.fn('ShadowRepo.make')(function* ( const getStatus = Effect.fn('ShadowRepo.getStatus')(function* (refresh = false) { yield* Effect.sync(() => logger.trace(`lite start: getStatus (refresh = ${refresh})`)); const cached = yield* Ref.get(statusCache); - if (yield* readBooleanEnv('SF_SOURCE_TRACKING_ASSUME_SYNCED')) { + if (settings.assumeSynced) { const shouldWarn = Option.isNone(cached) || cached.value.added.length + cached.value.modified.length + cached.value.deleted.length > 0; @@ -328,11 +309,19 @@ const runEffect = ( ); /** Promise adapter for SourceTracking and CLI callers; the implementation above stays Effect-native. */ -export const createShadowRepo = async (options: ShadowRepoOptions): Promise => { +export const createShadowRepo = async ( + options: ShadowRepoOptions, + testSettings?: SourceTrackingSettings +): Promise => { const logger = await Logger.child('ShadowRepo'); - const cfg = await Effect.runPromise(makeRepoConfig(options)); + const settings = testSettings ?? (await getProcessSourceTrackingSettings()); + const cfg = makeRepoConfig(options, settings); const runtime = ManagedRuntime.make( - Repo.layer(cfg).pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, cryptoLayer))) + Repo.layer(cfg).pipe( + Layer.provideMerge( + Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, cryptoLayer, sourceTrackingConfigLayer(settings)) + ) + ) ); const effects = await runEffect(runtime, logger, 'init', makeShadowRepo(options, logger, cfg)); const getStatus = (refresh = false): Promise => diff --git a/src/shared/remote/fileOperations.ts b/src/shared/remote/fileOperations.ts index 28fd5b22..ceb88cfb 100644 --- a/src/shared/remote/fileOperations.ts +++ b/src/shared/remote/fileOperations.ts @@ -17,8 +17,6 @@ import path from 'node:path'; import fs from 'graceful-fs'; import { lockInit, Logger, SfError } from '@salesforce/core'; -import * as Config from 'effect/Config'; -import * as ConfigProvider from 'effect/ConfigProvider'; import * as Effect from 'effect/Effect'; import { isNotUndefined } from 'effect/Predicate'; import * as Schema from 'effect/Schema'; @@ -29,11 +27,11 @@ import { getMetadataTypeFromLegacyKey, } from '../functions.js'; import { type RemoteChangeElement } from '../types.js'; +import { SourceTrackingConfig } from '../../config.js'; import { ContentsJsonSchema } from './types.js'; import type { ContentsV0, ContentsV1, MemberRevision, MemberRevisionLegacy } from './types.js'; const FILENAME = 'maxRevision.json'; -const FILE_VERSION_ENV = 'SF_SOURCE_TRACKING_FILE_VERSION'; export type RemoteTrackingIdentity = { projectPath: string; @@ -121,11 +119,8 @@ export const writeTrackingFile = Effect.fnUntraced(function* ({ /** Reset replaces the snapshot instead of merging it with the previous contents. */ replace?: boolean; }) { - const provider = ConfigProvider.fromEnv(); - const currentFileVersion = yield* Config.Number(FILE_VERSION_ENV) - .pipe(Config.withDefault(0)) - .parse(provider) - .pipe(Effect.orDie); + const config = yield* SourceTrackingConfig; + const currentFileVersion = config.fileVersion; const incoming: ContentsV1 = { fileVersion: 1, diff --git a/src/shared/remote/remoteSourceTracking.ts b/src/shared/remote/remoteSourceTracking.ts index 218e6aaa..f313b91d 100644 --- a/src/shared/remote/remoteSourceTracking.ts +++ b/src/shared/remote/remoteSourceTracking.ts @@ -20,8 +20,6 @@ import fs from 'graceful-fs'; import { Logger, Lifecycle, Messages, type Org, SfError } from '@salesforce/core'; import * as Cache from 'effect/Cache'; import * as Chunk from 'effect/Chunk'; -import * as Config from 'effect/Config'; -import * as ConfigProvider from 'effect/ConfigProvider'; import * as Duration from 'effect/Duration'; import * as Effect from 'effect/Effect'; import * as Exit from 'effect/Exit'; @@ -41,6 +39,7 @@ import { } from '../types.js'; import { getMetadataKeyFromFileResponse, getMappingsForSourceMemberTypesToMetadataType } from '../metadataKeys.js'; import { getMetadataKey, TYPES_WITH_SOURCEMEMBER_NAME_MISMATCH } from '../functions.js'; +import { SourceTrackingConfig } from '../../config.js'; import { calculateExpectedSourceMembers } from './expectedSourceMembers.js'; import type { MemberRevision, PinoLogger, SourceMember } from './types.js'; import { @@ -64,8 +63,6 @@ import { } from './remoteTrackingState.js'; const POLLING_DELAY_MS = 1000; -const SOURCE_MEMBER_POLLING_TIMEOUT = 'SF_SOURCE_MEMBER_POLLING_TIMEOUT'; -const DISABLE_SOURCE_MEMBER_POLLING = 'SF_DISABLE_SOURCE_MEMBER_POLLING'; const DEFAULT_EMPTY_POLLING_RESULT_LIMIT = 120; export type RemoteTrackingOptions = { @@ -300,14 +297,8 @@ const pollForSourceTracking = Effect.fn('pollForSourceTracking')(function* ({ state: RemoteTrackingState; logger: PinoLogger; }) { - const pollingDisabled = yield* Config.String(DISABLE_SOURCE_MEMBER_POLLING) - .pipe( - Config.withDefault('false'), - Config.map((value) => value.toLowerCase() === 'true' || value === '1') - ) - .parse(ConfigProvider.fromEnv()) - .pipe(Effect.catchTag('ConfigError', () => Effect.succeed(false))); - if (pollingDisabled) { + const config = yield* SourceTrackingConfig; + if (config.disableSourceMemberPolling) { logger.warn('Not polling for SourceMembers since SF_DISABLE_SOURCE_MEMBER_POLLING = true.'); return; } @@ -315,14 +306,11 @@ const pollForSourceTracking = Effect.fn('pollForSourceTracking')(function* ({ const expectedSourceMembers = calculateExpectedSourceMembers(registry, elements); const originalOutstandingSize = expectedSourceMembers.size; - const timeoutOverride = yield* Config.option(Config.Number(SOURCE_MEMBER_POLLING_TIMEOUT)) - .parse(ConfigProvider.fromEnv()) - .pipe(Effect.catchTag('ConfigError', () => Effect.succeed(Option.none()))); - const overriddenTimeout = Option.getOrElse(timeoutOverride, () => 0); - const consecutiveEmptyPollLimit = Option.getOrElse( - Option.map(timeoutOverride, (seconds) => seconds / Duration.toSeconds(Duration.millis(POLLING_DELAY_MS))), - () => DEFAULT_EMPTY_POLLING_RESULT_LIMIT - ); + const overriddenTimeout = config.sourceMemberPollingTimeout ?? 0; + const consecutiveEmptyPollLimit = + config.sourceMemberPollingTimeout === undefined + ? DEFAULT_EMPTY_POLLING_RESULT_LIMIT + : config.sourceMemberPollingTimeout / Duration.toSeconds(Duration.millis(POLLING_DELAY_MS)); const pollingTimeout = calculateTimeout(logger)(expectedSourceMembers.size, overriddenTimeout); const pollState = yield* Ref.make({ highestRevisionSoFar: state.serverMaxRevisionCounter, diff --git a/src/sourceTracking.ts b/src/sourceTracking.ts index 0e3c7083..212351d2 100644 --- a/src/sourceTracking.ts +++ b/src/sourceTracking.ts @@ -41,6 +41,7 @@ import * as Schema from 'effect/Schema'; import { filePathsFromMetadataComponent } from '@salesforce/source-deploy-retrieve/lib/src/utils/filePathGenerator.js'; import * as Effect from 'effect/Effect'; import { isNotUndefined } from 'effect/Predicate'; +import { runWithProcessSourceTrackingConfig } from './config.js'; import { deleteRemoteTracking, initializeRemoteTracking, @@ -342,7 +343,7 @@ export class SourceTracking extends AsyncCreatable { if (options?.origin === 'remote') { await this.ensureRemoteTracking(); - const remoteChanges = await Effect.runPromise( + const remoteChanges = await runWithProcessSourceTrackingConfig( retrieveRemoteChanges({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -522,7 +523,7 @@ export class SourceTracking extends AsyncCreatable { */ public async updateRemoteTracking(fileResponses: RemoteSyncInput[], skipPolling = false): Promise { await this.ensureRemoteTracking(false); - await Effect.runPromise( + await runWithProcessSourceTrackingConfig( updateRemoteTrackingEffect({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -535,7 +536,7 @@ export class SourceTracking extends AsyncCreatable { public async reReadRemoteTracking(): Promise { await this.ensureRemoteTracking(false); - await Effect.runPromise( + await runWithProcessSourceTrackingConfig( retrieveRemoteChanges({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -584,18 +585,18 @@ export class SourceTracking extends AsyncCreatable { * Useful before parallel operations */ public async ensureRemoteTracking(initializeWithQuery = false): Promise { - this.remoteTrackingInitialization ??= Effect.runPromise( + this.remoteTrackingInitialization ??= runWithProcessSourceTrackingConfig( initializeRemoteTracking({ org: this.org, identity: this.remoteTrackingIdentity() }) ).catch((error: unknown) => { this.remoteTrackingInitialization = undefined; throw error; }); await this.remoteTrackingInitialization; - this.remoteTrackingCaches ??= await Effect.runPromise( + this.remoteTrackingCaches ??= await runWithProcessSourceTrackingConfig( makeRemoteTrackingCaches({ org: this.org, identity: this.remoteTrackingIdentity() }) ); if (initializeWithQuery) { - await Effect.runPromise( + await runWithProcessSourceTrackingConfig( retrieveRemoteChanges({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -635,7 +636,7 @@ export class SourceTracking extends AsyncCreatable { * Deletes the remote tracking files */ public async clearRemoteTracking(): Promise { - const filePath = await Effect.runPromise(deleteRemoteTracking(this.remoteTrackingIdentity())); + const filePath = await runWithProcessSourceTrackingConfig(deleteRemoteTracking(this.remoteTrackingIdentity())); this.remoteTrackingInitialization = undefined; this.remoteTrackingCaches = undefined; return filePath; @@ -646,7 +647,7 @@ export class SourceTracking extends AsyncCreatable { */ public async resetRemoteTracking(serverRevision?: number): Promise { await this.ensureRemoteTracking(); - const resetMembers = await Effect.runPromise( + const resetMembers = await runWithProcessSourceTrackingConfig( resetRemoteTrackingEffect({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -655,7 +656,7 @@ export class SourceTracking extends AsyncCreatable { }) ); if (this.remoteTrackingCaches) { - await Effect.runPromise(this.remoteTrackingCaches.sourceMembers.pipe(Cache.invalidateAll)); + await runWithProcessSourceTrackingConfig(this.remoteTrackingCaches.sourceMembers.pipe(Cache.invalidateAll)); } return resetMembers.length; } @@ -823,7 +824,7 @@ export class SourceTracking extends AsyncCreatable { const toOutput = localChangesToOutputRow(this.logger)(this.forceIgnore); // Resolve adds/modifies from the filesystem and deletes from a virtual tree. - const [resolvedAdds, resolvedModifies, resolvedDeletes] = await Effect.runPromise( + const [resolvedAdds, resolvedModifies, resolvedDeletes] = await runWithProcessSourceTrackingConfig( Effect.all( [ populateTypesAndNames(base)(adds), diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts new file mode 100644 index 00000000..8b175b74 --- /dev/null +++ b/test/unit/config.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import * as ConfigProvider from 'effect/ConfigProvider'; +import * as Effect from 'effect/Effect'; +import * as Logger from 'effect/Logger'; +import { loadSourceTrackingSettings } from '../../src/config.js'; + +describe('source tracking config', () => { + it('uses defaults and Kit-compatible boolean parsing', async () => { + const provider = ConfigProvider.fromEnvRecord({ + SF_DISABLE_SOURCE_MOBILITY: 'TrUe', + SF_SOURCE_TRACKING_ASSUME_SYNCED: '1', + SF_DISABLE_SOURCE_MEMBER_POLLING: 'yes', + SF_SOURCE_TRACKING_INTEGRITY_CHECK: 'TRUE', + }); + const settings = await loadSourceTrackingSettings(provider).pipe(Effect.runPromise); + + expect(settings).to.include({ + disableSourceMobility: true, + assumeSynced: true, + disableSourceMemberPolling: false, + integrityCheck: true, + fileVersion: 0, + lockTimeoutSeconds: 600, + }); + }); + + it('uses validated aliases and warns on deprecated batch-size variables', async () => { + const warnings: string[] = []; + const logger = Logger.make((entry) => { + if (entry.logLevel === 'Warn') warnings.push(String(entry.message)); + }); + const settings = await loadSourceTrackingSettings( + ConfigProvider.fromEnvRecord({ SFDX_SOURCE_TRACKING_BATCH_SIZE: '2000' }) + ).pipe(Effect.provide(Logger.layer([logger])), Effect.runPromise); + + expect(settings.fdPermits).to.equal(process.platform === 'win32' ? 64 : 256); + expect(warnings).to.have.length(1); + expect(warnings[0]).to.contain('SFDX_SOURCE_TRACKING_BATCH_SIZE is deprecated'); + }); + + it('warns and falls back on invalid values; canonical batch config wins', async () => { + const warnings: string[] = []; + const logger = Logger.make((entry) => { + if (entry.logLevel === 'Warn') warnings.push(String(entry.message)); + }); + const provider = ConfigProvider.fromEnvRecord({ + SF_SOURCE_TRACKING_FD_PERMITS: '0', + SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS: 'NaN', + SF_SOURCE_TRACKING_BATCH_SIZE: '100', + SFDX_SOURCE_TRACKING_BATCH_SIZE: '200', + SF_SOURCE_TRACKING_FILE_VERSION: '-1', + }); + const settings = await loadSourceTrackingSettings(provider).pipe( + Effect.provide(Logger.layer([logger])), + Effect.runPromise + ); + + expect(settings).to.include({ fdPermits: process.platform === 'win32' ? 64 : 256, lockTimeoutSeconds: 600 }); + expect(warnings).to.have.length(4); + expect(warnings.join('\n')).to.contain('SF_SOURCE_TRACKING_BATCH_SIZE is ignored'); + }); +}); diff --git a/test/unit/git/integrityCheck.test.ts b/test/unit/git/integrityCheck.test.ts index c1298e27..a9afb962 100644 --- a/test/unit/git/integrityCheck.test.ts +++ b/test/unit/git/integrityCheck.test.ts @@ -29,6 +29,7 @@ import { applyChanges } from '../../../src/git/applyChanges.js'; import { init } from '../../../src/git/init.js'; import { isEnabled, runIntegrityCheck } from '../../../src/git/integrityCheck.js'; import { type Author, RepoPath, type RepoConfig } from '../../../src/git/schemas.js'; +import { DEFAULT_SOURCE_TRACKING_SETTINGS, SourceTrackingConfig } from '../../../src/config.js'; const Layered = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, NodeCrypto.layer); const FIXED_TS = Date.parse('2026-01-01T00:00:00Z'); @@ -44,17 +45,24 @@ const cfg = (dir: string, gitdir: string): RepoConfig => ({ describe('git/integrityCheck (phase 13)', () => { describe('isEnabled', () => { it('default off', async () => { - delete process.env.SF_SOURCE_TRACKING_INTEGRITY_CHECK; - expect(await Effect.runPromise(isEnabled())).to.equal(false); + expect( + await Effect.runPromise( + isEnabled.pipe(Effect.provideService(SourceTrackingConfig, DEFAULT_SOURCE_TRACKING_SETTINGS)) + ) + ).to.equal(false); }); it('on for "true"', async () => { - process.env.SF_SOURCE_TRACKING_INTEGRITY_CHECK = 'true'; - try { - expect(await Effect.runPromise(isEnabled())).to.equal(true); - } finally { - delete process.env.SF_SOURCE_TRACKING_INTEGRITY_CHECK; - } + expect( + await Effect.runPromise( + isEnabled.pipe( + Effect.provideService(SourceTrackingConfig, { + ...DEFAULT_SOURCE_TRACKING_SETTINGS, + integrityCheck: true, + }) + ) + ) + ).to.equal(true); }); }); diff --git a/test/unit/git/lock.test.ts b/test/unit/git/lock.test.ts index 3940f65f..002ae02c 100644 --- a/test/unit/git/lock.test.ts +++ b/test/unit/git/lock.test.ts @@ -19,7 +19,6 @@ import * as os from 'node:os'; import { spawn, type ChildProcess } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { expect } from 'chai'; -import sinon from 'sinon'; import * as Effect from 'effect/Effect'; import * as Deferred from 'effect/Deferred'; import * as Layer from 'effect/Layer'; @@ -34,6 +33,7 @@ import { FileSystem } from 'effect/FileSystem'; import { type Path } from 'effect/Path'; import { withIndexLock, withIndexLockCtx } from '../../../src/git/lock.js'; import { RepoLockedError } from '../../../src/git/errors.js'; +import { SourceTrackingConfig } from '../../../src/config.js'; class TestFailure extends Schema.TaggedError()('TestFailure', { message: Schema.String, @@ -43,7 +43,18 @@ class LockWorkerError extends Schema.TaggedError()('LockWorkerE message: Schema.String, }) {} -const TestLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer); +const configLayer = (lockTimeoutSeconds = 10, lockAutoclearSeconds = 300) => + Layer.succeed(SourceTrackingConfig, { + fdPermits: 64, + disableSourceMobility: false, + assumeSynced: false, + lockTimeoutSeconds, + lockAutoclearSeconds, + disableSourceMemberPolling: false, + fileVersion: 0, + integrityCheck: false, + }); +const TestLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, configLayer()); const run = (eff: Effect.Effect): Promise> => Effect.runPromiseExit(Effect.provide(eff, TestLayer)); @@ -358,35 +369,35 @@ describe('git/lock (phase 4)', () => { it('stale lock older than autoclear threshold is auto-cleared', async () => { const { tmp, gitdir } = await mkGitdir(); - process.env.SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS = '1'; - const consoleLog = sinon.spy(console, 'log'); - const consoleError = sinon.spy(console, 'error'); try { // Plant a stale lock (mtime well past 1s) const stale = path.join(gitdir, 'index.lock'); await fs.writeFile(stale, 'stale'); await fs.utimes(stale, 0, 0); - const exit = await run(Effect.succeed('ok').pipe(withIndexLock(gitdir))); + const exit = await Effect.runPromiseExit( + Effect.provide( + Effect.succeed('ok').pipe(withIndexLock(gitdir)), + Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, configLayer(10, 1)) + ) + ); expect(Exit.isSuccess(exit)).to.equal(true); - expect(consoleLog.called).to.equal(false); - expect(consoleError.called).to.equal(false); } finally { - consoleLog.restore(); - consoleError.restore(); - delete process.env.SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS; await fs.rm(tmp, { recursive: true, force: true }); } }); it('lock held past timeout → RepoLockedError', async () => { const { tmp, gitdir } = await mkGitdir(); - process.env.SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS = '1'; - process.env.SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS = '3600'; try { // Plant a young lock await fs.writeFile(path.join(gitdir, 'index.lock'), 'held'); - const exit = await run(Effect.succeed('ok').pipe(withIndexLock(gitdir))); + const exit = await Effect.runPromiseExit( + Effect.provide( + Effect.succeed('ok').pipe(withIndexLock(gitdir)), + Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, configLayer(1, 3600)) + ) + ); expect(Exit.isFailure(exit)).to.equal(true); if (Exit.isFailure(exit)) { const fail = Cause.findErrorOption(exit.cause); @@ -399,8 +410,6 @@ describe('git/lock (phase 4)', () => { } } } finally { - delete process.env.SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS; - delete process.env.SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS; await fs.rm(tmp, { recursive: true, force: true }); } }).timeout(8000); diff --git a/test/unit/git/lockWorker.ts b/test/unit/git/lockWorker.ts index ab6a6503..525cbdb6 100644 --- a/test/unit/git/lockWorker.ts +++ b/test/unit/git/lockWorker.ts @@ -20,6 +20,7 @@ import { isNotUndefined } from 'effect/Predicate'; import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem'; import * as NodePath from '@effect/platform-node/NodePath'; import { withIndexLock } from '../../../src/git/lock.js'; +import { getProcessSourceTrackingSettings, SourceTrackingConfig } from '../../../src/config.js'; const role = process.env.LOCK_TEST_ROLE; const gitdir = process.env.LOCK_TEST_GITDIR; @@ -55,4 +56,9 @@ const action = withIndexLock(gitdir)( ); if (role === 'second') process.stdout.write('second-attempting\n'); -await Effect.runPromise(Effect.provide(action, Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))); +await Effect.runPromise( + Effect.promise(getProcessSourceTrackingSettings).pipe( + Effect.flatMap((settings) => Effect.provideService(action, SourceTrackingConfig, settings)), + Effect.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) + ) +); diff --git a/test/unit/localDetectMovedFiles.test.ts b/test/unit/localDetectMovedFiles.test.ts index 1cbfb396..a270819f 100644 --- a/test/unit/localDetectMovedFiles.test.ts +++ b/test/unit/localDetectMovedFiles.test.ts @@ -20,6 +20,7 @@ import { expect, config } from 'chai'; import sinon from 'sinon'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; import { createShadowRepo, type ShadowRepo } from '../../src/shared/local/localShadowRepo.js'; +import { DEFAULT_SOURCE_TRACKING_SETTINGS } from '../../src/config.js'; config.truncateThreshold = 0; afterEach(() => { @@ -38,11 +39,6 @@ describe('local detect moved files (lite)', () => { after(() => { // No backend choice to reset }); - afterEach(() => { - delete process.env.SF_DISABLE_SOURCE_MOBILITY; - delete process.env.SF_SOURCE_TRACKING_ASSUME_SYNCED; - }); - it('initializes an existing empty local tracking directory', async () => { let projectDir!: string; try { @@ -53,14 +49,17 @@ describe('local detect moved files (lite)', () => { fs.writeFileSync(path.join(projectDir, filename), 'public class Recovery {}'); fs.mkdirSync(path.join(projectDir, '.sf', 'orgs', orgId, 'localSourceTracking'), { recursive: true }); - const shadowRepo = await createShadowRepo({ - orgId, - projectPath: projectDir, - packageDirs: [ - { name: 'dummy', fullPath: path.join(projectDir, 'force-app'), path: path.join(projectDir, 'force-app') }, - ], - registry, - }); + const shadowRepo = await createShadowRepo( + { + orgId, + projectPath: projectDir, + packageDirs: [ + { name: 'dummy', fullPath: path.join(projectDir, 'force-app'), path: path.join(projectDir, 'force-app') }, + ], + registry, + }, + DEFAULT_SOURCE_TRACKING_SETTINGS + ); expect(await shadowRepo.recordSync({ deployedFiles: [filename] })).to.match(/^[a-f0-9]{40}$/); expect(await shadowRepo.getChangedFilenames()).to.be.empty; @@ -78,14 +77,17 @@ describe('local detect moved files (lite)', () => { fs.writeFileSync(path.join(projectDir, 'force-app', 'labels', 'CustomLabels.labels-meta.xml'), ''); fs.writeFileSync(path.join(projectDir, 'force-app', 'labels', 'CustomLabelsTwo.labels-meta.xml'), ''); - const shadowRepo: ShadowRepo = await createShadowRepo({ - orgId: '00D456789012345', - projectPath: projectDir, - packageDirs: [ - { name: 'dummy', fullPath: path.join(projectDir, 'force-app'), path: path.join(projectDir, 'force-app') }, - ], - registry, - }); + const shadowRepo: ShadowRepo = await createShadowRepo( + { + orgId: '00D456789012345', + projectPath: projectDir, + packageDirs: [ + { name: 'dummy', fullPath: path.join(projectDir, 'force-app'), path: path.join(projectDir, 'force-app') }, + ], + registry, + }, + DEFAULT_SOURCE_TRACKING_SETTINGS + ); const labelsFile = path.join('force-app', 'labels', 'CustomLabels.labels-meta.xml'); const labelsFileTwo = path.join('force-app', 'labels', 'CustomLabelsTwo.labels-meta.xml'); @@ -109,7 +111,6 @@ describe('local detect moved files (lite)', () => { }); it('skips moved file detection when opt-out is enabled', async () => { - process.env.SF_DISABLE_SOURCE_MOBILITY = 'true'; let projectDir!: string; try { projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'localShadowRepoTest')); @@ -117,14 +118,17 @@ describe('local detect moved files (lite)', () => { fs.mkdirSync(path.join(projectDir, 'force-app', 'labels'), { recursive: true }); fs.writeFileSync(path.join(projectDir, 'force-app', 'labels', 'CustomLabels.labels-meta.xml'), ''); - const shadowRepo: ShadowRepo = await createShadowRepo({ - orgId: '00D456789012345', - projectPath: projectDir, - packageDirs: [ - { name: 'dummy', fullPath: path.join(projectDir, 'force-app'), path: path.join(projectDir, 'force-app') }, - ], - registry, - }); + const shadowRepo: ShadowRepo = await createShadowRepo( + { + orgId: '00D456789012345', + projectPath: projectDir, + packageDirs: [ + { name: 'dummy', fullPath: path.join(projectDir, 'force-app'), path: path.join(projectDir, 'force-app') }, + ], + registry, + }, + { ...DEFAULT_SOURCE_TRACKING_SETTINGS, disableSourceMobility: true } + ); const labelsFile = path.join('force-app', 'labels', 'CustomLabels.labels-meta.xml'); await shadowRepo.recordSync({ deployedFiles: [labelsFile] }); @@ -316,18 +320,20 @@ describe('local detect moved files (lite)', () => { it('returns no local changes when assume-synced mode is enabled', async () => { let projectDir!: string; - process.env.SF_SOURCE_TRACKING_ASSUME_SYNCED = 'true'; try { projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'localShadowRepoTest')); const packageDir = path.join(projectDir, 'force-app'); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(packageDir, 'untracked.cls'), 'class Untracked {}'); - const shadowRepo = await createShadowRepo({ - orgId: '00D456789012345', - projectPath: projectDir, - packageDirs: [{ name: 'dummy', fullPath: packageDir, path: packageDir }], - registry, - }); + const shadowRepo = await createShadowRepo( + { + orgId: '00D456789012345', + projectPath: projectDir, + packageDirs: [{ name: 'dummy', fullPath: packageDir, path: packageDir }], + registry, + }, + { ...DEFAULT_SOURCE_TRACKING_SETTINGS, assumeSynced: true } + ); expect(await shadowRepo.getChangedFilenames()).to.deep.equal([]); } finally { diff --git a/test/unit/remote/fileOperations.test.ts b/test/unit/remote/fileOperations.test.ts index 67b1ecf9..ae9d50df 100644 --- a/test/unit/remote/fileOperations.test.ts +++ b/test/unit/remote/fileOperations.test.ts @@ -29,8 +29,7 @@ import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; import { readFileContents, writeTrackingFile } from '../../../src/shared/remote/fileOperations.js'; import { ContentsJsonSchema, type MemberRevision } from '../../../src/shared/remote/types.js'; - -const FILE_VERSION_ENV = 'SF_SOURCE_TRACKING_FILE_VERSION'; +import { DEFAULT_SOURCE_TRACKING_SETTINGS, SourceTrackingConfig } from '../../../src/config.js'; const member: MemberRevision = { MemberType: 'ApexClass', @@ -53,15 +52,17 @@ describe('remote tracking file operations', () => { afterEach(async () => { await rm(directory, { recursive: true, force: true }); - delete process.env[FILE_VERSION_ENV]; }); it('writes and reads the current Schema format', async () => { - process.env[FILE_VERSION_ENV] = '1'; const filePath = path.join(directory, 'maxRevision.json'); await Effect.runPromise( - writeTrackingFile({ filePath, maxCounter: 3, members: new Map([['ApexClass###MyClass', member]]) }) + writeTrackingFile({ + filePath, + maxCounter: 3, + members: new Map([['ApexClass###MyClass', member]]), + }).pipe(Effect.provideService(SourceTrackingConfig, { ...DEFAULT_SOURCE_TRACKING_SETTINGS, fileVersion: 1 })) ); const fileContents = Schema.decodeUnknownSync(ContentsJsonSchema)(await readFile(filePath, 'utf8')); @@ -74,11 +75,12 @@ describe('remote tracking file operations', () => { }); it('reads and upgrades the legacy Schema format to the current in-memory format', async () => { - delete process.env[FILE_VERSION_ENV]; const filePath = path.join(directory, 'maxRevision.json'); await Effect.runPromise( - writeTrackingFile({ filePath, maxCounter: 3, members: new Map([['ApexClass###MyClass', member]]) }) + writeTrackingFile({ filePath, maxCounter: 3, members: new Map([['ApexClass###MyClass', member]]) }).pipe( + Effect.provideService(SourceTrackingConfig, DEFAULT_SOURCE_TRACKING_SETTINGS) + ) ); const fileContents = Schema.decodeUnknownSync(ContentsJsonSchema)(await readFile(filePath, 'utf8')); diff --git a/test/unit/remote/remoteSourceTracking.test.ts b/test/unit/remote/remoteSourceTracking.test.ts index 7fc90356..f834f0c5 100644 --- a/test/unit/remote/remoteSourceTracking.test.ts +++ b/test/unit/remote/remoteSourceTracking.test.ts @@ -19,11 +19,10 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { MockTestOrgData, instantiateContext, restoreContext, stubContext } from '@salesforce/core/testSetup'; -import { envVars, Lifecycle, Org } from '@salesforce/core'; +import { Lifecycle, Org } from '@salesforce/core'; import { ComponentStatus, RegistryAccess } from '@salesforce/source-deploy-retrieve'; import { expect } from 'chai'; import * as Effect from 'effect/Effect'; -import { isUndefined } from 'effect/Predicate'; import { deleteRemoteTracking, initializeRemoteTracking, @@ -43,6 +42,7 @@ import { import type { RemoteTrackingIdentity } from '../../../src/shared/remote/fileOperations.js'; import type { ContentsV1, MemberRevision, SourceMember } from '../../../src/shared/remote/types.js'; import type { RemoteChangeElement, RemoteSyncInput } from '../../../src/shared/types.js'; +import { DEFAULT_SOURCE_TRACKING_SETTINGS, SourceTrackingConfig } from '../../../src/config.js'; const defaultSourceMember: SourceMember = { IsNewMember: false, @@ -66,11 +66,6 @@ const memberRevision = (revision: number, lastRetrievedFromServer?: number): Mem lastRetrievedFromServer, }); -const reResolveEnvVars = (): void => { - // @ts-expect-error test setup forces EnvVars to resolve updated process values - envVars.resolve(); // eslint-disable-line @typescript-eslint/no-unsafe-call -}; - describe('remote source tracking Effects', () => { const $$ = instantiateContext(); let org: Org; @@ -86,7 +81,7 @@ describe('remote source tracking Effects', () => { filePath: getFilePath(identity), maxCounter: serverMaxRevisionCounter, members: new Map(Object.entries(sourceMembers)), - }) + }).pipe(Effect.provideService(SourceTrackingConfig, DEFAULT_SOURCE_TRACKING_SETTINGS)) ); }; @@ -110,11 +105,6 @@ describe('remote source tracking Effects', () => { }); afterEach(async () => { - envVars.unset('SF_DISABLE_SOURCE_MEMBER_POLLING'); - envVars.unset('SFDX_DISABLE_SOURCE_MEMBER_POLLING'); - envVars.unset('SF_SOURCE_MEMBER_POLLING_TIMEOUT'); - envVars.unset('SFDX_SOURCE_MEMBER_POLLING_TIMEOUT'); - reResolveEnvVars(); restoreContext($$); await rm(projectPath, { recursive: true, force: true }); }); @@ -242,36 +232,41 @@ describe('remote source tracking Effects', () => { it('syncs encoded stored keys through decoded-key resolution', async () => { const encodedKey = 'Layout###Broker__c-v1%2E1 Broker Layout'; - const fileVersion = process.env.SF_SOURCE_TRACKING_FILE_VERSION; - process.env.SF_SOURCE_TRACKING_FILE_VERSION = '1'; - try { - await writeSnapshot(2, { - [encodedKey]: { - ...memberRevision(2, 1), - MemberType: 'Layout', - MemberName: 'Broker__c-v1.1 Broker Layout', - }, - }); + const currentConfig = { ...DEFAULT_SOURCE_TRACKING_SETTINGS, fileVersion: 1 }; + await Effect.runPromise( + writeTrackingFile({ + filePath: getFilePath(identity), + maxCounter: 2, + members: new Map([ + [ + encodedKey, + { + ...memberRevision(2, 1), + MemberType: 'Layout', + MemberName: 'Broker__c-v1.1 Broker Layout', + }, + ], + ]), + }).pipe(Effect.provideService(SourceTrackingConfig, currentConfig)) + ); - const elements: RemoteSyncInput[] = [ - { - fullName: 'Broker__c-v1.1 Broker Layout', - type: 'Layout', - filePath: 'Broker__c-v1.1 Broker Layout.layout-meta.xml', - state: ComponentStatus.Changed, - }, - ]; - await Effect.runPromise( - updateRemoteTracking({ org, identity, registry: new RegistryAccess(), elements, skipPolling: true }) - ); + const elements: RemoteSyncInput[] = [ + { + fullName: 'Broker__c-v1.1 Broker Layout', + type: 'Layout', + filePath: 'Broker__c-v1.1 Broker Layout.layout-meta.xml', + state: ComponentStatus.Changed, + }, + ]; + await Effect.runPromise( + updateRemoteTracking({ org, identity, registry: new RegistryAccess(), elements, skipPolling: true }).pipe( + Effect.provideService(SourceTrackingConfig, currentConfig) + ) + ); - const contents = await readSnapshot(); - expect(contents.sourceMembers).to.have.all.keys(encodedKey); - expect(contents.sourceMembers[encodedKey].lastRetrievedFromServer).to.equal(2); - } finally { - if (isUndefined(fileVersion)) delete process.env.SF_SOURCE_TRACKING_FILE_VERSION; - else process.env.SF_SOURCE_TRACKING_FILE_VERSION = fileVersion; - } + const contents = await readSnapshot(); + expect(contents.sourceMembers).to.have.all.keys(encodedKey); + expect(contents.sourceMembers[encodedKey].lastRetrievedFromServer).to.equal(2); }); it('syncs a NavigationMenu whose Metadata API name includes the SourceMember name', async () => { @@ -386,8 +381,6 @@ describe('remote source tracking Effects', () => { ['Default_Navigation', 'Default'], ]) { it(`finishes polling overlapping names returned in order ${names.join(', ')}`, async () => { - envVars.setString('SF_SOURCE_MEMBER_POLLING_TIMEOUT', '1'); - reResolveEnvVars(); const queryStub = $$.SANDBOX.stub(sourceMemberQueries, 'querySourceMembersFrom').returns(Effect.succeed([])); queryStub.onFirstCall().returns( Effect.succeed( @@ -407,7 +400,12 @@ describe('remote source tracking Effects', () => { { fullName: 'site_Default', type: 'NavigationMenu', state: ComponentStatus.Changed }, { fullName: 'site_Default_Navigation', type: 'NavigationMenu', state: ComponentStatus.Changed }, ], - }) + }).pipe( + Effect.provideService(SourceTrackingConfig, { + ...DEFAULT_SOURCE_TRACKING_SETTINGS, + sourceMemberPollingTimeout: 1, + }) + ) ); expect(queryStub.callCount).to.equal(1); expect(warnings.called).to.equal(false); @@ -469,8 +467,6 @@ describe('remote source tracking Effects', () => { }); it('matches platform-event custom-field SourceMembers when the server omits the __e suffix', async () => { - envVars.setString('SF_SOURCE_MEMBER_POLLING_TIMEOUT', '1'); - reResolveEnvVars(); const queryStub = $$.SANDBOX.stub(sourceMemberQueries, 'querySourceMembersFrom'); queryStub.onFirstCall().returns( Effect.succeed([ @@ -497,7 +493,14 @@ describe('remote source tracking Effects', () => { }, ]; - await Effect.runPromise(updateRemoteTracking({ org, identity, registry: new RegistryAccess(), elements })); + await Effect.runPromise( + updateRemoteTracking({ org, identity, registry: new RegistryAccess(), elements }).pipe( + Effect.provideService(SourceTrackingConfig, { + ...DEFAULT_SOURCE_TRACKING_SETTINGS, + sourceMemberPollingTimeout: 1, + }) + ) + ); expect(queryStub.callCount).to.equal(1); expect(warnings.some((warning) => warning.includes('SourceMembers timed out'))).to.equal(false); @@ -507,8 +510,6 @@ describe('remote source tracking Effects', () => { }); it('emits a warning and persists partial state when polling times out', async () => { - envVars.setString('SF_SOURCE_MEMBER_POLLING_TIMEOUT', '1'); - reResolveEnvVars(); const warnings: string[] = []; Lifecycle.getInstance().onWarning((warning) => { warnings.push(warning); @@ -522,7 +523,14 @@ describe('remote source tracking Effects', () => { { fullName: 'MyClass', type: 'ApexClass', filePath: 'MyClass.cls', state: ComponentStatus.Changed }, ]; - await Effect.runPromise(updateRemoteTracking({ org, identity, registry: new RegistryAccess(), elements })); + await Effect.runPromise( + updateRemoteTracking({ org, identity, registry: new RegistryAccess(), elements }).pipe( + Effect.provideService(SourceTrackingConfig, { + ...DEFAULT_SOURCE_TRACKING_SETTINGS, + sourceMemberPollingTimeout: 1, + }) + ) + ); expect(queryStub.called).to.equal(true); expect(warnings.some((warning) => warning.includes('Polling for 1 SourceMembers timed out'))).to.equal(true); @@ -534,14 +542,19 @@ describe('remote source tracking Effects', () => { }); it('does not query while polling is disabled', async () => { - envVars.setString('SF_DISABLE_SOURCE_MEMBER_POLLING', 'true'); - reResolveEnvVars(); const queryStub = $$.SANDBOX.stub(sourceMemberQueries, 'querySourceMembersFrom').returns(Effect.succeed([])); const elements: RemoteSyncInput[] = [ { fullName: 'MyClass', type: 'ApexClass', filePath: 'MyClass.cls', state: ComponentStatus.Changed }, ]; - await Effect.runPromise(updateRemoteTracking({ org, identity, registry: new RegistryAccess(), elements })); + await Effect.runPromise( + updateRemoteTracking({ org, identity, registry: new RegistryAccess(), elements }).pipe( + Effect.provideService(SourceTrackingConfig, { + ...DEFAULT_SOURCE_TRACKING_SETTINGS, + disableSourceMemberPolling: true, + }) + ) + ); expect(queryStub.called).to.equal(false); }); From b078fb4f2c702b169f3be36cf3c4509503c33e99 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 2 Nov 2026 09:27:25 -0600 Subject: [PATCH 02/10] refactor(git): inline lock timeout conversion --- src/git/lock.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/git/lock.ts b/src/git/lock.ts index 5a86f409..9138f7de 100644 --- a/src/git/lock.ts +++ b/src/git/lock.ts @@ -56,8 +56,7 @@ const acquireLock = Effect.fn('acquireLock')(function* ( const indexPath = path.join(gitdir, 'index'); const timeout = Duration.seconds(config.lockTimeoutSeconds); const autoclear = Duration.seconds(config.lockAutoclearSeconds); - const timeoutMs = Duration.toMillis(timeout); - const autoclearMs = Duration.toMillis(autoclear); + const autoclearThresholdMs = Duration.toMillis(autoclear); const startMs = yield* Clock.currentTimeMillis; const tryAutoclear = Effect.fnUntraced(function* () { @@ -71,7 +70,7 @@ const acquireLock = Effect.fn('acquireLock')(function* ( () => now ); const ageMs = now - mtimeMs; - if (ageMs <= autoclearMs) return false; + if (ageMs <= autoclearThresholdMs) return false; yield* fs.remove(lockPath).pipe(Effect.catchReason('PlatformError', 'NotFound', () => Effect.void)); return true; }); @@ -131,7 +130,7 @@ const acquireLock = Effect.fn('acquireLock')(function* ( Effect.fail( new RepoLockedError({ lockPath, - ageMs: timeoutMs, + ageMs: Duration.toMillis(timeout), ageHumanReadable: humanReadable(timeout), message: `index.lock at ${lockPath} not acquired within ${humanReadable( timeout From 76ae9c50674937c08153efc88229c7a3234880c4 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 2 Nov 2026 09:40:03 -0600 Subject: [PATCH 03/10] refactor(effect): run source tracking through managed runtime --- src/config.ts | 28 +++++++++++++++++++--------- src/shared/conflicts.ts | 4 ++-- src/sourceTracking.ts | 22 +++++++++++----------- test/unit/config.test.ts | 19 ++++++++++++++++++- test/unit/git/lockWorker.ts | 16 +++++++++++----- 5 files changed, 61 insertions(+), 28 deletions(-) diff --git a/src/config.ts b/src/config.ts index b14c67a6..5d02f805 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,6 +19,7 @@ import * as ConfigProvider from 'effect/ConfigProvider'; import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; +import * as ManagedRuntime from 'effect/ManagedRuntime'; import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; @@ -218,11 +219,24 @@ export const loadSourceTrackingSettings = ( } satisfies SourceTrackingSettings; }); -let processSettings: Promise | undefined; +export type SourceTrackingRuntime = ManagedRuntime.ManagedRuntime; + +/** Create an isolated config runtime. Tests can inject a ConfigProvider here. */ +export const makeSourceTrackingRuntime = ( + provider: ConfigProvider.ConfigProvider = ConfigProvider.fromEnv() +): SourceTrackingRuntime => + ManagedRuntime.make( + provider.pipe(ConfigProvider.layer, Layer.provideMerge(sourceTrackingConfigLayerFromProvider(provider))) + ); + +let processRuntime: SourceTrackingRuntime | undefined; + +/** Resolve the one process-wide runtime and its immutable environment snapshot. */ +export const getProcessSourceTrackingRuntime = (): SourceTrackingRuntime => + (processRuntime ??= makeSourceTrackingRuntime()); -/** Resolve one immutable environment snapshot for this Node process. */ export const getProcessSourceTrackingSettings = (): Promise => - (processSettings ??= Effect.runPromise(loadSourceTrackingSettings().pipe(Effect.orDie))); + getProcessSourceTrackingRuntime().runPromise(SourceTrackingConfig); export const sourceTrackingConfigLayer = (settings: SourceTrackingSettings): Layer.Layer => Layer.succeed(SourceTrackingConfig, settings); @@ -232,9 +246,5 @@ export const sourceTrackingConfigLayerFromProvider = ( ): Layer.Layer => Layer.effect(SourceTrackingConfig, loadSourceTrackingSettings(provider)); -/** Run Effect code with the process-wide settings snapshot; tests may provide a different service directly. */ -export const provideProcessSourceTrackingConfig = (effect: Effect.Effect): Effect.Effect => - effect.pipe(Effect.provideServiceEffect(SourceTrackingConfig, Effect.promise(getProcessSourceTrackingSettings))); - -export const runWithProcessSourceTrackingConfig = (effect: Effect.Effect): Promise => - provideProcessSourceTrackingConfig(effect).pipe(Effect.orDie, Effect.runPromise); +export const runWithProcessSourceTrackingRuntime = (effect: Effect.Effect): Promise => + getProcessSourceTrackingRuntime().runPromise(effect); diff --git a/src/shared/conflicts.ts b/src/shared/conflicts.ts index 27052565..3e90a530 100644 --- a/src/shared/conflicts.ts +++ b/src/shared/conflicts.ts @@ -15,7 +15,7 @@ */ import { resolve } from 'node:path'; import { type ComponentSet, type ForceIgnore, type RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { runWithProcessSourceTrackingConfig } from '../config.js'; +import { runWithProcessSourceTrackingRuntime } from '../config.js'; import { type ConflictResponse, type ChangeResult, SourceConflictError } from './types.js'; import { getMetadataKey } from './functions.js'; import { populateTypesAndNames } from './populateTypesAndNames.js'; @@ -76,7 +76,7 @@ export const getDedupedConflictsFromChanges = async ({ ); return ( - await runWithProcessSourceTrackingConfig( + await runWithProcessSourceTrackingRuntime( populateTypesAndNames({ excludeUnresolvable: true, projectPath, forceIgnore, registry })(localChanges) ) ) diff --git a/src/sourceTracking.ts b/src/sourceTracking.ts index 212351d2..1fd06abb 100644 --- a/src/sourceTracking.ts +++ b/src/sourceTracking.ts @@ -41,7 +41,7 @@ import * as Schema from 'effect/Schema'; import { filePathsFromMetadataComponent } from '@salesforce/source-deploy-retrieve/lib/src/utils/filePathGenerator.js'; import * as Effect from 'effect/Effect'; import { isNotUndefined } from 'effect/Predicate'; -import { runWithProcessSourceTrackingConfig } from './config.js'; +import { runWithProcessSourceTrackingRuntime } from './config.js'; import { deleteRemoteTracking, initializeRemoteTracking, @@ -343,7 +343,7 @@ export class SourceTracking extends AsyncCreatable { if (options?.origin === 'remote') { await this.ensureRemoteTracking(); - const remoteChanges = await runWithProcessSourceTrackingConfig( + const remoteChanges = await runWithProcessSourceTrackingRuntime( retrieveRemoteChanges({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -523,7 +523,7 @@ export class SourceTracking extends AsyncCreatable { */ public async updateRemoteTracking(fileResponses: RemoteSyncInput[], skipPolling = false): Promise { await this.ensureRemoteTracking(false); - await runWithProcessSourceTrackingConfig( + await runWithProcessSourceTrackingRuntime( updateRemoteTrackingEffect({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -536,7 +536,7 @@ export class SourceTracking extends AsyncCreatable { public async reReadRemoteTracking(): Promise { await this.ensureRemoteTracking(false); - await runWithProcessSourceTrackingConfig( + await runWithProcessSourceTrackingRuntime( retrieveRemoteChanges({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -585,18 +585,18 @@ export class SourceTracking extends AsyncCreatable { * Useful before parallel operations */ public async ensureRemoteTracking(initializeWithQuery = false): Promise { - this.remoteTrackingInitialization ??= runWithProcessSourceTrackingConfig( + this.remoteTrackingInitialization ??= runWithProcessSourceTrackingRuntime( initializeRemoteTracking({ org: this.org, identity: this.remoteTrackingIdentity() }) ).catch((error: unknown) => { this.remoteTrackingInitialization = undefined; throw error; }); await this.remoteTrackingInitialization; - this.remoteTrackingCaches ??= await runWithProcessSourceTrackingConfig( + this.remoteTrackingCaches ??= await runWithProcessSourceTrackingRuntime( makeRemoteTrackingCaches({ org: this.org, identity: this.remoteTrackingIdentity() }) ); if (initializeWithQuery) { - await runWithProcessSourceTrackingConfig( + await runWithProcessSourceTrackingRuntime( retrieveRemoteChanges({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -636,7 +636,7 @@ export class SourceTracking extends AsyncCreatable { * Deletes the remote tracking files */ public async clearRemoteTracking(): Promise { - const filePath = await runWithProcessSourceTrackingConfig(deleteRemoteTracking(this.remoteTrackingIdentity())); + const filePath = await runWithProcessSourceTrackingRuntime(deleteRemoteTracking(this.remoteTrackingIdentity())); this.remoteTrackingInitialization = undefined; this.remoteTrackingCaches = undefined; return filePath; @@ -647,7 +647,7 @@ export class SourceTracking extends AsyncCreatable { */ public async resetRemoteTracking(serverRevision?: number): Promise { await this.ensureRemoteTracking(); - const resetMembers = await runWithProcessSourceTrackingConfig( + const resetMembers = await runWithProcessSourceTrackingRuntime( resetRemoteTrackingEffect({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -656,7 +656,7 @@ export class SourceTracking extends AsyncCreatable { }) ); if (this.remoteTrackingCaches) { - await runWithProcessSourceTrackingConfig(this.remoteTrackingCaches.sourceMembers.pipe(Cache.invalidateAll)); + await runWithProcessSourceTrackingRuntime(this.remoteTrackingCaches.sourceMembers.pipe(Cache.invalidateAll)); } return resetMembers.length; } @@ -824,7 +824,7 @@ export class SourceTracking extends AsyncCreatable { const toOutput = localChangesToOutputRow(this.logger)(this.forceIgnore); // Resolve adds/modifies from the filesystem and deletes from a virtual tree. - const [resolvedAdds, resolvedModifies, resolvedDeletes] = await runWithProcessSourceTrackingConfig( + const [resolvedAdds, resolvedModifies, resolvedDeletes] = await runWithProcessSourceTrackingRuntime( Effect.all( [ populateTypesAndNames(base)(adds), diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 8b175b74..dbf869f5 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -18,9 +18,26 @@ import { expect } from 'chai'; import * as ConfigProvider from 'effect/ConfigProvider'; import * as Effect from 'effect/Effect'; import * as Logger from 'effect/Logger'; -import { loadSourceTrackingSettings } from '../../src/config.js'; +import { loadSourceTrackingSettings, makeSourceTrackingRuntime, SourceTrackingConfig } from '../../src/config.js'; describe('source tracking config', () => { + it('provides one injected config snapshot through a reusable runtime', async () => { + const runtime = makeSourceTrackingRuntime( + ConfigProvider.fromEnvRecord({ SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS: '12' }) + ); + try { + const [first, second] = await Promise.all([ + runtime.runPromise(SourceTrackingConfig), + runtime.runPromise(SourceTrackingConfig), + ]); + + expect(first).to.equal(second); + expect(first.lockTimeoutSeconds).to.equal(12); + } finally { + await runtime.dispose(); + } + }); + it('uses defaults and Kit-compatible boolean parsing', async () => { const provider = ConfigProvider.fromEnvRecord({ SF_DISABLE_SOURCE_MOBILITY: 'TrUe', diff --git a/test/unit/git/lockWorker.ts b/test/unit/git/lockWorker.ts index 525cbdb6..7b52af94 100644 --- a/test/unit/git/lockWorker.ts +++ b/test/unit/git/lockWorker.ts @@ -15,12 +15,14 @@ */ import { access, appendFile } from 'node:fs/promises'; import * as Effect from 'effect/Effect'; +import * as ManagedRuntime from 'effect/ManagedRuntime'; +import * as ConfigProvider from 'effect/ConfigProvider'; import * as Layer from 'effect/Layer'; import { isNotUndefined } from 'effect/Predicate'; import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem'; import * as NodePath from '@effect/platform-node/NodePath'; import { withIndexLock } from '../../../src/git/lock.js'; -import { getProcessSourceTrackingSettings, SourceTrackingConfig } from '../../../src/config.js'; +import { sourceTrackingConfigLayerFromProvider } from '../../../src/config.js'; const role = process.env.LOCK_TEST_ROLE; const gitdir = process.env.LOCK_TEST_GITDIR; @@ -56,9 +58,13 @@ const action = withIndexLock(gitdir)( ); if (role === 'second') process.stdout.write('second-attempting\n'); -await Effect.runPromise( - Effect.promise(getProcessSourceTrackingSettings).pipe( - Effect.flatMap((settings) => Effect.provideService(action, SourceTrackingConfig, settings)), - Effect.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) +const provider = ConfigProvider.fromEnv(); +const runtime = ManagedRuntime.make( + Layer.mergeAll( + ConfigProvider.layer(provider), + sourceTrackingConfigLayerFromProvider(provider), + NodeFileSystem.layer, + NodePath.layer ) ); +await runtime.runPromise(action); From a4ef498e0338d94ce3378a4233beb5a2e77c9fe2 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 2 Nov 2026 09:45:29 -0600 Subject: [PATCH 04/10] fix(config): route warnings through lifecycle --- src/config.ts | 21 ++++++++++++++------- src/shared/remote/orgQueries.ts | 4 ++-- src/shared/remote/remoteSourceTracking.ts | 3 +-- test/unit/config.test.ts | 23 +++++++++++------------ test/unit/remote/orgQueries.test.ts | 8 ++------ 5 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/config.ts b/src/config.ts index 5d02f805..4d8b6b21 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ import * as Layer from 'effect/Layer'; import * as ManagedRuntime from 'effect/ManagedRuntime'; import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; +import { Lifecycle } from '@salesforce/core'; const DEFAULT_FD_PERMITS = process.platform === 'win32' ? 64 : 256; const DEFAULT_LOCK_TIMEOUT_SECONDS = 600; @@ -74,6 +75,12 @@ const positiveNumber = Schema.Number.check(Schema.isFinite(), Schema.isGreaterTh const positiveInteger = Schema.Int.check(Schema.isGreaterThan(0)); const nonNegativeInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); +const emitConfigWarning = (warning: string): Effect.Effect => + Effect.tryPromise({ + try: () => Lifecycle.getInstance().emitWarning(warning), + catch: () => undefined, + }).pipe(Effect.ignore); + const kitNumber = (raw: string): number => // Kit's toNumber accepts JavaScript numeric forms (including hex and Infinity). // Empty strings are treated as missing by Env.getNumber. @@ -92,7 +99,7 @@ const readNumber = ( return Option.match(decoded, { onSome: Effect.succeed, onNone: () => - Effect.logWarning(`${name}=${JSON.stringify(raw)} is invalid; using ${String(fallback)}`).pipe( + emitConfigWarning(`${name}=${JSON.stringify(raw)} is invalid; using ${String(fallback)}`).pipe( Effect.as(fallback) ), }); @@ -108,7 +115,7 @@ const readOptionalNumber = (name: string, value: Option.Option): Effect. return Option.match(decoded, { onSome: (seconds) => Effect.succeed(seconds), onNone: () => - Effect.logWarning(`${name}=${JSON.stringify(raw)} is invalid; ignoring it`).pipe(Effect.as(undefined)), + emitConfigWarning(`${name}=${JSON.stringify(raw)} is invalid; ignoring it`).pipe(Effect.as(undefined)), }); }, }); @@ -120,7 +127,7 @@ const kitBoolean = (name: string, value: Option.Option): Effect.Effect raw.legacyDisableSourceMemberPolling); const pollingTimeoutRaw = Option.orElse(raw.sourceMemberPollingTimeout, () => raw.legacySourceMemberPollingTimeout); if (Option.isNone(raw.disableSourceMemberPolling) && Option.isSome(raw.legacyDisableSourceMemberPolling)) { - yield* Effect.logWarning( + yield* emitConfigWarning( 'SFDX_DISABLE_SOURCE_MEMBER_POLLING is deprecated; use SF_DISABLE_SOURCE_MEMBER_POLLING.' ); } if (Option.isNone(raw.sourceMemberPollingTimeout) && Option.isSome(raw.legacySourceMemberPollingTimeout)) { - yield* Effect.logWarning( + yield* emitConfigWarning( 'SFDX_SOURCE_MEMBER_POLLING_TIMEOUT is deprecated; use SF_SOURCE_MEMBER_POLLING_TIMEOUT.' ); } @@ -147,10 +154,10 @@ export const loadSourceTrackingSettings = ( : 'SFDX_SOURCE_TRACKING_BATCH_SIZE'; const fdPermitsRaw = Option.isSome(raw.fdPermits) ? raw.fdPermits : legacyBatchSize; if (Option.isSome(fdPermitsRaw) && Option.isNone(raw.fdPermits)) { - yield* Effect.logWarning(`${legacyBatchName} is deprecated; use SF_SOURCE_TRACKING_FD_PERMITS.`); + yield* emitConfigWarning(`${legacyBatchName} is deprecated; use SF_SOURCE_TRACKING_FD_PERMITS.`); } if (Option.isSome(raw.fdPermits) && Option.isSome(legacyBatchSize)) { - yield* Effect.logWarning(`${legacyBatchName} is ignored because SF_SOURCE_TRACKING_FD_PERMITS is set.`); + yield* emitConfigWarning(`${legacyBatchName} is ignored because SF_SOURCE_TRACKING_FD_PERMITS is set.`); } const [ rawFdPermits, diff --git a/src/shared/remote/orgQueries.ts b/src/shared/remote/orgQueries.ts index 3f81ce46..bea0a22f 100644 --- a/src/shared/remote/orgQueries.ts +++ b/src/shared/remote/orgQueries.ts @@ -40,8 +40,8 @@ export type UserNameCache = Cache.Cache, export const calculateTimeout = (logger: PinoLogger) => - (memberCount: number, overriddenTimeout = 0): Duration.Duration => { - if (overriddenTimeout > 0) { + (memberCount: number, overriddenTimeout?: number): Duration.Duration => { + if (overriddenTimeout !== undefined) { logger.debug(`Overriding SourceMember polling timeout to ${overriddenTimeout}`); return Duration.seconds(overriddenTimeout); } diff --git a/src/shared/remote/remoteSourceTracking.ts b/src/shared/remote/remoteSourceTracking.ts index f313b91d..e2b0e29f 100644 --- a/src/shared/remote/remoteSourceTracking.ts +++ b/src/shared/remote/remoteSourceTracking.ts @@ -306,12 +306,11 @@ const pollForSourceTracking = Effect.fn('pollForSourceTracking')(function* ({ const expectedSourceMembers = calculateExpectedSourceMembers(registry, elements); const originalOutstandingSize = expectedSourceMembers.size; - const overriddenTimeout = config.sourceMemberPollingTimeout ?? 0; const consecutiveEmptyPollLimit = config.sourceMemberPollingTimeout === undefined ? DEFAULT_EMPTY_POLLING_RESULT_LIMIT : config.sourceMemberPollingTimeout / Duration.toSeconds(Duration.millis(POLLING_DELAY_MS)); - const pollingTimeout = calculateTimeout(logger)(expectedSourceMembers.size, overriddenTimeout); + const pollingTimeout = calculateTimeout(logger)(expectedSourceMembers.size, config.sourceMemberPollingTimeout); const pollState = yield* Ref.make({ highestRevisionSoFar: state.serverMaxRevisionCounter, pollAttempts: 0, diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index dbf869f5..d84a15c6 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -15,12 +15,15 @@ */ import { expect } from 'chai'; +import { Lifecycle } from '@salesforce/core'; import * as ConfigProvider from 'effect/ConfigProvider'; import * as Effect from 'effect/Effect'; -import * as Logger from 'effect/Logger'; +import sinon from 'sinon'; import { loadSourceTrackingSettings, makeSourceTrackingRuntime, SourceTrackingConfig } from '../../src/config.js'; describe('source tracking config', () => { + afterEach(() => sinon.restore()); + it('provides one injected config snapshot through a reusable runtime', async () => { const runtime = makeSourceTrackingRuntime( ConfigProvider.fromEnvRecord({ SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS: '12' }) @@ -39,6 +42,8 @@ describe('source tracking config', () => { }); it('uses defaults and Kit-compatible boolean parsing', async () => { + const warnings: string[] = []; + sinon.stub(Lifecycle.getInstance(), 'emitWarning').callsFake(async (warning) => void warnings.push(warning)); const provider = ConfigProvider.fromEnvRecord({ SF_DISABLE_SOURCE_MOBILITY: 'TrUe', SF_SOURCE_TRACKING_ASSUME_SYNCED: '1', @@ -55,16 +60,15 @@ describe('source tracking config', () => { fileVersion: 0, lockTimeoutSeconds: 600, }); + expect(warnings).to.deep.equal(['SF_DISABLE_SOURCE_MEMBER_POLLING="yes" is invalid; using false']); }); it('uses validated aliases and warns on deprecated batch-size variables', async () => { const warnings: string[] = []; - const logger = Logger.make((entry) => { - if (entry.logLevel === 'Warn') warnings.push(String(entry.message)); - }); + sinon.stub(Lifecycle.getInstance(), 'emitWarning').callsFake(async (warning) => void warnings.push(warning)); const settings = await loadSourceTrackingSettings( ConfigProvider.fromEnvRecord({ SFDX_SOURCE_TRACKING_BATCH_SIZE: '2000' }) - ).pipe(Effect.provide(Logger.layer([logger])), Effect.runPromise); + ).pipe(Effect.runPromise); expect(settings.fdPermits).to.equal(process.platform === 'win32' ? 64 : 256); expect(warnings).to.have.length(1); @@ -73,9 +77,7 @@ describe('source tracking config', () => { it('warns and falls back on invalid values; canonical batch config wins', async () => { const warnings: string[] = []; - const logger = Logger.make((entry) => { - if (entry.logLevel === 'Warn') warnings.push(String(entry.message)); - }); + sinon.stub(Lifecycle.getInstance(), 'emitWarning').callsFake(async (warning) => void warnings.push(warning)); const provider = ConfigProvider.fromEnvRecord({ SF_SOURCE_TRACKING_FD_PERMITS: '0', SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS: 'NaN', @@ -83,10 +85,7 @@ describe('source tracking config', () => { SFDX_SOURCE_TRACKING_BATCH_SIZE: '200', SF_SOURCE_TRACKING_FILE_VERSION: '-1', }); - const settings = await loadSourceTrackingSettings(provider).pipe( - Effect.provide(Logger.layer([logger])), - Effect.runPromise - ); + const settings = await loadSourceTrackingSettings(provider).pipe(Effect.runPromise); expect(settings).to.include({ fdPermits: process.platform === 'win32' ? 64 : 256, lockTimeoutSeconds: 600 }); expect(warnings).to.have.length(4); diff --git a/test/unit/remote/orgQueries.test.ts b/test/unit/remote/orgQueries.test.ts index f975aa51..886fafb7 100644 --- a/test/unit/remote/orgQueries.test.ts +++ b/test/unit/remote/orgQueries.test.ts @@ -109,18 +109,14 @@ describe('calculateTimeout', () => { const functionUnderTest = calculateTimeout(logger); it('defaults to five seconds with no members', () => { - expect(Duration.toSeconds(functionUnderTest(0, 0))).to.equal(5); + expect(Duration.toSeconds(functionUnderTest(0))).to.equal(5); }); it('allocates fifty seconds per thousand members plus five seconds', () => { - expect(Duration.toSeconds(functionUnderTest(10_000, 0))).to.equal(505); + expect(Duration.toSeconds(functionUnderTest(10_000))).to.equal(505); }); it('uses a positive override', () => { expect(Duration.toSeconds(functionUnderTest(10_000, 60))).to.equal(60); }); - - it('ignores a zero override', () => { - expect(Duration.toSeconds(functionUnderTest(10_000, 0))).to.equal(505); - }); }); From 5ef1350273b70cedd0d1959aa4773424ae0a2fbd Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 2 Nov 2026 10:29:04 -0600 Subject: [PATCH 05/10] refactor(config): use Effect schemas for numeric settings --- src/config.ts | 155 ++++++++++++++++++++++++--------------- test/unit/config.test.ts | 26 +++++++ 2 files changed, 121 insertions(+), 60 deletions(-) diff --git a/src/config.ts b/src/config.ts index 4d8b6b21..f55c9488 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ import * as Layer from 'effect/Layer'; import * as ManagedRuntime from 'effect/ManagedRuntime'; import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; +import * as SchemaGetter from 'effect/SchemaGetter'; import { Lifecycle } from '@salesforce/core'; const DEFAULT_FD_PERMITS = process.platform === 'win32' ? 64 : 256; @@ -71,9 +72,16 @@ export const SourceTrackingConfig = Context.Reference('@ defaultValue: () => DEFAULT_SOURCE_TRACKING_SETTINGS, }); -const positiveNumber = Schema.Number.check(Schema.isFinite(), Schema.isGreaterThan(0)); -const positiveInteger = Schema.Int.check(Schema.isGreaterThan(0)); -const nonNegativeInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); +const strictNumberFromString = Schema.String.pipe( + Schema.check(Schema.isPattern(/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/)), + Schema.decodeTo(Schema.Finite, { decode: SchemaGetter.Number(), encode: SchemaGetter.String() }) +); +const positiveNumberFromString = strictNumberFromString.check(Schema.isGreaterThan(0)); +const positiveIntegerFromString = strictNumberFromString.check(Schema.isInt(), Schema.isGreaterThan(0)); +const naturalFromString = Schema.String.pipe( + Schema.check(Schema.isPattern(/^[+]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/)), + Schema.decodeTo(Schema.Natural, { decode: SchemaGetter.Number(), encode: SchemaGetter.String() }) +); const emitConfigWarning = (warning: string): Effect.Effect => Effect.tryPromise({ @@ -81,43 +89,45 @@ const emitConfigWarning = (warning: string): Effect.Effect => catch: () => undefined, }).pipe(Effect.ignore); -const kitNumber = (raw: string): number => - // Kit's toNumber accepts JavaScript numeric forms (including hex and Infinity). - // Empty strings are treated as missing by Env.getNumber. - Number(raw); -const readNumber = ( - name: string, - value: Option.Option, - schema: Schema.ConstraintCodec, - fallback: A -): Effect.Effect => - Option.match(value, { +const readNumber = ( + warningName: string, + raw: Option.Option, + config: Config.Config, + provider: ConfigProvider.ConfigProvider, + fallback: number +): Effect.Effect => + Option.match(raw, { onNone: () => Effect.succeed(fallback), - onSome: (raw) => { - const parsed = raw === '' ? Number.NaN : kitNumber(raw); - const decoded = Schema.decodeUnknownOption(schema)(parsed); - return Option.match(decoded, { - onSome: Effect.succeed, - onNone: () => - emitConfigWarning(`${name}=${JSON.stringify(raw)} is invalid; using ${String(fallback)}`).pipe( - Effect.as(fallback) - ), - }); - }, + onSome: (rawValue) => + config + .parse(provider) + .pipe( + Effect.catchTag('ConfigError', () => + emitConfigWarning(`${warningName}=${JSON.stringify(rawValue)} is invalid; using ${String(fallback)}`).pipe( + Effect.as(fallback) + ) + ) + ), }); -const readOptionalNumber = (name: string, value: Option.Option): Effect.Effect => - Option.match(value, { - onNone: () => Effect.sync(() => undefined), - onSome: (raw) => { - const parsed = raw === '' ? Number.NaN : kitNumber(raw); - const decoded = Schema.decodeUnknownOption(positiveNumber)(parsed); - return Option.match(decoded, { - onSome: (seconds) => Effect.succeed(seconds), - onNone: () => - emitConfigWarning(`${name}=${JSON.stringify(raw)} is invalid; ignoring it`).pipe(Effect.as(undefined)), - }); - }, +const readOptionalNumber = ( + warningName: string, + raw: Option.Option, + config: Config.Config, + provider: ConfigProvider.ConfigProvider +): Effect.Effect => + Option.match(raw, { + onNone: () => Effect.as(Effect.void, undefined), + onSome: (rawValue) => + config + .parse(provider) + .pipe( + Effect.catchTag('ConfigError', () => + emitConfigWarning(`${warningName}=${JSON.stringify(rawValue)} is invalid; ignoring it`).pipe( + Effect.as(undefined) + ) + ) + ), }); const kitBoolean = (name: string, value: Option.Option): Effect.Effect => @@ -131,34 +141,38 @@ const kitBoolean = (name: string, value: Option.Option): Effect.Effect) { + if (Option.isNone(raw.disableSourceMemberPolling) && Option.isSome(raw.legacyDisableSourceMemberPolling)) { + yield* emitConfigWarning('SFDX_DISABLE_SOURCE_MEMBER_POLLING is deprecated; use SF_DISABLE_SOURCE_MEMBER_POLLING.'); + } + if (Option.isNone(raw.sourceMemberPollingTimeout) && Option.isSome(raw.legacySourceMemberPollingTimeout)) { + yield* emitConfigWarning('SFDX_SOURCE_MEMBER_POLLING_TIMEOUT is deprecated; use SF_SOURCE_MEMBER_POLLING_TIMEOUT.'); + } + const legacyBatchSize = Option.orElse(raw.batchSize, () => raw.legacyBatchSize); + const legacyBatchName = Option.isSome(raw.batchSize) + ? 'SF_SOURCE_TRACKING_BATCH_SIZE' + : 'SFDX_SOURCE_TRACKING_BATCH_SIZE'; + const fdPermitsRaw = Option.isSome(raw.fdPermits) ? raw.fdPermits : legacyBatchSize; + if (Option.isSome(fdPermitsRaw) && Option.isNone(raw.fdPermits)) { + yield* emitConfigWarning(`${legacyBatchName} is deprecated; use SF_SOURCE_TRACKING_FD_PERMITS.`); + } + if (Option.isSome(raw.fdPermits) && Option.isSome(legacyBatchSize)) { + yield* emitConfigWarning(`${legacyBatchName} is ignored because SF_SOURCE_TRACKING_FD_PERMITS is set.`); + } +}); + export const loadSourceTrackingSettings = ( provider: ConfigProvider.ConfigProvider = ConfigProvider.fromEnv() ): Effect.Effect => Effect.gen(function* () { const raw = yield* RawConfig.parse(provider); + yield* emitAliasWarnings(raw); const disablePollingRaw = Option.orElse(raw.disableSourceMemberPolling, () => raw.legacyDisableSourceMemberPolling); - const pollingTimeoutRaw = Option.orElse(raw.sourceMemberPollingTimeout, () => raw.legacySourceMemberPollingTimeout); - if (Option.isNone(raw.disableSourceMemberPolling) && Option.isSome(raw.legacyDisableSourceMemberPolling)) { - yield* emitConfigWarning( - 'SFDX_DISABLE_SOURCE_MEMBER_POLLING is deprecated; use SF_DISABLE_SOURCE_MEMBER_POLLING.' - ); - } - if (Option.isNone(raw.sourceMemberPollingTimeout) && Option.isSome(raw.legacySourceMemberPollingTimeout)) { - yield* emitConfigWarning( - 'SFDX_SOURCE_MEMBER_POLLING_TIMEOUT is deprecated; use SF_SOURCE_MEMBER_POLLING_TIMEOUT.' - ); - } const legacyBatchSize = Option.orElse(raw.batchSize, () => raw.legacyBatchSize); const legacyBatchName = Option.isSome(raw.batchSize) ? 'SF_SOURCE_TRACKING_BATCH_SIZE' : 'SFDX_SOURCE_TRACKING_BATCH_SIZE'; const fdPermitsRaw = Option.isSome(raw.fdPermits) ? raw.fdPermits : legacyBatchSize; - if (Option.isSome(fdPermitsRaw) && Option.isNone(raw.fdPermits)) { - yield* emitConfigWarning(`${legacyBatchName} is deprecated; use SF_SOURCE_TRACKING_FD_PERMITS.`); - } - if (Option.isSome(raw.fdPermits) && Option.isSome(legacyBatchSize)) { - yield* emitConfigWarning(`${legacyBatchName} is ignored because SF_SOURCE_TRACKING_FD_PERMITS is set.`); - } const [ rawFdPermits, lockTimeoutSeconds, @@ -172,29 +186,50 @@ export const loadSourceTrackingSettings = ( ] = yield* Effect.all([ readNumber( Option.isSome(raw.fdPermits) ? 'SF_SOURCE_TRACKING_FD_PERMITS' : legacyBatchName, - fdPermitsRaw, - positiveInteger, + Option.isSome(raw.fdPermits) ? raw.fdPermits : legacyBatchSize, + Config.schema( + positiveIntegerFromString, + Option.isSome(raw.fdPermits) ? 'SF_SOURCE_TRACKING_FD_PERMITS' : legacyBatchName + ), + provider, DEFAULT_FD_PERMITS ), readNumber( 'SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS', raw.lockTimeoutSeconds, - positiveNumber, + Config.schema(positiveNumberFromString, 'SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS'), + provider, DEFAULT_LOCK_TIMEOUT_SECONDS ), readNumber( 'SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS', raw.lockAutoclearSeconds, - positiveNumber, + Config.schema(positiveNumberFromString, 'SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS'), + provider, DEFAULT_LOCK_AUTOCLEAR_SECONDS ), readOptionalNumber( Option.isSome(raw.sourceMemberPollingTimeout) ? 'SF_SOURCE_MEMBER_POLLING_TIMEOUT' : 'SFDX_SOURCE_MEMBER_POLLING_TIMEOUT', - pollingTimeoutRaw + Option.isSome(raw.sourceMemberPollingTimeout) + ? raw.sourceMemberPollingTimeout + : raw.legacySourceMemberPollingTimeout, + Config.schema( + positiveNumberFromString, + Option.isSome(raw.sourceMemberPollingTimeout) + ? 'SF_SOURCE_MEMBER_POLLING_TIMEOUT' + : 'SFDX_SOURCE_MEMBER_POLLING_TIMEOUT' + ), + provider + ), + readNumber( + 'SF_SOURCE_TRACKING_FILE_VERSION', + raw.fileVersion, + Config.schema(naturalFromString, 'SF_SOURCE_TRACKING_FILE_VERSION'), + provider, + 0 ), - readNumber('SF_SOURCE_TRACKING_FILE_VERSION', raw.fileVersion, nonNegativeInteger, 0), kitBoolean('SF_DISABLE_SOURCE_MOBILITY', raw.disableSourceMobility), kitBoolean('SF_SOURCE_TRACKING_ASSUME_SYNCED', raw.assumeSynced), kitBoolean( diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index d84a15c6..67496fbf 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -91,4 +91,30 @@ describe('source tracking config', () => { expect(warnings).to.have.length(4); expect(warnings.join('\n')).to.contain('SF_SOURCE_TRACKING_BATCH_SIZE is ignored'); }); + + it('uses defaults for unsupported numeric forms', async () => { + const warnings: string[] = []; + sinon.stub(Lifecycle.getInstance(), 'emitWarning').callsFake(async (warning) => void warnings.push(warning)); + const settings = await loadSourceTrackingSettings( + ConfigProvider.fromEnvRecord( + { + SF_SOURCE_TRACKING_FD_PERMITS: '0x10', + SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS: 'Infinity', + SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS: 'NaN', + SF_SOURCE_TRACKING_FILE_VERSION: '', + SF_SOURCE_MEMBER_POLLING_TIMEOUT: '', + }, + { preserveEmptyStrings: true } + ) + ).pipe(Effect.runPromise); + + expect(settings).to.include({ + fdPermits: process.platform === 'win32' ? 64 : 256, + lockTimeoutSeconds: 600, + lockAutoclearSeconds: 300, + fileVersion: 0, + }); + expect(settings.sourceMemberPollingTimeout).to.equal(undefined); + expect(warnings).to.have.length(5); + }); }); From 14c71b2a8e2959b74394de45d498bc5c40a1dd7e Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 2 Nov 2026 10:52:11 -0600 Subject: [PATCH 06/10] refactor(config): resolve polling settings centrally --- src/config.ts | 315 +++++++----------- src/shared/remote/orgQueries.ts | 14 - src/shared/remote/remoteSourceTracking.ts | 16 +- test/unit/config.test.ts | 99 +++++- test/unit/git/lock.test.ts | 8 +- test/unit/remote/orgQueries.test.ts | 20 -- test/unit/remote/remoteSourceTracking.test.ts | 65 +++- 7 files changed, 280 insertions(+), 257 deletions(-) diff --git a/src/config.ts b/src/config.ts index f55c9488..1f27ef50 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,32 +17,37 @@ import * as Config from 'effect/Config'; import * as ConfigProvider from 'effect/ConfigProvider'; import * as Context from 'effect/Context'; +import * as Duration from 'effect/Duration'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as ManagedRuntime from 'effect/ManagedRuntime'; import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; import * as SchemaGetter from 'effect/SchemaGetter'; +import * as SchemaTransformation from 'effect/SchemaTransformation'; import { Lifecycle } from '@salesforce/core'; const DEFAULT_FD_PERMITS = process.platform === 'win32' ? 64 : 256; const DEFAULT_LOCK_TIMEOUT_SECONDS = 600; const DEFAULT_LOCK_AUTOCLEAR_SECONDS = 300; - -const RawConfig = Config.all({ - fdPermits: Config.option(Config.String('SF_SOURCE_TRACKING_FD_PERMITS')), - batchSize: Config.option(Config.String('SF_SOURCE_TRACKING_BATCH_SIZE')), - legacyBatchSize: Config.option(Config.String('SFDX_SOURCE_TRACKING_BATCH_SIZE')), - disableSourceMobility: Config.option(Config.String('SF_DISABLE_SOURCE_MOBILITY')), - assumeSynced: Config.option(Config.String('SF_SOURCE_TRACKING_ASSUME_SYNCED')), - lockTimeoutSeconds: Config.option(Config.String('SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS')), - lockAutoclearSeconds: Config.option(Config.String('SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS')), - disableSourceMemberPolling: Config.option(Config.String('SF_DISABLE_SOURCE_MEMBER_POLLING')), - legacyDisableSourceMemberPolling: Config.option(Config.String('SFDX_DISABLE_SOURCE_MEMBER_POLLING')), - sourceMemberPollingTimeout: Config.option(Config.String('SF_SOURCE_MEMBER_POLLING_TIMEOUT')), - legacySourceMemberPollingTimeout: Config.option(Config.String('SFDX_SOURCE_MEMBER_POLLING_TIMEOUT')), - fileVersion: Config.option(Config.String('SF_SOURCE_TRACKING_FILE_VERSION')), - integrityCheck: Config.option(Config.String('SF_SOURCE_TRACKING_INTEGRITY_CHECK')), +const SOURCE_MEMBER_POLLING_INTERVAL_MS = 1000; +const DEFAULT_EMPTY_POLLING_RESULT_LIMIT = 120; + +const sourceMemberPollingSettings = ( + timeoutSeconds?: number +): Pick< + SourceTrackingSettings, + 'sourceMemberPollingTimeout' | 'sourceMemberPollingIntervalMs' | 'sourceMemberPollingEmptyResultLimit' +> => ({ + sourceMemberPollingIntervalMs: SOURCE_MEMBER_POLLING_INTERVAL_MS, + sourceMemberPollingEmptyResultLimit: + timeoutSeconds === undefined + ? DEFAULT_EMPTY_POLLING_RESULT_LIMIT + : timeoutSeconds / (SOURCE_MEMBER_POLLING_INTERVAL_MS / 1000), + sourceMemberPollingTimeout: + timeoutSeconds === undefined + ? (memberCount: number): Duration.Duration => Duration.seconds(Math.ceil(memberCount * 0.05) + 5) + : (): Duration.Duration => Duration.seconds(timeoutSeconds), }); export type SourceTrackingSettings = { @@ -52,7 +57,9 @@ export type SourceTrackingSettings = { readonly lockTimeoutSeconds: number; readonly lockAutoclearSeconds: number; readonly disableSourceMemberPolling: boolean; - readonly sourceMemberPollingTimeout?: number; + readonly sourceMemberPollingTimeout: (memberCount: number) => Duration.Duration; + readonly sourceMemberPollingIntervalMs: number; + readonly sourceMemberPollingEmptyResultLimit: number; readonly fileVersion: number; readonly integrityCheck: boolean; }; @@ -64,6 +71,7 @@ export const DEFAULT_SOURCE_TRACKING_SETTINGS: SourceTrackingSettings = { lockTimeoutSeconds: DEFAULT_LOCK_TIMEOUT_SECONDS, lockAutoclearSeconds: DEFAULT_LOCK_AUTOCLEAR_SECONDS, disableSourceMemberPolling: false, + ...sourceMemberPollingSettings(), fileVersion: 0, integrityCheck: false, }; @@ -72,194 +80,121 @@ export const SourceTrackingConfig = Context.Reference('@ defaultValue: () => DEFAULT_SOURCE_TRACKING_SETTINGS, }); -const strictNumberFromString = Schema.String.pipe( - Schema.check(Schema.isPattern(/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/)), - Schema.decodeTo(Schema.Finite, { decode: SchemaGetter.Number(), encode: SchemaGetter.String() }) -); -const positiveNumberFromString = strictNumberFromString.check(Schema.isGreaterThan(0)); -const positiveIntegerFromString = strictNumberFromString.check(Schema.isInt(), Schema.isGreaterThan(0)); -const naturalFromString = Schema.String.pipe( - Schema.check(Schema.isPattern(/^[+]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/)), - Schema.decodeTo(Schema.Natural, { decode: SchemaGetter.Number(), encode: SchemaGetter.String() }) +const positiveNumber = Schema.Finite.check(Schema.isGreaterThan(0)); +const positiveInteger = Schema.Int.check(Schema.isGreaterThan(0)); +const kitBoolean = Schema.String.pipe( + Schema.decode(SchemaTransformation.toLowerCase()), + Schema.decodeTo(Schema.Literals(['true', 'false', '1', '0'])), + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === 'true' || value === '1'), + encode: SchemaGetter.transform((value) => (value ? 'true' : 'false')), + }) ); -const emitConfigWarning = (warning: string): Effect.Effect => - Effect.tryPromise({ +const emitConfigWarning = Effect.fnUntraced(function* (warning: string) { + yield* Effect.tryPromise({ try: () => Lifecycle.getInstance().emitWarning(warning), catch: () => undefined, }).pipe(Effect.ignore); +}); -const readNumber = ( - warningName: string, - raw: Option.Option, - config: Config.Config, - provider: ConfigProvider.ConfigProvider, - fallback: number -): Effect.Effect => - Option.match(raw, { - onNone: () => Effect.succeed(fallback), - onSome: (rawValue) => - config - .parse(provider) - .pipe( - Effect.catchTag('ConfigError', () => - emitConfigWarning(`${warningName}=${JSON.stringify(rawValue)} is invalid; using ${String(fallback)}`).pipe( - Effect.as(fallback) +const setting = (name: string, schema: Schema.ConstraintCodec, fallback: A): Config.Config => + Config.schema(schema, name).pipe( + Config.withDefault(fallback), + Config.orElse((error) => + Schema.isSchemaError(error.cause) + ? Config.succeed(fallback).pipe( + Config.mapEffect(() => + emitConfigWarning(`${name} is invalid; using ${String(fallback)}`).pipe(Effect.as(fallback)) ) ) - ), - }); + : Config.fail(error.cause) + ) + ); -const readOptionalNumber = ( - warningName: string, - raw: Option.Option, - config: Config.Config, - provider: ConfigProvider.ConfigProvider -): Effect.Effect => - Option.match(raw, { - onNone: () => Effect.as(Effect.void, undefined), - onSome: (rawValue) => - config - .parse(provider) - .pipe( - Effect.catchTag('ConfigError', () => - emitConfigWarning(`${warningName}=${JSON.stringify(rawValue)} is invalid; ignoring it`).pipe( - Effect.as(undefined) - ) - ) - ), - }); +// Choose aliases before validation: an invalid canonical value must not enable a legacy override. +const aliasedSetting = ( + name: string, + alias: string, + schema: Schema.ConstraintCodec, + fallback: A +): Config.Config => + Config.all({ + value: Config.option(Config.String(name)), + legacyValue: Config.option(Config.String(alias)), + }).pipe( + Config.flatMap(({ value, legacyValue }) => { + if (Option.isSome(value) || Option.isNone(legacyValue)) return setting(name, schema, fallback); + return setting(alias, schema, fallback).pipe( + Config.mapEffect((decoded) => + emitConfigWarning(`${alias} is deprecated; use ${name}.`).pipe(Effect.as(decoded)) + ) + ); + }) + ); -const kitBoolean = (name: string, value: Option.Option): Effect.Effect => - Option.match(value, { - onNone: () => Effect.succeed(false), - onSome: (raw) => { - const normalized = raw.toLowerCase(); - if (normalized === 'true' || raw === '1') return Effect.succeed(true); - if (normalized === 'false' || raw === '0') return Effect.succeed(false); - return emitConfigWarning(`${name}=${JSON.stringify(raw)} is invalid; using false`).pipe(Effect.as(false)); - }, - }); +const fdPermitsConfig = Config.all({ + canonical: Config.option(Config.String('SF_SOURCE_TRACKING_FD_PERMITS')), + batchSize: Config.option(Config.String('SF_SOURCE_TRACKING_BATCH_SIZE')), + legacyBatchSize: Config.option(Config.String('SFDX_SOURCE_TRACKING_BATCH_SIZE')), +}).pipe( + Config.flatMap(({ canonical, batchSize, legacyBatchSize }) => { + const alias = Option.isSome(batchSize) ? 'SF_SOURCE_TRACKING_BATCH_SIZE' : 'SFDX_SOURCE_TRACKING_BATCH_SIZE'; + if (Option.isSome(canonical)) { + return setting('SF_SOURCE_TRACKING_FD_PERMITS', positiveInteger, DEFAULT_FD_PERMITS).pipe( + Config.mapEffect((value) => + Option.isSome(batchSize) || Option.isSome(legacyBatchSize) + ? emitConfigWarning(`${alias} is ignored because SF_SOURCE_TRACKING_FD_PERMITS is set.`).pipe( + Effect.as(value) + ) + : Effect.succeed(value) + ) + ); + } + if (Option.isNone(batchSize) && Option.isNone(legacyBatchSize)) return Config.succeed(DEFAULT_FD_PERMITS); + return setting(alias, positiveInteger, DEFAULT_FD_PERMITS).pipe( + Config.map((value) => Math.min(value, DEFAULT_FD_PERMITS)), + Config.mapEffect((value) => + emitConfigWarning(`${alias} is deprecated; use SF_SOURCE_TRACKING_FD_PERMITS.`).pipe(Effect.as(value)) + ) + ); + }) +); -const emitAliasWarnings = Effect.fnUntraced(function* (raw: Config.Success) { - if (Option.isNone(raw.disableSourceMemberPolling) && Option.isSome(raw.legacyDisableSourceMemberPolling)) { - yield* emitConfigWarning('SFDX_DISABLE_SOURCE_MEMBER_POLLING is deprecated; use SF_DISABLE_SOURCE_MEMBER_POLLING.'); - } - if (Option.isNone(raw.sourceMemberPollingTimeout) && Option.isSome(raw.legacySourceMemberPollingTimeout)) { - yield* emitConfigWarning('SFDX_SOURCE_MEMBER_POLLING_TIMEOUT is deprecated; use SF_SOURCE_MEMBER_POLLING_TIMEOUT.'); - } - const legacyBatchSize = Option.orElse(raw.batchSize, () => raw.legacyBatchSize); - const legacyBatchName = Option.isSome(raw.batchSize) - ? 'SF_SOURCE_TRACKING_BATCH_SIZE' - : 'SFDX_SOURCE_TRACKING_BATCH_SIZE'; - const fdPermitsRaw = Option.isSome(raw.fdPermits) ? raw.fdPermits : legacyBatchSize; - if (Option.isSome(fdPermitsRaw) && Option.isNone(raw.fdPermits)) { - yield* emitConfigWarning(`${legacyBatchName} is deprecated; use SF_SOURCE_TRACKING_FD_PERMITS.`); - } - if (Option.isSome(raw.fdPermits) && Option.isSome(legacyBatchSize)) { - yield* emitConfigWarning(`${legacyBatchName} is ignored because SF_SOURCE_TRACKING_FD_PERMITS is set.`); - } -}); +const settingsConfig = Config.all({ + fdPermits: fdPermitsConfig, + disableSourceMobility: setting('SF_DISABLE_SOURCE_MOBILITY', kitBoolean, false), + assumeSynced: setting('SF_SOURCE_TRACKING_ASSUME_SYNCED', kitBoolean, false), + lockTimeoutSeconds: setting('SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS', positiveNumber, DEFAULT_LOCK_TIMEOUT_SECONDS), + lockAutoclearSeconds: setting( + 'SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS', + positiveNumber, + DEFAULT_LOCK_AUTOCLEAR_SECONDS + ), + disableSourceMemberPolling: aliasedSetting( + 'SF_DISABLE_SOURCE_MEMBER_POLLING', + 'SFDX_DISABLE_SOURCE_MEMBER_POLLING', + kitBoolean, + false + ), + sourceMemberPollingTimeout: aliasedSetting( + 'SF_SOURCE_MEMBER_POLLING_TIMEOUT', + 'SFDX_SOURCE_MEMBER_POLLING_TIMEOUT', + positiveNumber, + undefined + ), + fileVersion: setting('SF_SOURCE_TRACKING_FILE_VERSION', Schema.Natural, 0), + integrityCheck: setting('SF_SOURCE_TRACKING_INTEGRITY_CHECK', kitBoolean, false), +}).pipe( + Config.map(({ sourceMemberPollingTimeout, ...settings }) => ({ + ...settings, + ...sourceMemberPollingSettings(sourceMemberPollingTimeout), + })) +); export const loadSourceTrackingSettings = ( provider: ConfigProvider.ConfigProvider = ConfigProvider.fromEnv() -): Effect.Effect => - Effect.gen(function* () { - const raw = yield* RawConfig.parse(provider); - yield* emitAliasWarnings(raw); - const disablePollingRaw = Option.orElse(raw.disableSourceMemberPolling, () => raw.legacyDisableSourceMemberPolling); - const legacyBatchSize = Option.orElse(raw.batchSize, () => raw.legacyBatchSize); - const legacyBatchName = Option.isSome(raw.batchSize) - ? 'SF_SOURCE_TRACKING_BATCH_SIZE' - : 'SFDX_SOURCE_TRACKING_BATCH_SIZE'; - const fdPermitsRaw = Option.isSome(raw.fdPermits) ? raw.fdPermits : legacyBatchSize; - const [ - rawFdPermits, - lockTimeoutSeconds, - lockAutoclearSeconds, - sourceMemberPollingTimeout, - fileVersion, - disableSourceMobility, - assumeSynced, - disableSourceMemberPolling, - integrityCheck, - ] = yield* Effect.all([ - readNumber( - Option.isSome(raw.fdPermits) ? 'SF_SOURCE_TRACKING_FD_PERMITS' : legacyBatchName, - Option.isSome(raw.fdPermits) ? raw.fdPermits : legacyBatchSize, - Config.schema( - positiveIntegerFromString, - Option.isSome(raw.fdPermits) ? 'SF_SOURCE_TRACKING_FD_PERMITS' : legacyBatchName - ), - provider, - DEFAULT_FD_PERMITS - ), - readNumber( - 'SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS', - raw.lockTimeoutSeconds, - Config.schema(positiveNumberFromString, 'SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS'), - provider, - DEFAULT_LOCK_TIMEOUT_SECONDS - ), - readNumber( - 'SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS', - raw.lockAutoclearSeconds, - Config.schema(positiveNumberFromString, 'SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS'), - provider, - DEFAULT_LOCK_AUTOCLEAR_SECONDS - ), - readOptionalNumber( - Option.isSome(raw.sourceMemberPollingTimeout) - ? 'SF_SOURCE_MEMBER_POLLING_TIMEOUT' - : 'SFDX_SOURCE_MEMBER_POLLING_TIMEOUT', - Option.isSome(raw.sourceMemberPollingTimeout) - ? raw.sourceMemberPollingTimeout - : raw.legacySourceMemberPollingTimeout, - Config.schema( - positiveNumberFromString, - Option.isSome(raw.sourceMemberPollingTimeout) - ? 'SF_SOURCE_MEMBER_POLLING_TIMEOUT' - : 'SFDX_SOURCE_MEMBER_POLLING_TIMEOUT' - ), - provider - ), - readNumber( - 'SF_SOURCE_TRACKING_FILE_VERSION', - raw.fileVersion, - Config.schema(naturalFromString, 'SF_SOURCE_TRACKING_FILE_VERSION'), - provider, - 0 - ), - kitBoolean('SF_DISABLE_SOURCE_MOBILITY', raw.disableSourceMobility), - kitBoolean('SF_SOURCE_TRACKING_ASSUME_SYNCED', raw.assumeSynced), - kitBoolean( - Option.isSome(raw.disableSourceMemberPolling) - ? 'SF_DISABLE_SOURCE_MEMBER_POLLING' - : 'SFDX_DISABLE_SOURCE_MEMBER_POLLING', - disablePollingRaw - ), - kitBoolean('SF_SOURCE_TRACKING_INTEGRITY_CHECK', raw.integrityCheck), - ]); - // Prior batch-size values can exceed safe concurrent file descriptors by - // orders of magnitude. Keep smaller explicit values as useful limits. - const fdPermits = Option.isSome(raw.fdPermits) - ? rawFdPermits - : Option.isSome(fdPermitsRaw) - ? Math.min(rawFdPermits, DEFAULT_FD_PERMITS) - : rawFdPermits; - - return { - fdPermits, - disableSourceMobility, - assumeSynced, - lockTimeoutSeconds, - lockAutoclearSeconds, - disableSourceMemberPolling, - ...(sourceMemberPollingTimeout === undefined ? {} : { sourceMemberPollingTimeout }), - fileVersion, - integrityCheck, - } satisfies SourceTrackingSettings; - }); +): Effect.Effect => settingsConfig.parse(provider); export type SourceTrackingRuntime = ManagedRuntime.ManagedRuntime; diff --git a/src/shared/remote/orgQueries.ts b/src/shared/remote/orgQueries.ts index bea0a22f..81ba74ff 100644 --- a/src/shared/remote/orgQueries.ts +++ b/src/shared/remote/orgQueries.ts @@ -38,20 +38,6 @@ const UserNameRequest = Request.tagged('UserNameRequest'); /** Per-org user-name cache with no TTL for successful names. */ export type UserNameCache = Cache.Cache, SfError>; -export const calculateTimeout = - (logger: PinoLogger) => - (memberCount: number, overriddenTimeout?: number): Duration.Duration => { - if (overriddenTimeout !== undefined) { - logger.debug(`Overriding SourceMember polling timeout to ${overriddenTimeout}`); - return Duration.seconds(overriddenTimeout); - } - - // Calculate 50s per 1000 members, plus a 5s buffer. - const pollingTimeout = Math.ceil(memberCount * 0.05) + 5; - logger.debug(`Computed SourceMember polling timeout of ${pollingTimeout}s`); - return Duration.seconds(pollingTimeout); - }; - /** @internal */ export const querySourceMembersTo = Effect.fn('querySourceMembersTo')(function* (conn: Connection, toRevision: number) { const query = `SELECT ${SOURCE_MEMBER_FIELDS.join(', ')} FROM SourceMember WHERE RevisionCounter <= ${toRevision}`; diff --git a/src/shared/remote/remoteSourceTracking.ts b/src/shared/remote/remoteSourceTracking.ts index e2b0e29f..0baacb1b 100644 --- a/src/shared/remote/remoteSourceTracking.ts +++ b/src/shared/remote/remoteSourceTracking.ts @@ -50,7 +50,7 @@ import { trackingFileExists, writeTrackingFile, } from './fileOperations.js'; -import { calculateTimeout, makeUserNameCache, sourceMemberQueries } from './orgQueries.js'; +import { makeUserNameCache, sourceMemberQueries } from './orgQueries.js'; import type { UserNameCache } from './orgQueries.js'; import { emptyRemoteTrackingState, @@ -62,9 +62,6 @@ import { trackSourceMembers, } from './remoteTrackingState.js'; -const POLLING_DELAY_MS = 1000; -const DEFAULT_EMPTY_POLLING_RESULT_LIMIT = 120; - export type RemoteTrackingOptions = { org: Org; identity: RemoteTrackingIdentity; @@ -306,11 +303,7 @@ const pollForSourceTracking = Effect.fn('pollForSourceTracking')(function* ({ const expectedSourceMembers = calculateExpectedSourceMembers(registry, elements); const originalOutstandingSize = expectedSourceMembers.size; - const consecutiveEmptyPollLimit = - config.sourceMemberPollingTimeout === undefined - ? DEFAULT_EMPTY_POLLING_RESULT_LIMIT - : config.sourceMemberPollingTimeout / Duration.toSeconds(Duration.millis(POLLING_DELAY_MS)); - const pollingTimeout = calculateTimeout(logger)(expectedSourceMembers.size, config.sourceMemberPollingTimeout); + const pollingTimeout = config.sourceMemberPollingTimeout(expectedSourceMembers.size); const pollState = yield* Ref.make({ highestRevisionSoFar: state.serverMaxRevisionCounter, pollAttempts: 0, @@ -392,7 +385,8 @@ const pollForSourceTracking = Effect.fn('pollForSourceTracking')(function* ({ } of ${originalOutstandingSize} expected SourceMembers` ); const complete = HashMap.size(current.outstandingSourceMembers) === 0; - const stopped = current.someResultsReturned && current.consecutiveEmptyResults >= consecutiveEmptyPollLimit; + const stopped = + current.someResultsReturned && current.consecutiveEmptyResults >= config.sourceMemberPollingEmptyResultLimit; if (!complete && !stopped) { logger.debug( @@ -404,7 +398,7 @@ const pollForSourceTracking = Effect.fn('pollForSourceTracking')(function* ({ return { members: queriedMembers, continuePolling: !complete && !stopped } satisfies PollBatch; }); - const pollingSchedule = Schedule.spaced(Duration.millis(POLLING_DELAY_MS)).pipe( + const pollingSchedule = Schedule.spaced(Duration.millis(config.sourceMemberPollingIntervalMs)).pipe( Schedule.upTo({ duration: pollingTimeout }), Schedule.while(({ input }: Schedule.Metadata) => input.continuePolling) ); diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 67496fbf..42f27b49 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -17,9 +17,16 @@ import { expect } from 'chai'; import { Lifecycle } from '@salesforce/core'; import * as ConfigProvider from 'effect/ConfigProvider'; +import * as Duration from 'effect/Duration'; import * as Effect from 'effect/Effect'; +import * as Exit from 'effect/Exit'; import sinon from 'sinon'; -import { loadSourceTrackingSettings, makeSourceTrackingRuntime, SourceTrackingConfig } from '../../src/config.js'; +import { + DEFAULT_SOURCE_TRACKING_SETTINGS, + loadSourceTrackingSettings, + makeSourceTrackingRuntime, + SourceTrackingConfig, +} from '../../src/config.js'; describe('source tracking config', () => { afterEach(() => sinon.restore()); @@ -60,7 +67,7 @@ describe('source tracking config', () => { fileVersion: 0, lockTimeoutSeconds: 600, }); - expect(warnings).to.deep.equal(['SF_DISABLE_SOURCE_MEMBER_POLLING="yes" is invalid; using false']); + expect(warnings).to.deep.equal(['SF_DISABLE_SOURCE_MEMBER_POLLING is invalid; using false']); }); it('uses validated aliases and warns on deprecated batch-size variables', async () => { @@ -114,7 +121,93 @@ describe('source tracking config', () => { lockAutoclearSeconds: 300, fileVersion: 0, }); - expect(settings.sourceMemberPollingTimeout).to.equal(undefined); + expect(Duration.toSeconds(settings.sourceMemberPollingTimeout(10_000))).to.equal(505); + expect(settings.sourceMemberPollingEmptyResultLimit).to.equal(120); expect(warnings).to.have.length(5); }); + + it('resolves all polling defaults without an override', async () => { + const settings = await ConfigProvider.fromEnvRecord({}).pipe(loadSourceTrackingSettings, Effect.runPromise); + for (const config of [settings, DEFAULT_SOURCE_TRACKING_SETTINGS]) { + expect(config.sourceMemberPollingIntervalMs).to.equal(1000); + expect(config.sourceMemberPollingEmptyResultLimit).to.equal(120); + expect(Duration.toSeconds(config.sourceMemberPollingTimeout(0))).to.equal(5); + expect(Duration.toSeconds(config.sourceMemberPollingTimeout(10_000))).to.equal(505); + } + }); + + for (const name of ['SF_SOURCE_MEMBER_POLLING_TIMEOUT', 'SFDX_SOURCE_MEMBER_POLLING_TIMEOUT']) { + it(`resolves the timeout and empty-result limit from ${name}`, async () => { + const warning = sinon.stub(Lifecycle.getInstance(), 'emitWarning').resolves(); + const settings = await ConfigProvider.fromEnvRecord({ [name]: '2.5' }).pipe( + loadSourceTrackingSettings, + Effect.runPromise + ); + expect(Duration.toSeconds(settings.sourceMemberPollingTimeout(0))).to.equal(2.5); + expect(Duration.toSeconds(settings.sourceMemberPollingTimeout(10_000))).to.equal(2.5); + expect(settings.sourceMemberPollingEmptyResultLimit).to.equal(2.5); + expect(warning.callCount).to.equal(name.startsWith('SFDX') ? 1 : 0); + }); + } + + it('keeps an invalid canonical timeout from enabling a legacy override', async () => { + const warning = sinon.stub(Lifecycle.getInstance(), 'emitWarning').resolves(); + const settings = await ConfigProvider.fromEnvRecord({ + SF_SOURCE_MEMBER_POLLING_TIMEOUT: 'NaN', + SFDX_SOURCE_MEMBER_POLLING_TIMEOUT: '10', + }).pipe(loadSourceTrackingSettings, Effect.runPromise); + expect(Duration.toSeconds(settings.sourceMemberPollingTimeout(10_000))).to.equal(505); + expect(settings.sourceMemberPollingEmptyResultLimit).to.equal(120); + expect(warning.callCount).to.equal(1); + }); + + for (const value of ['Infinity', 'NaN', '0x10', '']) { + it(`defaults every numeric setting for ${JSON.stringify(value)}`, async () => { + sinon.stub(Lifecycle.getInstance(), 'emitWarning').resolves(); + const settings = await ConfigProvider.fromEnvRecord({ + SF_SOURCE_TRACKING_FD_PERMITS: value, + SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS: value, + SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS: value, + SF_SOURCE_TRACKING_FILE_VERSION: value, + SF_SOURCE_MEMBER_POLLING_TIMEOUT: value, + }).pipe(loadSourceTrackingSettings, Effect.runPromise); + expect(settings).to.include({ + fdPermits: DEFAULT_SOURCE_TRACKING_SETTINGS.fdPermits, + lockTimeoutSeconds: 600, + lockAutoclearSeconds: 300, + fileVersion: 0, + sourceMemberPollingEmptyResultLimit: 120, + }); + expect(Duration.toSeconds(settings.sourceMemberPollingTimeout(10_000))).to.equal(505); + }); + } + + it('retains positive fractional timeouts, integer counts, and false booleans', async () => { + const warning = sinon.stub(Lifecycle.getInstance(), 'emitWarning').resolves(); + const settings = await ConfigProvider.fromEnvRecord({ + SF_SOURCE_TRACKING_FD_PERMITS: '10', + SF_SOURCE_TRACKING_FILE_VERSION: '2', + SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS: '1.5', + SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS: '2.5', + SF_DISABLE_SOURCE_MOBILITY: 'FALSE', + SF_SOURCE_TRACKING_ASSUME_SYNCED: '0', + }).pipe(loadSourceTrackingSettings, Effect.runPromise); + expect(settings).to.include({ + fdPermits: 10, + fileVersion: 2, + lockTimeoutSeconds: 1.5, + lockAutoclearSeconds: 2.5, + disableSourceMobility: false, + assumeSynced: false, + }); + expect(warning.called).to.equal(false); + }); + + it('does not replace provider failures with defaults', async () => { + const warning = sinon.stub(Lifecycle.getInstance(), 'emitWarning').resolves(); + const provider = ConfigProvider.make(() => Effect.fail(new ConfigProvider.SourceError({ message: 'unavailable' }))); + const result = await provider.pipe(loadSourceTrackingSettings, Effect.runPromiseExit); + expect(Exit.isFailure(result)).to.equal(true); + expect(warning.called).to.equal(false); + }); }); diff --git a/test/unit/git/lock.test.ts b/test/unit/git/lock.test.ts index 002ae02c..80ec6386 100644 --- a/test/unit/git/lock.test.ts +++ b/test/unit/git/lock.test.ts @@ -33,7 +33,7 @@ import { FileSystem } from 'effect/FileSystem'; import { type Path } from 'effect/Path'; import { withIndexLock, withIndexLockCtx } from '../../../src/git/lock.js'; import { RepoLockedError } from '../../../src/git/errors.js'; -import { SourceTrackingConfig } from '../../../src/config.js'; +import { DEFAULT_SOURCE_TRACKING_SETTINGS, SourceTrackingConfig } from '../../../src/config.js'; class TestFailure extends Schema.TaggedError()('TestFailure', { message: Schema.String, @@ -45,14 +45,10 @@ class LockWorkerError extends Schema.TaggedError()('LockWorkerE const configLayer = (lockTimeoutSeconds = 10, lockAutoclearSeconds = 300) => Layer.succeed(SourceTrackingConfig, { + ...DEFAULT_SOURCE_TRACKING_SETTINGS, fdPermits: 64, - disableSourceMobility: false, - assumeSynced: false, lockTimeoutSeconds, lockAutoclearSeconds, - disableSourceMemberPolling: false, - fileVersion: 0, - integrityCheck: false, }); const TestLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, configLayer()); const run = (eff: Effect.Effect): Promise> => diff --git a/test/unit/remote/orgQueries.test.ts b/test/unit/remote/orgQueries.test.ts index 886fafb7..9b7318cc 100644 --- a/test/unit/remote/orgQueries.test.ts +++ b/test/unit/remote/orgQueries.test.ts @@ -14,13 +14,10 @@ * limitations under the License. */ -import { Logger } from '@salesforce/core'; import type { Connection } from '@salesforce/core'; import { expect } from 'chai'; -import * as Duration from 'effect/Duration'; import * as Effect from 'effect/Effect'; import { - calculateTimeout, makeUserNameCache, querySourceMembersFrom, querySourceMembersTo, @@ -103,20 +100,3 @@ describe('remote SourceMember queries', () => { expect(results[0].MemberType).to.equal('QuickAction'); }); }); - -describe('calculateTimeout', () => { - const logger = new Logger({ useMemoryLogger: true, name: 'test' }).getRawLogger(); - const functionUnderTest = calculateTimeout(logger); - - it('defaults to five seconds with no members', () => { - expect(Duration.toSeconds(functionUnderTest(0))).to.equal(5); - }); - - it('allocates fifty seconds per thousand members plus five seconds', () => { - expect(Duration.toSeconds(functionUnderTest(10_000))).to.equal(505); - }); - - it('uses a positive override', () => { - expect(Duration.toSeconds(functionUnderTest(10_000, 60))).to.equal(60); - }); -}); diff --git a/test/unit/remote/remoteSourceTracking.test.ts b/test/unit/remote/remoteSourceTracking.test.ts index f834f0c5..64edee83 100644 --- a/test/unit/remote/remoteSourceTracking.test.ts +++ b/test/unit/remote/remoteSourceTracking.test.ts @@ -22,6 +22,7 @@ import { MockTestOrgData, instantiateContext, restoreContext, stubContext } from import { Lifecycle, Org } from '@salesforce/core'; import { ComponentStatus, RegistryAccess } from '@salesforce/source-deploy-retrieve'; import { expect } from 'chai'; +import * as ConfigProvider from 'effect/ConfigProvider'; import * as Effect from 'effect/Effect'; import { deleteRemoteTracking, @@ -42,7 +43,11 @@ import { import type { RemoteTrackingIdentity } from '../../../src/shared/remote/fileOperations.js'; import type { ContentsV1, MemberRevision, SourceMember } from '../../../src/shared/remote/types.js'; import type { RemoteChangeElement, RemoteSyncInput } from '../../../src/shared/types.js'; -import { DEFAULT_SOURCE_TRACKING_SETTINGS, SourceTrackingConfig } from '../../../src/config.js'; +import { + DEFAULT_SOURCE_TRACKING_SETTINGS, + loadSourceTrackingSettings, + SourceTrackingConfig, +} from '../../../src/config.js'; const defaultSourceMember: SourceMember = { IsNewMember: false, @@ -401,10 +406,13 @@ describe('remote source tracking Effects', () => { { fullName: 'site_Default_Navigation', type: 'NavigationMenu', state: ComponentStatus.Changed }, ], }).pipe( - Effect.provideService(SourceTrackingConfig, { - ...DEFAULT_SOURCE_TRACKING_SETTINGS, - sourceMemberPollingTimeout: 1, - }) + Effect.provideService( + SourceTrackingConfig, + await ConfigProvider.fromEnvRecord({ SF_SOURCE_MEMBER_POLLING_TIMEOUT: '1' }).pipe( + loadSourceTrackingSettings, + Effect.runPromise + ) + ) ) ); expect(queryStub.callCount).to.equal(1); @@ -495,10 +503,13 @@ describe('remote source tracking Effects', () => { await Effect.runPromise( updateRemoteTracking({ org, identity, registry: new RegistryAccess(), elements }).pipe( - Effect.provideService(SourceTrackingConfig, { - ...DEFAULT_SOURCE_TRACKING_SETTINGS, - sourceMemberPollingTimeout: 1, - }) + Effect.provideService( + SourceTrackingConfig, + await ConfigProvider.fromEnvRecord({ SF_SOURCE_MEMBER_POLLING_TIMEOUT: '1' }).pipe( + loadSourceTrackingSettings, + Effect.runPromise + ) + ) ) ); @@ -525,10 +536,13 @@ describe('remote source tracking Effects', () => { await Effect.runPromise( updateRemoteTracking({ org, identity, registry: new RegistryAccess(), elements }).pipe( - Effect.provideService(SourceTrackingConfig, { - ...DEFAULT_SOURCE_TRACKING_SETTINGS, - sourceMemberPollingTimeout: 1, - }) + Effect.provideService( + SourceTrackingConfig, + await ConfigProvider.fromEnvRecord({ SF_SOURCE_MEMBER_POLLING_TIMEOUT: '1' }).pipe( + loadSourceTrackingSettings, + Effect.runPromise + ) + ) ) ); @@ -541,6 +555,31 @@ describe('remote source tracking Effects', () => { expect(contents.sourceMembers['ApexClass###ArrivedBeforeTimeout']).to.be.an('object'); }); + it('uses the resolved empty-result limit independently of the overall timeout', async () => { + const queryStub = $$.SANDBOX.stub(sourceMemberQueries, 'querySourceMembersFrom'); + queryStub.onFirstCall().returns(Effect.succeed([sourceMember(4, 'ArrivedBeforeTimeout')])); + queryStub.returns(Effect.succeed([])); + const warning = $$.SANDBOX.stub(Lifecycle.getInstance(), 'emitWarning').resolves(); + + await Effect.runPromise( + updateRemoteTracking({ + org, + identity, + registry: new RegistryAccess(), + elements: [{ fullName: 'MyClass', type: 'ApexClass', state: ComponentStatus.Changed }], + }).pipe( + Effect.provideService(SourceTrackingConfig, { + ...DEFAULT_SOURCE_TRACKING_SETTINGS, + sourceMemberPollingIntervalMs: 1, + sourceMemberPollingEmptyResultLimit: 1, + }) + ) + ); + + expect(queryStub.callCount).to.equal(2); + expect(warning.calledWithMatch('after 2 attempts (last 1 were empty)')).to.equal(true); + }); + it('does not query while polling is disabled', async () => { const queryStub = $$.SANDBOX.stub(sourceMemberQueries, 'querySourceMembersFrom').returns(Effect.succeed([])); const elements: RemoteSyncInput[] = [ From 8416a5bde5d0dc999173210487ed7985af82c301 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 2 Nov 2026 11:19:20 -0600 Subject: [PATCH 07/10] refactor(config): parse booleans without encoding --- src/config.ts | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/config.ts b/src/config.ts index 1f27ef50..5dd559f7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,8 +23,6 @@ import * as Layer from 'effect/Layer'; import * as ManagedRuntime from 'effect/ManagedRuntime'; import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; -import * as SchemaGetter from 'effect/SchemaGetter'; -import * as SchemaTransformation from 'effect/SchemaTransformation'; import { Lifecycle } from '@salesforce/core'; const DEFAULT_FD_PERMITS = process.platform === 'win32' ? 64 : 256; @@ -80,16 +78,21 @@ export const SourceTrackingConfig = Context.Reference('@ defaultValue: () => DEFAULT_SOURCE_TRACKING_SETTINGS, }); -const positiveNumber = Schema.Finite.check(Schema.isGreaterThan(0)); -const positiveInteger = Schema.Int.check(Schema.isGreaterThan(0)); -const kitBoolean = Schema.String.pipe( - Schema.decode(SchemaTransformation.toLowerCase()), - Schema.decodeTo(Schema.Literals(['true', 'false', '1', '0'])), - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === 'true' || value === '1'), - encode: SchemaGetter.transform((value) => (value ? 'true' : 'false')), - }) -); +const positiveNumber = (name: string): Config.Config => + Config.schema(Schema.Finite.check(Schema.isGreaterThan(0)), name); +const positiveInteger = (name: string): Config.Config => + Config.schema(Schema.Int.check(Schema.isGreaterThan(0)), name); +const natural = (name: string): Config.Config => Config.schema(Schema.Natural, name); +const decodeBoolean = Schema.decodeUnknownEffect(Schema.Literals(['true', 'false', '1', '0'])); +const kitBoolean = (name: string): Config.Config => + Config.String(name).pipe( + Config.mapEffect((raw) => + decodeBoolean(raw.toLowerCase()).pipe( + Effect.map((value) => value === 'true' || value === '1'), + Effect.mapError((cause) => new Config.ConfigError(cause)) + ) + ) + ); const emitConfigWarning = Effect.fnUntraced(function* (warning: string) { yield* Effect.tryPromise({ @@ -98,8 +101,8 @@ const emitConfigWarning = Effect.fnUntraced(function* (warning: string) { }).pipe(Effect.ignore); }); -const setting = (name: string, schema: Schema.ConstraintCodec, fallback: A): Config.Config => - Config.schema(schema, name).pipe( +const setting = (name: string, makeConfig: (name: string) => Config.Config, fallback: A): Config.Config => + makeConfig(name).pipe( Config.withDefault(fallback), Config.orElse((error) => Schema.isSchemaError(error.cause) @@ -116,7 +119,7 @@ const setting = (name: string, schema: Schema.ConstraintCodec, fa const aliasedSetting = ( name: string, alias: string, - schema: Schema.ConstraintCodec, + makeConfig: (name: string) => Config.Config, fallback: A ): Config.Config => Config.all({ @@ -124,8 +127,8 @@ const aliasedSetting = ( legacyValue: Config.option(Config.String(alias)), }).pipe( Config.flatMap(({ value, legacyValue }) => { - if (Option.isSome(value) || Option.isNone(legacyValue)) return setting(name, schema, fallback); - return setting(alias, schema, fallback).pipe( + if (Option.isSome(value) || Option.isNone(legacyValue)) return setting(name, makeConfig, fallback); + return setting(alias, makeConfig, fallback).pipe( Config.mapEffect((decoded) => emitConfigWarning(`${alias} is deprecated; use ${name}.`).pipe(Effect.as(decoded)) ) @@ -183,7 +186,7 @@ const settingsConfig = Config.all({ positiveNumber, undefined ), - fileVersion: setting('SF_SOURCE_TRACKING_FILE_VERSION', Schema.Natural, 0), + fileVersion: setting('SF_SOURCE_TRACKING_FILE_VERSION', natural, 0), integrityCheck: setting('SF_SOURCE_TRACKING_INTEGRITY_CHECK', kitBoolean, false), }).pipe( Config.map(({ sourceMemberPollingTimeout, ...settings }) => ({ From 249ae4eb609c049895cf9f2b14bfd3ea055501de Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 2 Nov 2026 12:58:46 -0600 Subject: [PATCH 08/10] refactor(test): inject source tracking config through Effect layer --- src/shared/local/localShadowRepo.ts | 23 ++++++++++++----------- test/unit/localDetectMovedFiles.test.ts | 10 ++++++++-- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/shared/local/localShadowRepo.ts b/src/shared/local/localShadowRepo.ts index f4a8e0ca..cde6fc3f 100644 --- a/src/shared/local/localShadowRepo.ts +++ b/src/shared/local/localShadowRepo.ts @@ -37,7 +37,7 @@ import { repoErrorToSfError } from '../../git/sfCompatibility.js'; import { Author as AuthorSchema, RepoPath as RepoPathSchema, type RepoPath, RepoConfig } from '../../git/schemas.js'; import { excludeLwcLocalOnlyTest, folderContainsPath } from '../functions.js'; import { - getProcessSourceTrackingSettings, + getProcessSourceTrackingRuntime, SourceTrackingConfig, sourceTrackingConfigLayer, type SourceTrackingSettings, @@ -308,13 +308,10 @@ const runEffect = ( ) ); -/** Promise adapter for SourceTracking and CLI callers; the implementation above stays Effect-native. */ -export const createShadowRepo = async ( - options: ShadowRepoOptions, - testSettings?: SourceTrackingSettings -): Promise => { - const logger = await Logger.child('ShadowRepo'); - const settings = testSettings ?? (await getProcessSourceTrackingSettings()); +/** @internal Build a local repo using SourceTrackingConfig from the Effect context. */ +export const createShadowRepoEffect = Effect.fn('createShadowRepoEffect')(function* (options: ShadowRepoOptions) { + const logger = yield* Effect.promise(() => Logger.child('ShadowRepo')); + const settings = yield* SourceTrackingConfig; const cfg = makeRepoConfig(options, settings); const runtime = ManagedRuntime.make( Repo.layer(cfg).pipe( @@ -323,7 +320,7 @@ export const createShadowRepo = async ( ) ) ); - const effects = await runEffect(runtime, logger, 'init', makeShadowRepo(options, logger, cfg)); + const effects = yield* Effect.promise(() => runEffect(runtime, logger, 'init', makeShadowRepo(options, logger, cfg))); const getStatus = (refresh = false): Promise => runEffect(runtime, logger, 'getStatus', effects.getStatus(refresh)); @@ -337,7 +334,11 @@ export const createShadowRepo = async ( getNonDeleteFilenames: () => getStatus().then((changes) => [...changes.added, ...changes.modified]), getAddFilenames: () => getStatus().then((changes) => Array.from(changes.added)), getModifyFilenames: () => getStatus().then((changes) => Array.from(changes.modified)), - recordSync: (request) => runEffect(runtime, logger, 'recordSync', effects.recordSync(request)), + recordSync: (request?: SyncRequest) => runEffect(runtime, logger, 'recordSync', effects.recordSync(request)), delete: () => runEffect(runtime, logger, 'delete', effects.delete()), }; -}; +}); + +/** Promise adapter for SourceTracking and CLI callers; the implementation above stays Effect-native. */ +export const createShadowRepo = (options: ShadowRepoOptions): Promise => + getProcessSourceTrackingRuntime().runPromise(createShadowRepoEffect(options)); diff --git a/test/unit/localDetectMovedFiles.test.ts b/test/unit/localDetectMovedFiles.test.ts index a270819f..0e85d87e 100644 --- a/test/unit/localDetectMovedFiles.test.ts +++ b/test/unit/localDetectMovedFiles.test.ts @@ -19,10 +19,16 @@ import fs from 'node:fs'; import { expect, config } from 'chai'; import sinon from 'sinon'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo, type ShadowRepo } from '../../src/shared/local/localShadowRepo.js'; -import { DEFAULT_SOURCE_TRACKING_SETTINGS } from '../../src/config.js'; +import * as Effect from 'effect/Effect'; +import { createShadowRepoEffect, type ShadowRepo } from '../../src/shared/local/localShadowRepo.js'; +import { DEFAULT_SOURCE_TRACKING_SETTINGS, sourceTrackingConfigLayer } from '../../src/config.js'; config.truncateThreshold = 0; +const createShadowRepo = ( + options: Parameters[0], + settings = DEFAULT_SOURCE_TRACKING_SETTINGS +) => Effect.runPromise(Effect.provide(createShadowRepoEffect(options), sourceTrackingConfigLayer(settings))); + afterEach(() => { sinon.restore(); }); From e29ad97d0cd7d3348df094457d21d6b34cc2d6c5 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 2 Nov 2026 13:19:07 -0600 Subject: [PATCH 09/10] refactor(config): use Effect boolean config --- src/config.ts | 19 +++++-------------- test/unit/config.test.ts | 14 +++++++------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/config.ts b/src/config.ts index 5dd559f7..f32ae425 100644 --- a/src/config.ts +++ b/src/config.ts @@ -83,16 +83,7 @@ const positiveNumber = (name: string): Config.Config => const positiveInteger = (name: string): Config.Config => Config.schema(Schema.Int.check(Schema.isGreaterThan(0)), name); const natural = (name: string): Config.Config => Config.schema(Schema.Natural, name); -const decodeBoolean = Schema.decodeUnknownEffect(Schema.Literals(['true', 'false', '1', '0'])); -const kitBoolean = (name: string): Config.Config => - Config.String(name).pipe( - Config.mapEffect((raw) => - decodeBoolean(raw.toLowerCase()).pipe( - Effect.map((value) => value === 'true' || value === '1'), - Effect.mapError((cause) => new Config.ConfigError(cause)) - ) - ) - ); +const boolean = (name: string): Config.Config => Config.Boolean(name); const emitConfigWarning = Effect.fnUntraced(function* (warning: string) { yield* Effect.tryPromise({ @@ -166,8 +157,8 @@ const fdPermitsConfig = Config.all({ const settingsConfig = Config.all({ fdPermits: fdPermitsConfig, - disableSourceMobility: setting('SF_DISABLE_SOURCE_MOBILITY', kitBoolean, false), - assumeSynced: setting('SF_SOURCE_TRACKING_ASSUME_SYNCED', kitBoolean, false), + disableSourceMobility: setting('SF_DISABLE_SOURCE_MOBILITY', boolean, false), + assumeSynced: setting('SF_SOURCE_TRACKING_ASSUME_SYNCED', boolean, false), lockTimeoutSeconds: setting('SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS', positiveNumber, DEFAULT_LOCK_TIMEOUT_SECONDS), lockAutoclearSeconds: setting( 'SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS', @@ -177,7 +168,7 @@ const settingsConfig = Config.all({ disableSourceMemberPolling: aliasedSetting( 'SF_DISABLE_SOURCE_MEMBER_POLLING', 'SFDX_DISABLE_SOURCE_MEMBER_POLLING', - kitBoolean, + boolean, false ), sourceMemberPollingTimeout: aliasedSetting( @@ -187,7 +178,7 @@ const settingsConfig = Config.all({ undefined ), fileVersion: setting('SF_SOURCE_TRACKING_FILE_VERSION', natural, 0), - integrityCheck: setting('SF_SOURCE_TRACKING_INTEGRITY_CHECK', kitBoolean, false), + integrityCheck: setting('SF_SOURCE_TRACKING_INTEGRITY_CHECK', boolean, false), }).pipe( Config.map(({ sourceMemberPollingTimeout, ...settings }) => ({ ...settings, diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 42f27b49..dd20ac7c 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -48,13 +48,13 @@ describe('source tracking config', () => { } }); - it('uses defaults and Kit-compatible boolean parsing', async () => { + it('uses Effect boolean literals and warns on invalid values', async () => { const warnings: string[] = []; sinon.stub(Lifecycle.getInstance(), 'emitWarning').callsFake(async (warning) => void warnings.push(warning)); const provider = ConfigProvider.fromEnvRecord({ - SF_DISABLE_SOURCE_MOBILITY: 'TrUe', + SF_DISABLE_SOURCE_MOBILITY: 'yes', SF_SOURCE_TRACKING_ASSUME_SYNCED: '1', - SF_DISABLE_SOURCE_MEMBER_POLLING: 'yes', + SF_DISABLE_SOURCE_MEMBER_POLLING: 'on', SF_SOURCE_TRACKING_INTEGRITY_CHECK: 'TRUE', }); const settings = await loadSourceTrackingSettings(provider).pipe(Effect.runPromise); @@ -62,12 +62,12 @@ describe('source tracking config', () => { expect(settings).to.include({ disableSourceMobility: true, assumeSynced: true, - disableSourceMemberPolling: false, - integrityCheck: true, + disableSourceMemberPolling: true, + integrityCheck: false, fileVersion: 0, lockTimeoutSeconds: 600, }); - expect(warnings).to.deep.equal(['SF_DISABLE_SOURCE_MEMBER_POLLING is invalid; using false']); + expect(warnings).to.deep.equal(['SF_SOURCE_TRACKING_INTEGRITY_CHECK is invalid; using false']); }); it('uses validated aliases and warns on deprecated batch-size variables', async () => { @@ -189,7 +189,7 @@ describe('source tracking config', () => { SF_SOURCE_TRACKING_FILE_VERSION: '2', SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS: '1.5', SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS: '2.5', - SF_DISABLE_SOURCE_MOBILITY: 'FALSE', + SF_DISABLE_SOURCE_MOBILITY: 'false', SF_SOURCE_TRACKING_ASSUME_SYNCED: '0', }).pipe(loadSourceTrackingSettings, Effect.runPromise); expect(settings).to.include({ From 34499429f30b87ad405ceccecb2588e3f250ba5e Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 2 Nov 2026 15:22:26 -0600 Subject: [PATCH 10/10] refactor(effect): keep shadow creation inside SourceTracking --- src/config.ts | 13 +- src/git/integrityCheck.ts | 84 ------------- src/shared/local/localShadowRepo.ts | 13 +- src/sourceTracking.ts | 14 ++- test/nuts/local/commitPerf.nut.ts | 2 +- ...rackingFileMoveChildrenWithSameHash.nut.ts | 2 +- .../localTrackingFileMoveThenEdit.nut.ts | 2 +- ...calTrackingFileMovesDecomposedChild.nut.ts | 2 +- .../local/localTrackingFileMovesImage.nut.ts | 2 +- .../local/localTrackingFileMovesScale.nut.ts | 2 +- test/nuts/local/localTrackingScale.nut.ts | 2 +- test/nuts/local/localTrackingScenario.nut.ts | 2 +- test/nuts/local/nonTopLevelIgnore.nut.ts | 2 +- test/nuts/local/pkgDirMatching.nut.ts | 2 +- test/nuts/local/reactInternalApp.nut.ts | 2 +- test/nuts/local/relativePkgDirs.nut.ts | 2 +- test/nuts/local/uiBundleSourceTracking.nut.ts | 2 +- test/unit/config.test.ts | 8 +- test/unit/git/integrityCheck.test.ts | 113 ------------------ test/unit/localDetectMovedFiles.test.ts | 10 +- test/unit/moveDetection.test.ts | 2 +- test/utils/createShadowRepo.ts | 33 +++++ 22 files changed, 66 insertions(+), 250 deletions(-) delete mode 100644 src/git/integrityCheck.ts delete mode 100644 test/unit/git/integrityCheck.test.ts create mode 100644 test/utils/createShadowRepo.ts diff --git a/src/config.ts b/src/config.ts index f32ae425..44c7fea7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,7 +59,6 @@ export type SourceTrackingSettings = { readonly sourceMemberPollingIntervalMs: number; readonly sourceMemberPollingEmptyResultLimit: number; readonly fileVersion: number; - readonly integrityCheck: boolean; }; export const DEFAULT_SOURCE_TRACKING_SETTINGS: SourceTrackingSettings = { @@ -71,7 +70,6 @@ export const DEFAULT_SOURCE_TRACKING_SETTINGS: SourceTrackingSettings = { disableSourceMemberPolling: false, ...sourceMemberPollingSettings(), fileVersion: 0, - integrityCheck: false, }; export const SourceTrackingConfig = Context.Reference('@salesforce/source-tracking/config', { @@ -82,8 +80,6 @@ const positiveNumber = (name: string): Config.Config => Config.schema(Schema.Finite.check(Schema.isGreaterThan(0)), name); const positiveInteger = (name: string): Config.Config => Config.schema(Schema.Int.check(Schema.isGreaterThan(0)), name); -const natural = (name: string): Config.Config => Config.schema(Schema.Natural, name); -const boolean = (name: string): Config.Config => Config.Boolean(name); const emitConfigWarning = Effect.fnUntraced(function* (warning: string) { yield* Effect.tryPromise({ @@ -157,8 +153,8 @@ const fdPermitsConfig = Config.all({ const settingsConfig = Config.all({ fdPermits: fdPermitsConfig, - disableSourceMobility: setting('SF_DISABLE_SOURCE_MOBILITY', boolean, false), - assumeSynced: setting('SF_SOURCE_TRACKING_ASSUME_SYNCED', boolean, false), + disableSourceMobility: setting('SF_DISABLE_SOURCE_MOBILITY', Config.Boolean, false), + assumeSynced: setting('SF_SOURCE_TRACKING_ASSUME_SYNCED', Config.Boolean, false), lockTimeoutSeconds: setting('SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS', positiveNumber, DEFAULT_LOCK_TIMEOUT_SECONDS), lockAutoclearSeconds: setting( 'SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS', @@ -168,7 +164,7 @@ const settingsConfig = Config.all({ disableSourceMemberPolling: aliasedSetting( 'SF_DISABLE_SOURCE_MEMBER_POLLING', 'SFDX_DISABLE_SOURCE_MEMBER_POLLING', - boolean, + Config.Boolean, false ), sourceMemberPollingTimeout: aliasedSetting( @@ -177,8 +173,7 @@ const settingsConfig = Config.all({ positiveNumber, undefined ), - fileVersion: setting('SF_SOURCE_TRACKING_FILE_VERSION', natural, 0), - integrityCheck: setting('SF_SOURCE_TRACKING_INTEGRITY_CHECK', boolean, false), + fileVersion: setting('SF_SOURCE_TRACKING_FILE_VERSION', (name) => Config.schema(Schema.Natural, name), 0), }).pipe( Config.map(({ sourceMemberPollingTimeout, ...settings }) => ({ ...settings, diff --git a/src/git/integrityCheck.ts b/src/git/integrityCheck.ts deleted file mode 100644 index 21f99d66..00000000 --- a/src/git/integrityCheck.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2026, Salesforce, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import * as Effect from 'effect/Effect'; -import * as Schema from 'effect/Schema'; -import { SourceTrackingConfig } from '../config.js'; -import { IndexCorruptError } from './errors.js'; -import { readIndex } from './indexV2.js'; -import { readDirectRef, readHead } from './refs.js'; -import { RefName } from './schemas.js'; - -/** - * Optional startup integrity check, gated by SF_SOURCE_TRACKING_INTEGRITY_CHECK - * during the dogfood window per phase 13. Verifies that .git/HEAD parses - * and points at refs/heads/main, that refs/heads/main resolves to a 40-hex - * oid, and that .git/index parses as v2 with a valid trailing SHA-1. - * - * Run by source-tracking on first ShadowRepo construction when the flag - * is set. Fails closed with IndexCorruptError so the caller can prompt the - * user to delete + re-init the shadow. - */ -export const isEnabled = Effect.map(SourceTrackingConfig, (config) => config.integrityCheck); - -const MAIN_REF: RefName = Schema.decodeUnknownSync(RefName)('refs/heads/main'); - -/** @internal */ -export const runIntegrityCheck = Effect.fn('runIntegrityCheck')(function* (gitdir: string) { - const head = yield* readHead(gitdir).pipe( - Effect.catchTag('RefNotFoundError', (cause) => - Effect.fail( - new IndexCorruptError({ - gitdir, - reason: 'head-missing', - message: `integrity check: HEAD not found (${cause.message})`, - }) - ) - ) - ); - yield* head.kind === 'symbolic' && head.target === MAIN_REF - ? Effect.void - : Effect.fail( - new IndexCorruptError({ - gitdir, - reason: 'head-not-symbolic-main', - message: `integrity check: HEAD is ${ - head.kind === 'symbolic' ? `symbolic to ${head.target}` : 'detached' - }, expected refs/heads/main`, - }) - ); - yield* readDirectRef(gitdir, MAIN_REF).pipe( - Effect.catchTag('RefNotFoundError', (cause) => - Effect.fail( - new IndexCorruptError({ - gitdir, - reason: 'main-missing', - message: `integrity check: refs/heads/main not found (${cause.message})`, - }) - ) - ) - ); - yield* readIndex(gitdir).pipe( - Effect.catchTag('IndexCorruptError', (cause) => - Effect.fail( - new IndexCorruptError({ - gitdir, - reason: 'index-corrupt', - message: `integrity check: ${cause.message}`, - }) - ) - ) - ); -}); diff --git a/src/shared/local/localShadowRepo.ts b/src/shared/local/localShadowRepo.ts index cde6fc3f..43c9c7b7 100644 --- a/src/shared/local/localShadowRepo.ts +++ b/src/shared/local/localShadowRepo.ts @@ -36,12 +36,7 @@ import { Repo } from '../../git/repo.js'; import { repoErrorToSfError } from '../../git/sfCompatibility.js'; import { Author as AuthorSchema, RepoPath as RepoPathSchema, type RepoPath, RepoConfig } from '../../git/schemas.js'; import { excludeLwcLocalOnlyTest, folderContainsPath } from '../functions.js'; -import { - getProcessSourceTrackingRuntime, - SourceTrackingConfig, - sourceTrackingConfigLayer, - type SourceTrackingSettings, -} from '../../config.js'; +import { SourceTrackingConfig, sourceTrackingConfigLayer, type SourceTrackingSettings } from '../../config.js'; import { getMatches, ensurePosix, IS_WINDOWS } from './functions.js'; import { filenameMatchesToMap } from './moveDetection.js'; import type { LocalChanges, ShadowRepoOptions, SyncRequest } from './types.js'; @@ -309,7 +304,7 @@ const runEffect = ( ); /** @internal Build a local repo using SourceTrackingConfig from the Effect context. */ -export const createShadowRepoEffect = Effect.fn('createShadowRepoEffect')(function* (options: ShadowRepoOptions) { +export const createShadowRepo = Effect.fn('createShadowRepo')(function* (options: ShadowRepoOptions) { const logger = yield* Effect.promise(() => Logger.child('ShadowRepo')); const settings = yield* SourceTrackingConfig; const cfg = makeRepoConfig(options, settings); @@ -338,7 +333,3 @@ export const createShadowRepoEffect = Effect.fn('createShadowRepoEffect')(functi delete: () => runEffect(runtime, logger, 'delete', effects.delete()), }; }); - -/** Promise adapter for SourceTracking and CLI callers; the implementation above stays Effect-native. */ -export const createShadowRepo = (options: ShadowRepoOptions): Promise => - getProcessSourceTrackingRuntime().runPromise(createShadowRepoEffect(options)); diff --git a/src/sourceTracking.ts b/src/sourceTracking.ts index 1fd06abb..dc8d36c7 100644 --- a/src/sourceTracking.ts +++ b/src/sourceTracking.ts @@ -562,12 +562,14 @@ export class SourceTracking extends AsyncCreatable { return; } const initialization = (this.localTrackingInitialization ??= (async (): Promise => { - const localRepo = await createShadowRepo({ - orgId: this.orgId, - projectPath: normalize(this.projectPath), - packageDirs: this.packagesDirs, - registry: this.registry, - }); + const localRepo = await runWithProcessSourceTrackingRuntime( + createShadowRepo({ + orgId: this.orgId, + projectPath: normalize(this.projectPath), + packageDirs: this.packagesDirs, + registry: this.registry, + }) + ); // Load status before publishing the repo so concurrent callers await // the same initialized handle and cache. await localRepo.getStatus(); diff --git a/test/nuts/local/commitPerf.nut.ts b/test/nuts/local/commitPerf.nut.ts index 7a016670..f08e7bf6 100644 --- a/test/nuts/local/commitPerf.nut.ts +++ b/test/nuts/local/commitPerf.nut.ts @@ -18,7 +18,7 @@ import { performance } from 'node:perf_hooks'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo, type ShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo, type ShadowRepo } from '../../utils/createShadowRepo.js'; describe('perf testing for big commits', () => { let session: TestSession; diff --git a/test/nuts/local/localTrackingFileMoveChildrenWithSameHash.nut.ts b/test/nuts/local/localTrackingFileMoveChildrenWithSameHash.nut.ts index 795c23bd..058bc066 100644 --- a/test/nuts/local/localTrackingFileMoveChildrenWithSameHash.nut.ts +++ b/test/nuts/local/localTrackingFileMoveChildrenWithSameHash.nut.ts @@ -19,7 +19,7 @@ import * as fs from 'node:fs'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo, type ShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo, type ShadowRepo } from '../../utils/createShadowRepo.js'; describe('can match files with the same hash when the have different parents ', () => { const registry = new RegistryAccess(); diff --git a/test/nuts/local/localTrackingFileMoveThenEdit.nut.ts b/test/nuts/local/localTrackingFileMoveThenEdit.nut.ts index 452f8764..cc9da73d 100644 --- a/test/nuts/local/localTrackingFileMoveThenEdit.nut.ts +++ b/test/nuts/local/localTrackingFileMoveThenEdit.nut.ts @@ -22,7 +22,7 @@ import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; import { type ProjectJson } from '@salesforce/schemas'; import { type NamedPackageDir } from '@salesforce/core'; import { type PackageDir } from '@salesforce/schemas'; -import { createShadowRepo, type ShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo, type ShadowRepo } from '../../utils/createShadowRepo.js'; describe('handles local files moves that also change the file', () => { let session: TestSession; diff --git a/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts b/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts index a75c65fa..c2c11958 100644 --- a/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts +++ b/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts @@ -19,7 +19,7 @@ import fs from 'graceful-fs'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo, type ShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo, type ShadowRepo } from '../../utils/createShadowRepo.js'; describe('ignores moved files that are children of a decomposed metadata type', () => { const FIELD = path.join('fields', 'Account__c.field-meta.xml'); diff --git a/test/nuts/local/localTrackingFileMovesImage.nut.ts b/test/nuts/local/localTrackingFileMovesImage.nut.ts index 2968d7bf..24b9a9bc 100644 --- a/test/nuts/local/localTrackingFileMovesImage.nut.ts +++ b/test/nuts/local/localTrackingFileMovesImage.nut.ts @@ -19,7 +19,7 @@ import fs from 'graceful-fs'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo, type ShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo, type ShadowRepo } from '../../utils/createShadowRepo.js'; describe('it detects image file moves ', () => { const registry = new RegistryAccess(); diff --git a/test/nuts/local/localTrackingFileMovesScale.nut.ts b/test/nuts/local/localTrackingFileMovesScale.nut.ts index 200c4869..13e44409 100644 --- a/test/nuts/local/localTrackingFileMovesScale.nut.ts +++ b/test/nuts/local/localTrackingFileMovesScale.nut.ts @@ -19,7 +19,7 @@ import fs from 'graceful-fs'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo, type ShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo, type ShadowRepo } from '../../utils/createShadowRepo.js'; const dirCount = 20; const classesPerDir = 50; diff --git a/test/nuts/local/localTrackingScale.nut.ts b/test/nuts/local/localTrackingScale.nut.ts index 62230195..faf9f8b3 100644 --- a/test/nuts/local/localTrackingScale.nut.ts +++ b/test/nuts/local/localTrackingScale.nut.ts @@ -19,7 +19,7 @@ import fs from 'graceful-fs'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo, type ShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo, type ShadowRepo } from '../../utils/createShadowRepo.js'; const dirCount = 200; const classesPerDir = 500; diff --git a/test/nuts/local/localTrackingScenario.nut.ts b/test/nuts/local/localTrackingScenario.nut.ts index f6d9012e..dfbf189b 100644 --- a/test/nuts/local/localTrackingScenario.nut.ts +++ b/test/nuts/local/localTrackingScenario.nut.ts @@ -20,7 +20,7 @@ import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { shouldThrow } from '@salesforce/core/testSetup'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo, type ShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo, type ShadowRepo } from '../../utils/createShadowRepo.js'; describe('end-to-end-test for local tracking', () => { let session: TestSession; diff --git a/test/nuts/local/nonTopLevelIgnore.nut.ts b/test/nuts/local/nonTopLevelIgnore.nut.ts index cf9f8817..91d1063e 100644 --- a/test/nuts/local/nonTopLevelIgnore.nut.ts +++ b/test/nuts/local/nonTopLevelIgnore.nut.ts @@ -18,7 +18,7 @@ import fs from 'graceful-fs'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo, type ShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo, type ShadowRepo } from '../../utils/createShadowRepo.js'; const registry = new RegistryAccess(); diff --git a/test/nuts/local/pkgDirMatching.nut.ts b/test/nuts/local/pkgDirMatching.nut.ts index 5f6d9cf5..fc580ae2 100644 --- a/test/nuts/local/pkgDirMatching.nut.ts +++ b/test/nuts/local/pkgDirMatching.nut.ts @@ -18,7 +18,7 @@ import fs from 'graceful-fs'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo, type ShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo, type ShadowRepo } from '../../utils/createShadowRepo.js'; describe('verifies exact match of pkgDirs', () => { const registry = new RegistryAccess(); diff --git a/test/nuts/local/reactInternalApp.nut.ts b/test/nuts/local/reactInternalApp.nut.ts index 704f0421..9a934d57 100644 --- a/test/nuts/local/reactInternalApp.nut.ts +++ b/test/nuts/local/reactInternalApp.nut.ts @@ -19,7 +19,7 @@ import { execSync } from 'node:child_process'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo } from '../../utils/createShadowRepo.js'; import { getGroupedFiles, getComponentSets } from '../../../src/shared/localComponentSetArray.js'; const findUiBundleDir = (projectDir: string): string => { diff --git a/test/nuts/local/relativePkgDirs.nut.ts b/test/nuts/local/relativePkgDirs.nut.ts index 390e7adb..52c7a025 100644 --- a/test/nuts/local/relativePkgDirs.nut.ts +++ b/test/nuts/local/relativePkgDirs.nut.ts @@ -18,7 +18,7 @@ import fs from 'graceful-fs'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo, type ShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo, type ShadowRepo } from '../../utils/createShadowRepo.js'; describe('verifies behavior of relative pkgDirs', () => { let session: TestSession; diff --git a/test/nuts/local/uiBundleSourceTracking.nut.ts b/test/nuts/local/uiBundleSourceTracking.nut.ts index 2c3ba219..3cd1d88b 100644 --- a/test/nuts/local/uiBundleSourceTracking.nut.ts +++ b/test/nuts/local/uiBundleSourceTracking.nut.ts @@ -17,7 +17,7 @@ import path from 'node:path'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { ComponentStatus, RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { createShadowRepo } from '../../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo } from '../../utils/createShadowRepo.js'; import { calculateExpectedSourceMembers } from '../../../src/shared/remote/expectedSourceMembers.js'; describe('UIBundle excluded from source tracking polling', () => { diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index dd20ac7c..ed3c92bf 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -54,20 +54,18 @@ describe('source tracking config', () => { const provider = ConfigProvider.fromEnvRecord({ SF_DISABLE_SOURCE_MOBILITY: 'yes', SF_SOURCE_TRACKING_ASSUME_SYNCED: '1', - SF_DISABLE_SOURCE_MEMBER_POLLING: 'on', - SF_SOURCE_TRACKING_INTEGRITY_CHECK: 'TRUE', + SF_DISABLE_SOURCE_MEMBER_POLLING: 'TRUE', }); const settings = await loadSourceTrackingSettings(provider).pipe(Effect.runPromise); expect(settings).to.include({ disableSourceMobility: true, assumeSynced: true, - disableSourceMemberPolling: true, - integrityCheck: false, + disableSourceMemberPolling: false, fileVersion: 0, lockTimeoutSeconds: 600, }); - expect(warnings).to.deep.equal(['SF_SOURCE_TRACKING_INTEGRITY_CHECK is invalid; using false']); + expect(warnings).to.deep.equal(['SF_DISABLE_SOURCE_MEMBER_POLLING is invalid; using false']); }); it('uses validated aliases and warns on deprecated batch-size variables', async () => { diff --git a/test/unit/git/integrityCheck.test.ts b/test/unit/git/integrityCheck.test.ts deleted file mode 100644 index a9afb962..00000000 --- a/test/unit/git/integrityCheck.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2026, Salesforce, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import * as path from 'node:path'; -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import { expect } from 'chai'; -import * as Effect from 'effect/Effect'; -import * as Layer from 'effect/Layer'; -import * as Stream from 'effect/Stream'; -import * as Exit from 'effect/Exit'; -import * as Schema from 'effect/Schema'; -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem'; -import * as NodeCrypto from '@effect/platform-node/NodeCrypto'; -import * as NodePath from '@effect/platform-node/NodePath'; -import { applyChanges } from '../../../src/git/applyChanges.js'; -import { init } from '../../../src/git/init.js'; -import { isEnabled, runIntegrityCheck } from '../../../src/git/integrityCheck.js'; -import { type Author, RepoPath, type RepoConfig } from '../../../src/git/schemas.js'; -import { DEFAULT_SOURCE_TRACKING_SETTINGS, SourceTrackingConfig } from '../../../src/config.js'; - -const Layered = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, NodeCrypto.layer); -const FIXED_TS = Date.parse('2026-01-01T00:00:00Z'); -const ALICE: Author = { name: 'sfdx source tracking', email: 'source-tracking@noreply.salesforce.com' }; - -const cfg = (dir: string, gitdir: string): RepoConfig => ({ - dir, - gitdir, - roots: [Schema.decodeUnknownSync(RepoPath)('a')], - fdPermits: 8, -}); - -describe('git/integrityCheck (phase 13)', () => { - describe('isEnabled', () => { - it('default off', async () => { - expect( - await Effect.runPromise( - isEnabled.pipe(Effect.provideService(SourceTrackingConfig, DEFAULT_SOURCE_TRACKING_SETTINGS)) - ) - ).to.equal(false); - }); - - it('on for "true"', async () => { - expect( - await Effect.runPromise( - isEnabled.pipe( - Effect.provideService(SourceTrackingConfig, { - ...DEFAULT_SOURCE_TRACKING_SETTINGS, - integrityCheck: true, - }) - ) - ) - ).to.equal(true); - }); - }); - - describe('runIntegrityCheck', () => { - it('passes on a freshly-init+applyChanges shadow', async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'lite-integ-')); - try { - const dir = path.join(tmp, 'work'); - const gitdir = path.join(dir, '.git'); - await fs.mkdir(path.join(dir, 'a'), { recursive: true }); - await fs.writeFile(path.join(dir, 'a', 'b.txt'), 'hello\n'); - await Effect.runPromise(Effect.provide(init({ cfg: cfg(dir, gitdir), timestampMs: FIXED_TS }), Layered)); - await Effect.runPromise( - Effect.provide( - applyChanges({ - cfg: { dir, gitdir }, - adds: Stream.fromIterable([Schema.decodeUnknownSync(RepoPath)('a/b.txt')]), - removes: Stream.empty, - message: 'add', - author: ALICE, - }), - Layered - ) - ); - const exit = await Effect.runPromiseExit(Effect.provide(runIntegrityCheck(gitdir), Layered)); - expect(Exit.isSuccess(exit)).to.equal(true); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } - }); - - it('fails closed when refs/heads/main is absent', async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'lite-integ-fail-')); - try { - const dir = path.join(tmp, 'work'); - const gitdir = path.join(dir, '.git'); - await fs.mkdir(dir, { recursive: true }); - await Effect.runPromise(Effect.provide(init({ cfg: cfg(dir, gitdir), timestampMs: FIXED_TS }), Layered)); - // Plant corruption: delete refs/heads/main. - await fs.unlink(path.join(gitdir, 'refs', 'heads', 'main')); - const exit = await Effect.runPromiseExit(Effect.provide(runIntegrityCheck(gitdir), Layered)); - expect(Exit.isFailure(exit)).to.equal(true); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } - }); - }); -}); diff --git a/test/unit/localDetectMovedFiles.test.ts b/test/unit/localDetectMovedFiles.test.ts index 0e85d87e..1dbd0e34 100644 --- a/test/unit/localDetectMovedFiles.test.ts +++ b/test/unit/localDetectMovedFiles.test.ts @@ -19,16 +19,10 @@ import fs from 'node:fs'; import { expect, config } from 'chai'; import sinon from 'sinon'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import * as Effect from 'effect/Effect'; -import { createShadowRepoEffect, type ShadowRepo } from '../../src/shared/local/localShadowRepo.js'; -import { DEFAULT_SOURCE_TRACKING_SETTINGS, sourceTrackingConfigLayer } from '../../src/config.js'; +import { createShadowRepo, type ShadowRepo } from '../utils/createShadowRepo.js'; +import { DEFAULT_SOURCE_TRACKING_SETTINGS } from '../../src/config.js'; config.truncateThreshold = 0; -const createShadowRepo = ( - options: Parameters[0], - settings = DEFAULT_SOURCE_TRACKING_SETTINGS -) => Effect.runPromise(Effect.provide(createShadowRepoEffect(options), sourceTrackingConfigLayer(settings))); - afterEach(() => { sinon.restore(); }); diff --git a/test/unit/moveDetection.test.ts b/test/unit/moveDetection.test.ts index b7f0b49e..4ce4ac58 100644 --- a/test/unit/moveDetection.test.ts +++ b/test/unit/moveDetection.test.ts @@ -27,7 +27,7 @@ import * as Layer from 'effect/Layer'; import * as Schema from 'effect/Schema'; import { Repo } from '../../src/git/repo.js'; import { type RepoConfig, RepoPath } from '../../src/git/schemas.js'; -import { createShadowRepo } from '../../src/shared/local/localShadowRepo.js'; +import { createShadowRepo } from '../utils/createShadowRepo.js'; import { filenameMatchesToMap } from '../../src/shared/local/moveDetection.js'; const testLayer = (cfg: RepoConfig) => diff --git a/test/utils/createShadowRepo.ts b/test/utils/createShadowRepo.ts new file mode 100644 index 00000000..dfea94f4 --- /dev/null +++ b/test/utils/createShadowRepo.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as Effect from 'effect/Effect'; +import { + DEFAULT_SOURCE_TRACKING_SETTINGS, + sourceTrackingConfigLayer, + type SourceTrackingSettings, +} from '../../src/config.js'; +import { createShadowRepo as createShadowRepoEffect, type ShadowRepo } from '../../src/shared/local/localShadowRepo.js'; +import type { ShadowRepoOptions } from '../../src/shared/local/types.js'; + +export type { ShadowRepo }; + +/** Run the Effect factory with an injected config layer for local-tracking tests. */ +export const createShadowRepo = ( + options: ShadowRepoOptions, + settings: SourceTrackingSettings = DEFAULT_SOURCE_TRACKING_SETTINGS +): Promise => + Effect.runPromise(Effect.provide(createShadowRepoEffect(options), sourceTrackingConfigLayer(settings)));