diff --git a/docs/superpowers/plans/2026-09-16-adapter-barrel-boundary.md b/docs/superpowers/plans/2026-09-16-adapter-barrel-boundary.md new file mode 100644 index 0000000..33f79ea --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-adapter-barrel-boundary.md @@ -0,0 +1,673 @@ +# 어댑터 배럴 공개 경계 확립 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 어댑터 16개 그룹 전부에 `index.ts` 공개 경계를 세우고, 그룹 바깥은 배럴만 import하도록 게이트로 강제한다. + +**Architecture:** 각 어댑터 그룹의 `index.ts`가 그 그룹의 공개 표면이 된다. 그룹 내부 파일끼리, 그리고 어댑터→커널(`platform/**`) 간선은 지금처럼 파일 직접 import를 유지한다 — 커널의 정체성은 파일이고 `check:adapter-inventory`가 그것을 파일 경로로 검증하기 때문이다. `.dependency-cruiser.json`에 규칙 하나를 추가하고 회귀 fixture로 그 거부를 고정한다. + +**Tech Stack:** TypeScript (NodeNext/Bundler), dependency-cruiser, 자체 TypeScript 인지 import 그래프(`scripts/check-architecture.ts`), Vite, Vitest + +**Spec:** [`docs/superpowers/specs/2026-09-16-adapter-barrel-boundary-design.md`](../specs/2026-09-16-adapter-barrel-boundary-design.md) + +## Global Constraints + +- 기준선(2026-09-16, `develop` `5434760`): `check:architecture` PASS(297 모듈 / 897 의존 / 미해결 0), `check:types:app` PASS, `check:adapter-inventory` PASS(120 파일). **이 셋을 깨면 안 된다.** +- `docs/reviews/adapters/INVENTORY.md`는 `git ls-files src/adapters`와 **집합이 정확히 일치**해야 한다. 소스 파일을 추가/이동하면 같은 커밋에서 이 표와 하단 합계를 고친다. +- 어댑터→커널 import는 **파일 직접 경로를 유지**한다. `scripts/check-adapter-inventory.ts:63-83`이 4개 소비자에게 `platform/abortable-operation.ts`로 해석되는 specifier를 정규식으로 요구한다. +- `src/adapters/storage/index.ts`는 `indexeddb/`·`opfs/` 서브배럴을 **재수출하지 않는다.** `scripts/test-browser-file-storage-runtime-removal.ts:23-34`가 그 두 폴더만 삭제한 뒤 잔존 import를 예외로 잡는다. +- `src/adapters/service-worker/index.ts`는 `service-worker-entry.ts`를 참조하지 않는다. `tsconfig.app.json:18`이 제외한 파일이다. +- 번들 예산: `config/performance/budgets.json`의 `bundle.initialJsGzipBytes = 204800`. +- 프로젝트 소스는 TypeScript/TSX만. `allowJs` 비활성. 로컬 import는 명시적 `.ts`/`.tsx` 확장자 필수. +- 커밋 메시지 끝에 붙일 것: + `Co-Authored-By: Claude Opus 5 (1M context) ` + +--- + +### Task 1: 배럴 8개 생성 + INVENTORY 등록 + +없는 8개 그룹에 `index.ts`를 만든다. 이 태스크는 **파일 추가만** 한다 — 기존 import는 건드리지 않으므로 런타임 동작이 바뀌지 않는다. + +**Files:** +- Create: `src/adapters/auth/index.ts` +- Create: `src/adapters/diagnostics/index.ts` +- Create: `src/adapters/telemetry/index.ts` +- Create: `src/adapters/platform/index.ts` +- Create: `src/adapters/query-cache/index.ts` +- Create: `src/adapters/service-worker/index.ts` +- Create: `src/adapters/storage/index.ts` +- Create: `src/adapters/http/index.ts` +- Modify: `docs/reviews/adapters/INVENTORY.md` (행 8개 추가 + 합계) + +**Interfaces:** +- Produces: 위 8개 배럴이 내보내는 심볼 82개. Task 2가 이 경로들로 import를 바꾼다. 심볼 존재는 spec §7에서 82/82 기계 대조 완료(누락 0, 오타 0). +- Consumes: 없음 (첫 태스크) + +- [ ] **Step 1: 배럴 5개 생성 (단일 파일 그룹 + query-cache)** + +`src/adapters/auth/index.ts`: +```ts +export { + createAnonymousSessionAdapter, + createDemoSessionAdapter, + createExternalAuthSessionAdapter, + createUnavailableSessionAdapter, + DEMO_AUTHORIZATION_MARKER, + type DemoSessionAdapter, + type ExternalSessionOwner, +} from "./external-session-adapter.ts"; +``` + +`src/adapters/diagnostics/index.ts`: +```ts +export { + createDiagnosticsAdapter, + getLastBootEvidence, + MAX_DIAGNOSTIC_ENTRIES, + noOpDiagnostics, + recordBootFailure, +} from "./bounded-diagnostics.ts"; +``` + +`src/adapters/telemetry/index.ts`: +```ts +export { + createTelemetryAdapter, + MAX_TELEMETRY_QUEUE, + noOpTelemetry, + type TelemetryAdapter, + type TelemetryAdapterOptions, +} from "./best-effort-telemetry.ts"; +``` + +`src/adapters/storage/index.ts`: +```ts +/** + * 최상위 storage 배럴은 Web Storage 어댑터만 내보낸다. + * + * `./indexeddb/index.ts`와 `./opfs/index.ts`를 여기서 재수출하지 말 것. + * `scripts/test-browser-file-storage-runtime-removal.ts:23-34`가 그 두 폴더만 + * 삭제한 뒤 잔존 import를 예외로 잡는다 — 재수출하면 제거 드릴이 죽는다. + * 두 서브배럴이 각자 제거 가능한 런타임의 경계다. + */ +export { + createBrowserStorageAdapter, + type BrowserStorageDependencies, +} from "./browser-storage-adapter.ts"; +``` + +`src/adapters/query-cache/index.ts`: +```ts +export { + createConditionalValidatorStore, + type ConditionalValidatorBinding, + type ConditionalValidatorStore, +} from "./conditional-validator-store.ts"; +export { createCursorPaginationRuntime } from "./cursor-pagination-runtime.ts"; +export { + createServerStateScopeRuntime, + type ScopeResetParticipant, + type ServerStateScopeDependencies, +} from "./server-state-scope-runtime.ts"; +export { + createTanStackCacheCoordinator, + type TanStackCacheCoordinatorDependencies, +} from "./tanstack-cache-coordinator.ts"; +export { + createQueryCacheAdapter, + createQueryClient, + QUERY_CACHE_DEFAULTS, + type QueryCacheDependencies, +} from "./tanstack-query-cache.ts"; +``` + +- [ ] **Step 2: 배럴 3개 생성 (주석이 계약인 것들)** + +`src/adapters/platform/index.ts`: +```ts +/** + * 어댑터 커널의 공개 경계. + * + * `src/adapters/**` 안에서는 이 배럴을 쓰지 않는다. 커널 프리미티브는 파일 + * 경로로 직접 import한다 — `scripts/check-adapter-inventory.ts:63-83`이 + * `platform/abortable-operation.ts`로 해석되는 specifier를 네 소비자에게 + * 요구하고, `.dependency-cruiser.json`의 kernel carve-out도 폴더 단위다. + * 이 배럴은 bootstrap·features·tests 같은 그룹 바깥 소비자를 위한 문이다. + */ +export { + compensateLateHandle, + createAbortableOperation, + snapshotAbortTimers, + type AbortableOperation, + type AbortableOperationInput, + type AbortRace, + type AbortTerminalReason, + type AbortTimerSnapshot, +} from "./abortable-operation.ts"; +export { assertBoundedCapacity } from "./bounded-capacity.ts"; +export { + createBrowserLifecycleRuntime, + type BrowserLifecycleEvent, + type BrowserLifecycleRuntime, + type BrowserLifecycleSnapshot, +} from "./browser-lifecycle.ts"; +export { + createBrowserMutationIntentFactory, + type BrowserMutationIntentFactoryDependencies, +} from "./browser-mutation-intent-factory.ts"; +export { systemClock } from "./system-clock.ts"; +``` + +`src/adapters/service-worker/index.ts`: +```ts +/** + * `service-worker-entry.ts`는 여기서 참조하지 않는다. `tsconfig.app.json:18`이 + * 제외한 파일이라 참조하면 app 타입체크가 제외 대상을 끌어들인다. 그 파일은 + * export가 0개이므로 넣을 것도 없다. + */ +export { + createServiceWorkerRuntime, + type WorkerClientLike, + type WorkerRuntimeConfig, + type WorkerScopeLike, +} from "./service-worker-lifecycle.ts"; +export { + createServiceWorkerPageController, + type ActivationBlocker, + type PageControllerDependencies, +} from "./service-worker-page-controller.ts"; +export { + createNonceRegistry, + createServiceWorkerMessage, + parseServiceWorkerMessage, + type ParsedMessage, +} from "./service-worker-protocol.ts"; +``` + +`src/adapters/http/index.ts`: +```ts +/** + * §7–§8. 권장 경로는 V3 계약 실행기(`createContractHttpExecutor`)다. 설치된 + * 계약과 타입 입력을 받아 상한·전체 데드라인·재시도 권한·효과 확실성 판정을 + * 런타임이 소유한다. + */ +export { + createContractHttpExecutor, + type AuthIntegrationFailureReason, + type AuthOperationContext, + type CancellationOwner, + type ContractHttpExecutor, + type ContractHttpExecutorDependencies, + type HttpContractViolation, + type HttpContractViolationKind, + type HttpEffectCertainty, + type HttpExecutionContext, + type HttpExecutionObservation, + type HttpExecutionOutcome, + type HttpTransportFailure, + type SafeResponseMetadata, +} from "./http-execution-v3.ts"; +/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */ +export type { CredentialPatchOutcome } from "./http-contract-bridge.ts"; +/** + * V2 legacy. operationId + `LegacyHttpInput`으로 호출하는 범용 클라이언트다. + * 새 코드는 위의 V3 실행기를 쓴다. 남아 있는 이유는 계약이 아직 없는 + * 오퍼레이션을 위한 이행 경로이기 때문이다. + */ +export { + createHttpClient, + type HttpClient, + type HttpClientDependencies, + type HttpFailure, + type HttpResult, + type LegacyHttpInput, + type Scheduler, +} from "./client.ts"; +/** V2 `HttpClient.execute`의 첫 인자 타입. */ +export type { OperationRequestInput } from "./request-builder.ts"; +``` + +- [ ] **Step 3: 타입체크로 심볼 존재를 검증한다** + +Run: `corepack pnpm check:types:app` +Expected: PASS. 실패하면 존재하지 않는 심볼을 재수출한 것이다 — 에러가 지목한 이름을 해당 소스 파일에서 확인하고 배럴에서 빼라. spec §7의 대조표와 대조할 것. + +- [ ] **Step 4: 게이트가 새 파일 8개를 거부하는 것을 확인한다 (의도된 실패)** + +Run: `git add -A && corepack pnpm check:adapter-inventory` +Expected: **FAIL.** `git ls-files src/adapters`가 128개를 보고하는데 INVENTORY.md는 120행이므로 불일치를 보고한다. 이 실패를 본 뒤 Step 5로 간다. (실패하지 않으면 `git add`가 안 된 것이다.) + +- [ ] **Step 5: INVENTORY.md에 행 8개 추가** + +`docs/reviews/adapters/INVENTORY.md`의 표에 경로 알파벳 순서 위치로 끼워 넣고 번호를 다시 매긴다. 상세 리뷰 링크는 같은 그룹의 기존 행과 동일하게 쓴다. + +| 추가할 경로 | 상세 리뷰 링크 | +|---|---| +| `src/adapters/auth/index.ts` | `[Network/state](./01-network-and-state.md)` | +| `src/adapters/diagnostics/index.ts` | `[Network/state](./01-network-and-state.md)` | +| `src/adapters/http/index.ts` | `[Network/state](./01-network-and-state.md)` | +| `src/adapters/platform/index.ts` | `[Network/state](./01-network-and-state.md)` | +| `src/adapters/query-cache/index.ts` | `[Network/state](./01-network-and-state.md)` | +| `src/adapters/service-worker/index.ts` | `[Worker/push](./05-service-worker-and-web-push.md)` | +| `src/adapters/storage/index.ts` | `[Storage/files](./03-storage-and-browser-files.md)` | +| `src/adapters/telemetry/index.ts` | `[Network/state](./01-network-and-state.md)` | + +그리고 `docs/reviews/adapters/INVENTORY.md:132`의 `합계: **120/120**` → `합계: **128/128**`. + +- [ ] **Step 6: 게이트 3종 통과 확인** + +Run: `corepack pnpm check:adapter-inventory && corepack pnpm check:types:app && corepack pnpm check:architecture` +Expected: 셋 다 PASS. `check:adapter-inventory`가 `128 files PASS`를 출력한다. + +- [ ] **Step 7: 워커 realm 타입체크 — 실행 중 발견한 필수 단계** + +> 2026-09-16 실행 중 발견. 계획 초안에는 없었다. +> `tsconfig.service-worker.json`은 `src/adapters/service-worker` 폴더를 통째로 +> WebWorker lib로 컴파일하면서 페이지 realm 파일 2개만 `exclude`로 뺀다. +> 새 `index.ts`가 그 폴더 안에서 `service-worker-page-controller.ts`를 +> import하므로, 제외하지 않으면 페이지 realm 파일이 WebWorker lib 컴파일에 +> 끌려 들어와 `Cannot find name 'document'`로 실패한다. +> (M6 리뷰의 "realm 경계가 폴더가 아니라 tsconfig exclude 2줄로만 표현된다"가 +> 그대로 발현된 것이다.) + +`tsconfig.service-worker.json`의 `exclude` 배열 맨 앞에 추가한다: + +```json + "src/adapters/service-worker/index.ts", +``` + +타입 커버리지는 `tsconfig.app.json`이 이 배럴을 포함하므로 유지된다. 워커 진입점 +`service-worker-entry.ts`는 배럴을 쓰지 않고 파일을 직접 import하므로 워커 번들에 +영향이 없다. + +Run: `corepack pnpm check:types:service-worker` +Expected: PASS + +- [ ] **Step 8: 제거 드릴이 살아 있는지 확인한다** + +`storage/index.ts`를 새로 만들었으므로 제거 드릴을 돌려 서브폴더 삭제가 여전히 성립하는지 본다. + +Run: `corepack pnpm test:browser-file-storage-removal` +Expected: 드릴 출력에 `error TS`가 0건이어야 한다. `storage/index.ts`가 `indexeddb/`나 `opfs/`를 참조하면 `still imported`로 죽는다 — Step 1의 주석대로 재수출을 제거하라. + +> **이 환경의 알려진 제약.** `tests/unit/ci-artifact-contract.test.ts`의 16건은 +> `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`로 실패한다. +> 샌드박스가 네트워크 네임스페이스를 못 만들기 때문이며 `develop` `5434760` +> 기준선에서도 동일하게 16건 실패한다(2026-09-16 확인). 이 드릴은 내부적으로 +> `test:unit`을 돌리므로 그 16건 때문에 항상 exit 1이 된다. +> **판정 기준은 드릴의 exit code가 아니라 `error TS` 0건과 `still imported` 부재다.** +> CI 환경에서는 전체 PASS를 확인할 것. + +- [ ] **Step 9: 커밋** + +```bash +git add src/adapters/*/index.ts docs/reviews/adapters/INVENTORY.md tsconfig.service-worker.json +git commit -m "$(cat <<'EOF' +feat: give every adapter group a public barrel + +각 어댑터 그룹의 공개 표면을 index.ts로 선언한다. 소비자는 아직 바꾸지 +않았으므로 런타임 동작은 그대로다. storage 배럴은 서브배럴을 재수출하지 +않는다 — 제거 드릴이 indexeddb/와 opfs/만 삭제하기 때문이다. + +Co-Authored-By: Claude Opus 5 (1M context) +EOF +)" +``` + +--- + +### Task 2: 그룹 바깥 소비자 15줄을 배럴 경로로 치환 + +**Files:** +- Modify: `src/bootstrap/runtime-adapters.ts:6-29` (12줄, 블록 병합 포함) +- Modify: `src/bootstrap/main.tsx:3` +- Modify: `src/bootstrap/optional-runtime-host.ts:4` +- Modify: `src/bootstrap/register-service-worker.ts:5` +- Modify: `src/features/reference-feature/adapters/create-reference-feature-input.ts:9` +- Modify: `.storybook/preview.tsx:5-6` (게이트 범위 밖, 일관성 목적) + +**Interfaces:** +- Consumes: Task 1이 만든 8개 배럴의 심볼 82개 +- Produces: `src/` 안에 어댑터 내부 파일을 직접 겨누는 import 0건. Task 3의 게이트 규칙이 이 상태를 전제로 통과한다. + +- [ ] **Step 1: 치환 전 위반 건수를 기록한다** + +Run: +```bash +grep -rn 'from "[^"]*adapters/[^"]*"' src/ | grep -v '^src/adapters/' | grep -v 'index\.ts"' | wc -l +``` +Expected: `15`. 이 숫자가 Step 4에서 `0`이 되어야 한다. + +- [ ] **Step 2: `runtime-adapters.ts`의 import 블록을 다시 쓴다** + +`src/bootstrap/runtime-adapters.ts`의 `:6`~`:29` 구간이 한 덩어리다. 아래에서 위로 편집하거나 블록 전체를 한 번에 교체한다 — 위에서부터 고치면 줄 번호가 밀린다. + +치환 내용: + +| 현재 specifier | 바뀔 specifier | +|---|---| +| `../adapters/auth/external-session-adapter.ts` | `../adapters/auth/index.ts` | +| `../adapters/diagnostics/bounded-diagnostics.ts` | `../adapters/diagnostics/index.ts` | +| `../adapters/http/client.ts` | `../adapters/http/index.ts` | +| `../adapters/http/http-execution-v3.ts` | `../adapters/http/index.ts` (위와 **한 블록으로 병합**) | +| `../adapters/query-cache/tanstack-cache-coordinator.ts` | `../adapters/query-cache/index.ts` | +| `../adapters/query-cache/tanstack-query-cache.ts` | `../adapters/query-cache/index.ts` | +| `../adapters/query-cache/server-state-scope-runtime.ts` | `../adapters/query-cache/index.ts` | +| `../adapters/query-cache/conditional-validator-store.ts` | `../adapters/query-cache/index.ts` (위 넷을 **한 블록으로 병합**) | +| `../adapters/storage/browser-storage-adapter.ts` | `../adapters/storage/index.ts` | +| `../adapters/platform/browser-mutation-intent-factory.ts` | `../adapters/platform/index.ts` | +| `../adapters/telemetry/best-effort-telemetry.ts` | `../adapters/telemetry/index.ts` | + +`../adapters/cross-context-invalidation/index.ts` 2줄은 이미 배럴이므로 **건드리지 않는다.** + +- [ ] **Step 3: 나머지 4개 파일을 치환한다** + +| 파일:줄 | 현재 | 바뀔 것 | +|---|---|---| +| `src/bootstrap/main.tsx:3` | `../adapters/diagnostics/bounded-diagnostics.ts` | `../adapters/diagnostics/index.ts` | +| `src/bootstrap/optional-runtime-host.ts:4` | `../adapters/platform/browser-lifecycle.ts` | `../adapters/platform/index.ts` | +| `src/bootstrap/register-service-worker.ts:5` | `../adapters/service-worker/service-worker-page-controller.ts` | `../adapters/service-worker/index.ts` | +| `src/features/reference-feature/adapters/create-reference-feature-input.ts:9` | `../../../adapters/http/http-execution-v3.ts` | `../../../adapters/http/index.ts` | +| `.storybook/preview.tsx:5` | `../src/adapters/auth/external-session-adapter.ts` | `../src/adapters/auth/index.ts` | +| `.storybook/preview.tsx:6` | `../src/adapters/query-cache/tanstack-query-cache.ts` | `../src/adapters/query-cache/index.ts` | + +- [ ] **Step 4: 위반이 0이 된 것을 확인한다** + +Run: +```bash +grep -rn 'from "[^"]*adapters/[^"]*"' src/ | grep -v '^src/adapters/' | grep -v 'index\.ts"' | wc -l +``` +Expected: `0` + +- [ ] **Step 5: 타입·린트·아키텍처 게이트** + +Run: `corepack pnpm check:types:app && corepack pnpm lint && corepack pnpm check:architecture` +Expected: 셋 다 PASS. + +- [ ] **Step 6: 번들 예산을 확인한다 — 이 태스크의 최대 위험** + +배럴 재수출이 트리셰이킹을 무력화하면 초기 청크가 커진다. `package.json`에 `sideEffects` 선언이 없어 번들러가 모든 모듈을 부작용 있는 것으로 본다. + +Run: `corepack pnpm check:bundle` +Expected: PASS (`bundle.initialJsGzipBytes` 상한 204800). + +**FAIL한 경우 순서대로 시도한다:** +1. `package.json`에 `"sideEffects": false`를 추가한다. 추가 전에 `src/adapters` 아래 최상위 부작용이 있는지 확인하고, CSS import가 있으면 `"sideEffects": ["*.css"]` 형태로 보존한다. +2. 그래도 넘치면 `src/adapters/service-worker/index.ts`에서 `service-worker-lifecycle.ts` 블록을 빼고, 워커 realm은 파일 직접 import를 유지한다. 그리고 Task 3의 게이트 규칙 `from.pathNot`에 워커 진입점을 추가한다. (근거: realm이 다르면 문도 다르다 — `platform`과 같은 논리.) +3. 1·2로 안 되면 이 태스크를 중단하고 보고한다. 예산 초과를 안고 진행하지 않는다. + +- [ ] **Step 7: 단위·통합 테스트** + +Run: `corepack pnpm test:unit && corepack pnpm test:integration` +Expected: PASS. 이 태스크는 import 경로만 바꿨으므로 테스트 결과가 달라질 이유가 없다. 깨지면 배럴이 내보내는 심볼이 원본과 다른 것이다. + +- [ ] **Step 8: 커밋** + +```bash +git add src/bootstrap src/features .storybook +git commit -m "$(cat <<'EOF' +refactor: reach adapter groups through their barrel + +bootstrap과 feature 어댑터가 어댑터 내부 파일을 직접 겨누던 15곳을 그룹 +배럴로 바꾼다. 커널(platform/**) 간선과 그룹 내부 import는 파일 경로를 +유지한다 — check:adapter-inventory가 그 경로를 직접 검증한다. + +Co-Authored-By: Claude Opus 5 (1M context) +EOF +)" +``` + +--- + +### Task 3: 게이트 규칙 + 회귀 fixture + +규칙만 추가하면 다음 사람이 규칙을 지워도 아무도 모른다. 이 레포는 거부 규칙마다 회귀 fixture를 두는 방식(`docs/architecture/layers.md`)이므로 fixture까지 같이 넣는다. + +**Files:** +- Modify: `.dependency-cruiser.json` (`:193`과 `:194` 사이에 규칙 1개) +- Create: `tests/fixtures/architecture/dependency-graph/barrel/bootstrap/compose-deep.ts` +- Create: `tests/fixtures/architecture/dependency-graph/barrel/bootstrap/compose-barrel.ts` +- Create: `tests/fixtures/architecture/dependency-graph/barrel/adapters/http/client.ts` +- Create: `tests/fixtures/architecture/dependency-graph/barrel/adapters/http/index.ts` +- Modify: `scripts/check-architecture.ts` (`runGraphFixtureChecks`에 그래프 1개 + assertion 2개) + +**Interfaces:** +- Consumes: Task 2가 만든 "위반 0건" 상태. 위반이 남아 있으면 이 규칙 추가가 곧바로 게이트를 깬다. +- Produces: `adapter-groups-are-reached-through-their-barrel` 규칙과 그 회귀 fixture 2종 + +- [ ] **Step 1: fixture 트리를 만든다 (규칙보다 먼저 — 실패를 먼저 본다)** + +`tests/fixtures/architecture/dependency-graph/barrel/adapters/http/client.ts`: +```ts +export function createFixtureHttpClient(): string { + return "fixture"; +} +``` + +`tests/fixtures/architecture/dependency-graph/barrel/adapters/http/index.ts`: +```ts +export { createFixtureHttpClient } from "./client.ts"; +``` + +`tests/fixtures/architecture/dependency-graph/barrel/bootstrap/compose-deep.ts` — 규칙이 **잡아야 할** 형태: +```ts +import { createFixtureHttpClient } from "../adapters/http/client.ts"; + +export const deepComposition = createFixtureHttpClient; +``` + +`tests/fixtures/architecture/dependency-graph/barrel/bootstrap/compose-barrel.ts` — 규칙이 **통과시켜야 할** 형태: +```ts +import { createFixtureHttpClient } from "../adapters/http/index.ts"; + +export const barrelComposition = createFixtureHttpClient; +``` + +> fixture는 `analyzeSourceGraph(dir, "src")`로 분석되어 경로가 `src/...`로 보고된다(`scripts/check-architecture.ts:821-833`). 그래서 `^src/`로 시작하는 규칙이 fixture 트리에 그대로 적용된다. + +- [ ] **Step 2: `.dependency-cruiser.json`에 규칙을 추가한다** + +`forbidden` 배열의 `adapters-do-not-know-other-concrete-adapters` **바로 다음**, `no-circular-dependencies` **앞**에 넣는다. + +```json + { + "name": "adapter-groups-are-reached-through-their-barrel", + "comment": "어댑터 그룹의 공개 표면은 그 그룹의 index.ts다. 그룹 바깥(bootstrap, features, presentation)은 배럴만 import한다. 배럴이 없던 시절 bootstrap은 어댑터 내부 파일 15곳을 직접 겨눴고, 그래서 어떤 파일이 공개이고 어떤 파일이 내부 헬퍼인지 아무 데도 적혀 있지 않았다. 출발점에서 src/adapters를 뺀 이유는 어댑터끼리의 간선은 바로 위 adapters-do-not-know-other-concrete-adapters가 이미 담당하고, 커널(platform/**)은 파일 단위로 공유되기 때문이다 — scripts/check-adapter-inventory.ts가 네 소비자에게 platform/abortable-operation.ts로 해석되는 specifier를 직접 요구한다. 도착점에서 1단계 중첩 index.ts를 허용한 이유는 storage/indexeddb와 storage/opfs가 각자 독립적으로 제거 가능한 런타임이고(scripts/test-browser-file-storage-runtime-removal.ts), 그래서 각자의 배럴이 곧 경계이기 때문이다.", + "severity": "error", + "from": { + "path": "^src/", + "pathNot": "^src/adapters/" + }, + "to": { + "path": "^src/adapters/[^/]+/", + "pathNot": "^src/adapters/[^/]+/(?:[^/]+/)?index\\.ts$" + } + }, +``` + +> 규칙 형태 적합성: `scripts/check-architecture.ts:799-815`의 `validateArchitectureRules`는 `from`에 `path`/`pathNot`, `to`에 `path`/`pathNot`/`circular`만 허용한다. 정규식은 `new RegExp(pattern, "u")`로 평가되므로 비캡처 그룹 `(?:...)`이 허용된다. `$1` 역참조는 쓰지 않았다. + +- [ ] **Step 3: 규칙이 실제 소스에서 위반 0인지 확인한다** + +Run: `corepack pnpm check:architecture` +Expected: PASS. FAIL하면 Task 2에서 놓친 import가 있다는 뜻이다 — 출력이 지목한 파일을 배럴 경로로 고쳐라. + +- [ ] **Step 4: fixture 검사를 `check-architecture.ts`에 배선한다** + +`runGraphFixtureChecks()`의 `Promise.all` 블록(`scripts/check-architecture.ts:826-833`)에 `barrelGraph`를 추가한다: + +```ts + const [allowedGraph, unresolvedGraph, layerGraph, cycleGraph, barrelGraph] = + await Promise.all([ + analyzeSourceGraph(allowedRoot, "src"), + analyzeSourceGraph(resolve(fixtureRoot, "unresolved"), "src"), + analyzeSourceGraph(resolve(fixtureRoot, "layer"), "src"), + analyzeSourceGraph(resolve(fixtureRoot, "cycle"), "src"), + analyzeSourceGraph(resolve(fixtureRoot, "barrel"), "src"), + ]); +``` + +그리고 `assertions` 배열에 두 항목을 추가한다: + +```ts + { + name: "deep adapter import from outside the group is rejected", + passed: blockingViolations(barrelGraph).some( + ({ rule, source, target }) => + rule === "adapter-groups-are-reached-through-their-barrel" && + source === "src/bootstrap/compose-deep.ts" && + target === "src/adapters/http/client.ts", + ), + }, + { + name: "barrel import from outside the group is accepted", + passed: !blockingViolations(barrelGraph).some( + ({ source }) => source === "src/bootstrap/compose-barrel.ts", + ), + }, +``` + +> 필드명 근거: `ArchitectureViolation`은 `rule` / `severity` / `source` / `target`을 갖는다 (`scripts/check-architecture.ts:35`, 생성부 `:734-739`). `from`/`to`가 아니다. + +- [ ] **Step 5: fixture 회귀 검사가 통과하는지 확인한다** + +Run: `corepack pnpm check:architecture` +Expected: `Architecture graph fixtures: 14 regression checks PASS` (기존 12 + 신규 2). 그리고 전체 PASS. + +- [ ] **Step 6: fixture가 실제로 무언가를 잡는지 역검증한다** + +규칙을 잠시 무력화해서 fixture가 FAIL하는지 본다. fixture가 항상 통과하면 회귀 검사가 아니다. + +```bash +# 규칙 이름을 일시적으로 바꿔 매칭되지 않게 한다 +sed -i 's/"adapter-groups-are-reached-through-their-barrel"/"temporarily-disabled-barrel-rule"/' .dependency-cruiser.json +corepack pnpm check:architecture; echo "EXIT=$?" +# 되돌린다 +sed -i 's/"temporarily-disabled-barrel-rule"/"adapter-groups-are-reached-through-their-barrel"/' .dependency-cruiser.json +corepack pnpm check:architecture; echo "EXIT=$?" +``` +Expected: 첫 번째 EXIT는 0이 아니어야 하고(assertion 실패), 되돌린 뒤 EXIT는 0이어야 한다. + +- [ ] **Step 7: 커밋** + +```bash +git add .dependency-cruiser.json scripts/check-architecture.ts tests/fixtures/architecture/dependency-graph/barrel +git commit -m "$(cat <<'EOF' +feat: enforce the adapter barrel boundary in the architecture gate + +그룹 바깥에서 어댑터 내부 파일을 직접 import하면 check:architecture가 +거부한다. 회귀 fixture 2개가 거부와 허용을 각각 고정한다 — 규칙을 지우면 +fixture 검사가 먼저 깨진다. + +Co-Authored-By: Claude Opus 5 (1M context) +EOF +)" +``` + +--- + +### Task 4: 규칙을 문서에 명문화 + +코드로 강제되는 규칙이 문서에 없으면 다음 사람은 게이트 에러를 보고서야 규칙을 알게 된다. + +**Files:** +- Modify: `docs/architecture/layers.md` (배럴 경계 절 추가) +- Modify: `docs/reviews/adapters/README.md` (테스트 이관 규칙) + +**Interfaces:** +- Consumes: Task 3의 규칙 이름 `adapter-groups-are-reached-through-their-barrel` +- Produces: 없음 (문서만) + +- [ ] **Step 1: `layers.md`에 배럴 경계 절을 추가한다** + +"The adapter kernel" 절 **다음에** 아래를 넣는다: + +```markdown +## 어댑터 그룹의 공개 경계 + +각 어댑터 그룹의 공개 표면은 그 그룹의 `index.ts`다. 그룹 바깥 +(`bootstrap`, `features`, `presentation`)은 배럴만 import한다. +`adapter-groups-are-reached-through-their-barrel` 규칙이 이를 강제하고, +`tests/fixtures/architecture/dependency-graph/barrel`이 거부와 허용을 +각각 고정한다. + +두 가지 예외가 있고 둘 다 의도된 것이다. + +- **그룹 내부 파일끼리**는 파일 경로로 직접 import한다. 배럴은 바깥을 위한 + 문이지 내부 규율이 아니다. +- **어댑터 → 커널(`platform/**`)** 간선도 파일 경로를 유지한다. 커널은 + 런타임 합성물이 아니라 프리미티브이고, `check:adapter-inventory`가 네 + 소비자에게 `platform/abortable-operation.ts`로 해석되는 specifier를 + 직접 요구한다. 커널 배럴(`platform/index.ts`)은 bootstrap과 테스트를 + 위한 것이다. + +`storage`는 최상위 배럴이 `indexeddb/`·`opfs/` 서브배럴을 재수출하지 +않는다. 두 런타임이 각자 독립적으로 제거 가능하고 +(`test:browser-file-storage-removal`), 그래서 각 서브배럴이 곧 경계다. +규칙의 도착점 정규식이 1단계 중첩 `index.ts`를 배럴로 인정하는 이유가 +이것이다. +``` + +- [ ] **Step 2: 테스트 이관 규칙을 적는다** + +`docs/reviews/adapters/README.md` 끝에 추가: + +```markdown +## 테스트의 어댑터 import + +`tests/` 아래 어댑터 import는 배럴로 일괄 이관하지 않는다. `check:architecture`는 +`src`만 스캔하므로 강제되지 않고, 단위 테스트의 상당수가 배럴에 없는 내부 +심볼을 의도적으로 겨눈다. + +어떤 테스트 파일을 **다른 이유로** 수정하거나 분할할 때, 그 파일이 쓰는 +심볼이 해당 그룹 배럴에 있으면 그 파일 안에서만 배럴 경로로 바꾼다. +배럴에 없는 심볼이면 깊은 경로를 유지한다. 배럴에 추가하고 싶으면 그 심볼이 +공개 표면임을 먼저 논증한다 — 테스트 편의로 배럴을 키우면 배럴이 경계가 +아니라 재수출 덤프가 된다. +``` + +- [ ] **Step 3: 문서 게이트 확인** + +Run: `corepack pnpm lint && corepack pnpm check:architecture` +Expected: PASS. (문서 링크 검증이 있으면 `corepack pnpm verify:documentation`도 돌린다.) + +- [ ] **Step 4: 커밋** + +```bash +git add docs/architecture/layers.md docs/reviews/adapters/README.md +git commit -m "$(cat <<'EOF' +docs: write down the adapter barrel boundary and its two exceptions + +Co-Authored-By: Claude Opus 5 (1M context) +EOF +)" +``` + +--- + +## 완료 판정 + +네 태스크가 끝나면 아래가 전부 PASS여야 한다. + +```bash +corepack pnpm check:types +corepack pnpm lint +corepack pnpm check:architecture +corepack pnpm check:adapter-inventory +corepack pnpm check:bundle +corepack pnpm test:unit +corepack pnpm test:integration +corepack pnpm test:browser-file-storage-removal +corepack pnpm test:realtime-removal +``` + +그리고 아래가 `0`이어야 한다. +```bash +grep -rn 'from "[^"]*adapters/[^"]*"' src/ | grep -v '^src/adapters/' | grep -v 'index\.ts"' | wc -l +``` + +## 이 계획이 하지 않는 것 + +- `tests/` 166줄의 배럴 이관 — Task 4 Step 2의 규칙대로 파일을 손댈 때만 한다. +- 대형 파일 분할 — 별도 계획. 이 계획이 그 선행조건이다. +- spec §6이 남긴 부수 발견 4건(`telemetry:49`의 잉여 재수출, `browser-files/index.ts`의 포트 재수출, `opfs/index.ts` 누락 심볼 3개, `.dependency-cruiser.json:141`의 존재하지 않는 `web-worker` 경로) — 각각 별도 티켓. diff --git a/docs/superpowers/specs/2026-09-16-adapter-barrel-boundary-design.md b/docs/superpowers/specs/2026-09-16-adapter-barrel-boundary-design.md new file mode 100644 index 0000000..92ac715 --- /dev/null +++ b/docs/superpowers/specs/2026-09-16-adapter-barrel-boundary-design.md @@ -0,0 +1,751 @@ +# 어댑터 배럴(`index.ts`)을 공개 경계로 승격 — 설계서 + +- 대상 레포: `/home/donghyeon/workspace/desktop-server-git/clean-architecture-frontend-template` +- 브랜치: `develop` (기준 커밋 `5434760`) +- 전제(이미 확정): 배럴 폐지안은 기각. `src/adapters//index.ts`를 **진짜 공개 경계**로 만든다. +- 게이트 기준선: `check:architecture` PASS, `check:types:app` PASS 유지. +- 이 문서는 설계만 한다. 소스 수정·빌드·테스트 실행 없음. + +--- + +## 0. 요약 (먼저 읽을 것) + +| 항목 | 판정 | +|---|---| +| 배럴 표기 표준 | **명명 재수출(named re-export)**. `export *`는 "이미 명시적인 서브배럴을 합칠 때"만 허용 | +| 새로 만들 배럴 | 8개, 합계 114줄 (auth 9 / diagnostics 7 / telemetry 7 / storage 4 / service-worker 17 / query-cache 21 / platform 22 / http 27) | +| `platform/` | 배럴은 만든다. 단 **어댑터→커널 간선은 파일 직접 import를 유지**한다 (게이트가 그걸 요구함) | +| `storage/` | 최상위 배럴은 **서브폴더를 재수출하지 않는다**. `indexeddb/index.ts`·`opfs/index.ts`가 곧 경계다 | +| `http/` | V3(`createContractHttpExecutor`)가 권장 경로, V2(`createHttpClient`)는 legacy 보존. 둘 다 배럴에 넣고 주석으로 표시 | +| 치환할 import | `src/` 15줄 (배럴 경유 2줄은 이미 합격) + `.storybook/` 2줄(선택) | +| 게이트 | `.dependency-cruiser.json`에 규칙 1개 추가. `tests/`는 대상 아님(스캔 범위가 `src`뿐) | +| 최대 위험 | 번들 예산. `package.json`에 `sideEffects` 선언이 없어 배럴이 초기 청크를 키울 수 있다 | + +--- + +## 1. 기존 배럴 8개의 지배적 관례 + +읽은 파일: `src/adapters/{browser-files,browser-file-storage,browser-rpc,browser-transfer,cache-storage,cross-context-invalidation,realtime,web-push}/index.ts` +추가로 서브배럴 8개: `browser-transfer/{image-cdn,presigned,resumable-upload}/index.ts`, `realtime/{polling,sse,websocket}/index.ts`, `storage/{indexeddb,opfs}/index.ts` + +### 1.1 관례 (문장으로) + +1. **소스 파일 단위로 블록을 만들고, 블록마다 `export { ... } from "./파일.ts";` 로 이름을 전부 적는다.** + 블록이 전부 타입이면 `export type { ... } from "...";` 형태를 쓴다 (`browser-files/index.ts:1`, `:28`, `:52`, `:53`). +2. **블록 안 순서는 「값 먼저, 타입 나중」이고 각각 대소문자 무시 알파벳순이다.** + 근거: `web-push/index.ts:42-51` — `createLinkedAbortController, failureCode, nativeFailure, observeWebPush, systemTimeoutScheduler, withAbortableDeadline, type LinkedAbortController, type TimeoutScheduler`. + 상수도 값이므로 같은 줄에 섞인다: `realtime/index.ts:13-22` — `parseRetryAfterDelay, REALTIME_RECONNECT_CEILINGS, reconnectBudgetRemaining` (대소문자 무시로 `parse < realtime_ < reconnect`). + 타입은 인라인 `type X` 접두어로 쓴다 (`browser-rpc/index.ts:9-18`). +3. **블록(파일) 순서는 대체로 알파벳순이되 엄격하지 않다.** 하위 폴더 블록은 뒤로 몰아둔다 (`web-push/index.ts:59`, `:66`의 `./inbound/*`). + 엄격하지 않은 실례: `cross-context-invalidation/index.ts`는 `browser-cross-context-invalidation.ts`(:1) 다음에 `browser-cross-context-host.ts`(:20) — 역순. +4. **배럴은 그룹의 전체 export 목록이 아니다.** 그룹 안에 배럴에 없는 파일이 실제로 존재한다. + 근거: `src/adapters/realtime/result.ts`는 export를 가지지만 `realtime/index.ts` 어디에도 없다. `browser-files/browser-file-vault.ts`도 `browser-files/index.ts`에 없다. + → 즉 이 레포는 이미 "배럴 = 선별된 공개 표면"을 실천하고 있다. 새 배럴도 같은 기준으로 고르면 된다. +5. (참고, 따라하지 말 것) `browser-files/index.ts:1-16`은 어댑터가 아니라 **application 포트 타입**을 재수출한다. 배럴이 하위 레이어의 통로가 되는 형태라 새 배럴에서는 재현하지 않는다. 필요하면 소비자가 포트에서 직접 가져오면 된다. + +### 1.2 `export *`는 표준인가 — 판정 + +**표준은 명명 재수출이다. `export *`는 예외가 아니라 "서브배럴 합성" 전용 관용구다.** + +근거: + +- `export *`가 쓰인 곳은 단 두 파일, 여섯 줄이다. + - `src/adapters/browser-transfer/index.ts:1-3` — `./image-cdn/index.ts`, `./presigned/index.ts`, `./resumable-upload/index.ts` + - `src/adapters/realtime/index.ts:56-58` — `./polling/index.ts`, `./sse/index.ts`, `./websocket/index.ts` +- **여섯 줄 전부 대상이 `index.ts`(서브배럴)다.** 구현 파일(`.ts`)을 `export *`로 푼 사례는 0건이다. +- 그리고 그 서브배럴들은 자기 자신이 전부 명명 재수출이다 (`browser-transfer/image-cdn/index.ts:1-6`, `realtime/sse/index.ts:1-9` 등). +- `realtime/index.ts`는 한 파일 안에서 두 형태를 동시에 쓴다: 1-55줄은 구현 파일에 대한 명명 재수출, 56-58줄은 서브배럴에 대한 `export *`. 즉 `browser-transfer`만 특이한 게 아니라, **대상이 서브배럴이냐 구현 파일이냐**가 형태를 가른다. + +왜 이 구분이 옳은가: 지시대로 `export *`는 "무엇이 공개되는지 파일을 열어야 안다"는 문제가 있다. 그런데 대상이 서브배럴이면 그 파일 자체가 이미 명시적 목록이므로, `export *` 한 줄을 따라가면 곧바로 명시적 목록에 도달한다. 목록이 사라지는 게 아니라 한 단계 아래에 있는 것뿐이다. 반대로 구현 파일을 `export *`하면 목록이 어디에도 없어진다. + +**채택 규칙** + +> 배럴은 구현 파일(`*.ts`)에 대해서는 반드시 이름을 하나씩 적는다. +> `export *`는 대상이 같은 그룹의 서브배럴(`*/index.ts`)일 때만 쓴다. + +새로 만드는 8개 중 `export *`를 쓸 자리는 **없다** (아래 §3.7에서 `storage`가 서브배럴을 재수출하지 않기로 판정하므로). + +--- + +## 2. 공개/내부 판정 기준 + +판정 근거는 실제 사용처다. 측정 명령: + +``` +grep -rnE 'from "[^"]*adapters/(auth|browser-files|browser-file-storage|browser-rpc|browser-transfer|cache-storage|cross-context-invalidation|diagnostics|http|platform|query-cache|realtime|service-worker|storage|telemetry|web-push)/' src/ --include='*.ts' --include='*.tsx' | grep -v '^src/adapters/' +``` + +- **공개**: `src/bootstrap/**`, `src/features/**`, `src/presentation/**`이 import하는 심볼 + 그 심볼의 시그니처에 이름으로 등장하는 타입(의존성/옵션/반환 파사드). +- **내부**: 그룹 안에서만 쓰이는 헬퍼. +- **내부(테스트 전용)**: `tests/`만 import하는 심볼. 배럴에 넣지 않고, 테스트는 깊은 경로를 유지한다. 각 그룹에서 별도로 표시했다. + +예외 처리 하나: 같은 파일의 동급 팩토리 형제는 오늘 테스트만 쓰더라도 공개로 올린다(예 `createAnonymousSessionAdapter`). 근거는 §3.1. + +--- + +## 3. 새로 만들 배럴 8개 (전문) + +아래 내용은 전부 **그대로 파일로 저장 가능**하다. 모든 심볼은 기계 대조로 존재를 확인했다(82개 전수, §7). + +### 3.1 `src/adapters/auth/index.ts` + +그룹 파일: `external-session-adapter.ts` 1개. + +| 심볼 | 위치 | 판정 | 근거 | +|---|---|---|---| +| `createExternalAuthSessionAdapter` | `external-session-adapter.ts:51` | 공개 | `src/bootstrap/runtime-adapters.ts:3` | +| `createDemoSessionAdapter` | `:104` | 공개 | `src/bootstrap/runtime-adapters.ts:2`, `:279` | +| `createUnavailableSessionAdapter` | `:140` | 공개 | `src/bootstrap/runtime-adapters.ts:4` | +| `createAnonymousSessionAdapter` | `:77` | 공개(승격) | 위 셋과 같은 파일·같은 반환형(`AuthSessionPort`)의 형제 팩토리. 오늘 `src` 소비자는 `.storybook/preview.tsx:5`뿐이라 배럴이 없으면 깊은 경로가 남는다 | +| `DEMO_AUTHORIZATION_MARKER` | `:98` | 공개 | 계약 문서가 이름으로 참조: `docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md:305` | +| `ExternalSessionOwner` | `:10` | 공개 | `src/bootstrap/runtime-adapters.ts:5` | +| `DemoSessionAdapter` | `:89` | 공개 | `createDemoSessionAdapter`의 반환 타입 | +| `validateCredentialPatch` | `:26` | **내부** | `src/`·`tests/` 어디서도 import하지 않음 | + +```ts +export { + createAnonymousSessionAdapter, + createDemoSessionAdapter, + createExternalAuthSessionAdapter, + createUnavailableSessionAdapter, + DEMO_AUTHORIZATION_MARKER, + type DemoSessionAdapter, + type ExternalSessionOwner, +} from "./external-session-adapter.ts"; +``` + +--- + +### 3.2 `src/adapters/diagnostics/index.ts` + +그룹 파일: `bounded-diagnostics.ts` 1개. export 5개 전부 공개. + +| 심볼 | 위치 | 판정 | 근거 | +|---|---|---|---| +| `createDiagnosticsAdapter` | `bounded-diagnostics.ts:18` | 공개 | `src/bootstrap/runtime-adapters.ts:7` | +| `recordBootFailure` | `:80` | 공개 | `src/bootstrap/main.tsx:3` | +| `getLastBootEvidence` | `:108` | 공개 | `recordBootFailure`의 짝(부팅 증거 읽기). 오늘 사용처는 `tests/`만 | +| `noOpDiagnostics` | `:11` | 공개 | `DiagnosticsPort`의 null object. 다른 그룹이 기본값으로 기대하는 형태 | +| `MAX_DIAGNOSTIC_ENTRIES` | `:16` | 공개 | 선언된 상한값(계약 수치) | + +```ts +export { + createDiagnosticsAdapter, + getLastBootEvidence, + MAX_DIAGNOSTIC_ENTRIES, + noOpDiagnostics, + recordBootFailure, +} from "./bounded-diagnostics.ts"; +``` + +--- + +### 3.3 `src/adapters/telemetry/index.ts` + +그룹 파일: `best-effort-telemetry.ts` 1개. + +| 심볼 | 위치 | 판정 | 근거 | +|---|---|---|---| +| `createTelemetryAdapter` | `best-effort-telemetry.ts:52` | 공개 | `src/bootstrap/runtime-adapters.ts:29` | +| `noOpTelemetry` | `:31` | 공개 | null object | +| `MAX_TELEMETRY_QUEUE` | `:47` | 공개 | 선언된 상한값 | +| `TelemetryAdapter` | `:10` | 공개 | 팩토리 반환 타입 | +| `TelemetryAdapterOptions` | `:20` | 공개 | 팩토리 인자 타입 | +| `safeTraceparent` | `:229` | **내부(테스트 전용)** | `tests/`만 import. traceparent 정규화는 어댑터 내부 동작 | +| `assertBoundedCapacity` 재수출 | `:49` | **배럴에 넣지 않음** | 이건 telemetry의 export가 아니라 **커널(`../platform/bounded-capacity.ts`) 심볼의 재수출**이다. 배럴에 올리면 커널로 가는 두 번째 문이 생긴다 | + +> **부수 발견 / 별도 처리 권고** +> `src/adapters/telemetry/best-effort-telemetry.ts:49` +> ```ts +> export { assertBoundedCapacity } from "../platform/bounded-capacity.ts"; +> ``` +> 이 줄은 이 배럴 작업과 무관하게 지우는 게 맞다. 같은 파일 `:8`에서 이미 같은 심볼을 import해서 `:61`에서 쓰고 있으므로 재수출은 순수 잉여이고, 실제 소비자도 없다(`grep -rn 'assertBoundedCapacity' src/ tests/` → `platform/bounded-capacity.ts` 정의부, `telemetry:8,49,61`, `diagnostics:9,25`가 전부). 이 문서 범위에서는 **건드리지 않고** 배럴에서 제외만 한다. + +```ts +export { + createTelemetryAdapter, + MAX_TELEMETRY_QUEUE, + noOpTelemetry, + type TelemetryAdapter, + type TelemetryAdapterOptions, +} from "./best-effort-telemetry.ts"; +``` + +--- + +### 3.4 `src/adapters/platform/index.ts` — 커널 + +#### 판정: 배럴은 만들되, **어댑터→커널 간선은 파일 직접 import를 유지한다.** + +질문은 "`browser-transfer/presigned/presigned-transfer-executor.ts:15`의 `../../platform/abortable-operation.ts`가 `../../platform/index.ts`로 바뀌어야 하는가"였다. **아니다. 바꾸면 게이트가 깨진다.** + +**근거 1 (결정적) — `check:adapter-inventory`가 파일 경로를 직접 검증한다.** +`scripts/check-adapter-inventory.ts:63-83`: + +```ts +const PRIMITIVE_PATH = path.resolve("src/adapters/platform/abortable-operation.ts"); +for (const consumer of REQUIRED_ABORT_CONSUMERS) { + ... + const specifiers = [...source.matchAll(/from\s+"([^"]*platform\/abortable-operation\.ts)"/gu)]...; + const resolved = specifiers.some( + (specifier) => path.resolve(path.dirname(consumer), specifier) === PRIMITIVE_PATH, + ); + if (!resolved) { problems.push(`abortable-operation: ${consumer} does not resolve...`); } +} +``` + +`REQUIRED_ABORT_CONSUMERS`(`scripts/check-adapter-inventory.ts:134-137`)는 정확히 네 파일이다: +`browser-transfer/presigned/presigned-capability-http-provider.ts`, `.../presigned-transfer-executor.ts`, `browser-transfer/image-cdn/browser-image-probe.ts`, `browser-transfer/resumable-upload/fetch-json-transport.ts`. +이들이 `../../platform/index.ts`로 바뀌면 정규식이 매칭되지 않아 `check:adapter-inventory`가 실패한다. + +**근거 2 — 규칙상으로는 둘 다 가능하지만, 커널의 정체성은 "파일"이다.** +`.dependency-cruiser.json:191`의 carve-out은 `^src/adapters/($1/|platform/|browser-file-storage/result\.ts$|cross-context-invalidation/index\.ts$)` 로 `platform/` 폴더 전체를 허용한다. 즉 규칙은 중립이다. +그런데 `docs/architecture/layers.md:34`는 커널을 "the system clock, the shared abort primitive and the bounded-capacity guard" — **세 개의 프리미티브**로 정의한다. 런타임 합성물이 아니라 원시 도구다. 원시 도구는 "어느 파일에서 왔는지"가 곧 정체성이고, 실제로 위 게이트가 그 정체성을 파일 경로로 확인한다. + +**근거 3 — 그래서 새 게이트 규칙은 `src/adapters/**`를 출발점에서 제외한다.** +어댑터끼리의 간선은 이미 `adapters-do-not-know-other-concrete-adapters`(`.dependency-cruiser.json:183-193`)가 담당한다. 새 규칙이 그 위에 겹칠 이유가 없다. §5의 `from.pathNot: "^src/adapters/"`가 이 판정의 실행형이다. + +#### 그러면 배럴에는 뭘 넣나 + +커널 프리미티브도 **전부** 넣는다. 이유: 배럴을 쓰는 쪽은 `src/bootstrap`과 `tests`인데, 이들은 `systemClock`(tests), `createAbortableOperation`(tests), `createBrowserLifecycleRuntime`(`src/bootstrap/optional-runtime-host.ts:2`), `createBrowserMutationIntentFactory`(`src/bootstrap/runtime-adapters.ts:28`)를 쓴다. 일부만 넣으면 "바깥은 배럴만"이라는 규칙에 예외가 생긴다. 두 개의 문이 생기는 게 아니라 **문이 소비자별로 하나씩**이다: 어댑터는 파일, 그 외는 배럴. + +| 파일 | 공개 심볼 | 소비자 근거 | +|---|---|---| +| `abortable-operation.ts` | `createAbortableOperation:90`, `compensateLateHandle:261`, `snapshotAbortTimers:70`, `AbortableOperation:24`, `AbortableOperationInput:51`, `AbortRace:19`, `AbortTerminalReason:11`, `AbortTimerSnapshot:59` | 어댑터 4곳(직접 경로 유지) + `tests/` | +| `bounded-capacity.ts` | `assertBoundedCapacity:10` | `diagnostics:9`, `telemetry:8` (직접 경로 유지) | +| `browser-lifecycle.ts` | `createBrowserLifecycleRuntime:57`, `BrowserLifecycleEvent:20`, `BrowserLifecycleRuntime:36`, `BrowserLifecycleSnapshot:13` | `src/bootstrap/optional-runtime-host.ts:1-4` | +| `browser-mutation-intent-factory.ts` | `createBrowserMutationIntentFactory:9`, `BrowserMutationIntentFactoryDependencies:4` | `src/bootstrap/runtime-adapters.ts:28` | +| `system-clock.ts` | `systemClock:3` | 어댑터 6곳(직접 경로 유지) + `tests/` | + +내부: 없음. 이 그룹은 모든 export가 커널 표면이다. + +```ts +/** + * 어댑터 커널의 공개 경계. + * + * `src/adapters/**` 안에서는 이 배럴을 쓰지 않는다. 커널 프리미티브는 파일 + * 경로로 직접 import한다 — `scripts/check-adapter-inventory.ts`가 + * `platform/abortable-operation.ts`로 해석되는 specifier를 네 소비자에게 + * 요구하고, `.dependency-cruiser.json`의 kernel carve-out도 폴더 단위다. + * 이 배럴은 bootstrap·features·tests 같은 그룹 바깥 소비자를 위한 문이다. + */ +export { + compensateLateHandle, + createAbortableOperation, + snapshotAbortTimers, + type AbortableOperation, + type AbortableOperationInput, + type AbortRace, + type AbortTerminalReason, + type AbortTimerSnapshot, +} from "./abortable-operation.ts"; +export { assertBoundedCapacity } from "./bounded-capacity.ts"; +export { + createBrowserLifecycleRuntime, + type BrowserLifecycleEvent, + type BrowserLifecycleRuntime, + type BrowserLifecycleSnapshot, +} from "./browser-lifecycle.ts"; +export { + createBrowserMutationIntentFactory, + type BrowserMutationIntentFactoryDependencies, +} from "./browser-mutation-intent-factory.ts"; +export { systemClock } from "./system-clock.ts"; +``` + +--- + +### 3.5 `src/adapters/query-cache/index.ts` + +파일 5개, 파일마다 팩토리 1~3개. 전부 공개(4개는 bootstrap이 직접, `createCursorPaginationRuntime`·`createQueryCacheAdapter`는 동급 팩토리). + +| 심볼 | 위치 | 근거 | +|---|---|---| +| `createConditionalValidatorStore` | `conditional-validator-store.ts:55` | `src/bootstrap/runtime-adapters.ts:26` | +| `ConditionalValidatorBinding` / `ConditionalValidatorStore` | `:3` / `:10` | 위 팩토리의 반환·요소 타입 | +| `createCursorPaginationRuntime` | `cursor-pagination-runtime.ts:49` | 동급 런타임 팩토리(오늘 소비자는 `tests/`만) | +| `createServerStateScopeRuntime` | `server-state-scope-runtime.ts:34` | `src/bootstrap/runtime-adapters.ts:25` | +| `ScopeResetParticipant` / `ServerStateScopeDependencies` | `:19` / `:26` | 위 팩토리의 인자 타입 | +| `createTanStackCacheCoordinator` | `tanstack-cache-coordinator.ts:33` | `src/bootstrap/runtime-adapters.ts:22` | +| `TanStackCacheCoordinatorDependencies` | `:21` | 인자 타입 | +| `createQueryClient` | `tanstack-query-cache.ts:21` | `src/bootstrap/runtime-adapters.ts:24`, `.storybook/preview.tsx:6` | +| `createQueryCacheAdapter` | `:59` | 동급 팩토리(`QueryCachePort` 구현) | +| `QUERY_CACHE_DEFAULTS` | `:12` | 선언된 기본값 | +| `QueryCacheDependencies` | `:8` | 인자 타입 | + +내부: 없음. + +> 주의: 이 그룹은 `.dependency-cruiser.json:184`가 명시적으로 이름 붙인 미해결 간선의 출발점이다 — `tanstack-cache-coordinator.ts:19`가 `../cross-context-invalidation/index.ts`에서 협력자 타입 2개를 읽는다. 배럴 작업은 이 간선을 건드리지 않는다(어댑터→어댑터 간선이므로 새 규칙 범위 밖). + +```ts +export { + createConditionalValidatorStore, + type ConditionalValidatorBinding, + type ConditionalValidatorStore, +} from "./conditional-validator-store.ts"; +export { createCursorPaginationRuntime } from "./cursor-pagination-runtime.ts"; +export { + createServerStateScopeRuntime, + type ScopeResetParticipant, + type ServerStateScopeDependencies, +} from "./server-state-scope-runtime.ts"; +export { + createTanStackCacheCoordinator, + type TanStackCacheCoordinatorDependencies, +} from "./tanstack-cache-coordinator.ts"; +export { + createQueryCacheAdapter, + createQueryClient, + QUERY_CACHE_DEFAULTS, + type QueryCacheDependencies, +} from "./tanstack-query-cache.ts"; +``` + +--- + +### 3.6 `src/adapters/service-worker/index.ts` + +#### 제약 하나 먼저 + +`tsconfig.app.json:14-19`의 `exclude`에 `src/adapters/service-worker/service-worker-entry.ts`가 들어 있다. **배럴은 이 파일을 절대 참조하면 안 된다** — 참조하면 app 타입체크가 제외된 파일을 끌어들인다. 다행히 `service-worker-entry.ts`는 export가 0개라(`grep -n '^export ' → 없음`) 넣을 것도 없다. + +| 파일 | 판정 | 근거 | +|---|---|---| +| `service-worker-page-controller.ts` | 공개 | `src/bootstrap/register-service-worker.ts:5`가 `createServiceWorkerPageController`를 씀 | +| `service-worker-lifecycle.ts` | 공개 | 워커 realm 진입점(`service-worker-entry.ts:11`)이 합성하는 런타임. 그룹 바깥(워커 번들·tests)이 실제 소비자 | +| `service-worker-protocol.ts` | 공개 | 페이지↔워커 메시지 코덱. 양쪽 realm이 공유하는 어휘 | +| `service-worker-removal.ts` | **내부** | 소비자는 `service-worker-page-controller.ts:20`(그룹 내) + `tests/`뿐 | +| `service-worker-static-assets.ts` | **내부** | 소비자는 `service-worker-lifecycle.ts:17`(그룹 내) + `tests/`뿐 | +| `service-worker-entry.ts` | 대상 아님 | export 0개 + `tsconfig.app.json` 제외 | + +내부(테스트 전용)로 남아 깊은 경로를 유지할 심볼: `removeOwnedRegistration`, `purgeOwnedResources`, `isOwnedRegistration`, `expectedServiceWorkerUrls`(`service-worker-removal.ts:33,65,89,138`), `installStaticAssets`, `classifyFetch`, `validateStaticAssetManifest`, `selectCachesToDelete`(`service-worker-static-assets.ts:35,85,119,377`). + +```ts +export { + createServiceWorkerRuntime, + type WorkerClientLike, + type WorkerRuntimeConfig, + type WorkerScopeLike, +} from "./service-worker-lifecycle.ts"; +export { + createServiceWorkerPageController, + type ActivationBlocker, + type PageControllerDependencies, +} from "./service-worker-page-controller.ts"; +export { + createNonceRegistry, + createServiceWorkerMessage, + parseServiceWorkerMessage, + type ParsedMessage, +} from "./service-worker-protocol.ts"; +``` + +--- + +### 3.7 `src/adapters/storage/index.ts` + +#### 판정: 최상위 배럴은 서브폴더를 재수출하지 **않는다**. 서브폴더 배럴이 곧 경계다. + +`browser-transfer/index.ts:1-3`과 `realtime/index.ts:56-58`의 선례를 따라 `export * from "./indexeddb/index.ts"`를 넣고 싶어지지만, **storage에서는 그게 게이트를 깬다.** + +**근거 (결정적) — 제거 드릴이 서브폴더만 삭제한다.** +`scripts/test-browser-file-storage-runtime-removal.ts:23-34`: + +```ts +const runtimePaths = [ + ... + "src/adapters/storage/indexeddb", + "src/adapters/storage/opfs", + ... +] as const; +``` + +이 스크립트는 fixture 트리에서 위 경로를 `rm -rf`한 뒤(`:56-61`) `assertNoRuntimeImports(fixtureRoot, runtimeSourceRoots, ...)`를 호출한다(`:129-133`). 그 함수는 `scripts/lib/removal-fixture.ts:251-260`: + +```ts +const graph = await runtimeImportGraph(root, runtimeSourceRoots); +if (graph.importingFiles.length > 0) { + throw new Error(`Removed ${capability} runtime is still imported by: ...`); +} +``` + +즉 `src/adapters/storage/index.ts`가 `./indexeddb/index.ts`를 재수출하면, 삭제 후에도 그 파일이 살아남아 삭제된 런타임을 import하는 상태가 되어 드릴이 **즉시 예외로 실패**한다. 그 뒤에 이어지는 `check:types` / `lint` / `check:architecture` / `build`(`:135-143`)까지 전부 못 간다. + +**대조 — realtime·browser-transfer는 왜 괜찮은가.** +`scripts/test-realtime-runtime-removal.ts:21-31`은 `src/adapters/realtime` **폴더 전체**를 지운다. `browser-file-storage` 드릴도 `src/adapters/browser-transfer` 전체를 지운다(`test-browser-file-storage-runtime-removal.ts:28`). 배럴이 폴더와 함께 사라지므로 문제가 없다. **storage만 부분 삭제 대상**이다 — 최상위 `browser-storage-adapter.ts`(localStorage/sessionStorage KV)는 남고 IndexedDB·OPFS 런타임만 빠진다. + +**따라서:** +- `src/adapters/storage/index.ts` = 최상위 파일들만. +- `src/adapters/storage/indexeddb/index.ts`, `src/adapters/storage/opfs/index.ts` = 각 제거 가능 런타임의 공개 경계. **이미 존재하고 이미 명명 재수출이다.** 새로 만들 필요 없다. +- §5의 게이트 정규식은 그래서 1단계 중첩 `index.ts`까지 배럴로 인정해야 한다. + +| 심볼 | 위치 | 판정 | 근거 | +|---|---|---|---| +| `createBrowserStorageAdapter` | `browser-storage-adapter.ts:35` | 공개 | `src/bootstrap/runtime-adapters.ts:27` | +| `BrowserStorageDependencies` | `:20` | 공개 | 팩토리 인자 타입. 필드가 전부 원시형/포트라 codec 타입을 노출하지 않는다(`:20-27` 확인) | +| `browser-storage-codec.ts` 전체 (`DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES:1`, `encodeBrowserStorageEnvelope:31`, `decodeBrowserStorageEnvelope:57`, `assertValidBrowserStorageByteLimit:88`, `BrowserStorageEnvelope:11`, `BrowserStorageCodecFailure:17`, `BrowserStorageCodecResult:22`) | — | **내부** | 유일한 소비자가 `browser-storage-adapter.ts:14-18`(그룹 내). `src/`·`tests/` 어디서도 직접 import 없음 | + +```ts +export { + createBrowserStorageAdapter, + type BrowserStorageDependencies, +} from "./browser-storage-adapter.ts"; +``` + +> **서브배럴 보완 권고 (별건, 이 문서 범위 밖)** +> `tests/unit/opfs-byte-store.test.ts:25,28`가 `OPFS_WORKER_PROTOCOL_VERSION`(`opfs/opfs-worker-protocol.ts:23`), `PreparePhysicalObjectRequest`(`:179`), `writeWithSyncAccessHandle`(`opfs/opfs-worker-runtime.ts:1363`)를 쓰는데 이 셋은 `opfs/index.ts`에 없다. 테스트를 배럴로 옮길 때(§4.2) 함께 결정해야 한다 — 올리거나, 내부로 확정하고 테스트가 깊은 경로를 유지하거나. + +--- + +### 3.8 `src/adapters/http/index.ts` + +#### V2 / V3 판정 + +**V3(`createContractHttpExecutor`, `http-execution-v3.ts:377`)가 권장 경로.** V2(`createHttpClient`, `client.ts:138`)는 legacy 보존. + +근거: +1. `src/adapters/http/client.ts:121`에 `LegacyHttpInput`이라는 타입이 있고, 공개 시그니처 `HttpClient.execute`(`:131-137`)가 그걸 두 번째 인자로 받는다. 파일이 스스로 legacy라고 말한다. +2. `src/adapters/http/http-execution-v3.ts:45-51` 헤더 주석: "§7–§8. Descriptor-driven HTTP execution. ... the runtime owns bounds, the total deadline, the single retry authority and the effect-certainty verdict." — 계약 기반 실행이 V3에 있다. +3. 라이브 경로가 V3다. `src/bootstrap/runtime-adapters.ts:423`이 `createContractHttpExecutor`를 조립해 실제 런타임에 넣는다. 반면 V2 래퍼 `createRuntimeHttpClient`(`src/bootstrap/runtime-adapters.ts:163`)의 유일한 호출자는 `tests/unit/runtime-adapters.test.ts:371`이다 (`grep -rn 'createRuntimeHttpClient' src/ tests/` 결과 3줄: 정의 1 + 테스트 2). +4. V3 타입은 이미 feature 경계를 넘는다: `src/features/reference-feature/adapters/create-reference-feature-input.ts:9`가 `HttpExecutionOutcome`를 import한다. + +둘 다 배럴에 넣되 **블록 순서로 V3를 먼저 두고 주석으로 표시**한다. + +#### 공개 표면 + +| 파일 | 판정 | 근거 | +|---|---|---| +| `http-execution-v3.ts` | 공개 | `src/bootstrap/runtime-adapters.ts:10,11`, `src/features/reference-feature/adapters/create-reference-feature-input.ts:9` | +| `client.ts` | 공개(legacy) | `src/bootstrap/runtime-adapters.ts:8` | +| `http-contract-bridge.ts` | `CredentialPatchOutcome`만 공개 | `http-execution-v3.ts:292`의 공개 시그니처 `attachCredentials(...): Promise \| CredentialPatchOutcome`에 이름으로 등장 → bootstrap이 그 콜백을 구현한다(`src/bootstrap/runtime-adapters.ts:434` 부근) | +| `request-builder.ts` | `OperationRequestInput`만 공개 | `client.ts:133`의 공개 시그니처 `execute(request: string \| OperationRequestInput, ...)`에 이름으로 등장 | +| `bounded-body-reader.ts` | **내부** | 소비자는 `bounded-json.ts:1`, `http-execution-v3.ts:18-20`(그룹 내) + `tests/` | +| `bounded-json.ts` | **내부** | 소비자는 `client.ts:45`(그룹 내) + `tests/` | +| `http-effect-certainty.ts` | **내부** | 소비자는 `http-execution-v3.ts:38-42`(그룹 내) + `tests/` | +| `retry-policy.ts` | **내부** | 소비자는 `client.ts:9`, `http-execution-v3.ts:43`(그룹 내) + `tests/` | +| `schema-registry.ts` | **내부** | 소비자는 `client.ts:14`(그룹 내) + `tests/` | +| `resource-mapper.ts` | **내부** | 소비자는 `client.ts:8`(그룹 내) + `tests/` | +| `http-contract-bridge.ts` 나머지 11개 | **내부** | 그룹 내 + `tests/` | +| `request-builder.ts` 나머지 2개 (`buildRequestTarget:28`, `RequestTargetResult:14`) | **내부(테스트 전용)** | `tests/`만 | + +```ts +/** + * §7–§8. 권장 경로는 V3 계약 실행기(`createContractHttpExecutor`)다. 설치된 + * 계약과 타입 입력을 받아 상한·전체 데드라인·재시도 권한·효과 확실성 판정을 + * 런타임이 소유한다. + */ +export { + createContractHttpExecutor, + type AuthIntegrationFailureReason, + type AuthOperationContext, + type CancellationOwner, + type ContractHttpExecutor, + type ContractHttpExecutorDependencies, + type HttpContractViolation, + type HttpContractViolationKind, + type HttpEffectCertainty, + type HttpExecutionContext, + type HttpExecutionObservation, + type HttpExecutionOutcome, + type HttpTransportFailure, + type SafeResponseMetadata, +} from "./http-execution-v3.ts"; +/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */ +export type { CredentialPatchOutcome } from "./http-contract-bridge.ts"; +/** + * V2 legacy. operationId + `LegacyHttpInput`으로 호출하는 범용 클라이언트다. + * 새 코드는 위의 V3 실행기를 쓴다. 남아 있는 이유는 계약이 아직 없는 + * 오퍼레이션을 위한 이행 경로이기 때문이다. + */ +export { + createHttpClient, + type HttpClient, + type HttpClientDependencies, + type HttpFailure, + type HttpResult, + type LegacyHttpInput, + type Scheduler, +} from "./client.ts"; +/** V2 `HttpClient.execute`의 첫 인자 타입. */ +export type { OperationRequestInput } from "./request-builder.ts"; +``` + +--- + +## 4. 호출처 치환 목록 + +### 4.1 `src/` — 전수 (17줄) + +측정: §2의 grep. 17줄 중 2줄은 이미 배럴 경유라 변경 없음. 실제 치환 대상 **15줄**. + +| # | 파일:줄 | 현재 specifier | 바뀔 specifier | 비고 | +|---:|---|---|---|---| +| 1 | `src/bootstrap/main.tsx:3` | `../adapters/diagnostics/bounded-diagnostics.ts` | `../adapters/diagnostics/index.ts` | `recordBootFailure` | +| 2 | `src/bootstrap/optional-runtime-host.ts:4` | `../adapters/platform/browser-lifecycle.ts` | `../adapters/platform/index.ts` | 블록 시작은 `:1` | +| 3 | `src/bootstrap/register-service-worker.ts:5` | `../adapters/service-worker/service-worker-page-controller.ts` | `../adapters/service-worker/index.ts` | `createServiceWorkerPageController` | +| 4 | `src/bootstrap/runtime-adapters.ts:6` | `../adapters/auth/external-session-adapter.ts` | `../adapters/auth/index.ts` | 블록 시작 `:1` | +| 5 | `src/bootstrap/runtime-adapters.ts:7` | `../adapters/diagnostics/bounded-diagnostics.ts` | `../adapters/diagnostics/index.ts` | `createDiagnosticsAdapter` | +| 6 | `src/bootstrap/runtime-adapters.ts:8` | `../adapters/http/client.ts` | `../adapters/http/index.ts` | **7번과 한 블록으로 합칠 것** | +| 7 | `src/bootstrap/runtime-adapters.ts:12` | `../adapters/http/http-execution-v3.ts` | `../adapters/http/index.ts` | 블록 시작 `:9` | +| 8 | `src/bootstrap/runtime-adapters.ts:20` | `../adapters/cross-context-invalidation/index.ts` | (변경 없음) | 이미 배럴 | +| 9 | `src/bootstrap/runtime-adapters.ts:23` | `../adapters/query-cache/tanstack-cache-coordinator.ts` | `../adapters/query-cache/index.ts` | 블록 시작 `:21`. **9~12를 한 블록으로 합칠 것** | +| 10 | `src/bootstrap/runtime-adapters.ts:24` | `../adapters/query-cache/tanstack-query-cache.ts` | `../adapters/query-cache/index.ts` | `createQueryClient` | +| 11 | `src/bootstrap/runtime-adapters.ts:25` | `../adapters/query-cache/server-state-scope-runtime.ts` | `../adapters/query-cache/index.ts` | `createServerStateScopeRuntime` | +| 12 | `src/bootstrap/runtime-adapters.ts:26` | `../adapters/query-cache/conditional-validator-store.ts` | `../adapters/query-cache/index.ts` | `createConditionalValidatorStore` | +| 13 | `src/bootstrap/runtime-adapters.ts:27` | `../adapters/storage/browser-storage-adapter.ts` | `../adapters/storage/index.ts` | `createBrowserStorageAdapter` | +| 14 | `src/bootstrap/runtime-adapters.ts:28` | `../adapters/platform/browser-mutation-intent-factory.ts` | `../adapters/platform/index.ts` | `createBrowserMutationIntentFactory` | +| 15 | `src/bootstrap/runtime-adapters.ts:29` | `../adapters/telemetry/best-effort-telemetry.ts` | `../adapters/telemetry/index.ts` | `createTelemetryAdapter` | +| 16 | `src/bootstrap/server-state-generation-store.ts:3` | `../adapters/cross-context-invalidation/index.ts` | (변경 없음) | 이미 배럴 | +| 17 | `src/features/reference-feature/adapters/create-reference-feature-input.ts:9` | `../../../adapters/http/http-execution-v3.ts` | `../../../adapters/http/index.ts` | `type HttpExecutionOutcome` | + +**작업 순서 주의.** `runtime-adapters.ts`는 12줄이 한 덩어리(`:6`~`:29`)다. 6·7을 합치고 9~12를 합치면 그 아래 줄 번호가 전부 밀린다. **아래에서 위로 편집**하거나 `:1`~`:29` 블록을 한 번에 다시 쓴다. + +**선택 사항 (게이트 범위 밖, 일관성 목적).** `check:architecture`는 `src`만 스캔하므로(`scripts/check-architecture.ts:69`, `:116-123`) 아래 두 줄은 강제되지 않는다. 같은 커밋에서 정리하는 걸 권한다. + +| 파일:줄 | 현재 | 바뀔 것 | +|---|---|---| +| `.storybook/preview.tsx:5` | `../src/adapters/auth/external-session-adapter.ts` | `../src/adapters/auth/index.ts` | +| `.storybook/preview.tsx:6` | `../src/adapters/query-cache/tanstack-query-cache.ts` | `../src/adapters/query-cache/index.ts` | + +**곁다리 정리 기회 하나.** `src/bootstrap/runtime-adapters.ts:65`가 `type HttpClientDependencies = Parameters[0];`로 타입을 역추출한다. 배럴이 `HttpClientDependencies`를 직접 내보내므로 이 줄은 지우고 import로 대체할 수 있다. 필수는 아니다. + +### 4.2 `tests/` — 그룹별 집계와 권고 + +측정(2026-09-16, `develop` `5434760`): + +``` +grep -rnE 'from "[^"]*adapters/<16개 그룹>/' tests/ --include='*.ts' --include='*.tsx' | grep -v '^tests/fixtures/' +``` + +**166줄 / 83개 파일.** (`tests/fixtures` 포함 시 169줄. 지시문의 184와 다른데, `tests/fixtures`는 `.dependency-cruiser.json:208`에서 제외 대상이고 그 안의 `indexeddb-repository.ts` 같은 경로는 존재하지 않는 금지 recipe fixture다.) + +| 그룹 | 줄 수 | 배럴 존재 | 배럴 경유 | +|---|---:|---|---:| +| browser-transfer | 26 | 기존 | 0 | +| realtime | 24 | 기존 | 1 | +| http | 22 | **신규** | 0 | +| storage | 20 | **신규**(+서브배럴 2) | 0 | +| web-push | 17 | 기존 | 0 | +| browser-files | 17 | 기존 | 1 | +| query-cache | 9 | **신규** | 0 | +| auth | 9 | **신규** | 0 | +| browser-file-storage | 5 | 기존 | 0 | +| service-worker | 4 | **신규** | 0 | +| platform | 3 | **신규** | 0 | +| browser-rpc | 3 | 기존 | 2 | +| diagnostics | 2 | **신규** | 0 | +| cross-context-invalidation | 2 | 기존 | 2 | +| cache-storage | 2 | 기존 | 0 | +| telemetry | 1 | **신규** | 0 | +| **합계** | **166** | | **6** | + +배럴 경유 6줄의 정확한 위치: +`tests/unit/browser-file-runtime.test.ts:13`, `tests/unit/browser-rpc/browser-rpc-remediation.test.ts:9`, `tests/unit/browser-rpc/browser-rpc-runtime.test.ts:11`, `tests/unit/cross-tab-invalidation.test.ts:21`, `tests/unit/realtime/realtime-reconnect-coordinator.test.ts:20`, `tests/unit/tanstack-cache-coordinator.test.ts:7`. + +#### 권고: 한꺼번에 옮기지 않는다. 파일을 건드릴 때 그 파일 것만 옮긴다. + +이유 셋: + +1. **게이트가 강제하지 않는다.** `check:architecture`는 `src`만 본다(`scripts/check-architecture.ts:69`, `:116-123`). 테스트를 지금 옮겨도 검증되는 게 없고, 안 옮겨도 깨지는 게 없다. 강제되지 않는 대량 변경은 리뷰 비용만 남는다. +2. **테스트의 절반 이상이 내부 심볼을 쓴다.** 예: `tests/unit/opfs-byte-store.test.ts:25,28`의 `PreparePhysicalObjectRequest`·`writeWithSyncAccessHandle`은 `opfs/index.ts`에 없다. http의 `retry-policy`·`schema-registry`·`bounded-body-reader` 테스트도 마찬가지로 §3.8에서 내부로 판정한 심볼을 직접 겨눈다. 일괄 치환은 곧 "배럴에 내부 심볼을 밀어넣자"는 압력이 되고, 그러면 배럴이 경계가 아니라 재수출 덤프가 된다. +3. **내부 심볼을 직접 겨누는 단위 테스트는 그래도 된다.** 배럴 규칙은 "그룹 바깥 **프로덕션 코드**는 배럴만"이지 "아무도 내부를 못 본다"가 아니다. 테스트는 구현 계약을 검증하는 게 일이다. + +**실행 규칙 (문서에 남길 문장)** + +> `tests/` 아래 어댑터 import는 배럴로 일괄 이관하지 않는다. +> 어떤 테스트 파일을 **다른 이유로** 수정하거나 분할할 때, 그 파일이 쓰는 심볼이 해당 그룹 배럴에 있으면 그 파일 안에서만 배럴 경로로 바꾼다. +> 배럴에 없는 심볼이면 깊은 경로를 유지한다. 배럴에 추가하고 싶으면 §2 기준으로 "공개"임을 논증하는 게 먼저다. + +우선순위를 굳이 매긴다면: `auth`(9줄, 전부 공개 심볼), `diagnostics`(2줄), `telemetry`(1줄), `query-cache`(9줄) — 이 넷은 배럴이 그룹 표면을 100% 덮으므로 기계적 치환이 가능하다. 21줄. `http`·`storage`·`service-worker`는 내부 심볼 비중이 커서 파일 단위로만 접근한다. + +--- + +## 5. 게이트로 강제하기 + +### 5.1 추가할 규칙 (그대로 붙여넣기) + +`.dependency-cruiser.json`의 `forbidden` 배열에서 **`adapters-do-not-know-other-concrete-adapters` 바로 다음, `no-circular-dependencies` 앞**에 넣는다 (현재 `:193`과 `:194` 사이). + +```json + { + "name": "adapter-groups-are-reached-through-their-barrel", + "comment": "docs/architecture/layers.md §4: 어댑터 그룹의 공개 표면은 그 그룹의 `index.ts`다. 그룹 바깥(bootstrap, features, presentation)은 배럴만 import한다. 배럴이 없던 시절 bootstrap은 어댑터 내부 파일 15곳을 직접 겨눴고, 그래서 어떤 파일이 공개이고 어떤 파일이 내부 헬퍼인지 아무 데도 적혀 있지 않았다. 출발점에서 `src/adapters`를 뺀 이유는 어댑터끼리의 간선은 바로 위 `adapters-do-not-know-other-concrete-adapters`가 이미 담당하고, 커널(`platform/**`)은 파일 단위로 공유되기 때문이다 — `scripts/check-adapter-inventory.ts`가 네 소비자에게 `platform/abortable-operation.ts`로 해석되는 specifier를 직접 요구한다. 도착점에서 1단계 중첩 `index.ts`를 허용한 이유는 `storage/indexeddb`와 `storage/opfs`가 각자 독립적으로 제거 가능한 런타임이고(scripts/test-browser-file-storage-runtime-removal.ts), 그래서 각자의 배럴이 곧 경계이기 때문이다.", + "severity": "error", + "from": { + "path": "^src/", + "pathNot": "^src/adapters/" + }, + "to": { + "path": "^src/adapters/[^/]+/", + "pathNot": "^src/adapters/[^/]+/(?:[^/]+/)?index\\.ts$" + } + }, +``` + +규칙 형태 적합성: `scripts/check-architecture.ts:799-815`의 `validateArchitectureRules`는 `from`에 `path`/`pathNot`, `to`에 `path`/`pathNot`/`circular`만 허용한다. 이 규칙은 그 안에 있다. 정규식은 `new RegExp(pattern, "u")`로 평가되므로(`:739`, `:747`) 비캡처 그룹 `(?:...)`도 문제없다. `$1` 역참조는 쓰지 않았다. + +### 5.2 이 규칙이 무엇을 잡고 무엇을 안 잡는가 — 실측 + +레포의 실제 import 그래프(`src` 전체, 상대 specifier 해석)에 규칙을 그대로 돌려본 결과: + +**잡는 것 — 오늘 기준 위반 15건** (= §4.1의 치환 대상 15줄과 정확히 일치) + +``` +src/bootstrap/main.tsx:3 -> src/adapters/diagnostics/bounded-diagnostics.ts +src/bootstrap/optional-runtime-host.ts:4 -> src/adapters/platform/browser-lifecycle.ts +src/bootstrap/register-service-worker.ts:5 -> src/adapters/service-worker/service-worker-page-controller.ts +src/bootstrap/runtime-adapters.ts:6 -> src/adapters/auth/external-session-adapter.ts +src/bootstrap/runtime-adapters.ts:7 -> src/adapters/diagnostics/bounded-diagnostics.ts +src/bootstrap/runtime-adapters.ts:8 -> src/adapters/http/client.ts +src/bootstrap/runtime-adapters.ts:12 -> src/adapters/http/http-execution-v3.ts +src/bootstrap/runtime-adapters.ts:23 -> src/adapters/query-cache/tanstack-cache-coordinator.ts +src/bootstrap/runtime-adapters.ts:24 -> src/adapters/query-cache/tanstack-query-cache.ts +src/bootstrap/runtime-adapters.ts:25 -> src/adapters/query-cache/server-state-scope-runtime.ts +src/bootstrap/runtime-adapters.ts:26 -> src/adapters/query-cache/conditional-validator-store.ts +src/bootstrap/runtime-adapters.ts:27 -> src/adapters/storage/browser-storage-adapter.ts +src/bootstrap/runtime-adapters.ts:28 -> src/adapters/platform/browser-mutation-intent-factory.ts +src/bootstrap/runtime-adapters.ts:29 -> src/adapters/telemetry/best-effort-telemetry.ts +src/features/reference-feature/adapters/create-reference-feature-input.ts:9 -> src/adapters/http/http-execution-v3.ts +``` + +§4.1을 적용하면 이 15건이 0이 된다. **즉 배럴 8개 생성 + import 15줄 치환 + 규칙 추가를 한 커밋에 넣어야 `check:architecture`가 계속 PASS한다.** 순서를 나누면 중간 커밋이 빨간불이 된다. + +**안 잡는 것 (의도대로)** + +| 경우 | 건수 | 왜 안 잡히나 | +|---|---:|---| +| 같은 그룹 내부 파일끼리 | 229건 중 189건 | `from.pathNot: "^src/adapters/"`가 출발점을 제외 | +| 어댑터 → 커널 (`platform/**`, `browser-file-storage/result.ts`, `cross-context-invalidation/index.ts`) | 40건 | 같은 이유. 이 간선들은 `adapters-do-not-know-other-concrete-adapters`(`:183-193`)의 `pathNot` carve-out이 계속 담당 | +| 바깥 → 배럴 (이미 합격) | 2건 | `to.pathNot`이 `index.ts`를 면제. `src/bootstrap/runtime-adapters.ts:20`, `src/bootstrap/server-state-generation-store.ts:3` | +| `tests/**` | 166줄 전부 | 스캔 범위 밖 (아래 §5.3) | +| `src/presentation/adapters/query/**` | — | 경로가 `src/presentation/...`이라 `to.path` `^src/adapters/`에 매칭 자체가 안 됨 | + +세 가지 질문에 대한 명시적 답: + +1. **같은 그룹 내부 import — 허용된다.** 출발점이 `^src/adapters/`이면 규칙이 아예 평가되지 않는다. +2. **커널 접근 — 허용된다.** `platform/`은 위와 같은 이유로 통과. `browser-file-storage/result.ts`와 `cross-context-invalidation/index.ts`도 출발점이 어댑터이므로 통과. (참고: `cross-context-invalidation/index.ts`는 마침 `index.ts`라 도착점 면제에도 걸린다 — 이중으로 안전.) +3. **`tests/`는 대상이 아니다.** `scripts/check-architecture.ts:69`가 `sourceRoot = resolve(projectRoot, "src")`이고 `:116-123`이 `depcruise src --config ...`를 돌린다. 두 그래프 모두 `src`만 본다. + +### 5.3 기존 규칙과의 충돌 검토 + +`.dependency-cruiser.json` 전체(18개 규칙)를 읽고 대조했다. + +| 기존 규칙 | 줄 | 충돌 | +|---|---:|---| +| `domain-is-framework-neutral` | 4 | 없음. domain→adapters는 어차피 전면 금지 | +| `application-does-not-know-concrete-runtime` | 13 | 없음. 동일 | +| `presentation-does-not-know-adapters` | 24 | 없음. presentation→adapters 전면 금지가 상위 | +| `page-templates-own-layout-only` | 34 | 없음 | +| `icon-vendor-is-facade-only` | 44 | 없음 (외부 패키지 대상) | +| `adapters-do-not-know-presentation` | 54 | 없음 (방향 반대) | +| `feature-*` 4개 | 64–103 | 없음. feature adapters→`src/adapters`는 이 규칙들이 막지 않으므로 새 규칙이 유효하게 작동 (실측 15번째 위반이 그 경우) | +| `concrete-adapters-compose-only-in-bootstrap` | 104 | 없음. `src/(domain\|application\|presentation\|contracts)` → `^src/adapters` 전면 금지. 새 규칙은 그 나머지(`bootstrap`, `features`)에서만 실효 | +| `external-contract-package-single-import-path` | 114 | 없음 | +| `presentation-does-not-fetch-directly` | 126 | 없음. `^src/adapters/(http\|realtime\|service-worker\|web-worker\|storage)` 를 겨누는데 presentation은 이미 전면 금지 | +| `generic-worker-has-no-network-or-credentials` | 137 | 없음. `^src/adapters/web-worker` 출발이라 새 규칙 출발점 제외와 겹칠 뿐. (`src/adapters/web-worker`는 현재 존재하지 않는 예방 규칙) | +| `service-worker-entry-is-not-page-code` | 148 | 없음 | +| `contracts-do-not-know-application` | 159 | 없음 | +| `generic-presentation-does-not-compose-the-product` | 170 | 없음 | +| **`adapters-do-not-know-other-concrete-adapters`** | **183** | **없음 — 상보적.** 저쪽은 `from: ^src/adapters/([^/]+)/`, 이쪽은 `from.pathNot: ^src/adapters/`. 정확히 반대 집합이라 이중 판정이 생기지 않는다 | +| `no-circular-dependencies` | 194 | 주의 필요 → 아래 | + +**순환 검토.** `no-circular-dependencies`(`:194-201`)가 error다. 배럴 도입이 순환을 만들려면 그룹 안 파일이 자기 그룹 `index.ts`를 import해야 한다. + +``` +grep -rn 'from "\./index\.ts"\|from "\.\./index\.ts"' src/adapters/ --include='*.ts' → 0건 +``` + +또 `src/bootstrap/**`는 어댑터에서 import되지 않는다(`adapters-do-not-know-presentation`이 `^src/(presentation|bootstrap)`을 막음, `:54-62`). 따라서 **새 순환 없음.** 단 §3.7의 판정을 뒤집어 `storage/index.ts`가 서브배럴을 재수출하면, 순환은 아니지만 제거 드릴이 깨진다(§6 위험 2). + +### 5.4 ESLint 쪽은 손대지 않는다 + +`eslint.config.ts:9-34`의 `layerPatterns`는 레이어 단위(`**/adapters/**`)만 다루고 그룹 내부 경로를 구분하지 않는다. 배럴 규칙을 여기에도 복제하면 두 곳에서 같은 사실을 관리하게 된다. dependency-cruiser 쪽 한 곳만 유지한다. + +--- + +## 6. 위험과 부수 작업 + +### 위험 1 (최대) — 번들 예산 + +`package.json`에 `"sideEffects"` 필드가 없다. 번들러는 모든 모듈을 부작용 있을 수 있는 것으로 보고, 배럴 재수출을 통해 들어온 모듈을 트리셰이킹에서 살려둘 수 있다. + +구체적으로 위험한 세 곳: +- `src/bootstrap/register-service-worker.ts`가 `service-worker/index.ts`를 import하면 `service-worker-lifecycle.ts`(666줄) → `service-worker-static-assets.ts`(394줄)가 **페이지 번들 그래프**에 들어온다. 지금은 `service-worker-page-controller.ts`(494줄) → `service-worker-protocol.ts` + `service-worker-removal.ts`만 들어온다. +- `src/bootstrap/runtime-adapters.ts`가 `http/index.ts`를 import하면 V2·V3가 항상 함께 들어온다 (`client.ts` 1107줄 + `http-execution-v3.ts` 1602줄). 지금도 둘 다 import하긴 한다. +- `query-cache/index.ts`는 `cursor-pagination-runtime.ts`(234줄)를 추가로 끌어온다. + +예산: `config/performance/budgets.json`의 `bundle.initialJsGzipBytes = 204800`. `check:bundle`(`scripts/check-bundle.ts:47`)이 초과 시 실패한다. + +**대응 (권장 순서)** +1. 배럴 커밋에서 `corepack pnpm check:bundle`을 반드시 돌린다. 이 문서에서는 실행하지 않았다. +2. 넘치면 `package.json`에 `"sideEffects": false`를 추가한다. `src/adapters` 아래에 최상위 부작용이 있는지 먼저 확인해야 한다(`presentation/styles/theme.css` 같은 CSS import는 `"sideEffects": ["*.css"]` 형태로 보존). +3. 그래도 넘치면 `service-worker/index.ts`에서 `service-worker-lifecycle.ts` 블록을 빼고, 워커 realm은 파일 직접 import를 유지한다(`platform`과 같은 논리 — realm이 다르면 문도 다르다). + +### 위험 2 — `storage/index.ts`에 서브배럴을 넣고 싶은 충동 + +`browser-transfer`/`realtime` 선례만 보고 `export * from "./indexeddb/index.ts"`를 넣으면 `test:browser-file-storage-removal`이 `assertNoRuntimeImports` 단계에서 예외로 죽는다(§3.7). 배럴 파일에 그 이유를 주석으로 못 박아 두는 걸 권한다. + +### 위험 3 — `check:adapter-inventory`가 새 파일 8개를 거부한다 + +`scripts/check-adapter-inventory.ts:58-74`가 `git ls-files src/adapters` 결과와 `docs/reviews/adapters/INVENTORY.md`의 행 목록을 **정확히 일치**시키고, `합계: **N/N**` 숫자도 파일 수와 같아야 한다. + +**같은 커밋에서 해야 할 일:** +1. 새 `index.ts` 8개를 `git add` 한다 (추적되지 않으면 `git ls-files`에 안 잡혀서 오히려 통과하지만, 커밋하는 순간 깨진다). +2. `docs/reviews/adapters/INVENTORY.md`에 행 8개 추가: + +| 추가할 경로 | 상세 리뷰 링크 (같은 그룹 기존 행과 동일하게) | +|---|---| +| `src/adapters/auth/index.ts` | `[Network/state](./01-network-and-state.md)` — 기존 행 `:11` | +| `src/adapters/diagnostics/index.ts` | `[Network/state](./01-network-and-state.md)` — `:58` | +| `src/adapters/http/index.ts` | `[Network/state](./01-network-and-state.md)` — `:59-68` | +| `src/adapters/platform/index.ts` | `[Network/state](./01-network-and-state.md)` — `:69-73` | +| `src/adapters/query-cache/index.ts` | `[Network/state](./01-network-and-state.md)` — `:74-78` | +| `src/adapters/telemetry/index.ts` | `[Network/state](./01-network-and-state.md)` — `:119` | +| `src/adapters/service-worker/index.ts` | `[Worker/push](./05-service-worker-and-web-push.md)` — `:96-101` | +| `src/adapters/storage/index.ts` | `[Storage/files](./03-storage-and-browser-files.md)` — `:102-103` | + +3. `docs/reviews/adapters/INVENTORY.md:132`의 `합계: **120/120**` → `합계: **128/128**`. + +행 번호(`| N |`)는 `inventoryRows`(`scripts/check-adapter-inventory.ts:33-40`)가 아래 정규식으로 **경로만** 뽑고 순서·연속성은 검사하지 않는다. + +``` +^\|\s*\d+\s*\|\s*`([^`]+)`\s*\| +``` + +그래도 읽는 사람을 위해 정렬 위치에 끼워 넣고 번호를 다시 매기는 걸 권한다. + +### 위험 4 — `tsconfig.app.json` 제외 파일 + +`tsconfig.app.json:18`이 `src/adapters/service-worker/service-worker-entry.ts`를 제외한다. `service-worker/index.ts`가 이 파일을 참조하면 app 타입체크가 제외 대상을 끌어들인다. §3.6의 배럴은 참조하지 않는다(그 파일은 export가 0개다). 나중에 누가 "완전성"을 이유로 추가하지 않도록 배럴에 주석을 남기는 것도 방법이다. + +### 부수 발견 (이 문서 범위 밖, 별도 티켓) + +1. `src/adapters/telemetry/best-effort-telemetry.ts:49` — 커널 심볼 재수출. 소비자 0, 같은 파일 `:8`에 import가 이미 있음. 삭제 후보. +2. `src/adapters/browser-files/index.ts:1-16` — 배럴이 application 포트 타입을 재수출. 어댑터 배럴이 하위 레이어의 통로가 되는 형태. +3. `src/adapters/storage/opfs/index.ts` — `tests/unit/opfs-byte-store.test.ts:25,28`이 쓰는 `OPFS_WORKER_PROTOCOL_VERSION`·`PreparePhysicalObjectRequest`·`writeWithSyncAccessHandle` 3개가 빠져 있다. +4. `.dependency-cruiser.json:141` — `^src/adapters/web-worker` 를 겨누는 규칙이 있으나 해당 디렉터리는 존재하지 않는다(어댑터 그룹은 16개). 예방 규칙인지 잔재인지 확인 필요. + +--- + +## 7. 심볼 존재 검증 (기계 대조) + +제시한 8개 배럴의 **모든 재수출 심볼 82개**를, 각 대상 파일의 실제 `export` 선언과 이름 단위로 대조했다. + +- 대조 방법: 각 `export { ... } from "./X.ts"` 블록의 이름을 뽑아, `src/adapters//X.ts`에서 `^export (declare )?(async function|function|const|let|var|class|type|interface|enum) ` 으로 선언된 이름 집합에 들어 있는지 확인. +- 결과: **82/82 존재. 누락 0, 오타 0.** +- 대상 파일 존재 여부도 함께 확인(8개 배럴이 참조하는 소스 파일 16개 전부 존재). + +그룹별 심볼 수: auth 7, diagnostics 5, telemetry 5, platform 16, query-cache 13, storage 2, service-worker 11, http 23. + +--- + +## 8. 실행 체크리스트 (한 커밋) + +1. `src/adapters/{auth,diagnostics,http,platform,query-cache,service-worker,storage,telemetry}/index.ts` 8개 생성 (§3 내용 그대로). +2. §4.1 표의 15줄 치환. `src/bootstrap/runtime-adapters.ts`는 아래에서 위로 편집하거나 `:1-29` 블록 전체를 다시 쓴다. +3. (선택) `.storybook/preview.tsx:5,6` 2줄 치환. +4. `.dependency-cruiser.json`에 §5.1 규칙 추가 (현재 `:193`과 `:194` 사이). +5. `docs/reviews/adapters/INVENTORY.md`에 행 8개 추가 + `:132`의 합계를 `128/128`로. +6. `docs/architecture/layers.md`에 "어댑터 그룹의 공개 표면은 그 그룹의 `index.ts`다. 커널은 예외로 파일 단위로 공유된다"를 한 문단 추가 (`:29-45`의 adapter kernel 절 뒤). 이 문서 규칙들은 실행 규칙과 짝을 이루게 되어 있다(`layers.md:39-45`). +7. 게이트 실행: `check:architecture` → `check:types:app` → `check:adapter-inventory` → `lint` → `check:bundle` → `test:browser-file-storage-removal`. + 마지막 두 개가 이번 변경의 실제 리스크 지점이다(§6 위험 1, 2). diff --git a/docs/superpowers/specs/2026-09-16-indexeddb-kernel-promotion-design.md b/docs/superpowers/specs/2026-09-16-indexeddb-kernel-promotion-design.md new file mode 100644 index 0000000..ffde878 --- /dev/null +++ b/docs/superpowers/specs/2026-09-16-indexeddb-kernel-promotion-design.md @@ -0,0 +1,1154 @@ +# IndexedDB 커널 승격 — 구체 설계 + +> 대상 브랜치: `develop` (5434760) +> 이 문서는 설계만 담는다. 소스는 수정하지 않았고 빌드/테스트도 돌리지 않았다. +> 모든 주장에 `파일:줄번호` 근거를 달았다. 근거를 못 단 항목은 **미확인**이라고 표시했다. + +--- + +## 0. 사본 개수 확정: 5벌이 아니라 **4벌** + +`src/adapters/web-push/push-association-fence-store.ts`는 IndexedDB 사본이 **아니다.** + +``` +$ grep -n "indexedDB|IDBDatabase|IDBTransaction|IDBRequest|IDBObjectStore|IDBKeyRange|IDBFactory|deleteDatabase|onupgradeneeded|objectStore" \ + src/adapters/web-push/push-association-fence-store.ts +→ 매치 없음 +``` + +이 파일은 IndexedDB API를 한 줄도 쓰지 않는다. 대신 `PushControlRepository`라는 **구조적 포트**를 자기가 선언하고 +(`push-association-fence-store.ts:59-87`) 합성 루트에서 주입받는다. 파일 자신의 주석이 그 의도를 명시한다: + +> `push-association-fence-store.ts:49-58` +> "It is declared here, structurally, rather than imported from the browser file/storage port so the two capabilities stay +> independently removable. The generic IndexedDB repository satisfies it as-is; the composition root is where the two are +> joined, and it owns connection, migration, transaction, timeout, codec and version-change policy." + +즉 이 파일은 **이번 리팩토링이 지향하는 모범 사례의 예시**이지 중복 사본이 아니다. 커널 이행 대상에서 제외한다. +(다만 `tests/helpers/fake-push-control-repository.ts`, `tests/unit/web-push-store-port-compatibility.test.ts`는 +runtime의 공개 형태에 구조적으로 의존하므로 §5의 위험 목록에는 남긴다.) + +한편 **실제 IndexedDB 코드를 가진 5번째 파일은 따로 있다**: `src/adapters/storage/indexeddb/indexeddb-governance.ts` +(`queueIndexedDbUpgradeBinding` L160-215, `verifyIndexedDbDatasetBinding` L220-338). 이건 `indexeddb/` 폴더 내부 +헬퍼라 runtime과 maintenance가 **이미 공유**하고 있다. 중복이 아니므로 대조표에는 참고 열로만 넣는다. + +--- + +## 1. 4벌 대조표 + +열 약어: +- **RT** = `src/adapters/storage/indexeddb/indexeddb-runtime.ts` (2902 LOC) +- **MT** = `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` (1558 LOC) +- **OP** = `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` (1817 LOC) +- **CP** = `src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts` (712 LOC) + +### 1.1 연결 수립 + +| 기능 | RT | MT | OP | CP | +|---|---|---|---|---| +| `factory.open()` → Promise | 있음 `L684-921` | 있음 `L434-585` | 있음 `L961-1072` | 있음 `L156-239` | +| `factory` 기본값을 전역에서 해석 | 있음 `L565-569` | 있음 `L359-363` | 있음 `L130-134` | 있음 `L95-97` | +| `IDBKeyRange` 기본값을 전역에서 해석 | 있음 `L570-574` | 있음 `L364-368` | 있음 `L135-139` | **없음** (범위 질의 없음) | +| 단일 비행(single-flight) 오픈 캐시 | 있음 `L594` `L942-951` | **없음 — 의도적**. 배치마다 열고 `finally`에서 닫음 `L1364-1366` `L1549-1551` | 있음 `L166` `L1067-1071` | 있음 `L129` `L144-148` | +| 진행 중 open 취소 훅 | 있음 `cancelPendingOpen L596,L741-744`, `close()`에서 호출 `L2876` | 있음 `signal`→`request.transaction?.abort()` `L467-474` | **없음** | **없음** | +| open generation 토큰(늦게 온 요청 무시) | 있음 `L595,L691-697` | **없음** | **없음** | **없음** | +| 늦게 도착한 connection 강제 close | 있음 `L835-841,L896-902` | 있음 `L505-508,L577-580` | 있음 `L1050-1053` | 있음 `L168-171,L219-224` | + +### 1.2 blocked 처리 + +| 기능 | RT | MT | OP | CP | +|---|---|---|---|---| +| `onblocked` 핸들러 | 있음 `L776-799` | 있음 `L485-492` | 있음 `L1031-1041` | 있음 `L198-207` | +| blocked 후 **대기 타이머** | 있음, 주입형 scheduler `L789-798`, 기본 10_000ms `L576` | **다르게 구현 — 타이머 없이 blocked 이벤트 즉시 실패** `L485-492` | 있음, 주입형 scheduler `L1032-1040`, 기본 10_000ms `L143` | **다르게 구현 — 네이티브 `setTimeout` 하드코딩(주입 불가)** `L199-206`, 기본 5_000ms `L23,L98-99` | +| blocked를 관측 이벤트로 발행 | 있음 `L782-788` | **없음** (결과 매핑에서만 `observeResult` `L391-392`) | **없음** | **없음** | +| blocked 시 연결 상태 전이 | 있음 `{kind:"BLOCKED"}` `L777-781` | **없음** | **없음** | **없음** | + +### 1.3 버전 마이그레이션 / 스키마 + +| 기능 | RT | MT | OP | CP | +|---|---|---|---|---| +| `onupgradeneeded` 훅 | 있음 `L746-774` | 있음 `L477-484` | 있음 `L991-1030` | 있음 `L176-197` | +| upgrade 내부 동작 | **선언적 migration 목록 적용** `applyIndexedDbMigrations L754-760` + governance binding queue `L761-769` | **upgrade 자체를 실패로 취급** — `unexpectedUpgrade=true` 후 abort `L477-484`, 결과 `MIGRATION_FAILED` `L495-496` | **인라인 하드코딩된 createObjectStore 6개 + index 3개** `L997-1029`, `oldVersion!==0`이면 abort `L993-996` | **인라인 `contains` 체크 후 createObjectStore 2개** `L179-188` | +| upgrade 실패 플래그 → 결과 코드 | 있음 `migrationFailed` `L714,L811-819` | 있음 `unexpectedUpgrade` `L460,L495-496` | **없음** (abort가 onerror로 흐름) | **없음** (catch 후 `finish` `L189-196`) | +| open 후 스토어 존재 검증 | 있음 `assertIndexedDbRuntimeStores L844-853` (구현은 `indexeddb-migrations.ts:162-207`) | **다르게 구현 — 인라인 `objectStoreNames.contains` 5개 + index 접근 `L509-542`** | **없음** | **없음** | +| governance/scope binding 검증 | 있음 `verifyIndexedDbDatasetBinding L866-871` | 있음 동일 함수 `L545-550` | **없음** (open 경로에서는 안 함; scope 바인딩은 트랜잭션 내부 로직 `L1310-1347`) | **다르게 구현 — 자체 `bindScope` 별도 트랜잭션 `L598-654`** | +| 마이그레이션 개수를 결과로 반환 | 있음 `appliedMigrations L716,L910-916` | 해당 없음 | 해당 없음 | 해당 없음 | + +### 1.4 versionchange / 연결 무효화 + +| 기능 | RT | MT | OP | CP | +|---|---|---|---|---| +| `onversionchange` | 있음 `L904` → `handleVersionChange L637-651` (상태 브로드캐스트 + 콜백) | 있음 `L543` — `() => database.close()` 한 줄 | 있음 `L1055-1059` — 캐시 무효화(`database=null; opening=null`) | 있음 `L230-233` — 캐시 무효화 | +| `onclose` (강제 종료) | 있음 `L905` → `handleForcedClose L653-657` | **없음** | 있음 `L1060-1063` | 있음 `L234-236` | +| 연결 상태 구독 API | 있음 `getStatus/subscribeStatus L2893-2899` | **없음** | **없음** | **없음** | +| `close()`/dispose | 있음 `L2873-2884` (pending open 취소 + DISPOSED 브로드캐스트) | **없음** (배치마다 자동 close) | 있음 `L757-761` | 있음 `L395-399` + `deletePartition`에서도 `L411-413` | + +### 1.5 트랜잭션 실행 + +| 기능 | RT | MT | OP | CP | +|---|---|---|---|---| +| `createTransaction` + durability 폴백 | 있음 `L957-974` | **RT와 문자 단위로 동일** `L587-604` | **다르게 구현 — readwrite만 `durability:"strict"` 하드코딩, readonly는 옵션 없음** `L1176-1190` | **다르게 구현 — durability 옵션 자체가 없음** `L512` | +| durability를 의존성으로 주입 | 있음 `dependencies.durability?.read/.write` `L962-965` | 있음 동일 `L592-595` | **없음** (하드코딩) | **없음** | +| `runTransaction` (트랜잭션→Promise) | 있음 `L976-1074` | 있음 `L606-705` — RT와 구조 동일, `operation`이 `"INDEXEDDB_MIGRATE"` 고정이고 `database`를 인자로 받음 | 있음 `L1098-1174` — **모듈 최상위 자유함수** | 있음 `runCheckpointTransaction L497-596` — **단일 스토어 `CHECKPOINT_STORE` 고정** `L512,L591` | +| `TransactionContext` 타입 | `L85-89` (`succeed`/`fail`/`requestFailed`) | `L97-101` (동일 3종) | `L48-51` (**`succeed`/`fail` 2종 — `requestFailed` 없음**) | `L491-495` (`succeed`/`fail`/`nativeFailure`) | +| caller `AbortSignal` → transaction.abort | 있음 `L1010-1017,L1041` | 있음 `L640-650,L676` | **없음 — signal 인자 자체가 없음** | 있음 `L527-539` | +| `oncomplete`인데 값이 없을 때 | `UNAVAILABLE/retryable:true/REOPEN` `L1020` (`unavailable` `L397-404`) | `UNAVAILABLE/retryable:true/REOPEN` `L653` (`unavailable` `L257-262`) | `UNAVAILABLE/retryable:true/REOPEN` `L1144-1151` | **다르게 구현 — `CORRUPT_DATA/RECONCILE`** `L541-547` | +| 요청 오류 기록 후 abort 여부 | **abort 안 함** — `requestFailed`는 기록만 `L1055-1060` | **abort 안 함** `L686-694` | 해당 없음 (요청 오류를 안 봄) | **abort 함** — `nativeFailure`가 즉시 abort `L577-588` | +| `queue` 콜백이 throw할 때 | 있음 `L1063-1072` | 있음 `L697-703` | 있음 `L1163-1172` | 있음 `L590-594` | + +### 1.6 요청 → 콜백 변환 + +| 기능 | RT | MT | OP | CP | +|---|---|---|---|---| +| `request.onsuccess` 인라인 배선 | 있음, 수십 곳 (예 `L1134,L1782,L1878,L1932`) | 있음 (예 `L721,L799,L1033,L1079`) | 있음 (예 `L187,L497,L604,L790`) | 있음 (예 `L265,L323,L372`) | +| `request.onerror` 인라인 배선 | 있음, 거의 모든 요청에 (예 `L1133,L1780,L1876`) | 있음 (예 `L720,L798,L1031`) | **없음 — 파일 전체에서 request `.onerror`는 open 요청 1개뿐** (`L1042`; `L1160`은 transaction.onerror) | 있음 (예 `L264,L322,L341,L371,L387`) | +| 요청→콜백 공통 헬퍼 | **없음** | **없음** | **없음** | **없음** | + +### 1.7 커서 순회 + +| 기능 | RT | MT | OP | CP | +|---|---|---|---|---| +| `openCursor` 순회 루프 | 있음 4곳: `query L1364-1527`, `purgeEligibleRecords L2345-2472`, `purgePartitionRecords` 내부 4개 루프 `L2528-2566,L2570-2638,L2641-2713,L2715-2809` | 있음 2곳: `scanBatch L795-858`, `pruneExpiredReceipts L1429-1542` | 있음 2곳: `listIncomplete L600-633`, `listCommittedObjects L673-712` | **없음** | +| 행 수 예산(maxRows) | 있음 — **삭제 행 기준** `deletedRows >= input.maxRows` `L2384,L2543,L2585,L2656,L2730` | 있음 — **스캔 행 기준** `rows.length >= input.maxRows` `L823`, 삭제 행 기준 `L1471` | **다르게 구현 — `limit`만, 예산 개념 없음** `L622,L699` | 해당 없음 | +| 시간 예산(deadline) | 있음 `monotonicClock() L2260-2269`, 비교 `L2385,L2544` 등 | 있음 `clock() L401-410`, 비교 `L824,L1472` | **없음** | **없음** | +| 스캔 상한(방어적) | 있음 `MAX_QUERY_SCANNED_ROWS=5_000 L105`, 계산 `L1349-1352`, 비교 `L1454` | **없음** | **없음** | 해당 없음 | +| 커서 재개 방식 3종 (`continue`/`continue(key)`/`continuePrimaryKey`) | 있음 `L1430-1445` | **없음** (`continue()`만 `L852`) | **없음** (`continue()`만 `L632,L711`) | 해당 없음 | +| 커서 중간에 중첩 요청 체인 실행 | 있음 (모든 순회가 그렇게 함, 예 `L1487-1526`, `L2406-2471`) | 있음 (`L1490-1541`) | **없음** (`push` 후 즉시 `continue()`) | 해당 없음 | +| 순회 중 `signal.aborted` 체크 | 있음 `L1375-1382,L2365-2372` | 있음 `L800-807,L1452-1459` | **없음** | 해당 없음 | + +### 1.8 실패 매핑 + +| 기능 | RT | MT | OP | CP | +|---|---|---|---|---| +| 예외→실패 매핑 함수 | `mapIndexedDbException` `indexeddb-failure.ts:23-72` | 동일 함수 `L15` | 동일 함수 `L21` | **다르게 구현 — `mapBrowserDataException`** `browser-file-storage/result.ts:43-90` (`L11`에서 import) | +| 매핑 테이블이 실제로 다른 지점 | — | — | — | `ConstraintError`: IDB=`CONFLICT/recovery:NONE`(`indexeddb-failure.ts:30-31`) vs BD=`CONFLICT/recovery:REOPEN`(`result.ts:58-60`) · `QuotaExceededError`: IDB=`retryable:false`(`:56-60`) vs BD=`retryable:true`(`:74-79`) · `NotFoundError`: IDB=`MIGRATION_FAILED`(`:41-45`) vs BD=`NOT_FOUND`(`:67-68`) · BD는 `DOMException`이 아니면 전부 `UNAVAILABLE`(`:47-52`), IDB는 name만 보고 판단(`:7-17`) | +| `operation` 라벨 | 호출처마다 다름: `INDEXEDDB_OPEN/READ/WRITE/MIGRATE` | `INDEXEDDB_MIGRATE` 고정 `L248,L252,L258` 등 | 호출처마다 다름: `INDEXEDDB_OPEN/READ/WRITE` | `UPLOAD_RECONCILE` 고정 | +| abort 단축 헬퍼 | `abortedResult` `L19` | `abortedResult` `L11` | **없음** (`signal` 미사용) | **없음** (인라인 `signal?.aborted` `L140-142,L408-410,L506-508`) | + +### 1.9 deleteDatabase + +| 기능 | RT | MT | OP | CP | +|---|---|---|---|---| +| `factory.deleteDatabase` | **없음** | **없음** | **없음** | **있음** `L110-113`, `deletePartition L403-485` | +| 파티션 전체 삭제를 다른 방식으로 구현 | 있음 — **행 단위 순회 삭제** `purgePartitionRecords L2477-2812` | **없음** | **없음** | 해당 없음 | +| 삭제 blocked 처리 | 해당 없음 | 해당 없음 | 해당 없음 | 있음 — blocked 데드라인 후 `PENDING/UNKNOWN/BLOCKED_DEADLINE` 반환 `L449-462` (open의 `L198-207`과 동일 패턴 재작성) | +| 진행 중 삭제 레지스트리 | 해당 없음 | 해당 없음 | 해당 없음 | 있음 `PENDING_DELETIONS WeakMap L31-41`, 생성 시 검사 `L116-122` | + +### 1.10 관측 / 구성 검증 + +| 기능 | RT | MT | OP | CP | +|---|---|---|---|---| +| observe 래퍼 | 있음 `L599-605` + `observeResult L607-624` | 있음 `L373-379` + `observeResult L381-399` — **RT와 형태 동일** | **다르게 구현 — 이벤트 shape가 다름** (`SUCCEEDED/FAILED`, countBucket 없음) `L1074-1091` | **없음** | +| `countBucket` | 있음 `L119-125` | **RT와 문자 단위로 동일** `L239-245` | **없음** | **없음** | +| 식별자 정규식 | `SAFE_DATABASE_NAME` `L91` `{0,63}` | `SAFE_IDENTIFIER` `L103` `{0,127}` | `SAFE_DATABASE_NAME` `L110` `{0,255}`, `SAFE_BOUNDARY_ID` `L111` | `SAFE_OPAQUE_ID`/`SAFE_UPLOAD_KEY` (`checkpoint-schema.ts`) | +| 구성 불변식 검사 후 `TypeError` | 있음 `L530-563` | 있음 `L331-357` | 있음 `L155-163` | 있음 `L100-106,L116-122` | +| 의존성 메서드 bind 스냅샷 | 있음 `L474-527` | 있음 `L290-326` | **없음** | 있음 (부분) `L108-113` | +| 저장 레코드 검증 술어 | 있음 `L223-301` | **거의 동일하지만 별도 구현** `L118-223` | 있음, 도메인 고유 `L1406-1665` | 있음, 도메인 고유 (`checkpoint-schema.ts`) | + +### 1.11 대조표 결론 + +**4벌 전부가 같은 의미로 필요로 하는 것** (커널 후보): +`factory.open→Promise`, `onupgradeneeded` 훅, `onblocked` 처리, 늦게 온 connection 강제 close, +`onversionchange`/`onclose` 배선, `transaction→Promise` 상태기계(succeed/fail/settle-once/queue throw), +`request→콜백` 배선. + +**3벌** (커널 후보): 단일 비행 오픈 캐시(MT 제외 — 의도적), durability 폴백을 가진 트랜잭션 팩토리(CP 제외), +커서 순회(CP 제외), caller signal → transaction.abort(OP 제외 — 결함). + +**2벌 이하** (커널 아님): open 후 스토어 존재 검증(2), governance binding(2, 이미 `indexeddb-governance.ts`로 공유), +observe/countBucket(2), 시간 예산 클럭(2), 상태 구독 API(1), `deleteDatabase`(1), 저장 레코드 술어(전부 도메인 고유). + +**그리고 가장 중요한 결론: 실패 매핑은 공통이 아니다.** 3벌이 `mapIndexedDbException`, 1벌이 +`mapBrowserDataException`을 쓰고 두 테이블은 §1.8에 적은 대로 실제로 다른 답을 낸다. 커널이 매핑을 고르면 +CP의 동작이 조용히 바뀐다. → **매핑은 주입한다.** + +--- + +## 2. 커널 경계선 + +판단 기준은 「4벌 중 3벌 이상이 **같은 의미로** 필요로 하는가」다. + +### 2.1 커널로 올릴 것 + +| 항목 | 벌 수 | 근거 | +|---|---|---| +| `IDBOpenDBRequest` → Promise (settle-once, blocked 데드라인, upgrade 훅, 늦은 connection close, signal→upgrade abort) | 4/4 | §1.1 §1.2 §1.3 | +| 단일 비행 연결 핸들 (+ versionchange/close 무효화, close()가 진행 중 open 취소) | 3/4 | §1.1 §1.4. MT는 의도적으로 안 씀 → 커널의 open 함수만 쓰고 핸들은 안 쓴다 | +| 트랜잭션 팩토리 + durability 폴백 | 3/4 | §1.5. CP는 `durability: undefined`를 넘겨 오늘 동작 유지 | +| `transaction` → Promise 상태기계 | 4/4 | §1.5 | +| `request` → 콜백 배선(성공/오류) | 4/4 | §1.6 | +| 커서 펌프(순회 + 예산 + 중첩 체인 재개) | 3/4 | §1.7 | + +### 2.2 남길 것 (근거 포함) + +| 항목 | 벌 수 | 남기는 이유 | +|---|---|---| +| 실패 매핑 테이블 | 3 + 1 | 두 테이블이 실제로 다른 값을 낸다(§1.8). 통일은 **별도 결정**이고 별도의 테스트 낙진을 만든다. 커널은 **원인(cause)만 알리고 코드를 고르지 않는다.** | +| `operation` 라벨 | 4/4 다름 | 호출처마다 다르다. 커널이 알 필요가 없다. | +| open 후 스토어/인덱스 존재 검증 | 2/4 | RT는 `indexeddb-migrations.ts:162-207`, MT는 인라인. 검증 대상 스토어 목록이 도메인 스키마다. 커널에는 `admit` 콜백만 둔다. | +| governance / scope binding | 2/4 + 1 자체구현 | `indexeddb-governance.ts`가 이미 RT/MT 공유분이다. CP의 `bindScope`는 스코프 모델 자체가 다르다(`L60-66` vs `IndexedDbDatasetScope`). | +| observe / countBucket | 2/4 | RT·MT는 동일하지만 OP는 shape가 다르고 CP는 없다. 2벌은 올리지 않는다. RT·MT 간 중복은 `indexeddb-types.ts`로 내리는 **별건**이다. | +| 시간 예산 클럭 | 2/4 | 커널의 커서 펌프가 예산 판정을 **콜백으로 받는다**. 클럭·deadline·실패값은 호출처가 소유한다. | +| 연결 상태 구독(`getStatus`/`subscribeStatus`) | 1/4 | RT의 포트 계약(`IndexedDbRepositoryPort`)이다. | +| 저장 레코드 검증 술어, 코덱, 예산(bytes), 보존, 영수증 | 도메인 | 커널은 **정책을 하나도 안 갖는다**(§5.3의 방어선). | + +### 2.3 `deleteDatabase` — 규칙의 명시적 예외 (1/4) + +「≥3벌만 커널」 규칙을 그대로 적용하면 `deleteDatabase`는 탈락이다. **그럼에도 커널에 둔다.** 근거: + +1. 이건 **새 기능 발명이 아니라 이동**이다. 규칙이 막으려는 건 아무도 안 쓰는 추상화인데, 이건 오늘 호출자가 + 실제로 존재한다(`indexeddb-checkpoint-store.ts:403-485`). +2. `deleteDatabase`는 `open`과 **같은 `IDBOpenDBRequest` 상태기계**다. CP는 blocked 데드라인 + settle-once + 로직을 `L198-207`(open)과 `L449-462`(delete)에 **두 번** 적었다. 커널이 open만 가져가면 CP 안에 그 로직의 + 세 번째 사본이 남는다. +3. **드러난 발산 자체의 진짜 원인은 `deleteDatabase`가 아니다.** 리뷰가 "한쪽에만 있다"고 잡은 건 표면 차이다. + RT는 파티션 삭제를 `purgePartitionRecords`(`L2477-2812`)의 행 단위 순회로 **의도적으로** 다르게 한다. + 따라서 **`deleteDatabase`를 나머지 3벌에 새로 넣지 않는다.** 이행 계획(§4)에 그런 항목은 없다. +4. 별도 export라 나중에 떼어내도 나머지 커널이 안 흔들린다. + +`PENDING_DELETIONS` WeakMap 레지스트리(`L31-41`)는 **커널로 올리지 않는다.** 그건 realm 정책이고 CP에만 있는 판단이다. + +--- + +## 3. 실제 TypeScript 시그니처 + +### 3.1 파일 구성: **2개** + +``` +src/adapters/platform/indexeddb-connection.ts (~240 LOC) 연결 수명주기 +src/adapters/platform/indexeddb-transaction.ts (~230 LOC) 연결 안에서 벌어지는 일 +``` + +**기각한 대안 1 — 단일 `indexeddb-kernel.ts`(~470 LOC):** `platform/`의 현재 최대 파일은 +`abortable-operation.ts` 269 LOC다. 470 LOC는 1.7배로 폴더 관례를 깬다. 더 중요한 건 테스트 셋업이 갈린다는 +점이다: 연결 쪽은 가짜 `IDBFactory`가 필요하고 트랜잭션 쪽은 가짜 `IDBDatabase`면 충분하다. + +**기각한 대안 2 — 3분할(`open-request`/`connection`/`transaction`):** open 요청과 연결 핸들은 같은 수명주기 +개념이고, 분리하면 `IndexedDbFailureCause`가 두 파일에 걸치거나 세 번째 타입 파일이 필요해진다. 또 inventory +행이 하나 더 는다(§5.2). + +### 3.2 `src/adapters/platform/indexeddb-connection.ts` + +```ts +/** + * IDB-X-01. Shared IndexedDB connection mechanics. + * + * Four adapters independently reimplemented "turn an open request into a + * promise, hold a blocked deadline, route upgrade/error/success, close a + * connection that arrives after the caller gave up, and drop the cached handle + * when the browser takes it away". Only the mechanics are shared here. Database + * naming, schema, migrations, governance binding and the failure taxonomy stay + * with each subsystem, so this module imports none of them and is not a + * generic storage layer. + * + * The `IDBFactory` is a required parameter rather than a read of + * `globalThis.indexedDB`. `eslint.config.ts:40-63` bans that property on every + * browser root and `eslint.config.ts:519-530` grants the owned-adapter escape + * hatch to `src/adapters/platform/browser-lifecycle.ts` as a single file, not + * to this folder. Requiring the factory is also what all four callers already + * do, so nothing in eslint.config.ts has to change. + */ + +import type { Result } from "../../contracts/result.ts"; +import type { AbortTimerSnapshot } from "./abortable-operation.ts"; + +/** + * Everything that can end an IndexedDB operation without the caller getting a + * value. The kernel reports the cause; the caller's `translate` turns it into + * that subsystem's failure code. + * + * This union is the extension point. `abortable-operation.ts:11` baked a closed + * three-member `AbortTerminalReason` into its return type, so http v3 needed + * five owners and could not use the kernel at all. Here the owner vocabulary is + * never in a return type: adding a member is a compile error in every + * `translate` (they are total functions over the union) rather than a silent + * behavior change, and no consumer has to fork. + */ +export type IndexedDbFailureCause = + /** A native throw or a `request.error` / `transaction.error`. */ + | Readonly<{ kind: "NATIVE_EXCEPTION"; error: unknown }> + /** `onblocked` fired and no deadline was configured. */ + | Readonly<{ kind: "BLOCKED"; oldVersion: number; newVersion: number | null }> + /** `onblocked` fired and the configured deadline then elapsed. */ + | Readonly<{ kind: "BLOCKED_DEADLINE" }> + /** The caller's own `AbortSignal` fired. */ + | Readonly<{ kind: "CALLER_ABORT" }> + /** The connection handle was closed, which is not the caller aborting. */ + | Readonly<{ kind: "CLOSED" }> + /** `upgrade` returned `REJECTED`, or a version change happened unexpectedly. */ + | Readonly<{ kind: "UPGRADE_REJECTED"; oldVersion: number; newVersion: number | null; detail?: unknown }> + /** `admit` returned `REJECT`. `detail` is opaque to the kernel. */ + | Readonly<{ kind: "ADMISSION_REJECTED"; detail?: unknown }> + /** A transaction completed without `succeed()` ever being called. */ + | Readonly<{ kind: "NO_VALUE_PRODUCED" }> + /** No `IDBFactory`, or a required `IDBKeyRange` the caller did not supply. */ + | Readonly<{ kind: "UNSUPPORTED" }>; + +/** + * Turns a cause into this subsystem's failure value. Built per call site so the + * kernel never learns an operation label or a failure code; a caller that needs + * `INDEXEDDB_READ` and one that needs `UPLOAD_RECONCILE` differ only here. + */ +export type IndexedDbTranslate = ( + cause: IndexedDbFailureCause, +) => Failure; + +export type IndexedDbUpgradeContext = Readonly<{ + database: IDBDatabase; + transaction: IDBTransaction; + oldVersion: number; + /** Never `null`: a null `newVersion` is reported as `UPGRADE_REJECTED` before `upgrade` runs. */ + newVersion: number; +}>; + +/** + * `REJECTED` aborts the versionchange transaction, so a schema change can never + * commit under a rejected policy. A throw from `upgrade` is equivalent to + * `REJECTED` with the thrown value as `detail`. + */ +export type IndexedDbUpgradeOutcome = + | Readonly<{ kind: "APPLIED" }> + | Readonly<{ kind: "REJECTED"; detail?: unknown }>; + +/** + * Post-open validation. It runs after `onsuccess` and may be asynchronous, so + * store/index assertions and governance reads both fit. A rejected or failed + * admission closes the connection before the caller ever sees it. + */ +export type IndexedDbAdmission = + | Readonly<{ kind: "ADMIT" }> + | Readonly<{ kind: "REJECT"; detail?: unknown }> + | Readonly<{ kind: "FAIL"; cause: IndexedDbFailureCause }>; + +export type IndexedDbOpenInput = Readonly<{ + /** Required. See the module comment for why this is not read from a global. */ + factory: IDBFactory; + databaseName: string; + /** Omit to open whatever version exists. */ + version?: number; + translate: IndexedDbTranslate; + /** + * Called inside the versionchange transaction. Omitting it means any upgrade + * is unexpected and the open fails with `UPGRADE_REJECTED` — which is what + * `indexeddb-maintenance.ts:477-484` does by hand today. + */ + upgrade?: (context: IndexedDbUpgradeContext) => IndexedDbUpgradeOutcome; + /** Post-open validation. Omitting it admits every successful open. */ + admit?: (database: IDBDatabase) => IndexedDbAdmission | Promise; + /** Aborts a pending upgrade transaction and settles with `CALLER_ABORT`. */ + signal?: AbortSignal; + /** + * `undefined` or `0`: the `onblocked` event itself is terminal and settles + * with `BLOCKED` (maintenance's behavior). A positive value waits that long + * before settling with `BLOCKED_DEADLINE` (runtime/opfs/checkpoint). + */ + blockedTimeoutMs?: number; + /** + * Required when `blockedTimeoutMs` is positive. Build it with + * `snapshotAbortTimers` from `./abortable-operation.ts`, which binds the + * callables once so replacing a method after composition cannot change how an + * open already in flight is bounded. + */ + timers?: AbortTimerSnapshot; + /** Observation only; it cannot change the outcome and its throw is swallowed. */ + onBlocked?: ( + event: Readonly<{ oldVersion: number; newVersion: number | null }>, + ) => void; +}>; + +/** + * Settles exactly once. A connection that arrives after the settle — a late + * `onsuccess`, a rejected admission, an abort — is closed rather than leaked. + */ +export function openIndexedDbDatabase( + input: IndexedDbOpenInput, +): Promise>; + +export type IndexedDbConnection = Readonly<{ + /** + * Single-flight: concurrent callers share one in-flight open, and a cached + * live connection is returned without touching the factory. + */ + acquire(signal?: AbortSignal): Promise>; + /** The cached connection, or `null` while none is live. Live accessor, not a snapshot. */ + current(): IDBDatabase | null; + /** + * Idempotent. Closes the cached connection and settles any in-flight open + * with `CLOSED` — not `CALLER_ABORT`, because the two have different codes in + * `indexeddb-runtime.ts` (`L743` resolves UNAVAILABLE while `L676` resolves + * ABORTED), and collapsing them would change one of them. + */ + close(): void; + isClosed(): boolean; +}>; + +export type IndexedDbConnectionInput = Readonly<{ + /** + * How to produce a connection. Normally a closure over + * `openIndexedDbDatabase`. It is a seam rather than a fixed body so a caller + * can retry, decorate or fake the open without faking an `IDBFactory`. + */ + open: (signal: AbortSignal | undefined) => Promise>; + translate: IndexedDbTranslate; + /** + * Fired after the handle has already dropped its cached connection, so a + * listener cannot keep a connection the browser is taking back. The next + * `acquire()` opens again. + */ + onVersionChange?: (event: IDBVersionChangeEvent) => void; + /** `onclose`: the browser closed the connection without a version change. */ + onForcedClose?: () => void; +}>; + +export function createIndexedDbConnection( + input: IndexedDbConnectionInput, +): IndexedDbConnection; + +export type IndexedDbDeleteOutcome = + | Readonly<{ kind: "DELETED" }> + /** + * The request is still live in the browser. It is not a failure and it is not + * "not applied": `deleteDatabase` cannot be cancelled after dispatch, so the + * effect is unknown. `indexeddb-checkpoint-store.ts:449-462` makes the same + * distinction and its comment explains why. + */ + | Readonly<{ kind: "BLOCKED_DEADLINE" }>; + +export type IndexedDbDeleteInput = Readonly<{ + factory: IDBFactory; + databaseName: string; + translate: IndexedDbTranslate; + blockedTimeoutMs?: number; + timers?: AbortTimerSnapshot; + /** + * Called exactly once when the native request truly settles, success or + * error — never on a blocked deadline. The caller uses it to release a + * pending-deletion registration; the kernel does not own such a registry + * because whether a realm may recreate the database is the caller's policy. + */ + onSettled?: () => void; +}>; + +/** + * In this module because it is the same `IDBOpenDBRequest` state machine as + * `openIndexedDbDatabase`, not because three subsystems need it — only + * `indexeddb-checkpoint-store.ts` deletes a database, and nothing here asks the + * other three to start. Leaving it out would leave a third hand-written copy of + * the settle-once blocked-deadline latch eight lines away from the kernel's. + * + * There is deliberately no `signal`: the request cannot be cancelled after + * dispatch, so reporting ABORTED while the deletion may still commit would be a + * lie. Callers check their signal before calling. + */ +export function deleteIndexedDbDatabase( + input: IndexedDbDeleteInput, +): Promise>; +``` + +핵심 구현 로직 (본문은 생략, 분기만): + +```ts +export function openIndexedDbDatabase( + input: IndexedDbOpenInput, +): Promise> { + const { factory, databaseName, translate } = input; + if (input.signal?.aborted) { + return Promise.resolve(fail(translate({ kind: "CALLER_ABORT" }))); + } + return new Promise((resolve) => { + let settled = false; + let blockedTimer: unknown; + let upgradeRejection: IndexedDbFailureCause | null = null; + + const settle = (result: Result) => { + if (settled) { + // A connection that lost the race is closed, never leaked. + if (result.ok) closeQuietly(result.value); + return; + } + settled = true; + if (blockedTimer !== undefined) input.timers?.clearTimer(blockedTimer); + input.signal?.removeEventListener("abort", onCallerAbort); + resolve(result); + }; + + let request: IDBOpenDBRequest; + try { + request = input.version === undefined + ? factory.open(databaseName) + : factory.open(databaseName, input.version); + } catch (error) { + settle(fail(translate({ kind: "NATIVE_EXCEPTION", error }))); + return; + } + + function onCallerAbort(): void { + // An upgrade transaction is the only cancellable part of an open request. + try { request.transaction?.abort(); } catch { /* the error path owns it */ } + settle(fail(translate({ kind: "CALLER_ABORT" }))); + } + input.signal?.addEventListener("abort", onCallerAbort, { once: true }); + + request.onupgradeneeded = (event) => { /* upgrade ?? reject; abort on REJECTED */ }; + request.onblocked = (event) => { /* onBlocked hook; timer or immediate BLOCKED */ }; + request.onerror = () => { /* upgradeRejection ?? NATIVE_EXCEPTION(request.error) */ }; + request.onsuccess = () => { /* settled/closed → close; else await admit → settle */ }; + }); +} +``` + +### 3.3 `src/adapters/platform/indexeddb-transaction.ts` + +```ts +/** + * IDB-X-02. Shared IndexedDB transaction and cursor mechanics. + * + * The same settle-once transaction state machine exists four times + * (`indexeddb-runtime.ts:976-1074`, `indexeddb-maintenance.ts:606-705`, + * `indexeddb-opfs-journal.ts:1098-1174`, + * `indexeddb-checkpoint-store.ts:497-596`) and the four already disagree: one + * of them never routes a request error at all, one aborts on a request error + * while two only record it, and one reports a value-less completion as + * CORRUPT_DATA while three report UNAVAILABLE. This module owns the mechanics + * and keeps every one of those choices at the call site. + */ + +import type { Result } from "../../contracts/result.ts"; +import type { + IndexedDbFailureCause, + IndexedDbTranslate, +} from "./indexeddb-connection.ts"; + +export type IndexedDbDurability = "default" | "strict" | "relaxed"; + +/** + * The failure half of a transaction context. Split out so helpers that only + * need to report failure (`onIndexedDbRequest`, `walkIndexedDbCursor`) do not + * have to be generic over the transaction's success type. + */ +export type IndexedDbRequestSink = Readonly<{ + /** + * Records the failure and aborts the transaction. The first failure wins. + * This is `indexeddb-runtime.ts:1047-1054`'s `fail`. + */ + fail(failure: Failure): void; + /** + * Records a request-level error **without aborting**: the transaction is left + * to complete or abort on its own, and the recorded error becomes the reported + * failure if it aborts. This is `indexeddb-runtime.ts:1055-1060`'s + * `requestFailed`. + * + * `indexeddb-checkpoint-store.ts:577-588` deliberately aborts instead. It + * keeps doing so by calling `fail(translate({kind:"NATIVE_EXCEPTION", error}))`. + * The kernel does not pick. + */ + requestFailed(error: unknown): void; +}>; + +export type IndexedDbTransactionContext = + IndexedDbRequestSink & + Readonly<{ + /** The first `succeed` wins; later ones are ignored. */ + succeed(value: Value): void; + /** For callers that need `objectStore()`/`index()` directly. */ + readonly transaction: IDBTransaction; + }>; + +export type IndexedDbTransactionInput = Readonly<{ + database: IDBDatabase; + stores: readonly string[]; + mode: "readonly" | "readwrite"; + translate: IndexedDbTranslate; + /** Aborts the transaction; the outcome is `CALLER_ABORT` unless completion won. */ + signal?: AbortSignal; + /** + * `undefined` opens with **no options bag at all**, which is + * `indexeddb-checkpoint-store.ts:512`'s current behavior — not the same as + * `"default"`, which passes `{durability:"default"}`. A named value falls back + * to the no-options form when the engine rejects the bag with a `TypeError`. + */ + durability?: IndexedDbDurability; + queue: ( + transaction: IDBTransaction, + context: IndexedDbTransactionContext, + ) => void; +}>; + +/** + * A transaction that completes without `succeed()` is reported through + * `translate({kind:"NO_VALUE_PRODUCED"})`. Three callers map that to + * UNAVAILABLE and `indexeddb-checkpoint-store.ts:541-547` maps it to + * CORRUPT_DATA; the kernel never picks. + */ +export function runIndexedDbTransaction( + input: IndexedDbTransactionInput, +): Promise>; + +/** + * The durability fallback on its own, for a caller that manages its own + * transaction. `undefined` omits the options bag entirely. + */ +export function openIndexedDbTransaction( + database: IDBDatabase, + stores: readonly string[], + mode: "readonly" | "readwrite", + durability?: IndexedDbDurability, +): IDBTransaction; + +/** + * Wires `onsuccess`/`onerror` in one place. The four copies write this pair by + * hand at roughly 70 sites and `indexeddb-opfs-journal.ts` omits `onerror` + * everywhere, which is how a request-level error there becomes whatever + * `transaction.error` happens to hold. + */ +export function onIndexedDbRequest( + request: IDBRequest, + sink: IndexedDbRequestSink, + onSuccess: (value: Value) => void, +): void; + +/** How the visitor wants the cursor advanced. */ +export type IndexedDbCursorStep = + | Readonly<{ kind: "CONTINUE" }> + | Readonly<{ kind: "CONTINUE_FROM"; key: IDBValidKey }> + | Readonly<{ kind: "CONTINUE_PRIMARY"; key: IDBValidKey; primaryKey: IDBValidKey }> + /** End the walk here; `done` gets `reason: "STOPPED"`. */ + | Readonly<{ kind: "STOP" }> + /** + * The visitor started its own request chain and will call `resume(step)` when + * that chain finishes. Without this the pump is unusable by three of the four + * callers: every walk in `indexeddb-runtime.ts` and `indexeddb-maintenance.ts` + * issues nested requests before advancing (e.g. `L1487-1526`, `L2406-2471`, + * `L1490-1541`). A pump that only understood `CONTINUE` would be the + * too-narrow-to-adopt failure again. + */ + | Readonly<{ kind: "SUSPEND" }>; + +export type IndexedDbBudgetVerdict = "CONTINUE" | "ROW_BUDGET" | "TIME_BUDGET"; + +export type IndexedDbBudget = Readonly<{ + /** + * Checked before each row. The kernel counts nothing itself: runtime bounds + * on rows it deleted (`indexeddb-runtime.ts:2384`) while maintenance bounds on + * rows it scanned (`indexeddb-maintenance.ts:823`), so the counter, the clock + * and the deadline all belong to the caller. A clock that cannot be read is a + * failure rather than a `false`, which is what `monotonicClock()` + * (`indexeddb-runtime.ts:2260-2269`) already does. + */ + admit(scannedRows: number): Result; +}>; + +export type IndexedDbCursorVisit = Readonly<{ + cursor: IDBCursorWithValue; + /** Rows handed to `visit` so far, this row included. */ + scannedRows: number; + /** Only meaningful after the visitor returned `SUSPEND`. Idempotent. */ + resume(step: IndexedDbCursorStep): void; +}>; + +export type IndexedDbWalkSummary = Readonly<{ + reason: "EXHAUSTED" | "STOPPED" | "ROW_BUDGET" | "TIME_BUDGET" | "ABORTED"; + scannedRows: number; +}>; + +export type IndexedDbWalkInput = Readonly<{ + request: IDBRequest; + sink: IndexedDbRequestSink; + translate: IndexedDbTranslate; + budget?: IndexedDbBudget; + /** + * Checked at each row. An aborted signal aborts the transaction and ends the + * walk with `reason: "ABORTED"`, which is what `indexeddb-runtime.ts:1375-1382` + * does inline today. + */ + signal?: AbortSignal; + visit: (visit: IndexedDbCursorVisit) => IndexedDbCursorStep; + /** The only success exit. The caller routes it into its own `succeed`. */ + done: (summary: IndexedDbWalkSummary) => void; +}>; + +/** + * Drives an open cursor. It reports a native advance failure through `sink` and + * never decides what a finished walk means — `reason` distinguishes a row budget + * from a time budget so a caller can keep reporting `budgetExhausted` exactly as + * it does now (`indexeddb-runtime.ts:2387`). + */ +export function walkIndexedDbCursor( + input: IndexedDbWalkInput, +): void; +``` + +### 3.4 확장점이 4벌의 요구를 어떻게 다 받는지 — 사본별 예시 + +**(a) RT: `mapIndexedDbException` + 호출처별 operation + 상태 브로드캐스트 + blocked 타이머** + +```ts +// 연산마다 번역기 하나. 커널은 "INDEXEDDB_OPEN"이라는 문자열을 모른다. +const translateFor = (operation: BrowserDataOperation): IndexedDbTranslate => + (cause) => { + switch (cause.kind) { + case "NATIVE_EXCEPTION": + return unwrap(mapIndexedDbException(cause.error, operation)); + case "BLOCKED": + case "BLOCKED_DEADLINE": + return unwrap(browserDataFailure("BLOCKED", operation, { + retryable: true, recovery: "RELOAD_OTHER_CONTEXTS", + })); + case "CALLER_ABORT": + return unwrap(browserDataFailure("ABORTED", operation)); + case "CLOSED": + case "NO_VALUE_PRODUCED": + // runtime L743 / L1020: close와 값 없는 완료는 둘 다 UNAVAILABLE이다. + return unwrap(unavailable(operation)); + case "UPGRADE_REJECTED": + return unwrap(browserDataFailure("MIGRATION_FAILED", "INDEXEDDB_MIGRATE", { recovery: "READ_ONLY" })); + case "ADMISSION_REJECTED": + // detail이 governance 거절인지 store 검증 실패인지는 RT만 안다. + return cause.detail === "POLICY" + ? unwrap(browserDataFailure("POLICY_REJECTED", operation, { + recovery: storagePolicySnapshot.unavailableFallback })) + : unwrap(mapIndexedDbException(cause.detail, "INDEXEDDB_MIGRATE")); + case "UNSUPPORTED": + return unwrap(browserDataFailure("UNSUPPORTED", operation, { recovery: "ONLINE_ONLY" })); + } + }; + +const connection = createIndexedDbConnection({ + open: (signal) => openIndexedDbDatabase({ + factory, databaseName, version: dependencies.schemaVersion, + translate: translateFor("INDEXEDDB_OPEN"), + signal, + blockedTimeoutMs, // L576 + timers: snapshotAbortTimers(scheduler), // L514-527를 대체 + onBlocked: (event) => { // L777-788 그대로 + updateStatus({ kind: "BLOCKED", currentVersion: event.oldVersion, + targetVersion: event.newVersion ?? dependencies.schemaVersion }); + observe({ operation: "INDEXEDDB_OPEN", outcome: "BLOCKED", + schemaVersion: dependencies.schemaVersion, countBucket: "0", failureCode: "BLOCKED" }); + }, + upgrade: ({ database, transaction, oldVersion, newVersion }) => { // L746-774 그대로 + let rejected = false; + try { + appliedMigrations = applyIndexedDbMigrations( + database, transaction, oldVersion, newVersion, dependencies.migrations); + queueIndexedDbUpgradeBinding( + transaction, dependencies.governanceStore, expectedBinding, oldVersion, + () => { rejected = true; }); + } catch (error) { + return { kind: "REJECTED", detail: error }; + } + return rejected ? { kind: "REJECTED", detail: "POLICY" } : { kind: "APPLIED" }; + }, + admit: async (database) => { // L844-895 그대로 + try { + assertIndexedDbRuntimeStores(database, /* … 8개 인자 … */); + } catch (error) { return { kind: "FAIL", cause: { kind: "ADMISSION_REJECTED", detail: error } }; } + const binding = await verifyIndexedDbDatasetBinding( + database, dependencies.governanceStore, expectedBinding, undefined); + if (binding.ok) return { kind: "ADMIT" }; + return binding.reason === "ABORTED" + ? { kind: "FAIL", cause: { kind: "CALLER_ABORT" } } + : binding.reason === "NATIVE_ERROR" + ? { kind: "FAIL", cause: { kind: "NATIVE_EXCEPTION", error: binding.error } } + : { kind: "REJECT", detail: "POLICY" }; + }, + }), + translate: translateFor("INDEXEDDB_OPEN"), + onVersionChange: () => { // L637-651에서 db.close()/connection=null 부분을 뺀 나머지 + const next = Object.freeze({ kind: "CLOSED" as const, reason: "VERSION_CHANGE" as const }); + updateStatus(next); + try { dependencies.onVersionChange?.(next); } catch { /* 알림과 종료는 독립 */ } + }, + onForcedClose: () => updateStatus({ kind: "CLOSED", reason: "FORCED" }), +}); +``` + +RT의 커서 예산(삭제 행 기준 + 모노토닉 deadline)은 `budget.admit`으로: + +```ts +// purgeEligibleRecords L2378-2389 → budget 하나로 +const budget: IndexedDbBudget = { + admit: () => { + const clock = monotonicClock(); // L2260-2269 그대로 남는다 + if (!clock.ok) return clock; + if (clock.value >= deadline) return ok("TIME_BUDGET"); + if (deletedRows >= input.maxRows) return ok("ROW_BUDGET"); // 삭제 행 기준 유지 + return ok("CONTINUE"); + }, +}; +``` + +**(b) MT: blocked 타이머 없음 + upgrade 자체가 실패 + `INDEXEDDB_MIGRATE` 고정 + 연결 캐시 안 씀** + +```ts +// 핸들을 만들지 않는다. openIndexedDbDatabase만 직접 부른다. (L1294-1296, finally L1364-1366 유지) +const opened = await openIndexedDbDatabase({ + factory, databaseName, version: dependencies.schemaVersion, + translate, // operation이 상수라 번역기도 하나 + signal: input.signal, + // blockedTimeoutMs 생략 → BLOCKED 원인이 blocked 이벤트에서 바로 나온다 (L485-492 동작 보존) + // upgrade 생략 → 어떤 upgrade든 UPGRADE_REJECTED (L477-484, L495-496 동작 보존) + admit: async (database) => { // L509-550 그대로 + for (const store of [recordStore, governanceStore, retentionStore, checkpointStore, idempotencyStore]) { + if (!database.objectStoreNames.contains(store)) return { kind: "REJECT", detail: "STORE" }; + } + try { + openIndexedDbTransaction(database, [dependencies.idempotencyStore], "readonly") + .objectStore(dependencies.idempotencyStore).index(dependencies.idempotencyExpiryIndex); + } catch { return { kind: "REJECT", detail: "INDEX" }; } + database.onversionchange = () => database.close(); // L543 그대로 + const binding = await verifyIndexedDbDatasetBinding( + database, dependencies.governanceStore, expectedBinding, input.signal); + return binding.ok ? { kind: "ADMIT" } : { kind: "REJECT", detail: binding }; + }, +}); +``` + +**(c) OP: signal 없음 + readwrite만 strict + 자체 observe shape** + +```ts +// listIncomplete L600-633 → walk. signal도 budget도 안 넘긴다: 지금 없는 걸 새로 만들지 않는다. +runIndexedDbTransaction({ + database: db, stores: [JOURNAL_STORE], mode: "readonly", + translate: translateFor("INDEXEDDB_READ"), + durability: undefined, // readonly는 오늘도 옵션 없음 (L1118) + queue: (transaction, context) => { + const rows: OpfsJournalTransaction[] = []; + walkIndexedDbCursor({ + request: transaction.objectStore(JOURNAL_STORE).index(STARTED_AT_INDEX).openCursor(), + sink: context, translate: translateFor("INDEXEDDB_READ"), + visit: ({ cursor }) => { + if (!isStoredJournalRow(cursor.value) || !isBoundScope(cursor.value.scope)) { + context.fail(unwrap(corrupt("INDEXEDDB_READ"))); + return { kind: "STOP" }; + } + if (rows.length === limit) return { kind: "STOP" }; + rows.push(cursor.value); + return { kind: "CONTINUE" }; + }, + done: (summary) => context.succeed(Object.freeze({ + transactions: Object.freeze(rows), + moreAvailable: summary.reason === "STOPPED" && rows.length === limit, + })), + }); + }, +}); +// readwrite 쪽만 durability: "strict" (L1181-1183 동작 보존) +``` + +**(d) CP: 다른 매핑 테이블 + 값 없는 완료가 CORRUPT_DATA + durability 없음 + 요청 오류에 즉시 abort** + +```ts +const translate: IndexedDbTranslate = (cause) => { + switch (cause.kind) { + case "NATIVE_EXCEPTION": + return unwrap(mapBrowserDataException(cause.error, "UPLOAD_RECONCILE")); // ← 다른 테이블 유지 + case "NO_VALUE_PRODUCED": + return unwrap(browserDataFailure("CORRUPT_DATA", "UPLOAD_RECONCILE", { recovery: "RECONCILE" })); + case "BLOCKED": + case "BLOCKED_DEADLINE": + return unwrap(browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", { retryable: true, recovery: "RESUME" })); + case "CALLER_ABORT": + return unwrap(browserDataFailure("ABORTED", "UPLOAD_RECONCILE")); + case "CLOSED": + return unwrap(browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", { recovery: "RESUME" })); + /* … */ + } +}; + +runIndexedDbTransaction({ + database, stores: [CHECKPOINT_STORE], mode: "readonly", translate, signal, + durability: undefined, // L512: 오늘도 옵션 없음. 커널 기본값을 상속하지 않는다. + queue: (transaction, context) => { + const store = transaction.objectStore(CHECKPOINT_STORE); + const request = store.get(uploadKey); + // nativeFailure(= 기록 후 즉시 abort) 유지: requestFailed가 아니라 fail을 쓴다. + request.onerror = () => context.fail(translate({ kind: "NATIVE_EXCEPTION", error: request.error })); + request.onsuccess = () => { /* L265-283 그대로 */ }; + }, +}); +``` + +이 네 예시가 보여주는 것: **커널은 operation 라벨, 실패 코드, 매핑 테이블, blocked 정책, upgrade 정책, +예산 기준, abort 정책, durability 정책을 하나도 안 정한다.** `abortable-operation.ts:11`이 +`AbortTerminalReason`을 반환 타입에 박아 넣어 http v3가 못 쓴 것과 정확히 반대 구조다. + +--- + +## 4. 사본별 이행 계획 + +LOC 감소는 **추정**이다. 근거로 삭제 대상 블록의 줄 범위를 함께 적었다. + +### 4.1 RT — `indexeddb-runtime.ts` (2902 → 약 2665, **−235**) + +**삭제:** + +| 블록 | 줄 | LOC | 대체 | +|---|---|---:|---| +| `defaultScheduler` | L108-117 | 10 | `snapshotAbortTimers(scheduler)` | +| `TransactionContext` 타입 | L85-89 | 5 | `IndexedDbTransactionContext` | +| `waitForOpeningAttempt` | L659-682 | 24 | `connection.acquire(signal)` | +| `startOpeningAttempt` 중 배선 부분 (settle-once, generation 부기, blocked 타이머, onerror 라우팅, 늦은 close) | L684-745, L776-843, L872-921 중 배선분 | ~110 | `openIndexedDbDatabase` (upgrade/admit 콜백 본문은 그대로 남음) | +| `createTransaction` | L957-974 | 18 | `openIndexedDbTransaction` | +| `runTransaction` | L976-1074 | 99 | `runIndexedDbTransaction` | +| 커서 5곳의 deadline/maxRows/abort 보일러플레이트 | L1374-1382, L1454-1471, L2364-2389, L2531-2548, L2573-2590, L2644-2661, L2718-2735 | ~60 | `walkIndexedDbCursor` + `budget.admit` | +| 소계 삭제 | | **~326** | | +| 커널 호출부·콜백 추가 | | ~90 | | +| **순감** | | **~−235** | | + +**남는 것:** 코덱/영수증/보존/예산(bytes)/governance/migration 목록/상태 브로드캐스트/`monotonicClock`/ +`countBucket`/저장 레코드 술어/`purgePartitionRecords`의 스토어 순서 로직 — 전부 정책이라 그대로. + +**동작 변화 지점:** +- **RT-1.** `close()`가 진행 중 open을 끝내는 원인이 `unavailable(operation)`(L743)에서 + `translate({kind:"CLOSED"})`로 바뀐다. 위 번역기는 `CLOSED → unavailable`로 매핑해 **동일하게 유지**한다. + 이 매핑을 빠뜨리면 `close()` 중 open이 ABORTED로 보고된다. **테스트가 잡아야 할 지점.** +- **RT-2.** `settleNativeRequest`/`activeOpeningGeneration`(L595, L691-697)이 사라진다. 늦게 온 open의 무해화를 + 커널의 settle-once가 대신한다. RT의 generation 토큰은 `openingPromise` 정리 시점 제어용이었고 + `createIndexedDbConnection`의 단일 비행이 같은 역할을 한다. **의미 동일. 단 경합 순서가 달라질 수 있다.** +- **RT-3.** `openingRequest` 필드(L593)가 쓰이지 않게 된다(오늘도 L712와 L2878 대입 외에 읽는 곳 없음). 삭제. + +### 4.2 MT — `indexeddb-maintenance.ts` (1558 → 약 1370, **−188**) + +**삭제:** + +| 블록 | 줄 | LOC | 대체 | +|---|---|---:|---| +| `TransactionContext` 타입 | L97-101 | 5 | 커널 타입 | +| `openExactVersion` 중 배선분 | L434-508, L551-585 | ~95 | `openIndexedDbDatabase` (`admit`에 L509-550 이식) | +| `createTransaction` | L587-604 | 18 | `openIndexedDbTransaction` | +| `runTransaction` | L606-705 | 100 | `runIndexedDbTransaction` | +| 커서 2곳의 deadline/maxRows/abort 보일러플레이트 | L799-833, L1451-1479 | ~30 | `walkIndexedDbCursor` | +| 소계 삭제 | | **~248** | | +| 커널 호출부·콜백 추가 | | ~60 | | +| **순감** | | **~−188** | | + +**남는 것:** 체크포인트 상태기계(`readCheckpoint` L707-756, `commitPrepared` L969-1251), +`prepareRecords` L863-967, `clock`/`epochClock` L401-421, `countBucket` L239-245, 저장 술어 L118-223. + +**동작 변화 지점:** +- **MT-1. (가장 위험)** `blockedTimeoutMs`를 안 넘기면 오늘 동작(blocked 이벤트 즉시 BLOCKED, L485-492)이 + 유지된다. **넘기면 배치가 최대 그 시간만큼 매달린다.** `tests/unit/indexeddb-maintenance.test.ts:289-290`이 + `BLOCKED/retryable:true/RELOAD_OTHER_CONTEXTS`를 즉시 받길 기대하므로 값을 잘못 넣으면 타임아웃 실패한다. +- **MT-2.** `upgrade` 콜백을 **생략**해야 오늘의 "upgrade는 곧 실패"(L477-484)가 유지된다. 커널은 생략을 + `UPGRADE_REJECTED`로 해석하므로 실수로 빈 `upgrade: () => ({kind:"APPLIED"})`를 넣으면 잘못된 스키마로 + 열린다. **테스트가 잡아야 할 지점.** +- **MT-3.** `database.onversionchange = () => database.close()`(L543)를 `admit` 안으로 옮긴다. `admit`은 + 성공 경로에서만 실행되므로 등록 시점이 오늘과 같다. + +### 4.3 OP — `indexeddb-opfs-journal.ts` (1817 → 약 1690, **−127**) + +**삭제:** + +| 블록 | 줄 | LOC | 대체 | +|---|---|---:|---| +| `TransactionContext` 타입 | L48-51 | 4 | 커널 타입 | +| scheduler 기본값 인라인 | L144-153 | 10 | `snapshotAbortTimers` | +| `openDatabase` 중 배선분 | L961-996, L1031-1072 | ~79 | `openIndexedDbDatabase` + `createIndexedDbConnection` (upgrade 본문 L997-1029는 `upgrade` 콜백으로 그대로 이동) | +| `runTransaction` | L1098-1174 | 77 | `runIndexedDbTransaction` | +| `strictReadwriteTransaction` | L1176-1190 | 15 | `openIndexedDbTransaction(…, "strict")` | +| 커서 2곳의 limit 루프 | L604-633, L677-712 | ~20 | `walkIndexedDbCursor` | +| 소계 삭제 | | **~205** | | +| 커널 호출부 + **누락된 요청 오류 배선 추가** | | ~78 | | +| **순감** | | **~−127** | | + +**동작 변화 지점:** +- **OP-1. (이 리팩토링에서 가장 큰 동작 변화)** 오늘 OP는 트랜잭션 안의 개별 요청에 `.onerror`를 **하나도** + 안 단다(파일 전체 request `.onerror`는 L1042 open 요청뿐). 요청 실패는 `transaction.onabort`로만 흘러 + `mapIndexedDbException(transaction.error)`(L1158)가 된다. 커널을 쓰면 **요청 자신의 오류가 보고된다.** + 구체적으로 `LOGICAL_KEY_INDEX`가 `unique:true`(L1000-1004)이므로 중복 put은 요청 레벨 + `ConstraintError` → `CONFLICT`가 되고, 오늘은 `transaction.error`가 무엇이냐에 따라 달라진다. + **`tests/unit/indexeddb-opfs-journal.test.ts`가 잡아야 할 지점.** +- **OP-2.** 오늘 OP의 `runTransaction`은 `succeed` 이후 `fail`이 와도 `explicitFailure`가 이기지만 + (L1133-1141, `hasValue`는 그대로 true) `oncomplete`는 값을 반환한다(L1143-1153). 커널의 + "첫 결과가 이긴다" 규칙(RT L1044-1054와 동일)을 따르면 **`succeed` 후의 `fail`이 무시된다.** + 현재 OP 코드에서 그 순서가 실제로 발생하는 경로가 있는지는 **미확인** — 이행 시 확인 필요. +- **OP-3.** `context.fail` 시 abort가 실패하면 오늘은 즉시 `settle(result)`(L1139)한다. 커널(RT식)은 + `finish(candidate ?? …)`로 같은 결과를 낸다. 의미 동일. +- **OP-4.** `signal`은 **넣지 않는다.** 오늘 없는 취소를 새로 만들지 않는다. + +### 4.4 CP — `indexeddb-checkpoint-store.ts` (712 → 약 570, **−142**) + +**삭제:** + +| 블록 | 줄 | LOC | 대체 | +|---|---|---:|---| +| `TransactionContext` 타입 | L491-495 | 5 | 커널 타입 | +| `openAndBind` 중 open 요청 Promise 배선 | L163-175, L198-217 | ~33 | `openIndexedDbDatabase` (upgrade 본문 L176-197은 콜백으로 이동, `bindScope` 호출은 `admit`으로 이동) | +| `runCheckpointTransaction` | L497-596 | 100 | `runIndexedDbTransaction` | +| `bindScope`의 트랜잭션 배선 | L602-623 | ~22 | `runIndexedDbTransaction` (검증 로직 L624-652는 `queue`로 그대로) | +| `deletePartition`의 blocked/settle 배선 | L431-462, L472-483 | ~35 | `deleteIndexedDbDatabase` | +| 소계 삭제 | | **~195** | | +| 커널 호출부·콜백 추가 | | ~53 | | +| **순감** | | **~−142** | | + +**남는 것:** `PENDING_DELETIONS` 레지스트리 L31-41 + 생성 시 검사 L116-122, `uploadCheckpointDatabaseName` +L73-83, `sameScopeBinding` L656-672, `snapshotScope` L674-691, `snapshotCheckpoint` L693-711. + +**동작 변화 지점:** +- **CP-1.** 값 없는 완료가 `CORRUPT_DATA/RECONCILE`(L541-547)로 유지되려면 번역기가 + `NO_VALUE_PRODUCED → CORRUPT_DATA`를 **명시적으로** 매핑해야 한다. 안 하면 UNAVAILABLE로 바뀐다. + **테스트가 잡아야 할 지점.** +- **CP-2.** `durability: undefined`를 **명시적으로** 넘겨야 오늘 동작(옵션 bag 없음, L512)이 유지된다. + 커널 기본을 상속하거나 `"strict"`를 넣으면 체크포인트 쓰기가 조용히 strict가 되어 느려진다. 정확성은 + 안 깨지지만 성능 회귀다. +- **CP-3.** `nativeFailure`(L577-588, 기록 후 즉시 abort)는 `context.fail(translate(NATIVE_EXCEPTION))`으로 + 표현한다. `requestFailed`(abort 안 함)로 잘못 바꾸면 **요청이 실패했는데도 뒤 요청이 커밋될 수 있다.** + `compareAndSwap`의 `get → put` 체인(L321-343)에서 특히 위험하다. **테스트가 잡아야 할 지점.** +- **CP-4.** `abort()` 호출이 throw했을 때 오늘은 `failure`를 **되돌린다**(L528, L536: "durably committed while + its completion event is still queued" — 커밋된 변경을 ABORTED로 보고하지 않기 위함). 커널의 + RT식 `onAbort`(L1010-1017)에는 이 되돌림이 없다. **커널의 signal abort 처리는 CP의 이 규칙을 채택해야 한다: + abort가 throw하면 caller-abort 표시를 세우지 않고 transaction 이벤트가 결과를 정한다.** 이건 커널 구현의 + 필수 요건이고, 놓치면 CP가 커밋된 체크포인트를 ABORTED로 보고한다. + → `tests/unit/resumable-upload-checkpoint.test.ts:238` "never reports a false abort after irreversible + deleteDatabase dispatch"가 인접 케이스를 이미 지킨다. +- **CP-5.** `deletePartition`의 blocked 타이머가 네이티브 `setTimeout`(L450)에서 주입형 `timers`로 바뀐다. + `tests/unit/resumable-upload-checkpoint.test.ts:194`가 이 경로를 본다. +- **CP-6.** `deleteDatabase`를 나머지 3벌에 **추가하지 않는다.** §2.3 근거. + +### 4.5 전체 합계 + +| | LOC | +|---|---:| +| 4벌에서 삭제 | ~974 | +| 4벌에 추가 (커널 호출부·콜백) | ~281 | +| 4벌 순감 | **~−693** | +| 커널 신규 2파일 | +~470 | +| **레포 순증감** | **~−223** | + +**솔직한 평가:** LOC 절감은 크지 않다. 이 작업의 성과는 줄 수가 아니라 **트랜잭션 상태기계가 4개에서 1개가 +되는 것**이다. 오늘 그 4개는 §1.5에서 본 대로 이미 4가지 다른 답을 낸다(요청 오류를 안 봄 / 기록만 / 즉시 +abort, 값 없는 완료가 UNAVAILABLE / CORRUPT_DATA). LOC로 정당화하면 안 된다. + +--- + +## 5. 위험과 선행조건 + +### 5.1 선행조건 P1 — ESLint (**가장 놓치기 쉬움**) + +`eslint.config.ts:40-63`의 `restrictedBrowserDataProperties`는 `globalThis`/`window`/`self` 세 루트 전부에서 +`indexedDB` **속성 접근**을 금지한다. 추가로 `eslint.config.ts:112-…`의 커스텀 규칙 +`browser-data-boundary/no-capability-alias`가 지역 별칭까지 따라간다 +(`const host = globalThis; host.indexedDB`도 잡힌다 — 규칙 주석 `eslint.config.ts:107-111`). + +두 개의 예외 목록(`eslint.config.ts:519-530`, `eslint.config.ts:677-691`)은 `src/adapters/platform/`을 +**폴더가 아니라 `browser-lifecycle.ts` 한 파일로** 열어 놨다. + +→ **설계 결론: 커널은 `globalThis.indexedDB`를 절대 읽지 않는다.** `IDBFactory`는 필수 주입 파라미터다 +(§3.2). 네 사본은 이미 전역 해석을 자기가 한다(RT L565-569, MT L359-363, OP L130-134, CP L95-97). +**이 설계대로면 `eslint.config.ts`를 한 줄도 안 고쳐도 된다.** + +확인해 둔 것: `IDBFactory`·`IDBDatabase`·`IDBTransaction`·`IDBRequest`·`IDBKeyRange`·`IDBCursorWithValue`는 +어느 금지 목록에도 없다(`eslint.config.ts:40-63`, `:544-561`). 커널이 이 타입들을 이름으로 쓰는 건 문제없다. + +대안(커널이 전역을 읽게 하기)을 택하면 `eslint.config.ts`의 **두 목록 모두**에 신규 파일 2개를 추가해야 한다. +한쪽만 고치면 `no-capability-alias`나 `no-restricted-properties` 중 하나가 남아서 `pnpm lint`가 깨진다. + +### 5.2 선행조건 P2 — `docs/reviews/adapters/INVENTORY.md` **갱신 필요** + +`scripts/check-adapter-inventory.ts:23-31`이 `git ls-files src/adapters`와 inventory 행을 **집합으로** 대조하고 +(`:41-53`), 중복을 거부하며(`:62-64`), `합계: **N/N**`의 두 숫자가 파일 수와 같아야 한다(`:65-72`). + +현재 총계는 `INVENTORY.md:132`의 `합계: **120/120**`이다. 신규 파일 2개 → **`122/122`**. + +확인해 둔 유용한 사실: 행 번호 정규식은 `/^\|\s*\d+\s*\|\s*` + 백틱 경로다(`:35`). 번호는 **위치와 대조되지 +않고** 순서도 무관하다(실제로 현재 `platform/` 행 59-63은 `git ls-files` 정렬과 순서가 다르다 — +`INVENTORY.md:69-73`). → **기존 행을 재번호할 필요 없이 121, 122번 행을 추가하면 된다.** + +추가할 행(기존 `platform/` 행들과 같은 상세 리뷰 링크): + +``` +| 121 | `src/adapters/platform/indexeddb-connection.ts` | [Network/state](./01-network-and-state.md) | +| 122 | `src/adapters/platform/indexeddb-transaction.ts` | [Network/state](./01-network-and-state.md) | +``` + +`INVENTORY.md:132`의 안내문이 "해당 상세 리뷰 inventory를 같은 변경에서 갱신한다"고 요구한다. +→ `docs/reviews/adapters/01-network-and-state.md`에도 두 행이 필요하다. 다만 **그 파일에 구조적 게이트가 +걸려 있는지는 미확인**이다 (`check-adapter-inventory.ts`는 `INVENTORY.md`만 읽는다, `:20`). + +### 5.3 선행조건 P3 — dependency-cruiser **변경 불필요** + +`adapters-do-not-know-other-concrete-adapters`의 `pathNot`이 +`^src/adapters/($1/|platform/|browser-file-storage/result\.ts$|cross-context-invalidation/index\.ts$)`이므로 +`src/adapters/platform/` 전체가 이미 허용 대상이다. 네 사본 전부 커널을 import할 수 있다. + +커널이 `src/contracts/result.ts`를 import하는 것도 허용된다: +- dependency-cruiser에서 contracts를 언급하는 유일한 규칙은 `concrete-adapters-compose-only-in-bootstrap`인데, + 이건 `contracts → adapters`를 막지 `adapters → contracts`를 막지 않는다. +- eslint `layerPatterns.adapters`는 `["**/presentation/**", "**/bootstrap/**"]`뿐이다(`eslint.config.ts:33`). +- 선례: `src/adapters/web-push/push-association-fence-store.ts:12`가 `../../contracts/web-push.ts`를 import한다. + +그리고 `Result`(`src/contracts/result.ts:13-15`)를 쓰면 +`Result`가 정의상 `BrowserDataResult`와 **같은 타입**이다 +(`src/application/ports/browser-file-storage/shared.ts:95`). 변환 코드가 0줄이다. + +### 5.4 깨질 수 있는 기존 테스트 (grep으로 확인한 목록) + +| 파일 | LOC | 위험 | +|---|---:|---| +| `tests/helpers/memory-indexeddb.ts` | 922 | **최고 위험.** 단위 테스트 전부가 도는 가짜 IDB. OP-1(요청 레벨 `onerror`를 새로 달게 됨)이 이 가짜가 `request.error`를 실제로 채우는지에 의존한다. 오늘 OP는 요청 오류를 안 보므로 그 경로가 **한 번도 행사되지 않았을 수 있다.** 채우지 않으면 `NATIVE_EXCEPTION{error: null}` → `UNAVAILABLE`로 뭉개진다. **이행 전에 이 파일을 먼저 읽어야 한다 (이번 조사에서는 미확인).** | +| `tests/unit/indexeddb-runtime.test.ts` | 1140 | RT-1(CLOSED 매핑). 실패 코드 단언은 `:339-340, :371-372, :454, :534, :549-550, :734-735` | +| `tests/unit/indexeddb-maintenance.test.ts` | 842 | MT-1(blocked 즉시 실패). `:289-290`이 `BLOCKED/retryable:true/RELOAD_OTHER_CONTEXTS`를 단언. MT-2(upgrade 거절)도 여기 | +| `tests/unit/indexeddb-opfs-journal.test.ts` | 431 | **OP-1, OP-2.** 요청 오류가 새 코드로 보고되는 건 이 테스트가 잡아야 한다 | +| `tests/unit/resumable-upload-checkpoint.test.ts` | 265 | **CP-1, CP-3, CP-4, CP-5.** `:142` CONFLICT/RECONCILE, `:190` UNAVAILABLE/RESUME, `:194` blocked PENDING, `:238` false-abort 금지 | +| `tests/unit/runtime-adapters.test.ts` | — | `storage/indexeddb` import. 공개 형태가 안 바뀌면 영향 없음 | +| `tests/helpers/fake-push-control-repository.ts`, `tests/unit/web-push-store-port-compatibility.test.ts` | — | RT의 공개 포트 형태에만 의존. §0대로 IDB 코드 없음. 공개 형태 불변이면 영향 없음 | +| `tests/unit/abortable-operation.test.ts`, `tests/unit/system-clock.test.ts` | — | 기존 커널 테스트. `snapshotAbortTimers` 재사용은 계약을 안 바꾸므로 영향 없음 | +| `tests/browser-capabilities/indexeddb-runtime.spec.ts` | 1033 | 실브라우저 Playwright. `deleteDatabase`는 정리용(`:443, :832, :923, :1019`)이라 이번 변경과 무관. 다만 RT의 blocked/versionchange 실동작을 보므로 회귀 감지망 | +| `tests/browser-capabilities/opfs-runtime.spec.ts` | 221 | 동 (`:137`) | +| `tests/browser-capabilities/resumable-upload.spec.ts` | 526 | CP 실동작 | +| `tests/fixtures/optional-recipes/forbidden/runtime-composition/src/bootstrap/bad-runtime.ts` | — | 픽스처. 경로 변경이 없으므로 영향 없음 | + +**신규 테스트:** `tests/unit/indexeddb-connection.test.ts`, `tests/unit/indexeddb-transaction.test.ts`가 +관례상 필요하다(`abortable-operation.test.ts`, `system-clock.test.ts` 선례). `check:test-evidence` / +`check:risk-coverage`가 신규 소스 파일당 테스트를 **강제하는지는 미확인**. + +### 5.5 "커널이 커진다"는 반론과 답 + +**반론:** 오늘 `src/adapters/platform/`은 5파일 550 LOC이고 전부 진짜 원시 요소다 — 시계 24줄, abort 269줄, +용량 가드 21줄, 라이프사이클 186줄, UUID 50줄. 여기에 IndexedDB 전용 470 LOC를 넣으면 "platform"이라는 이름이 +거짓말이 된다. IndexedDB는 플랫폼 원시 요소가 아니라 **하나의 저장 기술**이다. + +**답 1 — 이 레포의 커널 기준은 "보편성"이 아니라 "네이티브 표면의 단일 소유자"다.** 같은 반론이 +`browser-lifecycle.ts`(186 LOC, `visibilitychange`/`online`/`pagehide` 전용)에도 그대로 적용되지만 그 파일은 +이미 커널이고, 자기 주석이 근거를 밝힌다(`browser-lifecycle.ts:4-7`: "No capability adds its own listener… +so the listener count stays constant"). dependency-cruiser 규칙 주석도 같은 말을 한다: +"Only the adapter kernel is shared — `src/adapters/platform` (clock, abort primitive, capacity guard)". + +**답 2 — 다른 위치는 전부 더 나쁘다.** +- `src/adapters/storage/indexeddb/`에 두면 `adapters-do-not-know-other-concrete-adapters`가 + `browser-transfer/`와 `storage/opfs/`의 import를 금지한다. **중복이 존재하는 이유가 바로 이것이다.** +- 새 최상위 `src/adapters/indexeddb-kernel/`은 dependency-cruiser 규칙 변경이 필요한데, 리뷰가 이미 그 비용을 + 이유로 기각했다. + +**답 3 (실제로 위험을 한정하는 답) — 커널은 IndexedDB "정책"을 하나도 갖지 않는다.** +커널이 소유하는 것: 이벤트 배선(request→콜백), 트랜잭션 상태기계, 커서 펌프, settle-once, blocked 래치. +커널이 소유하지 **않는** 것: DB 이름, 스키마, 마이그레이션, governance 바인딩, 코덱, 바이트 예산, 보존 정책, +멱등 영수증, 실패 코드 테이블, operation 라벨, 관측 이벤트 shape, durability 기본값, 예산 기준. +**나중에 이 목록 중 하나를 커널에 넣고 싶어지면, 그게 멈춰야 한다는 신호다.** 이 문장을 커널 파일 최상단 +주석에 넣는다. + +**답 4 — `abortable-operation.ts`의 실패를 반복하지 않는 구체적 차이.** +`abortable-operation.ts:11`의 `AbortTerminalReason`은 3멤버 닫힌 union이고 `AbortRace`(`:19-22`)의 +**반환 타입에 박혀 있다.** 소비자가 5종이 필요하면 커널을 수정하거나 포기하는 두 길뿐이고, http v3는 포기했다. +커널의 `IndexedDbFailureCause`는 **반환 타입에 안 나온다.** 반환 타입은 `Result`이고 `Failure`는 +호출처가 정한다. cause는 `translate` 함수의 **입력**일 뿐이라, 멤버를 추가하면 모든 `translate`에서 +**컴파일 에러**가 나고(전역 함수이므로) 조용한 동작 변화가 안 생긴다. 확장이 커널 수정 없이도 되고, +커널 수정이 필요할 때도 누락이 타입 검사로 잡힌다. + +**남는 솔직한 위험:** 그래도 4개 어댑터가 공통으로 의존하는 470 LOC가 생긴다. 여기 버그가 나면 폭발 반경이 +4배다. 이걸 없앨 방법은 없고, 대가로 얻는 건 §1.5의 4가지 다른 답이 1가지가 되는 것이다. 그 교환이 +받아들일 만한지가 이 리팩토링의 진짜 판단이다. + +--- + +## 6. 이행 순서 권고 + +1. 커널 2파일 + 단위 테스트 추가. 사본은 손대지 않는다. → 게이트 기준선 유지 확인 + (`check:architecture`, `check:types:app`, `lint`, `check:adapter-inventory`). + 이 단계에서 INVENTORY.md(+`01-network-and-state.md`)를 같이 갱신한다. +2. **CP 먼저** 이행. 가장 작고(712), 동작 변화 지점(CP-1~CP-6)이 가장 명확하며, 커널의 CP-4 요건 + (abort가 throw하면 caller-abort를 세우지 않는다)을 조기에 검증한다. +3. **MT.** 커널 사용자 중 연결 핸들을 안 쓰는 유일한 사본이라 `openIndexedDbDatabase` 단독 사용 경로를 검증한다. +4. **OP.** OP-1 때문에 `tests/helpers/memory-indexeddb.ts` 보강이 선행될 가능성이 높다. +5. **RT 마지막.** 가장 크고 다른 셋이 커널을 다 검증한 뒤에 옮긴다. diff --git a/src/adapters/auth/index.ts b/src/adapters/auth/index.ts new file mode 100644 index 0000000..cb8b996 --- /dev/null +++ b/src/adapters/auth/index.ts @@ -0,0 +1,9 @@ +export { + createAnonymousSessionAdapter, + createDemoSessionAdapter, + createExternalAuthSessionAdapter, + createUnavailableSessionAdapter, + DEMO_AUTHORIZATION_MARKER, + type DemoSessionAdapter, + type ExternalSessionOwner, +} from "./external-session-adapter.ts"; diff --git a/src/adapters/diagnostics/index.ts b/src/adapters/diagnostics/index.ts new file mode 100644 index 0000000..57f0af5 --- /dev/null +++ b/src/adapters/diagnostics/index.ts @@ -0,0 +1,7 @@ +export { + createDiagnosticsAdapter, + getLastBootEvidence, + MAX_DIAGNOSTIC_ENTRIES, + noOpDiagnostics, + recordBootFailure, +} from "./bounded-diagnostics.ts"; diff --git a/src/adapters/http/index.ts b/src/adapters/http/index.ts new file mode 100644 index 0000000..7959c4c --- /dev/null +++ b/src/adapters/http/index.ts @@ -0,0 +1,39 @@ +/** + * §7–§8. 권장 경로는 V3 계약 실행기(`createContractHttpExecutor`)다. 설치된 + * 계약과 타입 입력을 받아 상한·전체 데드라인·재시도 권한·효과 확실성 판정을 + * 런타임이 소유한다. + */ +export { + createContractHttpExecutor, + type AuthIntegrationFailureReason, + type AuthOperationContext, + type CancellationOwner, + type ContractHttpExecutor, + type ContractHttpExecutorDependencies, + type HttpContractViolation, + type HttpContractViolationKind, + type HttpEffectCertainty, + type HttpExecutionContext, + type HttpExecutionObservation, + type HttpExecutionOutcome, + type HttpTransportFailure, + type SafeResponseMetadata, +} from "./http-execution-v3.ts"; +/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */ +export type { CredentialPatchOutcome } from "./http-contract-bridge.ts"; +/** + * V2 legacy. operationId + `LegacyHttpInput`으로 호출하는 범용 클라이언트다. + * 새 코드는 위의 V3 실행기를 쓴다. 남아 있는 이유는 계약이 아직 없는 + * 오퍼레이션을 위한 이행 경로이기 때문이다. + */ +export { + createHttpClient, + type HttpClient, + type HttpClientDependencies, + type HttpFailure, + type HttpResult, + type LegacyHttpInput, + type Scheduler, +} from "./client.ts"; +/** V2 `HttpClient.execute`의 첫 인자 타입. */ +export type { OperationRequestInput } from "./request-builder.ts"; diff --git a/src/adapters/platform/index.ts b/src/adapters/platform/index.ts new file mode 100644 index 0000000..015735f --- /dev/null +++ b/src/adapters/platform/index.ts @@ -0,0 +1,31 @@ +/** + * 어댑터 커널의 공개 경계. + * + * `src/adapters/**` 안에서는 이 배럴을 쓰지 않는다. 커널 프리미티브는 파일 + * 경로로 직접 import한다 — `scripts/check-adapter-inventory.ts:63-83`이 + * `platform/abortable-operation.ts`로 해석되는 specifier를 네 소비자에게 + * 요구하고, `.dependency-cruiser.json`의 kernel carve-out도 폴더 단위다. + * 이 배럴은 bootstrap·features·tests 같은 그룹 바깥 소비자를 위한 문이다. + */ +export { + compensateLateHandle, + createAbortableOperation, + snapshotAbortTimers, + type AbortableOperation, + type AbortableOperationInput, + type AbortRace, + type AbortTerminalReason, + type AbortTimerSnapshot, +} from "./abortable-operation.ts"; +export { assertBoundedCapacity } from "./bounded-capacity.ts"; +export { + createBrowserLifecycleRuntime, + type BrowserLifecycleEvent, + type BrowserLifecycleRuntime, + type BrowserLifecycleSnapshot, +} from "./browser-lifecycle.ts"; +export { + createBrowserMutationIntentFactory, + type BrowserMutationIntentFactoryDependencies, +} from "./browser-mutation-intent-factory.ts"; +export { systemClock } from "./system-clock.ts"; diff --git a/src/adapters/query-cache/index.ts b/src/adapters/query-cache/index.ts new file mode 100644 index 0000000..57c1c6c --- /dev/null +++ b/src/adapters/query-cache/index.ts @@ -0,0 +1,21 @@ +export { + createConditionalValidatorStore, + type ConditionalValidatorBinding, + type ConditionalValidatorStore, +} from "./conditional-validator-store.ts"; +export { createCursorPaginationRuntime } from "./cursor-pagination-runtime.ts"; +export { + createServerStateScopeRuntime, + type ScopeResetParticipant, + type ServerStateScopeDependencies, +} from "./server-state-scope-runtime.ts"; +export { + createTanStackCacheCoordinator, + type TanStackCacheCoordinatorDependencies, +} from "./tanstack-cache-coordinator.ts"; +export { + createQueryCacheAdapter, + createQueryClient, + QUERY_CACHE_DEFAULTS, + type QueryCacheDependencies, +} from "./tanstack-query-cache.ts"; diff --git a/src/adapters/service-worker/index.ts b/src/adapters/service-worker/index.ts new file mode 100644 index 0000000..6a4774c --- /dev/null +++ b/src/adapters/service-worker/index.ts @@ -0,0 +1,31 @@ +/** + * 이 배럴은 그룹 바깥(bootstrap, tests)을 위한 문이다. 워커 realm 진입점 + * `service-worker-entry.ts`는 이 배럴을 쓰지 않고 파일을 직접 import한다. + * + * 그래서 `tsconfig.service-worker.json`의 `exclude`에 이 파일이 들어 있다. + * 그 설정은 `src/adapters/service-worker` 폴더를 통째로 WebWorker lib로 + * 컴파일하면서 페이지 realm 파일(`service-worker-page-controller.ts`, + * `service-worker-removal.ts`)만 빼는 구조다. 배럴이 제외되지 않으면 그 + * 페이지 realm 파일을 다시 끌어들여 `document`를 찾지 못한다. + * 타입 커버리지는 `tsconfig.app.json`이 이 배럴을 포함하므로 유지된다. + * + * `service-worker-entry.ts`도 여기서 참조하지 않는다. `tsconfig.app.json:18`이 + * 제외한 파일이고, export가 0개이므로 넣을 것도 없다. + */ +export { + createServiceWorkerRuntime, + type WorkerClientLike, + type WorkerRuntimeConfig, + type WorkerScopeLike, +} from "./service-worker-lifecycle.ts"; +export { + createServiceWorkerPageController, + type ActivationBlocker, + type PageControllerDependencies, +} from "./service-worker-page-controller.ts"; +export { + createNonceRegistry, + createServiceWorkerMessage, + parseServiceWorkerMessage, + type ParsedMessage, +} from "./service-worker-protocol.ts"; diff --git a/src/adapters/storage/index.ts b/src/adapters/storage/index.ts new file mode 100644 index 0000000..43e058d --- /dev/null +++ b/src/adapters/storage/index.ts @@ -0,0 +1,12 @@ +/** + * 최상위 storage 배럴은 Web Storage 어댑터만 내보낸다. + * + * `./indexeddb/index.ts`와 `./opfs/index.ts`를 여기서 재수출하지 말 것. + * `scripts/test-browser-file-storage-runtime-removal.ts:23-34`가 그 두 폴더만 + * 삭제한 뒤 잔존 import를 예외로 잡는다 — 재수출하면 제거 드릴이 죽는다. + * 두 서브배럴이 각자 제거 가능한 런타임의 경계다. + */ +export { + createBrowserStorageAdapter, + type BrowserStorageDependencies, +} from "./browser-storage-adapter.ts"; diff --git a/src/adapters/telemetry/index.ts b/src/adapters/telemetry/index.ts new file mode 100644 index 0000000..f6abed3 --- /dev/null +++ b/src/adapters/telemetry/index.ts @@ -0,0 +1,7 @@ +export { + createTelemetryAdapter, + MAX_TELEMETRY_QUEUE, + noOpTelemetry, + type TelemetryAdapter, + type TelemetryAdapterOptions, +} from "./best-effort-telemetry.ts";