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