사양 추정이 전부 빗나갔다. 4벌 감소는 -693이 아니라 -322이고 커널은 470이 아니라 1132줄이라, 소스 순증감이 -223이 아니라 +810이다. 원인은 사양이 translate/실패 헬퍼 비용을 안 셌고 이행된 파일의 주석이 크게 늘었기 때문이다. 작업의 근거는 처음부터 줄 수가 아니었지만, 줄 수가 준다는 기대가 틀렸다는 것은 기록해 둔다. 사양이 틀린 것으로 판명된 항목 3건과, 보존하지 못한 동작 1건(RT-2)을 복원하지 않기로 한 근거, 테스트로 덮지 못한 경로 1건을 남긴다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
465 lines
29 KiB
Markdown
465 lines
29 KiB
Markdown
# 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 또는 로컬 브라우저 실행이 남은 과제다.**
|