From 7976abd74f16dd622f99fec3e2183929854217e7 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 6 Sep 2026 23:49:57 +0900 Subject: [PATCH 01/20] Add ActivityPub object creation and outbox GraphQL support Implement the [#9](https://github.com/fedify-dev/drfed/issues/9) with Note and Article storage, authenticated createObject, Relay object queries, ActivityPub dispatch, and paginated synthetic Create activities in local actor outboxes. AI provenance: The human user provided Fable with the scope and ideas for the implementation and had them draft a plan. The user read the draft, corrected any problematic parts, and had Astra handle the implementation. This session verified those changes and ran Claude Code with claude-fable-5 in a read-only review loop. After that, the human user read and verified. Automated validation: mise run check; mise run test including the full build (73 GraphQL and 3 model tests); mise run dev startup and HTTP GraphQL Object introspection. Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra --- packages/graphql/package.json | 5 + packages/graphql/src/actor.test.ts | 121 +- packages/graphql/src/federation.test.ts | 232 +++ packages/graphql/src/federation.ts | 133 +- packages/graphql/src/object.test.ts | 340 ++++ packages/graphql/src/object.ts | 336 ++++ packages/graphql/src/schema.ts | 1 + packages/graphql/src/seed.test.ts | 134 ++ .../20260906133944_add_objects/migration.sql | 23 + .../20260906133944_add_objects/snapshot.json | 1597 +++++++++++++++++ packages/models/src/relations.ts | 8 + packages/models/src/schema.ts | 55 +- 12 files changed, 2868 insertions(+), 117 deletions(-) create mode 100644 packages/graphql/src/object.test.ts create mode 100644 packages/graphql/src/object.ts create mode 100644 packages/graphql/src/seed.test.ts create mode 100644 packages/models/drizzle/20260906133944_add_objects/migration.sql create mode 100644 packages/models/drizzle/20260906133944_add_objects/snapshot.json diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 9457ba5..b680a2e 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -70,6 +70,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" @@ -88,6 +92,7 @@ "src/federation.ts", "src/instance.ts", "src/schema.ts", + "src/object.ts", "src/origin.ts" ], "dts": { diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index 74e0b14..9d1d15f 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -18,25 +18,23 @@ import assert from "node:assert/strict"; -import { type Database, schema } from "@drfed/models"; +import { schema } from "@drfed/models"; import { describe, it } from "@logtape/testing-node/autoload"; import { eq } from "drizzle-orm/sql/expressions"; -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, + seedAuthenticatedLocalInstance, + seedLocalActor, + seedRemoteActor, +} from "./seed.test.ts"; const generateActorsMutation = ` mutation GenerateActors($instance: ID!, $size: Int!) { @@ -296,96 +294,3 @@ describe("Actor", () => { }); }); }); - -function globalId(type: "Actor" | "Instance", id: string): string { - return Buffer.from(`${type}:${id}`).toString("base64"); -} - -async function seedAuthenticatedLocalInstance( - db: Database, -): Promise { - await db.insert(schema.accounts).values({ - id: accountId, - email: "owner@example.com", - name: "Owner", - created, - }); - await db.insert(schema.sessions).values({ - id: sessionId, - accountId, - tokenHash: await hashSecret(accessToken), - }); - await seedLocalInstance(db); - await db.insert(schema.instanceMembers).values({ - accountId, - instanceId: localInstanceId, - admin: true, - accepted, - created, - }); - return { headers: { authorization: `Bearer ${accessToken}` } }; -} - -async function seedLocalActor(db: Database): Promise { - await seedLocalInstance(db); - await db.insert(schema.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, - }); -} - -async function seedLocalInstance(db: Database): Promise { - await db.insert(schema.localInstances).values({ - id: localInstanceId, - slug: "test-instance", - expires, - }); - await db.insert(schema.instances).values({ - id: localInstanceId, - localId: localInstanceId, - created, - host: "test-instance.drfed.org", - }); -} - -async function seedRemoteActor(db: Database): Promise { - await db.insert(schema.instances).values({ - id: remoteInstanceId, - created, - host: "remote.example.com", - }); - 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, - }); -} diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index bce194d..3eadec3 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -21,9 +21,18 @@ import createFederation, { buildFederation } from "@drfed/graphql/federation"; import { schema } from "@drfed/models"; import { uuidV7 } from "@drfed/models/uuid"; import { MemoryKvStore } from "@fedify/fedify"; +import { Object as ASObject } from "@fedify/vocab"; import { describe, it } from "@logtape/testing-node/autoload"; +import { eq } from "drizzle-orm"; +import { v7 as uuid } from "uuid"; import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts"; +import { + localActorId, + remoteActorId, + seedLocalActor, + seedRemoteActor, +} from "./seed.test.ts"; const origin = new URL("https://drfed.test"); const activityJson = "application/activity+json"; @@ -35,6 +44,10 @@ describe("createFederation()", () => { kv: new MemoryKvStore(), }); const ctx = federation.createContext(origin, undefined); + assert.equal( + ctx.getObjectUri(ASObject, { identifier: "a", id: "b" }).href, + "https://drfed.test/users/a/objects/b", + ); assert.equal( ctx.getActorUri("identifier").href, "https://drfed.test/users/identifier", @@ -167,3 +180,222 @@ describe("createYogaServer()", () => { }); }); }); + +const actorIri = `https://test-instance.drfed.org/users/${localActorId}`; +const accept = { accept: "application/activity+json" }; + +function values(id: string) { + return { + id, + actorId: localActorId, + iri: `${actorIri}/objects/${id}`, + type: "Note" as const, + contentHtml: "

Hello

", + }; +} + +describe("ActivityPub objects", () => { + for (const visibility of ["public", "unlisted"] as const) { + it(`serves ${visibility} objects with contentMap and recipients`, async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + await db.insert(schema.objects).values({ + ...object, + visibility, + 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, + visibility === "public" ? "as:Public" : `${actorIri}/followers`, + ); + assert.equal( + body.cc, + visibility === "public" ? `${actorIri}/followers` : "as:Public", + ); + }); + }); + } + for (const deleted of [null, new Date("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 db + .insert(schema.objects) + .values({ ...object, visibility: "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 db.insert(schema.objects).values({ ...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 = new Date("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(new Date(tombstone.deleted).getTime(), deletedAt.getTime()); + }); + }); + 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 db.insert(schema.objects).values({ + ...object, + actorId: scenario === "remote" ? remoteActorId : localActorId, + }); + if (scenario === "deletedActor") { + await db + .update(schema.actors) + .set({ deleted: new Date() }) + .where(eq(schema.actors.id, localActorId)); + } + const iri = + scenario === "missing" + ? `${actorIri}/objects/${uuid()}` + : scenario === "malformed" + ? `${actorIri}/objects/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 outbox", () => { + it("paginates Create activities while excluding followers-only and deleted objects", async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const ids = Array.from({ length: 23 }, () => uuid()); + await db.insert(schema.objects).values( + ids.map((id, index) => ({ + ...values(id), + visibility: + index === 22 + ? ("followers" as const) + : index === 20 + ? ("unlisted" as const) + : ("public" as const), + deleted: index === 21 ? new Date() : null, + })), + ); + await db + .update(schema.actors) + .set({ postsCount: 23 }) + .where(eq(schema.actors.id, localActorId)); + 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, 23); + 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, + objectId: activity.object.id, + to: activity.to, + cc: activity.cc, + }, + { + type: "Create", + id: `${values(ids[20]!).iri}/activity`, + actor: actorIri, + objectId: 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.id, 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); + }); + }); + 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); + }); + }); +}); diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index fbb1dc0..d63e17e 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -15,7 +15,11 @@ // along with this program. If not, see . import type { Database } from "@drfed/models"; -import type { Actor } from "@drfed/models/schema"; +import type { + ActivityPubObject, + Actor, + ObjectType, +} from "@drfed/models/schema"; import type { Uuid } from "@drfed/models/uuid"; import { type Context, @@ -25,12 +29,18 @@ import { createFederationBuilder, } from "@fedify/fedify"; import { + Object as ASObject, Activity, Application, + Article, + Create, Endpoints, Group, Image, + LanguageString, + Note, Organization, + PUBLIC_COLLECTION, Person, Service, Tombstone, @@ -132,14 +142,64 @@ 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( + ASObject, + "/users/{identifier}/objects/{id}", + async (ctx, { identifier, id }) => { + if (!validateUuid(identifier) || !validateUuid(id)) return null; + const object = await db.query.objects.findFirst({ + where: { + id, + actorId: identifier, + actor: { + localId: { isNotNull: true }, + deleted: { isNull: true }, + instance: { host: ctx.host }, + }, + }, + }); + if (object == null || object.visibility === "followers") return null; + if (object.deleted != null) { + return new Tombstone({ + id: ctx.getObjectUri(ASObject, { identifier, id }), + deleted: Temporal.Instant.from(object.deleted.toISOString()), + }); + } + return toObject(ctx, object); + }, ); + builder + .setOutboxDispatcher( + "/users/{identifier}/outbox", + async (ctx, identifier, cursor) => { + if ((await findActiveActor(db, ctx, identifier)) == null) return null; + if (cursor != null && cursor !== "" && !validateUuid(cursor)) { + return null; + } + const rows = await db.query.objects.findMany({ + where: { + actorId: identifier, + deleted: { isNull: true }, + visibility: { in: ["public", "unlisted"] }, + ...(cursor == null || cursor === "" ? {} : { id: { lt: cursor } }), + }, + orderBy: { id: "desc" }, + limit: OUTBOX_PAGE_SIZE + 1, + }); + 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)!.id : null, + }; + }, + ) + .setFirstCursor(async (ctx, identifier) => + (await findActiveActor(db, ctx, identifier)) == null ? null : "", + ) + .setCounter( + async (ctx, identifier) => + (await findActiveActor(db, ctx, identifier))?.postsCount ?? null, + ); builder .setFollowersDispatcher( @@ -243,3 +303,60 @@ function toActorObject( } const logger = getLogger(["drfed", "graphql", "federation"]); + +const OUTBOX_PAGE_SIZE = 20; +type ObjectProps = ConstructorParameters[0]; +const objectConstructors: Record ASObject> = + { + Article: (props) => new Article(props), + Note: (props) => new Note(props), + }; + +function recipients( + ctx: Context, + object: ActivityPubObject, +): { tos: URL[]; ccs: URL[] } { + const followers = ctx.getFollowersUri(object.actorId); + switch (object.visibility) { + case "public": + return { tos: [PUBLIC_COLLECTION], ccs: [followers] }; + case "unlisted": + return { tos: [followers], ccs: [PUBLIC_COLLECTION] }; + case "followers": + return { tos: [followers], ccs: [] }; + default: + throw new Error( + `Unsupported visibility: ${object.visibility satisfies never}`, + ); + } +} + +function toObject(ctx: Context, object: ActivityPubObject): ASObject { + return objectConstructors[object.type]({ + id: new URL(object.iri), + attribution: ctx.getActorUri(object.actorId), + contents: [ + object.contentHtml, + ...(object.language == null + ? [] + : [new LanguageString(object.contentHtml, object.language)]), + ], + name: object.name, + summary: object.summary, + sensitive: object.sensitive, + published: Temporal.Instant.from(object.published.toISOString()), + updated: Temporal.Instant.from(object.updated.toISOString()), + url: object.url == null ? null : new URL(object.url), + ...recipients(ctx, object), + }); +} + +function toCreate(ctx: Context, object: ActivityPubObject): Create { + return new Create({ + id: new URL(`${object.iri}/activity`), + actor: ctx.getActorUri(object.actorId), + object: toObject(ctx, object), + published: Temporal.Instant.from(object.published.toISOString()), + ...recipients(ctx, object), + }); +} diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts new file mode 100644 index 0000000..dd13977 --- /dev/null +++ b/packages/graphql/src/object.test.ts @@ -0,0 +1,340 @@ +// 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 { describe, it } from "@logtape/testing-node/autoload"; +import { eq } from "drizzle-orm"; +import { v7 as uuid } from "uuid"; + +import { withTestHarness } from "./harness.test.ts"; +import { + globalId, + localActorId, + remoteActorId, + seedAuthenticatedLocalInstance, + seedLocalActor, + seedRemoteActor, +} from "./seed.test.ts"; + +const fields = `id uuid iri url type actor { uuid } visibility name summary contentHtml language sensitive published updated created`; +const mutation = `mutation Create($actor: ID!, $contentHtml: String!, $language: String, $type: ObjectType! = Note, $visibility: ObjectVisibility! = PUBLIC) { + createObject(actor: $actor, contentHtml: $contentHtml, language: $language, type: $type, visibility: $visibility) { + resultType: __typename + ... on Object { ${fields} } + ... on CreateObjectError { errorType: type message } + } +}`; +const variables = { + actor: globalId("Actor", localActorId), + contentHtml: "

Hello

", +}; + +describe("Mutation.createObject", () => { + it("creates verbatim HTML, canonicalizes language and resolves every node field", async () => { + await withTestHarness(async ({ db, post }) => { + 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.equal(object.visibility, "PUBLIC"); + assert.equal(object.language, "ko-KR"); + assert.equal(object.contentHtml, contentHtml); + assert.equal( + object.iri, + `https://test-instance.drfed.org/users/${localActorId}/objects/${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"); + assert.equal( + (await db.query.actors.findFirst({ where: { id: localActorId } })) + ?.postsCount, + 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 } }); + }); + }); + 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: new Date(0) }); + } + if (scenario === "deleted") { + await db + .update(schema.actors) + .set({ deleted: new Date() }) + .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 all visibilities 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: new Date(0) }) + .where(eq(schema.actors.id, localActorId)); + for (const visibility of ["PUBLIC", "UNLISTED", "FOLLOWERS"]) { + const query = mutation.replace( + "type: $type,", + 'name: "Title", summary: "CW", sensitive: true, type: $type,', + ); + const body = await ( + await post( + { query, variables: { ...variables, type: "Article", visibility } }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.createObject.type, "Article"); + assert.equal(body.data.createObject.visibility, visibility); + assert.equal(body.data.createObject.name, "Title"); + assert.equal(body.data.createObject.summary, "CW"); + assert.equal(body.data.createObject.sensitive, true); + } + assert.equal( + (await db.query.actors.findFirst({ where: { id: localActorId } })) + ?.postsCount, + 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 db.insert(schema.objects).values({ + id, + actorId: localActorId, + type: "Note", + visibility: "followers", + iri: `https://test-instance.drfed.org/users/${localActorId}/objects/${id}`, + contentHtml: "GraphQL debugging content", + }); + const query = `query($object: ID!, $actor: ID!) { + node(id: $object) { ... on Object { uuid visibility 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, + visibility: "FOLLOWERS", + 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 db.insert(schema.objects).values( + ids.map((id, index) => ({ + id, + actorId: index === 4 ? remoteActorId : localActorId, + type: "Note" as const, + iri: `https://test.example/objects/${id}`, + contentHtml: "test", + published: new Date(index === 0 ? "2027-01-01" : "2026-01-01"), + deleted: index === 3 ? new Date() : 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); + }); + }); +}); diff --git a/packages/graphql/src/object.ts b/packages/graphql/src/object.ts new file mode 100644 index 0000000..ca4d9c6 --- /dev/null +++ b/packages/graphql/src/object.ts @@ -0,0 +1,336 @@ +// 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 { schema } from "@drfed/models"; +import { objectTypeEnum } from "@drfed/models/schema"; +import { Object as ASObject } from "@fedify/vocab"; +import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; +import { and, eq, gt, isNotNull, isNull, sql } from "drizzle-orm"; +import { v7 as uuid, validate as validateUuid } from "uuid"; + +import { Actor } from "./actor.ts"; +import builder, { type DrFedObjectRef } from "./builder.ts"; + +const ObjectType = builder.enumType("ObjectType", { + values: objectTypeEnum.enumValues, +}); +const ObjectVisibility = builder.enumType("ObjectVisibility", { + values: { + PUBLIC: { value: "public" }, + UNLISTED: { value: "unlisted" }, + FOLLOWERS: { value: "followers" }, + } as const, +}); +const ObjectRef = builder.drizzleNode("objects", { + name: "Object", + description: "Represents an ActivityPub object authored by an `Actor`.", + id: { + column: ({ id }) => id, + description: "The Relay global ID of the object.", + }, + fields: (t) => ({ + uuid: t.expose("id", { + type: "UUID", + description: "The UUID of the object.", + }), + iri: t.exposeString("iri", { + description: "The canonical ActivityPub identifier of the 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.", + }), + visibility: t.expose("visibility", { + type: ObjectVisibility, + description: + "ActivityPub addressing policy. FOLLOWERS objects are not served over ActivityPub; GraphQL reads remain public.", + }), + 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; + +const objectsConnection = drizzleConnectionHelpers(builder, "objects", { + query: { + where: { deleted: { isNull: true } }, + orderBy: { published: "desc", id: "desc" }, + }, +}); +builder.drizzleObjectField("actors", "objects", (t) => + t.connection( + { + type: ActivityPubObject, + description: + "Non-deleted objects, newest publication first. All visibilities are 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, across all visibilities.", + 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.", + }), + visibility: t.arg({ + type: ObjectVisibility, + required: true, + defaultValue: "public", + description: + "ActivityPub addressing policy; this does not restrict GraphQL reads.", + }), + }, + async resolve( + _parent, + { actor: { id: actorId }, language, ...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, new Date()), + eq(schema.instanceMembers.accountId, account.id), + isNotNull(schema.instanceMembers.accepted), + ), + ) + .limit(1); + if (actor == null) return actorNotFound; + const id = uuid(); + const fedCtx = ctx.federation.createContext( + new URL(`https://${actor.host}`), + undefined, + ); + const iri = fedCtx.getObjectUri(ASObject, { + identifier: actorId, + id, + }).href; + const [object] = await tx + .insert(schema.objects) + .values({ + ...input, + name: normalizeOptionalText(input.name), + summary: normalizeOptionalText(input.summary), + id, + actorId, + iri, + language: canonicalLanguage, + }) + .returning(); + await tx + .update(schema.actors) + .set({ postsCount: sql`${schema.actors.postsCount} + 1` }) + .where(eq(schema.actors.id, actorId)); + if (object == null) { + throw new Error("Object insertion returned no row."); + } + return object; + }); + }, + }), +})); + +function normalizeOptionalText( + value: string | null | undefined, +): string | null { + return value == null || value.trim() === "" ? null : value; +} 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..3ab0a01 --- /dev/null +++ b/packages/graphql/src/seed.test.ts @@ -0,0 +1,134 @@ +// 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 { hashSecret } from "./auth/hash.ts"; + +export const accepted = new Date("2026-08-04T00:00:00.000Z"); +export const created = new Date("2026-08-04T00:00:00.000Z"); +export const expires = new Date("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"; +export const remoteActorId = "00000000-0000-4000-8000-000000000202"; +export const sessionId = "00000000-0000-4000-8000-000000000301"; +export const accessToken = "test-access-token"; + +export function globalId( + type: "Actor" | "Instance" | "Object", + 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 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, + }); +} + +export async function seedLocalInstance(db: Database): 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: "test-instance.drfed.org", + }) + .onConflictDoNothing(); +} + +export async function seedRemoteActor(db: Database): Promise { + await db.insert(schema.instances).values({ + id: remoteInstanceId, + created, + host: "remote.example.com", + }); + 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, + }); +} diff --git a/packages/models/drizzle/20260906133944_add_objects/migration.sql b/packages/models/drizzle/20260906133944_add_objects/migration.sql new file mode 100644 index 0000000..3eb0b0b --- /dev/null +++ b/packages/models/drizzle/20260906133944_add_objects/migration.sql @@ -0,0 +1,23 @@ +CREATE TYPE "object_type" AS ENUM('Article', 'Note');--> statement-breakpoint +CREATE TYPE "object_visibility" AS ENUM('public', 'unlisted', 'followers');--> statement-breakpoint +CREATE TABLE "objects" ( + "id" uuid PRIMARY KEY, + "actorId" uuid NOT NULL, + "type" "object_type" NOT NULL, + "iri" text NOT NULL UNIQUE, + "url" text, + "visibility" "object_visibility" DEFAULT 'public'::"object_visibility" NOT NULL, + "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 INDEX "object_actor_published_index" ON "objects" ("actorId","published" desc,"id" desc);--> statement-breakpoint +ALTER TABLE "objects" ADD CONSTRAINT "objects_actorId_actors_id_fkey" FOREIGN KEY ("actorId") REFERENCES "actors"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/packages/models/drizzle/20260906133944_add_objects/snapshot.json b/packages/models/drizzle/20260906133944_add_objects/snapshot.json new file mode 100644 index 0000000..7deecd3 --- /dev/null +++ b/packages/models/drizzle/20260906133944_add_objects/snapshot.json @@ -0,0 +1,1597 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "97f2c698-2361-4eae-b5c0-969ee1d2f8e7", + "prevIds": ["6b70d7c3-f645-4130-8f85-073c3204c78d"], + "ddl": [ + { + "values": ["Application", "Group", "Organization", "Person", "Service"], + "name": "actor_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Article", "Note"], + "name": "object_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["public", "unlisted", "followers"], + "name": "object_visibility", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instance_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "login_tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "objects", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "max_instances", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "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": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "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": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followersUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followingUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "featuredUrl", + "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": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "postsCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "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_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeHash", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "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": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "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": "object_visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "visibility", + "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": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_instance_index", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "accountId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_accountId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_instanceId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": 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": ["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": ["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_tokens" + }, + { + "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": ["instanceId", "accountId"], + "nameExplicit": false, + "name": "instance_members_pkey", + "entityType": "pks", + "schema": "public", + "table": "instance_members" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "accounts_pkey", + "schema": "public", + "table": "accounts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_actors_pkey", + "schema": "public", + "table": "local_actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_instances_pkey", + "schema": "public", + "table": "local_instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "login_tokens_pkey", + "schema": "public", + "table": "login_tokens", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "objects_pkey", + "schema": "public", + "table": "objects", + "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": 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": ["iri"], + "nullsNotDistinct": false, + "name": "actors_iri_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": ["tokenHash"], + "nullsNotDistinct": false, + "name": "login_tokens_tokenHash_key", + "schema": "public", + "table": "login_tokens", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["iri"], + "nullsNotDistinct": false, + "name": "objects_iri_key", + "schema": "public", + "table": "objects", + "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/src/relations.ts b/packages/models/src/relations.ts index 3ec17e1..fc2d2b9 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -98,7 +98,15 @@ export const relations = defineRelations(schema, (r) => ({ optional: false, }), }, + objects: { + actor: r.one.actors({ + from: r.objects.actorId, + to: r.actors.id, + optional: false, + }), + }, actors: { + objects: r.many.objects({ from: r.actors.id, to: r.objects.actorId }), instance: r.one.instances({ from: r.actors.instanceId, to: r.instances.id, diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index d40166d..7bd9955 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { sql } from "drizzle-orm"; +import { desc, sql } from "drizzle-orm"; import { type AnyPgColumn, boolean, @@ -303,3 +303,56 @@ 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]; +export const objectVisibilityEnum = pgEnum("object_visibility", [ + "public", + "unlisted", + "followers", +]); +export type ObjectVisibility = (typeof objectVisibilityEnum.enumValues)[number]; + +/** ActivityPub objects authored by actors. */ +export const objects = pgTable( + "objects", + { + id: uuid().primaryKey(), + actorId: uuid() + .notNull() + .references(() => actors.id, { onDelete: "cascade" }), + type: objectTypeEnum().notNull(), + iri: text().notNull().unique(), + url: text(), + visibility: objectVisibilityEnum().notNull().default("public"), + name: text(), + summary: text(), + contentHtml: text().notNull(), + language: varchar({ length: 35 }), + sensitive: boolean().notNull().default(false), + published: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), + updated: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp) + .$onUpdate(() => currentTimestamp), + created: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), + deleted: timestamp({ withTimezone: true }), + }, + (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; From 70c10a5643b70067d2c1be9eec910782f654ed19 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 6 Sep 2026 23:50:08 +0900 Subject: [PATCH 02/20] Ignore local mise configuration Keep the pre-existing mise.local.toml ignore rule separate from the ActivityPub object feature. AI provenance: The user requested a Fable review loop. Codex reviewed and committed this existing working-tree change separately after Fable noted that it was unrelated to the feature. Codex did not author the rule. No human manual verification was confirmed. Repository checks passed. Assisted-by: Codex:gpt-6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 05071e0..54c1941 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ index.db # Agent skills from npm packages (managed by skills-npm) **/skills/npm-* +mise.local.toml From af600b6b6a7643050df44cfc246069e57061c231 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 8 Sep 2026 15:02:54 +0900 Subject: [PATCH 03/20] Cleanup AI genned - Rename `/users//objects/` to `/users//` - Rename `ASObject` to `APObject` --- packages/graphql/src/federation.test.ts | 12 ++++++------ packages/graphql/src/federation.ts | 14 +++++++------- packages/graphql/src/object.test.ts | 6 +++--- packages/graphql/src/object.ts | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 3eadec3..017389f 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -21,7 +21,7 @@ import createFederation, { buildFederation } from "@drfed/graphql/federation"; import { schema } from "@drfed/models"; import { uuidV7 } from "@drfed/models/uuid"; import { MemoryKvStore } from "@fedify/fedify"; -import { Object as ASObject } from "@fedify/vocab"; +import { Object as APObject } from "@fedify/vocab"; import { describe, it } from "@logtape/testing-node/autoload"; import { eq } from "drizzle-orm"; import { v7 as uuid } from "uuid"; @@ -45,8 +45,8 @@ describe("createFederation()", () => { }); const ctx = federation.createContext(origin, undefined); assert.equal( - ctx.getObjectUri(ASObject, { identifier: "a", id: "b" }).href, - "https://drfed.test/users/a/objects/b", + ctx.getObjectUri(APObject, { identifier: "a", id: "b" }).href, + "https://drfed.test/users/a/b", ); assert.equal( ctx.getActorUri("identifier").href, @@ -188,7 +188,7 @@ function values(id: string) { return { id, actorId: localActorId, - iri: `${actorIri}/objects/${id}`, + iri: `${actorIri}/${id}`, type: "Note" as const, contentHtml: "

Hello

