Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/backend/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/server/src/__tests__/app/selfhost.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'");

Expand All @@ -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'");

Expand Down
2 changes: 1 addition & 1 deletion packages/backend/server/src/core/selfhost/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/frontend/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/admin/src/global.css
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
91 changes: 85 additions & 6 deletions packages/frontend/admin/src/modules/queue/index.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
Expand Down
5 changes: 0 additions & 5 deletions packages/frontend/admin/src/modules/queue/queue.css

This file was deleted.

14 changes: 14 additions & 0 deletions packages/frontend/admin/src/modules/queue/queuedash.css
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions packages/frontend/apps/electron/src/helper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
125 changes: 123 additions & 2 deletions packages/frontend/native/nbstore/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
}
1 change: 1 addition & 0 deletions tools/cli/bin/cli.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';

spawnSync('yarn', ['r', 'affine.ts', ...process.argv.slice(2)], {
Expand Down
1 change: 1 addition & 0 deletions tools/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"node-loader": "^2.1.0",
"postcss": "^8.4.49",
"postcss-loader": "^8.1.1",
"postcss-selector-parser": "^7.1.0",
"prettier": "^3.7.4",
"react-refresh": "^0.17.0",
"source-map-loader": "^5.0.0",
Expand Down
10 changes: 8 additions & 2 deletions tools/cli/src/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ function getWebpackBundleConfigs(pkg: Package): webpack.MultiConfiguration {
switch (pkg.name) {
case '@affine/admin': {
return [
createWebpackHTMLTargetConfig(pkg, pkg.srcPath.join('index.tsx').value),
createWebpackHTMLTargetConfig(
pkg,
pkg.srcPath.join('index.tsx').value,
{ selfhostPublicPath: '/admin/' }
),
] as webpack.MultiConfiguration;
}
case '@affine/web':
Expand Down Expand Up @@ -158,7 +162,9 @@ function getRspackBundleConfigs(pkg: Package): MultiRspackOptions {
switch (pkg.name) {
case '@affine/admin': {
return [
createRspackHTMLTargetConfig(pkg, pkg.srcPath.join('index.tsx').value),
createRspackHTMLTargetConfig(pkg, pkg.srcPath.join('index.tsx').value, {
selfhostPublicPath: '/admin/',
}),
] as MultiRspackOptions;
}
case '@affine/web':
Expand Down
Loading
Loading