From 797ac392aade993100fefdb894a438cba1eb91e8 Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 29 Oct 2026 01:55:24 +0300 Subject: [PATCH] web/migration: fill in a profile left empty by onboarding Signed-off-by: dawn --- web/src/lib/migration/import.test.ts | 39 +++++++++++++-- web/src/lib/migration/import.ts | 72 +++++++++++++++++----------- 2 files changed, 77 insertions(+), 34 deletions(-) diff --git a/web/src/lib/migration/import.test.ts b/web/src/lib/migration/import.test.ts index 4f0955556..55f5b9e13 100644 --- a/web/src/lib/migration/import.test.ts +++ b/web/src/lib/migration/import.test.ts @@ -103,8 +103,15 @@ const fakePds = ({ }); } batchSizes.push(body.writes.length); - for (const write of body.writes) - records.set(`${write.collection}/${write.rkey}`, write.value); + for (const write of body.writes) { + const key = `${write.collection}/${write.rkey}`; + if (write.$type.endsWith("#create") && records.has(key)) + return json( + { error: "InvalidRequest", message: "record already exists" }, + 400 + ); + records.set(key, write.value); + } return json({}); } } @@ -241,6 +248,21 @@ describe("importFromLiar", () => { }); }); + it("fills in a profile that holds nothing but its type", async () => { + const pds = fakePds(); + pds.records.set("org.tangled.actor.profile/self", { $type: "org.tangled.actor.profile" }); + const profile = { $type: "org.tangled.actor.profile", description: "tangled guy" }; + const fetch = liarFetchFor([ + { collection: "org.tangled.actor.profile", rkey: "self", value: profile } + ]); + + await expect(importFromLiar(pds.agent, { liarPdsUrl, bobbinUrl, fetch })).resolves.toEqual({ + written: 1, + skipped: 0 + }); + expect(pds.records.get("org.tangled.actor.profile/self")).toEqual(profile); + }); + it("refuses an incomplete repo instead of unmarking over missing data", async () => { const pds = fakePds(); const fetch = liarFetchFor([star(0)], { dropRecordBlocks: true }); @@ -334,9 +356,16 @@ describe("batchWrites", () => { describe("missingImportScopes", () => { it("always asks for the unmark rpc too", () => { expect( - missingImportScopes("atproto", ["org.tangled.feed.star" as Nsid], bobbinUrl).map((p) => - p.resource === "repo" ? p.collection : p.lxm - ) + missingImportScopes( + "atproto", + [ + { + $type: "com.atproto.repo.applyWrites#create", + collection: "org.tangled.feed.star" as Nsid + } + ], + bobbinUrl + ).map((p) => (p.resource === "repo" ? p.collection : p.lxm)) ).toEqual(["org.tangled.feed.star", "org.tangled.foster.unmarkLiarAccount"]); }); }); diff --git a/web/src/lib/migration/import.ts b/web/src/lib/migration/import.ts index a78e4a04b..29b655e5d 100644 --- a/web/src/lib/migration/import.ts +++ b/web/src/lib/migration/import.ts @@ -43,8 +43,8 @@ export class MigrationScopeError extends Error { } } -interface CreateWrite { - $type: "com.atproto.repo.applyWrites#create"; +interface Write { + $type: "com.atproto.repo.applyWrites#create" | "com.atproto.repo.applyWrites#update"; collection: Nsid; rkey: Rkey; value: LiarRecord["value"]; @@ -52,22 +52,28 @@ interface CreateWrite { export const missingImportScopes = ( scope: string, - collections: Iterable, + writes: Iterable>, bobbinUrl: string -): readonly Permission[] => - missingPermissions(scope, [ - ...[...new Set(collections)].map((collection): Permission => ({ +): readonly Permission[] => { + const actions = new Map>(); + for (const write of writes) { + const action = write.$type === "com.atproto.repo.applyWrites#update" ? "update" : "create"; + actions.set(write.collection, (actions.get(write.collection) ?? new Set()).add(action)); + } + return missingPermissions(scope, [ + ...[...actions].map(([collection, held]): Permission => ({ resource: "repo", collection, - actions: ["create"] + actions: [...held] })), { resource: "rpc", lxm: UNMARK, aud: serviceDidForHost(bobbinUrl) } ]); +}; -export const batchWrites = (writes: readonly CreateWrite[]): CreateWrite[][] => { +export const batchWrites = (writes: readonly Write[]): Write[][] => { const encoder = new TextEncoder(); - const batches: CreateWrite[][] = []; - let current: CreateWrite[] = []; + const batches: Write[][] = []; + let current: Write[] = []; let bytes = 0; for (const write of writes) { const size = encoder.encode(JSON.stringify(write)).length; @@ -86,13 +92,18 @@ export const batchWrites = (writes: readonly CreateWrite[]): CreateWrite[][] => return batches; }; +// a profile skipped at /welcome holds nothing but its $type +const isBare = (value: unknown): boolean => + typeof value === "object" && value !== null && Object.keys(value).join() === "$type"; + +// rkey to whether the record there is bare const existingRkeys = async ( agent: OAuthUserAgent, collection: Nsid, signal?: AbortSignal -): Promise> => { +): Promise> => { const client = createClient(agent); - const rkeys = new Set(); + const rkeys = new Map(); let cursor: string | undefined; do { const page = await ok( @@ -102,7 +113,7 @@ const existingRkeys = async ( }) ); for (const record of page.records) - rkeys.add(record.uri.slice(record.uri.lastIndexOf("/") + 1)); + rkeys.set(record.uri.slice(record.uri.lastIndexOf("/") + 1), isBare(record.value)); cursor = page.records.length === 0 ? undefined : page.cursor; } while (cursor); return rkeys; @@ -113,9 +124,9 @@ const writesToMake = async ( agent: OAuthUserAgent, records: readonly LiarRecord[], signal?: AbortSignal -): Promise => { +): Promise => { const collections = [...new Set(records.map((record) => record.collection))]; - const existing = new Map>( + const existing = new Map>( await Promise.all( collections.map( async (collection) => @@ -123,14 +134,21 @@ const writesToMake = async ( ) ) ); - return records - .filter((record) => !existing.get(record.collection)?.has(record.rkey)) - .map((record) => ({ - $type: "com.atproto.repo.applyWrites#create", - collection: record.collection, - rkey: record.rkey, - value: record.value - })); + return records.flatMap((record): Write[] => { + const bare = existing.get(record.collection)?.get(record.rkey); + if (bare === false) return []; + return [ + { + $type: + bare === undefined + ? "com.atproto.repo.applyWrites#create" + : "com.atproto.repo.applyWrites#update", + collection: record.collection, + rkey: record.rkey, + value: record.value + } + ]; + }); }; // the pds sniffs an upload's type and refuses a ref that names another one @@ -218,11 +236,7 @@ export const importFromLiar = async ( const writes = await writesToMake(agent, records, signal); - const missing = missingImportScopes( - agent.session.token.scope, - writes.map((write) => write.collection), - bobbinUrl - ); + const missing = missingImportScopes(agent.session.token.scope, writes, bobbinUrl); if (missing.length > 0) throw new MigrationScopeError(missing); const client = createClient(agent); @@ -256,7 +270,7 @@ export const importFromLiar = async ( signal?.throwIfAborted(); const fixed = batch.map((write) => ({ ...write, - value: withStoredMimeTypes(write.value, stored) as CreateWrite["value"] + value: withStoredMimeTypes(write.value, stored) as Write["value"] })); await throttled( () => -- knotmirror 0.0.1