diff --git a/.gitignore b/.gitignore index 05071e0..3cdfb96 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __generated__/ .pgdata/ +mise.local.toml node_modules/ packages/*/dist/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 1ff122b..624d85c 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -33,7 +33,7 @@ "eslint/max-lines": "off", "eslint/id-length": ["warn", { "exceptionPatterns": ["^_", "^[Tertv]$"] }], "eslint/init-declarations": "off", - "eslint/max-params": ["warn", { "max": 4 }], + "eslint/max-params": "off", "eslint/max-statements": ["warn", { "max": 20 }], "eslint/no-console": "warn", "eslint/no-use-before-define": "off", diff --git a/AI_POLICY.md b/AI_POLICY.md index 17f62ff..9f5bf98 100644 --- a/AI_POLICY.md +++ b/AI_POLICY.md @@ -102,6 +102,7 @@ Assisted-by: OpenCode:qwen3.6-plus Assisted-by: Claude Code:claude-sonnet-5 Assisted-by: Antigravity:gemini-3.7-flash Assisted-by: Codex:gpt-5.6-sol +Assisted-by: Codex:gpt-6-astra ~~~~ If multiple AI tools were used, include one `Assisted-by` line per tool. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b7a8c4..1ee30f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -237,6 +237,16 @@ Use existing dependencies and patterns before adding new ones. In particular: Formatting is handled by [Oxfmt] and [Hongdown]. Do not hand-align code in a way that fights those tools. +When you leave a `FIXME` comment, open a GitHub issue that describes the +problem and the intended change, and put only the issue URL in the comment: + +~~~~ ts +// FIXME: https://github.com/fedify-dev/drfed/issues/35 +~~~~ + +Keep the details in the issue rather than in the comment, and update the issue +when the plan changes. + [Optique]: https://optique.dev/ [srvx]: https://srvx.h3.dev/ [Drizzle ORM]: https://orm.drizzle.team/ diff --git a/packages/drfed/src/seed.ts b/packages/drfed/src/seed.ts index 002c47d..f11e7a3 100644 --- a/packages/drfed/src/seed.ts +++ b/packages/drfed/src/seed.ts @@ -26,7 +26,7 @@ const sessionId = "00000000-0000-4000-8000-000000000000"; const accountId = "00000000-0000-4000-8000-000000000001"; const memberId = "00000000-0000-4000-8000-000000000002"; const pendingMemberId = "00000000-0000-4000-8000-000000000003"; -const created = new Date("2026-06-24T00:00:00.000Z"); +const created = Temporal.Instant.from("2026-06-24T00:00:00.000Z"); const tokenHash = "dev-token-hash"; async function seedAccounts(db: Database): Promise { diff --git a/packages/drfed/src/serving.test.ts b/packages/drfed/src/serving.test.ts index d6b52a3..fea5552 100644 --- a/packages/drfed/src/serving.test.ts +++ b/packages/drfed/src/serving.test.ts @@ -22,13 +22,12 @@ import { warnAboutStrandedInstances, } from "@drfed/drfed/serving"; import { migrate, relations, schema } from "@drfed/models"; -import { uuidV7 } from "@drfed/models/uuid"; +import { uuidV7 as uuid } from "@drfed/models/uuid"; import { PGlite } from "@electric-sql/pglite"; import { describe, it } from "@logtape/testing-node/autoload"; import { drizzle } from "drizzle-orm/pglite"; const rootOrigin = new URL("https://drfed.net"); -const dayInMilliseconds = 86_400_000; /** * A stand-in for the request object a server adapter hands the handler, whose @@ -204,10 +203,10 @@ describe("findStrandedInstances()", () => { // the stored host is consulted. { host: "999.1.1.1", slug: "unparseable", local: true }, ]; - const expires = new Date(Date.now() + dayInMilliseconds); + const expires = Temporal.Now.instant().add({ hours: 24 }); const seeded = rows.map(({ host, slug, local }) => ({ host, - localId: local ? uuidV7() : null, + localId: local ? uuid() : null, slug, })); await db @@ -220,7 +219,7 @@ describe("findStrandedInstances()", () => { await db .insert(schema.instances) .values( - seeded.map(({ host, localId }) => ({ id: uuidV7(), localId, host })), + seeded.map(({ host, localId }) => ({ id: uuid(), localId, host })), ); const stranded = await findStrandedInstances(db, rootOrigin); diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 9457ba5..9b2e908 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -58,6 +58,10 @@ "types": "./dist/builder.d.mts", "default": "./dist/builder.mjs" }, + "./classification": { + "types": "./dist/classification.d.mts", + "default": "./dist/classification.mjs" + }, "./federation": { "types": "./dist/federation.d.mts", "default": "./dist/federation.mjs" @@ -70,6 +74,10 @@ "types": "./dist/schema.d.mts", "default": "./dist/schema.mjs" }, + "./object": { + "types": "./dist/object.d.mts", + "default": "./dist/object.mjs" + }, "./origin": { "types": "./dist/origin.d.mts", "default": "./dist/origin.mjs" @@ -85,9 +93,11 @@ "src/account.ts", "src/actor.ts", "src/builder.ts", + "src/classification.ts", "src/federation.ts", "src/instance.ts", "src/schema.ts", + "src/object.ts", "src/origin.ts" ], "dts": { diff --git a/packages/graphql/src/account.test.ts b/packages/graphql/src/account.test.ts index c864bd5..1ed1dd9 100644 --- a/packages/graphql/src/account.test.ts +++ b/packages/graphql/src/account.test.ts @@ -22,9 +22,9 @@ import { describe, it } from "@logtape/testing-node/autoload"; 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 accepted = Temporal.Instant.from("2026-06-24T00:00:00.000Z"); +const created = Temporal.Instant.from("2026-06-24T00:00:00.000Z"); +const expires = Temporal.Instant.from("2026-07-24T00:00:00.000Z"); const ok = 200; const accountId = "00000000-0000-4000-8000-000000000001"; @@ -116,8 +116,8 @@ const accountInstancesResponse = { totalCount: 1, edges: [ { - created: "2026-06-24T00:00:00.000Z", - accepted: "2026-06-24T00:00:00.000Z", + created: created.toString(), + accepted: accepted.toString(), admin: true, node: { uuid: acceptedInstanceId, @@ -463,3 +463,54 @@ async function seedLocalInstances(db: Database): Promise { }, ]); } + +// Cursor requests depend on the preceding page. +// oxlint-disable no-await-in-loop +it("paginates both membership connections with identical microsecond timestamps", async () => { + await withTestHarness(async ({ db, post }) => { + await seedMembershipGraph(db); + await db.update(schema.instanceMembers).set({ + accepted, + created: Temporal.Instant.from("2026-09-14T12:00:00.123456Z"), + }); + const auth = await createSession(db); + for (const scenario of [ + { + type: "Account", + id: accountId, + field: "instances", + ids: [pendingInstanceId, acceptedInstanceId], + }, + { + type: "Instance", + id: acceptedInstanceId, + field: "members", + ids: [pendingMemberId, memberId, accountId], + }, + ]) { + const seen: string[] = []; + let after: string | null = null; + for (const expected of scenario.ids) { + const response = await post( + { + query: `query($id: ID!, $after: String) { node(id: $id) { ... on ${scenario.type} { ${scenario.field}(first: 1, after: $after) { edges { cursor node { uuid } } pageInfo { hasNextPage } } } } }`, + variables: { id: btoa(`${scenario.type}:${scenario.id}`), after }, + }, + auth, + ); + const body = await response.json(); + assert.equal(body.errors, undefined); + const connection = body.data.node[scenario.field]; + assert.equal(connection.edges.length, 1); + assert.equal(connection.edges[0].node.uuid, expected); + seen.push(connection.edges[0].node.uuid); + assert.equal( + connection.pageInfo.hasNextPage, + seen.length < scenario.ids.length, + ); + after = connection.edges[0].cursor; + } + assert.deepEqual(seen, scenario.ids); + } + }); +}); diff --git a/packages/graphql/src/account.ts b/packages/graphql/src/account.ts index 203d4dc..dc666e5 100644 --- a/packages/graphql/src/account.ts +++ b/packages/graphql/src/account.ts @@ -90,16 +90,13 @@ const accountInstancesConnection = drizzleConnectionHelpers( "instanceMembers", { query: { - orderBy: { created: "desc" }, + orderBy: { created: "desc", instanceId: "desc" }, }, select(nestedSelection) { return { with: { instance: nestedSelection(), }, - where: { - accepted: { isNotNull: true }, - }, }; }, resolveNode(instanceMember) { @@ -211,16 +208,13 @@ const instanceMembersConnection = drizzleConnectionHelpers( "instanceMembers", { query: { - orderBy: { created: "desc" }, + orderBy: { created: "desc", accountId: "desc" }, }, select(nestedSelection) { return { with: { account: nestedSelection(), }, - where: { - accepted: { isNotNull: true }, - }, }; }, resolveNode(instanceMember) { diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index 74e0b14..1ae1d75 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -15,28 +15,32 @@ // along with this program. If not, see . // oxlint-disable max-lines +// Cursor pagination tests walk pages sequentially. +// oxlint-disable no-await-in-loop import assert from "node:assert/strict"; -import { type Database, schema } from "@drfed/models"; +import { schema } from "@drfed/models"; +import { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid"; import { describe, it } from "@logtape/testing-node/autoload"; -import { eq } from "drizzle-orm/sql/expressions"; +import { and, eq } from "drizzle-orm"; -import { hashSecret } from "./auth/hash.ts"; import { withTestHarness } from "./harness.test.ts"; - -const accepted = new Date("2026-08-04T00:00:00.000Z"); -const created = new Date("2026-08-04T00:00:00.000Z"); -const expires = new Date("2030-08-04T00:00:00.000Z"); -const ok = 200; - -const accountId = "00000000-0000-4000-8000-000000000001"; -const localInstanceId = "00000000-0000-4000-8000-000000000101"; -const remoteInstanceId = "00000000-0000-4000-8000-000000000102"; -const localActorId = "00000000-0000-4000-8000-000000000201"; -const remoteActorId = "00000000-0000-4000-8000-000000000202"; -const sessionId = "00000000-0000-4000-8000-000000000301"; -const accessToken = "test-access-token"; +import { + created, + globalId, + localActorId, + localInstanceId, + ok, + remoteActorId, + remoteInstanceId, + seedActors, + seedAuthenticatedLocalInstance, + seedLocalActor, + seedLocalInstance, + seedObjects, + seedRemoteActor, +} from "./seed.test.ts"; const generateActorsMutation = ` mutation GenerateActors($instance: ID!, $size: Int!) { @@ -79,13 +83,13 @@ const actorQuery = ` header } inboxUrl - outboxUrl + outbox { iri } avatarUrl - followersUrl - followingUrl + followers { iri } + following { iri } headerUrl profileUrl - featuredUrl + featured { iri } created } } @@ -194,15 +198,7 @@ describe("Mutation.generateActors", () => { const [actor] = await db.select().from(schema.actors); assert.ok(actor != null); - for (const url of [ - actor.iri, - actor.inboxUrl, - actor.outboxUrl, - actor.followersUrl, - actor.followingUrl, - actor.featuredUrl, - actor.profileUrl, - ]) { + for (const url of [actor.inboxUrl, actor.profileUrl]) { assert.ok(url != null); assert.equal( new URL(url).origin, @@ -243,14 +239,22 @@ describe("Actor", () => { header: "header.png", }, inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`, - outboxUrl: `https://test-instance.drfed.org/users/${localActorId}/outbox`, + outbox: { + iri: `https://test-instance.drfed.org/users/${localActorId}/outbox`, + }, avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`, - followersUrl: `https://test-instance.drfed.org/users/${localActorId}/followers`, - followingUrl: `https://test-instance.drfed.org/users/${localActorId}/following`, + followers: { + iri: `https://test-instance.drfed.org/users/${localActorId}/followers`, + }, + following: { + iri: `https://test-instance.drfed.org/users/${localActorId}/following`, + }, headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`, profileUrl: "https://test-instance.drfed.org/@alice", - featuredUrl: `https://test-instance.drfed.org/users/${localActorId}/featured`, - created: created.toISOString(), + featured: { + iri: `https://test-instance.drfed.org/users/${localActorId}/featured`, + }, + created: created.toString(), }, }, }); @@ -282,110 +286,280 @@ describe("Actor", () => { }, local: null, inboxUrl: "https://remote.example.com/users/bob/inbox", - outboxUrl: "https://remote.example.com/users/bob/outbox", + outbox: { iri: "https://remote.example.com/users/bob/outbox" }, avatarUrl: "https://remote.example.com/users/bob/avatar.png", - followersUrl: "https://remote.example.com/users/bob/followers", - followingUrl: "https://remote.example.com/users/bob/following", + followers: { + iri: "https://remote.example.com/users/bob/followers", + }, + following: { + iri: "https://remote.example.com/users/bob/following", + }, headerUrl: "https://remote.example.com/users/bob/header.png", profileUrl: "https://remote.example.com/@bob", - featuredUrl: "https://remote.example.com/users/bob/featured", - created: created.toISOString(), + featured: { iri: "https://remote.example.com/users/bob/featured" }, + created: created.toString(), }, }, }); }); }); + + it("hides deleted actors from node and nodes while keeping live ones", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + const query = `query($live: ID!, $deleted: ID!) { + live: node(id: $live) { ... on Actor { uuid instance { uuid } } } + deleted: node(id: $deleted) { ... on Actor { uuid instance { uuid } } } + nodes(ids: [$live, $deleted]) { ... on Actor { uuid } } + }`; + const body = await ( + await post({ + query, + variables: { + live: globalId("Actor", remoteActorId), + deleted: globalId("Actor", localActorId), + }, + }) + ).json(); + assert.deepEqual(body, { + data: { + live: { uuid: remoteActorId, instance: { uuid: remoteInstanceId } }, + deleted: null, + nodes: [{ uuid: remoteActorId }, null], + }, + }); + }); + }); }); -function globalId(type: "Actor" | "Instance", id: string): string { - return Buffer.from(`${type}:${id}`).toString("base64"); -} +const instanceActorsQuery = `query($instance: ID!) { + node(id: $instance) { + ... on Instance { + actors { + totalCount + edges { node { uuid objects { totalCount edges { node { uuid } } } } } + } + } + } +}`; -async function seedAuthenticatedLocalInstance( - db: Database, -): Promise { - await db.insert(schema.accounts).values({ - id: accountId, - email: "owner@example.com", - name: "Owner", - created, - }); - await db.insert(schema.sessions).values({ - id: sessionId, - accountId, - tokenHash: await hashSecret(accessToken), - }); - await seedLocalInstance(db); - await db.insert(schema.instanceMembers).values({ - accountId, - instanceId: localInstanceId, - admin: true, - accepted, - created, - }); - return { headers: { authorization: `Bearer ${accessToken}` } }; +function localActorValues(id: Uuid, username: string) { + const iri = `https://test-instance.drfed.org/users/${id}`; + return { + id, + localId: id, + instanceId: localInstanceId as Uuid, + type: "Person" as const, + username, + iri, + inboxUrl: `${iri}/inbox`, + }; } -async function seedLocalActor(db: Database): Promise { - await seedLocalInstance(db); - await db.insert(schema.localActors).values({ - id: localActorId, - avatar: "avatar.png", - header: "header.png", - }); - await db.insert(schema.actors).values({ - id: localActorId, - localId: localActorId, - instanceId: localInstanceId, - type: "Person", - username: "alice", - iri: `https://test-instance.drfed.org/users/${localActorId}`, - inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`, - outboxUrl: `https://test-instance.drfed.org/users/${localActorId}/outbox`, - avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`, - followersUrl: `https://test-instance.drfed.org/users/${localActorId}/followers`, - followingUrl: `https://test-instance.drfed.org/users/${localActorId}/following`, - headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`, - profileUrl: "https://test-instance.drfed.org/@alice", - featuredUrl: `https://test-instance.drfed.org/users/${localActorId}/featured`, - created, +// Regression test for +// https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163257: +// `filterDeleted` only applies to `node`/`nodes`, so a soft-deleted actor is +// still reachable through `Instance.actors` and its `objects` connection +// still returns content. +describe("Instance.actors with a deleted actor", () => { + it("hides the deleted actor and its objects while keeping live ones", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + const carolId = "00000000-0000-4000-8000-000000000203" as const; + await db.insert(schema.localActors).values({ id: carolId }); + await seedActors(db, localActorValues(carolId, "carol")); + const hiddenObjectId = uuid(); + const liveObjectId = uuid(); + await seedObjects( + db, + [hiddenObjectId, liveObjectId].map((id) => ({ + id, + actorId: id === hiddenObjectId ? localActorId : carolId, + type: "Note" as const, + iri: `https://test-instance.drfed.org/objects/${id}`, + contentHtml: "test", + })), + ); + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + const body = await ( + await post({ + query: instanceActorsQuery, + variables: { instance: globalId("Instance", localInstanceId) }, + }) + ).json(); + assert.deepEqual(body, { + data: { + node: { + actors: { + totalCount: 1, + edges: [ + { + node: { + uuid: carolId, + objects: { + totalCount: 1, + edges: [{ node: { uuid: liveObjectId } }], + }, + }, + }, + ], + }, + }, + }, + }); + }); }); -} +}); -async function seedLocalInstance(db: Database): Promise { - await db.insert(schema.localInstances).values({ - id: localInstanceId, - slug: "test-instance", - expires, +// Regression test for +// https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163244: +// Preserve microseconds and distinguish equal timestamps using the actor ID. +describe("Instance.actors cursor precision", () => { + it("returns every actor whose created time carries microseconds", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalInstance(db); + const ids = Array.from({ length: 3 }, () => uuid()); + await seedActors( + db, + ids.map((id) => ({ + ...localActorValues(id, id), + localId: null, + created: Temporal.Instant.from("2026-09-14T12:00:00.123456Z"), + })), + ); + const query = `query($instance: ID!, $after: String) { node(id: $instance) { ... on Instance { actors(first: 1, after: $after) { edges { cursor node { uuid } } pageInfo { hasNextPage } } } } }`; + const seen: string[] = []; + let after: string | null = null; + let hasNextPage = true; + for (let page = 0; hasNextPage && page <= ids.length; page += 1) { + const body = await ( + await post({ + query, + variables: { + instance: globalId("Instance", localInstanceId), + after, + }, + }) + ).json(); + assert.equal(body.errors, undefined); + const connection = body.data.node.actors; + if (connection.edges.length === 0) break; + seen.push( + ...connection.edges.map( + (edge: { node: { uuid: string } }) => edge.node.uuid, + ), + ); + ({ hasNextPage } = connection.pageInfo); + ({ cursor: after } = connection.edges.at(-1)); + } + assert.deepEqual(seen, [...ids].sort().reverse()); + assert.equal(hasNextPage, false); + }); }); - await db.insert(schema.instances).values({ - id: localInstanceId, - localId: localInstanceId, - created, - host: "test-instance.drfed.org", +}); + +it("hides references to collections whose owner is deleted", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const outboxReference = await db.query.actorCollectionReferences.findFirst({ + where: { actorId: localActorId, role: "outbox" }, + }); + assert.ok(outboxReference); + await db + .update(schema.actorCollectionReferences) + .set({ collectionId: outboxReference.collectionId }) + .where( + and( + eq(schema.actorCollectionReferences.actorId, remoteActorId), + eq(schema.actorCollectionReferences.role, "featured"), + ), + ); + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + const response = await ( + await post({ + query: `query($remote: ID!) { remote: node(id: $remote) { ... on Actor { featured { iri } followers { iri } } } }`, + variables: { remote: globalId("Actor", remoteActorId) }, + }) + ).json(); + assert.deepEqual(response, { + data: { + remote: { + featured: null, + followers: { + iri: "https://remote.example.com/users/bob/followers", + }, + }, + }, + }); }); -} +}); -async function seedRemoteActor(db: Database): Promise { - await db.insert(schema.instances).values({ - id: remoteInstanceId, - created, - host: "remote.example.com", +it("resolves multiple actor roles referencing a shared collection", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const outboxReference = await db.query.actorCollectionReferences.findFirst({ + where: { actorId: localActorId, role: "outbox" }, + with: { collection: { with: { resource: true } } }, + }); + assert.ok(outboxReference); + const outbox = outboxReference.collection; + await db + .update(schema.actorCollectionReferences) + .set({ collectionId: outbox.id }) + .where(eq(schema.actorCollectionReferences.role, "featured")); + const response = await ( + await post({ + query: `query($local: ID!, $remote: ID!) { local: node(id: $local) { ... on Actor { outbox { id iri } featured { id iri } } } remote: node(id: $remote) { ... on Actor { featured { id iri } } } }`, + variables: { + local: globalId("Actor", localActorId), + remote: globalId("Actor", remoteActorId), + }, + }) + ).json(); + assert.equal(response.errors, undefined); + assert.deepEqual(response.data.local.featured, response.data.local.outbox); + assert.deepEqual(response.data.remote.featured, response.data.local.outbox); }); - await db.insert(schema.actors).values({ - id: remoteActorId, - instanceId: remoteInstanceId, - type: "Service", - username: "bob", - iri: "https://remote.example.com/users/bob", - inboxUrl: "https://remote.example.com/users/bob/inbox", - outboxUrl: "https://remote.example.com/users/bob/outbox", - avatarUrl: "https://remote.example.com/users/bob/avatar.png", - followersUrl: "https://remote.example.com/users/bob/followers", - followingUrl: "https://remote.example.com/users/bob/following", - headerUrl: "https://remote.example.com/users/bob/header.png", - profileUrl: "https://remote.example.com/@bob", - featuredUrl: "https://remote.example.com/users/bob/featured", - created, +}); + +it("hides deleted local actor details from node and nodes", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + await db + .update(schema.localActors) + .set({ avatar: "avatar.png", header: "header.png" }); + const query = `query($id: ID!) { + node(id: $id) { ... on LocalActor { uuid avatar header } } + nodes(ids: [$id]) { ... on LocalActor { uuid avatar header } } + }`; + const variables = { id: globalId("LocalActor", localActorId) }; + const live = { + uuid: localActorId, + avatar: "avatar.png", + header: "header.png", + }; + assert.deepEqual(await (await post({ query, variables })).json(), { + data: { node: live, nodes: [live] }, + }); + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + assert.deepEqual(await (await post({ query, variables })).json(), { + data: { node: null, nodes: [null] }, + }); }); -} +}); diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index 7774d63..5fa14e7 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -16,16 +16,20 @@ // oxlint-disable max-lines-per-function eslint/max-lines -import { schema } from "@drfed/models"; -import { actorTypeEnum } from "@drfed/models/schema"; +// Keep dependent database writes and observations sequential. +// oxlint-disable no-await-in-loop + +import { type Database, promoteResource, schema } from "@drfed/models"; +import { type CollectionRole, actorTypeEnum } from "@drfed/models/schema"; import { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid"; import type { Context } from "@fedify/fedify"; import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import type { PgInsertValue } from "drizzle-orm/pg-core"; -import { and, eq, gt, isNotNull } from "drizzle-orm/sql/expressions"; +import { and, eq, gt, isNotNull, isNull } from "drizzle-orm/sql/expressions"; import builder, { type DrFedObjectRef } from "./builder.ts"; import { Instance } from "./instance.ts"; +import { Collection, ResourceDetail } from "./resource.ts"; const ActorType = builder.enumType("ActorType", { values: actorTypeEnum.enumValues, @@ -35,6 +39,23 @@ const ACTOR_TYPES_DOC = actorTypeEnum.enumValues .map((t) => `\`${t}\``) .join(" | "); +async function resolveActorCollection( + db: Database, + actorId: Uuid, + role: CollectionRole, +) { + const reference = await db.query.actorCollectionReferences.findFirst({ + where: { actorId, role }, + with: { + collection: { + with: { ownerActor: { columns: { deleted: true } } }, + }, + }, + }); + const collection = reference?.collection; + return collection?.ownerActor?.deleted == null ? (collection ?? null) : null; +} + const ActorRef = builder.drizzleNode("actors", { name: "Actor", description: "Represents an `Actor` in the DrFed platform.", @@ -43,13 +64,16 @@ const ActorRef = builder.drizzleNode("actors", { description: "The unique identifier of the `Actor`.", }, fields: (t) => ({ + resource: t.relation("resource"), + iri: t.field({ + type: "URL", + select: { with: { resource: true } }, + resolve: (row) => row.resource.iri, + }), uuid: t.expose("id", { type: "UUID", description: "The UUID of the `Actor`.", }), - iri: t.exposeString("iri", { - description: "The Internationalized Resource Identifier of the `Actor`", - }), handle: t.field({ type: "String", description: "The handle of the `Actor`.", @@ -77,24 +101,33 @@ const ActorRef = builder.drizzleNode("actors", { type: "URL", description: "The inbox URL of the `Actor`.", }), - outboxUrl: t.expose("outboxUrl", { - type: "URL", - description: "The outbox URL of the `Actor`.", + outbox: t.field({ + type: Collection, + nullable: true, + description: + "The stored outbox collection. For local actors, it contains every stored `Create` activity regardless of addressing and retains activities whose objects are deleted so their recorded history remains inspectable. The ActivityPub outbox serves only activities with Public addressing whose objects are not deleted.", + select: { columns: { id: true } }, + resolve: (actor, _, ctx) => + resolveActorCollection(ctx.db, actor.id, "outbox"), }), avatarUrl: t.expose("avatarUrl", { type: "URL", description: "The avatar URL of the `Actor`.", nullable: true, }), - followersUrl: t.expose("followersUrl", { - type: "URL", - description: "The followers URL of the `Actor`.", + followers: t.field({ + type: Collection, nullable: true, + select: { columns: { id: true } }, + resolve: (actor, _, ctx) => + resolveActorCollection(ctx.db, actor.id, "followers"), }), - followingUrl: t.expose("followingUrl", { - type: "URL", - description: "The following URL of the `Actor`.", + following: t.field({ + type: Collection, nullable: true, + select: { columns: { id: true } }, + resolve: (actor, _, ctx) => + resolveActorCollection(ctx.db, actor.id, "following"), }), headerUrl: t.expose("headerUrl", { type: "URL", @@ -106,10 +139,12 @@ const ActorRef = builder.drizzleNode("actors", { description: "The profile URL of the `Actor`.", nullable: true, }), - featuredUrl: t.expose("featuredUrl", { - type: "URL", - description: "The featured URL of the `Actor`.", + featured: t.field({ + type: Collection, nullable: true, + select: { columns: { id: true } }, + resolve: (actor, _, ctx) => + resolveActorCollection(ctx.db, actor.id, "featured"), }), created: t.expose("created", { type: "DateTime", @@ -119,9 +154,14 @@ const ActorRef = builder.drizzleNode("actors", { }); export const Actor: DrFedObjectRef = ActorRef; +ResourceDetail.addTypes([Actor]); const LocalActorRef = builder.drizzleNode("localActors", { name: "LocalActor", + select: { + columns: { id: true }, + with: { actor: { columns: { deleted: true } } }, + }, description: "Represents the local details of an `Actor`.", id: { column: ({ id }) => id, @@ -261,7 +301,7 @@ builder.mutationFields((t) => ({ .where( and( eq(schema.instanceMembers.accountId, account.id), - gt(schema.localInstances.expires, new Date()), + gt(schema.localInstances.expires, Temporal.Now.instant()), eq(schema.instances.id, targetInstanceId), isNotNull(schema.instanceMembers.accepted), ), @@ -299,12 +339,50 @@ builder.mutationFields((t) => ({ ); const ids = Array.from({ length: size }, () => ({ id: uuid() })); await tx.insert(schema.localActors).values(ids); - const createdActors = await tx - .insert(schema.actors) - .values( - ids.map(({ id }) => generateActor(id, targetInstanceId, fedCtx)), - ) - .returning(); + const createdActors = []; + for (const { id } of ids) { + const actor = await promoteResource( + tx, + fedCtx.getActorUri(id).href, + "actor", + async (inner, resource) => { + const [createdActor] = await inner + .insert(schema.actors) + .values(generateActor(resource.id, targetInstanceId, fedCtx)) + .returning(); + if (createdActor == null) { + throw new Error("Actor insertion returned no row."); + } + return createdActor; + }, + id, + ); + for (const [role, iri] of [ + ["followers", fedCtx.getFollowersUri(id).href], + ["following", fedCtx.getFollowingUri(id).href], + ["featured", fedCtx.getFeaturedUri(id).href], + ["outbox", fedCtx.getOutboxUri(id).href], + ] as const) { + await promoteResource( + tx, + iri, + "collection", + async (inner, resource) => { + await inner.insert(schema.collections).values({ + id: resource.id, + type: "OrderedCollection", + ownerActorId: actor.id, + }); + await inner.insert(schema.actorCollectionReferences).values({ + actorId: actor.id, + role, + collectionId: resource.id, + }); + }, + ); + } + createdActors.push(actor); + } return { actors: createdActors }; }); }, @@ -319,22 +397,17 @@ function generateActor( return { id, localId: id, - // FIXME: Generate handle using Faker.js or something + // FIXME: https://github.com/fedify-dev/drfed/issues/85 username: id, instanceId, type: "Person", - iri: fedCtx.getActorUri(id).href, inboxUrl: fedCtx.getInboxUri(id).href, - outboxUrl: fedCtx.getOutboxUri(id).href, - followersUrl: fedCtx.getFollowersUri(id).href, - followingUrl: fedCtx.getFollowingUri(id).href, - featuredUrl: fedCtx.getFeaturedUri(id).href, profileUrl: new URL(`/@${id}`, fedCtx.origin).href, }; } const actorsConnection = drizzleConnectionHelpers(builder, "actors", { - query: { orderBy: { created: "desc" } }, + query: { orderBy: { created: "desc", id: "desc" } }, }); builder.drizzleObjectField("instances", "actors", (t) => @@ -355,7 +428,10 @@ builder.drizzleObjectField("instances", "actors", (t) => totalCount() { return ctx.db.$count( schema.actors, - eq(schema.actors.instanceId, instance.id), + and( + eq(schema.actors.instanceId, instance.id), + isNull(schema.actors.deleted), + ), ); }, }; diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts index 367d04a..b1c00b0 100644 --- a/packages/graphql/src/auth.test.ts +++ b/packages/graphql/src/auth.test.ts @@ -211,8 +211,8 @@ describe("email authentication", () => { .insert(schema.instances) .values({ id: instanceId, host: "shared.example.com" }); await db.insert(schema.instanceMembers).values([ - { accountId, instanceId, accepted: new Date() }, - { accountId: memberId, instanceId, accepted: new Date() }, + { accountId, instanceId, accepted: Temporal.Now.instant() }, + { accountId: memberId, instanceId, accepted: Temporal.Now.instant() }, ]); const { challengeId, code } = await requestLoginCode(post, mailer); @@ -360,7 +360,7 @@ describe("email authentication", () => { ); await db .update(schema.loginChallenges) - .set({ expires: new Date(0) }) + .set({ expires: Temporal.Instant.fromEpochMilliseconds(0) }) .where(eq(schema.loginChallenges.id, challengeId)); const expired = await ( await post({ diff --git a/packages/graphql/src/auth/challenge.ts b/packages/graphql/src/auth/challenge.ts index 00d3fc2..60ef3b7 100644 --- a/packages/graphql/src/auth/challenge.ts +++ b/packages/graphql/src/auth/challenge.ts @@ -65,7 +65,7 @@ builder.mutationFields((t) => ({ }, async resolve(query, _root, { challengeId, code }, ctx) { try { - const now = new Date(); + const now = Temporal.Now.instant(); const row = await findLoginChallenge(ctx.db, challengeId, now); const id = crypto.randomUUID(); const accessToken = generateAccessToken(); diff --git a/packages/graphql/src/auth/mail.ts b/packages/graphql/src/auth/mail.ts index be02eea..3990f7d 100644 --- a/packages/graphql/src/auth/mail.ts +++ b/packages/graphql/src/auth/mail.ts @@ -28,14 +28,14 @@ export const sendMail = async ( createMessage({ from: ctx.emailFrom, to, - // FIXME: Internationalize the email subject + // FIXME: https://github.com/fedify-dev/drfed/issues/35 subject: "Sign in to DrFed", content: renderLoginEmail(loginUrl), }), ); const renderLoginEmail = (loginUrl: string): MessageContent => ({ - // FIXME: Internationalize the email content + // FIXME: https://github.com/fedify-dev/drfed/issues/35 text: `Hello, Welcome to DrFed! If you request to login to DrFed, please visit: open ${loginUrl} diff --git a/packages/graphql/src/builder.test.ts b/packages/graphql/src/builder.test.ts new file mode 100644 index 0000000..e650436 --- /dev/null +++ b/packages/graphql/src/builder.test.ts @@ -0,0 +1,55 @@ +// 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/graphql/schema"; +import { Kind, isScalarType } from "graphql"; + +describe("DateTime scalar", () => { + it("preserves microseconds and normalizes offsets for variables and literals", () => { + const scalar = schema.getType("DateTime"); + assert.ok(isScalarType(scalar)); + const input = "2026-09-14T17:30:00.123456+05:30"; + const expected = Temporal.Instant.from("2026-09-14T12:00:00.123456Z"); + for (const instant of [ + scalar.parseValue(input), + scalar.parseLiteral({ kind: Kind.STRING, value: input }, {}), + ]) { + assert.ok(instant instanceof Temporal.Instant); + assert.equal(instant.epochNanoseconds, expected.epochNanoseconds); + assert.equal(scalar.serialize(instant), expected.toString()); + } + }); + + it("rejects invalid instants, non-string inputs and non-Instant outputs", () => { + const scalar = schema.getType("DateTime"); + assert.ok(isScalarType(scalar)); + for (const input of [ + 0, + {}, + "invalid", + "2026-09-14", + "2026-09-14T12:00:00", + ]) { + assert.throws(() => scalar.parseValue(input)); + } + assert.throws(() => + scalar.parseLiteral({ kind: Kind.INT, value: "0" }, {}), + ); + assert.throws(() => scalar.serialize("2026-09-14T12:00:00Z")); + }); +}); diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index 5b98d3d..a3a33b3 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -32,7 +32,8 @@ import ScopeAuthPlugin from "@pothos/plugin-scope-auth"; import type { Transport } from "@upyo/core"; import { getTableConfig } from "drizzle-orm/pg-core"; import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; -import { DateTimeResolver, URLResolver, UUIDResolver } from "graphql-scalars"; +import { GraphQLScalarType, Kind } from "graphql"; +import { JSONResolver, URLResolver, UUIDResolver } from "graphql-scalars"; /** * The context data for the GraphQL server, which includes the incoming request @@ -97,9 +98,10 @@ export interface UserContext extends ServerContext { export interface SchemaTypes { Context: UserContext; Scalars: { + JSON: { Input: unknown; Output: unknown }; DateTime: { - Input: Date; - Output: Date; + Input: Temporal.Instant; + Output: Temporal.Instant; }; Email: { Input: string; @@ -114,7 +116,7 @@ export interface SchemaTypes { Output: Template; }; URL: { - Input: URL; + Input: string; Output: string; }; }; @@ -158,6 +160,16 @@ export const builder = new SchemaBuilder({ }, plugins: [DrizzlePlugin, RelayPlugin, ErrorsPlugin, ScopeAuthPlugin], errors: { defaultTypes: [] }, + relay: { + nodeQueryOptions: { + resolve: async (_, { id }, __, ___, resolveNode) => + filterDeleted(await resolveNode(id)), + }, + nodesQueryOptions: { + resolve: async (_, { ids }, __, ___, resolveNodes) => + (await resolveNodes(ids)).map(filterDeleted), + }, + }, scopeAuth: { authorizeOnSubscribe: true, authScopes(context) { @@ -175,6 +187,26 @@ export const builder = new SchemaBuilder({ }, }); +const isDeleted = (node: unknown): boolean => + node != null && + typeof node === "object" && + "deleted" in node && + node.deleted != null; + +// Relations whose deletion hides the node: an object's or activity's +// author, or a collection's owner. +const OWNER_RELATIONS = ["actor", "ownerActor"] as const; + +const filterDeleted = (node: unknown): unknown => + isDeleted(node) || + (node != null && + typeof node === "object" && + OWNER_RELATIONS.some((key) => + isDeleted((node as Record)[key]), + )) + ? null + : node; + /** * Determines whether the viewer is an accepted member of the `Instance` that * the given `LocalInstance` backs. Pending members, i.e. those who have been @@ -208,8 +240,50 @@ async function isLocalInstanceMember( return rows.length > 0; } -builder.addScalarType("DateTime", DateTimeResolver); -builder.addScalarType("URL", URLResolver); +builder.scalarType("DateTime", { + description: "An ISO 8601 instant preserving sub-millisecond precision.", + serialize(value) { + if (!(value instanceof Temporal.Instant)) { + throw new TypeError("Expected a Temporal.Instant."); + } + return value.toString(); + }, + parseValue(value) { + if (typeof value !== "string") { + throw new TypeError("Expected an instant string."); + } + return Temporal.Instant.from(value); + }, + parseLiteral(node) { + if (node.kind !== Kind.STRING) { + throw new TypeError("Expected an instant string."); + } + return Temporal.Instant.from(node.value); + }, +}); +builder.addScalarType( + "URL", + new GraphQLScalarType({ + ...URLResolver.toConfig(), + serialize(value) { + URLResolver.serialize(value); + return String(value); + }, + // Validate URLs while preserving the caller's exact spelling. + parseValue(value) { + URLResolver.parseValue(value); + return String(value); + }, + parseLiteral(node, variables) { + URLResolver.parseLiteral(node, variables); + if (node.kind !== Kind.STRING) { + throw new TypeError("Expected a URL string."); + } + return node.value; + }, + }), +); +builder.addScalarType("JSON", JSONResolver); builder.scalarType("Email", { parseValue: (v) => normalizeEmail(String(v)), diff --git a/packages/graphql/src/classification.test.ts b/packages/graphql/src/classification.test.ts new file mode 100644 index 0000000..7e401ae --- /dev/null +++ b/packages/graphql/src/classification.test.ts @@ -0,0 +1,99 @@ +// 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 { + classifyMastodon, + classifyMisskey, +} from "@drfed/graphql/classification"; +import { PUBLIC_IRI } from "@drfed/models/resource"; + +const author = { + iri: "https://example.com/alice", + followersIri: "https://example.com/alice/followers", +}; +describe("expected classifications", () => { + for (const publicIri of [PUBLIC_IRI, "as:Public", "Public"]) { + it(`recognizes ${publicIri} in both implementations`, () => { + assert.equal( + classifyMastodon({ to: [publicIri] }, null, author).classification, + "public", + ); + assert.equal( + classifyMisskey({ to: [publicIri] }, null, author).classification, + "public", + ); + assert.equal( + classifyMastodon({ cc: [publicIri] }, null, author).classification, + "unlisted", + ); + assert.equal( + classifyMisskey({ cc: [publicIri] }, null, author).classification, + "home", + ); + }); + } + it("distinguishes followers only in cc", () => { + const addressing = { cc: [author.followersIri] }; + assert.equal( + classifyMastodon(addressing, null, author).classification, + "direct", + ); + assert.equal( + classifyMisskey(addressing, null, author).classification, + "followers", + ); + assert.equal( + classifyMastodon({ to: [author.followersIri] }, null, author) + .classification, + "private", + ); + }); + it("falls back per missing object property only in Mastodon", () => { + const activity = { to: [PUBLIC_IRI], cc: [PUBLIC_IRI] }; + assert.equal( + classifyMastodon({}, activity, author).classification, + "public", + ); + assert.equal( + classifyMastodon({ to: [], cc: [] }, activity, author).classification, + "direct", + ); + assert.equal( + classifyMastodon({ to: [] }, activity, author).classification, + "unlisted", + ); + assert.equal( + classifyMisskey({}, activity, author).classification, + "specified", + ); + }); + it("uses Misskey's inferred followers path only when the author has none", () => { + const unknown = { iri: author.iri, followersIri: null }; + assert.equal( + classifyMastodon({ to: [author.followersIri] }, null, unknown) + .classification, + "direct", + ); + assert.equal( + classifyMisskey({ to: [author.followersIri] }, null, unknown) + .classification, + "followers", + ); + }); +}); diff --git a/packages/graphql/src/classification.ts b/packages/graphql/src/classification.ts new file mode 100644 index 0000000..efb561a --- /dev/null +++ b/packages/graphql/src/classification.ts @@ -0,0 +1,146 @@ +// 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 . + +// Rules verified at these upstream revisions: +// mastodon/mastodon fba604991b404096878932691b7673c61357f65c +// app/lib/activitypub/parser/status_parser.rb and app/lib/activitypub/tag_manager.rb +// misskey-dev/misskey 5e52f8609913a7a4f8bc76cbe26c2b99938310ae +// packages/backend/src/core/activitypub/ApAudienceService.ts +import { PUBLIC_IRI } from "@drfed/models/resource"; + +/** Missing/null properties permit Mastodon's activity fallback; [] does not. */ +export interface AddressingRows { + readonly to?: readonly string[] | null | undefined; + readonly cc?: readonly string[] | null | undefined; +} +export interface ExpectedClassification { + readonly implementation: "MASTODON" | "MISSKEY"; + readonly version: string; + readonly classification: string; + readonly reason: string; +} +interface Author { + readonly followersIri: string | null; + readonly iri: string; +} +const isPublic = (iri: string): boolean => + [PUBLIC_IRI, "as:Public", "Public"].includes(iri); + +/** + * Expected Mastodon classification, independent of receiver state or policy. + * @returns The expected Mastodon label, pinned revision, and applied rule. + */ +export function classifyMastodon( + object: AddressingRows, + activity: AddressingRows | null, + author: Author, +): ExpectedClassification { + const to = object.to ?? activity?.to ?? []; + const cc = object.cc ?? activity?.cc ?? []; + const [classification, rule]: readonly [string, string] = to.some(isPublic) + ? ["public", "Public is in to"] + : cc.some(isPublic) + ? ["unlisted", "Public is in cc"] + : author.followersIri != null && to.includes(author.followersIri) + ? ["private", "The author's followers collection is in to"] + : [ + "direct", + "Neither Public nor the author's followers collection occurs in to, and Public is absent from cc", + ]; + return { + implementation: "MASTODON", + version: "fba604991b404096878932691b7673c61357f65c", + classification, + reason: `Expected: ${rule}. Missing object properties fall back to the activity. Receiver state and policy can change access.`, + }; +} + +/** + * Expected Misskey classification; activity addressing is deliberately unused. + * @returns The expected Misskey label, pinned revision, and applied rule. + */ +export function classifyMisskey( + object: AddressingRows, + _activity: AddressingRows | null, + author: Author, +): ExpectedClassification { + const to = object.to ?? []; + const cc = object.cc ?? []; + const followers = author.followersIri ?? `${author.iri}/followers`; + const [classification, rule]: readonly [string, string] = to.some(isPublic) + ? ["public", "Public is in to"] + : cc.some(isPublic) + ? ["home", "Public is in cc"] + : [...to, ...cc].includes(followers) + ? ["followers", "The author's followers collection is in to or cc"] + : [ + "specified", + "Public and the author's followers collection are absent from to and cc", + ]; + return { + implementation: "MISSKEY", + version: "5e52f8609913a7a4f8bc76cbe26c2b99938310ae", + classification, + reason: `Expected: ${rule}. Only object addressing is considered. Receiver state and policy can change access.`, + }; +} + +/** + * Converts stored addressing while preserving absent properties for fallback. + * @returns Addressing suitable for the classification rules. + */ +export function classificationInput(row: { + document: unknown; + addressing: readonly { + property: string; + position: number; + targetResource: { iri: string }; + }[]; +}): AddressingRows { + const property = (name: "to" | "cc"): readonly string[] | undefined => { + const rows = row.addressing + .filter((entry) => entry.property === name) + .toSorted((left, right) => left.position - right.position); + if (rows.length > 0) return rows.map((entry) => entry.targetResource.iri); + const { document } = row; + return document != null && + typeof document === "object" && + name in document && + document[name as keyof typeof document] != null + ? [] + : undefined; + }; + return { to: property("to"), cc: property("cc") }; +} + +/** + * Extracts the object author's canonical IRI and declared followers IRI. + * @returns The author identifiers used by the classification rules. + */ +export function classificationAuthor(actor: { + resource: { iri: string }; + collectionReferences: readonly { + role: string; + collection: { resource: { iri: string } }; + }[]; +}): Author { + return { + iri: actor.resource.iri, + followersIri: + actor.collectionReferences.find((entry) => entry.role === "followers") + ?.collection.resource.iri ?? null, + }; +} diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index bce194d..440da65 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -14,16 +14,32 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +// Keep dependent database writes and observations sequential. +// oxlint-disable no-await-in-loop + import assert from "node:assert/strict"; import { createYogaServer } from "@drfed/graphql"; import createFederation, { buildFederation } from "@drfed/graphql/federation"; import { schema } from "@drfed/models"; -import { uuidV7 } from "@drfed/models/uuid"; +import { PUBLIC_IRI } from "@drfed/models/resource"; +import { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid"; import { MemoryKvStore } from "@fedify/fedify"; +import { Object as APObject, Create } from "@fedify/vocab"; import { describe, it } from "@logtape/testing-node/autoload"; +import { eq, sql } from "drizzle-orm"; import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts"; +import { + globalId, + localActorId, + remoteActorId, + seedActors, + seedAuthenticatedLocalInstance, + seedLocalActor, + seedObjects, + seedRemoteActor, +} from "./seed.test.ts"; const origin = new URL("https://drfed.test"); const activityJson = "application/activity+json"; @@ -35,6 +51,14 @@ describe("createFederation()", () => { kv: new MemoryKvStore(), }); const ctx = federation.createContext(origin, undefined); + assert.equal( + ctx.getObjectUri(APObject, { identifier: "a", id: "b" }).href, + "https://drfed.test/users/a/b", + ); + assert.equal( + ctx.getObjectUri(Create, { id: "b" }).href, + "https://drfed.test/ap/creates/b", + ); assert.equal( ctx.getActorUri("identifier").href, "https://drfed.test/users/identifier", @@ -69,31 +93,30 @@ describe("createFederation()", () => { // `demo.drfed.test`. Without canonicalizing the lookup key, every actor // on that instance answers 404 to such a request. await withTemporaryDatabase(async (db) => { - const localInstanceId = uuidV7(); - const instanceId = uuidV7(); - const localActorId = uuidV7(); - const actorId = uuidV7(); + const localInstanceId = uuid(); + const instanceId = uuid(); + const localId = uuid(); + const actorId = uuid(); const base = "https://demo.drfed.test"; await db.insert(schema.localInstances).values({ id: localInstanceId, slug: "demo", - expires: new Date(Date.now() + 86_400_000), + expires: Temporal.Now.instant().add({ hours: 24 }), }); await db.insert(schema.instances).values({ id: instanceId, localId: localInstanceId, host: "demo.drfed.test", }); - await db.insert(schema.localActors).values({ id: localActorId }); - await db.insert(schema.actors).values({ + await db.insert(schema.localActors).values({ id: localId }); + await seedActors(db, { id: actorId, - localId: localActorId, + localId, type: "Person", username: "alice", instanceId, iri: `${base}/users/${actorId}`, inboxUrl: `${base}/users/${actorId}/inbox`, - outboxUrl: `${base}/users/${actorId}/outbox`, }); const federation = await createFederation(db, { @@ -167,3 +190,582 @@ describe("createYogaServer()", () => { }); }); }); + +const actorIri = `https://test-instance.drfed.org/users/${localActorId}`; +const createIri = (id: string) => + `https://test-instance.drfed.org/ap/creates/${id}`; +const accept = { accept: "application/activity+json" }; + +function values(id: string) { + return { + id: id as Uuid, + activityId: uuid(), + actorId: localActorId as Uuid, + iri: `${actorIri}/${id}`, + type: "Note" as const, + contentHtml: "

