배경 — 소비자 요구 3건, 하나의 스캔 구조
- 취소·양보 가능 스캔:
search()가 전 엔트리를 동기 단일 루프로 훑고 한 번에 반환한다 (createSearcher.ts makeRuntime의 scan 루프). 워커에서 쓰면 블로킹되고, 키스트로크 간 이전 쿼리 취소가 불가능하다. 소비자는 N건마다 event loop 양보 + AbortSignal 취소가 필요하다. 세션 재사용은 비용을 줄일 뿐 cold(첫 타) 스캔의 양보/취소성을 주지 못한다
- 정확한 total:
limit > 0이면 heap이 top-N만 반환하지만, 전체 매치 수(matchedIndices.length)는 내부적으로 이미 알고 있다 — 노출만 안 됨. UI의 "전체 N건" 표시에 필요
- per-call filter: 그룹 필터링을 위해 searcher를 그룹별로 쪼개면 전역 랭킹·limit·세션이 깨진다 → 단일 searcher에 per-call 필터 필요. 단, 필터는 세션 prefix-reuse 캐시와 충돌하므로 재사용 규칙이 함께 정의되어야 한다
셋 다 "스캔 한 번의 진행 / 산출물 / 커밋 시점"이라는 같은 지점을 건드리므로 하나의 커서 구조로 함께 해결한다.
설계 결정
pull 기반 커서 — 라이브러리에 Promise/AbortSignal을 도입하지 않는다:
- 취소 = 커서를 버리는 것. 세션 커밋은 스캔 완료 시에만 일어나므로, 중단된 스캔의 부분
matchedIndices(전체 매치 집합의 부분집합)가 세션에 커밋되어 이후 prefix 쿼리가 매치를 누락하는 오염이 구조적으로 불가능
- 양보 주기(budget)와 async 래핑은 소비자 몫 — 라이브러리는 zero-dependency 유지
interface ScanCursor<R> {
/** budget개 엔트리 평가 후 반환. 스캔 완료 시 true. budget 생략 = 끝까지 */
next(budget?: number): boolean;
readonly done: boolean;
/** 지금까지 평가한 엔트리 수 (진행률 UI용) */
readonly processed: number;
/** 이 스캔이 평가할 엔트리 총수 (세션 재사용 시 = 이전 매치 수) */
readonly scanSize: number;
/** 지금까지 발견한 매치 수. done 이후엔 limit와 무관한 정확한 전체 매치 수 */
readonly total: number;
/** score desc 정렬된 결과 (limit 적용). done 전엔 현재까지의 부분 결과 snapshot */
results(): R[];
}
Searcher<T>.scan(queryInput, options?: SearchResultOptions<T>): ScanCursor<SearchResult<T>>, 멀티필드 동일 (ScanCursor<MultiFieldSearchResult<T>>)
SearchResultOptions를 SearchResultOptions<T = unknown>로 제네릭화하고 filter?: (item: T) => boolean 추가. 기본 타입 인자 덕에 기존 무인자 사용처(SearchOptions alias 포함) 호환
search()는 scan 위에 재구현: const c = scanImpl(q, opts); c.next(); return c.results(); — 코드 경로 단일화. 기존 시그니처·반환 타입 불변 (total이 필요한 소비자는 scan 사용)
세션 커밋 규칙
- 커밋(
prevTokens / prevLiteral / prevMatchedIndices / prevFilter)은 next()가 마지막 엔트리를 평가해 스캔을 완료하는 시점에만 수행
- mutation guard: makeRuntime에
generation 카운터를 두고 add/remove/replaceAll에서 증가. 커서는 생성 시 캡처하고 next()에서 불일치하면 Error("fuzzly: searcher was mutated during scan") throw (entries 인덱스가 무효화되므로). results()는 이미 만들어진 값의 반환이라 guard 불필요
- 커서 동시 사용 허용, last-completion-wins: 늦게 완료된 이전 쿼리의 커서가 세션을 "되돌려도", 커밋되는 (tokens ↔ matched set ↔ filter) 쌍이 내부적으로 일관되므로 unsound하지 않다 — 다음 재사용이 덜 최적일 뿐. 코드 주석으로 문서화
filter × 세션 재사용
- filter는 evaluate 전에 평가 — 미통과 엔트리는 매칭 비용 자체를 스킵하고 결과·total·
matchedIndices에서 제외
- 재사용 조건 확장: 기존 토큰 atom-prefix 조건 AND literal 플래그 일치 AND
filtersCompatible:
currentFilter === prevFilter (참조 동등) → 재사용 가능
prevFilter == null (이전 스캔이 무필터) → 재사용 가능 — superset을 좁히는 방향이라 sound
- 그 외 (필터 제거·교체) → full scan
- 계약 문서화: 키스트로크 간 세션 재사용을 유지하려면 동일한 함수 참조를 유지할 것 (그룹 선택별로 filter 함수를 memoize)
선행 이슈
변경 사항
src/types.ts
ScanCursor<R> 추가 (export)
SearchResultOptions<T = unknown> + filter?: (item: T) => boolean
Searcher<T> / MultiFieldSearcher<T>에 scan() 추가, search의 options 타입을 SearchResultOptions<T>로
src/createSearcher.ts — makeRuntime 재구성
SEARCH_ONLY_KEYS에 "filter" 추가
- 세션 상태에
prevFilter 추가 (resetSession 포함), generation 카운터 + 뮤테이션 메서드에서 증가
scan(queryInput, opts) 구현:
- 쿼리 빌드 / 토큰 산출 / 재사용 판정(filter 호환 포함)은 커서 생성 시 1회
- 스캔 소스는
sessionIndices 배열 또는 0..entries.length 숫자 범위 — 커서 내부 position 인덱스로 budget 단위 진행 (iota generator 제거)
- limit 경로 heap / no-limit 수집 배열을 커서 상태로 이동.
results(): 미완료 시 복사본 정렬([...heap].sort), 완료 시 1회 정렬 후 캐시
- 완료 시점에 세션 커밋
search()를 scan 합성으로 교체
테스트 (신규 test/scan.test.ts 권장)
- 등가성: 동일 쿼리에서
scan + next() + results() ≡ search() (search가 scan 합성으로 바뀌므로 기존 search 테스트 전체 green도 등가성 증거)
- budget:
next(2) 반복 시 processed 단조 증가, 완료 전 done === false, 최종 processed === scanSize
- total: 매치 5건 /
limit: 2 → results().length === 2, total === 5. prefix 확장으로 세션 재사용된 후에도 fresh searcher와 total 동일
- abort 무해성:
scan("가")을 절반만 진행하고 버림 → 이어지는 search("가나") 결과가 fresh searcher와 동일 (부분 스캔이 세션을 오염시키지 않음)
- filter:
- 적용 시 결과·total 정확
- 동일 참조 유지한 prefix 시퀀스 = fresh와 동일 결과 (재사용 경로 정확성)
- 참조 교체 시에도 정확 (full scan 강제 확인 — 이전 필터보다 넓은 필터로 교체해 재사용이 남아 있으면 결과가 누락되는 구성으로)
- 무필터 세션 뒤 필터 추가도 정확
- mutation guard: scan 진행 중
add() → 다음 next() throw
- 멀티필드 smoke: scan / total / filter 각 1케이스 (공유 런타임 검증)
문서
- CLAUDE.md: Public API에
scan/ScanCursor, SearchResultOptions.filter, total 노출, 커밋·재사용 규칙(완료 시 커밋, filter 참조 동등) 요약 추가
- 소비자 async 래퍼 예시를
scan() JSDoc에 포함:
async function searchAsync(searcher, q, { limit, filter, signal, chunk = 256 } = {}) {
const cursor = searcher.scan(q, { limit, filter });
while (!cursor.next(chunk)) {
if (signal?.aborted) return null; // 커서 버림 = 취소. 세션 오염 없음
await new Promise((r) => setTimeout(r)); // event loop 양보
}
return { results: cursor.results(), total: cursor.total };
}
Non-goals
- AbortSignal/async API 라이브러리 내장 (위 래퍼로 충분, zero-dependency 유지)
- done 전
results()의 순서/완전성 보장 강화 (현재까지의 top-N snapshot이면 충분)
- score 기반 초성 demotion (#36의 후속 아이디어 — 별도 이슈로)
완료 기준
배경 — 소비자 요구 3건, 하나의 스캔 구조
search()가 전 엔트리를 동기 단일 루프로 훑고 한 번에 반환한다 (createSearcher.tsmakeRuntime의 scan 루프). 워커에서 쓰면 블로킹되고, 키스트로크 간 이전 쿼리 취소가 불가능하다. 소비자는 N건마다 event loop 양보 + AbortSignal 취소가 필요하다. 세션 재사용은 비용을 줄일 뿐 cold(첫 타) 스캔의 양보/취소성을 주지 못한다limit > 0이면 heap이 top-N만 반환하지만, 전체 매치 수(matchedIndices.length)는 내부적으로 이미 알고 있다 — 노출만 안 됨. UI의 "전체 N건" 표시에 필요셋 다 "스캔 한 번의 진행 / 산출물 / 커밋 시점"이라는 같은 지점을 건드리므로 하나의 커서 구조로 함께 해결한다.
설계 결정
pull 기반 커서 — 라이브러리에 Promise/AbortSignal을 도입하지 않는다:
matchedIndices(전체 매치 집합의 부분집합)가 세션에 커밋되어 이후 prefix 쿼리가 매치를 누락하는 오염이 구조적으로 불가능Searcher<T>.scan(queryInput, options?: SearchResultOptions<T>): ScanCursor<SearchResult<T>>, 멀티필드 동일 (ScanCursor<MultiFieldSearchResult<T>>)SearchResultOptions를SearchResultOptions<T = unknown>로 제네릭화하고filter?: (item: T) => boolean추가. 기본 타입 인자 덕에 기존 무인자 사용처(SearchOptionsalias 포함) 호환search()는 scan 위에 재구현:const c = scanImpl(q, opts); c.next(); return c.results();— 코드 경로 단일화. 기존 시그니처·반환 타입 불변 (total이 필요한 소비자는 scan 사용)세션 커밋 규칙
prevTokens/prevLiteral/prevMatchedIndices/prevFilter)은next()가 마지막 엔트리를 평가해 스캔을 완료하는 시점에만 수행generation카운터를 두고add/remove/replaceAll에서 증가. 커서는 생성 시 캡처하고next()에서 불일치하면Error("fuzzly: searcher was mutated during scan")throw (entries 인덱스가 무효화되므로).results()는 이미 만들어진 값의 반환이라 guard 불필요filter × 세션 재사용
matchedIndices에서 제외filtersCompatible:currentFilter === prevFilter(참조 동등) → 재사용 가능prevFilter == null(이전 스캔이 무필터) → 재사용 가능 — superset을 좁히는 방향이라 sound선행 이슈
(score, tie)비교를 그대로 유지변경 사항
src/types.tsScanCursor<R>추가 (export)SearchResultOptions<T = unknown>+filter?: (item: T) => booleanSearcher<T>/MultiFieldSearcher<T>에scan()추가,search의 options 타입을SearchResultOptions<T>로src/createSearcher.ts— makeRuntime 재구성SEARCH_ONLY_KEYS에"filter"추가prevFilter추가 (resetSession포함),generation카운터 + 뮤테이션 메서드에서 증가scan(queryInput, opts)구현:sessionIndices배열 또는0..entries.length숫자 범위 — 커서 내부 position 인덱스로 budget 단위 진행 (iotagenerator 제거)results(): 미완료 시 복사본 정렬([...heap].sort), 완료 시 1회 정렬 후 캐시search()를 scan 합성으로 교체테스트 (신규
test/scan.test.ts권장)scan+next()+results()≡search()(search가 scan 합성으로 바뀌므로 기존 search 테스트 전체 green도 등가성 증거)next(2)반복 시processed단조 증가, 완료 전done === false, 최종processed === scanSizelimit: 2→results().length === 2,total === 5. prefix 확장으로 세션 재사용된 후에도 fresh searcher와 total 동일scan("가")을 절반만 진행하고 버림 → 이어지는search("가나")결과가 fresh searcher와 동일 (부분 스캔이 세션을 오염시키지 않음)add()→ 다음next()throw문서
scan/ScanCursor,SearchResultOptions.filter, total 노출, 커밋·재사용 규칙(완료 시 커밋, filter 참조 동등) 요약 추가scan()JSDoc에 포함:Non-goals
results()의 순서/완전성 보장 강화 (현재까지의 top-N snapshot이면 충분)완료 기준
npm run check:fixcleansearch()가 scan 합성으로 단일 경로화