Compare commits
29
Commits
@@ -156,6 +156,54 @@
|
||||
"path": "^(src/(presentation|bootstrap)|react|react-dom|@tanstack)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "contracts-do-not-know-application",
|
||||
"comment": "§4. `src/contracts` is the lower of the two packages: application reads contracts, never the other way round. Before this rule the shared Result carrier and the compatibility predicate lived in application and were imported back down by contracts, so neither package owned the shared vocabulary and the coupling was invisible to every gate.",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/contracts"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/(application|features)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "generic-presentation-does-not-compose-the-product",
|
||||
"comment": "§4 / §9. Which features are installed is a product decision that belongs to bootstrap. Generic presentation reads the installed registries directly today; the paths below are the exact set that does so, frozen so the coupling cannot spread while the assembly is lifted into bootstrap.",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/presentation/",
|
||||
"pathNot": "^src/presentation/(layouts/app-shell\\.tsx|pages/(not-found-page|home-page)\\.tsx|routes/(route-contract|route-codecs|app-router|navigation-policy)\\.(ts|tsx)|i18n/catalog\\.ts|examples/platform-overview-page\\.tsx)$"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/features/installed-"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "adapters-do-not-know-other-concrete-adapters",
|
||||
"comment": "docs/architecture/layers.md §4: a concrete adapter never depends on another concrete adapter. Only the adapter kernel is shared — `src/adapters/platform` (clock, abort primitive, capacity guard) and the browser-data result helpers. `query-cache` still reads two collaborator types from `cross-context-invalidation`; that edge is named here rather than left silent, and closes when those types are lifted to a port.",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/adapters/([^/]+)/"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/adapters/([^/]+)/",
|
||||
"pathNot": "^src/adapters/($1/|platform/|browser-file-storage/result\\.ts$|cross-context-invalidation/index\\.ts$)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "adapter-groups-are-reached-through-their-barrel",
|
||||
"comment": "어댑터 그룹의 공개 표면은 그 그룹의 index.ts다. 그룹 바깥(bootstrap, features, presentation)은 배럴만 import한다. 배럴이 없던 시절 bootstrap은 어댑터 내부 파일 15곳을 직접 겨눴고, 그래서 어떤 파일이 공개이고 어떤 파일이 내부 헬퍼인지 아무 데도 적혀 있지 않았다. 출발점에서 src/adapters를 뺀 이유는 어댑터끼리의 간선은 바로 위 adapters-do-not-know-other-concrete-adapters가 이미 담당하고, 커널(platform/**)은 파일 단위로 공유되기 때문이다 — scripts/check-adapter-inventory.ts가 네 소비자에게 platform/abortable-operation.ts로 해석되는 specifier를 직접 요구한다. 도착점에서 1단계 중첩 index.ts를 허용한 이유는 storage/indexeddb와 storage/opfs가 각자 독립적으로 제거 가능한 런타임이고(scripts/test-browser-file-storage-runtime-removal.ts), 그래서 각자의 배럴이 곧 경계이기 때문이다.",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/",
|
||||
"pathNot": "^src/adapters/"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/adapters/[^/]+/",
|
||||
"pathNot": "^src/adapters/[^/]+/index\\.ts$|^src/adapters/[^/]+/[^/]+/index\\.ts$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "no-circular-dependencies",
|
||||
"severity": "error",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Build-time inputs (§6.1). These are compiled into the bundle by Vite, so
|
||||
# everything here is public by definition. Never put a secret in this file or in
|
||||
# any `.env*` file: a frontend has no confidential storage, and a value that
|
||||
# reaches the browser has been published.
|
||||
#
|
||||
# Runtime configuration — API endpoints, auth mode, telemetry, capability
|
||||
# switches — is NOT here. It lives in `config/runtime/<profile>.json` and is
|
||||
# materialized into `dist/config.json` at build time, so it can be changed
|
||||
# without rebuilding. See docs/architecture/layers.md.
|
||||
#
|
||||
# Copy to `.env.local` (git-ignored) to override locally.
|
||||
|
||||
# Identifies the build in release manifests and the runtime document.
|
||||
# CI supplies the real value; a developer build falls back to "local-build".
|
||||
VITE_BUILD_ID=local-build
|
||||
|
||||
# Source revision the bundle was produced from.
|
||||
VITE_COMMIT_SHA=local
|
||||
|
||||
# Sub-path the app is served under. Must start and end with "/".
|
||||
# Feeds the router, the Service Worker scope and Vite's asset base together.
|
||||
VITE_ROUTER_BASE_PATH=/
|
||||
|
||||
# Where the browser fetches the runtime document from at boot.
|
||||
VITE_RUNTIME_CONFIG_URL=/config.json
|
||||
@@ -126,6 +126,9 @@ jobs:
|
||||
outputs:
|
||||
dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }}
|
||||
archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }}
|
||||
env:
|
||||
APP_PROFILE: "${{ vars.APP_PROFILE }}"
|
||||
RELEASE_TARGET: "${{ vars.RELEASE_TARGET }}"
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
|
||||
@@ -18,3 +18,9 @@ artifacts/storybook/
|
||||
artifacts/tests/storybook/
|
||||
artifacts/tests/visual/
|
||||
!artifacts/**/.gitkeep
|
||||
|
||||
# Local environment overrides. `.env.example` is the tracked template; every
|
||||
# other `.env*` file is a developer's own machine and never enters the repo.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
+17
-2
@@ -2,14 +2,19 @@ import type { Preview } from "@storybook/react-vite";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import { createAnonymousSessionAdapter } from "../src/adapters/auth/external-session-adapter.ts";
|
||||
import { createQueryClient } from "../src/adapters/query-cache/tanstack-query-cache.ts";
|
||||
import { createAnonymousSessionAdapter } from "../src/adapters/auth/index.ts";
|
||||
import { createQueryClient } from "../src/adapters/query-cache/index.ts";
|
||||
import { createApplication } from "../src/application/create-application.ts";
|
||||
import { LocaleProvider } from "../src/presentation/i18n/index.ts";
|
||||
import { ApplicationProvider } from "../src/presentation/providers/application-provider.tsx";
|
||||
import { SessionProvider } from "../src/presentation/providers/session-provider.tsx";
|
||||
import { ThemeProvider } from "../src/presentation/providers/theme-provider.tsx";
|
||||
import "../src/presentation/styles/theme.css";
|
||||
import { resolveProductFeatures } from "../src/contracts/product-features.ts";
|
||||
import {
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
} from "../src/features/installed-product-manifest.ts";
|
||||
|
||||
const preferences = new Map<string, unknown>();
|
||||
const application = createApplication({
|
||||
@@ -45,6 +50,16 @@ const application = createApplication({
|
||||
routeChunks: {},
|
||||
}),
|
||||
},
|
||||
// Storybook renders components, not a product: every declared feature is
|
||||
// shown as active so a story is never blank because of a deployment switch.
|
||||
productFeatures: {
|
||||
getSnapshot: () =>
|
||||
resolveProductFeatures(
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
),
|
||||
isActive: () => true,
|
||||
},
|
||||
runtimeCapabilities: {
|
||||
getSnapshot: () =>
|
||||
Object.freeze(
|
||||
|
||||
@@ -47,21 +47,28 @@ authoritative.
|
||||
|
||||
## Architecture
|
||||
|
||||
This repository is a **Frontend Application Foundation**: a starter/composition
|
||||
skeleton plus a reusable capability platform. Platform capabilities stay
|
||||
horizontal while product business features use vertical slices.
|
||||
|
||||
Dependencies point inward:
|
||||
|
||||
```text
|
||||
presentation -> application -> domain
|
||||
adapters -----^
|
||||
bootstrap composes concrete adapters
|
||||
contracts own cross-cutting registries
|
||||
feature use case -> feature port <- feature-owned capability binding
|
||||
```
|
||||
|
||||
See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and
|
||||
See
|
||||
`docs/architecture/frontend-application-foundation.md`,
|
||||
`docs/architecture/overview.md`, `docs/architecture/layers.md`, and
|
||||
`docs/architecture/starter-experience.md`. The removable vertical slice is
|
||||
under `src/features/reference-feature`; its domain, application input, HTTP
|
||||
adapter, contracts, route runtime, and presentation are installed through the
|
||||
feature contribution files in `src/features`. The generic starter routes
|
||||
continue to typecheck, test, and build after that contribution is removed.
|
||||
binding, contracts, route runtime, and presentation own their contributions.
|
||||
The central installed catalogs only aggregate selected contributions. The
|
||||
generic starter routes continue to typecheck, test, and build after that
|
||||
feature is removed.
|
||||
|
||||
### Platform capability review
|
||||
|
||||
@@ -103,6 +110,7 @@ corepack pnpm check:types:node
|
||||
corepack pnpm check:types:test
|
||||
corepack pnpm check:architecture
|
||||
corepack pnpm test:all
|
||||
corepack pnpm test:contract
|
||||
corepack pnpm test:e2e
|
||||
corepack pnpm test:a11y
|
||||
corepack pnpm build
|
||||
@@ -115,6 +123,11 @@ corepack pnpm drill:runbooks
|
||||
corepack pnpm check:ci
|
||||
```
|
||||
|
||||
`test:all` is the ordinary product-development loop and intentionally excludes
|
||||
host-level CI-runner assurance. Run `corepack pnpm test:system` only on the
|
||||
compatible Linux assurance host described in
|
||||
[`docs/testing/taxonomy.md`](docs/testing/taxonomy.md).
|
||||
|
||||
`check:types`는 source, Node scripts/config와 tests를 분리된 TypeScript
|
||||
project로 모두 검사한다. type/architecture/security/registry의 invalid
|
||||
fixture는 `config/ci/gates.json`에서 “실패해야 통과”하는 negative gate로
|
||||
@@ -140,7 +153,12 @@ corepack pnpm exec playwright install --with-deps chromium firefox webkit
|
||||
Two gates intentionally need external evidence:
|
||||
|
||||
- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review
|
||||
for all six registered routes.
|
||||
for all ten registered routes: `APP_HOME`, `EXAMPLES_PLATFORM`,
|
||||
`EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`, `NOT_FOUND`,
|
||||
`REFERENCE_RESOURCE_LIST`, `REFERENCE_RESOURCE_DETAIL`,
|
||||
`REFERENCE_RESOURCE_FORM` and `REFERENCE_RESOURCE_STATUS`.
|
||||
`verify:documentation` derives that list from the route registry and fails if
|
||||
this paragraph falls behind it.
|
||||
- `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum
|
||||
eligible-sample threshold and 28 days of production data exist.
|
||||
|
||||
@@ -148,7 +166,7 @@ Live release verification additionally requires `HOSTING_BASE_URL`.
|
||||
|
||||
## CI and evidence
|
||||
|
||||
The 26-gate registry is `config/ci/gates.json`; the Gitea workflow is
|
||||
The 27-gate registry is `config/ci/gates.json`; the Gitea workflow is
|
||||
`.gitea/workflows/quality-gates.yml`. It follows:
|
||||
|
||||
```text
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
body, html {
|
||||
margin:0; padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: Helvetica Neue, Helvetica, Arial;
|
||||
font-size: 14px;
|
||||
color:#333;
|
||||
}
|
||||
.small { font-size: 12px; }
|
||||
*, *:after, *:before {
|
||||
-webkit-box-sizing:border-box;
|
||||
-moz-box-sizing:border-box;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
h1 { font-size: 20px; margin: 0;}
|
||||
h2 { font-size: 14px; }
|
||||
pre {
|
||||
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-moz-tab-size: 2;
|
||||
-o-tab-size: 2;
|
||||
tab-size: 2;
|
||||
}
|
||||
a { color:#0074D9; text-decoration:none; }
|
||||
a:hover { text-decoration:underline; }
|
||||
.strong { font-weight: bold; }
|
||||
.space-top1 { padding: 10px 0 0 0; }
|
||||
.pad2y { padding: 20px 0; }
|
||||
.pad1y { padding: 10px 0; }
|
||||
.pad2x { padding: 0 20px; }
|
||||
.pad2 { padding: 20px; }
|
||||
.pad1 { padding: 10px; }
|
||||
.space-left2 { padding-left:55px; }
|
||||
.space-right2 { padding-right:20px; }
|
||||
.center { text-align:center; }
|
||||
.clearfix { display:block; }
|
||||
.clearfix:after {
|
||||
content:'';
|
||||
display:block;
|
||||
height:0;
|
||||
clear:both;
|
||||
visibility:hidden;
|
||||
}
|
||||
.fl { float: left; }
|
||||
@media only screen and (max-width:640px) {
|
||||
.col3 { width:100%; max-width:100%; }
|
||||
.hide-mobile { display:none!important; }
|
||||
}
|
||||
|
||||
.quiet {
|
||||
color: #7f7f7f;
|
||||
color: rgba(0,0,0,0.5);
|
||||
}
|
||||
.quiet a { opacity: 0.7; }
|
||||
|
||||
.fraction {
|
||||
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
background: #E8E8E8;
|
||||
padding: 4px 5px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
div.path a:link, div.path a:visited { color: #333; }
|
||||
table.coverage {
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table.coverage td {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.coverage td.line-count {
|
||||
text-align: right;
|
||||
padding: 0 5px 0 20px;
|
||||
}
|
||||
table.coverage td.line-coverage {
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
min-width:20px;
|
||||
}
|
||||
|
||||
table.coverage td span.cline-any {
|
||||
display: inline-block;
|
||||
padding: 0 5px;
|
||||
width: 100%;
|
||||
}
|
||||
.missing-if-branch {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
padding: 0 4px;
|
||||
background: #333;
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.skip-if-branch {
|
||||
display: none;
|
||||
margin-right: 10px;
|
||||
position: relative;
|
||||
padding: 0 4px;
|
||||
background: #ccc;
|
||||
color: white;
|
||||
}
|
||||
.missing-if-branch .typ, .skip-if-branch .typ {
|
||||
color: inherit !important;
|
||||
}
|
||||
.coverage-summary {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.coverage-summary tr { border-bottom: 1px solid #bbb; }
|
||||
.keyline-all { border: 1px solid #ddd; }
|
||||
.coverage-summary td, .coverage-summary th { padding: 10px; }
|
||||
.coverage-summary tbody { border: 1px solid #bbb; }
|
||||
.coverage-summary td { border-right: 1px solid #bbb; }
|
||||
.coverage-summary td:last-child { border-right: none; }
|
||||
.coverage-summary th {
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coverage-summary th.file { border-right: none !important; }
|
||||
.coverage-summary th.pct { }
|
||||
.coverage-summary th.pic,
|
||||
.coverage-summary th.abs,
|
||||
.coverage-summary td.pct,
|
||||
.coverage-summary td.abs { text-align: right; }
|
||||
.coverage-summary td.file { white-space: nowrap; }
|
||||
.coverage-summary td.pic { min-width: 120px !important; }
|
||||
.coverage-summary tfoot td { }
|
||||
|
||||
.coverage-summary .sorter {
|
||||
height: 10px;
|
||||
width: 7px;
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
|
||||
}
|
||||
.coverage-summary .sorted .sorter {
|
||||
background-position: 0 -20px;
|
||||
}
|
||||
.coverage-summary .sorted-desc .sorter {
|
||||
background-position: 0 -10px;
|
||||
}
|
||||
.status-line { height: 10px; }
|
||||
/* yellow */
|
||||
.cbranch-no { background: yellow !important; color: #111; }
|
||||
/* dark red */
|
||||
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
|
||||
.low .chart { border:1px solid #C21F39 }
|
||||
.highlighted,
|
||||
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
|
||||
background: #C21F39 !important;
|
||||
}
|
||||
/* medium red */
|
||||
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
|
||||
/* light red */
|
||||
.low, .cline-no { background:#FCE1E5 }
|
||||
/* light green */
|
||||
.high, .cline-yes { background:rgb(230,245,208) }
|
||||
/* medium green */
|
||||
.cstat-yes { background:rgb(161,215,106) }
|
||||
/* dark green */
|
||||
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
|
||||
.high .chart { border:1px solid rgb(77,146,33) }
|
||||
/* dark yellow (gold) */
|
||||
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
|
||||
.medium .chart { border:1px solid #f9cd0b; }
|
||||
/* light yellow */
|
||||
.medium { background: #fff4c2; }
|
||||
|
||||
.cstat-skip { background: #ddd; color: #111; }
|
||||
.fstat-skip { background: #ddd; color: #111 !important; }
|
||||
.cbranch-skip { background: #ddd !important; color: #111; }
|
||||
|
||||
span.cline-neutral { background: #eaeaea; }
|
||||
|
||||
.coverage-summary td.empty {
|
||||
opacity: .5;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
line-height: 1;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.cover-fill, .cover-empty {
|
||||
display:inline-block;
|
||||
height: 12px;
|
||||
}
|
||||
.chart {
|
||||
line-height: 0;
|
||||
}
|
||||
.cover-empty {
|
||||
background: white;
|
||||
}
|
||||
.cover-full {
|
||||
border-right: none !important;
|
||||
}
|
||||
pre.prettyprint {
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.com { color: #999 !important; }
|
||||
.ignore-none { color: #999; font-weight: normal; }
|
||||
|
||||
.wrapper {
|
||||
min-height: 100%;
|
||||
height: auto !important;
|
||||
height: 100%;
|
||||
margin: 0 auto -48px;
|
||||
}
|
||||
.footer, .push {
|
||||
height: 48px;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable */
|
||||
var jumpToCode = (function init() {
|
||||
// Classes of code we would like to highlight in the file view
|
||||
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
|
||||
|
||||
// Elements to highlight in the file listing view
|
||||
var fileListingElements = ['td.pct.low'];
|
||||
|
||||
// We don't want to select elements that are direct descendants of another match
|
||||
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
|
||||
|
||||
// Selector that finds elements on the page to which we can jump
|
||||
var selector =
|
||||
fileListingElements.join(', ') +
|
||||
', ' +
|
||||
notSelector +
|
||||
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
|
||||
|
||||
// The NodeList of matching elements
|
||||
var missingCoverageElements = document.querySelectorAll(selector);
|
||||
|
||||
var currentIndex;
|
||||
|
||||
function toggleClass(index) {
|
||||
missingCoverageElements
|
||||
.item(currentIndex)
|
||||
.classList.remove('highlighted');
|
||||
missingCoverageElements.item(index).classList.add('highlighted');
|
||||
}
|
||||
|
||||
function makeCurrent(index) {
|
||||
toggleClass(index);
|
||||
currentIndex = index;
|
||||
missingCoverageElements.item(index).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center'
|
||||
});
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
var nextIndex = 0;
|
||||
if (typeof currentIndex !== 'number' || currentIndex === 0) {
|
||||
nextIndex = missingCoverageElements.length - 1;
|
||||
} else if (missingCoverageElements.length > 1) {
|
||||
nextIndex = currentIndex - 1;
|
||||
}
|
||||
|
||||
makeCurrent(nextIndex);
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
var nextIndex = 0;
|
||||
|
||||
if (
|
||||
typeof currentIndex === 'number' &&
|
||||
currentIndex < missingCoverageElements.length - 1
|
||||
) {
|
||||
nextIndex = currentIndex + 1;
|
||||
}
|
||||
|
||||
makeCurrent(nextIndex);
|
||||
}
|
||||
|
||||
return function jump(event) {
|
||||
if (
|
||||
document.getElementById('fileSearch') === document.activeElement &&
|
||||
document.activeElement != null
|
||||
) {
|
||||
// if we're currently focused on the search input, we don't want to navigate
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.which) {
|
||||
case 78: // n
|
||||
case 74: // j
|
||||
goToNext();
|
||||
break;
|
||||
case 66: // b
|
||||
case 75: // k
|
||||
case 80: // p
|
||||
goToPrevious();
|
||||
break;
|
||||
}
|
||||
};
|
||||
})();
|
||||
window.addEventListener('keydown', jumpToCode);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 445 B |
@@ -0,0 +1,116 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for All files</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="prettify.css" />
|
||||
<link rel="stylesheet" href="base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1>All files</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>11/11</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>8/8</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>5/5</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>11/11</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file high" data-value="reference-http-gateway.ts"><a href="reference-http-gateway.ts.html">reference-http-gateway.ts</a></td>
|
||||
<td data-value="100" class="pic high">
|
||||
<div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
|
||||
</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="11" class="abs high">11/11</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="8" class="abs high">8/8</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="5" class="abs high">5/5</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="11" class="abs high">11/11</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-09-18T11:39:00.427Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="sorter.js"></script>
|
||||
<script src="block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,331 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for reference-http-gateway.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="prettify.css" />
|
||||
<link rel="stylesheet" href="base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="index.html">All files</a> reference-http-gateway.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>11/11</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>8/8</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>5/5</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>11/11</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import {
|
||||
defineFeatureHttpOperationForRoutes,
|
||||
type FeatureHttpBinding,
|
||||
} from "../../../adapters/http/index.ts";
|
||||
import {
|
||||
CREATE_REFERENCE_RESOURCE_CONTRACT,
|
||||
GET_REFERENCE_RESOURCE_CONTRACT,
|
||||
LIST_REFERENCE_RESOURCES_CONTRACT,
|
||||
} from "../contracts/reference-feature-contract-contribution.ts";
|
||||
import {
|
||||
REFERENCE_FEATURE_CONTRACT,
|
||||
} from "../contracts/reference-feature-contract.ts";
|
||||
import {
|
||||
mapReferenceResourceListPayload,
|
||||
mapReferenceResourcePayload,
|
||||
} from "../contracts/reference-mapper.ts";
|
||||
import type {
|
||||
ReferenceGateway,
|
||||
} from "../application/reference-feature-api.ts";
|
||||
|
||||
export type ReferenceFeatureRouteId =
|
||||
keyof typeof REFERENCE_FEATURE_CONTRACT.routes;
|
||||
|
||||
const defineReferenceHttpOperation =
|
||||
defineFeatureHttpOperationForRoutes<ReferenceFeatureRouteId>();
|
||||
|
||||
export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
|
||||
LIST_REFERENCE_RESOURCES: defineReferenceHttpOperation({
|
||||
contract: LIST_REFERENCE_RESOURCES_CONTRACT,
|
||||
routeId: "REFERENCE_RESOURCE_LIST",
|
||||
mapSuccess: mapReferenceResourceListPayload,
|
||||
}),
|
||||
CREATE_REFERENCE_RESOURCE: defineReferenceHttpOperation({
|
||||
contract: CREATE_REFERENCE_RESOURCE_CONTRACT,
|
||||
routeId: "REFERENCE_RESOURCE_LIST",
|
||||
mapSuccess: mapReferenceResourcePayload,
|
||||
mapProblem(problem, metadata) {
|
||||
if (metadata.status === 409) {
|
||||
return Object.freeze({
|
||||
kind: "CONFLICT" as const,
|
||||
code: problem.code ?? "REFERENCE_RESOURCE_CONFLICT",
|
||||
});
|
||||
}
|
||||
if (metadata.status === 422) {
|
||||
return Object.freeze({
|
||||
kind: "VALIDATION_REJECTED" as const,
|
||||
code: problem.code ?? "REFERENCE_RESOURCE_REJECTED",
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
GET_REFERENCE_RESOURCE: defineReferenceHttpOperation({
|
||||
contract: GET_REFERENCE_RESOURCE_CONTRACT,
|
||||
routeId: "REFERENCE_RESOURCE_DETAIL",
|
||||
mapSuccess: mapReferenceResourcePayload,
|
||||
}),
|
||||
} as const);
|
||||
|
||||
export type ReferenceHttpBinding = FeatureHttpBinding<
|
||||
typeof REFERENCE_HTTP_OPERATIONS
|
||||
>;
|
||||
|
||||
export function createReferenceHttpGateway(
|
||||
http: ReferenceHttpBinding,
|
||||
): ReferenceGateway {
|
||||
return Object.freeze({
|
||||
list(filters, context) {
|
||||
return http.execute("LIST_REFERENCE_RESOURCES", filters, context);
|
||||
},
|
||||
create(command, context) {
|
||||
return http.execute("CREATE_REFERENCE_RESOURCE", command, context);
|
||||
},
|
||||
get(resourceId, context) {
|
||||
return http.execute(
|
||||
"GET_REFERENCE_RESOURCE",
|
||||
Object.freeze({ resourceId }),
|
||||
context,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-09-18T11:39:00.427Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="sorter.js"></script>
|
||||
<script src="block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 138 B |
@@ -0,0 +1,210 @@
|
||||
/* eslint-disable */
|
||||
var addSorting = (function() {
|
||||
'use strict';
|
||||
var cols,
|
||||
currentSort = {
|
||||
index: 0,
|
||||
desc: false
|
||||
};
|
||||
|
||||
// returns the summary table element
|
||||
function getTable() {
|
||||
return document.querySelector('.coverage-summary');
|
||||
}
|
||||
// returns the thead element of the summary table
|
||||
function getTableHeader() {
|
||||
return getTable().querySelector('thead tr');
|
||||
}
|
||||
// returns the tbody element of the summary table
|
||||
function getTableBody() {
|
||||
return getTable().querySelector('tbody');
|
||||
}
|
||||
// returns the th element for nth column
|
||||
function getNthColumn(n) {
|
||||
return getTableHeader().querySelectorAll('th')[n];
|
||||
}
|
||||
|
||||
function onFilterInput() {
|
||||
const searchValue = document.getElementById('fileSearch').value;
|
||||
const rows = document.getElementsByTagName('tbody')[0].children;
|
||||
|
||||
// Try to create a RegExp from the searchValue. If it fails (invalid regex),
|
||||
// it will be treated as a plain text search
|
||||
let searchRegex;
|
||||
try {
|
||||
searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
|
||||
} catch (error) {
|
||||
searchRegex = null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
let isMatch = false;
|
||||
|
||||
if (searchRegex) {
|
||||
// If a valid regex was created, use it for matching
|
||||
isMatch = searchRegex.test(row.textContent);
|
||||
} else {
|
||||
// Otherwise, fall back to the original plain text search
|
||||
isMatch = row.textContent
|
||||
.toLowerCase()
|
||||
.includes(searchValue.toLowerCase());
|
||||
}
|
||||
|
||||
row.style.display = isMatch ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// loads the search box
|
||||
function addSearchBox() {
|
||||
var template = document.getElementById('filterTemplate');
|
||||
var templateClone = template.content.cloneNode(true);
|
||||
templateClone.getElementById('fileSearch').oninput = onFilterInput;
|
||||
template.parentElement.appendChild(templateClone);
|
||||
}
|
||||
|
||||
// loads all columns
|
||||
function loadColumns() {
|
||||
var colNodes = getTableHeader().querySelectorAll('th'),
|
||||
colNode,
|
||||
cols = [],
|
||||
col,
|
||||
i;
|
||||
|
||||
for (i = 0; i < colNodes.length; i += 1) {
|
||||
colNode = colNodes[i];
|
||||
col = {
|
||||
key: colNode.getAttribute('data-col'),
|
||||
sortable: !colNode.getAttribute('data-nosort'),
|
||||
type: colNode.getAttribute('data-type') || 'string'
|
||||
};
|
||||
cols.push(col);
|
||||
if (col.sortable) {
|
||||
col.defaultDescSort = col.type === 'number';
|
||||
colNode.innerHTML =
|
||||
colNode.innerHTML + '<span class="sorter"></span>';
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
// attaches a data attribute to every tr element with an object
|
||||
// of data values keyed by column name
|
||||
function loadRowData(tableRow) {
|
||||
var tableCols = tableRow.querySelectorAll('td'),
|
||||
colNode,
|
||||
col,
|
||||
data = {},
|
||||
i,
|
||||
val;
|
||||
for (i = 0; i < tableCols.length; i += 1) {
|
||||
colNode = tableCols[i];
|
||||
col = cols[i];
|
||||
val = colNode.getAttribute('data-value');
|
||||
if (col.type === 'number') {
|
||||
val = Number(val);
|
||||
}
|
||||
data[col.key] = val;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
// loads all row data
|
||||
function loadData() {
|
||||
var rows = getTableBody().querySelectorAll('tr'),
|
||||
i;
|
||||
|
||||
for (i = 0; i < rows.length; i += 1) {
|
||||
rows[i].data = loadRowData(rows[i]);
|
||||
}
|
||||
}
|
||||
// sorts the table using the data for the ith column
|
||||
function sortByIndex(index, desc) {
|
||||
var key = cols[index].key,
|
||||
sorter = function(a, b) {
|
||||
a = a.data[key];
|
||||
b = b.data[key];
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
},
|
||||
finalSorter = sorter,
|
||||
tableBody = document.querySelector('.coverage-summary tbody'),
|
||||
rowNodes = tableBody.querySelectorAll('tr'),
|
||||
rows = [],
|
||||
i;
|
||||
|
||||
if (desc) {
|
||||
finalSorter = function(a, b) {
|
||||
return -1 * sorter(a, b);
|
||||
};
|
||||
}
|
||||
|
||||
for (i = 0; i < rowNodes.length; i += 1) {
|
||||
rows.push(rowNodes[i]);
|
||||
tableBody.removeChild(rowNodes[i]);
|
||||
}
|
||||
|
||||
rows.sort(finalSorter);
|
||||
|
||||
for (i = 0; i < rows.length; i += 1) {
|
||||
tableBody.appendChild(rows[i]);
|
||||
}
|
||||
}
|
||||
// removes sort indicators for current column being sorted
|
||||
function removeSortIndicators() {
|
||||
var col = getNthColumn(currentSort.index),
|
||||
cls = col.className;
|
||||
|
||||
cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
|
||||
col.className = cls;
|
||||
}
|
||||
// adds sort indicators for current column being sorted
|
||||
function addSortIndicators() {
|
||||
getNthColumn(currentSort.index).className += currentSort.desc
|
||||
? ' sorted-desc'
|
||||
: ' sorted';
|
||||
}
|
||||
// adds event listeners for all sorter widgets
|
||||
function enableUI() {
|
||||
var i,
|
||||
el,
|
||||
ithSorter = function ithSorter(i) {
|
||||
var col = cols[i];
|
||||
|
||||
return function() {
|
||||
var desc = col.defaultDescSort;
|
||||
|
||||
if (currentSort.index === i) {
|
||||
desc = !currentSort.desc;
|
||||
}
|
||||
sortByIndex(i, desc);
|
||||
removeSortIndicators();
|
||||
currentSort.index = i;
|
||||
currentSort.desc = desc;
|
||||
addSortIndicators();
|
||||
};
|
||||
};
|
||||
for (i = 0; i < cols.length; i += 1) {
|
||||
if (cols[i].sortable) {
|
||||
// add the click event handler on the th so users
|
||||
// dont have to click on those tiny arrows
|
||||
el = getNthColumn(i).querySelector('.sorter').parentElement;
|
||||
if (el.addEventListener) {
|
||||
el.addEventListener('click', ithSorter(i));
|
||||
} else {
|
||||
el.attachEvent('onclick', ithSorter(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// adds sorting functionality to the UI
|
||||
return function() {
|
||||
if (!getTable()) {
|
||||
return;
|
||||
}
|
||||
cols = loadColumns();
|
||||
loadData();
|
||||
addSearchBox();
|
||||
addSortIndicators();
|
||||
enableUI();
|
||||
};
|
||||
})();
|
||||
|
||||
window.addEventListener('load', addSorting);
|
||||
@@ -0,0 +1,38 @@
|
||||
TN:
|
||||
SF:src/features/reference-feature/adapters/reference-http-gateway.ts
|
||||
FN:37,mapProblem
|
||||
FN:64,createReferenceHttpGateway
|
||||
FN:68,list
|
||||
FN:71,create
|
||||
FN:74,get
|
||||
FNF:5
|
||||
FNH:5
|
||||
FNDA:5,mapProblem
|
||||
FNDA:8,createReferenceHttpGateway
|
||||
FNDA:1,list
|
||||
FNDA:6,create
|
||||
FNDA:3,get
|
||||
DA:25,2
|
||||
DA:27,2
|
||||
DA:38,5
|
||||
DA:39,2
|
||||
DA:44,3
|
||||
DA:45,2
|
||||
DA:50,1
|
||||
DA:67,8
|
||||
DA:69,1
|
||||
DA:72,6
|
||||
DA:75,3
|
||||
LF:11
|
||||
LH:11
|
||||
BRDA:38,0,0,2
|
||||
BRDA:38,0,1,3
|
||||
BRDA:41,1,0,2
|
||||
BRDA:41,1,1,1
|
||||
BRDA:44,2,0,2
|
||||
BRDA:44,2,1,1
|
||||
BRDA:47,3,0,2
|
||||
BRDA:47,3,1,1
|
||||
BRF:8
|
||||
BRH:8
|
||||
end_of_record
|
||||
+141
-4
@@ -81,6 +81,55 @@
|
||||
"script": "check:types:fixture:reference-operation",
|
||||
"expect": "fail",
|
||||
"expectedExitCode": 1,
|
||||
"expectedDiagnosticId": "error TS2741:"
|
||||
},
|
||||
{
|
||||
"id": "check-types-fixture-http-operation-input",
|
||||
"script": "check:types:fixture:http-operation-input",
|
||||
"expect": "fail",
|
||||
"expectedExitCode": 1,
|
||||
"expectedDiagnosticId": "error TS2353:"
|
||||
},
|
||||
{
|
||||
"id": "check-types-fixture-http-wire-mapper",
|
||||
"script": "check:types:fixture:http-wire-mapper",
|
||||
"expect": "fail",
|
||||
"expectedExitCode": 1,
|
||||
"expectedDiagnosticId": "error TS2322:"
|
||||
},
|
||||
{
|
||||
"id": "check-types-fixture-http-operation-id",
|
||||
"script": "check:types:fixture:http-operation-id",
|
||||
"expect": "fail",
|
||||
"expectedExitCode": 1,
|
||||
"expectedDiagnosticId": "error TS2345:"
|
||||
},
|
||||
{
|
||||
"id": "check-types-fixture-http-route-id",
|
||||
"script": "check:types:fixture:http-route-id",
|
||||
"expect": "fail",
|
||||
"expectedExitCode": 1,
|
||||
"expectedDiagnosticId": "error TS2322:"
|
||||
},
|
||||
{
|
||||
"id": "check-types-fixture-feature-contribution-input",
|
||||
"script": "check:types:fixture:feature-contribution-input",
|
||||
"expect": "fail",
|
||||
"expectedExitCode": 1,
|
||||
"expectedDiagnosticId": "error TS2322:"
|
||||
},
|
||||
{
|
||||
"id": "check-types-fixture-feature-capability-selection",
|
||||
"script": "check:types:fixture:feature-capability-selection",
|
||||
"expect": "fail",
|
||||
"expectedExitCode": 1,
|
||||
"expectedDiagnosticId": "error TS2339:"
|
||||
},
|
||||
{
|
||||
"id": "check-types-fixture-direct-feature-composition",
|
||||
"script": "check:types:fixture:direct-feature-composition",
|
||||
"expect": "fail",
|
||||
"expectedExitCode": 1,
|
||||
"expectedDiagnosticId": "error TS2345:"
|
||||
},
|
||||
{
|
||||
@@ -95,7 +144,7 @@
|
||||
"script": "check:types:fixture:route-runtime",
|
||||
"expect": "fail",
|
||||
"expectedExitCode": 1,
|
||||
"expectedDiagnosticId": "error TS2353:"
|
||||
"expectedDiagnosticId": "error TS2740:"
|
||||
},
|
||||
{
|
||||
"id": "check-types-fixture-page-action",
|
||||
@@ -142,6 +191,11 @@
|
||||
"script": "test:unit",
|
||||
"expect": "pass"
|
||||
},
|
||||
{
|
||||
"id": "test-contract",
|
||||
"script": "test:contract",
|
||||
"expect": "pass"
|
||||
},
|
||||
{
|
||||
"id": "test-coverage",
|
||||
"script": "test:coverage",
|
||||
@@ -164,6 +218,11 @@
|
||||
"script": "test:integration",
|
||||
"expect": "pass"
|
||||
},
|
||||
{
|
||||
"id": "test-system",
|
||||
"script": "test:system",
|
||||
"expect": "pass"
|
||||
},
|
||||
{
|
||||
"id": "test-http-scenario-evidence",
|
||||
"script": "test:http-scenario-evidence",
|
||||
@@ -472,6 +531,11 @@
|
||||
"id": "check-ci",
|
||||
"script": "check:ci",
|
||||
"expect": "pass"
|
||||
},
|
||||
{
|
||||
"id": "check-release-admission",
|
||||
"script": "check:release-admission",
|
||||
"expect": "pass"
|
||||
}
|
||||
],
|
||||
"artifactSchemas": [
|
||||
@@ -732,6 +796,12 @@
|
||||
"id": "sarif-secret-scan",
|
||||
"kind": "sarif",
|
||||
"maxBytes": 67108864
|
||||
},
|
||||
{
|
||||
"id": "json-deployment-admission",
|
||||
"kind": "json",
|
||||
"maxBytes": 67108864,
|
||||
"executableSchemaId": "deployment-admission"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
@@ -783,6 +853,15 @@
|
||||
"test-unit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-contract-xml",
|
||||
"path": "artifacts/tests/contract.xml",
|
||||
"schemaId": "junit",
|
||||
"production": "command-generated",
|
||||
"producerCommandIds": [
|
||||
"test-contract"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-coverage-xml",
|
||||
"path": "artifacts/tests/coverage.xml",
|
||||
@@ -849,6 +928,15 @@
|
||||
"test-integration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-system-xml",
|
||||
"path": "artifacts/tests/system.xml",
|
||||
"schemaId": "junit",
|
||||
"production": "command-generated",
|
||||
"producerCommandIds": [
|
||||
"test-system"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-http-scenario-executions-json",
|
||||
"path": "artifacts/tests/http-scenario-executions.json",
|
||||
@@ -1602,6 +1690,21 @@
|
||||
"producerCommandIds": [
|
||||
"check-ci"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-release-deployment-admission-json",
|
||||
"path": "artifacts/release/deployment-admission.json",
|
||||
"schemaId": "json-deployment-admission",
|
||||
"production": "command-generated",
|
||||
"producerCommandIds": [
|
||||
"check-release-admission"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-quality-gates-FE-GATE-027-txt",
|
||||
"path": "artifacts/quality/gates/FE-GATE-027.txt",
|
||||
"schemaId": "text",
|
||||
"production": "runner-generated"
|
||||
}
|
||||
],
|
||||
"gates": [
|
||||
@@ -1644,6 +1747,13 @@
|
||||
"check-types-fixture-feature-input",
|
||||
"check-types-fixture-failure-kind",
|
||||
"check-types-fixture-reference-operation",
|
||||
"check-types-fixture-http-operation-input",
|
||||
"check-types-fixture-http-wire-mapper",
|
||||
"check-types-fixture-http-operation-id",
|
||||
"check-types-fixture-http-route-id",
|
||||
"check-types-fixture-feature-contribution-input",
|
||||
"check-types-fixture-feature-capability-selection",
|
||||
"check-types-fixture-direct-feature-composition",
|
||||
"check-types-fixture-async-overlay",
|
||||
"check-types-fixture-route-runtime",
|
||||
"check-types-fixture-page-action",
|
||||
@@ -1675,12 +1785,14 @@
|
||||
"name": "unit",
|
||||
"commandIds": [
|
||||
"test-unit",
|
||||
"test-contract",
|
||||
"test-coverage",
|
||||
"check-coverage-fixture"
|
||||
],
|
||||
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-005-txt",
|
||||
"evidenceArtifactIds": [
|
||||
"artifact-artifacts-tests-unit-xml",
|
||||
"artifact-artifacts-tests-contract-xml",
|
||||
"artifact-artifacts-tests-coverage-xml",
|
||||
"artifact-artifacts-tests-coverage-coverage-summary-json",
|
||||
"artifact-artifacts-quality-risk-coverage-json",
|
||||
@@ -1845,6 +1957,7 @@
|
||||
"id": "FE-GATE-013",
|
||||
"name": "security",
|
||||
"commandIds": [
|
||||
"test-system",
|
||||
"verify-reproducible-build",
|
||||
"build-release-candidate",
|
||||
"verify-local-evidence",
|
||||
@@ -1855,6 +1968,7 @@
|
||||
],
|
||||
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-013-txt",
|
||||
"evidenceArtifactIds": [
|
||||
"artifact-artifacts-tests-system-xml",
|
||||
"artifact-artifacts-security-scan-sarif",
|
||||
"artifact-artifacts-release-dependency-inventory-json",
|
||||
"artifact-artifacts-release-sbom-cdx-json",
|
||||
@@ -2049,6 +2163,18 @@
|
||||
"artifact-artifacts-performance-lab-json"
|
||||
],
|
||||
"retentionClassId": "release-coherence"
|
||||
},
|
||||
{
|
||||
"id": "FE-GATE-027",
|
||||
"name": "release-admission",
|
||||
"commandIds": [
|
||||
"check-release-admission"
|
||||
],
|
||||
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-027-txt",
|
||||
"evidenceArtifactIds": [
|
||||
"artifact-artifacts-release-deployment-admission-json"
|
||||
],
|
||||
"retentionClassId": "release-coherence"
|
||||
}
|
||||
],
|
||||
"stages": [
|
||||
@@ -2083,7 +2209,8 @@
|
||||
"FE-GATE-014",
|
||||
"FE-GATE-015",
|
||||
"FE-GATE-019",
|
||||
"FE-GATE-026"
|
||||
"FE-GATE-026",
|
||||
"FE-GATE-027"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -2236,10 +2363,20 @@
|
||||
"condition": "release",
|
||||
"timeoutMinutes": 45,
|
||||
"gateIds": [
|
||||
"FE-GATE-015"
|
||||
"FE-GATE-015",
|
||||
"FE-GATE-027"
|
||||
],
|
||||
"browserGateIds": [],
|
||||
"environment": [],
|
||||
"environment": [
|
||||
{
|
||||
"name": "APP_PROFILE",
|
||||
"value": "${{ vars.APP_PROFILE }}"
|
||||
},
|
||||
{
|
||||
"name": "RELEASE_TARGET",
|
||||
"value": "${{ vars.RELEASE_TARGET }}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"kind": "checkout"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"APP_ENV": "development",
|
||||
"API_BASE_URL": "https://api.dev.example.com/",
|
||||
"REQUEST_TIMEOUT_MS": 15000,
|
||||
"MAX_RETRY_ATTEMPTS": 2,
|
||||
"TELEMETRY_ENABLED": false,
|
||||
"AUTH_MODE": "external",
|
||||
"CONFIG_SCHEMA_VERSION": "2.0",
|
||||
"RELEASE_MANIFEST_URL": "/release-manifest.json",
|
||||
"CAPABILITY_OVERRIDES": {
|
||||
"REALTIME": "DEFAULT",
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"APP_ENV": "local",
|
||||
"API_BASE_URL": "http://localhost:8080/",
|
||||
"REQUEST_TIMEOUT_MS": 10000,
|
||||
"MAX_RETRY_ATTEMPTS": 2,
|
||||
"TELEMETRY_ENABLED": false,
|
||||
"AUTH_MODE": "demo",
|
||||
"CONFIG_SCHEMA_VERSION": "2.0",
|
||||
"RELEASE_MANIFEST_URL": "/release-manifest.json",
|
||||
"CAPABILITY_OVERRIDES": {
|
||||
"REALTIME": "DEFAULT",
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"APP_ENV": "production",
|
||||
"API_BASE_URL": "https://api.example.com/",
|
||||
"REQUEST_TIMEOUT_MS": 10000,
|
||||
"MAX_RETRY_ATTEMPTS": 2,
|
||||
"TELEMETRY_ENABLED": true,
|
||||
"TELEMETRY_ENDPOINT": "https://telemetry.example.com/v1/events",
|
||||
"AUTH_MODE": "external",
|
||||
"CONFIG_SCHEMA_VERSION": "2.0",
|
||||
"RELEASE_MANIFEST_URL": "/release-manifest.json",
|
||||
"CAPABILITY_OVERRIDES": {
|
||||
"REALTIME": "DEFAULT",
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"APP_ENV": "staging",
|
||||
"API_BASE_URL": "https://api.staging.example.com/",
|
||||
"REQUEST_TIMEOUT_MS": 10000,
|
||||
"MAX_RETRY_ATTEMPTS": 2,
|
||||
"TELEMETRY_ENABLED": true,
|
||||
"TELEMETRY_ENDPOINT": "https://telemetry.staging.example.com/v1/events",
|
||||
"AUTH_MODE": "external",
|
||||
"CONFIG_SCHEMA_VERSION": "2.0",
|
||||
"RELEASE_MANIFEST_URL": "/release-manifest.json",
|
||||
"CAPABILITY_OVERRIDES": {
|
||||
"REALTIME": "DEFAULT",
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
@@ -12,97 +12,192 @@
|
||||
{
|
||||
"path": "src/adapters/http/bounded-body-reader.ts",
|
||||
"owner": "http-runtime",
|
||||
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 90 }
|
||||
"minimum": {
|
||||
"lines": 95,
|
||||
"statements": 95,
|
||||
"functions": 95,
|
||||
"branches": 90
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/http/bounded-json.ts",
|
||||
"owner": "http-runtime",
|
||||
"minimum": { "lines": 85, "statements": 84, "functions": 95, "branches": 78 }
|
||||
"minimum": {
|
||||
"lines": 85,
|
||||
"statements": 84,
|
||||
"functions": 95,
|
||||
"branches": 78
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/http/http-execution-v3.ts",
|
||||
"owner": "http-runtime",
|
||||
"minimum": { "lines": 75, "statements": 73, "functions": 70, "branches": 52 }
|
||||
"minimum": {
|
||||
"lines": 75,
|
||||
"statements": 73,
|
||||
"functions": 70,
|
||||
"branches": 52
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/http/request-builder.ts",
|
||||
"owner": "http-runtime",
|
||||
"minimum": { "lines": 85, "statements": 85, "functions": 95, "branches": 82 }
|
||||
"minimum": {
|
||||
"lines": 85,
|
||||
"statements": 85,
|
||||
"functions": 95,
|
||||
"branches": 82
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/http/retry-policy.ts",
|
||||
"owner": "http-runtime",
|
||||
"minimum": { "lines": 80, "statements": 78, "functions": 95, "branches": 78 }
|
||||
"minimum": {
|
||||
"lines": 80,
|
||||
"statements": 78,
|
||||
"functions": 95,
|
||||
"branches": 78
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/query-cache/server-state-scope-runtime.ts",
|
||||
"owner": "server-state-runtime",
|
||||
"minimum": { "lines": 85, "statements": 85, "functions": 85, "branches": 75 }
|
||||
"minimum": {
|
||||
"lines": 85,
|
||||
"statements": 85,
|
||||
"functions": 85,
|
||||
"branches": 75
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/service-worker/service-worker-lifecycle.ts",
|
||||
"owner": "service-worker-runtime",
|
||||
"minimum": { "lines": 64, "statements": 60, "functions": 65, "branches": 43 }
|
||||
"minimum": {
|
||||
"lines": 64,
|
||||
"statements": 60,
|
||||
"functions": 65,
|
||||
"branches": 43
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/storage/browser-storage-adapter.ts",
|
||||
"owner": "storage-runtime",
|
||||
"minimum": { "lines": 60, "statements": 60, "functions": 70, "branches": 60 }
|
||||
"minimum": {
|
||||
"lines": 60,
|
||||
"statements": 60,
|
||||
"functions": 70,
|
||||
"branches": 60
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/adapters/telemetry/best-effort-telemetry.ts",
|
||||
"owner": "telemetry-runtime",
|
||||
"minimum": { "lines": 85, "statements": 85, "functions": 70, "branches": 75 }
|
||||
"minimum": {
|
||||
"lines": 85,
|
||||
"statements": 85,
|
||||
"functions": 70,
|
||||
"branches": 75
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/application/create-application.ts",
|
||||
"owner": "application-runtime",
|
||||
"minimum": { "lines": 90, "statements": 90, "functions": 80, "branches": 68 }
|
||||
"minimum": {
|
||||
"lines": 90,
|
||||
"statements": 90,
|
||||
"functions": 80,
|
||||
"branches": 68
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/application/policies/compatibility.ts",
|
||||
"owner": "application-policy",
|
||||
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 75 }
|
||||
"path": "src/contracts/compatibility.ts",
|
||||
"owner": "compatibility-contracts",
|
||||
"minimum": {
|
||||
"lines": 95,
|
||||
"statements": 95,
|
||||
"functions": 95,
|
||||
"branches": 75
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/application/policies/performance-budgets.ts",
|
||||
"owner": "application-policy",
|
||||
"minimum": { "lines": 80, "statements": 80, "functions": 80, "branches": 40 }
|
||||
"minimum": {
|
||||
"lines": 80,
|
||||
"statements": 80,
|
||||
"functions": 80,
|
||||
"branches": 40
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/application/policies/promotion-readiness.ts",
|
||||
"owner": "release-runtime",
|
||||
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 95 }
|
||||
"minimum": {
|
||||
"lines": 95,
|
||||
"statements": 95,
|
||||
"functions": 95,
|
||||
"branches": 95
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/application/use-cases/decide-chunk-recovery.ts",
|
||||
"owner": "application-runtime",
|
||||
"minimum": { "lines": 90, "statements": 90, "functions": 95, "branches": 85 }
|
||||
"minimum": {
|
||||
"lines": 90,
|
||||
"statements": 90,
|
||||
"functions": 95,
|
||||
"branches": 85
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/bootstrap/load-release-manifest.ts",
|
||||
"owner": "release-runtime",
|
||||
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
|
||||
"minimum": {
|
||||
"lines": 90,
|
||||
"statements": 90,
|
||||
"functions": 90,
|
||||
"branches": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/bootstrap/read-bounded-boot-json.ts",
|
||||
"owner": "bootstrap-runtime",
|
||||
"minimum": { "lines": 71, "statements": 66, "functions": 48, "branches": 57 }
|
||||
"minimum": {
|
||||
"lines": 71,
|
||||
"statements": 66,
|
||||
"functions": 48,
|
||||
"branches": 57
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/contracts/diagnostics.ts",
|
||||
"owner": "diagnostics-contracts",
|
||||
"minimum": { "lines": 68, "statements": 68, "functions": 95, "branches": 58 }
|
||||
"minimum": {
|
||||
"lines": 68,
|
||||
"statements": 68,
|
||||
"functions": 95,
|
||||
"branches": 58
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/features/reference-feature/adapters/reference-http-gateway.ts",
|
||||
"owner": "reference-feature",
|
||||
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 90 }
|
||||
"minimum": {
|
||||
"lines": 90,
|
||||
"statements": 90,
|
||||
"functions": 90,
|
||||
"branches": 90
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/presentation/adapters/query/application-query.ts",
|
||||
"owner": "presentation-runtime",
|
||||
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
|
||||
"minimum": {
|
||||
"lines": 90,
|
||||
"statements": 90,
|
||||
"functions": 90,
|
||||
"branches": 80
|
||||
}
|
||||
}
|
||||
],
|
||||
"highRiskPaths": [
|
||||
@@ -116,7 +211,7 @@
|
||||
"src/adapters/storage/browser-storage-adapter.ts",
|
||||
"src/adapters/telemetry/best-effort-telemetry.ts",
|
||||
"src/application/create-application.ts",
|
||||
"src/application/policies/compatibility.ts",
|
||||
"src/contracts/compatibility.ts",
|
||||
"src/application/policies/performance-budgets.ts",
|
||||
"src/application/policies/promotion-readiness.ts",
|
||||
"src/application/use-cases/decide-chunk-recovery.ts",
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
# Manual accessibility review checklist
|
||||
|
||||
Automated axe checks do not establish WCAG conformance. A human reviewer must
|
||||
review all six route records in `artifacts/tests/a11y-manual/` against one
|
||||
review all ten route records in `artifacts/tests/a11y-manual/` against one
|
||||
release candidate and sign them. The required scope is derived from the route
|
||||
registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`,
|
||||
`REFERENCE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the
|
||||
gate rejects blank identity/timestamp/signature fields, pending verdicts,
|
||||
mismatched release IDs, or missing routes.
|
||||
registry: `APP_HOME`, `EXAMPLES_PLATFORM`, `EXAMPLES_UI`, `EXAMPLES_STATES`,
|
||||
`EXAMPLES_AUTH`, `NOT_FOUND`, `REFERENCE_RESOURCE_LIST`,
|
||||
`REFERENCE_RESOURCE_DETAIL`, `REFERENCE_RESOURCE_FORM` and
|
||||
`REFERENCE_RESOURCE_STATUS`. Copy the template fields exactly; the gate rejects
|
||||
blank identity/timestamp/signature fields, pending verdicts, mismatched release
|
||||
IDs, or missing routes.
|
||||
|
||||
This list is not maintained by hand: `verify:documentation` compares it against
|
||||
the installed route registry and fails when a registered route is absent. It
|
||||
said six routes while ten were registered, which put the platform overview and
|
||||
the three reference-resource screens outside the declared manual review scope
|
||||
without anyone deciding they should be.
|
||||
|
||||
Allowed item verdicts:
|
||||
|
||||
|
||||
@@ -247,6 +247,8 @@ domain, application state, query cache, global store, diagnostics에 넣지 않
|
||||
### 3.2 picker baseline과 enhancement
|
||||
|
||||
- 접근 가능한 `<input type="file">`가 모든 browser의 canonical baseline이다.
|
||||
baseline adapter는 `input.click()`으로 이 경로를 활성화한다. `showPicker()`
|
||||
존재 여부는 portable baseline capability의 판정 기준으로 사용하지 않는다.
|
||||
- `showOpenFilePicker()`와 `showSaveFilePicker()`는 runtime method별 feature
|
||||
detection을 거친 progressive enhancement다. UA sniffing을 사용하지 않는다.
|
||||
- picker 호출은 click/keyboard handler의 첫 browser action이어야 한다. 그 전에
|
||||
@@ -987,7 +989,7 @@ recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기
|
||||
| `PERMISSION_DENIED` | picker/save permission | baseline/manual fallback |
|
||||
| `LIMIT_EXCEEDED` | count/bytes/page/buffer budget | 입력 축소 |
|
||||
| `POLICY_REJECTED` | type/cache/data policy | 저장·전송 금지 |
|
||||
| `BLOCKED` | IndexedDB older context | 다른 탭 close/retry UI |
|
||||
| `BLOCKED` | IndexedDB older context가 live open/upgrade를 막는 중 | 다른 탭 close/retry UI |
|
||||
| `CONFLICT` | revision/generation/idempotency conflict | authoritative re-read |
|
||||
| `MIGRATION_FAILED` | schema/data migration | read-only/online-only |
|
||||
| `QUOTA_EXCEEDED` | actual write failure | rollback, reconstructable GC, bounded retry |
|
||||
@@ -999,6 +1001,12 @@ recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기
|
||||
| `UNAVAILABLE` | browser/worker/storage temporarily unavailable | documented fallback |
|
||||
| `UNSUPPORTED` | capability absence | baseline/online-only |
|
||||
|
||||
`IndexedDbConnectionStatus.BLOCKED`는 대기 중인 live attempt의 observable
|
||||
상태다. blocked deadline이 끝나 `open()`이 `BLOCKED` failure로 settle되면
|
||||
그 attempt는 더 이상 열리는 중이 아니므로 status는
|
||||
`CLOSED / NOT_OPENED`로 전이한다. 늦게 성공한 native connection은 즉시 닫고
|
||||
settled status를 되살리지 않는다.
|
||||
|
||||
user dismissal은 failure가 아니라 outcome이다. browser DOMException name은
|
||||
adapter에서 이 vocabulary로 mapping하고 raw message/stack은 local bounded
|
||||
diagnostic에도 기본 저장하지 않는다.
|
||||
@@ -1085,18 +1093,17 @@ Playwright Chromium, Firefox, WebKit에서 실제 secure-origin API를 검사한
|
||||
`test:browser-capabilities`의 JUnit을
|
||||
`verify:browser-capability-evidence`가 읽어 세 engine의 testcase 집합 동일성,
|
||||
양수 실행 수, zero failure/error/skipped와 skipped/failure node 부재를 강제한다.
|
||||
현재 checkout의 source suite는 engine마다 정확히 같은 14개 case(File 2,
|
||||
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2,
|
||||
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다.
|
||||
promotion artifact는 Chromium/Firefox/WebKit의 14개씩, 총 42개가 모두
|
||||
실행되어야 한다. 이 host의
|
||||
WebKit은 필수 native libraries(예:
|
||||
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
|
||||
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았고 현재 보존 artifact도
|
||||
Chromium/Firefox 14개씩 총 28개만 통과한 상태다. 따라서 promotion evidence를
|
||||
충족하지 않으며 verifier가 실패하는 것이 정상이다. `INSTALLED` 전에는 필요한
|
||||
system dependency가 있는 CI/device에서 세 engine 전체 evidence를 새로 생성해야
|
||||
한다.
|
||||
browser capability promotion은 source suite에서 관찰된 동일 testcase set을
|
||||
Chromium/Firefox/WebKit에서 모두 실행하는 규칙으로 관리한다. case 수를 문서
|
||||
상수로 복제하지 않고 `verify:browser-capability-evidence`가 Chromium 결과를
|
||||
baseline set으로 계산해 Firefox/WebKit의 set 동일성, 양수 실행 수,
|
||||
failure/error/skipped 0과 failure/skipped node 부재를 검증한다.
|
||||
|
||||
capability absence도 skip 사유가 아니다. 예를 들어 어떤 engine에서
|
||||
`navigator.storage`가 없다면 동일 testcase 안에서 adapter의
|
||||
`UNSUPPORTED / STORAGE_ESTIMATE / ONLINE_ONLY` 결과를 browser truth로
|
||||
검증한다. 세 engine을 실행할 수 없는 host의 artifact는 promotion evidence로
|
||||
사용하지 않는다.
|
||||
|
||||
- native input keyboard/focus/same-file reselection/multiple/dismissal
|
||||
- Chromium conditional picker/save enhancement와 다른 engine fallback
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Capability consumer experience baseline
|
||||
|
||||
Correctness inside a reusable capability is not sufficient. The platform is
|
||||
also evaluated by how much of that correctness a normal feature developer must
|
||||
understand.
|
||||
|
||||
These measurements are baselines, not score targets. A lower line count is not
|
||||
automatically better if it hides business semantics or creates a universal
|
||||
repository abstraction.
|
||||
|
||||
## Scenario A — reference REST feature
|
||||
|
||||
The existing reference feature is the executable REST consumer.
|
||||
|
||||
It covers list/detail/create behavior, mapping, typed operation inputs,
|
||||
application ports and an optimistic mutation path.
|
||||
|
||||
Current source size:
|
||||
|
||||
| Feature-owned area | LOC |
|
||||
| --- | ---: |
|
||||
| `application/reference-feature-api.ts` | 91 |
|
||||
| `contracts/reference-mapper.ts` | 109 |
|
||||
| `adapters/reference-http-gateway.ts` | 65 |
|
||||
| `adapters/create-reference-feature-input.ts` | 41 |
|
||||
| Total measured boundary/application source | 306 |
|
||||
|
||||
The important boundary metric is not the raw total. It is what those files need
|
||||
to know about the platform.
|
||||
|
||||
Current result:
|
||||
|
||||
- HTTP platform imports in the feature adapter layer: 2 files,
|
||||
- canonical import path used by both:
|
||||
`src/adapters/http/index.ts`,
|
||||
- direct imports of `http-execution-v3.ts`, retry scheduler, response reader,
|
||||
auth admission or effect-certainty internals: 0,
|
||||
- feature gateway owns transport failure projection: 0,
|
||||
- feature gateway chooses operation ID, route ID, exact input/value type and
|
||||
mapper: yes,
|
||||
- central installed files own feature-specific adapter/runtime wiring: no;
|
||||
feature-owned contributions are aggregated centrally.
|
||||
|
||||
The reusable HTTP capability now owns execution-outcome normalization through
|
||||
`createFeatureHttpBinding`. A feature may still implement a custom outbound
|
||||
adapter behind its application port when the reusable contract does not match
|
||||
its business requirement.
|
||||
|
||||
## Scenario B — IndexedDB local draft
|
||||
|
||||
The original executable consumer probe remains at:
|
||||
|
||||
`tests/contract/consumer-experience/indexeddb-local-draft.test.ts`
|
||||
|
||||
with its isolated fixture:
|
||||
|
||||
`tests/contract/consumer-experience/fixtures/local-draft-feature.ts`
|
||||
|
||||
The architecture is now also exercised by a real vertical slice under:
|
||||
|
||||
`src/features/local-draft-feature`
|
||||
|
||||
It owns the Local Draft domain/application API and binds an
|
||||
`IndexedDbRepositoryPort<LocalDraft, never>` in its feature adapter. The
|
||||
cross-capability composition contract lives at:
|
||||
|
||||
`tests/contract/reusable-capability/feature-adapter-composition.test.ts`
|
||||
|
||||
That test composes one HTTP-only contribution and the IndexedDB-only Local Draft
|
||||
contribution through the same generic catalog path.
|
||||
|
||||
The browser-level composition proof lives at:
|
||||
|
||||
`tests/browser-capabilities/local-draft-composition.spec.ts`
|
||||
|
||||
It runs against native browser IndexedDB. The test creates the platform
|
||||
`createIndexedDbRuntime`, exposes that runtime through the typed
|
||||
`createIndexedDbRepositoryProvider`, composes
|
||||
`LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION`, then executes Local Draft
|
||||
save/find/remove through the feature API. The Local Draft feature still does
|
||||
not import the IndexedDB runtime or native browser API.
|
||||
|
||||
Measured probe result:
|
||||
|
||||
| Metric | Result |
|
||||
| --- | ---: |
|
||||
| feature-owned fixture LOC | 97 |
|
||||
| platform import statements | 1 |
|
||||
| native IndexedDB API references | 0 |
|
||||
| `src/adapters/storage/indexeddb/**` imports | 0 |
|
||||
| runtime-internal IndexedDB types imported by feature | 0 |
|
||||
|
||||
The feature depends only on the public application boundary:
|
||||
|
||||
`src/application/ports/browser-file-storage/index.ts`
|
||||
|
||||
and specifically `IndexedDbRepositoryPort<LocalDraft, never>`.
|
||||
|
||||
The contract test rejects feature source that reaches for
|
||||
`globalThis.indexedDB`, `IDBFactory`, `IDBDatabase`, `IDBTransaction`,
|
||||
`IDBObjectStore` or the concrete IndexedDB adapter directory.
|
||||
|
||||
## What this does and does not prove
|
||||
|
||||
The contract probe confirms that **feature business code does not need native
|
||||
IndexedDB knowledge** once an `IndexedDbRepositoryPort` has been composed.
|
||||
The browser-capability proof additionally confirms that the same feature
|
||||
contribution works when that port is backed by the repository's real
|
||||
`createIndexedDbRuntime` and native IndexedDB implementation.
|
||||
|
||||
It does not prove that composition of `createIndexedDbRuntime` is cheap.
|
||||
That constructor still owns substantial infrastructure policy:
|
||||
|
||||
- dataset scope and storage policy,
|
||||
- physical store governance,
|
||||
- retention/idempotency stores,
|
||||
- schema migrations,
|
||||
- codec and query policy,
|
||||
- lifecycle authority,
|
||||
- durability, scheduling and observation.
|
||||
|
||||
That complexity belongs at the composition/platform boundary, not in the
|
||||
feature. The Local Draft vertical slice is now the second concrete consumer, and
|
||||
it confirms that the stable seam is the typed repository provider plus a
|
||||
feature-owned repository identity. It does **not** show that dataset scope,
|
||||
retention, migration, codec or lifecycle-authority configuration can be safely
|
||||
collapsed into one universal `create...Repository<T>` factory.
|
||||
|
||||
A convenience profile should therefore be introduced only after another
|
||||
IndexedDB-backed product feature repeats the same infrastructure policy, not
|
||||
merely because two features consume the same repository port.
|
||||
|
||||
## Consumer-quality review checklist
|
||||
|
||||
For each new product feature, record:
|
||||
|
||||
- feature-owned adapter LOC,
|
||||
- platform glue LOC,
|
||||
- files changed,
|
||||
- central catalog edits,
|
||||
- direct imports from capability-internal modules,
|
||||
- native browser/network API references,
|
||||
- duplicated failure/retry/lifecycle policy.
|
||||
|
||||
A healthy feature path should look like:
|
||||
|
||||
1. domain type and invariant,
|
||||
2. use case,
|
||||
3. business port,
|
||||
4. feature-owned mapper/codec and policy,
|
||||
5. capability-specific binding,
|
||||
6. presentation controller/page.
|
||||
|
||||
The feature should not need the retry scheduler, abort ownership,
|
||||
effect-certainty machinery, IndexedDB transaction lifecycle, OPFS journal,
|
||||
reconnect coordinator or provider process model.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Contract ownership
|
||||
|
||||
`src/contracts` is not a default destination for every shared-looking type.
|
||||
A contract belongs there only when the change authority is genuinely shared
|
||||
across layers, capabilities, build tooling, or runtime composition.
|
||||
|
||||
## Decision rule
|
||||
|
||||
For every proposed contract, ask:
|
||||
|
||||
1. Which requirement can cause this type or policy to change?
|
||||
2. Is there one clear capability or feature owner?
|
||||
3. Does another runtime/tooling boundary consume the same semantic contract?
|
||||
4. Is the contract a wire/artifact authority shared by browser runtime and
|
||||
build/release tooling?
|
||||
|
||||
The placement rule is:
|
||||
|
||||
- one feature owner -> keep it under that feature,
|
||||
- one reusable capability owner -> keep it under that adapter/capability and
|
||||
export it through the capability public entry point,
|
||||
- multiple independent capability/layer owners -> `src/contracts`,
|
||||
- browser/build/release wire or artifact authority -> `src/contracts` even
|
||||
when the browser source graph alone looks small.
|
||||
|
||||
Consumer count alone is not sufficient. Scripts, generated artifacts and
|
||||
release gates are semantic consumers too.
|
||||
|
||||
## Audit result
|
||||
|
||||
The September 2026 architecture review triggered an import-graph audit of all
|
||||
39 contract files.
|
||||
|
||||
### Capability-owned contract moved
|
||||
|
||||
`cursor-pagination.ts` had one production owner:
|
||||
`src/adapters/query-cache/cursor-pagination-runtime.ts`.
|
||||
|
||||
It moved to:
|
||||
|
||||
`src/adapters/query-cache/cursor-pagination-contract.ts`
|
||||
|
||||
and is exported through:
|
||||
|
||||
`src/adapters/query-cache/index.ts`
|
||||
|
||||
Tests use that public capability entry point. Pagination vocabulary no longer
|
||||
occupies the global contract bucket merely because it is reusable inside one
|
||||
adapter.
|
||||
|
||||
### Contracts intentionally kept global
|
||||
|
||||
The following examples have multiple semantic owners and remain global:
|
||||
|
||||
- `errors.ts` — application, presentation and several adapters,
|
||||
- `result.ts` — common success/failure carrier below application,
|
||||
- `boundary-mapper.ts` — HTTP, browser RPC, realtime and feature registries,
|
||||
- `mutation-intent.ts` — application, presentation, HTTP, platform and
|
||||
bootstrap,
|
||||
- `exact-snapshot.ts` — HTTP, query-cache and browser-transfer,
|
||||
- `rest-profiles.ts` — auth, HTTP, bootstrap and feature contract validation,
|
||||
- `query-invalidation.ts` / `query-keys.ts` — query-cache, presentation,
|
||||
bootstrap and features,
|
||||
- `cache-invalidation.ts` — cross-context wire protocol plus query
|
||||
invalidation policy,
|
||||
- `storage-keys.ts` — browser storage and cross-context invalidation,
|
||||
- `telemetry.ts` / `diagnostics.ts` — runtime adapters, application and
|
||||
bootstrap.
|
||||
|
||||
Some contracts appear to have few browser-source consumers but are still shared
|
||||
authorities:
|
||||
|
||||
- `env.ts` is consumed by bootstrap, runtime-schema/security tests and
|
||||
registry governance,
|
||||
- `deployment-admission.ts` is shared by runtime-config generation and release
|
||||
admission,
|
||||
- `release-tokens.ts` is shared by runtime coherence tooling and tests,
|
||||
- `service-worker-static-manifest.ts` is a runtime-neutral canonical format
|
||||
shared by build generation, validation and service-worker evidence.
|
||||
|
||||
Moving those based only on `src/**` import counts would split one semantic
|
||||
authority across processes.
|
||||
|
||||
## Feature contracts
|
||||
|
||||
Feature-specific contracts stay inside the vertical slice:
|
||||
|
||||
`features/<feature>/contracts`
|
||||
|
||||
The reference feature owns routes, schemas, mapper definitions, message
|
||||
catalogs and contribution identities. Central installed files aggregate those
|
||||
contributions; they do not own their semantics.
|
||||
|
||||
The message catalog is a deliberate special case: the compiled catalog remains
|
||||
total even when a feature is build-time disabled so the typed message lookup
|
||||
does not become partial. The central message file therefore aggregates compiled
|
||||
message keys rather than treating runtime installation as message ownership.
|
||||
|
||||
## Review rule for future additions
|
||||
|
||||
A new file under `src/contracts` should be rejected during review when all of
|
||||
the following are true:
|
||||
|
||||
- one feature or one capability is the only semantic owner,
|
||||
- no build/release/runtime wire authority needs the same definition,
|
||||
- moving the definition to that owner does not create an inward dependency
|
||||
violation.
|
||||
|
||||
Do not move a contract merely to reduce the number 39. The goal is explicit
|
||||
ownership, not a smaller directory.
|
||||
@@ -189,17 +189,18 @@ chunk별 `CapabilityResult<Uint8Array>`를 반환한다. backend upload example
|
||||
실제 upload feature는 이 예시를 그대로 import하지 않고 purpose와 backend
|
||||
protocol에 맞게 contract를 더 좁힌다.
|
||||
|
||||
현재 checkout의 browser source suite는 engine마다 같은 14개 case(File 2,
|
||||
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2,
|
||||
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다.
|
||||
promotion artifact는 Chromium/Firefox/WebKit 각각 14개, 총 42개를 모두
|
||||
실행해야 한다. WebKit은 현재
|
||||
host의 필수 native libraries(예:
|
||||
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
|
||||
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았다. 보존 artifact는
|
||||
Chromium/Firefox 14개씩 총 28개만 통과했으므로
|
||||
`verify:browser-capability-evidence`가 실패하는 것이 정상이다. 세 engine
|
||||
evidence가 완성되기 전에는 product 상태를 `INSTALLED`로 올리지 않는다.
|
||||
browser capability promotion은 source suite가 정의한 동일 testcase set을
|
||||
Chromium/Firefox/WebKit에서 모두 실행해야 한다. 구체적인 case 개수는 이 결정
|
||||
문서에 고정하지 않는다. `verify:browser-capability-evidence`가 Chromium
|
||||
artifact에서 baseline set을 계산하고 Firefox/WebKit과의 set 동일성 및
|
||||
failure/error/skipped 0을 기계적으로 검증한다.
|
||||
|
||||
engine별 native capability 차이는 testcase를 skip하는 이유가 아니다. capability가
|
||||
없으면 adapter의 명시적 unsupported/degraded result를 같은 testcase에서
|
||||
검증한다. baseline `<input type="file">` activation은 portable한
|
||||
`input.click()` 경로를 사용하고, `showOpenFilePicker()` 같은 API는 별도
|
||||
progressive enhancement로 유지한다. 세 engine evidence가 완성되기 전에는
|
||||
product 상태를 `INSTALLED`로 올리지 않는다.
|
||||
|
||||
## 선택 이후 필요한 구현
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
# Frontend Application Foundation
|
||||
|
||||
This repository is not treated as a minimal React project template and it is not
|
||||
an independent general-purpose SDK. Its architectural role is:
|
||||
|
||||
> Frontend Application Foundation = Starter / Composition Skeleton + Reusable Capability Platform
|
||||
|
||||
The starter side owns bootstrap, routing, providers, project conventions and a
|
||||
removable reference feature. The capability side owns reusable technical
|
||||
problems such as HTTP execution, Server State, authentication boundaries,
|
||||
IndexedDB/OPFS, Cache Storage, realtime, browser RPC, transfer and diagnostics.
|
||||
|
||||
The cost of a sophisticated capability is acceptable only when product features
|
||||
do not have to understand that internal sophistication.
|
||||
|
||||
## Hybrid architecture
|
||||
|
||||
Platform code is horizontal:
|
||||
|
||||
- `application`: generic application inputs/policies/ports
|
||||
- `contracts`: genuinely cross-capability shared vocabulary and registries
|
||||
- `adapters`: reusable capability runtimes
|
||||
- `presentation`: generic UI/routing/query integration
|
||||
- `bootstrap`: concrete composition
|
||||
|
||||
Product business code is vertical:
|
||||
|
||||
- `features/<feature>/domain`
|
||||
- `features/<feature>/application`
|
||||
- `features/<feature>/contracts`
|
||||
- `features/<feature>/adapters`
|
||||
- `features/<feature>/presentation`
|
||||
|
||||
The dependency model is:
|
||||
|
||||
Domain / Use case
|
||||
|
|
||||
| owns
|
||||
v
|
||||
Business port
|
||||
^
|
||||
| implements / binds
|
||||
|
|
||||
Feature-owned adapter binding
|
||||
|
|
||||
| generic type + mapper/codec + policy
|
||||
v
|
||||
Reusable capability runtime
|
||||
|
|
||||
v
|
||||
Browser / network / native API
|
||||
|
||||
A use case never imports a platform adapter. Generic binding happens in the
|
||||
feature adapter/composition seam.
|
||||
|
||||
## Capability-specific typed bindings
|
||||
|
||||
Do not introduce one universal `Repository<TKey, TValue>` abstraction for HTTP,
|
||||
storage, realtime and transfer. Their lifecycle and failure semantics differ.
|
||||
|
||||
A reusable capability boundary is composed from:
|
||||
|
||||
- generic input/output types,
|
||||
- feature-owned mapper or codec,
|
||||
- feature-selected policy,
|
||||
- one capability-specific runtime.
|
||||
|
||||
The HTTP reference path is the first concrete example.
|
||||
`src/adapters/http/feature-http-binding.ts` owns transport/outcome
|
||||
normalization. The reference feature contributes only:
|
||||
|
||||
- operation ID,
|
||||
- route ID,
|
||||
- exact request input type,
|
||||
- exact success value type,
|
||||
- wire-to-domain mapper.
|
||||
|
||||
The feature gateway therefore does not reimplement timeout, cancellation,
|
||||
transport failure, authentication failure or contract-violation projection.
|
||||
|
||||
Storage, realtime and transfer may gain their own typed binders only after
|
||||
actual feature repetition demonstrates the need. They must not be forced
|
||||
through the HTTP abstraction.
|
||||
|
||||
## Custom adapter escape hatch
|
||||
|
||||
A product feature uses a reusable capability when the capability preserves the
|
||||
business requirement.
|
||||
|
||||
If a platform contract would require changing or weakening the business model,
|
||||
the feature implements a custom outbound adapter behind the same application
|
||||
port. The architecture boundary remains stable; platform reuse is optional.
|
||||
|
||||
## Feature installation
|
||||
|
||||
A feature owns its contract, runtime and adapter contributions.
|
||||
|
||||
Central installed catalogs are aggregation points only:
|
||||
|
||||
- `installed-product-manifest.ts`: which product features are compiled/selected
|
||||
- `installed-feature-contracts.ts`: contract aggregation
|
||||
- `installed-feature-runtimes.tsx`: runtime contribution aggregation
|
||||
- `installed-feature-adapters.ts`: application-input contribution aggregation
|
||||
|
||||
Feature-specific composition belongs under the feature itself. Central
|
||||
catalogs must not grow feature-specific branching logic.
|
||||
|
||||
Adapter contributions declare the platform capabilities they consume through
|
||||
`needs`. The generic contribution seam in
|
||||
`src/features/feature-adapter-contribution.ts` derives the context from that
|
||||
list, so an HTTP-only feature cannot reach IndexedDB and an IndexedDB-only
|
||||
feature does not receive the HTTP executor. It also binds
|
||||
`featureId -> ApplicationFeatureInputs[featureId]` at the contribution
|
||||
definition site instead of recovering that relationship with a final catalog
|
||||
cast.
|
||||
|
||||
A contribution becomes composable only through
|
||||
`defineFeatureAdapterContribution()`. That factory adds the private
|
||||
contribution brand required by `composeFeatureAdapterInputs()`; a raw object
|
||||
with the same visible fields is rejected by TypeScript and checked again at
|
||||
runtime. The negative type fixture
|
||||
`invalid-direct-feature-composition.ts` is part of FE-GATE-003 so this
|
||||
authority cannot be bypassed by calling the composer directly.
|
||||
|
||||
For IndexedDB-backed features, `createIndexedDbRepositoryProvider()` is the
|
||||
composition helper that maps feature-owned repository identities to typed
|
||||
`IndexedDbRepositoryPort` instances. It does not create a universal storage
|
||||
repository or move dataset/migration/lifecycle policy into the feature.
|
||||
|
||||
The repository now has two executable contribution shapes:
|
||||
|
||||
- Reference feature: `needs: ["http"]`
|
||||
- Local Draft feature: `needs: ["indexedDb"]`
|
||||
|
||||
Local Draft is a compiled architecture consumer used to prove the second
|
||||
capability shape; it is not added to the default product manifest. Its
|
||||
browser-capability test composes the feature over the real
|
||||
`createIndexedDbRuntime` and native IndexedDB, proving that this seam is not
|
||||
limited to an in-memory contract fixture.
|
||||
|
||||
## Presentation consumer surface
|
||||
|
||||
`ApplicationProvider` remains the composition root for presentation, but new
|
||||
consumers should not navigate a root `ApplicationApi` service locator.
|
||||
|
||||
Use the narrow hooks in
|
||||
`src/presentation/providers/application-provider.tsx`:
|
||||
|
||||
- `useApplicationSession`
|
||||
- `useApplicationPreferences`
|
||||
- `useApplicationDiagnostics`
|
||||
- `useApplicationRuntime`
|
||||
- `useApplicationRecovery`
|
||||
- `useApplicationFeature`
|
||||
|
||||
`useApplication` exists only as a deprecated compatibility escape hatch.
|
||||
|
||||
## Canonical imports
|
||||
|
||||
New feature code should use capability public entry points rather than deep
|
||||
runtime modules. For HTTP the canonical path is
|
||||
`src/adapters/http/index.ts`.
|
||||
|
||||
Compatibility re-exports may exist during a migration window, but they must be
|
||||
marked as compatibility/deprecated paths and should not expand into an
|
||||
unbounded public barrel.
|
||||
|
||||
## Policy ownership
|
||||
|
||||
Duplication is judged by ownership, not by syntax percentage.
|
||||
|
||||
Small local validators can remain duplicated when locality improves auditing.
|
||||
Business or concurrency policy must have one owner. For example the reference
|
||||
create mutation keeps definition ID, idempotency requirement, duplicate policy
|
||||
and invalidation policy in one feature-owned definition and binds only
|
||||
`scope` and `execute` per usage site.
|
||||
|
||||
## Runtime decomposition rule
|
||||
|
||||
Large runtime files are not split by line count.
|
||||
|
||||
Extract a boundary when it has its own state machine, lifecycle owner, failure
|
||||
model or compensation/recovery responsibility. Candidate seams include:
|
||||
|
||||
- connection/open/upgrade lifecycle,
|
||||
- transaction ownership,
|
||||
- migration state machine,
|
||||
- reconnect/backoff and heartbeat,
|
||||
- subscription ownership,
|
||||
- retry/deadline/cancellation ownership,
|
||||
- settlement/reconciliation/cleanup.
|
||||
|
||||
A cohesive 2,000-line state machine can remain together. A 300-line file with
|
||||
multiple lifecycle owners is a better extraction candidate.
|
||||
|
||||
### Current runtime boundary audit
|
||||
|
||||
The current large-runtime inventory was reviewed using that rule.
|
||||
|
||||
- IndexedDB remains large, but connection, transaction, migration, maintenance
|
||||
and failure translation already have separate owners/modules.
|
||||
- resumable upload already separates runtime policy, checkpoint persistence,
|
||||
HTTP control-plane transport, part execution, cancellation and mutation
|
||||
locking.
|
||||
- realtime already separates reconnect policy/coordinator, event codec/consumer
|
||||
and stream coordination.
|
||||
- OPFS is separated into browser runtime, journal, byte-store, policy and worker
|
||||
protocol/runtime responsibilities.
|
||||
- HTTP V3 still owned retry eligibility/backoff inside the execution state
|
||||
machine, so that responsibility moved to
|
||||
`src/adapters/http/http-retry-lifecycle.ts`.
|
||||
|
||||
No other runtime is split merely because of its line count.
|
||||
|
||||
## Consumer quality metrics
|
||||
|
||||
Before adding another abstraction, implement or model multiple real feature
|
||||
uses and measure:
|
||||
|
||||
- feature-owned adapter LOC,
|
||||
- repeated platform glue,
|
||||
- number of platform-internal types exposed to the feature,
|
||||
- central catalog edits,
|
||||
- files changed for one normal query/command,
|
||||
- whether native browser/network APIs leak into the feature.
|
||||
|
||||
The target feature-development path is:
|
||||
|
||||
1. domain type and invariant,
|
||||
2. use case,
|
||||
3. port,
|
||||
4. transport/storage schema plus mapper/codec,
|
||||
5. capability binding,
|
||||
6. presentation controller/page.
|
||||
|
||||
A product feature should not need to know the retry scheduler, abort ownership,
|
||||
effect-certainty machinery, transaction leases, reconnect coordinator, OPFS
|
||||
journal or provider lifecycle.
|
||||
|
||||
The executable REST and IndexedDB consumer baselines are recorded in
|
||||
[`capability-consumer-experience.md`](./capability-consumer-experience.md).
|
||||
Contract placement and the global-vs-owner-local audit are recorded in
|
||||
[`contract-ownership.md`](./contract-ownership.md).
|
||||
|
||||
## Verification paths
|
||||
|
||||
Product-development verification, capability verification and release assurance
|
||||
are intentionally separate. See
|
||||
[`docs/testing/taxonomy.md`](../testing/taxonomy.md).
|
||||
|
||||
Host-level CI-runner tests belong to `tests/system`, not `tests/unit`.
|
||||
Reusable capability consumer contracts belong to `tests/contract`.
|
||||
@@ -17,11 +17,63 @@ The following edges are forbidden:
|
||||
|
||||
- domain to application, presentation, adapters, bootstrap, React, or browser globals
|
||||
- application to presentation, concrete adapters, bootstrap, React, or browser globals
|
||||
- `contracts` to application or features: contracts is the lower package and
|
||||
owns the shared vocabulary both of them read
|
||||
- presentation to concrete adapters, raw DTO schemas, or storage implementations
|
||||
- generic presentation to the installed-feature registries: which features exist
|
||||
is a product decision owned by `bootstrap`
|
||||
- an adapter to presentation, bootstrap internals, or another concrete adapter
|
||||
- feature domain/application to its presentation or outbound adapter, and
|
||||
feature presentation to its outbound adapter
|
||||
|
||||
## The adapter kernel
|
||||
|
||||
"Another concrete adapter" excludes the adapter kernel, which is shared on
|
||||
purpose and is the only adapter code an adapter may reach across a group for:
|
||||
|
||||
- `src/adapters/platform/**` — the system clock, the shared abort primitive and
|
||||
the bounded-capacity guard
|
||||
- `src/adapters/browser-file-storage/result.ts` — the browser-data result and
|
||||
failure constructors
|
||||
|
||||
Each rule above is enforced by `check:architecture`, including the kernel
|
||||
carve-out, so this table and the executable rules cannot drift apart. Two edges
|
||||
are still open and are named explicitly in `.dependency-cruiser.json` rather
|
||||
than left silent: the generic presentation modules that read the installed
|
||||
registries today, and the two collaborator types `query-cache` reads from
|
||||
`cross-context-invalidation`. Both lists are frozen — a new edge of either kind
|
||||
fails the gate.
|
||||
|
||||
## 어댑터 그룹의 공개 경계
|
||||
|
||||
각 어댑터 그룹의 공개 표면은 그 그룹의 `index.ts`다. 그룹 바깥
|
||||
(`bootstrap`, `features`, `presentation`)은 배럴만 import한다.
|
||||
`adapter-groups-are-reached-through-their-barrel` 규칙이 이를 강제하고,
|
||||
`tests/fixtures/architecture/dependency-graph/barrel`이 거부와 허용을
|
||||
각각 고정한다 — 규칙을 지우면 그 회귀 검사가 먼저 깨진다.
|
||||
|
||||
두 가지 예외가 있고 둘 다 의도된 것이다.
|
||||
|
||||
- **그룹 내부 파일끼리**는 파일 경로로 직접 import한다. 배럴은 바깥을 위한
|
||||
문이지 내부 규율이 아니다.
|
||||
- **어댑터 → 커널(`platform/**`)** 간선도 파일 경로를 유지한다. 커널은
|
||||
런타임 합성물이 아니라 프리미티브이고, `check:adapter-inventory`가 네
|
||||
소비자에게 `platform/abortable-operation.ts`로 해석되는 specifier를
|
||||
직접 요구한다. 커널 배럴(`platform/index.ts`)은 bootstrap과 테스트를
|
||||
위한 것이다.
|
||||
|
||||
`storage`는 최상위 배럴이 `indexeddb/`·`opfs/` 서브배럴을 재수출하지
|
||||
않는다. 두 런타임이 각자 독립적으로 제거 가능하고
|
||||
(`test:browser-file-storage-removal`), 그래서 각 서브배럴이 곧 경계다.
|
||||
규칙의 도착점 정규식이 1단계 중첩 `index.ts`를 배럴로 인정하는 이유가
|
||||
이것이다.
|
||||
|
||||
`service-worker/index.ts`는 `tsconfig.service-worker.json`의 `exclude`에
|
||||
들어 있다. 그 설정이 그룹 폴더를 통째로 WebWorker lib로 컴파일하면서 페이지
|
||||
realm 파일만 빼는 구조라, 배럴이 그 파일을 다시 끌어들이면 워커 타입체크가
|
||||
`document`를 찾지 못한다. 배럴의 타입 커버리지는 `tsconfig.app.json`이
|
||||
담당하고, 워커 진입점은 배럴을 쓰지 않는다.
|
||||
|
||||
`bootstrap` contains composition only. Business rules and page-specific
|
||||
orchestration belong to domain/application.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"standard": "rules/diagram-standards.md v2",
|
||||
"evidenceReport": {
|
||||
"repoPath": "docs/architecture/review-evidence.md",
|
||||
"canonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md",
|
||||
"upstreamCanonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md",
|
||||
"canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29"
|
||||
},
|
||||
"reviews": {
|
||||
@@ -25,5 +25,6 @@
|
||||
"thresholdSatisfied": true,
|
||||
"scope": "immutable static assets and mutable /config.json delivery"
|
||||
}
|
||||
}
|
||||
},
|
||||
"note": "`repoPath` is this repository's copy and must resolve. `upstreamCanonicalPath` and every `reviews[*].sourcePath` name the reviewing workspace, not this tree; they are provenance labels and are deliberately not resolvable here. `canonicalSha256` is what binds the two, and the gate checks it appears in `repoPath`."
|
||||
}
|
||||
|
||||
@@ -337,7 +337,14 @@ Run on the landed tree. Only what actually passed is claimed as passing.
|
||||
| --- | --- | --- |
|
||||
| `tests/unit/ci-workflow-generation.test.ts` | 82 failed / 325 passed | Identical on the pre-change baseline (`git stash` comparison). The subprocess gates it spawns cannot run in this sandbox. |
|
||||
| `tests/unit/ci-artifact-contract.test.ts` | fails | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
|
||||
| `tests/unit/security-followup.test.ts`, `tests/unit/provider-guardian-transaction.test.ts`, `tests/unit/risk-coverage.test.ts` | flaky under full-suite load | All three pass in a fresh process (78 passed together). They spawn and reap process groups, so their timing assertions are load sensitive. |
|
||||
| `tests/unit/security-followup.test.ts`, `tests/unit/provider-guardian-transaction.test.ts`, `tests/unit/risk-coverage.test.ts` | flaky under full-suite load | Historical baseline result. All three passed in a fresh process (78 passed together). |
|
||||
|
||||
> Current classification update (2026-09-18): the provider guardian transaction
|
||||
> suite moved to `tests/integration/provider-guardian-transaction.test.ts`.
|
||||
> It spawns child processes and exercises filesystem/IPC/process lifecycle, so it
|
||||
> is not part of the pure unit pool. Its READY/PUBLISHED wait uses a dedicated
|
||||
> test watchdog rather than treating a 1-second scheduler delay as a production
|
||||
> protocol deadline.
|
||||
|
||||
### Destructive fixture hazard — fixed
|
||||
|
||||
@@ -375,7 +382,7 @@ files above plus the two flaky-under-load ones:
|
||||
| `tests/unit/ci-workflow-generation.test.ts` | 82 | Identical on the pre-change baseline (`git stash` comparison). Its subprocess gates cannot run in this sandbox. |
|
||||
| `tests/unit/ci-artifact-contract.test.ts` | 19 | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
|
||||
| `tests/unit/security-followup.test.ts` | 2 | Passes in isolation. |
|
||||
| `tests/unit/provider-guardian-transaction.test.ts` | 1 | Passes in isolation. |
|
||||
| `tests/unit/provider-guardian-transaction.test.ts` (historical path; now `tests/integration/provider-guardian-transaction.test.ts`) | 1 | Historical baseline: passed in isolation; current suite is classified as process integration. |
|
||||
|
||||
1619 passed / 1723 total, and `tests/unit/removal-fixture.test.ts`,
|
||||
`tests/unit/supply-chain.test.ts` and
|
||||
@@ -462,6 +469,103 @@ The full `tests/unit` + `tests/integration` run is **1,845 passed / 1,864**,
|
||||
cgroup, RLIMIT and `/tmp` permission behaviour already recorded above — the same
|
||||
file failed identically before this work. No adapter test fails.
|
||||
|
||||
## Operational contract review (2026-08-15)
|
||||
|
||||
A fourth review looked past the adapter layer at the operational contract:
|
||||
feature on/off, environment separation, folder boundaries, and which gates were
|
||||
actually green. It found five red gates and three structural gaps. Every row
|
||||
below names the defect, not the symptom.
|
||||
|
||||
| id | area | disposition | what was actually wrong |
|
||||
| --- | --- | --- | --- |
|
||||
| `OPS-01` | release | `FIXED` | `public/` is copied verbatim into `dist/`, so every build — production included — shipped the local runtime document. Runtime config now comes from `config/runtime/<profile>.json`. |
|
||||
| `OPS-02` | release | `FIXED` | Release coherence proved the artifacts agreed with each other, never that they belonged in production. `FE-GATE-027` refuses an artifact whose `APP_ENV`, auth mode, endpoints or build identity do not match a declared `RELEASE_TARGET`, and refuses an undeclared target outright. |
|
||||
| `OPS-03` | runtime | `FIXED` | `REQUEST_TIMEOUT_MS` was validated and then never passed to the V3 executor; every operation ran on its contract's own deadline. It is now a ceiling that may tighten a contract, never loosen one. |
|
||||
| `OPS-04` | build | `FIXED` | `VITE_ROUTER_BASE_PATH` drove the router and the Service Worker scope but not Vite's asset `base`, so a sub-path deployment emitted root-absolute assets. One value now feeds all three. |
|
||||
| `OPS-05` | provider | `FIXED` | bubblewrap 0.9.0 drops whatever follows the option stream inside an `--args` file, so the sandboxed command was never executed: bwrap printed usage and exited 1. Options stay hidden; the command travels on real argv. |
|
||||
| `OPS-06` | provider | `FIXED` | The scope wrapper read its liveness pipe through `fs`, a blocking `read(2)` on a pipe the supervisor never closes. `process.exit` deadlocked joining that thread, so a completed provider was reported as a timeout kill. |
|
||||
| `OPS-07` | release | `FIXED` | `mkdir`/`open` modes were left to the ambient umask, so a hardened runner produced directories it could not enter and handed `tar` a file it could not re-open. |
|
||||
| `OPS-08` | release | `FIXED` | Promotion cleanup deleted this promotion's exact five through a pinned descriptor and only then noticed the leaf had been substituted, leaving a half-emptied directory a retry could not distinguish from a completed one. |
|
||||
| `OPS-09` | removability | `FIXED` | The removal fixture was not a repository, had no `.gitignore`, and each removal script kept its own copy-target list that had drifted. Supply-chain generation therefore failed inside every fixture and took the whole provider suite down with it. |
|
||||
| `OPS-10` | removability | `FIXED` | A platform integration file asserted the reference feature's route ids, so removing the feature left it importing a deleted module. The assertion moved to the feature's own test tree. |
|
||||
| `OPS-11` | removability | `FIXED` | A removal fixture runs against a deliberately reduced CI contract; the canonical exact-count tests re-imposed the full authority on it and failed the fixture for the reduction it exists to prove. |
|
||||
| `OPS-12` | browser | `FIXED` | Four browser-capability specs answered capability requests without the `protocol` field the hardened envelope requires, so every capability was refused and the download and part-upload paths asserted against an empty transcript. |
|
||||
| `OPS-13` | browser | `FIXED` | A refused capability document answered `recovery: NONE`, contradicting both the design record and the vault, which already answers `REISSUE_CAPABILITY`. |
|
||||
| `OPS-14` | performance | `FIXED` | Playwright matches accessible names by substring, so the navigation entry matched the home page's call to action too; the run died on a strict-mode violation before the first measurement and produced no evidence at all. |
|
||||
| `OPS-15` | visual | `FIXED` | The platform overview baseline predated the reference routes moving from `integration-defined` to `session-required`, so the only visual gate covering that page failed for its own staleness. |
|
||||
| `OPS-16` | architecture | `FIXED` | `src/contracts` imported `src/application` for the shared `Result` and the compatibility predicate; neither package owned the shared vocabulary. Both moved down to contracts. |
|
||||
| `OPS-17` | architecture | `FIXED` | The documented "no adapter depends on another concrete adapter" rule had no executable form, and `diagnostics` imported a guard out of `telemetry`. The guard moved to the adapter kernel and the rule is now enforced with a same-directory backreference. |
|
||||
| `OPS-18` | architecture | `PARTIAL` | Generic presentation still reads the installed-feature registries. The rule freezes the exact set of modules doing so today; a new edge fails. Lifting the assembly into `bootstrap` is not done. |
|
||||
| `OPS-19` | documentation | `FIXED` | README and the manual accessibility checklist both claimed six routes while ten were registered, leaving four screens outside the declared manual review scope. The list is now derived from the route registry by `verify:documentation`. |
|
||||
|
||||
### Product feature selection (2026-08-15, second pass)
|
||||
|
||||
| id | disposition | what changed |
|
||||
| --- | --- | --- |
|
||||
| `OPS-20` | `FIXED` | Which features a build contains is now a declared manifest rather than five registries spreading a literal. `VITE_PRODUCT_FEATURES` narrows it at build time; a test fails if a new registry forgets to consult it. |
|
||||
| `OPS-21` | `FIXED` | `FEATURE_OVERRIDES` in the runtime document takes an installed feature out of service without a rebuild. The router refuses its routes, not just the navigation, so a typed deep link cannot still mount it. |
|
||||
| `OPS-22` | `FIXED` | Both inputs are subtractive by vocabulary: the override enum has no `ENABLED`, and a build-time selection naming a feature the source tree does not declare is refused rather than ignored. |
|
||||
| `OPS-23` | `FIXED` | A sandbox that fails to launch now reports why. The supervisor consumed the child's output only to enforce a byte cap and discarded it, so a host restriction surfaced as an unexplained `exit=1`. Lines the sandbox tooling itself emits are kept; provider output is still discarded. |
|
||||
|
||||
An env var does **not** shrink the bundle, and the code says so. A static import
|
||||
cannot be undone by a value, and making the import graph depend on a
|
||||
configuration string is what §3.5 exists to prevent. Measured: `none` changes
|
||||
the output by 58 bytes. Physical removal is FE-GATE-020's job.
|
||||
|
||||
### Host restriction discovered during this pass
|
||||
|
||||
`bwrap --unshare-net` no longer works on this machine:
|
||||
|
||||
```
|
||||
$ printf '%s\0' --unshare-net --ro-bind /usr /usr ... | bwrap --args 3 -- /bin/true
|
||||
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
|
||||
$ sysctl kernel.apparmor_restrict_unprivileged_userns
|
||||
kernel.apparmor_restrict_unprivileged_userns = 1
|
||||
```
|
||||
|
||||
That reproduction contains none of this repository's code. Earlier in the same
|
||||
session the identical sandbox ran to completion, so the restriction became
|
||||
active partway through. While it holds, 16 of the 108 provider tests cannot run
|
||||
here — they need a sandbox the kernel will not grant. They are not counted as
|
||||
green and not counted as product defects; under a host that permits the
|
||||
namespace the same file was 107/108.
|
||||
|
||||
### Still red after this pass
|
||||
|
||||
*applies effective aggregate cgroup limits without exposing command or
|
||||
credentials* was rewritten. It used to read the live process tree with one
|
||||
`ps` per pid and assert mid-run, which lost a race against a sandbox that now
|
||||
completes in a few hundred milliseconds; it records the tree from `/proc` every
|
||||
5ms and asserts on the recording after the run. That restructuring is also what
|
||||
revealed the host restriction above — the supervisor had been failing to launch
|
||||
the sandbox and the test was dying on the observation first.
|
||||
|
||||
Tests that spawn processes, build archives and sign evidence were given a
|
||||
30s budget instead of the 10s default sized for pure-JS unit tests. The default
|
||||
was not raised: that would hide a genuinely hung test.
|
||||
|
||||
### FE-GATE-020 after this pass
|
||||
|
||||
| fixture | before | after |
|
||||
| --- | ---: | ---: |
|
||||
| reference feature | failed before its first assertion | 1,612 pass / 1 fail |
|
||||
| optional recipe | 39 failures | 1,386 pass / 2 fail |
|
||||
| browser file + storage | 40 failures | 1,006 pass / 3 fail |
|
||||
| realtime | not reached | 1,159 pass / 1 fail |
|
||||
|
||||
Every remaining failure is one of the three environment-limited tests above.
|
||||
|
||||
Lab performance now produces evidence, and that evidence shows the
|
||||
named-interaction budget missed on this machine (367–724ms against 200ms). The
|
||||
metric measures a full lazy-route navigation while the budget is an
|
||||
INP-shaped 200ms, so the two do not describe the same thing. No budget was
|
||||
changed to make this green.
|
||||
|
||||
WebKit remains unavailable in this environment (`libevent-2.1-7t64`,
|
||||
`libavif16` are not installed), so 14 browser-capability specs and the WebKit
|
||||
E2E project are unverified here. Chromium and Firefox are 28/28 and visual is
|
||||
5/5.
|
||||
|
||||
## Rules for updating this ledger
|
||||
|
||||
- A row moves out of `NOT_STARTED` only with a linked red test, its green run, and the commit id.
|
||||
|
||||
@@ -148,6 +148,9 @@ read-only나 online-only가 사용자 작성 내용을 잃게 한다면 먼저 e
|
||||
### 신호
|
||||
|
||||
- `BLOCKED`, `UPGRADE_BLOCKED` 또는 blocked duration bucket 증가
|
||||
- live attempt가 기다리는 동안 status는 `BLOCKED`
|
||||
- blocked deadline settle 뒤에는 open result가 `BLOCKED` failure여도
|
||||
runtime status는 `CLOSED / NOT_OPENED`
|
||||
- `versionchange` 뒤 connection이 남음
|
||||
- repeated reload/update loop
|
||||
- open/maintenance의 `POLICY_REJECTED`: immutable dataset binding missing/mismatch
|
||||
@@ -500,16 +503,13 @@ browser engine/version, fixture ID, fault phase, bounded counts/buckets와 PASS/
|
||||
|
||||
promotion 직전에는 다음 repository evidence도 함께 보존한다.
|
||||
|
||||
- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이 동일
|
||||
14개 testcase set(File 2, IndexedDB 4, OPFS/Cache/StorageManager 각 1,
|
||||
cross-context invalidation 2, presigned streaming download/multipart
|
||||
upload/Image CDN 각 1)을 실제 실행해 총 42개이며 failure/error/skipped가 모두
|
||||
0이어야 한다.
|
||||
`verify:browser-capability-evidence`가 engine 집합과 testcase 동일성을
|
||||
기계적으로 검증한다. 현재 artifact는 Chromium/Firefox 14개씩 총 28개가
|
||||
통과했지만 WebKit 실행에 필요한 native libraries(예:
|
||||
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
|
||||
`libavif.so.16`과 WPE 계열)가 이 host에 없으므로 아직 promotion 가능 상태가
|
||||
- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이
|
||||
source suite의 동일 testcase set을 실제 실행하고 failure/error/skipped가 모두
|
||||
0이어야 한다. 구체적인 case 수는 이 runbook에 복제하지 않는다.
|
||||
`verify:browser-capability-evidence`가 Chromium을 baseline으로 testcase set과
|
||||
engine 집합을 동적으로 검증한다. native capability가 없는 engine도 testcase를
|
||||
skip하지 않고 adapter의 명시적 `UNSUPPORTED`/degraded 결과를 검증해야 한다.
|
||||
세 engine 중 하나라도 실행되지 않은 artifact는 promotion 가능 상태가
|
||||
아니다.
|
||||
- `artifacts/quality/vite-module-inventory.json`에서 optional runtime source root가
|
||||
production chunk에 없음을 `check:optional-recipes`로 검증한다.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+130
-119
@@ -9,124 +9,135 @@
|
||||
| # | full path | 상세 리뷰 |
|
||||
| ---: | --- | --- |
|
||||
| 1 | `src/adapters/auth/external-session-adapter.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 2 | `src/adapters/browser-file-storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 3 | `src/adapters/browser-file-storage/result.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 4 | `src/adapters/browser-file-storage/storage-manager-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 5 | `src/adapters/browser-files/browser-file-picker.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 6 | `src/adapters/browser-files/browser-file-policy-registry.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 7 | `src/adapters/browser-files/browser-file-vault.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 8 | `src/adapters/browser-files/create-browser-file-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 9 | `src/adapters/browser-files/download-delivery-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 10 | `src/adapters/browser-files/file-observer.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 11 | `src/adapters/browser-files/file-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 12 | `src/adapters/browser-files/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 13 | `src/adapters/browser-files/object-url-lease.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 14 | `src/adapters/browser-rpc/browser-rpc-runtime.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 15 | `src/adapters/browser-rpc/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 16 | `src/adapters/browser-rpc/transport.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 17 | `src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 18 | `src/adapters/browser-transfer/image-cdn/README.md` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 19 | `src/adapters/browser-transfer/image-cdn/browser-image-probe.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 20 | `src/adapters/browser-transfer/image-cdn/image-cdn-policy.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 21 | `src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 22 | `src/adapters/browser-transfer/image-cdn/image-header-metadata.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 23 | `src/adapters/browser-transfer/image-cdn/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 24 | `src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 25 | `src/adapters/browser-transfer/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 26 | `src/adapters/browser-transfer/presigned/incremental-sha256.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 27 | `src/adapters/browser-transfer/presigned/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 28 | `src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 29 | `src/adapters/browser-transfer/presigned/presigned-capability-vault.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 30 | `src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 31 | `src/adapters/browser-transfer/resumable-upload/checkpoint-schema.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 32 | `src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 33 | `src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 34 | `src/adapters/browser-transfer/resumable-upload/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 35 | `src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 36 | `src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 37 | `src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 38 | `src/adapters/browser-transfer/resumable-upload/runtime-policy.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 39 | `src/adapters/browser-transfer/resumable-upload/upload-byte-source.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 40 | `src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 41 | `src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 42 | `src/adapters/cache-storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 43 | `src/adapters/cache-storage/public-cache-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 44 | `src/adapters/cache-storage/public-response-cache-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 45 | `src/adapters/cross-context-invalidation/browser-cross-context-host.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 46 | `src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 47 | `src/adapters/cross-context-invalidation/index.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 48 | `src/adapters/diagnostics/bounded-diagnostics.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 49 | `src/adapters/http/bounded-body-reader.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 50 | `src/adapters/http/bounded-json.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 51 | `src/adapters/http/client.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 52 | `src/adapters/http/http-contract-bridge.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 53 | `src/adapters/http/http-effect-certainty.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 54 | `src/adapters/http/http-execution-v3.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 55 | `src/adapters/http/request-builder.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 56 | `src/adapters/http/resource-mapper.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 57 | `src/adapters/http/retry-policy.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 58 | `src/adapters/http/schema-registry.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 59 | `src/adapters/platform/abortable-operation.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 60 | `src/adapters/platform/browser-lifecycle.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 61 | `src/adapters/platform/browser-mutation-intent-factory.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 62 | `src/adapters/platform/system-clock.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 63 | `src/adapters/query-cache/conditional-validator-store.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 64 | `src/adapters/query-cache/cursor-pagination-runtime.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 65 | `src/adapters/query-cache/server-state-scope-runtime.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 66 | `src/adapters/query-cache/tanstack-cache-coordinator.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 67 | `src/adapters/query-cache/tanstack-query-cache.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 68 | `src/adapters/realtime/event-codec.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 69 | `src/adapters/realtime/event-consumer.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 70 | `src/adapters/realtime/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 71 | `src/adapters/realtime/json-member-scanner.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 72 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 73 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 74 | `src/adapters/realtime/polling/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 75 | `src/adapters/realtime/reconnect-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 76 | `src/adapters/realtime/reconnect-policy.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 77 | `src/adapters/realtime/result.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 78 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 79 | `src/adapters/realtime/sse/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 80 | `src/adapters/realtime/sse/sse-parser.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 81 | `src/adapters/realtime/stream-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 82 | `src/adapters/realtime/websocket/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 83 | `src/adapters/realtime/websocket/websocket-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 84 | `src/adapters/realtime/websocket/websocket-protocol.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 85 | `src/adapters/service-worker/service-worker-entry.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 86 | `src/adapters/service-worker/service-worker-lifecycle.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 87 | `src/adapters/service-worker/service-worker-page-controller.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 88 | `src/adapters/service-worker/service-worker-protocol.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 89 | `src/adapters/service-worker/service-worker-removal.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 90 | `src/adapters/service-worker/service-worker-static-assets.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 91 | `src/adapters/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 92 | `src/adapters/storage/browser-storage-codec.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 93 | `src/adapters/storage/indexeddb/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 94 | `src/adapters/storage/indexeddb/indexeddb-failure.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 95 | `src/adapters/storage/indexeddb/indexeddb-governance.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 96 | `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 97 | `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 98 | `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 99 | `src/adapters/storage/indexeddb/indexeddb-types.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 100 | `src/adapters/storage/opfs/browser-opfs-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 101 | `src/adapters/storage/opfs/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 102 | `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 103 | `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 104 | `src/adapters/storage/opfs/opfs-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 105 | `src/adapters/storage/opfs/opfs-worker-client.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 106 | `src/adapters/storage/opfs/opfs-worker-protocol.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 107 | `src/adapters/storage/opfs/opfs-worker-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 108 | `src/adapters/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 109 | `src/adapters/web-push/inbound/notification-click-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 110 | `src/adapters/web-push/inbound/push-event-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 111 | `src/adapters/web-push/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 112 | `src/adapters/web-push/notification-registry.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 113 | `src/adapters/web-push/push-association-fence-store.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 114 | `src/adapters/web-push/push-codec.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 115 | `src/adapters/web-push/push-registration-gateway.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 116 | `src/adapters/web-push/push-subscription-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 117 | `src/adapters/web-push/runtime-support.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 118 | `src/adapters/web-push/service-worker-runtime.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 119 | `src/adapters/web-push/service-worker-scope-host.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 2 | `src/adapters/auth/index.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 3 | `src/adapters/browser-file-storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 4 | `src/adapters/browser-file-storage/result.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 5 | `src/adapters/browser-file-storage/storage-manager-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 6 | `src/adapters/browser-files/browser-file-picker.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 7 | `src/adapters/browser-files/browser-file-policy-registry.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 8 | `src/adapters/browser-files/browser-file-vault.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 9 | `src/adapters/browser-files/create-browser-file-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 10 | `src/adapters/browser-files/download-delivery-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 11 | `src/adapters/browser-files/file-observer.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 12 | `src/adapters/browser-files/file-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 13 | `src/adapters/browser-files/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 14 | `src/adapters/browser-files/object-url-lease.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 15 | `src/adapters/browser-rpc/browser-rpc-runtime.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 16 | `src/adapters/browser-rpc/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 17 | `src/adapters/browser-rpc/transport.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 18 | `src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 19 | `src/adapters/browser-transfer/image-cdn/README.md` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 20 | `src/adapters/browser-transfer/image-cdn/browser-image-probe.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 21 | `src/adapters/browser-transfer/image-cdn/image-cdn-policy.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 22 | `src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 23 | `src/adapters/browser-transfer/image-cdn/image-header-metadata.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 24 | `src/adapters/browser-transfer/image-cdn/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 25 | `src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 26 | `src/adapters/browser-transfer/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 27 | `src/adapters/browser-transfer/presigned/incremental-sha256.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 28 | `src/adapters/browser-transfer/presigned/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 29 | `src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 30 | `src/adapters/browser-transfer/presigned/presigned-capability-vault.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 31 | `src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 32 | `src/adapters/browser-transfer/resumable-upload/checkpoint-schema.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 33 | `src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 34 | `src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 35 | `src/adapters/browser-transfer/resumable-upload/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 36 | `src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 37 | `src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 38 | `src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 39 | `src/adapters/browser-transfer/resumable-upload/runtime-policy.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 40 | `src/adapters/browser-transfer/resumable-upload/upload-byte-source.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 41 | `src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 42 | `src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 43 | `src/adapters/cache-storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 44 | `src/adapters/cache-storage/public-cache-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 45 | `src/adapters/cache-storage/public-response-cache-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 46 | `src/adapters/cross-context-invalidation/browser-cross-context-host.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 47 | `src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 48 | `src/adapters/cross-context-invalidation/index.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 49 | `src/adapters/diagnostics/bounded-diagnostics.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 50 | `src/adapters/diagnostics/index.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 51 | `src/adapters/http/bounded-body-reader.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 52 | `src/adapters/http/bounded-json.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 53 | `src/adapters/http/client.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 54 | `src/adapters/http/http-contract-bridge.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 55 | `src/adapters/http/http-effect-certainty.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 56 | `src/adapters/http/http-execution-v3.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 57 | `src/adapters/http/index.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 58 | `src/adapters/http/request-builder.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 59 | `src/adapters/http/resource-mapper.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 60 | `src/adapters/http/retry-policy.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 61 | `src/adapters/http/schema-registry.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 62 | `src/adapters/platform/abortable-operation.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 63 | `src/adapters/platform/browser-lifecycle.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 64 | `src/adapters/platform/browser-mutation-intent-factory.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 65 | `src/adapters/platform/bounded-capacity.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 66 | `src/adapters/platform/index.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 67 | `src/adapters/platform/indexeddb-connection.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 68 | `src/adapters/platform/indexeddb-transaction.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 69 | `src/adapters/platform/system-clock.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 70 | `src/adapters/query-cache/conditional-validator-store.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 71 | `src/adapters/query-cache/cursor-pagination-runtime.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 72 | `src/adapters/query-cache/index.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 73 | `src/adapters/query-cache/server-state-scope-runtime.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 74 | `src/adapters/query-cache/tanstack-cache-coordinator.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 75 | `src/adapters/query-cache/tanstack-query-cache.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 76 | `src/adapters/realtime/event-codec.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 77 | `src/adapters/realtime/event-consumer.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 78 | `src/adapters/realtime/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 79 | `src/adapters/realtime/json-member-scanner.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 80 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 81 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 82 | `src/adapters/realtime/polling/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 83 | `src/adapters/realtime/reconnect-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 84 | `src/adapters/realtime/reconnect-policy.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 85 | `src/adapters/realtime/result.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 86 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 87 | `src/adapters/realtime/sse/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 88 | `src/adapters/realtime/sse/sse-parser.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 89 | `src/adapters/realtime/stream-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 90 | `src/adapters/realtime/websocket/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 91 | `src/adapters/realtime/websocket/websocket-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 92 | `src/adapters/realtime/websocket/websocket-protocol.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 93 | `src/adapters/service-worker/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 94 | `src/adapters/service-worker/service-worker-entry.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 95 | `src/adapters/service-worker/service-worker-lifecycle.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 96 | `src/adapters/service-worker/service-worker-page-controller.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 97 | `src/adapters/service-worker/service-worker-protocol.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 98 | `src/adapters/service-worker/service-worker-removal.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 99 | `src/adapters/service-worker/service-worker-static-assets.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 100 | `src/adapters/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 101 | `src/adapters/storage/browser-storage-codec.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 102 | `src/adapters/storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 103 | `src/adapters/storage/indexeddb/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 104 | `src/adapters/storage/indexeddb/indexeddb-failure.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 105 | `src/adapters/storage/indexeddb/indexeddb-governance.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 106 | `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 107 | `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 108 | `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 109 | `src/adapters/storage/indexeddb/indexeddb-types.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 110 | `src/adapters/storage/opfs/browser-opfs-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 111 | `src/adapters/storage/opfs/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 112 | `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 113 | `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 114 | `src/adapters/storage/opfs/opfs-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 115 | `src/adapters/storage/opfs/opfs-worker-client.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 116 | `src/adapters/storage/opfs/opfs-worker-protocol.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 117 | `src/adapters/storage/opfs/opfs-worker-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 118 | `src/adapters/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 119 | `src/adapters/telemetry/index.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 120 | `src/adapters/web-push/inbound/notification-click-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 121 | `src/adapters/web-push/inbound/push-event-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 122 | `src/adapters/web-push/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 123 | `src/adapters/web-push/notification-registry.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 124 | `src/adapters/web-push/push-association-fence-store.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 125 | `src/adapters/web-push/push-codec.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 126 | `src/adapters/web-push/push-registration-gateway.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 127 | `src/adapters/web-push/push-subscription-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 128 | `src/adapters/web-push/runtime-support.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 129 | `src/adapters/web-push/service-worker-runtime.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 130 | `src/adapters/web-push/service-worker-scope-host.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
|
||||
합계: **119/119**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다.
|
||||
합계: **130/130**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다.
|
||||
|
||||
|
||||
@@ -133,3 +133,15 @@ listener/timer 정리, first-terminal-owner, late rejection 관찰, late native
|
||||
- cleanup 실패를 observation만 남기고 success로 바꾸지 않는다.
|
||||
- schema/database version을 downgrade하거나 broad prefix/root/database 전체 삭제를 rollback으로 사용하지 않는다.
|
||||
- SSE↔WebSocket, Connect↔gRPC-Web↔REST를 장애 중 자동 전환하지 않는다.
|
||||
|
||||
## 테스트의 어댑터 import
|
||||
|
||||
`tests/` 아래 어댑터 import는 배럴로 일괄 이관하지 않는다. `check:architecture`는
|
||||
`src`만 스캔하므로 강제되지 않고, 단위 테스트의 상당수가 배럴에 없는 내부
|
||||
심볼을 의도적으로 겨눈다.
|
||||
|
||||
어떤 테스트 파일을 **다른 이유로** 수정하거나 분할할 때, 그 파일이 쓰는
|
||||
심볼이 해당 그룹 배럴에 있으면 그 파일 안에서만 배럴 경로로 바꾼다.
|
||||
배럴에 없는 심볼이면 깊은 경로를 유지한다. 배럴에 추가하고 싶으면 그 심볼이
|
||||
공개 표면임을 먼저 논증한다 — 테스트 편의로 배럴을 키우면 배럴이 경계가
|
||||
아니라 재수출 덤프가 된다.
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
# 어댑터 배럴 공개 경계 확립 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 어댑터 16개 그룹 전부에 `index.ts` 공개 경계를 세우고, 그룹 바깥은 배럴만 import하도록 게이트로 강제한다.
|
||||
|
||||
**Architecture:** 각 어댑터 그룹의 `index.ts`가 그 그룹의 공개 표면이 된다. 그룹 내부 파일끼리, 그리고 어댑터→커널(`platform/**`) 간선은 지금처럼 파일 직접 import를 유지한다 — 커널의 정체성은 파일이고 `check:adapter-inventory`가 그것을 파일 경로로 검증하기 때문이다. `.dependency-cruiser.json`에 규칙 하나를 추가하고 회귀 fixture로 그 거부를 고정한다.
|
||||
|
||||
**Tech Stack:** TypeScript (NodeNext/Bundler), dependency-cruiser, 자체 TypeScript 인지 import 그래프(`scripts/check-architecture.ts`), Vite, Vitest
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-09-16-adapter-barrel-boundary-design.md`](../specs/2026-09-16-adapter-barrel-boundary-design.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 기준선(2026-09-16, `develop` `5434760`): `check:architecture` PASS(297 모듈 / 897 의존 / 미해결 0), `check:types:app` PASS, `check:adapter-inventory` PASS(120 파일). **이 셋을 깨면 안 된다.**
|
||||
- `docs/reviews/adapters/INVENTORY.md`는 `git ls-files src/adapters`와 **집합이 정확히 일치**해야 한다. 소스 파일을 추가/이동하면 같은 커밋에서 이 표와 하단 합계를 고친다.
|
||||
- 어댑터→커널 import는 **파일 직접 경로를 유지**한다. `scripts/check-adapter-inventory.ts:63-83`이 4개 소비자에게 `platform/abortable-operation.ts`로 해석되는 specifier를 정규식으로 요구한다.
|
||||
- `src/adapters/storage/index.ts`는 `indexeddb/`·`opfs/` 서브배럴을 **재수출하지 않는다.** `scripts/test-browser-file-storage-runtime-removal.ts:23-34`가 그 두 폴더만 삭제한 뒤 잔존 import를 예외로 잡는다.
|
||||
- `src/adapters/service-worker/index.ts`는 `service-worker-entry.ts`를 참조하지 않는다. `tsconfig.app.json:18`이 제외한 파일이다.
|
||||
- 번들 예산: `config/performance/budgets.json`의 `bundle.initialJsGzipBytes = 204800`.
|
||||
- 프로젝트 소스는 TypeScript/TSX만. `allowJs` 비활성. 로컬 import는 명시적 `.ts`/`.tsx` 확장자 필수.
|
||||
- 커밋 메시지 끝에 붙일 것:
|
||||
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 배럴 8개 생성 + INVENTORY 등록
|
||||
|
||||
없는 8개 그룹에 `index.ts`를 만든다. 이 태스크는 **파일 추가만** 한다 — 기존 import는 건드리지 않으므로 런타임 동작이 바뀌지 않는다.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/adapters/auth/index.ts`
|
||||
- Create: `src/adapters/diagnostics/index.ts`
|
||||
- Create: `src/adapters/telemetry/index.ts`
|
||||
- Create: `src/adapters/platform/index.ts`
|
||||
- Create: `src/adapters/query-cache/index.ts`
|
||||
- Create: `src/adapters/service-worker/index.ts`
|
||||
- Create: `src/adapters/storage/index.ts`
|
||||
- Create: `src/adapters/http/index.ts`
|
||||
- Modify: `docs/reviews/adapters/INVENTORY.md` (행 8개 추가 + 합계)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 위 8개 배럴이 내보내는 심볼 82개. Task 2가 이 경로들로 import를 바꾼다. 심볼 존재는 spec §7에서 82/82 기계 대조 완료(누락 0, 오타 0).
|
||||
- Consumes: 없음 (첫 태스크)
|
||||
|
||||
- [x] **Step 1: 배럴 5개 생성 (단일 파일 그룹 + query-cache)**
|
||||
|
||||
`src/adapters/auth/index.ts`:
|
||||
```ts
|
||||
export {
|
||||
createAnonymousSessionAdapter,
|
||||
createDemoSessionAdapter,
|
||||
createExternalAuthSessionAdapter,
|
||||
createUnavailableSessionAdapter,
|
||||
DEMO_AUTHORIZATION_MARKER,
|
||||
type DemoSessionAdapter,
|
||||
type ExternalSessionOwner,
|
||||
} from "./external-session-adapter.ts";
|
||||
```
|
||||
|
||||
`src/adapters/diagnostics/index.ts`:
|
||||
```ts
|
||||
export {
|
||||
createDiagnosticsAdapter,
|
||||
getLastBootEvidence,
|
||||
MAX_DIAGNOSTIC_ENTRIES,
|
||||
noOpDiagnostics,
|
||||
recordBootFailure,
|
||||
} from "./bounded-diagnostics.ts";
|
||||
```
|
||||
|
||||
`src/adapters/telemetry/index.ts`:
|
||||
```ts
|
||||
export {
|
||||
createTelemetryAdapter,
|
||||
MAX_TELEMETRY_QUEUE,
|
||||
noOpTelemetry,
|
||||
type TelemetryAdapter,
|
||||
type TelemetryAdapterOptions,
|
||||
} from "./best-effort-telemetry.ts";
|
||||
```
|
||||
|
||||
`src/adapters/storage/index.ts`:
|
||||
```ts
|
||||
/**
|
||||
* 최상위 storage 배럴은 Web Storage 어댑터만 내보낸다.
|
||||
*
|
||||
* `./indexeddb/index.ts`와 `./opfs/index.ts`를 여기서 재수출하지 말 것.
|
||||
* `scripts/test-browser-file-storage-runtime-removal.ts:23-34`가 그 두 폴더만
|
||||
* 삭제한 뒤 잔존 import를 예외로 잡는다 — 재수출하면 제거 드릴이 죽는다.
|
||||
* 두 서브배럴이 각자 제거 가능한 런타임의 경계다.
|
||||
*/
|
||||
export {
|
||||
createBrowserStorageAdapter,
|
||||
type BrowserStorageDependencies,
|
||||
} from "./browser-storage-adapter.ts";
|
||||
```
|
||||
|
||||
`src/adapters/query-cache/index.ts`:
|
||||
```ts
|
||||
export {
|
||||
createConditionalValidatorStore,
|
||||
type ConditionalValidatorBinding,
|
||||
type ConditionalValidatorStore,
|
||||
} from "./conditional-validator-store.ts";
|
||||
export { createCursorPaginationRuntime } from "./cursor-pagination-runtime.ts";
|
||||
export {
|
||||
createServerStateScopeRuntime,
|
||||
type ScopeResetParticipant,
|
||||
type ServerStateScopeDependencies,
|
||||
} from "./server-state-scope-runtime.ts";
|
||||
export {
|
||||
createTanStackCacheCoordinator,
|
||||
type TanStackCacheCoordinatorDependencies,
|
||||
} from "./tanstack-cache-coordinator.ts";
|
||||
export {
|
||||
createQueryCacheAdapter,
|
||||
createQueryClient,
|
||||
QUERY_CACHE_DEFAULTS,
|
||||
type QueryCacheDependencies,
|
||||
} from "./tanstack-query-cache.ts";
|
||||
```
|
||||
|
||||
- [x] **Step 2: 배럴 3개 생성 (주석이 계약인 것들)**
|
||||
|
||||
`src/adapters/platform/index.ts`:
|
||||
```ts
|
||||
/**
|
||||
* 어댑터 커널의 공개 경계.
|
||||
*
|
||||
* `src/adapters/**` 안에서는 이 배럴을 쓰지 않는다. 커널 프리미티브는 파일
|
||||
* 경로로 직접 import한다 — `scripts/check-adapter-inventory.ts:63-83`이
|
||||
* `platform/abortable-operation.ts`로 해석되는 specifier를 네 소비자에게
|
||||
* 요구하고, `.dependency-cruiser.json`의 kernel carve-out도 폴더 단위다.
|
||||
* 이 배럴은 bootstrap·features·tests 같은 그룹 바깥 소비자를 위한 문이다.
|
||||
*/
|
||||
export {
|
||||
compensateLateHandle,
|
||||
createAbortableOperation,
|
||||
snapshotAbortTimers,
|
||||
type AbortableOperation,
|
||||
type AbortableOperationInput,
|
||||
type AbortRace,
|
||||
type AbortTerminalReason,
|
||||
type AbortTimerSnapshot,
|
||||
} from "./abortable-operation.ts";
|
||||
export { assertBoundedCapacity } from "./bounded-capacity.ts";
|
||||
export {
|
||||
createBrowserLifecycleRuntime,
|
||||
type BrowserLifecycleEvent,
|
||||
type BrowserLifecycleRuntime,
|
||||
type BrowserLifecycleSnapshot,
|
||||
} from "./browser-lifecycle.ts";
|
||||
export {
|
||||
createBrowserMutationIntentFactory,
|
||||
type BrowserMutationIntentFactoryDependencies,
|
||||
} from "./browser-mutation-intent-factory.ts";
|
||||
export { systemClock } from "./system-clock.ts";
|
||||
```
|
||||
|
||||
`src/adapters/service-worker/index.ts`:
|
||||
```ts
|
||||
/**
|
||||
* `service-worker-entry.ts`는 여기서 참조하지 않는다. `tsconfig.app.json:18`이
|
||||
* 제외한 파일이라 참조하면 app 타입체크가 제외 대상을 끌어들인다. 그 파일은
|
||||
* export가 0개이므로 넣을 것도 없다.
|
||||
*/
|
||||
export {
|
||||
createServiceWorkerRuntime,
|
||||
type WorkerClientLike,
|
||||
type WorkerRuntimeConfig,
|
||||
type WorkerScopeLike,
|
||||
} from "./service-worker-lifecycle.ts";
|
||||
export {
|
||||
createServiceWorkerPageController,
|
||||
type ActivationBlocker,
|
||||
type PageControllerDependencies,
|
||||
} from "./service-worker-page-controller.ts";
|
||||
export {
|
||||
createNonceRegistry,
|
||||
createServiceWorkerMessage,
|
||||
parseServiceWorkerMessage,
|
||||
type ParsedMessage,
|
||||
} from "./service-worker-protocol.ts";
|
||||
```
|
||||
|
||||
`src/adapters/http/index.ts`:
|
||||
```ts
|
||||
/**
|
||||
* §7–§8. 권장 경로는 V3 계약 실행기(`createContractHttpExecutor`)다. 설치된
|
||||
* 계약과 타입 입력을 받아 상한·전체 데드라인·재시도 권한·효과 확실성 판정을
|
||||
* 런타임이 소유한다.
|
||||
*/
|
||||
export {
|
||||
createContractHttpExecutor,
|
||||
type AuthIntegrationFailureReason,
|
||||
type AuthOperationContext,
|
||||
type CancellationOwner,
|
||||
type ContractHttpExecutor,
|
||||
type ContractHttpExecutorDependencies,
|
||||
type HttpContractViolation,
|
||||
type HttpContractViolationKind,
|
||||
type HttpEffectCertainty,
|
||||
type HttpExecutionContext,
|
||||
type HttpExecutionObservation,
|
||||
type HttpExecutionOutcome,
|
||||
type HttpTransportFailure,
|
||||
type SafeResponseMetadata,
|
||||
} from "./http-execution-v3.ts";
|
||||
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
|
||||
export type { CredentialPatchOutcome } from "./http-contract-bridge.ts";
|
||||
/**
|
||||
* V2 legacy. operationId + `LegacyHttpInput`으로 호출하는 범용 클라이언트다.
|
||||
* 새 코드는 위의 V3 실행기를 쓴다. 남아 있는 이유는 계약이 아직 없는
|
||||
* 오퍼레이션을 위한 이행 경로이기 때문이다.
|
||||
*/
|
||||
export {
|
||||
createHttpClient,
|
||||
type HttpClient,
|
||||
type HttpClientDependencies,
|
||||
type HttpFailure,
|
||||
type HttpResult,
|
||||
type LegacyHttpInput,
|
||||
type Scheduler,
|
||||
} from "./client.ts";
|
||||
/** V2 `HttpClient.execute`의 첫 인자 타입. */
|
||||
export type { OperationRequestInput } from "./request-builder.ts";
|
||||
```
|
||||
|
||||
- [x] **Step 3: 타입체크로 심볼 존재를 검증한다**
|
||||
|
||||
Run: `corepack pnpm check:types:app`
|
||||
Expected: PASS. 실패하면 존재하지 않는 심볼을 재수출한 것이다 — 에러가 지목한 이름을 해당 소스 파일에서 확인하고 배럴에서 빼라. spec §7의 대조표와 대조할 것.
|
||||
|
||||
- [x] **Step 4: 게이트가 새 파일 8개를 거부하는 것을 확인한다 (의도된 실패)**
|
||||
|
||||
Run: `git add -A && corepack pnpm check:adapter-inventory`
|
||||
Expected: **FAIL.** `git ls-files src/adapters`가 128개를 보고하는데 INVENTORY.md는 120행이므로 불일치를 보고한다. 이 실패를 본 뒤 Step 5로 간다. (실패하지 않으면 `git add`가 안 된 것이다.)
|
||||
|
||||
- [x] **Step 5: INVENTORY.md에 행 8개 추가**
|
||||
|
||||
`docs/reviews/adapters/INVENTORY.md`의 표에 경로 알파벳 순서 위치로 끼워 넣고 번호를 다시 매긴다. 상세 리뷰 링크는 같은 그룹의 기존 행과 동일하게 쓴다.
|
||||
|
||||
| 추가할 경로 | 상세 리뷰 링크 |
|
||||
|---|---|
|
||||
| `src/adapters/auth/index.ts` | `[Network/state](./01-network-and-state.md)` |
|
||||
| `src/adapters/diagnostics/index.ts` | `[Network/state](./01-network-and-state.md)` |
|
||||
| `src/adapters/http/index.ts` | `[Network/state](./01-network-and-state.md)` |
|
||||
| `src/adapters/platform/index.ts` | `[Network/state](./01-network-and-state.md)` |
|
||||
| `src/adapters/query-cache/index.ts` | `[Network/state](./01-network-and-state.md)` |
|
||||
| `src/adapters/service-worker/index.ts` | `[Worker/push](./05-service-worker-and-web-push.md)` |
|
||||
| `src/adapters/storage/index.ts` | `[Storage/files](./03-storage-and-browser-files.md)` |
|
||||
| `src/adapters/telemetry/index.ts` | `[Network/state](./01-network-and-state.md)` |
|
||||
|
||||
그리고 `docs/reviews/adapters/INVENTORY.md:132`의 `합계: **120/120**` → `합계: **128/128**`.
|
||||
|
||||
- [x] **Step 6: 게이트 3종 통과 확인**
|
||||
|
||||
Run: `corepack pnpm check:adapter-inventory && corepack pnpm check:types:app && corepack pnpm check:architecture`
|
||||
Expected: 셋 다 PASS. `check:adapter-inventory`가 `128 files PASS`를 출력한다.
|
||||
|
||||
- [x] **Step 7: 워커 realm 타입체크 — 실행 중 발견한 필수 단계**
|
||||
|
||||
> 2026-09-16 실행 중 발견. 계획 초안에는 없었다.
|
||||
> `tsconfig.service-worker.json`은 `src/adapters/service-worker` 폴더를 통째로
|
||||
> WebWorker lib로 컴파일하면서 페이지 realm 파일 2개만 `exclude`로 뺀다.
|
||||
> 새 `index.ts`가 그 폴더 안에서 `service-worker-page-controller.ts`를
|
||||
> import하므로, 제외하지 않으면 페이지 realm 파일이 WebWorker lib 컴파일에
|
||||
> 끌려 들어와 `Cannot find name 'document'`로 실패한다.
|
||||
> (M6 리뷰의 "realm 경계가 폴더가 아니라 tsconfig exclude 2줄로만 표현된다"가
|
||||
> 그대로 발현된 것이다.)
|
||||
|
||||
`tsconfig.service-worker.json`의 `exclude` 배열 맨 앞에 추가한다:
|
||||
|
||||
```json
|
||||
"src/adapters/service-worker/index.ts",
|
||||
```
|
||||
|
||||
타입 커버리지는 `tsconfig.app.json`이 이 배럴을 포함하므로 유지된다. 워커 진입점
|
||||
`service-worker-entry.ts`는 배럴을 쓰지 않고 파일을 직접 import하므로 워커 번들에
|
||||
영향이 없다.
|
||||
|
||||
Run: `corepack pnpm check:types:service-worker`
|
||||
Expected: PASS
|
||||
|
||||
- [x] **Step 8: 제거 드릴이 살아 있는지 확인한다**
|
||||
|
||||
`storage/index.ts`를 새로 만들었으므로 제거 드릴을 돌려 서브폴더 삭제가 여전히 성립하는지 본다.
|
||||
|
||||
Run: `corepack pnpm test:browser-file-storage-removal`
|
||||
Expected: 드릴 출력에 `error TS`가 0건이어야 한다. `storage/index.ts`가 `indexeddb/`나 `opfs/`를 참조하면 `still imported`로 죽는다 — Step 1의 주석대로 재수출을 제거하라.
|
||||
|
||||
> **이 환경의 알려진 제약.** `tests/unit/ci-artifact-contract.test.ts`의 16건은
|
||||
> `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`로 실패한다.
|
||||
> 샌드박스가 네트워크 네임스페이스를 못 만들기 때문이며 `develop` `5434760`
|
||||
> 기준선에서도 동일하게 16건 실패한다(2026-09-16 확인). 이 드릴은 내부적으로
|
||||
> `test:unit`을 돌리므로 그 16건 때문에 항상 exit 1이 된다.
|
||||
> **판정 기준은 드릴의 exit code가 아니라 `error TS` 0건과 `still imported` 부재다.**
|
||||
> CI 환경에서는 전체 PASS를 확인할 것.
|
||||
|
||||
- [x] **Step 9: 커밋**
|
||||
|
||||
```bash
|
||||
git add src/adapters/*/index.ts docs/reviews/adapters/INVENTORY.md tsconfig.service-worker.json
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat: give every adapter group a public barrel
|
||||
|
||||
각 어댑터 그룹의 공개 표면을 index.ts로 선언한다. 소비자는 아직 바꾸지
|
||||
않았으므로 런타임 동작은 그대로다. storage 배럴은 서브배럴을 재수출하지
|
||||
않는다 — 제거 드릴이 indexeddb/와 opfs/만 삭제하기 때문이다.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 그룹 바깥 소비자 15줄을 배럴 경로로 치환
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bootstrap/runtime-adapters.ts:6-29` (12줄, 블록 병합 포함)
|
||||
- Modify: `src/bootstrap/main.tsx:3`
|
||||
- Modify: `src/bootstrap/optional-runtime-host.ts:4`
|
||||
- Modify: `src/bootstrap/register-service-worker.ts:5`
|
||||
- Modify: `src/features/reference-feature/adapters/create-reference-feature-input.ts:9`
|
||||
- Modify: `.storybook/preview.tsx:5-6` (게이트 범위 밖, 일관성 목적)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1이 만든 8개 배럴의 심볼 82개
|
||||
- Produces: `src/` 안에 어댑터 내부 파일을 직접 겨누는 import 0건. Task 3의 게이트 규칙이 이 상태를 전제로 통과한다.
|
||||
|
||||
- [x] **Step 1: 치환 전 위반 건수를 기록한다**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rn 'from "[^"]*adapters/[^"]*"' src/ | grep -v '^src/adapters/' | grep -v 'index\.ts"' | wc -l
|
||||
```
|
||||
Expected: `15`. 이 숫자가 Step 4에서 `0`이 되어야 한다.
|
||||
|
||||
- [x] **Step 2: `runtime-adapters.ts`의 import 블록을 다시 쓴다**
|
||||
|
||||
`src/bootstrap/runtime-adapters.ts`의 `:6`~`:29` 구간이 한 덩어리다. 아래에서 위로 편집하거나 블록 전체를 한 번에 교체한다 — 위에서부터 고치면 줄 번호가 밀린다.
|
||||
|
||||
치환 내용:
|
||||
|
||||
| 현재 specifier | 바뀔 specifier |
|
||||
|---|---|
|
||||
| `../adapters/auth/external-session-adapter.ts` | `../adapters/auth/index.ts` |
|
||||
| `../adapters/diagnostics/bounded-diagnostics.ts` | `../adapters/diagnostics/index.ts` |
|
||||
| `../adapters/http/client.ts` | `../adapters/http/index.ts` |
|
||||
| `../adapters/http/http-execution-v3.ts` | `../adapters/http/index.ts` (위와 **한 블록으로 병합**) |
|
||||
| `../adapters/query-cache/tanstack-cache-coordinator.ts` | `../adapters/query-cache/index.ts` |
|
||||
| `../adapters/query-cache/tanstack-query-cache.ts` | `../adapters/query-cache/index.ts` |
|
||||
| `../adapters/query-cache/server-state-scope-runtime.ts` | `../adapters/query-cache/index.ts` |
|
||||
| `../adapters/query-cache/conditional-validator-store.ts` | `../adapters/query-cache/index.ts` (위 넷을 **한 블록으로 병합**) |
|
||||
| `../adapters/storage/browser-storage-adapter.ts` | `../adapters/storage/index.ts` |
|
||||
| `../adapters/platform/browser-mutation-intent-factory.ts` | `../adapters/platform/index.ts` |
|
||||
| `../adapters/telemetry/best-effort-telemetry.ts` | `../adapters/telemetry/index.ts` |
|
||||
|
||||
`../adapters/cross-context-invalidation/index.ts` 2줄은 이미 배럴이므로 **건드리지 않는다.**
|
||||
|
||||
- [x] **Step 3: 나머지 4개 파일을 치환한다**
|
||||
|
||||
| 파일:줄 | 현재 | 바뀔 것 |
|
||||
|---|---|---|
|
||||
| `src/bootstrap/main.tsx:3` | `../adapters/diagnostics/bounded-diagnostics.ts` | `../adapters/diagnostics/index.ts` |
|
||||
| `src/bootstrap/optional-runtime-host.ts:4` | `../adapters/platform/browser-lifecycle.ts` | `../adapters/platform/index.ts` |
|
||||
| `src/bootstrap/register-service-worker.ts:5` | `../adapters/service-worker/service-worker-page-controller.ts` | `../adapters/service-worker/index.ts` |
|
||||
| `src/features/reference-feature/adapters/create-reference-feature-input.ts:9` | `../../../adapters/http/http-execution-v3.ts` | `../../../adapters/http/index.ts` |
|
||||
| `.storybook/preview.tsx:5` | `../src/adapters/auth/external-session-adapter.ts` | `../src/adapters/auth/index.ts` |
|
||||
| `.storybook/preview.tsx:6` | `../src/adapters/query-cache/tanstack-query-cache.ts` | `../src/adapters/query-cache/index.ts` |
|
||||
|
||||
- [x] **Step 4: 위반이 0이 된 것을 확인한다**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rn 'from "[^"]*adapters/[^"]*"' src/ | grep -v '^src/adapters/' | grep -v 'index\.ts"' | wc -l
|
||||
```
|
||||
Expected: `0`
|
||||
|
||||
- [x] **Step 5: 타입·린트·아키텍처 게이트**
|
||||
|
||||
Run: `corepack pnpm check:types:app && corepack pnpm lint && corepack pnpm check:architecture`
|
||||
Expected: 셋 다 PASS.
|
||||
|
||||
- [x] **Step 6: 번들 예산을 확인한다 — 이 태스크의 최대 위험**
|
||||
|
||||
배럴 재수출이 트리셰이킹을 무력화하면 초기 청크가 커진다. `package.json`에 `sideEffects` 선언이 없어 번들러가 모든 모듈을 부작용 있는 것으로 본다.
|
||||
|
||||
Run: `corepack pnpm check:bundle`
|
||||
Expected: PASS (`bundle.initialJsGzipBytes` 상한 204800).
|
||||
|
||||
**FAIL한 경우 순서대로 시도한다:**
|
||||
1. `package.json`에 `"sideEffects": false`를 추가한다. 추가 전에 `src/adapters` 아래 최상위 부작용이 있는지 확인하고, CSS import가 있으면 `"sideEffects": ["*.css"]` 형태로 보존한다.
|
||||
2. 그래도 넘치면 `src/adapters/service-worker/index.ts`에서 `service-worker-lifecycle.ts` 블록을 빼고, 워커 realm은 파일 직접 import를 유지한다. 그리고 Task 3의 게이트 규칙 `from.pathNot`에 워커 진입점을 추가한다. (근거: realm이 다르면 문도 다르다 — `platform`과 같은 논리.)
|
||||
3. 1·2로 안 되면 이 태스크를 중단하고 보고한다. 예산 초과를 안고 진행하지 않는다.
|
||||
|
||||
- [x] **Step 7: 단위·통합 테스트**
|
||||
|
||||
Run: `corepack pnpm test:unit && corepack pnpm test:integration`
|
||||
Expected: PASS. 이 태스크는 import 경로만 바꿨으므로 테스트 결과가 달라질 이유가 없다. 깨지면 배럴이 내보내는 심볼이 원본과 다른 것이다.
|
||||
|
||||
- [x] **Step 8: 커밋**
|
||||
|
||||
```bash
|
||||
git add src/bootstrap src/features .storybook
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor: reach adapter groups through their barrel
|
||||
|
||||
bootstrap과 feature 어댑터가 어댑터 내부 파일을 직접 겨누던 15곳을 그룹
|
||||
배럴로 바꾼다. 커널(platform/**) 간선과 그룹 내부 import는 파일 경로를
|
||||
유지한다 — check:adapter-inventory가 그 경로를 직접 검증한다.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 게이트 규칙 + 회귀 fixture
|
||||
|
||||
규칙만 추가하면 다음 사람이 규칙을 지워도 아무도 모른다. 이 레포는 거부 규칙마다 회귀 fixture를 두는 방식(`docs/architecture/layers.md`)이므로 fixture까지 같이 넣는다.
|
||||
|
||||
**Files:**
|
||||
- Modify: `.dependency-cruiser.json` (`:193`과 `:194` 사이에 규칙 1개)
|
||||
- Create: `tests/fixtures/architecture/dependency-graph/barrel/bootstrap/compose-deep.ts`
|
||||
- Create: `tests/fixtures/architecture/dependency-graph/barrel/bootstrap/compose-barrel.ts`
|
||||
- Create: `tests/fixtures/architecture/dependency-graph/barrel/adapters/http/client.ts`
|
||||
- Create: `tests/fixtures/architecture/dependency-graph/barrel/adapters/http/index.ts`
|
||||
- Modify: `scripts/check-architecture.ts` (`runGraphFixtureChecks`에 그래프 1개 + assertion 2개)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2가 만든 "위반 0건" 상태. 위반이 남아 있으면 이 규칙 추가가 곧바로 게이트를 깬다.
|
||||
- Produces: `adapter-groups-are-reached-through-their-barrel` 규칙과 그 회귀 fixture 2종
|
||||
|
||||
- [x] **Step 1: fixture 트리를 만든다 (규칙보다 먼저 — 실패를 먼저 본다)**
|
||||
|
||||
`tests/fixtures/architecture/dependency-graph/barrel/adapters/http/client.ts`:
|
||||
```ts
|
||||
export function createFixtureHttpClient(): string {
|
||||
return "fixture";
|
||||
}
|
||||
```
|
||||
|
||||
`tests/fixtures/architecture/dependency-graph/barrel/adapters/http/index.ts`:
|
||||
```ts
|
||||
export { createFixtureHttpClient } from "./client.ts";
|
||||
```
|
||||
|
||||
`tests/fixtures/architecture/dependency-graph/barrel/bootstrap/compose-deep.ts` — 규칙이 **잡아야 할** 형태:
|
||||
```ts
|
||||
import { createFixtureHttpClient } from "../adapters/http/client.ts";
|
||||
|
||||
export const deepComposition = createFixtureHttpClient;
|
||||
```
|
||||
|
||||
`tests/fixtures/architecture/dependency-graph/barrel/bootstrap/compose-barrel.ts` — 규칙이 **통과시켜야 할** 형태:
|
||||
```ts
|
||||
import { createFixtureHttpClient } from "../adapters/http/index.ts";
|
||||
|
||||
export const barrelComposition = createFixtureHttpClient;
|
||||
```
|
||||
|
||||
> fixture는 `analyzeSourceGraph(dir, "src")`로 분석되어 경로가 `src/...`로 보고된다(`scripts/check-architecture.ts:821-833`). 그래서 `^src/`로 시작하는 규칙이 fixture 트리에 그대로 적용된다.
|
||||
|
||||
- [x] **Step 2: `.dependency-cruiser.json`에 규칙을 추가한다**
|
||||
|
||||
`forbidden` 배열의 `adapters-do-not-know-other-concrete-adapters` **바로 다음**, `no-circular-dependencies` **앞**에 넣는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "adapter-groups-are-reached-through-their-barrel",
|
||||
"comment": "어댑터 그룹의 공개 표면은 그 그룹의 index.ts다. 그룹 바깥(bootstrap, features, presentation)은 배럴만 import한다. 배럴이 없던 시절 bootstrap은 어댑터 내부 파일 15곳을 직접 겨눴고, 그래서 어떤 파일이 공개이고 어떤 파일이 내부 헬퍼인지 아무 데도 적혀 있지 않았다. 출발점에서 src/adapters를 뺀 이유는 어댑터끼리의 간선은 바로 위 adapters-do-not-know-other-concrete-adapters가 이미 담당하고, 커널(platform/**)은 파일 단위로 공유되기 때문이다 — scripts/check-adapter-inventory.ts가 네 소비자에게 platform/abortable-operation.ts로 해석되는 specifier를 직접 요구한다. 도착점에서 1단계 중첩 index.ts를 허용한 이유는 storage/indexeddb와 storage/opfs가 각자 독립적으로 제거 가능한 런타임이고(scripts/test-browser-file-storage-runtime-removal.ts), 그래서 각자의 배럴이 곧 경계이기 때문이다.",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/",
|
||||
"pathNot": "^src/adapters/"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/adapters/[^/]+/",
|
||||
"pathNot": "^src/adapters/[^/]+/(?:[^/]+/)?index\\.ts$"
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
> 규칙 형태 적합성: `scripts/check-architecture.ts:799-815`의 `validateArchitectureRules`는 `from`에 `path`/`pathNot`, `to`에 `path`/`pathNot`/`circular`만 허용한다. 정규식은 `new RegExp(pattern, "u")`로 평가되므로 비캡처 그룹 `(?:...)`이 허용된다. `$1` 역참조는 쓰지 않았다.
|
||||
|
||||
- [x] **Step 3: 규칙이 실제 소스에서 위반 0인지 확인한다**
|
||||
|
||||
Run: `corepack pnpm check:architecture`
|
||||
Expected: PASS. FAIL하면 Task 2에서 놓친 import가 있다는 뜻이다 — 출력이 지목한 파일을 배럴 경로로 고쳐라.
|
||||
|
||||
- [x] **Step 4: fixture 검사를 `check-architecture.ts`에 배선한다**
|
||||
|
||||
`runGraphFixtureChecks()`의 `Promise.all` 블록(`scripts/check-architecture.ts:826-833`)에 `barrelGraph`를 추가한다:
|
||||
|
||||
```ts
|
||||
const [allowedGraph, unresolvedGraph, layerGraph, cycleGraph, barrelGraph] =
|
||||
await Promise.all([
|
||||
analyzeSourceGraph(allowedRoot, "src"),
|
||||
analyzeSourceGraph(resolve(fixtureRoot, "unresolved"), "src"),
|
||||
analyzeSourceGraph(resolve(fixtureRoot, "layer"), "src"),
|
||||
analyzeSourceGraph(resolve(fixtureRoot, "cycle"), "src"),
|
||||
analyzeSourceGraph(resolve(fixtureRoot, "barrel"), "src"),
|
||||
]);
|
||||
```
|
||||
|
||||
그리고 `assertions` 배열에 두 항목을 추가한다:
|
||||
|
||||
```ts
|
||||
{
|
||||
name: "deep adapter import from outside the group is rejected",
|
||||
passed: blockingViolations(barrelGraph).some(
|
||||
({ rule, source, target }) =>
|
||||
rule === "adapter-groups-are-reached-through-their-barrel" &&
|
||||
source === "src/bootstrap/compose-deep.ts" &&
|
||||
target === "src/adapters/http/client.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "barrel import from outside the group is accepted",
|
||||
passed: !blockingViolations(barrelGraph).some(
|
||||
({ source }) => source === "src/bootstrap/compose-barrel.ts",
|
||||
),
|
||||
},
|
||||
```
|
||||
|
||||
> 필드명 근거: `ArchitectureViolation`은 `rule` / `severity` / `source` / `target`을 갖는다 (`scripts/check-architecture.ts:35`, 생성부 `:734-739`). `from`/`to`가 아니다.
|
||||
|
||||
- [x] **Step 5: fixture 회귀 검사가 통과하는지 확인한다**
|
||||
|
||||
Run: `corepack pnpm check:architecture`
|
||||
Expected: `Architecture graph fixtures: 14 regression checks PASS` (기존 12 + 신규 2). 그리고 전체 PASS.
|
||||
|
||||
- [x] **Step 6: fixture가 실제로 무언가를 잡는지 역검증한다**
|
||||
|
||||
규칙을 잠시 무력화해서 fixture가 FAIL하는지 본다. fixture가 항상 통과하면 회귀 검사가 아니다.
|
||||
|
||||
```bash
|
||||
# 규칙 이름을 일시적으로 바꿔 매칭되지 않게 한다
|
||||
sed -i 's/"adapter-groups-are-reached-through-their-barrel"/"temporarily-disabled-barrel-rule"/' .dependency-cruiser.json
|
||||
corepack pnpm check:architecture; echo "EXIT=$?"
|
||||
# 되돌린다
|
||||
sed -i 's/"temporarily-disabled-barrel-rule"/"adapter-groups-are-reached-through-their-barrel"/' .dependency-cruiser.json
|
||||
corepack pnpm check:architecture; echo "EXIT=$?"
|
||||
```
|
||||
Expected: 첫 번째 EXIT는 0이 아니어야 하고(assertion 실패), 되돌린 뒤 EXIT는 0이어야 한다.
|
||||
|
||||
- [x] **Step 7: 커밋**
|
||||
|
||||
```bash
|
||||
git add .dependency-cruiser.json scripts/check-architecture.ts tests/fixtures/architecture/dependency-graph/barrel
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat: enforce the adapter barrel boundary in the architecture gate
|
||||
|
||||
그룹 바깥에서 어댑터 내부 파일을 직접 import하면 check:architecture가
|
||||
거부한다. 회귀 fixture 2개가 거부와 허용을 각각 고정한다 — 규칙을 지우면
|
||||
fixture 검사가 먼저 깨진다.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 규칙을 문서에 명문화
|
||||
|
||||
코드로 강제되는 규칙이 문서에 없으면 다음 사람은 게이트 에러를 보고서야 규칙을 알게 된다.
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/layers.md` (배럴 경계 절 추가)
|
||||
- Modify: `docs/reviews/adapters/README.md` (테스트 이관 규칙)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 3의 규칙 이름 `adapter-groups-are-reached-through-their-barrel`
|
||||
- Produces: 없음 (문서만)
|
||||
|
||||
- [x] **Step 1: `layers.md`에 배럴 경계 절을 추가한다**
|
||||
|
||||
"The adapter kernel" 절 **다음에** 아래를 넣는다:
|
||||
|
||||
```markdown
|
||||
## 어댑터 그룹의 공개 경계
|
||||
|
||||
각 어댑터 그룹의 공개 표면은 그 그룹의 `index.ts`다. 그룹 바깥
|
||||
(`bootstrap`, `features`, `presentation`)은 배럴만 import한다.
|
||||
`adapter-groups-are-reached-through-their-barrel` 규칙이 이를 강제하고,
|
||||
`tests/fixtures/architecture/dependency-graph/barrel`이 거부와 허용을
|
||||
각각 고정한다.
|
||||
|
||||
두 가지 예외가 있고 둘 다 의도된 것이다.
|
||||
|
||||
- **그룹 내부 파일끼리**는 파일 경로로 직접 import한다. 배럴은 바깥을 위한
|
||||
문이지 내부 규율이 아니다.
|
||||
- **어댑터 → 커널(`platform/**`)** 간선도 파일 경로를 유지한다. 커널은
|
||||
런타임 합성물이 아니라 프리미티브이고, `check:adapter-inventory`가 네
|
||||
소비자에게 `platform/abortable-operation.ts`로 해석되는 specifier를
|
||||
직접 요구한다. 커널 배럴(`platform/index.ts`)은 bootstrap과 테스트를
|
||||
위한 것이다.
|
||||
|
||||
`storage`는 최상위 배럴이 `indexeddb/`·`opfs/` 서브배럴을 재수출하지
|
||||
않는다. 두 런타임이 각자 독립적으로 제거 가능하고
|
||||
(`test:browser-file-storage-removal`), 그래서 각 서브배럴이 곧 경계다.
|
||||
규칙의 도착점 정규식이 1단계 중첩 `index.ts`를 배럴로 인정하는 이유가
|
||||
이것이다.
|
||||
```
|
||||
|
||||
- [x] **Step 2: 테스트 이관 규칙을 적는다**
|
||||
|
||||
`docs/reviews/adapters/README.md` 끝에 추가:
|
||||
|
||||
```markdown
|
||||
## 테스트의 어댑터 import
|
||||
|
||||
`tests/` 아래 어댑터 import는 배럴로 일괄 이관하지 않는다. `check:architecture`는
|
||||
`src`만 스캔하므로 강제되지 않고, 단위 테스트의 상당수가 배럴에 없는 내부
|
||||
심볼을 의도적으로 겨눈다.
|
||||
|
||||
어떤 테스트 파일을 **다른 이유로** 수정하거나 분할할 때, 그 파일이 쓰는
|
||||
심볼이 해당 그룹 배럴에 있으면 그 파일 안에서만 배럴 경로로 바꾼다.
|
||||
배럴에 없는 심볼이면 깊은 경로를 유지한다. 배럴에 추가하고 싶으면 그 심볼이
|
||||
공개 표면임을 먼저 논증한다 — 테스트 편의로 배럴을 키우면 배럴이 경계가
|
||||
아니라 재수출 덤프가 된다.
|
||||
```
|
||||
|
||||
- [x] **Step 3: 문서 게이트 확인**
|
||||
|
||||
Run: `corepack pnpm lint && corepack pnpm check:architecture`
|
||||
Expected: PASS. (문서 링크 검증이 있으면 `corepack pnpm verify:documentation`도 돌린다.)
|
||||
|
||||
- [x] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add docs/architecture/layers.md docs/reviews/adapters/README.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs: write down the adapter barrel boundary and its two exceptions
|
||||
|
||||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 완료 판정
|
||||
|
||||
네 태스크가 끝나면 아래가 전부 PASS여야 한다.
|
||||
|
||||
```bash
|
||||
corepack pnpm check:types
|
||||
corepack pnpm lint
|
||||
corepack pnpm check:architecture
|
||||
corepack pnpm check:adapter-inventory
|
||||
corepack pnpm check:bundle
|
||||
corepack pnpm test:unit
|
||||
corepack pnpm test:integration
|
||||
corepack pnpm test:browser-file-storage-removal
|
||||
corepack pnpm test:realtime-removal
|
||||
```
|
||||
|
||||
그리고 아래가 `0`이어야 한다.
|
||||
```bash
|
||||
grep -rn 'from "[^"]*adapters/[^"]*"' src/ | grep -v '^src/adapters/' | grep -v 'index\.ts"' | wc -l
|
||||
```
|
||||
|
||||
## 이 계획이 하지 않는 것
|
||||
|
||||
- `tests/` 166줄의 배럴 이관 — Task 4 Step 2의 규칙대로 파일을 손댈 때만 한다.
|
||||
- 대형 파일 분할 — 별도 계획. 이 계획이 그 선행조건이다.
|
||||
- spec §6이 남긴 부수 발견 4건(`telemetry:49`의 잉여 재수출, `browser-files/index.ts`의 포트 재수출, `opfs/index.ts` 누락 심볼 3개, `.dependency-cruiser.json:141`의 존재하지 않는 `web-worker` 경로) — 각각 별도 티켓.
|
||||
|
||||
---
|
||||
|
||||
## 실행 기록 (2026-09-16 완료)
|
||||
|
||||
네 태스크 전부 실행했다. 커밋 5개: `a42d961` (spec·plan), `b29a471` (배럴 8개),
|
||||
`1606d9b` (소비자 15줄), `27ab17d` (게이트 규칙 + fixture), `eb40bc9` (문서).
|
||||
|
||||
**계획과 달랐던 것 2건.**
|
||||
|
||||
1. **워커 realm 타입체크 실패** — 계획 초안에 없던 Task 1 Step 7이 여기서 나왔다.
|
||||
`service-worker/index.ts`가 `tsconfig.service-worker.json`의 WebWorker lib
|
||||
컴파일에 페이지 realm 파일을 끌어들여 `Cannot find name 'document'`로 실패했다.
|
||||
배럴을 그 tsconfig의 `exclude`에 추가해 해결.
|
||||
2. **spec의 정규식이 게이트에 거부당함** — spec §5.1이 제시한
|
||||
`^src/adapters/[^/]+/(?:[^/]+/)?index\.ts$`를 dependency-cruiser가
|
||||
"unsafe regular expression"으로 거부했다(`(?:[^/]+/)?` 안의 `+`가 star height 2).
|
||||
중첩 없는 교대로 바꿔 통과시켰다:
|
||||
`^src/adapters/[^/]+/index\.ts$|^src/adapters/[^/]+/[^/]+/index\.ts$`
|
||||
spec은 이 규칙을 실제로 실행해 본 적이 없었다.
|
||||
|
||||
**측정 결과.**
|
||||
|
||||
- 번들 영향 **0바이트**: 배럴 도입 전후 모두 초기 JS 181114 / 204800 gzip bytes.
|
||||
`sideEffects` 선언이 없는데도 Rollup이 재수출을 트리셰이킹했다. 계획이 지목한
|
||||
최대 위험은 현실화되지 않았고 대응 3단계는 쓰지 않았다.
|
||||
- import 그래프: 297 모듈 / 897 의존 → 305 모듈 / 914 의존 (배럴 8개 + fixture 4개).
|
||||
- 회귀 검사: 12 → 14. 규칙 이름을 바꿔 fixture가 실제로 깨지는 것을 확인했다.
|
||||
- 배럴 미경유 import: `src/` 15건 → **0건**.
|
||||
|
||||
**PASS:** `check:types` `lint` `check:architecture` `check:adapter-inventory`
|
||||
`check:bundle` `test:integration`(81) `test:component`(130) `verify:documentation`
|
||||
|
||||
**이 환경에서 판정 불가:** `test:unit`, `test:browser-file-storage-removal`,
|
||||
`test:realtime-removal`. 셋 다 `tests/unit/ci-artifact-contract.test.ts`의 16건이
|
||||
`bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`로 실패해 exit 1이
|
||||
된다. `develop` `5434760` 기준선에서도 동일하게 16건 실패함을 stash 후 실행해
|
||||
확인했다. 어댑터 관련 테스트는 전부 통과했고(`test:unit` 1796 passed), 제거 드릴의
|
||||
`error TS`는 0건이다. **CI 환경에서 이 셋의 전체 PASS를 확인해야 한다.**
|
||||
@@ -0,0 +1,464 @@
|
||||
# IndexedDB 커널 승격 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 어댑터 4곳이 각자 구현한 IndexedDB 연결·트랜잭션 메커니즘을 `src/adapters/platform/`의 커널 2파일로 모으고, 4벌이 서로 다른 답을 내던 지점을 하나로 만든다.
|
||||
|
||||
**Architecture:** 커널은 **메커니즘만** 갖는다 — open 요청을 Promise로 바꾸기, blocked 데드라인, upgrade/error/success 라우팅, 늦게 도착한 연결 닫기, 트랜잭션 상태기계, 커서 펌프. 데이터베이스 이름·스키마·마이그레이션·governance·**실패 분류(taxonomy)**는 각 서브시스템에 남는다. 실패 매핑은 `translate` 콜백으로 주입하므로 `mapIndexedDbException`과 `mapBrowserDataException`이 서로 다른 답을 내는 현 상태가 보존된다.
|
||||
|
||||
**Tech Stack:** TypeScript, IndexedDB, Vitest, `tests/helpers/memory-indexeddb.ts`(가짜 IDB)
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-09-16-indexeddb-kernel-promotion-design.md`](../specs/2026-09-16-indexeddb-kernel-promotion-design.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **`src/adapters/platform/`은 이미 dependency-cruiser의 kernel carve-out이다.** 커널에 파일을 추가하는 데 규칙 변경이 필요 없다(spec §5.3).
|
||||
- **`IDBFactory`는 필수 주입 파라미터다.** `globalThis.indexedDB`를 읽으면 안 된다 — `eslint.config.ts:40-63`이 모든 브라우저 루트에서 그 속성을 막고, 예외는 `platform/browser-lifecycle.ts` **한 파일**에만 부여돼 있다. 주입받으면 `eslint.config.ts`를 건드릴 필요가 없다(spec §5.1).
|
||||
- **소스 파일을 추가하면 `docs/reviews/adapters/INVENTORY.md`에 행을 추가하고 하단 합계를 고친다.** `check:adapter-inventory`가 `git ls-files src/adapters`와 집합을 정확히 대조한다.
|
||||
- **`check:adapter-inventory`에 abort 래칫이 있다.** `addEventListener("abort")`를 쓰는 어댑터 파일이 24개를 넘으면 실패한다. `platform/`은 세지 않는다.
|
||||
- **실패 매핑 4벌을 통일하지 마라.** `mapIndexedDbException`(RT/MT/OP)과 `mapBrowserDataException`(CP)은 같은 에러에 다른 답을 낸다 — `ConstraintError` recovery가 NONE vs REOPEN, `QuotaExceeded` retryable이 false vs true, `NotFound`가 MIGRATION_FAILED vs NOT_FOUND. 통일은 별건이고 이 계획의 범위가 아니다.
|
||||
- **`deleteDatabase`를 나머지 3벌에 추가하지 마라.** CP에만 있는 것이 의도다(spec §2.3).
|
||||
- **이 환경의 알려진 제약:** `tests/unit/ci-artifact-contract.test.ts` 16건이 `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`로 실패한다. `develop` `5434760` 기준선에서도 동일하다. **판정 기준은 그 파일 외의 실패가 0인지**다.
|
||||
- 커밋 메시지 끝에 붙일 것: `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
|
||||
|
||||
## 왜 이 작업을 하는가 — LOC로 정당화하지 않는다
|
||||
|
||||
> **2026-09-16 갱신 — 사양의 추정이 빗나갔다.** 커널을 실제로 구현하니
|
||||
> 2파일 **1,132줄**(코드 751줄)이다. 사양 추정 470줄의 2.4배다. 사양 §3.1이
|
||||
> "470 LOC라 2분할한다"고 쓴 논거도 사실이 아니었다(2분할 자체는 테스트 셋업이
|
||||
> 갈린다는 별도 근거로 유지). 그래서 아래 표의 순증감이 뒤집힌다.
|
||||
|
||||
| | 사양 추정 | 실제 |
|
||||
|---|---:|---:|
|
||||
| 4벌에서 삭제 | ~974 | (미측정, 이행 후 확정) |
|
||||
| 4벌에 추가 (커널 호출부·콜백) | ~281 | (미측정) |
|
||||
| 4벌 순감 | −693 | (미측정) |
|
||||
| 커널 신규 2파일 | +470 | **+1,132** |
|
||||
| 신규 커널 테스트 2파일 | (미기재) | **+1,602** |
|
||||
| **레포 순증감 (소스만)** | **−223** | **약 +439** |
|
||||
|
||||
**이 리팩토링은 줄 수를 줄이지 않는다. 늘린다.** 소스 약 +439줄, 테스트까지 하면
|
||||
약 +2,041줄이다. 사양은 "절감이 작다"고 썼지만 실제로는 절감이 아니라 증가다.
|
||||
|
||||
**그래서 이 작업의 근거는 오로지 하나다: 트랜잭션 상태기계가 4개에서 1개가 되는 것.**
|
||||
줄 수로 정당화하려는 시도는 이제 불가능하다. 근거가 성립하는 이유는 오늘 그 4개가
|
||||
이미 서로 다른 답을 내고 있기 때문이다:
|
||||
|
||||
| 상황 | RT | MT | OP | CP |
|
||||
|---|---|---|---|---|
|
||||
| 트랜잭션 안 개별 요청 실패 | 본다 | 기록만 | **안 본다** | 즉시 abort |
|
||||
| 값 없이 완료 | UNAVAILABLE | UNAVAILABLE | UNAVAILABLE | **CORRUPT_DATA** |
|
||||
| `deleteDatabase` | 없음 | 없음 | 없음 | **있음** |
|
||||
|
||||
리뷰가 "이미 갈라졌다"고 판정한 근거가 이 표다.
|
||||
|
||||
**그리고 이행을 멈추면 최악이다.** 커널만 넣고 사본을 안 옮기면 +1,132줄의
|
||||
쓰이지 않는 코드가 남는다. 이 레포가 이미 `abortable-operation.ts`로 겪고 있는
|
||||
병(커널은 있는데 24개 파일이 안 씀)을 하나 더 만드는 것이다. 되돌리려면 지금이
|
||||
가장 싸다 — 커널 커밋 하나를 revert하면 끝이고 사본은 아직 안 건드렸다.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 커널 2파일 + 단위 테스트
|
||||
|
||||
사본은 **손대지 않는다.** 코드 추가만 하므로 런타임 동작이 바뀌지 않는다.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/adapters/platform/indexeddb-connection.ts` (~240 LOC)
|
||||
- Create: `src/adapters/platform/indexeddb-transaction.ts` (~230 LOC)
|
||||
- Create: `tests/unit/indexeddb-connection.test.ts`
|
||||
- Create: `tests/unit/indexeddb-transaction.test.ts`
|
||||
- Modify: `docs/reviews/adapters/INVENTORY.md` (행 2개 + 합계 `128/128` → `130/130`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: spec §3.2·§3.3의 전체 export 시그니처. Task 2~5가 이것만 쓴다.
|
||||
- Consumes: 기존 커널 `snapshotAbortTimers`(`platform/abortable-operation.ts`), `contracts/result.ts`의 `Result`
|
||||
|
||||
- [x] **Step 1: 기존 커널 관례를 읽는다**
|
||||
|
||||
`src/adapters/platform/`의 5파일을 읽고 주석 스타일(왜 이 규칙이 있는지를 근거와 함께 적는 방식), 에러 처리, 의존성 주입 방식을 파악한다. 새 파일은 그 관례를 따른다.
|
||||
|
||||
- [x] **Step 2: 가짜 IDB의 오류 배선을 확인한다**
|
||||
|
||||
`tests/helpers/memory-indexeddb.ts`를 읽는다. 커널은 요청 레벨 `onerror`를 새로 보게 되므로 가짜가 `request.error`를 채우는지가 전제다.
|
||||
|
||||
확인됨(2026-09-16): 채운다. `:167-170`이 `request.error = asException(error)` 후 `queueMicrotask`로 `onerror` 발화, `:309-311`이 같은 일을 **동기로** 한다. **두 경로의 타이밍이 다르므로** 테스트에서 주의한다.
|
||||
|
||||
- [x] **Step 3: 실패하는 테스트를 먼저 쓴다**
|
||||
|
||||
최소한 아래를 고정한다. 각각 먼저 실패하는 것을 확인한 뒤 구현한다.
|
||||
|
||||
연결(`tests/unit/indexeddb-connection.test.ts`):
|
||||
- open 성공 / `onerror` / native throw
|
||||
- `onblocked` — deadline 미설정 시 `BLOCKED`, deadline 경과 시 `BLOCKED_DEADLINE`
|
||||
- upgrade `APPLIED`
|
||||
- upgrade `REJECTED` — **versionchange 트랜잭션이 abort되어 스키마가 커밋되지 않는 것까지** 확인
|
||||
- upgrade가 throw → `REJECTED` + `detail`에 thrown value
|
||||
- `newVersion`이 null → `upgrade` 실행 **전에** `UPGRADE_REJECTED`
|
||||
- admission `ADMIT` / `REJECT` / `FAIL` — 거부 시 연결이 **닫히는지**
|
||||
- 호출자가 포기한 뒤 늦게 도착한 연결이 닫히는지
|
||||
- `CALLER_ABORT`
|
||||
- `translate`가 각 cause에 대해 호출되는지
|
||||
|
||||
트랜잭션(`tests/unit/indexeddb-transaction.test.ts`):
|
||||
- 커밋 / abort
|
||||
- `succeed()` 없이 완료 → `NO_VALUE_PRODUCED`
|
||||
- 첫 결과가 이긴다(`succeed` 이후 `fail` 무시)
|
||||
- 커서 순회와 `SUSPEND` 스텝(중첩 요청 체인)
|
||||
- 예산 콜백이 "삭제 행 기준"과 "스캔 행 기준" 양쪽을 표현할 수 있는지
|
||||
- **CP-4 필수 요건:** `abort()`가 throw하면 caller-abort 표시를 세우지 않고 transaction 이벤트가 결과를 정한다
|
||||
|
||||
- [x] **Step 4: 구현한다 — 좁은 커널 함정을 피한다**
|
||||
|
||||
기존 커널 `abortable-operation.ts:11`은 `AbortTerminalReason` 3멤버를 **반환 타입**에 박아서 5종이 필요한 `http-execution-v3`가 아예 못 썼다. 같은 실수를 반복하면 이 작업은 실패다.
|
||||
|
||||
구현 후 아래를 확인한다:
|
||||
- `IndexedDbFailureCause`가 어떤 공개 **반환 타입**에도 나타나지 않는가 (`translate`의 입력으로만 쓰이는가)
|
||||
- spec §3.4의 4개 사본 예시(RT/MT/OP/CP)가 **전부** 수용되는가
|
||||
|
||||
하나라도 수용되지 않으면 **구현을 멈추고 보고한다.** spec에 억지로 맞추지 않는다.
|
||||
|
||||
- [x] **Step 5: INVENTORY 갱신**
|
||||
|
||||
`docs/reviews/adapters/INVENTORY.md`에 두 행을 알파벳 위치에 넣고 번호를 다시 매긴다. 링크는 같은 그룹(`platform/`) 기존 행과 동일하게 `[Network/state](./01-network-and-state.md)`. 하단 합계를 `130/130`으로.
|
||||
|
||||
- [x] **Step 6: 게이트**
|
||||
|
||||
```bash
|
||||
corepack pnpm check:types:app && corepack pnpm check:types:test \
|
||||
&& corepack pnpm lint && corepack pnpm check:architecture \
|
||||
&& corepack pnpm check:adapter-inventory
|
||||
corepack pnpm test:unit
|
||||
```
|
||||
Expected: 앞 묶음 전부 PASS. `test:unit`은 `ci-artifact-contract.test.ts` 외 실패 0.
|
||||
|
||||
- [x] **Step 7: 커밋**
|
||||
|
||||
```bash
|
||||
git add src/adapters/platform tests/unit/indexeddb-*.test.ts docs/reviews/adapters/INVENTORY.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat: add the shared IndexedDB connection and transaction kernel
|
||||
|
||||
네 어댑터가 각자 구현한 open/blocked/upgrade/트랜잭션 메커니즘을 커널로
|
||||
모은다. 사본은 아직 이행하지 않았으므로 런타임 동작은 그대로다.
|
||||
|
||||
실패 분류는 커널에 넣지 않고 translate 콜백으로 주입한다. RT/MT/OP의
|
||||
mapIndexedDbException과 CP의 mapBrowserDataException이 같은 에러에 다른
|
||||
답을 내며, 그 차이를 통일하는 것은 별건이기 때문이다.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: CP 이행 — `indexeddb-checkpoint-store.ts` (712 → 약 570)
|
||||
|
||||
**CP를 먼저 하는 이유:** 가장 작고, 동작 변화 지점이 가장 명확하며, 커널의 CP-4 요건(abort가 throw하면 caller-abort를 세우지 않는다)을 조기에 검증한다.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts`
|
||||
- Modify: `tests/unit/resumable-upload-checkpoint.test.ts` (동작 변화 지점 고정)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1의 `openIndexedDbDatabase`, `runIndexedDbTransaction`, `deleteIndexedDbDatabase`
|
||||
- Produces: 없음 (공개 포트 형태 불변)
|
||||
|
||||
- [x] **Step 1: 동작 변화 지점을 테스트로 먼저 고정한다**
|
||||
|
||||
구현 전에 아래 5개가 현재 동작대로 통과하는지 확인한다. 이행 후에도 같아야 한다.
|
||||
|
||||
| # | 지점 | 현재 동작 | 깨지면 |
|
||||
|---|---|---|---|
|
||||
| CP-1 | 값 없는 완료 | `CORRUPT_DATA/RECONCILE` (L541-547) | 번역기가 `NO_VALUE_PRODUCED → CORRUPT_DATA`를 명시 매핑 안 하면 UNAVAILABLE로 바뀜 |
|
||||
| CP-2 | `durability` | 옵션 bag 없음 (L512) | 커널 기본이나 `"strict"`를 넣으면 체크포인트 쓰기가 조용히 느려짐 (성능 회귀) |
|
||||
| CP-3 | `nativeFailure` | 기록 후 **즉시 abort** (L577-588) | `requestFailed`(abort 안 함)로 바꾸면 요청 실패 후에도 뒤 요청이 커밋됨. `compareAndSwap`의 `get→put` 체인(L321-343)에서 특히 위험 |
|
||||
| CP-4 | `abort()`가 throw | `failure`를 **되돌린다** (L528, L536) | 커밋된 체크포인트를 ABORTED로 보고 |
|
||||
| CP-5 | `deletePartition` blocked 타이머 | 네이티브 `setTimeout` (L450) | 주입형 `timers`로 바뀜. `tests/unit/resumable-upload-checkpoint.test.ts:194`가 이 경로를 봄 |
|
||||
|
||||
기존 단언 위치: `:142` CONFLICT/RECONCILE, `:190` UNAVAILABLE/RESUME, `:194` blocked PENDING, `:238` false-abort 금지.
|
||||
|
||||
- [x] **Step 2: 삭제 대상 블록을 커널 호출로 바꾼다**
|
||||
|
||||
| 블록 | 줄 | 대체 |
|
||||
|---|---|---|
|
||||
| `TransactionContext` 타입 | L491-495 | 커널 타입 |
|
||||
| `openAndBind`의 open 요청 Promise 배선 | L163-175, L198-217 | `openIndexedDbDatabase` (upgrade 본문 L176-197은 `upgrade` 콜백으로, `bindScope` 호출은 `admit`으로) |
|
||||
| `runCheckpointTransaction` | L497-596 | `runIndexedDbTransaction` |
|
||||
| `bindScope`의 트랜잭션 배선 | L602-623 | `runIndexedDbTransaction` (검증 로직 L624-652는 `queue`로 그대로) |
|
||||
| `deletePartition`의 blocked/settle 배선 | L431-462, L472-483 | `deleteIndexedDbDatabase` |
|
||||
|
||||
**남길 것:** `PENDING_DELETIONS` 레지스트리(L31-41) + 생성 시 검사(L116-122), `uploadCheckpointDatabaseName`(L73-83), `sameScopeBinding`(L656-672), `snapshotScope`(L674-691), `snapshotCheckpoint`(L693-711).
|
||||
|
||||
- [x] **Step 3: 게이트 + 표적 테스트**
|
||||
|
||||
```bash
|
||||
corepack pnpm check:types:app && corepack pnpm lint && corepack pnpm check:architecture
|
||||
npx vitest run tests/unit/resumable-upload-checkpoint.test.ts --reporter=default
|
||||
```
|
||||
Expected: 전부 PASS. CP-1~CP-5가 이행 전과 같은 답을 내야 한다.
|
||||
|
||||
- [x] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add src/adapters/browser-transfer tests/unit/resumable-upload-checkpoint.test.ts
|
||||
git commit -m "refactor: move the upload checkpoint store onto the IndexedDB kernel
|
||||
|
||||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: MT 이행 — `indexeddb-maintenance.ts` (1558 → 약 1370)
|
||||
|
||||
**MT를 두 번째로 하는 이유:** 커널 사용자 중 연결 핸들을 안 쓰는 유일한 사본이라 `openIndexedDbDatabase` 단독 사용 경로를 검증한다.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/adapters/storage/indexeddb/indexeddb-maintenance.ts`
|
||||
- Modify: `tests/unit/indexeddb-maintenance.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1의 `openIndexedDbDatabase`, `openIndexedDbTransaction`, `runIndexedDbTransaction`, `walkIndexedDbCursor`
|
||||
|
||||
- [x] **Step 1: 두 개의 함정을 먼저 이해한다**
|
||||
|
||||
**MT-1 (가장 위험).** `blockedTimeoutMs`를 **넘기지 마라.** 안 넘겨야 오늘 동작(blocked 이벤트 즉시 `BLOCKED`, L485-492)이 유지된다. 넘기면 배치가 최대 그 시간만큼 매달린다. `tests/unit/indexeddb-maintenance.test.ts:289-290`이 `BLOCKED/retryable:true/RELOAD_OTHER_CONTEXTS`를 **즉시** 받길 기대하므로 값을 넣으면 타임아웃으로 실패한다.
|
||||
|
||||
**MT-2.** `upgrade` 콜백을 **생략하라.** 생략해야 오늘의 "upgrade는 곧 실패"(L477-484)가 유지된다. 커널은 생략을 `UPGRADE_REJECTED`로 해석한다. 실수로 `upgrade: () => ({kind:"APPLIED"})`를 넣으면 **잘못된 스키마로 열린다.**
|
||||
|
||||
- [x] **Step 2: 삭제 대상 블록을 커널 호출로 바꾼다**
|
||||
|
||||
| 블록 | 줄 | 대체 |
|
||||
|---|---|---|
|
||||
| `TransactionContext` 타입 | L97-101 | 커널 타입 |
|
||||
| `openExactVersion`의 배선분 | L434-508, L551-585 | `openIndexedDbDatabase` (L509-550을 `admit`으로 이식) |
|
||||
| `createTransaction` | L587-604 | `openIndexedDbTransaction` |
|
||||
| `runTransaction` | L606-705 | `runIndexedDbTransaction` |
|
||||
| 커서 2곳의 deadline/maxRows/abort 보일러플레이트 | L799-833, L1451-1479 | `walkIndexedDbCursor` |
|
||||
|
||||
**MT-3.** `database.onversionchange = () => database.close()`(L543)를 `admit` 안으로 옮긴다. `admit`은 성공 경로에서만 실행되므로 등록 시점이 오늘과 같다.
|
||||
|
||||
**남길 것:** 체크포인트 상태기계(`readCheckpoint` L707-756, `commitPrepared` L969-1251), `prepareRecords`(L863-967), `clock`/`epochClock`(L401-421), `countBucket`(L239-245), 저장 술어(L118-223).
|
||||
|
||||
- [x] **Step 3: 게이트 + 표적 테스트**
|
||||
|
||||
```bash
|
||||
corepack pnpm check:types:app && corepack pnpm lint && corepack pnpm check:architecture
|
||||
npx vitest run tests/unit/indexeddb-maintenance.test.ts --reporter=default
|
||||
```
|
||||
Expected: 전부 PASS. 특히 `:289-290`이 **즉시** BLOCKED를 받아야 한다(타임아웃이 아니라).
|
||||
|
||||
- [x] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add src/adapters/storage tests/unit/indexeddb-maintenance.test.ts
|
||||
git commit -m "refactor: move IndexedDB maintenance onto the kernel
|
||||
|
||||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: OP 이행 — `indexeddb-opfs-journal.ts` (1817 → 약 1690)
|
||||
|
||||
**이 계획에서 동작 변화가 가장 큰 태스크다.**
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/adapters/storage/opfs/indexeddb-opfs-journal.ts`
|
||||
- Modify: `tests/unit/indexeddb-opfs-journal.test.ts`
|
||||
- 가능성: `tests/helpers/memory-indexeddb.ts` 보강
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1의 `openIndexedDbDatabase`, `createIndexedDbConnection`, `runIndexedDbTransaction`, `openIndexedDbTransaction`, `walkIndexedDbCursor`
|
||||
|
||||
- [x] **Step 1: OP-1을 테스트로 먼저 드러낸다 — 이 태스크의 핵심**
|
||||
|
||||
오늘 OP는 트랜잭션 안의 개별 요청에 `.onerror`를 **하나도** 안 단다(파일 전체에서 request `.onerror`는 L1042의 open 요청 하나뿐). 요청 실패는 `transaction.onabort`로만 흘러 `mapIndexedDbException(transaction.error)`(L1158)가 된다.
|
||||
|
||||
커널을 쓰면 **요청 자신의 오류가 보고된다.** 구체적으로 `LOGICAL_KEY_INDEX`가 `unique: true`(L1000-1004)이므로 **중복 put은 요청 레벨 `ConstraintError` → `CONFLICT`**가 되고, 오늘은 `transaction.error`가 무엇이냐에 따라 달라진다.
|
||||
|
||||
이행 **전에** 중복 put 테스트를 `tests/unit/indexeddb-opfs-journal.test.ts`에 추가해 현재 답을 기록하고, 이행 후 달라진 답을 의도된 변경으로 승인한다. 가짜 IDB가 이 경로를 표현하지 못하면 `tests/helpers/memory-indexeddb.ts`를 먼저 보강한다.
|
||||
|
||||
- [x] **Step 2: OP-2를 확인한다**
|
||||
|
||||
오늘 OP의 `runTransaction`은 `succeed` 이후 `fail`이 와도 `explicitFailure`가 이기지만(L1133-1141, `hasValue`는 true 유지) `oncomplete`는 값을 반환한다(L1143-1153). 커널의 "첫 결과가 이긴다" 규칙을 따르면 **`succeed` 후의 `fail`이 무시된다.**
|
||||
|
||||
현재 OP 코드에 그 순서가 실제로 발생하는 경로가 있는지 **확인하라**(spec은 미확인으로 남겼다). 없으면 변화 없음으로 기록하고 넘어간다.
|
||||
|
||||
- [x] **Step 3: 삭제 대상 블록을 커널 호출로 바꾼다**
|
||||
|
||||
| 블록 | 줄 | 대체 |
|
||||
|---|---|---|
|
||||
| `TransactionContext` 타입 | L48-51 | 커널 타입 |
|
||||
| scheduler 기본값 인라인 | L144-153 | `snapshotAbortTimers` |
|
||||
| `openDatabase`의 배선분 | L961-996, L1031-1072 | `openIndexedDbDatabase` + `createIndexedDbConnection` (upgrade 본문 L997-1029는 `upgrade` 콜백으로 그대로 이동) |
|
||||
| `runTransaction` | L1098-1174 | `runIndexedDbTransaction` |
|
||||
| `strictReadwriteTransaction` | L1176-1190 | `openIndexedDbTransaction(…, "strict")` |
|
||||
| 커서 2곳의 limit 루프 | L604-633, L677-712 | `walkIndexedDbCursor` |
|
||||
|
||||
**OP-4.** `signal`은 **넣지 않는다.** 오늘 없는 취소를 새로 만들지 않는다.
|
||||
|
||||
- [x] **Step 4: 게이트 + 표적 테스트**
|
||||
|
||||
```bash
|
||||
corepack pnpm check:types:app && corepack pnpm lint && corepack pnpm check:architecture
|
||||
npx vitest run tests/unit/indexeddb-opfs-journal.test.ts tests/unit/opfs-byte-store.test.ts --reporter=default
|
||||
```
|
||||
Expected: PASS. OP-1로 인한 답 변화는 Step 1에서 승인한 것만 있어야 한다.
|
||||
|
||||
- [x] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add src/adapters/storage tests
|
||||
git commit -m "refactor: move the OPFS journal onto the IndexedDB kernel
|
||||
|
||||
요청 레벨 오류를 처음으로 보게 된다. unique 인덱스 위반이 트랜잭션 abort
|
||||
사유가 아니라 요청 자신의 ConstraintError로 보고된다.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: RT 이행 — `indexeddb-runtime.ts` (2902 → 약 2665)
|
||||
|
||||
**가장 크고 마지막이다.** 다른 셋이 커널을 전부 검증한 뒤에 옮긴다.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/adapters/storage/indexeddb/indexeddb-runtime.ts`
|
||||
- Modify: `tests/unit/indexeddb-runtime.test.ts`
|
||||
|
||||
- [x] **Step 1: RT-1을 번역기에 명시한다**
|
||||
|
||||
`close()`가 진행 중 open을 끝내는 원인이 `unavailable(operation)`(L743)에서 `translate({kind:"CLOSED"})`로 바뀐다. 번역기가 **`CLOSED → unavailable`을 명시 매핑**해야 오늘 동작이 유지된다. 빠뜨리면 `close()` 중 open이 ABORTED로 보고된다.
|
||||
|
||||
기존 실패 코드 단언 위치: `tests/unit/indexeddb-runtime.test.ts:339-340, :371-372, :454, :534, :549-550, :734-735`.
|
||||
|
||||
- [x] **Step 2: 삭제 대상 블록을 커널 호출로 바꾼다**
|
||||
|
||||
| 블록 | 줄 | 대체 |
|
||||
|---|---|---|
|
||||
| `defaultScheduler` | L108-117 | `snapshotAbortTimers(scheduler)` |
|
||||
| `TransactionContext` 타입 | L85-89 | `IndexedDbTransactionContext` |
|
||||
| `waitForOpeningAttempt` | L659-682 | `connection.acquire(signal)` |
|
||||
| `startOpeningAttempt`의 배선분 | L684-745, L776-843, L872-921 중 배선분 | `openIndexedDbDatabase` (upgrade/admit 콜백 본문은 그대로) |
|
||||
| `createTransaction` | L957-974 | `openIndexedDbTransaction` |
|
||||
| `runTransaction` | L976-1074 | `runIndexedDbTransaction` |
|
||||
| 커서 5곳의 보일러플레이트 | L1374-1382, L1454-1471, L2364-2389, L2531-2548, L2573-2590, L2644-2661, L2718-2735 | `walkIndexedDbCursor` + `budget.admit` |
|
||||
|
||||
**RT-2.** `settleNativeRequest`/`activeOpeningGeneration`(L595, L691-697)이 사라진다. 커널의 settle-once와 단일 비행이 같은 역할을 한다. **의미는 같지만 경합 순서가 달라질 수 있다** — 동시 open 테스트를 주의해서 본다.
|
||||
|
||||
**RT-3.** `openingRequest` 필드(L593)는 오늘도 L712·L2878의 대입 외에 읽는 곳이 없다. 삭제한다.
|
||||
|
||||
**남길 것:** 코덱/영수증/보존/예산(bytes)/governance/migration 목록/상태 브로드캐스트/`monotonicClock`/`countBucket`/저장 레코드 술어/`purgePartitionRecords`의 스토어 순서 로직 — 전부 정책이다.
|
||||
|
||||
- [x] **Step 3: 게이트 + 전체 테스트**
|
||||
|
||||
```bash
|
||||
corepack pnpm check:types && corepack pnpm lint && corepack pnpm check:architecture \
|
||||
&& corepack pnpm check:adapter-inventory
|
||||
corepack pnpm test:unit
|
||||
corepack pnpm test:integration
|
||||
```
|
||||
Expected: `ci-artifact-contract.test.ts` 외 실패 0.
|
||||
|
||||
- [x] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add src/adapters/storage tests/unit/indexeddb-runtime.test.ts
|
||||
git commit -m "refactor: move the IndexedDB runtime onto the kernel
|
||||
|
||||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 완료 판정
|
||||
|
||||
```bash
|
||||
corepack pnpm check:types
|
||||
corepack pnpm lint
|
||||
corepack pnpm check:architecture
|
||||
corepack pnpm check:adapter-inventory
|
||||
corepack pnpm check:bundle
|
||||
corepack pnpm test:unit # ci-artifact-contract.test.ts 외 실패 0
|
||||
corepack pnpm test:integration
|
||||
corepack pnpm test:component
|
||||
corepack pnpm test:browser-file-storage-removal # error TS 0건
|
||||
corepack pnpm test:realtime-removal # error TS 0건
|
||||
```
|
||||
|
||||
그리고 아래가 **0**이어야 한다 — 커널 밖에 남은 open 요청 배선:
|
||||
```bash
|
||||
grep -rn "createObjectStore\|onupgradeneeded" src/adapters --include='*.ts' \
|
||||
| grep -v "src/adapters/platform/" | grep -v "upgrade" | wc -l
|
||||
```
|
||||
|
||||
**실브라우저 확인이 필요하다.** 이 계획은 가짜 IDB 위에서만 검증된다. `tests/browser-capabilities/indexeddb-runtime.spec.ts`(1033 LOC), `opfs-runtime.spec.ts`(221), `resumable-upload.spec.ts`(526)를 실브라우저에서 돌려야 blocked/versionchange 실동작 회귀를 잡는다.
|
||||
|
||||
## 이 계획이 하지 않는 것
|
||||
|
||||
- **실패 매핑 4벌 통일** — `mapIndexedDbException`과 `mapBrowserDataException`이 같은 에러에 다른 답을 내는 것은 별건이다. 이 계획은 그 차이를 `translate` 주입으로 **보존**한다.
|
||||
- **`deleteDatabase`를 3벌에 추가** — CP에만 있는 것이 의도다(spec §2.3).
|
||||
- 대형 파일 분할 — 커널 이행으로 RT가 2902 → 2665가 되지만 여전히 크다. 분할은 별도 계획이다.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 실행 기록 (2026-09-16 — 5개 태스크 전부 완료)
|
||||
|
||||
커밋 5개: `cb62bfb`(커널) · `bb6080b`(CP) · `3366a81`(MT) · `217c1dd`(OP) · `a91e78f`(RT).
|
||||
|
||||
### 실측 LOC — 사양 추정은 전부 빗나갔다
|
||||
|
||||
| 사본 | 사양 추정 | 실제(전체 줄) | 실제(코드 줄) |
|
||||
|---|---:|---:|---:|
|
||||
| CP `indexeddb-checkpoint-store.ts` | −142 | 712 → 612 = **−100** | |
|
||||
| MT `indexeddb-maintenance.ts` | −188 | 1558 → 1430 = **−128** | |
|
||||
| OP `indexeddb-opfs-journal.ts` | −127 | 1817 → 1881 = **+64** | 1744 → 1724 = −20 |
|
||||
| RT `indexeddb-runtime.ts` | −235 | 2902 → 2744 = **−158** | 2807 → 2563 = −244 |
|
||||
| 4벌 합 | **−693** | **−322** | |
|
||||
| 커널 2파일 | +470 | **+1,132** | +751 |
|
||||
| **소스 순증감** | **−223** | **+810** | |
|
||||
| 커널 테스트 2파일 | (미기재) | +1,602 | |
|
||||
|
||||
**공통 원인:** 사양이 `translate`/실패 헬퍼 어댑터 함수 비용을 세지 않았다. CP는 4개(~37줄), MT는 6개가 필요했다. 그리고 이행된 파일들의 주석이 크게 늘었다(OP 6→90줄, RT 12→111줄) — 이 레포 관례상 부풀림이 아니라 개선이지만, 줄 수 예측은 무너뜨린다.
|
||||
|
||||
**결론은 바뀌지 않는다.** 이 작업의 근거는 처음부터 줄 수가 아니라 상태기계 통합이었고, 그건 달성됐다. 다만 **줄 수가 준다는 기대는 완전히 틀렸다**는 것을 기록해 둔다.
|
||||
|
||||
### 부수 성과
|
||||
|
||||
- **abort 래칫이 24 → 21로 세 칸 조여졌다.** CP·MT·RT가 각각 자기 abort 리스너를 지웠다. 래칫이 설계대로 작동했다.
|
||||
- **가짜 IndexedDB가 unique 인덱스를 전혀 강제하지 않는 것을 찾아 고쳤다**(`tests/helpers/memory-indexeddb.ts`). 보강 전에는 중복 `begin`이 `ok:true`로 성공했다 — 브라우저가 거부할 상태를 테스트가 조용히 허용하고 있었다. 이 발견이 커널 이행 자체보다 가치가 클 수 있다.
|
||||
- 사양이 빠뜨린 함정 하나를 CP에서 막았다: `bindScope`는 `succeed()`에 해당하는 것이 없어 그대로 옮기면 정상 바인딩이 `CORRUPT_DATA`로 보고된다. 이후 MT·OP·RT는 성공 출구를 전수 대조했다.
|
||||
|
||||
### 사양이 틀린 것으로 판명된 항목 3건
|
||||
|
||||
1. **OP-1** — 사양은 "이 리팩토링에서 가장 큰 동작 변화"로 지목하며 중복 put의 답이 달라진다고 봤다. **틀렸다.** 이행 전후 모두 `CONFLICT / retryable:false / recovery:NONE`이다. 요청 오류를 아무도 처리하지 않으면 스토어가 바로 그 에러로 abort해서 `transaction.error === request.error`이기 때문이다. 바뀐 것은 답이 아니라 출처다.
|
||||
2. **RT-2** — 사양은 "의미 동일"이라고 썼다. **틀렸다.** 아래 참조.
|
||||
3. **§3.1의 파일 분할 논거** — "470 LOC라 1파일은 폴더 관례를 깬다"고 썼으나 실제 구현은 1,132줄이다. 2분할 자체는 유지할 값이 있지만(테스트 셋업이 갈린다) 그 논거는 사실이 아니었다.
|
||||
|
||||
### 보존하지 못한 동작 1건 — RT-2 (의도적으로 남김)
|
||||
|
||||
blocked 데드라인 이후 재시도가 새 `factory.open()`을 띄운다. 이행 전에는 안 띄웠다. 커널에 "settle 이후에도 살아 있는 요청"을 알려줄 훅이 없기 때문이다.
|
||||
|
||||
**복원하지 않기로 했다.** 근거:
|
||||
- 이행 전후 모두 10초 blocked 데드라인이 있고, 차이는 두 번째 네이티브 open을 띄우는지뿐이다.
|
||||
- 늦게 도착한 연결은 커널이 `closeQuietly`로 닫으므로 **연결 누수도 데이터 위험도 없다.**
|
||||
- 재시도가 10초 데드라인에 게이트되므로 쌓이는 속도가 제한적이다.
|
||||
- 반면 지금 커널을 고치면 **이미 검증이 끝난 4개 사본을 전부 재검증**해야 한다. 이익 대비 위험이 맞지 않는다.
|
||||
|
||||
복원하려면 `openIndexedDbDatabase`에 `onSettled`를 추가하면 된다(`deleteIndexedDbDatabase`에는 이미 있다). 다만 그러면 "blocked 데드라인 실패가 버려진 요청이 끝날 때까지 다음 acquire를 막는가"라는 설계 질문이 따라온다 — 막는다면 다른 탭이 영영 안 닫힐 때 재시도가 영구 차단되어 **지금보다 나쁘다.** 착수 전 그 답부터 정해야 한다.
|
||||
|
||||
### 테스트로 덮지 못한 경로 1건
|
||||
|
||||
RT의 `POLICY_REJECTED` upgrade 거절 경로. `queueIndexedDbUpgradeBinding`의 `onRejected`가 비동기라 `.then` 후처리로 보존했으나, **가짜 IDB의 upgrade 트랜잭션이 동기라 단위 테스트로 검증할 수 없다.** 레포 전체에 이 경로를 덮는 테스트가 없다. **실브라우저 확인이 필요하다.**
|
||||
|
||||
### 검증 결과
|
||||
|
||||
**PASS:** `check:types` `lint` `check:architecture` `check:adapter-inventory`(21/21 래칫) `check:bundle`(181114/204800, 이행 전과 동일) `test:integration`(81) `test:component`(130)
|
||||
|
||||
**`test:unit`:** 실패 파일은 `tests/unit/ci-artifact-contract.test.ts` 하나뿐(16~18건, 실행마다 흔들림 — `bwrap: loopback: Failed RTM_NEWADDR`). `develop` `5434760` 기준선에서도 동일함을 stash 후 실행해 확인했다. **그 파일 밖 실패 0.**
|
||||
|
||||
**실브라우저 미검증:** `tests/browser-capabilities/indexeddb-runtime.spec.ts`(1033) · `opfs-runtime.spec.ts`(221) · `resumable-upload.spec.ts`(526). 전부 가짜 IDB 위에서만 검증됐다. blocked/versionchange 실동작과 위 upgrade 경로는 여기서만 잡힌다. **CI 또는 로컬 브라우저 실행이 남은 과제다.**
|
||||
@@ -0,0 +1,751 @@
|
||||
# 어댑터 배럴(`index.ts`)을 공개 경계로 승격 — 설계서
|
||||
|
||||
- 대상 레포: `/home/donghyeon/workspace/desktop-server-git/clean-architecture-frontend-template`
|
||||
- 브랜치: `develop` (기준 커밋 `5434760`)
|
||||
- 전제(이미 확정): 배럴 폐지안은 기각. `src/adapters/<group>/index.ts`를 **진짜 공개 경계**로 만든다.
|
||||
- 게이트 기준선: `check:architecture` PASS, `check:types:app` PASS 유지.
|
||||
- 이 문서는 설계만 한다. 소스 수정·빌드·테스트 실행 없음.
|
||||
|
||||
---
|
||||
|
||||
## 0. 요약 (먼저 읽을 것)
|
||||
|
||||
| 항목 | 판정 |
|
||||
|---|---|
|
||||
| 배럴 표기 표준 | **명명 재수출(named re-export)**. `export *`는 "이미 명시적인 서브배럴을 합칠 때"만 허용 |
|
||||
| 새로 만들 배럴 | 8개, 합계 114줄 (auth 9 / diagnostics 7 / telemetry 7 / storage 4 / service-worker 17 / query-cache 21 / platform 22 / http 27) |
|
||||
| `platform/` | 배럴은 만든다. 단 **어댑터→커널 간선은 파일 직접 import를 유지**한다 (게이트가 그걸 요구함) |
|
||||
| `storage/` | 최상위 배럴은 **서브폴더를 재수출하지 않는다**. `indexeddb/index.ts`·`opfs/index.ts`가 곧 경계다 |
|
||||
| `http/` | V3(`createContractHttpExecutor`)가 권장 경로, V2(`createHttpClient`)는 legacy 보존. 둘 다 배럴에 넣고 주석으로 표시 |
|
||||
| 치환할 import | `src/` 15줄 (배럴 경유 2줄은 이미 합격) + `.storybook/` 2줄(선택) |
|
||||
| 게이트 | `.dependency-cruiser.json`에 규칙 1개 추가. `tests/`는 대상 아님(스캔 범위가 `src`뿐) |
|
||||
| 최대 위험 | 번들 예산. `package.json`에 `sideEffects` 선언이 없어 배럴이 초기 청크를 키울 수 있다 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 기존 배럴 8개의 지배적 관례
|
||||
|
||||
읽은 파일: `src/adapters/{browser-files,browser-file-storage,browser-rpc,browser-transfer,cache-storage,cross-context-invalidation,realtime,web-push}/index.ts`
|
||||
추가로 서브배럴 8개: `browser-transfer/{image-cdn,presigned,resumable-upload}/index.ts`, `realtime/{polling,sse,websocket}/index.ts`, `storage/{indexeddb,opfs}/index.ts`
|
||||
|
||||
### 1.1 관례 (문장으로)
|
||||
|
||||
1. **소스 파일 단위로 블록을 만들고, 블록마다 `export { ... } from "./파일.ts";` 로 이름을 전부 적는다.**
|
||||
블록이 전부 타입이면 `export type { ... } from "...";` 형태를 쓴다 (`browser-files/index.ts:1`, `:28`, `:52`, `:53`).
|
||||
2. **블록 안 순서는 「값 먼저, 타입 나중」이고 각각 대소문자 무시 알파벳순이다.**
|
||||
근거: `web-push/index.ts:42-51` — `createLinkedAbortController, failureCode, nativeFailure, observeWebPush, systemTimeoutScheduler, withAbortableDeadline, type LinkedAbortController, type TimeoutScheduler`.
|
||||
상수도 값이므로 같은 줄에 섞인다: `realtime/index.ts:13-22` — `parseRetryAfterDelay, REALTIME_RECONNECT_CEILINGS, reconnectBudgetRemaining` (대소문자 무시로 `parse < realtime_ < reconnect`).
|
||||
타입은 인라인 `type X` 접두어로 쓴다 (`browser-rpc/index.ts:9-18`).
|
||||
3. **블록(파일) 순서는 대체로 알파벳순이되 엄격하지 않다.** 하위 폴더 블록은 뒤로 몰아둔다 (`web-push/index.ts:59`, `:66`의 `./inbound/*`).
|
||||
엄격하지 않은 실례: `cross-context-invalidation/index.ts`는 `browser-cross-context-invalidation.ts`(:1) 다음에 `browser-cross-context-host.ts`(:20) — 역순.
|
||||
4. **배럴은 그룹의 전체 export 목록이 아니다.** 그룹 안에 배럴에 없는 파일이 실제로 존재한다.
|
||||
근거: `src/adapters/realtime/result.ts`는 export를 가지지만 `realtime/index.ts` 어디에도 없다. `browser-files/browser-file-vault.ts`도 `browser-files/index.ts`에 없다.
|
||||
→ 즉 이 레포는 이미 "배럴 = 선별된 공개 표면"을 실천하고 있다. 새 배럴도 같은 기준으로 고르면 된다.
|
||||
5. (참고, 따라하지 말 것) `browser-files/index.ts:1-16`은 어댑터가 아니라 **application 포트 타입**을 재수출한다. 배럴이 하위 레이어의 통로가 되는 형태라 새 배럴에서는 재현하지 않는다. 필요하면 소비자가 포트에서 직접 가져오면 된다.
|
||||
|
||||
### 1.2 `export *`는 표준인가 — 판정
|
||||
|
||||
**표준은 명명 재수출이다. `export *`는 예외가 아니라 "서브배럴 합성" 전용 관용구다.**
|
||||
|
||||
근거:
|
||||
|
||||
- `export *`가 쓰인 곳은 단 두 파일, 여섯 줄이다.
|
||||
- `src/adapters/browser-transfer/index.ts:1-3` — `./image-cdn/index.ts`, `./presigned/index.ts`, `./resumable-upload/index.ts`
|
||||
- `src/adapters/realtime/index.ts:56-58` — `./polling/index.ts`, `./sse/index.ts`, `./websocket/index.ts`
|
||||
- **여섯 줄 전부 대상이 `index.ts`(서브배럴)다.** 구현 파일(`.ts`)을 `export *`로 푼 사례는 0건이다.
|
||||
- 그리고 그 서브배럴들은 자기 자신이 전부 명명 재수출이다 (`browser-transfer/image-cdn/index.ts:1-6`, `realtime/sse/index.ts:1-9` 등).
|
||||
- `realtime/index.ts`는 한 파일 안에서 두 형태를 동시에 쓴다: 1-55줄은 구현 파일에 대한 명명 재수출, 56-58줄은 서브배럴에 대한 `export *`. 즉 `browser-transfer`만 특이한 게 아니라, **대상이 서브배럴이냐 구현 파일이냐**가 형태를 가른다.
|
||||
|
||||
왜 이 구분이 옳은가: 지시대로 `export *`는 "무엇이 공개되는지 파일을 열어야 안다"는 문제가 있다. 그런데 대상이 서브배럴이면 그 파일 자체가 이미 명시적 목록이므로, `export *` 한 줄을 따라가면 곧바로 명시적 목록에 도달한다. 목록이 사라지는 게 아니라 한 단계 아래에 있는 것뿐이다. 반대로 구현 파일을 `export *`하면 목록이 어디에도 없어진다.
|
||||
|
||||
**채택 규칙**
|
||||
|
||||
> 배럴은 구현 파일(`*.ts`)에 대해서는 반드시 이름을 하나씩 적는다.
|
||||
> `export *`는 대상이 같은 그룹의 서브배럴(`*/index.ts`)일 때만 쓴다.
|
||||
|
||||
새로 만드는 8개 중 `export *`를 쓸 자리는 **없다** (아래 §3.7에서 `storage`가 서브배럴을 재수출하지 않기로 판정하므로).
|
||||
|
||||
---
|
||||
|
||||
## 2. 공개/내부 판정 기준
|
||||
|
||||
판정 근거는 실제 사용처다. 측정 명령:
|
||||
|
||||
```
|
||||
grep -rnE 'from "[^"]*adapters/(auth|browser-files|browser-file-storage|browser-rpc|browser-transfer|cache-storage|cross-context-invalidation|diagnostics|http|platform|query-cache|realtime|service-worker|storage|telemetry|web-push)/' src/ --include='*.ts' --include='*.tsx' | grep -v '^src/adapters/'
|
||||
```
|
||||
|
||||
- **공개**: `src/bootstrap/**`, `src/features/**`, `src/presentation/**`이 import하는 심볼 + 그 심볼의 시그니처에 이름으로 등장하는 타입(의존성/옵션/반환 파사드).
|
||||
- **내부**: 그룹 안에서만 쓰이는 헬퍼.
|
||||
- **내부(테스트 전용)**: `tests/`만 import하는 심볼. 배럴에 넣지 않고, 테스트는 깊은 경로를 유지한다. 각 그룹에서 별도로 표시했다.
|
||||
|
||||
예외 처리 하나: 같은 파일의 동급 팩토리 형제는 오늘 테스트만 쓰더라도 공개로 올린다(예 `createAnonymousSessionAdapter`). 근거는 §3.1.
|
||||
|
||||
---
|
||||
|
||||
## 3. 새로 만들 배럴 8개 (전문)
|
||||
|
||||
아래 내용은 전부 **그대로 파일로 저장 가능**하다. 모든 심볼은 기계 대조로 존재를 확인했다(82개 전수, §7).
|
||||
|
||||
### 3.1 `src/adapters/auth/index.ts`
|
||||
|
||||
그룹 파일: `external-session-adapter.ts` 1개.
|
||||
|
||||
| 심볼 | 위치 | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `createExternalAuthSessionAdapter` | `external-session-adapter.ts:51` | 공개 | `src/bootstrap/runtime-adapters.ts:3` |
|
||||
| `createDemoSessionAdapter` | `:104` | 공개 | `src/bootstrap/runtime-adapters.ts:2`, `:279` |
|
||||
| `createUnavailableSessionAdapter` | `:140` | 공개 | `src/bootstrap/runtime-adapters.ts:4` |
|
||||
| `createAnonymousSessionAdapter` | `:77` | 공개(승격) | 위 셋과 같은 파일·같은 반환형(`AuthSessionPort`)의 형제 팩토리. 오늘 `src` 소비자는 `.storybook/preview.tsx:5`뿐이라 배럴이 없으면 깊은 경로가 남는다 |
|
||||
| `DEMO_AUTHORIZATION_MARKER` | `:98` | 공개 | 계약 문서가 이름으로 참조: `docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md:305` |
|
||||
| `ExternalSessionOwner` | `:10` | 공개 | `src/bootstrap/runtime-adapters.ts:5` |
|
||||
| `DemoSessionAdapter` | `:89` | 공개 | `createDemoSessionAdapter`의 반환 타입 |
|
||||
| `validateCredentialPatch` | `:26` | **내부** | `src/`·`tests/` 어디서도 import하지 않음 |
|
||||
|
||||
```ts
|
||||
export {
|
||||
createAnonymousSessionAdapter,
|
||||
createDemoSessionAdapter,
|
||||
createExternalAuthSessionAdapter,
|
||||
createUnavailableSessionAdapter,
|
||||
DEMO_AUTHORIZATION_MARKER,
|
||||
type DemoSessionAdapter,
|
||||
type ExternalSessionOwner,
|
||||
} from "./external-session-adapter.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `src/adapters/diagnostics/index.ts`
|
||||
|
||||
그룹 파일: `bounded-diagnostics.ts` 1개. export 5개 전부 공개.
|
||||
|
||||
| 심볼 | 위치 | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `createDiagnosticsAdapter` | `bounded-diagnostics.ts:18` | 공개 | `src/bootstrap/runtime-adapters.ts:7` |
|
||||
| `recordBootFailure` | `:80` | 공개 | `src/bootstrap/main.tsx:3` |
|
||||
| `getLastBootEvidence` | `:108` | 공개 | `recordBootFailure`의 짝(부팅 증거 읽기). 오늘 사용처는 `tests/`만 |
|
||||
| `noOpDiagnostics` | `:11` | 공개 | `DiagnosticsPort`의 null object. 다른 그룹이 기본값으로 기대하는 형태 |
|
||||
| `MAX_DIAGNOSTIC_ENTRIES` | `:16` | 공개 | 선언된 상한값(계약 수치) |
|
||||
|
||||
```ts
|
||||
export {
|
||||
createDiagnosticsAdapter,
|
||||
getLastBootEvidence,
|
||||
MAX_DIAGNOSTIC_ENTRIES,
|
||||
noOpDiagnostics,
|
||||
recordBootFailure,
|
||||
} from "./bounded-diagnostics.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `src/adapters/telemetry/index.ts`
|
||||
|
||||
그룹 파일: `best-effort-telemetry.ts` 1개.
|
||||
|
||||
| 심볼 | 위치 | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `createTelemetryAdapter` | `best-effort-telemetry.ts:52` | 공개 | `src/bootstrap/runtime-adapters.ts:29` |
|
||||
| `noOpTelemetry` | `:31` | 공개 | null object |
|
||||
| `MAX_TELEMETRY_QUEUE` | `:47` | 공개 | 선언된 상한값 |
|
||||
| `TelemetryAdapter` | `:10` | 공개 | 팩토리 반환 타입 |
|
||||
| `TelemetryAdapterOptions` | `:20` | 공개 | 팩토리 인자 타입 |
|
||||
| `safeTraceparent` | `:229` | **내부(테스트 전용)** | `tests/`만 import. traceparent 정규화는 어댑터 내부 동작 |
|
||||
| `assertBoundedCapacity` 재수출 | `:49` | **배럴에 넣지 않음** | 이건 telemetry의 export가 아니라 **커널(`../platform/bounded-capacity.ts`) 심볼의 재수출**이다. 배럴에 올리면 커널로 가는 두 번째 문이 생긴다 |
|
||||
|
||||
> **부수 발견 / 별도 처리 권고**
|
||||
> `src/adapters/telemetry/best-effort-telemetry.ts:49`
|
||||
> ```ts
|
||||
> export { assertBoundedCapacity } from "../platform/bounded-capacity.ts";
|
||||
> ```
|
||||
> 이 줄은 이 배럴 작업과 무관하게 지우는 게 맞다. 같은 파일 `:8`에서 이미 같은 심볼을 import해서 `:61`에서 쓰고 있으므로 재수출은 순수 잉여이고, 실제 소비자도 없다(`grep -rn 'assertBoundedCapacity' src/ tests/` → `platform/bounded-capacity.ts` 정의부, `telemetry:8,49,61`, `diagnostics:9,25`가 전부). 이 문서 범위에서는 **건드리지 않고** 배럴에서 제외만 한다.
|
||||
|
||||
```ts
|
||||
export {
|
||||
createTelemetryAdapter,
|
||||
MAX_TELEMETRY_QUEUE,
|
||||
noOpTelemetry,
|
||||
type TelemetryAdapter,
|
||||
type TelemetryAdapterOptions,
|
||||
} from "./best-effort-telemetry.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `src/adapters/platform/index.ts` — 커널
|
||||
|
||||
#### 판정: 배럴은 만들되, **어댑터→커널 간선은 파일 직접 import를 유지한다.**
|
||||
|
||||
질문은 "`browser-transfer/presigned/presigned-transfer-executor.ts:15`의 `../../platform/abortable-operation.ts`가 `../../platform/index.ts`로 바뀌어야 하는가"였다. **아니다. 바꾸면 게이트가 깨진다.**
|
||||
|
||||
**근거 1 (결정적) — `check:adapter-inventory`가 파일 경로를 직접 검증한다.**
|
||||
`scripts/check-adapter-inventory.ts:63-83`:
|
||||
|
||||
```ts
|
||||
const PRIMITIVE_PATH = path.resolve("src/adapters/platform/abortable-operation.ts");
|
||||
for (const consumer of REQUIRED_ABORT_CONSUMERS) {
|
||||
...
|
||||
const specifiers = [...source.matchAll(/from\s+"([^"]*platform\/abortable-operation\.ts)"/gu)]...;
|
||||
const resolved = specifiers.some(
|
||||
(specifier) => path.resolve(path.dirname(consumer), specifier) === PRIMITIVE_PATH,
|
||||
);
|
||||
if (!resolved) { problems.push(`abortable-operation: ${consumer} does not resolve...`); }
|
||||
}
|
||||
```
|
||||
|
||||
`REQUIRED_ABORT_CONSUMERS`(`scripts/check-adapter-inventory.ts:134-137`)는 정확히 네 파일이다:
|
||||
`browser-transfer/presigned/presigned-capability-http-provider.ts`, `.../presigned-transfer-executor.ts`, `browser-transfer/image-cdn/browser-image-probe.ts`, `browser-transfer/resumable-upload/fetch-json-transport.ts`.
|
||||
이들이 `../../platform/index.ts`로 바뀌면 정규식이 매칭되지 않아 `check:adapter-inventory`가 실패한다.
|
||||
|
||||
**근거 2 — 규칙상으로는 둘 다 가능하지만, 커널의 정체성은 "파일"이다.**
|
||||
`.dependency-cruiser.json:191`의 carve-out은 `^src/adapters/($1/|platform/|browser-file-storage/result\.ts$|cross-context-invalidation/index\.ts$)` 로 `platform/` 폴더 전체를 허용한다. 즉 규칙은 중립이다.
|
||||
그런데 `docs/architecture/layers.md:34`는 커널을 "the system clock, the shared abort primitive and the bounded-capacity guard" — **세 개의 프리미티브**로 정의한다. 런타임 합성물이 아니라 원시 도구다. 원시 도구는 "어느 파일에서 왔는지"가 곧 정체성이고, 실제로 위 게이트가 그 정체성을 파일 경로로 확인한다.
|
||||
|
||||
**근거 3 — 그래서 새 게이트 규칙은 `src/adapters/**`를 출발점에서 제외한다.**
|
||||
어댑터끼리의 간선은 이미 `adapters-do-not-know-other-concrete-adapters`(`.dependency-cruiser.json:183-193`)가 담당한다. 새 규칙이 그 위에 겹칠 이유가 없다. §5의 `from.pathNot: "^src/adapters/"`가 이 판정의 실행형이다.
|
||||
|
||||
#### 그러면 배럴에는 뭘 넣나
|
||||
|
||||
커널 프리미티브도 **전부** 넣는다. 이유: 배럴을 쓰는 쪽은 `src/bootstrap`과 `tests`인데, 이들은 `systemClock`(tests), `createAbortableOperation`(tests), `createBrowserLifecycleRuntime`(`src/bootstrap/optional-runtime-host.ts:2`), `createBrowserMutationIntentFactory`(`src/bootstrap/runtime-adapters.ts:28`)를 쓴다. 일부만 넣으면 "바깥은 배럴만"이라는 규칙에 예외가 생긴다. 두 개의 문이 생기는 게 아니라 **문이 소비자별로 하나씩**이다: 어댑터는 파일, 그 외는 배럴.
|
||||
|
||||
| 파일 | 공개 심볼 | 소비자 근거 |
|
||||
|---|---|---|
|
||||
| `abortable-operation.ts` | `createAbortableOperation:90`, `compensateLateHandle:261`, `snapshotAbortTimers:70`, `AbortableOperation:24`, `AbortableOperationInput:51`, `AbortRace:19`, `AbortTerminalReason:11`, `AbortTimerSnapshot:59` | 어댑터 4곳(직접 경로 유지) + `tests/` |
|
||||
| `bounded-capacity.ts` | `assertBoundedCapacity:10` | `diagnostics:9`, `telemetry:8` (직접 경로 유지) |
|
||||
| `browser-lifecycle.ts` | `createBrowserLifecycleRuntime:57`, `BrowserLifecycleEvent:20`, `BrowserLifecycleRuntime:36`, `BrowserLifecycleSnapshot:13` | `src/bootstrap/optional-runtime-host.ts:1-4` |
|
||||
| `browser-mutation-intent-factory.ts` | `createBrowserMutationIntentFactory:9`, `BrowserMutationIntentFactoryDependencies:4` | `src/bootstrap/runtime-adapters.ts:28` |
|
||||
| `system-clock.ts` | `systemClock:3` | 어댑터 6곳(직접 경로 유지) + `tests/` |
|
||||
|
||||
내부: 없음. 이 그룹은 모든 export가 커널 표면이다.
|
||||
|
||||
```ts
|
||||
/**
|
||||
* 어댑터 커널의 공개 경계.
|
||||
*
|
||||
* `src/adapters/**` 안에서는 이 배럴을 쓰지 않는다. 커널 프리미티브는 파일
|
||||
* 경로로 직접 import한다 — `scripts/check-adapter-inventory.ts`가
|
||||
* `platform/abortable-operation.ts`로 해석되는 specifier를 네 소비자에게
|
||||
* 요구하고, `.dependency-cruiser.json`의 kernel carve-out도 폴더 단위다.
|
||||
* 이 배럴은 bootstrap·features·tests 같은 그룹 바깥 소비자를 위한 문이다.
|
||||
*/
|
||||
export {
|
||||
compensateLateHandle,
|
||||
createAbortableOperation,
|
||||
snapshotAbortTimers,
|
||||
type AbortableOperation,
|
||||
type AbortableOperationInput,
|
||||
type AbortRace,
|
||||
type AbortTerminalReason,
|
||||
type AbortTimerSnapshot,
|
||||
} from "./abortable-operation.ts";
|
||||
export { assertBoundedCapacity } from "./bounded-capacity.ts";
|
||||
export {
|
||||
createBrowserLifecycleRuntime,
|
||||
type BrowserLifecycleEvent,
|
||||
type BrowserLifecycleRuntime,
|
||||
type BrowserLifecycleSnapshot,
|
||||
} from "./browser-lifecycle.ts";
|
||||
export {
|
||||
createBrowserMutationIntentFactory,
|
||||
type BrowserMutationIntentFactoryDependencies,
|
||||
} from "./browser-mutation-intent-factory.ts";
|
||||
export { systemClock } from "./system-clock.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 `src/adapters/query-cache/index.ts`
|
||||
|
||||
파일 5개, 파일마다 팩토리 1~3개. 전부 공개(4개는 bootstrap이 직접, `createCursorPaginationRuntime`·`createQueryCacheAdapter`는 동급 팩토리).
|
||||
|
||||
| 심볼 | 위치 | 근거 |
|
||||
|---|---|---|
|
||||
| `createConditionalValidatorStore` | `conditional-validator-store.ts:55` | `src/bootstrap/runtime-adapters.ts:26` |
|
||||
| `ConditionalValidatorBinding` / `ConditionalValidatorStore` | `:3` / `:10` | 위 팩토리의 반환·요소 타입 |
|
||||
| `createCursorPaginationRuntime` | `cursor-pagination-runtime.ts:49` | 동급 런타임 팩토리(오늘 소비자는 `tests/`만) |
|
||||
| `createServerStateScopeRuntime` | `server-state-scope-runtime.ts:34` | `src/bootstrap/runtime-adapters.ts:25` |
|
||||
| `ScopeResetParticipant` / `ServerStateScopeDependencies` | `:19` / `:26` | 위 팩토리의 인자 타입 |
|
||||
| `createTanStackCacheCoordinator` | `tanstack-cache-coordinator.ts:33` | `src/bootstrap/runtime-adapters.ts:22` |
|
||||
| `TanStackCacheCoordinatorDependencies` | `:21` | 인자 타입 |
|
||||
| `createQueryClient` | `tanstack-query-cache.ts:21` | `src/bootstrap/runtime-adapters.ts:24`, `.storybook/preview.tsx:6` |
|
||||
| `createQueryCacheAdapter` | `:59` | 동급 팩토리(`QueryCachePort` 구현) |
|
||||
| `QUERY_CACHE_DEFAULTS` | `:12` | 선언된 기본값 |
|
||||
| `QueryCacheDependencies` | `:8` | 인자 타입 |
|
||||
|
||||
내부: 없음.
|
||||
|
||||
> 주의: 이 그룹은 `.dependency-cruiser.json:184`가 명시적으로 이름 붙인 미해결 간선의 출발점이다 — `tanstack-cache-coordinator.ts:19`가 `../cross-context-invalidation/index.ts`에서 협력자 타입 2개를 읽는다. 배럴 작업은 이 간선을 건드리지 않는다(어댑터→어댑터 간선이므로 새 규칙 범위 밖).
|
||||
|
||||
```ts
|
||||
export {
|
||||
createConditionalValidatorStore,
|
||||
type ConditionalValidatorBinding,
|
||||
type ConditionalValidatorStore,
|
||||
} from "./conditional-validator-store.ts";
|
||||
export { createCursorPaginationRuntime } from "./cursor-pagination-runtime.ts";
|
||||
export {
|
||||
createServerStateScopeRuntime,
|
||||
type ScopeResetParticipant,
|
||||
type ServerStateScopeDependencies,
|
||||
} from "./server-state-scope-runtime.ts";
|
||||
export {
|
||||
createTanStackCacheCoordinator,
|
||||
type TanStackCacheCoordinatorDependencies,
|
||||
} from "./tanstack-cache-coordinator.ts";
|
||||
export {
|
||||
createQueryCacheAdapter,
|
||||
createQueryClient,
|
||||
QUERY_CACHE_DEFAULTS,
|
||||
type QueryCacheDependencies,
|
||||
} from "./tanstack-query-cache.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.6 `src/adapters/service-worker/index.ts`
|
||||
|
||||
#### 제약 하나 먼저
|
||||
|
||||
`tsconfig.app.json:14-19`의 `exclude`에 `src/adapters/service-worker/service-worker-entry.ts`가 들어 있다. **배럴은 이 파일을 절대 참조하면 안 된다** — 참조하면 app 타입체크가 제외된 파일을 끌어들인다. 다행히 `service-worker-entry.ts`는 export가 0개라(`grep -n '^export ' → 없음`) 넣을 것도 없다.
|
||||
|
||||
| 파일 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| `service-worker-page-controller.ts` | 공개 | `src/bootstrap/register-service-worker.ts:5`가 `createServiceWorkerPageController`를 씀 |
|
||||
| `service-worker-lifecycle.ts` | 공개 | 워커 realm 진입점(`service-worker-entry.ts:11`)이 합성하는 런타임. 그룹 바깥(워커 번들·tests)이 실제 소비자 |
|
||||
| `service-worker-protocol.ts` | 공개 | 페이지↔워커 메시지 코덱. 양쪽 realm이 공유하는 어휘 |
|
||||
| `service-worker-removal.ts` | **내부** | 소비자는 `service-worker-page-controller.ts:20`(그룹 내) + `tests/`뿐 |
|
||||
| `service-worker-static-assets.ts` | **내부** | 소비자는 `service-worker-lifecycle.ts:17`(그룹 내) + `tests/`뿐 |
|
||||
| `service-worker-entry.ts` | 대상 아님 | export 0개 + `tsconfig.app.json` 제외 |
|
||||
|
||||
내부(테스트 전용)로 남아 깊은 경로를 유지할 심볼: `removeOwnedRegistration`, `purgeOwnedResources`, `isOwnedRegistration`, `expectedServiceWorkerUrls`(`service-worker-removal.ts:33,65,89,138`), `installStaticAssets`, `classifyFetch`, `validateStaticAssetManifest`, `selectCachesToDelete`(`service-worker-static-assets.ts:35,85,119,377`).
|
||||
|
||||
```ts
|
||||
export {
|
||||
createServiceWorkerRuntime,
|
||||
type WorkerClientLike,
|
||||
type WorkerRuntimeConfig,
|
||||
type WorkerScopeLike,
|
||||
} from "./service-worker-lifecycle.ts";
|
||||
export {
|
||||
createServiceWorkerPageController,
|
||||
type ActivationBlocker,
|
||||
type PageControllerDependencies,
|
||||
} from "./service-worker-page-controller.ts";
|
||||
export {
|
||||
createNonceRegistry,
|
||||
createServiceWorkerMessage,
|
||||
parseServiceWorkerMessage,
|
||||
type ParsedMessage,
|
||||
} from "./service-worker-protocol.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 `src/adapters/storage/index.ts`
|
||||
|
||||
#### 판정: 최상위 배럴은 서브폴더를 재수출하지 **않는다**. 서브폴더 배럴이 곧 경계다.
|
||||
|
||||
`browser-transfer/index.ts:1-3`과 `realtime/index.ts:56-58`의 선례를 따라 `export * from "./indexeddb/index.ts"`를 넣고 싶어지지만, **storage에서는 그게 게이트를 깬다.**
|
||||
|
||||
**근거 (결정적) — 제거 드릴이 서브폴더만 삭제한다.**
|
||||
`scripts/test-browser-file-storage-runtime-removal.ts:23-34`:
|
||||
|
||||
```ts
|
||||
const runtimePaths = [
|
||||
...
|
||||
"src/adapters/storage/indexeddb",
|
||||
"src/adapters/storage/opfs",
|
||||
...
|
||||
] as const;
|
||||
```
|
||||
|
||||
이 스크립트는 fixture 트리에서 위 경로를 `rm -rf`한 뒤(`:56-61`) `assertNoRuntimeImports(fixtureRoot, runtimeSourceRoots, ...)`를 호출한다(`:129-133`). 그 함수는 `scripts/lib/removal-fixture.ts:251-260`:
|
||||
|
||||
```ts
|
||||
const graph = await runtimeImportGraph(root, runtimeSourceRoots);
|
||||
if (graph.importingFiles.length > 0) {
|
||||
throw new Error(`Removed ${capability} runtime is still imported by: ...`);
|
||||
}
|
||||
```
|
||||
|
||||
즉 `src/adapters/storage/index.ts`가 `./indexeddb/index.ts`를 재수출하면, 삭제 후에도 그 파일이 살아남아 삭제된 런타임을 import하는 상태가 되어 드릴이 **즉시 예외로 실패**한다. 그 뒤에 이어지는 `check:types` / `lint` / `check:architecture` / `build`(`:135-143`)까지 전부 못 간다.
|
||||
|
||||
**대조 — realtime·browser-transfer는 왜 괜찮은가.**
|
||||
`scripts/test-realtime-runtime-removal.ts:21-31`은 `src/adapters/realtime` **폴더 전체**를 지운다. `browser-file-storage` 드릴도 `src/adapters/browser-transfer` 전체를 지운다(`test-browser-file-storage-runtime-removal.ts:28`). 배럴이 폴더와 함께 사라지므로 문제가 없다. **storage만 부분 삭제 대상**이다 — 최상위 `browser-storage-adapter.ts`(localStorage/sessionStorage KV)는 남고 IndexedDB·OPFS 런타임만 빠진다.
|
||||
|
||||
**따라서:**
|
||||
- `src/adapters/storage/index.ts` = 최상위 파일들만.
|
||||
- `src/adapters/storage/indexeddb/index.ts`, `src/adapters/storage/opfs/index.ts` = 각 제거 가능 런타임의 공개 경계. **이미 존재하고 이미 명명 재수출이다.** 새로 만들 필요 없다.
|
||||
- §5의 게이트 정규식은 그래서 1단계 중첩 `index.ts`까지 배럴로 인정해야 한다.
|
||||
|
||||
| 심볼 | 위치 | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `createBrowserStorageAdapter` | `browser-storage-adapter.ts:35` | 공개 | `src/bootstrap/runtime-adapters.ts:27` |
|
||||
| `BrowserStorageDependencies` | `:20` | 공개 | 팩토리 인자 타입. 필드가 전부 원시형/포트라 codec 타입을 노출하지 않는다(`:20-27` 확인) |
|
||||
| `browser-storage-codec.ts` 전체 (`DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES:1`, `encodeBrowserStorageEnvelope:31`, `decodeBrowserStorageEnvelope:57`, `assertValidBrowserStorageByteLimit:88`, `BrowserStorageEnvelope:11`, `BrowserStorageCodecFailure:17`, `BrowserStorageCodecResult:22`) | — | **내부** | 유일한 소비자가 `browser-storage-adapter.ts:14-18`(그룹 내). `src/`·`tests/` 어디서도 직접 import 없음 |
|
||||
|
||||
```ts
|
||||
export {
|
||||
createBrowserStorageAdapter,
|
||||
type BrowserStorageDependencies,
|
||||
} from "./browser-storage-adapter.ts";
|
||||
```
|
||||
|
||||
> **서브배럴 보완 권고 (별건, 이 문서 범위 밖)**
|
||||
> `tests/unit/opfs-byte-store.test.ts:25,28`가 `OPFS_WORKER_PROTOCOL_VERSION`(`opfs/opfs-worker-protocol.ts:23`), `PreparePhysicalObjectRequest`(`:179`), `writeWithSyncAccessHandle`(`opfs/opfs-worker-runtime.ts:1363`)를 쓰는데 이 셋은 `opfs/index.ts`에 없다. 테스트를 배럴로 옮길 때(§4.2) 함께 결정해야 한다 — 올리거나, 내부로 확정하고 테스트가 깊은 경로를 유지하거나.
|
||||
|
||||
---
|
||||
|
||||
### 3.8 `src/adapters/http/index.ts`
|
||||
|
||||
#### V2 / V3 판정
|
||||
|
||||
**V3(`createContractHttpExecutor`, `http-execution-v3.ts:377`)가 권장 경로.** V2(`createHttpClient`, `client.ts:138`)는 legacy 보존.
|
||||
|
||||
근거:
|
||||
1. `src/adapters/http/client.ts:121`에 `LegacyHttpInput`이라는 타입이 있고, 공개 시그니처 `HttpClient.execute`(`:131-137`)가 그걸 두 번째 인자로 받는다. 파일이 스스로 legacy라고 말한다.
|
||||
2. `src/adapters/http/http-execution-v3.ts:45-51` 헤더 주석: "§7–§8. Descriptor-driven HTTP execution. ... the runtime owns bounds, the total deadline, the single retry authority and the effect-certainty verdict." — 계약 기반 실행이 V3에 있다.
|
||||
3. 라이브 경로가 V3다. `src/bootstrap/runtime-adapters.ts:423`이 `createContractHttpExecutor`를 조립해 실제 런타임에 넣는다. 반면 V2 래퍼 `createRuntimeHttpClient`(`src/bootstrap/runtime-adapters.ts:163`)의 유일한 호출자는 `tests/unit/runtime-adapters.test.ts:371`이다 (`grep -rn 'createRuntimeHttpClient' src/ tests/` 결과 3줄: 정의 1 + 테스트 2).
|
||||
4. V3 타입은 이미 feature 경계를 넘는다: `src/features/reference-feature/adapters/create-reference-feature-input.ts:9`가 `HttpExecutionOutcome`를 import한다.
|
||||
|
||||
둘 다 배럴에 넣되 **블록 순서로 V3를 먼저 두고 주석으로 표시**한다.
|
||||
|
||||
#### 공개 표면
|
||||
|
||||
| 파일 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| `http-execution-v3.ts` | 공개 | `src/bootstrap/runtime-adapters.ts:10,11`, `src/features/reference-feature/adapters/create-reference-feature-input.ts:9` |
|
||||
| `client.ts` | 공개(legacy) | `src/bootstrap/runtime-adapters.ts:8` |
|
||||
| `http-contract-bridge.ts` | `CredentialPatchOutcome`만 공개 | `http-execution-v3.ts:292`의 공개 시그니처 `attachCredentials(...): Promise<CredentialPatchOutcome> \| CredentialPatchOutcome`에 이름으로 등장 → bootstrap이 그 콜백을 구현한다(`src/bootstrap/runtime-adapters.ts:434` 부근) |
|
||||
| `request-builder.ts` | `OperationRequestInput`만 공개 | `client.ts:133`의 공개 시그니처 `execute(request: string \| OperationRequestInput, ...)`에 이름으로 등장 |
|
||||
| `bounded-body-reader.ts` | **내부** | 소비자는 `bounded-json.ts:1`, `http-execution-v3.ts:18-20`(그룹 내) + `tests/` |
|
||||
| `bounded-json.ts` | **내부** | 소비자는 `client.ts:45`(그룹 내) + `tests/` |
|
||||
| `http-effect-certainty.ts` | **내부** | 소비자는 `http-execution-v3.ts:38-42`(그룹 내) + `tests/` |
|
||||
| `retry-policy.ts` | **내부** | 소비자는 `client.ts:9`, `http-execution-v3.ts:43`(그룹 내) + `tests/` |
|
||||
| `schema-registry.ts` | **내부** | 소비자는 `client.ts:14`(그룹 내) + `tests/` |
|
||||
| `resource-mapper.ts` | **내부** | 소비자는 `client.ts:8`(그룹 내) + `tests/` |
|
||||
| `http-contract-bridge.ts` 나머지 11개 | **내부** | 그룹 내 + `tests/` |
|
||||
| `request-builder.ts` 나머지 2개 (`buildRequestTarget:28`, `RequestTargetResult:14`) | **내부(테스트 전용)** | `tests/`만 |
|
||||
|
||||
```ts
|
||||
/**
|
||||
* §7–§8. 권장 경로는 V3 계약 실행기(`createContractHttpExecutor`)다. 설치된
|
||||
* 계약과 타입 입력을 받아 상한·전체 데드라인·재시도 권한·효과 확실성 판정을
|
||||
* 런타임이 소유한다.
|
||||
*/
|
||||
export {
|
||||
createContractHttpExecutor,
|
||||
type AuthIntegrationFailureReason,
|
||||
type AuthOperationContext,
|
||||
type CancellationOwner,
|
||||
type ContractHttpExecutor,
|
||||
type ContractHttpExecutorDependencies,
|
||||
type HttpContractViolation,
|
||||
type HttpContractViolationKind,
|
||||
type HttpEffectCertainty,
|
||||
type HttpExecutionContext,
|
||||
type HttpExecutionObservation,
|
||||
type HttpExecutionOutcome,
|
||||
type HttpTransportFailure,
|
||||
type SafeResponseMetadata,
|
||||
} from "./http-execution-v3.ts";
|
||||
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
|
||||
export type { CredentialPatchOutcome } from "./http-contract-bridge.ts";
|
||||
/**
|
||||
* V2 legacy. operationId + `LegacyHttpInput`으로 호출하는 범용 클라이언트다.
|
||||
* 새 코드는 위의 V3 실행기를 쓴다. 남아 있는 이유는 계약이 아직 없는
|
||||
* 오퍼레이션을 위한 이행 경로이기 때문이다.
|
||||
*/
|
||||
export {
|
||||
createHttpClient,
|
||||
type HttpClient,
|
||||
type HttpClientDependencies,
|
||||
type HttpFailure,
|
||||
type HttpResult,
|
||||
type LegacyHttpInput,
|
||||
type Scheduler,
|
||||
} from "./client.ts";
|
||||
/** V2 `HttpClient.execute`의 첫 인자 타입. */
|
||||
export type { OperationRequestInput } from "./request-builder.ts";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 호출처 치환 목록
|
||||
|
||||
### 4.1 `src/` — 전수 (17줄)
|
||||
|
||||
측정: §2의 grep. 17줄 중 2줄은 이미 배럴 경유라 변경 없음. 실제 치환 대상 **15줄**.
|
||||
|
||||
| # | 파일:줄 | 현재 specifier | 바뀔 specifier | 비고 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `src/bootstrap/main.tsx:3` | `../adapters/diagnostics/bounded-diagnostics.ts` | `../adapters/diagnostics/index.ts` | `recordBootFailure` |
|
||||
| 2 | `src/bootstrap/optional-runtime-host.ts:4` | `../adapters/platform/browser-lifecycle.ts` | `../adapters/platform/index.ts` | 블록 시작은 `:1` |
|
||||
| 3 | `src/bootstrap/register-service-worker.ts:5` | `../adapters/service-worker/service-worker-page-controller.ts` | `../adapters/service-worker/index.ts` | `createServiceWorkerPageController` |
|
||||
| 4 | `src/bootstrap/runtime-adapters.ts:6` | `../adapters/auth/external-session-adapter.ts` | `../adapters/auth/index.ts` | 블록 시작 `:1` |
|
||||
| 5 | `src/bootstrap/runtime-adapters.ts:7` | `../adapters/diagnostics/bounded-diagnostics.ts` | `../adapters/diagnostics/index.ts` | `createDiagnosticsAdapter` |
|
||||
| 6 | `src/bootstrap/runtime-adapters.ts:8` | `../adapters/http/client.ts` | `../adapters/http/index.ts` | **7번과 한 블록으로 합칠 것** |
|
||||
| 7 | `src/bootstrap/runtime-adapters.ts:12` | `../adapters/http/http-execution-v3.ts` | `../adapters/http/index.ts` | 블록 시작 `:9` |
|
||||
| 8 | `src/bootstrap/runtime-adapters.ts:20` | `../adapters/cross-context-invalidation/index.ts` | (변경 없음) | 이미 배럴 |
|
||||
| 9 | `src/bootstrap/runtime-adapters.ts:23` | `../adapters/query-cache/tanstack-cache-coordinator.ts` | `../adapters/query-cache/index.ts` | 블록 시작 `:21`. **9~12를 한 블록으로 합칠 것** |
|
||||
| 10 | `src/bootstrap/runtime-adapters.ts:24` | `../adapters/query-cache/tanstack-query-cache.ts` | `../adapters/query-cache/index.ts` | `createQueryClient` |
|
||||
| 11 | `src/bootstrap/runtime-adapters.ts:25` | `../adapters/query-cache/server-state-scope-runtime.ts` | `../adapters/query-cache/index.ts` | `createServerStateScopeRuntime` |
|
||||
| 12 | `src/bootstrap/runtime-adapters.ts:26` | `../adapters/query-cache/conditional-validator-store.ts` | `../adapters/query-cache/index.ts` | `createConditionalValidatorStore` |
|
||||
| 13 | `src/bootstrap/runtime-adapters.ts:27` | `../adapters/storage/browser-storage-adapter.ts` | `../adapters/storage/index.ts` | `createBrowserStorageAdapter` |
|
||||
| 14 | `src/bootstrap/runtime-adapters.ts:28` | `../adapters/platform/browser-mutation-intent-factory.ts` | `../adapters/platform/index.ts` | `createBrowserMutationIntentFactory` |
|
||||
| 15 | `src/bootstrap/runtime-adapters.ts:29` | `../adapters/telemetry/best-effort-telemetry.ts` | `../adapters/telemetry/index.ts` | `createTelemetryAdapter` |
|
||||
| 16 | `src/bootstrap/server-state-generation-store.ts:3` | `../adapters/cross-context-invalidation/index.ts` | (변경 없음) | 이미 배럴 |
|
||||
| 17 | `src/features/reference-feature/adapters/create-reference-feature-input.ts:9` | `../../../adapters/http/http-execution-v3.ts` | `../../../adapters/http/index.ts` | `type HttpExecutionOutcome` |
|
||||
|
||||
**작업 순서 주의.** `runtime-adapters.ts`는 12줄이 한 덩어리(`:6`~`:29`)다. 6·7을 합치고 9~12를 합치면 그 아래 줄 번호가 전부 밀린다. **아래에서 위로 편집**하거나 `:1`~`:29` 블록을 한 번에 다시 쓴다.
|
||||
|
||||
**선택 사항 (게이트 범위 밖, 일관성 목적).** `check:architecture`는 `src`만 스캔하므로(`scripts/check-architecture.ts:69`, `:116-123`) 아래 두 줄은 강제되지 않는다. 같은 커밋에서 정리하는 걸 권한다.
|
||||
|
||||
| 파일:줄 | 현재 | 바뀔 것 |
|
||||
|---|---|---|
|
||||
| `.storybook/preview.tsx:5` | `../src/adapters/auth/external-session-adapter.ts` | `../src/adapters/auth/index.ts` |
|
||||
| `.storybook/preview.tsx:6` | `../src/adapters/query-cache/tanstack-query-cache.ts` | `../src/adapters/query-cache/index.ts` |
|
||||
|
||||
**곁다리 정리 기회 하나.** `src/bootstrap/runtime-adapters.ts:65`가 `type HttpClientDependencies = Parameters<typeof createHttpClient>[0];`로 타입을 역추출한다. 배럴이 `HttpClientDependencies`를 직접 내보내므로 이 줄은 지우고 import로 대체할 수 있다. 필수는 아니다.
|
||||
|
||||
### 4.2 `tests/` — 그룹별 집계와 권고
|
||||
|
||||
측정(2026-09-16, `develop` `5434760`):
|
||||
|
||||
```
|
||||
grep -rnE 'from "[^"]*adapters/<16개 그룹>/' tests/ --include='*.ts' --include='*.tsx' | grep -v '^tests/fixtures/'
|
||||
```
|
||||
|
||||
**166줄 / 83개 파일.** (`tests/fixtures` 포함 시 169줄. 지시문의 184와 다른데, `tests/fixtures`는 `.dependency-cruiser.json:208`에서 제외 대상이고 그 안의 `indexeddb-repository.ts` 같은 경로는 존재하지 않는 금지 recipe fixture다.)
|
||||
|
||||
| 그룹 | 줄 수 | 배럴 존재 | 배럴 경유 |
|
||||
|---|---:|---|---:|
|
||||
| browser-transfer | 26 | 기존 | 0 |
|
||||
| realtime | 24 | 기존 | 1 |
|
||||
| http | 22 | **신규** | 0 |
|
||||
| storage | 20 | **신규**(+서브배럴 2) | 0 |
|
||||
| web-push | 17 | 기존 | 0 |
|
||||
| browser-files | 17 | 기존 | 1 |
|
||||
| query-cache | 9 | **신규** | 0 |
|
||||
| auth | 9 | **신규** | 0 |
|
||||
| browser-file-storage | 5 | 기존 | 0 |
|
||||
| service-worker | 4 | **신규** | 0 |
|
||||
| platform | 3 | **신규** | 0 |
|
||||
| browser-rpc | 3 | 기존 | 2 |
|
||||
| diagnostics | 2 | **신규** | 0 |
|
||||
| cross-context-invalidation | 2 | 기존 | 2 |
|
||||
| cache-storage | 2 | 기존 | 0 |
|
||||
| telemetry | 1 | **신규** | 0 |
|
||||
| **합계** | **166** | | **6** |
|
||||
|
||||
배럴 경유 6줄의 정확한 위치:
|
||||
`tests/unit/browser-file-runtime.test.ts:13`, `tests/unit/browser-rpc/browser-rpc-remediation.test.ts:9`, `tests/unit/browser-rpc/browser-rpc-runtime.test.ts:11`, `tests/unit/cross-tab-invalidation.test.ts:21`, `tests/unit/realtime/realtime-reconnect-coordinator.test.ts:20`, `tests/unit/tanstack-cache-coordinator.test.ts:7`.
|
||||
|
||||
#### 권고: 한꺼번에 옮기지 않는다. 파일을 건드릴 때 그 파일 것만 옮긴다.
|
||||
|
||||
이유 셋:
|
||||
|
||||
1. **게이트가 강제하지 않는다.** `check:architecture`는 `src`만 본다(`scripts/check-architecture.ts:69`, `:116-123`). 테스트를 지금 옮겨도 검증되는 게 없고, 안 옮겨도 깨지는 게 없다. 강제되지 않는 대량 변경은 리뷰 비용만 남는다.
|
||||
2. **테스트의 절반 이상이 내부 심볼을 쓴다.** 예: `tests/unit/opfs-byte-store.test.ts:25,28`의 `PreparePhysicalObjectRequest`·`writeWithSyncAccessHandle`은 `opfs/index.ts`에 없다. http의 `retry-policy`·`schema-registry`·`bounded-body-reader` 테스트도 마찬가지로 §3.8에서 내부로 판정한 심볼을 직접 겨눈다. 일괄 치환은 곧 "배럴에 내부 심볼을 밀어넣자"는 압력이 되고, 그러면 배럴이 경계가 아니라 재수출 덤프가 된다.
|
||||
3. **내부 심볼을 직접 겨누는 단위 테스트는 그래도 된다.** 배럴 규칙은 "그룹 바깥 **프로덕션 코드**는 배럴만"이지 "아무도 내부를 못 본다"가 아니다. 테스트는 구현 계약을 검증하는 게 일이다.
|
||||
|
||||
**실행 규칙 (문서에 남길 문장)**
|
||||
|
||||
> `tests/` 아래 어댑터 import는 배럴로 일괄 이관하지 않는다.
|
||||
> 어떤 테스트 파일을 **다른 이유로** 수정하거나 분할할 때, 그 파일이 쓰는 심볼이 해당 그룹 배럴에 있으면 그 파일 안에서만 배럴 경로로 바꾼다.
|
||||
> 배럴에 없는 심볼이면 깊은 경로를 유지한다. 배럴에 추가하고 싶으면 §2 기준으로 "공개"임을 논증하는 게 먼저다.
|
||||
|
||||
우선순위를 굳이 매긴다면: `auth`(9줄, 전부 공개 심볼), `diagnostics`(2줄), `telemetry`(1줄), `query-cache`(9줄) — 이 넷은 배럴이 그룹 표면을 100% 덮으므로 기계적 치환이 가능하다. 21줄. `http`·`storage`·`service-worker`는 내부 심볼 비중이 커서 파일 단위로만 접근한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 게이트로 강제하기
|
||||
|
||||
### 5.1 추가할 규칙 (그대로 붙여넣기)
|
||||
|
||||
`.dependency-cruiser.json`의 `forbidden` 배열에서 **`adapters-do-not-know-other-concrete-adapters` 바로 다음, `no-circular-dependencies` 앞**에 넣는다 (현재 `:193`과 `:194` 사이).
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "adapter-groups-are-reached-through-their-barrel",
|
||||
"comment": "docs/architecture/layers.md §4: 어댑터 그룹의 공개 표면은 그 그룹의 `index.ts`다. 그룹 바깥(bootstrap, features, presentation)은 배럴만 import한다. 배럴이 없던 시절 bootstrap은 어댑터 내부 파일 15곳을 직접 겨눴고, 그래서 어떤 파일이 공개이고 어떤 파일이 내부 헬퍼인지 아무 데도 적혀 있지 않았다. 출발점에서 `src/adapters`를 뺀 이유는 어댑터끼리의 간선은 바로 위 `adapters-do-not-know-other-concrete-adapters`가 이미 담당하고, 커널(`platform/**`)은 파일 단위로 공유되기 때문이다 — `scripts/check-adapter-inventory.ts`가 네 소비자에게 `platform/abortable-operation.ts`로 해석되는 specifier를 직접 요구한다. 도착점에서 1단계 중첩 `index.ts`를 허용한 이유는 `storage/indexeddb`와 `storage/opfs`가 각자 독립적으로 제거 가능한 런타임이고(scripts/test-browser-file-storage-runtime-removal.ts), 그래서 각자의 배럴이 곧 경계이기 때문이다.",
|
||||
"severity": "error",
|
||||
"from": {
|
||||
"path": "^src/",
|
||||
"pathNot": "^src/adapters/"
|
||||
},
|
||||
"to": {
|
||||
"path": "^src/adapters/[^/]+/",
|
||||
"pathNot": "^src/adapters/[^/]+/(?:[^/]+/)?index\\.ts$"
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
규칙 형태 적합성: `scripts/check-architecture.ts:799-815`의 `validateArchitectureRules`는 `from`에 `path`/`pathNot`, `to`에 `path`/`pathNot`/`circular`만 허용한다. 이 규칙은 그 안에 있다. 정규식은 `new RegExp(pattern, "u")`로 평가되므로(`:739`, `:747`) 비캡처 그룹 `(?:...)`도 문제없다. `$1` 역참조는 쓰지 않았다.
|
||||
|
||||
### 5.2 이 규칙이 무엇을 잡고 무엇을 안 잡는가 — 실측
|
||||
|
||||
레포의 실제 import 그래프(`src` 전체, 상대 specifier 해석)에 규칙을 그대로 돌려본 결과:
|
||||
|
||||
**잡는 것 — 오늘 기준 위반 15건** (= §4.1의 치환 대상 15줄과 정확히 일치)
|
||||
|
||||
```
|
||||
src/bootstrap/main.tsx:3 -> src/adapters/diagnostics/bounded-diagnostics.ts
|
||||
src/bootstrap/optional-runtime-host.ts:4 -> src/adapters/platform/browser-lifecycle.ts
|
||||
src/bootstrap/register-service-worker.ts:5 -> src/adapters/service-worker/service-worker-page-controller.ts
|
||||
src/bootstrap/runtime-adapters.ts:6 -> src/adapters/auth/external-session-adapter.ts
|
||||
src/bootstrap/runtime-adapters.ts:7 -> src/adapters/diagnostics/bounded-diagnostics.ts
|
||||
src/bootstrap/runtime-adapters.ts:8 -> src/adapters/http/client.ts
|
||||
src/bootstrap/runtime-adapters.ts:12 -> src/adapters/http/http-execution-v3.ts
|
||||
src/bootstrap/runtime-adapters.ts:23 -> src/adapters/query-cache/tanstack-cache-coordinator.ts
|
||||
src/bootstrap/runtime-adapters.ts:24 -> src/adapters/query-cache/tanstack-query-cache.ts
|
||||
src/bootstrap/runtime-adapters.ts:25 -> src/adapters/query-cache/server-state-scope-runtime.ts
|
||||
src/bootstrap/runtime-adapters.ts:26 -> src/adapters/query-cache/conditional-validator-store.ts
|
||||
src/bootstrap/runtime-adapters.ts:27 -> src/adapters/storage/browser-storage-adapter.ts
|
||||
src/bootstrap/runtime-adapters.ts:28 -> src/adapters/platform/browser-mutation-intent-factory.ts
|
||||
src/bootstrap/runtime-adapters.ts:29 -> src/adapters/telemetry/best-effort-telemetry.ts
|
||||
src/features/reference-feature/adapters/create-reference-feature-input.ts:9 -> src/adapters/http/http-execution-v3.ts
|
||||
```
|
||||
|
||||
§4.1을 적용하면 이 15건이 0이 된다. **즉 배럴 8개 생성 + import 15줄 치환 + 규칙 추가를 한 커밋에 넣어야 `check:architecture`가 계속 PASS한다.** 순서를 나누면 중간 커밋이 빨간불이 된다.
|
||||
|
||||
**안 잡는 것 (의도대로)**
|
||||
|
||||
| 경우 | 건수 | 왜 안 잡히나 |
|
||||
|---|---:|---|
|
||||
| 같은 그룹 내부 파일끼리 | 229건 중 189건 | `from.pathNot: "^src/adapters/"`가 출발점을 제외 |
|
||||
| 어댑터 → 커널 (`platform/**`, `browser-file-storage/result.ts`, `cross-context-invalidation/index.ts`) | 40건 | 같은 이유. 이 간선들은 `adapters-do-not-know-other-concrete-adapters`(`:183-193`)의 `pathNot` carve-out이 계속 담당 |
|
||||
| 바깥 → 배럴 (이미 합격) | 2건 | `to.pathNot`이 `index.ts`를 면제. `src/bootstrap/runtime-adapters.ts:20`, `src/bootstrap/server-state-generation-store.ts:3` |
|
||||
| `tests/**` | 166줄 전부 | 스캔 범위 밖 (아래 §5.3) |
|
||||
| `src/presentation/adapters/query/**` | — | 경로가 `src/presentation/...`이라 `to.path` `^src/adapters/`에 매칭 자체가 안 됨 |
|
||||
|
||||
세 가지 질문에 대한 명시적 답:
|
||||
|
||||
1. **같은 그룹 내부 import — 허용된다.** 출발점이 `^src/adapters/`이면 규칙이 아예 평가되지 않는다.
|
||||
2. **커널 접근 — 허용된다.** `platform/`은 위와 같은 이유로 통과. `browser-file-storage/result.ts`와 `cross-context-invalidation/index.ts`도 출발점이 어댑터이므로 통과. (참고: `cross-context-invalidation/index.ts`는 마침 `index.ts`라 도착점 면제에도 걸린다 — 이중으로 안전.)
|
||||
3. **`tests/`는 대상이 아니다.** `scripts/check-architecture.ts:69`가 `sourceRoot = resolve(projectRoot, "src")`이고 `:116-123`이 `depcruise src --config ...`를 돌린다. 두 그래프 모두 `src`만 본다.
|
||||
|
||||
### 5.3 기존 규칙과의 충돌 검토
|
||||
|
||||
`.dependency-cruiser.json` 전체(18개 규칙)를 읽고 대조했다.
|
||||
|
||||
| 기존 규칙 | 줄 | 충돌 |
|
||||
|---|---:|---|
|
||||
| `domain-is-framework-neutral` | 4 | 없음. domain→adapters는 어차피 전면 금지 |
|
||||
| `application-does-not-know-concrete-runtime` | 13 | 없음. 동일 |
|
||||
| `presentation-does-not-know-adapters` | 24 | 없음. presentation→adapters 전면 금지가 상위 |
|
||||
| `page-templates-own-layout-only` | 34 | 없음 |
|
||||
| `icon-vendor-is-facade-only` | 44 | 없음 (외부 패키지 대상) |
|
||||
| `adapters-do-not-know-presentation` | 54 | 없음 (방향 반대) |
|
||||
| `feature-*` 4개 | 64–103 | 없음. feature adapters→`src/adapters`는 이 규칙들이 막지 않으므로 새 규칙이 유효하게 작동 (실측 15번째 위반이 그 경우) |
|
||||
| `concrete-adapters-compose-only-in-bootstrap` | 104 | 없음. `src/(domain\|application\|presentation\|contracts)` → `^src/adapters` 전면 금지. 새 규칙은 그 나머지(`bootstrap`, `features`)에서만 실효 |
|
||||
| `external-contract-package-single-import-path` | 114 | 없음 |
|
||||
| `presentation-does-not-fetch-directly` | 126 | 없음. `^src/adapters/(http\|realtime\|service-worker\|web-worker\|storage)` 를 겨누는데 presentation은 이미 전면 금지 |
|
||||
| `generic-worker-has-no-network-or-credentials` | 137 | 없음. `^src/adapters/web-worker` 출발이라 새 규칙 출발점 제외와 겹칠 뿐. (`src/adapters/web-worker`는 현재 존재하지 않는 예방 규칙) |
|
||||
| `service-worker-entry-is-not-page-code` | 148 | 없음 |
|
||||
| `contracts-do-not-know-application` | 159 | 없음 |
|
||||
| `generic-presentation-does-not-compose-the-product` | 170 | 없음 |
|
||||
| **`adapters-do-not-know-other-concrete-adapters`** | **183** | **없음 — 상보적.** 저쪽은 `from: ^src/adapters/([^/]+)/`, 이쪽은 `from.pathNot: ^src/adapters/`. 정확히 반대 집합이라 이중 판정이 생기지 않는다 |
|
||||
| `no-circular-dependencies` | 194 | 주의 필요 → 아래 |
|
||||
|
||||
**순환 검토.** `no-circular-dependencies`(`:194-201`)가 error다. 배럴 도입이 순환을 만들려면 그룹 안 파일이 자기 그룹 `index.ts`를 import해야 한다.
|
||||
|
||||
```
|
||||
grep -rn 'from "\./index\.ts"\|from "\.\./index\.ts"' src/adapters/ --include='*.ts' → 0건
|
||||
```
|
||||
|
||||
또 `src/bootstrap/**`는 어댑터에서 import되지 않는다(`adapters-do-not-know-presentation`이 `^src/(presentation|bootstrap)`을 막음, `:54-62`). 따라서 **새 순환 없음.** 단 §3.7의 판정을 뒤집어 `storage/index.ts`가 서브배럴을 재수출하면, 순환은 아니지만 제거 드릴이 깨진다(§6 위험 2).
|
||||
|
||||
### 5.4 ESLint 쪽은 손대지 않는다
|
||||
|
||||
`eslint.config.ts:9-34`의 `layerPatterns`는 레이어 단위(`**/adapters/**`)만 다루고 그룹 내부 경로를 구분하지 않는다. 배럴 규칙을 여기에도 복제하면 두 곳에서 같은 사실을 관리하게 된다. dependency-cruiser 쪽 한 곳만 유지한다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 위험과 부수 작업
|
||||
|
||||
### 위험 1 (최대) — 번들 예산
|
||||
|
||||
`package.json`에 `"sideEffects"` 필드가 없다. 번들러는 모든 모듈을 부작용 있을 수 있는 것으로 보고, 배럴 재수출을 통해 들어온 모듈을 트리셰이킹에서 살려둘 수 있다.
|
||||
|
||||
구체적으로 위험한 세 곳:
|
||||
- `src/bootstrap/register-service-worker.ts`가 `service-worker/index.ts`를 import하면 `service-worker-lifecycle.ts`(666줄) → `service-worker-static-assets.ts`(394줄)가 **페이지 번들 그래프**에 들어온다. 지금은 `service-worker-page-controller.ts`(494줄) → `service-worker-protocol.ts` + `service-worker-removal.ts`만 들어온다.
|
||||
- `src/bootstrap/runtime-adapters.ts`가 `http/index.ts`를 import하면 V2·V3가 항상 함께 들어온다 (`client.ts` 1107줄 + `http-execution-v3.ts` 1602줄). 지금도 둘 다 import하긴 한다.
|
||||
- `query-cache/index.ts`는 `cursor-pagination-runtime.ts`(234줄)를 추가로 끌어온다.
|
||||
|
||||
예산: `config/performance/budgets.json`의 `bundle.initialJsGzipBytes = 204800`. `check:bundle`(`scripts/check-bundle.ts:47`)이 초과 시 실패한다.
|
||||
|
||||
**대응 (권장 순서)**
|
||||
1. 배럴 커밋에서 `corepack pnpm check:bundle`을 반드시 돌린다. 이 문서에서는 실행하지 않았다.
|
||||
2. 넘치면 `package.json`에 `"sideEffects": false`를 추가한다. `src/adapters` 아래에 최상위 부작용이 있는지 먼저 확인해야 한다(`presentation/styles/theme.css` 같은 CSS import는 `"sideEffects": ["*.css"]` 형태로 보존).
|
||||
3. 그래도 넘치면 `service-worker/index.ts`에서 `service-worker-lifecycle.ts` 블록을 빼고, 워커 realm은 파일 직접 import를 유지한다(`platform`과 같은 논리 — realm이 다르면 문도 다르다).
|
||||
|
||||
### 위험 2 — `storage/index.ts`에 서브배럴을 넣고 싶은 충동
|
||||
|
||||
`browser-transfer`/`realtime` 선례만 보고 `export * from "./indexeddb/index.ts"`를 넣으면 `test:browser-file-storage-removal`이 `assertNoRuntimeImports` 단계에서 예외로 죽는다(§3.7). 배럴 파일에 그 이유를 주석으로 못 박아 두는 걸 권한다.
|
||||
|
||||
### 위험 3 — `check:adapter-inventory`가 새 파일 8개를 거부한다
|
||||
|
||||
`scripts/check-adapter-inventory.ts:58-74`가 `git ls-files src/adapters` 결과와 `docs/reviews/adapters/INVENTORY.md`의 행 목록을 **정확히 일치**시키고, `합계: **N/N**` 숫자도 파일 수와 같아야 한다.
|
||||
|
||||
**같은 커밋에서 해야 할 일:**
|
||||
1. 새 `index.ts` 8개를 `git add` 한다 (추적되지 않으면 `git ls-files`에 안 잡혀서 오히려 통과하지만, 커밋하는 순간 깨진다).
|
||||
2. `docs/reviews/adapters/INVENTORY.md`에 행 8개 추가:
|
||||
|
||||
| 추가할 경로 | 상세 리뷰 링크 (같은 그룹 기존 행과 동일하게) |
|
||||
|---|---|
|
||||
| `src/adapters/auth/index.ts` | `[Network/state](./01-network-and-state.md)` — 기존 행 `:11` |
|
||||
| `src/adapters/diagnostics/index.ts` | `[Network/state](./01-network-and-state.md)` — `:58` |
|
||||
| `src/adapters/http/index.ts` | `[Network/state](./01-network-and-state.md)` — `:59-68` |
|
||||
| `src/adapters/platform/index.ts` | `[Network/state](./01-network-and-state.md)` — `:69-73` |
|
||||
| `src/adapters/query-cache/index.ts` | `[Network/state](./01-network-and-state.md)` — `:74-78` |
|
||||
| `src/adapters/telemetry/index.ts` | `[Network/state](./01-network-and-state.md)` — `:119` |
|
||||
| `src/adapters/service-worker/index.ts` | `[Worker/push](./05-service-worker-and-web-push.md)` — `:96-101` |
|
||||
| `src/adapters/storage/index.ts` | `[Storage/files](./03-storage-and-browser-files.md)` — `:102-103` |
|
||||
|
||||
3. `docs/reviews/adapters/INVENTORY.md:132`의 `합계: **120/120**` → `합계: **128/128**`.
|
||||
|
||||
행 번호(`| N |`)는 `inventoryRows`(`scripts/check-adapter-inventory.ts:33-40`)가 아래 정규식으로 **경로만** 뽑고 순서·연속성은 검사하지 않는다.
|
||||
|
||||
```
|
||||
^\|\s*\d+\s*\|\s*`([^`]+)`\s*\|
|
||||
```
|
||||
|
||||
그래도 읽는 사람을 위해 정렬 위치에 끼워 넣고 번호를 다시 매기는 걸 권한다.
|
||||
|
||||
### 위험 4 — `tsconfig.app.json` 제외 파일
|
||||
|
||||
`tsconfig.app.json:18`이 `src/adapters/service-worker/service-worker-entry.ts`를 제외한다. `service-worker/index.ts`가 이 파일을 참조하면 app 타입체크가 제외 대상을 끌어들인다. §3.6의 배럴은 참조하지 않는다(그 파일은 export가 0개다). 나중에 누가 "완전성"을 이유로 추가하지 않도록 배럴에 주석을 남기는 것도 방법이다.
|
||||
|
||||
### 부수 발견 (이 문서 범위 밖, 별도 티켓)
|
||||
|
||||
1. `src/adapters/telemetry/best-effort-telemetry.ts:49` — 커널 심볼 재수출. 소비자 0, 같은 파일 `:8`에 import가 이미 있음. 삭제 후보.
|
||||
2. `src/adapters/browser-files/index.ts:1-16` — 배럴이 application 포트 타입을 재수출. 어댑터 배럴이 하위 레이어의 통로가 되는 형태.
|
||||
3. `src/adapters/storage/opfs/index.ts` — `tests/unit/opfs-byte-store.test.ts:25,28`이 쓰는 `OPFS_WORKER_PROTOCOL_VERSION`·`PreparePhysicalObjectRequest`·`writeWithSyncAccessHandle` 3개가 빠져 있다.
|
||||
4. `.dependency-cruiser.json:141` — `^src/adapters/web-worker` 를 겨누는 규칙이 있으나 해당 디렉터리는 존재하지 않는다(어댑터 그룹은 16개). 예방 규칙인지 잔재인지 확인 필요.
|
||||
|
||||
---
|
||||
|
||||
## 7. 심볼 존재 검증 (기계 대조)
|
||||
|
||||
제시한 8개 배럴의 **모든 재수출 심볼 82개**를, 각 대상 파일의 실제 `export` 선언과 이름 단위로 대조했다.
|
||||
|
||||
- 대조 방법: 각 `export { ... } from "./X.ts"` 블록의 이름을 뽑아, `src/adapters/<group>/X.ts`에서 `^export (declare )?(async function|function|const|let|var|class|type|interface|enum) <name>` 으로 선언된 이름 집합에 들어 있는지 확인.
|
||||
- 결과: **82/82 존재. 누락 0, 오타 0.**
|
||||
- 대상 파일 존재 여부도 함께 확인(8개 배럴이 참조하는 소스 파일 16개 전부 존재).
|
||||
|
||||
그룹별 심볼 수: auth 7, diagnostics 5, telemetry 5, platform 16, query-cache 13, storage 2, service-worker 11, http 23.
|
||||
|
||||
---
|
||||
|
||||
## 8. 실행 체크리스트 (한 커밋)
|
||||
|
||||
1. `src/adapters/{auth,diagnostics,http,platform,query-cache,service-worker,storage,telemetry}/index.ts` 8개 생성 (§3 내용 그대로).
|
||||
2. §4.1 표의 15줄 치환. `src/bootstrap/runtime-adapters.ts`는 아래에서 위로 편집하거나 `:1-29` 블록 전체를 다시 쓴다.
|
||||
3. (선택) `.storybook/preview.tsx:5,6` 2줄 치환.
|
||||
4. `.dependency-cruiser.json`에 §5.1 규칙 추가 (현재 `:193`과 `:194` 사이).
|
||||
5. `docs/reviews/adapters/INVENTORY.md`에 행 8개 추가 + `:132`의 합계를 `128/128`로.
|
||||
6. `docs/architecture/layers.md`에 "어댑터 그룹의 공개 표면은 그 그룹의 `index.ts`다. 커널은 예외로 파일 단위로 공유된다"를 한 문단 추가 (`:29-45`의 adapter kernel 절 뒤). 이 문서 규칙들은 실행 규칙과 짝을 이루게 되어 있다(`layers.md:39-45`).
|
||||
7. 게이트 실행: `check:architecture` → `check:types:app` → `check:adapter-inventory` → `lint` → `check:bundle` → `test:browser-file-storage-removal`.
|
||||
마지막 두 개가 이번 변경의 실제 리스크 지점이다(§6 위험 1, 2).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,13 +90,39 @@ production Playwright profile은 source fixture가 아니라 `build` + `preview`
|
||||
|
||||
#### TanStack Query의 React integration test 기반
|
||||
|
||||
`tests/component/application-query.test.tsx`는 production query inbound
|
||||
adapter의 query/mutation lifecycle을 검증한다. cancellation, initial terminal
|
||||
failure, background stale-failure latch와 retry 복구, duplicate submit,
|
||||
optimistic commit/rollback, conflict 해제와 namespace invalidation이 실제
|
||||
QueryClient 위에서 실행된다. HTTP 자동 retry가 소유자이므로 이 adapter의
|
||||
production query inbound adapter의 React integration은 behavior owner별
|
||||
component suite로 분리되어 있다.
|
||||
|
||||
- `application-query-bridge.test.tsx`: initial/background state, cancellation
|
||||
- `application-query-scope-fence.test.tsx`: scope commit fence와 result budget
|
||||
- `application-mutation-scope-fence.test.tsx`: mutation scope fence
|
||||
- `application-query.test.tsx`: unknown-effect reconciliation
|
||||
- `application-mutation-admission.test.tsx`: intent/duplicate admission
|
||||
- `application-mutation-optimistic-cache.test.tsx`: optimistic cache commit/rollback
|
||||
- `application-query-fixture.tsx`: 공통 QueryClient/scope fixture
|
||||
|
||||
각 실패 파일명이 깨진 behavior contract를 직접 드러내며, 동일한 52개 계약을
|
||||
실제 QueryClient 위에서 검증한다. HTTP 자동 retry가 소유자이므로 이 adapter의
|
||||
query/mutation vendor retry는 꺼져 있다.
|
||||
|
||||
같은 기준을 대형 unit suite에도 적용한다. 줄 수를 기준으로 자르지 않고 실패가
|
||||
가리켜야 하는 behavior owner를 기준으로 분리한다.
|
||||
|
||||
- `public-response-cache.test.ts`: 일반 stage/activate/lookup/cache contract
|
||||
- `public-response-cache-repair.test.ts`: active release repair의 failure atomicity
|
||||
- `resumable-upload-runtime.test.ts`: upload/reconcile/control-plane 흐름
|
||||
- `resumable-upload-runtime-teardown.test.ts`: bounded drain/raw provider teardown
|
||||
- `security-followup.test.ts`: archived local evidence
|
||||
- `security-provider-evidence.test.ts`: provider signature/supervision/process lifecycle
|
||||
- `security-promotion-staging.test.ts`: private staging seal/replay/CLI identity
|
||||
- `tests/integration/provider-guardian-transaction.test.ts`: child process,
|
||||
filesystem, IPC frame, READY/PUBLISHED handshake와 process lifecycle. pure unit
|
||||
pool에 두지 않으며 handshake budget은 protocol timeout이 아닌 test watchdog이다.
|
||||
- 각 `*-fixture.ts`: 해당 owner들 사이에서만 공유하는 deterministic test fixture
|
||||
|
||||
이 분리는 production owner와 test failure surface를 맞추기 위한 것이며, 단순
|
||||
LOC 감축 목적이 아니다.
|
||||
|
||||
#### Form과 route 위험
|
||||
|
||||
form component/reference feature test가 error summary, 첫 오류 focus, Zod
|
||||
|
||||
+69
-14
@@ -3,14 +3,72 @@
|
||||
Each gate is blocking in its declared scope. Failures are not downgraded with
|
||||
`continue-on-error` or warning-only scripts.
|
||||
|
||||
| Level | Command | Evidence |
|
||||
| --- | --- | --- |
|
||||
| runtime schema | `pnpm test:runtime-schema` | `artifacts/tests/runtime-schema.xml` |
|
||||
| unit | `pnpm test:unit` | `artifacts/tests/unit.xml` |
|
||||
| component | `pnpm test:component` | `artifacts/tests/component.xml` |
|
||||
| integration | `pnpm test:integration` | `artifacts/tests/integration.xml` |
|
||||
| end-to-end | `pnpm test:e2e` | `artifacts/tests/e2e/` |
|
||||
| accessibility | `pnpm test:a11y` | `artifacts/tests/a11y.json` |
|
||||
## Executable levels
|
||||
|
||||
| Level | Command | Ownership / prerequisite | Evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| runtime schema | `pnpm test:runtime-schema` | pure runtime schema contracts | `artifacts/tests/runtime-schema.xml` |
|
||||
| unit | `pnpm test:unit` | Node-only domain/application/pure policy/runtime units; no systemd/bwrap/cgroup prerequisite | `artifacts/tests/unit.xml` |
|
||||
| capability contract | `pnpm test:contract` | reusable capability consumer contracts | `artifacts/tests/contract.xml` |
|
||||
| component | `pnpm test:component` | React/hook/UI behavior | `artifacts/tests/component.xml` |
|
||||
| integration | `pnpm test:integration` | HTTP/MSW, IndexedDB, composed browser-runtime boundaries, child-process/filesystem/IPC integration | `artifacts/tests/integration.xml` |
|
||||
| system / CI runner | `pnpm test:system` | compatible Linux host with systemd, bubblewrap, cgroup v2 and CI-provider process controls | `artifacts/tests/system.xml` |
|
||||
| end-to-end | `pnpm test:e2e` | pinned browser engines | `artifacts/tests/e2e/` |
|
||||
| accessibility | `pnpm test:a11y` | pinned browser engines | `artifacts/tests/a11y.json` |
|
||||
|
||||
`test:all` is the normal product-development loop. It intentionally includes
|
||||
runtime-schema, unit, capability-contract, component, integration, reference
|
||||
feature and recipe suites, but does not include `test:system`. CI-runner and
|
||||
supply-chain assurance has different host prerequisites and is invoked
|
||||
explicitly in the assurance path.
|
||||
|
||||
## Deterministic test process
|
||||
|
||||
All Vitest package scripts launch through `scripts/run-vitest.ts`.
|
||||
|
||||
That runner:
|
||||
|
||||
1. rejects Node versions outside the repository-supported
|
||||
`>=24.11.0 <25.0.0` range before the suite starts,
|
||||
2. owns `NODE_ENV=test` rather than trusting the parent shell,
|
||||
3. removes host-specific `npm_config_userconfig`, `npm_config_prefix` and
|
||||
`npm_config_globalconfig` values before Vitest starts.
|
||||
|
||||
`vitest.config.ts` also fixes `NODE_ENV=test` so a direct Vitest invocation
|
||||
cannot accidentally select React's production behavior.
|
||||
|
||||
The system suite additionally runs
|
||||
`scripts/check-system-test-prerequisites.ts` and fails with one prerequisite
|
||||
report when the CI-runner host does not provide its required Linux facilities.
|
||||
|
||||
## Development paths
|
||||
|
||||
Product feature:
|
||||
|
||||
- focused feature/unit/component test
|
||||
- capability contract test when a reusable boundary changes
|
||||
- type/lint/architecture
|
||||
- `test:all`
|
||||
|
||||
Reusable capability:
|
||||
|
||||
- focused unit tests
|
||||
- capability contract tests
|
||||
- integration tests
|
||||
- type/lint/architecture
|
||||
|
||||
CI / release assurance:
|
||||
|
||||
- `test:system`
|
||||
- supply-chain / promotion / release gates
|
||||
|
||||
A host-level process/sandbox test must not be placed in `tests/unit` merely
|
||||
because it uses Vitest. The classification follows the system boundary and
|
||||
prerequisites, not the test framework. Process-heavy tests that spawn child
|
||||
processes but do not require privileged host facilities belong in
|
||||
`tests/integration`; for example the provider guardian transaction protocol
|
||||
lives at `tests/integration/provider-guardian-transaction.test.ts`. Its
|
||||
handshake timeout is a test watchdog, not a production protocol deadline.
|
||||
|
||||
End-to-end and automated accessibility scenarios run on the pinned Chromium,
|
||||
Firefox, and WebKit engines. The responsive contract explicitly exercises
|
||||
@@ -30,9 +88,6 @@ Promotion is an AND graph:
|
||||
3. release gates plus rollback/runbook drills
|
||||
4. production promotion plus eligible field Web Vitals evidence
|
||||
|
||||
This file describes the currently registered taxonomy. The
|
||||
[frontend platform testing strategy](./frontend-platform-testing-strategy.md)
|
||||
documents the target additions: test TypeScript projects, real bootstrap
|
||||
composition tests, shared MSW scenarios, query/mutation/form/router coverage,
|
||||
Storybook interaction and accessibility checks, visual regression, and a
|
||||
built-output Playwright profile.
|
||||
The [frontend platform testing strategy](./frontend-platform-testing-strategy.md)
|
||||
contains the broader testing design. This file is the executable taxonomy for
|
||||
where a test belongs and which environment is allowed to run it.
|
||||
|
||||
+37
-26
@@ -11,6 +11,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "node scripts/build-frontend.ts",
|
||||
"build:profile": "node scripts/generate-runtime-config.ts",
|
||||
"build:release-candidate": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security && corepack pnpm verify:release && node scripts/verify-supply-chain-artifacts.ts && node scripts/create-release-candidate.ts",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src scripts tests recipes .storybook vite.config.ts vitest.config.ts playwright*.config.ts --max-warnings=0",
|
||||
@@ -21,6 +22,7 @@
|
||||
"check:i18n:fixture": "node scripts/check-i18n.ts --fixture",
|
||||
"check:adapter-inventory": "node scripts/check-adapter-inventory.ts",
|
||||
"check:remediation-ledger": "node scripts/check-remediation-ledger.ts",
|
||||
"check:release-admission": "node scripts/check-release-admission.ts",
|
||||
"check:diagnostics": "node scripts/check-diagnostics.ts",
|
||||
"check:diagnostics:fixture": "node scripts/check-diagnostics.ts --fixture",
|
||||
"check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test && corepack pnpm check:types:recipes && corepack pnpm check:types:web-worker && corepack pnpm check:types:service-worker",
|
||||
@@ -28,29 +30,31 @@
|
||||
"check:types:node": "tsc --project tsconfig.node.json",
|
||||
"check:types:test": "tsc --project tsconfig.test.json",
|
||||
"check:types:recipes": "node scripts/check-optional-recipe-types.ts",
|
||||
"check:types:fixture": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.ts",
|
||||
"check:types:fixture:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts",
|
||||
"check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts",
|
||||
"check:types:fixture:application-output": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts",
|
||||
"check:types:fixture:application-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts",
|
||||
"check:types:fixture:feature-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-feature-input.ts",
|
||||
"check:types:fixture:failure-kind": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-failure-kind.ts",
|
||||
"check:types:fixture:reference-operation": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-reference-operation.ts",
|
||||
"check:types:fixture:async-overlay": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts",
|
||||
"check:types:fixture:route-runtime": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
|
||||
"check:types:fixture:page-action": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx",
|
||||
"check:types:fixture:icon-button": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx",
|
||||
"check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts",
|
||||
"check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts",
|
||||
"check:types:fixture:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.ts",
|
||||
"check:types:fixture:image-resolve-signal": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts",
|
||||
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
|
||||
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
|
||||
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
|
||||
"test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
|
||||
"test:http-scenario-catalog": "vitest run tests/integration/http-scenario-catalog.test.ts --reporter=default --maxWorkers=1",
|
||||
"check:types:fixture": "tsc -p tests/fixtures/typecheck/tsconfig.port-call.json",
|
||||
"check:types:fixture:ts-port": "tsc -p tests/fixtures/typecheck/tsconfig.port-implementation.json",
|
||||
"check:types:fixture:ts-result": "tsc -p tests/fixtures/typecheck/tsconfig.result-narrowing.json",
|
||||
"check:types:fixture:application-output": "tsc -p tests/fixtures/typecheck/tsconfig.application-output.json",
|
||||
"check:types:fixture:application-input": "tsc -p tests/fixtures/typecheck/tsconfig.application-input.json",
|
||||
"check:types:fixture:feature-input": "tsc -p tests/fixtures/typecheck/tsconfig.feature-input.json",
|
||||
"check:types:fixture:failure-kind": "tsc -p tests/fixtures/typecheck/tsconfig.failure-kind.json",
|
||||
"check:types:fixture:reference-operation": "tsc -p tests/fixtures/typecheck/tsconfig.reference-operation.json",
|
||||
"check:types:fixture:async-overlay": "tsc -p tests/fixtures/typecheck/tsconfig.async-overlay.json",
|
||||
"check:types:fixture:route-runtime": "tsc -p tests/fixtures/typecheck/tsconfig.route-runtime.json",
|
||||
"check:types:fixture:page-action": "tsc -p tests/fixtures/typecheck/tsconfig.page-action.json",
|
||||
"check:types:fixture:icon-button": "tsc -p tests/fixtures/typecheck/tsconfig.icon-button.json",
|
||||
"check:types:fixture:i18n-key": "tsc -p tests/fixtures/typecheck/tsconfig.message-key.json",
|
||||
"check:types:fixture:i18n-params": "tsc -p tests/fixtures/typecheck/tsconfig.message-params.json",
|
||||
"check:types:fixture:diagnostics": "tsc -p tests/fixtures/typecheck/tsconfig.diagnostics-port.json",
|
||||
"check:types:fixture:image-resolve-signal": "tsc -p tests/fixtures/typecheck/tsconfig.image-cdn-resolve-signal.json",
|
||||
"test:runtime-schema": "node scripts/run-vitest.ts run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
|
||||
"test:unit": "node scripts/run-vitest.ts run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
|
||||
"test:contract": "node scripts/run-vitest.ts run tests/contract --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/contract.xml",
|
||||
"test:system": "node scripts/check-system-test-prerequisites.ts && node scripts/run-vitest.ts run tests/system --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/system.xml",
|
||||
"test:component": "node scripts/run-vitest.ts run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
|
||||
"test:integration": "node scripts/run-vitest.ts run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
|
||||
"test:http-scenario-catalog": "node scripts/run-vitest.ts run tests/integration/http-scenario-catalog.test.ts --reporter=default --maxWorkers=1",
|
||||
"test:http-scenario-evidence": "node scripts/run-http-scenario-evidence.ts",
|
||||
"test:recipes": "vitest run tests/recipes --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/optional-recipes.xml --passWithNoTests",
|
||||
"test:recipes": "node scripts/run-vitest.ts run tests/recipes --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/optional-recipes.xml --passWithNoTests",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:dev": "playwright test --config playwright.dev.config.ts",
|
||||
"test:browser-capabilities": "playwright test --config playwright.capabilities.config.ts",
|
||||
@@ -72,11 +76,11 @@
|
||||
"test:optional-recipe-removal": "node scripts/test-optional-recipe-removal.ts",
|
||||
"test:browser-file-storage-removal": "node scripts/test-browser-file-storage-runtime-removal.ts",
|
||||
"test:realtime-removal": "node scripts/test-realtime-runtime-removal.ts",
|
||||
"test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests",
|
||||
"test:reference-feature": "node scripts/run-vitest.ts run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests",
|
||||
"check:v8-coverage-counter-semantics": "node scripts/check-v8-coverage-counter-semantics.ts",
|
||||
"test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts",
|
||||
"test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && node scripts/run-vitest.ts run tests/runtime-schema tests/unit tests/contract tests/component tests/integration tests/features/reference-feature --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts",
|
||||
"check:coverage:fixture": "node scripts/check-risk-coverage.ts --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json",
|
||||
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes",
|
||||
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:contract && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes",
|
||||
"verify:lockfile": "corepack pnpm install --frozen-lockfile --ignore-scripts",
|
||||
"check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts",
|
||||
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
|
||||
@@ -120,7 +124,14 @@
|
||||
"build:app-only": "vite build && node scripts/generate-build-manifest.ts",
|
||||
"generate:contract-set": "node scripts/generate-contract-set.ts",
|
||||
"check:types:web-worker": "tsc --project tsconfig.web-worker.json",
|
||||
"check:types:service-worker": "tsc --project tsconfig.service-worker.json"
|
||||
"check:types:service-worker": "tsc --project tsconfig.service-worker.json",
|
||||
"check:types:fixture:http-operation-input": "tsc -p tests/fixtures/typecheck/tsconfig.http-operation-input.json",
|
||||
"check:types:fixture:http-wire-mapper": "tsc -p tests/fixtures/typecheck/tsconfig.http-wire-mapper.json",
|
||||
"check:types:fixture:http-operation-id": "tsc -p tests/fixtures/typecheck/tsconfig.http-operation-id.json",
|
||||
"check:types:fixture:http-route-id": "tsc -p tests/fixtures/typecheck/tsconfig.http-route-id.json",
|
||||
"check:types:fixture:feature-contribution-input": "tsc -p tests/fixtures/typecheck/tsconfig.feature-contribution-input.json",
|
||||
"check:types:fixture:feature-capability-selection": "tsc -p tests/fixtures/typecheck/tsconfig.feature-capability-selection.json",
|
||||
"check:types:fixture:direct-feature-composition": "tsc -p tests/fixtures/typecheck/tsconfig.direct-feature-composition.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
|
||||
@@ -14,5 +14,8 @@
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,19 @@ import { INSTALLED_RUNTIME_CAPABILITIES } from "../src/features/installed-runtim
|
||||
* 1. clean dist and .generated/frontend-runtime
|
||||
* 2. generate contractSet and build-info source
|
||||
* 3. Vite app build (emptyOutDir = true)
|
||||
* 4. scan app dist and generate the static asset source
|
||||
* 5. ACTIVE only: Vite Service Worker build (emptyOutDir = false)
|
||||
* 6. generate Release Manifest V2 and the build manifest
|
||||
* 4. materialize dist/config.json from the declared APP_PROFILE
|
||||
* 5. scan app dist and generate the static asset source
|
||||
* 6. ACTIVE only: Vite Service Worker build (emptyOutDir = false)
|
||||
* 7. generate Release Manifest V2 and the build manifest
|
||||
*
|
||||
* Steps 4 and 5 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES`
|
||||
* Steps 5 and 6 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES`
|
||||
* and `null`: those modes never run an active worker build.
|
||||
*
|
||||
* Step 4 has to follow the Vite build and precede the asset scan. Vite copies
|
||||
* `public/` verbatim, so without it every build — including a production one —
|
||||
* ships the local runtime document; and the Service Worker hashes the emitted
|
||||
* `config.json`, so the profile must be in place before that inventory is
|
||||
* taken.
|
||||
*/
|
||||
|
||||
const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker;
|
||||
@@ -45,10 +52,13 @@ run("node", ["scripts/generate-contract-set.ts"]);
|
||||
// 3. app build
|
||||
run("npx", ["vite", "build"]);
|
||||
|
||||
// 4. runtime config for the declared profile
|
||||
run("node", ["scripts/generate-runtime-config.ts"]);
|
||||
|
||||
if (buildsActiveWorker) {
|
||||
// 4. hashed asset inventory
|
||||
// 5. hashed asset inventory
|
||||
run("node", ["scripts/generate-service-worker-assets.ts", "dist"]);
|
||||
// 5. service worker build
|
||||
// 6. service worker build
|
||||
run("npx", ["vite", "build", "--config", "vite.service-worker.config.ts"]);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
|
||||
@@ -130,6 +130,12 @@ async function main(): Promise<void> {
|
||||
// let an unrelated production import satisfy the gate while Image and
|
||||
// Resumable kept their own diverging copies of the same mechanics — which is
|
||||
// exactly how the four hand-written versions drifted apart in the first place.
|
||||
//
|
||||
// 이 목록은 재검토가 이름으로 지목한 네 파일만 덮는다. 같은 두 그룹의
|
||||
// `image-cdn-runtime.ts`와 `resumable-upload-runtime.ts`는 여기 없고 지금도
|
||||
// 자기 abort 사본을 들고 있다. 어댑터 전체로는 23개 파일이 그렇다. 그 전수
|
||||
// 이행은 abort 의미론을 바꾸는 별도 작업이라 이 목록으로 강제하지 않고,
|
||||
// 아래 래칫이 개수가 늘어나는 것만 막는다.
|
||||
const REQUIRED_ABORT_CONSUMERS: readonly string[] = [
|
||||
"src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts",
|
||||
"src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts",
|
||||
@@ -164,6 +170,7 @@ async function main(): Promise<void> {
|
||||
const PRIMITIVE_PATH = path.resolve(
|
||||
"src/adapters/platform/abortable-operation.ts",
|
||||
);
|
||||
const PRIMITIVE_SPECIFIER = "platform/abortable-operation.ts";
|
||||
for (const consumer of REQUIRED_ABORT_CONSUMERS) {
|
||||
if (!importerSet.has(consumer)) continue;
|
||||
const source = readFileSync(consumer, "utf8");
|
||||
@@ -181,6 +188,43 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 손수 짠 abort 배선은 줄어들기만 해야 한다.
|
||||
//
|
||||
// 커널 `platform/abortable-operation.ts`가 있는데도 어댑터 23개 파일이
|
||||
// `addEventListener("abort")`로 같은 race/cleanup을 각자 짠다. 그 전수 이행은
|
||||
// 동작이 바뀌는 큰 작업이라 한 번에 하지 않는다. 대신 개수를 여기 고정해
|
||||
// 되돌아가지 못하게 한다. 이행으로 숫자가 내려가면 이 상수도 같이 내린다.
|
||||
// `platform/`은 커널 자신이므로 세지 않는다.
|
||||
//
|
||||
// 24 → 23: `browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts`가
|
||||
// IndexedDB 커널로 옮겨가면서 자기 abort 리스너를 지웠다.
|
||||
// 23 → 22: `storage/indexeddb/indexeddb-maintenance.ts`가 같은 이유로 지웠다.
|
||||
// 22 → 21: `storage/indexeddb/indexeddb-runtime.ts`가 같은 이유로 지웠다.
|
||||
// 이것으로 IndexedDB 어댑터 4벌이 모두 커널을 쓴다.
|
||||
const HAND_ROLLED_ABORT_CEILING = 21;
|
||||
const handRolledScan = spawnSync(
|
||||
"git",
|
||||
["grep", "-l", 'addEventListener("abort"', "--", "src/adapters"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const handRolledFiles = (
|
||||
handRolledScan.status === 0 ? handRolledScan.stdout : ""
|
||||
)
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((file) => !file.startsWith("src/adapters/platform/"))
|
||||
.filter((file) => !readFileSync(file, "utf8").includes(PRIMITIVE_SPECIFIER))
|
||||
.sort();
|
||||
if (handRolledFiles.length > HAND_ROLLED_ABORT_CEILING) {
|
||||
problems.push(
|
||||
`abortable-operation: hand-rolled abort wiring grew to ` +
|
||||
`${handRolledFiles.length} files (ceiling ${HAND_ROLLED_ABORT_CEILING}). ` +
|
||||
`Use platform/abortable-operation.ts instead of a new listener pair. ` +
|
||||
`Current offenders (the new one is whichever this change added): ` +
|
||||
`${handRolledFiles.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(problem);
|
||||
process.exitCode = 1;
|
||||
@@ -195,7 +239,9 @@ async function main(): Promise<void> {
|
||||
} shared extensions PASS; ` +
|
||||
`fixture node_modules linking PASS; ` +
|
||||
`shared abort primitive: ${importers.length} importers ` +
|
||||
`(${importers.join(", ")}) PASS`,
|
||||
`(${importers.join(", ")}) PASS; ` +
|
||||
`hand-rolled abort wiring: ${handRolledFiles.length}/` +
|
||||
`${HAND_ROLLED_ABORT_CEILING} files (ratchet) PASS`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -723,9 +723,13 @@ function findArchitectureViolations(
|
||||
continue;
|
||||
}
|
||||
for (const dependency of dependencies) {
|
||||
const sourceGroups = rule.from?.path
|
||||
? (new RegExp(rule.from.path, "u").exec(dependency.source)?.slice(1) ??
|
||||
[])
|
||||
: [];
|
||||
if (
|
||||
matchesPath(dependency.source, rule.from) &&
|
||||
matchesPath(dependency.target, rule.to)
|
||||
matchesPath(dependency.target, rule.to, sourceGroups)
|
||||
) {
|
||||
violations.push({
|
||||
rule: rule.name,
|
||||
@@ -746,16 +750,52 @@ function findArchitectureViolations(
|
||||
function matchesPath(
|
||||
modulePath: string,
|
||||
criterion: PathRule | undefined,
|
||||
sourceGroups: readonly string[] = [],
|
||||
): boolean {
|
||||
if (!criterion) return true;
|
||||
if (criterion.path && !new RegExp(criterion.path, "u").test(modulePath)) {
|
||||
if (
|
||||
criterion.path &&
|
||||
!new RegExp(expandSourceGroups(criterion.path, sourceGroups), "u").test(
|
||||
modulePath,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !(
|
||||
criterion.pathNot && new RegExp(criterion.pathNot, "u").test(modulePath)
|
||||
criterion.pathNot &&
|
||||
new RegExp(expandSourceGroups(criterion.pathNot, sourceGroups), "u").test(
|
||||
modulePath,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitutes `$1`..`$9` in a `to` pattern with the capture groups the `from`
|
||||
* pattern matched on the importing module.
|
||||
*
|
||||
* Without it, "an adapter may not import a *different* adapter" cannot be
|
||||
* written as one rule: the target pattern has to name the importer's own
|
||||
* directory to exempt it. The alternative is one rule per adapter group, which
|
||||
* silently stops covering a group the moment somebody adds one — exactly the
|
||||
* gap that let `diagnostics` import `telemetry` while the documented rule said
|
||||
* it could not.
|
||||
*/
|
||||
function expandSourceGroups(
|
||||
pattern: string,
|
||||
sourceGroups: readonly string[],
|
||||
): string {
|
||||
return pattern.replaceAll(/\$([1-9])/gu, (whole, index: string) => {
|
||||
const captured = sourceGroups[Number(index) - 1];
|
||||
// A `from` pattern that did not capture leaves the token literal rather
|
||||
// than quietly matching everything.
|
||||
return captured === undefined ? whole : escapeRegExp(captured);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
||||
}
|
||||
|
||||
function validateArchitectureRules(rules: readonly ArchitectureRule[]): void {
|
||||
if (!rules.some((rule) => rule.to?.circular === true)) {
|
||||
throw new Error("Architecture configuration must contain a circular rule");
|
||||
@@ -784,12 +824,13 @@ async function runGraphFixtureChecks(): Promise<GraphFixtureResult> {
|
||||
"tests/fixtures/architecture/dependency-graph",
|
||||
);
|
||||
const allowedRoot = resolve(fixtureRoot, "allowed");
|
||||
const [allowedGraph, unresolvedGraph, layerGraph, cycleGraph] =
|
||||
const [allowedGraph, unresolvedGraph, layerGraph, cycleGraph, barrelGraph] =
|
||||
await Promise.all([
|
||||
analyzeSourceGraph(allowedRoot, "src"),
|
||||
analyzeSourceGraph(resolve(fixtureRoot, "unresolved"), "src"),
|
||||
analyzeSourceGraph(resolve(fixtureRoot, "layer"), "src"),
|
||||
analyzeSourceGraph(resolve(fixtureRoot, "cycle"), "src"),
|
||||
analyzeSourceGraph(resolve(fixtureRoot, "barrel"), "src"),
|
||||
]);
|
||||
const allowedFiles = new Set(await listFiles(allowedRoot));
|
||||
const allowedSourceFile = resolve(
|
||||
@@ -810,6 +851,21 @@ async function runGraphFixtureChecks(): Promise<GraphFixtureResult> {
|
||||
),
|
||||
);
|
||||
const assertions = [
|
||||
{
|
||||
name: "deep adapter import from outside the group is rejected",
|
||||
passed: blockingViolations(barrelGraph).some(
|
||||
({ rule, source, target }) =>
|
||||
rule === "adapter-groups-are-reached-through-their-barrel" &&
|
||||
source === "src/bootstrap/compose-deep.ts" &&
|
||||
target === "src/adapters/http/client.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "barrel import from outside the group is accepted",
|
||||
passed: !blockingViolations(barrelGraph).some(
|
||||
({ source }) => source === "src/bootstrap/compose-barrel.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "explicit TS specifier resolves to a TS module",
|
||||
passed: allowedGraph.dependencies.some(
|
||||
|
||||
@@ -77,7 +77,7 @@ if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.pat
|
||||
}
|
||||
|
||||
const expectedGateIds = Array.from(
|
||||
{ length: 26 },
|
||||
{ length: 27 },
|
||||
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
|
||||
);
|
||||
const passingResults: Record<string, GateResult> = Object.fromEntries(
|
||||
@@ -139,4 +139,4 @@ if (failures.length > 0) {
|
||||
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model PASS\n");
|
||||
process.stdout.write("CI contract: 27 gates, strict v2 graph and generated workflow model PASS\n");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
|
||||
import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.ts";
|
||||
import { classifyObjectSchemaChange } from "../src/contracts/compatibility.ts";
|
||||
import { compatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
|
||||
import {
|
||||
DEPLOYMENT_TARGETS,
|
||||
findAdmissionViolations,
|
||||
isDeploymentTarget,
|
||||
type AdmissionInput,
|
||||
} from "../src/contracts/deployment-admission.ts";
|
||||
import { parseRuntimeConfigArtifact } from "../src/contracts/release-artifacts.ts";
|
||||
|
||||
/**
|
||||
* §6.4 / FE-GATE-027. Refuses to admit an artifact to an environment it was not
|
||||
* built for.
|
||||
*
|
||||
* Release coherence already proves the artifacts agree with each other. It
|
||||
* cannot prove they belong in production, because a local build is coherent
|
||||
* with itself: `APP_ENV: local`, `AUTH_MODE: demo` and a loopback API pass
|
||||
* every existing gate. This gate closes that by making the destination an
|
||||
* explicit, declared input and refusing anything that does not match it.
|
||||
*
|
||||
* It fails closed in both directions. An undeclared destination is a refusal,
|
||||
* not a default, so an artifact can never be admitted by omission; and every
|
||||
* rule is stated as a reason to refuse, so an unreadable field cannot pass.
|
||||
*/
|
||||
|
||||
const RUNTIME_CONFIG_PATH = "dist/config.json";
|
||||
const RECORD_PATH = "artifacts/release/deployment-admission.json";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const declared = process.env["RELEASE_TARGET"];
|
||||
if (!isDeploymentTarget(declared)) {
|
||||
process.stderr.write(
|
||||
"release admission refused: RELEASE_TARGET must be declared as one of " +
|
||||
`${DEPLOYMENT_TARGETS.join(", ")}; received ${
|
||||
declared === undefined ? "nothing" : declared
|
||||
}.\n` +
|
||||
"An artifact is never admitted by default — name the environment it is for.\n",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let document: unknown;
|
||||
try {
|
||||
document = JSON.parse(await readFile(RUNTIME_CONFIG_PATH, "utf8"));
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`release admission refused: ${RUNTIME_CONFIG_PATH} is unreadable: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let config: AdmissionInput;
|
||||
try {
|
||||
config = parseRuntimeConfigArtifact(document) as AdmissionInput;
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`release admission refused: ${RUNTIME_CONFIG_PATH} is not a valid runtime config: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const violations = findAdmissionViolations(declared, config);
|
||||
await mkdir("artifacts/release", { recursive: true });
|
||||
await writeFile(
|
||||
RECORD_PATH,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
target: declared,
|
||||
appEnv: config.APP_ENV,
|
||||
authMode: config.AUTH_MODE,
|
||||
apiBaseUrl: config.API_BASE_URL,
|
||||
buildId: config.BUILD_ID ?? null,
|
||||
releaseId: config.RELEASE_ID ?? null,
|
||||
status: violations.length === 0 ? "ADMITTED" : "REFUSED",
|
||||
violations,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
if (violations.length > 0) {
|
||||
process.stderr.write(
|
||||
`release admission refused for ${declared}:\n${violations
|
||||
.map((violation) => ` ${violation.field}: ${violation.reason}`)
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`release admission: ${declared} ADMITTED ` +
|
||||
`(APP_ENV=${config.APP_ENV}, AUTH_MODE=${config.AUTH_MODE}, ` +
|
||||
`API=${config.API_BASE_URL}); record at ${RECORD_PATH}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,55 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const failures: string[] = [];
|
||||
|
||||
async function requireExecutable(path: string, label: string): Promise<void> {
|
||||
try {
|
||||
await access(path, constants.X_OK);
|
||||
} catch {
|
||||
failures.push(`${label} is required at ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
requireExecutable("/usr/bin/bwrap", "bubblewrap"),
|
||||
requireExecutable("/usr/bin/systemctl", "systemctl"),
|
||||
requireExecutable("/usr/bin/tar", "tar"),
|
||||
]);
|
||||
|
||||
try {
|
||||
const controllers = await readFile("/sys/fs/cgroup/cgroup.controllers", "utf8");
|
||||
if (controllers.trim().length === 0) {
|
||||
failures.push("cgroup v2 controllers are unavailable");
|
||||
}
|
||||
} catch {
|
||||
failures.push("cgroup v2 is required at /sys/fs/cgroup/cgroup.controllers");
|
||||
}
|
||||
|
||||
if (!failures.some((failure) => failure.includes("systemctl"))) {
|
||||
const probe = spawnSync("/usr/bin/systemctl", ["show-environment"], {
|
||||
encoding: "utf8",
|
||||
timeout: 3_000,
|
||||
});
|
||||
if (probe.error || probe.status !== 0) {
|
||||
failures.push("a reachable systemd manager bus is required");
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(
|
||||
[
|
||||
"System/CI-runner test prerequisites are unavailable:",
|
||||
...failures.map((failure) => ` - ${failure}`),
|
||||
"",
|
||||
"Run test:unit/test:contract/test:component/test:integration for the",
|
||||
"developer loop. test:system is intentionally reserved for a compatible",
|
||||
"Linux CI-runner host.",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write("System test prerequisites: PASS\n");
|
||||
@@ -247,6 +247,7 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [
|
||||
"provider-provenance",
|
||||
"provider-verification",
|
||||
"ci-contract-report",
|
||||
"deployment-admission",
|
||||
]),
|
||||
})
|
||||
.strict(),
|
||||
@@ -442,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{
|
||||
}>;
|
||||
|
||||
const CANONICAL_GATE_SHAPE_SHA256 =
|
||||
"a4a963d0b9deffb7a0a3d755bbbcb979d72610eb74751c3a2e5eca55251e12d4";
|
||||
"43dc4ed1cdaf21a674bd069d42eb2c32276afd8f763d72e52c9f7c3adccbeb58";
|
||||
|
||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||
const normalized = gates.map(
|
||||
@@ -473,16 +474,16 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
||||
(total, gate) => total + gate.commandIds.length,
|
||||
0,
|
||||
);
|
||||
if (contract.gates.length !== 26) {
|
||||
failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`);
|
||||
if (contract.gates.length !== 27) {
|
||||
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
|
||||
}
|
||||
if (contract.commands.length !== 81 || commandReferenceCount !== 93) {
|
||||
if (contract.commands.length !== 91 || commandReferenceCount !== 103) {
|
||||
failures.push(
|
||||
`command authority baseline must contain exactly 81 definitions and 93 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
|
||||
`command authority baseline must contain exactly 91 definitions and 103 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
|
||||
);
|
||||
}
|
||||
if (contract.artifacts.length !== 105) {
|
||||
failures.push(`artifact authority baseline must contain exactly 105 artifacts; received ${contract.artifacts.length}`);
|
||||
if (contract.artifacts.length !== 109) {
|
||||
failures.push(`artifact authority baseline must contain exactly 109 artifacts; received ${contract.artifacts.length}`);
|
||||
}
|
||||
if (contract.stages.length !== 5) {
|
||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||
@@ -511,7 +512,7 @@ export function parseCiGateContract(
|
||||
.join("\n");
|
||||
throw new TypeError(`CI gate contract invalid:\n${diagnostic}`);
|
||||
}
|
||||
if ((options.mode ?? "canonical") === "canonical") {
|
||||
if ((options.mode ?? defaultCiContractMode()) === "canonical") {
|
||||
const failures = canonicalAuthorityBaselineFailures(result.data);
|
||||
if (failures.length > 0) {
|
||||
throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`);
|
||||
@@ -520,11 +521,29 @@ export function parseCiGateContract(
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* A removal fixture runs the whole suite against a deliberately *reduced* CI
|
||||
* contract: the removed capability's gates, commands and artifacts are pruned.
|
||||
* Loading that contract in canonical mode re-imposes the full exact-count
|
||||
* authority on it, so the fixture failed on the very reduction it exists to
|
||||
* prove. `runRemovalFixturePnpm` marks those runs, and this is where the mark
|
||||
* is honoured.
|
||||
*/
|
||||
export function defaultCiContractMode(): "canonical" | "removal-fixture" {
|
||||
return process.env.CI_CONTRACT_MODE === "removal-fixture"
|
||||
? "removal-fixture"
|
||||
: "canonical";
|
||||
}
|
||||
|
||||
export function isReducedCiContractRun(): boolean {
|
||||
return defaultCiContractMode() === "removal-fixture";
|
||||
}
|
||||
|
||||
export async function loadCiGateContract(
|
||||
root = process.cwd(),
|
||||
options: LoadCiGateContractOptions = {},
|
||||
): Promise<CiGateContract> {
|
||||
const mode = options.mode ?? "canonical";
|
||||
const mode = options.mode ?? defaultCiContractMode();
|
||||
const [rawContract, rawPackage] = await Promise.all([
|
||||
readFile(path.join(root, "config/ci/gates.json"), "utf8"),
|
||||
readFile(path.join(root, "package.json"), "utf8"),
|
||||
@@ -759,11 +778,11 @@ function validateContractSemantics(
|
||||
}
|
||||
|
||||
const expectedGateIds = Array.from(
|
||||
{ length: 26 },
|
||||
{ length: 27 },
|
||||
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
|
||||
);
|
||||
if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) {
|
||||
issue("gate registry must contain FE-GATE-001..026 in canonical order");
|
||||
issue("gate registry must contain FE-GATE-001..027 in canonical order");
|
||||
}
|
||||
const expectedStages: ReadonlyArray<readonly [string, string, readonly string[], readonly string[]]> = [
|
||||
["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY],
|
||||
@@ -834,7 +853,7 @@ function validateContractSemantics(
|
||||
const expectedJobOwnership: Readonly<Record<string, readonly string[]>> = {
|
||||
merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"],
|
||||
release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"],
|
||||
immutable_build: ["FE-GATE-015"],
|
||||
immutable_build: ["FE-GATE-015", "FE-GATE-027"],
|
||||
vulnerability_provider: [],
|
||||
provenance_provider: [],
|
||||
promotion: [],
|
||||
@@ -888,7 +907,13 @@ function validateContractSemantics(
|
||||
const expectedEnvironmentBindings: Readonly<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
|
||||
merge_gate: [],
|
||||
release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }],
|
||||
immutable_build: [],
|
||||
immutable_build: [
|
||||
// FE-GATE-027 admits the built artifact to a named environment, so both
|
||||
// the profile it was built from and the destination it is claimed for are
|
||||
// declared inputs. An absent RELEASE_TARGET is a refusal, not a default.
|
||||
{ name: "APP_PROFILE", value: "${{ vars.APP_PROFILE }}" },
|
||||
{ name: "RELEASE_TARGET", value: "${{ vars.RELEASE_TARGET }}" },
|
||||
],
|
||||
vulnerability_provider: [
|
||||
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
||||
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
||||
|
||||
@@ -670,6 +670,27 @@ export const labPerformanceArtifactSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* FE-GATE-027. The record of which environment an artifact was admitted to, and
|
||||
* every reason it was refused. Refusals are kept in the artifact so a rejected
|
||||
* promotion leaves evidence rather than only a non-zero exit code.
|
||||
*/
|
||||
export const deploymentAdmissionArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
target: z.enum(["local", "development", "staging", "production"]),
|
||||
appEnv: z.enum(["local", "development", "staging", "production"]),
|
||||
authMode: z.enum(["external", "demo"]),
|
||||
apiBaseUrl: nonEmptyString,
|
||||
buildId: nonEmptyString.nullable(),
|
||||
releaseId: nonEmptyString.nullable(),
|
||||
status: z.enum(["ADMITTED", "REFUSED"]),
|
||||
violations: z.array(
|
||||
z.object({ field: nonEmptyString, reason: nonEmptyString }).strict(),
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const releaseVerificationArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
@@ -1321,9 +1342,28 @@ export const documentationReviewArtifactSchema = z
|
||||
reviewer: z.literal("wiki-diagram-reviewer"),
|
||||
standard: z.literal("rules/diagram-standards.md v2"),
|
||||
evidenceReport: z
|
||||
.object({ repoPath: nonEmptyString, canonicalPath: nonEmptyString, canonicalSha256: sha256 })
|
||||
.object({
|
||||
repoPath: nonEmptyString,
|
||||
upstreamCanonicalPath: nonEmptyString,
|
||||
canonicalSha256: sha256,
|
||||
})
|
||||
.strict(),
|
||||
reportDigestValid: z.boolean(),
|
||||
/**
|
||||
* The declared review scope, derived from the installed route registry
|
||||
* rather than read off a sentence. Both scope documents claimed six routes
|
||||
* while ten were registered.
|
||||
*/
|
||||
routeScope: z.array(
|
||||
z
|
||||
.object({
|
||||
path: nonEmptyString,
|
||||
missingRouteIds: z.array(nonEmptyString),
|
||||
documented: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
).min(1),
|
||||
routeScopeDocumented: z.boolean(),
|
||||
results: z.array(
|
||||
z
|
||||
.object({
|
||||
@@ -1350,8 +1390,21 @@ export const documentationReviewArtifactSchema = z
|
||||
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" });
|
||||
}
|
||||
});
|
||||
if (artifact.passed !== (artifact.reportDigestValid && artifact.results.every(({ passed }) => passed))) {
|
||||
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest and review results" });
|
||||
artifact.routeScope.forEach((entry, index) => {
|
||||
if (entry.documented !== (entry.missingRouteIds.length === 0)) {
|
||||
context.addIssue({ code: "custom", path: ["routeScope", index, "documented"], message: "must agree with the missing route list" });
|
||||
}
|
||||
});
|
||||
if (artifact.routeScopeDocumented !== artifact.routeScope.every(({ documented }) => documented)) {
|
||||
context.addIssue({ code: "custom", path: ["routeScopeDocumented"], message: "must agree with every scope document" });
|
||||
}
|
||||
if (
|
||||
artifact.passed !==
|
||||
(artifact.reportDigestValid &&
|
||||
artifact.routeScopeDocumented &&
|
||||
artifact.results.every(({ passed }) => passed))
|
||||
) {
|
||||
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest, documented scope and review results" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url";
|
||||
|
||||
import { shouldRetry } from "../src/adapters/http/retry-policy.ts";
|
||||
import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.ts";
|
||||
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.ts";
|
||||
import { verifyCompatibilityTuple } from "../src/contracts/compatibility.ts";
|
||||
import type { StoragePort } from "../src/application/ports/storage-port.ts";
|
||||
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts";
|
||||
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts";
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import {
|
||||
DEPLOYMENT_TARGETS,
|
||||
isDeploymentTarget,
|
||||
type DeploymentTarget,
|
||||
} from "../src/contracts/deployment-admission.ts";
|
||||
import { runtimeConfigV2ArtifactSchema } from "../src/contracts/release-artifacts.ts";
|
||||
|
||||
/**
|
||||
* §6.4. Materializes `dist/config.json` from the profile the build declares.
|
||||
*
|
||||
* `public/` is copied verbatim into `dist/`, so before this step the runtime
|
||||
* document that shipped with every build was the local one — `APP_ENV: local`,
|
||||
* `AUTH_MODE: demo`, a loopback API — regardless of what the build was for.
|
||||
* The profile is the source of truth instead, and the only values a deployment
|
||||
* may inject are the ones it actually owns: its endpoints and its identity.
|
||||
*
|
||||
* The result is validated against the same V2 schema the browser will apply, so
|
||||
* an override cannot produce a document that only fails at boot.
|
||||
*/
|
||||
|
||||
const PROFILE_DIRECTORY = "config/runtime";
|
||||
const OUTPUT_PATH = "dist/config.json";
|
||||
|
||||
/**
|
||||
* Deployment-supplied values. Everything else is fixed by the profile so a
|
||||
* deployment cannot quietly widen what was reviewed.
|
||||
*/
|
||||
const OVERRIDES = Object.freeze({
|
||||
API_BASE_URL: "RUNTIME_API_BASE_URL",
|
||||
TELEMETRY_ENDPOINT: "RUNTIME_TELEMETRY_ENDPOINT",
|
||||
} as const);
|
||||
|
||||
export async function generateRuntimeConfig(
|
||||
target: DeploymentTarget,
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const profilePath = path.join(PROFILE_DIRECTORY, `${target}.json`);
|
||||
const source: unknown = JSON.parse(await readFile(profilePath, "utf8"));
|
||||
if (source === null || typeof source !== "object" || Array.isArray(source)) {
|
||||
throw new TypeError(`${profilePath}: runtime profile must be an object`);
|
||||
}
|
||||
const draft: Record<string, unknown> = { ...(source as Record<string, unknown>) };
|
||||
if (draft["APP_ENV"] !== target) {
|
||||
throw new Error(
|
||||
`${profilePath}: declares APP_ENV ${String(draft["APP_ENV"])}, expected ${target}`,
|
||||
);
|
||||
}
|
||||
for (const [field, variable] of Object.entries(OVERRIDES)) {
|
||||
const supplied = environment[variable];
|
||||
if (supplied !== undefined && supplied !== "") draft[field] = supplied;
|
||||
}
|
||||
const buildId = environment["VITE_BUILD_ID"] ?? "local-build";
|
||||
const releaseId = environment["RELEASE_ID"] ?? "local-release";
|
||||
draft["BUILD_ID"] = buildId;
|
||||
draft["RELEASE_ID"] = releaseId;
|
||||
|
||||
const parsed = runtimeConfigV2ArtifactSchema.safeParse(draft);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
||||
.join("\n ");
|
||||
throw new Error(`${profilePath}: runtime config is invalid\n ${issues}`);
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function resolveTarget(environment: NodeJS.ProcessEnv): DeploymentTarget {
|
||||
const declared = environment["APP_PROFILE"] ?? "local";
|
||||
if (!isDeploymentTarget(declared)) {
|
||||
throw new Error(
|
||||
`APP_PROFILE must be one of ${DEPLOYMENT_TARGETS.join(", ")}; received ${declared}`,
|
||||
);
|
||||
}
|
||||
return declared;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const target = resolveTarget(process.env);
|
||||
const config = await generateRuntimeConfig(target);
|
||||
await writeFile(OUTPUT_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
process.stdout.write(
|
||||
`runtime config: ${target} profile written to ${OUTPUT_PATH} ` +
|
||||
`(APP_ENV=${String(config["APP_ENV"])}, AUTH_MODE=${String(config["AUTH_MODE"])})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]))) {
|
||||
await main();
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
registryGovernanceRunArtifactSchema,
|
||||
registryCompatibilityFixturesArtifactSchema,
|
||||
registrySnapshotArtifactSchema,
|
||||
deploymentAdmissionArtifactSchema,
|
||||
releaseVerificationArtifactSchema,
|
||||
reproducibleBuildArtifactSchema,
|
||||
runbookRecordArtifactSchema,
|
||||
@@ -238,6 +239,7 @@ const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> =
|
||||
"provider-provenance": provenanceProviderAttestationSchema,
|
||||
"provider-verification": providerVerificationArtifactSchema,
|
||||
"ci-contract-report": ciContractReportSchema,
|
||||
"deployment-admission": deploymentAdmissionArtifactSchema,
|
||||
});
|
||||
|
||||
export function hasCiArtifactSemanticValidator(
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { FileHandle } from "node:fs/promises";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
@@ -28,6 +27,10 @@ import {
|
||||
assertSafePublishLeaf,
|
||||
ensureSafePublishDirectory,
|
||||
} from "./ci-gate-log.ts";
|
||||
import {
|
||||
makePrivateTemporaryDirectory,
|
||||
withPrivateUmask,
|
||||
} from "./private-filesystem.ts";
|
||||
|
||||
const MAX_ARCHIVE_BYTES = 268_435_456;
|
||||
const MAX_CANDIDATE_FILES = 4_096;
|
||||
@@ -147,11 +150,11 @@ export async function verifyCiCandidateArchive(
|
||||
path.dirname(extractionTarget),
|
||||
);
|
||||
await assertSafePublishLeaf(extractionTarget, input.extractTo);
|
||||
extractionRoot = await mkdtemp(
|
||||
extractionRoot = makePrivateTemporaryDirectory(
|
||||
path.join(path.dirname(extractionTarget), `.${path.basename(extractionTarget)}.verified-`),
|
||||
);
|
||||
} else {
|
||||
extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-candidate-archive-"));
|
||||
extractionRoot = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-candidate-archive-"));
|
||||
}
|
||||
let published = false;
|
||||
try {
|
||||
@@ -221,7 +224,7 @@ export async function verifyCapturedCiCandidateArchive(
|
||||
throw new Error("candidate archive SHA-256 mismatch");
|
||||
}
|
||||
const captured = await materializeCapturedArchive(archive);
|
||||
const extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-captured-candidate-"));
|
||||
const extractionRoot = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-captured-candidate-"));
|
||||
try {
|
||||
const manifest = preflightArchiveHandle(captured.handle);
|
||||
extractArchiveHandle(captured.handle, extractionRoot);
|
||||
@@ -318,25 +321,33 @@ function preflightArchiveHandle(archiveHandle: FileHandle): ReleaseCandidateMani
|
||||
}
|
||||
|
||||
function extractArchiveHandle(archiveHandle: FileHandle, extractionRoot: string): void {
|
||||
const extracted = spawnSync(
|
||||
TAR_EXECUTABLE,
|
||||
[
|
||||
"--extract",
|
||||
"--gzip",
|
||||
"--file",
|
||||
"/proc/self/fd/3",
|
||||
"--directory",
|
||||
extractionRoot,
|
||||
"--no-same-owner",
|
||||
"--no-same-permissions",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1_048_576,
|
||||
timeout: 30_000,
|
||||
env: TAR_ENVIRONMENT,
|
||||
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
|
||||
},
|
||||
// `--no-same-permissions` is what keeps an untrusted archive from choosing
|
||||
// its own modes, but it hands the decision to the inherited umask instead.
|
||||
// Under a hardened `umask 077x` tar then creates directories it cannot
|
||||
// descend into and extraction fails part-way. Pinning the umask for the
|
||||
// duration makes the extracted tree exactly private, whatever the caller's
|
||||
// ambient state is. `spawnSync` keeps this window free of interleaved work.
|
||||
const extracted = withPrivateUmask(() =>
|
||||
spawnSync(
|
||||
TAR_EXECUTABLE,
|
||||
[
|
||||
"--extract",
|
||||
"--gzip",
|
||||
"--file",
|
||||
"/proc/self/fd/3",
|
||||
"--directory",
|
||||
extractionRoot,
|
||||
"--no-same-owner",
|
||||
"--no-same-permissions",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1_048_576,
|
||||
timeout: 30_000,
|
||||
env: TAR_ENVIRONMENT,
|
||||
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
|
||||
},
|
||||
),
|
||||
);
|
||||
if (extracted.status !== 0 || extracted.signal || extracted.error) {
|
||||
throw new Error(
|
||||
@@ -592,7 +603,7 @@ function readManifestFromArchive(archiveHandle: FileHandle): ReleaseCandidateMan
|
||||
async function materializeCapturedArchive(
|
||||
archive: Buffer,
|
||||
): Promise<Readonly<{ root: string; handle: FileHandle }>> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ci-captured-archive-"));
|
||||
const root = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-captured-archive-"));
|
||||
const file = path.join(root, "candidate.tar.gz");
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
@@ -601,6 +612,11 @@ async function materializeCapturedArchive(
|
||||
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
// `open` subtracts the umask too. The extractor re-opens this file by
|
||||
// `/proc/self/fd/N` from a child process, and that re-open is a real
|
||||
// permission check, so a umask-zeroed mode makes `tar` fail to read the
|
||||
// candidate it was just handed.
|
||||
await handle.chmod(0o600);
|
||||
await handle.writeFile(archive);
|
||||
await handle.sync();
|
||||
await unlink(file);
|
||||
|
||||
@@ -4,7 +4,7 @@ export const ciContractReportSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(2),
|
||||
nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u),
|
||||
gateCount: z.literal(26),
|
||||
gateCount: z.literal(27),
|
||||
commandDefinitionCount: z.number().int().positive(),
|
||||
commandReferenceCount: z.number().int().positive(),
|
||||
artifactCount: z.number().int().positive(),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { mkdirSync, mkdtempSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Creation modes that must not depend on the caller's ambient umask.
|
||||
*
|
||||
* `mkdir(path, { mode: 0o700 })` and `open(path, ..., 0o600)` are requests, not
|
||||
* guarantees: the kernel subtracts the process umask from every one of them. A
|
||||
* runner hardened with `umask 0777` therefore produces directories nobody can
|
||||
* enter and files nobody can read, and the failure surfaces far from its cause
|
||||
* — as `tar` failing to mkdir a nested path, or as EACCES opening a staging
|
||||
* leaf this process created moments earlier.
|
||||
*
|
||||
* Release evidence has to be exactly private, so the mode is pinned rather than
|
||||
* inherited. The pin is held across a synchronous call only: nothing else in
|
||||
* this process can interleave, so the global umask is never observably changed.
|
||||
*/
|
||||
const PRIVATE_UMASK = 0o077;
|
||||
|
||||
export function withPrivateUmask<T>(operation: () => T): T {
|
||||
const previous = process.umask(PRIVATE_UMASK);
|
||||
try {
|
||||
return operation();
|
||||
} finally {
|
||||
process.umask(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a uniquely named private directory under `prefix`. */
|
||||
export function makePrivateTemporaryDirectory(prefix: string): string {
|
||||
return withPrivateUmask(() => mkdtempSync(prefix));
|
||||
}
|
||||
|
||||
/** Creates `target` privately, failing if it already exists. */
|
||||
export function makePrivateDirectory(target: string): void {
|
||||
withPrivateUmask(() => mkdirSync(target, { mode: 0o700 }));
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rm,
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
} from "./ci-candidate-archive.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
import { makePrivateDirectory } from "./private-filesystem.ts";
|
||||
|
||||
|
||||
export type StagedFile = Readonly<{
|
||||
@@ -334,6 +334,18 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
await dependencies.beforeRemove?.();
|
||||
const visibleParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
|
||||
// Re-bind the name to the inode before removing anything.
|
||||
//
|
||||
// The removals below run through the pinned staging descriptor, so they
|
||||
// always reach the owned inode even after the name has been re-pointed
|
||||
// somewhere else. That is safe for the substitute, but it destroys this
|
||||
// promotion's exact five first and only reports the substitution
|
||||
// afterwards — a caller that retries then finds a half-emptied staging
|
||||
// directory and no way to tell a completed cleanup from an interrupted
|
||||
// one. Detecting the swap here makes the failure total: nothing is
|
||||
// removed unless the leaf still is what was validated.
|
||||
assertStagingIdentity(await lstat(descriptorExpected), input.stagingIdentity);
|
||||
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
|
||||
for (const name of PROMOTED_FILE_NAMES) {
|
||||
await rm(path.join(stagingDescriptorRoot, name), { force: false });
|
||||
}
|
||||
@@ -456,7 +468,7 @@ export async function publishPrivatePromotionStaging(
|
||||
try {
|
||||
const procMetadata = await stat(descriptorRoot);
|
||||
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
|
||||
await mkdir(descriptorStaging, { mode: 0o700 });
|
||||
makePrivateDirectory(descriptorStaging);
|
||||
ownsStaging = true;
|
||||
const createdStaging = await lstat(descriptorStaging);
|
||||
if (!createdStaging.isDirectory() || createdStaging.isSymbolicLink()) {
|
||||
|
||||
@@ -112,6 +112,21 @@ export function systemdRunProviderArguments(
|
||||
|
||||
export type ProviderScopeFrame = Readonly<{
|
||||
bwrapInput: Buffer;
|
||||
/**
|
||||
* The sandboxed command, kept out of the args file on purpose.
|
||||
*
|
||||
* `bwrap --args FD` splices the file's options into the option stream, but
|
||||
* bubblewrap stops at the first non-option and never propagates the command
|
||||
* back out of the recursive parse. A command written into the args file is
|
||||
* therefore silently dropped and bubblewrap exits with its usage text, so
|
||||
* the sandbox is never entered and the provider produces no evidence at all.
|
||||
* Only the options may be hidden; the command travels on real argv.
|
||||
*
|
||||
* Nothing secret lives here: credentials and the provider command reach the
|
||||
* sandbox through `--setenv` inside the args file, and this vector only ever
|
||||
* names `prlimit` and a shell that expands `$PROVIDER_COMMAND`.
|
||||
*/
|
||||
bwrapCommand: readonly string[];
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
@@ -126,8 +141,10 @@ export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer {
|
||||
) {
|
||||
throw new TypeError("provider scope frame is invalid");
|
||||
}
|
||||
assertBwrapCommand(input.bwrapCommand);
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
bwrapInputBase64: input.bwrapInput.toString("base64"),
|
||||
bwrapCommand: [...input.bwrapCommand],
|
||||
reportPath: input.reportPath,
|
||||
reportDev: input.reportDev,
|
||||
reportIno: input.reportIno,
|
||||
@@ -138,13 +155,36 @@ export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer {
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command vector bubblewrap will exec. It has to be an absolute executable
|
||||
* so the sandbox never resolves it through a `PATH` the caller controls.
|
||||
*/
|
||||
export function assertBwrapCommand(command: readonly string[]): void {
|
||||
if (
|
||||
!Array.isArray(command) || command.length === 0 ||
|
||||
typeof command[0] !== "string" || !command[0].startsWith("/") ||
|
||||
command.some((argument) =>
|
||||
typeof argument !== "string" || argument.includes("\0"),
|
||||
)
|
||||
) {
|
||||
throw new TypeError("provider bwrap command is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeProviderBwrapInput(
|
||||
arguments_: readonly string[],
|
||||
optionArguments: readonly string[],
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Buffer {
|
||||
if (arguments_.some((argument) => argument.includes("\0"))) {
|
||||
if (optionArguments.some((argument) => argument.includes("\0"))) {
|
||||
throw new TypeError("provider bwrap argument is invalid");
|
||||
}
|
||||
// A bare `--` ends bubblewrap's option stream. Inside an args file that also
|
||||
// ends the recursive parse, so everything after it is discarded rather than
|
||||
// executed. Refusing it here keeps the drop from being reintroduced by a
|
||||
// caller that appends a command to the option list.
|
||||
if (optionArguments.includes("--")) {
|
||||
throw new TypeError("provider bwrap options may not terminate the option stream");
|
||||
}
|
||||
const entries = Object.entries(environment).sort(([left], [right]) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
@@ -155,7 +195,7 @@ export function encodeProviderBwrapInput(
|
||||
}
|
||||
const input = ["--clearenv"];
|
||||
for (const [name, value] of entries) input.push("--setenv", name, value ?? "");
|
||||
input.push(...arguments_);
|
||||
input.push(...optionArguments);
|
||||
return Buffer.from(`${input.join("\0")}\0`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { closeSync, createReadStream, writeSync } from "node:fs";
|
||||
import { closeSync, writeSync } from "node:fs";
|
||||
import { Socket } from "node:net";
|
||||
|
||||
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
|
||||
|
||||
@@ -10,7 +11,18 @@ let expectedBytes: number | undefined;
|
||||
let provider: ReturnType<typeof spawn> | undefined;
|
||||
let providerClosed = false;
|
||||
let livenessLost = false;
|
||||
const liveness = createReadStream("", { fd: 0, autoClose: false });
|
||||
/**
|
||||
* The supervisor keeps this pipe open for the scope's whole life — that is how
|
||||
* parent loss is observed — and only ever writes one frame into it.
|
||||
*
|
||||
* It must be read through libuv's event loop, not through `fs`. An `fs` read
|
||||
* runs a blocking `read(2)` on a threadpool thread, and on a pipe with a live
|
||||
* writer that call never returns. Closing the descriptor does not interrupt it,
|
||||
* so once bubblewrap exits the wrapper deadlocks in `process.exit` waiting to
|
||||
* join that thread: the scope outlives the provider, the supervisor's wall
|
||||
* clock expires, and a completed provider is reported as a timeout kill.
|
||||
*/
|
||||
const liveness = openLivenessChannel();
|
||||
|
||||
liveness.on("data", (chunk: Buffer | string) => {
|
||||
if (provider) {
|
||||
@@ -46,10 +58,17 @@ function launchProvider(payload: Buffer): void {
|
||||
throw new TypeError("provider scope frame identity does not match its launch identity");
|
||||
}
|
||||
const bwrapInput = Buffer.from(frame.bwrapInputBase64, "base64");
|
||||
provider = spawn("/usr/bin/bwrap", ["--args", "0"], {
|
||||
// The options are read from fd 0; the command must stay on real argv because
|
||||
// bubblewrap discards whatever follows the option stream inside an args file.
|
||||
provider = spawn("/usr/bin/bwrap", ["--args", "0", "--", ...frame.bwrapCommand], {
|
||||
detached: true,
|
||||
stdio: ["pipe", "inherit", "inherit"],
|
||||
});
|
||||
// bubblewrap can exit before the options are fully written — a usage error
|
||||
// closes fd 0 immediately. Without this the EPIPE would surface as an
|
||||
// unhandled stream error and the scope would be torn down as a crash rather
|
||||
// than reported as the provider exit it is.
|
||||
provider.stdin?.once("error", () => {});
|
||||
provider.stdin?.end(bwrapInput);
|
||||
provider.once("error", (error) => finishProvider(frame, null, null, error));
|
||||
provider.once("close", (code, signal) => finishProvider(frame, code, signal));
|
||||
@@ -102,18 +121,34 @@ function terminateForProtocolFailure(message: string): void {
|
||||
terminateForParentLoss();
|
||||
}
|
||||
|
||||
function openLivenessChannel(): Socket {
|
||||
try {
|
||||
return new Socket({ fd: 0, readable: true, writable: false });
|
||||
} catch (error) {
|
||||
// Without an observable parent this process cannot be trusted to notice
|
||||
// supervisor loss, and an unsupervised sandbox is worse than no run.
|
||||
writeSync(2, `provider scope liveness channel is unavailable: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\n`);
|
||||
process.exit(125);
|
||||
}
|
||||
}
|
||||
|
||||
function closeLivenessInput(): void {
|
||||
liveness.removeAllListeners();
|
||||
liveness.destroy();
|
||||
try {
|
||||
closeSync(0);
|
||||
} catch (error) {
|
||||
// `Socket.destroy()` owns the descriptor and closes it itself, so a second
|
||||
// close is expected rather than exceptional.
|
||||
if (!hasErrorCode(error, "EBADF")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrame(payload: Buffer): Readonly<{
|
||||
bwrapInputBase64: string;
|
||||
bwrapCommand: readonly string[];
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
@@ -127,14 +162,26 @@ function parseFrame(payload: Buffer): Readonly<{
|
||||
) {
|
||||
throw new TypeError("provider scope frame payload is invalid");
|
||||
}
|
||||
assertBwrapCommand(value.bwrapCommand);
|
||||
return {
|
||||
bwrapInputBase64: value.bwrapInputBase64,
|
||||
bwrapCommand: Object.freeze([...value.bwrapCommand]),
|
||||
reportPath: value.reportPath,
|
||||
reportDev: Number(value.reportDev),
|
||||
reportIno: Number(value.reportIno),
|
||||
};
|
||||
}
|
||||
|
||||
function assertBwrapCommand(value: unknown): asserts value is readonly string[] {
|
||||
if (
|
||||
!Array.isArray(value) || value.length === 0 ||
|
||||
typeof value[0] !== "string" || !value[0].startsWith("/") ||
|
||||
value.some((argument) => typeof argument !== "string" || argument.includes("\0"))
|
||||
) {
|
||||
throw new TypeError("provider scope frame command is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function parseReportIdentity(arguments_: readonly string[]): Readonly<{
|
||||
cpuSeconds: number;
|
||||
reportPath: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isVersionCompatible } from "../../src/application/policies/compatibility.ts";
|
||||
import { isVersionCompatible } from "../../src/contracts/compatibility.ts";
|
||||
import { verifyContractSet } from "../../src/contracts/contract-set.ts";
|
||||
import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts";
|
||||
import type { ReleaseArtifact } from "../../src/contracts/release-artifacts.ts";
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -24,9 +25,75 @@ export const REMOVAL_FIXTURE_COPY_TARGETS = Object.freeze([
|
||||
"vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts",
|
||||
"playwright.config.ts", "playwright.capabilities.config.ts", "playwright.dev.config.ts",
|
||||
"playwright.storybook.config.ts", "playwright.visual.config.ts", "eslint.config.ts",
|
||||
".dependency-cruiser.json", ".nvmrc",
|
||||
".dependency-cruiser.json", ".nvmrc", ".gitignore",
|
||||
// Install and workspace identity. Without these the fixture is not the same
|
||||
// project: `corepack pnpm` resolves a different store, and the provider
|
||||
// suites — which build a release candidate containing `pnpm-lock.yaml` —
|
||||
// cannot assemble their fixture at all.
|
||||
".npmrc", "pnpm-lock.yaml", "pnpm-workspace.yaml",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* This is the only copy-target list. Each removal script used to keep its own,
|
||||
* and they drifted: the reference-feature fixture omitted
|
||||
* `playwright.capabilities.config.ts`, which the repository file inventory
|
||||
* requires, so supply-chain generation failed inside the fixture and took every
|
||||
* provider suite down with it — twenty-odd failures with one cause.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regenerated result trees under `artifacts/`: traces, coverage HTML, recorded
|
||||
* videos and Storybook bundles. They are tens of megabytes and mean nothing to
|
||||
* a fixture. Everything else under `artifacts/` is release evidence a candidate
|
||||
* is assembled from — and most of it is git-ignored too, so "is it tracked?"
|
||||
* cannot be used to tell the two apart. `keepsReleaseEvidence` in
|
||||
* tests/unit/removal-fixture.test.ts pins both halves of this split.
|
||||
*/
|
||||
const REGENERATED_ARTIFACT_TREES: readonly string[] = Object.freeze([
|
||||
"artifacts/storybook",
|
||||
"artifacts/tests/browser-capabilities",
|
||||
"artifacts/tests/coverage",
|
||||
"artifacts/tests/e2e",
|
||||
"artifacts/tests/storybook",
|
||||
"artifacts/tests/visual",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Copies the release evidence a candidate build needs into a fixture root.
|
||||
*
|
||||
* A fixture that omits it cannot assemble a candidate archive at all, so every
|
||||
* provider suite fails while constructing its own fixture — long before it
|
||||
* reaches an assertion, and with an error that says nothing about the
|
||||
* capability under test.
|
||||
*/
|
||||
export async function copyReleaseEvidenceTree(
|
||||
sourceRoot: string,
|
||||
destinationRoot: string,
|
||||
): Promise<void> {
|
||||
const source = path.join(sourceRoot, "artifacts");
|
||||
try {
|
||||
await stat(source);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await cp(source, path.join(destinationRoot, "artifacts"), {
|
||||
recursive: true,
|
||||
filter: (candidate) => {
|
||||
const relative = path.relative(sourceRoot, candidate).split(path.sep).join("/");
|
||||
return !REGENERATED_ARTIFACT_TREES.some(
|
||||
(tree) => relative === tree || relative.startsWith(`${tree}/`),
|
||||
);
|
||||
},
|
||||
});
|
||||
// The result directories still have to exist: several are tracked through a
|
||||
// `.gitkeep` the repository inventory expects to find.
|
||||
for (const tree of REGENERATED_ARTIFACT_TREES) {
|
||||
await mkdir(path.join(destinationRoot, tree), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export const RELEASE_EVIDENCE_REGENERATED_TREES = REGENERATED_ARTIFACT_TREES;
|
||||
|
||||
export function requireRemovalFixtureEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required for removal verification`);
|
||||
@@ -42,9 +109,56 @@ export async function prepareRemovalFixture(
|
||||
for (const target of copyTargets) {
|
||||
await cp(target, path.join(root, target), { recursive: true });
|
||||
}
|
||||
await copyReleaseEvidenceTree(process.cwd(), root);
|
||||
runFixtureGit(root, ["init", "--quiet", "--initial-branch=fixture"]);
|
||||
await linkFixtureNodeModules(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the fixture's post-removal contents as its repository state.
|
||||
*
|
||||
* The release candidate path asks `git ls-files` what the repository contains —
|
||||
* the supply-chain inventory is defined as the tracked file set, not as
|
||||
* whatever happens to be on disk. A fixture without a repository cannot answer
|
||||
* that, so supply-chain generation failed and took every provider suite down
|
||||
* with it; the claim "this build still produces a release candidate after the
|
||||
* capability is removed" was never actually being tested.
|
||||
*
|
||||
* It runs after the removal, not during preparation: an index recorded before
|
||||
* the deletions still lists the removed files, and the inventory then demands
|
||||
* files the fixture exists to prove are gone.
|
||||
*/
|
||||
export function sealRemovalFixtureRepository(root: string): void {
|
||||
// `.gitignore` travels with the fixture, so the tracked set it records is the
|
||||
// same tracked set the real repository has. Without it every generated
|
||||
// artifact and every linked module landed in the index, and the supply-chain
|
||||
// inventory refused the fixture for having tracked and generated paths
|
||||
// collide — the fixture disagreed with the repository it was copied from.
|
||||
runFixtureGit(root, ["add", "--all"]);
|
||||
runFixtureGit(root, ["commit", "--quiet", "--no-gpg-sign", "-m", "removal fixture"]);
|
||||
}
|
||||
|
||||
function runFixtureGit(root: string, argv: readonly string[]): void {
|
||||
const result = spawnSync("git", [...argv], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "removal-fixture",
|
||||
GIT_AUTHOR_EMAIL: "removal-fixture@localhost",
|
||||
GIT_COMMITTER_NAME: "removal-fixture",
|
||||
GIT_COMMITTER_EMAIL: "removal-fixture@localhost",
|
||||
},
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(
|
||||
`removal fixture repository setup failed at git ${argv[0]}: ${
|
||||
result.stderr || result.error?.message || `exit ${result.status}`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function runRemovalFixturePnpm(
|
||||
root: string,
|
||||
pnpmCli: string,
|
||||
@@ -162,6 +276,45 @@ export function pruneScriptOrchestration(
|
||||
export async function regenerateRemovalFixtureWorkflow(root: string): Promise<void> {
|
||||
const contract = await loadCiGateContract(root, { mode: "removal-fixture" });
|
||||
await generateCiWorkflow({ root, contract, check: false });
|
||||
// Every removal script calls this once, after it has finished mutating the
|
||||
// tree, so it is the one place where the fixture's contents are final.
|
||||
await pruneRemovalFixtureInventoryRoots(root);
|
||||
sealRemovalFixtureRepository(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops repository roots the removal deleted from the supply-chain inventory
|
||||
* policy.
|
||||
*
|
||||
* The policy lists `recipes` as a required tracked root, and removing an
|
||||
* optional recipe deletes exactly that directory. Supply-chain generation then
|
||||
* refused the fixture for missing a root the removal was supposed to remove, so
|
||||
* the capability could never be shown to be removable. A root that is not on
|
||||
* disk after the removal is not required of the result.
|
||||
*/
|
||||
async function pruneRemovalFixtureInventoryRoots(root: string): Promise<void> {
|
||||
const policyPath = path.join(root, "config/security/secret-scan-policy.json");
|
||||
let policy: Record<string, unknown>;
|
||||
try {
|
||||
policy = JSON.parse(await readFile(policyPath, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const tracked = policy["trackedRoots"];
|
||||
if (!Array.isArray(tracked)) return;
|
||||
const surviving: string[] = [];
|
||||
for (const entry of tracked) {
|
||||
if (typeof entry !== "string") continue;
|
||||
try {
|
||||
await stat(path.join(root, entry));
|
||||
surviving.push(entry);
|
||||
} catch {
|
||||
// Deleted by the removal under test.
|
||||
}
|
||||
}
|
||||
if (surviving.length === tracked.length) return;
|
||||
policy["trackedRoots"] = surviving;
|
||||
await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export async function pruneRemovalFixtureCiContract(options: Readonly<{
|
||||
|
||||
@@ -281,14 +281,25 @@ async function runProviderInSandbox(
|
||||
"--remount-ro", "/",
|
||||
"--bind", reportAbsolute, reportAbsolute,
|
||||
"--chdir", workspaceRoot,
|
||||
"--", "/usr/bin/prlimit",
|
||||
);
|
||||
/**
|
||||
* Everything above is a bubblewrap *option* and travels in the args file, so
|
||||
* host paths never reach `/proc/<pid>/cmdline`. The command below cannot: an
|
||||
* args file's option stream ends at the first non-option and bubblewrap drops
|
||||
* the remainder, so a command written there is never executed. It stays on
|
||||
* real argv, and it is safe there because the provider command and its
|
||||
* credentials are passed as `--setenv PROVIDER_COMMAND` inside the args file
|
||||
* and only expanded by the innermost shell.
|
||||
*/
|
||||
const bwrapCommand = [
|
||||
"/usr/bin/prlimit",
|
||||
"--core=0:0",
|
||||
"--fsize=8388607:8388607",
|
||||
"--nofile=64:64",
|
||||
`--cpu=${cpuSeconds}:${cpuSeconds}`,
|
||||
"--", "/bin/sh", "-eu", "-c",
|
||||
'exec /bin/sh -eu -c "$PROVIDER_COMMAND"',
|
||||
);
|
||||
];
|
||||
const unitName = formatProviderCgroupUnitName(
|
||||
providerKind,
|
||||
process.pid,
|
||||
@@ -301,6 +312,7 @@ async function runProviderInSandbox(
|
||||
});
|
||||
const scopeFrame = encodeProviderScopeFrame({
|
||||
bwrapInput,
|
||||
bwrapCommand,
|
||||
reportPath: reportAbsolute,
|
||||
reportDev: reportIdentity.dev,
|
||||
reportIno: reportIdentity.ino,
|
||||
@@ -361,7 +373,25 @@ async function waitForProvider(
|
||||
PROVIDER_MAX_OUTPUT_BYTES,
|
||||
() => terminate("output"),
|
||||
);
|
||||
/**
|
||||
* Lines the sandbox tooling itself emits, kept so a launch failure can say
|
||||
* why. Everything else the child writes is provider output and may carry
|
||||
* credentials, so it is counted and discarded as before.
|
||||
*
|
||||
* Without this a sandbox that never started reported only `exit=1`, and the
|
||||
* actual cause — `bwrap: loopback: Failed RTM_NEWADDR: Operation not
|
||||
* permitted` on a host with `kernel.apparmor_restrict_unprivileged_userns=1`
|
||||
* — was invisible. That turned a host restriction into an unexplained
|
||||
* product failure.
|
||||
*/
|
||||
const SANDBOX_DIAGNOSTIC = /^(?:bwrap|prlimit|systemd-run|systemctl):\s.*$/gmu;
|
||||
const sandboxDiagnostics: string[] = [];
|
||||
const capture = (chunk: Buffer | string): void => {
|
||||
for (const line of String(chunk).matchAll(SANDBOX_DIAGNOSTIC)) {
|
||||
if (sandboxDiagnostics.length < 8 && !sandboxDiagnostics.includes(line[0])) {
|
||||
sandboxDiagnostics.push(line[0]);
|
||||
}
|
||||
}
|
||||
if (termination) return;
|
||||
outputLimiter.consume(chunk);
|
||||
};
|
||||
@@ -415,7 +445,13 @@ async function waitForProvider(
|
||||
await collection;
|
||||
if (result.error) throw result.error;
|
||||
if (result.code !== 0 || result.signal !== null) {
|
||||
throw new Error(`sandboxed external provider failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}`);
|
||||
throw new Error(
|
||||
`sandboxed external provider failed: exit=${result.code ?? "none"}, ` +
|
||||
`signal=${result.signal ?? "none"}` +
|
||||
(sandboxDiagnostics.length > 0
|
||||
? `; sandbox reported: ${sandboxDiagnostics.join("; ")}`
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
if (inputError) throw inputError;
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function assertSupportedNode(): void {
|
||||
const [majorText = "0", minorText = "0"] = process.versions.node.split(".");
|
||||
const major = Number(majorText);
|
||||
const minor = Number(minorText);
|
||||
|
||||
if (major !== 24 || minor < 11) {
|
||||
process.stderr.write(
|
||||
[
|
||||
`Unsupported Node.js runtime for tests: ${process.versions.node}`,
|
||||
"Required by package.json: >=24.11.0 <25.0.0",
|
||||
"Use the repository-supported Node.js runtime before running a test suite.",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
assertSupportedNode();
|
||||
|
||||
const vitestEntry = fileURLToPath(
|
||||
new URL("../node_modules/vitest/vitest.mjs", import.meta.url),
|
||||
);
|
||||
if (!existsSync(vitestEntry)) {
|
||||
process.stderr.write(
|
||||
"Vitest is not installed. Run the repository package installation first.\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const testEnvironment: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
for (const key of [
|
||||
"npm_config_userconfig",
|
||||
"npm_config_prefix",
|
||||
"npm_config_globalconfig",
|
||||
"NPM_CONFIG_USERCONFIG",
|
||||
"NPM_CONFIG_PREFIX",
|
||||
"NPM_CONFIG_GLOBALCONFIG",
|
||||
]) {
|
||||
delete testEnvironment[key];
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[vitestEntry, ...process.argv.slice(2)],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: testEnvironment,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.signal) {
|
||||
process.stderr.write(`Vitest terminated by signal ${result.signal}.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -18,43 +18,12 @@ import {
|
||||
|
||||
const fixtureRoot = path.resolve(".tmp/optional-recipe-removal");
|
||||
const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath");
|
||||
const copyTargets = [
|
||||
"src",
|
||||
"tests",
|
||||
"recipes",
|
||||
"scripts",
|
||||
"schemas",
|
||||
"config",
|
||||
"public",
|
||||
".gitea",
|
||||
".storybook",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"tsconfig.base.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.node.json",
|
||||
"tsconfig.test.json",
|
||||
"tsconfig.recipes.json",
|
||||
"tsconfig.web-worker.json",
|
||||
"tsconfig.service-worker.json",
|
||||
"vite.service-worker.config.ts",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.dev.config.ts",
|
||||
"playwright.storybook.config.ts",
|
||||
"playwright.visual.config.ts",
|
||||
"eslint.config.ts",
|
||||
".dependency-cruiser.json",
|
||||
".nvmrc",
|
||||
];
|
||||
|
||||
function runPnpm(script: string): boolean {
|
||||
return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script);
|
||||
}
|
||||
|
||||
await prepareRemovalFixture(fixtureRoot, copyTargets);
|
||||
await prepareRemovalFixture(fixtureRoot);
|
||||
for (const rootOnlyTest of [
|
||||
"tests/unit/ci-workflow-generation.test.ts",
|
||||
"tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap",
|
||||
|
||||
@@ -90,7 +90,13 @@ try {
|
||||
throw new Error("Performance route must be present in navigation.");
|
||||
}
|
||||
const interactionStarted = performance.now();
|
||||
await page.getByRole("link", { name: targetLabel }).click();
|
||||
// Playwright matches accessible names by substring, so the navigation entry
|
||||
// "플랫폼 구성" also matched the home page's "플랫폼 구성 보기" call to
|
||||
// action and the locator resolved to two links. That is a strict-mode
|
||||
// violation before the first measurement is taken, so no lab performance
|
||||
// evidence could be produced at all — the run failed for an ambiguous
|
||||
// selector rather than for anything about performance.
|
||||
await page.getByRole("link", { name: targetLabel, exact: true }).click();
|
||||
await page.getByRole("heading", { name: target.title }).waitFor();
|
||||
const namedInteractionMs = performance.now() - interactionStarted;
|
||||
const paint = await page.evaluate(
|
||||
|
||||
+116
-44
@@ -27,58 +27,54 @@ const fixtureRoot = await mkdtemp(
|
||||
const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath");
|
||||
const featureSource = "src/features/reference-feature";
|
||||
const featureTests = "tests/features/reference-feature";
|
||||
/**
|
||||
* Platform tests that must survive the sample feature's removal. Asserting they
|
||||
* are still present is what stops the removal fixture from "passing" by having
|
||||
* quietly deleted the platform's own coverage along with the feature.
|
||||
*/
|
||||
const commonTestPaths = [
|
||||
"tests/unit/external-contract-runtime.test.ts",
|
||||
"tests/unit/http-execution-v3.test.ts",
|
||||
"tests/unit/runtime-adapters.test.ts",
|
||||
"tests/integration/http-execution-v3-observability.test.ts",
|
||||
];
|
||||
const featureOwnedPaths = [
|
||||
featureSource,
|
||||
featureTests,
|
||||
"tests/integration/http-scenario-catalog.test.ts",
|
||||
"tests/component/product-feature-switch.test.tsx",
|
||||
"tests/e2e/reference-form.spec.ts",
|
||||
"tests/e2e/reference-route.spec.ts",
|
||||
"tests/mocks",
|
||||
"tests/fixtures/typecheck/invalid-feature-input.ts",
|
||||
"tests/fixtures/typecheck/invalid-reference-operation.ts",
|
||||
"tests/fixtures/typecheck/invalid-http-operation-input.ts",
|
||||
"tests/fixtures/typecheck/invalid-http-wire-mapper.ts",
|
||||
"tests/fixtures/typecheck/invalid-http-operation-id.ts",
|
||||
"tests/fixtures/typecheck/invalid-http-route-id.ts",
|
||||
"tests/fixtures/typecheck/invalid-feature-contribution-input.ts",
|
||||
"tests/fixtures/typecheck/invalid-feature-capability-selection.ts",
|
||||
"tests/fixtures/typecheck/tsconfig.feature-input.json",
|
||||
"tests/fixtures/typecheck/tsconfig.reference-operation.json",
|
||||
"tests/fixtures/typecheck/tsconfig.http-operation-input.json",
|
||||
"tests/fixtures/typecheck/tsconfig.http-wire-mapper.json",
|
||||
"tests/fixtures/typecheck/tsconfig.http-operation-id.json",
|
||||
"tests/fixtures/typecheck/tsconfig.http-route-id.json",
|
||||
"tests/fixtures/typecheck/tsconfig.feature-contribution-input.json",
|
||||
"tests/fixtures/typecheck/tsconfig.feature-capability-selection.json",
|
||||
];
|
||||
const copyTargets = [
|
||||
"src",
|
||||
"tests",
|
||||
"recipes",
|
||||
"scripts",
|
||||
"schemas",
|
||||
"config",
|
||||
"public",
|
||||
".gitea",
|
||||
".storybook",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"tsconfig.base.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.node.json",
|
||||
"tsconfig.test.json",
|
||||
"tsconfig.recipes.json",
|
||||
"tsconfig.web-worker.json",
|
||||
"tsconfig.service-worker.json",
|
||||
"vite.service-worker.config.ts",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.dev.config.ts",
|
||||
"playwright.storybook.config.ts",
|
||||
"playwright.visual.config.ts",
|
||||
"eslint.config.ts",
|
||||
".dependency-cruiser.json",
|
||||
".nvmrc",
|
||||
];
|
||||
|
||||
const emptyProductManifest = `export const COMPILED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze([]);
|
||||
export const INSTALLED_PRODUCT_FEATURES: readonly never[] = Object.freeze([]);
|
||||
export const INSTALLED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze([]);
|
||||
`;
|
||||
|
||||
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
|
||||
import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts";
|
||||
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts";
|
||||
import { INSTALLED_PRODUCT_FEATURES } from "./installed-product-manifest.ts";
|
||||
|
||||
export const INSTALLED_FEATURE_CONTRACTS: readonly unknown[] = Object.freeze([]);
|
||||
export const INSTALLED_FEATURE_CONTRACTS = INSTALLED_PRODUCT_FEATURES;
|
||||
export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
|
||||
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
|
||||
export const API_OPERATIONS = Object.freeze({});
|
||||
@@ -89,6 +85,7 @@ export const INVALIDATION_REGISTRY = Object.freeze({
|
||||
});
|
||||
export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]);
|
||||
export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY;
|
||||
export const ROUTE_FEATURE_OWNER: Readonly<Record<string, string>> = Object.freeze({});
|
||||
export const NAVIGATION_ROUTES = Object.freeze(
|
||||
Object.values(ROUTE_REGISTRY)
|
||||
.filter((definition) => definition.navigationOrder !== null)
|
||||
@@ -110,14 +107,39 @@ export function routePath(routeId: string): string {
|
||||
|
||||
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
|
||||
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
|
||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||
|
||||
const COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS: readonly Readonly<{
|
||||
featureId: string;
|
||||
}>[] = Object.freeze([]);
|
||||
|
||||
const INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS =
|
||||
COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS.filter((contribution) =>
|
||||
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
|
||||
);
|
||||
|
||||
void INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS;
|
||||
|
||||
export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS;
|
||||
export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME;
|
||||
`;
|
||||
|
||||
const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) {
|
||||
void _context;
|
||||
return Object.freeze({});
|
||||
const emptyAdapters = `import {
|
||||
composeFeatureAdapterInputs,
|
||||
type InstalledFeatureInputs,
|
||||
} from "./feature-adapter-contribution.ts";
|
||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||
|
||||
const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([] as const);
|
||||
|
||||
export function createInstalledFeatureInputs(
|
||||
context: Readonly<Record<never, never>>,
|
||||
): InstalledFeatureInputs {
|
||||
return composeFeatureAdapterInputs(
|
||||
COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
context,
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -126,9 +148,17 @@ const emptyContractContributions = `import {
|
||||
type InstalledContractContribution,
|
||||
type InstalledContractPackageIdentity,
|
||||
} from "../contracts/external-contract-runtime.ts";
|
||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||
|
||||
const COMPILED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
||||
Object.freeze([]);
|
||||
|
||||
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
||||
Object.freeze([]);
|
||||
Object.freeze(
|
||||
COMPILED_CONTRACT_CONTRIBUTIONS.filter((contribution) =>
|
||||
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
|
||||
),
|
||||
);
|
||||
|
||||
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
||||
INSTALLED_CONTRACT_CONTRIBUTIONS,
|
||||
@@ -172,7 +202,7 @@ function runPnpm(script: string, extra: string[] = []): boolean {
|
||||
}
|
||||
|
||||
try {
|
||||
await prepareRemovalFixture(fixtureRoot, copyTargets);
|
||||
await prepareRemovalFixture(fixtureRoot);
|
||||
for (const excludedFixtureTest of [
|
||||
"tests/unit/ci-workflow-generation.test.ts",
|
||||
"tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap",
|
||||
@@ -206,6 +236,10 @@ try {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
await writeFile(
|
||||
path.join(fixtureRoot, "src/features/installed-product-manifest.ts"),
|
||||
emptyProductManifest,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"),
|
||||
emptyContracts,
|
||||
@@ -227,6 +261,34 @@ try {
|
||||
emptyContractContributions,
|
||||
);
|
||||
|
||||
// Runtime profiles are product configuration. Once the feature is physically
|
||||
// removed, its override key must disappear too or the built config still
|
||||
// advertises a source capability that no longer exists.
|
||||
for (const profile of [
|
||||
"local",
|
||||
"development",
|
||||
"staging",
|
||||
"production",
|
||||
] as const) {
|
||||
const profilePath = path.join(
|
||||
fixtureRoot,
|
||||
"config/runtime",
|
||||
`${profile}.json`,
|
||||
);
|
||||
const runtimeConfig = JSON.parse(
|
||||
await readFile(profilePath, "utf8"),
|
||||
) as {
|
||||
FEATURE_OVERRIDES?: Record<string, string>;
|
||||
};
|
||||
if (runtimeConfig.FEATURE_OVERRIDES) {
|
||||
delete runtimeConfig.FEATURE_OVERRIDES["reference-feature"];
|
||||
}
|
||||
await writeFile(
|
||||
profilePath,
|
||||
`${JSON.stringify(runtimeConfig, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const retainedCriticalModules = coveragePolicy.criticalModules.filter(
|
||||
(modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`),
|
||||
);
|
||||
@@ -321,6 +383,12 @@ try {
|
||||
const removedCiScripts = new Set([
|
||||
"check:types:fixture:feature-input",
|
||||
"check:types:fixture:reference-operation",
|
||||
"check:types:fixture:http-operation-input",
|
||||
"check:types:fixture:http-wire-mapper",
|
||||
"check:types:fixture:http-operation-id",
|
||||
"check:types:fixture:http-route-id",
|
||||
"check:types:fixture:feature-contribution-input",
|
||||
"check:types:fixture:feature-capability-selection",
|
||||
"test:http-scenario-evidence",
|
||||
"test:reference-feature",
|
||||
]);
|
||||
@@ -395,13 +463,17 @@ try {
|
||||
["build", runPnpm("build")],
|
||||
];
|
||||
const builtResidue: string[] = [];
|
||||
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
|
||||
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
|
||||
const content = await readFile(file, "utf8");
|
||||
if (
|
||||
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
|
||||
) {
|
||||
builtResidue.push(path.relative(fixtureRoot, file));
|
||||
const buildSucceeded =
|
||||
checks.find(([name]) => name === "build")?.[1] === true;
|
||||
if (buildSucceeded) {
|
||||
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
|
||||
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
|
||||
const content = await readFile(file, "utf8");
|
||||
if (
|
||||
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
|
||||
) {
|
||||
builtResidue.push(path.relative(fixtureRoot, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
const routeCatalog = await import(
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { access, mkdir, readFile } from "node:fs/promises";
|
||||
|
||||
import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.ts";
|
||||
import { documentationReviewArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
/**
|
||||
* Documents that state the review scope. The route registry is the source of
|
||||
* truth for what that scope is, so these have to enumerate exactly the
|
||||
* installed routes.
|
||||
*
|
||||
* Both said "six routes" while ten were registered: the four newest — the
|
||||
* platform overview and three reference-resource screens — were outside the
|
||||
* declared manual accessibility scope without anybody deciding they should be.
|
||||
* A hand-typed count drifts silently, so it is derived here instead.
|
||||
*/
|
||||
const ROUTE_SCOPE_DOCUMENTS = Object.freeze([
|
||||
"README.md",
|
||||
"docs/accessibility/manual-checklist.md",
|
||||
]);
|
||||
|
||||
type DocumentationReview = Readonly<{
|
||||
sourcePath: string;
|
||||
sha256: string;
|
||||
@@ -14,6 +30,7 @@ type DocumentationReview = Readonly<{
|
||||
type ReviewLedger = Readonly<{
|
||||
evidenceReport: Readonly<{
|
||||
repoPath: string;
|
||||
upstreamCanonicalPath: string;
|
||||
canonicalSha256: string;
|
||||
}>;
|
||||
reviews: Record<string, DocumentationReview>;
|
||||
@@ -58,8 +75,30 @@ for (const [diagram, review] of Object.entries(ledger.reviews)) {
|
||||
const reportDigestValid =
|
||||
/^[0-9a-f]{64}$/.test(ledger.evidenceReport.canonicalSha256) &&
|
||||
evidence.includes(ledger.evidenceReport.canonicalSha256);
|
||||
|
||||
const installedRouteIds = Object.values(ROUTE_REGISTRY)
|
||||
.map((route) => route.routeId)
|
||||
.sort();
|
||||
const routeScope = [];
|
||||
for (const path of ROUTE_SCOPE_DOCUMENTS) {
|
||||
let text: string;
|
||||
try {
|
||||
await access(path);
|
||||
text = await readFile(path, "utf8");
|
||||
} catch {
|
||||
routeScope.push({ path, missingRouteIds: [...installedRouteIds], documented: false });
|
||||
continue;
|
||||
}
|
||||
const missingRouteIds = installedRouteIds.filter(
|
||||
(routeId) => !text.includes(routeId),
|
||||
);
|
||||
routeScope.push({ path, missingRouteIds, documented: missingRouteIds.length === 0 });
|
||||
}
|
||||
const routeScopeDocumented = routeScope.every((entry) => entry.documented);
|
||||
|
||||
const passed =
|
||||
reportDigestValid &&
|
||||
routeScopeDocumented &&
|
||||
results.length === 2 &&
|
||||
results.every((result) => result.passed);
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
@@ -74,6 +113,8 @@ await writeValidatedJsonArtifact({
|
||||
standard: ledger.standard,
|
||||
evidenceReport: ledger.evidenceReport,
|
||||
reportDigestValid,
|
||||
routeScope,
|
||||
routeScopeDocumented,
|
||||
results,
|
||||
passed,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
verifyCompatibilityTuple,
|
||||
type CompatibilityTuple,
|
||||
} from "../src/application/policies/compatibility.ts";
|
||||
} from "../src/contracts/compatibility.ts";
|
||||
import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts";
|
||||
import {
|
||||
parseBuildManifestArtifact,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
createAnonymousSessionAdapter,
|
||||
createDemoSessionAdapter,
|
||||
createExternalAuthSessionAdapter,
|
||||
createUnavailableSessionAdapter,
|
||||
DEMO_AUTHORIZATION_MARKER,
|
||||
type DemoSessionAdapter,
|
||||
type ExternalSessionOwner,
|
||||
} from "./external-session-adapter.ts";
|
||||
@@ -108,14 +108,12 @@ export class NativeInputFilePicker implements DisposableFilePicker {
|
||||
const addInputEvent = options.input.addEventListener;
|
||||
const removeInputEvent = options.input.removeEventListener;
|
||||
const getInputAttribute = options.input.getAttribute;
|
||||
const showPicker = options.input.showPicker;
|
||||
const click = options.input.click;
|
||||
if (
|
||||
typeof addInputEvent !== "function" ||
|
||||
typeof removeInputEvent !== "function" ||
|
||||
typeof getInputAttribute !== "function" ||
|
||||
(typeof showPicker !== "function" &&
|
||||
typeof click !== "function")
|
||||
typeof click !== "function"
|
||||
) {
|
||||
throw new TypeError("Native file input API is invalid.");
|
||||
}
|
||||
@@ -123,10 +121,11 @@ export class NativeInputFilePicker implements DisposableFilePicker {
|
||||
add: addInputEvent.bind(options.input),
|
||||
remove: removeInputEvent.bind(options.input),
|
||||
getAttribute: getInputAttribute.bind(options.input),
|
||||
activate:
|
||||
typeof showPicker === "function"
|
||||
? showPicker.bind(options.input)
|
||||
: click.bind(options.input),
|
||||
// The baseline <input type="file"> path intentionally uses click().
|
||||
// showPicker() exists in multiple engines but is not a portable
|
||||
// automation/event-interception contract. Enhanced native picking is a
|
||||
// separate capability owned by EnhancedFilePicker.
|
||||
activate: click.bind(options.input),
|
||||
});
|
||||
const windowHost = options.window ?? globalThis.window;
|
||||
if (windowHost) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
BrowserRpcUnaryPort,
|
||||
} from "../../application/ports/browser-rpc/index.ts";
|
||||
import type { ClockPort } from "../../application/ports/clock-port.ts";
|
||||
import type { Result } from "../../application/result.ts";
|
||||
import type { Result } from "../../contracts/result.ts";
|
||||
import {
|
||||
installBrowserRpcContractBindings,
|
||||
type InstalledBrowserRpcContractBindings,
|
||||
|
||||
@@ -671,7 +671,14 @@ function validateCapabilityPayload(
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
||||
// BT-PRE-04. A capability document this adapter refuses is not a dead end
|
||||
// for the caller: the only way forward is to ask the issuer for a new one.
|
||||
// `NONE` said the opposite — that nothing could be done — and disagreed
|
||||
// with both the design record for an unsupported protocol and the vault,
|
||||
// which already answers `REISSUE_CAPABILITY` for the same class of refusal.
|
||||
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", {
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,26 @@ import type {
|
||||
ResumableUploadCheckpointStore,
|
||||
PartitionDeleteOutcome,
|
||||
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import type {
|
||||
BrowserDataFailure,
|
||||
BrowserDataFailureCode,
|
||||
BrowserDataRecovery,
|
||||
BrowserDataResult,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import { isBrowserDataFailureCode } from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
mapBrowserDataException,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import { snapshotAbortTimers } from "../../platform/abortable-operation.ts";
|
||||
import {
|
||||
createIndexedDbConnection,
|
||||
deleteIndexedDbDatabase,
|
||||
openIndexedDbDatabase,
|
||||
type IndexedDbTranslate,
|
||||
} from "../../platform/indexeddb-connection.ts";
|
||||
import { runIndexedDbTransaction } from "../../platform/indexeddb-transaction.ts";
|
||||
import {
|
||||
isResumableUploadCheckpoint,
|
||||
SAFE_OPAQUE_ID,
|
||||
@@ -20,6 +34,7 @@ const DATABASE_VERSION = 1;
|
||||
const CHECKPOINT_STORE = "checkpoints";
|
||||
const GOVERNANCE_STORE = "governance";
|
||||
const GOVERNANCE_KEY = "scope-binding";
|
||||
const OPERATION = "UPLOAD_RECONCILE";
|
||||
const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
@@ -65,10 +80,94 @@ type ScopeBinding = Readonly<{
|
||||
partitionToken: string;
|
||||
}>;
|
||||
|
||||
type OpenFactory = (
|
||||
name: string,
|
||||
version?: number,
|
||||
) => IDBOpenDBRequest;
|
||||
/**
|
||||
* `browserDataFailure` and `mapBrowserDataException` build a `Result`, while the
|
||||
* kernel's `translate` contract wants the failure on its own. Both only ever
|
||||
* build the failure arm, so the branch below is a narrowing, not a claim.
|
||||
*/
|
||||
function failureOf(result: BrowserDataResult<never>): BrowserDataFailure {
|
||||
if (result.ok) {
|
||||
throw new TypeError("A browser data failure was expected.");
|
||||
}
|
||||
return result.error;
|
||||
}
|
||||
|
||||
function checkpointFailure(
|
||||
code: BrowserDataFailureCode,
|
||||
options: Readonly<{
|
||||
retryable?: boolean;
|
||||
recovery?: BrowserDataRecovery;
|
||||
}> = {},
|
||||
): BrowserDataFailure {
|
||||
return failureOf(browserDataFailure(code, OPERATION, options));
|
||||
}
|
||||
|
||||
function mappedFailure(error: unknown): BrowserDataFailure {
|
||||
return failureOf(mapBrowserDataException(error, OPERATION));
|
||||
}
|
||||
|
||||
/**
|
||||
* The kernel keeps an admission's `detail` opaque, so a rejected scope binding
|
||||
* carries its own failure through it and the guard hands that failure straight
|
||||
* back. Any other shape would mean a second taxonomy grew beside this one.
|
||||
*/
|
||||
function admissionFailure(detail: unknown): BrowserDataFailure | null {
|
||||
if (!detail || typeof detail !== "object") return null;
|
||||
const record = detail as Record<string, unknown>;
|
||||
return isBrowserDataFailureCode(record.code) && record.operation === OPERATION
|
||||
? (detail as BrowserDataFailure)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* BT-UP-03. The checkpoint store keeps its own failure table rather than the
|
||||
* `mapIndexedDbException` the other three IndexedDB adapters share: the same
|
||||
* native error is a different answer here (a `ConstraintError` recovers by
|
||||
* REOPEN, a quota failure is retryable), and unifying the four tables is a
|
||||
* separate change from moving the mechanics onto the kernel.
|
||||
*/
|
||||
const translate: IndexedDbTranslate<BrowserDataFailure> = (cause) => {
|
||||
switch (cause.kind) {
|
||||
case "NATIVE_EXCEPTION":
|
||||
return mappedFailure(cause.error);
|
||||
case "NO_VALUE_PRODUCED":
|
||||
// Three adapters call this UNAVAILABLE. Here a checkpoint transaction
|
||||
// that committed without producing a value read a row it could not turn
|
||||
// into a checkpoint, so reconciling is the only way forward; retrying
|
||||
// would read the same row again.
|
||||
return checkpointFailure("CORRUPT_DATA", { recovery: "RECONCILE" });
|
||||
case "BLOCKED":
|
||||
case "BLOCKED_DEADLINE":
|
||||
return checkpointFailure("BLOCKED", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
});
|
||||
case "CALLER_ABORT":
|
||||
return checkpointFailure("ABORTED");
|
||||
case "CLOSED":
|
||||
// A closed handle is not the caller aborting: the upload can resume once
|
||||
// a new store is built over the partition.
|
||||
return checkpointFailure("UNAVAILABLE", { recovery: "RESUME" });
|
||||
case "UPGRADE_REJECTED":
|
||||
// The schema body's own throw is what the caller sees. A rejection with
|
||||
// no detail can only come from a null `newVersion`, which an open request
|
||||
// never reports.
|
||||
return cause.detail === undefined
|
||||
? checkpointFailure("MIGRATION_FAILED", { recovery: "READ_ONLY" })
|
||||
: mappedFailure(cause.detail);
|
||||
case "ADMISSION_REJECTED":
|
||||
return (
|
||||
admissionFailure(cause.detail) ??
|
||||
checkpointFailure("POLICY_REJECTED", { recovery: "READ_ONLY" })
|
||||
);
|
||||
case "UNSUPPORTED":
|
||||
return checkpointFailure("UNSUPPORTED", { recovery: "READ_ONLY" });
|
||||
default: {
|
||||
const exhaustive: never = cause;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function uploadCheckpointDatabaseName(
|
||||
scope: IndexedDbUploadCheckpointScope,
|
||||
@@ -104,13 +203,19 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
) {
|
||||
throw new TypeError("Upload checkpoint blocked timeout is invalid.");
|
||||
}
|
||||
const openFactory: OpenFactory | undefined = factory
|
||||
? factory.open.bind(factory)
|
||||
: undefined;
|
||||
const deleteFactory =
|
||||
factory && typeof factory.deleteDatabase === "function"
|
||||
? factory.deleteDatabase.bind(factory)
|
||||
: undefined;
|
||||
// X-AUDIT-02. The timer callables are captured once, bound to their receiver,
|
||||
// so replacing a global after composition cannot change how a blocked open or
|
||||
// a blocked deletion already in flight is bounded. The kernel throws at
|
||||
// construction if a positive deadline arrives without them.
|
||||
const timers = snapshotAbortTimers({
|
||||
setTimeout: (callback: () => void, milliseconds: number) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle: ReturnType<typeof globalThis.setTimeout>) => {
|
||||
globalThis.clearTimeout(handle);
|
||||
},
|
||||
});
|
||||
const canDelete =
|
||||
factory !== undefined && typeof factory.deleteDatabase === "function";
|
||||
const databaseName = uploadCheckpointDatabaseName(scope);
|
||||
const pendingDeletions = pendingDeletionsFor(factory);
|
||||
if (pendingDeletions.has(databaseName)) {
|
||||
@@ -125,118 +230,60 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
schemaVersion: 1,
|
||||
...scope,
|
||||
});
|
||||
let database: IDBDatabase | null = null;
|
||||
let opening: Promise<BrowserDataResult<IDBDatabase>> | null = null;
|
||||
let closed = false;
|
||||
|
||||
async function open(
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<IDBDatabase>> {
|
||||
if (closed || !openFactory) {
|
||||
return browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", {
|
||||
recovery: "RESUME",
|
||||
});
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
||||
}
|
||||
if (database) return browserDataSuccess(database);
|
||||
if (!opening) {
|
||||
opening = openAndBind().finally(() => {
|
||||
opening = null;
|
||||
});
|
||||
}
|
||||
const result = await opening;
|
||||
if (signal?.aborted) {
|
||||
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function openAndBind(): Promise<BrowserDataResult<IDBDatabase>> {
|
||||
let request: IDBOpenDBRequest;
|
||||
try {
|
||||
request = openFactory!(databaseName, DATABASE_VERSION);
|
||||
} catch (error) {
|
||||
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
|
||||
}
|
||||
const opened = await new Promise<BrowserDataResult<IDBDatabase>>(
|
||||
(resolve) => {
|
||||
let settled = false;
|
||||
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (result: BrowserDataResult<IDBDatabase>) => {
|
||||
if (settled) {
|
||||
if (result.ok) result.value.close();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (blockedTimer) clearTimeout(blockedTimer);
|
||||
resolve(result);
|
||||
};
|
||||
request.onupgradeneeded = () => {
|
||||
try {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(CHECKPOINT_STORE)) {
|
||||
db.createObjectStore(CHECKPOINT_STORE, {
|
||||
keyPath: "uploadKey",
|
||||
});
|
||||
}
|
||||
if (!db.objectStoreNames.contains(GOVERNANCE_STORE)) {
|
||||
db.createObjectStore(GOVERNANCE_STORE, {
|
||||
keyPath: "key",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
request.transaction?.abort();
|
||||
} catch {
|
||||
// The open request will surface the original closed failure.
|
||||
}
|
||||
finish(mapBrowserDataException(error, "UPLOAD_RECONCILE"));
|
||||
}
|
||||
};
|
||||
request.onblocked = () => {
|
||||
blockedTimer = setTimeout(() => {
|
||||
finish(
|
||||
browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
}),
|
||||
);
|
||||
}, blockedTimeoutMs);
|
||||
};
|
||||
request.onerror = () =>
|
||||
finish(
|
||||
mapBrowserDataException(
|
||||
request.error,
|
||||
"UPLOAD_RECONCILE",
|
||||
),
|
||||
);
|
||||
request.onsuccess = () => finish(browserDataSuccess(request.result));
|
||||
},
|
||||
);
|
||||
if (!opened.ok) return opened;
|
||||
if (closed) {
|
||||
opened.value.close();
|
||||
return browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", {
|
||||
recovery: "RESUME",
|
||||
});
|
||||
}
|
||||
const bound = await bindScope(opened.value, expectedBinding);
|
||||
if (!bound.ok) {
|
||||
opened.value.close();
|
||||
return bound;
|
||||
}
|
||||
opened.value.onversionchange = () => {
|
||||
opened.value.close();
|
||||
if (database === opened.value) database = null;
|
||||
};
|
||||
opened.value.onclose = () => {
|
||||
if (database === opened.value) database = null;
|
||||
};
|
||||
database = opened.value;
|
||||
return browserDataSuccess(opened.value);
|
||||
}
|
||||
/**
|
||||
* The handle owns the cached connection, the single-flight open and the
|
||||
* `versionchange`/`close` invalidation this file used to wire by hand. No
|
||||
* `onVersionChange` callback is passed because closing the connection and
|
||||
* dropping the cached handle — which the kernel already does — was this
|
||||
* store's entire listener body; registering one inside `admit` instead would
|
||||
* be overwritten when the handle adopts the connection.
|
||||
*/
|
||||
const connection = createIndexedDbConnection<BrowserDataFailure>({
|
||||
translate,
|
||||
open: (signal) =>
|
||||
factory === undefined
|
||||
? Promise.resolve(
|
||||
browserDataFailure("UNAVAILABLE", OPERATION, {
|
||||
recovery: "RESUME",
|
||||
}),
|
||||
)
|
||||
: openIndexedDbDatabase<BrowserDataFailure>({
|
||||
factory,
|
||||
databaseName,
|
||||
version: DATABASE_VERSION,
|
||||
translate,
|
||||
signal,
|
||||
blockedTimeoutMs,
|
||||
timers,
|
||||
upgrade: ({ database }) => {
|
||||
try {
|
||||
if (!database.objectStoreNames.contains(CHECKPOINT_STORE)) {
|
||||
database.createObjectStore(CHECKPOINT_STORE, {
|
||||
keyPath: "uploadKey",
|
||||
});
|
||||
}
|
||||
if (!database.objectStoreNames.contains(GOVERNANCE_STORE)) {
|
||||
database.createObjectStore(GOVERNANCE_STORE, {
|
||||
keyPath: "key",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return { kind: "REJECTED", detail: error };
|
||||
}
|
||||
return { kind: "APPLIED" };
|
||||
},
|
||||
// The binding runs before the caller ever sees the connection, so a
|
||||
// partition bound to another scope can never serve a read. A
|
||||
// rejected admission closes the connection inside the kernel.
|
||||
admit: async (database) => {
|
||||
const bound = await bindScope(database, expectedBinding);
|
||||
return bound.ok
|
||||
? { kind: "ADMIT" }
|
||||
: { kind: "REJECT", detail: bound.error };
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const storeValue: ResumableUploadCheckpointStore = {
|
||||
async read(
|
||||
@@ -246,12 +293,9 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
BrowserDataResult<ResumableUploadCheckpoint | null>
|
||||
> {
|
||||
if (!SAFE_UPLOAD_KEY.test(uploadKey)) {
|
||||
return browserDataFailure(
|
||||
"INVALID_INPUT",
|
||||
"UPLOAD_RECONCILE",
|
||||
);
|
||||
return browserDataFailure("INVALID_INPUT", OPERATION);
|
||||
}
|
||||
const opened = await open(signal);
|
||||
const opened = await connection.acquire(signal);
|
||||
if (!opened.ok) return opened;
|
||||
return await runCheckpointTransaction<
|
||||
ResumableUploadCheckpoint | null
|
||||
@@ -261,7 +305,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
signal,
|
||||
(nativeStore, context) => {
|
||||
const request = nativeStore.get(uploadKey);
|
||||
request.onerror = () => context.nativeFailure(request.error);
|
||||
request.onerror = () => context.fail(mappedFailure(request.error));
|
||||
request.onsuccess = () => {
|
||||
if (request.result === undefined) {
|
||||
context.succeed(null);
|
||||
@@ -269,11 +313,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
}
|
||||
if (!isResumableUploadCheckpoint(request.result)) {
|
||||
context.fail(
|
||||
browserDataFailure(
|
||||
"CORRUPT_DATA",
|
||||
"UPLOAD_RECONCILE",
|
||||
{ recovery: "RECONCILE" },
|
||||
),
|
||||
checkpointFailure("CORRUPT_DATA", { recovery: "RECONCILE" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -294,10 +334,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
try {
|
||||
checkpoint = snapshotCheckpoint(inputValue.checkpoint);
|
||||
} catch {
|
||||
return browserDataFailure(
|
||||
"INVALID_INPUT",
|
||||
"UPLOAD_RECONCILE",
|
||||
);
|
||||
return browserDataFailure("INVALID_INPUT", OPERATION);
|
||||
}
|
||||
const expectedRevision = inputValue.expectedRevision;
|
||||
if (
|
||||
@@ -306,12 +343,9 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
expectedRevision < 1)) ||
|
||||
checkpoint.revision !== (expectedRevision ?? 0) + 1
|
||||
) {
|
||||
return browserDataFailure(
|
||||
"INVALID_INPUT",
|
||||
"UPLOAD_RECONCILE",
|
||||
);
|
||||
return browserDataFailure("INVALID_INPUT", OPERATION);
|
||||
}
|
||||
const opened = await open(inputValue.signal);
|
||||
const opened = await connection.acquire(inputValue.signal);
|
||||
if (!opened.ok) return opened;
|
||||
return await runCheckpointTransaction<ResumableUploadCheckpoint>(
|
||||
opened.value,
|
||||
@@ -319,7 +353,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
inputValue.signal,
|
||||
(nativeStore, context) => {
|
||||
const request = nativeStore.get(checkpoint.uploadKey);
|
||||
request.onerror = () => context.nativeFailure(request.error);
|
||||
request.onerror = () => context.fail(mappedFailure(request.error));
|
||||
request.onsuccess = () => {
|
||||
const current = request.result;
|
||||
if (
|
||||
@@ -329,16 +363,12 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
current.revision !== expectedRevision))
|
||||
) {
|
||||
context.fail(
|
||||
browserDataFailure(
|
||||
"CONFLICT",
|
||||
"UPLOAD_RECONCILE",
|
||||
{ recovery: "RECONCILE" },
|
||||
),
|
||||
checkpointFailure("CONFLICT", { recovery: "RECONCILE" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const put = nativeStore.put(checkpoint);
|
||||
put.onerror = () => context.nativeFailure(put.error);
|
||||
put.onerror = () => context.fail(mappedFailure(put.error));
|
||||
put.onsuccess = () => context.succeed(checkpoint);
|
||||
};
|
||||
},
|
||||
@@ -355,12 +385,9 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
!Number.isSafeInteger(inputValue.expectedRevision) ||
|
||||
inputValue.expectedRevision < 1
|
||||
) {
|
||||
return browserDataFailure(
|
||||
"INVALID_INPUT",
|
||||
"UPLOAD_RECONCILE",
|
||||
);
|
||||
return browserDataFailure("INVALID_INPUT", OPERATION);
|
||||
}
|
||||
const opened = await open(inputValue.signal);
|
||||
const opened = await connection.acquire(inputValue.signal);
|
||||
if (!opened.ok) return opened;
|
||||
return await runCheckpointTransaction<void>(
|
||||
opened.value,
|
||||
@@ -368,24 +395,20 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
inputValue.signal,
|
||||
(nativeStore, context) => {
|
||||
const request = nativeStore.get(inputValue.uploadKey);
|
||||
request.onerror = () => context.nativeFailure(request.error);
|
||||
request.onerror = () => context.fail(mappedFailure(request.error));
|
||||
request.onsuccess = () => {
|
||||
if (
|
||||
!isResumableUploadCheckpoint(request.result) ||
|
||||
request.result.revision !== inputValue.expectedRevision
|
||||
) {
|
||||
context.fail(
|
||||
browserDataFailure(
|
||||
"CONFLICT",
|
||||
"UPLOAD_RECONCILE",
|
||||
{ recovery: "RECONCILE" },
|
||||
),
|
||||
checkpointFailure("CONFLICT", { recovery: "RECONCILE" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const deletion = nativeStore.delete(inputValue.uploadKey);
|
||||
deletion.onerror = () =>
|
||||
context.nativeFailure(deletion.error);
|
||||
context.fail(mappedFailure(deletion.error));
|
||||
deletion.onsuccess = () => context.succeed(undefined);
|
||||
};
|
||||
},
|
||||
@@ -393,9 +416,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
},
|
||||
|
||||
close() {
|
||||
closed = true;
|
||||
database?.close();
|
||||
database = null;
|
||||
connection.close();
|
||||
},
|
||||
};
|
||||
const store = Object.freeze(storeValue);
|
||||
@@ -405,82 +426,50 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
): Promise<
|
||||
BrowserDataResult<PartitionDeleteOutcome>
|
||||
> {
|
||||
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal is
|
||||
// intentionally observed only before dispatch so the adapter never
|
||||
// reports ABORTED while deletion may still commit — which is also why the
|
||||
// kernel's delete takes no signal.
|
||||
if (signal?.aborted) {
|
||||
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
||||
return browserDataFailure("ABORTED", OPERATION);
|
||||
}
|
||||
closed = true;
|
||||
database?.close();
|
||||
database = null;
|
||||
if (!deleteFactory) {
|
||||
return browserDataFailure(
|
||||
"UNSUPPORTED",
|
||||
"UPLOAD_RECONCILE",
|
||||
{ recovery: "READ_ONLY" },
|
||||
connection.close();
|
||||
if (factory === undefined || !canDelete) {
|
||||
return browserDataFailure("UNSUPPORTED", OPERATION, {
|
||||
recovery: "READ_ONLY",
|
||||
});
|
||||
}
|
||||
// BT-UP-03. Once dispatched the deletion may still commit after this call
|
||||
// returns, so the pending registration is installed before the promise
|
||||
// settles and is only released by the real native completion — which is
|
||||
// exactly what `onSettled` reports and a blocked deadline never does.
|
||||
pendingDeletions.add(databaseName);
|
||||
const deleted = await deleteIndexedDbDatabase<BrowserDataFailure>({
|
||||
factory,
|
||||
databaseName,
|
||||
translate,
|
||||
blockedTimeoutMs,
|
||||
timers,
|
||||
onSettled: () => {
|
||||
pendingDeletions.delete(databaseName);
|
||||
},
|
||||
});
|
||||
if (!deleted.ok) return deleted;
|
||||
if (deleted.value.kind === "DELETED") {
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
state: "DELETED" as const,
|
||||
effect: "APPLIED" as const,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let request: IDBOpenDBRequest;
|
||||
try {
|
||||
request = deleteFactory(databaseName);
|
||||
} catch (error) {
|
||||
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
|
||||
}
|
||||
// BT-UP-03. Once dispatched the deletion may still commit after this
|
||||
// call returns, so the pending registration is installed before the
|
||||
// promise settles and is only released by the real native completion.
|
||||
pendingDeletions.add(databaseName);
|
||||
return await new Promise<BrowserDataResult<PartitionDeleteOutcome>>(
|
||||
(resolve) => {
|
||||
let settled = false;
|
||||
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (
|
||||
result: BrowserDataResult<PartitionDeleteOutcome>,
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (blockedTimer) clearTimeout(blockedTimer);
|
||||
resolve(result);
|
||||
};
|
||||
const releasePending = () => {
|
||||
pendingDeletions.delete(databaseName);
|
||||
};
|
||||
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal
|
||||
// is intentionally observed only before dispatch so the adapter never
|
||||
// reports ABORTED while deletion may still commit.
|
||||
request.onblocked = () => {
|
||||
blockedTimer = setTimeout(() => {
|
||||
// Not NOT_APPLIED: the request is still live in the browser.
|
||||
finish(
|
||||
browserDataSuccess(
|
||||
Object.freeze({
|
||||
state: "PENDING" as const,
|
||||
effect: "UNKNOWN" as const,
|
||||
reason: "BLOCKED_DEADLINE" as const,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, blockedTimeoutMs);
|
||||
};
|
||||
request.onerror = () => {
|
||||
releasePending();
|
||||
finish(
|
||||
mapBrowserDataException(
|
||||
request.error,
|
||||
"UPLOAD_RECONCILE",
|
||||
),
|
||||
);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
releasePending();
|
||||
finish(
|
||||
browserDataSuccess(
|
||||
Object.freeze({
|
||||
state: "DELETED" as const,
|
||||
effect: "APPLIED" as const,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
},
|
||||
// Not NOT_APPLIED: the request is still live in the browser.
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
state: "PENDING" as const,
|
||||
effect: "UNKNOWN" as const,
|
||||
reason: "BLOCKED_DEADLINE" as const,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -488,168 +477,79 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
return Object.freeze({ store, admin });
|
||||
}
|
||||
|
||||
type TransactionContext<Value> = Readonly<{
|
||||
succeed(value: Value): void;
|
||||
fail(result: BrowserDataResult<never>): void;
|
||||
nativeFailure(error: unknown): void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* `durability` is deliberately `undefined` rather than `"default"`: that opens
|
||||
* the transaction with no options bag at all, which is what checkpoint writes
|
||||
* have always done. Passing a named value would quietly move them onto a
|
||||
* different flush policy and slow every checkpoint write down.
|
||||
*
|
||||
* A request error goes through `fail`, which aborts, rather than through the
|
||||
* kernel's `requestFailed`, which only records. `compareAndSwap` issues its put
|
||||
* inside the get's success handler, so a transaction left running after a
|
||||
* failed request is a transaction that can still commit half of a swap.
|
||||
*/
|
||||
async function runCheckpointTransaction<Value>(
|
||||
database: IDBDatabase,
|
||||
mode: IDBTransactionMode,
|
||||
mode: "readonly" | "readwrite",
|
||||
signal: AbortSignal | undefined,
|
||||
execute: (
|
||||
store: IDBObjectStore,
|
||||
context: TransactionContext<Value>,
|
||||
context: Readonly<{
|
||||
succeed(value: Value): void;
|
||||
fail(failure: BrowserDataFailure): void;
|
||||
}>,
|
||||
) => void,
|
||||
): Promise<BrowserDataResult<Value>> {
|
||||
if (signal?.aborted) {
|
||||
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
||||
}
|
||||
return await new Promise<BrowserDataResult<Value>>((resolve) => {
|
||||
let transaction: IDBTransaction;
|
||||
try {
|
||||
transaction = database.transaction(CHECKPOINT_STORE, mode);
|
||||
} catch (error) {
|
||||
resolve(mapBrowserDataException(error, "UPLOAD_RECONCILE"));
|
||||
return;
|
||||
}
|
||||
let value: Value | undefined;
|
||||
let hasValue = false;
|
||||
let failure: BrowserDataResult<never> | null = null;
|
||||
let settled = false;
|
||||
const finish = (result: BrowserDataResult<Value>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener("abort", abort);
|
||||
resolve(result);
|
||||
};
|
||||
const abort = () => {
|
||||
const previousFailure = failure;
|
||||
failure = browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// The transaction may already be durably committed while its
|
||||
// completion event is still queued. Wait for oncomplete/onabort so we
|
||||
// never report ABORTED for a mutation that actually committed.
|
||||
failure = previousFailure;
|
||||
}
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
transaction.oncomplete = () => {
|
||||
if (!hasValue) {
|
||||
finish(
|
||||
browserDataFailure("CORRUPT_DATA", "UPLOAD_RECONCILE", {
|
||||
recovery: "RECONCILE",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
finish(browserDataSuccess(value as Value));
|
||||
};
|
||||
transaction.onerror = () => {
|
||||
// onabort is the terminal transaction signal.
|
||||
};
|
||||
transaction.onabort = () =>
|
||||
finish(
|
||||
failure ??
|
||||
mapBrowserDataException(
|
||||
transaction.error,
|
||||
"UPLOAD_RECONCILE",
|
||||
),
|
||||
);
|
||||
const context: TransactionContext<Value> = Object.freeze({
|
||||
succeed(next) {
|
||||
if (failure) return;
|
||||
value = next;
|
||||
hasValue = true;
|
||||
},
|
||||
fail(result) {
|
||||
if (failure) return;
|
||||
failure = result;
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
finish(result);
|
||||
}
|
||||
},
|
||||
nativeFailure(error) {
|
||||
if (failure) return;
|
||||
failure = mapBrowserDataException(
|
||||
error,
|
||||
"UPLOAD_RECONCILE",
|
||||
);
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
finish(failure);
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
return await runIndexedDbTransaction<Value, BrowserDataFailure>({
|
||||
database,
|
||||
stores: [CHECKPOINT_STORE],
|
||||
mode,
|
||||
translate,
|
||||
signal,
|
||||
durability: undefined,
|
||||
queue: (transaction, context) => {
|
||||
execute(transaction.objectStore(CHECKPOINT_STORE), context);
|
||||
} catch (error) {
|
||||
context.nativeFailure(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* No `signal`: the binding is part of opening the connection, and an open the
|
||||
* caller gave up on is already ended by the kernel's own abort path.
|
||||
*/
|
||||
async function bindScope(
|
||||
database: IDBDatabase,
|
||||
expected: ScopeBinding,
|
||||
): Promise<BrowserDataResult<void>> {
|
||||
return await new Promise<BrowserDataResult<void>>((resolve) => {
|
||||
let transaction: IDBTransaction;
|
||||
try {
|
||||
transaction = database.transaction(GOVERNANCE_STORE, "readwrite");
|
||||
} catch (error) {
|
||||
resolve(mapBrowserDataException(error, "UPLOAD_RECONCILE"));
|
||||
return;
|
||||
}
|
||||
let failure: BrowserDataResult<never> | null = null;
|
||||
transaction.onerror = () => {
|
||||
// onabort owns terminal resolution.
|
||||
};
|
||||
transaction.onabort = () =>
|
||||
resolve(
|
||||
failure ??
|
||||
mapBrowserDataException(
|
||||
transaction.error,
|
||||
"UPLOAD_RECONCILE",
|
||||
),
|
||||
);
|
||||
transaction.oncomplete = () => resolve(browserDataSuccess(undefined));
|
||||
const store = transaction.objectStore(GOVERNANCE_STORE);
|
||||
const request = store.get(GOVERNANCE_KEY);
|
||||
request.onerror = () => {
|
||||
failure = mapBrowserDataException(
|
||||
request.error,
|
||||
"UPLOAD_RECONCILE",
|
||||
);
|
||||
transaction.abort();
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
if (request.result === undefined) {
|
||||
const add = store.add(expected);
|
||||
add.onerror = () => {
|
||||
failure = mapBrowserDataException(
|
||||
add.error,
|
||||
"UPLOAD_RECONCILE",
|
||||
return await runIndexedDbTransaction<void, BrowserDataFailure>({
|
||||
database,
|
||||
stores: [GOVERNANCE_STORE],
|
||||
mode: "readwrite",
|
||||
translate,
|
||||
durability: undefined,
|
||||
queue: (transaction, context) => {
|
||||
const store = transaction.objectStore(GOVERNANCE_STORE);
|
||||
const request = store.get(GOVERNANCE_KEY);
|
||||
request.onerror = () => context.fail(mappedFailure(request.error));
|
||||
request.onsuccess = () => {
|
||||
if (request.result === undefined) {
|
||||
const add = store.add(expected);
|
||||
add.onerror = () => context.fail(mappedFailure(add.error));
|
||||
// The binding is the value: without this the committed transaction
|
||||
// would report NO_VALUE_PRODUCED, which this store reads as
|
||||
// CORRUPT_DATA.
|
||||
add.onsuccess = () => context.succeed(undefined);
|
||||
return;
|
||||
}
|
||||
if (!sameScopeBinding(request.result, expected)) {
|
||||
context.fail(
|
||||
checkpointFailure("POLICY_REJECTED", { recovery: "READ_ONLY" }),
|
||||
);
|
||||
transaction.abort();
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!sameScopeBinding(request.result, expected)) {
|
||||
failure = browserDataFailure(
|
||||
"POLICY_REJECTED",
|
||||
"UPLOAD_RECONCILE",
|
||||
{ recovery: "READ_ONLY" },
|
||||
);
|
||||
transaction.abort();
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
context.succeed(undefined);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type DiagnosticRecordInput,
|
||||
} from "../../contracts/diagnostics.ts";
|
||||
import { projectTelemetryEvent } from "../../contracts/telemetry.ts";
|
||||
import { assertBoundedCapacity } from "../telemetry/best-effort-telemetry.ts";
|
||||
import { assertBoundedCapacity } from "../platform/bounded-capacity.ts";
|
||||
|
||||
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
|
||||
record() {},
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createDiagnosticsAdapter,
|
||||
getLastBootEvidence,
|
||||
MAX_DIAGNOSTIC_ENTRIES,
|
||||
noOpDiagnostics,
|
||||
recordBootFailure,
|
||||
} from "./bounded-diagnostics.ts";
|
||||
@@ -0,0 +1,400 @@
|
||||
import type {
|
||||
InstalledHttpContract,
|
||||
} from "../../contracts/external-contract-runtime.ts";
|
||||
import type { Result } from "../../contracts/result.ts";
|
||||
import type { MappingResult } from "../../contracts/boundary-mapper.ts";
|
||||
import {
|
||||
createFailure,
|
||||
kindForStatus,
|
||||
type ApiFailure,
|
||||
type FailureEffectCertainty,
|
||||
type FailureKind,
|
||||
} from "../../contracts/errors.ts";
|
||||
import type { MutationIntent } from "../../contracts/mutation-intent.ts";
|
||||
import type {
|
||||
HttpExecutionOutcome,
|
||||
SafeResponseMetadata,
|
||||
} from "./http-execution-v3.ts";
|
||||
|
||||
type ValidatorValue<Validator> =
|
||||
Validator extends Readonly<{
|
||||
safeParse(value: unknown): infer ParseResult;
|
||||
}>
|
||||
? Extract<
|
||||
ParseResult,
|
||||
Readonly<{ success: true; data: unknown }>
|
||||
> extends Readonly<{ data: infer Value }>
|
||||
? Value
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type HttpContractInput<Contract> =
|
||||
Contract extends Readonly<{
|
||||
contract: Readonly<{ inputValidator: infer Validator }>;
|
||||
}>
|
||||
? ValidatorValue<Validator>
|
||||
: never;
|
||||
|
||||
export type HttpContractWireOutput<Contract> =
|
||||
Contract extends Readonly<{
|
||||
contract: Readonly<{ outputValidator: infer Validator }>;
|
||||
}>
|
||||
? ValidatorValue<Validator>
|
||||
: never;
|
||||
|
||||
export type HttpContractProblem<Contract> =
|
||||
Contract extends Readonly<{
|
||||
contract: Readonly<{ problemValidator: infer Validator }>;
|
||||
}>
|
||||
? ValidatorValue<Validator>
|
||||
: never;
|
||||
|
||||
export type HttpContractOperationId<Contract> =
|
||||
Contract extends Readonly<{
|
||||
contract: Readonly<{ operationId: infer OperationId extends string }>;
|
||||
}>
|
||||
? OperationId
|
||||
: never;
|
||||
|
||||
export type InstalledHttpOperationExecutor = Readonly<{
|
||||
execute<Input, WireOutput, Problem>(
|
||||
contract: InstalledHttpContract<Input, WireOutput, Problem>,
|
||||
input: Input,
|
||||
context: Readonly<{
|
||||
routeId: string;
|
||||
signal?: AbortSignal;
|
||||
intent?: MutationIntent;
|
||||
}>,
|
||||
): Promise<HttpExecutionOutcome<WireOutput, Problem>>;
|
||||
}>;
|
||||
|
||||
export type FeatureHttpProblemMapping = Readonly<{
|
||||
kind: FailureKind;
|
||||
code: string;
|
||||
}>;
|
||||
|
||||
type FeatureHttpOperationSpec<
|
||||
Contract,
|
||||
Value,
|
||||
RouteId extends string,
|
||||
> = Readonly<{
|
||||
contract: Contract;
|
||||
routeId: RouteId;
|
||||
mapSuccess(
|
||||
value: HttpContractWireOutput<Contract>,
|
||||
): MappingResult<Value>;
|
||||
mapProblem?(
|
||||
problem: HttpContractProblem<Contract>,
|
||||
metadata: SafeResponseMetadata,
|
||||
): FeatureHttpProblemMapping | undefined;
|
||||
}>;
|
||||
|
||||
export type FeatureHttpOperation<
|
||||
Contract,
|
||||
Value,
|
||||
RouteId extends string = string,
|
||||
> = FeatureHttpOperationSpec<Contract, Value, RouteId> &
|
||||
Readonly<{
|
||||
operationId: HttpContractOperationId<Contract>;
|
||||
/**
|
||||
* Compile-time only carrier. The runtime object has no extra type payload;
|
||||
* the contract remains the authority for input/wire/problem types.
|
||||
*/
|
||||
__types?: Readonly<{
|
||||
value: Value;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
type ContractValidity<Contract> =
|
||||
HttpContractOperationId<Contract> extends never
|
||||
? never
|
||||
: HttpContractInput<Contract> extends never
|
||||
? never
|
||||
: HttpContractWireOutput<Contract> extends never
|
||||
? never
|
||||
: HttpContractProblem<Contract> extends never
|
||||
? never
|
||||
: unknown;
|
||||
|
||||
function defineFeatureHttpOperationForRoute<
|
||||
const RouteId extends string,
|
||||
const Contract,
|
||||
Value,
|
||||
>(
|
||||
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
|
||||
ContractValidity<Contract>,
|
||||
): FeatureHttpOperation<Contract, Value, RouteId> {
|
||||
return Object.freeze({
|
||||
...spec,
|
||||
operationId: (spec.contract as Readonly<{
|
||||
contract: Readonly<{ operationId: HttpContractOperationId<Contract> }>;
|
||||
}>).contract.operationId,
|
||||
}) as FeatureHttpOperation<Contract, Value, RouteId>;
|
||||
}
|
||||
|
||||
export function defineFeatureHttpOperation<
|
||||
const Contract,
|
||||
Value,
|
||||
const RouteId extends string,
|
||||
>(
|
||||
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
|
||||
ContractValidity<Contract>,
|
||||
): FeatureHttpOperation<Contract, Value, RouteId> {
|
||||
return defineFeatureHttpOperationForRoute(spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature-local route typing without introducing a dependency from the HTTP
|
||||
* capability to the presentation route registry.
|
||||
*/
|
||||
export function defineFeatureHttpOperationForRoutes<
|
||||
const RouteId extends string,
|
||||
>() {
|
||||
return function defineRouteBoundFeatureHttpOperation<
|
||||
const Contract,
|
||||
Value,
|
||||
>(
|
||||
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
|
||||
ContractValidity<Contract>,
|
||||
): FeatureHttpOperation<Contract, Value, RouteId> {
|
||||
return defineFeatureHttpOperationForRoute(spec);
|
||||
};
|
||||
}
|
||||
|
||||
type FeatureHttpOperationShape = Readonly<{
|
||||
contract: object;
|
||||
operationId: string;
|
||||
routeId: string;
|
||||
mapSuccess: (...args: never[]) => unknown;
|
||||
mapProblem?: (...args: never[]) => unknown;
|
||||
}>;
|
||||
|
||||
type OperationContract<Operation> =
|
||||
Operation extends Readonly<{ contract: infer Contract }> ? Contract : never;
|
||||
|
||||
type OperationInput<Operation> = HttpContractInput<OperationContract<Operation>>;
|
||||
type OperationWireOutput<Operation> =
|
||||
HttpContractWireOutput<OperationContract<Operation>>;
|
||||
type OperationProblem<Operation> =
|
||||
HttpContractProblem<OperationContract<Operation>>;
|
||||
|
||||
type OperationValue<Operation> =
|
||||
Operation extends FeatureHttpOperation<infer _Contract, infer Value, string>
|
||||
? Value
|
||||
: never;
|
||||
|
||||
type InvalidOperationRegistryKeys<
|
||||
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
|
||||
> = {
|
||||
[OperationId in keyof Operations & string]:
|
||||
OperationId extends Operations[OperationId]["operationId"]
|
||||
? Operations[OperationId]["operationId"] extends OperationId
|
||||
? never
|
||||
: OperationId
|
||||
: OperationId;
|
||||
}[keyof Operations & string];
|
||||
|
||||
type ValidOperationRegistry<
|
||||
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
|
||||
> = InvalidOperationRegistryKeys<Operations> extends never
|
||||
? unknown
|
||||
: Readonly<{
|
||||
__operationRegistryKeyMismatch: InvalidOperationRegistryKeys<Operations>;
|
||||
}>;
|
||||
|
||||
export type FeatureHttpBinding<
|
||||
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
|
||||
> = Readonly<{
|
||||
execute<OperationId extends keyof Operations & string>(
|
||||
operationId: OperationId,
|
||||
input: OperationInput<Operations[OperationId]>,
|
||||
context?: Readonly<{
|
||||
signal?: AbortSignal;
|
||||
intent?: MutationIntent;
|
||||
}>,
|
||||
): Promise<Result<OperationValue<Operations[OperationId]>, ApiFailure>>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Capability-specific feature binding for the installed HTTP runtime.
|
||||
*
|
||||
* The installed contract is the single type authority. A feature contributes
|
||||
* only a route identity plus wire-to-domain/problem interpretation. Transport,
|
||||
* retry, validation and effect-certainty stay in the reusable HTTP capability.
|
||||
*/
|
||||
export function createFeatureHttpBinding<
|
||||
const Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
|
||||
>(
|
||||
executor: InstalledHttpOperationExecutor,
|
||||
operations: Operations & ValidOperationRegistry<Operations>,
|
||||
): FeatureHttpBinding<Operations> {
|
||||
for (const [registryId, operation] of Object.entries(operations)) {
|
||||
if (registryId !== operation.operationId) {
|
||||
throw new TypeError(
|
||||
`Feature HTTP operation key mismatch: ${registryId} !== ${operation.operationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async execute<OperationId extends keyof Operations & string>(
|
||||
operationId: OperationId,
|
||||
input: OperationInput<Operations[OperationId]>,
|
||||
context: Readonly<{
|
||||
signal?: AbortSignal;
|
||||
intent?: MutationIntent;
|
||||
}> = {},
|
||||
): Promise<Result<OperationValue<Operations[OperationId]>, ApiFailure>> {
|
||||
const operation = operations[operationId];
|
||||
type SelectedOperation = Operations[OperationId];
|
||||
type Input = OperationInput<SelectedOperation>;
|
||||
type WireOutput = OperationWireOutput<SelectedOperation>;
|
||||
type Problem = OperationProblem<SelectedOperation>;
|
||||
type Value = OperationValue<SelectedOperation>;
|
||||
|
||||
const contract = operation.contract as InstalledHttpContract<
|
||||
Input,
|
||||
WireOutput,
|
||||
Problem
|
||||
>;
|
||||
const outcome = await executor.execute(contract, input, {
|
||||
routeId: operation.routeId,
|
||||
...(context.signal === undefined ? {} : { signal: context.signal }),
|
||||
...(context.intent === undefined ? {} : { intent: context.intent }),
|
||||
});
|
||||
return projectExecutionOutcome(
|
||||
operation.operationId,
|
||||
operation.mapSuccess as (value: WireOutput) => MappingResult<Value>,
|
||||
operation.mapProblem as
|
||||
| ((
|
||||
problem: Problem,
|
||||
metadata: SafeResponseMetadata,
|
||||
) => FeatureHttpProblemMapping | undefined)
|
||||
| undefined,
|
||||
outcome,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function projectExecutionOutcome<WireOutput, Problem, Value>(
|
||||
operationId: string,
|
||||
mapSuccess: (value: WireOutput) => MappingResult<Value>,
|
||||
mapProblem:
|
||||
| ((
|
||||
problem: Problem,
|
||||
metadata: SafeResponseMetadata,
|
||||
) => FeatureHttpProblemMapping | undefined)
|
||||
| undefined,
|
||||
outcome: HttpExecutionOutcome<WireOutput, Problem>,
|
||||
): Result<Value, ApiFailure> {
|
||||
switch (outcome.kind) {
|
||||
case "SUCCESS": {
|
||||
const mapped = mapSuccess(outcome.value);
|
||||
return mapped.ok
|
||||
? Object.freeze({ ok: true as const, value: mapped.value })
|
||||
: failure(
|
||||
"MAPPING_CONTRACT_VIOLATION",
|
||||
operationId,
|
||||
mapped.code,
|
||||
{ effect: outcome.effect },
|
||||
);
|
||||
}
|
||||
case "PROBLEM": {
|
||||
const mapped = mapProblem?.(outcome.problem, outcome.metadata);
|
||||
return failure(
|
||||
mapped?.kind ?? kindForStatus(outcome.metadata.status),
|
||||
operationId,
|
||||
mapped?.code ?? "CONTRACT_PROBLEM",
|
||||
{ httpStatus: outcome.metadata.status, effect: outcome.effect },
|
||||
);
|
||||
}
|
||||
case "UNAUTHENTICATED":
|
||||
return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", {
|
||||
effect: outcome.effect,
|
||||
});
|
||||
case "FORBIDDEN":
|
||||
return failure("FORBIDDEN", operationId, "FORBIDDEN", {
|
||||
effect: outcome.effect,
|
||||
});
|
||||
case "RATE_LIMITED":
|
||||
return failure("RATE_LIMITED", operationId, "RATE_LIMITED", {
|
||||
...(outcome.retryAfterMs === undefined
|
||||
? {}
|
||||
: { retryAfterMs: outcome.retryAfterMs }),
|
||||
effect: outcome.effect,
|
||||
});
|
||||
case "CANCELLED":
|
||||
return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", {
|
||||
effect: outcome.effect,
|
||||
});
|
||||
case "AUTH_INTEGRATION_FAILURE":
|
||||
return failure(
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
operationId,
|
||||
outcome.reason,
|
||||
{ effect: outcome.effect },
|
||||
);
|
||||
case "TRANSPORT_FAILURE":
|
||||
return failure(
|
||||
outcome.failure.kind === "TIMEOUT"
|
||||
? "REQUEST_TIMEOUT"
|
||||
: outcome.failure.kind === "ABORTED_BY_SCOPE"
|
||||
? "SCOPE_GENERATION_CHANGED"
|
||||
: "NETWORK_UNREACHABLE",
|
||||
operationId,
|
||||
outcome.failure.kind,
|
||||
{ effect: outcome.effect },
|
||||
);
|
||||
case "CONTRACT_VIOLATION":
|
||||
return failure(
|
||||
failureKindForViolation(outcome.violation.kind),
|
||||
operationId,
|
||||
outcome.violation.kind,
|
||||
{ effect: outcome.effect },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function failureKindForViolation(
|
||||
violation: Extract<
|
||||
HttpExecutionOutcome<unknown, unknown>,
|
||||
{ kind: "CONTRACT_VIOLATION" }
|
||||
>["violation"]["kind"],
|
||||
): FailureKind {
|
||||
switch (violation) {
|
||||
case "CONTENT_TYPE_MISMATCH":
|
||||
return "CONTENT_TYPE_MISMATCH";
|
||||
case "RESPONSE_TOO_LARGE":
|
||||
return "RESPONSE_BODY_LIMIT";
|
||||
case "UTF8_INVALID":
|
||||
case "JSON_INVALID":
|
||||
return "MALFORMED_JSON";
|
||||
case "MAPPING_CONTRACT_VIOLATION":
|
||||
return "MAPPING_CONTRACT_VIOLATION";
|
||||
case "SCOPE_FENCED":
|
||||
return "SCOPE_GENERATION_CHANGED";
|
||||
case "SUCCESS_SCHEMA_INVALID":
|
||||
case "PROBLEM_SCHEMA_INVALID":
|
||||
case "VALIDATOR_RUNTIME_FAILURE":
|
||||
return "SCHEMA_MISMATCH";
|
||||
default:
|
||||
return "ENVELOPE_MISMATCH";
|
||||
}
|
||||
}
|
||||
|
||||
function failure(
|
||||
kind: FailureKind,
|
||||
operationId: string,
|
||||
code: string,
|
||||
details: Readonly<{
|
||||
httpStatus?: number;
|
||||
retryAfterMs?: number;
|
||||
effect?: FailureEffectCertainty;
|
||||
}> = {},
|
||||
): Result<never, ApiFailure> {
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
error: createFailure(kind, operationId, 0, { code, ...details }),
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,13 @@ import {
|
||||
type PhysicalAttemptState,
|
||||
} from "./http-effect-certainty.ts";
|
||||
import { parseRetryAfter } from "./retry-policy.ts";
|
||||
import {
|
||||
canRetryTransport,
|
||||
isRetryableHttpStatus,
|
||||
isRetryableSemantics,
|
||||
jitteredDelay,
|
||||
retryDelayFor,
|
||||
} from "./http-retry-lifecycle.ts";
|
||||
|
||||
/**
|
||||
* §7–§8. Descriptor-driven HTTP execution.
|
||||
@@ -269,6 +276,17 @@ export type ContractHttpExecutorDependencies = Readonly<{
|
||||
baseUrl: string;
|
||||
/** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */
|
||||
maxRetryAttempts: number;
|
||||
/**
|
||||
* §6.1 / §8.5. `REQUEST_TIMEOUT_MS` from Runtime Config, as a ceiling only.
|
||||
*
|
||||
* The contract owns each operation's deadline, because the deadline is part
|
||||
* of what the operation promises. A deployment still has to be able to hold
|
||||
* the whole app to something stricter than the sum of its contracts, so this
|
||||
* value may only shorten a deadline, never extend one — the same direction
|
||||
* `CAPABILITY_OVERRIDES` is allowed to move in. Absent, contracts stand
|
||||
* exactly as written.
|
||||
*/
|
||||
requestDeadlineCeilingMs?: number;
|
||||
/** The installed profile registry; the executor never invents a profile. */
|
||||
authProfiles?: InstalledRestAuthProfiles;
|
||||
attachCredentials(
|
||||
@@ -288,14 +306,6 @@ export type ContractHttpExecutorDependencies = Readonly<{
|
||||
observe?: (observation: HttpExecutionObservation) => void;
|
||||
}>;
|
||||
|
||||
const RETRYABLE_STATUSES: ReadonlySet<number> = new Set([
|
||||
408, 425, 429, 502, 503, 504,
|
||||
]);
|
||||
|
||||
const RETRY_BASE_DELAY_MS = 250;
|
||||
const RETRY_MAX_LOCAL_DELAY_MS = 2_000;
|
||||
const RETRY_AFTER_CEILING_MS = 5_000;
|
||||
|
||||
type MutationIntentValidation =
|
||||
| Readonly<{ ok: true; intent?: MutationIntent }>
|
||||
| Readonly<{
|
||||
@@ -373,6 +383,13 @@ export function createContractHttpExecutor(
|
||||
dependencies.readBoundedResponseBytes ?? readBoundedBytes;
|
||||
const now = dependencies.monotonicNow ?? (() => performance.now());
|
||||
const random = dependencies.random ?? Math.random;
|
||||
const deadlineCeilingMs = dependencies.requestDeadlineCeilingMs;
|
||||
const effectiveDeadlineMs = (contractDeadlineMs: number): number =>
|
||||
typeof deadlineCeilingMs === "number" &&
|
||||
Number.isFinite(deadlineCeilingMs) &&
|
||||
deadlineCeilingMs > 0
|
||||
? Math.min(contractDeadlineMs, deadlineCeilingMs)
|
||||
: contractDeadlineMs;
|
||||
const sleep =
|
||||
dependencies.sleep ??
|
||||
((ms: number, signal: AbortSignal) =>
|
||||
@@ -400,7 +417,8 @@ export function createContractHttpExecutor(
|
||||
// §8.5. One monotonic deadline covers credential resolution, encoding,
|
||||
// backoff, every physical attempt, body read and validation.
|
||||
const startedAt = now();
|
||||
const deadlineAt = startedAt + policy.totalDeadlineMs;
|
||||
const totalDeadlineMs = effectiveDeadlineMs(policy.totalDeadlineMs);
|
||||
const deadlineAt = startedAt + totalDeadlineMs;
|
||||
const remaining = () => deadlineAt - now();
|
||||
|
||||
let attemptState: PhysicalAttemptState = "PREPARING";
|
||||
@@ -451,7 +469,7 @@ export function createContractHttpExecutor(
|
||||
const lifetimeDeadlineTimer = setTimeout(() => {
|
||||
terminalCancellation ??= "DEADLINE";
|
||||
lifetimeController.abort();
|
||||
}, policy.totalDeadlineMs);
|
||||
}, totalDeadlineMs);
|
||||
let lifetimeDisposed = false;
|
||||
const disposeLifetime = () => {
|
||||
if (lifetimeDisposed) return;
|
||||
@@ -1328,7 +1346,7 @@ async function admitProblem<Input, WireOutput, Problem>(
|
||||
): Promise<AdmissionOutcome<WireOutput, Problem>> {
|
||||
const contract = operation.contract;
|
||||
const isCommand = contract.commandEffect !== null;
|
||||
const retryable = RETRYABLE_STATUSES.has(status);
|
||||
const retryable = isRetryableHttpStatus(status);
|
||||
|
||||
const bytes = await readResponseBytes(
|
||||
response,
|
||||
@@ -1415,56 +1433,6 @@ async function admitProblem<Input, WireOutput, Problem>(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.3. `SAFE` and `IDEMPOTENT` may replay the same frozen request. `KEYED`
|
||||
* must not automatically retry once an attempt was dispatched and its response
|
||||
* was lost; that path goes to inspect/reconciliation instead. `NEVER` is zero.
|
||||
*/
|
||||
function canRetryTransport(
|
||||
semantics: InstalledHttpContract<
|
||||
unknown,
|
||||
unknown,
|
||||
unknown
|
||||
>["contract"]["retrySemantics"],
|
||||
attemptState: PhysicalAttemptState,
|
||||
): boolean {
|
||||
if (semantics === "NEVER") return false;
|
||||
if (semantics === "KEYED") {
|
||||
return attemptState === "PREPARING" || attemptState === "READY_TO_SEND";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isRetryableSemantics(
|
||||
semantics: InstalledHttpContract<
|
||||
unknown,
|
||||
unknown,
|
||||
unknown
|
||||
>["contract"]["retrySemantics"],
|
||||
): boolean {
|
||||
return semantics === "SAFE" || semantics === "IDEMPOTENT";
|
||||
}
|
||||
|
||||
/** §8.2. Full jitter over `min(2000, 250 * 2^index)`. */
|
||||
function jitteredDelay(retryIndex: number, random: () => number): number {
|
||||
const ceiling = Math.min(
|
||||
RETRY_MAX_LOCAL_DELAY_MS,
|
||||
RETRY_BASE_DELAY_MS * 2 ** retryIndex,
|
||||
);
|
||||
return Math.floor(random() * ceiling);
|
||||
}
|
||||
|
||||
function retryDelayFor(
|
||||
retryAfterMs: number | null,
|
||||
retryIndex: number,
|
||||
random: () => number,
|
||||
): number | null {
|
||||
const local = jitteredDelay(retryIndex, random);
|
||||
if (retryAfterMs === null) return local;
|
||||
if (retryAfterMs > RETRY_AFTER_CEILING_MS) return null;
|
||||
return Math.max(local, retryAfterMs);
|
||||
}
|
||||
|
||||
/** §7.12. No raw header map, URL, cookie, traceparent or ETag value escapes. */
|
||||
function safeMetadata(
|
||||
response: Response,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { RetrySemantics } from "../../contracts/external-contract-runtime.ts";
|
||||
import type { PhysicalAttemptState } from "./http-effect-certainty.ts";
|
||||
|
||||
const RETRYABLE_STATUSES: ReadonlySet<number> = new Set([
|
||||
408, 425, 429, 502, 503, 504,
|
||||
]);
|
||||
const RETRY_BASE_DELAY_MS = 250;
|
||||
const RETRY_MAX_LOCAL_DELAY_MS = 2_000;
|
||||
const RETRY_AFTER_CEILING_MS = 5_000;
|
||||
|
||||
export function isRetryableHttpStatus(status: number): boolean {
|
||||
return RETRYABLE_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport replay authority.
|
||||
*
|
||||
* KEYED commands may retry only before a physical dispatch. Once dispatched,
|
||||
* an uncertain result belongs to reconciliation rather than automatic replay.
|
||||
*/
|
||||
export function canRetryTransport(
|
||||
semantics: RetrySemantics,
|
||||
attemptState: PhysicalAttemptState,
|
||||
): boolean {
|
||||
if (semantics === "NEVER") return false;
|
||||
if (semantics === "KEYED") {
|
||||
return attemptState === "PREPARING" || attemptState === "READY_TO_SEND";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isRetryableSemantics(semantics: RetrySemantics): boolean {
|
||||
return semantics === "SAFE" || semantics === "IDEMPOTENT";
|
||||
}
|
||||
|
||||
/** Full jitter over min(2000, 250 * 2^retryIndex). */
|
||||
export function jitteredDelay(
|
||||
retryIndex: number,
|
||||
random: () => number,
|
||||
): number {
|
||||
const ceiling = Math.min(
|
||||
RETRY_MAX_LOCAL_DELAY_MS,
|
||||
RETRY_BASE_DELAY_MS * 2 ** retryIndex,
|
||||
);
|
||||
return Math.floor(random() * ceiling);
|
||||
}
|
||||
|
||||
export function retryDelayFor(
|
||||
retryAfterMs: number | null,
|
||||
retryIndex: number,
|
||||
random: () => number,
|
||||
): number | null {
|
||||
const local = jitteredDelay(retryIndex, random);
|
||||
if (retryAfterMs === null) return local;
|
||||
if (retryAfterMs > RETRY_AFTER_CEILING_MS) return null;
|
||||
return Math.max(local, retryAfterMs);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* §7–§8. 권장 경로는 V3 계약 실행기(`createContractHttpExecutor`)다. 설치된
|
||||
* 계약과 타입 입력을 받아 상한·전체 데드라인·재시도 권한·효과 확실성 판정을
|
||||
* 런타임이 소유한다.
|
||||
*/
|
||||
export {
|
||||
createContractHttpExecutor,
|
||||
type AuthIntegrationFailureReason,
|
||||
type AuthOperationContext,
|
||||
type CancellationOwner,
|
||||
type ContractHttpExecutor,
|
||||
type ContractHttpExecutorDependencies,
|
||||
type HttpContractViolation,
|
||||
type HttpContractViolationKind,
|
||||
type HttpEffectCertainty,
|
||||
type HttpExecutionContext,
|
||||
type HttpExecutionObservation,
|
||||
type HttpExecutionOutcome,
|
||||
type HttpTransportFailure,
|
||||
type SafeResponseMetadata,
|
||||
} from "./http-execution-v3.ts";
|
||||
export {
|
||||
createFeatureHttpBinding,
|
||||
defineFeatureHttpOperation,
|
||||
defineFeatureHttpOperationForRoutes,
|
||||
type FeatureHttpBinding,
|
||||
type FeatureHttpOperation,
|
||||
type FeatureHttpProblemMapping,
|
||||
type HttpContractInput,
|
||||
type HttpContractOperationId,
|
||||
type HttpContractProblem,
|
||||
type HttpContractWireOutput,
|
||||
type InstalledHttpOperationExecutor,
|
||||
} from "./feature-http-binding.ts";
|
||||
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
|
||||
export type { CredentialPatchOutcome } from "./http-contract-bridge.ts";
|
||||
/**
|
||||
* V2 legacy. operationId + `LegacyHttpInput`으로 호출하는 범용 클라이언트다.
|
||||
* 새 코드는 위의 V3 실행기를 쓴다.
|
||||
*
|
||||
* 이 템플릿은 V2의 오퍼레이션 레지스트리와 payload 매퍼를 비워서 출하한다.
|
||||
* 제품이 `RuntimeHttpContract`(`src/bootstrap/runtime-adapters.ts`)의 네
|
||||
* 슬롯을 주입해야 동작한다 — 주입 없이 V3에서 되돌리면 모든 요청이 실패한다.
|
||||
*/
|
||||
export {
|
||||
createHttpClient,
|
||||
type HttpClient,
|
||||
type HttpClientDependencies,
|
||||
type HttpFailure,
|
||||
type HttpResult,
|
||||
type LegacyHttpInput,
|
||||
type Scheduler,
|
||||
} from "./client.ts";
|
||||
/** V2 `HttpClient.execute`의 첫 인자 타입. */
|
||||
export type { OperationRequestInput } from "./request-builder.ts";
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* N-11. A non-finite or fractional capacity silently disables eviction, so it is
|
||||
* a construction-time configuration error rather than a runtime drop.
|
||||
*
|
||||
* This lives in the adapter kernel rather than inside the telemetry adapter:
|
||||
* the diagnostics adapter needs the same guard, and importing it from telemetry
|
||||
* made one concrete adapter depend on another for a rule that belongs to
|
||||
* neither of them.
|
||||
*/
|
||||
export function assertBoundedCapacity(
|
||||
value: number,
|
||||
ceiling: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) {
|
||||
throw new TypeError(
|
||||
`${label} must be a safe integer between 1 and ${ceiling}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 어댑터 커널의 공개 경계.
|
||||
*
|
||||
* `src/adapters/**` 안에서는 이 배럴을 쓰지 않는다. 커널 프리미티브는 파일
|
||||
* 경로로 직접 import한다 — `scripts/check-adapter-inventory.ts:63-83`이
|
||||
* `platform/abortable-operation.ts`로 해석되는 specifier를 네 소비자에게
|
||||
* 요구하고, `.dependency-cruiser.json`의 kernel carve-out도 폴더 단위다.
|
||||
* 이 배럴은 bootstrap·features·tests 같은 그룹 바깥 소비자를 위한 문이다.
|
||||
*/
|
||||
export {
|
||||
compensateLateHandle,
|
||||
createAbortableOperation,
|
||||
snapshotAbortTimers,
|
||||
type AbortableOperation,
|
||||
type AbortableOperationInput,
|
||||
type AbortRace,
|
||||
type AbortTerminalReason,
|
||||
type AbortTimerSnapshot,
|
||||
} from "./abortable-operation.ts";
|
||||
export { assertBoundedCapacity } from "./bounded-capacity.ts";
|
||||
export {
|
||||
createBrowserLifecycleRuntime,
|
||||
type BrowserLifecycleEvent,
|
||||
type BrowserLifecycleRuntime,
|
||||
type BrowserLifecycleSnapshot,
|
||||
} from "./browser-lifecycle.ts";
|
||||
export {
|
||||
createBrowserMutationIntentFactory,
|
||||
type BrowserMutationIntentFactoryDependencies,
|
||||
} from "./browser-mutation-intent-factory.ts";
|
||||
export { systemClock } from "./system-clock.ts";
|
||||
@@ -0,0 +1,699 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* What this module owns: event wiring, the settle-once latch, the blocked
|
||||
* deadline and the cached-handle lifecycle. What it must never own: database
|
||||
* names, schemas, migrations, governance bindings, codecs, byte budgets,
|
||||
* retention, idempotency receipts, failure code tables, operation labels,
|
||||
* observation event shapes, durability defaults and budget criteria. Wanting to
|
||||
* move one of those in here is the signal to stop.
|
||||
*/
|
||||
|
||||
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.
|
||||
* The kernel never raises this itself — the factory is a required parameter,
|
||||
* so the condition can only exist before the kernel is called. It is part of
|
||||
* the vocabulary so a caller that resolves those globals has one place to
|
||||
* name the failure instead of a second taxonomy beside `translate`.
|
||||
*/
|
||||
| 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;
|
||||
}>;
|
||||
|
||||
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 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;
|
||||
}>;
|
||||
|
||||
function succeeded<Value, Failure>(value: Value): Result<Value, Failure> {
|
||||
return Object.freeze({ ok: true as const, value });
|
||||
}
|
||||
|
||||
function failed<Value, Failure>(error: Failure): Result<Value, Failure> {
|
||||
return Object.freeze({ ok: false as const, error });
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection that lost a race is closed, never leaked. The close itself is
|
||||
* best effort: a handle the browser already tore down cannot be closed again,
|
||||
* and that must not replace the outcome the caller was given.
|
||||
*/
|
||||
function closeQuietly(database: IDBDatabase): void {
|
||||
try {
|
||||
database.close();
|
||||
} catch {
|
||||
// A connection that cannot be closed is already gone.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A positive deadline with no scheduler leaves the open unbounded, which is the
|
||||
* same class of configuration defect `assertBoundedCapacity` and
|
||||
* `snapshotAbortTimers` reject at construction rather than at runtime. It is a
|
||||
* throw instead of a `Failure` because no `translate` could describe it without
|
||||
* the caller first deciding it is acceptable to run unbounded.
|
||||
*/
|
||||
function assertBlockedDeadline(
|
||||
blockedTimeoutMs: number | undefined,
|
||||
timers: AbortTimerSnapshot | undefined,
|
||||
): boolean {
|
||||
const bounded =
|
||||
blockedTimeoutMs !== undefined &&
|
||||
Number.isFinite(blockedTimeoutMs) &&
|
||||
blockedTimeoutMs > 0;
|
||||
if (bounded && !timers) {
|
||||
throw new TypeError(
|
||||
"A positive blockedTimeoutMs requires a timer snapshot.",
|
||||
);
|
||||
}
|
||||
return bounded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>> {
|
||||
const { factory, databaseName, translate } = input;
|
||||
const bounded = assertBlockedDeadline(input.blockedTimeoutMs, input.timers);
|
||||
if (input.signal?.aborted) {
|
||||
return Promise.resolve(failed(translate({ kind: "CALLER_ABORT" })));
|
||||
}
|
||||
|
||||
return new Promise<Result<IDBDatabase, Failure>>((resolve) => {
|
||||
let settled = false;
|
||||
let blockedTimer: unknown;
|
||||
let blockedTimerSet = false;
|
||||
let upgradeRejection: IndexedDbFailureCause | null = null;
|
||||
|
||||
const settle = (result: Result<IDBDatabase, Failure>) => {
|
||||
if (settled) {
|
||||
if (result.ok) closeQuietly(result.value);
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (blockedTimerSet) {
|
||||
blockedTimerSet = false;
|
||||
try {
|
||||
input.timers?.clearTimer(blockedTimer);
|
||||
} catch {
|
||||
// A throwing scheduler cannot keep the open unresolved.
|
||||
}
|
||||
}
|
||||
try {
|
||||
input.signal?.removeEventListener("abort", onCallerAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block cleanup.
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const settleFailure = (cause: IndexedDbFailureCause) => {
|
||||
settle(failed(translate(cause)));
|
||||
};
|
||||
|
||||
let request: IDBOpenDBRequest;
|
||||
try {
|
||||
request =
|
||||
input.version === undefined
|
||||
? factory.open(databaseName)
|
||||
: factory.open(databaseName, input.version);
|
||||
} catch (error) {
|
||||
settleFailure({ kind: "NATIVE_EXCEPTION", error });
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* An upgrade transaction is the only cancellable part of an open request:
|
||||
* aborting it makes the request fail through `onerror`, and there is no
|
||||
* other way to stop a dispatched open.
|
||||
*/
|
||||
const abortUpgrade = () => {
|
||||
try {
|
||||
request.transaction?.abort();
|
||||
} catch {
|
||||
// The request's own error path owns whatever happens next.
|
||||
}
|
||||
};
|
||||
|
||||
function onCallerAbort(): void {
|
||||
abortUpgrade();
|
||||
settleFailure({ kind: "CALLER_ABORT" });
|
||||
}
|
||||
input.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const transaction = request.transaction;
|
||||
const oldVersion = event.oldVersion;
|
||||
const newVersion = event.newVersion;
|
||||
// A null `newVersion` means a delete is in progress, and no `upgrade`
|
||||
// policy can be applied to a schema that is going away. It is reported
|
||||
// before the callback runs so a caller never sees a half-open upgrade.
|
||||
if (!transaction || newVersion === null || !input.upgrade) {
|
||||
upgradeRejection = { kind: "UPGRADE_REJECTED", oldVersion, newVersion };
|
||||
abortUpgrade();
|
||||
return;
|
||||
}
|
||||
let outcome: IndexedDbUpgradeOutcome;
|
||||
try {
|
||||
outcome = input.upgrade({
|
||||
database: request.result,
|
||||
transaction,
|
||||
oldVersion,
|
||||
newVersion,
|
||||
});
|
||||
} catch (error) {
|
||||
outcome = { kind: "REJECTED", detail: error };
|
||||
}
|
||||
if (outcome.kind === "APPLIED") return;
|
||||
upgradeRejection = {
|
||||
kind: "UPGRADE_REJECTED",
|
||||
oldVersion,
|
||||
newVersion,
|
||||
detail: outcome.detail,
|
||||
};
|
||||
abortUpgrade();
|
||||
};
|
||||
|
||||
request.onblocked = (event) => {
|
||||
const blocked = Object.freeze({
|
||||
oldVersion: event.oldVersion,
|
||||
newVersion: event.newVersion,
|
||||
});
|
||||
if (input.onBlocked) {
|
||||
try {
|
||||
input.onBlocked(blocked);
|
||||
} catch {
|
||||
// Observation cannot change the outcome.
|
||||
}
|
||||
}
|
||||
if (!bounded) {
|
||||
settleFailure({ kind: "BLOCKED", ...blocked });
|
||||
return;
|
||||
}
|
||||
if (blockedTimerSet || settled) return;
|
||||
try {
|
||||
blockedTimer = input.timers?.setTimer(() => {
|
||||
settleFailure({ kind: "BLOCKED_DEADLINE" });
|
||||
}, input.blockedTimeoutMs as number);
|
||||
blockedTimerSet = true;
|
||||
} catch {
|
||||
// A scheduler that cannot install the deadline leaves the open
|
||||
// unbounded, so the deadline is treated as already elapsed.
|
||||
settleFailure({ kind: "BLOCKED_DEADLINE" });
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
settleFailure(
|
||||
upgradeRejection ?? { kind: "NATIVE_EXCEPTION", error: request.error },
|
||||
);
|
||||
};
|
||||
|
||||
const routeAdmission = (
|
||||
database: IDBDatabase,
|
||||
admission: IndexedDbAdmission,
|
||||
) => {
|
||||
if (settled) {
|
||||
closeQuietly(database);
|
||||
return;
|
||||
}
|
||||
if (admission.kind === "ADMIT") {
|
||||
settle(succeeded(database));
|
||||
return;
|
||||
}
|
||||
// A connection the caller will never see is closed before the failure is
|
||||
// reported, so a rejected admission cannot leak a live handle.
|
||||
closeQuietly(database);
|
||||
settleFailure(
|
||||
admission.kind === "REJECT"
|
||||
? { kind: "ADMISSION_REJECTED", detail: admission.detail }
|
||||
: admission.cause,
|
||||
);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
const database = request.result;
|
||||
if (settled) {
|
||||
closeQuietly(database);
|
||||
return;
|
||||
}
|
||||
if (!input.admit) {
|
||||
settle(succeeded(database));
|
||||
return;
|
||||
}
|
||||
let admission: IndexedDbAdmission | Promise<IndexedDbAdmission>;
|
||||
try {
|
||||
admission = input.admit(database);
|
||||
} catch (error) {
|
||||
closeQuietly(database);
|
||||
settleFailure({ kind: "NATIVE_EXCEPTION", error });
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(admission).then(
|
||||
(resolved) => routeAdmission(database, resolved),
|
||||
(error: unknown) => {
|
||||
closeQuietly(database);
|
||||
settleFailure({ kind: "NATIVE_EXCEPTION", error });
|
||||
},
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createIndexedDbConnection<Failure>(
|
||||
input: IndexedDbConnectionInput<Failure>,
|
||||
): IndexedDbConnection<Failure> {
|
||||
let connection: IDBDatabase | null = null;
|
||||
let attempt: Promise<Result<IDBDatabase, Failure>> | null = null;
|
||||
let controller: AbortController | null = null;
|
||||
let closed = false;
|
||||
const closeWaiters = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* The browser is taking the connection back. The cached handle is dropped
|
||||
* before the subsystem is told, so a notification callback cannot hand out a
|
||||
* connection that is already gone. The next `acquire()` opens again.
|
||||
*/
|
||||
const invalidate = (database: IDBDatabase, notify: () => void) => {
|
||||
if (connection !== database) return;
|
||||
connection = null;
|
||||
try {
|
||||
notify();
|
||||
} catch {
|
||||
// Losing the connection is independent from announcing it.
|
||||
}
|
||||
};
|
||||
|
||||
const adopt = (database: IDBDatabase): IDBDatabase => {
|
||||
database.onversionchange = (event) => {
|
||||
// The connection has to go for the other context's upgrade to proceed,
|
||||
// so it is closed here rather than left to a listener's discretion.
|
||||
closeQuietly(database);
|
||||
invalidate(database, () => input.onVersionChange?.(event));
|
||||
};
|
||||
database.onclose = () => {
|
||||
// `onclose` means the browser already tore the connection down, so there
|
||||
// is nothing left to close — only a cached handle to drop.
|
||||
invalidate(database, () => input.onForcedClose?.());
|
||||
};
|
||||
return database;
|
||||
};
|
||||
|
||||
const start = (): Promise<Result<IDBDatabase, Failure>> => {
|
||||
const openController = new AbortController();
|
||||
controller = openController;
|
||||
const started = input.open(openController.signal).then(
|
||||
(result) => {
|
||||
if (attempt === started) {
|
||||
attempt = null;
|
||||
controller = null;
|
||||
}
|
||||
if (!result.ok) return result;
|
||||
if (closed) {
|
||||
// The handle was closed while the open was in flight; the connection
|
||||
// that arrived belongs to nobody.
|
||||
closeQuietly(result.value);
|
||||
return result;
|
||||
}
|
||||
connection = adopt(result.value);
|
||||
return result;
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (attempt === started) {
|
||||
attempt = null;
|
||||
controller = null;
|
||||
}
|
||||
return failed<IDBDatabase, Failure>(
|
||||
input.translate({ kind: "NATIVE_EXCEPTION", error }),
|
||||
);
|
||||
},
|
||||
);
|
||||
attempt = started;
|
||||
return started;
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
acquire(signal) {
|
||||
if (closed) {
|
||||
return Promise.resolve(
|
||||
failed<IDBDatabase, Failure>(input.translate({ kind: "CLOSED" })),
|
||||
);
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve(
|
||||
failed<IDBDatabase, Failure>(
|
||||
input.translate({ kind: "CALLER_ABORT" }),
|
||||
),
|
||||
);
|
||||
}
|
||||
const live = connection;
|
||||
if (live) return Promise.resolve(succeeded<IDBDatabase, Failure>(live));
|
||||
|
||||
const shared = attempt ?? start();
|
||||
// The shared open is not cancelled by one caller giving up: another
|
||||
// caller may still want the connection, and the request cannot be
|
||||
// un-dispatched anyway.
|
||||
return new Promise<Result<IDBDatabase, Failure>>((resolve) => {
|
||||
let callerSettled = false;
|
||||
const finishCaller = (result: Result<IDBDatabase, Failure>) => {
|
||||
if (callerSettled) return;
|
||||
callerSettled = true;
|
||||
try {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block cleanup.
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
function onAbort(): void {
|
||||
finishCaller(
|
||||
failed(input.translate({ kind: "CALLER_ABORT" })),
|
||||
);
|
||||
}
|
||||
function onClose(): void {
|
||||
finishCaller(failed(input.translate({ kind: "CLOSED" })));
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
closeWaiters.add(onClose);
|
||||
void shared.then(
|
||||
(result) => {
|
||||
closeWaiters.delete(onClose);
|
||||
// A handle closed mid-open reports CLOSED even if the open itself
|
||||
// succeeded: the connection is already gone.
|
||||
finishCaller(
|
||||
closed
|
||||
? failed(input.translate({ kind: "CLOSED" }))
|
||||
: result,
|
||||
);
|
||||
},
|
||||
() => {
|
||||
closeWaiters.delete(onClose);
|
||||
finishCaller(failed(input.translate({ kind: "CLOSED" })));
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
current: () => connection,
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
const live = connection;
|
||||
connection = null;
|
||||
if (live) closeQuietly(live);
|
||||
// Cancelling the in-flight open aborts a pending upgrade transaction, so
|
||||
// a closed handle does not leave a versionchange transaction running.
|
||||
try {
|
||||
controller?.abort();
|
||||
} catch {
|
||||
// A cancelled open still reports CLOSED to its waiters below.
|
||||
}
|
||||
controller = null;
|
||||
for (const waiter of [...closeWaiters]) {
|
||||
closeWaiters.delete(waiter);
|
||||
waiter();
|
||||
}
|
||||
},
|
||||
isClosed: () => closed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>> {
|
||||
const { factory, databaseName, translate } = input;
|
||||
const bounded = assertBlockedDeadline(input.blockedTimeoutMs, input.timers);
|
||||
|
||||
return new Promise<Result<IndexedDbDeleteOutcome, Failure>>((resolve) => {
|
||||
let settled = false;
|
||||
let notified = false;
|
||||
let blockedTimer: unknown;
|
||||
let blockedTimerSet = false;
|
||||
|
||||
/**
|
||||
* Independent of `settle`: a blocked deadline resolves the caller while the
|
||||
* request is still live, and the registration must be released when the
|
||||
* request actually lands, not when the caller stopped waiting.
|
||||
*/
|
||||
const notifySettled = () => {
|
||||
if (notified) return;
|
||||
notified = true;
|
||||
try {
|
||||
input.onSettled?.();
|
||||
} catch {
|
||||
// Releasing a registration cannot change the deletion outcome.
|
||||
}
|
||||
};
|
||||
|
||||
const settle = (result: Result<IndexedDbDeleteOutcome, Failure>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (blockedTimerSet) {
|
||||
blockedTimerSet = false;
|
||||
try {
|
||||
input.timers?.clearTimer(blockedTimer);
|
||||
} catch {
|
||||
// A throwing scheduler cannot keep the deletion unresolved.
|
||||
}
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
let request: IDBOpenDBRequest;
|
||||
try {
|
||||
request = factory.deleteDatabase(databaseName);
|
||||
} catch (error) {
|
||||
notifySettled();
|
||||
settle(failed(translate({ kind: "NATIVE_EXCEPTION", error })));
|
||||
return;
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
notifySettled();
|
||||
settle(succeeded({ kind: "DELETED" as const }));
|
||||
};
|
||||
request.onerror = () => {
|
||||
notifySettled();
|
||||
settle(
|
||||
failed(translate({ kind: "NATIVE_EXCEPTION", error: request.error })),
|
||||
);
|
||||
};
|
||||
request.onblocked = (event) => {
|
||||
if (!bounded) {
|
||||
settle(
|
||||
failed(
|
||||
translate({
|
||||
kind: "BLOCKED",
|
||||
oldVersion: event.oldVersion,
|
||||
newVersion: event.newVersion,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (blockedTimerSet || settled) return;
|
||||
try {
|
||||
blockedTimer = input.timers?.setTimer(() => {
|
||||
settle(succeeded({ kind: "BLOCKED_DEADLINE" as const }));
|
||||
}, input.blockedTimeoutMs as number);
|
||||
blockedTimerSet = true;
|
||||
} catch {
|
||||
settle(succeeded({ kind: "BLOCKED_DEADLINE" as const }));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Like `indexeddb-connection.ts`, it owns no policy: not a store name, not a
|
||||
* durability default, not a budget criterion, not a failure code.
|
||||
*/
|
||||
|
||||
import type { Result } from "../../contracts/result.ts";
|
||||
import type { 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;
|
||||
}>;
|
||||
|
||||
/** 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, with the number of rows already handed to `visit`.
|
||||
* 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;
|
||||
}>;
|
||||
|
||||
function succeeded<Value, Failure>(value: Value): Result<Value, Failure> {
|
||||
return Object.freeze({ ok: true as const, value });
|
||||
}
|
||||
|
||||
function failed<Value, Failure>(error: Failure): Result<Value, Failure> {
|
||||
return Object.freeze({ ok: false as const, error });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const names = [...stores];
|
||||
// Not the same as `{durability:"default"}`: an engine that has never seen the
|
||||
// options bag treats the two differently, and one caller relies on that.
|
||||
if (durability === undefined) return database.transaction(names, mode);
|
||||
try {
|
||||
return database.transaction(names, mode, { durability });
|
||||
} catch (error) {
|
||||
// Only an engine that does not know the option answers with a TypeError.
|
||||
// Anything else — a closed connection, an unknown store — is a real error
|
||||
// and must not be retried into a second, differently shaped failure.
|
||||
if (error instanceof TypeError) return database.transaction(names, mode);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>> {
|
||||
const { translate } = input;
|
||||
if (input.signal?.aborted) {
|
||||
return Promise.resolve(failed(translate({ kind: "CALLER_ABORT" })));
|
||||
}
|
||||
|
||||
let transaction: IDBTransaction;
|
||||
try {
|
||||
transaction = openIndexedDbTransaction(
|
||||
input.database,
|
||||
input.stores,
|
||||
input.mode,
|
||||
input.durability,
|
||||
);
|
||||
} catch (error) {
|
||||
return Promise.resolve(failed(translate({ kind: "NATIVE_EXCEPTION", error })));
|
||||
}
|
||||
|
||||
return new Promise<Result<Value, Failure>>((resolve) => {
|
||||
let candidate: Result<Value, Failure> | undefined;
|
||||
let requestError: unknown;
|
||||
let hasRequestError = false;
|
||||
let callerAborted = false;
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: Result<Value, Failure>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
input.signal?.removeEventListener("abort", onCallerAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block cleanup.
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
function onCallerAbort(): void {
|
||||
const previous = callerAborted;
|
||||
callerAborted = true;
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// CP-4. The transaction may already be durably committed while its
|
||||
// completion event is still queued. Claiming the abort would report a
|
||||
// committed mutation as ABORTED, so the claim is withdrawn and the
|
||||
// transaction's own events decide.
|
||||
callerAborted = previous;
|
||||
}
|
||||
}
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
finish(candidate ?? failed(translate({ kind: "NO_VALUE_PRODUCED" })));
|
||||
};
|
||||
transaction.onerror = () => {
|
||||
// `onabort` is the terminal signal; this only captures the error that a
|
||||
// request left behind before the transaction unwinds.
|
||||
if (hasRequestError) return;
|
||||
requestError = transaction.error;
|
||||
hasRequestError = true;
|
||||
};
|
||||
transaction.onabort = () => {
|
||||
if (callerAborted) {
|
||||
finish(failed(translate({ kind: "CALLER_ABORT" })));
|
||||
return;
|
||||
}
|
||||
if (candidate && !candidate.ok) {
|
||||
finish(candidate);
|
||||
return;
|
||||
}
|
||||
// An abort never yields the value a `succeed` recorded: the transaction
|
||||
// did not commit, so the native error is what happened.
|
||||
finish(
|
||||
failed(
|
||||
translate({
|
||||
kind: "NATIVE_EXCEPTION",
|
||||
error: hasRequestError ? requestError : transaction.error,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
input.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
||||
|
||||
const context: IndexedDbTransactionContext<Value, Failure> = Object.freeze({
|
||||
transaction,
|
||||
succeed(value) {
|
||||
candidate ??= succeeded(value);
|
||||
},
|
||||
fail(failure) {
|
||||
candidate ??= failed(failure);
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// The transaction is already finished, so no abort event is coming.
|
||||
// The recorded failure is the outcome rather than a hang.
|
||||
finish(candidate);
|
||||
}
|
||||
},
|
||||
requestFailed(error) {
|
||||
if (!hasRequestError) {
|
||||
requestError = error;
|
||||
hasRequestError = true;
|
||||
}
|
||||
candidate ??= failed(translate({ kind: "NATIVE_EXCEPTION", error }));
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
input.queue(transaction, context);
|
||||
} catch (error) {
|
||||
context.fail(translate({ kind: "NATIVE_EXCEPTION", error }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
request.onsuccess = () => {
|
||||
onSuccess(request.result);
|
||||
};
|
||||
request.onerror = () => {
|
||||
sink.requestFailed(request.error);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const { request, sink, translate } = input;
|
||||
let scannedRows = 0;
|
||||
let finished = false;
|
||||
|
||||
const end = (reason: IndexedDbWalkSummary["reason"]) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
input.done(Object.freeze({ reason, scannedRows }));
|
||||
};
|
||||
const abandon = (failure: Failure) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
sink.fail(failure);
|
||||
};
|
||||
|
||||
const advance = (action: () => void) => {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
abandon(translate({ kind: "NATIVE_EXCEPTION", error }));
|
||||
}
|
||||
};
|
||||
|
||||
const applyStep = (
|
||||
cursor: IDBCursorWithValue,
|
||||
step: IndexedDbCursorStep,
|
||||
): void => {
|
||||
switch (step.kind) {
|
||||
case "CONTINUE":
|
||||
advance(() => cursor.continue());
|
||||
return;
|
||||
case "CONTINUE_FROM":
|
||||
advance(() => cursor.continue(step.key));
|
||||
return;
|
||||
case "CONTINUE_PRIMARY":
|
||||
advance(() => cursor.continuePrimaryKey(step.key, step.primaryKey));
|
||||
return;
|
||||
case "STOP":
|
||||
end("STOPPED");
|
||||
return;
|
||||
case "SUSPEND":
|
||||
// The visitor owns the cursor until it calls `resume`.
|
||||
return;
|
||||
default: {
|
||||
const exhaustive: never = step;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
// No `done`: the walk produced no summary, and the transaction's own
|
||||
// outcome decides what a failed advance means for this call site.
|
||||
sink.requestFailed(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
if (finished) return;
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
end("EXHAUSTED");
|
||||
return;
|
||||
}
|
||||
if (input.signal?.aborted) {
|
||||
// Aborting through the sink keeps one abort path instead of reaching for
|
||||
// a transaction the pump was never handed. `done` still runs so the
|
||||
// caller sees why the walk stopped; the recorded failure outranks any
|
||||
// value it produces there.
|
||||
finished = true;
|
||||
sink.fail(translate({ kind: "CALLER_ABORT" }));
|
||||
input.done(Object.freeze({ reason: "ABORTED" as const, scannedRows }));
|
||||
return;
|
||||
}
|
||||
if (input.budget) {
|
||||
const verdict = input.budget.admit(scannedRows);
|
||||
if (!verdict.ok) {
|
||||
abandon(verdict.error);
|
||||
return;
|
||||
}
|
||||
if (verdict.value === "ROW_BUDGET" || verdict.value === "TIME_BUDGET") {
|
||||
end(verdict.value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
scannedRows += 1;
|
||||
let suspended = false;
|
||||
let resumed = false;
|
||||
const resume = (step: IndexedDbCursorStep) => {
|
||||
if (!suspended || resumed || finished) return;
|
||||
resumed = true;
|
||||
applyStep(cursor, step);
|
||||
};
|
||||
|
||||
let step: IndexedDbCursorStep;
|
||||
try {
|
||||
step = input.visit(
|
||||
Object.freeze({ cursor, scannedRows, resume }),
|
||||
);
|
||||
} catch (error) {
|
||||
// A visitor defect must not let a partially applied write commit, so it
|
||||
// aborts rather than merely being recorded.
|
||||
abandon(translate({ kind: "NATIVE_EXCEPTION", error }));
|
||||
return;
|
||||
}
|
||||
if (step.kind === "SUSPEND") suspended = true;
|
||||
applyStep(cursor, step);
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { Result } from "../application/result.ts";
|
||||
import type { Result } from "../../contracts/result.ts";
|
||||
|
||||
export type CursorPage<Value> = Readonly<{
|
||||
items: readonly Value[];
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Result } from "../../application/result.ts";
|
||||
import type { Result } from "../../contracts/result.ts";
|
||||
import type {
|
||||
CursorPage,
|
||||
CursorPaginationProfile,
|
||||
CursorPaginationRuntime,
|
||||
} from "../../contracts/cursor-pagination.ts";
|
||||
} from "./cursor-pagination-contract.ts";
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
import { snapshotExactObject } from "../../contracts/exact-snapshot.ts";
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export {
|
||||
createConditionalValidatorStore,
|
||||
type ConditionalValidatorBinding,
|
||||
type ConditionalValidatorStore,
|
||||
} from "./conditional-validator-store.ts";
|
||||
export { createCursorPaginationRuntime } from "./cursor-pagination-runtime.ts";
|
||||
export type {
|
||||
CursorPage,
|
||||
CursorPaginationProfile,
|
||||
CursorPaginationRuntime,
|
||||
} from "./cursor-pagination-contract.ts";
|
||||
export {
|
||||
createServerStateScopeRuntime,
|
||||
type ScopeResetParticipant,
|
||||
type ServerStateScopeDependencies,
|
||||
} from "./server-state-scope-runtime.ts";
|
||||
export {
|
||||
createTanStackCacheCoordinator,
|
||||
type TanStackCacheCoordinatorDependencies,
|
||||
} from "./tanstack-cache-coordinator.ts";
|
||||
export {
|
||||
createQueryCacheAdapter,
|
||||
createQueryClient,
|
||||
QUERY_CACHE_DEFAULTS,
|
||||
type QueryCacheDependencies,
|
||||
} from "./tanstack-query-cache.ts";
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 이 배럴은 그룹 바깥(bootstrap, tests)을 위한 문이다. 워커 realm 진입점
|
||||
* `service-worker-entry.ts`는 이 배럴을 쓰지 않고 파일을 직접 import한다.
|
||||
*
|
||||
* 그래서 `tsconfig.service-worker.json`의 `exclude`에 이 파일이 들어 있다.
|
||||
* 그 설정은 `src/adapters/service-worker` 폴더를 통째로 WebWorker lib로
|
||||
* 컴파일하면서 페이지 realm 파일(`service-worker-page-controller.ts`,
|
||||
* `service-worker-removal.ts`)만 빼는 구조다. 배럴이 제외되지 않으면 그
|
||||
* 페이지 realm 파일을 다시 끌어들여 `document`를 찾지 못한다.
|
||||
* 타입 커버리지는 `tsconfig.app.json`이 이 배럴을 포함하므로 유지된다.
|
||||
*
|
||||
* `service-worker-entry.ts`도 여기서 참조하지 않는다. `tsconfig.app.json:18`이
|
||||
* 제외한 파일이고, export가 0개이므로 넣을 것도 없다.
|
||||
*/
|
||||
export {
|
||||
createServiceWorkerRuntime,
|
||||
type WorkerClientLike,
|
||||
type WorkerRuntimeConfig,
|
||||
type WorkerScopeLike,
|
||||
} from "./service-worker-lifecycle.ts";
|
||||
export {
|
||||
createServiceWorkerPageController,
|
||||
type ActivationBlocker,
|
||||
type PageControllerDependencies,
|
||||
} from "./service-worker-page-controller.ts";
|
||||
export {
|
||||
createNonceRegistry,
|
||||
createServiceWorkerMessage,
|
||||
parseServiceWorkerMessage,
|
||||
type ParsedMessage,
|
||||
} from "./service-worker-protocol.ts";
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 최상위 storage 배럴은 Web Storage 어댑터만 내보낸다.
|
||||
*
|
||||
* `./indexeddb/index.ts`와 `./opfs/index.ts`를 여기서 재수출하지 말 것.
|
||||
* `scripts/test-browser-file-storage-runtime-removal.ts:23-34`가 그 두 폴더만
|
||||
* 삭제한 뒤 잔존 import를 예외로 잡는다 — 재수출하면 제거 드릴이 죽는다.
|
||||
* 두 서브배럴이 각자 제거 가능한 런타임의 경계다.
|
||||
*/
|
||||
export {
|
||||
createBrowserStorageAdapter,
|
||||
type BrowserStorageDependencies,
|
||||
} from "./browser-storage-adapter.ts";
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
IndexedDbReceiptPruneBatchReceipt,
|
||||
} from "../../../application/ports/browser-file-storage/indexeddb-port.ts";
|
||||
import type {
|
||||
BrowserDataFailure,
|
||||
BrowserDataResult,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
@@ -12,6 +13,17 @@ import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import {
|
||||
openIndexedDbDatabase,
|
||||
type IndexedDbTranslate,
|
||||
} from "../../platform/indexeddb-connection.ts";
|
||||
import {
|
||||
openIndexedDbTransaction,
|
||||
runIndexedDbTransaction,
|
||||
walkIndexedDbCursor,
|
||||
type IndexedDbBudget,
|
||||
type IndexedDbTransactionContext,
|
||||
} from "../../platform/indexeddb-transaction.ts";
|
||||
import { mapIndexedDbException } from "./indexeddb-failure.ts";
|
||||
import {
|
||||
createIndexedDbDatasetBinding,
|
||||
@@ -94,12 +106,6 @@ type PreparedRecord<WireValue> = Readonly<{
|
||||
measuredBytes: number | undefined;
|
||||
}>;
|
||||
|
||||
type TransactionContext<Value> = Readonly<{
|
||||
succeed(value: Value): void;
|
||||
fail(result: BrowserDataResult<never>): void;
|
||||
requestFailed(error: unknown): void;
|
||||
}>;
|
||||
|
||||
const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
||||
const OPAQUE_SHA256_FINGERPRINT = /^[a-f0-9]{64}$/u;
|
||||
const MAX_BATCH_ROWS = 500;
|
||||
@@ -261,6 +267,41 @@ function unavailable(): BrowserDataResult<never> {
|
||||
});
|
||||
}
|
||||
|
||||
function unsupported(): BrowserDataResult<never> {
|
||||
return browserDataFailure("UNSUPPORTED", "INDEXEDDB_MIGRATE", {
|
||||
recovery: "ONLINE_ONLY",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `browserDataFailure` and `mapIndexedDbException` build a `Result`, while the
|
||||
* kernel's `translate` and `context.fail` want the failure on its own. Both
|
||||
* only ever build the failure arm, so the branch below is a narrowing rather
|
||||
* than a claim.
|
||||
*/
|
||||
function failureOf(result: BrowserDataResult<never>): BrowserDataFailure {
|
||||
if (result.ok) {
|
||||
throw new TypeError("A browser data failure was expected.");
|
||||
}
|
||||
return result.error;
|
||||
}
|
||||
|
||||
function invalidInputFailure(): BrowserDataFailure {
|
||||
return failureOf(invalidInput());
|
||||
}
|
||||
|
||||
function migrationFailure(): BrowserDataFailure {
|
||||
return failureOf(migrationFailed());
|
||||
}
|
||||
|
||||
function unsupportedFailure(): BrowserDataFailure {
|
||||
return failureOf(unsupported());
|
||||
}
|
||||
|
||||
function mappedFailure(error: unknown): BrowserDataFailure {
|
||||
return failureOf(mapIndexedDbException(error, "INDEXEDDB_MIGRATE"));
|
||||
}
|
||||
|
||||
function defaultNow(): number {
|
||||
return typeof globalThis.performance === "undefined"
|
||||
? Date.now()
|
||||
@@ -420,6 +461,59 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STO-06. Maintenance keeps `mapIndexedDbException`, which the runtime and
|
||||
* the OPFS journal share, rather than the checkpoint store's own table: the
|
||||
* same native error is a different answer in the two families, and unifying
|
||||
* them is a separate change from moving the mechanics onto the kernel.
|
||||
*
|
||||
* One translator covers the whole adapter because the operation label is the
|
||||
* constant `INDEXEDDB_MIGRATE` here — the runtime needs one per operation.
|
||||
*/
|
||||
const translate: IndexedDbTranslate<BrowserDataFailure> = (cause) => {
|
||||
switch (cause.kind) {
|
||||
case "NATIVE_EXCEPTION":
|
||||
return mappedFailure(cause.error);
|
||||
case "BLOCKED":
|
||||
case "BLOCKED_DEADLINE":
|
||||
return failureOf(
|
||||
browserDataFailure("BLOCKED", "INDEXEDDB_MIGRATE", {
|
||||
retryable: true,
|
||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||
}),
|
||||
);
|
||||
case "CALLER_ABORT":
|
||||
return failureOf(
|
||||
browserDataFailure("ABORTED", "INDEXEDDB_MIGRATE"),
|
||||
);
|
||||
case "CLOSED":
|
||||
case "NO_VALUE_PRODUCED":
|
||||
// A transaction that committed without producing a value is retried,
|
||||
// not reconciled. `CLOSED` cannot reach here — maintenance opens a
|
||||
// connection per batch instead of holding a cached handle — and is
|
||||
// mapped alongside it so the union stays total.
|
||||
return failureOf(unavailable());
|
||||
case "UPGRADE_REJECTED":
|
||||
// Maintenance owns no schema. Any upgrade means the database is not
|
||||
// the one this batch was configured against.
|
||||
return migrationFailure();
|
||||
case "ADMISSION_REJECTED":
|
||||
return cause.detail === "POLICY"
|
||||
? failureOf(
|
||||
browserDataFailure("POLICY_REJECTED", "INDEXEDDB_MIGRATE", {
|
||||
recovery: storagePolicySnapshot.unavailableFallback,
|
||||
}),
|
||||
)
|
||||
: migrationFailure();
|
||||
case "UNSUPPORTED":
|
||||
return unsupportedFailure();
|
||||
default: {
|
||||
const exhaustive: never = cause;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function validBatchInput(
|
||||
input: IndexedDbMaintenanceBatchInput,
|
||||
): boolean {
|
||||
@@ -436,173 +530,76 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
): Promise<BrowserDataResult<IDBDatabase>> {
|
||||
const cancelled = abortedResult(signal, "INDEXEDDB_MIGRATE");
|
||||
if (cancelled) return Promise.resolve(cancelled);
|
||||
if (!factory) {
|
||||
return Promise.resolve(
|
||||
browserDataFailure("UNSUPPORTED", "INDEXEDDB_MIGRATE", {
|
||||
recovery: "ONLINE_ONLY",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!factory) return Promise.resolve(unsupported());
|
||||
|
||||
return new Promise<BrowserDataResult<IDBDatabase>>((resolve) => {
|
||||
let request: IDBOpenDBRequest;
|
||||
try {
|
||||
request = factory.open(
|
||||
databaseName,
|
||||
dependencies.schemaVersion,
|
||||
);
|
||||
} catch (error) {
|
||||
resolve(mapIndexedDbException(error, "INDEXEDDB_MIGRATE"));
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
let unexpectedUpgrade = false;
|
||||
const finish = (result: BrowserDataResult<IDBDatabase>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(result);
|
||||
};
|
||||
function onAbort(): void {
|
||||
try {
|
||||
request.transaction?.abort();
|
||||
} catch {
|
||||
// A pending non-upgrade open request cannot be cancelled.
|
||||
}
|
||||
finish(browserDataFailure("ABORTED", "INDEXEDDB_MIGRATE"));
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
unexpectedUpgrade = true;
|
||||
try {
|
||||
request.transaction?.abort();
|
||||
} catch {
|
||||
// The error handler below owns the closed failure result.
|
||||
}
|
||||
};
|
||||
request.onblocked = () => {
|
||||
finish(
|
||||
browserDataFailure("BLOCKED", "INDEXEDDB_MIGRATE", {
|
||||
retryable: true,
|
||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||
}),
|
||||
);
|
||||
};
|
||||
request.onerror = () => {
|
||||
finish(
|
||||
unexpectedUpgrade
|
||||
? migrationFailed()
|
||||
: mapIndexedDbException(
|
||||
request.error,
|
||||
"INDEXEDDB_MIGRATE",
|
||||
),
|
||||
);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const database = request.result;
|
||||
if (settled) {
|
||||
database.close();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!database.objectStoreNames.contains(
|
||||
dependencies.recordStore,
|
||||
) ||
|
||||
!database.objectStoreNames.contains(
|
||||
dependencies.governanceStore,
|
||||
) ||
|
||||
!database.objectStoreNames.contains(
|
||||
dependencies.retentionStore,
|
||||
) ||
|
||||
!database.objectStoreNames.contains(
|
||||
dependencies.checkpointStore,
|
||||
) ||
|
||||
!database.objectStoreNames.contains(
|
||||
dependencies.idempotencyStore,
|
||||
)
|
||||
) {
|
||||
database.close();
|
||||
finish(migrationFailed());
|
||||
return;
|
||||
return openIndexedDbDatabase<BrowserDataFailure>({
|
||||
factory,
|
||||
databaseName,
|
||||
version: dependencies.schemaVersion,
|
||||
translate,
|
||||
signal,
|
||||
// No `blockedTimeoutMs`, and therefore no `timers`. The blocked event is
|
||||
// terminal for maintenance: a batch is an opt-in background pass, so
|
||||
// hanging it for a deadline in the hope another context goes away costs
|
||||
// more than reporting BLOCKED and letting the caller retry.
|
||||
//
|
||||
// No `upgrade` either. The kernel reads an omitted callback as "any
|
||||
// upgrade is unexpected" and rejects it, which is what maintenance has
|
||||
// always done: it migrates records under a schema somebody else owns and
|
||||
// must never create or change one.
|
||||
admit: async (database) => {
|
||||
for (const store of [
|
||||
dependencies.recordStore,
|
||||
dependencies.governanceStore,
|
||||
dependencies.retentionStore,
|
||||
dependencies.checkpointStore,
|
||||
dependencies.idempotencyStore,
|
||||
]) {
|
||||
if (!database.objectStoreNames.contains(store)) {
|
||||
return { kind: "REJECT", detail: "STORE" };
|
||||
}
|
||||
}
|
||||
try {
|
||||
const transaction = database.transaction(
|
||||
dependencies.idempotencyStore,
|
||||
openIndexedDbTransaction(
|
||||
database,
|
||||
[dependencies.idempotencyStore],
|
||||
"readonly",
|
||||
);
|
||||
transaction
|
||||
)
|
||||
.objectStore(dependencies.idempotencyStore)
|
||||
.index(dependencies.idempotencyExpiryIndex);
|
||||
} catch {
|
||||
database.close();
|
||||
finish(migrationFailed());
|
||||
return;
|
||||
return { kind: "REJECT", detail: "INDEX" };
|
||||
}
|
||||
// Maintenance never holds the connection past a batch, so the listener
|
||||
// belongs here rather than on a cached handle: `admit` runs on the
|
||||
// successful open path only, which is exactly the window the batch
|
||||
// owns the connection for.
|
||||
database.onversionchange = () => database.close();
|
||||
void (async () => {
|
||||
const binding = await verifyIndexedDbDatasetBinding(
|
||||
database,
|
||||
dependencies.governanceStore,
|
||||
expectedBinding,
|
||||
signal,
|
||||
);
|
||||
if (!binding.ok) {
|
||||
database.close();
|
||||
if (!settled) {
|
||||
finish(
|
||||
binding.reason === "ABORTED"
|
||||
? browserDataFailure(
|
||||
"ABORTED",
|
||||
"INDEXEDDB_MIGRATE",
|
||||
)
|
||||
: binding.reason === "NATIVE_ERROR"
|
||||
? mapIndexedDbException(
|
||||
binding.error,
|
||||
"INDEXEDDB_MIGRATE",
|
||||
)
|
||||
: browserDataFailure(
|
||||
"POLICY_REJECTED",
|
||||
"INDEXEDDB_MIGRATE",
|
||||
{
|
||||
recovery:
|
||||
storagePolicySnapshot.unavailableFallback,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (settled) {
|
||||
database.close();
|
||||
return;
|
||||
}
|
||||
finish(browserDataSuccess(database));
|
||||
})();
|
||||
};
|
||||
const binding = await verifyIndexedDbDatasetBinding(
|
||||
database,
|
||||
dependencies.governanceStore,
|
||||
expectedBinding,
|
||||
signal,
|
||||
);
|
||||
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" };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createTransaction(
|
||||
database: IDBDatabase,
|
||||
stores: readonly string[],
|
||||
mode: "readonly" | "readwrite",
|
||||
): IDBTransaction {
|
||||
const durability =
|
||||
mode === "readonly"
|
||||
? dependencies.durability?.read ?? "default"
|
||||
: dependencies.durability?.write ?? "strict";
|
||||
try {
|
||||
return database.transaction([...stores], mode, { durability });
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
return database.transaction([...stores], mode);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durability is named explicitly on both sides because maintenance rewrites
|
||||
* records: a write that is only queued when the tab goes away would leave a
|
||||
* checkpoint claiming rows that were never stored. The kernel falls back to
|
||||
* the no-options form on an engine that rejects the bag.
|
||||
*/
|
||||
function runTransaction<Value>(
|
||||
database: IDBDatabase,
|
||||
stores: readonly string[],
|
||||
@@ -610,97 +607,20 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
signal: AbortSignal | undefined,
|
||||
queue: (
|
||||
transaction: IDBTransaction,
|
||||
context: TransactionContext<Value>,
|
||||
context: IndexedDbTransactionContext<Value, BrowserDataFailure>,
|
||||
) => void,
|
||||
): Promise<BrowserDataResult<Value>> {
|
||||
const cancelled = abortedResult(signal, "INDEXEDDB_MIGRATE");
|
||||
if (cancelled) return Promise.resolve(cancelled);
|
||||
|
||||
let transaction: IDBTransaction;
|
||||
try {
|
||||
transaction = createTransaction(database, stores, mode);
|
||||
} catch (error) {
|
||||
return Promise.resolve(
|
||||
mapIndexedDbException(error, "INDEXEDDB_MIGRATE"),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<BrowserDataResult<Value>>((resolve) => {
|
||||
let candidate: BrowserDataResult<Value> | undefined;
|
||||
let requestError: unknown;
|
||||
let callerAborted = false;
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: BrowserDataResult<Value>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(result);
|
||||
};
|
||||
const abortTransaction = () => {
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// Completion or a prior abort already owns the result.
|
||||
}
|
||||
};
|
||||
function onAbort(): void {
|
||||
callerAborted = true;
|
||||
abortTransaction();
|
||||
}
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
finish(candidate ?? unavailable());
|
||||
};
|
||||
transaction.onerror = () => {
|
||||
requestError ??= transaction.error;
|
||||
};
|
||||
transaction.onabort = () => {
|
||||
if (callerAborted) {
|
||||
finish(
|
||||
browserDataFailure("ABORTED", "INDEXEDDB_MIGRATE"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (candidate && !candidate.ok) {
|
||||
finish(candidate);
|
||||
return;
|
||||
}
|
||||
finish(
|
||||
mapIndexedDbException(
|
||||
requestError ?? transaction.error,
|
||||
"INDEXEDDB_MIGRATE",
|
||||
),
|
||||
);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
const context: TransactionContext<Value> = Object.freeze({
|
||||
succeed(value) {
|
||||
if (!candidate) candidate = browserDataSuccess(value);
|
||||
},
|
||||
fail(result) {
|
||||
if (!candidate) candidate = result;
|
||||
abortTransaction();
|
||||
},
|
||||
requestFailed(error) {
|
||||
requestError ??= error;
|
||||
if (!candidate) {
|
||||
candidate = mapIndexedDbException(
|
||||
error,
|
||||
"INDEXEDDB_MIGRATE",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
queue(transaction, context);
|
||||
} catch (error) {
|
||||
context.fail(
|
||||
mapIndexedDbException(error, "INDEXEDDB_MIGRATE"),
|
||||
);
|
||||
}
|
||||
return runIndexedDbTransaction<Value, BrowserDataFailure>({
|
||||
database,
|
||||
stores,
|
||||
mode,
|
||||
translate,
|
||||
signal,
|
||||
durability:
|
||||
mode === "readonly"
|
||||
? dependencies.durability?.read ?? "default"
|
||||
: dependencies.durability?.write ?? "strict",
|
||||
queue,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -732,7 +652,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
dependencies.checkpointKey,
|
||||
)
|
||||
) {
|
||||
context.fail(migrationFailed());
|
||||
context.fail(migrationFailure());
|
||||
return;
|
||||
}
|
||||
const persisted = request.result;
|
||||
@@ -777,85 +697,69 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
true,
|
||||
);
|
||||
} catch {
|
||||
context.fail(invalidInput());
|
||||
context.fail(invalidInputFailure());
|
||||
return;
|
||||
}
|
||||
if (checkpoint.effective.lastKey !== null && !query) {
|
||||
context.fail(
|
||||
browserDataFailure(
|
||||
"UNSUPPORTED",
|
||||
"INDEXEDDB_MIGRATE",
|
||||
{ recovery: "ONLINE_ONLY" },
|
||||
),
|
||||
);
|
||||
context.fail(unsupportedFailure());
|
||||
return;
|
||||
}
|
||||
|
||||
const rows: ScannedRecord[] = [];
|
||||
const request = transaction
|
||||
.objectStore(dependencies.recordStore)
|
||||
.openCursor(query);
|
||||
request.onerror = () => context.requestFailed(request.error);
|
||||
request.onsuccess = () => {
|
||||
if (input.signal?.aborted) {
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// The transaction event decides the abort/complete race.
|
||||
// The scan bounds on rows it read, so the counter the kernel offers is
|
||||
// the right one here. The time check comes first so a batch that ran
|
||||
// out of both answers `budgetExhausted` the way it does today.
|
||||
const budget: IndexedDbBudget<BrowserDataFailure> = {
|
||||
admit: (scannedRows) => {
|
||||
const currentTime = clock();
|
||||
if (!currentTime.ok) return currentTime;
|
||||
if (currentTime.value >= deadline) {
|
||||
return browserDataSuccess("TIME_BUDGET" as const);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
context.succeed({
|
||||
rows: Object.freeze(rows),
|
||||
reachedEnd: true,
|
||||
budgetExhausted: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const currentTime = clock();
|
||||
if (!currentTime.ok) {
|
||||
context.fail(currentTime);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
rows.length >= input.maxRows ||
|
||||
currentTime.value >= deadline
|
||||
) {
|
||||
context.succeed({
|
||||
rows: Object.freeze(rows),
|
||||
reachedEnd: false,
|
||||
budgetExhausted:
|
||||
currentTime.value >= deadline,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isStoredRecord(cursor.value)) {
|
||||
context.fail(migrationFailed());
|
||||
return;
|
||||
}
|
||||
if (
|
||||
cursor.value.codecVersion >
|
||||
dependencies.migrationPolicy.targetCodecVersion
|
||||
) {
|
||||
context.fail(migrationFailed());
|
||||
return;
|
||||
}
|
||||
rows.push({
|
||||
key: cursor.value.key,
|
||||
codecVersion: cursor.value.codecVersion,
|
||||
revision: cursor.value.revision,
|
||||
payload: cursor.value.payload,
|
||||
});
|
||||
try {
|
||||
cursor.continue();
|
||||
} catch (error) {
|
||||
context.fail(
|
||||
mapIndexedDbException(error, "INDEXEDDB_MIGRATE"),
|
||||
return browserDataSuccess(
|
||||
scannedRows >= input.maxRows
|
||||
? ("ROW_BUDGET" as const)
|
||||
: ("CONTINUE" as const),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
walkIndexedDbCursor<BrowserDataFailure>({
|
||||
request: transaction
|
||||
.objectStore(dependencies.recordStore)
|
||||
.openCursor(query),
|
||||
sink: context,
|
||||
translate,
|
||||
budget,
|
||||
signal: input.signal,
|
||||
visit: ({ cursor }) => {
|
||||
if (
|
||||
!isStoredRecord(cursor.value) ||
|
||||
cursor.value.codecVersion >
|
||||
dependencies.migrationPolicy.targetCodecVersion
|
||||
) {
|
||||
context.fail(migrationFailure());
|
||||
// `fail` aborts, so the walk has nowhere to go. Suspending
|
||||
// without ever resuming says that without asking the pump for
|
||||
// another row or claiming a summary the scan never reached.
|
||||
return { kind: "SUSPEND" };
|
||||
}
|
||||
rows.push({
|
||||
key: cursor.value.key,
|
||||
codecVersion: cursor.value.codecVersion,
|
||||
revision: cursor.value.revision,
|
||||
payload: cursor.value.payload,
|
||||
});
|
||||
return { kind: "CONTINUE" };
|
||||
},
|
||||
done: (summary) => {
|
||||
if (summary.reason === "ABORTED") return;
|
||||
context.succeed({
|
||||
rows: Object.freeze(rows),
|
||||
reachedEnd: summary.reason === "EXHAUSTED",
|
||||
budgetExhausted: summary.reason === "TIME_BUDGET",
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1023,9 +927,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
try {
|
||||
request = checkpoints.put(stored);
|
||||
} catch (error) {
|
||||
context.fail(
|
||||
mapIndexedDbException(error, "INDEXEDDB_MIGRATE"),
|
||||
);
|
||||
context.fail(mappedFailure(error));
|
||||
return;
|
||||
}
|
||||
request.onerror = () =>
|
||||
@@ -1057,7 +959,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
const currentTime = clock();
|
||||
if (!currentTime.ok) {
|
||||
// A broken clock aborts rather than committing an unbounded batch.
|
||||
context.fail(currentTime);
|
||||
context.fail(currentTime.error);
|
||||
return;
|
||||
}
|
||||
if (currentTime.value >= deadline) {
|
||||
@@ -1069,9 +971,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
try {
|
||||
request = records.get(preparedRecord.source.key);
|
||||
} catch (error) {
|
||||
context.fail(
|
||||
mapIndexedDbException(error, "INDEXEDDB_MIGRATE"),
|
||||
);
|
||||
context.fail(mappedFailure(error));
|
||||
return;
|
||||
}
|
||||
request.onerror = () =>
|
||||
@@ -1091,7 +991,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
preparedRecord.source.key,
|
||||
)
|
||||
) {
|
||||
context.fail(migrationFailed());
|
||||
context.fail(migrationFailure());
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -1123,7 +1023,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
!preparedRecord.needsMigration ||
|
||||
preparedRecord.measuredBytes === undefined
|
||||
) {
|
||||
context.fail(migrationFailed());
|
||||
context.fail(migrationFailure());
|
||||
return;
|
||||
}
|
||||
const measuredBytes = preparedRecord.measuredBytes;
|
||||
@@ -1137,7 +1037,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
live.key,
|
||||
)
|
||||
) {
|
||||
context.fail(migrationFailed());
|
||||
context.fail(migrationFailure());
|
||||
return;
|
||||
}
|
||||
const previousSidecar = sidecarRequest.result;
|
||||
@@ -1152,7 +1052,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
budgetRequest.result.usedBytes <
|
||||
previousSidecar.measuredBytes
|
||||
) {
|
||||
context.fail(migrationFailed());
|
||||
context.fail(migrationFailure());
|
||||
return;
|
||||
}
|
||||
const usedBytes =
|
||||
@@ -1165,7 +1065,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
usedBytes >
|
||||
storagePolicySnapshot.hardBudgetBytes
|
||||
) {
|
||||
context.fail(migrationFailed());
|
||||
context.fail(migrationFailure());
|
||||
return;
|
||||
}
|
||||
const writeRequest = records.put({
|
||||
@@ -1221,7 +1121,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
dependencies.checkpointKey,
|
||||
)
|
||||
) {
|
||||
context.fail(migrationFailed());
|
||||
context.fail(migrationFailure());
|
||||
return;
|
||||
}
|
||||
liveCheckpoint = checkpointRequest.result;
|
||||
@@ -1412,134 +1312,106 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
let range: IDBKeyRange;
|
||||
try {
|
||||
if (!keyRange) {
|
||||
context.fail(
|
||||
browserDataFailure(
|
||||
"UNSUPPORTED",
|
||||
"INDEXEDDB_MIGRATE",
|
||||
{ recovery: "ONLINE_ONLY" },
|
||||
),
|
||||
);
|
||||
context.fail(unsupportedFailure());
|
||||
return;
|
||||
}
|
||||
range = keyRange.upperBound(cutoff.value);
|
||||
} catch {
|
||||
context.fail(invalidInput());
|
||||
context.fail(invalidInputFailure());
|
||||
return;
|
||||
}
|
||||
const request = store
|
||||
.index(dependencies.idempotencyExpiryIndex)
|
||||
.openCursor(range);
|
||||
let scannedRows = 0;
|
||||
let deletedRows = 0;
|
||||
|
||||
const succeed = (
|
||||
state: "MORE" | "COMPLETE",
|
||||
budgetExhausted: boolean,
|
||||
) => {
|
||||
context.succeed(
|
||||
Object.freeze({
|
||||
state,
|
||||
scannedRows,
|
||||
deletedRows,
|
||||
budgetExhausted,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
request.onerror = () =>
|
||||
context.requestFailed(request.error);
|
||||
request.onsuccess = () => {
|
||||
if (input.signal?.aborted) {
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// The transaction event owns the completion race.
|
||||
// The prune bounds on rows it deleted, not on rows it read, so the
|
||||
// counter stays here while the kernel counts scanned rows for the
|
||||
// receipt. A row past the cutoff is corrupt data, not a stop
|
||||
// condition, so the walk never reports STOPPED.
|
||||
const budget: IndexedDbBudget<BrowserDataFailure> = {
|
||||
admit: () => {
|
||||
const currentTime = clock();
|
||||
if (!currentTime.ok) return currentTime;
|
||||
if (currentTime.value >= deadline) {
|
||||
return browserDataSuccess("TIME_BUDGET" as const);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
succeed("COMPLETE", false);
|
||||
return;
|
||||
}
|
||||
const currentTime = clock();
|
||||
if (!currentTime.ok) {
|
||||
context.fail(currentTime);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
deletedRows >= input.maxRows ||
|
||||
currentTime.value >= deadline
|
||||
) {
|
||||
succeed(
|
||||
"MORE",
|
||||
currentTime.value >= deadline,
|
||||
return browserDataSuccess(
|
||||
deletedRows >= input.maxRows
|
||||
? ("ROW_BUDGET" as const)
|
||||
: ("CONTINUE" as const),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isStoredReceipt(cursor.value) ||
|
||||
cursor.value.idempotencyKey !==
|
||||
String(cursor.primaryKey) ||
|
||||
cursor.value.expiresAtEpochMs > cutoff.value
|
||||
) {
|
||||
context.fail(migrationFailed());
|
||||
return;
|
||||
}
|
||||
scannedRows += 1;
|
||||
let deleteRequest: IDBRequest<undefined>;
|
||||
try {
|
||||
deleteRequest = store.delete(
|
||||
cursor.primaryKey,
|
||||
);
|
||||
} catch (error) {
|
||||
context.fail(
|
||||
mapIndexedDbException(
|
||||
error,
|
||||
"INDEXEDDB_MIGRATE",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
deleteRequest.onerror = () =>
|
||||
context.requestFailed(deleteRequest.error);
|
||||
deleteRequest.onsuccess = () => {
|
||||
const budgetRequest = governance.get(
|
||||
INDEXEDDB_DATASET_BUDGET_KEY,
|
||||
);
|
||||
budgetRequest.onerror = () =>
|
||||
context.requestFailed(budgetRequest.error);
|
||||
budgetRequest.onsuccess = () => {
|
||||
if (
|
||||
!isStoredDatasetBudget(budgetRequest.result) ||
|
||||
budgetRequest.result.receiptCount < 1
|
||||
) {
|
||||
context.fail(migrationFailed());
|
||||
return;
|
||||
}
|
||||
const budgetWrite = governance.put({
|
||||
...budgetRequest.result,
|
||||
receiptCount:
|
||||
budgetRequest.result.receiptCount - 1,
|
||||
} satisfies StoredDatasetBudget);
|
||||
budgetWrite.onerror = () =>
|
||||
context.requestFailed(budgetWrite.error);
|
||||
budgetWrite.onsuccess = () => {
|
||||
deletedRows += 1;
|
||||
try {
|
||||
cursor.continue();
|
||||
} catch (error) {
|
||||
context.fail(
|
||||
mapIndexedDbException(
|
||||
error,
|
||||
"INDEXEDDB_MIGRATE",
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
walkIndexedDbCursor<BrowserDataFailure>({
|
||||
request: store
|
||||
.index(dependencies.idempotencyExpiryIndex)
|
||||
.openCursor(range),
|
||||
sink: context,
|
||||
translate,
|
||||
budget,
|
||||
signal: input.signal,
|
||||
visit: ({ cursor, resume }) => {
|
||||
if (
|
||||
!isStoredReceipt(cursor.value) ||
|
||||
cursor.value.idempotencyKey !==
|
||||
String(cursor.primaryKey) ||
|
||||
cursor.value.expiresAtEpochMs > cutoff.value
|
||||
) {
|
||||
context.fail(migrationFailure());
|
||||
return { kind: "SUSPEND" };
|
||||
}
|
||||
let deleteRequest: IDBRequest<undefined>;
|
||||
try {
|
||||
deleteRequest = store.delete(cursor.primaryKey);
|
||||
} catch (error) {
|
||||
context.fail(mappedFailure(error));
|
||||
return { kind: "SUSPEND" };
|
||||
}
|
||||
deleteRequest.onerror = () =>
|
||||
context.requestFailed(deleteRequest.error);
|
||||
deleteRequest.onsuccess = () => {
|
||||
const budgetRequest = governance.get(
|
||||
INDEXEDDB_DATASET_BUDGET_KEY,
|
||||
);
|
||||
budgetRequest.onerror = () =>
|
||||
context.requestFailed(budgetRequest.error);
|
||||
budgetRequest.onsuccess = () => {
|
||||
if (
|
||||
!isStoredDatasetBudget(budgetRequest.result) ||
|
||||
budgetRequest.result.receiptCount < 1
|
||||
) {
|
||||
context.fail(migrationFailure());
|
||||
return;
|
||||
}
|
||||
const budgetWrite = governance.put({
|
||||
...budgetRequest.result,
|
||||
receiptCount:
|
||||
budgetRequest.result.receiptCount - 1,
|
||||
} satisfies StoredDatasetBudget);
|
||||
budgetWrite.onerror = () =>
|
||||
context.requestFailed(budgetWrite.error);
|
||||
budgetWrite.onsuccess = () => {
|
||||
deletedRows += 1;
|
||||
resume({ kind: "CONTINUE" });
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
// The receipt is only counted once its whole delete chain
|
||||
// commits, so the cursor stays parked until `resume`.
|
||||
return { kind: "SUSPEND" };
|
||||
},
|
||||
done: (summary) => {
|
||||
if (summary.reason === "ABORTED") return;
|
||||
context.succeed(
|
||||
Object.freeze({
|
||||
state:
|
||||
summary.reason === "EXHAUSTED"
|
||||
? ("COMPLETE" as const)
|
||||
: ("MORE" as const),
|
||||
scannedRows: summary.scannedRows,
|
||||
deletedRows,
|
||||
budgetExhausted: summary.reason === "TIME_BUDGET",
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
return observeResult(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import type {
|
||||
TelemetryEventName,
|
||||
} from "../../contracts/telemetry.ts";
|
||||
import type { TelemetryPort } from "../../application/ports/telemetry-port.ts";
|
||||
import { assertBoundedCapacity } from "../platform/bounded-capacity.ts";
|
||||
|
||||
export type TelemetryAdapter = TelemetryPort &
|
||||
Readonly<{
|
||||
@@ -45,22 +46,6 @@ type TelemetryLifecycle = "ACTIVE" | "DISPOSED";
|
||||
/** N-11. Documented absolute ceiling for the in-memory best-effort queue. */
|
||||
export const MAX_TELEMETRY_QUEUE = 10_000;
|
||||
|
||||
/**
|
||||
* N-11. A non-finite or fractional capacity silently disables eviction, so it is
|
||||
* a construction-time configuration error rather than a runtime drop.
|
||||
*/
|
||||
export function assertBoundedCapacity(
|
||||
value: number,
|
||||
ceiling: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) {
|
||||
throw new TypeError(
|
||||
`${label} must be a safe integer between 1 and ${ceiling}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createTelemetryAdapter(
|
||||
options: TelemetryAdapterOptions,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createTelemetryAdapter,
|
||||
MAX_TELEMETRY_QUEUE,
|
||||
noOpTelemetry,
|
||||
type TelemetryAdapter,
|
||||
type TelemetryAdapterOptions,
|
||||
} from "./best-effort-telemetry.ts";
|
||||
@@ -102,6 +102,12 @@ export function createApplication(
|
||||
getCapabilitySnapshot() {
|
||||
return outputPorts.runtimeCapabilities.getSnapshot();
|
||||
},
|
||||
getFeatureSnapshot() {
|
||||
return outputPorts.productFeatures.getSnapshot();
|
||||
},
|
||||
isFeatureActive(featureId: string) {
|
||||
return outputPorts.productFeatures.isActive(featureId);
|
||||
},
|
||||
});
|
||||
|
||||
const recovery = Object.freeze({
|
||||
|
||||
@@ -1,107 +1,23 @@
|
||||
export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([
|
||||
"buildId",
|
||||
"configSchemaVersion",
|
||||
"apiContractVersion",
|
||||
"assetManifestHash",
|
||||
"releaseId",
|
||||
] as const);
|
||||
|
||||
export type CompatibilityTupleField =
|
||||
(typeof COMPATIBILITY_TUPLE_FIELDS)[number];
|
||||
|
||||
export type CompatibilityTuple = Readonly<
|
||||
Record<CompatibilityTupleField, string>
|
||||
>;
|
||||
|
||||
export type NumericVersion = Readonly<{
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}>;
|
||||
|
||||
export function parseNumericVersion(version: string): NumericVersion | null {
|
||||
const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version);
|
||||
if (!match) return null;
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2] ?? 0),
|
||||
patch: Number(match[3] ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function isVersionCompatible(
|
||||
supported: string,
|
||||
actual: string,
|
||||
): boolean {
|
||||
const expected = parseNumericVersion(supported);
|
||||
const candidate = parseNumericVersion(actual);
|
||||
if (!expected || !candidate) return false;
|
||||
return (
|
||||
expected.major === candidate.major &&
|
||||
candidate.minor >= expected.minor
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyCompatibilityTuple(input: Readonly<{
|
||||
frontend: CompatibilityTuple;
|
||||
runtime: CompatibilityTuple;
|
||||
}>) {
|
||||
const mismatches: CompatibilityTupleField[] = [];
|
||||
if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId");
|
||||
if (
|
||||
!isVersionCompatible(
|
||||
input.frontend.configSchemaVersion,
|
||||
input.runtime.configSchemaVersion,
|
||||
)
|
||||
) {
|
||||
mismatches.push("configSchemaVersion");
|
||||
}
|
||||
if (
|
||||
!isVersionCompatible(
|
||||
input.frontend.apiContractVersion,
|
||||
input.runtime.apiContractVersion,
|
||||
)
|
||||
) {
|
||||
mismatches.push("apiContractVersion");
|
||||
}
|
||||
if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) {
|
||||
mismatches.push("assetManifestHash");
|
||||
}
|
||||
|
||||
const releaseWarning: "releaseId" | null =
|
||||
input.frontend.releaseId === input.runtime.releaseId
|
||||
? null
|
||||
: "releaseId";
|
||||
return Object.freeze({
|
||||
compatible: mismatches.length === 0,
|
||||
mismatches: Object.freeze(mismatches),
|
||||
warnings: Object.freeze(releaseWarning ? [releaseWarning] : []),
|
||||
});
|
||||
}
|
||||
|
||||
export type ObjectSchemaShape = Readonly<{
|
||||
required?: readonly string[];
|
||||
properties?: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
export type SchemaChangeClassification = "breaking" | "additive" | "none";
|
||||
|
||||
export function classifyObjectSchemaChange(
|
||||
before: ObjectSchemaShape,
|
||||
after: ObjectSchemaShape,
|
||||
): SchemaChangeClassification {
|
||||
const beforeRequired = new Set(before.required ?? []);
|
||||
const afterRequired = new Set(after.required ?? []);
|
||||
const removedProperties = Object.keys(before.properties ?? {}).filter(
|
||||
(key) => !(key in (after.properties ?? {})),
|
||||
);
|
||||
const addedRequired = [...afterRequired].filter(
|
||||
(key) => !beforeRequired.has(key),
|
||||
);
|
||||
if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking";
|
||||
|
||||
const addedProperties = Object.keys(after.properties ?? {}).filter(
|
||||
(key) => !(key in (before.properties ?? {})),
|
||||
);
|
||||
return addedProperties.length > 0 ? "additive" : "none";
|
||||
}
|
||||
/**
|
||||
* Release compatibility comparison.
|
||||
*
|
||||
* The implementation lives in `src/contracts/compatibility.ts`: it is a pure
|
||||
* predicate over release tokens with no application state, and
|
||||
* `src/contracts/release-tokens.ts` needs it, which previously made contracts
|
||||
* import the application layer. This module is retained only as a migration
|
||||
* shim; internal callers use src/contracts/compatibility.ts directly.
|
||||
*
|
||||
* @deprecated Import compatibility contracts from ../../contracts/compatibility.ts.
|
||||
*/
|
||||
export {
|
||||
COMPATIBILITY_TUPLE_FIELDS,
|
||||
classifyObjectSchemaChange,
|
||||
isVersionCompatible,
|
||||
parseNumericVersion,
|
||||
verifyCompatibilityTuple,
|
||||
type CompatibilityTuple,
|
||||
type CompatibilityTupleField,
|
||||
type NumericVersion,
|
||||
type ObjectSchemaShape,
|
||||
type SchemaChangeClassification,
|
||||
} from "../../contracts/compatibility.ts";
|
||||
|
||||
@@ -20,6 +20,9 @@ export const PROMOTION_FORMULA = Object.freeze({
|
||||
"FE-GATE-015",
|
||||
"FE-GATE-019",
|
||||
"FE-GATE-026",
|
||||
// FE-GATE-027. A candidate is only release-ready once it has been admitted
|
||||
// to a named environment; coherence alone never proved it belonged there.
|
||||
"FE-GATE-027",
|
||||
]),
|
||||
PROD_PROMOTION_READY: Object.freeze([
|
||||
"FE-GATE-016",
|
||||
|
||||
@@ -21,9 +21,23 @@ export type IndexedDbSynchronizationState =
|
||||
| "CONFIRMED";
|
||||
|
||||
export type IndexedDbConnectionStatus =
|
||||
| Readonly<{ kind: "CLOSED"; reason: "NOT_OPENED" | "VERSION_CHANGE" | "FORCED" }>
|
||||
| Readonly<{
|
||||
kind: "CLOSED";
|
||||
/**
|
||||
* NOT_OPENED means the current open attempt has settled without a live
|
||||
* connection. A blocked upgrade may therefore be observed as BLOCKED
|
||||
* while it is waiting, then transition to CLOSED/NOT_OPENED when its
|
||||
* blocked deadline fails closed.
|
||||
*/
|
||||
reason: "NOT_OPENED" | "VERSION_CHANGE" | "FORCED";
|
||||
}>
|
||||
| Readonly<{ kind: "OPENING"; targetVersion: number }>
|
||||
| Readonly<{
|
||||
/**
|
||||
* A live open/upgrade attempt is currently waiting for another
|
||||
* IndexedDB context. BLOCKED is transient and is not the settled state
|
||||
* after the blocked deadline has expired.
|
||||
*/
|
||||
kind: "BLOCKED";
|
||||
currentVersion: number;
|
||||
targetVersion: number;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { SessionState } from "../auth-session-port.ts";
|
||||
import type { ProductFeatureStatus } from "../product-features-port.ts";
|
||||
import type { RuntimeCapabilitySnapshot } from "../runtime-capabilities-port.ts";
|
||||
import type { StoragePort } from "../storage-port.ts";
|
||||
|
||||
export type { SessionState } from "../auth-session-port.ts";
|
||||
export type { ProductFeatureStatus };
|
||||
export type { RuntimeCapabilitySnapshot };
|
||||
|
||||
/**
|
||||
@@ -64,6 +66,13 @@ export type ApplicationApi = Readonly<{
|
||||
* reads capability state here instead of importing the composition root.
|
||||
*/
|
||||
getCapabilitySnapshot(): RuntimeCapabilitySnapshot;
|
||||
/**
|
||||
* §3.5. Which product features this build contains and which of them the
|
||||
* runtime document switched off. Presentation reads state here; it never
|
||||
* learns how to reach a feature the build left out.
|
||||
*/
|
||||
getFeatureSnapshot(): readonly ProductFeatureStatus[];
|
||||
isFeatureActive(featureId: string): boolean;
|
||||
}>;
|
||||
recovery: Readonly<{
|
||||
recoverChunk(input: Readonly<{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user