mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-22 08:47:10 +08:00
Compare commits
5 Commits
v2026.2.21
...
darksky/tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b438e04b2a | ||
|
|
b0494ec3c1 | ||
|
|
096bbcf7e6 | ||
|
|
3d01766f55 | ||
|
|
2414aa5848 |
@@ -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'");
|
||||
|
||||
|
||||
@@ -276,22 +276,16 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.models.history.create(
|
||||
{
|
||||
spaceId: snapshot.spaceId,
|
||||
docId: snapshot.docId,
|
||||
timestamp: snapshot.timestamp,
|
||||
blob: Buffer.from(snapshot.bin),
|
||||
editorId: snapshot.editor,
|
||||
},
|
||||
historyMaxAge
|
||||
);
|
||||
} catch (e) {
|
||||
// safe to ignore
|
||||
// only happens when duplicated history record created in multi processes
|
||||
this.logger.error('Failed to create history record', e);
|
||||
}
|
||||
await this.models.history.create(
|
||||
{
|
||||
spaceId: snapshot.spaceId,
|
||||
docId: snapshot.docId,
|
||||
timestamp: snapshot.timestamp,
|
||||
blob: Buffer.from(snapshot.bin),
|
||||
editorId: snapshot.editor,
|
||||
},
|
||||
historyMaxAge
|
||||
);
|
||||
|
||||
metrics.doc
|
||||
.counter('history_created_counter', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,6 +74,27 @@ test('should create a history record', async t => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should not fail on duplicated history record', async t => {
|
||||
const snapshot = {
|
||||
spaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
blob: Uint8Array.from([1, 2, 3]),
|
||||
timestamp: Date.now(),
|
||||
editorId: user.id,
|
||||
};
|
||||
|
||||
const created1 = await t.context.history.create(snapshot, 1000);
|
||||
const created2 = await t.context.history.create(snapshot, 1000);
|
||||
t.deepEqual(created1.timestamp, snapshot.timestamp);
|
||||
t.deepEqual(created2.timestamp, snapshot.timestamp);
|
||||
|
||||
const histories = await t.context.history.findMany(
|
||||
snapshot.spaceId,
|
||||
snapshot.docId
|
||||
);
|
||||
t.is(histories.length, 1);
|
||||
});
|
||||
|
||||
test('should return null when history timestamp not match', async t => {
|
||||
const snapshot = {
|
||||
spaceId: workspace.id,
|
||||
|
||||
@@ -33,22 +33,33 @@ export class HistoryModel extends BaseModel {
|
||||
* Create a doc history with a max age.
|
||||
*/
|
||||
async create(snapshot: Doc, maxAge: number): Promise<DocHistorySimple> {
|
||||
const row = await this.db.snapshotHistory.create({
|
||||
select: {
|
||||
timestamp: true,
|
||||
createdByUser: { select: publicUserSelect },
|
||||
const timestamp = new Date(snapshot.timestamp);
|
||||
const expiredAt = new Date(Date.now() + maxAge);
|
||||
|
||||
// This method may be called concurrently by multiple processes for the same
|
||||
// (workspaceId, docId, timestamp). Using upsert avoids duplicate key errors
|
||||
// that would otherwise abort the surrounding transaction.
|
||||
const row = await this.db.snapshotHistory.upsert({
|
||||
where: {
|
||||
workspaceId_id_timestamp: {
|
||||
workspaceId: snapshot.spaceId,
|
||||
id: snapshot.docId,
|
||||
timestamp,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
select: { timestamp: true, createdByUser: { select: publicUserSelect } },
|
||||
create: {
|
||||
workspaceId: snapshot.spaceId,
|
||||
id: snapshot.docId,
|
||||
timestamp: new Date(snapshot.timestamp),
|
||||
timestamp,
|
||||
blob: snapshot.blob,
|
||||
createdBy: snapshot.editorId,
|
||||
expiredAt: new Date(Date.now() + maxAge),
|
||||
expiredAt,
|
||||
},
|
||||
update: { expiredAt },
|
||||
});
|
||||
this.logger.debug(
|
||||
`Created history ${row.timestamp} for ${snapshot.docId} in ${snapshot.spaceId}`
|
||||
`Upserted history ${row.timestamp} for ${snapshot.docId} in ${snapshot.spaceId}`
|
||||
);
|
||||
return {
|
||||
timestamp: row.timestamp.getTime(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
14
packages/frontend/admin/src/modules/queue/queuedash.css
Normal file
14
packages/frontend/admin/src/modules/queue/queuedash.css
Normal file
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import cp from 'node:child_process';
|
||||
import { rm, symlink } from 'node:fs/promises';
|
||||
import { readdir, rm, symlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -25,6 +25,144 @@ const fromBuildIdentifier = utils.fromBuildIdentifier;
|
||||
const linuxMimeTypes = [`x-scheme-handler/${productName.toLowerCase()}`];
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
const DEFAULT_ELECTRON_LOCALES_KEEP = new Set([
|
||||
'en',
|
||||
'en_US',
|
||||
'en_GB',
|
||||
'zh_CN',
|
||||
'zh_TW',
|
||||
'fr',
|
||||
'es',
|
||||
'es_419',
|
||||
'pl',
|
||||
'de',
|
||||
'ru',
|
||||
'ja',
|
||||
'it',
|
||||
'ca',
|
||||
'da',
|
||||
'hi',
|
||||
'sv',
|
||||
'ur',
|
||||
'ar',
|
||||
'uk',
|
||||
'ko',
|
||||
'pt_BR',
|
||||
'fa',
|
||||
'nb',
|
||||
]);
|
||||
|
||||
const getElectronLocalesKeep = () => {
|
||||
const raw = process.env.ELECTRON_LOCALES_KEEP?.trim();
|
||||
if (!raw) return DEFAULT_ELECTRON_LOCALES_KEEP;
|
||||
|
||||
const normalized = raw.toLowerCase();
|
||||
if (normalized === 'all' || normalized === '*') return null;
|
||||
|
||||
const keep = new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
// Always keep English as a safe fallback.
|
||||
keep.add('en');
|
||||
keep.add('en_US');
|
||||
keep.add('en_GB');
|
||||
return keep;
|
||||
};
|
||||
|
||||
const getElectronPakLocalesKeep = keep => {
|
||||
const pakKeep = new Set();
|
||||
for (const locale of keep) {
|
||||
if (locale === 'en') {
|
||||
pakKeep.add('en-US');
|
||||
continue;
|
||||
}
|
||||
pakKeep.add(locale.replaceAll('_', '-'));
|
||||
}
|
||||
|
||||
// Always keep English (US) as a safe fallback for Chromium/Electron locales.
|
||||
pakKeep.add('en');
|
||||
pakKeep.add('en-US');
|
||||
pakKeep.add('en-GB');
|
||||
return pakKeep;
|
||||
};
|
||||
|
||||
const trimElectronFrameworkLocales = async (
|
||||
resourcesAppDir,
|
||||
targetPlatform
|
||||
) => {
|
||||
if (process.env.TRIM_ELECTRON_LOCALES === '0') return;
|
||||
if (targetPlatform !== 'darwin' && targetPlatform !== 'mas') return;
|
||||
|
||||
const keep = getElectronLocalesKeep();
|
||||
if (!keep) return;
|
||||
|
||||
const contentsDir = path.resolve(resourcesAppDir, '..', '..');
|
||||
const frameworkResourcesDir = path.join(
|
||||
contentsDir,
|
||||
'Frameworks',
|
||||
'Electron Framework.framework',
|
||||
'Versions',
|
||||
'A',
|
||||
'Resources'
|
||||
);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(frameworkResourcesDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const localeDirs = entries
|
||||
.filter(entry => entry.isDirectory() && entry.name.endsWith('.lproj'))
|
||||
.map(entry => entry.name);
|
||||
|
||||
await Promise.all(
|
||||
localeDirs.map(async dirName => {
|
||||
const locale = dirName.slice(0, -'.lproj'.length);
|
||||
if (keep.has(locale)) return;
|
||||
await rm(path.join(frameworkResourcesDir, dirName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const trimElectronPakLocales = async (resourcesAppDir, targetPlatform) => {
|
||||
if (process.env.TRIM_ELECTRON_LOCALES === '0') return;
|
||||
if (targetPlatform !== 'win32' && targetPlatform !== 'linux') return;
|
||||
|
||||
const keep = getElectronLocalesKeep();
|
||||
if (!keep) return;
|
||||
|
||||
const rootDir = path.resolve(resourcesAppDir, '..', '..');
|
||||
const localesDir = path.join(rootDir, 'locales');
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(localesDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const pakKeep = getElectronPakLocalesKeep(keep);
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter(entry => entry.isFile() && entry.name.endsWith('.pak'))
|
||||
.map(async entry => {
|
||||
const locale = entry.name.slice(0, -'.pak'.length);
|
||||
if (pakKeep.has(locale)) return;
|
||||
await rm(path.join(localesDir, entry.name), { force: true });
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const makers = [
|
||||
!process.env.SKIP_BUNDLE &&
|
||||
platform === 'darwin' && {
|
||||
@@ -204,7 +342,27 @@ export default {
|
||||
},
|
||||
],
|
||||
executableName: productName,
|
||||
ignore: [/\.map$/],
|
||||
ignore: [
|
||||
/\.map$/,
|
||||
/\/test($|\/)/,
|
||||
/\/scripts($|\/)/,
|
||||
/\/examples($|\/)/,
|
||||
/\/docs($|\/)/,
|
||||
/\/README\.md$/,
|
||||
/\/forge\.config\.mjs$/,
|
||||
/\/dev-app-update\.yml$/,
|
||||
/\/resources\/app-update\.yml$/,
|
||||
],
|
||||
afterCopy: [
|
||||
(buildPath, _electronVersion, targetPlatform, _arch, done) => {
|
||||
Promise.all([
|
||||
trimElectronFrameworkLocales(buildPath, targetPlatform),
|
||||
trimElectronPakLocales(buildPath, targetPlatform),
|
||||
])
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
],
|
||||
asar: true,
|
||||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription:
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -40,14 +40,7 @@ interface CodeArtifactToolResult {
|
||||
toolCallId: string;
|
||||
toolName: string; // 'code_artifact'
|
||||
args: { title: string };
|
||||
result:
|
||||
| {
|
||||
title: string;
|
||||
html: string;
|
||||
size: number;
|
||||
}
|
||||
| ToolError
|
||||
| null;
|
||||
result: { title: string; html: string; size: number } | ToolError | null;
|
||||
}
|
||||
|
||||
export class CodeHighlighter extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
spawnSync('yarn', ['r', 'affine.ts', ...process.argv.slice(2)], {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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':
|
||||
@@ -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':
|
||||
|
||||
111
tools/cli/src/postcss/queuedash-scope.ts
Normal file
111
tools/cli/src/postcss/queuedash-scope.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { AtRule, Container, Node, PluginCreator, Rule } from 'postcss';
|
||||
import selectorParser from 'postcss-selector-parser';
|
||||
|
||||
export interface QueuedashScopeOptions {
|
||||
scopeClass?: string;
|
||||
}
|
||||
|
||||
function normalizeFilePath(filePath: string) {
|
||||
return filePath.replaceAll('\\', '/').split('?')[0];
|
||||
}
|
||||
|
||||
function isInKeyframes(rule: Rule) {
|
||||
let parent: Node | undefined = rule.parent;
|
||||
while (parent) {
|
||||
if (parent.type === 'atrule') {
|
||||
const name = (parent as AtRule).name?.toLowerCase();
|
||||
if (name && name.endsWith('keyframes')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const DEFAULT_SCOPE_CLASS = 'affine-queuedash';
|
||||
|
||||
export const queuedashScopePostcssPlugin: PluginCreator<
|
||||
QueuedashScopeOptions
|
||||
> = (options = {}) => {
|
||||
const scopeClass = options.scopeClass ?? DEFAULT_SCOPE_CLASS;
|
||||
const scopeSelector = `:where(.${scopeClass})`;
|
||||
|
||||
const scopeAst = selectorParser().astSync(scopeSelector);
|
||||
const scopeNodes = scopeAst.nodes[0]?.nodes;
|
||||
|
||||
if (!scopeNodes) {
|
||||
throw new Error(
|
||||
`[queuedashScopePostcssPlugin] Failed to parse scope selector: ${scopeSelector}`
|
||||
);
|
||||
}
|
||||
|
||||
const scopeProcessor = selectorParser(selectors => {
|
||||
selectors.each(selector => {
|
||||
const raw = selector.toString().trim();
|
||||
|
||||
if (
|
||||
raw.startsWith(scopeSelector) ||
|
||||
raw.startsWith(`.${scopeClass}`) ||
|
||||
raw.startsWith(`:where(.${scopeClass})`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
raw === 'html' ||
|
||||
raw === 'body' ||
|
||||
raw === ':host' ||
|
||||
raw === ':root'
|
||||
) {
|
||||
selector.nodes = scopeNodes.map(node => node.clone());
|
||||
return;
|
||||
}
|
||||
|
||||
const prefixNodes = scopeNodes.map(node => node.clone());
|
||||
const space = selectorParser.combinator({ value: ' ' });
|
||||
selector.nodes = [...prefixNodes, space, ...selector.nodes];
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
postcssPlugin: 'affine-queuedash-scope',
|
||||
Once(root, { result }) {
|
||||
const from =
|
||||
root.source?.input.file ||
|
||||
root.source?.input.from ||
|
||||
result.opts.from ||
|
||||
'';
|
||||
|
||||
const normalized = from ? normalizeFilePath(from) : '';
|
||||
const isQueuedashVendorCss = normalized.endsWith(
|
||||
'/@queuedash/ui/dist/styles.css'
|
||||
);
|
||||
|
||||
const queuedashLayers: AtRule[] = [];
|
||||
root.walkAtRules('layer', atRule => {
|
||||
if (atRule.params?.trim() === 'queuedash' && atRule.nodes?.length) {
|
||||
queuedashLayers.push(atRule);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isQueuedashVendorCss && queuedashLayers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targets: Container[] =
|
||||
queuedashLayers.length > 0 ? queuedashLayers : [root];
|
||||
|
||||
targets.forEach(container => {
|
||||
container.walkRules(rule => {
|
||||
if (!rule.selector || isInKeyframes(rule)) {
|
||||
return;
|
||||
}
|
||||
rule.selector = scopeProcessor.processSync(rule.selector);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
queuedashScopePostcssPlugin.postcss = true;
|
||||
@@ -12,6 +12,7 @@ import { VanillaExtractPlugin } from '@vanilla-extract/webpack-plugin';
|
||||
import cssnano from 'cssnano';
|
||||
import { compact, merge } from 'lodash-es';
|
||||
|
||||
import { queuedashScopePostcssPlugin } from '../postcss/queuedash-scope.js';
|
||||
import { productionCacheGroups } from '../webpack/cache-group.js';
|
||||
import {
|
||||
type CreateHTMLPluginConfig,
|
||||
@@ -228,6 +229,9 @@ export function createHTMLTargetConfig(
|
||||
require(pkg.join('tailwind.config.js').value),
|
||||
],
|
||||
['autoprefixer'],
|
||||
...(buildConfig.isAdmin
|
||||
? [queuedashScopePostcssPlugin()]
|
||||
: []),
|
||||
]
|
||||
: [
|
||||
cssnano({
|
||||
|
||||
@@ -79,6 +79,7 @@ const currentDir = Path.dir(import.meta.url);
|
||||
export interface CreateHTMLPluginConfig {
|
||||
filename?: string;
|
||||
additionalEntryForSelfhost?: boolean;
|
||||
selfhostPublicPath?: string;
|
||||
injectGlobalErrorHandler?: boolean;
|
||||
emitAssetsManifest?: boolean;
|
||||
}
|
||||
@@ -206,6 +207,7 @@ export function createHTMLPlugins(
|
||||
): WebpackPluginInstance[] {
|
||||
const publicPath = getPublicPath(BUILD_CONFIG);
|
||||
const htmlPluginOptions = getHTMLPluginOptions(BUILD_CONFIG);
|
||||
const selfhostPublicPath = config.selfhostPublicPath ?? '/';
|
||||
|
||||
const plugins: WebpackPluginInstance[] = [];
|
||||
plugins.push(
|
||||
@@ -269,9 +271,10 @@ export function createHTMLPlugins(
|
||||
new HTMLPlugin({
|
||||
...htmlPluginOptions,
|
||||
chunks: ['index'],
|
||||
publicPath: selfhostPublicPath,
|
||||
meta: {
|
||||
'env:isSelfHosted': 'true',
|
||||
'env:publicPath': '/',
|
||||
'env:publicPath': selfhostPublicPath,
|
||||
},
|
||||
filename: 'selfhost.html',
|
||||
templateParameters: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
||||
import TerserPlugin from 'terser-webpack-plugin';
|
||||
import webpack from 'webpack';
|
||||
|
||||
import { queuedashScopePostcssPlugin } from '../postcss/queuedash-scope.js';
|
||||
import { productionCacheGroups } from './cache-group.js';
|
||||
import {
|
||||
type CreateHTMLPluginConfig,
|
||||
@@ -230,6 +231,9 @@ export function createHTMLTargetConfig(
|
||||
require(pkg.join('tailwind.config.js').value),
|
||||
],
|
||||
['autoprefixer'],
|
||||
...(buildConfig.isAdmin
|
||||
? [queuedashScopePostcssPlugin()]
|
||||
: []),
|
||||
]
|
||||
: [
|
||||
cssnano({
|
||||
|
||||
62
yarn.lock
62
yarn.lock
@@ -143,6 +143,7 @@ __metadata:
|
||||
node-loader: "npm:^2.1.0"
|
||||
postcss: "npm:^8.4.49"
|
||||
postcss-loader: "npm:^8.1.1"
|
||||
postcss-selector-parser: "npm:^7.1.0"
|
||||
prettier: "npm:^3.7.4"
|
||||
react-refresh: "npm:^0.17.0"
|
||||
source-map-loader: "npm:^5.0.0"
|
||||
@@ -185,7 +186,7 @@ __metadata:
|
||||
"@affine/graphql": "workspace:*"
|
||||
"@affine/routes": "workspace:*"
|
||||
"@blocksuite/icons": "npm:^2.2.17"
|
||||
"@queuedash/ui": "npm:^3.14.0"
|
||||
"@queuedash/ui": "npm:^3.16.0"
|
||||
"@radix-ui/react-accordion": "npm:^1.2.2"
|
||||
"@radix-ui/react-alert-dialog": "npm:^1.1.3"
|
||||
"@radix-ui/react-aspect-ratio": "npm:^1.1.1"
|
||||
@@ -1022,7 +1023,7 @@ __metadata:
|
||||
"@opentelemetry/semantic-conventions": "npm:^1.38.0"
|
||||
"@prisma/client": "npm:^6.6.0"
|
||||
"@prisma/instrumentation": "npm:^6.7.0"
|
||||
"@queuedash/api": "npm:^3.14.0"
|
||||
"@queuedash/api": "npm:^3.16.0"
|
||||
"@react-email/components": "npm:0.0.38"
|
||||
"@socket.io/redis-adapter": "npm:^8.3.0"
|
||||
"@types/cookie-parser": "npm:^1.4.8"
|
||||
@@ -11676,11 +11677,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@queuedash/api@npm:3.14.0, @queuedash/api@npm:^3.14.0":
|
||||
version: 3.14.0
|
||||
resolution: "@queuedash/api@npm:3.14.0"
|
||||
"@queuedash/api@npm:3.16.0, @queuedash/api@npm:^3.16.0":
|
||||
version: 3.16.0
|
||||
resolution: "@queuedash/api@npm:3.16.0"
|
||||
dependencies:
|
||||
"@trpc/server": "npm:^11.6.0"
|
||||
"@trpc/server": "npm:^11.8.1"
|
||||
redis: "npm:^4.7.0"
|
||||
redis-info: "npm:^3.1.0"
|
||||
zod: "npm:^3.24.2"
|
||||
@@ -11713,23 +11714,23 @@ __metadata:
|
||||
optional: true
|
||||
hono:
|
||||
optional: true
|
||||
checksum: 10/d0117ab10c4a59ea0b3d29bc783b273b7b2b08290e122ed1b05bbf185f44ba87b5229ae8e98ce41692de334548c02bf8f7980f7b42a623352d0a048b74966a36
|
||||
checksum: 10/84c3b552450a72a76245930caab2371aabe85168a2accaa9bc3aefa7e4ea22e99a0e77005f528995e66f39adb63a109721ff89cb556565292d7429c22c5254ec
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@queuedash/ui@npm:^3.14.0":
|
||||
version: 3.14.0
|
||||
resolution: "@queuedash/ui@npm:3.14.0"
|
||||
"@queuedash/ui@npm:^3.16.0":
|
||||
version: 3.16.0
|
||||
resolution: "@queuedash/ui@npm:3.16.0"
|
||||
dependencies:
|
||||
"@monaco-editor/react": "npm:^4.7.0"
|
||||
"@queuedash/api": "npm:3.14.0"
|
||||
"@queuedash/api": "npm:3.16.0"
|
||||
"@radix-ui/react-checkbox": "npm:^1.3.3"
|
||||
"@radix-ui/react-icons": "npm:^1.3.2"
|
||||
"@tanstack/react-query": "npm:^5.90.5"
|
||||
"@tanstack/react-table": "npm:^8.21.3"
|
||||
"@trpc/client": "npm:^11.6.0"
|
||||
"@trpc/react-query": "npm:^11.6.0"
|
||||
"@trpc/server": "npm:^11.6.0"
|
||||
"@trpc/client": "npm:^11.8.1"
|
||||
"@trpc/react-query": "npm:^11.8.1"
|
||||
"@trpc/server": "npm:^11.8.1"
|
||||
clsx: "npm:^2.1.1"
|
||||
cronstrue: "npm:^2.61.0"
|
||||
date-fns: "npm:^4.1.0"
|
||||
@@ -11744,7 +11745,7 @@ __metadata:
|
||||
peerDependencies:
|
||||
react: ">=18"
|
||||
react-dom: ">=18"
|
||||
checksum: 10/d35dd553bc5a8896d93b1ef6bbd1c7801e5a6ac8e91f392b107d4c4681aa0d46e933ae9c258f2ef0611bd7a1bbc8279506168461ec390afbe3c5a90931c71579
|
||||
checksum: 10/a3be3e738e281cde533ccadefadaeae1a324fec7658ed38d5e942bd965a4866f5a335f6b4b9a6701386a9cf74a8b931faac45662f8e895df467a2cff75584dd0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -16711,36 +16712,35 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@trpc/client@npm:^11.6.0":
|
||||
version: 11.8.1
|
||||
resolution: "@trpc/client@npm:11.8.1"
|
||||
"@trpc/client@npm:^11.8.1":
|
||||
version: 11.10.0
|
||||
resolution: "@trpc/client@npm:11.10.0"
|
||||
peerDependencies:
|
||||
"@trpc/server": 11.8.1
|
||||
"@trpc/server": 11.10.0
|
||||
typescript: ">=5.7.2"
|
||||
checksum: 10/1411acb66ad8c94cb5cf90386fcca2cf0f67a7d6a7ca90e42b23e737f39d1e8079e8c9088d9b46d3643a57e6eefbf721f83b372624cb0062b371e4c362d2bd98
|
||||
checksum: 10/91c6c7bf8ac471e4e15042097eace3cfbed10e9af986bef57dfa320c55802aeabe45fbc0bed785bd42b18e8158dd5d8b89709d6bc41c595d4cab70f72f0c3dc9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@trpc/react-query@npm:^11.6.0":
|
||||
version: 11.8.1
|
||||
resolution: "@trpc/react-query@npm:11.8.1"
|
||||
"@trpc/react-query@npm:^11.8.1":
|
||||
version: 11.10.0
|
||||
resolution: "@trpc/react-query@npm:11.10.0"
|
||||
peerDependencies:
|
||||
"@tanstack/react-query": ^5.80.3
|
||||
"@trpc/client": 11.8.1
|
||||
"@trpc/server": 11.8.1
|
||||
"@trpc/client": 11.10.0
|
||||
"@trpc/server": 11.10.0
|
||||
react: ">=18.2.0"
|
||||
react-dom: ">=18.2.0"
|
||||
typescript: ">=5.7.2"
|
||||
checksum: 10/6244083404ff0f9e218a6239af43f84941cf20fe30ea6a8b02655649ea627161294360228c54070b0bde6332252c26545b53c7e2148c67afe691863a976422b2
|
||||
checksum: 10/80b76ad84915a693504545cde5d8d8d9c0f26beefea7809c852d44779e27d01e6af95d0154546887ecdc22f2f747dec7d26a609527a627f7eea198990867f0af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@trpc/server@npm:^11.6.0":
|
||||
version: 11.8.1
|
||||
resolution: "@trpc/server@npm:11.8.1"
|
||||
"@trpc/server@npm:^11.8.1":
|
||||
version: 11.10.0
|
||||
resolution: "@trpc/server@npm:11.10.0"
|
||||
peerDependencies:
|
||||
typescript: ">=5.7.2"
|
||||
checksum: 10/074b7bd564d0821cbd0711486fb51ee5733d13afe894e5f3e4b0b5ff5c1b71b156d820322e068763b47e076e5cd22a81f8c1df085834cc4ff93c4a1e0005e1de
|
||||
checksum: 10/e50d2aaefa7b40ef1a270d209ea7b37f37d3990dd15008fc5069c73cf2f108fe3a8777c7c498a1950a984f34f7a26e3922f03a2e87a8ba05d8bc5f2c82f04ff3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user