diff --git a/.oxlintrc.json b/.oxlintrc.json
index 10405cf..6206ce6 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -24,7 +24,6 @@
{ "ignoreConsecutiveComments": true }
],
"eslint/curly": ["error", "multi-line"],
- "node/no-top-level-await": "off",
"oxc/no-optional-chaining": "off",
"oxc/no-async-await": "off",
"eslint/eqeqeq": ["off", "smart"],
@@ -42,6 +41,7 @@
"eslint/no-eq-null": "off",
"eslint/no-magic-numbers": "off",
"eslint/no-ternary": "off",
+ "eslint/no-negated-condition": "off",
"eslint/no-nested-ternary": "off",
"eslint/no-undefined": "off",
"eslint/no-void": "off",
diff --git a/mise.toml b/mise.toml
index a8e44c3..283ddaf 100644
--- a/mise.toml
+++ b/mise.toml
@@ -4,6 +4,7 @@ min_version = "2026.6.10"
"aqua:dahlia/hongdown" = "0.4.3"
"github:nushell/nushell" = "0.114.1"
node = "26"
+"npm:@fedify/cli" = "2.4.0-dev.1758"
"npm:oxlint" = "1.75.0"
"npm:oxlint-tsgolint" = "7.0.2001"
"npm:pglite-cli" = "0.0.1"
diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index cc5e926..39c8d73 100644
--- a/packages/graphql/package.json
+++ b/packages/graphql/package.json
@@ -50,6 +50,10 @@
"types": "./dist/account.d.mts",
"default": "./dist/account.mjs"
},
+ "./actor": {
+ "types": "./dist/actor.d.mts",
+ "default": "./dist/actor.mjs"
+ },
"./builder": {
"types": "./dist/builder.d.mts",
"default": "./dist/builder.mjs"
@@ -71,6 +75,7 @@
"entry": [
"src/index.ts",
"src/account.ts",
+ "src/actor.ts",
"src/builder.ts",
"src/instance.ts",
"src/schema.ts"
diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts
new file mode 100644
index 0000000..140a892
--- /dev/null
+++ b/packages/graphql/src/actor.test.ts
@@ -0,0 +1,333 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// oxlint-disable max-lines
+
+import assert from "node:assert/strict";
+
+import { type Database, schema } from "@drfed/models";
+import { describe, it } from "@logtape/testing-node/autoload";
+
+import { withTestHarness } from "./harness.test.ts";
+
+const accepted = new Date("2026-08-04T00:00:00.000Z");
+const created = new Date("2026-08-04T00:00:00.000Z");
+const expires = new Date("2030-08-04T00:00:00.000Z");
+const ok = 200;
+
+const accountId = "00000000-0000-4000-8000-000000000001";
+const localInstanceId = "00000000-0000-4000-8000-000000000101";
+const remoteInstanceId = "00000000-0000-4000-8000-000000000102";
+const localActorId = "00000000-0000-4000-8000-000000000201";
+const remoteActorId = "00000000-0000-4000-8000-000000000202";
+const sessionId = "00000000-0000-4000-8000-000000000301";
+const accessToken = "test-access-token";
+
+const genActorsMutation = `
+ mutation GenActors($instance: ID!, $size: Int!) {
+ genActors(instance: $instance, size: $size) {
+ resultType: __typename
+ ... on CreateActorsSuccess {
+ actors {
+ uuid
+ iri
+ username
+ local {
+ uuid
+ }
+ }
+ }
+ ... on CreateActorsError {
+ type
+ message
+ }
+ }
+ }
+`;
+
+const actorQuery = `
+ query Actor($id: ID!) {
+ node(id: $id) {
+ ... on Actor {
+ id
+ uuid
+ iri
+ handle
+ type
+ username
+ instance {
+ uuid
+ host
+ }
+ local {
+ avatar
+ header
+ }
+ inboxUrl
+ outboxUrl
+ avatarUrl
+ followersUrl
+ followeesUrl
+ headerUrl
+ profileUrl
+ featuredUrl
+ created
+ }
+ }
+ }
+`;
+
+describe("Mutation.genActors", () => {
+ it("creates local actors", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ const auth = await seedAuthenticatedLocalInstance(db);
+
+ const response = await post(
+ {
+ query: genActorsMutation,
+ variables: {
+ instance: globalId("Instance", localInstanceId),
+ size: 2,
+ },
+ },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ assert.equal(body.errors, undefined);
+ assert.equal(body.data.genActors.resultType, "CreateActorsSuccess");
+ assert.equal(body.data.genActors.actors.length, 2);
+ assert.equal(
+ body.data.genActors.actors.every(
+ (actor: {
+ iri: unknown;
+ local: { uuid: unknown } | null;
+ username: unknown;
+ uuid: unknown;
+ }) =>
+ typeof actor.uuid === "string" &&
+ typeof actor.username === "string" &&
+ actor.iri ===
+ `https://test-instance.drfed.org/users/${actor.uuid}` &&
+ typeof actor.local?.uuid === "string",
+ ),
+ true,
+ );
+
+ const actors = await db.select().from(schema.actors);
+ assert.equal(actors.length, 2);
+ assert.equal(
+ actors.every(
+ (actor) =>
+ actor.instanceId === localInstanceId &&
+ actor.localId != null &&
+ actor.type === "Person",
+ ),
+ true,
+ );
+
+ const localActors = await db.select().from(schema.localActors);
+ assert.equal(localActors.length, 2);
+ assert.deepEqual(
+ new Set(localActors.map(({ id }) => id)),
+ new Set(actors.map(({ localId }) => localId)),
+ );
+ });
+ });
+});
+
+describe("Actor", () => {
+ it("returns a local actor", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedLocalActor(db);
+
+ const response = await post({
+ query: actorQuery,
+ variables: { id: globalId("Actor", localActorId) },
+ });
+
+ assert.equal(response.status, ok);
+ assert.deepEqual(await response.json(), {
+ data: {
+ node: {
+ id: globalId("Actor", localActorId),
+ uuid: localActorId,
+ iri: `https://test-instance.drfed.org/users/${localActorId}`,
+ handle: "@alice@test-instance.drfed.org",
+ type: "Person",
+ username: "alice",
+ instance: {
+ uuid: localInstanceId,
+ host: "test-instance.drfed.org",
+ },
+ local: {
+ avatar: "avatar.png",
+ header: "header.png",
+ },
+ inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`,
+ outboxUrl: `https://test-instance.drfed.org/users/${localActorId}/outbox`,
+ avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`,
+ followersUrl: `https://test-instance.drfed.org/users/${localActorId}/followers`,
+ followeesUrl: `https://test-instance.drfed.org/users/${localActorId}/followees`,
+ headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`,
+ profileUrl: "https://test-instance.drfed.org/@alice",
+ featuredUrl: `https://test-instance.drfed.org/users/${localActorId}/featured`,
+ created: created.toISOString(),
+ },
+ },
+ });
+ });
+ });
+
+ it("returns a remote actor", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedRemoteActor(db);
+
+ const response = await post({
+ query: actorQuery,
+ variables: { id: globalId("Actor", remoteActorId) },
+ });
+
+ assert.equal(response.status, ok);
+ assert.deepEqual(await response.json(), {
+ data: {
+ node: {
+ id: globalId("Actor", remoteActorId),
+ uuid: remoteActorId,
+ iri: "https://remote.example.com/users/bob",
+ handle: "@bob@remote.example.com",
+ type: "Service",
+ username: "bob",
+ instance: {
+ uuid: remoteInstanceId,
+ host: "remote.example.com",
+ },
+ local: null,
+ inboxUrl: "https://remote.example.com/users/bob/inbox",
+ outboxUrl: "https://remote.example.com/users/bob/outbox",
+ avatarUrl: "https://remote.example.com/users/bob/avatar.png",
+ followersUrl: "https://remote.example.com/users/bob/followers",
+ followeesUrl: "https://remote.example.com/users/bob/followees",
+ headerUrl: "https://remote.example.com/users/bob/header.png",
+ profileUrl: "https://remote.example.com/@bob",
+ featuredUrl: "https://remote.example.com/users/bob/featured",
+ created: created.toISOString(),
+ },
+ },
+ });
+ });
+ });
+});
+
+function globalId(type: "Actor" | "Instance", id: string): string {
+ return Buffer.from(`${type}:${id}`).toString("base64");
+}
+
+async function seedAuthenticatedLocalInstance(
+ db: Database,
+): Promise {
+ await db.insert(schema.accounts).values({
+ id: accountId,
+ email: "owner@example.com",
+ name: "Owner",
+ created,
+ });
+ await db.insert(schema.sessions).values({
+ id: sessionId,
+ accountId,
+ tokenHash: await hashSecret(accessToken),
+ });
+ await seedLocalInstance(db);
+ await db.insert(schema.instanceMembers).values({
+ accountId,
+ instanceId: localInstanceId,
+ admin: true,
+ accepted,
+ created,
+ });
+ return { headers: { authorization: `Bearer ${accessToken}` } };
+}
+
+async function seedLocalActor(db: Database): Promise {
+ await seedLocalInstance(db);
+ await db.insert(schema.localActors).values({
+ id: localActorId,
+ avatar: "avatar.png",
+ header: "header.png",
+ });
+ await db.insert(schema.actors).values({
+ id: localActorId,
+ localId: localActorId,
+ instanceId: localInstanceId,
+ type: "Person",
+ username: "alice",
+ iri: `https://test-instance.drfed.org/users/${localActorId}`,
+ inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`,
+ outboxUrl: `https://test-instance.drfed.org/users/${localActorId}/outbox`,
+ avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`,
+ followersUrl: `https://test-instance.drfed.org/users/${localActorId}/followers`,
+ followeesUrl: `https://test-instance.drfed.org/users/${localActorId}/followees`,
+ headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`,
+ profileUrl: "https://test-instance.drfed.org/@alice",
+ featuredUrl: `https://test-instance.drfed.org/users/${localActorId}/featured`,
+ created,
+ });
+}
+
+async function seedLocalInstance(db: Database): Promise {
+ await db.insert(schema.localInstances).values({
+ id: localInstanceId,
+ slug: "test-instance",
+ expires,
+ });
+ await db.insert(schema.instances).values({
+ id: localInstanceId,
+ localId: localInstanceId,
+ created,
+ host: "test-instance.drfed.org",
+ });
+}
+
+async function seedRemoteActor(db: Database): Promise {
+ await db.insert(schema.instances).values({
+ id: remoteInstanceId,
+ created,
+ host: "remote.example.com",
+ });
+ await db.insert(schema.actors).values({
+ id: remoteActorId,
+ instanceId: remoteInstanceId,
+ type: "Service",
+ username: "bob",
+ iri: "https://remote.example.com/users/bob",
+ inboxUrl: "https://remote.example.com/users/bob/inbox",
+ outboxUrl: "https://remote.example.com/users/bob/outbox",
+ avatarUrl: "https://remote.example.com/users/bob/avatar.png",
+ followersUrl: "https://remote.example.com/users/bob/followers",
+ followeesUrl: "https://remote.example.com/users/bob/followees",
+ headerUrl: "https://remote.example.com/users/bob/header.png",
+ profileUrl: "https://remote.example.com/@bob",
+ featuredUrl: "https://remote.example.com/users/bob/featured",
+ created,
+ });
+}
+
+async function hashSecret(raw: string): Promise {
+ return new Uint8Array(
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw)),
+ ).toHex();
+}
diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts
new file mode 100644
index 0000000..7a6b116
--- /dev/null
+++ b/packages/graphql/src/actor.ts
@@ -0,0 +1,349 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// oxlint-disable max-lines-per-function eslint/max-lines
+
+import { schema } from "@drfed/models";
+import { actorTypeEnum } from "@drfed/models/schema";
+import type { PgInsertValue } from "drizzle-orm/pg-core";
+import { and, eq, gt, isNotNull } from "drizzle-orm/sql/expressions";
+import { v7 as uuid } from "uuid";
+
+import builder, { type DrFedObjectRef } from "./builder.ts";
+// oxlint-disable-next-line import/no-cycle
+import { Instance } from "./instance.ts";
+import templates from "./uri-templates.ts";
+
+export const ActorType = builder.enumType("ActorType", {
+ values: actorTypeEnum.enumValues,
+});
+
+export const ACTOR_TYPES = actorTypeEnum.enumValues.join(" | ");
+
+const ActorRef = builder.drizzleNode("actors", {
+ name: "Actor",
+ description: "Represents an `Actor` in the DrFed platform.",
+ id: {
+ column: ({ id }) => id,
+ description: "The unique identifier of the `Actor`.",
+ },
+ fields: (t) => ({
+ uuid: t.expose("id", {
+ type: "UUID",
+ description: "The UUID of the `Actor`.",
+ }),
+ iri: t.exposeString("iri", {
+ description: "The Internationalized Resource Identifier of the `Actor`",
+ }),
+ handle: t.field({
+ type: "String",
+ description: "The handle of the `Actor`.",
+ select: {
+ columns: { username: true },
+ with: { instance: { columns: { host: true } } },
+ },
+ resolve: ({ instance, username }) =>
+ templates.handle.expand({ username, host: instance.host }),
+ }),
+ type: t.expose("type", {
+ type: ActorType,
+ description: `The type of the \`Actor\`: ${ACTOR_TYPES}`,
+ }),
+ username: t.exposeString("username", {
+ description: "The username of the `Actor`.",
+ }),
+ instance: t.relation("instance", {
+ description: "The `Instance` that the `Actor` belongs to.",
+ }),
+ local: t.relation("localActor", {
+ nullable: true,
+ description: "The local details of the `Actor`, or null if it is remote.",
+ }),
+ inboxUrl: t.exposeString("inboxUrl", {
+ description: "The inbox URL of the `Actor`.",
+ }),
+ outboxUrl: t.exposeString("outboxUrl", {
+ description: "The outbox URL of the `Actor`.",
+ }),
+ avatarUrl: t.exposeString("avatarUrl", {
+ description: "The avatar URL of the `Actor`.",
+ nullable: true,
+ }),
+ followersUrl: t.exposeString("followersUrl", {
+ description: "The followers URL of the `Actor`.",
+ nullable: true,
+ }),
+ followeesUrl: t.exposeString("followeesUrl", {
+ description: "The followees URL of the `Actor`.",
+ nullable: true,
+ }),
+ headerUrl: t.exposeString("headerUrl", {
+ description: "The header URL of the `Actor`.",
+ nullable: true,
+ }),
+ profileUrl: t.exposeString("profileUrl", {
+ description: "The profile URL of the `Actor`.",
+ nullable: true,
+ }),
+ featuredUrl: t.exposeString("featuredUrl", {
+ description: "The featured URL of the `Actor`.",
+ nullable: true,
+ }),
+ created: t.expose("created", {
+ type: "DateTime",
+ description: "The creation date/time of the `Actor`.",
+ }),
+ }),
+});
+
+export const Actor: DrFedObjectRef = ActorRef;
+
+const LocalActorRef = builder.drizzleNode("localActors", {
+ name: "LocalActor",
+ description: "Represents the local details of an `Actor`.",
+ id: {
+ column: ({ id }) => id,
+ description: "The unique identifier of the local actor details.",
+ },
+ fields: (t) => ({
+ uuid: t.expose("id", {
+ type: "UUID",
+ description: "The UUID of the local actor details.",
+ }),
+ avatar: t.exposeString("avatar", {
+ nullable: true,
+ description: "The profile image of the actor.",
+ }),
+ header: t.exposeString("header", {
+ nullable: true,
+ description: "The profile banner image of the actor.",
+ }),
+ }),
+});
+
+export const LocalActor: DrFedObjectRef = LocalActorRef;
+
+interface CreateActorsSuccess {
+ readonly actors: readonly (typeof ActorRef.$inferType)[];
+}
+
+const CreateActorsSuccessRef = builder.objectRef(
+ "CreateActorsSuccess",
+);
+
+CreateActorsSuccessRef.implement({
+ fields: (t) => ({
+ actors: t.field({
+ type: [ActorRef],
+ resolve: ({ actors }) => actors,
+ }),
+ }),
+});
+
+const INVALID_SIZE = "InvalidSize" as const;
+const INSTANCE_NOT_FOUND = "InstanceNotFound" as const;
+const TOO_MANY_ACTORS = "TooManyActors" as const;
+const CreateActorsErrors = [
+ INVALID_SIZE,
+ INSTANCE_NOT_FOUND,
+ TOO_MANY_ACTORS,
+] as const;
+
+const CreateActorsErrorType = builder.enumType("CreateActorsErrorType", {
+ values: CreateActorsErrors,
+});
+
+interface CreateActorsError {
+ readonly type: typeof CreateActorsErrorType.$inferType;
+ readonly message: string;
+}
+
+const CreateActorsErrorRef =
+ builder.objectRef("CreateActorsError");
+
+CreateActorsErrorRef.implement({
+ description: "Represents an error that occurred while creating an `Actor`.",
+ fields: (t) => ({
+ type: t.expose("type", {
+ type: CreateActorsErrorType,
+ description:
+ "The type of the error. Use this for programmatic error handling.",
+ }),
+ message: t.exposeString("message", {
+ description:
+ "A human-readable message describing the error. " +
+ "Don't use this for programmatic error handling, " +
+ "use the `type` field instead.",
+ }),
+ }),
+});
+
+const CreateActorsResult = builder.unionType("CreateActorsResult", {
+ types: [CreateActorsSuccessRef, CreateActorsErrorRef],
+ resolveType(value) {
+ if ("message" in value) return CreateActorsErrorRef;
+ return CreateActorsSuccessRef;
+ },
+});
+
+interface SelectedInstance {
+ host: string;
+ maxActors: number;
+}
+
+builder.mutationFields((t) => ({
+ genActors: t.field({
+ type: CreateActorsResult,
+ description: "Create actors.",
+ authScopes: { authenticated: true },
+ args: {
+ instance: t.arg.globalID({
+ for: Instance,
+ required: true,
+ description: "The ID of the target instance",
+ }),
+ size: t.arg({
+ type: "Int",
+ required: true,
+ description: "How many actors to generate",
+ }),
+ },
+ async resolve(_query, { instance: { id: instanceId }, size }, ctx) {
+ if (size < 1) {
+ return {
+ type: INVALID_SIZE,
+ message: `${size} is too small. At least 1 or more.`,
+ };
+ }
+ if (ctx.account == null) {
+ // Note that the following error is not expected to be thrown,
+ // because the `authScopes` option above should prevent this resolver
+ throw new Error("You must be authenticated to create actors.");
+ }
+ const { account } = ctx;
+
+ let instance: SelectedInstance | undefined;
+ let tooManyActors = false;
+ try {
+ return await ctx.db.transaction(async (tx) => {
+ // Find the instance that the account is included
+ [instance] = await tx
+ .select({
+ host: schema.instances.host,
+ maxActors: schema.localInstances.maxActors,
+ })
+ .from(schema.instanceMembers)
+ .innerJoin(
+ schema.instances,
+ eq(schema.instanceMembers.instanceId, schema.instances.id),
+ )
+ .innerJoin(
+ schema.localInstances,
+ eq(schema.instances.localId, schema.localInstances.id),
+ )
+ .where(
+ and(
+ eq(schema.instanceMembers.accountId, account.id),
+ gt(schema.localInstances.expires, new Date()),
+ eq(schema.instances.id, instanceId),
+ isNotNull(schema.instanceMembers.accepted),
+ ),
+ )
+ .limit(1);
+ if (instance == null) throw new Error(INSTANCE_NOT_FOUND);
+ const { host, maxActors } = instance;
+ const currActors = await tx.$count(
+ schema.actors,
+ eq(schema.actors.instanceId, instanceId),
+ );
+
+ if (size + currActors > maxActors) {
+ return {
+ type: TOO_MANY_ACTORS,
+ message: `${size} is too big. The maximum number of actors of ${
+ host
+ } is ${maxActors} and the current number of actors is ${
+ currActors
+ }.`,
+ };
+ }
+ // Create actors
+ const localActors = await tx
+ .insert(schema.localActors)
+ .values(Array.from({ length: size }, () => ({ id: uuid() })))
+ .returning();
+ const createdActors = await tx
+ .insert(schema.actors)
+ .values(localActors.map(({ id }) => genActor(id, instanceId, host)))
+ .returning();
+ const actorCounts = await tx.$count(
+ schema.actors,
+ eq(schema.actors.instanceId, instanceId),
+ );
+ if (actorCounts > maxActors) {
+ tooManyActors = true;
+ tx.rollback();
+ }
+ return { actors: createdActors };
+ });
+ } catch (e) {
+ if (instance == null) {
+ if (e instanceof Error && e.message === INSTANCE_NOT_FOUND) {
+ return {
+ type: INSTANCE_NOT_FOUND,
+ message: "Can't find the instance.",
+ };
+ }
+ } else if (tooManyActors) {
+ return {
+ type: TOO_MANY_ACTORS,
+ message: `${
+ instance.host
+ } instance reached the maximum number of actors (${
+ instance.maxActors
+ })`,
+ };
+ }
+ throw e;
+ }
+ },
+ }),
+}));
+
+function genActor(
+ localId: string,
+ instanceId: string,
+ host: string,
+): PgInsertValue {
+ const id = uuid();
+ const username = id;
+ const templateArgs = { host, id, username };
+ return {
+ id,
+ localId,
+ // FIXME: Generate handle using Faker.js or something
+ username,
+ instanceId,
+ type: "Person",
+ iri: templates.iri.expand(templateArgs),
+ inboxUrl: templates.inbox.expand(templateArgs),
+ outboxUrl: templates.outbox.expand(templateArgs),
+ followersUrl: templates.followers.expand(templateArgs),
+ followeesUrl: templates.followees.expand(templateArgs),
+ featuredUrl: templates.featured.expand(templateArgs),
+ profileUrl: templates.profile.expand(templateArgs),
+ };
+}
diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts
index d6925cc..88e20e2 100644
--- a/packages/graphql/src/builder.ts
+++ b/packages/graphql/src/builder.ts
@@ -15,7 +15,7 @@
// along with this program. If not, see .
import { type Database, normalizeEmail, relations } from "@drfed/models";
-import type { Account, Session } from "@drfed/models/schema";
+import { type Account, type Session } from "@drfed/models/schema";
import { Template } from "@fedify/uri-template";
import SchemaBuilder, { type ObjectRef } from "@pothos/core";
import DrizzlePlugin from "@pothos/plugin-drizzle";
diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts
index f778f83..c6069a2 100644
--- a/packages/graphql/src/instance.ts
+++ b/packages/graphql/src/instance.ts
@@ -23,6 +23,8 @@ import { v7 as uuid } from "uuid";
// oxlint-disable-next-line import/no-cycle
import { Account } from "./account.ts";
+// oxlint-disable-next-line import/no-cycle
+import { ACTOR_TYPES, Actor, ActorType } from "./actor.ts";
import builder, { type DrFedObjectRef } from "./builder.ts";
const InstanceRef = builder.drizzleNode("instances", {
@@ -179,6 +181,70 @@ builder.drizzleObjectField(InstanceRef, "members", (t) =>
),
);
+const actorsConnection = drizzleConnectionHelpers(builder, "actors", {
+ query: { orderBy: { created: "desc" } },
+});
+
+// oxlint-disable-next-line max-lines-per-function
+builder.drizzleObjectField(InstanceRef, "actors", (t) =>
+ t.connection(
+ {
+ type: Actor,
+ description: "The `Actor`s that belong to the `Instance`.",
+ select(args, ctx, nestedSelection) {
+ return {
+ with: {
+ actors: actorsConnection.getQuery(args, ctx, nestedSelection),
+ },
+ };
+ },
+ resolve(instance, args, ctx) {
+ return {
+ ...actorsConnection.resolve(instance.actors, args, ctx, instance),
+ totalCount() {
+ return ctx.db.$count(
+ schema.actors,
+ eq(schema.actors.instanceId, instance.id),
+ );
+ },
+ };
+ },
+ },
+ {
+ fields(fb) {
+ return {
+ totalCount: fb.int({
+ description:
+ "The total number of `Actor`s that belong to the `Instance`." +
+ "Note that pending members are not counted.",
+ resolve(connection) {
+ return connection.totalCount();
+ },
+ }),
+ };
+ },
+ },
+ {
+ fields(fb) {
+ return {
+ created: fb.expose("created", {
+ type: "DateTime",
+ description:
+ "The date/time when the `Account` was added to the `Instance`.",
+ }),
+ type: fb.expose("type", {
+ type: ActorType,
+ description: `The type of the \`Actor\`: ${ACTOR_TYPES}`,
+ }),
+ username: fb.exposeString("username", {
+ description: "The username of the `Actor`.",
+ }),
+ };
+ },
+ },
+ ),
+);
+
export const CreateInstanceErrorType = builder.enumType(
"CreateInstanceErrorType",
{
diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts
index 84bfa4f..f5af295 100644
--- a/packages/graphql/src/schema.ts
+++ b/packages/graphql/src/schema.ts
@@ -17,6 +17,7 @@
import "./account.ts";
import "./instance.ts";
import "./auth/entry.ts";
+import "./actor.ts";
import builder from "./builder.ts";
builder.queryType({});
diff --git a/packages/graphql/src/uri-templates.ts b/packages/graphql/src/uri-templates.ts
new file mode 100644
index 0000000..29e5a2a
--- /dev/null
+++ b/packages/graphql/src/uri-templates.ts
@@ -0,0 +1,52 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { Template } from "@fedify/uri-template";
+
+export const avatarTemplate = new Template(
+ "https://{host}/users/{id}/avatar/{avatar}",
+);
+export const featuredTemplate = new Template(
+ "https://{host}/users/{id}/featured",
+);
+export const followeesTemplate = new Template(
+ "https://{host}/users/{id}/followees",
+);
+export const followersTemplate = new Template(
+ "https://{host}/users/{id}/followers",
+);
+export const handleTemplate = new Template("@{username}@{host}");
+export const headerTemplate = new Template(
+ "https://{host}/users/{id}/header/{header}",
+);
+export const inboxTemplate = new Template("https://{host}/users/{id}/inbox");
+export const iriTemplate = new Template("https://{host}/users/{id}");
+export const outboxTemplate = new Template("https://{host}/users/{id}/outbox");
+export const profileTemplate = new Template("https://{host}/@{username}");
+
+const templates = {
+ avatar: avatarTemplate,
+ featured: featuredTemplate,
+ followees: followeesTemplate,
+ followers: followersTemplate,
+ handle: handleTemplate,
+ header: headerTemplate,
+ inbox: inboxTemplate,
+ iri: iriTemplate,
+ outbox: outboxTemplate,
+ profile: profileTemplate,
+};
+export default templates;
diff --git a/packages/models/drizzle/20260821072150_merge_remote_actors/migration.sql b/packages/models/drizzle/20260821072150_merge_remote_actors/migration.sql
new file mode 100644
index 0000000..ac731f6
--- /dev/null
+++ b/packages/models/drizzle/20260821072150_merge_remote_actors/migration.sql
@@ -0,0 +1,54 @@
+CREATE TYPE "actor_type" AS ENUM('Application', 'Group', 'Organization', 'Person', 'Service');--> statement-breakpoint
+CREATE TABLE "actors" (
+ "id" uuid PRIMARY KEY,
+ "localId" uuid UNIQUE,
+ "type" "actor_type" NOT NULL,
+ "username" text NOT NULL,
+ "instanceId" uuid NOT NULL,
+ "iri" text NOT NULL UNIQUE,
+ "inboxUrl" text NOT NULL,
+ "outboxUrl" text NOT NULL,
+ "followersUrl" text,
+ "followeesUrl" text,
+ "featuredUrl" text,
+ "profileUrl" text,
+ "avatarUrl" text,
+ "headerUrl" text,
+ "name" text,
+ "bioHtml" text,
+ "automaticallyApprovesFollowers" boolean DEFAULT false NOT NULL,
+ "fieldHtmls" jsonb DEFAULT '{}' NOT NULL,
+ "emojis" jsonb DEFAULT '{}' NOT NULL,
+ "tags" jsonb DEFAULT '{}' NOT NULL,
+ "sensitive" boolean DEFAULT false NOT NULL,
+ "suspended" timestamp with time zone,
+ "suspendedUntil" timestamp with time zone,
+ "successorId" uuid,
+ "aliases" text[] DEFAULT (ARRAY[]::text[])::text[] NOT NULL,
+ "followeesCount" integer DEFAULT 0 NOT NULL,
+ "followersCount" integer DEFAULT 0 NOT NULL,
+ "postsCount" integer DEFAULT 0 NOT NULL,
+ "updated" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ "published" timestamp with time zone,
+ "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ "deleted" timestamp with time zone,
+ CONSTRAINT "username_key" UNIQUE("username","instanceId"),
+ CONSTRAINT "actors_username_check" CHECK ("username" NOT LIKE '%@%'),
+ CONSTRAINT "actors_suspended_check" CHECK (
+ "suspendedUntil" IS NULL OR (
+ "suspended" IS NOT NULL AND
+ "suspendedUntil" > "suspended"
+ )
+ )
+);
+--> statement-breakpoint
+CREATE TABLE "local_actors" (
+ "id" uuid PRIMARY KEY,
+ "avatar" text,
+ "header" text
+);
+--> statement-breakpoint
+CREATE INDEX "actor_instance_index" ON "actors" ("instanceId");--> statement-breakpoint
+ALTER TABLE "actors" ADD CONSTRAINT "actors_localId_local_actors_id_fkey" FOREIGN KEY ("localId") REFERENCES "local_actors"("id") ON DELETE CASCADE;--> statement-breakpoint
+ALTER TABLE "actors" ADD CONSTRAINT "actors_instanceId_instances_id_fkey" FOREIGN KEY ("instanceId") REFERENCES "instances"("id") ON DELETE CASCADE;--> statement-breakpoint
+ALTER TABLE "actors" ADD CONSTRAINT "actors_successorId_actors_id_fkey" FOREIGN KEY ("successorId") REFERENCES "actors"("id") ON DELETE SET NULL;
\ No newline at end of file
diff --git a/packages/models/drizzle/20260821072150_merge_remote_actors/snapshot.json b/packages/models/drizzle/20260821072150_merge_remote_actors/snapshot.json
new file mode 100644
index 0000000..1caf8f4
--- /dev/null
+++ b/packages/models/drizzle/20260821072150_merge_remote_actors/snapshot.json
@@ -0,0 +1,1312 @@
+{
+ "version": "8",
+ "dialect": "postgres",
+ "id": "ac4fc4e2-2217-4c4c-9c8a-0ec2a8162819",
+ "prevIds": ["d8ac955f-1e74-4493-ade9-a25c40b01c20"],
+ "ddl": [
+ {
+ "values": ["Application", "Group", "Organization", "Person", "Service"],
+ "name": "actor_type",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "accounts",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "actors",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "instance_members",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "instances",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "local_actors",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "local_instances",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "login_tokens",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "sessions",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "email",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "varchar(100)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "10",
+ "generated": null,
+ "identity": null,
+ "name": "max_instances",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "admin",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "localId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "actor_type",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "type",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "username",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "instanceId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "iri",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "inboxUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "outboxUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "followersUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "followeesUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "featuredUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "profileUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "avatarUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "headerUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "bioHtml",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "automaticallyApprovesFollowers",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "fieldHtmls",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "emojis",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "tags",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "sensitive",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "suspended",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "suspendedUntil",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "successorId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 1,
+ "default": "(ARRAY[]::text[])",
+ "generated": null,
+ "identity": null,
+ "name": "aliases",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "followeesCount",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "followersCount",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "postsCount",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "updated",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "published",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "deleted",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accountId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "instanceId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "admin",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accepted",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "localId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "varchar(100)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "host",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "nodeInfoUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "software",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "softwareVersion",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "avatar",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "header",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_instances"
+ },
+ {
+ "type": "varchar(63)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "slug",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_instances"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "expires",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_instances"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "10",
+ "generated": null,
+ "identity": null,
+ "name": "maxActors",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_instances"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accountId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "tokenHash",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "codeHash",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'",
+ "generated": null,
+ "identity": null,
+ "name": "expires",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "consumed",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accountId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "tokenHash",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'",
+ "generated": null,
+ "identity": null,
+ "name": "expires",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "instanceId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "actor_instance_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ {
+ "value": "accountId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": "\"accepted\" IS NOT NULL",
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "instance_members_accountId_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ {
+ "value": "instanceId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": "\"accepted\" IS NOT NULL",
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "instance_members_instanceId_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["localId"],
+ "schemaTo": "public",
+ "tableTo": "local_actors",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "actors_localId_local_actors_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["instanceId"],
+ "schemaTo": "public",
+ "tableTo": "instances",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "actors_instanceId_instances_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["successorId"],
+ "schemaTo": "public",
+ "tableTo": "actors",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "SET NULL",
+ "name": "actors_successorId_actors_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["accountId"],
+ "schemaTo": "public",
+ "tableTo": "accounts",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "NO ACTION",
+ "name": "instance_members_accountId_accounts_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["instanceId"],
+ "schemaTo": "public",
+ "tableTo": "instances",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "NO ACTION",
+ "name": "instance_members_instanceId_instances_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["localId"],
+ "schemaTo": "public",
+ "tableTo": "local_instances",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "instances_localId_local_instances_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["accountId"],
+ "schemaTo": "public",
+ "tableTo": "accounts",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "login_tokens_accountId_accounts_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "login_tokens"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["accountId"],
+ "schemaTo": "public",
+ "tableTo": "accounts",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "sessions_accountId_accounts_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "columns": ["instanceId", "accountId"],
+ "nameExplicit": false,
+ "name": "instance_members_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "accounts_pkey",
+ "schema": "public",
+ "table": "accounts",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "actors_pkey",
+ "schema": "public",
+ "table": "actors",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "instances_pkey",
+ "schema": "public",
+ "table": "instances",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "local_actors_pkey",
+ "schema": "public",
+ "table": "local_actors",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "local_instances_pkey",
+ "schema": "public",
+ "table": "local_instances",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "login_tokens_pkey",
+ "schema": "public",
+ "table": "login_tokens",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "sessions_pkey",
+ "schema": "public",
+ "table": "sessions",
+ "entityType": "pks"
+ },
+ {
+ "nameExplicit": true,
+ "columns": ["username", "instanceId"],
+ "nullsNotDistinct": false,
+ "name": "username_key",
+ "entityType": "uniques",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["email"],
+ "nullsNotDistinct": false,
+ "name": "accounts_email_key",
+ "schema": "public",
+ "table": "accounts",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["localId"],
+ "nullsNotDistinct": false,
+ "name": "actors_localId_key",
+ "schema": "public",
+ "table": "actors",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["iri"],
+ "nullsNotDistinct": false,
+ "name": "actors_iri_key",
+ "schema": "public",
+ "table": "actors",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["host"],
+ "nullsNotDistinct": false,
+ "name": "instances_host_key",
+ "schema": "public",
+ "table": "instances",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["slug"],
+ "nullsNotDistinct": false,
+ "name": "local_instances_slug_key",
+ "schema": "public",
+ "table": "local_instances",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["tokenHash"],
+ "nullsNotDistinct": false,
+ "name": "login_tokens_tokenHash_key",
+ "schema": "public",
+ "table": "login_tokens",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["tokenHash"],
+ "nullsNotDistinct": false,
+ "name": "sessions_tokenHash_key",
+ "schema": "public",
+ "table": "sessions",
+ "entityType": "uniques"
+ },
+ {
+ "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'",
+ "name": "accounts_email_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "value": "\"max_instances\" >= 0",
+ "name": "accounts_max_instances_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "value": "trim(both from \"name\") <> ''",
+ "name": "accounts_name_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "value": "\"username\" NOT LIKE '%@%'",
+ "name": "actors_username_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "value": "\n \"suspendedUntil\" IS NULL OR (\n \"suspended\" IS NOT NULL AND\n \"suspendedUntil\" > \"suspended\"\n )\n ",
+ "name": "actors_suspended_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "value": "\"slug\" ~ '^[a-z0-9-]{4,63}$'",
+ "name": "instances_slug_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "local_instances"
+ },
+ {
+ "value": "\"maxActors\" > 0",
+ "name": "instances_max_actors_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "local_instances"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts
index 299db4c..13bc11a 100644
--- a/packages/models/src/relations.ts
+++ b/packages/models/src/relations.ts
@@ -72,6 +72,7 @@ export const relations = defineRelations(schema, (r) => ({
accepted: { isNotNull: true },
},
}),
+ actors: r.many.actors({ from: r.instances.id, to: r.actors.instanceId }),
localInstances: r.one.localInstances({
from: r.instances.localId,
to: r.localInstances.id,
@@ -97,6 +98,20 @@ export const relations = defineRelations(schema, (r) => ({
optional: false,
}),
},
+ actors: {
+ instance: r.one.instances({
+ from: r.actors.instanceId,
+ to: r.instances.id,
+ optional: false,
+ }),
+ localActor: r.one.localActors({
+ from: r.actors.localId,
+ to: r.localActors.id,
+ }),
+ },
+ localActors: {
+ actor: r.one.actors({ from: r.localActors.id, to: r.actors.localId }),
+ },
}));
export default relations;
diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts
index d68bc1a..e1ddad8 100644
--- a/packages/models/src/schema.ts
+++ b/packages/models/src/schema.ts
@@ -16,14 +16,18 @@
import { sql } from "drizzle-orm";
import {
+ type AnyPgColumn,
boolean,
check,
index,
integer,
+ jsonb,
+ pgEnum,
pgTable,
primaryKey,
text,
timestamp,
+ unique,
uuid,
varchar,
} from "drizzle-orm/pg-core";
@@ -174,3 +178,100 @@ export const sessions = pgTable("sessions", {
export type Session = typeof sessions.$inferSelect;
export type NewSession = typeof sessions.$inferInsert;
+
+export const actorTypeEnum = pgEnum("actor_type", [
+ "Application",
+ "Group",
+ "Organization",
+ "Person",
+ "Service",
+]);
+
+export type ActorType = (typeof actorTypeEnum.enumValues)[number];
+
+export const actors = pgTable(
+ "actors",
+ {
+ id: uuid().primaryKey(),
+ localId: uuid()
+ .unique()
+ .references(() => localActors.id, { onDelete: "cascade" }),
+ type: actorTypeEnum().notNull(),
+ username: text().notNull(),
+ instanceId: uuid()
+ .notNull()
+ .references(() => instances.id, { onDelete: "cascade" }),
+ iri: text().notNull().unique(),
+ inboxUrl: text().notNull(),
+ outboxUrl: text().notNull(),
+ followersUrl: text(),
+ followeesUrl: text(),
+ featuredUrl: text(),
+ profileUrl: text(),
+ avatarUrl: text(),
+ headerUrl: text(),
+ name: text(),
+ bioHtml: text(),
+ automaticallyApprovesFollowers: boolean().notNull().default(false),
+ fieldHtmls: jsonb().$type>().notNull().default({}),
+ emojis: jsonb().$type>().notNull().default({}),
+ tags: jsonb().$type>().notNull().default({}),
+ sensitive: boolean().notNull().default(false),
+ // Moderation sanction state, denormalized from flag_action records
+ // (which remain the audit source of truth):
+ // - Not sanctioned: suspended IS NULL
+ // - Temporary suspension: suspended = start, suspendedUntil = end
+ // - Permanent suspension (ban) for local actors, or permanent federation
+ // block for remote actors: suspended set, suspendedUntil IS NULL
+ // Whether a sanction is *currently* active is always determined by
+ // comparing against the current time (lazy expiry; no cron):
+ // suspended <= now AND (suspendedUntil IS NULL OR suspendedUntil > now).
+ suspended: timestamp({ withTimezone: true }),
+ suspendedUntil: timestamp({ withTimezone: true }),
+ successorId: uuid().references((): AnyPgColumn => actors.id, {
+ onDelete: "set null",
+ }),
+ aliases: text()
+ .array()
+ .notNull()
+ .default(sql`(ARRAY[]::text[])`),
+ followeesCount: integer().notNull().default(0),
+ followersCount: integer().notNull().default(0),
+ postsCount: integer().notNull().default(0),
+ updated: timestamp({ withTimezone: true })
+ .notNull()
+ .default(currentTimestamp)
+ .$onUpdate(() => currentTimestamp),
+ published: timestamp({ withTimezone: true }),
+ created: timestamp({ withTimezone: true })
+ .notNull()
+ .default(currentTimestamp),
+ deleted: timestamp({ withTimezone: true }),
+ },
+ (t) => [
+ unique("username_key").on(t.username, t.instanceId),
+ check("actors_username_check", sql`${t.username} NOT LIKE '%@%'`),
+ check(
+ "actors_suspended_check",
+ sql`
+ ${t.suspendedUntil} IS NULL OR (
+ ${t.suspended} IS NOT NULL AND
+ ${t.suspendedUntil} > ${t.suspended}
+ )
+ `,
+ ),
+ index("actor_instance_index").on(t.instanceId),
+ ],
+);
+
+export type Actor = typeof actors.$inferSelect;
+export type NewActor = typeof actors.$inferInsert;
+
+export const localActors = pgTable("local_actors", {
+ id: uuid().primaryKey(),
+ avatar: text(),
+ header: text(),
+});
+
+export type LocalActor = typeof localActors.$inferSelect;
+export type NewLocalActor = typeof localActors.$inferInsert;