From b99bedc31c5717d123f59646ad2161b74f26e5aa Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 24 Jun 2026 01:25:13 +0900 Subject: [PATCH 1/5] Add account names and instance membership acceptance tracking - Add `name` column to accounts table with a CHECK constraint ensuring non-empty names - Add `accepted` timestamp to instance_members table to distinguish accepted members from invited (pending) ones - Add partial indexes on instance_members for efficient lookups of accepted members - Add filtered instanceMembers relations on both accounts and instances with `accepted IS NOT NULL` condition - Add `instances` GraphQL connection on Account and `members` GraphQL connection on Instance, both filtering by accepted status - Expose `name` field on Account and `slug` field on Instance in GraphQL The instance_members junction table now tracks both invited and accepted members. The `accepted` field is NULL for pending invitations. Partial indexes on (accountId) and (instanceId) with `accepted IS NOT NULL` optimize queries for active memberships. --- packages/graphql/src/account.ts | 58 +++- packages/graphql/src/instance.ts | 55 ++- .../migration.sql | 5 + .../snapshot.json | 321 ++++++++++++++++++ packages/models/src/relations.ts | 24 ++ packages/models/src/schema.ts | 32 +- 6 files changed, 462 insertions(+), 33 deletions(-) create mode 100644 packages/models/drizzle/20260623162238_dear_felicia_hardy/migration.sql create mode 100644 packages/models/drizzle/20260623162238_dear_felicia_hardy/snapshot.json diff --git a/packages/graphql/src/account.ts b/packages/graphql/src/account.ts index 90f8fa1..28d7415 100644 --- a/packages/graphql/src/account.ts +++ b/packages/graphql/src/account.ts @@ -13,33 +13,65 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { instanceMembers } from "@drfed/models/schema"; +import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; + import builder, { type DrFedObjectRef } from "./builder.ts"; const AccountRef = builder.drizzleNode("accounts", { + name: "Account", description: "Represents an `Account` in the DrFed platform. " + "Note that it differs from the ActivityPub `Actor`s that belong to `Instance`s.", + id: { + column(account) { + return account.id; + }, + description: "The unique identifier of the `Account`.", + }, fields: (t) => ({ - created: t.expose("created", { - type: "DateTime", - description: "The date/time when the `Account` was created.", + uuid: t.expose("id", { + type: "UUID", + description: "The UUID of the `Account`.", }), email: t.expose("email", { type: "Email", description: "The email address of the `Account`.", }), - uuid: t.expose("id", { - type: "UUID", - description: "The UUID of the `Account`.", + name: t.exposeString("name", { + description: "The display name of the `Account`.", + }), + created: t.expose("created", { + type: "DateTime", + description: "The date/time when the `Account` was created.", + }), + // We can simplify the below connection by using `relatedConnection()` + // when Drizzle ORM supports additional filters on the junction table + // for relations. See also the below issue: + // https://github.com/drizzle-team/drizzle-orm/issues/5343 + instances: t.drizzleConnection({ + type: "instances", + description: "The `Instance`s that the `Account` belongs to.", + totalCount(parent, _args, ctx) { + return ctx.db.$count( + instanceMembers, + and( + eq(instanceMembers.accountId, parent.id), + isNotNull(instanceMembers.accepted), + ), + ); + }, + resolve(query, parent, _args, ctx) { + return ctx.db.query.instances.findMany( + query({ + where: { + instanceMembers: { accountId: parent.id }, + }, + }), + ); + }, }), }), - id: { - column(account) { - return account.id; - }, - description: "The unique identifier of the `Account`.", - }, - name: "Account", }); export const Account: DrFedObjectRef = AccountRef; diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index 9b49486..dc23961 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -13,28 +13,61 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { instanceMembers } from "@drfed/models/schema"; +import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; + import builder, { type DrFedObjectRef } from "./builder.ts"; const InstanceRef = builder.drizzleNode("instances", { + name: "Instance", description: "Represents an `Instance` in the DrFed platform.", + id: { + column(instance) { + return instance.id; + }, + description: "The unique identifier of the `Instance`.", + }, fields: (t) => ({ - created: t.expose("created", { - type: "DateTime", - description: "The creation date/time of the `Instance`.", + uuid: t.expose("id", { + 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`.", }), - slug: t.exposeString("slug"), + created: t.expose("created", { + type: "DateTime", + description: "The creation date/time of the `Instance`.", + }), + // We can simplify the below connection by using `relatedConnection()` + // when Drizzle ORM supports additional filters on the junction table + // for relations. See also the below issue: + // https://github.com/drizzle-team/drizzle-orm/issues/5343 + members: t.drizzleConnection({ + type: "accounts", + description: "The `Account`s that belong to the `Instance`.", + totalCount(parent, _args, ctx) { + return ctx.db.$count( + instanceMembers, + and( + eq(instanceMembers.instanceId, parent.id), + isNotNull(instanceMembers.accepted), + ), + ); + }, + resolve(query, parent, _args, ctx) { + return ctx.db.query.accounts.findMany( + query({ + where: { + instanceMembers: { instanceId: parent.id }, + }, + }), + ); + }, + }), }), - id: { - column(instance) { - return instance.id; - }, - description: "The unique identifier of the `Instance`.", - }, - name: "Instance", }); export const Instance: DrFedObjectRef = InstanceRef; diff --git a/packages/models/drizzle/20260623162238_dear_felicia_hardy/migration.sql b/packages/models/drizzle/20260623162238_dear_felicia_hardy/migration.sql new file mode 100644 index 0000000..d377583 --- /dev/null +++ b/packages/models/drizzle/20260623162238_dear_felicia_hardy/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "accounts" ADD COLUMN "name" varchar(100) NOT NULL;--> statement-breakpoint +ALTER TABLE "instance_members" ADD COLUMN "accepted" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "instance_members_accountId_index" ON "instance_members" ("accountId") WHERE "accepted" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "instance_members_instanceId_index" ON "instance_members" ("instanceId") WHERE "accepted" IS NOT NULL;--> statement-breakpoint +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_name_check" CHECK (trim(both from "name") <> ''); \ No newline at end of file diff --git a/packages/models/drizzle/20260623162238_dear_felicia_hardy/snapshot.json b/packages/models/drizzle/20260623162238_dear_felicia_hardy/snapshot.json new file mode 100644 index 0000000..88ce77a --- /dev/null +++ b/packages/models/drizzle/20260623162238_dear_felicia_hardy/snapshot.json @@ -0,0 +1,321 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "f04d695a-a99e-4bb9-be8d-190f9e7b67dc", + "prevIds": ["2cffbe98-208b-4301-ad32-8c2e3e74928f"], + "ddl": [ + { + "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" + }, + { + "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": "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": "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": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "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": "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" + }, + { + "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" + }, + { + "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" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["slug"], + "nullsNotDistinct": false, + "name": "instances_slug_key", + "schema": "public", + "table": "instances", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_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,100}$'", + "name": "instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "instances" + }, + { + "value": "\"expires\" < (\"created\" + INTERVAL '1 year')", + "name": "instances_expires_check", + "entityType": "checks", + "schema": "public", + "table": "instances" + } + ], + "renames": [] +} diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index da56125..f730edd 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -22,6 +22,18 @@ export const relations = defineRelations(schema, (r) => ({ instances: r.many.instances({ from: r.accounts.id.through(r.instanceMembers.accountId), to: r.instances.id.through(r.instanceMembers.instanceId), + // Drizzle ORM currently does not support many-to-many relationships with + // additional filters on the junction table. So we need to specify + // filter `accepted IS NOT NULL` everywhere we query for instances of + // an account (sigh). See also the below issue: + // https://github.com/drizzle-team/drizzle-orm/issues/5343 + }), + instanceMembers: r.many.instanceMembers({ + from: r.accounts.id, + to: r.instanceMembers.accountId, + where: { + accepted: { isNotNull: true }, + }, }), }, instanceMembers: { @@ -38,6 +50,18 @@ export const relations = defineRelations(schema, (r) => ({ members: r.many.accounts({ from: r.instances.id.through(r.instanceMembers.instanceId), to: r.accounts.id.through(r.instanceMembers.accountId), + // Drizzle ORM currently does not support many-to-many relationships with + // additional filters on the junction table. So we need to specify + // filter `accepted IS NOT NULL` everywhere we query for members of + // an instance (sigh). See also the below issue: + // https://github.com/drizzle-team/drizzle-orm/issues/5343 + }), + instanceMembers: r.many.instanceMembers({ + from: r.instances.id, + to: r.instanceMembers.instanceId, + where: { + accepted: { isNotNull: true }, + }, }), }, })); diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 35ef9eb..fccbdb6 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -16,6 +16,7 @@ import { sql } from "drizzle-orm"; import { check, + index, pgTable, primaryKey, timestamp, @@ -29,17 +30,19 @@ import { export const accounts = pgTable( "accounts", { + id: uuid().primaryKey(), + email: varchar({ length: 255 }).notNull().unique(), + name: varchar({ length: 100 }).notNull(), created: timestamp({ withTimezone: true }) .notNull() .default(sql`CURRENT_TIMESTAMP`), - email: varchar({ length: 255 }).notNull().unique(), - id: uuid().primaryKey(), }, (table) => [ check( "accounts_email_check", sql`${table.email} ~ '^[^@]+@[^@]+\\.[^@]+$'`, ), + check("accounts_name_check", sql`trim(both from ${table.name}) <> ''`), ], ); @@ -52,12 +55,12 @@ export type NewAccount = typeof accounts.$inferInsert; export const instances = pgTable( "instances", { + id: uuid().primaryKey(), + slug: varchar({ length: 100 }).notNull().unique(), + expires: timestamp({ withTimezone: true }).notNull(), created: timestamp({ withTimezone: true }) .notNull() .default(sql`CURRENT_TIMESTAMP`), - expires: timestamp({ withTimezone: true }).notNull(), - id: uuid().primaryKey(), - slug: varchar({ length: 100 }).notNull().unique(), }, (table) => [ check("instances_slug_check", sql`${table.slug} ~ '^[a-z0-9-]{4,100}$'`), @@ -73,6 +76,8 @@ export type NewInstance = typeof instances.$inferInsert; /** * The association table between instances and its member accounts. + * Note that it also contains the just invited members, which are not yet + * accepted. The `accepted` field is `NULL` for those members. */ export const instanceMembers = pgTable( "instance_members", @@ -80,14 +85,23 @@ export const instanceMembers = pgTable( accountId: uuid() .notNull() .references(() => accounts.id), - created: timestamp({ withTimezone: true }) - .notNull() - .default(sql`CURRENT_TIMESTAMP`), instanceId: uuid() .notNull() .references(() => instances.id), + accepted: timestamp({ withTimezone: true }), + created: timestamp({ withTimezone: true }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`), }, - (table) => [primaryKey({ columns: [table.instanceId, table.accountId] })], + (table) => [ + primaryKey({ columns: [table.instanceId, table.accountId] }), + index() + .on(table.accountId) + .where(sql`${table.accepted} IS NOT NULL`), + index() + .on(table.instanceId) + .where(sql`${table.accepted} IS NOT NULL`), + ], ); export type InstanceMember = typeof instanceMembers.$inferSelect; From 69f77700cd269179adbb622dd6525ff879369a01 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 24 Jun 2026 13:56:28 +0900 Subject: [PATCH 2/5] Add GraphQL database test harness Add a test-only helper that runs callbacks with a fresh migrated PGlite database for GraphQL package tests. Cover the helper with a smoke test that verifies migrated tables are queryable. Move shared database-related package versions into the pnpm catalog so workspace packages can use the same PGlite, pg, and @types/pg versions. Assisted-by: Codex:gpt-5.5 --- packages/drfed/package.json | 6 +-- packages/graphql/package.json | 1 + packages/graphql/src/harness-smoke.test.ts | 30 +++++++++++ packages/graphql/src/harness.test.ts | 61 ++++++++++++++++++++++ packages/models/package.json | 6 +-- pnpm-lock.yaml | 24 ++++++--- pnpm-workspace.yaml | 3 ++ 7 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 packages/graphql/src/harness-smoke.test.ts create mode 100644 packages/graphql/src/harness.test.ts diff --git a/packages/drfed/package.json b/packages/drfed/package.json index da92e88..a67061f 100644 --- a/packages/drfed/package.json +++ b/packages/drfed/package.json @@ -60,18 +60,18 @@ }, "devDependencies": { "@types/node": "catalog:", - "@types/pg": "^8.20.0", + "@types/pg": "catalog:", "tsdown": "catalog:", "typescript": "catalog:" }, "dependencies": { "@drfed/graphql": "workspace:*", "@drfed/models": "workspace:*", - "@electric-sql/pglite": "^0.5.3", + "@electric-sql/pglite": "catalog:", "@optique/core": "^1.1.0", "@optique/run": "^1.1.0", "drizzle-orm": "catalog:", - "pg": "^8.21.0", + "pg": "catalog:", "srvx": "^0.11.16" } } diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 9dc27ea..8d06686 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -85,6 +85,7 @@ "test": "node --test" }, "devDependencies": { + "@electric-sql/pglite": "catalog:", "@types/node": "catalog:", "tsdown": "catalog:", "typescript": "catalog:" diff --git a/packages/graphql/src/harness-smoke.test.ts b/packages/graphql/src/harness-smoke.test.ts new file mode 100644 index 0000000..12d32ee --- /dev/null +++ b/packages/graphql/src/harness-smoke.test.ts @@ -0,0 +1,30 @@ +// 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 { describe, it } from "node:test"; + +import { schema } from "@drfed/models"; + +import { withTemporaryDatabase } from "./harness.test.ts"; + +describe("withTemporaryDatabase()", () => { + it("provides a migrated temporary database", async () => { + await withTemporaryDatabase(async (db) => { + const accounts = await db.select().from(schema.accounts); + assert.deepEqual(accounts, []); + }); + }); +}); diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts new file mode 100644 index 0000000..fb0d707 --- /dev/null +++ b/packages/graphql/src/harness.test.ts @@ -0,0 +1,61 @@ +// 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 Database, migrate, relations, schema } from "@drfed/models"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; + +/** + * Runs a callback with a fresh in-memory PGlite database. + * + * The database is migrated before the callback is invoked, so every table in + * the current `@drfed/models` schema is available. The underlying PGlite + * client is closed after the callback resolves or rejects. + * + * @example + * ```ts + * import assert from "node:assert/strict"; + * import { it } from "node:test"; + * + * import { schema } from "@drfed/models"; + * + * import { withTemporaryDatabase } from "./harness.test.ts"; + * + * it("queries accounts", async () => { + * await withTemporaryDatabase(async (db) => { + * const accounts = await db.select().from(schema.accounts); + * assert.deepEqual(accounts, []); + * }); + * }); + * ``` + * + * @param callback A function that receives the migrated database. + * @returns The callback's resolved value. + */ +export async function withTemporaryDatabase( + // oxlint-disable-next-line promise/prefer-await-to-callbacks + callback: (db: Database) => Promise | T, +): Promise> { + const client = new PGlite(); + try { + await client.waitReady; + await migrate({ credentials: { driver: "pglite", client } }); + const db: Database = drizzle({ client, relations, schema }); + // oxlint-disable-next-line promise/prefer-await-to-callbacks + return await callback(db); + } finally { + await client.close(); + } +} diff --git a/packages/models/package.json b/packages/models/package.json index 4a5095f..3133911 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -92,14 +92,14 @@ }, "devDependencies": { "@types/node": "catalog:", - "@types/pg": "^8.20.0", + "@types/pg": "catalog:", "drizzle-kit": "1.0.0-beta.22", "tsdown": "catalog:", "typescript": "catalog:" }, "dependencies": { - "@electric-sql/pglite": "^0.5.3", + "@electric-sql/pglite": "catalog:", "drizzle-orm": "catalog:", - "pg": "^8.21.0" + "pg": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdcf570..296231c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,12 +6,21 @@ settings: catalogs: default: + '@electric-sql/pglite': + specifier: ^0.5.3 + version: 0.5.3 '@types/node': specifier: ^26.0.0 version: 26.0.0 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 drizzle-orm: specifier: 1.0.0-beta.22 version: 1.0.0-beta.22 + pg: + specifier: ^8.21.0 + version: 8.21.0 tsdown: specifier: ^0.22.3 version: 0.22.3 @@ -30,7 +39,7 @@ importers: specifier: workspace:* version: link:../models '@electric-sql/pglite': - specifier: ^0.5.3 + specifier: 'catalog:' version: 0.5.3 '@optique/core': specifier: ^1.1.0 @@ -42,7 +51,7 @@ importers: specifier: 'catalog:' version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) pg: - specifier: ^8.21.0 + specifier: 'catalog:' version: 8.21.0 srvx: specifier: ^0.11.16 @@ -52,7 +61,7 @@ importers: specifier: 'catalog:' version: 26.0.0 '@types/pg': - specifier: ^8.20.0 + specifier: 'catalog:' version: 8.20.0 tsdown: specifier: 'catalog:' @@ -88,6 +97,9 @@ importers: specifier: ^5.21.2 version: 5.21.2(graphql@16.14.2) devDependencies: + '@electric-sql/pglite': + specifier: 'catalog:' + version: 0.5.3 '@types/node': specifier: 'catalog:' version: 26.0.0 @@ -101,20 +113,20 @@ importers: packages/models: dependencies: '@electric-sql/pglite': - specifier: ^0.5.3 + specifier: 'catalog:' version: 0.5.3 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) pg: - specifier: ^8.21.0 + specifier: 'catalog:' version: 8.21.0 devDependencies: '@types/node': specifier: 'catalog:' version: 26.0.0 '@types/pg': - specifier: ^8.20.0 + specifier: 'catalog:' version: 8.20.0 drizzle-kit: specifier: 1.0.0-beta.22 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b4b4756..cb61ad7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,10 @@ allowBuilds: esbuild: true catalog: + "@electric-sql/pglite": ^0.5.3 "@types/node": ^26.0.0 + "@types/pg": ^8.20.0 drizzle-orm: 1.0.0-beta.22 + pg: ^8.21.0 tsdown: ^0.22.3 typescript: ^6.0.3 From 69fc93ec15c568e40501320f4ec3a6694016f3d2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 24 Jun 2026 17:44:14 +0900 Subject: [PATCH 3/5] Add Yoga test harness Add a test harness that builds a Yoga server on top of the temporary migrated database helper. The harness exposes the database, Yoga server, bound fetch function, and a JSON POST helper for GraphQL tests. Cover the harness with a smoke test that posts a simple operation through the Yoga server. Assisted-by: Codex:gpt-5.5 --- packages/graphql/src/harness-smoke.test.ts | 14 ++- packages/graphql/src/harness.test.ts | 120 +++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/packages/graphql/src/harness-smoke.test.ts b/packages/graphql/src/harness-smoke.test.ts index 12d32ee..d1e0c8b 100644 --- a/packages/graphql/src/harness-smoke.test.ts +++ b/packages/graphql/src/harness-smoke.test.ts @@ -18,7 +18,7 @@ import { describe, it } from "node:test"; import { schema } from "@drfed/models"; -import { withTemporaryDatabase } from "./harness.test.ts"; +import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts"; describe("withTemporaryDatabase()", () => { it("provides a migrated temporary database", async () => { @@ -28,3 +28,15 @@ describe("withTemporaryDatabase()", () => { }); }); }); + +describe("withTestHarness()", () => { + it("posts requests to a Yoga server", async () => { + await withTestHarness(async ({ post }) => { + const response = await post({ query: "{ __typename }" }); + assert.ok(response.ok); + assert.deepEqual(await response.json(), { + data: { __typename: "Query" }, + }); + }); + }); +}); diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts index fb0d707..f679fca 100644 --- a/packages/graphql/src/harness.test.ts +++ b/packages/graphql/src/harness.test.ts @@ -13,9 +13,68 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { createYogaServer } from "@drfed/graphql"; +import type { ServerContext, UserContext } from "@drfed/graphql/builder"; import { type Database, migrate, relations, schema } from "@drfed/models"; import { PGlite } from "@electric-sql/pglite"; import { drizzle } from "drizzle-orm/pglite"; +import type { YogaServerInstance } from "graphql-yoga"; + +const testEndpoint = "http://drfed.test/graphql"; + +/** + * The `fetch()` function exposed by the test Yoga server. + */ +export type TestFetch = YogaServerInstance["fetch"]; + +/** + * A JSON GraphQL request body for {@link TestHarness.post}. + */ +export interface RequestBody { + /** + * The GraphQL operation source text. + */ + readonly query: string; + + /** + * Variables for the GraphQL operation. + */ + readonly variables?: Readonly>; + + /** + * The operation name to execute when `query` contains multiple operations. + */ + readonly operationName?: string; +} + +/** + * Utilities for testing the GraphQL server against a temporary database. + */ +export interface TestHarness { + /** + * The migrated temporary database. + */ + readonly db: Database; + + /** + * The test server's `fetch()` function, bound to the Yoga server instance. + */ + readonly fetch: TestFetch; + + /** + * The Yoga server instance under test. + */ + readonly yoga: YogaServerInstance; + + /** + * Sends a JSON `POST` request to the test server's GraphQL endpoint. + * + * @param body The GraphQL request body. + * @param init Additional request options. + * @returns The HTTP response returned by the Yoga server. + */ + post(body: RequestBody, init?: RequestInit): Promise; +} /** * Runs a callback with a fresh in-memory PGlite database. @@ -59,3 +118,64 @@ export async function withTemporaryDatabase( await client.close(); } } + +/** + * Runs a callback with a Yoga server backed by a temporary database. + * + * The harness exposes the migrated database for seeding, the Yoga server for + * lower-level tests, and `post()` for JSON requests. + * + * @example + * ```ts + * import assert from "node:assert/strict"; + * import { it } from "node:test"; + * + * import { withTestHarness } from "./harness.test.ts"; + * + * it("queries the schema", async () => { + * await withTestHarness(async ({ post }) => { + * const response = await post({ query: "{ __typename }" }); + * + * assert.equal(response.status, 200); + * assert.deepEqual(await response.json(), { + * data: { __typename: "Query" }, + * }); + * }); + * }); + * ``` + * + * @param callback A function that receives the test harness. + * @returns The callback's resolved value. + */ +export async function withTestHarness( + // oxlint-disable-next-line promise/prefer-await-to-callbacks + callback: (harness: TestHarness) => Promise | T, +): Promise> { + return await withTemporaryDatabase(async (db) => { + const yoga = createYogaServer(db); + const fetch: TestFetch = yoga.fetch.bind(yoga); + + const harness: TestHarness = { + db, + fetch, + yoga, + post(body, init) { + const headers = new Headers(init?.headers); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + return Promise.resolve( + fetch(testEndpoint, { + ...init, + body: JSON.stringify(body), + headers, + method: "POST", + }), + ); + }, + }; + + // oxlint-disable-next-line promise/prefer-await-to-callbacks + return await callback(harness); + }); +} From bdba4b7423ce9842c81434c279e874edcf01cc49 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 24 Jun 2026 18:22:34 +0900 Subject: [PATCH 4/5] Test account and instance GraphQL fields Add GraphQL tests for accountByUuid, Account.instances, and Instance.members using the temporary Yoga test harness. The tests seed accepted and pending memberships to verify connection results only include accepted relationships. Assisted-by: Codex:gpt-5.5 --- packages/graphql/src/account.test.ts | 185 ++++++++++++++++++++++++++ packages/graphql/src/instance.test.ts | 153 +++++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 packages/graphql/src/account.test.ts create mode 100644 packages/graphql/src/instance.test.ts diff --git a/packages/graphql/src/account.test.ts b/packages/graphql/src/account.test.ts new file mode 100644 index 0000000..b3c2b67 --- /dev/null +++ b/packages/graphql/src/account.test.ts @@ -0,0 +1,185 @@ +// 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 { describe, it } from "node:test"; + +import { type Database, schema } from "@drfed/models"; + +import { withTestHarness } from "./harness.test.ts"; + +const accepted = new Date("2026-06-24T00:00:00.000Z"); +const created = new Date("2026-06-24T00:00:00.000Z"); +const expires = new Date("2026-07-24T00:00:00.000Z"); +const ok = 200; + +const accountId = "00000000-0000-4000-8000-000000000001"; +const memberId = "00000000-0000-4000-8000-000000000002"; +const pendingMemberId = "00000000-0000-4000-8000-000000000003"; +const acceptedInstanceId = "00000000-0000-4000-8000-000000000101"; +const pendingInstanceId = "00000000-0000-4000-8000-000000000102"; + +const accountByUuidQuery = ` + query Account($uuid: UUID!) { + accountByUuid(uuid: $uuid) { + uuid + email + name + } + } +`; + +const accountInstancesQuery = ` + query AccountInstances($uuid: UUID!) { + accountByUuid(uuid: $uuid) { + instances { + totalCount + edges { + node { + uuid + slug + } + } + } + } + } +`; + +const accountByUuidResponse = { + data: { + accountByUuid: { + uuid: accountId, + email: "owner@example.com", + name: "Owner", + }, + }, +}; + +const accountInstancesResponse = { + data: { + accountByUuid: { + instances: { + totalCount: 1, + edges: [ + { + node: { + uuid: acceptedInstanceId, + slug: "test-instance", + }, + }, + ], + }, + }, + }, +}; + +describe("accountByUuid", () => { + it("returns an account by UUID", async () => { + await withTestHarness(async ({ db, post }) => { + await seedAccounts(db); + + const response = await post({ + query: accountByUuidQuery, + variables: { uuid: accountId }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), accountByUuidResponse); + }); + }); +}); + +describe("Account.instances", () => { + it("returns the account's accepted instances", async () => { + await withTestHarness(async ({ db, post }) => { + await seedMembershipGraph(db); + + const response = await post({ + query: accountInstancesQuery, + variables: { uuid: accountId }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), accountInstancesResponse); + }); + }); +}); + +async function seedAccounts(db: Database): Promise { + await db.insert(schema.accounts).values([ + { + id: accountId, + email: "owner@example.com", + name: "Owner", + created, + }, + { + id: memberId, + email: "member@example.com", + name: "Member", + created, + }, + { + id: pendingMemberId, + email: "pending@example.com", + name: "Pending", + created, + }, + ]); +} + +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 db.insert(schema.instanceMembers).values([ + { + accountId, + instanceId: acceptedInstanceId, + accepted, + created, + }, + { + accountId: memberId, + instanceId: acceptedInstanceId, + accepted, + created, + }, + { + accountId: pendingMemberId, + instanceId: acceptedInstanceId, + accepted: null, + created, + }, + { + accountId, + instanceId: pendingInstanceId, + accepted: null, + created, + }, + ]); +} diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts new file mode 100644 index 0000000..109abf7 --- /dev/null +++ b/packages/graphql/src/instance.test.ts @@ -0,0 +1,153 @@ +// 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 { describe, it } from "node:test"; + +import { type Database, schema } from "@drfed/models"; + +import { withTestHarness } from "./harness.test.ts"; + +const accepted = new Date("2026-06-24T00:00:00.000Z"); +const created = new Date("2026-06-24T00:00:00.000Z"); +const expires = new Date("2026-07-24T00:00:00.000Z"); +const ok = 200; + +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 instanceMembersQuery = ` + query InstanceMembers($uuid: UUID!) { + accountByUuid(uuid: $uuid) { + instances { + edges { + node { + members { + totalCount + edges { + node { + uuid + email + name + } + } + } + } + } + } + } + } +`; + +const instanceMembersResponse = { + data: { + accountByUuid: { + instances: { + edges: [ + { + node: { + members: { + totalCount: 2, + edges: [ + { + node: { + uuid: accountId, + email: "owner@example.com", + name: "Owner", + }, + }, + { + node: { + uuid: memberId, + email: "member@example.com", + name: "Member", + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, +}; + +describe("Instance.members", () => { + it("returns the instance's accepted members", async () => { + await withTestHarness(async ({ db, post }) => { + await seedInstanceMembers(db); + + const response = await post({ + query: instanceMembersQuery, + variables: { uuid: accountId }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), instanceMembersResponse); + }); + }); +}); + +async function seedInstanceMembers(db: Database): Promise { + await db.insert(schema.accounts).values([ + { + id: accountId, + email: "owner@example.com", + name: "Owner", + created, + }, + { + id: memberId, + email: "member@example.com", + name: "Member", + created, + }, + { + id: pendingMemberId, + email: "pending@example.com", + name: "Pending", + created, + }, + ]); + await db.insert(schema.instances).values({ + id: instanceId, + slug: "test-instance", + created, + expires, + }); + await db.insert(schema.instanceMembers).values([ + { + accountId, + instanceId, + accepted, + created, + }, + { + accountId: memberId, + instanceId, + accepted, + created, + }, + { + accountId: pendingMemberId, + instanceId, + accepted: null, + created, + }, + ]); +} From e073125655ded3823c0abd2514e0969ebb0941e9 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 24 Jun 2026 19:13:03 +0900 Subject: [PATCH 5/5] Expose membership metadata on edges Add admin flags to accounts and instance memberships, with the corresponding Drizzle migration. Define account and instance membership connections through Drizzle connection helpers so their edges can expose membership metadata. The connections now include created, accepted, and admin fields while keeping accepted-only filtering and totalCount behavior. Assisted-by: Codex:gpt-5.5 --- packages/graphql/src/account.test.ts | 10 + packages/graphql/src/account.ts | 126 +++++-- packages/graphql/src/instance.test.ts | 16 + packages/graphql/src/instance.ts | 123 +++++-- .../migration.sql | 2 + .../snapshot.json | 347 ++++++++++++++++++ packages/models/src/schema.ts | 3 + 7 files changed, 579 insertions(+), 48 deletions(-) create mode 100644 packages/models/drizzle/20260624094817_lethal_sinister_six/migration.sql create mode 100644 packages/models/drizzle/20260624094817_lethal_sinister_six/snapshot.json diff --git a/packages/graphql/src/account.test.ts b/packages/graphql/src/account.test.ts index b3c2b67..2ec82a4 100644 --- a/packages/graphql/src/account.test.ts +++ b/packages/graphql/src/account.test.ts @@ -47,6 +47,9 @@ const accountInstancesQuery = ` instances { totalCount edges { + created + accepted + admin node { uuid slug @@ -74,6 +77,9 @@ const accountInstancesResponse = { totalCount: 1, edges: [ { + created: "2026-06-24T00:00:00.000Z", + accepted: "2026-06-24T00:00:00.000Z", + admin: true, node: { uuid: acceptedInstanceId, slug: "test-instance", @@ -160,24 +166,28 @@ async function seedMembershipGraph(db: Database): Promise { { accountId, instanceId: acceptedInstanceId, + admin: true, accepted, created, }, { accountId: memberId, instanceId: acceptedInstanceId, + admin: false, accepted, created, }, { accountId: pendingMemberId, instanceId: acceptedInstanceId, + admin: false, accepted: null, created, }, { accountId, instanceId: pendingInstanceId, + admin: false, accepted: null, created, }, diff --git a/packages/graphql/src/account.ts b/packages/graphql/src/account.ts index 28d7415..0451807 100644 --- a/packages/graphql/src/account.ts +++ b/packages/graphql/src/account.ts @@ -14,9 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { instanceMembers } from "@drfed/models/schema"; +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"; const AccountRef = builder.drizzleNode("accounts", { name: "Account", @@ -41,40 +44,115 @@ const AccountRef = builder.drizzleNode("accounts", { name: t.exposeString("name", { description: "The display name of the `Account`.", }), + admin: t.exposeBoolean("admin", { + description: "Whether the `Account` has administrator privileges.", + }), created: t.expose("created", { type: "DateTime", description: "The date/time when the `Account` was created.", }), - // We can simplify the below connection by using `relatedConnection()` - // when Drizzle ORM supports additional filters on the junction table - // for relations. See also the below issue: - // https://github.com/drizzle-team/drizzle-orm/issues/5343 - instances: t.drizzleConnection({ - type: "instances", + }), +}); + +export const Account: DrFedObjectRef = AccountRef; + +const accountInstancesConnection = drizzleConnectionHelpers( + builder, + "instanceMembers", + { + query: { + orderBy: { created: "desc" }, + }, + select(nestedSelection) { + return { + with: { + instance: nestedSelection(), + }, + where: { + accepted: { isNotNull: true }, + }, + }; + }, + resolveNode(instanceMember) { + return instanceMember.instance!; + }, + }, +); + +// oxlint-disable-next-line max-lines-per-function +builder.drizzleObjectField(AccountRef, "instances", (t) => + t.connection( + { + type: Instance, description: "The `Instance`s that the `Account` belongs to.", - totalCount(parent, _args, ctx) { - return ctx.db.$count( - instanceMembers, - and( - eq(instanceMembers.accountId, parent.id), - isNotNull(instanceMembers.accepted), + select(args, ctx, nestedSelection) { + return { + with: { + instanceMembers: accountInstancesConnection.getQuery( + args, + ctx, + nestedSelection, + ), + }, + }; + }, + resolve(account, args, ctx) { + return { + ...accountInstancesConnection.resolve( + account.instanceMembers, + args, + ctx, + account, ), - ); + totalCount() { + return ctx.db.$count( + instanceMembers, + and( + eq(instanceMembers.accountId, account.id), + isNotNull(instanceMembers.accepted), + ), + ); + }, + }; }, - resolve(query, parent, _args, ctx) { - return ctx.db.query.instances.findMany( - query({ - where: { - instanceMembers: { accountId: parent.id }, + }, + { + fields(fb) { + return { + totalCount: fb.int({ + description: + "The total number of `Instance`s that the `Account` belongs to." + + "Note that pending memberships are not counted.", + resolve(connection) { + return connection.totalCount(); }, }), - ); + }; }, - }), - }), -}); - -export const Account: DrFedObjectRef = AccountRef; + }, + { + 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) => ({ accountByUuid: t.drizzleField({ diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index 109abf7..5479556 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -39,6 +39,9 @@ const instanceMembersQuery = ` members { totalCount edges { + created + accepted + admin node { uuid email @@ -64,6 +67,9 @@ const instanceMembersResponse = { totalCount: 2, edges: [ { + created: "2026-06-24T00:00:00.000Z", + accepted: "2026-06-24T00:00:00.000Z", + admin: true, node: { uuid: accountId, email: "owner@example.com", @@ -71,6 +77,9 @@ const instanceMembersResponse = { }, }, { + created: "2026-06-24T00:00:00.000Z", + accepted: "2026-06-24T00:00:00.000Z", + admin: false, node: { uuid: memberId, email: "member@example.com", @@ -103,24 +112,28 @@ describe("Instance.members", () => { }); }); +// oxlint-disable-next-line max-lines-per-function async function seedInstanceMembers(db: Database): Promise { await db.insert(schema.accounts).values([ { id: accountId, email: "owner@example.com", name: "Owner", + admin: false, created, }, { id: memberId, email: "member@example.com", name: "Member", + admin: false, created, }, { id: pendingMemberId, email: "pending@example.com", name: "Pending", + admin: false, created, }, ]); @@ -134,18 +147,21 @@ async function seedInstanceMembers(db: Database): Promise { { accountId, instanceId, + admin: true, accepted, created, }, { accountId: memberId, instanceId, + admin: false, accepted, created, }, { accountId: pendingMemberId, instanceId, + admin: false, accepted: null, created, }, diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index dc23961..ebe5399 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -14,8 +14,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { instanceMembers } from "@drfed/models/schema"; +import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; +// oxlint-disable-next-line import/no-cycle +import { Account } from "./account.ts"; import builder, { type DrFedObjectRef } from "./builder.ts"; const InstanceRef = builder.drizzleNode("instances", { @@ -41,33 +44,105 @@ const InstanceRef = builder.drizzleNode("instances", { type: "DateTime", description: "The creation date/time of the `Instance`.", }), - // We can simplify the below connection by using `relatedConnection()` - // when Drizzle ORM supports additional filters on the junction table - // for relations. See also the below issue: - // https://github.com/drizzle-team/drizzle-orm/issues/5343 - members: t.drizzleConnection({ - type: "accounts", + }), +}); + +export const Instance: DrFedObjectRef = InstanceRef; + +const instanceMembersConnection = drizzleConnectionHelpers( + builder, + "instanceMembers", + { + query: { + orderBy: { created: "desc" }, + }, + select(nestedSelection) { + return { + with: { + account: nestedSelection(), + }, + where: { + accepted: { isNotNull: true }, + }, + }; + }, + resolveNode(instanceMember) { + return instanceMember.account; + }, + }, +); + +// oxlint-disable-next-line max-lines-per-function +builder.drizzleObjectField(InstanceRef, "members", (t) => + t.connection( + { + type: Account, description: "The `Account`s that belong to the `Instance`.", - totalCount(parent, _args, ctx) { - return ctx.db.$count( - instanceMembers, - and( - eq(instanceMembers.instanceId, parent.id), - isNotNull(instanceMembers.accepted), + 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), + ), + ); + }, + }; }, - resolve(query, parent, _args, ctx) { - return ctx.db.query.accounts.findMany( - query({ - where: { - instanceMembers: { instanceId: parent.id }, + }, + { + 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(); }, }), - ); + }; }, - }), - }), -}); - -export const Instance: DrFedObjectRef = InstanceRef; + }, + { + 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/models/drizzle/20260624094817_lethal_sinister_six/migration.sql b/packages/models/drizzle/20260624094817_lethal_sinister_six/migration.sql new file mode 100644 index 0000000..3237c9f --- /dev/null +++ b/packages/models/drizzle/20260624094817_lethal_sinister_six/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "accounts" ADD COLUMN "admin" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "instance_members" ADD COLUMN "admin" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/models/drizzle/20260624094817_lethal_sinister_six/snapshot.json b/packages/models/drizzle/20260624094817_lethal_sinister_six/snapshot.json new file mode 100644 index 0000000..5db731b --- /dev/null +++ b/packages/models/drizzle/20260624094817_lethal_sinister_six/snapshot.json @@ -0,0 +1,347 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "15845826-f718-42c3-85f2-fed368f261f4", + "prevIds": ["f04d695a-a99e-4bb9-be8d-190f9e7b67dc"], + "ddl": [ + { + "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" + }, + { + "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": "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": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "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": "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" + }, + { + "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" + }, + { + "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" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["slug"], + "nullsNotDistinct": false, + "name": "instances_slug_key", + "schema": "public", + "table": "instances", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_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,100}$'", + "name": "instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "instances" + }, + { + "value": "\"expires\" < (\"created\" + INTERVAL '1 year')", + "name": "instances_expires_check", + "entityType": "checks", + "schema": "public", + "table": "instances" + } + ], + "renames": [] +} diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index fccbdb6..38ba83c 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -15,6 +15,7 @@ // along with this program. If not, see . import { sql } from "drizzle-orm"; import { + boolean, check, index, pgTable, @@ -33,6 +34,7 @@ export const accounts = pgTable( id: uuid().primaryKey(), email: varchar({ length: 255 }).notNull().unique(), name: varchar({ length: 100 }).notNull(), + admin: boolean().notNull().default(false), created: timestamp({ withTimezone: true }) .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -88,6 +90,7 @@ export const instanceMembers = pgTable( instanceId: uuid() .notNull() .references(() => instances.id), + admin: boolean().notNull().default(false), accepted: timestamp({ withTimezone: true }), created: timestamp({ withTimezone: true }) .notNull()