mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user