모듈별 구조 리뷰에서 나온 두 설계를 spec으로 남기고, 그중 배럴 경계 작업의 실행 계획을 쓴다. 배럴 규칙은 실제 import 그래프에 돌려 위반 15건이 치환 대상 15줄과 일치함을 확인했고, 재수출할 심볼 82개는 실제 export와 대조했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
68 KiB
IndexedDB 커널 승격 — 구체 설계
대상 브랜치:
develop(5434760) 이 문서는 설계만 담는다. 소스는 수정하지 않았고 빌드/테스트도 돌리지 않았다. 모든 주장에파일:줄번호근거를 달았다. 근거를 못 단 항목은 미확인이라고 표시했다.
0. 사본 개수 확정: 5벌이 아니라 4벌
src/adapters/web-push/push-association-fence-store.ts는 IndexedDB 사본이 아니다.
$ grep -n "indexedDB|IDBDatabase|IDBTransaction|IDBRequest|IDBObjectStore|IDBKeyRange|IDBFactory|deleteDatabase|onupgradeneeded|objectStore" \
src/adapters/web-push/push-association-fence-store.ts
→ 매치 없음
이 파일은 IndexedDB API를 한 줄도 쓰지 않는다. 대신 PushControlRepository라는 구조적 포트를 자기가 선언하고
(push-association-fence-store.ts:59-87) 합성 루트에서 주입받는다. 파일 자신의 주석이 그 의도를 명시한다:
push-association-fence-store.ts:49-58"It is declared here, structurally, rather than imported from the browser file/storage port so the two capabilities stay independently removable. The generic IndexedDB repository satisfies it as-is; the composition root is where the two are joined, and it owns connection, migration, transaction, timeout, codec and version-change policy."
즉 이 파일은 이번 리팩토링이 지향하는 모범 사례의 예시이지 중복 사본이 아니다. 커널 이행 대상에서 제외한다.
(다만 tests/helpers/fake-push-control-repository.ts, tests/unit/web-push-store-port-compatibility.test.ts는
runtime의 공개 형태에 구조적으로 의존하므로 §5의 위험 목록에는 남긴다.)
한편 실제 IndexedDB 코드를 가진 5번째 파일은 따로 있다: src/adapters/storage/indexeddb/indexeddb-governance.ts
(queueIndexedDbUpgradeBinding L160-215, verifyIndexedDbDatasetBinding L220-338). 이건 indexeddb/ 폴더 내부
헬퍼라 runtime과 maintenance가 이미 공유하고 있다. 중복이 아니므로 대조표에는 참고 열로만 넣는다.
1. 4벌 대조표
열 약어:
- RT =
src/adapters/storage/indexeddb/indexeddb-runtime.ts(2902 LOC) - MT =
src/adapters/storage/indexeddb/indexeddb-maintenance.ts(1558 LOC) - OP =
src/adapters/storage/opfs/indexeddb-opfs-journal.ts(1817 LOC) - CP =
src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts(712 LOC)
1.1 연결 수립
| 기능 | RT | MT | OP | CP |
|---|---|---|---|---|
factory.open() → Promise |
있음 L684-921 |
있음 L434-585 |
있음 L961-1072 |
있음 L156-239 |
factory 기본값을 전역에서 해석 |
있음 L565-569 |
있음 L359-363 |
있음 L130-134 |
있음 L95-97 |
IDBKeyRange 기본값을 전역에서 해석 |
있음 L570-574 |
있음 L364-368 |
있음 L135-139 |
없음 (범위 질의 없음) |
| 단일 비행(single-flight) 오픈 캐시 | 있음 L594 L942-951 |
없음 — 의도적. 배치마다 열고 finally에서 닫음 L1364-1366 L1549-1551 |
있음 L166 L1067-1071 |
있음 L129 L144-148 |
| 진행 중 open 취소 훅 | 있음 cancelPendingOpen L596,L741-744, close()에서 호출 L2876 |
있음 signal→request.transaction?.abort() L467-474 |
없음 | 없음 |
| open generation 토큰(늦게 온 요청 무시) | 있음 L595,L691-697 |
없음 | 없음 | 없음 |
| 늦게 도착한 connection 강제 close | 있음 L835-841,L896-902 |
있음 L505-508,L577-580 |
있음 L1050-1053 |
있음 L168-171,L219-224 |
1.2 blocked 처리
| 기능 | RT | MT | OP | CP |
|---|---|---|---|---|
onblocked 핸들러 |
있음 L776-799 |
있음 L485-492 |
있음 L1031-1041 |
있음 L198-207 |
| blocked 후 대기 타이머 | 있음, 주입형 scheduler L789-798, 기본 10_000ms L576 |
다르게 구현 — 타이머 없이 blocked 이벤트 즉시 실패 L485-492 |
있음, 주입형 scheduler L1032-1040, 기본 10_000ms L143 |
다르게 구현 — 네이티브 setTimeout 하드코딩(주입 불가) L199-206, 기본 5_000ms L23,L98-99 |
| blocked를 관측 이벤트로 발행 | 있음 L782-788 |
없음 (결과 매핑에서만 observeResult L391-392) |
없음 | 없음 |
| blocked 시 연결 상태 전이 | 있음 {kind:"BLOCKED"} L777-781 |
없음 | 없음 | 없음 |
1.3 버전 마이그레이션 / 스키마
| 기능 | RT | MT | OP | CP |
|---|---|---|---|---|
onupgradeneeded 훅 |
있음 L746-774 |
있음 L477-484 |
있음 L991-1030 |
있음 L176-197 |
| upgrade 내부 동작 | 선언적 migration 목록 적용 applyIndexedDbMigrations L754-760 + governance binding queue L761-769 |
upgrade 자체를 실패로 취급 — unexpectedUpgrade=true 후 abort L477-484, 결과 MIGRATION_FAILED L495-496 |
인라인 하드코딩된 createObjectStore 6개 + index 3개 L997-1029, oldVersion!==0이면 abort L993-996 |
인라인 contains 체크 후 createObjectStore 2개 L179-188 |
| upgrade 실패 플래그 → 결과 코드 | 있음 migrationFailed L714,L811-819 |
있음 unexpectedUpgrade L460,L495-496 |
없음 (abort가 onerror로 흐름) | 없음 (catch 후 finish L189-196) |
| open 후 스토어 존재 검증 | 있음 assertIndexedDbRuntimeStores L844-853 (구현은 indexeddb-migrations.ts:162-207) |
다르게 구현 — 인라인 objectStoreNames.contains 5개 + index 접근 L509-542 |
없음 | 없음 |
| governance/scope binding 검증 | 있음 verifyIndexedDbDatasetBinding L866-871 |
있음 동일 함수 L545-550 |
없음 (open 경로에서는 안 함; scope 바인딩은 트랜잭션 내부 로직 L1310-1347) |
다르게 구현 — 자체 bindScope 별도 트랜잭션 L598-654 |
| 마이그레이션 개수를 결과로 반환 | 있음 appliedMigrations L716,L910-916 |
해당 없음 | 해당 없음 | 해당 없음 |
1.4 versionchange / 연결 무효화
| 기능 | RT | MT | OP | CP |
|---|---|---|---|---|
onversionchange |
있음 L904 → handleVersionChange L637-651 (상태 브로드캐스트 + 콜백) |
있음 L543 — () => database.close() 한 줄 |
있음 L1055-1059 — 캐시 무효화(database=null; opening=null) |
있음 L230-233 — 캐시 무효화 |
onclose (강제 종료) |
있음 L905 → handleForcedClose L653-657 |
없음 | 있음 L1060-1063 |
있음 L234-236 |
| 연결 상태 구독 API | 있음 getStatus/subscribeStatus L2893-2899 |
없음 | 없음 | 없음 |
close()/dispose |
있음 L2873-2884 (pending open 취소 + DISPOSED 브로드캐스트) |
없음 (배치마다 자동 close) | 있음 L757-761 |
있음 L395-399 + deletePartition에서도 L411-413 |
1.5 트랜잭션 실행
| 기능 | RT | MT | OP | CP |
|---|---|---|---|---|
createTransaction + durability 폴백 |
있음 L957-974 |
RT와 문자 단위로 동일 L587-604 |
다르게 구현 — readwrite만 durability:"strict" 하드코딩, readonly는 옵션 없음 L1176-1190 |
다르게 구현 — durability 옵션 자체가 없음 L512 |
| durability를 의존성으로 주입 | 있음 dependencies.durability?.read/.write L962-965 |
있음 동일 L592-595 |
없음 (하드코딩) | 없음 |
runTransaction (트랜잭션→Promise) |
있음 L976-1074 |
있음 L606-705 — RT와 구조 동일, operation이 "INDEXEDDB_MIGRATE" 고정이고 database를 인자로 받음 |
있음 L1098-1174 — 모듈 최상위 자유함수 |
있음 runCheckpointTransaction L497-596 — 단일 스토어 CHECKPOINT_STORE 고정 L512,L591 |
TransactionContext 타입 |
L85-89 (succeed/fail/requestFailed) |
L97-101 (동일 3종) |
L48-51 (succeed/fail 2종 — requestFailed 없음) |
L491-495 (succeed/fail/nativeFailure) |
caller AbortSignal → transaction.abort |
있음 L1010-1017,L1041 |
있음 L640-650,L676 |
없음 — signal 인자 자체가 없음 | 있음 L527-539 |
oncomplete인데 값이 없을 때 |
UNAVAILABLE/retryable:true/REOPEN L1020 (unavailable L397-404) |
UNAVAILABLE/retryable:true/REOPEN L653 (unavailable L257-262) |
UNAVAILABLE/retryable:true/REOPEN L1144-1151 |
다르게 구현 — CORRUPT_DATA/RECONCILE L541-547 |
| 요청 오류 기록 후 abort 여부 | abort 안 함 — requestFailed는 기록만 L1055-1060 |
abort 안 함 L686-694 |
해당 없음 (요청 오류를 안 봄) | abort 함 — nativeFailure가 즉시 abort L577-588 |
queue 콜백이 throw할 때 |
있음 L1063-1072 |
있음 L697-703 |
있음 L1163-1172 |
있음 L590-594 |
1.6 요청 → 콜백 변환
| 기능 | RT | MT | OP | CP |
|---|---|---|---|---|
request.onsuccess 인라인 배선 |
있음, 수십 곳 (예 L1134,L1782,L1878,L1932) |
있음 (예 L721,L799,L1033,L1079) |
있음 (예 L187,L497,L604,L790) |
있음 (예 L265,L323,L372) |
request.onerror 인라인 배선 |
있음, 거의 모든 요청에 (예 L1133,L1780,L1876) |
있음 (예 L720,L798,L1031) |
없음 — 파일 전체에서 request .onerror는 open 요청 1개뿐 (L1042; L1160은 transaction.onerror) |
있음 (예 L264,L322,L341,L371,L387) |
| 요청→콜백 공통 헬퍼 | 없음 | 없음 | 없음 | 없음 |
1.7 커서 순회
| 기능 | RT | MT | OP | CP |
|---|---|---|---|---|
openCursor 순회 루프 |
있음 4곳: query L1364-1527, purgeEligibleRecords L2345-2472, purgePartitionRecords 내부 4개 루프 L2528-2566,L2570-2638,L2641-2713,L2715-2809 |
있음 2곳: scanBatch L795-858, pruneExpiredReceipts L1429-1542 |
있음 2곳: listIncomplete L600-633, listCommittedObjects L673-712 |
없음 |
| 행 수 예산(maxRows) | 있음 — 삭제 행 기준 deletedRows >= input.maxRows L2384,L2543,L2585,L2656,L2730 |
있음 — 스캔 행 기준 rows.length >= input.maxRows L823, 삭제 행 기준 L1471 |
다르게 구현 — limit만, 예산 개념 없음 L622,L699 |
해당 없음 |
| 시간 예산(deadline) | 있음 monotonicClock() L2260-2269, 비교 L2385,L2544 등 |
있음 clock() L401-410, 비교 L824,L1472 |
없음 | 없음 |
| 스캔 상한(방어적) | 있음 MAX_QUERY_SCANNED_ROWS=5_000 L105, 계산 L1349-1352, 비교 L1454 |
없음 | 없음 | 해당 없음 |
커서 재개 방식 3종 (continue/continue(key)/continuePrimaryKey) |
있음 L1430-1445 |
없음 (continue()만 L852) |
없음 (continue()만 L632,L711) |
해당 없음 |
| 커서 중간에 중첩 요청 체인 실행 | 있음 (모든 순회가 그렇게 함, 예 L1487-1526, L2406-2471) |
있음 (L1490-1541) |
없음 (push 후 즉시 continue()) |
해당 없음 |
순회 중 signal.aborted 체크 |
있음 L1375-1382,L2365-2372 |
있음 L800-807,L1452-1459 |
없음 | 해당 없음 |
1.8 실패 매핑
| 기능 | RT | MT | OP | CP |
|---|---|---|---|---|
| 예외→실패 매핑 함수 | mapIndexedDbException indexeddb-failure.ts:23-72 |
동일 함수 L15 |
동일 함수 L21 |
다르게 구현 — mapBrowserDataException browser-file-storage/result.ts:43-90 (L11에서 import) |
| 매핑 테이블이 실제로 다른 지점 | — | — | — | ConstraintError: IDB=CONFLICT/recovery:NONE(indexeddb-failure.ts:30-31) vs BD=CONFLICT/recovery:REOPEN(result.ts:58-60) · QuotaExceededError: IDB=retryable:false(:56-60) vs BD=retryable:true(:74-79) · NotFoundError: IDB=MIGRATION_FAILED(:41-45) vs BD=NOT_FOUND(:67-68) · BD는 DOMException이 아니면 전부 UNAVAILABLE(:47-52), IDB는 name만 보고 판단(:7-17) |
operation 라벨 |
호출처마다 다름: INDEXEDDB_OPEN/READ/WRITE/MIGRATE |
INDEXEDDB_MIGRATE 고정 L248,L252,L258 등 |
호출처마다 다름: INDEXEDDB_OPEN/READ/WRITE |
UPLOAD_RECONCILE 고정 |
| abort 단축 헬퍼 | abortedResult L19 |
abortedResult L11 |
없음 (signal 미사용) |
없음 (인라인 signal?.aborted L140-142,L408-410,L506-508) |
1.9 deleteDatabase
| 기능 | RT | MT | OP | CP |
|---|---|---|---|---|
factory.deleteDatabase |
없음 | 없음 | 없음 | 있음 L110-113, deletePartition L403-485 |
| 파티션 전체 삭제를 다른 방식으로 구현 | 있음 — 행 단위 순회 삭제 purgePartitionRecords L2477-2812 |
없음 | 없음 | 해당 없음 |
| 삭제 blocked 처리 | 해당 없음 | 해당 없음 | 해당 없음 | 있음 — blocked 데드라인 후 PENDING/UNKNOWN/BLOCKED_DEADLINE 반환 L449-462 (open의 L198-207과 동일 패턴 재작성) |
| 진행 중 삭제 레지스트리 | 해당 없음 | 해당 없음 | 해당 없음 | 있음 PENDING_DELETIONS WeakMap L31-41, 생성 시 검사 L116-122 |
1.10 관측 / 구성 검증
| 기능 | RT | MT | OP | CP |
|---|---|---|---|---|
| observe 래퍼 | 있음 L599-605 + observeResult L607-624 |
있음 L373-379 + observeResult L381-399 — RT와 형태 동일 |
다르게 구현 — 이벤트 shape가 다름 (SUCCEEDED/FAILED, countBucket 없음) L1074-1091 |
없음 |
countBucket |
있음 L119-125 |
RT와 문자 단위로 동일 L239-245 |
없음 | 없음 |
| 식별자 정규식 | SAFE_DATABASE_NAME L91 {0,63} |
SAFE_IDENTIFIER L103 {0,127} |
SAFE_DATABASE_NAME L110 {0,255}, SAFE_BOUNDARY_ID L111 |
SAFE_OPAQUE_ID/SAFE_UPLOAD_KEY (checkpoint-schema.ts) |
구성 불변식 검사 후 TypeError |
있음 L530-563 |
있음 L331-357 |
있음 L155-163 |
있음 L100-106,L116-122 |
| 의존성 메서드 bind 스냅샷 | 있음 L474-527 |
있음 L290-326 |
없음 | 있음 (부분) L108-113 |
| 저장 레코드 검증 술어 | 있음 L223-301 |
거의 동일하지만 별도 구현 L118-223 |
있음, 도메인 고유 L1406-1665 |
있음, 도메인 고유 (checkpoint-schema.ts) |
1.11 대조표 결론
4벌 전부가 같은 의미로 필요로 하는 것 (커널 후보):
factory.open→Promise, onupgradeneeded 훅, onblocked 처리, 늦게 온 connection 강제 close,
onversionchange/onclose 배선, transaction→Promise 상태기계(succeed/fail/settle-once/queue throw),
request→콜백 배선.
3벌 (커널 후보): 단일 비행 오픈 캐시(MT 제외 — 의도적), durability 폴백을 가진 트랜잭션 팩토리(CP 제외), 커서 순회(CP 제외), caller signal → transaction.abort(OP 제외 — 결함).
2벌 이하 (커널 아님): open 후 스토어 존재 검증(2), governance binding(2, 이미 indexeddb-governance.ts로 공유),
observe/countBucket(2), 시간 예산 클럭(2), 상태 구독 API(1), deleteDatabase(1), 저장 레코드 술어(전부 도메인 고유).
그리고 가장 중요한 결론: 실패 매핑은 공통이 아니다. 3벌이 mapIndexedDbException, 1벌이
mapBrowserDataException을 쓰고 두 테이블은 §1.8에 적은 대로 실제로 다른 답을 낸다. 커널이 매핑을 고르면
CP의 동작이 조용히 바뀐다. → 매핑은 주입한다.
2. 커널 경계선
판단 기준은 「4벌 중 3벌 이상이 같은 의미로 필요로 하는가」다.
2.1 커널로 올릴 것
| 항목 | 벌 수 | 근거 |
|---|---|---|
IDBOpenDBRequest → Promise (settle-once, blocked 데드라인, upgrade 훅, 늦은 connection close, signal→upgrade abort) |
4/4 | §1.1 §1.2 §1.3 |
| 단일 비행 연결 핸들 (+ versionchange/close 무효화, close()가 진행 중 open 취소) | 3/4 | §1.1 §1.4. MT는 의도적으로 안 씀 → 커널의 open 함수만 쓰고 핸들은 안 쓴다 |
| 트랜잭션 팩토리 + durability 폴백 | 3/4 | §1.5. CP는 durability: undefined를 넘겨 오늘 동작 유지 |
transaction → Promise 상태기계 |
4/4 | §1.5 |
request → 콜백 배선(성공/오류) |
4/4 | §1.6 |
| 커서 펌프(순회 + 예산 + 중첩 체인 재개) | 3/4 | §1.7 |
2.2 남길 것 (근거 포함)
| 항목 | 벌 수 | 남기는 이유 |
|---|---|---|
| 실패 매핑 테이블 | 3 + 1 | 두 테이블이 실제로 다른 값을 낸다(§1.8). 통일은 별도 결정이고 별도의 테스트 낙진을 만든다. 커널은 원인(cause)만 알리고 코드를 고르지 않는다. |
operation 라벨 |
4/4 다름 | 호출처마다 다르다. 커널이 알 필요가 없다. |
| open 후 스토어/인덱스 존재 검증 | 2/4 | RT는 indexeddb-migrations.ts:162-207, MT는 인라인. 검증 대상 스토어 목록이 도메인 스키마다. 커널에는 admit 콜백만 둔다. |
| governance / scope binding | 2/4 + 1 자체구현 | indexeddb-governance.ts가 이미 RT/MT 공유분이다. CP의 bindScope는 스코프 모델 자체가 다르다(L60-66 vs IndexedDbDatasetScope). |
| observe / countBucket | 2/4 | RT·MT는 동일하지만 OP는 shape가 다르고 CP는 없다. 2벌은 올리지 않는다. RT·MT 간 중복은 indexeddb-types.ts로 내리는 별건이다. |
| 시간 예산 클럭 | 2/4 | 커널의 커서 펌프가 예산 판정을 콜백으로 받는다. 클럭·deadline·실패값은 호출처가 소유한다. |
연결 상태 구독(getStatus/subscribeStatus) |
1/4 | RT의 포트 계약(IndexedDbRepositoryPort)이다. |
| 저장 레코드 검증 술어, 코덱, 예산(bytes), 보존, 영수증 | 도메인 | 커널은 정책을 하나도 안 갖는다(§5.3의 방어선). |
2.3 deleteDatabase — 규칙의 명시적 예외 (1/4)
「≥3벌만 커널」 규칙을 그대로 적용하면 deleteDatabase는 탈락이다. 그럼에도 커널에 둔다. 근거:
- 이건 새 기능 발명이 아니라 이동이다. 규칙이 막으려는 건 아무도 안 쓰는 추상화인데, 이건 오늘 호출자가
실제로 존재한다(
indexeddb-checkpoint-store.ts:403-485). deleteDatabase는open과 같은IDBOpenDBRequest상태기계다. CP는 blocked 데드라인 + settle-once 로직을L198-207(open)과L449-462(delete)에 두 번 적었다. 커널이 open만 가져가면 CP 안에 그 로직의 세 번째 사본이 남는다.- 드러난 발산 자체의 진짜 원인은
deleteDatabase가 아니다. 리뷰가 "한쪽에만 있다"고 잡은 건 표면 차이다. RT는 파티션 삭제를purgePartitionRecords(L2477-2812)의 행 단위 순회로 의도적으로 다르게 한다. 따라서deleteDatabase를 나머지 3벌에 새로 넣지 않는다. 이행 계획(§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:11이
AbortTerminalReason을 반환 타입에 박아 넣어 http v3가 못 쓴 것과 정확히 반대 구조다.
4. 사본별 이행 계획
LOC 감소는 추정이다. 근거로 삭제 대상 블록의 줄 범위를 함께 적었다.
4.1 RT — indexeddb-runtime.ts (2902 → 약 2665, −235)
삭제:
| 블록 | 줄 | LOC | 대체 |
|---|---|---|---|
defaultScheduler |
L108-117 | 10 | snapshotAbortTimers(scheduler) |
TransactionContext 타입 |
L85-89 | 5 | IndexedDbTransactionContext |
waitForOpeningAttempt |
L659-682 | 24 | connection.acquire(signal) |
startOpeningAttempt 중 배선 부분 (settle-once, generation 부기, blocked 타이머, onerror 라우팅, 늦은 close) |
L684-745, L776-843, L872-921 중 배선분 | ~110 | openIndexedDbDatabase (upgrade/admit 콜백 본문은 그대로 남음) |
createTransaction |
L957-974 | 18 | openIndexedDbTransaction |
runTransaction |
L976-1074 | 99 | runIndexedDbTransaction |
| 커서 5곳의 deadline/maxRows/abort 보일러플레이트 | L1374-1382, L1454-1471, L2364-2389, L2531-2548, L2573-2590, L2644-2661, L2718-2735 | ~60 | walkIndexedDbCursor + budget.admit |
| 소계 삭제 | ~326 | ||
| 커널 호출부·콜백 추가 | ~90 | ||
| 순감 | ~−235 |
남는 것: 코덱/영수증/보존/예산(bytes)/governance/migration 목록/상태 브로드캐스트/monotonicClock/
countBucket/저장 레코드 술어/purgePartitionRecords의 스토어 순서 로직 — 전부 정책이라 그대로.
동작 변화 지점:
- RT-1.
close()가 진행 중 open을 끝내는 원인이unavailable(operation)(L743)에서translate({kind:"CLOSED"})로 바뀐다. 위 번역기는CLOSED → unavailable로 매핑해 동일하게 유지한다. 이 매핑을 빠뜨리면close()중 open이 ABORTED로 보고된다. 테스트가 잡아야 할 지점. - RT-2.
settleNativeRequest/activeOpeningGeneration(L595, L691-697)이 사라진다. 늦게 온 open의 무해화를 커널의 settle-once가 대신한다. RT의 generation 토큰은openingPromise정리 시점 제어용이었고createIndexedDbConnection의 단일 비행이 같은 역할을 한다. 의미 동일. 단 경합 순서가 달라질 수 있다. - RT-3.
openingRequest필드(L593)가 쓰이지 않게 된다(오늘도 L712와 L2878 대입 외에 읽는 곳 없음). 삭제.
4.2 MT — indexeddb-maintenance.ts (1558 → 약 1370, −188)
삭제:
| 블록 | 줄 | LOC | 대체 |
|---|---|---|---|
TransactionContext 타입 |
L97-101 | 5 | 커널 타입 |
openExactVersion 중 배선분 |
L434-508, L551-585 | ~95 | openIndexedDbDatabase (admit에 L509-550 이식) |
createTransaction |
L587-604 | 18 | openIndexedDbTransaction |
runTransaction |
L606-705 | 100 | runIndexedDbTransaction |
| 커서 2곳의 deadline/maxRows/abort 보일러플레이트 | L799-833, L1451-1479 | ~30 | walkIndexedDbCursor |
| 소계 삭제 | ~248 | ||
| 커널 호출부·콜백 추가 | ~60 | ||
| 순감 | ~−188 |
남는 것: 체크포인트 상태기계(readCheckpoint L707-756, commitPrepared L969-1251),
prepareRecords L863-967, clock/epochClock L401-421, countBucket L239-245, 저장 술어 L118-223.
동작 변화 지점:
- MT-1. (가장 위험)
blockedTimeoutMs를 안 넘기면 오늘 동작(blocked 이벤트 즉시 BLOCKED, L485-492)이 유지된다. 넘기면 배치가 최대 그 시간만큼 매달린다.tests/unit/indexeddb-maintenance.test.ts:289-290이BLOCKED/retryable:true/RELOAD_OTHER_CONTEXTS를 즉시 받길 기대하므로 값을 잘못 넣으면 타임아웃 실패한다. - MT-2.
upgrade콜백을 생략해야 오늘의 "upgrade는 곧 실패"(L477-484)가 유지된다. 커널은 생략을UPGRADE_REJECTED로 해석하므로 실수로 빈upgrade: () => ({kind:"APPLIED"})를 넣으면 잘못된 스키마로 열린다. 테스트가 잡아야 할 지점. - MT-3.
database.onversionchange = () => database.close()(L543)를admit안으로 옮긴다.admit은 성공 경로에서만 실행되므로 등록 시점이 오늘과 같다.
4.3 OP — indexeddb-opfs-journal.ts (1817 → 약 1690, −127)
삭제:
| 블록 | 줄 | LOC | 대체 |
|---|---|---|---|
TransactionContext 타입 |
L48-51 | 4 | 커널 타입 |
| scheduler 기본값 인라인 | L144-153 | 10 | snapshotAbortTimers |
openDatabase 중 배선분 |
L961-996, L1031-1072 | ~79 | openIndexedDbDatabase + createIndexedDbConnection (upgrade 본문 L997-1029는 upgrade 콜백으로 그대로 이동) |
runTransaction |
L1098-1174 | 77 | runIndexedDbTransaction |
strictReadwriteTransaction |
L1176-1190 | 15 | openIndexedDbTransaction(…, "strict") |
| 커서 2곳의 limit 루프 | L604-633, L677-712 | ~20 | walkIndexedDbCursor |
| 소계 삭제 | ~205 | ||
| 커널 호출부 + 누락된 요청 오류 배선 추가 | ~78 | ||
| 순감 | ~−127 |
동작 변화 지점:
- OP-1. (이 리팩토링에서 가장 큰 동작 변화) 오늘 OP는 트랜잭션 안의 개별 요청에
.onerror를 하나도 안 단다(파일 전체 request.onerror는 L1042 open 요청뿐). 요청 실패는transaction.onabort로만 흘러mapIndexedDbException(transaction.error)(L1158)가 된다. 커널을 쓰면 요청 자신의 오류가 보고된다. 구체적으로LOGICAL_KEY_INDEX가unique:true(L1000-1004)이므로 중복 put은 요청 레벨ConstraintError→CONFLICT가 되고, 오늘은transaction.error가 무엇이냐에 따라 달라진다.tests/unit/indexeddb-opfs-journal.test.ts가 잡아야 할 지점. - OP-2. 오늘 OP의
runTransaction은succeed이후fail이 와도explicitFailure가 이기지만 (L1133-1141,hasValue는 그대로 true)oncomplete는 값을 반환한다(L1143-1153). 커널의 "첫 결과가 이긴다" 규칙(RT L1044-1054와 동일)을 따르면succeed후의fail이 무시된다. 현재 OP 코드에서 그 순서가 실제로 발생하는 경로가 있는지는 미확인 — 이행 시 확인 필요. - OP-3.
context.fail시 abort가 실패하면 오늘은 즉시settle(result)(L1139)한다. 커널(RT식)은finish(candidate ?? …)로 같은 결과를 낸다. 의미 동일. - OP-4.
signal은 넣지 않는다. 오늘 없는 취소를 새로 만들지 않는다.
4.4 CP — indexeddb-checkpoint-store.ts (712 → 약 570, −142)
삭제:
| 블록 | 줄 | LOC | 대체 |
|---|---|---|---|
TransactionContext 타입 |
L491-495 | 5 | 커널 타입 |
openAndBind 중 open 요청 Promise 배선 |
L163-175, L198-217 | ~33 | openIndexedDbDatabase (upgrade 본문 L176-197은 콜백으로 이동, bindScope 호출은 admit으로 이동) |
runCheckpointTransaction |
L497-596 | 100 | runIndexedDbTransaction |
bindScope의 트랜잭션 배선 |
L602-623 | ~22 | runIndexedDbTransaction (검증 로직 L624-652는 queue로 그대로) |
deletePartition의 blocked/settle 배선 |
L431-462, L472-483 | ~35 | deleteIndexedDbDatabase |
| 소계 삭제 | ~195 | ||
| 커널 호출부·콜백 추가 | ~53 | ||
| 순감 | ~−142 |
남는 것: PENDING_DELETIONS 레지스트리 L31-41 + 생성 시 검사 L116-122, uploadCheckpointDatabaseName
L73-83, sameScopeBinding L656-672, snapshotScope L674-691, snapshotCheckpoint L693-711.
동작 변화 지점:
- CP-1. 값 없는 완료가
CORRUPT_DATA/RECONCILE(L541-547)로 유지되려면 번역기가NO_VALUE_PRODUCED → CORRUPT_DATA를 명시적으로 매핑해야 한다. 안 하면 UNAVAILABLE로 바뀐다. 테스트가 잡아야 할 지점. - CP-2.
durability: undefined를 명시적으로 넘겨야 오늘 동작(옵션 bag 없음, L512)이 유지된다. 커널 기본을 상속하거나"strict"를 넣으면 체크포인트 쓰기가 조용히 strict가 되어 느려진다. 정확성은 안 깨지지만 성능 회귀다. - CP-3.
nativeFailure(L577-588, 기록 후 즉시 abort)는context.fail(translate(NATIVE_EXCEPTION))으로 표현한다.requestFailed(abort 안 함)로 잘못 바꾸면 요청이 실패했는데도 뒤 요청이 커밋될 수 있다.compareAndSwap의get → put체인(L321-343)에서 특히 위험하다. 테스트가 잡아야 할 지점. - CP-4.
abort()호출이 throw했을 때 오늘은failure를 되돌린다(L528, L536: "durably committed while its completion event is still queued" — 커밋된 변경을 ABORTED로 보고하지 않기 위함). 커널의 RT식onAbort(L1010-1017)에는 이 되돌림이 없다. 커널의 signal abort 처리는 CP의 이 규칙을 채택해야 한다: abort가 throw하면 caller-abort 표시를 세우지 않고 transaction 이벤트가 결과를 정한다. 이건 커널 구현의 필수 요건이고, 놓치면 CP가 커밋된 체크포인트를 ABORTED로 보고한다. →tests/unit/resumable-upload-checkpoint.test.ts:238"never reports a false abort after irreversible deleteDatabase dispatch"가 인접 케이스를 이미 지킨다. - CP-5.
deletePartition의 blocked 타이머가 네이티브setTimeout(L450)에서 주입형timers로 바뀐다.tests/unit/resumable-upload-checkpoint.test.ts:194가 이 경로를 본다. - CP-6.
deleteDatabase를 나머지 3벌에 추가하지 않는다. §2.3 근거.
4.5 전체 합계
| LOC | |
|---|---|
| 4벌에서 삭제 | ~974 |
| 4벌에 추가 (커널 호출부·콜백) | ~281 |
| 4벌 순감 | ~−693 |
| 커널 신규 2파일 | +~470 |
| 레포 순증감 | ~−223 |
솔직한 평가: LOC 절감은 크지 않다. 이 작업의 성과는 줄 수가 아니라 트랜잭션 상태기계가 4개에서 1개가 되는 것이다. 오늘 그 4개는 §1.5에서 본 대로 이미 4가지 다른 답을 낸다(요청 오류를 안 봄 / 기록만 / 즉시 abort, 값 없는 완료가 UNAVAILABLE / CORRUPT_DATA). LOC로 정당화하면 안 된다.
5. 위험과 선행조건
5.1 선행조건 P1 — ESLint (가장 놓치기 쉬움)
eslint.config.ts:40-63의 restrictedBrowserDataProperties는 globalThis/window/self 세 루트 전부에서
indexedDB 속성 접근을 금지한다. 추가로 eslint.config.ts:112-…의 커스텀 규칙
browser-data-boundary/no-capability-alias가 지역 별칭까지 따라간다
(const host = globalThis; host.indexedDB도 잡힌다 — 규칙 주석 eslint.config.ts:107-111).
두 개의 예외 목록(eslint.config.ts:519-530, eslint.config.ts:677-691)은 src/adapters/platform/을
폴더가 아니라 browser-lifecycle.ts 한 파일로 열어 놨다.
→ 설계 결론: 커널은 globalThis.indexedDB를 절대 읽지 않는다. IDBFactory는 필수 주입 파라미터다
(§3.2). 네 사본은 이미 전역 해석을 자기가 한다(RT L565-569, MT L359-363, OP L130-134, CP L95-97).
이 설계대로면 eslint.config.ts를 한 줄도 안 고쳐도 된다.
확인해 둔 것: IDBFactory·IDBDatabase·IDBTransaction·IDBRequest·IDBKeyRange·IDBCursorWithValue는
어느 금지 목록에도 없다(eslint.config.ts:40-63, :544-561). 커널이 이 타입들을 이름으로 쓰는 건 문제없다.
대안(커널이 전역을 읽게 하기)을 택하면 eslint.config.ts의 두 목록 모두에 신규 파일 2개를 추가해야 한다.
한쪽만 고치면 no-capability-alias나 no-restricted-properties 중 하나가 남아서 pnpm lint가 깨진다.
5.2 선행조건 P2 — docs/reviews/adapters/INVENTORY.md 갱신 필요
scripts/check-adapter-inventory.ts:23-31이 git ls-files src/adapters와 inventory 행을 집합으로 대조하고
(:41-53), 중복을 거부하며(:62-64), 합계: **N/N**의 두 숫자가 파일 수와 같아야 한다(:65-72).
현재 총계는 INVENTORY.md:132의 합계: **120/120**이다. 신규 파일 2개 → 122/122.
확인해 둔 유용한 사실: 행 번호 정규식은 /^\|\s*\d+\s*\|\s* + 백틱 경로다(:35). 번호는 위치와 대조되지
않고 순서도 무관하다(실제로 현재 platform/ 행 59-63은 git ls-files 정렬과 순서가 다르다 —
INVENTORY.md:69-73). → 기존 행을 재번호할 필요 없이 121, 122번 행을 추가하면 된다.
추가할 행(기존 platform/ 행들과 같은 상세 리뷰 링크):
| 121 | `src/adapters/platform/indexeddb-connection.ts` | [Network/state](./01-network-and-state.md) |
| 122 | `src/adapters/platform/indexeddb-transaction.ts` | [Network/state](./01-network-and-state.md) |
INVENTORY.md:132의 안내문이 "해당 상세 리뷰 inventory를 같은 변경에서 갱신한다"고 요구한다.
→ docs/reviews/adapters/01-network-and-state.md에도 두 행이 필요하다. 다만 그 파일에 구조적 게이트가
걸려 있는지는 미확인이다 (check-adapter-inventory.ts는 INVENTORY.md만 읽는다, :20).
5.3 선행조건 P3 — dependency-cruiser 변경 불필요
adapters-do-not-know-other-concrete-adapters의 pathNot이
^src/adapters/($1/|platform/|browser-file-storage/result\.ts$|cross-context-invalidation/index\.ts$)이므로
src/adapters/platform/ 전체가 이미 허용 대상이다. 네 사본 전부 커널을 import할 수 있다.
커널이 src/contracts/result.ts를 import하는 것도 허용된다:
- dependency-cruiser에서 contracts를 언급하는 유일한 규칙은
concrete-adapters-compose-only-in-bootstrap인데, 이건contracts → adapters를 막지adapters → contracts를 막지 않는다. - eslint
layerPatterns.adapters는["**/presentation/**", "**/bootstrap/**"]뿐이다(eslint.config.ts:33). - 선례:
src/adapters/web-push/push-association-fence-store.ts:12가../../contracts/web-push.ts를 import한다.
그리고 Result<Value, Failure>(src/contracts/result.ts:13-15)를 쓰면
Result<T, BrowserDataFailure>가 정의상 BrowserDataResult<T>와 같은 타입이다
(src/application/ports/browser-file-storage/shared.ts:95). 변환 코드가 0줄이다.
5.4 깨질 수 있는 기존 테스트 (grep으로 확인한 목록)
| 파일 | LOC | 위험 |
|---|---|---|
tests/helpers/memory-indexeddb.ts |
922 | 최고 위험. 단위 테스트 전부가 도는 가짜 IDB. OP-1(요청 레벨 onerror를 새로 달게 됨)이 이 가짜가 request.error를 실제로 채우는지에 의존한다. 오늘 OP는 요청 오류를 안 보므로 그 경로가 한 번도 행사되지 않았을 수 있다. 채우지 않으면 NATIVE_EXCEPTION{error: null} → UNAVAILABLE로 뭉개진다. 이행 전에 이 파일을 먼저 읽어야 한다 (이번 조사에서는 미확인). |
tests/unit/indexeddb-runtime.test.ts |
1140 | RT-1(CLOSED 매핑). 실패 코드 단언은 :339-340, :371-372, :454, :534, :549-550, :734-735 |
tests/unit/indexeddb-maintenance.test.ts |
842 | MT-1(blocked 즉시 실패). :289-290이 BLOCKED/retryable:true/RELOAD_OTHER_CONTEXTS를 단언. MT-2(upgrade 거절)도 여기 |
tests/unit/indexeddb-opfs-journal.test.ts |
431 | OP-1, OP-2. 요청 오류가 새 코드로 보고되는 건 이 테스트가 잡아야 한다 |
tests/unit/resumable-upload-checkpoint.test.ts |
265 | CP-1, CP-3, CP-4, CP-5. :142 CONFLICT/RECONCILE, :190 UNAVAILABLE/RESUME, :194 blocked PENDING, :238 false-abort 금지 |
tests/unit/runtime-adapters.test.ts |
— | storage/indexeddb import. 공개 형태가 안 바뀌면 영향 없음 |
tests/helpers/fake-push-control-repository.ts, tests/unit/web-push-store-port-compatibility.test.ts |
— | RT의 공개 포트 형태에만 의존. §0대로 IDB 코드 없음. 공개 형태 불변이면 영향 없음 |
tests/unit/abortable-operation.test.ts, tests/unit/system-clock.test.ts |
— | 기존 커널 테스트. snapshotAbortTimers 재사용은 계약을 안 바꾸므로 영향 없음 |
tests/browser-capabilities/indexeddb-runtime.spec.ts |
1033 | 실브라우저 Playwright. deleteDatabase는 정리용(:443, :832, :923, :1019)이라 이번 변경과 무관. 다만 RT의 blocked/versionchange 실동작을 보므로 회귀 감지망 |
tests/browser-capabilities/opfs-runtime.spec.ts |
221 | 동 (:137) |
tests/browser-capabilities/resumable-upload.spec.ts |
526 | CP 실동작 |
tests/fixtures/optional-recipes/forbidden/runtime-composition/src/bootstrap/bad-runtime.ts |
— | 픽스처. 경로 변경이 없으므로 영향 없음 |
신규 테스트: tests/unit/indexeddb-connection.test.ts, tests/unit/indexeddb-transaction.test.ts가
관례상 필요하다(abortable-operation.test.ts, system-clock.test.ts 선례). check:test-evidence /
check:risk-coverage가 신규 소스 파일당 테스트를 강제하는지는 미확인.
5.5 "커널이 커진다"는 반론과 답
반론: 오늘 src/adapters/platform/은 5파일 550 LOC이고 전부 진짜 원시 요소다 — 시계 24줄, abort 269줄,
용량 가드 21줄, 라이프사이클 186줄, UUID 50줄. 여기에 IndexedDB 전용 470 LOC를 넣으면 "platform"이라는 이름이
거짓말이 된다. IndexedDB는 플랫폼 원시 요소가 아니라 하나의 저장 기술이다.
답 1 — 이 레포의 커널 기준은 "보편성"이 아니라 "네이티브 표면의 단일 소유자"다. 같은 반론이
browser-lifecycle.ts(186 LOC, visibilitychange/online/pagehide 전용)에도 그대로 적용되지만 그 파일은
이미 커널이고, 자기 주석이 근거를 밝힌다(browser-lifecycle.ts:4-7: "No capability adds its own listener…
so the listener count stays constant"). dependency-cruiser 규칙 주석도 같은 말을 한다:
"Only the adapter kernel is shared — src/adapters/platform (clock, abort primitive, capacity guard)".
답 2 — 다른 위치는 전부 더 나쁘다.
src/adapters/storage/indexeddb/에 두면adapters-do-not-know-other-concrete-adapters가browser-transfer/와storage/opfs/의 import를 금지한다. 중복이 존재하는 이유가 바로 이것이다.- 새 최상위
src/adapters/indexeddb-kernel/은 dependency-cruiser 규칙 변경이 필요한데, 리뷰가 이미 그 비용을 이유로 기각했다.
답 3 (실제로 위험을 한정하는 답) — 커널은 IndexedDB "정책"을 하나도 갖지 않는다. 커널이 소유하는 것: 이벤트 배선(request→콜백), 트랜잭션 상태기계, 커서 펌프, settle-once, blocked 래치. 커널이 소유하지 않는 것: DB 이름, 스키마, 마이그레이션, governance 바인딩, 코덱, 바이트 예산, 보존 정책, 멱등 영수증, 실패 코드 테이블, operation 라벨, 관측 이벤트 shape, durability 기본값, 예산 기준. 나중에 이 목록 중 하나를 커널에 넣고 싶어지면, 그게 멈춰야 한다는 신호다. 이 문장을 커널 파일 최상단 주석에 넣는다.
답 4 — abortable-operation.ts의 실패를 반복하지 않는 구체적 차이.
abortable-operation.ts:11의 AbortTerminalReason은 3멤버 닫힌 union이고 AbortRace<Value>(:19-22)의
반환 타입에 박혀 있다. 소비자가 5종이 필요하면 커널을 수정하거나 포기하는 두 길뿐이고, http v3는 포기했다.
커널의 IndexedDbFailureCause는 반환 타입에 안 나온다. 반환 타입은 Result<Value, Failure>이고 Failure는
호출처가 정한다. cause는 translate 함수의 입력일 뿐이라, 멤버를 추가하면 모든 translate에서
컴파일 에러가 나고(전역 함수이므로) 조용한 동작 변화가 안 생긴다. 확장이 커널 수정 없이도 되고,
커널 수정이 필요할 때도 누락이 타입 검사로 잡힌다.
남는 솔직한 위험: 그래도 4개 어댑터가 공통으로 의존하는 470 LOC가 생긴다. 여기 버그가 나면 폭발 반경이 4배다. 이걸 없앨 방법은 없고, 대가로 얻는 건 §1.5의 4가지 다른 답이 1가지가 되는 것이다. 그 교환이 받아들일 만한지가 이 리팩토링의 진짜 판단이다.
6. 이행 순서 권고
- 커널 2파일 + 단위 테스트 추가. 사본은 손대지 않는다. → 게이트 기준선 유지 확인
(
check:architecture,check:types:app,lint,check:adapter-inventory). 이 단계에서 INVENTORY.md(+01-network-and-state.md)를 같이 갱신한다. - CP 먼저 이행. 가장 작고(712), 동작 변화 지점(CP-1~CP-6)이 가장 명확하며, 커널의 CP-4 요건 (abort가 throw하면 caller-abort를 세우지 않는다)을 조기에 검증한다.
- MT. 커널 사용자 중 연결 핸들을 안 쓰는 유일한 사본이라
openIndexedDbDatabase단독 사용 경로를 검증한다. - OP. OP-1 때문에
tests/helpers/memory-indexeddb.ts보강이 선행될 가능성이 높다. - RT 마지막. 가장 크고 다른 셋이 커널을 다 검증한 뒤에 옮긴다.