", }; @@ -302,9 +302,9 @@ describe("ActivityPub objects", () => { } const iri = scenario === "missing" - ? `${actorIri}/objects/${uuid()}` + ? `${actorIri}/${uuid()}` : scenario === "malformed" - ? `${actorIri}/objects/bad` + ? `${actorIri}/bad` : scenario === "host" ? object.iri.replace("test-instance.drfed.org", "wrong.example") : scenario === "actor" || scenario === "remote" diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index d63e17e..3d4d825 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -29,7 +29,7 @@ import { createFederationBuilder, } from "@fedify/fedify"; import { - Object as ASObject, + Object as APObject, Activity, Application, Article, @@ -142,9 +142,9 @@ export function buildFederation(db: Database): FederationBuilder { }); }); - builder.setObjectDispatcher( - ASObject, - "/users/{identifier}/objects/{id}", + builder.setObjectDispatcher( + APObject, + "/users/{identifier}/{id}", async (ctx, { identifier, id }) => { if (!validateUuid(identifier) || !validateUuid(id)) return null; const object = await db.query.objects.findFirst({ @@ -161,7 +161,7 @@ export function buildFederation(db: Database): FederationBuilder { if (object == null || object.visibility === "followers") return null; if (object.deleted != null) { return new Tombstone({ - id: ctx.getObjectUri(ASObject, { identifier, id }), + id: ctx.getObjectUri(APObject, { identifier, id }), deleted: Temporal.Instant.from(object.deleted.toISOString()), }); } @@ -306,7 +306,7 @@ const logger = getLogger(["drfed", "graphql", "federation"]); const OUTBOX_PAGE_SIZE = 20; type ObjectProps = ConstructorParameters[0]; -const objectConstructors: Record ASObject> = +const objectConstructors: Record APObject> = { Article: (props) => new Article(props), Note: (props) => new Note(props), @@ -331,7 +331,7 @@ function recipients( } } -function toObject(ctx: Context, object: ActivityPubObject): ASObject { +function toObject(ctx: Context, object: ActivityPubObject): APObject { return objectConstructors[object.type]({ id: new URL(object.iri), attribution: ctx.getActorUri(object.actorId), diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts index dd13977..69f9f8c 100644 --- a/packages/graphql/src/object.test.ts +++ b/packages/graphql/src/object.test.ts @@ -71,7 +71,7 @@ describe("Mutation.createObject", () => { assert.equal(object.contentHtml, contentHtml); assert.equal( object.iri, - `https://test-instance.drfed.org/users/${localActorId}/objects/${object.uuid}`, + `https://test-instance.drfed.org/users/${localActorId}/${object.uuid}`, ); assert.equal(object.sensitive, false); assert.equal(object.url, null); @@ -239,7 +239,7 @@ describe("Actor.objects", () => { actorId: localActorId, type: "Note", visibility: "followers", - iri: `https://test-instance.drfed.org/users/${localActorId}/objects/${id}`, + iri: `https://test-instance.drfed.org/users/${localActorId}/${id}`, contentHtml: "GraphQL debugging content", }); const query = `query($object: ID!, $actor: ID!) { @@ -282,7 +282,7 @@ describe("Actor.objects", () => { id, actorId: index === 4 ? remoteActorId : localActorId, type: "Note" as const, - iri: `https://test.example/objects/${id}`, + iri: `https://test.example/${id}`, contentHtml: "test", published: new Date(index === 0 ? "2027-01-01" : "2026-01-01"), deleted: index === 3 ? new Date() : null, diff --git a/packages/graphql/src/object.ts b/packages/graphql/src/object.ts index ca4d9c6..429a82a 100644 --- a/packages/graphql/src/object.ts +++ b/packages/graphql/src/object.ts @@ -16,7 +16,7 @@ import { schema } from "@drfed/models"; import { objectTypeEnum } from "@drfed/models/schema"; -import { Object as ASObject } from "@fedify/vocab"; +import { Object as APObject } from "@fedify/vocab"; import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import { and, eq, gt, isNotNull, isNull, sql } from "drizzle-orm"; import { v7 as uuid, validate as validateUuid } from "uuid"; @@ -300,7 +300,7 @@ builder.mutationFields((t) => ({ new URL(`https://${actor.host}`), undefined, ); - const iri = fedCtx.getObjectUri(ASObject, { + const iri = fedCtx.getObjectUri(APObject, { identifier: actorId, id, }).href; From 278001d24353d3ca7d2516a9f7f18bac59766d9c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 11 Sep 2026 18:10:12 +0900 Subject: [PATCH 04/20] Fix type errors reported by mise run check Brand the objects.id and objects.actorId columns with the Uuid type in @drfed/models so that Drizzle queries on the objects table require Uuid values instead of plain strings. Add validateUuid to @drfed/models/uuid as a type guard and route all UUID generation and validation in @drfed/graphql through that module instead of importing uuid directly. Cast the actor identifier in the outbox dispatcher and the test fixture values to Uuid, and declare the seed actor identifiers as const so they satisfy the branded column types. Also clarify the description of the GraphQL Object.uuid field. AI provenance: The human user authored and verified every code change in this commit. AI assistance was limited to analyzing the error messages emitted by mise run check and to drafting this commit message. Assisted-by: Claude Code:claude-fable-5-1 --- packages/graphql/src/federation.test.ts | 15 +++++++-------- packages/graphql/src/federation.ts | 5 ++--- packages/graphql/src/object.test.ts | 2 +- packages/graphql/src/object.ts | 4 ++-- packages/graphql/src/seed.test.ts | 4 ++-- packages/models/src/schema.ts | 3 ++- packages/models/src/uuid.ts | 9 ++++++++- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 017389f..6c675a8 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -19,12 +19,11 @@ 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 { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid"; import { MemoryKvStore } from "@fedify/fedify"; import { Object as APObject } from "@fedify/vocab"; import { describe, it } from "@logtape/testing-node/autoload"; import { eq } from "drizzle-orm"; -import { v7 as uuid } from "uuid"; import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts"; import { @@ -82,10 +81,10 @@ 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 localActorId = uuid(); + const actorId = uuid(); const base = "https://demo.drfed.test"; await db.insert(schema.localInstances).values({ id: localInstanceId, @@ -186,8 +185,8 @@ const accept = { accept: "application/activity+json" }; function values(id: string) { return { - id, - actorId: localActorId, + id: id as Uuid, + actorId: localActorId as Uuid, iri: `${actorIri}/${id}`, type: "Note" as const, contentHtml: "

Hello

", diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 3d4d825..9fd405a 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -20,7 +20,7 @@ import type { Actor, ObjectType, } from "@drfed/models/schema"; -import type { Uuid } from "@drfed/models/uuid"; +import { type Uuid, validateUuid } from "@drfed/models/uuid"; import { type Context, type Federation, @@ -46,7 +46,6 @@ import { Tombstone, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; -import { validate as validateUuid } from "uuid"; import { canonicalizeAuthority } from "./origin.ts"; @@ -178,7 +177,7 @@ export function buildFederation(db: Database): FederationBuilder { } const rows = await db.query.objects.findMany({ where: { - actorId: identifier, + actorId: identifier as Uuid, deleted: { isNull: true }, visibility: { in: ["public", "unlisted"] }, ...(cursor == null || cursor === "" ? {} : { id: { lt: cursor } }), diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts index 69f9f8c..5bcbaac 100644 --- a/packages/graphql/src/object.test.ts +++ b/packages/graphql/src/object.test.ts @@ -21,9 +21,9 @@ import assert from "node:assert/strict"; import { schema } from "@drfed/models"; +import { uuidV7 as uuid } from "@drfed/models/uuid"; import { describe, it } from "@logtape/testing-node/autoload"; import { eq } from "drizzle-orm"; -import { v7 as uuid } from "uuid"; import { withTestHarness } from "./harness.test.ts"; import { diff --git a/packages/graphql/src/object.ts b/packages/graphql/src/object.ts index 429a82a..e4f25d7 100644 --- a/packages/graphql/src/object.ts +++ b/packages/graphql/src/object.ts @@ -16,10 +16,10 @@ import { schema } from "@drfed/models"; import { objectTypeEnum } from "@drfed/models/schema"; +import { uuidV7 as uuid, validateUuid } from "@drfed/models/uuid"; import { Object as APObject } from "@fedify/vocab"; import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import { and, eq, gt, isNotNull, isNull, sql } from "drizzle-orm"; -import { v7 as uuid, validate as validateUuid } from "uuid"; import { Actor } from "./actor.ts"; import builder, { type DrFedObjectRef } from "./builder.ts"; @@ -44,7 +44,7 @@ const ObjectRef = builder.drizzleNode("objects", { fields: (t) => ({ uuid: t.expose("id", { type: "UUID", - description: "The UUID of the object.", + description: "The UUID of the ActivityPub Object.", }), iri: t.exposeString("iri", { description: "The canonical ActivityPub identifier of the object.", diff --git a/packages/graphql/src/seed.test.ts b/packages/graphql/src/seed.test.ts index 3ab0a01..265b5c2 100644 --- a/packages/graphql/src/seed.test.ts +++ b/packages/graphql/src/seed.test.ts @@ -26,8 +26,8 @@ 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"; -export const remoteActorId = "00000000-0000-4000-8000-000000000202"; +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"; diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 7bd9955..cf23176 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -317,8 +317,9 @@ export type ObjectVisibility = (typeof objectVisibilityEnum.enumValues)[number]; export const objects = pgTable( "objects", { - id: uuid().primaryKey(), + id: uuid().$type().primaryKey(), actorId: uuid() + .$type() .notNull() .references(() => actors.id, { onDelete: "cascade" }), type: objectTypeEnum().notNull(), 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); From 4c39888970554c08e53604538c1e24f13da14043 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 12 Sep 2026 22:01:30 +0900 Subject: [PATCH 05/20] Sort .gitignore https://github.com/fedify-dev/drfed/pull/73#discussion_r3996175902 --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 54c1941..3cdfb96 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __generated__/ .pgdata/ +mise.local.toml node_modules/ packages/*/dist/ @@ -13,4 +14,3 @@ index.db # Agent skills from npm packages (managed by skills-npm) **/skills/npm-* -mise.local.toml From 1f88ebe17449d106b23afd6bd295f11b7ec1b88a Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 14 Sep 2026 11:17:58 +0900 Subject: [PATCH 06/20] Add tests hiding deleted nodes from node and nodes Actor.objects already filters out objects whose deleted timestamp is set, but Query.node and Query.nodes still resolve deleted Actor and Object rows by primary key, including their relations. Add tests that mark one row deleted and expect node to return null and nodes to return null at that position, while a live sibling row keeps resolving. The tests currently fail on purpose: they define the expected contract for a follow-up change that adds a deleted IS NULL condition to the Actor and Object drizzleNode loaders. LocalActor is not covered because the localActors table has no deleted column. The tests were generated by Claude Code at the user's direction to address the review comment below, and the user reviewed the result. https://github.com/fedify-dev/drfed/pull/73#discussion_r3998602698 Assisted-by: Claude Code:claude-fable-5-1 --- packages/graphql/src/actor.test.ts | 34 +++++++++++++++++++++++- packages/graphql/src/object.test.ts | 41 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index 9d1d15f..e714976 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -20,7 +20,7 @@ import assert from "node:assert/strict"; import { schema } from "@drfed/models"; import { describe, it } from "@logtape/testing-node/autoload"; -import { eq } from "drizzle-orm/sql/expressions"; +import { eq } from "drizzle-orm"; import { withTestHarness } from "./harness.test.ts"; import { @@ -293,4 +293,36 @@ describe("Actor", () => { }); }); }); + + 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: new Date() }) + .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], + }, + }); + }); + }); }); diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts index 5bcbaac..d775f21 100644 --- a/packages/graphql/src/object.test.ts +++ b/packages/graphql/src/object.test.ts @@ -338,3 +338,44 @@ describe("Actor.objects", () => { }); }); }); + +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 db.insert(schema.objects).values( + [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 ? new Date() : 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], + }, + }); + }); + }); +}); From 45d546d4abccedb81b07ee9ec64a8ac80f494983 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 14 Sep 2026 12:01:52 +0900 Subject: [PATCH 07/20] Filter deleted records https://github.com/fedify-dev/drfed/pull/73#discussion_r3998602698 --- .oxlintrc.json | 2 +- packages/graphql/src/builder.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index 5b98d3d..e788bd1 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -158,6 +158,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 +185,14 @@ export const builder = new SchemaBuilder({ }, }); +const filterDeleted = (node: unknown): unknown => + node != null && + typeof node === "object" && + "deleted" in node && + node.deleted != null + ? 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 From be79726327bf25ef180783e6fa0f2cb0c93ff780 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 14 Sep 2026 13:08:26 +0900 Subject: [PATCH 08/20] Add Create Dispatcher https://github.com/fedify-dev/drfed/pull/73#discussion_r3998675851 --- packages/graphql/src/federation.ts | 38 ++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 9fd405a..a35bbc4 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -167,6 +167,34 @@ export function buildFederation(db: Database): FederationBuilder { return toObject(ctx, object); }, ); + + builder.setObjectDispatcher( + Create, + "/ap/creates/{id}", + async (ctx, { id }) => { + if (!validateUuid(id)) return null; + const object = await db.query.objects.findFirst({ + where: { + id, + actor: { + localId: { isNotNull: true }, + deleted: { isNull: true }, + instance: { host: ctx.host }, + }, + }, + }); + if ( + object == null || + object.visibility === "followers" || + object.deleted != null + ) { + return null; + } + + return toCreate(ctx, object); + }, + ); + builder .setOutboxDispatcher( "/users/{identifier}/outbox", @@ -343,8 +371,8 @@ function toObject(ctx: Context, object: ActivityPubObject): APObject { name: object.name, summary: object.summary, sensitive: object.sensitive, - published: Temporal.Instant.from(object.published.toISOString()), - updated: Temporal.Instant.from(object.updated.toISOString()), + published: object.published.toTemporalInstant(), + updated: object.updated.toTemporalInstant(), url: object.url == null ? null : new URL(object.url), ...recipients(ctx, object), }); @@ -352,10 +380,10 @@ function toObject(ctx: Context, object: ActivityPubObject): APObject { function toCreate(ctx: Context, object: ActivityPubObject): Create { return new Create({ - id: new URL(`${object.iri}/activity`), + id: ctx.getObjectUri(Create, { id: object.id }), actor: ctx.getActorUri(object.actorId), - object: toObject(ctx, object), - published: Temporal.Instant.from(object.published.toISOString()), ...recipients(ctx, object), + object: new URL(object.iri), + published: object.published.toTemporalInstant(), }); } From 4b5e83662be7c3ea0ef77cdb6bd5922adb13dc9b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 14 Sep 2026 13:19:36 +0900 Subject: [PATCH 09/20] Test the Create activity dispatcher The outbox previously emitted Create activities whose id pointed at a URL that nothing served, so dereferencing it returned 404. The Create object dispatcher registered at /ap/creates/{id} in the previous commit fixes that; this commit covers it with tests and updates the outbox expectations to the new id and to the object being referenced by IRI instead of embedded. - Assert the Create URI layout from Context.getObjectUri(). - Dereference Create activities for public and unlisted objects and check their type, id, actor, object, and recipients. - Reject followers-only, deleted, missing, malformed, remote-actor, deleted-actor, and wrong-host requests with 404, matching the object dispatcher. Addresses the review comment at https://github.com/fedify-dev/drfed/pull/73#discussion_r3998675851 The test changes were written by an AI assistant following the reviewer's comment and the hackerspub implementation as a reference, and were verified by the human author by running mise run test and mise run check. Assisted-by: Claude Code:claude-fable-5-1 --- packages/graphql/src/federation.test.ts | 110 ++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 5 deletions(-) diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 6c675a8..45a3f43 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -21,7 +21,7 @@ import createFederation, { buildFederation } from "@drfed/graphql/federation"; import { schema } from "@drfed/models"; import { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid"; import { MemoryKvStore } from "@fedify/fedify"; -import { Object as APObject } from "@fedify/vocab"; +import { Object as APObject, Create } from "@fedify/vocab"; import { describe, it } from "@logtape/testing-node/autoload"; import { eq } from "drizzle-orm"; @@ -47,6 +47,10 @@ describe("createFederation()", () => { 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", @@ -181,6 +185,8 @@ 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) { @@ -319,6 +325,100 @@ describe("ActivityPub objects", () => { } }); +describe("ActivityPub Create activities", () => { + for (const visibility of ["public", "unlisted"] as const) { + it(`serves Create activities for ${visibility} objects`, async () => { + await withTestHarness(async ({ db, federation }) => { + await seedLocalActor(db); + const object = values(uuid()); + await db.insert(schema.objects).values({ ...object, visibility }); + const response = await federation.fetch( + new Request(createIri(object.id), { 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.id), + actor: actorIri, + object: object.iri, + to: visibility === "public" ? "as:Public" : `${actorIri}/followers`, + cc: visibility === "public" ? `${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 db.insert(schema.objects).values({ + ...object, + actorId: scenario === "remote" ? remoteActorId : localActorId, + visibility: scenario === "followers" ? "followers" : "public", + deleted: scenario === "deleted" ? new Date() : null, + }); + if (scenario === "deletedActor") { + await db + .update(schema.actors) + .set({ deleted: new Date() }) + .where(eq(schema.actors.id, localActorId)); + } + const iri = + scenario === "missing" + ? createIri(uuid()) + : scenario === "malformed" + ? createIri("bad") + : createIri(object.id); + 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 db.insert(schema.objects).values(object); + const response = await federation.fetch( + new Request( + createIri(object.id).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 () => { await withTestHarness(async ({ db, federation }) => { @@ -359,22 +459,22 @@ describe("ActivityPub outbox", () => { type: activity.type, id: activity.id, actor: activity.actor, - objectId: activity.object.id, + object: activity.object, to: activity.to, cc: activity.cc, }, { type: "Create", - id: `${values(ids[20]!).iri}/activity`, + id: createIri(ids[20]!), actor: actorIri, - objectId: values(ids[20]!).iri, + 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.id, values(ids[0]!).iri); + 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 }), From f5c54e68d6ddf9b19f606bc61762244920e8d64f Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 15 Sep 2026 22:04:10 +0900 Subject: [PATCH 10/20] ActivityPub addressing preservation and Create activity persistence Replace object classification enums with a canonical resource registry, ordered addressing occurrences, actor collections, observed memberships, and independently persisted Create activities. Backfill existing data while preserving previously published Create IRIs. Expose stored data and versioned expected classifications via GraphQL, and use explicit Public addressing for ActivityPub serving and outbox page/count selection. The user provided two PR [#73](https://github.com/fedify-dev/drfed/issues/73) implementation plans and explicitly requested persisted Create activities. Codex implemented the models, migrations, GraphQL and federation changes, and regression tests according to the plans. The user also requested an independent Claude Fable 5 review loop. Codex applied verified review fixes for resource lock contention, shared collection migrations and role references, nullable remote outboxes, and timestamp-based outbox pagination using full database precision. Claude Code reviewed the entire changes using claude-fable-5. The user read and verified the code, and directly ran `mise test` and `mise check` to confirm they passed. Assisted-by: Codex:gpt-6 Assisted-by: Claude Code:claude-fable-5 --- mise.toml | 1 + packages/graphql/package.json | 5 + packages/graphql/src/actor.test.ts | 46 +- packages/graphql/src/actor.ts | 127 +- packages/graphql/src/builder.ts | 35 +- packages/graphql/src/classification.test.ts | 99 + packages/graphql/src/classification.ts | 99 + packages/graphql/src/federation.test.ts | 151 +- packages/graphql/src/federation.ts | 332 ++- packages/graphql/src/object.test.ts | 42 +- packages/graphql/src/object.ts | 223 +- packages/graphql/src/resource.ts | 192 ++ packages/graphql/src/seed.test.ts | 130 +- .../migration.sql | 137 + .../snapshot.json | 2233 ++++++++++++++++ .../migration.sql | 26 + .../snapshot.json | 2330 +++++++++++++++++ packages/models/package.json | 5 + packages/models/src/index.ts | 1 + packages/models/src/relations.ts | 129 + packages/models/src/resource.test.ts | 352 +++ packages/models/src/resource.ts | 134 + packages/models/src/schema.ts | 199 +- .../workspace/create/[instance_id]/actors.tsx | 1 + 24 files changed, 6783 insertions(+), 246 deletions(-) create mode 100644 packages/graphql/src/classification.test.ts create mode 100644 packages/graphql/src/classification.ts create mode 100644 packages/graphql/src/resource.ts create mode 100644 packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/migration.sql create mode 100644 packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/snapshot.json create mode 100644 packages/models/drizzle/20260915125322_add_actor_collection_references/migration.sql create mode 100644 packages/models/drizzle/20260915125322_add_actor_collection_references/snapshot.json create mode 100644 packages/models/src/resource.test.ts create mode 100644 packages/models/src/resource.ts diff --git a/mise.toml b/mise.toml index ce2f680..315ddc3 100644 --- a/mise.toml +++ b/mise.toml @@ -5,6 +5,7 @@ experimental = true [tools] "aqua:dahlia/hongdown" = "0.4.3" +claude-code = "latest" "github:nushell/nushell" = "0.114.1" node = "26" "npm:@fedify/cli" = { version = "2.4.0-dev.1758", allow_low_downloads = true } diff --git a/packages/graphql/package.json b/packages/graphql/package.json index b680a2e..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" @@ -89,6 +93,7 @@ "src/account.ts", "src/actor.ts", "src/builder.ts", + "src/classification.ts", "src/federation.ts", "src/instance.ts", "src/schema.ts", diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index e714976..d24efb6 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -77,13 +77,13 @@ const actorQuery = ` header } inboxUrl - outboxUrl + outbox { iri } avatarUrl - followersUrl - followingUrl + followers { iri } + following { iri } headerUrl profileUrl - featuredUrl + featured { iri } created } } @@ -192,15 +192,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, @@ -241,13 +233,21 @@ 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`, + featured: { + iri: `https://test-instance.drfed.org/users/${localActorId}/featured`, + }, created: created.toISOString(), }, }, @@ -280,13 +280,17 @@ 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", + featured: { iri: "https://remote.example.com/users/bob/featured" }, created: created.toISOString(), }, }, diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index 7774d63..722d497 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -16,7 +16,10 @@ // oxlint-disable max-lines-per-function eslint/max-lines -import { schema } from "@drfed/models"; +// Keep dependent database writes and observations sequential. +// oxlint-disable no-await-in-loop + +import { promoteResource, schema } from "@drfed/models"; import { actorTypeEnum } from "@drfed/models/schema"; import { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid"; import type { Context } from "@fedify/fedify"; @@ -26,6 +29,7 @@ import { and, eq, gt, isNotNull } from "drizzle-orm/sql/expressions"; import builder, { type DrFedObjectRef } from "./builder.ts"; import { Instance } from "./instance.ts"; +import { Collection, Resource } from "./resource.ts"; const ActorType = builder.enumType("ActorType", { values: actorTypeEnum.enumValues, @@ -37,6 +41,7 @@ const ACTOR_TYPES_DOC = actorTypeEnum.enumValues const ActorRef = builder.drizzleNode("actors", { name: "Actor", + interfaces: [Resource], description: "Represents an `Actor` in the DrFed platform.", id: { column: ({ id }) => id, @@ -47,9 +52,6 @@ const ActorRef = builder.drizzleNode("actors", { 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 +79,52 @@ 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, + select: { columns: { id: true } }, + resolve: async (actor, _, ctx) => { + const reference = + await ctx.db.query.actorCollectionReferences.findFirst({ + where: { actorId: actor.id, role: "outbox" }, + with: { collection: true }, + }); + const collection = reference?.collection; + return collection ?? null; + }, }), 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: async (actor, _, ctx) => { + const reference = + await ctx.db.query.actorCollectionReferences.findFirst({ + where: { actorId: actor.id, role: "followers" }, + with: { collection: true }, + }); + const collection = reference?.collection; + return collection ?? null; + }, }), - 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: async (actor, _, ctx) => { + const reference = + await ctx.db.query.actorCollectionReferences.findFirst({ + where: { actorId: actor.id, role: "following" }, + with: { collection: true }, + }); + const collection = reference?.collection; + return collection ?? null; + }, }), headerUrl: t.expose("headerUrl", { type: "URL", @@ -106,10 +136,19 @@ 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: async (actor, _, ctx) => { + const reference = + await ctx.db.query.actorCollectionReferences.findFirst({ + where: { actorId: actor.id, role: "featured" }, + with: { collection: true }, + }); + const collection = reference?.collection; + return collection ?? null; + }, }), created: t.expose("created", { type: "DateTime", @@ -299,12 +338,51 @@ 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, + role, + }); + await inner.insert(schema.actorCollectionReferences).values({ + actorId: actor.id, + role, + collectionId: resource.id, + }); + }, + ); + } + createdActors.push(actor); + } return { actors: createdActors }; }); }, @@ -323,12 +401,7 @@ function generateActor( 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, }; } diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index e788bd1..faa6fdd 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -32,7 +32,13 @@ 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 { + DateTimeResolver, + JSONResolver, + URLResolver, + UUIDResolver, +} from "graphql-scalars"; /** * The context data for the GraphQL server, which includes the incoming request @@ -97,6 +103,7 @@ export interface UserContext extends ServerContext { export interface SchemaTypes { Context: UserContext; Scalars: { + JSON: { Input: unknown; Output: unknown }; DateTime: { Input: Date; Output: Date; @@ -114,7 +121,7 @@ export interface SchemaTypes { Output: Template; }; URL: { - Input: URL; + Input: string; Output: string; }; }; @@ -227,7 +234,29 @@ async function isLocalInstanceMember( } builder.addScalarType("DateTime", DateTimeResolver); -builder.addScalarType("URL", URLResolver); +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..e57a69c --- /dev/null +++ b/packages/graphql/src/classification.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 . + +// 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.`, + }; +} diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 45a3f43..4a6d4cc 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -14,22 +14,28 @@ // 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 { 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 } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts"; import { localActorId, remoteActorId, + seedActors, seedLocalActor, + seedObjects, seedRemoteActor, } from "./seed.test.ts"; @@ -87,7 +93,7 @@ describe("createFederation()", () => { await withTemporaryDatabase(async (db) => { const localInstanceId = uuid(); const instanceId = uuid(); - const localActorId = uuid(); + const localId = uuid(); const actorId = uuid(); const base = "https://demo.drfed.test"; await db.insert(schema.localInstances).values({ @@ -100,16 +106,15 @@ describe("createFederation()", () => { 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, { @@ -200,14 +205,17 @@ function values(id: string) { } describe("ActivityPub objects", () => { - for (const visibility of ["public", "unlisted"] as const) { - it(`serves ${visibility} objects with contentMap and recipients`, async () => { + 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 db.insert(schema.objects).values({ + await seedObjects(db, { ...object, - visibility, + addressing: { + [publicProperty]: [PUBLIC_IRI], + [publicProperty === "to" ? "cc" : "to"]: [`${actorIri}/followers`], + }, language: "ko-KR", name: "Title", summary: "CW", @@ -231,11 +239,11 @@ describe("ActivityPub objects", () => { assert.ok(body.updated); assert.equal( body.to, - visibility === "public" ? "as:Public" : `${actorIri}/followers`, + publicProperty === "to" ? "as:Public" : `${actorIri}/followers`, ); assert.equal( body.cc, - visibility === "public" ? `${actorIri}/followers` : "as:Public", + publicProperty === "to" ? `${actorIri}/followers` : "as:Public", ); }); }); @@ -245,9 +253,15 @@ describe("ActivityPub objects", () => { await withTestHarness(async ({ db, federation }) => { await seedLocalActor(db); const object = values(uuid()); - await db - .insert(schema.objects) - .values({ ...object, visibility: "followers", deleted }); + 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 }, @@ -260,7 +274,7 @@ describe("ActivityPub objects", () => { await withTestHarness(async ({ db, federation }) => { await seedLocalActor(db); const object = values(uuid()); - await db.insert(schema.objects).values({ ...object, type: "Article" }); + await seedObjects(db, { ...object, type: "Article" }); const response = await federation.fetch( new Request(object.iri, { headers: accept }), { contextData: undefined }, @@ -295,7 +309,7 @@ describe("ActivityPub objects", () => { await seedLocalActor(db); await seedRemoteActor(db); const object = values(uuid()); - await db.insert(schema.objects).values({ + await seedObjects(db, { ...object, actorId: scenario === "remote" ? remoteActorId : localActorId, }); @@ -326,12 +340,18 @@ describe("ActivityPub objects", () => { }); describe("ActivityPub Create activities", () => { - for (const visibility of ["public", "unlisted"] as const) { - it(`serves Create activities for ${visibility} objects`, async () => { + 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 db.insert(schema.objects).values({ ...object, visibility }); + await seedObjects(db, { + ...object, + addressing: { + [publicProperty]: [PUBLIC_IRI], + [publicProperty === "to" ? "cc" : "to"]: [`${actorIri}/followers`], + }, + }); const response = await federation.fetch( new Request(createIri(object.id), { headers: accept }), { contextData: undefined }, @@ -352,8 +372,8 @@ describe("ActivityPub Create activities", () => { id: createIri(object.id), actor: actorIri, object: object.iri, - to: visibility === "public" ? "as:Public" : `${actorIri}/followers`, - cc: visibility === "public" ? `${actorIri}/followers` : "as:Public", + to: publicProperty === "to" ? "as:Public" : `${actorIri}/followers`, + cc: publicProperty === "to" ? `${actorIri}/followers` : "as:Public", }, ); assert.ok(body.published); @@ -373,10 +393,13 @@ describe("ActivityPub Create activities", () => { await seedLocalActor(db); await seedRemoteActor(db); const object = values(uuid()); - await db.insert(schema.objects).values({ + await seedObjects(db, { ...object, actorId: scenario === "remote" ? remoteActorId : localActorId, - visibility: scenario === "followers" ? "followers" : "public", + addressing: + scenario === "followers" + ? { to: [`${actorIri}/followers`] } + : { to: [PUBLIC_IRI] }, deleted: scenario === "deleted" ? new Date() : null, }); if (scenario === "deletedActor") { @@ -403,7 +426,7 @@ describe("ActivityPub Create activities", () => { await withTestHarness(async ({ db, federation }) => { await seedLocalActor(db); const object = values(uuid()); - await db.insert(schema.objects).values(object); + await seedObjects(db, object); const response = await federation.fetch( new Request( createIri(object.id).replace( @@ -421,18 +444,20 @@ describe("ActivityPub Create activities", () => { 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()); - await db.insert(schema.objects).values( + await seedObjects( + db, ids.map((id, index) => ({ ...values(id), - visibility: + addressing: index === 22 - ? ("followers" as const) + ? { to: [`${actorIri}/followers`] } : index === 20 - ? ("unlisted" as const) - : ("public" as const), + ? { to: [`${actorIri}/followers`], cc: [PUBLIC_IRI] } + : { to: [PUBLIC_IRI] }, deleted: index === 21 ? new Date() : null, })), ); @@ -450,7 +475,7 @@ describe("ActivityPub outbox", () => { }; const collection = await fetchJson(`${actorIri}/outbox`); assert.equal(collection.type, "OrderedCollection"); - assert.equal(collection.totalItems, 23); + assert.equal(collection.totalItems, 21); const page = await fetchJson(`${actorIri}/outbox?cursor=`); assert.equal(page.orderedItems.length, 20); const activity = page.orderedItems[0]; @@ -481,6 +506,70 @@ describe("ActivityPub outbox", () => { { 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 () => { diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index a35bbc4..0c6bc14 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -14,11 +14,15 @@ // 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 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 { @@ -40,12 +44,12 @@ import { LanguageString, Note, Organization, - PUBLIC_COLLECTION, Person, Service, Tombstone, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; +import { type SQL, type SQLWrapper, and, eq, sql } from "drizzle-orm"; import { canonicalizeAuthority } from "./origin.ts"; @@ -71,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: { @@ -79,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; } @@ -87,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; } @@ -129,9 +139,9 @@ export function buildFederation(db: Database): FederationBuilder { 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: Validate and persist incoming activities. The local createObject + // mutation already stores Create activities; incoming activities are + // currently only logged. .on(Activity, (_ctx, activity) => { logger.debug("Received an activity: {activity}", { activity }); }) @@ -143,6 +153,7 @@ export function buildFederation(db: Database): FederationBuilder { builder.setObjectDispatcher( APObject, + // Keep migration backfill IRI formats in sync when changing these paths. "/users/{identifier}/{id}", async (ctx, { identifier, id }) => { if (!validateUuid(identifier) || !validateUuid(id)) return null; @@ -150,14 +161,16 @@ export function buildFederation(db: Database): FederationBuilder { where: { id, actorId: identifier, + RAW: (table) => publicAddressing(table.id), actor: { localId: { isNotNull: true }, deleted: { isNull: true }, instance: { host: ctx.host }, }, }, + with: objectSelection, }); - if (object == null || object.visibility === "followers") return null; + if (object == null) return null; if (object.deleted != null) { return new Tombstone({ id: ctx.getObjectUri(APObject, { identifier, id }), @@ -173,25 +186,19 @@ export function buildFederation(db: Database): FederationBuilder { "/ap/creates/{id}", async (ctx, { id }) => { if (!validateUuid(id)) return null; - const object = await db.query.objects.findFirst({ + const activity = await db.query.activities.findFirst({ where: { - id, + resource: { iri: ctx.getObjectUri(Create, { id }).href }, actor: { localId: { isNotNull: true }, deleted: { isNull: true }, instance: { host: ctx.host }, }, + RAW: (table) => servedActivity(table), }, + with: activitySelection, }); - if ( - object == null || - object.visibility === "followers" || - object.deleted != null - ) { - return null; - } - - return toCreate(ctx, object); + return activity == null ? null : toCreate(ctx, activity); }, ); @@ -200,72 +207,114 @@ export function buildFederation(db: Database): FederationBuilder { "/users/{identifier}/outbox", async (ctx, identifier, cursor) => { if ((await findActiveActor(db, ctx, identifier)) == null) return null; - if (cursor != null && cursor !== "" && !validateUuid(cursor)) { - return null; - } - const rows = await db.query.objects.findMany({ + const boundary = parseOutboxCursor(cursor); + if (boundary === false) return null; + const rows = await db.query.activities.findMany({ where: { actorId: identifier as Uuid, - deleted: { isNull: true }, - visibility: { in: ["public", "unlisted"] }, - ...(cursor == null || cursor === "" ? {} : { id: { lt: cursor } }), + RAW: (table) => + and( + servedActivity(table), + boundary == null + ? undefined + : sql`(${table.published}, ${table.id}) < + (${boundary.published}::timestamptz, ${boundary.id}::uuid)`, + )!, }, - orderBy: { id: "desc" }, + // Backfilled activity IDs are UUIDv4, 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)!.id : null, + 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) => - (await findActiveActor(db, ctx, identifier))?.postsCount ?? 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; } @@ -300,10 +349,10 @@ function isSuspended({ suspended, suspendedUntil }: Actor): boolean { 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, @@ -320,17 +369,44 @@ 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> = @@ -339,29 +415,108 @@ const objectConstructors: Record APObject> = Note: (props) => new Note(props), }; -function recipients( +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, - object: ActivityPubObject, -): { tos: URL[]; ccs: URL[] } { - const followers = ctx.getFollowersUri(object.actorId); - switch (object.visibility) { - case "public": - return { tos: [PUBLIC_COLLECTION], ccs: [followers] }; - case "unlisted": - return { tos: [followers], ccs: [PUBLIC_COLLECTION] }; - case "followers": - return { tos: [followers], ccs: [] }; - default: - throw new Error( - `Unsupported visibility: ${object.visibility satisfies never}`, - ); - } + 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: [] }; } -function toObject(ctx: Context, object: ActivityPubObject): APObject { +/** + * 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.iri), - attribution: ctx.getActorUri(object.actorId), + id: new URL(object.resource.iri), + attribution: new URL(object.actor.resource.iri), contents: [ object.contentHtml, ...(object.language == null @@ -374,16 +529,23 @@ function toObject(ctx: Context, object: ActivityPubObject): APObject { published: object.published.toTemporalInstant(), updated: object.updated.toTemporalInstant(), url: object.url == null ? null : new URL(object.url), - ...recipients(ctx, object), + ...recipients(object.addressing), }); } -function toCreate(ctx: Context, object: ActivityPubObject): Create { +/** + * 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: ctx.getObjectUri(Create, { id: object.id }), - actor: ctx.getActorUri(object.actorId), - ...recipients(ctx, object), - object: new URL(object.iri), - published: object.published.toTemporalInstant(), + 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.toTemporalInstant(), }); } diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts index d775f21..52e5773 100644 --- a/packages/graphql/src/object.test.ts +++ b/packages/graphql/src/object.test.ts @@ -21,6 +21,7 @@ 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"; @@ -32,12 +33,13 @@ import { remoteActorId, seedAuthenticatedLocalInstance, seedLocalActor, + seedObjects, seedRemoteActor, } from "./seed.test.ts"; -const fields = `id uuid iri url type actor { uuid } visibility name summary contentHtml language sensitive published updated created`; -const mutation = `mutation Create($actor: ID!, $contentHtml: String!, $language: String, $type: ObjectType! = Note, $visibility: ObjectVisibility! = PUBLIC) { - createObject(actor: $actor, contentHtml: $contentHtml, language: $language, type: $type, visibility: $visibility) { +const fields = `id uuid iri url type actor { uuid } to { target { iri kind } } cc { target { 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 } @@ -46,6 +48,7 @@ const mutation = `mutation Create($actor: ID!, $contentHtml: String!, $language: const variables = { actor: globalId("Actor", localActorId), contentHtml: "

