-
-
+
+ {detail ? (
+
+ {detail}
+
+ ) : null}
);
diff --git a/apps/desktop/src/shared/long-load-gate.test.tsx b/apps/desktop/src/shared/long-load-gate.test.tsx
index b2a704abb3..d6a812fa82 100644
--- a/apps/desktop/src/shared/long-load-gate.test.tsx
+++ b/apps/desktop/src/shared/long-load-gate.test.tsx
@@ -1,9 +1,12 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+const getStartupStatus = vi.hoisted(() => vi.fn());
const waitUntilReady = vi.hoisted(() => vi.fn());
vi.mock("@anlg/plugin-db", () => ({
+ getStartupStatus,
waitUntilReady,
}));
@@ -15,6 +18,12 @@ import { LONG_LOAD_SPLASH_DELAY_MS, LongLoadGate } from "./long-load-gate";
describe("LongLoadGate", () => {
beforeEach(() => {
+ getStartupStatus.mockReset();
+ getStartupStatus.mockResolvedValue({
+ phase: "preparing_database",
+ migrationCurrent: null,
+ migrationTotal: null,
+ });
waitUntilReady.mockReset();
});
@@ -29,33 +38,30 @@ describe("LongLoadGate", () => {
bootSplash.id = "boot-splash";
document.body.append(bootSplash);
- render(
-
- app
- ,
- );
+ renderLongLoadGate();
await waitFor(() => {
expect(screen.getByText("app")).toBeTruthy();
+ expect(document.getElementById("boot-splash")).toBeNull();
});
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
- expect(document.getElementById("boot-splash")).toBeNull();
});
- it("shows the branded splash after the delay while startup is still running", async () => {
+ it("shows the reported migration progress after the delay", async () => {
let resolveReady!: () => void;
waitUntilReady.mockReturnValue(
new Promise
((resolve) => {
resolveReady = resolve;
}),
);
+ getStartupStatus.mockResolvedValue({
+ phase: "migrating_database",
+ migrationCurrent: 2,
+ migrationTotal: 5,
+ });
vi.useFakeTimers();
- render(
-
- app
- ,
- );
+ renderLongLoadGate();
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
expect(screen.queryByText("app")).toBeNull();
@@ -65,6 +71,11 @@ describe("LongLoadGate", () => {
});
expect(screen.getByRole("status", { name: "Loading" })).toBeTruthy();
+ expect(
+ screen.getByText(
+ "Migrating your local database (2 of 5). This may take a few minutes.",
+ ),
+ ).toBeTruthy();
await act(async () => {
resolveReady();
@@ -74,6 +85,24 @@ describe("LongLoadGate", () => {
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
});
+ it("does not claim a migration while the database is only being checked", async () => {
+ waitUntilReady.mockReturnValue(new Promise(() => {}));
+ vi.useFakeTimers();
+
+ renderLongLoadGate();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(LONG_LOAD_SPLASH_DELAY_MS);
+ });
+
+ expect(
+ screen.getByText(
+ "Checking your local database. This is taking longer than expected.",
+ ),
+ ).toBeTruthy();
+ expect(screen.queryByText(/Migrating your local database/)).toBeNull();
+ });
+
it("shows an update prompt when startup reports a newer schema", async () => {
waitUntilReady.mockRejectedValue(
new Error(
@@ -81,11 +110,7 @@ describe("LongLoadGate", () => {
),
);
- render(
-
- app
- ,
- );
+ renderLongLoadGate();
await waitFor(() => {
expect(screen.getByText("Anarlog needs an update")).toBeTruthy();
@@ -94,3 +119,22 @@ describe("LongLoadGate", () => {
expect(screen.queryByRole("button", { name: "Restart App" })).toBeNull();
});
});
+
+function renderLongLoadGate() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ gcTime: Infinity,
+ retry: false,
+ },
+ },
+ });
+
+ return render(
+
+
+ app
+
+ ,
+ );
+}
diff --git a/apps/desktop/src/shared/long-load-gate.tsx b/apps/desktop/src/shared/long-load-gate.tsx
index 7be206fbb6..42a9a8b474 100644
--- a/apps/desktop/src/shared/long-load-gate.tsx
+++ b/apps/desktop/src/shared/long-load-gate.tsx
@@ -1,8 +1,13 @@
import { ArrowClockwise } from "@phosphor-icons/react";
+import { useQuery } from "@tanstack/react-query";
import { relaunch } from "@tauri-apps/plugin-process";
import { useEffect, useState, type ReactNode } from "react";
-import { waitUntilReady } from "@anlg/plugin-db";
+import {
+ getStartupStatus,
+ waitUntilReady,
+ type StartupStatus,
+} from "@anlg/plugin-db";
import { Button } from "@anlg/ui/components/ui/button";
import { cn } from "@anlg/utils";
@@ -11,6 +16,7 @@ import { BrandLoadingView } from "./brand-loading-view";
import { captureOperationalError } from "~/error-reporting";
export const LONG_LOAD_SPLASH_DELAY_MS = 400;
+const STARTUP_STATUS_REFETCH_INTERVAL_MS = 250;
function dismissBootSplash() {
document.getElementById("boot-splash")?.remove();
@@ -20,6 +26,13 @@ export function LongLoadGate({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [showSplash, setShowSplash] = useState(false);
const [error, setError] = useState(null);
+ const { data: startupStatus } = useQuery({
+ queryKey: ["database-startup-status"],
+ queryFn: getStartupStatus,
+ enabled: !ready && !error,
+ refetchInterval: ready ? false : STARTUP_STATUS_REFETCH_INTERVAL_MS,
+ retry: false,
+ });
useEffect(() => {
if (ready || showSplash || error) {
@@ -65,7 +78,29 @@ export function LongLoadGate({ children }: { children: ReactNode }) {
if (!showSplash) {
return null;
}
- return ;
+ return ;
+}
+
+function getStartupDetail(status: StartupStatus | undefined) {
+ switch (status?.phase) {
+ case "preparing_database":
+ return "Checking your local database. This is taking longer than expected.";
+ case "migrating_database": {
+ const progress =
+ status.migrationCurrent && status.migrationTotal
+ ? ` (${status.migrationCurrent} of ${status.migrationTotal})`
+ : "";
+ return `Migrating your local database${progress}. This may take a few minutes.`;
+ }
+ case "importing_legacy_data":
+ return "Importing your existing notes. This may take a few minutes.";
+ case "configuring_cloudsync":
+ return "Preparing sync. This should only take a moment.";
+ case "ready":
+ case "failed":
+ case undefined:
+ return undefined;
+ }
}
function StartupErrorView({ error }: { error: Error }) {
diff --git a/crates/db-app/src/lib.rs b/crates/db-app/src/lib.rs
index 29d6495d0e..9f26a34894 100644
--- a/crates/db-app/src/lib.rs
+++ b/crates/db-app/src/lib.rs
@@ -459,12 +459,19 @@ impl From for AppSchemaError {
}
pub async fn prepare_schema(db: &anlg_db_core::Db) -> Result<(), AppSchemaError> {
+ prepare_schema_with_progress(db, |_| {}).await
+}
+
+pub async fn prepare_schema_with_progress(
+ db: &anlg_db_core::Db,
+ on_migration_progress: impl FnMut(anlg_db_migrate::MigrationProgress) + Send,
+) -> Result<(), AppSchemaError> {
let templates_missing_before_migration = !templates_table_exists(db.pool()).await?;
adopt_legacy_mobile_schema_migration(db.pool()).await?;
repair_legacy_shared_session_cache_migration(db.pool()).await?;
repair_legacy_attachment_transfer_jobs_migration(db.pool()).await?;
repair_torn_e2ee_payload_hash_local_state_migration(db).await?;
- anlg_db_migrate::migrate(db, schema()).await?;
+ anlg_db_migrate::migrate_with_progress(db, schema(), on_migration_progress).await?;
repair_missing_core_tables(db.pool(), templates_missing_before_migration).await?;
backfill_session_share_activation(db.pool()).await?;
ensure_cloudsync_workspace_binding(db.pool()).await?;
diff --git a/crates/db-migrate/src/lib.rs b/crates/db-migrate/src/lib.rs
index d288a17d53..2c6754628d 100644
--- a/crates/db-migrate/src/lib.rs
+++ b/crates/db-migrate/src/lib.rs
@@ -9,8 +9,22 @@ pub use schema::{DbSchema, MigrationScope, MigrationStep};
use anlg_db_core::Db;
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct MigrationProgress {
+ pub completed: usize,
+ pub total: usize,
+}
+
pub async fn migrate(db: &Db, schema: DbSchema) -> Result<(), MigrateError> {
- migrate::run_migrations(db, schema).await
+ migrate_with_progress(db, schema, |_| {}).await
+}
+
+pub async fn migrate_with_progress(
+ db: &Db,
+ schema: DbSchema,
+ on_progress: impl FnMut(MigrationProgress) + Send,
+) -> Result<(), MigrateError> {
+ migrate::run_migrations(db, schema, on_progress).await
}
#[cfg(test)]
@@ -208,4 +222,37 @@ mod tests {
assert!(tables.contains(&"_sqlx_migrations".to_string()));
}
+
+ #[tokio::test]
+ async fn migration_progress_only_counts_pending_steps() {
+ let db = open_memory_db().await;
+ migrate(&db, schema_of(&[STEP_ONE])).await.unwrap();
+
+ let mut updates = Vec::new();
+ migrate_with_progress(
+ &db,
+ schema_of(&[STEP_ONE, STEP_TWO_ADDITIVE, STEP_THREE_ADDITIVE]),
+ |progress| updates.push(progress),
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(
+ updates,
+ vec![
+ MigrationProgress {
+ completed: 0,
+ total: 2,
+ },
+ MigrationProgress {
+ completed: 1,
+ total: 2,
+ },
+ MigrationProgress {
+ completed: 2,
+ total: 2,
+ },
+ ]
+ );
+ }
}
diff --git a/crates/db-migrate/src/migrate.rs b/crates/db-migrate/src/migrate.rs
index 26fdfe10e3..8ef4b6c7c7 100644
--- a/crates/db-migrate/src/migrate.rs
+++ b/crates/db-migrate/src/migrate.rs
@@ -41,7 +41,11 @@ impl<'a> DbMigrateConnection<'a> {
}
}
-pub(crate) async fn run_migrations(db: &Db, schema: DbSchema) -> Result<(), MigrateError> {
+pub(crate) async fn run_migrations(
+ db: &Db,
+ schema: DbSchema,
+ on_progress: impl FnMut(crate::MigrationProgress) + Send,
+) -> Result<(), MigrateError> {
let resolved = resolve_migrations(schema)?;
let meta_by_version = resolved
.iter()
@@ -62,7 +66,7 @@ pub(crate) async fn run_migrations(db: &Db, schema: DbSchema) -> Result<(), Migr
let conn = db.pool().acquire().await?;
let mut conn = DbMigrateConnection::new(db, conn, meta_by_version);
- run_direct(&migrations, &mut conn).await?;
+ run_direct(&migrations, &mut conn, on_progress).await?;
Ok(())
}
@@ -71,6 +75,7 @@ const MIGRATIONS_TABLE: &str = "_sqlx_migrations";
async fn run_direct(
migrations: &[Migration],
conn: &mut DbMigrateConnection<'_>,
+ mut on_progress: impl FnMut(crate::MigrationProgress) + Send,
) -> Result<(), MigrateError> {
conn.lock().await?;
conn.ensure_migrations_table(MIGRATIONS_TABLE).await?;
@@ -121,6 +126,15 @@ async fn run_direct(
});
}
+ let pending_total = migrations
+ .iter()
+ .filter(|migration| {
+ !migration.migration_type.is_down_migration()
+ && !applied_migrations.contains_key(&migration.version)
+ })
+ .count();
+ let mut completed = 0;
+
for migration in migrations {
if migration.migration_type.is_down_migration() {
continue;
@@ -133,7 +147,18 @@ async fn run_direct(
}
}
None => {
+ if completed == 0 {
+ on_progress(crate::MigrationProgress {
+ completed,
+ total: pending_total,
+ });
+ }
conn.apply(MIGRATIONS_TABLE, migration).await?;
+ completed += 1;
+ on_progress(crate::MigrationProgress {
+ completed,
+ total: pending_total,
+ });
}
}
}
diff --git a/plugins/db/build.rs b/plugins/db/build.rs
index df7c6bb461..171b078412 100644
--- a/plugins/db/build.rs
+++ b/plugins/db/build.rs
@@ -34,6 +34,7 @@ const COMMANDS: &[&str] = &[
"sync_cloudsync_now",
"begin_cloudsync_activity",
"end_cloudsync_activity",
+ "get_startup_status",
"wait_until_ready",
];
diff --git a/plugins/db/js/bindings.gen.ts b/plugins/db/js/bindings.gen.ts
index 5b1bd4b3db..fdfd33c7ab 100644
--- a/plugins/db/js/bindings.gen.ts
+++ b/plugins/db/js/bindings.gen.ts
@@ -286,6 +286,9 @@ async endCloudsyncActivity(activity: string, key: string) : Promise {
+ return await TAURI_INVOKE("plugin:db|get_startup_status");
+},
async waitUntilReady() : Promise> {
try {
return { status: "ok", data: await TAURI_INVOKE("plugin:db|wait_until_ready") };
@@ -339,6 +342,8 @@ export type Participant = { human_id: string; display_name: string; email: strin
export type QueryEvent = { event: "result"; data: JsonValue[] } | { event: "error"; data: string }
export type SealedWorkspaceE2eeKey = { keyId: string; grants: WorkspaceE2eeKeyGrantUpload[] }
export type SessionIngestApplyResult = "applied" | "already_applied" | "rejected"
+export type StartupPhase = "preparing_database" | "migrating_database" | "importing_legacy_data" | "configuring_cloudsync" | "ready" | "failed"
+export type StartupStatus = { phase: StartupPhase; migrationCurrent: number | null; migrationTotal: number | null }
export type StorageMigrationState = { phase: string; latestRunId: string; parityVerified: boolean; cutoverAt: string | null; rollbackUntil: string | null; lastError: string; updatedAt: string }
export type SubscriptionRegistration = { id: string; analysis: DependencyAnalysis }
export type TAURI_CHANNEL = null
diff --git a/plugins/db/js/index.ts b/plugins/db/js/index.ts
index cc0817feae..7edc01b28d 100644
--- a/plugins/db/js/index.ts
+++ b/plugins/db/js/index.ts
@@ -20,6 +20,7 @@ import type {
Meeting,
MeetingPage,
SessionIngestApplyResult,
+ StartupStatus,
SubscriptionRegistration,
TranscriptPage,
WorkspaceE2eeKeyRecipient,
@@ -42,6 +43,7 @@ export type {
Meeting,
MeetingPage,
SessionIngestApplyResult,
+ StartupStatus,
TranscriptPage,
WorkspaceE2eeKeyRecipient,
} from "./bindings.gen";
@@ -362,6 +364,10 @@ export async function waitUntilReady(): Promise {
return invoke("plugin:db|wait_until_ready");
}
+export async function getStartupStatus(): Promise {
+ return invoke("plugin:db|get_startup_status");
+}
+
export async function beginCloudsyncActivity(
activity: string,
key: string,
diff --git a/plugins/db/permissions/autogenerated/commands/get_startup_status.toml b/plugins/db/permissions/autogenerated/commands/get_startup_status.toml
new file mode 100644
index 0000000000..cb2ce7ba4b
--- /dev/null
+++ b/plugins/db/permissions/autogenerated/commands/get_startup_status.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-get-startup-status"
+description = "Enables the get_startup_status command without any pre-configured scope."
+commands.allow = ["get_startup_status"]
+
+[[permission]]
+identifier = "deny-get-startup-status"
+description = "Denies the get_startup_status command without any pre-configured scope."
+commands.deny = ["get_startup_status"]
diff --git a/plugins/db/permissions/autogenerated/reference.md b/plugins/db/permissions/autogenerated/reference.md
index 4adf47fafa..464a98b2b5 100644
--- a/plugins/db/permissions/autogenerated/reference.md
+++ b/plugins/db/permissions/autogenerated/reference.md
@@ -34,6 +34,7 @@ Default permissions for the plugin
- `allow-suspend-cloudsync-for-sign-out`
- `allow-suspend-cloudsync-after-auth-loss`
- `allow-get-cloudsync-status`
+- `allow-get-startup-status`
- `allow-wait-until-ready`
## Permission Table
@@ -568,6 +569,32 @@ Denies the get_recurring_meeting_history command without any pre-configured scop
|
+`db:allow-get-startup-status`
+
+ |
+
+
+Enables the get_startup_status command without any pre-configured scope.
+
+ |
+
+
+
+|
+
+`db:deny-get-startup-status`
+
+ |
+
+
+Denies the get_startup_status command without any pre-configured scope.
+
+ |
+
+
+
+|
+
`db:allow-import-e2ee-device-enrollment`
|
diff --git a/plugins/db/permissions/default.toml b/plugins/db/permissions/default.toml
index 7559da7517..bb7a870b75 100644
--- a/plugins/db/permissions/default.toml
+++ b/plugins/db/permissions/default.toml
@@ -31,5 +31,6 @@ permissions = [
"allow-suspend-cloudsync-for-sign-out",
"allow-suspend-cloudsync-after-auth-loss",
"allow-get-cloudsync-status",
+ "allow-get-startup-status",
"allow-wait-until-ready",
]
diff --git a/plugins/db/permissions/schemas/schema.json b/plugins/db/permissions/schemas/schema.json
index 1dd127c7d9..424619e06a 100644
--- a/plugins/db/permissions/schemas/schema.json
+++ b/plugins/db/permissions/schemas/schema.json
@@ -534,6 +534,18 @@
"const": "deny-get-recurring-meeting-history",
"markdownDescription": "Denies the get_recurring_meeting_history command without any pre-configured scope."
},
+ {
+ "description": "Enables the get_startup_status command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-get-startup-status",
+ "markdownDescription": "Enables the get_startup_status command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the get_startup_status command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-get-startup-status",
+ "markdownDescription": "Denies the get_startup_status command without any pre-configured scope."
+ },
{
"description": "Enables the import_e2ee_device_enrollment command without any pre-configured scope.",
"type": "string",
@@ -727,10 +739,10 @@
"markdownDescription": "Denies the wait_until_ready command without any pre-configured scope."
},
{
- "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-apply-session-ingest`\n- `allow-execute`\n- `allow-execute-proxy`\n- `allow-execute-transaction`\n- `allow-list-meetings`\n- `allow-get-meeting`\n- `allow-get-meeting-transcript`\n- `allow-get-recurring-meeting-history`\n- `allow-get-legacy-cleanup-status`\n- `allow-get-legacy-import-report`\n- `allow-cleanup-legacy-files`\n- `allow-run-legacy-import`\n- `allow-get-e2ee-identity-status`\n- `allow-inspect-e2ee-recovery-key`\n- `allow-create-e2ee-identity`\n- `allow-import-e2ee-identity`\n- `allow-get-or-create-e2ee-device-identity`\n- `allow-seal-e2ee-recovery-key-for-device`\n- `allow-seal-workspace-e2ee-key-for-recipients`\n- `allow-import-e2ee-device-enrollment`\n- `allow-subscribe`\n- `allow-unsubscribe`\n- `allow-bind-cloudsync-account`\n- `allow-configure-cloudsync-token`\n- `allow-configure-e2ee-replica`\n- `allow-stop-cloudsync`\n- `allow-suspend-cloudsync`\n- `allow-suspend-cloudsync-for-sign-out`\n- `allow-suspend-cloudsync-after-auth-loss`\n- `allow-get-cloudsync-status`\n- `allow-wait-until-ready`",
+ "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-apply-session-ingest`\n- `allow-execute`\n- `allow-execute-proxy`\n- `allow-execute-transaction`\n- `allow-list-meetings`\n- `allow-get-meeting`\n- `allow-get-meeting-transcript`\n- `allow-get-recurring-meeting-history`\n- `allow-get-legacy-cleanup-status`\n- `allow-get-legacy-import-report`\n- `allow-cleanup-legacy-files`\n- `allow-run-legacy-import`\n- `allow-get-e2ee-identity-status`\n- `allow-inspect-e2ee-recovery-key`\n- `allow-create-e2ee-identity`\n- `allow-import-e2ee-identity`\n- `allow-get-or-create-e2ee-device-identity`\n- `allow-seal-e2ee-recovery-key-for-device`\n- `allow-seal-workspace-e2ee-key-for-recipients`\n- `allow-import-e2ee-device-enrollment`\n- `allow-subscribe`\n- `allow-unsubscribe`\n- `allow-bind-cloudsync-account`\n- `allow-configure-cloudsync-token`\n- `allow-configure-e2ee-replica`\n- `allow-stop-cloudsync`\n- `allow-suspend-cloudsync`\n- `allow-suspend-cloudsync-for-sign-out`\n- `allow-suspend-cloudsync-after-auth-loss`\n- `allow-get-cloudsync-status`\n- `allow-get-startup-status`\n- `allow-wait-until-ready`",
"type": "string",
"const": "default",
- "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-apply-session-ingest`\n- `allow-execute`\n- `allow-execute-proxy`\n- `allow-execute-transaction`\n- `allow-list-meetings`\n- `allow-get-meeting`\n- `allow-get-meeting-transcript`\n- `allow-get-recurring-meeting-history`\n- `allow-get-legacy-cleanup-status`\n- `allow-get-legacy-import-report`\n- `allow-cleanup-legacy-files`\n- `allow-run-legacy-import`\n- `allow-get-e2ee-identity-status`\n- `allow-inspect-e2ee-recovery-key`\n- `allow-create-e2ee-identity`\n- `allow-import-e2ee-identity`\n- `allow-get-or-create-e2ee-device-identity`\n- `allow-seal-e2ee-recovery-key-for-device`\n- `allow-seal-workspace-e2ee-key-for-recipients`\n- `allow-import-e2ee-device-enrollment`\n- `allow-subscribe`\n- `allow-unsubscribe`\n- `allow-bind-cloudsync-account`\n- `allow-configure-cloudsync-token`\n- `allow-configure-e2ee-replica`\n- `allow-stop-cloudsync`\n- `allow-suspend-cloudsync`\n- `allow-suspend-cloudsync-for-sign-out`\n- `allow-suspend-cloudsync-after-auth-loss`\n- `allow-get-cloudsync-status`\n- `allow-wait-until-ready`"
+ "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-apply-session-ingest`\n- `allow-execute`\n- `allow-execute-proxy`\n- `allow-execute-transaction`\n- `allow-list-meetings`\n- `allow-get-meeting`\n- `allow-get-meeting-transcript`\n- `allow-get-recurring-meeting-history`\n- `allow-get-legacy-cleanup-status`\n- `allow-get-legacy-import-report`\n- `allow-cleanup-legacy-files`\n- `allow-run-legacy-import`\n- `allow-get-e2ee-identity-status`\n- `allow-inspect-e2ee-recovery-key`\n- `allow-create-e2ee-identity`\n- `allow-import-e2ee-identity`\n- `allow-get-or-create-e2ee-device-identity`\n- `allow-seal-e2ee-recovery-key-for-device`\n- `allow-seal-workspace-e2ee-key-for-recipients`\n- `allow-import-e2ee-device-enrollment`\n- `allow-subscribe`\n- `allow-unsubscribe`\n- `allow-bind-cloudsync-account`\n- `allow-configure-cloudsync-token`\n- `allow-configure-e2ee-replica`\n- `allow-stop-cloudsync`\n- `allow-suspend-cloudsync`\n- `allow-suspend-cloudsync-for-sign-out`\n- `allow-suspend-cloudsync-after-auth-loss`\n- `allow-get-cloudsync-status`\n- `allow-get-startup-status`\n- `allow-wait-until-ready`"
}
]
}
diff --git a/plugins/db/src/commands.rs b/plugins/db/src/commands.rs
index 6a0fe54ffb..debaab5150 100644
--- a/plugins/db/src/commands.rs
+++ b/plugins/db/src/commands.rs
@@ -790,6 +790,12 @@ pub(crate) async fn end_cloudsync_activity(
.map_err(|error| error.to_string())
}
+#[tauri::command]
+#[specta::specta]
+pub(crate) fn get_startup_status(state: tauri::State<'_, ManagedState>) -> crate::StartupStatus {
+ state.startup_status()
+}
+
#[tauri::command]
#[specta::specta]
pub(crate) async fn wait_until_ready(state: tauri::State<'_, ManagedState>) -> Result<(), String> {
diff --git a/plugins/db/src/import/mod.rs b/plugins/db/src/import/mod.rs
index 091a2f3521..d6789d36ef 100644
--- a/plugins/db/src/import/mod.rs
+++ b/plugins/db/src/import/mod.rs
@@ -39,7 +39,7 @@ pub async fn import_legacy_data(
Ok(())
}
-async fn legacy_import_attempt_required(pool: &SqlitePool) -> Result {
+pub(crate) async fn legacy_import_attempt_required(pool: &SqlitePool) -> Result {
let attempted: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1
diff --git a/plugins/db/src/lib.rs b/plugins/db/src/lib.rs
index 072b2f1f05..e6303e361b 100644
--- a/plugins/db/src/lib.rs
+++ b/plugins/db/src/lib.rs
@@ -21,6 +21,35 @@ pub struct TransactionStatement {
pub expected_rows_affected: Option,
}
+#[derive(Debug, Clone, Copy, serde::Serialize, specta::Type, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum StartupPhase {
+ PreparingDatabase,
+ MigratingDatabase,
+ ImportingLegacyData,
+ ConfiguringCloudsync,
+ Ready,
+ Failed,
+}
+
+#[derive(Debug, Clone, serde::Serialize, specta::Type, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct StartupStatus {
+ pub phase: StartupPhase,
+ pub migration_current: Option,
+ pub migration_total: Option,
+}
+
+impl StartupStatus {
+ fn for_phase(phase: StartupPhase) -> Self {
+ Self {
+ phase,
+ migration_current: None,
+ migration_total: None,
+ }
+ }
+}
+
#[derive(Debug, Clone, serde::Serialize, specta::Type, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
pub struct StorageMigrationState {
@@ -331,6 +360,7 @@ fn make_specta_builder() -> tauri_specta::Builder {
commands::sync_cloudsync_now,
commands::begin_cloudsync_activity,
commands::end_cloudsync_activity,
+ commands::get_startup_status,
commands::wait_until_ready,
])
.error_handling(tauri_specta::ErrorHandlingMode::Result)
@@ -346,10 +376,21 @@ async fn bootstrap_app_database(
.ensure_app_schema()
.await
.map_err(|error| error.to_string())?;
+ if import::legacy_import_attempt_required(db.pool())
+ .await
+ .map_err(|error| error.to_string())?
+ {
+ runtime.set_startup_status_if_running(StartupStatus::for_phase(
+ StartupPhase::ImportingLegacyData,
+ ));
+ }
import::import_legacy_data(&app, db.pool())
.await
.map_err(|error| error.to_string())?;
if let Some(config) = startup_config {
+ runtime.set_startup_status_if_running(StartupStatus::for_phase(
+ StartupPhase::ConfiguringCloudsync,
+ ));
let migration_ready = import::legacy_migration_ready(db.pool())
.await
.map_err(|error| error.to_string())?;
diff --git a/plugins/db/src/runtime.rs b/plugins/db/src/runtime.rs
index bc83dad6f0..113a06b288 100644
--- a/plugins/db/src/runtime.rs
+++ b/plugins/db/src/runtime.rs
@@ -135,6 +135,7 @@ pub struct PluginDbRuntime {
db: std::sync::Arc,
schema_ready: tokio::sync::OnceCell<()>,
startup_tx: tokio::sync::watch::Sender