feat: improve admin build (#14485)

#### PR Dependency Tree


* **PR #14485** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
  * Admin static assets now served under /admin for self-hosted installs
  * CLI is directly executable from the command line
  * Build tooling supports a configurable self-hosted public path
  * Updated admin package script for adding UI components
* Added a PostCSS dependency and plugin to the build toolchain for admin
builds

* **Style**
* Switched queue module to a local queuedash stylesheet, added queuedash
Tailwind layer, and scoped queuedash styles for the admin UI

* **Bug Fixes**
  * Improved error propagation in the Electron renderer
* Migration compatibility to repair a legacy checksum during native
storage upgrades

* **Tests**
  * Added tests covering the migration repair flow
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-21 23:25:05 +08:00
committed by GitHub
parent 0de1bd0da8
commit 2414aa5848
18 changed files with 397 additions and 53 deletions
+1 -1
View File
@@ -71,7 +71,7 @@
"@opentelemetry/semantic-conventions": "^1.38.0",
"@prisma/client": "^6.6.0",
"@prisma/instrumentation": "^6.7.0",
"@queuedash/api": "^3.14.0",
"@queuedash/api": "^3.16.0",
"@react-email/components": "0.0.38",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^5.0.118",
@@ -97,7 +97,7 @@ test('should always return static asset files', async t => {
t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer())
.get('/main.b.js')
.get('/admin/main.b.js')
.expect(200);
t.is(res.text, "const name = 'affine-admin'");
@@ -119,7 +119,7 @@ test('should always return static asset files', async t => {
t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer())
.get('/main.b.js')
.get('/admin/main.b.js')
.expect(200);
t.is(res.text, "const name = 'affine-admin'");
@@ -52,7 +52,7 @@ export class StaticFilesResolver implements OnModuleInit {
// serve all static files
app.use(
basePath,
basePath + '/admin',
serveStatic(join(staticPath, 'admin'), {
redirect: false,
index: false,
+2 -2
View File
@@ -9,7 +9,7 @@
"@affine/graphql": "workspace:*",
"@affine/routes": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@queuedash/ui": "^3.14.0",
"@queuedash/ui": "^3.16.0",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.3",
"@radix-ui/react-aspect-ratio": "^1.1.1",
@@ -74,7 +74,7 @@
"scripts": {
"build": "affine bundle",
"dev": "affine bundle --dev",
"update-shadcn": "shadcn-ui add -p src/components/ui"
"update-shadcn": "yarn dlx shadcn@latest add -p src/components/ui"
},
"exports": {
"./*": "./src/*.ts",
+2
View File
@@ -1,5 +1,7 @@
@config '../tailwind.config.js';
@layer properties, theme, base, components, utilities, queuedash;
@import 'tailwindcss';
@import 'tailwindcss/utilities';
@import '@toeverything/theme/style.css';
@@ -1,19 +1,98 @@
import '@queuedash/ui/dist/styles.css';
import './queue.css';
import './queuedash.css';
import { QueueDashApp } from '@queuedash/ui';
import { useEffect } from 'react';
import { Header } from '../header';
const QUEUEDASH_SCOPE_CLASS = 'affine-queuedash';
const PORTAL_CONTENT_SELECTOR =
'.react-aria-ModalOverlay, .react-aria-Menu, [data-rac][data-placement][data-trigger]';
export function QueuePage() {
useEffect(() => {
const marked = new Set<HTMLElement>();
const markScopeRoot = (el: Element) => {
if (!(el instanceof HTMLElement)) {
return;
}
if (el.classList.contains(QUEUEDASH_SCOPE_CLASS)) {
return;
}
el.classList.add(QUEUEDASH_SCOPE_CLASS);
marked.add(el);
};
const isPortalContent = (el: Element) => {
return (
el.matches(PORTAL_CONTENT_SELECTOR) ||
!!el.querySelector(PORTAL_CONTENT_SELECTOR)
);
};
const markIfPortalRoot = (el: Element) => {
if (!isPortalContent(el)) {
return;
}
markScopeRoot(el);
};
const getBodyChildRoot = (el: Element) => {
let current: Element | null = el;
while (
current?.parentElement &&
current.parentElement !== document.body
) {
current = current.parentElement;
}
return current?.parentElement === document.body ? current : null;
};
Array.from(document.body.children).forEach(child => {
if (child.id === 'app') {
return;
}
markIfPortalRoot(child);
});
const observer = new MutationObserver(mutations => {
const appRoot = document.getElementById('app');
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) {
continue;
}
const root = getBodyChildRoot(node) ?? node;
if (appRoot && root === appRoot) {
continue;
}
markIfPortalRoot(root);
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => {
observer.disconnect();
marked.forEach(el => el.classList.remove(QUEUEDASH_SCOPE_CLASS));
};
}, []);
return (
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
<Header title="Queue" />
<div className="flex-1 overflow-hidden">
<QueueDashApp
apiUrl={`${environment.subPath}/api/queue/trpc`}
basename="/admin/queue"
/>
<div className={`${QUEUEDASH_SCOPE_CLASS} h-full`}>
<QueueDashApp
apiUrl={`${environment.subPath}/api/queue/trpc`}
basename="/admin/queue"
/>
</div>
</div>
</div>
);
@@ -1,5 +0,0 @@
/* Scoped queuedash modal alignment */
.react-aria-ModalOverlay section[role='dialog'] {
transform: unset;
}
@@ -0,0 +1,14 @@
@import '@queuedash/ui/dist/styles.css' layer(queuedash);
/*
* QueueDash UI is built with Tailwind v3 (translate via `transform`), while AFFiNE Admin
* uses Tailwind v4 (translate via the individual `translate` property). When QueueDash
* overlays are portaled to `document.body`, both utility sets can apply at once and
* result in double transforms (mis-centered dialogs, etc). Reset individual transform
* properties within the queuedash scope so Tailwind v3 styles win.
*/
:where(.affine-queuedash) * {
translate: none;
rotate: none;
scale: none;
}
@@ -25,6 +25,9 @@ function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
return result;
} catch (error) {
logger.error('[async-api]', `${namespace}.${name}`, error);
// Propagate errors to the renderer so callers don't receive `undefined`
// and fail with confusing TypeErrors.
throw error instanceof Error ? error : new Error(String(error));
}
};
return [`${namespace}:${name}`, handlerWithLog];
+123 -2
View File
@@ -4,7 +4,7 @@ use affine_schema::get_migrator;
use memory_indexer::InMemoryIndex;
use sqlx::{
Pool, Row,
migrate::MigrateDatabase,
migrate::{MigrateDatabase, Migration, Migrator},
sqlite::{Sqlite, SqliteConnectOptions, SqlitePoolOptions},
};
use tokio::sync::RwLock;
@@ -75,11 +75,74 @@ impl SqliteDocStorage {
async fn migrate(&self) -> Result<()> {
let migrator = get_migrator();
migrator.run(&self.pool).await?;
if let Err(err) = migrator.run(&self.pool).await {
// Compatibility: migration 3 (`add_idx_snapshots`) had a whitespace-only SQL
// change (trailing space) between releases, which causes sqlx to reject
// existing DBs with: `VersionMismatch(3)`. It's safe to fix by updating
// the stored checksum.
if matches!(err, sqlx::migrate::MigrateError::VersionMismatch(3))
&& self.try_repair_migration_3_checksum(&migrator).await?
{
migrator.run(&self.pool).await?;
} else {
return Err(err.into());
}
}
Ok(())
}
async fn try_repair_migration_3_checksum(&self, migrator: &Migrator) -> Result<bool> {
let Some(migration) = migrator.iter().find(|m| m.version == 3) else {
return Ok(false);
};
// We're only prepared to repair the known `add_idx_snapshots` whitespace-only
// mismatch.
if migration.description.as_ref() != "add_idx_snapshots" {
return Ok(false);
}
let row = sqlx::query("SELECT description, checksum FROM _sqlx_migrations WHERE version = 3")
.fetch_optional(&self.pool)
.await?;
let Some(row) = row else {
return Ok(false);
};
let applied_description: String = row.try_get("description")?;
if applied_description != migration.description.as_ref() {
return Ok(false);
}
let applied_checksum: Vec<u8> = row.try_get("checksum")?;
let expected_checksum = migration.checksum.as_ref();
// sqlx computes the checksum as SHA-384 of the raw SQL bytes. The legacy
// variant had an extra trailing space at the end of the SQL string (after
// the final newline).
let legacy_sql = format!("{} ", migration.sql);
let legacy_migration = Migration::new(
migration.version,
migration.description.clone(),
migration.migration_type,
std::borrow::Cow::Owned(legacy_sql),
migration.no_tx,
);
if applied_checksum.as_slice() != legacy_migration.checksum.as_ref() {
return Ok(false);
}
sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = 3")
.bind(expected_checksum)
.execute(&self.pool)
.await?;
Ok(true)
}
pub async fn close(&self) {
self.pool.close().await
}
@@ -100,6 +163,11 @@ impl SqliteDocStorage {
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use affine_schema::get_migrator;
use sqlx::migrate::{Migration, Migrator};
use super::*;
async fn get_storage() -> SqliteDocStorage {
@@ -135,4 +203,57 @@ mod tests {
let storage = SqliteDocStorage::new(":memory:".to_string());
assert!(!storage.validate().await.unwrap());
}
#[tokio::test]
async fn connect_repairs_whitespace_only_migration_checksum_mismatch() {
// Simulate a DB migrated with an older `add_idx_snapshots` SQL that had a
// trailing space.
let storage = SqliteDocStorage::new(":memory:".to_string());
let new_migrator = get_migrator();
let mut migrations = new_migrator.migrations.to_vec();
assert!(migrations.len() >= 3);
let mig3 = migrations[2].clone();
assert_eq!(mig3.version, 3);
assert_eq!(mig3.description.as_ref(), "add_idx_snapshots");
let legacy_sql = format!("{} ", mig3.sql);
migrations[2] = Migration::new(
mig3.version,
mig3.description.clone(),
mig3.migration_type,
Cow::Owned(legacy_sql),
mig3.no_tx,
);
// The legacy DB didn't have newer migrations.
migrations.truncate(3);
let legacy_migrator = Migrator {
migrations: Cow::Owned(migrations),
..Migrator::DEFAULT
};
legacy_migrator.run(&storage.pool).await.unwrap();
// Now connecting with the current code should auto-repair the checksum and
// succeed.
storage.connect().await.unwrap();
let expected_checksum = get_migrator()
.iter()
.find(|m| m.version == 3)
.unwrap()
.checksum
.as_ref()
.to_vec();
let row = sqlx::query("SELECT checksum FROM _sqlx_migrations WHERE version = 3")
.fetch_one(&storage.pool)
.await
.unwrap();
let checksum: Vec<u8> = row.get("checksum");
assert_eq!(checksum, expected_checksum);
}
}