Files
clean-architecture-frontend…/docs/superpowers/specs/2026-09-16-indexeddb-kernel-promotion-design.md
T
DongHyeonkaandClaude Opus 5 a42d96185f docs: record the barrel boundary and IndexedDB kernel designs with a plan
모듈별 구조 리뷰에서 나온 두 설계를 spec으로 남기고, 그중 배럴 경계
작업의 실행 계획을 쓴다. 배럴 규칙은 실제 import 그래프에 돌려 위반
15건이 치환 대상 15줄과 일치함을 확인했고, 재수출할 심볼 82개는 실제
export와 대조했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:50:02 +09:00

1155 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<Failure> = (
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<Failure> = 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<Failure>;
/**
* 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<IndexedDbAdmission>;
/** 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<Failure>(
input: IndexedDbOpenInput<Failure>,
): Promise<Result<IDBDatabase, Failure>>;
export type IndexedDbConnection<Failure> = 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<Result<IDBDatabase, Failure>>;
/** 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<Failure> = 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<Result<IDBDatabase, Failure>>;
translate: IndexedDbTranslate<Failure>;
/**
* 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<Failure>(
input: IndexedDbConnectionInput<Failure>,
): IndexedDbConnection<Failure>;
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<Failure> = Readonly<{
factory: IDBFactory;
databaseName: string;
translate: IndexedDbTranslate<Failure>;
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<Failure>(
input: IndexedDbDeleteInput<Failure>,
): Promise<Result<IndexedDbDeleteOutcome, Failure>>;
```
핵심 구현 로직 (본문은 생략, 분기만):
```ts
export function openIndexedDbDatabase<Failure>(
input: IndexedDbOpenInput<Failure>,
): Promise<Result<IDBDatabase, Failure>> {
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<IDBDatabase, Failure>) => {
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<Failure> = 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<Value, Failure> =
IndexedDbRequestSink<Failure> &
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<Value, Failure> = Readonly<{
database: IDBDatabase;
stores: readonly string[];
mode: "readonly" | "readwrite";
translate: IndexedDbTranslate<Failure>;
/** 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<Value, Failure>,
) => 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<Value, Failure>(
input: IndexedDbTransactionInput<Value, Failure>,
): Promise<Result<Value, Failure>>;
/**
* 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<Value, Failure>(
request: IDBRequest<Value>,
sink: IndexedDbRequestSink<Failure>,
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<Failure> = 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<IndexedDbBudgetVerdict, Failure>;
}>;
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<Failure> = Readonly<{
request: IDBRequest<IDBCursorWithValue | null>;
sink: IndexedDbRequestSink<Failure>;
translate: IndexedDbTranslate<Failure>;
budget?: IndexedDbBudget<Failure>;
/**
* 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<Failure>(
input: IndexedDbWalkInput<Failure>,
): void;
```
### 3.4 확장점이 4벌의 요구를 어떻게 다 받는지 — 사본별 예시
**(a) RT: `mapIndexedDbException` + 호출처별 operation + 상태 브로드캐스트 + blocked 타이머**
```ts
// 연산마다 번역기 하나. 커널은 "INDEXEDDB_OPEN"이라는 문자열을 모른다.
const translateFor = (operation: BrowserDataOperation): IndexedDbTranslate<BrowserDataFailure> =>
(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<BrowserDataFailure>({
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<BrowserDataFailure> = {
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<BrowserDataFailure>({
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<OpfsJournalPage, BrowserDataFailure>({
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<BrowserDataFailure> = (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<ResumableUploadCheckpoint | null, BrowserDataFailure>({
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<Value, Failure>`(`src/contracts/result.ts:13-15`)를 쓰면
`Result<T, BrowserDataFailure>`가 정의상 `BrowserDataResult<T>`와 **같은 타입**이다
(`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<Value>`(`:19-22`)의
**반환 타입에 박혀 있다.** 소비자가 5종이 필요하면 커널을 수정하거나 포기하는 두 길뿐이고, http v3는 포기했다.
커널의 `IndexedDbFailureCause`**반환 타입에 안 나온다.** 반환 타입은 `Result<Value, Failure>`이고 `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 마지막.** 가장 크고 다른 셋이 커널을 다 검증한 뒤에 옮긴다.