diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 00000000..44c7fea7 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,216 @@ +/* + * 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 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 { 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 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 = { + readonly fdPermits: number; + readonly disableSourceMobility: boolean; + readonly assumeSynced: boolean; + readonly lockTimeoutSeconds: number; + readonly lockAutoclearSeconds: number; + readonly disableSourceMemberPolling: boolean; + readonly sourceMemberPollingTimeout: (memberCount: number) => Duration.Duration; + readonly sourceMemberPollingIntervalMs: number; + readonly sourceMemberPollingEmptyResultLimit: number; + readonly fileVersion: number; +}; + +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, + ...sourceMemberPollingSettings(), + fileVersion: 0, +}; + +export const SourceTrackingConfig = Context.Reference('@salesforce/source-tracking/config', { + defaultValue: () => DEFAULT_SOURCE_TRACKING_SETTINGS, +}); + +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 emitConfigWarning = Effect.fnUntraced(function* (warning: string) { + yield* Effect.tryPromise({ + try: () => Lifecycle.getInstance().emitWarning(warning), + catch: () => undefined, + }).pipe(Effect.ignore); +}); + +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) + ? Config.succeed(fallback).pipe( + Config.mapEffect(() => + emitConfigWarning(`${name} is invalid; using ${String(fallback)}`).pipe(Effect.as(fallback)) + ) + ) + : Config.fail(error.cause) + ) + ); + +// Choose aliases before validation: an invalid canonical value must not enable a legacy override. +const aliasedSetting = ( + name: string, + alias: string, + makeConfig: (name: string) => Config.Config, + 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, makeConfig, fallback); + return setting(alias, makeConfig, fallback).pipe( + Config.mapEffect((decoded) => + emitConfigWarning(`${alias} is deprecated; use ${name}.`).pipe(Effect.as(decoded)) + ) + ); + }) + ); + +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 settingsConfig = Config.all({ + fdPermits: fdPermitsConfig, + 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', + positiveNumber, + DEFAULT_LOCK_AUTOCLEAR_SECONDS + ), + disableSourceMemberPolling: aliasedSetting( + 'SF_DISABLE_SOURCE_MEMBER_POLLING', + 'SFDX_DISABLE_SOURCE_MEMBER_POLLING', + Config.Boolean, + false + ), + sourceMemberPollingTimeout: aliasedSetting( + 'SF_SOURCE_MEMBER_POLLING_TIMEOUT', + 'SFDX_SOURCE_MEMBER_POLLING_TIMEOUT', + positiveNumber, + undefined + ), + fileVersion: setting('SF_SOURCE_TRACKING_FILE_VERSION', (name) => Config.schema(Schema.Natural, name), 0), +}).pipe( + Config.map(({ sourceMemberPollingTimeout, ...settings }) => ({ + ...settings, + ...sourceMemberPollingSettings(sourceMemberPollingTimeout), + })) +); + +export const loadSourceTrackingSettings = ( + provider: ConfigProvider.ConfigProvider = ConfigProvider.fromEnv() +): Effect.Effect => settingsConfig.parse(provider); + +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()); + +export const getProcessSourceTrackingSettings = (): Promise => + getProcessSourceTrackingRuntime().runPromise(SourceTrackingConfig); + +export const sourceTrackingConfigLayer = (settings: SourceTrackingSettings): Layer.Layer => + Layer.succeed(SourceTrackingConfig, settings); + +export const sourceTrackingConfigLayerFromProvider = ( + provider: ConfigProvider.ConfigProvider +): Layer.Layer => + Layer.effect(SourceTrackingConfig, loadSourceTrackingSettings(provider)); + +export const runWithProcessSourceTrackingRuntime = (effect: Effect.Effect): Promise => + getProcessSourceTrackingRuntime().runPromise(effect); diff --git a/src/git/integrityCheck.ts b/src/git/integrityCheck.ts deleted file mode 100644 index 9f66dcd7..00000000 --- a/src/git/integrityCheck.ts +++ /dev/null @@ -1,94 +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 Config from 'effect/Config'; -import * as ConfigProvider from 'effect/ConfigProvider'; -import * as Effect from 'effect/Effect'; -import * as Schema from 'effect/Schema'; -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 - * 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 = () => - Config.String(ENV_FLAG) - .pipe( - Config.withDefault('false'), - Config.map((value) => value === 'true') - ) - .parse(ConfigProvider.fromEnv()) - .pipe(Effect.catchTag('ConfigError', () => Effect.succeed(false))); - -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/git/lock.ts b/src/git/lock.ts index 57f4970a..9138f7de 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,14 +51,12 @@ 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 timeoutMs = Duration.toMillis(timeout); - const autoclearMs = Duration.toMillis(autoclear); + const timeout = Duration.seconds(config.lockTimeoutSeconds); + const autoclear = Duration.seconds(config.lockAutoclearSeconds); + const autoclearThresholdMs = Duration.toMillis(autoclear); const startMs = yield* Clock.currentTimeMillis; const tryAutoclear = Effect.fnUntraced(function* () { @@ -87,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; }); @@ -147,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 diff --git a/src/shared/conflicts.ts b/src/shared/conflicts.ts index 898b86e4..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 * as Effect from 'effect/Effect'; +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 Effect.runPromise( + await runWithProcessSourceTrackingRuntime( populateTypesAndNames({ excludeUnresolvable: true, projectPath, forceIgnore, registry })(localChanges) ) ) diff --git a/src/shared/local/localShadowRepo.ts b/src/shared/local/localShadowRepo.ts index 9a14420f..43c9c7b7 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,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 { 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 +52,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 +96,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 +112,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 +191,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 +212,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; @@ -327,14 +303,19 @@ const runEffect = ( ) ); -/** Promise adapter for SourceTracking and CLI callers; the implementation above stays Effect-native. */ -export const createShadowRepo = async (options: ShadowRepoOptions): Promise => { - const logger = await Logger.child('ShadowRepo'); - const cfg = await Effect.runPromise(makeRepoConfig(options)); +/** @internal Build a local repo using SourceTrackingConfig from the Effect context. */ +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); 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 effects = yield* Effect.promise(() => runEffect(runtime, logger, 'init', makeShadowRepo(options, logger, cfg))); const getStatus = (refresh = false): Promise => runEffect(runtime, logger, 'getStatus', effects.getStatus(refresh)); @@ -348,7 +329,7 @@ export const createShadowRepo = async (options: ShadowRepoOptions): Promise 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()), }; -}; +}); 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/orgQueries.ts b/src/shared/remote/orgQueries.ts index 3f81ce46..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 = 0): Duration.Duration => { - if (overriddenTimeout > 0) { - 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 218e6aaa..0baacb1b 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 { @@ -51,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, @@ -63,11 +62,6 @@ import { trackSourceMembers, } 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 = { org: Org; identity: RemoteTrackingIdentity; @@ -300,14 +294,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,15 +303,7 @@ 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 pollingTimeout = calculateTimeout(logger)(expectedSourceMembers.size, overriddenTimeout); + const pollingTimeout = config.sourceMemberPollingTimeout(expectedSourceMembers.size); const pollState = yield* Ref.make({ highestRevisionSoFar: state.serverMaxRevisionCounter, pollAttempts: 0, @@ -405,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( @@ -417,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/src/sourceTracking.ts b/src/sourceTracking.ts index 0e3c7083..dc8d36c7 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 { runWithProcessSourceTrackingRuntime } 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 runWithProcessSourceTrackingRuntime( 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 runWithProcessSourceTrackingRuntime( 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 runWithProcessSourceTrackingRuntime( retrieveRemoteChanges({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -561,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(); @@ -584,18 +587,18 @@ export class SourceTracking extends AsyncCreatable { * Useful before parallel operations */ public async ensureRemoteTracking(initializeWithQuery = false): Promise { - this.remoteTrackingInitialization ??= Effect.runPromise( + this.remoteTrackingInitialization ??= runWithProcessSourceTrackingRuntime( 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 runWithProcessSourceTrackingRuntime( makeRemoteTrackingCaches({ org: this.org, identity: this.remoteTrackingIdentity() }) ); if (initializeWithQuery) { - await Effect.runPromise( + await runWithProcessSourceTrackingRuntime( retrieveRemoteChanges({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -635,7 +638,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 runWithProcessSourceTrackingRuntime(deleteRemoteTracking(this.remoteTrackingIdentity())); this.remoteTrackingInitialization = undefined; this.remoteTrackingCaches = undefined; return filePath; @@ -646,7 +649,7 @@ export class SourceTracking extends AsyncCreatable { */ public async resetRemoteTracking(serverRevision?: number): Promise { await this.ensureRemoteTracking(); - const resetMembers = await Effect.runPromise( + const resetMembers = await runWithProcessSourceTrackingRuntime( resetRemoteTrackingEffect({ org: this.org, identity: this.remoteTrackingIdentity(), @@ -655,7 +658,7 @@ export class SourceTracking extends AsyncCreatable { }) ); if (this.remoteTrackingCaches) { - await Effect.runPromise(this.remoteTrackingCaches.sourceMembers.pipe(Cache.invalidateAll)); + await runWithProcessSourceTrackingRuntime(this.remoteTrackingCaches.sourceMembers.pipe(Cache.invalidateAll)); } return resetMembers.length; } @@ -823,7 +826,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 runWithProcessSourceTrackingRuntime( Effect.all( [ populateTypesAndNames(base)(adds), 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 new file mode 100644 index 00000000..ed3c92bf --- /dev/null +++ b/test/unit/config.test.ts @@ -0,0 +1,211 @@ +/* + * 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 { 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 { + DEFAULT_SOURCE_TRACKING_SETTINGS, + 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' }) + ); + 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 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: 'yes', + SF_SOURCE_TRACKING_ASSUME_SYNCED: '1', + SF_DISABLE_SOURCE_MEMBER_POLLING: 'TRUE', + }); + const settings = await loadSourceTrackingSettings(provider).pipe(Effect.runPromise); + + expect(settings).to.include({ + disableSourceMobility: true, + assumeSynced: true, + disableSourceMemberPolling: false, + fileVersion: 0, + lockTimeoutSeconds: 600, + }); + 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 () => { + const warnings: string[] = []; + 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.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[] = []; + 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', + 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.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'); + }); + + 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(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/integrityCheck.test.ts b/test/unit/git/integrityCheck.test.ts deleted file mode 100644 index c1298e27..00000000 --- a/test/unit/git/integrityCheck.test.ts +++ /dev/null @@ -1,105 +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'; - -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 () => { - delete process.env.SF_SOURCE_TRACKING_INTEGRITY_CHECK; - expect(await Effect.runPromise(isEnabled())).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; - } - }); - }); - - 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/git/lock.test.ts b/test/unit/git/lock.test.ts index 3940f65f..80ec6386 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 { DEFAULT_SOURCE_TRACKING_SETTINGS, SourceTrackingConfig } from '../../../src/config.js'; class TestFailure extends Schema.TaggedError()('TestFailure', { message: Schema.String, @@ -43,7 +43,14 @@ 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, { + ...DEFAULT_SOURCE_TRACKING_SETTINGS, + fdPermits: 64, + lockTimeoutSeconds, + lockAutoclearSeconds, + }); +const TestLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, configLayer()); const run = (eff: Effect.Effect): Promise> => Effect.runPromiseExit(Effect.provide(eff, TestLayer)); @@ -358,35 +365,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 +406,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..7b52af94 100644 --- a/test/unit/git/lockWorker.ts +++ b/test/unit/git/lockWorker.ts @@ -15,11 +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 { sourceTrackingConfigLayerFromProvider } from '../../../src/config.js'; const role = process.env.LOCK_TEST_ROLE; const gitdir = process.env.LOCK_TEST_GITDIR; @@ -55,4 +58,13 @@ 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))); +const provider = ConfigProvider.fromEnv(); +const runtime = ManagedRuntime.make( + Layer.mergeAll( + ConfigProvider.layer(provider), + sourceTrackingConfigLayerFromProvider(provider), + NodeFileSystem.layer, + NodePath.layer + ) +); +await runtime.runPromise(action); diff --git a/test/unit/localDetectMovedFiles.test.ts b/test/unit/localDetectMovedFiles.test.ts index 1cbfb396..1dbd0e34 100644 --- a/test/unit/localDetectMovedFiles.test.ts +++ b/test/unit/localDetectMovedFiles.test.ts @@ -19,7 +19,8 @@ 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 { createShadowRepo, type ShadowRepo } from '../utils/createShadowRepo.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/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/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/orgQueries.test.ts b/test/unit/remote/orgQueries.test.ts index f975aa51..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,24 +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, 0))).to.equal(5); - }); - - it('allocates fifty seconds per thousand members plus five seconds', () => { - expect(Duration.toSeconds(functionUnderTest(10_000, 0))).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); - }); -}); diff --git a/test/unit/remote/remoteSourceTracking.test.ts b/test/unit/remote/remoteSourceTracking.test.ts index 7fc90356..64edee83 100644 --- a/test/unit/remote/remoteSourceTracking.test.ts +++ b/test/unit/remote/remoteSourceTracking.test.ts @@ -19,11 +19,11 @@ 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 ConfigProvider from 'effect/ConfigProvider'; import * as Effect from 'effect/Effect'; -import { isUndefined } from 'effect/Predicate'; import { deleteRemoteTracking, initializeRemoteTracking, @@ -43,6 +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, + loadSourceTrackingSettings, + SourceTrackingConfig, +} from '../../../src/config.js'; const defaultSourceMember: SourceMember = { IsNewMember: false, @@ -66,11 +71,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 +86,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 +110,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 +237,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 +386,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 +405,15 @@ 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, + await ConfigProvider.fromEnvRecord({ SF_SOURCE_MEMBER_POLLING_TIMEOUT: '1' }).pipe( + loadSourceTrackingSettings, + Effect.runPromise + ) + ) + ) ); expect(queryStub.callCount).to.equal(1); expect(warnings.called).to.equal(false); @@ -469,8 +475,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 +501,17 @@ 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, + await ConfigProvider.fromEnvRecord({ SF_SOURCE_MEMBER_POLLING_TIMEOUT: '1' }).pipe( + loadSourceTrackingSettings, + Effect.runPromise + ) + ) + ) + ); expect(queryStub.callCount).to.equal(1); expect(warnings.some((warning) => warning.includes('SourceMembers timed out'))).to.equal(false); @@ -507,8 +521,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 +534,17 @@ 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, + await ConfigProvider.fromEnvRecord({ SF_SOURCE_MEMBER_POLLING_TIMEOUT: '1' }).pipe( + loadSourceTrackingSettings, + Effect.runPromise + ) + ) + ) + ); expect(queryStub.called).to.equal(true); expect(warnings.some((warning) => warning.includes('Polling for 1 SourceMembers timed out'))).to.equal(true); @@ -533,15 +555,45 @@ 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 () => { - 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); }); 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)));