Hello

", + addressing: { to: [PUBLIC_IRI] }, }; describe("Mutation.createObject", () => { @@ -66,7 +69,9 @@ describe("Mutation.createObject", () => { const object = body.data.createObject; assert.equal(object.resultType, "Object"); assert.equal(object.type, "Note"); - assert.equal(object.visibility, "PUBLIC"); + assert.deepEqual(object.to, [ + { target: { iri: PUBLIC_IRI, kind: "collection" } }, + ]); assert.equal(object.language, "ko-KR"); assert.equal(object.contentHtml, contentHtml); assert.equal( @@ -194,7 +199,7 @@ describe("Mutation.createObject", () => { assert.ok(body.errors?.length); }); }); - it("creates Articles with optional fields and all visibilities on a suspended actor", async () => { + 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); @@ -202,20 +207,24 @@ describe("Mutation.createObject", () => { .update(schema.actors) .set({ suspended: new Date(0) }) .where(eq(schema.actors.id, localActorId)); - for (const visibility of ["PUBLIC", "UNLISTED", "FOLLOWERS"]) { + 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", visibility } }, + { 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.visibility, visibility); + assert.equal(body.data.createObject.name, "Title"); assert.equal(body.data.createObject.summary, "CW"); assert.equal(body.data.createObject.sensitive, true); @@ -234,16 +243,20 @@ describe("Actor.objects", () => { await withTestHarness(async ({ db, post }) => { await seedLocalActor(db); const id = uuid(); - await db.insert(schema.objects).values({ + await seedObjects(db, { id, actorId: localActorId, type: "Note", - visibility: "followers", + 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 visibility contentHtml } } + 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 } } } } } }`; @@ -260,7 +273,6 @@ describe("Actor.objects", () => { data: { node: { uuid: id, - visibility: "FOLLOWERS", contentHtml: "GraphQL debugging content", }, nodes: [{ uuid: id }], @@ -277,7 +289,8 @@ describe("Actor.objects", () => { await seedLocalActor(db); await seedRemoteActor(db); const ids = Array.from({ length: 5 }, () => uuid()); - await db.insert(schema.objects).values( + await seedObjects( + db, ids.map((id, index) => ({ id, actorId: index === 4 ? remoteActorId : localActorId, @@ -345,7 +358,8 @@ describe("Query.node", () => { await seedLocalActor(db); const liveId = uuid(); const deletedId = uuid(); - await db.insert(schema.objects).values( + await seedObjects( + db, [liveId, deletedId].map((id) => ({ id, actorId: localActorId, diff --git a/packages/graphql/src/object.ts b/packages/graphql/src/object.ts index e4f25d7..3259508 100644 --- a/packages/graphql/src/object.ts +++ b/packages/graphql/src/object.ts @@ -14,28 +14,59 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { schema } from "@drfed/models"; +import { 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 } from "@fedify/vocab"; +import { Object as APObject, Create } from "@fedify/vocab"; import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import { and, eq, gt, isNotNull, isNull, sql } from "drizzle-orm"; import { Actor } from "./actor.ts"; import builder, { type DrFedObjectRef } from "./builder.ts"; +import { + type AddressingRows, + classifyMastodon, + classifyMisskey, +} from "./classification.ts"; +import { + activitySelection, + objectSelection, + toCreate, + toObject, +} from "./federation.ts"; +import { Resource, registerAddressingFields } from "./resource.ts"; const ObjectType = builder.enumType("ObjectType", { values: objectTypeEnum.enumValues, }); -const ObjectVisibility = builder.enumType("ObjectVisibility", { - values: { - PUBLIC: { value: "public" }, - UNLISTED: { value: "unlisted" }, - FOLLOWERS: { value: "followers" }, - } as const, +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 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"), + }), }); const ObjectRef = builder.drizzleNode("objects", { name: "Object", + interfaces: [Resource], description: "Represents an ActivityPub object authored by an `Actor`.", id: { column: ({ id }) => id, @@ -46,9 +77,6 @@ const ObjectRef = builder.drizzleNode("objects", { type: "UUID", description: "The UUID of the ActivityPub Object.", }), - iri: t.exposeString("iri", { - description: "The canonical ActivityPub identifier of the object.", - }), url: t.expose("url", { type: "URL", nullable: true, @@ -61,10 +89,45 @@ const ObjectRef = builder.drizzleNode("objects", { actor: t.relation("actor", { description: "The actor that authored the object.", }), - visibility: t.expose("visibility", { - type: ObjectVisibility, - description: - "ActivityPub addressing policy. FOLLOWERS objects are not served over ActivityPub; GraphQL reads remain public.", + document: t.expose("document", { type: "JSON", nullable: true }), + createActivity: t.relation("createActivity", { nullable: true }), + expectedClassifications: t.field({ + type: [ExpectedClassification], + select: { columns: { id: true } }, + resolve: async (object, _, ctx) => { + const row = await ctx.db.query.objects.findFirst({ + where: { id: object.id }, + with: { + ...objectSelection, + actor: { + with: { + resource: true, + collectionReferences: { + with: { collection: { with: { resource: true } } }, + }, + }, + }, + createActivity: { with: activitySelection }, + }, + }); + if (row == null) throw new Error("Missing object."); + const author = { + iri: row.actor.resource.iri, + followersIri: + row.actor.collectionReferences.find( + (collection) => collection.role === "followers", + )?.collection.resource.iri ?? null, + }; + const addressing = classificationInput(row); + const activity = + row.createActivity == null + ? null + : classificationInput(row.createActivity); + return [ + classifyMastodon(addressing, activity, author), + classifyMisskey(addressing, activity, author), + ]; + }, }), name: t.exposeString("name", { nullable: true, @@ -102,6 +165,7 @@ const ObjectRef = builder.drizzleNode("objects", { }), }); export const ActivityPubObject: DrFedObjectRef = ObjectRef; +registerAddressingFields("objects"); const objectsConnection = drizzleConnectionHelpers(builder, "objects", { query: { @@ -114,7 +178,7 @@ builder.drizzleObjectField("actors", "objects", (t) => { type: ActivityPubObject, description: - "Non-deleted objects, newest publication first. All visibilities are publicly readable through GraphQL.", + "Non-deleted objects, newest publication first. All addressing is publicly readable through GraphQL.", select(args, ctx, nestedSelection) { return { with: { @@ -142,7 +206,7 @@ builder.drizzleObjectField("actors", "objects", (t) => fields: (fb) => ({ totalCount: fb.int({ description: - "The number of non-deleted objects authored by this actor, across all visibilities.", + "The number of non-deleted objects authored by this actor, regardless of addressing.", resolve: (connection) => connection.totalCount(), }), }), @@ -226,17 +290,16 @@ builder.mutationFields((t) => ({ defaultValue: false, description: "Whether to mark the content as sensitive.", }), - visibility: t.arg({ - type: ObjectVisibility, + addressing: t.arg({ + type: AddressingInput, required: true, - defaultValue: "public", description: - "ActivityPub addressing policy; this does not restrict GraphQL reads.", + "Explicit addressing preserved in order, including duplicates. Empty lists are allowed.", }), }, async resolve( _parent, - { actor: { id: actorId }, language, ...input }, + { actor: { id: actorId }, language, addressing, ...input }, ctx, ) { if (input.contentHtml.trim() === "") { @@ -304,26 +367,81 @@ builder.mutationFields((t) => ({ identifier: actorId, id, }).href; - const [object] = await tx - .insert(schema.objects) - .values({ - ...input, - name: normalizeOptionalText(input.name), - summary: normalizeOptionalText(input.summary), - id, - actorId, - iri, - language: canonicalLanguage, + 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, + }) + .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); + }, + 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, + }, }) - .returning(); + .where(eq(schema.activities.id, activityId)); await tx .update(schema.actors) .set({ postsCount: sql`${schema.actors.postsCount} + 1` }) .where(eq(schema.actors.id, actorId)); - if (object == null) { - throw new Error("Object insertion returned no row."); - } - return object; + return { ...object, document: snapshot }; }); }, }), @@ -334,3 +452,34 @@ function normalizeOptionalText( ): string | null { return value == null || value.trim() === "" ? null : value; } + +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") }; +} + +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.ts b/packages/graphql/src/resource.ts new file mode 100644 index 0000000..f9367e8 --- /dev/null +++ b/packages/graphql/src/resource.ts @@ -0,0 +1,192 @@ +// 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 } from "@drfed/models/uuid"; +import { resolveOffsetConnection } from "@pothos/plugin-relay"; +import { eq } from "drizzle-orm"; + +import builder from "./builder.ts"; + +export const ResourceKind = builder.enumType("ResourceKind", { + values: schema.resourceKindEnum.enumValues, +}); +export const Resource = builder.interfaceRef<{ id: Uuid }>("Resource"); +Resource.implement({ + fields: (t) => ({ + iri: t.field({ + type: "URL", + resolve: async ({ id }, _, ctx) => { + const row = await ctx.db.query.resources.findFirst({ where: { id } }); + if (row == null) throw new Error("Missing resource."); + return row.iri; + }, + }), + kind: t.field({ + type: ResourceKind, + resolve: async ({ id }, _, ctx) => { + const row = await ctx.db.query.resources.findFirst({ where: { id } }); + if (row == null) throw new Error("Missing resource."); + return row.kind; + }, + }), + }), + resolveType: async ({ id }, ctx) => { + const row = await ctx.db.query.resources.findFirst({ where: { id } }); + return ( + { + actor: "Actor", + object: "Object", + activity: "Activity", + collection: "Collection", + unknown: "UnknownResource", + } as const + )[row?.kind ?? "unknown"]; + }, +}); + +/** + * Loads the typed record so interface fragments see the complete entity. + * @returns The entity represented by this resource, or the unknown resource itself. + */ +export async function resolveResource( + db: Database, + row: ResourceRow, +): Promise<{ id: Uuid }> { + if (row.kind === "unknown") return row; + 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; +} + +builder.drizzleNode("resources", { + name: "UnknownResource", + interfaces: [Resource], + id: { column: (row) => row.id }, + fields: () => ({}), +}); +const AddressingTarget = builder.objectRef< + typeof schema.addressing.$inferSelect & { targetResource: ResourceRow } +>("AddressingTarget"); +AddressingTarget.implement({ + fields: (t) => ({ + target: t.field({ + type: Resource, + resolve: (row, _, ctx) => resolveResource(ctx.db, row.targetResource), + }), + raw: t.expose("target", { + type: "JSON", + nullable: true, + description: "The original inline object or Link, if present.", + }), + }), +}); + +export function registerAddressingFields( + table: "objects" | "activities", +): void { + for (const property of schema.addressingPropertyEnum.enumValues) { + builder.drizzleObjectField(table, property, (t) => + t.field({ + type: [AddressingTarget], + description: `Stored ${property} occurrences, in original order including duplicates.`, + select: { columns: { id: true } }, + resolve: (row, _, ctx) => + ctx.db.query.addressing.findMany({ + where: { sourceId: row.id, property }, + orderBy: { position: "asc" }, + with: { targetResource: true }, + }), + }), + ); + } +} + +const CollectionType = builder.enumType("CollectionType", { + values: schema.collectionTypeEnum.enumValues, +}); +const CollectionRole = builder.enumType("CollectionRole", { + values: schema.collectionRoleEnum.enumValues, +}); +export const Collection = builder.drizzleNode("collections", { + name: "Collection", + interfaces: [Resource], + id: { column: (row) => row.id }, + fields: (t) => ({ + type: t.expose("type", { type: CollectionType }), + role: t.expose("role", { type: CollectionRole, nullable: true }), + owner: t.relation("ownerActor", { nullable: true }), + totalCount: t.int({ + select: { columns: { id: true, totalItems: true } }, + resolve: (row, _, ctx) => + row.totalItems ?? + ctx.db.$count( + schema.collectionItems, + eq(schema.collectionItems.collectionId, row.id), + ), + }), + items: t.connection({ + type: Resource, + select: { columns: { id: true } }, + resolve: (row, args, ctx) => + resolveOffsetConnection({ args }, async ({ offset, limit }) => { + const items = await ctx.db.query.collectionItems.findMany({ + where: { collectionId: row.id }, + orderBy: { position: "asc", itemId: "asc" }, + offset, + limit, + with: { item: true }, + }); + return await Promise.all( + items.map((item) => resolveResource(ctx.db, item.item)), + ); + }), + }), + }), +}); +const ActivityType = builder.enumType("ActivityType", { + values: schema.activityTypeEnum.enumValues, +}); +export const Activity = builder.drizzleNode("activities", { + name: "Activity", + interfaces: [Resource], + id: { column: (row) => row.id }, + fields: (t) => ({ + type: t.expose("type", { type: ActivityType }), + actor: t.relation("actor"), + object: t.field({ + type: Resource, + nullable: true, + select: { with: { object: true } }, + resolve: (row, _, ctx) => + row.object == null ? null : resolveResource(ctx.db, row.object), + }), + published: t.expose("published", { type: "DateTime" }), + document: t.expose("document", { type: "JSON", nullable: true }), + }), +}); +registerAddressingFields("activities"); diff --git a/packages/graphql/src/seed.test.ts b/packages/graphql/src/seed.test.ts index 265b5c2..3ad152b 100644 --- a/packages/graphql/src/seed.test.ts +++ b/packages/graphql/src/seed.test.ts @@ -14,7 +14,18 @@ // 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"; +// Keep dependent database writes and observations sequential. +// oxlint-disable no-await-in-loop + +import { + type Database, + 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"; @@ -32,7 +43,7 @@ export const sessionId = "00000000-0000-4000-8000-000000000301"; export const accessToken = "test-access-token"; export function globalId( - type: "Actor" | "Instance" | "Object", + type: "Actor" | "Instance" | "Object" | "Activity" | "Collection", id: string, ): string { return Buffer.from(`${type}:${id}`).toString("base64"); @@ -70,7 +81,7 @@ export async function seedLocalActor(db: Database): Promise { avatar: "avatar.png", header: "header.png", }); - await db.insert(schema.actors).values({ + await seedActors(db, { id: localActorId, localId: localActorId, instanceId: localInstanceId, @@ -78,13 +89,9 @@ export async function seedLocalActor(db: Database): Promise { 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, }); } @@ -115,20 +122,119 @@ export async function seedRemoteActor(db: Database): Promise { created, host: "remote.example.com", }); - await db.insert(schema.actors).values({ + 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", - 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, }); } + +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, + role, + }); + 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 legacy IRI layout for migration coverage. */ +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/${row.id}`, + "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); + }, + activityId, + ); + }, + object.id, + ); + } +} diff --git a/packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/migration.sql b/packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/migration.sql new file mode 100644 index 0000000..b9bb2c3 --- /dev/null +++ b/packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/migration.sql @@ -0,0 +1,137 @@ +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', 'public');--> statement-breakpoint +CREATE TYPE "collection_type" AS ENUM('Collection', 'OrderedCollection');--> 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 "addressing" ( + "id" uuid PRIMARY KEY, + "sourceId" uuid NOT NULL, + "property" "addressing_property" NOT NULL, + "position" integer NOT NULL, + "targetId" uuid NOT NULL, + "target" json, + 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, + "role" "collection_role", + "totalItems" integer, + "document" json, + "updated" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> 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 +ALTER TABLE "actors" DROP CONSTRAINT "actors_iri_key";--> statement-breakpoint +ALTER TABLE "objects" DROP CONSTRAINT "objects_iri_key";--> statement-breakpoint +ALTER TABLE "actors" ADD COLUMN "document" json;--> statement-breakpoint +ALTER TABLE "objects" ADD COLUMN "document" json;--> statement-breakpoint +-- Preserve existing identifiers before dropping their old columns. +INSERT INTO resources (id, iri, kind, created) SELECT id, iri, 'actor', created FROM actors; +--> statement-breakpoint +INSERT INTO resources (id, iri, kind, created) SELECT id, iri, 'object', created FROM objects; +--> statement-breakpoint +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, role) VALUES ('00000000-0000-4000-8000-000000000000', 'Collection', 'public'); +--> statement-breakpoint +INSERT INTO resources (id, iri, kind) +SELECT gen_random_uuid(), iri, 'collection' FROM ( + SELECT DISTINCT c.iri FROM actors a CROSS JOIN LATERAL (VALUES + (a."followersUrl"), (a."followingUrl"), (a."featuredUrl"), (a."outboxUrl") + ) c(iri) WHERE c.iri IS NOT NULL +) urls ON CONFLICT (iri) DO NOTHING; +--> statement-breakpoint +-- These are reconstructed legacy relationship snapshots, not fetched documents. +UPDATE actors SET document = json_strip_nulls(json_build_object( + '@context', 'https://www.w3.org/ns/activitystreams', 'id', iri, 'type', type, + 'inbox', "inboxUrl", 'outbox', "outboxUrl", 'followers', "followersUrl", + 'following', "followingUrl", 'featured', "featuredUrl" +)); +--> statement-breakpoint +INSERT INTO collections (id, type, "ownerActorId", role) +SELECT r.id, 'OrderedCollection', + CASE WHEN count(DISTINCT a.id) = 1 THEN min(a.id::text)::uuid END, + CASE WHEN count(DISTINCT c.role) = 1 THEN min(c.role)::collection_role END +FROM actors a CROSS JOIN LATERAL (VALUES + ('followers', a."followersUrl"), ('following', a."followingUrl"), + ('featured', a."featuredUrl"), ('outbox', a."outboxUrl") +) c(role, iri) JOIN resources r ON r.iri = c.iri +WHERE r.kind = 'collection' +GROUP BY r.id ON CONFLICT (id) DO NOTHING; +--> statement-breakpoint +INSERT INTO addressing (id, "sourceId", property, position, "targetId") +SELECT gen_random_uuid(), o.id, + CASE WHEN o.visibility = 'public' THEN 'to' ELSE 'cc' END::addressing_property, + 0, '00000000-0000-4000-8000-000000000000' +FROM objects o WHERE o.visibility IN ('public', 'unlisted'); +--> statement-breakpoint +INSERT INTO addressing (id, "sourceId", property, position, "targetId") +SELECT gen_random_uuid(), o.id, + CASE WHEN o.visibility = 'public' THEN 'cc' ELSE 'to' END::addressing_property, + 0, r.id +FROM objects o JOIN actors a ON a.id = o."actorId" +JOIN resources r ON r.iri = a."followersUrl"; +--> statement-breakpoint +INSERT INTO resources (id, iri, kind, created) +SELECT gen_random_uuid(), 'https://' || i.host || '/ap/creates/' || o.id, 'activity', o.created +FROM objects o JOIN actors a ON a.id = o."actorId" JOIN instances i ON i.id = a."instanceId"; +--> statement-breakpoint +INSERT INTO activities (id, type, "actorId", "objectId", published, created) +SELECT r.id, 'Create', o."actorId", o.id, o.published, o.created +FROM objects o JOIN actors a ON a.id = o."actorId" JOIN instances i ON i.id = a."instanceId" +JOIN resources r ON r.iri = 'https://' || i.host || '/ap/creates/' || o.id; +--> statement-breakpoint +INSERT INTO addressing (id, "sourceId", property, position, "targetId", target) +SELECT gen_random_uuid(), a.id, d.property, d.position, d."targetId", d.target +FROM activities a JOIN addressing d ON d."sourceId" = a."objectId"; +--> statement-breakpoint +CREATE INDEX "activity_actor_published_index" ON "activities" ("actorId","published" desc,"id" desc);--> 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 UNIQUE INDEX "collection_owner_role_key" ON "collections" ("ownerActorId","role") WHERE "role" IS NOT NULL;--> 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 "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 "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 +ALTER TABLE "objects" DROP COLUMN "iri";--> statement-breakpoint +ALTER TABLE "objects" DROP COLUMN "visibility";--> statement-breakpoint +DROP TYPE "object_visibility"; \ No newline at end of file diff --git a/packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/snapshot.json b/packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/snapshot.json new file mode 100644 index 0000000..344b91a --- /dev/null +++ b/packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/snapshot.json @@ -0,0 +1,2233 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "bb2c4a8b-5c07-4f2e-abeb-c9710239aeef", + "prevIds": [ + "3d7bb672-489b-4d1e-8efb-e602d21f6e98", + "97f2c698-2361-4eae-b5c0-969ee1d2f8e7" + ], + "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", "public"], + "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": "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": "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": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "postsCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "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": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target", + "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": "collection_role", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "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": "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": true, + "columns": [ + { + "value": "ownerActorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "role", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"role\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "collection_owner_role_key", + "entityType": "indexes", + "schema": "public", + "table": "collections" + }, + { + "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": ["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": ["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/20260915125322_add_actor_collection_references/migration.sql b/packages/models/drizzle/20260915125322_add_actor_collection_references/migration.sql new file mode 100644 index 0000000..e903d39 --- /dev/null +++ b/packages/models/drizzle/20260915125322_add_actor_collection_references/migration.sql @@ -0,0 +1,26 @@ +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 INDEX "actor_collection_reference_collection_index" ON "actor_collection_references" ("collectionId");--> 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 +-- Recover every actor-declared role from the legacy snapshots, including +-- multiple roles or actors naming the same collection IRI. +INSERT INTO actor_collection_references ("actorId", role, "collectionId") +SELECT a.id, roles.role::collection_role, r.id +FROM actors a CROSS JOIN (VALUES ('followers'), ('following'), ('featured'), ('outbox')) roles(role) +JOIN resources r ON r.iri = a.document ->> roles.role +JOIN collections c ON c.id = r.id +ON CONFLICT DO NOTHING; +--> statement-breakpoint +-- Also upgrade databases that already applied the initial resource migration +-- before it started retaining legacy snapshots, plus subsequently created actors. +INSERT INTO actor_collection_references ("actorId", role, "collectionId") +SELECT "ownerActorId", role, id FROM collections +WHERE "ownerActorId" IS NOT NULL AND role IS NOT NULL +ON CONFLICT DO NOTHING; diff --git a/packages/models/drizzle/20260915125322_add_actor_collection_references/snapshot.json b/packages/models/drizzle/20260915125322_add_actor_collection_references/snapshot.json new file mode 100644 index 0000000..62e91a3 --- /dev/null +++ b/packages/models/drizzle/20260915125322_add_actor_collection_references/snapshot.json @@ -0,0 +1,2330 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "017eac58-0854-4c57-9224-a18872dab88f", + "prevIds": ["bb2c4a8b-5c07-4f2e-abeb-c9710239aeef"], + "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", "public"], + "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": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "postsCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "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": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target", + "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": "collection_role", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "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": true, + "columns": [ + { + "value": "ownerActorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "role", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"role\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "collection_owner_role_key", + "entityType": "indexes", + "schema": "public", + "table": "collections" + }, + { + "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/relations.ts b/packages/models/src/relations.ts index fc2d2b9..c286751 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -98,7 +98,119 @@ 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, + }), + createActivity: r.one.activities({ + from: r.objects.id, + to: r.activities.objectId, + optional: true, + where: { type: "Create" }, + }), actor: r.one.actors({ from: r.objects.actorId, to: r.actors.id, @@ -106,6 +218,23 @@ export const relations = defineRelations(schema, (r) => ({ }), }, 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 }), instance: r.one.instances({ from: r.actors.instanceId, diff --git a/packages/models/src/resource.test.ts b/packages/models/src/resource.test.ts new file mode 100644 index 0000000..661884e --- /dev/null +++ b/packages/models/src/resource.test.ts @@ -0,0 +1,352 @@ +// 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 upgrade before/after assertions together. +// Keep dependent database writes and observations sequential. +// oxlint-disable 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 { + PUBLIC_IRI, + PUBLIC_RESOURCE_ID, + ensureResource, + promoteResource, + storeAddressing, +} from "@drfed/models/resource"; +import { uuidV7 } from "@drfed/models/uuid"; +import { PGlite } from "@electric-sql/pglite"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate as migrateBaseline } from "drizzle-orm/pglite/migrator"; + +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(); + } +}); + +const migrationName = "20260915095905_add_resources_addressing_and_activities"; +const migrations = join( + dirname(fileURLToPath(import.meta.resolve("@drfed/models/migrate"))), + "..", + "drizzle", +); + +it("backfills resources, actor collections, addressing and independent Create activities", async () => { + const baseline = await mkdtemp(join(tmpdir(), "drfed-addressing-migration-")); + const client = new PGlite(); + try { + const entries = await readdir(migrations, { withFileTypes: true }); + await Promise.all( + entries + .filter((e) => e.isDirectory() && e.name < migrationName) + .map((e) => + cp(join(migrations, e.name), join(baseline, e.name), { + recursive: true, + }), + ), + ); + await migrateBaseline(drizzle({ client }), { migrationsFolder: baseline }); + const instanceId = uuidV7(); + const actorId = uuidV7(); + const iri = `https://old.example/users/${actorId}`; + await client.query("INSERT INTO instances (id, host) VALUES ($1, $2)", [ + instanceId, + "old.example", + ]); + await client.query( + 'INSERT INTO actors (id, "instanceId", type, username, iri, "inboxUrl", "outboxUrl", "followersUrl", "followingUrl", "featuredUrl") VALUES ($1, $2, \'Person\', \'old\', $3, $4, $5, $6, $7, $8)', + [ + actorId, + instanceId, + iri, + `${iri}/inbox`, + `${iri}/outbox`, + `${iri}/followers`, + `${iri}/following`, + `${iri}/featured`, + ], + ); + const ids = [uuidV7(), uuidV7(), uuidV7()]; + for (const [index, label] of [ + "public", + "unlisted", + "followers", + ].entries()) { + // Historical column names are restricted to the upgrade fixture. + await client.query( + "INSERT INTO objects (id, \"actorId\", type, iri, visibility, \"contentHtml\") VALUES ($1, $2, 'Note', $3, $4, 'old content')", + [ids[index], actorId, `${iri}/${ids[index]}`, label], + ); + } + await migrate({ credentials: { driver: "pglite", client } }); + const db = drizzle({ client, schema, relations }); + assert.equal(await db.$count(schema.resources), 12); + assert.equal(await db.$count(schema.collections), 5); + assert.equal(await db.$count(schema.activities), 3); + assert.equal(await db.$count(schema.addressing), 10); + const followers = await db.query.collections.findFirst({ + where: { ownerActorId: actorId, role: "followers" }, + with: { resource: true }, + }); + assert.equal(followers?.resource.iri, `${iri}/followers`); + for (const [index, id] of ids.entries()) { + const object = await db.query.objects.findFirst({ + where: { id }, + with: { + resource: true, + addressing: { orderBy: { property: "asc", position: "asc" } }, + createActivity: { + with: { + resource: true, + addressing: { orderBy: { property: "asc", position: "asc" } }, + }, + }, + }, + }); + assert.ok(object?.createActivity); + assert.equal(object.resource.iri, `${iri}/${id}`); + const activity = object.createActivity; + assert.notEqual(activity.id, id); + assert.equal( + activity.resource.iri, + `https://old.example/ap/creates/${id}`, + ); + assert.equal(activity.published.getTime(), object.published.getTime()); + const targets = (rows: typeof object.addressing) => + rows.map((r) => [r.property, r.position, r.targetId]); + assert.deepEqual( + targets(activity.addressing), + targets(object.addressing), + ); + const expected: unknown = + index === 0 + ? [ + ["to", 0, PUBLIC_RESOURCE_ID], + ["cc", 0, followers?.id], + ] + : index === 1 + ? [ + ["to", 0, followers?.id], + ["cc", 0, PUBLIC_RESOURCE_ID], + ] + : [["to", 0, followers?.id]]; + assert.deepEqual(targets(object.addressing), expected); + } + await migrate({ credentials: { driver: "pglite", client } }); + assert.equal(await db.$count(schema.activities), 3); + } finally { + await client.close(); + await rm(baseline, { recursive: true, force: true }); + } +}); + +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("upgrades shared collection IRIs without dropping actor roles or addressing", async () => { + const baseline = await mkdtemp(join(tmpdir(), "drfed-shared-collections-")); + 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 instanceId = uuidV7(); + const alice = uuidV7(); + const bob = uuidV7(); + const shared = "https://old.example/shared"; + await client.query( + "INSERT INTO instances (id, host) VALUES ($1, 'old.example')", + [instanceId], + ); + for (const [id, username, followers, featured] of [ + [alice, "alice", shared, shared], + [bob, "bob", PUBLIC_IRI, null], + ] as const) { + await client.query( + 'INSERT INTO actors (id, "instanceId", type, username, iri, "inboxUrl", "outboxUrl", "followersUrl", "featuredUrl") VALUES ($1,$2,\'Person\',$3,$4,$5,$6,$7,$8)', + [ + id, + instanceId, + username, + `https://old.example/${username}`, + `https://old.example/${username}/inbox`, + shared, + followers, + featured, + ], + ); + await client.query( + "INSERT INTO objects (id, \"actorId\", type, iri, visibility, \"contentHtml\") VALUES ($1,$2,'Note',$3,'followers','hello')", + [uuidV7(), id, `https://old.example/${username}/note`], + ); + } + await migrate({ credentials: { driver: "pglite", client } }); + const db = drizzle({ client, schema, relations }); + assert.equal(await db.$count(schema.collections), 2); + const collection = await db.query.collections.findFirst({ + where: { resource: { iri: shared } }, + }); + assert.ok(collection); + assert.equal(collection.ownerActorId, null); + assert.equal(collection.role, null); + const refs = await db.query.actorCollectionReferences.findMany({ + with: { collection: { with: { resource: true } } }, + orderBy: { actorId: "asc", role: "asc" }, + }); + assert.equal(refs.length, 5); + for (const [actorId, role, iri] of [ + [alice, "outbox", shared], + [alice, "featured", shared], + [alice, "followers", shared], + [bob, "outbox", shared], + [bob, "followers", PUBLIC_IRI], + ] as const) { + assert.equal( + refs.find((ref) => ref.actorId === actorId && ref.role === role) + ?.collection.resource.iri, + iri, + ); + } + for (const [actorId, iri] of [ + [alice, shared], + [bob, PUBLIC_IRI], + ] as const) { + const object = await db.query.objects.findFirst({ + where: { actorId }, + with: { + addressing: { with: { targetResource: true } }, + createActivity: { + with: { addressing: { with: { targetResource: true } } }, + }, + }, + }); + assert.ok(object?.createActivity); + assert.deepEqual( + object.addressing.map((entry) => [ + entry.property, + entry.targetResource.iri, + ]), + [["to", iri]], + ); + assert.deepEqual( + object.createActivity.addressing.map((entry) => [ + entry.property, + entry.targetResource.iri, + ]), + [["to", iri]], + ); + } + } finally { + await client.close(); + await rm(baseline, { recursive: true, force: true }); + } +}); diff --git a/packages/models/src/resource.ts b/packages/models/src/resource.ts new file mode 100644 index 0000000..b4be6e9 --- /dev/null +++ b/packages/models/src/resource.ts @@ -0,0 +1,134 @@ +// 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 { eq } from "drizzle-orm"; + +import type { Database, Transaction } from "./db.ts"; +import { + type AddressingProperty, + type Resource, + addressing, + addressingPropertyEnum, + 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, + }); + } + } +} diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index cf23176..6865e58 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -22,6 +22,7 @@ import { check, index, integer, + json, jsonb, pgEnum, pgTable, @@ -29,6 +30,7 @@ import { text, timestamp, unique, + uniqueIndex, uuid, varchar, } from "drizzle-orm/pg-core"; @@ -213,10 +215,36 @@ 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: timestamp({ withTimezone: true }) + .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 +255,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(), @@ -306,26 +330,21 @@ export type NewLocalActor = typeof localActors.$inferInsert; export const objectTypeEnum = pgEnum("object_type", ["Article", "Note"]); export type ObjectType = (typeof objectTypeEnum.enumValues)[number]; -export const objectVisibilityEnum = pgEnum("object_visibility", [ - "public", - "unlisted", - "followers", -]); -export type ObjectVisibility = (typeof objectVisibilityEnum.enumValues)[number]; - /** ActivityPub objects authored by actors. */ export const objects = pgTable( "objects", { - id: uuid().$type().primaryKey(), + id: uuid() + .$type() + .primaryKey() + .references(() => resources.id, { onDelete: "cascade" }), actorId: uuid() .$type() .notNull() .references(() => actors.id, { onDelete: "cascade" }), type: objectTypeEnum().notNull(), - iri: text().notNull().unique(), + document: json(), url: text(), - visibility: objectVisibilityEnum().notNull().default("public"), name: text(), summary: text(), contentHtml: text().notNull(), @@ -357,3 +376,151 @@ export const objects = pgTable( ); 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", + "public", +]); +export const collections = pgTable( + "collections", + { + id: uuid() + .$type() + .primaryKey() + .references(() => resources.id, { onDelete: "cascade" }), + type: collectionTypeEnum().notNull(), + ownerActorId: uuid() + .$type() + .references(() => actors.id, { onDelete: "cascade" }), + role: collectionRoleEnum(), + totalItems: integer(), + document: json(), + updated: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp) + .$onUpdate(() => currentTimestamp), + }, + (t) => [ + uniqueIndex("collection_owner_role_key") + .on(t.ownerActorId, t.role) + .where(sql`${t.role} IS NOT NULL`), + ], +); +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: timestamp({ withTimezone: true }) + .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: timestamp({ withTimezone: true }).notNull(), + document: json(), + created: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), + }, + (t) => [ + index("activity_actor_published_index").on( + t.actorId, + desc(t.published), + desc(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" }), + target: json(), + }, + (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/web/src/routes/workspace/create/[instance_id]/actors.tsx b/packages/web/src/routes/workspace/create/[instance_id]/actors.tsx index 31eef4c..e187600 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) { From 21a8878de361c01507981b5fda90f4697079ed73 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 15 Sep 2026 16:29:36 +0900 Subject: [PATCH 11/20] Add failing tests reproducing PR #73 review findings The user requested implementation of tests for three of the five review threads from https://github.com/fedify-dev/drfed/pull/73 that demonstrate the issues actually exist and do not require a schema redesign: - Cursor precision (https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163244): Actor.objects and Instance.actors paginated with first: 1 over rows whose published or created time carries microseconds return only the first row, because the Date mapping truncates the cursor to milliseconds. - Outbox totalItems (https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163252): a followers-only object and a deleted object are both counted by the outbox collection while the outbox page returns nothing. - Soft-deleted actors (https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163257): objects of a deleted actor still resolve through node, nodes, and Object.actor, and the actor is still listed by Instance.actors with its objects. The tests were generated by an AI assistant from the review threads, reviewed by the human author, and run directly with mise run test: all six new tests fail for the reasons described in the reviews and every existing test still passes. mise run check passes. https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163244 https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163252 https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163257 Assisted-by: Claude Code:claude-fable-5-1 --- packages/graphql/src/actor.test.ts | 136 +++++++++++++++++++++++- packages/graphql/src/federation.test.ts | 56 ++++++++++ packages/graphql/src/object.test.ts | 100 ++++++++++++++++- 3 files changed, 290 insertions(+), 2 deletions(-) diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index d24efb6..c5fa63e 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -15,12 +15,15 @@ // 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 { 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"; +import { eq, sql } from "drizzle-orm"; import { withTestHarness } from "./harness.test.ts"; import { @@ -33,6 +36,7 @@ import { remoteInstanceId, seedAuthenticatedLocalInstance, seedLocalActor, + seedLocalInstance, seedRemoteActor, } from "./seed.test.ts"; @@ -330,3 +334,133 @@ describe("Actor", () => { }); }); }); + +const instanceActorsQuery = `query($instance: ID!) { + node(id: $instance) { + ... on Instance { + actors { + totalCount + edges { node { uuid objects { totalCount edges { node { uuid } } } } } + } + } + } +}`; + +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`, + outboxUrl: `${iri}/outbox`, + }; +} + +// 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 db.insert(schema.actors).values(localActorValues(carolId, "carol")); + const hiddenObjectId = uuid(); + const liveObjectId = uuid(); + await db.insert(schema.objects).values( + [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: new Date() }) + .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 } }], + }, + }, + }, + ], + }, + }, + }, + }); + }); + }); +}); + +// Regression test for +// https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163244: +// the `created` cursor is truncated to milliseconds by the `Date` mapping, so +// rows that differ only in microseconds are skipped on the next page. +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()); + // `Date` cannot express microseconds, so the values go through SQL. + await db.insert(schema.actors).values( + ids.map((id, index) => ({ + ...localActorValues(id, id), + localId: null, + created: sql`${`2026-09-14T12:00:00.12345${index}Z`}::timestamptz`, + })), + ); + 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].reverse()); + assert.equal(hasNextPage, false); + }); + }); +}); diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 4a6d4cc..f68322d 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -31,9 +31,11 @@ import { eq, sql } from "drizzle-orm"; import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts"; import { + globalId, localActorId, remoteActorId, seedActors, + seedAuthenticatedLocalInstance, seedLocalActor, seedObjects, seedRemoteActor, @@ -587,3 +589,57 @@ describe("ActivityPub outbox", () => { }); }); }); + +const createMutation = `mutation Create($actor: ID!, $visibility: ObjectVisibility!) { + createObject(actor: $actor, contentHtml: "

