From 91c63fad21a0604bb5214bd0e48683d15738b9c3 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 3 Aug 2026 04:58:58 +0900 Subject: [PATCH 1/5] Add root domain --- packages/graphql/src/builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"; From b865e993a876aa39b5ef43777842e70ff46ef105 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 4 Aug 2026 05:52:32 +0900 Subject: [PATCH 2/5] Add `actors` table --- .../migration.sql | 66 + .../snapshot.json | 1464 +++++++++++++++++ packages/models/src/relations.ts | 22 + packages/models/src/schema.ts | 144 ++ 4 files changed, 1696 insertions(+) create mode 100644 packages/models/drizzle/20260803204536_tired_ken_ellis/migration.sql create mode 100644 packages/models/drizzle/20260803204536_tired_ken_ellis/snapshot.json diff --git a/packages/models/drizzle/20260803204536_tired_ken_ellis/migration.sql b/packages/models/drizzle/20260803204536_tired_ken_ellis/migration.sql new file mode 100644 index 0000000..9e685cb --- /dev/null +++ b/packages/models/drizzle/20260803204536_tired_ken_ellis/migration.sql @@ -0,0 +1,66 @@ +CREATE TYPE "actor_type" AS ENUM('Application', 'Group', 'Organization', 'Person', 'Service');--> statement-breakpoint +CREATE TABLE "actors" ( + "id" uuid PRIMARY KEY, + "location" "location" NOT NULL, + "type" "actor_type" NOT NULL, + "username" text NOT NULL, + "instanceId" uuid NOT NULL, + "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_id_location_key" UNIQUE("id","location"), + 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, + "location" "location" DEFAULT 'Local'::"location" NOT NULL, + CONSTRAINT "local_check" CHECK ("location" = 'Local') +); +--> statement-breakpoint +CREATE TABLE "remote_actors" ( + "id" uuid PRIMARY KEY, + "iriUrl" text NOT NULL UNIQUE, + "inboxUrl" text NOT NULL, + "outboxUrl" text NOT NULL, + "followersUrl" text, + "followeesUrl" text, + "featuredUrl" text, + "profileUrl" text, + "avatarUrl" text, + "headerUrl" text, + "location" "location" DEFAULT 'Remote'::"location" NOT NULL, + CONSTRAINT "remote_check" CHECK ("location" = 'Remote') +); +--> statement-breakpoint +CREATE INDEX "actor_instance_index" ON "actors" ("instanceId");--> 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;--> statement-breakpoint +ALTER TABLE "local_actors" ADD CONSTRAINT "local_actors_id_actors_id_fkey" FOREIGN KEY ("id") REFERENCES "actors"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "local_actors" ADD CONSTRAINT "local_actor_fk" FOREIGN KEY ("id","location") REFERENCES "actors"("id","location") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "remote_actors" ADD CONSTRAINT "remote_actors_id_actors_id_fkey" FOREIGN KEY ("id") REFERENCES "actors"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "remote_actors" ADD CONSTRAINT "remote_actor_fk" FOREIGN KEY ("id","location") REFERENCES "actors"("id","location") ON DELETE CASCADE; \ No newline at end of file diff --git a/packages/models/drizzle/20260803204536_tired_ken_ellis/snapshot.json b/packages/models/drizzle/20260803204536_tired_ken_ellis/snapshot.json new file mode 100644 index 0000000..b21dbf7 --- /dev/null +++ b/packages/models/drizzle/20260803204536_tired_ken_ellis/snapshot.json @@ -0,0 +1,1464 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "dac33dc8-f18a-423f-809f-b6c390dd1bdc", + "prevIds": ["08f1dfcb-1110-4550-9777-36d017ecfb10"], + "ddl": [ + { + "values": ["Application", "Group", "Organization", "Person", "Service"], + "name": "actor_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Local", "Remote"], + "name": "location", + "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": "remote_actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "remote_instances", + "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": "location", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "location", + "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": 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": "location", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "location", + "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": "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": "location", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'Local'", + "generated": null, + "identity": null, + "name": "location", + "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": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iriUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inboxUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outboxUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followersUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followeesUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "featuredUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profileUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headerUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "location", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'Remote'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nodeInfoUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "softwareVersion", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "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": ["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": ["id"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "local_actors_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "local_actors" + }, + { + "nameExplicit": true, + "columns": ["id", "location"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id", "location"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "local_actor_fk", + "entityType": "fks", + "schema": "public", + "table": "local_actors" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "local_instances_id_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "local_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": ["id"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "remote_actors_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "remote_actors" + }, + { + "nameExplicit": true, + "columns": ["id", "location"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id", "location"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "remote_actor_fk", + "entityType": "fks", + "schema": "public", + "table": "remote_actors" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "remote_instances_id_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "remote_instances" + }, + { + "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": "remote_actors_pkey", + "schema": "public", + "table": "remote_actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "remote_instances_pkey", + "schema": "public", + "table": "remote_instances", + "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": true, + "columns": ["id", "location"], + "nullsNotDistinct": false, + "name": "actors_id_location_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": ["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": ["iriUrl"], + "nullsNotDistinct": false, + "name": "remote_actors_iriUrl_key", + "schema": "public", + "table": "remote_actors", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["host"], + "nullsNotDistinct": false, + "name": "remote_instances_host_key", + "schema": "public", + "table": "remote_instances", + "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": "\"location\" = 'Local'", + "name": "local_check", + "entityType": "checks", + "schema": "public", + "table": "local_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" + }, + { + "value": "\"location\" = 'Remote'", + "name": "remote_check", + "entityType": "checks", + "schema": "public", + "table": "remote_actors" + } + ], + "renames": [] +} diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index 299db4c..e61eb01 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,27 @@ 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.id, + to: r.localActors.id, + }), + remoteActor: r.one.remoteActors({ + from: r.actors.id, + to: r.remoteActors.id, + }), + }, + localActors: { + actor: r.one.actors({ from: r.localActors.id, to: r.actors.id }), + }, + remoteActors: { + actor: r.one.actors({ from: r.remoteActors.id, to: r.actors.id }), + }, })); export default relations; diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index d68bc1a..2c53369 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -16,10 +16,13 @@ import { sql } from "drizzle-orm"; import { + type AnyPgColumn, boolean, check, index, integer, + jsonb, + pgEnum, pgTable, primaryKey, text, @@ -174,3 +177,144 @@ 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(), + location: locationEnum().notNull(), + type: actorTypeEnum().notNull(), + username: text().notNull(), + instanceId: uuid() + .notNull() + .references(() => instances.id, { onDelete: "cascade" }), + 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), + unique("actors_id_location_key").on(t.id, t.location), + ], +); + +export type Actor = typeof actors.$inferSelect; +export type NewActor = typeof actors.$inferInsert; + +export const localActors = pgTable( + "local_actors", + { + id: uuid() + .primaryKey() + .references(() => actors.id, { onDelete: "cascade" }), + avatar: text(), + header: text(), + /** + * This `location` column is not an actually used value; + * it exists to prevent simultaneous Local/Remote registration, + * so it must be fixed as `Local`. + */ + location: locationEnum().default("Local").notNull(), + }, + (t) => [ + check("local_check", sql`${t.location} = 'Local'`), + foreignKey({ + name: "local_actor_fk", + columns: [t.id, t.location], + foreignColumns: [actors.id, actors.location], + }).onDelete("cascade"), + ], +); + +export type LocalActor = typeof localActors.$inferSelect; +export type NewLocalActor = typeof localActors.$inferInsert; + +export const remoteActors = pgTable( + "remote_actors", + { + id: uuid() + .primaryKey() + .references(() => actors.id, { onDelete: "cascade" }), + iriUrl: text().notNull().unique(), + inboxUrl: text().notNull(), + outboxUrl: text().notNull(), + followersUrl: text(), + followeesUrl: text(), + featuredUrl: text(), + profileUrl: text(), + avatarUrl: text(), + headerUrl: text(), + /** + * This `location` column is not an actually used value; + * it exists to prevent simultaneous Local/Remote registration, + * so it must be fixed as `Remote`. + */ + location: locationEnum().default("Remote").notNull(), + }, + (t) => [ + check("remote_check", sql`${t.location} = 'Remote'`), + foreignKey({ + name: "remote_actor_fk", + columns: [t.id, t.location], + foreignColumns: [actors.id, actors.location], + }).onDelete("cascade"), + ], +); + +export type RemoteActor = typeof remoteActors.$inferSelect; +export type NewRemoteActor = typeof remoteActors.$inferInsert; From 93c2407c5b47b537a09762194f1c6948aae1bb04 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 4 Aug 2026 05:53:09 +0900 Subject: [PATCH 3/5] Add get `actor` and post `actors` --- .oxlintrc.json | 2 +- mise.toml | 1 + packages/graphql/package.json | 5 + packages/graphql/src/actor.ts | 416 ++++++++++++++++++++++++++ packages/graphql/src/instance.ts | 68 +++++ packages/graphql/src/schema.ts | 1 + packages/graphql/src/uri-templates.ts | 52 ++++ 7 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 packages/graphql/src/actor.ts create mode 100644 packages/graphql/src/uri-templates.ts 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.ts b/packages/graphql/src/actor.ts new file mode 100644 index 0000000..1b7598b --- /dev/null +++ b/packages/graphql/src/actor.ts @@ -0,0 +1,416 @@ +// 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 { type relations, schema } from "@drfed/models"; +import { actorTypeEnum } from "@drfed/models/schema"; +import type { ExpandContext } from "@fedify/uri-template"; +import type { BuildQueryResult, DBQueryConfig } from "drizzle-orm"; +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(" | "); + +type DrizzleRelations = typeof relations; +type ActorTableConfig = DrizzleRelations["actors"]; +type ActorSelect = DBQueryConfig<"one", DrizzleRelations, ActorTableConfig>; + +const ACTOR_REF_SELECT = { + columns: { id: true, username: true }, + with: { + instance: { + columns: { id: true }, + with: { localInstances: true, remoteInstances: true }, + }, + localActor: true, + remoteActor: true, + }, +} as const satisfies ActorSelect; + +type SelectedActor = BuildQueryResult< + DrizzleRelations, + ActorTableConfig, + typeof ACTOR_REF_SELECT +>; + +interface LocalTemplateArgs extends ExpandContext { + id: string; + host: string; + username: string; + avatar: string | null; + header: string | null; +} + +const getLocalInfo = (actor: SelectedActor, root: string): LocalTemplateArgs => + actor?.localActor == null || actor.instance?.localInstances == null + ? throwNonLocal(actor.id, "actor") + : { + id: actor.id, + host: `${actor.instance.localInstances.slug}.${root}`, + username: actor.username, + avatar: actor.localActor.avatar, + header: actor.localActor.header, + }; + +function throwNonLocal(id: string, kind: "actor" | "instance"): never { + throw new Error(`This ${kind} is not local: ${id}`); +} + +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`.", + }, + select: ACTOR_REF_SELECT, + fields: (t) => ({ + iri: t.field({ + type: "String", + resolve: (parent, _, ctx) => + parent.remoteActor?.iriUrl ?? + templates.iri.expand(getLocalInfo(parent, ctx.root)), + description: "The Internationalized Resource Identifier of the `Actor`", + }), + handle: t.field({ + type: "String", + description: "The handle of the `Actor`.", + resolve: (parent, _, ctx) => + templates.handle.expand( + parent.instance?.remoteInstances + ? { + username: parent.username, + host: parent.instance.remoteInstances.host, + } + : getLocalInfo(parent, ctx.root), + ), + }), + 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.", + }), + inboxUrl: t.field({ + type: "String", + description: "The inbox URL of the `Actor`.", + resolve: (parent, _, ctx) => + parent.remoteActor?.inboxUrl ?? + templates.inbox.expand(getLocalInfo(parent, ctx.root)), + }), + outboxUrl: t.field({ + type: "String", + description: "The outbox URL of the `Actor`.", + resolve: (parent, _, ctx) => + parent.remoteActor?.outboxUrl ?? + templates.outbox.expand(getLocalInfo(parent, ctx.root)), + }), + avatarUrl: t.field({ + type: "String", + description: "The avatar URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor + ? parent.remoteActor.avatarUrl + : templates.avatar.expand(getLocalInfo(parent, ctx.root)), + }), + followersUrl: t.field({ + type: "String", + description: "The followers URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor != null + ? parent.remoteActor.followersUrl + : templates.followers.expand(getLocalInfo(parent, ctx.root)), + }), + followeesUrl: t.field({ + type: "String", + description: "The followees URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor != null + ? parent.remoteActor.followeesUrl + : templates.followees.expand(getLocalInfo(parent, ctx.root)), + }), + headerUrl: t.field({ + type: "String", + description: "The header URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor != null + ? parent.remoteActor.headerUrl + : templates.header.expand(getLocalInfo(parent, ctx.root)), + }), + profileUrl: t.field({ + type: "String", + description: "The profile URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor != null + ? parent.remoteActor.profileUrl + : templates.profile.expand(getLocalInfo(parent, ctx.root)), + }), + featuredUrl: t.field({ + type: "String", + description: "The featured URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor != null + ? parent.remoteActor.featuredUrl + : templates.featured.expand(getLocalInfo(parent, ctx.root)), + }), + }), +}); + +export const Actor: DrFedObjectRef = ActorRef; + +const CreateActorsRef = builder.drizzleNode("localActors", { + name: "CreateActors", + description: "Represents an `Actor` in the DrFed platform.", + id: { + column: ({ id }) => id, + description: "The unique identifier of the `Actor`.", + }, + select: { + columns: { id: true }, + with: { actor: { columns: { username: true } } }, + }, + fields: (t) => ({ + username: t.field({ + type: "String", + description: "username", + resolve: ({ actor }) => actor!.username, + }), + }), +}); + +interface CreateActorsSuccess { + readonly actors: readonly (typeof CreateActorsRef.$inferType)[]; +} + +const CreateActorsSuccessRef = builder.objectRef( + "CreateActorsSuccess", +); + +CreateActorsSuccessRef.implement({ + fields: (t) => ({ + actors: t.field({ + type: [CreateActorsRef], + 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 { + maxActors: number; + slug: string; +} + +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, root } = 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({ + maxActors: schema.localInstances.maxActors, + slug: schema.localInstances.slug, + }) + .from(schema.instanceMembers) + .innerJoin( + schema.instances, + eq(schema.instanceMembers.instanceId, schema.instances.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 { slug, maxActors } = instance; + const host = `${slug}.${root}`; + 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 createdActors = await tx + .insert(schema.actors) + .values( + Array.from({ length: size }).map(() => genActor(instanceId)), + ) + .returning(); + const actorCounts = await tx.$count( + schema.actors, + eq(schema.actors.instanceId, instanceId), + ); + if (actorCounts > maxActors) { + tooManyActors = true; + tx.rollback(); + } + const localActors = await tx + .insert(schema.localActors) + .values(createdActors) + .returning(); + // oxlint-disable-next-line eslint/id-length + const actors = localActors.map((local, i) => ({ + actor: createdActors[i]!, + ...local, + })); + return { actors }; + }); + } 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.slug + } instance reached the maximum number of actors (${ + instance.maxActors + })`, + }; + } + throw e; + } + }, + }), +})); + +function genActor(instanceId: string): PgInsertValue { + const id = uuid(); + return { + id, + // FIXME: Generate handle using Faker.js or something + username: id, + location: "Local", + instanceId, + type: "Person", + }; +} diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index f778f83..5c85145 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,72 @@ builder.drizzleObjectField(InstanceRef, "members", (t) => ), ); +const actorsConnection = drizzleConnectionHelpers(builder, "actors", { + query: { orderBy: { created: "desc" } }, + select: (nestedSelection) => ({ with: { instance: nestedSelection() } }), + resolveNode: ({ instanceId }) => instanceId, +}); + +// 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; From 237fe6eaa83a42904e7164e5edcb69445fdc1e06 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 4 Aug 2026 14:01:51 +0900 Subject: [PATCH 4/5] Add test for actor GraphQL API using AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompts: Actor 를 위한 GraphQL 빌더를 추가했습니다. @packages/graphql/src/actor.test.ts 에 이를 위한 테스트를 생성해주세요. 파일 추가 직후 `mise run fmt` 로 라이선스를 추가하세요. 최소한 다음과 같은 테스트가 필요합니다. - 액터 생성 - 로컬 액터 조회 - 리모트 액터 조회 Assisted-by: Codex:gpt-5-6-sol --- packages/graphql/src/actor.test.ts | 304 +++++++++++++++++++++++++++++ packages/graphql/src/actor.ts | 4 + 2 files changed, 308 insertions(+) create mode 100644 packages/graphql/src/actor.test.ts diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts new file mode 100644 index 0000000..e699bd1 --- /dev/null +++ b/packages/graphql/src/actor.test.ts @@ -0,0 +1,304 @@ +// 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 { + username + } + } + ... on CreateActorsError { + type + message + } + } + } +`; + +const actorQuery = ` + query Actor($id: ID!) { + node(id: $id) { + ... on Actor { + id + iri + handle + type + username + instance { + uuid + location + host + } + inboxUrl + outboxUrl + avatarUrl + followersUrl + followeesUrl + headerUrl + profileUrl + featuredUrl + } + } + } +`; + +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( + ({ username }: { username: unknown }) => typeof username === "string", + ), + true, + ); + + const actors = await db.select().from(schema.actors); + assert.equal(actors.length, 2); + assert.equal( + actors.every( + (actor) => + actor.instanceId === localInstanceId && + actor.location === "Local" && + 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(({ id }) => id)), + ); + }); + }); +}); + +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), + iri: `https://test-instance.drfed.org/users/${localActorId}`, + handle: "@alice@test-instance.drfed.org", + type: "Person", + username: "alice", + instance: { + uuid: localInstanceId, + location: "Local", + host: "test-instance.drfed.org", + }, + 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`, + }, + }, + }); + }); + }); + + 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), + iri: "https://remote.example.com/users/bob", + handle: "@bob@remote.example.com", + type: "Service", + username: "bob", + instance: { + uuid: remoteInstanceId, + location: "Remote", + host: "remote.example.com", + }, + 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", + }, + }, + }); + }); + }); +}); + +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.actors).values({ + id: localActorId, + instanceId: localInstanceId, + location: "Local", + type: "Person", + username: "alice", + created, + }); + await db.insert(schema.localActors).values({ + id: localActorId, + avatar: "avatar.png", + header: "header.png", + }); +} + +async function seedLocalInstance(db: Database): Promise { + await db.insert(schema.instances).values({ + id: localInstanceId, + location: "Local", + created, + }); + await db.insert(schema.localInstances).values({ + id: localInstanceId, + slug: "test-instance", + expires, + }); +} + +async function seedRemoteActor(db: Database): Promise { + await db.insert(schema.instances).values({ + id: remoteInstanceId, + location: "Remote", + created, + }); + await db.insert(schema.remoteInstances).values({ + id: remoteInstanceId, + host: "remote.example.com", + }); + await db.insert(schema.actors).values({ + id: remoteActorId, + instanceId: remoteInstanceId, + location: "Remote", + type: "Service", + username: "bob", + created, + }); + await db.insert(schema.remoteActors).values({ + id: remoteActorId, + iriUrl: "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", + }); +} + +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 index 1b7598b..2f68796 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -326,6 +326,10 @@ builder.mutationFields((t) => ({ schema.instances, eq(schema.instanceMembers.instanceId, schema.instances.id), ) + .innerJoin( + schema.localInstances, + eq(schema.instances.id, schema.localInstances.id), + ) .where( and( eq(schema.instanceMembers.accountId, account.id), From 1a1885da5c72d33afd2e35ec6bb0631619fe12a2 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 21 Aug 2026 16:48:53 +0900 Subject: [PATCH 5/5] Merge remoteActors into actors Apply the same shape to actors that 09be000 applied to instances: - Move the remote_actors columns (iri, inboxUrl, outboxUrl, and the optional followers/followees/featured/profile/avatar/header URLs) into actors and drop the remote_actors table. iriUrl is renamed to iri and stays unique. - Drop the location enum column and the (id, location) composite FK/CHECK pairs. Local actors are identified by actors.localId, a nullable unique FK to local_actors.id with cascade delete, which inverts the previous local_actors.id -> actors.id dependency. local_actors keeps only id, avatar, and header. - Replace the actor migration. The old 20260803204536 migration created actors with the "location" type that the later 20260818 migration drops, so it could not apply on a fresh database and it was never on main. The new migration is regenerated from the current snapshot. - Store local actor URLs at creation time. genActors inserts local_actors first, then actors with the URLs expanded from the URI templates using instances.host, and joins local_instances via instances.localId. avatarUrl and headerUrl start as NULL. - Simplify the Actor GraphQL type to expose stored columns, keep handle as a field-level select on instance.host, and add uuid, created, and a nullable local: LocalActor relation. Replace the CreateActors node with Actor in CreateActorsSuccess.actors. - Fix the Instance.actors connection, whose resolveNode returned the instanceId instead of the actor. - Update the actor tests for the new seeds, fields, and assertions. AI provenance: Claude Code was asked to analyze the instance merge in 09be000 and write a plan applying the same approach to actors, covering the schema, migration, GraphQL API, and tests; it found that the branch no longer type-checked after the rebase and that the old actor migration depended on a dropped enum type. Codex implemented the changes from that plan. Claude Code then reviewed the diff against the plan, ran tsc for each package, and ran the test suite. The human contributor has read and reviewed both the plan and the implementation, and directly modified some of the designs. They validated the generated migration SQL and performed local checks and tests. Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.6-sol --- packages/graphql/src/actor.test.ts | 83 ++- packages/graphql/src/actor.ts | 221 +++---- packages/graphql/src/instance.ts | 2 - .../migration.sql | 38 +- .../snapshot.json | 578 +++++++----------- packages/models/src/relations.ts | 11 +- packages/models/src/schema.ts | 79 +-- 7 files changed, 377 insertions(+), 635 deletions(-) rename packages/models/drizzle/{20260803204536_tired_ken_ellis => 20260821072150_merge_remote_actors}/migration.sql (64%) rename packages/models/drizzle/{20260803204536_tired_ken_ellis => 20260821072150_merge_remote_actors}/snapshot.json (85%) diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index e699bd1..140a892 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -42,7 +42,12 @@ const genActorsMutation = ` resultType: __typename ... on CreateActorsSuccess { actors { + uuid + iri username + local { + uuid + } } } ... on CreateActorsError { @@ -58,15 +63,19 @@ const actorQuery = ` node(id: $id) { ... on Actor { id + uuid iri handle type username instance { uuid - location host } + local { + avatar + header + } inboxUrl outboxUrl avatarUrl @@ -75,6 +84,7 @@ const actorQuery = ` headerUrl profileUrl featuredUrl + created } } } @@ -103,7 +113,17 @@ describe("Mutation.genActors", () => { assert.equal(body.data.genActors.actors.length, 2); assert.equal( body.data.genActors.actors.every( - ({ username }: { username: unknown }) => typeof username === "string", + (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, ); @@ -114,7 +134,7 @@ describe("Mutation.genActors", () => { actors.every( (actor) => actor.instanceId === localInstanceId && - actor.location === "Local" && + actor.localId != null && actor.type === "Person", ), true, @@ -124,7 +144,7 @@ describe("Mutation.genActors", () => { assert.equal(localActors.length, 2); assert.deepEqual( new Set(localActors.map(({ id }) => id)), - new Set(actors.map(({ id }) => id)), + new Set(actors.map(({ localId }) => localId)), ); }); }); @@ -145,15 +165,19 @@ describe("Actor", () => { 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, - location: "Local", 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`, @@ -162,6 +186,7 @@ describe("Actor", () => { 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(), }, }, }); @@ -182,15 +207,16 @@ describe("Actor", () => { 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, - location: "Remote", 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", @@ -199,6 +225,7 @@ describe("Actor", () => { 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(), }, }, }); @@ -237,55 +264,56 @@ async function seedAuthenticatedLocalInstance( 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, - location: "Local", 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, }); - await db.insert(schema.localActors).values({ - id: localActorId, - avatar: "avatar.png", - header: "header.png", - }); } async function seedLocalInstance(db: Database): Promise { - await db.insert(schema.instances).values({ - id: localInstanceId, - location: "Local", - created, - }); 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, - location: "Remote", created, - }); - await db.insert(schema.remoteInstances).values({ - id: remoteInstanceId, host: "remote.example.com", }); await db.insert(schema.actors).values({ id: remoteActorId, instanceId: remoteInstanceId, - location: "Remote", type: "Service", username: "bob", - created, - }); - await db.insert(schema.remoteActors).values({ - id: remoteActorId, - iriUrl: "https://remote.example.com/users/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", @@ -294,6 +322,7 @@ async function seedRemoteActor(db: Database): Promise { headerUrl: "https://remote.example.com/users/bob/header.png", profileUrl: "https://remote.example.com/@bob", featuredUrl: "https://remote.example.com/users/bob/featured", + created, }); } diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index 2f68796..7a6b116 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -16,10 +16,8 @@ // oxlint-disable max-lines-per-function eslint/max-lines -import { type relations, schema } from "@drfed/models"; +import { schema } from "@drfed/models"; import { actorTypeEnum } from "@drfed/models/schema"; -import type { ExpandContext } from "@fedify/uri-template"; -import type { BuildQueryResult, DBQueryConfig } from "drizzle-orm"; import type { PgInsertValue } from "drizzle-orm/pg-core"; import { and, eq, gt, isNotNull } from "drizzle-orm/sql/expressions"; import { v7 as uuid } from "uuid"; @@ -35,51 +33,6 @@ export const ActorType = builder.enumType("ActorType", { export const ACTOR_TYPES = actorTypeEnum.enumValues.join(" | "); -type DrizzleRelations = typeof relations; -type ActorTableConfig = DrizzleRelations["actors"]; -type ActorSelect = DBQueryConfig<"one", DrizzleRelations, ActorTableConfig>; - -const ACTOR_REF_SELECT = { - columns: { id: true, username: true }, - with: { - instance: { - columns: { id: true }, - with: { localInstances: true, remoteInstances: true }, - }, - localActor: true, - remoteActor: true, - }, -} as const satisfies ActorSelect; - -type SelectedActor = BuildQueryResult< - DrizzleRelations, - ActorTableConfig, - typeof ACTOR_REF_SELECT ->; - -interface LocalTemplateArgs extends ExpandContext { - id: string; - host: string; - username: string; - avatar: string | null; - header: string | null; -} - -const getLocalInfo = (actor: SelectedActor, root: string): LocalTemplateArgs => - actor?.localActor == null || actor.instance?.localInstances == null - ? throwNonLocal(actor.id, "actor") - : { - id: actor.id, - host: `${actor.instance.localInstances.slug}.${root}`, - username: actor.username, - avatar: actor.localActor.avatar, - header: actor.localActor.header, - }; - -function throwNonLocal(id: string, kind: "actor" | "instance"): never { - throw new Error(`This ${kind} is not local: ${id}`); -} - const ActorRef = builder.drizzleNode("actors", { name: "Actor", description: "Represents an `Actor` in the DrFed platform.", @@ -87,27 +40,23 @@ const ActorRef = builder.drizzleNode("actors", { column: ({ id }) => id, description: "The unique identifier of the `Actor`.", }, - select: ACTOR_REF_SELECT, fields: (t) => ({ - iri: t.field({ - type: "String", - resolve: (parent, _, ctx) => - parent.remoteActor?.iriUrl ?? - templates.iri.expand(getLocalInfo(parent, ctx.root)), + 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`.", - resolve: (parent, _, ctx) => - templates.handle.expand( - parent.instance?.remoteInstances - ? { - username: parent.username, - host: parent.instance.remoteInstances.host, - } - : getLocalInfo(parent, ctx.root), - ), + 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, @@ -119,101 +68,76 @@ const ActorRef = builder.drizzleNode("actors", { instance: t.relation("instance", { description: "The `Instance` that the `Actor` belongs to.", }), - inboxUrl: t.field({ - type: "String", + 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`.", - resolve: (parent, _, ctx) => - parent.remoteActor?.inboxUrl ?? - templates.inbox.expand(getLocalInfo(parent, ctx.root)), }), - outboxUrl: t.field({ - type: "String", + outboxUrl: t.exposeString("outboxUrl", { description: "The outbox URL of the `Actor`.", - resolve: (parent, _, ctx) => - parent.remoteActor?.outboxUrl ?? - templates.outbox.expand(getLocalInfo(parent, ctx.root)), }), - avatarUrl: t.field({ - type: "String", + avatarUrl: t.exposeString("avatarUrl", { description: "The avatar URL of the `Actor`.", nullable: true, - resolve: (parent, _, ctx) => - parent.remoteActor - ? parent.remoteActor.avatarUrl - : templates.avatar.expand(getLocalInfo(parent, ctx.root)), }), - followersUrl: t.field({ - type: "String", + followersUrl: t.exposeString("followersUrl", { description: "The followers URL of the `Actor`.", nullable: true, - resolve: (parent, _, ctx) => - parent.remoteActor != null - ? parent.remoteActor.followersUrl - : templates.followers.expand(getLocalInfo(parent, ctx.root)), }), - followeesUrl: t.field({ - type: "String", + followeesUrl: t.exposeString("followeesUrl", { description: "The followees URL of the `Actor`.", nullable: true, - resolve: (parent, _, ctx) => - parent.remoteActor != null - ? parent.remoteActor.followeesUrl - : templates.followees.expand(getLocalInfo(parent, ctx.root)), }), - headerUrl: t.field({ - type: "String", + headerUrl: t.exposeString("headerUrl", { description: "The header URL of the `Actor`.", nullable: true, - resolve: (parent, _, ctx) => - parent.remoteActor != null - ? parent.remoteActor.headerUrl - : templates.header.expand(getLocalInfo(parent, ctx.root)), }), - profileUrl: t.field({ - type: "String", + profileUrl: t.exposeString("profileUrl", { description: "The profile URL of the `Actor`.", nullable: true, - resolve: (parent, _, ctx) => - parent.remoteActor != null - ? parent.remoteActor.profileUrl - : templates.profile.expand(getLocalInfo(parent, ctx.root)), }), - featuredUrl: t.field({ - type: "String", + featuredUrl: t.exposeString("featuredUrl", { description: "The featured URL of the `Actor`.", nullable: true, - resolve: (parent, _, ctx) => - parent.remoteActor != null - ? parent.remoteActor.featuredUrl - : templates.featured.expand(getLocalInfo(parent, ctx.root)), + }), + created: t.expose("created", { + type: "DateTime", + description: "The creation date/time of the `Actor`.", }), }), }); export const Actor: DrFedObjectRef = ActorRef; -const CreateActorsRef = builder.drizzleNode("localActors", { - name: "CreateActors", - description: "Represents an `Actor` in the DrFed platform.", +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 `Actor`.", - }, - select: { - columns: { id: true }, - with: { actor: { columns: { username: true } } }, + description: "The unique identifier of the local actor details.", }, fields: (t) => ({ - username: t.field({ - type: "String", - description: "username", - resolve: ({ actor }) => actor!.username, + 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 CreateActorsRef.$inferType)[]; + readonly actors: readonly (typeof ActorRef.$inferType)[]; } const CreateActorsSuccessRef = builder.objectRef( @@ -223,7 +147,7 @@ const CreateActorsSuccessRef = builder.objectRef( CreateActorsSuccessRef.implement({ fields: (t) => ({ actors: t.field({ - type: [CreateActorsRef], + type: [ActorRef], resolve: ({ actors }) => actors, }), }), @@ -276,8 +200,8 @@ const CreateActorsResult = builder.unionType("CreateActorsResult", { }); interface SelectedInstance { + host: string; maxActors: number; - slug: string; } builder.mutationFields((t) => ({ @@ -309,7 +233,7 @@ builder.mutationFields((t) => ({ // because the `authScopes` option above should prevent this resolver throw new Error("You must be authenticated to create actors."); } - const { account, root } = ctx; + const { account } = ctx; let instance: SelectedInstance | undefined; let tooManyActors = false; @@ -318,8 +242,8 @@ builder.mutationFields((t) => ({ // Find the instance that the account is included [instance] = await tx .select({ + host: schema.instances.host, maxActors: schema.localInstances.maxActors, - slug: schema.localInstances.slug, }) .from(schema.instanceMembers) .innerJoin( @@ -328,7 +252,7 @@ builder.mutationFields((t) => ({ ) .innerJoin( schema.localInstances, - eq(schema.instances.id, schema.localInstances.id), + eq(schema.instances.localId, schema.localInstances.id), ) .where( and( @@ -340,8 +264,7 @@ builder.mutationFields((t) => ({ ) .limit(1); if (instance == null) throw new Error(INSTANCE_NOT_FOUND); - const { slug, maxActors } = instance; - const host = `${slug}.${root}`; + const { host, maxActors } = instance; const currActors = await tx.$count( schema.actors, eq(schema.actors.instanceId, instanceId), @@ -358,11 +281,13 @@ builder.mutationFields((t) => ({ }; } // 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( - Array.from({ length: size }).map(() => genActor(instanceId)), - ) + .values(localActors.map(({ id }) => genActor(id, instanceId, host))) .returning(); const actorCounts = await tx.$count( schema.actors, @@ -372,16 +297,7 @@ builder.mutationFields((t) => ({ tooManyActors = true; tx.rollback(); } - const localActors = await tx - .insert(schema.localActors) - .values(createdActors) - .returning(); - // oxlint-disable-next-line eslint/id-length - const actors = localActors.map((local, i) => ({ - actor: createdActors[i]!, - ...local, - })); - return { actors }; + return { actors: createdActors }; }); } catch (e) { if (instance == null) { @@ -395,7 +311,7 @@ builder.mutationFields((t) => ({ return { type: TOO_MANY_ACTORS, message: `${ - instance.slug + instance.host } instance reached the maximum number of actors (${ instance.maxActors })`, @@ -407,14 +323,27 @@ builder.mutationFields((t) => ({ }), })); -function genActor(instanceId: string): PgInsertValue { +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: id, - location: "Local", + 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/instance.ts b/packages/graphql/src/instance.ts index 5c85145..c6069a2 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -183,8 +183,6 @@ builder.drizzleObjectField(InstanceRef, "members", (t) => const actorsConnection = drizzleConnectionHelpers(builder, "actors", { query: { orderBy: { created: "desc" } }, - select: (nestedSelection) => ({ with: { instance: nestedSelection() } }), - resolveNode: ({ instanceId }) => instanceId, }); // oxlint-disable-next-line max-lines-per-function diff --git a/packages/models/drizzle/20260803204536_tired_ken_ellis/migration.sql b/packages/models/drizzle/20260821072150_merge_remote_actors/migration.sql similarity index 64% rename from packages/models/drizzle/20260803204536_tired_ken_ellis/migration.sql rename to packages/models/drizzle/20260821072150_merge_remote_actors/migration.sql index 9e685cb..ac731f6 100644 --- a/packages/models/drizzle/20260803204536_tired_ken_ellis/migration.sql +++ b/packages/models/drizzle/20260821072150_merge_remote_actors/migration.sql @@ -1,10 +1,19 @@ CREATE TYPE "actor_type" AS ENUM('Application', 'Group', 'Organization', 'Person', 'Service');--> statement-breakpoint CREATE TABLE "actors" ( "id" uuid PRIMARY KEY, - "location" "location" NOT NULL, + "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, @@ -24,7 +33,6 @@ CREATE TABLE "actors" ( "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, "deleted" timestamp with time zone, CONSTRAINT "username_key" UNIQUE("username","instanceId"), - CONSTRAINT "actors_id_location_key" UNIQUE("id","location"), CONSTRAINT "actors_username_check" CHECK ("username" NOT LIKE '%@%'), CONSTRAINT "actors_suspended_check" CHECK ( "suspendedUntil" IS NULL OR ( @@ -37,30 +45,10 @@ CREATE TABLE "actors" ( CREATE TABLE "local_actors" ( "id" uuid PRIMARY KEY, "avatar" text, - "header" text, - "location" "location" DEFAULT 'Local'::"location" NOT NULL, - CONSTRAINT "local_check" CHECK ("location" = 'Local') -); ---> statement-breakpoint -CREATE TABLE "remote_actors" ( - "id" uuid PRIMARY KEY, - "iriUrl" text NOT NULL UNIQUE, - "inboxUrl" text NOT NULL, - "outboxUrl" text NOT NULL, - "followersUrl" text, - "followeesUrl" text, - "featuredUrl" text, - "profileUrl" text, - "avatarUrl" text, - "headerUrl" text, - "location" "location" DEFAULT 'Remote'::"location" NOT NULL, - CONSTRAINT "remote_check" CHECK ("location" = 'Remote') + "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;--> statement-breakpoint -ALTER TABLE "local_actors" ADD CONSTRAINT "local_actors_id_actors_id_fkey" FOREIGN KEY ("id") REFERENCES "actors"("id") ON DELETE CASCADE;--> statement-breakpoint -ALTER TABLE "local_actors" ADD CONSTRAINT "local_actor_fk" FOREIGN KEY ("id","location") REFERENCES "actors"("id","location") ON DELETE CASCADE;--> statement-breakpoint -ALTER TABLE "remote_actors" ADD CONSTRAINT "remote_actors_id_actors_id_fkey" FOREIGN KEY ("id") REFERENCES "actors"("id") ON DELETE CASCADE;--> statement-breakpoint -ALTER TABLE "remote_actors" ADD CONSTRAINT "remote_actor_fk" FOREIGN KEY ("id","location") REFERENCES "actors"("id","location") ON DELETE CASCADE; \ No newline at end of file +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/20260803204536_tired_ken_ellis/snapshot.json b/packages/models/drizzle/20260821072150_merge_remote_actors/snapshot.json similarity index 85% rename from packages/models/drizzle/20260803204536_tired_ken_ellis/snapshot.json rename to packages/models/drizzle/20260821072150_merge_remote_actors/snapshot.json index b21dbf7..1caf8f4 100644 --- a/packages/models/drizzle/20260803204536_tired_ken_ellis/snapshot.json +++ b/packages/models/drizzle/20260821072150_merge_remote_actors/snapshot.json @@ -1,8 +1,8 @@ { "version": "8", "dialect": "postgres", - "id": "dac33dc8-f18a-423f-809f-b6c390dd1bdc", - "prevIds": ["08f1dfcb-1110-4550-9777-36d017ecfb10"], + "id": "ac4fc4e2-2217-4c4c-9c8a-0ec2a8162819", + "prevIds": ["d8ac955f-1e74-4493-ade9-a25c40b01c20"], "ddl": [ { "values": ["Application", "Group", "Organization", "Person", "Service"], @@ -10,12 +10,6 @@ "entityType": "enums", "schema": "public" }, - { - "values": ["Local", "Remote"], - "name": "location", - "entityType": "enums", - "schema": "public" - }, { "isRlsEnabled": false, "name": "accounts", @@ -58,18 +52,6 @@ "entityType": "tables", "schema": "public" }, - { - "isRlsEnabled": false, - "name": "remote_actors", - "entityType": "tables", - "schema": "public" - }, - { - "isRlsEnabled": false, - "name": "remote_instances", - "entityType": "tables", - "schema": "public" - }, { "isRlsEnabled": false, "name": "sessions", @@ -168,14 +150,14 @@ "table": "actors" }, { - "type": "location", - "typeSchema": "public", - "notNull": true, + "type": "uuid", + "typeSchema": null, + "notNull": false, "dimensions": 0, "default": null, "generated": null, "identity": null, - "name": "location", + "name": "localId", "entityType": "columns", "schema": "public", "table": "actors" @@ -219,6 +201,123 @@ "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, @@ -532,14 +631,14 @@ "table": "instances" }, { - "type": "location", - "typeSchema": "public", - "notNull": true, + "type": "uuid", + "typeSchema": null, + "notNull": false, "dimensions": 0, "default": null, "generated": null, "identity": null, - "name": "location", + "name": "localId", "entityType": "columns", "schema": "public", "table": "instances" @@ -558,17 +657,17 @@ "table": "instances" }, { - "type": "uuid", + "type": "varchar(100)", "typeSchema": null, "notNull": true, "dimensions": 0, "default": null, "generated": null, "identity": null, - "name": "id", + "name": "host", "entityType": "columns", "schema": "public", - "table": "local_actors" + "table": "instances" }, { "type": "text", @@ -578,7 +677,46 @@ "default": null, "generated": null, "identity": null, - "name": "avatar", + "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" @@ -591,20 +729,20 @@ "default": null, "generated": null, "identity": null, - "name": "header", + "name": "avatar", "entityType": "columns", "schema": "public", "table": "local_actors" }, { - "type": "location", - "typeSchema": "public", - "notNull": true, + "type": "text", + "typeSchema": null, + "notNull": false, "dimensions": 0, - "default": "'Local'", + "default": null, "generated": null, "identity": null, - "name": "location", + "name": "header", "entityType": "columns", "schema": "public", "table": "local_actors" @@ -752,214 +890,6 @@ "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": "remote_actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "iriUrl", - "entityType": "columns", - "schema": "public", - "table": "remote_actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "inboxUrl", - "entityType": "columns", - "schema": "public", - "table": "remote_actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "outboxUrl", - "entityType": "columns", - "schema": "public", - "table": "remote_actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "followersUrl", - "entityType": "columns", - "schema": "public", - "table": "remote_actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "followeesUrl", - "entityType": "columns", - "schema": "public", - "table": "remote_actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "featuredUrl", - "entityType": "columns", - "schema": "public", - "table": "remote_actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "profileUrl", - "entityType": "columns", - "schema": "public", - "table": "remote_actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "avatarUrl", - "entityType": "columns", - "schema": "public", - "table": "remote_actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "headerUrl", - "entityType": "columns", - "schema": "public", - "table": "remote_actors" - }, - { - "type": "location", - "typeSchema": "public", - "notNull": true, - "dimensions": 0, - "default": "'Remote'", - "generated": null, - "identity": null, - "name": "location", - "entityType": "columns", - "schema": "public", - "table": "remote_actors" - }, - { - "type": "uuid", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "id", - "entityType": "columns", - "schema": "public", - "table": "remote_instances" - }, - { - "type": "varchar(100)", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "host", - "entityType": "columns", - "schema": "public", - "table": "remote_instances" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "nodeInfoUrl", - "entityType": "columns", - "schema": "public", - "table": "remote_instances" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "software", - "entityType": "columns", - "schema": "public", - "table": "remote_instances" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "softwareVersion", - "entityType": "columns", - "schema": "public", - "table": "remote_instances" - }, { "type": "uuid", "typeSchema": null, @@ -1088,6 +1018,19 @@ "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"], @@ -1142,42 +1085,16 @@ }, { "nameExplicit": false, - "columns": ["id"], + "columns": ["localId"], "schemaTo": "public", - "tableTo": "actors", + "tableTo": "local_instances", "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", - "name": "local_actors_id_actors_id_fkey", - "entityType": "fks", - "schema": "public", - "table": "local_actors" - }, - { - "nameExplicit": true, - "columns": ["id", "location"], - "schemaTo": "public", - "tableTo": "actors", - "columnsTo": ["id", "location"], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "name": "local_actor_fk", + "name": "instances_localId_local_instances_id_fkey", "entityType": "fks", "schema": "public", - "table": "local_actors" - }, - { - "nameExplicit": false, - "columns": ["id"], - "schemaTo": "public", - "tableTo": "instances", - "columnsTo": ["id"], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "name": "local_instances_id_instances_id_fkey", - "entityType": "fks", - "schema": "public", - "table": "local_instances" + "table": "instances" }, { "nameExplicit": false, @@ -1192,45 +1109,6 @@ "schema": "public", "table": "login_tokens" }, - { - "nameExplicit": false, - "columns": ["id"], - "schemaTo": "public", - "tableTo": "actors", - "columnsTo": ["id"], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "name": "remote_actors_id_actors_id_fkey", - "entityType": "fks", - "schema": "public", - "table": "remote_actors" - }, - { - "nameExplicit": true, - "columns": ["id", "location"], - "schemaTo": "public", - "tableTo": "actors", - "columnsTo": ["id", "location"], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "name": "remote_actor_fk", - "entityType": "fks", - "schema": "public", - "table": "remote_actors" - }, - { - "nameExplicit": false, - "columns": ["id"], - "schemaTo": "public", - "tableTo": "instances", - "columnsTo": ["id"], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "name": "remote_instances_id_instances_id_fkey", - "entityType": "fks", - "schema": "public", - "table": "remote_instances" - }, { "nameExplicit": false, "columns": ["accountId"], @@ -1300,22 +1178,6 @@ "table": "login_tokens", "entityType": "pks" }, - { - "columns": ["id"], - "nameExplicit": false, - "name": "remote_actors_pkey", - "schema": "public", - "table": "remote_actors", - "entityType": "pks" - }, - { - "columns": ["id"], - "nameExplicit": false, - "name": "remote_instances_pkey", - "schema": "public", - "table": "remote_instances", - "entityType": "pks" - }, { "columns": ["id"], "nameExplicit": false, @@ -1334,57 +1196,57 @@ "table": "actors" }, { - "nameExplicit": true, - "columns": ["id", "location"], + "nameExplicit": false, + "columns": ["email"], "nullsNotDistinct": false, - "name": "actors_id_location_key", - "entityType": "uniques", + "name": "accounts_email_key", "schema": "public", - "table": "actors" + "table": "accounts", + "entityType": "uniques" }, { "nameExplicit": false, - "columns": ["email"], + "columns": ["localId"], "nullsNotDistinct": false, - "name": "accounts_email_key", + "name": "actors_localId_key", "schema": "public", - "table": "accounts", + "table": "actors", "entityType": "uniques" }, { "nameExplicit": false, - "columns": ["slug"], + "columns": ["iri"], "nullsNotDistinct": false, - "name": "local_instances_slug_key", + "name": "actors_iri_key", "schema": "public", - "table": "local_instances", + "table": "actors", "entityType": "uniques" }, { "nameExplicit": false, - "columns": ["tokenHash"], + "columns": ["host"], "nullsNotDistinct": false, - "name": "login_tokens_tokenHash_key", + "name": "instances_host_key", "schema": "public", - "table": "login_tokens", + "table": "instances", "entityType": "uniques" }, { "nameExplicit": false, - "columns": ["iriUrl"], + "columns": ["slug"], "nullsNotDistinct": false, - "name": "remote_actors_iriUrl_key", + "name": "local_instances_slug_key", "schema": "public", - "table": "remote_actors", + "table": "local_instances", "entityType": "uniques" }, { "nameExplicit": false, - "columns": ["host"], + "columns": ["tokenHash"], "nullsNotDistinct": false, - "name": "remote_instances_host_key", + "name": "login_tokens_tokenHash_key", "schema": "public", - "table": "remote_instances", + "table": "login_tokens", "entityType": "uniques" }, { @@ -1431,13 +1293,6 @@ "schema": "public", "table": "actors" }, - { - "value": "\"location\" = 'Local'", - "name": "local_check", - "entityType": "checks", - "schema": "public", - "table": "local_actors" - }, { "value": "\"slug\" ~ '^[a-z0-9-]{4,63}$'", "name": "instances_slug_check", @@ -1451,13 +1306,6 @@ "entityType": "checks", "schema": "public", "table": "local_instances" - }, - { - "value": "\"location\" = 'Remote'", - "name": "remote_check", - "entityType": "checks", - "schema": "public", - "table": "remote_actors" } ], "renames": [] diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index e61eb01..13bc11a 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -105,19 +105,12 @@ export const relations = defineRelations(schema, (r) => ({ optional: false, }), localActor: r.one.localActors({ - from: r.actors.id, + from: r.actors.localId, to: r.localActors.id, }), - remoteActor: r.one.remoteActors({ - from: r.actors.id, - to: r.remoteActors.id, - }), }, localActors: { - actor: r.one.actors({ from: r.localActors.id, to: r.actors.id }), - }, - remoteActors: { - actor: r.one.actors({ from: r.remoteActors.id, to: r.actors.id }), + actor: r.one.actors({ from: r.localActors.id, to: r.actors.localId }), }, })); diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 2c53369..e1ddad8 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -27,6 +27,7 @@ import { primaryKey, text, timestamp, + unique, uuid, varchar, } from "drizzle-orm/pg-core"; @@ -192,12 +193,23 @@ export const actors = pgTable( "actors", { id: uuid().primaryKey(), - location: locationEnum().notNull(), + 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), @@ -249,72 +261,17 @@ export const actors = pgTable( `, ), index("actor_instance_index").on(t.instanceId), - unique("actors_id_location_key").on(t.id, t.location), ], ); export type Actor = typeof actors.$inferSelect; export type NewActor = typeof actors.$inferInsert; -export const localActors = pgTable( - "local_actors", - { - id: uuid() - .primaryKey() - .references(() => actors.id, { onDelete: "cascade" }), - avatar: text(), - header: text(), - /** - * This `location` column is not an actually used value; - * it exists to prevent simultaneous Local/Remote registration, - * so it must be fixed as `Local`. - */ - location: locationEnum().default("Local").notNull(), - }, - (t) => [ - check("local_check", sql`${t.location} = 'Local'`), - foreignKey({ - name: "local_actor_fk", - columns: [t.id, t.location], - foreignColumns: [actors.id, actors.location], - }).onDelete("cascade"), - ], -); +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; - -export const remoteActors = pgTable( - "remote_actors", - { - id: uuid() - .primaryKey() - .references(() => actors.id, { onDelete: "cascade" }), - iriUrl: text().notNull().unique(), - inboxUrl: text().notNull(), - outboxUrl: text().notNull(), - followersUrl: text(), - followeesUrl: text(), - featuredUrl: text(), - profileUrl: text(), - avatarUrl: text(), - headerUrl: text(), - /** - * This `location` column is not an actually used value; - * it exists to prevent simultaneous Local/Remote registration, - * so it must be fixed as `Remote`. - */ - location: locationEnum().default("Remote").notNull(), - }, - (t) => [ - check("remote_check", sql`${t.location} = 'Remote'`), - foreignKey({ - name: "remote_actor_fk", - columns: [t.id, t.location], - foreignColumns: [actors.id, actors.location], - }).onDelete("cascade"), - ], -); - -export type RemoteActor = typeof remoteActors.$inferSelect; -export type NewRemoteActor = typeof remoteActors.$inferInsert;