From 9f790dbb24557381579138dccb60402068616f5e Mon Sep 17 00:00:00 2001 From: James Willis Date: Fri, 27 Mar 2026 13:32:26 +0000 Subject: [PATCH 1/4] perf(studio): eliminate N badge-fetch API calls on studio load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On every studio open, the client was firing N parallel requests to `GET /api/v1/pkgs/:pkgId/versions-without-data` (one per direct dependency) to populate "update available" badges in the sidebar. Each request acquired its own DB connection, ran a full permission chain, and saturated the pool alongside all the other concurrent startup requests. This commit eliminates those calls on initial load by including the latest published version for every direct dependency in the `getSiteInfo` response, and parallelises the remaining sequential queries in `getProjectRev`. Changes by area: server/routes/projects.ts + AppServer.ts - Remove `withNext` (outer transaction) from `GET /api/v1/projects/:id` so queries use pool connections and can run concurrently. - Restructure `getProjectRev` into three parallel rounds: Round 1 – 9 independent queries in Promise.all (branch, project, rev, perms, modelVersion, hostlessVersion, latestRevisionSynced, appAuthConfig, allowedDataSources). Round 2 – migratedBundle, owner fetch, workspaceTutorialDbs in parallel (depend on project/rev from Round 1). Round 3 – loadDepPackages → getLatestPkgVersionsForPkgIds (depend on migratedBundle). - `claimPublicProject` (write path) gets its own explicit startTransaction since the outer withNext is removed. - Add `latestDepPkgVersions` to the response payload. server/db/DbMgr.ts - Add `getLatestPkgVersionsForPkgIds`: single WHERE IN query fetching the latest main-branch PkgVersion per pkgId, excluding the heavy `model` column. No permission check — called inside getProjectRev which has already verified access, same trust level as loadDepPackages. - Refactor `checkPkgPerms` to return the Pkg entity it already fetches, removing a redundant `getPkgById` call in `publishPkgVersion`. - Remove redundant `checkPkgPerms` call from `listPkgVersionsRaw` (the endpoint-level auth check in `listPkgVersions` is sufficient). server/db/bundle-migration-utils.ts - Replace the 10-minute TTL cache invalidation for hostless data with an explicit flag (`_hostlessCacheStale`) that is set on startup and cleared only after a successful reload. `invalidateHostlessCache()` is called by `bumpHostlessVersion` so the cache is always fresh after a publish, without polling the DB on every request. - Remove `hostlessVersionCount` from the cached payload (no longer needed since the cache is invalidated explicitly rather than by comparing version counts). server/db/BundleMigrator.ts - Hoist the `bundleHasStaleHostlessDeps` call before the `isExpectedBundleVersion` check so both conditions are evaluated before branching, avoiding a potential double-await. shared/ApiSchema.ts - Add `latestDepPkgVersions: Record` to `GetProjectResponse`. client/db.ts - Add `latestDepPkgVersions` to `DbCtxArgs` / `DbCtx` so the server-provided map flows through to StudioCtx. client/init-ctx.tsx - Destructure `latestDepPkgVersions` from the getSiteInfo response and pass it to DbCtx. - Simplify `checkDepPkgHosts`: it already has the full PkgVersionInfo from `depPkgs` so there's no need to fire N extra `getPkgVersionMeta` calls — use the already-loaded data directly. client/ProjectDependencyManager.ts - Add `seedLatestVersionMeta`: pre-populates latestPkgVersionMeta in _dependencyMap from the server-provided map. Since `_fetchLatestVersionMeta` skips the API call when the field is already set, this eliminates all N badge-fetch calls on first load. - Add `setPlumePkgFetch`: allows the Plume package to be fetched in parallel with project load by accepting a pre-started Promise. - Split `_fetchData` into `_fetchLatestVersionMeta` (spawned, non- blocking) and `_fetchPlumeSite` (awaited, required before sync), so badge updates do not block studio startup. - Change `refreshDeps()` to accept `{ forceVersionMeta?: boolean }` (default false) so the initial load does not bypass pre-seeded data. client/studio-ctx/StudioCtx.tsx - Call `seedLatestVersionMeta` immediately after constructing ProjectDependencyManager. client/components/sidebar/ProjectDependencies.tsx - Pass `{ forceVersionMeta: true }` to `refreshDeps` when the user clicks "Check for updates", so manual refresh still hits the API. client/components/studio/studio-initializer.tsx - Fire `getPlumePkg()` before `initStudioCtx` and hand the in-flight Promise to `setPlumePkgFetch`, so the Plume fetch runs in parallel with the project load rather than serially after it. client/api.ts - Remove the now-unnecessary `listPkgVersionsWithoutData` override from `filteredApi` (the function is no longer called by the client in a context that requires it to be filtered). --- .../wab/client/ProjectDependencyManager.ts | 69 +++++++-- platform/wab/src/wab/client/api.ts | 7 +- .../sidebar/ProjectDependencies.tsx | 2 +- .../components/studio/studio-initializer.tsx | 4 + platform/wab/src/wab/client/db.ts | 4 + platform/wab/src/wab/client/init-ctx.tsx | 30 ++-- .../src/wab/client/studio-ctx/StudioCtx.tsx | 3 + platform/wab/src/wab/server/AppServer.ts | 2 +- .../wab/src/wab/server/db/BundleMigrator.ts | 4 +- platform/wab/src/wab/server/db/DbMgr.ts | 51 +++++-- .../wab/server/db/bundle-migration-utils.ts | 35 ++--- .../wab/src/wab/server/routes/projects.ts | 132 +++++++++++------- platform/wab/src/wab/shared/ApiSchema.ts | 1 + 13 files changed, 226 insertions(+), 118 deletions(-) diff --git a/platform/wab/src/wab/client/ProjectDependencyManager.ts b/platform/wab/src/wab/client/ProjectDependencyManager.ts index 3081bad96..daffc9739 100644 --- a/platform/wab/src/wab/client/ProjectDependencyManager.ts +++ b/platform/wab/src/wab/client/ProjectDependencyManager.ts @@ -1,6 +1,6 @@ import { checkDepPkgHosts } from "@/wab/client/init-ctx"; import { StudioCtx } from "@/wab/client/studio-ctx/StudioCtx"; -import { PkgInfo, PkgVersionInfoMeta } from "@/wab/shared/SharedApi"; +import { PkgInfo, PkgVersionInfo, PkgVersionInfoMeta } from "@/wab/shared/SharedApi"; import { FastBundler } from "@/wab/shared/bundler"; import { getUsedDataSourcesFromDep } from "@/wab/shared/cached-selectors"; import { Dict } from "@/wab/shared/collections"; @@ -71,6 +71,12 @@ export class ProjectDependencyManager { // Stores the Site of the Plume project plumeSite: Site | undefined; + // Pre-fetched plumePkg promise — set externally before refreshDeps() is called + // so the fetch can run in parallel with project load. + private _plumePkgFetch: + | Promise<{ pkg: PkgVersionInfo; depPkgs: PkgVersionInfo[] }> + | undefined; + // Tracks Component, Mixin, StyleToken, Theme, ImageAsset and global VariantGroup to // the ProjectDependency it was imported from // This will include all assets across the ENTIRE dependency tree @@ -87,14 +93,26 @@ export class ProjectDependencyManager { } /** - * Fetch any missing data from the server - * TODO: this currently only fetches data once on load and caches it - * - It does not know when to refresh the data if it has changed - * (i.e. if new version published while editing) - * - We could add some logic to know when to refresh - **/ - private async _fetchData(force?: boolean) { - // Get PkgVersionMeta of all project dependencies + * Call this as early as possible (before refreshDeps) to pre-warm the Plume + * package fetch so it runs in parallel with project load. + */ + setPlumePkgFetch( + fetch: Promise<{ pkg: PkgVersionInfo; depPkgs: PkgVersionInfo[] }> + ) { + this._plumePkgFetch = fetch; + } + + seedLatestVersionMeta(latestVersions: Record) { + for (const [pkgId, meta] of Object.entries(latestVersions)) { + if (this._dependencyMap[pkgId]) { + this._dependencyMap[pkgId].latestPkgVersionMeta = meta; + } + } + } + + // Fetches latest version metadata for all direct deps (used for update badges). + // Non-critical — spawned so it doesn't block studio startup. + private async _fetchLatestVersionMeta(force?: boolean) { const data = await Promise.all( L.map(L.values(this._dependencyMap), async (dep) => { return { @@ -102,8 +120,11 @@ export class ProjectDependencyManager { latestPkgVersionMeta: dep.latestPkgVersionMeta && !force ? dep.latestPkgVersionMeta - : (await this._sc.appCtx.api.getPkgVersionMeta(dep.model.pkgId)) - .pkg, + : ( + await this._sc.appCtx.api.listPkgVersionsWithoutData( + dep.model.pkgId + ) + ).pkgVersions[0], }; }) ); @@ -113,12 +134,16 @@ export class ProjectDependencyManager { d.latestPkgVersionMeta; }); }); + } + // Fetches and unbundles the Plume site. Must complete before sync. + // Uses a pre-fetched promise if setPlumePkgFetch() was called earlier. + private async _fetchPlumeSite() { // We no longer plan to make any updates to plume site. So its unnecessary to re-fetch it if it has already been fetched. if (!this.plumeSite) { const bundler = new FastBundler(); - // Get Plume site - const plumePkg = await this._sc.appCtx.api.getPlumePkg(); + const plumePkg = await (this._plumePkgFetch ?? + this._sc.appCtx.api.getPlumePkg()); const plumeSite = unbundleProjectDependency( bundler, plumePkg.pkg, @@ -128,6 +153,20 @@ export class ProjectDependencyManager { } } + /** + * Fetch any missing data from the server + * TODO: this currently only fetches data once on load and caches it + * - It does not know when to refresh the data if it has changed + * (i.e. if new version published while editing) + * - We could add some logic to know when to refresh + **/ + private async _fetchData(force?: boolean) { + // Update badges — non-blocking, runs in background + spawn(this._fetchLatestVersionMeta(force)); + // Plume site — must be ready before sync; likely already in-flight + await this._fetchPlumeSite(); + } + /** * Mutates every component in the site, doing: * - Deref all token references @@ -490,8 +529,8 @@ export class ProjectDependencyManager { } } - async refreshDeps() { - return this._fetchData(true); + async refreshDeps({ forceVersionMeta = false }: { forceVersionMeta?: boolean } = {}) { + return this._fetchData(forceVersionMeta); } private _trackDepObjs(dep: ProjectDependency) { diff --git a/platform/wab/src/wab/client/api.ts b/platform/wab/src/wab/client/api.ts index d92ee0767..d97f84f59 100644 --- a/platform/wab/src/wab/client/api.ts +++ b/platform/wab/src/wab/client/api.ts @@ -481,6 +481,7 @@ export function filteredApi( "listUnpublishedProjectRevisions", "revertProjectToRevision", "getPkgVersionMeta", + "listPkgVersionsWithoutData", "refreshCsrfToken", "getLastBundleVersion", "getAppConfig", @@ -586,12 +587,6 @@ export function filteredApi( saveProjectRevChanges: checkProjectIdInFirstArg, computeNextProjectVersion: checkProjectIdInFirstArg, publishProject: checkProjectIdInFirstArg, - listPkgVersionsWithoutData: - (f) => - async (...args) => { - assert(args[0] === (await getPkgId()), "Unexpected pkgId"); - return f(...args); - }, updatePkgVersion: (f) => async (...args) => { diff --git a/platform/wab/src/wab/client/components/sidebar/ProjectDependencies.tsx b/platform/wab/src/wab/client/components/sidebar/ProjectDependencies.tsx index 7506a77ef..9fe09a1ef 100644 --- a/platform/wab/src/wab/client/components/sidebar/ProjectDependencies.tsx +++ b/platform/wab/src/wab/client/components/sidebar/ProjectDependencies.tsx @@ -347,7 +347,7 @@ function _ProjectDependenciesPanel() { : { onClick: () => { setState("refreshing"); - spawn(sc.projectDependencyManager.refreshDeps()); + spawn(sc.projectDependencyManager.refreshDeps({ forceVersionMeta: true })); setState(undefined); }, "data-test-id": "check-for-updates-btn", diff --git a/platform/wab/src/wab/client/components/studio/studio-initializer.tsx b/platform/wab/src/wab/client/components/studio/studio-initializer.tsx index 7d71ea0d8..b89a2cb7b 100644 --- a/platform/wab/src/wab/client/components/studio/studio-initializer.tsx +++ b/platform/wab/src/wab/client/components/studio/studio-initializer.tsx @@ -94,7 +94,11 @@ class StudioInitializer_ extends React.Component< if (appCtx.appConfig.incrementalObservables) { makeGlobalObservable(); } + // Plume package doesn't depend on project data — fetch it in parallel with + // project load so it's ready (or nearly ready) by the time refreshDeps runs. + const plumePkgFetch = appCtx.api.getPlumePkg(); const studioCtx = await initStudioCtx(appCtx, projectId, onRefreshUi); + studioCtx.projectDependencyManager.setPlumePkgFetch(plumePkgFetch); const previewCtx = new PreviewCtx(hostFrameCtx, studioCtx); spawn(studioCtx.startListeningForSocketEvents()); diff --git a/platform/wab/src/wab/client/db.ts b/platform/wab/src/wab/client/db.ts index da0817a63..e51af97b4 100644 --- a/platform/wab/src/wab/client/db.ts +++ b/platform/wab/src/wab/client/db.ts @@ -31,6 +31,7 @@ class DbCtxArgs { appCtx: AppCtx; revisionNum: number; branch?: ApiBranch; + latestDepPkgVersions: Record; } // for use in views @@ -74,6 +75,9 @@ export class DbCtx { get api() { return this.args.api; } + get latestDepPkgVersions() { + return this.args.latestDepPkgVersions ?? {}; + } get siteInfo() { return this._siteInfo.get(); } diff --git a/platform/wab/src/wab/client/init-ctx.tsx b/platform/wab/src/wab/client/init-ctx.tsx index 1539cb853..420a65bbf 100644 --- a/platform/wab/src/wab/client/init-ctx.tsx +++ b/platform/wab/src/wab/client/init-ctx.tsx @@ -7,7 +7,7 @@ import { } from "@/wab/client/cli-routes"; import * as DbMod from "@/wab/client/db"; import { ApiBranch, MainBranchId, ProjectId } from "@/wab/shared/ApiSchema"; -import { SiteInfo } from "@/wab/shared/SharedApi"; +import { PkgVersionInfo, SiteInfo } from "@/wab/shared/SharedApi"; import * as slotUtils from "@/wab/shared/SlotUtils"; import { $$$ } from "@/wab/shared/TplQuery"; import { getBundle } from "@/wab/shared/bundles"; @@ -18,7 +18,6 @@ import { unbundleSite } from "@/wab/shared/core/tagged-unbundle"; import * as tpls from "@/wab/shared/core/tpls"; import { getProjectFlags } from "@/wab/shared/devflags"; import { instUtil } from "@/wab/shared/model/InstUtil"; -import { ProjectDependency } from "@/wab/shared/model/classes"; import { APP_ROUTES } from "@/wab/shared/route/app-routes"; import { fillRoute } from "@/wab/shared/route/route"; import { fixPageHrefsToLocal } from "@/wab/shared/utils/split-site-utils"; @@ -57,6 +56,7 @@ export async function loadSiteDbCtx( appAuthProvider, workspaceTutorialDbs, isMainBranchProtected, + latestDepPkgVersions, } = await (async () => { try { return await baseApi.getSiteInfo(siteId, { branchId: branch?.id }); @@ -85,14 +85,14 @@ export async function loadSiteDbCtx( siteInfo.isMainBranchProtected = isMainBranchProtected; const bundle = getBundle(rev, appCtx.lastBundleVersion); - const { site, depPkgs: depPkgVersions } = unbundleSite( + const { site } = unbundleSite( bundler, siteInfo.id, bundle, depPkgs ); appCtx.appConfig = getProjectFlags(site, appCtx.appConfig); - spawn(checkDepPkgHosts(appCtx, siteInfo, depPkgVersions)); + spawn(checkDepPkgHosts(appCtx, siteInfo, depPkgs)); // Enable data queries after RSC release if any components already use them. // Occurs after applyPlasmicUserDevFlagOverrides, so skip if already enabled @@ -124,25 +124,21 @@ export async function loadSiteDbCtx( appCtx, revisionNum: rev.revision, branch, + latestDepPkgVersions, }); return dbCtx; } -export async function checkDepPkgHosts( +export function checkDepPkgHosts( appCtx: AppCtx, siteInfo: SiteInfo, - deps: ProjectDependency[] + deps: PkgVersionInfo[] ) { - const pkgMetas = await Promise.all( - deps.map((dep) => appCtx.api.getPkgVersionMeta(dep.pkgId, dep.version)) - ); - for (const pkgVersion of pkgMetas) { + for (const dep of deps) { if ( - pkgVersion.pkg.hostUrl && - ![siteInfo.hostUrl, appCtx.appConfig.defaultHostUrl].includes( - pkgVersion.pkg.hostUrl - ) + dep.hostUrl && + ![siteInfo.hostUrl, appCtx.appConfig.defaultHostUrl].includes(dep.hostUrl) ) { notification.warn({ message: "This project imports from a project hosted by another app", @@ -153,12 +149,12 @@ export async function checkDepPkgHosts( - {pkgVersion.pkg.pkg?.name} + {dep.pkg?.name} - , which is hosted by {pkgVersion.pkg.hostUrl}.
+ , which is hosted by {dep.hostUrl}.
Notice this can prevent the canvas from rendering components correctly.

