diff --git a/appview/db/migration.go b/appview/db/migration.go
new file mode 100644
index 00000000..0b69d0c8
--- /dev/null
+++ b/appview/db/migration.go
@@ -0,0 +1,18 @@
+package db
+
+import (
+ "context"
+
+ "github.com/bluesky-social/indigo/atproto/syntax"
+ "tangled.org/core/appview/models"
+)
+
+// "migration" for records stored in user's PDS, not AppView DB
+
+// NOTE: each migration row should represent atomic operation.
+
+func ListPDSRecordMigrations(ctx context.Context, e Execer, user syntax.DID) ([]*models.PDSRecordMigration, error) {
+ // select did, name, record from pds_migrations group by (did, name)
+ // merge by (did, name)
+ panic("unimplemented")
+}
diff --git a/appview/migration/migration.go b/appview/migration/migration.go
new file mode 100644
index 00000000..768e8a98
--- /dev/null
+++ b/appview/migration/migration.go
@@ -0,0 +1,154 @@
+package migration
+
+import (
+ "context"
+ "fmt"
+ "html"
+ "net/http"
+
+ comatproto "github.com/bluesky-social/indigo/api/atproto"
+ "github.com/bluesky-social/indigo/atproto/atclient"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+ "tangled.org/core/api/tangled"
+ "tangled.org/core/appview/db"
+ "tangled.org/core/appview/oauth"
+ "tangled.org/core/appview/pages"
+)
+
+type Migration struct {
+ db *db.DB
+ oauth *oauth.OAuth
+ pages *pages.Pages
+}
+
+// migration page handler
+func (s *Migration) HandleMigration(w http.ResponseWriter, r *http.Request) {
+ returnURL := r.URL.Query().Get("return_url")
+ if returnURL == "" {
+ returnURL = "/"
+ }
+
+ panic("unimplemented")
+}
+
+// HandleMigrationProgress is migration progress handler
+// This is the place where migration process gets started.
+func (s *Migration) HandleMigrationProgress(w http.ResponseWriter, r *http.Request) {
+ user := s.oauth.GetMultiAccountUser(r)
+ client, err := s.oauth.AuthorizedClient(r)
+ if err != nil {
+ panic("unimplemented")
+ }
+
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ panic("unimplemented")
+ }
+
+ // start SSE streaming
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("X-Accel-Buffering", "no")
+
+ send := func(event, data string) {
+ fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data)
+ flusher.Flush()
+ }
+ sendStepProgress := func(stepName string, curr, max int) {
+ send("StepProgress", fmt.Sprintf(`[%d/%d]: %s`, curr, max, html.EscapeString(stepName)))
+ }
+ sendRecordProgress := func(curr, max int) {
+ percent := 100 * curr / max
+ send("RecordProgress", fmt.Sprintf(``, curr, max, percent))
+ }
+ sendError := func(msg string) {
+ send("Error", fmt.Sprintf(`%s`, html.EscapeString(msg)))
+ }
+
+ // event:StepProgress ->
[1/3]: adding repository name to migration
+ // event:RecordProgress ->