From 8f2d0537e018b266a5647f23dc51aede7bec13bb Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 4 Aug 2026 05:52:32 +0900 Subject: [PATCH 01/15] 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 1b8a8f3..374266c 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 }), localInstance: 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 48338f5a6d85fb631a67d6a3f8ab57f2263a60fc Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 4 Aug 2026 05:53:09 +0900 Subject: [PATCH 02/15] 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 b027de3..4254908 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 9b51256..54fcbb0 100644 --- a/mise.toml +++ b/mise.toml @@ -7,6 +7,7 @@ experimental = true "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 73321d3..9153973 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", { @@ -242,6 +244,72 @@ builder.queryFields((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 b650c7fb6d59a196801ac5a3d2843f6a42b58a58 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 4 Aug 2026 14:01:51 +0900 Subject: [PATCH 03/15] 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` 로 라이선스를 추가하세요. 최소한 다음과 같은 테스트가 필요합니다. - 액터 생성 - 로컬 액터 조회 - 리모트 액터 조회 AI provenance: The contributor asked Codex to write tests for the new Actor GraphQL builder in packages/graphql/src/actor.test.ts, covering at least actor creation, local actor lookup, and remote actor lookup, and to run `mise run fmt` right after adding the file so the license header is inserted. Codex generated the whole test file: it reuses the existing withTestHarness helper to seed accounts, instances, and actors, and adds a Mutation.genActors test that creates local actors plus Actor query tests for a local actor and a remote actor. While making the tests pass, the genActors mutation query in actor.ts gained an inner join on local_instances so that actors are only generated for local instances. The human contributor reviewed the generated tests, ran the formatter, and ran the test suite locally before committing. 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 03ccf77f35aff7eec80e9db07643345bd2f507f8 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 21 Aug 2026 16:48:53 +0900 Subject: [PATCH 04/15] 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 9153973..a8c17e4 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -246,8 +246,6 @@ builder.queryFields((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 374266c..ebb6c76 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; From c05695e35390721981a4edc6bcc5d093144a8b13 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 29 Aug 2026 13:30:38 +0900 Subject: [PATCH 05/15] Remove circular import https://github.com/fedify-dev/drfed/pull/47#discussion_r3869714565 --- packages/graphql/src/account.ts | 98 +++++++++++++++++- packages/graphql/src/actor.ts | 68 ++++++++++++- packages/graphql/src/instance.ts | 169 +------------------------------ 3 files changed, 163 insertions(+), 172 deletions(-) diff --git a/packages/graphql/src/account.ts b/packages/graphql/src/account.ts index d6ec689..7a7865c 100644 --- a/packages/graphql/src/account.ts +++ b/packages/graphql/src/account.ts @@ -18,7 +18,6 @@ import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; import builder, { type DrFedObjectRef } from "./builder.ts"; -// oxlint-disable-next-line import/no-cycle import { Instance } from "./instance.ts"; /** @@ -204,3 +203,100 @@ builder.queryFields((t) => ({ type: AccountRef, }), })); + +const instanceMembersConnection = drizzleConnectionHelpers( + builder, + "instanceMembers", + { + query: { + orderBy: { created: "desc" }, + }, + select(nestedSelection) { + return { + with: { + account: nestedSelection(), + }, + where: { + accepted: { isNotNull: true }, + }, + }; + }, + resolveNode(instanceMember) { + return instanceMember.account; + }, + }, +); + +builder.drizzleObjectField("instances", "members", (t) => + t.connection( + { + type: Account, + description: "The `Account`s that belong to the `Instance`.", + select(args, ctx, nestedSelection) { + return { + with: { + instanceMembers: instanceMembersConnection.getQuery( + args, + ctx, + nestedSelection, + ), + }, + }; + }, + resolve(instance, args, ctx) { + return { + ...instanceMembersConnection.resolve( + instance.instanceMembers, + args, + ctx, + instance, + ), + totalCount() { + return ctx.db.$count( + instanceMembers, + and( + eq(instanceMembers.instanceId, instance.id), + isNotNull(instanceMembers.accepted), + ), + ); + }, + }; + }, + }, + { + fields(fb) { + return { + totalCount: fb.int({ + description: + "The total number of `Account`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`.", + }), + accepted: fb.expose("accepted", { + type: "DateTime", + nullable: true, + description: + "The date/time when the `Account` accepted membership in the `Instance`.", + }), + admin: fb.exposeBoolean("admin", { + description: + "Whether the `Account` has administrator privileges in the `Instance`.", + }), + }; + }, + }, + ), +); diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index 7a6b116..4f24b20 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -18,20 +18,20 @@ import { schema } from "@drfed/models"; import { actorTypeEnum } from "@drfed/models/schema"; +import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; 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", { +const ActorType = builder.enumType("ActorType", { values: actorTypeEnum.enumValues, }); -export const ACTOR_TYPES = actorTypeEnum.enumValues.join(" | "); +const ACTOR_TYPES = actorTypeEnum.enumValues.join(" | "); const ActorRef = builder.drizzleNode("actors", { name: "Actor", @@ -347,3 +347,65 @@ function genActor( profileUrl: templates.profile.expand(templateArgs), }; } + +const actorsConnection = drizzleConnectionHelpers(builder, "actors", { + query: { orderBy: { created: "desc" } }, +}); + +builder.drizzleObjectField("instances", "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`.", + resolve(connection) { + return connection.totalCount(); + }, + }), + }; + }, + }, + { + fields(fb) { + return { + created: fb.expose("created", { + type: "DateTime", + description: + "The date/time when the `Actor` 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`.", + }), + }; + }, + }, + ), +); diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index a8c17e4..f9b282d 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -15,16 +15,10 @@ // along with this program. If not, see . import { schema } from "@drfed/models"; -import { instanceMembers } from "@drfed/models/schema"; -import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import { DrizzleQueryError } from "drizzle-orm"; -import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; +import { eq } from "drizzle-orm/sql/expressions"; 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", { @@ -124,103 +118,6 @@ builder.drizzleObjectField(LocalInstanceRef, "instance", (t) => }), ); -const instanceMembersConnection = drizzleConnectionHelpers( - builder, - "instanceMembers", - { - query: { - orderBy: { created: "desc" }, - }, - select(nestedSelection) { - return { - with: { - account: nestedSelection(), - }, - where: { - accepted: { isNotNull: true }, - }, - }; - }, - resolveNode(instanceMember) { - return instanceMember.account; - }, - }, -); - -builder.drizzleObjectField(InstanceRef, "members", (t) => - t.connection( - { - type: Account, - description: "The `Account`s that belong to the `Instance`.", - select(args, ctx, nestedSelection) { - return { - with: { - instanceMembers: instanceMembersConnection.getQuery( - args, - ctx, - nestedSelection, - ), - }, - }; - }, - resolve(instance, args, ctx) { - return { - ...instanceMembersConnection.resolve( - instance.instanceMembers, - args, - ctx, - instance, - ), - totalCount() { - return ctx.db.$count( - instanceMembers, - and( - eq(instanceMembers.instanceId, instance.id), - isNotNull(instanceMembers.accepted), - ), - ); - }, - }; - }, - }, - { - fields(fb) { - return { - totalCount: fb.int({ - description: - "The total number of `Account`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`.", - }), - accepted: fb.expose("accepted", { - type: "DateTime", - nullable: true, - description: - "The date/time when the `Account` accepted membership in the `Instance`.", - }), - admin: fb.exposeBoolean("admin", { - description: - "Whether the `Account` has administrator privileges in the `Instance`.", - }), - }; - }, - }, - ), -); - builder.queryFields((t) => ({ localInstanceBySlug: t.drizzleField({ type: LocalInstanceRef, @@ -244,70 +141,6 @@ builder.queryFields((t) => ({ }), })); -const actorsConnection = drizzleConnectionHelpers(builder, "actors", { - query: { orderBy: { created: "desc" } }, -}); - -// oxlint-disable-next-line max-lines-per-function -builder.drizzleObjectField(InstanceRef, "actors", (t) => - t.connection( - { - type: Actor, - description: "The `Actor`s that belong to the `Instance`.", - select(args, ctx, nestedSelection) { - return { - with: { - actors: actorsConnection.getQuery(args, ctx, nestedSelection), - }, - }; - }, - resolve(instance, args, ctx) { - return { - ...actorsConnection.resolve(instance.actors, args, ctx, instance), - totalCount() { - return ctx.db.$count( - schema.actors, - eq(schema.actors.instanceId, instance.id), - ); - }, - }; - }, - }, - { - fields(fb) { - return { - totalCount: fb.int({ - description: - "The total number of `Actor`s that belong to the `Instance`." + - "Note that pending members are not counted.", - resolve(connection) { - return connection.totalCount(); - }, - }), - }; - }, - }, - { - fields(fb) { - return { - created: fb.expose("created", { - type: "DateTime", - description: - "The date/time when the `Account` was added to the `Instance`.", - }), - type: fb.expose("type", { - type: ActorType, - description: `The type of the \`Actor\`: ${ACTOR_TYPES}`, - }), - username: fb.exposeString("username", { - description: "The username of the `Actor`.", - }), - }; - }, - }, - ), -); - export const CreateInstanceErrorType = builder.enumType( "CreateInstanceErrorType", { From 7bb52da5ecc13371c6a1af1e08db61a3c8e38e41 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 29 Aug 2026 23:08:03 +0900 Subject: [PATCH 06/15] Wrapped actor types by backticks https://github.com/fedify-dev/drfed/pull/47\#discussion_r3886021834 --- packages/graphql/src/actor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index 4f24b20..ecb25b0 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -31,7 +31,9 @@ const ActorType = builder.enumType("ActorType", { values: actorTypeEnum.enumValues, }); -const ACTOR_TYPES = actorTypeEnum.enumValues.join(" | "); +const ACTOR_TYPES_DOC = actorTypeEnum.enumValues + .map((t) => `\`${t}\``) + .join(" | "); const ActorRef = builder.drizzleNode("actors", { name: "Actor", From 803c40a3747f7f112d908d79854b51cd460f99b9 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 29 Aug 2026 23:15:04 +0900 Subject: [PATCH 07/15] Resolve URLs to URL types https://github.com/fedify-dev/drfed/pull/47\#discussion_r3886022844 --- packages/graphql/src/actor.ts | 28 ++++++++++++++++++---------- packages/graphql/src/builder.ts | 5 +++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index ecb25b0..233e6d4 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -62,7 +62,7 @@ const ActorRef = builder.drizzleNode("actors", { }), type: t.expose("type", { type: ActorType, - description: `The type of the \`Actor\`: ${ACTOR_TYPES}`, + description: `The type of the \`Actor\`: ${ACTOR_TYPES_DOC}`, }), username: t.exposeString("username", { description: "The username of the `Actor`.", @@ -74,33 +74,41 @@ const ActorRef = builder.drizzleNode("actors", { nullable: true, description: "The local details of the `Actor`, or null if it is remote.", }), - inboxUrl: t.exposeString("inboxUrl", { + inboxUrl: t.expose("inboxUrl", { + type: "URL", description: "The inbox URL of the `Actor`.", }), - outboxUrl: t.exposeString("outboxUrl", { + outboxUrl: t.expose("outboxUrl", { + type: "URL", description: "The outbox URL of the `Actor`.", }), - avatarUrl: t.exposeString("avatarUrl", { + avatarUrl: t.expose("avatarUrl", { + type: "URL", description: "The avatar URL of the `Actor`.", nullable: true, }), - followersUrl: t.exposeString("followersUrl", { + followersUrl: t.expose("followersUrl", { + type: "URL", description: "The followers URL of the `Actor`.", nullable: true, }), - followeesUrl: t.exposeString("followeesUrl", { + followeesUrl: t.expose("followeesUrl", { + type: "URL", description: "The followees URL of the `Actor`.", nullable: true, }), - headerUrl: t.exposeString("headerUrl", { + headerUrl: t.expose("headerUrl", { + type: "URL", description: "The header URL of the `Actor`.", nullable: true, }), - profileUrl: t.exposeString("profileUrl", { + profileUrl: t.expose("profileUrl", { + type: "URL", description: "The profile URL of the `Actor`.", nullable: true, }), - featuredUrl: t.exposeString("featuredUrl", { + featuredUrl: t.expose("featuredUrl", { + type: "URL", description: "The featured URL of the `Actor`.", nullable: true, }), @@ -401,7 +409,7 @@ builder.drizzleObjectField("instances", "actors", (t) => }), type: fb.expose("type", { type: ActorType, - description: `The type of the \`Actor\`: ${ACTOR_TYPES}`, + description: `The type of the \`Actor\`: ${ACTOR_TYPES_DOC}`, }), username: fb.exposeString("username", { description: "The username of the `Actor`.", diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index d13edbf..d74645b 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -104,6 +104,10 @@ export interface SchemaTypes { Input: Template; Output: Template; }; + URL: { + Input: URL; + Output: string; + }; }; DefaultFieldNullability: false; DrizzleRelations: typeof relations; @@ -196,6 +200,7 @@ async function isLocalInstanceMember( } builder.addScalarType("DateTime", DateTimeResolver); +builder.addScalarType("URL", URLResolver); builder.scalarType("Email", { parseValue: (v) => normalizeEmail(String(v)), From a7d743e24a501649d438cc5f9d6cc9e7538c2e8f Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 3 Sep 2026 02:13:22 +0900 Subject: [PATCH 08/15] Add `federation` prop to `ServerContext` --- packages/drfed/package.json | 7 +- packages/drfed/src/index.ts | 27 +- packages/graphql/package.json | 1 + packages/graphql/src/builder.ts | 8 +- packages/graphql/src/federation.ts | 21 ++ packages/graphql/src/index.ts | 20 +- pnpm-lock.yaml | 570 ++++++++++++++++++++++++----- pnpm-workspace.yaml | 11 + 8 files changed, 568 insertions(+), 97 deletions(-) create mode 100644 packages/graphql/src/federation.ts diff --git a/packages/drfed/package.json b/packages/drfed/package.json index 5a440fc..d8ee4a4 100644 --- a/packages/drfed/package.json +++ b/packages/drfed/package.json @@ -70,16 +70,19 @@ "@drfed/graphql": "workspace:*", "@drfed/models": "workspace:*", "@electric-sql/pglite": "catalog:", + "@fedify/fedify": "catalog:", + "@fedify/pglite": "catalog:", + "@fedify/postgres": "catalog:", "@logtape/drizzle-orm": "catalog:", "@logtape/logtape": "catalog:", "@optique/core": "catalog:", "@optique/logtape": "catalog:", "@optique/run": "catalog:", - "@upyo/smtp": "catalog:", "@upyo/logtape": "catalog:", + "@upyo/smtp": "catalog:", "drizzle-orm": "catalog:", "graphql": "catalog:", - "pg": "catalog:", + "postgres": "^3.4.9", "srvx": "^0.11.16" } } diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 9b633cd..9e9de9a 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -20,14 +20,17 @@ import process from "node:process"; import { createYogaServer } from "@drfed/graphql"; import { schema } from "@drfed/graphql/schema"; import { migrate } from "@drfed/models"; +import { createFederation } from "@fedify/fedify"; +import { PgliteKvStore } from "@fedify/pglite"; +import { PostgresKvStore } from "@fedify/postgres"; import { configure, getConsoleSink } from "@logtape/logtape"; import { createLoggingConfig } from "@optique/logtape"; import { run } from "@optique/run"; import { SmtpTransport } from "@upyo/smtp"; import { printSchema } from "graphql"; +import postgres from "postgres"; import { serve } from "srvx"; -// oxlint-disable-next-line import/no-relative-parent-imports import metadata from "../package.json" with { type: "json" }; import type { Options, @@ -38,14 +41,20 @@ import program from "./program.ts"; import seedData from "./seed.ts"; async function runServer(options: ServerOptions) { - if (options.drizzle.migrate) { - await migrate({ credentials: options.drizzle.credentials }); - } - if (options.seed) { - await seedData(options.drizzle.db); - } + const { credentials } = options.drizzle; + if (options.drizzle.migrate) await migrate({ credentials }); + if (options.seed) await seedData(options.drizzle.db); + const kv = + "driver" in credentials + ? new PgliteKvStore(credentials.client) + : new PostgresKvStore(postgres(credentials.url)); + const federation = createFederation({ kv }); const { mailer, root } = options; - const yogaServer = createYogaServer(options.drizzle.db, { root, mailer }); + const yogaServer = createYogaServer(options.drizzle.db, { + root, + mailer, + federation, + }); const server = serve({ fetch: yogaServer.fetch.bind(yogaServer), hostname: options.address.host, @@ -56,7 +65,7 @@ async function runServer(options: ServerOptions) { if (mailer instanceof SmtpTransport) { mailer.closeAllConnections(); } - // oxlint-disable-next-line promise/catch-or-return promise/prefer-await-to-then no-magic-numbers + // oxlint-disable-next-line promise/catch-or-return promise/prefer-await-to-then server.close().then(() => process.exit(0)); } process.once("SIGINT", shutdown); diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 39c8d73..9702567 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -100,6 +100,7 @@ "dependencies": { "@drfed/models": "workspace:*", "@fedify/uri-template": "^2.3.1", + "@fedify/fedify": "catalog:", "@logtape/graphql-yoga": "catalog:", "@logtape/logtape": "catalog:", "@pothos/core": "^4.13.0", diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index d74645b..49063f9 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -21,6 +21,7 @@ import { instanceMembers, instances, } from "@drfed/models/schema"; +import type { Federation } from "@fedify/fedify"; import { Template } from "@fedify/uri-template"; import SchemaBuilder, { type ObjectRef } from "@pothos/core"; import DrizzlePlugin from "@pothos/plugin-drizzle"; @@ -30,7 +31,7 @@ import ScopeAuthPlugin from "@pothos/plugin-scope-auth"; import type { Transport } from "@upyo/core"; import { getTableConfig } from "drizzle-orm/pg-core"; import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; -import { DateTimeResolver, UUIDResolver } from "graphql-scalars"; +import { DateTimeResolver, URLResolver, UUIDResolver } from "graphql-scalars"; /** * The context data for the GraphQL server, which includes the incoming request @@ -67,6 +68,11 @@ export interface ServerContext { * Root domain. */ readonly root: string; + + /** + * The federation instance. + */ + readonly federation: Federation; } /** diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts new file mode 100644 index 0000000..504e0a8 --- /dev/null +++ b/packages/graphql/src/federation.ts @@ -0,0 +1,21 @@ +// 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 type { ServerContext } from "./builder.ts"; + +export default function buildFederation(_: ServerContext): void { + // FIXME +} diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index afd8ddd..5ebbcf7 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -15,6 +15,11 @@ // along with this program. If not, see . import type { Database } from "@drfed/models"; +import { + type Federation, + MemoryKvStore, + createFederation, +} from "@fedify/fedify"; import { getYogaLogger } from "@logtape/graphql-yoga"; import { getLogger } from "@logtape/logtape"; import type { Transport } from "@upyo/core"; @@ -27,8 +32,8 @@ import { import { hashSecret } from "./auth/hash.ts"; import type { ServerContext, UserContext } from "./builder.ts"; +import buildFederation from "./federation.ts"; import { schema } from "./schema.ts"; - /** * Options for Yoga server. */ @@ -52,6 +57,11 @@ export interface YogaServerOptions { * Root domain. */ root?: string | undefined; + + /** + * The federation instance. + */ + federation?: Federation; } /** @@ -73,6 +83,7 @@ export function createYogaServer( }, async context(ctx) { const anonymous = { db, request: ctx.request, ...options }; + buildFederation(anonymous); const accessToken = getAccessToken(ctx.request.headers); if (accessToken == null) { return anonymous; @@ -98,12 +109,15 @@ function mockTransport() { return new MockTransport(); } -const fillOptions = (opt: YogaServerOptions): Required => ({ +const fillOptions = ( + opt: YogaServerOptions, +): Omit => ({ mailer: opt?.mailer ?? mockTransport(), emailFrom: opt?.emailFrom ?? "noreply@drfed.org", // FIXME: Properly parametrize the following allowlist: origins: opt?.origins ?? new Set(["https://drfed.org"]), root: opt?.root ?? "drfed.org", + federation: opt?.federation ?? createFederation({ kv: new MemoryKvStore() }), }); const getAccessToken = (headers: Headers) => @@ -114,7 +128,7 @@ const findSession = async (accessToken: string, db: Database) => await db.query.sessions.findFirst({ where: { tokenHash: await hashSecret(accessToken), - expires: { gt: new Date(Temporal.Now.instant().toString()) }, + expires: { gt: new Date() }, }, with: { account: true }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbc7c8e..e976fae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,15 @@ catalogs: '@electric-sql/pglite': specifier: ^0.5.3 version: 0.5.3 + '@fedify/fedify': + specifier: 2.4.0-pr.1020.43+41cebe9c + version: 2.4.0-pr.1020.43 + '@fedify/pglite': + specifier: 2.4.0-pr.1020.43+41cebe9c + version: 2.4.0-pr.1020.43 + '@fedify/postgres': + specifier: 2.4.0-pr.1020.43+41cebe9c + version: 2.4.0-pr.1020.43 '@logtape/drizzle-orm': specifier: ^2.2.2 version: 2.2.2 @@ -89,6 +98,15 @@ importers: '@electric-sql/pglite': specifier: 'catalog:' version: 0.5.3 + '@fedify/fedify': + specifier: 'catalog:' + version: 2.4.0-pr.1020.43 + '@fedify/pglite': + specifier: 'catalog:' + version: 2.4.0-pr.1020.43(@electric-sql/pglite@0.5.3)(@fedify/fedify@2.4.0-pr.1020.43) + '@fedify/postgres': + specifier: 'catalog:' + version: 2.4.0-pr.1020.43(@fedify/fedify@2.4.0-pr.1020.43)(postgres@3.4.9) '@logtape/drizzle-orm': specifier: 'catalog:' version: 2.2.2(@logtape/logtape@2.3.0-dev.840) @@ -112,13 +130,13 @@ importers: version: 0.6.0-dev.263(@upyo/core@0.6.0-dev.263) drizzle-orm: specifier: 'catalog:' - version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) + version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) graphql: specifier: 'catalog:' version: 16.14.2 - pg: - specifier: 'catalog:' - version: 8.21.0 + postgres: + specifier: ^3.4.9 + version: 3.4.9 srvx: specifier: ^0.11.16 version: 0.11.16 @@ -144,6 +162,9 @@ importers: '@drfed/models': specifier: workspace:* version: link:../models + '@fedify/fedify': + specifier: 'catalog:' + version: 2.4.0-pr.1020.43 '@fedify/uri-template': specifier: ^2.3.1 version: 2.3.1 @@ -158,7 +179,7 @@ importers: version: 4.13.0(graphql@16.14.2) '@pothos/plugin-drizzle': specifier: ^0.17.4 - version: 0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0))(graphql@16.14.2) + version: 0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9))(graphql@16.14.2) '@pothos/plugin-errors': specifier: ^4.9.1 version: 4.9.1(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2) @@ -176,7 +197,7 @@ importers: version: 0.6.0-dev.263(@upyo/core@0.6.0-dev.263) drizzle-orm: specifier: 'catalog:' - version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) + version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) graphql: specifier: 'catalog:' version: 16.14.2 @@ -216,7 +237,7 @@ importers: version: 2.3.0-dev.840 drizzle-orm: specifier: 'catalog:' - version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) + version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) pg: specifier: 'catalog:' version: 8.21.0 @@ -253,10 +274,10 @@ importers: version: 1.0.0(solid-js@1.9.14) '@solidjs/start': specifier: ^2.0.0 - version: 2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(supports-color@10.2.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: 2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.0)(ioredis@5.11.1(supports-color@10.2.2))(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: 3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.0)(ioredis@5.11.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) relay-runtime: specifier: ^21.0.1 version: 21.0.1 @@ -278,7 +299,7 @@ importers: version: 20.1.1 eslint-plugin-solid: specifier: ^0.14.5 - version: 0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.0.2) + version: 0.14.5(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2) relay-compiler: specifier: ^21.0.1 version: 21.0.1 @@ -379,6 +400,9 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@clack/core@1.4.3': resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} engines: {node: '>= 20.12.0'} @@ -392,6 +416,10 @@ packages: peerDependencies: solid-js: ^1.8 + '@digitalbazaar/http-client@4.4.0': + resolution: {integrity: sha512-ODhCGmElUPmR3IR+KZmBNkRAFyjJ01rxvk2E+/qQ2h2EGPJH5k6bz3N24ympGc5+i4YCGk/ipIpmkwc0+iSmRg==} + engines: {node: '>=18.0'} + '@drizzle-team/brocli@0.11.0': resolution: {integrity: sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg==} @@ -785,10 +813,46 @@ packages: '@fastify/busboy@3.2.0': resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@fedify/fedify@2.4.0-pr.1020.43': + resolution: {integrity: sha512-uZvxgWklmBNZ3VQ6YJR7W+zZamwX8+2c1O4h/TZfjRm17ycVcFOHXNcdAFS2TjsVSQpo/axoGt25PI8Wb/+oQA==} + engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} + + '@fedify/pglite@2.4.0-pr.1020.43': + resolution: {integrity: sha512-R3meizlecC1EXHwiNgwCJ6LwtpCvqee46eK7HrCoxA/ll9+/goCgBY0QIYJdhS/EqknrYF1koSKoO8LxposnvQ==} + peerDependencies: + '@electric-sql/pglite': ^0.5.8 + '@fedify/fedify': ^2.4.0-pr.1020.43+41cebe9c + + '@fedify/postgres@2.4.0-pr.1020.43': + resolution: {integrity: sha512-EGhZXyRFf5oS4fdDdAVUxn1MkAbSxcV99AXZQ8PkH/ejpFNmgcHmsQabefpTl9wqEyg8l8t1M1C0EPvQ5N6t1Q==} + peerDependencies: + '@fedify/fedify': ^2.4.0-pr.1020.43+41cebe9c + postgres: ^3.4.7 + '@fedify/uri-template@2.3.1': resolution: {integrity: sha512-322xch1WhasP/vjH4yDPA1RnYwI6tlG72aH7fCpdFge8IC845urKEMET9LzJ2G5HfeCKAXPAcaZZEx9FXnTNrg==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} + '@fedify/uri-template@2.4.0-pr.1020.43': + resolution: {integrity: sha512-2JsU5q+pDNMuoD3ZcJLwubpZWgixwcy0H4yVY/bpz/vEEEWu+g22pa6r52rloWvn5ARJXAkMxcyHDcBlZojpBg==} + engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} + + '@fedify/vocab-runtime@2.4.0-pr.1020.43': + resolution: {integrity: sha512-97ctqwEghTbyCU24VuZ14yS9Fs+Wa2dhA9orVV/6hZgalaz8LRUv2mV5wgR2BKddfKmJG5m5i7icV3BkVVwD/w==} + engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} + + '@fedify/vocab-tools@2.4.0-pr.1020.43': + resolution: {integrity: sha512-QL6t5PT/1sTNt8zG/TtggM7XJPKy3/Mem8l+unrt/rtCTTFtK5IO+VhnJz+PXfd61HEeZY680IYt71GSntgU8g==} + engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} + + '@fedify/vocab@2.4.0-pr.1020.43': + resolution: {integrity: sha512-fcvwaGXw5BJN28BIyDXQMSr4zS5F/ZWtTN3PFqVNtqGc5B5OdWLP+dwgq6CxAGW+vcC6t+8I84vSZbUML3Jq8A==} + engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} + + '@fedify/webfinger@2.4.0-pr.1020.43': + resolution: {integrity: sha512-tEWdomxujooqZzxXgd1UKGkny+DtObN2eYAFLrQfmy2M1hxEmuyphNNB/ECOWDM9QScDE2P72lJItCDuyWDOiw==} + engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} + '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} @@ -919,6 +983,9 @@ packages: '@logtape/logtape@2.3.0-dev.840': resolution: {integrity: sha512-ozdG01RAKtEbDO9iDZDQOyUX9/4p1AcuHaxJtarDK22ngFU7vSxXyPGn9bf89pX358v2QXY+3+CqXWMlT6tiaQ==} + '@logtape/logtape@2.3.2': + resolution: {integrity: sha512-SfxHfSdDlTp6tLpYP5uezNitSCTQUB3FZ1MXDbgDBN9t9kvF+XL0rOGTIa/pJcDMy1o9FELaZ25E/Ypmsf5fNQ==} + '@logtape/testing-node@2.3.0-dev.840': resolution: {integrity: sha512-85UWXs1LCVKbL6nrcR7tPeh5/stQYwrBCkEOuUOJ0ufkTG7l6TP+eNWlCr0P0hrG4NzjjP9GhOpIamrLXbvMtg==} peerDependencies: @@ -929,12 +996,19 @@ packages: peerDependencies: '@logtape/logtape': ^2.3.0-dev.840+34e837bf + '@multiformats/base-x@4.0.1': + resolution: {integrity: sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -947,6 +1021,56 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.11.0': + resolution: {integrity: sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.7.1': + resolution: {integrity: sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/resources@2.11.0': + resolution: {integrity: sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.7.1': + resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.7.1': + resolution: {integrity: sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.11.0': + resolution: {integrity: sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.11.0': + resolution: {integrity: sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@optique/core@1.2.0': resolution: {integrity: sha512-h3gHGe8BCo5iVpOt4CcWCmTnPDYdyys0XqOCCc5ZPA2A2S+GmupMkqYNeI57+SildteuGEgBrGP+MlwRlWdzPA==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} @@ -1606,6 +1730,9 @@ packages: '@solidjs/router': optional: true + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -1627,6 +1754,9 @@ packages: '@types/braces@3.0.5': resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} + '@types/emscripten@1.41.6': + resolution: {integrity: sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -2016,6 +2146,10 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + babel-plugin-jsx-dom-expressions@0.40.7: resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} peerDependencies: @@ -2061,6 +2195,13 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + byte-encodings@1.0.11: + resolution: {integrity: sha512-+/xR2+ySc2yKGtud3DGkGSH1DNwHfRVK0KTnMhoeH36/KwG+tHQ4d9B3jxJFq7dW27YcfudkywaYJRPA2dmxzg==} + + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} @@ -2072,6 +2213,10 @@ packages: caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + canonicalize@2.1.0: + resolution: {integrity: sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==} + hasBin: true + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2200,6 +2345,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devalue@5.9.2: + resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -2380,6 +2528,9 @@ packages: error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -2709,6 +2860,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-canon@1.0.1: + resolution: {integrity: sha512-PQcj4PFOTAQxE8PgoQ4KrM0DcKWZd7S3ELOON8rmysl9I8JuFMgxu1H9v+oZsTPjjkpeS3IHPwLjr7d+gKygnw==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -2723,6 +2877,10 @@ packages: engines: {node: '>=6'} hasBin: true + jsonld@9.0.0: + resolution: {integrity: sha512-pjMIdkXfC1T2wrX9B9i2uXhGdyCmgec3qgMht+TDj+S0qX3bjWMQUfL7NeqEhuRTi8G5ESzmL9uGlST7nzSEWg==} + engines: {node: '>=18'} + kebab-case@1.0.2: resolution: {integrity: sha512-7n6wXq4gNgBELfDCpzKc+mRrZFs7D+wgfF5WRFLNAr4DA/qtr9Js8uOAVAfHhuLMfAcQ0pRKqbpjx+TcJVdE1Q==} @@ -2740,6 +2898,10 @@ packages: known-css-properties@0.30.0: resolution: {integrity: sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==} + ky@1.14.3: + resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} + engines: {node: '>=18'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2842,6 +3004,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3053,6 +3219,10 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} + postcss@8.5.25: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} @@ -3073,6 +3243,10 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3087,6 +3261,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -3096,6 +3277,10 @@ packages: radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + rdf-canonize@5.0.0: + resolution: {integrity: sha512-g8OUrgMXAR9ys/ZuJVfBr05sPPoMA7nHIVs8VEvg9QwM5W4GR2qSFEEHjsyHF1eWlBaf8Ev40WNjQFQ+nJTO3w==} + engines: {node: '>=18'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -3312,17 +3497,25 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + structured-field-values@2.0.4: + resolution: {integrity: sha512-5zpJXYLPwW3WYUD/D58tQjIBs10l3Yx64jZfcKGs/RH79E2t9Xm/b9+ydwdMNVSksnsIY+HR/2IlQmgo0AcTAg==} + style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - supports-color@10.2.2: - resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} - engines: {node: '>=18'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + temporal-polyfill@1.0.4: + resolution: {integrity: sha512-MLEU0qOD2uXlz24oINNtdLZQl8RgmMxSnRtKEGZGGetTJjlRwQJjk+VsJ4EREaUoiaTb8NJOcUiKtoAiWn9EBg==} + + temporal-spec@1.0.1: + resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==} + + temporal-utils@1.0.2: + resolution: {integrity: sha512-1B8Dl4KzrOvsNUlpoWGno2VLQlxroLjDgc5NBjVC9ax9ymdo+ezfyvhyjEMx+IVfhINr6CIG2gcnlP95iRrybQ==} + terracotta@1.1.1: resolution: {integrity: sha512-Sa6wnvkGMFmPq8N/wQb2aKkIW2eXH0LJvUOEk6QZLBEjqy+LsbzAOpDuqEfOmfA/hexssE6eQve2i1IIOeOh4g==} engines: {node: '>=10'} @@ -3424,6 +3617,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -3642,6 +3839,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -3673,20 +3873,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7(supports-color@10.2.2)': + '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3715,19 +3915,19 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color @@ -3748,9 +3948,9 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime@7.29.7': {} @@ -3761,7 +3961,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - '@babel/traverse@7.29.8(supports-color@10.2.2)': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -3769,7 +3969,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -3778,6 +3978,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@cfworker/json-schema@4.1.1': {} + '@clack/core@1.4.3': dependencies: fast-wrap-ansi: 0.2.2 @@ -3795,6 +3997,11 @@ snapshots: '@floating-ui/dom': 1.8.0 solid-js: 1.9.14 + '@digitalbazaar/http-client@4.4.0': + dependencies: + ky: 1.14.3 + undici: 6.28.0 + '@drizzle-team/brocli@0.11.0': {} '@electric-sql/pglite@0.5.3': {} @@ -4004,17 +4211,17 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': dependencies: - eslint: 9.39.4(jiti@2.7.0)(supports-color@10.2.2) + eslint: 9.39.4(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2(supports-color@10.2.2)': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4027,10 +4234,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5(supports-color@10.2.2)': + '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -4054,8 +4261,86 @@ snapshots: '@fastify/busboy@3.2.0': {} + '@fedify/fedify@2.4.0-pr.1020.43': + dependencies: + '@fedify/uri-template': 2.4.0-pr.1020.43 + '@fedify/vocab': 2.4.0-pr.1020.43 + '@fedify/vocab-runtime': 2.4.0-pr.1020.43 + '@fedify/webfinger': 2.4.0-pr.1020.43 + '@logtape/logtape': 2.3.2 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + byte-encodings: 1.0.11 + devalue: 5.9.2 + es-toolkit: 1.46.1 + json-canon: 1.0.1 + jsonld: 9.0.0 + structured-field-values: 2.0.4 + temporal-polyfill: 1.0.4 + urlpattern-polyfill: 10.1.0 + + '@fedify/pglite@2.4.0-pr.1020.43(@electric-sql/pglite@0.5.3)(@fedify/fedify@2.4.0-pr.1020.43)': + dependencies: + '@electric-sql/pglite': 0.5.3 + '@fedify/fedify': 2.4.0-pr.1020.43 + '@logtape/logtape': 2.3.2 + '@types/emscripten': 1.41.6 + temporal-polyfill: 1.0.4 + + '@fedify/postgres@2.4.0-pr.1020.43(@fedify/fedify@2.4.0-pr.1020.43)(postgres@3.4.9)': + dependencies: + '@fedify/fedify': 2.4.0-pr.1020.43 + '@logtape/logtape': 2.3.2 + postgres: 3.4.9 + temporal-polyfill: 1.0.4 + '@fedify/uri-template@2.3.1': {} + '@fedify/uri-template@2.4.0-pr.1020.43': {} + + '@fedify/vocab-runtime@2.4.0-pr.1020.43': + dependencies: + '@js-temporal/polyfill': 0.5.1 + '@logtape/logtape': 2.3.2 + '@multiformats/base-x': 4.0.1 + '@opentelemetry/api': 1.9.1 + asn1js: 3.0.10 + byte-encodings: 1.0.11 + jsonld: 9.0.0 + pkijs: 3.4.0 + + '@fedify/vocab-tools@2.4.0-pr.1020.43': + dependencies: + '@cfworker/json-schema': 4.1.1 + byte-encodings: 1.0.11 + es-toolkit: 1.46.1 + yaml: 2.9.0 + + '@fedify/vocab@2.4.0-pr.1020.43': + dependencies: + '@fedify/vocab-runtime': 2.4.0-pr.1020.43 + '@fedify/vocab-tools': 2.4.0-pr.1020.43 + '@fedify/webfinger': 2.4.0-pr.1020.43 + '@logtape/logtape': 2.3.2 + '@multiformats/base-x': 4.0.1 + '@opentelemetry/api': 1.9.1 + asn1js: 3.0.10 + es-toolkit: 1.46.1 + jsonld: 9.0.0 + pkijs: 3.4.0 + temporal-polyfill: 1.0.4 + + '@fedify/webfinger@2.4.0-pr.1020.43': + dependencies: + '@fedify/vocab-runtime': 2.4.0-pr.1020.43 + '@logtape/logtape': 2.3.2 + '@opentelemetry/api': 1.9.1 + es-toolkit: 1.46.1 + '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -4212,6 +4497,8 @@ snapshots: '@logtape/logtape@2.3.0-dev.840': {} + '@logtape/logtape@2.3.2': {} + '@logtape/testing-node@2.3.0-dev.840(@logtape/logtape@2.3.0-dev.840)': dependencies: '@logtape/logtape': 2.3.0-dev.840 @@ -4221,6 +4508,8 @@ snapshots: dependencies: '@logtape/logtape': 2.3.0-dev.840 + '@multiformats/base-x@4.0.1': {} + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -4235,6 +4524,8 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@noble/hashes@1.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4247,6 +4538,53 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@optique/core@1.2.0': {} '@optique/logtape@1.2.0(@logtape/logtape@2.3.0-dev.840)': @@ -4396,10 +4734,10 @@ snapshots: dependencies: graphql: 16.14.2 - '@pothos/plugin-drizzle@0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0))(graphql@16.14.2)': + '@pothos/plugin-drizzle@0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9))(graphql@16.14.2)': dependencies: '@pothos/core': 4.13.0(graphql@16.14.2) - drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) + drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) graphql: 16.14.2 '@pothos/plugin-errors@4.9.1(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2)': @@ -4656,10 +4994,10 @@ snapshots: dependencies: solid-js: 1.9.14 - '@solidjs/start@2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(supports-color@10.2.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@solidjs/start@2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/core': 7.29.7 + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 '@solidjs/meta': 0.29.4(solid-js@1.9.14) '@types/babel__traverse': 7.28.0 @@ -4683,7 +5021,7 @@ snapshots: srvx: 0.12.5 terracotta: 1.1.1(solid-js@1.9.14) vite: 8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vite-plugin-solid: 2.11.14(solid-js@1.9.14)(supports-color@10.2.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + vite-plugin-solid: 2.11.14(solid-js@1.9.14)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) optionalDependencies: '@solidjs/router': 1.0.0(solid-js@1.9.14) transitivePeerDependencies: @@ -4691,6 +5029,8 @@ snapshots: - crossws - supports-color + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -4723,6 +5063,8 @@ snapshots: '@types/braces@3.0.5': {} + '@types/emscripten@1.41.6': {} + '@types/estree@1.0.9': {} '@types/hast@3.0.5': @@ -4753,11 +5095,11 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/project-service@8.62.1(supports-color@10.2.2)(typescript@7.0.2)': + '@typescript-eslint/project-service@8.62.1(typescript@7.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@7.0.2) '@typescript-eslint/types': 8.62.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -4773,13 +5115,13 @@ snapshots: '@typescript-eslint/types@8.62.1': {} - '@typescript-eslint/typescript-estree@8.62.1(supports-color@10.2.2)(typescript@7.0.2)': + '@typescript-eslint/typescript-estree@8.62.1(typescript@7.0.2)': dependencies: - '@typescript-eslint/project-service': 8.62.1(supports-color@10.2.2)(typescript@7.0.2) + '@typescript-eslint/project-service': 8.62.1(typescript@7.0.2) '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@7.0.2) '@typescript-eslint/types': 8.62.1 '@typescript-eslint/visitor-keys': 8.62.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.4 tinyglobby: 0.2.17 @@ -4788,13 +5130,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.62.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.0.2)': + '@typescript-eslint/utils@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.62.1 '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/typescript-estree': 8.62.1(supports-color@10.2.2)(typescript@7.0.2) - eslint: 9.39.4(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/typescript-estree': 8.62.1(typescript@7.0.2) + eslint: 9.39.4(jiti@2.7.0) typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -5009,19 +5351,25 @@ snapshots: asap@2.0.6: {} - babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7(supports-color@10.2.2)): + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) '@babel/types': 7.29.8 html-entities: 2.3.3 parse5: 7.3.0 - babel-preset-solid@1.9.12(@babel/core@7.29.7(supports-color@10.2.2))(solid-js@1.9.14): + babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.14): dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7) optionalDependencies: solid-js: 1.9.14 @@ -5055,12 +5403,18 @@ snapshots: buffer-from@1.1.2: optional: true + byte-encodings@1.0.11: {} + + bytestreamjs@2.0.1: {} + cac@7.0.0: {} callsites@3.1.0: {} caniuse-lite@1.0.30001806: {} + canonicalize@2.1.0: {} + ccount@2.0.1: {} chalk@4.1.2: @@ -5138,11 +5492,9 @@ snapshots: optionalDependencies: '@electric-sql/pglite': 0.5.3 - debug@4.4.3(supports-color@10.2.2): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 10.2.2 deep-is@0.1.4: {} @@ -5155,6 +5507,8 @@ snapshots: detect-libc@2.1.2: {} + devalue@5.9.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -5170,11 +5524,13 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0): + drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9): optionalDependencies: '@electric-sql/pglite': 0.5.3 + '@opentelemetry/api': 1.9.1 '@types/pg': 8.20.0 pg: 8.21.0 + postgres: 3.4.9 dts-resolver@3.0.0: {} @@ -5201,6 +5557,8 @@ snapshots: dependencies: stackframe: 1.3.4 + es-toolkit@1.46.1: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -5264,10 +5622,10 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-solid@0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.0.2): + eslint-plugin-solid@0.14.5(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2): dependencies: - '@typescript-eslint/utils': 8.62.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@7.0.2) - eslint: 9.39.4(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/utils': 8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2) + eslint: 9.39.4(jiti@2.7.0) estraverse: 5.3.0 is-html: 2.0.0 kebab-case: 1.0.2 @@ -5288,14 +5646,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2): + eslint@9.39.4(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2(supports-color@10.2.2) + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5(supports-color@10.2.2) + '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -5305,7 +5663,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -5544,11 +5902,11 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@5.11.1(supports-color@10.2.2): + ioredis@5.11.1: dependencies: '@ioredis/commands': 1.10.0 cluster-key-slot: 1.1.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 denque: 2.1.0 redis-errors: 1.2.0 redis-parser: 3.0.0 @@ -5596,6 +5954,8 @@ snapshots: json-buffer@3.0.1: {} + json-canon@1.0.1: {} + json-parse-even-better-errors@2.3.1: {} json-schema-traverse@0.4.1: {} @@ -5604,6 +5964,13 @@ snapshots: json5@2.2.3: {} + jsonld@9.0.0: + dependencies: + '@digitalbazaar/http-client': 4.4.0 + canonicalize: 2.1.0 + lru-cache: 6.0.0 + rdf-canonize: 5.0.0 + kebab-case@1.0.2: {} keyv@4.5.4: @@ -5616,6 +5983,8 @@ snapshots: known-css-properties@0.30.0: {} + ky@1.14.3: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -5691,6 +6060,10 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5751,7 +6124,7 @@ snapshots: nf3@0.3.23: {} - nitro@3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.0)(ioredis@5.11.1(supports-color@10.2.2))(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + nitro@3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.0)(ioredis@5.11.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: consola: 3.4.2 crossws: 0.4.10(srvx@0.11.16) @@ -5766,7 +6139,7 @@ snapshots: rolldown: 1.2.0 srvx: 0.11.16 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3))(ioredis@5.11.1(supports-color@10.2.2))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3))(ioredis@5.11.1)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.4.2 giget: 3.3.0 @@ -5962,6 +6335,15 @@ snapshots: picomatch@4.0.5: {} + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + postcss@8.5.25: dependencies: nanoid: 3.3.16 @@ -5978,6 +6360,8 @@ snapshots: dependencies: xtend: 4.0.2 + postgres@3.4.9: {} + prelude-ls@1.2.1: {} promise@7.3.1: @@ -5988,12 +6372,22 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + quansync@1.0.0: {} queue-microtask@1.2.3: {} radix3@1.1.2: {} + rdf-canonize@5.0.0: + dependencies: + setimmediate: 1.0.5 + readdirp@5.0.0: optional: true @@ -6175,10 +6569,10 @@ snapshots: '@corvu/utils': 0.4.2(solid-js@1.9.14) solid-js: 1.9.14 - solid-refresh@0.6.3(solid-js@1.9.14)(supports-color@10.2.2): + solid-refresh@0.6.3(solid-js@1.9.14): dependencies: '@babel/generator': 7.29.8 - '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7 '@babel/types': 7.29.8 solid-js: 1.9.14 transitivePeerDependencies: @@ -6233,17 +6627,25 @@ snapshots: strip-json-comments@3.1.1: {} + structured-field-values@2.0.4: {} + style-to-object@1.0.14: dependencies: inline-style-parser: 0.2.7 - supports-color@10.2.2: - optional: true - supports-color@7.2.0: dependencies: has-flag: 4.0.0 + temporal-polyfill@1.0.4: + dependencies: + temporal-spec: 1.0.1 + temporal-utils: 1.0.2 + + temporal-spec@1.0.1: {} + + temporal-utils@1.0.2: {} + terracotta@1.1.1(solid-js@1.9.14): dependencies: solid-js: 1.9.14 @@ -6351,6 +6753,8 @@ snapshots: undici-types@8.3.0: {} + undici@6.28.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -6378,11 +6782,11 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3))(ioredis@5.11.1(supports-color@10.2.2))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3))(ioredis@5.11.1)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): optionalDependencies: chokidar: 5.0.0 db0: 0.3.4(@electric-sql/pglite@0.5.3) - ioredis: 5.11.1(supports-color@10.2.2) + ioredis: 5.11.1 lru-cache: 11.5.1 ofetch: 2.0.0-alpha.3 @@ -6430,14 +6834,14 @@ snapshots: transitivePeerDependencies: - typescript - vite-plugin-solid@2.11.14(solid-js@1.9.14)(supports-color@10.2.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + vite-plugin-solid@2.11.14(solid-js@1.9.14)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.12(@babel/core@7.29.7(supports-color@10.2.2))(solid-js@1.9.14) + babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.14) merge-anything: 5.1.7 solid-js: 1.9.14 - solid-refresh: 0.6.3(solid-js@1.9.14)(supports-color@10.2.2) + solid-refresh: 0.6.3(solid-js@1.9.14) vite: 8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) vitefu: 1.1.3(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: @@ -6481,6 +6885,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yaml@2.9.0: {} yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0e6d23a..59deea4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,9 @@ allowBuilds: catalog: "@electric-sql/pglite": ^0.5.3 + "@fedify/fedify": 2.4.0-pr.1020.43+41cebe9c + "@fedify/postgres": 2.4.0-pr.1020.43+41cebe9c + "@fedify/pglite": 2.4.0-pr.1020.43+41cebe9c "@logtape/drizzle-orm": ^2.2.2 "@logtape/graphql-yoga": 2.3.0-dev.840 "@logtape/logtape": 2.3.0-dev.840 @@ -30,6 +33,14 @@ catalog: uuid: ^14.0.1 minimumReleaseAgeExclude: + - "@fedify/fedify@2.4.0-pr.1020.43" + - "@fedify/postgres@2.4.0-pr.1020.43" + - "@fedify/uri-template@2.4.0-pr.1020.43" + - "@fedify/vocab-runtime@2.4.0-pr.1020.43" + - "@fedify/vocab-tools@2.4.0-pr.1020.43" + - "@fedify/vocab@2.4.0-pr.1020.43" + - "@fedify/webfinger@2.4.0-pr.1020.43" + - "@fedify/pglite@2.4.0-pr.1020.43" - "@logtape/graphql-yoga@2.3.0-dev.840" - "@logtape/logtape@2.3.0-dev.840" - "@logtape/testing-node@2.3.0-dev.840" From 43c732b314f404804257048f3da15c5148c16c5c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 3 Sep 2026 03:23:50 +0900 Subject: [PATCH 09/15] Wire actor URI generation through Fedify federation Replace the ad-hoc URI templates in the GraphQL package with Fedify's Federation object as the single source of truth for ActivityPub URIs. federation.ts now registers the actor, inbox, outbox, followers, following, and featured dispatchers, deriving each URL from the `Context` getters instead of string templates. The actor dispatcher scopes lookups to the requesting host so a multi-tenant DrFed instance never leaks one instance's actors under another's host, maps deleted actors to `Tombstone` objects, and resolves WebFinger handles via `mapHandle`. The inbox listener registers a catch-all logger so incoming activities are visible instead of silently discarded, since the data model has no follows/posts tables yet to persist them. actor.ts's `genActor` and the `handle` field now go through a Fedify `Context` created from `ServerContext.federation` instead of the old `uri-templates.ts` module, which is removed along with the `@fedify/uri-template` based templates. Adds `@fedify/vocab` as a direct dependency for the ActivityPub vocabulary classes used to build actor responses. Provenance: the contributor asked Claude Code to implement `buildFederation` in federation.ts using Fedify dispatcher setters (actor, inbox, outbox, followers, following, featured) and to replace the `uri-templates.ts`-based URI generation in actor.ts with Fedify's `Context` URI getters, then remove uri-templates.ts. Claude Code inspected the installed Fedify/vocab type declarations and the project's Drizzle schema to draft the dispatcher implementations, including the tombstone handling for deleted actors, the per-host actor scoping, and the inbox catch-all logger, refined after a follow-up automated review of the draft flagged missing inbox visibility and 404-vs-Tombstone handling for deleted actors. The contributor then asked Claude Code to hoist `findLocalActor` out of `buildFederation` into a module-level function that takes the database as an explicit argument, which was applied directly. The contributor verified the result by running `mise run check`, the package's `tsc --noEmit`, its test suite, and by exercising the dispatchers against a temporary PGlite-backed Federation instance (actor lookup, tombstone response for deleted actors, followers collection, and cross-host isolation). Assisted-by: Claude Code:claude-fable-5 Assisted-by: Claude Code:claude-sonnet-5 --- packages/graphql/package.json | 1 + packages/graphql/src/actor.ts | 30 ++-- packages/graphql/src/federation.ts | 216 +++++++++++++++++++++++++- packages/graphql/src/index.ts | 2 +- packages/graphql/src/uri-templates.ts | 52 ------- pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 1 + 7 files changed, 240 insertions(+), 68 deletions(-) delete mode 100644 packages/graphql/src/uri-templates.ts diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 9702567..76d8da5 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -101,6 +101,7 @@ "@drfed/models": "workspace:*", "@fedify/uri-template": "^2.3.1", "@fedify/fedify": "catalog:", + "@fedify/vocab": "catalog:", "@logtape/graphql-yoga": "catalog:", "@logtape/logtape": "catalog:", "@pothos/core": "^4.13.0", diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index 233e6d4..a74e673 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -18,6 +18,7 @@ import { schema } from "@drfed/models"; import { actorTypeEnum } from "@drfed/models/schema"; +import type { Context } from "@fedify/fedify"; import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import type { PgInsertValue } from "drizzle-orm/pg-core"; import { and, eq, gt, isNotNull } from "drizzle-orm/sql/expressions"; @@ -25,7 +26,6 @@ import { v7 as uuid } from "uuid"; import builder, { type DrFedObjectRef } from "./builder.ts"; import { Instance } from "./instance.ts"; -import templates from "./uri-templates.ts"; const ActorType = builder.enumType("ActorType", { values: actorTypeEnum.enumValues, @@ -57,8 +57,7 @@ const ActorRef = builder.drizzleNode("actors", { columns: { username: true }, with: { instance: { columns: { host: true } } }, }, - resolve: ({ instance, username }) => - templates.handle.expand({ username, host: instance.host }), + resolve: ({ instance, username }) => `@${username}@${instance.host}`, }), type: t.expose("type", { type: ActorType, @@ -291,13 +290,19 @@ builder.mutationFields((t) => ({ }; } // Create actors + const fedCtx = ctx.federation.createContext( + new URL(`https://${host}`), + undefined, + ); const localActors = await tx .insert(schema.localActors) .values(Array.from({ length: size }, () => ({ id: uuid() }))) .returning(); const createdActors = await tx .insert(schema.actors) - .values(localActors.map(({ id }) => genActor(id, instanceId, host))) + .values( + localActors.map(({ id }) => genActor(id, instanceId, fedCtx)), + ) .returning(); const actorCounts = await tx.$count( schema.actors, @@ -336,11 +341,10 @@ builder.mutationFields((t) => ({ function genActor( localId: string, instanceId: string, - host: string, + fedCtx: Context, ): PgInsertValue { const id = uuid(); const username = id; - const templateArgs = { host, id, username }; return { id, localId, @@ -348,13 +352,13 @@ function genActor( 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), + iri: fedCtx.getActorUri(id).href, + inboxUrl: fedCtx.getInboxUri(id).href, + outboxUrl: fedCtx.getOutboxUri(id).href, + followersUrl: fedCtx.getFollowersUri(id).href, + followeesUrl: fedCtx.getFollowingUri(id).href, + featuredUrl: fedCtx.getFeaturedUri(id).href, + profileUrl: new URL(`/@${username}`, fedCtx.origin).href, }; } diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 504e0a8..762e62a 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -14,8 +14,220 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import type { Database } from "@drfed/models"; +import type { Actor, Instance } from "@drfed/models/schema"; +import type { Context } from "@fedify/fedify"; +import { + Activity, + Application, + Endpoints, + Group, + Image, + Organization, + Person, + Service, + Tombstone, +} from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import { validate as validateUuid } from "uuid"; + import type { ServerContext } from "./builder.ts"; -export default function buildFederation(_: ServerContext): void { - // FIXME +/** + * The vocabulary object types that DrFed serves as actors. + */ +type ActorObject = Application | Group | Organization | Person | Service; + +type ActorProps = ConstructorParameters[0]; + +const actorConstructors: Record< + Actor["type"], + (props: ActorProps) => ActorObject +> = { + Application: (props) => new Application(props), + Group: (props) => new Group(props), + Organization: (props) => new Organization(props), + Person: (props) => new Person(props), + Service: (props) => new Service(props), +}; + +/** + * An actor record joined with the instance it belongs to. + */ +type LocalActorRecord = Actor & { readonly instance: Instance }; + +// DrFed hosts multiple instances under distinct hosts, so every dispatcher +// must only serve the actors whose instance matches the requested host. +const findLocalActor = async ( + db: Database, + fedCtx: Context, + identifier: string, +): Promise => { + if (!validateUuid(identifier)) return null; + const actor = await db.query.actors.findFirst({ + where: { id: identifier }, + with: { instance: true }, + }); + return actor == null || + actor.localId == null || + actor.instance.host !== fedCtx.host + ? null + : actor; +}; + +async function findActiveActor( + db: Database, + fedCtx: Context, + identifier: string, +): Promise { + const actor = await findLocalActor(db, fedCtx, identifier); + return actor == null || actor.deleted != null ? null : actor; +} + +/** + * Registers the ActivityPub dispatchers on the `Federation` instance. + * The registered paths define the URI layout of the federated objects, + * which makes the object URI getters (e.g. `Context.getActorUri()`) + * available. Call this exactly once per `Federation` instance; Fedify + * throws on duplicate registration. + * @param context The server context holding the `Federation` instance. + */ +export default function buildFederation( + context: Omit, +): void { + const { db, federation } = context; + + federation + .setActorDispatcher("/users/{identifier}", async (fedCtx, identifier) => { + const actor = await findLocalActor(db, fedCtx, identifier); + if (actor == null) return null; + // Deleted actors are served as `Tombstone`s (HTTP 410) so that remote + // peers purge them instead of retrying on 404. + if (actor.deleted != null) { + return new Tombstone({ id: fedCtx.getActorUri(identifier) }); + } + return toActorObject(fedCtx, identifier, actor); + }) + .mapHandle(async (fedCtx, username) => { + const instance = await db.query.instances.findFirst({ + where: { host: fedCtx.host }, + }); + if (instance == null) return null; + const actor = await db.query.actors.findFirst({ + where: { username, instanceId: instance.id }, + }); + return actor == null || actor.localId == null || actor.deleted != null + ? null + : actor.id; + }); + // FIXME: Provide actor key pairs via setKeyPairsDispatcher() once the + // data model stores signing keys. + + federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + // FIXME: Record incoming activities once the data model can store them; + // until then the catch-all below only surfaces them in the logs so that + // deliveries are not silently discarded. + .on(Activity, (_fedCtx, activity) => { + logger.debug("Received an activity: {activity}", { activity }); + }) + .onError((_fedCtx, error) => { + logger.error("An error occurred while processing an inbox: {error}", { + error, + }); + }); + + federation.setOutboxDispatcher( + "/users/{identifier}/outbox", + async (fedCtx, identifier) => + // FIXME: Return the actual activities once the data model stores them + (await findActiveActor(db, fedCtx, identifier)) == null + ? null + : { items: [] }, + ); + + federation + .setFollowersDispatcher( + "/users/{identifier}/followers", + async (fedCtx, identifier) => + // FIXME: Return the actual followers once the data model stores + // follows + (await findActiveActor(db, fedCtx, identifier)) == null + ? null + : { items: [] }, + ) + .setCounter( + async (fedCtx, identifier) => + (await findActiveActor(db, fedCtx, identifier))?.followersCount ?? null, + ); + + federation + .setFollowingDispatcher( + "/users/{identifier}/followees", + async (fedCtx, identifier) => + // FIXME: Return the actual followees once the data model stores + // follows + (await findActiveActor(db, fedCtx, identifier)) == null + ? null + : { items: [] }, + ) + .setCounter( + async (fedCtx, identifier) => + (await findActiveActor(db, fedCtx, identifier))?.followeesCount ?? null, + ); + + federation.setFeaturedDispatcher( + "/users/{identifier}/featured", + async (fedCtx, identifier) => + // FIXME: Return the actual pinned objects once the data model stores + // them + (await findActiveActor(db, fedCtx, identifier)) == null + ? null + : { items: [] }, + ); } + +// Whether a sanction is *currently* active is always determined by comparing +// against the current time (lazy expiry; no cron); see the actors table. +const isSuspended = ({ suspended, suspendedUntil }: Actor): boolean => { + const now = new Date(); + return ( + suspended != null && + suspended <= now && + (suspendedUntil == null || suspendedUntil > now) + ); +}; + +function toActorObject( + fedCtx: Context, + identifier: string, + actor: LocalActorRecord, +): ActorObject { + return actorConstructors[actor.type]({ + id: fedCtx.getActorUri(identifier), + preferredUsername: actor.username, + name: actor.name, + summary: actor.bioHtml, + url: actor.profileUrl == null ? null : new URL(actor.profileUrl), + icon: + actor.avatarUrl == null + ? null + : new Image({ url: new URL(actor.avatarUrl) }), + image: + actor.headerUrl == null + ? null + : new Image({ url: new URL(actor.headerUrl) }), + manuallyApprovesFollowers: !actor.automaticallyApprovesFollowers, + sensitive: actor.sensitive, + suspended: isSuspended(actor), + aliases: actor.aliases.map((alias) => new URL(alias)), + inbox: fedCtx.getInboxUri(identifier), + outbox: fedCtx.getOutboxUri(identifier), + followers: fedCtx.getFollowersUri(identifier), + following: fedCtx.getFollowingUri(identifier), + featured: fedCtx.getFeaturedUri(identifier), + endpoints: new Endpoints({ sharedInbox: fedCtx.getInboxUri() }), + }); +} + +const logger = getLogger(["drfed", "graphql", "federation"]); diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 5ebbcf7..bb54706 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -76,6 +76,7 @@ export function createYogaServer( _options: YogaServerOptions = {}, ): YogaServerInstance { const options = fillOptions(_options); + buildFederation({ db, ...options }); return createYoga({ cors: { origin: [...options.origins], @@ -83,7 +84,6 @@ export function createYogaServer( }, async context(ctx) { const anonymous = { db, request: ctx.request, ...options }; - buildFederation(anonymous); const accessToken = getAccessToken(ctx.request.headers); if (accessToken == null) { return anonymous; diff --git a/packages/graphql/src/uri-templates.ts b/packages/graphql/src/uri-templates.ts deleted file mode 100644 index 29e5a2a..0000000 --- a/packages/graphql/src/uri-templates.ts +++ /dev/null @@ -1,52 +0,0 @@ -// DrFed: A web-based platform for developing and debugging ActivityPub apps -// Copyright (C) 2026 DrFed team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { Template } from "@fedify/uri-template"; - -export const avatarTemplate = new Template( - "https://{host}/users/{id}/avatar/{avatar}", -); -export const featuredTemplate = new Template( - "https://{host}/users/{id}/featured", -); -export const followeesTemplate = new Template( - "https://{host}/users/{id}/followees", -); -export const followersTemplate = new Template( - "https://{host}/users/{id}/followers", -); -export const handleTemplate = new Template("@{username}@{host}"); -export const headerTemplate = new Template( - "https://{host}/users/{id}/header/{header}", -); -export const inboxTemplate = new Template("https://{host}/users/{id}/inbox"); -export const iriTemplate = new Template("https://{host}/users/{id}"); -export const outboxTemplate = new Template("https://{host}/users/{id}/outbox"); -export const profileTemplate = new Template("https://{host}/@{username}"); - -const templates = { - avatar: avatarTemplate, - featured: featuredTemplate, - followees: followeesTemplate, - followers: followersTemplate, - handle: handleTemplate, - header: headerTemplate, - inbox: inboxTemplate, - iri: iriTemplate, - outbox: outboxTemplate, - profile: profileTemplate, -}; -export default templates; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e976fae..ead5a57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: '@fedify/postgres': specifier: 2.4.0-pr.1020.43+41cebe9c version: 2.4.0-pr.1020.43 + '@fedify/vocab': + specifier: 2.4.0-pr.1020.43+41cebe9c + version: 2.4.0-pr.1020.43 '@logtape/drizzle-orm': specifier: ^2.2.2 version: 2.2.2 @@ -168,6 +171,9 @@ importers: '@fedify/uri-template': specifier: ^2.3.1 version: 2.3.1 + '@fedify/vocab': + specifier: 'catalog:' + version: 2.4.0-pr.1020.43 '@logtape/graphql-yoga': specifier: 'catalog:' version: 2.3.0-dev.840(@logtape/logtape@2.3.0-dev.840)(graphql-yoga@5.21.2(graphql@16.14.2))(graphql@16.14.2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 59deea4..e7cf1b5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,7 @@ catalog: "@fedify/fedify": 2.4.0-pr.1020.43+41cebe9c "@fedify/postgres": 2.4.0-pr.1020.43+41cebe9c "@fedify/pglite": 2.4.0-pr.1020.43+41cebe9c + "@fedify/vocab": 2.4.0-pr.1020.43+41cebe9c "@logtape/drizzle-orm": ^2.2.2 "@logtape/graphql-yoga": 2.3.0-dev.840 "@logtape/logtape": 2.3.0-dev.840 From 4952ec159b696b03f888d5b94118b3de9612898e Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 3 Sep 2026 04:47:42 +0900 Subject: [PATCH 10/15] Cleanup AI genned --- .oxlintrc.json | 1 + packages/graphql/src/actor.test.ts | 13 +++-- packages/graphql/src/federation.ts | 84 +++++++++++++--------------- packages/graphql/src/harness.test.ts | 2 +- 4 files changed, 51 insertions(+), 49 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 4254908..1ff122b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -26,6 +26,7 @@ "eslint/curly": ["error", "multi-line"], "oxc/no-optional-chaining": "off", "oxc/no-async-await": "off", + "jsdoc/require-param": "off", "eslint/eqeqeq": ["off", "smart"], "eslint/func-style": ["off"], "eslint/max-lines-per-function": "off", diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index 140a892..75873b9 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -111,7 +111,7 @@ describe("Mutation.genActors", () => { assert.equal(body.errors, undefined); assert.equal(body.data.genActors.resultType, "CreateActorsSuccess"); assert.equal(body.data.genActors.actors.length, 2); - assert.equal( + assert.ok( body.data.genActors.actors.every( (actor: { iri: unknown; @@ -121,11 +121,16 @@ describe("Mutation.genActors", () => { }) => typeof actor.uuid === "string" && typeof actor.username === "string" && - actor.iri === - `https://test-instance.drfed.org/users/${actor.uuid}` && + typeof actor.iri === "string" && typeof actor.local?.uuid === "string", ), - true, + ); + assert.deepStrictEqual( + body.data.genActors.actors.map((actor: { iri: string }) => actor.iri), + body.data.genActors.actors.map( + (actor: { uuid: string }) => + `https://test-instance.drfed.org/users/${actor.uuid}`, + ), ); const actors = await db.select().from(schema.actors); diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 762e62a..598b53b 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -56,13 +56,11 @@ const actorConstructors: Record< */ type LocalActorRecord = Actor & { readonly instance: Instance }; -// DrFed hosts multiple instances under distinct hosts, so every dispatcher -// must only serve the actors whose instance matches the requested host. -const findLocalActor = async ( +async function findLocalActor( db: Database, - fedCtx: Context, + ctx: Context, identifier: string, -): Promise => { +): Promise { if (!validateUuid(identifier)) return null; const actor = await db.query.actors.findFirst({ where: { id: identifier }, @@ -70,17 +68,17 @@ const findLocalActor = async ( }); return actor == null || actor.localId == null || - actor.instance.host !== fedCtx.host + actor.instance.host !== ctx.host ? null : actor; -}; +} async function findActiveActor( db: Database, - fedCtx: Context, + ctx: Context, identifier: string, ): Promise { - const actor = await findLocalActor(db, fedCtx, identifier); + const actor = await findLocalActor(db, ctx, identifier); return actor == null || actor.deleted != null ? null : actor; } @@ -92,25 +90,23 @@ async function findActiveActor( * throws on duplicate registration. * @param context The server context holding the `Federation` instance. */ -export default function buildFederation( - context: Omit, -): void { - const { db, federation } = context; - +export default function buildFederation< + T extends Pick, +>({ db, federation }: T): void { federation - .setActorDispatcher("/users/{identifier}", async (fedCtx, identifier) => { - const actor = await findLocalActor(db, fedCtx, identifier); + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + const actor = await findLocalActor(db, ctx, identifier); if (actor == null) return null; // Deleted actors are served as `Tombstone`s (HTTP 410) so that remote // peers purge them instead of retrying on 404. if (actor.deleted != null) { - return new Tombstone({ id: fedCtx.getActorUri(identifier) }); + return new Tombstone({ id: ctx.getActorUri(identifier) }); } - return toActorObject(fedCtx, identifier, actor); + return toActorObject(ctx, identifier, actor); }) - .mapHandle(async (fedCtx, username) => { + .mapHandle(async (ctx, username) => { const instance = await db.query.instances.findFirst({ - where: { host: fedCtx.host }, + where: { host: ctx.host }, }); if (instance == null) return null; const actor = await db.query.actors.findFirst({ @@ -128,10 +124,10 @@ export default function buildFederation( // FIXME: Record incoming activities once the data model can store them; // until then the catch-all below only surfaces them in the logs so that // deliveries are not silently discarded. - .on(Activity, (_fedCtx, activity) => { + .on(Activity, (_ctx, activity) => { logger.debug("Received an activity: {activity}", { activity }); }) - .onError((_fedCtx, error) => { + .onError((_ctx, error) => { logger.error("An error occurred while processing an inbox: {error}", { error, }); @@ -139,9 +135,9 @@ export default function buildFederation( federation.setOutboxDispatcher( "/users/{identifier}/outbox", - async (fedCtx, identifier) => + async (ctx, identifier) => // FIXME: Return the actual activities once the data model stores them - (await findActiveActor(db, fedCtx, identifier)) == null + (await findActiveActor(db, ctx, identifier)) == null ? null : { items: [] }, ); @@ -149,39 +145,39 @@ export default function buildFederation( federation .setFollowersDispatcher( "/users/{identifier}/followers", - async (fedCtx, identifier) => + async (ctx, identifier) => // FIXME: Return the actual followers once the data model stores // follows - (await findActiveActor(db, fedCtx, identifier)) == null + (await findActiveActor(db, ctx, identifier)) == null ? null : { items: [] }, ) .setCounter( - async (fedCtx, identifier) => - (await findActiveActor(db, fedCtx, identifier))?.followersCount ?? null, + async (ctx, identifier) => + (await findActiveActor(db, ctx, identifier))?.followersCount ?? null, ); federation .setFollowingDispatcher( "/users/{identifier}/followees", - async (fedCtx, identifier) => + async (ctx, identifier) => // FIXME: Return the actual followees once the data model stores // follows - (await findActiveActor(db, fedCtx, identifier)) == null + (await findActiveActor(db, ctx, identifier)) == null ? null : { items: [] }, ) .setCounter( - async (fedCtx, identifier) => - (await findActiveActor(db, fedCtx, identifier))?.followeesCount ?? null, + async (ctx, identifier) => + (await findActiveActor(db, ctx, identifier))?.followeesCount ?? null, ); federation.setFeaturedDispatcher( "/users/{identifier}/featured", - async (fedCtx, identifier) => + async (ctx, identifier) => // FIXME: Return the actual pinned objects once the data model stores // them - (await findActiveActor(db, fedCtx, identifier)) == null + (await findActiveActor(db, ctx, identifier)) == null ? null : { items: [] }, ); @@ -189,22 +185,22 @@ export default function buildFederation( // Whether a sanction is *currently* active is always determined by comparing // against the current time (lazy expiry; no cron); see the actors table. -const isSuspended = ({ suspended, suspendedUntil }: Actor): boolean => { +function isSuspended({ suspended, suspendedUntil }: Actor): boolean { const now = new Date(); return ( suspended != null && suspended <= now && (suspendedUntil == null || suspendedUntil > now) ); -}; +} function toActorObject( - fedCtx: Context, + ctx: Context, identifier: string, actor: LocalActorRecord, ): ActorObject { return actorConstructors[actor.type]({ - id: fedCtx.getActorUri(identifier), + id: ctx.getActorUri(identifier), preferredUsername: actor.username, name: actor.name, summary: actor.bioHtml, @@ -221,12 +217,12 @@ function toActorObject( sensitive: actor.sensitive, suspended: isSuspended(actor), aliases: actor.aliases.map((alias) => new URL(alias)), - inbox: fedCtx.getInboxUri(identifier), - outbox: fedCtx.getOutboxUri(identifier), - followers: fedCtx.getFollowersUri(identifier), - following: fedCtx.getFollowingUri(identifier), - featured: fedCtx.getFeaturedUri(identifier), - endpoints: new Endpoints({ sharedInbox: fedCtx.getInboxUri() }), + inbox: ctx.getInboxUri(identifier), + outbox: ctx.getOutboxUri(identifier), + followers: ctx.getFollowersUri(identifier), + following: ctx.getFollowingUri(identifier), + featured: ctx.getFeaturedUri(identifier), + endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }), }); } diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts index 48dba70..3d36a8c 100644 --- a/packages/graphql/src/harness.test.ts +++ b/packages/graphql/src/harness.test.ts @@ -24,7 +24,7 @@ import type { YogaServerInstance } from "graphql-yoga"; const logger = getLogger(["drfed", "graphql", "test"]); -const testEndpoint = "http://drfed.test/graphql"; +const testEndpoint = "https://drfed.test/graphql"; /** * The `fetch()` function exposed by the test Yoga server. From 440bef66afd20ab485cdd2a25024ca90f0f1b09c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 3 Sep 2026 05:35:30 +0900 Subject: [PATCH 11/15] Replace pg with postgres driver PostgreSQL access was split between two drivers: Drizzle used pg (node-postgres) while Fedify's PostgresKvStore used postgres (postgres-js), so a --postgres-url server opened two separate connection pools. Unify on postgres: - @drfed/models: migrate() now uses drizzle-orm/postgres-js. The PostgreSQL credentials mirror the PGlite shape: either a url with optional postgres.Options (a max: 1 client is created and closed after migrating) or an existing Sql client that is left open. - @drfed/drfed: the --postgres-url parser creates one postgres() client and shares it between Drizzle and PostgresKvStore through credentials.client. - Drop pg and @types/pg from both packages and the catalog; move postgres into the catalog. - Prune the orphaned pg-related entries from pnpm-lock.yaml, which pnpm did not remove on its own because drizzle-orm still lists pg as an optional peer. drizzle-orm/pg-core imports are the Drizzle dialect module, not the pg package, and remain. Verified with mise run check/build/test, a PGlite startup, and a PostgreSQL 17 container (migrations applied, GraphQL responding). AI assistance: Claude Code drafted the driver replacement, the credential type changes, the lockfile pruning, and this message; the result was reviewed and verified by a human. Assisted-by: Claude Code:claude-fable-5-1 --- packages/drfed/package.json | 3 +- packages/drfed/src/index.ts | 3 +- packages/drfed/src/parser.ts | 28 ++++++++++--------- packages/models/package.json | 3 +- packages/models/src/migrate.ts | 41 +++++++++++++-------------- pnpm-lock.yaml | 51 +++++++++++++++++++--------------- pnpm-workspace.yaml | 2 +- 7 files changed, 66 insertions(+), 65 deletions(-) diff --git a/packages/drfed/package.json b/packages/drfed/package.json index d8ee4a4..bcb540c 100644 --- a/packages/drfed/package.json +++ b/packages/drfed/package.json @@ -62,7 +62,6 @@ "devDependencies": { "@logtape/testing-node": "catalog:", "@types/node": "catalog:", - "@types/pg": "catalog:", "tsdown": "catalog:", "typescript": "catalog:" }, @@ -82,7 +81,7 @@ "@upyo/smtp": "catalog:", "drizzle-orm": "catalog:", "graphql": "catalog:", - "postgres": "^3.4.9", + "postgres": "catalog:", "srvx": "^0.11.16" } } diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 9e9de9a..d52f878 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -28,7 +28,6 @@ import { createLoggingConfig } from "@optique/logtape"; import { run } from "@optique/run"; import { SmtpTransport } from "@upyo/smtp"; import { printSchema } from "graphql"; -import postgres from "postgres"; import { serve } from "srvx"; import metadata from "../package.json" with { type: "json" }; @@ -47,7 +46,7 @@ async function runServer(options: ServerOptions) { const kv = "driver" in credentials ? new PgliteKvStore(credentials.client) - : new PostgresKvStore(postgres(credentials.url)); + : new PostgresKvStore(credentials.client); const federation = createFederation({ kv }); const { mailer, root } = options; const yogaServer = createYogaServer(options.drizzle.db, { diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts index b7beff0..ba66bb7 100644 --- a/packages/drfed/src/parser.ts +++ b/packages/drfed/src/parser.ts @@ -27,8 +27,9 @@ import { loggingOptions } from "@optique/logtape"; import { path } from "@optique/run/valueparser"; import { LogTapeTransport } from "@upyo/logtape"; import { SmtpTransport } from "@upyo/smtp"; -import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres"; import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; +import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; const pgliteParser = map( option( @@ -67,19 +68,20 @@ const postgresParser = map( description: message`The URL of the PostgreSQL database to connect to. Mutually exclusive with ${optionNames(["--pglite-data-path", "--data-path", "-d"])}.`, }, ), - (dbUrl) => ({ - credentials: { - url: dbUrl.href, - }, - db: drizzlePostgres({ - connection: { - connectionString: dbUrl.href, + (dbUrl) => { + const client = postgres(dbUrl.href); + return { + credentials: { + client, }, - relations, - schema, - logger: getLogger(), - }), - }), + db: drizzlePostgres({ + client, + relations, + schema, + logger: getLogger(), + }), + }; + }, ); const smtpParser = map( diff --git a/packages/models/package.json b/packages/models/package.json index 4d2ccc3..51b9ec6 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -94,7 +94,6 @@ "devDependencies": { "@logtape/testing-node": "catalog:", "@types/node": "catalog:", - "@types/pg": "catalog:", "drizzle-kit": "1.0.0-beta.22", "tsdown": "catalog:", "typescript": "catalog:" @@ -103,6 +102,6 @@ "@electric-sql/pglite": "catalog:", "@logtape/logtape": "catalog:", "drizzle-orm": "catalog:", - "pg": "catalog:" + "postgres": "catalog:" } } diff --git a/packages/models/src/migrate.ts b/packages/models/src/migrate.ts index 4d7f93b..b8c5135 100644 --- a/packages/models/src/migrate.ts +++ b/packages/models/src/migrate.ts @@ -18,25 +18,26 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { PGlite, type PGliteOptions } from "@electric-sql/pglite"; -import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres"; -import { migrate as migratePostgres } from "drizzle-orm/node-postgres/migrator"; import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; import { migrate as migratePglite } from "drizzle-orm/pglite/migrator"; -import { Pool, type PoolConfig } from "pg"; +import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"; +import { migrate as migratePostgres } from "drizzle-orm/postgres-js/migrator"; +import postgres, { type Options, type Sql } from "postgres"; export type MigrateCredentials = | PostgresMigrateCredentials | PGliteMigrateCredentials; export type PostgresMigrateCredentials = - | (Omit & { - readonly url: string; + | { readonly driver?: never; - }) - | (PoolConfig & { - readonly url?: never; + readonly url: string; + readonly options?: Omit>, "max">; + } + | { readonly driver?: never; - }); + readonly client: Sql; + }; export type PGliteMigrateCredentials = | { @@ -143,22 +144,18 @@ async function migratePostgresDatabase( credentials: PostgresMigrateCredentials, config: MigrationConfig, ): Promise { - const pool = - "url" in credentials - ? new Pool({ - ...credentials, - connectionString: credentials.url, - max: 1, - }) - : new Pool({ - ...credentials, - max: 1, - }); + const client = + "client" in credentials + ? credentials.client + : postgres(credentials.url, { ...credentials.options, max: 1 }); + const shouldCloseClient = !("client" in credentials); try { - await migratePostgres(drizzlePostgres({ client: pool }), config); + await migratePostgres(drizzlePostgres({ client }), config); } finally { - await pool.end(); + if (shouldCloseClient) { + await client.end(); + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ead5a57..adee240 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,9 +45,6 @@ catalogs: '@types/node': specifier: ^26.0.0 version: 26.0.0 - '@types/pg': - specifier: ^8.20.0 - version: 8.20.0 '@upyo/core': specifier: 0.6.0-dev.263+e633e1e6 version: 0.6.0-dev.263 @@ -66,9 +63,9 @@ catalogs: graphql: specifier: ^16.14.2 version: 16.14.2 - pg: - specifier: ^8.21.0 - version: 8.21.0 + postgres: + specifier: ^3.4.9 + version: 3.4.9 skills-npm: specifier: ^1.2.0 version: 1.2.0 @@ -138,7 +135,7 @@ importers: specifier: 'catalog:' version: 16.14.2 postgres: - specifier: ^3.4.9 + specifier: 'catalog:' version: 3.4.9 srvx: specifier: ^0.11.16 @@ -150,9 +147,6 @@ importers: '@types/node': specifier: 'catalog:' version: 26.0.0 - '@types/pg': - specifier: 'catalog:' - version: 8.20.0 tsdown: specifier: 'catalog:' version: 0.22.14(typescript@7.0.2) @@ -244,9 +238,9 @@ importers: drizzle-orm: specifier: 'catalog:' version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9) - pg: + postgres: specifier: 'catalog:' - version: 8.21.0 + version: 3.4.9 devDependencies: '@logtape/testing-node': specifier: 'catalog:' @@ -254,9 +248,6 @@ importers: '@types/node': specifier: 'catalog:' version: 26.0.0 - '@types/pg': - specifier: 'catalog:' - version: 8.20.0 drizzle-kit: specifier: 1.0.0-beta.22 version: 1.0.0-beta.22 @@ -5096,6 +5087,7 @@ snapshots: '@types/node': 26.0.0 pg-protocol: 1.14.0 pg-types: 2.2.0 + optional: true '@types/relay-runtime@20.1.1': {} @@ -6303,15 +6295,19 @@ snapshots: pg-cloudflare@1.4.0: optional: true - pg-connection-string@2.13.0: {} + pg-connection-string@2.13.0: + optional: true - pg-int8@1.0.1: {} + pg-int8@1.0.1: + optional: true pg-pool@3.14.0(pg@8.21.0): dependencies: pg: 8.21.0 + optional: true - pg-protocol@1.14.0: {} + pg-protocol@1.14.0: + optional: true pg-types@2.2.0: dependencies: @@ -6320,6 +6316,7 @@ snapshots: postgres-bytea: 1.0.1 postgres-date: 1.0.7 postgres-interval: 1.2.0 + optional: true pg@8.21.0: dependencies: @@ -6330,10 +6327,12 @@ snapshots: pgpass: 1.0.5 optionalDependencies: pg-cloudflare: 1.4.0 + optional: true pgpass@1.0.5: dependencies: split2: 4.2.0 + optional: true picocolors@1.1.1: {} @@ -6356,15 +6355,19 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: {} + postgres-array@2.0.0: + optional: true - postgres-bytea@1.0.1: {} + postgres-bytea@1.0.1: + optional: true - postgres-date@1.0.7: {} + postgres-date@1.0.7: + optional: true postgres-interval@1.2.0: dependencies: xtend: 4.0.2 + optional: true postgres@3.4.9: {} @@ -6609,7 +6612,8 @@ snapshots: space-separated-tokens@2.0.2: {} - split2@4.2.0: {} + split2@4.2.0: + optional: true sprintf-js@1.0.3: {} @@ -6887,7 +6891,8 @@ snapshots: xdg-basedir@5.1.0: {} - xtend@4.0.2: {} + xtend@4.0.2: + optional: true yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e7cf1b5..512da5c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,7 +20,6 @@ catalog: "@optique/logtape": ^1.2.0 "@optique/run": ^1.2.0 "@types/node": ^26.0.0 - "@types/pg": ^8.20.0 "@upyo/core": 0.6.0-dev.263+e633e1e6 "@upyo/logtape": 0.6.0-dev.263+e633e1e6 "@upyo/mock": 0.6.0-dev.263+e633e1e6 @@ -28,6 +27,7 @@ catalog: drizzle-orm: 1.0.0-beta.22 graphql: ^16.14.2 pg: ^8.21.0 + postgres: ^3.4.9 skills-npm: ^1.2.0 tsdown: ^0.22.14 typescript: ^7.0.2 From 337568aec62e145d76773aadeee1cdba439a67d7 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 4 Sep 2026 16:10:47 +0900 Subject: [PATCH 12/15] Build Federation with dispatchers before Yoga server createYogaServer() used to register the ActivityPub dispatchers on the Federation instance it received, so calling it twice with the same instance crashed with a RouterError from Fedify's duplicate-route check. The constraint was not documented on the option either. Move the registration into the Federation factory itself: - packages/graphql/src/federation.ts now exports buildFederation(), which registers every dispatcher and listener on a fresh Fedify FederationBuilder, and a default createFederation() that builds the Federation from that builder with the given options. - createYogaServer() takes the built Federation as its second positional argument and only stores it in the resolver context. The federation field is removed from YogaServerOptions, which is optional again. - @drfed/graphql exposes the ./federation subpath so that @drfed/drfed can build the Federation at startup. - The test harness builds a Federation with MemoryKvStore and exposes it; federation.test.ts checks the URI layout, that one builder yields independent instances, and that createYogaServer() no longer mutates the instance it is given. This commit was produced with AI assistance. The approach (moving the dispatcher registration to the Federation factory and using Fedify's FederationBuilder) and the second-positional-argument signature were chosen by the human author; the assistant wrote the code, tests, and documentation updates, and verified them with the mise check, build, and test tasks and a manual server start. Assisted-by: Claude Code:claude-fable-5-1 --- packages/drfed/src/index.ts | 7 +-- packages/graphql/README.md | 18 ++++-- packages/graphql/package.json | 5 ++ packages/graphql/src/federation.test.ts | 78 +++++++++++++++++++++++++ packages/graphql/src/federation.ts | 56 ++++++++++++------ packages/graphql/src/harness.test.ts | 11 +++- packages/graphql/src/index.ts | 32 +++++----- 7 files changed, 160 insertions(+), 47 deletions(-) create mode 100644 packages/graphql/src/federation.test.ts diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index d52f878..84fcc04 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -18,9 +18,9 @@ import { writeFile } from "node:fs/promises"; import process from "node:process"; import { createYogaServer } from "@drfed/graphql"; +import createFederation from "@drfed/graphql/federation"; import { schema } from "@drfed/graphql/schema"; import { migrate } from "@drfed/models"; -import { createFederation } from "@fedify/fedify"; import { PgliteKvStore } from "@fedify/pglite"; import { PostgresKvStore } from "@fedify/postgres"; import { configure, getConsoleSink } from "@logtape/logtape"; @@ -47,12 +47,11 @@ async function runServer(options: ServerOptions) { "driver" in credentials ? new PgliteKvStore(credentials.client) : new PostgresKvStore(credentials.client); - const federation = createFederation({ kv }); + const federation = await createFederation(options.drizzle.db, { kv }); const { mailer, root } = options; - const yogaServer = createYogaServer(options.drizzle.db, { + const yogaServer = createYogaServer(options.drizzle.db, federation, { root, mailer, - federation, }); const server = serve({ fetch: yogaServer.fetch.bind(yogaServer), diff --git a/packages/graphql/README.md b/packages/graphql/README.md index f41999c..1424e39 100644 --- a/packages/graphql/README.md +++ b/packages/graphql/README.md @@ -24,10 +24,18 @@ Usage ~~~~ ts import { createYogaServer } from "@drfed/graphql"; - -const yoga = createYogaServer(db); -serve({ fetch: yoga.fetch.bind(yoga) }); +import createFederation from "@drfed/graphql/federation"; + +const federation = await createFederation(db, { kv }); +const yoga = createYogaServer(db, federation); +serve({ + fetch: (request) => + federation.fetch(request, { onNotFound: yoga.fetch, contextData: undefined }), +}); ~~~~ -`createYogaServer` accepts a Drizzle database instance and returns a -GraphQL Yoga server ready to handle HTTP requests. +`createFederation` builds a Fedify `Federation` with every DrFed dispatcher +registered. `createYogaServer` accepts a Drizzle database instance, that +federation, and optional server options, and returns a GraphQL Yoga server +ready to handle HTTP requests. The federation is stored in the resolver +context as is; `createYogaServer` never registers anything on it. diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 76d8da5..ce60ce6 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -58,6 +58,10 @@ "types": "./dist/builder.d.mts", "default": "./dist/builder.mjs" }, + "./federation": { + "types": "./dist/federation.d.mts", + "default": "./dist/federation.mjs" + }, "./instance": { "types": "./dist/instance.d.mts", "default": "./dist/instance.mjs" @@ -77,6 +81,7 @@ "src/account.ts", "src/actor.ts", "src/builder.ts", + "src/federation.ts", "src/instance.ts", "src/schema.ts" ], diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts new file mode 100644 index 0000000..ce039d8 --- /dev/null +++ b/packages/graphql/src/federation.test.ts @@ -0,0 +1,78 @@ +// 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 assert from "node:assert/strict"; + +import { createYogaServer } from "@drfed/graphql"; +import createFederation, { buildFederation } from "@drfed/graphql/federation"; +import { MemoryKvStore } from "@fedify/fedify"; +import { describe, it } from "@logtape/testing-node/autoload"; + +import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts"; + +const origin = new URL("https://drfed.test"); + +describe("createFederation()", () => { + it("registers the actor URI layout", async () => { + await withTemporaryDatabase(async (db) => { + const federation = await createFederation(db, { + kv: new MemoryKvStore(), + }); + const ctx = federation.createContext(origin, undefined); + assert.equal( + ctx.getActorUri("identifier").href, + "https://drfed.test/users/identifier", + ); + assert.equal( + ctx.getInboxUri("identifier").href, + "https://drfed.test/users/identifier/inbox", + ); + assert.equal(ctx.getInboxUri().href, "https://drfed.test/inbox"); + assert.equal( + ctx.getOutboxUri("identifier").href, + "https://drfed.test/users/identifier/outbox", + ); + assert.equal( + ctx.getFollowersUri("identifier").href, + "https://drfed.test/users/identifier/followers", + ); + assert.equal( + ctx.getFollowingUri("identifier").href, + "https://drfed.test/users/identifier/followees", + ); + assert.equal( + ctx.getFeaturedUri("identifier").href, + "https://drfed.test/users/identifier/featured", + ); + }); + }); + + it("builds independent instances from one builder", async () => { + await withTemporaryDatabase(async (db) => { + const builder = buildFederation(db); + const first = await builder.build({ kv: new MemoryKvStore() }); + const second = await builder.build({ kv: new MemoryKvStore() }); + assert.notEqual(first, second); + }); + }); +}); + +describe("createYogaServer()", () => { + it("does not mutate the federation instance", async () => { + await withTestHarness(({ db, mailer, federation }) => { + assert.doesNotThrow(() => createYogaServer(db, federation, { mailer })); + }); + }); +}); diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 598b53b..7285ec8 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -16,7 +16,13 @@ import type { Database } from "@drfed/models"; import type { Actor, Instance } from "@drfed/models/schema"; -import type { Context } from "@fedify/fedify"; +import { + type Context, + type Federation, + type FederationBuilder, + type FederationOptions, + createFederationBuilder, +} from "@fedify/fedify"; import { Activity, Application, @@ -31,8 +37,6 @@ import { import { getLogger } from "@logtape/logtape"; import { validate as validateUuid } from "uuid"; -import type { ServerContext } from "./builder.ts"; - /** * The vocabulary object types that DrFed serves as actors. */ @@ -83,17 +87,16 @@ async function findActiveActor( } /** - * Registers the ActivityPub dispatchers on the `Federation` instance. - * The registered paths define the URI layout of the federated objects, - * which makes the object URI getters (e.g. `Context.getActorUri()`) - * available. Call this exactly once per `Federation` instance; Fedify - * throws on duplicate registration. - * @param context The server context holding the `Federation` instance. + * Creates a `FederationBuilder` with every ActivityPub dispatcher and + * listener that DrFed serves registered on it. The registered paths define + * the URI layout of the federated objects, which makes the object URI getters + * (e.g. `Context.getActorUri()`) available once the builder is built. + * @param db The database to resolve local actors from. + * @returns A builder that has not been built yet. */ -export default function buildFederation< - T extends Pick, ->({ db, federation }: T): void { - federation +export function buildFederation(db: Database): FederationBuilder { + const builder = createFederationBuilder(); + builder .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { const actor = await findLocalActor(db, ctx, identifier); if (actor == null) return null; @@ -119,7 +122,7 @@ export default function buildFederation< // FIXME: Provide actor key pairs via setKeyPairsDispatcher() once the // data model stores signing keys. - federation + builder .setInboxListeners("/users/{identifier}/inbox", "/inbox") // FIXME: Record incoming activities once the data model can store them; // until then the catch-all below only surfaces them in the logs so that @@ -133,7 +136,7 @@ export default function buildFederation< }); }); - federation.setOutboxDispatcher( + builder.setOutboxDispatcher( "/users/{identifier}/outbox", async (ctx, identifier) => // FIXME: Return the actual activities once the data model stores them @@ -142,7 +145,7 @@ export default function buildFederation< : { items: [] }, ); - federation + builder .setFollowersDispatcher( "/users/{identifier}/followers", async (ctx, identifier) => @@ -157,7 +160,7 @@ export default function buildFederation< (await findActiveActor(db, ctx, identifier))?.followersCount ?? null, ); - federation + builder .setFollowingDispatcher( "/users/{identifier}/followees", async (ctx, identifier) => @@ -172,7 +175,7 @@ export default function buildFederation< (await findActiveActor(db, ctx, identifier))?.followeesCount ?? null, ); - federation.setFeaturedDispatcher( + builder.setFeaturedDispatcher( "/users/{identifier}/featured", async (ctx, identifier) => // FIXME: Return the actual pinned objects once the data model stores @@ -181,6 +184,23 @@ export default function buildFederation< ? null : { items: [] }, ); + return builder; +} + +/** + * Creates a `Federation` instance with every DrFed dispatcher registered. + * Every registration happens on a fresh builder inside this function, so the + * returned instance is complete and must not be mutated further. + * @param db The database to resolve local actors from. + * @param options Options for the underlying Fedify `Federation`, such as + * the `kv` store. + * @returns The built `Federation` instance. + */ +export default async function createFederation( + db: Database, + options: FederationOptions, +): Promise> { + return await buildFederation(db).build(options); } // Whether a sanction is *currently* active is always determined by comparing diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts index 3d36a8c..7d1c619 100644 --- a/packages/graphql/src/harness.test.ts +++ b/packages/graphql/src/harness.test.ts @@ -15,8 +15,10 @@ // along with this program. If not, see . import { createYogaServer } from "@drfed/graphql"; import type { ServerContext, UserContext } from "@drfed/graphql/builder"; +import createFederation from "@drfed/graphql/federation"; import { type Database, migrate, relations, schema } from "@drfed/models"; import { PGlite } from "@electric-sql/pglite"; +import { type Federation, MemoryKvStore } from "@fedify/fedify"; import { getLogger } from "@logtape/logtape"; import { MockTransport } from "@upyo/mock"; import { drizzle } from "drizzle-orm/pglite"; @@ -65,6 +67,11 @@ export interface TestHarness { */ readonly mailer: MockTransport; + /** + * The federation instance the Yoga server was created with. + */ + readonly federation: Federation; + /** * The test server's `fetch()` function, bound to the Yoga server instance. */ @@ -162,12 +169,14 @@ export async function withTestHarness( ): Promise> { return await withTemporaryDatabase(async (db) => { const mailer = new MockTransport(); - const yoga = createYogaServer(db, { mailer }); + const federation = await createFederation(db, { kv: new MemoryKvStore() }); + const yoga = createYogaServer(db, federation, { mailer }); const fetch: TestFetch = yoga.fetch.bind(yoga); const harness: TestHarness = { db, mailer, + federation, fetch, yoga, async post(body, init) { diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index bb54706..21265a5 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -15,11 +15,7 @@ // along with this program. If not, see . import type { Database } from "@drfed/models"; -import { - type Federation, - MemoryKvStore, - createFederation, -} from "@fedify/fedify"; +import type { Federation } from "@fedify/fedify"; import { getYogaLogger } from "@logtape/graphql-yoga"; import { getLogger } from "@logtape/logtape"; import type { Transport } from "@upyo/core"; @@ -32,7 +28,6 @@ import { import { hashSecret } from "./auth/hash.ts"; import type { ServerContext, UserContext } from "./builder.ts"; -import buildFederation from "./federation.ts"; import { schema } from "./schema.ts"; /** * Options for Yoga server. @@ -57,26 +52,26 @@ export interface YogaServerOptions { * Root domain. */ root?: string | undefined; - - /** - * The federation instance. - */ - federation?: Federation; } /** * Creates a Yoga server instance with the provided schema and context. * @param {Database} db The database instance. + * @param {Federation} federation The federation instance. It must + * already have every dispatcher registered (see `createFederation()` + * in `@drfed/graphql/federation`); this function only stores it in + * the resolver context and never mutates it, so the same instance can + * be shared by several servers. * @param {YogaServerOptions} _options Options for server. * @returns A `YogaServerInstance` configured with the schema and context for * handling GraphQL requests. */ export function createYogaServer( db: Database, + federation: Federation, _options: YogaServerOptions = {}, ): YogaServerInstance { - const options = fillOptions(_options); - buildFederation({ db, ...options }); + const options = { ...fillOptions(_options), federation }; return createYoga({ cors: { origin: [...options.origins], @@ -111,13 +106,12 @@ function mockTransport() { const fillOptions = ( opt: YogaServerOptions, -): Omit => ({ - mailer: opt?.mailer ?? mockTransport(), - emailFrom: opt?.emailFrom ?? "noreply@drfed.org", +): Omit => ({ + mailer: opt.mailer ?? mockTransport(), + emailFrom: opt.emailFrom ?? "noreply@drfed.org", // FIXME: Properly parametrize the following allowlist: - origins: opt?.origins ?? new Set(["https://drfed.org"]), - root: opt?.root ?? "drfed.org", - federation: opt?.federation ?? createFederation({ kv: new MemoryKvStore() }), + origins: opt.origins ?? new Set(["https://drfed.org"]), + root: opt.root ?? "drfed.org", }); const getAccessToken = (headers: Headers) => From d51f8c447c3e3941080fd0446e0cc7df69abfadd Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 4 Sep 2026 17:19:50 +0900 Subject: [PATCH 13/15] Address review findings on actor generation and federation Apply the fixes from an AI-assisted code review of the create-actor branch (https://github.com/fedify-dev/drfed/pull/47). - Mount the Fedify federation in front of the Yoga server. The srvx fetch handler now calls federation.fetch() and falls back to Yoga via onNotFound and onNotAcceptable, so the actor, WebFinger, inbox, and collection dispatchers are reachable over HTTP instead of only serving as URI builders. - Close the database client (PGlite or postgres.js) during shutdown before exiting the process. - Serialize genActors per instance by locking the local_instances row with SELECT ... FOR UPDATE before counting actors. This closes the race where two concurrent calls both passed the maxActors check under READ COMMITTED. The post-insert recount, tx.rollback(), the outer mutable state, and the string-matched catch block are gone; every error path now returns a result object directly from the transaction callback. - Derive the instance host from local_instances.slug and ctx.root rather than reading instances.host. - Generate one UUID per actor and reuse it for local_actors.id, actors.id, actors.localId, and the placeholder username, dropping the .returning() round trip on the local_actors insert. - Push the local-actor and host predicates into the Drizzle relational where clause in findLocalActor and mapHandle, replacing the full-row load plus JS filtering and the two-step instance/actor lookup. - Import hashSecret from auth/hash.ts in actor.test.ts and instance.test.ts instead of keeping duplicate copies. Provenance: the contributor asked Claude Code to review the branch against the pull request comments and the overall diff. Claude Code produced the review findings (unmounted federation routes, the maxActors race, the unclosed database client, the duplicated lookups, and the duplicated test helper) and drafted this commit message. The contributor implemented every change in this commit by hand and verified them with mise run check and the package test suites. Assisted-by: Claude Code:claude-fable-5-1 --- packages/drfed/src/index.ts | 14 ++- packages/graphql/src/actor.test.ts | 7 +- packages/graphql/src/actor.ts | 157 +++++++++--------------- packages/graphql/src/federation.test.ts | 1 + packages/graphql/src/federation.ts | 41 +++---- packages/graphql/src/index.ts | 4 +- packages/graphql/src/instance.test.ts | 14 +-- 7 files changed, 95 insertions(+), 143 deletions(-) diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 84fcc04..00242d8 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -54,7 +54,12 @@ async function runServer(options: ServerOptions) { mailer, }); const server = serve({ - fetch: yogaServer.fetch.bind(yogaServer), + fetch: (req) => + federation.fetch(req, { + onNotFound: yogaServer.fetch, + onNotAcceptable: yogaServer.fetch, + contextData: undefined, + }), hostname: options.address.host, manual: true, port: options.address.port, @@ -64,7 +69,12 @@ async function runServer(options: ServerOptions) { mailer.closeAllConnections(); } // oxlint-disable-next-line promise/catch-or-return promise/prefer-await-to-then - server.close().then(() => process.exit(0)); + server.close().then(async () => { + await ("driver" in credentials + ? credentials.client.close() + : credentials.client.end()); + process.exit(0); + }); } process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index 75873b9..eb0c498 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -21,6 +21,7 @@ import assert from "node:assert/strict"; import { type Database, schema } from "@drfed/models"; import { describe, it } from "@logtape/testing-node/autoload"; +import { hashSecret } from "./auth/hash.ts"; import { withTestHarness } from "./harness.test.ts"; const accepted = new Date("2026-08-04T00:00:00.000Z"); @@ -330,9 +331,3 @@ async function seedRemoteActor(db: Database): Promise { created, }); } - -async function hashSecret(raw: string): Promise { - return new Uint8Array( - await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw)), - ).toHex(); -} diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index a74e673..9f51652 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -208,11 +208,6 @@ const CreateActorsResult = builder.unionType("CreateActorsResult", { }, }); -interface SelectedInstance { - host: string; - maxActors: number; -} - builder.mutationFields((t) => ({ genActors: t.field({ type: CreateActorsResult, @@ -244,112 +239,82 @@ builder.mutationFields((t) => ({ } const { account } = ctx; - let instance: SelectedInstance | undefined; - let tooManyActors = false; - try { - return await ctx.db.transaction(async (tx) => { - // Find the instance that the account is included - [instance] = await tx - .select({ - host: schema.instances.host, - maxActors: schema.localInstances.maxActors, - }) - .from(schema.instanceMembers) - .innerJoin( - schema.instances, - eq(schema.instanceMembers.instanceId, schema.instances.id), - ) - .innerJoin( - schema.localInstances, - eq(schema.instances.localId, schema.localInstances.id), - ) - .where( - and( - eq(schema.instanceMembers.accountId, account.id), - gt(schema.localInstances.expires, new Date()), - eq(schema.instances.id, instanceId), - isNotNull(schema.instanceMembers.accepted), - ), - ) - .limit(1); - if (instance == null) throw new Error(INSTANCE_NOT_FOUND); - const { host, maxActors } = instance; - const currActors = await tx.$count( - schema.actors, - eq(schema.actors.instanceId, instanceId), - ); - - if (size + currActors > maxActors) { - return { - type: TOO_MANY_ACTORS, - message: `${size} is too big. The maximum number of actors of ${ - host - } is ${maxActors} and the current number of actors is ${ - currActors - }.`, - }; - } - // Create actors - const fedCtx = ctx.federation.createContext( - new URL(`https://${host}`), - undefined, - ); - const localActors = await tx - .insert(schema.localActors) - .values(Array.from({ length: size }, () => ({ id: uuid() }))) - .returning(); - const createdActors = await tx - .insert(schema.actors) - .values( - localActors.map(({ id }) => genActor(id, instanceId, fedCtx)), - ) - .returning(); - const actorCounts = await tx.$count( - schema.actors, - eq(schema.actors.instanceId, instanceId), - ); - if (actorCounts > maxActors) { - tooManyActors = true; - tx.rollback(); - } - return { actors: createdActors }; - }); - } catch (e) { + return await ctx.db.transaction(async (tx) => { + // Find the instance that the account is included + const [instance] = await tx + .select({ + slug: schema.localInstances.slug, + maxActors: schema.localInstances.maxActors, + }) + .from(schema.instanceMembers) + .for("update", { of: schema.localInstances }) + .innerJoin( + schema.instances, + eq(schema.instanceMembers.instanceId, schema.instances.id), + ) + .innerJoin( + schema.localInstances, + eq(schema.instances.localId, schema.localInstances.id), + ) + .where( + and( + eq(schema.instanceMembers.accountId, account.id), + gt(schema.localInstances.expires, new Date()), + eq(schema.instances.id, instanceId), + isNotNull(schema.instanceMembers.accepted), + ), + ) + .limit(1); if (instance == null) { - 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: INSTANCE_NOT_FOUND, + message: "Can't find the instance.", + }; + } + const { slug, maxActors } = instance; + const host = `${slug}.${ctx.root}`; + const currActors = await tx.$count( + schema.actors, + eq(schema.actors.instanceId, instanceId), + ); + + if (size + currActors > maxActors) { return { type: TOO_MANY_ACTORS, - message: `${ - instance.host - } instance reached the maximum number of actors (${ - instance.maxActors - })`, + message: `${size} is too big. The maximum number of actors of ${ + host + } is ${maxActors} and the current number of actors is ${ + currActors + }.`, }; } - throw e; - } + // Create actors + const fedCtx = ctx.federation.createContext( + new URL(`https://${host}`), + undefined, + ); + const ids = Array.from({ length: size }, () => ({ id: uuid() })); + await tx.insert(schema.localActors).values(ids); + const createdActors = await tx + .insert(schema.actors) + .values(ids.map(({ id }) => genActor(id, instanceId, fedCtx))) + .returning(); + return { actors: createdActors }; + }); }, }), })); function genActor( - localId: string, + id: string, instanceId: string, fedCtx: Context, ): PgInsertValue { - const id = uuid(); - const username = id; return { id, - localId, + localId: id, // FIXME: Generate handle using Faker.js or something - username, + username: id, instanceId, type: "Person", iri: fedCtx.getActorUri(id).href, @@ -358,7 +323,7 @@ function genActor( followersUrl: fedCtx.getFollowersUri(id).href, followeesUrl: fedCtx.getFollowingUri(id).href, featuredUrl: fedCtx.getFeaturedUri(id).href, - profileUrl: new URL(`/@${username}`, fedCtx.origin).href, + profileUrl: new URL(`/@${id}`, fedCtx.origin).href, }; } diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index ce039d8..7c964b9 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -13,6 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . + import assert from "node:assert/strict"; import { createYogaServer } from "@drfed/graphql"; diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 7285ec8..7c59ff1 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . import type { Database } from "@drfed/models"; -import type { Actor, Instance } from "@drfed/models/schema"; +import type { Actor } from "@drfed/models/schema"; import { type Context, type Federation, @@ -55,33 +55,27 @@ const actorConstructors: Record< Service: (props) => new Service(props), }; -/** - * An actor record joined with the instance it belongs to. - */ -type LocalActorRecord = Actor & { readonly instance: Instance }; - async function findLocalActor( db: Database, ctx: Context, identifier: string, -): Promise { +): Promise { if (!validateUuid(identifier)) return null; const actor = await db.query.actors.findFirst({ - where: { id: identifier }, - with: { instance: true }, + where: { + id: identifier, + localId: { isNotNull: true }, + instance: { host: ctx.host }, + }, }); - return actor == null || - actor.localId == null || - actor.instance.host !== ctx.host - ? null - : actor; + return actor ?? null; } async function findActiveActor( db: Database, ctx: Context, identifier: string, -): Promise { +): Promise { const actor = await findLocalActor(db, ctx, identifier); return actor == null || actor.deleted != null ? null : actor; } @@ -108,16 +102,15 @@ export function buildFederation(db: Database): FederationBuilder { return toActorObject(ctx, identifier, actor); }) .mapHandle(async (ctx, username) => { - const instance = await db.query.instances.findFirst({ - where: { host: ctx.host }, - }); - if (instance == null) return null; const actor = await db.query.actors.findFirst({ - where: { username, instanceId: instance.id }, + where: { + username, + localId: { isNotNull: true }, + instance: { host: ctx.host }, + deleted: { isNull: true }, + }, }); - return actor == null || actor.localId == null || actor.deleted != null - ? null - : actor.id; + return actor?.id ?? null; }); // FIXME: Provide actor key pairs via setKeyPairsDispatcher() once the // data model stores signing keys. @@ -217,7 +210,7 @@ function isSuspended({ suspended, suspendedUntil }: Actor): boolean { function toActorObject( ctx: Context, identifier: string, - actor: LocalActorRecord, + actor: Actor, ): ActorObject { return actorConstructors[actor.type]({ id: ctx.getActorUri(identifier), diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 21265a5..4fff0de 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -71,14 +71,14 @@ export function createYogaServer( federation: Federation, _options: YogaServerOptions = {}, ): YogaServerInstance { - const options = { ...fillOptions(_options), federation }; + const options = fillOptions(_options); return createYoga({ cors: { origin: [...options.origins], credentials: true, }, async context(ctx) { - const anonymous = { db, request: ctx.request, ...options }; + const anonymous = { db, federation, request: ctx.request, ...options }; const accessToken = getAccessToken(ctx.request.headers); if (accessToken == null) { return anonymous; diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index 59977d3..db2d9ab 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -21,6 +21,7 @@ import { type Database, schema } from "@drfed/models"; import { describe, it } from "@logtape/testing-node/autoload"; import { DrizzleQueryError } from "drizzle-orm"; +import { hashSecret } from "./auth/hash.ts"; import { withTestHarness } from "./harness.test.ts"; const accepted = new Date("2026-06-24T00:00:00.000Z"); @@ -904,19 +905,6 @@ async function createSession( return { headers: { authorization: `Bearer ${token}` } }; } -/** - * Computes the SHA-256 hex digest the server stores for a bearer token, - * mirroring `hashSecret` in *auth/hash.ts*. - * - * @param raw The raw access token. - * @returns The lowercase hex-encoded SHA-256 digest. - */ -async function hashSecret(raw: string): Promise { - return new Uint8Array( - await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw)), - ).toHex(); -} - async function seedInstanceMembers(db: Database): Promise { await db.insert(schema.accounts).values([ { From 6abdea51d2bb60fc52c2c751b5fe6e0498b5bdcd Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 5 Sep 2026 18:33:26 +0900 Subject: [PATCH 14/15] Rename genActors mutation to generateActors Code review objected to abbreviated identifiers. Rename the `Mutation.genActors` field to `Mutation.generateActors`, the `genActor` helper to `generateActor`, and update the test's operation name, query constant, and assertions to match. This is a breaking change to the GraphQL schema. No frontend code referenced the old field name. The rename was applied by a subagent and reviewed by the assistant, which also caught the remaining `GenActors` operation name in the test. Assisted-by: Claude Code:claude-fable-5-1 --- packages/graphql/src/actor.test.ts | 22 ++++++++++++---------- packages/graphql/src/actor.ts | 6 +++--- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index eb0c498..9b3d86a 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -37,9 +37,9 @@ 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) { +const generateActorsMutation = ` + mutation GenerateActors($instance: ID!, $size: Int!) { + generateActors(instance: $instance, size: $size) { resultType: __typename ... on CreateActorsSuccess { actors { @@ -91,14 +91,14 @@ const actorQuery = ` } `; -describe("Mutation.genActors", () => { +describe("Mutation.generateActors", () => { it("creates local actors", async () => { await withTestHarness(async ({ db, post }) => { const auth = await seedAuthenticatedLocalInstance(db); const response = await post( { - query: genActorsMutation, + query: generateActorsMutation, variables: { instance: globalId("Instance", localInstanceId), size: 2, @@ -110,10 +110,10 @@ describe("Mutation.genActors", () => { 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.generateActors.resultType, "CreateActorsSuccess"); + assert.equal(body.data.generateActors.actors.length, 2); assert.ok( - body.data.genActors.actors.every( + body.data.generateActors.actors.every( (actor: { iri: unknown; local: { uuid: unknown } | null; @@ -127,8 +127,10 @@ describe("Mutation.genActors", () => { ), ); assert.deepStrictEqual( - body.data.genActors.actors.map((actor: { iri: string }) => actor.iri), - body.data.genActors.actors.map( + body.data.generateActors.actors.map( + (actor: { iri: string }) => actor.iri, + ), + body.data.generateActors.actors.map( (actor: { uuid: string }) => `https://test-instance.drfed.org/users/${actor.uuid}`, ), diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index 9f51652..4b4acdc 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -209,7 +209,7 @@ const CreateActorsResult = builder.unionType("CreateActorsResult", { }); builder.mutationFields((t) => ({ - genActors: t.field({ + generateActors: t.field({ type: CreateActorsResult, description: "Create actors.", authScopes: { authenticated: true }, @@ -297,7 +297,7 @@ builder.mutationFields((t) => ({ await tx.insert(schema.localActors).values(ids); const createdActors = await tx .insert(schema.actors) - .values(ids.map(({ id }) => genActor(id, instanceId, fedCtx))) + .values(ids.map(({ id }) => generateActor(id, instanceId, fedCtx))) .returning(); return { actors: createdActors }; }); @@ -305,7 +305,7 @@ builder.mutationFields((t) => ({ }), })); -function genActor( +function generateActor( id: string, instanceId: string, fedCtx: Context, From ca3292a1e307e7ca03016d1fe56bd769f41c71c6 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 6 Sep 2026 14:43:09 +0900 Subject: [PATCH 15/15] Rename followees to following Rename actors.followeesUrl/followeesCount columns to followingUrl and followingCount, and update the GraphQL field and Fedify dispatcher path to match. A new migration renames the columns in place. Assisted-by: Claude Code:claude-fable-5-1 --- packages/graphql/src/actor.test.ts | 10 +- packages/graphql/src/actor.ts | 6 +- packages/graphql/src/federation.test.ts | 2 +- packages/graphql/src/federation.ts | 6 +- .../migration.sql | 2 + .../snapshot.json | 1315 +++++++++++++++++ packages/models/src/schema.ts | 4 +- 7 files changed, 1331 insertions(+), 14 deletions(-) create mode 100644 packages/models/drizzle/20260906052949_rename_followees_to_following/migration.sql create mode 100644 packages/models/drizzle/20260906052949_rename_followees_to_following/snapshot.json diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index 9b3d86a..4deae76 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -81,7 +81,7 @@ const actorQuery = ` outboxUrl avatarUrl followersUrl - followeesUrl + followingUrl headerUrl profileUrl featuredUrl @@ -190,7 +190,7 @@ describe("Actor", () => { 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`, + followingUrl: `https://test-instance.drfed.org/users/${localActorId}/following`, 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`, @@ -229,7 +229,7 @@ describe("Actor", () => { 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", + followingUrl: "https://remote.example.com/users/bob/following", headerUrl: "https://remote.example.com/users/bob/header.png", profileUrl: "https://remote.example.com/@bob", featuredUrl: "https://remote.example.com/users/bob/featured", @@ -288,7 +288,7 @@ async function seedLocalActor(db: Database): Promise { 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`, + followingUrl: `https://test-instance.drfed.org/users/${localActorId}/following`, 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`, @@ -326,7 +326,7 @@ async function seedRemoteActor(db: Database): Promise { 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", + followingUrl: "https://remote.example.com/users/bob/following", headerUrl: "https://remote.example.com/users/bob/header.png", profileUrl: "https://remote.example.com/@bob", featuredUrl: "https://remote.example.com/users/bob/featured", diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index 4b4acdc..ff1af4a 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -91,9 +91,9 @@ const ActorRef = builder.drizzleNode("actors", { description: "The followers URL of the `Actor`.", nullable: true, }), - followeesUrl: t.expose("followeesUrl", { + followingUrl: t.expose("followingUrl", { type: "URL", - description: "The followees URL of the `Actor`.", + description: "The following URL of the `Actor`.", nullable: true, }), headerUrl: t.expose("headerUrl", { @@ -321,7 +321,7 @@ function generateActor( inboxUrl: fedCtx.getInboxUri(id).href, outboxUrl: fedCtx.getOutboxUri(id).href, followersUrl: fedCtx.getFollowersUri(id).href, - followeesUrl: fedCtx.getFollowingUri(id).href, + followingUrl: fedCtx.getFollowingUri(id).href, featuredUrl: fedCtx.getFeaturedUri(id).href, profileUrl: new URL(`/@${id}`, fedCtx.origin).href, }; diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 7c964b9..054acff 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -51,7 +51,7 @@ describe("createFederation()", () => { ); assert.equal( ctx.getFollowingUri("identifier").href, - "https://drfed.test/users/identifier/followees", + "https://drfed.test/users/identifier/following", ); assert.equal( ctx.getFeaturedUri("identifier").href, diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 7c59ff1..91589dc 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -155,9 +155,9 @@ export function buildFederation(db: Database): FederationBuilder { builder .setFollowingDispatcher( - "/users/{identifier}/followees", + "/users/{identifier}/following", async (ctx, identifier) => - // FIXME: Return the actual followees once the data model stores + // FIXME: Return the actual following once the data model stores // follows (await findActiveActor(db, ctx, identifier)) == null ? null @@ -165,7 +165,7 @@ export function buildFederation(db: Database): FederationBuilder { ) .setCounter( async (ctx, identifier) => - (await findActiveActor(db, ctx, identifier))?.followeesCount ?? null, + (await findActiveActor(db, ctx, identifier))?.followingCount ?? null, ); builder.setFeaturedDispatcher( diff --git a/packages/models/drizzle/20260906052949_rename_followees_to_following/migration.sql b/packages/models/drizzle/20260906052949_rename_followees_to_following/migration.sql new file mode 100644 index 0000000..52f797b --- /dev/null +++ b/packages/models/drizzle/20260906052949_rename_followees_to_following/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "actors" RENAME COLUMN "followeesUrl" TO "followingUrl";--> statement-breakpoint +ALTER TABLE "actors" RENAME COLUMN "followeesCount" TO "followingCount"; \ No newline at end of file diff --git a/packages/models/drizzle/20260906052949_rename_followees_to_following/snapshot.json b/packages/models/drizzle/20260906052949_rename_followees_to_following/snapshot.json new file mode 100644 index 0000000..732f941 --- /dev/null +++ b/packages/models/drizzle/20260906052949_rename_followees_to_following/snapshot.json @@ -0,0 +1,1315 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "6b70d7c3-f645-4130-8f85-073c3204c78d", + "prevIds": ["ac4fc4e2-2217-4c4c-9c8a-0ec2a8162819"], + "ddl": [ + { + "values": ["Application", "Group", "Organization", "Person", "Service"], + "name": "actor_type", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instance_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "login_tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "max_instances", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "actor_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followersUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followingUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "featuredUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profileUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headerUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bioHtml", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "automaticallyApprovesFollowers", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fieldHtmls", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "emojis", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspended", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspendedUntil", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "successorId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "(ARRAY[]::text[])", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followingCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followersCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "postsCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nodeInfoUrl", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "softwareVersion", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "header", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "varchar(63)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "maxActors", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeHash", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_instance_index", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "accountId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_accountId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_instanceId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_localId_local_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["successorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "actors_successorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "instances_localId_local_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instances" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "login_tokens_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "login_tokens" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "columns": ["instanceId", "accountId"], + "nameExplicit": false, + "name": "instance_members_pkey", + "entityType": "pks", + "schema": "public", + "table": "instance_members" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "accounts_pkey", + "schema": "public", + "table": "accounts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_actors_pkey", + "schema": "public", + "table": "local_actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_instances_pkey", + "schema": "public", + "table": "local_instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "login_tokens_pkey", + "schema": "public", + "table": "login_tokens", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": ["username", "instanceId"], + "nullsNotDistinct": false, + "name": "username_key", + "entityType": "uniques", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "nullsNotDistinct": false, + "name": "actors_localId_key", + "schema": "public", + "table": "actors", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["iri"], + "nullsNotDistinct": false, + "name": "actors_iri_key", + "schema": "public", + "table": "actors", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["host"], + "nullsNotDistinct": false, + "name": "instances_host_key", + "schema": "public", + "table": "instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["slug"], + "nullsNotDistinct": false, + "name": "local_instances_slug_key", + "schema": "public", + "table": "local_instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "login_tokens_tokenHash_key", + "schema": "public", + "table": "login_tokens", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "sessions_tokenHash_key", + "schema": "public", + "table": "sessions", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"max_instances\" >= 0", + "name": "accounts_max_instances_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "trim(both from \"name\") <> ''", + "name": "accounts_name_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"username\" NOT LIKE '%@%'", + "name": "actors_username_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\n \"suspendedUntil\" IS NULL OR (\n \"suspended\" IS NOT NULL AND\n \"suspendedUntil\" > \"suspended\"\n )\n ", + "name": "actors_suspended_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\"slug\" ~ '^[a-z0-9-]{4,63}$'", + "name": "instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "\"maxActors\" > 0", + "name": "instances_max_actors_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + } + ], + "renames": [ + "public.actors.followeesUrl->public.actors.followingUrl", + "public.actors.followeesCount->public.actors.followingCount" + ] +} diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index e1ddad8..105ce8d 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -205,7 +205,7 @@ export const actors = pgTable( inboxUrl: text().notNull(), outboxUrl: text().notNull(), followersUrl: text(), - followeesUrl: text(), + followingUrl: text(), featuredUrl: text(), profileUrl: text(), avatarUrl: text(), @@ -235,7 +235,7 @@ export const actors = pgTable( .array() .notNull() .default(sql`(ARRAY[]::text[])`), - followeesCount: integer().notNull().default(0), + followingCount: integer().notNull().default(0), followersCount: integer().notNull().default(0), postsCount: integer().notNull().default(0), updated: timestamp({ withTimezone: true })