Hello

", visibility: $visibility) { + ... on Object { uuid } + ... on CreateObjectError { errorType: type message } + } +}`; + +// Regression tests for +// https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163252: +// the outbox counter reads `postsCount`, which counts objects that the outbox +// pages never return. +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), + visibility: scenario === "followers" ? "FOLLOWERS" : "PUBLIC", + }, + }, + auth, + ) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.createObject.errorType, undefined); + if (scenario === "deleted") { + await db + .update(schema.objects) + .set({ deleted: new Date() }) + .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); + }); + }); + } +}); diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts index 52e5773..4944972 100644 --- a/packages/graphql/src/object.test.ts +++ b/packages/graphql/src/object.test.ts @@ -24,7 +24,7 @@ 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 { eq, sql } from "drizzle-orm"; import { withTestHarness } from "./harness.test.ts"; import { @@ -393,3 +393,101 @@ describe("Query.node", () => { }); }); }); + +// Regression test for +// https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163244: +// Drizzle maps timestamptz to `Date`, so the cursor carries `.123Z` while the +// database holds `.123456`, and the `published = cursor AND id < cursor.id` +// tie-breaker never matches the remaining rows. +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()); + // `Date` cannot express microseconds, so the value goes through SQL. + await db.insert(schema.objects).values( + ids.map((id) => ({ + id, + actorId: localActorId, + type: "Note" as const, + iri: `https://test-instance.drfed.org/users/${localActorId}/${id}`, + contentHtml: "test", + published: sql`'2026-09-14T12:00:00.123456Z'::timestamptz`, + })), + ); + const query = `query($actor: ID!, $after: String) { node(id: $actor) { ... on Actor { objects(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: { actor: globalId("Actor", localActorId), after }, + }) + ).json(); + assert.equal(body.errors, undefined); + const connection = body.data.node.objects; + 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); + }); + }); +}); + +// 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 db.insert(schema.objects).values( + [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: new Date() }) + .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 }], + }, + }); + }); + }); +}); From 412a045c673df6ad8cc534c238b19426407e7561 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 15 Sep 2026 22:26:10 +0900 Subject: [PATCH 12/20] Maintain timestamp precision and hide soft-deleted resources Use Temporal.Instant for all timestamptz columns and GraphQL DateTime scalars. Preserve microsecond precision in composite cursors, and provide unique tie-breakers for membership connections without overwriting cursor predicates. Filter out deleted actors and their objects across nodes, relationships, addressing targets, and observed collection items/counts. Retain addressing occurrences where the target is nullable when the referenced entity is deleted. Defer deletion propagation and record future transaction and retention policy rules. During the review of PR [[#73](https://github.com/fedify-dev/drfed/issues/73)](https://github.com/fedify-dev/drfed/issues/73), the user wrote a 3-step review application plan that requires no schema changes. They requested Codex to implement this and then run an independent Claude Fable 5 review. Codex implemented the changes, aligned the plan with the existing resource/activity model, and added regression tests. Claude Code reviewed the changes using claude-fable-5, and Codex confirmed and fixed Claude Code's finding that direct LocalActor nodes exposed details of deleted actors. Codex verified `mise run check`, `mise run test` (including build, 136 tests passed), no schema differences via `mise run generate:migrate`, and HTTP 200 GraphQL and frontend responses from `mise run dev --no-seed` using a temporary local `.env` file that was subsequently removed. The human user also read and reviewed the code, and verified its operation with `mise run check` and `mise run test`. Assisted-by: Codex:gpt-6 Assisted-by: Claude Code:claude-fable-5 --- packages/drfed/src/seed.ts | 2 +- packages/drfed/src/serving.test.ts | 9 +- packages/graphql/src/account.test.ts | 61 +++++- packages/graphql/src/account.ts | 10 +- packages/graphql/src/actor.test.ts | 88 ++++++-- packages/graphql/src/actor.ts | 15 +- packages/graphql/src/auth.test.ts | 6 +- packages/graphql/src/auth/challenge.ts | 2 +- packages/graphql/src/builder.test.ts | 55 +++++ packages/graphql/src/builder.ts | 44 +++- packages/graphql/src/federation.test.ts | 115 ++++++++-- packages/graphql/src/federation.ts | 15 +- packages/graphql/src/index.ts | 2 +- packages/graphql/src/instance.test.ts | 36 +-- packages/graphql/src/instance.ts | 6 +- packages/graphql/src/object.test.ts | 205 ++++++++++++++++-- packages/graphql/src/object.ts | 14 +- packages/graphql/src/resource.test.ts | 181 ++++++++++++++++ packages/graphql/src/resource.ts | 57 ++++- packages/graphql/src/seed.test.ts | 14 +- packages/models/src/login.test.ts | 36 ++- packages/models/src/login.ts | 10 +- packages/models/src/relations.ts | 13 +- packages/models/src/resource.test.ts | 5 +- packages/models/src/schema.ts | 94 ++++---- .../workspace/create/[instance_id]/actors.tsx | 2 +- scripts/create-account.mts | 2 +- 27 files changed, 903 insertions(+), 196 deletions(-) create mode 100644 packages/graphql/src/builder.test.ts create mode 100644 packages/graphql/src/resource.test.ts 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/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 c5fa63e..27dad06 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -23,7 +23,7 @@ import assert from "node:assert/strict"; 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, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { withTestHarness } from "./harness.test.ts"; import { @@ -34,9 +34,11 @@ import { ok, remoteActorId, remoteInstanceId, + seedActors, seedAuthenticatedLocalInstance, seedLocalActor, seedLocalInstance, + seedObjects, seedRemoteActor, } from "./seed.test.ts"; @@ -252,7 +254,7 @@ describe("Actor", () => { featured: { iri: `https://test-instance.drfed.org/users/${localActorId}/featured`, }, - created: created.toISOString(), + created: created.toString(), }, }, }); @@ -295,7 +297,7 @@ describe("Actor", () => { headerUrl: "https://remote.example.com/users/bob/header.png", profileUrl: "https://remote.example.com/@bob", featured: { iri: "https://remote.example.com/users/bob/featured" }, - created: created.toISOString(), + created: created.toString(), }, }, }); @@ -308,7 +310,7 @@ describe("Actor", () => { await seedRemoteActor(db); await db .update(schema.actors) - .set({ deleted: new Date() }) + .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 } } } @@ -356,7 +358,6 @@ function localActorValues(id: Uuid, username: string) { username, iri, inboxUrl: `${iri}/inbox`, - outboxUrl: `${iri}/outbox`, }; } @@ -371,10 +372,11 @@ describe("Instance.actors with a deleted actor", () => { await seedLocalActor(db); const carolId = "00000000-0000-4000-8000-000000000203" as const; await db.insert(schema.localActors).values({ id: carolId }); - await db.insert(schema.actors).values(localActorValues(carolId, "carol")); + await seedActors(db, localActorValues(carolId, "carol")); const hiddenObjectId = uuid(); const liveObjectId = uuid(); - await db.insert(schema.objects).values( + await seedObjects( + db, [hiddenObjectId, liveObjectId].map((id) => ({ id, actorId: id === hiddenObjectId ? localActorId : carolId, @@ -385,7 +387,7 @@ describe("Instance.actors with a deleted actor", () => { ); await db .update(schema.actors) - .set({ deleted: new Date() }) + .set({ deleted: Temporal.Now.instant() }) .where(eq(schema.actors.id, localActorId)); const body = await ( await post({ @@ -419,19 +421,18 @@ describe("Instance.actors with a deleted actor", () => { // Regression test for // https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163244: -// the `created` cursor is truncated to milliseconds by the `Date` mapping, so -// rows that differ only in microseconds are skipped on the next page. +// 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()); - // `Date` cannot express microseconds, so the values go through SQL. - await db.insert(schema.actors).values( - ids.map((id, index) => ({ + await seedActors( + db, + ids.map((id) => ({ ...localActorValues(id, id), localId: null, - created: sql`${`2026-09-14T12:00:00.12345${index}Z`}::timestamptz`, + 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 } } } } }`; @@ -459,8 +460,65 @@ describe("Instance.actors cursor precision", () => { ({ hasNextPage } = connection.pageInfo); ({ cursor: after } = connection.edges.at(-1)); } - assert.deepEqual(seen, [...ids].reverse()); + assert.deepEqual(seen, [...ids].sort().reverse()); assert.equal(hasNextPage, false); }); }); }); + +it("resolves multiple actor roles referencing a shared collection", async () => { + await withTestHarness(async ({ db, post }) => { + await seedLocalActor(db); + await seedRemoteActor(db); + const outbox = await db.query.collections.findFirst({ + where: { ownerActorId: localActorId, role: "outbox" }, + with: { resource: true }, + }); + assert.ok(outbox); + 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); + }); +}); + +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 722d497..739b02f 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -25,7 +25,7 @@ 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"; @@ -161,6 +161,10 @@ export const Actor: DrFedObjectRef = ActorRef; 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, @@ -300,7 +304,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), ), @@ -407,7 +411,7 @@ function generateActor( } const actorsConnection = drizzleConnectionHelpers(builder, "actors", { - query: { orderBy: { created: "desc" } }, + query: { orderBy: { created: "desc", id: "desc" } }, }); builder.drizzleObjectField("instances", "actors", (t) => @@ -428,7 +432,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/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 faa6fdd..2474d29 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -33,12 +33,7 @@ import type { Transport } from "@upyo/core"; import { getTableConfig } from "drizzle-orm/pg-core"; import { and, eq, isNotNull } from "drizzle-orm/sql/expressions"; import { GraphQLScalarType, Kind } from "graphql"; -import { - DateTimeResolver, - JSONResolver, - URLResolver, - UUIDResolver, -} from "graphql-scalars"; +import { JSONResolver, URLResolver, UUIDResolver } from "graphql-scalars"; /** * The context data for the GraphQL server, which includes the incoming request @@ -105,8 +100,8 @@ export interface SchemaTypes { Scalars: { JSON: { Input: unknown; Output: unknown }; DateTime: { - Input: Date; - Output: Date; + Input: Temporal.Instant; + Output: Temporal.Instant; }; Email: { Input: string; @@ -192,11 +187,18 @@ export const builder = new SchemaBuilder({ }, }); -const filterDeleted = (node: unknown): unknown => +const isDeleted = (node: unknown): boolean => node != null && typeof node === "object" && "deleted" in node && - node.deleted != null + node.deleted != null; + +const filterDeleted = (node: unknown): unknown => + isDeleted(node) || + (node != null && + typeof node === "object" && + "actor" in node && + isDeleted(node.actor)) ? null : node; @@ -233,7 +235,27 @@ async function isLocalInstanceMember( return rows.length > 0; } -builder.addScalarType("DateTime", DateTimeResolver); +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({ diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index f68322d..1d00663 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -101,7 +101,7 @@ describe("createFederation()", () => { 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, @@ -250,7 +250,7 @@ describe("ActivityPub objects", () => { }); }); } - for (const deleted of [null, new Date("2026-09-06T12:00:00Z")]) { + 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); @@ -282,7 +282,7 @@ describe("ActivityPub objects", () => { { contextData: undefined }, ); assert.equal((await response.json()).type, "Article"); - const deletedAt = new Date("2026-09-06T12:00:00.000Z"); + const deletedAt = Temporal.Instant.from("2026-09-06T12:00:00.000Z"); await db .update(schema.objects) .set({ deleted: deletedAt }) @@ -295,7 +295,10 @@ describe("ActivityPub objects", () => { assert.equal(deleted.status, 200); const tombstone = await deleted.json(); assert.equal(tombstone.type, "Tombstone"); - assert.equal(new Date(tombstone.deleted).getTime(), deletedAt.getTime()); + assert.equal( + Temporal.Instant.from(tombstone.deleted).epochNanoseconds, + deletedAt.epochNanoseconds, + ); }); }); for (const scenario of [ @@ -318,7 +321,7 @@ describe("ActivityPub objects", () => { if (scenario === "deletedActor") { await db .update(schema.actors) - .set({ deleted: new Date() }) + .set({ deleted: Temporal.Now.instant() }) .where(eq(schema.actors.id, localActorId)); } const iri = @@ -402,12 +405,12 @@ describe("ActivityPub Create activities", () => { scenario === "followers" ? { to: [`${actorIri}/followers`] } : { to: [PUBLIC_IRI] }, - deleted: scenario === "deleted" ? new Date() : null, + deleted: scenario === "deleted" ? Temporal.Now.instant() : null, }); if (scenario === "deletedActor") { await db .update(schema.actors) - .set({ deleted: new Date() }) + .set({ deleted: Temporal.Now.instant() }) .where(eq(schema.actors.id, localActorId)); } const iri = @@ -460,7 +463,7 @@ describe("ActivityPub outbox", () => { : index === 20 ? { to: [`${actorIri}/followers`], cc: [PUBLIC_IRI] } : { to: [PUBLIC_IRI] }, - deleted: index === 21 ? new Date() : null, + deleted: index === 21 ? Temporal.Now.instant() : null, })), ); await db @@ -590,8 +593,8 @@ describe("ActivityPub outbox", () => { }); }); -const createMutation = `mutation Create($actor: ID!, $visibility: ObjectVisibility!) { - createObject(actor: $actor, contentHtml: "

Hello

", visibility: $visibility) { +const createMutation = `mutation Create($actor: ID!, $addressing: AddressingInput!) { + createObject(actor: $actor, contentHtml: "

Hello