Hello

", + }; +} + +describe("ActivityPub resource origin spelling", () => { + for (const resource of ["object", "Create", "Tombstone"] as const) { + for (const requestOrigin of [ + "https://test-instance.drfed.org.", + "http://test-instance.drfed.org", + ]) { + it(`serves ${resource} from ${requestOrigin} with its stored canonical IRI`, async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + const deleted = + resource === "Tombstone" + ? Temporal.Instant.from("2026-09-06T12:00:00Z") + : null; + await seedObjects(db, { ...object, deleted }); + const iri = + resource === "Create" ? createIri(object.activityId) : object.iri; + const requestIri = new URL(new URL(iri).pathname, requestOrigin); + const response = await federation.fetch( + new Request(requestIri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.id, iri); + assert.equal(body.type, resource === "object" ? "Note" : resource); + if (deleted != null) { + assert.equal( + Temporal.Instant.from(body.deleted).epochNanoseconds, + deleted.epochNanoseconds, + ); + } + }); + }); + } + } +}); + +describe("ActivityPub objects", () => { + for (const publicProperty of ["to", "cc"] as const) { + it(`serves ${publicProperty} Public objects with contentMap and recipients`, async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + await seedObjects(db, { + ...object, + addressing: { + [publicProperty]: [PUBLIC_IRI], + [publicProperty === "to" ? "cc" : "to"]: [`${actorIri}/followers`], + }, + language: "ko-KR", + name: "Title", + summary: "CW", + sensitive: true, + }); + const response = await federation.fetch( + new Request(object.iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.type, "Note"); + assert.equal(body.id, object.iri); + assert.equal(body.attributedTo, actorIri); + assert.equal(body.content, object.contentHtml); + assert.deepEqual(body.contentMap, { "ko-kr": object.contentHtml }); + assert.equal(body.name, "Title"); + assert.equal(body.summary, "CW"); + assert.equal(body.sensitive, true); + assert.ok(body.published); + assert.ok(body.updated); + assert.equal( + body.to, + publicProperty === "to" ? "as:Public" : `${actorIri}/followers`, + ); + assert.equal( + body.cc, + publicProperty === "to" ? `${actorIri}/followers` : "as:Public", + ); + }); + }); + } + for (const deleted of [null, Temporal.Instant.from("2026-09-06T12:00:00Z")]) { + it(`does not serve followers-only objects (deleted: ${deleted != null})`, async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + await seedObjects(db, { + ...object, + addressing: { + to: [ + `https://test-instance.drfed.org/users/${localActorId}/followers`, + ], + }, + deleted, + }); + const response = await federation.fetch( + new Request(object.iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 404); + }); + }); + } + it("serves Articles and tombstones", async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + await seedObjects(db, { ...object, type: "Article" }); + const response = await federation.fetch( + new Request(object.iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal((await response.json()).type, "Article"); + const deletedAt = Temporal.Instant.from("2026-09-06T12:00:00.000Z"); + await db + .update(schema.objects) + .set({ deleted: deletedAt }) + .where(eq(schema.objects.id, object.id)); + const deleted = await federation.fetch( + new Request(object.iri, { headers: accept }), + { contextData: undefined }, + ); + // Fedify serializes generic object tombstones with HTTP 200. + assert.equal(deleted.status, 200); + const tombstone = await deleted.json(); + assert.equal(tombstone.type, "Tombstone"); + assert.equal( + Temporal.Instant.from(tombstone.deleted).epochNanoseconds, + deletedAt.epochNanoseconds, + ); + }); + }); + for (const scenario of [ + "missing", + "malformed", + "remote", + "host", + "actor", + "deletedActor", + ] as const) { + it(`rejects ${scenario} object requests`, async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const object = values(uuid()); + await seedObjects(db, { + ...object, + actorId: scenario === "remote" ? remoteActorId : localActorId, + }); + if (scenario === "deletedActor") { + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + } + const iri = + scenario === "missing" + ? `${actorIri}/${uuid()}` + : scenario === "malformed" + ? `${actorIri}/bad` + : scenario === "host" + ? object.iri.replace("test-instance.drfed.org", "wrong.example") + : scenario === "actor" || scenario === "remote" + ? object.iri.replace(localActorId, remoteActorId) + : object.iri; + const response = await federation.fetch( + new Request(iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 404); + }); + }); + } +}); + +describe("ActivityPub Create activities", () => { + for (const publicProperty of ["to", "cc"] as const) { + it(`serves Create activities for ${publicProperty} Public objects`, async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + await seedObjects(db, { + ...object, + addressing: { + [publicProperty]: [PUBLIC_IRI], + [publicProperty === "to" ? "cc" : "to"]: [`${actorIri}/followers`], + }, + }); + const response = await federation.fetch( + new Request(createIri(object.activityId), { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual( + { + type: body.type, + id: body.id, + actor: body.actor, + object: body.object, + to: body.to, + cc: body.cc, + }, + { + type: "Create", + id: createIri(object.activityId), + actor: actorIri, + object: object.iri, + to: publicProperty === "to" ? "as:Public" : `${actorIri}/followers`, + cc: publicProperty === "to" ? `${actorIri}/followers` : "as:Public", + }, + ); + assert.ok(body.published); + }); + }); + } + for (const scenario of [ + "followers", + "deleted", + "missing", + "malformed", + "remote", + "deletedActor", + ] as const) { + it(`rejects ${scenario} Create requests`, async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const object = values(uuid()); + await seedObjects(db, { + ...object, + actorId: scenario === "remote" ? remoteActorId : localActorId, + addressing: + scenario === "followers" + ? { to: [`${actorIri}/followers`] } + : { to: [PUBLIC_IRI] }, + deleted: scenario === "deleted" ? Temporal.Now.instant() : null, + }); + if (scenario === "deletedActor") { + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + } + const iri = + scenario === "missing" + ? createIri(uuid()) + : scenario === "malformed" + ? createIri("bad") + : createIri(object.activityId); + const response = await federation.fetch( + new Request(iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 404); + }); + }); + } + it("rejects Create requests from another host", async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + await seedObjects(db, object); + const response = await federation.fetch( + new Request( + createIri(object.activityId).replace( + "test-instance.drfed.org", + "wrong.example", + ), + { headers: accept }, + ), + { contextData: undefined }, + ); + assert.equal(response.status, 404); + }); + }); +}); + +describe("ActivityPub outbox", () => { + it("paginates Create activities while excluding followers-only and deleted objects", async () => { + // oxlint-disable-next-line max-statements + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const ids = Array.from({ length: 23 }, () => uuid()); + const activityIds = ids.map(() => uuid()); + await seedObjects( + db, + ids.map((id, index) => ({ + ...values(id), + activityId: activityIds[index]!, + addressing: + index === 22 + ? { to: [`${actorIri}/followers`] } + : index === 20 + ? { to: [`${actorIri}/followers`], cc: [PUBLIC_IRI] } + : { to: [PUBLIC_IRI] }, + deleted: index === 21 ? Temporal.Now.instant() : null, + })), + ); + const fetchJson = async (iri: string) => { + const response = await federation.fetch( + new Request(iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + return await response.json(); + }; + const collection = await fetchJson(`${actorIri}/outbox`); + assert.equal(collection.type, "OrderedCollection"); + assert.equal(collection.totalItems, 21); + const page = await fetchJson(`${actorIri}/outbox?cursor=`); + assert.equal(page.orderedItems.length, 20); + const activity = page.orderedItems[0]; + assert.deepEqual( + { + type: activity.type, + id: activity.id, + actor: activity.actor, + object: activity.object, + to: activity.to, + cc: activity.cc, + }, + { + type: "Create", + id: createIri(activityIds[20]!), + actor: actorIri, + object: values(ids[20]!).iri, + to: `${actorIri}/followers`, + cc: "as:Public", + }, + ); + const last = await fetchJson(page.next); + assert.equal(last.orderedItems.length, 1); + assert.equal(last.orderedItems[0].object, values(ids[0]!).iri); + assert.equal(last.next, undefined); + const bad = await federation.fetch( + new Request(`${actorIri}/outbox?cursor=bad`, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(bad.status, 404); + for (const published of [ + "0000-01-01T00:00:00.000000Z", + "2026-02-30T00:00:00.000000Z", + ]) { + const cursor = encodeURIComponent(`${published}|${uuid()}`); + const invalid = await federation.fetch( + new Request(`${actorIri}/outbox?cursor=${cursor}`, { + headers: accept, + }), + { contextData: undefined }, + ); + assert.equal(invalid.status, 404); + } + }); + }); + it("orders UUIDv4 backfills by publication and retains microseconds across pages", async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const objects = Array.from({ length: 24 }, () => uuid()); + for (const [index, id] of objects.entries()) { + // Old migrated activities have random UUIDv4 IDs larger than UUIDv7. + // Reverse IDs deliberately oppose publication order within a page. + const activityId = + index === 23 + ? uuid() + : (`ffffffff-ffff-4fff-8fff-${String(24 - index).padStart(12, "0")}` as Uuid); + await seedObjects(db, { ...values(id), activityId }); + const published = `2026-09-15T00:00:00.${String(Math.floor(index / 2)).padStart(6, "0")}Z`; + await db + .update(schema.activities) + .set({ published: sql`${published}::timestamptz` }) + .where(eq(schema.activities.id, activityId)); + } + const fetchJson = async (iri: string) => { + const response = await federation.fetch( + new Request(iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + return await response.json(); + }; + const first = await fetchJson(`${actorIri}/outbox?cursor=`); + assert.equal(first.orderedItems.length, 20); + // Equal publication times use descending IDs as a stable tie-breaker. + const expected = Array.from({ length: 12 }, (_, index) => 22 - 2 * index) + .flatMap((index) => [objects[index], objects[index + 1]]) + .map((id) => values(id!).iri); + assert.deepEqual( + first.orderedItems.map((item: { object: string }) => item.object), + expected.slice(0, 20), + ); + // A cursor remains valid even if its activity has since been deleted. + const boundary = new URL(first.next).searchParams + .get("cursor")! + .split("|")[1]!; + await db + .delete(schema.resources) + .where(eq(schema.resources.id, boundary as Uuid)); + const last = await fetchJson(first.next); + assert.deepEqual( + last.orderedItems.map((item: { object: string }) => item.object), + expected.slice(20), + ); + assert.equal(last.next, undefined); + }); + }); + it("serves an empty outbox", async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const response = await federation.fetch( + new Request(`${actorIri}/outbox?cursor=`, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.type, "OrderedCollectionPage"); + assert.deepEqual(body.orderedItems ?? [], []); + assert.equal(body.next, undefined); + }); + }); +}); + +const createMutation = `mutation Create($actor: ID!, $addressing: AddressingInput!) { + createObject(actor: $actor, contentHtml: "

