From d12da18579550eb4f1d8ccbf0db010c5282c8a9f Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 3 Aug 2026 04:58:58 +0900 Subject: [PATCH 1/8] Add root domain --- packages/drfed/src/index.ts | 4 ++-- packages/drfed/src/parser.ts | 9 ++++++++- packages/graphql/src/builder.ts | 7 ++++++- packages/graphql/src/index.ts | 6 ++++++ scripts/dev.mts | 1 + 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 077b548..1195156 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -44,8 +44,8 @@ async function runServer(options: ServerOptions) { if (options.seed) { await seedData(options.drizzle.db); } - const { mailer } = options; - const yogaServer = createYogaServer(options.drizzle.db, { mailer }); + const { mailer, root } = options; + const yogaServer = createYogaServer(options.drizzle.db, { root, mailer }); const server = serve({ fetch: yogaServer.fetch.bind(yogaServer), hostname: options.address.host, diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts index 0828e36..b7beff0 100644 --- a/packages/drfed/src/parser.ts +++ b/packages/drfed/src/parser.ts @@ -22,7 +22,7 @@ import { message, optionNames } from "@optique/core/message"; import { map, optional, withDefault } from "@optique/core/modifiers"; import type { InferValue } from "@optique/core/parser"; import { flag, option } from "@optique/core/primitives"; -import { socketAddress, url } from "@optique/core/valueparser"; +import { domain, socketAddress, url } from "@optique/core/valueparser"; import { loggingOptions } from "@optique/logtape"; import { path } from "@optique/run/valueparser"; import { LogTapeTransport } from "@upyo/logtape"; @@ -105,6 +105,12 @@ const seedParser = option("--dev-seed", { hidden: true, }); +const rootParser = optional( + option("--root-domain", "-r", domain({ lowercase: true }), { + description: message`The root domain of host.`, + }), +); + const serverParser = object("DrFed server", { address: withDefault( option("--listen", "-l", socketAddress({ requirePort: true }), { @@ -126,6 +132,7 @@ const serverParser = object("DrFed server", { ), }), ), + root: rootParser, mailer: smtpParser, seed: seedParser, }); diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index 2a08a30..88e20e2 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . import { type Database, normalizeEmail, relations } from "@drfed/models"; -import type { Account, Session } from "@drfed/models/schema"; +import { type Account, type Session } from "@drfed/models/schema"; import { Template } from "@fedify/uri-template"; import SchemaBuilder, { type ObjectRef } from "@pothos/core"; import DrizzlePlugin from "@pothos/plugin-drizzle"; @@ -56,6 +56,11 @@ export interface ServerContext { * Origin list. */ readonly origins: ReadonlySet; + + /** + * Root domain. + */ + readonly root: string; } /** diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 9e991b4..9ddbfa0 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -47,6 +47,11 @@ export interface YogaServerOptions { * Origin list. */ origins?: ReadonlySet; + + /** + * Root domain. + */ + root?: string | undefined; } /** @@ -104,6 +109,7 @@ const fillOptions = (opt: YogaServerOptions): Required => ({ "http://localhost:5173", "http://0.0.0.0:5173", ]), + root: opt?.root ?? "drfed.org", }); const getAccessToken = (headers: Headers) => diff --git a/scripts/dev.mts b/scripts/dev.mts index 4d94252..88711ea 100644 --- a/scripts/dev.mts +++ b/scripts/dev.mts @@ -306,6 +306,7 @@ try { "../../.pgdata", "--listen=0.0.0.0:8888", "--log-format=color", + "--root-domain=drfed.org", ]; const logLevel = process.env.usage_log_level; From 4e386b16d5f967a46641af67eac68cf146301921 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 3 Aug 2026 04:59:13 +0900 Subject: [PATCH 2/8] Ignore `node/no-top-level-await` --- .oxlintrc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.oxlintrc.json b/.oxlintrc.json index 9126f90..b955275 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -68,6 +68,7 @@ "import/prefer-default-export": "off", "jsdoc/require-param-type": "off", "jsdoc/require-returns-type": "off", + "node/no-top-level-await": "off", "promise/avoid-new": "warn" }, "overrides": [ From 1b473077995ebd36a5e5d17f1c8646f3cb32671d Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 3 Aug 2026 05:21:03 +0900 Subject: [PATCH 3/8] Separate local/remote schema --- packages/models/src/relations.ts | 20 ++++++++++ packages/models/src/schema.ts | 67 +++++++++++++++++++++++--------- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index c1848df..d8f2e51 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -72,6 +72,26 @@ export const relations = defineRelations(schema, (r) => ({ accepted: { isNotNull: true }, }, }), + localInstances: r.one.localInstances({ + from: r.instances.id, + to: r.localInstances.id, + }), + remoteInstances: r.one.remoteInstances({ + from: r.instances.id, + to: r.remoteInstances.id, + }), + }, + localInstance: { + instances: r.one.instances({ + from: r.localInstances.id, + to: r.instances.id, + }), + }, + remoteInstance: { + instances: r.one.instances({ + from: r.remoteInstances.id, + to: r.instances.id, + }), }, sessions: { account: r.one.accounts({ diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 6693785..518cef0 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -13,19 +13,26 @@ // // 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 { sql } from "drizzle-orm"; import { boolean, check, index, integer, + pgEnum, pgTable, primaryKey, + text, timestamp, uuid, varchar, } from "drizzle-orm/pg-core"; +const currentTimestamp = sql`CURRENT_TIMESTAMP`; + /** * The database table to represent accounts. */ @@ -39,7 +46,7 @@ export const accounts = pgTable( admin: boolean().notNull().default(false), created: timestamp({ withTimezone: true }) .notNull() - .default(sql`CURRENT_TIMESTAMP`), + .default(currentTimestamp), }, (table) => [ check( @@ -54,30 +61,54 @@ export const accounts = pgTable( export type Account = typeof accounts.$inferSelect; export type NewAccount = typeof accounts.$inferInsert; +export const locationEnum = pgEnum("location", ["Local", "Remote"]); +export type Location = (typeof locationEnum.enumValues)[number]; + /** * The database table to represent instances. */ -export const instances = pgTable( - "instances", +export const instances = pgTable("instances", { + id: uuid().primaryKey(), + location: locationEnum().notNull(), + created: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), +}); + +export type Instance = typeof instances.$inferSelect; +export type NewInstance = typeof instances.$inferInsert; + +export const localInstances = pgTable( + "local_instances", { - id: uuid().primaryKey(), - slug: varchar({ length: 100 }).notNull().unique(), + id: uuid() + .primaryKey() + .references(() => instances.id, { onDelete: "cascade" }), + slug: varchar({ length: 63 }).notNull().unique(), expires: timestamp({ withTimezone: true }).notNull(), - created: timestamp({ withTimezone: true }) - .notNull() - .default(sql`CURRENT_TIMESTAMP`), + maxActors: integer().notNull().default(10), }, (table) => [ - check("instances_slug_check", sql`${table.slug} ~ '^[a-z0-9-]{4,100}$'`), - check( - "instances_expires_check", - sql`${table.expires} < (${table.created} + INTERVAL '1 year')`, - ), + check("instances_slug_check", sql`${table.slug} ~ '^[a-z0-9-]{4,63}$'`), + check("instances_max_actors_check", sql`${table.maxActors} > 0`), ], ); -export type Instance = typeof instances.$inferSelect; -export type NewInstance = typeof instances.$inferInsert; +export type LocalInstance = typeof localInstances.$inferSelect; +export type NewLocalInstance = typeof localInstances.$inferInsert; + +export const remoteInstances = pgTable("remote_instances", { + id: uuid() + .primaryKey() + .references(() => instances.id, { onDelete: "cascade" }), + host: varchar({ length: 100 }).notNull().unique(), + nodeInfoUrl: text(), + software: text(), + softwareVersion: text(), +}); + +export type RemoteInstance = typeof remoteInstances.$inferSelect; +export type NewRemoteInstance = typeof remoteInstances.$inferInsert; /** * The association table between instances and its member accounts. @@ -97,7 +128,7 @@ export const instanceMembers = pgTable( accepted: timestamp({ withTimezone: true }), created: timestamp({ withTimezone: true }) .notNull() - .default(sql`CURRENT_TIMESTAMP`), + .default(currentTimestamp), }, (table) => [ primaryKey({ columns: [table.instanceId, table.accountId] }), @@ -128,7 +159,7 @@ export const loginTokens = pgTable("login_tokens", { codeHash: varchar({ length: 64 }).notNull(), created: timestamp({ withTimezone: true }) .notNull() - .default(sql`CURRENT_TIMESTAMP`), + .default(currentTimestamp), expires: timestamp({ withTimezone: true }) .notNull() .default(sql`CURRENT_TIMESTAMP + INTERVAL '15 minutes'`), @@ -150,7 +181,7 @@ export const sessions = pgTable("sessions", { tokenHash: varchar({ length: 64 }).notNull().unique(), created: timestamp({ withTimezone: true }) .notNull() - .default(sql`CURRENT_TIMESTAMP`), + .default(currentTimestamp), expires: timestamp({ withTimezone: true }) .notNull() .default(sql`CURRENT_TIMESTAMP + INTERVAL '1 month'`), From 19e1d14f64292d266495cde2a0f9ee9c5f1b166b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 3 Aug 2026 05:22:30 +0900 Subject: [PATCH 4/8] Add host, location and remove slug, expires when get Instance Add , and remove , when get --- packages/graphql/src/builder.ts | 6 +++- packages/graphql/src/instance.test.ts | 13 ++++++-- packages/graphql/src/instance.ts | 48 +++++++++++++++++++++------ 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index 88e20e2..b52152f 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . import { type Database, normalizeEmail, relations } from "@drfed/models"; -import { type Account, type Session } from "@drfed/models/schema"; +import type { Account, Session, Location } from "@drfed/models/schema"; import { Template } from "@fedify/uri-template"; import SchemaBuilder, { type ObjectRef } from "@pothos/core"; import DrizzlePlugin from "@pothos/plugin-drizzle"; @@ -90,6 +90,10 @@ export interface SchemaTypes { Input: string; Output: string; }; + Location: { + Input: Location; + Output: Location; + }; UUID: { Input: string; Output: string; diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index cc32334..aa3c6ba 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -151,7 +151,12 @@ describe("Mutation.createInstance", () => { const instances = await db.select().from(schema.instances); assert.equal(instances.length, 1); - assert.equal(instances[0]?.slug, "my-instance"); + const instance = instances[0]!; + assert.equal(instance.location, "Local"); + const local = await db.query.localInstances.findFirst({ + where: instance, + }); + assert.equal(local?.slug, "my-instance"); const members = await db.select().from(schema.instanceMembers); assert.equal(members.length, 1); @@ -294,8 +299,12 @@ async function seedInstanceMembers(db: Database): Promise { ]); await db.insert(schema.instances).values({ id: instanceId, - slug: "test-instance", + location: "Local", created, + }); + await db.insert(schema.localInstances).values({ + id: instanceId, + slug: "test-instance", expires, }); await db.insert(schema.instanceMembers).values([ diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index 32cdd5c..d5caddb 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -40,10 +40,28 @@ const InstanceRef = builder.drizzleNode("instances", { type: "UUID", description: "The UUID of the `Instance`.", }), - slug: t.exposeString("slug"), - expires: t.expose("expires", { - type: "DateTime", - description: "The expiration date/time of the `Instance`.", + location: t.expose("location", { + type: "Location", + description: 'The location of the `Instance`: "Local" | "Remote"', + }), + host: t.string({ + async resolve({ id, location }, _, { db, root }) { + if (location === "Local") { + const ins = await db.query.localInstances.findFirst({ + columns: { slug: true }, + where: { id }, + }); + if (ins == null) throwUncontested(id); + return `${ins.slug}.${root}`; + } + const ins = await db.query.remoteInstances.findFirst({ + columns: { host: true }, + where: { id }, + }); + if (ins == null) throwUncontested(id); + return ins.host; + }, + description: "The host of the `Instance`.", }), created: t.expose("created", { type: "DateTime", @@ -52,6 +70,10 @@ const InstanceRef = builder.drizzleNode("instances", { }), }); +function throwUncontested(id: string): never { + throw new Error(`DB consistency is broken.: ${id}`); +} + export const Instance: DrFedObjectRef = InstanceRef; const instanceMembersConnection = drizzleConnectionHelpers( @@ -218,15 +240,10 @@ builder.mutationFields((t) => ({ let tooManyInstances = false; try { return await ctx.db.transaction(async (tx) => { + const id = uuid(); const [instance] = await tx .insert(schema.instances) - .values({ - id: uuid(), - slug, - expires: new Date( - Temporal.Now.instant().add({ hours: 8750 }).toString(), - ), - }) + .values({ id, location: "Local" }) .returning(); if (instance == null) throw new Error("Failed to create instance."); await tx.insert(schema.instanceMembers).values({ @@ -241,6 +258,13 @@ builder.mutationFields((t) => ({ tooManyInstances = true; tx.rollback(); } + tx.insert(schema.localInstances).values({ + id, + slug, + expires: new Date( + Temporal.Now.instant().add({ hours: YEAR_BY_HOURS }).toString(), + ), + }); return instance; }); } catch (e) { @@ -266,3 +290,5 @@ builder.mutationFields((t) => ({ }, }), })); + +const YEAR_BY_HOURS = 8760; From 0ef857bc86f21c661e88c5c6d4f51a1bece6d06c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 3 Aug 2026 06:02:06 +0900 Subject: [PATCH 5/8] Generate migration --- .../migration.sql | 26 + .../snapshot.json | 768 ++++++++++++++++++ 2 files changed, 794 insertions(+) create mode 100644 packages/models/drizzle/20260802205417_separate-instance-local-remote/migration.sql create mode 100644 packages/models/drizzle/20260802205417_separate-instance-local-remote/snapshot.json diff --git a/packages/models/drizzle/20260802205417_separate-instance-local-remote/migration.sql b/packages/models/drizzle/20260802205417_separate-instance-local-remote/migration.sql new file mode 100644 index 0000000..7b30cef --- /dev/null +++ b/packages/models/drizzle/20260802205417_separate-instance-local-remote/migration.sql @@ -0,0 +1,26 @@ +CREATE TYPE "location" AS ENUM('Local', 'Remote');--> statement-breakpoint +CREATE TABLE "local_instances" ( + "id" uuid PRIMARY KEY, + "slug" varchar(63) NOT NULL UNIQUE, + "expires" timestamp with time zone NOT NULL, + "maxActors" integer DEFAULT 10 NOT NULL, + CONSTRAINT "instances_slug_check" CHECK ("slug" ~ '^[a-z0-9-]{4,63}$'), + CONSTRAINT "instances_max_actors_check" CHECK ("maxActors" > 0) +); +--> statement-breakpoint +CREATE TABLE "remote_instances" ( + "id" uuid PRIMARY KEY, + "host" varchar(100) NOT NULL UNIQUE, + "nodeInfoUrl" text, + "software" text, + "softwareVersion" text +); +--> statement-breakpoint +ALTER TABLE "instances" DROP CONSTRAINT "instances_slug_key";--> statement-breakpoint +ALTER TABLE "instances" DROP CONSTRAINT "instances_slug_check";--> statement-breakpoint +ALTER TABLE "instances" DROP CONSTRAINT "instances_expires_check";--> statement-breakpoint +ALTER TABLE "instances" ADD COLUMN "location" "location" NOT NULL;--> statement-breakpoint +ALTER TABLE "instances" DROP COLUMN "slug";--> statement-breakpoint +ALTER TABLE "instances" DROP COLUMN "expires";--> statement-breakpoint +ALTER TABLE "local_instances" ADD CONSTRAINT "local_instances_id_instances_id_fkey" FOREIGN KEY ("id") REFERENCES "instances"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "remote_instances" ADD CONSTRAINT "remote_instances_id_instances_id_fkey" FOREIGN KEY ("id") REFERENCES "instances"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/packages/models/drizzle/20260802205417_separate-instance-local-remote/snapshot.json b/packages/models/drizzle/20260802205417_separate-instance-local-remote/snapshot.json new file mode 100644 index 0000000..fa02ce8 --- /dev/null +++ b/packages/models/drizzle/20260802205417_separate-instance-local-remote/snapshot.json @@ -0,0 +1,768 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "08f1dfcb-1110-4550-9777-36d017ecfb10", + "prevIds": ["d11b25cc-8a54-4c9f-95c5-1891ce2a133a"], + "ddl": [ + { + "values": ["Local", "Remote"], + "name": "location", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "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_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "login_tokens", + "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": "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_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_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": 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": ["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": "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": "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": "instances_pkey", + "schema": "public", + "table": "instances", + "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_instances_pkey", + "schema": "public", + "table": "remote_instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "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": ["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": "\"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": [] +} From fab0c9fe6219b6737eb9d4fa4a64deb90561bd33 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 3 Aug 2026 06:04:59 +0900 Subject: [PATCH 6/8] Generate test code by AI agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt: DB 스키마에서 `instance` 를 Local/Remote 로 분리하고 API 를 수정하면서 기존 스키마/API 에 맞춰놨던 일부 테스트가 문제 되고 있습니다. [instance.test.ts](packages/graphql/src/instance.test.ts) 파일의 20419cea220545130243b6787ad1122a95ef1462 커밋 변경 사항을 참고해서 문제되는 파일들의 코드를 직접 수정하세요. 문제되는 파일들은 `mise check`, `mise test` 로 찾을 수 있습니다. 추가적으로 리모트 인스턴스를 위한 테스트도 추가하세요. 너무 복잡한 테스트 말고, 생성, host 유일성 체크 정도만 추가하세요. Assisted-by: Codex:gpt-5-6-sol --- packages/graphql/src/account.test.ts | 48 ++++++++---- packages/graphql/src/builder.ts | 6 +- packages/graphql/src/instance.test.ts | 105 +++++++++++++++++++++++++- packages/graphql/src/instance.ts | 12 ++- 4 files changed, 143 insertions(+), 28 deletions(-) diff --git a/packages/graphql/src/account.test.ts b/packages/graphql/src/account.test.ts index 060dc6a..c20ca5c 100644 --- a/packages/graphql/src/account.test.ts +++ b/packages/graphql/src/account.test.ts @@ -52,7 +52,8 @@ const accountInstancesQuery = ` admin node { uuid - slug + location + host } } } @@ -82,7 +83,8 @@ const accountInstancesResponse = { admin: true, node: { uuid: acceptedInstanceId, - slug: "test-instance", + location: "Local", + host: "test-instance.drfed.org", }, }, ], @@ -148,20 +150,7 @@ async function seedAccounts(db: Database): Promise { async function seedMembershipGraph(db: Database): Promise { await seedAccounts(db); - await db.insert(schema.instances).values([ - { - id: acceptedInstanceId, - slug: "test-instance", - created, - expires, - }, - { - id: pendingInstanceId, - slug: "pending-instance", - created, - expires, - }, - ]); + await seedLocalInstances(db); await db.insert(schema.instanceMembers).values([ { accountId, @@ -193,3 +182,30 @@ async function seedMembershipGraph(db: Database): Promise { }, ]); } + +async function seedLocalInstances(db: Database): Promise { + await db.insert(schema.instances).values([ + { + id: acceptedInstanceId, + location: "Local", + created, + }, + { + id: pendingInstanceId, + location: "Local", + created, + }, + ]); + await db.insert(schema.localInstances).values([ + { + id: acceptedInstanceId, + slug: "test-instance", + expires, + }, + { + id: pendingInstanceId, + slug: "pending-instance", + expires, + }, + ]); +} diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index b52152f..d6925cc 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . import { type Database, normalizeEmail, relations } from "@drfed/models"; -import type { Account, Session, Location } from "@drfed/models/schema"; +import type { Account, Session } from "@drfed/models/schema"; import { Template } from "@fedify/uri-template"; import SchemaBuilder, { type ObjectRef } from "@pothos/core"; import DrizzlePlugin from "@pothos/plugin-drizzle"; @@ -90,10 +90,6 @@ export interface SchemaTypes { Input: string; Output: string; }; - Location: { - Input: Location; - Output: Location; - }; UUID: { Input: string; Output: string; diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index aa3c6ba..b24ca04 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -19,6 +19,7 @@ import assert from "node:assert/strict"; import { type Database, schema } from "@drfed/models"; import { describe, it } from "@logtape/testing-node/autoload"; +import { DrizzleQueryError } from "drizzle-orm"; import { withTestHarness } from "./harness.test.ts"; @@ -31,9 +32,26 @@ const accountId = "00000000-0000-4000-8000-000000000001"; const memberId = "00000000-0000-4000-8000-000000000002"; const pendingMemberId = "00000000-0000-4000-8000-000000000003"; const instanceId = "00000000-0000-4000-8000-000000000101"; +const duplicateRemoteInstanceId = "00000000-0000-4000-8000-000000000102"; const sessionId = "00000000-0000-4000-8000-000000000201"; const accessToken = "test-access-token"; +const remoteInstanceQuery = ` + query RemoteInstance($uuid: UUID!) { + accountByUuid(uuid: $uuid) { + instances { + edges { + node { + uuid + location + host + } + } + } + } + } +`; + const instanceMembersQuery = ` query InstanceMembers($uuid: UUID!) { accountByUuid(uuid: $uuid) { @@ -122,7 +140,8 @@ const createInstanceMutation = ` __typename ... on Instance { uuid - slug + location + host } ... on CreateInstanceError { type @@ -146,7 +165,8 @@ describe("Mutation.createInstance", () => { const body = await response.json(); assert.equal(body.errors, undefined); assert.equal(body.data.createInstance.__typename, "Instance"); - assert.equal(body.data.createInstance.slug, "my-instance"); + assert.equal(body.data.createInstance.location, "Local"); + assert.equal(body.data.createInstance.host, "my-instance.drfed.org"); assert.equal(typeof body.data.createInstance.uuid, "string"); const instances = await db.select().from(schema.instances); @@ -154,7 +174,7 @@ describe("Mutation.createInstance", () => { const instance = instances[0]!; assert.equal(instance.location, "Local"); const local = await db.query.localInstances.findFirst({ - where: instance, + where: { id: instance.id }, }); assert.equal(local?.slug, "my-instance"); @@ -231,6 +251,61 @@ describe("Mutation.createInstance", () => { }); }); +describe("Remote instance", () => { + it("returns a created remote instance", async () => { + await withTestHarness(async ({ db, post }) => { + await seedRemoteInstance(db); + + const response = await post({ + query: remoteInstanceQuery, + variables: { uuid: accountId }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), { + data: { + accountByUuid: { + instances: { + edges: [ + { + node: { + uuid: instanceId, + location: "Remote", + host: "remote.example.com", + }, + }, + ], + }, + }, + }, + }); + }); + }); + + it("requires a unique host", async () => { + await withTestHarness(async ({ db }) => { + await seedRemoteInstance(db); + await db.insert(schema.instances).values({ + id: duplicateRemoteInstanceId, + location: "Remote", + created, + }); + + await assert.rejects( + db.insert(schema.remoteInstances).values({ + id: duplicateRemoteInstanceId, + host: "remote.example.com", + }), + (error: unknown) => + error instanceof DrizzleQueryError && + error.cause != null && + "constraint" in error.cause && + error.cause.constraint === "remote_instances_host_key", + ); + }); + }); +}); + /** * Seeds an account and an authenticated session, then returns the request * options carrying the session's bearer token. @@ -331,3 +406,27 @@ async function seedInstanceMembers(db: Database): Promise { }, ]); } + +async function seedRemoteInstance(db: Database): Promise { + await db.insert(schema.accounts).values({ + id: accountId, + email: "owner@example.com", + name: "Owner", + created, + }); + await db.insert(schema.instances).values({ + id: instanceId, + location: "Remote", + created, + }); + await db.insert(schema.remoteInstances).values({ + id: instanceId, + host: "remote.example.com", + }); + await db.insert(schema.instanceMembers).values({ + accountId, + instanceId, + accepted, + created, + }); +} diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index d5caddb..73649b7 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -16,7 +16,7 @@ // oxlint-disable max-lines-per-function import { schema } from "@drfed/models"; -import { instanceMembers } from "@drfed/models/schema"; +import { instanceMembers, locationEnum } from "@drfed/models/schema"; import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import { DrizzleQueryError } from "drizzle-orm"; import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; @@ -26,6 +26,10 @@ import { v7 as uuid } from "uuid"; import { Account } from "./account.ts"; import builder, { type DrFedObjectRef } from "./builder.ts"; +const Location = builder.enumType("Location", { + values: locationEnum.enumValues, +}); + const InstanceRef = builder.drizzleNode("instances", { name: "Instance", description: "Represents an `Instance` in the DrFed platform.", @@ -41,7 +45,7 @@ const InstanceRef = builder.drizzleNode("instances", { description: "The UUID of the `Instance`.", }), location: t.expose("location", { - type: "Location", + type: Location, description: 'The location of the `Instance`: "Local" | "Remote"', }), host: t.string({ @@ -258,7 +262,7 @@ builder.mutationFields((t) => ({ tooManyInstances = true; tx.rollback(); } - tx.insert(schema.localInstances).values({ + await tx.insert(schema.localInstances).values({ id, slug, expires: new Date( @@ -278,7 +282,7 @@ builder.mutationFields((t) => ({ e instanceof DrizzleQueryError && e.cause != null && "constraint" in e.cause && - e.cause.constraint === "instances_slug_key" + e.cause.constraint === "local_instances_slug_key" ) { return { message: `The slug ${JSON.stringify(slug)} is already taken.`, From 59c87cee56bcdb51a3b48ad2f0347cb6b44fd3b7 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 4 Aug 2026 14:42:11 +0900 Subject: [PATCH 7/8] Add constraints to prevent simultaneous Local/Remote registration --- .../migration.sql | 7 + .../snapshot.json | 843 ++++++++++++++++++ packages/models/src/schema.ts | 72 +- 3 files changed, 904 insertions(+), 18 deletions(-) create mode 100644 packages/models/drizzle/20260804061119_instances-local-remote-constraint/migration.sql create mode 100644 packages/models/drizzle/20260804061119_instances-local-remote-constraint/snapshot.json diff --git a/packages/models/drizzle/20260804061119_instances-local-remote-constraint/migration.sql b/packages/models/drizzle/20260804061119_instances-local-remote-constraint/migration.sql new file mode 100644 index 0000000..563fe43 --- /dev/null +++ b/packages/models/drizzle/20260804061119_instances-local-remote-constraint/migration.sql @@ -0,0 +1,7 @@ +ALTER TABLE "local_instances" ADD COLUMN "location" "location" DEFAULT 'Local'::"location" NOT NULL;--> statement-breakpoint +ALTER TABLE "remote_instances" ADD COLUMN "location" "location" DEFAULT 'Remote'::"location" NOT NULL;--> statement-breakpoint +ALTER TABLE "instances" ADD CONSTRAINT "instances_id_location_key" UNIQUE("id","location");--> statement-breakpoint +ALTER TABLE "local_instances" ADD CONSTRAINT "local_instance_fk" FOREIGN KEY ("id","location") REFERENCES "instances"("id","location") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "remote_instances" ADD CONSTRAINT "remote_instance_fk" FOREIGN KEY ("id","location") REFERENCES "instances"("id","location") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "local_instances" ADD CONSTRAINT "local_check" CHECK ("location" = 'Local');--> statement-breakpoint +ALTER TABLE "remote_instances" ADD CONSTRAINT "remote_check" CHECK ("location" = 'Remote'); \ No newline at end of file diff --git a/packages/models/drizzle/20260804061119_instances-local-remote-constraint/snapshot.json b/packages/models/drizzle/20260804061119_instances-local-remote-constraint/snapshot.json new file mode 100644 index 0000000..c980d7c --- /dev/null +++ b/packages/models/drizzle/20260804061119_instances-local-remote-constraint/snapshot.json @@ -0,0 +1,843 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "c40a9b41-e120-4f01-865b-68583fbb921c", + "prevIds": ["08f1dfcb-1110-4550-9777-36d017ecfb10"], + "ddl": [ + { + "values": ["Local", "Remote"], + "name": "location", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "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_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "login_tokens", + "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": "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_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": "location", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'Local'", + "generated": null, + "identity": null, + "name": "location", + "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_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": "location", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'Remote'", + "generated": null, + "identity": null, + "name": "location", + "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": 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": ["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": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "local_instances_id_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "local_instances" + }, + { + "nameExplicit": true, + "columns": ["id", "location"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id", "location"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "local_instance_fk", + "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": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "remote_instances_id_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "remote_instances" + }, + { + "nameExplicit": true, + "columns": ["id", "location"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id", "location"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "remote_instance_fk", + "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": "instances_pkey", + "schema": "public", + "table": "instances", + "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_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": ["id", "location"], + "nullsNotDistinct": false, + "name": "instances_id_location_key", + "entityType": "uniques", + "schema": "public", + "table": "instances" + }, + { + "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": ["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": "\"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\" = 'Local'", + "name": "local_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "\"location\" = 'Remote'", + "name": "remote_check", + "entityType": "checks", + "schema": "public", + "table": "remote_instances" + } + ], + "renames": [] +} diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 518cef0..ba44d01 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -14,12 +14,11 @@ // 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 { sql } from "drizzle-orm"; import { boolean, check, + foreignKey, index, integer, pgEnum, @@ -27,6 +26,7 @@ import { primaryKey, text, timestamp, + unique, uuid, varchar, } from "drizzle-orm/pg-core"; @@ -67,13 +67,20 @@ export type Location = (typeof locationEnum.enumValues)[number]; /** * The database table to represent instances. */ -export const instances = pgTable("instances", { - id: uuid().primaryKey(), - location: locationEnum().notNull(), - created: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), -}); +export const instances = pgTable( + "instances", + { + id: uuid().primaryKey(), + location: locationEnum().notNull(), + created: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), + }, + (t) => [ + // This unique constraint prevent simultaneous Local/Remote registration. + unique("instances_id_location_key").on(t.id, t.location), + ], +); export type Instance = typeof instances.$inferSelect; export type NewInstance = typeof instances.$inferInsert; @@ -87,25 +94,54 @@ export const localInstances = pgTable( slug: varchar({ length: 63 }).notNull().unique(), expires: timestamp({ withTimezone: true }).notNull(), maxActors: integer().notNull().default(10), + // 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(), }, (table) => [ check("instances_slug_check", sql`${table.slug} ~ '^[a-z0-9-]{4,63}$'`), check("instances_max_actors_check", sql`${table.maxActors} > 0`), + // Following `location` check and FK prevent simultaneous Local/Remote + // registration. + check("local_check", sql`${table.location} = 'Local'`), + foreignKey({ + name: "local_instance_fk", + columns: [table.id, table.location], + foreignColumns: [instances.id, instances.location], + }).onDelete("cascade"), ], ); export type LocalInstance = typeof localInstances.$inferSelect; export type NewLocalInstance = typeof localInstances.$inferInsert; -export const remoteInstances = pgTable("remote_instances", { - id: uuid() - .primaryKey() - .references(() => instances.id, { onDelete: "cascade" }), - host: varchar({ length: 100 }).notNull().unique(), - nodeInfoUrl: text(), - software: text(), - softwareVersion: text(), -}); +export const remoteInstances = pgTable( + "remote_instances", + { + id: uuid() + .primaryKey() + .references(() => instances.id, { onDelete: "cascade" }), + host: varchar({ length: 100 }).notNull().unique(), + nodeInfoUrl: text(), + software: text(), + softwareVersion: 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(), + }, + (table) => [ + // Following `location` check and FK prevent simultaneous Local/Remote + // registration. + check("remote_check", sql`${table.location} = 'Remote'`), + foreignKey({ + name: "remote_instance_fk", + columns: [table.id, table.location], + foreignColumns: [instances.id, instances.location], + }).onDelete("cascade"), + ], +); export type RemoteInstance = typeof remoteInstances.$inferSelect; export type NewRemoteInstance = typeof remoteInstances.$inferInsert; From 39e033eb9f764957a013c06ece1ff206b9ac57b2 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 4 Aug 2026 14:47:06 +0900 Subject: [PATCH 8/8] Add oxlint rules https://github.com/fedify-dev/drfed/pull/44#pullrequestreview-4850930385 https://github.com/fedify-dev/drfed/pull/44#pullrequestreview-4850931835 --- .oxlintrc.json | 1 + packages/drfed/src/index.ts | 1 - packages/graphql/src/account.ts | 1 - packages/graphql/src/auth.test.ts | 2 +- packages/graphql/src/instance.test.ts | 3 +-- packages/graphql/src/instance.ts | 2 -- packages/models/src/relations.ts | 2 +- scripts/dev.mts | 2 +- 8 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index b955275..f7b6e6e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -30,6 +30,7 @@ "eslint/eqeqeq": ["off", "smart"], "eslint/func-style": ["off"], "eslint/max-lines-per-function": "off", + "eslint/max-lines": "off", "eslint/id-length": ["warn", { "exceptionPatterns": ["^_", "^[Tertv]$"] }], "eslint/init-declarations": "off", "eslint/max-params": ["warn", { "max": 4 }], diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 1195156..9b633cd 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -76,7 +76,6 @@ async function runSchemaGenerator( await writeFile(options.outputFile, schemaCode, { encoding: "utf-8" }); } -// oxlint-disable-next-line max-lines-per-function export async function main(): Promise { const options: Options = run(program, { help: "option", diff --git a/packages/graphql/src/account.ts b/packages/graphql/src/account.ts index 0451807..f07b118 100644 --- a/packages/graphql/src/account.ts +++ b/packages/graphql/src/account.ts @@ -79,7 +79,6 @@ const accountInstancesConnection = drizzleConnectionHelpers( }, ); -// oxlint-disable-next-line max-lines-per-function builder.drizzleObjectField(AccountRef, "instances", (t) => t.connection( { diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts index 88673ec..fc5dd4f 100644 --- a/packages/graphql/src/auth.test.ts +++ b/packages/graphql/src/auth.test.ts @@ -14,7 +14,7 @@ // 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 max-statements no-magic-numbers +// oxlint-disable max-statements no-magic-numbers import { deepEqual, equal, ok } from "node:assert/strict"; diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index b24ca04..00ab79e 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// oxlint-disable max-lines max-lines-per-function no-underscore-dangle +// oxlint-disable no-underscore-dangle import assert from "node:assert/strict"; import { type Database, schema } from "@drfed/models"; @@ -347,7 +347,6 @@ async function hashSecret(raw: string): Promise { ).toHex(); } -// oxlint-disable-next-line max-lines-per-function async function seedInstanceMembers(db: Database): Promise { await db.insert(schema.accounts).values([ { diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index 73649b7..c95f5f5 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -14,7 +14,6 @@ // 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 import { schema } from "@drfed/models"; import { instanceMembers, locationEnum } from "@drfed/models/schema"; import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; @@ -103,7 +102,6 @@ const instanceMembersConnection = drizzleConnectionHelpers( }, ); -// oxlint-disable-next-line max-lines-per-function builder.drizzleObjectField(InstanceRef, "members", (t) => t.connection( { diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index d8f2e51..104376e 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -13,11 +13,11 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . + import { defineRelations } from "drizzle-orm"; import * as schema from "./schema.ts"; -// oxlint-disable-next-line eslint/max-lines-per-function export const relations = defineRelations(schema, (r) => ({ accounts: { instances: r.many.instances({ diff --git a/scripts/dev.mts b/scripts/dev.mts index 88711ea..595642d 100644 --- a/scripts/dev.mts +++ b/scripts/dev.mts @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// oxlint-disable no-console no-magic-numbers eslin/max-lines node/no-top-level-await +// oxlint-disable no-console no-magic-numbers node/no-top-level-await import { type ChildProcess, spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { readFile, readdir, rm } from "node:fs/promises";