", addressing: $addressing) { ... on Object { uuid } ... on CreateObjectError { errorType: type message } } @@ -599,8 +602,7 @@ const createMutation = `mutation Create($actor: ID!, $visibility: ObjectVisibili // Regression tests for // https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163252: -// the outbox counter reads `postsCount`, which counts objects that the outbox -// pages never return. +// The outbox counter must match its page predicate independently of postsCount. describe("ActivityPub outbox totalItems", () => { for (const scenario of ["followers", "deleted"] as const) { it(`does not count ${scenario} objects that outbox pages never return`, async () => { @@ -613,7 +615,10 @@ describe("ActivityPub outbox totalItems", () => { query: createMutation, variables: { actor: globalId("Actor", localActorId), - visibility: scenario === "followers" ? "FOLLOWERS" : "PUBLIC", + addressing: + scenario === "followers" + ? { to: [`${actorIri}/followers`] } + : { to: [PUBLIC_IRI] }, }, }, auth, @@ -624,7 +629,7 @@ describe("ActivityPub outbox totalItems", () => { if (scenario === "deleted") { await db .update(schema.objects) - .set({ deleted: new Date() }) + .set({ deleted: Temporal.Now.instant() }) .where(eq(schema.objects.id, body.data.createObject.uuid)); } const fetchJson = async (iri: string) => { @@ -643,3 +648,85 @@ describe("ActivityPub outbox totalItems", () => { }); } }); + +describe("stored collection membership and independent activity addressing", () => { + it("serves backfilled 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(object.id)); + const fetch = (iri: string) => + federation.fetch(new Request(iri, { headers: accept }), { + contextData: undefined, + }); + assert.equal((await fetch(createIri(object.id))).status, 200); + await db + .delete(schema.addressing) + .where(eq(schema.addressing.sourceId, activity.id)); + assert.equal((await fetch(createIri(object.id))).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 collection = await db.query.collections.findFirst({ + where: { ownerActorId: localActorId, role }, + }); + assert.ok(collection); + 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 { kind role totalCount items(first: 1) { edges { cursor node { kind iri ... 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", + username: "bob", + }); + }); + }); +}); diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 0c6bc14..e6133f4 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -174,7 +174,7 @@ export function buildFederation(db: Database): FederationBuilder { if (object.deleted != null) { return new Tombstone({ id: ctx.getObjectUri(APObject, { identifier, id }), - deleted: Temporal.Instant.from(object.deleted.toISOString()), + deleted: object.deleted, }); } return toObject(ctx, object); @@ -338,11 +338,12 @@ 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) ); } @@ -526,8 +527,8 @@ export function toObject( name: object.name, summary: object.summary, sensitive: object.sensitive, - published: object.published.toTemporalInstant(), - updated: object.updated.toTemporalInstant(), + published: object.published, + updated: object.updated, url: object.url == null ? null : new URL(object.url), ...recipients(object.addressing), }); @@ -546,6 +547,6 @@ export function toCreate( actor: new URL(activity.actor.resource.iri), ...recipients(activity.addressing), object: activity.object == null ? null : new URL(activity.object.iri), - published: activity.published.toTemporalInstant(), + 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 index 4944972..925b364 100644 --- a/packages/graphql/src/object.test.ts +++ b/packages/graphql/src/object.test.ts @@ -24,7 +24,7 @@ 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, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { withTestHarness } from "./harness.test.ts"; import { @@ -142,12 +142,14 @@ describe("Mutation.createObject", () => { await db.update(schema.instanceMembers).set({ accepted: null }); } if (scenario === "expired") { - await db.update(schema.localInstances).set({ expires: new Date(0) }); + await db + .update(schema.localInstances) + .set({ expires: Temporal.Instant.fromEpochMilliseconds(0) }); } if (scenario === "deleted") { await db .update(schema.actors) - .set({ deleted: new Date() }) + .set({ deleted: Temporal.Now.instant() }) .where(eq(schema.actors.id, localActorId)); } const actorId = @@ -205,7 +207,7 @@ describe("Mutation.createObject", () => { await seedLocalActor(db); await db .update(schema.actors) - .set({ suspended: new Date(0) }) + .set({ suspended: Temporal.Instant.fromEpochMilliseconds(0) }) .where(eq(schema.actors.id, localActorId)); for (const addressing of [ { to: [PUBLIC_IRI] }, @@ -297,8 +299,10 @@ describe("Actor.objects", () => { type: "Note" as const, iri: `https://test.example/${id}`, contentHtml: "test", - published: new Date(index === 0 ? "2027-01-01" : "2026-01-01"), - deleted: index === 3 ? new Date() : null, + 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 } } } } }`; @@ -366,7 +370,7 @@ describe("Query.node", () => { type: "Note" as const, iri: `https://test-instance.drfed.org/users/${localActorId}/${id}`, contentHtml: "test", - deleted: id === deletedId ? new Date() : null, + deleted: id === deletedId ? Temporal.Now.instant() : null, })), ); const query = `query($live: ID!, $deleted: ID!) { @@ -396,26 +400,24 @@ describe("Query.node", () => { // Regression test for // https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163244: -// Drizzle maps timestamptz to `Date`, so the cursor carries `.123Z` while the -// database holds `.123456`, and the `published = cursor AND id < cursor.id` -// tie-breaker never matches the remaining rows. +// 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()); - // `Date` cannot express microseconds, so the value goes through SQL. - await db.insert(schema.objects).values( + 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: sql`'2026-09-14T12:00:00.123456Z'::timestamptz`, + 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 } } pageInfo { hasNextPage } } } } }`; + 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; @@ -428,7 +430,11 @@ describe("Actor.objects cursor precision", () => { ).json(); assert.equal(body.errors, undefined); const connection = body.data.node.objects; - if (connection.edges.length === 0) break; + 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, @@ -454,7 +460,8 @@ describe("Query.node with a deleted actor", () => { await seedRemoteActor(db); const hiddenId = uuid(); const liveId = uuid(); - await db.insert(schema.objects).values( + await seedObjects( + db, [hiddenId, liveId].map((id) => ({ id, actorId: id === hiddenId ? localActorId : remoteActorId, @@ -465,7 +472,7 @@ describe("Query.node with a deleted actor", () => { ); await db .update(schema.actors) - .set({ deleted: new Date() }) + .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 } } } @@ -491,3 +498,167 @@ describe("Query.node with a deleted actor", () => { }); }); }); + +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 { target { iri } } bcc { target { iri } } audience { target { iri } } createActivity { id iri document type actor { uuid } object { iri kind ... on Object { contentHtml } } to { target { iri } } bto { target { 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: { target: { iri: string } }) => r.target.iri), + addressing.to, + ); + assert.equal(object.to[0].target.kind, "unknown"); + assert.deepEqual( + object.audience.map((r: { target: { iri: string } }) => r.target.iri), + addressing.audience, + ); + assert.deepEqual(object.bto, [{ target: { iri: blind } }]); + assert.deepEqual(object.bcc, object.bto); + assert.equal(object.createActivity.type, "Create"); + assert.equal(object.createActivity.object.iri, object.iri); + assert.equal(object.createActivity.object.kind, "object"); + assert.equal( + object.createActivity.object.contentHtml, + variables.contentHtml, + ); + assert.deepEqual(object.createActivity.actor, { uuid: localActorId }); + for (const document of [ + object.document, + object.createActivity.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.createActivity.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 { 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.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 index 3259508..da6a11c 100644 --- a/packages/graphql/src/object.ts +++ b/packages/graphql/src/object.ts @@ -66,6 +66,10 @@ ExpectedClassification.implement({ }); const ObjectRef = builder.drizzleNode("objects", { name: "Object", + select: { + columns: { id: true, deleted: true }, + with: { actor: { columns: { deleted: true } } }, + }, interfaces: [Resource], description: "Represents an ActivityPub object authored by an `Actor`.", id: { @@ -90,7 +94,10 @@ const ObjectRef = builder.drizzleNode("objects", { description: "The actor that authored the object.", }), document: t.expose("document", { type: "JSON", nullable: true }), - createActivity: t.relation("createActivity", { nullable: true }), + createActivity: t.relation("createActivity", { + nullable: true, + query: { where: { actor: { deleted: { isNull: true } } } }, + }), expectedClassifications: t.field({ type: [ExpectedClassification], select: { columns: { id: true } }, @@ -169,7 +176,6 @@ registerAddressingFields("objects"); const objectsConnection = drizzleConnectionHelpers(builder, "objects", { query: { - where: { deleted: { isNull: true } }, orderBy: { published: "desc", id: "desc" }, }, }); @@ -351,7 +357,7 @@ builder.mutationFields((t) => ({ eq(schema.actors.id, actorId), isNotNull(schema.actors.localId), isNull(schema.actors.deleted), - gt(schema.localInstances.expires, new Date()), + gt(schema.localInstances.expires, Temporal.Now.instant()), eq(schema.instanceMembers.accountId, account.id), isNotNull(schema.instanceMembers.accepted), ), @@ -441,7 +447,7 @@ builder.mutationFields((t) => ({ .update(schema.actors) .set({ postsCount: sql`${schema.actors.postsCount} + 1` }) .where(eq(schema.actors.id, actorId)); - return { ...object, document: snapshot }; + return { ...object, actor: storedObject.actor, document: snapshot }; }); }, }), diff --git a/packages/graphql/src/resource.test.ts b/packages/graphql/src/resource.test.ts new file mode 100644 index 0000000..d868f51 --- /dev/null +++ b/packages/graphql/src/resource.test.ts @@ -0,0 +1,181 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// 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 { schema } from "@drfed/models"; +import { PUBLIC_IRI } from "@drfed/models/resource"; +import { uuidV7 as uuid } from "@drfed/models/uuid"; +import { eq } from "drizzle-orm"; + +import { withTestHarness } from "./harness.test.ts"; +import { + globalId, + localActorId, + remoteActorId, + seedLocalActor, + seedObjects, + seedRemoteActor, +} from "./seed.test.ts"; + +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}`; + 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] }, + }, + ]); + const activity = await db.query.activities.findFirst({ + where: { objectId: hiddenId }, + }); + const collection = await db.query.collections.findFirst({ + where: { ownerActorId: localActorId, role: "featured" }, + }); + assert.ok(activity); + assert.ok(collection); + await db.insert(schema.collectionItems).values( + [localActorId, hiddenId, liveId, activity.id].map( + (itemId, position) => ({ + collectionId: collection.id, + itemId, + position, + }), + ), + ); + 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!) { + live: node(id: $live) { ... on Object { to { target { iri ... on Actor { objects { totalCount } } } } } } + activity: node(id: $activity) { ... on Activity { actor { uuid } object { iri } } } + collection: node(id: $collection) { ... on Collection { owner { uuid } totalCount } } + }`, + variables: { + live: globalId("Object", liveId), + activity: globalId("Activity", activity.id), + collection: globalId("Collection", collection.id), + }, + }) + ).json(); + assert.equal(body.errors, undefined); + assert.equal(body.data.live.to.length, 3); + assert.equal(body.data.live.to[1].target, null); + assert.equal(body.data.live.to[2].target.iri, PUBLIC_IRI); + assert.deepEqual( + body.data.activity, + deleted === "actor" + ? null + : { actor: { uuid: localActorId }, object: null }, + ); + assert.deepEqual(body.data.collection, { + owner: deleted === "actor" ? null : { uuid: localActorId }, + totalCount: deleted === "actor" ? 1 : 3, + }); + if (deleted === "actor") assert.equal(body.data.live.to[0].target, null); + 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, deleted === "actor" ? 1 : 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 createActivity { actor { uuid } } } } }`, + variables: { id: globalId("Object", id) }, + }) + ).json(); + assert.deepEqual(body, { + data: { node: { uuid: id, createActivity: null } }, + }); + }); +}); diff --git a/packages/graphql/src/resource.ts b/packages/graphql/src/resource.ts index f9367e8..8ee73e9 100644 --- a/packages/graphql/src/resource.ts +++ b/packages/graphql/src/resource.ts @@ -18,9 +18,9 @@ import { type Database, schema } from "@drfed/models"; import type { Resource as ResourceRow } from "@drfed/models/schema"; import type { Uuid } from "@drfed/models/uuid"; import { resolveOffsetConnection } from "@pothos/plugin-relay"; -import { eq } from "drizzle-orm"; +import { type SQL, type SQLWrapper, and, eq, sql } from "drizzle-orm"; -import builder from "./builder.ts"; +import builder, { type DrFedObjectRef } from "./builder.ts"; export const ResourceKind = builder.enumType("ResourceKind", { values: schema.resourceKindEnum.enumValues, @@ -61,12 +61,17 @@ Resource.implement({ /** * Loads the typed record so interface fragments see the complete entity. - * @returns The entity represented by this resource, or the unknown resource itself. + * @returns The typed entity, or null when it or its author is deleted. */ export async function resolveResource( db: Database, row: ResourceRow, -): Promise<{ id: Uuid }> { +): 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 row; const where = { id: row.id }; const result = @@ -96,6 +101,9 @@ AddressingTarget.implement({ fields: (t) => ({ target: t.field({ type: Resource, + nullable: true, + description: + "The target resource, or null if it or its author is deleted.", resolve: (row, _, ctx) => resolveResource(ctx.db, row.targetResource), }), raw: t.expose("target", { @@ -132,7 +140,7 @@ const CollectionType = builder.enumType("CollectionType", { const CollectionRole = builder.enumType("CollectionRole", { values: schema.collectionRoleEnum.enumValues, }); -export const Collection = builder.drizzleNode("collections", { +const CollectionRef = builder.drizzleNode("collections", { name: "Collection", interfaces: [Resource], id: { column: (row) => row.id }, @@ -146,7 +154,10 @@ export const Collection = builder.drizzleNode("collections", { row.totalItems ?? ctx.db.$count( schema.collectionItems, - eq(schema.collectionItems.collectionId, row.id), + and( + eq(schema.collectionItems.collectionId, row.id), + visibleResource(schema.collectionItems.itemId), + ), ), }), items: t.connection({ @@ -155,7 +166,10 @@ export const Collection = builder.drizzleNode("collections", { resolve: (row, args, ctx) => resolveOffsetConnection({ args }, async ({ offset, limit }) => { const items = await ctx.db.query.collectionItems.findMany({ - where: { collectionId: row.id }, + where: { + collectionId: row.id, + RAW: (table) => visibleResource(table.itemId), + }, orderBy: { position: "asc", itemId: "asc" }, offset, limit, @@ -168,11 +182,17 @@ export const Collection = builder.drizzleNode("collections", { }), }), }); +export const Collection: DrFedObjectRef = CollectionRef; + const ActivityType = builder.enumType("ActivityType", { values: schema.activityTypeEnum.enumValues, }); -export const Activity = builder.drizzleNode("activities", { +const ActivityRef = builder.drizzleNode("activities", { name: "Activity", + select: { + columns: { id: true }, + with: { actor: { columns: { deleted: true } } }, + }, interfaces: [Resource], id: { column: (row) => row.id }, fields: (t) => ({ @@ -189,4 +209,25 @@ export const Activity = builder.drizzleNode("activities", { document: t.expose("document", { type: "JSON", nullable: true }), }), }); +export const Activity: DrFedObjectRef = ActivityRef; registerAddressingFields("activities"); + +/** + * Excludes deleted actors and objects, including resources authored 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 + )`; +} diff --git a/packages/graphql/src/seed.test.ts b/packages/graphql/src/seed.test.ts index 3ad152b..8aadca1 100644 --- a/packages/graphql/src/seed.test.ts +++ b/packages/graphql/src/seed.test.ts @@ -29,9 +29,9 @@ import type { PgInsertValue } from "drizzle-orm/pg-core"; import { hashSecret } from "./auth/hash.ts"; -export const accepted = new Date("2026-08-04T00:00:00.000Z"); -export const created = new Date("2026-08-04T00:00:00.000Z"); -export const expires = new Date("2030-08-04T00:00:00.000Z"); +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"; @@ -43,7 +43,13 @@ export const sessionId = "00000000-0000-4000-8000-000000000301"; export const accessToken = "test-access-token"; export function globalId( - type: "Actor" | "Instance" | "Object" | "Activity" | "Collection", + type: + | "Actor" + | "LocalActor" + | "Instance" + | "Object" + | "Activity" + | "Collection", id: string, ): string { return Buffer.from(`${type}:${id}`).toString("base64"); 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/relations.ts b/packages/models/src/relations.ts index c286751..99992ca 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, @@ -157,6 +161,7 @@ export const relations = defineRelations(schema, (r) => ({ ownerActor: r.one.actors({ from: r.collections.ownerActorId, to: r.actors.id, + where: { deleted: { isNull: true } }, }), items: r.many.collectionItems({ from: r.collections.id, @@ -235,7 +240,11 @@ export const relations = defineRelations(schema, (r) => ({ from: r.actors.id, to: r.activities.actorId, }), - objects: r.many.objects({ from: r.actors.id, to: r.objects.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 index 661884e..0b2ce73 100644 --- a/packages/models/src/resource.test.ts +++ b/packages/models/src/resource.test.ts @@ -193,7 +193,10 @@ it("backfills resources, actor collections, addressing and independent Create ac activity.resource.iri, `https://old.example/ap/creates/${id}`, ); - assert.equal(activity.published.getTime(), object.published.getTime()); + assert.equal( + activity.published.epochNanoseconds, + object.published.epochNanoseconds, + ); const targets = (rows: typeof object.addressing) => rows.map((r) => [r.property, r.position, r.targetId]); assert.deepEqual( diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 6865e58..89c63a3 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -20,6 +20,7 @@ import { boolean, char, check, + customType, index, integer, json, @@ -28,7 +29,6 @@ import { pgTable, primaryKey, text, - timestamp, unique, uniqueIndex, uuid, @@ -37,6 +37,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`; /** @@ -50,9 +64,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( @@ -77,9 +89,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 @@ -100,7 +110,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) => [ @@ -138,10 +148,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] }), @@ -171,13 +179,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; @@ -194,10 +200,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'`), }); @@ -232,9 +236,7 @@ export const resources = pgTable("resources", { id: uuid().$type().primaryKey(), iri: text().notNull().unique(), kind: resourceKindEnum().notNull(), - created: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), + created: instant().notNull().default(currentTimestamp), }); export type Resource = typeof resources.$inferSelect; @@ -275,9 +277,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, { @@ -290,15 +292,17 @@ export const actors = pgTable( 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: Let instance administrators choose deletion, anonymization or + // preservation of authored objects. For now, delete them with the actor. + deleted: instant(), }, (t) => [ unique("username_key").on(t.username, t.instanceId), @@ -350,17 +354,13 @@ export const objects = pgTable( contentHtml: text().notNull(), language: varchar({ length: 35 }), sensitive: boolean().notNull().default(false), - published: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), - updated: timestamp({ withTimezone: true }) + published: instant().notNull().default(currentTimestamp), + updated: instant() .notNull() .default(currentTimestamp) .$onUpdate(() => currentTimestamp), - created: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), - deleted: timestamp({ withTimezone: true }), + created: instant().notNull().default(currentTimestamp), + deleted: instant(), }, (t) => [ check( @@ -402,7 +402,7 @@ export const collections = pgTable( role: collectionRoleEnum(), totalItems: integer(), document: json(), - updated: timestamp({ withTimezone: true }) + updated: instant() .notNull() .default(currentTimestamp) .$onUpdate(() => currentTimestamp), @@ -447,9 +447,7 @@ export const collectionItems = pgTable( .notNull() .references(() => resources.id, { onDelete: "cascade" }), position: integer(), - observed: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), + observed: instant().notNull().default(currentTimestamp), }, (t) => [ primaryKey({ columns: [t.collectionId, t.itemId] }), @@ -473,11 +471,9 @@ export const activities = pgTable( objectId: uuid() .$type() .references(() => resources.id, { onDelete: "cascade" }), - published: timestamp({ withTimezone: true }).notNull(), + published: instant().notNull(), document: json(), - created: timestamp({ withTimezone: true }) - .notNull() - .default(currentTimestamp), + created: instant().notNull().default(currentTimestamp), }, (t) => [ index("activity_actor_published_index").on( 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 e187600..bb54b43 100644 --- a/packages/web/src/routes/workspace/create/[instance_id]/actors.tsx +++ b/packages/web/src/routes/workspace/create/[instance_id]/actors.tsx @@ -139,7 +139,7 @@ export default function CreateActorsPage(props: RouteSectionProps) { return; } - // TODO: Replace current codes to dictionary looking up + // FIXME: Replace current codes to dictionary looking up 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)) { From 168955a192c23ed56c0ef76b9205d018a225c34b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 16 Sep 2026 18:31:15 +0900 Subject: [PATCH 13/20] Squash unreleased ActivityPub resource migrations Replace the three migrations added on this branch since 165935e8 (add_objects, add_resources_addressing_and_activities, and add_actor_collection_references) with a single migration generated from the current schema on top of simplify_login_challenges. The resulting snapshot is structurally identical to the previous final one. The generated SQL is extended by hand in two places: the fixed identifier row for the public addressing collection that ensureResource() relies on, and registration of existing actors as resources before their iri column is dropped so databases created from main can still be upgraded. The legacy backfill of objects, collections, addressing, and Create activities is dropped along with the two tests that exercised it, since nothing has been deployed yet and there is no data to preserve. The user reviewed the changes and verified them locally by running `mise run check` and `mise run test`. https://github.com/fedify-dev/drfed/pull/73 Assisted-by: Claude Code:claude-fable-5-1 --- .../20260906133944_add_objects/migration.sql | 23 - .../20260906133944_add_objects/snapshot.json | 1597 ------------ .../snapshot.json | 2233 ----------------- .../migration.sql | 26 - .../migration.sql | 114 +- .../snapshot.json | 4 +- packages/models/src/resource.test.ts | 236 +- 7 files changed, 47 insertions(+), 4186 deletions(-) delete mode 100644 packages/models/drizzle/20260906133944_add_objects/migration.sql delete mode 100644 packages/models/drizzle/20260906133944_add_objects/snapshot.json delete mode 100644 packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/snapshot.json delete mode 100644 packages/models/drizzle/20260915125322_add_actor_collection_references/migration.sql rename packages/models/drizzle/{20260915095905_add_resources_addressing_and_activities => 20260916085902_add_objects_activities_and_resources}/migration.sql (62%) rename packages/models/drizzle/{20260915125322_add_actor_collection_references => 20260916085902_add_objects_activities_and_resources}/snapshot.json (99%) diff --git a/packages/models/drizzle/20260906133944_add_objects/migration.sql b/packages/models/drizzle/20260906133944_add_objects/migration.sql deleted file mode 100644 index 3eb0b0b..0000000 --- a/packages/models/drizzle/20260906133944_add_objects/migration.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TYPE "object_type" AS ENUM('Article', 'Note');--> statement-breakpoint -CREATE TYPE "object_visibility" AS ENUM('public', 'unlisted', 'followers');--> statement-breakpoint -CREATE TABLE "objects" ( - "id" uuid PRIMARY KEY, - "actorId" uuid NOT NULL, - "type" "object_type" NOT NULL, - "iri" text NOT NULL UNIQUE, - "url" text, - "visibility" "object_visibility" DEFAULT 'public'::"object_visibility" NOT NULL, - "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 INDEX "object_actor_published_index" ON "objects" ("actorId","published" desc,"id" desc);--> statement-breakpoint -ALTER TABLE "objects" ADD CONSTRAINT "objects_actorId_actors_id_fkey" FOREIGN KEY ("actorId") REFERENCES "actors"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/packages/models/drizzle/20260906133944_add_objects/snapshot.json b/packages/models/drizzle/20260906133944_add_objects/snapshot.json deleted file mode 100644 index 7deecd3..0000000 --- a/packages/models/drizzle/20260906133944_add_objects/snapshot.json +++ /dev/null @@ -1,1597 +0,0 @@ -{ - "version": "8", - "dialect": "postgres", - "id": "97f2c698-2361-4eae-b5c0-969ee1d2f8e7", - "prevIds": ["6b70d7c3-f645-4130-8f85-073c3204c78d"], - "ddl": [ - { - "values": ["Application", "Group", "Organization", "Person", "Service"], - "name": "actor_type", - "entityType": "enums", - "schema": "public" - }, - { - "values": ["Article", "Note"], - "name": "object_type", - "entityType": "enums", - "schema": "public" - }, - { - "values": ["public", "unlisted", "followers"], - "name": "object_visibility", - "entityType": "enums", - "schema": "public" - }, - { - "isRlsEnabled": false, - "name": "accounts", - "entityType": "tables", - "schema": "public" - }, - { - "isRlsEnabled": false, - "name": "actors", - "entityType": "tables", - "schema": "public" - }, - { - "isRlsEnabled": false, - "name": "instance_members", - "entityType": "tables", - "schema": "public" - }, - { - "isRlsEnabled": false, - "name": "instances", - "entityType": "tables", - "schema": "public" - }, - { - "isRlsEnabled": false, - "name": "local_actors", - "entityType": "tables", - "schema": "public" - }, - { - "isRlsEnabled": false, - "name": "local_instances", - "entityType": "tables", - "schema": "public" - }, - { - "isRlsEnabled": false, - "name": "login_tokens", - "entityType": "tables", - "schema": "public" - }, - { - "isRlsEnabled": false, - "name": "objects", - "entityType": "tables", - "schema": "public" - }, - { - "isRlsEnabled": false, - "name": "sessions", - "entityType": "tables", - "schema": "public" - }, - { - "type": "uuid", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "id", - "entityType": "columns", - "schema": "public", - "table": "accounts" - }, - { - "type": "varchar(255)", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "email", - "entityType": "columns", - "schema": "public", - "table": "accounts" - }, - { - "type": "varchar(100)", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "name", - "entityType": "columns", - "schema": "public", - "table": "accounts" - }, - { - "type": "integer", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "10", - "generated": null, - "identity": null, - "name": "max_instances", - "entityType": "columns", - "schema": "public", - "table": "accounts" - }, - { - "type": "boolean", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "false", - "generated": null, - "identity": null, - "name": "admin", - "entityType": "columns", - "schema": "public", - "table": "accounts" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "CURRENT_TIMESTAMP", - "generated": null, - "identity": null, - "name": "created", - "entityType": "columns", - "schema": "public", - "table": "accounts" - }, - { - "type": "uuid", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "id", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "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": "text", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "iri", - "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": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "outboxUrl", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "followersUrl", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "followingUrl", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "featuredUrl", - "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": "integer", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "0", - "generated": null, - "identity": null, - "name": "postsCount", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "CURRENT_TIMESTAMP", - "generated": null, - "identity": null, - "name": "updated", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "published", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "CURRENT_TIMESTAMP", - "generated": null, - "identity": null, - "name": "created", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "deleted", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "uuid", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "accountId", - "entityType": "columns", - "schema": "public", - "table": "instance_members" - }, - { - "type": "uuid", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "instanceId", - "entityType": "columns", - "schema": "public", - "table": "instance_members" - }, - { - "type": "boolean", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "false", - "generated": null, - "identity": null, - "name": "admin", - "entityType": "columns", - "schema": "public", - "table": "instance_members" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "accepted", - "entityType": "columns", - "schema": "public", - "table": "instance_members" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "CURRENT_TIMESTAMP", - "generated": null, - "identity": null, - "name": "created", - "entityType": "columns", - "schema": "public", - "table": "instance_members" - }, - { - "type": "uuid", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "id", - "entityType": "columns", - "schema": "public", - "table": "instances" - }, - { - "type": "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_tokens" - }, - { - "type": "uuid", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "accountId", - "entityType": "columns", - "schema": "public", - "table": "login_tokens" - }, - { - "type": "varchar(64)", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "tokenHash", - "entityType": "columns", - "schema": "public", - "table": "login_tokens" - }, - { - "type": "varchar(64)", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "codeHash", - "entityType": "columns", - "schema": "public", - "table": "login_tokens" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "CURRENT_TIMESTAMP", - "generated": null, - "identity": null, - "name": "created", - "entityType": "columns", - "schema": "public", - "table": "login_tokens" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'", - "generated": null, - "identity": null, - "name": "expires", - "entityType": "columns", - "schema": "public", - "table": "login_tokens" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "consumed", - "entityType": "columns", - "schema": "public", - "table": "login_tokens" - }, - { - "type": "uuid", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "id", - "entityType": "columns", - "schema": "public", - "table": "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": "text", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "iri", - "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": "object_visibility", - "typeSchema": "public", - "notNull": true, - "dimensions": 0, - "default": "'public'", - "generated": null, - "identity": null, - "name": "visibility", - "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": "sessions" - }, - { - "type": "uuid", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "accountId", - "entityType": "columns", - "schema": "public", - "table": "sessions" - }, - { - "type": "varchar(64)", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "tokenHash", - "entityType": "columns", - "schema": "public", - "table": "sessions" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "CURRENT_TIMESTAMP", - "generated": null, - "identity": null, - "name": "created", - "entityType": "columns", - "schema": "public", - "table": "sessions" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'", - "generated": null, - "identity": null, - "name": "expires", - "entityType": "columns", - "schema": "public", - "table": "sessions" - }, - { - "nameExplicit": true, - "columns": [ - { - "value": "instanceId", - "isExpression": false, - "asc": true, - "nullsFirst": false, - "opclass": null - } - ], - "isUnique": false, - "where": null, - "with": "", - "method": "btree", - "concurrently": false, - "name": "actor_instance_index", - "entityType": "indexes", - "schema": "public", - "table": "actors" - }, - { - "nameExplicit": false, - "columns": [ - { - "value": "accountId", - "isExpression": false, - "asc": true, - "nullsFirst": false, - "opclass": null - } - ], - "isUnique": false, - "where": "\"accepted\" IS NOT NULL", - "with": "", - "method": "btree", - "concurrently": false, - "name": "instance_members_accountId_index", - "entityType": "indexes", - "schema": "public", - "table": "instance_members" - }, - { - "nameExplicit": false, - "columns": [ - { - "value": "instanceId", - "isExpression": false, - "asc": true, - "nullsFirst": false, - "opclass": null - } - ], - "isUnique": false, - "where": "\"accepted\" IS NOT NULL", - "with": "", - "method": "btree", - "concurrently": false, - "name": "instance_members_instanceId_index", - "entityType": "indexes", - "schema": "public", - "table": "instance_members" - }, - { - "nameExplicit": 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": ["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": ["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_tokens" - }, - { - "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": ["instanceId", "accountId"], - "nameExplicit": false, - "name": "instance_members_pkey", - "entityType": "pks", - "schema": "public", - "table": "instance_members" - }, - { - "columns": ["id"], - "nameExplicit": false, - "name": "accounts_pkey", - "schema": "public", - "table": "accounts", - "entityType": "pks" - }, - { - "columns": ["id"], - "nameExplicit": false, - "name": "actors_pkey", - "schema": "public", - "table": "actors", - "entityType": "pks" - }, - { - "columns": ["id"], - "nameExplicit": false, - "name": "instances_pkey", - "schema": "public", - "table": "instances", - "entityType": "pks" - }, - { - "columns": ["id"], - "nameExplicit": false, - "name": "local_actors_pkey", - "schema": "public", - "table": "local_actors", - "entityType": "pks" - }, - { - "columns": ["id"], - "nameExplicit": false, - "name": "local_instances_pkey", - "schema": "public", - "table": "local_instances", - "entityType": "pks" - }, - { - "columns": ["id"], - "nameExplicit": false, - "name": "login_tokens_pkey", - "schema": "public", - "table": "login_tokens", - "entityType": "pks" - }, - { - "columns": ["id"], - "nameExplicit": false, - "name": "objects_pkey", - "schema": "public", - "table": "objects", - "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": 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": ["iri"], - "nullsNotDistinct": false, - "name": "actors_iri_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": ["tokenHash"], - "nullsNotDistinct": false, - "name": "login_tokens_tokenHash_key", - "schema": "public", - "table": "login_tokens", - "entityType": "uniques" - }, - { - "nameExplicit": false, - "columns": ["iri"], - "nullsNotDistinct": false, - "name": "objects_iri_key", - "schema": "public", - "table": "objects", - "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/20260915095905_add_resources_addressing_and_activities/snapshot.json b/packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/snapshot.json deleted file mode 100644 index 344b91a..0000000 --- a/packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/snapshot.json +++ /dev/null @@ -1,2233 +0,0 @@ -{ - "version": "8", - "dialect": "postgres", - "id": "bb2c4a8b-5c07-4f2e-abeb-c9710239aeef", - "prevIds": [ - "3d7bb672-489b-4d1e-8efb-e602d21f6e98", - "97f2c698-2361-4eae-b5c0-969ee1d2f8e7" - ], - "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", "public"], - "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": "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": "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": "integer", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "0", - "generated": null, - "identity": null, - "name": "postsCount", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "CURRENT_TIMESTAMP", - "generated": null, - "identity": null, - "name": "updated", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "published", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "CURRENT_TIMESTAMP", - "generated": null, - "identity": null, - "name": "created", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "deleted", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, - { - "type": "uuid", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "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": "json", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "target", - "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": "collection_role", - "typeSchema": "public", - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "role", - "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": "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": true, - "columns": [ - { - "value": "ownerActorId", - "isExpression": false, - "asc": true, - "nullsFirst": false, - "opclass": null - }, - { - "value": "role", - "isExpression": false, - "asc": true, - "nullsFirst": false, - "opclass": null - } - ], - "isUnique": true, - "where": "\"role\" IS NOT NULL", - "with": "", - "method": "btree", - "concurrently": false, - "name": "collection_owner_role_key", - "entityType": "indexes", - "schema": "public", - "table": "collections" - }, - { - "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": ["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": ["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/20260915125322_add_actor_collection_references/migration.sql b/packages/models/drizzle/20260915125322_add_actor_collection_references/migration.sql deleted file mode 100644 index e903d39..0000000 --- a/packages/models/drizzle/20260915125322_add_actor_collection_references/migration.sql +++ /dev/null @@ -1,26 +0,0 @@ -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 INDEX "actor_collection_reference_collection_index" ON "actor_collection_references" ("collectionId");--> 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 --- Recover every actor-declared role from the legacy snapshots, including --- multiple roles or actors naming the same collection IRI. -INSERT INTO actor_collection_references ("actorId", role, "collectionId") -SELECT a.id, roles.role::collection_role, r.id -FROM actors a CROSS JOIN (VALUES ('followers'), ('following'), ('featured'), ('outbox')) roles(role) -JOIN resources r ON r.iri = a.document ->> roles.role -JOIN collections c ON c.id = r.id -ON CONFLICT DO NOTHING; ---> statement-breakpoint --- Also upgrade databases that already applied the initial resource migration --- before it started retaining legacy snapshots, plus subsequently created actors. -INSERT INTO actor_collection_references ("actorId", role, "collectionId") -SELECT "ownerActorId", role, id FROM collections -WHERE "ownerActorId" IS NOT NULL AND role IS NOT NULL -ON CONFLICT DO NOTHING; diff --git a/packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/migration.sql b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/migration.sql similarity index 62% rename from packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/migration.sql rename to packages/models/drizzle/20260916085902_add_objects_activities_and_resources/migration.sql index b9bb2c3..29423b3 100644 --- a/packages/models/drizzle/20260915095905_add_resources_addressing_and_activities/migration.sql +++ b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/migration.sql @@ -2,6 +2,7 @@ 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', 'public');--> 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, @@ -13,6 +14,13 @@ CREATE TABLE "activities" ( "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, @@ -41,6 +49,24 @@ CREATE TABLE "collections" ( "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, @@ -48,77 +74,27 @@ CREATE TABLE "resources" ( "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL ); --> statement-breakpoint -ALTER TABLE "actors" DROP CONSTRAINT "actors_iri_key";--> statement-breakpoint -ALTER TABLE "objects" DROP CONSTRAINT "objects_iri_key";--> statement-breakpoint -ALTER TABLE "actors" ADD COLUMN "document" json;--> statement-breakpoint -ALTER TABLE "objects" ADD COLUMN "document" json;--> statement-breakpoint --- Preserve existing identifiers before dropping their old columns. +-- 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 -INSERT INTO resources (id, iri, kind, created) SELECT id, iri, 'object', created FROM objects; ---> statement-breakpoint -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, role) VALUES ('00000000-0000-4000-8000-000000000000', 'Collection', 'public'); ---> statement-breakpoint -INSERT INTO resources (id, iri, kind) -SELECT gen_random_uuid(), iri, 'collection' FROM ( - SELECT DISTINCT c.iri FROM actors a CROSS JOIN LATERAL (VALUES - (a."followersUrl"), (a."followingUrl"), (a."featuredUrl"), (a."outboxUrl") - ) c(iri) WHERE c.iri IS NOT NULL -) urls ON CONFLICT (iri) DO NOTHING; ---> statement-breakpoint --- These are reconstructed legacy relationship snapshots, not fetched documents. -UPDATE actors SET document = json_strip_nulls(json_build_object( - '@context', 'https://www.w3.org/ns/activitystreams', 'id', iri, 'type', type, - 'inbox', "inboxUrl", 'outbox', "outboxUrl", 'followers', "followersUrl", - 'following', "followingUrl", 'featured', "featuredUrl" -)); ---> statement-breakpoint -INSERT INTO collections (id, type, "ownerActorId", role) -SELECT r.id, 'OrderedCollection', - CASE WHEN count(DISTINCT a.id) = 1 THEN min(a.id::text)::uuid END, - CASE WHEN count(DISTINCT c.role) = 1 THEN min(c.role)::collection_role END -FROM actors a CROSS JOIN LATERAL (VALUES - ('followers', a."followersUrl"), ('following', a."followingUrl"), - ('featured', a."featuredUrl"), ('outbox', a."outboxUrl") -) c(role, iri) JOIN resources r ON r.iri = c.iri -WHERE r.kind = 'collection' -GROUP BY r.id ON CONFLICT (id) DO NOTHING; ---> statement-breakpoint -INSERT INTO addressing (id, "sourceId", property, position, "targetId") -SELECT gen_random_uuid(), o.id, - CASE WHEN o.visibility = 'public' THEN 'to' ELSE 'cc' END::addressing_property, - 0, '00000000-0000-4000-8000-000000000000' -FROM objects o WHERE o.visibility IN ('public', 'unlisted'); ---> statement-breakpoint -INSERT INTO addressing (id, "sourceId", property, position, "targetId") -SELECT gen_random_uuid(), o.id, - CASE WHEN o.visibility = 'public' THEN 'cc' ELSE 'to' END::addressing_property, - 0, r.id -FROM objects o JOIN actors a ON a.id = o."actorId" -JOIN resources r ON r.iri = a."followersUrl"; ---> statement-breakpoint -INSERT INTO resources (id, iri, kind, created) -SELECT gen_random_uuid(), 'https://' || i.host || '/ap/creates/' || o.id, 'activity', o.created -FROM objects o JOIN actors a ON a.id = o."actorId" JOIN instances i ON i.id = a."instanceId"; ---> statement-breakpoint -INSERT INTO activities (id, type, "actorId", "objectId", published, created) -SELECT r.id, 'Create', o."actorId", o.id, o.published, o.created -FROM objects o JOIN actors a ON a.id = o."actorId" JOIN instances i ON i.id = a."instanceId" -JOIN resources r ON r.iri = 'https://' || i.host || '/ap/creates/' || o.id; ---> statement-breakpoint -INSERT INTO addressing (id, "sourceId", property, position, "targetId", target) -SELECT gen_random_uuid(), a.id, d.property, d.position, d."targetId", d.target -FROM activities a JOIN addressing d ON d."sourceId" = a."objectId"; ---> 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 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 UNIQUE INDEX "collection_owner_role_key" ON "collections" ("ownerActorId","role") WHERE "role" IS NOT NULL;--> 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 @@ -127,11 +103,9 @@ ALTER TABLE "collection_items" ADD CONSTRAINT "collection_items_itemId_resources 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 "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 -ALTER TABLE "objects" DROP COLUMN "iri";--> statement-breakpoint -ALTER TABLE "objects" DROP COLUMN "visibility";--> statement-breakpoint -DROP TYPE "object_visibility"; \ No newline at end of file +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, role) VALUES ('00000000-0000-4000-8000-000000000000', 'Collection', 'public'); diff --git a/packages/models/drizzle/20260915125322_add_actor_collection_references/snapshot.json b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/snapshot.json similarity index 99% rename from packages/models/drizzle/20260915125322_add_actor_collection_references/snapshot.json rename to packages/models/drizzle/20260916085902_add_objects_activities_and_resources/snapshot.json index 62e91a3..4567c7b 100644 --- a/packages/models/drizzle/20260915125322_add_actor_collection_references/snapshot.json +++ b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/snapshot.json @@ -1,8 +1,8 @@ { "version": "8", "dialect": "postgres", - "id": "017eac58-0854-4c57-9224-a18872dab88f", - "prevIds": ["bb2c4a8b-5c07-4f2e-abeb-c9710239aeef"], + "id": "a9c7a317-96db-4a3a-80fa-db8cee5609fe", + "prevIds": ["3d7bb672-489b-4d1e-8efb-e602d21f6e98"], "ddl": [ { "values": ["Create"], diff --git a/packages/models/src/resource.test.ts b/packages/models/src/resource.test.ts index 0b2ce73..793add9 100644 --- a/packages/models/src/resource.test.ts +++ b/packages/models/src/resource.test.ts @@ -14,16 +14,10 @@ // 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 upgrade before/after assertions together. -// Keep dependent database writes and observations sequential. -// oxlint-disable no-await-in-loop +// oxlint-disable max-statements -- Keep resource before/after assertions together. 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 { @@ -33,11 +27,9 @@ import { promoteResource, storeAddressing, } from "@drfed/models/resource"; -import { uuidV7 } from "@drfed/models/uuid"; import { PGlite } from "@electric-sql/pglite"; import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/pglite"; -import { migrate as migrateBaseline } from "drizzle-orm/pglite/migrator"; it("reuses exact IRIs and promotes unknown resources atomically", async () => { const client = new PGlite(); @@ -106,125 +98,6 @@ it("reuses exact IRIs and promotes unknown resources atomically", async () => { } }); -const migrationName = "20260915095905_add_resources_addressing_and_activities"; -const migrations = join( - dirname(fileURLToPath(import.meta.resolve("@drfed/models/migrate"))), - "..", - "drizzle", -); - -it("backfills resources, actor collections, addressing and independent Create activities", async () => { - const baseline = await mkdtemp(join(tmpdir(), "drfed-addressing-migration-")); - const client = new PGlite(); - try { - const entries = await readdir(migrations, { withFileTypes: true }); - await Promise.all( - entries - .filter((e) => e.isDirectory() && e.name < migrationName) - .map((e) => - cp(join(migrations, e.name), join(baseline, e.name), { - recursive: true, - }), - ), - ); - await migrateBaseline(drizzle({ client }), { migrationsFolder: baseline }); - const instanceId = uuidV7(); - const actorId = uuidV7(); - const iri = `https://old.example/users/${actorId}`; - await client.query("INSERT INTO instances (id, host) VALUES ($1, $2)", [ - instanceId, - "old.example", - ]); - await client.query( - 'INSERT INTO actors (id, "instanceId", type, username, iri, "inboxUrl", "outboxUrl", "followersUrl", "followingUrl", "featuredUrl") VALUES ($1, $2, \'Person\', \'old\', $3, $4, $5, $6, $7, $8)', - [ - actorId, - instanceId, - iri, - `${iri}/inbox`, - `${iri}/outbox`, - `${iri}/followers`, - `${iri}/following`, - `${iri}/featured`, - ], - ); - const ids = [uuidV7(), uuidV7(), uuidV7()]; - for (const [index, label] of [ - "public", - "unlisted", - "followers", - ].entries()) { - // Historical column names are restricted to the upgrade fixture. - await client.query( - "INSERT INTO objects (id, \"actorId\", type, iri, visibility, \"contentHtml\") VALUES ($1, $2, 'Note', $3, $4, 'old content')", - [ids[index], actorId, `${iri}/${ids[index]}`, label], - ); - } - await migrate({ credentials: { driver: "pglite", client } }); - const db = drizzle({ client, schema, relations }); - assert.equal(await db.$count(schema.resources), 12); - assert.equal(await db.$count(schema.collections), 5); - assert.equal(await db.$count(schema.activities), 3); - assert.equal(await db.$count(schema.addressing), 10); - const followers = await db.query.collections.findFirst({ - where: { ownerActorId: actorId, role: "followers" }, - with: { resource: true }, - }); - assert.equal(followers?.resource.iri, `${iri}/followers`); - for (const [index, id] of ids.entries()) { - const object = await db.query.objects.findFirst({ - where: { id }, - with: { - resource: true, - addressing: { orderBy: { property: "asc", position: "asc" } }, - createActivity: { - with: { - resource: true, - addressing: { orderBy: { property: "asc", position: "asc" } }, - }, - }, - }, - }); - assert.ok(object?.createActivity); - assert.equal(object.resource.iri, `${iri}/${id}`); - const activity = object.createActivity; - assert.notEqual(activity.id, id); - assert.equal( - activity.resource.iri, - `https://old.example/ap/creates/${id}`, - ); - assert.equal( - activity.published.epochNanoseconds, - object.published.epochNanoseconds, - ); - const targets = (rows: typeof object.addressing) => - rows.map((r) => [r.property, r.position, r.targetId]); - assert.deepEqual( - targets(activity.addressing), - targets(object.addressing), - ); - const expected: unknown = - index === 0 - ? [ - ["to", 0, PUBLIC_RESOURCE_ID], - ["cc", 0, followers?.id], - ] - : index === 1 - ? [ - ["to", 0, followers?.id], - ["cc", 0, PUBLIC_RESOURCE_ID], - ] - : [["to", 0, followers?.id]]; - assert.deepEqual(targets(object.addressing), expected); - } - await migrate({ credentials: { driver: "pglite", client } }); - assert.equal(await db.$count(schema.activities), 3); - } finally { - await client.close(); - await rm(baseline, { recursive: true, force: true }); - } -}); - it("reuses existing addressing targets without updating or locking their resource rows", async () => { const client = new PGlite(); try { @@ -246,110 +119,3 @@ it("reuses existing addressing targets without updating or locking their resourc await client.close(); } }); - -it("upgrades shared collection IRIs without dropping actor roles or addressing", async () => { - const baseline = await mkdtemp(join(tmpdir(), "drfed-shared-collections-")); - 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 instanceId = uuidV7(); - const alice = uuidV7(); - const bob = uuidV7(); - const shared = "https://old.example/shared"; - await client.query( - "INSERT INTO instances (id, host) VALUES ($1, 'old.example')", - [instanceId], - ); - for (const [id, username, followers, featured] of [ - [alice, "alice", shared, shared], - [bob, "bob", PUBLIC_IRI, null], - ] as const) { - await client.query( - 'INSERT INTO actors (id, "instanceId", type, username, iri, "inboxUrl", "outboxUrl", "followersUrl", "featuredUrl") VALUES ($1,$2,\'Person\',$3,$4,$5,$6,$7,$8)', - [ - id, - instanceId, - username, - `https://old.example/${username}`, - `https://old.example/${username}/inbox`, - shared, - followers, - featured, - ], - ); - await client.query( - "INSERT INTO objects (id, \"actorId\", type, iri, visibility, \"contentHtml\") VALUES ($1,$2,'Note',$3,'followers','hello')", - [uuidV7(), id, `https://old.example/${username}/note`], - ); - } - await migrate({ credentials: { driver: "pglite", client } }); - const db = drizzle({ client, schema, relations }); - assert.equal(await db.$count(schema.collections), 2); - const collection = await db.query.collections.findFirst({ - where: { resource: { iri: shared } }, - }); - assert.ok(collection); - assert.equal(collection.ownerActorId, null); - assert.equal(collection.role, null); - const refs = await db.query.actorCollectionReferences.findMany({ - with: { collection: { with: { resource: true } } }, - orderBy: { actorId: "asc", role: "asc" }, - }); - assert.equal(refs.length, 5); - for (const [actorId, role, iri] of [ - [alice, "outbox", shared], - [alice, "featured", shared], - [alice, "followers", shared], - [bob, "outbox", shared], - [bob, "followers", PUBLIC_IRI], - ] as const) { - assert.equal( - refs.find((ref) => ref.actorId === actorId && ref.role === role) - ?.collection.resource.iri, - iri, - ); - } - for (const [actorId, iri] of [ - [alice, shared], - [bob, PUBLIC_IRI], - ] as const) { - const object = await db.query.objects.findFirst({ - where: { actorId }, - with: { - addressing: { with: { targetResource: true } }, - createActivity: { - with: { addressing: { with: { targetResource: true } } }, - }, - }, - }); - assert.ok(object?.createActivity); - assert.deepEqual( - object.addressing.map((entry) => [ - entry.property, - entry.targetResource.iri, - ]), - [["to", iri]], - ); - assert.deepEqual( - object.createActivity.addressing.map((entry) => [ - entry.property, - entry.targetResource.iri, - ]), - [["to", iri]], - ); - } - } finally { - await client.close(); - await rm(baseline, { recursive: true, force: true }); - } -}); From 5de427e0429767d022045a06972d045aeb6c89b2 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 16 Sep 2026 19:59:10 +0900 Subject: [PATCH 14/20] Fill outbox collections and hide deleted actors' collections Address the remaining review findings on the ActivityPub resource work in PR #73: - Actor.outbox.totalCount always returned 0 because createObject never recorded the Create activity as an outbox member. Add addActorCollectionItem() to @drfed/models and call it from createObject and the test seed helper, so the GraphQL collection reflects the same activities the ActivityPub outbox serves. - Drop actors.postsCount. Nothing read it and it drifted because it kept counting deleted objects; Actor.objects.totalCount already derives the count from objects.deleted. - Hide collections owned by a deleted actor from node(), nodes(), addressing targets, and collection items, matching the policy that already hides the actor itself. The ownerActor relation no longer filters deleted rows; the GraphQL owner field does so instead. - Drop addressing.target and the AddressingTarget.raw field. Nothing populated the column until incoming activities are persisted, so the field always returned null. The unreleased squashed migration is edited in place and its snapshot verified with drizzle-kit generate reporting no schema changes. The user pointed out remaining issues from previous changes and requested fixes. After reviewing the changes and identifying missing tests, they requested their addition and verified the results locally by running `mise run check`, `mise run test` once all tests were added. https://github.com/fedify-dev/drfed/pull/73 Assisted-by: Claude Code:claude-fable-5-1 --- packages/graphql/src/builder.ts | 9 ++- packages/graphql/src/federation.test.ts | 6 +- packages/graphql/src/object.test.ts | 63 +++++++++++++++--- packages/graphql/src/object.ts | 14 ++-- packages/graphql/src/resource.test.ts | 61 ++++++++++++++--- packages/graphql/src/resource.ts | 21 ++++-- packages/graphql/src/seed.test.ts | 7 ++ .../migration.sql | 3 +- .../snapshot.json | 26 -------- packages/models/src/relations.ts | 1 - packages/models/src/resource.test.ts | 65 +++++++++++++++++++ packages/models/src/resource.ts | 35 +++++++++- packages/models/src/schema.ts | 3 +- 13 files changed, 244 insertions(+), 70 deletions(-) diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index 2474d29..a3a33b3 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -193,12 +193,17 @@ const isDeleted = (node: unknown): boolean => "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" && - "actor" in node && - isDeleted(node.actor)) + OWNER_RELATIONS.some((key) => + isDeleted((node as Record)[key]), + )) ? null : node; diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 1d00663..b0576f1 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -466,10 +466,6 @@ describe("ActivityPub outbox", () => { deleted: index === 21 ? Temporal.Now.instant() : null, })), ); - await db - .update(schema.actors) - .set({ postsCount: 23 }) - .where(eq(schema.actors.id, localActorId)); const fetchJson = async (iri: string) => { const response = await federation.fetch( new Request(iri, { headers: accept }), @@ -602,7 +598,7 @@ const createMutation = `mutation Create($actor: ID!, $addressing: AddressingInpu // Regression tests for // https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163252: -// The outbox counter must match its page predicate independently of postsCount. +// 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 () => { diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts index 925b364..e919a76 100644 --- a/packages/graphql/src/object.test.ts +++ b/packages/graphql/src/object.test.ts @@ -50,10 +50,21 @@ const variables = { contentHtml: "

Hello

", addressing: { to: [PUBLIC_IRI] }, }; +const outboxQuery = `query($actor: ID!) { + node(id: $actor) { + ... on Actor { + outbox { + totalCount + items { edges { node { ... on Activity { type object { ... on Object { uuid } } } } } } + } + } + } +}`; +const accept = { accept: "application/activity+json" }; describe("Mutation.createObject", () => { it("creates verbatim HTML, canonicalizes language and resolves every node field", async () => { - await withTestHarness(async ({ db, post }) => { + await withTestHarness(async ({ db, post, federation }) => { const auth = await seedAuthenticatedLocalInstance(db); await seedLocalActor(db); const contentHtml = '

Hello

'; @@ -86,11 +97,37 @@ describe("Mutation.createObject", () => { }); assert.equal(row?.contentHtml, contentHtml); assert.equal(row?.language, "ko-KR"); - assert.equal( - (await db.query.actors.findFirst({ where: { id: localActorId } })) - ?.postsCount, - 1, + // 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: { + totalCount: 1, + items: { + edges: [ + { node: { type: "Create", object: { 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 }, @@ -231,11 +268,17 @@ describe("Mutation.createObject", () => { assert.equal(body.data.createObject.summary, "CW"); assert.equal(body.data.createObject.sensitive, true); } - assert.equal( - (await db.query.actors.findFirst({ where: { id: localActorId } })) - ?.postsCount, - 3, - ); + // 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.totalCount, 3); + assert.equal(outbox.data.node.outbox.items.edges.length, 3); }); }); }); diff --git a/packages/graphql/src/object.ts b/packages/graphql/src/object.ts index da6a11c..072c020 100644 --- a/packages/graphql/src/object.ts +++ b/packages/graphql/src/object.ts @@ -14,12 +14,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { promoteResource, schema, storeAddressing } from "@drfed/models"; +import { + addActorCollectionItem, + 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, sql } from "drizzle-orm"; +import { and, eq, gt, isNotNull, isNull } from "drizzle-orm"; import { Actor } from "./actor.ts"; import builder, { type DrFedObjectRef } from "./builder.ts"; @@ -411,6 +416,7 @@ builder.mutationFields((t) => ({ published: object.published, }); await storeAddressing(inner, resource.id, addressing); + await addActorCollectionItem(inner, actorId, "outbox", resource.id); }, activityId, ); @@ -443,10 +449,6 @@ builder.mutationFields((t) => ({ }, }) .where(eq(schema.activities.id, activityId)); - await tx - .update(schema.actors) - .set({ postsCount: sql`${schema.actors.postsCount} + 1` }) - .where(eq(schema.actors.id, actorId)); return { ...object, actor: storedObject.actor, document: snapshot }; }); }, diff --git a/packages/graphql/src/resource.test.ts b/packages/graphql/src/resource.test.ts index d868f51..60e916d 100644 --- a/packages/graphql/src/resource.test.ts +++ b/packages/graphql/src/resource.test.ts @@ -43,6 +43,7 @@ for (const deleted of ["actor", "object"] as const) { 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, @@ -57,7 +58,7 @@ for (const deleted of ["actor", "object"] as const) { type: "Note", iri: `https://test.example/${liveId}`, contentHtml: "live", - addressing: { to: [actorIri, hiddenIri, PUBLIC_IRI] }, + addressing: { to: [actorIri, hiddenIri, PUBLIC_IRI, followersIri] }, }, ]); const activity = await db.query.activities.findFirst({ @@ -66,8 +67,16 @@ for (const deleted of ["actor", "object"] as const) { const collection = await db.query.collections.findFirst({ where: { ownerActorId: localActorId, role: "featured" }, }); + const remoteCollection = await db.query.collections.findFirst({ + where: { ownerActorId: remoteActorId, role: "featured" }, + }); + const followers = await db.query.resources.findFirst({ + where: { iri: followersIri }, + }); assert.ok(activity); assert.ok(collection); + assert.ok(remoteCollection); + assert.ok(followers); await db.insert(schema.collectionItems).values( [localActorId, hiddenId, liveId, activity.id].map( (itemId, position) => ({ @@ -77,6 +86,13 @@ for (const deleted of ["actor", "object"] as const) { }), ), ); + // 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) @@ -90,20 +106,23 @@ for (const deleted of ["actor", "object"] as const) { } const body = await ( await post({ - query: `query($live: ID!, $activity: ID!, $collection: ID!) { + query: `query($live: ID!, $activity: ID!, $collection: ID!, $remote: ID!) { live: node(id: $live) { ... on Object { to { target { iri ... on Actor { objects { totalCount } } } } } } activity: node(id: $activity) { ... on Activity { actor { uuid } object { iri } } } 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, 3); + assert.equal(body.data.live.to.length, 4); assert.equal(body.data.live.to[1].target, null); assert.equal(body.data.live.to[2].target.iri, PUBLIC_IRI); assert.deepEqual( @@ -112,11 +131,35 @@ for (const deleted of ["actor", "object"] as const) { ? null : { actor: { uuid: localActorId }, object: null }, ); - assert.deepEqual(body.data.collection, { - owner: deleted === "actor" ? null : { uuid: localActorId }, - totalCount: deleted === "actor" ? 1 : 3, - }); - if (deleted === "actor") assert.equal(body.data.live.to[0].target, 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].target, + 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].target, null); + return; + } const seen: string[] = []; let after: string | null = null; for (let page = 0; page < 4; page += 1) { @@ -145,7 +188,7 @@ for (const deleted of ["actor", "object"] as const) { if (!connection.pageInfo.hasNextPage) break; after = edge.cursor; } - assert.equal(seen.length, deleted === "actor" ? 1 : 3); + assert.equal(seen.length, 3); assert.ok(!seen.includes(hiddenIri)); }); }); diff --git a/packages/graphql/src/resource.ts b/packages/graphql/src/resource.ts index 8ee73e9..afaa508 100644 --- a/packages/graphql/src/resource.ts +++ b/packages/graphql/src/resource.ts @@ -106,11 +106,6 @@ AddressingTarget.implement({ "The target resource, or null if it or its author is deleted.", resolve: (row, _, ctx) => resolveResource(ctx.db, row.targetResource), }), - raw: t.expose("target", { - type: "JSON", - nullable: true, - description: "The original inline object or Link, if present.", - }), }), }); @@ -142,12 +137,19 @@ const CollectionRole = builder.enumType("CollectionRole", { }); const CollectionRef = builder.drizzleNode("collections", { name: "Collection", + select: { + columns: { id: true }, + with: { ownerActor: { columns: { deleted: true } } }, + }, interfaces: [Resource], id: { column: (row) => row.id }, fields: (t) => ({ type: t.expose("type", { type: CollectionType }), role: t.expose("role", { type: CollectionRole, nullable: true }), - owner: t.relation("ownerActor", { nullable: true }), + owner: t.relation("ownerActor", { + nullable: true, + query: { where: { deleted: { isNull: true } } }, + }), totalCount: t.int({ select: { columns: { id: true, totalItems: true } }, resolve: (row, _, ctx) => @@ -213,7 +215,8 @@ export const Activity: DrFedObjectRef = ActivityRef; registerAddressingFields("activities"); /** - * Excludes deleted actors and objects, including resources authored by deleted actors. + * 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 { @@ -229,5 +232,9 @@ function visibleResource(id: SQLWrapper): SQL { 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/seed.test.ts b/packages/graphql/src/seed.test.ts index 8aadca1..205c7a6 100644 --- a/packages/graphql/src/seed.test.ts +++ b/packages/graphql/src/seed.test.ts @@ -19,6 +19,7 @@ import { type Database, + addActorCollectionItem, promoteResource, schema, storeAddressing, @@ -236,6 +237,12 @@ export async function seedObjects( published: row.published, }); await storeAddressing(inner, activity.id, addressing); + await addActorCollectionItem( + inner, + row.actorId, + "outbox", + activity.id, + ); }, activityId, ); 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 index 29423b3..8252405 100644 --- a/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/migration.sql +++ b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/migration.sql @@ -27,7 +27,6 @@ CREATE TABLE "addressing" ( "property" "addressing_property" NOT NULL, "position" integer NOT NULL, "targetId" uuid NOT NULL, - "target" json, CONSTRAINT "addressing_source_property_position_key" UNIQUE("sourceId","property","position") ); --> statement-breakpoint @@ -84,6 +83,8 @@ 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 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 index 4567c7b..283c686 100644 --- a/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/snapshot.json +++ b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/snapshot.json @@ -643,19 +643,6 @@ "schema": "public", "table": "actors" }, - { - "type": "integer", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "0", - "generated": null, - "identity": null, - "name": "postsCount", - "entityType": "columns", - "schema": "public", - "table": "actors" - }, { "type": "timestamp with time zone", "typeSchema": null, @@ -773,19 +760,6 @@ "schema": "public", "table": "addressing" }, - { - "type": "json", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "target", - "entityType": "columns", - "schema": "public", - "table": "addressing" - }, { "type": "uuid", "typeSchema": null, diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index 99992ca..6f0d589 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -161,7 +161,6 @@ export const relations = defineRelations(schema, (r) => ({ ownerActor: r.one.actors({ from: r.collections.ownerActorId, to: r.actors.id, - where: { deleted: { isNull: true } }, }), items: r.many.collectionItems({ from: r.collections.id, diff --git a/packages/models/src/resource.test.ts b/packages/models/src/resource.test.ts index 793add9..400c843 100644 --- a/packages/models/src/resource.test.ts +++ b/packages/models/src/resource.test.ts @@ -23,6 +23,7 @@ import { migrate, relations, schema } from "@drfed/models"; import { PUBLIC_IRI, PUBLIC_RESOURCE_ID, + addActorCollectionItem, ensureResource, promoteResource, storeAddressing, @@ -119,3 +120,67 @@ it("reuses existing addressing targets without updating or locking their resourc 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, + role: "outbox", + }); + 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 }, + }), + [{ collectionId: outbox.id, itemId: item.id }], + ); + await assert.rejects( + addActorCollectionItem(db, actor.id, "featured", item.id), + /declares no featured collection/u, + ); + assert.equal(await db.$count(schema.collectionItems), 1); + } finally { + await client.close(); + } +}); diff --git a/packages/models/src/resource.ts b/packages/models/src/resource.ts index b4be6e9..564ea18 100644 --- a/packages/models/src/resource.ts +++ b/packages/models/src/resource.ts @@ -17,14 +17,17 @@ // Keep dependent database writes and observations sequential. // oxlint-disable no-await-in-loop -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import type { Database, Transaction } from "./db.ts"; import { type AddressingProperty, + type CollectionRole, type Resource, + actorCollectionReferences, addressing, addressingPropertyEnum, + collectionItems, resources, } from "./schema.ts"; import { type Uuid, uuidV7 } from "./uuid.ts"; @@ -132,3 +135,33 @@ export async function storeAddressing( } } } + +/** + * Records a resource as a member of the collection an actor declares for + * `role`, such as its outbox. Membership is idempotent per collection. + * @throws {Error} If the actor declares no collection for `role`. + */ +export async function addActorCollectionItem( + tx: Database | Transaction, + actorId: Uuid, + role: CollectionRole, + itemId: Uuid, +): Promise { + const [reference] = await tx + .select({ collectionId: actorCollectionReferences.collectionId }) + .from(actorCollectionReferences) + .where( + and( + eq(actorCollectionReferences.actorId, actorId), + eq(actorCollectionReferences.role, role), + ), + ) + .limit(1); + if (reference == null) { + throw new Error(`Actor ${actorId} declares no ${role} collection.`); + } + await tx + .insert(collectionItems) + .values({ collectionId: reference.collectionId, itemId }) + .onConflictDoNothing(); +} diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 89c63a3..09b5ead 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -291,7 +291,6 @@ export const actors = pgTable( .default(sql`(ARRAY[]::text[])`), followingCount: integer().notNull().default(0), followersCount: integer().notNull().default(0), - postsCount: integer().notNull().default(0), updated: instant() .notNull() .default(currentTimestamp) @@ -388,6 +387,7 @@ export const collectionRoleEnum = pgEnum("collection_role", [ "outbox", "public", ]); +export type CollectionRole = (typeof collectionRoleEnum.enumValues)[number]; export const collections = pgTable( "collections", { @@ -508,7 +508,6 @@ export const addressing = pgTable( .$type() .notNull() .references(() => resources.id, { onDelete: "restrict" }), - target: json(), }, (t) => [ unique("addressing_source_property_position_key").on( From 925acbe9ab9076ace60e9c60c8a20b7d3a79c7f3 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 17 Sep 2026 04:03:57 +0900 Subject: [PATCH 15/20] Split collection role, count, and addressing IRI Address the follow-up review comments on PR #73 about the resource/collection model and outbox consistency: - Drop collections.role, the collection_owner_role_key index, the GraphQL Collection.role field, and the 'public' collection_role value. Roles now live only in actor_collection_references; the Public collection is identified by its fixed UUID. Document ownerActorId as the lifecycle owner of a locally managed collection, and make Actor.outbox, followers, following, and featured return null when the referenced collection's owner is soft-deleted, matching node lookups, addressing targets, and collection items. - Add Collection.declaredTotalItems for the totalItems a collection document reports, and make Collection.totalCount always count the locally stored, visible members instead of falling back to the declared value. Document the deletion-filter policy on both fields and on Collection.items. - Add AddressingTarget.iri so the stored recipient IRI stays readable after the target or its author is deleted and target becomes null. - Document the Actor.outbox filtering policy: the GraphQL outbox keeps every stored Create activity, including those whose object is deleted, while the ActivityPub outbox serves only Public activities with live objects. Add a regression test for the deleted-object case. The unreleased migration is edited in place and its snapshot verified with drizzle-kit generate reporting no schema changes. The user wrote a per-review application plan for each review thread and requested its implementation. Codex implemented and reviewed each plan, and the user read and verified the resulting code and confirmed it by running `mise run check` and `mise run test`. https://github.com/fedify-dev/drfed/pull/73#discussion_r4025516859 https://github.com/fedify-dev/drfed/pull/73#discussion_r4025516962 https://github.com/fedify-dev/drfed/pull/73#discussion_r4025517155 https://github.com/fedify-dev/drfed/pull/73#discussion_r4025517270 Assisted-by: Codex:gpt-5.6-sol Assisted-by: Claude Code:claude-fable-5-1 --- packages/graphql/src/actor.test.ts | 51 ++++++++++++-- packages/graphql/src/actor.ts | 68 ++++++++----------- packages/graphql/src/federation.test.ts | 10 +-- packages/graphql/src/object.test.ts | 51 +++++++++++++- packages/graphql/src/resource.test.ts | 57 +++++++++++++--- packages/graphql/src/resource.ts | 22 ++++-- packages/graphql/src/seed.test.ts | 1 - .../migration.sql | 6 +- .../snapshot.json | 43 +----------- packages/models/src/resource.test.ts | 1 - packages/models/src/schema.ts | 48 ++++++------- 11 files changed, 218 insertions(+), 140 deletions(-) diff --git a/packages/graphql/src/actor.test.ts b/packages/graphql/src/actor.test.ts index 27dad06..1ae1d75 100644 --- a/packages/graphql/src/actor.test.ts +++ b/packages/graphql/src/actor.test.ts @@ -23,7 +23,7 @@ import assert from "node:assert/strict"; 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"; +import { and, eq } from "drizzle-orm"; import { withTestHarness } from "./harness.test.ts"; import { @@ -466,15 +466,56 @@ describe("Instance.actors cursor precision", () => { }); }); +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", + }, + }, + }, + }); + }); +}); + it("resolves multiple actor roles referencing a shared collection", async () => { await withTestHarness(async ({ db, post }) => { await seedLocalActor(db); await seedRemoteActor(db); - const outbox = await db.query.collections.findFirst({ - where: { ownerActorId: localActorId, role: "outbox" }, - with: { resource: true }, + const outboxReference = await db.query.actorCollectionReferences.findFirst({ + where: { actorId: localActorId, role: "outbox" }, + with: { collection: { with: { resource: true } } }, }); - assert.ok(outbox); + assert.ok(outboxReference); + const outbox = outboxReference.collection; await db .update(schema.actorCollectionReferences) .set({ collectionId: outbox.id }) diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index 739b02f..0984110 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -19,8 +19,8 @@ // Keep dependent database writes and observations sequential. // oxlint-disable no-await-in-loop -import { promoteResource, schema } from "@drfed/models"; -import { actorTypeEnum } from "@drfed/models/schema"; +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"; @@ -39,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", interfaces: [Resource], @@ -82,16 +99,11 @@ const ActorRef = builder.drizzleNode("actors", { 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: async (actor, _, ctx) => { - const reference = - await ctx.db.query.actorCollectionReferences.findFirst({ - where: { actorId: actor.id, role: "outbox" }, - with: { collection: true }, - }); - const collection = reference?.collection; - return collection ?? null; - }, + resolve: (actor, _, ctx) => + resolveActorCollection(ctx.db, actor.id, "outbox"), }), avatarUrl: t.expose("avatarUrl", { type: "URL", @@ -102,29 +114,15 @@ const ActorRef = builder.drizzleNode("actors", { type: Collection, nullable: true, select: { columns: { id: true } }, - resolve: async (actor, _, ctx) => { - const reference = - await ctx.db.query.actorCollectionReferences.findFirst({ - where: { actorId: actor.id, role: "followers" }, - with: { collection: true }, - }); - const collection = reference?.collection; - return collection ?? null; - }, + resolve: (actor, _, ctx) => + resolveActorCollection(ctx.db, actor.id, "followers"), }), following: t.field({ type: Collection, nullable: true, select: { columns: { id: true } }, - resolve: async (actor, _, ctx) => { - const reference = - await ctx.db.query.actorCollectionReferences.findFirst({ - where: { actorId: actor.id, role: "following" }, - with: { collection: true }, - }); - const collection = reference?.collection; - return collection ?? null; - }, + resolve: (actor, _, ctx) => + resolveActorCollection(ctx.db, actor.id, "following"), }), headerUrl: t.expose("headerUrl", { type: "URL", @@ -140,15 +138,8 @@ const ActorRef = builder.drizzleNode("actors", { type: Collection, nullable: true, select: { columns: { id: true } }, - resolve: async (actor, _, ctx) => { - const reference = - await ctx.db.query.actorCollectionReferences.findFirst({ - where: { actorId: actor.id, role: "featured" }, - with: { collection: true }, - }); - const collection = reference?.collection; - return collection ?? null; - }, + resolve: (actor, _, ctx) => + resolveActorCollection(ctx.db, actor.id, "featured"), }), created: t.expose("created", { type: "DateTime", @@ -375,7 +366,6 @@ builder.mutationFields((t) => ({ id: resource.id, type: "OrderedCollection", ownerActorId: actor.id, - role, }); await inner.insert(schema.actorCollectionReferences).values({ actorId: actor.id, diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index b0576f1..4df7187 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -692,10 +692,12 @@ describe("stored collection membership and independent activity addressing", () contextData: undefined, }); for (const role of ["followers", "following", "featured"] as const) { - const collection = await db.query.collections.findFirst({ - where: { ownerActorId: localActorId, role }, + const reference = await db.query.actorCollectionReferences.findFirst({ + where: { actorId: localActorId, role }, + with: { collection: true }, }); - assert.ok(collection); + assert.ok(reference); + const { collection } = reference; await db.insert(schema.collectionItems).values({ collectionId: collection.id, itemId: remoteActorId, @@ -712,7 +714,7 @@ describe("stored collection membership and independent activity addressing", () } const body = await ( await post({ - query: `query($id: ID!) { node(id: $id) { ... on Actor { followers { kind role totalCount items(first: 1) { edges { cursor node { kind iri ... on Actor { username } } } pageInfo { hasNextPage } } } } } }`, + query: `query($id: ID!) { node(id: $id) { ... on Actor { followers { kind totalCount items(first: 1) { edges { cursor node { kind iri ... on Actor { username } } } pageInfo { hasNextPage } } } } } }`, variables: { id: globalId("Actor", localActorId) }, }) ).json(); diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts index e919a76..965937f 100644 --- a/packages/graphql/src/object.test.ts +++ b/packages/graphql/src/object.test.ts @@ -37,7 +37,7 @@ import { seedRemoteActor, } from "./seed.test.ts"; -const fields = `id uuid iri url type actor { uuid } to { target { iri kind } } cc { target { iri } } name summary contentHtml language sensitive published updated created`; +const fields = `id uuid iri url type actor { uuid } to { iri target { iri kind } } cc { target { 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 @@ -54,6 +54,7 @@ const outboxQuery = `query($actor: ID!) { node(id: $actor) { ... on Actor { outbox { + declaredTotalItems totalCount items { edges { node { ... on Activity { type object { ... on Object { uuid } } } } } } } @@ -81,7 +82,7 @@ describe("Mutation.createObject", () => { assert.equal(object.resultType, "Object"); assert.equal(object.type, "Note"); assert.deepEqual(object.to, [ - { target: { iri: PUBLIC_IRI, kind: "collection" } }, + { iri: PUBLIC_IRI, target: { iri: PUBLIC_IRI, kind: "collection" } }, ]); assert.equal(object.language, "ko-KR"); assert.equal(object.contentHtml, contentHtml); @@ -109,6 +110,7 @@ describe("Mutation.createObject", () => { data: { node: { outbox: { + declaredTotalItems: null, totalCount: 1, items: { edges: [ @@ -135,6 +137,50 @@ describe("Mutation.createObject", () => { assert.deepEqual(await node.json(), { data: { node: object } }); }); }); + 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: { type: "Create", object: 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"], @@ -277,6 +323,7 @@ describe("Mutation.createObject", () => { }) ).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); }); diff --git a/packages/graphql/src/resource.test.ts b/packages/graphql/src/resource.test.ts index 60e916d..64ace03 100644 --- a/packages/graphql/src/resource.test.ts +++ b/packages/graphql/src/resource.test.ts @@ -33,6 +33,38 @@ import { 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 @@ -64,18 +96,24 @@ for (const deleted of ["actor", "object"] as const) { const activity = await db.query.activities.findFirst({ where: { objectId: hiddenId }, }); - const collection = await db.query.collections.findFirst({ - where: { ownerActorId: localActorId, role: "featured" }, - }); - const remoteCollection = await db.query.collections.findFirst({ - where: { ownerActorId: remoteActorId, role: "featured" }, - }); + 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(collection); - assert.ok(remoteCollection); assert.ok(followers); await db.insert(schema.collectionItems).values( [localActorId, hiddenId, liveId, activity.id].map( @@ -107,7 +145,7 @@ for (const deleted of ["actor", "object"] as const) { const body = await ( await post({ query: `query($live: ID!, $activity: ID!, $collection: ID!, $remote: ID!) { - live: node(id: $live) { ... on Object { to { target { iri ... on Actor { objects { totalCount } } } } } } + live: node(id: $live) { ... on Object { to { iri target { iri ... on Actor { objects { totalCount } } } } } } activity: node(id: $activity) { ... on Activity { actor { uuid } object { iri } } } collection: node(id: $collection) { ... on Collection { owner { uuid } totalCount } } collections: nodes(ids: [$collection]) { ... on Collection { totalCount } } @@ -123,6 +161,7 @@ for (const deleted of ["actor", "object"] as const) { ).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].target, null); assert.equal(body.data.live.to[2].target.iri, PUBLIC_IRI); assert.deepEqual( diff --git a/packages/graphql/src/resource.ts b/packages/graphql/src/resource.ts index afaa508..55f67e2 100644 --- a/packages/graphql/src/resource.ts +++ b/packages/graphql/src/resource.ts @@ -99,6 +99,12 @@ const AddressingTarget = builder.objectRef< >("AddressingTarget"); AddressingTarget.implement({ fields: (t) => ({ + iri: t.field({ + type: "URL", + description: + "The stored IRI, even when `target` is null because it or its author is deleted.", + resolve: (row) => row.targetResource.iri, + }), target: t.field({ type: Resource, nullable: true, @@ -132,9 +138,6 @@ export function registerAddressingFields( const CollectionType = builder.enumType("CollectionType", { values: schema.collectionTypeEnum.enumValues, }); -const CollectionRole = builder.enumType("CollectionRole", { - values: schema.collectionRoleEnum.enumValues, -}); const CollectionRef = builder.drizzleNode("collections", { name: "Collection", select: { @@ -145,15 +148,20 @@ const CollectionRef = builder.drizzleNode("collections", { id: { column: (row) => row.id }, fields: (t) => ({ type: t.expose("type", { type: CollectionType }), - role: t.expose("role", { type: CollectionRole, nullable: true }), 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({ - select: { columns: { id: true, totalItems: true } }, + 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) => - row.totalItems ?? ctx.db.$count( schema.collectionItems, and( @@ -164,6 +172,8 @@ const CollectionRef = builder.drizzleNode("collections", { }), 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) => resolveOffsetConnection({ args }, async ({ offset, limit }) => { diff --git a/packages/graphql/src/seed.test.ts b/packages/graphql/src/seed.test.ts index 205c7a6..86d5661 100644 --- a/packages/graphql/src/seed.test.ts +++ b/packages/graphql/src/seed.test.ts @@ -173,7 +173,6 @@ export async function seedActors( id: collection.id, type: "OrderedCollection", ownerActorId: resource.id, - role, }); await inner.insert(schema.actorCollectionReferences).values({ actorId: resource.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 index 8252405..05de2f9 100644 --- a/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/migration.sql +++ b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/migration.sql @@ -1,6 +1,6 @@ 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', 'public');--> 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 @@ -42,7 +42,6 @@ CREATE TABLE "collections" ( "id" uuid PRIMARY KEY, "type" "collection_type" NOT NULL, "ownerActorId" uuid, - "role" "collection_role", "totalItems" integer, "document" json, "updated" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL @@ -89,7 +88,6 @@ CREATE INDEX "activity_actor_published_index" ON "activities" ("actorId","publis 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 UNIQUE INDEX "collection_owner_role_key" ON "collections" ("ownerActorId","role") WHERE "role" IS NOT NULL;--> 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 @@ -109,4 +107,4 @@ ALTER TABLE "objects" ADD CONSTRAINT "objects_actorId_actors_id_fkey" FOREIGN KE -- 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, role) VALUES ('00000000-0000-4000-8000-000000000000', 'Collection', 'public'); +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 index 283c686..2a13db0 100644 --- a/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/snapshot.json +++ b/packages/models/drizzle/20260916085902_add_objects_activities_and_resources/snapshot.json @@ -23,7 +23,7 @@ "schema": "public" }, { - "values": ["followers", "following", "featured", "outbox", "public"], + "values": ["followers", "following", "featured", "outbox"], "name": "collection_role", "entityType": "enums", "schema": "public" @@ -851,19 +851,6 @@ "schema": "public", "table": "collections" }, - { - "type": "collection_role", - "typeSchema": "public", - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "role", - "entityType": "columns", - "schema": "public", - "table": "collections" - }, { "type": "integer", "typeSchema": null, @@ -1660,34 +1647,6 @@ "schema": "public", "table": "collection_items" }, - { - "nameExplicit": true, - "columns": [ - { - "value": "ownerActorId", - "isExpression": false, - "asc": true, - "nullsFirst": false, - "opclass": null - }, - { - "value": "role", - "isExpression": false, - "asc": true, - "nullsFirst": false, - "opclass": null - } - ], - "isUnique": true, - "where": "\"role\" IS NOT NULL", - "with": "", - "method": "btree", - "concurrently": false, - "name": "collection_owner_role_key", - "entityType": "indexes", - "schema": "public", - "table": "collections" - }, { "nameExplicit": false, "columns": [ diff --git a/packages/models/src/resource.test.ts b/packages/models/src/resource.test.ts index 400c843..79e9f2a 100644 --- a/packages/models/src/resource.test.ts +++ b/packages/models/src/resource.test.ts @@ -156,7 +156,6 @@ it("records idempotent collection membership only for declared roles", async () id: row.id, type: "OrderedCollection", ownerActorId: actor.id, - role: "outbox", }); await tx.insert(schema.actorCollectionReferences).values({ actorId: actor.id, diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 09b5ead..4a0adce 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -30,7 +30,6 @@ import { primaryKey, text, unique, - uniqueIndex, uuid, varchar, } from "drizzle-orm/pg-core"; @@ -385,34 +384,29 @@ export const collectionRoleEnum = pgEnum("collection_role", [ "following", "featured", "outbox", - "public", ]); export type CollectionRole = (typeof collectionRoleEnum.enumValues)[number]; -export const collections = pgTable( - "collections", - { - id: uuid() - .$type() - .primaryKey() - .references(() => resources.id, { onDelete: "cascade" }), - type: collectionTypeEnum().notNull(), - ownerActorId: uuid() - .$type() - .references(() => actors.id, { onDelete: "cascade" }), - role: collectionRoleEnum(), - totalItems: integer(), - document: json(), - updated: instant() - .notNull() - .default(currentTimestamp) - .$onUpdate(() => currentTimestamp), - }, - (t) => [ - uniqueIndex("collection_owner_role_key") - .on(t.ownerActorId, t.role) - .where(sql`${t.role} IS NOT NULL`), - ], -); +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. */ From 4dad970b930fe48e9235b8501e04ca7457fe4986 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 18 Sep 2026 01:39:04 +0900 Subject: [PATCH 16/20] Stabilize resource nodes and align activity and outbox queries Represent registered IRIs with stable Resource nodes and nullable typed details. Expose every referencing activity through Object.activities and compute expected classifications from the selected activity. Serialize local outbox insertions and backfill existing positions to match the ActivityPub publication order. AI provenance: The user reviewed the feedback their PR received, drafted a plan for actual application, and requested Codex to implement it along with a Fable review loop via Claude Code. Codex implemented the supplied design and regression tests. Claude Code identified missing position backfill for existing outbox rows; Codex added the migration and upgrade test in response. The user read and reviewed the completed code, then confirmed that `mise run check` and `mise run test` were successfully executed. https://github.com/fedify-dev/drfed/pull/73#discussion_r4034567816 https://github.com/fedify-dev/drfed/pull/73#discussion_r4034655408 https://github.com/fedify-dev/drfed/pull/73#discussion_r4034678914 https://github.com/fedify-dev/drfed/pull/73#discussion_r4034679053 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5 --- mise.toml | 1 - packages/graphql/src/actor.ts | 10 +- packages/graphql/src/classification.ts | 47 + packages/graphql/src/federation.test.ts | 4 +- packages/graphql/src/object.test.ts | 116 +- packages/graphql/src/object.ts | 135 +- packages/graphql/src/resource.test.ts | 166 +- packages/graphql/src/resource.ts | 180 +- .../migration.sql | 22 + .../snapshot.json | 2263 +++++++++++++++++ packages/models/src/outbox-migration.test.ts | 185 ++ packages/models/src/relations.ts | 4 +- packages/models/src/resource.test.ts | 18 +- packages/models/src/resource.ts | 54 +- 14 files changed, 2993 insertions(+), 212 deletions(-) create mode 100644 packages/models/drizzle/20260917163517_order_local_outbox_items/migration.sql create mode 100644 packages/models/drizzle/20260917163517_order_local_outbox_items/snapshot.json create mode 100644 packages/models/src/outbox-migration.test.ts diff --git a/mise.toml b/mise.toml index 315ddc3..ce2f680 100644 --- a/mise.toml +++ b/mise.toml @@ -5,7 +5,6 @@ experimental = true [tools] "aqua:dahlia/hongdown" = "0.4.3" -claude-code = "latest" "github:nushell/nushell" = "0.114.1" node = "26" "npm:@fedify/cli" = { version = "2.4.0-dev.1758", allow_low_downloads = true } diff --git a/packages/graphql/src/actor.ts b/packages/graphql/src/actor.ts index 0984110..da80fe9 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -29,7 +29,7 @@ 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, Resource } from "./resource.ts"; +import { Collection, ResourceDetail } from "./resource.ts"; const ActorType = builder.enumType("ActorType", { values: actorTypeEnum.enumValues, @@ -58,13 +58,18 @@ async function resolveActorCollection( const ActorRef = builder.drizzleNode("actors", { name: "Actor", - interfaces: [Resource], description: "Represents an `Actor` in the DrFed platform.", id: { column: ({ id }) => id, 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`.", @@ -149,6 +154,7 @@ const ActorRef = builder.drizzleNode("actors", { }); export const Actor: DrFedObjectRef = ActorRef; +ResourceDetail.addTypes([Actor]); const LocalActorRef = builder.drizzleNode("localActors", { name: "LocalActor", diff --git a/packages/graphql/src/classification.ts b/packages/graphql/src/classification.ts index e57a69c..efb561a 100644 --- a/packages/graphql/src/classification.ts +++ b/packages/graphql/src/classification.ts @@ -97,3 +97,50 @@ export function classifyMisskey( 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 4df7187..7e31db5 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -714,7 +714,7 @@ describe("stored collection membership and independent activity addressing", () } const body = await ( await post({ - query: `query($id: ID!) { node(id: $id) { ... on Actor { followers { kind totalCount items(first: 1) { edges { cursor node { kind iri ... on Actor { username } } } pageInfo { hasNextPage } } } } } }`, + 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(); @@ -723,7 +723,7 @@ describe("stored collection membership and independent activity addressing", () assert.deepEqual(body.data.node.followers.items.edges[0].node, { kind: "actor", iri: "https://remote.example.com/users/bob", - username: "bob", + detail: { username: "bob" }, }); }); }); diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts index 965937f..f934d79 100644 --- a/packages/graphql/src/object.test.ts +++ b/packages/graphql/src/object.test.ts @@ -37,7 +37,7 @@ import { seedRemoteActor, } from "./seed.test.ts"; -const fields = `id uuid iri url type actor { uuid } to { iri target { iri kind } } cc { target { iri } } name summary contentHtml language sensitive published updated created`; +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 @@ -56,7 +56,7 @@ const outboxQuery = `query($actor: ID!) { outbox { declaredTotalItems totalCount - items { edges { node { ... on Activity { type object { ... on Object { uuid } } } } } } + items { edges { node { detail { ... on Activity { type object { detail { ... on Object { uuid } } } } } } } } } } } @@ -81,9 +81,7 @@ describe("Mutation.createObject", () => { const object = body.data.createObject; assert.equal(object.resultType, "Object"); assert.equal(object.type, "Note"); - assert.deepEqual(object.to, [ - { iri: PUBLIC_IRI, target: { iri: PUBLIC_IRI, kind: "collection" } }, - ]); + assert.deepEqual(object.to, [{ iri: PUBLIC_IRI, kind: "collection" }]); assert.equal(object.language, "ko-KR"); assert.equal(object.contentHtml, contentHtml); assert.equal( @@ -114,7 +112,14 @@ describe("Mutation.createObject", () => { totalCount: 1, items: { edges: [ - { node: { type: "Create", object: { uuid: object.uuid } } }, + { + node: { + detail: { + type: "Create", + object: { detail: { uuid: object.uuid } }, + }, + }, + }, ], }, }, @@ -137,6 +142,61 @@ describe("Mutation.createObject", () => { 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); @@ -165,7 +225,15 @@ describe("Mutation.createObject", () => { outbox: { declaredTotalItems: null, totalCount: 1, - items: { edges: [{ node: { type: "Create", object: null } }] }, + items: { + edges: [ + { + node: { + detail: { type: "Create", object: { detail: null } }, + }, + }, + ], + }, }, }, }, @@ -605,7 +673,7 @@ describe("explicit addressing and persisted activities", () => { }; const query = mutation.replace( "... on Object {", - "... on Object { document bto { target { iri } } bcc { target { iri } } audience { target { iri } } createActivity { id iri document type actor { uuid } object { iri kind ... on Object { contentHtml } } to { target { iri } } bto { target { iri } } }", + "... 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 ( @@ -617,27 +685,29 @@ describe("explicit addressing and persisted activities", () => { const object = await create(); await create(); assert.deepEqual( - object.to.map((r: { target: { iri: string } }) => r.target.iri), + object.to.map((r: { iri: string }) => r.iri), addressing.to, ); - assert.equal(object.to[0].target.kind, "unknown"); + assert.equal(object.to[0].kind, "unknown"); assert.deepEqual( - object.audience.map((r: { target: { iri: string } }) => r.target.iri), + object.audience.map((r: { iri: string }) => r.iri), addressing.audience, ); - assert.deepEqual(object.bto, [{ target: { iri: blind } }]); + assert.deepEqual(object.bto, [{ iri: blind }]); assert.deepEqual(object.bcc, object.bto); - assert.equal(object.createActivity.type, "Create"); - assert.equal(object.createActivity.object.iri, object.iri); - assert.equal(object.createActivity.object.kind, "object"); + 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.createActivity.object.contentHtml, + object.activities.edges[0].node.object.detail.contentHtml, variables.contentHtml, ); - assert.deepEqual(object.createActivity.actor, { uuid: localActorId }); + assert.deepEqual(object.activities.edges[0].node.actor, { + uuid: localActorId, + }); for (const document of [ object.document, - object.createActivity.document, + object.activities.edges[0].node.document, ]) { for (const [property, values] of Object.entries(addressing)) { assert.deepEqual(document[property], values); @@ -658,7 +728,10 @@ describe("explicit addressing and persisted activities", () => { where: { id: object.uuid }, }); assert.deepEqual(stored?.document, object.document); - assert.deepEqual(activity.document, object.createActivity.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, { @@ -719,7 +792,7 @@ describe("explicit addressing and persisted activities", () => { { query: mutation.replace( "... on Object {", - "... on Object { expectedClassifications { implementation version classification reason }", + "... on Object { activities(first: 1, type: Create) { edges { node { expectedClassifications { implementation version classification reason } } } }", ), variables: { ...variables, @@ -734,7 +807,8 @@ describe("explicit addressing and persisted activities", () => { ) ).json(); assert.equal(body.errors, undefined); - const results = body.data.createObject.expectedClassifications; + const results = + body.data.createObject.activities.edges[0].node.expectedClassifications; assert.deepEqual( results.map((r: { implementation: string; classification: string }) => [ r.implementation, diff --git a/packages/graphql/src/object.ts b/packages/graphql/src/object.ts index 072c020..c8613c1 100644 --- a/packages/graphql/src/object.ts +++ b/packages/graphql/src/object.ts @@ -16,6 +16,7 @@ import { addActorCollectionItem, + lockActorCollection, promoteResource, schema, storeAddressing, @@ -28,18 +29,18 @@ import { and, eq, gt, isNotNull, isNull } from "drizzle-orm"; import { Actor } from "./actor.ts"; import builder, { type DrFedObjectRef } from "./builder.ts"; -import { - type AddressingRows, - classifyMastodon, - classifyMisskey, -} from "./classification.ts"; import { activitySelection, objectSelection, toCreate, toObject, } from "./federation.ts"; -import { Resource, registerAddressingFields } from "./resource.ts"; +import { + Activity, + ActivityType, + ResourceDetail, + registerAddressingFields, +} from "./resource.ts"; const ObjectType = builder.enumType("ObjectType", { values: objectTypeEnum.enumValues, @@ -53,35 +54,24 @@ const AddressingInput = builder.inputType("AddressingInput", { audience: t.field({ type: ["URL"], required: true, defaultValue: [] }), }), }); -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"), - }), -}); const ObjectRef = builder.drizzleNode("objects", { name: "Object", select: { columns: { id: true, deleted: true }, with: { actor: { columns: { deleted: true } } }, }, - interfaces: [Resource], 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.", @@ -99,48 +89,6 @@ const ObjectRef = builder.drizzleNode("objects", { description: "The actor that authored the object.", }), document: t.expose("document", { type: "JSON", nullable: true }), - createActivity: t.relation("createActivity", { - nullable: true, - query: { where: { actor: { deleted: { isNull: true } } } }, - }), - expectedClassifications: t.field({ - type: [ExpectedClassification], - select: { columns: { id: true } }, - resolve: async (object, _, ctx) => { - const row = await ctx.db.query.objects.findFirst({ - where: { id: object.id }, - with: { - ...objectSelection, - actor: { - with: { - resource: true, - collectionReferences: { - with: { collection: { with: { resource: true } } }, - }, - }, - }, - createActivity: { with: activitySelection }, - }, - }); - if (row == null) throw new Error("Missing object."); - const author = { - iri: row.actor.resource.iri, - followersIri: - row.actor.collectionReferences.find( - (collection) => collection.role === "followers", - )?.collection.resource.iri ?? null, - }; - const addressing = classificationInput(row); - const activity = - row.createActivity == null - ? null - : classificationInput(row.createActivity); - return [ - classifyMastodon(addressing, activity, author), - classifyMisskey(addressing, activity, author), - ]; - }, - }), name: t.exposeString("name", { nullable: true, description: "The optional title of the object.", @@ -177,8 +125,38 @@ const ObjectRef = builder.drizzleNode("objects", { }), }); 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" }, @@ -369,6 +347,8 @@ builder.mutationFields((t) => ({ ) .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(`https://${actor.host}`), @@ -392,6 +372,7 @@ builder.mutationFields((t) => ({ id: resource.id, actorId, language: canonicalLanguage, + published, }) .returning(); if (row == null) { @@ -461,30 +442,6 @@ function normalizeOptionalText( return value == null || value.trim() === "" ? null : value; } -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") }; -} - function asDocument(value: unknown): Record { if (value == null || typeof value !== "object" || Array.isArray(value)) { throw new TypeError("Expected a JSON-LD object."); diff --git a/packages/graphql/src/resource.test.ts b/packages/graphql/src/resource.test.ts index 64ace03..25a81b0 100644 --- a/packages/graphql/src/resource.test.ts +++ b/packages/graphql/src/resource.test.ts @@ -18,7 +18,7 @@ import assert from "node:assert/strict"; import { it } from "node:test"; -import { schema } from "@drfed/models"; +import { promoteResource, schema, storeAddressing } from "@drfed/models"; import { PUBLIC_IRI } from "@drfed/models/resource"; import { uuidV7 as uuid } from "@drfed/models/uuid"; import { eq } from "drizzle-orm"; @@ -145,8 +145,8 @@ for (const deleted of ["actor", "object"] as const) { const body = await ( await post({ query: `query($live: ID!, $activity: ID!, $collection: ID!, $remote: ID!) { - live: node(id: $live) { ... on Object { to { iri target { iri ... on Actor { objects { totalCount } } } } } } - activity: node(id: $activity) { ... on Activity { actor { uuid } object { iri } } } + 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 } } } } } @@ -162,13 +162,16 @@ for (const deleted of ["actor", "object"] as const) { 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].target, null); - assert.equal(body.data.live.to[2].target.iri, PUBLIC_IRI); + 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: 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. @@ -183,7 +186,7 @@ for (const deleted of ["actor", "object"] as const) { deleted === "actor" ? [null] : [{ totalCount: 3 }], ); assert.deepEqual( - body.data.live.to[3].target, + body.data.live.to[3].detail, deleted === "actor" ? null : { iri: followersIri }, ); assert.deepEqual( @@ -196,7 +199,7 @@ for (const deleted of ["actor", "object"] as const) { }, ); if (deleted === "actor") { - assert.equal(body.data.live.to[0].target, null); + assert.equal(body.data.live.to[0].detail, null); return; } const seen: string[] = []; @@ -252,12 +255,155 @@ it("hides Create relations authored by a deleted actor", async () => { .where(eq(schema.actors.id, localActorId)); const body = await ( await post({ - query: `query($id: ID!) { node(id: $id) { ... on Object { uuid createActivity { actor { uuid } } } } }`, + 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, createActivity: null } }, + 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) } }, + ], + }, + }, + }, }); }); }); diff --git a/packages/graphql/src/resource.ts b/packages/graphql/src/resource.ts index 55f67e2..fa0f77c 100644 --- a/packages/graphql/src/resource.ts +++ b/packages/graphql/src/resource.ts @@ -21,46 +21,55 @@ import { resolveOffsetConnection } 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 Resource = builder.interfaceRef<{ id: Uuid }>("Resource"); -Resource.implement({ - fields: (t) => ({ - iri: t.field({ - type: "URL", - resolve: async ({ id }, _, ctx) => { - const row = await ctx.db.query.resources.findFirst({ where: { id } }); - if (row == null) throw new Error("Missing resource."); - return row.iri; - }, - }), - kind: t.field({ - type: ResourceKind, - resolve: async ({ id }, _, ctx) => { - const row = await ctx.db.query.resources.findFirst({ where: { id } }); - if (row == null) throw new Error("Missing resource."); - return row.kind; - }, - }), - }), - resolveType: async ({ id }, ctx) => { +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", - unknown: "UnknownResource", } as const - )[row?.kind ?? "unknown"]; + )[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 interface fragments see the complete entity. + * 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( @@ -72,7 +81,7 @@ export async function resolveResource( columns: { id: true }, }); if (visible == null) return null; - if (row.kind === "unknown") return row; + if (row.kind === "unknown") return null; const where = { id: row.id }; const result = row.kind === "actor" @@ -88,48 +97,23 @@ export async function resolveResource( return result; } -builder.drizzleNode("resources", { - name: "UnknownResource", - interfaces: [Resource], - id: { column: (row) => row.id }, - fields: () => ({}), -}); -const AddressingTarget = builder.objectRef< - typeof schema.addressing.$inferSelect & { targetResource: ResourceRow } ->("AddressingTarget"); -AddressingTarget.implement({ - fields: (t) => ({ - iri: t.field({ - type: "URL", - description: - "The stored IRI, even when `target` is null because it or its author is deleted.", - resolve: (row) => row.targetResource.iri, - }), - target: t.field({ - type: Resource, - nullable: true, - description: - "The target resource, or null if it or its author is deleted.", - resolve: (row, _, ctx) => resolveResource(ctx.db, row.targetResource), - }), - }), -}); - export function registerAddressingFields( table: "objects" | "activities", ): void { for (const property of schema.addressingPropertyEnum.enumValues) { builder.drizzleObjectField(table, property, (t) => t.field({ - type: [AddressingTarget], + type: [Resource], description: `Stored ${property} occurrences, in original order including duplicates.`, select: { columns: { id: true } }, - resolve: (row, _, ctx) => - ctx.db.query.addressing.findMany({ - where: { sourceId: row.id, property }, - orderBy: { position: "asc" }, - with: { targetResource: 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), }), ); } @@ -144,9 +128,14 @@ const CollectionRef = builder.drizzleNode("collections", { columns: { id: true }, with: { ownerActor: { columns: { deleted: true } } }, }, - interfaces: [Resource], 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, @@ -187,16 +176,30 @@ const CollectionRef = builder.drizzleNode("collections", { limit, with: { item: true }, }); - return await Promise.all( - items.map((item) => resolveResource(ctx.db, item.item)), - ); + return items.map((item) => item.item); }), }), }), }); export const Collection: DrFedObjectRef = CollectionRef; -const ActivityType = builder.enumType("ActivityType", { +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", { @@ -205,17 +208,60 @@ const ActivityRef = builder.drizzleNode("activities", { columns: { id: true }, with: { actor: { columns: { deleted: true } } }, }, - interfaces: [Resource], 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, _, ctx) => - row.object == null ? null : resolveResource(ctx.db, row.object), + 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 }), 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/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 6f0d589..c57398d 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -209,11 +209,9 @@ export const relations = defineRelations(schema, (r) => ({ from: r.objects.id, to: r.addressing.sourceId, }), - createActivity: r.one.activities({ + activities: r.many.activities({ from: r.objects.id, to: r.activities.objectId, - optional: true, - where: { type: "Create" }, }), actor: r.one.actors({ from: r.objects.actorId, diff --git a/packages/models/src/resource.test.ts b/packages/models/src/resource.test.ts index 79e9f2a..97f4ab4 100644 --- a/packages/models/src/resource.test.ts +++ b/packages/models/src/resource.test.ts @@ -170,15 +170,27 @@ it("records idempotent collection membership only for declared roles", async () await addActorCollectionItem(db, actor.id, "outbox", item.id); assert.deepEqual( await db.query.collectionItems.findMany({ - columns: { collectionId: true, itemId: true }, + columns: { collectionId: true, itemId: true, position: true }, }), - [{ collectionId: outbox.id, itemId: item.id }], + [{ 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), 1); + assert.equal(await db.$count(schema.collectionItems), 2); } finally { await client.close(); } diff --git a/packages/models/src/resource.ts b/packages/models/src/resource.ts index 564ea18..386f599 100644 --- a/packages/models/src/resource.ts +++ b/packages/models/src/resource.ts @@ -17,7 +17,7 @@ // Keep dependent database writes and observations sequential. // oxlint-disable no-await-in-loop -import { and, eq } from "drizzle-orm"; +import { and, eq, min } from "drizzle-orm"; import type { Database, Transaction } from "./db.ts"; import { @@ -28,6 +28,7 @@ import { addressing, addressingPropertyEnum, collectionItems, + collections, resources, } from "./schema.ts"; import { type Uuid, uuidV7 } from "./uuid.ts"; @@ -137,31 +138,56 @@ export async function storeAddressing( } /** - * Records a resource as a member of the collection an actor declares for - * `role`, such as its outbox. Membership is idempotent per collection. - * @throws {Error} If the actor declares no collection for `role`. + * Locks an actor's declared collection until the transaction ends. + * @returns The locked collection ID. */ -export async function addActorCollectionItem( - tx: Database | Transaction, +export async function lockActorCollection( + tx: Transaction, actorId: Uuid, role: CollectionRole, - itemId: Uuid, -): Promise { +): Promise { const [reference] = await tx - .select({ collectionId: actorCollectionReferences.collectionId }) - .from(actorCollectionReferences) + .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.`); } - await tx - .insert(collectionItems) - .values({ collectionId: reference.collectionId, itemId }) - .onConflictDoNothing(); + 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(); + }); } From b328929a9f41bdf0d5c9445f7a46a5f86f66145e Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 18 Sep 2026 03:19:36 +0900 Subject: [PATCH 17/20] Track FIXME comments with issue links Replace every FIXME comment in the codebase with a bare link to the issue that tracks it, and document that rule in CONTRIBUTING.md. - packages/graphql/src/auth/mail.ts: the two email localization notes now point at the existing web i18n issue. - packages/graphql/src/actor.ts: readable username generation. - packages/web/src/routes/workspace/create/[instance_id]/actors.tsx: result-type switch lookup tables. - packages/graphql/src/federation.ts: actor key pairs dispatcher and inbound activity persistence. - packages/models/src/schema.ts: actor deletion object policy. - CONTRIBUTING.md: when leaving a FIXME comment, open an issue and put only the issue URL in the comment. This applies dodok8's review suggestion on the actors.tsx FIXME (linked below) to every FIXME comment, at the user's direction. The user also directed turning it into a general rule in CONTRIBUTING.md. AI provenance: The user asked Claude Code to survey every FIXME comment and draft an issue for each. The user read and edited the drafts, then directed Claude Code to file them through the GitHub CLI, replace the comments with the issue URLs, and add the CONTRIBUTING.md rule. Claude Code performed those steps and verified the result with `mise run check` (types, lint, oxfmt, hongdown, license, versions) passing. https://github.com/fedify-dev/drfed/pull/73#discussion_r4034901590 https://github.com/fedify-dev/drfed/issues/35 https://github.com/fedify-dev/drfed/issues/85 https://github.com/fedify-dev/drfed/issues/86 https://github.com/fedify-dev/drfed/issues/87 https://github.com/fedify-dev/drfed/issues/88 https://github.com/fedify-dev/drfed/issues/89 Assisted-by: Claude Code:claude-fable-5-1 --- CONTRIBUTING.md | 10 ++++++++++ packages/graphql/src/actor.ts | 2 +- packages/graphql/src/auth/mail.ts | 4 ++-- packages/graphql/src/federation.ts | 7 ++----- packages/models/src/schema.ts | 3 +-- .../routes/workspace/create/[instance_id]/actors.tsx | 2 +- 6 files changed, 17 insertions(+), 11 deletions(-) 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/graphql/src/actor.ts b/packages/graphql/src/actor.ts index da80fe9..5fa14e7 100644 --- a/packages/graphql/src/actor.ts +++ b/packages/graphql/src/actor.ts @@ -397,7 +397,7 @@ 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", 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/federation.ts b/packages/graphql/src/federation.ts index e6133f4..662b369 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -134,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: Validate and persist incoming activities. The local createObject - // mutation already stores Create activities; incoming activities are - // currently only logged. + // FIXME: https://github.com/fedify-dev/drfed/issues/88 .on(Activity, (_ctx, activity) => { logger.debug("Received an activity: {activity}", { activity }); }) diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 4a0adce..2ce673c 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -298,8 +298,7 @@ export const actors = pgTable( created: instant().notNull().default(currentTimestamp), // When implementing actor deletion, add activities.deleted and set it // together with objects.deleted in the same transaction. - // FIXME: Let instance administrators choose deletion, anonymization or - // preservation of authored objects. For now, delete them with the actor. + // FIXME: https://github.com/fedify-dev/drfed/issues/89 deleted: instant(), }, (t) => [ 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 bb54b43..665a996 100644 --- a/packages/web/src/routes/workspace/create/[instance_id]/actors.tsx +++ b/packages/web/src/routes/workspace/create/[instance_id]/actors.tsx @@ -139,7 +139,7 @@ export default function CreateActorsPage(props: RouteSectionProps) { return; } - // FIXME: 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": { From 57d3e90d9e20e978ac74d0e5eba4667018ebb17b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 18 Sep 2026 16:56:54 +0900 Subject: [PATCH 18/20] Index activities by referenced object Object.activities filters activities by the referenced object and orders them by published ASC, id ASC, but the only secondary index on activities started with actorId. Add activity_object_published_index on (objectId, published, id) together with its migration. The same index also serves the ON DELETE CASCADE lookup on activities.objectId, which PostgreSQL does not index on its own. A regression test checks the index definition in a freshly migrated database. The user planned how to apply the reviews and used Claude Code to write regression tests. Afterward, they blocked the agent's access to the tests and had the plan implemented using the Codex implementation and Claude review loop. Finally, the user reviewed the changes and confirmed that `mise run check` and `mise run test` passed. https://github.com/fedify-dev/drfed/pull/73#discussion_r4044073762 Assisted-by: Codex:gpt-5.6-sol Assisted-by: Claude Code:claude-fable-5-1 --- .../migration.sql | 1 + .../snapshot.json | 2298 +++++++++++++++++ packages/models/src/resource.test.ts | 19 + packages/models/src/schema.ts | 1 + 4 files changed, 2319 insertions(+) create mode 100644 packages/models/drizzle/20260918071839_add_activity_object_index/migration.sql create mode 100644 packages/models/drizzle/20260918071839_add_activity_object_index/snapshot.json 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/src/resource.test.ts b/packages/models/src/resource.test.ts index 97f4ab4..7cc5b80 100644 --- a/packages/models/src/resource.test.ts +++ b/packages/models/src/resource.test.ts @@ -195,3 +195,22 @@ it("records idempotent collection membership only for declared roles", async () 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/schema.ts b/packages/models/src/schema.ts index 2ce673c..d7c1ba9 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -474,6 +474,7 @@ export const activities = pgTable( desc(t.published), desc(t.id), ), + index("activity_object_published_index").on(t.objectId, t.published, t.id), ], ); export type StoredActivity = typeof activities.$inferSelect; From 3d32b3d6199adaacb6ab827d2ddf4367dd6fcbc3 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 18 Sep 2026 16:57:03 +0900 Subject: [PATCH 19/20] Page collection items with keyset cursors Collection.items used an offset cursor. Local outbox entries are prepended with min(position) - 1, so creating a post between two page requests shifted the offsets and the next page repeated an item. Use resolveCursorConnection with an opaque base64 cursor over the stored (position, itemId) ordering and query past that boundary. position is nullable, so the predicates treat NULL positions as a separate range that sorts last instead of dropping those rows, and last/before returns edges in the same order as first/after. Malformed cursors are reported to clients as validation errors. Regression tests cover a post created between page requests, NULL positions with an item prepended in the middle of a walk, and backward paging. The user planned how to apply the reviews and used Claude Code to write regression tests. Afterward, they blocked the agent's access to the tests and had the plan implemented using the Codex implementation and Claude review loop. Finally, the user reviewed the changes and confirmed that `mise run check` and `mise run test` passed. https://github.com/fedify-dev/drfed/pull/73#discussion_r4044017371 Assisted-by: Codex:gpt-5.6-sol Assisted-by: Claude Code:claude-fable-5-1 --- packages/graphql/src/resource.test.ts | 203 +++++++++++++++++++++++++- packages/graphql/src/resource.ts | 146 ++++++++++++++++-- 2 files changed, 331 insertions(+), 18 deletions(-) diff --git a/packages/graphql/src/resource.test.ts b/packages/graphql/src/resource.test.ts index 25a81b0..ef3c8f2 100644 --- a/packages/graphql/src/resource.test.ts +++ b/packages/graphql/src/resource.test.ts @@ -18,16 +18,22 @@ import assert from "node:assert/strict"; import { it } from "node:test"; -import { promoteResource, schema, storeAddressing } from "@drfed/models"; +import { + type Database, + promoteResource, + schema, + storeAddressing, +} from "@drfed/models"; import { PUBLIC_IRI } from "@drfed/models/resource"; -import { uuidV7 as uuid } from "@drfed/models/uuid"; +import { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid"; import { eq } from "drizzle-orm"; -import { withTestHarness } from "./harness.test.ts"; +import { type TestHarness, withTestHarness } from "./harness.test.ts"; import { globalId, localActorId, remoteActorId, + seedAuthenticatedLocalInstance, seedLocalActor, seedObjects, seedRemoteActor, @@ -407,3 +413,194 @@ it("lists every referencing activity and classifies the explicitly selected acti }); }); }); + +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 index fa0f77c..b73a89c 100644 --- a/packages/graphql/src/resource.ts +++ b/packages/graphql/src/resource.ts @@ -16,8 +16,9 @@ import { type Database, schema } from "@drfed/models"; import type { Resource as ResourceRow } from "@drfed/models/schema"; -import type { Uuid } from "@drfed/models/uuid"; -import { resolveOffsetConnection } from "@pothos/plugin-relay"; +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"; @@ -164,25 +165,140 @@ const CollectionRef = builder.drizzleNode("collections", { 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) => - resolveOffsetConnection({ args }, async ({ offset, limit }) => { - const items = await ctx.db.query.collectionItems.findMany({ - where: { - collectionId: row.id, - RAW: (table) => visibleResource(table.itemId), + 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, + }); }, - orderBy: { position: "asc", itemId: "asc" }, - offset, - limit, - with: { item: true }, - }); - return items.map((item) => item.item); - }), + }, + 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, }); From 193b0ca1b3a629a8c94038c2922e802cf80b69e2 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 19 Sep 2026 01:12:11 +0900 Subject: [PATCH 20/20] Preserve configured origins for ActivityPub resources Canonicalize instance authorities in object and Create dispatchers and look up Create activities by ID within their local instance. Preserve the root origin protocol and stored authority, including its port, when creating object and activity IRIs. Align test seeds with production activity IDs. Add regression coverage for trailing-dot authorities, HTTP requests for stored HTTPS resources, and HTTP creation with and without a custom port. Keep the existing cross-host rejection and independent addressing coverage. Use the persisted object resource IRI for Tombstone.id so trailing-dot hosts and HTTP requests retain the original deleted object identifier. Extend the origin-spelling regression cases to check Tombstone IDs, types, and deletion timestamps. AI provenance: The user wrote a plan to apply review and requested Codex implement it followed by a Claude review. Codex implemented the code and regression tests; Claude reported bugs and requested fixes. After loops, the user checked the result and tested it with `mise run check` and `mise run test`. https://github.com/fedify-dev/drfed/pull/73#discussion_r4046243076 https://github.com/fedify-dev/drfed/pull/73#discussion_r4046243069 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5-1 --- AI_POLICY.md | 1 + packages/graphql/src/federation.test.ts | 59 ++++++++++++++++++++---- packages/graphql/src/federation.ts | 11 ++--- packages/graphql/src/object.test.ts | 61 +++++++++++++++++++++++++ packages/graphql/src/object.ts | 2 +- packages/graphql/src/seed.test.ts | 11 +++-- 6 files changed, 125 insertions(+), 20 deletions(-) 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/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 7e31db5..440da65 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -199,6 +199,7 @@ 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, @@ -206,6 +207,44 @@ function values(id: string) { }; } +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 () => { @@ -358,7 +397,7 @@ describe("ActivityPub Create activities", () => { }, }); const response = await federation.fetch( - new Request(createIri(object.id), { headers: accept }), + new Request(createIri(object.activityId), { headers: accept }), { contextData: undefined }, ); assert.equal(response.status, 200); @@ -374,7 +413,7 @@ describe("ActivityPub Create activities", () => { }, { type: "Create", - id: createIri(object.id), + id: createIri(object.activityId), actor: actorIri, object: object.iri, to: publicProperty === "to" ? "as:Public" : `${actorIri}/followers`, @@ -418,7 +457,7 @@ describe("ActivityPub Create activities", () => { ? createIri(uuid()) : scenario === "malformed" ? createIri("bad") - : createIri(object.id); + : createIri(object.activityId); const response = await federation.fetch( new Request(iri, { headers: accept }), { contextData: undefined }, @@ -434,7 +473,7 @@ describe("ActivityPub Create activities", () => { await seedObjects(db, object); const response = await federation.fetch( new Request( - createIri(object.id).replace( + createIri(object.activityId).replace( "test-instance.drfed.org", "wrong.example", ), @@ -453,10 +492,12 @@ describe("ActivityPub outbox", () => { 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`] } @@ -491,7 +532,7 @@ describe("ActivityPub outbox", () => { }, { type: "Create", - id: createIri(ids[20]!), + id: createIri(activityIds[20]!), actor: actorIri, object: values(ids[20]!).iri, to: `${actorIri}/followers`, @@ -646,7 +687,7 @@ describe("ActivityPub outbox totalItems", () => { }); describe("stored collection membership and independent activity addressing", () => { - it("serves backfilled Create IRIs and uses activity addressing for outbox and Create", async () => { + 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()); @@ -657,16 +698,16 @@ describe("stored collection membership and independent activity addressing", () }); assert.ok(activity); assert.notEqual(activity.id, object.id); - assert.equal(activity.resource.iri, createIri(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.id))).status, 200); + 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.id))).status, 404); + 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, diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 662b369..94a2e15 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -150,7 +150,6 @@ export function buildFederation(db: Database): FederationBuilder { builder.setObjectDispatcher( APObject, - // Keep migration backfill IRI formats in sync when changing these paths. "/users/{identifier}/{id}", async (ctx, { identifier, id }) => { if (!validateUuid(identifier) || !validateUuid(id)) return null; @@ -162,7 +161,7 @@ export function buildFederation(db: Database): FederationBuilder { actor: { localId: { isNotNull: true }, deleted: { isNull: true }, - instance: { host: ctx.host }, + instance: { host: canonicalizeAuthority(ctx.host) }, }, }, with: objectSelection, @@ -170,7 +169,7 @@ export function buildFederation(db: Database): FederationBuilder { if (object == null) return null; if (object.deleted != null) { return new Tombstone({ - id: ctx.getObjectUri(APObject, { identifier, id }), + id: new URL(object.resource.iri), deleted: object.deleted, }); } @@ -185,11 +184,11 @@ export function buildFederation(db: Database): FederationBuilder { if (!validateUuid(id)) return null; const activity = await db.query.activities.findFirst({ where: { - resource: { iri: ctx.getObjectUri(Create, { id }).href }, + id, actor: { localId: { isNotNull: true }, deleted: { isNull: true }, - instance: { host: ctx.host }, + instance: { host: canonicalizeAuthority(ctx.host) }, }, RAW: (table) => servedActivity(table), }, @@ -218,7 +217,7 @@ export function buildFederation(db: Database): FederationBuilder { (${boundary.published}::timestamptz, ${boundary.id}::uuid)`, )!, }, - // Backfilled activity IDs are UUIDv4, so only publication time + // Backfilled activity IDs are UUIDv7, so only publication time // determines chronology. Keep full database precision in cursors. extras: { cursorPublished: (table) => diff --git a/packages/graphql/src/object.test.ts b/packages/graphql/src/object.test.ts index f934d79..edeb460 100644 --- a/packages/graphql/src/object.test.ts +++ b/packages/graphql/src/object.test.ts @@ -30,9 +30,12 @@ import { withTestHarness } from "./harness.test.ts"; import { globalId, localActorId, + localInstanceId, remoteActorId, + seedActors, seedAuthenticatedLocalInstance, seedLocalActor, + seedLocalInstance, seedObjects, seedRemoteActor, } from "./seed.test.ts"; @@ -64,6 +67,64 @@ const outboxQuery = `query($actor: ID!) { 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); diff --git a/packages/graphql/src/object.ts b/packages/graphql/src/object.ts index c8613c1..0f15506 100644 --- a/packages/graphql/src/object.ts +++ b/packages/graphql/src/object.ts @@ -351,7 +351,7 @@ builder.mutationFields((t) => ({ const published = Temporal.Now.instant(); const id = uuid(); const fedCtx = ctx.federation.createContext( - new URL(`https://${actor.host}`), + new URL(`${ctx.rootOrigin.protocol}//${actor.host}`), undefined, ); const iri = fedCtx.getObjectUri(APObject, { diff --git a/packages/graphql/src/seed.test.ts b/packages/graphql/src/seed.test.ts index 86d5661..adc34a3 100644 --- a/packages/graphql/src/seed.test.ts +++ b/packages/graphql/src/seed.test.ts @@ -103,7 +103,10 @@ export async function seedLocalActor(db: Database): Promise { }); } -export async function seedLocalInstance(db: Database): Promise { +export async function seedLocalInstance( + db: Database, + host = "test-instance.drfed.org", +): Promise { await db .insert(schema.localInstances) .values({ @@ -118,7 +121,7 @@ export async function seedLocalInstance(db: Database): Promise { id: localInstanceId, localId: localInstanceId, created, - host: "test-instance.drfed.org", + host, }) .onConflictDoNothing(); } @@ -193,7 +196,7 @@ type ObjectSeed = PgInsertValue & { addressing?: AddressingInput; activityId?: Uuid; }; -/** Seeds independent Create rows using the legacy IRI layout for migration coverage. */ +/** Seeds independent Create rows using the production activity ID layout. */ export async function seedObjects( db: Database, values: ObjectSeed | ObjectSeed[], @@ -225,7 +228,7 @@ export async function seedObjects( if (actor == null) throw new Error("Missing seeded actor."); await promoteResource( tx, - `https://${actor.instance.host}/ap/creates/${row.id}`, + `https://${actor.instance.host}/ap/creates/${activityId}`, "activity", async (inner, activity) => { await inner.insert(schema.activities).values({