Hello

", addressing: $addressing) { + ... on Object { uuid } + ... on CreateObjectError { errorType: type message } + } +}`; + +// Regression tests for +// https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163252: +// The outbox counter must match its page predicate rather than a stored count. +describe("ActivityPub outbox totalItems", () => { + for (const scenario of ["followers", "deleted"] as const) { + it(`does not count ${scenario} objects that outbox pages never return`, async () => { + await withTestHarness(async ({ db, federation, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const body = await ( + await post( + { + query: createMutation, + variables: { + actor: globalId("Actor", localActorId), + addressing: + scenario === "followers" + ? { to: [`${actorIri}/followers`] } + : { to: [PUBLIC_IRI] }, + }, + }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.createObject.errorType, undefined); + if (scenario === "deleted") { + await db + .update(schema.objects) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.objects.id, body.data.createObject.uuid)); + } + const fetchJson = async (iri: string) => { + const response = await federation.fetch( + new Request(iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + return await response.json(); + }; + const page = await fetchJson(`${actorIri}/outbox?cursor=`); + assert.deepEqual(page.orderedItems ?? [], []); + const collection = await fetchJson(`${actorIri}/outbox`); + assert.equal(collection.totalItems, 0); + }); + }); + } +}); + +describe("stored collection membership and independent activity addressing", () => { + it("serves stored Create IRIs and uses activity addressing for outbox and Create", async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + await seedObjects(db, object); + const activity = await db.query.activities.findFirst({ + where: { objectId: object.id }, + with: { resource: true }, + }); + assert.ok(activity); + assert.notEqual(activity.id, object.id); + assert.equal(activity.resource.iri, createIri(activity.id)); + const fetch = (iri: string) => + federation.fetch(new Request(iri, { headers: accept }), { + contextData: undefined, + }); + assert.equal((await fetch(createIri(object.activityId))).status, 200); + await db + .delete(schema.addressing) + .where(eq(schema.addressing.sourceId, activity.id)); + assert.equal((await fetch(createIri(object.activityId))).status, 404); + assert.equal((await fetch(object.iri)).status, 200); + assert.equal( + (await (await fetch(`${actorIri}/outbox`)).json()).totalItems, + 0, + ); + assert.deepEqual( + (await (await fetch(`${actorIri}/outbox?cursor=`)).json()) + .orderedItems ?? [], + [], + ); + const rows = await db.query.addressing.findMany({ + where: { sourceId: object.id }, + }); + assert.ok(rows.length > 0); + }); + }); + it("reads collection_items for followers, following, featured and GraphQL items", async () => { + await withTestHarness(async ({ db, federation, post }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const fetch = (iri: string) => + federation.fetch(new Request(iri, { headers: accept }), { + contextData: undefined, + }); + for (const role of ["followers", "following", "featured"] as const) { + const reference = await db.query.actorCollectionReferences.findFirst({ + where: { actorId: localActorId, role }, + with: { collection: true }, + }); + assert.ok(reference); + const { collection } = reference; + await db.insert(schema.collectionItems).values({ + collectionId: collection.id, + itemId: remoteActorId, + position: 0, + }); + const response = await fetch(`${actorIri}/${role}`); + assert.equal(response.status, 200); + const body = await response.json(); + const item = body.orderedItems?.[0] ?? body.items?.[0]; + assert.equal( + typeof item === "string" ? item : item?.id, + "https://remote.example.com/users/bob", + ); + } + const body = await ( + await post({ + query: `query($id: ID!) { node(id: $id) { ... on Actor { followers { resource { kind } totalCount items(first: 1) { edges { cursor node { kind iri detail { ... on Actor { username } } } } pageInfo { hasNextPage } } } } } }`, + variables: { id: globalId("Actor", localActorId) }, + }) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.node.followers.totalCount, 1); + assert.deepEqual(body.data.node.followers.items.edges[0].node, { + kind: "actor", + iri: "https://remote.example.com/users/bob", + detail: { username: "bob" }, + }); + }); + }); +}); diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index fbb1dc0..94a2e15 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -14,9 +14,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import type { Database } from "@drfed/models"; -import type { Actor } from "@drfed/models/schema"; -import type { Uuid } from "@drfed/models/uuid"; +import { type Database, schema } from "@drfed/models"; +import { PUBLIC_RESOURCE_ID } from "@drfed/models/resource"; +import type { + ActivityPubObject, + Actor, + Addressing, + ObjectType, + Resource, + StoredActivity, +} from "@drfed/models/schema"; +import { type Uuid, validateUuid } from "@drfed/models/uuid"; import { type Context, type Federation, @@ -25,18 +33,23 @@ import { createFederationBuilder, } from "@fedify/fedify"; import { + Object as APObject, Activity, Application, + Article, + Create, Endpoints, Group, Image, + LanguageString, + Note, Organization, Person, Service, Tombstone, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; -import { validate as validateUuid } from "uuid"; +import { type SQL, type SQLWrapper, and, eq, sql } from "drizzle-orm"; import { canonicalizeAuthority } from "./origin.ts"; @@ -62,7 +75,7 @@ async function findLocalActor( db: Database, ctx: Context, identifier: string, -): Promise { +): Promise { if (!validateUuid(identifier)) return null; const actor = await db.query.actors.findFirst({ where: { @@ -70,6 +83,12 @@ async function findLocalActor( localId: { isNotNull: true }, instance: { host: canonicalizeAuthority(ctx.host) }, }, + with: { + resource: true, + collectionReferences: { + with: { collection: { with: { resource: true } } }, + }, + }, }); return actor ?? null; } @@ -78,7 +97,7 @@ async function findActiveActor( db: Database, ctx: Context, identifier: string, -): Promise { +): Promise { const actor = await findLocalActor(db, ctx, identifier); return actor == null || actor.deleted != null ? null : actor; } @@ -115,14 +134,11 @@ export function buildFederation(db: Database): FederationBuilder { }); return actor?.id ?? null; }); - // FIXME: Provide actor key pairs via setKeyPairsDispatcher() once the - // data model stores signing keys. + // FIXME: https://github.com/fedify-dev/drfed/issues/87 builder .setInboxListeners("/users/{identifier}/inbox", "/inbox") - // FIXME: Record incoming activities once the data model can store them; - // until then the catch-all below only surfaces them in the logs so that - // deliveries are not silently discarded. + // FIXME: https://github.com/fedify-dev/drfed/issues/88 .on(Activity, (_ctx, activity) => { logger.debug("Received an activity: {activity}", { activity }); }) @@ -132,53 +148,169 @@ export function buildFederation(db: Database): FederationBuilder { }); }); - builder.setOutboxDispatcher( - "/users/{identifier}/outbox", - async (ctx, identifier) => - // FIXME: Return the actual activities once the data model stores them - (await findActiveActor(db, ctx, identifier)) == null - ? null - : { items: [] }, + builder.setObjectDispatcher( + APObject, + "/users/{identifier}/{id}", + async (ctx, { identifier, id }) => { + if (!validateUuid(identifier) || !validateUuid(id)) return null; + const object = await db.query.objects.findFirst({ + where: { + id, + actorId: identifier, + RAW: (table) => publicAddressing(table.id), + actor: { + localId: { isNotNull: true }, + deleted: { isNull: true }, + instance: { host: canonicalizeAuthority(ctx.host) }, + }, + }, + with: objectSelection, + }); + if (object == null) return null; + if (object.deleted != null) { + return new Tombstone({ + id: new URL(object.resource.iri), + deleted: object.deleted, + }); + } + return toObject(ctx, object); + }, ); + builder.setObjectDispatcher( + Create, + "/ap/creates/{id}", + async (ctx, { id }) => { + if (!validateUuid(id)) return null; + const activity = await db.query.activities.findFirst({ + where: { + id, + actor: { + localId: { isNotNull: true }, + deleted: { isNull: true }, + instance: { host: canonicalizeAuthority(ctx.host) }, + }, + RAW: (table) => servedActivity(table), + }, + with: activitySelection, + }); + return activity == null ? null : toCreate(ctx, activity); + }, + ); + + builder + .setOutboxDispatcher( + "/users/{identifier}/outbox", + async (ctx, identifier, cursor) => { + if ((await findActiveActor(db, ctx, identifier)) == null) return null; + const boundary = parseOutboxCursor(cursor); + if (boundary === false) return null; + const rows = await db.query.activities.findMany({ + where: { + actorId: identifier as Uuid, + RAW: (table) => + and( + servedActivity(table), + boundary == null + ? undefined + : sql`(${table.published}, ${table.id}) < + (${boundary.published}::timestamptz, ${boundary.id}::uuid)`, + )!, + }, + // Backfilled activity IDs are UUIDv7, so only publication time + // determines chronology. Keep full database precision in cursors. + extras: { + cursorPublished: (table) => + sql`to_char(${table.published} AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, + }, + orderBy: { published: "desc", id: "desc" }, + limit: OUTBOX_PAGE_SIZE + 1, + with: activitySelection, + }); + const page = rows.slice(0, OUTBOX_PAGE_SIZE); + return { + items: page.map((object) => toCreate(ctx, object)), + nextCursor: + rows.length > OUTBOX_PAGE_SIZE + ? `${page.at(-1)!.cursorPublished}|${page.at(-1)!.id}` + : null, + }; + }, + ) + .setFirstCursor(async (ctx, identifier) => + (await findActiveActor(db, ctx, identifier)) == null ? null : "", + ) + .setCounter(async (ctx, identifier) => { + const actor = await findActiveActor(db, ctx, identifier); + return actor == null + ? null + : db.$count( + schema.activities, + and( + eq(schema.activities.actorId, actor.id), + servedActivity(schema.activities), + ), + ); + }); + builder .setFollowersDispatcher( "/users/{identifier}/followers", - async (ctx, identifier) => - // FIXME: Return the actual followers once the data model stores - // follows - (await findActiveActor(db, ctx, identifier)) == null - ? null - : { items: [] }, + async (ctx, identifier) => { + const collection = await actorCollection( + db, + ctx, + identifier, + "followers", + ); + if (collection == null) return null; + // FollowersDispatcher requires actor inboxes for future delivery fan-out. + return { + items: collection.items.map(({ item }) => ({ + id: new URL(item.iri), + inboxId: item.actor == null ? null : new URL(item.actor.inboxUrl), + })), + }; + }, ) .setCounter( async (ctx, identifier) => - (await findActiveActor(db, ctx, identifier))?.followersCount ?? null, + (await actorCollection(db, ctx, identifier, "followers"))?.items + .length ?? null, ); - builder .setFollowingDispatcher( "/users/{identifier}/following", - async (ctx, identifier) => - // FIXME: Return the actual following once the data model stores - // follows - (await findActiveActor(db, ctx, identifier)) == null + async (ctx, identifier) => { + const collection = await actorCollection( + db, + ctx, + identifier, + "following", + ); + return collection == null ? null - : { items: [] }, + : { items: collection.items.map(({ item }) => new URL(item.iri)) }; + }, ) .setCounter( async (ctx, identifier) => - (await findActiveActor(db, ctx, identifier))?.followingCount ?? null, + (await actorCollection(db, ctx, identifier, "following"))?.items + .length ?? null, ); - builder.setFeaturedDispatcher( "/users/{identifier}/featured", - async (ctx, identifier) => - // FIXME: Return the actual pinned objects once the data model stores - // them - (await findActiveActor(db, ctx, identifier)) == null + async (ctx, identifier) => { + const collection = await actorCollection(db, ctx, identifier, "featured"); + return collection == null ? null - : { items: [] }, + : { + items: collection.items.map( + ({ item }) => new APObject({ id: new URL(item.iri) }), + ), + }; + }, ); return builder; } @@ -202,21 +334,22 @@ export default async function createFederation( // Whether a sanction is *currently* active is always determined by comparing // against the current time (lazy expiry; no cron); see the actors table. function isSuspended({ suspended, suspendedUntil }: Actor): boolean { - const now = new Date(); + const now = Temporal.Now.instant(); return ( suspended != null && - suspended <= now && - (suspendedUntil == null || suspendedUntil > now) + Temporal.Instant.compare(suspended, now) <= 0 && + (suspendedUntil == null || + Temporal.Instant.compare(suspendedUntil, now) > 0) ); } function toActorObject( ctx: Context, identifier: string, - actor: Actor, + actor: StoredActor, ): ActorObject { return actorConstructors[actor.type]({ - id: ctx.getActorUri(identifier), + id: new URL(actor.resource.iri), preferredUsername: actor.username, name: actor.name, summary: actor.bioHtml, @@ -233,13 +366,183 @@ function toActorObject( sensitive: actor.sensitive, suspended: isSuspended(actor), aliases: actor.aliases.map((alias) => new URL(alias)), - inbox: ctx.getInboxUri(identifier), - outbox: ctx.getOutboxUri(identifier), - followers: ctx.getFollowersUri(identifier), - following: ctx.getFollowingUri(identifier), - featured: ctx.getFeaturedUri(identifier), + inbox: new URL(actor.inboxUrl), + outbox: collectionIri(actor, "outbox"), + followers: collectionIri(actor, "followers"), + following: collectionIri(actor, "following"), + featured: collectionIri(actor, "featured"), endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }), }); } const logger = getLogger(["drfed", "graphql", "federation"]); + +/** + * Parse an opaque boundary without rounding database microseconds. + * @returns The boundary, null for the first page, or false for invalid input. + */ +function parseOutboxCursor( + cursor: string | null, +): { published: string; id: string } | null | false { + if (cursor == null || cursor === "") return null; + const [published, id, extra] = cursor.split("|"); + if ( + published == null || + published.startsWith("0000-") || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/u.test(published) || + id == null || + !validateUuid(id) || + extra != null + ) { + return false; + } + try { + Temporal.Instant.from(published); + return { published, id }; + } catch { + return false; + } +} + +const OUTBOX_PAGE_SIZE = 20; +type ObjectProps = ConstructorParameters[0]; +const objectConstructors: Record APObject> = + { + Article: (props) => new Article(props), + Note: (props) => new Note(props), + }; + +type StoredAddressing = Addressing & { targetResource: Resource }; +type StoredActor = Actor & { + resource: Resource; + collectionReferences: (typeof schema.actorCollectionReferences.$inferSelect & { + collection: typeof schema.collections.$inferSelect & { resource: Resource }; + })[]; +}; +export const objectSelection = { + resource: true, + actor: { with: { resource: true } }, + addressing: { with: { targetResource: true }, orderBy: { position: "asc" } }, +} as const; +export const activitySelection = { + resource: true, + actor: { with: { resource: true } }, + object: true, + addressing: { with: { targetResource: true }, orderBy: { position: "asc" } }, +} as const; +type StoredObject = ActivityPubObject & { + resource: Resource; + actor: Actor & { resource: Resource }; + addressing: StoredAddressing[]; +}; +type StoredCreate = StoredActivity & { + resource: Resource; + actor: Actor & { resource: Resource }; + object: Resource | null; + addressing: StoredAddressing[]; +}; + +function recipients(rows: readonly StoredAddressing[]): { + tos: URL[]; + ccs: URL[]; + audiences: URL[]; +} { + const values = (property: string): URL[] => + rows + .filter((entry) => entry.property === property) + .toSorted((left, right) => left.position - right.position) + .map((entry) => new URL(entry.targetResource.iri)); + return { + tos: values("to"), + ccs: values("cc"), + audiences: values("audience"), + }; +} + +/** + * Shared Public predicate for object, activity, outbox page and counter. + * @returns An EXISTS predicate matching explicit Public addressing. + */ +function publicAddressing(sourceId: SQLWrapper): SQL { + return sql`exists (select 1 from ${schema.addressing} where ${schema.addressing.sourceId} = ${sourceId} and ${schema.addressing.targetId} = ${PUBLIC_RESOURCE_ID} and ${schema.addressing.property} in ('to', 'cc'))`; +} +function servedActivity(table: { + id: SQLWrapper; + objectId: SQLWrapper; + type: SQLWrapper; +}): SQL { + return sql`${table.type} = 'Create' and ${publicAddressing(table.id)} and exists (select 1 from ${schema.objects} where ${schema.objects.id} = ${table.objectId} and ${schema.objects.deleted} is null)`; +} +function collectionIri(actor: StoredActor, role: string): URL | null { + const reference = actor.collectionReferences.find( + (entry) => entry.role === role, + ); + return reference == null ? null : new URL(reference.collection.resource.iri); +} +async function actorCollection( + db: Database, + ctx: Context, + identifier: string, + role: "followers" | "following" | "featured", +) { + const actor = await findActiveActor(db, ctx, identifier); + if (actor == null) return null; + const reference = await db.query.actorCollectionReferences.findFirst({ + where: { actorId: actor.id, role }, + with: { + collection: { + with: { + items: { + orderBy: { position: "asc", itemId: "asc" }, + with: { item: { with: { actor: true } } }, + }, + }, + }, + }, + }); + return reference?.collection ?? { items: [] }; +} + +/** + * Serializes stored object addressing; blind recipients stay in the database. + * @returns The vocabulary object without blind recipients. + */ +export function toObject( + _ctx: Context, + object: StoredObject, +): APObject { + return objectConstructors[object.type]({ + id: new URL(object.resource.iri), + attribution: new URL(object.actor.resource.iri), + contents: [ + object.contentHtml, + ...(object.language == null + ? [] + : [new LanguageString(object.contentHtml, object.language)]), + ], + name: object.name, + summary: object.summary, + sensitive: object.sensitive, + published: object.published, + updated: object.updated, + url: object.url == null ? null : new URL(object.url), + ...recipients(object.addressing), + }); +} + +/** + * Serializes a persisted Create activity, retaining its own IRI and addressing. + * @returns The vocabulary activity without blind recipients. + */ +export function toCreate( + _ctx: Context, + activity: StoredCreate, +): Create { + return new Create({ + id: new URL(activity.resource.iri), + actor: new URL(activity.actor.resource.iri), + ...recipients(activity.addressing), + object: activity.object == null ? null : new URL(activity.object.iri), + published: activity.published, + }); +} diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 9786876..f12885d 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -128,7 +128,7 @@ const findSession = async (accessToken: string, db: Database) => await db.query.sessions.findFirst({ where: { tokenHash: await hashSecret(accessToken), - expires: { gt: new Date() }, + expires: { gt: Temporal.Now.instant() }, }, with: { account: true }, }); diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index 6b4814c..58ac19f 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -25,9 +25,9 @@ import { DrizzleQueryError } from "drizzle-orm"; import { hashSecret } from "./auth/hash.ts"; 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 accepted = Temporal.Instant.from("2026-06-24T00:00:00.000Z"); +const created = Temporal.Instant.from("2026-06-24T00:00:00.000Z"); +const expires = Temporal.Instant.from("2026-07-24T00:00:00.000Z"); const ok = 200; const defaultMaxActors = 10; @@ -191,18 +191,8 @@ const instanceMembersResponse = { totalCount: 2, edges: [ { - created: "2026-06-24T00:00:00.000Z", - accepted: "2026-06-24T00:00:00.000Z", - admin: true, - node: { - uuid: accountId, - email: "owner@example.com", - name: "Owner", - }, - }, - { - created: "2026-06-24T00:00:00.000Z", - accepted: "2026-06-24T00:00:00.000Z", + created: created.toString(), + accepted: accepted.toString(), admin: false, node: { uuid: memberId, @@ -210,6 +200,16 @@ const instanceMembersResponse = { name: "Member", }, }, + { + created: created.toString(), + accepted: accepted.toString(), + admin: true, + node: { + uuid: accountId, + email: "owner@example.com", + name: "Owner", + }, + }, ], }, }, @@ -429,7 +429,7 @@ describe("Mutation.createInstance", () => { assert.equal(members.length, 1); assert.equal(members[0]?.accountId, accountId); assert.equal(members[0]?.instanceId, instances[0]?.id); - assert.ok(members[0]?.accepted instanceof Date); + assert.ok(members[0]?.accepted instanceof Temporal.Instant); }); }); @@ -522,7 +522,7 @@ describe("Instance.localInstance", () => { localInstance: { uuid: localInstanceId, slug: "test-instance", - expires: expires.toISOString(), + expires: expires.toString(), maxActors: defaultMaxActors, }, }, @@ -586,7 +586,7 @@ describe("Query.localInstanceBySlug", () => { localInstanceBySlug: { uuid: localInstanceId, slug: "test-instance", - expires: expires.toISOString(), + expires: expires.toString(), maxActors: defaultMaxActors, instance: { uuid: instanceId, diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index ed42f1b..8c92d66 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -261,9 +261,7 @@ builder.mutationFields((t) => ({ .values({ id: uuid(), slug, - expires: new Date( - Temporal.Now.instant().add({ hours: YEAR_BY_HOURS }).toString(), - ), + expires: Temporal.Now.instant().add({ hours: YEAR_BY_HOURS }), }) .returning(); if (local == null) { @@ -277,7 +275,7 @@ builder.mutationFields((t) => ({ await tx.insert(schema.instanceMembers).values({ instanceId: instance.id, accountId: account.id, - accepted: new Date(), + accepted: Temporal.Now.instant(), }); const instances = await tx.$count( schema.instanceMembers, diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts new file mode 100644 index 0000000..edeb460 --- /dev/null +++ b/packages/graphql/src/object.test.ts @@ -0,0 +1,889 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// oxlint-disable max-statements +// Sequential mutations exercise counter updates in the same database. +// oxlint-disable no-await-in-loop + +import assert from "node:assert/strict"; + +import { schema } from "@drfed/models"; +import { PUBLIC_IRI } from "@drfed/models/resource"; +import { uuidV7 as uuid } from "@drfed/models/uuid"; +import { describe, it } from "@logtape/testing-node/autoload"; +import { eq } from "drizzle-orm"; + +import { withTestHarness } from "./harness.test.ts"; +import { + globalId, + localActorId, + localInstanceId, + remoteActorId, + seedActors, + seedAuthenticatedLocalInstance, + seedLocalActor, + seedLocalInstance, + seedObjects, + seedRemoteActor, +} from "./seed.test.ts"; + +const fields = `id uuid iri url type actor { uuid } to { iri kind } cc { iri } name summary contentHtml language sensitive published updated created`; +const mutation = `mutation Create($actor: ID!, $contentHtml: String!, $language: String, $type: ObjectType! = Note, $addressing: AddressingInput!) { + createObject(actor: $actor, contentHtml: $contentHtml, language: $language, type: $type, addressing: $addressing) { + resultType: __typename + ... on Object { ${fields} } + ... on CreateObjectError { errorType: type message } + } +}`; +const variables = { + actor: globalId("Actor", localActorId), + contentHtml: "

