Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/drfed/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
1 change: 1 addition & 0 deletions packages/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"test": "node --test"
},
"devDependencies": {
"@electric-sql/pglite": "catalog:",
"@types/node": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
Expand Down
195 changes: 195 additions & 0 deletions packages/graphql/src/account.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// 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 <https://www.gnu.org/licenses/>.
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 {
created
accepted
admin
node {
uuid
slug
}
}
}
}
}
`;

const accountByUuidResponse = {
data: {
accountByUuid: {
uuid: accountId,
email: "owner@example.com",
name: "Owner",
},
},
};

const accountInstancesResponse = {
data: {
accountByUuid: {
instances: {
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",
},
},
],
},
},
},
};

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<void> {
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<void> {
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,
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,
},
]);
}
136 changes: 123 additions & 13 deletions packages/graphql/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,37 +13,147 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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",
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`.",
}),
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.",
}),
}),
id: {
column(account) {
return account.id;
},
description: "The unique identifier of the `Account`.",
},
name: "Account",
});

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.",
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),
),
);
},
};
},
},
{
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();
},
}),
};
},
},
{
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({
args: {
Expand Down
Loading
Loading