diff --git a/apps/api/src/routes/content/collections.test.ts b/apps/api/src/routes/content/collections.test.ts index 418c1f2..d492798 100644 --- a/apps/api/src/routes/content/collections.test.ts +++ b/apps/api/src/routes/content/collections.test.ts @@ -18,14 +18,10 @@ describe("Collections Routes Tests", () => { vi.mocked(db.query.collections.findMany).mockResolvedValue([ { slug: "harsh-leadership-truths", - data: [ - { - title: "Harsh Leadership Truths", - description: - "Exploring the often overlooked realities of engineering leadership.", - coverImage: "content/cover.png", - }, - ], + title: "Harsh Leadership Truths", + description: + "Exploring the often overlooked realities of engineering leadership.", + coverImage: "content/cover.png", authors: [ { slug: "crutchcorn", @@ -91,14 +87,10 @@ describe("Collections Routes Tests", () => { vi.mocked(db.query.collections.findMany).mockResolvedValue([ { slug: "harsh-leadership-truths", - data: [ - { - title: "Harsh Leadership Truths", - description: - "Exploring the often overlooked realities of engineering leadership.", - coverImage: null, - }, - ], + title: "Harsh Leadership Truths", + description: + "Exploring the often overlooked realities of engineering leadership.", + coverImage: null, authors: [ { slug: "crutchcorn", diff --git a/apps/api/src/routes/content/collections.ts b/apps/api/src/routes/content/collections.ts index b6219cd..0c12646 100644 --- a/apps/api/src/routes/content/collections.ts +++ b/apps/api/src/routes/content/collections.ts @@ -6,6 +6,7 @@ import { createImageUrl } from "../../utils.ts"; const CollectionsQueryParamsSchema = Type.Object({ locale: Type.String({ default: "en" }), + branch: Type.String({ default: "main" }), page: Type.Number({ minimum: 0 }), limit: Type.Number({ minimum: 1 }), author: Type.Optional(Type.String()), @@ -97,14 +98,14 @@ const collectionsRoutes: FastifyPluginAsync = async (fastify) => { const queryParams = request.query; const collections = await db.query.collections.findMany({ - where: queryParams.author - ? { authors: { slug: queryParams.author } } - : undefined, + where: { + locale: queryParams.locale, + branch: queryParams.branch, + authors: queryParams.author + ? { slug: queryParams.author } + : undefined, + }, with: { - data: { - columns: { coverImage: true, title: true, description: true }, - where: { locale: queryParams.locale }, - }, authors: { columns: { slug: true, name: true, profileImage: true } }, }, extras: { @@ -117,16 +118,13 @@ const collectionsRoutes: FastifyPluginAsync = async (fastify) => { const collectionsResponse: CollectionsResponse = []; for (const collection of collections) { - const collectionData = collection.data[0]; - if (!collectionData) continue; - const formattedCollection: CollectionsResponse[number] = { slug: collection.slug, - coverUrl: collectionData.coverImage - ? createImageUrl(collectionData.coverImage) + coverUrl: collection.coverImage + ? createImageUrl(collection.coverImage) : undefined, - title: collectionData.title, - description: collectionData.description, + title: collection.title, + description: collection.description, chapterCount: collection.chapterCount, authors: [], }; diff --git a/apps/api/src/routes/content/post.test.ts b/apps/api/src/routes/content/post.test.ts index 92d82aa..47d9ba0 100644 --- a/apps/api/src/routes/content/post.test.ts +++ b/apps/api/src/routes/content/post.test.ts @@ -14,7 +14,7 @@ describe("Post Routes Tests", () => { }); describe("/content/post/:slug", () => { - test("returns a post with its authors and a chapter list sorted by collectionOrder", async () => { + test("returns a post with its authors and a chapter list", async () => { vi.mocked(db.query.posts.findFirst).mockResolvedValue({ slug: "chapter-two", title: "Chapter Two", @@ -30,22 +30,22 @@ describe("Post Routes Tests", () => { profileImage: "content/profile.png", }, ], - collection: { - slug: "example-collection", - data: [{ title: "Example Collection" }], - posts: [ - { - slug: "chapter-two", - collectionOrder: 1, - title: "Chapter Two", - }, - { - slug: "chapter-one", - collectionOrder: 0, - title: "Chapter One", - }, - ], - }, + collections: [ + { + slug: "example-collection", + title: "Example Collection", + posts: [ + { + slug: "chapter-one", + title: "Chapter One", + }, + { + slug: "chapter-two", + title: "Chapter Two", + }, + ], + }, + ], versions: [], } as never); @@ -103,7 +103,7 @@ describe("Post Routes Tests", () => { authors: [ { slug: "crutchcorn", name: "Corbin Crutchley", profileImage: null }, ], - collection: null, + collections: [], versions: [], } as never); @@ -142,7 +142,7 @@ describe("Post Routes Tests", () => { wordCount: 100, publishedAt: null, authors: [], - collection: null, + collections: [], } as never); const response = await app.inject({ @@ -159,77 +159,6 @@ describe("Post Routes Tests", () => { `); }); - test("excludes unpublished sibling chapters from the chapter list", async () => { - vi.mocked(db.query.posts.findFirst).mockResolvedValue({ - slug: "chapter-one", - title: "Chapter One", - description: "The first chapter", - bannerImage: null, - socialImage: null, - wordCount: 300, - publishedAt: new Date("2024-01-15T00:00:00Z"), - authors: [], - collection: { - slug: "example-collection", - data: [{ title: "Example Collection" }], - posts: [ - { - slug: "chapter-one", - collectionOrder: 0, - title: "Chapter One", - publishedAt: new Date("2024-01-15T00:00:00Z"), - }, - { - slug: "chapter-two-draft", - collectionOrder: 1, - title: "Chapter Two (Draft)", - publishedAt: null, - }, - { - slug: "chapter-three", - collectionOrder: 2, - title: "Chapter Three", - publishedAt: new Date("2024-01-20T00:00:00Z"), - }, - ], - }, - versions: [], - } as never); - - const response = await app.inject({ - method: "GET", - url: "/content/post/chapter-one", - query: { locale: "en" }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toMatchInlineSnapshot(` - { - "authors": [], - "collection": { - "chapters": [ - { - "slug": "chapter-one", - "title": "Chapter One", - }, - { - "slug": "chapter-three", - "title": "Chapter Three", - }, - ], - "slug": "example-collection", - "title": "Example Collection", - }, - "description": "The first chapter", - "publishedAt": "2024-01-15T00:00:00.000Z", - "slug": "chapter-one", - "title": "Chapter One", - "versions": [], - "wordCount": 300, - } - `); - }); - test("includes the current post in the versions list, in the order returned by the query", async () => { vi.mocked(db.query.posts.findFirst).mockResolvedValue({ slug: "example-post", @@ -240,7 +169,7 @@ describe("Post Routes Tests", () => { wordCount: 400, publishedAt: new Date("2024-01-15T00:00:00Z"), authors: [], - collection: null, + collections: [], versions: [ { slug: "example-post", @@ -308,7 +237,7 @@ describe("Post Routes Tests", () => { wordCount: 200, publishedAt: new Date("2024-01-15T00:00:00Z"), authors: [], - collection: null, + collections: [], versions: [], } as never); diff --git a/apps/api/src/routes/content/post.ts b/apps/api/src/routes/content/post.ts index ce39c92..f1959fa 100644 --- a/apps/api/src/routes/content/post.ts +++ b/apps/api/src/routes/content/post.ts @@ -134,20 +134,26 @@ const postRoutes: FastifyPluginAsync = async (fastify) => { }, with: { authors: { columns: { slug: true, name: true, profileImage: true } }, - collection: { + collections: { + columns: { slug: true, title: true }, + where: { locale, branch }, with: { - data: { - columns: { title: true }, - where: { locale }, - }, posts: { columns: { slug: true, - collectionOrder: true, title: true, - publishedAt: true, }, - where: { locale, branch }, + where: { + locale, + branch, + publishedAt: { + isNotNull: true, + }, + }, + orderBy: { + collectionOrder: "asc", + publishedAt: "asc", + }, }, }, }, @@ -178,20 +184,17 @@ const postRoutes: FastifyPluginAsync = async (fastify) => { return; } - const collectionData = post.collection?.data[0]; + const collectionData = post.collections[0]; const collection: PostResponse["collection"] = - post.collection && collectionData + post.collections && collectionData ? { - slug: post.collection.slug, + slug: collectionData.slug, title: collectionData.title, - chapters: post.collection.posts - .filter((chapter) => chapter.publishedAt !== null) - .sort((a, b) => a.collectionOrder - b.collectionOrder) - .map((chapter) => ({ - slug: chapter.slug, - title: chapter.title, - })), + chapters: collectionData.posts.map((chapter) => ({ + slug: chapter.slug, + title: chapter.title, + })), } : undefined; diff --git a/apps/worker/src/tasks/grant-author-achievements/processor.ts b/apps/worker/src/tasks/grant-author-achievements/processor.ts index 8d5ece7..aed8c76 100644 --- a/apps/worker/src/tasks/grant-author-achievements/processor.ts +++ b/apps/worker/src/tasks/grant-author-achievements/processor.ts @@ -4,7 +4,7 @@ import { authorAchievements, postAuthors, collectionAuthors, - collectionData, + collections, posts, } from "@playfulprogramming/db"; import { createInstallationClient } from "@playfulprogramming/github-api"; @@ -91,16 +91,17 @@ export default createProcessor(Tasks.GRANT_AUTHOR_ACHIEVEMENTS, async (job) => { .select({ value: count() }) .from(collectionAuthors) .innerJoin( - collectionData, + collections, and( - eq(collectionData.slug, collectionAuthors.collectionSlug), - eq(collectionData.locale, "en"), + eq(collections.id, collectionAuthors.collectionId), + eq(collections.branch, "main"), + eq(collections.locale, "en"), ), ) .where( and( eq(collectionAuthors.authorSlug, authorSlug), - isNotNull(collectionData.publishedAt), + isNotNull(collections.publishedAt), ), ); const collectionCount = collectionCountResult[0]?.value ?? 0; diff --git a/apps/worker/src/tasks/sync-author/processor.test.ts b/apps/worker/src/tasks/sync-author/processor.test.ts index 59722b5..baebec2 100644 --- a/apps/worker/src/tasks/sync-author/processor.test.ts +++ b/apps/worker/src/tasks/sync-author/processor.test.ts @@ -4,7 +4,7 @@ import { db, authors, authorRoles } from "@playfulprogramming/db"; import { s3 } from "@playfulprogramming/s3"; import { createInstallationClient } from "@playfulprogramming/github-api"; import { Readable } from "node:stream"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { uploadProcessedImage } from "../../utils/uploadProcessedImage.ts"; const github = await createInstallationClient(0); @@ -13,27 +13,12 @@ function fakeJob(data: Data): Job { return { data } as unknown as Job; } -test("Creates an example author successfully", async () => { - const insertAuthorsValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertAuthorRolesValues = vi.fn(); - - vi.mocked(db.insert).mockImplementation((table) => { - if (table === authors) { - return { values: insertAuthorsValues } as never; - } - if (table === authorRoles) { - return { values: insertAuthorRolesValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); +const insertAuthorsValues = db.insert(authors).values; +const deleteAuthorsWhere = db.delete(authors).where; +const insertAuthorRolesValues = db.insert(authorRoles).values; +const deleteAuthorRolesWhere = db.delete(authorRoles).where; +test("Creates an example author successfully", async () => { vi.mocked(github.getContentsRaw).mockImplementation((params) => { if (params.path === "/content/example/index.md") { return Promise.resolve({ @@ -87,6 +72,7 @@ test("Creates an example author successfully", async () => { expect(insertAuthorsValues).toHaveBeenCalledWith({ slug: "example", name: "Example Person", + branch: "main", description: "Hello", profileImage: "profiles/example.jpeg", meta: { @@ -95,7 +81,7 @@ test("Creates an example author successfully", async () => { }); // Old roles were deleted, new roles were inserted - expect(deleteWhere).toHaveBeenCalledWith( + expect(deleteAuthorRolesWhere).toHaveBeenCalledWith( eq(authorRoles.authorSlug, "example"), ); expect(insertAuthorRolesValues).toHaveBeenCalledWith([ @@ -105,26 +91,6 @@ test("Creates an example author successfully", async () => { }); test("Replaces an existing author's roles on a subsequent sync with a different role set", async () => { - const insertAuthorsValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertAuthorRolesValues = vi.fn(); - - vi.mocked(db.insert).mockImplementation((table) => { - if (table === authors) { - return { values: insertAuthorsValues } as never; - } - if (table === authorRoles) { - return { values: insertAuthorRolesValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - vi.mocked(github.getContentsRaw).mockImplementationOnce((params) => { if (params.path === "/content/example/index.md") { return Promise.resolve({ @@ -180,7 +146,7 @@ test("Replaces an existing author's roles on a subsequent sync with a different ); // Old roles were deleted again, and only the new role set was inserted - expect(deleteWhere).toHaveBeenCalledWith( + expect(deleteAuthorRolesWhere).toHaveBeenCalledWith( eq(authorRoles.authorSlug, "example"), ); expect(insertAuthorRolesValues).toHaveBeenLastCalledWith([ @@ -190,26 +156,6 @@ test("Replaces an existing author's roles on a subsequent sync with a different }); test("Inserts no rows for an author with an empty roles array", async () => { - const insertAuthorsValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertAuthorRolesValues = vi.fn(); - - vi.mocked(db.insert).mockImplementation((table) => { - if (table === authors) { - return { values: insertAuthorsValues } as never; - } - if (table === authorRoles) { - return { values: insertAuthorRolesValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - vi.mocked(github.getContentsRaw).mockImplementation((params) => { if (params.path === "/content/example/index.md") { return Promise.resolve({ @@ -235,18 +181,13 @@ test("Inserts no rows for an author with an empty roles array", async () => { ); // Roles were still deleted (in case any existed previously), but nothing was inserted - expect(deleteWhere).toHaveBeenCalledWith( + expect(deleteAuthorRolesWhere).toHaveBeenCalledWith( eq(authorRoles.authorSlug, "example"), ); expect(insertAuthorRolesValues).not.toHaveBeenCalled(); }); test("Deletes an author record if it no longer exists", async () => { - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - vi.mocked(github.getContentsRaw).mockImplementation((params) => { if (params.path === "/content/example/index.md") { return Promise.resolve({ @@ -266,7 +207,9 @@ test("Deletes an author record if it no longer exists", async () => { ); // The author was deleted from the database - expect(deleteWhere).toHaveBeenCalledWith(eq(authors.slug, "example")); + expect(deleteAuthorsWhere).toHaveBeenCalledWith( + and(eq(authors.slug, "example"), eq(authors.branch, "main")), + ); }); test("Rejects the profile image upload when the signal is already aborted", async () => { diff --git a/apps/worker/src/tasks/sync-author/processor.ts b/apps/worker/src/tasks/sync-author/processor.ts index d80c420..e39c459 100644 --- a/apps/worker/src/tasks/sync-author/processor.ts +++ b/apps/worker/src/tasks/sync-author/processor.ts @@ -5,6 +5,7 @@ import { authors, authorAchievements, authorRoles, + authorSlugs, } from "@playfulprogramming/db"; import { createInstallationClient } from "@playfulprogramming/github-api"; import { createProcessor } from "../../createProcessor.ts"; @@ -17,9 +18,9 @@ import { uploadProcessedImage } from "../../utils/uploadProcessedImage.ts"; const PROFILE_IMAGE_SIZE_MAX = 2048; export default createProcessor(Tasks.SYNC_AUTHOR, async (job, { signal }) => { - const authorId = job.data.author; + const authorSlug = job.data.author; const authorMetaUrl = new URL( - `content/${encodeURIComponent(authorId)}/index.md`, + `content/${encodeURIComponent(authorSlug)}/index.md`, "http://localhost", ); const github = await createInstallationClient(job.data.installation.id); @@ -35,13 +36,17 @@ export default createProcessor(Tasks.SYNC_AUTHOR, async (job, { signal }) => { if (authorMetaResponse.data === undefined) { if (authorMetaResponse.status == 404) { console.log( - `Metadata for ${authorId} (${authorMetaUrl.pathname}) returned 404 - removing profile entry.`, + `Metadata for ${authorSlug} (${authorMetaUrl.pathname}) returned 404 - removing profile entry.`, ); - await db.delete(authors).where(eq(authors.slug, authorId)); + await db + .delete(authors) + .where( + and(eq(authors.slug, authorSlug), eq(authors.branch, job.data.ref)), + ); return; } - throw new Error(`Unable to fetch author data for ${authorId}`); + throw new Error(`Unable to fetch author data for ${authorSlug}`); } const { data } = matter(authorMetaResponse.data); @@ -60,11 +65,11 @@ export default createProcessor(Tasks.SYNC_AUTHOR, async (job, { signal }) => { if (profileImgStream === null || typeof profileImgStream === "undefined") { throw new Error( - `Unable to fetch profile image for ${authorId} (${profileImgUrl.pathname})`, + `Unable to fetch profile image for ${authorSlug} (${profileImgUrl.pathname})`, ); } - profileImgKey = `profiles/${authorId}.jpeg`; + profileImgKey = `profiles/${authorSlug}.jpeg`; await uploadProcessedImage( profileImgStream, profileImgKey, @@ -74,7 +79,8 @@ export default createProcessor(Tasks.SYNC_AUTHOR, async (job, { signal }) => { } const result = { - slug: authorId, + slug: authorSlug, + branch: job.data.ref, name: authorData.name, description: authorData.description, profileImage: profileImgKey, @@ -90,46 +96,61 @@ export default createProcessor(Tasks.SYNC_AUTHOR, async (job, { signal }) => { await db.transaction(async (tx) => { await tx - .insert(authors) - .values(result) - .onConflictDoUpdate({ target: authors.slug, set: result }); + .insert(authorSlugs) + .values({ slug: authorSlug }) + .onConflictDoNothing(); await tx - .delete(authorAchievements) - .where( - and( - eq(authorAchievements.authorSlug, authorId), - inArray( - authorAchievements.achievementId, - MANUAL_ACHIEVEMENT_IDS as unknown as string[], + .insert(authors) + .values(result) + .onConflictDoUpdate({ + target: [authors.slug, authors.branch], + set: result, + }) + .returning({ id: authors.id }); + + if (job.data.ref === "main") { + await tx + .delete(authorAchievements) + .where( + and( + eq(authorAchievements.authorSlug, authorSlug), + inArray( + authorAchievements.achievementId, + MANUAL_ACHIEVEMENT_IDS as unknown as string[], + ), ), - ), - ); - - if (earnedManualIds.length > 0) { - await tx.insert(authorAchievements).values( - earnedManualIds.map((achievementId) => ({ - authorSlug: authorId, - achievementId, - })), - ); - } - - await tx.delete(authorRoles).where(eq(authorRoles.authorSlug, authorId)); - - if (authorData.roles.length > 0) { - await tx.insert(authorRoles).values( - authorData.roles.map((role) => ({ - authorSlug: authorId, - role, - })), - ); + ); + + if (earnedManualIds.length > 0) { + await tx.insert(authorAchievements).values( + earnedManualIds.map((achievementId: string) => ({ + authorSlug, + achievementId, + })), + ); + } + + await tx + .delete(authorRoles) + .where(eq(authorRoles.authorSlug, authorSlug)); + + if (authorData.roles.length > 0) { + await tx.insert(authorRoles).values( + authorData.roles.map((role) => ({ + authorSlug, + role, + })), + ); + } } }); - await createJob( - Tasks.GRANT_AUTHOR_ACHIEVEMENTS, - `grant-author-achievements:${authorId}`, - { authorSlug: authorId, installation: job.data.installation }, - ); + if (job.data.ref === "main") { + await createJob( + Tasks.GRANT_AUTHOR_ACHIEVEMENTS, + `grant-author-achievements:${authorSlug}`, + { authorSlug, installation: job.data.installation }, + ); + } }); diff --git a/apps/worker/src/tasks/sync-collection/processor.test.ts b/apps/worker/src/tasks/sync-collection/processor.test.ts index a71d857..4dc5f22 100644 --- a/apps/worker/src/tasks/sync-collection/processor.test.ts +++ b/apps/worker/src/tasks/sync-collection/processor.test.ts @@ -3,14 +3,14 @@ import type { Job } from "bullmq"; import { collections, collectionAuthors, - collectionData, collectionTags, db, + collectionSlugs, } from "@playfulprogramming/db"; import { s3 } from "@playfulprogramming/s3"; import { createInstallationClient } from "@playfulprogramming/github-api"; import { Readable } from "node:stream"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { uploadProcessedImage } from "../../utils/uploadProcessedImage.ts"; const mockImage = `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`; @@ -20,35 +20,22 @@ function fakeJob(data: Data): Job { return { data } as unknown as Job; } -test("Creates an example collection successfully", async () => { - const insertCollectionValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertCollectionDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertAuthorValues = vi.fn(); - const insertTagsValues = vi.fn(); - vi.mocked(db.insert).mockImplementation((table) => { - if (table === collections) { - return { values: insertCollectionValues } as never; - } - if (table === collectionData) { - return { values: insertCollectionDataValues } as never; - } - if (table === collectionAuthors) { - return { values: insertAuthorValues } as never; - } - if (table === collectionTags) { - return { values: insertTagsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); +const deleteCollectionWhere = db.delete(collections).where; +const insertCollectionSlugValues = db.insert(collectionSlugs).values; +const insertCollectionValues = db.insert(collections).values; +const insertCollectionValuesReturning = db + .insert(collections) + .values(expect.anything()).returning; +const insertAuthorValues = db.insert(collectionAuthors).values; +const deleteAuthorsWhere = db.delete(collectionAuthors).where; +const insertTagsValues = db.insert(collectionTags).values; +const deleteTagsWhere = db.delete(collectionTags).where; - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); +test("Creates an example collection successfully", async () => { + const collectionId = "00000000-0000-0000-0000-000000000000"; + vi.mocked(insertCollectionValuesReturning).mockResolvedValue([ + { id: collectionId }, + ]); vi.mocked(github.getContents).mockImplementation(((params: { path: string; @@ -117,7 +104,7 @@ tags: ); // The cover image was uploaded to S3 - expect(s3.upload).toBeCalledWith( + expect(s3.upload).toHaveBeenCalledWith( "example-bucket", "collections/example-collection/en/cover.jpg", undefined, @@ -126,10 +113,13 @@ tags: ); // The collection was inserted into the database - expect(insertCollectionValues).toBeCalledWith({ slug: "example-collection" }); - expect(insertCollectionDataValues).toBeCalledWith({ + expect(insertCollectionSlugValues).toHaveBeenCalledWith({ + slug: "example-collection", + }); + expect(insertCollectionValues).toHaveBeenCalledWith({ slug: "example-collection", locale: "en", + branch: "main", title: "Example Collection", description: "A test collection", coverImage: "collections/example-collection/en/cover.jpg", @@ -142,38 +132,33 @@ tags: }); // The author association was deleted and re-inserted - expect(deleteWhere).toBeCalledWith( - eq(collectionAuthors.collectionSlug, "example-collection"), + expect(deleteAuthorsWhere).toHaveBeenCalledWith( + eq(collectionAuthors.collectionId, collectionId), ); - expect(insertAuthorValues).toBeCalledWith([ + expect(insertAuthorValues).toHaveBeenCalledWith([ { - collectionSlug: "example-collection", + collectionId, authorSlug: "example-author", }, ]); // The tag association was deleted and re-inserted - expect(deleteWhere).toBeCalledWith( - eq(collectionTags.collectionSlug, "example-collection"), + expect(deleteTagsWhere).toHaveBeenCalledWith( + eq(collectionTags.collectionId, collectionId), ); - expect(insertTagsValues).toBeCalledWith([ + expect(insertTagsValues).toHaveBeenCalledWith([ { - collectionSlug: "example-collection", + collectionId, tag: "javascript", }, { - collectionSlug: "example-collection", + collectionId, tag: "tutorial", }, ]); }); test("Deletes a collection record if it no longer exists", async () => { - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - vi.mocked(github.getContents).mockImplementation(((params: { path: string; }) => { @@ -198,37 +183,18 @@ test("Deletes a collection record if it no longer exists", async () => { ); // The collection was deleted from the database - expect(deleteWhere).toBeCalledWith( - eq(collectionData.slug, "example-collection"), + expect(deleteCollectionWhere).toHaveBeenCalledWith( + and( + eq(collections.slug, "example-collection"), + eq(collections.branch, "main"), + ), ); }); test("Fails if author profile does not exist", async () => { - const insertCollectionValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertCollectionDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertCollectionAuthorsValues = vi.fn().mockImplementation(() => { + vi.mocked(insertAuthorValues).mockImplementationOnce(() => { throw new Error("Failed relation constraint"); }); - vi.mocked(db.insert).mockImplementation((table) => { - if (table === collections) { - return { values: insertCollectionValues } as never; - } - if (table === collectionData) { - return { values: insertCollectionDataValues } as never; - } - if (table === collectionAuthors) { - return { values: insertCollectionAuthorsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - vi.mocked(db.delete).mockReturnValue({ - where: vi.fn(), - } as never); vi.mocked(github.getContents).mockImplementation(((params: { path: string; @@ -297,30 +263,10 @@ published: "2023-01-01T00:00:00Z" }); test("Handles collection with multiple authors", async () => { - const insertCollectionValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertCollectionDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertAuthorValues = vi.fn(); - vi.mocked(db.insert).mockImplementation((table) => { - if (table === collections) { - return { values: insertCollectionValues } as never; - } - if (table === collectionData) { - return { values: insertCollectionDataValues } as never; - } - if (table === collectionAuthors) { - return { values: insertAuthorValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); + const collectionId = "00000000-0000-0000-0000-000000000000"; + vi.mocked(insertCollectionValuesReturning).mockResolvedValue([ + { id: collectionId }, + ]); vi.mocked(github.getContents).mockImplementation(((params: { path: string; @@ -388,47 +334,23 @@ published: "2023-01-01T00:00:00Z" ); // Both authors should be inserted (co-author from frontmatter + example-author as the folder owner) - expect(insertAuthorValues).toBeCalledWith([ + expect(insertAuthorValues).toHaveBeenCalledWith([ { - collectionSlug: "example-collection", + collectionId, authorSlug: "co-author", }, { - collectionSlug: "example-collection", + collectionId, authorSlug: "example-author", }, ]); }); test("Replaces tags when synced again with a different tag set", async () => { - const insertCollectionValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertCollectionDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertAuthorValues = vi.fn(); - const insertTagsValues = vi.fn(); - vi.mocked(db.insert).mockImplementation((table) => { - if (table === collections) { - return { values: insertCollectionValues } as never; - } - if (table === collectionData) { - return { values: insertCollectionDataValues } as never; - } - if (table === collectionAuthors) { - return { values: insertAuthorValues } as never; - } - if (table === collectionTags) { - return { values: insertTagsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); + const collectionId = "00000000-0000-0000-0000-000000000000"; + vi.mocked(insertCollectionValuesReturning).mockResolvedValue([ + { id: collectionId }, + ]); vi.mocked(github.getContents).mockImplementation(((params: { path: string; @@ -497,19 +419,19 @@ tags: }), ); - expect(insertTagsValues).toBeCalledWith([ + expect(insertTagsValues).toHaveBeenCalledWith([ { - collectionSlug: "example-collection", + collectionId, tag: "javascript", }, { - collectionSlug: "example-collection", + collectionId, tag: "tutorial", }, ]); - insertTagsValues.mockClear(); - deleteWhere.mockClear(); + vi.mocked(deleteTagsWhere).mockClear(); + vi.mocked(insertTagsValues).mockClear(); // Second sync: a different tag set vi.mocked(github.getContentsRaw).mockImplementation((params) => { @@ -543,59 +465,32 @@ tags: ); // Old tags were deleted and only the new tag set remains - expect(deleteWhere).toBeCalledWith( - eq(collectionTags.collectionSlug, "example-collection"), + expect(deleteTagsWhere).toHaveBeenCalledWith( + eq(collectionTags.collectionId, collectionId), ); - expect(insertTagsValues).toBeCalledWith([ + expect(insertTagsValues).toHaveBeenCalledWith([ { - collectionSlug: "example-collection", + collectionId, tag: "rust", }, ]); - expect(insertTagsValues).not.toBeCalledWith( + expect(insertTagsValues).not.toHaveBeenCalledWith( expect.arrayContaining([expect.objectContaining({ tag: "javascript" })]), ); - expect(insertTagsValues).not.toBeCalledWith( + expect(insertTagsValues).not.toHaveBeenCalledWith( expect.arrayContaining([expect.objectContaining({ tag: "tutorial" })]), ); }); test("Unions tags across all locales", async () => { - const insertCollectionValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertCollectionDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertAuthorValues = vi.fn(); - const insertTagsValues = vi.fn(); - vi.mocked(db.insert).mockImplementation((table) => { - if (table === collections) { - return { values: insertCollectionValues } as never; - } - if (table === collectionData) { - return { values: insertCollectionDataValues } as never; - } - if (table === collectionAuthors) { - return { values: insertAuthorValues } as never; - } - if (table === collectionTags) { - return { values: insertTagsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteCollectionAuthorsWhere = vi.fn(); - const deleteCollectionTagsWhere = vi.fn(); - vi.mocked(db.delete).mockImplementation((table) => { - if (table === collectionAuthors) { - return { where: deleteCollectionAuthorsWhere } as never; - } - if (table === collectionTags) { - return { where: deleteCollectionTagsWhere } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); + const collectionEnId = "00000000-0000-0000-0000-000000000000"; + const collectionEsId = "11111111-1111-1111-1111-111111111111"; + vi.mocked(insertCollectionValuesReturning).mockResolvedValueOnce([ + { id: collectionEnId }, + ]); + vi.mocked(insertCollectionValuesReturning).mockResolvedValueOnce([ + { id: collectionEsId }, + ]); // Return folder listing with both index.md and index.es.md vi.mocked(github.getContents).mockImplementation(((params: { @@ -686,29 +581,45 @@ tags: }), ); - // Assert: tags from both locales are unioned and deduped in a single insert - expect(insertTagsValues).toBeCalledTimes(1); - expect(insertTagsValues).toBeCalledWith([ + // Assert: tags from both locales are unioned and deduped + expect(insertTagsValues).toHaveBeenCalledTimes(2); + expect(insertTagsValues).toHaveBeenCalledWith([ { - collectionSlug: "multilang-tags-collection", + collectionId: collectionEnId, tag: "javascript", }, { - collectionSlug: "multilang-tags-collection", + collectionId: collectionEnId, tag: "tutorial", }, { - collectionSlug: "multilang-tags-collection", + collectionId: collectionEnId, + tag: "espanol", + }, + ]); + expect(insertTagsValues).toHaveBeenCalledWith([ + { + collectionId: collectionEsId, + tag: "javascript", + }, + { + collectionId: collectionEsId, + tag: "tutorial", + }, + { + collectionId: collectionEsId, tag: "espanol", }, ]); - // Assert: the tags delete ran once (not once per locale, unlike author associations) - expect(deleteCollectionTagsWhere).toBeCalledTimes(1); - expect(deleteCollectionTagsWhere).toBeCalledWith( - eq(collectionTags.collectionSlug, "multilang-tags-collection"), + // Assert: the tags delete ran once per locale + expect(deleteTagsWhere).toHaveBeenCalledTimes(2); + expect(deleteTagsWhere).toHaveBeenCalledWith( + eq(collectionTags.collectionId, collectionEnId), + ); + expect(deleteTagsWhere).toHaveBeenCalledWith( + eq(collectionTags.collectionId, collectionEsId), ); - expect(deleteCollectionAuthorsWhere).toBeCalledTimes(2); }); test("Rejects the image upload when the signal is already aborted", async () => { diff --git a/apps/worker/src/tasks/sync-collection/processor.ts b/apps/worker/src/tasks/sync-collection/processor.ts index 2d1e86f..cfdf709 100644 --- a/apps/worker/src/tasks/sync-collection/processor.ts +++ b/apps/worker/src/tasks/sync-collection/processor.ts @@ -2,14 +2,14 @@ import { env, CollectionMetaSchema } from "@playfulprogramming/common"; import { Tasks, createJob } from "@playfulprogramming/bullmq"; import { collectionAuthors, - collectionData, collections, + collectionSlugs, collectionTags, db, } from "@playfulprogramming/db"; import { createInstallationClient } from "@playfulprogramming/github-api"; import { createProcessor } from "../../createProcessor.ts"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import matter from "gray-matter"; import { Value } from "typebox/value"; import { extractLocale } from "../../utils/extractLocale.ts"; @@ -20,12 +20,12 @@ const IMAGE_SIZE_MAX = 2048; export default createProcessor( Tasks.SYNC_COLLECTION, async (job, { signal }) => { - const authorId = job.data.author; - const collectionId = job.data.collection; + const authorSlug = job.data.author; + const collectionSlug = job.data.collection; const client = await createInstallationClient(job.data.installation.id); const collectionMetaUrl = new URL( - `content/${encodeURIComponent(authorId)}/collections/${encodeURIComponent(collectionId)}/`, + `content/${encodeURIComponent(authorSlug)}/collections/${encodeURIComponent(collectionSlug)}/`, "http://localhost", ); @@ -40,22 +40,27 @@ export default createProcessor( if (collectionMetaResponse.data === undefined) { if (collectionMetaResponse.status === 404) { console.log( - `Metadata for ${collectionId} (${collectionMetaUrl.pathname}) returned 404 - removing collection entry.`, + `Metadata for ${collectionSlug} (${collectionMetaUrl.pathname}) returned 404 - removing collection entry.`, ); await db - .delete(collectionData) - .where(eq(collectionData.slug, collectionId)); + .delete(collections) + .where( + and( + eq(collections.slug, collectionSlug), + eq(collections.branch, job.data.ref), + ), + ); return; } - throw new Error(`Unable to fetch collection data for ${collectionId}`); + throw new Error(`Unable to fetch collection data for ${collectionSlug}`); } if ( !collectionMetaResponse.data.entries || !Array.isArray(collectionMetaResponse.data.entries) ) { - throw new Error(`Unable to fetch collection data for ${collectionId}`); + throw new Error(`Unable to fetch collection data for ${collectionSlug}`); } type Entry = (typeof collectionMetaResponse.data.entries)[number]; @@ -83,160 +88,179 @@ export default createProcessor( // so we can enqueue achievements for each of them after the loop. const touchedAuthorSlugs = new Set(); - // Check if coverImg or socialImg have changed since last edit, if so upload to S3 - for (const { entry, locale } of collectionEntries) { - const contentUrl = new URL(entry.path, "http://localhost"); - - const contentResponse = await client.getContentsRaw({ - ref: job.data.ref, - path: contentUrl.pathname, - repoOwner: env.GITHUB_REPO_OWNER, - repoName: env.GITHUB_REPO_NAME, - signal, - }); - - if (contentResponse.data === undefined) { - throw new Error( - `Unable to fetch collection content for ${collectionId} locale ${locale}`, - ); - } - - const { data } = matter(contentResponse.data); - const collectionParsedData = Value.Parse(CollectionMetaSchema, data); + const localeData = await Promise.all( + collectionEntries.map(async ({ entry, locale }) => { + const contentUrl = new URL(entry.path, "http://localhost"); - if (collectionParsedData.tags) { - collectionParsedData.tags.forEach((tag) => allTags.add(tag)); - } - - let coverImgKey: string | null = null; - let socialImgKey: string | null = null; - if (collectionParsedData.coverImg) { - const coverImgUrl = new URL( - collectionParsedData.coverImg, - collectionMetaUrl, - ); - const { data: coverImgStream } = await client.getContentsRawStream({ + const contentResponse = await client.getContentsRaw({ ref: job.data.ref, - path: coverImgUrl.pathname, + path: contentUrl.pathname, repoOwner: env.GITHUB_REPO_OWNER, repoName: env.GITHUB_REPO_NAME, signal, }); - if (coverImgStream === null || typeof coverImgStream === "undefined") { + if (contentResponse.data === undefined) { throw new Error( - `Unable to fetch cover image for ${collectionId} (${coverImgUrl.pathname})`, + `Unable to fetch collection content for ${collectionSlug} locale ${locale}`, ); } - coverImgKey = `collections/${collectionId}/${locale}/cover.jpg`; - await uploadProcessedImage( - coverImgStream, - coverImgKey, - IMAGE_SIZE_MAX, - signal, - ); - } + const { data } = matter(contentResponse.data); + const collectionParsedData = Value.Parse(CollectionMetaSchema, data); - if (collectionParsedData.socialImg) { - const socialImgUrl = new URL( - collectionParsedData.socialImg, - collectionMetaUrl, - ); - const { data: socialImgStream } = await client.getContentsRawStream({ - ref: job.data.ref, - path: socialImgUrl.pathname, - repoOwner: env.GITHUB_REPO_OWNER, - repoName: env.GITHUB_REPO_NAME, - signal, - }); + if (collectionParsedData.tags) { + collectionParsedData.tags.forEach((tag) => allTags.add(tag)); + } - if ( - socialImgStream === null || - typeof socialImgStream === "undefined" - ) { - throw new Error( - `Unable to fetch social image for ${collectionId} (${socialImgUrl.pathname})`, + // Check if coverImg or socialImg have changed since last edit, if so upload to S3 + let coverImgKey: string | null = null; + let socialImgKey: string | null = null; + if (collectionParsedData.coverImg) { + const coverImgUrl = new URL( + collectionParsedData.coverImg, + collectionMetaUrl, + ); + const { data: coverImgStream } = await client.getContentsRawStream({ + ref: job.data.ref, + path: coverImgUrl.pathname, + repoOwner: env.GITHUB_REPO_OWNER, + repoName: env.GITHUB_REPO_NAME, + signal, + }); + + if ( + coverImgStream === null || + typeof coverImgStream === "undefined" + ) { + throw new Error( + `Unable to fetch cover image for ${collectionSlug} (${coverImgUrl.pathname})`, + ); + } + + coverImgKey = `collections/${collectionSlug}/${locale}/cover.jpg`; + await uploadProcessedImage( + coverImgStream, + coverImgKey, + IMAGE_SIZE_MAX, + signal, ); } - socialImgKey = `collections/${collectionId}/${locale}/social.jpg`; - await uploadProcessedImage( - socialImgStream, + if (collectionParsedData.socialImg) { + const socialImgUrl = new URL( + collectionParsedData.socialImg, + collectionMetaUrl, + ); + const { data: socialImgStream } = await client.getContentsRawStream({ + ref: job.data.ref, + path: socialImgUrl.pathname, + repoOwner: env.GITHUB_REPO_OWNER, + repoName: env.GITHUB_REPO_NAME, + signal, + }); + + if ( + socialImgStream === null || + typeof socialImgStream === "undefined" + ) { + throw new Error( + `Unable to fetch social image for ${collectionSlug} (${socialImgUrl.pathname})`, + ); + } + + socialImgKey = `collections/${collectionSlug}/${locale}/social.jpg`; + await uploadProcessedImage( + socialImgStream, + socialImgKey, + IMAGE_SIZE_MAX, + signal, + ); + } + + return { + locale, + parsed: collectionParsedData, + coverImgKey, socialImgKey, - IMAGE_SIZE_MAX, - signal, + }; + }), + ); + + await db.transaction(async (tx) => { + // Remove the existing collection records (relations are removed by cascading deletes) + await tx + .delete(collections) + .where( + and( + eq(collections.slug, collectionSlug), + eq(collections.branch, job.data.ref), + ), ); - } - const result = { - slug: collectionId, - locale: locale, - title: collectionParsedData.title, - description: collectionParsedData.description, - coverImage: coverImgKey, - socialImage: socialImgKey, - meta: { - buttons: collectionParsedData.buttons, - tags: collectionParsedData.tags, - chapterList: collectionParsedData.chapterList, - }, - }; - - // Handle authors - const authorSlugs = collectionParsedData.authors - ? [...new Set([...collectionParsedData.authors, authorId])] - : [authorId]; - - authorSlugs.forEach((s) => touchedAuthorSlugs.add(s)); - - await db.transaction(async (tx) => { - await tx + // Create a slug record (if it doesn't already exist) + await tx + .insert(collectionSlugs) + .values({ slug: collectionSlug }) + .onConflictDoNothing(); + + for (const { locale, parsed, ...data } of localeData) { + const result = { + slug: collectionSlug, + locale: locale, + branch: job.data.ref, + title: parsed.title, + description: parsed.description, + coverImage: data.coverImgKey, + socialImage: data.socialImgKey, + meta: { + buttons: parsed.buttons, + tags: parsed.tags, + chapterList: parsed.chapterList, + }, + }; + + // Handle authors + const authorSlugs = parsed.authors + ? [...new Set([...parsed.authors, authorSlug])] + : [authorSlug]; + + authorSlugs.forEach((s) => touchedAuthorSlugs.add(s)); + + const [collectionRecord] = await tx .insert(collections) - .values({ slug: collectionId }) - .onConflictDoNothing(); - - await tx - .insert(collectionData) .values(result) - .onConflictDoUpdate({ - target: [collectionData.slug, collectionData.locale], - set: result, - }); + .returning({ id: collections.id }); // Delete existing author associations for this collection await tx .delete(collectionAuthors) - .where(eq(collectionAuthors.collectionSlug, collectionId)); + .where(eq(collectionAuthors.collectionId, collectionRecord.id)); // Insert new author associations if (authorSlugs.length > 0) { await tx.insert(collectionAuthors).values( authorSlugs.map((authorSlug) => ({ - collectionSlug: collectionId, + collectionId: collectionRecord.id, authorSlug, })), ); } - }); - } - - const tags = [...allTags]; - await db.transaction(async (tx) => { - // Delete existing tag associations for this collection - await tx - .delete(collectionTags) - .where(eq(collectionTags.collectionSlug, collectionId)); - - // Insert new tag associations - if (tags.length > 0) { - await tx.insert(collectionTags).values( - tags.map((tag) => ({ - collectionSlug: collectionId, - tag, - })), - ); + // Delete existing tag associations for this collection + await tx + .delete(collectionTags) + .where(eq(collectionTags.collectionId, collectionRecord.id)); + + // Insert new tag associations + if (allTags.size > 0) { + await tx.insert(collectionTags).values( + [...allTags].map((tag) => ({ + collectionId: collectionRecord.id, + tag, + })), + ); + } } }); diff --git a/apps/worker/src/tasks/sync-post/processor.ts b/apps/worker/src/tasks/sync-post/processor.ts index f35197d..075d187 100644 --- a/apps/worker/src/tasks/sync-post/processor.ts +++ b/apps/worker/src/tasks/sync-post/processor.ts @@ -349,6 +349,11 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { ); await db.transaction(async (tx) => { + // Remove the existing post records (relations are removed by cascading deletes) + await tx + .delete(posts) + .where(and(eq(posts.slug, post), eq(posts.branch, ref))); + for (const { locale, parsed, wordCount } of localeData) { let groupId: string | undefined; if (parsed.upToDateSlug) { @@ -371,17 +376,6 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { } } - // Remove the existing post record (relations are removed by cascading deletes) - await tx - .delete(posts) - .where( - and( - eq(posts.slug, post), - eq(posts.locale, locale), - eq(posts.branch, ref), - ), - ); - const postValues = { slug: post, locale, @@ -412,9 +406,7 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { .returning({ id: posts.id }); const authorSlugs = new Set([author, ...(parsed.authors ?? [])]); - parsed.authors?.forEach((authorSlug) => - affectedAuthorSlugs.add(authorSlug), - ); + authorSlugs.forEach((authorSlug) => affectedAuthorSlugs.add(authorSlug)); await tx.insert(postAuthors).values( Array.from( diff --git a/packages/db/drizzle/20260917034659_misty_stick/migration.sql b/packages/db/drizzle/20260917034659_misty_stick/migration.sql new file mode 100644 index 0000000..a002e7f --- /dev/null +++ b/packages/db/drizzle/20260917034659_misty_stick/migration.sql @@ -0,0 +1,46 @@ +CREATE TABLE "author_slugs" ( + "slug" text PRIMARY KEY +); +--> statement-breakpoint +CREATE TABLE "collection_slugs" ( + "slug" text PRIMARY KEY +); +--> statement-breakpoint +ALTER TABLE "collection_authors" DROP CONSTRAINT "collection_authors_collection_slug_collections_slug_fk";--> statement-breakpoint +ALTER TABLE "collection_authors" DROP CONSTRAINT "collection_authors_author_slug_profiles_slug_fk";--> statement-breakpoint +ALTER TABLE "collection_tags" DROP CONSTRAINT "collection_tags_collection_slug_collections_slug_fkey";--> statement-breakpoint +ALTER TABLE "post_authors" DROP CONSTRAINT "post_authors_author_slug_profiles_slug_fkey";--> statement-breakpoint +ALTER TABLE "posts" DROP CONSTRAINT "posts_collection_slug_collections_slug_fkey";--> statement-breakpoint +ALTER TABLE "author_achievements" DROP CONSTRAINT "profile_achievements_profile_slug_profiles_slug_fkey";--> statement-breakpoint +ALTER TABLE "author_roles" DROP CONSTRAINT "author_roles_profile_slug_profiles_slug_fkey";--> statement-breakpoint +DROP TABLE "collection_data";--> statement-breakpoint +ALTER TABLE "collection_authors" DROP CONSTRAINT "collection_authors_collection_slug_author_slug_pk";--> statement-breakpoint +ALTER TABLE "authors" DROP CONSTRAINT "profiles_pkey";--> statement-breakpoint +ALTER TABLE "authors" ADD COLUMN "id" uuid DEFAULT gen_random_uuid();--> statement-breakpoint +ALTER TABLE "authors" ADD COLUMN "branch" text NOT NULL;--> statement-breakpoint +ALTER TABLE "collection_authors" ADD COLUMN "collection_id" uuid;--> statement-breakpoint +ALTER TABLE "collection_tags" ADD COLUMN "collection_id" uuid;--> statement-breakpoint +ALTER TABLE "collections" ADD COLUMN "id" uuid DEFAULT gen_random_uuid();--> statement-breakpoint +ALTER TABLE "collections" ADD COLUMN "locale" text NOT NULL;--> statement-breakpoint +ALTER TABLE "collections" ADD COLUMN "branch" text NOT NULL;--> statement-breakpoint +ALTER TABLE "collections" ADD COLUMN "title" text NOT NULL;--> statement-breakpoint +ALTER TABLE "collections" ADD COLUMN "description" text DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE "collections" ADD COLUMN "published_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "collections" ADD COLUMN "meta" jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "collections" ADD COLUMN "cover_image" text;--> statement-breakpoint +ALTER TABLE "collections" ADD COLUMN "social_image" text;--> statement-breakpoint +ALTER TABLE "collection_authors" ADD PRIMARY KEY ("collection_id","author_slug");--> statement-breakpoint +ALTER TABLE "authors" ADD PRIMARY KEY ("id");--> statement-breakpoint +ALTER TABLE "collection_authors" DROP COLUMN "collection_slug";--> statement-breakpoint +ALTER TABLE "collection_tags" DROP COLUMN "collection_slug";--> statement-breakpoint +ALTER TABLE "collection_tags" ADD PRIMARY KEY ("collection_id","tag");--> statement-breakpoint +ALTER TABLE "collections" ADD PRIMARY KEY ("id");--> statement-breakpoint +ALTER TABLE "authors" ADD CONSTRAINT "authors_slug_branch_unique" UNIQUE("slug","branch");--> statement-breakpoint +ALTER TABLE "collections" ADD CONSTRAINT "collections_slug_locale_branch_unique" UNIQUE("slug","locale","branch");--> statement-breakpoint +ALTER TABLE "authors" ADD CONSTRAINT "authors_slug_author_slugs_slug_fkey" FOREIGN KEY ("slug") REFERENCES "author_slugs"("slug") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "collection_authors" ADD CONSTRAINT "collection_authors_collection_id_collections_id_fkey" FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "collection_authors" ADD CONSTRAINT "collection_authors_author_slug_author_slugs_slug_fkey" FOREIGN KEY ("author_slug") REFERENCES "author_slugs"("slug") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "collection_tags" ADD CONSTRAINT "collection_tags_collection_id_collections_id_fkey" FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "collections" ADD CONSTRAINT "collections_slug_collection_slugs_slug_fkey" FOREIGN KEY ("slug") REFERENCES "collection_slugs"("slug") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "post_authors" ADD CONSTRAINT "post_authors_author_slug_author_slugs_slug_fkey" FOREIGN KEY ("author_slug") REFERENCES "author_slugs"("slug") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_collection_slug_collection_slugs_slug_fkey" FOREIGN KEY ("collection_slug") REFERENCES "collection_slugs"("slug") ON DELETE SET NULL; \ No newline at end of file diff --git a/packages/db/drizzle/20260917034659_misty_stick/snapshot.json b/packages/db/drizzle/20260917034659_misty_stick/snapshot.json new file mode 100644 index 0000000..8faf1d4 --- /dev/null +++ b/packages/db/drizzle/20260917034659_misty_stick/snapshot.json @@ -0,0 +1,2057 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "97c1bdbb-4f41-4e07-bce8-a3098c96a9d1", + "prevIds": [ + "8088207f-23f3-4832-9bd8-10ad857d438e" + ], + "ddl": [ + { + "isRlsEnabled": false, + "name": "attachments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "author_achievements", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "author_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "author_slugs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "authors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_authors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_slugs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "github_installations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_images", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_attachments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_authors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_groups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "url_metadata", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "url_metadata_gist", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "url_metadata_gist_file", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "url_metadata_post", + "entityType": "tables", + "schema": "public" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attachment_key", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sha", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "width", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "height", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_modified", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_slug", + "entityType": "columns", + "schema": "public", + "table": "author_achievements" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "achievement_id", + "entityType": "columns", + "schema": "public", + "table": "author_achievements" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "granted_at", + "entityType": "columns", + "schema": "public", + "table": "author_achievements" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_slug", + "entityType": "columns", + "schema": "public", + "table": "author_roles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "author_roles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "author_slugs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "authors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "authors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "branch", + "entityType": "columns", + "schema": "public", + "table": "authors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "authors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "authors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profile_image", + "entityType": "columns", + "schema": "public", + "table": "authors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "authors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "authors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collection_id", + "entityType": "columns", + "schema": "public", + "table": "collection_authors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_slug", + "entityType": "columns", + "schema": "public", + "table": "collection_authors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "collection_slugs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collection_id", + "entityType": "columns", + "schema": "public", + "table": "collection_tags" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag", + "entityType": "columns", + "schema": "public", + "table": "collection_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "branch", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cover_image", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "social_image", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "github_installations" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "installation_id", + "entityType": "columns", + "schema": "public", + "table": "github_installations" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "post_images" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_key", + "entityType": "columns", + "schema": "public", + "table": "post_images" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "link_preview_key", + "entityType": "columns", + "schema": "public", + "table": "post_images" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "index_md5", + "entityType": "columns", + "schema": "public", + "table": "post_images" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fetched_at", + "entityType": "columns", + "schema": "public", + "table": "post_images" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "post_images" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "post_attachments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attachment_key", + "entityType": "columns", + "schema": "public", + "table": "post_attachments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attachment_name", + "entityType": "columns", + "schema": "public", + "table": "post_attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "post_authors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_slug", + "entityType": "columns", + "schema": "public", + "table": "post_authors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "post_groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "post_tags" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag", + "entityType": "columns", + "schema": "public", + "table": "post_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "branch", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collection_slug", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "collection_order", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "group_id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "version_name", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "version_order", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "word_count", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "social_image", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_image", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "original_link", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "noindex", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "edited_at", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon_key", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon_width", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon_height", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_key", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_width", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_height", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "gist_id", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "embed_src", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "embed_width", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "embed_height", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "embed_type", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fetched_at", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "url_metadata" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "gist_id", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_gist" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_gist" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_gist" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "gist_id", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_gist_file" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "filename", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_gist_file" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content_key", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_gist_file" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_gist_file" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_name", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_handle", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_key", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_width", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_height", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image_key", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image_width", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image_height", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image_alt_text", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "num_likes", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "num_reposts", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "num_replies", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "url_metadata_post" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "last_modified", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_last_modified_idx", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "schemaTo": "public", + "tableTo": "author_slugs", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "authors_slug_author_slugs_slug_fkey", + "entityType": "fks", + "schema": "public", + "table": "authors" + }, + { + "nameExplicit": false, + "columns": [ + "collection_id" + ], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_authors_collection_id_collections_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_authors" + }, + { + "nameExplicit": false, + "columns": [ + "author_slug" + ], + "schemaTo": "public", + "tableTo": "author_slugs", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_authors_author_slug_author_slugs_slug_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_authors" + }, + { + "nameExplicit": false, + "columns": [ + "collection_id" + ], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_tags_collection_id_collections_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_tags" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "schemaTo": "public", + "tableTo": "collection_slugs", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collections_slug_collection_slugs_slug_fkey", + "entityType": "fks", + "schema": "public", + "table": "collections" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_attachments_post_id_posts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "post_attachments" + }, + { + "nameExplicit": false, + "columns": [ + "attachment_key" + ], + "schemaTo": "public", + "tableTo": "attachments", + "columnsTo": [ + "attachment_key" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_attachments_attachment_key_attachments_attachment_key_fkey", + "entityType": "fks", + "schema": "public", + "table": "post_attachments" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_authors_post_id_posts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "post_authors" + }, + { + "nameExplicit": false, + "columns": [ + "author_slug" + ], + "schemaTo": "public", + "tableTo": "author_slugs", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_authors_author_slug_author_slugs_slug_fkey", + "entityType": "fks", + "schema": "public", + "table": "post_authors" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_tags_post_id_posts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "post_tags" + }, + { + "nameExplicit": false, + "columns": [ + "collection_slug" + ], + "schemaTo": "public", + "tableTo": "collection_slugs", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "posts_collection_slug_collection_slugs_slug_fkey", + "entityType": "fks", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + "group_id" + ], + "schemaTo": "public", + "tableTo": "post_groups", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "posts_group_id_post_groups_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + "gist_id" + ], + "schemaTo": "public", + "tableTo": "url_metadata_gist", + "columnsTo": [ + "gist_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "url_metadata_gist_id_url_metadata_gist_gist_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "url_metadata" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "url_metadata_post", + "columnsTo": [ + "post_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "url_metadata_post_id_url_metadata_post_post_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "url_metadata" + }, + { + "nameExplicit": false, + "columns": [ + "gist_id" + ], + "schemaTo": "public", + "tableTo": "url_metadata_gist", + "columnsTo": [ + "gist_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "url_metadata_gist_file_gist_id_url_metadata_gist_gist_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "url_metadata_gist_file" + }, + { + "columns": [ + "author_slug", + "achievement_id" + ], + "nameExplicit": false, + "name": "profile_achievements_pkey", + "entityType": "pks", + "schema": "public", + "table": "author_achievements" + }, + { + "columns": [ + "author_slug", + "role" + ], + "nameExplicit": false, + "name": "author_roles_pkey", + "entityType": "pks", + "schema": "public", + "table": "author_roles" + }, + { + "columns": [ + "collection_id", + "author_slug" + ], + "nameExplicit": false, + "name": "collection_authors_pkey", + "entityType": "pks", + "schema": "public", + "table": "collection_authors" + }, + { + "columns": [ + "collection_id", + "tag" + ], + "nameExplicit": false, + "name": "collection_tags_pkey", + "entityType": "pks", + "schema": "public", + "table": "collection_tags" + }, + { + "columns": [ + "post_id", + "attachment_key" + ], + "nameExplicit": false, + "name": "post_attachments_pkey", + "entityType": "pks", + "schema": "public", + "table": "post_attachments" + }, + { + "columns": [ + "post_id", + "author_slug" + ], + "nameExplicit": false, + "name": "post_authors_pkey", + "entityType": "pks", + "schema": "public", + "table": "post_authors" + }, + { + "columns": [ + "post_id", + "tag" + ], + "nameExplicit": false, + "name": "post_tags_pkey", + "entityType": "pks", + "schema": "public", + "table": "post_tags" + }, + { + "columns": [ + "gist_id", + "filename" + ], + "nameExplicit": false, + "name": "url_metadata_gist_file_pkey", + "entityType": "pks", + "schema": "public", + "table": "url_metadata_gist_file" + }, + { + "columns": [ + "attachment_key" + ], + "nameExplicit": false, + "name": "attachments_pkey", + "schema": "public", + "table": "attachments", + "entityType": "pks" + }, + { + "columns": [ + "slug" + ], + "nameExplicit": false, + "name": "author_slugs_pkey", + "schema": "public", + "table": "author_slugs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "authors_pkey", + "schema": "public", + "table": "authors", + "entityType": "pks" + }, + { + "columns": [ + "slug" + ], + "nameExplicit": false, + "name": "collection_slugs_pkey", + "schema": "public", + "table": "collection_slugs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "collections_pkey", + "schema": "public", + "table": "collections", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "github_installations_pkey", + "schema": "public", + "table": "github_installations", + "entityType": "pks" + }, + { + "columns": [ + "slug" + ], + "nameExplicit": false, + "name": "post_images_pkey", + "schema": "public", + "table": "post_images", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "post_groups_pkey", + "schema": "public", + "table": "post_groups", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "posts_pkey", + "schema": "public", + "table": "posts", + "entityType": "pks" + }, + { + "columns": [ + "url" + ], + "nameExplicit": false, + "name": "url_metadata_pkey", + "schema": "public", + "table": "url_metadata", + "entityType": "pks" + }, + { + "columns": [ + "gist_id" + ], + "nameExplicit": false, + "name": "url_metadata_gist_pkey", + "schema": "public", + "table": "url_metadata_gist", + "entityType": "pks" + }, + { + "columns": [ + "post_id" + ], + "nameExplicit": false, + "name": "url_metadata_post_pkey", + "schema": "public", + "table": "url_metadata_post", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "slug", + "branch" + ], + "nullsNotDistinct": false, + "name": "authors_slug_branch_unique", + "entityType": "uniques", + "schema": "public", + "table": "authors" + }, + { + "nameExplicit": false, + "columns": [ + "slug", + "locale", + "branch" + ], + "nullsNotDistinct": false, + "name": "collections_slug_locale_branch_unique", + "entityType": "uniques", + "schema": "public", + "table": "collections" + }, + { + "nameExplicit": false, + "columns": [ + "slug", + "locale", + "branch" + ], + "nullsNotDistinct": false, + "name": "posts_slug_locale_branch_unique", + "entityType": "uniques", + "schema": "public", + "table": "posts" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/db/src/relations.ts b/packages/db/src/relations.ts index 462e1be..a2e4dfa 100644 --- a/packages/db/src/relations.ts +++ b/packages/db/src/relations.ts @@ -5,23 +5,29 @@ export const relations = defineRelations(schema, (r) => ({ // Collections relations collections: { authors: r.many.authors({ - from: r.collections.slug.through(r.collectionAuthors.collectionSlug), + from: r.collections.id.through(r.collectionAuthors.collectionId), to: r.authors.slug.through(r.collectionAuthors.authorSlug), }), - posts: r.many.posts(), - data: r.many.collectionData({ + posts: r.many.posts({ from: r.collections.slug, - to: r.collectionData.slug, + to: r.posts.collectionSlug, + }), + }, + + collectionSlugs: { + data: r.many.collections({ + from: r.collectionSlugs.slug, + to: r.collections.slug, }), }, // Collection authors junction collectionAuthors: { collection: r.one.collections({ - from: r.collectionAuthors.collectionSlug, - to: r.collections.slug, + from: r.collectionAuthors.collectionId, + to: r.collections.id, }), - author: r.one.authors({ + author: r.many.authors({ from: r.collectionAuthors.authorSlug, to: r.authors.slug, }), @@ -33,7 +39,7 @@ export const relations = defineRelations(schema, (r) => ({ from: r.posts.id.through(r.postAuthors.postId), to: r.authors.slug.through(r.postAuthors.authorSlug), }), - collection: r.one.collections({ + collections: r.many.collections({ from: r.posts.collectionSlug, to: r.collections.slug, }), @@ -47,18 +53,6 @@ export const relations = defineRelations(schema, (r) => ({ }), }, - // Posts authors junction - postAuthors: { - post: r.one.posts({ - from: r.postAuthors.postId, - to: r.posts.id, - }), - author: r.one.authors({ - from: r.postAuthors.authorSlug, - to: r.authors.slug, - }), - }, - // Authors relations authors: { postsAuthored: r.many.posts({ @@ -67,7 +61,7 @@ export const relations = defineRelations(schema, (r) => ({ }), collectionsAuthored: r.many.collections({ from: r.authors.slug.through(r.collectionAuthors.authorSlug), - to: r.collections.slug.through(r.collectionAuthors.collectionSlug), + to: r.collections.id.through(r.collectionAuthors.collectionId), }), achievements: r.many.authorAchievements({ from: r.authors.slug, diff --git a/packages/db/src/schema/authors.ts b/packages/db/src/schema/authors.ts index 4a9dc6f..0c320f8 100644 --- a/packages/db/src/schema/authors.ts +++ b/packages/db/src/schema/authors.ts @@ -4,25 +4,37 @@ import { jsonb, text, primaryKey, + uuid, + unique, } from "drizzle-orm/pg-core"; -export const authors = pgTable("authors", { +export const authorSlugs = pgTable("author_slugs", { slug: text("slug").primaryKey(), - name: text("name").notNull(), - description: text("description").notNull().default(""), - profileImage: text("profile_image"), - publishedAt: timestamp("published_at", { withTimezone: true }) - .notNull() - .$default(() => new Date()), - meta: jsonb("meta").notNull(), }); +export const authors = pgTable( + "authors", + { + id: uuid("id").primaryKey().defaultRandom(), + slug: text("slug") + .notNull() + .references(() => authorSlugs.slug, { onDelete: "cascade" }), + branch: text("branch").notNull(), + name: text("name").notNull(), + description: text("description").notNull().default(""), + profileImage: text("profile_image"), + publishedAt: timestamp("published_at", { withTimezone: true }) + .notNull() + .$default(() => new Date()), + meta: jsonb("meta").notNull(), + }, + (table) => [unique().on(table.slug, table.branch)], +); + export const authorAchievements = pgTable( "author_achievements", { - authorSlug: text("author_slug") - .notNull() - .references(() => authors.slug, { onDelete: "cascade" }), + authorSlug: text("author_slug").notNull(), achievementId: text("achievement_id").notNull(), grantedAt: timestamp("granted_at", { withTimezone: true }) .notNull() @@ -34,9 +46,7 @@ export const authorAchievements = pgTable( export const authorRoles = pgTable( "author_roles", { - authorSlug: text("author_slug") - .notNull() - .references(() => authors.slug, { onDelete: "cascade" }), + authorSlug: text("author_slug").notNull(), role: text("role").notNull(), }, (table) => [primaryKey({ columns: [table.authorSlug, table.role] })], diff --git a/packages/db/src/schema/collections.ts b/packages/db/src/schema/collections.ts index f65ea82..81e555c 100644 --- a/packages/db/src/schema/collections.ts +++ b/packages/db/src/schema/collections.ts @@ -4,20 +4,24 @@ import { timestamp, jsonb, primaryKey, + uuid, + unique, } from "drizzle-orm/pg-core"; -import { authors } from "./authors.ts"; +import { authorSlugs } from "./authors.ts"; -export const collections = pgTable("collections", { +export const collectionSlugs = pgTable("collection_slugs", { slug: text("slug").primaryKey(), }); -export const collectionData = pgTable( - "collection_data", +export const collections = pgTable( + "collections", { + id: uuid("id").primaryKey().defaultRandom(), slug: text("slug") .notNull() - .references(() => collections.slug, { onDelete: "cascade" }), + .references(() => collectionSlugs.slug, { onDelete: "cascade" }), locale: text("locale").notNull(), + branch: text("branch").notNull(), title: text("title").notNull(), description: text("description").notNull().default(""), publishedAt: timestamp("published_at", { withTimezone: true }), @@ -25,22 +29,22 @@ export const collectionData = pgTable( coverImage: text("cover_image"), socialImage: text("social_image"), }, - (table) => [primaryKey({ columns: [table.slug, table.locale] })], + (table) => [unique().on(table.slug, table.locale, table.branch)], ); export const collectionAuthors = pgTable( "collection_authors", { - collectionSlug: text("collection_slug") + collectionId: uuid("collection_id") .notNull() - .references(() => collections.slug, { onDelete: "cascade" }), + .references(() => collections.id, { onDelete: "cascade" }), authorSlug: text("author_slug") .notNull() - .references(() => authors.slug, { onDelete: "cascade" }), + .references(() => authorSlugs.slug, { onDelete: "cascade" }), }, (table) => [ primaryKey({ - columns: [table.collectionSlug, table.authorSlug], + columns: [table.collectionId, table.authorSlug], }), ], ); @@ -48,14 +52,14 @@ export const collectionAuthors = pgTable( export const collectionTags = pgTable( "collection_tags", { - collectionSlug: text("collection_slug") + collectionId: uuid("collection_id") .notNull() - .references(() => collections.slug, { onDelete: "cascade" }), + .references(() => collections.id, { onDelete: "cascade" }), tag: text("tag").notNull(), }, (table) => [ primaryKey({ - columns: [table.collectionSlug, table.tag], + columns: [table.collectionId, table.tag], }), ], ); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 7d58a25..c70aca3 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,7 +1,7 @@ +export * from "./attachments.ts"; export * from "./authors.ts"; export * from "./collections.ts"; export * from "./github-installations.ts"; -export * from "./posts.ts"; export * from "./post-images.ts"; +export * from "./posts.ts"; export * from "./url-metadata.ts"; -export * from "./attachments.ts"; diff --git a/packages/db/src/schema/posts.ts b/packages/db/src/schema/posts.ts index 4392f6e..33d6191 100644 --- a/packages/db/src/schema/posts.ts +++ b/packages/db/src/schema/posts.ts @@ -9,8 +9,8 @@ import { uuid, unique, } from "drizzle-orm/pg-core"; -import { authors } from "./authors.ts"; -import { collections } from "./collections.ts"; +import { authorSlugs } from "./authors.ts"; +import { collectionSlugs } from "./collections.ts"; import { attachments } from "./attachments.ts"; export const postGroups = pgTable("post_groups", { @@ -24,9 +24,12 @@ export const posts = pgTable( slug: text("slug").notNull(), locale: text("locale").notNull(), branch: text("branch").notNull(), - collectionSlug: text("collection_slug").references(() => collections.slug, { - onDelete: "set null", - }), + collectionSlug: text("collection_slug").references( + () => collectionSlugs.slug, + { + onDelete: "set null", + }, + ), collectionOrder: integer("collection_order").notNull().default(0), groupId: uuid("group_id").references(() => postGroups.id, { onDelete: "cascade", @@ -57,7 +60,7 @@ export const postAuthors = pgTable( }), authorSlug: text("author_slug") .notNull() - .references(() => authors.slug, { + .references(() => authorSlugs.slug, { onDelete: "cascade", }), }, diff --git a/packages/test-fixtures/src/db-mock.ts b/packages/test-fixtures/src/db-mock.ts index 13b8038..4a603a8 100644 --- a/packages/test-fixtures/src/db-mock.ts +++ b/packages/test-fixtures/src/db-mock.ts @@ -1,54 +1,80 @@ import { vi } from "vitest"; +const tableName = Symbol(); + export function createDbMock() { const insertMap = new Map(); - const insertMockResponse = () => { - const returning = vi.fn(); - const onConflictDoNothing = vi.fn(() => ({ returning })); - const onConflictDoUpdate = vi.fn(() => ({ returning })); + const insertMockResponse = (name: string) => { + const returning = vi.fn().mockName(`insert(${name}).returning`); + const onConflictDoNothing = vi + .fn(() => ({ returning })) + .mockName(`insert(${name}).onConflictDoNothing`); + const onConflictDoUpdate = vi + .fn(() => ({ returning })) + .mockName(`insert(${name}).onConflictDoUpdate`); return { - values: vi.fn(() => ({ - returning, - onConflictDoNothing, - onConflictDoUpdate, - })), + values: vi + .fn(() => ({ + returning, + onConflictDoNothing, + onConflictDoUpdate, + })) + .mockName(`insert(${name}).values`), }; }; const deleteMap = new Map(); - const deleteMockResponse = () => { - const returning = vi.fn(); - return { where: vi.fn(() => ({ returning })) }; + const deleteMockResponse = (name: string) => { + const returning = vi.fn().mockName(`delete(${name}).returning`); + return { + where: vi.fn(() => ({ returning })).mockName(`delete(${name}).where`), + }; }; const selectMap = new Map(); - const selectMockResponse = () => { - const limit = vi.fn(); - const where = vi.fn(() => ({ limit })); - const innerJoin = vi.fn(() => ({ where, innerJoin })); + const selectMockResponse = (name: string) => { + const limit = vi.fn().mockName(`select().from(${name}).limit`); + const where = vi + .fn(() => ({ limit })) + .mockName(`select().from(${name}).where`); + const innerJoin = vi + .fn(() => ({ where, innerJoin })) + .mockName(`select().from(${name}).innerJoin`); return { innerJoin, where, limit }; }; const db = { - insert: vi.fn((arg) => { - return ( - insertMap.get(arg) ?? insertMap.set(arg, insertMockResponse()).get(arg) - ); - }), - delete: vi.fn((arg) => { - return ( - deleteMap.get(arg) ?? deleteMap.set(arg, deleteMockResponse()).get(arg) - ); - }), - select: vi.fn(() => ({ - from: vi.fn((arg) => { + insert: vi + .fn((arg) => { return ( - selectMap.get(arg) ?? - selectMap.set(arg, selectMockResponse()).get(arg) + insertMap.get(arg) ?? + insertMap.set(arg, insertMockResponse(arg[tableName])).get(arg) ); - }), - })), - transaction: vi.fn((cb: (tx: unknown) => unknown) => cb(db)), + }) + .mockName("insert"), + delete: vi + .fn((arg) => { + return ( + deleteMap.get(arg) ?? + deleteMap.set(arg, deleteMockResponse(arg[tableName])).get(arg) + ); + }) + .mockName("delete"), + select: vi + .fn(() => ({ + from: vi + .fn((arg) => { + return ( + selectMap.get(arg) ?? + selectMap.set(arg, selectMockResponse(arg[tableName])).get(arg) + ); + }) + .mockName("select().from"), + })) + .mockName("select"), + transaction: vi + .fn((cb: (tx: unknown) => unknown) => cb(db)) + .mockName("transaction"), query: { postImages: { findFirst: vi.fn(), @@ -72,9 +98,14 @@ export function createDbMock() { }, }; - return { + const tables = { + authorSlugs: { + slug: Symbol("authorSlugs.slug"), + }, authors: { - slug: {}, + id: Symbol("authors.id"), + slug: Symbol("authors.slug"), + branch: Symbol("authors.branch"), name: {}, description: {}, profileImage: {}, @@ -111,12 +142,14 @@ export function createDbMock() { publishedAt: {}, meta: {}, }, - collections: { - slug: {}, + collectionSlugs: { + slug: Symbol("collectionSlugs.slug"), }, - collectionData: { - slug: {}, - locale: {}, + collections: { + id: Symbol("collections.id"), + slug: Symbol("collections.slug"), + locale: Symbol("collections.locale"), + branch: Symbol("collections.branch"), }, collectionAuthors: { collectionSlug: {}, @@ -156,4 +189,10 @@ export function createDbMock() { urlMetadataGistFile: {}, db, }; + + for (const [key, value] of Object.entries(tables)) { + (value as Record)[tableName as never] = key; + } + + return tables; }