Files
clean-architecture-frontend…/docs/superpowers/plans/2026-09-16-indexeddb-kernel-promotion.md
T
DongHyeonkaandClaude Opus 5 c10a709f2c docs: record how the IndexedDB kernel promotion actually executed
사양 추정이 전부 빗나갔다. 4벌 감소는 -693이 아니라 -322이고 커널은 470이
아니라 1132줄이라, 소스 순증감이 -223이 아니라 +810이다. 원인은 사양이
translate/실패 헬퍼 비용을 안 셌고 이행된 파일의 주석이 크게 늘었기
때문이다. 작업의 근거는 처음부터 줄 수가 아니었지만, 줄 수가 준다는 기대가
틀렸다는 것은 기록해 둔다.

사양이 틀린 것으로 판명된 항목 3건과, 보존하지 못한 동작 1건(RT-2)을
복원하지 않기로 한 근거, 테스트로 덮지 못한 경로 1건을 남긴다.

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

465 lines
29 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 커널 승격 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 어댑터 4곳이 각자 구현한 IndexedDB 연결·트랜잭션 메커니즘을 `src/adapters/platform/`의 커널 2파일로 모으고, 4벌이 서로 다른 답을 내던 지점을 하나로 만든다.
**Architecture:** 커널은 **메커니즘만** 갖는다 — open 요청을 Promise로 바꾸기, blocked 데드라인, upgrade/error/success 라우팅, 늦게 도착한 연결 닫기, 트랜잭션 상태기계, 커서 펌프. 데이터베이스 이름·스키마·마이그레이션·governance·**실패 분류(taxonomy)**는 각 서브시스템에 남는다. 실패 매핑은 `translate` 콜백으로 주입하므로 `mapIndexedDbException``mapBrowserDataException`이 서로 다른 답을 내는 현 상태가 보존된다.
**Tech Stack:** TypeScript, IndexedDB, Vitest, `tests/helpers/memory-indexeddb.ts`(가짜 IDB)
**Spec:** [`docs/superpowers/specs/2026-09-16-indexeddb-kernel-promotion-design.md`](../specs/2026-09-16-indexeddb-kernel-promotion-design.md)
## Global Constraints
- **`src/adapters/platform/`은 이미 dependency-cruiser의 kernel carve-out이다.** 커널에 파일을 추가하는 데 규칙 변경이 필요 없다(spec §5.3).
- **`IDBFactory`는 필수 주입 파라미터다.** `globalThis.indexedDB`를 읽으면 안 된다 — `eslint.config.ts:40-63`이 모든 브라우저 루트에서 그 속성을 막고, 예외는 `platform/browser-lifecycle.ts` **한 파일**에만 부여돼 있다. 주입받으면 `eslint.config.ts`를 건드릴 필요가 없다(spec §5.1).
- **소스 파일을 추가하면 `docs/reviews/adapters/INVENTORY.md`에 행을 추가하고 하단 합계를 고친다.** `check:adapter-inventory``git ls-files src/adapters`와 집합을 정확히 대조한다.
- **`check:adapter-inventory`에 abort 래칫이 있다.** `addEventListener("abort")`를 쓰는 어댑터 파일이 24개를 넘으면 실패한다. `platform/`은 세지 않는다.
- **실패 매핑 4벌을 통일하지 마라.** `mapIndexedDbException`(RT/MT/OP)과 `mapBrowserDataException`(CP)은 같은 에러에 다른 답을 낸다 — `ConstraintError` recovery가 NONE vs REOPEN, `QuotaExceeded` retryable이 false vs true, `NotFound`가 MIGRATION_FAILED vs NOT_FOUND. 통일은 별건이고 이 계획의 범위가 아니다.
- **`deleteDatabase`를 나머지 3벌에 추가하지 마라.** CP에만 있는 것이 의도다(spec §2.3).
- **이 환경의 알려진 제약:** `tests/unit/ci-artifact-contract.test.ts` 16건이 `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`로 실패한다. `develop` `5434760` 기준선에서도 동일하다. **판정 기준은 그 파일 외의 실패가 0인지**다.
- 커밋 메시지 끝에 붙일 것: `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
## 왜 이 작업을 하는가 — LOC로 정당화하지 않는다
> **2026-09-16 갱신 — 사양의 추정이 빗나갔다.** 커널을 실제로 구현하니
> 2파일 **1,132줄**(코드 751줄)이다. 사양 추정 470줄의 2.4배다. 사양 §3.1이
> "470 LOC라 2분할한다"고 쓴 논거도 사실이 아니었다(2분할 자체는 테스트 셋업이
> 갈린다는 별도 근거로 유지). 그래서 아래 표의 순증감이 뒤집힌다.
| | 사양 추정 | 실제 |
|---|---:|---:|
| 4벌에서 삭제 | ~974 | (미측정, 이행 후 확정) |
| 4벌에 추가 (커널 호출부·콜백) | ~281 | (미측정) |
| 4벌 순감 | 693 | (미측정) |
| 커널 신규 2파일 | +470 | **+1,132** |
| 신규 커널 테스트 2파일 | (미기재) | **+1,602** |
| **레포 순증감 (소스만)** | **223** | **약 +439** |
**이 리팩토링은 줄 수를 줄이지 않는다. 늘린다.** 소스 약 +439줄, 테스트까지 하면
약 +2,041줄이다. 사양은 "절감이 작다"고 썼지만 실제로는 절감이 아니라 증가다.
**그래서 이 작업의 근거는 오로지 하나다: 트랜잭션 상태기계가 4개에서 1개가 되는 것.**
줄 수로 정당화하려는 시도는 이제 불가능하다. 근거가 성립하는 이유는 오늘 그 4개가
이미 서로 다른 답을 내고 있기 때문이다:
| 상황 | RT | MT | OP | CP |
|---|---|---|---|---|
| 트랜잭션 안 개별 요청 실패 | 본다 | 기록만 | **안 본다** | 즉시 abort |
| 값 없이 완료 | UNAVAILABLE | UNAVAILABLE | UNAVAILABLE | **CORRUPT_DATA** |
| `deleteDatabase` | 없음 | 없음 | 없음 | **있음** |
리뷰가 "이미 갈라졌다"고 판정한 근거가 이 표다.
**그리고 이행을 멈추면 최악이다.** 커널만 넣고 사본을 안 옮기면 +1,132줄의
쓰이지 않는 코드가 남는다. 이 레포가 이미 `abortable-operation.ts`로 겪고 있는
병(커널은 있는데 24개 파일이 안 씀)을 하나 더 만드는 것이다. 되돌리려면 지금이
가장 싸다 — 커널 커밋 하나를 revert하면 끝이고 사본은 아직 안 건드렸다.
---
### Task 1: 커널 2파일 + 단위 테스트
사본은 **손대지 않는다.** 코드 추가만 하므로 런타임 동작이 바뀌지 않는다.
**Files:**
- Create: `src/adapters/platform/indexeddb-connection.ts` (~240 LOC)
- Create: `src/adapters/platform/indexeddb-transaction.ts` (~230 LOC)
- Create: `tests/unit/indexeddb-connection.test.ts`
- Create: `tests/unit/indexeddb-transaction.test.ts`
- Modify: `docs/reviews/adapters/INVENTORY.md` (행 2개 + 합계 `128/128``130/130`)
**Interfaces:**
- Produces: spec §3.2·§3.3의 전체 export 시그니처. Task 2~5가 이것만 쓴다.
- Consumes: 기존 커널 `snapshotAbortTimers`(`platform/abortable-operation.ts`), `contracts/result.ts``Result`
- [x] **Step 1: 기존 커널 관례를 읽는다**
`src/adapters/platform/`의 5파일을 읽고 주석 스타일(왜 이 규칙이 있는지를 근거와 함께 적는 방식), 에러 처리, 의존성 주입 방식을 파악한다. 새 파일은 그 관례를 따른다.
- [x] **Step 2: 가짜 IDB의 오류 배선을 확인한다**
`tests/helpers/memory-indexeddb.ts`를 읽는다. 커널은 요청 레벨 `onerror`를 새로 보게 되므로 가짜가 `request.error`를 채우는지가 전제다.
확인됨(2026-09-16): 채운다. `:167-170``request.error = asException(error)``queueMicrotask``onerror` 발화, `:309-311`이 같은 일을 **동기로** 한다. **두 경로의 타이밍이 다르므로** 테스트에서 주의한다.
- [x] **Step 3: 실패하는 테스트를 먼저 쓴다**
최소한 아래를 고정한다. 각각 먼저 실패하는 것을 확인한 뒤 구현한다.
연결(`tests/unit/indexeddb-connection.test.ts`):
- open 성공 / `onerror` / native throw
- `onblocked` — deadline 미설정 시 `BLOCKED`, deadline 경과 시 `BLOCKED_DEADLINE`
- upgrade `APPLIED`
- upgrade `REJECTED`**versionchange 트랜잭션이 abort되어 스키마가 커밋되지 않는 것까지** 확인
- upgrade가 throw → `REJECTED` + `detail`에 thrown value
- `newVersion`이 null → `upgrade` 실행 **전에** `UPGRADE_REJECTED`
- admission `ADMIT` / `REJECT` / `FAIL` — 거부 시 연결이 **닫히는지**
- 호출자가 포기한 뒤 늦게 도착한 연결이 닫히는지
- `CALLER_ABORT`
- `translate`가 각 cause에 대해 호출되는지
트랜잭션(`tests/unit/indexeddb-transaction.test.ts`):
- 커밋 / abort
- `succeed()` 없이 완료 → `NO_VALUE_PRODUCED`
- 첫 결과가 이긴다(`succeed` 이후 `fail` 무시)
- 커서 순회와 `SUSPEND` 스텝(중첩 요청 체인)
- 예산 콜백이 "삭제 행 기준"과 "스캔 행 기준" 양쪽을 표현할 수 있는지
- **CP-4 필수 요건:** `abort()`가 throw하면 caller-abort 표시를 세우지 않고 transaction 이벤트가 결과를 정한다
- [x] **Step 4: 구현한다 — 좁은 커널 함정을 피한다**
기존 커널 `abortable-operation.ts:11``AbortTerminalReason` 3멤버를 **반환 타입**에 박아서 5종이 필요한 `http-execution-v3`가 아예 못 썼다. 같은 실수를 반복하면 이 작업은 실패다.
구현 후 아래를 확인한다:
- `IndexedDbFailureCause`가 어떤 공개 **반환 타입**에도 나타나지 않는가 (`translate`의 입력으로만 쓰이는가)
- spec §3.4의 4개 사본 예시(RT/MT/OP/CP)가 **전부** 수용되는가
하나라도 수용되지 않으면 **구현을 멈추고 보고한다.** spec에 억지로 맞추지 않는다.
- [x] **Step 5: INVENTORY 갱신**
`docs/reviews/adapters/INVENTORY.md`에 두 행을 알파벳 위치에 넣고 번호를 다시 매긴다. 링크는 같은 그룹(`platform/`) 기존 행과 동일하게 `[Network/state](./01-network-and-state.md)`. 하단 합계를 `130/130`으로.
- [x] **Step 6: 게이트**
```bash
corepack pnpm check:types:app && corepack pnpm check:types:test \
&& corepack pnpm lint && corepack pnpm check:architecture \
&& corepack pnpm check:adapter-inventory
corepack pnpm test:unit
```
Expected: 앞 묶음 전부 PASS. `test:unit``ci-artifact-contract.test.ts` 외 실패 0.
- [x] **Step 7: 커밋**
```bash
git add src/adapters/platform tests/unit/indexeddb-*.test.ts docs/reviews/adapters/INVENTORY.md
git commit -m "$(cat <<'EOF'
feat: add the shared IndexedDB connection and transaction kernel
네 어댑터가 각자 구현한 open/blocked/upgrade/트랜잭션 메커니즘을 커널로
모은다. 사본은 아직 이행하지 않았으므로 런타임 동작은 그대로다.
실패 분류는 커널에 넣지 않고 translate 콜백으로 주입한다. RT/MT/OP의
mapIndexedDbException과 CP의 mapBrowserDataException이 같은 에러에 다른
답을 내며, 그 차이를 통일하는 것은 별건이기 때문이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: CP 이행 — `indexeddb-checkpoint-store.ts` (712 → 약 570)
**CP를 먼저 하는 이유:** 가장 작고, 동작 변화 지점이 가장 명확하며, 커널의 CP-4 요건(abort가 throw하면 caller-abort를 세우지 않는다)을 조기에 검증한다.
**Files:**
- Modify: `src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts`
- Modify: `tests/unit/resumable-upload-checkpoint.test.ts` (동작 변화 지점 고정)
**Interfaces:**
- Consumes: Task 1의 `openIndexedDbDatabase`, `runIndexedDbTransaction`, `deleteIndexedDbDatabase`
- Produces: 없음 (공개 포트 형태 불변)
- [x] **Step 1: 동작 변화 지점을 테스트로 먼저 고정한다**
구현 전에 아래 5개가 현재 동작대로 통과하는지 확인한다. 이행 후에도 같아야 한다.
| # | 지점 | 현재 동작 | 깨지면 |
|---|---|---|---|
| CP-1 | 값 없는 완료 | `CORRUPT_DATA/RECONCILE` (L541-547) | 번역기가 `NO_VALUE_PRODUCED → CORRUPT_DATA`를 명시 매핑 안 하면 UNAVAILABLE로 바뀜 |
| CP-2 | `durability` | 옵션 bag 없음 (L512) | 커널 기본이나 `"strict"`를 넣으면 체크포인트 쓰기가 조용히 느려짐 (성능 회귀) |
| CP-3 | `nativeFailure` | 기록 후 **즉시 abort** (L577-588) | `requestFailed`(abort 안 함)로 바꾸면 요청 실패 후에도 뒤 요청이 커밋됨. `compareAndSwap``get→put` 체인(L321-343)에서 특히 위험 |
| CP-4 | `abort()`가 throw | `failure`**되돌린다** (L528, L536) | 커밋된 체크포인트를 ABORTED로 보고 |
| CP-5 | `deletePartition` blocked 타이머 | 네이티브 `setTimeout` (L450) | 주입형 `timers`로 바뀜. `tests/unit/resumable-upload-checkpoint.test.ts:194`가 이 경로를 봄 |
기존 단언 위치: `:142` CONFLICT/RECONCILE, `:190` UNAVAILABLE/RESUME, `:194` blocked PENDING, `:238` false-abort 금지.
- [x] **Step 2: 삭제 대상 블록을 커널 호출로 바꾼다**
| 블록 | 줄 | 대체 |
|---|---|---|
| `TransactionContext` 타입 | L491-495 | 커널 타입 |
| `openAndBind`의 open 요청 Promise 배선 | L163-175, L198-217 | `openIndexedDbDatabase` (upgrade 본문 L176-197은 `upgrade` 콜백으로, `bindScope` 호출은 `admit`으로) |
| `runCheckpointTransaction` | L497-596 | `runIndexedDbTransaction` |
| `bindScope`의 트랜잭션 배선 | L602-623 | `runIndexedDbTransaction` (검증 로직 L624-652는 `queue`로 그대로) |
| `deletePartition`의 blocked/settle 배선 | L431-462, L472-483 | `deleteIndexedDbDatabase` |
**남길 것:** `PENDING_DELETIONS` 레지스트리(L31-41) + 생성 시 검사(L116-122), `uploadCheckpointDatabaseName`(L73-83), `sameScopeBinding`(L656-672), `snapshotScope`(L674-691), `snapshotCheckpoint`(L693-711).
- [x] **Step 3: 게이트 + 표적 테스트**
```bash
corepack pnpm check:types:app && corepack pnpm lint && corepack pnpm check:architecture
npx vitest run tests/unit/resumable-upload-checkpoint.test.ts --reporter=default
```
Expected: 전부 PASS. CP-1~CP-5가 이행 전과 같은 답을 내야 한다.
- [x] **Step 4: 커밋**
```bash
git add src/adapters/browser-transfer tests/unit/resumable-upload-checkpoint.test.ts
git commit -m "refactor: move the upload checkpoint store onto the IndexedDB kernel
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: MT 이행 — `indexeddb-maintenance.ts` (1558 → 약 1370)
**MT를 두 번째로 하는 이유:** 커널 사용자 중 연결 핸들을 안 쓰는 유일한 사본이라 `openIndexedDbDatabase` 단독 사용 경로를 검증한다.
**Files:**
- Modify: `src/adapters/storage/indexeddb/indexeddb-maintenance.ts`
- Modify: `tests/unit/indexeddb-maintenance.test.ts`
**Interfaces:**
- Consumes: Task 1의 `openIndexedDbDatabase`, `openIndexedDbTransaction`, `runIndexedDbTransaction`, `walkIndexedDbCursor`
- [x] **Step 1: 두 개의 함정을 먼저 이해한다**
**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"})`를 넣으면 **잘못된 스키마로 열린다.**
- [x] **Step 2: 삭제 대상 블록을 커널 호출로 바꾼다**
| 블록 | 줄 | 대체 |
|---|---|---|
| `TransactionContext` 타입 | L97-101 | 커널 타입 |
| `openExactVersion`의 배선분 | L434-508, L551-585 | `openIndexedDbDatabase` (L509-550을 `admit`으로 이식) |
| `createTransaction` | L587-604 | `openIndexedDbTransaction` |
| `runTransaction` | L606-705 | `runIndexedDbTransaction` |
| 커서 2곳의 deadline/maxRows/abort 보일러플레이트 | L799-833, L1451-1479 | `walkIndexedDbCursor` |
**MT-3.** `database.onversionchange = () => database.close()`(L543)를 `admit` 안으로 옮긴다. `admit`은 성공 경로에서만 실행되므로 등록 시점이 오늘과 같다.
**남길 것:** 체크포인트 상태기계(`readCheckpoint` L707-756, `commitPrepared` L969-1251), `prepareRecords`(L863-967), `clock`/`epochClock`(L401-421), `countBucket`(L239-245), 저장 술어(L118-223).
- [x] **Step 3: 게이트 + 표적 테스트**
```bash
corepack pnpm check:types:app && corepack pnpm lint && corepack pnpm check:architecture
npx vitest run tests/unit/indexeddb-maintenance.test.ts --reporter=default
```
Expected: 전부 PASS. 특히 `:289-290`**즉시** BLOCKED를 받아야 한다(타임아웃이 아니라).
- [x] **Step 4: 커밋**
```bash
git add src/adapters/storage tests/unit/indexeddb-maintenance.test.ts
git commit -m "refactor: move IndexedDB maintenance onto the kernel
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: OP 이행 — `indexeddb-opfs-journal.ts` (1817 → 약 1690)
**이 계획에서 동작 변화가 가장 큰 태스크다.**
**Files:**
- Modify: `src/adapters/storage/opfs/indexeddb-opfs-journal.ts`
- Modify: `tests/unit/indexeddb-opfs-journal.test.ts`
- 가능성: `tests/helpers/memory-indexeddb.ts` 보강
**Interfaces:**
- Consumes: Task 1의 `openIndexedDbDatabase`, `createIndexedDbConnection`, `runIndexedDbTransaction`, `openIndexedDbTransaction`, `walkIndexedDbCursor`
- [x] **Step 1: 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`가 무엇이냐에 따라 달라진다.
이행 **전에** 중복 put 테스트를 `tests/unit/indexeddb-opfs-journal.test.ts`에 추가해 현재 답을 기록하고, 이행 후 달라진 답을 의도된 변경으로 승인한다. 가짜 IDB가 이 경로를 표현하지 못하면 `tests/helpers/memory-indexeddb.ts`를 먼저 보강한다.
- [x] **Step 2: OP-2를 확인한다**
오늘 OP의 `runTransaction``succeed` 이후 `fail`이 와도 `explicitFailure`가 이기지만(L1133-1141, `hasValue`는 true 유지) `oncomplete`는 값을 반환한다(L1143-1153). 커널의 "첫 결과가 이긴다" 규칙을 따르면 **`succeed` 후의 `fail`이 무시된다.**
현재 OP 코드에 그 순서가 실제로 발생하는 경로가 있는지 **확인하라**(spec은 미확인으로 남겼다). 없으면 변화 없음으로 기록하고 넘어간다.
- [x] **Step 3: 삭제 대상 블록을 커널 호출로 바꾼다**
| 블록 | 줄 | 대체 |
|---|---|---|
| `TransactionContext` 타입 | L48-51 | 커널 타입 |
| scheduler 기본값 인라인 | L144-153 | `snapshotAbortTimers` |
| `openDatabase`의 배선분 | L961-996, L1031-1072 | `openIndexedDbDatabase` + `createIndexedDbConnection` (upgrade 본문 L997-1029는 `upgrade` 콜백으로 그대로 이동) |
| `runTransaction` | L1098-1174 | `runIndexedDbTransaction` |
| `strictReadwriteTransaction` | L1176-1190 | `openIndexedDbTransaction(…, "strict")` |
| 커서 2곳의 limit 루프 | L604-633, L677-712 | `walkIndexedDbCursor` |
**OP-4.** `signal`**넣지 않는다.** 오늘 없는 취소를 새로 만들지 않는다.
- [x] **Step 4: 게이트 + 표적 테스트**
```bash
corepack pnpm check:types:app && corepack pnpm lint && corepack pnpm check:architecture
npx vitest run tests/unit/indexeddb-opfs-journal.test.ts tests/unit/opfs-byte-store.test.ts --reporter=default
```
Expected: PASS. OP-1로 인한 답 변화는 Step 1에서 승인한 것만 있어야 한다.
- [x] **Step 5: 커밋**
```bash
git add src/adapters/storage tests
git commit -m "refactor: move the OPFS journal onto the IndexedDB kernel
요청 레벨 오류를 처음으로 보게 된다. unique 인덱스 위반이 트랜잭션 abort
사유가 아니라 요청 자신의 ConstraintError로 보고된다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: RT 이행 — `indexeddb-runtime.ts` (2902 → 약 2665)
**가장 크고 마지막이다.** 다른 셋이 커널을 전부 검증한 뒤에 옮긴다.
**Files:**
- Modify: `src/adapters/storage/indexeddb/indexeddb-runtime.ts`
- Modify: `tests/unit/indexeddb-runtime.test.ts`
- [x] **Step 1: RT-1을 번역기에 명시한다**
`close()`가 진행 중 open을 끝내는 원인이 `unavailable(operation)`(L743)에서 `translate({kind:"CLOSED"})`로 바뀐다. 번역기가 **`CLOSED → unavailable`을 명시 매핑**해야 오늘 동작이 유지된다. 빠뜨리면 `close()` 중 open이 ABORTED로 보고된다.
기존 실패 코드 단언 위치: `tests/unit/indexeddb-runtime.test.ts:339-340, :371-372, :454, :534, :549-550, :734-735`.
- [x] **Step 2: 삭제 대상 블록을 커널 호출로 바꾼다**
| 블록 | 줄 | 대체 |
|---|---|---|
| `defaultScheduler` | L108-117 | `snapshotAbortTimers(scheduler)` |
| `TransactionContext` 타입 | L85-89 | `IndexedDbTransactionContext` |
| `waitForOpeningAttempt` | L659-682 | `connection.acquire(signal)` |
| `startOpeningAttempt`의 배선분 | L684-745, L776-843, L872-921 중 배선분 | `openIndexedDbDatabase` (upgrade/admit 콜백 본문은 그대로) |
| `createTransaction` | L957-974 | `openIndexedDbTransaction` |
| `runTransaction` | L976-1074 | `runIndexedDbTransaction` |
| 커서 5곳의 보일러플레이트 | L1374-1382, L1454-1471, L2364-2389, L2531-2548, L2573-2590, L2644-2661, L2718-2735 | `walkIndexedDbCursor` + `budget.admit` |
**RT-2.** `settleNativeRequest`/`activeOpeningGeneration`(L595, L691-697)이 사라진다. 커널의 settle-once와 단일 비행이 같은 역할을 한다. **의미는 같지만 경합 순서가 달라질 수 있다** — 동시 open 테스트를 주의해서 본다.
**RT-3.** `openingRequest` 필드(L593)는 오늘도 L712·L2878의 대입 외에 읽는 곳이 없다. 삭제한다.
**남길 것:** 코덱/영수증/보존/예산(bytes)/governance/migration 목록/상태 브로드캐스트/`monotonicClock`/`countBucket`/저장 레코드 술어/`purgePartitionRecords`의 스토어 순서 로직 — 전부 정책이다.
- [x] **Step 3: 게이트 + 전체 테스트**
```bash
corepack pnpm check:types && corepack pnpm lint && corepack pnpm check:architecture \
&& corepack pnpm check:adapter-inventory
corepack pnpm test:unit
corepack pnpm test:integration
```
Expected: `ci-artifact-contract.test.ts` 외 실패 0.
- [x] **Step 4: 커밋**
```bash
git add src/adapters/storage tests/unit/indexeddb-runtime.test.ts
git commit -m "refactor: move the IndexedDB runtime onto the kernel
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
## 완료 판정
```bash
corepack pnpm check:types
corepack pnpm lint
corepack pnpm check:architecture
corepack pnpm check:adapter-inventory
corepack pnpm check:bundle
corepack pnpm test:unit # ci-artifact-contract.test.ts 외 실패 0
corepack pnpm test:integration
corepack pnpm test:component
corepack pnpm test:browser-file-storage-removal # error TS 0건
corepack pnpm test:realtime-removal # error TS 0건
```
그리고 아래가 **0**이어야 한다 — 커널 밖에 남은 open 요청 배선:
```bash
grep -rn "createObjectStore\|onupgradeneeded" src/adapters --include='*.ts' \
| grep -v "src/adapters/platform/" | grep -v "upgrade" | wc -l
```
**실브라우저 확인이 필요하다.** 이 계획은 가짜 IDB 위에서만 검증된다. `tests/browser-capabilities/indexeddb-runtime.spec.ts`(1033 LOC), `opfs-runtime.spec.ts`(221), `resumable-upload.spec.ts`(526)를 실브라우저에서 돌려야 blocked/versionchange 실동작 회귀를 잡는다.
## 이 계획이 하지 않는 것
- **실패 매핑 4벌 통일** — `mapIndexedDbException``mapBrowserDataException`이 같은 에러에 다른 답을 내는 것은 별건이다. 이 계획은 그 차이를 `translate` 주입으로 **보존**한다.
- **`deleteDatabase`를 3벌에 추가** — CP에만 있는 것이 의도다(spec §2.3).
- 대형 파일 분할 — 커널 이행으로 RT가 2902 → 2665가 되지만 여전히 크다. 분할은 별도 계획이다.
---
## 실행 기록 (2026-09-16 — 5개 태스크 전부 완료)
커밋 5개: `cb62bfb`(커널) · `bb6080b`(CP) · `3366a81`(MT) · `217c1dd`(OP) · `a91e78f`(RT).
### 실측 LOC — 사양 추정은 전부 빗나갔다
| 사본 | 사양 추정 | 실제(전체 줄) | 실제(코드 줄) |
|---|---:|---:|---:|
| CP `indexeddb-checkpoint-store.ts` | 142 | 712 → 612 = **100** | |
| MT `indexeddb-maintenance.ts` | 188 | 1558 → 1430 = **128** | |
| OP `indexeddb-opfs-journal.ts` | 127 | 1817 → 1881 = **+64** | 1744 → 1724 = 20 |
| RT `indexeddb-runtime.ts` | 235 | 2902 → 2744 = **158** | 2807 → 2563 = 244 |
| 4벌 합 | **693** | **322** | |
| 커널 2파일 | +470 | **+1,132** | +751 |
| **소스 순증감** | **223** | **+810** | |
| 커널 테스트 2파일 | (미기재) | +1,602 | |
**공통 원인:** 사양이 `translate`/실패 헬퍼 어댑터 함수 비용을 세지 않았다. CP는 4개(~37줄), MT는 6개가 필요했다. 그리고 이행된 파일들의 주석이 크게 늘었다(OP 6→90줄, RT 12→111줄) — 이 레포 관례상 부풀림이 아니라 개선이지만, 줄 수 예측은 무너뜨린다.
**결론은 바뀌지 않는다.** 이 작업의 근거는 처음부터 줄 수가 아니라 상태기계 통합이었고, 그건 달성됐다. 다만 **줄 수가 준다는 기대는 완전히 틀렸다**는 것을 기록해 둔다.
### 부수 성과
- **abort 래칫이 24 → 21로 세 칸 조여졌다.** CP·MT·RT가 각각 자기 abort 리스너를 지웠다. 래칫이 설계대로 작동했다.
- **가짜 IndexedDB가 unique 인덱스를 전혀 강제하지 않는 것을 찾아 고쳤다**(`tests/helpers/memory-indexeddb.ts`). 보강 전에는 중복 `begin``ok:true`로 성공했다 — 브라우저가 거부할 상태를 테스트가 조용히 허용하고 있었다. 이 발견이 커널 이행 자체보다 가치가 클 수 있다.
- 사양이 빠뜨린 함정 하나를 CP에서 막았다: `bindScope``succeed()`에 해당하는 것이 없어 그대로 옮기면 정상 바인딩이 `CORRUPT_DATA`로 보고된다. 이후 MT·OP·RT는 성공 출구를 전수 대조했다.
### 사양이 틀린 것으로 판명된 항목 3건
1. **OP-1** — 사양은 "이 리팩토링에서 가장 큰 동작 변화"로 지목하며 중복 put의 답이 달라진다고 봤다. **틀렸다.** 이행 전후 모두 `CONFLICT / retryable:false / recovery:NONE`이다. 요청 오류를 아무도 처리하지 않으면 스토어가 바로 그 에러로 abort해서 `transaction.error === request.error`이기 때문이다. 바뀐 것은 답이 아니라 출처다.
2. **RT-2** — 사양은 "의미 동일"이라고 썼다. **틀렸다.** 아래 참조.
3. **§3.1의 파일 분할 논거** — "470 LOC라 1파일은 폴더 관례를 깬다"고 썼으나 실제 구현은 1,132줄이다. 2분할 자체는 유지할 값이 있지만(테스트 셋업이 갈린다) 그 논거는 사실이 아니었다.
### 보존하지 못한 동작 1건 — RT-2 (의도적으로 남김)
blocked 데드라인 이후 재시도가 새 `factory.open()`을 띄운다. 이행 전에는 안 띄웠다. 커널에 "settle 이후에도 살아 있는 요청"을 알려줄 훅이 없기 때문이다.
**복원하지 않기로 했다.** 근거:
- 이행 전후 모두 10초 blocked 데드라인이 있고, 차이는 두 번째 네이티브 open을 띄우는지뿐이다.
- 늦게 도착한 연결은 커널이 `closeQuietly`로 닫으므로 **연결 누수도 데이터 위험도 없다.**
- 재시도가 10초 데드라인에 게이트되므로 쌓이는 속도가 제한적이다.
- 반면 지금 커널을 고치면 **이미 검증이 끝난 4개 사본을 전부 재검증**해야 한다. 이익 대비 위험이 맞지 않는다.
복원하려면 `openIndexedDbDatabase``onSettled`를 추가하면 된다(`deleteIndexedDbDatabase`에는 이미 있다). 다만 그러면 "blocked 데드라인 실패가 버려진 요청이 끝날 때까지 다음 acquire를 막는가"라는 설계 질문이 따라온다 — 막는다면 다른 탭이 영영 안 닫힐 때 재시도가 영구 차단되어 **지금보다 나쁘다.** 착수 전 그 답부터 정해야 한다.
### 테스트로 덮지 못한 경로 1건
RT의 `POLICY_REJECTED` upgrade 거절 경로. `queueIndexedDbUpgradeBinding``onRejected`가 비동기라 `.then` 후처리로 보존했으나, **가짜 IDB의 upgrade 트랜잭션이 동기라 단위 테스트로 검증할 수 없다.** 레포 전체에 이 경로를 덮는 테스트가 없다. **실브라우저 확인이 필요하다.**
### 검증 결과
**PASS:** `check:types` `lint` `check:architecture` `check:adapter-inventory`(21/21 래칫) `check:bundle`(181114/204800, 이행 전과 동일) `test:integration`(81) `test:component`(130)
**`test:unit`:** 실패 파일은 `tests/unit/ci-artifact-contract.test.ts` 하나뿐(16~18건, 실행마다 흔들림 — `bwrap: loopback: Failed RTM_NEWADDR`). `develop` `5434760` 기준선에서도 동일함을 stash 후 실행해 확인했다. **그 파일 밖 실패 0.**
**실브라우저 미검증:** `tests/browser-capabilities/indexeddb-runtime.spec.ts`(1033) · `opfs-runtime.spec.ts`(221) · `resumable-upload.spec.ts`(526). 전부 가짜 IDB 위에서만 검증됐다. blocked/versionchange 실동작과 위 upgrade 경로는 여기서만 잡힌다. **CI 또는 로컬 브라우저 실행이 남은 과제다.**