diff --git a/packages/drfed/package.json b/packages/drfed/package.json index da92e88..a67061f 100644 --- a/packages/drfed/package.json +++ b/packages/drfed/package.json @@ -60,18 +60,18 @@ }, "devDependencies": { "@types/node": "catalog:", - "@types/pg": "^8.20.0", + "@types/pg": "catalog:", "tsdown": "catalog:", "typescript": "catalog:" }, "dependencies": { "@drfed/graphql": "workspace:*", "@drfed/models": "workspace:*", - "@electric-sql/pglite": "^0.5.3", + "@electric-sql/pglite": "catalog:", "@optique/core": "^1.1.0", "@optique/run": "^1.1.0", "drizzle-orm": "catalog:", - "pg": "^8.21.0", + "pg": "catalog:", "srvx": "^0.11.16" } } diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 9dc27ea..8d06686 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -85,6 +85,7 @@ "test": "node --test" }, "devDependencies": { + "@electric-sql/pglite": "catalog:", "@types/node": "catalog:", "tsdown": "catalog:", "typescript": "catalog:" diff --git a/packages/graphql/src/account.test.ts b/packages/graphql/src/account.test.ts new file mode 100644 index 0000000..2ec82a4 --- /dev/null +++ b/packages/graphql/src/account.test.ts @@ -0,0 +1,195 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { type Database, schema } from "@drfed/models"; + +import { withTestHarness } from "./harness.test.ts"; + +const accepted = new Date("2026-06-24T00:00:00.000Z"); +const created = new Date("2026-06-24T00:00:00.000Z"); +const expires = new Date("2026-07-24T00:00:00.000Z"); +const ok = 200; + +const accountId = "00000000-0000-4000-8000-000000000001"; +const memberId = "00000000-0000-4000-8000-000000000002"; +const pendingMemberId = "00000000-0000-4000-8000-000000000003"; +const acceptedInstanceId = "00000000-0000-4000-8000-000000000101"; +const pendingInstanceId = "00000000-0000-4000-8000-000000000102"; + +const accountByUuidQuery = ` + query Account($uuid: UUID!) { + accountByUuid(uuid: $uuid) { + uuid + email + name + } + } +`; + +const accountInstancesQuery = ` + query AccountInstances($uuid: UUID!) { + accountByUuid(uuid: $uuid) { + instances { + totalCount + edges { + created + accepted + admin + node { + uuid + slug + } + } + } + } + } +`; + +const accountByUuidResponse = { + data: { + accountByUuid: { + uuid: accountId, + email: "owner@example.com", + name: "Owner", + }, + }, +}; + +const accountInstancesResponse = { + data: { + accountByUuid: { + instances: { + totalCount: 1, + edges: [ + { + created: "2026-06-24T00:00:00.000Z", + accepted: "2026-06-24T00:00:00.000Z", + admin: true, + node: { + uuid: acceptedInstanceId, + slug: "test-instance", + }, + }, + ], + }, + }, + }, +}; + +describe("accountByUuid", () => { + it("returns an account by UUID", async () => { + await withTestHarness(async ({ db, post }) => { + await seedAccounts(db); + + const response = await post({ + query: accountByUuidQuery, + variables: { uuid: accountId }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), accountByUuidResponse); + }); + }); +}); + +describe("Account.instances", () => { + it("returns the account's accepted instances", async () => { + await withTestHarness(async ({ db, post }) => { + await seedMembershipGraph(db); + + const response = await post({ + query: accountInstancesQuery, + variables: { uuid: accountId }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), accountInstancesResponse); + }); + }); +}); + +async function seedAccounts(db: Database): Promise { + await db.insert(schema.accounts).values([ + { + id: accountId, + email: "owner@example.com", + name: "Owner", + created, + }, + { + id: memberId, + email: "member@example.com", + name: "Member", + created, + }, + { + id: pendingMemberId, + email: "pending@example.com", + name: "Pending", + created, + }, + ]); +} + +async function seedMembershipGraph(db: Database): Promise { + await seedAccounts(db); + await db.insert(schema.instances).values([ + { + id: acceptedInstanceId, + slug: "test-instance", + created, + expires, + }, + { + id: pendingInstanceId, + slug: "pending-instance", + created, + expires, + }, + ]); + await db.insert(schema.instanceMembers).values([ + { + accountId, + instanceId: acceptedInstanceId, + admin: true, + accepted, + created, + }, + { + accountId: memberId, + instanceId: acceptedInstanceId, + admin: false, + accepted, + created, + }, + { + accountId: pendingMemberId, + instanceId: acceptedInstanceId, + admin: false, + accepted: null, + created, + }, + { + accountId, + instanceId: pendingInstanceId, + admin: false, + accepted: null, + created, + }, + ]); +} diff --git a/packages/graphql/src/account.ts b/packages/graphql/src/account.ts index 90f8fa1..0451807 100644 --- a/packages/graphql/src/account.ts +++ b/packages/graphql/src/account.ts @@ -13,37 +13,147 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { instanceMembers } from "@drfed/models/schema"; +import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; +import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; + import builder, { type DrFedObjectRef } from "./builder.ts"; +// oxlint-disable-next-line import/no-cycle +import { Instance } from "./instance.ts"; const AccountRef = builder.drizzleNode("accounts", { + name: "Account", description: "Represents an `Account` in the DrFed platform. " + "Note that it differs from the ActivityPub `Actor`s that belong to `Instance`s.", + id: { + column(account) { + return account.id; + }, + description: "The unique identifier of the `Account`.", + }, fields: (t) => ({ - created: t.expose("created", { - type: "DateTime", - description: "The date/time when the `Account` was created.", + uuid: t.expose("id", { + type: "UUID", + description: "The UUID of the `Account`.", }), email: t.expose("email", { type: "Email", description: "The email address of the `Account`.", }), - uuid: t.expose("id", { - type: "UUID", - description: "The UUID of the `Account`.", + name: t.exposeString("name", { + description: "The display name of the `Account`.", + }), + admin: t.exposeBoolean("admin", { + description: "Whether the `Account` has administrator privileges.", + }), + created: t.expose("created", { + type: "DateTime", + description: "The date/time when the `Account` was created.", }), }), - id: { - column(account) { - return account.id; - }, - description: "The unique identifier of the `Account`.", - }, - name: "Account", }); export const Account: DrFedObjectRef = AccountRef; +const accountInstancesConnection = drizzleConnectionHelpers( + builder, + "instanceMembers", + { + query: { + orderBy: { created: "desc" }, + }, + select(nestedSelection) { + return { + with: { + instance: nestedSelection(), + }, + where: { + accepted: { isNotNull: true }, + }, + }; + }, + resolveNode(instanceMember) { + return instanceMember.instance!; + }, + }, +); + +// oxlint-disable-next-line max-lines-per-function +builder.drizzleObjectField(AccountRef, "instances", (t) => + t.connection( + { + type: Instance, + description: "The `Instance`s that the `Account` belongs to.", + select(args, ctx, nestedSelection) { + return { + with: { + instanceMembers: accountInstancesConnection.getQuery( + args, + ctx, + nestedSelection, + ), + }, + }; + }, + resolve(account, args, ctx) { + return { + ...accountInstancesConnection.resolve( + account.instanceMembers, + args, + ctx, + account, + ), + totalCount() { + return ctx.db.$count( + instanceMembers, + and( + eq(instanceMembers.accountId, account.id), + isNotNull(instanceMembers.accepted), + ), + ); + }, + }; + }, + }, + { + fields(fb) { + return { + totalCount: fb.int({ + description: + "The total number of `Instance`s that the `Account` belongs to." + + "Note that pending memberships are not counted.", + resolve(connection) { + return connection.totalCount(); + }, + }), + }; + }, + }, + { + fields(fb) { + return { + created: fb.expose("created", { + type: "DateTime", + description: + "The date/time when the `Account` was added to the `Instance`.", + }), + accepted: fb.expose("accepted", { + type: "DateTime", + nullable: true, + description: + "The date/time when the `Account` accepted membership in the `Instance`.", + }), + admin: fb.exposeBoolean("admin", { + description: + "Whether the `Account` has administrator privileges in the `Instance`.", + }), + }; + }, + }, + ), +); + builder.queryFields((t) => ({ accountByUuid: t.drizzleField({ args: { diff --git a/packages/graphql/src/harness-smoke.test.ts b/packages/graphql/src/harness-smoke.test.ts new file mode 100644 index 0000000..d1e0c8b --- /dev/null +++ b/packages/graphql/src/harness-smoke.test.ts @@ -0,0 +1,42 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { schema } from "@drfed/models"; + +import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts"; + +describe("withTemporaryDatabase()", () => { + it("provides a migrated temporary database", async () => { + await withTemporaryDatabase(async (db) => { + const accounts = await db.select().from(schema.accounts); + assert.deepEqual(accounts, []); + }); + }); +}); + +describe("withTestHarness()", () => { + it("posts requests to a Yoga server", async () => { + await withTestHarness(async ({ post }) => { + const response = await post({ query: "{ __typename }" }); + assert.ok(response.ok); + assert.deepEqual(await response.json(), { + data: { __typename: "Query" }, + }); + }); + }); +}); diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts new file mode 100644 index 0000000..f679fca --- /dev/null +++ b/packages/graphql/src/harness.test.ts @@ -0,0 +1,181 @@ +// 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 { createYogaServer } from "@drfed/graphql"; +import type { ServerContext, UserContext } from "@drfed/graphql/builder"; +import { type Database, migrate, relations, schema } from "@drfed/models"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import type { YogaServerInstance } from "graphql-yoga"; + +const testEndpoint = "http://drfed.test/graphql"; + +/** + * The `fetch()` function exposed by the test Yoga server. + */ +export type TestFetch = YogaServerInstance["fetch"]; + +/** + * A JSON GraphQL request body for {@link TestHarness.post}. + */ +export interface RequestBody { + /** + * The GraphQL operation source text. + */ + readonly query: string; + + /** + * Variables for the GraphQL operation. + */ + readonly variables?: Readonly>; + + /** + * The operation name to execute when `query` contains multiple operations. + */ + readonly operationName?: string; +} + +/** + * Utilities for testing the GraphQL server against a temporary database. + */ +export interface TestHarness { + /** + * The migrated temporary database. + */ + readonly db: Database; + + /** + * The test server's `fetch()` function, bound to the Yoga server instance. + */ + readonly fetch: TestFetch; + + /** + * The Yoga server instance under test. + */ + readonly yoga: YogaServerInstance; + + /** + * Sends a JSON `POST` request to the test server's GraphQL endpoint. + * + * @param body The GraphQL request body. + * @param init Additional request options. + * @returns The HTTP response returned by the Yoga server. + */ + post(body: RequestBody, init?: RequestInit): Promise; +} + +/** + * Runs a callback with a fresh in-memory PGlite database. + * + * The database is migrated before the callback is invoked, so every table in + * the current `@drfed/models` schema is available. The underlying PGlite + * client is closed after the callback resolves or rejects. + * + * @example + * ```ts + * import assert from "node:assert/strict"; + * import { it } from "node:test"; + * + * import { schema } from "@drfed/models"; + * + * import { withTemporaryDatabase } from "./harness.test.ts"; + * + * it("queries accounts", async () => { + * await withTemporaryDatabase(async (db) => { + * const accounts = await db.select().from(schema.accounts); + * assert.deepEqual(accounts, []); + * }); + * }); + * ``` + * + * @param callback A function that receives the migrated database. + * @returns The callback's resolved value. + */ +export async function withTemporaryDatabase( + // oxlint-disable-next-line promise/prefer-await-to-callbacks + callback: (db: Database) => Promise | T, +): Promise> { + const client = new PGlite(); + try { + await client.waitReady; + await migrate({ credentials: { driver: "pglite", client } }); + const db: Database = drizzle({ client, relations, schema }); + // oxlint-disable-next-line promise/prefer-await-to-callbacks + return await callback(db); + } finally { + await client.close(); + } +} + +/** + * Runs a callback with a Yoga server backed by a temporary database. + * + * The harness exposes the migrated database for seeding, the Yoga server for + * lower-level tests, and `post()` for JSON requests. + * + * @example + * ```ts + * import assert from "node:assert/strict"; + * import { it } from "node:test"; + * + * import { withTestHarness } from "./harness.test.ts"; + * + * it("queries the schema", async () => { + * await withTestHarness(async ({ post }) => { + * const response = await post({ query: "{ __typename }" }); + * + * assert.equal(response.status, 200); + * assert.deepEqual(await response.json(), { + * data: { __typename: "Query" }, + * }); + * }); + * }); + * ``` + * + * @param callback A function that receives the test harness. + * @returns The callback's resolved value. + */ +export async function withTestHarness( + // oxlint-disable-next-line promise/prefer-await-to-callbacks + callback: (harness: TestHarness) => Promise | T, +): Promise> { + return await withTemporaryDatabase(async (db) => { + const yoga = createYogaServer(db); + const fetch: TestFetch = yoga.fetch.bind(yoga); + + const harness: TestHarness = { + db, + fetch, + yoga, + post(body, init) { + const headers = new Headers(init?.headers); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + return Promise.resolve( + fetch(testEndpoint, { + ...init, + body: JSON.stringify(body), + headers, + method: "POST", + }), + ); + }, + }; + + // oxlint-disable-next-line promise/prefer-await-to-callbacks + return await callback(harness); + }); +} diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts new file mode 100644 index 0000000..5479556 --- /dev/null +++ b/packages/graphql/src/instance.test.ts @@ -0,0 +1,169 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { type Database, schema } from "@drfed/models"; + +import { withTestHarness } from "./harness.test.ts"; + +const accepted = new Date("2026-06-24T00:00:00.000Z"); +const created = new Date("2026-06-24T00:00:00.000Z"); +const expires = new Date("2026-07-24T00:00:00.000Z"); +const ok = 200; + +const accountId = "00000000-0000-4000-8000-000000000001"; +const memberId = "00000000-0000-4000-8000-000000000002"; +const pendingMemberId = "00000000-0000-4000-8000-000000000003"; +const instanceId = "00000000-0000-4000-8000-000000000101"; + +const instanceMembersQuery = ` + query InstanceMembers($uuid: UUID!) { + accountByUuid(uuid: $uuid) { + instances { + edges { + node { + members { + totalCount + edges { + created + accepted + admin + node { + uuid + email + name + } + } + } + } + } + } + } + } +`; + +const instanceMembersResponse = { + data: { + accountByUuid: { + instances: { + edges: [ + { + node: { + members: { + totalCount: 2, + edges: [ + { + created: "2026-06-24T00:00:00.000Z", + accepted: "2026-06-24T00:00:00.000Z", + admin: true, + node: { + uuid: accountId, + email: "owner@example.com", + name: "Owner", + }, + }, + { + created: "2026-06-24T00:00:00.000Z", + accepted: "2026-06-24T00:00:00.000Z", + admin: false, + node: { + uuid: memberId, + email: "member@example.com", + name: "Member", + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, +}; + +describe("Instance.members", () => { + it("returns the instance's accepted members", async () => { + await withTestHarness(async ({ db, post }) => { + await seedInstanceMembers(db); + + const response = await post({ + query: instanceMembersQuery, + variables: { uuid: accountId }, + }); + + assert.equal(response.status, ok); + assert.deepEqual(await response.json(), instanceMembersResponse); + }); + }); +}); + +// oxlint-disable-next-line max-lines-per-function +async function seedInstanceMembers(db: Database): Promise { + await db.insert(schema.accounts).values([ + { + id: accountId, + email: "owner@example.com", + name: "Owner", + admin: false, + created, + }, + { + id: memberId, + email: "member@example.com", + name: "Member", + admin: false, + created, + }, + { + id: pendingMemberId, + email: "pending@example.com", + name: "Pending", + admin: false, + created, + }, + ]); + await db.insert(schema.instances).values({ + id: instanceId, + slug: "test-instance", + created, + expires, + }); + await db.insert(schema.instanceMembers).values([ + { + accountId, + instanceId, + admin: true, + accepted, + created, + }, + { + accountId: memberId, + instanceId, + admin: false, + accepted, + created, + }, + { + accountId: pendingMemberId, + instanceId, + admin: false, + accepted: null, + created, + }, + ]); +} diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index 9b49486..ebe5399 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -13,28 +13,136 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { instanceMembers } from "@drfed/models/schema"; +import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; +import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; + +// oxlint-disable-next-line import/no-cycle +import { Account } from "./account.ts"; import builder, { type DrFedObjectRef } from "./builder.ts"; const InstanceRef = builder.drizzleNode("instances", { + name: "Instance", description: "Represents an `Instance` in the DrFed platform.", + id: { + column(instance) { + return instance.id; + }, + description: "The unique identifier of the `Instance`.", + }, fields: (t) => ({ - created: t.expose("created", { - type: "DateTime", - description: "The creation date/time of the `Instance`.", + uuid: t.expose("id", { + type: "UUID", + description: "The UUID of the `Instance`.", }), + slug: t.exposeString("slug"), expires: t.expose("expires", { type: "DateTime", description: "The expiration date/time of the `Instance`.", }), - slug: t.exposeString("slug"), + created: t.expose("created", { + type: "DateTime", + description: "The creation date/time of the `Instance`.", + }), }), - id: { - column(instance) { - return instance.id; - }, - description: "The unique identifier of the `Instance`.", - }, - name: "Instance", }); export const Instance: DrFedObjectRef = InstanceRef; + +const instanceMembersConnection = drizzleConnectionHelpers( + builder, + "instanceMembers", + { + query: { + orderBy: { created: "desc" }, + }, + select(nestedSelection) { + return { + with: { + account: nestedSelection(), + }, + where: { + accepted: { isNotNull: true }, + }, + }; + }, + resolveNode(instanceMember) { + return instanceMember.account; + }, + }, +); + +// oxlint-disable-next-line max-lines-per-function +builder.drizzleObjectField(InstanceRef, "members", (t) => + t.connection( + { + type: Account, + description: "The `Account`s that belong to the `Instance`.", + select(args, ctx, nestedSelection) { + return { + with: { + instanceMembers: instanceMembersConnection.getQuery( + args, + ctx, + nestedSelection, + ), + }, + }; + }, + resolve(instance, args, ctx) { + return { + ...instanceMembersConnection.resolve( + instance.instanceMembers, + args, + ctx, + instance, + ), + totalCount() { + return ctx.db.$count( + instanceMembers, + and( + eq(instanceMembers.instanceId, instance.id), + isNotNull(instanceMembers.accepted), + ), + ); + }, + }; + }, + }, + { + fields(fb) { + return { + totalCount: fb.int({ + description: + "The total number of `Account`s that belong to the `Instance`." + + "Note that pending members are not counted.", + resolve(connection) { + return connection.totalCount(); + }, + }), + }; + }, + }, + { + fields(fb) { + return { + created: fb.expose("created", { + type: "DateTime", + description: + "The date/time when the `Account` was added to the `Instance`.", + }), + accepted: fb.expose("accepted", { + type: "DateTime", + nullable: true, + description: + "The date/time when the `Account` accepted membership in the `Instance`.", + }), + admin: fb.exposeBoolean("admin", { + description: + "Whether the `Account` has administrator privileges in the `Instance`.", + }), + }; + }, + }, + ), +); diff --git a/packages/models/drizzle/20260623162238_dear_felicia_hardy/migration.sql b/packages/models/drizzle/20260623162238_dear_felicia_hardy/migration.sql new file mode 100644 index 0000000..d377583 --- /dev/null +++ b/packages/models/drizzle/20260623162238_dear_felicia_hardy/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "accounts" ADD COLUMN "name" varchar(100) NOT NULL;--> statement-breakpoint +ALTER TABLE "instance_members" ADD COLUMN "accepted" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "instance_members_accountId_index" ON "instance_members" ("accountId") WHERE "accepted" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "instance_members_instanceId_index" ON "instance_members" ("instanceId") WHERE "accepted" IS NOT NULL;--> statement-breakpoint +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_name_check" CHECK (trim(both from "name") <> ''); \ No newline at end of file diff --git a/packages/models/drizzle/20260623162238_dear_felicia_hardy/snapshot.json b/packages/models/drizzle/20260623162238_dear_felicia_hardy/snapshot.json new file mode 100644 index 0000000..88ce77a --- /dev/null +++ b/packages/models/drizzle/20260623162238_dear_felicia_hardy/snapshot.json @@ -0,0 +1,321 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "f04d695a-a99e-4bb9-be8d-190f9e7b67dc", + "prevIds": ["2cffbe98-208b-4301-ad32-8c2e3e74928f"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instance_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "accountId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_accountId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_instanceId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "columns": ["instanceId", "accountId"], + "nameExplicit": false, + "name": "instance_members_pkey", + "entityType": "pks", + "schema": "public", + "table": "instance_members" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "accounts_pkey", + "schema": "public", + "table": "accounts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["slug"], + "nullsNotDistinct": false, + "name": "instances_slug_key", + "schema": "public", + "table": "instances", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "trim(both from \"name\") <> ''", + "name": "accounts_name_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"slug\" ~ '^[a-z0-9-]{4,100}$'", + "name": "instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "instances" + }, + { + "value": "\"expires\" < (\"created\" + INTERVAL '1 year')", + "name": "instances_expires_check", + "entityType": "checks", + "schema": "public", + "table": "instances" + } + ], + "renames": [] +} diff --git a/packages/models/drizzle/20260624094817_lethal_sinister_six/migration.sql b/packages/models/drizzle/20260624094817_lethal_sinister_six/migration.sql new file mode 100644 index 0000000..3237c9f --- /dev/null +++ b/packages/models/drizzle/20260624094817_lethal_sinister_six/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "accounts" ADD COLUMN "admin" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "instance_members" ADD COLUMN "admin" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/models/drizzle/20260624094817_lethal_sinister_six/snapshot.json b/packages/models/drizzle/20260624094817_lethal_sinister_six/snapshot.json new file mode 100644 index 0000000..5db731b --- /dev/null +++ b/packages/models/drizzle/20260624094817_lethal_sinister_six/snapshot.json @@ -0,0 +1,347 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "15845826-f718-42c3-85f2-fed368f261f4", + "prevIds": ["f04d695a-a99e-4bb9-be8d-190f9e7b67dc"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instance_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "accountId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_accountId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_instanceId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "columns": ["instanceId", "accountId"], + "nameExplicit": false, + "name": "instance_members_pkey", + "entityType": "pks", + "schema": "public", + "table": "instance_members" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "accounts_pkey", + "schema": "public", + "table": "accounts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["slug"], + "nullsNotDistinct": false, + "name": "instances_slug_key", + "schema": "public", + "table": "instances", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "trim(both from \"name\") <> ''", + "name": "accounts_name_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"slug\" ~ '^[a-z0-9-]{4,100}$'", + "name": "instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "instances" + }, + { + "value": "\"expires\" < (\"created\" + INTERVAL '1 year')", + "name": "instances_expires_check", + "entityType": "checks", + "schema": "public", + "table": "instances" + } + ], + "renames": [] +} diff --git a/packages/models/package.json b/packages/models/package.json index 4a5095f..3133911 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -92,14 +92,14 @@ }, "devDependencies": { "@types/node": "catalog:", - "@types/pg": "^8.20.0", + "@types/pg": "catalog:", "drizzle-kit": "1.0.0-beta.22", "tsdown": "catalog:", "typescript": "catalog:" }, "dependencies": { - "@electric-sql/pglite": "^0.5.3", + "@electric-sql/pglite": "catalog:", "drizzle-orm": "catalog:", - "pg": "^8.21.0" + "pg": "catalog:" } } diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index da56125..f730edd 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -22,6 +22,18 @@ export const relations = defineRelations(schema, (r) => ({ instances: r.many.instances({ from: r.accounts.id.through(r.instanceMembers.accountId), to: r.instances.id.through(r.instanceMembers.instanceId), + // Drizzle ORM currently does not support many-to-many relationships with + // additional filters on the junction table. So we need to specify + // filter `accepted IS NOT NULL` everywhere we query for instances of + // an account (sigh). See also the below issue: + // https://github.com/drizzle-team/drizzle-orm/issues/5343 + }), + instanceMembers: r.many.instanceMembers({ + from: r.accounts.id, + to: r.instanceMembers.accountId, + where: { + accepted: { isNotNull: true }, + }, }), }, instanceMembers: { @@ -38,6 +50,18 @@ export const relations = defineRelations(schema, (r) => ({ members: r.many.accounts({ from: r.instances.id.through(r.instanceMembers.instanceId), to: r.accounts.id.through(r.instanceMembers.accountId), + // Drizzle ORM currently does not support many-to-many relationships with + // additional filters on the junction table. So we need to specify + // filter `accepted IS NOT NULL` everywhere we query for members of + // an instance (sigh). See also the below issue: + // https://github.com/drizzle-team/drizzle-orm/issues/5343 + }), + instanceMembers: r.many.instanceMembers({ + from: r.instances.id, + to: r.instanceMembers.instanceId, + where: { + accepted: { isNotNull: true }, + }, }), }, })); diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 35ef9eb..38ba83c 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -15,7 +15,9 @@ // along with this program. If not, see . import { sql } from "drizzle-orm"; import { + boolean, check, + index, pgTable, primaryKey, timestamp, @@ -29,17 +31,20 @@ import { export const accounts = pgTable( "accounts", { + id: uuid().primaryKey(), + email: varchar({ length: 255 }).notNull().unique(), + name: varchar({ length: 100 }).notNull(), + admin: boolean().notNull().default(false), created: timestamp({ withTimezone: true }) .notNull() .default(sql`CURRENT_TIMESTAMP`), - email: varchar({ length: 255 }).notNull().unique(), - id: uuid().primaryKey(), }, (table) => [ check( "accounts_email_check", sql`${table.email} ~ '^[^@]+@[^@]+\\.[^@]+$'`, ), + check("accounts_name_check", sql`trim(both from ${table.name}) <> ''`), ], ); @@ -52,12 +57,12 @@ export type NewAccount = typeof accounts.$inferInsert; export const instances = pgTable( "instances", { + id: uuid().primaryKey(), + slug: varchar({ length: 100 }).notNull().unique(), + expires: timestamp({ withTimezone: true }).notNull(), created: timestamp({ withTimezone: true }) .notNull() .default(sql`CURRENT_TIMESTAMP`), - expires: timestamp({ withTimezone: true }).notNull(), - id: uuid().primaryKey(), - slug: varchar({ length: 100 }).notNull().unique(), }, (table) => [ check("instances_slug_check", sql`${table.slug} ~ '^[a-z0-9-]{4,100}$'`), @@ -73,6 +78,8 @@ export type NewInstance = typeof instances.$inferInsert; /** * The association table between instances and its member accounts. + * Note that it also contains the just invited members, which are not yet + * accepted. The `accepted` field is `NULL` for those members. */ export const instanceMembers = pgTable( "instance_members", @@ -80,14 +87,24 @@ export const instanceMembers = pgTable( accountId: uuid() .notNull() .references(() => accounts.id), - created: timestamp({ withTimezone: true }) - .notNull() - .default(sql`CURRENT_TIMESTAMP`), instanceId: uuid() .notNull() .references(() => instances.id), + admin: boolean().notNull().default(false), + accepted: timestamp({ withTimezone: true }), + created: timestamp({ withTimezone: true }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`), }, - (table) => [primaryKey({ columns: [table.instanceId, table.accountId] })], + (table) => [ + primaryKey({ columns: [table.instanceId, table.accountId] }), + index() + .on(table.accountId) + .where(sql`${table.accepted} IS NOT NULL`), + index() + .on(table.instanceId) + .where(sql`${table.accepted} IS NOT NULL`), + ], ); export type InstanceMember = typeof instanceMembers.$inferSelect; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdcf570..296231c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,12 +6,21 @@ settings: catalogs: default: + '@electric-sql/pglite': + specifier: ^0.5.3 + version: 0.5.3 '@types/node': specifier: ^26.0.0 version: 26.0.0 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 drizzle-orm: specifier: 1.0.0-beta.22 version: 1.0.0-beta.22 + pg: + specifier: ^8.21.0 + version: 8.21.0 tsdown: specifier: ^0.22.3 version: 0.22.3 @@ -30,7 +39,7 @@ importers: specifier: workspace:* version: link:../models '@electric-sql/pglite': - specifier: ^0.5.3 + specifier: 'catalog:' version: 0.5.3 '@optique/core': specifier: ^1.1.0 @@ -42,7 +51,7 @@ importers: specifier: 'catalog:' version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) pg: - specifier: ^8.21.0 + specifier: 'catalog:' version: 8.21.0 srvx: specifier: ^0.11.16 @@ -52,7 +61,7 @@ importers: specifier: 'catalog:' version: 26.0.0 '@types/pg': - specifier: ^8.20.0 + specifier: 'catalog:' version: 8.20.0 tsdown: specifier: 'catalog:' @@ -88,6 +97,9 @@ importers: specifier: ^5.21.2 version: 5.21.2(graphql@16.14.2) devDependencies: + '@electric-sql/pglite': + specifier: 'catalog:' + version: 0.5.3 '@types/node': specifier: 'catalog:' version: 26.0.0 @@ -101,20 +113,20 @@ importers: packages/models: dependencies: '@electric-sql/pglite': - specifier: ^0.5.3 + specifier: 'catalog:' version: 0.5.3 drizzle-orm: specifier: 'catalog:' version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) pg: - specifier: ^8.21.0 + specifier: 'catalog:' version: 8.21.0 devDependencies: '@types/node': specifier: 'catalog:' version: 26.0.0 '@types/pg': - specifier: ^8.20.0 + specifier: 'catalog:' version: 8.20.0 drizzle-kit: specifier: 1.0.0-beta.22 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b4b4756..cb61ad7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,10 @@ allowBuilds: esbuild: true catalog: + "@electric-sql/pglite": ^0.5.3 "@types/node": ^26.0.0 + "@types/pg": ^8.20.0 drizzle-orm: 1.0.0-beta.22 + pg: ^8.21.0 tsdown: ^0.22.3 typescript: ^6.0.3