From 934f7cc67fc6cb75a40dd4c38881e63e55b68321 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 5 Feb 2025 19:39:10 +0100 Subject: [PATCH 01/23] feat: new fluent pub mutation api --- core/{ => lib/__tests__}/globalSetup.ts | 5 +- core/lib/__tests__/matchers.ts | 67 ++++ core/lib/server/pub-op.db.test.ts | 400 ++++++++++++++++++++++++ core/lib/server/pub-op.ts | 398 +++++++++++++++++++++++ core/lib/server/pub.ts | 10 +- core/lib/server/pubFields.ts | 5 +- core/lib/server/vitest.d.ts | 14 + core/vitest.config.mts | 3 +- packages/schemas/package.json | 4 +- packages/utils/package.json | 15 + packages/utils/src/uuid.ts | 5 + 11 files changed, 920 insertions(+), 6 deletions(-) rename core/{ => lib/__tests__}/globalSetup.ts (83%) create mode 100644 core/lib/__tests__/matchers.ts create mode 100644 core/lib/server/pub-op.db.test.ts create mode 100644 core/lib/server/pub-op.ts create mode 100644 core/lib/server/vitest.d.ts create mode 100644 packages/utils/src/uuid.ts 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 3ef4b9cb6..478276a90 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__/matchers.ts b/core/lib/__tests__/matchers.ts new file mode 100644 index 000000000..715b7538d --- /dev/null +++ b/core/lib/__tests__/matchers.ts @@ -0,0 +1,67 @@ +import { expect } from "vitest"; + +import type { ProcessedPub } from "contracts"; +import type { PubsId } from "db/public"; + +import type { db } from "~/kysely/database"; + +expect.extend({ + async toExist(received: PubsId | ProcessedPub, expected?: typeof db) { + if (typeof received !== "string") { + throw new Error("toExist() can only be called with a PubsId"); + } + 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: () => + pass + ? `Expected pub with ID ${received} ${isNot ? "not" : ""} to exist, and it does ${isNot ? "not" : ""}` + : `Expected pub with ID ${received} ${isNot ? "not to" : "to"} exist, but it does not`, + }; + }, + + toHaveValues( + received: PubsId | ProcessedPub, + expected: Partial[] + ) { + if (typeof received === "string") { + throw new Error("toHaveValues() can only be called with a ProcessedPub"); + } + + const pub = received; + const sortedPubValues = [...pub.values].sort((a, b) => + (a.value as string).localeCompare(b.value as string) + ); + + const expectedLength = expected.length; + const receivedLength = sortedPubValues.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, 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, expected)}`, + }; + }, +}); 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 000000000..7e6de0812 --- /dev/null +++ b/core/lib/server/pub-op.db.test.ts @@ -0,0 +1,400 @@ +import { describe, expect, expectTypeOf, it, vitest } from "vitest"; + +import type { PubsId, PubTypes, Stages } from "db/public"; +import { CoreSchemaType, MemberRole } from "db/public"; + +import { mockServerCode } from "~/lib/__tests__/utils"; +import { createLastModifiedBy } from "../lastModifiedBy"; +import { PubOp } from "./pub-op"; + +const { createSeed, seedCommunity } = await import("~/prisma/seed/seedCommunity"); + +const { createForEachMockedTransaction } = await mockServerCode(); + +const { getTrx } = 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, + }, + }, + }, + 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", + }, + }, + ], +}); + +const { community, pubFields, pubTypes, stages, pubs, users } = await seedCommunity(seed); + +describe("PubOp", () => { + it("should create a new pub", async () => { + const trx = getTrx(); + const id = crypto.randomUUID() as PubsId; + const pubOp = PubOp.upsert(id, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }); + + const pub = await pubOp.execute(); + await expect(pub.id).toExist(trx); + }); + + it("should not fail when upserting existing pub", async () => { + const trx = getTrx(); + const id = crypto.randomUUID() as PubsId; + const pubOp = PubOp.upsert(id, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }); + + const pub = await pubOp.execute(); + await expect(pub.id).toExist(trx); + + const pub2 = await PubOp.upsert(id, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).execute(); + + await expect(pub2.id).toExist(trx); + }); + + it("should create a new pub and set values", async () => { + const id = crypto.randomUUID() as PubsId; + const pubOp = PubOp.upsert(id, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .set(pubFields["Title"].slug, "Some title") + .set({ + [pubFields["Description"].slug]: "Some description", + }); + + const pub = await pubOp.execute(); + await expect(pub.id).toExist(); + + expect(pub).toHaveValues([ + { + fieldSlug: pubFields["Description"].slug, + value: "Some description", + }, + { + fieldSlug: pubFields["Title"].slug, + value: "Some title", + }, + ]); + }); + + it("should be able to relate existing pubs", async () => { + const trx = getTrx(); + const pubOp = PubOp.upsert(crypto.randomUUID() as PubsId, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }); + + const pub = await pubOp.execute(); + await expect(pub.id).toExist(trx); + + const pub2 = await PubOp.upsert(crypto.randomUUID() as PubsId, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .relate(pubFields["Some relation"].slug, "test relations value", pubOp) + .execute(); + + await expect(pub2.id).toExist(trx); + expect(pub2).toHaveValues([ + { + fieldSlug: pubFields["Some relation"].slug, + value: "test relations value", + relatedPubId: pub.id, + }, + ]); + }); + + it("should create multiple related pubs in a single operation", async () => { + const trx = getTrx(); + const mainPub = PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Main Pub") + .relate( + pubFields["Some relation"].slug, + "the first related pub", + PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(pubFields["Title"].slug, "Related Pub 1") + ) + .relate( + pubFields["Another relation"].slug, + "the second related pub", + PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(pubFields["Title"].slug, "Related Pub 2") + ); + + const result = await mainPub.execute(); + + expect(result).toHaveValues([ + { fieldSlug: pubFields["Title"].slug, value: "Main Pub" }, + { + fieldSlug: pubFields["Some relation"].slug, + value: "the first related pub", + relatedPubId: expect.any(String), + }, + { + fieldSlug: pubFields["Another relation"].slug, + value: "the second related pub", + relatedPubId: expect.any(String), + }, + ]); + }); + + it("should handle deeply nested relations", async () => { + const trx = getTrx(); + const relatedPub = PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Level 1") + .relate( + pubFields["Another relation"].slug, + "the second related pub", + PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(pubFields["Title"].slug, "Level 2") + ); + + const mainPub = PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Root") + .relate(pubFields["Some relation"].slug, "the first related pub", relatedPub); + + const result = await mainPub.execute(); + + expect(result).toHaveValues([ + { fieldSlug: pubFields["Title"].slug, value: "Root" }, + { + fieldSlug: pubFields["Some relation"].slug, + value: "the first related pub", + relatedPubId: expect.any(String), + relatedPub: { + values: [ + { + fieldSlug: pubFields["Title"].slug, + value: "Level 1", + }, + { + fieldSlug: pubFields["Another relation"].slug, + value: "the second related pub", + relatedPubId: expect.any(String), + }, + ], + }, + }, + ]); + }); + + it("should handle mixing existing and new pubs in relations", async () => { + const trx = getTrx(); + + // First create a pub that we'll relate to + const existingPub = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Existing Pub") + .execute(); + + const mainPub = PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Main Pub") + .relate(pubFields["Some relation"].slug, "the first related pub", existingPub.id) + .relate( + pubFields["Another relation"].slug, + "the second related pub", + PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(pubFields["Title"].slug, "New Related Pub") + ); + + const result = await mainPub.execute(); + + expect(result).toHaveValues([ + { fieldSlug: pubFields["Title"].slug, value: "Main Pub" }, + { + fieldSlug: pubFields["Some relation"].slug, + value: "the first related pub", + relatedPubId: existingPub.id, + relatedPub: { + id: existingPub.id, + values: [{ fieldSlug: pubFields["Title"].slug, value: "Existing Pub" }], + }, + }, + { + fieldSlug: pubFields["Another relation"].slug, + value: "the second related pub", + relatedPubId: expect.any(String), + relatedPub: { + values: [{ fieldSlug: pubFields["Title"].slug, value: "New Related Pub" }], + }, + }, + ]); + }); + + it("should handle circular relations", async () => { + const trx = getTrx(); + + const pub1 = PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(pubFields["Title"].slug, "Pub 1"); + + const pub2 = PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Pub 2") + .relate(pubFields["Some relation"].slug, "the first related pub", pub1); + + pub1.relate(pubFields["Another relation"].slug, "the second related pub", pub2); + + const result = await pub1.execute(); + + expect(result).toHaveValues([ + { fieldSlug: pubFields["Title"].slug, value: "Pub 1" }, + { + fieldSlug: pubFields["Another relation"].slug, + value: "the second related pub", + relatedPubId: expect.any(String), + relatedPub: { + values: [ + { fieldSlug: pubFields["Title"].slug, value: "Pub 2" }, + { + fieldSlug: 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 trx = getTrx(); + const pubOp = PubOp.createWithId(pubs[0].id, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }); + + await expect(pubOp.execute()).rejects.toThrow( + /Cannot create a pub with an id that already exists/ + ); + }); +}); diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts new file mode 100644 index 000000000..6924e95cd --- /dev/null +++ b/core/lib/server/pub-op.ts @@ -0,0 +1,398 @@ +import type { Transaction } from "kysely"; + +import { sql } from "kysely"; + +import type { JsonValue, ProcessedPub } from "contracts"; +import type { Database } from "db/Database"; +import type { CommunitiesId, CoreSchemaType, PubFieldsId, PubsId, PubTypesId } 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 { + getPubsWithRelatedValuesAndChildren, + maybeWithTrx, + upsertPubRelationValues, + upsertPubValues, + validatePubValues, +} from "./pub"; + +type PubValue = string | number | boolean | JsonValue; +type SetCommand = { type: "set"; slug: string; value: PubValue }; +type RelateCommand = { type: "relate"; slug: string; value: PubValue; target: PubOp | PubsId }; + +type PubOpCommand = SetCommand | RelateCommand; + +type PubOpOptions = { + communityId: CommunitiesId; + pubTypeId: PubTypesId; + lastModifiedBy: LastModifiedBy; + trx?: Transaction; +}; + +type OperationsMap = Map< + PubsId | symbol, + { + id?: PubsId; + mode: "create" | "upsert"; + values: Omit[]; + relations: (Omit & { target: PubsId | symbol })[]; + } +>; + +function isPubId(val: string | PubsId): val is PubsId { + return isUuid(val); +} + +export class PubOp { + readonly #options: PubOpOptions; + readonly #commands: PubOpCommand[] = []; + readonly #mode: "create" | "upsert" = "create"; + readonly #initialId?: PubsId; + readonly #initialslug?: string; + readonly #initialValue?: PubValue; + readonly #thisSymbol: symbol; + static #symbolCounter = 0; + + private constructor( + options: PubOpOptions, + mode: "create" | "upsert", + initialId?: PubsId, + initialslug?: string, + initialValue?: PubValue, + commands: PubOpCommand[] = [] + ) { + this.#options = options; + this.#mode = mode; + this.#initialId = initialId; + this.#initialslug = initialslug; + this.#initialValue = initialValue; + this.#commands = commands; + this.#thisSymbol = Symbol(`pub-${PubOp.#symbolCounter++}`); + } + + static createWithId(id: PubsId, options: PubOpOptions): PubOp { + return new PubOp(options, "create", id); + } + + static create(options: PubOpOptions): PubOp { + return new PubOp(options, "create"); + } + + static upsert(id: PubsId, options: PubOpOptions): PubOp; + static upsert(slug: string, value: PubValue, options: PubOpOptions): PubOp; + static upsert( + slugOrId: string | PubsId, + valueOrOptions: PubValue | PubOpOptions, + options?: PubOpOptions + ): PubOp { + if (isPubId(slugOrId)) { + return new PubOp(valueOrOptions as PubOpOptions, "upsert", slugOrId); + } + return new PubOp(options!, "upsert", undefined, slugOrId, valueOrOptions as PubValue); + } + + /** + * Add a single value to this pub + * The slug is the field slug of format `[communitySlug]:[fieldSlug]` + */ + set(slug: string, value: PubValue): this; + /** + * Add multiple values to this pub + * The keys are the field slugs of format `[communitySlug]:[fieldSlug]` + */ + set(values: Record): this; + set(slugOrValues: string | Record, value?: PubValue): this { + if (typeof slugOrValues === "string") { + this.#commands.push({ + type: "set", + slug: slugOrValues, + value: value!, + }); + } else { + this.#commands.push( + ...Object.entries(slugOrValues).map(([slug, value]) => ({ + type: "set" as const, + slug, + value, + })) + ); + } + return this; + } + + /** + * Relate this pub to another pub which you'll create/upsert in the same operation + */ + relate(slug: string, value: PubValue, target: PubOp): this; + /** + * Relate this pub to another pub with a known id + */ + relate(slug: string, value: PubValue, target: PubsId): this; + relate(slug: string, value: PubValue, target: PubOp | PubsId): this { + this.#commands.push({ type: "relate", slug, value, target }); + return this; + } + + private collectAllOperations(processed = new Set()): OperationsMap { + // If we've already processed this PubOp, return empty map to avoid circular recursion + if (processed.has(this.#thisSymbol)) { + return new Map(); + } + + const operations = new Map() as OperationsMap; + processed.add(this.#thisSymbol); + + operations.set(this.#initialId || this.#thisSymbol, { + id: this.#initialId, + mode: this.#mode, + values: [ + // if we use a value rather than the id as the unique identifier + ...(this.#initialslug + ? [{ slug: this.#initialslug, value: this.#initialValue! }] + : []), + ...this.#commands + .filter( + (cmd): cmd is Extract => cmd.type === "set" + ) + .map((cmd) => ({ + slug: cmd.slug, + value: cmd.value, + })), + ], + relations: [], + }); + + for (const cmd of this.#commands) { + if (cmd.type !== "relate") { + continue; + } + + const rootOp = operations.get(this.#initialId || this.#thisSymbol); + assert(rootOp, "Root operation not found"); + + if (!(cmd.target instanceof PubOp)) { + rootOp.relations.push({ + slug: cmd.slug, + value: cmd.value, + target: cmd.target, + }); + continue; + } + + rootOp.relations.push({ + slug: cmd.slug, + value: cmd.value, + target: cmd.target.#initialId || cmd.target.#thisSymbol, + }); + + // Only collect target operations if we haven't processed it yet + // to prevent infinite loops + if (!processed.has(cmd.target.#thisSymbol)) { + const targetOps = cmd.target.collectAllOperations(processed); + for (const [key, value] of targetOps) { + operations.set(key, value); + } + } + } + + return operations; + } + + private async executeWithTrx(trx: Transaction): Promise { + const operations = this.collectAllOperations(); + const idMap = new Map(); + + const pubsToCreate = [] as { + id: PubsId | undefined; + communityId: CommunitiesId; + pubTypeId: PubTypesId; + }[]; + + for (const [key, value] of operations) { + pubsToCreate.push({ + id: typeof key === "symbol" ? undefined : key, + communityId: this.#options.communityId, + pubTypeId: this.#options.pubTypeId, + }); + } + + // we create the necessary pubs + const pubCreateResult = await autoRevalidate( + trx + .insertInto("pubs") + .values(pubsToCreate) + .onConflict((oc) => oc.columns(["id"]).doNothing()) + .returningAll() + ).execute(); + + let index = 0; + /** + * this is somewhat convoluted, but basically: + * - onConflict().doNothing() does not return anything if it's triggered + * - therefore we need to fill in the "holes" in the pubCreateResult array + * in order to effectively loop over it by comparing it with the operations + */ + const pubCreateResultWithHolesFilled = pubsToCreate.map((pubToCreate) => { + const correspondingPubCreateResult = pubCreateResult[index]; + + if (pubToCreate.id && pubToCreate.id !== correspondingPubCreateResult?.id) { + return null; + } + + index++; + + if (correspondingPubCreateResult) { + return correspondingPubCreateResult; + } + + return null; + }); + + let idx = 0; + for (const [key, op] of operations) { + let result = pubCreateResultWithHolesFilled[idx]; + + if (result) { + idMap.set(key, result.id); + idx++; + continue; + } + + // we are upserting a pub, OR we are creating a pub with a specific id that has failed + const possiblyExistingPubId = pubsToCreate[idx]?.id; + + if (possiblyExistingPubId && op.mode === "create") { + throw new Error( + `Cannot create a pub with an id that already exists: ${possiblyExistingPubId}` + ); + } + + if (possiblyExistingPubId) { + idMap.set(key, possiblyExistingPubId); + idx++; + continue; + } + + if (typeof key === "symbol") { + throw new Error("Pub not created"); + } + idMap.set(key, key); + + idx++; + } + + const rootId = this.#initialId || idMap.get(this.#thisSymbol)!; + assert(rootId, "Root ID should exist"); + + const valuesToUpsert = [] as { + pubId: PubsId; + slug: string; + value: PubValue; + }[]; + + const relationValuesToUpsert = [] as { + pubId: PubsId; + slug: string; + value: PubValue; + relatedPubId: PubsId; + }[]; + + for (const [key, op] of operations) { + const pubId = typeof key === "symbol" ? idMap.get(key) : idMap.get(key); + + assert(pubId, "Pub ID is required"); + + if (op.values.length > 0) { + valuesToUpsert.push( + ...op.values.map((v) => ({ + pubId, + slug: v.slug, + value: v.value, + })) + ); + } + + if (op.relations.length > 0) { + relationValuesToUpsert.push( + ...op.relations.map((r) => ({ + pubId, + slug: r.slug, + value: r.value, + relatedPubId: expect( + typeof r.target === "string" ? r.target : idMap.get(r.target), + "Related pub ID should exist" + ), + })) + ); + } + } + + if (valuesToUpsert.length === 0 && relationValuesToUpsert.length === 0) { + return rootId; + } + + // kind of clunky, but why do it twice when we can do it once + const validatedPubAndRelationValues = await validatePubValues({ + pubValues: [...valuesToUpsert, ...relationValuesToUpsert], + communityId: this.#options.communityId, + continueOnValidationError: false, + trx, + }); + + const validatedPubValues = validatedPubAndRelationValues + .filter((v) => !("relatedPubId" in v) || !v.relatedPubId) + .map((v) => ({ + pubId: v.pubId, + fieldId: v.fieldId, + value: v.value, + lastModifiedBy: this.#options.lastModifiedBy, + })); + const validatedRelationValues = validatedPubAndRelationValues + .filter( + (v): v is typeof v & { 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, + })); + + await Promise.all([ + upsertPubValues({ + // this is a dummy pubId, we will use the ones on the validatedPubValues + pubId: "xxx" as PubsId, + pubValues: validatedPubValues, + lastModifiedBy: this.#options.lastModifiedBy, + trx, + }), + upsertPubRelationValues({ + // this is a dummy pubId, we will use the ones on the validatedPubRelationValues + pubId: "xxx" as PubsId, + allRelationsToCreate: validatedRelationValues, + lastModifiedBy: this.#options.lastModifiedBy, + trx, + }), + ]); + + return rootId; + } + + async execute(): Promise { + const { trx = db } = this.#options; + + const pubId = await maybeWithTrx(trx, async (trx) => { + return await this.executeWithTrx(trx); + }); + + return await getPubsWithRelatedValuesAndChildren( + { pubId, communityId: this.#options.communityId }, + { trx } + ); + } +} diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index 5334c31d9..71578c04d 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 => { @@ -698,10 +698,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 +715,7 @@ const getFieldInfoForSlugs = async ({ communityId, slugs: toBeUpdatedPubFieldSlugs, includeRelations, + trx, }).executeTakeFirstOrThrow(); const pubFields = Object.values(fields); @@ -746,21 +749,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); diff --git a/core/lib/server/pubFields.ts b/core/lib/server/pubFields.ts index 7eb7efd44..8688a4a7f 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 000000000..c5d15c913 --- /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/vitest.config.mts b/core/vitest.config.mts index 359fccdec..84dbee543 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 4f2172223..88888ec2c 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 c838a13d6..267e9464f 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 000000000..87cf4e1ea --- /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); +}; From 1969dfa3c91d0498b278fb5900dee3616f59662c Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 6 Feb 2025 16:40:06 +0100 Subject: [PATCH 02/23] feat: add ability to replace pub relations --- core/lib/server/pub-op.db.test.ts | 271 ++++++++++++++++++++++++++++++ core/lib/server/pub-op.ts | 265 +++++++++++++++++++++++++---- 2 files changed, 507 insertions(+), 29 deletions(-) diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index 7e6de0812..e5174e6d7 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -397,4 +397,275 @@ describe("PubOp", () => { /Cannot create a pub with an id that already exists/ ); }); + + it("should update the value of a relationship", async () => { + const trx = getTrx(); + const pub1 = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Pub 1") + .execute(); + + const pub2 = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Pub 2") + .relate(pubFields["Some relation"].slug, "initial value", pub1.id) + .execute(); + + const updatedPub = await PubOp.upsert(pub2.id, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .relate(pubFields["Some relation"].slug, "updated value", pub1.id) + .execute(); + + expect(updatedPub).toHaveValues([ + { fieldSlug: pubFields["Title"].slug, value: "Pub 2" }, + { + fieldSlug: pubFields["Some relation"].slug, + value: "updated value", + relatedPubId: pub1.id, + }, + ]); + }); +}); + +describe("relation management", () => { + it("should disconnect a specific relation", async () => { + const trx = getTrx(); + + // Create two pubs to relate + const pub1 = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Pub 1") + .execute(); + + const pub2 = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Pub 2") + .relate(pubFields["Some relation"].slug, "initial value", pub1.id) + .execute(); + + // Disconnect the relation + const updatedPub = await PubOp.upsert(pub2.id, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .disconnect(pubFields["Some relation"].slug, pub1.id) + .execute(); + + expect(updatedPub).toHaveValues([{ fieldSlug: pubFields["Title"].slug, value: "Pub 2" }]); + }); + + it("should delete orphaned pubs when disconnecting relations", async () => { + const trx = getTrx(); + + // Create a pub that will become orphaned + const orphanedPub = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Soon to be orphaned") + .execute(); + + // Create a pub that relates to it + const mainPub = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Main pub") + .relate(pubFields["Some relation"].slug, "only relation", orphanedPub.id) + .execute(); + + // Disconnect with deleteOrphaned option + await PubOp.upsert(mainPub.id, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .disconnect(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 trx = getTrx(); + + // Create multiple related pubs + const related1 = PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(pubFields["Title"].slug, "Related 1"); + + const related2 = PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(pubFields["Title"].slug, "Related 2"); + + // Create main pub with multiple relations + const mainPub = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Main pub") + .relate(pubFields["Some relation"].slug, "relation 1", related1) + .relate(pubFields["Some relation"].slug, "relation 2", related2) + .execute(); + + // Clear all relations for the field + const updatedPub = await PubOp.upsert(mainPub.id, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .clearRelations({ slug: pubFields["Some relation"].slug }) + .execute(); + + expect(updatedPub).toHaveValues([ + { fieldSlug: pubFields["Title"].slug, value: "Main pub" }, + ]); + }); + + it("should override existing relations when using override option", async () => { + const trx = getTrx(); + + // Create initial related pubs + const related1 = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(pubFields["Title"].slug, "Related 1"); + + const related2 = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }).set(pubFields["Title"].slug, "Related 2"); + + // Create main pub with initial relations + const mainPub = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Main pub") + .relate(pubFields["Some relation"].slug, "relation 1", related1) + .relate(pubFields["Some relation"].slug, "relation 2", related2) + .execute(); + + const related3 = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Related 3") + .execute(); + + // Update with override - only related3 should remain + const updatedPub = await PubOp.upsert(mainPub.id, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .relate(pubFields["Some relation"].slug, "new relation", related3.id, { + override: true, + }) + .execute(); + + expect(updatedPub).toHaveValues([ + { fieldSlug: pubFields["Title"].slug, value: "Main pub" }, + { + fieldSlug: pubFields["Some relation"].slug, + value: "new relation", + relatedPubId: related3.id, + }, + ]); + }); + + it("should handle multiple override relations for the same field", async () => { + const trx = getTrx(); + + // Create related pubs + const related1 = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Related 1") + .execute(); + + const related2 = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Related 2") + .execute(); + + // Create main pub and set multiple relations with override + const mainPub = await PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .set(pubFields["Title"].slug, "Main pub") + .relate(pubFields["Some relation"].slug, "relation 1", related1.id, { override: true }) + .relate(pubFields["Some relation"].slug, "relation 2", related2.id, { override: true }) + .execute(); + + // Should have both relations since they were part of the same override operation + expect(mainPub).toHaveValues([ + { fieldSlug: pubFields["Title"].slug, value: "Main pub" }, + { + fieldSlug: pubFields["Some relation"].slug, + value: "relation 1", + relatedPubId: related1.id, + }, + { + fieldSlug: pubFields["Some relation"].slug, + value: "relation 2", + relatedPubId: related2.id, + }, + ]); + }); }); diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 6924e95cd..4f8e79bf3 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -21,9 +21,29 @@ import { type PubValue = string | number | boolean | JsonValue; type SetCommand = { type: "set"; slug: string; value: PubValue }; -type RelateCommand = { type: "relate"; slug: string; value: PubValue; target: PubOp | PubsId }; +type RelateCommand = { + type: "relate"; + slug: string; + value: PubValue; + target: PubOp | PubsId; + override?: boolean; + deleteOrphaned?: boolean; +}; + +type DisconnectCommand = { + type: "disconnect"; + slug: string; + target: PubsId; + deleteOrphaned?: boolean; +}; -type PubOpCommand = SetCommand | RelateCommand; +type ClearRelationsCommand = { + type: "clearRelations"; + slug?: string; + deleteOrphaned?: boolean; +}; + +type PubOpCommand = SetCommand | RelateCommand | DisconnectCommand | ClearRelationsCommand; type PubOpOptions = { communityId: CommunitiesId; @@ -38,7 +58,9 @@ type OperationsMap = Map< id?: PubsId; mode: "create" | "upsert"; values: Omit[]; - relations: (Omit & { target: PubsId | symbol })[]; + relationsToAdd: (Omit & { target: PubsId | symbol })[]; + relationsToRemove: (Omit & { target: PubsId })[]; + relationsToClear: (Omit & { slug: string })[]; } >; @@ -124,15 +146,54 @@ export class PubOp { } /** - * Relate this pub to another pub which you'll create/upsert in the same operation + * Relate this pub to another pub + * @param slug The field slug + * @param value The value for this relation + * @param target The pub to relate to + * @param options Additional options for this relation + */ + relate( + slug: string, + value: PubValue, + target: PubOp | PubsId, + options?: { + override?: boolean; + deleteOrphaned?: boolean; + } + ): this { + this.#commands.push({ + type: "relate", + slug, + value, + target, + override: options?.override, + deleteOrphaned: options?.deleteOrphaned, + }); + return this; + } + + /** + * Remove a specific relation from this pub */ - relate(slug: string, value: PubValue, target: PubOp): this; + disconnect(slug: string, target: PubsId, options?: { deleteOrphaned?: boolean }): this { + this.#commands.push({ + type: "disconnect", + slug, + target, + deleteOrphaned: options?.deleteOrphaned, + }); + return this; + } + /** - * Relate this pub to another pub with a known id + * Clear all relations for specified field(s) */ - relate(slug: string, value: PubValue, target: PubsId): this; - relate(slug: string, value: PubValue, target: PubOp | PubsId): this { - this.#commands.push({ type: "relate", slug, value, target }); + clearRelations(options?: { slug?: string; deleteOrphaned?: boolean }): this { + this.#commands.push({ + type: "clearRelations", + slug: options?.slug, + deleteOrphaned: options?.deleteOrphaned, + }); return this; } @@ -162,38 +223,54 @@ export class PubOp { value: cmd.value, })), ], - relations: [], + relationsToAdd: [], + relationsToRemove: [], + relationsToClear: [], }); for (const cmd of this.#commands) { - if (cmd.type !== "relate") { + if (cmd.type === "set") { continue; } const rootOp = operations.get(this.#initialId || this.#thisSymbol); assert(rootOp, "Root operation not found"); - if (!(cmd.target instanceof PubOp)) { - rootOp.relations.push({ + if (cmd.type === "clearRelations") { + rootOp.relationsToClear.push({ + slug: cmd.slug || "*", + deleteOrphaned: cmd.deleteOrphaned, + }); + } else if (cmd.type === "disconnect") { + rootOp.relationsToRemove.push({ + slug: cmd.slug, + target: cmd.target, + deleteOrphaned: cmd.deleteOrphaned, + }); + } else if (!(cmd.target instanceof PubOp)) { + rootOp.relationsToAdd.push({ slug: cmd.slug, value: cmd.value, target: cmd.target, + override: cmd.override, + deleteOrphaned: cmd.deleteOrphaned, + }); + } else { + rootOp.relationsToAdd.push({ + slug: cmd.slug, + value: cmd.value, + target: cmd.target.#initialId || cmd.target.#thisSymbol, + override: cmd.override, + deleteOrphaned: cmd.deleteOrphaned, }); - continue; - } - - rootOp.relations.push({ - slug: cmd.slug, - value: cmd.value, - target: cmd.target.#initialId || cmd.target.#thisSymbol, - }); - // Only collect target operations if we haven't processed it yet - // to prevent infinite loops - if (!processed.has(cmd.target.#thisSymbol)) { - const targetOps = cmd.target.collectAllOperations(processed); - for (const [key, value] of targetOps) { - operations.set(key, value); + // Only collect target operations if we haven't processed it yet + // to prevent infinite loops + if (!processed.has(cmd.target.#thisSymbol)) { + const targetOps = cmd.target.collectAllOperations(processed); + for (const [key, value] of targetOps) { + operations.set(key, value); + } } } } @@ -287,6 +364,136 @@ export class PubOp { const rootId = this.#initialId || idMap.get(this.#thisSymbol)!; assert(rootId, "Root ID should exist"); + // First handle relation clearing/disconnections + for (const [key, op] of operations) { + const pubId = typeof key === "symbol" ? idMap.get(key) : idMap.get(key); + assert(pubId, "Pub ID is required"); + + // Process relate commands with override + const overrideRelateCommands = op.relationsToAdd?.filter((cmd) => !!cmd.override) ?? []; + + // Group by slug for override operations + const overridesBySlug = new Map< + string, + (Omit & { target: PubsId | symbol })[] + >(); + + for (const cmd of overrideRelateCommands) { + const cmds = overridesBySlug.get(cmd.slug) ?? []; + cmds.push(cmd); + overridesBySlug.set(cmd.slug, cmds); + } + + // Handle all disconnections + if ( + op.relationsToClear.length > 0 || + op.relationsToRemove.length > 0 || + overridesBySlug.size > 0 + ) { + // Build query to find relations to remove + const query = 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); + + // Add slug conditions + const slugConditions = [ + ...op.relationsToClear.map((cmd) => cmd.slug).filter(Boolean), + ...op.relationsToRemove.map((cmd) => cmd.slug), + ...overridesBySlug.keys(), + ]; + + if (slugConditions.length > 0) { + query.where("slug", "in", slugConditions); + } + + const existingRelations = await query.execute(); + + // Determine which relations to remove + const relationsToRemove = existingRelations.filter((rel) => { + // Remove if explicitly disconnected + if ( + op.relationsToRemove.some( + (cmd) => cmd.slug === rel.slug && cmd.target === rel.relatedPubId + ) + ) { + return true; + } + + // Remove if field is being cleared + if (op.relationsToClear.some((cmd) => !cmd.slug || cmd.slug === rel.slug)) { + return true; + } + + // Remove if not in override set + const overrides = overridesBySlug.get(rel.slug); + if ( + overrides && + !overrides.some( + (cmd) => + (typeof cmd.target === "string" + ? cmd.target + : idMap.get(cmd.target)) === rel.relatedPubId + ) + ) { + return true; + } + + return false; + }); + + // Remove the relations + if (relationsToRemove.length > 0) { + await trx + .deleteFrom("pub_values") + .where( + "pub_values.id", + "in", + relationsToRemove.map((r) => r.id) + ) + .execute(); + + // Handle orphaned pubs if requested + const shouldCheckOrphaned = [ + ...op.relationsToClear, + ...op.relationsToRemove, + ...overrideRelateCommands, + ].some((cmd) => cmd.deleteOrphaned); + + if (shouldCheckOrphaned) { + const orphanedPubIds = relationsToRemove + .map((r) => r.relatedPubId!) + .filter(Boolean); + + if (orphanedPubIds.length > 0) { + // Find and delete truly orphaned pubs + const orphanedPubs = await trx + .selectFrom("pubs as p") + .select("p.id") + .leftJoin("pub_values as pv", "pv.relatedPubId", "p.id") + .where("p.id", "in", orphanedPubIds) + .groupBy("p.id") + .having((eb) => eb.fn.count("pv.id"), "=", 0) + .execute(); + + if (orphanedPubs.length > 0) { + await trx + .deleteFrom("pubs") + .where( + "id", + "in", + orphanedPubs.map((p) => p.id) + ) + .execute(); + } + } + } + } + } + } + const valuesToUpsert = [] as { pubId: PubsId; slug: string; @@ -315,9 +522,9 @@ export class PubOp { ); } - if (op.relations.length > 0) { + if (op.relationsToAdd.length > 0) { relationValuesToUpsert.push( - ...op.relations.map((r) => ({ + ...op.relationsToAdd.map((r) => ({ pubId, slug: r.slug, value: r.value, From 7b961d0faa13521c4d523725d83eb31626a74fcc Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 6 Feb 2025 20:48:56 +0100 Subject: [PATCH 03/23] feat: beautiful better implementation --- core/lib/server/pub-op.db.test.ts | 69 ++- core/lib/server/pub-op.ts | 998 +++++++++++++++++------------- core/lib/server/pub.ts | 50 +- 3 files changed, 624 insertions(+), 493 deletions(-) diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index e5174e6d7..bf87269fa 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -163,6 +163,7 @@ describe("PubOp", () => { }); const pub = await pubOp.execute(); + await expect(pub.id).toExist(trx); const pub2 = await PubOp.upsert(crypto.randomUUID() as PubsId, { @@ -171,7 +172,7 @@ describe("PubOp", () => { lastModifiedBy: createLastModifiedBy("system"), trx, }) - .relate(pubFields["Some relation"].slug, "test relations value", pubOp) + .connect(pubFields["Some relation"].slug, pub.id, "test relations value") .execute(); await expect(pub2.id).toExist(trx); @@ -193,25 +194,25 @@ describe("PubOp", () => { trx, }) .set(pubFields["Title"].slug, "Main Pub") - .relate( + .connect( pubFields["Some relation"].slug, - "the first related pub", PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), trx, - }).set(pubFields["Title"].slug, "Related Pub 1") + }).set(pubFields["Title"].slug, "Related Pub 1"), + "the first related pub" ) - .relate( + .connect( pubFields["Another relation"].slug, - "the second related pub", PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), trx, - }).set(pubFields["Title"].slug, "Related Pub 2") + }).set(pubFields["Title"].slug, "Related Pub 2"), + "the second related pub" ); const result = await mainPub.execute(); @@ -240,15 +241,15 @@ describe("PubOp", () => { trx, }) .set(pubFields["Title"].slug, "Level 1") - .relate( + .connect( pubFields["Another relation"].slug, - "the second related pub", PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), trx, - }).set(pubFields["Title"].slug, "Level 2") + }).set(pubFields["Title"].slug, "Level 2"), + "the second related pub" ); const mainPub = PubOp.create({ @@ -258,7 +259,7 @@ describe("PubOp", () => { trx, }) .set(pubFields["Title"].slug, "Root") - .relate(pubFields["Some relation"].slug, "the first related pub", relatedPub); + .connect(pubFields["Some relation"].slug, relatedPub, "the first related pub"); const result = await mainPub.execute(); @@ -305,16 +306,16 @@ describe("PubOp", () => { trx, }) .set(pubFields["Title"].slug, "Main Pub") - .relate(pubFields["Some relation"].slug, "the first related pub", existingPub.id) - .relate( + .connect(pubFields["Some relation"].slug, existingPub.id, "the first related pub") + .connect( pubFields["Another relation"].slug, - "the second related pub", PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), trx, - }).set(pubFields["Title"].slug, "New Related Pub") + }).set(pubFields["Title"].slug, "New Related Pub"), + "the second related pub" ); const result = await mainPub.execute(); @@ -358,9 +359,9 @@ describe("PubOp", () => { trx, }) .set(pubFields["Title"].slug, "Pub 2") - .relate(pubFields["Some relation"].slug, "the first related pub", pub1); + .connect(pubFields["Some relation"].slug, pub1, "the first related pub"); - pub1.relate(pubFields["Another relation"].slug, "the second related pub", pub2); + pub1.connect(pubFields["Another relation"].slug, pub2, "the second related pub"); const result = await pub1.execute(); @@ -416,7 +417,7 @@ describe("PubOp", () => { trx, }) .set(pubFields["Title"].slug, "Pub 2") - .relate(pubFields["Some relation"].slug, "initial value", pub1.id) + .connect(pubFields["Some relation"].slug, pub1.id, "initial value") .execute(); const updatedPub = await PubOp.upsert(pub2.id, { @@ -425,7 +426,7 @@ describe("PubOp", () => { lastModifiedBy: createLastModifiedBy("system"), trx, }) - .relate(pubFields["Some relation"].slug, "updated value", pub1.id) + .connect(pubFields["Some relation"].slug, pub1.id, "updated value") .execute(); expect(updatedPub).toHaveValues([ @@ -460,11 +461,11 @@ describe("relation management", () => { trx, }) .set(pubFields["Title"].slug, "Pub 2") - .relate(pubFields["Some relation"].slug, "initial value", pub1.id) + .connect(pubFields["Some relation"].slug, pub1.id, "initial value") .execute(); // Disconnect the relation - const updatedPub = await PubOp.upsert(pub2.id, { + const updatedPub = await PubOp.update(pub2.id, { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), @@ -497,11 +498,11 @@ describe("relation management", () => { trx, }) .set(pubFields["Title"].slug, "Main pub") - .relate(pubFields["Some relation"].slug, "only relation", orphanedPub.id) + .connect(pubFields["Some relation"].slug, orphanedPub.id, "only relation") .execute(); // Disconnect with deleteOrphaned option - await PubOp.upsert(mainPub.id, { + await PubOp.update(mainPub.id, { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), @@ -539,18 +540,18 @@ describe("relation management", () => { trx, }) .set(pubFields["Title"].slug, "Main pub") - .relate(pubFields["Some relation"].slug, "relation 1", related1) - .relate(pubFields["Some relation"].slug, "relation 2", related2) + .connect(pubFields["Some relation"].slug, related1, "relation 1") + .connect(pubFields["Some relation"].slug, related2, "relation 2") .execute(); // Clear all relations for the field - const updatedPub = await PubOp.upsert(mainPub.id, { + const updatedPub = await PubOp.update(mainPub.id, { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), trx, }) - .clearRelations({ slug: pubFields["Some relation"].slug }) + .clearRelationsForField(pubFields["Some relation"].slug) .execute(); expect(updatedPub).toHaveValues([ @@ -562,14 +563,14 @@ describe("relation management", () => { const trx = getTrx(); // Create initial related pubs - const related1 = await PubOp.create({ + const related1 = PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), trx, }).set(pubFields["Title"].slug, "Related 1"); - const related2 = await PubOp.create({ + const related2 = PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), @@ -584,8 +585,8 @@ describe("relation management", () => { trx, }) .set(pubFields["Title"].slug, "Main pub") - .relate(pubFields["Some relation"].slug, "relation 1", related1) - .relate(pubFields["Some relation"].slug, "relation 2", related2) + .connect(pubFields["Some relation"].slug, related1, "relation 1") + .connect(pubFields["Some relation"].slug, related2, "relation 2") .execute(); const related3 = await PubOp.create({ @@ -604,7 +605,7 @@ describe("relation management", () => { lastModifiedBy: createLastModifiedBy("system"), trx, }) - .relate(pubFields["Some relation"].slug, "new relation", related3.id, { + .connect(pubFields["Some relation"].slug, related3.id, "new relation", { override: true, }) .execute(); @@ -649,8 +650,8 @@ describe("relation management", () => { trx, }) .set(pubFields["Title"].slug, "Main pub") - .relate(pubFields["Some relation"].slug, "relation 1", related1.id, { override: true }) - .relate(pubFields["Some relation"].slug, "relation 2", related2.id, { override: true }) + .connect(pubFields["Some relation"].slug, related1.id, "relation 1", { override: true }) + .connect(pubFields["Some relation"].slug, related2.id, "relation 2", { override: true }) .execute(); // Should have both relations since they were part of the same override operation diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 4f8e79bf3..cc7eaba38 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -4,7 +4,14 @@ import { sql } from "kysely"; import type { JsonValue, ProcessedPub } from "contracts"; import type { Database } from "db/Database"; -import type { CommunitiesId, CoreSchemaType, PubFieldsId, PubsId, PubTypesId } from "db/public"; +import type { + CommunitiesId, + CoreSchemaType, + PubFieldsId, + PubsId, + PubTypesId, + PubValuesId, +} from "db/public"; import type { LastModifiedBy } from "db/types"; import { assert, expect } from "utils"; import { isUuid } from "utils/uuid"; @@ -12,6 +19,8 @@ import { isUuid } from "utils/uuid"; import { db } from "~/kysely/database"; import { autoRevalidate } from "./cache/autoRevalidate"; import { + deletePub, + deletePubValuesByValueId, getPubsWithRelatedValuesAndChildren, maybeWithTrx, upsertPubRelationValues, @@ -20,121 +29,128 @@ import { } from "./pub"; type PubValue = string | number | boolean | JsonValue; -type SetCommand = { type: "set"; slug: string; value: PubValue }; -type RelateCommand = { - type: "relate"; - slug: string; - value: PubValue; - target: PubOp | PubsId; + +type PubOpOptions = { + communityId: CommunitiesId; + pubTypeId: PubTypesId; + lastModifiedBy: LastModifiedBy; + trx?: Transaction; +}; + +type RelationOptions = { override?: boolean; deleteOrphaned?: boolean; }; +// Base commands that will be used internally +type SetCommand = { type: "set"; slug: string; value: PubValue | undefined }; +type RelateCommand = { + type: "relate"; + slug: string; + relations: Array<{ target: PubOp | PubsId; value: PubValue }>; + options: RelationOptions; +}; type DisconnectCommand = { type: "disconnect"; slug: string; target: PubsId; deleteOrphaned?: boolean; }; - type ClearRelationsCommand = { type: "clearRelations"; slug?: string; deleteOrphaned?: boolean; }; -type PubOpCommand = SetCommand | RelateCommand | DisconnectCommand | ClearRelationsCommand; - -type PubOpOptions = { - communityId: CommunitiesId; - pubTypeId: PubTypesId; - lastModifiedBy: LastModifiedBy; - trx?: Transaction; +type UnsetCommand = { + type: "unset"; + slug: string; }; -type OperationsMap = Map< - PubsId | symbol, - { - id?: PubsId; - mode: "create" | "upsert"; - values: Omit[]; - relationsToAdd: (Omit & { target: PubsId | symbol })[]; - relationsToRemove: (Omit & { target: PubsId })[]; - relationsToClear: (Omit & { slug: string })[]; - } ->; +type PubOpCommand = + | SetCommand + | RelateCommand + | DisconnectCommand + | ClearRelationsCommand + | UnsetCommand; + +// Types for operation collection +type OperationMode = "create" | "upsert" | "update"; + +interface CollectedOperation { + id: PubsId | undefined; + mode: OperationMode; + values: Array<{ slug: string; value: PubValue }>; + relationsToAdd: Array<{ + slug: string; + value: PubValue; + target: PubsId | symbol; + override?: boolean; + deleteOrphaned?: boolean; + }>; + relationsToRemove: Array<{ + slug: string; + target: PubsId; + deleteOrphaned?: boolean; + }>; + relationsToClear: Array<{ + slug: string | "*"; + deleteOrphaned?: boolean; + }>; +} + +type OperationsMap = Map; + +export type SingleRelationInput = { + target: PubOp | PubsId; + value: PubValue; + override?: boolean; + deleteOrphaned?: boolean; +}; function isPubId(val: string | PubsId): val is PubsId { return isUuid(val); } -export class PubOp { - readonly #options: PubOpOptions; - readonly #commands: PubOpCommand[] = []; - readonly #mode: "create" | "upsert" = "create"; - readonly #initialId?: PubsId; - readonly #initialslug?: string; - readonly #initialValue?: PubValue; - readonly #thisSymbol: symbol; - static #symbolCounter = 0; - - private constructor( - options: PubOpOptions, - mode: "create" | "upsert", - initialId?: PubsId, - initialslug?: string, - initialValue?: PubValue, - commands: PubOpCommand[] = [] - ) { - this.#options = options; - this.#mode = mode; - this.#initialId = initialId; - this.#initialslug = initialslug; - this.#initialValue = initialValue; - this.#commands = commands; - this.#thisSymbol = Symbol(`pub-${PubOp.#symbolCounter++}`); - } +type PubOpMode = "create" | "upsert" | "update"; - static createWithId(id: PubsId, options: PubOpOptions): PubOp { - return new PubOp(options, "create", id); - } +type PubIdMap = Map; +type RelationModification = { + slug: string; + relatedPubId: PubsId | null; +}; +type RelationModifications = { + overrides: Map; + clears: Map; + removes: Map; +}; - static create(options: PubOpOptions): PubOp { - return new PubOp(options, "create"); - } +// Common operations available to all PubOp types +abstract class BasePubOp { + protected readonly options: PubOpOptions; + protected readonly commands: PubOpCommand[] = []; + protected readonly thisSymbol: symbol; + protected static symbolCounter = 0; - static upsert(id: PubsId, options: PubOpOptions): PubOp; - static upsert(slug: string, value: PubValue, options: PubOpOptions): PubOp; - static upsert( - slugOrId: string | PubsId, - valueOrOptions: PubValue | PubOpOptions, - options?: PubOpOptions - ): PubOp { - if (isPubId(slugOrId)) { - return new PubOp(valueOrOptions as PubOpOptions, "upsert", slugOrId); - } - return new PubOp(options!, "upsert", undefined, slugOrId, valueOrOptions as PubValue); + constructor(options: PubOpOptions) { + this.options = options; + this.thisSymbol = Symbol(`pub-${BasePubOp.symbolCounter++}`); } /** - * Add a single value to this pub - * The slug is the field slug of format `[communitySlug]:[fieldSlug]` + * Set a single value or multiple values */ set(slug: string, value: PubValue): this; - /** - * Add multiple values to this pub - * The keys are the field slugs of format `[communitySlug]:[fieldSlug]` - */ set(values: Record): this; set(slugOrValues: string | Record, value?: PubValue): this { if (typeof slugOrValues === "string") { - this.#commands.push({ + this.commands.push({ type: "set", slug: slugOrValues, value: value!, }); } else { - this.#commands.push( + this.commands.push( ...Object.entries(slugOrValues).map(([slug, value]) => ({ type: "set" as const, slug, @@ -146,96 +162,111 @@ export class PubOp { } /** - * Relate this pub to another pub - * @param slug The field slug - * @param value The value for this relation - * @param target The pub to relate to - * @param options Additional options for this relation + * Unset a value for a specific field */ - relate( - slug: string, - value: PubValue, - target: PubOp | PubsId, - options?: { - override?: boolean; - deleteOrphaned?: boolean; - } - ): this { - this.#commands.push({ - type: "relate", + unset(slug: string): this { + this.commands.push({ + type: "set", slug, - value, - target, - override: options?.override, - deleteOrphaned: options?.deleteOrphaned, + value: undefined, }); return this; } /** - * Remove a specific relation from this pub + * Connect to one or more pubs with the same value */ - disconnect(slug: string, target: PubsId, options?: { deleteOrphaned?: boolean }): this { - this.#commands.push({ - type: "disconnect", + connect(slug: string, target: PubOp | PubsId, value: PubValue, options?: RelationOptions): this; + /** + * Connect to one or more pubs with individual values + */ + connect( + slug: string, + relations: Array<{ target: PubOp | PubsId; value: PubValue }>, + options?: RelationOptions + ): this; + connect( + slug: string, + targetsOrRelations: PubOp | PubsId | Array<{ target: PubOp | PubsId; value: PubValue }>, + valueOrOptions?: PubValue | RelationOptions, + maybeOptions?: RelationOptions + ): this { + if (typeof targetsOrRelations === "string" && !isPubId(targetsOrRelations)) { + throw new Error( + `Invalid target: should either be an existing pub id or a PubOp instance, but got \`${targetsOrRelations}\`` + ); + } + + const options = + (Array.isArray(targetsOrRelations) + ? (valueOrOptions as RelationOptions) + : maybeOptions) ?? {}; + const relations = Array.isArray(targetsOrRelations) + ? targetsOrRelations + : [ + { + target: targetsOrRelations, + value: valueOrOptions as PubValue, + }, + ]; + + this.commands.push({ + type: "relate", slug, - target, - deleteOrphaned: options?.deleteOrphaned, + relations, + options, }); return this; } /** - * Clear all relations for specified field(s) + * Set relation values for existing relations */ - clearRelations(options?: { slug?: string; deleteOrphaned?: boolean }): this { - this.#commands.push({ - type: "clearRelations", - slug: options?.slug, - deleteOrphaned: options?.deleteOrphaned, - }); - return this; + setRelation(slug: string, target: PubsId, value: PubValue): this { + return this.connect(slug, [{ target, value }], { override: false }); + } + /** + * Set multiple relation values for existing relations + */ + setRelations(slug: string, relations: Array<{ target: PubsId; value: PubValue }>): this { + return this.connect(slug, relations, { override: false }); } - private collectAllOperations(processed = new Set()): OperationsMap { + 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 } + ); + } + + protected collectOperations(processed = new Set()): OperationsMap { // If we've already processed this PubOp, return empty map to avoid circular recursion - if (processed.has(this.#thisSymbol)) { + if (processed.has(this.thisSymbol)) { return new Map(); } const operations = new Map() as OperationsMap; - processed.add(this.#thisSymbol); - - operations.set(this.#initialId || this.#thisSymbol, { - id: this.#initialId, - mode: this.#mode, - values: [ - // if we use a value rather than the id as the unique identifier - ...(this.#initialslug - ? [{ slug: this.#initialslug, value: this.#initialValue! }] - : []), - ...this.#commands - .filter( - (cmd): cmd is Extract => cmd.type === "set" - ) - .map((cmd) => ({ - slug: cmd.slug, - value: cmd.value, - })), - ], + processed.add(this.thisSymbol); + + // Add this pub's operations + operations.set(this.getOperationKey(), { + id: this.getInitialId(), + mode: this.getMode(), + values: this.collectValues(), relationsToAdd: [], relationsToRemove: [], relationsToClear: [], }); - for (const cmd of this.#commands) { - if (cmd.type === "set") { - continue; - } - - const rootOp = operations.get(this.#initialId || this.#thisSymbol); + // Process commands + for (const cmd of this.commands) { + const rootOp = operations.get(this.getOperationKey()); assert(rootOp, "Root operation not found"); + if (cmd.type === "set") continue; // Values already collected + if (cmd.type === "clearRelations") { rootOp.relationsToClear.push({ slug: cmd.slug || "*", @@ -247,57 +278,83 @@ export class PubOp { target: cmd.target, deleteOrphaned: cmd.deleteOrphaned, }); - } else if (!(cmd.target instanceof PubOp)) { - rootOp.relationsToAdd.push({ - slug: cmd.slug, - value: cmd.value, - target: cmd.target, - override: cmd.override, - deleteOrphaned: cmd.deleteOrphaned, - }); - } else { - rootOp.relationsToAdd.push({ - slug: cmd.slug, - value: cmd.value, - target: cmd.target.#initialId || cmd.target.#thisSymbol, - override: cmd.override, - deleteOrphaned: cmd.deleteOrphaned, - }); - - // Only collect target operations if we haven't processed it yet - // to prevent infinite loops - if (!processed.has(cmd.target.#thisSymbol)) { - const targetOps = cmd.target.collectAllOperations(processed); - for (const [key, value] of targetOps) { - operations.set(key, value); + } else if (cmd.type === "relate") { + // Process each relation in the command + cmd.relations.forEach((relation) => { + if (!(relation.target instanceof BasePubOp)) { + rootOp.relationsToAdd.push({ + slug: cmd.slug, + value: relation.value, + target: relation.target as PubsId, + override: cmd.options.override, + deleteOrphaned: cmd.options.deleteOrphaned, + }); + } else { + rootOp.relationsToAdd.push({ + slug: cmd.slug, + value: relation.value, + target: relation.target.thisSymbol, + override: cmd.options.override, + deleteOrphaned: cmd.options.deleteOrphaned, + }); + + // Collect nested PubOp operations + if (!processed.has(relation.target.thisSymbol)) { + const targetOps = relation.target.collectOperations(processed); + for (const [key, value] of targetOps) { + operations.set(key, value); + } + } } - } + }); } } return operations; } - private async executeWithTrx(trx: Transaction): Promise { - const operations = this.collectAllOperations(); - const idMap = new Map(); + // Helper methods for operation collection + protected abstract getMode(): OperationMode; + protected abstract getInitialId(): PubsId | undefined; + protected getOperationKey(): PubsId | symbol { + return this.getInitialId() || this.thisSymbol; + } - const pubsToCreate = [] as { - id: PubsId | undefined; - communityId: CommunitiesId; - pubTypeId: PubTypesId; - }[]; - - for (const [key, value] of operations) { - pubsToCreate.push({ - id: typeof key === "symbol" ? undefined : key, - communityId: this.#options.communityId, - pubTypeId: this.#options.pubTypeId, - }); - } + private collectValues(): Array<{ slug: string; value: PubValue }> { + return this.commands + .filter( + (cmd): cmd is Extract => + cmd.type === "set" && cmd.value !== undefined + ) + .map((cmd) => ({ + slug: cmd.slug, + value: cmd.value!, + })); + } + + // Split executeWithTrx into smaller, focused methods + protected async executeWithTrx(trx: Transaction): Promise { + const operations = this.collectOperations(); + const idMap = await this.createAllPubs(trx, operations); + await this.processRelations(trx, operations, idMap); + await this.processValues(trx, operations, idMap); - // we create the necessary pubs - const pubCreateResult = await autoRevalidate( + return this.resolvePubId(this.getOperationKey(), idMap); + } + + private async createAllPubs( + trx: Transaction, + operations: OperationsMap + ): Promise { + const idMap = new Map(); + const pubsToCreate = Array.from(operations.entries()).map(([key, _]) => ({ + id: typeof key === "symbol" ? undefined : key, + communityId: this.options.communityId, + pubTypeId: this.options.pubTypeId, + })); + + // Create pubs and handle conflicts + const createdPubs = await autoRevalidate( trx .insertInto("pubs") .values(pubsToCreate) @@ -305,301 +362,364 @@ export class PubOp { .returningAll() ).execute(); - let index = 0; - /** - * this is somewhat convoluted, but basically: - * - onConflict().doNothing() does not return anything if it's triggered - * - therefore we need to fill in the "holes" in the pubCreateResult array - * in order to effectively loop over it by comparing it with the operations - */ - const pubCreateResultWithHolesFilled = pubsToCreate.map((pubToCreate) => { - const correspondingPubCreateResult = pubCreateResult[index]; - - if (pubToCreate.id && pubToCreate.id !== correspondingPubCreateResult?.id) { - return null; - } - - index++; - - if (correspondingPubCreateResult) { - return correspondingPubCreateResult; + // Map IDs, handling both new and existing pubs + let createdIndex = 0; + let operationIndex = 0; + for (const [key, op] of operations) { + const createdPub = createdPubs[createdIndex]; + const pubToCreate = pubsToCreate[operationIndex]; + + if (createdPub) { + idMap.set(key, createdPub.id); + createdIndex++; + } else if (pubToCreate.id) { + if (op.mode === "create") { + throw new Error( + `Cannot create a pub with an id that already exists: ${pubToCreate.id}` + ); + } + idMap.set(key, pubToCreate.id); + } else if (typeof key === "symbol") { + throw new Error("Pub not created"); + } else { + idMap.set(key, key); } + operationIndex++; + } - return null; - }); + return idMap; + } - let idx = 0; + private async processRelations( + trx: Transaction, + operations: OperationsMap, + idMap: PubIdMap + ): Promise { for (const [key, op] of operations) { - let result = pubCreateResultWithHolesFilled[idx]; + const pubId = this.resolvePubId(key, idMap); + + // Skip if no relation changes + const modifications = { + overrides: this.groupBySlug(op.relationsToAdd.filter((cmd) => cmd.override)), + clears: this.groupBySlug(op.relationsToClear), + removes: this.groupBySlug(op.relationsToRemove), + }; - if (result) { - idMap.set(key, result.id); - idx++; + if (!this.hasModifications(modifications)) { continue; } - // we are upserting a pub, OR we are creating a pub with a specific id that has failed - const possiblyExistingPubId = pubsToCreate[idx]?.id; + // Find and remove existing relations + 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", [ + ...modifications.overrides.keys(), + ...modifications.clears.keys(), + ...modifications.removes.keys(), + ]) + .execute(); + + const relationsToRemove = existingRelations.filter( + (r) => !modifications.overrides.has(r.id) + ); - if (possiblyExistingPubId && op.mode === "create") { - throw new Error( - `Cannot create a pub with an id that already exists: ${possiblyExistingPubId}` - ); + if (relationsToRemove.length === 0) { + return; } + await deletePubValuesByValueId({ + pubId, + valueIds: relationsToRemove.map((r) => r.id), + lastModifiedBy: this.options.lastModifiedBy, + trx, + }); - if (possiblyExistingPubId) { - idMap.set(key, possiblyExistingPubId); - idx++; - continue; - } + // Handle orphaned pubs if needed + await this.cleanupOrphanedPubs(trx, relationsToRemove, modifications); + } + } - if (typeof key === "symbol") { - throw new Error("Pub not created"); - } - idMap.set(key, key); + private async processValues( + trx: Transaction, + operations: OperationsMap, + idMap: PubIdMap + ): Promise { + // Collect all values and relations to upsert + const toUpsert = Array.from(operations.entries()).flatMap(([key, op]) => { + const pubId = this.resolvePubId(key, idMap); + return [ + // Regular values + ...op.values.map((v) => ({ + pubId, + slug: v.slug, + value: v.value, + })), + // Relations + ...op.relationsToAdd.map((r) => ({ + pubId, + slug: r.slug, + value: r.value, + relatedPubId: typeof r.target === "string" ? r.target : idMap.get(r.target)!, + })), + ]; + }); - idx++; + if (toUpsert.length === 0) { + return; } - const rootId = this.#initialId || idMap.get(this.#thisSymbol)!; - assert(rootId, "Root ID should exist"); + // Validate and upsert + const validated = await validatePubValues({ + pubValues: toUpsert, + communityId: this.options.communityId, + continueOnValidationError: false, + trx, + }); - // First handle relation clearing/disconnections - for (const [key, op] of operations) { - const pubId = typeof key === "symbol" ? idMap.get(key) : idMap.get(key); - assert(pubId, "Pub ID is required"); - - // Process relate commands with override - const overrideRelateCommands = op.relationsToAdd?.filter((cmd) => !!cmd.override) ?? []; - - // Group by slug for override operations - const overridesBySlug = new Map< - string, - (Omit & { target: PubsId | symbol })[] - >(); - - for (const cmd of overrideRelateCommands) { - const cmds = overridesBySlug.get(cmd.slug) ?? []; - cmds.push(cmd); - overridesBySlug.set(cmd.slug, cmds); - } + const { values, relations } = this.partitionValidatedValues(validated); - // Handle all disconnections - if ( - op.relationsToClear.length > 0 || - op.relationsToRemove.length > 0 || - overridesBySlug.size > 0 - ) { - // Build query to find relations to remove - const query = 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); - - // Add slug conditions - const slugConditions = [ - ...op.relationsToClear.map((cmd) => cmd.slug).filter(Boolean), - ...op.relationsToRemove.map((cmd) => cmd.slug), - ...overridesBySlug.keys(), - ]; + // Perform upserts in parallel + 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, + }), + ]); + } - if (slugConditions.length > 0) { - query.where("slug", "in", slugConditions); - } + // --- Helper methods --- - const existingRelations = await query.execute(); - - // Determine which relations to remove - const relationsToRemove = existingRelations.filter((rel) => { - // Remove if explicitly disconnected - if ( - op.relationsToRemove.some( - (cmd) => cmd.slug === rel.slug && cmd.target === rel.relatedPubId - ) - ) { - return true; - } + private hasModifications(mods: { + [K in "overrides" | "clears" | "removes"]: ReturnType; + }): boolean { + return mods.overrides.size > 0 || mods.clears.size > 0 || mods.removes.size > 0; + } - // Remove if field is being cleared - if (op.relationsToClear.some((cmd) => !cmd.slug || cmd.slug === rel.slug)) { - return true; - } + private groupBySlug(items: T[]): Map { + return items.reduce((map, item) => { + const existing = map.get(item.slug) ?? []; + existing.push(item); + map.set(item.slug, existing); + return map; + }, new Map()); + } - // Remove if not in override set - const overrides = overridesBySlug.get(rel.slug); - if ( - overrides && - !overrides.some( - (cmd) => - (typeof cmd.target === "string" - ? cmd.target - : idMap.get(cmd.target)) === rel.relatedPubId - ) - ) { - return true; - } + private async cleanupOrphanedPubs( + trx: Transaction, + removedRelations: Array<{ relatedPubId: PubsId | null }>, + modifications: Record>> + ): Promise { + const shouldDelete = Object.values(modifications) + .flatMap((m) => Array.from(m.values())) + .flat() + .some((m) => m.deleteOrphaned); + + if (!shouldDelete) return; + + const orphanedIds = removedRelations + .map((r) => r.relatedPubId) + .filter((id): id is PubsId => id !== null); + + if (orphanedIds.length === 0) return; + + const trulyOrphaned = await trx + .selectFrom("pubs as p") + .select("p.id") + .leftJoin("pub_values as pv", "pv.relatedPubId", "p.id") + .where("p.id", "in", orphanedIds) + .groupBy("p.id") + .having((eb) => eb.fn.count("pv.id"), "=", 0) + .execute(); + + if (trulyOrphaned.length > 0) { + await deletePub({ + pubId: trulyOrphaned.map((p) => p.id), + communityId: this.options.communityId, + lastModifiedBy: this.options.lastModifiedBy, + trx, + }); + } + } - return false; - }); + private partitionValidatedValues(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, + })), + relations: validated + .filter( + (v): v is typeof v & { 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, + })), + }; + } - // Remove the relations - if (relationsToRemove.length > 0) { - await trx - .deleteFrom("pub_values") - .where( - "pub_values.id", - "in", - relationsToRemove.map((r) => r.id) - ) - .execute(); - - // Handle orphaned pubs if requested - const shouldCheckOrphaned = [ - ...op.relationsToClear, - ...op.relationsToRemove, - ...overrideRelateCommands, - ].some((cmd) => cmd.deleteOrphaned); - - if (shouldCheckOrphaned) { - const orphanedPubIds = relationsToRemove - .map((r) => r.relatedPubId!) - .filter(Boolean); - - if (orphanedPubIds.length > 0) { - // Find and delete truly orphaned pubs - const orphanedPubs = await trx - .selectFrom("pubs as p") - .select("p.id") - .leftJoin("pub_values as pv", "pv.relatedPubId", "p.id") - .where("p.id", "in", orphanedPubIds) - .groupBy("p.id") - .having((eb) => eb.fn.count("pv.id"), "=", 0) - .execute(); - - if (orphanedPubs.length > 0) { - await trx - .deleteFrom("pubs") - .where( - "id", - "in", - orphanedPubs.map((p) => p.id) - ) - .execute(); - } - } - } - } - } - } + private resolvePubId(key: PubsId | symbol, idMap: Map): PubsId { + const pubId = typeof key === "symbol" ? idMap.get(key) : idMap.get(key); + assert(pubId, "Pub ID is required"); + return pubId; + } +} - const valuesToUpsert = [] as { - pubId: PubsId; - slug: string; - value: PubValue; - }[]; +interface UpdateOnlyOps { + unset(slug: string): this; + disconnect(slug: string, target: PubsId, options?: { deleteOrphaned?: boolean }): this; + clearRelationsForField(slug: string, options?: { deleteOrphaned?: boolean }): this; + clearAllRelations(options?: { deleteOrphaned?: boolean }): this; +} - const relationValuesToUpsert = [] as { - pubId: PubsId; - slug: string; - value: PubValue; - relatedPubId: PubsId; - }[]; +// Implementation classes - these are not exported +class CreatePubOp extends BasePubOp { + private readonly initialId?: PubsId; - for (const [key, op] of operations) { - const pubId = typeof key === "symbol" ? idMap.get(key) : idMap.get(key); - - assert(pubId, "Pub ID is required"); - - if (op.values.length > 0) { - valuesToUpsert.push( - ...op.values.map((v) => ({ - pubId, - slug: v.slug, - value: v.value, - })) - ); - } + constructor(options: PubOpOptions, initialId?: PubsId) { + super(options); + this.initialId = initialId; + } - if (op.relationsToAdd.length > 0) { - relationValuesToUpsert.push( - ...op.relationsToAdd.map((r) => ({ - pubId, - slug: r.slug, - value: r.value, - relatedPubId: expect( - typeof r.target === "string" ? r.target : idMap.get(r.target), - "Related pub ID should exist" - ), - })) - ); - } - } + protected getMode(): OperationMode { + return "create"; + } - if (valuesToUpsert.length === 0 && relationValuesToUpsert.length === 0) { - return rootId; - } + protected getInitialId(): PubsId | undefined { + return this.initialId; + } +} - // kind of clunky, but why do it twice when we can do it once - const validatedPubAndRelationValues = await validatePubValues({ - pubValues: [...valuesToUpsert, ...relationValuesToUpsert], - communityId: this.#options.communityId, - continueOnValidationError: false, - trx, - }); +class UpsertPubOp extends BasePubOp { + private readonly initialId?: PubsId; + private readonly initialSlug?: string; + private readonly initialValue?: PubValue; - const validatedPubValues = validatedPubAndRelationValues - .filter((v) => !("relatedPubId" in v) || !v.relatedPubId) - .map((v) => ({ - pubId: v.pubId, - fieldId: v.fieldId, - value: v.value, - lastModifiedBy: this.#options.lastModifiedBy, - })); - const validatedRelationValues = validatedPubAndRelationValues - .filter( - (v): v is typeof v & { 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, - })); + constructor( + options: PubOpOptions, + initialId?: PubsId, + initialSlug?: string, + initialValue?: PubValue + ) { + super(options); + this.initialId = initialId; + this.initialSlug = initialSlug; + this.initialValue = initialValue; + } - await Promise.all([ - upsertPubValues({ - // this is a dummy pubId, we will use the ones on the validatedPubValues - pubId: "xxx" as PubsId, - pubValues: validatedPubValues, - lastModifiedBy: this.#options.lastModifiedBy, - trx, - }), - upsertPubRelationValues({ - // this is a dummy pubId, we will use the ones on the validatedPubRelationValues - pubId: "xxx" as PubsId, - allRelationsToCreate: validatedRelationValues, - lastModifiedBy: this.#options.lastModifiedBy, - trx, - }), - ]); + protected getMode(): OperationMode { + return "upsert"; + } - return rootId; + protected getInitialId(): PubsId | undefined { + return this.initialId; } +} - async execute(): Promise { - const { trx = db } = this.#options; +class UpdatePubOp extends BasePubOp implements UpdateOnlyOps { + private readonly pubId: PubsId | undefined; + private readonly initialSlug?: string; + private readonly initialValue?: PubValue; - const pubId = await maybeWithTrx(trx, async (trx) => { - return await this.executeWithTrx(trx); + constructor( + id: PubsId | undefined, + options: PubOpOptions, + initialSlug?: string, + initialValue?: PubValue + ) { + super(options); + this.pubId = id; + this.initialSlug = initialSlug; + this.initialValue = initialValue; + } + + protected getMode(): OperationMode { + return "update"; + } + + unset(slug: string): this { + this.commands.push({ + type: "unset", + slug, }); + return this; + } - return await getPubsWithRelatedValuesAndChildren( - { pubId, communityId: this.#options.communityId }, - { trx } - ); + disconnect(slug: string, target: PubsId, options?: { deleteOrphaned?: boolean }): this { + this.commands.push({ + type: "disconnect", + slug, + target, + deleteOrphaned: options?.deleteOrphaned, + }); + return this; + } + + clearRelationsForField(slug: string, options?: { deleteOrphaned?: boolean }): this { + this.commands.push({ + type: "clearRelations", + slug, + deleteOrphaned: options?.deleteOrphaned, + }); + return this; + } + + clearAllRelations(options?: { deleteOrphaned?: boolean }): this { + this.commands.push({ + type: "clearRelations", + deleteOrphaned: options?.deleteOrphaned, + }); + return this; + } + protected getInitialId(): PubsId | undefined { + return this.pubId; + } +} + +// The factory class - this is the only exported class +export class PubOp { + static create(options: PubOpOptions): CreatePubOp { + return new CreatePubOp(options); + } + + static createWithId(id: PubsId, options: PubOpOptions): CreatePubOp { + return new CreatePubOp(options, id); + } + + static update(id: PubsId, options: PubOpOptions): UpdatePubOp { + return new UpdatePubOp(id, options); + } + + static updateByValue(slug: string, value: PubValue, options: PubOpOptions): UpdatePubOp { + return new UpdatePubOp(undefined, options, slug, value); + } + + static upsert(id: PubsId, options: PubOpOptions): UpsertPubOp { + return new UpsertPubOp(options, id); + } + + static upsertByValue(slug: string, value: PubValue, options: PubOpOptions): UpsertPubOp { + return new UpsertPubOp(options, undefined, slug, value); } } diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index 71578c04d..29bc71e33 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -655,33 +655,43 @@ 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, + 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) => From 0feeca140285f0ddf4d79768524e738c5d0a9b98 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 6 Feb 2025 22:06:07 +0100 Subject: [PATCH 04/23] fix: cleanup a bit --- core/lib/server/pub-op.db.test.ts | 111 ++++------ core/lib/server/pub-op.ts | 355 ++++++++++++++++++++---------- 2 files changed, 283 insertions(+), 183 deletions(-) diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index bf87269fa..4a8d4913c 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -11,8 +11,6 @@ const { createSeed, seedCommunity } = await import("~/prisma/seed/seedCommunity" const { createForEachMockedTransaction } = await mockServerCode(); -const { getTrx } = createForEachMockedTransaction(); - const seed = createSeed({ community: { name: "test", @@ -90,40 +88,35 @@ const { community, pubFields, pubTypes, stages, pubs, users } = await seedCommun describe("PubOp", () => { it("should create a new pub", async () => { - const trx = getTrx(); const id = crypto.randomUUID() as PubsId; const pubOp = PubOp.upsert(id, { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }); const pub = await pubOp.execute(); - await expect(pub.id).toExist(trx); + await expect(pub.id).toExist(); }); it("should not fail when upserting existing pub", async () => { - const trx = getTrx(); const id = crypto.randomUUID() as PubsId; const pubOp = PubOp.upsert(id, { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }); const pub = await pubOp.execute(); - await expect(pub.id).toExist(trx); + await expect(pub.id).toExist(); const pub2 = await PubOp.upsert(id, { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }).execute(); - await expect(pub2.id).toExist(trx); + await expect(pub2.id).toExist(); }); it("should create a new pub and set values", async () => { @@ -154,28 +147,25 @@ describe("PubOp", () => { }); it("should be able to relate existing pubs", async () => { - const trx = getTrx(); const pubOp = PubOp.upsert(crypto.randomUUID() as PubsId, { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }); const pub = await pubOp.execute(); - await expect(pub.id).toExist(trx); + await expect(pub.id).toExist(); const pub2 = await PubOp.upsert(crypto.randomUUID() as PubsId, { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .connect(pubFields["Some relation"].slug, pub.id, "test relations value") .execute(); - await expect(pub2.id).toExist(trx); + await expect(pub2.id).toExist(); expect(pub2).toHaveValues([ { fieldSlug: pubFields["Some relation"].slug, @@ -186,12 +176,10 @@ describe("PubOp", () => { }); it("should create multiple related pubs in a single operation", async () => { - const trx = getTrx(); const mainPub = PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Main Pub") .connect( @@ -200,7 +188,6 @@ describe("PubOp", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }).set(pubFields["Title"].slug, "Related Pub 1"), "the first related pub" ) @@ -210,7 +197,6 @@ describe("PubOp", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }).set(pubFields["Title"].slug, "Related Pub 2"), "the second related pub" ); @@ -233,12 +219,10 @@ describe("PubOp", () => { }); it("should handle deeply nested relations", async () => { - const trx = getTrx(); const relatedPub = PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Level 1") .connect( @@ -247,7 +231,6 @@ describe("PubOp", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }).set(pubFields["Title"].slug, "Level 2"), "the second related pub" ); @@ -256,7 +239,6 @@ describe("PubOp", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Root") .connect(pubFields["Some relation"].slug, relatedPub, "the first related pub"); @@ -287,14 +269,11 @@ describe("PubOp", () => { }); it("should handle mixing existing and new pubs in relations", async () => { - const trx = getTrx(); - // First create a pub that we'll relate to const existingPub = await PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Existing Pub") .execute(); @@ -303,7 +282,6 @@ describe("PubOp", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Main Pub") .connect(pubFields["Some relation"].slug, existingPub.id, "the first related pub") @@ -313,7 +291,6 @@ describe("PubOp", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }).set(pubFields["Title"].slug, "New Related Pub"), "the second related pub" ); @@ -343,20 +320,16 @@ describe("PubOp", () => { }); it("should handle circular relations", async () => { - const trx = getTrx(); - const pub1 = PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }).set(pubFields["Title"].slug, "Pub 1"); const pub2 = PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Pub 2") .connect(pubFields["Some relation"].slug, pub1, "the first related pub"); @@ -386,12 +359,10 @@ describe("PubOp", () => { }); it("should fail if you try to createWithId a pub that already exists", async () => { - const trx = getTrx(); const pubOp = PubOp.createWithId(pubs[0].id, { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }); await expect(pubOp.execute()).rejects.toThrow( @@ -400,12 +371,10 @@ describe("PubOp", () => { }); it("should update the value of a relationship", async () => { - const trx = getTrx(); const pub1 = await PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Pub 1") .execute(); @@ -414,7 +383,6 @@ describe("PubOp", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Pub 2") .connect(pubFields["Some relation"].slug, pub1.id, "initial value") @@ -424,7 +392,6 @@ describe("PubOp", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .connect(pubFields["Some relation"].slug, pub1.id, "updated value") .execute(); @@ -442,14 +409,11 @@ describe("PubOp", () => { describe("relation management", () => { it("should disconnect a specific relation", async () => { - const trx = getTrx(); - // Create two pubs to relate const pub1 = await PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Pub 1") .execute(); @@ -458,7 +422,6 @@ describe("relation management", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Pub 2") .connect(pubFields["Some relation"].slug, pub1.id, "initial value") @@ -469,7 +432,6 @@ describe("relation management", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .disconnect(pubFields["Some relation"].slug, pub1.id) .execute(); @@ -478,14 +440,11 @@ describe("relation management", () => { }); it("should delete orphaned pubs when disconnecting relations", async () => { - const trx = getTrx(); - // Create a pub that will become orphaned const orphanedPub = await PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Soon to be orphaned") .execute(); @@ -495,7 +454,6 @@ describe("relation management", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Main pub") .connect(pubFields["Some relation"].slug, orphanedPub.id, "only relation") @@ -506,7 +464,6 @@ describe("relation management", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .disconnect(pubFields["Some relation"].slug, orphanedPub.id, { deleteOrphaned: true }) .execute(); @@ -515,21 +472,17 @@ describe("relation management", () => { }); it("should clear all relations for a specific field", async () => { - const trx = getTrx(); - // Create multiple related pubs const related1 = PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }).set(pubFields["Title"].slug, "Related 1"); const related2 = PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }).set(pubFields["Title"].slug, "Related 2"); // Create main pub with multiple relations @@ -537,7 +490,6 @@ describe("relation management", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Main pub") .connect(pubFields["Some relation"].slug, related1, "relation 1") @@ -549,7 +501,6 @@ describe("relation management", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .clearRelationsForField(pubFields["Some relation"].slug) .execute(); @@ -560,21 +511,17 @@ describe("relation management", () => { }); it("should override existing relations when using override option", async () => { - const trx = getTrx(); - // Create initial related pubs const related1 = PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }).set(pubFields["Title"].slug, "Related 1"); const related2 = PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }).set(pubFields["Title"].slug, "Related 2"); // Create main pub with initial relations @@ -582,18 +529,23 @@ describe("relation management", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Main pub") .connect(pubFields["Some relation"].slug, related1, "relation 1") .connect(pubFields["Some relation"].slug, related2, "relation 2") .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: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Related 3") .execute(); @@ -603,7 +555,6 @@ describe("relation management", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .connect(pubFields["Some relation"].slug, related3.id, "new relation", { override: true, @@ -618,17 +569,18 @@ describe("relation management", () => { 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 trx = getTrx(); - // Create related pubs const related1 = await PubOp.create({ communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Related 1") .execute(); @@ -637,7 +589,6 @@ describe("relation management", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Related 2") .execute(); @@ -647,25 +598,41 @@ describe("relation management", () => { communityId: community.id, pubTypeId: pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - trx, }) .set(pubFields["Title"].slug, "Main pub") .connect(pubFields["Some relation"].slug, related1.id, "relation 1", { override: true }) + .execute(); + + const updatedMainPub = await PubOp.update(mainPub.id, { + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) .connect(pubFields["Some relation"].slug, related2.id, "relation 2", { override: true }) + .connect( + pubFields["Some relation"].slug, + PubOp.create({ + communityId: community.id, + pubTypeId: pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }), + "relation 3", + { override: true } + ) .execute(); - // Should have both relations since they were part of the same override operation - expect(mainPub).toHaveValues([ + // Should have relation 2 and 3, but not 1 + expect(updatedMainPub).toHaveValues([ { fieldSlug: pubFields["Title"].slug, value: "Main pub" }, { fieldSlug: pubFields["Some relation"].slug, - value: "relation 1", - relatedPubId: related1.id, + value: "relation 2", + relatedPubId: related2.id, }, { fieldSlug: pubFields["Some relation"].slug, - value: "relation 2", - relatedPubId: related2.id, + value: "relation 3", + relatedPubId: expect.any(String), }, ]); }); diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index cc7eaba38..444f93c69 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -1,17 +1,8 @@ import type { Transaction } from "kysely"; -import { sql } from "kysely"; - import type { JsonValue, ProcessedPub } from "contracts"; import type { Database } from "db/Database"; -import type { - CommunitiesId, - CoreSchemaType, - PubFieldsId, - PubsId, - PubTypesId, - PubValuesId, -} from "db/public"; +import type { CommunitiesId, PubsId, PubTypesId, PubValuesId } from "db/public"; import type { LastModifiedBy } from "db/types"; import { assert, expect } from "utils"; import { isUuid } from "utils/uuid"; @@ -37,10 +28,26 @@ type PubOpOptions = { trx?: Transaction; }; -type RelationOptions = { - override?: boolean; - deleteOrphaned?: boolean; -}; +type RelationOptions = + | { + /** + * If true, existing relations on the same field will be removed + */ + override?: false; + deleteOrphaned?: never; + } + | { + /** + * If true, existing relations on the same field will be removed + */ + override: true; + /** + * If true, pubs that have been disconnected, + * either manually or because they were orphaned because of `override: true`, + * will be deleted. + */ + deleteOrphaned?: boolean; + }; // Base commands that will be used internally type SetCommand = { type: "set"; slug: string; value: PubValue | undefined }; @@ -74,6 +81,30 @@ type PubOpCommand = | ClearRelationsCommand | UnsetCommand; +type ClearRelationOperation = { + type: "clear"; + slug: string; + deleteOrphaned?: boolean; +}; + +type RemoveRelationOperation = { + type: "remove"; + slug: string; + target: PubsId; + deleteOrphaned?: boolean; +}; + +type OverrideRelationOperation = { + type: "override"; + slug: string; + deleteOrphaned?: boolean; +}; + +type RelationOperation = + | ClearRelationOperation + | RemoveRelationOperation + | OverrideRelationOperation; + // Types for operation collection type OperationMode = "create" | "upsert" | "update"; @@ -115,15 +146,6 @@ function isPubId(val: string | PubsId): val is PubsId { type PubOpMode = "create" | "upsert" | "update"; type PubIdMap = Map; -type RelationModification = { - slug: string; - relatedPubId: PubsId | null; -}; -type RelationModifications = { - overrides: Map; - clears: Map; - removes: Map; -}; // Common operations available to all PubOp types abstract class BasePubOp { @@ -161,18 +183,6 @@ abstract class BasePubOp { return this; } - /** - * Unset a value for a specific field - */ - unset(slug: string): this { - this.commands.push({ - type: "set", - slug, - value: undefined, - }); - return this; - } - /** * Connect to one or more pubs with the same value */ @@ -342,6 +352,29 @@ abstract class BasePubOp { return this.resolvePubId(this.getOperationKey(), idMap); } + /** + * 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 + */ + private fillCreateResultHoles( + pubsToCreate: Array<{ id?: PubsId }>, + pubCreateResult: Array<{ id: PubsId }> + ) { + let index = 0; + return pubsToCreate.map((pubToCreate) => { + const correspondingResult = pubCreateResult[index]; + + if (pubToCreate.id && pubToCreate.id !== correspondingResult?.id) { + return null; + } + + index++; + return correspondingResult || null; + }); + } + private async createAllPubs( trx: Transaction, operations: OperationsMap @@ -353,7 +386,6 @@ abstract class BasePubOp { pubTypeId: this.options.pubTypeId, })); - // Create pubs and handle conflicts const createdPubs = await autoRevalidate( trx .insertInto("pubs") @@ -362,29 +394,44 @@ abstract class BasePubOp { .returningAll() ).execute(); - // Map IDs, handling both new and existing pubs - let createdIndex = 0; - let operationIndex = 0; + // fill any gaps in the array of created pubs + const filledCreatedPubs = this.fillCreateResultHoles(pubsToCreate, createdPubs); + + let index = 0; + // map each operation to its final pub id for (const [key, op] of operations) { - const createdPub = createdPubs[createdIndex]; - const pubToCreate = pubsToCreate[operationIndex]; + const createdPub = filledCreatedPubs[index]; + const pubToCreate = pubsToCreate[index]; + // if we successfully created a new pub, use its id if (createdPub) { idMap.set(key, createdPub.id); - createdIndex++; - } else if (pubToCreate.id) { + index++; + continue; + } + + // if we had an existing id..., ie it was provided for an upsert or create + if (pubToCreate.id) { + // ...but were trying to create a new pub, that's an error, because there's no pub that was created + // that means we were trying to create a pub with an id that already exists if (op.mode === "create") { throw new Error( `Cannot create a pub with an id that already exists: ${pubToCreate.id}` ); } idMap.set(key, pubToCreate.id); - } else if (typeof key === "symbol") { + index++; + continue; + } + + // we have symbol key (no id provided) but no pub was created. that's not good + if (typeof key === "symbol") { throw new Error("Pub not created"); - } else { - idMap.set(key, key); } - operationIndex++; + + // fallback - use the key as the id i guess? + idMap.set(key, key); + index++; } return idMap; @@ -395,50 +442,179 @@ abstract class BasePubOp { operations: OperationsMap, idMap: PubIdMap ): Promise { + // First collect ALL relation operations, including nested ones + // const allRelationOps = new Map< + // PubsId, + // { + // override: { + // slug: string; + // deleteOrphaned?: boolean; + // }[]; + // clear: { + // slug: string; + // deleteOrphaned?: boolean; + // }[]; + // remove: { + // slug: string; + // target: PubsId; + // deleteOrphaned?: boolean; + // }[]; + // } + // >(); + + // Collect relation operations from all pubs (including nested ones) + // for (const [key, op] of operations) { + // const pubId = this.resolvePubId(key, idMap); + // const relationOps = { + // override: op.relationsToAdd + // .filter((r) => r.override) + // .map((r) => ({ + // slug: r.slug, + // deleteOrphaned: r.deleteOrphaned, + // })), + // clear: op.relationsToClear.map((r) => ({ + // slug: r.slug, + // deleteOrphaned: r.deleteOrphaned, + // })), + // remove: op.relationsToRemove.map((r) => ({ + // slug: r.slug, + // target: r.target, + // deleteOrphaned: r.deleteOrphaned, + // })), + // }; + + // if ( + // relationOps.override.length > 0 || + // relationOps.clear.length > 0 || + // relationOps.remove.length > 0 + // ) { + // allRelationOps.set(pubId, relationOps); + // } + // } + + // Process all relation operations for (const [key, op] of operations) { const pubId = this.resolvePubId(key, idMap); - // Skip if no relation changes - const modifications = { - overrides: this.groupBySlug(op.relationsToAdd.filter((cmd) => cmd.override)), - clears: this.groupBySlug(op.relationsToClear), - removes: this.groupBySlug(op.relationsToRemove), - }; + const allOps = [ + ...op.relationsToAdd + .filter((r) => r.override) + .map((r) => ({ type: "override", ...r })), + ...op.relationsToClear.map((r) => ({ type: "clear", ...r })), + ...op.relationsToRemove.map((r) => ({ type: "remove", ...r })), + ] as RelationOperation[]; - if (!this.hasModifications(modifications)) { + if (allOps.length === 0) { continue; } - // Find and remove existing relations + // 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", [ - ...modifications.overrides.keys(), - ...modifications.clears.keys(), - ...modifications.removes.keys(), - ]) + .where( + "slug", + "in", + allOps.map((op) => op.slug) + ) + .$narrowType<{ relatedPubId: PubsId }>() .execute(); - const relationsToRemove = existingRelations.filter( - (r) => !modifications.overrides.has(r.id) - ); + // 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 (relationsToRemove.length === 0) { - return; + if (relationsToDelete.length === 0) { + continue; } + // delete the relation values only await deletePubValuesByValueId({ pubId, - valueIds: relationsToRemove.map((r) => r.id), + valueIds: relationsToDelete.map((r) => r.id), lastModifiedBy: this.options.lastModifiedBy, trx, }); - // Handle orphaned pubs if needed - await this.cleanupOrphanedPubs(trx, relationsToRemove, modifications); + // check which relations should also be removed due to being orphaned + const relationsToCheckForOrphans = relationsToDelete.filter((relation) => { + return allOps.some((relationOp) => { + if (relationOp.slug !== relation.slug) { + return false; + } + + if (!relationOp.deleteOrphaned) { + return false; + } + + switch (relationOp.type) { + case "clear": + return true; + case "remove": + return relationOp.target === relation.relatedPubId; + case "override": + return true; + } + }); + }); + + if (!relationsToCheckForOrphans.length) { + continue; + } + + await this.cleanupOrphanedPubs(trx, 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 + */ + private async cleanupOrphanedPubs( + trx: Transaction, + removedRelations: Array<{ relatedPubId: PubsId }> + ): Promise { + const orphanedIds = removedRelations.map((r) => r.relatedPubId); + if (orphanedIds.length === 0) { + return; + } + + const trulyOrphaned = await trx + .selectFrom("pubs as p") + .select("p.id") + .leftJoin("pub_values as pv", "pv.relatedPubId", "p.id") + .where("p.id", "in", orphanedIds) + .groupBy("p.id") + .having((eb) => eb.fn.count("pv.id"), "=", 0) + .execute(); + + if (trulyOrphaned.length > 0) { + await deletePub({ + pubId: trulyOrphaned.map((p) => p.id), + communityId: this.options.communityId, + lastModifiedBy: this.options.lastModifiedBy, + trx, + }); } } @@ -502,12 +678,6 @@ abstract class BasePubOp { // --- Helper methods --- - private hasModifications(mods: { - [K in "overrides" | "clears" | "removes"]: ReturnType; - }): boolean { - return mods.overrides.size > 0 || mods.clears.size > 0 || mods.removes.size > 0; - } - private groupBySlug(items: T[]): Map { return items.reduce((map, item) => { const existing = map.get(item.slug) ?? []; @@ -517,43 +687,6 @@ abstract class BasePubOp { }, new Map()); } - private async cleanupOrphanedPubs( - trx: Transaction, - removedRelations: Array<{ relatedPubId: PubsId | null }>, - modifications: Record>> - ): Promise { - const shouldDelete = Object.values(modifications) - .flatMap((m) => Array.from(m.values())) - .flat() - .some((m) => m.deleteOrphaned); - - if (!shouldDelete) return; - - const orphanedIds = removedRelations - .map((r) => r.relatedPubId) - .filter((id): id is PubsId => id !== null); - - if (orphanedIds.length === 0) return; - - const trulyOrphaned = await trx - .selectFrom("pubs as p") - .select("p.id") - .leftJoin("pub_values as pv", "pv.relatedPubId", "p.id") - .where("p.id", "in", orphanedIds) - .groupBy("p.id") - .having((eb) => eb.fn.count("pv.id"), "=", 0) - .execute(); - - if (trulyOrphaned.length > 0) { - await deletePub({ - pubId: trulyOrphaned.map((p) => p.id), - communityId: this.options.communityId, - lastModifiedBy: this.options.lastModifiedBy, - trx, - }); - } - } - private partitionValidatedValues(validated: Array) { return { values: validated From 18b38c6f7cb70fb369713b5fc588ec056e909a68 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Feb 2025 18:51:06 +0100 Subject: [PATCH 05/23] feat: allow to properly purge orphans --- core/lib/__tests__/matchers.ts | 6 +- core/lib/server/pub-op.db.test.ts | 778 +++++++++++++++++++++++------- core/lib/server/pub-op.ts | 150 +++--- core/lib/server/pub.db.test.ts | 3 +- core/prisma/seed/createSeed.ts | 55 +++ core/prisma/seed/seedCommunity.ts | 45 +- 6 files changed, 756 insertions(+), 281 deletions(-) create mode 100644 core/prisma/seed/createSeed.ts diff --git a/core/lib/__tests__/matchers.ts b/core/lib/__tests__/matchers.ts index 715b7538d..2274c5bf7 100644 --- a/core/lib/__tests__/matchers.ts +++ b/core/lib/__tests__/matchers.ts @@ -19,9 +19,9 @@ expect.extend({ return { pass, message: () => - pass - ? `Expected pub with ID ${received} ${isNot ? "not" : ""} to exist, and it does ${isNot ? "not" : ""}` - : `Expected pub with ID ${received} ${isNot ? "not to" : "to"} exist, but it does not`, + 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"}`, }; }, diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index 4a8d4913c..0c1a9de0b 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -1,15 +1,18 @@ -import { describe, expect, expectTypeOf, it, vitest } from "vitest"; +import { beforeAll, beforeEach, describe, expect, expectTypeOf, it, vitest } from "vitest"; import type { PubsId, PubTypes, Stages } from "db/public"; import { CoreSchemaType, MemberRole } from "db/public"; +import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; +import type { seedCommunity } from "~/prisma/seed/seedCommunity"; import { mockServerCode } from "~/lib/__tests__/utils"; import { createLastModifiedBy } from "../lastModifiedBy"; import { PubOp } from "./pub-op"; -const { createSeed, seedCommunity } = await import("~/prisma/seed/seedCommunity"); +const { createSeed } = await import("~/prisma/seed/createSeed"); const { createForEachMockedTransaction } = await mockServerCode(); +const { getTrx, rollback, commit } = createForEachMockedTransaction(); const seed = createSeed({ community: { @@ -84,14 +87,19 @@ const seed = createSeed({ ], }); -const { community, pubFields, pubTypes, stages, pubs, users } = await seedCommunity(seed); +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: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }); @@ -102,8 +110,8 @@ describe("PubOp", () => { it("should not fail when upserting existing pub", async () => { const id = crypto.randomUUID() as PubsId; const pubOp = PubOp.upsert(id, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }); @@ -111,8 +119,8 @@ describe("PubOp", () => { await expect(pub.id).toExist(); const pub2 = await PubOp.upsert(id, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }).execute(); @@ -122,13 +130,13 @@ describe("PubOp", () => { it("should create a new pub and set values", async () => { const id = crypto.randomUUID() as PubsId; const pubOp = PubOp.upsert(id, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Some title") + .set(seededCommunity.pubFields["Title"].slug, "Some title") .set({ - [pubFields["Description"].slug]: "Some description", + [seededCommunity.pubFields["Description"].slug]: "Some description", }); const pub = await pubOp.execute(); @@ -136,11 +144,11 @@ describe("PubOp", () => { expect(pub).toHaveValues([ { - fieldSlug: pubFields["Description"].slug, + fieldSlug: seededCommunity.pubFields["Description"].slug, value: "Some description", }, { - fieldSlug: pubFields["Title"].slug, + fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Some title", }, ]); @@ -148,8 +156,8 @@ describe("PubOp", () => { it("should be able to relate existing pubs", async () => { const pubOp = PubOp.upsert(crypto.randomUUID() as PubsId, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }); @@ -158,17 +166,21 @@ describe("PubOp", () => { await expect(pub.id).toExist(); const pub2 = await PubOp.upsert(crypto.randomUUID() as PubsId, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .connect(pubFields["Some relation"].slug, pub.id, "test relations value") + .connect( + seededCommunity.pubFields["Some relation"].slug, + pub.id, + "test relations value" + ) .execute(); await expect(pub2.id).toExist(); expect(pub2).toHaveValues([ { - fieldSlug: pubFields["Some relation"].slug, + fieldSlug: seededCommunity.pubFields["Some relation"].slug, value: "test relations value", relatedPubId: pub.id, }, @@ -177,41 +189,41 @@ describe("PubOp", () => { it("should create multiple related pubs in a single operation", async () => { const mainPub = PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Main Pub") + .set(seededCommunity.pubFields["Title"].slug, "Main Pub") .connect( - pubFields["Some relation"].slug, + seededCommunity.pubFields["Some relation"].slug, PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - }).set(pubFields["Title"].slug, "Related Pub 1"), + }).set(seededCommunity.pubFields["Title"].slug, "Related Pub 1"), "the first related pub" ) .connect( - pubFields["Another relation"].slug, + seededCommunity.pubFields["Another relation"].slug, PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - }).set(pubFields["Title"].slug, "Related Pub 2"), + }).set(seededCommunity.pubFields["Title"].slug, "Related Pub 2"), "the second related pub" ); const result = await mainPub.execute(); expect(result).toHaveValues([ - { fieldSlug: pubFields["Title"].slug, value: "Main Pub" }, + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Main Pub" }, { - fieldSlug: pubFields["Some relation"].slug, + fieldSlug: seededCommunity.pubFields["Some relation"].slug, value: "the first related pub", relatedPubId: expect.any(String), }, { - fieldSlug: pubFields["Another relation"].slug, + fieldSlug: seededCommunity.pubFields["Another relation"].slug, value: "the second related pub", relatedPubId: expect.any(String), }, @@ -220,45 +232,49 @@ describe("PubOp", () => { it("should handle deeply nested relations", async () => { const relatedPub = PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Level 1") + .set(seededCommunity.pubFields["Title"].slug, "Level 1") .connect( - pubFields["Another relation"].slug, + seededCommunity.pubFields["Another relation"].slug, PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - }).set(pubFields["Title"].slug, "Level 2"), + }).set(seededCommunity.pubFields["Title"].slug, "Level 2"), "the second related pub" ); const mainPub = PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Root") - .connect(pubFields["Some relation"].slug, relatedPub, "the first related pub"); + .set(seededCommunity.pubFields["Title"].slug, "Root") + .connect( + seededCommunity.pubFields["Some relation"].slug, + relatedPub, + "the first related pub" + ); const result = await mainPub.execute(); expect(result).toHaveValues([ - { fieldSlug: pubFields["Title"].slug, value: "Root" }, + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Root" }, { - fieldSlug: pubFields["Some relation"].slug, + fieldSlug: seededCommunity.pubFields["Some relation"].slug, value: "the first related pub", relatedPubId: expect.any(String), relatedPub: { values: [ { - fieldSlug: pubFields["Title"].slug, + fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Level 1", }, { - fieldSlug: pubFields["Another relation"].slug, + fieldSlug: seededCommunity.pubFields["Another relation"].slug, value: "the second related pub", relatedPubId: expect.any(String), }, @@ -271,49 +287,63 @@ describe("PubOp", () => { 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: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Existing Pub") + .set(seededCommunity.pubFields["Title"].slug, "Existing Pub") .execute(); const mainPub = PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Main Pub") - .connect(pubFields["Some relation"].slug, existingPub.id, "the first related pub") + .set(seededCommunity.pubFields["Title"].slug, "Main Pub") .connect( - pubFields["Another relation"].slug, + seededCommunity.pubFields["Some relation"].slug, + existingPub.id, + "the first related pub" + ) + .connect( + seededCommunity.pubFields["Another relation"].slug, PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - }).set(pubFields["Title"].slug, "New Related Pub"), + }).set(seededCommunity.pubFields["Title"].slug, "New Related Pub"), "the second related pub" ); const result = await mainPub.execute(); expect(result).toHaveValues([ - { fieldSlug: pubFields["Title"].slug, value: "Main Pub" }, + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Main Pub" }, { - fieldSlug: pubFields["Some relation"].slug, + fieldSlug: seededCommunity.pubFields["Some relation"].slug, value: "the first related pub", relatedPubId: existingPub.id, relatedPub: { id: existingPub.id, - values: [{ fieldSlug: pubFields["Title"].slug, value: "Existing Pub" }], + values: [ + { + fieldSlug: seededCommunity.pubFields["Title"].slug, + value: "Existing Pub", + }, + ], }, }, { - fieldSlug: pubFields["Another relation"].slug, + fieldSlug: seededCommunity.pubFields["Another relation"].slug, value: "the second related pub", relatedPubId: expect.any(String), relatedPub: { - values: [{ fieldSlug: pubFields["Title"].slug, value: "New Related Pub" }], + values: [ + { + fieldSlug: seededCommunity.pubFields["Title"].slug, + value: "New Related Pub", + }, + ], }, }, ]); @@ -321,34 +351,42 @@ describe("PubOp", () => { it("should handle circular relations", async () => { const pub1 = PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - }).set(pubFields["Title"].slug, "Pub 1"); + }).set(seededCommunity.pubFields["Title"].slug, "Pub 1"); const pub2 = PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Pub 2") - .connect(pubFields["Some relation"].slug, pub1, "the first related pub"); + .set(seededCommunity.pubFields["Title"].slug, "Pub 2") + .connect( + seededCommunity.pubFields["Some relation"].slug, + pub1, + "the first related pub" + ); - pub1.connect(pubFields["Another relation"].slug, pub2, "the second related pub"); + pub1.connect( + seededCommunity.pubFields["Another relation"].slug, + pub2, + "the second related pub" + ); const result = await pub1.execute(); expect(result).toHaveValues([ - { fieldSlug: pubFields["Title"].slug, value: "Pub 1" }, + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 1" }, { - fieldSlug: pubFields["Another relation"].slug, + fieldSlug: seededCommunity.pubFields["Another relation"].slug, value: "the second related pub", relatedPubId: expect.any(String), relatedPub: { values: [ - { fieldSlug: pubFields["Title"].slug, value: "Pub 2" }, + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 2" }, { - fieldSlug: pubFields["Some relation"].slug, + fieldSlug: seededCommunity.pubFields["Some relation"].slug, value: "the first related pub", relatedPubId: result.id, }, @@ -359,9 +397,9 @@ describe("PubOp", () => { }); it("should fail if you try to createWithId a pub that already exists", async () => { - const pubOp = PubOp.createWithId(pubs[0].id, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + const pubOp = PubOp.createWithId(seededCommunity.pubs[0].id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }); @@ -372,34 +410,34 @@ describe("PubOp", () => { it("should update the value of a relationship", async () => { const pub1 = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Pub 1") + .set(seededCommunity.pubFields["Title"].slug, "Pub 1") .execute(); const pub2 = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Pub 2") - .connect(pubFields["Some relation"].slug, pub1.id, "initial value") + .set(seededCommunity.pubFields["Title"].slug, "Pub 2") + .connect(seededCommunity.pubFields["Some relation"].slug, pub1.id, "initial value") .execute(); const updatedPub = await PubOp.upsert(pub2.id, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .connect(pubFields["Some relation"].slug, pub1.id, "updated value") + .connect(seededCommunity.pubFields["Some relation"].slug, pub1.id, "updated value") .execute(); expect(updatedPub).toHaveValues([ - { fieldSlug: pubFields["Title"].slug, value: "Pub 2" }, + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 2" }, { - fieldSlug: pubFields["Some relation"].slug, + fieldSlug: seededCommunity.pubFields["Some relation"].slug, value: "updated value", relatedPubId: pub1.id, }, @@ -411,61 +449,69 @@ describe("relation management", () => { it("should disconnect a specific relation", async () => { // Create two pubs to relate const pub1 = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Pub 1") + .set(seededCommunity.pubFields["Title"].slug, "Pub 1") .execute(); const pub2 = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Pub 2") - .connect(pubFields["Some relation"].slug, pub1.id, "initial value") + .set(seededCommunity.pubFields["Title"].slug, "Pub 2") + .connect(seededCommunity.pubFields["Some relation"].slug, pub1.id, "initial value") .execute(); // Disconnect the relation const updatedPub = await PubOp.update(pub2.id, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .disconnect(pubFields["Some relation"].slug, pub1.id) + .disconnect(seededCommunity.pubFields["Some relation"].slug, pub1.id) .execute(); - expect(updatedPub).toHaveValues([{ fieldSlug: pubFields["Title"].slug, value: "Pub 2" }]); + expect(updatedPub).toHaveValues([ + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Pub 2" }, + ]); }); it("should delete orphaned pubs when disconnecting relations", async () => { // Create a pub that will become orphaned const orphanedPub = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Soon to be orphaned") + .set(seededCommunity.pubFields["Title"].slug, "Soon to be orphaned") .execute(); // Create a pub that relates to it const mainPub = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Main pub") - .connect(pubFields["Some relation"].slug, orphanedPub.id, "only relation") + .set(seededCommunity.pubFields["Title"].slug, "Main pub") + .connect( + seededCommunity.pubFields["Some relation"].slug, + orphanedPub.id, + "only relation" + ) .execute(); // Disconnect with deleteOrphaned option await PubOp.update(mainPub.id, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .disconnect(pubFields["Some relation"].slug, orphanedPub.id, { deleteOrphaned: true }) + .disconnect(seededCommunity.pubFields["Some relation"].slug, orphanedPub.id, { + deleteOrphaned: true, + }) .execute(); await expect(orphanedPub.id).not.toExist(); @@ -474,65 +520,65 @@ describe("relation management", () => { it("should clear all relations for a specific field", async () => { // Create multiple related pubs const related1 = PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - }).set(pubFields["Title"].slug, "Related 1"); + }).set(seededCommunity.pubFields["Title"].slug, "Related 1"); const related2 = PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - }).set(pubFields["Title"].slug, "Related 2"); + }).set(seededCommunity.pubFields["Title"].slug, "Related 2"); // Create main pub with multiple relations const mainPub = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Main pub") - .connect(pubFields["Some relation"].slug, related1, "relation 1") - .connect(pubFields["Some relation"].slug, related2, "relation 2") + .set(seededCommunity.pubFields["Title"].slug, "Main pub") + .connect(seededCommunity.pubFields["Some relation"].slug, related1, "relation 1") + .connect(seededCommunity.pubFields["Some relation"].slug, related2, "relation 2") .execute(); // Clear all relations for the field const updatedPub = await PubOp.update(mainPub.id, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .clearRelationsForField(pubFields["Some relation"].slug) + .clearRelationsForField(seededCommunity.pubFields["Some relation"].slug) .execute(); expect(updatedPub).toHaveValues([ - { fieldSlug: pubFields["Title"].slug, value: "Main pub" }, + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Main pub" }, ]); }); it("should override existing relations when using override option", async () => { // Create initial related pubs const related1 = PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - }).set(pubFields["Title"].slug, "Related 1"); + }).set(seededCommunity.pubFields["Title"].slug, "Related 1"); const related2 = PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), - }).set(pubFields["Title"].slug, "Related 2"); + }).set(seededCommunity.pubFields["Title"].slug, "Related 2"); // Create main pub with initial relations const mainPub = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Main pub") - .connect(pubFields["Some relation"].slug, related1, "relation 1") - .connect(pubFields["Some relation"].slug, related2, "relation 2") + .set(seededCommunity.pubFields["Title"].slug, "Main pub") + .connect(seededCommunity.pubFields["Some relation"].slug, related1, "relation 1") + .connect(seededCommunity.pubFields["Some relation"].slug, related2, "relation 2") .execute(); const relatedPub1 = mainPub.values.find((v) => v.value === "relation 1")?.relatedPubId; @@ -543,28 +589,28 @@ describe("relation management", () => { await expect(relatedPub2).toExist(); const related3 = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Related 3") + .set(seededCommunity.pubFields["Title"].slug, "Related 3") .execute(); // Update with override - only related3 should remain const updatedPub = await PubOp.upsert(mainPub.id, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .connect(pubFields["Some relation"].slug, related3.id, "new relation", { + .connect(seededCommunity.pubFields["Some relation"].slug, related3.id, "new relation", { override: true, }) .execute(); expect(updatedPub).toHaveValues([ - { fieldSlug: pubFields["Title"].slug, value: "Main pub" }, + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Main pub" }, { - fieldSlug: pubFields["Some relation"].slug, + fieldSlug: seededCommunity.pubFields["Some relation"].slug, value: "new relation", relatedPubId: related3.id, }, @@ -578,42 +624,46 @@ describe("relation management", () => { it("should handle multiple override relations for the same field", async () => { // Create related pubs const related1 = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Related 1") + .set(seededCommunity.pubFields["Title"].slug, "Related 1") .execute(); const related2 = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Related 2") + .set(seededCommunity.pubFields["Title"].slug, "Related 2") .execute(); // Create main pub and set multiple relations with override const mainPub = await PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .set(pubFields["Title"].slug, "Main pub") - .connect(pubFields["Some relation"].slug, related1.id, "relation 1", { override: true }) + .set(seededCommunity.pubFields["Title"].slug, "Main pub") + .connect(seededCommunity.pubFields["Some relation"].slug, related1.id, "relation 1", { + override: true, + }) .execute(); const updatedMainPub = await PubOp.update(mainPub.id, { - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .connect(pubFields["Some relation"].slug, related2.id, "relation 2", { override: true }) + .connect(seededCommunity.pubFields["Some relation"].slug, related2.id, "relation 2", { + override: true, + }) .connect( - pubFields["Some relation"].slug, + seededCommunity.pubFields["Some relation"].slug, PubOp.create({ - communityId: community.id, - pubTypeId: pubTypes["Basic Pub"].id, + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }), "relation 3", @@ -623,17 +673,411 @@ describe("relation management", () => { // Should have relation 2 and 3, but not 1 expect(updatedMainPub).toHaveValues([ - { fieldSlug: pubFields["Title"].slug, value: "Main pub" }, + { fieldSlug: seededCommunity.pubFields["Title"].slug, value: "Main pub" }, { - fieldSlug: pubFields["Some relation"].slug, + fieldSlug: seededCommunity.pubFields["Some relation"].slug, value: "relation 2", relatedPubId: related2.id, }, { - fieldSlug: pubFields["Some relation"].slug, + 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") + .connect(seededCommunity.pubFields["Some relation"].slug, pubL_op, "to L"); + + 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") + .connect(seededCommunity.pubFields["Some relation"].slug, pubK_op, "to K") + .connect(seededCommunity.pubFields["Some relation"].slug, pubL_op, "to L"); + + 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") + .connect(seededCommunity.pubFields["Some relation"].slug, pubF_op, "to F"); + + 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") + .connect(seededCommunity.pubFields["Some relation"].slug, pubE_op, "to E"); + + 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") + .connect(seededCommunity.pubFields["Some relation"].slug, pubH_op, "to H"); + + 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") + .connect(seededCommunity.pubFields["Some relation"].slug, pubG_op, "to G"); + + 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") + .connect(seededCommunity.pubFields["Some relation"].slug, pubI_op, "to I") + .connect(seededCommunity.pubFields["Some relation"].slug, pubD_op, "to D") + .connect(seededCommunity.pubFields["Some relation"].slug, pubE_op, "to E"); + + // Create root + 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") + .connect(seededCommunity.pubFields["Some relation"].slug, pubB_op, "to B") + .connect(seededCommunity.pubFields["Some relation"].slug, pubC_op, "to C") + .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") + .connect(seededCommunity.pubFields["Some relation"].slug, pubI, "to I") + .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.values).toMatchObject([ + { 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 I", + relatedPubId: pubI, + relatedPub: { + values: [{ value: "I" }], + }, + }, + { + 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, + }, + ], + }, + }, + ]); + + // Now disconnect 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, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + trx, + }) + .disconnect(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 connected through G + await expect(pubF, "F should exist").toExist(trx); + await expect(pubG, "G should exist").toExist(trx); + await expect(pubH, "H should not exist").not.toExist(trx); + await expect(pubI, "I should exist").toExist(trx); // Still connected through J + await expect(pubJ, "J should exist").toExist(trx); + 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") + .connect(seededCommunity.pubFields["Some relation"].slug, related1.id, "relation1") + .connect(seededCommunity.pubFields["Another relation"].slug, related2.id, "relation2") + .execute(); + + // Clear one field with deleteOrphaned and one without + await PubOp.update(mainPub.id, { + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .clearRelationsForField(seededCommunity.pubFields["Some relation"].slug, { + deleteOrphaned: true, + }) + .clearRelationsForField(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") + .connect(seededCommunity.pubFields["Some relation"].slug, toKeep.id, "keep") + .connect(seededCommunity.pubFields["Another relation"].slug, toDelete.id, "delete") + .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, + pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }) + .connect(seededCommunity.pubFields["Some relation"].slug, newRelation, "new", { + override: true, + }) + .connect(seededCommunity.pubFields["Another relation"].slug, newRelation, "also new", { + override: 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(); + }); }); diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 444f93c69..a346cdf68 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -1,5 +1,8 @@ import type { Transaction } from "kysely"; +import { sql } from "kysely"; +import { jsonArrayFrom } from "kysely/helpers/postgres"; + import type { JsonValue, ProcessedPub } from "contracts"; import type { Database } from "db/Database"; import type { CommunitiesId, PubsId, PubTypesId, PubValuesId } from "db/public"; @@ -303,7 +306,7 @@ abstract class BasePubOp { rootOp.relationsToAdd.push({ slug: cmd.slug, value: relation.value, - target: relation.target.thisSymbol, + target: relation.target.getOperationKey(), override: cmd.options.override, deleteOrphaned: cmd.options.deleteOrphaned, }); @@ -442,57 +445,8 @@ abstract class BasePubOp { operations: OperationsMap, idMap: PubIdMap ): Promise { - // First collect ALL relation operations, including nested ones - // const allRelationOps = new Map< - // PubsId, - // { - // override: { - // slug: string; - // deleteOrphaned?: boolean; - // }[]; - // clear: { - // slug: string; - // deleteOrphaned?: boolean; - // }[]; - // remove: { - // slug: string; - // target: PubsId; - // deleteOrphaned?: boolean; - // }[]; - // } - // >(); - - // Collect relation operations from all pubs (including nested ones) - // for (const [key, op] of operations) { - // const pubId = this.resolvePubId(key, idMap); - // const relationOps = { - // override: op.relationsToAdd - // .filter((r) => r.override) - // .map((r) => ({ - // slug: r.slug, - // deleteOrphaned: r.deleteOrphaned, - // })), - // clear: op.relationsToClear.map((r) => ({ - // slug: r.slug, - // deleteOrphaned: r.deleteOrphaned, - // })), - // remove: op.relationsToRemove.map((r) => ({ - // slug: r.slug, - // target: r.target, - // deleteOrphaned: r.deleteOrphaned, - // })), - // }; - - // if ( - // relationOps.override.length > 0 || - // relationOps.clear.length > 0 || - // relationOps.remove.length > 0 - // ) { - // allRelationOps.set(pubId, relationOps); - // } - // } - - // Process all relation operations + const relationsToCheckForOrphans = new Set(); + for (const [key, op] of operations) { const pubId = this.resolvePubId(key, idMap); @@ -553,7 +507,7 @@ abstract class BasePubOp { }); // check which relations should also be removed due to being orphaned - const relationsToCheckForOrphans = relationsToDelete.filter((relation) => { + const possiblyOrphanedRelations = relationsToDelete.filter((relation) => { return allOps.some((relationOp) => { if (relationOp.slug !== relation.slug) { return false; @@ -574,12 +528,16 @@ abstract class BasePubOp { }); }); - if (!relationsToCheckForOrphans.length) { + if (!possiblyOrphanedRelations.length) { continue; } - await this.cleanupOrphanedPubs(trx, relationsToCheckForOrphans); + possiblyOrphanedRelations.forEach((r) => { + relationsToCheckForOrphans.add(r.relatedPubId); + }); } + + await this.cleanupOrphanedPubs(trx, Array.from(relationsToCheckForOrphans)); } /** @@ -592,25 +550,76 @@ abstract class BasePubOp { */ private async cleanupOrphanedPubs( trx: Transaction, - removedRelations: Array<{ relatedPubId: PubsId }> + orphanedPubIds: PubsId[] ): Promise { - const orphanedIds = removedRelations.map((r) => r.relatedPubId); - if (orphanedIds.length === 0) { + if (orphanedPubIds.length === 0) { return; } - const trulyOrphaned = await trx - .selectFrom("pubs as p") - .select("p.id") - .leftJoin("pub_values as pv", "pv.relatedPubId", "p.id") - .where("p.id", "in", orphanedIds) - .groupBy("p.id") - .having((eb) => eb.fn.count("pv.id"), "=", 0) + 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 (trulyOrphaned.length > 0) { + if (pubsToDelete.length > 0) { await deletePub({ - pubId: trulyOrphaned.map((p) => p.id), + pubId: pubsToDelete.map((p) => p.id), communityId: this.options.communityId, lastModifiedBy: this.options.lastModifiedBy, trx, @@ -623,17 +632,16 @@ abstract class BasePubOp { operations: OperationsMap, idMap: PubIdMap ): Promise { - // Collect all values and relations to upsert const toUpsert = Array.from(operations.entries()).flatMap(([key, op]) => { const pubId = this.resolvePubId(key, idMap); return [ - // Regular values + // regular values ...op.values.map((v) => ({ pubId, slug: v.slug, value: v.value, })), - // Relations + // relations ...op.relationsToAdd.map((r) => ({ pubId, slug: r.slug, @@ -647,7 +655,6 @@ abstract class BasePubOp { return; } - // Validate and upsert const validated = await validatePubValues({ pubValues: toUpsert, communityId: this.options.communityId, @@ -657,7 +664,6 @@ abstract class BasePubOp { const { values, relations } = this.partitionValidatedValues(validated); - // Perform upserts in parallel await Promise.all([ values.length > 0 && upsertPubValues({ diff --git a/core/lib/server/pub.db.test.ts b/core/lib/server/pub.db.test.ts index a72dde2fc..fed93f820 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(); diff --git a/core/prisma/seed/createSeed.ts b/core/prisma/seed/createSeed.ts new file mode 100644 index 000000000..a98ca13cf --- /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 fb26048df..c9eb76024 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]; From db62ddad01df90ff54138002a6f655f9cd668568 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Feb 2025 19:47:30 +0100 Subject: [PATCH 06/23] fix: get rid of the concept of a pubId map and just pregenerate the ids --- core/lib/server/pub-op.db.test.ts | 46 ++++-- core/lib/server/pub-op.ts | 224 ++++++++++++++++-------------- 2 files changed, 156 insertions(+), 114 deletions(-) diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index 0c1a9de0b..2373b410e 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -426,6 +426,7 @@ describe("PubOp", () => { .connect(seededCommunity.pubFields["Some relation"].slug, pub1.id, "initial value") .execute(); + console.log(pub2.id); const updatedPub = await PubOp.upsert(pub2.id, { communityId: seededCommunity.community.id, pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, @@ -443,6 +444,40 @@ describe("PubOp", () => { }, ]); }); + + 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") + .connect( + seededCommunity.pubFields["Some relation"].slug, + PubOp.create({ + communityId: seededCommunity.community.id, + pubTypeId: seededCommunity.pubTypes["Minimal Pub"].id, + lastModifiedBy: createLastModifiedBy("system"), + }).set(seededCommunity.pubFields["Title"].slug, "Pub 2"), + "relation" + ) + .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("relation management", () => { @@ -468,7 +503,6 @@ describe("relation management", () => { // Disconnect the relation const updatedPub = await PubOp.update(pub2.id, { communityId: seededCommunity.community.id, - pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) .disconnect(seededCommunity.pubFields["Some relation"].slug, pub1.id) @@ -506,7 +540,6 @@ describe("relation management", () => { // Disconnect with deleteOrphaned option await PubOp.update(mainPub.id, { communityId: seededCommunity.community.id, - pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) .disconnect(seededCommunity.pubFields["Some relation"].slug, orphanedPub.id, { @@ -545,7 +578,6 @@ describe("relation management", () => { // Clear all relations for the field const updatedPub = await PubOp.update(mainPub.id, { communityId: seededCommunity.community.id, - pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) .clearRelationsForField(seededCommunity.pubFields["Some relation"].slug) @@ -653,7 +685,6 @@ describe("relation management", () => { const updatedMainPub = await PubOp.update(mainPub.id, { communityId: seededCommunity.community.id, - pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) .connect(seededCommunity.pubFields["Some relation"].slug, related2.id, "relation 2", { @@ -862,7 +893,9 @@ describe("relation management", () => { { value: "to F", relatedPubId: pubF, - relatedPub: { values: [{ value: "F" }] }, + relatedPub: { + values: [{ value: "F" }], + }, }, ], }, @@ -952,7 +985,6 @@ describe("relation management", () => { // K --> L await PubOp.update(pubA, { communityId: seededCommunity.community.id, - pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), trx, }) @@ -1011,7 +1043,6 @@ describe("relation management", () => { // Clear one field with deleteOrphaned and one without await PubOp.update(mainPub.id, { communityId: seededCommunity.community.id, - pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) .clearRelationsForField(seededCommunity.pubFields["Some relation"].slug, { @@ -1063,7 +1094,6 @@ describe("relation management", () => { await PubOp.update(mainPub.id, { communityId: seededCommunity.community.id, - pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) .connect(seededCommunity.pubFields["Some relation"].slug, newRelation, "new", { diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index a346cdf68..739178c51 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -1,11 +1,12 @@ +import { randomUUID } from "crypto"; + import type { Transaction } from "kysely"; import { sql } from "kysely"; -import { jsonArrayFrom } from "kysely/helpers/postgres"; import type { JsonValue, ProcessedPub } from "contracts"; import type { Database } from "db/Database"; -import type { CommunitiesId, PubsId, PubTypesId, PubValuesId } from "db/public"; +import type { CommunitiesId, PubFieldsId, PubsId, PubTypesId, PubValuesId } from "db/public"; import type { LastModifiedBy } from "db/types"; import { assert, expect } from "utils"; import { isUuid } from "utils/uuid"; @@ -26,7 +27,7 @@ type PubValue = string | number | boolean | JsonValue; type PubOpOptions = { communityId: CommunitiesId; - pubTypeId: PubTypesId; + pubTypeId?: PubTypesId; lastModifiedBy: LastModifiedBy; trx?: Transaction; }; @@ -55,7 +56,7 @@ type RelationOptions = // Base commands that will be used internally type SetCommand = { type: "set"; slug: string; value: PubValue | undefined }; type RelateCommand = { - type: "relate"; + type: "connect"; slug: string; relations: Array<{ target: PubOp | PubsId; value: PubValue }>; options: RelationOptions; @@ -111,14 +112,13 @@ type RelationOperation = // Types for operation collection type OperationMode = "create" | "upsert" | "update"; -interface CollectedOperation { +interface CollectedOperationBase { id: PubsId | undefined; - mode: OperationMode; values: Array<{ slug: string; value: PubValue }>; relationsToAdd: Array<{ slug: string; value: PubValue; - target: PubsId | symbol; + target: PubsId; override?: boolean; deleteOrphaned?: boolean; }>; @@ -133,7 +133,21 @@ interface CollectedOperation { }>; } -type OperationsMap = Map; +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; export type SingleRelationInput = { target: PubOp | PubsId; @@ -146,20 +160,17 @@ function isPubId(val: string | PubsId): val is PubsId { return isUuid(val); } -type PubOpMode = "create" | "upsert" | "update"; - -type PubIdMap = Map; - -// Common operations available to all PubOp types +/** + * common operations available to all PubOp types + */ abstract class BasePubOp { protected readonly options: PubOpOptions; protected readonly commands: PubOpCommand[] = []; - protected readonly thisSymbol: symbol; - protected static symbolCounter = 0; + protected readonly id: PubsId; - constructor(options: PubOpOptions) { + constructor(options: PubOpOptions & { id?: PubsId }) { this.options = options; - this.thisSymbol = Symbol(`pub-${BasePubOp.symbolCounter++}`); + this.id = options.id ?? (crypto.randomUUID() as PubsId); } /** @@ -224,7 +235,7 @@ abstract class BasePubOp { ]; this.commands.push({ - type: "relate", + type: "connect", slug, relations, options, @@ -254,28 +265,28 @@ abstract class BasePubOp { ); } - protected collectOperations(processed = new Set()): OperationsMap { + protected collectOperations(processed = new Set()): OperationsMap { // If we've already processed this PubOp, return empty map to avoid circular recursion - if (processed.has(this.thisSymbol)) { + if (processed.has(this.id)) { return new Map(); } const operations = new Map() as OperationsMap; - processed.add(this.thisSymbol); + processed.add(this.id); // Add this pub's operations - operations.set(this.getOperationKey(), { - id: this.getInitialId(), + operations.set(this.id, { + id: this.id, mode: this.getMode(), + pubTypeId: this.options.pubTypeId, values: this.collectValues(), relationsToAdd: [], relationsToRemove: [], relationsToClear: [], - }); + } as CollectedOperation); - // Process commands for (const cmd of this.commands) { - const rootOp = operations.get(this.getOperationKey()); + const rootOp = operations.get(this.id); assert(rootOp, "Root operation not found"); if (cmd.type === "set") continue; // Values already collected @@ -291,9 +302,10 @@ abstract class BasePubOp { target: cmd.target, deleteOrphaned: cmd.deleteOrphaned, }); - } else if (cmd.type === "relate") { + } else if (cmd.type === "connect") { // Process each relation in the command cmd.relations.forEach((relation) => { + // if the target is just a PubId, we can add the relation directly if (!(relation.target instanceof BasePubOp)) { rootOp.relationsToAdd.push({ slug: cmd.slug, @@ -302,22 +314,26 @@ abstract class BasePubOp { override: cmd.options.override, deleteOrphaned: cmd.options.deleteOrphaned, }); - } else { - rootOp.relationsToAdd.push({ - slug: cmd.slug, - value: relation.value, - target: relation.target.getOperationKey(), - override: cmd.options.override, - deleteOrphaned: cmd.options.deleteOrphaned, - }); - // Collect nested PubOp operations - if (!processed.has(relation.target.thisSymbol)) { - const targetOps = relation.target.collectOperations(processed); - for (const [key, value] of targetOps) { - operations.set(key, value); - } - } + return; + } + + rootOp.relationsToAdd.push({ + slug: cmd.slug, + value: relation.value, + target: relation.target.id, + override: cmd.options.override, + deleteOrphaned: cmd.options.deleteOrphaned, + }); + + // 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); } }); } @@ -326,11 +342,21 @@ abstract class BasePubOp { return operations; } - // Helper methods for operation collection protected abstract getMode(): OperationMode; - protected abstract getInitialId(): PubsId | undefined; - protected getOperationKey(): PubsId | symbol { - return this.getInitialId() || this.thisSymbol; + + /** + * 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.processRelations(trx, operations); + await this.processValues(trx, operations); + + return this.id; } private collectValues(): Array<{ slug: string; value: PubValue }> { @@ -345,16 +371,6 @@ abstract class BasePubOp { })); } - // Split executeWithTrx into smaller, focused methods - protected async executeWithTrx(trx: Transaction): Promise { - const operations = this.collectOperations(); - const idMap = await this.createAllPubs(trx, operations); - await this.processRelations(trx, operations, idMap); - await this.processValues(trx, operations, idMap); - - return this.resolvePubId(this.getOperationKey(), idMap); - } - /** * 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 @@ -381,12 +397,19 @@ abstract class BasePubOp { private async createAllPubs( trx: Transaction, operations: OperationsMap - ): Promise { - const idMap = new Map(); - const pubsToCreate = Array.from(operations.entries()).map(([key, _]) => ({ - id: typeof key === "symbol" ? undefined : key, + ): 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: this.options.pubTypeId, + pubTypeId: expect(operation.pubTypeId), })); const createdPubs = await autoRevalidate( @@ -402,13 +425,12 @@ abstract class BasePubOp { let index = 0; // map each operation to its final pub id - for (const [key, op] of operations) { + for (const [key, op] of createOrUpsertOperations) { const createdPub = filledCreatedPubs[index]; const pubToCreate = pubsToCreate[index]; // if we successfully created a new pub, use its id if (createdPub) { - idMap.set(key, createdPub.id); index++; continue; } @@ -422,34 +444,29 @@ abstract class BasePubOp { `Cannot create a pub with an id that already exists: ${pubToCreate.id}` ); } - idMap.set(key, pubToCreate.id); index++; continue; } - // we have symbol key (no id provided) but no pub was created. that's not good - if (typeof key === "symbol") { - throw new Error("Pub not created"); - } + // // we have symbol key (no id provided) but no pub was created. that's not good + // if (typeof key === "symbol") { + // throw new Error("Pub not created"); + // } // fallback - use the key as the id i guess? - idMap.set(key, key); index++; } - return idMap; + return; } private async processRelations( trx: Transaction, - operations: OperationsMap, - idMap: PubIdMap + operations: OperationsMap ): Promise { const relationsToCheckForOrphans = new Set(); - for (const [key, op] of operations) { - const pubId = this.resolvePubId(key, idMap); - + for (const [pubId, op] of operations) { const allOps = [ ...op.relationsToAdd .filter((r) => r.override) @@ -629,24 +646,22 @@ abstract class BasePubOp { private async processValues( trx: Transaction, - operations: OperationsMap, - idMap: PubIdMap + operations: OperationsMap ): Promise { const toUpsert = Array.from(operations.entries()).flatMap(([key, op]) => { - const pubId = this.resolvePubId(key, idMap); return [ // regular values ...op.values.map((v) => ({ - pubId, + pubId: key, slug: v.slug, value: v.value, })), // relations ...op.relationsToAdd.map((r) => ({ - pubId, + pubId: key, slug: r.slug, value: r.value, - relatedPubId: typeof r.target === "string" ? r.target : idMap.get(r.target)!, + relatedPubId: r.target, })), ]; }); @@ -684,16 +699,9 @@ abstract class BasePubOp { // --- Helper methods --- - private groupBySlug(items: T[]): Map { - return items.reduce((map, item) => { - const existing = map.get(item.slug) ?? []; - existing.push(item); - map.set(item.slug, existing); - return map; - }, new Map()); - } - - private partitionValidatedValues(validated: Array) { + private partitionValidatedValues< + T extends { pubId: PubsId; fieldId: PubFieldsId; value: PubValue }, + >(validated: Array) { return { values: validated .filter((v) => !("relatedPubId" in v) || !v.relatedPubId) @@ -705,7 +713,7 @@ abstract class BasePubOp { })), relations: validated .filter( - (v): v is typeof v & { relatedPubId: PubsId } => + (v): v is T & { relatedPubId: PubsId } => "relatedPubId" in v && !!v.relatedPubId ) .map((v) => ({ @@ -717,12 +725,6 @@ abstract class BasePubOp { })), }; } - - private resolvePubId(key: PubsId | symbol, idMap: Map): PubsId { - const pubId = typeof key === "symbol" ? idMap.get(key) : idMap.get(key); - assert(pubId, "Pub ID is required"); - return pubId; - } } interface UpdateOnlyOps { @@ -737,7 +739,10 @@ class CreatePubOp extends BasePubOp { private readonly initialId?: PubsId; constructor(options: PubOpOptions, initialId?: PubsId) { - super(options); + super({ + ...options, + id: initialId, + }); this.initialId = initialId; } @@ -761,7 +766,7 @@ class UpsertPubOp extends BasePubOp { initialSlug?: string, initialValue?: PubValue ) { - super(options); + super({ ...options, id: initialId }); this.initialId = initialId; this.initialSlug = initialSlug; this.initialValue = initialValue; @@ -782,12 +787,15 @@ class UpdatePubOp extends BasePubOp implements UpdateOnlyOps { private readonly initialValue?: PubValue; constructor( + options: Omit, id: PubsId | undefined, - options: PubOpOptions, initialSlug?: string, initialValue?: PubValue ) { - super(options); + super({ + ...options, + id, + }); this.pubId = id; this.initialSlug = initialSlug; this.initialValue = initialValue; @@ -846,12 +854,16 @@ export class PubOp { return new CreatePubOp(options, id); } - static update(id: PubsId, options: PubOpOptions): UpdatePubOp { - return new UpdatePubOp(id, options); + static update(id: PubsId, options: Omit): UpdatePubOp { + return new UpdatePubOp(options, id); } - static updateByValue(slug: string, value: PubValue, options: PubOpOptions): UpdatePubOp { - return new UpdatePubOp(undefined, options, slug, value); + static updateByValue( + slug: string, + value: PubValue, + options: Omit + ): UpdatePubOp { + return new UpdatePubOp(options, undefined, slug, value); } static upsert(id: PubsId, options: PubOpOptions): UpsertPubOp { From f9567d08b805f6d8ee43dfde38b252c2b2fec974 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Feb 2025 19:51:11 +0100 Subject: [PATCH 07/23] chore: add some comments --- core/lib/server/pub-op.ts | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 739178c51..854a9ebca 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -844,20 +844,36 @@ class UpdatePubOp extends BasePubOp implements UpdateOnlyOps { } } -// The factory class - this is the only exported class +/** + * 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: Omit): UpdatePubOp { return new UpdatePubOp(options, id); } + /** + * Update an existing pub by a specific value + */ static updateByValue( slug: string, value: PubValue, @@ -866,10 +882,23 @@ export class PubOp { 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: PubOpOptions): 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: PubOpOptions): UpsertPubOp { return new UpsertPubOp(options, undefined, slug, value); } From 7deacf6e132dbaeb9dc3f83c2d3268a408b6c656 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Feb 2025 20:37:29 +0100 Subject: [PATCH 08/23] feat: add ability to move stages --- core/lib/server/pub-op.db.test.ts | 79 +++++++++++++++++++- core/lib/server/pub-op.ts | 116 +++++++++++++++++++++++++++--- 2 files changed, 185 insertions(+), 10 deletions(-) diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index 2373b410e..d218cbc7e 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -1,3 +1,4 @@ +import { tr } from "date-fns/locale"; import { beforeAll, beforeEach, describe, expect, expectTypeOf, it, vitest } from "vitest"; import type { PubsId, PubTypes, Stages } from "db/public"; @@ -49,6 +50,11 @@ const seed = createSeed({ stageEditor: MemberRole.editor, }, }, + "Stage 2": { + members: { + stageEditor: MemberRole.editor, + }, + }, }, pubs: [ { @@ -426,7 +432,6 @@ describe("PubOp", () => { .connect(seededCommunity.pubFields["Some relation"].slug, pub1.id, "initial value") .execute(); - console.log(pub2.id); const updatedPub = await PubOp.upsert(pub2.id, { communityId: seededCommunity.community.id, pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, @@ -1111,3 +1116,75 @@ describe("relation management", () => { await expect(toDelete.id).not.toExist(); }); }); + +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(); + + const stages = await trx + .selectFrom("PubsInStages") + .selectAll() + .where("pubId", "=", pub.id) + .execute(); + console.log(stages); + + // expect(stages).toEqual([{ pubId: pub.id, stageId: seededCommunity.stages["Stage 2"].id }]); + }); +}); diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 854a9ebca..21e36ea45 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -6,7 +6,14 @@ import { sql } from "kysely"; import type { JsonValue, ProcessedPub } from "contracts"; import type { Database } from "db/Database"; -import type { CommunitiesId, PubFieldsId, PubsId, PubTypesId, PubValuesId } from "db/public"; +import type { + CommunitiesId, + PubFieldsId, + PubsId, + PubTypesId, + PubValuesId, + StagesId, +} from "db/public"; import type { LastModifiedBy } from "db/types"; import { assert, expect } from "utils"; import { isUuid } from "utils/uuid"; @@ -25,13 +32,22 @@ import { type PubValue = string | number | boolean | JsonValue; -type PubOpOptions = { +type PubOpOptionsBase = { communityId: CommunitiesId; - pubTypeId?: PubTypesId; lastModifiedBy: LastModifiedBy; trx?: Transaction; }; +type PubOpOptionsCreateUpsert = PubOpOptionsBase & { + pubTypeId: PubTypesId; +}; + +type PubOpOptionsUpdate = PubOpOptionsBase & { + pubTypeId?: never; +}; + +type PubOpOptions = PubOpOptionsCreateUpsert | PubOpOptionsUpdate; + type RelationOptions = | { /** @@ -78,12 +94,18 @@ type UnsetCommand = { slug: string; }; +type SetStageCommand = { + type: "setStage"; + stage: StagesId | null; +}; + type PubOpCommand = | SetCommand | RelateCommand | DisconnectCommand | ClearRelationsCommand - | UnsetCommand; + | UnsetCommand + | SetStageCommand; type ClearRelationOperation = { type: "clear"; @@ -131,6 +153,10 @@ interface CollectedOperationBase { slug: string | "*"; deleteOrphaned?: boolean; }>; + /** + * null meaning no stage + */ + stage?: StagesId | null; } type CreateOrUpsertOperation = CollectedOperationBase & { @@ -256,6 +282,19 @@ abstract class BasePubOp { return this.connect(slug, relations, { override: false }); } + /** + * 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)); @@ -302,6 +341,8 @@ abstract class BasePubOp { target: cmd.target, deleteOrphaned: cmd.deleteOrphaned, }); + } else if (cmd.type === "setStage") { + rootOp.stage = cmd.stage; } else if (cmd.type === "connect") { // Process each relation in the command cmd.relations.forEach((relation) => { @@ -353,6 +394,7 @@ abstract class BasePubOp { const operations = this.collectOperations(); await this.createAllPubs(trx, operations); + await this.processStages(trx, operations); await this.processRelations(trx, operations); await this.processValues(trx, operations); @@ -725,6 +767,58 @@ abstract class BasePubOp { })), }; } + + 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 { @@ -761,7 +855,7 @@ class UpsertPubOp extends BasePubOp { private readonly initialValue?: PubValue; constructor( - options: PubOpOptions, + options: PubOpOptionsCreateUpsert, initialId?: PubsId, initialSlug?: string, initialValue?: PubValue @@ -787,7 +881,7 @@ class UpdatePubOp extends BasePubOp implements UpdateOnlyOps { private readonly initialValue?: PubValue; constructor( - options: Omit, + options: PubOpOptionsUpdate, id: PubsId | undefined, initialSlug?: string, initialValue?: PubValue @@ -867,7 +961,7 @@ export class PubOp { /** * Update an existing pub */ - static update(id: PubsId, options: Omit): UpdatePubOp { + static update(id: PubsId, options: PubOpOptionsUpdate): UpdatePubOp { return new UpdatePubOp(options, id); } @@ -887,7 +981,7 @@ export class PubOp { * * Either create a new pub, or override an existing pub */ - static upsert(id: PubsId, options: PubOpOptions): UpsertPubOp { + static upsert(id: PubsId, options: PubOpOptionsCreateUpsert): UpsertPubOp { return new UpsertPubOp(options, id); } @@ -899,7 +993,11 @@ export class PubOp { * PubOp.upsertByValue("community-slug:googleDriveId", googleDriveId, options) * ``` */ - static upsertByValue(slug: string, value: PubValue, options: PubOpOptions): UpsertPubOp { + static upsertByValue( + slug: string, + value: PubValue, + options: PubOpOptionsCreateUpsert + ): UpsertPubOp { return new UpsertPubOp(options, undefined, slug, value); } } From 7ead642007296d861b55b7cab2be89df12b87cdb Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Feb 2025 20:38:09 +0100 Subject: [PATCH 09/23] fix: update test --- core/lib/server/pub-op.db.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index d218cbc7e..f014fccae 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -1178,13 +1178,6 @@ describe("PubOp stage", () => { .setStage(seededCommunity.stages["Stage 2"].id) .execute(); - const stages = await trx - .selectFrom("PubsInStages") - .selectAll() - .where("pubId", "=", pub.id) - .execute(); - console.log(stages); - - // expect(stages).toEqual([{ pubId: pub.id, stageId: seededCommunity.stages["Stage 2"].id }]); + expect(updatedPub.stageId).toEqual(seededCommunity.stages["Stage 2"].id); }); }); From eab252d1d10963778625300458b47352c96c58fb Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 11:37:49 +0100 Subject: [PATCH 10/23] feat: allow inline specification of pubops + cleanup of api --- core/lib/server/pub-op.db.test.ts | 232 ++++++++++++++------------ core/lib/server/pub-op.ts | 266 ++++++++++++++++++++---------- 2 files changed, 310 insertions(+), 188 deletions(-) diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index f014fccae..13d20cc2e 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -176,11 +176,7 @@ describe("PubOp", () => { pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .connect( - seededCommunity.pubFields["Some relation"].slug, - pub.id, - "test relations value" - ) + .relate(seededCommunity.pubFields["Some relation"].slug, "test relations value", pub.id) .execute(); await expect(pub2.id).toExist(); @@ -200,23 +196,23 @@ describe("PubOp", () => { lastModifiedBy: createLastModifiedBy("system"), }) .set(seededCommunity.pubFields["Title"].slug, "Main Pub") - .connect( + .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"), - "the first related pub" + }).set(seededCommunity.pubFields["Title"].slug, "Related Pub 1") ) - .connect( + .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"), - "the second related pub" + }).set(seededCommunity.pubFields["Title"].slug, "Related Pub 2") ); const result = await mainPub.execute(); @@ -243,14 +239,15 @@ describe("PubOp", () => { lastModifiedBy: createLastModifiedBy("system"), }) .set(seededCommunity.pubFields["Title"].slug, "Level 1") - .connect( + .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"), - "the second related pub" + }).set(seededCommunity.pubFields["Title"].slug, "Level 2") ); const mainPub = PubOp.create({ @@ -259,10 +256,10 @@ describe("PubOp", () => { lastModifiedBy: createLastModifiedBy("system"), }) .set(seededCommunity.pubFields["Title"].slug, "Root") - .connect( + .relate( seededCommunity.pubFields["Some relation"].slug, - relatedPub, - "the first related pub" + "the first related pub", + relatedPub ); const result = await mainPub.execute(); @@ -306,19 +303,19 @@ describe("PubOp", () => { lastModifiedBy: createLastModifiedBy("system"), }) .set(seededCommunity.pubFields["Title"].slug, "Main Pub") - .connect( + .relate( seededCommunity.pubFields["Some relation"].slug, - existingPub.id, - "the first related pub" + "the first related pub", + existingPub.id ) - .connect( + .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"), - "the second related pub" + }).set(seededCommunity.pubFields["Title"].slug, "New Related Pub") ); const result = await mainPub.execute(); @@ -368,16 +365,12 @@ describe("PubOp", () => { lastModifiedBy: createLastModifiedBy("system"), }) .set(seededCommunity.pubFields["Title"].slug, "Pub 2") - .connect( - seededCommunity.pubFields["Some relation"].slug, - pub1, - "the first related pub" - ); + .relate(seededCommunity.pubFields["Some relation"].slug, "the first related pub", pub1); - pub1.connect( + pub1.relate( seededCommunity.pubFields["Another relation"].slug, - pub2, - "the second related pub" + "the second related pub", + pub2 ); const result = await pub1.execute(); @@ -429,7 +422,7 @@ describe("PubOp", () => { lastModifiedBy: createLastModifiedBy("system"), }) .set(seededCommunity.pubFields["Title"].slug, "Pub 2") - .connect(seededCommunity.pubFields["Some relation"].slug, pub1.id, "initial value") + .relate(seededCommunity.pubFields["Some relation"].slug, "initial value", pub1.id) .execute(); const updatedPub = await PubOp.upsert(pub2.id, { @@ -437,7 +430,7 @@ describe("PubOp", () => { pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .connect(seededCommunity.pubFields["Some relation"].slug, pub1.id, "updated value") + .relate(seededCommunity.pubFields["Some relation"].slug, "updated value", pub1.id) .execute(); expect(updatedPub).toHaveValues([ @@ -457,14 +450,15 @@ describe("PubOp", () => { lastModifiedBy: createLastModifiedBy("system"), }) .set(seededCommunity.pubFields["Title"].slug, "Pub 1") - .connect( + .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"), - "relation" + }).set(seededCommunity.pubFields["Title"].slug, "Pub 2") ) .execute(); @@ -486,8 +480,7 @@ describe("PubOp", () => { }); describe("relation management", () => { - it("should disconnect a specific relation", async () => { - // Create two pubs to relate + it("should disrelate a specific relation", async () => { const pub1 = await PubOp.create({ communityId: seededCommunity.community.id, pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, @@ -502,15 +495,15 @@ describe("relation management", () => { lastModifiedBy: createLastModifiedBy("system"), }) .set(seededCommunity.pubFields["Title"].slug, "Pub 2") - .connect(seededCommunity.pubFields["Some relation"].slug, pub1.id, "initial value") + .relate(seededCommunity.pubFields["Some relation"].slug, "initial value", pub1.id) .execute(); - // Disconnect the relation + // disrelate the relation const updatedPub = await PubOp.update(pub2.id, { communityId: seededCommunity.community.id, lastModifiedBy: createLastModifiedBy("system"), }) - .disconnect(seededCommunity.pubFields["Some relation"].slug, pub1.id) + .unrelate(seededCommunity.pubFields["Some relation"].slug, pub1.id) .execute(); expect(updatedPub).toHaveValues([ @@ -518,8 +511,7 @@ describe("relation management", () => { ]); }); - it("should delete orphaned pubs when disconnecting relations", async () => { - // Create a pub that will become orphaned + it("should delete orphaned pubs when disrelateing relations", async () => { const orphanedPub = await PubOp.create({ communityId: seededCommunity.community.id, pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, @@ -528,26 +520,25 @@ describe("relation management", () => { .set(seededCommunity.pubFields["Title"].slug, "Soon to be orphaned") .execute(); - // Create a pub that relates to it 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") - .connect( + .relate( seededCommunity.pubFields["Some relation"].slug, - orphanedPub.id, - "only relation" + "only relation", + orphanedPub.id ) .execute(); - // Disconnect with deleteOrphaned option + // disrelate with deleteOrphaned option await PubOp.update(mainPub.id, { communityId: seededCommunity.community.id, lastModifiedBy: createLastModifiedBy("system"), }) - .disconnect(seededCommunity.pubFields["Some relation"].slug, orphanedPub.id, { + .unrelate(seededCommunity.pubFields["Some relation"].slug, orphanedPub.id, { deleteOrphaned: true, }) .execute(); @@ -556,41 +547,50 @@ describe("relation management", () => { }); it("should clear all relations for a specific field", async () => { - // Create multiple related pubs - const related1 = PubOp.create({ + 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.create({ + 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"); - // Create main pub with multiple 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") - .connect(seededCommunity.pubFields["Some relation"].slug, related1, "relation 1") - .connect(seededCommunity.pubFields["Some relation"].slug, related2, "relation 2") + .relate(seededCommunity.pubFields["Some relation"].slug, "relation 1", related1) + .relate(seededCommunity.pubFields["Some relation"].slug, "relation 2", related2) .execute(); - // Clear all relations for the field + 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"), }) - .clearRelationsForField(seededCommunity.pubFields["Some relation"].slug) + .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 () => { @@ -614,8 +614,8 @@ describe("relation management", () => { lastModifiedBy: createLastModifiedBy("system"), }) .set(seededCommunity.pubFields["Title"].slug, "Main pub") - .connect(seededCommunity.pubFields["Some relation"].slug, related1, "relation 1") - .connect(seededCommunity.pubFields["Some relation"].slug, related2, "relation 2") + .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; @@ -639,7 +639,7 @@ describe("relation management", () => { pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) - .connect(seededCommunity.pubFields["Some relation"].slug, related3.id, "new relation", { + .relate(seededCommunity.pubFields["Some relation"].slug, "new relation", related3.id, { override: true, }) .execute(); @@ -659,7 +659,6 @@ describe("relation management", () => { }); it("should handle multiple override relations for the same field", async () => { - // Create related pubs const related1 = await PubOp.create({ communityId: seededCommunity.community.id, pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, @@ -676,14 +675,13 @@ describe("relation management", () => { .set(seededCommunity.pubFields["Title"].slug, "Related 2") .execute(); - // Create main pub and set multiple relations with override 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") - .connect(seededCommunity.pubFields["Some relation"].slug, related1.id, "relation 1", { + .relate(seededCommunity.pubFields["Some relation"].slug, "relation 1", related1.id, { override: true, }) .execute(); @@ -692,17 +690,17 @@ describe("relation management", () => { communityId: seededCommunity.community.id, lastModifiedBy: createLastModifiedBy("system"), }) - .connect(seededCommunity.pubFields["Some relation"].slug, related2.id, "relation 2", { + .relate(seededCommunity.pubFields["Some relation"].slug, "relation 2", related2.id, { override: true, }) - .connect( + .relate( seededCommunity.pubFields["Some relation"].slug, + "relation 3", PubOp.create({ communityId: seededCommunity.community.id, pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }), - "relation 3", { override: true } ) .execute(); @@ -741,7 +739,7 @@ describe("relation management", () => { const pubK = "55555555-0000-0000-0000-000000000000" as PubsId; const pubL = "66666666-0000-0000-0000-000000000000" as PubsId; - // Create the graph structure: + // create the graph structure: // A J // / \ | // / \ | @@ -753,7 +751,7 @@ describe("relation management", () => { // / \ // K --> L - // Create leaf nodes first + // create leaf nodes first const pubL_op = PubOp.createWithId(pubL, { communityId: seededCommunity.community.id, pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, @@ -768,7 +766,7 @@ describe("relation management", () => { trx, }) .set(seededCommunity.pubFields["Title"].slug, "K") - .connect(seededCommunity.pubFields["Some relation"].slug, pubL_op, "to L"); + .relate(seededCommunity.pubFields["Some relation"].slug, "to L", pubL_op); const pubF_op = PubOp.createWithId(pubF, { communityId: seededCommunity.community.id, @@ -784,8 +782,8 @@ describe("relation management", () => { trx, }) .set(seededCommunity.pubFields["Title"].slug, "H") - .connect(seededCommunity.pubFields["Some relation"].slug, pubK_op, "to K") - .connect(seededCommunity.pubFields["Some relation"].slug, pubL_op, "to L"); + .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, @@ -794,7 +792,7 @@ describe("relation management", () => { trx, }) .set(seededCommunity.pubFields["Title"].slug, "E") - .connect(seededCommunity.pubFields["Some relation"].slug, pubF_op, "to F"); + .relate(seededCommunity.pubFields["Some relation"].slug, "to F", pubF_op); const pubG_op = PubOp.createWithId(pubG, { communityId: seededCommunity.community.id, @@ -803,7 +801,7 @@ describe("relation management", () => { trx, }) .set(seededCommunity.pubFields["Title"].slug, "G") - .connect(seededCommunity.pubFields["Some relation"].slug, pubE_op, "to E"); + .relate(seededCommunity.pubFields["Some relation"].slug, "to E", pubE_op); const pubD_op = PubOp.createWithId(pubD, { communityId: seededCommunity.community.id, @@ -812,7 +810,7 @@ describe("relation management", () => { trx, }) .set(seededCommunity.pubFields["Title"].slug, "D") - .connect(seededCommunity.pubFields["Some relation"].slug, pubH_op, "to H"); + .relate(seededCommunity.pubFields["Some relation"].slug, "to H", pubH_op); const pubI_op = PubOp.createWithId(pubI, { communityId: seededCommunity.community.id, @@ -829,7 +827,7 @@ describe("relation management", () => { trx, }) .set(seededCommunity.pubFields["Title"].slug, "B") - .connect(seededCommunity.pubFields["Some relation"].slug, pubG_op, "to G"); + .relate(seededCommunity.pubFields["Some relation"].slug, "to G", pubG_op); const pubC_op = PubOp.createWithId(pubC, { communityId: seededCommunity.community.id, @@ -838,11 +836,11 @@ describe("relation management", () => { trx, }) .set(seededCommunity.pubFields["Title"].slug, "C") - .connect(seededCommunity.pubFields["Some relation"].slug, pubI_op, "to I") - .connect(seededCommunity.pubFields["Some relation"].slug, pubD_op, "to D") - .connect(seededCommunity.pubFields["Some relation"].slug, pubE_op, "to E"); + .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 + // create root and J const rootPub = await PubOp.createWithId(pubA, { communityId: seededCommunity.community.id, pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, @@ -850,8 +848,8 @@ describe("relation management", () => { trx, }) .set(seededCommunity.pubFields["Title"].slug, "A") - .connect(seededCommunity.pubFields["Some relation"].slug, pubB_op, "to B") - .connect(seededCommunity.pubFields["Some relation"].slug, pubC_op, "to C") + .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, { @@ -861,7 +859,7 @@ describe("relation management", () => { trx, }) .set(seededCommunity.pubFields["Title"].slug, "J") - .connect(seededCommunity.pubFields["Some relation"].slug, pubI, "to I") + .relate(seededCommunity.pubFields["Some relation"].slug, "to I", pubI) .execute(); const { getPubsWithRelatedValuesAndChildren } = await import("~/lib/server/pub"); @@ -970,7 +968,7 @@ describe("relation management", () => { }, ]); - // Now disconnect C from A, which should + // 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 @@ -993,22 +991,22 @@ describe("relation management", () => { lastModifiedBy: createLastModifiedBy("system"), trx, }) - .disconnect(seededCommunity.pubFields["Some relation"].slug, pubC, { + .unrelate(seededCommunity.pubFields["Some relation"].slug, pubC, { deleteOrphaned: true, }) .execute(); - // Verify deletions + // 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 connected through G - await expect(pubF, "F should exist").toExist(trx); - await expect(pubG, "G should exist").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 connected through J - await expect(pubJ, "J should exist").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) { @@ -1041,24 +1039,24 @@ describe("relation management", () => { lastModifiedBy: createLastModifiedBy("system"), }) .set(seededCommunity.pubFields["Title"].slug, "Main") - .connect(seededCommunity.pubFields["Some relation"].slug, related1.id, "relation1") - .connect(seededCommunity.pubFields["Another relation"].slug, related2.id, "relation2") + .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 + // clear one field with deleteOrphaned and one without await PubOp.update(mainPub.id, { communityId: seededCommunity.community.id, lastModifiedBy: createLastModifiedBy("system"), }) - .clearRelationsForField(seededCommunity.pubFields["Some relation"].slug, { + .unrelate(seededCommunity.pubFields["Some relation"].slug, "*", { deleteOrphaned: true, }) - .clearRelationsForField(seededCommunity.pubFields["Another relation"].slug) + .unrelate(seededCommunity.pubFields["Another relation"].slug, "*") .execute(); - // Related1 should be deleted (orphaned with deleteOrphaned: true) + // related1 should be deleted (orphaned with deleteOrphaned: true) await expect(related1.id).not.toExist(); - // Related2 should still exist (orphaned but deleteOrphaned not set) + // related2 should still exist (orphaned but deleteOrphaned not set) await expect(related2.id).toExist(); }); @@ -1086,8 +1084,8 @@ describe("relation management", () => { lastModifiedBy: createLastModifiedBy("system"), }) .set(seededCommunity.pubFields["Title"].slug, "Main") - .connect(seededCommunity.pubFields["Some relation"].slug, toKeep.id, "keep") - .connect(seededCommunity.pubFields["Another relation"].slug, toDelete.id, "delete") + .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 @@ -1101,10 +1099,10 @@ describe("relation management", () => { communityId: seededCommunity.community.id, lastModifiedBy: createLastModifiedBy("system"), }) - .connect(seededCommunity.pubFields["Some relation"].slug, newRelation, "new", { + .relate(seededCommunity.pubFields["Some relation"].slug, "new", newRelation, { override: true, }) - .connect(seededCommunity.pubFields["Another relation"].slug, newRelation, "also new", { + .relate(seededCommunity.pubFields["Another relation"].slug, "also new", newRelation, { override: true, deleteOrphaned: true, }) @@ -1115,6 +1113,38 @@ describe("relation management", () => { // 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" }, + ]); + }); }); describe("PubOp stage", () => { diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 21e36ea45..1d45d6566 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -72,20 +72,15 @@ type RelationOptions = // Base commands that will be used internally type SetCommand = { type: "set"; slug: string; value: PubValue | undefined }; type RelateCommand = { - type: "connect"; + type: "relate"; slug: string; relations: Array<{ target: PubOp | PubsId; value: PubValue }>; options: RelationOptions; }; -type DisconnectCommand = { - type: "disconnect"; - slug: string; - target: PubsId; - deleteOrphaned?: boolean; -}; -type ClearRelationsCommand = { - type: "clearRelations"; - slug?: string; +type UnrelateCommand = { + type: "unrelate"; + slug: (string & {}) | "*"; + target: PubsId | "*"; deleteOrphaned?: boolean; }; @@ -99,13 +94,7 @@ type SetStageCommand = { stage: StagesId | null; }; -type PubOpCommand = - | SetCommand - | RelateCommand - | DisconnectCommand - | ClearRelationsCommand - | UnsetCommand - | SetStageCommand; +type PubOpCommand = SetCommand | RelateCommand | UnrelateCommand | UnsetCommand | SetStageCommand; type ClearRelationOperation = { type: "clear"; @@ -186,6 +175,70 @@ 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 */ @@ -224,64 +277,83 @@ abstract class BasePubOp { } /** - * Connect to one or more pubs with the same value + * Relate to a single pub with a value */ - connect(slug: string, target: PubOp | PubsId, value: PubValue, options?: RelationOptions): this; + relate( + slug: string, + value: PubValue, + target: ActivePubOp | PubsId | ((pubOp: NestedPubOpBuilder) => ActivePubOp), + options?: RelationOptions + ): this; /** - * Connect to one or more pubs with individual values + * Relate to multiple pubs at once */ - connect( + relate( slug: string, - relations: Array<{ target: PubOp | PubsId; value: PubValue }>, + relations: Array<{ + target: PubsId | BasePubOp | ((builder: NestedPubOpBuilder) => BasePubOp); + value: PubValue; + }>, options?: RelationOptions ): this; - connect( + relate( slug: string, - targetsOrRelations: PubOp | PubsId | Array<{ target: PubOp | PubsId; value: PubValue }>, - valueOrOptions?: PubValue | RelationOptions, - maybeOptions?: RelationOptions + valueOrRelations: + | PubValue + | Array<{ + target: PubsId | BasePubOp | ((builder: NestedPubOpBuilder) => BasePubOp); + value: PubValue; + }>, + targetOrOptions?: + | ActivePubOp + | PubsId + | ((pubOp: NestedPubOpBuilder) => ActivePubOp) + | RelationOptions, + options?: RelationOptions ): this { - if (typeof targetsOrRelations === "string" && !isPubId(targetsOrRelations)) { - throw new Error( - `Invalid target: should either be an existing pub id or a PubOp instance, but got \`${targetsOrRelations}\`` - ); - } + const nestedBuilder = new NestedPubOpBuilder(this.options); + + // Handle single relation case + if (!Array.isArray(valueOrRelations)) { + 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 Error( + `Invalid target: should either be an existing pub id or a PubOp instance, but got \`${resolvedTarget}\`` + ); + } - const options = - (Array.isArray(targetsOrRelations) - ? (valueOrOptions as RelationOptions) - : maybeOptions) ?? {}; - const relations = Array.isArray(targetsOrRelations) - ? targetsOrRelations - : [ + this.commands.push({ + type: "relate", + slug, + relations: [ { - target: targetsOrRelations, - value: valueOrOptions as PubValue, + target: resolvedTarget, + value: valueOrRelations, }, - ]; + ], + options: options ?? {}, + }); + return this; + } + // Handle multiple relations case this.commands.push({ - type: "connect", + type: "relate", slug, - relations, - options, + relations: valueOrRelations.map((r) => ({ + target: typeof r.target === "function" ? r.target(nestedBuilder) : r.target, + value: r.value, + })), + options: (targetOrOptions as RelationOptions) ?? {}, }); return this; } - /** - * Set relation values for existing relations - */ - setRelation(slug: string, target: PubsId, value: PubValue): this { - return this.connect(slug, [{ target, value }], { override: false }); - } - /** - * Set multiple relation values for existing relations - */ - setRelations(slug: string, relations: Array<{ target: PubsId; value: PubValue }>): this { - return this.connect(slug, relations, { override: false }); - } - /** * Set the stage of the pub * @@ -330,12 +402,23 @@ abstract class BasePubOp { if (cmd.type === "set") continue; // Values already collected - if (cmd.type === "clearRelations") { - rootOp.relationsToClear.push({ - slug: cmd.slug || "*", - deleteOrphaned: cmd.deleteOrphaned, - }); - } else if (cmd.type === "disconnect") { + if (cmd.type === "unrelate") { + if (cmd.slug === "*") { + rootOp.relationsToClear.push({ + slug: "*", + deleteOrphaned: cmd.deleteOrphaned, + }); + continue; + } + + if (cmd.target === "*") { + rootOp.relationsToClear.push({ + slug: cmd.slug, + deleteOrphaned: cmd.deleteOrphaned, + }); + continue; + } + rootOp.relationsToRemove.push({ slug: cmd.slug, target: cmd.target, @@ -343,11 +426,11 @@ abstract class BasePubOp { }); } else if (cmd.type === "setStage") { rootOp.stage = cmd.stage; - } else if (cmd.type === "connect") { + } 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 (!(relation.target instanceof BasePubOp)) { + if (typeof relation.target === "string") { rootOp.relationsToAdd.push({ slug: cmd.slug, value: relation.value, @@ -823,9 +906,7 @@ abstract class BasePubOp { interface UpdateOnlyOps { unset(slug: string): this; - disconnect(slug: string, target: PubsId, options?: { deleteOrphaned?: boolean }): this; - clearRelationsForField(slug: string, options?: { deleteOrphaned?: boolean }): this; - clearAllRelations(options?: { deleteOrphaned?: boolean }): this; + unrelate(slug: string, target: PubsId, options?: { deleteOrphaned?: boolean }): this; } // Implementation classes - these are not exported @@ -899,6 +980,11 @@ class UpdatePubOp extends BasePubOp implements UpdateOnlyOps { 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", @@ -907,32 +993,36 @@ class UpdatePubOp extends BasePubOp implements UpdateOnlyOps { return this; } - disconnect(slug: string, target: PubsId, options?: { deleteOrphaned?: boolean }): this { - this.commands.push({ - type: "disconnect", - slug, - target, - deleteOrphaned: options?.deleteOrphaned, - }); - return this; - } - - clearRelationsForField(slug: string, options?: { deleteOrphaned?: boolean }): 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: "clearRelations", + type: "unrelate", slug, + target: typeof optionsOrTarget === "string" ? optionsOrTarget : "*", deleteOrphaned: options?.deleteOrphaned, }); return this; } - clearAllRelations(options?: { deleteOrphaned?: boolean }): this { - this.commands.push({ - type: "clearRelations", - deleteOrphaned: options?.deleteOrphaned, - }); - return this; - } protected getInitialId(): PubsId | undefined { return this.pubId; } @@ -1001,3 +1091,5 @@ export class PubOp { return new UpsertPubOp(options, undefined, slug, value); } } + +type ActivePubOp = CreatePubOp | UpdatePubOp | UpsertPubOp; From 631bc83cdc688c9d3571dfec8c6d52bb1e524470 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 11:38:29 +0100 Subject: [PATCH 11/23] refactor: rename override option to 'replaceExisting' --- core/lib/server/pub-op.db.test.ts | 12 ++++++------ core/lib/server/pub-op.ts | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index 13d20cc2e..b89965a9d 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -640,7 +640,7 @@ describe("relation management", () => { lastModifiedBy: createLastModifiedBy("system"), }) .relate(seededCommunity.pubFields["Some relation"].slug, "new relation", related3.id, { - override: true, + replaceExisting: true, }) .execute(); @@ -682,7 +682,7 @@ describe("relation management", () => { }) .set(seededCommunity.pubFields["Title"].slug, "Main pub") .relate(seededCommunity.pubFields["Some relation"].slug, "relation 1", related1.id, { - override: true, + replaceExisting: true, }) .execute(); @@ -691,7 +691,7 @@ describe("relation management", () => { lastModifiedBy: createLastModifiedBy("system"), }) .relate(seededCommunity.pubFields["Some relation"].slug, "relation 2", related2.id, { - override: true, + replaceExisting: true, }) .relate( seededCommunity.pubFields["Some relation"].slug, @@ -701,7 +701,7 @@ describe("relation management", () => { pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }), - { override: true } + { replaceExisting: true } ) .execute(); @@ -1100,10 +1100,10 @@ describe("relation management", () => { lastModifiedBy: createLastModifiedBy("system"), }) .relate(seededCommunity.pubFields["Some relation"].slug, "new", newRelation, { - override: true, + replaceExisting: true, }) .relate(seededCommunity.pubFields["Another relation"].slug, "also new", newRelation, { - override: true, + replaceExisting: true, deleteOrphaned: true, }) .execute(); diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 1d45d6566..7c2b915f7 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -53,14 +53,14 @@ type RelationOptions = /** * If true, existing relations on the same field will be removed */ - override?: false; + replaceExisting?: false; deleteOrphaned?: never; } | { /** * If true, existing relations on the same field will be removed */ - override: true; + replaceExisting: true; /** * If true, pubs that have been disconnected, * either manually or because they were orphaned because of `override: true`, @@ -435,7 +435,7 @@ abstract class BasePubOp { slug: cmd.slug, value: relation.value, target: relation.target as PubsId, - override: cmd.options.override, + override: cmd.options.replaceExisting, deleteOrphaned: cmd.options.deleteOrphaned, }); @@ -446,7 +446,7 @@ abstract class BasePubOp { slug: cmd.slug, value: relation.value, target: relation.target.id, - override: cmd.options.override, + override: cmd.options.replaceExisting, deleteOrphaned: cmd.options.deleteOrphaned, }); From 39dd06dcef30d55819ac75dfac0bda71bc9efeef Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 11:41:26 +0100 Subject: [PATCH 12/23] test: add test for multiple relate --- core/lib/server/pub-op.db.test.ts | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index b89965a9d..c72c37c76 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -1145,6 +1145,52 @@ describe("relation management", () => { { 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", () => { From d6d638e9b57a0175ce68921beac3f7c7ac6b5c4c Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 11:54:26 +0100 Subject: [PATCH 13/23] fix: fix type issues --- core/lib/server/pub-op.ts | 87 ++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 33 deletions(-) diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 7c2b915f7..54e83db11 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -74,7 +74,7 @@ type SetCommand = { type: "set"; slug: string; value: PubValue | undefined }; type RelateCommand = { type: "relate"; slug: string; - relations: Array<{ target: PubOp | PubsId; value: PubValue }>; + relations: Array<{ target: ActivePubOp | PubsId; value: PubValue }>; options: RelationOptions; }; type UnrelateCommand = { @@ -245,7 +245,7 @@ class NestedPubOpBuilder { abstract class BasePubOp { protected readonly options: PubOpOptions; protected readonly commands: PubOpCommand[] = []; - protected readonly id: PubsId; + readonly id: PubsId; constructor(options: PubOpOptions & { id?: PubsId }) { this.options = options; @@ -276,6 +276,26 @@ abstract class BasePubOp { 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 */ @@ -291,7 +311,7 @@ abstract class BasePubOp { relate( slug: string, relations: Array<{ - target: PubsId | BasePubOp | ((builder: NestedPubOpBuilder) => BasePubOp); + target: PubsId | ActivePubOp | ((builder: NestedPubOpBuilder) => ActivePubOp); value: PubValue; }>, options?: RelationOptions @@ -301,7 +321,7 @@ abstract class BasePubOp { valueOrRelations: | PubValue | Array<{ - target: PubsId | BasePubOp | ((builder: NestedPubOpBuilder) => BasePubOp); + target: PubsId | ActivePubOp | ((builder: NestedPubOpBuilder) => ActivePubOp); value: PubValue; }>, targetOrOptions?: @@ -313,43 +333,43 @@ abstract class BasePubOp { ): this { const nestedBuilder = new NestedPubOpBuilder(this.options); - // Handle single relation case - if (!Array.isArray(valueOrRelations)) { - 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 Error( - `Invalid target: should either be an existing pub id or a PubOp instance, but got \`${resolvedTarget}\`` - ); - } - + // multi relation case + if (this.isRelationBlockConfig(valueOrRelations)) { this.commands.push({ type: "relate", slug, - relations: [ - { - target: resolvedTarget, - value: valueOrRelations, - }, - ], - options: options ?? {}, + relations: valueOrRelations.map((r) => ({ + target: typeof r.target === "function" ? r.target(nestedBuilder) : r.target, + value: r.value, + })), + options: (targetOrOptions as RelationOptions) ?? {}, }); return this; } - // Handle multiple relations case + // 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 Error( + `Invalid target: should either be an existing pub id or a PubOp instance, but got \`${resolvedTarget}\`` + ); + } + 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) ?? {}, + relations: [ + { + target: resolvedTarget, + value: valueOrRelations, + }, + ], + options: options ?? {}, }); return this; } @@ -376,7 +396,7 @@ abstract class BasePubOp { ); } - protected collectOperations(processed = new Set()): OperationsMap { + 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(); @@ -430,7 +450,8 @@ abstract class BasePubOp { // 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") { + + if (typeof relation.target === "string" && isPubId(relation.target)) { rootOp.relationsToAdd.push({ slug: cmd.slug, value: relation.value, From 0c90e5d49826aa53aaf378bac415922683a50bd7 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 12:03:07 +0100 Subject: [PATCH 14/23] refactor: add slightly better error handling --- core/lib/server/pub-op.ts | 67 ++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 54e83db11..40e0a16e7 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -171,6 +171,59 @@ export type SingleRelationInput = { deleteOrphaned?: boolean; }; +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); } @@ -355,9 +408,7 @@ abstract class BasePubOp { const resolvedTarget = typeof target === "function" ? target(nestedBuilder) : target; if (typeof resolvedTarget === "string" && !isPubId(resolvedTarget)) { - throw new Error( - `Invalid target: should either be an existing pub id or a PubOp instance, but got \`${resolvedTarget}\`` - ); + throw new PubOpInvalidTargetError(slug, resolvedTarget); } this.commands.push({ @@ -586,20 +637,12 @@ abstract class BasePubOp { // ...but were trying to create a new pub, that's an error, because there's no pub that was created // that means we were trying to create a pub with an id that already exists if (op.mode === "create") { - throw new Error( - `Cannot create a pub with an id that already exists: ${pubToCreate.id}` - ); + throw new PubOpCreateExistingError(pubToCreate.id); } index++; continue; } - // // we have symbol key (no id provided) but no pub was created. that's not good - // if (typeof key === "symbol") { - // throw new Error("Pub not created"); - // } - - // fallback - use the key as the id i guess? index++; } From 2258024e6bdf5d0ccacf9af3a1b45efef9facf7c Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 12:17:21 +0100 Subject: [PATCH 15/23] fix: add back deletePubValuesByValueId --- core/lib/server/pub.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index 29bc71e33..f5856bae6 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -652,6 +652,38 @@ export const createPubRecursiveNew = async { + 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, From 13907135d5086413fe657a2d82a84af8c5763cd3 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 12:21:25 +0100 Subject: [PATCH 16/23] fix: add back pubvalue upsert functions --- core/lib/server/pub.ts | 155 ++++++++++++++++++++++++++++------------- 1 file changed, 108 insertions(+), 47 deletions(-) diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index f5856bae6..34b86189e 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -934,30 +934,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( @@ -1204,29 +1186,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; } @@ -1234,6 +1199,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; From e7aa8ac2e6c85058c55228cf95ad870b51afa52d Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 13:07:38 +0100 Subject: [PATCH 17/23] fix: sort pubvalues deeply for tests --- core/lib/__tests__/live.db.test.ts | 54 ++++++++++++++++++++++++------ core/lib/__tests__/matchers.ts | 21 ++++++++---- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/core/lib/__tests__/live.db.test.ts b/core/lib/__tests__/live.db.test.ts index 2adf02fe4..113aadf45 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 index 2274c5bf7..2387c1344 100644 --- a/core/lib/__tests__/matchers.ts +++ b/core/lib/__tests__/matchers.ts @@ -5,6 +5,17 @@ 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 | ProcessedPub, expected?: typeof db) { if (typeof received !== "string") { @@ -34,12 +45,10 @@ expect.extend({ } const pub = received; - const sortedPubValues = [...pub.values].sort((a, b) => - (a.value as string).localeCompare(b.value as string) - ); + const sortedPubValues = deepSortValues(pub); const expectedLength = expected.length; - const receivedLength = sortedPubValues.length; + const receivedLength = sortedPubValues.values.length; const isNot = this.isNot; if (!isNot && !this.equals(expectedLength, receivedLength)) { @@ -51,7 +60,7 @@ expect.extend({ } // equiv. to .toMatchObject - const pass = this.equals(sortedPubValues, expected, [ + const pass = this.equals(sortedPubValues.values, expected, [ this.utils.iterableEquality, this.utils.subsetEquality, ]); @@ -61,7 +70,7 @@ expect.extend({ 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, expected)}`, + : `Expected pub ${isNot ? "not to" : "to"} match values ${this.utils.diff(sortedPubValues.values, expected)}`, }; }, }); From 6c42929561276611195dbd6984b00725ca0a045d Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 13:07:56 +0100 Subject: [PATCH 18/23] fix: fix tests and imports --- .../actions/_lib/runActionInstance.db.test.ts | 2 +- .../site/[...ts-rest]/route.ts | 3 +- core/app/components/pubs/PubEditor/actions.ts | 2 +- core/lib/server/pub-capabilities.db.test.ts | 6 +- core/lib/server/pub-op.db.test.ts | 16 +-- core/lib/server/pub-trigger.db.test.ts | 2 +- core/lib/server/pub.db.test.ts | 102 +++++++++--------- core/lib/server/pub.fts.db.test.ts | 15 ++- core/lib/server/pub.sort.db.test.ts | 3 +- 9 files changed, 76 insertions(+), 75 deletions(-) diff --git a/core/actions/_lib/runActionInstance.db.test.ts b/core/actions/_lib/runActionInstance.db.test.ts index 7e0914716..3d00083ea 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 041ff30c0..7c4ae7948 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 812c82661..69b1f68ab 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/lib/server/pub-capabilities.db.test.ts b/core/lib/server/pub-capabilities.db.test.ts index 73aa203f4..648b8f796 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 index c72c37c76..9ce6b962d 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -873,7 +873,7 @@ describe("relation management", () => { { trx, depth: 10 } ); - expect(initialState.values).toMatchObject([ + expect(initialState).toHaveValues([ { value: "A" }, { value: "to B", @@ -915,13 +915,6 @@ describe("relation management", () => { relatedPub: { values: [ { value: "C" }, - { - value: "to I", - relatedPubId: pubI, - relatedPub: { - values: [{ value: "I" }], - }, - }, { value: "to D", relatedPubId: pubD, @@ -963,6 +956,13 @@ describe("relation management", () => { value: "to E", relatedPubId: pubE, }, + { + value: "to I", + relatedPubId: pubI, + relatedPub: { + values: [{ value: "I" }], + }, + }, ], }, }, diff --git a/core/lib/server/pub-trigger.db.test.ts b/core/lib/server/pub-trigger.db.test.ts index 45ce3eb87..665b70376 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 fed93f820..73d960fef 100644 --- a/core/lib/server/pub.db.test.ts +++ b/core/lib/server/pub.db.test.ts @@ -646,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 () => { @@ -903,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 () => { @@ -964,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}` @@ -1031,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 33a4a522a..cb8e0403e 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 d85c6c961..2be366b04 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({ From 8cdb53052872a66ab93671a45b3fa933218a2ac9 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 15:53:37 +0100 Subject: [PATCH 19/23] feat: make upsert override by default --- core/lib/server/pub-op.db.test.ts | 76 ++++++++- core/lib/server/pub-op.ts | 245 ++++++++++++++++++++++-------- core/lib/server/pub.ts | 4 + 3 files changed, 260 insertions(+), 65 deletions(-) diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index 9ce6b962d..75e1dd650 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -477,6 +477,79 @@ describe("PubOp", () => { }, ]); }); + + 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", () => { @@ -634,9 +707,8 @@ describe("relation management", () => { .execute(); // Update with override - only related3 should remain - const updatedPub = await PubOp.upsert(mainPub.id, { + const updatedPub = await PubOp.update(mainPub.id, { communityId: seededCommunity.community.id, - pubTypeId: seededCommunity.pubTypes["Basic Pub"].id, lastModifiedBy: createLastModifiedBy("system"), }) .relate(seededCommunity.pubFields["Some relation"].slug, "new relation", related3.id, { diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 40e0a16e7..88e871064 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -48,29 +48,104 @@ type PubOpOptionsUpdate = PubOpOptionsBase & { type PubOpOptions = PubOpOptionsCreateUpsert | PubOpOptionsUpdate; -type RelationOptions = - | { - /** - * If true, existing relations on the same field will be removed - */ - replaceExisting?: false; - deleteOrphaned?: never; - } - | { - /** - * If true, existing relations on the same field will be removed - */ - replaceExisting: true; - /** - * If true, pubs that have been disconnected, - * either manually or because they were orphaned because of `override: true`, - * will be deleted. - */ - deleteOrphaned?: boolean; - }; +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 }; +type SetCommand = { type: "set"; slug: string; value: PubValue | undefined; options?: SetOptions }; type RelateCommand = { type: "relate"; slug: string; @@ -81,7 +156,13 @@ type UnrelateCommand = { type: "unrelate"; slug: (string & {}) | "*"; target: PubsId | "*"; - deleteOrphaned?: boolean; + options?: { + /** + * If true, pubs that have been disconnected and all their descendants that are not otherwise connected + * will be deleted. + */ + deleteOrphaned?: boolean; + }; }; type UnsetCommand = { @@ -99,20 +180,20 @@ type PubOpCommand = SetCommand | RelateCommand | UnrelateCommand | UnsetCommand type ClearRelationOperation = { type: "clear"; slug: string; - deleteOrphaned?: boolean; + options?: Omit; }; type RemoveRelationOperation = { type: "remove"; slug: string; target: PubsId; - deleteOrphaned?: boolean; + options?: Omit; }; type OverrideRelationOperation = { type: "override"; slug: string; - deleteOrphaned?: boolean; + options?: RelationOptions; }; type RelationOperation = @@ -125,22 +206,21 @@ type OperationMode = "create" | "upsert" | "update"; interface CollectedOperationBase { id: PubsId | undefined; - values: Array<{ slug: string; value: PubValue }>; + values: Array<{ slug: string; value: PubValue; options?: SetOptions }>; relationsToAdd: Array<{ slug: string; value: PubValue; target: PubsId; - override?: boolean; - deleteOrphaned?: boolean; + options: RelationOptions; }>; relationsToRemove: Array<{ slug: string; target: PubsId; - deleteOrphaned?: boolean; + options?: Omit; }>; relationsToClear: Array<{ slug: string | "*"; - deleteOrphaned?: boolean; + options?: Omit; }>; /** * null meaning no stage @@ -164,13 +244,6 @@ type CollectedOperation = CreateOrUpsertOperation | UpdateOperation; type OperationsMap = Map; -export type SingleRelationInput = { - target: PubOp | PubsId; - value: PubValue; - override?: boolean; - deleteOrphaned?: boolean; -}; - type PubOpErrorCode = | "RELATION_CYCLE" | "ORPHAN_CONFLICT" @@ -308,24 +381,33 @@ abstract class BasePubOp { /** * Set a single value or multiple values */ - set(slug: string, value: PubValue): this; - set(values: Record): this; - set(slugOrValues: string | Record, value?: PubValue): this { + 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: value!, + value: valueOrOptions, + options: options ?? defaultOptions, }); - } else { - this.commands.push( - ...Object.entries(slugOrValues).map(([slug, value]) => ({ - type: "set" as const, - slug, - value, - })) - ); + return this; } + + this.commands.push( + ...Object.entries(slugOrValues).map(([slug, value]) => ({ + type: "set" as const, + slug, + value, + options: (valueOrOptions as SetOptions) ?? defaultOptions, + })) + ); return this; } @@ -386,6 +468,9 @@ abstract class BasePubOp { ): 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({ @@ -395,7 +480,7 @@ abstract class BasePubOp { target: typeof r.target === "function" ? r.target(nestedBuilder) : r.target, value: r.value, })), - options: (targetOrOptions as RelationOptions) ?? {}, + options: (targetOrOptions as RelationOptions) ?? defaultOptions, }); return this; } @@ -420,7 +505,7 @@ abstract class BasePubOp { value: valueOrRelations, }, ], - options: options ?? {}, + options: options ?? defaultOptions, }); return this; } @@ -477,7 +562,7 @@ abstract class BasePubOp { if (cmd.slug === "*") { rootOp.relationsToClear.push({ slug: "*", - deleteOrphaned: cmd.deleteOrphaned, + options: cmd.options, }); continue; } @@ -485,7 +570,7 @@ abstract class BasePubOp { if (cmd.target === "*") { rootOp.relationsToClear.push({ slug: cmd.slug, - deleteOrphaned: cmd.deleteOrphaned, + options: cmd.options, }); continue; } @@ -493,7 +578,7 @@ abstract class BasePubOp { rootOp.relationsToRemove.push({ slug: cmd.slug, target: cmd.target, - deleteOrphaned: cmd.deleteOrphaned, + options: cmd.options, }); } else if (cmd.type === "setStage") { rootOp.stage = cmd.stage; @@ -507,8 +592,7 @@ abstract class BasePubOp { slug: cmd.slug, value: relation.value, target: relation.target as PubsId, - override: cmd.options.replaceExisting, - deleteOrphaned: cmd.options.deleteOrphaned, + options: cmd.options, }); return; @@ -518,8 +602,7 @@ abstract class BasePubOp { slug: cmd.slug, value: relation.value, target: relation.target.id, - override: cmd.options.replaceExisting, - deleteOrphaned: cmd.options.deleteOrphaned, + options: cmd.options, }); // if we have already processed this target, we can stop here @@ -556,7 +639,7 @@ abstract class BasePubOp { return this.id; } - private collectValues(): Array<{ slug: string; value: PubValue }> { + private collectValues(): Array<{ slug: string; value: PubValue; options?: SetOptions }> { return this.commands .filter( (cmd): cmd is Extract => @@ -565,6 +648,7 @@ abstract class BasePubOp { .map((cmd) => ({ slug: cmd.slug, value: cmd.value!, + options: cmd.options, })); } @@ -658,7 +742,7 @@ abstract class BasePubOp { for (const [pubId, op] of operations) { const allOps = [ ...op.relationsToAdd - .filter((r) => r.override) + .filter((r) => r.options.replaceExisting) .map((r) => ({ type: "override", ...r })), ...op.relationsToClear.map((r) => ({ type: "clear", ...r })), ...op.relationsToRemove.map((r) => ({ type: "remove", ...r })), @@ -719,7 +803,7 @@ abstract class BasePubOp { return false; } - if (!relationOp.deleteOrphaned) { + if (!relationOp.options?.deleteOrphaned) { return false; } @@ -844,6 +928,7 @@ abstract class BasePubOp { pubId: key, slug: v.slug, value: v.value, + options: v.options, })), // relations ...op.relationsToAdd.map((r) => ({ @@ -868,6 +953,33 @@ abstract class BasePubOp { 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({ @@ -889,7 +1001,12 @@ abstract class BasePubOp { // --- Helper methods --- private partitionValidatedValues< - T extends { pubId: PubsId; fieldId: PubFieldsId; value: PubValue }, + T extends { + pubId: PubsId; + fieldId: PubFieldsId; + value: PubValue; + options?: SetOptions; + }, >(validated: Array) { return { values: validated @@ -899,6 +1016,7 @@ abstract class BasePubOp { fieldId: v.fieldId, value: v.value, lastModifiedBy: this.options.lastModifiedBy, + options: v.options, })), relations: validated .filter( @@ -911,6 +1029,7 @@ abstract class BasePubOp { value: v.value, relatedPubId: v.relatedPubId, lastModifiedBy: this.options.lastModifiedBy, + options: v.options, })), }; } @@ -1082,7 +1201,7 @@ class UpdatePubOp extends BasePubOp implements UpdateOnlyOps { type: "unrelate", slug, target: typeof optionsOrTarget === "string" ? optionsOrTarget : "*", - deleteOrphaned: options?.deleteOrphaned, + options: typeof optionsOrTarget === "string" ? options : optionsOrTarget, }); return this; } diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index 34b86189e..3e87c05a2 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -663,6 +663,10 @@ export const deletePubValuesByValueId = async ({ lastModifiedBy: LastModifiedBy; trx?: typeof db; }) => { + if (valueIds.length === 0) { + return; + } + const result = await maybeWithTrx(trx, async (trx) => { const deletedPubValues = await autoRevalidate( trx From da0b8eb8988605d7385f2f183765bfc7e3f5dd48 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 16:29:38 +0100 Subject: [PATCH 20/23] docs: add better documentation to orphan --- core/lib/server/pub-op.ts | 81 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 88e871064..4cb9be384 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -837,6 +837,87 @@ abstract class BasePubOp { * * 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, From b52d745df27109dc97d3f78ad19783974d966ee9 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 18:37:41 +0100 Subject: [PATCH 21/23] refactor: simplify createPub logic a bit --- core/lib/server/pub-op.ts | 59 ++++++++------------------------------- 1 file changed, 11 insertions(+), 48 deletions(-) diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index 4cb9be384..c72bf6714 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -652,29 +652,6 @@ abstract class BasePubOp { })); } - /** - * 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 - */ - private fillCreateResultHoles( - pubsToCreate: Array<{ id?: PubsId }>, - pubCreateResult: Array<{ id: PubsId }> - ) { - let index = 0; - return pubsToCreate.map((pubToCreate) => { - const correspondingResult = pubCreateResult[index]; - - if (pubToCreate.id && pubToCreate.id !== correspondingResult?.id) { - return null; - } - - index++; - return correspondingResult || null; - }); - } - private async createAllPubs( trx: Transaction, operations: OperationsMap @@ -701,34 +678,20 @@ abstract class BasePubOp { .returningAll() ).execute(); - // fill any gaps in the array of created pubs - const filledCreatedPubs = this.fillCreateResultHoles(pubsToCreate, createdPubs); - - let index = 0; - // map each operation to its final pub id - for (const [key, op] of createOrUpsertOperations) { - const createdPub = filledCreatedPubs[index]; + /** + * 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 we successfully created a new pub, use its id - if (createdPub) { - index++; - continue; - } - - // if we had an existing id..., ie it was provided for an upsert or create - if (pubToCreate.id) { - // ...but were trying to create a new pub, that's an error, because there's no pub that was created - // that means we were trying to create a pub with an id that already exists - if (op.mode === "create") { - throw new PubOpCreateExistingError(pubToCreate.id); - } - index++; - continue; + if (pubToCreate.id && pubToCreate.id !== createdPub?.id && op.mode === "create") { + throw new PubOpCreateExistingError(pubToCreate.id); } - - index++; - } + }); return; } From 64ebece2eab27b80e4c1b7d8c3c6a988d863f193 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Feb 2025 19:38:54 +0100 Subject: [PATCH 22/23] chore: clean up --- core/lib/server/pub-op.db.test.ts | 12 +++++------- core/lib/server/pub-op.ts | 11 +---------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/core/lib/server/pub-op.db.test.ts b/core/lib/server/pub-op.db.test.ts index 75e1dd650..41bb0dc31 100644 --- a/core/lib/server/pub-op.db.test.ts +++ b/core/lib/server/pub-op.db.test.ts @@ -1,11 +1,9 @@ -import { tr } from "date-fns/locale"; -import { beforeAll, beforeEach, describe, expect, expectTypeOf, it, vitest } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; -import type { PubsId, PubTypes, Stages } from "db/public"; +import type { PubsId } from "db/public"; import { CoreSchemaType, MemberRole } from "db/public"; import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; -import type { seedCommunity } from "~/prisma/seed/seedCommunity"; import { mockServerCode } from "~/lib/__tests__/utils"; import { createLastModifiedBy } from "../lastModifiedBy"; import { PubOp } from "./pub-op"; @@ -1052,9 +1050,9 @@ describe("relation management", () => { // | / \ // v v v // G --> E D - // / \ - // v v - // F H + // | \ + // v v + // F H // / \ // v v // K --> L diff --git a/core/lib/server/pub-op.ts b/core/lib/server/pub-op.ts index c72bf6714..b7d1d00d5 100644 --- a/core/lib/server/pub-op.ts +++ b/core/lib/server/pub-op.ts @@ -1,19 +1,10 @@ -import { randomUUID } from "crypto"; - 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, - PubValuesId, - StagesId, -} from "db/public"; +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"; From 72fc7d28c0623c82f1996697196c1ddfbc8c2bb2 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 12 Feb 2025 16:59:06 +0100 Subject: [PATCH 23/23] fix: update matcher types --- core/lib/__tests__/matchers.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/core/lib/__tests__/matchers.ts b/core/lib/__tests__/matchers.ts index 2387c1344..75f3d31c6 100644 --- a/core/lib/__tests__/matchers.ts +++ b/core/lib/__tests__/matchers.ts @@ -17,10 +17,7 @@ const deepSortValues = (pub: ProcessedPub): ProcessedPub => { }; expect.extend({ - async toExist(received: PubsId | ProcessedPub, expected?: typeof db) { - if (typeof received !== "string") { - throw new Error("toExist() can only be called with a PubsId"); - } + async toExist(received: PubsId, expected?: typeof db) { const { getPlainPub } = await import("../server/pub"); const pub = await getPlainPub(received, expected).executeTakeFirst(); @@ -36,14 +33,7 @@ expect.extend({ }; }, - toHaveValues( - received: PubsId | ProcessedPub, - expected: Partial[] - ) { - if (typeof received === "string") { - throw new Error("toHaveValues() can only be called with a ProcessedPub"); - } - + toHaveValues(received: ProcessedPub, expected: Partial[]) { const pub = received; const sortedPubValues = deepSortValues(pub);