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
28 changes: 28 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ To be released.

### @fedify/botkit

- Added `Session.move()` to move a bot's followers to a linked actor,
persist its `movedTo` redirect, and show its new home on the profile.
Moved bots reject new follows and cannot publish, reply, or share new
messages. Publishing, sharing, and follow acceptance are serialized
with moves within the same instance. `Session.republishMove()`
resends failed migration notifications. Custom repositories must now
implement the required `getSuccessor()` and atomic, write-once
`setSuccessor()` methods. [[#50], [#57]]
- Added an `aliases` option to `CreateBotOptions` and `BotProfile` so
existing accounts can move their followers to a BotKit bot. Actor URIs
listed in the option are published as `alsoKnownAs`, and are available
Expand Down Expand Up @@ -45,10 +53,30 @@ To be released.
[#43]: https://github.com/fedify-dev/botkit/pull/43
[#48]: https://github.com/fedify-dev/botkit/issues/48
[#49]: https://github.com/fedify-dev/botkit/issues/49
[#50]: https://github.com/fedify-dev/botkit/issues/50
[#52]: https://github.com/fedify-dev/botkit/issues/52
[#53]: https://github.com/fedify-dev/botkit/pull/53
[#55]: https://github.com/fedify-dev/botkit/pull/55
[#56]: https://github.com/fedify-dev/botkit/pull/56
[#57]: https://github.com/fedify-dev/botkit/pull/57

### @fedify/botkit-postgres

- Added persistent account migration state through `getSuccessor()` and
atomic, write-once `setSuccessor()`, keeping moved bots inactive across
restarts. [[#50], [#57]]

### @fedify/botkit-redis

- Added persistent account migration state through `getSuccessor()` and
atomic, write-once `setSuccessor()`, keeping moved bots inactive across
restarts. [[#50], [#57]]

### @fedify/botkit-sqlite

- Added persistent account migration state through `getSuccessor()` and
atomic, write-once `setSuccessor()`, keeping moved bots inactive across
restarts. [[#50], [#57]]


Version 0.5.6
Expand Down
8 changes: 8 additions & 0 deletions changes.d/botkit-postgres/account-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
links:
'#50': https://github.com/fedify-dev/botkit/issues/50
'#57': https://github.com/fedify-dev/botkit/pull/57
---
- Added persistent account migration state through `getSuccessor()` and
atomic, write-once `setSuccessor()`, keeping moved bots inactive across
restarts. [[#50], [#57]]
8 changes: 8 additions & 0 deletions changes.d/botkit-redis/account-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
links:
'#50': https://github.com/fedify-dev/botkit/issues/50
'#57': https://github.com/fedify-dev/botkit/pull/57
---
- Added persistent account migration state through `getSuccessor()` and
atomic, write-once `setSuccessor()`, keeping moved bots inactive across
restarts. [[#50], [#57]]
8 changes: 8 additions & 0 deletions changes.d/botkit-sqlite/account-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
links:
'#50': https://github.com/fedify-dev/botkit/issues/50
'#57': https://github.com/fedify-dev/botkit/pull/57
---
- Added persistent account migration state through `getSuccessor()` and
atomic, write-once `setSuccessor()`, keeping moved bots inactive across
restarts. [[#50], [#57]]
13 changes: 13 additions & 0 deletions changes.d/botkit/account-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
links:
'#50': https://github.com/fedify-dev/botkit/issues/50
'#57': https://github.com/fedify-dev/botkit/pull/57
---
- Added `Session.move()` to move a bot's followers to a linked actor,
persist its `movedTo` redirect, and show its new home on the profile.
Moved bots reject new follows and cannot publish, reply, or share new
messages. Publishing, sharing, and follow acceptance are serialized
with moves within the same instance. `Session.republishMove()`
resends failed migration notifications. Custom repositories must now
implement the required `getSuccessor()` and atomic, write-once
`setSuccessor()` methods. [[#50], [#57]]
29 changes: 29 additions & 0 deletions docs/concepts/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,35 @@ the quote's authorization state.
[FEP-044f]: https://w3id.org/fep/044f


Account migration storage
-------------------------

Since BotKit 0.6.0, custom repositories must implement two additional methods:

- `~Repository.getSuccessor(identifier, signal?)` returns the successor actor
`URL`, or `undefined` if the bot has not moved. Return a fresh `URL` so
callers cannot mutate the stored value.
- `~Repository.setSuccessor(identifier, successorId, signal?)` atomically
records the first successor and returns `true`. If one already exists,
return `false`, including when the URI is identical. Never overwrite it.

These are required even if your application never calls `Session.move()`:
actor dispatch and publishing read the moved state. BotKit rejects repositories
missing either method during construction, including repositories passed
through a cache or the single-bot compatibility wrapper. Honor an aborted
signal before a read or write, and never report cancellation after a write has
committed. See [moving a bot](./session.md#moving-the-bot-to-another-actor) for
the lifecycle and recovery behavior.

All built-in repositories implement this contract. SQL repositories create an
additional table automatically, and KV/Redis repositories store a bot-scoped
successor key. `MemoryCachedRepository` reads successors directly from its
backing repository, so another process's move takes effect immediately. A
`KvRepository` without CAS can serialize successor writes only within the
same repository instance; use a CAS-capable store or a dedicated SQL/Redis
repository when several instances may move the same bot concurrently.


`KvRepository`
--------------

Expand Down
98 changes: 98 additions & 0 deletions docs/concepts/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,104 @@ followers. Call it after your application updates the bot profile and you want
the change to propagate without waiting for the next post.


Moving the bot to another actor
-------------------------------

*This API is available since BotKit 0.6.0.*

Call `~Session.move()` to move the bot's followers to another BotKit bot or
an account on a different server. First configure the destination to list
this bot's *actor URI* in its `alsoKnownAs` aliases. For a BotKit destination,
use [`aliases`](./bot.md#createbotoptions-aliases) and deploy it before starting
the move. The URI is `session.actorId`, rather than the bot's profile page URL.

~~~~ typescript twoslash
import type { Session } from "@fedify/botkit";
declare const session: Session<void>;
// ---cut-before---
await session.move("@mybot@new.example");
~~~~

The target can also be an actor `URL`, a URI string, or an `Actor` object.
BotKit fetches its current actor document and verifies that its aliases contain
this bot's actor URI. It rejects the bot itself, targets without an inbox,
and targets that have already moved.

BotKit stores the successor, publishes an actor `Update` carrying `movedTo`,
then sends a push-mode [FEP-7628] `Move` to the old followers. This includes
followers on the same instance. Only followers move: posts, followed accounts,
and other data stay on the old server. Keep that server running while remote
servers process the migration. A successful call means the notifications were
submitted, rather than that every follower has already moved.

The moved state survives a restart when the repository is persistent. The
old actor's `successorId` points to the destination, its profile shows a link,
and it rejects incoming follow requests without invoking `onFollow`, regardless
of `followerPolicy`. `Session.publish()`, `Message.reply()`, and
`Message.share()` throw `TypeError` on a moved bot. Existing posts remain
accessible, and their editing and deletion remain available.

Other event handlers still run. Deploy a moved-state check in handlers that
publish or reply *before* starting the move:

~~~~ typescript twoslash
import { type Bot, text } from "@fedify/botkit";
declare const bot: Bot<void>;
// ---cut-before---
bot.onMention = async (session, message) => {
if ((await session.getActor()).successorId != null) return;
await message.reply(text`Thanks for mentioning me!`);
};
~~~~

Without this check, a reply attempt throws from the handler and fails the
incoming activity's processing; a configured queue may retry it. Pending
follow requests retained before the move also cannot be accepted afterwards,
but can still be rejected. There is no API to undo a move or change its
stored destination.

Within one instance, once publishing, sharing, or follow acceptance passes its
final state check, `move()` waits for its storage and activity submissions to
finish. Text rendering happens before that check, so a move during rendering
rejects publication before it stores anything. Applications serving the same
bot from several processes must coordinate these operations and migration
between those processes.

[FEP-7628]: https://w3id.org/fep/7628

### Recovering notification failures

A storage or delivery failure can occur after the successor has been stored.
BotKit keeps the moved state because some servers may already have processed
the migration. It attempts the `Move` even if submitting the `Update` fails.
Post-commit notification failures throw `AggregateError`; a storage error can
also leave the write's outcome uncertain. On any error, check
`(await session.getActor()).successorId` before choosing how to retry.

Calling `move()` on a moved bot throws `TypeError`. Use
`~Session.republishMove()` to revalidate the stored successor's alias and
resend both notifications to the remaining followers:

~~~~ typescript twoslash
import type { Session } from "@fedify/botkit";
declare const session: Session<void>;
// ---cut-before---
if ((await session.getActor()).successorId != null) {
await session.republishMove();
}
~~~~

It does not change the successor. The destination must still list
the old actor as an alias, even if it has since moved again. With a configured
queue, Fedify retries delivery of notifications it has accepted; without a
queue, delivery happens during the call and can partially fail.

Both methods accept `{ signal: AbortSignal }`. `move()` honours cancellation
until successor storage commits, then continues both notifications.
`republishMove()` honours cancellation during preparation, including the
follower snapshot, then continues both notifications once submission starts.


Publishing a message
--------------------

Expand Down
39 changes: 39 additions & 0 deletions packages/botkit-postgres/src/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,44 @@ if (postgresUrl == null) {
test("PostgresRepository integration tests", { skip: true }, () => {});
} else {
describe("PostgresRepository", () => {
test("successor is atomic, scoped and persistent", async (t) => {
const harness = createHarness();
const repo = harness.repository;
const target = new URL("https://new.example/actor");
try {
assert.strictEqual(await repo.getSuccessor("old", t.signal), undefined);
const results = await Promise.all([
repo.setSuccessor("old", target, t.signal),
repo.setSuccessor(
"old",
new URL("https://other.example/actor"),
t.signal,
),
]);
assert.strictEqual(results.filter(Boolean).length, 1);
const successor = await repo.getSuccessor("old", t.signal);
assert.ok(successor);
assert.ok(!await repo.setSuccessor("old", successor));
assert.strictEqual(await repo.getSuccessor("sibling"), undefined);
await assert.rejects(
repo.setSuccessor("sibling", target, AbortSignal.abort()),
{ name: "AbortError" },
);
const second = new PostgresRepository({
url: postgresUrl,
schema: harness.schema,
});
try {
assert.deepStrictEqual(await second.getSuccessor("old"), successor);
assert.ok(!await second.setSuccessor("old", target));
} finally {
await second.close();
}
} finally {
await harness.cleanup();
}
});

test("initializes schema explicitly", async () => {
const sql = createSql(postgresUrl);
const schema = createSchemaName();
Expand All @@ -129,6 +167,7 @@ if (postgresUrl == null) {
assert.deepStrictEqual(
tables.map((row) => row.table_name),
[
"bot_successors",
"botkit_metadata",
"follow_requests",
"followees",
Expand Down
48 changes: 48 additions & 0 deletions packages/botkit-postgres/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ async function initializePostgresRepositorySchemaInTransaction(
[],
prepare,
);
await execute(
sql,
`CREATE TABLE IF NOT EXISTS "${validatedSchema}"."bot_successors" (
bot_id TEXT PRIMARY KEY,
successor_id TEXT NOT NULL
)`,
[],
prepare,
);
await execute(
sql,
`CREATE TABLE IF NOT EXISTS "${validatedSchema}"."key_pairs" (
Expand Down Expand Up @@ -595,6 +604,45 @@ export class PostgresRepository implements Repository, AsyncDisposable {
}
}

/** {@inheritDoc Repository.getSuccessor} */
async getSuccessor(
identifier: string,
signal?: AbortSignal,
): Promise<URL | undefined> {
signal?.throwIfAborted();
await this.ensureReady();
signal?.throwIfAborted();
const rows = await this.query<{ readonly successor_id: string }>(
this.sql,
`SELECT successor_id FROM ${
this.table("bot_successors")
} WHERE bot_id = $1`,
[identifier],
);
return rows[0] === undefined ? undefined : new URL(rows[0].successor_id);
}

/** {@inheritDoc Repository.setSuccessor} */
async setSuccessor(
identifier: string,
successorId: URL,
signal?: AbortSignal,
): Promise<boolean> {
signal?.throwIfAborted();
const href = successorId.href;
await this.ensureReady();
signal?.throwIfAborted();
const rows = await this.query<{ readonly bot_id: string }>(
this.sql,
`INSERT INTO ${
this.table("bot_successors")
} (bot_id, successor_id) VALUES ($1, $2)
ON CONFLICT (bot_id) DO NOTHING RETURNING bot_id`,
[identifier, href],
);
return rows.length > 0;
}

async setKeyPairs(
identifier: string,
keyPairs: CryptoKeyPair[],
Expand Down
38 changes: 38 additions & 0 deletions packages/botkit-redis/src/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,44 @@ if (redisUrl == null) {
);
});

test("successor is atomic, scoped and persistent", async (t) => {
const harness = createHarness();
const repo = harness.repository;
const target = new URL("https://new.example/actor");
try {
assert.strictEqual(await repo.getSuccessor("old", t.signal), undefined);
const results = await Promise.all([
repo.setSuccessor("old", target, t.signal),
repo.setSuccessor(
"old",
new URL("https://other.example/actor"),
t.signal,
),
]);
assert.strictEqual(results.filter(Boolean).length, 1);
const successor = await repo.getSuccessor("old", t.signal);
assert.ok(successor);
assert.ok(!await repo.setSuccessor("old", successor));
assert.strictEqual(await repo.getSuccessor("sibling"), undefined);
await assert.rejects(
repo.setSuccessor("sibling", target, AbortSignal.abort()),
{ name: "AbortError" },
);
const second = new RedisRepository({
url: redisUrl,
prefix: harness.prefix,
});
try {
assert.deepStrictEqual(await second.getSuccessor("old"), successor);
assert.ok(!await second.setSuccessor("old", target));
} finally {
await second.close();
}
} finally {
await harness.cleanup();
}
});

test("key pairs", async () => {
const { repository, cleanup } = createHarness();
try {
Expand Down
Loading
Loading