diff --git a/src/features/tech-log/adapters/static/public-query.ts b/src/features/tech-log/adapters/static/public-query.ts index d6866bc..ca6e423 100644 --- a/src/features/tech-log/adapters/static/public-query.ts +++ b/src/features/tech-log/adapters/static/public-query.ts @@ -208,14 +208,42 @@ export function searchPublicContent(query: string): SearchablePublicEntity[] { ); } +/** + * The MOCK source. The functions above stay synchronous — they filter arrays + * that are already in the bundle, and making them async would only add a + * microtask to every fixture test — so the port's async shape is applied here, + * at the adapter boundary, rather than pushed into the query implementations. + * + * `async` rather than `Promise.resolve(...)` so a throw from one of these + * becomes a rejected promise like the HTTP adapter's would, instead of + * escaping synchronously past the caller's await. + */ export const publicContentQueries = Object.freeze({ - listRecords, - getRecord, - getProject, - getRelease, - getProjectRecords, - getProjectDecisions, - getProjectActivity, - getHomeFocusItems, - searchPublicContent, + async listRecords(filters?: RecordFilters) { + return listRecords(filters); + }, + async getRecord(kind: K, slug: string) { + return getRecord(kind, slug); + }, + async getProject(slug: string) { + return getProject(slug); + }, + async getRelease(version: string) { + return getRelease(version); + }, + async getProjectRecords(projectSlug: string) { + return getProjectRecords(projectSlug); + }, + async getProjectDecisions(projectSlug: string) { + return getProjectDecisions(projectSlug); + }, + async getProjectActivity(projectSlug: string) { + return getProjectActivity(projectSlug); + }, + async getHomeFocusItems() { + return getHomeFocusItems(); + }, + async searchPublicContent(query: string) { + return searchPublicContent(query); + }, }) satisfies PublicContentQueries; diff --git a/src/features/tech-log/application/ports/public-content-queries.ts b/src/features/tech-log/application/ports/public-content-queries.ts index d6ca418..f133f93 100644 --- a/src/features/tech-log/application/ports/public-content-queries.ts +++ b/src/features/tech-log/application/ports/public-content-queries.ts @@ -165,17 +165,31 @@ export type SearchablePublicEntity = { * The application-facing boundary for the immutable source Public catalog. * Method signatures intentionally retain the source query argument and return shapes. */ +/** + * The public read surface. + * + * Every method is async because one of the two adapters behind this port is a + * network client. The other reads a bundled fixture and could answer + * synchronously, but a port has one shape: if the fixture adapter kept the + * synchronous signature, the HTTP adapter could not implement the same port + * and callers written against the fixture would not compile against the + * network. + * + * Failures throw rather than resolving to a Result. That matches the Studio + * gateways, and it lets `useApplicationQuery` classify a rejection once at the + * boundary instead of every caller unwrapping. + */ export type PublicContentQueries = Readonly<{ - listRecords(filters?: RecordFilters): PublicRecord[]; + listRecords(filters?: RecordFilters): Promise; getRecord( kind: K, slug: string, - ): Extract | undefined; - getProject(slug: string): Project | undefined; - getRelease(version: string): Release | undefined; - getProjectRecords(projectSlug: string): PublicRecord[]; - getProjectDecisions(projectSlug: string): ProjectDecision[]; - getProjectActivity(projectSlug: string): ProjectActivity[]; - getHomeFocusItems(): HomeFocusItem[]; - searchPublicContent(query: string): SearchablePublicEntity[]; + ): Promise | undefined>; + getProject(slug: string): Promise; + getRelease(version: string): Promise; + getProjectRecords(projectSlug: string): Promise; + getProjectDecisions(projectSlug: string): Promise; + getProjectActivity(projectSlug: string): Promise; + getHomeFocusItems(): Promise; + searchPublicContent(query: string): Promise; }>; diff --git a/src/features/tech-log/presentation/public/components/explore-filter-form.tsx b/src/features/tech-log/presentation/public/components/explore-filter-form.tsx index 27e351a..52fe466 100644 --- a/src/features/tech-log/presentation/public/components/explore-filter-form.tsx +++ b/src/features/tech-log/presentation/public/components/explore-filter-form.tsx @@ -1,8 +1,7 @@ import { Link, useNavigate } from "react-router-dom"; import type { RecordKind } from "../../../application/ports/public-content-queries.ts"; -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; export function ExploreFilterForm({ action, @@ -18,20 +17,34 @@ export function ExploreFilterForm({ showType?: boolean; }) { const navigate = useNavigate(); - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const publicRecords = publicContent.listRecords(); - const topics = [...new Set(publicRecords.map((record) => record.topic))].sort(); - const projectPrefix = "/projects/"; - const projects = publicContent - .searchPublicContent("") - .filter((entity) => entity.contentType === "PROJECT") - .flatMap((entity) => { - if (!entity.path.startsWith(projectPrefix)) return []; - const item = publicContent.getProject( - decodeURIComponent(entity.path.slice(projectPrefix.length)), + // This form sits inside a page that renders its own loading state, so it does + // not hand back a fallback of its own — that would put a second skeleton + // inside a screen already showing one, and move the layout under it. It + // renders its real structure immediately with empty option lists and fills + // them in when the catalog arrives. + const view = usePublicContent(["tech-log", "explore-filters"], async (queries) => { + const projectPrefix = "/projects/"; + const [records, entities] = await Promise.all([ + queries.listRecords(), + queries.searchPublicContent(""), + ]); + const projectSlugs = entities + .filter((entity) => entity.contentType === "PROJECT") + .flatMap((entity) => + entity.path.startsWith(projectPrefix) + ? [decodeURIComponent(entity.path.slice(projectPrefix.length))] + : [], ); - return item ? [{ slug: item.slug, title: item.title }] : []; - }); + const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug))); + return { + topics: [...new Set(records.map((record) => record.topic))].sort(), + projects: resolved + .filter((item) => item !== undefined) + .map((item) => ({ slug: item.slug, title: item.title })), + }; + }); + const topics = view.data?.topics ?? []; + const projects = view.data?.projects ?? []; const normalizedTopic = topic?.toLocaleLowerCase("ko-KR"); const selectedTopic = topics.find( (item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic, diff --git a/src/features/tech-log/presentation/public/components/search-dialog.tsx b/src/features/tech-log/presentation/public/components/search-dialog.tsx index 56e1cfa..ee01220 100644 --- a/src/features/tech-log/presentation/public/components/search-dialog.tsx +++ b/src/features/tech-log/presentation/public/components/search-dialog.tsx @@ -1,8 +1,7 @@ import { useId, useRef, useState } from "react"; import { Link } from "react-router-dom"; -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; type SearchDialogProps = { className?: string; @@ -19,8 +18,21 @@ export function SearchDialog({ const triggerRef = useRef(null); const [query, setQuery] = useState(""); const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR"); - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const results = publicContent.searchPublicContent(normalizedQuery); + // Keyed on the empty query, then filtered here, rather than one request per + // keystroke. This is a type-ahead: re-querying per character would replace the + // result list with a loading skeleton on every key, which is a worse dialog + // than a stale-free local filter. The predicate is the same one the catalog + // applies for a non-empty query, so the visible result set is unchanged. + const view = usePublicContent(["tech-log", "search", ""], async (queries) => ({ + entities: await queries.searchPublicContent(""), + })); + const results = (view.data?.entities ?? []).filter((entity) => + normalizedQuery + ? [entity.title, entity.summary, entity.topic, entity.project, ...(entity.topics ?? [])] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLocaleLowerCase("ko-KR").includes(normalizedQuery)) + : true, + ); function open() { onBeforeOpen?.(); diff --git a/src/features/tech-log/presentation/public/pages/case-page.tsx b/src/features/tech-log/presentation/public/pages/case-page.tsx index d1d2cc4..6d622cc 100644 --- a/src/features/tech-log/presentation/public/pages/case-page.tsx +++ b/src/features/tech-log/presentation/public/pages/case-page.tsx @@ -1,10 +1,9 @@ -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { RegisteredNotFoundRoute, useRouteInput, } from "../../../../../presentation/routes/route-input.tsx"; import { CaseDocumentPage } from "../components/case-document-page.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; @@ -14,9 +13,12 @@ export function CasePage() { const { params, search } = useRouteInput<"TECH_LOG_CASE">(); const slug = optionalString(params.slug); const requestedState = optionalString(search.state); - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const record = slug ? publicContent.getRecord("CASE", slug) : undefined; + const view = usePublicContent(["tech-log", "case", slug], async (queries) => ({ + record: slug ? await queries.getRecord("CASE", slug) : undefined, + })); + if (!view.ready) return view.fallback; + const { record } = view.data; if (!record) return ; return ( diff --git a/src/features/tech-log/presentation/public/pages/explore-kind-page.tsx b/src/features/tech-log/presentation/public/pages/explore-kind-page.tsx index a772ad9..980e2de 100644 --- a/src/features/tech-log/presentation/public/pages/explore-kind-page.tsx +++ b/src/features/tech-log/presentation/public/pages/explore-kind-page.tsx @@ -1,13 +1,12 @@ import { Link } from "react-router-dom"; -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { RegisteredNotFoundRoute, useRouteInput, } from "../../../../../presentation/routes/route-input.tsx"; import { ExploreFilterForm } from "../components/explore-filter-form.tsx"; import { PublicRecordList } from "../components/public-record-list.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; const kinds = { cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." }, @@ -28,18 +27,29 @@ function getKindConfig(value: string | undefined) { export function ExploreKindPage() { const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">(); - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const kind = optionalString(params.kind); const config = getKindConfig(kind); - if (!config) return ; const topic = optionalString(search.topic); const project = optionalString(search.project); - const records = publicContent.listRecords({ - kind: config.kind, - ...(topic ? { topic } : {}), - ...(project ? { project } : {}), - }); + // The unknown-kind check reads as an early return, but it cannot come before + // the query: hooks run unconditionally or React loses the call order. The + // loader short-circuits instead, and the not-found route is chosen below. + const view = usePublicContent( + ["tech-log", "explore-kind", config?.kind, topic, project], + async (queries) => ({ + records: config + ? await queries.listRecords({ + kind: config.kind, + ...(topic ? { topic } : {}), + ...(project ? { project } : {}), + }) + : [], + }), + ); + if (!config) return ; + if (!view.ready) return view.fallback; + const { records } = view.data; return

Explore

{config.title}

{config.description}

diff --git a/src/features/tech-log/presentation/public/pages/explore-page.tsx b/src/features/tech-log/presentation/public/pages/explore-page.tsx index a8cc91c..08de317 100644 --- a/src/features/tech-log/presentation/public/pages/explore-page.tsx +++ b/src/features/tech-log/presentation/public/pages/explore-page.tsx @@ -1,9 +1,8 @@ import type { RecordKind } from "../../../application/ports/public-content-queries.ts"; -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx"; import { ExploreFilterForm } from "../components/explore-filter-form.tsx"; import { PublicRecordList } from "../components/public-record-list.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; @@ -17,13 +16,19 @@ export function ExplorePage() { const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find( (item) => item === requestedKind, ) satisfies RecordKind | undefined; - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const records = publicContent.listRecords({ - ...(kind ? { kind } : {}), - ...(topic ? { topic } : {}), - ...(project ? { project } : {}), - }); + const view = usePublicContent( + ["tech-log", "explore", kind, topic, project], + async (queries) => ({ + records: await queries.listRecords({ + ...(kind ? { kind } : {}), + ...(topic ? { topic } : {}), + ...(project ? { project } : {}), + }), + }), + ); + if (!view.ready) return view.fallback; + const { records } = view.data; return

Explore

탐색

유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다.

diff --git a/src/features/tech-log/presentation/public/pages/home-page.tsx b/src/features/tech-log/presentation/public/pages/home-page.tsx index e42a909..1c7194b 100644 --- a/src/features/tech-log/presentation/public/pages/home-page.tsx +++ b/src/features/tech-log/presentation/public/pages/home-page.tsx @@ -1,11 +1,10 @@ import { Link } from "react-router-dom"; import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts"; -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; import { publicSiteConfig } from "../../../contracts/public-site-config.ts"; import { normalizeFocus } from "../../../domain/public/focus-state.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; import { FatalErrorState } from "../components/fatal-error-state.tsx"; import { HomeFocus } from "../components/home-focus.tsx"; import { @@ -40,12 +39,14 @@ function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] { - const publicRecords = publicContent.listRecords(); +async function getLatestEntries( + publicContent: PublicContentQueries, +): Promise { + const publicRecords = await publicContent.listRecords(); const publicRecordByPath = new Map( publicRecords.map((record) => [record.path, record]), ); - const searchableEntities = publicContent.searchPublicContent(""); + const searchableEntities = await publicContent.searchPublicContent(""); const projectPrefix = "/projects/"; const projectSlugs = searchableEntities .filter((entity) => entity.contentType === "PROJECT") @@ -54,10 +55,16 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] { ? [decodeURIComponent(entity.path.slice(projectPrefix.length))] : [], ); - const projectTimeline = projectSlugs.flatMap((projectSlug) => { - const project = publicContent.getProject(projectSlug); - if (!project) return []; - return publicContent.getProjectActivity(projectSlug).map((activity) => { + // One project at a time would serialise a request per project; issuing them + // together keeps the timeline's cost at its slowest project rather than their + // sum. The flatten below restores the original single-list shape. + const projectTimeline = ( + await Promise.all( + projectSlugs.map(async (projectSlug) => { + const project = await publicContent.getProject(projectSlug); + if (!project) return []; + const activities = await publicContent.getProjectActivity(projectSlug); + return activities.map((activity) => { const record = publicRecordByPath.get( activity.recordPath ?? activity.path, ); @@ -77,20 +84,24 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] { topic: record?.topic ?? project.topics[0] ?? "", project: project.title, path: activity.path, - }; - }); - }); - const releaseTimeline = searchableEntities - .filter((entity) => entity.contentType === "RELEASE") - .flatMap((entity) => { - const prefix = "/releases/"; - if (!entity.path.startsWith(prefix)) return []; - const release = publicContent.getRelease( - decodeURIComponent(entity.path.slice(prefix.length)), - ); - if (!release) return []; - return [ - { + }; + }); + }), + ) + ).flat(); + const releaseTimeline = ( + await Promise.all( + searchableEntities + .filter((entity) => entity.contentType === "RELEASE") + .map(async (entity) => { + const prefix = "/releases/"; + if (!entity.path.startsWith(prefix)) return []; + const release = await publicContent.getRelease( + decodeURIComponent(entity.path.slice(prefix.length)), + ); + if (!release) return []; + return [ + { id: `release-${release.version}`, typeLabel: "RELEASE", title: release.title, @@ -99,10 +110,12 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] { dateTime: release.publishedAt, topic: "TechLog", project: "TechLog", - path: release.path, - }, - ]; - }); + path: release.path, + }, + ]; + }), + ) + ).flat(); return [...projectTimeline, ...releaseTimeline].sort((left, right) => right.dateTime.localeCompare(left.dateTime), @@ -113,8 +126,16 @@ export function HomePage() { const { search } = useRouteInput<"TECH_LOG_HOME">(); const requestedKey = optionalString(search.focus); const requestedState = optionalString(search.state); - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const focusItems = publicContent.getHomeFocusItems(); + const view = usePublicContent(["tech-log", "home"], async (queries) => { + const [focusItems, latestEntries] = await Promise.all([ + queries.getHomeFocusItems(), + getLatestEntries(queries), + ]); + return { focusItems, latestEntries }; + }); + if (!view.ready) return view.fallback; + + const { focusItems, latestEntries } = view.data; const availableFocusItems = requestedState === "focus-empty" ? [] : focusItems; const normalizedKey = normalizeFocus( requestedKey, @@ -131,8 +152,6 @@ export function HomePage() { return ; } - const latestEntries = getLatestEntries(publicContent); - return (
diff --git a/src/features/tech-log/presentation/public/pages/profile-page.tsx b/src/features/tech-log/presentation/public/pages/profile-page.tsx index 9a345f4..4947adf 100644 --- a/src/features/tech-log/presentation/public/pages/profile-page.tsx +++ b/src/features/tech-log/presentation/public/pages/profile-page.tsx @@ -2,6 +2,7 @@ import { Link } from "react-router-dom"; import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; import { publicSiteConfig } from "../../../contracts/public-site-config.ts"; +import { usePublicContent } from "../use-public-content.tsx"; import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; const principles = [ @@ -26,12 +27,15 @@ const currentProjectSlugs = ["backend-skeleton", "auth-lab"] as const; const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const; export function ProfilePage() { - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const currentProjects = currentProjectSlugs.flatMap((slug) => { - const project = publicContent.getProject(slug); - return project ? [project] : []; + const view = usePublicContent(["tech-log", "profile"], async (queries) => { + const resolved = await Promise.all( + currentProjectSlugs.map((slug) => queries.getProject(slug)), + ); + return { currentProjects: resolved.filter((project) => project !== undefined) }; }); + if (!view.ready) return view.fallback; + const { currentProjects } = view.data; return (
diff --git a/src/features/tech-log/presentation/public/pages/project-activity-page.tsx b/src/features/tech-log/presentation/public/pages/project-activity-page.tsx index 421964f..e65f205 100644 --- a/src/features/tech-log/presentation/public/pages/project-activity-page.tsx +++ b/src/features/tech-log/presentation/public/pages/project-activity-page.tsx @@ -1,22 +1,26 @@ import { Link } from "react-router-dom"; -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { RegisteredNotFoundRoute, useRouteInput, } from "../../../../../presentation/routes/route-input.tsx"; import { ProjectPageHeader } from "../components/project-page-header.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; export function ProjectActivityPage() { const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">(); const slug = typeof params.slug === "string" ? params.slug : ""; - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const project = publicContent.getProject(slug); + const view = usePublicContent(["tech-log", "project", slug, "activity"], async (queries) => { + const project = await queries.getProject(slug); + return project + ? { project, activity: await queries.getProjectActivity(slug) } + : { project: undefined, activity: [] }; + }); + if (!view.ready) return view.fallback; + const { project, activity } = view.data; if (!project) return ; - const activity = publicContent.getProjectActivity(slug); return (
diff --git a/src/features/tech-log/presentation/public/pages/project-decisions-page.tsx b/src/features/tech-log/presentation/public/pages/project-decisions-page.tsx index 29f3873..1a57135 100644 --- a/src/features/tech-log/presentation/public/pages/project-decisions-page.tsx +++ b/src/features/tech-log/presentation/public/pages/project-decisions-page.tsx @@ -1,22 +1,26 @@ import { Link } from "react-router-dom"; -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { RegisteredNotFoundRoute, useRouteInput, } from "../../../../../presentation/routes/route-input.tsx"; import { ProjectPageHeader } from "../components/project-page-header.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; export function ProjectDecisionsPage() { const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">(); const slug = typeof params.slug === "string" ? params.slug : ""; - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const project = publicContent.getProject(slug); + const view = usePublicContent(["tech-log", "project", slug, "decisions"], async (queries) => { + const project = await queries.getProject(slug); + return project + ? { project, decisions: await queries.getProjectDecisions(slug) } + : { project: undefined, decisions: [] }; + }); + if (!view.ready) return view.fallback; + const { project, decisions } = view.data; if (!project) return ; - const decisions = publicContent.getProjectDecisions(slug); return (
diff --git a/src/features/tech-log/presentation/public/pages/project-overview-page.tsx b/src/features/tech-log/presentation/public/pages/project-overview-page.tsx index d8ff59b..115fd6e 100644 --- a/src/features/tech-log/presentation/public/pages/project-overview-page.tsx +++ b/src/features/tech-log/presentation/public/pages/project-overview-page.tsx @@ -1,24 +1,34 @@ import { Link } from "react-router-dom"; -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { RegisteredNotFoundRoute, useRouteInput, } from "../../../../../presentation/routes/route-input.tsx"; import { ProjectPageHeader } from "../components/project-page-header.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; export function ProjectOverviewPage() { const { params } = useRouteInput<"TECH_LOG_PROJECT">(); const slug = typeof params.slug === "string" ? params.slug : ""; - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const project = publicContent.getProject(slug); + const view = usePublicContent(["tech-log", "project", slug, "overview"], async (queries) => { + const project = await queries.getProject(slug); + if (!project) { + return { project: undefined, records: [], decisions: [], activity: [] }; + } + // Three independent reads for one screen: issued together rather than in + // sequence, so the page waits for the slowest instead of their sum. + const [records, decisions, activity] = await Promise.all([ + queries.getProjectRecords(slug), + queries.getProjectDecisions(slug), + queries.getProjectActivity(slug), + ]); + return { project, records, decisions, activity }; + }); + if (!view.ready) return view.fallback; + const { project, records, decisions, activity } = view.data; if (!project) return ; - const records = publicContent.getProjectRecords(slug); - const decisions = publicContent.getProjectDecisions(slug); - const activity = publicContent.getProjectActivity(slug); return (
diff --git a/src/features/tech-log/presentation/public/pages/project-records-page.tsx b/src/features/tech-log/presentation/public/pages/project-records-page.tsx index c3cec6e..61aa06e 100644 --- a/src/features/tech-log/presentation/public/pages/project-records-page.tsx +++ b/src/features/tech-log/presentation/public/pages/project-records-page.tsx @@ -1,21 +1,25 @@ -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { RegisteredNotFoundRoute, useRouteInput, } from "../../../../../presentation/routes/route-input.tsx"; import { ProjectPageHeader } from "../components/project-page-header.tsx"; import { PublicRecordList } from "../components/public-record-list.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; export function ProjectRecordsPage() { const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">(); const slug = typeof params.slug === "string" ? params.slug : ""; - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const project = publicContent.getProject(slug); + const view = usePublicContent(["tech-log", "project", slug, "records"], async (queries) => { + const project = await queries.getProject(slug); + return project + ? { project, records: await queries.getProjectRecords(slug) } + : { project: undefined, records: [] }; + }); + if (!view.ready) return view.fallback; + const { project, records } = view.data; if (!project) return ; - const records = publicContent.getProjectRecords(slug); return (
diff --git a/src/features/tech-log/presentation/public/pages/projects-page.tsx b/src/features/tech-log/presentation/public/pages/projects-page.tsx index 8e5cd32..b3ae107 100644 --- a/src/features/tech-log/presentation/public/pages/projects-page.tsx +++ b/src/features/tech-log/presentation/public/pages/projects-page.tsx @@ -1,18 +1,20 @@ import { Link } from "react-router-dom"; -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; export function ProjectsPage() { - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const projects = publicContent - .searchPublicContent("") - .filter((item) => item.contentType === "PROJECT") - .flatMap((item) => { - const project = publicContent.getProject(item.path.replace("/projects/", "")); - return project ? [project] : []; - }); + const view = usePublicContent(["tech-log", "projects"], async (queries) => { + const entries = (await queries.searchPublicContent("")).filter( + (item) => item.contentType === "PROJECT", + ); + const resolved = await Promise.all( + entries.map((item) => queries.getProject(item.path.replace("/projects/", ""))), + ); + return { projects: resolved.filter((project) => project !== undefined) }; + }); + if (!view.ready) return view.fallback; + const { projects } = view.data; return (
(); const slug = optionalString(params.slug); - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const record = slug ? publicContent.getRecord("QUESTION", slug) : undefined; + const view = usePublicContent( + ["tech-log", "question", slug], + async (queries) => ({ + record: slug ? await queries.getRecord("QUESTION", slug) : undefined, + }), + ); + if (!view.ready) return view.fallback; + const { record } = view.data; return record ? ( ) : ( diff --git a/src/features/tech-log/presentation/public/pages/reference-page.tsx b/src/features/tech-log/presentation/public/pages/reference-page.tsx index eb80a5d..03dd9d5 100644 --- a/src/features/tech-log/presentation/public/pages/reference-page.tsx +++ b/src/features/tech-log/presentation/public/pages/reference-page.tsx @@ -1,10 +1,9 @@ -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { RegisteredNotFoundRoute, useRouteInput, } from "../../../../../presentation/routes/route-input.tsx"; import { ReferenceDocumentPage } from "../components/reference-document-page.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; @@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined { export function ReferencePage() { const { params } = useRouteInput<"TECH_LOG_REFERENCE">(); const slug = optionalString(params.slug); - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const record = slug ? publicContent.getRecord("REFERENCE", slug) : undefined; + const view = usePublicContent( + ["tech-log", "reference", slug], + async (queries) => ({ + record: slug ? await queries.getRecord("REFERENCE", slug) : undefined, + }), + ); + if (!view.ready) return view.fallback; + const { record } = view.data; return record ? ( ) : ( diff --git a/src/features/tech-log/presentation/public/pages/release-page.tsx b/src/features/tech-log/presentation/public/pages/release-page.tsx index bbfa602..f6d45ba 100644 --- a/src/features/tech-log/presentation/public/pages/release-page.tsx +++ b/src/features/tech-log/presentation/public/pages/release-page.tsx @@ -1,18 +1,20 @@ import { Link } from "react-router-dom"; -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { RegisteredNotFoundRoute, useRouteInput, } from "../../../../../presentation/routes/route-input.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; export function ReleasePage() { const { params } = useRouteInput<"TECH_LOG_RELEASE">(); const version = typeof params.version === "string" ? params.version : ""; - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const release = publicContent.getRelease(version); + const view = usePublicContent(["tech-log", "release", version], async (queries) => ({ + release: await queries.getRelease(version), + })); + if (!view.ready) return view.fallback; + const { release } = view.data; if (!release) return ; return ( diff --git a/src/features/tech-log/presentation/public/pages/releases-page.tsx b/src/features/tech-log/presentation/public/pages/releases-page.tsx index a129c00..6818caf 100644 --- a/src/features/tech-log/presentation/public/pages/releases-page.tsx +++ b/src/features/tech-log/presentation/public/pages/releases-page.tsx @@ -1,18 +1,20 @@ import { Link } from "react-router-dom"; -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; export function ReleasesPage() { - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const releases = publicContent - .searchPublicContent("") - .filter((item) => item.contentType === "RELEASE") - .flatMap((item) => { - const release = publicContent.getRelease(item.path.replace("/releases/", "")); - return release ? [release] : []; - }); + const view = usePublicContent(["tech-log", "releases"], async (queries) => { + const entries = (await queries.searchPublicContent("")).filter( + (item) => item.contentType === "RELEASE", + ); + const resolved = await Promise.all( + entries.map((item) => queries.getRelease(item.path.replace("/releases/", ""))), + ); + return { releases: resolved.filter((release) => release !== undefined) }; + }); + if (!view.ready) return view.fallback; + const { releases } = view.data; return (
(); const query = optionalString(search.q)?.trim() ?? ""; - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - const results = publicContent.searchPublicContent(query); + const view = usePublicContent(["tech-log", "search", query], async (queries) => ({ + results: await queries.searchPublicContent(query), + })); function submit(event: React.FormEvent) { event.preventDefault(); @@ -25,6 +25,9 @@ export function SearchPage() { void navigate(`/search?q=${encodeURIComponent(nextQuery)}`); } + if (!view.ready) return view.fallback; + + const { results } = view.data; return

Search

검색

제목과 요약, 주제, 프로젝트를 함께 검색합니다.

diff --git a/src/features/tech-log/presentation/public/pages/topic-page.tsx b/src/features/tech-log/presentation/public/pages/topic-page.tsx index cdcbf21..aaa7434 100644 --- a/src/features/tech-log/presentation/public/pages/topic-page.tsx +++ b/src/features/tech-log/presentation/public/pages/topic-page.tsx @@ -1,10 +1,9 @@ -import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; -import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { RegisteredNotFoundRoute, useRouteInput, } from "../../../../../presentation/routes/route-input.tsx"; import { PublicRecordList } from "../components/public-record-list.tsx"; +import { usePublicContent } from "../use-public-content.tsx"; const topics = { jpa: { @@ -37,11 +36,17 @@ function topicConfig(value: unknown) { export function TopicPage() { const { params } = useRouteInput<"TECH_LOG_TOPIC">(); const topic = topicConfig(params.slug); - const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); - + // Hooks run unconditionally, so the unknown-topic case is handled by the + // loader and the not-found route is chosen after it. + const view = usePublicContent( + ["tech-log", "topic", topic?.title], + async (queries) => + topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] }, + ); if (!topic) return ; + if (!view.ready) return view.fallback; - const records = publicContent.listRecords({ topic: topic.title }); + const { records } = view.data; return (
diff --git a/src/features/tech-log/presentation/public/use-public-content.tsx b/src/features/tech-log/presentation/public/use-public-content.tsx new file mode 100644 index 0000000..0c08c97 --- /dev/null +++ b/src/features/tech-log/presentation/public/use-public-content.tsx @@ -0,0 +1,109 @@ +import { useCallback, useMemo, type ReactNode } from "react"; + +import { createFailure } from "../../../../contracts/errors.ts"; +import { + LoadingSurface, + TerminalErrorSurface, +} from "../../../../presentation/components/async-surface.tsx"; +import { useApplicationQuery } from "../../../../presentation/adapters/query/index.ts"; +import { useApplication } from "../../../../presentation/providers/application-provider.tsx"; +import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts"; +import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts"; + +/** + * One query per screen, not one per call. + * + * The public pages were written against a synchronous fixture, so they read + * whatever they needed inline — and several read in a loop: the home timeline + * walks every project for its activity, the explore filter walks search results + * to resolve project titles. Turning each of those into its own hook would mean + * a variable number of hooks per render, which React forbids outright. + * + * So a screen loads everything in one `execute`, where a loop is just a loop and + * `Promise.all` is available. The cost is that a screen waits for its slowest + * read; the benefit is that the page bodies keep computing from plain values and + * the markup is unchanged. + * + * The return is a discriminated union so a page can hand back `view.fallback` + * and have `view.data` narrow to present on the line after — without that, every + * page would need its own non-null assertion. + */ +export type PublicContentView = + | Readonly<{ ready: false; fallback: ReactNode; data?: undefined }> + | Readonly<{ ready: true; fallback: null; data: Value }>; + +/** + * `Value extends object` is load-bearing, not decoration. `undefined` is how the + * query layer says "no result yet", so a loader that returned the record itself + * would make a genuinely missing slug — `getRecord` resolving to `undefined` — + * indistinguishable from a request still in flight, and the page would sit on a + * loading skeleton instead of rendering its not-found route. Wrapping the + * screen's reads in an object keeps the two apart. + */ +export function usePublicContent( + queryKey: readonly unknown[], + load: (queries: PublicContentQueries) => Promise, +): PublicContentView { + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + + // `load` is a new closure every render, so depending on it would re-run the + // query forever. The key is the declared identity of the request — the same + // rule the rest of the query layer follows — so the key is what this closes + // over. + const execute = useCallback( + async () => { + try { + return { ok: true as const, value: await load(publicContent) }; + } catch (cause) { + return { ok: false as const, error: failureFor(cause) }; + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by queryKey, see above + [publicContent, ...queryKey], + ); + + const query = useApplicationQuery( + useMemo(() => ({ queryKey, execute }), [execute, queryKey]), + ); + + if (query.data !== undefined) { + return Object.freeze({ ready: true as const, fallback: null, data: query.data }); + } + const failure = query.state.failure; + return Object.freeze({ + ready: false as const, + fallback: failure ? ( + void query.retry()} + /> + ) : ( + + ), + }); +} + +/** + * Adapters throw. One that knows what went wrong attaches the classified + * failure to the error; anything else arriving here is a defect in this layer + * rather than a server condition, and is not reported as one. + */ +function failureFor(cause: unknown) { + const attached = (cause as { failure?: unknown } | null)?.failure; + if (isAppFailure(attached)) return attached; + return createFailure("UNKNOWN_CLIENT_FAILURE", "TECH_LOG_PUBLIC_CONTENT", 0, { + code: "PUBLIC_CONTENT_UNAVAILABLE", + }); +} + +function isAppFailure( + value: unknown, +): value is ReturnType { + return ( + typeof value === "object" && + value !== null && + typeof (value as { kind?: unknown }).kind === "string" && + typeof (value as { code?: unknown }).code === "string" + ); +} diff --git a/src/features/tech-log/presentation/studio/studio-shell.tsx b/src/features/tech-log/presentation/studio/studio-shell.tsx index 3001b1f..99d49c7 100644 --- a/src/features/tech-log/presentation/studio/studio-shell.tsx +++ b/src/features/tech-log/presentation/studio/studio-shell.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useState, type ReactNode } from "react"; + +import { usePublicContent } from "../public/use-public-content.tsx"; import { useLocation, useNavigate } from "react-router-dom"; import { useApplication } from "../../../../presentation/providers/application-provider.tsx"; @@ -60,13 +62,20 @@ export function StudioShell({ children }: StudioShellProps) { () => application.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(), [application], ); + // `ResolvePublishedLabel` is called from inside the public renderer, which is + // synchronous by design — making it async would push awaits through the whole + // render tree. So the catalog is loaded once here and the callback stays a + // lookup over what has already arrived. Before it arrives the renderer falls + // back to its own "게시 전" label, which is what it showed for an unknown path + // anyway. + const publishedLabels = usePublicContent( + ["tech-log", "studio", "published-labels"], + async (queries) => ({ records: await queries.listRecords() }), + ); + const records = publishedLabels.data?.records; const resolvePublishedLabel = useCallback( - (path: string) => - application.features - .get(TECH_LOG_FEATURE_ID) - .publicContent.listRecords() - .find((record) => record.path === path)?.publishedLabel, - [application], + (path: string) => records?.find((record) => record.path === path)?.publishedLabel, + [records], ); useEffect(() => { diff --git a/tests/component/product-feature-switch.test.tsx b/tests/component/product-feature-switch.test.tsx index 187594f..49de899 100644 --- a/tests/component/product-feature-switch.test.tsx +++ b/tests/component/product-feature-switch.test.tsx @@ -49,10 +49,12 @@ const { AppRouter } = await import("../../src/presentation/routes/app-router.tsx const { ApplicationProvider } = await import( "../../src/presentation/providers/application-provider.tsx" ); +const { renderWithQueryProviders } = await import("../helpers/query-providers.tsx"); function renderAt(path: string, disabled: boolean) { window.history.pushState({}, "", path); return render( + renderWithQueryProviders( , + ), ); } diff --git a/tests/component/router.test.tsx b/tests/component/router.test.tsx index 1d2e208..37d6aea 100644 --- a/tests/component/router.test.tsx +++ b/tests/component/router.test.tsx @@ -17,6 +17,7 @@ import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/ import { MOCK_STUDIO_INSTALL_CONTEXT } from "../helpers/studio-install-context.ts"; import { TECH_LOG_FEATURE_ID } from "../../src/features/tech-log/application/tech-log-feature-input.ts"; import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx"; +import { renderWithQueryProviders } from "../helpers/query-providers.tsx"; import { AppRouter, createGroupedRouteObjects, @@ -123,14 +124,16 @@ function createSignedInSessionAdapter() { function renderRouter(session = createAnonymousSessionAdapter()) { const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT); return render( - - - , + renderWithQueryProviders( + + + , + ), ); } @@ -154,15 +157,17 @@ describe("generic application router", () => { }); render( - - - - - - - - - , + renderWithQueryProviders( + + + + + + + + + , + ), ); expect( @@ -278,15 +283,17 @@ describe("generic application router", () => { }); render( - - - - - - - - - , + renderWithQueryProviders( + + + + + + + + + , + ), ); expect(await screen.findByTestId("studio-layout")).toBeVisible(); @@ -325,15 +332,17 @@ describe("generic application router", () => { }); render( - - - - - - - - - , + renderWithQueryProviders( + + + + + + + + + , + ), ); expect( diff --git a/tests/features/reference-feature/reference-runtime-composition.test.ts b/tests/features/reference-feature/reference-runtime-composition.test.ts index 490fd72..76de54e 100644 --- a/tests/features/reference-feature/reference-runtime-composition.test.ts +++ b/tests/features/reference-feature/reference-runtime-composition.test.ts @@ -30,6 +30,7 @@ const runtime: Runtime = { }, FEATURE_OVERRIDES: {}, TECH_LOG_STUDIO_SOURCE: "MOCK", + TECH_LOG_PUBLIC_SOURCE: "MOCK", }, configSchema: "V2", build: { diff --git a/tests/features/tech-log/runtime-composition.test.ts b/tests/features/tech-log/runtime-composition.test.ts index 41d4b8b..039441a 100644 --- a/tests/features/tech-log/runtime-composition.test.ts +++ b/tests/features/tech-log/runtime-composition.test.ts @@ -35,9 +35,11 @@ function inputOf(document: WorkingCopy) { function installedInputs( studioSource: "MOCK" | "HTTP" = "MOCK", + publicSource: "MOCK" | "HTTP" = "MOCK", ): InstalledInputs { return createInstalledFeatureInputs({ studioSource, + publicSource, contractOperations: { async execute() { throw new Error("reference executor is not used by composition tests"); @@ -53,7 +55,7 @@ function installedInputs( }); } -test("installs TechLog beside the retained reference feature through application-facing inputs", () => { +test("installs TechLog beside the retained reference feature through application-facing inputs", async () => { const installed = installedInputs(); assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]); @@ -66,7 +68,7 @@ test("installs TechLog beside the retained reference feature through application assert.equal(Object.isFrozen(installed["tech-log"]), true); assert.equal(Object.isFrozen(installed["tech-log"].publicContent), true); assert.equal( - installed["tech-log"].publicContent.getRelease("0.1.0")?.title, + (await installed["tech-log"].publicContent.getRelease("0.1.0"))?.title, "TechLog Public·Studio 경계를 확정했습니다", ); }); @@ -109,9 +111,11 @@ test("each createStudioGateway call owns an isolated mutable Studio session", as secondBefore.document.title, ); assert.equal( - installed["tech-log"].publicContent.getRecord( - "CASE", - "collection-fetch-join-pagination", + ( + await installed["tech-log"].publicContent.getRecord( + "CASE", + "collection-fetch-join-pagination", + ) )?.title, "컬렉션 Fetch Join과 페이징은 왜 충돌하는가", ); diff --git a/tests/helpers/query-providers.tsx b/tests/helpers/query-providers.tsx new file mode 100644 index 0000000..801e64c --- /dev/null +++ b/tests/helpers/query-providers.tsx @@ -0,0 +1,70 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +import { MutationIntentProvider } from "../../src/presentation/adapters/query/mutation-intent-provider.tsx"; +import { QueryInvalidationProvider } from "../../src/presentation/adapters/query/query-invalidation-provider.tsx"; +import type { MutationIntentFactory } from "../../src/application/ports/mutation-intent-factory.ts"; +import type { QueryInvalidationCoordinator } from "../../src/contracts/query-invalidation.ts"; + +/** + * The provider stack `useApplicationQuery` needs, in the order the running app + * assembles it (`ServerStateGenerationProvider`). + * + * A component test that renders a screen reading server state has to supply + * this or the render throws "No QueryClient set" — which is not a test-harness + * quirk but the same failure the app would produce if it were mounted without + * its query layer. + */ +export function renderWithQueryProviders(children: ReactNode): ReactNode { + const client = new QueryClient({ + defaultOptions: { + // Deterministic: a component test asserts on one settled render, so a + // retry would only turn a real failure into a timeout. + queries: { retry: false, staleTime: 0, gcTime: Infinity }, + mutations: { retry: false }, + }, + }); + const coordinator: QueryInvalidationCoordinator = { + async invalidate(topics) { + for (const topic of topics) { + await client.invalidateQueries({ + queryKey: [topic], + exact: false, + refetchType: "active", + }); + } + }, + beginMutation() { + return { release: async () => {} }; + }, + async resetLocal() { + await client.cancelQueries(); + client.clear(); + }, + dispose() {}, + }; + let sequence = 0; + const mutationIntentFactory: MutationIntentFactory = Object.freeze({ + create(input) { + sequence += 1; + return Object.freeze({ + intentId: `intent-${sequence}`, + operationId: input.operationId, + canonicalInputIdentity: input.canonicalInputIdentity, + ...(input.requiresIdempotencyKey + ? { idempotencyKey: `key-${sequence}` } + : {}), + createdAtMonotonicMs: sequence, + }); + }, + }); + return ( + + + + {children} + + + + ); +} diff --git a/tests/helpers/studio-install-context.ts b/tests/helpers/studio-install-context.ts index 7e139cc..963d523 100644 --- a/tests/helpers/studio-install-context.ts +++ b/tests/helpers/studio-install-context.ts @@ -15,6 +15,7 @@ import type { TechLogInstallContext } from "../../src/features/tech-log/adapters */ export const MOCK_STUDIO_INSTALL_CONTEXT: TechLogInstallContext = Object.freeze({ studioSource: "MOCK", + publicSource: "MOCK", contractOperations: Object.freeze({ async execute() { throw new Error("contract executor is not used by the mock Studio gateway"); diff --git a/tests/runtime-schema/release-manifest.test.ts b/tests/runtime-schema/release-manifest.test.ts index 750ef1e..ff2a1c1 100644 --- a/tests/runtime-schema/release-manifest.test.ts +++ b/tests/runtime-schema/release-manifest.test.ts @@ -47,6 +47,7 @@ const runtime: Parameters[0] = { }, FEATURE_OVERRIDES: {}, TECH_LOG_STUDIO_SOURCE: "MOCK", + TECH_LOG_PUBLIC_SOURCE: "MOCK", }, configSchema: "V2", validationDurationMs: 0, diff --git a/tests/unit/release-coherence.test.ts b/tests/unit/release-coherence.test.ts index 31957ce..984ffcd 100644 --- a/tests/unit/release-coherence.test.ts +++ b/tests/unit/release-coherence.test.ts @@ -76,6 +76,7 @@ const runtimeV2 = { }, FEATURE_OVERRIDES: {}, TECH_LOG_STUDIO_SOURCE: "MOCK", + TECH_LOG_PUBLIC_SOURCE: "MOCK", } as const satisfies RuntimeConfigArtifact; async function releaseV2With( diff --git a/tests/unit/runtime-adapters.test.ts b/tests/unit/runtime-adapters.test.ts index fbc4b17..14e07e4 100644 --- a/tests/unit/runtime-adapters.test.ts +++ b/tests/unit/runtime-adapters.test.ts @@ -28,6 +28,7 @@ const runtime: Runtime = { }, FEATURE_OVERRIDES: {}, TECH_LOG_STUDIO_SOURCE: "MOCK", + TECH_LOG_PUBLIC_SOURCE: "MOCK", }, configSchema: "V2", build: {