diff --git a/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx b/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx index c033d4790..0c9e36fba 100644 --- a/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx +++ b/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx @@ -705,6 +705,9 @@ export class StudioCtx extends WithDbCtx { spawn(this.handleInitialRoute()); this.projectDependencyManager = new ProjectDependencyManager(this); + this.projectDependencyManager.seedLatestVersionMeta( + this._dbCtx.latestDepPkgVersions + ); // styleMgr must be instantiated after ProjectDependencyManager, as // it makes use of TPL_COMPONENT_ROOT updates that ProjectDependencyManager diff --git a/platform/wab/src/wab/server/AppServer.ts b/platform/wab/src/wab/server/AppServer.ts index ab064db1e..a60ec7e3a 100644 --- a/platform/wab/src/wab/server/AppServer.ts +++ b/platform/wab/src/wab/server/AppServer.ts @@ -1569,7 +1569,7 @@ export function addMainAppServerRoutes( "/api/v1/projects/:projectId/main-branch-protection", withNext(setMainBranchProtection) ); - app.get("/api/v1/projects/:projectBranchId", withNext(getProjectRev)); + app.get("/api/v1/projects/:projectBranchId", getProjectRev); app.get( "/api/v1/projects/:projectId/revision-without-data", getProjectRevWithoutData diff --git a/platform/wab/src/wab/server/db/BundleMigrator.ts b/platform/wab/src/wab/server/db/BundleMigrator.ts index adcdd4db5..e2e0ab239 100644 --- a/platform/wab/src/wab/server/db/BundleMigrator.ts +++ b/platform/wab/src/wab/server/db/BundleMigrator.ts @@ -190,9 +190,11 @@ export async function getMigratedBundle( const fixedBundle = await conn.transaction(async (txMgr) => { const db = new DbMgr(txMgr, SUPER_USER); + const stale = await bundleHasStaleHostlessDeps(bundle, db); + if ( isExpectedBundleVersion(bundle, await getLastBundleVersion()) && - !(await bundleHasStaleHostlessDeps(bundle, db)) + !stale ) { return bundle; } diff --git a/platform/wab/src/wab/server/db/DbMgr.ts b/platform/wab/src/wab/server/db/DbMgr.ts index a76e87063..be4beea71 100644 --- a/platform/wab/src/wab/server/db/DbMgr.ts +++ b/platform/wab/src/wab/server/db/DbMgr.ts @@ -2585,15 +2585,16 @@ export class DbMgr implements MigrationDbMgr { const pkg = ensureFound(await this.pkgs().findOne(pkgId), `Pkg ${pkgId}`); // The only pkg without a projectId should be "base". if (!pkg.projectId) { - return; + return pkg; } - return await this.checkProjectPerms( + await this.checkProjectPerms( pkg.projectId, requireLevel, action, suggestion, true ); + return pkg; }; async deleteProject(id: string, _proof: ProofSafeDelete) { @@ -4327,8 +4328,7 @@ export class DbMgr implements MigrationDbMgr { } async getPkgById(id: string) { - await this.checkPkgPerms(id, "viewer", "get"); - return ensureFound(await this.pkgs().findOne(id), `Pkg ${id}`); + return ensureFound(await this.checkPkgPerms(id, "viewer", "get"), `Pkg ${id}`); } async getPkgByProjectId(projectId: string) { @@ -4370,7 +4370,6 @@ export class DbMgr implements MigrationDbMgr { branchId, }: { prefilledOnly?: boolean; branchId?: BranchId } = {} ) { - await this.checkPkgPerms(pkgId, "viewer", "get"); if (branchId) { await this.checkBranchPerms(branchId, "viewer", "get pkg version", true); } @@ -4596,9 +4595,7 @@ export class DbMgr implements MigrationDbMgr { branchId?: BranchId, id?: string ) { - await this.checkPkgPerms(pkgId, "content", "publish"); - - const pkg = await this.getPkgById(pkgId); + const pkg = await this.checkPkgPerms(pkgId, "content", "publish"); const pkgVersion = this.pkgVersions().create({ ...this.stampNew(), pkg, @@ -4658,11 +4655,47 @@ export class DbMgr implements MigrationDbMgr { .sort((a, b) => (semver.gt(a.version, b.version) ? -1 : +1)); } + /** + * Single-query batch fetch of the latest main-branch version for each of the + * given pkgIds. No permission check — only call this server-side within an + * already-authenticated handler where the caller has verified project access + * (same trust level as loadDepPackages). Returns a map pkgId → PkgVersion. + */ + async getLatestPkgVersionsForPkgIds( + pkgIds: string[] + ): Promise> { + if (pkgIds.length === 0) { + return {}; + } + const columns = this.entMgr.connection + .getMetadata(PkgVersion) + .columns.filter((c) => c.databaseName !== "model") + .map((c) => `pkgVersion.${c.databaseName}`); + + const rows = await this.pkgVersions() + .createQueryBuilder("pkgVersion") + .select(columns) + .leftJoinAndSelect("pkgVersion.pkg", "pkg") + .leftJoinAndSelect("pkgVersion.branch", "branch") + .where("pkgVersion.pkgId IN (:...pkgIds)", { pkgIds }) + .andWhere("pkgVersion.branchId IS NULL") + .andWhere("pkgVersion.deletedAt IS NULL") + .getMany(); + + // Group by pkgId and pick the highest semver per pkg. + const grouped = L.groupBy(rows, (r) => r.pkgId); + return Object.fromEntries( + Object.entries(grouped).map(([pkgId, versions]) => [ + pkgId, + versions.sort((a, b) => (semver.gt(a.version, b.version) ? -1 : 1))[0], + ]) + ); + } + private async listPkgVersionsRaw( pkgId: string, opts: { includeData?: boolean; branchId?: BranchId } = {} ) { - await this.checkPkgPerms(pkgId, "viewer", "get"); const columns = this.entMgr.connection .getMetadata(PkgVersion) .columns.map((c) => `pkgVersion.${c.databaseName}`); diff --git a/platform/wab/src/wab/server/db/bundle-migration-utils.ts b/platform/wab/src/wab/server/db/bundle-migration-utils.ts index a7f2dee9b..2fdac132c 100644 --- a/platform/wab/src/wab/server/db/bundle-migration-utils.ts +++ b/platform/wab/src/wab/server/db/bundle-migration-utils.ts @@ -164,6 +164,19 @@ export async function upgradeHostlessProject( ); } +// Set to true on startup and whenever bumpHostlessVersion is called. +// Causes the next getHostlessData() call to reload from DB. +// Never expires on a timer — only invalidated by an explicit publish. +let _hostlessCacheStale = true; + +/** + * Called by DbMgr.bumpHostlessVersion() after a hostless package is published. + * Forces the next getHostlessData() call to reload from the DB. + */ +export function invalidateHostlessCache() { + _hostlessCacheStale = true; +} + export const getHostlessData = (() => { const fn = async (db: MigrationDbMgr) => { logger().info("Refreshing hostless data"); @@ -201,35 +214,25 @@ export const getHostlessData = (() => { latestVersions.add(latestVersion.id); }) ); + // Mark the cache as fresh only after a successful load. + _hostlessCacheStale = false; return { versionIdToLatestVersion, latestVersions, - hostlessVersionCount: (await db.getHostlessVersion()).versionCount, }; }; let cachedData: ReturnType | undefined = undefined; - let lastEvaluationTime = 0; const runAndCache = (db: MigrationDbMgr, opts?: { clearCache?: boolean }) => { - if ( - !cachedData || - opts?.clearCache || - performance.now() - lastEvaluationTime >= 1000 * 60 * 10 - ) { + if (!cachedData || opts?.clearCache) { cachedData = fn(db); - lastEvaluationTime = performance.now(); } return cachedData; }; - return async (db: MigrationDbMgr) => { - const result = await runAndCache(db); - if ( - db instanceof DbMgr && - result?.hostlessVersionCount !== - (await db.getHostlessVersion()).versionCount - ) { + return (db: MigrationDbMgr) => { + if (_hostlessCacheStale) { return runAndCache(db, { clearCache: true }); } - return result; + return runAndCache(db); }; })(); diff --git a/platform/wab/src/wab/server/routes/projects.ts b/platform/wab/src/wab/server/routes/projects.ts index 267b90107..793864389 100644 --- a/platform/wab/src/wab/server/routes/projects.ts +++ b/platform/wab/src/wab/server/routes/projects.ts @@ -115,6 +115,7 @@ import { import { Bundle, OutdatedBundleError, + UnsafeBundle, getBundle, getSerializedBundleSize, isExpectedBundleVersion, @@ -1535,63 +1536,90 @@ export async function getProjectRev(req: Request, res: Response) { const { projectId, branchId } = parseProjectBranchId( req.params.projectBranchId ); - const branch = branchId ? await mgr.getBranchById(branchId) : undefined; - const project = await mgr.getProjectById(projectId); - req.promLabels.projectId = projectId; - const rev = + // Round 1: all independent queries run in parallel + const [ + branch, + project, + rev, + allPerms, + modelVersion, + hostlessVersion, + latestRevisionSynced, + appAuthConfig, + allowedDataSources, + ] = await Promise.all([ + branchId ? mgr.getBranchById(branchId) : Promise.resolve(undefined), + mgr.getProjectById(projectId), revisionNum !== undefined || revisionId - ? await mgr.getProjectRevision( + ? mgr.getProjectRevision( projectId, revisionNum !== undefined ? +revisionNum : revisionId!, branchId ) - : await mgr.getLatestProjectRev(projectId, { branchId }); + : mgr.getLatestProjectRev(projectId, { branchId }), + mgr.getPermissionsForProject(projectId), + getCurrentModelVersion(req.noTxMgr), + mgr.getHostlessVersion(), + getLatestRevisionSynced(mgr, projectId), + mgr.getPublicAppAuthConfig(projectId), + mgr.listAllowedDataSourcesForProject(projectId as ProjectId), + ]); + + req.promLabels.projectId = projectId; + const perms = project.readableByPublic - ? (await mgr.getPermissionsForProject(projectId)).filter((perm) => { - return ( + ? allPerms.filter( + (perm) => accessLevelRank(perm.accessLevel) >= accessLevelRank("commenter") - ); - }) - : await mgr.getPermissionsForProject(projectId); - const depPkgs = await loadDepPackages( - mgr, - dontMigrateProject ? JSON.parse(rev.data) : await getMigratedBundle(rev), - { dontMigrateBundle: dontMigrateProject } - ); - const modelVersion = await getCurrentModelVersion(req.txMgr || req.noTxMgr); - const hostlessDataVersion = (await mgr.getHostlessVersion()).versionCount; - let owner: User | undefined; - if (!project.createdById) { - const actorId = mgr.tryGetNormalActorId(); - if (actorId) { - await mgr.claimPublicProject(project.id, actorId); - } - } else { - owner = await mgr.tryGetUserById(project.createdById); - } - const latestRevisionSynced = await getLatestRevisionSynced(mgr, projectId); - // Make sure this revision bundle is up to date. - if (!dontMigrateProject) { - await getMigratedBundle(rev); - } - - const appAuthConfig = await mgr.getPublicAppAuthConfig(projectId); - const hasAppAuth = !!appAuthConfig; - const appAuthProvider = appAuthConfig?.provider; + ) + : allPerms; + + // Round 2: depends on project/rev from Round 1 + const [migratedBundle, owner, workspaceTutorialDbsRaw] = await Promise.all([ + dontMigrateProject + ? Promise.resolve(JSON.parse(rev.data) as UnsafeBundle) + : getMigratedBundle(rev), + project.createdById + ? mgr.tryGetUserById(project.createdById) + : (async () => { + const actorId = mgr.tryGetNormalActorId(); + if (actorId) { + await startTransaction(req, async () => { + await mgr.claimPublicProject(project.id, actorId); + return commitTransaction(); + }); + } + return undefined; + })(), + project.workspaceId + ? mgr.getWorkspaceTutorialDataSources(project.workspaceId) + : Promise.resolve([]), + ]); - const allowedDataSourceIds = ( - await mgr.listAllowedDataSourcesForProject(projectId as ProjectId) - ).map((ds) => ds.dataSourceId); + // Round 3: depends on migratedBundle from Round 2 + const depPkgs = await loadDepPackages(mgr, migratedBundle, { + dontMigrateBundle: dontMigrateProject, + }); - const workspaceTutorialDbs = project.workspaceId - ? (await mgr.getWorkspaceTutorialDataSources(project.workspaceId)) - .filter( - (ds) => - ds.source === "tutorialdb" && allowedDataSourceIds.includes(ds.id) - ) + // Identify direct dep pkgIds from bundle.deps (which contains PkgVersion IDs) + const directDepPkgVersionIds = new Set(migratedBundle.deps ?? []); + const directDepPkgIds = depPkgs + .filter((p) => directDepPkgVersionIds.has(p.id as string)) + .map((p) => p.pkgId) + .filter((pkgId): pkgId is string => pkgId != null); + const latestDepPkgVersions = + await mgr.getLatestPkgVersionsForPkgIds(directDepPkgIds); + + const allowedDataSourceIds = allowedDataSources.map((ds) => ds.dataSourceId); + const workspaceTutorialDbs = workspaceTutorialDbsRaw + .filter( + (ds) => + ds.source === "tutorialdb" && allowedDataSourceIds.includes(ds.id) + ) + .map((ds) => mkApiDataSource(ds)); - .map((ds) => mkApiDataSource(ds)) - : []; + const hasAppAuth = !!appAuthConfig; + const appAuthProvider = appAuthConfig?.provider; req.analytics.track("Open project", { projectId: project.id, @@ -1606,13 +1634,14 @@ export async function getProjectRev(req: Request, res: Response) { perms, depPkgs, modelVersion, - hostlessDataVersion, + hostlessDataVersion: hostlessVersion.versionCount, owner, latestRevisionSynced, hasAppAuth, appAuthProvider, workspaceTutorialDbs, isMainBranchProtected: !!project.isMainBranchProtected, + latestDepPkgVersions, }); } @@ -1991,10 +2020,9 @@ export async function getPkgVersion(req: Request, res: Response) { return; } - res.json({ - ...(await getPkgWithDeps(mgr, pkg, meta, { dontMigrateProject })), - etag, - }); + const result = await getPkgWithDeps(mgr, pkg, meta, { dontMigrateProject }); + + res.json({ ...result, etag }); } export async function listUnpublishedProjectRevisions( diff --git a/platform/wab/src/wab/shared/ApiSchema.ts b/platform/wab/src/wab/shared/ApiSchema.ts index a44000d6f..59e39859b 100644 --- a/platform/wab/src/wab/shared/ApiSchema.ts +++ b/platform/wab/src/wab/shared/ApiSchema.ts @@ -1853,6 +1853,7 @@ export interface GetProjectResponse { appAuthProvider?: AppAuthProvider; workspaceTutorialDbs?: ApiDataSource[]; isMainBranchProtected: boolean; + latestDepPkgVersions: Record; } export type MergeSrcDst = From 5a5484ca23aec940708158f25680bf71a9a25bf5 Mon Sep 17 00:00:00 2001 From: James Willis Date: Fri, 27 Mar 2026 13:35:42 +0000 Subject: [PATCH 2/4] revert(bundle-migration-utils): restore original hostless cache invalidation Revert the _hostlessCacheStale flag approach back to the original 10-minute TTL + hostlessVersionCount comparison. The cache change is out of scope for this PR and can be revisited separately. --- .../wab/server/db/bundle-migration-utils.ts | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/platform/wab/src/wab/server/db/bundle-migration-utils.ts b/platform/wab/src/wab/server/db/bundle-migration-utils.ts index 2fdac132c..a7f2dee9b 100644 --- a/platform/wab/src/wab/server/db/bundle-migration-utils.ts +++ b/platform/wab/src/wab/server/db/bundle-migration-utils.ts @@ -164,19 +164,6 @@ export async function upgradeHostlessProject( ); } -// Set to true on startup and whenever bumpHostlessVersion is called. -// Causes the next getHostlessData() call to reload from DB. -// Never expires on a timer — only invalidated by an explicit publish. -let _hostlessCacheStale = true; - -/** - * Called by DbMgr.bumpHostlessVersion() after a hostless package is published. - * Forces the next getHostlessData() call to reload from the DB. - */ -export function invalidateHostlessCache() { - _hostlessCacheStale = true; -} - export const getHostlessData = (() => { const fn = async (db: MigrationDbMgr) => { logger().info("Refreshing hostless data"); @@ -214,25 +201,35 @@ export const getHostlessData = (() => { latestVersions.add(latestVersion.id); }) ); - // Mark the cache as fresh only after a successful load. - _hostlessCacheStale = false; return { versionIdToLatestVersion, latestVersions, + hostlessVersionCount: (await db.getHostlessVersion()).versionCount, }; }; let cachedData: ReturnType | undefined = undefined; + let lastEvaluationTime = 0; const runAndCache = (db: MigrationDbMgr, opts?: { clearCache?: boolean }) => { - if (!cachedData || opts?.clearCache) { + if ( + !cachedData || + opts?.clearCache || + performance.now() - lastEvaluationTime >= 1000 * 60 * 10 + ) { cachedData = fn(db); + lastEvaluationTime = performance.now(); } return cachedData; }; - return (db: MigrationDbMgr) => { - if (_hostlessCacheStale) { + return async (db: MigrationDbMgr) => { + const result = await runAndCache(db); + if ( + db instanceof DbMgr && + result?.hostlessVersionCount !== + (await db.getHostlessVersion()).versionCount + ) { return runAndCache(db, { clearCache: true }); } - return runAndCache(db); + return result; }; })(); From 5dd0f007cb7dbadd4400cd4e190cfe20c0037a7a Mon Sep 17 00:00:00 2001 From: James Willis Date: Fri, 27 Mar 2026 13:38:29 +0000 Subject: [PATCH 3/4] revert: strip back to only checkDepPkgHosts simplification Keep only the removal of N duplicate getPkgVersionMeta API calls in checkDepPkgHosts. The data (hostUrl, pkg name/id) is already present on the depPkgs returned in the getSiteInfo response, so there is no need to re-fetch it. All server-side changes and broader client optimisations are reverted for a separate PR. --- .../wab/client/ProjectDependencyManager.ts | 69 ++------- platform/wab/src/wab/client/api.ts | 7 +- .../sidebar/ProjectDependencies.tsx | 2 +- .../components/studio/studio-initializer.tsx | 4 - platform/wab/src/wab/client/db.ts | 4 - platform/wab/src/wab/client/init-ctx.tsx | 2 - .../src/wab/client/studio-ctx/StudioCtx.tsx | 3 - platform/wab/src/wab/server/AppServer.ts | 2 +- .../wab/src/wab/server/db/BundleMigrator.ts | 4 +- platform/wab/src/wab/server/db/DbMgr.ts | 51 ++----- .../wab/src/wab/server/routes/projects.ts | 132 +++++++----------- platform/wab/src/wab/shared/ApiSchema.ts | 1 - 12 files changed, 85 insertions(+), 196 deletions(-) diff --git a/platform/wab/src/wab/client/ProjectDependencyManager.ts b/platform/wab/src/wab/client/ProjectDependencyManager.ts index daffc9739..3081bad96 100644 --- a/platform/wab/src/wab/client/ProjectDependencyManager.ts +++ b/platform/wab/src/wab/client/ProjectDependencyManager.ts @@ -1,6 +1,6 @@ import { checkDepPkgHosts } from "@/wab/client/init-ctx"; import { StudioCtx } from "@/wab/client/studio-ctx/StudioCtx"; -import { PkgInfo, PkgVersionInfo, PkgVersionInfoMeta } from "@/wab/shared/SharedApi"; +import { PkgInfo, PkgVersionInfoMeta } from "@/wab/shared/SharedApi"; import { FastBundler } from "@/wab/shared/bundler"; import { getUsedDataSourcesFromDep } from "@/wab/shared/cached-selectors"; import { Dict } from "@/wab/shared/collections"; @@ -71,12 +71,6 @@ export class ProjectDependencyManager { // Stores the Site of the Plume project plumeSite: Site | undefined; - // Pre-fetched plumePkg promise — set externally before refreshDeps() is called - // so the fetch can run in parallel with project load. - private _plumePkgFetch: - | Promise<{ pkg: PkgVersionInfo; depPkgs: PkgVersionInfo[] }> - | undefined; - // Tracks Component, Mixin, StyleToken, Theme, ImageAsset and global VariantGroup to // the ProjectDependency it was imported from // This will include all assets across the ENTIRE dependency tree @@ -93,26 +87,14 @@ export class ProjectDependencyManager { } /** - * Call this as early as possible (before refreshDeps) to pre-warm the Plume - * package fetch so it runs in parallel with project load. - */ - setPlumePkgFetch( - fetch: Promise<{ pkg: PkgVersionInfo; depPkgs: PkgVersionInfo[] }> - ) { - this._plumePkgFetch = fetch; - } - - seedLatestVersionMeta(latestVersions: Record) { - for (const [pkgId, meta] of Object.entries(latestVersions)) { - if (this._dependencyMap[pkgId]) { - this._dependencyMap[pkgId].latestPkgVersionMeta = meta; - } - } - } - - // Fetches latest version metadata for all direct deps (used for update badges). - // Non-critical — spawned so it doesn't block studio startup. - private async _fetchLatestVersionMeta(force?: boolean) { + * Fetch any missing data from the server + * TODO: this currently only fetches data once on load and caches it + * - It does not know when to refresh the data if it has changed + * (i.e. if new version published while editing) + * - We could add some logic to know when to refresh + **/ + private async _fetchData(force?: boolean) { + // Get PkgVersionMeta of all project dependencies const data = await Promise.all( L.map(L.values(this._dependencyMap), async (dep) => { return { @@ -120,11 +102,8 @@ export class ProjectDependencyManager { latestPkgVersionMeta: dep.latestPkgVersionMeta && !force ? dep.latestPkgVersionMeta - : ( - await this._sc.appCtx.api.listPkgVersionsWithoutData( - dep.model.pkgId - ) - ).pkgVersions[0], + : (await this._sc.appCtx.api.getPkgVersionMeta(dep.model.pkgId)) + .pkg, }; }) ); @@ -134,16 +113,12 @@ export class ProjectDependencyManager { d.latestPkgVersionMeta; }); }); - } - // Fetches and unbundles the Plume site. Must complete before sync. - // Uses a pre-fetched promise if setPlumePkgFetch() was called earlier. - private async _fetchPlumeSite() { // We no longer plan to make any updates to plume site. So its unnecessary to re-fetch it if it has already been fetched. if (!this.plumeSite) { const bundler = new FastBundler(); - const plumePkg = await (this._plumePkgFetch ?? - this._sc.appCtx.api.getPlumePkg()); + // Get Plume site + const plumePkg = await this._sc.appCtx.api.getPlumePkg(); const plumeSite = unbundleProjectDependency( bundler, plumePkg.pkg, @@ -153,20 +128,6 @@ export class ProjectDependencyManager { } } - /** - * Fetch any missing data from the server - * TODO: this currently only fetches data once on load and caches it - * - It does not know when to refresh the data if it has changed - * (i.e. if new version published while editing) - * - We could add some logic to know when to refresh - **/ - private async _fetchData(force?: boolean) { - // Update badges — non-blocking, runs in background - spawn(this._fetchLatestVersionMeta(force)); - // Plume site — must be ready before sync; likely already in-flight - await this._fetchPlumeSite(); - } - /** * Mutates every component in the site, doing: * - Deref all token references @@ -529,8 +490,8 @@ export class ProjectDependencyManager { } } - async refreshDeps({ forceVersionMeta = false }: { forceVersionMeta?: boolean } = {}) { - return this._fetchData(forceVersionMeta); + async refreshDeps() { + return this._fetchData(true); } private _trackDepObjs(dep: ProjectDependency) { diff --git a/platform/wab/src/wab/client/api.ts b/platform/wab/src/wab/client/api.ts index d97f84f59..d92ee0767 100644 --- a/platform/wab/src/wab/client/api.ts +++ b/platform/wab/src/wab/client/api.ts @@ -481,7 +481,6 @@ export function filteredApi( "listUnpublishedProjectRevisions", "revertProjectToRevision", "getPkgVersionMeta", - "listPkgVersionsWithoutData", "refreshCsrfToken", "getLastBundleVersion", "getAppConfig", @@ -587,6 +586,12 @@ export function filteredApi( saveProjectRevChanges: checkProjectIdInFirstArg, computeNextProjectVersion: checkProjectIdInFirstArg, publishProject: checkProjectIdInFirstArg, + listPkgVersionsWithoutData: + (f) => + async (...args) => { + assert(args[0] === (await getPkgId()), "Unexpected pkgId"); + return f(...args); + }, updatePkgVersion: (f) => async (...args) => { diff --git a/platform/wab/src/wab/client/components/sidebar/ProjectDependencies.tsx b/platform/wab/src/wab/client/components/sidebar/ProjectDependencies.tsx index 9fe09a1ef..7506a77ef 100644 --- a/platform/wab/src/wab/client/components/sidebar/ProjectDependencies.tsx +++ b/platform/wab/src/wab/client/components/sidebar/ProjectDependencies.tsx @@ -347,7 +347,7 @@ function _ProjectDependenciesPanel() { : { onClick: () => { setState("refreshing"); - spawn(sc.projectDependencyManager.refreshDeps({ forceVersionMeta: true })); + spawn(sc.projectDependencyManager.refreshDeps()); setState(undefined); }, "data-test-id": "check-for-updates-btn", diff --git a/platform/wab/src/wab/client/components/studio/studio-initializer.tsx b/platform/wab/src/wab/client/components/studio/studio-initializer.tsx index b89a2cb7b..7d71ea0d8 100644 --- a/platform/wab/src/wab/client/components/studio/studio-initializer.tsx +++ b/platform/wab/src/wab/client/components/studio/studio-initializer.tsx @@ -94,11 +94,7 @@ class StudioInitializer_ extends React.Component< if (appCtx.appConfig.incrementalObservables) { makeGlobalObservable(); } - // Plume package doesn't depend on project data — fetch it in parallel with - // project load so it's ready (or nearly ready) by the time refreshDeps runs. - const plumePkgFetch = appCtx.api.getPlumePkg(); const studioCtx = await initStudioCtx(appCtx, projectId, onRefreshUi); - studioCtx.projectDependencyManager.setPlumePkgFetch(plumePkgFetch); const previewCtx = new PreviewCtx(hostFrameCtx, studioCtx); spawn(studioCtx.startListeningForSocketEvents()); diff --git a/platform/wab/src/wab/client/db.ts b/platform/wab/src/wab/client/db.ts index e51af97b4..da0817a63 100644 --- a/platform/wab/src/wab/client/db.ts +++ b/platform/wab/src/wab/client/db.ts @@ -31,7 +31,6 @@ class DbCtxArgs { appCtx: AppCtx; revisionNum: number; branch?: ApiBranch; - latestDepPkgVersions: Record; } // for use in views @@ -75,9 +74,6 @@ export class DbCtx { get api() { return this.args.api; } - get latestDepPkgVersions() { - return this.args.latestDepPkgVersions ?? {}; - } get siteInfo() { return this._siteInfo.get(); } diff --git a/platform/wab/src/wab/client/init-ctx.tsx b/platform/wab/src/wab/client/init-ctx.tsx index 420a65bbf..67b36cb74 100644 --- a/platform/wab/src/wab/client/init-ctx.tsx +++ b/platform/wab/src/wab/client/init-ctx.tsx @@ -56,7 +56,6 @@ export async function loadSiteDbCtx( appAuthProvider, workspaceTutorialDbs, isMainBranchProtected, - latestDepPkgVersions, } = await (async () => { try { return await baseApi.getSiteInfo(siteId, { branchId: branch?.id }); @@ -124,7 +123,6 @@ export async function loadSiteDbCtx( appCtx, revisionNum: rev.revision, branch, - latestDepPkgVersions, }); return dbCtx; diff --git a/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx b/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx index 0c9e36fba..c033d4790 100644 --- a/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx +++ b/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx @@ -705,9 +705,6 @@ export class StudioCtx extends WithDbCtx { spawn(this.handleInitialRoute()); this.projectDependencyManager = new ProjectDependencyManager(this); - this.projectDependencyManager.seedLatestVersionMeta( - this._dbCtx.latestDepPkgVersions - ); // styleMgr must be instantiated after ProjectDependencyManager, as // it makes use of TPL_COMPONENT_ROOT updates that ProjectDependencyManager diff --git a/platform/wab/src/wab/server/AppServer.ts b/platform/wab/src/wab/server/AppServer.ts index a60ec7e3a..ab064db1e 100644 --- a/platform/wab/src/wab/server/AppServer.ts +++ b/platform/wab/src/wab/server/AppServer.ts @@ -1569,7 +1569,7 @@ export function addMainAppServerRoutes( "/api/v1/projects/:projectId/main-branch-protection", withNext(setMainBranchProtection) ); - app.get("/api/v1/projects/:projectBranchId", getProjectRev); + app.get("/api/v1/projects/:projectBranchId", withNext(getProjectRev)); app.get( "/api/v1/projects/:projectId/revision-without-data", getProjectRevWithoutData diff --git a/platform/wab/src/wab/server/db/BundleMigrator.ts b/platform/wab/src/wab/server/db/BundleMigrator.ts index e2e0ab239..adcdd4db5 100644 --- a/platform/wab/src/wab/server/db/BundleMigrator.ts +++ b/platform/wab/src/wab/server/db/BundleMigrator.ts @@ -190,11 +190,9 @@ export async function getMigratedBundle( const fixedBundle = await conn.transaction(async (txMgr) => { const db = new DbMgr(txMgr, SUPER_USER); - const stale = await bundleHasStaleHostlessDeps(bundle, db); - if ( isExpectedBundleVersion(bundle, await getLastBundleVersion()) && - !stale + !(await bundleHasStaleHostlessDeps(bundle, db)) ) { return bundle; } diff --git a/platform/wab/src/wab/server/db/DbMgr.ts b/platform/wab/src/wab/server/db/DbMgr.ts index be4beea71..a76e87063 100644 --- a/platform/wab/src/wab/server/db/DbMgr.ts +++ b/platform/wab/src/wab/server/db/DbMgr.ts @@ -2585,16 +2585,15 @@ export class DbMgr implements MigrationDbMgr { const pkg = ensureFound(await this.pkgs().findOne(pkgId), `Pkg ${pkgId}`); // The only pkg without a projectId should be "base". if (!pkg.projectId) { - return pkg; + return; } - await this.checkProjectPerms( + return await this.checkProjectPerms( pkg.projectId, requireLevel, action, suggestion, true ); - return pkg; }; async deleteProject(id: string, _proof: ProofSafeDelete) { @@ -4328,7 +4327,8 @@ export class DbMgr implements MigrationDbMgr { } async getPkgById(id: string) { - return ensureFound(await this.checkPkgPerms(id, "viewer", "get"), `Pkg ${id}`); + await this.checkPkgPerms(id, "viewer", "get"); + return ensureFound(await this.pkgs().findOne(id), `Pkg ${id}`); } async getPkgByProjectId(projectId: string) { @@ -4370,6 +4370,7 @@ export class DbMgr implements MigrationDbMgr { branchId, }: { prefilledOnly?: boolean; branchId?: BranchId } = {} ) { + await this.checkPkgPerms(pkgId, "viewer", "get"); if (branchId) { await this.checkBranchPerms(branchId, "viewer", "get pkg version", true); } @@ -4595,7 +4596,9 @@ export class DbMgr implements MigrationDbMgr { branchId?: BranchId, id?: string ) { - const pkg = await this.checkPkgPerms(pkgId, "content", "publish"); + await this.checkPkgPerms(pkgId, "content", "publish"); + + const pkg = await this.getPkgById(pkgId); const pkgVersion = this.pkgVersions().create({ ...this.stampNew(), pkg, @@ -4655,47 +4658,11 @@ export class DbMgr implements MigrationDbMgr { .sort((a, b) => (semver.gt(a.version, b.version) ? -1 : +1)); } - /** - * Single-query batch fetch of the latest main-branch version for each of the - * given pkgIds. No permission check — only call this server-side within an - * already-authenticated handler where the caller has verified project access - * (same trust level as loadDepPackages). Returns a map pkgId → PkgVersion. - */ - async getLatestPkgVersionsForPkgIds( - pkgIds: string[] - ): Promise> { - if (pkgIds.length === 0) { - return {}; - } - const columns = this.entMgr.connection - .getMetadata(PkgVersion) - .columns.filter((c) => c.databaseName !== "model") - .map((c) => `pkgVersion.${c.databaseName}`); - - const rows = await this.pkgVersions() - .createQueryBuilder("pkgVersion") - .select(columns) - .leftJoinAndSelect("pkgVersion.pkg", "pkg") - .leftJoinAndSelect("pkgVersion.branch", "branch") - .where("pkgVersion.pkgId IN (:...pkgIds)", { pkgIds }) - .andWhere("pkgVersion.branchId IS NULL") - .andWhere("pkgVersion.deletedAt IS NULL") - .getMany(); - - // Group by pkgId and pick the highest semver per pkg. - const grouped = L.groupBy(rows, (r) => r.pkgId); - return Object.fromEntries( - Object.entries(grouped).map(([pkgId, versions]) => [ - pkgId, - versions.sort((a, b) => (semver.gt(a.version, b.version) ? -1 : 1))[0], - ]) - ); - } - private async listPkgVersionsRaw( pkgId: string, opts: { includeData?: boolean; branchId?: BranchId } = {} ) { + await this.checkPkgPerms(pkgId, "viewer", "get"); const columns = this.entMgr.connection .getMetadata(PkgVersion) .columns.map((c) => `pkgVersion.${c.databaseName}`); diff --git a/platform/wab/src/wab/server/routes/projects.ts b/platform/wab/src/wab/server/routes/projects.ts index 793864389..267b90107 100644 --- a/platform/wab/src/wab/server/routes/projects.ts +++ b/platform/wab/src/wab/server/routes/projects.ts @@ -115,7 +115,6 @@ import { import { Bundle, OutdatedBundleError, - UnsafeBundle, getBundle, getSerializedBundleSize, isExpectedBundleVersion, @@ -1536,91 +1535,64 @@ export async function getProjectRev(req: Request, res: Response) { const { projectId, branchId } = parseProjectBranchId( req.params.projectBranchId ); - // Round 1: all independent queries run in parallel - const [ - branch, - project, - rev, - allPerms, - modelVersion, - hostlessVersion, - latestRevisionSynced, - appAuthConfig, - allowedDataSources, - ] = await Promise.all([ - branchId ? mgr.getBranchById(branchId) : Promise.resolve(undefined), - mgr.getProjectById(projectId), + const branch = branchId ? await mgr.getBranchById(branchId) : undefined; + const project = await mgr.getProjectById(projectId); + req.promLabels.projectId = projectId; + const rev = revisionNum !== undefined || revisionId - ? mgr.getProjectRevision( + ? await mgr.getProjectRevision( projectId, revisionNum !== undefined ? +revisionNum : revisionId!, branchId ) - : mgr.getLatestProjectRev(projectId, { branchId }), - mgr.getPermissionsForProject(projectId), - getCurrentModelVersion(req.noTxMgr), - mgr.getHostlessVersion(), - getLatestRevisionSynced(mgr, projectId), - mgr.getPublicAppAuthConfig(projectId), - mgr.listAllowedDataSourcesForProject(projectId as ProjectId), - ]); - - req.promLabels.projectId = projectId; - + : await mgr.getLatestProjectRev(projectId, { branchId }); const perms = project.readableByPublic - ? allPerms.filter( - (perm) => + ? (await mgr.getPermissionsForProject(projectId)).filter((perm) => { + return ( accessLevelRank(perm.accessLevel) >= accessLevelRank("commenter") - ) - : allPerms; - - // Round 2: depends on project/rev from Round 1 - const [migratedBundle, owner, workspaceTutorialDbsRaw] = await Promise.all([ - dontMigrateProject - ? Promise.resolve(JSON.parse(rev.data) as UnsafeBundle) - : getMigratedBundle(rev), - project.createdById - ? mgr.tryGetUserById(project.createdById) - : (async () => { - const actorId = mgr.tryGetNormalActorId(); - if (actorId) { - await startTransaction(req, async () => { - await mgr.claimPublicProject(project.id, actorId); - return commitTransaction(); - }); - } - return undefined; - })(), - project.workspaceId - ? mgr.getWorkspaceTutorialDataSources(project.workspaceId) - : Promise.resolve([]), - ]); - - // Round 3: depends on migratedBundle from Round 2 - const depPkgs = await loadDepPackages(mgr, migratedBundle, { - dontMigrateBundle: dontMigrateProject, - }); - - // Identify direct dep pkgIds from bundle.deps (which contains PkgVersion IDs) - const directDepPkgVersionIds = new Set(migratedBundle.deps ?? []); - const directDepPkgIds = depPkgs - .filter((p) => directDepPkgVersionIds.has(p.id as string)) - .map((p) => p.pkgId) - .filter((pkgId): pkgId is string => pkgId != null); - const latestDepPkgVersions = - await mgr.getLatestPkgVersionsForPkgIds(directDepPkgIds); - - const allowedDataSourceIds = allowedDataSources.map((ds) => ds.dataSourceId); - const workspaceTutorialDbs = workspaceTutorialDbsRaw - .filter( - (ds) => - ds.source === "tutorialdb" && allowedDataSourceIds.includes(ds.id) - ) - .map((ds) => mkApiDataSource(ds)); + ); + }) + : await mgr.getPermissionsForProject(projectId); + const depPkgs = await loadDepPackages( + mgr, + dontMigrateProject ? JSON.parse(rev.data) : await getMigratedBundle(rev), + { dontMigrateBundle: dontMigrateProject } + ); + const modelVersion = await getCurrentModelVersion(req.txMgr || req.noTxMgr); + const hostlessDataVersion = (await mgr.getHostlessVersion()).versionCount; + let owner: User | undefined; + if (!project.createdById) { + const actorId = mgr.tryGetNormalActorId(); + if (actorId) { + await mgr.claimPublicProject(project.id, actorId); + } + } else { + owner = await mgr.tryGetUserById(project.createdById); + } + const latestRevisionSynced = await getLatestRevisionSynced(mgr, projectId); + // Make sure this revision bundle is up to date. + if (!dontMigrateProject) { + await getMigratedBundle(rev); + } + const appAuthConfig = await mgr.getPublicAppAuthConfig(projectId); const hasAppAuth = !!appAuthConfig; const appAuthProvider = appAuthConfig?.provider; + const allowedDataSourceIds = ( + await mgr.listAllowedDataSourcesForProject(projectId as ProjectId) + ).map((ds) => ds.dataSourceId); + + const workspaceTutorialDbs = project.workspaceId + ? (await mgr.getWorkspaceTutorialDataSources(project.workspaceId)) + .filter( + (ds) => + ds.source === "tutorialdb" && allowedDataSourceIds.includes(ds.id) + ) + + .map((ds) => mkApiDataSource(ds)) + : []; + req.analytics.track("Open project", { projectId: project.id, projectName: project.name, @@ -1634,14 +1606,13 @@ export async function getProjectRev(req: Request, res: Response) { perms, depPkgs, modelVersion, - hostlessDataVersion: hostlessVersion.versionCount, + hostlessDataVersion, owner, latestRevisionSynced, hasAppAuth, appAuthProvider, workspaceTutorialDbs, isMainBranchProtected: !!project.isMainBranchProtected, - latestDepPkgVersions, }); } @@ -2020,9 +1991,10 @@ export async function getPkgVersion(req: Request, res: Response) { return; } - const result = await getPkgWithDeps(mgr, pkg, meta, { dontMigrateProject }); - - res.json({ ...result, etag }); + res.json({ + ...(await getPkgWithDeps(mgr, pkg, meta, { dontMigrateProject })), + etag, + }); } export async function listUnpublishedProjectRevisions( diff --git a/platform/wab/src/wab/shared/ApiSchema.ts b/platform/wab/src/wab/shared/ApiSchema.ts index 59e39859b..a44000d6f 100644 --- a/platform/wab/src/wab/shared/ApiSchema.ts +++ b/platform/wab/src/wab/shared/ApiSchema.ts @@ -1853,7 +1853,6 @@ export interface GetProjectResponse { appAuthProvider?: AppAuthProvider; workspaceTutorialDbs?: ApiDataSource[]; isMainBranchProtected: boolean; - latestDepPkgVersions: Record; } export type MergeSrcDst = From 0113a6364e5d08481af563747eda624d8b2277cc Mon Sep 17 00:00:00 2001 From: James Willis Date: Fri, 27 Mar 2026 14:00:48 +0000 Subject: [PATCH 4/4] fix: update remaining checkDepPkgHosts call sites to match new signature The previous commit changed checkDepPkgHosts from async (ProjectDependency[]) to sync (PkgVersionInfo[]) but missed two other call sites that still passed ProjectDependency[], causing TypeScript compilation failures. - ProjectDependencyManager.ts: pass [latest, ...depPkgs] (PkgVersionInfo[]) from the getPkgVersion response; drop the unused depPkgVersions destructure from unbundleProjectDependency - StudioCtx.tsx: pass depPkgs (PkgVersionInfo[]) directly; simplify the unbundleSite destructure to { site } since depPkgVersions is unused; remove now-unused isKnownProjectDependency import - All three call sites: remove spawn() wrapper since checkDepPkgHosts is now synchronous (spawn expects PromiseLike, not void) --- .../src/wab/client/ProjectDependencyManager.ts | 16 ++++++---------- platform/wab/src/wab/client/init-ctx.tsx | 2 +- .../wab/src/wab/client/studio-ctx/StudioCtx.tsx | 13 ++----------- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/platform/wab/src/wab/client/ProjectDependencyManager.ts b/platform/wab/src/wab/client/ProjectDependencyManager.ts index 3081bad96..8922497ab 100644 --- a/platform/wab/src/wab/client/ProjectDependencyManager.ts +++ b/platform/wab/src/wab/client/ProjectDependencyManager.ts @@ -368,18 +368,14 @@ export class ProjectDependencyManager { pkg.id ); - const { projectDependency, depPkgs: depPkgVersions } = - unbundleProjectDependency(this._sc.bundler(), latest, depPkgs); - - spawn( - checkDepPkgHosts(this._sc.appCtx, this._sc.siteInfo, [ - projectDependency, - ...depPkgVersions.filter((dep): dep is ProjectDependency => - isKnownProjectDependency(dep) - ), - ]) + const { projectDependency } = unbundleProjectDependency( + this._sc.bundler(), + latest, + depPkgs ); + checkDepPkgHosts(this._sc.appCtx, this._sc.siteInfo, [latest, ...depPkgs]); + this.canAddDependency(projectDependency, maybeMyPkg); await this.addDependency(projectDependency); diff --git a/platform/wab/src/wab/client/init-ctx.tsx b/platform/wab/src/wab/client/init-ctx.tsx index 67b36cb74..3d565b8a6 100644 --- a/platform/wab/src/wab/client/init-ctx.tsx +++ b/platform/wab/src/wab/client/init-ctx.tsx @@ -91,7 +91,7 @@ export async function loadSiteDbCtx( depPkgs ); appCtx.appConfig = getProjectFlags(site, appCtx.appConfig); - spawn(checkDepPkgHosts(appCtx, siteInfo, depPkgs)); + checkDepPkgHosts(appCtx, siteInfo, depPkgs); // Enable data queries after RSC release if any components already use them. // Occurs after applyPlasmicUserDevFlagOverrides, so skip if already enabled diff --git a/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx b/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx index c033d4790..14e20c1e9 100644 --- a/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx +++ b/platform/wab/src/wab/client/studio-ctx/StudioCtx.tsx @@ -333,7 +333,6 @@ import { VariantGroup, isKnownArenaFrame, isKnownComponentArena, - isKnownProjectDependency, isKnownVariantSetting, } from "@/wab/shared/model/classes"; import { modelSchemaHash } from "@/wab/shared/model/classes-metas"; @@ -5826,7 +5825,7 @@ export class StudioCtx extends WithDbCtx { })(); runInAction(() => { const newBundler = new FastBundler(); - const { site, depPkgs: depPkgVersions } = unbundleSite( + const { site } = unbundleSite( newBundler, this.siteInfo.id, bundle, @@ -5834,15 +5833,7 @@ export class StudioCtx extends WithDbCtx { ); this.appCtx.bundler = newBundler; this.dbCtx().setSite(site, branch, versionOrRevision); - spawn( - checkDepPkgHosts( - this.appCtx, - this.siteInfo, - depPkgVersions.filter((dep): dep is ProjectDependency => - isKnownProjectDependency(dep) - ) - ) - ); + checkDepPkgHosts(this.appCtx, this.siteInfo, depPkgs); this.projectDependencyManager.syncDirectDeps(); this.pruneInvalidViewCtxs(); this.pruneDetachedTpls();