Hello

", + addressing: { to: [PUBLIC_IRI] }, +}; +const outboxQuery = `query($actor: ID!) { + node(id: $actor) { + ... on Actor { + outbox { + declaredTotalItems + totalCount + items { edges { node { detail { ... on Activity { type object { detail { ... on Object { uuid } } } } } } } } + } + } + } +}`; +const accept = { accept: "application/activity+json" }; + +describe("Mutation.createObject", () => { + for (const rootOrigin of [ + "http://drfed.org", + "http://drfed.localhost:8888", + ]) { + it(`preserves the protocol and port of ${rootOrigin} in fetchable resource IRIs`, async () => { + await withTestHarness(async ({ db, post, federation }) => { + const origin = new URL(rootOrigin); + const host = `test-instance.${origin.host}`; + const base = `${origin.protocol}//${host}`; + await seedLocalInstance(db, host); + const auth = await seedAuthenticatedLocalInstance(db); + await db.insert(schema.localActors).values({ id: localActorId }); + await seedActors(db, { + id: localActorId, + localId: localActorId, + instanceId: localInstanceId, + type: "Person", + username: "alice", + iri: `${base}/users/${localActorId}`, + inboxUrl: `${base}/users/${localActorId}/inbox`, + }); + const body = await ( + await post({ query: mutation, variables }, auth) + ).json(); + assert.equal(body.errors, undefined); + const object = body.data.createObject; + assert.equal(object.resultType, "Object"); + const stored = await db.query.objects.findFirst({ + where: { id: object.uuid }, + with: { resource: true }, + }); + const activity = await db.query.activities.findFirst({ + where: { objectId: object.uuid }, + with: { resource: true }, + }); + assert.ok(stored); + assert.ok(activity); + assert.equal( + object.iri, + `${base}/users/${localActorId}/${object.uuid}`, + ); + assert.equal(stored.resource.iri, object.iri); + assert.equal( + activity.resource.iri, + `${base}/ap/creates/${activity.id}`, + ); + for (const iri of [stored.resource.iri, activity.resource.iri]) { + const response = await federation.fetch( + new Request(iri, { headers: accept }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + assert.equal((await response.json()).id, iri); + } + }, new URL(rootOrigin)); + }); + } + + it("creates verbatim HTML, canonicalizes language and resolves every node field", async () => { + await withTestHarness(async ({ db, post, federation }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const contentHtml = '

Hello

