diff --git a/core/actions/_lib/runActionInstance.db.test.ts b/core/actions/_lib/runActionInstance.db.test.ts index 7e09147166..3d00083ea7 100644 --- a/core/actions/_lib/runActionInstance.db.test.ts +++ b/core/actions/_lib/runActionInstance.db.test.ts @@ -10,7 +10,7 @@ const { getTrx, rollback, commit } = createForEachMockedTransaction(); const pubTriggerTestSeed = async () => { const slugName = `test-server-pub-${new Date().toISOString()}`; - const { createSeed } = await import("~/prisma/seed/seedCommunity"); + const { createSeed } = await import("~/prisma/seed/createSeed"); return createSeed({ community: { diff --git a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts index 041ff30c02..7c4ae79480 100644 --- a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts +++ b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts @@ -373,7 +373,7 @@ const handler = createNextHandler( }; }, archive: async ({ params }) => { - const { lastModifiedBy } = await checkAuthorization({ + const { lastModifiedBy, community } = await checkAuthorization({ token: { scope: ApiAccessScope.pub, type: ApiAccessType.write }, cookies: { capability: Capabilities.deletePub, @@ -383,6 +383,7 @@ const handler = createNextHandler( const result = await deletePub({ pubId: params.pubId as PubsId, + communityId: community.id, lastModifiedBy, }); diff --git a/core/app/components/pubs/PubEditor/actions.ts b/core/app/components/pubs/PubEditor/actions.ts index 812c82661c..69b1f68ab5 100644 --- a/core/app/components/pubs/PubEditor/actions.ts +++ b/core/app/components/pubs/PubEditor/actions.ts @@ -178,7 +178,7 @@ export const removePub = defineServerAction(async function removePub({ pubId }: } try { - await deletePub({ pubId, lastModifiedBy }); + await deletePub({ pubId, lastModifiedBy, communityId: community.id }); return { success: true, diff --git a/core/globalSetup.ts b/core/lib/__tests__/globalSetup.ts similarity index 83% rename from core/globalSetup.ts rename to core/lib/__tests__/globalSetup.ts index 3ef4b9cb68..478276a902 100644 --- a/core/globalSetup.ts +++ b/core/lib/__tests__/globalSetup.ts @@ -7,7 +7,10 @@ import { logger } from "logger"; export const setup = async () => { config({ - path: ["./.env.test", "./.env.test.local"], + path: [ + new URL("../../.env.test", import.meta.url).pathname, + new URL("../../.env.test.local", import.meta.url).pathname, + ], }); if (process.env.SKIP_RESET) { diff --git a/core/lib/__tests__/live.db.test.ts b/core/lib/__tests__/live.db.test.ts index 2adf02fe43..113aadf457 100644 --- a/core/lib/__tests__/live.db.test.ts +++ b/core/lib/__tests__/live.db.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "vitest"; +import { CoreSchemaType, MemberRole } from "db/public"; + import type { ClientException } from "../serverActions"; +import { createSeed } from "~/prisma/seed/createSeed"; import { isClientException } from "../serverActions"; import { mockServerCode } from "./utils"; @@ -8,6 +11,39 @@ const { testDb, getLoginData, createForEachMockedTransaction } = await mockServe const { getTrx, rollback, commit } = createForEachMockedTransaction(); +const communitySeed = createSeed({ + community: { + name: "test", + slug: "test", + }, + pubFields: { + Title: { schemaName: CoreSchemaType.String }, + }, + pubTypes: { + "Basic Pub": { + Title: { isTitle: true }, + }, + }, + users: { + admin: { + role: MemberRole.admin, + }, + }, + pubs: [ + { + pubType: "Basic Pub", + values: { + Title: "test", + }, + }, + ], +}); + +const seed = async (trx = testDb) => { + const { seedCommunity } = await import("~/prisma/seed/seedCommunity"); + return seedCommunity(communitySeed, undefined, trx); +}; + describe("live", () => { test("should be able to connect to db", async () => { const result = await testDb.selectFrom("users").selectAll().execute(); @@ -17,6 +53,8 @@ describe("live", () => { test("can rollback transactions", async () => { const trx = getTrx(); + const { community, users, pubs } = await seed(trx); + // Insert a user const user = await trx .insertInto("users") @@ -58,6 +96,7 @@ describe("live", () => { describe("transaction block example", () => { test("can add a user that will not persist", async () => { const trx = getTrx(); + await trx .insertInto("users") .values({ @@ -79,6 +118,9 @@ describe("live", () => { test("createForm needs a logged in user", async () => { const trx = getTrx(); + + const { community, users, pubs } = await seed(trx); + getLoginData.mockImplementation(() => { return undefined; }); @@ -87,12 +129,6 @@ describe("live", () => { (m) => m.createForm ); - const community = await trx - .selectFrom("communities") - .selectAll() - .where("slug", "=", "croccroc") - .executeTakeFirstOrThrow(); - const pubType = await trx .selectFrom("pub_types") .select(["id"]) @@ -110,15 +146,11 @@ describe("live", () => { return { id: "123", isSuperAdmin: true }; }); + const { community, users, pubs } = await seed(trx); const getForm = await import("../server/form").then((m) => m.getForm); const createForm = await import("~/app/c/[communitySlug]/forms/actions").then( (m) => m.createForm ); - const community = await trx - .selectFrom("communities") - .selectAll() - .where("slug", "=", "croccroc") - .executeTakeFirstOrThrow(); const forms = await getForm({ slug: "my-form-2", communityId: community.id }).execute(); expect(forms.length).toEqual(0); diff --git a/core/lib/__tests__/matchers.ts b/core/lib/__tests__/matchers.ts new file mode 100644 index 0000000000..75f3d31c6b --- /dev/null +++ b/core/lib/__tests__/matchers.ts @@ -0,0 +1,66 @@ +import { expect } from "vitest"; + +import type { ProcessedPub } from "contracts"; +import type { PubsId } from "db/public"; + +import type { db } from "~/kysely/database"; + +const deepSortValues = (pub: ProcessedPub): ProcessedPub => { + pub.values + .sort((a, b) => (a.value as string).localeCompare(b.value as string)) + .map((item) => ({ + ...item, + relatedPub: item.relatedPub?.values ? deepSortValues(item.relatedPub) : item.relatedPub, + })); + + return pub; +}; + +expect.extend({ + async toExist(received: PubsId, expected?: typeof db) { + const { getPlainPub } = await import("../server/pub"); + + const pub = await getPlainPub(received, expected).executeTakeFirst(); + const pass = Boolean(pub && pub.id === received); + const { isNot } = this; + + return { + pass, + message: () => + isNot + ? `Expected pub with ID ${received} not to exist, but it ${pass ? "does" : "does not"}` + : `Expected pub with ID ${received} to exist, but it ${pass ? "does" : "does not"}`, + }; + }, + + toHaveValues(received: ProcessedPub, expected: Partial[]) { + const pub = received; + const sortedPubValues = deepSortValues(pub); + + const expectedLength = expected.length; + const receivedLength = sortedPubValues.values.length; + + const isNot = this.isNot; + if (!isNot && !this.equals(expectedLength, receivedLength)) { + return { + pass: false, + message: () => + `Expected pub to have ${expectedLength} values, but it has ${receivedLength}`, + }; + } + + // equiv. to .toMatchObject + const pass = this.equals(sortedPubValues.values, expected, [ + this.utils.iterableEquality, + this.utils.subsetEquality, + ]); + + return { + pass, + message: () => + pass + ? `Expected pub ${isNot ? "not" : ""} to have values ${JSON.stringify(expected)}, and it does ${isNot ? "not" : ""}` + : `Expected pub ${isNot ? "not to" : "to"} match values ${this.utils.diff(sortedPubValues.values, expected)}`, + }; + }, +}); diff --git a/core/lib/server/pub-capabilities.db.test.ts b/core/lib/server/pub-capabilities.db.test.ts index 73aa203f43..648b8f7967 100644 --- a/core/lib/server/pub-capabilities.db.test.ts +++ b/core/lib/server/pub-capabilities.db.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from "vitest"; import { CoreSchemaType, MemberRole } from "db/public"; -import type { Seed } from "~/prisma/seed/seedCommunity"; +import { createSeed } from "~/prisma/seed/createSeed"; import { mockServerCode } from "../__tests__/utils"; await mockServerCode(); -const seed = { +const seed = createSeed({ community: { name: "test-pub-capabilities", slug: "test-pub-capabilities", @@ -97,7 +97,7 @@ const seed = { }, }, ], -} as Seed; +}); describe("getPubsWithRelatedValuesAndChildren capabilities", () => { it("should restrict pubs by visibility", async () => { diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts new file mode 100644 index 0000000000..41bb0dc31c --- /dev/null +++ b/core/lib/server/pub-op.db.test.ts @@ -0,0 +1,1329 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +import type { PubsId } from "db/public"; +import { CoreSchemaType, MemberRole } from "db/public"; + +import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; +import { mockServerCode } from "~/lib/__tests__/utils"; +import { createLastModifiedBy } from "../lastModifiedBy"; +import { PubOp } from "./pub-op"; + +const { createSeed } = await import("~/prisma/seed/createSeed"); + +const { createForEachMockedTransaction } = await mockServerCode(); +const { getTrx, rollback, commit } = createForEachMockedTransaction(); + +const seed = createSeed({ + community: { + name: "test", + slug: "test-server-pub", + }, + users: { + admin: { + role: MemberRole.admin, + }, + stageEditor: { + role: MemberRole.contributor, + }, + }, + pubFields: { + Title: { schemaName: CoreSchemaType.String }, + Description: { schemaName: CoreSchemaType.String }, + "Some relation": { schemaName: CoreSchemaType.String, relation: true }, + "Another relation": { schemaName: CoreSchemaType.String, relation: true }, + }, + pubTypes: { + "Basic Pub": { + Title: { isTitle: true }, + "Some relation": { isTitle: false }, + "Another relation": { isTitle: false }, + }, + "Minimal Pub": { + Title: { isTitle: true }, + }, + }, + stages: { + "Stage 1": { + members: { + stageEditor: MemberRole.editor, + }, + }, + "Stage 2": { + members: { + stageEditor: MemberRole.editor, + }, + }, + }, + pubs: [ + { + pubType: "Basic Pub", + values: { + Title: "Some title", + }, + stage: "Stage 1", + }, + { + pubType: "Basic Pub", + values: { + Title: "Another title", + }, + relatedPubs: { + "Some relation": [ + { + value: "test relation value", + pub: { + pubType: "Basic Pub", + values: { + Title: "A pub related to another Pub", + }, + }, + }, + ], + }, + }, + { + stage: "Stage 1", + pubType: "Minimal Pub", + values: { + Title: "Minimal pub", + }, + }, + ], +}); + +let seededCommunity: CommunitySeedOutput; + +beforeAll(async () => { + const { seedCommunity } = await import("~/prisma/seed/seedCommunity"); + seededCommunity = await seedCommunity(seed); +}); + +describe("PubOp", () => { + it("should create a new pub", async () => { + const id = crypto.randomUUID() as PubsId; + const pubOp = PubOp.upsert(id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }); + + const pub = await pubOp.execute(); + await expect(pub.id).toExist(); + }); + + it("should not fail when upserting existing pub", async () => { + const id = crypto.randomUUID() as PubsId; + const pubOp = PubOp.upsert(id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }); + + const pub = await pubOp.execute(); + await expect(pub.id).toExist(); + + const pub2 = await PubOp.upsert(id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).execute(); + + await expect(pub2.id).toExist(); + }); + + it("should create a new pub and set values", async () => { + const id = crypto.randomUUID() as PubsId; + const pubOp = PubOp.upsert(id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Some title") + .set({ + [seededCommunity.pubFields["Description"].slug]: "Some description", + }); + + const pub = await pubOp.execute(); + await expect(pub.id).toExist(); + + expect(pub).toHaveValues([ + { + fieldSlug: seededCommunity.pubFields["Description"].slug, + value: "Some description", + }, + { + fieldSlug: seededCommunity.pubFields["Title"].slug, + value: "Some title", + }, + ]); + }); + + it("should be able to relate existing pubs", async () => { + const pubOp = PubOp.upsert(crypto.randomUUID() as PubsId, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }); + + const pub = await pubOp.execute(); + + await expect(pub.id).toExist(); + + const pub2 = await PubOp.upsert(crypto.randomUUID() as PubsId, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .relate(seededCommunity.pubFields["Some relation"].slug, "test relations value", pub.id) + .execute(); + + await expect(pub2.id).toExist(); + expect(pub2).toHaveValues([ + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "test relations value", + relatedPubId: pub.id, + }, + ]); + }); + + it("should create multiple related pubs in a single operation", async () => { + const mainPub = PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Main Pub") + .relate( + seededCommunity.pubFields["Some relation"].slug, + "the first related pub", + PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "Related Pub 1") + ) + .relate( + seededCommunity.pubFields["Another relation"].slug, + "the second related pub", + PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "Related Pub 2") + ); + + const result = await mainPub.execute(); + + expect(result).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Main Pub" }, + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "the first related pub", + relatedPubId: expect.any(String), + }, + { + fieldSlug: seededCommunity.pubFields["Another relation"].slug, + value: "the second related pub", + relatedPubId: expect.any(String), + }, + ]); + }); + + it("should handle deeply nested relations", async () => { + const relatedPub = PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Level 1") + .relate( + seededCommunity.pubFields["Another relation"].slug, + "the second related pub", + + PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "Level 2") + ); + + const mainPub = PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Root") + .relate( + seededCommunity.pubFields["Some relation"].slug, + "the first related pub", + relatedPub + ); + + const result = await mainPub.execute(); + + expect(result).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Root" }, + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "the first related pub", + relatedPubId: expect.any(String), + relatedPub: { + values: [ + { + fieldSlug: seededCommunity.pubFields["Title"].slug, + value: "Level 1", + }, + { + fieldSlug: seededCommunity.pubFields["Another relation"].slug, + value: "the second related pub", + relatedPubId: expect.any(String), + }, + ], + }, + }, + ]); + }); + + it("should handle mixing existing and new pubs in relations", async () => { + // First create a pub that we'll relate to + const existingPub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Existing Pub") + .execute(); + + const mainPub = PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Main Pub") + .relate( + seededCommunity.pubFields["Some relation"].slug, + "the first related pub", + existingPub.id + ) + .relate( + seededCommunity.pubFields["Another relation"].slug, + "the second related pub", + PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "New Related Pub") + ); + + const result = await mainPub.execute(); + + expect(result).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Main Pub" }, + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "the first related pub", + relatedPubId: existingPub.id, + relatedPub: { + id: existingPub.id, + values: [ + { + fieldSlug: seededCommunity.pubFields["Title"].slug, + value: "Existing Pub", + }, + ], + }, + }, + { + fieldSlug: seededCommunity.pubFields["Another relation"].slug, + value: "the second related pub", + relatedPubId: expect.any(String), + relatedPub: { + values: [ + { + fieldSlug: seededCommunity.pubFields["Title"].slug, + value: "New Related Pub", + }, + ], + }, + }, + ]); + }); + + it("should handle circular relations", async () => { + const pub1 = PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "Pub 1"); + + const pub2 = PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Pub 2") + .relate(seededCommunity.pubFields["Some relation"].slug, "the first related pub", pub1); + + pub1.relate( + seededCommunity.pubFields["Another relation"].slug, + "the second related pub", + pub2 + ); + + const result = await pub1.execute(); + + expect(result).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 1" }, + { + fieldSlug: seededCommunity.pubFields["Another relation"].slug, + value: "the second related pub", + relatedPubId: expect.any(String), + relatedPub: { + values: [ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 2" }, + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "the first related pub", + relatedPubId: result.id, + }, + ], + }, + }, + ]); + }); + + it("should fail if you try to createWithId a pub that already exists", async () => { + const pubOp = PubOp.createWithId(seededCommunity.pubs[0].id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }); + + await expect(pubOp.execute()).rejects.toThrow( + /Cannot create a pub with an id that already exists/ + ); + }); + + it("should update the value of a relationship", async () => { + const pub1 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Pub 1") + .execute(); + + const pub2 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Pub 2") + .relate(seededCommunity.pubFields["Some relation"].slug, "initial value", pub1.id) + .execute(); + + const updatedPub = await PubOp.upsert(pub2.id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .relate(seededCommunity.pubFields["Some relation"].slug, "updated value", pub1.id) + .execute(); + + expect(updatedPub).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 2" }, + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "updated value", + relatedPubId: pub1.id, + }, + ]); + }); + + it("should be able to create a related pub with a different pubType then the toplevel pub", async () => { + const pub1 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Pub 1") + .relate( + seededCommunity.pubFields["Some relation"].slug, + "relation", + + PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Minimal Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "Pub 2") + ) + .execute(); + + expect(pub1.pubTypeId).toBe(seededCommunity.pubTypes["Basic Pub"].id); + expect(pub1).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 1" }, + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "relation", + relatedPub: { + pubTypeId: seededCommunity.pubTypes["Minimal Pub"].id, + values: [ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 2" }, + ], + }, + }, + ]); + }); + + describe("upsert", () => { + // when upserting a pub, we should (by default) delete existing values that are not being updated, + // like a PUT + it("should delete existing values that are not being updated", async () => { + const pub1 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Pub 1") + .set(seededCommunity.pubFields["Description"].slug, "Description 1") + .relate(seededCommunity.pubFields["Some relation"].slug, "relation 1", (pubOp) => + pubOp + .create({ + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + }) + .set(seededCommunity.pubFields["Title"].slug, "Pub 2") + ) + .execute(); + + const upsertedPub = await PubOp.upsert(pub1.id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Pub 1, updated") + .execute(); + + expect(upsertedPub).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 1, updated" }, + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "relation 1", + relatedPubId: expect.any(String), + relatedPub: { + values: [ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 2" }, + ], + }, + }, + ]); + }); + + it("should not delete existing values if the `deleteExistingValues` option is false", async () => { + const pub1 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Pub 1") + .set(seededCommunity.pubFields["Description"].slug, "Description 1") + .execute(); + + const upsertedPub = await PubOp.upsert(pub1.id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Pub 1, updated", { + deleteExistingValues: false, + }) + .execute(); + + expect(upsertedPub).toHaveValues([ + { + fieldSlug: seededCommunity.pubFields["Description"].slug, + value: "Description 1", + }, + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 1, updated" }, + ]); + }); + }); +}); + +describe("relation management", () => { + it("should disrelate a specific relation", async () => { + const pub1 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Pub 1") + .execute(); + + const pub2 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Pub 2") + .relate(seededCommunity.pubFields["Some relation"].slug, "initial value", pub1.id) + .execute(); + + // disrelate the relation + const updatedPub = await PubOp.update(pub2.id, { + communityId: seededCommunity.community.id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .unrelate(seededCommunity.pubFields["Some relation"].slug, pub1.id) + .execute(); + + expect(updatedPub).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 2" }, + ]); + }); + + it("should delete orphaned pubs when disrelateing relations", async () => { + const orphanedPub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Soon to be orphaned") + .execute(); + + const mainPub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Main pub") + .relate( + seededCommunity.pubFields["Some relation"].slug, + "only relation", + orphanedPub.id + ) + .execute(); + + // disrelate with deleteOrphaned option + await PubOp.update(mainPub.id, { + communityId: seededCommunity.community.id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .unrelate(seededCommunity.pubFields["Some relation"].slug, orphanedPub.id, { + deleteOrphaned: true, + }) + .execute(); + + await expect(orphanedPub.id).not.toExist(); + }); + + it("should clear all relations for a specific field", async () => { + const related1Id = crypto.randomUUID() as PubsId; + const related2Id = crypto.randomUUID() as PubsId; + + const related1 = PubOp.createWithId(related1Id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "Related 1"); + + const related2 = PubOp.createWithId(related2Id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "Related 2"); + + const mainPub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Main pub") + .relate(seededCommunity.pubFields["Some relation"].slug, "relation 1", related1) + .relate(seededCommunity.pubFields["Some relation"].slug, "relation 2", related2) + .execute(); + + await expect(related1Id).toExist(); + await expect(related2Id).toExist(); + + // clear all relations for the field + const updatedPub = await PubOp.update(mainPub.id, { + communityId: seededCommunity.community.id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .unrelate(seededCommunity.pubFields["Some relation"].slug, "*", { + deleteOrphaned: true, + }) + .execute(); + + expect(updatedPub).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Main pub" }, + ]); + + await expect(related1Id).not.toExist(); + await expect(related2Id).not.toExist(); + }); + + it("should override existing relations when using override option", async () => { + // Create initial related pubs + const related1 = PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "Related 1"); + + const related2 = PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "Related 2"); + + // Create main pub with initial relations + const mainPub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Main pub") + .relate(seededCommunity.pubFields["Some relation"].slug, "relation 1", related1) + .relate(seededCommunity.pubFields["Some relation"].slug, "relation 2", related2) + .execute(); + + const relatedPub1 = mainPub.values.find((v) => v.value === "relation 1")?.relatedPubId; + const relatedPub2 = mainPub.values.find((v) => v.value === "relation 2")?.relatedPubId; + expect(relatedPub1).toBeDefined(); + expect(relatedPub2).toBeDefined(); + await expect(relatedPub1).toExist(); + await expect(relatedPub2).toExist(); + + const related3 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Related 3") + .execute(); + + // Update with override - only related3 should remain + const updatedPub = await PubOp.update(mainPub.id, { + communityId: seededCommunity.community.id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .relate(seededCommunity.pubFields["Some relation"].slug, "new relation", related3.id, { + replaceExisting: true, + }) + .execute(); + + expect(updatedPub).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Main pub" }, + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "new relation", + relatedPubId: related3.id, + }, + ]); + + // related pubs should still exist + await expect(relatedPub1).toExist(); + await expect(relatedPub2).toExist(); + }); + + it("should handle multiple override relations for the same field", async () => { + const related1 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Related 1") + .execute(); + + const related2 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Related 2") + .execute(); + + const mainPub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Main pub") + .relate(seededCommunity.pubFields["Some relation"].slug, "relation 1", related1.id, { + replaceExisting: true, + }) + .execute(); + + const updatedMainPub = await PubOp.update(mainPub.id, { + communityId: seededCommunity.community.id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .relate(seededCommunity.pubFields["Some relation"].slug, "relation 2", related2.id, { + replaceExisting: true, + }) + .relate( + seededCommunity.pubFields["Some relation"].slug, + "relation 3", + PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }), + { replaceExisting: true } + ) + .execute(); + + // Should have relation 2 and 3, but not 1 + expect(updatedMainPub).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Main pub" }, + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "relation 2", + relatedPubId: related2.id, + }, + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "relation 3", + relatedPubId: expect.any(String), + }, + ]); + }); + + it("should handle complex nested relation scenarios", async () => { + const trx = getTrx(); + // manual rollback try/catch bc we are manually setting pubIds, so a failure in the middle of this will leave the db in a weird state + try { + // Create all pubs with meaningful IDs + const pubA = "aaaaaaaa-0000-0000-0000-000000000000" as PubsId; + const pubB = "bbbbbbbb-0000-0000-0000-000000000000" as PubsId; + const pubC = "cccccccc-0000-0000-0000-000000000000" as PubsId; + const pubD = "dddddddd-0000-0000-0000-000000000000" as PubsId; + const pubE = "eeeeeeee-0000-0000-0000-000000000000" as PubsId; + const pubF = "ffffffff-0000-0000-0000-000000000000" as PubsId; + const pubG = "11111111-0000-0000-0000-000000000000" as PubsId; + const pubH = "22222222-0000-0000-0000-000000000000" as PubsId; + const pubI = "33333333-0000-0000-0000-000000000000" as PubsId; + const pubJ = "44444444-0000-0000-0000-000000000000" as PubsId; + const pubK = "55555555-0000-0000-0000-000000000000" as PubsId; + const pubL = "66666666-0000-0000-0000-000000000000" as PubsId; + + // create the graph structure: + // A J + // / \ | + // / \ | + // B C --> I + // | / \ + // G --> E D + // / \ + // F H + // / \ + // K --> L + + // create leaf nodes first + const pubL_op = PubOp.createWithId(pubL, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(seededCommunity.pubFields["Title"].slug, "L"); + + const pubK_op = PubOp.createWithId(pubK, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(seededCommunity.pubFields["Title"].slug, "K") + .relate(seededCommunity.pubFields["Some relation"].slug, "to L", pubL_op); + + const pubF_op = PubOp.createWithId(pubF, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(seededCommunity.pubFields["Title"].slug, "F"); + + const pubH_op = PubOp.createWithId(pubH, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(seededCommunity.pubFields["Title"].slug, "H") + .relate(seededCommunity.pubFields["Some relation"].slug, "to K", pubK_op) + .relate(seededCommunity.pubFields["Some relation"].slug, "to L", pubL_op); + + const pubE_op = PubOp.createWithId(pubE, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(seededCommunity.pubFields["Title"].slug, "E") + .relate(seededCommunity.pubFields["Some relation"].slug, "to F", pubF_op); + + const pubG_op = PubOp.createWithId(pubG, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(seededCommunity.pubFields["Title"].slug, "G") + .relate(seededCommunity.pubFields["Some relation"].slug, "to E", pubE_op); + + const pubD_op = PubOp.createWithId(pubD, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(seededCommunity.pubFields["Title"].slug, "D") + .relate(seededCommunity.pubFields["Some relation"].slug, "to H", pubH_op); + + const pubI_op = PubOp.createWithId(pubI, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(seededCommunity.pubFields["Title"].slug, "I"); + + // Create second layer + const pubB_op = PubOp.createWithId(pubB, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(seededCommunity.pubFields["Title"].slug, "B") + .relate(seededCommunity.pubFields["Some relation"].slug, "to G", pubG_op); + + const pubC_op = PubOp.createWithId(pubC, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(seededCommunity.pubFields["Title"].slug, "C") + .relate(seededCommunity.pubFields["Some relation"].slug, "to I", pubI_op) + .relate(seededCommunity.pubFields["Some relation"].slug, "to D", pubD_op) + .relate(seededCommunity.pubFields["Some relation"].slug, "to E", pubE_op); + + // create root and J + const rootPub = await PubOp.createWithId(pubA, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(seededCommunity.pubFields["Title"].slug, "A") + .relate(seededCommunity.pubFields["Some relation"].slug, "to B", pubB_op) + .relate(seededCommunity.pubFields["Some relation"].slug, "to C", pubC_op) + .execute(); + + const pubJ_op = await PubOp.createWithId(pubJ, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(seededCommunity.pubFields["Title"].slug, "J") + .relate(seededCommunity.pubFields["Some relation"].slug, "to I", pubI) + .execute(); + + const { getPubsWithRelatedValuesAndChildren } = await import("~/lib/server/pub"); + + // verify the initial state + const initialState = await getPubsWithRelatedValuesAndChildren( + { + pubId: pubA, + communityId: seededCommunity.community.id, + }, + { trx, depth: 10 } + ); + + expect(initialState).toHaveValues([ + { value: "A" }, + { + value: "to B", + relatedPubId: pubB, + relatedPub: { + values: [ + { value: "B" }, + { + value: "to G", + relatedPubId: pubG, + relatedPub: { + values: [ + { value: "G" }, + { + value: "to E", + relatedPubId: pubE, + relatedPub: { + values: [ + { value: "E" }, + { + value: "to F", + relatedPubId: pubF, + relatedPub: { + values: [{ value: "F" }], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + value: "to C", + relatedPubId: pubC, + relatedPub: { + values: [ + { value: "C" }, + { + value: "to D", + relatedPubId: pubD, + relatedPub: { + values: [ + { value: "D" }, + { + value: "to H", + relatedPubId: pubH, + relatedPub: { + values: [ + { value: "H" }, + { + value: "to K", + relatedPubId: pubK, + relatedPub: { + values: [ + { value: "K" }, + { + value: "to L", + relatedPubId: pubL, + relatedPub: { + values: [{ value: "L" }], + }, + }, + ], + }, + }, + { + value: "to L", + }, + ], + }, + }, + ], + }, + }, + { + value: "to E", + relatedPubId: pubE, + }, + { + value: "to I", + relatedPubId: pubI, + relatedPub: { + values: [{ value: "I" }], + }, + }, + ], + }, + }, + ]); + + // Now we disrelate C from A, which should + // orphan everything from D down, + // but should not orphan I, bc J still points to it + // and should not orphan G, bc B still points to it + // it orphans L, even though K points to it, because K is itself an orphan + // A J + // / | + // v X v + // B C --> I + // | / \ + // v v v + // G --> E D + // | \ + // v v + // F H + // / \ + // v v + // K --> L + await PubOp.update(pubA, { + communityId: seededCommunity.community.id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .unrelate(seededCommunity.pubFields["Some relation"].slug, pubC, { + deleteOrphaned: true, + }) + .execute(); + + // verify deletions + await expect(pubA, "A should exist").toExist(trx); + await expect(pubB, "B should exist").toExist(trx); + await expect(pubC, "C should not exist").not.toExist(trx); + await expect(pubD, "D should not exist").not.toExist(trx); + await expect(pubE, "E should exist").toExist(trx); // still relateed through G + await expect(pubF, "F should exist").toExist(trx); // still relateed through E + await expect(pubG, "G should exist").toExist(trx); // not relateed to C at all + await expect(pubH, "H should not exist").not.toExist(trx); + await expect(pubI, "I should exist").toExist(trx); // still relateed through J + await expect(pubJ, "J should exist").toExist(trx); // not relateed to C at all + await expect(pubK, "K should not exist").not.toExist(trx); + await expect(pubL, "L should not exist").not.toExist(trx); + } catch (e) { + rollback(); + throw e; + } + }); + + it("should handle selective orphan deletion based on field", async () => { + // Create a pub with two relations + const related1 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Related 1") + .execute(); + + const related2 = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Related 2") + .execute(); + + const mainPub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Main") + .relate(seededCommunity.pubFields["Some relation"].slug, "relation1", related1.id) + .relate(seededCommunity.pubFields["Another relation"].slug, "relation2", related2.id) + .execute(); + + // clear one field with deleteOrphaned and one without + await PubOp.update(mainPub.id, { + communityId: seededCommunity.community.id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .unrelate(seededCommunity.pubFields["Some relation"].slug, "*", { + deleteOrphaned: true, + }) + .unrelate(seededCommunity.pubFields["Another relation"].slug, "*") + .execute(); + + // related1 should be deleted (orphaned with deleteOrphaned: true) + await expect(related1.id).not.toExist(); + // related2 should still exist (orphaned but deleteOrphaned not set) + await expect(related2.id).toExist(); + }); + + it("should handle override with mixed deleteOrphaned flags", async () => { + // Create initial relations + const toKeep = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Keep Me") + .execute(); + + const toDelete = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Delete Me") + .execute(); + + const mainPub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Main") + .relate(seededCommunity.pubFields["Some relation"].slug, "keep", toKeep.id) + .relate(seededCommunity.pubFields["Another relation"].slug, "delete", toDelete.id) + .execute(); + + // Override relations with different deleteOrphaned flags + const newRelation = PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "New"); + + await PubOp.update(mainPub.id, { + communityId: seededCommunity.community.id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .relate(seededCommunity.pubFields["Some relation"].slug, "new", newRelation, { + replaceExisting: true, + }) + .relate(seededCommunity.pubFields["Another relation"].slug, "also new", newRelation, { + replaceExisting: true, + deleteOrphaned: true, + }) + .execute(); + + // toKeep should still exist (override without deleteOrphaned) + await expect(toKeep.id).toExist(); + // toDelete should be deleted (override with deleteOrphaned) + await expect(toDelete.id).not.toExist(); + }); + + /** + * this is so you do not need to keep specifying the communityId, pubTypeId, etc. + * when creating nested PubOps + */ + it("should be able to do PubOps inline in a relate", async () => { + const pub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(seededCommunity.pubFields["Title"].slug, "Test") + .relate(seededCommunity.pubFields["Some relation"].slug, "relation1", (pubOp) => + pubOp + .create({ pubTypeId: seededCommunity.pubTypes["Minimal Pub"].id }) + .set(seededCommunity.pubFields["Title"].slug, "Relation 1") + ) + .execute(); + + expect(pub).toHaveValues([ + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "relation1", + relatedPub: { + values: [ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Relation 1" }, + ], + }, + }, + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Test" }, + ]); + }); + + it("should be able to relate many pubs at once", async () => { + const pub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .relate(seededCommunity.pubFields["Some relation"].slug, [ + { + target: (pubOp) => + pubOp + .create({ pubTypeId: seededCommunity.pubTypes["Minimal Pub"].id }) + .set(seededCommunity.pubFields["Title"].slug, "Relation 1"), + value: "relation1", + }, + { + target: (pubOp) => + pubOp + .create({ pubTypeId: seededCommunity.pubTypes["Minimal Pub"].id }) + .set(seededCommunity.pubFields["Title"].slug, "Relation 2"), + value: "relation2", + }, + ]) + .execute(); + + expect(pub).toHaveValues([ + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "relation1", + relatedPub: { + values: [ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Relation 1" }, + ], + }, + }, + { + fieldSlug: seededCommunity.pubFields["Some relation"].slug, + value: "relation2", + relatedPub: { + values: [ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Relation 2" }, + ], + }, + }, + ]); + }); +}); + +describe("PubOp stage", () => { + it("should be able to set a stage while creating a pub", async () => { + const trx = getTrx(); + const pub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(seededCommunity.pubFields["Title"].slug, "Test") + .setStage(seededCommunity.stages["Stage 1"].id) + .execute(); + + expect(pub.stageId).toEqual(seededCommunity.stages["Stage 1"].id); + }); + + it("should be able to unset a stage", async () => { + const trx = getTrx(); + const pub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .setStage(seededCommunity.stages["Stage 1"].id) + .execute(); + + expect(pub.stageId).toEqual(seededCommunity.stages["Stage 1"].id); + + const updatedPub = await PubOp.update(pub.id, { + communityId: seededCommunity.community.id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .setStage(null) + .execute(); + + expect(updatedPub.stageId).toEqual(null); + }); + + it("should be able to move a pub to different stage", async () => { + const trx = getTrx(); + const pub = await PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(seededCommunity.pubFields["Title"].slug, "Test") + .setStage(seededCommunity.stages["Stage 1"].id) + .execute(); + + const updatedPub = await PubOp.upsert(pub.id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .setStage(seededCommunity.stages["Stage 2"].id) + .execute(); + + expect(updatedPub.stageId).toEqual(seededCommunity.stages["Stage 2"].id); + }); +}); diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts new file mode 100644 index 0000000000..b7d1d00d5e --- /dev/null +++ b/core/lib/server/pub-op.ts @@ -0,0 +1,1313 @@ +import type { Transaction } from "kysely"; + +import { sql } from "kysely"; + +import type { JsonValue, ProcessedPub } from "contracts"; +import type { Database } from "db/Database"; +import type { CommunitiesId, PubFieldsId, PubsId, PubTypesId, StagesId } from "db/public"; +import type { LastModifiedBy } from "db/types"; +import { assert, expect } from "utils"; +import { isUuid } from "utils/uuid"; + +import { db } from "~/kysely/database"; +import { autoRevalidate } from "./cache/autoRevalidate"; +import { + deletePub, + deletePubValuesByValueId, + getPubsWithRelatedValuesAndChildren, + maybeWithTrx, + upsertPubRelationValues, + upsertPubValues, + validatePubValues, +} from "./pub"; + +type PubValue = string | number | boolean | JsonValue; + +type PubOpOptionsBase = { + communityId: CommunitiesId; + lastModifiedBy: LastModifiedBy; + trx?: Transaction; +}; + +type PubOpOptionsCreateUpsert = PubOpOptionsBase & { + pubTypeId: PubTypesId; +}; + +type PubOpOptionsUpdate = PubOpOptionsBase & { + pubTypeId?: never; +}; + +type PubOpOptions = PubOpOptionsCreateUpsert | PubOpOptionsUpdate; + +type SetOptions = { + /** + * if this is not `false` for all `set` commands, + * all non-updating non-relation values will be deleted. + * + * @default true for `upsert` + * @default false for `update`. + * + * Does not have an effect for `create`. + * + * eg, here all non-updating non-relation values will be deleted, because at least one value has `deleteExistingValues: true` + * ```ts + * // before + * // title: "old title" + * // description: "old description" + * // publishedAt: "2024-01-01" + * + * PubOp.update(id, { } ) + * .set("title", "new title", { deleteExistingValues: true }) + * .set("description", "new description") + * .execute(); + * + * // after + * // title: "new title" + * // description: "new description" + * // -- no publishedAt value, because it was deleted + * ``` + * + * The converse holds true for for `upsert`: by default we act as if `deleteExistingValues: true` for all `set` commands. + * ```ts + * // before + * // title: "old title" + * // description: "old description" + * // publishedAt: "2024-01-01" + * + * PubOp.upsert(id, { } ) + * .set("title", "new title") + * .set("description", "new description") + * .execute(); + * + * // after + * // title: "new title" + * // description: "new description" + * // -- no publishedAt value, because it was deleted + * ``` + * + * to opt out of this behavior on `upsert`, you need to explicitly set `{ deleteExistingValues: false }` for all `set` commands you pass + * ```ts + * // before + * // title: "old title" + * // description: "old description" + * // publishedAt: "2024-01-01" + * + * PubOp.upsert(id, { } ) + * .set("title", "new title", { deleteExistingValues: false }) + * .set("description", "new description", { deleteExistingValues: false }) + * .execute(); + * + * // OR + * PubOp.upsert(id, { } ) + * .set({ + * title: "new title", + * description: "new description", + * }, { deleteExistingValues: false }) + * .execute(); + * + * // after + * // title: "new title" + * // description: "new description" + * // publishedAt: "2024-01-01" -- not deleted, because `deleteExistingValues` is false for all + * ``` + */ + deleteExistingValues?: boolean; +}; + +type RelationOptions = { + /** + * If true, existing relations on the _same_ field will be removed + * + * Pubs these relations are pointing to will not be removed unless `deleteOrphaned` is also true + * + * @default true for `upsert` + * @default false for `update` and `create` + */ + replaceExisting?: boolean; + /** + * If true, pubs that have been disconnected and all their descendants that are not otherwise connected + * will be deleted. + * + * Does not do anything unless `replaceExisting` is also true + * + * @default false + */ + deleteOrphaned?: boolean; +}; + +// Base commands that will be used internally +type SetCommand = { type: "set"; slug: string; value: PubValue | undefined; options?: SetOptions }; +type RelateCommand = { + type: "relate"; + slug: string; + relations: Array<{ target: ActivePubOp | PubsId; value: PubValue }>; + options: RelationOptions; +}; +type UnrelateCommand = { + type: "unrelate"; + slug: (string & {}) | "*"; + target: PubsId | "*"; + options?: { + /** + * If true, pubs that have been disconnected and all their descendants that are not otherwise connected + * will be deleted. + */ + deleteOrphaned?: boolean; + }; +}; + +type UnsetCommand = { + type: "unset"; + slug: string; +}; + +type SetStageCommand = { + type: "setStage"; + stage: StagesId | null; +}; + +type PubOpCommand = SetCommand | RelateCommand | UnrelateCommand | UnsetCommand | SetStageCommand; + +type ClearRelationOperation = { + type: "clear"; + slug: string; + options?: Omit; +}; + +type RemoveRelationOperation = { + type: "remove"; + slug: string; + target: PubsId; + options?: Omit; +}; + +type OverrideRelationOperation = { + type: "override"; + slug: string; + options?: RelationOptions; +}; + +type RelationOperation = + | ClearRelationOperation + | RemoveRelationOperation + | OverrideRelationOperation; + +// Types for operation collection +type OperationMode = "create" | "upsert" | "update"; + +interface CollectedOperationBase { + id: PubsId | undefined; + values: Array<{ slug: string; value: PubValue; options?: SetOptions }>; + relationsToAdd: Array<{ + slug: string; + value: PubValue; + target: PubsId; + options: RelationOptions; + }>; + relationsToRemove: Array<{ + slug: string; + target: PubsId; + options?: Omit; + }>; + relationsToClear: Array<{ + slug: string | "*"; + options?: Omit; + }>; + /** + * null meaning no stage + */ + stage?: StagesId | null; +} + +type CreateOrUpsertOperation = CollectedOperationBase & { + mode: "upsert" | "create"; + pubTypeId: PubTypesId; +}; +type UpdateOperation = CollectedOperationBase & { + mode: "update"; + /** + * you cannot update the pubTypeId of an existing pub + */ + pubTypeId?: never; +}; + +type CollectedOperation = CreateOrUpsertOperation | UpdateOperation; + +type OperationsMap = Map; + +type PubOpErrorCode = + | "RELATION_CYCLE" + | "ORPHAN_CONFLICT" + | "VALIDATION_ERROR" + | "INVALID_TARGET" + | "CREATE_EXISTING" + | "UNKNOWN"; + +class PubOpError extends Error { + readonly code: PubOpErrorCode; + constructor(code: PubOpErrorCode, message: string) { + super(message); + this.name = "PubOpError"; + this.code = code; + } +} + +/** + * Could be useful if we want to disallow the creation of cycles + */ +class PubOpRelationCycleError extends PubOpError { + constructor(message: string) { + super("RELATION_CYCLE", `Relation cycle detected: ${message}`); + } +} + +class PubOpValidationError extends PubOpError { + constructor(message: string) { + super("VALIDATION_ERROR", `Validation error: ${message}`); + } +} + +class PubOpInvalidTargetError extends PubOpError { + constructor(relation: string, target: string, message?: string) { + super( + "INVALID_TARGET", + `Invalid target for relation \`${relation}\`: \`${target}\` ${message ?? ""}` + ); + } +} + +class PubOpCreateExistingError extends PubOpError { + constructor(pubId: PubsId) { + super("CREATE_EXISTING", `Cannot create a pub with an id that already exists: ${pubId}`); + } +} + +class PubOpUnknownError extends PubOpError { + constructor(message: string) { + super("UNKNOWN", message); + } +} + +function isPubId(val: string | PubsId): val is PubsId { + return isUuid(val); +} + +// Add this class to handle nested pub operations +class NestedPubOpBuilder { + constructor(private readonly parentOptions: PubOpOptionsBase) {} + + create(options: Partial & { pubTypeId: PubTypesId }): CreatePubOp { + return new CreatePubOp({ + ...this.parentOptions, + ...options, + }); + } + + createWithId( + id: PubsId, + options: Partial & { pubTypeId: PubTypesId } + ): CreatePubOp { + return new CreatePubOp( + { + ...this.parentOptions, + ...options, + }, + id + ); + } + + update(id: PubsId, options: Partial = {}): UpdatePubOp { + return new UpdatePubOp( + { + ...this.parentOptions, + ...options, + }, + id + ); + } + + upsert( + id: PubsId, + options: Omit> + ): UpsertPubOp { + return new UpsertPubOp( + { + ...this.parentOptions, + ...options, + }, + id + ); + } + + upsertByValue( + slug: string, + value: PubValue, + options: Omit> + ): UpsertPubOp { + return new UpsertPubOp( + { + ...this.parentOptions, + ...options, + }, + undefined, + slug, + value + ); + } +} + +/** + * common operations available to all PubOp types + */ +abstract class BasePubOp { + protected readonly options: PubOpOptions; + protected readonly commands: PubOpCommand[] = []; + readonly id: PubsId; + + constructor(options: PubOpOptions & { id?: PubsId }) { + this.options = options; + this.id = options.id ?? (crypto.randomUUID() as PubsId); + } + + /** + * Set a single value or multiple values + */ + set(slug: string, value: PubValue, options?: SetOptions): this; + set(values: Record, options?: SetOptions): this; + set( + slugOrValues: string | Record, + valueOrOptions?: PubValue | SetOptions, + options?: SetOptions + ): this { + const defaultOptions = this.getMode() === "upsert" ? { deleteExistingValues: true } : {}; + + if (typeof slugOrValues === "string") { + this.commands.push({ + type: "set", + slug: slugOrValues, + value: valueOrOptions, + options: options ?? defaultOptions, + }); + return this; + } + + this.commands.push( + ...Object.entries(slugOrValues).map(([slug, value]) => ({ + type: "set" as const, + slug, + value, + options: (valueOrOptions as SetOptions) ?? defaultOptions, + })) + ); + return this; + } + + private isRelationBlockConfig( + valueOrRelations: + | PubValue + | Array<{ + target: PubsId | BasePubOp | ((builder: NestedPubOpBuilder) => BasePubOp); + value: PubValue; + }> + ): valueOrRelations is Array<{ + target: PubsId | BasePubOp | ((builder: NestedPubOpBuilder) => BasePubOp); + value: PubValue; + }> { + if (!Array.isArray(valueOrRelations)) { + return false; + } + + return valueOrRelations.every( + (r) => typeof r === "object" && r !== null && "target" in r && "value" in r + ); + } + + /** + * Relate to a single pub with a value + */ + relate( + slug: string, + value: PubValue, + target: ActivePubOp | PubsId | ((pubOp: NestedPubOpBuilder) => ActivePubOp), + options?: RelationOptions + ): this; + /** + * Relate to multiple pubs at once + */ + relate( + slug: string, + relations: Array<{ + target: PubsId | ActivePubOp | ((builder: NestedPubOpBuilder) => ActivePubOp); + value: PubValue; + }>, + options?: RelationOptions + ): this; + relate( + slug: string, + valueOrRelations: + | PubValue + | Array<{ + target: PubsId | ActivePubOp | ((builder: NestedPubOpBuilder) => ActivePubOp); + value: PubValue; + }>, + targetOrOptions?: + | ActivePubOp + | PubsId + | ((pubOp: NestedPubOpBuilder) => ActivePubOp) + | RelationOptions, + options?: RelationOptions + ): this { + const nestedBuilder = new NestedPubOpBuilder(this.options); + + // for upsert we almost always want to replace existing relations + const defaultOptions = this.getMode() === "upsert" ? { replaceExisting: true } : {}; + + // multi relation case + if (this.isRelationBlockConfig(valueOrRelations)) { + this.commands.push({ + type: "relate", + slug, + relations: valueOrRelations.map((r) => ({ + target: typeof r.target === "function" ? r.target(nestedBuilder) : r.target, + value: r.value, + })), + options: (targetOrOptions as RelationOptions) ?? defaultOptions, + }); + return this; + } + + // single relation case + const target = targetOrOptions as + | ActivePubOp + | PubsId + | ((pubOp: NestedPubOpBuilder) => ActivePubOp); + const resolvedTarget = typeof target === "function" ? target(nestedBuilder) : target; + + if (typeof resolvedTarget === "string" && !isPubId(resolvedTarget)) { + throw new PubOpInvalidTargetError(slug, resolvedTarget); + } + + this.commands.push({ + type: "relate", + slug, + relations: [ + { + target: resolvedTarget, + value: valueOrRelations, + }, + ], + options: options ?? defaultOptions, + }); + return this; + } + + /** + * Set the stage of the pub + * + * `null` meaning no stage + */ + setStage(stage: StagesId | null): this { + this.commands.push({ + type: "setStage", + stage, + }); + return this; + } + + async execute(): Promise { + const { trx = db } = this.options; + const pubId = await maybeWithTrx(trx, (trx) => this.executeWithTrx(trx)); + return getPubsWithRelatedValuesAndChildren( + { pubId, communityId: this.options.communityId }, + { trx } + ); + } + + private collectOperations(processed = new Set()): OperationsMap { + // If we've already processed this PubOp, return empty map to avoid circular recursion + if (processed.has(this.id)) { + return new Map(); + } + + const operations = new Map() as OperationsMap; + processed.add(this.id); + + // Add this pub's operations + operations.set(this.id, { + id: this.id, + mode: this.getMode(), + pubTypeId: this.options.pubTypeId, + values: this.collectValues(), + relationsToAdd: [], + relationsToRemove: [], + relationsToClear: [], + } as CollectedOperation); + + for (const cmd of this.commands) { + const rootOp = operations.get(this.id); + assert(rootOp, "Root operation not found"); + + if (cmd.type === "set") continue; // Values already collected + + if (cmd.type === "unrelate") { + if (cmd.slug === "*") { + rootOp.relationsToClear.push({ + slug: "*", + options: cmd.options, + }); + continue; + } + + if (cmd.target === "*") { + rootOp.relationsToClear.push({ + slug: cmd.slug, + options: cmd.options, + }); + continue; + } + + rootOp.relationsToRemove.push({ + slug: cmd.slug, + target: cmd.target, + options: cmd.options, + }); + } else if (cmd.type === "setStage") { + rootOp.stage = cmd.stage; + } else if (cmd.type === "relate") { + // Process each relation in the command + cmd.relations.forEach((relation) => { + // if the target is just a PubId, we can add the relation directly + + if (typeof relation.target === "string" && isPubId(relation.target)) { + rootOp.relationsToAdd.push({ + slug: cmd.slug, + value: relation.value, + target: relation.target as PubsId, + options: cmd.options, + }); + + return; + } + + rootOp.relationsToAdd.push({ + slug: cmd.slug, + value: relation.value, + target: relation.target.id, + options: cmd.options, + }); + + // if we have already processed this target, we can stop here + if (processed.has(relation.target.id)) { + return; + } + + const targetOps = relation.target.collectOperations(processed); + for (const [key, value] of targetOps) { + operations.set(key, value); + } + }); + } + } + + return operations; + } + + protected abstract getMode(): OperationMode; + + /** + * execute the operations with a transaction + * + * this is where the magic happens, basically + */ + protected async executeWithTrx(trx: Transaction): Promise { + const operations = this.collectOperations(); + + await this.createAllPubs(trx, operations); + await this.processStages(trx, operations); + await this.processRelations(trx, operations); + await this.processValues(trx, operations); + + return this.id; + } + + private collectValues(): Array<{ slug: string; value: PubValue; options?: SetOptions }> { + return this.commands + .filter( + (cmd): cmd is Extract => + cmd.type === "set" && cmd.value !== undefined + ) + .map((cmd) => ({ + slug: cmd.slug, + value: cmd.value!, + options: cmd.options, + })); + } + + private async createAllPubs( + trx: Transaction, + operations: OperationsMap + ): Promise { + const createOrUpsertOperations = Array.from(operations.entries()).filter( + ([_, operation]) => operation.mode === "create" || operation.mode === "upsert" + ); + + if (createOrUpsertOperations.length === 0) { + return; + } + + const pubsToCreate = createOrUpsertOperations.map(([key, operation]) => ({ + id: key, + communityId: this.options.communityId, + pubTypeId: expect(operation.pubTypeId), + })); + + const createdPubs = await autoRevalidate( + trx + .insertInto("pubs") + .values(pubsToCreate) + .onConflict((oc) => oc.columns(["id"]).doNothing()) + .returningAll() + ).execute(); + + /** + * this is a bit of a hack to fill in the holes in the array of created pubs + * because onConflict().doNothing() does not return anything on conflict + * so we have to manually fill in the holes in the array of created pubs + * in order to make looping over the operations and upserting values/relations work + */ + createOrUpsertOperations.forEach(([key, op], index) => { + const createdPub = createdPubs[index]; + const pubToCreate = pubsToCreate[index]; + + if (pubToCreate.id && pubToCreate.id !== createdPub?.id && op.mode === "create") { + throw new PubOpCreateExistingError(pubToCreate.id); + } + }); + + return; + } + + private async processRelations( + trx: Transaction, + operations: OperationsMap + ): Promise { + const relationsToCheckForOrphans = new Set(); + + for (const [pubId, op] of operations) { + const allOps = [ + ...op.relationsToAdd + .filter((r) => r.options.replaceExisting) + .map((r) => ({ type: "override", ...r })), + ...op.relationsToClear.map((r) => ({ type: "clear", ...r })), + ...op.relationsToRemove.map((r) => ({ type: "remove", ...r })), + ] as RelationOperation[]; + + if (allOps.length === 0) { + continue; + } + + // Find all existing relations that might be affected + const existingRelations = await trx + .selectFrom("pub_values") + .innerJoin("pub_fields", "pub_fields.id", "pub_values.fieldId") + .select(["pub_values.id", "relatedPubId", "pub_fields.slug"]) + .where("pubId", "=", pubId) + .where("relatedPubId", "is not", null) + .where( + "slug", + "in", + allOps.map((op) => op.slug) + ) + .$narrowType<{ relatedPubId: PubsId }>() + .execute(); + + // Determine which relations to delete + const relationsToDelete = existingRelations.filter((relation) => { + return allOps.some((relationOp) => { + if (relationOp.slug !== relation.slug) { + return false; + } + + switch (relationOp.type) { + case "clear": + return true; + case "remove": + return relationOp.target === relation.relatedPubId; + case "override": + return true; + } + }); + }); + + if (relationsToDelete.length === 0) { + continue; + } + // delete the relation values only + await deletePubValuesByValueId({ + pubId, + valueIds: relationsToDelete.map((r) => r.id), + lastModifiedBy: this.options.lastModifiedBy, + trx, + }); + + // check which relations should also be removed due to being orphaned + const possiblyOrphanedRelations = relationsToDelete.filter((relation) => { + return allOps.some((relationOp) => { + if (relationOp.slug !== relation.slug) { + return false; + } + + if (!relationOp.options?.deleteOrphaned) { + return false; + } + + switch (relationOp.type) { + case "clear": + return true; + case "remove": + return relationOp.target === relation.relatedPubId; + case "override": + return true; + } + }); + }); + + if (!possiblyOrphanedRelations.length) { + continue; + } + + possiblyOrphanedRelations.forEach((r) => { + relationsToCheckForOrphans.add(r.relatedPubId); + }); + } + + await this.cleanupOrphanedPubs(trx, Array.from(relationsToCheckForOrphans)); + } + + /** + * remove pubs that have been disconnected/their value removed, + * has `deleteOrphaned` set to true for their relevant relation operation, + * AND have no other relations + * + * curently it's not possible to forcibly remove pubs if they are related to other pubs + * perhaps this could be yet another setting + * + * ### Brief explanation + * + * Say we have the following graph of pubs, + * where `A --> C` indicates the existence of a `pub_value` + * ```ts + * { + * pubId: "A", + * relatedPubId: "C", + * } + * ``` + * + * ``` + * A J + * ┌──┴───┐ │ + * ▼ ▼ ▼ + * B C ────────► I + * │ ┌─┴────┐ + * ▼ ▼ ▼ + * G ─► E D + * │ │ + * ▼ ▼ + * F H + * ┌─┴──┐ + * ▼ ▼ + * K ──► L + * ``` + * + * Say we now disconnect `C` from `A`, i.e. we remove the `pub_value` where `pubId = "A"` and `relatedPubId = "C"` + * + * + * Now we disrelate C from A, which should + * orphan everything from D down, + * but should not orphan I, bc J still points to it + * and should not orphan G, bc B still points to it + * it orphans L, even though K points to it, because K is itself an orphan + * ``` + * A J + * ┌──┴ │ + * ▼ ▼ + * B C ────────► I + * │ ┌─┴────┐ + * ▼ ▼ ▼ + * G ─► E D + * │ │ + * ▼ ▼ + * F H + * ┌─┴──┐ + * ▼ ▼ + * K ──► L + * ``` + * + * Then by using the following rules, we can determine which pubs should be deleted: + * + * 1. All pubs down from the disconnected pub + * 2. Which are not reachable from any other pub not in the tree + * + * Using these two rules, we can determine which pubs should be deleted: + * 1. C, as C is disconnected is not the target of any other relation + * 2. D, F, H, K, and L, as they are only reachable from C, which is being deleted + * + * Notably, E and I are not deleted, because + * 1. E is the target of a relation from G, which, while still a relation itself, is not reachable from the C-tree + * 2. I is the target of a relation from J, which, while still a relation itself, is not reachable from the C-tree + * + * So this should be the resulting graph: + * + * ``` + * A J + * ┌──┴ │ + * ▼ ▼ + * B I + * │ + * ▼ + * G ─► E + * │ + * ▼ + * F + * ``` + * + * + */ + private async cleanupOrphanedPubs( + trx: Transaction, + orphanedPubIds: PubsId[] + ): Promise { + if (orphanedPubIds.length === 0) { + return; + } + + const pubsToDelete = await trx + .withRecursive("affected_pubs", (db) => { + // Base case: direct connections from the to-be-removed-pubs down + const initial = db + .selectFrom("pub_values") + .select(["pubId as id", sql`array["pubId"]`.as("path")]) + .where("pubId", "in", orphanedPubIds); + + // Recursive case: keep traversing outward + const recursive = db + .selectFrom("pub_values") + .select([ + "relatedPubId as id", + sql`affected_pubs.path || array["relatedPubId"]`.as("path"), + ]) + .innerJoin("affected_pubs", "pub_values.pubId", "affected_pubs.id") + .where((eb) => eb.not(eb("relatedPubId", "=", eb.fn.any("affected_pubs.path")))) // Prevent cycles + .$narrowType<{ id: PubsId }>(); + + return initial.union(recursive); + }) + // pubs in the affected_pubs table but which should not be deleted because they are still related to other pubs + .with("safe_pubs", (db) => { + return ( + db + .selectFrom("pub_values") + .select(["relatedPubId as id"]) + .distinct() + // crucial part: + // find all the pub_values which + // - point to a node in the affected_pubs + // - but are not themselves affected + // these are the "safe" nodes + .innerJoin("affected_pubs", "pub_values.relatedPubId", "affected_pubs.id") + .where((eb) => + eb.not( + eb.exists((eb) => + eb + .selectFrom("affected_pubs") + .select("id") + .whereRef("id", "=", "pub_values.pubId") + ) + ) + ) + ); + }) + .selectFrom("affected_pubs") + .select(["id", "path"]) + .distinctOn("id") + .where((eb) => + eb.not( + eb.exists((eb) => + eb + .selectFrom("safe_pubs") + .select("id") + .where(sql`safe_pubs.id = any(affected_pubs.path)`) + ) + ) + ) + .execute(); + + if (pubsToDelete.length > 0) { + await deletePub({ + pubId: pubsToDelete.map((p) => p.id), + communityId: this.options.communityId, + lastModifiedBy: this.options.lastModifiedBy, + trx, + }); + } + } + + private async processValues( + trx: Transaction, + operations: OperationsMap + ): Promise { + const toUpsert = Array.from(operations.entries()).flatMap(([key, op]) => { + return [ + // regular values + ...op.values.map((v) => ({ + pubId: key, + slug: v.slug, + value: v.value, + options: v.options, + })), + // relations + ...op.relationsToAdd.map((r) => ({ + pubId: key, + slug: r.slug, + value: r.value, + relatedPubId: r.target, + })), + ]; + }); + + if (toUpsert.length === 0) { + return; + } + + const validated = await validatePubValues({ + pubValues: toUpsert, + communityId: this.options.communityId, + continueOnValidationError: false, + trx, + }); + + const { values, relations } = this.partitionValidatedValues(validated); + + // if some values have `deleteExistingValues` set to true, + // we need to delete all the existing values for this pub + const shouldDeleteExistingValues = values.some((v) => !!v.options?.deleteExistingValues); + + if (values.length > 0 && shouldDeleteExistingValues) { + // get all the values that are not being updated + + const nonUpdatingValues = await trx + .selectFrom("pub_values") + .where("pubId", "=", this.id) + .where("relatedPubId", "is", null) + .where( + "fieldId", + "not in", + values.map((v) => v.fieldId) + ) + .select("id") + .execute(); + + await deletePubValuesByValueId({ + pubId: this.id, + valueIds: nonUpdatingValues.map((v) => v.id), + lastModifiedBy: this.options.lastModifiedBy, + trx, + }); + } + + await Promise.all([ + values.length > 0 && + upsertPubValues({ + pubId: "xxx" as PubsId, + pubValues: values, + lastModifiedBy: this.options.lastModifiedBy, + trx, + }), + relations.length > 0 && + upsertPubRelationValues({ + pubId: "xxx" as PubsId, + allRelationsToCreate: relations, + lastModifiedBy: this.options.lastModifiedBy, + trx, + }), + ]); + } + + // --- Helper methods --- + + private partitionValidatedValues< + T extends { + pubId: PubsId; + fieldId: PubFieldsId; + value: PubValue; + options?: SetOptions; + }, + >(validated: Array) { + return { + values: validated + .filter((v) => !("relatedPubId" in v) || !v.relatedPubId) + .map((v) => ({ + pubId: v.pubId, + fieldId: v.fieldId, + value: v.value, + lastModifiedBy: this.options.lastModifiedBy, + options: v.options, + })), + relations: validated + .filter( + (v): v is T & { relatedPubId: PubsId } => + "relatedPubId" in v && !!v.relatedPubId + ) + .map((v) => ({ + pubId: v.pubId, + fieldId: v.fieldId, + value: v.value, + relatedPubId: v.relatedPubId, + lastModifiedBy: this.options.lastModifiedBy, + options: v.options, + })), + }; + } + + private async processStages( + trx: Transaction, + operations: OperationsMap + ): Promise { + const stagesToUpdate = Array.from(operations.entries()) + .filter(([_, op]) => op.stage !== undefined) + .map(([pubId, op]) => ({ + pubId, + stageId: op.stage!, + })); + + if (stagesToUpdate.length === 0) { + return; + } + + const nullStages = stagesToUpdate.filter(({ stageId }) => stageId === null); + + if (nullStages.length > 0) { + await autoRevalidate( + trx.deleteFrom("PubsInStages").where( + "pubId", + "in", + nullStages.map(({ pubId }) => pubId) + ) + ).execute(); + } + + const nonNullStages = stagesToUpdate.filter(({ stageId }) => stageId !== null); + + if (nonNullStages.length > 0) { + await autoRevalidate( + trx + .with("deletedStages", (db) => + db + .deleteFrom("PubsInStages") + .where((eb) => + eb.or( + nonNullStages.map((stageOp) => eb("pubId", "=", stageOp.pubId)) + ) + ) + ) + .insertInto("PubsInStages") + .values( + nonNullStages.map((stageOp) => ({ + pubId: stageOp.pubId, + stageId: stageOp.stageId, + })) + ) + ).execute(); + } + } +} + +interface UpdateOnlyOps { + unset(slug: string): this; + unrelate(slug: string, target: PubsId, options?: { deleteOrphaned?: boolean }): this; +} + +// Implementation classes - these are not exported +class CreatePubOp extends BasePubOp { + private readonly initialId?: PubsId; + + constructor(options: PubOpOptions, initialId?: PubsId) { + super({ + ...options, + id: initialId, + }); + this.initialId = initialId; + } + + protected getMode(): OperationMode { + return "create"; + } + + protected getInitialId(): PubsId | undefined { + return this.initialId; + } +} + +class UpsertPubOp extends BasePubOp { + private readonly initialId?: PubsId; + private readonly initialSlug?: string; + private readonly initialValue?: PubValue; + + constructor( + options: PubOpOptionsCreateUpsert, + initialId?: PubsId, + initialSlug?: string, + initialValue?: PubValue + ) { + super({ ...options, id: initialId }); + this.initialId = initialId; + this.initialSlug = initialSlug; + this.initialValue = initialValue; + } + + protected getMode(): OperationMode { + return "upsert"; + } + + protected getInitialId(): PubsId | undefined { + return this.initialId; + } +} + +class UpdatePubOp extends BasePubOp implements UpdateOnlyOps { + private readonly pubId: PubsId | undefined; + private readonly initialSlug?: string; + private readonly initialValue?: PubValue; + + constructor( + options: PubOpOptionsUpdate, + id: PubsId | undefined, + initialSlug?: string, + initialValue?: PubValue + ) { + super({ + ...options, + id, + }); + this.pubId = id; + this.initialSlug = initialSlug; + this.initialValue = initialValue; + } + + protected getMode(): OperationMode { + return "update"; + } + + /** + * Delete a value from the pub + * + * Will behave similarly to `unrelate('all')` if used for relations, except without the option to delete orphaned pubs + */ + unset(slug: string): this { + this.commands.push({ + type: "unset", + slug, + }); + return this; + } + + /** + * Disconnect all relations by passing `*` as the slug + * + * `deleteOrphaned: true` will delete the pubs that are now orphaned as a result of the disconnect. + */ + unrelate(slug: "*", options?: { deleteOrphaned?: boolean }): this; + /** + * Disconnect a specific relation + * + * If you pass `*` as the target, all relations for that field will be removed + * + * If you pass a pubId as the target, only that relation will be removed + * + * `deleteOrphaned: true` will delete the pubs that are now orphaned as a result of the disconnect. + */ + unrelate(slug: string, target: PubsId | "*", options?: { deleteOrphaned?: boolean }): this; + unrelate( + slug: string, + optionsOrTarget?: PubsId | "*" | { deleteOrphaned?: boolean }, + options?: { deleteOrphaned?: boolean } + ): this { + this.commands.push({ + type: "unrelate", + slug, + target: typeof optionsOrTarget === "string" ? optionsOrTarget : "*", + options: typeof optionsOrTarget === "string" ? options : optionsOrTarget, + }); + return this; + } + + protected getInitialId(): PubsId | undefined { + return this.pubId; + } +} + +/** + * A PubOp is a builder for a pub. + * + * It can be used to create, update or upsert a pub. + */ +export class PubOp { + /** + * Create a new pub + */ + static create(options: PubOpOptions): CreatePubOp { + return new CreatePubOp(options); + } + + /** + * Create a new pub with a specific id + */ + static createWithId(id: PubsId, options: PubOpOptions): CreatePubOp { + return new CreatePubOp(options, id); + } + + /** + * Update an existing pub + */ + static update(id: PubsId, options: PubOpOptionsUpdate): UpdatePubOp { + return new UpdatePubOp(options, id); + } + + /** + * Update an existing pub by a specific value + */ + static updateByValue( + slug: string, + value: PubValue, + options: Omit + ): UpdatePubOp { + return new UpdatePubOp(options, undefined, slug, value); + } + + /** + * Upsert a pub + * + * Either create a new pub, or override an existing pub + */ + static upsert(id: PubsId, options: PubOpOptionsCreateUpsert): UpsertPubOp { + return new UpsertPubOp(options, id); + } + + /** + * Upsert a pub by a specific, presumed to be unique, value + * + * Eg you want to upsert a pub by a google drive id, you would do + * ```ts + * PubOp.upsertByValue("community-slug:googleDriveId", googleDriveId, options) + * ``` + */ + static upsertByValue( + slug: string, + value: PubValue, + options: PubOpOptionsCreateUpsert + ): UpsertPubOp { + return new UpsertPubOp(options, undefined, slug, value); + } +} + +type ActivePubOp = CreatePubOp | UpdatePubOp | UpsertPubOp; diff --git a/core/lib/server/pub-trigger.db.test.ts b/core/lib/server/pub-trigger.db.test.ts index 45ce3eb87b..665b70376f 100644 --- a/core/lib/server/pub-trigger.db.test.ts +++ b/core/lib/server/pub-trigger.db.test.ts @@ -22,7 +22,7 @@ const { testDb, createForEachMockedTransaction, createSingleMockedTransaction } await mockServerCode(); const { getTrx, rollback, commit } = createForEachMockedTransaction(testDb); -const { createSeed } = await import("~/prisma/seed/seedCommunity"); +const { createSeed } = await import("~/prisma/seed/createSeed"); const pubTriggerTestSeed = createSeed({ community: { diff --git a/core/lib/server/pub.db.test.ts b/core/lib/server/pub.db.test.ts index a72dde2fc5..73d960fef1 100644 --- a/core/lib/server/pub.db.test.ts +++ b/core/lib/server/pub.db.test.ts @@ -6,10 +6,9 @@ import { CoreSchemaType, MemberRole } from "db/public"; import type { UnprocessedPub } from "./pub"; import { mockServerCode } from "~/lib/__tests__/utils"; +import { createSeed } from "~/prisma/seed/createSeed"; import { createLastModifiedBy } from "../lastModifiedBy"; -const { createSeed } = await import("~/prisma/seed/seedCommunity"); - const { createForEachMockedTransaction } = await mockServerCode(); const { getTrx } = createForEachMockedTransaction(); @@ -647,45 +646,44 @@ describe("getPubsWithRelatedValuesAndChildren", () => { pubWithRelatedValuesAndChildren.children[0].values.sort((a, b) => a.fieldSlug.localeCompare(b.fieldSlug) ); - expect(pubWithRelatedValuesAndChildren).toMatchObject({ - values: [ - { - value: "test relation value", - relatedPub: { - values: [{ value: "Nested Related Pub" }], - children: [{ values: [{ value: "Nested Child of Nested Related Pub" }] }], - }, + expect(pubWithRelatedValuesAndChildren).toHaveValues([ + { value: "Some title" }, + { + value: "test relation value", + relatedPub: { + values: [{ value: "Nested Related Pub" }], + children: [{ values: [{ value: "Nested Child of Nested Related Pub" }] }], }, - { value: "Some title" }, - ], - children: [ - { + }, + ]); + + expect(pubWithRelatedValuesAndChildren.children).toHaveLength(1); + expect(pubWithRelatedValuesAndChildren.children[0]).toHaveValues([ + { value: "Child of Root Pub" }, + { + value: "Nested Relation", + relatedPub: { values: [ { - value: "Nested Relation 2", - }, - { - value: "Nested Relation", + value: "Double nested relation", relatedPub: { - values: [ - { - value: "Nested Related Pub of Child of Root Pub", - }, - { - value: "Double nested relation", - relatedPub: { - values: [{ value: "Double nested relation title" }], - }, - }, - ], + values: [{ value: "Double nested relation title" }], }, }, - { value: "Child of Root Pub" }, + { + value: "Nested Related Pub of Child of Root Pub", + }, ], - children: [{ values: [{ value: "Grandchild of Root Pub" }] }], }, - ], - }); + }, + { + value: "Nested Relation 2", + }, + ]); + expect(pubWithRelatedValuesAndChildren.children[0].children).toHaveLength(1); + expect(pubWithRelatedValuesAndChildren.children[0].children[0]).toHaveValues([ + { value: "Grandchild of Root Pub" }, + ]); }); it("should be able to filter by pubtype or stage and pubtype and stage", async () => { @@ -904,21 +902,19 @@ describe("getPubsWithRelatedValuesAndChildren", () => { { depth: 10, fieldSlugs: [pubFields.Title.slug, pubFields["Some relation"].slug] } )) as unknown as UnprocessedPub[]; - expect(pubWithRelatedValuesAndChildren).toMatchObject({ - values: [ - { value: "test title" }, - { - value: "test relation value", - relatedPub: { - values: [ - { - value: "test relation title", - }, - ], - }, + expect(pubWithRelatedValuesAndChildren).toHaveValues([ + { + value: "test relation value", + relatedPub: { + values: [ + { + value: "test relation title", + }, + ], }, - ], - }); + }, + { value: "test title" }, + ]); }); it("is able to exclude children and related pubs from being fetched", async () => { @@ -965,14 +961,12 @@ describe("getPubsWithRelatedValuesAndChildren", () => { expectTypeOf(pubWithRelatedValuesAndChildren.children).toEqualTypeOf(); expect(pubWithRelatedValuesAndChildren.children).toEqual(undefined); - expect(pubWithRelatedValuesAndChildren).toMatchObject({ - values: [ - { value: "test title" }, - { - value: "test relation value", - }, - ], - }); + expect(pubWithRelatedValuesAndChildren).toHaveValues([ + { + value: "test relation value", + }, + { value: "test title" }, + ]); expect(pubWithRelatedValuesAndChildren.values[1].relatedPub).toBeUndefined(); // check that the relatedPub is `undefined` in type as well as value due to `{withRelatedPubs: false}` @@ -1032,6 +1026,7 @@ describe("getPubsWithRelatedValuesAndChildren", () => { { withMembers: true } ); + pub.members.sort((a, b) => a.slug.localeCompare(b.slug)); expect(pub).toMatchObject({ members: newUsers.map((u) => ({ ...u, role: MemberRole.admin })), }); diff --git a/core/lib/server/pub.fts.db.test.ts b/core/lib/server/pub.fts.db.test.ts index 33a4a522a6..cb8e0403eb 100644 --- a/core/lib/server/pub.fts.db.test.ts +++ b/core/lib/server/pub.fts.db.test.ts @@ -2,14 +2,15 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { CoreSchemaType, MemberRole } from "db/public"; -import type { Seed } from "~/prisma/seed/seedCommunity"; +import type { Seed } from "~/prisma/seed/createSeed"; import { mockServerCode } from "~/lib/__tests__/utils"; +import { createSeed } from "~/prisma/seed/createSeed"; const { createForEachMockedTransaction, testDb } = await mockServerCode(); const { getTrx } = createForEachMockedTransaction(); -const communitySeed = { +const communitySeed = createSeed({ community: { name: "test", slug: "test-server-pub", @@ -74,11 +75,15 @@ const communitySeed = { }, }, ], -} as Seed; +}); -const seed = async (trx = testDb, seed?: Seed) => { +const seed = async (trx = testDb, seed?: T) => { const { seedCommunity } = await import("~/prisma/seed/seedCommunity"); - const seeded = await seedCommunity(seed ?? { ...communitySeed }, undefined, trx); + if (!seed) { + return seedCommunity(communitySeed, undefined, trx); + } + + const seeded = await seedCommunity(seed, undefined, trx); return seeded; }; diff --git a/core/lib/server/pub.sort.db.test.ts b/core/lib/server/pub.sort.db.test.ts index d85c6c9610..2be366b046 100644 --- a/core/lib/server/pub.sort.db.test.ts +++ b/core/lib/server/pub.sort.db.test.ts @@ -6,10 +6,9 @@ import type { PubsId } from "db/public"; import { CoreSchemaType } from "db/public"; import { mockServerCode } from "~/lib/__tests__/utils"; +import { createSeed } from "~/prisma/seed/createSeed"; import { createLastModifiedBy } from "../lastModifiedBy"; -const { createSeed } = await import("~/prisma/seed/seedCommunity"); - const { testDb } = await mockServerCode(); const seed = createSeed({ diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index 5334c31d98..3e87c05a2f 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -421,7 +421,7 @@ export const doesPubExist = async ( /** * For recursive transactions */ -const maybeWithTrx = async ( +export const maybeWithTrx = async ( trx: Transaction | Kysely, fn: (trx: Transaction) => Promise ): Promise => { @@ -652,36 +652,82 @@ export const createPubRecursiveNew = async { - // first get the values before they are deleted - const pubValues = await trx - .selectFrom("pub_values") - .where("pubId", "=", pubId) - .selectAll() - .execute(); - - const deleteResult = await autoRevalidate( - trx.deleteFrom("pubs").where("id", "=", pubId) - ).executeTakeFirstOrThrow(); - - // this might not be necessary if we rarely delete pubs and - // give users ample warning that deletion is irreversible - // in that case we should probably also delete the relevant rows in the pub_values_history table - await addDeletePubValueHistoryEntries({ - lastModifiedBy, - pubValues, - trx, + if (valueIds.length === 0) { + return; + } + + const result = await maybeWithTrx(trx, async (trx) => { + const deletedPubValues = await autoRevalidate( + trx + .deleteFrom("pub_values") + .where("id", "in", valueIds) + .where("pubId", "=", pubId) + .returningAll() + ).execute(); + + await addDeletePubValueHistoryEntries({ + lastModifiedBy, + pubValues: deletedPubValues, + trx, + }); + + return deletedPubValues; + }); + + return result; +}; + +export const deletePub = async ({ + pubId, + lastModifiedBy, + communityId, + trx = db, +}: { + pubId: PubsId | PubsId[]; + lastModifiedBy: LastModifiedBy; + communityId: CommunitiesId; + trx?: typeof db; +}) => { + const result = await maybeWithTrx(trx, async (trx) => { + // first get the values before they are deleted + // that way we can add them to the history table + const pubValues = await trx + .selectFrom("pub_values") + .where("pubId", "in", Array.isArray(pubId) ? pubId : [pubId]) + .selectAll() + .execute(); + + const deleteResult = await autoRevalidate( + trx + .deleteFrom("pubs") + .where("id", "in", Array.isArray(pubId) ? pubId : [pubId]) + .where("communityId", "=", communityId) + ).executeTakeFirstOrThrow(); + + // this might not be necessary if we rarely delete pubs and + // give users ample warning that deletion is irreversible + // in that case we should probably also delete the relevant rows in the pub_values_history table + await addDeletePubValueHistoryEntries({ + lastModifiedBy, + pubValues, + trx, + }); + + return deleteResult; }); - return deleteResult; + return result; }; export const getPubStage = (pubId: PubsId, trx = db) => @@ -698,10 +744,12 @@ const getFieldInfoForSlugs = async ({ slugs, communityId, includeRelations = true, + trx = db, }: { slugs: string[]; communityId: CommunitiesId; includeRelations?: boolean; + trx?: typeof db; }) => { const toBeUpdatedPubFieldSlugs = Array.from(new Set(slugs)); @@ -713,6 +761,7 @@ const getFieldInfoForSlugs = async ({ communityId, slugs: toBeUpdatedPubFieldSlugs, includeRelations, + trx, }).executeTakeFirstOrThrow(); const pubFields = Object.values(fields); @@ -746,21 +795,24 @@ const getFieldInfoForSlugs = async ({ })); }; -const validatePubValues = async ({ +export const validatePubValues = async ({ pubValues, communityId, continueOnValidationError = false, includeRelations = true, + trx = db, }: { pubValues: T[]; communityId: CommunitiesId; continueOnValidationError?: boolean; includeRelations?: boolean; + trx?: typeof db; }) => { const relevantPubFields = await getFieldInfoForSlugs({ slugs: pubValues.map(({ slug }) => slug), communityId, includeRelations, + trx, }); const mergedPubFields = mergeSlugsWithFields(pubValues, relevantPubFields); @@ -886,30 +938,12 @@ export const upsertPubRelations = async ( fieldId: PubFieldsId; }[]; - const pubRelations = await autoRevalidate( - trx - .insertInto("pub_values") - .values( - allRelationsToCreate.map(({ relatedPubId, value, slug, fieldId }) => ({ - pubId, - relatedPubId, - value: JSON.stringify(value), - fieldId, - lastModifiedBy, - })) - ) - .onConflict((oc) => - oc - .columns(["pubId", "fieldId", "relatedPubId"]) - .where("relatedPubId", "is not", null) - // upsert - .doUpdateSet((eb) => ({ - value: eb.ref("excluded.value"), - lastModifiedBy: eb.ref("excluded.lastModifiedBy"), - })) - ) - .returningAll() - ).execute(); + const pubRelations = await upsertPubRelationValues({ + pubId, + allRelationsToCreate, + lastModifiedBy, + trx, + }); const createdRelations = pubRelations.map((relation) => { const correspondingValue = validatedRelationValues.find( @@ -1156,29 +1190,12 @@ export const updatePub = async ({ } if (pubValuesWithoutRelations.length) { - const result = await autoRevalidate( - trx - .insertInto("pub_values") - .values( - pubValuesWithoutRelations.map(({ value, fieldId }) => ({ - pubId, - fieldId, - value: JSON.stringify(value), - lastModifiedBy, - })) - ) - .onConflict((oc) => - oc - // we have a unique index on pubId and fieldId where relatedPubId is null - .columns(["pubId", "fieldId"]) - .where("relatedPubId", "is", null) - .doUpdateSet((eb) => ({ - value: eb.ref("excluded.value"), - lastModifiedBy: eb.ref("excluded.lastModifiedBy"), - })) - ) - .returningAll() - ).execute(); + const result = await upsertPubValues({ + pubId, + pubValues: pubValuesWithoutRelations, + lastModifiedBy, + trx, + }); return result; } @@ -1186,6 +1203,102 @@ export const updatePub = async ({ return result; }; + +export const upsertPubValues = async ({ + pubId, + pubValues, + lastModifiedBy, + trx, +}: { + pubId: PubsId; + pubValues: { + /** + * specify this if you do not want to use the pubId provided in the input + */ + pubId?: PubsId; + fieldId: PubFieldsId; + relatedPubId?: PubsId; + value: unknown; + }[]; + lastModifiedBy: LastModifiedBy; + trx: typeof db; +}): Promise => { + if (!pubValues.length) { + return []; + } + + return autoRevalidate( + trx + .insertInto("pub_values") + .values( + pubValues.map((value) => ({ + pubId: value.pubId ?? pubId, + fieldId: value.fieldId, + value: JSON.stringify(value.value), + lastModifiedBy, + relatedPubId: value.relatedPubId, + })) + ) + .onConflict((oc) => + oc + // we have a unique index on pubId and fieldId where relatedPubId is null + .columns(["pubId", "fieldId"]) + .where("relatedPubId", "is", null) + .doUpdateSet((eb) => ({ + value: eb.ref("excluded.value"), + lastModifiedBy: eb.ref("excluded.lastModifiedBy"), + })) + ) + .returningAll() + ).execute(); +}; + +export const upsertPubRelationValues = async ({ + pubId, + allRelationsToCreate, + lastModifiedBy, + trx, +}: { + pubId: PubsId; + allRelationsToCreate: { + pubId?: PubsId; + relatedPubId: PubsId; + value: unknown; + fieldId: PubFieldsId; + }[]; + lastModifiedBy: LastModifiedBy; + trx: typeof db; +}): Promise => { + if (!allRelationsToCreate.length) { + return []; + } + + return autoRevalidate( + trx + .insertInto("pub_values") + .values( + allRelationsToCreate.map((value) => ({ + pubId: value.pubId ?? pubId, + relatedPubId: value.relatedPubId, + value: JSON.stringify(value.value), + fieldId: value.fieldId, + lastModifiedBy, + })) + ) + .onConflict((oc) => + oc + .columns(["pubId", "fieldId", "relatedPubId"]) + .where("relatedPubId", "is not", null) + // upsert + .doUpdateSet((eb) => ({ + value: eb.ref("excluded.value"), + lastModifiedBy: eb.ref("excluded.lastModifiedBy"), + })) + ) + .returningAll() + ).execute(); +}; + export type UnprocessedPub = { id: PubsId; depth: number; diff --git a/core/lib/server/pubFields.ts b/core/lib/server/pubFields.ts index 7eb7efd449..8688a4a7ff 100644 --- a/core/lib/server/pubFields.ts +++ b/core/lib/server/pubFields.ts @@ -14,6 +14,7 @@ type GetPubFieldsInput = communityId: CommunitiesId; includeRelations?: boolean; slugs?: string[]; + trx?: typeof db; } | { pubId: PubsId; @@ -21,6 +22,7 @@ type GetPubFieldsInput = communityId: CommunitiesId; includeRelations?: boolean; slugs?: string[]; + trx?: typeof db; } | { pubId?: never; @@ -28,6 +30,7 @@ type GetPubFieldsInput = communityId: CommunitiesId; includeRelations?: boolean; slugs?: string[]; + trx?: typeof db; }; /** @@ -42,7 +45,7 @@ type GetPubFieldsInput = export const getPubFields = (props: GetPubFieldsInput) => autoCache(_getPubFields(props)); export const _getPubFields = (props: GetPubFieldsInput) => - db + (props.trx ?? db) .with("ids", (eb) => eb .selectFrom("pub_fields") diff --git a/core/lib/server/vitest.d.ts b/core/lib/server/vitest.d.ts new file mode 100644 index 0000000000..c5d15c9133 --- /dev/null +++ b/core/lib/server/vitest.d.ts @@ -0,0 +1,14 @@ +import "vitest"; + +import type { ProcessedPub } from "./pub"; +import type { db } from "~/kysely/database"; + +interface CustomMatchers { + toHaveValues(expected: Partial[]): R; + toExist(expected?: typeof db): Promise; +} + +declare module "vitest" { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} diff --git a/core/prisma/seed/createSeed.ts b/core/prisma/seed/createSeed.ts new file mode 100644 index 0000000000..a98ca13cf2 --- /dev/null +++ b/core/prisma/seed/createSeed.ts @@ -0,0 +1,55 @@ +import type { CommunitiesId } from "db/public"; + +import type { + FormInitializer, + PubFieldsInitializer, + PubInitializer, + PubTypeInitializer, + seedCommunity, + StageConnectionsInitializer, + StagesInitializer, + UsersInitializer, +} from "./seedCommunity"; + +/** + * Convenience method in case you want to define the input of `seedCommunity` before actually calling it + */ +export const createSeed = < + const PF extends PubFieldsInitializer, + const PT extends PubTypeInitializer, + const U extends UsersInitializer, + const S extends StagesInitializer, + const SC extends StageConnectionsInitializer, + const PI extends PubInitializer[], + const F extends FormInitializer, +>(props: { + community: { + id?: CommunitiesId; + name: string; + slug: string; + avatar?: string; + }; + pubFields?: PF; + pubTypes?: PT; + users?: U; + stages?: S; + stageConnections?: SC; + pubs?: PI; + forms?: F; +}) => props; + +export type Seed = Parameters[0]; + +export type CommunitySeedOutput> = Awaited< + ReturnType< + typeof seedCommunity< + NonNullable, + NonNullable, + NonNullable, + NonNullable, + NonNullable, + NonNullable, + NonNullable + > + > +>; diff --git a/core/prisma/seed/seedCommunity.ts b/core/prisma/seed/seedCommunity.ts index fb26048dfe..c9eb76024f 100644 --- a/core/prisma/seed/seedCommunity.ts +++ b/core/prisma/seed/seedCommunity.ts @@ -55,7 +55,7 @@ export type PubFieldsInitializer = Record< } >; -type PubTypeInitializer = Record< +export type PubTypeInitializer = Record< string, Partial> >; @@ -65,7 +65,7 @@ type PubTypeInitializer = Record< * except the `role`, which will be set to `MemberRole.editor` by default. * Set to `null` if you don't want to add the user as a member */ -type UsersInitializer = Record< +export type UsersInitializer = Record< string, { /** @@ -82,7 +82,7 @@ type UsersInitializer = Record< } >; -type ActionInstanceInitializer = { +export type ActionInstanceInitializer = { [K in ActionName]: { /** * @default randomUUID @@ -97,7 +97,7 @@ type ActionInstanceInitializer = { /** * Map of stagename to list of permissions */ -type StagesInitializer = Record< +export type StagesInitializer = Record< string, { id?: StagesId; @@ -108,7 +108,7 @@ type StagesInitializer = Record< } >; -type StageConnectionsInitializer> = Partial< +export type StageConnectionsInitializer> = Partial< Record< keyof S, { @@ -118,7 +118,7 @@ type StageConnectionsInitializer> = Partial< > >; -type PubInitializer< +export type PubInitializer< PF extends PubFieldsInitializer, PT extends PubTypeInitializer, U extends UsersInitializer, @@ -229,7 +229,7 @@ type PubInitializer< }; }[keyof PT & string]; -type FormElementInitializer< +export type FormElementInitializer< PF extends PubFieldsInitializer, PT extends PubTypeInitializer, PubType extends keyof PT, @@ -251,7 +251,7 @@ type FormElementInitializer< }[keyof PubFieldsForPubType] : never; -type FormInitializer< +export type FormInitializer< PF extends PubFieldsInitializer, PT extends PubTypeInitializer, U extends UsersInitializer, @@ -1169,32 +1169,3 @@ export async function seedCommunity< : undefined, }; } - -/** - * Convenience method in case you want to define the input of `seedCommunity` before actually calling it - */ -export const createSeed = < - const PF extends PubFieldsInitializer, - const PT extends PubTypeInitializer, - const U extends UsersInitializer, - const S extends StagesInitializer, - const SC extends StageConnectionsInitializer, - const PI extends PubInitializer[], - const F extends FormInitializer, ->(props: { - community: { - id?: CommunitiesId; - name: string; - slug: string; - avatar?: string; - }; - pubFields?: PF; - pubTypes?: PT; - users?: U; - stages?: S; - stageConnections?: SC; - pubs?: PI; - forms?: F; -}) => props; - -export type Seed = Parameters[0]; diff --git a/core/vitest.config.mts b/core/vitest.config.mts index 359fccdec3..84dbee5437 100644 --- a/core/vitest.config.mts +++ b/core/vitest.config.mts @@ -5,7 +5,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react(), tsconfigPaths()], test: { - globalSetup: ["./globalSetup.ts"], + globalSetup: ["./lib/__tests__/globalSetup.ts"], + setupFiles: ["./lib/__tests__/matchers.ts"], environment: "jsdom", environmentMatchGlobs: [ ["**/(!db).test.ts", "jsdom"], diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 4f2172223c..88888ec2c7 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -6,6 +6,7 @@ "module": "dist/schemas.esm.js", "exports": { ".": "./dist/schemas.js", + "./formats": "./dist/schemas-formats.js", "./schemas": "./dist/schemas-schemas.js", "./package.json": "./package.json" }, @@ -31,7 +32,8 @@ "preconstruct": { "entrypoints": [ "index.ts", - "schemas.ts" + "schemas.ts", + "formats.ts" ], "exports": true, "___experimentalFlags_WILL_CHANGE_IN_PATCH": { diff --git a/packages/utils/package.json b/packages/utils/package.json index c838a13d6b..267e9464f8 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -5,7 +5,13 @@ "main": "dist/utils.cjs.js", "module": "dist/utils.esm.js", "exports": { + "./doi": "./dist/utils-doi.js", + "./url": "./dist/utils-url.js", + "./uuid": "./dist/utils-uuid.js", ".": "./dist/utils.js", + "./sleep": "./dist/utils-sleep.js", + "./assert": "./dist/utils-assert.js", + "./classnames": "./dist/utils-classnames.js", "./package.json": "./package.json" }, "scripts": { @@ -24,6 +30,15 @@ }, "prettier": "@pubpub/prettier-config", "preconstruct": { + "entrypoints": [ + "index.ts", + "assert.ts", + "classnames.ts", + "url.ts", + "doi.ts", + "sleep.ts", + "uuid.ts" + ], "exports": true, "___experimentalFlags_WILL_CHANGE_IN_PATCH": { "typeModule": true, diff --git a/packages/utils/src/uuid.ts b/packages/utils/src/uuid.ts new file mode 100644 index 0000000000..87cf4e1ea0 --- /dev/null +++ b/packages/utils/src/uuid.ts @@ -0,0 +1,5 @@ +export const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +export const isUuid = (value: string) => { + return uuidRegex.test(value); +};