# 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 마지막.** 가장 크고 다른 셋이 커널을 다 검증한 뒤에 옮긴다.