'; + const response = await post( + { + query: mutation, + variables: { ...variables, contentHtml, language: "KO-kr" }, + }, + auth, + ); + const body = await response.json(); + assert.equal(body.errors, undefined); + const object = body.data.createObject; + assert.equal(object.resultType, "Object"); + assert.equal(object.type, "Note"); + assert.deepEqual(object.to, [{ iri: PUBLIC_IRI, kind: "collection" }]); + assert.equal(object.language, "ko-KR"); + assert.equal(object.contentHtml, contentHtml); + assert.equal( + object.iri, + `https://test-instance.drfed.org/users/${localActorId}/${object.uuid}`, + ); + assert.equal(object.sensitive, false); + assert.equal(object.url, null); + assert.deepEqual(object.actor, { uuid: localActorId }); + const row = await db.query.objects.findFirst({ + where: { id: object.uuid }, + }); + assert.equal(row?.contentHtml, contentHtml); + assert.equal(row?.language, "ko-KR"); + // The GraphQL outbox collection and the ActivityPub outbox must agree + // on a public Create activity. + const outbox = await ( + await post({ + query: outboxQuery, + variables: { actor: variables.actor }, + }) + ).json(); + assert.deepEqual(outbox, { + data: { + node: { + outbox: { + declaredTotalItems: null, + totalCount: 1, + items: { + edges: [ + { + node: { + detail: { + type: "Create", + object: { detail: { uuid: object.uuid } }, + }, + }, + }, + ], + }, + }, + }, + }, + }); + const served = await federation.fetch( + new Request( + `https://test-instance.drfed.org/users/${localActorId}/outbox`, + { headers: accept }, + ), + { contextData: undefined }, + ); + assert.equal(served.status, 200); + assert.equal((await served.json()).totalItems, 1); + const node = await post({ + query: `query($id: ID!) { node(id: $id) { resultType: __typename ... on Object { ${fields} } } }`, + variables: { id: object.id }, + }); + assert.deepEqual(await node.json(), { data: { node: object } }); + }); + }); + it("presents public outbox activities newest first in GraphQL and ActivityPub", async () => { + await withTestHarness(async ({ db, post, federation }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const created: { uuid: string; iri: string }[] = []; + for (const contentHtml of ["A", "B", "C"]) { + const body = await ( + await post( + { query: mutation, variables: { ...variables, contentHtml } }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + created.push(body.data.createObject); + } + const graphql = await ( + await post({ + query: outboxQuery, + variables: { actor: variables.actor }, + }) + ).json(); + assert.equal(graphql.errors, undefined); + const ids = graphql.data.node.outbox.items.edges.map( + (edge: { + node: { detail: { object: { detail: { uuid: string } } } }; + }) => edge.node.detail.object.detail.uuid, + ); + const response = await federation.fetch( + new Request( + `https://test-instance.drfed.org/users/${localActorId}/outbox?cursor=`, + { headers: accept }, + ), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + const page = await response.json(); + const objects = page.orderedItems.map( + (item: { object: { id: string } | string }) => + typeof item.object === "string" ? item.object : item.object.id, + ); + const expected = created.toReversed(); + assert.deepEqual( + ids, + expected.map((object) => object.uuid), + ); + assert.deepEqual( + objects, + expected.map((object) => object.iri), + ); + assert.deepEqual( + objects.map((iri: string) => iri.split("/").at(-1)), + ids, + ); + }); + }); + it("keeps deleted-object activity history in GraphQL but not ActivityPub outboxes", async () => { + await withTestHarness(async ({ db, post, federation }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const created = await ( + await post({ query: mutation, variables }, auth) + ).json(); + assert.equal(created.errors, undefined); + const objectId = created.data.createObject.uuid; + await db + .update(schema.objects) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.objects.id, objectId)); + + // Keep the stored activity inspectable for debugging even after its + // object is deleted; the public protocol view applies a stricter policy. + const outbox = await ( + await post({ + query: outboxQuery, + variables: { actor: variables.actor }, + }) + ).json(); + assert.deepEqual(outbox, { + data: { + node: { + outbox: { + declaredTotalItems: null, + totalCount: 1, + items: { + edges: [ + { + node: { + detail: { type: "Create", object: { detail: null } }, + }, + }, + ], + }, + }, + }, + }, + }); + const served = await federation.fetch( + new Request( + `https://test-instance.drfed.org/users/${localActorId}/outbox`, + { headers: accept }, + ), + { contextData: undefined }, + ); + assert.equal(served.status, 200); + assert.equal((await served.json()).totalItems, 0); + }); + }); + for (const [input, error] of [ + [{ contentHtml: " \n\t" }, "InvalidContent"], + [{ language: "not_a_tag" }, "InvalidLanguage"], + [{ language: "" }, "InvalidLanguage"], + [ + { language: "en-x-abcdefgh-abcdefgh-abcdefgh-abcdefgh" }, + "InvalidLanguage", + ], + ] as const) { + it(`rejects invalid input ${JSON.stringify(input)}`, async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const body = await ( + await post( + { query: mutation, variables: { ...variables, ...input } }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.createObject.errorType, error); + assert.equal(await db.$count(schema.objects), 0); + }); + }); + } + for (const scenario of [ + "remote", + "nonmember", + "pending", + "expired", + "deleted", + "missing", + "malformed", + ] as const) { + it(`hides ${scenario} actors`, async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + await seedRemoteActor(db); + if (scenario === "nonmember") await db.delete(schema.instanceMembers); + if (scenario === "pending") { + await db.update(schema.instanceMembers).set({ accepted: null }); + } + if (scenario === "expired") { + await db + .update(schema.localInstances) + .set({ expires: Temporal.Instant.fromEpochMilliseconds(0) }); + } + if (scenario === "deleted") { + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + } + const actorId = + scenario === "remote" + ? remoteActorId + : scenario === "missing" + ? uuid() + : scenario === "malformed" + ? "bad" + : localActorId; + const body = await ( + await post( + { + query: mutation, + variables: { ...variables, actor: globalId("Actor", actorId) }, + }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.createObject.errorType, "ActorNotFound"); + assert.equal(await db.$count(schema.objects), 0); + }); + }); + } + for (const text of ["", " \n\t", " Keep spacing "]) { + it(`normalizes optional text ${JSON.stringify(text)}`, async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const query = mutation.replace( + "type: $type,", + `name: ${JSON.stringify(text)}, summary: ${JSON.stringify(text)}, type: $type,`, + ); + const body = await (await post({ query, variables }, auth)).json(); + assert.equal(body.errors, undefined); + const expected = text.trim() === "" ? null : text; + assert.equal(body.data.createObject.name, expected); + assert.equal(body.data.createObject.summary, expected); + const row = await db.query.objects.findFirst(); + assert.equal(row?.name, expected); + assert.equal(row?.summary, expected); + }); + }); + } + it("requires authentication", async () => { + await withTestHarness(async ({ post }) => { + const body = await (await post({ query: mutation, variables })).json(); + assert.ok(body.errors?.length); + }); + }); + it("creates Articles with optional fields and varied addressing on a suspended actor", async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + await db + .update(schema.actors) + .set({ suspended: Temporal.Instant.fromEpochMilliseconds(0) }) + .where(eq(schema.actors.id, localActorId)); + for (const addressing of [ + { to: [PUBLIC_IRI] }, + { cc: [PUBLIC_IRI] }, + {}, + ]) { + const query = mutation.replace( + "type: $type,", + 'name: "Title", summary: "CW", sensitive: true, type: $type,', + ); + const body = await ( + await post( + { query, variables: { ...variables, type: "Article", addressing } }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.createObject.type, "Article"); + + assert.equal(body.data.createObject.name, "Title"); + assert.equal(body.data.createObject.summary, "CW"); + assert.equal(body.data.createObject.sensitive, true); + } + // Every stored Create activity is an outbox member regardless of + // addressing; only the ActivityPub outbox restricts itself to Public. + const outbox = await ( + await post({ + query: outboxQuery, + variables: { actor: variables.actor }, + }) + ).json(); + assert.equal(outbox.errors, undefined); + assert.equal(outbox.data.node.outbox.declaredTotalItems, null); + assert.equal(outbox.data.node.outbox.totalCount, 3); + assert.equal(outbox.data.node.outbox.items.edges.length, 3); + }); + }); +}); + +describe("Actor.objects", () => { + it("keeps followers objects publicly readable through GraphQL", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + const id = uuid(); + await seedObjects(db, { + id, + actorId: localActorId, + type: "Note", + addressing: { + to: [ + `https://test-instance.drfed.org/users/${localActorId}/followers`, + ], + }, + iri: `https://test-instance.drfed.org/users/${localActorId}/${id}`, + contentHtml: "GraphQL debugging content", + }); + const query = `query($object: ID!, $actor: ID!) { + node(id: $object) { ... on Object { uuid contentHtml } } + nodes(ids: [$object]) { ... on Object { uuid } } + actor: node(id: $actor) { ... on Actor { objects(first: 1) { totalCount edges { node { uuid } } } } } + }`; + const body = await ( + await post({ + query, + variables: { + object: globalId("Object", id), + actor: globalId("Actor", localActorId), + }, + }) + ).json(); + assert.deepEqual(body, { + data: { + node: { + uuid: id, + contentHtml: "GraphQL debugging content", + }, + nodes: [{ uuid: id }], + actor: { + objects: { totalCount: 1, edges: [{ node: { uuid: id } }] }, + }, + }, + }); + }); + }); + + it("paginates by published time and UUID, excluding deleted and other actors", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const ids = Array.from({ length: 5 }, () => uuid()); + await seedObjects( + db, + ids.map((id, index) => ({ + id, + actorId: index === 4 ? remoteActorId : localActorId, + type: "Note" as const, + iri: `https://test.example/${id}`, + contentHtml: "test", + published: Temporal.Instant.from( + index === 0 ? "2027-01-01T00:00:00Z" : "2026-01-01T00:00:00Z", + ), + deleted: index === 3 ? Temporal.Now.instant() : null, + })), + ); + const query = `query($actor: ID!, $after: String, $before: String, $first: Int, $last: Int) { node(id: $actor) { ... on Actor { objects(first: $first, after: $after, last: $last, before: $before) { totalCount edges { cursor node { uuid } } pageInfo { hasNextPage hasPreviousPage } } } } }`; + const first = await ( + await post({ + query, + variables: { actor: globalId("Actor", localActorId), first: 2 }, + }) + ).json(); + assert.equal(first.errors, undefined); + const connection = first.data.node.objects; + assert.equal(connection.totalCount, 3); + assert.deepEqual( + connection.edges.map( + (edge: { node: { uuid: string } }) => edge.node.uuid, + ), + [ids[0], ids[2]], + ); + assert.equal(connection.pageInfo.hasNextPage, true); + const next = await ( + await post({ + query, + variables: { + actor: globalId("Actor", localActorId), + first: 2, + after: connection.edges[1].cursor, + }, + }) + ).json(); + assert.equal(next.errors, undefined); + assert.deepEqual( + next.data.node.objects.edges.map( + (edge: { node: { uuid: string } }) => edge.node.uuid, + ), + [ids[1]], + ); + assert.equal(next.data.node.objects.pageInfo.hasNextPage, false); + const previous = await ( + await post({ + query, + variables: { + actor: globalId("Actor", localActorId), + last: 2, + before: next.data.node.objects.edges[0].cursor, + }, + }) + ).json(); + assert.equal(previous.errors, undefined); + assert.deepEqual(previous.data.node.objects.edges, connection.edges); + }); + }); +}); + +describe("Query.node", () => { + it("hides deleted objects from node and nodes while keeping live ones", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + const liveId = uuid(); + const deletedId = uuid(); + await seedObjects( + db, + [liveId, deletedId].map((id) => ({ + id, + actorId: localActorId, + type: "Note" as const, + iri: `https://test-instance.drfed.org/users/${localActorId}/${id}`, + contentHtml: "test", + deleted: id === deletedId ? Temporal.Now.instant() : null, + })), + ); + const query = `query($live: ID!, $deleted: ID!) { + live: node(id: $live) { ... on Object { uuid actor { uuid } } } + deleted: node(id: $deleted) { ... on Object { uuid actor { uuid } } } + nodes(ids: [$live, $deleted]) { ... on Object { uuid } } + }`; + const body = await ( + await post({ + query, + variables: { + live: globalId("Object", liveId), + deleted: globalId("Object", deletedId), + }, + }) + ).json(); + assert.deepEqual(body, { + data: { + live: { uuid: liveId, actor: { uuid: localActorId } }, + deleted: null, + nodes: [{ uuid: liveId }, null], + }, + }); + }); + }); +}); + +// Regression test for +// https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163244: +// Preserve microseconds through the database, GraphQL scalar and cursor. +describe("Actor.objects cursor precision", () => { + it("returns every object whose published time carries microseconds", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + const ids = Array.from({ length: 3 }, () => uuid()); + await seedObjects( + db, + ids.map((id) => ({ + id, + actorId: localActorId, + type: "Note" as const, + iri: `https://test-instance.drfed.org/users/${localActorId}/${id}`, + contentHtml: "test", + published: Temporal.Instant.from("2026-09-14T12:00:00.123456Z"), + })), + ); + const query = `query($actor: ID!, $after: String) { node(id: $actor) { ... on Actor { objects(first: 1, after: $after) { edges { cursor node { uuid published } } pageInfo { hasNextPage } } } } }`; + const seen: string[] = []; + let after: string | null = null; + let hasNextPage = true; + for (let page = 0; hasNextPage && page <= ids.length; page += 1) { + const body = await ( + await post({ + query, + variables: { actor: globalId("Actor", localActorId), after }, + }) + ).json(); + assert.equal(body.errors, undefined); + const connection = body.data.node.objects; + assert.equal(connection.edges.length, 1); + assert.equal( + connection.edges[0].node.published, + "2026-09-14T12:00:00.123456Z", + ); + seen.push( + ...connection.edges.map( + (edge: { node: { uuid: string } }) => edge.node.uuid, + ), + ); + ({ hasNextPage } = connection.pageInfo); + ({ cursor: after } = connection.edges.at(-1)); + } + assert.deepEqual(seen, [...ids].sort().reverse()); + assert.equal(hasNextPage, false); + }); + }); +}); + +// Regression test for +// https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163257: +// `filterDeleted` only inspects the node's own `deleted` column, so objects of +// a soft-deleted actor still resolve and `Object.actor` returns that actor. +describe("Query.node with a deleted actor", () => { + it("hides the objects of a deleted actor from node, nodes, and Object.actor", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const hiddenId = uuid(); + const liveId = uuid(); + await seedObjects( + db, + [hiddenId, liveId].map((id) => ({ + id, + actorId: id === hiddenId ? localActorId : remoteActorId, + type: "Note" as const, + iri: `https://test.example/${id}`, + contentHtml: "test", + })), + ); + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + const query = `query($hidden: ID!, $live: ID!) { + hidden: node(id: $hidden) { ... on Object { uuid actor { uuid } } } + live: node(id: $live) { ... on Object { uuid actor { uuid } } } + nodes(ids: [$hidden, $live]) { ... on Object { uuid } } + }`; + const body = await ( + await post({ + query, + variables: { + hidden: globalId("Object", hiddenId), + live: globalId("Object", liveId), + }, + }) + ).json(); + assert.deepEqual(body, { + data: { + hidden: null, + live: { uuid: liveId, actor: { uuid: remoteActorId } }, + nodes: [null, { uuid: liveId }], + }, + }); + }); + }); +}); + +describe("explicit addressing and persisted activities", () => { + it("preserves exact IRI order, duplicates, all properties and JSON-LD snapshots", async () => { + await withTestHarness(async ({ db, post, federation }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const unknown = "https://REMOTE.example:443/a/../target"; + const blind = "https://remote.example/blind"; + const addressing = { + to: [unknown, PUBLIC_IRI, unknown], + cc: [unknown], + bto: [blind], + bcc: [blind], + audience: [unknown, unknown], + }; + const query = mutation.replace( + "... on Object {", + "... on Object { document bto { iri } bcc { iri } audience { iri } activities(first: 1, type: Create) { edges { node { id iri document type actor { uuid } object { iri kind detail { ... on Object { contentHtml } } } to { iri } bto { iri } } } }", + ); + const create = async () => { + const body = await ( + await post({ query, variables: { ...variables, addressing } }, auth) + ).json(); + assert.equal(body.errors, undefined); + return body.data.createObject; + }; + const object = await create(); + await create(); + assert.deepEqual( + object.to.map((r: { iri: string }) => r.iri), + addressing.to, + ); + assert.equal(object.to[0].kind, "unknown"); + assert.deepEqual( + object.audience.map((r: { iri: string }) => r.iri), + addressing.audience, + ); + assert.deepEqual(object.bto, [{ iri: blind }]); + assert.deepEqual(object.bcc, object.bto); + assert.equal(object.activities.edges[0].node.type, "Create"); + assert.equal(object.activities.edges[0].node.object.iri, object.iri); + assert.equal(object.activities.edges[0].node.object.kind, "object"); + assert.equal( + object.activities.edges[0].node.object.detail.contentHtml, + variables.contentHtml, + ); + assert.deepEqual(object.activities.edges[0].node.actor, { + uuid: localActorId, + }); + for (const document of [ + object.document, + object.activities.edges[0].node.document, + ]) { + for (const [property, values] of Object.entries(addressing)) { + assert.deepEqual(document[property], values); + } + } + assert.equal( + await db.$count(schema.resources, eq(schema.resources.iri, unknown)), + 1, + ); + const activity = await db.query.activities.findFirst({ + where: { objectId: object.uuid }, + with: { resource: true }, + }); + assert.ok(activity); + assert.notEqual(activity.id, object.uuid); + assert.ok(activity.resource.iri.endsWith(`/ap/creates/${activity.id}`)); + const stored = await db.query.objects.findFirst({ + where: { id: object.uuid }, + }); + assert.deepEqual(stored?.document, object.document); + assert.deepEqual( + activity.document, + object.activities.edges[0].node.document, + ); + for (const iri of [object.iri, activity.resource.iri]) { + const response = await federation.fetch( + new Request(iri, { + headers: { accept: "application/activity+json" }, + }), + { contextData: undefined }, + ); + assert.equal(response.status, 200); + const document = await response.json(); + assert.equal(document.bto, undefined); + assert.equal(document.bcc, undefined); + assert.equal(document.id, iri); + } + }); + }); + it("accepts empty addressing but requires the input argument", async () => { + await withTestHarness(async ({ db, post, federation }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const missing = await ( + await post( + { + query: mutation, + variables: { + actor: variables.actor, + contentHtml: variables.contentHtml, + }, + }, + auth, + ) + ).json(); + assert.ok(missing.errors?.length); + const body = await ( + await post( + { query: mutation, variables: { ...variables, addressing: {} } }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + assert.deepEqual(body.data.createObject.to, []); + assert.equal(await db.$count(schema.addressing), 0); + assert.equal(await db.$count(schema.activities), 1); + const response = await federation.fetch( + new Request(body.data.createObject.iri, { + headers: { accept: "application/activity+json" }, + }), + { contextData: undefined }, + ); + assert.equal(response.status, 404); + }); + }); + it("computes different expected classifications for followers only in cc", async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const body = await ( + await post( + { + query: mutation.replace( + "... on Object {", + "... on Object { activities(first: 1, type: Create) { edges { node { expectedClassifications { implementation version classification reason } } } }", + ), + variables: { + ...variables, + addressing: { + cc: [ + `https://test-instance.drfed.org/users/${localActorId}/followers`, + ], + }, + }, + }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + const results = + body.data.createObject.activities.edges[0].node.expectedClassifications; + assert.deepEqual( + results.map((r: { implementation: string; classification: string }) => [ + r.implementation, + r.classification, + ]), + [ + ["MASTODON", "direct"], + ["MISSKEY", "followers"], + ], + ); + for (const result of results) { + assert.match(result.version, /^[0-9a-f]{40}$/u); + assert.match(result.reason, /Expected/u); + } + }); + }); +}); diff --git a/packages/graphql/src/object.ts b/packages/graphql/src/object.ts new file mode 100644 index 0000000..0f15506 --- /dev/null +++ b/packages/graphql/src/object.ts @@ -0,0 +1,450 @@ +// 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 { + addActorCollectionItem, + lockActorCollection, + promoteResource, + schema, + storeAddressing, +} from "@drfed/models"; +import { objectTypeEnum } from "@drfed/models/schema"; +import { uuidV7 as uuid, validateUuid } from "@drfed/models/uuid"; +import { Object as APObject, Create } from "@fedify/vocab"; +import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; +import { and, eq, gt, isNotNull, isNull } from "drizzle-orm"; + +import { Actor } from "./actor.ts"; +import builder, { type DrFedObjectRef } from "./builder.ts"; +import { + activitySelection, + objectSelection, + toCreate, + toObject, +} from "./federation.ts"; +import { + Activity, + ActivityType, + ResourceDetail, + registerAddressingFields, +} from "./resource.ts"; + +const ObjectType = builder.enumType("ObjectType", { + values: objectTypeEnum.enumValues, +}); +const AddressingInput = builder.inputType("AddressingInput", { + fields: (t) => ({ + to: t.field({ type: ["URL"], required: true, defaultValue: [] }), + cc: t.field({ type: ["URL"], required: true, defaultValue: [] }), + bto: t.field({ type: ["URL"], required: true, defaultValue: [] }), + bcc: t.field({ type: ["URL"], required: true, defaultValue: [] }), + audience: t.field({ type: ["URL"], required: true, defaultValue: [] }), + }), +}); +const ObjectRef = builder.drizzleNode("objects", { + name: "Object", + select: { + columns: { id: true, deleted: true }, + with: { actor: { columns: { deleted: true } } }, + }, + description: "Represents an ActivityPub object authored by an `Actor`.", + id: { + column: ({ id }) => id, + description: "The Relay global ID of the object.", + }, + fields: (t) => ({ + resource: t.relation("resource"), + iri: t.field({ + type: "URL", + select: { with: { resource: true } }, + resolve: (row) => row.resource.iri, + }), + uuid: t.expose("id", { + type: "UUID", + description: "The UUID of the ActivityPub Object.", + }), + url: t.expose("url", { + type: "URL", + nullable: true, + description: "The human-readable page URL, if available.", + }), + type: t.expose("type", { + type: ObjectType, + description: "The ActivityStreams vocabulary type: Note or Article.", + }), + actor: t.relation("actor", { + description: "The actor that authored the object.", + }), + document: t.expose("document", { type: "JSON", nullable: true }), + name: t.exposeString("name", { + nullable: true, + description: "The optional title of the object.", + }), + summary: t.exposeString("summary", { + nullable: true, + description: "The optional summary or content warning.", + }), + contentHtml: t.exposeString("contentHtml", { + description: + "HTML preserved verbatim. Clients must sanitize it before browser rendering.", + }), + language: t.exposeString("language", { + nullable: true, + description: + "The canonical BCP 47 tag used for contentMap, if specified.", + }), + sensitive: t.exposeBoolean("sensitive", { + description: "Whether the content is marked sensitive.", + }), + published: t.expose("published", { + type: "DateTime", + description: "The ActivityStreams publication time.", + }), + updated: t.expose("updated", { + type: "DateTime", + description: "The time the stored object was last updated.", + }), + created: t.expose("created", { + type: "DateTime", + description: + "The time the object was stored in DrFed, distinct from its publication time.", + }), + }), +}); +export const ActivityPubObject: DrFedObjectRef = ObjectRef; +ResourceDetail.addTypes([ActivityPubObject]); +registerAddressingFields("objects"); + +const activitiesConnection = drizzleConnectionHelpers(builder, "activities", { + query: (args: { + type?: typeof schema.activities.$inferSelect.type | null; + }) => ({ + where: { + actor: { deleted: { isNull: true } }, + ...(args.type == null ? {} : { type: args.type }), + }, + orderBy: { published: "asc", id: "asc" }, + }), +}); +builder.drizzleObjectField("objects", "activities", (t) => + t.connection({ + type: Activity, + args: { type: t.arg({ type: ActivityType }) }, + description: + "Activities referencing this object, optionally filtered by type. Excludes deleted authors; ordered by published ASC, id ASC.", + select(args, ctx, nestedSelection) { + return { + with: { + activities: activitiesConnection.getQuery(args, ctx, nestedSelection), + }, + }; + }, + resolve: (object, args, ctx) => + activitiesConnection.resolve(object.activities, args, ctx, object), + }), +); + +const objectsConnection = drizzleConnectionHelpers(builder, "objects", { + query: { + orderBy: { published: "desc", id: "desc" }, + }, +}); +builder.drizzleObjectField("actors", "objects", (t) => + t.connection( + { + type: ActivityPubObject, + description: + "Non-deleted objects, newest publication first. All addressing is publicly readable through GraphQL.", + select(args, ctx, nestedSelection) { + return { + with: { + objects: objectsConnection.getQuery(args, ctx, nestedSelection), + }, + }; + }, + resolve(actor, args, ctx) { + return { + ...objectsConnection.resolve(actor.objects, args, ctx, actor), + totalCount() { + return ctx.db.$count( + schema.objects, + and( + eq(schema.objects.actorId, actor.id), + isNull(schema.objects.deleted), + ), + ); + }, + }; + }, + }, + { + name: "ObjectConnection", + fields: (fb) => ({ + totalCount: fb.int({ + description: + "The number of non-deleted objects authored by this actor, regardless of addressing.", + resolve: (connection) => connection.totalCount(), + }), + }), + }, + { name: "ObjectEdge" }, + ), +); + +const CreateObjectErrorType = builder.enumType("CreateObjectErrorType", { + values: ["ActorNotFound", "InvalidContent", "InvalidLanguage"] as const, +}); +interface CreateObjectError { + readonly type: typeof CreateObjectErrorType.$inferType; + readonly message: string; +} +const CreateObjectErrorRef = + builder.objectRef("CreateObjectError"); +CreateObjectErrorRef.implement({ + fields: (t) => ({ + type: t.expose("type", { + type: CreateObjectErrorType, + description: + "The type of the error. Use this for programmatic error handling.", + }), + message: t.exposeString("message", { + description: + "A human-readable message describing the error. " + + "Don't use this for programmatic error handling, " + + "use the `type` field instead.", + }), + }), +}); +const CreateObjectResult = builder.unionType("CreateObjectResult", { + types: [ObjectRef, CreateObjectErrorRef], + resolveType: (value) => + "message" in value ? CreateObjectErrorRef : ObjectRef, +}); +const actorNotFound: CreateObjectError = { + type: "ActorNotFound", + message: "Can't find the actor.", +}; + +builder.mutationFields((t) => ({ + createObject: t.field({ + type: CreateObjectResult, + description: + "Create a local ActivityPub object without delivering it to remote servers.", + authScopes: { authenticated: true }, + args: { + actor: t.arg.globalID({ + for: Actor, + required: true, + description: + "The local author actor ID. The viewer must be an accepted instance member.", + }), + type: t.arg({ + type: ObjectType, + required: true, + defaultValue: "Note", + description: "The ActivityStreams object type to create.", + }), + contentHtml: t.arg.string({ + required: true, + description: + "Non-empty HTML stored verbatim; browser clients must sanitize it before rendering.", + }), + name: t.arg.string({ + description: + "Optional title. Empty or whitespace-only values are stored as null.", + }), + summary: t.arg.string({ + description: + "Optional summary or content warning. Empty or whitespace-only values are stored as null.", + }), + language: t.arg.string({ + description: + "A BCP 47 language tag, canonicalized and limited to 35 characters.", + }), + sensitive: t.arg.boolean({ + required: true, + defaultValue: false, + description: "Whether to mark the content as sensitive.", + }), + addressing: t.arg({ + type: AddressingInput, + required: true, + description: + "Explicit addressing preserved in order, including duplicates. Empty lists are allowed.", + }), + }, + async resolve( + _parent, + { actor: { id: actorId }, language, addressing, ...input }, + ctx, + ) { + if (input.contentHtml.trim() === "") { + return { + type: "InvalidContent" as const, + message: "Content must not be empty.", + }; + } + let canonicalLanguage: string | null = null; + if (language != null) { + try { + canonicalLanguage = Intl.getCanonicalLocales(language)[0] ?? null; + if (canonicalLanguage == null || canonicalLanguage.length > 35) { + throw new RangeError("Language tag is too long."); + } + } catch (error) { + if (!(error instanceof RangeError)) throw error; + return { + type: "InvalidLanguage" as const, + message: + "Language must be a valid BCP 47 tag of at most 35 characters.", + }; + } + } + const { account } = ctx; + if (account == null) { + throw new Error("You must be authenticated to create objects."); + } + if (!validateUuid(actorId)) return actorNotFound; + return await ctx.db.transaction(async (tx) => { + const [actor] = await tx + .select({ id: schema.actors.id, host: schema.instances.host }) + .from(schema.actors) + .for("update", { of: schema.actors }) + .innerJoin( + schema.instances, + eq(schema.actors.instanceId, schema.instances.id), + ) + .innerJoin( + schema.localInstances, + eq(schema.instances.localId, schema.localInstances.id), + ) + .innerJoin( + schema.instanceMembers, + eq(schema.instanceMembers.instanceId, schema.instances.id), + ) + .where( + and( + eq(schema.actors.id, actorId), + isNotNull(schema.actors.localId), + isNull(schema.actors.deleted), + gt(schema.localInstances.expires, Temporal.Now.instant()), + eq(schema.instanceMembers.accountId, account.id), + isNotNull(schema.instanceMembers.accepted), + ), + ) + .limit(1); + if (actor == null) return actorNotFound; + await lockActorCollection(tx, actorId, "outbox"); + const published = Temporal.Now.instant(); + const id = uuid(); + const fedCtx = ctx.federation.createContext( + new URL(`${ctx.rootOrigin.protocol}//${actor.host}`), + undefined, + ); + const iri = fedCtx.getObjectUri(APObject, { + identifier: actorId, + id, + }).href; + const object = await promoteResource( + tx, + iri, + "object", + async (inner, resource) => { + const [row] = await inner + .insert(schema.objects) + .values({ + ...input, + name: normalizeOptionalText(input.name), + summary: normalizeOptionalText(input.summary), + id: resource.id, + actorId, + language: canonicalLanguage, + published, + }) + .returning(); + if (row == null) { + throw new Error("Object insertion returned no row."); + } + return row; + }, + id, + ); + await storeAddressing(tx, object.id, addressing); + const activityId = uuid(); + await promoteResource( + tx, + fedCtx.getObjectUri(Create, { id: activityId }).href, + "activity", + async (inner, resource) => { + await inner.insert(schema.activities).values({ + id: resource.id, + type: "Create", + actorId, + objectId: object.id, + published: object.published, + }); + await storeAddressing(inner, resource.id, addressing); + await addActorCollectionItem(inner, actorId, "outbox", resource.id); + }, + activityId, + ); + const storedObject = await tx.query.objects.findFirst({ + where: { id: object.id }, + with: objectSelection, + }); + const storedActivity = await tx.query.activities.findFirst({ + where: { id: activityId }, + with: activitySelection, + }); + if (storedObject == null || storedActivity == null) { + throw new Error("Missing newly created resource."); + } + // Preserve blind recipients in the local snapshot; serving strips them. + const snapshot = { + ...asDocument(await toObject(fedCtx, storedObject).toJsonLd()), + ...addressing, + }; + await tx + .update(schema.objects) + .set({ document: snapshot, updated: object.updated }) + .where(eq(schema.objects.id, object.id)); + await tx + .update(schema.activities) + .set({ + document: { + ...asDocument(await toCreate(fedCtx, storedActivity).toJsonLd()), + ...addressing, + }, + }) + .where(eq(schema.activities.id, activityId)); + return { ...object, actor: storedObject.actor, document: snapshot }; + }); + }, + }), +})); + +function normalizeOptionalText( + value: string | null | undefined, +): string | null { + return value == null || value.trim() === "" ? null : value; +} + +function asDocument(value: unknown): Record { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Expected a JSON-LD object."); + } + return value as Record; +} diff --git a/packages/graphql/src/resource.test.ts b/packages/graphql/src/resource.test.ts new file mode 100644 index 0000000..ef3c8f2 --- /dev/null +++ b/packages/graphql/src/resource.test.ts @@ -0,0 +1,606 @@ +// 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 . +// Pagination requests depend on the preceding cursor. +// oxlint-disable no-await-in-loop +import assert from "node:assert/strict"; +import { it } from "node:test"; + +import { + type Database, + promoteResource, + schema, + storeAddressing, +} from "@drfed/models"; +import { PUBLIC_IRI } from "@drfed/models/resource"; +import { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid"; +import { eq } from "drizzle-orm"; + +import { type TestHarness, withTestHarness } from "./harness.test.ts"; +import { + globalId, + localActorId, + remoteActorId, + seedAuthenticatedLocalInstance, + seedLocalActor, + seedObjects, + seedRemoteActor, +} from "./seed.test.ts"; + +it("separates a collection's declared total from its visible member count", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const collectionReference = + await db.query.actorCollectionReferences.findFirst({ + where: { actorId: remoteActorId, role: "featured" }, + with: { collection: true }, + }); + assert.ok(collectionReference); + const { collection } = collectionReference; + await db + .update(schema.collections) + .set({ totalItems: 42 }) + .where(eq(schema.collections.id, collection.id)); + await db.insert(schema.collectionItems).values({ + collectionId: collection.id, + itemId: localActorId, + position: 0, + }); + const body = await ( + await post({ + query: `query($id: ID!) { node(id: $id) { ... on Collection { declaredTotalItems totalCount } } }`, + variables: { id: globalId("Collection", collection.id) }, + }) + ).json(); + assert.deepEqual(body, { + data: { node: { declaredTotalItems: 42, totalCount: 1 } }, + }); + }); +}); + +for (const deleted of ["actor", "object"] as const) { + it(`hides a deleted ${deleted} through resource targets, collections and activities`, async () => { + // oxlint-disable-next-line max-statements + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const hiddenId = uuid(); + const liveId = uuid(); + const hiddenIri = `https://test.example/${hiddenId}`; + const actorIri = `https://test-instance.drfed.org/users/${localActorId}`; + const followersIri = `${actorIri}/followers`; + await seedObjects(db, [ + { + id: hiddenId, + actorId: localActorId, + type: "Note", + iri: hiddenIri, + contentHtml: "hidden", + }, + { + id: liveId, + actorId: remoteActorId, + type: "Note", + iri: `https://test.example/${liveId}`, + contentHtml: "live", + addressing: { to: [actorIri, hiddenIri, PUBLIC_IRI, followersIri] }, + }, + ]); + const activity = await db.query.activities.findFirst({ + where: { objectId: hiddenId }, + }); + const collectionReference = + await db.query.actorCollectionReferences.findFirst({ + where: { actorId: localActorId, role: "featured" }, + with: { collection: true }, + }); + const remoteCollectionReference = + await db.query.actorCollectionReferences.findFirst({ + where: { actorId: remoteActorId, role: "featured" }, + with: { collection: true }, + }); + assert.ok(collectionReference); + assert.ok(remoteCollectionReference); + const { collection } = collectionReference; + const { collection: remoteCollection } = remoteCollectionReference; + const followers = await db.query.resources.findFirst({ + where: { iri: followersIri }, + }); + assert.ok(activity); + assert.ok(followers); + await db.insert(schema.collectionItems).values( + [localActorId, hiddenId, liveId, activity.id].map( + (itemId, position) => ({ + collectionId: collection.id, + itemId, + position, + }), + ), + ); + // The live remote actor's collection lists the local actor's followers + // collection, which must disappear along with the local actor. + await db.insert(schema.collectionItems).values({ + collectionId: remoteCollection.id, + itemId: followers.id, + position: 0, + }); + if (deleted === "actor") { + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + } else { + await db + .update(schema.objects) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.objects.id, hiddenId)); + } + const body = await ( + await post({ + query: `query($live: ID!, $activity: ID!, $collection: ID!, $remote: ID!) { + live: node(id: $live) { ... on Object { to { iri detail { ... on Actor { iri objects { totalCount } } ... on Collection { iri } } } } } + activity: node(id: $activity) { ... on Activity { actor { uuid } object { iri detail { __typename } } } } + collection: node(id: $collection) { ... on Collection { owner { uuid } totalCount } } + collections: nodes(ids: [$collection]) { ... on Collection { totalCount } } + remote: node(id: $remote) { ... on Collection { totalCount items { edges { node { iri } } } } } + }`, + variables: { + live: globalId("Object", liveId), + activity: globalId("Activity", activity.id), + collection: globalId("Collection", collection.id), + remote: globalId("Collection", remoteCollection.id), + }, + }) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.live.to.length, 4); + assert.equal(body.data.live.to[1].iri, hiddenIri); + assert.equal(body.data.live.to[1].detail, null); + assert.equal(body.data.live.to[2].iri, PUBLIC_IRI); + assert.deepEqual( + body.data.activity, + deleted === "actor" + ? null + : { + actor: { uuid: localActorId }, + object: { iri: hiddenIri, detail: null }, + }, + ); + // A deleted actor hides its collections everywhere, like the actor + // node itself: node, nodes, and addressing targets. + assert.deepEqual( + body.data.collection, + deleted === "actor" + ? null + : { owner: { uuid: localActorId }, totalCount: 3 }, + ); + assert.deepEqual( + body.data.collections, + deleted === "actor" ? [null] : [{ totalCount: 3 }], + ); + assert.deepEqual( + body.data.live.to[3].detail, + deleted === "actor" ? null : { iri: followersIri }, + ); + assert.deepEqual( + body.data.remote, + deleted === "actor" + ? { totalCount: 0, items: { edges: [] } } + : { + totalCount: 1, + items: { edges: [{ node: { iri: followersIri } }] }, + }, + ); + if (deleted === "actor") { + assert.equal(body.data.live.to[0].detail, null); + return; + } + const seen: string[] = []; + let after: string | null = null; + for (let page = 0; page < 4; page += 1) { + const result: { + errors?: unknown; + data: { + node: { + items: { + edges: { cursor: string; node: { iri: string } }[]; + pageInfo: { hasNextPage: boolean }; + }; + }; + }; + } = await ( + await post({ + query: `query($id: ID!, $after: String) { node(id: $id) { ... on Collection { items(first: 1, after: $after) { edges { cursor node { iri } } pageInfo { hasNextPage } } } } }`, + variables: { id: globalId("Collection", collection.id), after }, + }) + ).json(); + assert.equal(result.errors, undefined); + const connection = result.data.node.items; + assert.equal(connection.edges.length, 1); + const edge = connection.edges[0]; + assert.ok(edge); + seen.push(edge.node.iri); + if (!connection.pageInfo.hasNextPage) break; + after = edge.cursor; + } + assert.equal(seen.length, 3); + assert.ok(!seen.includes(hiddenIri)); + }); + }); +} + +it("hides Create relations authored by a deleted actor", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const id = uuid(); + await seedObjects(db, { + id, + actorId: remoteActorId, + type: "Note", + iri: `https://test.example/${id}`, + contentHtml: "live", + }); + await db.update(schema.activities).set({ actorId: localActorId }); + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + const body = await ( + await post({ + query: `query($id: ID!) { node(id: $id) { ... on Object { uuid activities(type: Create) { edges { node { actor { uuid } } } } } } }`, + variables: { id: globalId("Object", id) }, + }) + ).json(); + assert.deepEqual(body, { + data: { node: { uuid: id, activities: { edges: [] } } }, + }); + }); +}); + +it("preserves resource identity through promotion and typed-node refetch", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + const id = uuid(); + const iri = "https://remote.example/unresolved"; + await seedObjects(db, { + id, + actorId: localActorId, + type: "Note", + iri: `https://test.example/${id}`, + contentHtml: "reference", + addressing: { to: [iri] }, + }); + const query = `query($id: ID!) { node(id: $id) { ... on Object { to { id iri kind detail { __typename } } } } }`; + const before = await ( + await post({ query, variables: { id: globalId("Object", id) } }) + ).json(); + assert.equal(before.errors, undefined); + const resource = before.data.node.to[0]; + assert.deepEqual(resource, { + id: resource.id, + iri, + kind: "unknown", + detail: null, + }); + await promoteResource(db, iri, "collection", async (tx, row) => { + await tx + .insert(schema.collections) + .values({ id: row.id, type: "OrderedCollection" }); + }); + const refetched = await ( + await post({ + query: `query($id: ID!) { node(id: $id) { id ... on Resource { kind detail { ... on Collection { type resource { id } } } } } }`, + variables: { id: resource.id }, + }) + ).json(); + assert.deepEqual(refetched, { + data: { + node: { + id: resource.id, + kind: "collection", + detail: { type: "OrderedCollection", resource: { id: resource.id } }, + }, + }, + }); + const after = await ( + await post({ query, variables: { id: globalId("Object", id) } }) + ).json(); + assert.deepEqual(after, { + data: { + node: { + to: [ + { + ...resource, + kind: "collection", + detail: { __typename: "Collection" }, + }, + ], + }, + }, + }); + }); +}); + +it("lists every referencing activity and classifies the explicitly selected activity", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + const id = uuid(); + const firstId = uuid(); + const secondId = uuid(); + const followers = `https://test-instance.drfed.org/users/${localActorId}/followers`; + await seedObjects(db, { + id, + activityId: firstId, + actorId: localActorId, + type: "Note", + iri: `https://test.example/${id}`, + contentHtml: "no addressing", + addressing: {}, + published: Temporal.Instant.from("2026-01-01T00:00:00Z"), + }); + await db.transaction(async (tx) => { + await storeAddressing(tx, firstId, { to: [PUBLIC_IRI] }); + }); + await promoteResource( + db, + `https://test.example/${secondId}`, + "activity", + async (tx, resource) => { + await tx.insert(schema.activities).values({ + id: resource.id, + actorId: localActorId, + objectId: id, + type: "Create", + published: Temporal.Instant.from("2026-01-02T00:00:00Z"), + }); + await storeAddressing(tx, resource.id, { to: [followers] }); + }, + secondId, + ); + const body = await ( + await post({ + query: `query($id: ID!) { node(id: $id) { ... on Object { activities(type: Create) { edges { node { id expectedClassifications { implementation classification } } } } all: activities { edges { node { id } } } } } }`, + variables: { id: globalId("Object", id) }, + }) + ).json(); + assert.deepEqual(body, { + data: { + node: { + activities: { + edges: [ + { + node: { + id: globalId("Activity", firstId), + expectedClassifications: [ + { implementation: "MASTODON", classification: "public" }, + { implementation: "MISSKEY", classification: "specified" }, + ], + }, + }, + { + node: { + id: globalId("Activity", secondId), + expectedClassifications: [ + { implementation: "MASTODON", classification: "private" }, + { implementation: "MISSKEY", classification: "specified" }, + ], + }, + }, + ], + }, + all: { + edges: [ + { node: { id: globalId("Activity", firstId) } }, + { node: { id: globalId("Activity", secondId) } }, + ], + }, + }, + }, + }); + }); +}); + +interface ItemsPage { + edges: { cursor: string; node: { iri: string } }[]; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + }; +} +type Post = TestHarness["post"]; +const itemsQuery = `query($id: ID!, $first: Int, $after: String, $last: Int, $before: String) { + node(id: $id) { + ... on Collection { + items(first: $first, after: $after, last: $last, before: $before) { + edges { cursor node { iri } } + pageInfo { hasNextPage hasPreviousPage startCursor } + } + } + } +}`; +const createNote = `mutation($actor: ID!, $contentHtml: String!, $addressing: AddressingInput!) { + createObject(actor: $actor, contentHtml: $contentHtml, addressing: $addressing) { + ... on Object { uuid } + } +}`; +const iris = (page: ItemsPage): string[] => + page.edges.map((edge) => edge.node.iri); +const objectIri = (id: string): string => `https://test.example/${id}`; + +async function fetchItems( + post: Post, + collectionId: Uuid, + args: { + first?: number; + after?: string | undefined; + last?: number; + before?: string; + }, +): Promise { + const body = await ( + await post({ + query: itemsQuery, + variables: { id: globalId("Collection", collectionId), ...args }, + }) + ).json(); + assert.equal(body.errors, undefined); + return body.data.node.items; +} + +/** + * Walks `items(first: 1)` to the end, running `between` after the first page. + * @returns The IRIs of every returned item, in order. + */ +async function walkForward( + post: Post, + collectionId: Uuid, + between: () => Promise, +): Promise { + const seen: string[] = []; + let after: string | undefined; + for (let page = 0; page < 10; page += 1) { + const items = await fetchItems(post, collectionId, { first: 1, after }); + seen.push(...iris(items)); + if (page === 0) await between(); + if (!items.pageInfo.hasNextPage) break; + after = items.edges.at(-1)?.cursor; + } + return seen; +} + +async function collectionIdOf( + db: Database, + role: "outbox" | "featured", +): Promise { + const reference = await db.query.actorCollectionReferences.findFirst({ + where: { actorId: localActorId, role }, + }); + assert.ok(reference); + return reference.collectionId; +} + +/** + * Seeds live local objects. + * @returns The object ids in ascending order. + */ +async function seedItems(db: Database, count: number): Promise { + const ids = Array.from({ length: count }, () => uuid()).toSorted(); + await seedObjects( + db, + ids.map((id) => ({ + id, + actorId: localActorId, + type: "Note" as const, + iri: objectIri(id), + contentHtml: "item", + })), + ); + return ids; +} + +it("pages an outbox without duplicates or omissions when a post is created between requests", async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const create = async (contentHtml: string): Promise => { + const body = await ( + await post( + { + query: createNote, + variables: { + actor: globalId("Actor", localActorId), + contentHtml, + addressing: { to: [PUBLIC_IRI] }, + }, + }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + const activity = await db.query.activities.findFirst({ + where: { objectId: body.data.createObject.uuid }, + with: { resource: true }, + }); + assert.ok(activity); + return activity.resource.iri; + }; + const first = await create("A"); + const second = await create("B"); + const seen = await walkForward( + post, + await collectionIdOf(db, "outbox"), + async () => { + await create("C"); + }, + ); + assert.deepEqual(seen, [second, first]); + }); +}); + +it("pages past null positions when an item is prepended between requests", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + const collectionId = await collectionIdOf(db, "featured"); + const [positioned, firstNull, secondNull, prepended] = await seedItems( + db, + 4, + ); + assert.ok(positioned && firstNull && secondNull && prepended); + await db.insert(schema.collectionItems).values([ + { collectionId, itemId: secondNull, position: null }, + { collectionId, itemId: positioned, position: 0 }, + { collectionId, itemId: firstNull, position: null }, + ]); + const seen = await walkForward(post, collectionId, async () => { + await db + .insert(schema.collectionItems) + .values({ collectionId, itemId: prepended, position: -1 }); + }); + assert.deepEqual(seen, [positioned, firstNull, secondNull].map(objectIri)); + }); +}); + +it("pages a collection backward in the same edge order as forward", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + const collectionId = await collectionIdOf(db, "featured"); + const ids = await seedItems(db, 3); + await db.insert(schema.collectionItems).values( + ids.map((itemId, index) => ({ + collectionId, + itemId, + position: index === 2 ? null : index, + })), + ); + const expected = ids.map(objectIri); + assert.deepEqual( + iris(await fetchItems(post, collectionId, { first: 10 })), + expected, + ); + const tail = await fetchItems(post, collectionId, { last: 2 }); + assert.deepEqual(iris(tail), expected.slice(1)); + assert.equal(tail.pageInfo.hasPreviousPage, true); + assert.ok(tail.pageInfo.startCursor); + const head = await fetchItems(post, collectionId, { + last: 2, + before: tail.pageInfo.startCursor, + }); + assert.deepEqual(iris(head), expected.slice(0, 1)); + assert.equal(head.pageInfo.hasPreviousPage, false); + }); +}); diff --git a/packages/graphql/src/resource.ts b/packages/graphql/src/resource.ts new file mode 100644 index 0000000..b73a89c --- /dev/null +++ b/packages/graphql/src/resource.ts @@ -0,0 +1,412 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { type Database, schema } from "@drfed/models"; +import type { Resource as ResourceRow } from "@drfed/models/schema"; +import { type Uuid, validateUuid } from "@drfed/models/uuid"; +import { PothosValidationError } from "@pothos/core"; +import { resolveCursorConnection } from "@pothos/plugin-relay"; +import { type SQL, type SQLWrapper, and, eq, sql } from "drizzle-orm"; + +import builder, { type DrFedObjectRef } from "./builder.ts"; +import { + classificationAuthor, + classificationInput, + classifyMastodon, + classifyMisskey, +} from "./classification.ts"; +import { activitySelection, objectSelection } from "./federation.ts"; + +export const ResourceKind = builder.enumType("ResourceKind", { + values: schema.resourceKindEnum.enumValues, +}); +export const ResourceDetail = builder.unionType("ResourceDetail", { + types: () => [Activity, Collection], + resolveType: async (value, ctx) => { + const { id } = value as { id: Uuid }; + const row = await ctx.db.query.resources.findFirst({ where: { id } }); + if (row == null || row.kind === "unknown") { + throw new Error("Missing typed resource."); + } + return ( + { + actor: "Actor", + object: "Object", + activity: "Activity", + collection: "Collection", + } as const + )[row.kind]; + }, +}); +const ResourceRef = builder.drizzleNode("resources", { + name: "Resource", + id: { column: (row) => row.id }, + fields: (t) => ({ + iri: t.expose("iri", { type: "URL" }), + kind: t.expose("kind", { type: ResourceKind }), + detail: t.field({ + type: ResourceDetail, + nullable: true, + description: + "Typed details, or null while unknown or when the typed row or its author/owner is deleted.", + select: { columns: { id: true, iri: true, kind: true } }, + resolve: (row, _, ctx) => resolveResource(ctx.db, row), + }), + }), +}); +export const Resource: DrFedObjectRef = ResourceRef; + +/** + * Loads the typed record so union fragments see the complete entity. + * @returns The typed entity, or null when it or its author is deleted. + */ +export async function resolveResource( + db: Database, + row: ResourceRow, +): Promise<{ id: Uuid } | null> { + const visible = await db.query.resources.findFirst({ + where: { id: row.id, RAW: (table) => visibleResource(table.id) }, + columns: { id: true }, + }); + if (visible == null) return null; + if (row.kind === "unknown") return null; + const where = { id: row.id }; + const result = + row.kind === "actor" + ? await db.query.actors.findFirst({ where }) + : row.kind === "object" + ? await db.query.objects.findFirst({ where }) + : row.kind === "activity" + ? await db.query.activities.findFirst({ where }) + : await db.query.collections.findFirst({ where }); + if (result == null) { + throw new Error(`Missing ${row.kind} record for ${row.iri}.`); + } + return result; +} + +export function registerAddressingFields( + table: "objects" | "activities", +): void { + for (const property of schema.addressingPropertyEnum.enumValues) { + builder.drizzleObjectField(table, property, (t) => + t.field({ + type: [Resource], + description: `Stored ${property} occurrences, in original order including duplicates.`, + select: { columns: { id: true } }, + resolve: async (row, _, ctx) => + ( + await ctx.db.query.addressing.findMany({ + where: { sourceId: row.id, property }, + orderBy: { position: "asc" }, + with: { targetResource: true }, + }) + ).map((entry) => entry.targetResource), + }), + ); + } +} + +const CollectionType = builder.enumType("CollectionType", { + values: schema.collectionTypeEnum.enumValues, +}); +const CollectionRef = builder.drizzleNode("collections", { + name: "Collection", + select: { + columns: { id: true }, + with: { ownerActor: { columns: { deleted: true } } }, + }, + id: { column: (row) => row.id }, + fields: (t) => ({ + resource: t.relation("resource"), + iri: t.field({ + type: "URL", + select: { with: { resource: true } }, + resolve: (row) => row.resource.iri, + }), + type: t.expose("type", { type: CollectionType }), + owner: t.relation("ownerActor", { + nullable: true, + query: { where: { deleted: { isNull: true } } }, + }), + declaredTotalItems: t.exposeInt("totalItems", { + nullable: true, + description: + "The `totalItems` reported by the collection document. It can differ from the locally observed count and is null for local collections. Unlike `totalCount` and `items`, this value is not recalculated when deleted actors, deleted objects, or resources authored by deleted actors are excluded.", + }), + totalCount: t.int({ + description: + "The number of locally stored, visible members. It can differ from `declaredTotalItems`. Deleted actors, deleted objects, and resources authored by deleted actors are excluded from this count and `items`.", + select: { columns: { id: true } }, + resolve: (row, _, ctx) => + ctx.db.$count( + schema.collectionItems, + and( + eq(schema.collectionItems.collectionId, row.id), + visibleResource(schema.collectionItems.itemId), + ), + ), + }), + items: t.connection({ + type: Resource, + description: + "The locally stored, visible members. Deleted actors, deleted objects, and resources authored by deleted actors are excluded from this connection and `totalCount`.", + select: { columns: { id: true } }, + resolve: (row, args, ctx) => { + const positions = new Map(); + return resolveCursorConnection>( + { + args, + toCursor: (item: ResourceRow) => { + if (!positions.has(item.id)) { + throw new Error("Missing collection item position."); + } + return encodeCollectionCursor({ + position: positions.get(item.id)!, + itemId: item.id, + }); + }, + }, + async ({ before, after, limit, inverted }) => { + const parsedBefore = + before == null ? undefined : parseCollectionCursor(before); + const parsedAfter = + after == null ? undefined : parseCollectionCursor(after); + const items = await ctx.db.query.collectionItems.findMany({ + where: { + collectionId: row.id, + RAW: (table) => + and( + visibleResource(table.itemId), + collectionCursorPredicate( + table.position, + table.itemId, + parsedBefore, + parsedAfter, + ), + )!, + }, + orderBy: collectionItemsOrder(inverted), + limit, + with: { item: true }, + }); + for (const item of items) { + positions.set(item.itemId, item.position); + } + return items.map((item) => item.item); + }, + ); + }, + }), + }), +}); +export const Collection: DrFedObjectRef = CollectionRef; + +interface CollectionCursor { + position: number | null; + itemId: Uuid; +} + +const BASE64_PATTERN = + /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/u; + +function encodeCollectionCursor(cursor: CollectionCursor): string { + return Buffer.from(JSON.stringify([cursor.position, cursor.itemId])).toString( + "base64", + ); +} + +function parseCollectionCursor(cursor: string): CollectionCursor { + if (cursor === "" || !BASE64_PATTERN.test(cursor)) { + throw new PothosValidationError("Invalid collection cursor."); + } + const decoded = Buffer.from(cursor, "base64"); + if (decoded.toString("base64") !== cursor) { + throw new PothosValidationError("Invalid collection cursor."); + } + let value: unknown; + try { + value = JSON.parse(decoded.toString("utf8")); + } catch { + throw new PothosValidationError("Invalid collection cursor."); + } + if (!Array.isArray(value) || value.length !== 2) { + throw new PothosValidationError("Invalid collection cursor."); + } + const [position, itemId] = value; + if ( + (position !== null && + (!Number.isInteger(position) || + position < -2_147_483_648 || + position > 2_147_483_647)) || + !validateUuid(itemId) + ) { + throw new PothosValidationError("Invalid collection cursor."); + } + return { position, itemId }; +} + +function collectionCursorPredicate( + positionColumn: SQLWrapper, + itemIdColumn: SQLWrapper, + before?: CollectionCursor, + after?: CollectionCursor, +): SQL | undefined { + return and( + after == null + ? undefined + : after.position == null + ? sql`${positionColumn} is null and ${itemIdColumn} > ${after.itemId}` + : sql`( + ${positionColumn} > ${after.position} + or (${positionColumn} = ${after.position} and ${itemIdColumn} > ${after.itemId}) + or ${positionColumn} is null + )`, + before == null + ? undefined + : before.position == null + ? sql`( + ${positionColumn} is not null + or (${positionColumn} is null and ${itemIdColumn} < ${before.itemId}) + )` + : sql`( + ${positionColumn} < ${before.position} + or (${positionColumn} = ${before.position} and ${itemIdColumn} < ${before.itemId}) + )`, + ); +} + +function collectionItemsOrder(inverted: boolean): { + position: "asc" | "desc"; + itemId: "asc" | "desc"; +} { + // The keyset predicates rely on PostgreSQL's default ASC NULLS LAST and + // DESC NULLS FIRST ordering. + const direction = inverted ? "desc" : "asc"; + return { position: direction, itemId: direction }; +} + +const Implementation = builder.enumType("Implementation", { + values: ["MASTODON", "MISSKEY"] as const, +}); +const ExpectedClassification = builder.objectRef< + ReturnType +>("ExpectedClassification"); +ExpectedClassification.implement({ + description: + "Expected classification; actual access depends on receiver state and policy.", + fields: (t) => ({ + implementation: t.expose("implementation", { type: Implementation }), + version: t.exposeString("version"), + classification: t.exposeString("classification"), + reason: t.exposeString("reason"), + }), +}); +export const ActivityType = builder.enumType("ActivityType", { + values: schema.activityTypeEnum.enumValues, +}); +const ActivityRef = builder.drizzleNode("activities", { + name: "Activity", + select: { + columns: { id: true }, + with: { actor: { columns: { deleted: true } } }, + }, + id: { column: (row) => row.id }, + fields: (t) => ({ + resource: t.relation("resource"), + iri: t.field({ + type: "URL", + select: { with: { resource: true } }, + resolve: (row) => row.resource.iri, + }), + type: t.expose("type", { type: ActivityType }), + actor: t.relation("actor"), + object: t.field({ + type: Resource, + nullable: true, + select: { with: { object: true } }, + resolve: (row) => row.object, + }), + expectedClassifications: t.field({ + type: [ExpectedClassification], + description: + "Expected classifications for this activity and its Object. Empty if the object is absent, hidden, or not an Object.", + select: { columns: { id: true } }, + resolve: async (activity, _, ctx) => { + const row = await ctx.db.query.activities.findFirst({ + where: { id: activity.id }, + with: activitySelection, + }); + if (row?.objectId == null) return []; + const object = await ctx.db.query.objects.findFirst({ + where: { + id: row.objectId, + deleted: { isNull: true }, + actor: { deleted: { isNull: true } }, + }, + with: { + ...objectSelection, + actor: { + with: { + resource: true, + collectionReferences: { + with: { collection: { with: { resource: true } } }, + }, + }, + }, + }, + }); + if (object == null) return []; + const addressing = classificationInput(object); + const input = classificationInput(row); + const author = classificationAuthor(object.actor); + return [ + classifyMastodon(addressing, input, author), + classifyMisskey(addressing, input, author), + ]; + }, + }), + published: t.expose("published", { type: "DateTime" }), + document: t.expose("document", { type: "JSON", nullable: true }), + }), +}); +export const Activity: DrFedObjectRef = ActivityRef; +registerAddressingFields("activities"); + +/** + * Excludes deleted actors and objects, including resources authored or + * owned by deleted actors. + * @returns A predicate for a resource ID in an outer query. + */ +function visibleResource(id: SQLWrapper): SQL { + return sql`not exists ( + select 1 from ${schema.actors} + where ${schema.actors.id} = ${id} and ${schema.actors.deleted} is not null + ) and not exists ( + select 1 from ${schema.objects} + join ${schema.actors} on ${schema.actors.id} = ${schema.objects.actorId} + where ${schema.objects.id} = ${id} + and (${schema.objects.deleted} is not null or ${schema.actors.deleted} is not null) + ) and not exists ( + select 1 from ${schema.activities} + join ${schema.actors} on ${schema.actors.id} = ${schema.activities.actorId} + where ${schema.activities.id} = ${id} and ${schema.actors.deleted} is not null + ) and not exists ( + select 1 from ${schema.collections} + join ${schema.actors} on ${schema.actors.id} = ${schema.collections.ownerActorId} + where ${schema.collections.id} = ${id} and ${schema.actors.deleted} is not null + )`; +} diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts index f5af295..7dc7463 100644 --- a/packages/graphql/src/schema.ts +++ b/packages/graphql/src/schema.ts @@ -18,6 +18,7 @@ import "./account.ts"; import "./instance.ts"; import "./auth/entry.ts"; import "./actor.ts"; +import "./object.ts"; import builder from "./builder.ts"; builder.queryType({}); diff --git a/packages/graphql/src/seed.test.ts b/packages/graphql/src/seed.test.ts new file mode 100644 index 0000000..adc34a3 --- /dev/null +++ b/packages/graphql/src/seed.test.ts @@ -0,0 +1,255 @@ +// 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 . + +// Keep dependent database writes and observations sequential. +// oxlint-disable no-await-in-loop + +import { + type Database, + addActorCollectionItem, + promoteResource, + schema, + storeAddressing, +} from "@drfed/models"; +import { type AddressingInput, PUBLIC_IRI } from "@drfed/models/resource"; +import { type Uuid, uuidV7 } from "@drfed/models/uuid"; +import type { PgInsertValue } from "drizzle-orm/pg-core"; + +import { hashSecret } from "./auth/hash.ts"; + +export const accepted = Temporal.Instant.from("2026-08-04T00:00:00.000Z"); +export const created = Temporal.Instant.from("2026-08-04T00:00:00.000Z"); +export const expires = Temporal.Instant.from("2030-08-04T00:00:00.000Z"); +export const ok = 200; + +export const accountId = "00000000-0000-4000-8000-000000000001"; +export const localInstanceId = "00000000-0000-4000-8000-000000000101"; +export const remoteInstanceId = "00000000-0000-4000-8000-000000000102"; +export const localActorId = "00000000-0000-4000-8000-000000000201" as const; +export const remoteActorId = "00000000-0000-4000-8000-000000000202" as const; +export const sessionId = "00000000-0000-4000-8000-000000000301"; +export const accessToken = "test-access-token"; + +export function globalId( + type: + | "Actor" + | "LocalActor" + | "Instance" + | "Object" + | "Activity" + | "Collection", + id: string, +): string { + return Buffer.from(`${type}:${id}`).toString("base64"); +} + +export async function seedAuthenticatedLocalInstance( + db: Database, +): Promise { + await db.insert(schema.accounts).values({ + id: accountId, + email: "owner@example.com", + name: "Owner", + created, + }); + await db.insert(schema.sessions).values({ + id: sessionId, + accountId, + tokenHash: await hashSecret(accessToken), + }); + await seedLocalInstance(db); + await db.insert(schema.instanceMembers).values({ + accountId, + instanceId: localInstanceId, + admin: true, + accepted, + created, + }); + return { headers: { authorization: `Bearer ${accessToken}` } }; +} + +export async function seedLocalActor(db: Database): Promise { + await seedLocalInstance(db); + await db.insert(schema.localActors).values({ + id: localActorId, + avatar: "avatar.png", + header: "header.png", + }); + await seedActors(db, { + id: localActorId, + localId: localActorId, + instanceId: localInstanceId, + type: "Person", + username: "alice", + iri: `https://test-instance.drfed.org/users/${localActorId}`, + inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`, + avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`, + headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`, + profileUrl: "https://test-instance.drfed.org/@alice", + created, + }); +} + +export async function seedLocalInstance( + db: Database, + host = "test-instance.drfed.org", +): Promise { + await db + .insert(schema.localInstances) + .values({ + id: localInstanceId, + slug: "test-instance", + expires, + }) + .onConflictDoNothing(); + await db + .insert(schema.instances) + .values({ + id: localInstanceId, + localId: localInstanceId, + created, + host, + }) + .onConflictDoNothing(); +} + +export async function seedRemoteActor(db: Database): Promise { + await db.insert(schema.instances).values({ + id: remoteInstanceId, + created, + host: "remote.example.com", + }); + await seedActors(db, { + id: remoteActorId, + instanceId: remoteInstanceId, + type: "Service", + username: "bob", + iri: "https://remote.example.com/users/bob", + inboxUrl: "https://remote.example.com/users/bob/inbox", + avatarUrl: "https://remote.example.com/users/bob/avatar.png", + headerUrl: "https://remote.example.com/users/bob/header.png", + profileUrl: "https://remote.example.com/@bob", + created, + }); +} + +type ActorSeed = PgInsertValue & { + id: Uuid; + iri: string; +}; +export async function seedActors( + db: Database, + values: ActorSeed | ActorSeed[], +): Promise { + for (const { iri, ...actor } of Array.isArray(values) ? values : [values]) { + await promoteResource( + db, + iri, + "actor", + async (tx, resource) => { + await tx.insert(schema.actors).values({ ...actor, id: resource.id }); + for (const role of [ + "followers", + "following", + "featured", + "outbox", + ] as const) { + await promoteResource( + tx, + `${iri}/${role}`, + "collection", + async (inner, collection) => { + await inner.insert(schema.collections).values({ + id: collection.id, + type: "OrderedCollection", + ownerActorId: resource.id, + }); + await inner.insert(schema.actorCollectionReferences).values({ + actorId: resource.id, + role, + collectionId: collection.id, + }); + }, + ); + } + }, + actor.id, + ); + } +} +type ObjectSeed = PgInsertValue & { + id: Uuid; + iri: string; + addressing?: AddressingInput; + activityId?: Uuid; +}; +/** Seeds independent Create rows using the production activity ID layout. */ +export async function seedObjects( + db: Database, + values: ObjectSeed | ObjectSeed[], +): Promise { + for (const { + iri, + activityId = uuidV7(), + addressing = { + to: [PUBLIC_IRI], + cc: [`https://test-instance.drfed.org/users/${localActorId}/followers`], + }, + ...object + } of Array.isArray(values) ? values : [values]) { + await promoteResource( + db, + iri, + "object", + async (tx, resource) => { + const [row] = await tx + .insert(schema.objects) + .values({ ...object, id: resource.id }) + .returning(); + if (row == null) throw new Error("Missing seeded object."); + await storeAddressing(tx, resource.id, addressing); + const actor = await tx.query.actors.findFirst({ + where: { id: row.actorId }, + with: { instance: true }, + }); + if (actor == null) throw new Error("Missing seeded actor."); + await promoteResource( + tx, + `https://${actor.instance.host}/ap/creates/${activityId}`, + "activity", + async (inner, activity) => { + await inner.insert(schema.activities).values({ + id: activity.id, + type: "Create", + actorId: row.actorId, + objectId: row.id, + published: row.published, + }); + await storeAddressing(inner, activity.id, addressing); + await addActorCollectionItem( + inner, + row.actorId, + "outbox", + activity.id, + ); + }, + activityId, + ); + }, + object.id, + ); + } +} diff --git a/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/migration.sql b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/migration.sql new file mode 100644 index 0000000..05de2f9 --- /dev/null +++ b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/migration.sql @@ -0,0 +1,110 @@ +CREATE TYPE "activity_type" AS ENUM('Create');--> statement-breakpoint +CREATE TYPE "addressing_property" AS ENUM('to', 'cc', 'bto', 'bcc', 'audience');--> statement-breakpoint +CREATE TYPE "collection_role" AS ENUM('followers', 'following', 'featured', 'outbox');--> statement-breakpoint +CREATE TYPE "collection_type" AS ENUM('Collection', 'OrderedCollection');--> statement-breakpoint +CREATE TYPE "object_type" AS ENUM('Article', 'Note');--> statement-breakpoint +CREATE TYPE "resource_kind" AS ENUM('actor', 'object', 'activity', 'collection', 'unknown');--> statement-breakpoint +CREATE TABLE "activities" ( + "id" uuid PRIMARY KEY, + "type" "activity_type" NOT NULL, + "actorId" uuid NOT NULL, + "objectId" uuid, + "published" timestamp with time zone NOT NULL, + "document" json, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "actor_collection_references" ( + "actorId" uuid, + "role" "collection_role", + "collectionId" uuid NOT NULL, + CONSTRAINT "actor_collection_references_pkey" PRIMARY KEY("actorId","role") +); +--> statement-breakpoint +CREATE TABLE "addressing" ( + "id" uuid PRIMARY KEY, + "sourceId" uuid NOT NULL, + "property" "addressing_property" NOT NULL, + "position" integer NOT NULL, + "targetId" uuid NOT NULL, + CONSTRAINT "addressing_source_property_position_key" UNIQUE("sourceId","property","position") +); +--> statement-breakpoint +CREATE TABLE "collection_items" ( + "collectionId" uuid, + "itemId" uuid, + "position" integer, + "observed" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "collection_items_pkey" PRIMARY KEY("collectionId","itemId") +); +--> statement-breakpoint +CREATE TABLE "collections" ( + "id" uuid PRIMARY KEY, + "type" "collection_type" NOT NULL, + "ownerActorId" uuid, + "totalItems" integer, + "document" json, + "updated" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "objects" ( + "id" uuid PRIMARY KEY, + "actorId" uuid NOT NULL, + "type" "object_type" NOT NULL, + "document" json, + "url" text, + "name" text, + "summary" text, + "contentHtml" text NOT NULL, + "language" varchar(35), + "sensitive" boolean DEFAULT false NOT NULL, + "published" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "deleted" timestamp with time zone, + CONSTRAINT "objects_content_html_check" CHECK (trim(both from "contentHtml") <> '') +); +--> statement-breakpoint +CREATE TABLE "resources" ( + "id" uuid PRIMARY KEY, + "iri" text NOT NULL UNIQUE, + "kind" "resource_kind" NOT NULL, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +-- Register existing actors as resources before their IRI column disappears. +INSERT INTO resources (id, iri, kind, created) SELECT id, iri, 'actor', created FROM actors; +--> statement-breakpoint +ALTER TABLE "actors" DROP CONSTRAINT "actors_iri_key";--> statement-breakpoint +ALTER TABLE "actors" ADD COLUMN "document" json;--> statement-breakpoint +ALTER TABLE "actors" DROP COLUMN "iri";--> statement-breakpoint +ALTER TABLE "actors" DROP COLUMN "outboxUrl";--> statement-breakpoint +ALTER TABLE "actors" DROP COLUMN "followersUrl";--> statement-breakpoint +ALTER TABLE "actors" DROP COLUMN "followingUrl";--> statement-breakpoint +ALTER TABLE "actors" DROP COLUMN "featuredUrl";--> statement-breakpoint +-- Object counts are derived from objects.deleted instead of a stored counter. +ALTER TABLE "actors" DROP COLUMN "postsCount";--> statement-breakpoint +CREATE INDEX "activity_actor_published_index" ON "activities" ("actorId","published" desc,"id" desc);--> statement-breakpoint +CREATE INDEX "actor_collection_reference_collection_index" ON "actor_collection_references" ("collectionId");--> statement-breakpoint +CREATE INDEX "addressing_target_property_index" ON "addressing" ("targetId","property");--> statement-breakpoint +CREATE INDEX "collection_item_position_index" ON "collection_items" ("collectionId","position");--> statement-breakpoint +CREATE INDEX "object_actor_published_index" ON "objects" ("actorId","published" desc,"id" desc);--> statement-breakpoint +ALTER TABLE "activities" ADD CONSTRAINT "activities_id_resources_id_fkey" FOREIGN KEY ("id") REFERENCES "resources"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "activities" ADD CONSTRAINT "activities_actorId_actors_id_fkey" FOREIGN KEY ("actorId") REFERENCES "actors"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "activities" ADD CONSTRAINT "activities_objectId_resources_id_fkey" FOREIGN KEY ("objectId") REFERENCES "resources"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "actor_collection_references" ADD CONSTRAINT "actor_collection_references_actorId_actors_id_fkey" FOREIGN KEY ("actorId") REFERENCES "actors"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "actor_collection_references" ADD CONSTRAINT "actor_collection_references_collectionId_collections_id_fkey" FOREIGN KEY ("collectionId") REFERENCES "collections"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "actors" ADD CONSTRAINT "actors_id_resources_id_fkey" FOREIGN KEY ("id") REFERENCES "resources"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "addressing" ADD CONSTRAINT "addressing_sourceId_resources_id_fkey" FOREIGN KEY ("sourceId") REFERENCES "resources"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "addressing" ADD CONSTRAINT "addressing_targetId_resources_id_fkey" FOREIGN KEY ("targetId") REFERENCES "resources"("id") ON DELETE RESTRICT;--> statement-breakpoint +ALTER TABLE "collection_items" ADD CONSTRAINT "collection_items_collectionId_collections_id_fkey" FOREIGN KEY ("collectionId") REFERENCES "collections"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "collection_items" ADD CONSTRAINT "collection_items_itemId_resources_id_fkey" FOREIGN KEY ("itemId") REFERENCES "resources"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "collections" ADD CONSTRAINT "collections_id_resources_id_fkey" FOREIGN KEY ("id") REFERENCES "resources"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "collections" ADD CONSTRAINT "collections_ownerActorId_actors_id_fkey" FOREIGN KEY ("ownerActorId") REFERENCES "actors"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "objects" ADD CONSTRAINT "objects_id_resources_id_fkey" FOREIGN KEY ("id") REFERENCES "resources"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "objects" ADD CONSTRAINT "objects_actorId_actors_id_fkey" FOREIGN KEY ("actorId") REFERENCES "actors"("id") ON DELETE CASCADE; +--> statement-breakpoint +-- The public addressing collection has a fixed identifier (PUBLIC_RESOURCE_ID). +INSERT INTO resources (id, iri, kind) VALUES ('00000000-0000-4000-8000-000000000000', 'https://www.w3.org/ns/activitystreams#Public', 'collection'); +--> statement-breakpoint +INSERT INTO collections (id, type) VALUES ('00000000-0000-4000-8000-000000000000', 'Collection'); diff --git a/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/snapshot.json b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/snapshot.json new file mode 100644 index 0000000..2a13db0 --- /dev/null +++ b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/snapshot.json @@ -0,0 +1,2263 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "a9c7a317-96db-4a3a-80fa-db8cee5609fe", + "prevIds": ["3d7bb672-489b-4d1e-8efb-e602d21f6e98"], + "ddl": [ + { + "values": ["Create"], + "name": "activity_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Application", "Group", "Organization", "Person", "Service"], + "name": "actor_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["to", "cc", "bto", "bcc", "audience"], + "name": "addressing_property", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["followers", "following", "featured", "outbox"], + "name": "collection_role", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Collection", "OrderedCollection"], + "name": "collection_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Article", "Note"], + "name": "object_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["actor", "object", "activity", "collection", "unknown"], + "name": "resource_kind", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actor_collection_references", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "addressing", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_items", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instance_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "login_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "objects", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "resources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "max_instances", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "activity_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "objectId", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "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": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "collection_role", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collectionId", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "actor_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profileUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headerUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bioHtml", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "automaticallyApprovesFollowers", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fieldHtmls", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "emojis", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspended", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspendedUntil", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "successorId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "(ARRAY[]::text[])", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followingCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followersCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceId", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "addressing_property", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "property", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collectionId", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "observed", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "collection_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ownerActorId", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalItems", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "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": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nodeInfoUrl", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "softwareVersion", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "header", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "varchar(63)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "maxActors", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "char(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "object_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "summary", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentHtml", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "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": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "type": "resource_kind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "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": "resources" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"published\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_actor_published_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "collectionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_collection_reference_collection_index", + "entityType": "indexes", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_instance_index", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "property", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "addressing_target_property_index", + "entityType": "indexes", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "collectionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "collection_item_position_index", + "entityType": "indexes", + "schema": "public", + "table": "collection_items" + }, + { + "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": true, + "columns": [ + { + "value": "actorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"published\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "object_actor_published_index", + "entityType": "indexes", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["objectId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_objectId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actor_collection_references_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": false, + "columns": ["collectionId"], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actor_collection_references_collectionId_collections_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_localId_local_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["successorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "actors_successorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["sourceId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "addressing_sourceId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": false, + "columns": ["targetId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "addressing_targetId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": false, + "columns": ["collectionId"], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_items_collectionId_collections_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_items" + }, + { + "nameExplicit": false, + "columns": ["itemId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_items_itemId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_items" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collections_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collections" + }, + { + "nameExplicit": false, + "columns": ["ownerActorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collections_ownerActorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collections" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "instances_localId_local_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instances" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "login_tokens_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "login_challenges" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "objects_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "objects_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "columns": ["actorId", "role"], + "nameExplicit": false, + "name": "actor_collection_references_pkey", + "entityType": "pks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "columns": ["collectionId", "itemId"], + "nameExplicit": false, + "name": "collection_items_pkey", + "entityType": "pks", + "schema": "public", + "table": "collection_items" + }, + { + "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": "activities_pkey", + "schema": "public", + "table": "activities", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "addressing_pkey", + "schema": "public", + "table": "addressing", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "collections_pkey", + "schema": "public", + "table": "collections", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_actors_pkey", + "schema": "public", + "table": "local_actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_instances_pkey", + "schema": "public", + "table": "local_instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "login_tokens_pkey", + "schema": "public", + "table": "login_challenges", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "objects_pkey", + "schema": "public", + "table": "objects", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "resources_pkey", + "schema": "public", + "table": "resources", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": ["username", "instanceId"], + "nullsNotDistinct": false, + "name": "username_key", + "entityType": "uniques", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": ["sourceId", "property", "position"], + "nullsNotDistinct": false, + "name": "addressing_source_property_position_key", + "entityType": "uniques", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "nullsNotDistinct": false, + "name": "actors_localId_key", + "schema": "public", + "table": "actors", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["host"], + "nullsNotDistinct": false, + "name": "instances_host_key", + "schema": "public", + "table": "instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["slug"], + "nullsNotDistinct": false, + "name": "local_instances_slug_key", + "schema": "public", + "table": "local_instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["iri"], + "nullsNotDistinct": false, + "name": "resources_iri_key", + "schema": "public", + "table": "resources", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "sessions_tokenHash_key", + "schema": "public", + "table": "sessions", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"max_instances\" >= 0", + "name": "accounts_max_instances_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "trim(both from \"name\") <> ''", + "name": "accounts_name_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"username\" NOT LIKE '%@%'", + "name": "actors_username_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\n \"suspendedUntil\" IS NULL OR (\n \"suspended\" IS NOT NULL AND\n \"suspendedUntil\" > \"suspended\"\n )\n ", + "name": "actors_suspended_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\"slug\" ~ '^[a-z0-9-]{4,63}$'", + "name": "instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "\"maxActors\" > 0", + "name": "instances_max_actors_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "trim(both from \"contentHtml\") <> ''", + "name": "objects_content_html_check", + "entityType": "checks", + "schema": "public", + "table": "objects" + } + ], + "renames": [] +} diff --git a/packages/models/drizzle/20260917163517_order_local_outbox_items/migration.sql b/packages/models/drizzle/20260917163517_order_local_outbox_items/migration.sql new file mode 100644 index 0000000..e939e6b --- /dev/null +++ b/packages/models/drizzle/20260917163517_order_local_outbox_items/migration.sql @@ -0,0 +1,22 @@ +-- Rebuild local outbox positions using the same chronology as ActivityPub. +-- Include already positioned rows so mixed old/new data has one ordering. +WITH ranked AS ( + SELECT ci."collectionId", ci."itemId", + -row_number() OVER ( + PARTITION BY ci."collectionId" + ORDER BY a.published ASC, a.id ASC + ) AS position + FROM collection_items ci + JOIN activities a ON a.id = ci."itemId" + WHERE EXISTS ( + SELECT 1 FROM actor_collection_references ref + JOIN actors owner ON owner.id = ref."actorId" + WHERE ref."collectionId" = ci."collectionId" + AND ref.role = 'outbox' AND owner."localId" IS NOT NULL + ) +) +UPDATE collection_items ci +SET position = ranked.position::integer +FROM ranked +WHERE ci."collectionId" = ranked."collectionId" + AND ci."itemId" = ranked."itemId"; diff --git a/packages/models/drizzle/20260917163517_order_local_outbox_items/snapshot.json b/packages/models/drizzle/20260917163517_order_local_outbox_items/snapshot.json new file mode 100644 index 0000000..2d9f2b5 --- /dev/null +++ b/packages/models/drizzle/20260917163517_order_local_outbox_items/snapshot.json @@ -0,0 +1,2263 @@ +{ + "id": "99adfa3b-bf07-4beb-a284-6c9e5e2033e5", + "prevIds": ["a9c7a317-96db-4a3a-80fa-db8cee5609fe"], + "version": "8", + "dialect": "postgres", + "ddl": [ + { + "values": ["Create"], + "name": "activity_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Application", "Group", "Organization", "Person", "Service"], + "name": "actor_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["to", "cc", "bto", "bcc", "audience"], + "name": "addressing_property", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["followers", "following", "featured", "outbox"], + "name": "collection_role", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Collection", "OrderedCollection"], + "name": "collection_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Article", "Note"], + "name": "object_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["actor", "object", "activity", "collection", "unknown"], + "name": "resource_kind", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actor_collection_references", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "addressing", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_items", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instance_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "login_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "objects", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "resources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "max_instances", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "activity_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "objectId", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "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": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "collection_role", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collectionId", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "actor_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profileUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headerUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bioHtml", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "automaticallyApprovesFollowers", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fieldHtmls", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "emojis", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspended", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspendedUntil", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "successorId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "(ARRAY[]::text[])", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followingCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followersCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceId", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "addressing_property", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "property", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collectionId", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "observed", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "collection_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ownerActorId", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalItems", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "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": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nodeInfoUrl", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "softwareVersion", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "header", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "varchar(63)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "maxActors", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "char(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "object_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "summary", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentHtml", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "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": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "type": "resource_kind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "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": "resources" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"published\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_actor_published_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "collectionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_collection_reference_collection_index", + "entityType": "indexes", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_instance_index", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "property", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "addressing_target_property_index", + "entityType": "indexes", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "collectionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "collection_item_position_index", + "entityType": "indexes", + "schema": "public", + "table": "collection_items" + }, + { + "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": true, + "columns": [ + { + "value": "actorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"published\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "object_actor_published_index", + "entityType": "indexes", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["objectId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_objectId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actor_collection_references_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": false, + "columns": ["collectionId"], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actor_collection_references_collectionId_collections_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_localId_local_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["successorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "actors_successorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["sourceId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "addressing_sourceId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": false, + "columns": ["targetId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "addressing_targetId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": false, + "columns": ["collectionId"], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_items_collectionId_collections_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_items" + }, + { + "nameExplicit": false, + "columns": ["itemId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_items_itemId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_items" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collections_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collections" + }, + { + "nameExplicit": false, + "columns": ["ownerActorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collections_ownerActorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collections" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "instances_localId_local_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instances" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "login_tokens_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "login_challenges" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "objects_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "objects_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "columns": ["actorId", "role"], + "nameExplicit": false, + "name": "actor_collection_references_pkey", + "entityType": "pks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "columns": ["collectionId", "itemId"], + "nameExplicit": false, + "name": "collection_items_pkey", + "entityType": "pks", + "schema": "public", + "table": "collection_items" + }, + { + "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": "activities_pkey", + "schema": "public", + "table": "activities", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "addressing_pkey", + "schema": "public", + "table": "addressing", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "collections_pkey", + "schema": "public", + "table": "collections", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_actors_pkey", + "schema": "public", + "table": "local_actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_instances_pkey", + "schema": "public", + "table": "local_instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "login_tokens_pkey", + "schema": "public", + "table": "login_challenges", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "objects_pkey", + "schema": "public", + "table": "objects", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "resources_pkey", + "schema": "public", + "table": "resources", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": ["username", "instanceId"], + "nullsNotDistinct": false, + "name": "username_key", + "entityType": "uniques", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": ["sourceId", "property", "position"], + "nullsNotDistinct": false, + "name": "addressing_source_property_position_key", + "entityType": "uniques", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "nullsNotDistinct": false, + "name": "actors_localId_key", + "schema": "public", + "table": "actors", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["host"], + "nullsNotDistinct": false, + "name": "instances_host_key", + "schema": "public", + "table": "instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["slug"], + "nullsNotDistinct": false, + "name": "local_instances_slug_key", + "schema": "public", + "table": "local_instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["iri"], + "nullsNotDistinct": false, + "name": "resources_iri_key", + "schema": "public", + "table": "resources", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "sessions_tokenHash_key", + "schema": "public", + "table": "sessions", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"max_instances\" >= 0", + "name": "accounts_max_instances_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "trim(both from \"name\") <> ''", + "name": "accounts_name_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"username\" NOT LIKE '%@%'", + "name": "actors_username_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\n \"suspendedUntil\" IS NULL OR (\n \"suspended\" IS NOT NULL AND\n \"suspendedUntil\" > \"suspended\"\n )\n ", + "name": "actors_suspended_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\"slug\" ~ '^[a-z0-9-]{4,63}$'", + "name": "instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "\"maxActors\" > 0", + "name": "instances_max_actors_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "trim(both from \"contentHtml\") <> ''", + "name": "objects_content_html_check", + "entityType": "checks", + "schema": "public", + "table": "objects" + } + ], + "renames": [] +} diff --git a/packages/models/drizzle/20260918071839_add_activity_object_index/migration.sql b/packages/models/drizzle/20260918071839_add_activity_object_index/migration.sql new file mode 100644 index 0000000..e71d299 --- /dev/null +++ b/packages/models/drizzle/20260918071839_add_activity_object_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX "activity_object_published_index" ON "activities" ("objectId","published","id"); \ No newline at end of file diff --git a/packages/models/drizzle/20260918071839_add_activity_object_index/snapshot.json b/packages/models/drizzle/20260918071839_add_activity_object_index/snapshot.json new file mode 100644 index 0000000..3512fd1 --- /dev/null +++ b/packages/models/drizzle/20260918071839_add_activity_object_index/snapshot.json @@ -0,0 +1,2298 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "478b925d-8ac8-466c-9804-bb055fdd6489", + "prevIds": ["99adfa3b-bf07-4beb-a284-6c9e5e2033e5"], + "ddl": [ + { + "values": ["Create"], + "name": "activity_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Application", "Group", "Organization", "Person", "Service"], + "name": "actor_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["to", "cc", "bto", "bcc", "audience"], + "name": "addressing_property", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["followers", "following", "featured", "outbox"], + "name": "collection_role", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Collection", "OrderedCollection"], + "name": "collection_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Article", "Note"], + "name": "object_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["actor", "object", "activity", "collection", "unknown"], + "name": "resource_kind", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actor_collection_references", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "addressing", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_items", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instance_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "login_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "objects", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "resources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "max_instances", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "activity_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "objectId", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "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": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "collection_role", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collectionId", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "actor_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profileUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headerUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bioHtml", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "automaticallyApprovesFollowers", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fieldHtmls", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "emojis", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspended", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspendedUntil", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "successorId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "(ARRAY[]::text[])", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followingCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followersCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceId", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "addressing_property", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "property", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collectionId", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "observed", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "collection_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ownerActorId", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalItems", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "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": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nodeInfoUrl", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "softwareVersion", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "header", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "varchar(63)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "maxActors", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "char(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "object_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "summary", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentHtml", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "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": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "type": "resource_kind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "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": "resources" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"published\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_actor_published_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "objectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "published", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_object_published_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "collectionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_collection_reference_collection_index", + "entityType": "indexes", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_instance_index", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "property", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "addressing_target_property_index", + "entityType": "indexes", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "collectionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "collection_item_position_index", + "entityType": "indexes", + "schema": "public", + "table": "collection_items" + }, + { + "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": true, + "columns": [ + { + "value": "actorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"published\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "object_actor_published_index", + "entityType": "indexes", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["objectId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_objectId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actor_collection_references_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": false, + "columns": ["collectionId"], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actor_collection_references_collectionId_collections_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_localId_local_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["successorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "actors_successorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["sourceId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "addressing_sourceId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": false, + "columns": ["targetId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "addressing_targetId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": false, + "columns": ["collectionId"], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_items_collectionId_collections_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_items" + }, + { + "nameExplicit": false, + "columns": ["itemId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_items_itemId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_items" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collections_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collections" + }, + { + "nameExplicit": false, + "columns": ["ownerActorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collections_ownerActorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collections" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "instances_localId_local_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instances" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "login_tokens_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "login_challenges" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "objects_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "objects_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "columns": ["actorId", "role"], + "nameExplicit": false, + "name": "actor_collection_references_pkey", + "entityType": "pks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "columns": ["collectionId", "itemId"], + "nameExplicit": false, + "name": "collection_items_pkey", + "entityType": "pks", + "schema": "public", + "table": "collection_items" + }, + { + "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": "activities_pkey", + "schema": "public", + "table": "activities", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "addressing_pkey", + "schema": "public", + "table": "addressing", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "collections_pkey", + "schema": "public", + "table": "collections", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_actors_pkey", + "schema": "public", + "table": "local_actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_instances_pkey", + "schema": "public", + "table": "local_instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "login_tokens_pkey", + "schema": "public", + "table": "login_challenges", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "objects_pkey", + "schema": "public", + "table": "objects", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "resources_pkey", + "schema": "public", + "table": "resources", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": ["username", "instanceId"], + "nullsNotDistinct": false, + "name": "username_key", + "entityType": "uniques", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": ["sourceId", "property", "position"], + "nullsNotDistinct": false, + "name": "addressing_source_property_position_key", + "entityType": "uniques", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "nullsNotDistinct": false, + "name": "actors_localId_key", + "schema": "public", + "table": "actors", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["host"], + "nullsNotDistinct": false, + "name": "instances_host_key", + "schema": "public", + "table": "instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["slug"], + "nullsNotDistinct": false, + "name": "local_instances_slug_key", + "schema": "public", + "table": "local_instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["iri"], + "nullsNotDistinct": false, + "name": "resources_iri_key", + "schema": "public", + "table": "resources", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "sessions_tokenHash_key", + "schema": "public", + "table": "sessions", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"max_instances\" >= 0", + "name": "accounts_max_instances_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "trim(both from \"name\") <> ''", + "name": "accounts_name_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"username\" NOT LIKE '%@%'", + "name": "actors_username_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\n \"suspendedUntil\" IS NULL OR (\n \"suspended\" IS NOT NULL AND\n \"suspendedUntil\" > \"suspended\"\n )\n ", + "name": "actors_suspended_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\"slug\" ~ '^[a-z0-9-]{4,63}$'", + "name": "instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "\"maxActors\" > 0", + "name": "instances_max_actors_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "trim(both from \"contentHtml\") <> ''", + "name": "objects_content_html_check", + "entityType": "checks", + "schema": "public", + "table": "objects" + } + ], + "renames": [] +} diff --git a/packages/models/package.json b/packages/models/package.json index 8bca1a4..44e366b 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -74,6 +74,10 @@ "types": "./dist/login.d.mts", "default": "./dist/login.mjs" }, + "./resource": { + "types": "./dist/resource.d.mts", + "default": "./dist/resource.mjs" + }, "./slug": { "types": "./dist/slug.d.mts", "default": "./dist/slug.mjs" @@ -94,6 +98,7 @@ "src/schema.ts", "src/uuid.ts", "src/login.ts", + "src/resource.ts", "src/slug.ts" ], "dts": { diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 6abed39..a00025b 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -19,3 +19,4 @@ export * from "./migrate.ts"; export { relations } from "./relations.ts"; export * as schema from "./schema.ts"; export * from "./login.ts"; +export * from "./resource.ts"; diff --git a/packages/models/src/login.test.ts b/packages/models/src/login.test.ts index 6e52e4a..edac374 100644 --- a/packages/models/src/login.test.ts +++ b/packages/models/src/login.test.ts @@ -30,8 +30,8 @@ import { drizzle } from "drizzle-orm/pglite"; const accountId = uuidV7(); const challengeId = uuidV7(); -const now = new Date("2026-09-08T00:00:00Z"); -const expires = new Date("2026-09-08T00:15:00Z"); +const now = Temporal.Instant.from("2026-09-08T00:00:00Z"); +const expires = Temporal.Instant.from("2026-09-08T00:15:00Z"); let client: PGlite; let db: Database; @@ -58,12 +58,12 @@ afterEach(async () => { }); describe("login challenges", () => { - it("finds an active challenge by its public ID with Date and SQL clocks", async () => { + it("finds an active challenge by its public ID with Instant and SQL clocks", async () => { assert.equal( (await findLoginChallenge(db, challengeId, now)).accountId, accountId, ); - const clock = sql`${now.toISOString()}::timestamptz`; + const clock = sql`${now.toString()}::timestamptz`; assert.equal( (await findLoginChallenge(db, challengeId, clock)).id, challengeId, @@ -91,7 +91,7 @@ describe("login challenges", () => { const row = await db.query.loginChallenges.findFirst({ where: { id: challengeId }, }); - assert.deepEqual(row?.consumed, now); + assert.equal(row?.consumed?.epochNanoseconds, now.epochNanoseconds); await assert.rejects( consumeLoginChallenge(db, challengeId, now), LoginChallengeConsumptionError, @@ -148,7 +148,7 @@ describe("login challenges", () => { }) .where(eq(schema.loginChallenges.id, challengeId)); await findLoginChallenge(db, challengeId); - await consumeLoginChallenge(db, challengeId, sql`CURRENT_TIMESTAMP`); + await consumeLoginChallenge(db, challengeId, sql`CURRENT_TIMESTAMP`); await assert.rejects( findLoginChallenge(db, challengeId), LoginChallengeNotFoundError, @@ -166,6 +166,28 @@ describe("login challenges", () => { const row = await db.query.loginChallenges.findFirst({ where: { id: challengeId }, }); - assert.ok(row?.consumed instanceof Date); + assert.ok(row?.consumed instanceof Temporal.Instant); }); }); + +// The connection timezone must be set before each pair of reads. +// oxlint-disable no-await-in-loop +it("preserves microseconds through writes, SQL defaults and non-UTC query results", async () => { + const instant = Temporal.Instant.from("2026-09-14T12:00:00.123456Z"); + await db.update(schema.loginChallenges).set({ created: instant }); + for (const zone of [ + "UTC", + "Asia/Seoul", + "Asia/Kolkata", + "America/Los_Angeles", + ]) { + await db.execute(sql`select set_config('TimeZone', ${zone}, false)`); + const [selected] = await db.select().from(schema.loginChallenges); + const related = await db.query.loginChallenges.findFirst({ + with: { account: true }, + }); + assert.equal(selected?.created.epochNanoseconds, instant.epochNanoseconds); + assert.equal(related?.created.epochNanoseconds, instant.epochNanoseconds); + assert.ok(related?.account.created instanceof Temporal.Instant); + } +}); diff --git a/packages/models/src/login.ts b/packages/models/src/login.ts index fee88c9..1502773 100644 --- a/packages/models/src/login.ts +++ b/packages/models/src/login.ts @@ -57,15 +57,15 @@ export class LoginChallengeNotFoundError extends LoginChallengeError { export async function findLoginChallenge( db: Database | Transaction, id: Uuid, - now?: Date | SQL, + now?: Temporal.Instant | SQL, ): Promise { // oxlint-disable-next-line no-param-reassign - now ??= sql`CURRENT_TIMESTAMP`; + now ??= sql`CURRENT_TIMESTAMP`; const result = await db.query.loginChallenges.findFirst({ where: { id, consumed: { isNull: true }, - ...(now instanceof Date + ...(now instanceof Temporal.Instant ? { expires: { gt: now } } : { RAW: (t) => sql`${t.expires} > ${now}` }), }, @@ -98,10 +98,10 @@ export class LoginChallengeConsumptionError extends LoginChallengeError { export async function consumeLoginChallenge( db: Database | Transaction, id: Uuid, - now?: Date | SQL, + now?: Temporal.Instant | SQL, ): Promise { // oxlint-disable-next-line no-param-reassign - now ??= sql`CURRENT_TIMESTAMP`; + now ??= sql`CURRENT_TIMESTAMP`; const result = await db .update(loginChallenges) .set({ consumed: now }) diff --git a/packages/models/src/outbox-migration.test.ts b/packages/models/src/outbox-migration.test.ts new file mode 100644 index 0000000..649f1b3 --- /dev/null +++ b/packages/models/src/outbox-migration.test.ts @@ -0,0 +1,185 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// Keep the upgrade scenario and ordered writes together. +// oxlint-disable max-statements, no-await-in-loop +import assert from "node:assert/strict"; +import { cp, mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { it } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { migrate, relations, schema } from "@drfed/models"; +import { + addActorCollectionItem, + promoteResource, +} from "@drfed/models/resource"; +import { uuidV7 } from "@drfed/models/uuid"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate as migrateBaseline } from "drizzle-orm/pglite/migrator"; + +const migrationName = "20260917163517_order_local_outbox_items"; +const migrations = join( + dirname(fileURLToPath(import.meta.resolve("@drfed/models/migrate"))), + "..", + "drizzle", +); + +it("backfills local outbox chronology and preserves other collection positions", async () => { + const baseline = await mkdtemp(join(tmpdir(), "drfed-outbox-migration-")); + const client = new PGlite(); + try { + const entries = await readdir(migrations, { withFileTypes: true }); + await Promise.all( + entries + .filter((entry) => entry.isDirectory() && entry.name < migrationName) + .map((entry) => + cp(join(migrations, entry.name), join(baseline, entry.name), { + recursive: true, + }), + ), + ); + await migrateBaseline(drizzle({ client }), { migrationsFolder: baseline }); + const db = drizzle({ client, schema, relations }); + const instanceId = uuidV7(); + await db + .insert(schema.instances) + .values({ id: instanceId, host: "migration.example" }); + const actorId = uuidV7(); + const remoteId = uuidV7(); + await db.insert(schema.localActors).values({ id: actorId }); + for (const id of [actorId, remoteId]) { + await promoteResource( + db, + `https://migration.example/actors/${id}`, + "actor", + async (tx, row) => { + await tx.insert(schema.actors).values({ + id: row.id, + localId: id === actorId ? id : null, + instanceId, + type: "Person", + username: id, + inboxUrl: `https://migration.example/actors/${id}/inbox`, + }); + }, + id, + ); + } + const outboxId = uuidV7(); + const remoteOutboxId = uuidV7(); + const featuredId = uuidV7(); + for (const [id, ownerActorId, role] of [ + [outboxId, actorId, "outbox"], + [remoteOutboxId, remoteId, "outbox"], + [featuredId, actorId, "featured"], + ] as const) { + await promoteResource( + db, + `https://migration.example/collections/${id}`, + "collection", + async (tx, row) => { + await tx + .insert(schema.collections) + .values({ id: row.id, ownerActorId, type: "OrderedCollection" }); + await tx + .insert(schema.actorCollectionReferences) + .values({ actorId: ownerActorId, role, collectionId: row.id }); + }, + id, + ); + } + const ids = [uuidV7(), uuidV7(), uuidV7()] as const; + for (const [index, id] of ids.entries()) { + await promoteResource( + db, + `https://migration.example/activities/${id}`, + "activity", + async (tx, row) => { + await tx.insert(schema.activities).values({ + id: row.id, + actorId, + type: "Create", + published: Temporal.Instant.from( + index === 0 ? "2026-01-01T00:00:00Z" : "2026-01-02T00:00:00Z", + ), + }); + }, + id, + ); + await db.insert(schema.collectionItems).values([ + { + collectionId: outboxId, + itemId: id, + position: index === 0 ? -99 : null, + }, + { collectionId: remoteOutboxId, itemId: id, position: null }, + { collectionId: featuredId, itemId: id, position: index }, + ]); + } + const otherItems = await db.query.collectionItems.findMany({ + where: { collectionId: { ne: outboxId } }, + orderBy: { collectionId: "asc", itemId: "asc" }, + }); + await migrate({ credentials: { driver: "pglite", client } }); + const ordered = await db.query.collectionItems.findMany({ + where: { collectionId: outboxId }, + columns: { itemId: true, position: true }, + orderBy: { position: "asc" }, + }); + assert.deepEqual(ordered, [ + { itemId: ids[2], position: -3 }, + { itemId: ids[1], position: -2 }, + { itemId: ids[0], position: -1 }, + ]); + assert.deepEqual( + await db.query.collectionItems.findMany({ + where: { collectionId: { ne: outboxId } }, + orderBy: { collectionId: "asc", itemId: "asc" }, + }), + otherItems, + ); + const newest = uuidV7(); + await promoteResource( + db, + `https://migration.example/activities/${newest}`, + "activity", + async (tx, row) => { + await tx.insert(schema.activities).values({ + id: row.id, + actorId, + type: "Create", + published: Temporal.Instant.from("2026-01-03T00:00:00Z"), + }); + }, + newest, + ); + await addActorCollectionItem(db, actorId, "outbox", newest); + await migrate({ credentials: { driver: "pglite", client } }); + assert.deepEqual( + await db.query.collectionItems.findMany({ + where: { collectionId: outboxId }, + columns: { itemId: true, position: true }, + orderBy: { position: "asc" }, + }), + [{ itemId: newest, position: -4 }, ...ordered], + ); + } finally { + await client.close(); + await rm(baseline, { recursive: true, force: true }); + } +}); diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index 3ec17e1..c57398d 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -72,7 +72,11 @@ export const relations = defineRelations(schema, (r) => ({ accepted: { isNotNull: true }, }, }), - actors: r.many.actors({ from: r.instances.id, to: r.actors.instanceId }), + actors: r.many.actors({ + from: r.instances.id, + to: r.actors.instanceId, + where: { deleted: { isNull: true } }, + }), localInstance: r.one.localInstances({ from: r.instances.localId, to: r.localInstances.id, @@ -98,7 +102,146 @@ export const relations = defineRelations(schema, (r) => ({ optional: false, }), }, + resources: { + actor: r.one.actors({ + from: r.resources.id, + to: r.actors.id, + optional: true, + }), + object: r.one.objects({ + from: r.resources.id, + to: r.objects.id, + optional: true, + }), + activity: r.one.activities({ + from: r.resources.id, + to: r.activities.id, + optional: true, + }), + collection: r.one.collections({ + from: r.resources.id, + to: r.collections.id, + optional: true, + }), + addressedBy: r.many.addressing({ + from: r.resources.id, + to: r.addressing.targetId, + }), + }, + addressing: { + source: r.one.resources({ + from: r.addressing.sourceId, + to: r.resources.id, + optional: false, + }), + targetResource: r.one.resources({ + from: r.addressing.targetId, + to: r.resources.id, + optional: false, + }), + }, + actorCollectionReferences: { + actor: r.one.actors({ + from: r.actorCollectionReferences.actorId, + to: r.actors.id, + optional: false, + }), + collection: r.one.collections({ + from: r.actorCollectionReferences.collectionId, + to: r.collections.id, + optional: false, + }), + }, + collections: { + resource: r.one.resources({ + from: r.collections.id, + to: r.resources.id, + optional: false, + }), + ownerActor: r.one.actors({ + from: r.collections.ownerActorId, + to: r.actors.id, + }), + items: r.many.collectionItems({ + from: r.collections.id, + to: r.collectionItems.collectionId, + }), + }, + collectionItems: { + collection: r.one.collections({ + from: r.collectionItems.collectionId, + to: r.collections.id, + optional: false, + }), + item: r.one.resources({ + from: r.collectionItems.itemId, + to: r.resources.id, + optional: false, + }), + }, + activities: { + resource: r.one.resources({ + from: r.activities.id, + to: r.resources.id, + optional: false, + }), + actor: r.one.actors({ + from: r.activities.actorId, + to: r.actors.id, + optional: false, + }), + object: r.one.resources({ + from: r.activities.objectId, + to: r.resources.id, + }), + addressing: r.many.addressing({ + from: r.activities.id, + to: r.addressing.sourceId, + }), + }, + objects: { + resource: r.one.resources({ + from: r.objects.id, + to: r.resources.id, + optional: false, + }), + addressing: r.many.addressing({ + from: r.objects.id, + to: r.addressing.sourceId, + }), + activities: r.many.activities({ + from: r.objects.id, + to: r.activities.objectId, + }), + actor: r.one.actors({ + from: r.objects.actorId, + to: r.actors.id, + optional: false, + }), + }, actors: { + collectionReferences: r.many.actorCollectionReferences({ + from: r.actors.id, + to: r.actorCollectionReferences.actorId, + }), + resource: r.one.resources({ + from: r.actors.id, + to: r.resources.id, + optional: false, + }), + collections: r.many.collections({ + from: r.actors.id, + to: r.collections.ownerActorId, + }), + activities: r.many.activities({ + from: r.actors.id, + to: r.activities.actorId, + }), + objects: r.many.objects({ + from: r.actors.id, + to: r.objects.actorId, + where: { deleted: { isNull: true } }, + }), instance: r.one.instances({ from: r.actors.instanceId, to: r.instances.id, diff --git a/packages/models/src/resource.test.ts b/packages/models/src/resource.test.ts new file mode 100644 index 0000000..7cc5b80 --- /dev/null +++ b/packages/models/src/resource.test.ts @@ -0,0 +1,216 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// oxlint-disable max-statements -- Keep resource before/after assertions together. + +import assert from "node:assert/strict"; +import { it } from "node:test"; + +import { migrate, relations, schema } from "@drfed/models"; +import { + PUBLIC_IRI, + PUBLIC_RESOURCE_ID, + addActorCollectionItem, + ensureResource, + promoteResource, + storeAddressing, +} from "@drfed/models/resource"; +import { PGlite } from "@electric-sql/pglite"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; + +it("reuses exact IRIs and promotes unknown resources atomically", async () => { + const client = new PGlite(); + try { + await migrate({ credentials: { driver: "pglite", client } }); + const db = drizzle({ client, schema, relations }); + const iri = "https://remote.example/collection"; + const resource = await ensureResource(db, iri); + assert.equal(resource.kind, "unknown"); + assert.equal((await ensureResource(db, iri)).id, resource.id); + await assert.rejects( + promoteResource(db, iri, "collection", () => + Promise.reject(new Error("abort")), + ), + /abort/u, + ); + assert.equal((await ensureResource(db, iri)).kind, "unknown"); + await promoteResource(db, iri, "collection", async (tx, row) => { + await tx + .insert(schema.collections) + .values({ id: row.id, type: "OrderedCollection" }); + }); + assert.equal((await ensureResource(db, iri)).kind, "collection"); + assert.equal( + (await db.query.collections.findFirst({ where: { id: resource.id } })) + ?.id, + resource.id, + ); + await assert.rejects( + promoteResource(db, iri, "object", () => + Promise.reject(new Error("Unexpected incompatible insertion")), + ), + /already a collection/u, + ); + assert.notEqual( + (await ensureResource(db, "https://REMOTE.example/collection")).id, + resource.id, + ); + const source = await ensureResource(db, "https://example.com/source"); + await db.transaction(async (tx) => { + await storeAddressing(tx, source.id, { + to: [iri, PUBLIC_IRI, iri], + cc: [iri], + bto: [iri], + bcc: [iri], + audience: [iri], + }); + }); + const rows = await db.query.addressing.findMany({ + where: { sourceId: source.id, property: "to" }, + orderBy: { position: "asc" }, + }); + assert.deepEqual( + rows.map((r) => [r.position, r.targetId]), + [ + [0, resource.id], + [1, PUBLIC_RESOURCE_ID], + [2, resource.id], + ], + ); + await assert.rejects( + db.delete(schema.resources).where(eq(schema.resources.id, resource.id)), + ); + } finally { + await client.close(); + } +}); + +it("reuses existing addressing targets without updating or locking their resource rows", async () => { + const client = new PGlite(); + try { + await migrate({ credentials: { driver: "pglite", client } }); + const db = drizzle({ client, schema, relations }); + const source = await ensureResource(db, "https://example.com/source"); + await client.exec(` + CREATE FUNCTION reject_resource_update() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'existing resource must not be updated'; END $$; + CREATE TRIGGER no_resource_update BEFORE UPDATE ON resources + FOR EACH ROW EXECUTE FUNCTION reject_resource_update(); + `); + assert.equal((await ensureResource(db, PUBLIC_IRI)).id, PUBLIC_RESOURCE_ID); + await db.transaction(async (tx) => { + await storeAddressing(tx, source.id, { to: [PUBLIC_IRI, PUBLIC_IRI] }); + }); + assert.equal(await db.$count(schema.addressing), 2); + } finally { + await client.close(); + } +}); + +it("records idempotent collection membership only for declared roles", async () => { + const client = new PGlite(); + try { + await migrate({ credentials: { driver: "pglite", client } }); + const db = drizzle({ client, schema, relations }); + const instanceId = "00000000-0000-4000-8000-000000000101"; + await db.insert(schema.instances).values({ + id: instanceId, + host: "test-instance.drfed.org", + }); + const actorIri = "https://test-instance.drfed.org/users/alice"; + const actor = await promoteResource( + db, + actorIri, + "actor", + async (tx, row) => { + await tx.insert(schema.actors).values({ + id: row.id, + instanceId, + type: "Person", + username: "alice", + inboxUrl: `${actorIri}/inbox`, + }); + return row; + }, + ); + const outbox = await promoteResource( + db, + `${actorIri}/outbox`, + "collection", + async (tx, row) => { + await tx.insert(schema.collections).values({ + id: row.id, + type: "OrderedCollection", + ownerActorId: actor.id, + }); + await tx.insert(schema.actorCollectionReferences).values({ + actorId: actor.id, + role: "outbox", + collectionId: row.id, + }); + return row; + }, + ); + const item = await ensureResource(db, "https://remote.example/activity"); + await addActorCollectionItem(db, actor.id, "outbox", item.id); + await addActorCollectionItem(db, actor.id, "outbox", item.id); + assert.deepEqual( + await db.query.collectionItems.findMany({ + columns: { collectionId: true, itemId: true, position: true }, + }), + [{ collectionId: outbox.id, itemId: item.id, position: -1 }], + ); + const newer = await ensureResource(db, "https://remote.example/newer"); + await addActorCollectionItem(db, actor.id, "outbox", newer.id); + assert.deepEqual( + await db.query.collectionItems.findMany({ + columns: { itemId: true, position: true }, + orderBy: { position: "asc" }, + }), + [ + { itemId: newer.id, position: -2 }, + { itemId: item.id, position: -1 }, + ], + ); + await assert.rejects( + addActorCollectionItem(db, actor.id, "featured", item.id), + /declares no featured collection/u, + ); + assert.equal(await db.$count(schema.collectionItems), 2); + } finally { + await client.close(); + } +}); + +it("indexes activities by referenced object in connection order", async () => { + const client = new PGlite(); + try { + await migrate({ credentials: { driver: "pglite", client } }); + const { rows } = await client.query<{ indexdef: string }>( + "select indexdef from pg_indexes where tablename = 'activities'", + ); + const definitions = rows.map((row) => row.indexdef); + assert.ok( + definitions.some((definition) => + /\("objectId", published, id\)$/u.test(definition), + ), + `No (objectId, published, id) index on activities:\n${definitions.join("\n")}`, + ); + } finally { + await client.close(); + } +}); diff --git a/packages/models/src/resource.ts b/packages/models/src/resource.ts new file mode 100644 index 0000000..386f599 --- /dev/null +++ b/packages/models/src/resource.ts @@ -0,0 +1,193 @@ +// 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 . + +// Keep dependent database writes and observations sequential. +// oxlint-disable no-await-in-loop + +import { and, eq, min } from "drizzle-orm"; + +import type { Database, Transaction } from "./db.ts"; +import { + type AddressingProperty, + type CollectionRole, + type Resource, + actorCollectionReferences, + addressing, + addressingPropertyEnum, + collectionItems, + collections, + resources, +} from "./schema.ts"; +import { type Uuid, uuidV7 } from "./uuid.ts"; + +export const PUBLIC_IRI = "https://www.w3.org/ns/activitystreams#Public"; +export const PUBLIC_RESOURCE_ID: Uuid = "00000000-0000-4000-8000-000000000000"; + +/** + * Returns the canonical resource without interpreting or normalizing its IRI. + * @returns The existing or newly registered resource. + */ +export async function ensureResource( + tx: Database | Transaction, + iri: string, + id = uuidV7(), +): Promise { + const [existing] = await tx + .select() + .from(resources) + .where(eq(resources.iri, iri)) + .limit(1); + if (existing != null) return existing; + const [inserted] = await tx + .insert(resources) + .values({ id, iri, kind: "unknown" }) + .onConflictDoNothing({ target: resources.iri }) + .returning(); + if (inserted != null) return inserted; + // A concurrent insertion can win after the first SELECT. A separate + // statement sees that committed row under PostgreSQL's READ COMMITTED. + const [resource] = await tx + .select() + .from(resources) + .where(eq(resources.iri, iri)) + .limit(1); + if (resource == null) throw new Error("Resource insertion returned no row."); + return resource; +} + +/** + * Atomically promotes an unknown IRI and inserts its typed row. + * @returns The typed-row insertion callback result. + */ +export async function promoteResource( + db: Database | Transaction, + iri: string, + kind: Exclude, + insert: (tx: Transaction, resource: Resource) => Promise, + id?: Uuid, +): Promise { + return await db.transaction(async (tx) => { + const ensured = await ensureResource(tx, iri, id); + // Only promotion needs an exclusive lock; re-read the kind after locking + // so a concurrent promotion cannot change it between validation and update. + const [resource] = await tx + .select() + .from(resources) + .where(eq(resources.id, ensured.id)) + .for("update"); + if (resource == null) { + throw new Error("Resource disappeared during promotion."); + } + if (resource.kind !== "unknown" && resource.kind !== kind) { + throw new Error(`Resource ${iri} is already a ${resource.kind}.`); + } + await tx + .update(resources) + .set({ kind }) + .where(eq(resources.id, resource.id)); + return await insert(tx, { ...resource, kind }); + }); +} + +export type AddressingInput = Readonly< + Partial> +>; + +/** Stores every occurrence, including duplicates, in its original position. */ +export async function storeAddressing( + tx: Transaction, + sourceId: Uuid, + input: AddressingInput, +): Promise { + // Acquire unique-index locks for new IRIs in a consistent order across + // writers. This does not change occurrence order in the addressing rows. + const targets = new Map(); + const iris = [ + ...new Set( + addressingPropertyEnum.enumValues.flatMap( + (property) => input[property] ?? [], + ), + ), + ].sort(); + for (const iri of iris) targets.set(iri, await ensureResource(tx, iri)); + for (const property of addressingPropertyEnum.enumValues) { + for (const [position, iri] of (input[property] ?? []).entries()) { + const target = targets.get(iri)!; + await tx.insert(addressing).values({ + id: uuidV7(), + sourceId, + property, + position, + targetId: target.id, + }); + } + } +} + +/** + * Locks an actor's declared collection until the transaction ends. + * @returns The locked collection ID. + */ +export async function lockActorCollection( + tx: Transaction, + actorId: Uuid, + role: CollectionRole, +): Promise { + const [reference] = await tx + .select({ collectionId: collections.id }) + .from(collections) + .innerJoin( + actorCollectionReferences, + eq(collections.id, actorCollectionReferences.collectionId), + ) + .where( + and( + eq(actorCollectionReferences.actorId, actorId), + eq(actorCollectionReferences.role, role), + ), + ) + .for("update", { of: collections }) + .limit(1); + if (reference == null) { + throw new Error(`Actor ${actorId} declares no ${role} collection.`); + } + return reference.collectionId; +} + +/** + * Records a resource in an actor's declared collection, idempotently. + * Position ASC is presentation order. Outboxes are reverse chronological, + * so the newest item receives the smallest position. + * @throws {Error} If the actor declares no collection for the role. + */ +export async function addActorCollectionItem( + db: Database | Transaction, + actorId: Uuid, + role: CollectionRole, + itemId: Uuid, +): Promise { + await db.transaction(async (tx) => { + const collectionId = await lockActorCollection(tx, actorId, role); + const [row] = await tx + .select({ position: min(collectionItems.position) }) + .from(collectionItems) + .where(eq(collectionItems.collectionId, collectionId)); + await tx + .insert(collectionItems) + .values({ collectionId, itemId, position: (row?.position ?? 0) - 1 }) + .onConflictDoNothing(); + }); +} diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index d40166d..d7c1ba9 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -14,20 +14,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { sql } from "drizzle-orm"; +import { desc, sql } from "drizzle-orm"; import { type AnyPgColumn, boolean, char, check, + customType, index, integer, + json, jsonb, pgEnum, pgTable, primaryKey, text, - timestamp, unique, uuid, varchar, @@ -35,6 +36,20 @@ import { import type { Uuid } from "./uuid.ts"; +/** A timestamptz column preserving PostgreSQL's microsecond precision. */ +const instant = customType<{ data: Temporal.Instant; driverData: string }>({ + dataType: () => "timestamp with time zone", + fromDriver: (value) => Temporal.Instant.from(value), + toDriver(value: Temporal.Instant | string) { + // Pothos composite cursors decode timestamps as strings. + if (typeof value === "string") return value; + if (value instanceof Temporal.Instant) return value.toString(); + throw new TypeError( + "Expected a Temporal.Instant or a cursor timestamp string.", + ); + }, +}); + const currentTimestamp = sql`CURRENT_TIMESTAMP`; /** @@ -48,9 +63,7 @@ export const accounts = pgTable( name: varchar({ length: 100 }).notNull(), maxInstances: integer("max_instances").notNull().default(10), admin: boolean().notNull().default(false), - created: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), + created: instant().notNull().default(currentTimestamp), }, (table) => [ check( @@ -75,9 +88,7 @@ export const instances = pgTable("instances", { .references(() => localInstances.id, { onDelete: "cascade", }), - created: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), + created: instant().notNull().default(currentTimestamp), // The authority an instance is federated under, which is what Fedify's // `Context.host` reports and therefore what dispatchers look instances up // by. That is a DNS name, at most 253 octets, plus a `:port` suffix of up @@ -98,7 +109,7 @@ export const localInstances = pgTable( { id: uuid().$type().primaryKey(), slug: varchar({ length: 63 }).notNull().unique(), - expires: timestamp({ withTimezone: true }).notNull(), + expires: instant().notNull(), maxActors: integer().notNull().default(10), }, (table) => [ @@ -136,10 +147,8 @@ export const instanceMembers = pgTable( .notNull() .references(() => instances.id), admin: boolean().notNull().default(false), - accepted: timestamp({ withTimezone: true }), - created: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), + accepted: instant(), + created: instant().notNull().default(currentTimestamp), }, (table) => [ primaryKey({ columns: [table.instanceId, table.accountId] }), @@ -169,13 +178,11 @@ export const loginChallenges = pgTable("login_challenges", { .notNull() .references(() => accounts.id, { onDelete: "cascade" }), code: char({ length: LOGIN_CHALLENGE_CODE_LENGTH }).notNull(), - created: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), - expires: timestamp({ withTimezone: true }) + created: instant().notNull().default(currentTimestamp), + expires: instant() .notNull() .default(sql`CURRENT_TIMESTAMP + INTERVAL '15 minutes'`), - consumed: timestamp({ withTimezone: true }), + consumed: instant(), }); export type LoginChallenge = typeof loginChallenges.$inferSelect; @@ -192,10 +199,8 @@ export const sessions = pgTable("sessions", { .notNull() .references(() => accounts.id, { onDelete: "cascade" }), tokenHash: varchar({ length: 64 }).notNull().unique(), - created: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), - expires: timestamp({ withTimezone: true }) + created: instant().notNull().default(currentTimestamp), + expires: instant() .notNull() .default(sql`CURRENT_TIMESTAMP + INTERVAL '1 month'`), }); @@ -213,10 +218,34 @@ export const actorTypeEnum = pgEnum("actor_type", [ export type ActorType = (typeof actorTypeEnum.enumValues)[number]; +export const resourceKindEnum = pgEnum("resource_kind", [ + "actor", + "object", + "activity", + "collection", + "unknown", +]); + +/** + * Canonical IRI registry. Physical deletion of an actor, object, activity or + * collection must also delete its source addressing and resource in the same + * transaction. References from other resources intentionally restrict deletion. + */ +export const resources = pgTable("resources", { + id: uuid().$type().primaryKey(), + iri: text().notNull().unique(), + kind: resourceKindEnum().notNull(), + created: instant().notNull().default(currentTimestamp), +}); +export type Resource = typeof resources.$inferSelect; + export const actors = pgTable( "actors", { - id: uuid().$type().primaryKey(), + id: uuid() + .$type() + .primaryKey() + .references(() => resources.id, { onDelete: "cascade" }), localId: uuid() .$type() .unique() @@ -227,12 +256,8 @@ export const actors = pgTable( .$type() .notNull() .references(() => instances.id, { onDelete: "cascade" }), - iri: text().notNull().unique(), + document: json(), inboxUrl: text().notNull(), - outboxUrl: text().notNull(), - followersUrl: text(), - followingUrl: text(), - featuredUrl: text(), profileUrl: text(), avatarUrl: text(), headerUrl: text(), @@ -251,9 +276,9 @@ export const actors = pgTable( // block for remote actors: suspended set, suspendedUntil IS NULL // Whether a sanction is *currently* active is always determined by // comparing against the current time (lazy expiry; no cron): - // suspended <= now AND (suspendedUntil IS NULL OR suspendedUntil > now). - suspended: timestamp({ withTimezone: true }), - suspendedUntil: timestamp({ withTimezone: true }), + // Temporal.Instant.compare(suspended, now) <= 0 AND (suspendedUntil IS NULL OR Temporal.Instant.compare(suspendedUntil, now) > 0). + suspended: instant(), + suspendedUntil: instant(), successorId: uuid() .$type() .references((): AnyPgColumn => actors.id, { @@ -265,16 +290,16 @@ export const actors = pgTable( .default(sql`(ARRAY[]::text[])`), followingCount: integer().notNull().default(0), followersCount: integer().notNull().default(0), - postsCount: integer().notNull().default(0), - updated: timestamp({ withTimezone: true }) + updated: instant() .notNull() .default(currentTimestamp) .$onUpdate(() => currentTimestamp), - published: timestamp({ withTimezone: true }), - created: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), - deleted: timestamp({ withTimezone: true }), + published: instant(), + created: instant().notNull().default(currentTimestamp), + // When implementing actor deletion, add activities.deleted and set it + // together with objects.deleted in the same transaction. + // FIXME: https://github.com/fedify-dev/drfed/issues/89 + deleted: instant(), }, (t) => [ unique("username_key").on(t.username, t.instanceId), @@ -303,3 +328,188 @@ export const localActors = pgTable("local_actors", { export type LocalActor = typeof localActors.$inferSelect; export type NewLocalActor = typeof localActors.$inferInsert; + +export const objectTypeEnum = pgEnum("object_type", ["Article", "Note"]); +export type ObjectType = (typeof objectTypeEnum.enumValues)[number]; +/** ActivityPub objects authored by actors. */ +export const objects = pgTable( + "objects", + { + id: uuid() + .$type() + .primaryKey() + .references(() => resources.id, { onDelete: "cascade" }), + actorId: uuid() + .$type() + .notNull() + .references(() => actors.id, { onDelete: "cascade" }), + type: objectTypeEnum().notNull(), + document: json(), + url: text(), + name: text(), + summary: text(), + contentHtml: text().notNull(), + language: varchar({ length: 35 }), + sensitive: boolean().notNull().default(false), + published: instant().notNull().default(currentTimestamp), + updated: instant() + .notNull() + .default(currentTimestamp) + .$onUpdate(() => currentTimestamp), + created: instant().notNull().default(currentTimestamp), + deleted: instant(), + }, + (t) => [ + check( + "objects_content_html_check", + sql`trim(both from ${t.contentHtml}) <> ''`, + ), + index("object_actor_published_index").on( + t.actorId, + desc(t.published), + desc(t.id), + ), + ], +); +export type ActivityPubObject = typeof objects.$inferSelect; +export type NewActivityPubObject = typeof objects.$inferInsert; + +export const collectionTypeEnum = pgEnum("collection_type", [ + "Collection", + "OrderedCollection", +]); +export const collectionRoleEnum = pgEnum("collection_role", [ + "followers", + "following", + "featured", + "outbox", +]); +export type CollectionRole = (typeof collectionRoleEnum.enumValues)[number]; +export const collections = pgTable("collections", { + id: uuid() + .$type() + .primaryKey() + .references(() => resources.id, { onDelete: "cascade" }), + type: collectionTypeEnum().notNull(), + /** + * Lifecycle owner of a locally managed collection. Physical deletion of + * the owner cascades to the collection and all references. Soft deletion + * hides the collection and every actor's reference to it from GraphQL. + */ + ownerActorId: uuid() + .$type() + .references(() => actors.id, { onDelete: "cascade" }), + totalItems: integer(), + document: json(), + updated: instant() + .notNull() + .default(currentTimestamp) + .$onUpdate(() => currentTimestamp), +}); +export type Collection = typeof collections.$inferSelect; + +/** Actor-declared collection roles; a collection may be shared across roles or actors. */ +export const actorCollectionReferences = pgTable( + "actor_collection_references", + { + actorId: uuid() + .$type() + .notNull() + .references(() => actors.id, { onDelete: "cascade" }), + role: collectionRoleEnum().notNull(), + collectionId: uuid() + .$type() + .notNull() + .references(() => collections.id, { onDelete: "cascade" }), + }, + (t) => [ + primaryKey({ columns: [t.actorId, t.role] }), + index("actor_collection_reference_collection_index").on(t.collectionId), + ], +); + +export const collectionItems = pgTable( + "collection_items", + { + collectionId: uuid() + .$type() + .notNull() + .references(() => collections.id, { onDelete: "cascade" }), + itemId: uuid() + .$type() + .notNull() + .references(() => resources.id, { onDelete: "cascade" }), + position: integer(), + observed: instant().notNull().default(currentTimestamp), + }, + (t) => [ + primaryKey({ columns: [t.collectionId, t.itemId] }), + index("collection_item_position_index").on(t.collectionId, t.position), + ], +); + +export const activityTypeEnum = pgEnum("activity_type", ["Create"]); +export const activities = pgTable( + "activities", + { + id: uuid() + .$type() + .primaryKey() + .references(() => resources.id, { onDelete: "cascade" }), + type: activityTypeEnum().notNull(), + actorId: uuid() + .$type() + .notNull() + .references(() => actors.id, { onDelete: "cascade" }), + objectId: uuid() + .$type() + .references(() => resources.id, { onDelete: "cascade" }), + published: instant().notNull(), + document: json(), + created: instant().notNull().default(currentTimestamp), + }, + (t) => [ + index("activity_actor_published_index").on( + t.actorId, + desc(t.published), + desc(t.id), + ), + index("activity_object_published_index").on(t.objectId, t.published, t.id), + ], +); +export type StoredActivity = typeof activities.$inferSelect; + +export const addressingPropertyEnum = pgEnum("addressing_property", [ + "to", + "cc", + "bto", + "bcc", + "audience", +]); +export type AddressingProperty = + (typeof addressingPropertyEnum.enumValues)[number]; +export const addressing = pgTable( + "addressing", + { + id: uuid().$type().primaryKey(), + sourceId: uuid() + .$type() + .notNull() + .references(() => resources.id, { onDelete: "cascade" }), + property: addressingPropertyEnum().notNull(), + position: integer().notNull(), + targetId: uuid() + .$type() + .notNull() + .references(() => resources.id, { onDelete: "restrict" }), + }, + (t) => [ + unique("addressing_source_property_position_key").on( + t.sourceId, + t.property, + t.position, + ), + index("addressing_target_property_index").on(t.targetId, t.property), + ], +); +export type Addressing = typeof addressing.$inferSelect; diff --git a/packages/models/src/uuid.ts b/packages/models/src/uuid.ts index 1d49f69..2ffed34 100644 --- a/packages/models/src/uuid.ts +++ b/packages/models/src/uuid.ts @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { v7 } from "uuid"; +import { v7, validate } from "uuid"; /** * A UUID string. It does not guarantee that the string is a normalized UUID. @@ -37,3 +37,10 @@ export function areUuidsEqual(left: Uuid, right: Uuid): boolean { export function uuidV7(): Uuid { return v7() as Uuid; } + +/** + * Validate UUID. + * @param value A Value to validate. + * @returns `true` if the input is `Uuid`. + */ +export const validateUuid = (value: unknown): value is Uuid => validate(value); diff --git a/packages/web/src/routes/workspace/create/[instance_id]/actors.tsx b/packages/web/src/routes/workspace/create/[instance_id]/actors.tsx index 31eef4c..665a996 100644 --- a/packages/web/src/routes/workspace/create/[instance_id]/actors.tsx +++ b/packages/web/src/routes/workspace/create/[instance_id]/actors.tsx @@ -129,6 +129,7 @@ export default function CreateActorsPage(props: RouteSectionProps) { setErrorMessage(undefined); commitGenerateActors({ variables: { instance, size }, + // oxlint-disable-next-line max-statements onCompleted: (response, errors) => { const graphQLErrors = errors ?? []; if (graphQLErrors.length > 0) { @@ -138,7 +139,7 @@ export default function CreateActorsPage(props: RouteSectionProps) { return; } - // TODO: Replace current codes to dictionary looking up + // FIXME: https://github.com/fedify-dev/drfed/issues/86 const result = response.generateActors; switch (result.resultType) { case "CreateActorsSuccess": { diff --git a/scripts/create-account.mts b/scripts/create-account.mts index b80f211..263d9d2 100644 --- a/scripts/create-account.mts +++ b/scripts/create-account.mts @@ -46,7 +46,7 @@ const logger = getLogger(["drfed", "create-account"]); async function main(): Promise { const email = normalizeEmail(process.env.email ?? ""); const name = (process.env.name ?? "").trim(); - const created = new Date(); + const created = Temporal.Now.instant(); const accountId = uuidV7(); if (email.length > 255 || !/^[^@]+@[^@]+\.[^@]+$/u.test(email)) {