diff --git a/.oxlintrc.json b/.oxlintrc.json index 9126f90..4382ac2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -24,12 +24,12 @@ { "ignoreConsecutiveComments": true } ], "eslint/curly": ["error", "multi-line"], - "node/no-top-level-await": "off", "oxc/no-optional-chaining": "off", "oxc/no-async-await": "off", "eslint/eqeqeq": ["off", "smart"], "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 }], @@ -41,6 +41,7 @@ "eslint/no-eq-null": "off", "eslint/no-magic-numbers": "off", "eslint/no-ternary": "off", + "eslint/no-negated-condition": "off", "eslint/no-nested-ternary": "off", "eslint/no-undefined": "off", "eslint/no-void": "off", @@ -68,6 +69,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": [ diff --git a/mise.toml b/mise.toml index 5154641..2de839d 100644 --- a/mise.toml +++ b/mise.toml @@ -4,6 +4,7 @@ min_version = "2026.6.10" "aqua:dahlia/hongdown" = "0.4.3" "github:nushell/nushell" = "0.114.1" node = "26" +"npm:@fedify/cli" = "2.4.0-dev.1758" "npm:oxlint" = "1.75.0" "npm:oxlint-tsgolint" = "7.0.2001" "npm:pglite-cli" = "0.0.1" diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 077b548..9b633cd 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, @@ -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/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/package.json b/packages/graphql/package.json index cc5e926..39c8d73 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -50,6 +50,10 @@ "types": "./dist/account.d.mts", "default": "./dist/account.mjs" }, + "./actor": { + "types": "./dist/actor.d.mts", + "default": "./dist/actor.mjs" + }, "./builder": { "types": "./dist/builder.d.mts", "default": "./dist/builder.mjs" @@ -71,6 +75,7 @@ "entry": [ "src/index.ts", "src/account.ts", + "src/actor.ts", "src/builder.ts", "src/instance.ts", "src/schema.ts" diff --git a/packages/graphql/src/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/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/actor.test.ts b/packages/graphql/src/actor.test.ts new file mode 100644 index 0000000..e699bd1 --- /dev/null +++ b/packages/graphql/src/actor.test.ts @@ -0,0 +1,304 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// oxlint-disable max-lines + +import assert from "node:assert/strict"; + +import { type Database, schema } from "@drfed/models"; +import { describe, it } from "@logtape/testing-node/autoload"; + +import { withTestHarness } from "./harness.test.ts"; + +const accepted = new Date("2026-08-04T00:00:00.000Z"); +const created = new Date("2026-08-04T00:00:00.000Z"); +const expires = new Date("2030-08-04T00:00:00.000Z"); +const ok = 200; + +const accountId = "00000000-0000-4000-8000-000000000001"; +const localInstanceId = "00000000-0000-4000-8000-000000000101"; +const remoteInstanceId = "00000000-0000-4000-8000-000000000102"; +const localActorId = "00000000-0000-4000-8000-000000000201"; +const remoteActorId = "00000000-0000-4000-8000-000000000202"; +const sessionId = "00000000-0000-4000-8000-000000000301"; +const accessToken = "test-access-token"; + +const genActorsMutation = ` + mutation GenActors($instance: ID!, $size: Int!) { + genActors(instance: $instance, size: $size) { + resultType: __typename + ... on CreateActorsSuccess { + actors { + username + } + } + ... on CreateActorsError { + type + message + } + } + } +`; + +const actorQuery = ` + query Actor($id: ID!) { + node(id: $id) { + ... on Actor { + id + iri + handle + type + username + instance { + uuid + location + host + } + inboxUrl + outboxUrl + avatarUrl + followersUrl + followeesUrl + headerUrl + profileUrl + featuredUrl + } + } + } +`; + +describe("Mutation.genActors", () => { + it("creates local actors", async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + + const response = await post( + { + query: genActorsMutation, + variables: { + instance: globalId("Instance", localInstanceId), + size: 2, + }, + }, + auth, + ); + + assert.equal(response.status, ok); + const body = await response.json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.genActors.resultType, "CreateActorsSuccess"); + assert.equal(body.data.genActors.actors.length, 2); + assert.equal( + body.data.genActors.actors.every( + ({ username }: { username: unknown }) => typeof username === "string", + ), + true, + ); + + const actors = await db.select().from(schema.actors); + assert.equal(actors.length, 2); + assert.equal( + actors.every( + (actor) => + actor.instanceId === localInstanceId && + actor.location === "Local" && + actor.type === "Person", + ), + true, + ); + + const localActors = await db.select().from(schema.localActors); + assert.equal(localActors.length, 2); + assert.deepEqual( + new Set(localActors.map(({ id }) => id)), + new Set(actors.map(({ id }) => id)), + ); + }); + }); +}); + +describe("Actor", () => { + it("returns a local actor", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + + const response = await post({ + query: actorQuery, + variables: { id: globalId("Actor", localActorId) }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), { + data: { + node: { + id: globalId("Actor", localActorId), + iri: `https://test-instance.drfed.org/users/${localActorId}`, + handle: "@alice@test-instance.drfed.org", + type: "Person", + username: "alice", + instance: { + uuid: localInstanceId, + location: "Local", + host: "test-instance.drfed.org", + }, + inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`, + outboxUrl: `https://test-instance.drfed.org/users/${localActorId}/outbox`, + avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`, + followersUrl: `https://test-instance.drfed.org/users/${localActorId}/followers`, + followeesUrl: `https://test-instance.drfed.org/users/${localActorId}/followees`, + headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`, + profileUrl: "https://test-instance.drfed.org/@alice", + featuredUrl: `https://test-instance.drfed.org/users/${localActorId}/featured`, + }, + }, + }); + }); + }); + + it("returns a remote actor", async () => { + await withTestHarness(async ({ db, post }) => { + await seedRemoteActor(db); + + const response = await post({ + query: actorQuery, + variables: { id: globalId("Actor", remoteActorId) }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), { + data: { + node: { + id: globalId("Actor", remoteActorId), + iri: "https://remote.example.com/users/bob", + handle: "@bob@remote.example.com", + type: "Service", + username: "bob", + instance: { + uuid: remoteInstanceId, + location: "Remote", + host: "remote.example.com", + }, + inboxUrl: "https://remote.example.com/users/bob/inbox", + outboxUrl: "https://remote.example.com/users/bob/outbox", + avatarUrl: "https://remote.example.com/users/bob/avatar.png", + followersUrl: "https://remote.example.com/users/bob/followers", + followeesUrl: "https://remote.example.com/users/bob/followees", + headerUrl: "https://remote.example.com/users/bob/header.png", + profileUrl: "https://remote.example.com/@bob", + featuredUrl: "https://remote.example.com/users/bob/featured", + }, + }, + }); + }); + }); +}); + +function globalId(type: "Actor" | "Instance", id: string): string { + return Buffer.from(`${type}:${id}`).toString("base64"); +} + +async function seedAuthenticatedLocalInstance( + db: Database, +): Promise { + await db.insert(schema.accounts).values({ + id: accountId, + email: "owner@example.com", + name: "Owner", + created, + }); + await db.insert(schema.sessions).values({ + id: sessionId, + accountId, + tokenHash: await hashSecret(accessToken), + }); + await seedLocalInstance(db); + await db.insert(schema.instanceMembers).values({ + accountId, + instanceId: localInstanceId, + admin: true, + accepted, + created, + }); + return { headers: { authorization: `Bearer ${accessToken}` } }; +} + +async function seedLocalActor(db: Database): Promise { + await seedLocalInstance(db); + await db.insert(schema.actors).values({ + id: localActorId, + instanceId: localInstanceId, + location: "Local", + type: "Person", + username: "alice", + created, + }); + await db.insert(schema.localActors).values({ + id: localActorId, + avatar: "avatar.png", + header: "header.png", + }); +} + +async function seedLocalInstance(db: Database): Promise { + await db.insert(schema.instances).values({ + id: localInstanceId, + location: "Local", + created, + }); + await db.insert(schema.localInstances).values({ + id: localInstanceId, + slug: "test-instance", + expires, + }); +} + +async function seedRemoteActor(db: Database): Promise { + await db.insert(schema.instances).values({ + id: remoteInstanceId, + location: "Remote", + created, + }); + await db.insert(schema.remoteInstances).values({ + id: remoteInstanceId, + host: "remote.example.com", + }); + await db.insert(schema.actors).values({ + id: remoteActorId, + instanceId: remoteInstanceId, + location: "Remote", + type: "Service", + username: "bob", + created, + }); + await db.insert(schema.remoteActors).values({ + id: remoteActorId, + iriUrl: "https://remote.example.com/users/bob", + inboxUrl: "https://remote.example.com/users/bob/inbox", + outboxUrl: "https://remote.example.com/users/bob/outbox", + avatarUrl: "https://remote.example.com/users/bob/avatar.png", + followersUrl: "https://remote.example.com/users/bob/followers", + followeesUrl: "https://remote.example.com/users/bob/followees", + headerUrl: "https://remote.example.com/users/bob/header.png", + profileUrl: "https://remote.example.com/@bob", + featuredUrl: "https://remote.example.com/users/bob/featured", + }); +} + +async function hashSecret(raw: string): Promise { + return new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw)), + ).toHex(); +} diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts new file mode 100644 index 0000000..2f68796 --- /dev/null +++ b/packages/graphql/src/actor.ts @@ -0,0 +1,420 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// oxlint-disable max-lines-per-function eslint/max-lines + +import { type relations, schema } from "@drfed/models"; +import { actorTypeEnum } from "@drfed/models/schema"; +import type { ExpandContext } from "@fedify/uri-template"; +import type { BuildQueryResult, DBQueryConfig } from "drizzle-orm"; +import type { PgInsertValue } from "drizzle-orm/pg-core"; +import { and, eq, gt, isNotNull } from "drizzle-orm/sql/expressions"; +import { v7 as uuid } from "uuid"; + +import builder, { type DrFedObjectRef } from "./builder.ts"; +// oxlint-disable-next-line import/no-cycle +import { Instance } from "./instance.ts"; +import templates from "./uri-templates.ts"; + +export const ActorType = builder.enumType("ActorType", { + values: actorTypeEnum.enumValues, +}); + +export const ACTOR_TYPES = actorTypeEnum.enumValues.join(" | "); + +type DrizzleRelations = typeof relations; +type ActorTableConfig = DrizzleRelations["actors"]; +type ActorSelect = DBQueryConfig<"one", DrizzleRelations, ActorTableConfig>; + +const ACTOR_REF_SELECT = { + columns: { id: true, username: true }, + with: { + instance: { + columns: { id: true }, + with: { localInstances: true, remoteInstances: true }, + }, + localActor: true, + remoteActor: true, + }, +} as const satisfies ActorSelect; + +type SelectedActor = BuildQueryResult< + DrizzleRelations, + ActorTableConfig, + typeof ACTOR_REF_SELECT +>; + +interface LocalTemplateArgs extends ExpandContext { + id: string; + host: string; + username: string; + avatar: string | null; + header: string | null; +} + +const getLocalInfo = (actor: SelectedActor, root: string): LocalTemplateArgs => + actor?.localActor == null || actor.instance?.localInstances == null + ? throwNonLocal(actor.id, "actor") + : { + id: actor.id, + host: `${actor.instance.localInstances.slug}.${root}`, + username: actor.username, + avatar: actor.localActor.avatar, + header: actor.localActor.header, + }; + +function throwNonLocal(id: string, kind: "actor" | "instance"): never { + throw new Error(`This ${kind} is not local: ${id}`); +} + +const ActorRef = builder.drizzleNode("actors", { + name: "Actor", + description: "Represents an `Actor` in the DrFed platform.", + id: { + column: ({ id }) => id, + description: "The unique identifier of the `Actor`.", + }, + select: ACTOR_REF_SELECT, + fields: (t) => ({ + iri: t.field({ + type: "String", + resolve: (parent, _, ctx) => + parent.remoteActor?.iriUrl ?? + templates.iri.expand(getLocalInfo(parent, ctx.root)), + description: "The Internationalized Resource Identifier of the `Actor`", + }), + handle: t.field({ + type: "String", + description: "The handle of the `Actor`.", + resolve: (parent, _, ctx) => + templates.handle.expand( + parent.instance?.remoteInstances + ? { + username: parent.username, + host: parent.instance.remoteInstances.host, + } + : getLocalInfo(parent, ctx.root), + ), + }), + type: t.expose("type", { + type: ActorType, + description: `The type of the \`Actor\`: ${ACTOR_TYPES}`, + }), + username: t.exposeString("username", { + description: "The username of the `Actor`.", + }), + instance: t.relation("instance", { + description: "The `Instance` that the `Actor` belongs to.", + }), + inboxUrl: t.field({ + type: "String", + description: "The inbox URL of the `Actor`.", + resolve: (parent, _, ctx) => + parent.remoteActor?.inboxUrl ?? + templates.inbox.expand(getLocalInfo(parent, ctx.root)), + }), + outboxUrl: t.field({ + type: "String", + description: "The outbox URL of the `Actor`.", + resolve: (parent, _, ctx) => + parent.remoteActor?.outboxUrl ?? + templates.outbox.expand(getLocalInfo(parent, ctx.root)), + }), + avatarUrl: t.field({ + type: "String", + description: "The avatar URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor + ? parent.remoteActor.avatarUrl + : templates.avatar.expand(getLocalInfo(parent, ctx.root)), + }), + followersUrl: t.field({ + type: "String", + description: "The followers URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor != null + ? parent.remoteActor.followersUrl + : templates.followers.expand(getLocalInfo(parent, ctx.root)), + }), + followeesUrl: t.field({ + type: "String", + description: "The followees URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor != null + ? parent.remoteActor.followeesUrl + : templates.followees.expand(getLocalInfo(parent, ctx.root)), + }), + headerUrl: t.field({ + type: "String", + description: "The header URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor != null + ? parent.remoteActor.headerUrl + : templates.header.expand(getLocalInfo(parent, ctx.root)), + }), + profileUrl: t.field({ + type: "String", + description: "The profile URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor != null + ? parent.remoteActor.profileUrl + : templates.profile.expand(getLocalInfo(parent, ctx.root)), + }), + featuredUrl: t.field({ + type: "String", + description: "The featured URL of the `Actor`.", + nullable: true, + resolve: (parent, _, ctx) => + parent.remoteActor != null + ? parent.remoteActor.featuredUrl + : templates.featured.expand(getLocalInfo(parent, ctx.root)), + }), + }), +}); + +export const Actor: DrFedObjectRef = ActorRef; + +const CreateActorsRef = builder.drizzleNode("localActors", { + name: "CreateActors", + description: "Represents an `Actor` in the DrFed platform.", + id: { + column: ({ id }) => id, + description: "The unique identifier of the `Actor`.", + }, + select: { + columns: { id: true }, + with: { actor: { columns: { username: true } } }, + }, + fields: (t) => ({ + username: t.field({ + type: "String", + description: "username", + resolve: ({ actor }) => actor!.username, + }), + }), +}); + +interface CreateActorsSuccess { + readonly actors: readonly (typeof CreateActorsRef.$inferType)[]; +} + +const CreateActorsSuccessRef = builder.objectRef( + "CreateActorsSuccess", +); + +CreateActorsSuccessRef.implement({ + fields: (t) => ({ + actors: t.field({ + type: [CreateActorsRef], + resolve: ({ actors }) => actors, + }), + }), +}); + +const INVALID_SIZE = "InvalidSize" as const; +const INSTANCE_NOT_FOUND = "InstanceNotFound" as const; +const TOO_MANY_ACTORS = "TooManyActors" as const; +const CreateActorsErrors = [ + INVALID_SIZE, + INSTANCE_NOT_FOUND, + TOO_MANY_ACTORS, +] as const; + +const CreateActorsErrorType = builder.enumType("CreateActorsErrorType", { + values: CreateActorsErrors, +}); + +interface CreateActorsError { + readonly type: typeof CreateActorsErrorType.$inferType; + readonly message: string; +} + +const CreateActorsErrorRef = + builder.objectRef("CreateActorsError"); + +CreateActorsErrorRef.implement({ + description: "Represents an error that occurred while creating an `Actor`.", + fields: (t) => ({ + type: t.expose("type", { + type: CreateActorsErrorType, + description: + "The type of the error. Use this for programmatic error handling.", + }), + message: t.exposeString("message", { + description: + "A human-readable message describing the error. " + + "Don't use this for programmatic error handling, " + + "use the `type` field instead.", + }), + }), +}); + +const CreateActorsResult = builder.unionType("CreateActorsResult", { + types: [CreateActorsSuccessRef, CreateActorsErrorRef], + resolveType(value) { + if ("message" in value) return CreateActorsErrorRef; + return CreateActorsSuccessRef; + }, +}); + +interface SelectedInstance { + maxActors: number; + slug: string; +} + +builder.mutationFields((t) => ({ + genActors: t.field({ + type: CreateActorsResult, + description: "Create actors.", + authScopes: { authenticated: true }, + args: { + instance: t.arg.globalID({ + for: Instance, + required: true, + description: "The ID of the target instance", + }), + size: t.arg({ + type: "Int", + required: true, + description: "How many actors to generate", + }), + }, + async resolve(_query, { instance: { id: instanceId }, size }, ctx) { + if (size < 1) { + return { + type: INVALID_SIZE, + message: `${size} is too small. At least 1 or more.`, + }; + } + if (ctx.account == null) { + // Note that the following error is not expected to be thrown, + // because the `authScopes` option above should prevent this resolver + throw new Error("You must be authenticated to create actors."); + } + const { account, root } = ctx; + + let instance: SelectedInstance | undefined; + let tooManyActors = false; + try { + return await ctx.db.transaction(async (tx) => { + // Find the instance that the account is included + [instance] = await tx + .select({ + maxActors: schema.localInstances.maxActors, + slug: schema.localInstances.slug, + }) + .from(schema.instanceMembers) + .innerJoin( + schema.instances, + eq(schema.instanceMembers.instanceId, schema.instances.id), + ) + .innerJoin( + schema.localInstances, + eq(schema.instances.id, schema.localInstances.id), + ) + .where( + and( + eq(schema.instanceMembers.accountId, account.id), + gt(schema.localInstances.expires, new Date()), + eq(schema.instances.id, instanceId), + isNotNull(schema.instanceMembers.accepted), + ), + ) + .limit(1); + if (instance == null) throw new Error(INSTANCE_NOT_FOUND); + const { slug, maxActors } = instance; + const host = `${slug}.${root}`; + const currActors = await tx.$count( + schema.actors, + eq(schema.actors.instanceId, instanceId), + ); + + if (size + currActors > maxActors) { + return { + type: TOO_MANY_ACTORS, + message: `${size} is too big. The maximum number of actors of ${ + host + } is ${maxActors} and the current number of actors is ${ + currActors + }.`, + }; + } + // Create actors + const createdActors = await tx + .insert(schema.actors) + .values( + Array.from({ length: size }).map(() => genActor(instanceId)), + ) + .returning(); + const actorCounts = await tx.$count( + schema.actors, + eq(schema.actors.instanceId, instanceId), + ); + if (actorCounts > maxActors) { + tooManyActors = true; + tx.rollback(); + } + const localActors = await tx + .insert(schema.localActors) + .values(createdActors) + .returning(); + // oxlint-disable-next-line eslint/id-length + const actors = localActors.map((local, i) => ({ + actor: createdActors[i]!, + ...local, + })); + return { actors }; + }); + } catch (e) { + if (instance == null) { + if (e instanceof Error && e.message === INSTANCE_NOT_FOUND) { + return { + type: INSTANCE_NOT_FOUND, + message: "Can't find the instance.", + }; + } + } else if (tooManyActors) { + return { + type: TOO_MANY_ACTORS, + message: `${ + instance.slug + } instance reached the maximum number of actors (${ + instance.maxActors + })`, + }; + } + throw e; + } + }, + }), +})); + +function genActor(instanceId: string): PgInsertValue { + const id = uuid(); + return { + id, + // FIXME: Generate handle using Faker.js or something + username: id, + location: "Local", + instanceId, + type: "Person", + }; +} diff --git a/packages/graphql/src/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/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/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index cc32334..00ab79e 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -14,11 +14,12 @@ // 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"; 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,12 +165,18 @@ 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); 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: { id: instance.id }, + }); + assert.equal(local?.slug, "my-instance"); const members = await db.select().from(schema.instanceMembers); assert.equal(members.length, 1); @@ -226,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. @@ -267,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([ { @@ -294,8 +373,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([ @@ -322,3 +405,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 32cdd5c..48812bb 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -14,9 +14,8 @@ // 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 } 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"; @@ -24,8 +23,14 @@ import { v7 as uuid } from "uuid"; // oxlint-disable-next-line import/no-cycle import { Account } from "./account.ts"; +// oxlint-disable-next-line import/no-cycle +import { ACTOR_TYPES, Actor, ActorType } from "./actor.ts"; import builder, { type DrFedObjectRef } from "./builder.ts"; +const Location = builder.enumType("Location", { + values: locationEnum.enumValues, +}); + const InstanceRef = builder.drizzleNode("instances", { name: "Instance", description: "Represents an `Instance` in the DrFed platform.", @@ -40,10 +45,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 +75,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( @@ -77,7 +104,6 @@ const instanceMembersConnection = drizzleConnectionHelpers( }, ); -// oxlint-disable-next-line max-lines-per-function builder.drizzleObjectField(InstanceRef, "members", (t) => t.connection( { @@ -152,6 +178,72 @@ builder.drizzleObjectField(InstanceRef, "members", (t) => ), ); +const actorsConnection = drizzleConnectionHelpers(builder, "actors", { + query: { orderBy: { created: "desc" } }, + select: (nestedSelection) => ({ with: { instance: nestedSelection() } }), + resolveNode: ({ instanceId }) => instanceId, +}); + +// oxlint-disable-next-line max-lines-per-function +builder.drizzleObjectField(InstanceRef, "actors", (t) => + t.connection( + { + type: Actor, + description: "The `Actor`s that belong to the `Instance`.", + select(args, ctx, nestedSelection) { + return { + with: { + actors: actorsConnection.getQuery(args, ctx, nestedSelection), + }, + }; + }, + resolve(instance, args, ctx) { + return { + ...actorsConnection.resolve(instance.actors, args, ctx, instance), + totalCount() { + return ctx.db.$count( + schema.actors, + eq(schema.actors.instanceId, instance.id), + ); + }, + }; + }, + }, + { + fields(fb) { + return { + totalCount: fb.int({ + description: + "The total number of `Actor`s that belong to the `Instance`." + + "Note that pending members are not counted.", + resolve(connection) { + return connection.totalCount(); + }, + }), + }; + }, + }, + { + fields(fb) { + return { + created: fb.expose("created", { + type: "DateTime", + description: + "The date/time when the `Account` was added to the `Instance`.", + }), + type: fb.expose("type", { + type: ActorType, + description: `The type of the \`Actor\`: ${ACTOR_TYPES}`, + }), + username: fb.exposeString("username", { + description: "The username of the `Actor`.", + }), + }; + }, + }, + ), +); + export const CreateInstanceErrorType = builder.enumType( "CreateInstanceErrorType", { @@ -218,15 +310,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 +328,13 @@ builder.mutationFields((t) => ({ tooManyInstances = true; tx.rollback(); } + await tx.insert(schema.localInstances).values({ + id, + slug, + expires: new Date( + Temporal.Now.instant().add({ hours: YEAR_BY_HOURS }).toString(), + ), + }); return instance; }); } catch (e) { @@ -254,7 +348,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.`, @@ -266,3 +360,5 @@ builder.mutationFields((t) => ({ }, }), })); + +const YEAR_BY_HOURS = 8760; diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts index 84bfa4f..f5af295 100644 --- a/packages/graphql/src/schema.ts +++ b/packages/graphql/src/schema.ts @@ -17,6 +17,7 @@ import "./account.ts"; import "./instance.ts"; import "./auth/entry.ts"; +import "./actor.ts"; import builder from "./builder.ts"; builder.queryType({}); diff --git a/packages/graphql/src/uri-templates.ts b/packages/graphql/src/uri-templates.ts new file mode 100644 index 0000000..29e5a2a --- /dev/null +++ b/packages/graphql/src/uri-templates.ts @@ -0,0 +1,52 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { Template } from "@fedify/uri-template"; + +export const avatarTemplate = new Template( + "https://{host}/users/{id}/avatar/{avatar}", +); +export const featuredTemplate = new Template( + "https://{host}/users/{id}/featured", +); +export const followeesTemplate = new Template( + "https://{host}/users/{id}/followees", +); +export const followersTemplate = new Template( + "https://{host}/users/{id}/followers", +); +export const handleTemplate = new Template("@{username}@{host}"); +export const headerTemplate = new Template( + "https://{host}/users/{id}/header/{header}", +); +export const inboxTemplate = new Template("https://{host}/users/{id}/inbox"); +export const iriTemplate = new Template("https://{host}/users/{id}"); +export const outboxTemplate = new Template("https://{host}/users/{id}/outbox"); +export const profileTemplate = new Template("https://{host}/@{username}"); + +const templates = { + avatar: avatarTemplate, + featured: featuredTemplate, + followees: followeesTemplate, + followers: followersTemplate, + handle: handleTemplate, + header: headerTemplate, + inbox: inboxTemplate, + iri: iriTemplate, + outbox: outboxTemplate, + profile: profileTemplate, +}; +export default templates; 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": [] +} diff --git a/packages/models/drizzle/20260803204536_tired_ken_ellis/migration.sql b/packages/models/drizzle/20260803204536_tired_ken_ellis/migration.sql new file mode 100644 index 0000000..9e685cb --- /dev/null +++ b/packages/models/drizzle/20260803204536_tired_ken_ellis/migration.sql @@ -0,0 +1,66 @@ +CREATE TYPE "actor_type" AS ENUM('Application', 'Group', 'Organization', 'Person', 'Service');--> statement-breakpoint +CREATE TABLE "actors" ( + "id" uuid PRIMARY KEY, + "location" "location" NOT NULL, + "type" "actor_type" NOT NULL, + "username" text NOT NULL, + "instanceId" uuid NOT NULL, + "name" text, + "bioHtml" text, + "automaticallyApprovesFollowers" boolean DEFAULT false NOT NULL, + "fieldHtmls" jsonb DEFAULT '{}' NOT NULL, + "emojis" jsonb DEFAULT '{}' NOT NULL, + "tags" jsonb DEFAULT '{}' NOT NULL, + "sensitive" boolean DEFAULT false NOT NULL, + "suspended" timestamp with time zone, + "suspendedUntil" timestamp with time zone, + "successorId" uuid, + "aliases" text[] DEFAULT (ARRAY[]::text[])::text[] NOT NULL, + "followeesCount" integer DEFAULT 0 NOT NULL, + "followersCount" integer DEFAULT 0 NOT NULL, + "postsCount" integer DEFAULT 0 NOT NULL, + "updated" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "published" timestamp with time zone, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "deleted" timestamp with time zone, + CONSTRAINT "username_key" UNIQUE("username","instanceId"), + CONSTRAINT "actors_id_location_key" UNIQUE("id","location"), + CONSTRAINT "actors_username_check" CHECK ("username" NOT LIKE '%@%'), + CONSTRAINT "actors_suspended_check" CHECK ( + "suspendedUntil" IS NULL OR ( + "suspended" IS NOT NULL AND + "suspendedUntil" > "suspended" + ) + ) +); +--> statement-breakpoint +CREATE TABLE "local_actors" ( + "id" uuid PRIMARY KEY, + "avatar" text, + "header" text, + "location" "location" DEFAULT 'Local'::"location" NOT NULL, + CONSTRAINT "local_check" CHECK ("location" = 'Local') +); +--> statement-breakpoint +CREATE TABLE "remote_actors" ( + "id" uuid PRIMARY KEY, + "iriUrl" text NOT NULL UNIQUE, + "inboxUrl" text NOT NULL, + "outboxUrl" text NOT NULL, + "followersUrl" text, + "followeesUrl" text, + "featuredUrl" text, + "profileUrl" text, + "avatarUrl" text, + "headerUrl" text, + "location" "location" DEFAULT 'Remote'::"location" NOT NULL, + CONSTRAINT "remote_check" CHECK ("location" = 'Remote') +); +--> statement-breakpoint +CREATE INDEX "actor_instance_index" ON "actors" ("instanceId");--> statement-breakpoint +ALTER TABLE "actors" ADD CONSTRAINT "actors_instanceId_instances_id_fkey" FOREIGN KEY ("instanceId") REFERENCES "instances"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "actors" ADD CONSTRAINT "actors_successorId_actors_id_fkey" FOREIGN KEY ("successorId") REFERENCES "actors"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "local_actors" ADD CONSTRAINT "local_actors_id_actors_id_fkey" FOREIGN KEY ("id") REFERENCES "actors"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "local_actors" ADD CONSTRAINT "local_actor_fk" FOREIGN KEY ("id","location") REFERENCES "actors"("id","location") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "remote_actors" ADD CONSTRAINT "remote_actors_id_actors_id_fkey" FOREIGN KEY ("id") REFERENCES "actors"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "remote_actors" ADD CONSTRAINT "remote_actor_fk" FOREIGN KEY ("id","location") REFERENCES "actors"("id","location") ON DELETE CASCADE; \ No newline at end of file diff --git a/packages/models/drizzle/20260803204536_tired_ken_ellis/snapshot.json b/packages/models/drizzle/20260803204536_tired_ken_ellis/snapshot.json new file mode 100644 index 0000000..b21dbf7 --- /dev/null +++ b/packages/models/drizzle/20260803204536_tired_ken_ellis/snapshot.json @@ -0,0 +1,1464 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "dac33dc8-f18a-423f-809f-b6c390dd1bdc", + "prevIds": ["08f1dfcb-1110-4550-9777-36d017ecfb10"], + "ddl": [ + { + "values": ["Application", "Group", "Organization", "Person", "Service"], + "name": "actor_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Local", "Remote"], + "name": "location", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instance_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "login_tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "remote_actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "remote_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "max_instances", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "location", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "actor_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bioHtml", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "automaticallyApprovesFollowers", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fieldHtmls", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "emojis", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspended", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspendedUntil", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "successorId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "(ARRAY[]::text[])", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followeesCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followersCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "postsCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "location", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "header", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "location", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'Local'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "varchar(63)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "maxActors", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeHash", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iriUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inboxUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outboxUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followersUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followeesUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "featuredUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profileUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headerUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "location", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'Remote'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "remote_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nodeInfoUrl", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "softwareVersion", + "entityType": "columns", + "schema": "public", + "table": "remote_instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_instance_index", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "accountId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_accountId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_instanceId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["successorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "actors_successorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "local_actors_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "local_actors" + }, + { + "nameExplicit": true, + "columns": ["id", "location"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id", "location"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "local_actor_fk", + "entityType": "fks", + "schema": "public", + "table": "local_actors" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "local_instances_id_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "local_instances" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "login_tokens_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "login_tokens" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "remote_actors_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "remote_actors" + }, + { + "nameExplicit": true, + "columns": ["id", "location"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id", "location"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "remote_actor_fk", + "entityType": "fks", + "schema": "public", + "table": "remote_actors" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "remote_instances_id_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "remote_instances" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "columns": ["instanceId", "accountId"], + "nameExplicit": false, + "name": "instance_members_pkey", + "entityType": "pks", + "schema": "public", + "table": "instance_members" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "accounts_pkey", + "schema": "public", + "table": "accounts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_actors_pkey", + "schema": "public", + "table": "local_actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_instances_pkey", + "schema": "public", + "table": "local_instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "login_tokens_pkey", + "schema": "public", + "table": "login_tokens", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "remote_actors_pkey", + "schema": "public", + "table": "remote_actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "remote_instances_pkey", + "schema": "public", + "table": "remote_instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": ["username", "instanceId"], + "nullsNotDistinct": false, + "name": "username_key", + "entityType": "uniques", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": ["id", "location"], + "nullsNotDistinct": false, + "name": "actors_id_location_key", + "entityType": "uniques", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["slug"], + "nullsNotDistinct": false, + "name": "local_instances_slug_key", + "schema": "public", + "table": "local_instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "login_tokens_tokenHash_key", + "schema": "public", + "table": "login_tokens", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["iriUrl"], + "nullsNotDistinct": false, + "name": "remote_actors_iriUrl_key", + "schema": "public", + "table": "remote_actors", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["host"], + "nullsNotDistinct": false, + "name": "remote_instances_host_key", + "schema": "public", + "table": "remote_instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "sessions_tokenHash_key", + "schema": "public", + "table": "sessions", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"max_instances\" >= 0", + "name": "accounts_max_instances_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "trim(both from \"name\") <> ''", + "name": "accounts_name_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"username\" NOT LIKE '%@%'", + "name": "actors_username_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\n \"suspendedUntil\" IS NULL OR (\n \"suspended\" IS NOT NULL AND\n \"suspendedUntil\" > \"suspended\"\n )\n ", + "name": "actors_suspended_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\"location\" = 'Local'", + "name": "local_check", + "entityType": "checks", + "schema": "public", + "table": "local_actors" + }, + { + "value": "\"slug\" ~ '^[a-z0-9-]{4,63}$'", + "name": "instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "\"maxActors\" > 0", + "name": "instances_max_actors_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "\"location\" = 'Remote'", + "name": "remote_check", + "entityType": "checks", + "schema": "public", + "table": "remote_actors" + } + ], + "renames": [] +} diff --git a/packages/models/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/relations.ts b/packages/models/src/relations.ts index c1848df..853be21 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({ @@ -72,6 +72,27 @@ export const relations = defineRelations(schema, (r) => ({ accepted: { isNotNull: true }, }, }), + actors: r.many.actors({ from: r.instances.id, to: r.actors.instanceId }), + localInstances: r.one.localInstances({ + from: r.instances.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({ @@ -87,6 +108,27 @@ export const relations = defineRelations(schema, (r) => ({ optional: false, }), }, + actors: { + instance: r.one.instances({ + from: r.actors.instanceId, + to: r.instances.id, + optional: false, + }), + localActor: r.one.localActors({ + from: r.actors.id, + to: r.localActors.id, + }), + remoteActor: r.one.remoteActors({ + from: r.actors.id, + to: r.remoteActors.id, + }), + }, + localActors: { + actor: r.one.actors({ from: r.localActors.id, to: r.actors.id }), + }, + remoteActors: { + actor: r.one.actors({ from: r.remoteActors.id, to: r.actors.id }), + }, })); export default relations; diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 6693785..6253ab4 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -13,19 +13,28 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . + import { sql } from "drizzle-orm"; import { + type AnyPgColumn, boolean, check, + foreignKey, index, integer, + jsonb, + pgEnum, pgTable, primaryKey, + text, timestamp, + unique, uuid, varchar, } from "drizzle-orm/pg-core"; +const currentTimestamp = sql`CURRENT_TIMESTAMP`; + /** * The database table to represent accounts. */ @@ -39,7 +48,7 @@ export const accounts = pgTable( admin: boolean().notNull().default(false), created: timestamp({ withTimezone: true }) .notNull() - .default(sql`CURRENT_TIMESTAMP`), + .default(currentTimestamp), }, (table) => [ check( @@ -54,6 +63,9 @@ 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. */ @@ -61,24 +73,81 @@ export const instances = pgTable( "instances", { id: uuid().primaryKey(), - slug: varchar({ length: 100 }).notNull().unique(), - expires: timestamp({ withTimezone: true }).notNull(), + location: locationEnum().notNull(), created: timestamp({ withTimezone: true }) .notNull() - .default(sql`CURRENT_TIMESTAMP`), + .default(currentTimestamp), }, - (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')`, - ), + (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; +export const localInstances = pgTable( + "local_instances", + { + id: uuid() + .primaryKey() + .references(() => instances.id, { onDelete: "cascade" }), + 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(), + // 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; + /** * The association table between instances and its member accounts. * Note that it also contains the just invited members, which are not yet @@ -97,7 +166,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 +197,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 +219,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'`), @@ -158,3 +227,144 @@ export const sessions = pgTable("sessions", { export type Session = typeof sessions.$inferSelect; export type NewSession = typeof sessions.$inferInsert; + +export const actorTypeEnum = pgEnum("actor_type", [ + "Application", + "Group", + "Organization", + "Person", + "Service", +]); + +export type ActorType = (typeof actorTypeEnum.enumValues)[number]; + +export const actors = pgTable( + "actors", + { + id: uuid().primaryKey(), + location: locationEnum().notNull(), + type: actorTypeEnum().notNull(), + username: text().notNull(), + instanceId: uuid() + .notNull() + .references(() => instances.id, { onDelete: "cascade" }), + name: text(), + bioHtml: text(), + automaticallyApprovesFollowers: boolean().notNull().default(false), + fieldHtmls: jsonb().$type>().notNull().default({}), + emojis: jsonb().$type>().notNull().default({}), + tags: jsonb().$type>().notNull().default({}), + sensitive: boolean().notNull().default(false), + // Moderation sanction state, denormalized from flag_action records + // (which remain the audit source of truth): + // - Not sanctioned: suspended IS NULL + // - Temporary suspension: suspended = start, suspendedUntil = end + // - Permanent suspension (ban) for local actors, or permanent federation + // block for remote actors: suspended set, suspendedUntil IS NULL + // Whether a sanction is *currently* active is always determined by + // comparing against the current time (lazy expiry; no cron): + // suspended <= now AND (suspendedUntil IS NULL OR suspendedUntil > now). + suspended: timestamp({ withTimezone: true }), + suspendedUntil: timestamp({ withTimezone: true }), + successorId: uuid().references((): AnyPgColumn => actors.id, { + onDelete: "set null", + }), + aliases: text() + .array() + .notNull() + .default(sql`(ARRAY[]::text[])`), + followeesCount: integer().notNull().default(0), + followersCount: integer().notNull().default(0), + postsCount: integer().notNull().default(0), + updated: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp) + .$onUpdate(() => currentTimestamp), + published: timestamp({ withTimezone: true }), + created: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), + deleted: timestamp({ withTimezone: true }), + }, + (t) => [ + unique("username_key").on(t.username, t.instanceId), + check("actors_username_check", sql`${t.username} NOT LIKE '%@%'`), + check( + "actors_suspended_check", + sql` + ${t.suspendedUntil} IS NULL OR ( + ${t.suspended} IS NOT NULL AND + ${t.suspendedUntil} > ${t.suspended} + ) + `, + ), + index("actor_instance_index").on(t.instanceId), + unique("actors_id_location_key").on(t.id, t.location), + ], +); + +export type Actor = typeof actors.$inferSelect; +export type NewActor = typeof actors.$inferInsert; + +export const localActors = pgTable( + "local_actors", + { + id: uuid() + .primaryKey() + .references(() => actors.id, { onDelete: "cascade" }), + avatar: text(), + header: text(), + /** + * This `location` column is not an actually used value; + * it exists to prevent simultaneous Local/Remote registration, + * so it must be fixed as `Local`. + */ + location: locationEnum().default("Local").notNull(), + }, + (t) => [ + check("local_check", sql`${t.location} = 'Local'`), + foreignKey({ + name: "local_actor_fk", + columns: [t.id, t.location], + foreignColumns: [actors.id, actors.location], + }).onDelete("cascade"), + ], +); + +export type LocalActor = typeof localActors.$inferSelect; +export type NewLocalActor = typeof localActors.$inferInsert; + +export const remoteActors = pgTable( + "remote_actors", + { + id: uuid() + .primaryKey() + .references(() => actors.id, { onDelete: "cascade" }), + iriUrl: text().notNull().unique(), + inboxUrl: text().notNull(), + outboxUrl: text().notNull(), + followersUrl: text(), + followeesUrl: text(), + featuredUrl: text(), + profileUrl: text(), + avatarUrl: text(), + headerUrl: text(), + /** + * This `location` column is not an actually used value; + * it exists to prevent simultaneous Local/Remote registration, + * so it must be fixed as `Remote`. + */ + location: locationEnum().default("Remote").notNull(), + }, + (t) => [ + check("remote_check", sql`${t.location} = 'Remote'`), + foreignKey({ + name: "remote_actor_fk", + columns: [t.id, t.location], + foreignColumns: [actors.id, actors.location], + }).onDelete("cascade"), + ], +); + +export type RemoteActor = typeof remoteActors.$inferSelect; +export type NewRemoteActor = typeof remoteActors.$inferInsert; diff --git a/scripts/dev.mts b/scripts/dev.mts index 4d94252..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"; @@ -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;