Skip to content

Commit 2414aa5

Browse files
authored
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 -->
1 parent 0de1bd0 commit 2414aa5

File tree

18 files changed

+397
-53
lines changed

18 files changed

+397
-53
lines changed

packages/backend/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
"@opentelemetry/semantic-conventions": "^1.38.0",
7272
"@prisma/client": "^6.6.0",
7373
"@prisma/instrumentation": "^6.7.0",
74-
"@queuedash/api": "^3.14.0",
74+
"@queuedash/api": "^3.16.0",
7575
"@react-email/components": "0.0.38",
7676
"@socket.io/redis-adapter": "^8.3.0",
7777
"ai": "^5.0.118",

packages/backend/server/src/__tests__/app/selfhost.e2e.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ test('should always return static asset files', async t => {
9797
t.is(res.text, "const name = 'affine'");
9898

9999
res = await request(t.context.app.getHttpServer())
100-
.get('/main.b.js')
100+
.get('/admin/main.b.js')
101101
.expect(200);
102102
t.is(res.text, "const name = 'affine-admin'");
103103

@@ -119,7 +119,7 @@ test('should always return static asset files', async t => {
119119
t.is(res.text, "const name = 'affine'");
120120

121121
res = await request(t.context.app.getHttpServer())
122-
.get('/main.b.js')
122+
.get('/admin/main.b.js')
123123
.expect(200);
124124
t.is(res.text, "const name = 'affine-admin'");
125125

packages/backend/server/src/core/selfhost/static.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export class StaticFilesResolver implements OnModuleInit {
5252

5353
// serve all static files
5454
app.use(
55-
basePath,
55+
basePath + '/admin',
5656
serveStatic(join(staticPath, 'admin'), {
5757
redirect: false,
5858
index: false,

packages/frontend/admin/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"@affine/graphql": "workspace:*",
1010
"@affine/routes": "workspace:*",
1111
"@blocksuite/icons": "^2.2.17",
12-
"@queuedash/ui": "^3.14.0",
12+
"@queuedash/ui": "^3.16.0",
1313
"@radix-ui/react-accordion": "^1.2.2",
1414
"@radix-ui/react-alert-dialog": "^1.1.3",
1515
"@radix-ui/react-aspect-ratio": "^1.1.1",
@@ -74,7 +74,7 @@
7474
"scripts": {
7575
"build": "affine bundle",
7676
"dev": "affine bundle --dev",
77-
"update-shadcn": "shadcn-ui add -p src/components/ui"
77+
"update-shadcn": "yarn dlx shadcn@latest add -p src/components/ui"
7878
},
7979
"exports": {
8080
"./*": "./src/*.ts",

packages/frontend/admin/src/global.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
@config '../tailwind.config.js';
22

3+
@layer properties, theme, base, components, utilities, queuedash;
4+
35
@import 'tailwindcss';
46
@import 'tailwindcss/utilities';
57
@import '@toeverything/theme/style.css';

packages/frontend/admin/src/modules/queue/index.tsx

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,98 @@
1-
import '@queuedash/ui/dist/styles.css';
2-
import './queue.css';
1+
import './queuedash.css';
32

43
import { QueueDashApp } from '@queuedash/ui';
4+
import { useEffect } from 'react';
55

66
import { Header } from '../header';
77

8+
const QUEUEDASH_SCOPE_CLASS = 'affine-queuedash';
9+
const PORTAL_CONTENT_SELECTOR =
10+
'.react-aria-ModalOverlay, .react-aria-Menu, [data-rac][data-placement][data-trigger]';
11+
812
export function QueuePage() {
13+
useEffect(() => {
14+
const marked = new Set<HTMLElement>();
15+
16+
const markScopeRoot = (el: Element) => {
17+
if (!(el instanceof HTMLElement)) {
18+
return;
19+
}
20+
21+
if (el.classList.contains(QUEUEDASH_SCOPE_CLASS)) {
22+
return;
23+
}
24+
25+
el.classList.add(QUEUEDASH_SCOPE_CLASS);
26+
marked.add(el);
27+
};
28+
29+
const isPortalContent = (el: Element) => {
30+
return (
31+
el.matches(PORTAL_CONTENT_SELECTOR) ||
32+
!!el.querySelector(PORTAL_CONTENT_SELECTOR)
33+
);
34+
};
35+
36+
const markIfPortalRoot = (el: Element) => {
37+
if (!isPortalContent(el)) {
38+
return;
39+
}
40+
markScopeRoot(el);
41+
};
42+
43+
const getBodyChildRoot = (el: Element) => {
44+
let current: Element | null = el;
45+
while (
46+
current?.parentElement &&
47+
current.parentElement !== document.body
48+
) {
49+
current = current.parentElement;
50+
}
51+
return current?.parentElement === document.body ? current : null;
52+
};
53+
54+
Array.from(document.body.children).forEach(child => {
55+
if (child.id === 'app') {
56+
return;
57+
}
58+
markIfPortalRoot(child);
59+
});
60+
61+
const observer = new MutationObserver(mutations => {
62+
const appRoot = document.getElementById('app');
63+
for (const mutation of mutations) {
64+
for (const node of mutation.addedNodes) {
65+
if (!(node instanceof Element)) {
66+
continue;
67+
}
68+
69+
const root = getBodyChildRoot(node) ?? node;
70+
if (appRoot && root === appRoot) {
71+
continue;
72+
}
73+
markIfPortalRoot(root);
74+
}
75+
}
76+
});
77+
78+
observer.observe(document.body, { childList: true, subtree: true });
79+
80+
return () => {
81+
observer.disconnect();
82+
marked.forEach(el => el.classList.remove(QUEUEDASH_SCOPE_CLASS));
83+
};
84+
}, []);
85+
986
return (
1087
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
1188
<Header title="Queue" />
1289
<div className="flex-1 overflow-hidden">
13-
<QueueDashApp
14-
apiUrl={`${environment.subPath}/api/queue/trpc`}
15-
basename="/admin/queue"
16-
/>
90+
<div className={`${QUEUEDASH_SCOPE_CLASS} h-full`}>
91+
<QueueDashApp
92+
apiUrl={`${environment.subPath}/api/queue/trpc`}
93+
basename="/admin/queue"
94+
/>
95+
</div>
1796
</div>
1897
</div>
1998
);

packages/frontend/admin/src/modules/queue/queue.css

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
@import '@queuedash/ui/dist/styles.css' layer(queuedash);
2+
3+
/*
4+
* QueueDash UI is built with Tailwind v3 (translate via `transform`), while AFFiNE Admin
5+
* uses Tailwind v4 (translate via the individual `translate` property). When QueueDash
6+
* overlays are portaled to `document.body`, both utility sets can apply at once and
7+
* result in double transforms (mis-centered dialogs, etc). Reset individual transform
8+
* properties within the queuedash scope so Tailwind v3 styles win.
9+
*/
10+
:where(.affine-queuedash) * {
11+
translate: none;
12+
rotate: none;
13+
scale: none;
14+
}

packages/frontend/apps/electron/src/helper/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
2525
return result;
2626
} catch (error) {
2727
logger.error('[async-api]', `${namespace}.${name}`, error);
28+
// Propagate errors to the renderer so callers don't receive `undefined`
29+
// and fail with confusing TypeErrors.
30+
throw error instanceof Error ? error : new Error(String(error));
2831
}
2932
};
3033
return [`${namespace}:${name}`, handlerWithLog];

packages/frontend/native/nbstore/src/storage.rs

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use affine_schema::get_migrator;
44
use memory_indexer::InMemoryIndex;
55
use sqlx::{
66
Pool, Row,
7-
migrate::MigrateDatabase,
7+
migrate::{MigrateDatabase, Migration, Migrator},
88
sqlite::{Sqlite, SqliteConnectOptions, SqlitePoolOptions},
99
};
1010
use tokio::sync::RwLock;
@@ -75,11 +75,74 @@ impl SqliteDocStorage {
7575

7676
async fn migrate(&self) -> Result<()> {
7777
let migrator = get_migrator();
78-
migrator.run(&self.pool).await?;
78+
if let Err(err) = migrator.run(&self.pool).await {
79+
// Compatibility: migration 3 (`add_idx_snapshots`) had a whitespace-only SQL
80+
// change (trailing space) between releases, which causes sqlx to reject
81+
// existing DBs with: `VersionMismatch(3)`. It's safe to fix by updating
82+
// the stored checksum.
83+
if matches!(err, sqlx::migrate::MigrateError::VersionMismatch(3))
84+
&& self.try_repair_migration_3_checksum(&migrator).await?
85+
{
86+
migrator.run(&self.pool).await?;
87+
} else {
88+
return Err(err.into());
89+
}
90+
}
7991

8092
Ok(())
8193
}
8294

95+
async fn try_repair_migration_3_checksum(&self, migrator: &Migrator) -> Result<bool> {
96+
let Some(migration) = migrator.iter().find(|m| m.version == 3) else {
97+
return Ok(false);
98+
};
99+
100+
// We're only prepared to repair the known `add_idx_snapshots` whitespace-only
101+
// mismatch.
102+
if migration.description.as_ref() != "add_idx_snapshots" {
103+
return Ok(false);
104+
}
105+
106+
let row = sqlx::query("SELECT description, checksum FROM _sqlx_migrations WHERE version = 3")
107+
.fetch_optional(&self.pool)
108+
.await?;
109+
110+
let Some(row) = row else {
111+
return Ok(false);
112+
};
113+
114+
let applied_description: String = row.try_get("description")?;
115+
if applied_description != migration.description.as_ref() {
116+
return Ok(false);
117+
}
118+
119+
let applied_checksum: Vec<u8> = row.try_get("checksum")?;
120+
let expected_checksum = migration.checksum.as_ref();
121+
122+
// sqlx computes the checksum as SHA-384 of the raw SQL bytes. The legacy
123+
// variant had an extra trailing space at the end of the SQL string (after
124+
// the final newline).
125+
let legacy_sql = format!("{} ", migration.sql);
126+
let legacy_migration = Migration::new(
127+
migration.version,
128+
migration.description.clone(),
129+
migration.migration_type,
130+
std::borrow::Cow::Owned(legacy_sql),
131+
migration.no_tx,
132+
);
133+
134+
if applied_checksum.as_slice() != legacy_migration.checksum.as_ref() {
135+
return Ok(false);
136+
}
137+
138+
sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = 3")
139+
.bind(expected_checksum)
140+
.execute(&self.pool)
141+
.await?;
142+
143+
Ok(true)
144+
}
145+
83146
pub async fn close(&self) {
84147
self.pool.close().await
85148
}
@@ -100,6 +163,11 @@ impl SqliteDocStorage {
100163

101164
#[cfg(test)]
102165
mod tests {
166+
use std::borrow::Cow;
167+
168+
use affine_schema::get_migrator;
169+
use sqlx::migrate::{Migration, Migrator};
170+
103171
use super::*;
104172

105173
async fn get_storage() -> SqliteDocStorage {
@@ -135,4 +203,57 @@ mod tests {
135203
let storage = SqliteDocStorage::new(":memory:".to_string());
136204
assert!(!storage.validate().await.unwrap());
137205
}
206+
207+
#[tokio::test]
208+
async fn connect_repairs_whitespace_only_migration_checksum_mismatch() {
209+
// Simulate a DB migrated with an older `add_idx_snapshots` SQL that had a
210+
// trailing space.
211+
let storage = SqliteDocStorage::new(":memory:".to_string());
212+
213+
let new_migrator = get_migrator();
214+
let mut migrations = new_migrator.migrations.to_vec();
215+
assert!(migrations.len() >= 3);
216+
217+
let mig3 = migrations[2].clone();
218+
assert_eq!(mig3.version, 3);
219+
assert_eq!(mig3.description.as_ref(), "add_idx_snapshots");
220+
221+
let legacy_sql = format!("{} ", mig3.sql);
222+
migrations[2] = Migration::new(
223+
mig3.version,
224+
mig3.description.clone(),
225+
mig3.migration_type,
226+
Cow::Owned(legacy_sql),
227+
mig3.no_tx,
228+
);
229+
230+
// The legacy DB didn't have newer migrations.
231+
migrations.truncate(3);
232+
let legacy_migrator = Migrator {
233+
migrations: Cow::Owned(migrations),
234+
..Migrator::DEFAULT
235+
};
236+
237+
legacy_migrator.run(&storage.pool).await.unwrap();
238+
239+
// Now connecting with the current code should auto-repair the checksum and
240+
// succeed.
241+
storage.connect().await.unwrap();
242+
243+
let expected_checksum = get_migrator()
244+
.iter()
245+
.find(|m| m.version == 3)
246+
.unwrap()
247+
.checksum
248+
.as_ref()
249+
.to_vec();
250+
251+
let row = sqlx::query("SELECT checksum FROM _sqlx_migrations WHERE version = 3")
252+
.fetch_one(&storage.pool)
253+
.await
254+
.unwrap();
255+
let checksum: Vec<u8> = row.get("checksum");
256+
257+
assert_eq!(checksum, expected_checksum);
258+
}
138259
}

0 commit comments

Comments
 (0)