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();