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

68 KiB
Raw Blame History

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 있음 signalrequest.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 있음 L904handleVersionChange L637-651 (상태 브로드캐스트 + 콜백) 있음 L543() => database.close() 한 줄 있음 L1055-1059 — 캐시 무효화(database=null; opening=null) 있음 L230-233 — 캐시 무효화
onclose (강제 종료) 있음 L905handleForcedClose 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-399RT와 형태 동일 다르게 구현 — 이벤트 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. deleteDatabaseopen같은 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

/**
 * 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>>;

핵심 구현 로직 (본문은 생략, 분기만):

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

/**
 * 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 타이머

// 연산마다 번역기 하나. 커널은 "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으로:

// 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 고정 + 연결 캐시 안 씀

// 핸들을 만들지 않는다. 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

// 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

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:11AbortTerminalReason을 반환 타입에 박아 넣어 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-290BLOCKED/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_INDEXunique:true(L1000-1004)이므로 중복 put은 요청 레벨 ConstraintErrorCONFLICT가 되고, 오늘은 transaction.error가 무엇이냐에 따라 달라진다. tests/unit/indexeddb-opfs-journal.test.ts가 잡아야 할 지점.
  • OP-2. 오늘 OP의 runTransactionsucceed 이후 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 안 함)로 잘못 바꾸면 요청이 실패했는데도 뒤 요청이 커밋될 수 있다. compareAndSwapget → 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-63restrictedBrowserDataPropertiesglobalThis/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-aliasno-restricted-properties 중 하나가 남아서 pnpm lint가 깨진다.

5.2 선행조건 P2 — docs/reviews/adapters/INVENTORY.md 갱신 필요

scripts/check-adapter-inventory.ts:23-31git 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.tsINVENTORY.md만 읽는다, :20).

5.3 선행조건 P3 — dependency-cruiser 변경 불필요

adapters-do-not-know-other-concrete-adapterspathNot^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-290BLOCKED/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-adaptersbrowser-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:11AbortTerminalReason은 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 마지막. 가장 크고 다른 셋이 커널을 다 검증한 뒤에 옮긴다.